返回 html-video
render.ts
1 // Remotion adapter render() — RFC-08 §5.
2 //
3 // bundle() once (cached) → selectComposition() with per-call metadata overrides
4 // → renderMedia() into a tmp file → rename to the final path. The HTML frame is
5 // copied into the bundle's public/ dir and handed to the bridge composition as
6 // inputProps.htmlSrc; the bridge keeps its CSS/GSAP animation in sync with
7 // Remotion's frame clock (HtmlFrameDriver.tsx).
8 import { createRequire } from 'node:module';
9 import { fileURLToPath } from 'node:url';
10 import { dirname, join, resolve } from 'node:path';
11 import { mkdtemp, mkdir, copyFile, readFile, rename, rm, stat } from 'node:fs/promises';
12 import { existsSync } from 'node:fs';
13 import { tmpdir } from 'node:os';
14 import type { RenderInput, RenderContext, RenderOutput } from '@html-video/core';
15
16 const require = createRequire(import.meta.url);
17 const here = dirname(fileURLToPath(import.meta.url));
18
19 // Minimal structural types for the Remotion APIs we touch (peer deps, may be absent).
20 type Bundler = (opts: { entryPoint: string; publicDir?: string }) => Promise<string>;
21 type Renderer = {
22 selectComposition: (opts: {
23 serveUrl: string;
24 id: string;
25 inputProps?: Record<string, unknown>;
26 }) => Promise<{ id: string; width: number; height: number; fps: number; durationInFrames: number }>;
27 renderMedia: (opts: Record<string, unknown>) => Promise<unknown>;
28 };
29
30 class EngineError extends Error {
31 constructor(public code: string, message: string) {
32 super(message);
33 this.name = 'EngineError';
34 }
35 }
36
37 function loadRemotion(): { bundle: Bundler; renderer: Renderer } {
38 try {
39 const { bundle } = require('@remotion/bundler') as { bundle: Bundler };
40 const renderer = require('@remotion/renderer') as Renderer;
41 return { bundle, renderer };
42 } catch (err) {
43 throw new EngineError(
44 'engine-not-installed',
45 `Remotion is not installed. Run \`pnpm add remotion @remotion/bundler @remotion/renderer react react-dom\` in the workspace root. (${err instanceof Error ? err.message : err})`,
46 );
47 }
48 }
49
50 // bundle() is an expensive webpack build — cache it across multi-frame renders.
51 // Keyed by entry path so the bridge entry AND each native template entry each
52 // bundle once and are reused across frames in one process. A video that enhances
53 // three data frames with the same native template bundles it once, not thrice.
54 // (RFC-08 §5 "bundle 复用" — generalized for Phase 2 native templates.)
55 const bundleCache = new Map<string, string>(); // entryPath -> serveUrl
56
57 async function bundleOnce(bundle: Bundler, entry: string, ctx: RenderContext): Promise<string> {
58 const cached = bundleCache.get(entry);
59 if (cached) return cached;
60 ctx.onProgress?.(15, 'bundling');
61 const serveUrl = await bundle({ entryPoint: entry });
62 bundleCache.set(entry, serveUrl);
63 return serveUrl;
64 }
65
66 /** Locate bridge/entry — present in src during dev (ts-node/tsx) and copied to
67 * the package root `bridge/` when published. We ship the .tsx; Remotion bundles it. */
68 function bridgeEntry(): string {
69 const candidates = [
70 join(here, 'bridge', 'entry.ts'), // dist/bridge if ever emitted
71 join(here, '..', 'src', 'bridge', 'entry.ts'), // dist/ -> src/bridge (dev)
72 join(here, '..', 'bridge', 'entry.ts'), // published package `bridge/`
73 ];
74 for (const c of candidates) if (existsSync(c)) return c;
75 throw new EngineError('render-failed', `bridge entry not found (looked in: ${candidates.join(', ')})`);
76 }
77
78 /**
79 * Make an HTML frame safe to render inside Remotion's headless chromium iframe.
80 *
81 * A render-blocking external `<link rel="stylesheet">` (e.g. Google Fonts) keeps
82 * the iframe's render tree from painting until the stylesheet resolves. In the
83 * headless render environment that request is slow or unreachable, so it never
84 * resolves and Remotion screenshots a fully black frame — even though the DOM is
85 * correct. (Same family as the file:// fetch issue tracked in #16/#18.)
86 *
87 * Video rendering must be deterministic and offline-safe, so we do NOT gamble on
88 * a network font load: we convert blocking external stylesheet links into
89 * non-blocking async loads (media="print" + onload swap). The font applies if it
90 * arrives in time; if not, the CSS `font-family` fallback (templates declare one,
91 * e.g. `'Archivo Black', sans-serif`) renders instead. Either way paint is never
92 * blocked. Inline <style> and same-document CSS are untouched.
93 */
94 export function neutralizeBlockingResources(html: string): string {
95 return html.replace(
96 /<link\b[^>]*\brel=(["']?)stylesheet\1[^>]*>/gi,
97 (tag) => {
98 // Only neutralize links to an external origin (http/https/protocol-relative).
99 if (!/\bhref=(["'])(?:https?:)?\/\//i.test(tag)) return tag;
100 if (/\bmedia=/i.test(tag)) return tag; // already has media handling — leave it
101 // Load async (media="print" doesn't block paint), then swap to "all" onload.
102 return tag.replace(
103 /\s*\/?>$/,
104 ` media="print" onload="this.media='all'">`,
105 );
106 },
107 );
108 }
109
110 /**
111 * Shared select→render→atomic-rename, used by both the bridge and native paths.
112 * Selects `compositionId` on the bundle, overrides its metadata per call, renders
113 * to a tmp file, then renames into place (RFC-01 immutability).
114 */
115 async function renderComposition(args: {
116 renderer: Renderer;
117 serveUrl: string;
118 compositionId: string;
119 inputProps: Record<string, unknown>;
120 width: number;
121 height: number;
122 fps: number;
123 durationInFrames: number;
124 config: RenderInput['config'];
125 ctx: RenderContext;
126 }): Promise<void> {
127 const { renderer, serveUrl, compositionId, inputProps, width, height, fps, durationInFrames, config, ctx } = args;
128
129 if (ctx.signal?.aborted) throw new EngineError('cancelled', 'Aborted');
130
131 // --- select composition (override metadata per call) ---
132 ctx.onProgress?.(30, 'selecting composition');
133 const composition = await renderer.selectComposition({ serveUrl, id: compositionId, inputProps });
134 const comp = { ...composition, width, height, fps, durationInFrames };
135
136 // --- render to tmp, then atomic rename (RFC-01 immutability) ---
137 const tmpDir = await mkdtemp(join(tmpdir(), 'hv-remotion-'));
138 const tmpOut = join(tmpDir, `out.${config.format === 'webm' ? 'webm' : 'mp4'}`);
139 const codec = config.format === 'webm' ? 'vp8' : config.format === 'gif' ? 'gif' : 'h264';
140
141 try {
142 await renderer.renderMedia({
143 composition: comp,
144 serveUrl,
145 codec,
146 outputLocation: tmpOut,
147 inputProps,
148 onProgress: ({ progress }: { progress: number }) => {
149 // renderMedia 0..1 → our 30..90 band.
150 ctx.onProgress?.(30 + Math.round(progress * 60), 'rendering');
151 },
152 });
153 ctx.onProgress?.(92, 'finalising');
154 await rename(tmpOut, config.outputPath).catch(async () => {
155 // cross-device rename fails → copy fallback
156 await copyFile(tmpOut, config.outputPath);
157 });
158 } catch (err) {
159 if (ctx.signal?.aborted) throw new EngineError('cancelled', 'Aborted');
160 throw new EngineError('render-failed', `Remotion renderMedia failed: ${err instanceof Error ? err.message : err}`);
161 } finally {
162 await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
163 }
164 }
165
166 export async function render(input: RenderInput, ctx: RenderContext): Promise<RenderOutput> {
167 const t0 = Date.now();
168 const { bundle, renderer } = loadRemotion();
169 ctx.onProgress?.(5, 'preparing');
170
171 const { sourcePath } = input.template;
172 const isNative = input.template.mode === 'native';
173 if (!sourcePath || !existsSync(sourcePath)) {
174 throw new EngineError(
175 'template-invalid',
176 `${isNative ? 'Native template entry' : 'Source HTML'} not found: ${sourcePath}`,
177 );
178 }
179
180 const { width, height } = input.config.resolution;
181 const fps = input.config.fps || 30;
182 const requested = input.config.duration === 'auto' ? 5 : Math.max(0.5, Number(input.config.duration));
183 const durationInFrames = Math.max(1, Math.round(requested * fps));
184
185 const outDir = dirname(input.config.outputPath);
186 await mkdir(outDir, { recursive: true });
187
188 if (isNative) {
189 // --- Phase 2 native path: bundle the template's OWN entry and render its
190 // composition directly. The real data rides in as inputProps (the component
191 // animates props.data via interpolate/spring); no HTML, no neutralize. ---
192 const compositionId = input.template.nativeCompositionId;
193 if (!compositionId) {
194 throw new EngineError('template-invalid', 'Native template missing nativeCompositionId');
195 }
196 const serveUrl = await bundleOnce(bundle, sourcePath, ctx);
197 // The native component reads `data` (+ optional accent/background/foreground);
198 // width/height let calculateMetadata size the canvas for non-16:9 frames.
199 const { data, ...restVars } = input.variables ?? {};
200 const inputProps: Record<string, unknown> = { data, width, height, ...restVars };
201 await renderComposition({
202 renderer, serveUrl, compositionId, inputProps,
203 width, height, fps, durationInFrames, config: input.config, ctx,
204 });
205 } else {
206 // --- Phase 1 bridge path: render the given HTML on the bridge composition. ---
207 const serveUrl = await bundleOnce(bundle, bridgeEntry(), ctx);
208 // Read the HTML frame and pass its full source as inputProps. The bridge
209 // renders it via iframe srcdoc — no staticFile / publicDir / serve-path
210 // resolution, and srcdoc is same-origin so the per-frame animation seek works.
211 const rawHtml = await readFile(sourcePath, 'utf8');
212 const html = neutralizeBlockingResources(rawHtml);
213 await renderComposition({
214 renderer, serveUrl, compositionId: 'HtmlFrame', inputProps: { html, width, height },
215 width, height, fps, durationInFrames, config: input.config, ctx,
216 });
217 }
218
219 const st = await stat(input.config.outputPath);
220 ctx.onProgress?.(100, 'done');
221 return {
222 outputPath: resolve(input.config.outputPath),
223 meta: {
224 durationSec: durationInFrames / fps,
225 fileSizeBytes: st.size,
226 actualResolution: { width, height },
227 fps,
228 renderedFrames: durationInFrames,
229 renderWallClockSec: (Date.now() - t0) / 1000,
230 engineVersion: remotionVersion(),
231 },
232 diagnostics: [],
233 };
234 }
235
236 function remotionVersion(): string {
237 try {
238 return (require('remotion/package.json') as { version: string }).version;
239 } catch {
240 return '4.x';
241 }
242 }
243
243 lines TYPESCRIPT