diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e5a6d426da..f08e67031b 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -213,10 +213,13 @@ export { extractMediaMetadata, extractVideoMetadata, extractAudioMetadata, + extractVideoStreamStats, + validateVideoStreamParity, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type KeyframeAnalysis, + type VideoStreamStats, } from "./utils/ffprobe.js"; export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js"; diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 9751bc8e89..98a355ae23 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -397,11 +397,10 @@ describe("muxVideoWithAudio audio codec handling", () => { "+faststart", "-avoid_negative_ts", "make_zero", - "-r", - "30", "-y", "/tmp/output.mp4", ]); + expect(calls[0]!.args).not.toContain("-r"); expect(calls[0]!.args).not.toContain("-shortest"); expect(calls[0]!.args).not.toContain("-use_editlist"); @@ -596,6 +595,39 @@ describe("muxVideoWithAudio audio codec handling", () => { }); }); +describe("stream-copy remux frame preservation", () => { + it("does not apply an output -r while copying video during faststart", async () => { + const { spawn, calls } = createSpawnSpy(); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { applyFaststart } = await import("./chunkEncoder.js"); + const faststartPromise = applyFaststart( + "/tmp/video-only.mp4", + "/tmp/output.mp4", + undefined, + undefined, + { num: 30, den: 1 }, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]!.args).toEqual([ + "-i", + "/tmp/video-only.mp4", + "-c", + "copy", + "-movflags", + "+faststart", + "-y", + "/tmp/output.mp4", + ]); + expect(calls[0]!.args).not.toContain("-r"); + + emitClose(calls[0]!.proc, 0); + await expect(faststartPromise).resolves.toMatchObject({ success: true }); + }); +}); + describe("getEncoderPreset", () => { it("returns h264 with yuv420p for mp4 format", () => { const preset = getEncoderPreset("standard", "mp4"); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index e7d9908e2a..f850daaa3e 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -759,7 +759,7 @@ export async function muxVideoWithAudio( outputPath: string, signal?: AbortSignal, config?: MuxVideoWithAudioOptions, - fps?: Fps, + _fps?: Fps, ): Promise { const outputDir = dirname(outputPath); if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }); @@ -792,12 +792,10 @@ export async function muxVideoWithAudio( // PTS bases can diverge during mux and reintroduce negative DTS. See // buildEncoderArgs for the full reasoning on why that breaks playback. args.push("-avoid_negative_ts", "make_zero"); - if (fps !== undefined) { - // Set the exact output framerate so the muxer doesn't PTS-average a - // fractional rational like `360000/12001` instead of `30/1` into the - // output container metadata. `-c:v copy` is retained; no re-encode. - args.push("-r", fpsToFfmpegArg(fps)); - } + // Do not pass output `-r` while stream-copying video. FFmpeg is allowed to + // drop or retimestamp packets to satisfy an output rate even with `-c:v + // copy`, which can turn a complete long render into a short sparse stream. + // The encoded video already owns authoritative frame timestamps. args.push("-y", outputPath); const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout; @@ -824,7 +822,7 @@ export async function applyFaststart( outputPath: string, signal?: AbortSignal, config?: Partial>, - fps?: Fps, + _fps?: Fps, ): Promise { // faststart is MP4-only (moves moov atom to file start for streaming). // WebM and MOV don't need it — skip the re-mux. @@ -833,12 +831,8 @@ export async function applyFaststart( return { success: true, outputPath, durationMs: 0 }; } const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart"]; - if (fps !== undefined) { - // Set the exact output framerate so the final remux doesn't PTS-average - // a fractional rational like `360000/12001` instead of `30/1` into the - // output container metadata. `-c copy` is retained; no re-encode. - args.push("-r", fpsToFfmpegArg(fps)); - } + // Preserve the encoded stream timestamps. Output `-r` is not safe with + // stream copy because FFmpeg may drop or retimestamp packets. args.push("-y", outputPath); const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout; diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index c545190b59..a81d13e67b 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -3,7 +3,12 @@ import { EventEmitter } from "events"; import { readFileSync } from "fs"; import { resolve } from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { extractMediaMetadata, extractPngMetadataFromBuffer } from "./ffprobe.js"; +import { + extractMediaMetadata, + extractPngMetadataFromBuffer, + parseVideoStreamStatsProbe, + validateVideoStreamParity, +} from "./ffprobe.js"; function crc32(buf: Buffer): number { let crc = 0xffffffff; @@ -17,6 +22,50 @@ function crc32(buf: Buffer): number { return (crc ^ 0xffffffff) >>> 0; } +describe("video stream copy parity", () => { + it("parses packet count and stream duration from a count-packets probe", () => { + expect( + parseVideoStreamStatsProbe( + JSON.stringify({ + streams: [{ codec_type: "video", duration: "1050.900000", nb_read_packets: "31527" }], + format: { duration: "1050.900000" }, + }), + ), + ).toEqual({ durationSeconds: 1050.9, frameCount: 31527 }); + }); + + it("rejects a mux output that silently drops copied video packets", () => { + expect(() => + validateVideoStreamParity( + { durationSeconds: 1050.9, frameCount: 31527 }, + { durationSeconds: 1050.9, frameCount: 162 }, + ), + ).toThrow(/31527.*162/); + }); + + it("rejects stream-duration drift even when packet counts match", () => { + expect(() => + validateVideoStreamParity( + { durationSeconds: 1050.9, frameCount: 31527 }, + { durationSeconds: 5.4, frameCount: 31527 }, + ), + ).toThrow(/duration/i); + }); + + it("does not substitute longer container audio duration when video stream duration is absent", () => { + const output = parseVideoStreamStatsProbe( + JSON.stringify({ + streams: [{ codec_type: "video", nb_read_packets: "60" }], + format: { duration: "2.162000" }, + }), + ); + expect(output).toEqual({ durationSeconds: undefined, frameCount: 60 }); + expect(() => + validateVideoStreamParity({ durationSeconds: 2, frameCount: 60 }, output), + ).not.toThrow(); + }); +}); + function pngChunk(type: string, data: number[]): Buffer { const chunkData = Buffer.from(data); const header = Buffer.alloc(8); @@ -118,18 +167,22 @@ interface SpawnCall { interface FakeProc extends EventEmitter { stdout: EventEmitter; stderr: EventEmitter; + kill: ReturnType; } type SpawnOutcome = | { kind: "missing" } + | { kind: "hang" } | { kind: "error"; message: string; code?: string } | { kind: "exit"; code: number; stdout?: string; stderr?: string }; function createSpawnSpy(outcomes: SpawnOutcome[]): { spawn: (command: string, args: readonly string[]) => FakeProc; calls: SpawnCall[]; + procs: FakeProc[]; } { const calls: SpawnCall[] = []; + const procs: FakeProc[] = []; let invocation = 0; const spawn = (command: string, args: readonly string[]): FakeProc => { calls.push({ command, args }); @@ -139,9 +192,12 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): { const proc = new EventEmitter() as FakeProc; proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + procs.push(proc); process.nextTick(() => { if (!outcome) return; + if (outcome.kind === "hang") return; if (outcome.kind === "missing") { const err = new Error("spawn ffprobe ENOENT") as NodeJS.ErrnoException; err.code = "ENOENT"; @@ -161,7 +217,7 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): { return proc; }; - return { spawn, calls }; + return { spawn, calls, procs }; } describe("ffprobe missing-binary fallback", () => { @@ -351,4 +407,18 @@ describe("ffprobe missing-binary fallback", () => { await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i); }); + + it("kills a full-stream packet probe when assembly is aborted", async () => { + const { spawn, procs } = createSpawnSpy([{ kind: "hang" }]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + const { extractVideoStreamStats } = await import("./ffprobe.js"); + const controller = new AbortController(); + + const probe = extractVideoStreamStats("/tmp/long-video.mp4", controller.signal); + controller.abort(); + + await expect(probe).rejects.toThrow(/cancelled/i); + expect(procs[0]?.kill).toHaveBeenCalledWith("SIGTERM"); + }); }); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index 2fbaf42259..c823a9938c 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -4,13 +4,39 @@ import { readFileSync } from "fs"; import { extname } from "path"; import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js"; +interface RunFfprobeOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + /** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */ -function runFfprobe(args: string[]): Promise { +function runFfprobe(args: string[], options?: RunFfprobeOptions): Promise { return new Promise((resolve, reject) => { const command = getFfprobeBinary(); const proc = spawn(command, args); let stdout = ""; let stderr = ""; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + options?.signal?.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(stdout); + }; + const onAbort = () => { + proc.kill("SIGTERM"); + finish(new Error("[FFmpeg] ffprobe cancelled")); + }; + const timer = options?.timeoutMs + ? setTimeout(() => { + proc.kill("SIGTERM"); + finish(new Error(`[FFmpeg] ffprobe timed out after ${options.timeoutMs} ms`)); + }, options.timeoutMs) + : undefined; + if (options?.signal?.aborted) onAbort(); + else options?.signal?.addEventListener("abort", onAbort, { once: true }); proc.stdout.on("data", (data) => { stdout += data.toString(); }); @@ -19,15 +45,15 @@ function runFfprobe(args: string[]): Promise { }); proc.on("close", (code) => { if (code !== 0) { - reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`)); + finish(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`)); } else { - resolve(stdout); + finish(); } }); proc.on("error", (err) => { if ((err as NodeJS.ErrnoException).code === "ENOENT") { const configured = process.env[FFPROBE_PATH_ENV]?.trim(); - reject( + finish( new Error( configured ? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.` @@ -35,7 +61,7 @@ function runFfprobe(args: string[]): Promise { ), ); } else { - reject(err); + finish(err); } }); }); @@ -98,6 +124,7 @@ interface FFProbeStream { height?: number; duration?: string; nb_frames?: string; + nb_read_packets?: string; pix_fmt?: string; r_frame_rate?: string; avg_frame_rate?: string; @@ -109,6 +136,73 @@ interface FFProbeStream { tags?: Record; } +export interface VideoStreamStats { + /** True video-stream duration. Undefined when the container omits it. */ + durationSeconds?: number; + frameCount: number; +} + +export function parseVideoStreamStatsProbe(stdout: string): VideoStreamStats { + const output = parseProbeJson(stdout); + const videoStream = output.streams.find((stream) => stream.codec_type === "video"); + if (!videoStream) throw new Error("[FFmpeg] No video stream found"); + + const frameCount = Number.parseInt( + videoStream.nb_read_packets ?? videoStream.nb_frames ?? "", + 10, + ); + const parsedDuration = Number.parseFloat(videoStream.duration ?? ""); + const durationSeconds = + Number.isFinite(parsedDuration) && parsedDuration > 0 ? parsedDuration : undefined; + if (!Number.isFinite(frameCount) || frameCount < 1) { + throw new Error("[FFmpeg] ffprobe did not report a video packet count"); + } + return { durationSeconds, frameCount }; +} + +export async function extractVideoStreamStats( + filePath: string, + signal?: AbortSignal, +): Promise { + const stdout = await runFfprobe( + [ + "-v", + "quiet", + "-count_packets", + "-select_streams", + "v:0", + "-print_format", + "json", + "-show_entries", + "stream=codec_type,duration,nb_read_packets,nb_frames:format=duration", + filePath, + ], + { signal, timeoutMs: 300_000 }, + ); + return parseVideoStreamStatsProbe(stdout); +} + +export function validateVideoStreamParity( + source: VideoStreamStats, + output: VideoStreamStats, +): void { + if (source.frameCount !== output.frameCount) { + throw new Error( + `Video stream frame parity failed after mux: source=${source.frameCount}, output=${output.frameCount}`, + ); + } + if (source.durationSeconds === undefined || output.durationSeconds === undefined) return; + const frameDuration = source.durationSeconds / source.frameCount; + const toleranceSeconds = Math.max(0.05, frameDuration * 2); + const durationDrift = Math.abs(source.durationSeconds - output.durationSeconds); + if (durationDrift > toleranceSeconds) { + throw new Error( + `Video stream duration parity failed after mux: source=${source.durationSeconds.toFixed(3)}s, ` + + `output=${output.durationSeconds.toFixed(3)}s, drift=${durationDrift.toFixed(3)}s`, + ); + } +} + interface FFProbeFormat { duration?: string; bit_rate?: string; diff --git a/packages/producer/src/services/distributed/assemble.test.ts b/packages/producer/src/services/distributed/assemble.test.ts index 4cb662097a..12dc61ac28 100644 --- a/packages/producer/src/services/distributed/assemble.test.ts +++ b/packages/producer/src/services/distributed/assemble.test.ts @@ -75,14 +75,16 @@ function buildPlanDir( * independently concatenable because GOP === frame count and the first * frame is forced as a keyframe. */ -function makeMp4Chunk(outputPath: string, frameCount: number): void { +function makeMp4Chunk(outputPath: string, frameCount: number, rate = "30"): void { + const [rateNum, rateDen = "1"] = rate.split("/"); + const rateValue = Number(rateNum) / Number(rateDen); const args = [ "-v", "error", "-f", "lavfi", "-i", - `testsrc=size=160x120:rate=30:duration=${frameCount / 30}`, + `testsrc=size=160x120:rate=${rate}:duration=${frameCount / rateValue}`, "-c:v", "libx264", "-preset", @@ -200,12 +202,14 @@ describe("assemble()", () => { const probedFrames = Number(videoStream?.nb_read_packets ?? videoStream?.nb_frames); expect(probedFrames).toBe(10); - // ── ffprobe: exact framerate + duration equivalence ──────────────── - // The container's `r_frame_rate` must match the planDir's exact - // rational (30/1 here) — not a PTS-averaged fraction like - // `360000/12001`. This guards the `-r` flag on the concat / - // mux / faststart steps from regressing. - expect(videoStream?.r_frame_rate).toBe("30/1"); + // ── ffprobe: nominal framerate + duration equivalence ────────────── + // Stream-copy assembly must preserve every packet and the timeline. + // Some ffmpeg versions report a nearby PTS-derived r_frame_rate such + // as 30000/1001; forcing exact metadata with output `-r` is unsafe + // because it can drop packets from long renders. + const [rateNum, rateDen] = String(videoStream?.r_frame_rate).split("/").map(Number); + expect(rateNum! / rateDen!).toBeGreaterThan(29.9); + expect(rateNum! / rateDen!).toBeLessThanOrEqual(30); // Duration must equal `totalFrames * fpsDen / fpsNum` within 1ms. const expectedDuration = (10 * 1) / 30; const probedDuration = Number(videoStream?.duration ?? 0); @@ -243,7 +247,7 @@ describe("assemble()", () => { ); it( - "single-chunk render stamps exact r_frame_rate on the output container", + "single-chunk render preserves source packet timing without output-rate coercion", async () => { if (!hasFfmpeg) { console.warn( @@ -252,17 +256,14 @@ describe("assemble()", () => { return; } - // Reproducer for the single-chunk pass-through regression: when - // `chunkPaths.length === 1`, assemble must still stamp an exact - // `r_frame_rate` matching the planDir's rational (here 30/1), not - // a PTS-derived fraction like `359/12`. Multi-chunk renders go - // through the concat demuxer; single-chunk renders skip it and - // need the `-r ` flag on a direct remux step. + // A PTS-derived NTSC source intentionally differs from the 30/1 plan. + // Stream-copy assembly must preserve that encoded packet timing instead + // of coercing output `-r 30`, which can drop packets on long renders. const chunks: ChunkSliceJson[] = [{ index: 0, startFrame: 0, endFrame: 10 }]; const planDir = buildPlanDir("mp4", chunks, 10, false); const chunkPath = join(planDir, "chunk-0.mp4"); - makeMp4Chunk(chunkPath, 10); + makeMp4Chunk(chunkPath, 10, "30000/1001"); const outputPath = join(planDir, "output-single-chunk.mp4"); const result = await assemble(planDir, [chunkPath], null, outputPath); @@ -272,13 +273,12 @@ describe("assemble()", () => { expect(result.framesEncoded).toBe(10); const videoStream = probeStream(outputPath, "v:0"); + const sourceStream = probeStream(chunkPath, "v:0"); expect(videoStream).toBeDefined(); expect(videoStream?.codec_name).toBe("h264"); - // The exact-rational assertion — the regression hole that this - // test closes. Before the single-chunk -r fix, this came back as - // a PTS-derived fraction (e.g. `359/12`) on 1-chunk renders. - expect(videoStream?.r_frame_rate).toBe("30/1"); - const expectedDuration = 10 / 30; + expect(videoStream?.r_frame_rate).toBe(sourceStream?.r_frame_rate); + expect(Number(videoStream?.nb_read_packets ?? videoStream?.nb_frames)).toBe(10); + const expectedDuration = 10 / (30000 / 1001); const probedDuration = Number(videoStream?.duration ?? 0); expect(Math.abs(probedDuration - expectedDuration)).toBeLessThan(0.001); }, diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 747240f034..f3bf93f7b0 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -34,7 +34,13 @@ import { writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; -import { applyFaststart, muxVideoWithAudio, runFfmpeg } from "@hyperframes/engine"; +import { + applyFaststart, + extractVideoStreamStats, + muxVideoWithAudio, + runFfmpeg, + validateVideoStreamParity, +} from "@hyperframes/engine"; import { fpsToFfmpegArg } from "@hyperframes/core"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { formatExportFrameName } from "../../utils/paths.js"; @@ -156,18 +162,14 @@ export async function assemble( den: plan.dimensions.fpsDen, }); - // Single-chunk renders bypass the concat demuxer entirely. ffmpeg's - // concat demuxer with a one-entry list re-runs as a straight remux of - // the single source, and in that path the input-side `-r` flag does - // not consistently override the source's PTS-derived r_frame_rate - // (observed: `359/12` carrying through to the output container while - // the equivalent multi-chunk path produces `30/1` exact). Running a - // direct `-c copy` remux with `-r ` as an output flag gives the - // muxer the authoritative rate to stamp into the container without - // touching the encoded stream. Multi-chunk renders continue through - // the concat demuxer where the existing `-r` input flag works. + // Single-chunk renders bypass the concat demuxer entirely. Keep the + // stream-copy remux, but preserve the encoder's packet timestamps. + // Output `-r` may drop or retimestamp packets even with `-c copy`. if (chunkPaths.length === 1) { - const remuxArgs = ["-i", chunkPaths[0]!, "-c", "copy", "-r", fpsArg, "-y", concatOutputPath]; + // Never use output `-r` with stream copy: FFmpeg may drop or retimestamp + // packets to satisfy the requested rate. The encoded chunk already owns + // the authoritative timestamps. + const remuxArgs = ["-i", chunkPaths[0]!, "-c", "copy", "-y", concatOutputPath]; const remuxResult = await runFfmpeg(remuxArgs, { signal: abortSignal }); if (!remuxResult.success) { throw new Error( @@ -343,6 +345,11 @@ export async function assemble( if (!faststartResult.success) { throw new Error(`[assemble] faststart failed: ${faststartResult.error}`); } + const [sourceVideo, finalVideo] = await Promise.all([ + extractVideoStreamStats(postConcatPath, abortSignal), + extractVideoStreamStats(outputPath, abortSignal), + ]); + validateVideoStreamParity(sourceVideo, finalVideo); } finally { try { rmSync(workDir, { recursive: true, force: true }); diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts new file mode 100644 index 0000000000..3d84eb6044 --- /dev/null +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { muxVideoWithAudio, applyFaststart, extractVideoStreamStats, validateVideoStreamParity } = + vi.hoisted(() => ({ + muxVideoWithAudio: vi.fn(), + applyFaststart: vi.fn(), + extractVideoStreamStats: vi.fn(), + validateVideoStreamParity: vi.fn(), + })); + +vi.mock("@hyperframes/engine", () => ({ + muxVideoWithAudio, + applyFaststart, + extractVideoStreamStats, + validateVideoStreamParity, +})); + +vi.mock("../shared.js", () => ({ updateJobStatus: vi.fn() })); + +import { runAssembleStage } from "./assembleStage.js"; + +describe("runAssembleStage stream parity", () => { + beforeEach(() => { + vi.clearAllMocks(); + muxVideoWithAudio.mockResolvedValue({ success: true, durationMs: 1 }); + applyFaststart.mockResolvedValue({ success: true, durationMs: 1 }); + extractVideoStreamStats + .mockResolvedValueOnce({ durationSeconds: 1050.9, frameCount: 31527 }) + .mockResolvedValueOnce({ durationSeconds: 1050.9, frameCount: 162 }); + validateVideoStreamParity.mockImplementation(() => { + throw new Error("Video stream frame parity failed after mux: source=31527, output=162"); + }); + }); + + it("fails a completed audio mux whose final video packet count is truncated", async () => { + await expect( + runAssembleStage({ + job: { config: { fps: { num: 30, den: 1 } } } as never, + videoOnlyPath: "/tmp/video-only.mp4", + audioOutputPath: "/tmp/audio.aac", + outputPath: "/tmp/output.mp4", + hasAudio: true, + abortSignal: undefined, + assertNotAborted: vi.fn(), + }), + ).rejects.toThrow(/source=31527, output=162/); + + expect(extractVideoStreamStats).toHaveBeenNthCalledWith(1, "/tmp/video-only.mp4", undefined); + expect(extractVideoStreamStats).toHaveBeenNthCalledWith(2, "/tmp/output.mp4", undefined); + expect(validateVideoStreamParity).toHaveBeenCalledWith( + { durationSeconds: 1050.9, frameCount: 31527 }, + { durationSeconds: 1050.9, frameCount: 162 }, + ); + }); +}); diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index c3e4ebc3e7..4b2f8b08ee 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -16,7 +16,12 @@ * verbatim on the respective `success: false` results. */ -import { applyFaststart, muxVideoWithAudio } from "@hyperframes/engine"; +import { + applyFaststart, + extractVideoStreamStats, + muxVideoWithAudio, + validateVideoStreamParity, +} from "@hyperframes/engine"; import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js"; import { updateJobStatus } from "../shared.js"; @@ -81,5 +86,15 @@ export async function runAssembleStage(input: AssembleStageInput): Promise