fix: synth silence bed for audio-less produce sources

Audio-less inputs no longer emit [0:a] maps that ffmpeg rejects; the worker
branches to an anullsrc bed configured at the output preset's rate/layout.
Producer tests 42/42.
This commit is contained in:
glass beadian 2026-10-15 21:27:09 +00:00
parent 613aba236b
commit 5808e89dde
6 changed files with 130 additions and 8 deletions

View file

@ -43,6 +43,17 @@ function makeClip(path: string): void {
function key(bytes: Buffer): string { return `sha256/${createHash("sha256").update(bytes).digest("hex")}.mp4`; }
/**
* The audio-less class from the bug report: video only, no lavfi sine, no audio
* track at all (muted screen recording / GIF conversion / synthetic test clip).
*/
function makeSilentClip(path: string): void {
const r = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "testsrc2=s=320x180:r=30:d=2",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-movflags", "+faststart", path]);
if (r.status !== 0) throw new Error(`silent fixture encode failed: ${r.stderr?.toString() ?? ""}`);
}
function doubles() {
const events: Array<{ type: string; jobId?: string; detail?: string }> = [];
const jobs: Array<{ jobId: string; state: string; reason?: string; output?: { storageKey: string; producedAssetId: string } }> = [];
@ -132,6 +143,32 @@ test("compose job: encode, publish, derived asset, complete", { skip: !hasFfmpeg
assert.ok(body !== undefined && body.subarray(4, 8).toString("latin1") === "ftyp");
});
test("an audio-less source produces output WITH audio (synthetic bed), not a [0:a] crash", { skip: !hasFfmpeg() || !hasFont() ? "needs ffmpeg + a ttf font" : false }, async () => {
const d = doubles();
const clip = join(SCRATCH, "source-silent.mp4");
makeSilentClip(clip);
const bytes = await import("node:fs/promises").then((fs) => fs.readFile(clip));
const inputKey = key(bytes);
d.objects.set(inputKey, bytes);
// hasAudio:false is what the pg store reports for a probed audio-less asset.
d.setQueue([job(inputKey, "compose",
{ overlays: [{ kind: "lower_third", text: "Ada Lovelace", position: { x: 8, y: 85 } }], lane: "local" },
{ hasAudio: false, durationMs: 2000 })]);
await runner(d).pollOnce();
assert.equal(d.jobs[0]?.state, "complete", d.jobs[0]?.reason);
assert.ok(d.jobs[0]?.output?.storageKey.startsWith("sha256/"));
// The produced mp4 must actually carry an audio stream — the whole point of the bed.
const body = d.objects.get(d.published()!.key);
assert.ok(body !== undefined && body.subarray(4, 8).toString("latin1") === "ftyp");
const probe = spawnSync("ffprobe", ["-v", "error", "-print_format", "json", "-show_streams", "-i", `pipe:0`],
{ input: body, encoding: "buffer" });
const streams = JSON.parse(probe.stdout.toString("utf8")).streams as Array<{ codec_type: string }>;
assert.ok(streams.some((s) => s.codec_type === "audio"), "output must have an audio stream");
assert.ok(streams.some((s) => s.codec_type === "video"), "output must keep its video stream");
});
test("a mix job with a voiceover runs the ducking path end to end", { skip: !hasFfmpeg() || !hasFont() ? "needs ffmpeg + a ttf font" : false }, async () => {
const d = doubles();
const clip = join(SCRATCH, "source-mix.mp4");