commit 36fb403ae1f33500abe1f711576d5d3f0bdf72d5 Author: wehub-resource-sync Date: Mon Jul 13 12:30:22 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b31008c --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# macOS +.DS_Store +._* + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# Node +node_modules/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.npm +.yarn-integrity +package-lock.json +yarn.lock +pnpm-lock.yaml +.pnp.* + +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +.venv/ +venv/ +*.egg-info/ + +# Build / dist +dist/ +build/ +out/ +.next/ +.cache/ +.parcel-cache/ +*.tsbuildinfo + +# Env / secrets +.env +.env.* +!.env.example +*.pem + +# Wrangler / Cloudflare +.wrangler/ +.dev.vars + +# Local-only working files +scratch/ +tmp/ + +# Per-user runtime state (projects, assets, preview, MP4 outputs) +# This dir is created by the CLI / studio at runtime and is per-developer. +.html-video/ + +# Generated video output (large binaries) +*.mp4 +*.webm +*.mov +*.gif +output/ +renders/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c3e0886 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +auto-install-peers=true +strict-peer-dependencies=false +shamefully-hoist=false +prefer-workspace-packages=true diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md new file mode 100644 index 0000000..f552d52 --- /dev/null +++ b/ATTRIBUTIONS.md @@ -0,0 +1,65 @@ +# Attributions + +html-video is licensed under Apache-2.0. Some of its bundled video **templates** are +derived from open-source design skills under permissive licenses. This file records every +upstream we built on, its real copyright holder, and its license, as required by the +upstream licenses and by [RFC-07](research/2026-06-04-spec-07-ppt-to-template.md) (our +PPT-skill → template conversion standard). + +> **What "derived" means here**: we transformed static presentation/slide designs into +> animated [hyperframes](https://github.com/heygen-com/hyperframes) video templates — +> rebuilt as original CSS/SVG keyframe timelines, re-coloured/re-typed where noted, with +> our own sample data. No upstream source code is copied verbatim. The upstream licenses +> (MIT) permit commercial use and derivative works; we attribute them regardless. + +Many of these design styles are themselves homages to real-world design studios. Where a +template is inspired by a specific studio, we record that as the **L1 origin** in the +template's `provenance` block — as a factual statement of inspiration, never as a claim of +affiliation or endorsement. Studio names are **not** used as template names. + +--- + +## Upstream design skills + +### huashu-design + +- **Copyright holder**: alchaincyf (花叔 · 花生) © 2026 +- **License**: MIT +- **Source**: https://github.com/alchaincyf/huashu-design +- **Used by templates**: + - `frame-pentagram-stat` — via `assets/showcases/ppt/ppt-pentagram.html` + (L1 inspiration: Pentagram / Michael Bierut) + - `frame-build-minimal` — via `assets/showcases/ppt/ppt-build.html` + (L1 inspiration: Build, London design studio) + - `frame-takram-organic` — via `assets/showcases/ppt/ppt-takram.html` + (L1 inspiration: Takram, Japanese design firm) + +### frontend-slides + +- **Copyright holder**: Zara Zhang © 2025 +- **License**: MIT +- **Source**: https://github.com/zarazhangrui/frontend-slides +- **Used by templates**: + - `frame-bold-signal` — via `STYLE_PRESETS.md` (preset "Bold Signal") + - `frame-creative-voltage` — via `STYLE_PRESETS.md` (preset "Creative Voltage") + - `frame-electric-studio` — via `STYLE_PRESETS.md` (preset "Electric Studio") + + (These three are original presets composed by the skill author — no specific real-world + studio is the L1 origin.) + +--- + +## Studio names referenced as L1 inspiration + +The following real-world design studios / designers are named in template `provenance.origin` +purely as a record of stylistic inspiration. html-video is **not affiliated with, endorsed by, +or sponsored by** any of them. + +- **Pentagram** (and Michael Bierut) — pentagram.com +- **Build** — London design studio +- **Takram** — takram.com + +--- + +*Per-template details (the full three-layer `provenance` block: origin / via_skill / +transformation) live in each template's `template.html-video.yaml`.* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6cc625e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,223 @@ +# html-video 项目工作区 + +> Open-source HTML→Video meta-layer。让本地 coding agent 跨多个渲染 engine(Hyperframes / Remotion / Motion Canvas / Revideo)一站式做 HTML 视频。 +> 启动时间:2026-05-26(继 OD / HA / OD-pitch / OD-landing 之后的下一代 nexu-io 开源产品) + +## 角色与边界 + +- **角色**:T9(端口段 **3071-3079**)—— 现有 T1-T8 已分配 +- **职责**:html-video 项目的产品定位 / 架构 / engine 适配器 spec / 模板生态 / agent skill 设计 / 文档 / 公开 launch 物料 +- **不做**: + - 跨项目协调(T1 主控的活) + - OD 主线开发(T5 `open-design` 工作树) + - 增长策略 / OKR(T2 `opendesign-growth`) + +## 产品定位(决策时间线) + +- **2026-05-26 启动思路**:Joey 最初以为 Hyperframes 是闭源 SaaS,想做 "HTML 视频开源整合工具"。调研发现 Hyperframes 已经是 Apache-2.0 开源 + 21K★ + agent-native,跟 Joey 设想的产品 1:1 重合。 +- **2026-05-26 定位拍板**:放弃"做 HF 杀手"的正面竞争路线,改走 **Meta-aggregator** —— 把 HF / Remotion / Motion Canvas / Revideo 都包成可选 backend,agent 选 engine + 模板 + 一键出片。差异化明确,跟 HF 不正面撞。 + +### 关键差异化 vs Hyperframes + +| 维度 | Hyperframes | html-video | +|---|---|---| +| 渲染引擎 | 单一(GSAP+Puppeteer) | 多引擎 pluggable | +| Authoring 范式 | 单一(HTML+CSS+GSAP) | 跟随 backend(HTML/React/TS-generator 都行) | +| Agent 能力 | 自家 skill | 跨引擎 agent 决策 + skill 注入 | +| 模板生态 | 自家曲库 | 跨生态 curated + 社区贡献 | + +## 目录结构 + +``` +html-video/ +├── README.md 公开门面(对外,已立) +├── CLAUDE.md 本文件,内部 working notes +├── LICENSE Apache-2.0 +├── .gitignore 通用 +├── notes/ 迭代笔记 / RFC 草稿 / 决策记录 +├── research/ 竞品调研 / engine spec 草案 / API 探索 +└── assets/ 素材(截图 / 草图 / launch 物料) +``` + +代码骨架(adapter spec、CLI、studio 等)等架构定型后另起 `core/` `adapters/` `cli/` `studio/` 等目录。 + +## 当前状态(2026-05-28 v0.8 content-graph + multi-frame) + +v0.7 之前的进展见 git log。本节记 v0.8 落地的事 + 还没接的事。 + +- ✅ `@html-video/content-graph` 新 package:ContentGraph schema(entity/data/text 节点 + sequence/dependency/contrast 边)+ `validate` + `topoSort`(dependency 硬约束 / sequence 软排 / contrast 不参与排序)+ `totalDurationSec` +- ✅ core:`Project` 加 `contentGraphPath` + `frames[]`;`orchestrator` 加 `writeContentGraph` / `readContentGraph` / `writeFrameHtml`;`exportMp4` 多帧路径走 ffmpeg concat(缺 ffmpeg 时报友好错) +- ✅ studio agent:prompt 同时教单帧 fast path + 多帧(\`\`\`json#content-graph + \`\`\`html#<nodeId> 双块协议);server 解析后自动调 writeContentGraph + writeFrameHtml;无 graph 时回落 v0.7 +- ✅ studio UI:frames-strip 切帧 + graph viewer modal(JSON 只读 + 下载);单帧 fast path 时整条隐藏 +- ✅ 已 push `nexu-io/html-video` main(commit `149ace9`) +- ⏳ HF upstream 真实 render(v0.9?):adapter-hyperframes 仍是 stub,单帧/多帧 export MP4 都是空文件 +- ⏳ ffmpeg concat 真实跑通:依赖 ffmpeg 装好;adapter render 出真 MP4 之后才 end-to-end 有用 +- ⏳ RFC-06 正式文档稿(落 `research/2026-05-28-spec-06-content-graph.md`);目前规范散在 content-graph package README + smoke + agent prompt 三处 +- ⏳ studio 的 graph viewer 现在只读,未来可加可视化编辑(拖节点 / 加边) + +## 会话进度(2026-06-07)— Remotion 原生数据模板 + 按帧增强(Phase A+B 跑通,**git 待 Joey 定**) + +> 起点:Joey 修正定位——**Remotion 是用户主动按帧挂的"动效增强插件",hyperframes 是基座,AI 退到建议**(推翻 RFC-09 v0.1 的"AI 自动选引擎/引擎名隐形")。本轮做 RFC-08 **Phase 2 原生 tsx 数据动画** 的第一刀:CLI 层跑通混流 MP4。在 `feat/remotion-adapter` 分支(HEAD 原 `633a3dd` = Phase 1 桥接)。 + +**做完的(全部端到端真实渲染 + 量像素验证,非 mock):** +- ✅ 类型层(`packages/core/src/types/index.ts`):`TemplateRef` 加 `mode?:'bridge'|'native'` + `nativeCompositionId?`;`FrameRecord` 加 `engine?`/`nativeTemplateId?`/`data?`;`TemplateMetadata` 加 `native?:{compositionId}`。 +- ✅ 首个**原生 Remotion 模板** `templates/frame-data-rollup/`:`DataRollup.tsx`(柱子 `spring()` 生长 + 数字 `interpolate()` 0→目标滚动)+ `Root.tsx`(honor inputProps.width/height)+ `entry.ts` + yaml(`engine:remotion` / `native.compositionId:DataRollup` / provenance origin=none 原创)+ SKILL/example/package/preview.png。纯系统字体栈避黑屏。 +- ✅ `adapter-remotion/src/render.ts`:抽 `renderComposition()` helper;加 `mode==='native'` 分支(bundle 模板自己 entry / select compositionId / inputProps={data,width,height}、不跑 neutralize);`bundleCache` 单条 → `Map`(bridge+native 共存、原生模板一次 bundle 跨多帧复用)。 +- ✅ `core/src/project.ts`:export loop 按帧解析引擎(`resolveFrameTemplateRef`);`enhanceFrameNative(projectId,nodeId,nativeTemplateId)`(断言 kind=data、`normalizeRollupData` 归一 node.data、设三字段)+ `unenhanceFrame`(清三字段,htmlPath 不动=非破坏性);`concatFramesWithFfmpeg` 加 `reencode` 参数。 +- ✅ 文档纠正:RFC-09 升 v0.2(定位改"基座+主动增强+AI 建议");旧 notes `2026-06-06-remotion-user-experience-design.md` 顶部加作废声明指向 v0.2。 + +**Phase A 验收(单原生帧 vs 桥接对照,量像素)**:原生 native.mp4 非黑像素 **1.5%→8.8%→16.9%**(柱子在长)vs 桥接静态图 **22.6%×3 恒定**;同时刻 t=0.5 两者差 **56.7万像素**。铁证"原生数据动画 ≠ 桥接静态图"。6.2s 出 120 帧 1080p。 +**Phase B 验收(真实 orchestrator 3 帧混流)**:intro(hyperframes 蓝)→ middle(`enhanceFrameNative`→原生 Remotion 数据柱)→ outro(hyperframes 酒红),`exportMp4`→一条 **8.000s** MP4,全解码无错、单流、三段视觉各异。 + +**关键教训(新会话必读):** +- **混流 concat 不能用 concat demuxer + 重编码**:hyperframes 与 Remotion 段的 timebase/PTS 不兼容,concat demuxer 会**错误累加时间戳→8s 片变 35s**(`-vsync cfr` 也救不了,因为坏 PTS 已被喂进来)。**正解 = concat FILTER**:每段独立 `-i` + `-filter_complex "[0:v][1:v]...concat=n=N:v=1[v]"` 重建时间轴 → 精确 8s。单引擎仍走 demuxer `-c copy`(快、无损)。判混流类输出**必须 ffprobe 量时长 + `ffmpeg -f null -` 全解码**,不能只看文件生成。 +- **Remotion 能 bundle 模板自己的 `source/entry.ts`**(templates/ 下无独立 node_modules),webpack 能 resolve 到 workspace 根的 react/remotion——最大未知,已验证可行。原生模板做成 `templates/` 一等模板(不塞进 adapter 内部)这条路通。 +- **本机仍无 timeout 命令**(macOS);PIL 仍装不上(brew Python expat bug),量像素继续用 ImageMagick `magick ... -threshold -format '%[fx:100*mean]'`。 + +**待 Joey 定(git 写操作,未执行)**:① 本轮改动开 PR(按惯例对外可见/新模板走 PR review)还是直推;② 上一会话(6/6)遗留的 CLAUDE.md 39 行未提交记录怎么处理(CLAUDE.md 是追踪文件但属内部 notes,不宜混进代码 PR)。临时 driver 在 `~/Desktop/claude-code/scratch/hv-remotion-native/`(不进库)。 + +## 会话进度(2026-06-06)— 渲染 + studio 一连串修复,**4 个 PR 全部 merged 进 main** + +> 任务起点:"继续修字体跳闪"。顺着 Joey 的逐轮实测,从渲染引擎挖到 studio agent 交互/生成层, +> 修了 4 个 PR 全部合并。**全部端到端实调 Anthropic API 验证(非 mock)。** main 顶 = `1a83279`。 + +| PR | 修了什么 | merge commit | +|---|---|---| +| [#22](https://github.com/nexu-io/html-video/pull/22) | 字体跳闪(FOUT)+ 多 composition 完整播放 | `0029840` | +| [#23](https://github.com/nexu-io/html-video/pull/23) | 多帧改内容重生成整片 graph + 主题纠偏 + confirm 软校验 | `bf4b215`(merge `e340af1`) | +| [#24](https://github.com/nexu-io/html-video/pull/24) | 主题从开场原话贯穿到生成("随机"不跑题) | `a9c5325` | +| [#25](https://github.com/nexu-io/html-video/pull/25) | 生成后迭代子状态机 + 每帧时长硬约束 + 自由发挥推进 | `1a83279` | + +**改动文件**(都在 main):`packages/adapter-hyperframes/src/render.ts`(字体冻结、时长 explicit)、 +`packages/cli/src/studio-server.ts`(主题锁定、生成后迭代)、`packages/core/src/types/index.ts`+`project.ts`(durationMode)。 + +**#22 字体跳闪真根因(两轮才修对)**:① 第一版只加 `document.fonts.ready` 等待——不够,因为纯 CSS +`@keyframes` 动画在 `goto` 后**不等字体就自动跑**,等字体的 ~3s 连同 FOUT 一起被 playwright 录进开头, +单文件模板还不裁死区。② 正解:`addInitScript` 注入 `* {animation-play-state:paused}` 在文档解析前**冻结所有动画** +→ 等 stylesheet link + `fonts.load()` 每个 face + `fonts.ready` → 解冻并把冻结期当 leadInMs 裁掉。统一了单文件 + 多 composition 时序。 +对 CSS/rAF/setTimeout 三类驱动都验证首帧字体正确(`fonts.check()=true`)。 + +**#25 生成后迭代(Joey 反复反馈"后面指令全失效"的核心)**:根因 = `detectPhase` L1793 `hadGenerationYet()=true` +把**生成后任何消息硬判成 iterate**,iterate 只做单帧 preview.html 重写 → 假"✓ updated"。 +正解 = 卡片驱动子状态机:pin 帧→单帧编辑;明确说改风格/模板/时长/内容→直达对应卡;**其余一切(含模糊"改一下"/ +"换个模板重新生成")→ 默认弹 edit-menu 问清楚**。换风格走 restyle(复用现有 graph 文案、跳过重规划、仅按新风格逐帧重渲)。 + +### 关键教训(新会话必读) + +- **字体跳闪只在导出 mp4 复现**(后端冷 chromium 录屏);**studio iframe 实时预览有字体缓存,看不出**。验证载体只能是导出的 mp4,量早期帧(t≈0.1s)字体形态。本机 PIL 装不上(brew Python expat bug),量像素用 ImageMagick;颜色验证用 computed-style 而非截图(Playwright 截图在动画站超时)。 +- **studio agent 这种"判用户意图"的逻辑,别用白名单正则判触发**——"换个模板重新生成一下"就因不在白名单掉进单帧重写黑洞。正确思路是**反转默认**:默认走交互/重生成,只有明确的少数情况(pin 帧)才单帧。判 render/交互类修复**必须端到端实跑**,不能信"tsc 过 + 逻辑看着对"。 +- **`project.intent` 字段前端创建时不传**(只发 name),恢复开场主题靠 `history.find(role==='user')` 第一条。 +- **per_frame 时长**:render.ts 探测动画长度会无条件 extend 覆盖用户设定(4s→29.7s)。修法 = `RenderConfig.durationMode='explicit'`(多帧 export 传),explicit 时不 extend,ffmpeg `tpad=stop_mode=clone` 补尾帧到精确时长;单帧快速预览仍 'auto' 照常 extend。 +- **PR 合并流程坑(2026-06-06 踩过)**:PR #23 在我追加第二个 commit **之前**就被合并了(merge commit,非 squash,疑 Joey 手点或自动合),导致那个 commit 落空 → 只能 cherry-pick 到新分支另开 #24 补。教训:push 追加 commit 前先确认 PR 还 OPEN。Joey 偏好 **squash 合并**。 + +**会话尾已清理**:4 个 `fix/*` 分支(本地+远端)已删;本地 studio 3071/3075 + 画廊 3072 已关。 +诊断备份在 `notes/2026-06-06-studio-session-verify-bold/`(未追踪,含原始 messages/graph + DIAGNOSIS.md)。 + +**仍未追踪不进版本库**(Joey 未定):`research/render-all-previews.sh` / `repick-posters.sh` / `preview-renders/` / `preview-renders.old-*/` / spec-08/09 md / 上述 notes 目录。 + +## 会话进度(2026-06-05)— 新模板 frame-bold-poster,**已做完 + studio 验证,git 待定** + +> 上一会话在 `feat/template-bold-poster` 分支做了一个新模板 `frame-bold-poster`(暖白纸底 + +> 番红 + 巨型倾斜 Shrikhand 大字的 1970s 欧洲社论海报风),未提交即中断。本轮接续:核对 + 验证。 + +- ✅ 接续盘点:当前在 `feat/template-bold-poster` 分支(HEAD = main = origin/main = `cecbe0a`,三者同步)。 + 唯一在制品 = 未追踪目录 `templates/frame-bold-poster/`(template.html-video.yaml + SKILL.md + + example.md + package.json + preview.png + source/index.html)。**6/4 那轮 provenance 整改其实早已 + merge 进 main(commit `db31210`),下面 6/4 段里"待 commit"的说法已过时,勿再据此重复提交。** +- ✅ 核对新模板严格遵循 RFC-07:三层署名齐全 —— origin = "1970s 欧洲社论海报传统"(标 `kind: movement`, + 诚实地不伪造具体工作室)/ via_skill = frontend-slides · Zara Zhang · MIT · 指向具体上游 + `bold-template-pack/templates/bold-poster/design.md` / transformation 声明为原创 CSS keyframes、 + 调色与字体栈跟随上游 spec、**未照搬上游 HTML**。 +- ✅ 查重:与已有 `frame-bold-signal`(同样 frontend-slides 派生)**不撞车** —— bold-signal 是深色底 + `#1a1a1a` + Archivo Black + 橙红 `#FF5722`、源自 `STYLE_PRESETS.md` preset、origin=none; + bold-poster 是暖白 `#F5F2EF` + Shrikhand/Libre Baskerville 衬线 + 番红 `#D8000F`、源自具体上游模板。 +- ✅ 验证:① `pnpm -r build` 全过;② CLI `search-templates` 重读磁盘 → 27 模板齐全,bold-poster + 排第一(score 0.8)engine_installed=true license OK;③ 起 studio 到 **3071**(旧会话的 3074 还占着、 + 跑旧状态),`/api/templates` 确认 bold-poster present=true。`prefers-reduced-motion` 终态也处理了。 +- ⏳ **git 待定(Joey 6/5 决定先在本地 studio 看效果再说)**:本轮**不动 git**,未 commit / 未 push / + 未开 PR。待 Joey 在 studio 看完动效后,再决定 commit + 开 PR(按约定模板/公开内容走 PR review)。 + studio 运行中:`http://localhost:3071`(pid 见 `/tmp/hv-studio-3071.log`)。 + +## 会话进度(2026-06-04)— 模板来源/署名整改,**已完成并 merge 进 main(`db31210`)** + +> 起因:Joey 发现新加的模板与已有模板视觉撞车,且"原创"声明未经核实。 +> 做了一次全量来源审计 + 定转换规范 + 按规范整改了 6 个模板的署名。 + +**审计与规范(前半段):** +- ✅ 回退了今天误加的 3 个重复模板 commit(`abd8168`,曾是 build-metrics / pentagram-benchmark / + takram-radar)。`git reset --hard origin/main` 回到 26 个模板的干净状态,HEAD = `166fdc1`。 + → **结论:这 3 个是已有 huashu 系模板的"+3指标数据"换皮变体,不要再加回来。** +- ✅ 真实 clone 两个上游核实 license(都真是 MIT)+ 记下**真实版权人**: + - huashu-design → `alchaincyf(花叔 · 花生)` © 2026 + - frontend-slides → `Zara Zhang` © 2025 +- ✅ 关键发现:Pentagram / Build / Takram **不是 huashu 原创**,是 huashu 在致敬真实设计工作室 + (Pentagram=Michael Bierut / Build=伦敦工作室 / Takram=日本公司)。署名是**三层**不是两层。 + 且我们模板示例数据(95.7/73.8/AIME/SWE)是从上游 ppt 页照搬的。 +- ✅ 写了审计报告 `notes/2026-06-04-provenance-audit.md`(未提交) +- ✅ 写了转换规范 **`research/2026-06-04-spec-07-ppt-to-template.md`(RFC-07,未提交)** —— 含三层 + 署名 schema(origin/via_skill/transformation)+ 命名规范 + 转换质量门槛 + 查重 + 交付清单。 + Joey 已认可此规范,后续按它走。 +- ✅ 决定:**"默认渲染效果"不另做 loop.mp4**。studio 预览弹窗已用 `mode:'iframe'` 实时跑动画, + 用户在 studio 能直接看动效;preview.png 只做 studio 之外(README/官网)的静态兜底。 + +**整改(后半段,本轮做完)—— 6 个模板按 RFC-07 补三层署名,只补署名不改名:** +- ✅ 6 个模板的扁平 `provenance.inspired_by` 全部换成三层结构 + `origin`(L1 真实工作室)/ `via_skill`(L2 skill+真实作者全名+license+具体 source_file)/ `transformation`: + - huashu 系 source_file = `assets/showcases/ppt/ppt-{pentagram,build,takram}.html`, + origin 分别 = Pentagram(Michael Bierut) / Build(伦敦工作室) / Takram(日本公司)。 + - frontend-slides 系 source_file = `STYLE_PRESETS.md`(preset "Bold Signal"/"Creative Voltage"/ + "Electric Studio"),**origin 诚实标 `none`** —— 这三个是 skill 作者自组的原创 preset,没特定 L1 工作室。 + - via_skill.author 填真实全名:`alchaincyf (花叔 · 花生)` / `Zara Zhang`(上游 LICENSE 核实过)。 +- ✅ 遵守 Joey 决定(6/4):本轮**只补 provenance 三层 + 真实作者名,未改 id/显示名/示例数据** + (命名挪用工作室名的问题留下一轮)。 +- ✅ 新建根 `ATTRIBUTIONS.md` 汇总两个上游 + license + 每个模板的 L1/L2 映射 + "not affiliated" 声明。 +- ✅ 验证:① 用项目 `yaml@2.9.0` 直接解析 6 文件 → 三层字段齐全、无 `inspired_by` 残留、语法 OK; + ② 全新 CLI 进程 `search-templates` 重读磁盘 → 6 个模板全部正常加载(license/best_for/duration 都对); + ③ studio(localhost:3074) `/api/templates` 24 模板齐全。注:provenance **不进** API / UI 渲染路径 + (studio 只用 source HTML + inputs schema + poster),本轮没碰这些,渲染逻辑上不受影响。 + +**待办(下次接):** +- ~~commit + push~~ → **已于 6/4 完成并 merge 进 main(`db31210`)**,此条作废。 +- ⏳ 下一轮(独立):按 RFC-07 ③ 给 6 个模板**重命名**(去掉挪用的工作室名,改设计特征中性名, + 如 `frame-editorial-anchor` / `frame-luxe-minimal` / `frame-soft-radar`),涉及改 id 要同步 source 目录名 + 引用。 + +> 笔误备忘:本项目正确写法是 **hyperframes**(不是 hiframes/HiFrames),文档里如再见到要改。 +> 上游 clone 仍在 `~/Desktop/claude-code/scratch/upstream-check/`(huashu-design + frontend-slides,可能被定期清理)。 + +## 跑起来 + +```bash +cd ~/Desktop/claude-code/projects/html-video +pnpm install +pnpm -r build +pnpm --filter @html-video/cli smoke + +# CLI 直接跑 +./packages/cli/dist/bin.js doctor +./packages/cli/dist/bin.js search-templates --intent "github stars" --top 3 +``` + +## 与姊妹项目的关系 + +| 项目 | 角色 | 关系 | +|---|---|---| +| `open-design` (T5) | OD 主线 maintainer 工作树 | 设计资产侧;本项目是视频侧;同 nexu-io org,可以共享部分 skill 基建 | +| `html-anything` | OD 的 sister 开源(HTML 模板/页面) | 命名同 family("HTML X"),定位互补:HA 做 HTML 静态页、本项目做 HTML 视频 | +| `od-pitch` (T6) | 路演 deck | 启动时如果做 launch deck,可以复用 od-pitch 的 magazine/recruiting 同款风格 | +| `growth-dashboard` (T7) | 数据罗盘 | 上线后增加 html-video 的 stars/forks/issues 监控 | + +## 写操作守则 + +- 任何对外 publish 动作(CF Pages / GitHub repo create / 推 main / launch tweet / 公众号)**每次都先告诉 Joey** +- 改动 README.md 这种"公开门面"前先 review,避免误更新对外定位 +- 第一次 push 到 nexu-io/html-video 是高敏感动作,要 Joey 明确点头 + 选好 launch 时机 + +## 命名 & 标语备忘 + +- 项目名:`html-video` +- 公司名 / org: `nexu-io` +- 推荐 tagline 候选: + - "HTML→Video meta-layer for coding agents"(直白) + - "Bring your agent. Pick any engine. Render any video."(口号化) + - "One agent, every HTML video engine."(最短) +- 选哪个等公开发布前定 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..702476e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,502 @@ +# Contributing to html-video + +> **English** · [中文](#中文) + +Thank you for your interest in contributing! html-video is an Apache-2.0 project by the [Open Design](https://github.com/nexu-io/open-design) team. We welcome contributions of all kinds — code, docs, templates, bug reports, and ideas. + +--- + +## Community + +- **Discord**: [Join the Open Design Discord](https://github.com/nexu-io/open-design#community) — the main hub for questions, design discussions, and real-time help. +- **X (Twitter)**: Follow [@nexudotio](https://x.com/nexudotio) for project updates. +- **GitHub Issues**: Bug reports, feature requests, and template proposals all go here. + +--- + +## Development Setup + +### Prerequisites + +| Requirement | Minimum Version | How to check | +|---|---|---| +| **Node.js** | 20+ | `node --version` | +| **pnpm** | 9+ | `pnpm --version` | +| **ffmpeg** | Any recent | `ffmpeg -version` | +| **Chromium** (or Playwright browsers) | See below | `npx playwright install chromium` | + +**Why Chromium?** The default [Hyperframes](https://github.com/heygen-com/hyperframes) engine renders videos by recording animated HTML in a headless Chromium browser. You need either: + +- A system Chromium/Chrome install (auto-detected), or +- Playwright's bundled Chromium: `npx playwright install chromium` + +**Why ffmpeg?** After recording each frame as WebM, ffmpeg encodes them to MP4 (libx264) and concatenates them into the final video. It's also used for the optional AI soundtrack mixing. + +### Clone, Install, Build + +```bash +git clone https://github.com/nexu-io/html-video.git +cd html-video +pnpm install +pnpm -r build +``` + +This is a **pnpm workspace monorepo**. All packages live under `packages/` and templates under `templates/`. The `pnpm -r build` command builds every package in dependency order. + +### Run Locally + +**Studio (browser UI):** + +```bash +node packages/cli/dist/bin.js studio +# Opens at http://127.0.0.1:3071 +``` + +**CLI tools:** + +```bash +# Check what's installed and ready +node packages/cli/dist/bin.js doctor + +# Search templates by intent +node packages/cli/dist/bin.js search-templates --intent "data chart" --top 5 +``` + +--- + +## Project Structure + +``` +packages/ +├── core/ Types, registries, orchestrator, MiniMax + ffmpeg audio +├── content-graph/ Multi-frame storyboard IR (nodes + edges, topo-sort) +├── runtime/ Agent runtime — detect / spawn / stream (13 agents) +├── adapter-hyperframes/ Hyperframes engine adapter — Chromium + ffmpeg render +├── cli/ `html-video` command + studio HTTP server + source fetching +└── project-studio/ Browser studio UI (chat, gallery, frames, soundtrack, export) +templates/ 21 curated, license-clean video templates +research/ RFCs (engine adapter / template metadata / agent skill / content-graph) +``` + +--- + +## How to Add a New Agent Runtime + +Adding support for a new coding agent is the most common contribution. It's a self-contained change in `packages/runtime/`. + +**Pattern (from Trae CLI PR #12):** + +### 1. Create the agent definition file + +`packages/runtime/src/defs/.ts`: + +```ts +import type { AgentDef } from '../types.js'; + +export const myAgent: AgentDef = { + id: 'my-agent', // kebab-case, stable + name: 'My Agent', // Human-readable + bin: 'my-agent-cli', // CLI binary name (looked up on PATH) + versionArgs: ['--version'], // Args to check version (for `doctor`) + buildArgs(prompt, ctx) { // Build spawn arguments + return ['--print', prompt]; + }, + streamFormat: 'plain', // 'plain' | 'claude-stream' | 'json-event-stream' | 'acp-json-rpc' + promptViaStdin: false, // true if sending prompt via stdin + installUrl: 'https://example.com/install', // Where users can get it +}; +``` + +See `packages/runtime/src/types.ts` for the full `AgentDef` interface — it supports ACP JSON-RPC agents, HTTP-based agents (like the Anthropic API), binary fallbacks, and extra availability checks. + +### 2. Register the agent + +In `packages/runtime/src/registry.ts`, import and add your agent to the `AGENT_DEFS` array: + +```ts +import { myAgent } from './defs/my-agent.js'; + +export const AGENT_DEFS: AgentDef[] = [ + // ... existing agents + myAgent, +]; +``` + +Order matters: the first available agent is the default selection in the studio. + +### 3. Test + +```bash +pnpm --filter @html-video/runtime build +node packages/cli/dist/bin.js doctor # Your agent should appear if its binary is on PATH +``` + +--- + +## How to Add a New Template + +Templates live under `templates//` and are described by a `template.html-video.yaml` manifest. The studio scans templates at startup and the agent reads the manifest to understand what the template does and what inputs it needs. + +### Minimum structure + +``` +templates/frame-my-cool-animation/ +├── template.html-video.yaml # Required — see format below +├── source/index.html # Required — the animated HTML (Hyperframes engine) +├── SKILL.md # Agent-readable instructions for filling in the template +├── example.md # Example input +└── poster.svg / preview.png # Static preview image +``` + +### Provenance rules (RFC-07) + +Every template MUST follow the [RFC-07 provenance rules](research/2026-06-04-spec-07-ppt-to-template.md): + +1. **License gate**: Only permissive open-source licenses (MIT, Apache-2.0, BSD, CC-BY, CC-BY-SA). No NC, ND, or unlicensed sources. +2. **Three-layer attribution**: L1 (original design studio/designer) → L2 (skill/upstream author) → L3 (our transformation). All three must be recorded in `provenance`. +3. **Naming**: Use descriptive feature names, not studio/designer names. ❌ `frame-pentagram-stat` → ✅ `frame-editorial-anchor` +4. **Transformation quality**: Must add real animation timeline, use own sample data, and have identifiable redesign from the upstream source. +5. **Deduplication**: Check against existing templates from the same upstream. Don't ship near-identical variants. + +### manifest.yaml skeleton + +```yaml +spec_version: 1 +id: frame-my-cool-animation +name: My Cool Animation +description: A short description for agents and the gallery. +engine: hyperframes +category: title-card +tags: [animation, reveal] +best_for: + - "Product launch teasers" + - "Social media shorts" +inputs: + schema: + type: object + required: [title] + properties: + title: { type: string, description: "Main headline" } + subtitle: { type: string, description: "Subtitle line" } + examples: + - title: "Hello World" + subtitle: "This is an example" +output: + formats: [mp4] + default_format: mp4 + duration: { type: variable, min_sec: 3, max_sec: 15 } +license: + spdx: Apache-2.0 + attribution_required: false + redistribution_allowed: true + commercial_use: true +provenance: + origin: + name: "Original Designer / Studio" + kind: design-studio + reference: "https://example.com" + via_skill: + name: upstream-skill-name + author: "Author Name" + url: https://github.com/author/upstream + license: MIT + source_file: path/to/source.html + transformation: > + Static design → animated Hyperframes timeline with CSS @keyframes. + Re-colored, original sample data. +``` + +See [RFC-02](research/2026-05-26-spec-02-template-metadata.md) for the complete `template.html-video.yaml` specification. + +--- + +## How to Add a New Engine Adapter + +The engine adapter interface ([RFC-01](research/2026-05-26-spec-01-engine-adapter.md)) lets any video rendering backend plug into html-video. The shipped adapter is `@html-video/adapter-hyperframes` — use it as the reference implementation. + +### 1. Create a new package + +``` +packages/adapter-/ +├── package.json +├── src/ +│ ├── index.ts # Export default EngineAdapter instance +│ ├── capabilities.ts # Static capability declaration +│ ├── validate.ts # Validate a template for this engine +│ └── render.ts # Core render implementation +└── tsconfig.json +``` + +### 2. Implement the EngineAdapter interface + +The core contract is defined in `packages/core/src/types.ts`: + +```ts +export interface EngineAdapter { + id: EngineId; + name: string; + upstreamVersion: string; + capabilities: EngineCapabilities; + validate(template: Template): ValidationResult; + render(input: RenderInput, ctx: RenderContext): Promise; + preview?(template: Template, ctx: PreviewContext): Promise; + listNativeTemplates?(): Promise; +} +``` + +Key conventions from RFC-01: + +- **Process isolation**: Each `render()` spawns an independent subprocess. Subprocess crashes must reject the promise and leave no partial output files. +- **Progress reporting**: 0-100% based on current frame / total frames. Stage hints: `preparing` (0-10%), `rendering` (10-95%), `muxing` (95-100%). +- **Cancellation**: Respect `ctx.signal.aborted` — kill subprocess, cleanup workDir temp files, reject with `AbortError`. +- **Package naming**: `@html-video/adapter-`, peer-depend on the upstream engine. + +### 3. Register in core + +The core dynamically loads adapters — once your package is in `packages/` and listed in `pnpm-workspace.yaml`, it will be auto-discovered at runtime. + +--- + +## Code Style + +- **Language**: TypeScript (strict mode, `tsconfig.base.json` inheritance) +- **Formatting**: [Biome](https://biomejs.dev/) — 2 spaces, single quotes, trailing commas, semicolons, LF line endings. Run `pnpm format` to auto-format. +- **Linting**: `pnpm lint` runs Biome linter with recommended rules. +- **Monorepo tooling**: pnpm workspaces, `pnpm -r build` builds all packages in order. +- **Imports**: Use `.js` extensions in TypeScript imports (for ESM compatibility). + +### Before submitting + +```bash +pnpm typecheck # TypeScript across all packages +pnpm lint # Biome linter +pnpm format # Auto-format all files +pnpm test # Run all tests +``` + +--- + +## How to Submit a Pull Request + +1. **Fork** the repository and create a branch from `main`. +2. **Make your changes** — keep them focused. One PR = one logical change. +3. **Test your changes** — run `pnpm typecheck && pnpm lint && pnpm test`. +4. **Write a clear commit message** — follow conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. +5. **Open a PR** against `nexu-io/html-video:main`. +6. **Describe** what you changed, why, and how to verify it. + +We review PRs regularly. If your PR adds a new feature, link to or include related tests. + +--- + +## License + +By contributing, you agree that your contributions will be licensed under the [Apache-2.0 License](LICENSE) — same as the rest of the project. No contributor license agreement (CLA) is required. + +--- + +## Questions? + +- **Real-time chat**: Join the [Open Design Discord](https://github.com/nexu-io/open-design#community) +- **Bugs & features**: Open an issue on [GitHub](https://github.com/nexu-io/html-video/issues) +- **Design decisions**: Read the RFCs in [`research/`](research/) + +--- + +## 中文 {#中文} + +感谢你有意为 html-video 贡献!html-video 是 [Open Design](https://github.com/nexu-io/open-design) 团队维护的 Apache-2.0 项目。我们欢迎所有形式的贡献 —— 代码、文档、模板、bug 反馈和想法。 + +### 社区 + +- **Discord**:[加入 Open Design Discord](https://github.com/nexu-io/open-design#community) — 主要的问答、设计讨论和实时求助渠道。 +- **X(Twitter)**:关注 [@nexudotio](https://x.com/nexudotio) 获取项目动态。 +- **GitHub Issues**:提 bug、功能建议、模板提案都来这里。 + +### 开发环境搭建 + +#### 前置依赖 + +| 依赖 | 最低版本 | 检查方式 | +|---|---|---| +| **Node.js** | 20+ | `node --version` | +| **pnpm** | 9+ | `pnpm --version` | +| **ffmpeg** | 任意较新版本 | `ffmpeg -version` | +| **Chromium**(或 Playwright 浏览器)| 见下文 | `npx playwright install chromium` | + +**为什么需要 Chromium?** 默认的 [Hyperframes](https://github.com/heygen-com/hyperframes) 引擎用无头 Chromium 录制带动画的 HTML 来渲染视频。你需要以下之一: + +- 系统安装的 Chromium/Chrome(自动检测),或 +- Playwright 内置的 Chromium:`npx playwright install chromium` + +**为什么需要 ffmpeg?** 渲出每帧 WebM 后,ffmpeg 将它们编码为 MP4(libx264)再拼接成最终视频。可选的 AI 配乐混音也要用到它。 + +#### 克隆、安装、构建 + +```bash +git clone https://github.com/nexu-io/html-video.git +cd html-video +pnpm install +pnpm -r build +``` + +这是 **pnpm workspace 单体仓库**。所有包在 `packages/` 下,模板在 `templates/` 下。`pnpm -r build` 按依赖顺序构建所有包。 + +#### 本地运行 + +**Studio(浏览器界面):** + +```bash +node packages/cli/dist/bin.js studio +# 在 http://127.0.0.1:3071 打开 +``` + +**CLI 工具:** + +```bash +# 查看已安装并可用的 agent 和引擎 +node packages/cli/dist/bin.js doctor + +# 按意图搜索模板 +node packages/cli/dist/bin.js search-templates --intent "数据图表" --top 5 +``` + +### 如何添加新的 Agent 运行时 + +添加对新 coding agent 的支持是最常见的贡献类型。改动集中在 `packages/runtime/` 内。 + +**模式(参考 Trae CLI PR #12):** + +#### 1. 创建 agent 定义文件 + +`packages/runtime/src/defs/.ts`: + +```ts +import type { AgentDef } from '../types.js'; + +export const myAgent: AgentDef = { + id: 'my-agent', // kebab-case,稳定不变 + name: 'My Agent', // 可读名称 + bin: 'my-agent-cli', // CLI 二进制名(在 PATH 上查找) + versionArgs: ['--version'], // 检查版本的参数(给 `doctor` 用) + buildArgs(prompt, ctx) { // 构建启动参数 + return ['--print', prompt]; + }, + streamFormat: 'plain', // 'plain' | 'claude-stream' | 'json-event-stream' | 'acp-json-rpc' + promptViaStdin: false, // 是否通过 stdin 传递 prompt + installUrl: 'https://example.com/install', // 安装指引链接 +}; +``` + +完整 `AgentDef` 接口见 `packages/runtime/src/types.ts` —— 它支持 ACP JSON-RPC agent、基于 HTTP 的 agent(如 Anthropic API)、二进制回退路径和额外的可用性检查。 + +#### 2. 注册 agent + +在 `packages/runtime/src/registry.ts` 中 import 并将你的 agent 加入 `AGENT_DEFS` 数组: + +```ts +import { myAgent } from './defs/my-agent.js'; + +export const AGENT_DEFS: AgentDef[] = [ + // ... 已有 agent + myAgent, +]; +``` + +顺序很重要:第一个可用的 agent 是 studio 的默认选项。 + +#### 3. 测试 + +```bash +pnpm --filter @html-video/runtime build +node packages/cli/dist/bin.js doctor # 如果二进制在 PATH 上,你的 agent 应该出现 +``` + +### 如何添加新模板 + +模板放在 `templates//` 下,由 `template.html-video.yaml` 清单描述。studio 启动时扫描模板,agent 读取清单来了解模板用途和输入需求。 + +#### 最小目录结构 + +``` +templates/frame-my-cool-animation/ +├── template.html-video.yaml # 必选 —— 格式见下文 +├── source/index.html # 必选 —— 带动画的 HTML(Hyperframes 引擎) +├── SKILL.md # Agent 可读的填参说明 +├── example.md # 示例输入 +└── poster.svg / preview.png # 静态预览图 +``` + +#### 来源规范(RFC-07) + +每个模板必须遵守 [RFC-07 来源规范](research/2026-06-04-spec-07-ppt-to-template.md): + +1. **许可闸门**:只收明确宽松开源的许可(MIT、Apache-2.0、BSD、CC-BY、CC-BY-SA)。不收 NC、ND 或无许可的来源。 +2. **三层署名**:L1(原始设计工作室/设计师)→ L2(skill/上游作者)→ L3(我们的转化)。三层都必须记在 `provenance` 里。 +3. **命名**:用描述设计特征的名字,不要挪用工作室/设计师名。❌ `frame-pentagram-stat` → ✅ `frame-editorial-anchor` +4. **转化质量**:必须新增真实的动效时间线、用自有示例数据、相比上游有可辨别的再设计。 +5. **查重**:跟同一上游来源的已有模板比对,不提交几乎一样的变体。 + +### 如何添加新引擎适配器 + +引擎适配器接口([RFC-01](research/2026-05-26-spec-01-engine-adapter.md))让任何视频渲染后端都能接入 html-video。已发布的适配器是 `@html-video/adapter-hyperframes` —— 用它作为参考实现。 + +#### 1. 创建新包 + +``` +packages/adapter-/ +├── package.json +├── src/ +│ ├── index.ts # 导出默认 EngineAdapter 实例 +│ ├── capabilities.ts # 静态能力声明 +│ ├── validate.ts # 校验模板能否被本引擎渲染 +│ └── render.ts # 核心渲染实现 +└── tsconfig.json +``` + +#### 2. 实现 EngineAdapter 接口 + +核心契约定义在 `packages/core/src/types.ts`,详见 [RFC-01](research/2026-05-26-spec-01-engine-adapter.md)。关键约定: + +- **进程隔离**:每次 `render()` 启动独立子进程。子进程崩溃必须 reject promise,不留下不完整的输出文件。 +- **进度报告**:按当前帧/总帧数算 0-100%。阶段提示:`preparing`(0-10%)、`rendering`(10-95%)、`muxing`(95-100%)。 +- **取消**:响应 `ctx.signal.aborted` —— 杀掉子进程,清理 workDir 临时文件,reject `AbortError`。 +- **包命名**:`@html-video/adapter-`,peer-depend 上游引擎。 + +### 代码风格 + +- **语言**:TypeScript(strict 模式,继承 `tsconfig.base.json`) +- **格式化**:[Biome](https://biomejs.dev/) —— 2 空格缩进、单引号、尾逗号、分号、LF 换行。运行 `pnpm format` 自动格式化。 +- **Lint**:`pnpm lint` 用 Biome linter 的推荐规则。 +- **单体仓库工具链**:pnpm workspace,`pnpm -r build` 按顺序构建所有包。 +- **Import**:TypeScript import 使用 `.js` 扩展名(ESM 兼容)。 + +#### 提交前检查 + +```bash +pnpm typecheck # 全仓库 TypeScript 检查 +pnpm lint # Biome linter +pnpm format # 自动格式化所有文件 +pnpm test # 运行所有测试 +``` + +### 如何提交 Pull Request + +1. **Fork** 仓库,从 `main` 创建分支。 +2. **做出改动** —— 保持聚焦。一个 PR = 一个逻辑变更。 +3. **测试你的改动** —— 运行 `pnpm typecheck && pnpm lint && pnpm test`。 +4. **写清楚的 commit message** —— 遵循 conventional commits:`feat:`、`fix:`、`docs:`、`refactor:`、`test:`、`chore:`。 +5. **发起 PR** 到 `nexu-io/html-video:main`。 +6. **描述**你改了什么、为什么、怎么验证。 + +我们会定期 review PR。如果你的 PR 加了新功能,请附带或链接相关测试。 + +### 许可 + +贡献即表示你同意你的贡献将按照 [Apache-2.0 许可](LICENSE) 授权 —— 和项目其他部分一致。不需要签署贡献者协议(CLA)。 + +### 有问题? + +- **实时聊天**:加入 [Open Design Discord](https://github.com/nexu-io/open-design#community) +- **Bug 和功能**:在 [GitHub](https://github.com/nexu-io/html-video/issues) 提 issue +- **设计决策**:阅读 [`research/`](research/) 中的 RFC diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a238f81 --- /dev/null +++ b/README.md @@ -0,0 +1,289 @@ +# html-video + +

