视频工作室(video-studio)
一句话:视频 = 剧本 + 音频实测时长 + 确定性逐帧函数 + ffmpeg 编码 + 自动质检。 画面与声音全部由代码生成,零素材、可复现、可断点续跑、可脚本化验收。
0. 铁律(违反任意一条,成片一定会出问题)
- 音频先做,画面后做。时间轴必须由 TTS 实测时长反推,绝不按字数估算。按字数排的时间轴会在第 20 句开始明显漂移。
- 每帧是纯函数:
draw(t, frame) -> Image。禁random/time(),要随机用filmkit.hash2/hash3/vnoise。同一帧号必须永远画出同一张图。 - 字幕与大字必须落在安全区:正文
y ∈ [58, 974],字幕带y ∈ [872, 1030],永不超过 1030。 - 原始帧直通编码:PIL 帧 →
rawvideo管道 →libx264。不要用cv2.VideoWriter做中间文件(慢 2 倍且二次有损)。 - 混音用 numpy 采样级叠加,不要用一条 ffmpeg 命令喂上百个输入(Windows 上会非确定性静音)。
- 交付前必须跑质检脚本 + 亲眼看 3 张静帧(用
read_image)。"能出图"不等于"没问题"。 - 声音跟着画面走:该响一下的地方在场景代码里登记提示点(
K.CueBus/scenes.py的cues()钩子), 不要事后手工对时间。画面改了(换停留窗口、调节奏),提示点自动跟着改。 - 没看清就加停留,别整片放慢:
film.json的hold(全局)或lines[].hold(单句) 在语音结束后加停留;配乐用bpm对齐小节。
0.5 【铁律】先提案,再出片(不论用户是否要求)
用户没要求,也必须先给方案。 这是本技能最重要的一条产品行为,不是建议。 理由:外观一旦由程序替用户选,用户就从头到尾没参与决策,结果必然是"每个视频都差不多"。 所以流程固定为:现场采样出几个方案 → 用户挑一个 → 冻结成设计 → 再出片。
固定动作(任何新成片任务的第一件事):
python propose.py --brief "<题材/受众/时长/语气>" --script film.json --n 3
它会产出 N 个候选方案(每个 = 一份设计 + 4 张静帧 + 成本 + 与其它候选的设计距离),
落盘 work/proposal.json,并打印一句"确认其中一个 / 都不满意就 --revise"。全程离线、秒级。
| 用户反应 | 你要做的 |
|---|---|
| "就这个" / 选了某个 | python propose.py --confirm --candidate B,然后才允许渲染 |
| "太冷了/太花哨/换个声音/节奏快点" | python propose.py --revise "<原话>" —— 新 rev,旧 rev 保留可 diff |
| "都不行,重来" | 同上,把要求写具体(--revise 的文本会存进 proposal.feedback) |
| 明确说"你定就行" | 仍然先出方案,挑一个你推荐的并说明理由,让用户点头 |
硬约束:
propose.py --confirm之前,render.py的frames/all会被提案闸拦下(默认警告,--require-proposal失败)。- 候选必须两两在 ≥2 个设计维度上不同(配色族/母题/转场/缓动/编号/配乐/声线)。只换颜色不算多方案,
diversity.vs[].n < 2会被标出来。 - 每个候选必须有一句中文立意(
pitch),说清"这个方案想干成什么样"——不是"方案 A/方案 B"。 - 没有内置模板可套:
design.json的所有字段都开放(35 字段主题 + 五组视觉语言签名 + 声音/配乐/节奏),配色是连续色相空间里的任意一点,不是从若干套现成外观里挑。 每次提案都是现场生成——同一个 brief 连拍两次会得到不同设计(要复现就传--seed)。 - 片间去重台账(默认开启):
--confirm会把冻结的设计记进~/.dsh/video-studio/design-ledger.json(最多 500 条),下次提案主动避开最近交付过的外观—— 候选与台账里任一部片的设计距离 < 2 维时自动换 nonce 重采并告警。 这是"不同片不重复"从"靠随机"变成"有记忆"的落地机制。--show-ledger查台账、--no-ledger本次忽略、环境变量VIDEO_STUDIO_LEDGER改路径或设off关闭。 台账读写失败一律当空表,永不阻塞出片。
只有一种情况可以跳过提案:纯修复 / 纯重渲(不改内容、不改设计,只修音画不同步或重出片)。
这种情况下用 --require-proposal 之外的方式跑,并在回复里说明"本次未提案,因为…"。
0.5.1 外观从哪来:只有两条路
没有"内置风格"可选。 film.json 里写任何 style 值(auto / legacy / 某个 id)都会
被明确拒绝并给两步指引——剧本不决定长相,长相由 work/design.json 决定。
同理,技能里没有 THEMES/STYLES/VISUAL_LANGUAGES 表,也没有 presets/;
配乐由 musicgen.py 按参数现场编排,scenes.draw_frame 也没有 legacy 分支。
要特定外观只有两条路:
# ① 自然语言提要求(认得 must_have/avoid 里的关键词)
python propose.py --brief "科技发布会,深蓝,要遮幅" --script film.json
# ② 直接手写 design.json(字段全开放,配色是任意 RGB)
python render.py --script film.json --stage all # 前提:work/design.json 已存在
详细契约见 references/proposal.md 与 schemas/design.schema.json。
1. 五步上手(零拷贝:不拷脚本,不拷模板)
这条路径没有任何"拷贝"动作。 技能脚本留在技能目录里直接执行; 你的工程目录里只需要一个
film.json(外加运行产生的work/与out/)。
<SKILL_DIR>指本次加载技能时skill工具回报的resourceBase.path。装成插件后它就是 本插件包内的skills/video-studio/(profile 的node_modules/@dsh-gis/dsh-video-studio-skill是链接)。 找不到时按名字定位:find ~ -path '*/skills/video-studio/scripts/filmkit.py'。
<REPO>指包根(仓库根,或装好后 profile 里的node_modules/@dsh-gis/dsh-video-studio-skill/), 即<SKILL_DIR>往上两级。tools/与schemas/在<REPO>下,不在<SKILL_DIR>下。
# 1) 只写一个 film.json(工程目录里唯一需要你手写的文件)
mkdir -p myfilm && cd myfilm
cat > film.json <<'JSON'
{
"title": "我的片子",
"lines": [
{"id": "l01", "kind": "hook", "chapter": "01 · 开场",
"text": "这是用代码从零生成的画面与声音。", "sub": "零素材,全代码生成", "big": "零素材,全代码生成"},
{"id": "l02", "kind": "body", "chapter": "02 · 正文", "kicker": "PIPELINE",
"text": "整条管线只有五步:写剧本、生成旁白、排时间轴、逐帧渲染、混音合成。",
"sub": "五步成片,每步落盘", "big": "五步成片",
"items": [{"text": "剧本 film.json"}, {"text": "旁白"}, {"text": "时间轴"}, {"text": "逐帧渲染"}, {"text": "混音"}]},
{"id": "l03", "kind": "outro", "chapter": "99 · 收尾",
"text": "先定脚本,再定声音,最后才是画面。", "sub": "顺序比努力重要", "big": "顺序比努力重要"}
]
}
JSON
# lines[].text = 朗读文本(自然口语);sub = 上屏字幕(短);kind = hook/body/quote/outro/emphasis/calm
# 画风/配乐/声线/节奏全部由 design 决定,film.json 里**没有** style 字段可写。
# 契约校验(可选,几秒):
python <SKILL_DIR>/scripts/filmcheck.py film.json
# 2) 先自检一次环境(可选;69 条回归护栏,约 10–20 秒,不联网;缺可选模块会标 SKIP)
python <SKILL_DIR>/scripts/selfcheck.py
# 3) 【铁律】先出方案,再出片——同一个 brief 每次都不一样,所以必须让用户看过
python <SKILL_DIR>/scripts/propose.py \
--brief "AI 大模型的三年,30 秒科普,深蓝" \
--script film.json --work work
# 产出 3 个候选:各自一份 design + 静帧联系表(work/proposals/rev1/_compare.png)+ 设计距离,
# 2 秒级出结果、全程离线。看并排图,挑一个。
# 4) 确认候选,冻结设计(work/design.json)——此后渲染只认这份设计
python <SKILL_DIR>/scripts/propose.py --confirm --candidate A --work work
# 不满意就换一个:--revise "再冷一点,少装饰,换女声"(新 rev,旧 rev 留着眼 diff)
# 想复现某一次:首次提案时加 --seed 42(不传 seed = 每次新 nonce,这是"每片不同"的来源)
# 5) 先看静帧,再出全片;--scenes 不写就用技能内置渲染器
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage stills \
--stills "l01:3,l02:5,l03:2.5"
# → work/stills/_sheet.png:用 read_image 亲眼看,别跳过
python <SKILL_DIR>/scripts/render.py --script film.json --work work \
--out out/film.mp4 --stage all
# TTS→排版→配乐→音效→混音→逐帧→编码→质检 一条龙;中间产物全在 work/,可断点续跑。
关于 --scenes(零拷贝的关键):render.py 不写 --scenes 时的定位顺序是
① 剧本同目录的 scenes.py → ② 技能内置 assets/scenes.py。所以默认情况下你什么都不用写、
也不用拷,内置渲染器直接出片;只有想改画面时才在剧本同目录放一个自己的 scenes.py
(实现下面的 draw_frame 与可选的 cues()),它会被自动发现——也可以显式 --scenes my_scenes.py。
内置渲染器源码就是最好的范例:<SKILL_DIR>/assets/scenes.py。
# 真实签名(六个参数,少一个都会崩):`scenes.draw_frame`
def draw_frame(scene, t_local, dur, t_global, script, frame):
# scene : 这一段在 timeline 里的字典(id / kind / scene_type / sub / big / items / stats ...)
# t_local : 段内时间(秒,0..dur)——入场/持续动效都按它在段内推进
# dur : 这一段的总时长(秒)
# t_global : 全片时间(秒)——跨段的连续动效(镜头缓推、呼吸)用它
# script : 整份剧本/计划字典(plan、主题色、words 等;有缓存,别原地改)
# frame : 绝对帧号(整数)——需要确定性噪声/相位时用它,别用 time()
return img # PIL.Image,必须 1920x1080 RGB(用 K.new_frame() 起手最稳)
写自定义
scenes.py时先抄<SKILL_DIR>/assets/scenes.py的一个绘制器(_draw_hero之类), 它已经把纯函数、缓存排版、预烘焙底版这些坑都绕过一遍了。
为什么先提案:没有"内置风格"可选,外观由 design.json 决定。
同一个 brief 连拍两次会得到不同设计(配色/母题/转场/配乐/声线都可能不同),
所以流程必须是"生成方案 → 人确认 → 冻结 → 出片";不确认就渲染,render.py 会打提案警告
(加 --require-proposal 则直接失败)。设计契约见 references/proposal.md。
分阶段跑(每个 stage 只做自己的事,落盘后可单独重跑):
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage style # 解析已确认的 design,打印主题/视觉语言/版式轮换/字幕样式
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage tts # 只合成旁白 + 实测时长(带输入校验的缓存)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage layout # 只排时间轴 + 选构图,落 timeline.json / sub.srt / storyboard.json
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage cues # 只出提示点 -> work/cues.json
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage score # 只出配乐 -> work/music.wav(musicgen 现场编排)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage sfx # 只出音效轨(sfxkit,按 cues.json)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage audio # 只混音(旁白+配乐+音效,旁白优先)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames # 只渲染帧(--engine pil|web,--jobs N 分块并行)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage mux # 只合成(视频流 copy,两遍 loudnorm)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage qa # 只质检(只读输入,只写 work/qa/)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage stills # 只渲母版静帧 + 联系表
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage accept --gate stills # 人工签核一道闸(写 work/signoff.json)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage explain # 只报"哪些阶段过时、为什么、该跑什么",不跑任何 stage
--stage all 的完整顺序:style → tts → layout → cues → score → sfx → audio → frames → mux → qa。
任何一步出问题都会在该步停下,不会带着坏输入继续往下跑。
开渲前先算成本(可选;estimate.py 是仓库工具,按 <REPO> 调用):
python <REPO>/tools/estimate.py --script film.json --jobs 4
它给帧数/各阶段耗时/磁盘/分块建议(--json 可机读),是区间上界不是承诺;
有 work/timeline.json 时用真实时长,否则按字数粗估。要省掉全片 VMAF 就在 film.json 写
"qa": {"vmaf": {"mode": "off"}}(estimate 与 render 都读这个字段,G11 记 skipped)。
看交付凭据再签核:
cat out/QC-REPORT.md # gate matrix 分 pass / fail / skipped 三档;skipped = 本次没执行,不等于通过
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage accept --gate release
签核绑内容指纹:改了剧本 / scenes.py / 设计,旧签核自动失效,要重新看、重新签。
交付前想强制(skipped 也算失败):加 --strict-gates。
长片渲染(分块并行 + 断点续跑):
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames --jobs 4
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames --jobs 4 --resume
分块落在
work/segments/,按seg_<起始帧>_<结束帧>.mp4命名;拼接前会显式检测缺口, 缺块直接报错,不允许"少一段也能出片"。默认 chunk 按总帧数 / jobs / 4向整秒取整(jobs=4、 900 帧时约 8 秒一段),可用film.json的chunk_frames覆盖。改台词 / 改 scenes.py / 改设计 / 改编码参数之后,
--resume会拒绝复用旧分块(work/segments/_plan.json里存着计划指纹),并提示你rm -rf work/segments——这是刻意的:旧分块配新时间轴会拼出 "画面和旁白对不上"的片子,而且时长门禁查不出来。
2. 标准八阶段(P0–P7)
| 阶段 | 产出 | 关键动作 | 常见错误 |
|---|---|---|---|
| P0.5 定设计 | work/design.json + work/style.json | propose.py 出方案 → --confirm 冻结设计;--stage style 只解析已确认的设计并打印主题/视觉语言/版式轮换/字幕 | 跳过 → render.py 报错(没有 design 就没有外观);不确认就渲染会打提案警告 |
| P0 定基准 | CONVENTIONS.md | 分辨率/帧率/时长/BPM→帧换算一次算死,全项目引用同一份 | 各文件自己写 150 这种魔数 → 幕边界对不齐 |
| P1 写剧本 | film.json | 每句拆 text(朗读)与 sub(字幕);每段一个 kind;可选 lines[].type 锁定构图 | 一个字段两头用 → 字幕太长或读起来生硬;别写 style(不再影响画面,外观由 work/design.json 决定) |
| P2 事实核查 | FACTS.md | 上屏的每个数字/年份有出处编号;建"不能上屏"黑名单 | 跳过这步 → 画面和旁白数字打架,评论区第一条就是你错了 |
| P3 TTS | work/tts/*.mp3 + tts.json | edge-tts 逐句合成,ffmpeg -f f32le 实测时长,磁盘缓存 | 按字数估时长 → 字幕漂移 |
| P4 排时间轴 | work/timeline.json + sub.srt | dur = lead + 实测语音 + tail,句读必须在本段内 | 段太短 → 旁白被下一段截断 |
| P5 混音 | work/audio.wav | numpy 总线:旁白 + 配乐 + 音效按采样点叠加 | ffmpeg 多输入混流 → 非确定性静音 |
| P6 母版 | 3 张静帧 | 先渲 3 帧看构图/配色/字重,定了再渲全片 | 直接渲全片 → 36000 帧后发现字体丑 |
| P7 成片 | out/film.mp4 | rawvideo→libx264 CRF18 + 两遍 loudnorm 到 -16 LUFS | 码率过低/响度 -25 LUFS → "像草稿" |
注:
filmkit/render.py的默认是crf=18;assets/film.json只是测试夹具(跑通全链与金帧/门禁回归用,不是给你抄的模板),它写的是 17。 自己写剧本时不写crf就是 18;追求更锐利的文字可以写 17(体积约 +30%)。
3. 渲染引擎怎么选
| 场景 | 选型 | 实测速度 | 适合 |
|---|---|---|---|
| 图文/数据/科普/UI 风 | PIL(numpy+cv2 辅助) — filmkit 默认 | 40–80 fps @1080p30(示例片 75) | 绝大多数中文科普片、报告转视频 |
| 需要流畅矢量/粒子/3D/纸纹质感 | 无头浏览器 Canvas2D/WebGL + CDP 截图 → image2pipe(webengine.py) | 80–130 ms/帧(核显,PNG 截图) | 纪录片级长片、需要复杂视觉语言 |
| 纯数据动画 | manim(若已安装) | 视场景而定 | 数学/公式推导 |
上表速度是单机空载实测口径(换机、重负载画面都会变;详见 §12)。别把区间当保证。
设计只覆盖 PIL 引擎(
engine: pil,默认):work/design.json的配色/字体/版式/母题 由assets/scenes.py读取。--engine web的页面由你自己写(assets/web/只是能跑通的示例页, 不是出片模板),画面不跟随设计;但配乐与旁白照样跟随design.json(它们不依赖渲染引擎)。
默认走 PIL:依赖少(numpy+Pillow 就够)、启动快、确定性强、调试成本低。 需要"一镜到底的粒子形变 / 实时着色器质感"才上浏览器方案:
cp -r <SKILL_DIR>/assets/web ./web # 页面模板(原生 Canvas2D,零三方库)
pip install playwright && python -m playwright install chromium # 只在走 web 引擎时需要
python render.py ... --engine web --stage cues # 从页面导出提示点
python render.py ... --engine web --stage frames --jobs 3 --resume
python webengine.py textqa ./web/index.html # 文字重叠/出画/一闪而过/从没露面
两条路线的取舍(先想清楚再选,换引擎要重做画面):
PIL(engine: pil) | 浏览器(engine: web) | |
|---|---|---|
| 依赖 | numpy + Pillow(+cv2 做锐度比) | + playwright + Chromium/Edge |
| 速度 | 40–80 帧/秒 | 8–12 帧/秒(逐帧截图+编码,慢一个数量级) |
| 画面语言 | 位图合成、渐变、几何、文字 | Canvas2D/WebGL:粒子、形变、着色器、矢量动画 |
| 确定性 | 天然(纯函数画图) | 靠"把时间轴精确 seek 到 f/FPS"保证(window.renderFrame) |
| 调试 | 改一帧跑一帧 | 浏览器里 index.html?dev=1&t=秒 直接看,出了 bug 好查 |
| 适合 | 图文/数据/科普/宣传片 | 纪录片级长片、需要一镜到底的视觉语言 |
浏览器引擎的三条硬约束(webengine.py 已实现,自己写页面时也要守):
- 所有补间放在一条暂停的主时间轴上,渲染第 f 帧 = 把它 seek 到
f / FPS秒;不许依赖播放。 - 循环/粒子/波浪这类程序化内容用"按时间直算"的更新器(
update(fn)),不许用setInterval累加。 - 页面必须暴露
window.ready / DURATION / renderFrame(f) / getCues();不连 CDN、不请求网络。
要跨浏览器会话的字节级可复现(崩溃重开、多次渲染做 diff、质检要比对两张图)时开确定性模式:
--deterministic 或环境变量 WEBENGINE_DETERMINISTIC=1(关 GPU 光栅,实测 8/8 会话一致,
代价约 +22%/帧)。默认不开:同一会话内本来就一致,跨会话只有 ≤4 像素、±1 级的抗锯齿差异。
4. 画质清单(这几项决定"像不像专业片")
- 亮度与层次:暗底 (
#0a1420) + 顶部亮 accent;不要纯黑纯白,用户屏幕会吃掉渐变。 - 字重与字号:大字 84–96px、副标题 34–38px、字幕 46px;中文用
msyhbd.ttc(微软雅黑粗体)。 - 字幕:≤ 2 行、单行约 24 个中文字、带半透明背板、
y ≥ 872。 实际折行看的是像素宽度(fit_sub_lines按max_w=1560 px自动折),24 字是"大致上限"并非硬校验 ——SUB_MAX_CHARS=24这个常量目前没有被任何代码引用,别指望它拦你。 - 节奏:每 3–6 秒要有视觉变化(新卡片 / 位移 / 数字滚动);静止超过 6 秒观众就走。
- 镜头:只做 3–15% 缓推,不要大幅拉远拉近。
- 调色:整片一个主色 + 一个强调色,别超过 4 个色相。
- 音频:母带 -16 LUFS / 真峰 ≤ -1 dBTP;旁白比间隙响 12 dB 以上;AAC 前留 1.4 dB 余量。
5. 声音:配乐 + 音效 + 提示点
这一节的思路来自一部真实成片(
ai-history-film)的复盘:声音不好不是"混得不够响",而是"声音不认识画面"。 解决顺序永远是:先让声音知道画面上发生了什么(提示点),再谈音色,最后才是母带。
5.1 三层声音
| 层 | 谁做 | 落盘 | 说明 |
|---|---|---|---|
| 旁白 | filmkit.tts(edge-tts) | work/tts/*.mp3 | 唯一的时长权威:时间轴由它反推 |
| 配乐 | musicgen.py(参数化编排) | work/music.wav | 按设计里的 bpm/key/scale/mood/seed + 编排参数现场生成;没有现成曲风可挑 |
| 音效 | sfxkit.py(物理建模) | work/sfx.wav | 按 cues.json 里登记的提示点逐条合成 |
三条总线在 --stage audio 里合成:音效响起时配乐自动让位(duck_db 默认 4 dB,
cinema/epic 5.5 dB——3 dB 的让位量在段落过门时配乐几乎与音效等高,听感会糊成一团),
最后交给 ffmpeg 两遍 loudnorm 到 -16 LUFS / 真峰 ≤ -1 dBTP。不要自己在 numpy 里做最终响度标准化——
你有 loudnorm,它比手写归一化准得多,而且真峰才是门禁。
片内分离:配乐在段落交界本来就有过门(编排器写的 swell / riser / sub_boom), sfx 轨不在同一个边界重复放 whoosh/riser——
--stage sfx会对落在 [边界 - 2.2 s, 边界] 内的转场类 cue 自动 -3 dB(sfx.duck_transitions=true,默认)。 电影级长片建议提成duck_transitions=4.5以避免过门堆积。
5.2 提示点(让声音"看见"画面)
# 在自定义 scenes.py 的 cues() 里登记
from filmkit import CueBus
def cues():
bus = CueBus()
bus.add(t=2.5, kind="sfx:transition", meta={"type": "whoosh"})
bus.add(t=8.0, kind="sfx:impact", meta={"intensity": 0.7})
bus.add(t=15.0, kind="sfx:ambient", meta={"loop": True, "src": "wind"})
# 提示点自带时间戳(相对于段起点),`--stage layout` 会转成全局时间
return bus.cues
提示点不会自动生成——你得告诉音效师(
sfxkit.py)什么地方该响。CueBus生成的cues.json会被--stage sfx读取。
提示点类型:
| kind | 说明 | 音效素材 |
|---|---|---|
sfx:transition | 段落转场 | whoosh / swoosh / riser |
sfx:impact | 强调 / 重音 | 鼓 / 金属 / 合成器 hit |
sfx:ambient | 环境背景 | 风 / 城市 / 太空低频 |
sfx:stinger | 章节开场 / 重要时刻 | 短促标志音 |
music:beat | 配乐对齐点(节拍器) | 无(仅元数据) |
5.3 配乐编排参数
musicgen.py 通过 work/design.json 读取以下编排参数:
{
"music": {
"bpm": 90,
"key": "C",
"scale": "minor",
"mood": "cinematic",
"instruments": ["synth_pad", "orchestral_hits", "sub_bass"],
"structure": "intro-verse-chorus-verse-outro",
"transition_riser": true,
"density": 0.6
}
}
没有"预置曲风"——
musicgen.py的参数决定了配乐的性格。 参数组合是无限的,不要问"有没有欢快的",要问"bpm 多快、什么调、什么 mood"。 如果musicgen.py因为缺少依赖(如spleeter)回退到无配乐,--stage score会输出静音文件并告警,但不阻断流程。
6. 质检门禁(G1–G12)
--stage qa 自动跑以下 12 道闸。每道闸输出 pass / fail / skipped。
--stage accept --gate release 要求所有 gate 为 pass,否则拒绝签核。
| 编号 | 闸名 | 检查内容 | 失败时 |
|---|---|---|---|
| G1 | tts_duration | 每句 TTS 时长 > 0,全片旁白总长 < 片长 × 1.15 | 提示重新运行 --stage tts |
| G2 | timeline_fit | 每段时长 >= 该段 TTS 时长 + 0.5s | 提示手动调整 hold |
| G3 | sub_length | 字幕最多 2 行,每行 < 1560 px | 自动缩减或告警 |
| G4 | sub_safearea | 字幕 y >= 872 | --stage layout 重排 |
| G5 | big_safearea | 大字 y ∈ [58, 974] | --stage layout 重排 |
| G6 | frame_numbers | 渲染帧数 = 预期帧数(typ: 900 帧) | 提示重跑 --stage frames |
| G7 | frame_continuity | 相邻帧 PSNR < 99%(过静检测) | 提示检查 scenes.py 动效 |
| G8 | audio_loudness | 母带 -16 ±1 LUFS,真峰 ≤ -1 dBTP | 提示重新 loudnorm |
| G9 | audio_phase | 旁白与字幕时间戳偏移 < 100 ms | 提示重跑 --stage audio |
| G10 | video_bitrate | 码率 >= 8 Mbps @1080p | 提示降低 CRF |
| G11 | vmaf | VMAF >= 85 @ 源帧(可选,qa.vmaf.mode 默认 auto) | 提示重渲或调 CRF |
| G12 | duration_gate | 全片时长 ±3% 预期 | 提示检查 timeline |
G11(VMAF)默认只在
--stage all时运行;--stage qa时可加--run-vmaf强制。 要关闭 VMAF,在film.json写"qa": {"vmaf": {"mode": "off"}}。
7. 故障排除
7.1 常见问题
| 问题 | 原因 | 解决 |
|---|---|---|
| 字幕漂移 | 时间轴按字数估算 | rm -rf work/tts work/timeline.json 重跑 --stage tts --stage layout |
| 画面和旁白对不上 | --resume 用了旧分块 | rm -rf work/segments 重跑 --stage frames |
| 音效没声音 | 提示点没登记 / cues.json 为空 | 检查 scenes.py 的 cues() 是否返回非空列表 |
| 配乐太响/太轻 | duck_db 不合适 | 在 design.json 的 music 段调 duck_db |
| 字体显示为方块 | 字体文件没装 | fc-list :lang=zh 确认,或 pip install msyhbd.ttc(需手动) |
| 浏览器引擎很慢 | 逐帧截图天然慢 | 考虑换 PIL 引擎,或减少帧数 / 调高 --deterministic(关 GPU) |
| lmxl 缺少扩展 | 系统 libxml2 版本过低 | sudo apt install libxml2-dev 或 sudo ln -sf /usr/lib/x86_64-linux-gnu/libxml2.so.2 /usr/lib/x86_64-linux-gnu/libxml2.so |
7.2 日志与调试
# 每个 stage 在 work/ 下有自己的日志文件
ls work/logs/
# work/logs/tts.log
# work/logs/layout.log
# work/logs/frames.log
# 全局调试开关
export VIDEO_STUDIO_DEBUG=1
python render.py ... --stage all # 控制台输出更多细节
# 想看某一帧渲染了什么
export VIDEO_STUDIO_FRAME_DEBUG=1
python render.py ... --stage frames # 每帧输出渲染时间
7.3 参考文件
| 文件 | 路径 | 说明 |
|---|---|---|
| 设计契约 | references/proposal.md | 设计提案/确认/revise 的详细协议 |
| 设计 Schema | schemas/design.schema.json | work/design.json 的完整 JSON Schema |
| 剧本 Schema | schemas/script.schema.json | film.json 的完整 JSON Schema |
| 内置场景渲染 | assets/scenes.py | 默认渲染器,也是写自定义 scenes.py 的模板 |
| Web 示例页 | assets/web/ | 浏览器引擎的示例页面(原生 Canvas2D,零三方库) |
| 金帧测试 | tests/golden/ | 回归测试用的已知正确帧 |
| 门禁测试 | tests/gates/ | 每道闸的单元测试 |
附录 A:环境变量速查
| 变量 | 默认 | 说明 |
|---|---|---|
VIDEO_STUDIO_LEDGER | ~/.dsh/video-studio/design-ledger.json | 片间去重台账路径;设 off 关闭 |
WEBENGINE_DETERMINISTIC | 0 | 浏览器引擎确定性模式(关 GPU 光栅) |
VIDEO_STUDIO_DEBUG | 0 | 全局调试输出 |
VIDEO_STUDIO_FRAME_DEBUG | 0 | 逐帧渲染时间输出 |
VIDEO_STUDIO_NO_LOCK | 0 | 不创建文件锁(默认锁定 work/ 防并发) |
VIDEO_STUDIO_TTS_CACHE | ~/.cache/video-studio/tts | TTS 文件缓存目录 |
VIDEO_STUDIO_MUSIC_CACHE | ~/.cache/video-studio/music | 配乐缓存目录 |
附录 B:关于 film.json 的完整字段
建议用 filmmkit.FilmScript 构造,或参考完整 Schema schemas/script.schema.json。
以下只列常用字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
title | string | 是 | 片名(用于日志/文件名/元数据) |
lines | array[Line] | 是 | 台词数组;每句一条 |
lines[].id | string | 是 | 唯一 ID(如 l01) |
lines[].kind | enum | 是 | hook / body / quote / outro / emphasis / calm |
lines[].chapter | string | 否 | 章节标题(如 "01 · 开场") |
lines[].text | string | 是 | 朗读文本(自然口语) |
lines[].sub | string | 否 | 上屏字幕(短句) |
lines[].big | string | 否 | 大字标题 |
lines[].kicker | string | 否 | 段首小标签(如 "PIPELINE") |
lines[].items | array[Item] | 否 | 要点列表 |
lines[].type | enum | 否 | 强制构图类型(hero / split / center / list) |
lines[].hold | float | 否 | 该句语音结束后额外停留秒数 |
hold | float | 否 | 全局每句后额外停留秒数(会被 lines[].hold 覆盖) |
qa | object | 否 | 质检配置(如 {"vmaf": {"mode": "off"}}) |
chunk_frames | int | 否 | 并行渲染时的分块帧数(覆盖自动计算) |