From 65b2299db284ece3232de26ed7afbe0360e097bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 23 Aug 2026 13:18:18 -0400 Subject: [PATCH] fix(engine): preserve static dedup across caption runs (#3438) * fix(engine): preserve authored clip boundaries after normalization * perf(engine): bound static verification work across caption runs * fix(core): preserve explicit nonpositive timeline windows --- packages/core/src/index.ts | 7 + .../core/src/runtime/authoredTiming.test.ts | 82 ++++ packages/core/src/runtime/authoredTiming.ts | 50 ++ packages/core/src/runtime/startResolver.ts | 55 +-- packages/core/src/runtime/timeline.test.ts | 47 +- packages/core/src/runtime/timeline.ts | 26 +- packages/engine/src/index.ts | 1 + .../frameCapture-staticDedupBoundary.test.ts | 43 ++ .../frameCapture-staticDedupIndex.test.ts | 17 + ...meCapture-staticDedupVerifyDensity.test.ts | 260 ++++++---- packages/engine/src/services/frameCapture.ts | 451 +++++++++++++----- packages/engine/src/types.ts | 20 +- .../services/render/perfSummary-dedup.test.ts | 46 ++ .../src/services/render/perfSummary.ts | 48 +- .../src/services/renderOrchestrator.ts | 9 + 15 files changed, 901 insertions(+), 261 deletions(-) create mode 100644 packages/core/src/runtime/authoredTiming.test.ts create mode 100644 packages/core/src/runtime/authoredTiming.ts create mode 100644 packages/engine/src/services/frameCapture-staticDedupBoundary.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1bcd63c5f..2ce24c12b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,6 +51,13 @@ export type { export { parseSlideshowManifest, resolveSlideshow } from "./slideshow/index.js"; +export type { + AuthoredTimingValue, + RawAuthoredTiming, + AuthoredTimingWindow, +} from "./runtime/authoredTiming.js"; +export { resolveAuthoredTimingWindow } from "./runtime/authoredTiming.js"; + export { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, diff --git a/packages/core/src/runtime/authoredTiming.test.ts b/packages/core/src/runtime/authoredTiming.test.ts new file mode 100644 index 000000000..32dd725b5 --- /dev/null +++ b/packages/core/src/runtime/authoredTiming.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +const timingModule = await import("./authoredTiming.js").catch(() => null); + +describe("resolveAuthoredTimingWindow", () => { + const resolve = (values: { + start?: string | null; + duration?: string | null; + authoredDuration?: string | null; + end?: string | null; + authoredEnd?: string | null; + }) => { + expect(timingModule, "canonical authored timing helper must exist").not.toBeNull(); + return timingModule?.resolveAuthoredTimingWindow(values) ?? null; + }; + + it("uses public timing before preserved timing and duration before end", () => { + expect( + resolve({ + start: "2", + duration: "3", + authoredDuration: "4", + end: "20", + authoredEnd: "30", + }), + ).toEqual({ start: 2, duration: 3, end: 5 }); + }); + + it("falls back through preserved duration, public end, and preserved end", () => { + expect(resolve({ start: "1", duration: "", authoredDuration: "2.5" })).toEqual({ + start: 1, + duration: 2.5, + end: 3.5, + }); + expect(resolve({ start: "1", duration: "0", end: "4", authoredEnd: "8" })).toEqual({ + start: 1, + duration: 3, + end: 4, + }); + expect(resolve({ start: "1", duration: "-1", end: "NaN", authoredEnd: "6" })).toEqual({ + start: 1, + duration: 5, + end: 6, + }); + }); + + it("rejects unusable values and preserves a usable start-only window", () => { + expect(resolve({ start: null, duration: "2" })).toBeNull(); + expect(resolve({ start: "Infinity", duration: "2" })).toBeNull(); + expect(resolve({ start: "1.25", duration: "NaN", end: "1.25" })).toEqual({ + start: 1.25, + duration: null, + end: null, + }); + }); + + it.each([ + ["blank public duration", { start: "0", duration: " ", authoredDuration: "2" }, 2], + ["invalid public duration", { start: "0", duration: "NaN", authoredDuration: "2" }, 2], + ["infinite public duration", { start: "0", duration: "Infinity", authoredDuration: "2" }, 2], + ["zero public duration", { start: "0", duration: "0", authoredDuration: "2" }, 2], + ["negative public duration", { start: "0", duration: "-2", authoredDuration: "2" }, 2], + ])("uses a usable preserved duration after %s", (_label, values, expectedEnd) => { + expect(resolve(values)).toEqual({ start: 0, duration: 2, end: expectedEnd }); + }); + + it("uses preserved end when public end does not create a positive window", () => { + expect(resolve({ start: "4", end: "3", authoredEnd: "5.5" })).toEqual({ + start: 4, + duration: 1.5, + end: 5.5, + }); + }); + + it("clamps a finite negative absolute start before deriving the window", () => { + expect(resolve({ start: "-1", duration: "3" })).toEqual({ + start: 0, + duration: 3, + end: 3, + }); + }); +}); diff --git a/packages/core/src/runtime/authoredTiming.ts b/packages/core/src/runtime/authoredTiming.ts new file mode 100644 index 000000000..8d582dd12 --- /dev/null +++ b/packages/core/src/runtime/authoredTiming.ts @@ -0,0 +1,50 @@ +export type AuthoredTimingValue = string | number | null | undefined; + +export interface RawAuthoredTiming { + start?: AuthoredTimingValue; + duration?: AuthoredTimingValue; + authoredDuration?: AuthoredTimingValue; + end?: AuthoredTimingValue; + authoredEnd?: AuthoredTimingValue; +} + +export interface AuthoredTimingWindow { + start: number; + duration: number | null; + end: number | null; +} + +function finiteNumber(value: AuthoredTimingValue): number | null { + if (value == null) return null; + if (typeof value === "string" && value.trim() === "") return null; + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function resolveAuthoredTimingWindow( + values: RawAuthoredTiming, +): AuthoredTimingWindow | null { + const parsedStart = finiteNumber(values.start); + if (parsedStart == null) return null; + const start = Math.max(0, parsedStart); + + const publicDuration = finiteNumber(values.duration); + const preservedDuration = finiteNumber(values.authoredDuration); + const duration = + publicDuration != null && publicDuration > 0 + ? publicDuration + : preservedDuration != null && preservedDuration > 0 + ? preservedDuration + : null; + if (duration != null) return { start, duration, end: start + duration }; + + const publicEnd = finiteNumber(values.end); + const preservedEnd = finiteNumber(values.authoredEnd); + const end = + publicEnd != null && publicEnd > start + ? publicEnd + : preservedEnd != null && preservedEnd > start + ? preservedEnd + : null; + return { start, duration: end == null ? null : end - start, end }; +} diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index d42f8fbaa..f9ac701b4 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -1,27 +1,9 @@ import type { RuntimeTimelineLike } from "./types"; import { swallow } from "./diagnostics"; +import { resolveAuthoredTimingWindow } from "./authoredTiming"; import { readElementPlaybackRate } from "./media"; import { readMediaStart } from "./playbackRate"; -import { parseNumeric, parseStartExpression } from "./startExpression"; - -const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; -const AUTHORED_END_ATTR = "data-hf-authored-end"; - -function parseDurationAttr(element: Element): number | null { - return parseNumeric(element.getAttribute("data-duration")); -} - -function parseEndAttr(element: Element): number | null { - return parseNumeric(element.getAttribute("data-end")); -} - -function parseAuthoredDurationAttr(element: Element): number | null { - return parseNumeric(element.getAttribute(AUTHORED_DURATION_ATTR)); -} - -function parseAuthoredEndAttr(element: Element): number | null { - return parseNumeric(element.getAttribute(AUTHORED_END_ATTR)); -} +import { parseStartExpression } from "./startExpression"; export function createRuntimeStartTimeResolver(params: { timelineRegistry?: Record; @@ -64,22 +46,27 @@ export function createRuntimeStartTimeResolver(params: { const cached = durationCache.get(element); if (cached !== undefined) return cached; let resolved: number | null = null; - const durationAttr = - parseDurationAttr(element) ?? - (includeAuthoredTimingAttrs ? parseAuthoredDurationAttr(element) : null); - if (durationAttr != null && durationAttr > 0) { - resolved = durationAttr; + const durationTiming = resolveAuthoredTimingWindow({ + start: 0, + duration: element.getAttribute("data-duration"), + authoredDuration: includeAuthoredTimingAttrs + ? element.getAttribute("data-hf-authored-duration") + : null, + }); + if (durationTiming?.duration != null && durationTiming.duration > 0) { + resolved = durationTiming.duration; } if (resolved == null || resolved <= 0) { - const endAttr = - parseEndAttr(element) ?? - (includeAuthoredTimingAttrs ? parseAuthoredEndAttr(element) : null); - if (endAttr != null) { - const start = resolveStartForElementInternal(element, 0); - const delta = endAttr - start; - if (Number.isFinite(delta) && delta > 0) { - resolved = delta; - } + const start = resolveStartForElementInternal(element, 0); + const endTiming = resolveAuthoredTimingWindow({ + start, + end: element.getAttribute("data-end"), + authoredEnd: includeAuthoredTimingAttrs + ? element.getAttribute("data-hf-authored-end") + : null, + }); + if (endTiming?.duration != null && endTiming.duration > 0) { + resolved = endTiming.duration; } } if ((resolved == null || resolved <= 0) && isMediaElement(element)) { diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 74bd4b5da..3eeb95069 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -1,14 +1,39 @@ import { describe, it, expect, afterEach } from "vitest"; import { collectRuntimeTimelinePayload } from "./timeline"; +type TimelineTestWindow = Window & { + __timelines?: Record number }>; +}; + describe("collectRuntimeTimelinePayload", () => { afterEach(() => { document.body.innerHTML = ""; - delete (window as any).__timelines; + delete (window as TimelineTestWindow).__timelines; }); const defaultParams = { canonicalFps: 30 }; + function appendTimedCompositionClip( + id: string, + duration: string, + authoredDuration?: string, + ): HTMLDivElement { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-duration", "10"); + document.body.appendChild(root); + const clip = document.createElement("div"); + clip.id = id; + clip.setAttribute("data-composition-id", id); + clip.setAttribute("data-start", "0"); + clip.setAttribute("data-duration", duration); + if (authoredDuration != null) { + clip.setAttribute("data-hf-authored-duration", authoredDuration); + } + root.appendChild(clip); + return clip; + } + it("returns minimal payload for empty document", () => { const result = collectRuntimeTimelinePayload(defaultParams); expect(result.source).toBe("hf-preview"); @@ -664,6 +689,26 @@ describe("collectRuntimeTimelinePayload", () => { expect(result.durationInFrames).toBe(42 * 30); }); + it("uses preserved duration when a normalized timeline clip retains public zero", () => { + const clip = appendTimedCompositionClip("normalized-zero", "0", "3.5"); + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips.find((candidate) => candidate.id === clip.id)?.duration).toBe(3.5); + }); + + it.each(["0", "-2"])( + "drops an explicit nonpositive duration %s before timeline fallback", + (duration) => { + const clip = appendTimedCompositionClip("invalid-window", duration); + (window as TimelineTestWindow).__timelines = { + "invalid-window": { duration: () => 5 }, + }; + + const result = collectRuntimeTimelinePayload(defaultParams); + expect(result.clips.find((candidate) => candidate.id === clip.id)).toBeUndefined(); + }, + ); + it("discovers GSAP-animated scene elements via timeline introspection", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 59b68e1d6..2d82ef485 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -5,6 +5,7 @@ import type { RuntimeTimelineLike, } from "./types"; import { stableClipId } from "./clipTree"; +import { resolveAuthoredTimingWindow } from "./authoredTiming"; import { swallow } from "./diagnostics"; import { readElementPlaybackRate, readElementPlaybackStart } from "./media"; import { parseStrictFiniteTimingNumber, resolveNaturalMediaTimelineDuration } from "./playbackRate"; @@ -14,23 +15,32 @@ import { isSceneLikeCompositionId } from "../slideshow/index.js"; import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js"; import { runtimeProtocolMetadata } from "./protocol.js"; -const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; -const AUTHORED_END_ATTR = "data-hf-authored-end"; - function parseNum(value: string | null | undefined): number | null { return parseStrictFiniteTimingNumber(value); } function parseElementDurationAttr(element: Element): number | null { - return ( - parseNum(element.getAttribute("data-duration")) ?? - parseNum(element.getAttribute(AUTHORED_DURATION_ATTR)) - ); + const publicDuration = element.getAttribute("data-duration"); + const authoredDuration = element.getAttribute("data-hf-authored-duration"); + const resolved = resolveAuthoredTimingWindow({ + start: 0, + duration: publicDuration, + authoredDuration, + })?.duration; + if (resolved != null) return resolved; + const hasExplicitNonpositive = [publicDuration, authoredDuration] + .map(parseNum) + .some((duration) => duration != null && duration <= 0); + return hasExplicitNonpositive ? 0 : null; } function parseElementEndAttr(element: Element): number | null { return ( - parseNum(element.getAttribute("data-end")) ?? parseNum(element.getAttribute(AUTHORED_END_ATTR)) + resolveAuthoredTimingWindow({ + start: 0, + end: element.getAttribute("data-end"), + authoredEnd: element.getAttribute("data-hf-authored-end"), + })?.end ?? null ); } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index b8f8177f3..e8ab2e852 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -127,6 +127,7 @@ export { isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, + type StaticVerificationOutcome, } from "./services/frameCapture.js"; export { CaptureFailure, diff --git a/packages/engine/src/services/frameCapture-staticDedupBoundary.test.ts b/packages/engine/src/services/frameCapture-staticDedupBoundary.test.ts new file mode 100644 index 000000000..aeebf72db --- /dev/null +++ b/packages/engine/src/services/frameCapture-staticDedupBoundary.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { computeAuthoredClipBoundaryFrames } from "./frameCapture.js"; + +describe("computeClipBoundaryFrames", () => { + it("protects the normalized authored-duration disappearance neighborhood", async () => { + const frames = computeAuthoredClipBoundaryFrames( + [ + { + start: "0", + duration: null, + authoredDuration: "3.5", + end: null, + authoredEnd: null, + }, + ], + 25, + ); + + expect([...frames].sort((a, b) => a - b)).toEqual([0, 1, 87, 88, 89]); + }); + + it("rounds fractional-fps start and end edges and applies precedence", async () => { + const frames = computeAuthoredClipBoundaryFrames( + [ + { + start: "0.1", + duration: "0", + authoredDuration: "0.2", + end: "9", + authoredEnd: "10", + }, + ], + 23.976, + ); + + expect(frames).toEqual(new Set([1, 2, 3, 6, 7, 8])); + }); + + it("matches runtime clamping for a negative absolute start", () => { + const frames = computeAuthoredClipBoundaryFrames([{ start: "-1", duration: "3" }], 25); + expect(frames).toEqual(new Set([0, 1, 74, 75, 76])); + }); +}); diff --git a/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts b/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts index 66e53c99c..3d5dd5062 100644 --- a/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts +++ b/packages/engine/src/services/frameCapture-staticDedupIndex.test.ts @@ -39,6 +39,7 @@ function makeSession(staticFrames: Set, fps: { num: number; den: number isInitialized: false, staticFrames, lastFrameBuffer: SENTINEL, + lastFrameAbsoluteIndex: Math.min(...staticFrames) - 1, staticDedupCount: 0, } as unknown as CaptureSession; } @@ -73,4 +74,20 @@ describe("static-dedup reuse keys on absolute frame index (time), not relative f const session = makeSession(new Set([10, 11, 12]), fps30); await expect(captureFrameToBuffer(session, 0, 50 / 30)).rejects.toThrow(); }); + + it("does not reuse an unrelated cached frame from an interleaved worker", async () => { + const session = makeSession(new Set([90]), fps30); + session.lastFrameAbsoluteIndex = 87; + await expect(captureFrameToBuffer(session, 0, 90 / 30)).rejects.toThrow(); + expect(session.staticDedupCount).toBe(0); + }); + + it("advances the cached absolute index across consecutive reuse hits", async () => { + const session = makeSession(new Set([90, 91]), fps30); + await captureFrameToBuffer(session, 0, 90 / 30); + const second = await captureFrameToBuffer(session, 1, 91 / 30); + expect(second.buffer).toBe(SENTINEL); + expect(session.lastFrameAbsoluteIndex).toBe(91); + expect(session.staticDedupCount).toBe(2); + }); }); diff --git a/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts b/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts index 3035654ad..69df1fdfb 100644 --- a/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts +++ b/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { - computeStaticVerificationPoints, + planStaticVerification, verifyStaticFramesSafe, type CaptureSession, } from "./frameCapture.js"; @@ -11,76 +11,6 @@ vi.mock("./screenshotService.js", async (importOriginal) => { return { ...actual, pageScreenshotCapture: vi.fn() }; }); -/** - * Regression lock for static-dedup verification sample density. - * - * The prior formula capped points-per-run at a flat `min(sampleCount, 8)`, - * so the stride between checks grew linearly with the run's span — a run of - * a few thousand frames could end up with checks hundreds of frames apart. - * A genuine content change hiding between two such checks (e.g. text - * swapped by a mechanism the GSAP tween walk in computeStaticFrameSet can't - * see) would never get sampled, and the run would be wrongly trusted as - * static. - * - * A first version of this fix bounded the STRIDE by `sampleCount` directly — - * which fixed the density but inverted the config knob's polarity: raising - * `HF_STATIC_DEDUP_SAMPLES` widened the allowed gap instead of shrinking it. - * The current formula uses a fixed internal reference stride (independent of - * sampleCount) for the length-scaling fix, and sampleCount as a pure - * point-count floor that only ever increases density. - */ -describe("computeStaticVerificationPoints", () => { - const REFERENCE_STRIDE = 24; // matches STATIC_VERIFY_REFERENCE_STRIDE in frameCapture.ts - - function maxGap(points: number[]): number { - let max = 0; - for (let i = 1; i < points.length; i++) max = Math.max(max, points[i] - points[i - 1]); - return max; - } - - it("never leaves a gap wider than the reference stride on a long run, even with a low sampleCount", () => { - // Pre-fix (flat 8-point cap): stride = floor(2000/7) = 285. Using a LOW - // sampleCount (5) here proves the length-scaling fix is independent of the - // user's sampleCount setting, not just true when sampleCount happens to be large. - const points = computeStaticVerificationPoints(0, 2000, 5); - expect(maxGap(points)).toBeLessThanOrEqual(REFERENCE_STRIDE); - }); - - it("scales point count up further for an even longer run", () => { - const points = computeStaticVerificationPoints(0, 10_000, 24); - expect(maxGap(points)).toBeLessThanOrEqual(REFERENCE_STRIDE); - expect(points.length).toBeGreaterThan(400); - }); - - it("raising sampleCount only ever increases density (never decreases it)", () => { - // A prior version of this fix used sampleCount as a stride CAP, so raising - // it widened the allowed gap instead of narrowing it. Once sampleCount - // exceeds the length-scaled floor, it must now visibly tighten the gap. - const lowSample = computeStaticVerificationPoints(0, 2000, 24); - const highSample = computeStaticVerificationPoints(0, 2000, 200); - expect(maxGap(highSample)).toBeLessThan(maxGap(lowSample)); - }); - - it("sampleCount still governs density on short runs where the length-scaled floor is small", () => { - const points = computeStaticVerificationPoints(100, 150, 24); - expect(points[0]).toBe(100); - expect(points[points.length - 1]).toBe(150); - // perRun = max(3, 24, ceil(50/24)+1=4) = 24 → stride = floor(50/23) = 2. - expect(maxGap(points)).toBeLessThanOrEqual(2); - }); - - it("always includes the run's start and end", () => { - const points = computeStaticVerificationPoints(500, 500 + 3333, 24); - expect(points[0]).toBe(500); - expect(points[points.length - 1]).toBe(500 + 3333); - }); - - it("handles a single-frame run without dividing by zero", () => { - const points = computeStaticVerificationPoints(42, 42, 24); - expect(points).toEqual([42]); - }); -}); - /** * Behavior-level lock: a real content change that reverts before the run's end * (so the always-checked endpoint alone would NOT reveal it) must still be @@ -90,16 +20,6 @@ describe("computeStaticVerificationPoints", () => { describe("verifyStaticFramesSafe catches drift the old fixed-point density would miss", () => { const fps = 30; - function oldFormulaPoints(a: number, b: number, sampleCount: number): number[] { - const perRun = Math.max(3, Math.min(sampleCount, 8)); - const span = b - a; - const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1; - const pts = new Set(); - for (let f = a; f <= b; f += stride) pts.add(f); - pts.add(b); - return [...pts].sort((x, y) => x - y); - } - beforeEach(() => { vi.mocked(pageScreenshotCapture).mockReset(); }); @@ -112,10 +32,12 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would // Pick a frame the OLD formula would have skipped but the NEW one samples, // and confirm the endpoint alone (checked either way) would NOT reveal it — // isolating the assertion to interior-sample density, not the end-of-run check. - const oldPoints = new Set(oldFormulaPoints(a, b, sampleCount)); - const newPoints = computeStaticVerificationPoints(a, b, sampleCount); - const changeAt = newPoints.find((f) => !oldPoints.has(f) && f !== a && f !== b); - if (changeAt === undefined) throw new Error("test setup: no frame differs between formulas"); + const plannedRun = planStaticVerification( + new Set(Array.from({ length: b - a + 1 }, (_, index) => a + index)), + sampleCount, + ).runs[0]; + const changeAt = plannedRun?.comparisons.find((frame) => frame !== b); + if (changeAt === undefined) throw new Error("test setup: no interior planned comparison"); // Content is "before" everywhere except a single transient frame that reverts // immediately after — the anchor (a-1) and the run's end (b) both read "before". @@ -143,9 +65,9 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would sampleCount, ); - expect(result).not.toBeNull(); - expect(result?.budgetExhausted).toBe(false); - expect(result?.badFrame).toBe(changeAt); + expect(result.outcome).toBe("mismatch"); + expect(result.verifiedFrames.size).toBe(0); + expect(result.badFrame).toBe(changeAt); }); it("uses silent verification seeks and restores the playhead to frame zero", async () => { @@ -174,13 +96,13 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would const result = await verifyStaticFramesSafe( { options: {} } as unknown as CaptureSession, page as unknown as Parameters[1], - new Set([1, 2]), + new Set([1, 2, 3]), fps, 3, ); - expect(result).toBeNull(); - expect(seekCalls.map((call) => Math.round(call.t * fps))).toEqual([0, 1, 2, 0]); + expect(result.outcome).toBe("verified"); + expect(seekCalls.map((call) => Math.round(call.t * fps))).toEqual([0, 3, 0]); expect(seekCalls.every((call) => call.options?.suppressEvents === true)).toBe(true); }); @@ -211,7 +133,161 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would ); nowSpy.mockRestore(); - expect(result?.budgetExhausted).toBe(true); + expect(result.outcome).toBe("time_budget"); + expect(result.verifiedFrames.size).toBe(0); expect(pageScreenshotCapture).toHaveBeenCalledTimes(2); }); }); + +describe("composition-wide static verification planner", () => { + function framesForRuns(runs: Array<[number, number]>): Set { + return new Set(runs.flatMap(([a, b]) => Array.from({ length: b - a + 1 }, (_, i) => a + i))); + } + + function trackedPage(fps = 30) { + const cursor = { frame: 0 }; + const page = { + evaluate: vi.fn(async (_fn: unknown, time: number) => { + cursor.frame = Math.round(time * fps); + }), + }; + return { cursor, page }; + } + + function verifyTwoRuns( + page: ReturnType["page"], + dependencies: Parameters[5], + ) { + return verifyStaticFramesSafe( + { options: {} } as unknown as CaptureSession, + page as unknown as Parameters[1], + framesForRuns([ + [1, 3], + [10, 12], + ]), + 30, + 1, + dependencies, + ); + } + + it("keeps endpoints, a 24-frame max gap, a global floor, and monotonic samples", () => { + const frames = framesForRuns([ + [1, 100], + [110, 150], + ]); + const low = planStaticVerification(frames, 5); + const high = planStaticVerification(frames, 12); + expect(low.plannedComparisons).toBeGreaterThanOrEqual(5); + expect(high.plannedComparisons).toBeGreaterThanOrEqual(12); + for (const run of low.runs) { + const points = [run.anchor, ...run.comparisons]; + expect(run.comparisons.at(-1)).toBe(run.b); + expect(Math.max(...points.slice(1).map((point, i) => point - points[i]))).toBeLessThanOrEqual( + 24, + ); + const highRun = high.runs.find((candidate) => candidate.a === run.a); + expect(highRun).toBeDefined(); + expect(run.comparisons.every((point) => highRun?.comparisons.includes(point))).toBe(true); + } + }); + + it("clamps an extreme global sample request to the screenshot-cap-derived bound", () => { + const result = planStaticVerification(framesForRuns([[1, 1_000]]), 20_000); + expect(result.effectiveSampleFloor).toBe(50); + expect(result.plannedComparisons).toBe(50); + }); + + it("caption-heavy verification stays within budget and still detects drift", () => { + const staticFrames = new Set(); + let cursor = 1; + for (const length of [...Array(86).fill(5), ...Array(34).fill(4), 3, 1]) { + for (let offset = 0; offset < length; offset++) staticFrames.add(cursor + offset); + cursor += length + 1; + } + + const result = planStaticVerification(staticFrames, 24); + + expect(result.predictedFrames).toBe(570); + expect(result.runs).toHaveLength(121); + expect(result.plannedAnchors).toBe(121); + expect(result.plannedComparisons).toBe(121); + expect(result.plannedScreenshots).toBe(242); + expect(result.verifiedCandidateFrames).toBe(569); + expect(result.skippedRuns).toEqual([ + expect.objectContaining({ reason: "unprofitable", a: expect.any(Number) }), + ]); + expect( + result.runs.every( + (run: { comparisons: number[]; b: number }) => run.comparisons.at(-1) === run.b, + ), + ).toBe(true); + }); + + it("arms only completely verified runs when the wall budget expires", async () => { + let nowMs = 0; + const { cursor, page } = trackedPage(); + const result = await verifyTwoRuns(page, { + now: () => nowMs, + capture: async () => { + nowMs += 5_000; + return Buffer.from(`frame-${cursor.frame <= 3 ? 0 : 9}`); + }, + }); + expect(result.outcome).toBe("time_budget"); + expect([...result.verifiedFrames]).toEqual([1, 2, 3]); + expect(result.stats.completedRuns).toBe(1); + }); + + it("reports count-budget exhaustion before screenshot 401", async () => { + const runs = Array.from({ length: 201 }, (_, index): [number, number] => { + const start = index * 4 + 1; + return [start, start + 2]; + }); + const page = { evaluate: vi.fn(async () => undefined) }; + const result = await verifyStaticFramesSafe( + { options: {} } as unknown as CaptureSession, + page as unknown as Parameters[1], + framesForRuns(runs), + 30, + 1, + { now: () => 0, capture: async () => Buffer.from("same") }, + ); + expect(result.outcome).toBe("count_budget"); + expect(result.stats.screenshots).toBe(400); + expect(result.stats.completedRuns).toBe(200); + expect(result.verifiedFrames.size).toBe(600); + }); + + it("classifies an all-unprofitable plan without claiming budget exhaustion", async () => { + const page = { evaluate: vi.fn(async () => undefined) }; + const result = await verifyStaticFramesSafe( + { options: {} } as unknown as CaptureSession, + page as unknown as Parameters[1], + new Set([1]), + 30, + 24, + ); + expect(result.outcome).toBe("unprofitable"); + expect(result.verifiedFrames.size).toBe(0); + expect(result.stats.plannedRuns).toBe(0); + }); + + it.each([ + ["mismatch" as const, false], + ["infrastructure" as const, true], + ])("clears earlier verified runs on %s", async (expectedOutcome, throwCapture) => { + const { cursor, page } = trackedPage(); + const result = await verifyTwoRuns(page, { + capture: async () => { + if (cursor.frame === 12 && throwCapture) throw new Error("capture failed"); + if (cursor.frame === 12) return Buffer.from("drift"); + return Buffer.from(cursor.frame <= 3 ? "first" : "second"); + }, + }); + expect(result.outcome).toBe(expectedOutcome); + expect(result.verifiedFrames.size).toBe(0); + expect(result.stats.verifiedFrames).toBe(0); + expect(result.stats.unverifiedFrames).toBe(result.stats.predictedFrames); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index aefd3ea12..dda698f5d 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -11,7 +11,12 @@ import { type Browser, type Page, type Viewport, type ConsoleMessage } from "puppeteer-core"; import { existsSync, mkdirSync, writeFileSync } from "fs"; import { join } from "path"; -import { quantizeTimeToFrame, fpsToNumber } from "@hyperframes/core"; +import { + quantizeTimeToFrame, + fpsToNumber, + resolveAuthoredTimingWindow, + type RawAuthoredTiming, +} from "@hyperframes/core"; // ── Extracted modules ─────────────────────────────────────────────────────── import { @@ -85,19 +90,25 @@ export interface CaptureSession { staticFrames?: Set; /** Last non-deduped frame buffer, reused for every `staticFrames` index in its run. */ lastFrameBuffer?: Buffer; + /** Absolute index represented by lastFrameBuffer; reuse requires direct adjacency. */ + lastFrameAbsoluteIndex?: number; /** Count of frames served from a reused buffer (dedup telemetry). */ staticDedupCount?: number; // ── Static-dedup observability (set by armStaticDedup; surfaced via // getCapturePerfSummary → RenderPerfSummary → the render_complete event) ── - // NOTE: `armed` and `predicted` are NOT stored — they derive from - // `staticFrames` (armed ⟺ non-empty set; predicted === size) in - // getCapturePerfSummary, so they can't desync from the actual reuse set. + // `armed` derives from the verified staticFrames set. Predicted count is stored + // separately because partial budget arming intentionally makes them diverge. /** Dedup was enabled for this render (default-on; opt out with `HF_STATIC_DEDUP=false`). */ staticDedupEnabled?: boolean; + /** Original predicted-static count, before profitability or partial verification. */ + staticDedupPredictedCount?: number; + /** Bounded verifier taxonomy and counters for debug/perf reporting. */ + staticDedupVerification?: StaticVerificationResult; /** * Short machine code for WHY dedup did not arm, for a low-cardinality breakdown. * One of: `capture_mode` | `video_injection` | `page_composite` | - * `ineligible` | `verification_failed` | `verification_budget`. Undefined when armed or disabled. + * `ineligible` | `unprofitable` | `verification_failed` | `verification_budget`. + * Undefined when armed or disabled. */ staticDedupSkipReason?: string; // Tracks whether the page/browser handles have already been released by @@ -2597,18 +2608,16 @@ async function prepareFrameForCapture( * cut changes content with no tween; treat those frames as animated so the post-cut * frame is captured fresh and later static frames reuse the correct scene. */ -async function computeClipBoundaryFrames(page: Page, fps: number): Promise> { - const schedule = await page.evaluate(() => - Array.from(document.querySelectorAll("[data-start]")).map((el) => ({ - start: parseFloat((el as HTMLElement).dataset.start || ""), - dur: parseFloat((el as HTMLElement).dataset.duration || ""), - })), - ); +export function computeAuthoredClipBoundaryFrames( + schedule: RawAuthoredTiming[], + fps: number, +): Set { const frames = new Set(); - for (const { start, dur } of schedule) { - if (Number.isNaN(start)) continue; - const edges = [Math.round(start * fps)]; - if (!Number.isNaN(dur)) edges.push(Math.round((start + dur) * fps)); + for (const rawTiming of schedule) { + const timing = resolveAuthoredTimingWindow(rawTiming); + if (!timing) continue; + const edges = [Math.round(timing.start * fps)]; + if (timing.end != null) edges.push(Math.round(timing.end * fps)); for (const e of edges) { for (const f of [e - 1, e, e + 1]) { if (f >= 0) frames.add(f); @@ -2618,6 +2627,19 @@ async function computeClipBoundaryFrames(page: Page, fps: number): Promise> { + const schedule = await page.evaluate(() => + Array.from(document.querySelectorAll("[data-start]")).map((el) => ({ + start: el.getAttribute("data-start"), + duration: el.getAttribute("data-duration"), + authoredDuration: el.getAttribute("data-hf-authored-duration"), + end: el.getAttribute("data-end"), + authoredEnd: el.getAttribute("data-hf-authored-end"), + })), + ); + return computeAuthoredClipBoundaryFrames(schedule, fps); +} + // Static dedup is an optional optimization. Building frame-index Sets scales with the // composition's declared duration, so malformed/sentinel durations must fail closed before // allocating them. Normal capture and the producer's typed duration validation still proceed. @@ -2837,42 +2859,163 @@ const STATIC_VERIFY_REFERENCE_STRIDE = 24; // optimization before the real render starts. Exhaustion fails closed: dedup is // disabled and normal capture proceeds. const STATIC_VERIFY_MAX_MS = 15_000; +// Two captures (anchor + comparison) are the minimum proof for a run. Four hundred +// therefore permits 200 fully verified runs while bounding pathological schedules. +const STATIC_VERIFY_MIN_SCREENSHOT_CAP = 400; +// Preserve the legacy tuning headroom: raising the composition-wide sample floor may +// raise the cap proportionally, but never changes the fixed wall deadline. +const STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER = 8; +// Keep sample-driven work within the 400-screenshot base cap: 50 comparisons × 8 +// legacy headroom. Mandatory <=24-gap points may still raise the independent cap. +const STATIC_VERIFY_MAX_GLOBAL_SAMPLE_FLOOR = + STATIC_VERIFY_MIN_SCREENSHOT_CAP / STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER; -/** - * Interior verification points for a run [a..b], plus the always-included end `b`. - * Density used to be a flat point-count cap (min(sampleCount, 8)), so a run's - * stride grew with its span — on a long run (many merged static frames), two - * checks could land hundreds of frames apart. A genuine content change in - * between (e.g. text swapped by a mechanism computeStaticFrameSet's GSAP-only - * tween walk can't see) then hides between samples and the whole run gets - * wrongly trusted as static. - * - * `sampleCount` (HF_STATIC_DEDUP_SAMPLES) is a per-run point-count FLOOR, not a - * stride cap — raising it always increases density, never decreases it. (An - * earlier revision of this fix bounded the stride BY sampleCount directly, which - * inverted that: raising sampleCount widened the allowed gap instead of shrinking - * it, and the "raise HF_STATIC_DEDUP_SAMPLES to verify more" log guidance became - * backwards for exactly the long runs it's meant to help.) The length-scaling - * fix itself comes from STATIC_VERIFY_REFERENCE_STRIDE, which is independent of - * sampleCount, so density scales with run length regardless of how that knob is - * set; sampleCount only ever raises density further above that floor. - * - * Pure and exported so its scaling behavior is unit-testable without a real - * page/browser. - */ -export function computeStaticVerificationPoints( - a: number, - b: number, - sampleCount: number, -): number[] { - const span = b - a; - const lengthScaledPoints = span > 0 ? Math.ceil(span / STATIC_VERIFY_REFERENCE_STRIDE) + 1 : 1; - const perRun = Math.max(3, sampleCount, lengthScaledPoints); - const stride = span > 0 ? Math.max(1, Math.floor(span / (perRun - 1))) : 1; - const pts = new Set(); - for (let f = a; f <= b; f += stride) pts.add(f); - pts.add(b); - return [...pts].sort((x, y) => x - y); +export interface StaticVerificationRun { + a: number; + b: number; + anchor: number; + comparisons: number[]; + frameCount: number; + netSavings: number; +} + +export interface StaticVerificationPlan { + runs: StaticVerificationRun[]; + skippedRuns: Array<{ a: number; b: number; reason: "unprofitable" }>; + effectiveSampleFloor: number; + predictedFrames: number; + verifiedCandidateFrames: number; + plannedAnchors: number; + plannedComparisons: number; + plannedScreenshots: number; +} + +function contiguousStaticRuns(frames: number[]): Array<{ a: number; b: number }> { + const runs: Array<{ a: number; b: number }> = []; + for (const frame of frames) { + const last = runs.at(-1); + if (last && frame === last.b + 1) last.b = frame; + else runs.push({ a: frame, b: frame }); + } + return runs; +} + +function mandatoryRunComparisons(anchor: number, end: number): number[] { + const points = new Set(); + for ( + let frame = anchor + STATIC_VERIFY_REFERENCE_STRIDE; + frame < end; + frame += STATIC_VERIFY_REFERENCE_STRIDE + ) { + points.add(frame); + } + points.add(end); + return [...points].sort((left, right) => left - right); +} + +/** Pure composition-wide planner. Every retained run is profitable and has gaps <=24 frames. */ +export function planStaticVerification( + staticFrames: Set, + sampleFloor: number, +): StaticVerificationPlan { + const frames = [...staticFrames].sort((left, right) => left - right); + const allRuns = contiguousStaticRuns(frames).map(({ a, b }): StaticVerificationRun => { + const comparisons = mandatoryRunComparisons(a - 1, b); + const frameCount = b - a + 1; + return { + a, + b, + anchor: a - 1, + comparisons, + frameCount, + netSavings: frameCount - comparisons.length - 1, + }; + }); + const runs = allRuns.filter((run) => run.anchor >= 0 && run.netSavings > 0); + const skippedRuns = allRuns + .filter((run) => run.anchor < 0 || run.netSavings <= 0) + .map((run) => ({ a: run.a, b: run.b, reason: "unprofitable" as const })); + + const normalizedSampleFloor = Number.isFinite(sampleFloor) + ? Math.max(1, Math.floor(sampleFloor)) + : 1; + const effectiveSampleFloor = Math.min( + normalizedSampleFloor, + STATIC_VERIFY_MAX_GLOBAL_SAMPLE_FLOOR, + ); + const comparisonCount = () => runs.reduce((sum, run) => sum + run.comparisons.length, 0); + while (comparisonCount() < effectiveSampleFloor) { + const candidates = runs + .filter((run) => run.frameCount > run.comparisons.length + 2) + .flatMap((run) => { + const points = [run.anchor, ...run.comparisons]; + return points.slice(1).map((right, index) => ({ + run, + left: points[index]!, + right, + width: right - points[index]!, + })); + }) + .filter((gap) => gap.width > 1) + .sort( + (left, right) => + right.width - left.width || left.run.a - right.run.a || left.left - right.left, + ); + const selected = candidates[0]; + if (!selected) break; + selected.run.comparisons.push(Math.floor((selected.left + selected.right) / 2)); + selected.run.comparisons.sort((left, right) => left - right); + selected.run.netSavings = selected.run.frameCount - selected.run.comparisons.length - 1; + } + + runs.sort((left, right) => right.netSavings - left.netSavings || left.a - right.a); + const plannedComparisons = comparisonCount(); + return { + runs, + skippedRuns, + effectiveSampleFloor, + predictedFrames: frames.length, + verifiedCandidateFrames: runs.reduce((sum, run) => sum + run.frameCount, 0), + plannedAnchors: runs.length, + plannedComparisons, + plannedScreenshots: runs.length + plannedComparisons, + }; +} + +export type StaticVerificationOutcome = + | "verified" + | "unprofitable" + | "time_budget" + | "count_budget" + | "mismatch" + | "infrastructure"; + +export interface StaticVerificationStats { + plannedRuns: number; + completedRuns: number; + plannedAnchors: number; + completedAnchors: number; + plannedComparisons: number; + completedComparisons: number; + seeks: number; + screenshots: number; + byteComparisons: number; + elapsedMs: number; + predictedFrames: number; + verifiedFrames: number; + unverifiedFrames: number; +} + +export interface StaticVerificationResult { + outcome: StaticVerificationOutcome; + verifiedFrames: Set; + badFrame?: number; + stats: StaticVerificationStats; +} + +interface StaticVerificationDependencies { + now?: () => number; + capture?: typeof pageScreenshotCapture; } /** @@ -2880,9 +3023,11 @@ export function computeStaticVerificationPoints( * into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR, * not the predecessor — a slow drift with sub-quantization per-frame deltas is byte- * identical frame-to-frame yet drifts far from the anchor by the run's end (the real - * frozen error). Capture each run's anchor once, compare END + a midpoint to it; any - * mismatch ⇒ the run isn't truly static ⇒ disable dedup whole-comp. Capture-mode- - * independent (seeks + screenshots in normal DOM). Returns the first bad frame, or null. + * frozen error). Capture each run's anchor once, compare its end plus deterministic + * composition-wide points that preserve a 24-frame maximum gap; any mismatch ⇒ the + * run isn't truly static ⇒ disable dedup whole-comp. Capture-mode- + * independent (seeks + screenshots in normal DOM). Budget exhaustion may retain only + * runs whose anchor and every planned comparison completed successfully. */ export async function verifyStaticFramesSafe( session: CaptureSession, @@ -2890,19 +3035,41 @@ export async function verifyStaticFramesSafe( staticFrames: Set, fps: number, sampleCount: number, -): Promise<{ badFrame: number; budgetExhausted: boolean } | null> { - const frames = [...staticFrames].sort((a, b) => a - b); - if (frames.length === 0) return null; - const deadline = Date.now() + STATIC_VERIFY_MAX_MS; - // Runs are maximal-contiguous (adjacent frames merge), so a run's anchor a-1 is - // guaranteed NOT static — always a freshly-captured frame. - const runs: Array<{ a: number; b: number }> = []; - for (const f of frames) { - const last = runs[runs.length - 1]; - if (last && f === last.b + 1) last.b = f; - else runs.push({ a: f, b: f }); - } + dependencies: StaticVerificationDependencies = {}, +): Promise { + const plan = planStaticVerification(staticFrames, sampleCount); + const now = dependencies.now ?? Date.now; + const capture = dependencies.capture ?? pageScreenshotCapture; + const startedAt = now(); + const deadline = startedAt + STATIC_VERIFY_MAX_MS; + const verifiedFrames = new Set(); + const stats: StaticVerificationStats = { + plannedRuns: plan.runs.length, + completedRuns: 0, + plannedAnchors: plan.plannedAnchors, + completedAnchors: 0, + plannedComparisons: plan.plannedComparisons, + completedComparisons: 0, + seeks: 0, + screenshots: 0, + byteComparisons: 0, + elapsedMs: 0, + predictedFrames: plan.predictedFrames, + verifiedFrames: 0, + unverifiedFrames: plan.predictedFrames, + }; + const finish = ( + outcome: StaticVerificationOutcome, + badFrame?: number, + ): StaticVerificationResult => ({ + outcome, + verifiedFrames, + ...(badFrame == null ? {} : { badFrame }), + stats, + }); + if (plan.runs.length === 0) return finish("unprofitable"); const seekToFrame = async (frameIdx: number): Promise => { + stats.seeks++; const t = quantizeTimeToFrame(frameIdx / fps, fps); await page.evaluate((tt: number) => { const hf = ( @@ -2913,50 +3080,54 @@ export async function verifyStaticFramesSafe( if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: true }); }, t); }; - const seekCapture = async (frameIdx: number): Promise => { - await seekToFrame(frameIdx); - return pageScreenshotCapture(page, session.options); - }; - // Verify EVERY run in order (no longest-first truncation that would leave runs armed - // but unverified). Per run, compare the FIRST reused frame `a`, the END `b` (max - // accumulated drift), and interior points at a stride (see computeStaticVerificationPoints) - // — against the anchor the run actually reuses. - // - // hardCap bounds pathological cases and hitting it DISABLES dedup (conservative: - // never trust an unverified set). It must scale with the new density model: - // each run now costs roughly span/STATIC_VERIFY_REFERENCE_STRIDE + 1 checks (plus - // one anchor), not the ~8 the old flat point cap cost — sizing the budget only off - // sampleCount (which no longer drives density for long runs) would make a - // genuinely-static long composition spuriously disarm under the new, more - // thorough checking. `frames.length` approximates total interior checks; a 3x - // margin absorbs per-run anchor overhead and the 3-point floor on short runs. const hardCap = Math.max( - sampleCount * 8, - 400, - Math.ceil(frames.length / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + runs.length, + STATIC_VERIFY_MIN_SCREENSHOT_CAP, + plan.effectiveSampleFloor * STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER, + Math.ceil(plan.predictedFrames / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + plan.runs.length, ); + const seekCapture = async ( + frameIdx: number, + ): Promise => { + if (now() >= deadline) return "time_budget"; + if (stats.screenshots >= hardCap) return "count_budget"; + await seekToFrame(frameIdx); + stats.screenshots++; + return capture(page, session.options); + }; + // Verify profitable runs in deterministic savings order. A run is added to the armed + // set only after its anchor and every planner-selected comparison match. Budget exits + // retain completed runs; mismatch or infrastructure failure clears all verified frames. try { - let spent = 0; - for (const { a, b } of runs) { - const anchor = a - 1; - if (anchor < 0) continue; - if (Date.now() >= deadline) return { badFrame: a, budgetExhausted: true }; - const anchorBuf = await seekCapture(anchor); - spent++; - for (const f of computeStaticVerificationPoints(a, b, sampleCount)) { - if (Date.now() >= deadline) return { badFrame: f, budgetExhausted: true }; + for (const run of plan.runs) { + const anchorBuf = await seekCapture(run.anchor); + if (typeof anchorBuf === "string") return finish(anchorBuf, run.a); + stats.completedAnchors++; + for (const f of run.comparisons) { const cur = await seekCapture(f); - spent++; - if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false }; + if (typeof cur === "string") return finish(cur, f); + stats.completedComparisons++; + stats.byteComparisons++; + if (!anchorBuf.equals(cur)) { + verifiedFrames.clear(); + stats.verifiedFrames = 0; + stats.unverifiedFrames = plan.predictedFrames; + return finish("mismatch", f); + } } - // Budget exhausted → can't fully verify → disarm, distinct from real drift so a - // `verification_budget` spike in telemetry reads as "this composition has a lot - // of static material to verify," not "compositions are non-static." - if (spent > hardCap) return { badFrame: a, budgetExhausted: true }; + stats.completedRuns++; + for (let frame = run.a; frame <= run.b; frame++) verifiedFrames.add(frame); + stats.verifiedFrames = verifiedFrames.size; + stats.unverifiedFrames = plan.predictedFrames - verifiedFrames.size; } - return null; + return finish("verified"); + } catch { + verifiedFrames.clear(); + stats.verifiedFrames = 0; + stats.unverifiedFrames = plan.predictedFrames; + return finish("infrastructure"); } finally { await seekToFrame(0).catch(() => {}); + stats.elapsedMs = Math.max(0, now() - startedAt); } } @@ -3029,28 +3200,43 @@ async function armStaticDedup( } const rawSamples = Number(process.env.HF_STATIC_DEDUP_SAMPLES ?? "24"); const samples = Number.isFinite(rawSamples) && rawSamples >= 1 ? rawSamples : 24; - const verdict = - process.env.HF_STATIC_DEDUP_VERIFY === "false" - ? null - : await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples); - if (verdict !== null) { - session.staticDedupSkipReason = verdict.budgetExhausted - ? "verification_budget" - : "verification_failed"; + session.staticDedupPredictedCount = stats.staticFrameSet.size; + if (process.env.HF_STATIC_DEDUP_VERIFY === "false") { + session.staticFrames = stats.staticFrameSet; logInitPhase( - verdict.budgetExhausted - ? `static-frame dedup: disabled (verification budget exhausted before frame ${verdict.badFrame}; ` + - `too much predicted-static material to fully verify — this is the safe fallback, not an error)` - : `static-frame dedup: disabled (verification failed — content drifts from anchor at ` + - `predicted-static frame ${verdict.badFrame})`, + `static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` + + `(verification explicitly disabled)`, ); return; } - // armed + predicted are derived from staticFrames in getCapturePerfSummary. - session.staticFrames = stats.staticFrameSet; + const verdict = await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples); + session.staticDedupVerification = verdict; + if (verdict.outcome === "mismatch" || verdict.outcome === "infrastructure") { + session.staticDedupSkipReason = "verification_failed"; + logInitPhase( + verdict.outcome === "mismatch" + ? `static-frame dedup: disabled (verification mismatch at predicted-static frame ${verdict.badFrame})` + : "static-frame dedup: disabled (verification infrastructure failure)", + ); + return; + } + if (verdict.outcome === "unprofitable") { + session.staticDedupSkipReason = "unprofitable"; + logInitPhase("static-frame dedup: disabled (verification cost cannot save captures)"); + return; + } + if (verdict.verifiedFrames.size === 0) { + session.staticDedupSkipReason = "verification_budget"; + logInitPhase( + `static-frame dedup: disabled (${verdict.outcome} before frame ${verdict.badFrame}; no run fully verified)`, + ); + return; + } + session.staticFrames = verdict.verifiedFrames; logInitPhase( - `static-frame dedup: ${stats.staticFrameSet.size}/${stats.totalFrames} frame(s) reusable ` + - `(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`, + `static-frame dedup: ${verdict.verifiedFrames.size}/${stats.staticFrameSet.size} predicted frame(s) reusable ` + + `(outcome=${verdict.outcome}, runs=${verdict.stats.completedRuns}/${verdict.stats.plannedRuns}, ` + + `screenshots=${verdict.stats.screenshots}, seeks=${verdict.stats.seeks}, elapsedMs=${verdict.stats.elapsedMs})`, ); } @@ -3184,8 +3370,13 @@ async function captureFrameCore( // Use the SAME floor+epsilon idiom as quantizeTimeToFrame so the dedup lookup agrees // with the frame the seek actually lands on, even if `time` ever isn't exactly i/fps. const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9); - if (session.staticFrames?.has(absFrameIndex) && session.lastFrameBuffer) { + if ( + session.staticFrames?.has(absFrameIndex) && + session.lastFrameBuffer && + session.lastFrameAbsoluteIndex === absFrameIndex - 1 + ) { session.staticDedupCount = (session.staticDedupCount ?? 0) + 1; + session.lastFrameAbsoluteIndex = absFrameIndex; return { buffer: session.lastFrameBuffer, quantizedTime: quantizeTimeToFrame(time, fpsToNumber(options.fps)), @@ -3314,7 +3505,10 @@ async function captureFrameCore( session.capturePerf.frameMs.push(captureTimeMs); // Retain this freshly-captured buffer so the following static frames can reuse it. - if (session.staticFrames) session.lastFrameBuffer = screenshotBuffer; + if (session.staticFrames) { + session.lastFrameBuffer = screenshotBuffer; + session.lastFrameAbsoluteIndex = absFrameIndex; + } return { buffer: screenshotBuffer, quantizedTime, captureTimeMs }; } catch (captureError) { @@ -3683,6 +3877,7 @@ export async function discardWarmupCapture( const noDamageBefore = session.beginFrameNoDamageCount; const dedupCountBefore = session.staticDedupCount; const lastFrameBufferBefore = session.lastFrameBuffer; + const lastFrameAbsoluteIndexBefore = session.lastFrameAbsoluteIndex; try { await innerCapture(session, frameIndex, time); } finally { @@ -3694,6 +3889,7 @@ export async function discardWarmupCapture( session.beginFrameNoDamageCount = noDamageBefore; session.staticDedupCount = dedupCountBefore; session.lastFrameBuffer = lastFrameBufferBefore; + session.lastFrameAbsoluteIndex = lastFrameAbsoluteIndexBefore; } } @@ -3782,6 +3978,7 @@ export function prepareCaptureSessionForReuse( // intact: it's keyed in absolute frames and stays valid for a same-composition reuse; // lastFrameBuffer must be re-seeded by this render's first fresh capture. session.lastFrameBuffer = undefined; + session.lastFrameAbsoluteIndex = undefined; session.staticDedupCount = 0; } @@ -3964,9 +4161,17 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma warnings: cloneCaptureWarnings(session.warnings), staticDedupReused: session.staticDedupCount ?? 0, staticDedupEnabled: session.staticDedupEnabled ?? false, - // armed ⟺ a non-empty static set survived verification; predicted === its size. + // armed ⟺ a non-empty static set survived verification. staticDedupArmed: (session.staticFrames?.size ?? 0) > 0, - staticDedupPredicted: session.staticFrames?.size ?? 0, + staticDedupPredicted: session.staticDedupPredictedCount ?? 0, + staticDedupVerified: session.staticFrames?.size ?? 0, + staticDedupVerificationOutcome: session.staticDedupVerification?.outcome, + staticDedupVerificationPlannedRuns: session.staticDedupVerification?.stats.plannedRuns, + staticDedupVerificationCompletedRuns: session.staticDedupVerification?.stats.completedRuns, + staticDedupVerificationScreenshots: session.staticDedupVerification?.stats.screenshots, + staticDedupVerificationSeeks: session.staticDedupVerification?.stats.seeks, + staticDedupVerificationComparisons: session.staticDedupVerification?.stats.byteComparisons, + staticDedupVerificationElapsedMs: session.staticDedupVerification?.stats.elapsedMs, staticDedupSkipReason: session.staticDedupSkipReason, beginFrameNoDamage: session.beginFrameNoDamageCount, beginFrameHasDamage: session.beginFrameHasDamageCount, diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 8b33b2df5..0bcfed349 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -286,11 +286,27 @@ export interface CapturePerfSummary { staticDedupEnabled: boolean; /** Dedup passed every gate + verification and was active. */ staticDedupArmed: boolean; - /** Predicted reusable frame count when armed; 0 otherwise. */ + /** Original predicted reusable frame count before profitability/budget filtering. */ staticDedupPredicted: number; + /** Frames retained after complete-run verification. */ + staticDedupVerified?: number; + /** Bounded verifier result taxonomy. */ + staticDedupVerificationOutcome?: + | "verified" + | "unprofitable" + | "time_budget" + | "count_budget" + | "mismatch" + | "infrastructure"; + staticDedupVerificationPlannedRuns?: number; + staticDedupVerificationCompletedRuns?: number; + staticDedupVerificationScreenshots?: number; + staticDedupVerificationSeeks?: number; + staticDedupVerificationComparisons?: number; + staticDedupVerificationElapsedMs?: number; /** * Low-cardinality reason dedup did not arm: `capture_mode` | `video_injection` - * | `page_composite` | `ineligible` | `verification_failed` | `verification_budget`. + * | `page_composite` | `ineligible` | `unprofitable` | `verification_failed` | `verification_budget`. * Undefined when armed or when dedup was disabled. (Render-level aggregation may * `|`-join distinct reasons when parallel workers diverge.) */ diff --git a/packages/producer/src/services/render/perfSummary-dedup.test.ts b/packages/producer/src/services/render/perfSummary-dedup.test.ts index 51ab7de22..a5baadc89 100644 --- a/packages/producer/src/services/render/perfSummary-dedup.test.ts +++ b/packages/producer/src/services/render/perfSummary-dedup.test.ts @@ -82,6 +82,52 @@ describe("buildRenderPerfSummary static-dedup aggregation", () => { }); }); + it("keeps predicted and verified counts distinct and aggregates bounded verifier telemetry", () => { + const s = buildRenderPerfSummary( + baseInput([ + perf({ + staticDedupEnabled: true, + staticDedupArmed: true, + staticDedupPredicted: 300, + staticDedupVerified: 240, + staticDedupVerificationOutcome: "time_budget", + staticDedupVerificationPlannedRuns: 60, + staticDedupVerificationCompletedRuns: 48, + staticDedupVerificationScreenshots: 96, + staticDedupVerificationSeeks: 97, + staticDedupVerificationComparisons: 48, + staticDedupVerificationElapsedMs: 15_000, + }), + perf({ + staticDedupEnabled: true, + staticDedupArmed: true, + staticDedupPredicted: 200, + staticDedupVerified: 200, + staticDedupVerificationOutcome: "verified", + staticDedupVerificationPlannedRuns: 40, + staticDedupVerificationCompletedRuns: 40, + staticDedupVerificationScreenshots: 80, + staticDedupVerificationSeeks: 81, + staticDedupVerificationComparisons: 40, + staticDedupVerificationElapsedMs: 8_000, + }), + ]), + ).staticDedup; + expect(s).toMatchObject({ + armed: true, + predictedFrames: 500, + verifiedFrames: 440, + verificationOutcomes: ["time_budget", "verified"], + plannedRuns: 100, + completedRuns: 88, + screenshots: 176, + seeks: 178, + comparisons: 88, + verificationElapsedMs: 23_000, + skipReason: undefined, + }); + }); + it("reports skipReason when no worker armed", () => { const s = buildRenderPerfSummary( baseInput([ diff --git a/packages/producer/src/services/render/perfSummary.ts b/packages/producer/src/services/render/perfSummary.ts index 50ff45578..29472da3d 100644 --- a/packages/producer/src/services/render/perfSummary.ts +++ b/packages/producer/src/services/render/perfSummary.ts @@ -5,7 +5,12 @@ import { arch, cpus, platform, totalmem } from "node:os"; import { fpsToNumber } from "@hyperframes/core"; -import type { CapturePerfSummary, SubTimelineWaitOutcome, WorkerSizing } from "@hyperframes/engine"; +import type { + CapturePerfSummary, + StaticVerificationOutcome, + SubTimelineWaitOutcome, + WorkerSizing, +} from "@hyperframes/engine"; import type { CaptureCalibrationSample, CaptureCostEstimate } from "./captureCost.js"; import type { CaptureAttemptSummary, @@ -165,12 +170,53 @@ function aggregateDedup(perfs: CapturePerfSummary[]): RenderPerfSummary["staticD : [ ...new Set(perfs.map((p) => p.staticDedupSkipReason).filter((r): r is string => !!r)), ].sort(); + const verificationPerfs = perfs.filter((perf) => perf.staticDedupVerificationOutcome); + const verificationOutcomes = [ + ...new Set( + verificationPerfs + .map((perf) => perf.staticDedupVerificationOutcome) + .filter((outcome): outcome is StaticVerificationOutcome => outcome != null), + ), + ].sort(); return { enabled: perfs.some((p) => p.staticDedupEnabled), armed, predictedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupPredicted ?? 0), 0), reusedFrames: perfs.reduce((sum, p) => sum + (p.staticDedupReused ?? 0), 0), skipReason: skipReasons.length > 0 ? skipReasons.join("|") : undefined, + ...(verificationPerfs.length === 0 + ? {} + : { + verifiedFrames: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerified ?? 0), + 0, + ), + verificationOutcomes, + plannedRuns: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerificationPlannedRuns ?? 0), + 0, + ), + completedRuns: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerificationCompletedRuns ?? 0), + 0, + ), + screenshots: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerificationScreenshots ?? 0), + 0, + ), + seeks: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerificationSeeks ?? 0), + 0, + ), + comparisons: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerificationComparisons ?? 0), + 0, + ), + verificationElapsedMs: verificationPerfs.reduce( + (sum, perf) => sum + (perf.staticDedupVerificationElapsedMs ?? 0), + 0, + ), + }), }; } diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 235c23ee9..e81a927a1 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -77,6 +77,7 @@ import { type CaptureWarning, type SubTimelineWaitOutcome, type WorkerSizing, + type StaticVerificationOutcome, resolveBrowserGpuMode, resolveHeadlessShellPath, applyConcreteGpuScreenshotClamp, @@ -461,8 +462,16 @@ export interface RenderPerfSummary { enabled: boolean; armed: boolean; predictedFrames: number; + verifiedFrames?: number; reusedFrames: number; skipReason?: string; + verificationOutcomes?: StaticVerificationOutcome[]; + plannedRuns?: number; + completedRuns?: number; + screenshots?: number; + seeks?: number; + comparisons?: number; + verificationElapsedMs?: number; }; /** * BeginFrame no-damage reuse outcome for this render (Linux/Docker),