Skip to content
Merged
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
222 changes: 222 additions & 0 deletions src/components/ai-edition/NewEditorShell.togglePlay.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TooltipProvider>
<EditorDialogsProvider>
<NewEditorShell />
</EditorDialogsProvider>
</TooltipProvider>,
);
}

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);
});
});
46 changes: 40 additions & 6 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -81,6 +85,7 @@ interface SeekTarget {
timeSec: number;
isSource?: boolean;
requestId: number;
preservePlayback?: boolean;
}

/**
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/components/ai-edition/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ interface PreviewProps {
onAnnotationChange?: (id: string, patch: Partial<AxcutAnnotationRegion>) => 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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ interface PreviewCanvasProps {
onAnnotationChange?: (id: string, patch: Partial<AxcutAnnotationRegion>) => 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;
Expand Down
Loading
Loading