Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions packages/cli/src/capture/captureCompositionFrame.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS } from "./captureTimeout.js";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
Expand Down Expand Up @@ -111,7 +112,10 @@ describe("seekCompositionTimeline", () => {
expect(waitForFunction).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(4);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false, false);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true });
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), {
videoFrames: true,
timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS,
});
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).
Expand All @@ -126,7 +130,10 @@ describe("seekCompositionTimeline", () => {
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false, false);

await settleCompositionSeek(page);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true });
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), {
videoFrames: true,
timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS,
});
expect(evaluate).toHaveBeenCalledTimes(4);
});

Expand Down Expand Up @@ -245,7 +252,10 @@ describe("seekCompositionTimeline", () => {

expect(evaluate).toHaveBeenCalledTimes(3);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true, false, false);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true });
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), {
videoFrames: true,
timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS,
});
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), { ms: 500, frames: 2 });

const frames: (() => void)[] = [];
Expand Down Expand Up @@ -543,8 +553,12 @@ describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => {
return barrier.mock.calls;
};

expect(await barrierCalls(DENSE_GEOMETRY_SEEK_OPTIONS)).toEqual([[{ videoFrames: false }]]);
expect(await barrierCalls(AUDIT_SEEK_OPTIONS)).toEqual([[{ videoFrames: true }]]);
expect(await barrierCalls(DENSE_GEOMETRY_SEEK_OPTIONS)).toEqual([
[{ videoFrames: false, timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS }],
]);
expect(await barrierCalls(AUDIT_SEEK_OPTIONS)).toEqual([
[{ videoFrames: true, timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS }],
]);
});

it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => {
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/capture/captureCompositionFrame.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { oneProcessOverPipe } from "@hyperframes/engine";
import { SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS } from "./captureTimeout.js";
import {
rethrowAfterBrowserCleanup,
launchManagedBrowser,
Expand Down Expand Up @@ -72,7 +73,7 @@ export interface SeekCompositionTimelineOptions {
skipWebglDraws?: boolean;
}

type SeekBarrierOptions = { videoFrames: boolean };
type SeekBarrierOptions = { videoFrames: boolean; timeoutMs: number };
type FontWaitOptions = { ms: number; frames: number };

type CompositionPageFunction =
Expand Down Expand Up @@ -558,7 +559,10 @@ export async function settleCompositionSeek(
await Reflect.apply(waitForCompletion, window, [barrier]);
}
},
{ videoFrames: options.waitForVideoFrames !== false },
{
videoFrames: options.waitForVideoFrames !== false,
timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS,
},
);

const animationFrameSettle = options.animationFrameSettle ?? "race";
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/capture/captureTimeout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS = 180_000;
export const SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS =
PUPPETEER_DEFAULT_PROTOCOL_TIMEOUT_MS - 10_000;

