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
17 changes: 14 additions & 3 deletions packages/cli/src/capture/captureCompositionFrame.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/capture/captureCompositionFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -294,6 +296,7 @@ export async function seekCompositionTimeline(
},
timeSeconds,
options.fallbackToBridgeAndTimelines === true,
options.exactTime === true,
);

await page.evaluate(async () => {
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3897,15 +3897,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;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
19 changes: 13 additions & 6 deletions packages/core/src/runtime/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
20 changes: 19 additions & 1 deletion packages/engine/src/services/frameCapture-motionBlur.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
},
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/parsers/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down
Loading