mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user