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

@ -169,6 +169,31 @@ test("a source without audio mixes the voiceover over a synthetic silence bed",
assert.ok(g.includes("duration=longest"));
});
test("an audio-less source never references [0:a]: the bed is a valid source-filter chain", () => {
// No voiceover: the synthetic bed alone is the output's audio.
const bare = graph({ input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: 2, audioBed: "none" } });
const bareChains = bare.split(";");
// anullsrc is a source filter, so aformat follows a COMMA — the missing comma
// produced `anullsrc=...aformat=...`, which ffmpeg rejects with "Option
// 'sample_rates' not found".
assert.equal(bareChains.at(-1), `anullsrc=r=${48000}:cl=stereo,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[aout]`);
assert.ok(!bare.includes("[0:a]"), "an audio-less source must never be referenced as [0:a]");
// Ducking over silence: bedRaw gets the same comma-separated form.
const duck = graph({ input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: 2, audioBed: "none" },
voiceover: { path: `${SCRATCH}/${VO_HASH}.m4a`, gainDb: -6, duck: true } });
const duckChains = duck.split(";");
const bedRaw = duckChains.find((c) => c.endsWith("[bedRaw]"));
assert.ok(bedRaw !== undefined, "ducking path must still produce [bedRaw]");
assert.ok(bedRaw.startsWith(`anullsrc=r=${48000}:cl=stereo,aformat=`), "synthetic bed + aformat must be comma-separated");
assert.ok(duck.includes("duration=longest"), "a synthetic bed mixes to longest, bounded by -t");
assert.ok(!duck.includes("[0:a]"));
// An audio-bearing source keeps the input-pad form (no comma after [0:a]).
const withAudio = graph({ input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: 2, audioBed: "source" } });
assert.ok(withAudio.includes("[0:a]aformat=sample_fmts=fltp"), "the source bed stays an input pad chain");
});
test("a synthetic bed must be bounded by the source duration", () => {
assert.throws(() => graph({
input: { path: `${SCRATCH}/${HASH}.mp4`, durationSec: null, audioBed: "none" },

View file

@ -265,12 +265,33 @@ export interface ProducePlan {
/** ffmpeg input indexes: source 0, then each logo, then the voiceover — argv order. */
export interface ProduceInputIndex { logos: number[]; voiceover: number | null }
/** `aformat` as a filter step (a real input's chain: comma-separated, after a comma). */
function audioFormat(): string {
return `aformat=sample_fmts=fltp:sample_rates=${PRODUCE_PRESET.sampleRate}:channel_layouts=stereo`;
}
/**
* The synthetic silence bed for an audio-less source. anullsrc is a source
* filter, so it cannot be followed by `aformat` — it is configured directly with
* the output preset's rate and stereo layout, and the encoder's `-ar`/channel
* handling downstream already guarantees the format.
*/
function silenceBed(): string {
return `anullsrc=r=${PRODUCE_PRESET.sampleRate}:cl=stereo`;
}
function gain(gainDb: number): string { return `volume=${gainDb}dB`; }
/**
* One bed chain: `[0:a]` is an input pad, so `aformat` follows it directly, but
* the synthetic bed is a source filter, so it needs the `,` separator before
* `aformat` (the missing comma is what made the audio-less path emit
* `anullsrc=...aformat=...`, which ffmpeg rejects outright).
*/
function bedChain(bed: string, label: string): string {
return `${bed}${bed.startsWith("[") ? "" : ","}${audioFormat()}[${label}]`;
}
/**
* The `-filter_complex` string: video chain (scale → overlays in request order) and
* the audio mix. `min(px,W-tw)`-style expressions are single-quoted because `min`
@ -312,10 +333,10 @@ export function buildProduceFilter(plan: ProducePlan, index: ProduceInputIndex):
// on the bed. `duration=first` ends with the source bed; a synthetic bed is
// unbounded, so it mixes to `longest` and only the source's `-t` can end the encode.
const vo = plan.voiceover;
const bed = plan.input.audioBed === "source" ? "[0:a]" : `anullsrc=r=${PRODUCE_PRESET.sampleRate}:cl=stereo`;
const bed = plan.input.audioBed === "source" ? "[0:a]" : silenceBed();
if (vo === null) {
chains.push(`${bed}${audioFormat()}[aout]`);
chains.push(bedChain(bed, "aout"));
return chains.join(";");
}
const voIndex = index.voiceover ?? fail("voiceover input index required");
@ -324,13 +345,13 @@ export function buildProduceFilter(plan: ProducePlan, index: ProduceInputIndex):
if (vo.duck) {
// asplit feeds the same voiceover to the compressor key and to the mix.
chains.push(`${voIn}${audioFormat()},asplit=2[voMix][voKey]`);
chains.push(`${bed}${audioFormat()}[bedRaw]`);
chains.push(bedChain(bed, "bedRaw"));
chains.push(`[bedRaw][voKey]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300:makeup=1:link=average[duck]`);
if (vo.gainDb !== null && vo.gainDb !== 0) chains.push(`[voMix]${gain(vo.gainDb)}[voGain]`);
const mixIn = vo.gainDb !== null && vo.gainDb !== 0 ? "voGain" : "voMix";
chains.push(`[duck][${mixIn}]amix=inputs=2:duration=${amixDuration}:dropout_transition=2:normalize=0[aout]`);
} else {
chains.push(`${bed}${audioFormat()}[bedFmt]`);
chains.push(bedChain(bed, "bedFmt"));
chains.push(`${voIn}${audioFormat()}${vo.gainDb === null || vo.gainDb === 0 ? "" : `,${gain(vo.gainDb)}`}[voGain]`);
chains.push(`[bedFmt][voGain]amix=inputs=2:duration=${amixDuration}:dropout_transition=2:normalize=0[aout]`);
}

View file

@ -140,6 +140,33 @@ test("claim goes through the definer function with typed lane + lease params", a
assert.equal(job!.request.overlays[0]!.kind, "lower_third");
});
test("a probed audio-less asset reports hasAudio:false so the runner uses a silent bed", async () => {
// The probe writes duration_ms and audio_codec together: audio_codec NULL with
// a duration means probed-and-silent (muted screen recording, GIF conversion).
// Before the fix this mapped to hasAudio:null → audioBed "source" → ffmpeg
// "Stream specifier ':a' matches no streams".
const pool = new FakePool();
pool.expectClient({ rows: [{ ...assetRow(), audio_codec: null }] });
pool.reply([claimRow()]);
const job = await storeOf(pool).claim("local");
assert.ok(job !== null);
assert.equal(job.source.hasAudio, false, "probed + audio_codec NULL must be hasAudio:false");
// The runner turns hasAudio===false into audioBed "none" (synthetic silence).
});
test("an unprobed asset (NULL duration AND NULL codec) stays hasAudio:null", async () => {
const pool = new FakePool();
pool.expectClient({ rows: [{ ...assetRow(), audio_codec: null, duration_ms: null }] });
pool.reply([claimRow()]);
const job = await storeOf(pool).claim("local");
assert.ok(job !== null);
assert.equal(job.source.hasAudio, null, "unprobed is unknown, not audio-less — the source bed stays");
});
test("claim passes the lane through: a remote worker binds lane='remote'", async () => {
const { pool } = healthyClaim();
await storeOf(pool).claim("remote");

View file

@ -47,6 +47,11 @@ function uuid(value: unknown, field: string): string {
return text;
}
/** True once 009's probe job has run: the probe writes duration + codecs together, so duration≠NULL means probed. */
function probed(row: Record<string, unknown>): boolean {
return row.duration_ms !== null && row.duration_ms !== undefined;
}
function nullableNumber(value: unknown): number | null {
if (value === null || value === undefined) return null;
const n = typeof value === "number" ? value : Number(value);
@ -63,9 +68,11 @@ function sourceFromRow(row: Record<string, unknown>): ProduceSourceAsset {
height: nullableNumber(row.height),
durationMs: row.duration_ms === null || row.duration_ms === undefined ? null : Math.round(Number(row.duration_ms)),
frameRate: nullableNumber(row.frame_rate),
// Probed "no audio track" (009 stores an empty codec) → a silent bed, so the
// output always has an audio stream.
hasAudio: row.audio_codec === null || row.audio_codec === undefined ? null : String(row.audio_codec) !== "",
// An unprobed asset has NULL codec AND NULL duration (009's probe fills all
// of them at once); a probed one always has a duration. So the probed
// audio-less case is duration≠NULL + audio_codec NULL/'' — which must become
// a silent bed, or the graph references [0:a] on a file with no audio track.
hasAudio: probed(row) ? String(row.audio_codec ?? "") !== "" : null,
};
}

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");

View file

@ -171,7 +171,12 @@ export class ProduceRunner {
};
}
const durationSec = job.source.durationMs === null ? null : job.source.durationMs / 1000;
// A probed asset without an audio track gets a silent bed, so the output always has audio.
// An audio-less source (muted screen recording, GIF conversion, synthetic
// test clip) must not feed [0:a] into the graph — ffmpeg dies with "Stream
// specifier ':a' matches no streams". hasAudio===false means probed and
// audio-less; null means the probe never ran, where the source bed stays
// (the [0:a] reference then fails loudly rather than silently dropping audio
// from a file that may well have a track).
const audioBed = job.source.hasAudio === false ? "none" : "source";
return {
input: { path: source, durationSec, audioBed },