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