Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/engine/src/services/audioMixer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/engine/src/services/audioMixer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
7 changes: 5 additions & 2 deletions packages/producer/src/services/audioExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> elements", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-crossorigin-img-"));
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html><html><body>
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
<img id="hero" src="https://example.com/photo.jpg" crossorigin="anonymous" alt="" />
<img id="plain" src="local.jpg" alt="" />
</div>
</body></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 <video> elements", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-crossorigin-video-"));
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html><html><body>
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
<video id="clip" src="https://example.com/clip.mp4" crossorigin="anonymous" data-start="0" data-duration="1"></video>
</div>
</body></html>`,
);

const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);

expect(compiled.html).not.toContain("crossorigin");
expect(compiled.html).toContain('id="clip"');
});
});

describe("discoverAudioVolumeAutomationFromTimeline", () => {
it("samples video-derived audio volume without firing GSAP callbacks", async () => {
class TestAudioElement {}
Expand Down
6 changes: 6 additions & 0 deletions packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ async function compileHtmlFile(
// origins (e.g. S3 without CORS headers) keep readyState=0, blocking page setup.
compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");

// Strip crossorigin from img elements. The renderer captures DOM frames visually —
// no canvas readback — so CORS compliance is unnecessary. External images from
// CORS-restricted origins (e.g. S3) render blank when crossorigin forces a failed
// CORS request against the renderer's localhost file server.
compiledHtml = compiledHtml.replace(/(<img\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");

return { html: compiledHtml, unresolvedCompositions };
}

Expand Down
Loading