Skip to content
Open
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
64 changes: 64 additions & 0 deletions src/cli/CliExportRunner.audio.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 4 additions & 2 deletions src/cli/CliExportRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -96,7 +98,7 @@ function buildNativeClipList(axcutDocument: AxcutDocument): CompositorClipInput[
sourceStartSec: clip.sourceStartSec,
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
hasAudio: !recordingAudioMuted,
},
];
});
Expand Down
10 changes: 10 additions & 0 deletions src/components/ai-edition/ExportDialog.params.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
8 changes: 4 additions & 4 deletions src/components/ai-edition/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -122,17 +123,16 @@ 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,
webcamPath: camera.path,
sourceStartSec: clip.sourceStartSec,
sourceEndSec,
webcamOffsetSec: camera.offsetSec,
hasAudio: true,
hasAudio: !recordingAudioMuted,
},
];
});
Expand Down
45 changes: 45 additions & 0 deletions src/components/ai-edition/RightPanes.audioPane.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider>
<AudioPane />
</I18nProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "Reset audio" }));

expect(setSettings).toHaveBeenCalledWith({ audioGainDb: 0, recordingAudioMuted: false });
});
});
20 changes: 19 additions & 1 deletion src/components/ai-edition/RightPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3462,11 +3462,29 @@ export function AudioPane() {
onCommit={() => void commit()}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginTop: 12,
}}
>
<span>{ts("audio.muteRecording")}</span>
<Toggle
checked={settings.recordingAudioMuted}
Comment thread
My-Denia marked this conversation as resolved.
disabled={!hasDocument}
ariaLabel={ts("audio.muteRecording")}
tooltip={ts("audio.muteRecordingHelp")}
onChange={(checked) => void set({ recordingAudioMuted: checked })}
/>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={!hasDocument}
onClick={() => void set({ audioGainDb: 0 })}
onClick={() => void set({ audioGainDb: 0, recordingAudioMuted: false })}
>
{ts("audio.reset")}
</button>
Expand Down
32 changes: 32 additions & 0 deletions src/components/ai-edition/VirtualPreview.audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading