From a6d171a2f1e4e4661d2774b56e032f8d74057761 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 00:03:07 +0200 Subject: [PATCH 1/3] fix(editor): reset all parameters on audio track pane reset Clicking the reset button on an audio track inspector pane previously only reset the output gain, ignoring fade-in, fade-out, mute, and loop. Reset all track parameters (gainDb, fadeInMs, fadeOutMs, muted, loop) and clear draft slider drag values in a single update. --- .../ai-edition/RightPanes.audioTrack.test.tsx | 133 ++++++++++++++++++ src/components/ai-edition/RightPanes.tsx | 10 +- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/components/ai-edition/RightPanes.audioTrack.test.tsx diff --git a/src/components/ai-edition/RightPanes.audioTrack.test.tsx b/src/components/ai-edition/RightPanes.audioTrack.test.tsx new file mode 100644 index 000000000..214133a74 --- /dev/null +++ b/src/components/ai-edition/RightPanes.audioTrack.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { 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"; +import type { AxcutAudioTrack } from "@/lib/ai-edition/schema"; +import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import { AudioTrackPane } from "./RightPanes"; + +type TimelineApi = ReturnType; + +describe("AudioTrackPane reset button", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("resets all track parameters (gain, fades, mute, loop) on reset click", () => { + const updateAudioTrack = vi.fn(); + const mockTrack: AxcutAudioTrack = { + id: "audio_track_1", + clipId: "clip_1", + assetId: "asset_audio_1", + trackId: "audio_track_1", + startMs: 1000, + endMs: 5000, + durationSec: 10, + offsetMs: 0, + gainDb: -6, + fadeInMs: 500, + fadeOutMs: 1000, + muted: true, + loop: true, + kind: "music", + label: "test-audio.mp3", + origin: "user", + }; + + const tl = { + selectedAudioTrackId: "audio_track_1", + audioTracks: [mockTrack], + assets: [ + { + id: "asset_audio_1", + kind: "audio", + label: "test-audio.mp3", + originalPath: "/path/test-audio.mp3", + durationSec: 10, + }, + ], + updateAudioTrack, + } as unknown as TimelineApi; + + render( + + + , + ); + + const resetBtn = screen.getByRole("button", { name: /reset/i }); + fireEvent.click(resetBtn); + + expect(updateAudioTrack).toHaveBeenCalledTimes(1); + expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", { + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + loop: false, + }); + }); + + it("resets all track parameters under French locale", () => { + localStorage.setItem(LOCALE_STORAGE_KEY, "fr"); + const updateAudioTrack = vi.fn(); + const mockTrack: AxcutAudioTrack = { + id: "audio_track_1", + clipId: "clip_1", + assetId: "asset_audio_1", + trackId: "audio_track_1", + startMs: 1000, + endMs: 5000, + durationSec: 10, + offsetMs: 0, + gainDb: -0.5, + fadeInMs: 300, + fadeOutMs: 400, + muted: true, + loop: false, + kind: "music", + label: "openscreen-test-voix.mp3", + origin: "user", + }; + + const tl = { + selectedAudioTrackId: "audio_track_1", + audioTracks: [mockTrack], + assets: [ + { + id: "asset_audio_1", + kind: "audio", + label: "openscreen-test-voix.mp3", + originalPath: "/path/openscreen-test-voix.mp3", + durationSec: 10, + }, + ], + updateAudioTrack, + } as unknown as TimelineApi; + + render( + + + , + ); + + // French label: "Réinitialiser l’audio" + const resetBtn = screen.getByRole("button", { name: /réinitialiser l’audio/i }); + fireEvent.click(resetBtn); + + expect(updateAudioTrack).toHaveBeenCalledTimes(1); + expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", { + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + loop: false, + }); + }); +}); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 8d89fa510..0ed6c1a52 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3169,7 +3169,15 @@ export function AudioTrackPane({ tl }: { tl: TimelineApi }) { className={styles.secondaryBtn} onClick={() => { setLiveGain(null); - void tl.setAudioTrackGain(track.id, 0); + setLiveFadeIn(null); + setLiveFadeOut(null); + void tl.updateAudioTrack(track.id, { + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + loop: false, + }); }} > {ts("audio.reset")} From 958fd016235c394fe0027471549f8a131506f2b7 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 07:58:48 +0200 Subject: [PATCH 2/3] test(editor): verify audio track live drafts are cleared on reset Address CodeRabbit review feedback: test in-progress slider drafts before reset and assert drafted values are cleared. Add JSDoc docstring to AudioTrackPane. --- .../ai-edition/RightPanes.audioTrack.test.tsx | 75 ++++++++++++++++++- src/components/ai-edition/RightPanes.tsx | 7 ++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/components/ai-edition/RightPanes.audioTrack.test.tsx b/src/components/ai-edition/RightPanes.audioTrack.test.tsx index 214133a74..bcc534366 100644 --- a/src/components/ai-edition/RightPanes.audioTrack.test.tsx +++ b/src/components/ai-edition/RightPanes.audioTrack.test.tsx @@ -55,12 +55,25 @@ describe("AudioTrackPane reset button", () => { updateAudioTrack, } as unknown as TimelineApi; - render( + const { rerender } = render( , ); + const gainSlider = screen.getByRole("slider", { name: "Output level" }); + const fadeInSlider = screen.getByRole("slider", { name: "Fade in" }); + const fadeOutSlider = screen.getByRole("slider", { name: "Fade out" }); + + // Draft in-progress slider changes without committing + fireEvent.change(gainSlider, { target: { value: "3" } }); + fireEvent.change(fadeInSlider, { target: { value: "1500" } }); + fireEvent.change(fadeOutSlider, { target: { value: "2000" } }); + + expect(gainSlider).toHaveValue("3"); + expect(fadeInSlider).toHaveValue("1500"); + expect(fadeOutSlider).toHaveValue("2000"); + const resetBtn = screen.getByRole("button", { name: /reset/i }); fireEvent.click(resetBtn); @@ -72,6 +85,30 @@ describe("AudioTrackPane reset button", () => { muted: false, loop: false, }); + + // Draft values are cleared; inputs no longer display the drafted values + expect(gainSlider).not.toHaveValue("3"); + expect(fadeInSlider).not.toHaveValue("1500"); + expect(fadeOutSlider).not.toHaveValue("2000"); + + // When re-rendered with the reset track state, sliders show zeroed defaults + const resetTrack: AxcutAudioTrack = { + ...mockTrack, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + loop: false, + }; + rerender( + + + , + ); + + expect(gainSlider).toHaveValue("0"); + expect(fadeInSlider).toHaveValue("0"); + expect(fadeOutSlider).toHaveValue("0"); }); it("resets all track parameters under French locale", () => { @@ -111,12 +148,24 @@ describe("AudioTrackPane reset button", () => { updateAudioTrack, } as unknown as TimelineApi; - render( + const { rerender } = render( , ); + const gainSlider = screen.getByRole("slider", { name: /niveau de sortie/i }); + const fadeInSlider = screen.getByRole("slider", { name: /fondu d['’]entrée/i }); + const fadeOutSlider = screen.getByRole("slider", { name: /fondu de sortie/i }); + + fireEvent.change(gainSlider, { target: { value: "-12" } }); + fireEvent.change(fadeInSlider, { target: { value: "800" } }); + fireEvent.change(fadeOutSlider, { target: { value: "1200" } }); + + expect(gainSlider).toHaveValue("-12"); + expect(fadeInSlider).toHaveValue("800"); + expect(fadeOutSlider).toHaveValue("1200"); + // French label: "Réinitialiser l’audio" const resetBtn = screen.getByRole("button", { name: /réinitialiser l’audio/i }); fireEvent.click(resetBtn); @@ -129,5 +178,27 @@ describe("AudioTrackPane reset button", () => { muted: false, loop: false, }); + + expect(gainSlider).not.toHaveValue("-12"); + expect(fadeInSlider).not.toHaveValue("800"); + expect(fadeOutSlider).not.toHaveValue("1200"); + + const resetTrack: AxcutAudioTrack = { + ...mockTrack, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + loop: false, + }; + rerender( + + + , + ); + + expect(gainSlider).toHaveValue("0"); + expect(fadeInSlider).toHaveValue("0"); + expect(fadeOutSlider).toHaveValue("0"); }); }); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 0ed6c1a52..cb80a15d2 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3039,6 +3039,13 @@ type TimelineApi = ReturnType; // anyway (`resolveFadeSecs` reduces one that does not fit). const FADE_MAX_MS = 5000; +/** + * Per-track controls for the selected imported audio track (issue #350). Shown by + * the inspector in place of the facet when an audio track is selected (see + * FloatingInspector). The header is the generic "Audio track"; the body leads + * with the file name, then the volume, fade in/out, mute, and loop controls, + * with actions to reset all parameters or delete the track. + */ export function AudioTrackPane({ tl }: { tl: TimelineApi }) { const ts = useScopedT("settings"); const trackId = tl.selectedAudioTrackId; From 1f9445d30b73a2be8cc21df171097e9377b78d5f Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sat, 5 Sep 2026 08:26:32 +0200 Subject: [PATCH 3/3] fix(editor): prevent audio track slider jump on release On release, onCommit previously reset live slider states synchronously before the asynchronous document save finished, causing the slider thumb to jump back to its starting value until the store updated. Now awaits the commit and keeps the live draft until the update completes, eliminating the jump. --- .../ai-edition/RightPanes.audioTrack.test.tsx | 146 +++++++++++++++++- src/components/ai-edition/RightPanes.tsx | 37 +++-- 2 files changed, 172 insertions(+), 11 deletions(-) diff --git a/src/components/ai-edition/RightPanes.audioTrack.test.tsx b/src/components/ai-edition/RightPanes.audioTrack.test.tsx index bcc534366..595518b8e 100644 --- a/src/components/ai-edition/RightPanes.audioTrack.test.tsx +++ b/src/components/ai-edition/RightPanes.audioTrack.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, 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"; @@ -201,4 +201,148 @@ describe("AudioTrackPane reset button", () => { expect(fadeInSlider).toHaveValue("0"); expect(fadeOutSlider).toHaveValue("0"); }); + + it("retains slider value on release while async commit is in flight without jumping back", async () => { + let resolveGain: () => void = () => undefined; + let resolveFadeIn: () => void = () => undefined; + let resolveFadeOut: () => void = () => undefined; + + const setAudioTrackGain = vi.fn().mockImplementation( + () => + new Promise((res) => { + resolveGain = res; + }), + ); + const updateAudioTrack = vi.fn().mockImplementation( + (_id: string, patch: { fadeInMs?: number; fadeOutMs?: number }) => + new Promise((res) => { + if (patch.fadeInMs !== undefined) resolveFadeIn = res; + if (patch.fadeOutMs !== undefined) resolveFadeOut = res; + }), + ); + + const mockTrack: AxcutAudioTrack = { + id: "audio_track_1", + clipId: "clip_1", + assetId: "asset_audio_1", + trackId: "audio_track_1", + startMs: 1000, + endMs: 5000, + durationSec: 10, + offsetMs: 0, + gainDb: -6, + fadeInMs: 500, + fadeOutMs: 1000, + muted: false, + loop: false, + kind: "music", + label: "test-audio.mp3", + origin: "user", + }; + + const tl = { + selectedAudioTrackId: "audio_track_1", + audioTracks: [mockTrack], + assets: [ + { + id: "asset_audio_1", + kind: "audio", + label: "test-audio.mp3", + originalPath: "/path/test-audio.mp3", + durationSec: 10, + }, + ], + setAudioTrackGain, + updateAudioTrack, + } as unknown as TimelineApi; + + const { rerender } = render( + + + , + ); + + const gainSlider = screen.getByRole("slider", { name: "Output level" }); + const fadeInSlider = screen.getByRole("slider", { name: "Fade in" }); + const fadeOutSlider = screen.getByRole("slider", { name: "Fade out" }); + + // 1. Gain slider: drag to 2.5 and release (onMouseUp) + fireEvent.change(gainSlider, { target: { value: "2.5" } }); + fireEvent.mouseUp(gainSlider); + + expect(setAudioTrackGain).toHaveBeenCalledWith("audio_track_1", 2.5); + // While save is in flight, the slider MUST NOT jump back to -6 + expect(gainSlider).toHaveValue("2.5"); + + // Complete the async save and simulate the store update + await act(async () => { + resolveGain(); + }); + + rerender( + + + , + ); + expect(gainSlider).toHaveValue("2.5"); + + // 2. Fade in slider: drag to 1500 and release + fireEvent.change(fadeInSlider, { target: { value: "1500" } }); + fireEvent.mouseUp(fadeInSlider); + + expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", { fadeInMs: 1500 }); + // While save is in flight, the slider MUST NOT jump back to 500 + expect(fadeInSlider).toHaveValue("1500"); + + await act(async () => { + resolveFadeIn(); + }); + + rerender( + + + , + ); + expect(fadeInSlider).toHaveValue("1500"); + + // 3. Fade out slider: drag to 2500 and release + fireEvent.change(fadeOutSlider, { target: { value: "2500" } }); + fireEvent.mouseUp(fadeOutSlider); + + expect(updateAudioTrack).toHaveBeenCalledWith("audio_track_1", { fadeOutMs: 2500 }); + // While save is in flight, the slider MUST NOT jump back to 1000 + expect(fadeOutSlider).toHaveValue("2500"); + + await act(async () => { + resolveFadeOut(); + }); + + rerender( + + + , + ); + expect(fadeOutSlider).toHaveValue("2500"); + }); }); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index cb80a15d2..ca70e8885 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -3118,9 +3118,15 @@ export function AudioTrackPane({ tl }: { tl: TimelineApi }) { decimals={1} suffix=" dB" onChange={(value) => setLiveGain(value)} - onCommit={() => { - if (liveGain !== null) void tl.setAudioTrackGain(track.id, liveGain); - setLiveGain(null); + onCommit={async () => { + if (liveGain !== null) { + const target = liveGain; + try { + await tl.setAudioTrackGain(track.id, target); + } finally { + setLiveGain((current) => (current === target ? null : current)); + } + } }} /> { - if (liveFadeIn !== null) void tl.updateAudioTrack(track.id, { fadeInMs: liveFadeIn }); - setLiveFadeIn(null); + onCommit={async () => { + if (liveFadeIn !== null) { + const target = liveFadeIn; + try { + await tl.updateAudioTrack(track.id, { fadeInMs: target }); + } finally { + setLiveFadeIn((current) => (current === target ? null : current)); + } + } }} /> { - if (liveFadeOut !== null) - void tl.updateAudioTrack(track.id, { fadeOutMs: liveFadeOut }); - setLiveFadeOut(null); + onCommit={async () => { + if (liveFadeOut !== null) { + const target = liveFadeOut; + try { + await tl.updateAudioTrack(track.id, { fadeOutMs: target }); + } finally { + setLiveFadeOut((current) => (current === target ? null : current)); + } + } }} />