| 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 |