From 31184087e16ee181f7c1b84385d8053e0bb8bbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 24 Apr 2026 15:46:18 -0400 Subject: [PATCH] fix: preserve transparent looped video frames in renders --- packages/cli/src/commands/preview.ts | 20 +++- packages/cli/src/commands/snapshot.ts | 41 +++++-- packages/cli/src/server/studioServer.ts | 88 +++++++++++++- .../core/src/compiler/htmlCompiler.test.ts | 23 ++++ packages/core/src/compiler/htmlCompiler.ts | 1 + .../core/src/compiler/timingCompiler.test.ts | 15 +++ packages/core/src/compiler/timingCompiler.ts | 4 +- .../src/services/videoFrameExtractor.test.ts | 107 +++++++++++++++++- .../src/services/videoFrameExtractor.ts | 59 ++++++++-- packages/engine/src/utils/ffprobe.test.ts | 33 ++++++ packages/engine/src/utils/ffprobe.ts | 10 ++ .../producer/src/services/htmlCompiler.ts | 6 +- .../src/services/renderOrchestrator.ts | 4 + 13 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/compiler/htmlCompiler.test.ts diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index b23e60995..db425ebdf 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -318,15 +318,31 @@ async function runEmbeddedMode( projectName?: string, forceNew = false, ): Promise { - const { createStudioServer } = await import("../server/studioServer.js"); + const { createStudioServer, resolveStudioBundle } = await import("../server/studioServer.js"); const pName = projectName ?? basename(dir); - const { app } = createStudioServer({ projectDir: dir, projectName: pName }); + const studioBundle = resolveStudioBundle(); clack.intro(c.bold("hyperframes preview")); const s = clack.spinner(); s.start("Starting studio..."); + if (!studioBundle.available) { + s.stop(c.error("Studio build missing")); + console.error(); + console.error(` ${c.dim("Could not find")} ${c.accent("index.html")} ${c.dim("in:")}`); + for (const checkedPath of studioBundle.checkedPaths) { + console.error(` ${c.dim("-")} ${checkedPath}`); + } + console.error(); + console.error(` ${c.dim("Rebuild the CLI package with")} ${c.accent("pnpm run build")}`); + console.error(); + process.exitCode = 1; + return; + } + + const { app } = createStudioServer({ projectDir: dir, projectName: pName }); + let result: FindPortResult; try { result = await findPortAndServe(app.fetch, startPort, dir, forceNew); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index bd8be250f..5b21d62b9 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -25,6 +25,7 @@ const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000; async function extractVideoFrameToBuffer( videoPath: string, timeSeconds: number, + useVp9AlphaDecoder = false, ): Promise { const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-")); const outPath = join(tmp, "frame.png"); @@ -33,10 +34,11 @@ async function extractVideoFrameToBuffer( (resolvePromise) => { // `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy // (±1 frame) and orders of magnitude faster than the decode-and-scan alternative. - const ff = spawn("ffmpeg", [ - "-hide_banner", - "-loglevel", - "error", + const args = ["-hide_banner", "-loglevel", "error"]; + if (useVp9AlphaDecoder) { + args.push("-c:v", "libvpx-vp9"); + } + args.push( "-ss", String(Math.max(0, timeSeconds)), "-i", @@ -47,7 +49,8 @@ async function extractVideoFrameToBuffer( "2", "-y", outPath, - ]); + ); + const ff = spawn("ffmpeg", args); let stderr = ""; let timedOut = false; const timer = setTimeout(() => { @@ -252,19 +255,36 @@ async function captureSnapshots( updates: Array<{ videoId: string; dataUri: string }>, ) => Promise; type SyncVisibilityFn = (page: unknown, activeVideoIds: string[]) => Promise; + type ExtractMediaMetadataFn = ( + filePath: string, + ) => Promise<{ videoCodec: string; hasAlpha: boolean }>; let injectVideoFramesBatch: InjectFn | null = null; let syncVideoFrameVisibility: SyncVisibilityFn | null = null; + let extractMediaMetadata: ExtractMediaMetadataFn | null = null; try { const engine = (await import("@hyperframes/engine")) as { injectVideoFramesBatch: InjectFn; syncVideoFrameVisibility: SyncVisibilityFn; + extractMediaMetadata: ExtractMediaMetadataFn; }; injectVideoFramesBatch = engine.injectVideoFramesBatch; syncVideoFrameVisibility = engine.syncVideoFrameVisibility; + extractMediaMetadata = engine.extractMediaMetadata; } catch { // Engine unavailable in this install — snapshot will still run, and // compositions without