export class StageBudgetTimeoutError extends Error {
readonly budgetMs: number;
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CheckDeadline } from "./checkDeadline.js";
import { SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS } from "../capture/captureTimeout.js";
import { logCheckProgress } from "./checkProgress.js";
import { BrowserCleanupError, closeManagedBrowser, onStopSignal } from "../browser/launch.js";
import { collectMotionBlurDrafts, dropMotionBlurConsoleEcho } from "./checkMotionBlur.js";
Expand Down Expand Up @@ -1221,18 +1222,23 @@ async function overlapGridInPage(
fallback: boolean;
skipDraws: boolean;
videoFrames: boolean;
timeoutMs: number;
},
seek: typeof seekInPage,
anchors: typeof anchorsInPage,
): Promise<OverlapGridSample[]> {
const audit = Reflect.get(window, "__hyperframesOverlapAudit");
const waitForCompletion = Reflect.get(window, "__hfWaitForSeekCompletion");
const samples: OverlapGridSample[] = [];
const deadline = performance.now() + grid.timeoutMs;
for (const time of grid.times) {
console.debug(`${grid.marker}${time}`);
seek(time, grid.fallback, false, grid.skipDraws);
if (typeof waitForCompletion === "function") {
await Reflect.apply(waitForCompletion, window, [{ videoFrames: grid.videoFrames }]);
const timeoutMs = Math.max(0, deadline - performance.now());
await Reflect.apply(waitForCompletion, window, [
{ videoFrames: grid.videoFrames, timeoutMs },
]);
}
const result: unknown =
typeof audit === "function" ? Reflect.apply(audit, window, [{ time }]) : [];
Expand All @@ -1256,6 +1262,7 @@ async function collectOverlapGrid(page: Page, times: number[]): Promise<Anchored
fallback: DENSE_GEOMETRY_SEEK_OPTIONS.fallbackToBridgeAndTimelines,
skipDraws: DENSE_GEOMETRY_SEEK_OPTIONS.skipWebglDraws,
videoFrames: DENSE_GEOMETRY_SEEK_OPTIONS.waitForVideoFrames,
timeoutMs: SEEK_SETTLES_BEFORE_PROTOCOL_TIMEOUT_MS,
};
const samples = (await page.evaluate(
`(${overlapGridInPage})(${JSON.stringify(grid)}, ${seekInPage}, ${anchorsInPage})`,
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/runtime/adapters/seek-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import {
dispatchSeekEvent,
forceDispatchSeekEvent,
registerSeekCompletion,
resetSeekDispatchState,
waitForSeekCompletion,
type HfSeekEventDetail,
Expand Down Expand Up @@ -211,4 +212,53 @@ describe("seek-dispatch", () => {
]);
await expect(waitForSeekCompletion()).resolves.toBeUndefined();
});

// Main had no deadline: a hold that never settles waited out the caller's protocol timeout.
it("a seek with a hold that never settles ends at its deadline, naming what it still waits on", async () => {
registerSeekCompletion(new Promise(() => {}), "frame-source");
registerSeekCompletion(Promise.resolve(), "video-frame");
await expect(waitForSeekCompletion({ timeoutMs: 20 })).rejects.toThrow(
"seek did not settle within 20 ms: still waiting on frame-source",
);
});

it("holds registered while the barrier waits still end at the same deadline", async () => {
let stopped = false;
const again = (): void => {
if (stopped) return;
registerSeekCompletion(
new Promise((resolve) => setTimeout(() => (again(), resolve(undefined)), 1)),
"video-frame",
);
};
again();
const started = Date.now();
try {
await expect(waitForSeekCompletion({ timeoutMs: 50 })).rejects.toThrow(
/still waiting on video-frame/,
);
} finally {
stopped = true;
}
expect(Date.now() - started).toBeLessThan(1000);
});

it("without a caller's timeoutMs a seek waits as long as its holds take", async () => {
vi.useFakeTimers();
try {
let settle = () => {};
registerSeekCompletion(new Promise<void>((resolve) => (settle = resolve)), "vfx");
const barrier = waitForSeekCompletion();
await vi.advanceTimersByTimeAsync(30 * 60_000);
settle();
await expect(barrier).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});

it("a seek whose holds settle in time resolves as before", async () => {
registerSeekCompletion(new Promise((resolve) => setTimeout(resolve, 5)), "scene-window");
await expect(waitForSeekCompletion({ timeoutMs: 1000 })).resolves.toBeUndefined();
});
});
70 changes: 56 additions & 14 deletions packages/core/src/runtime/adapters/seek-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ 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>>();
/** What a seek waits on, named when the barrier stops waiting for it. */
export type SeekHold =
| "video-frame"
| "hf-seek"
| "frame-source"
| "scene-window"
| "motion-blur"
| "vfx"
| "image-decode";
let _pendingCompletions = new Map<Promise<SeekCompletionResult>, SeekHold>();
let _pendingFailure: { reason: unknown } | undefined;
let _activeCompletionBarriers = 0;

Expand Down Expand Up @@ -50,7 +58,7 @@ function dispatch(time: number): void {
accepting = false;
}
if (pending.length === 0) return;
registerSeekCompletion(Promise.all(pending));
registerSeekCompletion(Promise.all(pending), "hf-seek");
}

/**
Expand All @@ -63,12 +71,11 @@ 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>, kind?: "video-frame"): void {
export function registerSeekCompletion(promise: Promise<unknown>, hold: SeekHold): 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);
_pendingCompletions.set(completion, hold);
void completion.then((result) => {
if (!_pendingCompletions.delete(completion)) return;
if (result.status === "rejected" && _pendingFailure === undefined) {
Expand Down Expand Up @@ -105,9 +112,9 @@ export function isSeekCompletionBarrierActive(): boolean {
/** Settles with every pending completion, without consuming a failure the barrier reports. */
export async function seekCompletionsSettled(): Promise<void> {
for (
let pending = [..._pendingCompletions];
let pending = [..._pendingCompletions.keys()];
pending.length > 0;
pending = [..._pendingCompletions]
pending = [..._pendingCompletions.keys()]
) {
await Promise.all(pending);
}
Expand All @@ -117,20 +124,56 @@ export function hasPendingSeekCompletion(): boolean {
return _pendingCompletions.size > 0;
}

/** `videoFrames: false` skips only the video-frame holds: a geometry read needs every other completion. */
export async function waitForSeekCompletion(options?: { videoFrames?: boolean }): Promise<void> {
/** All of `pending` settled, or null once `deadline` (epoch ms) passes first. */
async function settledBy(
pending: Promise<SeekCompletionResult>[],
deadline: number | undefined,
): Promise<SeekCompletionResult[] | null> {
if (deadline === undefined) return Promise.all(pending);
let timer: ReturnType<typeof setTimeout> | undefined;
const late = new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
});
try {
return await Promise.race([Promise.all(pending), late]);
} finally {
clearTimeout(timer);
}
}

