Skip to content

Commit 77c4fca

Browse files
committed
fix(studio): address PR #2347 review findings (rounds 1-2)
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields
1 parent b751d00 commit 77c4fca

23 files changed

Lines changed: 1327 additions & 254 deletions
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { usePlayerStore } from "../../player/store/playerStore";
7+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8+
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9+
10+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11+
12+
let root: Root | null = null;
13+
14+
afterEach(() => {
15+
if (root) {
16+
act(() => root?.unmount());
17+
root = null;
18+
}
19+
document.body.innerHTML = "";
20+
usePlayerStore.getState().reset();
21+
useAssetPreviewStore.getState().clearPreviewAsset();
22+
});
23+
24+
function mountOverlay(): void {
25+
const host = document.createElement("div");
26+
document.body.append(host);
27+
root = createRoot(host);
28+
act(() => {
29+
root?.render(<AssetPreviewOverlay />);
30+
});
31+
}
32+
33+
describe("AssetPreviewOverlay playback dismissal", () => {
34+
it("dismisses immediately when opened while playback is ALREADY running", () => {
35+
// The RAF playback loop bypasses the store, so no post-open store change
36+
// will arrive — the dismiss check must be level-triggered, not edge-triggered.
37+
usePlayerStore.setState({ isPlaying: true });
38+
mountOverlay();
39+
40+
act(() => {
41+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
42+
});
43+
44+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
45+
expect(document.querySelector('[role="dialog"]')).toBeNull();
46+
});
47+
48+
it("stays open when the playhead is idle", () => {
49+
mountOverlay();
50+
51+
act(() => {
52+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
53+
});
54+
55+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
56+
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
57+
});
58+
59+
it("still dismisses when playback starts AFTER the preview opened", () => {
60+
mountOverlay();
61+
62+
act(() => {
63+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
64+
});
65+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/clip.mp3");
66+
67+
act(() => {
68+
usePlayerStore.setState({ isPlaying: true });
69+
});
70+
71+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
72+
});
73+
74+
it("still dismisses when the playhead is scrubbed after opening", () => {
75+
usePlayerStore.setState({ currentTime: 2 });
76+
mountOverlay();
77+
78+
act(() => {
79+
useAssetPreviewStore.getState().setPreviewAsset("assets/clip.mp3", "p1");
80+
});
81+
82+
act(() => {
83+
usePlayerStore.setState({ currentTime: 4.5 });
84+
});
85+
86+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
87+
});
88+
});

