| 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 | '&': '&', |
| 607 | '<': '<', |
| 608 | '>': '>', |
| 609 | '"': '"', |
| 610 | "'": ''', |
| 611 | }; |
| 612 | return map[c] ?? c; |
| 613 | }); |
| 614 | } |
| 615 | |
| 616 | // silence unused imports warning until real impl uses them |
| 617 | void stat; |
| 618 |