mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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 };
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user