欢迎光临
我们一直在努力

照片说话双引擎实战:OmniHuman + 可灵 Kling 接入踩坑全记录

🔥 本文是「一个人用AI做工具矩阵」专栏第16篇。我的独立项目智播坊(AI口播视频生成平台)已开源,欢迎 Star 👉 Gitee 仓库 | 在线体验

一、为什么要做双引擎

做智播坊 v1.4 版本的时候,我先把火山引擎 OmniHuman 接进来做了"照片说话"功能——用户上传一张照片,输入一段文案,系统自动合成 TTS 音频,再驱动照片里的人"说话",生成口播视频。

上线一周后,用户反馈了两个问题:

1. 多人照片容易识别错说话人。合影、团队照里有多张人脸时,OmniHuman 有时会把嘴巴动到"错误"的那个人脸上。虽然主体检测(遮罩)功能能缓解,但不稳定。

2. 价格偏高。OmniHuman 按次计费,小用户觉得每次生成几毛钱不便宜。有用户私下说"就想试试效果,太贵了"。

于是我决定接入可灵 Kling 作为第二引擎。可灵的数字人 API 价格更友好,接入难度也低不少。两个引擎共用同一套 TTS、COS 存储和字幕系统,但在鉴权、计费、API 调用层面完全独立,用户可以自由选择。

这篇文章把双引擎接入的技术细节和踩过的坑都讲清楚,代码全部用 TypeScript,和智播坊项目技术栈一致。

二、双引擎架构总览

先画个文字版架构图,有个整体印象:

                    ┌──────────────┐                     │   前端 Vue3   │                     │  用户选择引擎  │                     └──────┬───────┘                            │               ┌────────────┴────────────┐               ▼                         ▼    ┌──────────────────┐     ┌──────────────────┐    │ lip-sync.service │     │ lip-sync-kling   │    │   (OmniHuman)    │     │   .service.ts    │    │                  │     │    (可灵Kling)    │    │  SigV4签名鉴权    │     │  Bearer Token    │    │  主体识别+遮罩    │     │  音频base64编码   │    │  三步异步流程     │     │  单步提交         │    └────────┬─────────┘     └────────┬─────────┘             │                        │    ┌────────┴────────────────────────┴─────────┐    │            共用基础设施层                    │    │  TTS(火山方舟) │ COS存储 │ videos表 │ ASS字幕 │    └───────────────────────────────────────────┘  

核心思路:两条链路完全独立,但共享底层能力。不共用的是鉴权方式、积分计费逻辑、API 调用方式;共用的是 TTS 音频合成、腾讯云 COS 文件存储、字幕系统和视频记录表。

三、引擎一:火山 OmniHuman 接入

3.1 核心难点:SigV4 签名

OmniHuman 走的是火山引擎视觉服务的 API,鉴权方式叫 SigV4(HMAC-SHA256 链式签名),跟 AWS 的签名算法类似。每次请求都需要用 Secret Key 经过 4 轮 HMAC 派生出签名密钥,再对请求体签名。

这是我接入时最头疼的部分——签名错了就是 403,而且火山不会告诉你哪一步签错了。

