diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0e9a7209c..2217bde2c 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -120,6 +120,7 @@ export { getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, + deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, diff --git a/packages/engine/src/services/frameCapture-warmupTicks.test.ts b/packages/engine/src/services/frameCapture-warmupTicks.test.ts index 24e572342..f38839ca6 100644 --- a/packages/engine/src/services/frameCapture-warmupTicks.test.ts +++ b/packages/engine/src/services/frameCapture-warmupTicks.test.ts @@ -12,7 +12,10 @@ import { describe, expect, it } from "vitest"; import { LOCKED_WARMUP_TICKS, + deriveBeginFrameTimelineTicks, + deriveBeginFrameTimeTicks, driveWarmupTicks, + prepareBeginFrameTimeline, warmupFrameTimeTicks, type WarmupTickState, } from "./frameCapture.js"; @@ -172,3 +175,81 @@ describe("driveWarmupTicks — locked", () => { expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33); }); }); + +describe("deriveBeginFrameTimeTicks", () => { + const warmupIntervalMs = 33; + const state: WarmupTickState = { + running: false, + ticks: LOCKED_WARMUP_TICKS, + }; + const expectMonotonicTimeline = ( + warmupState: WarmupTickState, + captureIntervalMs: number, + ): void => { + const timeline = deriveBeginFrameTimelineTicks( + warmupState, + warmupIntervalMs, + captureIntervalMs, + ); + const lastWarmupTick = (warmupState.ticks - 1) * warmupIntervalMs; + + expect(timeline.commit).toBeGreaterThan(lastWarmupTick); + expect(timeline.probe).toBeGreaterThan(timeline.commit); + expect(timeline.capture).toBeGreaterThan(timeline.probe); + }; + + it.each([60, 120, 240, 60_000 / 1001])( + "keeps warmup, commit, probe, and capture monotonic at %ifps", + (fps) => { + expectMonotonicTimeline(state, 1000 / fps); + }, + ); + + it.each([24, 30, 30_000 / 1001, 31, 32])( + "preserves the legacy capture baseline when it is already monotonic at %ifps", + (fps) => { + const captureIntervalMs = 1000 / fps; + + expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo( + (LOCKED_WARMUP_TICKS + 10) * captureIntervalMs, + ); + }, + ); + + it("keeps an unlocked warmup timeline monotonic", () => { + const unlockedState: WarmupTickState = { running: false, ticks: 7 }; + expectMonotonicTimeline(unlockedState, 1000 / 60); + }); + + it("raises the capture baseline only when the warmup clock is ahead", () => { + const captureIntervalMs = 1000 / 60; + + expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo( + LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs, + ); + }); + + it("raises the baseline just above the safe legacy boundary", () => { + const captureIntervalMs = 1000 / 33; + + expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo( + LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs, + ); + }); + + it("wires the canonical capture and commit ticks into session initialization", () => { + const session = { + beginFrameIntervalMs: 1000 / 60, + beginFrameTimeTicks: 0, + }; + const prepared = prepareBeginFrameTimeline(session, state, warmupIntervalMs); + + expect(session.beginFrameTimeTicks).toBe(prepared.timeline.capture); + expect(prepared.commitParams).toEqual({ + frameTimeTicks: prepared.timeline.commit, + interval: session.beginFrameIntervalMs, + noDisplayUpdates: false, + }); + expect(prepared.timeline.commit).toBeGreaterThan((LOCKED_WARMUP_TICKS - 1) * warmupIntervalMs); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 7fdb61f28..d7d1c59c0 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -632,6 +632,101 @@ export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number) return state.ticks * intervalMs; } +const BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS = 10; +const BEGIN_FRAME_COMMIT_LEAD_INTERVALS = 6; +const BEGIN_FRAME_PROBE_LEAD_INTERVALS = 5; + +export interface BeginFrameTimelineTicks { + capture: number; + commit: number; + probe: number; +} + +export interface PreparedBeginFrameTimeline { + commitParams: { + frameTimeTicks: number; + interval: number; + noDisplayUpdates: false; + }; + timeline: BeginFrameTimelineTicks; +} + +/** + * Place frame zero after the warmup clock while retaining capture-rate-sized + * headroom for the visual commit and liveness probe ticks that precede it. + * + * The warmup and capture intervals can differ (warmup currently runs at a + * fixed 33ms). Basing both clocks on the capture interval would move time + * backwards whenever the output frame rate is faster than the warmup rate. + */ +export function deriveBeginFrameTimeTicks( + state: WarmupTickState, + warmupIntervalMs: number, + captureIntervalMs: number, +): number { + const legacyCaptureTimeTicks = + (state.ticks + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS) * captureIntervalMs; + const legacyCommitTimeTicks = deriveBeginFrameCommitTimeTicks( + legacyCaptureTimeTicks, + captureIntervalMs, + ); + const lastWarmupTimeTicks = Math.max(0, state.ticks - 1) * warmupIntervalMs; + if (legacyCommitTimeTicks > lastWarmupTimeTicks) return legacyCaptureTimeTicks; + + const monotonicCaptureTimeTicks = + warmupFrameTimeTicks(state, warmupIntervalMs) + + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS * captureIntervalMs; + return monotonicCaptureTimeTicks; +} + +function deriveBeginFrameCommitTimeTicks( + captureTimeTicks: number, + captureIntervalMs: number, +): number { + return captureTimeTicks - BEGIN_FRAME_COMMIT_LEAD_INTERVALS * captureIntervalMs; +} + +export function deriveBeginFrameProbeTimeTicks( + captureTimeTicks: number, + captureIntervalMs: number, +): number { + return Math.max(0, captureTimeTicks - BEGIN_FRAME_PROBE_LEAD_INTERVALS * captureIntervalMs); +} + +export function deriveBeginFrameTimelineTicks( + state: WarmupTickState, + warmupIntervalMs: number, + captureIntervalMs: number, +): BeginFrameTimelineTicks { + const capture = deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs); + return { + capture, + commit: deriveBeginFrameCommitTimeTicks(capture, captureIntervalMs), + probe: deriveBeginFrameProbeTimeTicks(capture, captureIntervalMs), + }; +} + +export function prepareBeginFrameTimeline( + session: Pick, + state: WarmupTickState, + warmupIntervalMs: number, +): PreparedBeginFrameTimeline { + const timeline = deriveBeginFrameTimelineTicks( + state, + warmupIntervalMs, + session.beginFrameIntervalMs, + ); + session.beginFrameTimeTicks = timeline.capture; + return { + timeline, + commitParams: { + frameTimeTicks: timeline.commit, + interval: session.beginFrameIntervalMs, + noDisplayUpdates: false, + }, + }; +} + export async function driveWarmupTicks( options: WarmupTickOptions, state: WarmupTickState, @@ -2277,10 +2372,16 @@ export async function initializeSession(session: CaptureSession): Promise warmupState.running = false; await warmupLoopPromise.catch(() => {}); - // Set base frame time ticks past warmup range. Locked mode pins to the - // constant so chunk workers on different hosts compute the same baseline. - const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks; - session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs; + // Preserve the legacy baseline when it is already safe. Otherwise continue + // from the clock actually used by warmup, then reserve capture-rate headroom + // for the commit and probe ticks below. Locked mode still produces an + // identical timeline on every host because its driver ends at exactly + // LOCKED_WARMUP_TICKS. + const preparedBeginFrameTimeline = prepareBeginFrameTimeline( + session, + warmupState, + warmupIntervalMs, + ); // drawElement or transparent-background init — runs after page is fully ready. // IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame @@ -2318,11 +2419,7 @@ export async function initializeSession(session: CaptureSession): Promise // `-6·interval` the order stays warmup < commit < probe < capture. await ensureRenderFrameSiblings(page); const commitCdp = await getCdpSession(page); - await commitCdp.send("HeadlessExperimental.beginFrame", { - frameTimeTicks: session.beginFrameTimeTicks - 6 * session.beginFrameIntervalMs, - interval: session.beginFrameIntervalMs, - noDisplayUpdates: false, - }); + await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams); session.isInitialized = true; } diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 684cd87f3..fe99e98b8 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -51,6 +51,7 @@ import { createCaptureSession, createFrameLookupTable, createVideoFrameInjector, + deriveBeginFrameProbeTimeTicks, type EngineConfig, type ExtractedFrames, type FrameLookupTable, @@ -316,7 +317,10 @@ export async function beginFrameSessionNeedsScreenshotFallback( Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS) > 0 ? Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS) : 30_000; - const probeTick = Math.max(0, session.beginFrameTimeTicks - 5 * session.beginFrameIntervalMs); + const probeTick = deriveBeginFrameProbeTimeTicks( + session.beginFrameTimeTicks, + session.beginFrameIntervalMs, + ); return !(await probe(session.page, timeoutMs, probeTick, session.beginFrameIntervalMs)); } diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts index 6fb292715..82945f224 100644 --- a/packages/producer/src/services/render/stages/probeStage.test.ts +++ b/packages/producer/src/services/render/stages/probeStage.test.ts @@ -46,6 +46,12 @@ let createSessionFailUntilAttempt = 0; let createSessionError: Error | null = null; let closeCaptureSessionCallCount = 0; let probeBeginFrameAlive = true; +const beginFrameProbeCalls: Array<{ + page: unknown; + timeoutMs: number; + frameTimeTicks: number; + intervalMs: number; +}> = []; const createdSessions: MockSession[] = []; const closedSessions: MockSession[] = []; const durationProbeSessions: MockSession[] = []; @@ -59,6 +65,7 @@ function resetRetryMocks() { createSessionError = null; closeCaptureSessionCallCount = 0; probeBeginFrameAlive = true; + beginFrameProbeCalls.length = 0; createdSessions.length = 0; closedSessions.length = 0; durationProbeSessions.length = 0; @@ -137,7 +144,15 @@ mock.module("@hyperframes/engine", () => ({ closeCaptureSessionCallCount++; closedSessions.push(session); }, - probeBeginFrameLiveness: async () => probeBeginFrameAlive, + probeBeginFrameLiveness: async ( + page: unknown, + timeoutMs: number, + frameTimeTicks: number, + intervalMs: number, + ) => { + beginFrameProbeCalls.push({ page, timeoutMs, frameTimeTicks, intervalMs }); + return probeBeginFrameAlive; + }, // Mirror of the real engine classifier. Canonical tests + pattern list // live in frameCapture-transientErrors.test.ts — update both if patterns change. isTransientBrowserError: (error: unknown) => { @@ -678,6 +693,14 @@ describe("runProbeStage — transient browser error retry (#1687)", () => { expect(createSessionCallCount).toBe(2); expect(closedSessions).toEqual([createdSessions[0]]); expect(capturedCfgs[1]).toMatchObject({ forceScreenshot: true }); + expect(beginFrameProbeCalls).toEqual([ + { + page: createdSessions[0]?.page, + timeoutMs: 30_000, + frameTimeTicks: 95, + intervalMs: 1, + }, + ]); expect(durationProbeSessions).toEqual([createdSessions[1]]); expect(result.probeSession).toBe(createdSessions[1]); expect(result.beginFrameStalled).toBe(true); diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index f93a3fe38..785b769bd 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -36,6 +36,7 @@ import { type EngineConfig, closeCaptureSession, createCaptureSession, + deriveBeginFrameProbeTimeTicks, getCompositionDuration, initializeSession, isTransientBrowserError, @@ -388,9 +389,9 @@ export async function runProbeStage(input: ProbeStageInput): Promise