@hyperframes/studio 0.8.74 → 0.8.76

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 (116) hide show
  1. package/dist/assets/hyperframes-player-BkDxgSKG.js +464 -0
  2. package/dist/assets/{index-vccPjvCl.js → index-COX0scwJ.js} +1 -1
  3. package/dist/assets/{index-DhTyI9zH.js → index-DeHir1up.js} +178 -178
  4. package/dist/assets/{index-B86QTmD2.js → index-FmKUmYhC.js} +1 -1
  5. package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
  6. package/dist/chunk-IYDE66BY.js.map +1 -0
  7. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
  8. package/dist/index.d.ts +35 -70
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +4518 -4568
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/components/StudioRightPanels.types.ts +0 -2
  14. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  15. package/src/components/editor/LayersPanel.tsx +3 -14
  16. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  17. package/src/components/editor/domEditing.ts +1 -0
  18. package/src/components/editor/domEditingLayers.test.ts +27 -0
  19. package/src/components/editor/domEditingLayers.ts +9 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  21. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  22. package/src/components/editor/overlayFrameLoop.ts +11 -0
  23. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  24. package/src/components/nle/NLEPreview.test.ts +94 -17
  25. package/src/components/nle/NLEPreview.tsx +14 -0
  26. package/src/components/nle/PreviewPoster.tsx +99 -0
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  28. package/src/components/panels/VariablesPanel.tsx +0 -2
  29. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  30. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  31. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  32. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  33. package/src/components/studioColorGradingScope.ts +0 -3
  34. package/src/hooks/domEditDeleteMembers.ts +0 -2
  35. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  36. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  37. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  38. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  39. package/src/hooks/timelineEditingHelpers.ts +11 -3
  40. package/src/hooks/timelineTimingSync.ts +0 -1
  41. package/src/hooks/timelineTrackVisibility.ts +0 -1
  42. package/src/hooks/useBlockHandlers.ts +0 -2
  43. package/src/hooks/useClipboard.test.ts +69 -10
  44. package/src/hooks/useClipboard.ts +28 -19
  45. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  48. package/src/hooks/useEditHistoryActions.ts +12 -7
  49. package/src/hooks/useEditorSave.ts +0 -3
  50. package/src/hooks/useElementLifecycleOps.ts +0 -1
  51. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  52. package/src/hooks/useFileManager.ts +0 -2
  53. package/src/hooks/useGroupCommits.ts +0 -1
  54. package/src/hooks/useGsapScriptCommits.ts +0 -1
  55. package/src/hooks/useLintModal.test.tsx +34 -1
  56. package/src/hooks/useLintModal.ts +32 -30
  57. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  58. package/src/hooks/usePersistentEditHistory.ts +155 -342
  59. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  60. package/src/hooks/usePreviewPersistence.ts +0 -2
  61. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  62. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  63. package/src/hooks/useRazorSplit.test.ts +17 -47
  64. package/src/hooks/useRenderClipContent.test.ts +29 -0
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  67. package/src/hooks/useSdkSession.ts +8 -2
  68. package/src/hooks/useSlideshowPersist.ts +0 -2
  69. package/src/hooks/useStudioContextValue.ts +6 -1
  70. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
  72. package/src/hooks/useTimelineDeleteOps.ts +8 -8
  73. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  74. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  75. package/src/hostMount.test.tsx +53 -22
  76. package/src/main.tsx +3 -0
  77. package/src/player/components/CompositionThumbnail.tsx +14 -0
  78. package/src/player/components/Player.test.ts +49 -0
  79. package/src/player/components/Player.tsx +21 -7
  80. package/src/player/components/PlayheadIndicator.tsx +4 -1
  81. package/src/player/components/Timeline.test.ts +1 -3
  82. package/src/player/components/Timeline.virtualization.test.tsx +21 -0
  83. package/src/player/components/TimelineCanvas.tsx +14 -12
  84. package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
  85. package/src/player/components/TimelineRuler.tsx +7 -40
  86. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  87. package/src/player/components/useSettledScrollLeft.ts +38 -0
  88. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  89. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  90. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  91. package/src/player/hooks/useShadowPreviewReload.ts +20 -1
  92. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  93. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
  94. package/src/player/hooks/useTimelinePlayer.ts +15 -3
  95. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  96. package/src/player/lib/timelineElementHelpers.ts +45 -9
  97. package/src/player/store/playerStore.test.ts +35 -1
  98. package/src/player/store/playerStore.ts +29 -0
  99. package/src/player/store/readinessSlice.ts +34 -6
  100. package/src/utils/blockInstaller.ts +0 -3
  101. package/src/utils/previewPrefetch.test.ts +22 -0
  102. package/src/utils/previewPrefetch.ts +14 -0
  103. package/src/utils/razorSplitTransaction.test.ts +0 -1
  104. package/src/utils/razorSplitTransaction.ts +1 -1
  105. package/src/utils/sdkEditTransaction.ts +0 -3
  106. package/src/utils/studioFileHistory.test.ts +0 -9
  107. package/src/utils/studioFileHistory.ts +1 -5
  108. package/src/utils/timelineCompositionInsert.ts +0 -1
  109. package/dist/assets/hyperframes-player-Bs9qG2hg.js +0 -464
  110. package/dist/chunk-7BP32G62.js.map +0 -1
  111. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  112. package/src/utils/editHistory.test.ts +0 -534
  113. package/src/utils/editHistory.ts +0 -236
  114. package/src/utils/editHistoryStorage.test.ts +0 -37
  115. package/src/utils/editHistoryStorage.ts +0 -99
  116. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
