Skip to content

Commit 7004dd8

Browse files
committed
fix(editor): preserve playback intent and isolate cross-source seeks
1. Pass preservePlayback through handleSeek and SeekTarget down to VirtualPreview so cross-asset replay preserves playback intent while loading the first asset. 2. In VirtualPreview rAF loop (tick()) and onEnded, read loadState through loadStateRef.current (gated on loadStateRef.current === "loading" and pendingSeekRef.current !== null) so in-flight seeks/loads do not evaluate stale or zero timestamps against clip boundaries, and the tick closure does not retain a stale "loading" state across asset switches. 3. In VirtualPreview seekToVirtualTime, pause the active video element immediately on paused cross-asset seeks, and ensure pendingSeekRef only carries play intent when preservePlayback is requested. 4. Restrict timeline-end fallbacks in VirtualPreview (tick() and onEnded) and rewind triggers in NewEditorShell (togglePlay) to the final clip on the timeline without following clips. 5. Add unit and regression tests in NewEditorShell.togglePlay.test.tsx and VirtualPreview.playback.test.tsx.
1 parent 9644513 commit 7004dd8

6 files changed

Lines changed: 756 additions & 69 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
202+
it("does not rewind to 0 when pressing play from an intermediate position after cross-asset seek", async () => {
203+
const doc = makeCrossAssetDocument();
204+
useProjectStore.setState({
205+
document: doc,
206+
projectId: "p1",
207+
currentTimeSec: 4, // in clip1 (0-5s), timeline ends at 10s
208+
playing: false,
209+
});
210+
211+
renderShell();
212+
213+
await act(async () => {
214+
fireEvent.keyDown(document.body, { key: " ", code: "Space" });
215+
});
216+
217+
// Must NOT seek to 0
218+
expect(mocks.latestSeekTarget).toBeNull();
219+
expect(mocks.previewPlay).toHaveBeenCalledTimes(1);
220+
expect(useProjectStore.getState().currentTimeSec).toBe(4);
221+
});
222+
});

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

Lines changed: 18 additions & 5 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
);
@@ -643,10 +649,17 @@ export function NewEditorShell() {
643649
if (videoElement.paused) {
644650
const playhead = useProjectStore.getState().currentTimeSec;
645651
const duration = totalVirtualDuration(clipsForEndedRef.current);
646-
if (duration > 0 && playhead >= duration - 0.05) {
647-
handleSeek(0);
652+
const endPos = locateVirtualPosition(clipsForEndedRef.current, duration);
653+
const currentPos = locateVirtualPosition(clipsForEndedRef.current, playhead);
654+
const hasNextClip = clipsForEndedRef.current.some((c) => c.timelineStartSec > playhead + 0.1);
655+
const isAtEnd =
656+
duration > 0 &&
657+
playhead >= duration - 0.05 &&
658+
!hasNextClip &&
659+
currentPos?.clip.id === endPos?.clip.id;
660+
if (isAtEnd) {
661+
handleSeek(0, true);
648662
const startPos = locateVirtualPosition(clipsForEndedRef.current, 0);
649-
const endPos = locateVirtualPosition(clipsForEndedRef.current, duration);
650663
if (startPos && startPos.clip.assetId === endPos?.clip.assetId) {
651664
try {
652665
videoElement.currentTime = startPos.sourceTimeSec;

‎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;

0 commit comments

Comments
 (0)