fix(cli): snapshot shows a short video's last frame instead of going black (#4552)

* fix(cli): snapshot holds a short video's last frame instead of going black

* fix(cli): snapshot falls back to a video's real last frame when a held tail lands past it
This commit is contained in:
Miguel Ángel
2026-09-27 01:05:56 +00:00
committed by GitHub
parent 640e990431
commit 76723c614c
2 changed files with 67 additions and 5 deletions
@@ -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(
+19 -5
View File
@@ -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
* <video> elements during snapshot capture.
*/
async function extractVideoFrameToBuffer(
export async function extractVideoFrameToBuffer(
videoPath: string,
timeSeconds: number,
useVp9AlphaDecoder = false,
accurateSeek = false,
holdLastFrame = false,
): Promise<Buffer | null> {
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
const outPath = join(tmp, "frame.png");
@@ -161,6 +163,7 @@ async function extractVideoFrameToBuffer(
if (useVp9AlphaDecoder) {
args.push("-c:v", "libvpx-vp9");
}
const decoderArgs = [...args];
const seek = ["-ss", String(Math.max(0, timeSeconds))];
args.push(
...(accurateSeek ? ["-i", videoPath, ...seek] : [...seek, "-i", videoPath]),
@@ -171,7 +174,16 @@ async function extractVideoFrameToBuffer(
"-y",
outPath,
);
const result = await runFfmpegOnce(ffmpegPath, args, FFMPEG_EXTRACT_TIMEOUT_MS);
let result = await runFfmpegOnce(ffmpegPath, args, FFMPEG_EXTRACT_TIMEOUT_MS);
if (holdLastFrame && result.code === 0 && !result.timedOut && !existsSync(outPath)) {
// Past the last frame's timestamp FFmpeg writes nothing; decode the final second and keep its last frame.
const tail = ["-sseof", "-1", "-i", videoPath, "-update", "1", "-q:v", "2", "-y", outPath];
result = await runFfmpegOnce(
ffmpegPath,
[...decoderArgs, ...tail],
FFMPEG_EXTRACT_TIMEOUT_MS,
);
}
if (result.code !== 0 || result.timedOut || !existsSync(outPath)) return null;
return readFileSync(outPath);
} finally {
@@ -550,6 +562,8 @@ async function captureSnapshots(
ffmpegInput,
Math.max(0, v.relTime),
useVp9AlphaDecoder,
false,
v.srcDuration > 0 && v.relTime >= v.srcDuration - 1,
);
if (!png) continue;
updates.push({