Skip to content

Latest commit

 

History

History
276 lines (202 loc) · 15.7 KB

DESIGN.md

File metadata and controls

276 lines (202 loc) · 15.7 KB

设计与实现

README.md 的深水区:这里解释为什么这样做,以及那些不写下来就会被反复踩的约束。

目录

  1. 渲染原理:为什么不用录屏
  2. B() 节拍系统
  3. 画面风格库
  4. 动效库
  5. 模板编排
  6. 封面多画幅
  7. 渲染性能实测
  8. 配音细节
  9. 思想来源

渲染原理:为什么不用录屏

多数「HTML 转视频」工具是启动浏览器、播放动画、录屏。这条路会生出一整类间歇性 bug —— 动画在录制开始前就播了、网络字体加载晚了导致半段视频用了错误字体、前几帧拍到的是动画前的状态。

html-explainer 不录制,它 seek:把 GSAP 时间轴定位到那一瞬间 (tl.pause(t, false)),同步所有 CSS 动画(document.getAnimations().currentTime), 等两个动画帧后截图。于是第 1204 帧与下一次渲染的第 1204 帧逐像素一致 —— QC 可以定点抽查、 单个场景可以独立重渲,一整类时序 bug 从根本上无法发生。

代价是每个动画都必须可 seek:墙钟动画(setInterval、requestAnimationFrame 计数、 CSS transition 入场)不可能工作,会被 lint_frames.py 直接拒绝。这也正是动效库被设计成 「t → 一组数字」的纯函数的原因:它天生可 seek。


B() 节拍系统

subs.py 为每个场景生成一份 beats.js,把每块字幕按原文暴露出来,于是画面动画可以直接 锚在吐字时刻上:

