@hyperframes/studio 0.6.96 → 0.6.98

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 (120) hide show
  1. package/dist/assets/hyperframes-player-DgsMQSvV.js +418 -0
  2. package/dist/assets/index-B62bDCQv.css +1 -0
  3. package/dist/assets/index-Ce3pBm_I.js +252 -0
  4. package/dist/assets/{index-BWFaypdT.js → index-D-ET9M0b.js} +1 -1
  5. package/dist/assets/index-D-bS9Dxx.js +1 -0
  6. package/dist/index.html +2 -2
  7. package/package.json +7 -5
  8. package/src/App.tsx +182 -177
  9. package/src/captions/store.ts +11 -11
  10. package/src/components/StudioHeader.tsx +4 -4
  11. package/src/components/StudioLeftSidebar.tsx +2 -2
  12. package/src/components/StudioPreviewArea.tsx +225 -183
  13. package/src/components/StudioRightPanel.tsx +3 -3
  14. package/src/components/TimelineToolbar.tsx +25 -0
  15. package/src/components/editor/DomEditOverlay.tsx +2 -5
  16. package/src/components/editor/EaseCurveSection.tsx +2 -3
  17. package/src/components/editor/GestureTrailOverlay.tsx +4 -3
  18. package/src/components/editor/LayersPanel.tsx +3 -9
  19. package/src/components/editor/PropertyPanel.tsx +20 -61
  20. package/src/components/editor/colorValue.ts +3 -1
  21. package/src/components/editor/domEditOverlayGestures.ts +54 -1
  22. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  23. package/src/components/editor/gradientValue.ts +3 -3
  24. package/src/components/editor/keyframeMove.test.ts +101 -0
  25. package/src/components/editor/keyframeMove.ts +151 -0
  26. package/src/components/editor/manualEditsDom.ts +0 -12
  27. package/src/components/editor/propertyPanelHelpers.ts +10 -38
  28. package/src/components/editor/propertyPanelMediaSection.tsx +1 -5
  29. package/src/components/editor/propertyPanelTimingSection.tsx +1 -6
  30. package/src/components/editor/propertyPanelTransformCommit.ts +129 -0
  31. package/src/components/editor/studioMotionOps.test.ts +1 -1
  32. package/src/components/editor/studioMotionOps.ts +2 -1
  33. package/src/components/editor/useDomEditOverlayGestures.ts +1 -46
  34. package/src/components/nle/NLELayout.tsx +1 -24
  35. package/src/components/sidebar/BlocksTab.tsx +2 -2
  36. package/src/contexts/DomEditContext.tsx +134 -31
  37. package/src/contexts/StudioContext.tsx +90 -40
  38. package/src/contexts/TimelineEditContext.tsx +47 -0
  39. package/src/hooks/domEditCommitTypes.ts +14 -0
  40. package/src/hooks/gsapDragCommit.ts +9 -24
  41. package/src/hooks/gsapKeyframeCacheHelpers.ts +2 -1
  42. package/src/hooks/gsapKeyframeCommit.ts +5 -15
  43. package/src/hooks/gsapRuntimeBridge.ts +18 -52
  44. package/src/hooks/gsapRuntimeKeyframes.ts +8 -57
  45. package/src/hooks/gsapRuntimeReaders.ts +19 -26
  46. package/src/hooks/gsapScriptCommitHelpers.ts +1 -11
  47. package/src/hooks/gsapScriptCommitTypes.ts +58 -0
  48. package/src/hooks/gsapShared.ts +157 -0
  49. package/src/hooks/timelineEditingHelpers.ts +63 -2
  50. package/src/hooks/useAnimatedPropertyCommit.ts +3 -25
  51. package/src/hooks/useAppHotkeys.ts +299 -377
  52. package/src/hooks/useConsoleErrorCapture.ts +33 -5
  53. package/src/hooks/useDomEditCommits.ts +35 -293
  54. package/src/hooks/useDomEditPositionPatchCommit.ts +1 -1
  55. package/src/hooks/useDomEditSession.ts +78 -249
  56. package/src/hooks/useDomEditTextCommits.ts +1 -1
  57. package/src/hooks/useDomEditWiring.ts +255 -0
  58. package/src/hooks/useDomGeometryCommits.ts +181 -0
  59. package/src/hooks/useDomSelection.ts +10 -27
  60. package/src/hooks/useEditorSave.ts +82 -0
  61. package/src/hooks/useElementLifecycleOps.ts +177 -0
  62. package/src/hooks/useEnableKeyframes.ts +10 -15
  63. package/src/hooks/useFileManager.ts +32 -114
  64. package/src/hooks/useFileTree.ts +80 -0
  65. package/src/hooks/useGestureCommit.ts +7 -5
  66. package/src/hooks/useGestureRecording.ts +1 -1
  67. package/src/hooks/useGsapAnimationOps.ts +122 -0
  68. package/src/hooks/useGsapArcPathOps.ts +61 -0
  69. package/src/hooks/useGsapAwareEditing.ts +242 -0
  70. package/src/hooks/useGsapKeyframeOps.ts +167 -0
  71. package/src/hooks/useGsapPropertyDebounce.ts +135 -0
  72. package/src/hooks/useGsapScriptCommits.ts +58 -570
  73. package/src/hooks/useGsapSelectionHandlers.ts +22 -9
  74. package/src/hooks/useGsapTweenCache.ts +35 -29
  75. package/src/hooks/useLintModal.ts +7 -0
  76. package/src/hooks/useMusicBeatAnalysis.ts +152 -0
  77. package/src/hooks/useRazorSplit.ts +1 -1
  78. package/src/hooks/useRenderClipContent.ts +46 -21
  79. package/src/hooks/useTimelineEditing.ts +48 -4
  80. package/src/player/components/AudioWaveform.tsx +29 -4
  81. package/src/player/components/BeatStrip.tsx +166 -0
  82. package/src/player/components/Timeline.tsx +39 -18
  83. package/src/player/components/TimelineCanvas.tsx +52 -12
  84. package/src/player/components/TimelineClipDiamonds.tsx +130 -20
  85. package/src/player/components/TimelinePropertyRows.tsx +8 -2
  86. package/src/player/components/TimelineRuler.tsx +36 -2
  87. package/src/player/components/timelineEditing.ts +30 -5
  88. package/src/player/components/useTimelineClipDrag.ts +155 -4
  89. package/src/player/components/useTimelinePlayhead.ts +30 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +47 -45
  91. package/src/player/lib/mediaProbe.ts +46 -3
  92. package/src/player/lib/playbackScrub.ts +16 -0
  93. package/src/player/lib/timelineDOM.ts +10 -2
  94. package/src/player/lib/timelineIframeHelpers.ts +89 -0
  95. package/src/player/store/playerStore.ts +92 -33
  96. package/src/utils/beatEditActions.ts +109 -0
  97. package/src/utils/beatEditing.ts +136 -0
  98. package/src/utils/clipboardPayload.ts +3 -2
  99. package/src/utils/compositionPatterns.ts +2 -0
  100. package/src/utils/keyframeSelection.test.ts +45 -0
  101. package/src/utils/keyframeSelection.ts +29 -0
  102. package/src/utils/rounding.ts +9 -0
  103. package/src/utils/studioHelpers.ts +5 -2
  104. package/src/utils/studioUrlState.ts +2 -1
  105. package/src/utils/timelineAssetDrop.ts +6 -5
  106. package/src/utils/timelineInspector.ts +15 -100
  107. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  108. package/dist/assets/index-B0twsRu0.css +0 -1
  109. package/dist/assets/index-BA979yF1.js +0 -251
  110. package/src/components/editor/DopesheetStrip.tsx +0 -141
  111. package/src/components/editor/StaggerControls.tsx +0 -61
  112. package/src/components/editor/TimelineLayerPanel.test.ts +0 -42
  113. package/src/components/editor/TimelineLayerPanel.tsx +0 -15
  114. package/src/components/nle/TimelineEditorNotice.tsx +0 -133
  115. package/src/hooks/gsapRuntimePreview.ts +0 -19
  116. package/src/player/components/timelineUtils.ts +0 -211
  117. package/src/utils/audioBeatDetection.ts +0 -58
  118. package/src/utils/keyframeSnapping.test.ts +0 -74
  119. package/src/utils/keyframeSnapping.ts +0 -63
  120. package/src/utils/timelineInspector.test.ts +0 -79