// typescript
function buildSignedHeaders(body: string, action: string): Record<string, string> {
  const now = new Date()
  const amzdate = now.toISOString().replace(/[:-]|\\.\\d{3}/g, '')  // YYYYMMDDTHHMMSSZ
  const datestamp = amzdate.substring(0, 8)
  const payloadHash = sha256Hex(body)

  // 1. 构造规范请求(Canonical Request)
  const canonicalHeaders = `host:${host}\\nx-content-sha256:${payloadHash}\\nx-date:${amzdate}\\n`
  const signedHeaders = 'host;x-content-sha256;x-date'
  const canonicalRequest =
    `POST\\n/\\nAction=${action}&Version=${VERSION}\\n${canonicalHeaders}\\n${signedHeaders}\\n${payloadHash}`

  // 2. 构造待签字符串
  const credentialScope = `${datestamp}/${region}/${service}/request`
  const stringToSign = `HMAC-SHA256\\n${amzdate}\\n${credentialScope}\\n${sha256Hex(canonicalRequest)}`

  // 3. 四轮 HMAC 派生签名密钥
  const kDate = hmac(secretAccessKey, datestamp)
  const kRegion = hmac(kDate, region)
  const kService = hmac(kRegion, service)
  const kSigning = hmac(kService, 'request')
  const signature = hmac(kSigning, stringToSign).toString('hex')

  // 4. 拼接 Authorization Header
  return {
    Host: host,
    'X-Date': amzdate,
    'X-Content-Sha256': payloadHash,
    Authorization: `HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
    'Content-Type': 'application/json'
  }
}

踩坑提醒:amzdate 的格式化很关键,要把 ISO 字符串里的冒号、横杠和毫秒全去掉。我第一次写的时候漏了毫秒处理,签名一直对不上,调了大半天。

3.2 三步异步流程

OmniHuman 不是简单的一次请求就能搞定的,它需要三步:

1. 主体识别(检测照片里有没有人脸)

2. 主体检测(获取人物遮罩,标记谁在说话)

3. 提交口型任务(照片 + 音频 → 生成视频)

每一步都是异步的——先提交拿 task_id,再轮询查结果:

// 步骤1:主体识别
const detectTaskId = await submitSubjectDetection(imageUrl)
const subjectResult = await querySubjectDetection(detectTaskId)
if (!subjectResult.hasSubject) {
  throw new Error('未检测到人脸,请更换照片')
}

// 步骤2:主体检测(获取遮罩)
const maskTaskId = await submitSubjectMask(imageUrl)
const result = await querySubjectMask(maskTaskId)
const maskUrls = result.maskUrls  // 遮罩 URL 列表

// 步骤3:提交口型任务
const volcanoTaskId = await submitLipSyncTask({
  imageUrl, audioUrl, prompt, maskUrls
})

3.3 火山返回格式的坑

这里必须单独说:火山引擎的 API 成功判断不是 HTTP 200,也不是 code: 0,而是 **code: 10000** 才算成功!

async function callVolcApi(action: string, bodyObj: Record<string, any>): Promise<any> {
  const resp = await axios.post(url, body, { headers, timeout })
  const data = resp.data
  // ⚠️ 火山引擎成功码为 10000,不是 0 也不是 200
  if (data.code !== 10000) {
    throw new Error(`火山引擎 ${action} 失败: code=${data.code}, message=${data.message}`)
  }
  return data.data
}

我第一次对接的时候按惯例判断 code === 0,结果所有请求都抛异常,还以为签名有问题……

四、引擎二:可灵 Kling 接入

4.1 鉴权简单很多

对比 OmniHuman 的 SigV4 签名,可灵的鉴权就是简单的 Bearer Token:

function createKlingClient(): AxiosInstance {
  return axios.create({
    baseURL: config.kling.baseUrl,
    timeout: config.kling.timeout,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${config.kling.apiKey}`,
    },
  })
}

一个 axios 实例就搞定了。没有签名、没有密钥派生,5 分钟就通了。

4.2 音频需要 base64 编码

可灵和 OmniHuman 的一个核心差异:OmniHuman 接收音频 URL,可灵需要 base64 编码的音频数据。

这意味着我不能直接把 COS 上的音频 URL 传过去,得先下载到服务端内存,再转 base64:

// 可灵提交任务时的音频处理
if (params.audioUrl.startsWith('http://') || params.audioUrl.startsWith('https://')) {
  console.log('[Kling] 下载音频文件:', params.audioUrl.slice(0, 80))
  const audioResp = await axios.get(params.audioUrl, {
    responseType: 'arraybuffer',
    timeout: 60000,
  })
  const audioBuffer = Buffer.from(audioResp.data)
  body.sound_file = audioBuffer.toString('base64')
}

这个设计意味着:可灵引擎每次提交任务,服务端都要多一次音频下载。对于长音频(比如 30 秒以上的文案),这个 Buffer 会比较大。目前智播坊的文案一般在 10-20 秒,暂时没问题。