+ html-video — HTML becomes video, on your laptop +

+ +> **HTML becomes video — on your laptop.** Bring your local coding agent (Open Design · Windsurf CLI · Trae CLI · Claude Code · Cursor · Codex · Gemini · Grok · Qwen · OpenCode · Copilot · Aider · Hermes · or the Anthropic API). Describe a video, or **paste an article link / GitHub repo**, and the agent turns it into a multi-frame, fully animated video — then renders it to a real MP4 right on your machine. One agent loop, pluggable rendering engines, a curated template gallery, optional AI soundtrack. Apache-2.0, no per-render fees, no vendor lock-in. + +

+ License + Agents + Templates + Sources + Soundtrack + Quickstart +

+ + +

+ Discord + Follow @nexudotio on X + By the Open Design team +

+ +

+ An official project by the Open Design team · open-design.ai +

+ +

English · 简体中文

+ +--- + +## Showcase + +Every template below is a real, animated single-file HTML video — these are live renders, not mockups. Drop one in, let the agent fill it with your content, export to MP4. + + + + + + + + + + + + + + + + + + + + + + + + + + +
NYT-style data chartGlitch title
frame-data-chart-nyt · data-viz
Editorial NYT-style animated line chart — headline, annotated data points, source line. For "the number went up" stories.
frame-glitch-title · title card
Chromatic-aberration glitch title with scanlines. For openers, drops, and "system online" energy.
Liquid background heroLight leak cinema
frame-liquid-bg-hero · hero
Aurora liquid-gradient hero with a centered headline. For product reveals and bold statements.
frame-light-leak-cinema · cinematic
Warm film-grain + light-leak cinematic frame. For mood, brand films, "a quiet year" storytelling.
Typewriter cursor VFXLogo outro
vfx-text-cursor · VFX
Typewriter text with a blinking terminal cursor. For code-style reveals and CLI demos.
frame-logo-outro · outro
Clean animated logo end card. For sign-offs and brand stamps at the end of any video.
+ +…and 15 more, including multi-scene product promos, kinetic type, Swiss-grid and Vignelli data cards, decision-tree explainers, Takram-organic motion, and warm-grain editorial. Browse all 21 live in the studio gallery. + +--- + +## Why this exists + +HTML→Video is a real category — but every engine is opinionated, and each wants you to learn *its* authoring model: + +| Engine | Paradigm | Tradeoff | In html-video | +|---|---|---|---| +| [Hyperframes](https://github.com/heygen-com/hyperframes) | HTML + CSS + GSAP, agent-skill driven | Single rendering paradigm | ✅ **Shipped** — the default engine; renders real MP4 via headless Chromium + ffmpeg | +| [Remotion](https://www.remotion.dev/) | React components | Source-available, paid above 4 devs | 🗺️ Planned | +| [Motion Canvas](https://github.com/motion-canvas/motion-canvas) · [Revideo](https://github.com/redotvideo/revideo) | TypeScript generators on canvas | Best for explainers, code-first | 🗺️ Planned | +| [Manim](https://github.com/3b1b/manim) & friends | Math / 3D first | Niche | 🗺️ Researching | + +Picking the right engine per use case, learning each model, and stitching them into one workflow costs real engineering time. Most teams pick one and live with its limits. + +**html-video is the meta-layer that sits above all of them.** You talk to your agent; it picks the engine, picks the template, fills in your content, and renders the video. The engine is an implementation detail behind a single adapter interface — one `render(input, ctx)` contract that any backend can satisfy. Add a new engine and every template, every agent, and the whole studio workflow get it for free. No new DSL to learn, no rewrite when you switch engines. + +The same idea powers [Open Design](https://github.com/nexu-io/open-design) in the *design* space — an agent meta-layer over many tools. html-video is the *motion* counterpart from the same team. + +> **Status:** the pluggable-engine architecture is in place, and the **Hyperframes engine is fully wired up and renders real MP4** — headless Chromium records the animated HTML frame-by-frame and ffmpeg encodes it (libx264). Remotion, Motion Canvas / Revideo, and Manim are on the roadmap: the adapter interface is designed for them, but their adapters aren't built yet. The "In html-video" column above is the single source of truth for what's actually runnable today. + +--- + +## At a glance + +| | | +|---|---| +| **Coding agents (14)** | Open Design (Vela) · Windsurf CLI · Trae CLI · Claude Code · Cursor Agent · Codex CLI · Gemini CLI · Grok Build · Qwen Code · OpenCode · GitHub Copilot CLI · Aider · Hermes · Anthropic Messages API — auto-detected on your `PATH`, switchable from the top bar. | +| **Real MP4 render** | Headless Chromium records the animated HTML and ffmpeg encodes it (libx264) — locally, no cloud render, no per-clip fee. | +| **Article / repo → video** | Paste a URL or GitHub repo; the studio fetches it server-side (handles WeChat 公众号 articles) and builds the video from the real content. | +| **21 templates** | Curated, license-clean patterns: data viz, product promos, social shorts, explainers, kinetic type, transitions — previewed live in the gallery. | +| **Multi-frame storyboards** | A content-graph drives multi-scene videos; edit per-frame text inline, reorder, re-render. | +| **AI soundtrack** | Optional background music + narration via MiniMax, mixed into the MP4 at export. | +| **Studio + CLI** | A local browser studio *and* a scriptable `html-video` CLI. | +| **License** | Apache-2.0 — no per-render fees, no seat caps, no contributor agreements. | + +--- + +## How it works + +One sentence (or one link) goes in; a real MP4 comes out. The pipeline is the same whether you start from a prompt, an article, or a repo: + +``` + prompt / link / repo + │ + ▼ + ① source fetch studio pulls the URL or repo server-side, flattens it to Markdown + │ + ▼ + ② agent loop your agent reads the material + the picked template's style and emits + │ a content-graph (the storyboard) + one HTML block per frame + ▼ + ③ content-graph multi-frame IR — nodes (entity / data / text) + edges (sequence / + │ dependency / contrast); topo-sorted into frame order & timing + ▼ + ④ per-frame HTML each node becomes a self-contained animated HTML frame on disk + │ + ▼ + ⑤ Hyperframes render headless Chromium loads each frame, records it (auto-extending to + │ cover the frame's own animation), → webm per frame + ▼ + ⑥ ffmpeg each webm → mp4 (libx264), then concat into one video; + │ optional MiniMax music + narration mixed in + ▼ + your.mp4 +``` + +Steps ②–④ are where the "meta-layer" lives: the agent decides the storyboard and the engine decides how to draw it, and neither leaks into the other. Step ⑤ is engine-specific — swapping in Remotion or Motion Canvas later replaces only that box, leaving the storyboard and the agent loop untouched. Everything runs on your machine; the only network calls are the optional source fetch and the optional soundtrack. + +Single-frame videos take a fast path that skips the content-graph — one template, one HTML, straight to render. + +--- + +## Turn a link into a video + +This is what most people reach for: hand your agent a link, get a video back. The agents run as local CLIs with no network access of their own, so the studio fetches the source **server-side** and feeds the real content into the generation prompt — no copy-pasting article bodies, and pages behind a login-free server render (like WeChat 公众号) just work. + +``` +You: 做一个解读视频 https://mp.weixin.qq.com/s/… +Agent: 好,我读完了《用嘴剪视频的时代来了?…》这篇文章 — 这就基于它生成。下一步选风格。 +→ multi-frame explainer, built from the article's actual points +``` + +- **Web article** → fetched and flattened to Markdown. Server-rendered pages like **WeChat 公众号** articles work out of the box. +- **GitHub repo** → description, top-level structure, and README pulled via the public API — great for "explain this open-source project" videos. +- **Just a prompt** → describe the topic and the agent writes the content from scratch. + +Whatever the source, it becomes the material the video is actually built from — not decoration around a canned template. The agent reads the fetched content, decides how many scenes it needs, and writes a **content-graph storyboard**: the key points become frames, the relationships between them (this follows that, this contrasts with that) become edges, and the picked template's visual style is applied per frame. So a 1,500-word article turns into a paced multi-scene explainer whose every line traces back to something in the source, and a repo turns into a structured walkthrough of what the project actually is. + +--- + +## Quick start + +### Prerequisites + +| Requirement | Minimum | Check | +|---|---|---| +| **Node.js** | 20+ | `node --version` | +| **pnpm** | 9+ | `pnpm --version` | +| **ffmpeg** | Any recent | `ffmpeg -version` | +| **Chromium** (or Playwright browsers) | — | `npx playwright install chromium` | + +The default rendering engine records animated HTML in a headless Chromium browser, then uses ffmpeg (libx264) to encode MP4. Install Playwright's Chromium if you don't have a system install: + +```bash +npx playwright install chromium +``` + +### Install & run + +```bash +pnpm install +pnpm -r build +node packages/cli/dist/bin.js studio # opens the studio at http://127.0.0.1:3071 +``` + +In the studio: pick a template (or just describe a video / paste a link), chat with your agent, edit per-frame text, add a soundtrack, and export MP4. + +CLI utilities: + +```bash +node packages/cli/dist/bin.js doctor # detect installed agents + engines +node packages/cli/dist/bin.js search-templates --intent "github stars race" --top 3 +``` + +--- + +## Supported agents + +Auto-detected on your `PATH`; switch the active one from the studio's top bar. The studio leads with **Open Design (Vela)** — one login, many models, lower cost — then falls back to the first *available* agent so a fresh project always has a working backend. + +| Agent | Detection | Invocation | +|---|---|---| +| **Open Design (Vela)** | `vela` / bundled in the Open Design app | ACP over stdio — one login in Open Design, pick any model | +| **Windsurf CLI** | `windsurf` | `windsurf --yolo`, ACP over stdio | +| **Trae CLI** | `traecli` | `traecli acp serve --yolo`, ACP over stdio | +| **Claude Code** | `claude` | `claude --print`, prompt via stdin | +| **Cursor Agent** | `cursor-agent` | `cursor-agent --print` | +| **Codex CLI** | `codex` | `codex exec`, prompt via stdin | +| **Hermes** | `hermes` | Hermes ACP CLI | +| **Gemini CLI** | `gemini` | Prompt via stdin | +| **Grok Build** | `grok` | `grok -p ` | +| **Qwen Code** | `qwen` | Prompt via stdin | +| **OpenCode** | `opencode` | `opencode run`, prompt via stdin | +| **GitHub Copilot CLI** | `copilot` | `copilot --allow-all-tools`, prompt via stdin | +| **Aider** | `aider` | `aider --message ` | +| **Anthropic API** | BYOK | Direct Messages API — works with no CLI installed | + +Nothing installed? Set an Anthropic key and the studio talks to the Messages API directly. + +--- + +## Soundtrack + +Give the finished video a voice. In **Settings → Audio**, add a MiniMax API key, then in the per-project **Soundtrack** panel: + +- **Background music** — describe a mood (`calm cinematic ambient, slow build`); MiniMax generates an instrumental track. +- **Narration** — type a script; MiniMax reads it (TTS). + +Both are mixed into the exported MP4 (music ducked under the voice, optional fade-in/out) via ffmpeg. No key configured? The rest of the studio works unchanged. + +--- + +## Template gallery + +The 21 templates aren't a random grab-bag — each one is a self-contained, agent-readable unit described by a `template.html-video.yaml` manifest the studio scans at startup. A manifest carries everything the agent needs to pick and drive the template without opening the HTML: + +- **What it's for** — `category`, `tags`, and a `best_for` list (e.g. *"Corporate slide"*, *"Minimal report card"*) that `search-templates` matches your intent against. +- **What it outputs** — supported resolutions, aspect ratios, fps, duration bounds, whether it has an alpha channel or audio. +- **What goes in** — an `inputs` JSON schema, so the agent knows exactly which text/data slots to fill. +- **License provenance** — an SPDX id plus explicit `attribution_required` / `redistribution_allowed` / `commercial_use` flags, and an `assets_attribution` block pointing at the upstream source URL. + +That last part is deliberate. Every template is **license-clean by construction**: forks carry their original license, the repo-root [`NOTICE.md`](templates/NOTICE.md) records each source and SPDX, and nothing without a clear permissive license ships. So you can put any of them in commercial work without an audit. Templates span data viz (NYT-style charts, Swiss/Vignelli grids), titles & VFX (glitch, kinetic type, typewriter cursor), heroes & cinematics (liquid gradients, light-leak, warm grain), product promos (15s / 30s multi-scene), and explainer scaffolds (decision trees) — and the format is open, so community templates drop in the same way. + +--- + +## Architecture + +``` +packages/ +├── core/ Project / Asset / ContentGraph types, registries, orchestrator, +│ MiniMax provider + ffmpeg audio mux +├── content-graph/ Multi-frame storyboard IR (nodes + edges, topo-sort) +│ runtime/ Agent runtime — detect / spawn / stream +│ (Open Design/Vela · Windsurf CLI · Trae CLI · Claude · Cursor · Codex · Gemini · Grok · Qwen · OpenCode · Copilot · Aider · Hermes · Anthropic API) +├── adapter-hyperframes/ Hyperframes engine adapter — real render via Chromium + ffmpeg +├── cli/ `html-video` command + the studio HTTP server + source fetching +└── project-studio/ Browser studio UI (chat, template gallery, frames, soundtrack, export) +templates/ 21 curated, license-clean video templates +research/ RFCs (engine adapter / template metadata / agent skill / content-graph) +``` + +--- + +## Roadmap + +- [x] Engine adapter spec — one interface, N backends +- [x] Template metadata format — license-first, agent-readable +- [x] Multi-frame storyboard workflow (content-graph) +- [x] Studio: live template gallery, agent switcher, per-frame text editing +- [x] Source material: article / GitHub-repo → video +- [x] AI soundtrack (MiniMax music + narration), mixed at export +- [x] Real MP4 render — Hyperframes engine via headless Chromium + ffmpeg +- [x] Agent model selection — Open Design (Vela) backend, live model catalog +- [ ] Adapters for Remotion / Motion Canvas / Revideo +- [ ] Agent skill packages + a template marketplace + +--- + +## References & lineage + +| Project | Role here | +|---|---| +| [Open Design](https://github.com/nexu-io/open-design) | Sister project — the design-agent meta-layer; same team, shared philosophy | +| [HTML Anything](https://github.com/nexu-io/html-anything) | Sister project — HTML for *static* deliverables; html-video is the *motion* side | +| [Hyperframes](https://github.com/heygen-com/hyperframes) | The shipped engine adapter; the HTML+CSS+GSAP rendering paradigm and the source of several Apache-2.0 templates | + +## License + +[Apache-2.0](LICENSE) + +## Built by + +[nexu-io](https://github.com/nexu-io) — the team behind [Open Design](https://github.com/nexu-io/open-design). Join the [Discord](https://github.com/nexu-io/open-design#community) · follow [@nexudotio](https://x.com/nexudotio). diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..f0ceae0 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`nexu-io/html-video` +- 原始仓库:https://github.com/nexu-io/html-video +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..08439f4 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,288 @@ +# html-video + +

