diff --git a/packages/producer/src/services/render/stages/compileStage.ts b/packages/producer/src/services/render/stages/compileStage.ts index d9d465459..a00724800 100644 --- a/packages/producer/src/services/render/stages/compileStage.ts +++ b/packages/producer/src/services/render/stages/compileStage.ts @@ -8,9 +8,9 @@ * `deviceScaleFactor` for supersampling. * * The probe sub-stage (browser launch, duration discovery, recompile, - * media reconciliation) is extracted separately in PR 1.3. This stage - * stops at the point where the in-process renderer formerly entered the - * `if (needsBrowser)` branch. + * media reconciliation) lives in a sibling stage. This stage stops at + * the point where the in-process renderer enters the `if (needsBrowser)` + * branch. * * Hard constraints preserved verbatim from the in-process renderer: * - `applyRenderModeHints(cfg, ...)` is allowed to mutate `cfg.forceScreenshot`. diff --git a/packages/producer/src/services/render/stages/freezePlan.ts b/packages/producer/src/services/render/stages/freezePlan.ts index f2cf28236..7e99627d1 100644 --- a/packages/producer/src/services/render/stages/freezePlan.ts +++ b/packages/producer/src/services/render/stages/freezePlan.ts @@ -3,11 +3,9 @@ * manifest at the end of `plan()`, compute the planHash from the frozen * artifacts, and return the manifest path. * - * Phase 1 PR 1.1 ships the signature only: there are no callers yet. The - * function body is added in Phase 3 PR 3.1 when `services/distributed/plan.ts` - * lands and composes the Phase-1 stages. Keeping the skeleton in this PR - * means subsequent stage-extraction PRs can grow the `stages/` directory - * without touching the `producer/src/index.ts` export surface again. + * Signature-only skeleton: there are no callers yet. The function body + * lands when `services/distributed/plan.ts` is added and composes the + * stage primitives. * * See DISTRIBUTED-RENDERING-PLAN.md §2.1 phase 6, §4.1 directory layout, * §4.3 LockedRenderConfig. @@ -18,9 +16,7 @@ import type { PlanDimensions } from "./planHash.js"; /** * The encoder configuration locked in at plan time. Mirrors §4.3 - * LockedRenderConfig in the design doc. Phase 1 declares the shape; Phase 2 - * + Phase 3 populate it from the existing `renderOrchestrator` config and - * the new closed-GOP encoder args. + * LockedRenderConfig in the design doc. */ export interface LockedRenderConfig { // Capture @@ -97,10 +93,9 @@ export interface FreezePlanResult { * Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then * compute `planHash` over the canonicalized contents. * - * Skeleton only in Phase 1. Phase 3 PR 3.1 wires this up. + * Skeleton — body lands when the distributed-render primitives compose the + * stage functions. */ export async function freezePlan(_input: FreezePlanInput): Promise { - throw new Error( - "freezePlan is not implemented yet — wired in Phase 3 PR 3.1 (see DISTRIBUTED-RENDERING-PLAN.md §11).", - ); + throw new Error("freezePlan is not implemented yet — see DISTRIBUTED-RENDERING-PLAN.md §11."); } 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..f3ba06adc --- /dev/null +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -0,0 +1,370 @@ +/** + * 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. + * - The stage computes the final composition `duration` and `totalFrames` + * and returns them. Assigning those values onto the `RenderJob` is the + * sequencer's responsibility — a future chunk worker can't mutate the + * orchestrator's `job` object, and keeping the assignment in one place + * prevents the same value living in two writers. + * - 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 (near-zero when `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; + + const duration = composition.duration; + const totalFrames = Math.ceil(duration * fpsToNumber(job.config.fps)); + + 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