4.3 返回格式不固定

这是可灵最让我头疼的地方:**task_id 在不同场景下可能出现在不同的字段路径**。

function extractTaskId(data: any): string {
  if (typeof data === 'string') return data
  if (data?.task_id) return data.task_id              // 格式1
  if (data?.data?.task_id) return data.data.task_id    // 格式2
  if (data?.data?.task?.task_id) return data.data.task.task_id  // 格式3
  if (data?.data?.id) return data.data.id              // 格式4
  if (data?.id) return data.id                         // 格式5

  throw new Error(`无法从可灵响应中提取 task_id: ${JSON.stringify(data).slice(0, 300)}`)
}

同样的逻辑,extractTaskStatusextractVideoUrl 也需要做多格式兼容。我怀疑是可灵后端不同版本或不同接口的返回结构不一致导致的。没办法,只能写防御性代码,把可能的路径都覆盖到。

五、打字机字幕系统(ASS 卡拉OK效果)

两个引擎共用同一套字幕生成逻辑,这是智播坊照片说话的一个特色功能:字幕不是简单地出现在底部,而是像打字机一样逐字高亮——已经读过的字是正常颜色,正在读的字逐个亮起,还没读到的字是灰色。

5.1 核心原理

利用 ASS 字幕格式的 \\k(卡拉OK)标签。每个字符前加一个 \\k{时长} 标签,播放器就会按时间逐个"点亮"字符。

export async function generateTypewriterASS(
  segments: { text: string; start: number; duration: number }[],
  fontSize: number,
  videoId?: number,
  fontColor?: string
): Promise<string> {
  for (const seg of segments) {
    const chars = Array.from(seg.text)  // 按码点拆分
    const centisPerChar = Math.max(1, Math.round((seg.duration * 100) / chars.length))

    // 每个字符前加 \\k 标签,实现逐字高亮
    const karaoke = chars.map((ch) => `{\\\\k${centisPerChar}}${escapeForASS(ch)}`).join('')

    const start = formatASSTime(seg.start)
    const end = formatASSTime(seg.start + seg.duration)
    lines.push(`Dialogue: 0,${start},${end},Default,,0,0,0,,${karaoke}`)
  }
  // … 写入 .ass 文件
}

配合 ASS 样式中的 PrimaryColour(已读字颜色)和 SecondaryColour(未读字灰色),\\k 标签会在播放时自动切换颜色,产生打字机效果。

5.2 长句语义拆分

实际使用中发现一个问题:TTS 返回的分段数据,有些句子特别长(超过 20 个字),烧录到视频上直接超出屏幕。

解决方案是在生成 ASS 之前,对超过 18 字的段落做语义拆分:

function splitLongSegments(
  segments: { text: string; start: number; duration: number }[],
  maxChars: number = 18
): { text: string; start: number; duration: number }[] {
  const PUNCTUATION = new Set([',', '。', '!', '?', ';', ':', '、'])

  for (const seg of segments) {
    const chars = Array.from(seg.text)
    if (chars.length <= maxChars) {
      result.push(seg)
      continue
    }

    // 优先在标点符号处拆分
    let splitIndex = -1
    for (let i = Math.min(maxChars, chars.length – 1); i >= Math.max(Math.floor(maxChars * 0.6), 1); i–) {
      if (PUNCTUATION.has(chars[i])) {
        splitIndex = i + 1
        break
      }
    }

    // 没有标点,就在 maxChars 处硬切
    if (splitIndex === -1) splitIndex = maxChars

    // 按字符比例分配时长
    const firstRatio = splitIndex / chars.length
    result.push({ text: chars.slice(0, splitIndex).join(''), start: seg.start, duration: seg.duration * firstRatio })
    result.push({ text: chars.slice(splitIndex).join(''), start: seg.start + firstDuration, duration: seg.duration * (1 – firstRatio) })
  }
  return result
}

