diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 375dfa0394..7fd4db398c 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -3,6 +3,8 @@ 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 { execFileSync } from "node:child_process"; +import { findFFmpeg } from "../browser/ffmpeg.js"; import { sourceTimeAt } from "@hyperframes/core"; const snapshotState = vi.hoisted(() => ({ @@ -25,6 +27,7 @@ vi.mock("../utils/staticProjectServer.js", () => ({ })); import snapshotCommand, { + extractVideoFrameToBuffer, computeSnapshotTimes, formatSnapshotTimestamp, parseZoomScale, @@ -180,6 +183,26 @@ describe("resolveSnapshotVideoFrameTime", () => { }, ); + it.each([ + [5, 0, 5], + [7, 0, 7], + [3, 0, 8], + ])( + "holds a video whose source ends before its slot on its last frame at %s, as the preview does", + (globalTime, clipStart, relativeTime) => { + expect( + resolveSnapshotVideoFrameTime({ + globalTime, + clipStart, + clipDuration: 10, + relativeTime, + sourceDuration: 5, + compositionDuration: 20, + }), + ).toBeCloseTo(5 - 1 / 30, 6); + }, + ); + it("keeps ordinary in-window media timestamps unchanged", () => { expect( resolveSnapshotVideoFrameTime({ @@ -281,6 +304,31 @@ describe("resolveSnapshotVideoFrameTime", () => { }); }); +describe("extractVideoFrameToBuffer", () => { + const ffmpeg = findFFmpeg(); + + it.skipIf(!ffmpeg)( + "gives a 24 fps clip's real last frame for a held tail that lands past it", + async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-snapshot-tail-")); + try { + const clip = join(dir, "clip.mp4"); + const source = ["-f", "lavfi", "-i", "testsrc=d=1:r=24:s=160x90", "-pix_fmt", "yuv420p"]; + execFileSync(ffmpeg!, ["-hide_banner", "-loglevel", "error", ...source, clip]); + + const held = await extractVideoFrameToBuffer(clip, 1 - 1 / 30, false, false, true); + const lastFrame = await extractVideoFrameToBuffer(clip, 23 / 24, false, true); + + expect(await extractVideoFrameToBuffer(clip, 1 - 1 / 30)).toBeNull(); + expect(lastFrame).not.toBeNull(); + expect(held?.equals(lastFrame!)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +}); + describe("resolveSnapshotVideoClipStart", () => { it("offsets a scene-local video start by its later template host", () => { expect( diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 9a24c3be0d..b091b3c6c1 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -82,8 +82,8 @@ export function formatSnapshotTimestamp(time: number): string { return `${Number(time.toFixed(3))}s`; } -/** Shows media by the runtime's visibility rule. A clip held at the composition end samples one - * nominal 30fps frame inside its source, since FFmpeg cannot decode at a source's exclusive duration. */ +/** Shows media by the runtime's visibility rule. A clip held at the composition end, or past the end of a source + * shorter than its slot, samples one nominal 30fps frame inside the source; FFmpeg has no frame at or past its end. */ export function resolveSnapshotVideoFrameTime(input: { globalTime: number; clipStart: number; @@ -98,7 +98,8 @@ export function resolveSnapshotVideoFrameTime(input: { const relativeTime = globalTime < clipStart ? Math.max(0, input.relativeTime) : input.relativeTime; if (relativeTime < 0) return null; - if (isInClipWindow(globalTime, clipStart, clipEnd)) return relativeTime; + const pastSource = sourceDuration > 0 && relativeTime >= sourceDuration; + if (isInClipWindow(globalTime, clipStart, clipEnd) && !pastSource) return relativeTime; const sourceEnd = sourceDuration > 0 ? sourceDuration : relativeTime; return Math.max(0, Math.min(relativeTime, sourceEnd - 1 / 30)); @@ -143,11 +144,12 @@ export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { * Used to work around Chrome-headless's inability to reliably seek *