diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index f60fc7a71..8305d6d44 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -63,6 +63,66 @@ describe("processCompositionAudio", () => { expect(filter).toContain("volume=0"); expect(filter).toContain("[mixed]volume=1[out]"); + expect(filter).not.toContain("normalize="); + expect(filter).not.toContain("weights="); + }); + + it("compensates amix normalization so multi-track master gain equals track count", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + + writeFileSync(join(baseDir, "a.wav"), "stub"); + writeFileSync(join(baseDir, "b.wav"), "stub"); + writeFileSync(join(baseDir, "c.wav"), "stub"); + + const result = await processCompositionAudio( + [ + { + id: "a", + src: "a.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 0, + volume: 0.8, + type: "audio", + }, + { + id: "b", + src: "b.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 1, + volume: 1, + type: "audio", + }, + { + id: "c", + src: "c.wav", + start: 0, + end: 2, + mediaStart: 0, + layer: 2, + volume: 0.5, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 2, + ); + + expect(result.success).toBe(true); + const mixArgs = runFfmpegMock.mock.calls[1]?.[0]; + const filter = mixArgs[mixArgs.indexOf("-filter_complex") + 1]; + + expect(filter).toContain("amix=inputs=3"); + expect(filter).not.toContain("normalize="); + // masterOutputGain(1) × tracks(3) = 3 + expect(filter).toContain("[mixed]volume=3[out]"); }); it("uses frame-evaluated volume automation when keyframes are present", async () => { diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index bb31a7099..b47d6171c 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -398,9 +398,11 @@ async function mixAudioTracks( }); const mixInputs = tracks.map((_, i) => `[a${i}]`).join(""); - const weights = tracks.map(() => "1").join(" "); - const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0:normalize=0:weights='${weights}'[mixed]`; - const postMixGainFilter = `[mixed]volume=${masterOutputGain}[out]`; + const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0[mixed]`; + // amix divides output by inputs count (default normalize=true). Multiply master + // gain by track count so per-track volumes authored in data-volume are preserved. + const compensatedGain = masterOutputGain * tracks.length; + const postMixGainFilter = `[mixed]volume=${formatFilterNumber(compensatedGain)}[out]`; const fullFilter = [...filterParts, mixFilter, postMixGainFilter].join(";"); return [ diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index cffff0ddb..ec810d026 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -211,8 +211,11 @@ async function mixTracks( }); const mixInputs = tracks.map((_, i) => `[a${i}]`).join(""); - const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:normalize=0[out]`; - const fullFilter = [...filterParts, mixFilter].join(";"); + // amix divides by track count by default (normalize=true). Compensate with + // a volume gain to preserve per-track levels across all FFmpeg versions. + const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest[mixed]`; + const postMixGain = `[mixed]volume=${tracks.length}[out]`; + const fullFilter = [...filterParts, mixFilter, postMixGain].join(";"); const args = [ ...inputs, diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index c83d55048..8372e0c33 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -797,6 +797,51 @@ describe("text-rendering rule injection", () => { }); }); +// ── crossorigin stripping ─────────────────────────────────────────────────── +// +// External images/videos with crossorigin="anonymous" force CORS-mode requests +// against the renderer's localhost file server. S3 and similar origins reject +// those requests, so the element renders blank. The strip removes the attribute +// so the browser falls back to no-cors (visual-only) mode. + +describe("crossorigin attribute stripping", () => { + it("strips crossorigin from elements", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-crossorigin-img-")); + writeFileSync( + join(projectDir, "index.html"), + ` +
+ + +
+`, + ); + + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + + expect(compiled.html).not.toContain('crossorigin="anonymous"'); + expect(compiled.html).toContain('id="hero"'); + expect(compiled.html).toContain('id="plain"'); + }); + + it("strips crossorigin from