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"),
+ `
+
+