@hyperframes/studio 0.8.64 → 0.8.65

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 (37) hide show
  1. package/dist/assets/hyperframes-player-CwFrSySS.js +464 -0
  2. package/dist/assets/{index-DLjjm1nC.js → index-Cs1lQrQ6.js} +142 -142
  3. package/dist/assets/{index-BEyJVAiH.js → index-a5nEhdAU.js} +1 -1
  4. package/dist/assets/{index-RRyhgH2k.js → index-wQFEeTL3.js} +1 -1
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +823 -748
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/manualEdits.ts +5 -1
  11. package/src/components/editor/manualEditsParsing.ts +15 -3
  12. package/src/components/sidebar/CompositionsPanel.masterComposition.test.tsx +1 -1
  13. package/src/components/sidebar/CompositionsTab.drag.test.tsx +2 -2
  14. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +1 -1
  15. package/src/components/sidebar/CompositionsTab.tsx +3 -2
  16. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +30 -3
  17. package/src/hooks/useExternalFileChangeCoordinator.ts +12 -5
  18. package/src/hooks/useRenderClipContent.test.ts +21 -2
  19. package/src/hooks/useRenderClipContent.ts +10 -5
  20. package/src/hooks/useStudioExternalFileChanges.ts +6 -3
  21. package/src/player/components/CompositionThumbnail.tsx +6 -0
  22. package/src/player/components/Timeline.virtualization.test.tsx +49 -4
  23. package/src/player/components/TimelineLanes.test.tsx +28 -0
  24. package/src/player/components/TimelineLanes.tsx +5 -4
  25. package/src/player/components/TimelineTransitionOverlays.tsx +3 -8
  26. package/src/player/components/timelineClipChildren.tsx +11 -1
  27. package/src/player/components/timelineProviderStateBuilders.test.ts +0 -7
  28. package/src/player/components/timelineProviderStateBuilders.ts +0 -9
  29. package/src/player/components/timelineTransitionSeams.test.ts +21 -1
  30. package/src/player/components/timelineTransitionSeams.ts +21 -0
  31. package/src/player/components/useTimelineProviderState.tsx +11 -5
  32. package/src/player/hooks/useShadowPreviewReload.ts +7 -0
  33. package/src/player/lib/thumbnailScheduler.test.ts +33 -0
  34. package/src/player/lib/thumbnailScheduler.ts +34 -0
  35. package/src/player/store/playerStore.test.ts +22 -8
  36. package/src/player/store/thumbnailSlice.ts +28 -6
  37. package/dist/assets/hyperframes-player-pWkneUHC.js +0 -464
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
2
+ import {
3
+ deriveTimelineTransitionSeams,
4
+ deriveTimelineTransitionSeamsByTrack,
5
+ } from "./timelineTransitionSeams";
3
6
  import type { TimelineElement } from "../store/playerStore";
4
7
 
5
8
  const clip = (
@@ -46,3 +49,20 @@ describe("deriveTimelineTransitionSeams", () => {
46
49
  expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2, 1)])).toEqual([]);
47
50
  });
48
51
  });
52
+
53
+ describe("deriveTimelineTransitionSeamsByTrack", () => {
54
+ it("finds each track's transitions even when another track's clip starts between them", () => {
55
+ const label = "hf:transition:out:in:crossfade";
56
+ const other = "hf:transition:a:b:wipe";
57
+ const byTrack = deriveTimelineTransitionSeamsByTrack([
58
+ clip("out", 0, 2, 0, label),
59
+ clip("in", 1.8, 2, 0, label),
60
+ clip("a", 5, 2, 2, other),
61
+ clip("b", 6.5, 2, 2, other),
62
+ clip("alone", 0, 3, 1),
63
+ ]);
64
+ expect(byTrack.get(0)?.map((seam) => seam.incoming.id)).toEqual(["in"]);
65
+ expect(byTrack.get(2)?.map((seam) => seam.incoming.id)).toEqual(["b"]);
66
+ expect(byTrack.has(1)).toBe(false);
67
+ });
68
+ });
@@ -34,3 +34,24 @@ export function deriveTimelineTransitionSeams(
34
34
  }
