内容流水线

配音时长不可控——那就让画面跟着它走

数字人口播成片的最后一环:配音时长不可控,如何反向驱动画面?原片如何作为独立图层无损合成?成片路径如何保证确定性?

数字人口播的完整链路已能生成对口型视频原片,但从音频出发反向约束画面节奏、再分层合成包装成可发布的成片,这一环涉及三个工程难点,都不是生成问题,而是一致性与路径确定性问题。

TTS 时长驱动的反向工作流

MiMo TTS 客户端同步调用返回 base64 音频与实际音长(单位秒),这个时长是后续所有操作的源头。关键约束在于时长由上游决定,不能人为指定

拿一个具体例子:用户输入 60 字的口播文案,送给 MiMo preset 模式(冰糖音色、wav 格式)。MiMo 返回的不是"这段话应该播 30 秒",而是"我合成出来的音频实际是 28.5 秒"。TTS 生成的时长由语速、音色、模型版本等因素共同决定,变量太多了。

在这个约束下,整个包装流程必须反向适配:

  1. TTS 合成音频 → 得到 audioDurationSec(来自 ffprobe 或 MiMo 返回)
  2. 飞天对口型 → 输入 audioDurationSec,输出"这个长度的人物口播视频"(飞天返回 duration
  3. HyperFrames 包装 → 模板中的时间线也以这个 duration 为基准构建字幕、转场、片头片尾的时间点

如果反过来做——先定好画面框架是 60 秒,再想办法让音频往里塞——就陷入了"剪音频、变速播放、或者音视不同步"的泥潭。

实现上,MiMo 音频落地后先上传我方 S3,用 ffprobe 测长作为权威值(probeVideoDurationSec 对音频也适用),这个测值再被传给飞天 create_by_audio 接口。飞天不是按输入时长生成固定时长的视频,而是真正根据音轨长度对口型——返回的 duration 几乎就等于输入的音频时长(考虑 AAC 编码器的 priming delay,实测偏差 0.64 帧即 21.33ms,可忽略)。

数字人作为独立图层而非重新生成

一个常见的工程误区:既然最终成片是"数字人 + 包装",能不能让 HyperFrames 直接去生成数字人?不能。飞天数字人是外部供应商接口,响应慢(异步任务,等待 2-5 分钟常态),接口也不开放生成能力给外部工具(只提供对口型 API)。再者,用户可能想用同一份配音试多个数字人形象、或试多个包装模板,重新生成整条视频的成本太高。

设计决策是把飞天原片(已完成对口型的 MP4 文件)当作 HyperFrames composition 里的一个 <video> 图层。模板里声明这样的结构:

<div data-track-index="0">
  <video data-start="0s" data-duration="$MAIN_VIDEO_DURATION" 
          data-has-audio="true" 
          src="$MAIN_VIDEO_URL">
  </video>
</div>

<div data-track-index="1">
  <!-- 字幕、花字、角标等包装层 -->
</div>

<div data-track-index="2">
  <!-- 片头片尾、转场 -->
</div>

data-has-audio="true" 这一行是命门——没有它,渲染器会给 <video> 默认加 muted,成片就没声音。

这样做的好处显而易见:

  • 数字人原片一旦生成就不动,支持单独重做配音(只需重新调用 TTS 和飞天,不影响包装渲染)
  • 同一个配音可套多个模板,不需要重新对口型
  • 包装层的 HTML/CSS 改动不会触发数字人重生成,迭代快

缺点是需要镜像内内置 Chromium 和 FFmpeg(官方渲染镜像实测 3.72GB),与 API 镜像分离部署。但这换来的是确定性输出和可控的环境——生产机、开发机、CI 跑同一个 composition,出片应该帧级一致(除了字体、系统库这类版本差异导致的微调,都锁版本了)。

成片路径的幂等性与状态机

用户选定模板、确认方案后,系统提交一个 VideoRenderJob:输入模板 ID、原片 key、字幕 cues、音频 URL 等,输出成片 objectKey。同一份输入如果重试或重新提交,必须得到同样的输出(或者快速失败)。

状态流转是 queued → preparing → rendering → uploading → completedfailed

  • queued:任务入队,等待 worker 消费。这一步是防并发上限的信号量检查——飞天有并发限制(错误码 1001),Redis 信号量 yunclaude:dub:sky:sem 兜底(触顶不直接失败,改为入队等待)。
  • preparing:下载原片、组装模板(注入数据、渲染占位符)。这一步的幂等性来自 objectKey 的内容寻址——同一个原片 key、同一个模板版本,组装出的 composition.html 比特级相同。
  • rendering:Chromium 逐帧捕获、FFmpeg 合成。这是确定性的关键——环境锁定(Node 24、Chromium 版本、FFmpeg 版本、字体版本都在镜像里写死),同一个 composition 渲染多次输出帧级一致。M1 POC 中用 seek 点验证:t=4.5s→第 135 帧t=30.0s→第 900 帧(读原片内置计数器,nb_frames=1800),长时间点零漂移。
  • uploading:上传 OSS,记录 objectKey。返回给前端时用现签(每次读时重新签,不存短时 URL)。
  • failed:任何一步异常,立即 rollback。计费侧已扣的视频点全额退款(resource operationId 幂等)。

失败的兜底是 reaper(BullMQ 的死信队列处理),轮询 status=running 超期(>10 分钟)的任务,标记为失败并补退款。

音画同步与字幕对齐

成片里字幕何时出现、何时消失,这些时间点由分析步生成的 subtitleCues 定义(格式 WebVTT)。一个 cue 的结构是 start → end + 文本,例如:

00:05.000 --> 00:08.500
这是一段口播文案

HyperFrames 的字幕图层根据这些 cues 生成动画:start 时刻淡入,end 时刻淡出。整个成片的时间线参考都来自主视频(<video> 元素),而主视频的时长就是飞天返回的 duration——由音频长度决定。

一个细节:AAC-LC 编码器有 priming delay(约 1024 samples@48kHz = 21.33ms),所以成片里音频起点和视频起点存在一个已知的 21ms 偏移。但这是编码器层的常数,不是渲染问题,接受即可。

关键词高亮(比如把卖点词着色为黄色)需要在分析时做标记,例如 HTML 标签:<span class="highlight">关键词</span>,然后 CSS 定义颜色。模板编写规约里明确禁止动画 letterSpacing 等布局属性(会在逐帧捕获时 snap 到整数像素产生抖动),只允许 transform(x/y/scale/opacity)。

成片路径的端到端烟测

发版前的验收分六个阶段:

A. 发版前置(缺一项线上就炸)

  • 后台已配渲染单价(resourceKey video_render_sec),且 enabled 勾选
  • 灰度名单已配置(环境变量 VIDEO_RENDER_HTML_USER_IDS
  • 发版机 release.sh 已改(支持第 6 个镜像 yc-video-render
  • K8s 集群配额已提升(requestQuota 新增 2C/2Gi、limitsQuota 新增 4C/4Gi)
  • 构建机磁盘充足(≥10GB 空闲)

B. 发版后基础设施(不通过立即 rollout undo)

  • 迁移已执行(VideoRenderJob 表已建)
  • 渲染 worker 已起(READY 1/1,镜像 tag 与本次发版一致)
  • 容器内 hyperframes CLI 可用(hyperframes --version 返回 0.7.70)
  • 容器内中文字体已装(fc-list :lang=zh 非空)
  • worker 连上 Redis 队列(日志无 crash loop)

C. 回归防线:未放量用户零感知(最高优先级)

  • 不在灰度名单的用户进增强步看不到"包装模板"卡
  • 旧链路(ffmpeg)完整出片,字幕/转场/BGM 都在
  • 旧链路仍生成 AI 插片,计费时间线出现 seedance 扣费
  • 未放量时 VideoRenderJob 表没有新行

这段最重要是因为旧链路服务着所有现存数字人用户。一个新功能开关不应该波及灰度外的用户。

D. 灰度用户正向流程(核心价值)

  • 模板列表可见:增强步看到"不加包装"+"美食探店"两张卡
  • 选模板后出片最终 completed,可播放
  • 成片有口播声音(这是 data-has-audio 命门检验)
  • 片头/片尾/角标都在:0-3s 品牌片头、右上角全程角标、最后 3s CTA
  • 中文不乱码:片头标题与字幕汉字正常
  • 字幕关键词高亮:关键词黄色,其余白色,标签没被打碎
  • 进度实时可见:出片过程中进度条推进,不死在一个数字
  • 成片链接可下载且长期有效:15 分钟后刷新页面仍能播放
  • 渲染时长符合预期:60s 成片≤5 分钟(M1 POC 实测 39.5s)

E. 资金正确性(看后台账单,不能只看页面)

  • 扣的是视频点不是算力点(计费时间线条目 title 首段是 video
  • 不再扣 seedance 插片钱(灰度用户的时间线里没有插片扣费)
  • 结算按实际秒数(units ≈ 成片时长整秒向上取整)
  • 余额不足时不产生任务、不扣费
  • 重复提交不重复扣费(operationId 幂等)

F. 异常路径

  • 渲染失败会退款:任务置 failed,计费时间线出现退款条目
  • 失败同步反映到增强任务:增强任务也变 failed,前端不会永远停在"渲染中"
  • 排队中可取消并全额退:返回成功,退款到账
  • 已在渲染中不可取消:返回 409,不退款(CPU 已烧)
  • worker 重启不丢任务:任务被重新捞起或置失败退款
  • 超时判失败:>10 分钟置 failed 并退款

每一条都写了具体的检查命令和判据。比如验证成片有声音,就是直接播放、耳朵听;验证字幕关键词高亮,就是肉眼看颜色;验证退款,就是对比出片前后的计费时间线。

设计的权衡

这套方案的成本是什么?

首先是镜像大小:官方渲染镜像装了 280 多个 apt 包、node_modules、Chromium 和 FFmpeg,构建出的镜像实测 3.72GB,单独占用一个 K8s node pool,构建时间约 10 分钟,推拉镜像耗时显著上升。但这是"要么装进 API 镜像肥到 6GB+、拖累三个部署,要么单独镜像"的取舍——选了单独。

其次是模板编写规约的学习成本。一个看起来"普通的网页动画"可能在逐帧 seek 渲染时炸:GSAP 退场动画必须挂在 clip 内层并补 hard kill,禁止 letterSpacing 等布局属性,中文字体必须显式 @font-face(容器内用 Noto Sans CJK)。hyperframes check 作为模板上架的强制 gate,能一次性拦截这类问题。

获得的是什么?確定性输出。同一个模板、同一份配音,渲染 100 次得到 100 个帧级一致的成片(前提是输入稳定,不变数字人形象、不变字体库版本)。这对 CI 自动回归、成片质量审核都很有意义。还有灵活性:配音、数字人、包装模板可以独立迭代,不互相阻塞。

线上部署的最后两步

发版后的两个关键项,缺一项都会导致灰度用户无法下单:

  1. 后台配价resourceKey=video_render_sec 的单价必须填(单位元/秒),且勾选 enabled。没配的话 chargeResource 会返回 404,用户点开始出片就直接报错。
  2. 灰度名单VIDEO_RENDER_HTML_USER_IDS 环境变量决定了谁能看到模板卡。留空 = 全员走旧 ffmpeg 链路(可用于紧急回滚),指定用户 ID = 该用户进入新链路。发版初期应只填 1-2 个测试账号。

这两个配置是代码之外的硬依赖,容易遗漏。烟测清单里放在最前面,作为"不做后面全走不通"的前置项。

整个方案的核心原则是分层隔离与路径确定性:TTS 决定时长、飞天决定人像、HyperFrames 决定包装,各层独立演进,成片路径从输入到输出一条流水线,无分支、无条件、无随机。这对一个产生可发布物料的流水线来说,是底线。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。