@@ -0,0 +1,99 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { buildProjectApiPath } from "../../utils/projectRouting";
3
+ import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
4
+
5
+ /** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
6
+ function previewPosterUrl(projectId: string, cachedOnly: boolean): string {
7
+ const query = `t=0&output=source${cachedOnly ? "&cached=1" : ""}`;
8
+ return buildProjectApiPath(projectId, `/thumbnail/index.html?${query}`);
9
+ }
10
+
11
+ const PREVIEW_POSTER_STYLE: React.CSSProperties = {
12
+ position: "absolute",
13
+ inset: 0,
14
+ width: "100%",
15
+ height: "100%",
16
+ objectFit: "contain",
17
+ zIndex: 2,
18
+ pointerEvents: "none",
19
+ };
20
+
21
+ /** Covers the live preview with the cached frame 0; reports its size so the stage fits it. */
22
+ export function PreviewPoster({
23
+ projectId,
24
+ hidden,
25
+ onSize,
26
+ onLoaded,
27
+ onMissing,
28
+ }: {
29
+ projectId: string;
30
+ hidden: boolean;
31
+ onSize: (size: PreviewCompositionSize) => void;
32
+ onLoaded: () => void;
33
+ onMissing: () => void;
34
+ }) {
35
+ return (
36
+ <img
37
+ src={previewPosterUrl(projectId, true)}
38
+ alt=""
39
+ aria-hidden
40
+ data-testid="preview-poster"
41
+ hidden={hidden}
42
+ style={PREVIEW_POSTER_STYLE}
43
+ onLoad={(event) => {
44
+ const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
45
+ if (!hidden && width > 0 && height > 0) onSize({ width, height });
46
+ onLoaded();
47
+ }}
48
+ onError={onMissing}
49
+ />
50
+ );
51
+ }
52
+
53
+ /** The poster covers the live slot until its first frame is ready; a missing poster is rendered
54
+ * after both are known, in either order, off the open path, for the next open. */
55
+ export function usePreviewPoster(
56
+ projectId: string,
57
+ activeKey: string,
58
+ directUrl: string | undefined,
59
+ ) {
60
+ const visit = useVisitNumber(activeKey);
61
+ const [coverDoneFor, setCoverDoneFor] = useState<number | null>(null);
62
+ const [posterSettledFor, setPosterSettledFor] = useState<number | null>(null);
63
+ const missingForRef = useRef<number | null>(null);
64
+ const liveReadyForRef = useRef<number | null>(null);
65
+ const renderMissingPoster = useCallback(() => {
66
+ if (missingForRef.current !== visit || liveReadyForRef.current !== visit) return;
67
+ missingForRef.current = null;
68
+ void fetch(previewPosterUrl(projectId, false)).catch(() => {});
69
+ }, [visit, projectId]);
70
+ const onLiveReadyToShowChange = useCallback(
71
+ (ready: boolean) => {
72
+ if (!ready) return;
73
+ setCoverDoneFor(visit);
74
+ liveReadyForRef.current = visit;
75
+ renderMissingPoster();
76
+ },
77
+ [visit, renderMissingPoster],
78
+ );
79
+ return {
80
+ mountPoster: !directUrl && (coverDoneFor !== visit || posterSettledFor !== visit),
81
+ hidePoster: coverDoneFor === visit,
82
+ onLiveReadyToShowChange,
83
+ onPreviewError: () => setCoverDoneFor(visit),
84
+ onPosterLoaded: () => setPosterSettledFor(visit),
85
+ onPosterMissing: () => {
86
+ setCoverDoneFor(visit);
87
+ setPosterSettledFor(visit);
88
+ missingForRef.current = visit;
89
+ renderMissingPoster();
90
+ },
91
+ };
92
+ }
93
+
94
+ function useVisitNumber(key: string): number {
95
+ const [seen, setSeen] = useState({ key, visit: 0 });
96
+ if (seen.key === key) return seen.visit;
97
+ setSeen({ key, visit: seen.visit + 1 });
98
+ return seen.visit + 1;
99
+ }
@@ -12,12 +12,6 @@ import {
12
12
  } from "./useCanvasZOrderTimelineMirror";
