@hyperframes/studio 0.8.84 → 0.8.86
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-Dx-2Gpsu.js +473 -0
- package/dist/assets/{index-D9dmtDap.js → index-8Fgryj7q.js} +1 -1
- package/dist/assets/{index-DFcIBFM8.js → index-Cz3E_ZSj.js} +1 -1
- package/dist/assets/{index-gjbSm-vt.js → index-d5DKK15Q.js} +175 -175
- package/dist/assets/{index-BWJqxeaH.css → index-mzZCha_N.css} +1 -1
- package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
- package/dist/chunk-7PLTC6SV.js.map +1 -0
- package/dist/index.d.ts +25 -2
- package/dist/index.html +2 -2
- package/dist/index.js +151 -111
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +53 -2
- package/src/components/TimelineToolbar.tsx +119 -101
- package/src/components/nle/CompositionBreadcrumb.tsx +5 -5
- package/src/components/nle/useCompositionStack.test.tsx +126 -0
- package/src/components/nle/useCompositionStack.ts +22 -12
- package/src/compositionStackExport.test.ts +7 -0
- package/src/index.ts +1 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +65 -0
- package/src/player/components/Timeline.test.ts +32 -19
- package/src/player/components/TimelineLanes.tsx +2 -3
- package/src/player/components/timelineKeyboardNavigation.ts +2 -0
- package/src/player/components/timelineLayout.ts +16 -23
- package/src/player/components/useTimelineGeometry.test.tsx +105 -0
- package/src/player/components/useTimelineGeometry.ts +18 -10
- package/src/player/components/useTimelineKeyboardActor.test.tsx +24 -1
- package/src/player/components/useTimelineKeyboardActor.ts +10 -2
- package/src/player/components/useTimelinePlayhead.ts +1 -1
- package/src/player/store/liveTime.ts +6 -1
- package/dist/assets/hyperframes-player-Dd74fv7B.js +0 -464
- package/dist/chunk-7VDQIGOU.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.86",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.86",
|
|
64
|
+
"@hyperframes/sdk": "0.8.86",
|
|
65
|
+
"@hyperframes/player": "0.8.86",
|
|
66
|
+
"@hyperframes/studio-server": "0.8.86",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.86",
|
|
68
|
+
"@hyperframes/parsers": "0.8.86"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.86"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -24,17 +24,22 @@ vi.mock("../contexts/StudioContext", () => ({
|
|
|
24
24
|
|
|
25
25
|
afterEach(() => {
|
|
26
26
|
document.body.innerHTML = "";
|
|
27
|
-
usePlayerStore.setState({
|
|
27
|
+
usePlayerStore.setState({
|
|
28
|
+
autoKeyframeEnabled: true,
|
|
29
|
+
thumbnailMode: "adaptive",
|
|
30
|
+
zoomMode: "fit",
|
|
31
|
+
});
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
function renderToolbar(
|
|
31
35
|
domEditSession?: React.ComponentProps<typeof TimelineToolbar>["domEditSession"],
|
|
36
|
+
props: Partial<React.ComponentProps<typeof TimelineToolbar>> = {},
|
|
32
37
|
) {
|
|
33
38
|
const host = document.createElement("div");
|
|
34
39
|
document.body.append(host);
|
|
35
40
|
const root = createRoot(host);
|
|
36
41
|
act(() => {
|
|
37
|
-
root.render(<TimelineToolbar domEditSession={domEditSession} />);
|
|
42
|
+
root.render(<TimelineToolbar domEditSession={domEditSession} {...props} />);
|
|
38
43
|
});
|
|
39
44
|
return { host, root };
|
|
40
45
|
}
|
|
@@ -175,6 +180,52 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
|
|
|
175
180
|
expect(button?.disabled).toBe(false);
|
|
176
181
|
act(() => root.unmount());
|
|
177
182
|
});
|
|
183
|
+
|
|
184
|
+
const keyframeControls = (host: HTMLElement) => [
|
|
185
|
+
host.querySelector('button[aria-label="Add keyframe at playhead"]'),
|
|
186
|
+
host.querySelector('button[aria-label="Auto-record manual edits as keyframes"]'),
|
|
187
|
+
];
|
|
188
|
+
/** True when the keyframe shortcut claimed K, so playback never saw it. */
|
|
189
|
+
const pressK = () => {
|
|
190
|
+
const event = new KeyboardEvent("keydown", { key: "k", bubbles: true, cancelable: true });
|
|
191
|
+
act(() => {
|
|
192
|
+
window.dispatchEvent(event);
|
|
193
|
+
});
|
|
194
|
+
return event.defaultPrevented;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
it("shows Add keyframe and auto-record, and K adds a keyframe, by default", () => {
|
|
198
|
+
const { host, root } = renderToolbar(sessionFor("div"));
|
|
199
|
+
expect(keyframeControls(host).every(Boolean)).toBe(true);
|
|
200
|
+
expect(pressK()).toBe(true);
|
|
201
|
+
act(() => root.unmount());
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("hides both controls for a host without keyframes, turns auto-record off and leaves K alone", () => {
|
|
205
|
+
const { host, root } = renderToolbar(sessionFor("div"), { showKeyframes: false });
|
|
206
|
+
expect(keyframeControls(host)).toEqual([null, null]);
|
|
207
|
+
expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
|
|
208
|
+
expect(pressK()).toBe(false);
|
|
209
|
+
act(() => root.unmount());
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("TimelineToolbar Fit", () => {
|
|
214
|
+
it("shows Fit as a named icon and says whether fit is on", () => {
|
|
215
|
+
const { host, root } = renderToolbar();
|
|
216
|
+
const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
|
|
217
|
+
expect(fit()?.textContent).toBe("");
|
|
218
|
+
expect(fit()?.querySelector("svg")).not.toBeNull();
|
|
219
|
+
act(() => fit()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
220
|
+
expect(fit()?.getAttribute("aria-pressed")).toBe("true");
|
|
221
|
+
act(() =>
|
|
222
|
+
host
|
|
223
|
+
.querySelector('button[aria-label="Zoom in"]')
|
|
224
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
225
|
+
);
|
|
226
|
+
expect(fit()?.getAttribute("aria-pressed")).toBe("false");
|
|
227
|
+
act(() => root.unmount());
|
|
228
|
+
});
|
|
178
229
|
});
|
|
179
230
|
|
|
180
231
|
describe("TimelineToolbar audio meters", () => {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ArrowsOutLineHorizontal,
|
|
4
|
+
Image,
|
|
5
|
+
Magnet,
|
|
6
|
+
MagnifyingGlassMinus,
|
|
7
|
+
MagnifyingGlassPlus,
|
|
8
|
+
} from "@phosphor-icons/react";
|
|
3
9
|
import {
|
|
4
10
|
useEnableKeyframes,
|
|
5
11
|
isPlayheadWithinTween,
|
|
@@ -38,6 +44,8 @@ export interface TimelineToolbarProps {
|
|
|
38
44
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
39
45
|
history?: TimelineHistoryButtonsProps;
|
|
40
46
|
showAddBeat?: boolean;
|
|
47
|
+
/** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
|
|
48
|
+
showKeyframes?: boolean;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
interface KeyframeToggleState {
|
|
@@ -134,6 +142,7 @@ export function TimelineToolbar({
|
|
|
134
142
|
onSplitElement,
|
|
135
143
|
history,
|
|
136
144
|
showAddBeat = true,
|
|
145
|
+
showKeyframes = true,
|
|
137
146
|
}: TimelineToolbarProps) {
|
|
138
147
|
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
139
148
|
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
@@ -174,10 +183,14 @@ export function TimelineToolbar({
|
|
|
174
183
|
// Wire the "Add keyframe (K)" shortcut the toolbar advertises. Active only when
|
|
175
184
|
// there's a keyframeable selection; otherwise K stays JKL-pause in playback.
|
|
176
185
|
useKeyframeKeyboard({
|
|
177
|
-
enabled: Boolean(onToggleKeyframe),
|
|
186
|
+
enabled: showKeyframes && Boolean(onToggleKeyframe),
|
|
178
187
|
onAddKeyframe: onToggleKeyframe,
|
|
179
188
|
});
|
|
180
189
|
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!showKeyframes) setAutoKeyframeEnabled(false);
|
|
192
|
+
}, [showKeyframes, setAutoKeyframeEnabled]);
|
|
193
|
+
|
|
181
194
|
// "N" toggles timeline snapping (industry convention: Resolve/FCP).
|
|
182
195
|
// Skip when typing in an input/contenteditable.
|
|
183
196
|
useEffect(() => {
|
|
@@ -270,107 +283,110 @@ export function TimelineToolbar({
|
|
|
270
283
|
</button>
|
|
271
284
|
</Tooltip>
|
|
272
285
|
)}
|
|
273
|
-
{
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
? "Extend motion path to playhead (K)"
|
|
285
|
-
: keyframeState === "active"
|
|
286
|
-
? "Remove waypoint from motion path (K)"
|
|
287
|
-
: "Add waypoint to motion path (K)"
|
|
288
|
-
: keyframeState === "active"
|
|
289
|
-
? "Remove keyframe at playhead (K)"
|
|
290
|
-
: keyframeState === "inactive"
|
|
286
|
+
{showKeyframes && (
|
|
287
|
+
<>
|
|
288
|
+
{/* With no keyframeable selection the button fades to disabled instead of
|
|
289
|
+
unmounting, so the toolbar layout never shifts (CapCut-style). */}
|
|
290
|
+
<Tooltip
|
|
291
|
+
label={
|
|
292
|
+
keyframePathEndpoint
|
|
293
|
+
? "Motion path endpoints cannot be removed"
|
|
294
|
+
: !onToggleKeyframe
|
|
295
|
+
? "Select an animated element to add keyframes"
|
|
296
|
+
: keyframeIsMotionPath
|
|
291
297
|
? keyframeWillExtend
|
|
292
|
-
? "
|
|
293
|
-
:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
? "
|
|
312
|
-
:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
298
|
+
? "Extend motion path to playhead (K)"
|
|
299
|
+
: keyframeState === "active"
|
|
300
|
+
? "Remove waypoint from motion path (K)"
|
|
301
|
+
: "Add waypoint to motion path (K)"
|
|
302
|
+
: keyframeState === "active"
|
|
303
|
+
? "Remove keyframe at playhead (K)"
|
|
304
|
+
: keyframeState === "inactive"
|
|
305
|
+
? keyframeWillExtend
|
|
306
|
+
? "Add keyframe at playhead, extends animation (K)"
|
|
307
|
+
: "Add keyframe at playhead (K)"
|
|
308
|
+
: "Add keyframe (K)"
|
|
309
|
+
}
|
|
310
|
+
>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
disabled={!onToggleKeyframe}
|
|
314
|
+
onClick={onToggleKeyframe}
|
|
315
|
+
aria-label={
|
|
316
|
+
keyframePathEndpoint
|
|
317
|
+
? "Motion path endpoint"
|
|
318
|
+
: keyframeIsMotionPath
|
|
319
|
+
? keyframeState === "active"
|
|
320
|
+
? "Remove motion path waypoint"
|
|
321
|
+
: keyframeWillExtend
|
|
322
|
+
? "Extend motion path to playhead"
|
|
323
|
+
: "Add motion path waypoint"
|
|
324
|
+
: keyframeState === "active"
|
|
325
|
+
? "Remove keyframe at playhead"
|
|
326
|
+
: "Add keyframe at playhead"
|
|
327
|
+
}
|
|
328
|
+
className={
|
|
329
|
+
!onToggleKeyframe
|
|
330
|
+
? flatDisabled
|
|
331
|
+
: `${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
|
|
332
|
+
keyframeState === "active"
|
|
333
|
+
? "text-studio-accent"
|
|
334
|
+
: keyframeState === "inactive"
|
|
335
|
+
? "text-neutral-400 hover:text-studio-accent"
|
|
336
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
337
|
+
}`
|
|
338
|
+
}
|
|
339
|
+
>
|
|
340
|
+
<svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
|
|
341
|
+
{keyframeState === "active" ? (
|
|
342
|
+
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
|
343
|
+
) : (
|
|
344
|
+
<path
|
|
345
|
+
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
|
346
|
+
fill="none"
|
|
347
|
+
stroke="currentColor"
|
|
348
|
+
strokeWidth="1.2"
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
</svg>
|
|
352
|
+
</button>
|
|
353
|
+
</Tooltip>
|
|
354
|
+
<Tooltip
|
|
355
|
+
label={
|
|
356
|
+
autoKeyframeEnabled
|
|
357
|
+
? "Auto-record manual edits as keyframes (click to turn off)"
|
|
358
|
+
: "Manual edits will not be recorded as keyframes (click to turn on)"
|
|
359
|
+
}
|
|
360
|
+
>
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
|
|
364
|
+
aria-label="Auto-record manual edits as keyframes"
|
|
365
|
+
aria-pressed={autoKeyframeEnabled}
|
|
366
|
+
className={`${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
|
|
367
|
+
autoKeyframeEnabled
|
|
368
|
+
? "text-red-400 hover:text-red-300"
|
|
369
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
370
|
+
}`}
|
|
371
|
+
>
|
|
372
|
+
<svg width="16" height="16" viewBox="0 0 10 10" fill="none">
|
|
373
|
+
{/* Same diamond outline as the Add-keyframe icon, with a
|
|
360
374
|
record-style dot inside: filled = auto-recording,
|
|
361
375
|
hollow = manual edits won't be keyframed. */}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
376
|
+
<path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
|
|
377
|
+
<circle
|
|
378
|
+
cx="5"
|
|
379
|
+
cy="5"
|
|
380
|
+
r="1.8"
|
|
381
|
+
fill={autoKeyframeEnabled ? "currentColor" : "none"}
|
|
382
|
+
stroke="currentColor"
|
|
383
|
+
strokeWidth="1"
|
|
384
|
+
/>
|
|
385
|
+
</svg>
|
|
386
|
+
</button>
|
|
387
|
+
</Tooltip>
|
|
388
|
+
</>
|
|
389
|
+
)}
|
|
374
390
|
{onSplitElement &&
|
|
375
391
|
(() => {
|
|
376
392
|
// Render the button unconditionally (disabled when unusable):
|
|
@@ -495,13 +511,15 @@ export function TimelineToolbar({
|
|
|
495
511
|
<button
|
|
496
512
|
type="button"
|
|
497
513
|
onClick={() => setZoomMode("fit")}
|
|
514
|
+
aria-label="Fit timeline to width"
|
|
515
|
+
aria-pressed={zoomMode === "fit"}
|
|
498
516
|
className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
|
|
499
517
|
zoomMode === "fit"
|
|
500
518
|
? "bg-studio-accent/10 text-studio-accent"
|
|
501
519
|
: "text-neutral-400 hover:bg-white/6 hover:text-neutral-200"
|
|
502
520
|
}`}
|
|
503
521
|
>
|
|
504
|
-
|
|
522
|
+
<ArrowsOutLineHorizontal size={16} aria-hidden="true" />
|
|
505
523
|
</button>
|
|
506
524
|
</Tooltip>
|
|
507
525
|
<Tooltip label="Zoom out">
|
|
@@ -21,7 +21,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
21
21
|
return (
|
|
22
22
|
<nav
|
|
23
23
|
aria-label="Composition navigation"
|
|
24
|
-
className="flex items-center gap-1 px-2 h-8 border-b border-
|
|
24
|
+
className="flex items-center gap-1 px-2 h-8 border-b border-hairline bg-bg-1 shrink-0"
|
|
25
25
|
>
|
|
26
26
|
{/* Back button — always goes to parent */}
|
|
27
27
|
<button
|
|
@@ -33,7 +33,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
33
33
|
});
|
|
34
34
|
onNavigate(stack.length - 2);
|
|
35
35
|
}}
|
|
36
|
-
className="flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-xs text-
|
|
36
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-xs text-text-3 hover:text-text-0 hover:bg-hover active:scale-[0.98] transition-colors"
|
|
37
37
|
title="Back (Esc, or double-click empty timeline)"
|
|
38
38
|
aria-label="Back to parent composition"
|
|
39
39
|
>
|
|
@@ -45,9 +45,9 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
45
45
|
const isLast = i === stack.length - 1;
|
|
46
46
|
return (
|
|
47
47
|
<span key={level.id} className="flex items-center gap-1">
|
|
48
|
-
{i > 0 && <CaretRight size={10} className="text-
|
|
48
|
+
{i > 0 && <CaretRight size={10} className="text-text-4 shrink-0" />}
|
|
49
49
|
{isLast ? (
|
|
50
|
-
<span className="text-xs text-
|
|
50
|
+
<span className="text-xs text-text-1 font-medium">{level.label}</span>
|
|
51
51
|
) : (
|
|
52
52
|
<button
|
|
53
53
|
type="button"
|
|
@@ -55,7 +55,7 @@ export function CompositionBreadcrumb({ stack, onNavigate }: CompositionBreadcru
|
|
|
55
55
|
trackStudioEvent("navigation", { action: "breadcrumb", target: level.label });
|
|
56
56
|
onNavigate(i);
|
|
57
57
|
}}
|
|
58
|
-
className="text-xs text-
|
|
58
|
+
className="text-xs text-text-3 hover:text-text-1 transition-colors"
|
|
59
59
|
>
|
|
60
60
|
{level.label}
|
|
61
61
|
</button>
|
|
@@ -3,6 +3,7 @@ import React, { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { liveTime, usePlayerStore } from "../../player/store/playerStore";
|
|
6
7
|
import { useCompositionStack } from "./useCompositionStack";
|
|
7
8
|
|
|
8
9
|
installReactActEnvironment();
|
|
@@ -111,3 +112,128 @@ describe("useCompositionStack — activating a composition by path", () => {
|
|
|
111
112
|
act(() => root.unmount());
|
|
112
113
|
});
|
|
113
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("useCompositionStack — back to the master", () => {
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
document.body.innerHTML = "";
|
|
119
|
+
usePlayerStore.getState().setCurrentTime(0);
|
|
120
|
+
usePlayerStore.getState().setIsPlaying(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const INTRO = { id: "intro", compositionSrc: "compositions/intro.html" };
|
|
124
|
+
const LOGO = { id: "logo", compositionSrc: "compositions/logo.html" };
|
|
125
|
+
const seek = (time: number) => act(() => usePlayerStore.getState().setCurrentTime(time));
|
|
126
|
+
const time = () => usePlayerStore.getState().currentTime;
|
|
127
|
+
|
|
128
|
+
async function mount(props: { projectId?: string; activeCompositionPath?: string | null } = {}) {
|
|
129
|
+
const host = document.createElement("div");
|
|
130
|
+
document.body.append(host);
|
|
131
|
+
const root = createRoot(host);
|
|
132
|
+
const seen = {} as { stack: ReturnType<typeof useCompositionStack> };
|
|
133
|
+
function Harness(p: { projectId: string; activeCompositionPath: string | null }) {
|
|
134
|
+
seen.stack = useCompositionStack(p);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const render = async (next: typeof props) => {
|
|
138
|
+
props = { ...props, ...next };
|
|
139
|
+
await act(async () => {
|
|
140
|
+
root.render(
|
|
141
|
+
<Harness
|
|
142
|
+
projectId={props.projectId ?? "p"}
|
|
143
|
+
activeCompositionPath={props.activeCompositionPath ?? null}
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
await render({});
|
|
149
|
+
const run = async (step: (stack: ReturnType<typeof useCompositionStack>) => void) => {
|
|
150
|
+
await act(async () => step(seen.stack));
|
|
151
|
+
};
|
|
152
|
+
return { seen, render, run, unmount: () => act(() => root.unmount()) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it("restores the master playhead from the breadcrumb after a two-level drill", async () => {
|
|
156
|
+
const { seen, run, unmount } = await mount();
|
|
157
|
+
seek(12);
|
|
158
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
159
|
+
seek(3);
|
|
160
|
+
await run((s) => s.handleDrillDown(LOGO));
|
|
161
|
+
expect(seen.stack.compositionStack.map((level) => level.label)).toEqual([
|
|
162
|
+
"Master",
|
|
163
|
+
"intro",
|
|
164
|
+
"logo",
|
|
165
|
+
]);
|
|
166
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
167
|
+
expect(time()).toBe(12);
|
|
168
|
+
unmount();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("restores the master playhead when Escape or a timeline double-click goes back", async () => {
|
|
172
|
+
const { run, unmount } = await mount();
|
|
173
|
+
seek(12);
|
|
174
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
175
|
+
seek(3);
|
|
176
|
+
await run((s) => s.handleDrillDown(LOGO));
|
|
177
|
+
seek(1);
|
|
178
|
+
await run((s) => s.updateCompositionStack((prev) => prev.slice(0, -1)));
|
|
179
|
+
expect(time()).toBe(1);
|
|
180
|
+
await run((s) => s.updateCompositionStack((prev) => prev.slice(0, -1)));
|
|
181
|
+
expect(time()).toBe(12);
|
|
182
|
+
unmount();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("restores the live master playhead when the drill happens during playback", async () => {
|
|
186
|
+
const { run, unmount } = await mount();
|
|
187
|
+
seek(4);
|
|
188
|
+
act(() => usePlayerStore.getState().setIsPlaying(true));
|
|
189
|
+
liveTime.notify(9);
|
|
190
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
191
|
+
act(() => usePlayerStore.getState().setIsPlaying(false));
|
|
192
|
+
seek(2);
|
|
193
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
194
|
+
expect(time()).toBe(9);
|
|
195
|
+
unmount();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("saves the master playhead at the first of two drills in one batch", async () => {
|
|
199
|
+
const { run, unmount } = await mount();
|
|
200
|
+
seek(12);
|
|
201
|
+
await run((s) => {
|
|
202
|
+
s.handleDrillDown(INTRO);
|
|
203
|
+
usePlayerStore.getState().setCurrentTime(7);
|
|
204
|
+
s.handleDrillDown(LOGO);
|
|
205
|
+
});
|
|
206
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
207
|
+
expect(time()).toBe(12);
|
|
208
|
+
unmount();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("restores a master playhead of exactly 0", async () => {
|
|
212
|
+
const { run, unmount } = await mount();
|
|
213
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
214
|
+
seek(3);
|
|
215
|
+
await run((s) => s.handleNavigateComposition(0));
|
|
216
|
+
expect(time()).toBe(0);
|
|
217
|
+
unmount();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("restores the master playhead when the Comps panel goes there and back", async () => {
|
|
221
|
+
const { render, unmount } = await mount();
|
|
222
|
+
seek(30);
|
|
223
|
+
await render({ activeCompositionPath: "compositions/intro.html" });
|
|
224
|
+
seek(4);
|
|
225
|
+
await render({ activeCompositionPath: "index.html" });
|
|
226
|
+
expect(time()).toBe(30);
|
|
227
|
+
unmount();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not carry one project's master playhead into another", async () => {
|
|
231
|
+
const { run, render, unmount } = await mount({ projectId: "a" });
|
|
232
|
+
seek(12);
|
|
233
|
+
await run((s) => s.handleDrillDown(INTRO));
|
|
234
|
+
seek(3);
|
|
235
|
+
await render({ projectId: "b" });
|
|
236
|
+
expect(time()).toBe(3);
|
|
237
|
+
unmount();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
// Composition drill-down stack management for NLEContext/EditorShell
|
|
3
3
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
|
-
import { usePlayerStore } from "../../player/store/playerStore";
|
|
4
|
+
import { liveTime, usePlayerStore } from "../../player/store/playerStore";
|
|
5
5
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
6
|
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
|
7
7
|
|
|
@@ -37,16 +37,30 @@ export function useCompositionStack({
|
|
|
37
37
|
const onCompositionChangeRef = useRef(onCompositionChange);
|
|
38
38
|
onCompositionChangeRef.current = onCompositionChange;
|
|
39
39
|
|
|
40
|
+
const masterSeekRef = useRef(0);
|
|
41
|
+
const masterSeekProjectRef = useRef<string | null>(null);
|
|
42
|
+
const projectIdRef = useRef(projectId);
|
|
43
|
+
projectIdRef.current = projectId;
|
|
44
|
+
const stackRef = useRef(compositionStack);
|
|
45
|
+
|
|
40
46
|
const updateCompositionStack: typeof setCompositionStack = useCallback((action) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const prev = stackRef.current;
|
|
48
|
+
const next = typeof action === "function" ? action(prev) : action;
|
|
49
|
+
stackRef.current = next;
|
|
50
|
+
const player = usePlayerStore.getState();
|
|
51
|
+
if (prev.length === 1 && next.length > 1) {
|
|
52
|
+
masterSeekRef.current = player.isPlaying ? liveTime.latest() : player.currentTime;
|
|
53
|
+
masterSeekProjectRef.current = projectIdRef.current;
|
|
54
|
+
} else if (next.length === 1 && prev.length > 1) {
|
|
55
|
+
if (masterSeekProjectRef.current === projectIdRef.current) {
|
|
56
|
+
player.setCurrentTime(masterSeekRef.current);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
setCompositionStack(next);
|
|
60
|
+
const id = next[next.length - 1]?.id;
|
|
61
|
+
queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
|
|
47
62
|
}, []);
|
|
48
63
|
|
|
49
|
-
const masterSeekRef = useRef(0);
|
|
50
64
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
51
65
|
|
|
52
66
|
const compIdToSrcRef = useRef(compIdToSrc);
|
|
@@ -54,9 +68,6 @@ export function useCompositionStack({
|
|
|
54
68
|
|
|
55
69
|
const handleNavigateComposition = useCallback(
|
|
56
70
|
(index: number) => {
|
|
57
|
-
if (index === 0 && masterSeekRef.current > 0) {
|
|
58
|
-
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
|
|
59
|
-
}
|
|
60
71
|
usePlayerStore.getState().setElements([]);
|
|
61
72
|
updateCompositionStack((prev) => prev.slice(0, index + 1));
|
|
62
73
|
},
|
|
@@ -67,7 +78,6 @@ export function useCompositionStack({
|
|
|
67
78
|
const handleDrillDown = useCallback(
|
|
68
79
|
(element: { id: string; compositionSrc?: string }) => {
|
|
69
80
|
if (!element.compositionSrc) return;
|
|
70
|
-
masterSeekRef.current = usePlayerStore.getState().currentTime;
|
|
71
81
|
|
|
72
82
|
const compId = element.id;
|
|
73
83
|
let resolvedPath = compIdToSrcRef.current.get(compId);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { CompositionBreadcrumb, useCompositionStack } from "@hyperframes/studio";
|
|
3
|
+
|
|
4
|
+
it("exports the composition stack hook beside the breadcrumb it feeds", () => {
|
|
5
|
+
expect(typeof useCompositionStack).toBe("function");
|
|
6
|
+
expect(typeof CompositionBreadcrumb).toBe("function");
|
|
7
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
|
|
|
9
9
|
export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
|
|
10
10
|
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
11
11
|
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
|
|
12
|
+
export { useCompositionStack } from "./components/nle/useCompositionStack";
|
|
12
13
|
|
|
13
14
|
// Player (preview, timeline, playback controls)
|
|
14
15
|
export {
|