From 4965b96e109e081e32fdb11262236e3a37048439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:22:53 +0800 Subject: [PATCH 1/2] feat: mute a recording's own audio in the editor Add a project setting that silences the recording in the preview and the export while voiceovers, music and click sounds keep playing. The Audio pane owns the toggle, its Reset clears it, and setEditorSettings reads and writes it. Closes #1104 --- electron/ai-edition/deep-agent/service.ts | 2 +- .../ai-edition/editor-settings-tool.test.ts | 2 + electron/ai-edition/editor-settings-tool.ts | 2 + .../ai-edition/ExportDialog.params.test.tsx | 10 + src/components/ai-edition/ExportDialog.tsx | 8 +- .../ai-edition/RightPanes.audioPane.test.tsx | 45 ++++ src/components/ai-edition/RightPanes.tsx | 20 +- .../ai-edition/VirtualPreview.audio.test.ts | 32 +++ .../VirtualPreview.playback.test.tsx | 245 +++++++++++++++++- src/components/ai-edition/VirtualPreview.tsx | 126 ++++++++- src/i18n/locales/ar/settings.json | 2 + src/i18n/locales/cs/settings.json | 2 + src/i18n/locales/de/settings.json | 2 + src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ja-JP/settings.json | 2 + src/i18n/locales/ko-KR/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 2 + src/i18n/locales/tr/settings.json | 2 + src/i18n/locales/vi/settings.json | 2 + src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + .../ai-edition/store/editorSettings.test.ts | 12 + src/lib/ai-edition/store/editorSettings.ts | 9 + .../store/useEditorSettings.test.ts | 19 +- src/native/sceneDescription.test.ts | 86 +++++- src/native/sceneDescription.ts | 18 +- .../architecture/mcp-server.md | 2 +- 31 files changed, 633 insertions(+), 35 deletions(-) create mode 100644 src/components/ai-edition/RightPanes.audioPane.test.tsx diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index aea2aaf828..72d873de65 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -265,7 +265,7 @@ export const TOOL_DESCRIPTIONS: Record = { setAspectRatio: "Set the shape of the exported video: 16:9 for X, YouTube or a slide, 9:16 for a vertical story or short, 1:1 or 4:5 for a square feed, auto to follow the recording, or one of the clips' own shapes (getCurrentDocument's settingsContext.originalAspectRatios). Annotations on the frame keep their place and shape. The same as setEditorSettings with aspectRatio alone.", setEditorSettings: - 'Change the project\'s look: pass only the settings to change, named as getCurrentDocument returns them under `settings`. Background: wallpaper (a bundled path from settingsContext.bundledWallpapers, a CSS colour or gradient, or a base64 PNG/JPEG data URL; never a file path or web URL; an embedded image reads back as a marker, leave the key out to keep it), wallpaperMotion (moves a gradient or image only), backgroundBlur 0–1. Frame: aspectRatio ("auto", a preset, or an originalAspectRatios token; a fixed format picked for the first time fills the frame with a window that follows the cursor, which formatFollowCursor turns off when settingsContext.formatFill is "available"), padding 0–100 %, borderRadius 0–64 px, shadowIntensity 0–1, frame (none, window, laptop, phone, monitor) and frameTheme (light, dark), motionBlurAmount 0–1, depthOfField (blurs 3D-tilted zooms only). Camera: webcamLayoutPreset, webcamMirrored, webcamReactiveZoom (shrinks on zoom), webcamMaskShape (square or rectangle, the camera\'s own proportions; an untouched roundness follows it), webcamRoundness 0–1, webcamSizePreset 15–60, webcamAnchor (a corner or an edge middle), webcamCropRegion (the framing: a square share of the picture, width = height ≥ 1/3) and webcamCropPan (where that window sits, 0–1 per axis, 0.5 centred), webcamBackgroundMode (none, transparent, blur, custom: needs settingsContext.cameraSegmentation ready), webcamBlurIntensity 0–1, webcamWallpaper (as wallpaper). Cursor, under `cursor`: show, autoHide, theme (settingsContext.cursorThemes), asArrow (the recorded cursor kinds drawn as the arrow instead), size 1.5–6, smoothing 0–1, motionBlur 0–1, clickBounce 0–2, clickImpact, clickSound (only heard when the take recorded clicks) and clickSoundGainDb −24–0, model3d. Also autoFocusAll (every zoom follows the cursor) and audioGainDb −12–12 dB, the master volume. A value out of range or a key not listed is refused with the reason, and nothing changes. The result lists what was applied, including what the editor changes with it, and `inactive` for any of those the current state leaves without effect.', + 'Change the project\'s look: pass only the settings to change, named as getCurrentDocument returns them under `settings`. Background: wallpaper (a bundled path from settingsContext.bundledWallpapers, a CSS colour or gradient, or a base64 PNG/JPEG data URL; never a file path or web URL; an embedded image reads back as a marker, leave the key out to keep it), wallpaperMotion (moves a gradient or image only), backgroundBlur 0–1. Frame: aspectRatio ("auto", a preset, or an originalAspectRatios token; a fixed format picked for the first time fills the frame with a window that follows the cursor, which formatFollowCursor turns off when settingsContext.formatFill is "available"), padding 0–100 %, borderRadius 0–64 px, shadowIntensity 0–1, frame (none, window, laptop, phone, monitor) and frameTheme (light, dark), motionBlurAmount 0–1, depthOfField (blurs 3D-tilted zooms only). Camera: webcamLayoutPreset, webcamMirrored, webcamReactiveZoom (shrinks on zoom), webcamMaskShape (square or rectangle, the camera\'s own proportions; an untouched roundness follows it), webcamRoundness 0–1, webcamSizePreset 15–60, webcamAnchor (a corner or an edge middle), webcamCropRegion (the framing: a square share of the picture, width = height ≥ 1/3) and webcamCropPan (where that window sits, 0–1 per axis, 0.5 centred), webcamBackgroundMode (none, transparent, blur, custom: needs settingsContext.cameraSegmentation ready), webcamBlurIntensity 0–1, webcamWallpaper (as wallpaper). Cursor, under `cursor`: show, autoHide, theme (settingsContext.cursorThemes), asArrow (the recorded cursor kinds drawn as the arrow instead), size 1.5–6, smoothing 0–1, motionBlur 0–1, clickBounce 0–2, clickImpact, clickSound (only heard when the take recorded clicks) and clickSoundGainDb −24–0, model3d. Also autoFocusAll (every zoom follows the cursor), audioGainDb −12–12 dB, the master volume, and recordingAudioMuted, which silences the recording\'s own sound in the preview and the export while voiceovers and music keep playing. A value out of range or a key not listed is refused with the reason, and nothing changes. The result lists what was applied, including what the editor changes with it, and `inactive` for any of those the current state leaves without effect.', addEffect: "Write a visual effect as a shader and pose it over a span of the edited timeline (virtual seconds), or pose one of this project's effects again (effectId). Use it for a look the settings do not offer. `effect.code` is WGSL that defines `fn effect(uv: vec2) -> vec4`, called for every pixel; uv is 0..1 with (0,0) top-left. Available inside it: `frame(uv)` samples the composed picture; `footage(p)` samples the recording itself at its own resolution at point p of its surface (the space of `fx_to_footage`), transparent outside 0..1, with no cursor or webcam on it: read it for anything that magnifies, frame() for the rest; `params.` reads each declared parameter; `ctx.time` is programme seconds; `ctx.region.x` goes 0 to 1 across the region (y its length in s, z seconds since it began); `ctx.resolution` and `ctx.aspect`; `ctx.cursor` (xy in uv, z = 1 when on screen); `ctx.clicks[0..3]`, the last four clicks, newest first (xy in uv where it happened, z seconds since, w = 1 when the slot holds one); `ctx.footage` and `ctx.camera`, the recording's and the webcam's rects (x, y, w, h in uv; camera zero-sized when absent) — all already placed through zoom and layout; `fx_to_footage(uv)` and `fx_from_footage(p)` map between output uv and the recording's own surface (0..1 across the recording) through the warp the compositor draws it with, zoom, layout and 3D camera included, and `ctx.footage_aspect` is its width over height: the effect works in whichever of the two spaces fits it; `fx_px(n)`, n pixels of a 1080p frame as a fraction of the height; `fx_hash(vec2)`, a random number in [0,1) identical on every GPU. Rules: the effect is a pure function of those inputs (no state between frames), so the preview and the export show the same pixels; reason in uv or fx_px, never raw pixels; every loop is a `for` with literal bounds (`for (var i = 0; i < 16; i++)`), at most 4096 iterations per pixel — the one hard rule. `params` declares up to 8 (recommended) parameters, each `{type: number|toggle|color|point, name, label, default}` (number also `min`, `max`); declare one only when moving it visibly changes the effect over its whole range. `target`: `image` (default: the picture under the annotations and captions), `text` (the annotations and captions alone, premultiplied alpha), `both` (everything); use text or both only when asked. One effect runs at a time: to combine two looks, write them in one shader. The result gives the cost tier, in frame() and footage() reads per pixel, with what a 1080p frame pays on a fast discrete GPU (RTX 4070 Ti) and on an integrated one (Apple M1): light ≤ 8, up to 0.06 ms and 1.3 ms; medium ≤ 32, 0.14 ms and 4.3 ms; heavy ≤ 128, 0.54 ms and 16 ms; very heavy beyond, 2 ms and 49 ms at 512 reads. It also gives a recommendation when the tier runs long: light anywhere, medium up to a minute, heavy up to 10 s, very heavy a few seconds. It is a recommendation, never a refusal. Code that does not compile is refused with the compiler's error at the line of your code: fix it and call again. Check the result with getFrames.", setEffect: diff --git a/electron/ai-edition/editor-settings-tool.test.ts b/electron/ai-edition/editor-settings-tool.test.ts index d838e95ce6..5db5d36ea3 100644 --- a/electron/ai-edition/editor-settings-tool.test.ts +++ b/electron/ai-edition/editor-settings-tool.test.ts @@ -68,6 +68,7 @@ describe("setEditorSettings", () => { borderRadius: 12, frame: "laptop", audioGainDb: -3, + recordingAudioMuted: true, autoFocusAll: true, cursor: { size: 2, theme: "glass-lens", show: true, asArrow: ["text"] }, }); @@ -80,6 +81,7 @@ describe("setEditorSettings", () => { borderRadius: 12, frame: "laptop", audioGainDb: -3, + recordingAudioMuted: true, autoFocusAll: true, cursorTheme: "glass-lens", }); diff --git a/electron/ai-edition/editor-settings-tool.ts b/electron/ai-edition/editor-settings-tool.ts index 4ed6bdf708..23666e0762 100644 --- a/electron/ai-edition/editor-settings-tool.ts +++ b/electron/ai-edition/editor-settings-tool.ts @@ -130,6 +130,7 @@ export const setEditorSettingsArgs = z webcamWallpaper: wallpaperArg("webcamWallpaper"), webcamBlurIntensity: bounded("webcamBlurIntensity"), audioGainDb: z.number().min(-AUDIO_GAIN_DB_LIMIT).max(AUDIO_GAIN_DB_LIMIT), + recordingAudioMuted: z.boolean(), autoFocusAll: z.boolean(), cursor: z .object({ @@ -191,6 +192,7 @@ function settingsForModel(document: AxcutDocument) { webcamWallpaper: elideDataUrl(s.webcamWallpaper), webcamBlurIntensity: s.webcamBlurIntensity, audioGainDb: s.audioGainDb, + recordingAudioMuted: s.recordingAudioMuted, autoFocusAll: s.autoFocusAll, cursor: { show: s.cursorShow, 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 a4c453c047..f9903e26cc 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -114,6 +114,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); @@ -125,9 +126,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, @@ -135,7 +135,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 1e0c674108..b322c7db28 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3454,11 +3454,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 517d0b9b66..0f987dd9a2 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(); }); @@ -350,7 +358,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 { @@ -358,21 +366,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); @@ -384,6 +426,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); }); @@ -459,6 +507,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