diff --git a/src/components/ai-edition/NewEditorShell.togglePlay.test.tsx b/src/components/ai-edition/NewEditorShell.togglePlay.test.tsx new file mode 100644 index 000000000..3fa5fcb73 --- /dev/null +++ b/src/components/ai-edition/NewEditorShell.togglePlay.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { createElement, useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + previewPlay: vi.fn(() => Promise.resolve()), + previewPause: vi.fn(), + latestSeekTarget: null as { + timeSec: number; + isSource?: boolean; + requestId: number; + preservePlayback?: boolean; + } | null, +})); + +vi.mock("@/contexts/ShortcutsContext", async () => { + const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts"); + return { + useShortcuts: () => ({ + shortcuts: DEFAULT_SHORTCUTS, + isMac: false, + isConfigOpen: false, + openConfig: vi.fn(), + closeConfig: vi.fn(), + setShortcuts: vi.fn(), + persistShortcuts: () => Promise.resolve(true), + }), + }; +}); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ + locale: "en", + setLocale: vi.fn(), + }), + useScopedT: () => (key: string) => key, +})); + +vi.mock("./Preview", () => ({ + Preview: ({ + seekTarget, + onVideoElement, + }: { + seekTarget: { + timeSec: number; + isSource?: boolean; + requestId: number; + preservePlayback?: boolean; + } | null; + onVideoElement: (element: HTMLVideoElement | null) => void; + }) => { + mocks.latestSeekTarget = seekTarget; + useEffect(() => { + const element = document.createElement("video"); + let paused = true; + Object.defineProperty(element, "paused", { + configurable: true, + get: () => paused, + }); + element.play = vi.fn(() => { + paused = false; + element.dispatchEvent(new Event("play")); + return mocks.previewPlay(); + }); + element.pause = vi.fn(() => { + paused = true; + element.dispatchEvent(new Event("pause")); + mocks.previewPause(); + }); + onVideoElement(element); + return () => onVideoElement(null); + }, [onVideoElement]); + return createElement("div", { + "data-testid": "preview-mock", + "data-seek-target": JSON.stringify(seekTarget), + }); + }, +})); + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext"; +import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { NewEditorShell } from "./NewEditorShell"; + +function makeCrossAssetDocument(): AxcutDocument { + const doc = createEmptyDocument({ projectId: "p1", title: "Cross-Asset Replay" }); + const asset1: AxcutAsset = { + id: "a1", + kind: "video", + label: "a1", + originalPath: "/a1.mp4", + durationSec: 5, + cameraTrack: null, + }; + const asset2: AxcutAsset = { + id: "a2", + kind: "video", + label: "a2", + originalPath: "/a2.mp4", + durationSec: 5, + cameraTrack: null, + }; + const clip1: AxcutClip = { + id: "c1", + assetId: "a1", + sourceStartSec: 0, + sourceEndSec: 5, + timelineStartSec: 0, + timelineEndSec: 5, + wordRefs: [], + origin: "user", + reason: "", + }; + const clip2: AxcutClip = { + id: "c2", + assetId: "a2", + sourceStartSec: 0, + sourceEndSec: 5, + timelineStartSec: 5, + timelineEndSec: 10, + wordRefs: [], + origin: "user", + reason: "", + }; + doc.assets = [asset1, asset2]; + doc.timeline.clips = [clip1, clip2]; + return doc; +} + +function renderShell() { + return render( + + + + + , + ); +} + +describe("NewEditorShell togglePlay cross-asset replay", () => { + beforeEach(() => { + localStorage.clear(); + mocks.previewPlay.mockClear(); + mocks.previewPause.mockClear(); + mocks.latestSeekTarget = null; + (window as unknown as { electronAPI?: unknown }).electronAPI = { + onAiEditionChatEvent: () => () => {}, + setTitleBarOverlay: () => {}, + setHasUnsavedChanges: () => {}, + onRequestCloseConfirm: () => () => {}, + onRequestSaveBeforeClose: () => () => {}, + sendCloseConfirmResponse: () => {}, + findRecordingCamera: () => Promise.resolve(null), + preparePreviewAudioTrack: () => Promise.resolve({ success: false, path: null }), + readBinaryFile: () => Promise.resolve(new Uint8Array(0)), + isAppPackaged: () => false, + }; + Element.prototype.scrollTo = () => {}; + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + afterEach(() => { + cleanup(); + (window as unknown as { electronAPI?: unknown }).electronAPI = undefined; + }); + + it("preserves playback intent when replaying across assets through togglePlay", async () => { + const doc = makeCrossAssetDocument(); + useProjectStore.setState({ + document: doc, + projectId: "p1", + currentTimeSec: 10, // at the end of the timeline + playing: false, + }); + + renderShell(); + + // Trigger replay via Space shortcut (togglePlay) + await act(async () => { + fireEvent.keyDown(document.body, { key: " ", code: "Space" }); + }); + + // 1. togglePlay must have initiated a seek to 0 carrying preservePlayback: true + expect(mocks.latestSeekTarget).toMatchObject({ + timeSec: 0, + preservePlayback: true, + }); + + // 2. togglePlay must have called play() to start playback + expect(mocks.previewPlay).toHaveBeenCalledTimes(1); + expect(useProjectStore.getState().currentTimeSec).toBe(0); + }); + + it("does not rewind to 0 when pressing play from an intermediate position after cross-asset seek", async () => { + const doc = makeCrossAssetDocument(); + useProjectStore.setState({ + document: doc, + projectId: "p1", + currentTimeSec: 4, // in clip1 (0-5s), timeline ends at 10s + playing: false, + }); + + renderShell(); + + await act(async () => { + fireEvent.keyDown(document.body, { key: " ", code: "Space" }); + }); + + // Must NOT seek to 0 + expect(mocks.latestSeekTarget).toBeNull(); + expect(mocks.previewPlay).toHaveBeenCalledTimes(1); + expect(useProjectStore.getState().currentTimeSec).toBe(4); + }); +}); diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index d87a215ea..b3340ce85 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -49,6 +49,10 @@ import { trimAppliesToClip, ventilateTimelineSpanToTrims, } from "@/lib/ai-edition/timeline/trim-mapping"; +import { + locateVirtualPosition, + totalVirtualDuration, +} from "@/lib/ai-edition/timeline/virtual-preview"; import { firstTimelineBusyView } from "@/lib/ai-edition/transcription/status"; import { matchesShortcut } from "@/lib/shortcuts"; import { nativeBridgeClient } from "@/native"; @@ -81,6 +85,7 @@ interface SeekTarget { timeSec: number; isSource?: boolean; requestId: number; + preservePlayback?: boolean; } /** @@ -536,9 +541,14 @@ export function NewEditorShell() { ); const handleSeek = useCallback( - (timeSec: number) => { + (timeSec: number, preservePlayback?: boolean) => { setCurrentTime(timeSec); - setSeekTarget({ timeSec, isSource: false, requestId: ++seekSeqRef.current }); + setSeekTarget({ + timeSec, + isSource: false, + requestId: ++seekSeqRef.current, + preservePlayback, + }); }, [setCurrentTime], ); @@ -602,8 +612,8 @@ export function NewEditorShell() { useEffect(() => { const el = videoElement; if (!el) return; - const onPlay = () => setPlaying(true); - const onPause = () => setPlaying(false); + const onPlay = () => setPlaying(!el.paused); + const onPause = () => setPlaying(!el.paused); // BUG corrigé : ce listener écoutait le même événement DOM natif 'ended' que le // onEnded interne de VirtualPreview.tsx, sans la moindre logique multi-clip — il // mettait TOUJOURS `playing` à false, y compris quand VirtualPreview venait @@ -614,9 +624,12 @@ export function NewEditorShell() { // suivant ?" que VirtualPreview — seul point de // vérité pour "y a-t-il encore de la timeline à jouer". const onEnded = () => { + if (!el.paused) return; const playhead = useProjectStore.getState().currentTimeSec; + const duration = totalVirtualDuration(clipsForEndedRef.current); + const isAtEnd = duration > 0 && playhead >= duration - 0.1; const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1); - if (!hasNextClip) setPlaying(false); + if (isAtEnd && !hasNextClip) setPlaying(false); }; el.addEventListener("play", onPlay); el.addEventListener("pause", onPause); @@ -634,6 +647,27 @@ export function NewEditorShell() { const togglePlay = useCallback(() => { if (!videoElement) return; if (videoElement.paused) { + const playhead = useProjectStore.getState().currentTimeSec; + const duration = totalVirtualDuration(clipsForEndedRef.current); + const endPos = locateVirtualPosition(clipsForEndedRef.current, duration); + const currentPos = locateVirtualPosition(clipsForEndedRef.current, playhead); + const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1); + const isAtEnd = + duration > 0 && + playhead >= duration - 0.05 && + !hasNextClip && + currentPos?.clip.id === endPos?.clip.id; + if (isAtEnd) { + handleSeek(0, true); + const startPos = locateVirtualPosition(clipsForEndedRef.current, 0); + if (startPos && startPos.clip.assetId === endPos?.clip.assetId) { + try { + videoElement.currentTime = startPos.sourceTimeSec; + } catch { + // media metadata not ready yet + } + } + } // Same catch as VirtualPreview's: `play()` rejects on the autoplay policy // or when a new load interrupts it, and the store's `playing` flag is // driven by the element's own play/pause listeners above — so a rejection @@ -644,7 +678,7 @@ export function NewEditorShell() { } else { videoElement.pause(); } - }, [videoElement]); + }, [videoElement, handleSeek]); // "Transcribe now" from the transcript pane. The run itself belongs to the // transcription store (it owns the queue, the toasts and the failure diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx index c809a329b..2d64b4428 100644 --- a/src/components/ai-edition/Preview.tsx +++ b/src/components/ai-edition/Preview.tsx @@ -50,7 +50,7 @@ interface PreviewProps { onAnnotationChange?: (id: string, patch: Partial) => void; onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void; onAnnotationCommit?: () => void; - seekTarget: { timeSec: number; requestId: number } | null; + seekTarget: { timeSec: number; requestId: number; preservePlayback?: boolean } | null; onTimeChange: (sec: number) => void; onSeek: (sec: number) => void; onLoadedMetadata: (sec: number, assetId: string) => void; diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 557593f4c..5ba8f0007 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -100,7 +100,7 @@ interface PreviewCanvasProps { onAnnotationChange?: (id: string, patch: Partial) => void; onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void; onAnnotationCommit?: () => void; - seekTarget: { timeSec: number; requestId: number } | null; + seekTarget: { timeSec: number; requestId: number; preservePlayback?: boolean } | null; onTimeChange: (sec: number) => void; onSeek: (sec: number) => void; onLoadedMetadata: (sec: number, assetId: string) => void; diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx index becfb077a..517d0b9b6 100644 --- a/src/components/ai-edition/VirtualPreview.playback.test.tsx +++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx @@ -93,6 +93,9 @@ function driveVideo(element: HTMLVideoElement) { play: () => { paused = false; }, + pause: () => { + paused = true; + }, seekTo: (next: number) => { currentTime = next; }, @@ -757,3 +760,534 @@ describe("VirtualPreview primary audio keeps to the picture", () => { expect(world.audioEl.playbackRate).toBe(1); }); }); + +describe("VirtualPreview replay", () => { + it.each([10, 4])("restarts with one play event after a %s-second clip ends", (end) => { + const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 2, end, 0)]); + video.play(); + video.seekTo(end - 0.04); + tick(); + tick(); + expect(element.paused).toBe(true); + + act(() => { + video.play(); + fireEvent.play(element); + }); + tick(); + expect(video.currentTime).toBe(2); + expect(element.paused).toBe(false); + expect(reportedTimes(onTimeChange).at(-1)).toBe(0); + }); + + it("restarts after the browser's ended event", () => { + const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 0, 10, 0)]); + video.seekTo(10); + act(() => fireEvent.ended(element)); + expect(reportedTimes(onTimeChange).at(-1)).toBe(10); + act(() => { + video.play(); + fireEvent.play(element); + }); + tick(); + expect(video.currentTime).toBe(0); + expect(element.paused).toBe(false); + }); + + it("does not pause or cancel playback while a replay seek is in flight", () => { + const { video, element, onTimeChange } = mount([clip("clip_1", "a1", 2, 4, 0)]); + video.play(); + video.seekTo(3.96); + tick(); + tick(); + expect(element.paused).toBe(true); + const initialPauseCalls = video.pauseCalls.length; + + let seeking = true; + Object.defineProperty(element, "seeking", { configurable: true, get: () => seeking }); + + act(() => { + video.play(); + fireEvent.play(element); + }); + // While seek is in flight across ticks, boundary check must not pause: + tick(); + expect(element.paused).toBe(false); + expect(video.pauseCalls.length).toBe(initialPauseCalls); + + // Demuxer finishes seeking to target: + seeking = false; + act(() => fireEvent.seeked(element)); + tick(); + expect(element.paused).toBe(false); + expect(video.pauseCalls.length).toBe(initialPauseCalls); + expect(video.currentTime).toBe(2); + expect(reportedTimes(onTimeChange).at(-1)).toBe(0); + }); + + it("restarts playback across assets when the last clip uses a different asset than the first", () => { + const sources: VideoSource[] = [ + { id: "a1", src: "file:///tmp/a1.mp4", label: "a1" }, + { id: "a2", src: "file:///tmp/a2.mp4", label: "a2" }, + ]; + const clips = [clip("clip_1", "a1", 0, 5, 0), clip("clip_2", "a2", 0, 5, 5)]; + let currentSeekTarget: { + timeSec: number; + requestId: number; + preservePlayback?: boolean; + } | null = null; + const onTimeChange = vi.fn(); + + const view = render( + , + ); + + // Mount first asset (a1) + const videoEl1 = view.container.querySelector("video"); + if (!videoEl1) throw new Error("no initial