diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index f920e709d0..8fb5404dc9 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -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, ]; diff --git a/packages/engine/src/services/videoFrameExtractor.test.ts b/packages/engine/src/services/videoFrameExtractor.test.ts index c70421e05b..dc23e387b6 100644 --- a/packages/engine/src/services/videoFrameExtractor.test.ts +++ b/packages/engine/src/services/videoFrameExtractor.test.ts @@ -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 }); diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index 606f6bf833..1ebe2c60ff 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -46,6 +46,7 @@ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, ffmpegSeekSeconds, + formatFfmpegSeconds, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, @@ -1059,12 +1060,12 @@ async function runSegmentedExtraction(job: SegmentedExtraction): Promise, 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); diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index f87bd70e2f..708b1221d8 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -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"); + }); +}); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index 0df8d35785..c55eeb6812 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -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, 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, );