返回 html-video
render.ts
1 /**
2 * Hyperframes render() — real recording via Playwright + ffmpeg.
3 *
4 * Per-frame strategy (orchestrator already loops per node and concats):
5 * 1. Launch chromium headless at the configured resolution
6 * 2. recordVideo into a tmp dir
7 * 3. file:// load the frame HTML
8 * 4. wait `durationSec` so any opening animation completes + plays
9 * 5. close → playwright dumps a webm
10 * 6. ffmpeg transmux/encode the webm to mp4 at `outputPath`
11 *
12 * Upstream Hyperframes was never required at runtime for this adapter —
13 * our generated HTML is plain inline-CSS+JS, chromium runs it as-is.
14 */
15
16 import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
17 import { existsSync, readdirSync } from 'node:fs';
18 import { spawn } from 'node:child_process';
19 import { tmpdir } from 'node:os';
20 import { dirname, join } from 'node:path';
21 import { pathToFileURL } from 'node:url';
22 import type {
23 HtmlSceneOutput,
24 RenderContext,
25 RenderInput,
26 RenderOutput,
27 } from '@html-video/core';
28 import { HtmlVideoError } from '@html-video/core';
29
30 const ADAPTER_VERSION = '0.2.0-playwright';
31
32 /** Real render: chromium records the page, ffmpeg transcodes to MP4. */
33 export async function render(input: RenderInput, ctx: RenderContext): Promise<RenderOutput> {
34 const t0 = Date.now();
35 ctx.onProgress?.(5, 'preparing');
36 const outDir = dirname(input.config.outputPath);
37 await mkdir(outDir, { recursive: true });
38 if (ctx.signal?.aborted) throw new HtmlVideoError('cancelled', 'Aborted');
39
40 // Resolve the source HTML path. Templates pass an absolute path already;
41 // multi-frame `core` calls pass the per-frame HTML path the same way.
42 if (!existsSync(input.template.sourcePath)) {
43 throw new HtmlVideoError(
44 'template-invalid',
45 `Source HTML not found: ${input.template.sourcePath}`,
46 );
47 }
48
49 let totalDuration =
50 input.config.duration === 'auto' ? 5 : Math.max(0.5, Number(input.config.duration));
51 const { width, height } = input.config.resolution;
52 const fps = input.config.fps || 30;
53
54 // Lazy-load playwright so the import cost only hits actual exports.
55 ctx.onProgress?.(15, 'launching browser');
56 const playwright = await import('playwright').catch((err) => {
57 throw new HtmlVideoError(
58 'render-failed',
59 `playwright not installed (run \`pnpm install\` from the monorepo root). ${err instanceof Error ? err.message : err}`,
60 );
61 });
62
63 const recordDir = await mkdtemp(join(tmpdir(), 'hv-render-'));
64 let browser: import('playwright').Browser | undefined;
65 let webmPath: string | undefined;
66 let cleanupSrc: (() => Promise<void>) | undefined;
67 // Wall-clock offset (ms) from when the webm starts recording to when we
68 // actually start the animation, so ffmpeg can trim the dead opening lead-in.
69 let leadInMs = 0;
70 try {
71 browser = await playwright.chromium.launch({
72 headless: true,
73 args: ['--no-sandbox', '--disable-blink-features=AutomationControlled'],
74 });
75 // recordVideo starts capturing the moment the context exists, so this is
76 // the webm's t=0 reference.
77 const tWebmStart = Date.now();
78 const context = await browser.newContext({
79 viewport: { width, height },
80 deviceScaleFactor: 1,
81 recordVideo: { dir: recordDir, size: { width, height } },
82 });
83 const page = await context.newPage();
84
85 // Freeze all CSS/SMIL animations the instant the document starts parsing,
86 // BEFORE any @keyframes can begin counting down. Single-file templates are
87 // pure CSS `animation: … forwards` timelines with no JS trigger — they
88 // start running on the wall clock the moment the element is styled, i.e.
89 // right after goto(). Meanwhile we then spend ~2–3s waiting for the Google
90 // Fonts faces (Shrikhand et al.) to download. Without this freeze the whole
91 // opening (text fading in while the real face is still downloading, then
92 // the swap) plays out during that font wait and gets recorded. Pausing all
93 // animations up front lets us hold the timeline at frame 0 until fonts are
94 // ready, then release it so capture and motion start together — the same
95 // shape as the multi-composition paused→drive path below.
96 await page.addInitScript(() => {
97 const style = document.createElement('style');
98 style.id = '__hv_freeze';
99 style.textContent =
100 '*, *::before, *::after { animation-play-state: paused !important;' +
101 ' -webkit-animation-play-state: paused !important; }';
102 const attach = () => (document.head || document.documentElement).appendChild(style);
103 if (document.head || document.documentElement) attach();
104 else document.addEventListener('DOMContentLoaded', attach, { once: true });
105 (window as unknown as { __hvUnfreeze?: () => void }).__hvUnfreeze = () => {
106 document.getElementById('__hv_freeze')?.remove();
107 };
108 });
109
110 ctx.onProgress?.(30, 'loading frame');
111 // Multi-composition templates ship an entry index.html that only stitches
112 // sub-scenes via `data-composition-src="compositions/x.html"`; loaded raw
113 // over file:// the scenes never appear (chromium blocks file:// fetch, so
114 // the studio's client-side fetch player can't run here). Inline the
115 // composition files into the HTML up front so chromium records real motion
116 // instead of an empty shell. Single-file templates pass through untouched.
117 const prepared = await prepareSourceHtml(input.template.sourcePath);
118 cleanupSrc = prepared.cleanup;
119 const fileUrl = pathToFileURL(prepared.loadPath).href;
120 // Wait only for the DOM + same-document scripts (GSAP, the inline player),
121 // NOT `load` — `load` blocks on every external asset, and some templates
122 // reference a cross-origin A-Roll video (e.g. an S3 mp4 with no CORS
123 // header) that chromium retries for ~4s before giving up. Under `load`
124 // those ~4s get recorded into the webm as a frozen first scene before the
125 // timeline ever plays, so the clip opens on several dead seconds. Fonts are
126 // awaited separately below (document.fonts.ready); GSAP is a synchronous
127 // <head> script so it's ready at DOMContentLoaded.
128 await page.goto(fileUrl, { waitUntil: 'domcontentloaded' });
129
130 // Wait for all web fonts to finish loading BEFORE recording. Templates
131 // pull display faces (Shrikhand, Libre Baskerville, Archivo Black, …) from
132 // Google Fonts with `font-display: swap`, which paints text in a fallback
133 // system font immediately and swaps in the real face once it downloads.
134 // If we start recording before the swap, the video shows a visible flash:
135 // the text renders in the fallback for the first frames, then the glyphs,
136 // widths and weights snap to the intended font mid-clip.
137 //
138 // `document.fonts.ready` alone is NOT enough here, and this was the bug in
139 // the first cut of this fix. We load the page with `domcontentloaded` (so a
140 // CORS-blocked A-Roll video can't freeze the opening — see above), which
141 // means at this point the Google Fonts <link> stylesheet has usually not
142 // come back yet. Until that CSS arrives, its @font-face rules are not in
143 // `document.fonts` at all, so `fonts.ready` sees an empty set and resolves
144 // INSTANTLY — recording starts, then the CSS lands, the faces download, and
145 // the swap happens mid-clip anyway. So we must, in order:
146 // 1. wait for every stylesheet <link> to load (or error) — this is what
147 // actually registers the @font-face rules into document.fonts;
148 // 2. explicitly fonts.load() each registered face — `display: swap` does
149 // NOT auto-download a face until something paints with it, and our
150 // off-screen/pre-animation text may not have triggered that yet;
151 // 3. then await fonts.ready, plus one rAF, so layout settles on the real
152 // glyph metrics before frame 0.
153 // Everything is capped so a slow/blocked font CDN can't stall forever —
154 // worst case we fall back to the previous behavior for that one frame.
155 ctx.onProgress?.(32, 'loading fonts');
156 await page
157 .evaluate(
158 () =>
159 new Promise<void>((resolve) => {
160 const doc = document as Document & { fonts?: FontFaceSet };
161 const fonts = doc.fonts;
162 if (!fonts || typeof fonts.ready?.then !== 'function') {
163 resolve();
164 return;
165 }
166
167 let settled = false;
168 const finish = () => {
169 if (settled) return;
170 settled = true;
171 // One more frame so the relayout on the real face is painted.
172 requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
173 };
174 // Hard cap: a blocked CDN must never stall the render.
175 const cap = setTimeout(finish, 8000);
176
177 // 1. Wait for stylesheet <link>s to load (registers @font-face).
178 const links = Array.from(
179 document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
180 );
181 const linkDone = links.map((link) => {
182 // An already-loaded sheet exposes cssRules without throwing.
183 try {
184 if (link.sheet && link.sheet.cssRules) return Promise.resolve();
185 } catch {
186 /* not ready yet — fall through to event wait */
187 }
188 return new Promise<void>((r) => {
189 const done = () => r();
190 link.addEventListener('load', done, { once: true });
191 link.addEventListener('error', done, { once: true });
192 // Per-link safety so one wedged link can't hold the batch.
193 setTimeout(done, 6000);
194 });
195 });
196
197 Promise.all(linkDone)
198 .then(() => {
199 // 2. Force every registered face to actually download. Under
200 // `display: swap` the browser otherwise defers the fetch.
201 const loads: Promise<unknown>[] = [];
202 fonts.forEach((face) => {
203 try {
204 loads.push(face.load().catch(() => undefined));
205 } catch {
206 /* some faces reject load() pre-paint — ignore */
207 }
208 });
209 return Promise.all(loads);
210 })
211 // 3. Now ready() reflects the real face set.
212 .then(() => fonts.ready)
213 .then(() => {
214 clearTimeout(cap);
215 finish();
216 })
217 .catch(() => {
218 clearTimeout(cap);
219 finish();
220 });
221 }),
222 )
223 .catch(() => {});
224
225 // Pages sometimes set up animations on the load tick — give a frame
226 // for animations to actually start before we count the duration.
227 await page.waitForTimeout(100);
228
229 // Probe the frame's own animation length so we never cut it off. A short
230 // per-frame duration set by the user could be < the frame's opening
231 // animation, truncating it mid-play. Take the longer of the two: the frame
232 // gets at least as long as its non-looping CSS animations / GSAP timeline.
233 try {
234 const animMs = await page.evaluate(() => {
235 let maxMs = 0;
236 Array.from(document.querySelectorAll('*')).forEach((el) => {
237 const s = getComputedStyle(el);
238 const durs = (s.animationDuration || '').split(',');
239 const dels = (s.animationDelay || '').split(',');
240 const iters = (s.animationIterationCount || '').split(',');
241 durs.forEach((d, i) => {
242 if ((iters[i] || '').trim() === 'infinite') return; // ignore looping bg anims
243 maxMs = Math.max(maxMs, ((parseFloat(d) || 0) + (parseFloat(dels[i] || '0') || 0)) * 1000);
244 });
245 });
246 // GSAP: do NOT use globalTimeline.totalDuration() — an infinitely
247 // repeating tween (repeat:-1, e.g. a blinking cursor) makes it ~1e10s.
248 // Walk the children and take the longest FINITE (non-repeat:-1) tween.
249 const g = (window as unknown as {
250 gsap?: { globalTimeline?: { getChildren?: (b?: boolean, t?: boolean, tl?: boolean) => Array<{ totalDuration?: () => number; repeat?: () => number; vars?: { repeat?: number } }> } };
251 }).gsap;
252 let gsapMs = 0;
253 const children = g?.globalTimeline?.getChildren?.(true, true, true) ?? [];
254 for (const c of children) {
255 const repeat = typeof c.repeat === 'function' ? c.repeat() : (c.vars?.repeat ?? 0);
256 if (repeat === -1) continue; // infinite loop — ignore
257 const td = typeof c.totalDuration === 'function' ? c.totalDuration() : 0;
258 if (Number.isFinite(td)) gsapMs = Math.max(gsapMs, td * 1000);
259 }
260 return Math.max(maxMs, gsapMs);
261 });
262 // +0.4s settle so the final animation frame is actually captured; cap at
263 // 30s so a stray huge value can't make a frame run away.
264 const needed = Math.min(30, (animMs + 400) / 1000);
265 // Only extend when the duration is a soft 'auto' fallback. When the user
266 // set an explicit per-frame length (multi-frame export), it's a hard cap —
267 // honoring it keeps "每帧 4s" at 4s instead of letting one long animation
268 // stretch the frame toward the 30s ceiling.
269 if (input.config.durationMode !== 'explicit' && needed > totalDuration) {
270 ctx.onProgress?.(38, `extending to ${needed.toFixed(1)}s for animation`);
271 totalDuration = needed;
272 }
273 } catch { /* probe failed — fall back to the requested duration */ }
274
275 // Multi-composition templates register their master timeline paused so the
276 // probe above can read its real (finite) duration. Now that the recording
277 // window is fixed, drive playback from frame zero so capture and animation
278 // start together — otherwise the auto-play fallback would have already run
279 // part of the timeline before we begin recording.
280 const drove = await page
281 .evaluate(() => {
282 const w = window as unknown as { __hvPlayAll?: () => void; __hvPlayed?: boolean };
283 if (typeof w.__hvPlayAll === 'function') {
284 w.__hvPlayed = true;
285 w.__hvPlayAll();
286 return true;
287 }
288 return false;
289 })
290 .catch(() => false);
291
292 // Release the animation freeze now that fonts are ready. Every template —
293 // single-file CSS keyframes and multi-composition GSAP alike — has been
294 // held at frame 0 since before goto(), so the entire recorded lead-in
295 // (page load + cold font fetch, ~2–4s) is a still hold of the first frame
296 // with no motion. Unfreezing here is the true t=0 of the animation, so the
297 // lead-in is always dead and always safe to trim. (Previously only
298 // multi-composition templates were parked, so single-file ones recorded
299 // their opening during the font wait and showed the fallback-font flash.)
300 await page
301 .evaluate(() => {
302 (window as unknown as { __hvUnfreeze?: () => void }).__hvUnfreeze?.();
303 })
304 .catch(() => {});
305 leadInMs = Date.now() - tWebmStart;
306 void drove; // playback already driven above for multi-composition timelines
307
308 ctx.onProgress?.(40, `recording ${totalDuration}s`);
309 // Stream a single coarse progress tick per second so the user sees
310 // "recording 1/5s …" type signal in the studio progress bar.
311 const totalMs = Math.round(totalDuration * 1000);
312 const tick = 250;
313 const start = Date.now();
314 while (Date.now() - start < totalMs) {
315 if (ctx.signal?.aborted) throw new HtmlVideoError('cancelled', 'Aborted');
316 await page.waitForTimeout(Math.min(tick, totalMs - (Date.now() - start)));
317 const pct = 40 + Math.floor(((Date.now() - start) / totalMs) * 45);
318 ctx.onProgress?.(pct, 'recording');
319 }
320
321 ctx.onProgress?.(85, 'finalising recording');
322 await context.close();
323 // playwright drops the webm into recordDir; pick the freshest .webm
324 const candidates = readdirSync(recordDir).filter((f) => f.endsWith('.webm'));
325 if (candidates.length === 0) {
326 throw new HtmlVideoError('render-failed', `Playwright produced no webm in ${recordDir}`);
327 }
328 candidates.sort();
329 webmPath = join(recordDir, candidates[candidates.length - 1]!);
330 } finally {
331 if (browser) await browser.close().catch(() => {});
332 if (cleanupSrc) await cleanupSrc().catch(() => {});
333 }
334
335 // ---- ffmpeg: webm → mp4 ----
336 ctx.onProgress?.(90, 'encoding mp4');
337 // Trim the dead lead-in (page load + font fetch before the timeline played)
338 // off the front of multi-composition webms. Back off 120ms so rounding /
339 // recorder start jitter can't clip the first real animation frame — a couple
340 // of still frames at the head are harmless, a missing opening beat is not.
341 const seekSec = leadInMs > 200 ? Math.max(0, (leadInMs - 120) / 1000) : 0;
342 // When the user set an explicit per-frame length, the output must be EXACTLY
343 // that long. The recorded webm can come up a little short (recorder start
344 // jitter, the lead-in trim, a sub-duration animation that finished early), so
345 // pad the tail by holding the last frame (`tpad stop_mode=clone`) up to the
346 // target. -t then trims to the precise length. For 'auto' we keep the old
347 // behavior (just -t, no padding) — there the duration is a soft fallback.
348 const explicit = input.config.durationMode === 'explicit';
349 await runFfmpeg([
350 '-y',
351 // -ss before -i = fast input seek, drops the frozen lead-in entirely.
352 ...(seekSec > 0 ? ['-ss', seekSec.toFixed(3)] : []),
353 '-i', webmPath!,
354 // Pad-then-trim so an explicit per-frame length lands exactly (e.g. user
355 // asked 4s, animation ran 2.8s → hold the final frame to fill 4s).
356 ...(explicit ? ['-vf', `tpad=stop_mode=clone:stop_duration=${totalDuration}`] : []),
357 // Force exact duration: playwright's recordVideo sometimes overshoots
358 // by the time it takes to close the context. -t trims to the requested
359 // length (seconds, accepts fractions).
360 '-t', String(totalDuration),
361 '-r', String(fps),
362 '-c:v', 'libx264',
363 '-pix_fmt', 'yuv420p',
364 '-preset', 'medium',
365 '-crf', '20',
366 '-movflags', '+faststart',
367 input.config.outputPath,
368 ]);
369
370 // Clean tmp dir
371 await rm(recordDir, { recursive: true, force: true }).catch(() => {});
372
373 const st = await stat(input.config.outputPath);
374 ctx.onProgress?.(100, 'done');
375 return {
376 outputPath: input.config.outputPath,
377 meta: {
378 durationSec: totalDuration,
379 fileSizeBytes: st.size,
380 actualResolution: input.config.resolution,
381 fps,
382 renderedFrames: Math.round(totalDuration * fps),
383 renderWallClockSec: (Date.now() - t0) / 1000,
384 engineVersion: `hyperframes-playwright@${ADAPTER_VERSION}`,
385 },
386 diagnostics: [`recorded via playwright/chromium then encoded with ffmpeg (libx264 crf20)`],
387 };
388 }
389
390 function runFfmpeg(args: string[]): Promise<void> {
391 return new Promise((resolve, reject) => {
392 const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
393 let stderr = '';
394 proc.stderr.on('data', (chunk: Buffer) => {
395 stderr += chunk.toString('utf8');
396 });
397 proc.on('error', (err: NodeJS.ErrnoException) => {
398 if (err.code === 'ENOENT') {
399 reject(new HtmlVideoError('render-failed',
400 'ffmpeg not found on PATH. Install with `brew install ffmpeg` (macOS).'));
401 } else reject(err);
402 });
403 proc.on('exit', (code) => {
404 if (code === 0) resolve();
405 else reject(new HtmlVideoError(
406 'render-failed',
407 `ffmpeg exited ${code}: ${stderr.slice(-2000)}`,
408 ));
409 });
410 });
411 }
412
413 /**
414 * Resolve the HTML to actually load into chromium.
415 *
416 * Single-file templates load as-is. Multi-composition templates declare their
417 * scenes as `<div data-composition-src="compositions/x.html">` placeholders;
418 * each composition file is a `<template>` wrapping markup + <style> + a <script>
419 * that registers a paused GSAP timeline on `window.__timelines[name]`. The
420 * studio preview assembles these client-side via fetch — but chromium blocks
421 * file:// fetch, so over file:// the scenes would never appear.
422 *
423 * This reads each composition file on the Node side, inlines them into a
424 * `window.__COMPOSITIONS__` map, and injects a player that grafts each
425 * `<template>.content` into its placeholder and re-executes the composition
426 * scripts (cloned <script> nodes never run on their own). The result is a
427 * self-contained HTML written next to the source (so sibling relative assets
428 * still resolve) and loaded over file://. Returns a cleanup() to remove it.
429 */
430 async function prepareSourceHtml(
431 sourcePath: string,
432 ): Promise<{ loadPath: string; cleanup?: () => Promise<void> }> {
433 const raw = await readFile(sourcePath, 'utf8');
434 const srcMatches = Array.from(raw.matchAll(/data-composition-src=["']([^"']+)["']/g));
435 if (srcMatches.length === 0) return { loadPath: sourcePath };
436
437 const srcDir = dirname(sourcePath);
438 const compMap: Record<string, string> = {};
439 for (const m of srcMatches) {
440 const rel = m[1]!;
441 if (compMap[rel] !== undefined) continue;
442 const compPath = join(srcDir, rel);
443 if (!existsSync(compPath)) continue;
444 compMap[rel] = await readFile(compPath, 'utf8');
445 }
446 if (Object.keys(compMap).length === 0) return { loadPath: sourcePath };
447
448 // Escape `</` (and the comment opener) so the JSON survives the inline
449 // <script> context — composition files contain their own </script> tags.
450 const safeJson = JSON.stringify(compMap).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
451
452 let out = raw
453 .replace(/__VIDEO_DURATION__/g, '15')
454 .replace(/__VIDEO_SRC__/g, 'data:video/mp4;base64,');
455
456 // Seed the timeline registry in <head> so the entry's own early
457 // `window.__timelines["x"] = …` assignments don't throw on undefined.
458 const head = `<script>window.__timelines=window.__timelines||{};window.__COMPOSITIONS__=${safeJson};</script>`;
459 out = /<head[^>]*>/i.test(out)
460 ? out.replace(/<head[^>]*>/i, (mm) => `${mm}\n${head}`)
461 : `${head}\n${out}`;
462
463 const player = `
464 <script>
465 (function () {
466 function reexec(root) {
467 root.querySelectorAll('script').forEach(function (old) {
468 if (old.src) { old.parentNode.removeChild(old); return; }
469 var s = document.createElement('script');
470 // Wrap each composition's inline script in a block so top-level
471 // \`const tl = …\` locals don't collide across scenes; the
472 // window.__timelines assignments still escape the block.
473 s.textContent = '{\\n' + old.textContent + '\\n}';
474 old.parentNode.replaceChild(s, old);
475 });
476 }
477 function mountOne(host) {
478 var src = host.getAttribute('data-composition-src');
479 var text = (window.__COMPOSITIONS__ || {})[src];
480 if (!text) return;
481 var holder = document.createElement('div');
482 holder.innerHTML = text;
483 var tpl = holder.querySelector('template');
484 host.appendChild(tpl ? tpl.content.cloneNode(true) : holder);
485 reexec(host);
486 }
487 // Play every registered timeline once from the start. Do NOT force
488 // repeat(-1): these composition timelines are finite, scene-by-scene
489 // narratives (e.g. kinetic-type is a 14.7s master timeline that wipes
490 // through 6 scenes). Looping them broke two things — it replayed the intro
491 // over the outro, and the renderer's duration probe SKIPS repeat:-1 tweens
492 // as "infinite background anim", so a looped master timeline read as 0s and
493 // the clip got truncated to the default 5s. Leaving them finite lets the
494 // probe see the real 14.7s and record the whole story.
495 window.__hvPlayAll = function () {
496 var tls = window.__timelines || {};
497 Object.keys(tls).forEach(function (k) {
498 var tl = tls[k];
499 if (tl && typeof tl.play === 'function') tl.play(0);
500 });
501 };
502 function boot() {
503 window.__timelines = window.__timelines || {};
504 Array.prototype.slice
505 .call(document.querySelectorAll('[data-composition-src]'))
506 .forEach(mountOne);
507 // The composition <script>s register their (paused) timelines synchronously
508 // as they're injected, so they're on window.__timelines now. Leave them
509 // paused here — the renderer probes their duration first, then calls
510 // window.__hvPlayAll() at the exact moment recording starts so playback and
511 // capture are aligned. If no driver calls it (e.g. opened standalone), fall
512 // back to auto-playing shortly after load.
513 setTimeout(function () { if (!window.__hvPlayed) window.__hvPlayAll(); }, 250);
514 }
515 if (document.readyState === 'loading') {
516 document.addEventListener('DOMContentLoaded', boot);
517 } else { boot(); }
518 })();
519 </script>`;
520 out = out.includes('</body>') ? out.replace('</body>', `${player}\n</body>`) : out + player;
521
522 const loadPath = join(srcDir, `.hv-render-${Date.now()}.html`);
523 await writeFile(loadPath, out, 'utf8');
524 return {
525 loadPath,
526 cleanup: async () => {
527 await rm(loadPath, { force: true }).catch(() => {});
528 },
529 };
530 }
531
532 /**
533 * Render template to a single HTML preview.
534 *
535 * v0.1: read the source HTML file (a Hyperframes template is HTML+CSS+JS),
536 * inject a banner showing the variables, copy referenced assets, write to ctx.workDir.
537 * Real upstream Hyperframes integration will replace the inject + add a frame-bound clock.
538 */
539 export async function renderToHtml(
540 input: RenderInput,
541 ctx: RenderContext,
542 ): Promise<HtmlSceneOutput> {
543 if (!existsSync(input.template.sourcePath)) {
544 throw new HtmlVideoError(
545 'template-invalid',
546 `Source not found: ${input.template.sourcePath}`,
547 );
548 }
549
550 await mkdir(ctx.workDir, { recursive: true });
551 const htmlPath = join(ctx.workDir, 'preview.html');
552 const posterPath = join(ctx.workDir, 'poster.svg');
553
554 const sourceHtml = await readFile(input.template.sourcePath, 'utf8');
555 const augmented = sourceHtml.replace(
556 '</body>',
557 `<script>
558 window.__HV_VARS__ = ${JSON.stringify(input.variables)};
559 window.__HV_DURATION__ = ${typeof input.config.duration === 'number' ? input.config.duration : 5};
560 console.log('html-video preview vars', window.__HV_VARS__);
561 </script></body>`,
562 );
563 await writeFile(htmlPath, augmented, 'utf8');
564
565 // Cheap poster: an SVG placeholder we draw ourselves (no headless chromium yet).
566 const { width, height } = input.config.resolution;
567 const title = String(input.variables.title ?? input.template.id);
568 const poster = `<?xml version="1.0" encoding="UTF-8"?>
569 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
570 <rect width="100%" height="100%" fill="#1a1a1a"/>
571 <text x="50%" y="50%" fill="#eee" font-family="Inter, system-ui, sans-serif"
572 font-size="72" text-anchor="middle" dominant-baseline="middle">${escapeXml(title)}</text>
573 <text x="50%" y="${height - 80}" fill="#888" font-family="monospace" font-size="32"
574 text-anchor="middle">hyperframes · ${input.template.id}</text>
575 </svg>`;
576 await writeFile(posterPath, poster, 'utf8');
577
578 // Copy any referenced asset files mentioned in variables (best-effort)
579 const referencedAssets: { assetId: string; usagePath: string }[] = [];
580 for (const v of Object.values(input.variables)) {
581 if (typeof v !== 'string') continue;
582 if (!v.includes('/.html-video/bundles/')) continue;
583 if (!existsSync(v)) continue;
584 const dest = join(ctx.workDir, 'assets', v.split('/').pop() ?? 'asset');
585 await mkdir(dirname(dest), { recursive: true });
586 if (!existsSync(dest)) await copyFile(v, dest);
587 const m = /assets\/([0-9a-f]{40})\./.exec(v);
588 if (m && m[1]) {
589 referencedAssets.push({ assetId: m[1], usagePath: dest });
590 }
591 }
592
593 const totalDuration =
594 input.config.duration === 'auto' ? 5 : input.config.duration;
595 return {
596 htmlPath,
597 referencedAssets,
598 posterPath,
599 durationSec: totalDuration,
600 };
601 }
602
603 function escapeXml(s: string): string {
604 return s.replace(/[&<>"']/g, (c) => {
605 const map: Record<string, string> = {
606 '&': '&amp;',
607 '<': '&lt;',
608 '>': '&gt;',
609 '"': '&quot;',
610 "'": '&apos;',
611 };
612 return map[c] ?? c;
613 });
614 }
615
616 // silence unused imports warning until real impl uses them
617 void stat;
618
618 lines TYPESCRIPT