35
35
  return seams;
36
36
  }
37
+
38
+ /**
39
+ * Transition seams keyed by track, derived once for every row. Pairs are found within a track, so a clip on
40
+ * another track that starts between a transition's two clips cannot hide it.
41
+ */
42
+ export function deriveTimelineTransitionSeamsByTrack(
43
+ elements: readonly TimelineElement[],
44
+ ): ReadonlyMap<number, readonly TimelineTransitionSeam[]> {
45
+ const tracks = new Map<number, TimelineElement[]>();
46
+ for (const element of elements) {
47
+ const track = tracks.get(element.track);
48
+ if (track) track.push(element);
49
+ else tracks.set(element.track, [element]);
50
+ }
51
+ const byTrack = new Map<number, readonly TimelineTransitionSeam[]>();
52
+ for (const [track, trackElements] of tracks) {
53
+ const seams = deriveTimelineTransitionSeams(trackElements);
54
+ if (seams.length > 0) byTrack.set(track, seams);
55
+ }
56
+ return byTrack;
57
+ }
@@ -11,7 +11,6 @@ import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvi
11
11
  import {
12
12
  buildTimelineMeta,
13
13
  resolveMultiDragPreview,
14
- resolveRenderClipContent,
15
14
  resolveResizingElementIds,
16
15
  shouldIgnoreTimelinePointerDown,
17
16
  } from "./timelineProviderStateBuilders";
@@ -22,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
22
21
  import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
23
22
  import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
24
23
  import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
24
+ import { ClipContentOnceShown } from "./timelineClipChildren";
25
25
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
26
26
  import type { TimelineProps } from "./TimelineTypes";