tl.fromTo('.card',    { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片'));
tl.fromTo('.verdict', { scale: 0.8 },          { scale: 1, duration: 0.6 },        B('结论句') + 0.2);

B('块文本') 是「这几个字开始被念出」的时刻,Be('块文本') 是念完的时刻。

这是本项目自己的设计,也是让工作方式变掉的那一块:改一次解说词,重跑三条命令,所有场景自行 重排对时。对比帧号硬编码 —— 改一句话就是整片手工重新对位。

兜底写法是被禁止的。 B('x') || 3.2 会把「节拍查不到」藏在一个过期的数字后面。查不到 就应该让构建直接失败 —— 一条对错了时间的视频,比一次构建失败糟糕得多。


画面风格库

不要对着空白页从零设计画面。 23 种风格、8 个类别,每种都记录了画布、字阶、时间轴结构与 配色纪律 —— 完整目录见 references/style-catalog.md。

涵盖:大胆信号卡 / 奢华极简留白 / NYT 编辑级数据图表 / 瑞士网格 / 故障艺术 / 胶片漏光 / 流体 Hero / 品牌 Logo 收尾 / VFX 文字光标 / 社媒竖版(9:16)/ 产品演示等。

分成两类,改编成本差别很大:

  • rich(12 个) —— 单文件 + 纯 CSS @keyframes。seek 渲染器零改动即可驱动;改编 只要三步:换系统字体栈、把底部元素抬出字幕带、填进真实内容。
  • gsap(11 个) —— 多 composition + CDN 加载。不要搬代码。 只取它的视觉 DNA,用 CSS keyframes 重新表达。

一个项目建议轮换 2–4 种风格。8 个场景共用同一张脸会显得单调 —— 编排器会自动把这件事做掉(见下)。


动效库

assets/motion.js(浏览器里挂到 window.HXM)把 23 个模板里反复出现的动作抽象成 40+ 动作词汇,分 5 组:

组 动作(节选)
enter 到场 riseWord dropLetters springIn blurAway riseFromMask typeChars checkOff flyPlane
carry 承接 morphBox irisOpen diveInto arcHop gatherTo railShift sealDisc burstWord
contact 接触 landHit splitOnHit tapPress pointer stretch2 sim.*(磁吸 / 跟随 / 软体 / 绳索)
camera 运镜 camTrack layerMatrix depthBlur whipPan camShake slowPush gridDots + 坐标互转
ambience 环境 swiftSpring glowField floodRings noiseField(5 套色带) beltLoop

每个动作都是 t(绝对秒)→ 一组数字:无状态、不碰 DOM、不碰 canvas,所以能被逐帧 seek —— 同一个 t 永远给出同一组数。所有随机数走 seededRng,渲染路径上不出现 Math.random(), 逐帧渲染与实时预览结果一致。tests/test_motion.mjs 有 148 条断言。

写画面时不再是从零调缓动曲线,而是从这套词汇里挑选、组合、调参 —— 一个场景通常由 「入场 + 承接 + 一点运镜 + 环境光」几层叠出来。完整词汇表见 references/motion-library.md。


模板编排

scripts/style_director.py 读解说词本身,先推断每场在片子里扮演什么角色 (开场 / 陈述 / 数据 / 原理 / 例证 / 反差 / 收束 / 落版),再按角色 × 子类别 × 时长 × 内容词 × 受众 × 节奏打分挑主风格,按能量预算给部分场搭次风格做局部元素替换,并决定开场变体 (由主题哈希轮换)与场间转场(由相邻两场能量差决定):

# 先 dry-run 看结果,满意再去掉 --dry-run 落盘
python scripts/style_director.py --project . --dry-run
python scripts/style_director.py --project . --mood calm --pace 慢 --audience 大众 --pin hook=bold-signal

产物 style-plan.json 每场带 role / primary / motion_intensity / transition_out / why —— 「这场为什么被挑中」是可查的。多样性约束(风格数上限 / 连续同风格上限 / 开场 ≠ 第二场)与 --pin 硬约束,保证它不会又退化成「一片一模板」。--seed 让同一题材每次换一批画面, --band 0 则完全确定(供回归)。完整规则见 references/style-director.md。

判据:成片能被认出「这是 bold-signal 模板」= 吸纳过头;只能说「颜色像 xx、节奏像 yy」= 深度对了。 核心是让模板服务于主题,而不是让主题被模板限制。


封面多画幅

每条成片产出互为姊妹、而非裁切关系的封面。从 16:9 居中裁 3:4,1920px 只剩 810px —— 丢掉 57.8% 的画面宽度,任何横跨全宽的标题都会被切掉一半。所以各画幅共享同一套视觉基因, 但各自重新排一次版:

元素 16:9(1920×1080) 3:4(1440×1080) 9:16(1080×1920)
用途 信息流 / 播放页 主页栅格 竖版全屏 / 小红书 / 视频号竖版
构型 左右并置 上方竖排堆叠 三段式,重心偏上
钩子 左下,两行,≥96px 下方,三行,≥120px 中下,三行,≥130px
每行字数上限 8 字 8 字 6 字
余量 四边 ≥96px 四边 ≥110px 上 ≥180px · 下 ≥160px · 左右 ≥90px

默认出 16:9 + 3:4 两张;竖版投放再加 9:16 —— 加一张 frames/cover_916.html 即可, 渲染器缺哪张就跳哪张。9:16 的上下边距是**「不可裁区」而不是「安全边距」**:顶部是账号与 时长层、底部是标题栏与互动按钮,压上去等于自杀。

模板由 new_project.py 生成,默认出 2 倍图。出图后跑一次终态几何实测 —— 肉眼只能看出 明显问题,差 20px 的贴边、多出一个字的孤行全靠它抓:

node scripts/cover_build.mjs .          # 渲染 → out/cover_{169,34,916}.png
node scripts/check_cover.mjs .          # 边距 / 钩子字号 / 行宽 / 孤字 / 9:16 禁两栏

完整规则与上传前自检清单:references/cover-guide.md。


渲染性能实测

一条 3 分 20 秒 · 1080p30 · 5982 帧的真实片子(本机 Windows 10 · 16 逻辑核 · Chrome · 6 个浏览器进程 · 快门 180°/8 样本,只开在 3 场 = 35.3% 的帧上):

阶段 --png-fast(默认) --jpeg --jpeg-quality 95
截图(5982 帧 · 11995 次采样) 365.4 s 365.8 s
快门积分 67.1 s 63.0 s
编码 + 收尾 36.8 s 31.9 s
端到端 469.2 s ≈ 7 分 49 秒 460.7 s ≈ 7 分 41 秒
成片体积 10.81 MB 11.02 MB

派生指标:全片平均截图吞吐 16.4 帧/秒、平均单次截图 30.5 ms(含换场与预载开销); 单场景热身后的峰值吞吐 26.65 帧/秒。快门只吃掉 13.7% 的耗时 —— 因为它只落在 35.3% 的帧上。

快的四个来源(按杠杆大小排)

  1. --workers N 真·多进程 —— 截图是纯 CPU 活,能拉满多核。--concurrency 完全无效 (中间帧编码在浏览器进程内串行,实测并发 1/3/6 路吞吐 1.80 / 1.86 / 1.87 帧/秒)。
  2. 换中间帧通道 —— 与并行同级的编码杠杆;png-fast 在纯图形帧上等于「同速 + 更小 + 无损」。
  3. --shutter-only 白名单 —— 快门是唯一按倍数付费的项。只给需要拖影的场景开,其余场零成本: 本片 35.3% 的帧开了快门,却只贡献 13.7% 的耗时。
  4. --profile draft + --preview —— 迭代期只看节奏:去掉快门、只渲一段,秒级出片。

上面是一条真片子的端到端实测,比合成微基准可信 —— 微基准的信噪比不足以预测全片收尾开销。 绝对值随机器与帧内容变化很大;换机器后请用自己的片子跑两遍同通道来标定基线。

中间帧通道的完整口径

通道 相对速度(纯图形帧 / 满幅照片帧) 画质 体积(1080p 纯图形帧) 定位
--png-fast ★默认 ×1.02 / ×4.4 逐像素无损 0.10 MB/帧 纯 CSS/MG 图形帧上与 JPEG q95 同速、体积更小 → 默认用它
--jpeg --jpeg-quality 95 ×1.00 / ×13 PSNR 41.65 dB(低于成片自身失真) 0.12 MB/帧 含满幅照片 / 重合成帧、或 4K 终稿时的首选
--png ×1.16 / ×1.0 逐像素无损 0.08 MB/帧 只有 legacy 复现 / master 极限档用
--jpeg --jpeg-quality 82 最快 略低 ~0.08 MB/帧 只做打样预览

口径陷阱:「×13 / ×4.4」是满幅照片帧的倍率,不要套到纯图形帧上。 本技能绝大多数片子是 大面积平色 + 锐利文字的纯 CSS/MG 图形帧,PNG 的 deflate 对这类帧极其高效 —— 实测 JPEG q95 与 PNG-fast 同速(27.14 vs 26.65 帧/秒,×1.02),而且 PNG-fast 体积更小、还无损。 所以默认推荐是 --png-fast;--jpeg q95 只在含照片 / 4K 终稿时才是首选 (照片帧上精细 PNG 是 582ms/帧的黑洞)。

端到端代价:同一条 5982 帧片子,JPEG q95 实测 460.7 s,PNG-fast 实测 469.2 s(+1.85%) —— 逐像素无损几乎没有代价,成片体积还小了 0.21 MB。

完整档位表、基准读法与快门磁盘护栏见 references/render-profiles.md。


配音细节

流水线开始前,智能体会先问你用哪个 TTS,再列候选音色让你挑(也可以直接给它音色 ID)。 两种引擎产出的 audio-manifest.json 结构完全一致,所以时间轴、字幕、渲染全都不用改 —— 换引擎只是换一个字段的事。

火山引擎音色

内置常用音色(完整列表见官方音色文档):

男声 女声
云舟 2.0(默认)· 温暖阿虎 2.0 · 解说小明 2.0 · 磁性解说男声 2.0
悬疑解说 2.0 · 广告解说 2.0 · 儒雅青年 2.0 · 少年梓辛 2.0 · 深夜播客 2.0
小何 2.0 · Vivi 2.0 · 知性灿灿 2.0
甜美桃子 2.0 · 邻家女孩 2.0 · 温柔淑女 2.0

内置列表不够用时,也可以直接用声音复刻出来的音色 ID。

密钥纪律

API Key 只存在 tts.env 里,而且只有技能里的一个接口包读它。 智能体调的是那个接口包,拿不到、也不需要读密钥的值:

你(手工填一次)→ tts.env(已被 .gitignore 忽略)
                      ↓  只有接口包读
               合成调用  ← 智能体只调这个,永远拿不到密钥值
                      ↓
               火山引擎
  • 不进对话:问智能体密钥状态,它给你的是脱敏摘要(ef90******86c8)。
  • 不进仓库:.gitignore 覆盖 tts.env / *.env / *.key / *.pem / secrets/; 仓库自检里有专门的密钥防线,并且做过反向自证 —— 故意植入一个假密钥会被当场抓到。
  • 不进日志:出错信息过脱敏,只抹「这次实际用到的密钥值」与 AKLT… 形态的 token —— 刻意不用「长字符串就算密钥」这种宽规则,否则连排查要用的请求 ID 一起抹掉。
  • 不进分发:技能打包时排除密钥文件。
  • 兜底层(真正的安全边界):万一还是泄了,损失要可控 —— 建议在火山控制台用 子账号只授语音合成权限、设用量告警与限额,密钥可随时轮换。

说句实话:合成代码是在你本机执行的,运行时进程里密钥对那一段脚本可见, 「智能体绝对读不到」做不到。上面保证的是不进对话、不进仓库、不进日志、不进分发, 把泄露半径收敛成一次可撤销的事故。

另外别用环境变量存密钥 —— 很多宿主会把进程环境原样打进会话记录。

别把 tts.env.example 改名成 tts.env 去填:那是要留在仓库里的模板, 改名会让后来的人没模板可抄(仓库自检会报错并提示)。复制一份再填。


思想来源

html-explainer 的方法论不是凭空来的。它有两处明确的思想来源,两个项目都由不同作者独立 开发,与本项目不是同一作者;本仓库不打包、运行时不依赖它们的任何代码 —— 吸收的是设计规范 与工程经验。

  • Vincentwei1021/anything2explainer (TypeScript / Remotion)—— 「主题进 → 带解说的讲解视频出」。本项目从这条线继承的是 解说与音画同步方法论:词边界字幕、两级时钟、语速标定、QC 判据。 差异:它是 Remotion + React 组件动画;本项目用 HTML/CSS/GSAP + 确定性 seek。

  • nexu-io/html-video(Apache-2.0,Open Design 团队)—— 「在本机把 HTML 变成真 MP4」。本项目从这条线继承的是 23 种画面风格目录, 转写自它的模板设计规范。其中 7 种又可追溯到 MIT 许可的设计作品。 差异:它靠实时录制;本项目靠 seek 逐帧渲染,帧可复现。

本项目自己的部分:确定性 seek 渲染器、B() 节拍锚定、动效库(assets/motion.js)、 模板编排器(style_director.py)、渲染档位与通道体系(画质/帧率/快门/并行, references/render-profiles.md)、封面多画幅(含几何实测器),以及 lint_frames.py / qc_check.py 的判据。完整的署名与逐风格对应关系见 THIRD_PARTY_NOTICES.md。

如果你要的是 React 组件动画、或 Studio 可视化协作,直接去用上面两个项目;如果你想改一句 解说词而不用手工重新对位、并且要帧级可复现,用本项目。