mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 19:57:13 +00:00
fix(engine): nested video clips on a frame boundary export every source frame (#6893)
* fix(engine): nested video clips on a frame boundary export every source frame * fix(engine): drop comment from ffmpeg time formatter
This commit is contained in:
@@ -5,7 +5,12 @@ import { renameWithRetry } from "@hyperframes/core/rename-retry";
|
||||
import { removeScratch } from "@hyperframes/core/remove-scratch";
|
||||
import { join, dirname, isAbsolute, relative } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { extractAudioMetadata, ffmpegSeekSeconds, type AudioMetadata } from "../utils/ffprobe.js";
|
||||
import {
|
||||
extractAudioMetadata,
|
||||
ffmpegSeekSeconds,
|
||||
formatFfmpegSeconds,
|
||||
type AudioMetadata,
|
||||
} from "../utils/ffprobe.js";
|
||||
import { isNotMediaPayload } from "../utils/notMediaPayload.js";
|
||||
import { urlInputArgs } from "../utils/urlInput.js";
|
||||
import { clampAudioGain, clipGain } from "@hyperframes/core/audio-gain";
|
||||
@@ -739,7 +744,7 @@ async function extractAudioSegment(
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-t",
|
||||
String(sourceDuration),
|
||||
formatFfmpegSeconds(sourceDuration),
|
||||
"-i",
|
||||
cyclePath,
|
||||
"-vn",
|
||||
@@ -749,7 +754,7 @@ async function extractAudioSegment(
|
||||
"48000",
|
||||
...outputArgs,
|
||||
"-t",
|
||||
String(duration),
|
||||
formatFfmpegSeconds(duration),
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
@@ -795,13 +800,19 @@ async function extractAudioSegment(
|
||||
...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
|
||||
...urlInputArgs(readFrom),
|
||||
...(inputSeek
|
||||
? ["-ss", String(ffmpegStart - warmUp), "-t", String(sourceDuration + warmUp)]
|
||||
? [
|
||||
"-ss",
|
||||
formatFfmpegSeconds(ffmpegStart - warmUp),
|
||||
"-t",
|
||||
formatFfmpegSeconds(sourceDuration + warmUp),
|
||||
]
|
||||
: []),
|
||||
"-i",
|
||||
readFrom,
|
||||
];
|
||||
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
|
||||
if (normalizedPlaybackRate !== 1 || options?.padToDuration) args.push("-t", String(duration));
|
||||
if (normalizedPlaybackRate !== 1 || options?.padToDuration)
|
||||
args.push("-t", formatFfmpegSeconds(duration));
|
||||
args.push("-y", outputPath);
|
||||
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
@@ -870,7 +881,7 @@ async function generateSilence(
|
||||
"-i",
|
||||
"anullsrc=r=48000:cl=stereo",
|
||||
"-t",
|
||||
String(duration),
|
||||
formatFfmpegSeconds(duration),
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-y",
|
||||
@@ -983,7 +994,7 @@ async function mixAudioTracksFlat(
|
||||
"-ar",
|
||||
"48000",
|
||||
"-t",
|
||||
String(totalDuration),
|
||||
formatFfmpegSeconds(totalDuration),
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
@@ -1168,7 +1179,7 @@ async function mixGroupMembersFlat(
|
||||
"-ar",
|
||||
"48000",
|
||||
"-t",
|
||||
String(totalDuration),
|
||||
formatFfmpegSeconds(totalDuration),
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createFrameLookupTable,
|
||||
FrameLookupTable,
|
||||
rebaseVideoToWindow,
|
||||
alignClipStartToFirstOutputFrame,
|
||||
resolveFrameFormat,
|
||||
codecMayHaveAlpha,
|
||||
decoderForCodec,
|
||||
@@ -193,6 +194,14 @@ describe("resolveVideoExtractionDuration", () => {
|
||||
expect(seconds).toBeCloseTo(4.6586, 3);
|
||||
});
|
||||
|
||||
it("leaves media start untouched for a nested clip whose start sums to a frame boundary with float dust", () => {
|
||||
// 3.3 + 1.3666666666666667 = 4.666666666666666, frame 140 at 30 fps
|
||||
const clip = video({ start: 3.3 + 1.3666666666666667, end: 8, mediaStart: 1.35 });
|
||||
alignClipStartToFirstOutputFrame(clip, 30);
|
||||
expect(clip.mediaStart).toBe(1.35);
|
||||
expect(clip.start).toBe(140 / 30);
|
||||
});
|
||||
|
||||
it("snaps a float-noise composition start to the timeline origin so the first frame is kept", () => {
|
||||
const clip = video({ start: -1, end: 8, playbackRate: 0.7 });
|
||||
rebaseVideoToWindow(clip, { compositionStart: 2.2e-16, mediaStart: 0.7, durationSeconds: 5 });
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
extractFinalVideoFrameTimestamp,
|
||||
extractMediaMetadata,
|
||||
ffmpegSeekSeconds,
|
||||
formatFfmpegSeconds,
|
||||
keyframeGapSeconds,
|
||||
mediaFileIdentity,
|
||||
readMetadataCache,
|
||||
@@ -1059,12 +1060,12 @@ async function runSegmentedExtraction(job: SegmentedExtraction): Promise<RunFfmp
|
||||
...decodeArgs,
|
||||
"-noaccurate_seek",
|
||||
"-ss",
|
||||
String(job.ffmpegSeek(seek)),
|
||||
formatFfmpegSeconds(job.ffmpegSeek(seek)),
|
||||
"-i",
|
||||
videoPath,
|
||||
// Read one frame past the segment and keep exactly its own frames.
|
||||
"-t",
|
||||
String((frames + 1) / fps),
|
||||
formatFfmpegSeconds((frames + 1) / fps),
|
||||
"-frames:v",
|
||||
String(frames),
|
||||
"-start_number",
|
||||
@@ -1207,11 +1208,25 @@ async function extractVideoFramesRangeUnredacted(
|
||||
// sample. This is intentionally reserved for the one-frame path: input
|
||||
// seeking is faster, but valid unindexed transports (notably MPEG-TS with
|
||||
// a negative timestamp base) can seek to EOF and emit zero frames.
|
||||
args.push("-i", videoPath, "-ss", String(Math.max(0, ffmpegSeek(startTime))), "-frames:v", "1");
|
||||
args.push(
|
||||
"-i",
|
||||
videoPath,
|
||||
"-ss",
|
||||
formatFfmpegSeconds(Math.max(0, ffmpegSeek(startTime))),
|
||||
"-frames:v",
|
||||
"1",
|
||||
);
|
||||
} else {
|
||||
// An accurate seek drops the frame on screen at startTime; the fps sampling below needs it.
|
||||
if (sampleAtOutputFps) args.push("-noaccurate_seek");
|
||||
args.push("-ss", String(ffmpegSeek(startTime)), "-i", videoPath, "-t", String(duration));
|
||||
args.push(
|
||||
"-ss",
|
||||
formatFfmpegSeconds(ffmpegSeek(startTime)),
|
||||
"-i",
|
||||
videoPath,
|
||||
"-t",
|
||||
formatFfmpegSeconds(duration),
|
||||
);
|
||||
}
|
||||
|
||||
const vfFilters: string[] = [];
|
||||
@@ -1510,13 +1525,17 @@ export function isVideoPastTimelineEnd(video: Pick<VideoElement, "start">, timel
|
||||
return video.start >= timelineEnd;
|
||||
}
|
||||
|
||||
function alignClipStartToFirstOutputFrame(video: VideoElement, fps: number): void {
|
||||
export function alignClipStartToFirstOutputFrame(video: VideoElement, fps: number): void {
|
||||
if (video.loop || !Number.isFinite(video.start) || video.start < 0 || !(fps > 0)) return;
|
||||
const position = exportClipWindow(video.start, Infinity, fps).start * fps;
|
||||
const firstFrame =
|
||||
Math.abs(position - Math.round(position)) < 1e-6 ? Math.round(position) : Math.ceil(position);
|
||||
const shift = firstFrame / fps - video.start;
|
||||
if (shift === 0) return;
|
||||
// Float dust (a sum landing a hair off the grid) snaps the start; it never reaches mediaStart.
|
||||
if (Math.abs(shift) < 1e-9) {
|
||||
video.start = firstFrame / fps;
|
||||
return;
|
||||
}
|
||||
const playbackRate = normalizeRateSpec(video.playbackRate);
|
||||
video.mediaStart += sourceTimeAt(playbackRate, shift);
|
||||
video.playbackRate = shiftRateLane(playbackRate, shift);
|
||||
|
||||
@@ -5,7 +5,12 @@ import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs
|
||||
import { tmpdir } from "os";
|
||||
import { basename, join, resolve } from "path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { extractMediaMetadata, extractPngMetadataFromBuffer, parseFrameRate } from "./ffprobe.js";
|
||||
import {
|
||||
extractMediaMetadata,
|
||||
extractPngMetadataFromBuffer,
|
||||
formatFfmpegSeconds,
|
||||
parseFrameRate,
|
||||
} from "./ffprobe.js";
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
@@ -2235,3 +2240,17 @@ describe.skipIf(!HAS_FFPROBE)("VP8/VP9 decoded alpha plane probe on real files",
|
||||
expect("alphaPlaneDecoded" in other).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFfmpegSeconds", () => {
|
||||
it("never writes an exponent, which ffmpeg and ffprobe reject", () => {
|
||||
expect(formatFfmpegSeconds(8.881784197001252e-16)).toBe("0");
|
||||
expect(formatFfmpegSeconds(-8.881784197001252e-16)).toBe("0");
|
||||
expect(formatFfmpegSeconds(1.5e-7)).toBe("0");
|
||||
});
|
||||
|
||||
it("keeps ordinary times exactly as String() writes them", () => {
|
||||
expect(formatFfmpegSeconds(1.35)).toBe("1.35");
|
||||
expect(formatFfmpegSeconds(4.666666666666667)).toBe("4.666666666666667");
|
||||
expect(formatFfmpegSeconds(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,6 +522,11 @@ export function videoStartOnMediaTimeline(
|
||||
return (metadata.videoStreamLeadSeconds ?? 0) + (metadata.mediaTimeOffsetSeconds ?? 0);
|
||||
}
|
||||
|
||||
export function formatFfmpegSeconds(seconds: number): string {
|
||||
const text = String(seconds);
|
||||
return text.includes("e") ? String(+seconds.toFixed(6)) : text;
|
||||
}
|
||||
|
||||
export function ffmpegSeekSeconds(
|
||||
metadata: Pick<VideoMetadata, "mediaTimeOffsetSeconds">,
|
||||
mediaTime: number,
|
||||
@@ -1104,7 +1109,7 @@ async function extractFinalPacketDuration(
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-read_intervals",
|
||||
`${intervalStart}%${streamEnd}`,
|
||||
`${formatFfmpegSeconds(intervalStart)}%${formatFfmpegSeconds(streamEnd)}`,
|
||||
"-show_entries",
|
||||
"packet=pts_time,duration_time",
|
||||
"-of",
|
||||
@@ -1200,7 +1205,8 @@ export async function extractFinalVideoFrameTimestamp(
|
||||
return parseFinalTimestamp(stdout);
|
||||
};
|
||||
const timestamp =
|
||||
(await probe(`${intervalStart}%${streamEnd}`)) ?? (await probe(/* full scan */));
|
||||
(await probe(`${formatFfmpegSeconds(intervalStart)}%${formatFfmpegSeconds(streamEnd)}`)) ??
|
||||
(await probe(/* full scan */));
|
||||
if (timestamp === undefined) {
|
||||
throw new Error("[FFmpeg] ffprobe found no decodable final video frame");
|
||||
}
|
||||
@@ -1442,7 +1448,7 @@ export async function keyframeGapSeconds(
|
||||
filePath,
|
||||
[
|
||||
...["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0"],
|
||||
...["-read_intervals", `${seekSeconds}%+#1`],
|
||||
...["-read_intervals", `${formatFfmpegSeconds(seekSeconds)}%+#1`],
|
||||
],
|
||||
signal,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user