返回 html-video
2026-05-26-spec-01-engine-adapter.md
根目录 / research / 2026-05-26-spec-01-engine-adapter.md
1 # RFC-01:Engine Adapter 接口(核心抽象)
2
3 > **Status**: Draft v0.1
4 > **Date**: 2026-05-26
5 > **Scope**: 定义 html-video 跨 engine 的统一接口,让 Hyperframes / Remotion / Motion Canvas / Revideo 可插拔接入。
6 > **Audience**: 写 adapter 的 contributor / 集成 html-video 的 agent skill / 后续做 studio UI 的人
7
8 ---
9
10 ## 设计目标
11
12 1. **加新 backend 不改 core** —— 一个 backend = 一个独立 npm 包 `@html-video/adapter-<name>`,实现 `EngineAdapter` 接口即可
13 2. **agent 友好** —— `capabilities` 让 agent 程序化地决策"这个用例选哪个 engine"
14 3. **不强求功能并集** —— 弱 engine 不必假装支持所有功能,`validate()` 返回明确的 fail reason
15 4. **不发明 authoring 范式** —— 用户写的还是 HTML / React / TS-generator 原生,html-video 不引入第四种 DSL
16
17 ---
18
19 ## 接口(TypeScript)
20
21 ```ts
22 // core/types.ts
23
24 export interface EngineAdapter {
25 /** 稳定的 ID,下划线/小写。例: "hyperframes" "remotion" "motion-canvas" "revideo" */
26 id: EngineId;
27
28 /** 人类可读名称 */
29 name: string;
30
31 /** 版本:upstream engine 版本(例 hyperframes@0.4.x),不是 adapter 自身版本 */
32 upstreamVersion: string;
33
34 /** 静态能力声明 —— agent 决策的关键依据 */
35 capabilities: EngineCapabilities;
36
37 /** 校验 template 能否被本 engine 渲染。不实际渲染。同步即可 */
38 validate(template: Template): ValidationResult;
39
40 /** 渲染入口 */
41 render(input: RenderInput, ctx: RenderContext): Promise<RenderOutput>;
42
43 /** 启动本地 preview server(studio 用);返回 dev URL */
44 preview(template: Template, ctx: PreviewContext): Promise<PreviewHandle>;
45
46 /** 列出 engine 知道的 native template 路径。给 template registry 做 import 用 */
47 listNativeTemplates?(): Promise<NativeTemplateRef[]>;
48 }
49
50 export type EngineId = string; // 不预设枚举,让第三方 adapter 自由扩展
51
52 export interface EngineCapabilities {
53 /** 输入范式:用户写代码用的语言/形态 */
54 paradigms: Paradigm[];
55
56 /** 输出格式 */
57 outputFormats: OutputFormat[];
58
59 /** 最大分辨率(adapter 实现层 cap,不是物理上限) */
60 maxResolution: { width: number; height: number };
61
62 /** 是否支持透明通道 */
63 alpha: boolean;
64
65 /** 音频:none = 不能加音轨;single = 一条;multi = 多轨混音 */
66 audio: 'none' | 'single' | 'multi';
67
68 /** 字幕:none / burn-in / sidecar (vtt/srt) */
69 subtitles: ('none' | 'burn-in' | 'sidecar')[];
70
71 /** 渲染 backend(关系到部署) */
72 renderTarget: ('local-chromium' | 'local-canvas' | 'lambda' | 'cloud-run')[];
73
74 /** License tier:影响 agent 决策(自由用户可能想避开 commercial-restricted) */
75 licensing: 'free-osi' | 'commercial-restricted' | 'unknown';
76
77 /** 平均渲染速度 hint:1080p / 10sec / 60fps 在标准硬件上的秒数。粗略,作为 agent tie-breaker */
78 renderSpeedHint?: { resolution: string; durationSec: number; fps: number; estimatedRenderSec: number };
79
80 /** 特长场景,自由文本 tag。agent 用 fuzzy match */
81 bestFor: string[];
82
83 /** 自报短板,文字描述。Agent decision 文档化 */
84 weaknesses: string[];
85 }
86
87 export type Paradigm =
88 | 'html-css-gsap' // HF, htmlrec
89 | 'react-tsx' // Remotion, Rendiv, OpenMotion
90 | 'ts-generator' // Motion Canvas, Revideo
91 | 'json-scene' // Reelgen, VideoFlow
92 | 'imperative-canvas'; // 通用 canvas API
93
94 export type OutputFormat = 'mp4' | 'webm' | 'webm-alpha' | 'gif' | 'png-sequence' | 'apng';
95
96 export interface Template {
97 /** 跨引擎统一 id(见 RFC-02) */
98 id: string;
99 /** 这个 template 实际归属的 engine */
100 engine: EngineId;
101 /** 源代码路径(绝对或 monorepo 相对)—— adapter 知道怎么打开 */
102 sourcePath: string;
103 /** 用户传入的变量。schema 在 template metadata 里 */
104 variables?: Record<string, unknown>;
105 }
106
107 export interface RenderInput {
108 template: Template;
109 variables: Record<string, unknown>;
110 config: RenderConfig;
111 }
112
113 export interface RenderConfig {
114 format: OutputFormat;
115 resolution: { width: number; height: number };
116 fps: number;
117 /** 时长(秒)。某些 engine 是 template 内部决定的,传 'auto' */
118 duration: number | 'auto';
119 /** 输出路径(绝对路径) */
120 outputPath: string;
121 /** 透明背景(仅当 format=webm-alpha 或 png-sequence) */
122 alpha?: boolean;
123 /** 编码质量 0-100 或 ffmpeg-style preset */
124 quality?: number | 'low' | 'medium' | 'high' | 'lossless';
125 /** 音频文件,需要 capabilities.audio !== 'none' */
126 audio?: { path: string; volumeDb?: number }[];
127 }
128
129 export interface RenderContext {
130 /** 项目工作目录(容纳 cache / temp / output) */
131 workDir: string;
132 /** 进度回调(0-100)。可选 */
133 onProgress?: (pct: number, stage: string) => void;
134 /** 取消信号 */
135 signal?: AbortSignal;
136 /** dotenv-style 环境变量(adapter 透传给子进程) */
137 env?: Record<string, string>;
138 }
139
140 export interface RenderOutput {
141 outputPath: string;
142 meta: {
143 durationSec: number;
144 fileSizeBytes: number;
145 actualResolution: { width: number; height: number };
146 fps: number;
147 renderedFrames: number;
148 renderWallClockSec: number;
149 /** 实际用的 engine 版本(runtime 探测) */
150 engineVersion: string;
151 };
152 /** stderr / log 摘要,调试用 */
153 diagnostics: string[];
154 }
155
156 export interface ValidationResult {
157 ok: boolean;
158 errors: ValidationError[];
159 warnings: ValidationWarning[];
160 }
161
162 export interface ValidationError {
163 code: string; // 例 'paradigm-mismatch' / 'unsupported-format' / 'missing-asset'
164 message: string;
165 /** 可选修复建议,agent 可以抓取直接 propose change */
166 fix?: string;
167 }
168
169 export interface ValidationWarning extends ValidationError {}
170
171 export interface PreviewContext {
172 workDir: string;
173 hostname?: string; // 默认 127.0.0.1
174 port?: number; // 默认 0 = 自动分配
175 }
176
177 export interface PreviewHandle {
178 url: string;
179 port: number;
180 /** 关闭 preview server */
181 close(): Promise<void>;
182 }
183
184 export interface NativeTemplateRef {
185 /** engine-native id(不强制跟跨引擎 id 一样) */
186 nativeId: string;
187 /** engine-native 路径 */
188 path: string;
189 /** 自报描述,html-video 可借此构建 metadata */
190 hints?: { name?: string; description?: string; bestFor?: string[] };
191 }
192 ```
193
194 ---
195
196 ## 4 个 backend 的 capability 声明(示例)
197
198 ### Hyperframes
199
200 ```ts
201 {
202 id: 'hyperframes',
203 name: 'Hyperframes',
204 upstreamVersion: '0.x',
205 capabilities: {
206 paradigms: ['html-css-gsap'],
207 outputFormats: ['mp4', 'webm', 'webm-alpha', 'png-sequence'],
208 maxResolution: { width: 3840, height: 2160 },
209 alpha: true,
210 audio: 'multi',
211 subtitles: ['burn-in', 'sidecar'],
212 renderTarget: ['local-chromium', 'lambda'],
213 licensing: 'free-osi',
214 renderSpeedHint: { resolution: '1080p', durationSec: 10, fps: 60, estimatedRenderSec: 18 },
215 bestFor: ['social-shorts', 'product-marketing', 'logo-reveal', 'gsap-animations'],
216 weaknesses: ['no-react-ecosystem', 'limited-3d-without-three.js']
217 }
218 }
219 ```
220
221 ### Remotion
222
223 ```ts
224 {
225 id: 'remotion',
226 name: 'Remotion',
227 upstreamVersion: '4.x',
228 capabilities: {
229 paradigms: ['react-tsx'],
230 outputFormats: ['mp4', 'webm', 'gif', 'png-sequence'],
231 maxResolution: { width: 7680, height: 4320 },
232 alpha: false, // webm-alpha 支持但有 caveat,先不暴露
233 audio: 'multi',
234 subtitles: ['burn-in', 'sidecar'],
235 renderTarget: ['local-chromium', 'lambda', 'cloud-run'],
236 licensing: 'commercial-restricted', // 4+ devs 付费
237 renderSpeedHint: { resolution: '1080p', durationSec: 10, fps: 60, estimatedRenderSec: 14 },
238 bestFor: ['react-team', 'lambda-scale', 'data-driven', 'long-form-narration'],
239 weaknesses: ['license-cost-at-scale', 'react-only']
240 }
241 }
242 ```
243
244 ### Motion Canvas
245
246 ```ts
247 {
248 id: 'motion-canvas',
249 name: 'Motion Canvas',
250 upstreamVersion: '3.x',
251 capabilities: {
252 paradigms: ['ts-generator'],
253 outputFormats: ['mp4', 'png-sequence'],
254 maxResolution: { width: 3840, height: 2160 },
255 alpha: false, // canvas-based 默认 opaque
256 audio: 'single',
257 subtitles: ['burn-in'],
258 renderTarget: ['local-canvas'], // 浏览器内或 Node canvas,不直接 lambda
259 licensing: 'free-osi',
260 renderSpeedHint: { resolution: '1080p', durationSec: 10, fps: 60, estimatedRenderSec: 22 },
261 bestFor: ['explainer-videos', 'math-visualization', 'code-block-animation', 'latex'],
262 weaknesses: ['no-html-css', 'single-author-style', 'no-server-render-natively']
263 }
264 }
265 ```
266
267 ### Revideo
268
269 ```ts
270 {
271 id: 'revideo',
272 name: 'Revideo',
273 upstreamVersion: '0.4.x',
274 capabilities: {
275 paradigms: ['ts-generator'],
276 outputFormats: ['mp4', 'webm'],
277 maxResolution: { width: 3840, height: 2160 },
278 alpha: false,
279 audio: 'multi',
280 subtitles: ['burn-in'],
281 renderTarget: ['local-canvas', 'cloud-run'],
282 licensing: 'free-osi',
283 renderSpeedHint: { resolution: '1080p', durationSec: 10, fps: 60, estimatedRenderSec: 16 },
284 bestFor: ['saas-pipelines', 'parameterized-batch-render', 'ts-team-without-react'],
285 weaknesses: ['smaller-community', 'canvas-not-html']
286 }
287 }
288 ```
289
290 ---
291
292 ## render() 行为约定
293
294 ### 1. 不可变性
295
296 `render()` 不修改用户 source(template `sourcePath`)。所有临时产物(middleframes / sprite / cache)写到 `ctx.workDir`。
297
298 ### 2. 进程隔离
299
300 每次 `render()` 启动独立子进程跑实际 engine:
301
302 - HF: 起 puppeteer + ffmpeg
303 - Remotion: 起 `@remotion/renderer` 或 lambda invoke
304 - Motion Canvas: 起 vite dev server + browser,frame 抓取
305 - Revideo: 调 `renderVideo()` Node API
306
307 子进程崩溃 / 卡死,adapter 必须捕获并 reject promise,`outputPath` 文件不留半成品(成功后再 rename)。
308
309 ### 3. 进度报告
310
311 - 全 engine 都按"当前帧/总帧数"算 0-100%
312 - 起步阶段(编译/打包/启动 chromium)报 stage `'preparing'` 0-10%
313 - 渲染阶段 stage `'rendering'` 10-95%
314 - mux/转码 stage `'muxing'` 95-100%
315
316 ### 4. 取消
317
318 `ctx.signal.aborted` → adapter 必须 kill 子进程,cleanup workDir 临时文件,reject `AbortError`。
319
320 ### 5. 错误码(统一)
321
322 | Code | 含义 | retryable |
323 |---|---|---|
324 | `engine-not-installed` | 用户没装 npm 包 / lambda 无凭证 | no |
325 | `template-invalid` | validate() 已 fail,仍调 render | no |
326 | `render-failed` | engine 子进程非 0 退出,diagnostics 含详情 | maybe |
327 | `render-timeout` | 超 ctx.timeout(默认 5min/分辨率比例) | maybe |
328 | `output-corrupt` | mux 完文件不可读 | yes |
329 | `disk-full` | workDir 写不下 | no |
330 | `cancelled` | signal aborted | no |
331
332 ---
333
334 ## validate() 行为约定
335
336 `validate(template)` 必须**只读**且**快**(< 50ms)。它做:
337
338 1. 读 template metadata(RFC-02),看 `engine` 字段是不是自己
339 2. 看 template 的 paradigm 在自己 `capabilities.paradigms` 里
340 3. 看 template 声明的 output format 在自己 `capabilities.outputFormats` 里
341 4. 看 template `sourcePath` 实际存在(不读内容)
342 5. 不要执行用户代码 / 不启 chromium
343
344 返回的 `errors` 让 agent 可读:
345
346 ```ts
347 {
348 code: 'paradigm-mismatch',
349 message: 'Template uses react-tsx but motion-canvas adapter only supports ts-generator',
350 fix: 'Switch engine to remotion or rendiv'
351 }
352 ```
353
354 ---
355
356 ## preview() vs render()
357
358 `preview()` 启 dev server 让用户在浏览器里实时调试(HF studio / Remotion Studio / MC editor 都已有);adapter 把 dev server URL 透出来即可。`preview()` **可选实现**(先 OS 跑 render 也能用)。
359
360 ---
361
362 ## Adapter 包结构(约定)
363
364 ```
365 @html-video/adapter-hyperframes/
366 ├── package.json # peerDependency: hyperframes@^0.4.0
367 ├── src/
368 │ ├── index.ts # 导出 default: EngineAdapter 实例
369 │ ├── render.ts # render() 实现
370 │ ├── validate.ts # validate() 实现
371 │ ├── preview.ts # preview() 实现
372 │ └── capabilities.ts # 静态 capability 声明
373 └── README.md
374 ```
375
376 `@html-video/core` 通过 `import('@html-video/adapter-hyperframes')` 动态加载,**不**直接依赖任何 backend。
377
378 ---
379
380 ## 反例:错误的抽象会长什么样
381
382 设计过程中拒绝过的几条路线(明确写下来防后续偏航):
383
384 ❌ **统一 DSL**:发明 html-video 自己的"video markup",再编译到 4 个 backend。
385 → 好处:用户只学一套;坏处:永远落后任意 backend 的新 feature,4 个 transpile 永远做不全 + 维护地狱。html-video **不是新 DSL**。
386
387 ❌ **Frame-level 抽象**:把 backend 的 frame 输出统一成 PNG 序列再自己 mux。
388 → 好处:可复用 ffmpeg pipeline;坏处:每个 backend 都已经有自己的 mux 优化(HF/Remotion 直接 ffmpeg pipe,MC canvas-encode 不出 PNG),强行打散反而慢一倍。
389
390 ❌ **Sandbox / VM 隔离**:用 docker / vm 跑 backend。
391 → 好处:环境干净;坏处:本地 dev / 离线场景慢 10x,违反"agent friendly"原则。子进程隔离够用。
392
393 ---
394
395 ## Open Questions(v0.2 待定)
396
397 1. **template variables 的 schema 校验**:放 core 还是 adapter?目前倾向 core 用 zod 统一校验(agent 可读 schema 推参数)。
398 2. **音频混音**:core 提供帮工具(ffmpeg 调)还是 adapter 各自处理?倾向 core helper,因为 ffmpeg 不该重复封装。
399 3. **字幕**:burn-in / sidecar 谁烧入?倾向 adapter——某些 engine 已原生支持。
400 4. **资产打包**:用户引用的图片 / 视频 / 字体怎么收集进 render context?倾向 core 提供 `AssetResolver`,adapter 收 resolved paths。
401 5. **跨 engine retry**:如果 HF 渲染挂,core 是否自动 fallback 到 Revideo?v0.1 不做(决策权给 agent,不偷偷换 engine)。
402
402 lines MARKDOWN