fix(cli): snapshot decodes each video once for all the times it shows (#6667)

* fix(cli): snapshot decodes each video once for all the times it shows

* fix(cli): shorten a comment line
This commit is contained in:
Miguel Ángel
2026-10-28 13:48:44 +00:00
committed by GitHub
parent a1733f4e40
commit cd3b90d1ed
5 changed files with 649 additions and 77 deletions
@@ -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!, [
+258 -77
View File
@@ -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 <video> shows at `time`, or null when the clip is not on screen then. */
export function activeVideoAt(
candidate: SnapshotVideoCandidate,
time: number,
compositionDuration: number,
) {
const start = resolveSnapshotVideoClipStart(candidate);
const playbackRate = resolveSnapshotVideoRateSpec(candidate);
const duration =
candidate.authoredDuration ??
(candidate.srcDuration > 0
? Math.max(0, timeAtSourceTime(playbackRate, candidate.srcDuration - candidate.mediaStart))
: Number.POSITIVE_INFINITY);
let relTime = sourceTimeAt(playbackRate, time - start) + candidate.mediaStart;
if (
candidate.loop &&
candidate.srcDuration > candidate.mediaStart &&
relTime >= candidate.srcDuration
) {
relTime =
candidate.mediaStart +
((relTime - candidate.mediaStart) % (candidate.srcDuration - candidate.mediaStart));
}
const frameTime = resolveSnapshotVideoFrameTime({
globalTime: time,
clipStart: start,
clipDuration: duration,
relativeTime: relTime,
sourceDuration: candidate.srcDuration,
compositionDuration,
});
return frameTime === null
? null
: { ...candidate, start, playbackRate, duration, relTime: frameTime };
}
export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
if (ffmpegPath) return ffmpegPath;
throw new Error(
@@ -334,7 +388,7 @@ export async function probeContainingSourceFrameSeek(
videoPath: string,
time: number,
holdLastFrame: boolean,
): Promise<{ seek?: number; select?: number } | null> {
): Promise<{ seek?: number; select?: number; decodesFrom?: number } | null> {
const ffprobe = findFFprobe();
// Without ffprobe, fall back to a plain seek: the first frame at or after `time`.
if (!ffprobe) return { seek: time };
@@ -427,16 +481,18 @@ export async function probeContainingSourceFrameSeek(
if (landed <= 0 || landed > frame) continue;
// An index that places this keyframe after its own time lands on the one before; an accurate seek
// to the keyframe's time decodes from there and drops everything before it.
if (landed < candidate.pts - 1e-6) return { seek: candidate.pts, select: mid - candidate.pts };
if (landed < candidate.pts - 1e-6)
return { seek: candidate.pts, select: mid - candidate.pts, decodesFrom: candidate.pts };
const again = Math.abs(landed - candidate.pts) < 1e-6 ? landed : await landing(landed);
if (again !== null && Math.abs(again - landed) < 1e-6)
return { seek: landed, select: mid - landed };
return { seek: landed, select: mid - landed, decodesFrom: landed };
}
// No usable keyframe landing: an accurate seek to the gap before the frame decodes up to it and drops the rest.
// An exact frame time keeps start_t at 0; a time between frames would shift the select by the gap to the next frame.
const from = timestamps.filter((t) => t > 0 && t <= mid - FALLBACK_SEEK_LEAD_SECONDS).at(-1) ?? 0;
const keyframeBefore = packets.filter((packet) => packet.key && packet.pts <= from).at(-1);
return from > 0
? { seek: from, select: mid - from }
? { seek: from, select: mid - from, decodesFrom: keyframeBefore?.pts ?? 0 }
: { select: mid - (timestamps.find((t) => t >= 0) ?? frame) };
}
@@ -484,6 +540,53 @@ function warnNoHdrFiltersOnce(): void {
);
}
type VideoMetadata = Awaited<ReturnType<typeof extractMediaMetadata>>;
/** The source's colour metadata and, for HDR footage, the SDR export's tone map, so a still matches the render. */
async function videoColour(ffmpegPath: string, videoPath: string) {
const metadata: VideoMetadata | null = findFFprobe()
? await extractMediaMetadata(videoPath)
: null;
if (!metadata) warnColourUnknownOnce();
const toneMap =
metadata && isHdrColorSpace(metadata.colorSpace)
? hdrToSdrToneMapFilter(
metadata.colorSpace ?? {},
parseFirstFrameColour(
(
await runFfmpegOnce(
ffmpegPath,
firstFrameColourArgs(videoPath),
FFMPEG_EXTRACT_TIMEOUT_MS,
)
).stderr,
),
)
: null;
return { metadata, toneMap };
}
function frameFilters(
select: number | undefined,
displayedPixels: number,
metadata: VideoMetadata | null,
toneMapFilter: string | null,
): string[] {
const filters: string[] = [];
if (select !== undefined) filters.push(`select=gte(t-start_t\\,${select})`);
if (metadata) {
if (toneMapFilter) filters.push(toneMapFilter);
filters.push(...sdrCanvasColourFilters(metadata, toneMapFilter !== null));
}
// Shrink so the short side still covers the displayed box at any rotation or fit; never enlarge.
if (displayedPixels > 0) {
filters.push(
`scale='min(iw,${displayedPixels})':'min(ih,${displayedPixels})':force_original_aspect_ratio=increase`,
);
}
return filters;
}
export async function extractVideoFrameToBuffer(
videoPath: string,
timeSeconds: number,
@@ -501,41 +604,13 @@ export async function extractVideoFrameToBuffer(
holdLastFrame,
);
if (seek === null) return null;
const metadata = findFFprobe() ? await extractMediaMetadata(videoPath) : null;
if (!metadata) warnColourUnknownOnce();
// HDR footage takes the tone map the SDR export uses, so a still matches the render.
const toneMap =
metadata && isHdrColorSpace(metadata.colorSpace)
? hdrToSdrToneMapFilter(
metadata.colorSpace ?? {},
parseFirstFrameColour(
(
await runFfmpegOnce(
ffmpegPath,
firstFrameColourArgs(videoPath),
FFMPEG_EXTRACT_TIMEOUT_MS,
)
).stderr,
),
)
: null;
const { metadata, toneMap } = await videoColour(ffmpegPath, videoPath);
const frameArgs = (toneMapFilter: string | null): string[] => {
const args = ["-hide_banner", "-loglevel", "error"];
if (useVp9AlphaDecoder) args.push("-c:v", "libvpx-vp9");
if (seek.seek !== undefined) args.push("-ss", String(seek.seek));
args.push("-i", videoPath, "-map", "0:v:0");
const filters: string[] = [];
if (seek.select !== undefined) filters.push(`select=gte(t-start_t\\,${seek.select})`);
if (metadata) {
if (toneMapFilter) filters.push(toneMapFilter);
filters.push(...sdrCanvasColourFilters(metadata, toneMapFilter !== null));
}
// Shrink so the short side still covers the displayed box at any rotation or fit; never enlarge.
if (displayedPixels > 0) {
filters.push(
`scale='min(iw,${displayedPixels})':'min(ih,${displayedPixels})':force_original_aspect_ratio=increase`,
);
}
const filters = frameFilters(seek.select, displayedPixels, metadata, toneMapFilter);
if (filters.length > 0) args.push("-vf", filters.join(","));
args.push("-frames:v", "1", "-q:v", "2", "-y", outPath);
return args;
@@ -566,13 +641,81 @@ export async function extractVideoFrameToBuffer(
}
}
/** The `select` that keeps the first frame at or after each of `offsets`: one stream, one frame per time. */
export function selectFirstFrameAfterEach(offsets: number[]): string {
const sinceLast = "if(isnan(prev_selected_t)\\,-1\\,prev_selected_t-start_t)";
const open = offsets.map((offset) => `gte(t-start_t\\,${offset})*gt(${offset}\\,${sinceLast})`);
return `select=gt(${open.join("+")}\\,0)`;
}
/** One ffmpeg pass that writes every planned frame of a video: the source decodes from the earliest keyframe once.
* The frames share one displayed size. Throws unless each time got a frame of its own. */
export async function decodeFramesInOnePass(
videoPath: string,
frames: PlannedFrame[],
useVp9AlphaDecoder = false,
): Promise<Buffer[]> {
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frames-"));
try {
const ffmpegPath = requireSnapshotFfmpeg(findFFmpeg());
const { metadata, toneMap } = await videoColour(ffmpegPath, videoPath);
const start = Math.min(...frames.map((frame) => frame.seek));
// Times inside one source frame share an offset, and so a frame.
const byOffset = new Map<number, number[]>();
for (const [index, frame] of frames.entries()) {
const offset = Number((frame.seek + frame.select - start).toFixed(6));
byOffset.set(offset, [...(byOffset.get(offset) ?? []), index]);
}
const byTime = [...byOffset].sort(([a], [b]) => a - b);
const passArgs = (toneMapFilter: string | null): string[] => {
const rest = frameFilters(undefined, frames[0]!.displayedPixels, metadata, toneMapFilter);
const filters = [selectFirstFrameAfterEach(byTime.map(([offset]) => offset)), ...rest];
const args = ["-hide_banner", "-loglevel", "error"];
if (useVp9AlphaDecoder) args.push("-c:v", "libvpx-vp9");
args.push("-ss", String(start), "-i", videoPath, "-map", "0:v:0", "-vf", filters.join(","));
args.push("-fps_mode", "passthrough", "-frames:v", String(byTime.length));
args.push("-q:v", "2", "-y", join(tmp, "%d.png"));
return args;
};
// Each frame keeps the time limit it had when it decoded alone.
const timeoutMs = FFMPEG_EXTRACT_TIMEOUT_MS * frames.length;
let result = await runFfmpegOnce(ffmpegPath, passArgs(toneMap), timeoutMs);
if (toneMap && result.code !== 0 && /no such filter/i.test(result.stderr)) {
warnNoHdrFiltersOnce();
result = await runFfmpegOnce(ffmpegPath, passArgs(null), timeoutMs);
}
const what = `${frames.length} frames of ${videoPath}`;
if (result.timedOut) {
throw new FrameExtractError(`FFmpeg timed out after ${timeoutMs / 1000}s extracting ${what}`);
}
const written = (n: number) => join(tmp, `${n}.png`);
if (result.code !== 0 || !byTime.every((_, i) => existsSync(written(i + 1)))) {
throw new FrameExtractError(
`FFmpeg could not extract ${what}: ${lastLine(result.stderr) || `exit code ${result.code}`}`,
);
}
const buffers: Buffer[] = [];
for (const [i, [, indexes]] of byTime.entries()) {
const png = readFileSync(written(i + 1));
for (const index of indexes) buffers[index] = png;
}
return buffers;
} finally {
try {
rmSync(tmp, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
}
/** A clip whose frame fails becomes a finding naming it and why, so the other frames of the snapshot still land. */
export async function extractVideoFrameOrWarn(
export async function frameOrWarn(
warnings: string[],
...args: Parameters<typeof extractVideoFrameToBuffer>
frame: () => Promise<Buffer | null>,
): Promise<Buffer | null> {
try {
return await extractVideoFrameToBuffer(...args);
return await frame();
} catch (error) {
if (!(error instanceof FrameExtractError)) throw error;
warnings.push(error.message);
@@ -581,6 +724,13 @@ export async function extractVideoFrameOrWarn(
}
}
export function extractVideoFrameOrWarn(
warnings: string[],
...args: Parameters<typeof extractVideoFrameToBuffer>
): Promise<Buffer | null> {
return frameOrWarn(warnings, () => extractVideoFrameToBuffer(...args));
}
export const examples: Example[] = [
["Capture 5 key frames from a composition", "snapshot capture"],
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
@@ -710,6 +860,7 @@ async function captureSnapshots(
log?: (message: string) => void;
},
): Promise<CapturedSnapshots> {
const startedAt = performance.now();
const log = opts.log ?? ((message: string) => console.log(message));
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");
@@ -930,14 +1081,30 @@ async function captureSnapshots(
console.warn(` ${c.warn("⚠")} No player API — seeks will be skipped`);
}
const frameBank = createFrameBank({
probe: probeContainingSourceFrameSeek,
decode: async (path, frames) =>
decodeFramesInOnePass(
path,
frames,
/^https?:\/\//i.test(path) ? false : await shouldUseVp9AlphaDecoder(path),
),
});
const plannedClips = new Set<string>();
const loopStartedAt = performance.now();
let seekMs = 0;
let videoMs = 0;
for (let i = 0; i < positions.length; i++) {
const time = positions[i]!;
console.error(`[hyperframes] snapshot: frame ${i + 1}/${positions.length} at ${time}s`);
const seekOptions = { exactTime: true, waitForImagesMs: SCREENSHOT_IMAGE_WAIT_MS };
const seekStartedAt = performance.now();
await atStep(`seeking to ${time}s, frame ${i + 1} of ${positions.length}`, () =>
seekCompositionTimelineOnly(page, time, seekOptions),
);
seekMs += performance.now() - seekStartedAt;
if (cameraExpr) await page.evaluate(cameraExpr);
@@ -976,45 +1143,18 @@ async function captureSnapshots(
};
});
});
const compositionDuration = duration;
let unreadable = 0;
const active = candidates.flatMap((candidate) => {
const start = resolveSnapshotVideoClipStart(candidate);
const playbackRate = resolveSnapshotVideoRateSpec(candidate);
const duration =
candidate.authoredDuration ??
(candidate.srcDuration > 0
? Math.max(
0,
timeAtSourceTime(playbackRate, candidate.srcDuration - candidate.mediaStart),
)
: Number.POSITIVE_INFINITY);
let relTime = sourceTimeAt(playbackRate, time - start) + candidate.mediaStart;
if (
candidate.loop &&
candidate.srcDuration > candidate.mediaStart &&
relTime >= candidate.srcDuration
) {
relTime =
candidate.mediaStart +
((relTime - candidate.mediaStart) % (candidate.srcDuration - candidate.mediaStart));
}
const frameTime = resolveSnapshotVideoFrameTime({
globalTime: time,
clipStart: start,
clipDuration: duration,
relativeTime: relTime,
sourceDuration: candidate.srcDuration,
compositionDuration,
});
if (frameTime === null) return [];
const video = activeVideoAt(candidate, time, duration);
if (!video) return [];
if (!candidate.id || !candidate.src) {
unreadable += 1;
return [];
}
return [{ ...candidate, start, playbackRate, duration, relTime: frameTime }];
return [video];
});
const videoStartedAt = performance.now();
const extracted = await runWithConcurrencyLimit(
active,
VIDEO_FRAME_EXTRACT_CONCURRENCY,
@@ -1043,16 +1183,39 @@ async function captureSnapshots(
const useVp9AlphaDecoder = inputIsLocal
? await shouldUseVp9AlphaDecoder(ffmpegInput)
: false;
const png = await extractVideoFrameOrWarn(
const displayedPixels = Math.ceil(
v.displayedSize *
(opts.zoom ? (opts.zoomScale ?? DEFAULT_ZOOM_SCALE) : v.devicePixelRatio),
);
const wantAt = (t: number): FrameWant | null => {
const shown = activeVideoAt(v, t, duration);
if (!shown) return null;
const holdLastFrame =
shown.srcDuration > 0 && shown.relTime >= shown.srcDuration - 1;
return { time: Math.max(0, shown.relTime), displayedPixels, holdLastFrame };
};
// Every time this clip will be asked for is planned at its first request, so one pass decodes them.
const planKey = `${v.id}\0${ffmpegInput}`;
if (!plannedClips.has(planKey)) {
plannedClips.add(planKey);
await frameBank.plan(
ffmpegInput,
positions.slice(i).flatMap((t) => wantAt(t) ?? []),
);
}
const want = wantAt(time)!;
const banked = await frameBank.take(ffmpegInput, want);
const png = await frameOrWarn(
warnings,
ffmpegInput,
Math.max(0, v.relTime),
useVp9AlphaDecoder,
v.srcDuration > 0 && v.relTime >= v.srcDuration - 1,
Math.ceil(
v.displayedSize *
(opts.zoom ? (opts.zoomScale ?? DEFAULT_ZOOM_SCALE) : v.devicePixelRatio),
),
async () =>
banked ??
extractVideoFrameToBuffer(
ffmpegInput,
want.time,
useVp9AlphaDecoder,
want.holdLastFrame,
want.displayedPixels,
),
);
if (!png) return null;
return { videoId: v.id, dataUri: `data:image/png;base64,${png.toString("base64")}` };
@@ -1084,6 +1247,7 @@ async function captureSnapshots(
` ${c.warn("⚠")} video frame injection failed at ${time.toFixed(1)}s — <video> elements will appear black/stale in this snapshot`,
);
}
videoMs += performance.now() - videoStartedAt;
}
warnUnsettledImages(await settleCompositionSeek(page, seekOptions), time, warnings);
@@ -1187,6 +1351,23 @@ async function captureSnapshots(
const rel = relative(projectDir, framePath);
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
}
const loopMs = performance.now() - loopStartedAt;
const stageMs = {
open: loopStartedAt - startedAt,
seek: seekMs,
video: videoMs,
capture: Math.max(0, loopMs - seekMs - videoMs),
};
trackSnapshotRun({
frames: positions.length,
videoFrames: frameBank.stats.frames,
decodePasses: frameBank.stats.passes,
sharedFrames: frameBank.stats.sharedFrames,
longestDecodeSeconds: frameBank.stats.longestDecodeSeconds,
slowStage: (Object.keys(stageMs) as (keyof typeof stageMs)[]).reduce((a, b) =>
stageMs[b] > stageMs[a] ? b : a,
),
});
completed = true;
} finally {
await chromeBrowser.close();
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import {
createFrameBank,
groupOverlappingDecodes,
type FrameWant,
type PlannedFrame,
type SeekPlan,
} from "./snapshotFrameBank.js";
const want = (time: number): FrameWant => ({ time, displayedPixels: 1080, holdLastFrame: false });
const planned = (decodesFrom: number, mid: number): PlannedFrame => ({
...want(mid),
seek: decodesFrom,
select: mid - decodesFrom,
decodesFrom,
});
/** A source whose only keyframe is at 0: every frame decodes from there. */
const singleKeyframe = async (_path: string, time: number): Promise<SeekPlan> => ({
seek: Math.max(0, time - 0.1),
select: 0.1,
decodesFrom: 0,
});
describe("groupOverlappingDecodes", () => {
it("puts frames that decode from the same keyframe in one pass", () => {
const groups = groupOverlappingDecodes([planned(0, 100), planned(0, 40), planned(0, 150)]);
expect(groups.map((group) => group.length)).toEqual([3]);
});
it("keeps frames with separate keyframes apart", () => {
const groups = groupOverlappingDecodes([planned(0, 1), planned(10, 11), planned(20, 21)]);
expect(groups.map((group) => group.length)).toEqual([1, 1, 1]);
});
it("chains runs that touch end to end", () => {
const groups = groupOverlappingDecodes([planned(0, 5), planned(4, 9), planned(30, 31)]);
expect(groups.map((group) => group.length)).toEqual([2, 1]);
});
});
describe("createFrameBank", () => {
const decoded = (frames: PlannedFrame[]) =>
frames.map((frame) => Buffer.from(String(frame.time)));
it("decodes every time of a one-keyframe source in a single pass", async () => {
const decode = vi.fn(async (_path: string, frames: PlannedFrame[]) => decoded(frames));
const bank = createFrameBank({ probe: singleKeyframe, decode });
const times = [105, 115, 125, 135, 145, 155];
await bank.plan("rec.mp4", times.map(want));
expect(decode, "nothing decodes until a frame is asked for").not.toHaveBeenCalled();
for (const time of times) {
expect((await bank.take("rec.mp4", want(time)))?.toString()).toBe(String(time));
}
expect(decode).toHaveBeenCalledTimes(1);
expect(decode.mock.calls[0]![1].map((frame) => frame.time).sort()).toEqual(times.sort());
expect(bank.stats).toMatchObject({ passes: 1, frames: 6, sharedFrames: 6 });
expect(bank.stats.longestDecodeSeconds).toBeCloseTo(155, 3);
});
it("decodes frames near their own keyframes separately", async () => {
const decode = vi.fn(async (_path: string, frames: PlannedFrame[]) => decoded(frames));
const bank = createFrameBank({
probe: async (_path, time) => ({
seek: Math.floor(time),
select: 0.4,
decodesFrom: Math.floor(time),
}),
decode,
});
await bank.plan("clip.mp4", [want(1.2), want(5.2), want(9.2)]);
await Promise.all([1.2, 5.2, 9.2].map((time) => bank.take("clip.mp4", want(time))));
expect(decode).toHaveBeenCalledTimes(3);
expect(bank.stats.sharedFrames).toBe(0);
});
it("hands back nothing for a frame it did not plan", async () => {
const bank = createFrameBank({ probe: singleKeyframe, decode: async (_p, f) => decoded(f) });
await bank.plan("rec.mp4", [want(10)]);
expect(bank.take("rec.mp4", want(11))).toBeUndefined();
expect(bank.take("other.mp4", want(10))).toBeUndefined();
});
it("leaves a frame to the single-frame path when its landing is unknown", async () => {
const decode = vi.fn(async (_p: string, f: PlannedFrame[]) => decoded(f));
const bank = createFrameBank({ probe: async () => ({ select: 1 }), decode });
await bank.plan("rec.mp4", [want(10)]);
expect(decode).not.toHaveBeenCalled();
expect(bank.take("rec.mp4", want(10))).toBeUndefined();
});
it("leaves every frame to the single-frame path when a probe fails", async () => {
const decode = vi.fn(async (_p: string, f: PlannedFrame[]) => decoded(f));
const bank = createFrameBank({
probe: async () => {
throw new Error("ffprobe could not read it");
},
decode,
});
await bank.plan("rec.mp4", [want(10), want(20)]);
expect(decode).not.toHaveBeenCalled();
expect(bank.take("rec.mp4", want(10))).toBeUndefined();
});
it("hands back nothing for the frames of a pass that failed, so each decodes alone", async () => {
const bank = createFrameBank({
probe: singleKeyframe,
decode: async () => {
throw new Error("FFmpeg timed out");
},
});
await bank.plan("rec.mp4", [want(10), want(20)]);
expect(await bank.take("rec.mp4", want(10))).toBeUndefined();
expect(await bank.take("rec.mp4", want(20))).toBeUndefined();
});
it("decodes frames of different displayed sizes in separate passes", async () => {
const decode = vi.fn(async (_p: string, f: PlannedFrame[]) => decoded(f));
const bank = createFrameBank({ probe: singleKeyframe, decode });
const small = { ...want(40), displayedPixels: 540 };
await bank.plan("rec.mp4", [want(30), small, want(50)]);
await Promise.all([want(30), small, want(50)].map((frame) => bank.take("rec.mp4", frame)));
expect(decode).toHaveBeenCalledTimes(2);
});
it("serves a time asked for twice from one decoded frame", async () => {
const decode = vi.fn(async (_p: string, f: PlannedFrame[]) => decoded(f));
const bank = createFrameBank({ probe: singleKeyframe, decode });
await bank.plan("rec.mp4", [want(30), want(30), want(40)]);
expect(await bank.take("rec.mp4", want(30))).toBeDefined();
expect(decode.mock.calls[0]![1]).toHaveLength(2);
expect(await bank.take("rec.mp4", want(30))).toBeDefined();
expect(bank.take("rec.mp4", want(30))).toBeUndefined();
});
});
@@ -0,0 +1,143 @@
export interface FrameWant {
time: number;
displayedPixels: number;
holdLastFrame: boolean;
}
export interface SeekPlan {
seek?: number;
select?: number;
/** Where ffmpeg really starts decoding: the keyframe at or before `seek`. */
decodesFrom?: number;
}
/** A frame with its decode known: runs from `decodesFrom`, `-ss seek` drops the rest, `seek + select` is the cut. */
export interface PlannedFrame extends FrameWant {
seek: number;
select: number;
decodesFrom: number;
}
interface FrameBankDeps {
probe: (path: string, time: number, holdLastFrame: boolean) => Promise<SeekPlan | null>;
/** One ffmpeg pass over `frames`, which share a displayed size; throws when the pass fails. */
decode: (path: string, frames: PlannedFrame[]) => Promise<Buffer[]>;
}
const slotKey = (path: string, want: FrameWant) =>
`${path}\0${want.time.toFixed(6)}\0${want.displayedPixels}\0${want.holdLastFrame}`;
/** Frames whose decode runs overlap share one pass: a one-keyframe source decodes once, not once per frame. */
export function groupOverlappingDecodes(frames: PlannedFrame[]): PlannedFrame[][] {
const sorted = [...frames].sort((a, b) => a.decodesFrom - b.decodesFrom);
const groups: PlannedFrame[][] = [];
let end = Number.NEGATIVE_INFINITY;
for (const frame of sorted) {
if (frame.decodesFrom > end) groups.push([]);
groups[groups.length - 1]!.push(frame);
end = Math.max(end, frame.seek + frame.select);
}
return groups;
}
type Probe = FrameBankDeps["probe"];
/** Each distinct frame once, with how many requests ask for it. */
function countAsked(
path: string,
wants: FrameWant[],
): Map<string, { want: FrameWant; uses: number }> {
const asked = new Map<string, { want: FrameWant; uses: number }>();
for (const want of wants) {
const key = slotKey(path, want);
asked.set(key, { want, uses: (asked.get(key)?.uses ?? 0) + 1 });
}
return asked;
}
/** The wanted frames whose decode is known; a failed probe leaves its frames to the single-frame path. */
async function probeFrames(
probe: Probe,
path: string,
wants: FrameWant[],
): Promise<PlannedFrame[]> {
try {
const plans = await Promise.all(
wants.map((want) => probe(path, want.time, want.holdLastFrame)),
);
return plans.flatMap((plan, i) =>
plan?.seek !== undefined && plan.select !== undefined
? [
{
...wants[i]!,
seek: plan.seek,
select: plan.select,
decodesFrom: plan.decodesFrom ?? plan.seek,
},
]
: [],
);
} catch {
return [];
}
}
/** One pass decodes one displayed size, so frames split by size before they split by overlap. */
function passGroups(planned: PlannedFrame[]): PlannedFrame[][] {
const sizes = [...new Set(planned.map((frame) => frame.displayedPixels))];
return sizes.flatMap((size) =>
groupOverlappingDecodes(planned.filter((frame) => frame.displayedPixels === size)),
);
}
interface BankStats {
passes: number;
frames: number;
sharedFrames: number;
longestDecodeSeconds: number;
}
function countPass(stats: BankStats, group: PlannedFrame[]): void {
stats.passes += 1;
stats.frames += group.length;
if (group.length > 1) stats.sharedFrames += group.length;
for (const frame of group)
stats.longestDecodeSeconds = Math.max(
stats.longestDecodeSeconds,
frame.seek + frame.select - frame.decodesFrom,
);
}
/** Plans every frame a video will be asked for, decodes them group by group, and hands each one to its request.
* A pass that fails hands back nothing, so its frames decode one at a time as they always did. */
export function createFrameBank(deps: FrameBankDeps) {
const slots = new Map<string, { frame: () => Promise<Buffer | undefined>; uses: number }>();
const stats: BankStats = { passes: 0, frames: 0, sharedFrames: 0, longestDecodeSeconds: 0 };
return {
stats,
async plan(path: string, wants: FrameWant[]): Promise<void> {
const asked = countAsked(path, wants);
const fresh = [...asked].filter(([key]) => !slots.has(key)).map(([, { want }]) => want);
for (const group of passGroups(await probeFrames(deps.probe, path, fresh))) {
countPass(stats, group);
// A pass starts when its first frame is asked for, so the caller's own limit on concurrent work holds.
let run: Promise<Buffer[] | undefined> | undefined;
const start = () => (run ??= deps.decode(path, group).catch(() => undefined));
for (const [i, frame] of group.entries()) {
const key = slotKey(path, frame);
slots.set(key, {
frame: () => start().then((buffers) => buffers?.[i]),
uses: asked.get(key)?.uses ?? 1,
});
}
}
},
/** The planned frame's bytes; undefined, now or once its pass failed, means decode it alone. */
take(path: string, want: FrameWant): Promise<Buffer | undefined> | undefined {
const key = slotKey(path, want);
const slot = slots.get(key);
if (slot && --slot.uses <= 0) slots.delete(key);
return slot?.frame();
},
};
}
+22
View File
@@ -1079,6 +1079,28 @@ export function trackCatalogSearchMiss(props: {
});
}
// Where a snapshot's time went and how far its video frames sat from a keyframe, so the slow runs
// split by cause. Low-cardinality only: counts, a stage name and a distance bucket.
export function trackSnapshotRun(props: {
frames: number;
videoFrames: number;
decodePasses: number;
sharedFrames: number;
longestDecodeSeconds: number;
slowStage: "open" | "seek" | "video" | "capture";
}): void {
const seconds = props.longestDecodeSeconds;
trackEvent("cli_snapshot_run", {
frames: props.frames,
video_frames: props.videoFrames,
decode_passes: props.decodePasses,
shared_frames: props.sharedFrames,
keyframe_distance:
seconds <= 2 ? "to_2s" : seconds <= 10 ? "to_10s" : seconds <= 60 ? "to_60s" : "over_60s",
slow_stage: props.slowStage,
});
}
export function trackCommandResult(props: {
command: string;
success: boolean;