diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 599e89c31b..2bd1783add 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -42,6 +42,14 @@ const AUTHORED_END_ATTR = "data-hf-authored-end"; export function initSandboxRuntimeModular(): void { const state = createRuntimeState(); + // Export harness injects the render fps (window.__hfCanonicalFps) so the + // deterministic seek quantizes on the export grid, not the preview default. + { + const injectedFps = (window as unknown as { __hfCanonicalFps?: number }).__hfCanonicalFps; + if (typeof injectedFps === "number" && Number.isFinite(injectedFps) && injectedFps > 0) { + state.canonicalFps = injectedFps; + } + } let colorGradingRuntime: RuntimeColorGradingApi | null = null; let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null; let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null; @@ -1035,6 +1043,19 @@ export function initSandboxRuntimeModular(): void { if (typeof state.capturedTimeline.timeScale === "function") { state.capturedTimeline.timeScale(state.playbackRate); } + // `render -c ` mounts the composition under a synthetic wrapper root + // whose host has no own timeline; only the inner composition registers in + // window.__timelines. The export poll then waits forever for the root's own + // timeline and child seeks never engage. Publish the resolved (composite) + // timeline under the root id so the poll resolves. No-op for full reels, + // whose root already registers its own timeline. + const resolvedRootId = resolveRootCompositionElement()?.getAttribute("data-composition-id"); + const timelineRegistry = window.__timelines as + | Record + | undefined; + if (resolvedRootId && timelineRegistry && !timelineRegistry[resolvedRootId]) { + timelineRegistry[resolvedRootId] = state.capturedTimeline; + } const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); if (boundDuration <= 0) { // No resolvable duration (e.g. a set()-only timeline, or one whose diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 4599a01307..f8fc0963f1 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -429,6 +429,15 @@ export async function createCaptureSession( } }, variablesJson); } + // Tell the runtime the export fps so its deterministic seek quantizes on the + // export grid, not its preview default (otherwise single-composition renders, + // which have no authored fps, snap to the 30fps grid). Set before page scripts. + const exportFps = fpsToNumber(options.fps); + if (Number.isFinite(exportFps) && exportFps > 0) { + await page.evaluateOnNewDocument((fps: number) => { + (window as unknown as { __hfCanonicalFps?: number }).__hfCanonicalFps = fps; + }, exportFps); + } const browserVersion = await browser.version(); const sessionOptions = resolveCaptureSessionOptions(options, browserVersion); const expectedMajor = config?.expectedChromiumMajor;