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