diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 1d01a7d93c..74e9f7f71b 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -58,6 +58,7 @@ vi.mock("../utils/staticProjectServer.js", () => ({ import snapshotCommand, { extractVideoFrameToBuffer, + decodeFramesInOnePass, extractVideoFrameOrWarn, probeContainingSourceFrameSeek, containingSourceFrameIndex, @@ -72,6 +73,7 @@ import snapshotCommand, { resolveSnapshotVideoRateSpec, tailFrameTime, } from "./snapshot.js"; +import type { PlannedFrame } from "./snapshotFrameBank.js"; describe("formatSnapshotTimestamp", () => { it.each([ @@ -657,6 +659,95 @@ describe("extractVideoFrameToBuffer", () => { }, ); + it.skipIf(noProbe)( + "decodes several times of one video in one pass, frame for frame as each decodes alone", + async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-snapshot-pass-")); + try { + const clip = join(dir, "one-keyframe.mp4"); + // A single keyframe at 0, as a long screen recording has: every frame decodes from the start. + execFileSync(ffmpeg!, [ + ...[ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=d=12:r=25:s=320x180", + ], + ...["-c:v", "libx264", "-g", "10000", "-keyint_min", "10000", "-sc_threshold", "0"], + ...["-pix_fmt", "yuv420p", clip], + ]); + const times = [6.2, 8.97, 11.5]; + const frames: PlannedFrame[] = []; + for (const time of times) { + const plan = await probeContainingSourceFrameSeek(clip, time, false); + expect(plan?.decodesFrom, "decodes from the only keyframe").toBe(0); + frames.push({ + time, + displayedPixels: 160, + holdLastFrame: false, + seek: plan!.seek!, + select: plan!.select!, + decodesFrom: plan!.decodesFrom!, + }); + } + const together = await decodeFramesInOnePass(clip, frames); + for (const [i, time] of times.entries()) { + const alone = await extractVideoFrameToBuffer(clip, time, false, false, 160); + expect(samePixels(together[i]!, alone!), `the frame at ${time}s`).toBe(true); + } + expect(new Set(together.map((png) => png.toString("base64"))).size).toBe(times.length); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(noProbe)( + "gives two times inside one source frame the same frame, without decoding on", + async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-snapshot-pass-same-")); + try { + const clip = join(dir, "one-keyframe.mp4"); + execFileSync(ffmpeg!, [ + ...[ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=d=60:r=25:s=320x180", + ], + ...["-c:v", "libx264", "-g", "10000", "-keyint_min", "10000", "-sc_threshold", "0"], + ...["-pix_fmt", "yuv420p", clip], + ]); + const frames: PlannedFrame[] = []; + for (const time of [8.97, 8.98]) { + const plan = await probeContainingSourceFrameSeek(clip, time, false); + frames.push({ + time, + displayedPixels: 160, + holdLastFrame: false, + seek: plan!.seek!, + select: plan!.select!, + decodesFrom: plan!.decodesFrom!, + }); + } + const startedAt = Date.now(); + const [first, second] = await decodeFramesInOnePass(clip, frames); + expect(first!.equals(second!)).toBe(true); + expect(Date.now() - startedAt, "stops at the frame, not the end of the file").toBeLessThan( + 4_000, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + const oneSecondClip = (dir: string) => { const clip = join(dir, "clip.mp4"); execFileSync(ffmpeg!, [ diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 39cbabcdeb..de23fd71d5 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -84,6 +84,7 @@ import { writeCaptureFileSync } from "../capture/captureFile.js"; import { runCancellableProcess } from "../utils/cancellableProcess.js"; import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { parseAngle, type Camera } from "./motionShotLayout.js"; +import { createFrameBank, type FrameWant, type PlannedFrame } from "./snapshotFrameBank.js"; import type { Example } from "./_examples.js"; import { loadInstalledSharp, loadOptionalPackage } from "../utils/optionalPackages.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; @@ -98,6 +99,7 @@ import { type ReviewSheets, } from "./snapshotReview.js"; import { withMeta } from "../utils/updateCheck.js"; +import { trackSnapshotRun } from "../telemetry/events.js"; import { failureMessage, jsonFailure } from "../utils/jsonError.js"; // Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so @@ -266,6 +268,58 @@ export function resolveSnapshotVideoRateSpec(input: { return readElementRateSpec({ getAttribute: (name) => attrs[name] ?? null }); } +export interface SnapshotVideoCandidate { + id: string; + src: string; + authoredStart: number; + authoredRate: string | undefined; + authoredAutomation: string | undefined; + defaultRate: number; + runtimeResolvedStart: number | null; + authoredDuration: number | null; + srcDuration: number; + mediaStart: number; + loop: boolean; + displayedSize: number; + devicePixelRatio: number; +} + +/** The source frame a