AI视频创作Agent实战05:Wan场景融合与VideoRetalk口型驱动
上传一张人物照片,再让这个人物出现在商家实拍场景里讲解,涉及的工作比"照片开口说话"更多:身体要进入现场,人物和背景的比例要协调,原镜头中的人不能被误配口型,分段之后还要保持音画时长与镜头运动连续。
当前视频创作 Agent 已接入 Wan 场景编辑与 VideoRetalk 口型处理,保留了一条 720×1280、10.28 秒的可播放成片。最近又加入同源镜头合并和图片场景缓慢运镜,但这批最新变化还没有新的真实样片完成验证。本文按现有代码解释链路,也把已实现、自动测试覆盖和仍需看片的部分分清楚。示例场景均为虚构,不涉及实际人物照片或口播内容。

1. 当前链路与源码入口
新任务的默认路线已经变为场景编辑,历史 LivePortrait 任务仍兼容恢复。对应源码如下,路径均相对项目根目录。
| 文件 | 入口或关键字段 | 职责 |
|---|---|---|
components/portrait-settings.tsx |
人物接口设置 | 保存人物模型服务地址与密钥 |
components/video-creation-wizard.tsx |
人物与声音步骤 | 选择人物、声音方案和场景偏好 |
shared/portrait-scene.mjs |
portraitSceneSettings |
冻结新任务的场景方案 |
server/workflow.mjs |
prepareScene |
将人物编辑接入完整制作流程 |
server/scene-integration.mjs |
integratePortraitVideo、runSceneTask |
Wan、VideoRetalk、分块拼接与请求恢复 |
server/lip-reference.mjs |
selectLipReference |
在编辑结果中定位目标人物 |
scripts/select-lip-reference.py |
本地人脸处理脚本 | 从场景选取口型参考图 |
server/portrait-fidelity.mjs |
checkPortraitFidelity |
原照片检查及生成视频检查开关 |
server/scene-chunks.mjs |
sceneIntegrationChunks |
同源合组、精确帧分块、方案冻结 |
server/scene-camera.mjs |
sceneCameraFilter |
对完整人物场景施加缓慢推进 |
server/media.mjs |
render |
最终原配音、字幕、配乐和编码校验 |
docs/portrait-matting.md |
人物流程说明 | 设计与历史抠像实现说明 |
tests/scene-integration.test.mjs、tests/scene-camera.test.mjs |
接口与媒体测试 | 请求契约、恢复、整场景合成和连续运镜 |
| 环境或模型项 | 项目当前约定 |
|---|---|
| Node.js | >=22.13.0,ESM,内置 node:test |
| 场景编辑模型 | wan3.0-video |
| 口型处理模型 | videoretalk |
| 服务配置 | 使用项目保存的阿里云北京地域人物接口配置 |
| 本地人脸环境 | 隔离 Python 环境,NumPy 2.2.6,OpenCV Headless 4.12.0.88 |
| 检测与特征模型 | YuNet、SFace,安装后校验模型文件 |
| 历史 RVM 路线 | ONNX Runtime 1.23.2;仅历史任务恢复使用 |
| 时间基准 | 最终片段按 25fps 帧数对齐 |
| 媒体工具 | FFmpeg、FFprobe;具体安装包由项目安装脚本校验 |
这些是项目适配器及安装脚本的约定,并不代表服务商长期不变的能力清单。模型开通状态、远端限制和费用要以实际配置与请求结果为准。本文不需要在代码片段中放任何账号或接口密钥。
2. 从照片到说话场景,拆成可以恢复的阶段
shared/portrait-scene.mjs 的真实新任务配置如下:
javascript
export function portraitSceneSettings(avatar) {
return {
version: 3,
fidelityVersion: 1,
renderer: 'scene-edit',
integration: avatar.integration || 'insert',
placement: avatar.placement || 'auto',
height: avatar.height ?? 0.65,
subtitles: avatar.subtitles ?? false,
};
}
新任务的实际主链如下。图中的目标定位仍然执行,生成视频前后的人物一致性检查目前关闭,二者不能合成一个"保真检查"节点:
#mermaid-svg-QhN5qyL3bOEGyucU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QhN5qyL3bOEGyucU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QhN5qyL3bOEGyucU .error-icon{fill:#552222;}#mermaid-svg-QhN5qyL3bOEGyucU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QhN5qyL3bOEGyucU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QhN5qyL3bOEGyucU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QhN5qyL3bOEGyucU .marker.cross{stroke:#333333;}#mermaid-svg-QhN5qyL3bOEGyucU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QhN5qyL3bOEGyucU p{margin:0;}#mermaid-svg-QhN5qyL3bOEGyucU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QhN5qyL3bOEGyucU .cluster-label text{fill:#333;}#mermaid-svg-QhN5qyL3bOEGyucU .cluster-label span{color:#333;}#mermaid-svg-QhN5qyL3bOEGyucU .cluster-label span p{background-color:transparent;}#mermaid-svg-QhN5qyL3bOEGyucU .label text,#mermaid-svg-QhN5qyL3bOEGyucU span{fill:#333;color:#333;}#mermaid-svg-QhN5qyL3bOEGyucU .node rect,#mermaid-svg-QhN5qyL3bOEGyucU .node circle,#mermaid-svg-QhN5qyL3bOEGyucU .node ellipse,#mermaid-svg-QhN5qyL3bOEGyucU .node polygon,#mermaid-svg-QhN5qyL3bOEGyucU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QhN5qyL3bOEGyucU .rough-node .label text,#mermaid-svg-QhN5qyL3bOEGyucU .node .label text,#mermaid-svg-QhN5qyL3bOEGyucU .image-shape .label,#mermaid-svg-QhN5qyL3bOEGyucU .icon-shape .label{text-anchor:middle;}#mermaid-svg-QhN5qyL3bOEGyucU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QhN5qyL3bOEGyucU .rough-node .label,#mermaid-svg-QhN5qyL3bOEGyucU .node .label,#mermaid-svg-QhN5qyL3bOEGyucU .image-shape .label,#mermaid-svg-QhN5qyL3bOEGyucU .icon-shape .label{text-align:center;}#mermaid-svg-QhN5qyL3bOEGyucU .node.clickable{cursor:pointer;}#mermaid-svg-QhN5qyL3bOEGyucU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QhN5qyL3bOEGyucU .arrowheadPath{fill:#333333;}#mermaid-svg-QhN5qyL3bOEGyucU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QhN5qyL3bOEGyucU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QhN5qyL3bOEGyucU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QhN5qyL3bOEGyucU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QhN5qyL3bOEGyucU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QhN5qyL3bOEGyucU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QhN5qyL3bOEGyucU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QhN5qyL3bOEGyucU .cluster text{fill:#333;}#mermaid-svg-QhN5qyL3bOEGyucU .cluster span{color:#333;}#mermaid-svg-QhN5qyL3bOEGyucU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QhN5qyL3bOEGyucU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QhN5qyL3bOEGyucU rect.text{fill:none;stroke-width:0;}#mermaid-svg-QhN5qyL3bOEGyucU .icon-shape,#mermaid-svg-QhN5qyL3bOEGyucU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QhN5qyL3bOEGyucU .icon-shape p,#mermaid-svg-QhN5qyL3bOEGyucU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QhN5qyL3bOEGyucU .icon-shape .label rect,#mermaid-svg-QhN5qyL3bOEGyucU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QhN5qyL3bOEGyucU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QhN5qyL3bOEGyucU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QhN5qyL3bOEGyucU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
检查原照片,准备原场景与 TTS
校验输入,同源合组后按最多 12 秒分块
Wan 静默编辑人物场景
找到目标人物的清晰正脸?
保留场景结果,停止口型请求
VideoRetalk:目标参考图与原 TTS
按帧截取,新图片块施加完整场景运镜
拼接场景,配回原 TTS 和可选字幕
媒体校验后生成作品,等待人工看片
insert 表示在原场景中新增照片人物并尽量保留原有人物;replace 表示替换主要出镜人物。假设虚构场景是一间咖啡教室,新增讲解者与原讲师同时出现时,口型处理必须找对新增的那个人,否则即使视频生成成功,也会出现"画面里的另一个人替你讲话"。
声音方案支持按外观选择合成声线风格和人工覆盖,但它不是从照片恢复人物真实声音。语音合成阶段将文案作为 TTS 正文,声音风格指令单独传入接口,不会被拼到要朗读的正文里。
3. Wan 先生成静默场景,原配音只交给口型阶段
新方案保留 fidelityVersion:1,在当前实现中会选择无损 PNG 参考图、1080P 静默编辑。server/scene-integration.mjs 的真实请求节选如下:
javascript
files: [
{ type: 'reference_video', file: source },
{ type: 'reference_image', file: photo },
...(fidelity ? [] : [{ type: 'reference_audio', file: voice }]),
],
javascript
parameters: {
resolution: fidelity ? '1080P' : '720P',
ratio: 'adaptive',
duration: padded,
audio: !fidelity,
prompt_extend: false,
},
fidelity 分支会要求目标人物自然闭口、不露齿、减少大幅转头和手势,同时尽量保留参考照片的脸部比例、皮肤纹理、发型和眼镜。这些是模型输入要求,不能写成已经由程序锁死的视觉约束。
阿里云 Wan 3.0 官方 API 参考 列出了视频编辑使用的 reference_video 与 reference_image 组合,以及 audio:false 表示输出不带音轨的含义。本文引用这些参数语义;接口地址和地域仍需结合实际接入配置核对。
两阶段的职责由此变清楚:Wan 处理人物、身体、现场光照和空间关系,VideoRetalk 处理指定人的说话口型。最终仍用原始 TTS 音轨,不采纳模型自行生成的台词。WAN 请求使用 1080P 也不等于最终视频必定为 1080P,后续会按目标画布尺寸完成合成;当前已有样片就是 720×1280。
提交 Wan 场景编辑前,还会检查总时长小于 180 秒、镜头时长总和与输入时长一致、画面每边处在项目允许的 640--2048 像素范围,以及参考图和媒体文件大小。失败在本地说明原因,避免等场景请求提交后才发现明显不符合输入条件;这些检查并不意味着此前的 TTS 尚未发生。
4. 找到目标人物,与检查人物始终相似,是两种能力
编辑后的目标人物不一定与输入照片处在同样的构图和光照下。项目先生成标准化场景视频,再由 selectLipReference 结合原照片、编辑结果和原镜头定位目标人脸。定位成功后,将选出的图片传给 VideoRetalk,而不是总把原照片直接作为口型目标。
真实请求结构如下:
javascript
const lips = await sceneTask({
config,
model: SCENE_LIPS_MODEL,
files: [
{ type: 'video_url', file: normalized },
{ type: 'audio_url', file: voice },
{ type: 'ref_image_url', file: lipReference },
],
parameters: { video_extension: false, query_face_threshold: 170 },
这是对象开头的源码节选,后面的输出路径、任务与取消信号参数仍由完整函数传入。video_extension:false 关闭这一路模型视频扩展。短片段补齐时使用末帧延展,不倒放视频来凑长度。
阿里云 VideoRetalk 官方 API 参考 说明 ref_image_url 用于多人物画面中的口型目标选择,参考图应包含视频里出现的清晰正脸;开启 video_extension 则可能采用正放与倒放交替扩展。因此项目先从编辑结果选参考脸,并让输入音视频等长,避免依赖该扩展行为。
如果本地没有找到目标人物,server/lip-reference.mjs 会抛出 SCENE_TARGET_NOT_INSERTED,保留场景视频并停止口型请求。工程上应当先检查"人是否真的进入场景、脸是否清晰",而不是反复请求口型接口期待它自动修复场景插入问题。
这里有一个必须按代码说明的当前状态:**生成场景及口型处理后的人物一致性自动检查暂时关闭,原始照片检查和目标人物定位仍保留。**真实开关为:
javascript
export const GENERATED_PORTRAIT_FIDELITY_ENABLED = false;
检查函数当前对两个生成视频阶段执行:
javascript
if (
!GENERATED_PORTRAIT_FIDELITY_ENABLED &&
['scene', 'lips'].includes(options.stage)
)
return { version: FIDELITY_VERSION, disabled: true };
因此 fidelityVersion:1 仍决定静默生成与照片参考方案,却不代表输出视频已通过身份检查。代码中的 requiresVisualReview:true 也只是标记需要人工看片。说明文档仍描述场景前后检查流程,理解当前行为应以这个运行开关为准。
即使以后恢复检查,YuNet、SFace 和稀疏抽帧规则也无法完整衡量牙齿、嘴唇动作、下颌轮廓和表情自然度。定位"该给谁配口型",与评价"整个视频里这个人是否一直自然相似",需要分开记录结果。
5. 同源合组后分块,保留精确帧数
模型输入时长、音频长度和最终输出时长不是完全相同的概念。项目先把每段映射到 25fps 帧数,再将最多 12 秒一块的请求片段补齐到 3--12 整秒,音视频使用相同补齐长度,回收结果后裁回需要的帧数。这里的 12 秒是本项目分块策略,不是对 Wan 所有模式通用最大时长的声明。
最近加入的合组也不是只比较文件名。scene-chunks.mjs 会比较素材类型、素材 ID、片段 ID、主体位置、场景裁切范围、增强状态和人物站位。视频还要求原始取用区间连续;同一个视频从头重复播放,不会被误认为连续镜头。
图片块会保存跨块的运镜进度,真实返回结构节选如下:
javascript
return {
sceneIndex,
index:
sceneIndex === lastSceneIndex
? `${sceneIndex}-${part}`
: `merged-${sceneIndex}-${lastSceneIndex}-${part}`,
start: scene.timelineStart + from / FPS,
duration: (to - from) / FPS,
...(merge && scene.kind === 'image'
? { camera: { version: 1, fromFrame: from, totalFrames: frames } }
: {}),
};
例如虚构的连续图片场景有 14 秒、共 350 帧,可以分成两个 175 帧片段。第二块的推进进度从第 175 帧继续,而不是重新从初始构图启动。这里 14 秒只是解释分块机制的示例,不是已有真实样片的时长。
方案会在上传或付费请求前保存。历史任务已经有请求记录时,保留原分段方式和步骤名称;已保存的老分组也不会在恢复时突然获得新运镜。这不仅避免画面变化,还确保旧请求能够找到原来的云任务和本地缓存。
6. 运镜放在口型后,让人物与背景一起移动
新图片场景的提示词要求仅对原环境里适合活动的元素增加细微变化,例如已有叶片或光影;墙体、货架、桌椅保持稳定,也不凭空增加新人物。该要求需要云模型实际生成,当前不能用一个本地滤镜测试证明它已经达到自然拍摄效果。
可确定执行的是后处理运镜。server/scene-camera.mjs 使用:
javascript
const last = Math.max(1, camera.totalFrames - 1);
const progress = `min(1,(on+${camera.fromFrame})/${last})`;
// A small crop (at most 2% per edge) avoids dramatic face/scene movement.
// Supersampling keeps integer zoompan crop coordinates from visibly stepping.
return `scale=iw*2:ih*2,zoompan=z='1+0.04*${progress}':x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2':d=1:s=${width}x${height}:fps=25`;
放大倍率最多从 1 到 1.04,即缓慢推进约 4%。先做两倍采样,是为了减轻整数裁切坐标跳动。滤镜作用在 VideoRetalk 输出的完整场景上,因此人物和背景共同移动,避免分别移动两层造成贴纸感。
这里 on 表示输出帧计数,d 控制每个输入图像对应的输出帧数,定义见 FFmpeg 官方 zoompan 文档。项目额外加上 fromFrame,让第二块继续使用整段的进度,而不是重新从零计算。
该运镜当前针对新的图片场景,既有视频保留其运动,历史任务重试不增加这个变化。合成每块时通过 -frames:v 裁到 Math.round(chunk.duration * 25),拼接后再次检查总时长误差不超过一帧加小容差。它确保时间轴可计算,不保证视觉上没有人脸变化或云模型接缝。
7. 云请求失败、口型失败和本地失败分别处理
两阶段各自保存输入哈希、模型、步骤、任务编号和本地文件校验信息。同一步骤如果已处于 sending 或 unknown,新提交会被拦住。查询失败或下载链接失效可继续查询原任务;已经明确失败后重新生成则可能产生新费用。
| 故障示例 | 项目怎样处理 | 排查重点 |
|---|---|---|
| 新增人物没有出现 | 目标定位失败,停止口型请求 | 检查编辑视频、照片清晰度和构图空间 |
口型返回 InvalidFile.FaceNotMatch |
保留旧回执,按分块冻结规则处理参考版本 | 区分失败块与已成功块,不重做整个批次 |
| 提交超时,不知道是否接单 | 标记未知,阻止自动改提示词重发 | 核对已有任务结果 |
| 视频下载失败 | 复用任务 ID,重新查询或下载 | 不将下载失败当成模型生成失败 |
| 最终 FFmpeg 合成失败 | 已保存云结果继续保留 | 检查音画长度、尺寸和本地输出 |
| 文档说有检查,但页面仍要求看片 | 生成视频检查开关关闭 | 看运行配置与返回值,不推测已通过检查 |
历史 LivePortrait 路线仍在 workflow.mjs 中通过条件分支存在:先生成说话视频;保存了旧版 portraitScene 配置的任务再由 RVM 抠像,用透明前景叠到素材上,更早没有该配置的任务则保留画中画合成。两种旧路线都没有场景编辑模型提供的身体重建、空间遮挡和光照融合能力。新场景编辑路线直接使用编辑后的完整画面,不能在成果说明里把这些历史路径混为一谈。
8. 自动测试、真实样片与验收标准
在项目依赖、FFmpeg 和必要本地模型准备完成后,可从项目根目录运行:
powershell
node --test --test-concurrency=2 tests/scene-integration.test.mjs tests/scene-chunks.test.mjs tests/scene-camera.test.mjs tests/portrait-fidelity.test.mjs
node --test --test-concurrency=2 tests/*.test.mjs
npx tsc --noEmit
截至 2026 年 9 月 8 日,全套 292 项测试及 TypeScript 检查通过。接口测试使用模拟响应,验证请求字段、未知提交保护、并发防重与结果复用;它们不会证明云模型认识某张人物照片。
媒体测试实际使用 FFmpeg。例如场景合成测试用测试色块替代云端编辑视频,确认最终确实使用完整编辑场景和原配音;运镜测试生成网格背景,比较完整渲染与分成两块渲染的每帧摘要,断言拼起来的帧序列完全一致,并验证首尾构图发生变化。测试明确禁止模型调用,因此它证明后处理运动及分块连续性,不证明真实背景动态和人物逼真程度。
只复现运镜的本地媒体断言时,可以运行下面的定向命令。它选择已有测试,不启动新的人物云生成任务:
powershell
node --test --test-name-pattern="camera transforms remain continuous" tests/scene-camera.test.mjs
该测试断言 350 帧完整输出与两段各 175 帧输出的拼接完全一致,同时首尾帧摘要不同。前者验证跨块不中断推进,后者验证实际发生了画面变化;两者都不评价人脸质量。
独立离线验收:让分块错误在第 175 帧暴露
只有"测试通过"四个字,仍然难以判断测试能否发现真正的回归。下面保留一个能输出视频和诊断报告的最小验收:正常路径比较连续渲染与分块渲染,负面对照则故意把第二块的进度归零。验收必须发现这个错误,才能说明它确实检查了跨块连续性。
将下面代码保存为项目根目录下的 work/verify-scene-media.mjs,从项目根目录运行 node work/verify-scene-media.mjs。脚本直接导入项目的分块、运镜和媒体工具函数;FFmpeg 路径沿用项目配置或 AGENT_FFMPEG_PATH、AGENT_FFPROBE_PATH 环境变量。输入全部是合成网格和 440Hz 测试音,不读取人物照片,不调用云模型,也不修改项目任务记录。每次运行创建新的 work/scene-offline-* 目录,保留检查报告和示例 MP4。
javascript
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const root = path.resolve(process.argv[2] || '.');
const base = path.resolve(process.argv[3] || 'work');
const source = (file) => import(pathToFileURL(path.join(root, 'server', file)));
const { sceneIntegrationChunks } = await source('scene-chunks.mjs');
const { sceneCameraFilter } = await source('scene-camera.mjs');
const { Media, execute } = await source('media.mjs');
globalThis.fetch = () => { throw new Error('Offline verification: network forbidden'); };
const media = new Media(path.join(root, '.agent-data'));
assert.ok(await media.ready(), 'FFmpeg and ffprobe must be available');
await fs.mkdir(base, { recursive: true });
const folder = await fs.mkdtemp(path.join(base, 'scene-offline-'));
const run = (args) => media.run(args, { cwd: folder });
const grid = 'color=0x243829:s=320x240:r=25,drawgrid=w=40:h=30:t=2:c=white';
const job = {};
let saves = 0;
const chunks = sceneIntegrationChunks({
scenes: [0, 7].map((start) => ({
kind: 'image', assetId: 'synthetic-grid', segmentId: 'grid-0',
start, end: 14, duration: 7, timelineStart: start,
})),
settings: { placement: 'auto' }, job, step: 'offline',
store: { save() { saves += 1; } },
});
assert.equal(saves, 1, 'plan must be saved before rendering');
assert.deepEqual(chunks.map((c) => Math.round(c.duration * 25)), [175, 175]);
assert.ok(chunks.every((c) => c.duration <= 12 && c.index.startsWith('merged-')));
assert.deepEqual(chunks.map((c) => c.camera.fromFrame), [0, 175]);
const filter = (camera) => sceneCameraFilter(camera, 320, 240);
const parseHashes = ({ stdout }) => stdout.toString().split(/\r?\n/)
.filter((line) => line && !line.startsWith('#'))
.map((line) => line.split(',').at(-1).trim());
const gridHashes = async (camera, frames) => parseHashes(await run([
'-f', 'lavfi', '-i', grid, '-vf', filter(camera), '-frames:v', String(frames),
'-pix_fmt', 'yuv420p', '-f', 'framemd5', 'pipe:1',
]));
const full = await gridHashes({ version: 1, fromFrame: 0, totalFrames: 350 }, 350);
const partHashes = [];
for (const [i, chunk] of chunks.entries()) {
await run(['-f', 'lavfi', '-i', grid, '-vf', filter(chunk.camera),
'-frames:v', String(Math.round(chunk.duration * 25)),
'-pix_fmt', 'yuv420p', '-c:v', 'ffv1', `part-${i}.mkv`]);
partHashes.push(parseHashes(await run(['-i', `part-${i}.mkv`,
'-map', '0:v:0', '-pix_fmt', 'yuv420p', '-f', 'framemd5', 'pipe:1'])));
}
assert.equal(full.length, 350);
assert.deepEqual(partHashes.flat(), full, 'chunking must preserve every decoded frame');
assert.notEqual(full[0], full.at(-1), 'camera must actually move');
// Negative control: deliberately restart the second chunk at frame zero.
const reset = await gridHashes({ ...chunks[1].camera, fromFrame: 0 }, 175);
const broken = [...partHashes[0], ...reset];
const firstMismatch = full.findIndex((hash, i) => hash !== broken[i]);
assert.equal(firstMismatch, 175, 'the check must detect a boundary reset');
await fs.writeFile(path.join(folder, 'concat.txt'), "file 'part-0.mkv'\nfile 'part-1.mkv'\n");
await run(['-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=24000:duration=14',
'-c:a', 'pcm_s16le', 'narration-test.wav']);
await run(['-f', 'concat', '-safe', '1', '-i', 'concat.txt',
'-i', 'narration-test.wav', '-map', '0:v:0', '-map', '1:a:0',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '96k', '-t', '14', '-movflags', '+faststart', 'fixture.mp4']);
const probe = JSON.parse((await execute(media.paths.ffprobe, [
'-v', 'error', '-count_frames', '-show_streams', '-show_format',
'-of', 'json', path.join(folder, 'fixture.mp4'),
])).stdout.toString());
const video = probe.streams.find((s) => s.codec_type === 'video');
const audio = probe.streams.find((s) => s.codec_type === 'audio');
assert.equal(probe.streams.length, 2);
assert.equal(video?.codec_name, 'h264');
assert.equal(audio?.codec_name, 'aac');
assert.equal(Number(video.nb_read_frames), 350);
assert.equal(video.avg_frame_rate, '25/1');
assert.deepEqual([video.width, video.height], [320, 240]);
for (const stream of [video, audio]) {
assert.ok(Math.abs(Number(stream.duration) - 14) <= 1 / 25 + 0.005);
}
const report = {
chunkFrames: chunks.map((c) => Math.round(c.duration * 25)),
chunkMaxSeconds: Math.max(...chunks.map((c) => c.duration)),
frameHashesEqual: true, uniqueFrames: new Set(full).size,
resetFirstMismatchFrame: firstMismatch,
video: { codec: video.codec_name, frames: Number(video.nb_read_frames),
size: [video.width, video.height], duration: Number(video.duration) },
audio: { codec: audio.codec_name, sampleRate: Number(audio.sample_rate),
duration: Number(audio.duration) },
fixture: 'fixture.mp4',
};
await fs.writeFile(path.join(folder, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
console.log(`Artifacts: ${folder}`);
2026 年 9 月 8 日,我在 Node.js 24.14.1、FFmpeg/ffprobe 9.0.1 环境实际运行了这份脚本,得到下面的结果。这里的 14 秒视频是本节新生成的离线网格,不是前文的人物样片。
| 检查项 | 本次实际结果 | 它能发现什么 |
|---|---|---|
| 同源合组与分块 | 两块各 175 帧、各 7 秒 | 合组失效、超过项目 12 秒上限、帧数丢失 |
| 无损解码帧比较 | 350 帧逐帧摘要完全相同 | 第二块错误地重置或跳过运镜进度 |
| 运动确实发生 | 共 38 个不同帧摘要,首尾不同 | 滤镜未执行、全段停留在第一帧 |
| 注入"第二块从零开始"错误 | 首次差异出现在零基第 175 帧,即 7 秒边界 | 确认连续性断言能检出人为制造的回归 |
| 最终 MP4 视频流 | H.264,320×240,25fps,350 帧,14 秒 | 编码、尺寸、帧率或总帧数异常 |
| 最终 MP4 音频流 | AAC,24000Hz,14 秒 | 漏映射音轨、音频长度与视频明显不符 |
分块文件使用 FFV1 无损编码,是为了让逐帧摘要比较有明确含义。最终 H.264/AAC 文件另做流信息与时长检查;不能拿有损编码后的字节摘要,要求它与原始图像或 PCM 音频完全一样。代码中的 -map 0:v:0 -map 1:a:0 明确选择拼接视频与指定测试音;音轨存在且长度正确仍不能证明任意实际口播语义正确,更不代表口型已经对齐。
另一个容易误读的结果是"38 个不同帧":本实现放大幅度小,裁切坐标经过整数计算,连续帧可能相同。这里仅断言发生了画面变化和跨块进度一致,不要求每一帧的构图都不同,也不据此评价肉眼是否平滑。fromFrame 归零对照则必须在边界失败,不能把正常脚本的通过输出当成唯一证据。
这份验收绕开了云端等待,适合修改分块或运镜后先确认本地媒体链路。它没有生成任何人物,不能覆盖 Wan 的背景动态、VideoRetalk 的嘴部自然度或真实生成视频的接缝;这些仍由下面的真实样片检查负责。
现有 10.28 秒样片已经探测为 H.264 视频与 AAC 音频,尺寸 720×1280。这能作为已有主链曾输出可播放成片的证据。最近合组和运镜修改发生在该样片完成之后,尚需用新任务验证,不能把旧片当作新效果验收。
下一次真实样片应按以下项目逐项记录:
- 原照片只有明确目标人脸,细节足够,生成视频中的目标从头到尾可辨认。
- 新增模式保留原有人物,口型出现在目标身上,原人物不会抢说同一段配音。
- 检查脸型、眼镜、嘴周、牙齿、手部、遮挡和身体比例,记录最明显的异常时间点。
- 12 秒分块前后没有突变或重复动作,推进方向和速度连续。
- 最终使用确认过的原 TTS,字幕按开关处理,音画时长与目标帧数一致。
- 对云端等待、下载失败和本地合成失败分别恢复,确认成功块没有再次提交。
- 当前关闭的生成一致性检查在记录中明确写出,人工验收结论不由
ready状态代替。
人物生成已经进入可运行、可恢复的工程链路,接下来的价值主要来自有针对性的样片验证。把"生成成功""后处理连续"和"看起来自然"分别留证,才能判断一次改动究竟修好了哪一类问题。