diff --git a/src/cli/CliExportRunner.audio.test.ts b/src/cli/CliExportRunner.audio.test.ts new file mode 100644 index 0000000000..2cdbd5fbe4 --- /dev/null +++ b/src/cli/CliExportRunner.audio.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; +import { axcutSchemaVersion } from "@/lib/ai-edition/schema"; +import { buildNativeClipList } from "./CliExportRunner"; + +const document: AxcutDocument = { + schemaVersion: axcutSchemaVersion, + project: { + id: "project", + title: "Audio mute", + createdAt: "2026-10-12T00:00:00.000Z", + updatedAt: "2026-10-12T00:00:00.000Z", + primaryAssetId: "recording", + }, + assets: [ + { + id: "recording", + kind: "video", + label: "recording.mp4", + originalPath: "/tmp/recording.mp4", + cameraTrack: null, + durationSec: 4, + video: { codec: "h264", width: 1920, height: 1080, fps: 30 }, + }, + ], + transcript: null, + timeline: { + clips: [ + { + id: "clip", + assetId: "recording", + sourceStartSec: 0, + sourceEndSec: 4, + timelineStartSec: 0, + timelineEndSec: 4, + wordRefs: [], + origin: "user", + reason: "", + }, + ], + gaps: [], + trimRanges: [], + muteRanges: [], + speedRanges: [], + captionRanges: [], + }, + annotations: [], + zoomRanges: [], + audioTracks: [], + transcripts: [], + legacyEditor: null, +}; + +describe("CLI recording audio mute", () => { + it("keeps clip audio by default and disables it only for a muted project", () => { + expect(buildNativeClipList(document)[0]?.hasAudio).toBe(true); + expect( + buildNativeClipList({ + ...document, + legacyEditor: { recordingAudioMuted: true }, + })[0]?.hasAudio, + ).toBe(false); + }); +}); diff --git a/src/cli/CliExportRunner.tsx b/src/cli/CliExportRunner.tsx index 85b0668519..27cff7088c 100644 --- a/src/cli/CliExportRunner.tsx +++ b/src/cli/CliExportRunner.tsx @@ -80,7 +80,9 @@ function replaceExtension(filePath: string, newExtension: string): string { /** Mirrors ExportDialog.buildNativeClipList: trim-narrowed visible clips mapped * onto the native multiclip contract. Kept in lock-step with * buildSceneDescription so export and scene agree on the clip stream. */ -function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[] { +/** @internal Exported for the focused clip-audio policy test. */ +export function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[] { + const recordingAudioMuted = getEditorSettings(axcutDocument).recordingAudioMuted; const assetById = new Map(axcutDocument.assets.map((asset) => [asset.id, asset])); return resolveVisibleClips(axcutDocument).flatMap((clip) => { const asset = assetById.get(clip.assetId); @@ -96,7 +98,7 @@ function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[ sourceStartSec: clip.sourceStartSec, sourceEndSec, webcamOffsetSec: camera.offsetSec, - hasAudio: true, + hasAudio: !recordingAudioMuted, }, ]; }); diff --git a/src/components/ai-edition/ExportDialog.params.test.tsx b/src/components/ai-edition/ExportDialog.params.test.tsx index d78ad091c2..4e2de3e66f 100644 --- a/src/components/ai-edition/ExportDialog.params.test.tsx +++ b/src/components/ai-edition/ExportDialog.params.test.tsx @@ -142,6 +142,16 @@ describe("ExportDialog MP4 params", () => { expect(at30).toMatchObject({ fps: 30, bitrate: expected(30) }); expect(at30?.bitrate).toBeLessThan(at60?.bitrate ?? 0); }); + + it.each([false, true])("sets clip hasAudio when recording mute is %s", async (muted) => { + renderDialog({ + ...DOC, + legacyEditor: { ...DOC.legacyEditor, recordingAudioMuted: muted }, + }); + await exportMp4(); + const clips = vi.mocked(exportMultiNative).mock.calls.at(-1)?.[0]; + expect(clips?.[0]?.hasAudio).toBe(!muted); + }); }); describe("ExportDialog format settings", () => { diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index a336901d85..0857319b86 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -111,6 +111,7 @@ function revealExportedFile(filePath: string): void { * each with its asset's screen file + camera file (falls back to the screen when a clip has * no camera — the no-webcam layout is a later step) and its source trim. */ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] { + const recordingAudioMuted = getEditorSettings(document).recordingAudioMuted; const assetById = new Map(document.assets.map((a) => [a.id, a])); return resolveVisibleClips(document).flatMap((clip) => { const asset = assetById.get(clip.assetId); @@ -122,9 +123,8 @@ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] { // the single canonical precedence used by every consumer (clip.probe → asset.duration // → timeline-length guess). See `resolveClipSourceEndSec` for the full order. const sourceEndSec = resolveClipSourceEndSec(clip, asset); - // ponytail: `hasAudio` stays optimistic for the same reason as in - // `buildSceneDescription` — nothing populates `asset.audio` yet, and the - // native side degrades cleanly on a stream-less file. + // `asset.audio` is not populated by the probe yet, so keep the current + // optimistic default unless the project explicitly mutes its recording audio. return [ { screenPath: asset.originalPath, @@ -132,7 +132,7 @@ function buildNativeClipList(document: AxcutDocument): CompositorClipInput[] { sourceStartSec: clip.sourceStartSec, sourceEndSec, webcamOffsetSec: camera.offsetSec, - hasAudio: true, + hasAudio: !recordingAudioMuted, }, ]; }); diff --git a/src/components/ai-edition/RightPanes.audioPane.test.tsx b/src/components/ai-edition/RightPanes.audioPane.test.tsx new file mode 100644 index 0000000000..e55206192e --- /dev/null +++ b/src/components/ai-edition/RightPanes.audioPane.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; + +const { setSettings } = vi.hoisted(() => ({ setSettings: vi.fn() })); + +vi.mock("@/lib/ai-edition/store/useEditorSettings", () => ({ + useEditorSettings: () => ({ + settings: { audioGainDb: -6, recordingAudioMuted: true }, + set: setSettings, + setLive: vi.fn(), + commit: vi.fn(), + hasDocument: true, + }), +})); + +import { AudioPane } from "./RightPanes"; + +beforeEach(() => { + localStorage.clear(); + localStorage.setItem(LOCALE_STORAGE_KEY, "en"); + setSettings.mockClear(); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe("AudioPane reset button", () => { + it("resets output gain and unmutes recording audio", () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Reset audio" })); + + expect(setSettings).toHaveBeenCalledWith({ audioGainDb: 0, recordingAudioMuted: false }); + }); +}); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 17f3350487..cff4294439 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3462,11 +3462,29 @@ export function AudioPane() { onCommit={() => void commit()} /> +
+ {ts("audio.muteRecording")} + void set({ recordingAudioMuted: checked })} + /> +
diff --git a/src/components/ai-edition/VirtualPreview.audio.test.ts b/src/components/ai-edition/VirtualPreview.audio.test.ts index d87e65816a..f8fc87cb96 100644 --- a/src/components/ai-edition/VirtualPreview.audio.test.ts +++ b/src/components/ai-edition/VirtualPreview.audio.test.ts @@ -142,6 +142,38 @@ describe("applyPreviewAudioSettings", () => { applyPreviewAudioSettings(null, [element], 0, 6.0206); expect(element.volume).toBe(1); }); + + it("mutes routed and direct-output recording elements without changing output gain", () => { + const graph = fakeGraph(); + const primary = { volume: 0.2 } as HTMLAudioElement; + const supplemental = { volume: 0.2 } as HTMLAudioElement; + const directFallback = { volume: 0.2 } as HTMLAudioElement; + const fallbackGain = { gain: { value: 1 } } as GainNode; + applyPreviewAudioSettings( + graph, + [primary, supplemental, directFallback], + 6.0206, + 9.5424, + true, + { + routedElements: new Set([primary, supplemental]), + fallbackGainNodes: new Map([[supplemental, fallbackGain]]), + }, + ); + + expect(graph.gain.gain.value).toBeCloseTo(2, 3); + expect(graph.voice.gain.value).toBe(0); + expect(primary.volume).toBe(1); + expect(supplemental.volume).toBe(1); + expect(fallbackGain.gain.value).toBe(0); + expect(directFallback.volume).toBe(0); + }); + + it("silences newly mounted direct-output sources before WebAudio is available", () => { + const element = { volume: 1 } as HTMLAudioElement; + applyPreviewAudioSettings(null, [element], 6.0206, 9.5424, true); + expect(element.volume).toBe(0); + }); }); describe("previewCeilingCurve", () => { diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx index c2be743922..019b7a1b6d 100644 --- a/src/components/ai-edition/VirtualPreview.playback.test.tsx +++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx @@ -2,7 +2,13 @@ import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AxcutClip, AxcutTrimRange } from "@/lib/ai-edition/schema"; +import type { + AxcutAudioTrack, + AxcutClip, + AxcutDocument, + AxcutTrimRange, +} from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import type { SpeedRegion } from "@/lib/ai-edition/timeline/speed"; import { type VideoSource, VirtualPreview } from "./VirtualPreview"; @@ -13,6 +19,7 @@ let frameCallbacks: FrameRequestCallback[] = []; beforeEach(() => { frameCallbacks = []; + useProjectStore.setState({ document: null, projectId: null }); vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { frameCallbacks.push(cb); return frameCallbacks.length; @@ -25,6 +32,7 @@ beforeEach(() => { afterEach(() => { cleanup(); + useProjectStore.setState({ document: null, projectId: null }); vi.unstubAllGlobals(); }); @@ -307,7 +315,7 @@ describe("VirtualPreview imported audio tracks", () => { // in the export. `element.volume` caps at 1, so the boost has to ride a WebAudio gain node. // jsdom has no WebAudio, so install a minimal fake context and watch the nodes it mints. class FakeAudioNode { - connect = vi.fn(); + connect = vi.fn((_destination?: unknown) => undefined); disconnect = vi.fn(); } class FakeGainNode extends FakeAudioNode { @@ -315,21 +323,55 @@ class FakeGainNode extends FakeAudioNode { } let createdGains: FakeGainNode[] = []; let createdShapers: Array = []; +let voiceGainNodes = new Set(); +let failSourceCreationFor = new Set(); +let failVoiceConnectionFor = new Set(); +let failAnalyserConnectionFor = new Set(); +let sourceAttempts: Array<{ testId: string; src: string; volume: number }> = []; +let createdSources: Array<{ testId: string; source: FakeAudioNode }> = []; class FakeAudioContext { state = "running"; destination = new FakeAudioNode(); + private gainNodes: FakeGainNode[] = []; + private analyserNode: FakeAudioNode | null = null; resume = vi.fn(() => Promise.resolve()); close = vi.fn(() => Promise.resolve()); - createMediaElementSource = vi.fn(() => new FakeAudioNode()); + createMediaElementSource = vi.fn((element: HTMLAudioElement) => { + const testId = element.getAttribute("data-testid") ?? ""; + sourceAttempts.push({ testId, src: element.src, volume: element.volume }); + if (failSourceCreationFor.has(testId)) throw new Error("source creation failed"); + const source = new FakeAudioNode(); + createdSources.push({ testId, source }); + if (failVoiceConnectionFor.has(testId) || failAnalyserConnectionFor.has(testId)) { + source.connect.mockImplementation((destination) => { + if (failVoiceConnectionFor.has(testId) && destination === this.gainNodes[2]) { + throw new Error("voice connection failed"); + } + if (failAnalyserConnectionFor.has(testId) && destination instanceof FakeGainNode) { + destination.connect.mockImplementation((output) => { + if (output === this.analyserNode) throw new Error("analyser connection failed"); + }); + } + }); + } + return source; + }); createGain = vi.fn(() => { const node = new FakeGainNode(); + this.gainNodes.push(node); + if (this.gainNodes.length === 3) voiceGainNodes.add(node); createdGains.push(node); return node; }); // The music ducker listens to the voice; silence here, so nothing ducks. - createAnalyser = vi.fn(() => - Object.assign(new FakeAudioNode(), { fftSize: 2048, getFloatTimeDomainData: vi.fn() }), - ); + createAnalyser = vi.fn(() => { + const node = Object.assign(new FakeAudioNode(), { + fftSize: 2048, + getFloatTimeDomainData: vi.fn(), + }); + this.analyserNode = node; + return node; + }); createWaveShaper = vi.fn(() => { const node = Object.assign(new FakeAudioNode(), { curve: null }); createdShapers.push(node); @@ -341,6 +383,12 @@ describe("VirtualPreview imported audio track boost", () => { beforeEach(() => { createdGains = []; createdShapers = []; + voiceGainNodes = new Set(); + failSourceCreationFor = new Set(); + failVoiceConnectionFor = new Set(); + failAnalyserConnectionFor = new Set(); + sourceAttempts = []; + createdSources = []; vi.stubGlobal("AudioContext", FakeAudioContext); }); @@ -416,6 +464,191 @@ describe("VirtualPreview imported audio track boost", () => { expect(gain.connect).not.toHaveBeenCalledWith(speakers); } }); + + it("mutes only the recording when source creation and connection fail independently", async () => { + useProjectStore.setState({ + document: { legacyEditor: { recordingAudioMuted: true } } as unknown as AxcutDocument, + }); + failSourceCreationFor.add("preview-audio-primary"); + failVoiceConnectionFor.add("preview-audio-supplemental"); + const music: AxcutAudioTrack = { + id: "music-track", + assetId: "music", + kind: "music", + startMs: 0, + endMs: 4000, + durationSec: 10, + offsetMs: 0, + gainDb: 6.0206, + loop: false, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + label: "", + origin: "user", + }; + const voiceover: AxcutAudioTrack = { + ...music, + id: "voiceover-track", + assetId: "voiceover", + kind: "voiceover", + gainDb: 3, + }; + window.electronAPI = { + preparePreviewAudioTrack: vi.fn(async (path: string) => ({ + success: true, + path: `${path}.mic.wav`, + })), + } as unknown as Window["electronAPI"]; + const videoSources: VideoSource[] = [ + { + id: "a1", + src: "file:///tmp/a1.mp4", + filePath: "/tmp/a1.mp4", + label: "recording", + }, + ]; + const audioSources: VideoSource[] = [ + { id: "music", src: "file:///tmp/music.mp3", label: "music" }, + { id: "voiceover", src: "file:///tmp/voiceover.mp3", label: "voiceover" }, + ]; + const { container } = render( + , + ); + const primary = container.querySelector( + "[data-testid='preview-audio-primary']", + ); + if (!primary) throw new Error("no primary recording audio element"); + const video = container.querySelector("video"); + if (!video) throw new Error("no preview video"); + driveVideo(video as HTMLVideoElement); + act(() => fireEvent.loadedMetadata(video)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const supplemental = container.querySelector( + "[data-testid='preview-audio-supplemental']", + ); + if (!supplemental) throw new Error("no supplemental recording audio element"); + for (const id of ["music-track", "voiceover-track"]) { + const audio = container.querySelector( + `[data-testid='preview-audio-track-${id}']`, + ); + if (!audio) throw new Error(`no imported audio element for ${id}`); + driveAudioEl(audio); + } + tick(); + + expect(primary.volume).toBe(0); + expect(supplemental.volume).toBe(1); + expect([...voiceGainNodes][0]?.gain.value).toBe(0); + const supplementalSource = createdSources.find( + (entry) => entry.testId === "preview-audio-supplemental", + )?.source; + const fallbackGain = supplementalSource?.connect.mock.calls.at(-1)?.[0] as + | FakeGainNode + | undefined; + expect(fallbackGain?.gain.value).toBe(0); + for (const testId of [ + "preview-audio-track-music-track", + "preview-audio-track-voiceover-track", + ]) { + const source = createdSources.find((entry) => entry.testId === testId)?.source; + const trackGain = source?.connect.mock.calls.at(-1)?.[0] as FakeGainNode | undefined; + expect(trackGain?.gain.value).toBeGreaterThan(0); + } + }); + + it("remounts a new recording source muted before its graph route is ready", async () => { + useProjectStore.setState({ + document: { legacyEditor: { recordingAudioMuted: true } } as unknown as AxcutDocument, + }); + window.electronAPI = { + preparePreviewAudioTrack: vi.fn(async (path: string) => ({ + success: true, + path: `${path}.mic.wav`, + })), + } as unknown as Window["electronAPI"]; + const videoSources: VideoSource[] = [ + { id: "a1", src: "file:///tmp/a1.mp4", filePath: "/tmp/a1.mp4", label: "first" }, + { id: "a2", src: "file:///tmp/a2.mp4", filePath: "/tmp/a2.mp4", label: "second" }, + ]; + const clips = [clip("c1", "a1", 0, 4, 0), clip("c2", "a2", 0, 4, 4)]; + const view = render( + , + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(sourceAttempts.find((attempt) => attempt.src.includes("a1.mp4"))?.volume).toBe(0); + + failSourceCreationFor.add("preview-audio-primary"); + await act(async () => { + view.rerender( + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + const activePrimary = view.container.querySelector( + "[data-testid='preview-audio-primary']", + ); + if (!activePrimary) throw new Error("no remounted primary recording audio element"); + expect(activePrimary.src).toContain("a2.mp4"); + expect(sourceAttempts.find((attempt) => attempt.src.includes("a2.mp4"))?.volume).toBe(0); + expect(activePrimary.volume).toBe(0); + }); + + it("disconnects a partial fallback when the analyser route fails after output connects", async () => { + useProjectStore.setState({ + document: { legacyEditor: { recordingAudioMuted: true } } as unknown as AxcutDocument, + }); + failVoiceConnectionFor.add("preview-audio-primary"); + failAnalyserConnectionFor.add("preview-audio-primary"); + const videoSources: VideoSource[] = [ + { id: "a1", src: "file:///tmp/a1.mp4", filePath: "/tmp/a1.mp4", label: "recording" }, + ]; + const { container } = render( + , + ); + const video = container.querySelector("video"); + if (!video) throw new Error("no preview video"); + driveVideo(video as HTMLVideoElement); + act(() => fireEvent.loadedMetadata(video)); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const source = createdSources.find((entry) => entry.testId === "preview-audio-primary")?.source; + if (!source) throw new Error("no primary recording source"); + const fallbackGain = source.connect.mock.calls.at(-1)?.[0] as FakeGainNode | undefined; + if (!fallbackGain) throw new Error("no source-only fallback gain"); + expect(fallbackGain.connect.mock.results.map((result) => result.type)).toEqual([ + "return", + "throw", + ]); + expect(fallbackGain.disconnect).toHaveBeenCalled(); + expect(source.disconnect.mock.calls.length).toBeGreaterThanOrEqual(3); + }); }); // The recording's sound is a second element over the same file: a muted