优先在逗号、句号等标点处断开,保持语义完整性。拆分后每段的时长按字符数比例分配,确保字幕和语音同步。

5.3 跨平台字体探测

字幕需要中文字体,但 Linux 服务器、macOS 开发机、Windows 用户各自字体不同。写了一个探测函数,按优先级查找:

function getChineseFontPath(): string {
  const platform = os.platform()

  // 优先使用项目内打包的字体(最可靠)
  const projectFont = path.join(process.cwd(), 'src', 'assets', 'fonts', 'HiraginoSansGB.ttc')
  if (fs.existsSync(projectFont)) return projectFont

  if (platform === 'darwin') {
    return '/System/Library/Fonts/Hiragino Sans GB.ttc'
  } else if (platform === 'win32') {
    return 'C:\\\\Windows\\\\Fonts\\\\msyh.ttc'
  } else {
    // Linux 服务器:依次尝试文泉驿、Noto CJK、Droid Sans
    const linuxFonts = [
      '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
      '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',
      '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
      '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',
    ]
    for (const font of linuxFonts) {
      if (fs.existsSync(font)) return font
    }
  }
}

建议直接在项目里打包一份字体文件,这样不管部署到哪台服务器都不会出现字幕乱码。

六、独立积分体系(照片说话专用)

智播坊有两条产品线:通用视频创作(模板视频、混剪等)和照片说话,各自有独立的积分体系。

照片说话的计费逻辑是按实际视频时长——每 0.5 秒消耗 1 积分。一个 10 秒的视频就是 20 积分,30 秒就是 60 积分。

6.1 预扣→结算→释放的三态生命周期

用户点击生成     │     ▼ 预估音频时长 → 计算积分 → 预扣(hold)     │     ├── 生成成功 → ffprobe 获取实际视频时长 → 重新计算积分 → 结算     │     └── 生成失败 → 释放预扣积分  

为什么这么设计?因为用户可能输入一段很长的文案(对应很长的音频),如果直接按文案长度扣费,用户生成失败了积分就白扣了。先预扣,成功后按实际生成的视频时长多退少补。

6.2 按实际时长结算

function settleCredits(
  creditRow: { user_id?: number; credit_hold_id?: number } | undefined,
  videoDuration: number
) {
  if (!creditRow?.credit_hold_id || !creditRow?.user_id) return
  const actualCredits = videoDuration > 0 ? Math.ceil(videoDuration / 0.5) : 0

  if (actualCredits > 0) {
    // 先释放原来的预估预扣
    releaseHold(creditRow.credit_hold_id)
    // 再按实际时长重新预扣并立即确认
    const newHoldId = holdCredits(creditRow.user_id, actualCredits)
    confirmHold(newHoldId)
    console.log(`[KlingLipSync] 积分结算完成:实际 ${actualCredits} 积分`)
  } else {
    // 无法获取时长,直接确认原预扣
    confirmHold(creditRow.credit_hold_id)
  }
}

失败时释放预扣:

// typescript
function releaseCredits(creditRow: { user_id?: number; credit_hold_id?: number } | undefined) {
  if (!creditRow?.credit_hold_id) return
  releaseHold(creditRow.credit_hold_id)
  console.log(`[KlingLipSync] 生成失败,已释放预扣积分`)
}

这样即使用户生成了一个超长视频,系统也不会被"薅羊毛"——积分是按实际消耗来算的。

七、字幕时间轴重映射(可灵引擎独有)

这是可灵引擎独有的一个问题:可灵生成的视频时长和 TTS 音频时长可能不一致。

比如 TTS 音频是 12.3 秒,但可灵生成的视频是 13.8 秒。如果字幕时间轴不调整,字幕就会和视频不同步——前面可能还行,到最后几秒字幕已经结束了,视频还在播。

解决方案:用 ffprobe 获取视频实际时长,计算与音频时长的比率,然后按比例缩放所有字幕段的 startduration

// queryAndPersistKling 中的时间轴重映射逻辑
const videoDuration = getVideoDuration(rawVideoPath)  // ffprobe 获取视频时长

