From 37c352c1f7cf7037e87031532e9d20b526aa34cb Mon Sep 17 00:00:00 2001 From: James Date: Sat, 25 Jul 2026 05:00:50 +0000 Subject: [PATCH 1/2] fix(audio): preserve causes and use portable padding --- packages/engine/src/index.ts | 4 + .../engine/src/services/audioMixer.test.ts | 216 ++++++++++++++- packages/engine/src/services/audioMixer.ts | 254 +++++++++++++++--- .../engine/src/services/audioMixer.types.ts | 34 +++ .../engine/src/services/captureWarning.ts | 24 ++ packages/engine/src/services/frameCapture.ts | 11 +- packages/engine/src/types.ts | 4 + .../src/services/distributed/plan.test.ts | 58 +++- .../producer/src/services/distributed/plan.ts | 20 +- .../render/renderEventPublisher.test.ts | 97 +++++++ .../services/render/renderEventPublisher.ts | 11 +- .../services/render/stages/audioStage.test.ts | 14 + .../src/services/render/stages/audioStage.ts | 14 +- .../services/render/stages/captureHdrStage.ts | 13 +- .../src/services/renderOrchestrator.ts | 40 ++- 15 files changed, 733 insertions(+), 81 deletions(-) create mode 100644 packages/engine/src/services/captureWarning.ts diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5996ee7a29..0353cf3125 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -198,8 +198,12 @@ export { export { createVideoFrameInjector } from "./services/videoFrameInjector.js"; export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js"; +export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js"; export type { AudioElement, + AudioFailureReason, + AudioFailureStage, + AudioProcessingFailure, AudioTrack, AudioVolumeKeyframe, MixResult, diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 3ac56fdbc2..296d074282 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -29,9 +30,10 @@ const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => { }; }); -vi.mock("../utils/runFfmpeg.js", () => ({ - runFfmpeg: runFfmpegMock, -})); +vi.mock("../utils/runFfmpeg.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runFfmpeg: runFfmpegMock }; +}); import { parseAudioElements, processCompositionAudio } from "./audioMixer.js"; @@ -79,6 +81,8 @@ describe("processCompositionAudio", () => { expect(filter).toContain("volume=0"); expect(filter).toContain("[mixed]volume=1[out]"); + expect(filter).toContain("apad,atrim=0:2"); + expect(filter).not.toContain("whole_dur"); expect(filter).not.toContain("normalize="); expect(filter).not.toContain("weights="); }); @@ -157,7 +161,9 @@ describe("processCompositionAudio", () => { return { success: !isMissingCuePrepare, durationMs: 1, - stderr: isMissingCuePrepare ? "Invalid data found when processing input" : "", + stderr: isMissingCuePrepare + ? "https://media.example.test/private.wav?token=secret /tmp/hf/private secret.wav: Invalid data found when processing input" + : "", exitCode: isMissingCuePrepare ? 1 : 0, }; }); @@ -194,10 +200,205 @@ describe("processCompositionAudio", () => { expect(result.success).toBe(false); expect(result.tracksProcessed).toBe(1); - expect(result.error).toMatch(/Prepare failed: missing-cue/); + expect(result.error).toContain("Invalid data found when processing input"); + expect(result.error).toContain(""); + expect(result.error).toContain(""); + expect(result.error).not.toContain("token=secret"); + expect(result.error).not.toContain("/tmp/hf/private"); + expect(result.error).not.toContain("secret.wav"); + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "prepare", + reason: "invalid_media", + owner: "user", + retryable: false, + elementId: "missing-cue", + }), + ]); expect(runFfmpegMock).toHaveBeenCalledTimes(2); }); + it("preserves and classifies unsupported FFmpeg filter failures", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "voice.wav"), "stub"); + + runFfmpegMock + .mockResolvedValueOnce({ + success: true, + durationMs: 1, + stderr: "", + exitCode: 0, + terminationReason: "exit", + }) + .mockResolvedValueOnce({ + success: false, + durationMs: 1, + stderr: "Error applying option 'whole_dur': Option not found", + exitCode: 8, + terminationReason: "exit", + }); + + const result = await processCompositionAudio( + [ + { + id: "voice", + src: "voice.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("Option not found"); + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "mix", + reason: "ffmpeg_unsupported", + owner: "system", + retryable: false, + }), + ]); + }); + + it("preserves a sanitized FFmpeg spawn failure cause", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "voice.wav"), "stub"); + + runFfmpegMock.mockResolvedValueOnce({ + success: false, + durationMs: 1, + stderr: "", + exitCode: null, + terminationReason: "spawn_error", + error: new Error("spawn C:\\private\\ffmpeg.exe ENOENT"), + }); + + const result = await processCompositionAudio( + [ + { + id: "voice", + src: "voice.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("ENOENT"); + expect(result.error).toContain(""); + expect(result.error).not.toContain("C:\\private\\ffmpeg.exe"); + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "prepare", + reason: "ffmpeg_unavailable", + owner: "system", + retryable: true, + }), + ]); + }); + + it("keeps invalid data from producer-generated mix inputs system-owned", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "voice.wav"), "stub"); + + runFfmpegMock + .mockResolvedValueOnce({ + success: true, + durationMs: 1, + stderr: "", + exitCode: 0, + terminationReason: "exit", + }) + .mockResolvedValueOnce({ + success: false, + durationMs: 1, + stderr: "Invalid data found when processing input", + exitCode: 1, + terminationReason: "exit", + }); + + const result = await processCompositionAudio( + [ + { + id: "voice", + src: "voice.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "mix", + reason: "ffmpeg_failed", + owner: "system", + retryable: false, + }), + ]); + }); + + it("bounds per-cause details and the aggregate error across many authored IDs", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + const oversizedId = "authored-id-".repeat(300); + + const result = await processCompositionAudio( + Array.from({ length: 3 }, (_, index) => ({ + id: `${oversizedId}-${index}`, + src: `missing-${index}.wav`, + start: 0, + end: 2, + mediaStart: 0, + layer: index, + volume: 1, + type: "audio" as const, + })), + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.success).toBe(false); + expect(result.error?.length).toBeLessThanOrEqual(2_000); + expect(result.failures).toHaveLength(3); + expect(result.failures?.every((failure) => failure.detail.length <= 2_000)).toBe(true); + }); + it("uses frame-evaluated volume automation when keyframes are present", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); @@ -402,7 +603,10 @@ describe("processCompositionAudio", () => { const filter = capturedFilterScripts.at(-1); expect(filter).toContain(`amix=inputs=${trackCount}`); - expect((filter?.match(/atrim=/g) ?? []).length).toBe(trackCount); + // Each track is trimmed once to its authored clip and once after portable + // indefinite `apad` to cap the padded stream at composition duration. + expect((filter?.match(/atrim=/g) ?? []).length).toBe(trackCount * 2); + expect((filter?.match(/apad,/g) ?? []).length).toBe(trackCount); }); it("retries with the current file-valued filter option when a nightly removes the legacy alias", async () => { diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index b3b2188666..e5d420066a 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file complexity code-duplication /** * Audio Mixer Service * @@ -10,11 +11,17 @@ import { parseHTML } from "linkedom"; import { extractAudioMetadata } from "../utils/ffprobe.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; -import { runFfmpeg } from "../utils/runFfmpeg.js"; +import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js"; import { unwrapTemplate } from "../utils/htmlTemplate.js"; import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js"; import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js"; -import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js"; +import type { + AudioElement, + AudioFailureStage, + AudioProcessingFailure, + AudioTrack, + MixResult, +} from "./audioMixer.types.js"; import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js"; export type { AudioElement, MixResult } from "./audioMixer.types.js"; @@ -190,6 +197,99 @@ interface ExtractResult { outputPath: string; durationMs: number; error?: string; + failure?: AudioProcessingFailure; +} + +function boundedDetail(message: string, maxLength = 2_000): string { + const redacted = message + .replace(/\bhttps?:\/\/[^\s"'<>]+/gi, "") + .replace(/\bfile:\/\/[^\s"'<>]+/gi, "") + .replace( + /\b[A-Za-z]:[\\/].+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, + "", + ) + .replace( + /(^|[\s"'(])\/.+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm, + "$1", + ); + return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 1)}…`; +} + +function probeFailure(message: string, elementId: string): AudioProcessingFailure { + const unavailable = /(?:not found|ENOENT|spawn)/i.test(message); + const timedOut = /(?:timed?\s*out|timeout|deadline|inactivity|aborted)/i.test(message); + const invalidMedia = + /(?:invalid data found|could not find codec parameters|moov atom not found|no audio stream)/i.test( + message, + ); + return { + stage: "probe", + reason: invalidMedia ? "invalid_media" : unavailable ? "ffmpeg_unavailable" : "probe_failed", + owner: invalidMedia ? "user" : "system", + retryable: !invalidMedia && (unavailable || timedOut), + elementId, + detail: boundedDetail(`Audio probe failed for element ${elementId}: ${message}`), + }; +} + +function downloadFailure(message: string, elementId: string): AudioProcessingFailure { + const invalidSource = + /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test( + message, + ); + return { + stage: "download", + reason: "download_failed", + owner: invalidSource ? "user" : "system", + retryable: !invalidSource, + elementId, + detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`), + }; +} + +function ffmpegFailure( + stage: Extract, + result: RunFfmpegResult, + elementId?: string, +): AudioProcessingFailure { + const stderr = result.stderr ?? ""; + let reason: AudioProcessingFailure["reason"] = "ffmpeg_failed"; + let owner: AudioProcessingFailure["owner"] = "system"; + let retryable = false; + + if (result.terminationReason === "abort") { + reason = "cancelled"; + owner = "user"; + } else if (result.terminationReason === "deadline" || result.terminationReason === "inactivity") { + reason = "ffmpeg_timeout"; + retryable = true; + } else if (result.terminationReason === "spawn_error") { + reason = "ffmpeg_unavailable"; + retryable = true; + } else if ( + /(?:unrecognized option|option (?:was )?not found|no option name near)/i.test(stderr) + ) { + reason = "ffmpeg_unsupported"; + } else if ( + (stage === "extract" || stage === "prepare") && + /(?:invalid data found|could not find codec parameters|moov atom not found)/i.test(stderr) + ) { + reason = "invalid_media"; + owner = "user"; + } + + return { + stage, + reason, + owner, + retryable, + elementId, + detail: boundedDetail( + result.error?.message + ? `${formatFfmpegError(result.exitCode, stderr)}: ${result.error.message}` + : formatFfmpegError(result.exitCode, stderr), + ), + }; } export function parseAudioElements(html: string): AudioElement[] { @@ -266,20 +366,29 @@ async function extractAudioFromVideo( const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }); if (signal?.aborted) { + const failure: AudioProcessingFailure = { + stage: "cancelled", + reason: "cancelled", + owner: "user", + retryable: false, + detail: "Audio extract cancelled", + }; return { success: false, outputPath, durationMs: result.durationMs, - error: "Audio extract cancelled", + error: failure.detail, + failure, }; } if (!result.success) { + const failure = ffmpegFailure("extract", result); return { success: false, outputPath, durationMs: result.durationMs, - error: - result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr, + error: failure.detail, + failure, }; } return { success: true, outputPath, durationMs: result.durationMs }; @@ -317,22 +426,28 @@ async function prepareAudioTrack( const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }); if (signal?.aborted) { + const failure: AudioProcessingFailure = { + stage: "cancelled", + reason: "cancelled", + owner: "user", + retryable: false, + detail: "Audio prepare cancelled", + }; return { success: false, outputPath, durationMs: result.durationMs, - error: "Audio prepare cancelled", + error: failure.detail, + failure, }; } + const failure = !result.success ? ffmpegFailure("prepare", result) : undefined; return { success: result.success, outputPath, durationMs: result.durationMs, - error: !result.success - ? result.exitCode !== null - ? `FFmpeg exited with code ${result.exitCode}: ${result.stderr.slice(-200)}` - : result.stderr - : undefined, + error: failure?.detail, + failure, }; } @@ -362,22 +477,28 @@ async function generateSilence( const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }); if (signal?.aborted) { + const failure: AudioProcessingFailure = { + stage: "cancelled", + reason: "cancelled", + owner: "user", + retryable: false, + detail: "Silence generation cancelled", + }; return { success: false, outputPath, durationMs: result.durationMs, - error: "Silence generation cancelled", + error: failure.detail, + failure, }; } + const failure = !result.success ? ffmpegFailure("silence", result) : undefined; return { success: result.success, outputPath, durationMs: result.durationMs, - error: !result.success - ? result.exitCode !== null - ? `FFmpeg exited with code ${result.exitCode}` - : result.stderr - : undefined, + error: failure?.detail, + failure, }; } @@ -399,6 +520,7 @@ async function mixAudioTracks( durationMs: result.durationMs, tracksProcessed: 0, error: result.error, + failures: result.failure ? [result.failure] : undefined, }; } @@ -412,7 +534,7 @@ async function mixAudioTracks( const trimDuration = track.end - track.start; const volumeFilter = buildVolumeExpression(track, ignoreAutomation); filterParts.push( - `[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`, + `[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`, ); }); @@ -499,16 +621,26 @@ async function mixAudioTracks( durationMs: result.durationMs, tracksProcessed: 0, error: "Audio mix cancelled", + failures: [ + { + stage: "cancelled", + reason: "cancelled", + owner: "user", + retryable: false, + detail: "Audio mix cancelled", + }, + ], }; } if (!result.success) { + const failure = ffmpegFailure("mix", result); return { success: false, outputPath, durationMs: result.durationMs, tracksProcessed: 0, - error: - result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr, + error: failure.detail, + failures: [failure], }; } return { @@ -534,14 +666,21 @@ export async function processCompositionAudio( ): Promise { const startMs = Date.now(); const tracks: AudioTrack[] = []; - const errors: string[] = []; + const failures: AudioProcessingFailure[] = []; if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true }); await Promise.all( elements.map(async (element) => { if (signal?.aborted) { - errors.push(`Cancelled: ${element.id}`); + failures.push({ + stage: "cancelled", + reason: "cancelled", + owner: "user", + retryable: false, + elementId: element.id, + detail: boundedDetail(`Cancelled audio element ${element.id}`), + }); return; } try { @@ -556,21 +695,36 @@ export async function processCompositionAudio( try { srcPath = await downloadToTemp(srcPath, workDir); } catch (err: unknown) { - errors.push( - `Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`, + failures.push( + downloadFailure(err instanceof Error ? err.message : String(err), element.id), ); return; } } if (!existsSync(srcPath)) { - errors.push(`Source not found: ${element.id} (${element.src})`); + failures.push({ + stage: "source", + reason: "source_not_found", + owner: "user", + retryable: false, + elementId: element.id, + detail: boundedDetail(`Source not found for audio element ${element.id}`), + }); return; } // Fallback: if no duration was specified, probe the actual file if (element.end - element.start <= 0) { - const metadata = await extractAudioMetadata(srcPath); + let metadata; + try { + metadata = await extractAudioMetadata(srcPath); + } catch (err: unknown) { + failures.push( + probeFailure(err instanceof Error ? err.message : String(err), element.id), + ); + return; + } const effectiveDuration = metadata.durationSeconds - element.mediaStart; element.end = element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds); @@ -590,7 +744,18 @@ export async function processCompositionAudio( config, ); if (!extractResult.success) { - errors.push(`Extract failed: ${element.id}`); + failures.push( + extractResult.failure + ? { ...extractResult.failure, elementId: element.id } + : { + stage: "extract", + reason: "ffmpeg_failed", + owner: "system", + retryable: false, + elementId: element.id, + detail: boundedDetail(`Audio extract failed for element ${element.id}`), + }, + ); return; } audioSrcPath = extractedPath; @@ -605,7 +770,18 @@ export async function processCompositionAudio( config, ); if (!prepResult.success) { - errors.push(`Prepare failed: ${element.id}`); + failures.push( + prepResult.failure + ? { ...prepResult.failure, elementId: element.id } + : { + stage: "prepare", + reason: "ffmpeg_failed", + owner: "system", + retryable: false, + elementId: element.id, + detail: boundedDetail(`Audio prepare failed for element ${element.id}`), + }, + ); return; } audioSrcPath = trimmedPath; @@ -636,7 +812,18 @@ export async function processCompositionAudio( volumeKeyframes: bakedEnvelope ? undefined : element.volumeKeyframes, }); } catch (err: unknown) { - errors.push(`Error: ${element.id} — ${err instanceof Error ? err.message : String(err)}`); + failures.push({ + stage: "internal", + reason: "internal", + owner: "system", + retryable: false, + elementId: element.id, + detail: boundedDetail( + `Audio processing failed for element ${element.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + }); } }), ); @@ -645,7 +832,7 @@ export async function processCompositionAudio( // The producer only surfaces audio failures when `success` is false; mixing // the remaining tracks made the omitted cue indistinguishable from a valid // render unless someone manually audited that exact audio window. - if (errors.length > 0) { + if (failures.length > 0) { try { rmSync(workDir, { recursive: true, force: true }); } catch { @@ -656,7 +843,10 @@ export async function processCompositionAudio( outputPath, durationMs: Date.now() - startMs, tracksProcessed: tracks.length, - error: `Audio processing failed: ${errors.join(", ")}`, + error: boundedDetail( + `Audio processing failed: ${failures.map((failure) => failure.detail).join(", ")}`, + ), + failures, }; } @@ -671,6 +861,6 @@ export async function processCompositionAudio( return { ...mixResult, durationMs: Date.now() - startMs, - error: errors.length > 0 ? `Warnings: ${errors.join(", ")}` : mixResult.error, + error: mixResult.error, }; } diff --git a/packages/engine/src/services/audioMixer.types.ts b/packages/engine/src/services/audioMixer.types.ts index e3cf1f6ad6..a599e1d675 100644 --- a/packages/engine/src/services/audioMixer.types.ts +++ b/packages/engine/src/services/audioMixer.types.ts @@ -26,10 +26,44 @@ export interface AudioTrack { volumeKeyframes?: AudioVolumeKeyframe[]; } +export type AudioFailureStage = + | "source" + | "download" + | "probe" + | "extract" + | "prepare" + | "mix" + | "silence" + | "cancelled" + | "internal"; + +export type AudioFailureReason = + | "source_not_found" + | "download_failed" + | "probe_failed" + | "invalid_media" + | "ffmpeg_unsupported" + | "ffmpeg_timeout" + | "ffmpeg_unavailable" + | "ffmpeg_failed" + | "cancelled" + | "internal"; + +export interface AudioProcessingFailure { + stage: AudioFailureStage; + reason: AudioFailureReason; + owner: "user" | "system"; + retryable: boolean; + elementId?: string; + /** Bounded diagnostic text; never includes the authored source URL/path. */ + detail: string; +} + export interface MixResult { success: boolean; outputPath: string; durationMs: number; tracksProcessed: number; error?: string; + failures?: AudioProcessingFailure[]; } diff --git a/packages/engine/src/services/captureWarning.ts b/packages/engine/src/services/captureWarning.ts new file mode 100644 index 0000000000..844b3602f3 --- /dev/null +++ b/packages/engine/src/services/captureWarning.ts @@ -0,0 +1,24 @@ +import type { CaptureWarning } from "../types.js"; + +/** Clone every mutable field before a warning crosses an async or ownership boundary. */ +export function cloneCaptureWarning(warning: T): T { + return { + ...warning, + details: warning.details + ? { + ...warning.details, + sources: warning.details.sources ? [...warning.details.sources] : undefined, + failureReasons: warning.details.failureReasons + ? [...warning.details.failureReasons] + : undefined, + failureStages: warning.details.failureStages + ? [...warning.details.failureStages] + : undefined, + } + : undefined, + } as T; +} + +export function cloneCaptureWarnings(warnings: readonly T[]): T[] { + return warnings.map(cloneCaptureWarning); +} diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 12b0d4f8da..af977c3a61 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -55,6 +55,7 @@ import type { CaptureWarning, SubTimelineWaitOutcome, } from "../types.js"; +import { cloneCaptureWarnings } from "./captureWarning.js"; export { isMemoryExhaustionError, isTransientBrowserError } from "./captureFailure.js"; export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary }; @@ -3773,15 +3774,7 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95), p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99), subTimelineWaitOutcome: session.subTimelineWaitOutcome, - warnings: session.warnings.map((warning) => ({ - ...warning, - details: warning.details - ? { - ...warning.details, - sources: warning.details.sources ? [...warning.details.sources] : undefined, - } - : undefined, - })), + warnings: cloneCaptureWarnings(session.warnings), staticDedupReused: session.staticDedupCount ?? 0, staticDedupEnabled: session.staticDedupEnabled ?? false, // armed ⟺ a non-empty static set survived verification; predicted === its size. diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 483a7b4de0..cc467c3289 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -29,6 +29,10 @@ export interface CaptureWarning { mediaType?: "image" | "video" | "audio"; sources?: string[]; timeoutMs?: number; + failureReasons?: string[]; + failureStages?: string[]; + failureOwner?: "user" | "system"; + retryable?: boolean; }; } diff --git a/packages/producer/src/services/distributed/plan.test.ts b/packages/producer/src/services/distributed/plan.test.ts index d005e23e04..ec2266495e 100644 --- a/packages/producer/src/services/distributed/plan.test.ts +++ b/packages/producer/src/services/distributed/plan.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication /** * Unit tests for `services/distributed/plan.ts`. * @@ -76,8 +77,63 @@ describe("distributed warning policy", () => { it("rejects distributed audio degradation in best-effort mode", () => { const job = createJob("best-effort"); - expect(() => applyDistributedAudioWarningPolicy(job, "mix failed")).toThrow(RenderQualityError); + expect(() => + applyDistributedAudioWarningPolicy(job, "mix failed", [ + { + stage: "mix", + reason: "ffmpeg_unsupported", + owner: "system", + retryable: false, + detail: "Option not found", + }, + ]), + ).toThrow(RenderQualityError); expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]); + expect(job.warnings[0]?.details).toEqual( + expect.objectContaining({ + failureReasons: ["ffmpeg_unsupported"], + failureStages: ["mix"], + failureOwner: "system", + retryable: false, + }), + ); + }); + + it("only marks a multi-cause audio failure retryable when every cause is retryable", () => { + const job = createJob("best-effort"); + expect(() => + applyDistributedAudioWarningPolicy(job, "mixed failure", [ + { + stage: "download", + reason: "download_failed", + owner: "system", + retryable: true, + detail: "temporary download failure", + }, + { + stage: "prepare", + reason: "invalid_media", + owner: "user", + retryable: false, + detail: "invalid media", + }, + ]), + ).toThrow(RenderQualityError); + expect(job.warnings[0]?.details).toEqual( + expect.objectContaining({ + failureOwner: "system", + retryable: false, + }), + ); + }); + + it("does not invent ownership or retryability for legacy untyped failures", () => { + const job = createJob("best-effort"); + expect(() => applyDistributedAudioWarningPolicy(job, "legacy failure")).toThrow( + RenderQualityError, + ); + expect(job.warnings[0]?.details?.failureOwner).toBeUndefined(); + expect(job.warnings[0]?.details?.retryable).toBeUndefined(); }); }); diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index 8f11d7bd55..796e7fb3e5 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -42,6 +42,7 @@ import { getEncoderPreset, normalizeVp9CpuUsed, resolveConfig, + type AudioProcessingFailure, } from "@hyperframes/engine"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { @@ -269,15 +270,30 @@ export interface PlanResult { export function applyDistributedAudioWarningPolicy( job: RenderJob, audioError: string, + audioFailures: readonly AudioProcessingFailure[] = [], log: ProducerLogger = defaultLogger, ): void { + const failureOwner = + audioFailures.length === 0 + ? undefined + : audioFailures.some((failure) => failure.owner === "system") + ? "system" + : "user"; + const retryable = + audioFailures.length === 0 ? undefined : audioFailures.every((failure) => failure.retryable); applyRenderWarningPolicy( job, [ { code: "audio_processing_failed", message: `Audio mix failed; output would be video-only: ${audioError}`, - details: { mediaType: "audio" }, + details: { + mediaType: "audio", + failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))], + failureStages: [...new Set(audioFailures.map((failure) => failure.stage))], + failureOwner, + retryable, + }, }, ], log, @@ -943,7 +959,7 @@ export async function plan( assertNotAborted, }); if (audioResult.audioError) { - applyDistributedAudioWarningPolicy(job, audioResult.audioError, log); + applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log); } // Promote staged artifacts from the temp work tree into the final planDir diff --git a/packages/producer/src/services/render/renderEventPublisher.test.ts b/packages/producer/src/services/render/renderEventPublisher.test.ts index 1d01101f00..bbe7dc4a2e 100644 --- a/packages/producer/src/services/render/renderEventPublisher.test.ts +++ b/packages/producer/src/services/render/renderEventPublisher.test.ts @@ -33,6 +33,38 @@ describe("OrderedRenderEventPublisher", () => { ]); }); + it("deep-clones typed warning arrays across the progress sink boundary", async () => { + const deliveredReasons: string[][] = []; + const publisher = new OrderedRenderEventPublisher( + async (snapshot) => { + const reasons = snapshot.warnings[0]?.details?.failureReasons; + if (reasons) { + deliveredReasons.push([...reasons]); + reasons.push("sink_mutation"); + } + }, + { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, + ); + const job = createRenderJob({ fps: 30, quality: "high" }); + job.warnings.push({ + code: "audio_processing_failed", + message: "audio failed", + stage: "capture-readiness", + details: { + mediaType: "audio", + failureReasons: ["ffmpeg_timeout"], + failureStages: ["prepare"], + }, + }); + + publisher.publish(job, "warning"); + job.warnings[0]!.details!.failureReasons!.push("source_mutation"); + await publisher.flush(); + + expect(deliveredReasons).toEqual([["ffmpeg_timeout"]]); + expect(job.warnings[0]?.details?.failureReasons).toEqual(["ffmpeg_timeout", "source_mutation"]); + }); + it("contains sink rejection and still delivers the terminal event", async () => { const delivered: number[] = []; const warn = vi.fn(); @@ -147,6 +179,71 @@ describe("updateJobStatus", () => { ).toThrow(RenderQualityError); expect(job.config.strictness).toBe("best-effort"); expect(job.warnings).toHaveLength(1); + expect(log.warn).toHaveBeenCalledWith( + "Render completed capture with correctness warnings", + expect.objectContaining({ warningRetryable: undefined }), + ); + }); + + it("logs bounded audio failure taxonomy without requiring raw stderr", () => { + const job = createRenderJob({ fps: 30, quality: "high" }); + const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + expect(() => + applyRenderWarningPolicy( + job, + [ + { + code: "audio_processing_failed", + message: "raw diagnostic stays on the warning object", + details: { + mediaType: "audio", + failureReasons: ["ffmpeg_timeout"], + failureStages: ["prepare"], + failureOwner: "system", + retryable: true, + }, + }, + ], + log, + ), + ).toThrow(RenderQualityError); + expect(log.warn).toHaveBeenCalledWith( + "Render completed capture with correctness warnings", + expect.objectContaining({ + warningCodes: ["audio_processing_failed"], + warningReasons: ["ffmpeg_timeout"], + warningStages: ["prepare"], + warningOwners: ["system"], + warningRetryable: true, + }), + ); + }); + + it("only logs an aggregate warning as retryable when every typed warning is retryable", () => { + const job = createRenderJob({ fps: 30, quality: "high" }); + const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + expect(() => + applyRenderWarningPolicy( + job, + [ + { + code: "audio_processing_failed", + message: "temporary audio failure", + details: { mediaType: "audio", retryable: true }, + }, + { + code: "media_load_failed", + message: "terminal media failure", + details: { mediaType: "video", retryable: false }, + }, + ], + log, + ), + ).toThrow(RenderQualityError); + expect(log.warn).toHaveBeenCalledWith( + "Render completed capture with correctness warnings", + expect.objectContaining({ warningRetryable: false }), + ); }); it("fails explicitly strict renders on correctness warnings", () => { diff --git a/packages/producer/src/services/render/renderEventPublisher.ts b/packages/producer/src/services/render/renderEventPublisher.ts index 861872250f..bcbc67f649 100644 --- a/packages/producer/src/services/render/renderEventPublisher.ts +++ b/packages/producer/src/services/render/renderEventPublisher.ts @@ -1,3 +1,4 @@ +import { cloneCaptureWarnings } from "@hyperframes/engine"; import type { ProducerLogger } from "../../logger.js"; import type { ProgressCallback, RenderJob } from "../renderOrchestrator.js"; import { updateJobStatus } from "./shared.js"; @@ -5,15 +6,7 @@ import { updateJobStatus } from "./shared.js"; function snapshotJob(job: RenderJob): RenderJob { return { ...job, - warnings: job.warnings.map((warning) => ({ - ...warning, - details: warning.details - ? { - ...warning.details, - sources: warning.details.sources ? [...warning.details.sources] : undefined, - } - : undefined, - })), + warnings: cloneCaptureWarnings(job.warnings), }; } diff --git a/packages/producer/src/services/render/stages/audioStage.test.ts b/packages/producer/src/services/render/stages/audioStage.test.ts index 1f218a2f60..38bbc82967 100644 --- a/packages/producer/src/services/render/stages/audioStage.test.ts +++ b/packages/producer/src/services/render/stages/audioStage.test.ts @@ -53,12 +53,25 @@ describe("runAudioStage", () => { durationMs: 1, tracksProcessed: 0, error: "Source not found: a1 (narration.wav)", + failures: [ + { + stage: "source", + reason: "source_not_found", + owner: "user", + retryable: false, + elementId: "a1", + detail: "Source not found for audio element a1", + }, + ], }); const result = await runAudioStage(makeInput()); expect(result.hasAudio).toBe(false); expect(result.audioError).toBe("Source not found: a1 (narration.wav)"); + expect(result.audioFailures).toEqual([ + expect.objectContaining({ reason: "source_not_found", stage: "source" }), + ]); }); it("falls back to a generic message when the mixer fails without an error string", async () => { @@ -95,5 +108,6 @@ describe("runAudioStage", () => { expect(processCompositionAudioMock).not.toHaveBeenCalled(); expect(result.hasAudio).toBe(false); expect(result.audioError).toBeUndefined(); + expect(result.audioFailures).toBeUndefined(); }); }); diff --git a/packages/producer/src/services/render/stages/audioStage.ts b/packages/producer/src/services/render/stages/audioStage.ts index 488a6e99a6..34da4c0d72 100644 --- a/packages/producer/src/services/render/stages/audioStage.ts +++ b/packages/producer/src/services/render/stages/audioStage.ts @@ -16,7 +16,7 @@ */ import { join } from "node:path"; -import { processCompositionAudio } from "@hyperframes/engine"; +import { processCompositionAudio, type AudioProcessingFailure } from "@hyperframes/engine"; import type { CompositionMetadata } from "../shared.js"; export interface AudioStageInput { @@ -45,6 +45,8 @@ export interface AudioStageResult { * both when there was no audio to mix and when the mix succeeded. */ audioError?: string; + /** Bounded typed causes for policy, telemetry, and caller classification. */ + audioFailures?: AudioProcessingFailure[]; } export async function runAudioStage(input: AudioStageInput): Promise { @@ -55,6 +57,7 @@ export async function runAudioStage(input: AudioStageInput): Promise 0) { const audioResult = await processCompositionAudio( @@ -70,6 +73,7 @@ export async function runAudioStage(input: AudioStageInput): Promise ({ - ...warning, - details: warning.details - ? { - ...warning.details, - sources: warning.details.sources ? [...warning.details.sources] : undefined, - } - : undefined, - })); -} - export async function runCaptureHdrStage( input: CaptureHdrStageInput, ): Promise { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index c9a0c81292..7e17bcf946 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -82,6 +82,7 @@ import { applyConcreteGpuScreenshotClamp, scaleProtocolTimeoutForComposition, classifyCaptureFailure, + cloneCaptureWarning, isMemoryExhaustionError, isDrawElementVerificationError, getDrawElementVerificationDetails, @@ -613,22 +614,28 @@ export function applyRenderWarningPolicy( if (existing.has(key)) continue; existing.add(key); job.warnings.push({ - ...warning, + ...cloneCaptureWarning(warning), stage: "capture-readiness", - details: warning.details - ? { - ...warning.details, - sources: warning.details.sources ? [...warning.details.sources] : undefined, - } - : undefined, }); } if (job.warnings.length === 0) return; const strictness = job.config.strictness ?? "best-effort"; + const typedRetryability = job.warnings.flatMap((warning) => + warning.details?.retryable === undefined ? [] : [warning.details.retryable], + ); log.warn("Render completed capture with correctness warnings", { strictness, warningCodes: job.warnings.map((warning) => warning.code), + warningReasons: job.warnings.flatMap((warning) => warning.details?.failureReasons ?? []), + warningStages: job.warnings.flatMap((warning) => warning.details?.failureStages ?? []), + warningOwners: job.warnings.flatMap((warning) => + warning.details?.failureOwner ? [warning.details.failureOwner] : [], + ), + warningRetryable: + typedRetryability.length === 0 + ? undefined + : typedRetryability.every((retryable) => retryable), }); const hasAudioProcessingFailure = job.warnings.some( (warning) => warning.code === "audio_processing_failed", @@ -2184,13 +2191,30 @@ async function executeRenderPipeline(input: { const { audioOutputPath, hasAudio } = audioResult; perfStages.audioProcessMs = audioResult.audioProcessMs; if (audioResult.audioError) { + const audioFailures = audioResult.audioFailures ?? []; + const failureOwner = + audioFailures.length === 0 + ? undefined + : audioFailures.some((failure) => failure.owner === "system") + ? "system" + : "user"; + const retryable = + audioFailures.length === 0 + ? undefined + : audioFailures.every((failure) => failure.retryable); applyRenderWarningPolicy( job, [ { code: "audio_processing_failed", message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`, - details: { mediaType: "audio" }, + details: { + mediaType: "audio", + failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))], + failureStages: [...new Set(audioFailures.map((failure) => failure.stage))], + failureOwner, + retryable, + }, }, ], log, From b550865136a65877c395cb2279c60ea2c6ace1b2 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 25 Jul 2026 19:13:46 +0000 Subject: [PATCH 2/2] fix(audio): address failure taxonomy review --- .../engine/src/services/audioMixer.test.ts | 69 ++++++++++++++++++- packages/engine/src/services/audioMixer.ts | 17 +++-- .../src/services/captureWarning.test.ts | 27 ++++++++ .../producer/src/services/audioExtractor.ts | 2 +- .../src/services/render/audioPadTrim.ts | 2 +- 5 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 packages/engine/src/services/captureWarning.test.ts diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 296d074282..88c389479d 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -11,10 +11,16 @@ import { tmpdir } from "node:os"; // filter content synchronously, while the file still exists, into an // index-aligned side array (rather than re-reading it from disk after // processCompositionAudio resolves, by which point it's already gone). -const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => { +const { runFfmpegMock, capturedFilterScripts, extractAudioMetadataMock } = vi.hoisted(() => { const capturedFilterScripts: string[] = []; return { capturedFilterScripts, + extractAudioMetadataMock: vi.fn(async () => ({ + durationSeconds: 2, + sampleRate: 48_000, + channels: 2, + audioCodec: "aac", + })), runFfmpegMock: vi.fn(async (args: string[]) => { const legacyIdx = args.indexOf("-filter_complex_script"); const currentIdx = args.indexOf("-/filter_complex"); @@ -35,6 +41,11 @@ vi.mock("../utils/runFfmpeg.js", async (importOriginal) => { return { ...actual, runFfmpeg: runFfmpegMock }; }); +vi.mock("../utils/ffprobe.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, extractAudioMetadata: extractAudioMetadataMock }; +}); + import { parseAudioElements, processCompositionAudio } from "./audioMixer.js"; describe("processCompositionAudio", () => { @@ -42,12 +53,68 @@ describe("processCompositionAudio", () => { afterEach(() => { runFfmpegMock.mockClear(); + extractAudioMetadataMock.mockReset(); + extractAudioMetadataMock.mockResolvedValue({ + durationSeconds: 2, + sampleRate: 48_000, + channels: 2, + audioCodec: "aac", + }); capturedFilterScripts.length = 0; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); + it.each([ + { + message: "AbortError: ffprobe operation aborted", + reason: "cancelled", + owner: "user", + retryable: false, + }, + { + message: "ffprobe timed out after inactivity deadline", + reason: "ffmpeg_timeout", + owner: "system", + retryable: true, + }, + ] as const)("classifies probe failure '$reason' independently", async (expected) => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + writeFileSync(join(baseDir, "voice.wav"), "stub"); + extractAudioMetadataMock.mockRejectedValueOnce(new Error(expected.message)); + + const result = await processCompositionAudio( + [ + { + id: "voice", + src: "voice.wav", + start: 0, + end: 0, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.failures).toEqual([ + expect.objectContaining({ + stage: "probe", + reason: expected.reason, + owner: expected.owner, + retryable: expected.retryable, + }), + ]); + }); + it("preserves muted tracks and uses unity master gain by default", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index e5d420066a..c228289751 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -217,16 +217,25 @@ function boundedDetail(message: string, maxLength = 2_000): string { function probeFailure(message: string, elementId: string): AudioProcessingFailure { const unavailable = /(?:not found|ENOENT|spawn)/i.test(message); - const timedOut = /(?:timed?\s*out|timeout|deadline|inactivity|aborted)/i.test(message); + const cancelled = /(?:aborted|AbortError|cancelled|canceled)/i.test(message); + const timedOut = /(?:timed?\s*out|timeout|deadline|inactivity)/i.test(message); const invalidMedia = /(?:invalid data found|could not find codec parameters|moov atom not found|no audio stream)/i.test( message, ); return { stage: "probe", - reason: invalidMedia ? "invalid_media" : unavailable ? "ffmpeg_unavailable" : "probe_failed", - owner: invalidMedia ? "user" : "system", - retryable: !invalidMedia && (unavailable || timedOut), + reason: cancelled + ? "cancelled" + : invalidMedia + ? "invalid_media" + : unavailable + ? "ffmpeg_unavailable" + : timedOut + ? "ffmpeg_timeout" + : "probe_failed", + owner: cancelled || invalidMedia ? "user" : "system", + retryable: !cancelled && !invalidMedia && (unavailable || timedOut), elementId, detail: boundedDetail(`Audio probe failed for element ${elementId}: ${message}`), }; diff --git a/packages/engine/src/services/captureWarning.test.ts b/packages/engine/src/services/captureWarning.test.ts new file mode 100644 index 0000000000..2f5931b9e4 --- /dev/null +++ b/packages/engine/src/services/captureWarning.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { cloneCaptureWarning } from "./captureWarning.js"; + +describe("cloneCaptureWarning", () => { + it("deep-clones every mutable warning-detail array", () => { + const source = { + code: "audio_processing_failed" as const, + message: "audio failed", + details: { + sources: ["voice.wav"], + failureReasons: ["ffmpeg_timeout"], + failureStages: ["prepare"], + }, + }; + + const clone = cloneCaptureWarning(source); + clone.details.sources.push("clone-source.wav"); + clone.details.failureReasons.push("clone-reason"); + clone.details.failureStages.push("clone-stage"); + + expect(source.details).toEqual({ + sources: ["voice.wav"], + failureReasons: ["ffmpeg_timeout"], + failureStages: ["prepare"], + }); + }); +}); diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index 07f16c8143..a57207ad97 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -207,7 +207,7 @@ async function mixTracks( const trimDuration = track.duration > 0 ? track.duration : totalDuration; filterParts.push( - `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`, + `[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad,atrim=0:${totalDuration}[a${i}]`, ); }); diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 2beb0bd419..a9a05668e3 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -246,7 +246,7 @@ function concatFileLine(path: string): string { // `///C:/…`, which Windows path parsing then rejects). Field- // signal reports ts=1784169914 / 1784177061 / 1784177375 (all // win32/x64 CLI 0.7.59; the last isolated the module's arg shape - // vs a working manual `apad=whole_dur` command). + // vs a working manual pad/trim command). // 2. Bare `/tmp/…` when the concat script was fed via `pipe:0` — // FFmpeg's URL joiner resolves absolute POSIX paths against the // base `pipe:` URL, producing `pipe:/tmp/…` which the demuxer