数字人口播的完整链路已能生成对口型视频原片,但从音频出发反向约束画面节奏、再分层合成包装成可发布的成片,这一环涉及三个工程难点,都不是生成问题,而是一致性与路径确定性问题。
TTS 时长驱动的反向工作流
MiMo TTS 客户端同步调用返回 base64 音频与实际音长(单位秒),这个时长是后续所有操作的源头。关键约束在于时长由上游决定,不能人为指定。
拿一个具体例子:用户输入 60 字的口播文案,送给 MiMo preset 模式(冰糖音色、wav 格式)。MiMo 返回的不是"这段话应该播 30 秒",而是"我合成出来的音频实际是 28.5 秒"。TTS 生成的时长由语速、音色、模型版本等因素共同决定,变量太多了。
在这个约束下,整个包装流程必须反向适配:
- TTS 合成音频 → 得到
audioDurationSec(来自 ffprobe 或 MiMo 返回) - 飞天对口型 → 输入
audioDurationSec,输出"这个长度的人物口播视频"(飞天返回duration) - 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 → completed 或 failed:
- 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 自动回归、成片质量审核都很有意义。还有灵活性:配音、数字人、包装模板可以独立迭代,不互相阻塞。
线上部署的最后两步
发版后的两个关键项,缺一项都会导致灰度用户无法下单:
- 后台配价:
resourceKey=video_render_sec的单价必须填(单位元/秒),且勾选 enabled。没配的话 chargeResource 会返回 404,用户点开始出片就直接报错。 - 灰度名单:
VIDEO_RENDER_HTML_USER_IDS环境变量决定了谁能看到模板卡。留空 = 全员走旧 ffmpeg 链路(可用于紧急回滚),指定用户 ID = 该用户进入新链路。发版初期应只填 1-2 个测试账号。
这两个配置是代码之外的硬依赖,容易遗漏。烟测清单里放在最前面,作为"不做后面全走不通"的前置项。
整个方案的核心原则是分层隔离与路径确定性:TTS 决定时长、飞天决定人像、HyperFrames 决定包装,各层独立演进,成片路径从输入到输出一条流水线,无分支、无条件、无随机。这对一个产生可发布物料的流水线来说,是底线。
■