13
13
  import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
14
14
  import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
15
- import {
16
- buildEditHistoryEntry,
17
- createEmptyEditHistory,
18
- pushEditHistoryEntry,
19
- type EditHistoryState,
20
- } from "../../utils/editHistory";
21
15
 
22
16
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
23
17
 
@@ -61,45 +55,38 @@ interface HarnessApi {
61
55
  mirror: (input: MirrorZOrderInput) => Promise<boolean>;
62
56
  }
63
57
 
58
+ interface RecordedWrite {
59
+ label: string;
60
+ coalesceKey: string;
61
+ after: string;
62
+ }
63
+
64
64
  /**
65
65
  * Mount the REAL wiring pair PreviewOverlays composes — handleDomZIndexReorderCommit
66
- * (z persist) + useCanvasZOrderTimelineMirror (lane mirror) — over a shared,
67
- * real editHistory reducer so the undo-fold assertion exercises the actual
68
- * pushEditHistoryEntry coalescing:
66
+ * (z persist) + useCanvasZOrderTimelineMirror (lane mirror). The server (not this
67
+ * test) owns folding two records that share a coalesceKey into one undo entry
68
+ * (projectHistory.ts, "claim: a writer that records after writing"), so this harness
69
+ * only captures what Studio sends on each write:
69
70
  *
70
71
  * - the z sink mimics commitDomEditPatchBatches' recordEdit call verbatim
71
- * (kind "manual", options.coalesceKey — see useDomEditCommits.ts), and
72
+ * (options.coalesceKey — see useDomEditCommits.ts), and
72
73
  * - the move sink mimics persistTimelineBatchEdit → saveProjectFilesWithHistory
73
- * (kind "timeline", the coalesceKey forwarded through onMoveElements — see
74
- * timelineEditingHelpers.ts / studioFileHistory.ts),
75
- *
76
- * with a deterministic clock inside the reducer's 300ms coalesce window.
74
+ * (the coalesceKey forwarded through onMoveElements — see
75
+ * timelineEditingHelpers.ts / studioFileHistory.ts).
77
76
  */
