fix(cli): check finishes on video-heavy projects, its geometry seeks skip only video-frame waits (#5715)

* fix(cli): check's dense geometry seeks stop waiting for video frames they never read

* test(cli): race the dense seek against the barrier ask instead of a timer

* fix(cli): keep the geometry seek's comment share at its base

* fix(cli): dense geometry seeks still wait for frame sources, only video frames are skipped
This commit is contained in:
Miguel Ángel
2026-10-16 23:50:49 +00:00
committed by GitHub
parent 13ea834959
commit b1fbc16b6c
8 changed files with 76 additions and 17 deletions
@@ -27,7 +27,7 @@ function fakeSeekPage() {
const evaluate = vi.fn(
async (
_pageFunction: Parameters<CompositionSeekPage["evaluate"]>[0],
_value?: number,
_value?: Parameters<CompositionSeekPage["evaluate"]>[1],
_fallbackToBridgeAndTimelines?: boolean,
): Promise<unknown> => undefined,
);
@@ -108,7 +108,7 @@ describe("seekCompositionTimeline", () => {
expect(waitForFunction).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(4);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true });
expect(evaluate.mock.calls[2]?.[0]).toContain("window.setTimeout(finish, 100)");
// Post-seek font settle: a seek can reveal glyphs whose unicode-range
// subsets only start loading after the next layout (CJK snapshot reports).
@@ -230,7 +230,7 @@ describe("seekCompositionTimeline", () => {
expect(evaluate).toHaveBeenCalledTimes(4);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true, false);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true });
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function));
expect(evaluate).toHaveBeenNthCalledWith(4, expect.any(Function), 500);
});
@@ -471,6 +471,26 @@ describe("installPageFunctionGuard", () => {
});
describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => {
// A 79 s project of 1080p H.264 clips: each of the 600 dense seeks waited ~185 ms for a video frame it never reads,
// so `check` ran past its deadline. Frame sources and hf-seek work can move the DOM, so those still hold it.
it("skips only the seek barrier's video-frame holds, while the audit seek waits for all", async () => {
const barrierCalls = async (options: Parameters<typeof seekCompositionTimeline>[2]) => {
const { page, evaluate } = fakeSeekPage();
const barrier = vi.fn(async (_options?: { videoFrames?: boolean }) => {});
vi.stubGlobal("window", { __hfWaitForSeekCompletion: barrier });
evaluate.mockImplementation(async (pageFunction, value) =>
typeof pageFunction === "function" && typeof value === "object"
? Reflect.apply(pageFunction, undefined, [value])
: undefined,
);
await seekCompositionTimeline(page, 3, options);
return barrier.mock.calls;
};
expect(await barrierCalls(DENSE_GEOMETRY_SEEK_OPTIONS)).toEqual([[{ videoFrames: false }]]);
expect(await barrierCalls(AUDIT_SEEK_OPTIONS)).toEqual([[{ videoFrames: true }]]);
});
it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => {
// Dense pass must not inherit AUDIT's post-seek waits — geometry is valid synchronously after setTime, and waits multiply by sample count.
expect(DENSE_GEOMETRY_SEEK_OPTIONS.animationFrameSettle).toBe("none");
@@ -40,6 +40,7 @@ export const DENSE_GEOMETRY_SEEK_OPTIONS = {
animationFrameSettle: "none",
waitForFontsMs: 0,
settleMs: 0,
waitForVideoFrames: false,
} as const;
export interface SeekCompositionTimelineOptions {
@@ -49,18 +50,22 @@ export interface SeekCompositionTimelineOptions {
animationFrameSettle?: "race" | "double" | "none";
waitForFontsMs?: number;
settleMs?: number;
waitForVideoFrames?: boolean;
}
type SeekBarrierOptions = { videoFrames: boolean };
type CompositionPageFunction =
| string
| (() => unknown)
| ((value: number) => unknown)
| ((barrier: SeekBarrierOptions) => unknown)
| ((value: number, fallbackToBridgeAndTimelines: boolean, exactTime: boolean) => unknown);
export interface CompositionEvaluationPage {
evaluate(
pageFunction: CompositionPageFunction,
value?: number,
value?: number | SeekBarrierOptions,
fallbackToBridgeAndTimelines?: boolean,
exactTime?: boolean,
): Promise<unknown>;
@@ -341,12 +346,15 @@ export async function seekCompositionTimeline(
options.exactTime === true,
);
await page.evaluate(async () => {
const waitForCompletion = Reflect.get(window, "__hfWaitForSeekCompletion");
if (typeof waitForCompletion === "function") {
await Reflect.apply(waitForCompletion, window, []);
}
});
await page.evaluate(
async (barrier: SeekBarrierOptions) => {
const waitForCompletion = Reflect.get(window, "__hfWaitForSeekCompletion");
if (typeof waitForCompletion === "function") {
await Reflect.apply(waitForCompletion, window, [barrier]);
}
},
{ videoFrames: options.waitForVideoFrames !== false },
);
const animationFrameSettle = options.animationFrameSettle ?? "race";
if (animationFrameSettle === "race") {
@@ -21,6 +21,7 @@ import { swallow } from "../diagnostics";
let _lastDispatchedTime = -1;
type SeekCompletionResult = { status: "fulfilled" } | { status: "rejected"; reason: unknown };
let _pendingCompletions = new Set<Promise<SeekCompletionResult>>();
let _videoFrameCompletions = new WeakSet<Promise<SeekCompletionResult>>();
let _pendingFailure: { reason: unknown } | undefined;
let _activeCompletionBarriers = 0;
@@ -62,11 +63,12 @@ function dispatch(time: number): void {
* its preview-side capture here, so the CLI seek paths that already await this
* barrier wait for a `self` chain's paint instead of racing it.
*/
export function registerSeekCompletion(promise: Promise<unknown>): void {
export function registerSeekCompletion(promise: Promise<unknown>, kind?: "video-frame"): void {
const completion = promise
.then<SeekCompletionResult>(() => ({ status: "fulfilled" }))
.catch<SeekCompletionResult>((reason: unknown) => ({ status: "rejected", reason }));
_pendingCompletions.add(completion);
if (kind === "video-frame") _videoFrameCompletions.add(completion);
void completion.then((result) => {
if (!_pendingCompletions.delete(completion)) return;
if (result.status === "rejected" && _pendingFailure === undefined) {
@@ -100,15 +102,20 @@ export function isSeekCompletionBarrierActive(): boolean {
return _activeCompletionBarriers > 0;
}
export async function waitForSeekCompletion(): Promise<void> {
/** `videoFrames: false` skips only the video-frame holds: a geometry read needs every other completion. */
export async function waitForSeekCompletion(options?: { videoFrames?: boolean }): Promise<void> {
const awaited = () =>
[..._pendingCompletions].filter(
(completion) => options?.videoFrames !== false || !_videoFrameCompletions.has(completion),
);
_activeCompletionBarriers += 1;
let failed = _pendingFailure;
try {
// Let concurrently started barriers snapshot the same retained failure
// before either one consumes it.
await Promise.resolve();
while (_pendingCompletions.size > 0) {
const results = await Promise.all([..._pendingCompletions]);
for (let pending = awaited(); pending.length > 0; pending = awaited()) {
const results = await Promise.all(pending);
const rejected = results.find((result) => result.status === "rejected");
if (failed === undefined && rejected?.status === "rejected") {
failed = { reason: rejected.reason };
@@ -131,6 +138,7 @@ export async function waitForSeekCompletion(): Promise<void> {
export function resetSeekDispatchState(): void {
_lastDispatchedTime = -1;
_pendingCompletions = new Set();
_videoFrameCompletions = new WeakSet();
_pendingFailure = undefined;
_activeCompletionBarriers = 0;
}
@@ -42,6 +42,19 @@ afterEach(() => {
});
describe("frame sources", () => {
it("holds a geometry read that skips video frames until its asynchronous draw lands", async () => {
const frame = deferred();
disposers.push(registerFrameSource({ element: mount(), render: () => frame.promise }));
adapter().seek({ time: 2 });
let captured = false;
const capture = waitForSeekCompletion({ videoFrames: false }).then(() => (captured = true));
await Promise.resolve();
expect(captured).toBe(false);
frame.resolve();
await capture;
expect(captured).toBe(true);
});
it("holds capture through setup and asynchronous drawing", async () => {
const setup = deferred();
const frame = deferred();
+6
View File
@@ -742,6 +742,12 @@ describe("syncRuntimeMedia", () => {
expect(await barrierSettled(barrier)).toBe(true);
});
it("does not hold a geometry read for a seeking video's frame", async () => {
seekColdVideo();
expect(await barrierSettled(waitForSeekCompletion({ videoFrames: false }))).toBe(true);
expect(await barrierSettled(waitForSeekCompletion())).toBe(false);
});
function seekLoadingVideo(): RuntimeMediaClip {
const clip = createMockClip({ start: 7.1, end: 18.24 });
Object.defineProperty(clip.el, "readyState", { value: 0, configurable: true });
+1
View File
@@ -48,6 +48,7 @@ function holdSeekBarrierUntilVideoLands(el: HTMLMediaElement): void {
for (const type of HOLD_END_EVENTS) el.addEventListener(type, done, true);
releaseHeldVideo.set(el, done);
}),
"video-frame",
);
}
+4 -1
View File
@@ -347,7 +347,10 @@ export function swapToProxy(
});
});
// A frame capture holds for the copy as it held for a loading video before, up to the same cap.
registerSeekCompletion(Promise.race([swap, new Promise((cap) => setTimeout(cap, HOLD_CAP_MS))]));
registerSeekCompletion(
Promise.race([swap, new Promise((cap) => setTimeout(cap, HOLD_CAP_MS))]),
"video-frame",
);
const codecName = entry?.codecName ?? null;
const details: Record<string, RuntimeJson> = {
asset: originalSrc,
+2 -2
View File
@@ -132,9 +132,9 @@ declare global {
__hfReseekGpu?: (time: number) => void;
/**
* Await GPU work registered synchronously by `hf-seek` listeners through
* `event.detail.waitUntil(...)`.
* `event.detail.waitUntil(...)`; `videoFrames: false` skips the video-frame holds.
*/
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfWaitForSeekCompletion?: (options?: { videoFrames?: boolean }) => Promise<void>;
/**
* Canonical root-timeline start for a media element. Snapshot capture uses
* this runtime-owned resolver so reference expressions, authored timing