zjchina.net

Drogon · ROS 2 · 独立开发笔记

ADR-00142026-11-07

可复现出片 —— 必须一致的片子归自绘内核,AI 生成只做一次性素材

拍板:zj

背景

博客开始图文并茂后,「配图/配片从哪来」成了内容管线的最后一环。此前三条通道并存: rsvg-convert(手写 SVG)、外部 AI 生图(seedream,需 ARK_API_KEY)、IDE 宿主生成。 前两者可入库,第三者对服务器是黑盒。

2026-11-07 做《四本账》封面与 15 秒摘要片时,这条路的问题一次暴露干净:

  • 不可复现:同一段 prompt 无法固定种子/尺寸/码率;换个时间生成就是另一条片子。 仓库里留下的只有产物,没有「重出一次」的办法。
  • 不可控规格:生成器给的是 11MB、带「AI 生成」水印的原始片;要能上网得手工裁水印、 压到 1.3MB、补 +faststart。规格全靠人盯,没有闸门。
  • 不可审计:prompt 里可能带未公开的产品命名,出境即不可撤回(见 ADR-0011 的隐私口径)。

而早在做 zjchina-anim 时,设计契约里就写着这条路是「免费」的:

无 retained 状态 ⇒ 动画值 = f(t) 纯函数 ⇒ 重定目标免费(spring)+ 离线帧序列导出免费 (固定 t 步进采样)。 —— zjchina-anim/src/lib.rs

今天要做的不是新能力,而是把这条「设计余量」兑现成「产品入口」。

这条路的血缘:对标 Impeller,抄 Qt 的纪律,借 UE 的编排

出片不是临时起意,它继承了内核选型时就写下的三笔账:

  • Flutter / Impeller 的形:自绘内核的存在前提是「自有 GPU 提交路径 + 预编译管线 + 显式状态」,与 Impeller 去掉运行时 shader 编译抖动是同一个立意( docs/renderer-roadmap.md)。 这条路径一旦成立,「同一场景 → 同一像素」就是默认属性,而不是出片时才去追的目标。
  • Qt 的魂:动画计算只发生在渲染循环里,UI/数据线程零参与——所以采样器可以脱离 窗口系统单独跑,zjchina-clip 才能在无界面环境下逐帧出图 (zjchina-anim/src/lib.rs 的「由来」段)。
  • UE 的意:Seq 的错峰编排是演绎骨架,但我们不搬它的时间轴资产与 Sequencer 编辑器——那会把「片子」变成需要维护的第二套资产。片子由 scene JSON + delta 模板 在采样时生成,改一行就改全片。

反过来,这条路线也划清了「不抄」:公开 Web 页永不自绘(走 DOM+SSR, LET_TIME_SEE.md),AI 生成只做一次性素材(本 ADR 的拍板)。

选项

  • A. 继续用 AI 生成,仅补规格闸门。 最省事,但每次改一个像素都要重新 prompt, 且片子永远无法在服务器上原样重出;「素材可复现」这条内容纪律直接破功。
  • B. 引 ffmpeg 的 Rust 绑定,把出片塞进内核。 会给 L0 内核拉入重依赖,违反 「内核只管画、不管封装」的分工;且 ffmpeg 绑定许可证与版本都难控。
  • C. 固定 t 采样 → stored PNG 帧 → 系统 ffmpeg 编码(选定)。 动画值来自 zjchina-anim(纯函数),帧来自 zjchina-render 离屏读回(已有 render_to_rgba + png::encode_png 的 stored 编码,专为中间帧设计),编码交给 系统 ffmpeg(命令行,零 Rust 依赖)。同一份源码 + 同一个 t ⇒ 同一串字节。

证据

  • 出帧已是既有能力:zjchina-render/tests/frame_export.rs 证明离屏读回逐字节确定; 它用的 stored(不压缩)PNG 编码就是为「喂给编码器」准备的。
  • 动画驱动逐帧提交已跑通:zjchina-render-capi/tests/frame_seq.rs 用 Stepper + Spring 采样 60 帧并逐帧 submit_delta,说明 f(t) → 帧的链路成立。
  • 缺的只有最后一环:全仓库没有任何一处调用 ffmpeg,也没有「采样 → 成片」的命令; 能力只以测试形式存在,没有产品入口。
  • 今天的人工成本即反例:裁水印 + 二次编码 + 手写 ?v=2 绕缓存,全是手工步骤, 一次可以,每周一次就会出错。
  • 服务端还有一个坑:视频落地时 application/octet-stream + 不支持 Range, Safari/iOS 直接不播——这类问题只有「我们自己产片、自己验收」才拦得住。

拍板

  1. 必须一致的片子(产品演示、大屏录制、文章摘要片)一律自绘出片: Stepper::at_fps 固定步长遍历 t → f(t) 改场景属性 → render_to_rgba → stored PNG → 系统 ffmpeg(H.264 / 720p / 无音轨 / +faststart)。
  2. AI 生成只做一次性素材(灵感图、非关键配图),走 zjchina-agent::media 的闸门(默认关、审计只记元数据、单件 ≤3MB);产物入库同样必须过 gen-asset.sh。
  3. 出片规格与配图共用一条闸门:scripts/gen-asset.sh 的 720p/H.264/静音/faststart/≤3MB 全部复用,不为视频另立一套。
  4. 不做:不造动画 DSL、不做时间轴编辑器、不引 ffmpeg 绑定; 场景用现成的 Scene JSON + 单个插值量驱动,够用即止。

落地与验证

  • 采样驱动:zjchina-clip(工具 crate,独立 workspace)——读 scene JSON + 可选 delta 模板,用 zjchina-anim 的曲线在固定 fps 下逐帧渲染落 PNG;
  • 成片:scripts/clip.sh 调 ffmpeg 编码(PNG 走 image2pipe 直连,不落中间盘), 产物随后过 gen-asset.sh 的 install/check;
  • 首个真片:scripts/frames/2026-11-07-four-accounts.{scene,delta}.json(四本账摘要片, --anim "120,300,ease_in_out" / 8s / 30fps / 720p)。delta 每帧整段替换 world: overlay 是屏幕空间(y 向下),柱子属于相机空间(y 向上),只有 world 段能按基线向上生长;
  • 封面也归同一条链:scripts/frames/2026-11-07-four-accounts.cover.scene.json 静态帧 1600x900 → JPEG(原 AI 封面已换掉);资产的出处写进 manifest 的 source (self-drawn / ai / handmade,gen-asset.sh check 拦缺失),不再靠回忆区分谁做的;
  • 复现性锁:scripts/clip.sh --verify 同参数连跑两遍帧流比 sha256(四本账片首验 190935bf…,同机同字体同 GPU 后端逐字节一致);帧数必须等于 fps × seconds;
  • 验收探针:curl -r 0-1023 必须 206 + video/mp4(服务端 MIME/Range 已随 2026-11-07 的静态文件修复落地)。

日期:2026-11-07。拍板者 zj。

← 返回决策日志