27
27
  import {
@@ -509,10 +509,16 @@ export function useTimelineProviderState({
509
509
  onRazorSplit: editContext.onRazorSplit,
510
510
  onRazorSplitAll: editContext.onRazorSplitAll,
511
511
  };
512
- const timelineRenderClipContent = resolveRenderClipContent(
513
- timelineFocus.rowVirtualizationActive,
514
- viewport.isScrolling,
515
- renderClipContent,
512
+ const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
513
+ const timelineRenderClipContent = useMemo<typeof renderClipContent>(
514
+ () =>
515
+ renderClipContent &&
516
+ ((element, style, context) => (
517
+ <ClipContentOnceShown hold={holdNewClipContent}>
518
+ {renderClipContent(element, style, context)}
519
+ </ClipContentOnceShown>
520
+ )),
521
+ [holdNewClipContent, renderClipContent],
516
522
  );
517
523
  const timelineMeta = buildTimelineMeta({
518
524
  emptyState: {
@@ -13,6 +13,7 @@ import {
13
13
  type UseTimelineSyncCallbacksParams,
14
14
  } from "./useTimelineSyncCallbacks";
15
15
  import type { PlaybackAdapter } from "../lib/playbackTypes";
16
+ import { thumbnailScheduler } from "../lib/thumbnailScheduler";
16
17
 
17
18
  // The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
18
19
  // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
@@ -82,6 +83,7 @@ export function useShadowPreviewReload({
82
83
  isRefreshingRef.current = false;
83
84
  pendingSeekRef.current = null;
84
85
  setPreviewSlots(planShadowDiscard);
86
+ thumbnailScheduler.setPreviewReloading(false);
85
87
  const message = `The preview did not reload (${cause}). The previous preview is still showing.`;
86
88
  logReload("shadow-failed", { cause });
87
89
  console.error(`[studio] ${message}`);
@@ -105,6 +107,7 @@ export function useShadowPreviewReload({
105
107
  applyPreviewAudioState();
106
108
  setPreviewSlots((prev) => planShadowPromotion(prev, gen));
107
109
  onPromotedRef.current?.();
110
+ thumbnailScheduler.setPreviewReloading(false);
108
111
  },
109
112
  [stopPendingShadow, iframeRef, attachIframeShortcutListeners, applyPreviewAudioState],
110
113
  );
@@ -175,6 +178,8 @@ export function useShadowPreviewReload({
175
178
  budgetGenRef.current = gen;
176
179
  armReadyTimer(gen);
177
180
  setPreviewSlots((prev) => planShadowReload(prev, gen, url));
181
+ // Thumbnails of the edit wait for the new preview instead of competing with it.
182
+ thumbnailScheduler.setPreviewReloading(true);
178
183
  },
179
184
  [stopPendingShadow, armReadyTimer],
180
185
  );
@@ -187,6 +192,7 @@ export function useShadowPreviewReload({
187
192
  isRefreshingRef.current = false;
188
193
  pendingSeekRef.current = null;
189
194
  setPreviewSlots(planShadowDiscard);
195
+ thumbnailScheduler.setPreviewReloading(false);
190
196
  }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
191
197
 
192
198
  // Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
@@ -203,6 +209,7 @@ export function useShadowPreviewReload({
203
209
  return () => {
204
210
  document.removeEventListener("visibilitychange", onVisibilityChange);
205
211
  stopPendingShadow();
212
+ thumbnailScheduler.setPreviewReloading(false);
206
213
  };
207
214
  });
208
215
 
@@ -153,6 +153,39 @@ describe("ThumbnailScheduler", () => {
153
153
  expect(richLoad).toHaveBeenCalledTimes(1);
154
154
  });
155
155
 
156
+ it("holds composition renders while the preview reloads and re-runs the ones it preempted", async () => {
157
+ const scheduler = new ThumbnailScheduler();
158
+ const signals: AbortSignal[] = [];
159
+ const composition = vi.fn((signal: AbortSignal) => {
160
+ signals.push(signal);
161
+ return new Promise<ThumbnailLoadedResult>((resolve, reject) => {
162
+ signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
163
+ if (signals.length > 1) resolve(result("scene"));
164
+ });
165
+ });
166
+ const image = vi.fn(async () => result("still"));
167
+ const scene = request("scene", composition, "visible", { kind: "composition" });
168
+ const still = request("still", image);
169
+
170
+ scheduler.acquire(scene, vi.fn());
171
+ expect(composition).toHaveBeenCalledTimes(1);
172
+ scheduler.setPreviewReloading(true);
173
+ await flush();
174
+ expect(signals[0]?.aborted).toBe(true);
175
+ expect(scheduler.getSnapshot(scene)).toEqual({ status: "queued" });
176
+ scheduler.acquire(still, vi.fn());
177
+ expect(image).toHaveBeenCalledTimes(1);
178
+ expect(composition).toHaveBeenCalledTimes(1);
179
+
180
+ scheduler.setPreviewReloading(false);
181
+ await flush();
182
+ expect(composition).toHaveBeenCalledTimes(2);
183
+ expect(scheduler.getSnapshot(scene)).toMatchObject({
184
+ status: "ready",
185
+ value: { url: "scene" },
186
+ });
187
+ });
188
+
156
189
  it("aborts queued and active jobs after the final release", async () => {
157
190
  const scheduler = new ThumbnailScheduler(
158
191
  resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
@@ -65,6 +65,8 @@ interface ThumbnailEntry {
65
65
  cached: boolean;
66
66
  lastAccess: number;
67
67
  snapshot: ThumbnailSnapshot;
68
+ /** Aborted by the preview-reload hold, to run again once the hold lifts. */
69
+ preempted: boolean;
68
70
  }
69
71
 
70
72
  const PRIORITY_SCORE: Readonly<Record<ThumbnailPriority, number>> = {
@@ -112,6 +114,7 @@ export class ThumbnailScheduler {
112
114
  private nextLeaseId = 1;
113
115
  private nextSequence = 1;
114
116
  private scrolling = false;
117
+ private previewReloading = false;
115
118
  private cacheBytes = 0;
116
119
  private waveformCacheBytes = 0;
117
120
  private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
@@ -155,6 +158,7 @@ export class ThumbnailScheduler {
155
158
  cached: false,
156
159
  lastAccess: this.nextSequence++,
157
160
  snapshot: Object.freeze({ status: "queued" }),
161
+ preempted: false,
158
162
  };
159
163
  this.entries.set(scopedKey, entry);
160
164
  }
@@ -210,6 +214,24 @@ export class ThumbnailScheduler {
210
214
  if (!scrolling) this.pump();
211
215
  }
212
216
 
217
+ /**
218
+ * Hold server-rendered composition thumbnails while the preview loads a new document: both
219
+ * are served by the same Studio server, and the preview is what the person is waiting for.
220
+ */
221
+ setPreviewReloading(reloading: boolean): void {
222
+ if (this.previewReloading === reloading) return;
223
+ this.previewReloading = reloading;
224
+ if (!reloading) {
225
+ this.pump();
226
+ return;
227
+ }
228
+ for (const entry of this.entries.values()) {
229
+ if (entry.state !== "loading" || entry.request.kind !== "composition") continue;
230
+ entry.preempted = true;
231
+ entry.controller?.abort();
232
+ }
233
+ }
234
+
213
235
  /** Evict project cache entries that are no longer owned by mounted consumers. */
214
236
  invalidateProject(projectId: string): void {
215
237
  for (const [key, entry] of this.entries) {
@@ -257,6 +279,7 @@ export class ThumbnailScheduler {
257
279
 
258
280
  for (const entry of queued) {
259
281
  if (this.scrolling && entry.request.rich) continue;
282
+ if (this.previewReloading && entry.request.kind === "composition") continue;
260
283
  const bucket = concurrencyBucket(entry.request.kind);
261
284
  if (this.activeByBucket[bucket] >= this.bucketLimit(bucket)) continue;
262
285
  this.start(entry, bucket);
@@ -281,6 +304,7 @@ export class ThumbnailScheduler {
281
304
  this.deleteEntry(entry.scopedKey, entry);
282
305
  return;
283
306
  }
307
+ if (this.requeuePreempted(entry)) return;
284
308
  entry.state = "error";
285
309
  entry.error = errorFrom(reason);
286
310
  entry.failedAt = this.now();
@@ -303,6 +327,7 @@ export class ThumbnailScheduler {
303
327
  ): void {
304
328
  if (controller.signal.aborted || this.entries.get(entry.scopedKey) !== entry) {
305
329
  this.safeDispose(result.dispose);
330
+ this.requeuePreempted(entry);
306
331
  return;
307
332
  }
308
333
  this.validateResult(result);
@@ -317,6 +342,15 @@ export class ThumbnailScheduler {
317
342
  if (!entry.cached && entry.leases.size === 0) this.deleteEntry(entry.scopedKey, entry);
318
343
  }
319
344
 
345
+ private requeuePreempted(entry: ThumbnailEntry): boolean {
346
+ if (!entry.preempted || this.entries.get(entry.scopedKey) !== entry) return false;
347
+ entry.preempted = false;
348
+ entry.state = "queued";
349
+ entry.snapshot = Object.freeze({ status: "queued" });
350
+ this.notify(entry);
351
+ return true;
352
+ }
353
+
320
354
  private validateResult(result: ThumbnailLoadedResult): void {
321
355
  if (Number.isFinite(result.weight) && result.weight >= 0) return;
322
356
  this.safeDispose(result.dispose);
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { describe, it, expect, beforeEach, vi } from "vitest";
3
3
  import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
4
+ import { thumbnailRevisionOf } from "./thumbnailSlice";
4
5
 
5
6
  /** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
6
7
  function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
@@ -32,25 +33,38 @@ describe("usePlayerStore", () => {
32
33
  });
33
34
 
34
35
  describe("thumbnail content revision", () => {
35
- it("advances once per accepted persisted file event", () => {
36
- const before = usePlayerStore.getState().thumbnailContentRevision;
36
+ const revisionOf = (path: string) =>
37
+ thumbnailRevisionOf(usePlayerStore.getState().thumbnailRevisions, path);
37
38
 
38
- usePlayerStore.getState().bumpThumbnailContentRevision();
39
+ it("advances only the compositions an accepted file event names", () => {
40
+ const scene = revisionOf("compositions/scene-a.html");
41
+ const sibling = revisionOf("compositions/scene-b.html");
39
42
 
40
- expect(usePlayerStore.getState().thumbnailContentRevision).toBe(before + 1);
43
+ usePlayerStore.getState().bumpThumbnailRevisions(["compositions/scene-a.html"]);
44
+
45
+ expect(revisionOf("compositions/scene-a.html")).toBe(scene + 1);
46
+ expect(revisionOf("compositions/scene-b.html")).toBe(sibling);
47
+ });
48
+
49
+ it("advances every composition when the event names none", () => {
50
+ const sibling = revisionOf("compositions/scene-b.html");
51
+
52
+ usePlayerStore.getState().bumpThumbnailRevisions(null);
53
+
54
+ expect(revisionOf("compositions/scene-b.html")).toBe(sibling + 1);
41
55
  });
42
56
 
43
57
  it("remains monotonic across soft resets while project identity stays with the session epoch", () => {
44
58
  const store = usePlayerStore.getState();
45
59
  store.beginTimelineSession("project-a");
46
- store.bumpThumbnailContentRevision();
47
- const revision = usePlayerStore.getState().thumbnailContentRevision;
60
+ store.bumpThumbnailRevisions(null);
61
+ const revision = revisionOf("index.html");
48
62
  const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
49
63
 
50
64
  store.reset();
51
- expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
65
+ expect(revisionOf("index.html")).toBe(revision);
52
66
  store.beginTimelineSession("project-b");
53
- expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
67
+ expect(revisionOf("index.html")).toBe(revision);
54
68
  expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
55
69
  });
56
70
  });
@@ -2,23 +2,45 @@ import type { StoreApi } from "zustand";
2
2
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
3
3
  import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy";
4
4
 
5
+ /** Revision that moves every composition's thumbnails at once. */
6
+ const EVERY_COMPOSITION = "*";
7
+
8
+ /** Mounts may be written `./scene.html`; the server reports `scene.html`. */
9
+ function revisionKey(path: string): string {
10
+ return (path.split(/[?#]/)[0] ?? "").replace(/\\/g, "/").replace(/^\.?\//, "");
11
+ }
12
+
5
13
  export interface ThumbnailSlice {
6
14
  thumbnailMode: ThumbnailMode;
7
- /** Monotonic identity for persisted project content shown by mounted thumbnails. */
8
- thumbnailContentRevision: number;
15
+ /** Monotonic identity, per composition path, for persisted content shown by mounted thumbnails. */
16
+ thumbnailRevisions: Readonly<Record<string, number>>;
9
17
  setThumbnailMode: (mode: ThumbnailMode) => void;
10
- bumpThumbnailContentRevision: () => void;
18
+ /** `null` moves every composition, e.g. after a root or asset change. */
19
+ bumpThumbnailRevisions: (compositions: readonly string[] | null) => void;
20
+ }
21
+
22
+ export function thumbnailRevisionOf(
23
+ revisions: Readonly<Record<string, number>>,
24
+ compositionPath: string,
25
+ ): number {
26
+ return (revisions[EVERY_COMPOSITION] ?? 0) + (revisions[revisionKey(compositionPath)] ?? 0);
11
27
  }
12
28
 
13
29
  export function createThumbnailSlice(set: StoreApi<ThumbnailSlice>["setState"]): ThumbnailSlice {
14
30
  return {
15
31
  thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
16
- thumbnailContentRevision: 0,
32
+ thumbnailRevisions: {},
17
33
  setThumbnailMode: (mode) => {
18
34
  writeStudioUiPreferences({ thumbnailMode: mode });
19
35
  set({ thumbnailMode: mode });
20
36
  },
21
- bumpThumbnailContentRevision: () =>
22
- set((state) => ({ thumbnailContentRevision: state.thumbnailContentRevision + 1 })),
37
+ bumpThumbnailRevisions: (compositions) =>
38
+ set(({ thumbnailRevisions }) => {
39
+ const next = { ...thumbnailRevisions };
40
+ for (const path of compositions?.map(revisionKey) ?? [EVERY_COMPOSITION]) {
41
+ next[path] = (next[path] ?? 0) + 1;
42
+ }
43
+ return { thumbnailRevisions: next };
44
+ }),
23
45
  };
24
46
  }