78
77
  function mountMirrorHarness(history: {
79
- state: EditHistoryState;
80
- now: () => number;
81
78
  fileContent: { current: string };
82
79
  moveCoalesceKeys: string[];
80
+ records: RecordedWrite[];
83
81
  }) {
84
82
  const record = (
85
83
  label: string,
86
- kind: "manual" | "timeline",
87
84
  coalesceKey: string,
88
- coalesceMs: number | undefined,
85
+ _coalesceMs: number | undefined,
89
86
  after: string,
90
87
  ) => {
91
- const entry = buildEditHistoryEntry({
92
- id: `e-${history.now()}`,
93
- projectId: "p",
94
- label,
95
- kind,
96
- coalesceKey,
97
- coalesceMs,
98
- now: history.now(),
99
- files: { "index.html": { before: history.fileContent.current, after } },
100
- });
88
+ history.records.push({ label, coalesceKey, after });
101
89
  history.fileContent.current = after;
102
- history.state = pushEditHistoryEntry(history.state, entry);
103
90
  };
104
91
 
105
92
  const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
@@ -109,7 +96,7 @@ function mountMirrorHarness(history: {
109
96
  coalesceMs,
110
97
  ) => {
111
98
  history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
112
- record("Move timeline clips", "timeline", coalesceKey ?? "<none>", coalesceMs, "C-move");
99
+ record("Move timeline clips", coalesceKey ?? "<none>", coalesceMs, "C-move");
113
100
  };
114
101
 
115
102
  const api: Partial<HarnessApi> = {};
@@ -117,7 +104,7 @@ function mountMirrorHarness(history: {
117
104
  const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
118
105
  makeLifecycleOpsParams({
119
106
  commitDomEditPatchBatches: async (_batches, options) => {
120
- record(options.label, "manual", options.coalesceKey, options.coalesceMs, "B-z");
107
+ record(options.label, options.coalesceKey, options.coalesceMs, "B-z");
121
108
  return { durable: true, allMatched: true, changed: true };
122
109
  },
123
110
  }),
@@ -135,17 +122,10 @@ function mountMirrorHarness(history: {
135
122
  }
136
123
 
137
124
  function makeHistory() {
138
- let tick = 1000;
139
125
  return {
140
- state: createEmptyEditHistory(),
141
- // Deterministic clock: consecutive records land 400ms apart — PAST the
142
- // reducer's default 300ms coalesce window, as in the live flow where the
143
- // mirror is dispatched only after the z persist's server round-trip
144
- // resolves (real network latency exceeds 300ms). The fold must therefore
145
- // ride the gesture's explicit coalesceMs window, not the default.
146
- now: () => (tick += 400),
147
126
  fileContent: { current: "A-original" },
148
127
  moveCoalesceKeys: [] as string[],
128
+ records: [] as RecordedWrite[],
149
129
  };
150
130
  }
151
131
 
@@ -208,13 +188,12 @@ describe("useCanvasZOrderTimelineMirror", () => {
208
188
 
209
189
  // The move persist received the EXACT z coalesce key…
210
190
  expect(history.moveCoalesceKeys).toEqual([coalesceKey]);
211
- // …and the real reducer folded the two records into one undo entry spanning
212
- // pre-z "before" → post-move "after". One Cmd+Z reverts both writes.
213
- expect(history.state.undo).toHaveLength(1);
214
- expect(history.state.undo[0].files["index.html"]).toMatchObject({
215
- before: "A-original",
216
- after: "C-move",
217
- });
191
+ // …and both writes carry that SAME non-empty coalesceKey: that is what lets
192
+ // the server (projectHistory.ts) fold them into one undo entry. One Cmd+Z
193
+ // then reverts both writes.
194
+ expect(history.records.map((r) => r.coalesceKey)).toEqual([coalesceKey, coalesceKey]);
195
+ expect(history.records[0]?.after).toBe("B-z");
196
+ expect(history.records[1]?.after).toBe("C-move");
218
197
  // Timeline UI reflects the lane change without a reload: optimistic store update.
219
198
  const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t");
220
199
  expect(t?.track).toBe(0);
@@ -265,8 +244,10 @@ describe("useCanvasZOrderTimelineMirror", () => {
265
244
  }
266
245
 
267
246
  expect(keys[0]).not.toBe(keys[1]); // fresh key per gesture
268
- // Each gesture folded its own z+move pair, but the two gestures stayed apart.
269
- expect(history.state.undo).toHaveLength(2);
247
+ // Each gesture's z+move pair shares that gesture's own key, so the server folds
248
+ // each pair on its own — but the two gestures never share a key, so they stay apart.
249
+ expect(history.records).toHaveLength(4);
250
+ expect(history.records.map((r) => r.coalesceKey)).toEqual([keys[0], keys[0], keys[1], keys[1]]);
270
251
  expect(history.moveCoalesceKeys).toEqual(keys);
271
252
  });
272
253
 
@@ -299,7 +280,7 @@ describe("useCanvasZOrderTimelineMirror", () => {
299
280
  });
300
281
 
301
282
  expect(history.moveCoalesceKeys).toEqual([]); // no lane persist dispatched
302
- expect(history.state.undo).toHaveLength(1); // just the z entry
283
+ expect(history.records).toHaveLength(1); // just the z write
303
284
  const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t");
304
285
  expect(t?.track).toBe(1); // lane unchanged
305
286
  });
@@ -5,7 +5,6 @@ import type {
5
5
  VariableUsageReport,
6
6
  VariableValidationIssue,
7
7
  } from "@hyperframes/sdk";
8
- import type { EditHistoryKind } from "../../utils/editHistory";
9
8
  import type { PublishSdkSession } from "../../utils/sdkCutover";
10
9
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
11
10
  import { useDomEditContext } from "../../contexts/DomEditContext";
@@ -37,7 +36,6 @@ export interface StudioEditPersistenceProps {
37
36
  reloadPreview: () => void;
38
37
  recordEdit: (entry: {
39
38
  label: string;
40
- kind: EditHistoryKind;
41
39
  files: Record<string, { before: string; after: string }>;
42
40
  }) => Promise<void>;
43
41
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { usePlayerStore } from "../../player/store/playerStore";
7
7
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
8
8
  import { CompositionsTab } from "./CompositionsTab";
@@ -14,6 +14,10 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
14
 
15
15
  let root: Root | null = null;
16
16
 
17
+ beforeEach(() => {
18
+ usePlayerStore.setState({ previewBooted: true });
19
+ });
20
+
17
21
  afterEach(() => {
18
22
  if (root) act(() => root?.unmount());
19
23
  root = null;
@@ -39,6 +39,20 @@ function mount(compositions: string[], masterCompositionPath: string | null) {
39
39
  return host;
40
40
  }
41
41
 
42
+ describe("CompositionsTab card thumbnails", () => {
43
+ it("waits for the live preview to boot and keeps them through edit reloads", () => {
44
+ usePlayerStore.getState().reset();
45
+ const host = mount(["index.html"], "index.html");
46
+ expect(host.querySelector("img")).toBeNull();
47
+
48
+ act(() => usePlayerStore.getState().markPreviewBooted());
49
+ expect(host.querySelector("img")?.getAttribute("src")).toContain("/thumbnail/index.html");
50
+
51
+ act(() => usePlayerStore.getState().setTimelineReady(false));
52
+ expect(host.querySelector("img")).not.toBeNull();
53
+ });
54
+ });
55
+
42
56
  describe("CompositionsTab root badge", () => {
43
57
  it("marks the composition matching masterCompositionPath as root", () => {
44
58
  const compositions = ["index.html", "compositions/headline.html"];
@@ -1,9 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import {
3
- resolveCompositionPreviewScale,
4
- resolveThumbnailSeekTime,
5
- syncIframePlayback,
6
- } from "./CompositionsTab";
2
+ import { resolveThumbnailSeekTime } from "../../player/components/CompositionThumbnail";
3
+ import { resolveCompositionPreviewScale, syncIframePlayback } from "./CompositionsTab";
7
4
 
8
5
  describe("resolveCompositionPreviewScale", () => {
9
6
  it("scales a 16:9 stage to fit the composition card", () => {
@@ -45,8 +42,9 @@ describe("resolveThumbnailSeekTime", () => {
45
42
  expect(resolveThumbnailSeekTime(6)).toBe(3);
46
43
  });
47
44
 
48
- it("uses the midpoint for compositions shorter than 3s", () => {
45
+ it("uses the midpoint for compositions of 3s or shorter, never their end frame", () => {
49
46
  expect(resolveThumbnailSeekTime(2)).toBe(1);
47
+ expect(resolveThumbnailSeekTime(3)).toBe(1.5);
50
48
  });
51
49
 
52
50
  it("falls back to the default 3s frame when duration is unknown", () => {
@@ -1,9 +1,14 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { memo, useCallback, useEffect, useRef, useState } from "react";
3
- import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
+ import {
4
+ buildCompositionThumbnailUrl,
5
+ resolveThumbnailSeekTime,
6
+ THUMBNAIL_SEEK_TIME_SECONDS,
7
+ } from "../../player/components/CompositionThumbnail";
4
8
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
5
9
  import { usePlayerStore } from "../../player/store/playerStore";
6
10
  import { thumbnailRevisionOf } from "../../player/store/thumbnailSlice";
11
+ import { encodePreviewPath } from "../../player/components/thumbnailUtils";
7
12
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
8
13
  import { Tooltip } from "../ui/Tooltip";
9
14
 
@@ -23,7 +28,6 @@ interface CompositionsTabProps {
23
28
  const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
24
29
  const CARD_W = 80;
25
30
  const CARD_H = 45;
26
- const THUMBNAIL_SEEK_TIME_SECONDS = 3;
27
31
  const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
28
32
 
29
33
  type PreviewWindow = Window & {
@@ -54,17 +58,22 @@ export function resolveCompositionPreviewScale(input: {
54
58
  return Math.min(scaleX, scaleY);
55
59
  }
56
60
 
57
- export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
58
- if (
59
- Number.isFinite(durationSeconds) &&
60
- durationSeconds != null &&
61
- durationSeconds > 0 &&
62
- durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
63
- ) {
64
- return durationSeconds / 2;
65
- }
61
+ function compositionPreviewUrl(projectId: string, comp: string): string {
62
+ return buildProjectApiPath(projectId, `/preview/comp/${encodePreviewPath(comp)}`);
63
+ }
66
64
 
67
- return THUMBNAIL_SEEK_TIME_SECONDS;
65
+ export function compositionCardThumbnailUrl(
66
+ projectId: string,
67
+ comp: string,
68
+ contentRevision: number,
69
+ ): string {
70
+ return buildCompositionThumbnailUrl({
71
+ previewUrl: compositionPreviewUrl(projectId, comp),
72
+ seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
73
+ duration: 0,
74
+ origin: window.location.origin,
75
+ contentRevision,
76
+ });
68
77
  }
69
78
 
70
79
  function parsePositiveNumber(value: string | null): number | null {
@@ -127,6 +136,7 @@ function CompCard({
127
136
  lintInfo,
128
137
  onAddToTimeline,
129
138
  contentRevision,
139
+ previewBooted,
130
140
  }: {
131
141
  projectId: string;
132
142
  comp: string;
@@ -138,6 +148,7 @@ function CompCard({
138
148
  lintInfo?: { count: number; messages: string[] };
139
149
  onAddToTimeline?: () => void;
140
150
  contentRevision: number;
151
+ previewBooted: boolean;
141
152
  }) {
142
153
  const [hovered, setHovered] = useState(false);
143
154
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
@@ -179,14 +190,8 @@ function CompCard({
179
190
  setLivePreviewLoaded(false);
180
191
  };
181
192
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
182
- const previewUrl = buildProjectApiPath(projectId, `/preview/comp/${comp}`);
183
- const thumbnailUrl = buildCompositionThumbnailUrl({
184
- previewUrl,
185
- seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
186
- duration: 0,
187
- origin: window.location.origin,
188
- contentRevision,
189
- });
193
+ const previewUrl = compositionPreviewUrl(projectId, comp);
194
+ const thumbnailUrl = compositionCardThumbnailUrl(projectId, comp, contentRevision);
190
195
  const thumbnailFailed = failedThumbnailUrl === thumbnailUrl;
191
196
  const previewScale = resolveCompositionPreviewScale({
192
197
  cardWidth: CARD_W,
@@ -250,7 +255,7 @@ function CompCard({
250
255
  <div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
251
256
  Preview unavailable
252
257
  </div>
253
- ) : (
258
+ ) : !previewBooted ? null : (
254
259
  <img
255
260
  src={thumbnailUrl}
256
261
  alt=""
@@ -390,6 +395,7 @@ export const CompositionsTab = memo(function CompositionsTab({
390
395
  lintFindingsByFile,
391
396
  }: CompositionsTabProps) {
392
397
  const thumbnailRevisions = usePlayerStore((state) => state.thumbnailRevisions);
398
+ const previewBooted = usePlayerStore((state) => state.previewBooted);
393
399
  if (compositions.length === 0) {
394
400
  return (
395
401
  <div className="flex-1 flex items-center justify-center px-4">
@@ -413,6 +419,7 @@ export const CompositionsTab = memo(function CompositionsTab({
413
419
  isRendering={isRendering}
414
420
  lintInfo={lintFindingsByFile?.get(comp)}
415
421
  contentRevision={thumbnailRevisionOf(thumbnailRevisions, comp)}
422
+ previewBooted={previewBooted}
416
423
  />
417
424
  ))}
418
425
  </div>
@@ -1,4 +1,3 @@
1
- import type { EditHistoryKind } from "../utils/editHistory";
2
1
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
2
  import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
4
3
  import { hasRelativeLutSource } from "./studioMediaJobs";
@@ -11,7 +10,6 @@ type ProjectFileWriter = (path: string, content: string) => Promise<void>;
11
10
  type ShowToast = (message: string, tone?: "error" | "info") => void;
12
11
  type RecordEdit = (entry: {
13
12
  label: string;
14
- kind: EditHistoryKind;
15
13
  files: Record<string, { before: string; after: string }>;
16
14
  }) => Promise<void>;
17
15
 
@@ -105,7 +103,6 @@ export async function applyColorGradingScopeUpdate({
105
103
  const changedPaths = await saveProjectFilesWithHistory({
106
104
  projectId,
107
105
  label: value ? "Apply color grading" : "Clear color grading",
108
- kind: "manual",
109
106
  files,
110
107
  readFile: readProjectFile,
111
108
  writeFile: writeProjectFile,
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
9
- import type { EditHistoryKind } from "../utils/editHistory";
10
9
  import type { TimelineElement } from "../player";
11
10
  import { findMatchingTimelineElementId } from "../utils/studioHelpers";
12
11
 
@@ -14,7 +13,6 @@ import { findMatchingTimelineElementId } from "../utils/studioHelpers";
14
13
  * supplies it. */
15
14
  export interface RecordEditInput {
16
15
  label: string;
17
- kind: EditHistoryKind;
18
16
  coalesceKey?: string;
19
17
  files: Record<string, { before: string; after: string }>;
20
18
  }
@@ -1,7 +1,6 @@
1
1
  import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
- import type { EditHistoryKind } from "../utils/editHistory";
5
4
  import type { PublishSdkSession } from "../utils/sdkCutover";
6
5
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
7
6
 
@@ -96,7 +95,6 @@ export interface GsapScriptCommitsParams {
96
95
  editHistory: {
97
96
  recordEdit: (entry: {
98
97
  label: string;
99
- kind: EditHistoryKind;
100
98
  coalesceKey?: string;
101
99
  coalesceMs?: number;
102
100
  files: Record<string, { before: string; after: string }>;
@@ -1,5 +1,6 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { fetchParsedAnimations } from "./keyframeCacheAstLoad";
3
+ import { usePlayerStore } from "../player/store/playerStore";
3
4
 
4
5
  /**
5
6
  * Parsing a composition is a whole-file read + parse on the server, and a
@@ -9,10 +10,39 @@ import { fetchParsedAnimations } from "./keyframeCacheAstLoad";
9
10
  * pre-write answer.
10
11
  */
11
12
  describe("fetchParsedAnimations — in-flight sharing", () => {
13
+ beforeEach(() => {
14
+ usePlayerStore.setState({ timelineProjectId: "p", previewBooted: true });
15
+ });
12
16
  afterEach(() => {
13
17
  vi.unstubAllGlobals();
14
18
  });
15
19
 
20
+ it("does not ask the server to parse until the live preview has booted", async () => {
21
+ usePlayerStore.setState({ previewBooted: false });
22
+ const fetchStub = stubFetch();
23
+
24
+ const parsed = fetchParsedAnimations("p", "boot.html");
25
+ await Promise.resolve();
26
+ expect(fetchStub.calls()).toBe(0);
27
+
28
+ usePlayerStore.getState().markPreviewBooted();
29
+ await vi.waitFor(() => expect(fetchStub.calls()).toBe(1));
30
+ fetchStub.settle();
31
+ expect((await parsed)?.animations).toHaveLength(1);
32
+ });
33
+
34
+ it("drops a parse for a project the user left before its preview booted", async () => {
35
+ usePlayerStore.setState({ previewBooted: false });
36
+ const fetchStub = stubFetch();
37
+
38
+ const parsed = fetchParsedAnimations("p", "left.html");
39
+ usePlayerStore.getState().beginTimelineSession("q");
40
+ usePlayerStore.getState().markPreviewBooted();
41
+
42
+ expect(await parsed).toBeNull();
43
+ expect(fetchStub.calls()).toBe(0);
44
+ });
45
+
16
46
  function stubFetch(): { calls: () => number; settle: () => void } {
17
47
  let calls = 0;
18
48
  const pending: Array<() => void> = [];
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
7
7
  import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
8
- import { usePlayerStore } from "../player/store/playerStore";
8
+ import { isPreviewBooted, usePlayerStore, whenPreviewBooted } from "../player/store/playerStore";
9
9
  import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
10
10
  import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
11
11
  import {
@@ -74,6 +74,7 @@ async function requestParsedAnimations(
74
74
  projectId: string,
75
75
  sourceFile: string,
76
76
  ): Promise<ParsedGsapAnimations | null> {
77
+ if (!isPreviewBooted(projectId) && !(await whenPreviewBooted(projectId))) return null;
77
78
  try {
78
79
  const res = await fetch(
79
80
  `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
@@ -237,7 +237,6 @@ export async function createAudioGroupAndAssignMembers({
237
237
  label: groupLabel
238
238
  ? `Group ${elements.length} clips as ${groupLabel}`
239
239
  : `Group ${elements.length} voice clips`,
240
- kind: "timeline",
241
240
  files,
242
241
  readFile: async (path) => {
243
242
  const original = originalByPath.get(path);
@@ -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" {
@@ -307,7 +318,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
307
318
  await saveProjectFilesWithHistory({
308
319
  projectId: input.projectId,
309
320
  label: input.label,
310
- kind: "timeline",
311
321
  coalesceKey: input.coalesceKey,
312
322
  files: { [targetPath]: patchedContent },
313
323
  readFile: async () => originalContent,
@@ -377,7 +387,6 @@ export async function persistTimelineBatchEdit(
377
387
  await saveProjectFilesWithHistory({
378
388
  projectId: input.projectId,
379
389
  label: input.label,
380
- kind: "timeline",
381
390
  coalesceKey: input.coalesceKey,
382
391
  coalesceMs: input.coalesceMs,
383
392
  files,
@@ -455,7 +464,6 @@ export async function persistElementAttribute({
455
464
  const changedPaths = await saveProjectFilesWithHistory({
456
465
  projectId,
457
466
  label,
458
- kind: "timeline",
459
467
  files: { [targetPath]: patched },
460
468
  readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
461
469
  writeFile: writeProjectFile,
@@ -399,7 +399,6 @@ async function foldGsapMutationIntoHistory(input: {
399
399
  if (Object.keys(files).length > 0) {
400
400
  await input.recordEdit({
401
401
  label: input.label,
402
- kind: "timeline",
403
402
  coalesceKey: input.coalesceKey,
404
403
  coalesceMs: GSAP_HISTORY_COALESCE_MS,
405
404
  files,
@@ -175,7 +175,6 @@ async function setElementsHidden({
175
175
  const changedPaths = await saveProjectFilesWithHistory({
176
176
  projectId,
177
177
  label,
178
- kind: "timeline",
179
178
  files,
180
179
  readFile: async (path) => {
181
180
  const original = originalByPath.get(path);
@@ -8,7 +8,6 @@ import { usePlayerStore } from "../player";
8
8
  import { addBlockToProject } from "../utils/blockInstaller";
9
9
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
10
10
  import type { BlockParam } from "@hyperframes/core/registry";
11
- import type { EditHistoryKind } from "../utils/editHistory";
12
11
  import type { RightPanelTab } from "../utils/studioHelpers";
13
12
  import type { MediaOverlayPlacement } from "../components/editor/propertyPanelTypes";
14
13
 
@@ -19,7 +18,6 @@ interface BlockCtxDeps {
19
18
  writeProjectFile: (path: string, content: string) => Promise<void>;
20
19
  recordEdit: (entry: {
21
20
  label: string;
22
- kind: EditHistoryKind;
23
21
  coalesceKey?: string;
24
22
  files: Record<string, { before: string; after: string }>;
25
23
  }) => Promise<void>;