// 从 segments 推算音频总时长
const audioDuration = rawSegments.length > 0
  ? rawSegments[rawSegments.length – 1].start + rawSegments[rawSegments.length – 1].duration
  : 0

let remappedSegments = rawSegments

if (videoDuration > 0 && audioDuration > 0) {
  const ratio = videoDuration / audioDuration
  const diffPct = Math.abs(1 – ratio)

  console.log(`视频: ${videoDuration.toFixed(2)}s, 音频: ${audioDuration.toFixed(2)}s, 比率: ${ratio.toFixed(4)}`)

  if (diffPct > 0.001) {  // 差异超过 0.1% 才重映射
    remappedSegments = rawSegments.map(seg => ({
      text: seg.text,
      start: Math.round(seg.start * ratio * 100) / 100,
      duration: Math.round(seg.duration * ratio * 100) / 100
    }))
    console.log(`segments 时间轴已按比率 ${ratio.toFixed(4)} 重映射`)
  }
}

OmniHuman 引擎不需要这个处理,因为它生成的视频时长和音频基本一致。但可灵偶尔会"加戏",多出 1-2 秒的画面,所以需要这层时间轴校正。

八、订单系统(LS 前缀)

照片说话有独立的订单系统,订单号以 LS(Lip Sync)为前缀,和通用视频创作的订单区分开。采用管理员手动确认模式:

export function createOrder(userId: number, packageId: number) {
  // 生成订单号:LS + 年月日时分秒 + 4位随机十六进制
  const orderNo = `LS${ts}${rand}`  // 例如: LS20260703143025A1B2

  // 创建 pending 状态订单,7天有效期
  db.prepare(`INSERT INTO lip_sync_orders (order_no, user_id, …) VALUES (?, ?, …)`).run(orderNo, userId, …)

  return { order, paymentQr: config.business.adminPaymentQrUrl }
}

用户扫码付款后,我在后台点"确认",积分就到了。虽然不够自动化,但对于独立开发者来说,初期这种模式足够用,也避免了接入支付 SDK 的复杂度。

九、双引擎对比与选型建议

维度

OmniHuman(火山)

可灵 Kling

鉴权复杂度

⭐⭐⭐⭐⭐ SigV4 签名

⭐ Bearer Token

音频传入方式

URL 直传

base64 编码

主体检测

支持(可指定说话人)

不支持

多人照片

遮罩可选人

默认第一个人脸

价格

偏高

相对便宜

生成速度

1-5 分钟

2-5 分钟

时间轴精度

高(与音频同步好)

偶有偏差(需重映射)

给用户的选择建议:

• 质量优先、多人合影 → 选 OmniHuman,主体检测更精准

• 成本敏感、单人照片 → 选可灵,性价比高

• 快速测试、批量生成 → 选可灵,接口更简单稳定

十、总结与下一步

双引擎架构的核心收益是给用户选择权。技术上,两套服务完全独立,互不影响,共用基础设施但不互相依赖。一个引擎挂了,另一个还能正常用。

下一步计划:

1. 接入更多引擎(比如阿里的 EMOTEUR、MuseTalk 等开源方案)

2. 支持多人照片的自动说话人选择

3. 增加视频模板和背景替换功能

🔗 相关链接

• 智播坊开源仓库:https://gitee.com/zhang-dongtao/zhibofang

• 在线体验:https://zhibofang.zhishujuzhen.com/

• CSDN 专栏:「一个人用AI做工具矩阵」

💬 互动

你们做 AI 视频项目用的是哪个引擎?OmniHuman、可灵、还是其他方案?遇到什么坑欢迎评论区交流~

如果这个项目对你有帮助,欢迎给智播坊点个 Star ⭐,你的支持是我持续开发的动力!

内容由 AI 辅助整理,核心代码和项目逻辑均来自智播坊真实开发实践。

赞(0)
未经允许不得转载:171主机测评 » 照片说话双引擎实战:OmniHuman + 可灵 Kling 接入踩坑全记录
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址