packages/studio/src/components/nle/AssetPreviewOverlay.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,15 @@ export function AssetPreviewOverlay() {
101101
// so a stale render can never dismiss against the wrong reference time.
102102
useEffect(() => {
103103
if (!previewAsset) return;
104-
const openedTime = usePlayerStore.getState().currentTime;
104+
const opened = usePlayerStore.getState();
105+
// Level-triggered, not edge-triggered: a preview opened while playback is
106+
// ALREADY running gets no store change to react to (the RAF loop bypasses
107+
// the store), so evaluate the current state once before subscribing.
108+
if (opened.isPlaying) {
109+
clearPreviewAsset();
110+
return;
111+
}
112+
const openedTime = opened.currentTime;
105113
return usePlayerStore.subscribe((state) => {
106114
if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
107115
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
7+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
8+
import { AssetCard } from "./AssetCard";
9+
import { AudioRow } from "./AudioRow";
10+
11+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12+
13+
let root: Root | null = null;
14+
15+
afterEach(() => {
16+
if (root) {
17+
act(() => root?.unmount());
18+
root = null;
19+
}
20+
document.body.innerHTML = "";
21+
usePlayerStore.getState().reset();
22+
useAssetPreviewStore.getState().clearPreviewAsset();
23+
vi.restoreAllMocks();
24+
});
25+
26+
function mount(node: React.ReactElement): HTMLElement {
27+
const host = document.createElement("div");
28+
document.body.append(host);
29+
root = createRoot(host);
30+
act(() => {
31+
root?.render(node);
32+
});
33+
return host;
34+
}
35+
36+
function clip(input: Partial<TimelineElement> & { id: string; src: string }): TimelineElement {
37+
return { tag: "div", start: 0, duration: 5, track: 0, ...input };
38+
}
39+
40+
/** Simulate a drag-free click: pointerdown + pointerup at the same point. */
41+
function clickCard(host: HTMLElement): void {
42+
const card = host.querySelector('[draggable="true"]');
43+
if (!card) throw new Error("Expected a draggable card root");
44+
const PointerCtor = (window as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
45+
act(() => {
46+
card.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
47+
card.dispatchEvent(new PointerCtor("pointerup", { bubbles: true, clientX: 5, clientY: 5 }));
48+
});
49+
}
50+
51+
describe("AssetCard click behavior", () => {
52+
const cardProps = {
53+
projectId: "p1",
54+
onCopy: vi.fn(),
55+
isCopied: false,
56+
};
57+
58+
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
59+
usePlayerStore.getState().setElements([clip({ id: "img1", src: "assets/logo.png" })]);
60+
// Preview overlay is open on ANOTHER asset — the reveal must dismiss it,
61+
// or it stays stuck over the canvas while the timeline reveals the clip.
62+
useAssetPreviewStore.getState().setPreviewAsset("assets/other.png", "p1");
63+
64+
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used />);
65+
clickCard(host);
66+
67+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
68+
expect(usePlayerStore.getState().selectedElementId).toBe("img1");
69+
});
70+
71+
it("opens the preview overlay for a not-yet-added asset", () => {
72+
const host = mount(<AssetCard {...cardProps} asset="assets/logo.png" used={false} />);
73+
clickCard(host);
74+
75+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/logo.png");
76+
expect(useAssetPreviewStore.getState().previewProjectId).toBe("p1");
77+
});
78+
});
79+
80+
describe("AudioRow click behavior", () => {
81+
const rowProps = {
82+
projectId: "p1",
83+
onCopy: vi.fn(),
84+
isCopied: false,
85+
};
86+
87+
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
88+
usePlayerStore
89+
.getState()
90+
.setElements([clip({ id: "bgm1", tag: "audio", src: "assets/bgm.mp3" })]);
91+
useAssetPreviewStore.getState().setPreviewAsset("assets/other.mp3", "p1");
92+
93+
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used />);
94+
clickCard(host);
95+
96+
expect(useAssetPreviewStore.getState().previewAsset).toBeNull();
97+
expect(usePlayerStore.getState().selectedElementId).toBe("bgm1");
98+
});
99+
100+
it("opens the preview overlay for a not-yet-added audio asset", () => {
101+
const host = mount(<AudioRow {...rowProps} asset="assets/bgm.mp3" used={false} />);
102+
clickCard(host);
103+
104+
expect(useAssetPreviewStore.getState().previewAsset).toBe("assets/bgm.mp3");
105+
});
106+
});

