@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.
Files changed (34) hide show
  1. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +473 -0
  2. package/dist/assets/{index-D9dmtDap.js → index-8Fgryj7q.js} +1 -1
  3. package/dist/assets/{index-DFcIBFM8.js → index-Cz3E_ZSj.js} +1 -1
  4. package/dist/assets/{index-gjbSm-vt.js → index-d5DKK15Q.js} +175 -175
  5. package/dist/assets/{index-BWJqxeaH.css → index-mzZCha_N.css} +1 -1
  6. package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
  7. package/dist/chunk-7PLTC6SV.js.map +1 -0
  8. package/dist/index.d.ts +25 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +151 -111
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/TimelineToolbar.test.tsx +53 -2
  16. package/src/components/TimelineToolbar.tsx +119 -101
  17. package/src/components/nle/CompositionBreadcrumb.tsx +5 -5
  18. package/src/components/nle/useCompositionStack.test.tsx +126 -0
  19. package/src/components/nle/useCompositionStack.ts +22 -12
  20. package/src/compositionStackExport.test.ts +7 -0
  21. package/src/index.ts +1 -0
  22. package/src/player/components/Timeline.playheadRemount.test.tsx +65 -0
  23. package/src/player/components/Timeline.test.ts +32 -19
  24. package/src/player/components/TimelineLanes.tsx +2 -3
  25. package/src/player/components/timelineKeyboardNavigation.ts +2 -0
  26. package/src/player/components/timelineLayout.ts +16 -23
  27. package/src/player/components/useTimelineGeometry.test.tsx +105 -0
  28. package/src/player/components/useTimelineGeometry.ts +18 -10
  29. package/src/player/components/useTimelineKeyboardActor.test.tsx +24 -1
  30. package/src/player/components/useTimelineKeyboardActor.ts +10 -2
  31. package/src/player/components/useTimelinePlayhead.ts +1 -1
  32. package/src/player/store/liveTime.ts +6 -1
  33. package/dist/assets/hyperframes-player-Dd74fv7B.js +0 -464
  34. 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.84",
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.84",
64
- "@hyperframes/parsers": "0.8.84",
65
- "@hyperframes/player": "0.8.84",
66
- "@hyperframes/shader-transitions": "0.8.84",
67
- "@hyperframes/studio-server": "0.8.84",
68
- "@hyperframes/sdk": "0.8.84"
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"
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({ autoKeyframeEnabled: true, thumbnailMode: "adaptive" });
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 { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
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
- {/* Always rendered (CapCut-style): with no keyframeable selection the
274
- button fades to a disabled state instead of unmounting, so the
275
- toolbar layout never shifts. */}
276
- <Tooltip
277
- label={
278
- keyframePathEndpoint
279
- ? "Motion path endpoints cannot be removed"
280
- : !onToggleKeyframe
281
- ? "Select an animated element to add keyframes"
282
- : keyframeIsMotionPath
283
- ? keyframeWillExtend
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
- ? "Add keyframe at playhead, extends animation (K)"
293
- : "Add keyframe at playhead (K)"
294
- : "Add keyframe (K)"
295
- }
296
- >
297
- <button
298
- type="button"
299
- disabled={!onToggleKeyframe}
300
- onClick={onToggleKeyframe}
301
- aria-label={
302
- keyframePathEndpoint
303
- ? "Motion path endpoint"
304
- : keyframeIsMotionPath
305
- ? keyframeState === "active"
306
- ? "Remove motion path waypoint"
307
- : keyframeWillExtend
308
- ? "Extend motion path to playhead"
309
- : "Add motion path waypoint"
310
- : keyframeState === "active"
311
- ? "Remove keyframe at playhead"
312
- : "Add keyframe at playhead"
313
- }
314
- className={
315
- !onToggleKeyframe
316
- ? flatDisabled
317
- : `${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
318
- keyframeState === "active"
319
- ? "text-studio-accent"
320
- : keyframeState === "inactive"
321
- ? "text-neutral-400 hover:text-studio-accent"
322
- : "text-neutral-600 hover:text-neutral-400"
323
- }`
324
- }
325
- >
326
- <svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
327
- {keyframeState === "active" ? (
328
- <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
329
- ) : (
330
- <path
331
- d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
332
- fill="none"
333
- stroke="currentColor"
334
- strokeWidth="1.2"
335
- />
336
- )}
337
- </svg>
338
- </button>
339
- </Tooltip>
340
- <Tooltip
341
- label={
342
- autoKeyframeEnabled
343
- ? "Auto-record manual edits as keyframes (click to turn off)"
344
- : "Manual edits will not be recorded as keyframes (click to turn on)"
345
- }
346
- >
347
- <button
348
- type="button"
349
- onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
350
- aria-label="Auto-record manual edits as keyframes"
351
- aria-pressed={autoKeyframeEnabled}
352
- className={`${flatBtn} active:scale-[0.98] hover:bg-white/6 ${
353
- autoKeyframeEnabled
354
- ? "text-red-400 hover:text-red-300"
355
- : "text-neutral-600 hover:text-neutral-400"
356
- }`}
357
- >
358
- <svg width="16" height="16" viewBox="0 0 10 10" fill="none">
359
- {/* Same diamond outline as the Add-keyframe icon, with a
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
- <path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
363
- <circle
364
- cx="5"
365
- cy="5"
366
- r="1.8"
367
- fill={autoKeyframeEnabled ? "currentColor" : "none"}
368
- stroke="currentColor"
369
- strokeWidth="1"
370
- />
371
- </svg>
372
- </button>
373
- </Tooltip>
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
- Fit
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-neutral-800/50 bg-neutral-900/50 shrink-0"
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-neutral-400 hover:text-white hover:bg-neutral-800 active:scale-[0.98] transition-colors"
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-neutral-600 shrink-0" />}
48
+ {i > 0 && <CaretRight size={10} className="text-text-4 shrink-0" />}
49
49
  {isLast ? (
50
- <span className="text-xs text-neutral-200 font-medium">{level.label}</span>
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-neutral-500 hover:text-neutral-200 transition-colors"
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
- setCompositionStack((prev) => {
42
- const next = typeof action === "function" ? action(prev) : action;
43
- const id = next[next.length - 1]?.id;
44
- queueMicrotask(() => onCompositionChangeRef.current?.(id === "master" ? null : id));
45
- return next;
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 {