@hyperframes/studio 0.8.74 → 0.8.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-Bs9qG2hg.js → hyperframes-player-WbBVbWuO.js} +1 -1
- package/dist/assets/{index-vccPjvCl.js → index-BiFKcNjs.js} +1 -1
- package/dist/assets/{index-DhTyI9zH.js → index-BlUYw1MB.js} +162 -162
- package/dist/assets/{index-B86QTmD2.js → index-HjexPsny.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +2230 -2174
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/timelineEditingHelpers.ts +11 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
- package/src/hooks/useTimelineDeleteOps.ts +8 -7
- package/src/player/components/PlayheadIndicator.tsx +4 -1
- package/src/player/components/Timeline.test.ts +1 -3
- package/src/player/components/Timeline.virtualization.test.tsx +21 -0
- package/src/player/components/TimelineCanvas.tsx +14 -12
- package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
- package/src/player/components/TimelineRuler.tsx +7 -40
- package/src/player/components/useSettledScrollLeft.ts +38 -0
- package/src/player/hooks/useShadowPreviewReload.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
- package/src/player/hooks/useTimelinePlayer.ts +14 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.75",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
"dockview-react": "8.3.1",
|
|
53
53
|
"gsap": "^3.13.0",
|
|
54
54
|
"mediabunny": "^1.45.3",
|
|
55
|
-
"@hyperframes/core": "0.8.
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
59
|
-
"@hyperframes/
|
|
55
|
+
"@hyperframes/core": "0.8.75",
|
|
56
|
+
"@hyperframes/player": "0.8.75",
|
|
57
|
+
"@hyperframes/parsers": "0.8.75",
|
|
58
|
+
"@hyperframes/sdk": "0.8.75",
|
|
59
|
+
"@hyperframes/studio-server": "0.8.75"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"vite": "^6.4.2",
|
|
73
73
|
"vitest": "^4.1.11",
|
|
74
74
|
"zustand": "^5.0.0",
|
|
75
|
-
"@hyperframes/producer": "0.8.
|
|
75
|
+
"@hyperframes/producer": "0.8.75"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
78
|
"react": "19",
|
|
@@ -182,6 +182,17 @@ export function patchIframeDomTiming(
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/** Takes deleted clips out of the live preview at once: until the reload lands, anything that re-reads the
|
|
186
|
+
* preview (composition enrichment) would otherwise put them back on the timeline. */
|
|
187
|
+
export function removeIframeTimelineElements(
|
|
188
|
+
iframe: HTMLIFrameElement | null,
|
|
189
|
+
elements: TimelineElement[],
|
|
190
|
+
activeCompositionPath: string | null = null,
|
|
191
|
+
): void {
|
|
192
|
+
for (const element of elements)
|
|
193
|
+
findTimelineElementInIframe(iframe, element, activeCompositionPath)?.remove();
|
|
194
|
+
}
|
|
195
|
+
|
|
185
196
|
export function playbackStartAttributeForElement(
|
|
186
197
|
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
|
|
187
198
|
): "data-media-start" | "data-playback-start" {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import type
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
6
6
|
import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
7
7
|
import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
|
|
8
8
|
import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
|
|
@@ -78,9 +78,30 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
|
|
|
78
78
|
return null;
|
|
79
79
|
}
|
|
80
80
|
mountReactHarness(<Harness />);
|
|
81
|
-
return { b: elements[1], getHook: () => hook! };
|
|
81
|
+
return { elements, b: elements[1], getHook: () => hook! };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
it("drops the deleted clip from the timeline while the ripple is still saving", async () => {
|
|
85
|
+
let finishRipple = () => {};
|
|
86
|
+
const handleTimelineGroupMove = vi.fn(
|
|
87
|
+
() => new Promise<void>((resolve) => (finishRipple = resolve)),
|
|
88
|
+
);
|
|
89
|
+
const { elements, b, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
|
|
90
|
+
usePlayerStore.getState().setElements(elements);
|
|
91
|
+
|
|
92
|
+
let deleting = Promise.resolve();
|
|
93
|
+
await act(async () => {
|
|
94
|
+
deleting = getHook().handleTimelineElementDelete(b);
|
|
95
|
+
await vi.waitFor(() => expect(handleTimelineGroupMove).toHaveBeenCalled());
|
|
96
|
+
});
|
|
97
|
+
expect(usePlayerStore.getState().elements.map((e) => e.id)).toEqual(["hf-a", "hf-c"]);
|
|
98
|
+
|
|
99
|
+
await act(async () => {
|
|
100
|
+
finishRipple();
|
|
101
|
+
await deleting;
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
84
105
|
// editHistory.ts's coalescing keeps the LAST recordEdit call's label, so the
|
|
85
106
|
// folded ripple move must carry the delete's label, not its own.
|
|
86
107
|
it("passes the delete's own label to the folded ripple move, not 'Move timeline clips'", async () => {
|
|
@@ -228,3 +249,65 @@ describe("useTimelineDeleteOps: undo race", () => {
|
|
|
228
249
|
});
|
|
229
250
|
});
|
|
230
251
|
});
|
|
252
|
+
|
|
253
|
+
// Regression: the live preview kept a deleted clip until its reload landed, and composition
|
|
254
|
+
// enrichment re-read it from there, so the clip stayed drawn after the file dropped it.
|
|
255
|
+
describe("useTimelineDeleteOps: live preview", () => {
|
|
256
|
+
const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
257
|
+
<div data-hf-id="hf-stage" data-composition-id="main" data-duration="4">
|
|
258
|
+
<div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
|
|
259
|
+
<div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
|
|
260
|
+
</div>
|
|
261
|
+
</body></html>`;
|
|
262
|
+
|
|
263
|
+
beforeEach(() => {
|
|
264
|
+
vi.stubGlobal(
|
|
265
|
+
"fetch",
|
|
266
|
+
vi.fn(async () => new Response(JSON.stringify({ changed: true, content: html }))),
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
document.body.innerHTML = "";
|
|
272
|
+
vi.unstubAllGlobals();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("takes the deleted clip out of the live preview before its reload", async () => {
|
|
276
|
+
const preview = document.implementation.createHTMLDocument();
|
|
277
|
+
preview.documentElement.innerHTML = html;
|
|
278
|
+
const iframe = { contentDocument: preview } as unknown as HTMLIFrameElement;
|
|
279
|
+
const clip = (id: string, start: number): TimelineElement => ({
|
|
280
|
+
id,
|
|
281
|
+
tag: "div",
|
|
282
|
+
start,
|
|
283
|
+
duration: 2,
|
|
284
|
+
track: 0,
|
|
285
|
+
hfId: id,
|
|
286
|
+
sourceFile: "index.html",
|
|
287
|
+
});
|
|
288
|
+
const elements = [clip("hf-a", 0), clip("hf-b", 2)];
|
|
289
|
+
let hook: ReturnType<typeof useTimelineDeleteOps> | null = null;
|
|
290
|
+
function Harness() {
|
|
291
|
+
hook = useTimelineDeleteOps({
|
|
292
|
+
projectIdRef: { current: "test-project" },
|
|
293
|
+
activeCompPath: "index.html",
|
|
294
|
+
timelineElements: elements,
|
|
295
|
+
showToast: vi.fn(),
|
|
296
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
297
|
+
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
298
|
+
reloadPreview: vi.fn(),
|
|
299
|
+
previewIframeRef: { current: iframe },
|
|
300
|
+
handleTimelineGroupMove: vi.fn().mockResolvedValue(undefined),
|
|
301
|
+
});
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
mountReactHarness(<Harness />);
|
|
305
|
+
|
|
306
|
+
await act(async () => {
|
|
307
|
+
await hook!.handleTimelineElementDelete(elements[1]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(preview.querySelector('[data-hf-id="hf-b"]')).toBeNull();
|
|
311
|
+
expect(preview.querySelector('[data-hf-id="hf-a"]')).not.toBeNull();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -8,7 +8,7 @@ import { usePlayerStore } from "../player";
|
|
|
8
8
|
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
|
-
import { buildPatchTarget } from "./timelineEditingHelpers";
|
|
11
|
+
import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
|
|
12
12
|
import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
|
|
13
13
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
14
14
|
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
@@ -172,6 +172,7 @@ export function useTimelineDeleteOps({
|
|
|
172
172
|
throw error;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
removeIframeTimelineElements(previewIframeRef.current, sameFile, activeCompPath);
|
|
175
176
|
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
|
176
177
|
const survivors = timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id));
|
|
177
178
|
|
|
@@ -184,10 +185,11 @@ export function useTimelineDeleteOps({
|
|
|
184
185
|
sameFile,
|
|
185
186
|
usePlayerStore.getState().rippleEditEnabled,
|
|
186
187
|
);
|
|
187
|
-
|
|
188
|
+
const rippleChanges = rippleShifts ? resolveShiftedElements(survivors, rippleShifts) : null;
|
|
189
|
+
// The timeline follows the delete's write at once, as the owner of the ripple's optimistic update.
|
|
190
|
+
usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleChanges));
|
|
188
191
|
let rippleFailed = false;
|
|
189
|
-
if (
|
|
190
|
-
const rippleChanges = resolveShiftedElements(survivors, rippleShifts);
|
|
192
|
+
if (rippleChanges) {
|
|
191
193
|
try {
|
|
192
194
|
await handleTimelineGroupMove(rippleChanges, {
|
|
193
195
|
coalesceKey,
|
|
@@ -200,18 +202,17 @@ export function useTimelineDeleteOps({
|
|
|
200
202
|
// the generic one would tell the user about one action twice.
|
|
201
203
|
suppressFailureToast: true,
|
|
202
204
|
});
|
|
203
|
-
rippleApplied = rippleChanges;
|
|
204
205
|
} catch (error) {
|
|
205
206
|
// The delete already committed; a failed ripple leaves the gap the
|
|
206
207
|
// toggle-off behaviour would have left anyway — not worth undoing
|
|
207
208
|
// an otherwise-successful delete over.
|
|
208
209
|
rippleFailed = true;
|
|
210
|
+
usePlayerStore.getState().setElements(survivors);
|
|
209
211
|
console.error("[Timeline] ripple-edit failed to persist after delete", error);
|
|
210
212
|
showToast("Clip deleted, but the gap could not be closed.", "error");
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
|
|
215
216
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
216
217
|
usePlayerStore.getState().setSelectedElementIds(new Set());
|
|
217
218
|
forceReloadSdkSession?.();
|
|
@@ -224,7 +225,7 @@ export function useTimelineDeleteOps({
|
|
|
224
225
|
"info",
|
|
225
226
|
);
|
|
226
227
|
}
|
|
227
|
-
if (
|
|
228
|
+
if (rippleChanges && !rippleFailed && !rippleNoticeShownRef.current) {
|
|
228
229
|
rippleNoticeShownRef.current = true;
|
|
229
230
|
showToast(
|
|
230
231
|
"Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
|
|
13
13
|
|
|
14
|
+
/** Widest part of the playhead, centred on its line. */
|
|
15
|
+
export const PLAYHEAD_GLOW_W = 13;
|
|
16
|
+
|
|
14
17
|
interface PlayheadIndicatorProps {
|
|
15
18
|
/** CSS color, defaults to the HF accent variable */
|
|
16
19
|
color?: string;
|
|
@@ -50,7 +53,7 @@ export function PlayheadIndicator({
|
|
|
50
53
|
className="absolute top-0 bottom-0"
|
|
51
54
|
style={{
|
|
52
55
|
left: "50%",
|
|
53
|
-
width:
|
|
56
|
+
width: PLAYHEAD_GLOW_W,
|
|
54
57
|
transform: "translateX(-50%)",
|
|
55
58
|
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
|
|
56
59
|
}}
|
|
@@ -88,9 +88,7 @@ function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: str
|
|
|
88
88
|
if (!ruler) throw new Error("Missing timeline ruler");
|
|
89
89
|
const rulerOrigin = ruler.previousElementSibling;
|
|
90
90
|
if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
|
|
91
|
-
const playhead =
|
|
92
|
-
(node) => node.style.zIndex === "100",
|
|
93
|
-
);
|
|
91
|
+
const playhead = host.querySelector<HTMLElement>("[data-timeline-playhead-layer] > div");
|
|
94
92
|
if (!playhead) throw new Error("Missing timeline playhead");
|
|
95
93
|
return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
|
|
96
94
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
|
|
6
7
|
|
|
7
8
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
9
|
|
|
@@ -430,6 +431,26 @@ describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
|
|
|
430
431
|
};
|
|
431
432
|
}
|
|
432
433
|
|
|
434
|
+
it("clips a scrolled-off playhead at the track headers once the scroll settles", async () => {
|
|
435
|
+
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
436
|
+
try {
|
|
437
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
438
|
+
const layer = () => host.querySelector<HTMLElement>("[data-timeline-playhead-layer]");
|
|
439
|
+
await scrollTimelineHorizontally(scroller, 166);
|
|
440
|
+
expect(layer()?.style.clipPath).toBe("");
|
|
441
|
+
await act(async () => {
|
|
442
|
+
await new Promise((resolve) => setTimeout(resolve, TIMELINE_SCROLL_SETTLE_MS + 20));
|
|
443
|
+
});
|
|
444
|
+
// The ruler's corner is as wide as the track headers; the glow may overhang by 6.5px.
|
|
445
|
+
const headerWidth = parseFloat(
|
|
446
|
+
host.querySelector<HTMLElement>(".sticky.left-0")!.style.width,
|
|
447
|
+
);
|
|
448
|
+
expect(layer()?.style.clipPath).toContain(`${166 + headerWidth - 6.5}px)`);
|
|
449
|
+
} finally {
|
|
450
|
+
dispose();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
433
454
|
it("mounts every clip rather than a window", async () => {
|
|
434
455
|
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
435
456
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
|
+
import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
|
|
4
5
|
import {
|
|
5
6
|
RULER_H,
|
|
6
7
|
CLIP_Y,
|
|
@@ -236,18 +237,19 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
236
237
|
the half-head offset baked into getTimelinePlayheadLeft keep the
|
|
237
238
|
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
|
|
238
239
|
ticks' center), instead of relying on shrink-wrap sizing. */}
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
240
|
+
<TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
|
|
241
|
+
<div
|
|
242
|
+
ref={props.playheadRef}
|
|
243
|
+
className="absolute top-0 bottom-0 pointer-events-none"
|
|
244
|
+
style={{
|
|
245
|
+
left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
|
|
246
|
+
width: PLAYHEAD_HEAD_W,
|
|
247
|
+
display: beatDragging ? "none" : undefined,
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
<PlayheadIndicator scrubbing={props.isScrubbing} />
|
|
251
|
+
</div>
|
|
252
|
+
</TimelinePlayheadLayer>
|
|
251
253
|
</div>
|
|
252
254
|
);
|
|
253
255
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
|
+
import { PLAYHEAD_GLOW_W } from "./PlayheadIndicator";
|
|
3
|
+
import { useSettledScrollLeft } from "./useSettledScrollLeft";
|
|
4
|
+
|
|
5
|
+
// Clips the playhead at the track headers' edge once a scroll settles, so one scrolled off to
|
|
6
|
+
// the left does not draw over the track names. Half the glow may pass the edge so 00:00 looks
|
|
7
|
+
// as before; mid-scroll it is not clipped.
|
|
8
|
+
export function TimelinePlayheadLayer({
|
|
9
|
+
scrollRef,
|
|
10
|
+
contentOrigin,
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
14
|
+
contentOrigin: number;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
const settledScrollLeft = useSettledScrollLeft(scrollRef);
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
data-timeline-playhead-layer=""
|
|
21
|
+
className="absolute inset-0 pointer-events-none"
|
|
22
|
+
style={{
|
|
23
|
+
zIndex: 100,
|
|
24
|
+
clipPath:
|
|
25
|
+
settledScrollLeft === null
|
|
26
|
+
? undefined
|
|
27
|
+
: `inset(0 0 0 ${settledScrollLeft + contentOrigin - PLAYHEAD_GLOW_W / 2}px)`,
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { memo,
|
|
2
|
-
import {
|
|
1
|
+
import { memo, type RefObject } from "react";
|
|
2
|
+
import { useSettledScrollLeft } from "./useSettledScrollLeft";
|
|
3
3
|
import type { TimelineTheme } from "./timelineTheme";
|
|
4
4
|
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
|
|
5
5
|
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
|
|
@@ -41,11 +41,8 @@ function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number):
|
|
|
41
41
|
return found;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* on every tick crossing cost 2-3x the scroll budget on a large timeline, so mid-scroll a
|
|
47
|
-
* fragment can still show.
|
|
48
|
-
*/
|
|
44
|
+
// Paints over the one label the header corner would slice, once a scroll settles; mid-scroll a
|
|
45
|
+
// fragment can still show.
|
|
49
46
|
const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
|
|
50
47
|
scrollRef,
|
|
51
48
|
major,
|
|
@@ -57,39 +54,9 @@ const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
|
|
|
57
54
|
pps: number;
|
|
58
55
|
background: string;
|
|
59
56
|
}) {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const el = scrollRef.current;
|
|
64
|
-
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
-
const onScroll = () => {
|
|
66
|
-
if (!scrollingRef.current) {
|
|
67
|
-
scrollingRef.current = true;
|
|
68
|
-
onChange();
|
|
69
|
-
}
|
|
70
|
-
clearTimeout(settle);
|
|
71
|
-
settle = setTimeout(() => {
|
|
72
|
-
scrollingRef.current = false;
|
|
73
|
-
onChange();
|
|
74
|
-
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
75
|
-
};
|
|
76
|
-
el?.addEventListener("scroll", onScroll, { passive: true });
|
|
77
|
-
return () => {
|
|
78
|
-
clearTimeout(settle);
|
|
79
|
-
scrollingRef.current = false;
|
|
80
|
-
el?.removeEventListener("scroll", onScroll);
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
[scrollRef],
|
|
84
|
-
);
|
|
85
|
-
const index = useSyncExternalStore(
|
|
86
|
-
subscribe,
|
|
87
|
-
() =>
|
|
88
|
-
scrollingRef.current
|
|
89
|
-
? -1
|
|
90
|
-
: tickIndexUnderHeader(major, pps, scrollRef.current?.scrollLeft ?? 0),
|
|
91
|
-
() => -1,
|
|
92
|
-
);
|
|
57
|
+
const settledScrollLeft = useSettledScrollLeft(scrollRef);
|
|
58
|
+
const index =
|
|
59
|
+
settledScrollLeft === null ? -1 : tickIndexUnderHeader(major, pps, settledScrollLeft);
|
|
93
60
|
if (index < 0) return null;
|
|
94
61
|
const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
|
|
95
62
|
const next = major[index + 1];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
|
|
2
|
+
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
|
|
3
|
+
|
|
4
|
+
// The timeline viewport's scrollLeft once a scroll settles, or null while it is scrolling. Readers
|
|
5
|
+
// re-render twice per gesture, never per frame: per-frame work cost 2-3x the viewport gate's budget
|
|
6
|
+
// on a large unvirtualized timeline.
|
|
7
|
+
export function useSettledScrollLeft(scrollRef: RefObject<HTMLDivElement | null>): number | null {
|
|
8
|
+
const scrollingRef = useRef(false);
|
|
9
|
+
const subscribe = useCallback(
|
|
10
|
+
(onChange: () => void) => {
|
|
11
|
+
const el = scrollRef.current;
|
|
12
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
const onScroll = () => {
|
|
14
|
+
if (!scrollingRef.current) {
|
|
15
|
+
scrollingRef.current = true;
|
|
16
|
+
onChange();
|
|
17
|
+
}
|
|
18
|
+
clearTimeout(settle);
|
|
19
|
+
settle = setTimeout(() => {
|
|
20
|
+
scrollingRef.current = false;
|
|
21
|
+
onChange();
|
|
22
|
+
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
23
|
+
};
|
|
24
|
+
el?.addEventListener("scroll", onScroll, { passive: true });
|
|
25
|
+
return () => {
|
|
26
|
+
clearTimeout(settle);
|
|
27
|
+
scrollingRef.current = false;
|
|
28
|
+
el?.removeEventListener("scroll", onScroll);
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
[scrollRef],
|
|
32
|
+
);
|
|
33
|
+
return useSyncExternalStore(
|
|
34
|
+
subscribe,
|
|
35
|
+
() => (scrollingRef.current ? null : (scrollRef.current?.scrollLeft ?? 0)),
|
|
36
|
+
() => null,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./useTimelineSyncCallbacks";
|
|
15
15
|
import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
16
16
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
17
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
17
18
|
|
|
18
19
|
// One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
19
20
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
@@ -35,6 +36,8 @@ type UseShadowPreviewReloadParams = Omit<
|
|
|
35
36
|
onPromoted?: () => void;
|
|
36
37
|
/** A shadow that never became ready was dropped; the live preview is unchanged. */
|
|
37
38
|
onReloadFailed?: (message: string) => void;
|
|
39
|
+
/** Puts the promoted document at the live frame's time, playing if the live frame was. */
|
|
40
|
+
handOverPlayback: (time: number, playing: boolean) => void;
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export function useShadowPreviewReload({
|
|
@@ -51,6 +54,7 @@ export function useShadowPreviewReload({
|
|
|
51
54
|
applyPreviewAudioState,
|
|
52
55
|
onPromoted,
|
|
53
56
|
onReloadFailed,
|
|
57
|
+
handOverPlayback,
|
|
54
58
|
}: UseShadowPreviewReloadParams) {
|
|
55
59
|
const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
56
60
|
const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
|
@@ -62,6 +66,8 @@ export function useShadowPreviewReload({
|
|
|
62
66
|
onPromotedRef.current = onPromoted;
|
|
63
67
|
const onReloadFailedRef = useRef(onReloadFailed);
|
|
64
68
|
onReloadFailedRef.current = onReloadFailed;
|
|
69
|
+
const handOverPlaybackRef = useRef(handOverPlayback);
|
|
70
|
+
handOverPlaybackRef.current = handOverPlayback;
|
|
65
71
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
66
72
|
const budgetsSpentRef = useRef(0);
|
|
67
73
|
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
@@ -102,6 +108,11 @@ export function useShadowPreviewReload({
|
|
|
102
108
|
const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
|
|
103
109
|
if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
|
|
104
110
|
stopPendingShadow();
|
|
111
|
+
// The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
|
|
112
|
+
const live = getAdapter();
|
|
113
|
+
const liveTime = live?.getTime();
|
|
114
|
+
const playing = usePlayerStore.getState().isPlaying;
|
|
115
|
+
live?.pause();
|
|
105
116
|
// The store takes the new document's timeline only now that it is the one on screen.
|
|
106
117
|
pending.commit();
|
|
107
118
|
iframeRef.current = shadow;
|
|
@@ -109,10 +120,17 @@ export function useShadowPreviewReload({
|
|
|
109
120
|
attachIframeShortcutListeners();
|
|
110
121
|
applyPreviewAudioState();
|
|
111
122
|
setPreviewSlots((prev) => planShadowPromotion(prev, gen));
|
|
123
|
+
if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
|
|
112
124
|
onPromotedRef.current?.();
|
|
113
125
|
thumbnailScheduler.setPreviewReloading(false);
|
|
114
126
|
},
|
|
115
|
-
[
|
|
127
|
+
[
|
|
128
|
+
stopPendingShadow,
|
|
129
|
+
getAdapter,
|
|
130
|
+
iframeRef,
|
|
131
|
+
attachIframeShortcutListeners,
|
|
132
|
+
applyPreviewAudioState,
|
|
133
|
+
],
|
|
116
134
|
);
|
|
117
135
|
|
|
118
136
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
@@ -224,6 +224,91 @@ describe("shadow reload readiness and failure", () => {
|
|
|
224
224
|
unmount(root);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
it("keeps a playing film playing through a reload, from where the live frame had reached", () => {
|
|
228
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
229
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
230
|
+
act(() => {
|
|
231
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
232
|
+
getApi().onIframeLoad();
|
|
233
|
+
});
|
|
234
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
235
|
+
act(() => getApi().play());
|
|
236
|
+
liveAdapter.pause.mockClear();
|
|
237
|
+
liveAdapter.seek(4.2);
|
|
238
|
+
|
|
239
|
+
act(() => getApi().refreshPlayer());
|
|
240
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
241
|
+
expect(liveAdapter.pause).not.toHaveBeenCalled();
|
|
242
|
+
|
|
243
|
+
liveAdapter.seek(5.5);
|
|
244
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
245
|
+
const shadow = makeShadowWithSpies();
|
|
246
|
+
act(() => {
|
|
247
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
248
|
+
getApi().onShadowIframeLoad(gen);
|
|
249
|
+
getApi().onShadowReadyChange(gen, true);
|
|
250
|
+
});
|
|
251
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
252
|
+
expect(liveAdapter.pause).toHaveBeenCalled();
|
|
253
|
+
expect(shadow.adapter.getTime()).toBe(5.5);
|
|
254
|
+
expect(shadow.adapter.isPlaying()).toBe(true);
|
|
255
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
256
|
+
unmount(root);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps where a film was paused while the reload loaded", () => {
|
|
260
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
261
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
262
|
+
act(() => {
|
|
263
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
264
|
+
getApi().onIframeLoad();
|
|
265
|
+
});
|
|
266
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
267
|
+
act(() => getApi().play());
|
|
268
|
+
liveAdapter.seek(4.2);
|
|
269
|
+
act(() => getApi().refreshPlayer());
|
|
270
|
+
|
|
271
|
+
liveAdapter.seek(7);
|
|
272
|
+
act(() => getApi().pause());
|
|
273
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
274
|
+
const shadow = makeShadowWithSpies();
|
|
275
|
+
act(() => {
|
|
276
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
277
|
+
getApi().onShadowIframeLoad(gen);
|
|
278
|
+
getApi().onShadowReadyChange(gen, true);
|
|
279
|
+
});
|
|
280
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
281
|
+
expect(shadow.adapter.getTime()).toBe(7);
|
|
282
|
+
expect(shadow.adapter.isPlaying()).toBe(false);
|
|
283
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
284
|
+
unmount(root);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("stops a playing film at the new end when the edit cut it short of the live time", () => {
|
|
288
|
+
const { getApi, root } = renderTimelinePlayerHarness();
|
|
289
|
+
const { adapter: liveAdapter, win } = makeAdapterWindow();
|
|
290
|
+
act(() => {
|
|
291
|
+
getApi().iframeRef.current = makeFakeIframe(win);
|
|
292
|
+
getApi().onIframeLoad();
|
|
293
|
+
});
|
|
294
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
295
|
+
act(() => getApi().play());
|
|
296
|
+
act(() => getApi().refreshPlayer());
|
|
297
|
+
|
|
298
|
+
liveAdapter.seek(12);
|
|
299
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
300
|
+
const shorter = makeAdapterWindow({ duration: 10 });
|
|
301
|
+
act(() => {
|
|
302
|
+
getApi().setShadowIframeNode(makeFakeIframe(shorter.win));
|
|
303
|
+
getApi().onShadowIframeLoad(gen);
|
|
304
|
+
getApi().onShadowReadyChange(gen, true);
|
|
305
|
+
});
|
|
306
|
+
expect(shorter.adapter.getTime()).toBe(10);
|
|
307
|
+
expect(shorter.adapter.isPlaying()).toBe(false);
|
|
308
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
309
|
+
unmount(root);
|
|
310
|
+
});
|
|
311
|
+
|
|
227
312
|
it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
|
|
228
313
|
const { getApi, live, gen, root } = beginReload();
|
|
229
314
|
const shadow = makeShadowWithSpies();
|
|
@@ -400,6 +400,13 @@ export function useTimelinePlayer({
|
|
|
400
400
|
applyPreviewAudioState,
|
|
401
401
|
onPromoted: onShadowPromoted,
|
|
402
402
|
onReloadFailed: onPreviewReloadFailed,
|
|
403
|
+
handOverPlayback: (time, playing) => {
|
|
404
|
+
// keepPlaying: move the playhead without the paused-seek audio scrub.
|
|
405
|
+
seek(time, { keepPlaying: true });
|
|
406
|
+
const adapter = getAdapter();
|
|
407
|
+
// An edit that cut the film short of the live time stops it at the new end, as playback does.
|
|
408
|
+
if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();
|
|
409
|
+
},
|
|
403
410
|
});
|
|
404
411
|
|
|
405
412
|
const saveSeekPosition = useCallback(() => {
|
|
@@ -422,17 +429,21 @@ export function useTimelinePlayer({
|
|
|
422
429
|
}
|
|
423
430
|
}
|
|
424
431
|
isRefreshingRef.current = true;
|
|
425
|
-
|
|
432
|
+
// Forward playback runs on in the live frame; the promotion hands it to the new document.
|
|
433
|
+
const keepPlaying =
|
|
434
|
+
usePlayerStore.getState().isPlaying && shuttleDirectionRef.current !== "backward";
|
|
426
435
|
stopReverseLoop();
|
|
436
|
+
if (keepPlaying) return true;
|
|
437
|
+
stopRAFLoop();
|
|
427
438
|
setIsPlaying(false);
|
|
439
|
+
return false;
|
|
428
440
|
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
|
429
441
|
const refreshPlayer = useCallback(() => {
|
|
430
442
|
const iframe = iframeRef.current;
|
|
431
443
|
if (!iframe) return;
|
|
432
444
|
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
433
|
-
saveSeekPosition();
|
|
434
445
|
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
|
435
|
-
getAdapter()?.pause();
|
|
446
|
+
if (!saveSeekPosition()) getAdapter()?.pause();
|
|
436
447
|
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
|
437
448
|
const src = iframe.src;
|
|
438
449
|
const url = new URL(src, window.location.origin);
|