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
This commit is contained in:
Miguel Ángel
2026-08-23 13:18:18 -04:00
committed by GitHub
parent dd0626a55a
commit 65b2299db2
15 changed files with 901 additions and 261 deletions
+7
View File
@@ -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,
@@ -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,
});
});
});
@@ -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 };
}
+21 -34
View File
@@ -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<string, RuntimeTimelineLike | undefined>;
@@ -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)) {
+46 -1
View File
@@ -1,14 +1,39 @@
import { describe, it, expect, afterEach } from "vitest";
import { collectRuntimeTimelinePayload } from "./timeline";
type TimelineTestWindow = Window & {
__timelines?: Record<string, { duration: () => 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");
+18 -8
View File
@@ -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
);
}
+1
View File
@@ -127,6 +127,7 @@ export {
isMemoryExhaustionError,
type BeforeCaptureHook,
type DiscardWarmupInnerCapture,
type StaticVerificationOutcome,
} from "./services/frameCapture.js";
export {
CaptureFailure,
@@ -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]));
});
});
@@ -39,6 +39,7 @@ function makeSession(staticFrames: Set<number>, 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);
});
});
@@ -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<number>();
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<typeof verifyStaticFramesSafe>[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<number> {
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<typeof trackedPage>["page"],
dependencies: Parameters<typeof verifyStaticFramesSafe>[5],
) {
return verifyStaticFramesSafe(
{ options: {} } as unknown as CaptureSession,
page as unknown as Parameters<typeof verifyStaticFramesSafe>[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<number>();
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<typeof verifyStaticFramesSafe>[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<typeof verifyStaticFramesSafe>[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);
});
});
+328 -123
View File
@@ -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<number>;
/** 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<Set<number>> {
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<number> {
const frames = new Set<number>();
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<Set<n
return frames;
}
async function computeClipBoundaryFrames(page: Page, fps: number): Promise<Set<number>> {
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<number>();
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<number>();
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<number>,
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<number>;
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<number>,
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<StaticVerificationResult> {
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<number>();
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<void> => {
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<Buffer> => {
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<Buffer | "time_budget" | "count_budget"> => {
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,
+18 -2
View File
@@ -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.)
*/
@@ -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([
@@ -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,
),
}),
};
}
@@ -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),