Skip to content
Closed
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
38 changes: 35 additions & 3 deletions packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -29,7 +30,7 @@ import snapshotCommand, {
requireSnapshotFfmpeg,
resolveSnapshotVideoClipStart,
resolveSnapshotVideoFrameTime,
resolveSnapshotVideoPlaybackRate,
resolveSnapshotVideoRateSpec,
tailFrameTime,
} from "./snapshot.js";

Expand Down Expand Up @@ -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);
});
});

Expand Down
36 changes: 26 additions & 10 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string | undefined> = {
"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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 &&
Expand Down
Loading