mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(cli): check's dense overlap pass takes at most 240 samples, at clip windows first (#5764)
* fix(cli): check's dense overlap pass takes at most 240 samples, at clip windows first * fix(cli): a held overlap on a long film is still an error under the 240-sample bound
This commit is contained in:
@@ -163,6 +163,7 @@ function fakeDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver
|
||||
getDuration: vi.fn(async () => 9),
|
||||
hasNoTimelineDeclaration: vi.fn(async () => false),
|
||||
getTransitionBoundaries: vi.fn(async () => []),
|
||||
getClipEdges: vi.fn(async () => []),
|
||||
getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })),
|
||||
findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []),
|
||||
seek: vi.fn(async (_time: number) => undefined),
|
||||
@@ -2312,6 +2313,46 @@ describe("dense motion-overlap re-sampling", () => {
|
||||
expect(driver.collectOverlap).toHaveBeenCalled();
|
||||
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a long film's dense pass to a fixed number of seeks", async () => {
|
||||
const clipEdges = Array.from({ length: 433 }, (_, i) => [i * 1.2, i * 1.2 + 1.1]).flat();
|
||||
const driver = fakeDriver({
|
||||
getDuration: vi.fn(async () => 540),
|
||||
getClipEdges: async () => clipEdges,
|
||||
});
|
||||
await runScenario(driver);
|
||||
expect(vi.mocked(driver.seekGeometry).mock.calls.length).toBeLessThanOrEqual(240);
|
||||
});
|
||||
|
||||
it("still promotes an overlap held on a long film", async () => {
|
||||
const held = (time: number): boolean => time >= 200 && time <= 260;
|
||||
const driver = fakeDriver({
|
||||
getDuration: vi.fn(async () => 540),
|
||||
collectLayout: vi.fn(async (_time: number) => []),
|
||||
collectOverlap: vi.fn(async (time: number) =>
|
||||
held(time) ? [layoutIssue("warning", { time, code: "content_overlap" })] : [],
|
||||
),
|
||||
});
|
||||
const { report } = await runScenario(driver);
|
||||
expect(report.layout.findings).toContainEqual(
|
||||
expect.objectContaining({ code: "content_overlap", severity: "error" }),
|
||||
);
|
||||
expect(vi.mocked(driver.seekGeometry).mock.calls.length).toBeLessThanOrEqual(240);
|
||||
});
|
||||
|
||||
it("samples a short clip the uniform grid steps over", async () => {
|
||||
const inShortClip = (time: number): boolean => time > 100.1 && time < 100.4;
|
||||
const driver = fakeDriver({
|
||||
getDuration: vi.fn(async () => 540),
|
||||
getClipEdges: async () => [100.1, 100.4],
|
||||
collectLayout: vi.fn(async (_time: number) => []),
|
||||
collectOverlap: vi.fn(async (time: number) =>
|
||||
inShortClip(time) ? [layoutIssue("warning", { time, code: "content_overlap" })] : [],
|
||||
),
|
||||
});
|
||||
const { report } = await runScenario(driver);
|
||||
expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoped check", () => {
|
||||
|
||||
@@ -570,6 +570,7 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud
|
||||
getDuration: () => getCompositionDuration(page),
|
||||
hasNoTimelineDeclaration: () => hasNoTimelineDeclaration(page),
|
||||
getTransitionBoundaries: () => collectTweenBoundaries(page),
|
||||
getClipEdges: () => collectClipEdges(page),
|
||||
getCanvas: () =>
|
||||
page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||
findAmbiguousSelectors: (selectors) => findAmbiguousSelectors(page, selectors),
|
||||
@@ -644,6 +645,16 @@ async function getCompositionDuration(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function collectClipEdges(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
type Clip = { kind: string; start: number; duration: number };
|
||||
const manifest = Reflect.get(window, "__clipManifest") as { clips?: Clip[] } | undefined;
|
||||
return (manifest?.clips ?? [])
|
||||
.filter((clip) => clip.kind !== "audio")
|
||||
.flatMap((clip) => [clip.start, clip.start + clip.duration]);
|
||||
});
|
||||
}
|
||||
|
||||
async function collectTweenBoundaries(page: Page): Promise<number[]> {
|
||||
// GSAP getter binding and parent-time conversion form one serialized algorithm.
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
buildLayoutSampleTimes,
|
||||
buildTransitionSampleTimes,
|
||||
collapseStaticLayoutIssues,
|
||||
CONTENT_OVERLAP_HELD_ERROR_MS,
|
||||
longestContiguousRunMs,
|
||||
dedupeLayoutIssues,
|
||||
limitLayoutIssues,
|
||||
@@ -489,19 +490,35 @@ async function collectGridSamples(
|
||||
|
||||
// Dense grid catches mid-motion text collisions the sparse layout grid seeks past; text-only overlap collection is cheap enough to afford it.
|
||||
const OVERLAP_SAMPLE_FPS = 8;
|
||||
// Ceiling that bounds dense seeks; past ~75s the grid degrades below 8fps rather than growing the seek budget without limit.
|
||||
const OVERLAP_MAX_SAMPLES = 600;
|
||||
// A fixed ceiling, so check's work does not grow with film length: clip windows first, then a uniform fill.
|
||||
const OVERLAP_MAX_SAMPLES = 240;
|
||||
// Part of it re-looks at an overlap half a second on where the grid is too sparse to show it held.
|
||||
const OVERLAP_HOLD_PROBES = 60;
|
||||
|
||||
function buildOverlapSampleTimes(duration: number): number[] {
|
||||
function evenlyPicked(times: number[], count: number): number[] {
|
||||
if (times.length <= count) return times;
|
||||
const last = Math.max(1, count - 1);
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => times[Math.floor((i * (times.length - 1)) / last)] ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function buildOverlapSampleTimes(duration: number, clipEdges: number[]): number[] {
|
||||
if (!Number.isFinite(duration) || duration <= 0) return [];
|
||||
const count = Math.min(
|
||||
OVERLAP_MAX_SAMPLES,
|
||||
OVERLAP_MAX_SAMPLES - OVERLAP_HOLD_PROBES,
|
||||
Math.max(2, Math.ceil(duration * OVERLAP_SAMPLE_FPS) + 1),
|
||||
);
|
||||
const step = duration / (count - 1);
|
||||
return mergeSampleTimes(
|
||||
Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000),
|
||||
);
|
||||
const inner = clipEdges.filter((t) => t > 0 && t < duration);
|
||||
const cuts = inner.length > 0 ? mergeSampleTimes([0, duration], inner) : [];
|
||||
// One sample inside every stretch where the visible clips stay the same, so a short overlap is never stepped over.
|
||||
const windows = cuts.slice(1).map((end, i) => ((cuts[i] ?? 0) + end) / 2);
|
||||
const picked = evenlyPicked(windows, count);
|
||||
const fill = count - picked.length;
|
||||
const step = duration / Math.max(1, fill - 1);
|
||||
const uniform = Array.from({ length: fill }, (_, i) => i * step);
|
||||
return mergeSampleTimes([...picked, ...uniform].map((t) => Math.round(t * 1000) / 1000));
|
||||
}
|
||||
|
||||
/** Reruns content_overlap on a fine grid, unconditionally rather than gated on fingerprint change, since an animation aliased to the sparse grid collides between identical-fingerprint samples. */
|
||||
@@ -512,15 +529,28 @@ async function collectMotionOverlapSamples(
|
||||
range?: [number, number],
|
||||
): Promise<void> {
|
||||
const baseTimes = new Set(grid.layoutSamples);
|
||||
const overlapTimes = buildOverlapSampleTimes(range ? range[1] - range[0] : grid.duration);
|
||||
const offset = range?.[0] ?? 0;
|
||||
const edges = (await driver.getClipEdges()).map((time) => time - offset);
|
||||
const overlapTimes = buildOverlapSampleTimes(range ? range[1] - range[0] : grid.duration, edges);
|
||||
const shiftedTimes = range
|
||||
? mergeSampleTimes(overlapTimes.map((time) => time + range[0]))
|
||||
: overlapTimes;
|
||||
for (const time of shiftedTimes) {
|
||||
const holdS = CONTENT_OVERLAP_HELD_ERROR_MS / 1000;
|
||||
const end = range ? range[1] : grid.duration;
|
||||
let probes = 0;
|
||||
for (const [index, time] of shiftedTimes.entries()) {
|
||||
if (baseTimes.has(time)) continue;
|
||||
// Settle-free seek: collectOverlap reads getBoundingClientRect geometry, valid synchronously after setTime, so the dense pass skips the per-seek paint settle.
|
||||
await driver.seekGeometry(time);
|
||||
collected.layoutIssues.push(...(await driver.collectOverlap(time)));
|
||||
const issues = await driver.collectOverlap(time);
|
||||
collected.layoutIssues.push(...issues);
|
||||
const next = shiftedTimes[index + 1] ?? Number.POSITIVE_INFINITY;
|
||||
const probe = Math.round((time + holdS) * 1000) / 1000;
|
||||
if (!issues.length || next - time <= 2 * holdS || probe > end || probes >= OVERLAP_HOLD_PROBES)
|
||||
continue;
|
||||
probes += 1;
|
||||
await driver.seekGeometry(probe);
|
||||
collected.layoutIssues.push(...(await driver.collectOverlap(probe)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ export interface CheckAuditDriver {
|
||||
/** True when the root composition explicitly declares that it is intentionally timeline-free. */
|
||||
hasNoTimelineDeclaration(): Promise<boolean>;
|
||||
getTransitionBoundaries(): Promise<number[]>;
|
||||
getClipEdges(): Promise<number[]>;
|
||||
getCanvas(): Promise<Canvas>;
|
||||
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
|
||||
seek(time: number): Promise<void>;
|
||||
|
||||
@@ -189,7 +189,7 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] {
|
||||
}
|
||||
|
||||
// Persistence-tier thresholds (#U10): occurrences>=2 alone can't imply the 500ms floor under dense 8fps sampling, so content_overlap promotion requires a literal firstSeen..lastSeen span >= 500ms — stricter for sparse callers too.
|
||||
const CONTENT_OVERLAP_HELD_ERROR_MS = 500;
|
||||
export const CONTENT_OVERLAP_HELD_ERROR_MS = 500;
|
||||
const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2;
|
||||
|
||||
// Tiering only applies to layout-audit.browser.js's own per-sample seek-grid
|
||||
|
||||
Reference in New Issue
Block a user