From 90f14703b2233455672cf5fde7a293baf1994455 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 11 May 2026 18:04:47 +0000 Subject: [PATCH 1/3] refactor(producer): extract probeStage from executeRenderJob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the browser probe / duration discovery / recompile / media reconciliation block out of `executeRenderJob` into `services/render/stages/probeStage.ts`. No behavior change. The sequencer calls `runProbeStage` at the same code point with identical inputs and outputs. The probe stage owns the `FileServerHandle` and the `CaptureSession` it creates and returns them to the sequencer. The sequencer still tracks them in its `let fileServer` / `let probeSession` bindings and closes them in its `finally` block — the resource lifetime is unchanged. `recompileWithResolutions` lives inside this stage because it depends on browser-resolved durations even though §2.1 of the distributed plan lists recompile as a sibling phase. Preserved invariants: - `composition` is mutated in place (videos / audios / duration) so downstream stages see the reconciled view through the same reference. - `job.duration` and `job.totalFrames` end up with the same values at the same code points. The result type carries `duration: number` alongside `totalFrames: number`, and the sequencer re-asserts the assignments after the call so TypeScript's control-flow narrowing works for the rest of `executeRenderJob`. - `perfStages.browserProbeMs` and `perfStages.compileMs` are written at the same code points with the same values. - The "Composition duration is 0" diagnostic builds the same hint string from the same console-buffer regex and `__timelines` probe. - The post-probe "failed network requests" warning fires with the same regex, the same first-10/first-5 slicing, and the same `console.warn` prefix. Renderer smoke-tested inside `Dockerfile.test` against `font-variant-numeric`, `many-cuts`, and `variables-prod` — all PSNR / audio correlation baselines match. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/render/stages/probeStage.ts | 369 ++++++++++++++++++ .../src/services/renderOrchestrator.ts | 294 ++------------ 2 files changed, 397 insertions(+), 266 deletions(-) create mode 100644 packages/producer/src/services/render/stages/probeStage.ts diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts new file mode 100644 index 000000000..c4709401a --- /dev/null +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -0,0 +1,369 @@ +/** + * probeStage — browser probe + recompile + media reconciliation. + * + * Runs only when `needsBrowser` is true (root duration unknown OR there are + * unresolved nested compositions). Owns the `FileServerHandle` and the + * `CaptureSession` it creates and returns them so the sequencer can both + * reuse them downstream (the capture stage reuses the probe session) and + * clean them up in its `finally` block. + * + * Hard constraints preserved verbatim from the in-process renderer: + * - `recompileWithResolutions` runs inside this stage because it depends + * on browser-resolved durations, even though §2.1 of the distributed + * plan lists recompile as a sibling phase. + * - `composition` (videos/audios/duration) is mutated in place — callers + * downstream see the reconciled view through the same object reference. + * - `job.duration` and `job.totalFrames` are assigned at the same code + * points. + * - The "Composition duration is 0" diagnostic builds the same hint + * string from the same console-buffer regex and `__timelines` probe. + * - The post-probe "failed network requests" warning fires with the same + * regex, the same first-10/first-5 slicing, and the same `console.warn` + * prefix. + */ + +import { join } from "node:path"; +import { + type CaptureOptions, + type CaptureSession, + type EngineConfig, + createCaptureSession, + getCompositionDuration, + initializeSession, +} from "@hyperframes/engine"; +import { fpsToNumber } from "@hyperframes/core"; +import type { CompiledComposition } from "../../htmlCompiler.js"; +import { + discoverMediaFromBrowser, + recompileWithResolutions, + resolveCompositionDurations, +} from "../../htmlCompiler.js"; +import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "../../fileServer.js"; +import type { ProducerLogger } from "../../../logger.js"; +import { + projectBrowserEndToCompositionTimeline, + writeCompiledArtifacts, + type CompositionMetadata, + type RenderJob, +} from "../../renderOrchestrator.js"; + +const BROWSER_MEDIA_EPSILON = 0.0001; + +export interface ProbeStageInput { + projectDir: string; + workDir: string; + job: RenderJob; + cfg: EngineConfig; + log: ProducerLogger; + assertNotAborted: () => void; + /** From compileStage. May be replaced via `recompileWithResolutions`. */ + compiled: CompiledComposition; + /** From compileStage. Mutated in place (videos/audios pushed, duration set). */ + composition: CompositionMetadata; + width: number; + height: number; + needsAlpha: boolean; + deviceScaleFactor: number; +} + +export interface ProbeStageResult { + /** May be reassigned from `recompileWithResolutions`. */ + compiled: CompiledComposition; + /** Created when `needsBrowser` was true; `null` otherwise. */ + fileServer: FileServerHandle | null; + /** Created when `needsBrowser` was true; `null` otherwise. */ + probeSession: CaptureSession | null; + /** The probeSession's `browserConsoleBuffer`, or `[]` if no probe ran. */ + lastBrowserConsole: string[]; + /** Composition duration (post-probe). Guaranteed > 0 — the stage throws on <= 0. */ + duration: number; + totalFrames: number; + /** Wall-clock ms for the entire probe phase (0 if `needsBrowser` was false). */ + browserProbeMs: number; +} + +export async function runProbeStage(input: ProbeStageInput): Promise { + const { + projectDir, + workDir, + job, + cfg, + log, + assertNotAborted, + composition, + width, + height, + needsAlpha, + deviceScaleFactor, + } = input; + let { compiled } = input; + let fileServer: FileServerHandle | null = null; + let probeSession: CaptureSession | null = null; + let lastBrowserConsole: string[] = []; + + const probeStart = Date.now(); + const needsBrowser = composition.duration <= 0 || compiled.unresolvedCompositions.length > 0; + + if (needsBrowser) { + const reasons = []; + if (composition.duration <= 0) reasons.push("root duration unknown"); + if (compiled.unresolvedCompositions.length > 0) + reasons.push(`${compiled.unresolvedCompositions.length} unresolved composition(s)`); + + fileServer = await createFileServer({ + projectDir, + compiledDir: join(workDir, "compiled"), + port: 0, + preHeadScripts: [VIRTUAL_TIME_SHIM], + }); + assertNotAborted(); + + const captureOpts: CaptureOptions = { + width, + height, + fps: job.config.fps, + format: needsAlpha ? "png" : "jpeg", + quality: needsAlpha ? undefined : 80, + deviceScaleFactor, + }; + probeSession = await createCaptureSession( + fileServer.url, + join(workDir, "probe"), + captureOpts, + null, + cfg, + ); + await initializeSession(probeSession); + assertNotAborted(); + lastBrowserConsole = probeSession.browserConsoleBuffer; + + // Discover root composition duration + if (composition.duration <= 0) { + const discoveredDuration = await getCompositionDuration(probeSession); + assertNotAborted(); + log.info("Probed composition duration from browser", { + discoveredDuration, + staticDuration: compiled.staticDuration, + }); + composition.duration = discoveredDuration; + } else { + log.info("Using static duration from data-duration attribute", { + duration: composition.duration, + }); + } + + // Resolve unresolved composition durations via window.__timelines + if (compiled.unresolvedCompositions.length > 0) { + const resolutions = await resolveCompositionDurations( + probeSession.page, + compiled.unresolvedCompositions, + ); + assertNotAborted(); + if (resolutions.length > 0) { + compiled = await recompileWithResolutions( + compiled, + resolutions, + projectDir, + join(workDir, "downloads"), + ); + assertNotAborted(); + // Update composition metadata with re-parsed media + composition.videos = compiled.videos; + composition.audios = compiled.audios; + composition.images = compiled.images; + writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug)); + } + } + + // Discover media elements from browser DOM (catches dynamically-set src) + const browserMedia = await discoverMediaFromBrowser(probeSession.page); + assertNotAborted(); + if (browserMedia.length > 0) { + const existingVideoIds = new Set(composition.videos.map((v) => v.id)); + const existingAudioIds = new Set(composition.audios.map((a) => a.id)); + + for (const el of browserMedia) { + if (!el.src || el.src === "about:blank") continue; + + // Convert absolute localhost URLs back to relative paths + let src = el.src; + if (fileServer && src.startsWith(fileServer.url)) { + src = src.slice(fileServer.url.length).replace(/^\//, ""); + } + + if (el.tagName === "video") { + if (existingVideoIds.has(el.id)) { + // Reconcile to browser/runtime media metadata (runtime src can differ from static HTML). + const existing = composition.videos.find((v) => v.id === el.id); + if (existing) { + if (existing.src !== src) { + existing.src = src; + } + const projectedEnd = projectBrowserEndToCompositionTimeline( + existing.start, + el.start, + el.end, + ); + if ( + projectedEnd > 0 && + (existing.end <= 0 || Math.abs(existing.end - projectedEnd) > BROWSER_MEDIA_EPSILON) + ) { + existing.end = projectedEnd; + } + if ( + el.mediaStart > 0 && + (existing.mediaStart <= 0 || + Math.abs(existing.mediaStart - el.mediaStart) > BROWSER_MEDIA_EPSILON) + ) { + existing.mediaStart = el.mediaStart; + } + if (el.hasAudio && !existing.hasAudio) { + existing.hasAudio = true; + } + if (el.loop && !existing.loop) { + existing.loop = true; + } + } + } else { + // New video discovered from browser + composition.videos.push({ + id: el.id, + src, + start: el.start, + end: el.end, + mediaStart: el.mediaStart, + loop: el.loop, + hasAudio: el.hasAudio, + }); + existingVideoIds.add(el.id); + } + } else if (el.tagName === "audio") { + if (existingAudioIds.has(el.id)) { + const existing = composition.audios.find((a) => a.id === el.id); + if (existing) { + if (existing.src !== src) { + existing.src = src; + } + const projectedEnd = projectBrowserEndToCompositionTimeline( + existing.start, + el.start, + el.end, + ); + if ( + projectedEnd > 0 && + (existing.end <= 0 || Math.abs(existing.end - projectedEnd) > BROWSER_MEDIA_EPSILON) + ) { + existing.end = projectedEnd; + } + if ( + el.mediaStart > 0 && + (existing.mediaStart <= 0 || + Math.abs(existing.mediaStart - el.mediaStart) > BROWSER_MEDIA_EPSILON) + ) { + existing.mediaStart = el.mediaStart; + } + if ( + el.volume > 0 && + Math.abs((existing.volume ?? 1) - el.volume) > BROWSER_MEDIA_EPSILON + ) { + existing.volume = el.volume; + } + } + } else { + composition.audios.push({ + id: el.id, + src, + start: el.start, + end: el.end, + mediaStart: el.mediaStart, + layer: 0, + volume: el.volume, + type: "audio", + }); + existingAudioIds.add(el.id); + } + } + } + } + } + const browserProbeMs = Date.now() - probeStart; + + job.duration = composition.duration; + job.totalFrames = Math.ceil(composition.duration * fpsToNumber(job.config.fps)); + const duration = composition.duration; + const totalFrames = job.totalFrames; + + if (duration <= 0) { + // Gather diagnostics to help users understand why the render would produce a black video. + // Wrapped in try/catch because the browser tab may have crashed (which could be + // WHY duration is 0), and we don't want a Puppeteer error to mask the real message. + const diagnostics: string[] = []; + try { + if (probeSession) { + const timelinesInfo = await probeSession.page.evaluate(() => { + const tl = (window as any).__timelines; + const hf = (window as any).__hf; + return { + timelineKeys: tl ? Object.keys(tl) : [], + hfDuration: hf?.duration ?? null, + gsapLoaded: typeof (window as any).gsap !== "undefined", + }; + }); + if (!timelinesInfo.gsapLoaded) { + diagnostics.push( + "GSAP is not loaded — CDN script may have failed to download. " + + "Bundle GSAP locally in your project instead of using a CDN