packages/studio/src/components/sidebar/AssetCard.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export function AssetCard({
136136
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
137137
const elements = usePlayerStore((s) => s.elements);
138138
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
139+
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
139140

140141
const handlePointerDown = useCallback((e: React.PointerEvent) => {
141142
pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -151,6 +152,9 @@ export function AssetCard({
151152
if (used) {
152153
const clip = findClipForAsset(elements, asset);
153154
if (clip) {
155+
// Dismiss any open preview overlay (from another asset) — the reveal
156+
// must not leave a stale preview card floating over the canvas.
157+
clearPreviewAsset();
154158
const clipKey = clip.key ?? clip.id;
155159
setSelectedElementId(clipKey);
156160
// Scroll the timeline so the selected clip is actually visible.
@@ -161,7 +165,16 @@ export function AssetCard({
161165
// Not added (or no matching clip found) → preview overlay
162166
setPreviewAsset(asset, projectId);
163167
},
164-
[used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset],
168+
[
169+
used,
170+
elements,
171+
asset,
172+
projectId,
173+
setSelectedElementId,
174+
requestClipReveal,
175+
setPreviewAsset,
176+
clearPreviewAsset,
177+
],
165178
);
166179

167180
return (

packages/studio/src/components/sidebar/AudioRow.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function AudioRow({
4646
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
4747
const elements = usePlayerStore((s) => s.elements);
4848
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
49+
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
4950

5051
const handlePointerDown = useCallback((e: React.PointerEvent) => {
5152
pointerDownRef.current = { x: e.clientX, y: e.clientY };
@@ -60,6 +61,9 @@ export function AudioRow({
6061
if (used) {
6162
const clip = findClipForAsset(elements, asset);
6263
if (clip) {
64+
// Dismiss any open preview overlay (from another asset) — the reveal
65+
// must not leave a stale preview card floating over the canvas.
66+
clearPreviewAsset();
6367
const clipKey = clip.key ?? clip.id;
6468
setSelectedElementId(clipKey);
6569
// Scroll the timeline so the selected clip is actually visible.
@@ -70,7 +74,16 @@ export function AudioRow({
7074
// Not added → preview overlay (audio player)
7175
setPreviewAsset(asset, projectId);
7276
},
73-
[used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset],
77+
[
78+
used,
79+
elements,
80+
asset,
81+
projectId,
82+
setSelectedElementId,
83+
requestClipReveal,
84+
setPreviewAsset,
85+
clearPreviewAsset,
86+
],
7487
);
7588

7689
useEffect(() => {

packages/studio/src/hooks/timelineEditingHelpers.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,19 @@ describe("persistTimelineBatchEdit", () => {
184184

185185
expect(writes).toHaveLength(0);
186186
});
187+
188+
it("throws on a mistargeted member instead of silently dropping it", async () => {
189+
// A member whose target does not resolve in the source (stale id) patches
190+
// to the identical string too — but that is a targeting FAILURE, not an
191+
// already-at-target no-op, and must abort the batch like the single path.
192+
stubReadFileContent(SOURCE);
193+
const writes: Array<[string, string]> = [];
194+
195+
await expect(
196+
persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
197+
).rejects.toThrow("Unable to patch timeline element ghost in index.html");
198+
expect(writes).toHaveLength(0);
199+
});
187200
});
188201

189202
describe("deleteSelectedKeyframes", () => {

packages/studio/src/hooks/timelineEditingHelpers.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2-
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
2+
import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
33
import {
44
formatTimelineAttributeNumber,
55
type TimelineStackingReorderIntent,
@@ -319,10 +319,17 @@ export async function persistTimelineBatchEdit(
319319
}
320320

321321
const current = patchedByPath.get(targetPath) ?? original;
322+
// Resolve the target FIRST: byte-identical output below is only a legit
323+
// no-op when the member actually resolved in the source. A mistargeted
324+
// member (stale id/selector) must fail loudly like the single-edit path,
325+
// not be silently dropped as "already at target".
326+
if (!findTagByTarget(current, patchTarget)) {
327+
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
328+
}
322329
const patched = change.buildPatches(current, patchTarget);
323-
// A member whose attributes already hold the target values patches to the
324-
// identical string — e.g. a track-insert renumber where one clip's lane is
325-
// already correct. That is a legitimate no-op, not a targeting failure:
330+
// The target resolved, so a member whose attributes already hold the target
331+
// values patches to the identical string — e.g. a track-insert renumber
332+
// where one clip's lane is already correct. That is a legitimate no-op:
326333
// skip it instead of aborting (and rolling back) the whole batch.
327334
if (patched === current) continue;
328335
patchedByPath.set(targetPath, patched);

0 commit comments

Comments
 (0)