+ html-video — 在你的电脑上,把 HTML 变成视频 +

+ +> **在你的电脑上,把 HTML 变成视频。** 接上你本地的 coding agent(Open Design · Windsurf CLI · Trae CLI · Claude Code · Cursor · Codex · Gemini · Grok · Qwen · OpenCode · Copilot · Aider · Hermes · 或 Anthropic API),描述一个视频,或者**直接粘一个文章链接 / GitHub 仓库**,agent 就把它变成一支多帧、带动画的视频 —— 然后就在你这台机器上渲染成真实 MP4。一个 agent 循环、可插拔渲染引擎、精选模板库、可选 AI 配乐。Apache-2.0,无单次渲染费用,不绑定厂商。 + +

+ License + Agents + Templates + Sources + Soundtrack + Quickstart +

+ +

+ Discord + Follow @nexudotio on X + By the Open Design team +

+ +

+ Open Design 团队官方出品 · open-design.ai +

+ +

English · 简体中文

+ +--- + +## 作品展示 + +下面每个模板都是一支真实、带动画的单文件 HTML 视频 —— 这些是实时渲染截图,不是效果图。挑一个,让 agent 填进你的内容,导出 MP4。 + + + + + + + + + + + + + + + + + + + + + + + + + + +
NYT 风格数据图表故障标题
frame-data-chart-nyt · 数据可视化
纽约时报风格的动态折线图 —— 大标题、标注数据点、来源行。适合「数字涨上去了」类叙事。
frame-glitch-title · 标题卡
带色彩偏移与扫描线的故障标题。适合开场、爆点、「系统上线」式的能量感。
液态背景主视觉漏光电影感
frame-liquid-bg-hero · 主视觉
极光液态渐变背景 + 居中大标题。适合产品发布与有力的口号。
frame-light-leak-cinema · 电影感
暖色胶片颗粒 + 漏光的电影感画面。适合氛围片、品牌片、叙事短片。
打字机光标特效Logo 片尾
vfx-text-cursor · 特效
打字机文字 + 闪烁的终端光标。适合代码风揭示、CLI 演示。
frame-logo-outro · 片尾
干净的 Logo 动画结束卡。适合任何视频结尾的署名与品牌落版。
+ +……还有 15 个,包括多场景产品宣传、动感排版、瑞士网格与 Vignelli 数据卡、决策树解说、Takram 有机动效、暖色颗粒杂志风。全部 21 个都可在 studio 模板库里实时浏览。 + +--- + +## 为什么做这个 + +HTML→Video 是个真实存在的品类 —— 但每个引擎都各有主张,且都要你学*它自己*的创作模型: + +| 引擎 | 范式 | 取舍 | 在 html-video 中 | +|---|---|---|---| +| [Hyperframes](https://github.com/heygen-com/hyperframes) | HTML + CSS + GSAP,agent skill 驱动 | 单一渲染范式 | ✅ **已发布** —— 默认引擎;经无头 Chromium + ffmpeg 渲染出真实 MP4 | +| [Remotion](https://www.remotion.dev/) | React 组件 | source-available,4 人以上收费 | 🗺️ 计划中 | +| [Motion Canvas](https://github.com/motion-canvas/motion-canvas) · [Revideo](https://github.com/redotvideo/revideo) | canvas 上的 TypeScript 生成器 | 最适合解说类、代码优先 | 🗺️ 计划中 | +| [Manim](https://github.com/3b1b/manim) 等 | 数学 / 3D 优先 | 小众 | 🗺️ 调研中 | + +按场景挑引擎、学每一种创作模型、再把它们拼成一条工作流,都要耗真实的工程时间。多数团队挑一个、然后忍受它的局限。 + +**html-video 是凌驾于它们之上的 meta-layer。** 你跟 agent 对话,它挑引擎、挑模板、填进你的内容、渲染视频。引擎只是一个适配器接口背后的实现细节 —— 一份 `render(input, ctx)` 契约,任何后端满足它即可接入。加一个新引擎,所有模板、所有 agent、整条 studio 工作流就都自动用上了。不用学新的 DSL,换引擎也不用重写。 + +同一套思路也驱动着 [Open Design](https://github.com/nexu-io/open-design) 在*设计*领域的产品 —— 凌驾于众多工具之上的 agent meta-layer。html-video 是同一团队在*动态视频*这一面的对应物。 + +> **状态:** 可插拔引擎架构已就位,**Hyperframes 引擎已完整接通、能渲染出真实 MP4** —— 无头 Chromium 逐帧录制带动画的 HTML,再用 ffmpeg 编码(libx264)。Remotion、Motion Canvas / Revideo、Manim 在路线图上:适配器接口已为它们设计好,但适配器本身还没写。上表「在 html-video 中」这一列,是当下真正可运行内容的唯一权威来源。 + +--- + +## 速览 + +| | | +|---|---| +| **Coding agent(14 个)** | Open Design (Vela) · Windsurf CLI · Trae CLI · Claude Code · Cursor Agent · Codex CLI · Gemini CLI · Grok Build · Qwen Code · OpenCode · GitHub Copilot CLI · Aider · Hermes · Anthropic Messages API —— 在 `PATH` 上自动探测,顶栏一键切换。 | +| **真实 MP4 渲染** | 无头 Chromium 录制带动画的 HTML,ffmpeg 编码(libx264)—— 全在本地,无云端渲染,无单片费用。 | +| **文章 / 仓库 → 视频** | 粘一个 URL 或 GitHub 仓库;studio 在服务端抓取(支持微信公众号文章),用真实内容生成视频。 | +| **21 个模板** | 精选、许可清晰的样式:数据可视化、产品宣传、社媒短片、解说、动感排版、转场 —— 在模板库里实时预览。 | +| **多帧故事板** | content-graph 驱动多场景视频;逐帧改文案、重排、重渲染。 | +| **AI 配乐** | 可选背景音乐 + 旁白(MiniMax),导出时混进 MP4。 | +| **Studio + CLI** | 一个本地浏览器 studio,外加一个可脚本化的 `html-video` CLI。 | +| **许可** | Apache-2.0 —— 无单次渲染费、无席位上限、无贡献者协议。 | + +--- + +## 它如何工作 + +一句话(或一个链接)进去,一支真实 MP4 出来。不论你从一句 prompt、一篇文章还是一个仓库开始,管线都是同一条: + +``` + prompt / 链接 / 仓库 + │ + ▼ + ① 来源抓取 studio 在服务端拉取 URL 或仓库,扁平成 Markdown + │ + ▼ + ② agent 循环 agent 读素材 + 所选模板的风格,产出一份 + │ content-graph(故事板)+ 每帧一个 HTML 块 + ▼ + ③ content-graph 多帧中间表示 —— 节点(实体 / 数据 / 文本)+ 边(顺序 / + │ 依赖 / 对比);拓扑排序成帧序与时长 + ▼ + ④ 逐帧 HTML 每个节点变成一个自包含、带动画的 HTML 帧,落到磁盘 + │ + ▼ + ⑤ Hyperframes 渲染 无头 Chromium 加载每一帧并录制(会自动延长时长 + │ 以覆盖该帧自身的动画)→ 每帧一个 webm + ▼ + ⑥ ffmpeg 每个 webm → mp4(libx264),再 concat 成一支视频; + │ 可选混入 MiniMax 的音乐 + 旁白 + ▼ + 你的.mp4 +``` + +第 ②–④ 步正是「meta-layer」所在:agent 决定故事板,引擎决定怎么画,两者互不渗透。第 ⑤ 步是引擎相关的 —— 以后换成 Remotion 或 Motion Canvas,只替换这一个环节,故事板和 agent 循环原封不动。全程在你本机运行;唯二的网络调用是可选的来源抓取和可选的配乐。 + +单帧视频走一条快速路径,跳过 content-graph —— 一个模板、一个 HTML,直接渲染。 + +--- + +## 把链接变成视频 + +这是大多数人最想要的用法:丢一个链接给 agent,拿回一支视频。agent 作为本地 CLI 运行、自身没有联网能力,所以 studio 在**服务端**抓取来源、把真实内容喂进生成 prompt —— 不用手动复制正文,像微信公众号这种无需登录的服务端渲染页面也直接能用。 + +``` +你: 做一个解读视频 https://mp.weixin.qq.com/s/… +Agent:好,我读完了《用嘴剪视频的时代来了?…》这篇文章 — 这就基于它生成。下一步选风格。 +→ 一支多帧解说视频,基于文章的真实要点 +``` + +- **网页文章** → 抓取并扁平成 Markdown。像**微信公众号**这种服务端渲染的页面开箱即用。 +- **GitHub 仓库** → 通过公开 API 拉取简介、顶层结构、README —— 很适合做「解读某开源项目」的视频。 +- **只给一句话** → 描述主题,agent 从零写内容。 + +无论哪种来源,它都会成为视频真正依据的素材 —— 不是套模板时的摆设。agent 读取抓来的内容,自己决定需要几个场景,写出一份 **content-graph 故事板**:要点变成帧,要点之间的关系(这个承接那个、这个对比那个)变成边,再把所选模板的视觉风格逐帧应用上去。于是一篇 1500 字的文章变成一支有节奏的多场景解说,每一句都能追溯回原文的某处;一个仓库变成一段「这个项目到底是什么」的结构化讲解。 + +--- + +## 快速开始 + +### 前置依赖 + +| 依赖 | 最低版本 | 检查方式 | +|---|---|---| +| **Node.js** | 20+ | `node --version` | +| **pnpm** | 9+ | `pnpm --version` | +| **ffmpeg** | 任意较新版本 | `ffmpeg -version` | +| **Chromium**(或 Playwright 浏览器)| — | `npx playwright install chromium` | + +默认渲染引擎用无头 Chromium 录制带动画的 HTML,再用 ffmpeg(libx264)编码为 MP4。如果没有系统安装的 Chromium,装 Playwright 内置的: + +```bash +npx playwright install chromium +``` + +### 安装 & 运行 + +```bash +pnpm install +pnpm -r build +node packages/cli/dist/bin.js studio # 在 http://127.0.0.1:3071 打开 studio +``` + +在 studio 里:挑一个模板(或直接描述视频 / 粘链接),跟 agent 对话,逐帧改文案,加配乐,导出 MP4。 + +CLI 工具: + +```bash +node packages/cli/dist/bin.js doctor # 探测已安装的 agent + 引擎 +node packages/cli/dist/bin.js search-templates --intent "github stars race" --top 3 +``` + +--- + +## 支持的 Agent + +在 `PATH` 上自动探测;在 studio 顶栏切换当前 agent。studio 默认把 **Open Design (Vela)** 排在最前 —— 一次登录、多种模型、成本更低 —— 然后回落到第一个*可用*的 agent,保证新项目永远有一个能用的后端。 + +| Agent | 探测 | 调用 | +|---|---|---| +| **Open Design (Vela)** | `vela` / Open Design 应用内置 | ACP over stdio —— 在 Open Design 里登录一次,任选模型 | +| **Windsurf CLI** | `windsurf` | `windsurf --yolo`,ACP over stdio | +| **Trae CLI** | `traecli` | `traecli acp serve --yolo`,ACP over stdio | +| **Claude Code** | `claude` | `claude --print`,prompt 走 stdin | +| **Cursor Agent** | `cursor-agent` | `cursor-agent --print` | +| **Codex CLI** | `codex` | `codex exec`,prompt 走 stdin | +| **Hermes** | `hermes` | Hermes ACP CLI | +| **Gemini CLI** | `gemini` | prompt 走 stdin | +| **Grok Build** | `grok` | `grok -p ` | +| **Qwen Code** | `qwen` | prompt 走 stdin | +| **OpenCode** | `opencode` | `opencode run`,prompt 走 stdin | +| **GitHub Copilot CLI** | `copilot` | `copilot --allow-all-tools`,prompt 走 stdin | +| **Aider** | `aider` | `aider --message ` | +| **Anthropic API** | BYOK | 直连 Messages API —— 不装任何 CLI 也能用 | + +什么都没装?配一个 Anthropic key,studio 直接走 Messages API。 + +--- + +## 配乐 + +给成片加上声音。在 **Settings → Audio** 填入 MiniMax API key,然后在每个项目的 **Soundtrack** 面板: + +- **背景音乐** —— 描述一种情绪(`舒缓的电影感氛围,缓慢推进`),MiniMax 生成一段器乐。 +- **旁白** —— 输入文案,MiniMax 朗读(TTS)。 + +两者都会通过 ffmpeg 混进导出的 MP4(音乐压低到人声之下,可选淡入淡出)。没配 key?studio 其余部分照常工作。 + +--- + +## 模板库 + +这 21 个模板不是随手凑的一堆 —— 每一个都是自包含、agent 可读的单元,由一份 `template.html-video.yaml` 清单描述,studio 启动时扫描加载。一份清单带齐了 agent 挑选和驱动这个模板所需的一切,根本不用打开 HTML: + +- **它用来做什么** —— `category`、`tags`,外加一个 `best_for` 清单(如*「企业幻灯片」*、*「极简报告卡」*),`search-templates` 拿你的意图去匹配它。 +- **它输出什么** —— 支持的分辨率、画幅比、fps、时长上下限、是否有 alpha 通道或音频。 +- **要喂什么进去** —— 一份 `inputs` JSON schema,让 agent 精确知道要填哪些文本 / 数据槽位。 +- **许可来源** —— 一个 SPDX 标识,外加明确的 `attribution_required` / `redistribution_allowed` / `commercial_use` 标志,以及一个指向上游来源 URL 的 `assets_attribution` 块。 + +最后这一点是刻意为之。每个模板都**从构造上就许可清晰**:fork 来的保留其原始许可,仓库根的 [`NOTICE.md`](templates/NOTICE.md) 记下每个来源与 SPDX,没有清晰宽松许可的一律不收。所以你可以把其中任何一个放进商业作品里,无需再做审查。模板覆盖数据可视化(NYT 风格图表、Swiss / Vignelli 网格)、标题与特效(故障、动感排版、打字机光标)、主视觉与电影感(液态渐变、漏光、暖色颗粒)、产品宣传(15 秒 / 30 秒多场景)、解说骨架(决策树)—— 而且格式是开放的,社区模板用同样的方式接入。 + +--- + +## 架构 + +``` +packages/ +├── core/ Project / Asset / ContentGraph 类型、registry、orchestrator、 +│ MiniMax provider + ffmpeg 音轨混合 +├── content-graph/ 多帧故事板中间表示(节点 + 边,拓扑排序) +│ runtime/ Agent 运行时 —— 探测 / spawn / 流式 +│ (Open Design/Vela · Windsurf CLI · Trae CLI · Claude · Cursor · Codex · Gemini · Grok · Qwen · OpenCode · Copilot · Aider · Hermes · Anthropic API) +├── adapter-hyperframes/ Hyperframes 引擎适配器 —— 经 Chromium + ffmpeg 真实渲染 +├── cli/ `html-video` 命令 + studio HTTP server + 来源抓取 +└── project-studio/ 浏览器 studio UI(对话、模板库、帧、配乐、导出) +templates/ 21 个精选、许可清晰的视频模板 +research/ RFC(引擎适配器 / 模板元数据 / agent skill / content-graph) +``` + +--- + +## 路线图 + +- [x] 引擎适配器规范 —— 一个接口,N 个后端 +- [x] 模板元数据格式 —— 许可优先、agent 可读 +- [x] 多帧故事板工作流(content-graph) +- [x] Studio:实时模板库、agent 切换器、逐帧改文案 +- [x] 来源素材:文章 / GitHub 仓库 → 视频 +- [x] AI 配乐(MiniMax 音乐 + 旁白),导出时混合 +- [x] 真实 MP4 渲染 —— Hyperframes 引擎经无头 Chromium + ffmpeg +- [x] Agent 模型选择 —— Open Design (Vela) 后端,实时模型目录 +- [ ] Remotion / Motion Canvas / Revideo 适配器 +- [ ] Agent skill 包 + 模板市场 + +--- + +## 参考与渊源 + +| 项目 | 在这里的角色 | +|---|---| +| [Open Design](https://github.com/nexu-io/open-design) | 姊妹项目 —— 设计 agent meta-layer;同一团队、同一理念 | +| [HTML Anything](https://github.com/nexu-io/html-anything) | 姊妹项目 —— 面向*静态*交付物的 HTML;html-video 是*动态*那一面 | +| [Hyperframes](https://github.com/heygen-com/hyperframes) | 已发布的引擎适配器;HTML+CSS+GSAP 渲染范式,也是若干 Apache-2.0 模板的来源 | + +## 许可 + +[Apache-2.0](LICENSE) + +## 出品 + +[nexu-io](https://github.com/nexu-io) —— [Open Design](https://github.com/nexu-io/open-design) 背后的团队。加入 [Discord](https://github.com/nexu-io/open-design#community) · 关注 [@nexudotio](https://x.com/nexudotio)。 diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..44701da Binary files /dev/null and b/assets/social-preview.png differ diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..84ba557 --- /dev/null +++ b/biome.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": ["**/dist/**", "**/node_modules/**", "**/.html-video/**", "**/templates/*/source/**"] + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "lineEnding": "lf" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "warn" + }, + "suspicious": { + "noExplicitAny": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always" + } + } +} diff --git a/docs/assets/hero.png b/docs/assets/hero.png new file mode 100644 index 0000000..5e4daa9 Binary files /dev/null and b/docs/assets/hero.png differ diff --git a/docs/assets/templates/frame-data-chart-nyt.png b/docs/assets/templates/frame-data-chart-nyt.png new file mode 100644 index 0000000..8cf0756 Binary files /dev/null and b/docs/assets/templates/frame-data-chart-nyt.png differ diff --git a/docs/assets/templates/frame-glitch-title.png b/docs/assets/templates/frame-glitch-title.png new file mode 100644 index 0000000..7504089 Binary files /dev/null and b/docs/assets/templates/frame-glitch-title.png differ diff --git a/docs/assets/templates/frame-light-leak-cinema.png b/docs/assets/templates/frame-light-leak-cinema.png new file mode 100644 index 0000000..7b1bf14 Binary files /dev/null and b/docs/assets/templates/frame-light-leak-cinema.png differ diff --git a/docs/assets/templates/frame-liquid-bg-hero.png b/docs/assets/templates/frame-liquid-bg-hero.png new file mode 100644 index 0000000..bff5761 Binary files /dev/null and b/docs/assets/templates/frame-liquid-bg-hero.png differ diff --git a/docs/assets/templates/frame-logo-outro.png b/docs/assets/templates/frame-logo-outro.png new file mode 100644 index 0000000..2f9bb82 Binary files /dev/null and b/docs/assets/templates/frame-logo-outro.png differ diff --git a/docs/assets/templates/vfx-text-cursor.png b/docs/assets/templates/vfx-text-cursor.png new file mode 100644 index 0000000..99edcee Binary files /dev/null and b/docs/assets/templates/vfx-text-cursor.png differ diff --git a/notes/2026-05-26-foundation-decisions.md b/notes/2026-05-26-foundation-decisions.md new file mode 100644 index 0000000..c7e52b1 --- /dev/null +++ b/notes/2026-05-26-foundation-decisions.md @@ -0,0 +1,183 @@ +# 2026-05-26 · 项目奠基决策日志 + +> 第一次会话产出的关键决策。串起 RFC-01/02/03,记录"为什么这么设计而不是别的",留 open question 给后续会话。 + +--- + +## 三份 RFC 的关系 + +``` +RFC-01: Engine Adapter 接口 ← 技术抽象层(最底) + ↓ 定义 EngineAdapter / Capabilities / RenderInput / Output +RFC-02: Template Metadata 格式 ← 内容描述层 + ↓ 描述每个 template,含 engine 归属 + 跨引擎检索元数据 +RFC-03: Agent Skill 设计 ← 用户交互层(最顶) + ↓ CLI Contract + Claude Code SKILL.md + transpile 到其他 agent +``` + +三者必须**自下而上**实现:先有 adapter 抽象(RFC-01),才能写 template metadata 校验它(RFC-02),才能让 agent CLI 决策(RFC-03)。 + +--- + +## ⭐ 产品形态修订(mid-session 关键澄清) + +Joey 在前 3 份 RFC 写完后澄清核心工作流:"产品内支持上传图文资产 → 整合 → 生成 HTML 分镜 → 用户审 → 导出 MP4"。这是**两段式 storyboard-first** 工作流,跟前 3 份 RFC 假设的 "agent fill vars → 一步 render MP4" 显著不同。 + +→ 新增 [RFC-04: Storyboard-first Workflow](../research/2026-05-26-spec-04-storyboard-workflow.md),作为前 3 份的修订指引。 + +**新核心命题**: +- 用户不是"工程师 with template id 和 vars",是"创作者 with 一堆图文 + 一句话意图" +- HTML Storyboard 是关键中间产物——人可读、可调、可批准 +- 只有 Storyboard 通过用户验收才 render MP4,避免渲染浪费 + 让用户保留创作主导权 + +**新护城河**:资产理解 + storyboard 编排 + 跨引擎渲染三层叠加,HF / Remotion 都没做前两层(他们假设用户已经知道想做什么)。 + +## 六个关键设计抉择(写下来不让后续会话动摇) + +### 1. **不发明新 DSL,做 thin meta-layer** + +拒绝:"让用户写 html-video.json,我们编译到 4 个 backend"。 +理由:永远落后任意 backend 的新 feature;4 个 transpile 维护地狱;用户写一段 React 想用 Remotion 优势但被 DSL 阉割 → 反向。 +做法:用户写的是 backend-native 代码,html-video 只在外面包一层。 + +### 2. **每个 template = 一个 engine 归属** + +拒绝:"同一个 idea 出多 engine 实现的同一个 template id"。 +理由:维护多份会漂移;用户视角下"同一模板"两版做出不同效果是 bug 不是 feature。 +做法:要多 engine → 发布多个 template id(例 `bar-chart-race-mc` / `bar-chart-race-hf`),各自独立 metadata。 + +### 3. **CLI 是 agent 唯一接口** + +拒绝:"agent 直接 import @html-video/core"。 +理由:进程隔离 + 跨 agent 复用(Claude Code / Cursor / Codex 都靠 shell 调),一个 contract 走天下。 +做法:所有功能从 CLI 走,stdout 严格 JSON,stderr 做 progress / debug。 + +### 4. **不发明 cross-engine fallback** + +拒绝:"HF 渲染挂自动用 Revideo 重试"。 +理由:用户选 engine 通常有理由(license、风格、团队偏好);偷偷换会让结果不一致。 +做法:失败 = 失败,agent 跟用户说,让 agent / 用户重新 search-templates。 + +### 5. **License 是一等公民** + +拒绝:"license 字段先留空之后再说"。 +理由:商用 / 二改 / attribution 是开源生态的真实问题,跑起来再补成本翻倍;OD 已经因为 plugins 的 license 漂移踩过坑。 +做法:metadata 强制要 SPDX id;CLI search 默认带 license-allow filter;CC-BY-SA / GPL 强 copyleft 给 warning。 + +### 6. **Storyboard 是用户审批 gate**(mid-session 新增) + +拒绝:"agent 一气呵成 render 出 MP4 就完了"。 +理由:渲染慢且贵;用户对 HTML 改的成本远低于对 MP4 改;分镜阶段批改是创作产业的标准范式(电影、广告、动画都先 storyboard)。 +做法:所有用户主流程必经 `storyboard preview` 阶段;`html-video render --template ...` 只留给开发者直跑(template 测试 / CI 用);agent 不允许跳过 storyboard 直接 render MP4。 + +--- + +## v0.1 MVP 范围(按 RFC-04 修订后) + +接下来写代码的范围: + +- ✅ `@html-video/core`:Asset / AssetBundle / Storyboard / Scene 数据结构 + sqlite 持久化 + content-addressed asset store + Template registry + Engine selector +- ✅ `@html-video/adapter-hyperframes`:第一个 reference adapter,实现 `validate / render / renderToHtml / preview` +- ✅ `@html-video/cli`:`doctor / list-engines / assets upload / storyboard generate / storyboard edit / storyboard preview / storyboard render`(用户主流程)+ `render` / `inspect-template`(开发者 dev-mode) +- ✅ `@html-video/storyboard-ui`:浏览器 storyboard preview 页面(timeline + scene grid + inline edit + 全屏播放 mock) +- ✅ `@html-video/agent-skill-claude-code`:storyboard-first SKILL.md + 3-5 个 dogfood use case +- ✅ 5 个 reference templates(覆盖 intro / data-chart / image-pan / text-card / outro 五种 scene role) +- ✅ Monorepo(pnpm workspace)+ 基础 CI(type-check / lint / unit test) + +**不**进 MVP(推到 v0.2+): +- ❌ adapter-remotion / motion-canvas / revideo(先把 HF 路径 + storyboard 闭环跑通) +- ❌ AI 智能编排(v0.1 是 LLM 即兴选 template,v0.2 加规则 + ML) +- ❌ AI 视觉理解资产(v0.1 用 mime + 文件名启发) +- ❌ 协作 / 多人审稿 +- ❌ Cloud / Lambda 部署(先本地 chromium) +- ❌ Telemetry(默认 off,alpha 不发) +- ❌ Template marketplace 网站(先 GitHub repo + npm 发布) + +--- + +## 命名约定(避免后续 bikeshed) + +| 类型 | 命名 | 例 | +|---|---|---| +| Repo | `nexu-io/html-video` | — | +| Monorepo workspace | `pnpm` | — | +| Core 包 | `@html-video/core` | — | +| Adapter 包 | `@html-video/adapter-` | `@html-video/adapter-hyperframes` | +| CLI 包 | `@html-video/cli` 或 binary 名 `html-video` | — | +| Agent skill 包 | `@html-video/agent-skill-` | `@html-video/agent-skill-claude-code` | +| Template 包 | `@html-video/template-` | `@html-video/template-data-bar-chart-race` | +| Template metadata 文件 | `template.html-video.yaml` | — | +| Config 文件 | `html-video.config.json` | — | + +--- + +## 跟姊妹项目的接口(提前想好) + +| 项目 | 接口点 | 状态 | +|---|---|---| +| `growth-dashboard` (T7) | 数据源(SQLite)→ html-video template vars | 待 v0.2,写一个 `html-video-data` adapter | +| `open-design`(T5)/ OD 客户端 | OD 客户端 plugins-home 是否要列 html-video templates? | 待 v0.2,等 html-video alpha 发布 | +| `od-pitch` (T6) | launch deck / 杂志风介绍页 | 等用户决定何时 launch | +| `od-landing` (T8) | nexu.io 主域 落地页给 html-video 加入口? | 待 v1 | + +--- + +## 风险 / 不确定性 register + +| 风险 | 严重度 | 缓解 | +|---|---|---| +| HF 上游 breaking change | 高 | adapter peerDep 锁 semver 范围;CI 跑 HF latest 兼容性 | +| Remotion license 变更 | 中 | 不依赖 Remotion 收入模型;adapter 只是壳 | +| Motion Canvas 无 server render → adapter 难做 | 中 | v0.2 才接,先用 Revideo 替代 explainer 路径 | +| 用户嫌 4 个 backend 太重 | 中 | adapter lazy install,doctor 自动 hint,按需 pull | +| HF 自己迭代到 multi-backend → 跟我们重叠 | 高 | 早点 ship + 强调 "neutral aggregator" 定位(HF 是参与者不是中立方) | +| AI generative video(Sora / Veo / Runway)取代 HTML 路线 | 中 | v0.3+ 加 generative-video adapter("生成 → HTML 模板再加工"两段式) | + +--- + +## 下次会话该做什么(顺序明确) + +按重要性 / 解锁度排: + +1. **写 `@html-video/core` 骨架**(pnpm workspace + TS 配置 + EngineAdapter type 落地) +2. **写 `@html-video/adapter-hyperframes` 骨架**(先实现 capabilities + validate,render 跑通再说) +3. **挑 1 个 HF native template 当 reference**(直接拷一个 HF 仓库的 example template,套上 RFC-02 metadata 验证 schema 合理性) +4. **写 CLI `html-video doctor` + `list-engines`**(最简单的两个命令,跑通端到端最小回路) +5. **手写一次 SKILL.md,让 Claude Code 这边的本人会话试用**(dogfood,发现 prompt-level 问题) + +每一步独立 commit,PR 评审走 nexu-io org(等 GitHub repo 推上去后开始)。 + +--- + +## Open Questions 集合(汇总三份 RFC 的) + +### 来自 RFC-01 + +1. template variables 的 schema 校验放 core 还是 adapter? +2. 音频混音 core helper vs adapter 各自处理? +3. 字幕 burn-in / sidecar 谁烧? +4. 资产 resolver(图片字体)放哪? +5. 跨 engine retry —— v0.1 已决定不做 + +### 来自 RFC-02 + +1. i18n 多语言 metadata +2. 复杂模板的 input grouping +3. Template 之间的依赖(transition + main 组合) +4. 外部数据源(CSV / Sheets URL)支持 +5. A/B 变体共享同源代码 + +### 来自 RFC-03 + +1. Skill 之间协作(html-video → growth-dashboard 直接读 SQLite vs MCP) +2. Multi-shot timeline composer skill +3. Content-hash cache +4. OpenCode 集成路径 + +这些不是 v0.1 阻塞,但写代码时遇到要回查这一节避免重新发明。 + +--- + +## 给 Joey 的复盘 sentence + +> 一次会话沉淀了 4 份 RFC(adapter / template / agent-skill / storyboard)+ 1 份决策日志,定下:**meta-layer / 不发明 DSL / template engine-locked / CLI 唯一 agent 接口 / license 一等公民 / Storyboard-first 用户审批 gate**。下次会话可直接进 `@html-video/core` 代码骨架(按 RFC-04 修订后的 v0.1 范围),不再 bikeshed 抽象。 diff --git a/notes/2026-06-04-provenance-audit.md b/notes/2026-06-04-provenance-audit.md new file mode 100644 index 0000000..950d888 --- /dev/null +++ b/notes/2026-06-04-provenance-audit.md @@ -0,0 +1,66 @@ +# Provenance audit — 模板来源核查报告 + +> 2026-06-04。起因:发现今天新增的 3 个模板(已回退)与仓库已有模板视觉撞车, +> 进而对整个模板库的"原创性声明 + 署名"做了一次真实上游比对。 + +## TL;DR + +仓库里 6 个模板声明了 provenance,来自 2 个 MIT 上游。核查后发现 **3 个严肃问题**: + +1. **署名链条缺了一层**:`Pentagram` / `Build` / `Takram` 不是上游(huashu-design)原创, + 而是 huashu 在**致敬真实世界的知名设计公司/设计师**。我们直接拿这些公司名当了模板 + id 和显示名,而 huashu 自己的文档明确告诫"不要直接写 in the style of Pentagram"。 +2. **"original / no source copied" 声明未经核实**:这句话是模板生成时自我宣称的,从未 + 真去上游 diff。实测复刻程度逐个不同(Pentagram 红+白全同;Build 共享 4 个暖灰色)。 +3. **连示例数据都是照搬的**:`95.7 / 73.8 / 87.4 / AIME / SWE-bench` 直接来自上游 + ppt 源页,不是我们自编的占位数据。 + +## 上游核实 + +| 上游 repo | 真实版权人 | License | 用于我们的模板 | +|---|---|---|---| +| [alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design) | **alchaincyf(花叔 · 花生)** © 2026 | MIT ✅ 已核实 | build-minimal, pentagram-stat, takram-organic | +| [zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides) | **Zara Zhang** © 2025 | MIT ✅ 已核实 | bold-signal, creative-voltage, electric-studio | + +License 真的是 MIT,允许商用 + 再分发,**所以这不是"能不能用"的问题**——MIT 完全允许。 +问题在**署名是否写清楚、风格名是否该直接挪用**。 + +## 关键发现:Pentagram/Build/Takram 是三层署名,不是两层 + +huashu-design 的 `references/design-styles.md` 明确写道: + +- **01 Pentagram** = "Michael Bierut 风格 / Pentagram"(搜索词:pentagram hillary logo system) + —— 真实的全球顶级设计公司 Pentagram,设计师 Michael Bierut +- **11 Build** = "Build studio luxury minimalism"(搜索词:build studio london branding) + —— 伦敦设计工作室 Build +- **17 Takram** = "Takram Japanese speculative design"(搜索词:takram nhk data visualization) + —— 日本设计公司 Takram + +→ 真实署名链是:**Pentagram / Build / Takram(真实工作室)→ huashu-design 提炼为"风格 + +HTML 示例"(MIT,花叔)→ html-video 转成视频模板**。我们当前 provenance 只写到 huashu +这一层,且把工作室名当成了模板名直接挂出。 + +huashu 自己的规矩(design-styles.md): +> ❌ 不要直接写 "in the style of Pentagram" → ✅ 用具体设计特征描述 + +我们的模板 id 恰恰是 `frame-pentagram-stat` / `frame-takram-radar` / `frame-build-minimal`。 + +## 实质比对(上游 ppt 源页 vs 我们的模板) + +| 风格 | 共享配色 | 字体 | 复刻程度 | +|---|---|---|---| +| Pentagram | 2/2(#E63946 红 + #FFFFFF 全同) | 我们换成 Archivo | 高 — 配色照搬 | +| Build | 4/7(#A8A4A0 #B0ACA4 #D4A574 暖灰全同) | 同 Inter | 高 — 配色+字体同源 | +| Takram | 0/7(配色我们重新调过) | 我们换成 Manrope | 中 — 重新着色 | + +示例数据 `95.7/73.8/87.4 + AIME/SWE-bench`:**三个上游 ppt 页里都有,我们照搬。** + +## 结论 + +- **不是侵权**(MIT 授权,允许商用+演绎),但**署名不充分 + 风格命名不当**。 +- 对一个要公开发布(Apache-2.0)、给 nexu.io / OD 生态导流的项目,这是要修的诚信/署名问题。 + +## 整改方案 + 标准转换规范 + +规范已定:见 [RFC-07 PPT Skill → Hyperframes 模板 转换规范](../research/2026-06-04-spec-07-ppt-to-template.md)。 +下一步用 RFC-07 回头整改本报告列出的 6 个模板(三层署名 + 重命名 + 自有示例)。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..640d1e1 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "html-video-monorepo", + "version": "0.0.0", + "private": true, + "description": "HTML→Video meta-layer for coding agents", + "license": "Apache-2.0", + "homepage": "https://github.com/nexu-io/html-video", + "repository": { + "type": "git", + "url": "git+https://github.com/nexu-io/html-video.git" + }, + "engines": { + "node": ">=20", + "pnpm": ">=9" + }, + "packageManager": "pnpm@9.15.0", + "workspaces": [ + "packages/*", + "templates/*" + ], + "scripts": { + "build": "pnpm -r build", + "typecheck": "pnpm -r typecheck", + "test": "pnpm -r test", + "smoke": "pnpm --filter @html-video/cli smoke", + "format": "biome format --write .", + "lint": "biome lint ." + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.0", + "typescript": "^5.7.2" + }, + "dependencies": { + "@remotion/bundler": "^4", + "@remotion/renderer": "^4", + "react": "^18", + "react-dom": "^18", + "remotion": "^4" + } +} diff --git a/packages/adapter-hyperframes/package.json b/packages/adapter-hyperframes/package.json new file mode 100644 index 0000000..488d97c --- /dev/null +++ b/packages/adapter-hyperframes/package.json @@ -0,0 +1,36 @@ +{ + "name": "@html-video/adapter-hyperframes", + "version": "0.1.0", + "description": "Hyperframes adapter for html-video meta-layer", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test test/" + }, + "dependencies": { + "@html-video/core": "workspace:*", + "playwright": "^1.49.0" + }, + "peerDependencies": { + "hyperframes": "^0.4.0" + }, + "peerDependenciesMeta": { + "hyperframes": { + "optional": true + } + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/packages/adapter-hyperframes/src/capabilities.ts b/packages/adapter-hyperframes/src/capabilities.ts new file mode 100644 index 0000000..253669d --- /dev/null +++ b/packages/adapter-hyperframes/src/capabilities.ts @@ -0,0 +1,31 @@ +import type { EngineCapabilities } from '@html-video/core'; + +/** + * Static capability declaration for Hyperframes. + * v0.1: hard-coded; v0.2 may detect upstream version and adjust. + */ +export const capabilities: EngineCapabilities = { + paradigms: ['html-css-gsap'], + outputFormats: ['mp4', 'webm', 'webm-alpha', 'png-sequence'], + maxResolution: { width: 3840, height: 2160 }, + alpha: true, + audio: 'multi', + subtitles: ['burn-in', 'sidecar'], + renderTarget: ['local-chromium', 'lambda'], + licensing: 'free-osi', + renderSpeedHint: { + resolution: '1080p', + durationSec: 10, + fps: 60, + estimatedRenderSec: 18, + }, + bestFor: [ + 'social-shorts', + 'product-marketing', + 'logo-reveal', + 'gsap-animations', + 'ken-burns', + 'text-card', + ], + weaknesses: ['no-react-ecosystem', 'limited-3d-without-three.js'], +}; diff --git a/packages/adapter-hyperframes/src/index.ts b/packages/adapter-hyperframes/src/index.ts new file mode 100644 index 0000000..6ea5f52 --- /dev/null +++ b/packages/adapter-hyperframes/src/index.ts @@ -0,0 +1,17 @@ +import type { EngineAdapter } from '@html-video/core'; +import { capabilities } from './capabilities.js'; +import { validate } from './validate.js'; +import { render, renderToHtml } from './render.js'; + +const adapter: EngineAdapter = { + id: 'hyperframes', + name: 'Hyperframes', + upstreamVersion: '0.4.x', + capabilities, + validate, + render, + renderToHtml, +}; + +export default adapter; +export { adapter }; diff --git a/packages/adapter-hyperframes/src/render.ts b/packages/adapter-hyperframes/src/render.ts new file mode 100644 index 0000000..1e9db24 --- /dev/null +++ b/packages/adapter-hyperframes/src/render.ts @@ -0,0 +1,617 @@ +/** + * Hyperframes render() — real recording via Playwright + ffmpeg. + * + * Per-frame strategy (orchestrator already loops per node and concats): + * 1. Launch chromium headless at the configured resolution + * 2. recordVideo into a tmp dir + * 3. file:// load the frame HTML + * 4. wait `durationSec` so any opening animation completes + plays + * 5. close → playwright dumps a webm + * 6. ffmpeg transmux/encode the webm to mp4 at `outputPath` + * + * Upstream Hyperframes was never required at runtime for this adapter — + * our generated HTML is plain inline-CSS+JS, chromium runs it as-is. + */ + +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { existsSync, readdirSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { + HtmlSceneOutput, + RenderContext, + RenderInput, + RenderOutput, +} from '@html-video/core'; +import { HtmlVideoError } from '@html-video/core'; + +const ADAPTER_VERSION = '0.2.0-playwright'; + +/** Real render: chromium records the page, ffmpeg transcodes to MP4. */ +export async function render(input: RenderInput, ctx: RenderContext): Promise { + const t0 = Date.now(); + ctx.onProgress?.(5, 'preparing'); + const outDir = dirname(input.config.outputPath); + await mkdir(outDir, { recursive: true }); + if (ctx.signal?.aborted) throw new HtmlVideoError('cancelled', 'Aborted'); + + // Resolve the source HTML path. Templates pass an absolute path already; + // multi-frame `core` calls pass the per-frame HTML path the same way. + if (!existsSync(input.template.sourcePath)) { + throw new HtmlVideoError( + 'template-invalid', + `Source HTML not found: ${input.template.sourcePath}`, + ); + } + + let totalDuration = + input.config.duration === 'auto' ? 5 : Math.max(0.5, Number(input.config.duration)); + const { width, height } = input.config.resolution; + const fps = input.config.fps || 30; + + // Lazy-load playwright so the import cost only hits actual exports. + ctx.onProgress?.(15, 'launching browser'); + const playwright = await import('playwright').catch((err) => { + throw new HtmlVideoError( + 'render-failed', + `playwright not installed (run \`pnpm install\` from the monorepo root). ${err instanceof Error ? err.message : err}`, + ); + }); + + const recordDir = await mkdtemp(join(tmpdir(), 'hv-render-')); + let browser: import('playwright').Browser | undefined; + let webmPath: string | undefined; + let cleanupSrc: (() => Promise) | undefined; + // Wall-clock offset (ms) from when the webm starts recording to when we + // actually start the animation, so ffmpeg can trim the dead opening lead-in. + let leadInMs = 0; + try { + browser = await playwright.chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-blink-features=AutomationControlled'], + }); + // recordVideo starts capturing the moment the context exists, so this is + // the webm's t=0 reference. + const tWebmStart = Date.now(); + const context = await browser.newContext({ + viewport: { width, height }, + deviceScaleFactor: 1, + recordVideo: { dir: recordDir, size: { width, height } }, + }); + const page = await context.newPage(); + + // Freeze all CSS/SMIL animations the instant the document starts parsing, + // BEFORE any @keyframes can begin counting down. Single-file templates are + // pure CSS `animation: … forwards` timelines with no JS trigger — they + // start running on the wall clock the moment the element is styled, i.e. + // right after goto(). Meanwhile we then spend ~2–3s waiting for the Google + // Fonts faces (Shrikhand et al.) to download. Without this freeze the whole + // opening (text fading in while the real face is still downloading, then + // the swap) plays out during that font wait and gets recorded. Pausing all + // animations up front lets us hold the timeline at frame 0 until fonts are + // ready, then release it so capture and motion start together — the same + // shape as the multi-composition paused→drive path below. + await page.addInitScript(() => { + const style = document.createElement('style'); + style.id = '__hv_freeze'; + style.textContent = + '*, *::before, *::after { animation-play-state: paused !important;' + + ' -webkit-animation-play-state: paused !important; }'; + const attach = () => (document.head || document.documentElement).appendChild(style); + if (document.head || document.documentElement) attach(); + else document.addEventListener('DOMContentLoaded', attach, { once: true }); + (window as unknown as { __hvUnfreeze?: () => void }).__hvUnfreeze = () => { + document.getElementById('__hv_freeze')?.remove(); + }; + }); + + ctx.onProgress?.(30, 'loading frame'); + // Multi-composition templates ship an entry index.html that only stitches + // sub-scenes via `data-composition-src="compositions/x.html"`; loaded raw + // over file:// the scenes never appear (chromium blocks file:// fetch, so + // the studio's client-side fetch player can't run here). Inline the + // composition files into the HTML up front so chromium records real motion + // instead of an empty shell. Single-file templates pass through untouched. + const prepared = await prepareSourceHtml(input.template.sourcePath); + cleanupSrc = prepared.cleanup; + const fileUrl = pathToFileURL(prepared.loadPath).href; + // Wait only for the DOM + same-document scripts (GSAP, the inline player), + // NOT `load` — `load` blocks on every external asset, and some templates + // reference a cross-origin A-Roll video (e.g. an S3 mp4 with no CORS + // header) that chromium retries for ~4s before giving up. Under `load` + // those ~4s get recorded into the webm as a frozen first scene before the + // timeline ever plays, so the clip opens on several dead seconds. Fonts are + // awaited separately below (document.fonts.ready); GSAP is a synchronous + // script so it's ready at DOMContentLoaded. + await page.goto(fileUrl, { waitUntil: 'domcontentloaded' }); + + // Wait for all web fonts to finish loading BEFORE recording. Templates + // pull display faces (Shrikhand, Libre Baskerville, Archivo Black, …) from + // Google Fonts with `font-display: swap`, which paints text in a fallback + // system font immediately and swaps in the real face once it downloads. + // If we start recording before the swap, the video shows a visible flash: + // the text renders in the fallback for the first frames, then the glyphs, + // widths and weights snap to the intended font mid-clip. + // + // `document.fonts.ready` alone is NOT enough here, and this was the bug in + // the first cut of this fix. We load the page with `domcontentloaded` (so a + // CORS-blocked A-Roll video can't freeze the opening — see above), which + // means at this point the Google Fonts stylesheet has usually not + // come back yet. Until that CSS arrives, its @font-face rules are not in + // `document.fonts` at all, so `fonts.ready` sees an empty set and resolves + // INSTANTLY — recording starts, then the CSS lands, the faces download, and + // the swap happens mid-clip anyway. So we must, in order: + // 1. wait for every stylesheet to load (or error) — this is what + // actually registers the @font-face rules into document.fonts; + // 2. explicitly fonts.load() each registered face — `display: swap` does + // NOT auto-download a face until something paints with it, and our + // off-screen/pre-animation text may not have triggered that yet; + // 3. then await fonts.ready, plus one rAF, so layout settles on the real + // glyph metrics before frame 0. + // Everything is capped so a slow/blocked font CDN can't stall forever — + // worst case we fall back to the previous behavior for that one frame. + ctx.onProgress?.(32, 'loading fonts'); + await page + .evaluate( + () => + new Promise((resolve) => { + const doc = document as Document & { fonts?: FontFaceSet }; + const fonts = doc.fonts; + if (!fonts || typeof fonts.ready?.then !== 'function') { + resolve(); + return; + } + + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + // One more frame so the relayout on the real face is painted. + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }; + // Hard cap: a blocked CDN must never stall the render. + const cap = setTimeout(finish, 8000); + + // 1. Wait for stylesheet s to load (registers @font-face). + const links = Array.from( + document.querySelectorAll('link[rel="stylesheet"]'), + ); + const linkDone = links.map((link) => { + // An already-loaded sheet exposes cssRules without throwing. + try { + if (link.sheet && link.sheet.cssRules) return Promise.resolve(); + } catch { + /* not ready yet — fall through to event wait */ + } + return new Promise((r) => { + const done = () => r(); + link.addEventListener('load', done, { once: true }); + link.addEventListener('error', done, { once: true }); + // Per-link safety so one wedged link can't hold the batch. + setTimeout(done, 6000); + }); + }); + + Promise.all(linkDone) + .then(() => { + // 2. Force every registered face to actually download. Under + // `display: swap` the browser otherwise defers the fetch. + const loads: Promise[] = []; + fonts.forEach((face) => { + try { + loads.push(face.load().catch(() => undefined)); + } catch { + /* some faces reject load() pre-paint — ignore */ + } + }); + return Promise.all(loads); + }) + // 3. Now ready() reflects the real face set. + .then(() => fonts.ready) + .then(() => { + clearTimeout(cap); + finish(); + }) + .catch(() => { + clearTimeout(cap); + finish(); + }); + }), + ) + .catch(() => {}); + + // Pages sometimes set up animations on the load tick — give a frame + // for animations to actually start before we count the duration. + await page.waitForTimeout(100); + + // Probe the frame's own animation length so we never cut it off. A short + // per-frame duration set by the user could be < the frame's opening + // animation, truncating it mid-play. Take the longer of the two: the frame + // gets at least as long as its non-looping CSS animations / GSAP timeline. + try { + const animMs = await page.evaluate(() => { + let maxMs = 0; + Array.from(document.querySelectorAll('*')).forEach((el) => { + const s = getComputedStyle(el); + const durs = (s.animationDuration || '').split(','); + const dels = (s.animationDelay || '').split(','); + const iters = (s.animationIterationCount || '').split(','); + durs.forEach((d, i) => { + if ((iters[i] || '').trim() === 'infinite') return; // ignore looping bg anims + maxMs = Math.max(maxMs, ((parseFloat(d) || 0) + (parseFloat(dels[i] || '0') || 0)) * 1000); + }); + }); + // GSAP: do NOT use globalTimeline.totalDuration() — an infinitely + // repeating tween (repeat:-1, e.g. a blinking cursor) makes it ~1e10s. + // Walk the children and take the longest FINITE (non-repeat:-1) tween. + const g = (window as unknown as { + gsap?: { globalTimeline?: { getChildren?: (b?: boolean, t?: boolean, tl?: boolean) => Array<{ totalDuration?: () => number; repeat?: () => number; vars?: { repeat?: number } }> } }; + }).gsap; + let gsapMs = 0; + const children = g?.globalTimeline?.getChildren?.(true, true, true) ?? []; + for (const c of children) { + const repeat = typeof c.repeat === 'function' ? c.repeat() : (c.vars?.repeat ?? 0); + if (repeat === -1) continue; // infinite loop — ignore + const td = typeof c.totalDuration === 'function' ? c.totalDuration() : 0; + if (Number.isFinite(td)) gsapMs = Math.max(gsapMs, td * 1000); + } + return Math.max(maxMs, gsapMs); + }); + // +0.4s settle so the final animation frame is actually captured; cap at + // 30s so a stray huge value can't make a frame run away. + const needed = Math.min(30, (animMs + 400) / 1000); + // Only extend when the duration is a soft 'auto' fallback. When the user + // set an explicit per-frame length (multi-frame export), it's a hard cap — + // honoring it keeps "每帧 4s" at 4s instead of letting one long animation + // stretch the frame toward the 30s ceiling. + if (input.config.durationMode !== 'explicit' && needed > totalDuration) { + ctx.onProgress?.(38, `extending to ${needed.toFixed(1)}s for animation`); + totalDuration = needed; + } + } catch { /* probe failed — fall back to the requested duration */ } + + // Multi-composition templates register their master timeline paused so the + // probe above can read its real (finite) duration. Now that the recording + // window is fixed, drive playback from frame zero so capture and animation + // start together — otherwise the auto-play fallback would have already run + // part of the timeline before we begin recording. + const drove = await page + .evaluate(() => { + const w = window as unknown as { __hvPlayAll?: () => void; __hvPlayed?: boolean }; + if (typeof w.__hvPlayAll === 'function') { + w.__hvPlayed = true; + w.__hvPlayAll(); + return true; + } + return false; + }) + .catch(() => false); + + // Release the animation freeze now that fonts are ready. Every template — + // single-file CSS keyframes and multi-composition GSAP alike — has been + // held at frame 0 since before goto(), so the entire recorded lead-in + // (page load + cold font fetch, ~2–4s) is a still hold of the first frame + // with no motion. Unfreezing here is the true t=0 of the animation, so the + // lead-in is always dead and always safe to trim. (Previously only + // multi-composition templates were parked, so single-file ones recorded + // their opening during the font wait and showed the fallback-font flash.) + await page + .evaluate(() => { + (window as unknown as { __hvUnfreeze?: () => void }).__hvUnfreeze?.(); + }) + .catch(() => {}); + leadInMs = Date.now() - tWebmStart; + void drove; // playback already driven above for multi-composition timelines + + ctx.onProgress?.(40, `recording ${totalDuration}s`); + // Stream a single coarse progress tick per second so the user sees + // "recording 1/5s …" type signal in the studio progress bar. + const totalMs = Math.round(totalDuration * 1000); + const tick = 250; + const start = Date.now(); + while (Date.now() - start < totalMs) { + if (ctx.signal?.aborted) throw new HtmlVideoError('cancelled', 'Aborted'); + await page.waitForTimeout(Math.min(tick, totalMs - (Date.now() - start))); + const pct = 40 + Math.floor(((Date.now() - start) / totalMs) * 45); + ctx.onProgress?.(pct, 'recording'); + } + + ctx.onProgress?.(85, 'finalising recording'); + await context.close(); + // playwright drops the webm into recordDir; pick the freshest .webm + const candidates = readdirSync(recordDir).filter((f) => f.endsWith('.webm')); + if (candidates.length === 0) { + throw new HtmlVideoError('render-failed', `Playwright produced no webm in ${recordDir}`); + } + candidates.sort(); + webmPath = join(recordDir, candidates[candidates.length - 1]!); + } finally { + if (browser) await browser.close().catch(() => {}); + if (cleanupSrc) await cleanupSrc().catch(() => {}); + } + + // ---- ffmpeg: webm → mp4 ---- + ctx.onProgress?.(90, 'encoding mp4'); + // Trim the dead lead-in (page load + font fetch before the timeline played) + // off the front of multi-composition webms. Back off 120ms so rounding / + // recorder start jitter can't clip the first real animation frame — a couple + // of still frames at the head are harmless, a missing opening beat is not. + const seekSec = leadInMs > 200 ? Math.max(0, (leadInMs - 120) / 1000) : 0; + // When the user set an explicit per-frame length, the output must be EXACTLY + // that long. The recorded webm can come up a little short (recorder start + // jitter, the lead-in trim, a sub-duration animation that finished early), so + // pad the tail by holding the last frame (`tpad stop_mode=clone`) up to the + // target. -t then trims to the precise length. For 'auto' we keep the old + // behavior (just -t, no padding) — there the duration is a soft fallback. + const explicit = input.config.durationMode === 'explicit'; + await runFfmpeg([ + '-y', + // -ss before -i = fast input seek, drops the frozen lead-in entirely. + ...(seekSec > 0 ? ['-ss', seekSec.toFixed(3)] : []), + '-i', webmPath!, + // Pad-then-trim so an explicit per-frame length lands exactly (e.g. user + // asked 4s, animation ran 2.8s → hold the final frame to fill 4s). + ...(explicit ? ['-vf', `tpad=stop_mode=clone:stop_duration=${totalDuration}`] : []), + // Force exact duration: playwright's recordVideo sometimes overshoots + // by the time it takes to close the context. -t trims to the requested + // length (seconds, accepts fractions). + '-t', String(totalDuration), + '-r', String(fps), + '-c:v', 'libx264', + '-pix_fmt', 'yuv420p', + '-preset', 'medium', + '-crf', '20', + '-movflags', '+faststart', + input.config.outputPath, + ]); + + // Clean tmp dir + await rm(recordDir, { recursive: true, force: true }).catch(() => {}); + + const st = await stat(input.config.outputPath); + ctx.onProgress?.(100, 'done'); + return { + outputPath: input.config.outputPath, + meta: { + durationSec: totalDuration, + fileSizeBytes: st.size, + actualResolution: input.config.resolution, + fps, + renderedFrames: Math.round(totalDuration * fps), + renderWallClockSec: (Date.now() - t0) / 1000, + engineVersion: `hyperframes-playwright@${ADAPTER_VERSION}`, + }, + diagnostics: [`recorded via playwright/chromium then encoded with ffmpeg (libx264 crf20)`], + }; +} + +function runFfmpeg(args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + proc.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + proc.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + reject(new HtmlVideoError('render-failed', + 'ffmpeg not found on PATH. Install with `brew install ffmpeg` (macOS).')); + } else reject(err); + }); + proc.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new HtmlVideoError( + 'render-failed', + `ffmpeg exited ${code}: ${stderr.slice(-2000)}`, + )); + }); + }); +} + +/** + * Resolve the HTML to actually load into chromium. + * + * Single-file templates load as-is. Multi-composition templates declare their + * scenes as `
` placeholders; + * each composition file is a `