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