Skip to content

Commit 3598857

Browse files
committed
fix(editor): preserve playback intent during cross-asset replay
When replaying from the end of a timeline where the final clip uses a different asset from the first clip, handleSeek(0) queued an asynchronous seekTarget without playback flags, which could overwrite the playback intent with play: false during the asset switch before metadata loaded. 1. Pass preservePlayback: true from NewEditorShell togglePlay through handleSeek and relay it via SeekTarget down to VirtualPreview. 2. In VirtualPreview, ensure seekTarget effect passes preservePlayback to seekToVirtualTime, and avoid clearing pending playback intent during asset switches. 3. Add cross-asset replay tests in NewEditorShell.togglePlay.test.tsx and VirtualPreview.playback.test.tsx.
1 parent 9644513 commit 3598857

6 files changed

Lines changed: 318 additions & 10 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// @vitest-environment jsdom
2+
import "@testing-library/jest-dom";
3+
import { act, cleanup, fireEvent, render } from "@testing-library/react";
4+
import { createElement, useEffect } from "react";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const mocks = vi.hoisted(() => ({
8+
previewPlay: vi.fn(() => Promise.resolve()),
9+
previewPause: vi.fn(),
10+
latestSeekTarget: null as {
11+
timeSec: number;
12+
isSource?: boolean;
13+
requestId: number;
14+
preservePlayback?: boolean;
15+
} | null,
16+
}));
17+
18+
vi.mock("@/contexts/ShortcutsContext", async () => {
19+
const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
20+
return {
21+
useShortcuts: () => ({
22+
shortcuts: DEFAULT_SHORTCUTS,
23+
isMac: false,
24+
isConfigOpen: false,
25+
openConfig: vi.fn(),
26+
closeConfig: vi.fn(),
27+
setShortcuts: vi.fn(),
28+
persistShortcuts: () => Promise.resolve(true),
29+
}),
30+
};
31+
});
32+
33+
vi.mock("@/contexts/I18nContext", () => ({
34+
useI18n: () => ({
35+
locale: "en",
36+
setLocale: vi.fn(),
37+
}),
38+
useScopedT: () => (key: string) => key,
39+
}));
40+
41+
vi.mock("./Preview", () => ({
42+
Preview: ({
43+
seekTarget,
44+
onVideoElement,
45+
}: {
46+
seekTarget: {
47+
timeSec: number;
48+
isSource?: boolean;
49+
requestId: number;
50+
preservePlayback?: boolean;
51+
} | null;
52+
onVideoElement: (element: HTMLVideoElement | null) => void;
53+
}) => {
54+
mocks.latestSeekTarget = seekTarget;
55+
useEffect(() => {
56+
const element = document.createElement("video");
57+
let paused = true;
58+
Object.defineProperty(element, "paused", {
59+
configurable: true,
60+
get: () => paused,
61+
});
62+
element.play = vi.fn(() => {
63+
paused = false;
64+
element.dispatchEvent(new Event("play"));
65+
return mocks.previewPlay();
66+
});
67+
element.pause = vi.fn(() => {
68+
paused = true;
69+
element.dispatchEvent(new Event("pause"));
70+
mocks.previewPause();
71+
});
72+
onVideoElement(element);
73+
return () => onVideoElement(null);
74+
}, [onVideoElement]);
75+
return createElement("div", {
76+
"data-testid": "preview-mock",
77+
"data-seek-target": JSON.stringify(seekTarget),
78+
});
79+
},
80+
}));
81+
82+
import { TooltipProvider } from "@/components/ui/tooltip";
83+
import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
84+
import type { AxcutAsset, AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
85+
import { createEmptyDocument } from "@/lib/ai-edition/schema";
86+
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
87+
import { NewEditorShell } from "./NewEditorShell";
88+
89+
function makeCrossAssetDocument(): AxcutDocument {
90+
const doc = createEmptyDocument({ projectId: "p1", title: "Cross-Asset Replay" });
91+
const asset1: AxcutAsset = {
92+
id: "a1",
93+
kind: "video",
94+
label: "a1",
95+
originalPath: "/a1.mp4",
96+
durationSec: 5,
97+
cameraTrack: null,
98+
};
99+
const asset2: AxcutAsset = {
100+
id: "a2",
101+
kind: "video",
102+
label: "a2",
103+
originalPath: "/a2.mp4",
104+
durationSec: 5,
105+
cameraTrack: null,
106+
};
107+
const clip1: AxcutClip = {
108+
id: "c1",
109+
assetId: "a1",
110+
sourceStartSec: 0,
111+
sourceEndSec: 5,
112+
timelineStartSec: 0,
113+
timelineEndSec: 5,
114+
wordRefs: [],
115+
origin: "user",
116+
reason: "",
117+
};
118+
const clip2: AxcutClip = {
119+
id: "c2",
120+
assetId: "a2",
121+
sourceStartSec: 0,
122+
sourceEndSec: 5,
123+
timelineStartSec: 5,
124+
timelineEndSec: 10,
125+
wordRefs: [],
126+
origin: "user",
127+
reason: "",
128+
};
129+
doc.assets = [asset1, asset2];
130+
doc.timeline.clips = [clip1, clip2];
131+
return doc;
132+
}
133+
134+
function renderShell() {
135+
return render(
136+
<TooltipProvider>
137+
<EditorDialogsProvider>
138+
<NewEditorShell />
139+
</EditorDialogsProvider>
140+
</TooltipProvider>,
141+
);
142+
}
143+
144+
describe("NewEditorShell togglePlay cross-asset replay", () => {
145+
beforeEach(() => {
146+
localStorage.clear();
147+
mocks.previewPlay.mockClear();
148+
mocks.previewPause.mockClear();
149+
mocks.latestSeekTarget = null;
150+
(window as unknown as { electronAPI?: unknown }).electronAPI = {
151+
onAiEditionChatEvent: () => () => {},
152+
setTitleBarOverlay: () => {},
153+
setHasUnsavedChanges: () => {},
154+
onRequestCloseConfirm: () => () => {},
155+
onRequestSaveBeforeClose: () => () => {},
156+
sendCloseConfirmResponse: () => {},
157+
findRecordingCamera: () => Promise.resolve(null),
158+
preparePreviewAudioTrack: () => Promise.resolve({ success: false, path: null }),
159+
readBinaryFile: () => Promise.resolve(new Uint8Array(0)),
160+
isAppPackaged: () => false,
161+
};
162+
Element.prototype.scrollTo = () => {};
163+
(globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class {
164+
observe() {}
165+
unobserve() {}
166+
disconnect() {}
167+
};
168+
});
169+
170+
afterEach(() => {
171+
cleanup();
172+
(window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
173+
});
174+
175+
it("preserves playback intent when replaying across assets through togglePlay", async () => {
176+
const doc = makeCrossAssetDocument();
177+
useProjectStore.setState({
178+
document: doc,
179+
projectId: "p1",
180+
currentTimeSec: 10, // at the end of the timeline
181+
playing: false,
182+
});
183+
184+
renderShell();
185+
186+
// Trigger replay via Space shortcut (togglePlay)
187+
await act(async () => {
188+
fireEvent.keyDown(document.body, { key: " ", code: "Space" });
189+
});
190+
191+
// 1. togglePlay must have initiated a seek to 0 carrying preservePlayback: true
192+
expect(mocks.latestSeekTarget).toMatchObject({
193+
timeSec: 0,
194+
preservePlayback: true,
195+
});
196+
197+
// 2. togglePlay must have called play() to start playback
198+
expect(mocks.previewPlay).toHaveBeenCalledTimes(1);
199+
expect(useProjectStore.getState().currentTimeSec).toBe(0);
200+
});
201+
});

‎src/components/ai-edition/NewEditorShell.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ interface SeekTarget {
8585
timeSec: number;
8686
isSource?: boolean;
8787
requestId: number;
88+
preservePlayback?: boolean;
8889
}
8990

9091
/**
@@ -540,9 +541,14 @@ export function NewEditorShell() {
540541
);
541542

542543
const handleSeek = useCallback(
543-
(timeSec: number) => {
544+
(timeSec: number, preservePlayback?: boolean) => {
544545
setCurrentTime(timeSec);
545-
setSeekTarget({ timeSec, isSource: false, requestId: ++seekSeqRef.current });
546+
setSeekTarget({
547+
timeSec,
548+
isSource: false,
549+
requestId: ++seekSeqRef.current,
550+
preservePlayback,
551+
});
546552
},
547553
[setCurrentTime],
548554
);
@@ -644,7 +650,7 @@ export function NewEditorShell() {
644650
const playhead = useProjectStore.getState().currentTimeSec;
645651
const duration = totalVirtualDuration(clipsForEndedRef.current);
646652
if (duration > 0 && playhead >= duration - 0.05) {
647-
handleSeek(0);
653+
handleSeek(0, true);
648654
const startPos = locateVirtualPosition(clipsForEndedRef.current, 0);
649655
const endPos = locateVirtualPosition(clipsForEndedRef.current, duration);
650656
if (startPos && startPos.clip.assetId === endPos?.clip.assetId) {

‎src/components/ai-edition/Preview.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ interface PreviewProps {
5050
onAnnotationChange?: (id: string, patch: Partial<AxcutAnnotationRegion>) => void;
5151
onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void;
5252
onAnnotationCommit?: () => void;
53-
seekTarget: { timeSec: number; requestId: number } | null;
53+
seekTarget: { timeSec: number; requestId: number; preservePlayback?: boolean } | null;
5454
onTimeChange: (sec: number) => void;
5555
onSeek: (sec: number) => void;
5656
onLoadedMetadata: (sec: number, assetId: string) => void;

‎src/components/ai-edition/PreviewCanvas.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ interface PreviewCanvasProps {
100100
onAnnotationChange?: (id: string, patch: Partial<AxcutAnnotationRegion>) => void;
101101
onAnnotationBlurDataChange?: (id: string, blurData: BlurData) => void;
102102
onAnnotationCommit?: () => void;
103-
seekTarget: { timeSec: number; requestId: number } | null;
103+
seekTarget: { timeSec: number; requestId: number; preservePlayback?: boolean } | null;
104104
onTimeChange: (sec: number) => void;
105105
onSeek: (sec: number) => void;
106106
onLoadedMetadata: (sec: number, assetId: string) => void;

‎src/components/ai-edition/VirtualPreview.playback.test.tsx‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ function driveVideo(element: HTMLVideoElement) {
9393
play: () => {
9494
paused = false;
9595
},
96+
pause: () => {
97+
paused = true;
98+
},
9699
seekTo: (next: number) => {
97100
currentTime = next;
98101
},
@@ -821,4 +824,93 @@ describe("VirtualPreview replay", () => {
821824
expect(video.currentTime).toBe(2);
822825
expect(reportedTimes(onTimeChange).at(-1)).toBe(0);
823826
});
827+
828+
it("restarts playback across assets when the last clip uses a different asset than the first", () => {
829+
const sources: VideoSource[] = [
830+
{ id: "a1", src: "file:///tmp/a1.mp4", label: "a1" },
831+
{ id: "a2", src: "file:///tmp/a2.mp4", label: "a2" },
832+
];
833+
const clips = [clip("clip_1", "a1", 0, 5, 0), clip("clip_2", "a2", 0, 5, 5)];
834+
let currentSeekTarget: {
835+
timeSec: number;
836+
requestId: number;
837+
preservePlayback?: boolean;
838+
} | null = null;
839+
const onTimeChange = vi.fn();
840+
841+
const view = render(
842+
<VirtualPreview
843+
videoSources={sources}
844+
clips={clips}
845+
seekTarget={currentSeekTarget}
846+
onTimeChange={onTimeChange}
847+
/>,
848+
);
849+
850+
// Mount first asset (a1)
851+
const videoEl1 = view.container.querySelector("video");
852+
if (!videoEl1) throw new Error("no initial <video>");
853+
driveVideo(videoEl1);
854+
act(() => {
855+
fireEvent.loadedMetadata(videoEl1);
856+
});
857+
858+
// Seek to the second clip on asset a2 (e.g. 8s virtual time)
859+
act(() => {
860+
currentSeekTarget = { timeSec: 8, requestId: 1 };
861+
view.rerender(
862+
<VirtualPreview
863+
videoSources={sources}
864+
clips={clips}
865+
seekTarget={currentSeekTarget}
866+
onTimeChange={onTimeChange}
867+
/>,
868+
);
869+
});
870+
871+
// Now second asset (a2) mounts
872+
const videoEl2 = view.container.querySelector("video");
873+
if (!videoEl2) throw new Error("no second <video>");
874+
const video2 = driveVideo(videoEl2);
875+
act(() => {
876+
fireEvent.loadedMetadata(videoEl2);
877+
});
878+
video2.play();
879+
// Play to end of clip_2 / timeline (10s)
880+
video2.seekTo(5);
881+
video2.pause();
882+
act(() => {
883+
fireEvent.ended(videoEl2);
884+
});
885+
expect(videoEl2.paused).toBe(true);
886+
887+
// Shell triggers replay: handleSeek(0, true) + play()
888+
act(() => {
889+
currentSeekTarget = { timeSec: 0, requestId: 2, preservePlayback: true };
890+
view.rerender(
891+
<VirtualPreview
892+
videoSources={sources}
893+
clips={clips}
894+
seekTarget={currentSeekTarget}
895+
onTimeChange={onTimeChange}
896+
/>,
897+
);
898+
video2.play();
899+
fireEvent.play(videoEl2);
900+
});
901+
902+
// Replay triggers switch back to a1
903+
const replayVideoEl1 = view.container.querySelector("video");
904+
if (!replayVideoEl1) throw new Error("no replay <video>");
905+
const replayVideo1 = driveVideo(replayVideoEl1);
906+
907+
// Metadata loads for a1
908+
act(() => {
909+
fireEvent.loadedMetadata(replayVideoEl1);
910+
});
911+
912+
// Replay must have automatically resumed playback on the first asset
913+
expect(replayVideo1.currentTime).toBe(0);
914+
expect(replayVideoEl1.paused).toBe(false);
915+
});
824916
});

0 commit comments

Comments
 (0)