diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 42022c4945..7a1f56561c 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; +import { sourceTimeAt } from "@hyperframes/core"; const snapshotState = vi.hoisted(() => ({ openSettledPage: vi.fn(async () => { @@ -29,7 +30,7 @@ import snapshotCommand, { requireSnapshotFfmpeg, resolveSnapshotVideoClipStart, resolveSnapshotVideoFrameTime, - resolveSnapshotVideoPlaybackRate, + resolveSnapshotVideoRateSpec, tailFrameTime, } from "./snapshot.js"; @@ -274,9 +275,40 @@ describe("resolveSnapshotVideoClipStart", () => { }); }); -describe("resolveSnapshotVideoPlaybackRate", () => { +describe("resolveSnapshotVideoRateSpec", () => { it("prefers the authored data-playback-rate over the browser default", () => { - expect(resolveSnapshotVideoPlaybackRate({ authoredRate: "1.8", defaultRate: 1 })).toBe(1.8); + expect(resolveSnapshotVideoRateSpec({ authoredRate: "1.8", defaultRate: 1 })).toBe(1.8); + }); + + it("falls back to the browser default when the authored rate is invalid", () => { + expect(resolveSnapshotVideoRateSpec({ authoredRate: "abc", defaultRate: 2 })).toBe(2); + expect(resolveSnapshotVideoRateSpec({ authoredRate: "0", defaultRate: 2 })).toBe(2); + }); + + it("allows rates up to the shared 10x bound", () => { + expect(resolveSnapshotVideoRateSpec({ authoredRate: "8", defaultRate: 1 })).toBe(8); + }); + + it("maps a frame through a rate lane instead of the constant", () => { + const lane = JSON.stringify({ + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 3 }, + ], + }, + ], + }); + const spec = resolveSnapshotVideoRateSpec({ + authoredRate: "1", + authoredAutomation: lane, + defaultRate: 1, + }); + expect(typeof spec).toBe("object"); + expect(sourceTimeAt(spec, 2)).toBeCloseTo(3.641, 2); }); }); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 24561425c9..4a8fb8f7be 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -14,6 +14,12 @@ import { seekCompositionTimeline, type ZoomTarget, } from "../capture/captureCompositionFrame.js"; +import { + readElementRateSpec, + sourceTimeAt, + timeAtSourceTime, + type RateSpec, +} from "@hyperframes/core"; import { resolveProject } from "../utils/project.js"; import { definitiveEntryMismatchComposition, @@ -110,16 +116,22 @@ export function resolveSnapshotVideoClipStart(input: { return input.runtimeResolvedStart ?? input.authoredStart; } -/** Match runtime/render timing: authored data-playback-rate wins over the - * browser default, then the effective rate is clamped to the supported range. */ -export function resolveSnapshotVideoPlaybackRate(input: { +/** Match runtime/render timing: a `rate` lane in data-automation wins, then the authored + * data-playback-rate, then the browser default, all through the runtime's own reader. */ +export function resolveSnapshotVideoRateSpec(input: { authoredRate: string | undefined; + authoredAutomation?: string | undefined; defaultRate: number; -}): number { +}): RateSpec { const authoredRate = Number.parseFloat(input.authoredRate ?? ""); - const rawRate = - Number.isFinite(authoredRate) && authoredRate > 0 ? authoredRate : input.defaultRate; - return Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1; + const attrs: Record = { + "data-playback-rate": + Number.isFinite(authoredRate) && authoredRate > 0 + ? input.authoredRate + : String(input.defaultRate), + "data-automation": input.authoredAutomation, + }; + return readElementRateSpec({ getAttribute: (name) => attrs[name] ?? null }); } export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { @@ -454,6 +466,7 @@ async function captureSnapshots( src: v.currentSrc || v.src, authoredStart, authoredRate: v.dataset.playbackRate, + authoredAutomation: v.dataset.automation, defaultRate: v.defaultPlaybackRate, runtimeResolvedStart: runtimeResolvedStart !== undefined && Number.isFinite(runtimeResolvedStart) @@ -469,13 +482,16 @@ async function captureSnapshots( }); const active = candidates.flatMap((candidate) => { const start = resolveSnapshotVideoClipStart(candidate); - const playbackRate = resolveSnapshotVideoPlaybackRate(candidate); + const playbackRate = resolveSnapshotVideoRateSpec(candidate); const duration = candidate.authoredDuration ?? (candidate.srcDuration > 0 - ? Math.max(0, (candidate.srcDuration - candidate.mediaStart) / playbackRate) + ? Math.max( + 0, + timeAtSourceTime(playbackRate, candidate.srcDuration - candidate.mediaStart), + ) : Number.POSITIVE_INFINITY); - let relTime = (time - start) * playbackRate + candidate.mediaStart; + let relTime = sourceTimeAt(playbackRate, time - start) + candidate.mediaStart; if ( candidate.loop && candidate.srcDuration > candidate.mediaStart &&