fix(cli): capture snapshot --at at the exact requested instant (#4827)

snapshot --at captures the frame at the exact requested instant instead of the nearest seek step.
This commit is contained in:
HEOJUNFO
2026-10-02 05:48:06 -07:00
committed by GitHub
parent efdabb758d
commit 8aefd91bf8
11 changed files with 150 additions and 22 deletions
@@ -56,7 +56,7 @@ describe("seekCompositionTimeline", () => {
expect(waitForFunction).not.toHaveBeenCalled();
expect(evaluate).toHaveBeenCalledTimes(4);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
expect(evaluate.mock.calls[2]?.[0]).toContain("window.setTimeout(finish, 100)");
// Post-seek font settle: a seek can reveal glyphs whose unicode-range
@@ -93,6 +93,17 @@ describe("seekCompositionTimeline", () => {
expect(timelineSeek).not.toHaveBeenCalled();
});
it("asks renderSeek for the exact instant when exactTime is set", async () => {
const { page, evaluate } = fakeSeekPage();
const renderSeek = vi.fn();
vi.stubGlobal("window", { __player: { renderSeek } });
await seekCompositionTimeline(page, 19.019018, { exactTime: true });
runBrowserSeek(evaluate);
expect(renderSeek).toHaveBeenCalledWith(19.019018, { exact: true });
});
function fakeBridgeOnlySeekPage() {
const { page, evaluate } = fakeSeekPage();
const bridgeSeek = vi.fn();
@@ -150,7 +161,7 @@ describe("seekCompositionTimeline", () => {
expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 });
expect(evaluate).toHaveBeenCalledTimes(2);
expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true);
expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true, false);
});
it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => {
@@ -167,7 +178,7 @@ describe("seekCompositionTimeline", () => {
await pending;
expect(evaluate).toHaveBeenCalledTimes(4);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true);
expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true, false);
expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function));
expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function));
expect(evaluate).toHaveBeenNthCalledWith(4, expect.any(Function), 500);
@@ -37,6 +37,7 @@ export const DENSE_GEOMETRY_SEEK_OPTIONS = {
export interface SeekCompositionTimelineOptions {
fallbackToBridgeAndTimelines?: boolean;
exactTime?: boolean;
waitForPreferredSeekTargetMs?: number;
animationFrameSettle?: "race" | "double" | "none";
waitForFontsMs?: number;
@@ -47,13 +48,14 @@ type CompositionPageFunction =
| string
| (() => unknown)
| ((value: number) => unknown)
| ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown);
| ((value: number, fallbackToBridgeAndTimelines: boolean, exactTime: boolean) => unknown);
export interface CompositionEvaluationPage {
evaluate(
pageFunction: CompositionPageFunction,
value?: number,
fallbackToBridgeAndTimelines?: boolean,
exactTime?: boolean,
): Promise<unknown>;
}
@@ -247,7 +249,7 @@ export async function seekCompositionTimeline(
await page.evaluate(
// Serialized into the page; the seek-target cascade must stay one function.
// fallow-ignore-next-line complexity
(t: number, fallbackToBridgeAndTimelines: boolean) => {
(t: number, fallbackToBridgeAndTimelines: boolean, exactTime: boolean) => {
const getProperty = (target: unknown, key: string): unknown => {
if ((typeof target !== "object" || target === null) && typeof target !== "function") {
return undefined;
@@ -271,7 +273,7 @@ export async function seekCompositionTimeline(
// Prefer renderSeek because it also runs the runtime's data-start/data-duration
// visibility sync; raw timeline seeks leave off-window clips visible to audits.
if (call(renderSeek, player, [safe])) {
if (call(renderSeek, player, exactTime ? [safe, { exact: true }] : [safe])) {
// Preferred runtime target handled the seek.
} else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) {
// Producer bridge handled the seek.
@@ -294,6 +296,7 @@ export async function seekCompositionTimeline(
},
timeSeconds,
options.fallbackToBridgeAndTimelines === true,
options.exactTime === true,
);
await page.evaluate(async () => {
+43 -1
View File
@@ -8,15 +8,19 @@ import { findFFmpeg } from "../browser/ffmpeg.js";
import { sourceTimeAt } from "@hyperframes/core";
const snapshotState = vi.hoisted(() => ({
openSettledPage: vi.fn(async () => {
openSettledPage: vi.fn(async (): Promise<unknown> => {
throw new Error("browser capture reached");
}),
seek: vi.fn(async (_page: unknown, _time: number, _options?: unknown): Promise<void> => {
throw new Error("seek reached");
}),
closeServer: vi.fn(async () => undefined),
}));
vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../capture/captureCompositionFrame.js")>()),
openSettledCompositionPage: snapshotState.openSettledPage,
seekCompositionTimeline: snapshotState.seek,
}));
vi.mock("../utils/staticProjectServer.js", () => ({
@@ -150,6 +154,44 @@ describe("snapshot lint preflight", () => {
});
});
describe("snapshot --at seeks", () => {
it("asks the runtime for the exact requested instant, not its 30fps grid", async () => {
const project = mkdtempSync(join(tmpdir(), "hf-snapshot-exact-at-"));
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="20" data-fps="29.97"><div class="clip" data-start="0" data-duration="20">Visible</div></div></body></html>`,
);
const evaluate = vi
.fn()
.mockResolvedValueOnce({ loaded: [], errored: [], unused: [] })
.mockResolvedValueOnce(20)
.mockResolvedValueOnce(true);
const close = vi.fn(async () => undefined);
snapshotState.openSettledPage.mockResolvedValueOnce({
browser: { close },
page: { evaluate },
renderReadyTimedOut: false,
});
snapshotState.seek.mockClear();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
await expect(
snapshotCommand.run?.({
args: { dir: project, at: "19.019018", end: false, output: join(project, "out") },
} as never),
).rejects.toBeDefined();
expect(snapshotState.seek).toHaveBeenCalledWith(expect.anything(), 19.019018, {
exactTime: true,
});
expect(close).toHaveBeenCalled();
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
});
});
describe("resolveSnapshotVideoFrameTime", () => {
it("holds a clip ending with the composition on its last decodable frame", () => {
expect(
+3 -2
View File
@@ -453,7 +453,7 @@ async function captureSnapshots(
for (let i = 0; i < positions.length; i++) {
const time = positions[i]!;
await seekCompositionTimeline(page, time);
await seekCompositionTimeline(page, time, { exactTime: true });
if (cameraExpr) await page.evaluate(cameraExpr);
@@ -684,7 +684,8 @@ export default defineCommand({
},
at: {
type: "string",
description: "Comma-separated timestamps in seconds (e.g., --at 3.0,10.5,18.0)",
description:
"Comma-separated timestamps in seconds (e.g., --at 3.0,10.5,18.0). Each is captured at that exact instant, not snapped to a frame.",
},
timeout: {
type: "string",
+36
View File
@@ -1023,6 +1023,42 @@ describe("initSandboxRuntimeModular", () => {
expect(timeline.time()).toBe(0);
});
describe("issue #4430 sweep on a 29.97fps project", () => {
// `snapshot --at` times from the issue, with where the 30fps grid floors each one.
const sweep = [
{ at: 19.019018, grid: 19 },
{ at: 19.05, grid: 571 / 30 },
];
function seekOnNtscProject(at: number, options?: { exact?: boolean }): number {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "20");
root.setAttribute("data-fps", "29.97");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const timeline = createMockTimeline(20);
window.__timelines = { main: timeline };
initSandboxRuntimeModular();
window.__player?.renderSeek(at, options);
return timeline.time();
}
it.each(sweep)("an exact renderSeek to $at lands on $at", ({ at }) => {
expect(seekOnNtscProject(at, { exact: true })).toBe(at);
});
// Frame export never passes `exact`, so its seeks keep flooring onto the frame grid.
it.each(sweep)("a default renderSeek to $at still floors to $grid", ({ at, grid }) => {
expect(seekOnNtscProject(at)).toBeCloseTo(grid, 9);
});
});
it("uses live child timeline duration when a composition host has no authored duration", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+5 -5
View File
@@ -44,7 +44,7 @@ import {
import { handleErrorForProxy, handleMetadataForProxy, maybeProxyProactively } from "./mediaProxy";
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
import { createPickerModule } from "./picker";
import { createRuntimePlayer, type RuntimePlayerTransport } from "./player";
import { createRuntimePlayer, resolveRenderSeekTime, type RuntimePlayerTransport } from "./player";
import { createRuntimeState } from "./state";
import {
collectRuntimeTimelinePayload,
@@ -97,7 +97,7 @@ import {
isMemberGroupHidden,
} from "../audioGroups";
import { clampNativeMediaVolume } from "../audioGain";
import { quantizeSeekTime, quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import { createManualEditGestureWatch } from "./manualEditGestureWatch";
import type {
HeldSeek,
@@ -3905,15 +3905,15 @@ export function initSandboxRuntimeModular(): void {
renderSeek: (timeSeconds, options) => {
heldSeek = null;
renderCaptureSeekStarted = true;
const quantized = quantizeSeekTime(
const seekTime = resolveRenderSeekTime(
Math.max(0, Number(timeSeconds) || 0),
state.canonicalFps,
options?.subFrameDivisions,
options,
);
webAudio.stopAll();
clock.detachAudioSource();
if (clock.isPlaying()) clock.pause();
clock.seek(quantized);
clock.seek(seekTime);
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
+9
View File
@@ -709,6 +709,15 @@ describe("renderSeek sub-frame grid (issue #4010 motion blur)", () => {
expect(timeline.time()).toBe(10 / 30);
});
it("lands on the requested instant, off every frame grid, for an exact seek", () => {
const timeline = createMockTimeline();
const player = createRuntimePlayer(createMockDeps(timeline));
player.renderSeek(10 / 30 + 0.017, { exact: true });
expect(timeline.time()).toBe(10 / 30 + 0.017);
});
it("suppresses timeline events on a sub-frame sample seek", () => {
const timeline = createMockTimeline();
const player = createRuntimePlayer(createMockDeps(timeline));
+13 -6
View File
@@ -83,13 +83,24 @@ function forEachSiblingTimeline(
}
}
export function resolveRenderSeekTime(
timeSeconds: number,
canonicalFps: number,
options?: RuntimeSeekOptions,
): number {
if (options?.exact === true) {
return Number.isFinite(timeSeconds) && timeSeconds > 0 ? timeSeconds : 0;
}
return quantizeSeekTime(timeSeconds, canonicalFps, options?.subFrameDivisions);
}
function seekTimelineDeterministically(
timeline: RuntimeTimelineLike,
timeSeconds: number,
canonicalFps: number,
options?: RuntimeSeekOptions,
): number {
const quantized = quantizeSeekTime(timeSeconds, canonicalFps, options?.subFrameDivisions);
const quantized = resolveRenderSeekTime(timeSeconds, canonicalFps, options);
const suppressEvents = options?.suppressEvents === true;
safeVoid(timeline, "pause");
if (typeof timeline.totalTime === "function") {
@@ -248,11 +259,7 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline);
return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps, options);
})()
: quantizeSeekTime(
Math.max(0, Number(timeSeconds) || 0),
canonicalFps,
options?.subFrameDivisions,
);
: resolveRenderSeekTime(Math.max(0, Number(timeSeconds) || 0), canonicalFps, options);
deps.onDeterministicSeek(quantized, options);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
+1
View File
@@ -286,6 +286,7 @@ export type RuntimeSeekOptions = {
* engine's sub-frame tick count so a fractional sample time survives quantization.
*/
subFrameDivisions?: number;
exact?: boolean;
};
export type RuntimeTimelineChildLike = {
@@ -28,13 +28,14 @@ interface RecordedSeek {
time: number;
suppressEvents?: boolean;
subFrameDivisions?: number;
exact?: boolean;
}
function installPageGlobals(seeks: RecordedSeek[]): void {
const root = globalThis as Record<string, unknown>;
root.window = {
__hf: {
seek: (time: number, options?: { suppressEvents?: boolean; subFrameDivisions?: number }) => {
seek: (time: number, options?: Omit<RecordedSeek, "time">) => {
seeks.push({ time, ...options });
},
},
@@ -175,6 +176,23 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
});
});
describe("frame export keeps its frame grid (issue #4430)", () => {
// `exact` is the snapshot-only opt-out of the runtime's seek quantization. Export must
// never send it, or frames would land between grid points instead of on them.
it("never asks for an exact seek with motion blur off", async () => {
await captureFrameToBuffer(makeSession({ motionBlur: undefined }), 10, 10 / 30);
expect(seeks).toEqual([{ time: 10 / 30 }]);
});
it("never asks for an exact seek on any motion-blur sample", async () => {
await captureFrameToBuffer(makeSession(), 10, 10 / 30);
expect(seeks.length).toBeGreaterThan(1);
expect(seeks.every((s) => !("exact" in s))).toBe(true);
});
});
describe("accumulation composes with static-frame dedup", () => {
// 720 degrees is the widest shutter After Effects offers, and the AD5 reference export
// uses it, so it is the case a reader is most likely to check the frame ranges against.
+1 -1
View File
@@ -439,7 +439,7 @@ export interface PlayerAPI {
disableRenderMode(): void;
renderSeek(
time: number,
options?: { suppressEvents?: boolean; subFrameDivisions?: number },
options?: { suppressEvents?: boolean; subFrameDivisions?: number; exact?: boolean },
): void;
getElementVisibility(elementId: string): { visible: boolean; opacity?: number };
getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>;