@@ -1,4 +1,4 @@
1
- import{n as Qi}from"./index-BA979yF1.js";/*!
1
+ import{n as Qi}from"./index-Ce3pBm_I.js";/*!
2
2
  * Copyright (c) 2026-present, Vanilagy and contributors
3
3
  *
4
4
  * This Source Code Form is subject to the terms of the Mozilla Public
@@ -0,0 +1 @@
1
+ import{g as P}from"./index-Ce3pBm_I.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-BA979yF1.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-B0twsRu0.css">
8
+ <script type="module" crossorigin src="/assets/index-Ce3pBm_I.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B62bDCQv.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.96",
3
+ "version": "0.6.98",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,8 @@
16
16
  "types": "./src/index.ts",
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
- "./tailwind-preset": "./src/styles/tailwind-preset.ts"
19
+ "./tailwind-preset": "./src/styles/tailwind-preset.ts",
20
+ "./package.json": "./package.json"
20
21
  },
21
22
  "dependencies": {
22
23
  "@codemirror/autocomplete": "^6.20.1",
@@ -30,9 +31,10 @@
30
31
  "@codemirror/theme-one-dark": "^6.1.2",
31
32
  "@codemirror/view": "6.40.0",
32
33
  "@phosphor-icons/react": "^2.1.10",
34
+ "bpm-detective": "^2.0.5",
33
35
  "mediabunny": "^1.45.3",
34
- "@hyperframes/player": "0.6.96",
35
- "@hyperframes/core": "0.6.96"
36
+ "@hyperframes/core": "0.6.98",
37
+ "@hyperframes/player": "0.6.98"
36
38
  },
37
39
  "devDependencies": {
38
40
  "@types/react": "19",
@@ -46,7 +48,7 @@
46
48
  "vite": "^6.4.2",
47
49
  "vitest": "^3.2.4",
48
50
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.96"
51
+ "@hyperframes/producer": "0.6.98"
50
52
  },
51
53
  "peerDependencies": {
52
54
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef, useMemo, useEffect } from "react";
1
+ import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
2
2
  import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
3
3
  import { useRenderQueue } from "./components/renders/useRenderQueue";
4
4
  import { usePlayerStore } from "./player";
@@ -17,6 +17,7 @@ import { useBlockHandlers } from "./hooks/useBlockHandlers";
17
17
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
18
18
  import { useClipboard } from "./hooks/useClipboard";
19
19
  import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
20
+ import { selectedKeyframePercentagesForElement } from "./utils/keyframeSelection";
20
21
  import { useCaptionDetection } from "./hooks/useCaptionDetection";
21
22
  import { useRenderClipContent } from "./hooks/useRenderClipContent";
22
23
  import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
@@ -37,13 +38,12 @@ import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
37
38
  import { StudioHeader } from "./components/StudioHeader";
38
39
  import { useGestureCommit } from "./hooks/useGestureCommit";
39
40
  import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
40
-
41
41
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
42
42
  import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
43
43
  import { StudioPreviewArea } from "./components/StudioPreviewArea";
44
44
  import { StudioRightPanel } from "./components/StudioRightPanel";
45
45
  import { TimelineToolbar } from "./components/TimelineToolbar";
46
- import { StudioProvider } from "./contexts/StudioContext";
46
+ import { StudioPlaybackProvider, StudioShellProvider } from "./contexts/StudioContext";
47
47
  import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
48
48
  import { FileManagerProvider } from "./contexts/FileManagerContext";
49
49
  import { DomEditProvider } from "./contexts/DomEditContext";
@@ -57,15 +57,13 @@ import {
57
57
  import { trackStudioSessionStart } from "./telemetry/events";
58
58
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
59
59
 
60
+ type CanvasRect = { left: number; top: number; width: number; height: number };
60
61
  // fallow-ignore-next-line complexity
61
62
  export function StudioApp() {
62
63
  const { projectId, resolving, waitingForServer } = useServerConnection();
63
64
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
64
65
 
65
- // Fire once per browser tab session sessionStorage-backed so HMR
66
- // remounts, route changes, and any future StudioApp remount within the
67
- // same tab don't refire `studio_session_start`. `has_project` lets us
68
- // tell scratch-open from project-context-open.
66
+ // sessionStorage-backed: fires once per tab, survives HMR remounts
69
67
  useEffect(() => {
70
68
  if (resolving || waitingForServer) return;
71
69
  if (hasFiredSessionStart()) return;
@@ -83,7 +81,6 @@ export function StudioApp() {
83
81
  const [refreshKey, setRefreshKey] = useState(0);
84
82
  const [, setPreviewDocumentVersion] = useState(0);
85
83
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
86
-
87
84
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
88
85
  const activeCompPathRef = useRef(activeCompPath);
89
86
  activeCompPathRef.current = activeCompPath;
@@ -107,12 +104,22 @@ export function StudioApp() {
107
104
  : 0;
108
105
  return Math.max(timelineDuration, maxEnd);
109
106
  }, [timelineDuration, timelineElements]);
107
+ const refreshTimersRef = useRef<number[]>([]);
110
108
  const refreshPreviewDocumentVersion = useCallback(() => {
109
+ for (const id of refreshTimersRef.current) clearTimeout(id);
110
+ refreshTimersRef.current = [];
111
111
  setPreviewDocumentVersion((v) => v + 1);
112
- window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80);
113
- window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
112
+ refreshTimersRef.current.push(
113
+ window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
114
+ window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
115
+ );
114
116
  }, []);
115
-
117
+ useEffect(
118
+ () => () => {
119
+ for (const id of refreshTimersRef.current) clearTimeout(id);
120
+ },
121
+ [],
122
+ );
116
123
  const [timelineVisible, setTimelineVisible] = useState(
117
124
  () =>
118
125
  initialUrlStateRef.current.timelineVisible ??
@@ -137,7 +144,6 @@ export function StudioApp() {
137
144
  const reloadPreview = useCallback(() => {
138
145
  setRefreshKey((k) => k + 1);
139
146
  }, []);
140
-
141
147
  const fileManager = useFileManager({
142
148
  projectId,
143
149
  showToast,
@@ -145,11 +151,9 @@ export function StudioApp() {
145
151
  domEditSaveTimestampRef,
146
152
  setRefreshKey,
147
153
  });
148
-
149
154
  useEffect(() => {
150
155
  if (activeCompPathHydrated) return;
151
156
  if (!fileManager.fileTreeLoaded) return;
152
-
153
157
  const nextCompPath = normalizeStudioCompositionPath(
154
158
  initialUrlStateRef.current.activeCompPath,
155
159
  fileManager.fileTree,
@@ -157,7 +161,6 @@ export function StudioApp() {
157
161
  setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
158
162
  setActiveCompPathHydrated(true);
159
163
  }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
160
-
161
164
  const previewPersistence = usePreviewPersistence({
162
165
  projectId,
163
166
  showToast,
@@ -170,7 +173,6 @@ export function StudioApp() {
170
173
  reloadPreview: () => setRefreshKey((k) => k + 1),
171
174
  pendingTimelineEditPathRef,
172
175
  });
173
-
174
176
  const timelineEditing = useTimelineEditing({
175
177
  projectId,
176
178
  activeCompPath,
@@ -185,7 +187,6 @@ export function StudioApp() {
185
187
  uploadProjectFiles: fileManager.uploadProjectFiles,
186
188
  isRecordingRef: isGestureRecordingRef,
187
189
  });
188
-
189
190
  const {
190
191
  activeBlockParams,
191
192
  setActiveBlockParams,
@@ -208,7 +209,6 @@ export function StudioApp() {
208
209
  setRightCollapsed: panelLayout.setRightCollapsed,
209
210
  setRightPanelTab: panelLayout.setRightPanelTab,
210
211
  });
211
-
212
212
  const clearDomSelectionRef = useRef<() => void>(() => {});
213
213
  const domEditSelectionBridgeRef = useRef<DomEditSelection | null>(null);
214
214
  const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
@@ -265,7 +265,6 @@ export function StudioApp() {
265
265
  () => leftSidebarRef.current?.getTab() ?? "compositions",
266
266
  [],
267
267
  );
268
-
269
268
  const domEditSession = useDomEditSession({
270
269
  projectId,
271
270
  activeCompPath,
@@ -307,13 +306,13 @@ export function StudioApp() {
307
306
  resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
308
307
  invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
309
308
  deleteSelectedKeyframesRef.current = () => {
310
- const sk = usePlayerStore.getState().selectedKeyframes;
309
+ const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
311
310
  const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
312
- if (!a || sk.size === 0) return;
313
- sk.forEach((k) => {
314
- const p = Number(k.split(":")[1]);
315
- if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p);
316
- });
311
+ if (!a) return;
312
+ // Only the active element's keyframes; a stale cross-element selection must not delete here.
313
+ for (const p of selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId)) {
314
+ domEditSession.handleGsapRemoveKeyframe(a.id, p);
315
+ }
317
316
  };
318
317
  useCaptionDetection({
319
318
  projectId,
@@ -325,20 +324,17 @@ export function StudioApp() {
325
324
  captionSync,
326
325
  setRightCollapsed: panelLayout.setRightCollapsed,
327
326
  });
328
-
329
327
  const renderClipContent = useRenderClipContent({
330
328
  projectIdRef: fileManager.projectIdRef,
331
329
  compIdToSrc,
332
330
  activePreviewUrl,
333
331
  effectiveTimelineDuration,
334
332
  });
335
-
336
333
  const compositionDimensions = useCompositionDimensions();
337
- const { lintModal, linting, handleLint, closeLintModal, findingsByElement, findingsByFile } =
338
- useLintModal(projectId, refreshKey);
339
- useEffect(() => {
340
- usePlayerStore.getState().setLintFindingsByElement(findingsByElement);
341
- }, [findingsByElement]);
334
+ const { lintModal, linting, handleLint, closeLintModal, findingsByFile } = useLintModal(
335
+ projectId,
336
+ refreshKey,
337
+ );
342
338
  const frameCapture = useFrameCapture({
343
339
  projectId,
344
340
  activeCompPath,
@@ -350,14 +346,12 @@ export function StudioApp() {
350
346
  setConsoleErrors,
351
347
  resetErrors: resetConsoleErrors,
352
348
  } = useConsoleErrorCapture(previewIframe);
353
-
354
349
  const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
355
350
 
356
351
  // Gesture recording
357
352
  const handleToggleRecordingRef = useRef<() => void>(() => {});
358
353
  const domEditSessionRef = useRef(domEditSession);
359
354
  domEditSessionRef.current = domEditSession;
360
-
361
355
  const { gestureState, gestureRecording, handleToggleRecording } = useGestureCommit({
362
356
  domEditSessionRef,
363
357
  previewIframeRef,
@@ -365,6 +359,15 @@ export function StudioApp() {
365
359
  isGestureRecordingRef,
366
360
  });
367
361
  handleToggleRecordingRef.current = handleToggleRecording;
362
+ const canvasRectRef = useRef<CanvasRect | null>(null);
363
+ useLayoutEffect(() => {
364
+ if (gestureState !== "recording" || !previewIframe) {
365
+ canvasRectRef.current = null;
366
+ return;
367
+ }
368
+ const r = previewIframe.getBoundingClientRect();
369
+ canvasRectRef.current = { left: r.left, top: r.top, width: r.width, height: r.height };
370
+ }, [gestureState, previewIframe]);
368
371
 
369
372
  const handlePreviewIframeRef = useCallback(
370
373
  (iframe: HTMLIFrameElement | null) => {
@@ -388,7 +391,6 @@ export function StudioApp() {
388
391
  },
389
392
  [projectId, fileManager],
390
393
  );
391
-
392
394
  const {
393
395
  designPanelActive,
394
396
  inspectorPanelActive,
@@ -400,7 +402,6 @@ export function StudioApp() {
400
402
  isPlaying,
401
403
  gestureState === "recording",
402
404
  );
403
-
404
405
  useStudioUrlState({
405
406
  projectId,
406
407
  activeCompPath,
@@ -418,7 +419,17 @@ export function StudioApp() {
418
419
  applyDomSelection: domEditSession.applyDomSelection,
419
420
  initialState: initialUrlStateRef.current,
420
421
  });
421
-
422
+ const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue;
423
+ const stableRenderQueue = useMemo(
424
+ () => ({
425
+ jobs,
426
+ isRendering,
427
+ deleteRender,
428
+ clearCompleted,
429
+ startRender: startRender as (options: unknown) => Promise<void>,
430
+ }),
431
+ [jobs, isRendering, deleteRender, clearCompleted, startRender],
432
+ );
422
433
  const studioCtxValue = buildStudioContextValue({
423
434
  projectId: projectId!,
424
435
  activeCompPath,
@@ -434,13 +445,7 @@ export function StudioApp() {
434
445
  editHistory,
435
446
  handleUndo: appHotkeys.handleUndo,
436
447
  handleRedo: appHotkeys.handleRedo,
437
- renderQueue: {
438
- jobs: renderQueue.jobs,
439
- isRendering: renderQueue.isRendering,
440
- deleteRender: renderQueue.deleteRender,
441
- clearCompleted: renderQueue.clearCompleted,
442
- startRender: renderQueue.startRender as (options: unknown) => Promise<void>,
443
- },
448
+ renderQueue: stableRenderQueue,
444
449
  compositionDimensions,
445
450
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
446
451
  handlePreviewIframeRef,
@@ -448,147 +453,147 @@ export function StudioApp() {
448
453
  timelineVisible,
449
454
  toggleTimelineVisibility,
450
455
  });
456
+ const timelineToolbar = useMemo(
457
+ () => (
458
+ <TimelineToolbar
459
+ toggleTimelineVisibility={toggleTimelineVisibility}
460
+ domEditSession={domEditSession}
461
+ onSplitElement={timelineEditing.handleTimelineElementSplit}
462
+ />
463
+ ),
464
+ [toggleTimelineVisibility, domEditSession, timelineEditing.handleTimelineElementSplit],
465
+ );
451
466
  if (resolving || waitingForServer || !projectId)
452
467
  return <StudioSplash waiting={waitingForServer} />;
453
- const timelineToolbar = (
454
- <TimelineToolbar
455
- toggleTimelineVisibility={toggleTimelineVisibility}
456
- domEditSession={domEditSession}
457
- onSplitElement={timelineEditing.handleTimelineElementSplit}
458
- />
459
- );
460
468
  return (
461
- <StudioProvider value={studioCtxValue}>
462
- <PanelLayoutProvider value={panelLayout}>
463
- <FileManagerProvider value={fileManager}>
464
- <DomEditProvider value={domEditSession}>
465
- <div
466
- className="flex flex-col h-full w-full bg-neutral-950 relative"
467
- onDragOver={dragOverlay.onDragOver}
468
- onDragEnter={dragOverlay.onDragEnter}
469
- onDragLeave={dragOverlay.onDragLeave}
470
- onDrop={dragOverlay.onDrop}
471
- >
472
- <StudioHeader
473
- captureFrameHref={frameCapture.captureFrameHref}
474
- captureFrameFilename={frameCapture.captureFrameFilename}
475
- handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
476
- refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
477
- inspectorButtonActive={inspectorButtonActive}
478
- inspectorPanelActive={inspectorPanelActive}
479
- onExport={() => void renderQueue.startRender()}
480
- />
481
-
482
- {previewPersistence.domEditSaveQueuePaused && (
483
- <SaveQueuePausedBanner
484
- message={previewPersistence.domEditSaveQueuePaused}
485
- onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
486
- />
487
- )}
488
-
489
- <div className="flex flex-1 min-h-0">
490
- <StudioLeftSidebar
491
- leftSidebarRef={leftSidebarRef}
492
- onSelectComposition={handleSelectComposition}
493
- onAddBlock={handleAddBlock}
494
- onPreviewBlock={setBlockPreview}
495
- onLint={handleLint}
496
- linting={linting}
497
- lintFindingCount={lintModal?.length ?? findingsByFile.size}
498
- lintFindingsByFile={findingsByFile}
499
- />
500
- <StudioPreviewArea
501
- timelineToolbar={timelineToolbar}
502
- renderClipContent={renderClipContent}
503
- handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
504
- handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
505
- handleTimelineBlockDrop={handleTimelineBlockDrop}
506
- handlePreviewBlockDrop={handlePreviewBlockDrop}
507
- handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
508
- handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
509
- handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
510
- handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
511
- handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
512
- handleRazorSplit={timelineEditing.handleRazorSplit}
513
- handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
514
- setCompIdToSrc={setCompIdToSrc}
515
- setCompositionLoading={setCompositionLoading}
516
- shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
517
- isGestureRecording={gestureState === "recording"}
518
- recordingState={gestureState}
519
- onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
520
- blockPreview={blockPreview}
521
- gestureOverlay={
522
- gestureState === "recording" && previewIframe ? (
523
- <GestureTrailOverlay
524
- samples={gestureRecording.samplesRef.current}
525
- sampleCount={gestureRecording.samplesRef.current.length}
526
- trail={gestureRecording.trailRef.current}
527
- canvasRect={(() => {
528
- const r = previewIframe.getBoundingClientRect();
529
- return { left: r.left, top: r.top, width: r.width, height: r.height };
530
- })()}
531
- compositionSize={compositionDimensions ?? undefined}
532
- mode="recording"
533
- />
534
- ) : undefined
535
- }
469
+ <StudioShellProvider value={studioCtxValue}>
470
+ <StudioPlaybackProvider value={studioCtxValue}>
471
+ <PanelLayoutProvider value={panelLayout}>
472
+ <FileManagerProvider value={fileManager}>
473
+ <DomEditProvider value={domEditSession}>
474
+ <div
475
+ className="flex flex-col h-full w-full bg-neutral-950 relative"
476
+ onDragOver={dragOverlay.onDragOver}
477
+ onDragEnter={dragOverlay.onDragEnter}
478
+ onDragLeave={dragOverlay.onDragLeave}
479
+ onDrop={dragOverlay.onDrop}
480
+ >
481
+ <StudioHeader
482
+ captureFrameHref={frameCapture.captureFrameHref}
483
+ captureFrameFilename={frameCapture.captureFrameFilename}
484
+ handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
485
+ refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
486
+ inspectorButtonActive={inspectorButtonActive}
487
+ inspectorPanelActive={inspectorPanelActive}
488
+ onExport={() => void renderQueue.startRender()}
536
489
  />
537
-
538
- {!panelLayout.rightCollapsed && (
539
- <StudioRightPanel
540
- designPanelActive={designPanelActive}
541
- activeBlockParams={activeBlockParams}
542
- onCloseBlockParams={() => {
543
- setActiveBlockParams(null);
544
- panelLayout.setRightPanelTab("design");
545
- }}
490
+ {previewPersistence.domEditSaveQueuePaused && (
491
+ <SaveQueuePausedBanner
492
+ message={previewPersistence.domEditSaveQueuePaused}
493
+ onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
494
+ />
495
+ )}
496
+ <div className="flex flex-1 min-h-0">
497
+ <StudioLeftSidebar
498
+ leftSidebarRef={leftSidebarRef}
499
+ onSelectComposition={handleSelectComposition}
500
+ onAddBlock={handleAddBlock}
501
+ onPreviewBlock={setBlockPreview}
502
+ onLint={handleLint}
503
+ linting={linting}
504
+ lintFindingCount={lintModal?.length ?? findingsByFile.size}
505
+ lintFindingsByFile={findingsByFile}
506
+ />
507
+ <StudioPreviewArea
508
+ timelineToolbar={timelineToolbar}
509
+ renderClipContent={renderClipContent}
510
+ handleTimelineElementDelete={timelineEditing.handleTimelineElementDelete}
511
+ handleTimelineAssetDrop={timelineEditing.handleTimelineAssetDrop}
512
+ handleTimelineBlockDrop={handleTimelineBlockDrop}
513
+ handlePreviewBlockDrop={handlePreviewBlockDrop}
514
+ handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
515
+ handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
516
+ handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
517
+ handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
518
+ handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
519
+ handleRazorSplit={timelineEditing.handleRazorSplit}
520
+ handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
521
+ setCompIdToSrc={setCompIdToSrc}
522
+ setCompositionLoading={setCompositionLoading}
523
+ shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
524
+ isGestureRecording={gestureState === "recording"}
546
525
  recordingState={gestureState}
547
- recordingDuration={gestureRecording.recordingDuration}
548
526
  onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
527
+ blockPreview={blockPreview}
528
+ gestureOverlay={
529
+ gestureState === "recording" && previewIframe ? (
530
+ <GestureTrailOverlay
531
+ samples={gestureRecording.samplesRef.current}
532
+ sampleCount={gestureRecording.samplesRef.current.length}
533
+ trail={gestureRecording.trailRef.current}
534
+ canvasRect={canvasRectRef.current!}
535
+ compositionSize={compositionDimensions ?? undefined}
536
+ mode="recording"
537
+ />
538
+ ) : undefined
539
+ }
549
540
  />
550
- )}
551
- </div>
552
-
553
- {lintModal !== null && (
554
- <LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
555
- )}
556
- {consoleErrors !== null && consoleErrors.length > 0 && (
557
- <LintModal
558
- findings={consoleErrors}
559
- projectId={projectId}
560
- onClose={() => setConsoleErrors(null)}
561
- />
562
- )}
563
- {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
564
- <AskAgentModal
565
- selectionLabel={domEditSession.domEditSelection.label}
566
- contextPreview={buildAgentContextPreview(
567
- domEditSession.domEditSelection,
568
- activeCompPath,
541
+ {!panelLayout.rightCollapsed && (
542
+ <StudioRightPanel
543
+ designPanelActive={designPanelActive}
544
+ activeBlockParams={activeBlockParams}
545
+ onCloseBlockParams={() => {
546
+ setActiveBlockParams(null);
547
+ panelLayout.setRightPanelTab("design");
548
+ }}
549
+ recordingState={gestureState}
550
+ recordingDuration={gestureRecording.recordingDuration}
551
+ onToggleRecording={
552
+ STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
553
+ }
554
+ />
569
555
  )}
570
- anchorPoint={domEditSession.agentModalAnchorPoint}
571
- onSubmit={domEditSession.handleAgentModalSubmit}
572
- onClose={() => {
573
- domEditSession.setAgentModalOpen(false);
574
- domEditSession.setAgentPromptSelectionContext(undefined);
575
- domEditSession.setAgentModalAnchorPoint(null);
576
- }}
577
- />
578
- )}
556
+ </div>
557
+ {lintModal !== null && (
558
+ <LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
559
+ )}
560
+ {consoleErrors !== null && consoleErrors.length > 0 && (
561
+ <LintModal
562
+ findings={consoleErrors}
563
+ projectId={projectId}
564
+ onClose={() => setConsoleErrors(null)}
565
+ />
566
+ )}
567
+ {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
568
+ <AskAgentModal
569
+ selectionLabel={domEditSession.domEditSelection.label}
570
+ contextPreview={buildAgentContextPreview(
571
+ domEditSession.domEditSelection,
572
+ activeCompPath,
573
+ )}
574
+ anchorPoint={domEditSession.agentModalAnchorPoint}
575
+ onSubmit={domEditSession.handleAgentModalSubmit}
576
+ onClose={() => {
577
+ domEditSession.setAgentModalOpen(false);
578
+ domEditSession.setAgentPromptSelectionContext(undefined);
579
+ domEditSession.setAgentModalAnchorPoint(null);
580
+ }}
581
+ />
582
+ )}
579
583
 
580
- {dragOverlay.active && <StudioGlobalDragOverlay />}
581
- {appToast && (
582
- <StudioToast
583
- message={appToast.message}
584
- tone={appToast.tone}
585
- onDismiss={dismissToast}
586
- />
587
- )}
588
- </div>
589
- </DomEditProvider>
590
- </FileManagerProvider>
591
- </PanelLayoutProvider>
592
- </StudioProvider>
584
+ {dragOverlay.active && <StudioGlobalDragOverlay />}
585
+ {appToast && (
586
+ <StudioToast
587
+ message={appToast.message}
588
+ tone={appToast.tone}
589
+ onDismiss={dismissToast}
590
+ />
591
+ )}
592
+ </div>
593
+ </DomEditProvider>
594
+ </FileManagerProvider>
595
+ </PanelLayoutProvider>
596
+ </StudioPlaybackProvider>
597
+ </StudioShellProvider>
593
598
  );
594
599
  }
@@ -59,7 +59,7 @@ const initialState = {
59
59
  sourceFilePath: null,
60
60
  };
61
61
 
62
- export const useCaptionStore = create<CaptionState>((set) => ({
62
+ export const useCaptionStore = create<CaptionState>((set, get) => ({
63
63
  ...initialState,
64
64
 
65
65
  // Basic
@@ -82,15 +82,11 @@ export const useCaptionStore = create<CaptionState>((set) => ({
82
82
  return { selectedSegmentIds: new Set([id]), selectedGroupId: null };
83
83
  }),
84
84
 
85
- selectGroup: (id) =>
86
- set((state) => {
87
- const group = state.model?.groups.get(id);
88
- if (!group) return {};
89
- return {
90
- selectedSegmentIds: new Set(group.segmentIds),
91
- selectedGroupId: id,
92
- };
93
- }),
85
+ selectGroup: (id) => {
86
+ const group = get().model?.groups.get(id);
87
+ if (!group) return;
88
+ set({ selectedSegmentIds: new Set(group.segmentIds), selectedGroupId: id });
89
+ },
94
90
 
95
91
  selectAll: () =>
96
92
  set((state) => {
@@ -101,7 +97,11 @@ export const useCaptionStore = create<CaptionState>((set) => ({
101
97
  };
102
98
  }),
103
99
 
104
- clearSelection: () => set({ selectedSegmentIds: new Set(), selectedGroupId: null }),
100
+ clearSelection: () => {
101
+ const { selectedSegmentIds, selectedGroupId } = get();
102
+ if (selectedSegmentIds.size === 0 && selectedGroupId === null) return;
103
+ set({ selectedSegmentIds: new Set(), selectedGroupId: null });
104
+ },
105
105
 
106
106
  // Segment mutations
107
107
  updateSegmentStyle: (segmentId, style) =>