mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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!, [
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user