/** `videoFrames: false` skips only video-frame holds. Past a caller's `timeoutMs` it throws, naming what is pending. */
export async function waitForSeekCompletion(options?: {
videoFrames?: boolean;
timeoutMs?: number;
}): Promise<void> {
const awaited = () =>
[..._pendingCompletions].filter(
(completion) => options?.videoFrames !== false || !_videoFrameCompletions.has(completion),
([, hold]) => options?.videoFrames !== false || hold !== "video-frame",
);
const timeoutMs = options?.timeoutMs;
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
_activeCompletionBarriers += 1;
let failed = _pendingFailure;
try {
// Let concurrently started barriers snapshot the same retained failure
// before either one consumes it.
await Promise.resolve();
for (let pending = awaited(); pending.length > 0; pending = awaited()) {
const results = await Promise.all(pending);
// Holds that keep arriving past the deadline must not outrun it.
const results =
deadline === undefined || Date.now() < deadline
? await settledBy(
pending.map(([completion]) => completion),
deadline,
)
: null;
if (!results) {
const holds = [...new Set(awaited().map(([, hold]) => hold))].join(", ");
throw Object.assign(
new Error(`seek did not settle within ${timeoutMs} ms: still waiting on ${holds}`),
{ name: "SeekNotSettledError" },
);
}
const rejected = results.find((result) => result.status === "rejected");
if (failed === undefined && rejected?.status === "rejected") {
failed = { reason: rejected.reason };
Expand All @@ -152,8 +195,7 @@ export async function waitForSeekCompletion(options?: { videoFrames?: boolean })
/** Reset internal state — used in tests to prevent cross-test contamination. */
export function resetSeekDispatchState(): void {
_lastDispatchedTime = -1;
_pendingCompletions = new Set();
_videoFrameCompletions = new WeakSet();
_pendingCompletions = new Map();
_pendingFailure = undefined;
_activeCompletionBarriers = 0;
}
4 changes: 2 additions & 2 deletions packages/core/src/runtime/frameSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function createFrameSourceAdapter(timing: {
if (!owned.has(source)) {
owned.add(source);
// Readiness trackers settle rejected setup; capture must also receive that failure.
registerSeekCompletion(source.ready);
registerSeekCompletion(source.ready, "frame-source");
}
connected.push([element, source]);
}
Expand Down Expand Up @@ -140,7 +140,7 @@ export function createFrameSourceAdapter(timing: {
const localTime = Math.max(0, time - start);
const sourceTime =
readMediaStart(element) + sourceTimeAt(readElementRateSpec(element), localTime);
registerSeekCompletion(source.seek(sourceTime));
registerSeekCompletion(source.seek(sourceTime), "frame-source");
}
},
getReadyPromise: () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime/init.seekOrder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ function mountTimingFilm() {
initSandboxRuntimeModular();
step(40);
let land = () => {};
registerSeekCompletion(new Promise<void>((resolve) => (land = resolve)));
registerSeekCompletion(new Promise<void>((resolve) => (land = resolve)), "hf-seek");
posts.length = 0;
const editTiming = async () => {
document.getElementById("clip")!.setAttribute("data-duration", "12");
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4520,7 +4520,11 @@ export function initSandboxRuntimeModular(): void {
({ name, start, end }) => start <= time && time <= end && parked(name),
);
if (windowWork) {
if (landed) registerSeekCompletion(windowWork.then(() => windowWork));
if (landed)
registerSeekCompletion(
windowWork.then(() => windowWork),
"scene-window",
);
return landed !== undefined;
}
const near = scenesWanted(windowScenes, time, clock.isPlaying());
Expand Down Expand Up @@ -4555,7 +4559,7 @@ export function initSandboxRuntimeModular(): void {
moveSceneWindow(state.currentTime);
});
// A seek is not done until the scene it lands in is mounted.
if (landed) registerSeekCompletion(windowWork);
if (landed) registerSeekCompletion(windowWork, "scene-window");
return landed !== undefined;
};
// Mounts the named scenes and keeps them mounted until the next call names others.
Expand Down Expand Up @@ -4650,7 +4654,7 @@ export function initSandboxRuntimeModular(): void {
colorGrading.redraw();
paintVfx(state.currentTime);
const blurred = paintCaptureMotionBlur(state.currentTime);
if (blurred) registerSeekCompletion(blurred);
if (blurred) registerSeekCompletion(blurred, "motion-blur");
postState(true);
};

Expand Down Expand Up @@ -4739,7 +4743,7 @@ export function initSandboxRuntimeModular(): void {
window.clearTimeout(capTimer);
if (heldSeek === held) flushHeldSeek();
});
registerSeekCompletion(landed);
registerSeekCompletion(landed, "image-decode");
return landed;
},
renderSeek: (timeSeconds, options) => {
Expand All @@ -4765,7 +4769,7 @@ export function initSandboxRuntimeModular(): void {
colorGrading.redraw();
paintVfx(state.currentTime, { engineMode: true });
const blurred = paintCaptureMotionBlur(state.currentTime, { engineMode: true });
if (blurred) registerSeekCompletion(blurred);
if (blurred) registerSeekCompletion(blurred, "motion-blur");
postState(true);
},
getTime: () => heldSeek?.time ?? clock.now(),
Expand Down
Loading
Loading