@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
8
 
9
9
  /**
10
- * Read a project file's content, or undefined on a non-2xx (optional read).
11
- * Replaces the removed SDK http adapter's `read()` — the only thing Studio used
12
- * it for (Studio is the sole writer, so the adapter's write path was dead).
10
+ * Why an optional project-file read produced no usable content. `stage: "read"`
11
+ * was a single opaque reason covering all of these, which made the largest
12
+ * remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
13
+ * window hit it and, between them, never landed a single successful SDK edit.
14
+ * Knowing which branch fired is the difference between "the file legitimately
15
+ * is not there" and "the request never reached the file".
16
+ *
17
+ * Every reason lives in this union so the full surface is readable from one
18
+ * place — `absent_or_empty` included, even though it is a 2xx.
19
+ */
20
+ type ProjectFileReadFailure =
21
+ | { ok: false; reason: "unsafe_path" }
22
+ | { ok: false; reason: "http_error"; status: number }
23
+ | { ok: false; reason: "missing_content" }
24
+ | { ok: false; reason: "absent_or_empty" };
25
+
26
+ type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
+
28
+ /**
29
+ * Read a project file's content (optional read — a missing file is not an
30
+ * error). Replaces the removed SDK http adapter's `read()` — the only thing
31
+ * Studio used it for (Studio is the sole writer, so the adapter's write path
32
+ * was dead).
13
33
  */
14
34
  async function readProjectFileOptional(
15
35
  projectId: string,
16
36
  path: string,
17
- ): Promise<string | undefined> {
37
+ ): Promise<ProjectFileReadResult> {
18
38
  // Reject traversal / NUL before building the request URL — `path` is a
19
39
  // user-influenced composition path (mirrors the guard in timelineEditingHelpers,
20
40
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
21
41
  // already confines both values to single segments of this same-origin URL.
22
- if (path.includes("\0") || path.includes("..")) return undefined;
42
+ if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
23
43
  const res = await fetch(
24
44
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
25
45
  );
26
- if (!res.ok) return undefined;
46
+ if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
27
47
  const data = (await res.json()) as { content?: string };
28
- return typeof data.content === "string" ? data.content : undefined;
48
+ // `optional=1` answers a missing file with 200 + `content: ""`, so a
49
+ // non-string here means a response shape we did not expect, not absence.
50
+ if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
51
+ // An empty body parses into a session with no elements, which declines every
52
+ // edit wholesale — not a session worth opening. The absent-file shim and a
53
+ // genuinely 0-byte file are the same 200 on the wire and cannot be told
54
+ // apart here, hence the name; for a composition it is always the former.
55
+ if (data.content === "") return { ok: false, reason: "absent_or_empty" };
56
+ return { ok: true, content: data.content };
29
57
  }
30
58
 
31
59
  /**
@@ -180,16 +208,21 @@ export function useSdkSession(
180
208
  };
181
209
 
182
210
  readProjectFileOptional(projectId, activeCompPath)
183
- .then(async (content) => {
211
+ .then(async (read) => {
184
212
  if (cancelled) return;
185
- if (typeof content !== "string") {
213
+ if (!read.ok) {
186
214
  // No SDK session follows, so EVERY cutover chokepoint below takes the
187
215
  // server path and emits nothing — the shadow never runs either. This
188
216
  // is the only place a missing session can originate, so a broken read
189
217
  // would otherwise be a silent, total SDK bypass.
190
- trackStudioEvent("sdk_session_unavailable", { stage: "read" });
218
+ trackStudioEvent("sdk_session_unavailable", {
219
+ stage: "read",
220
+ reason: read.reason,
221
+ ...(read.reason === "http_error" ? { status: read.status } : {}),
222
+ });
191
223
  return;
192
224
  }
225
+ const content = read.content;
193
226
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
194
227
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
195
228
  // queue too would double-write the file (queue auto-writes on every
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
1
+ import { useCallback, useMemo, type DragEvent } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import type { StudioContextValue } from "../contexts/StudioContext";
4
4
  import type { RightInspectorPanes } from "../utils/studioHelpers";
@@ -116,35 +116,21 @@ export function useInspectorState(
116
116
  ]);
117
117
  }
118
118
 
119
- // fallow-ignore-next-line complexity
120
- function useDragOverlay(onImportFiles: (files: FileList) => void) {
121
- const [active, setActive] = useState(false);
122
- const counterRef = useRef(0);
119
+ /** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */
120
+ function useFileDropTarget(onImportFiles: (files: FileList) => void) {
123
121
  const onDragOver = useCallback((e: DragEvent) => {
124
122
  if (!e.dataTransfer.types.includes("Files")) return;
125
123
  e.preventDefault();
126
124
  }, []);
127
- const onDragEnter = useCallback((e: DragEvent) => {
128
- if (!e.dataTransfer.types.includes("Files")) return;
129
- e.preventDefault();
130
- counterRef.current++;
131
- setActive(true);
132
- }, []);
133
- const onDragLeave = useCallback(() => {
134
- counterRef.current--;
135
- if (counterRef.current === 0) setActive(false);
136
- }, []);
137
125
  const onDrop = useCallback(
138
126
  (e: DragEvent) => {
139
- counterRef.current = 0;
140
- setActive(false);
141
127
  if (e.defaultPrevented) return;
142
128
  e.preventDefault();
143
129
  if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files);
144
130
  },
145
131
  [onImportFiles],
146
132
  );
147
- return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
133
+ return { onDragOver, onDrop };
148
134
  }
149
135
 
150
136
  /** Global OS file drop: imports and places at the playhead position. */
@@ -156,5 +142,5 @@ export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandle
156
142
  },
157
143
  [handleTimelineFileDrop],
158
144
  );
159
- return useDragOverlay(onDrop);
145
+ return useFileDropTarget(onDrop);
160
146
  }
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
+ import type { TimelineElement } from "../player";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -19,6 +20,7 @@ type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDro
19
20
  function renderDropHook(
20
21
  sourceContent: string,
21
22
  writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
23
+ timelineElements: TimelineElement[] = [],
22
24
  ) {
23
25
  vi.stubGlobal(
24
26
  "fetch",
@@ -29,7 +31,7 @@ function renderDropHook(
29
31
  const { handleTimelineAssetDrop } = useTimelineAssetDropOps({
30
32
  projectIdRef: { current: "project" },
31
33
  activeCompPath: "index.html",
32
- timelineElements: [],
34
+ timelineElements,
33
35
  showToast: vi.fn(),
34
36
  writeProjectFile,
35
37
  recordEdit: vi.fn().mockResolvedValue(undefined),
@@ -84,4 +86,42 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
84
86
 
85
87
  expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
86
88
  });
89
+
90
+ it("measures the insert row against the visible rows, not clips the timeline hides", async () => {
91
+ const clip = (id: string, track: number): TimelineElement => ({
92
+ id,
93
+ key: id,
94
+ tag: "div",
95
+ start: 0,
96
+ duration: 2,
97
+ track,
98
+ authoredTrack: track,
99
+ hfId: `hf-${id}`,
100
+ domId: id,
101
+ });
102
+ const source = [
103
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
104
+ ...["a:0", "hidden:1", "b:2"].map((t) => {
105
+ const [id, track] = t.split(":");
106
+ return `<div data-hf-id="hf-${id}" id="${id}" data-start="0" data-track-index="${track}"></div>`;
107
+ }),
108
+ "</main>",
109
+ ].join("\n");
110
+ usePlayerStore.getState().setTopLevelIds(new Set(["a", "b"]));
111
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
112
+ const getDrop = renderDropHook(source, writeProjectFile, [
113
+ clip("a", 0),
114
+ clip("hidden", 1),
115
+ clip("b", 2),
116
+ ]);
117
+
118
+ await act(async () => {
119
+ await getDrop()("clip.mp4", { start: 1, track: 1, insertRow: 1, trackOrder: [0, 2] }, 2);
120
+ });
121
+
122
+ const [, written] = writeProjectFile.mock.calls[0] as [string, string];
123
+ expect(written).toContain('id="b" data-start="0" data-track-index="2"');
124
+ expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
125
+ expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
126
+ });
87
127
  });
@@ -4,6 +4,8 @@
4
4
  // 600-line cap.
5
5
  import { useCallback, type MutableRefObject, type RefObject } from "react";
6
6
  import type { TimelineElement } from "../player";
7
+ import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
8
+ import { resolveDropTrack } from "../utils/timelineDropTrackInsert";
7
9
  import {
8
10
  buildTimelineAssetId,
9
11
  buildTimelineAssetInsertHtml,
@@ -23,6 +25,18 @@ import { commitTimelineCompositionInsertion } from "../utils/timelineComposition
23
25
  import { extendRootDurationInSource } from "../utils/rootDuration";
24
26
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
25
27
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
28
+ import { selectTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
+ import { usePlayerStore } from "../player/store/playerStore";
30
+
31
+ /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
32
+ function fileDropPlacement(
33
+ index: number,
34
+ next: { start: number; track: number },
35
+ dropped: TimelineDropPlacement | undefined,
36
+ landedTrack: number | undefined,
37
+ ): TimelineDropPlacement {
38
+ return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
39
+ }
26
40
 
27
41
  interface UseTimelineAssetDropOpsOptions {
28
42
  projectIdRef: MutableRefObject<string | null>;
@@ -52,16 +66,16 @@ export function useTimelineAssetDropOps({
52
66
  observeProjectFileVersion,
53
67
  }: UseTimelineAssetDropOpsOptions) {
54
68
  // fallow-ignore-next-line complexity
55
- const handleTimelineAssetDrop = useCallback(
69
+ const dropAssetAt = useCallback(
56
70
  // fallow-ignore-next-line complexity
57
71
  async (
58
72
  assetPath: string,
59
- placement: Pick<TimelineElement, "start" | "track">,
73
+ placement: TimelineDropPlacement,
60
74
  durationOverride?: number,
61
- ) => {
75
+ ): Promise<number | undefined> => {
62
76
  if (isRecordingRef?.current) {
63
77
  showToast("Cannot edit timeline while recording", "error");
64
- return;
78
+ return undefined;
65
79
  }
66
80
  const pid = projectIdRef.current;
67
81
  if (!pid) throw new Error("No active project");
@@ -69,7 +83,7 @@ export function useTimelineAssetDropOps({
69
83
  const kind = getTimelineAssetKind(assetPath);
70
84
  if (!kind) {
71
85
  showToast("Only image, video, and audio assets can be dropped onto the timeline.");
72
- return;
86
+ return undefined;
73
87
  }
74
88
 
75
89
  const targetPath = activeCompPath || "index.html";
@@ -91,9 +105,24 @@ export function useTimelineAssetDropOps({
91
105
  );
92
106
  const newElementZIndex = Math.max(1, relevantElements.length + 1);
93
107
 
108
+ const { source: sourceWithRoom, track } = resolveDropTrack({
109
+ source: originalContent,
110
+ // insertRow counts the rows the timeline shows, so plan against those.
111
+ elements: selectTimelineRowElements(
112
+ relevantElements,
113
+ usePlayerStore.getState().topLevelIds,
114
+ ),
115
+ placement,
116
+ dropped: {
117
+ id: newId,
118
+ tag: kind === "image" ? "img" : kind,
119
+ start: normalizedStart,
120
+ duration: normalizedDuration,
121
+ },
122
+ });
94
123
  const patchedContent = extendRootDurationInSource(
95
124
  insertTimelineAssetIntoSource(
96
- originalContent,
125
+ sourceWithRoom,
97
126
  buildTimelineAssetInsertHtml({
98
127
  id: newId,
99
128
  hfId: `hf-${generateId()}`,
@@ -101,7 +130,7 @@ export function useTimelineAssetDropOps({
101
130
  kind,
102
131
  start: normalizedStart,
103
132
  duration: normalizedDuration,
104
- track: placement.track,
133
+ track,
105
134
  zIndex: newElementZIndex,
106
135
  geometry: fitTimelineAssetGeometry(
107
136
  null,
@@ -125,10 +154,12 @@ export function useTimelineAssetDropOps({
125
154
  selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
126
155
  forceReloadSdkSession?.();
127
156
  reloadPreview();
157
+ return track;
128
158
  } catch (error) {
129
159
  const message =
130
160
  error instanceof Error ? error.message : "Failed to drop asset onto timeline";
131
161
  showToast(message);
162
+ return undefined;
132
163
  }
133
164
  },
134
165
  [
@@ -144,10 +175,17 @@ export function useTimelineAssetDropOps({
144
175
  ],
145
176
  );
146
177
 
178
+ const handleTimelineAssetDrop = useCallback(
179
+ async (assetPath: string, placement: TimelineDropPlacement, durationOverride?: number) => {
180
+ await dropAssetAt(assetPath, placement, durationOverride);
181
+ },
182
+ [dropAssetAt],
183
+ );
184
+
147
185
  // fallow-ignore-next-line complexity
148
186
  const handleTimelineFileDrop = useCallback(
149
187
  // fallow-ignore-next-line complexity
150
- async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
188
+ async (files: File[], placement?: TimelineDropPlacement) => {
151
189
  if (isRecordingRef?.current) {
152
190
  showToast("Cannot edit timeline while recording", "error");
153
191
  return;
@@ -166,15 +204,18 @@ export function useTimelineAssetDropOps({
166
204
  placement ?? { start: 0, track: 0 },
167
205
  durations,
168
206
  );
207
+ let landedTrack: number | undefined;
169
208
  for (const [index, assetPath] of uploaded.entries()) {
170
- await handleTimelineAssetDrop(
209
+ const next = placements[index] ?? placements[0];
210
+ const track = await dropAssetAt(
171
211
  assetPath,
172
- placements[index] ?? placements[0],
212
+ fileDropPlacement(index, next, placement, landedTrack),
173
213
  durations[index],
174
214
  );
215
+ if (index === 0) landedTrack = track;
175
216
  }
176
217
  },
177
- [handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
218
+ [dropAssetAt, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
178
219
  );
179
220
 
180
221
  const handleTimelineCompositionDrop = useCallback(
@@ -86,7 +86,6 @@ function timelineElement(input: {
86
86
  parentCompositionId: null,
87
87
  compositionAncestors: ["root"],
88
88
  sourceFile: input.sourceFile ?? "index.html",
89
- timingSource: "authored",
90
89
  };
91
90
  }
92
91
 
package/src/main.tsx CHANGED
@@ -1,7 +1,9 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
+ import { IconContext } from "@phosphor-icons/react";
3
4
  import { StudioApp } from "./App";
4
5
  import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
6
+ import { readIconTokens } from "./styles/iconTokens";
5
7
  import { trackStudioEvent } from "./utils/studioTelemetry";
6
8
  import "./styles/studio.css";
7
9
 
@@ -93,8 +95,12 @@ window.addEventListener("unhandledrejection", (event) => {
93
95
 
94
96
  createRoot(document.getElementById("root")!).render(
95
97
  <StrictMode>
96
- <StudioErrorBoundary>
97
- <StudioApp />
98
- </StudioErrorBoundary>
98
+ {/* One icon size and weight for the whole app, taken from the theme file.
99
+ Icons that pass their own size or weight still win. */}
100
+ <IconContext.Provider value={readIconTokens()}>
101
+ <StudioErrorBoundary>
102
+ <StudioApp />
103
+ </StudioErrorBoundary>
104
+ </IconContext.Provider>
99
105
  </StrictMode>,
100
106
  );
@@ -191,7 +191,7 @@ export const AudioWaveform = memo(function AudioWaveform({
191
191
  "repeating-linear-gradient(90deg, rgba(75,163,210,0.35) 0 2px, transparent 2px 5px)",
192
192
  }}
193
193
  />
194
- <span className="relative rounded bg-black/50 px-1 text-[8px] text-neutral-500">
194
+ <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
195
195
  waveform unavailable
196
196
  </span>
197
197
  </div>
@@ -41,11 +41,11 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
41
41
  return createPortal(
42
42
  <div
43
43
  ref={menuRef}
44
- // z-[200] for the same reason the timeline's FX popover uses it: this is
44
+ // z-200 for the same reason the timeline's FX popover uses it: this is
45
45
  // portaled to `document.body`, but the ruler's sticky header sits at z-70
46
46
  // in the SAME root stacking context, so a z-50 menu opened near the top of
47
47
  // the timeline is painted through by the ruler and the playhead.
48
- className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
48
+ className="hf-automation-menu fixed z-200 min-w-[140px] rounded-sm border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
49
49
  style={{ left: adjustedX, top: adjustedY }}
50
50
  >
51
51
  {AUTOMATION_SHAPES.map((shape) => (
@@ -1,6 +1,6 @@
1
1
  import { memo } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import type { TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
6
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
@@ -13,8 +13,14 @@ interface ClipContextMenuProps {
13
13
  onClose: () => void;
14
14
  onSplit: (element: TimelineElement, splitTime: number) => void;
15
15
  onDelete: (element: TimelineElement) => void;
16
+ onCopy?: () => boolean;
17
+ onPaste?: () => Promise<void>;
18
+ onDuplicate?: () => Promise<boolean>;
19
+ canPaste?: boolean;
16
20
  }
17
21
 
22
+ // A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
23
+ // fallow-ignore-next-line complexity
18
24
  export const ClipContextMenu = memo(function ClipContextMenu({
19
25
  x,
20
26
  y,
@@ -23,15 +29,22 @@ export const ClipContextMenu = memo(function ClipContextMenu({
23
29
  onClose,
24
30
  onSplit,
25
31
  onDelete,
32
+ onCopy,
33
+ onPaste,
34
+ onDuplicate,
35
+ canPaste,
26
36
  }: ClipContextMenuProps) {
27
37
  const menuRef = useContextMenuDismiss(onClose);
28
38
  useMenuKeyboardNav(menuRef);
29
-
30
- const menuWidth = 200;
31
- const menuHeight = 80;
32
- const overflowY = y + menuHeight - window.innerHeight;
33
- const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
34
- const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
39
+ // The right-clicked clip's own id: a member of the live multi-selection
40
+ // means Copy/Duplicate act on the whole group, matching onContextMenuClip's
41
+ // selection-preserving behaviour for a right-click inside it.
42
+ const selectionSize = usePlayerStore((s) => {
43
+ const id = element.key ?? element.id;
44
+ return s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)
45
+ ? s.selectedElementIds.size
46
+ : 1;
47
+ });
35
48
 
36
49
  const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
37
50
  const canSplit =
@@ -43,12 +56,29 @@ export const ClipContextMenu = memo(function ClipContextMenu({
43
56
  ? `Split at ${currentTime.toFixed(2)}s`
44
57
  : "Split (move playhead inside clip)";
45
58
 
59
+ const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
60
+ const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
61
+ const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
62
+ const menuWidth = 200;
63
+ const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
64
+ const overflowY = y + menuHeight - window.innerHeight;
65
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
66
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
67
+
68
+ // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
69
+ const itemClass = (enabled: boolean) =>
70
+ `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
71
+ enabled
72
+ ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
73
+ : " text-neutral-600 cursor-not-allowed"
74
+ }`;
75
+
46
76
  return createPortal(
47
77
  <div
48
78
  ref={menuRef}
49
79
  role="menu"
50
80
  aria-label="Clip actions"
51
- className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
81
+ className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
52
82
  style={{ left: adjustedX, top: adjustedY }}
53
83
  >
54
84
  {splitLabel && (
@@ -56,7 +86,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
56
86
  <button
57
87
  type="button"
58
88
  role="menuitem"
59
- className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
89
+ className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-hidden focus-visible:bg-neutral-800 ${
60
90
  canSplit
61
91
  ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
62
92
  : "text-neutral-600 cursor-not-allowed"
@@ -76,10 +106,60 @@ export const ClipContextMenu = memo(function ClipContextMenu({
76
106
  </>
77
107
  )}
78
108
 
109
+ {(onCopy || onPaste || onDuplicate) && (
110
+ <>
111
+ {onCopy && (
112
+ <button
113
+ type="button"
114
+ role="menuitem"
115
+ className={itemClass(true)}
116
+ onClick={() => {
117
+ onCopy();
118
+ onClose();
119
+ }}
120
+ >
121
+ <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
122
+ <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
123
+ </button>
124
+ )}
125
+ {onPaste && (
126
+ <button
127
+ type="button"
128
+ role="menuitem"
129
+ className={itemClass(!!canPaste)}
130
+ disabled={!canPaste}
131
+ onClick={() => {
132
+ if (!canPaste) return;
133
+ void onPaste();
134
+ onClose();
135
+ }}
136
+ >
137
+ <span>Paste</span>
138
+ <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
139
+ </button>
140
+ )}
141
+ {onDuplicate && (
142
+ <button
143
+ type="button"
144
+ role="menuitem"
145
+ className={itemClass(true)}
146
+ onClick={() => {
147
+ void onDuplicate();
148
+ onClose();
149
+ }}
150
+ >
151
+ <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
152
+ <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
153
+ </button>
154
+ )}
155
+ <div className="my-1 border-t border-neutral-700/60" />
156
+ </>
157
+ )}
158
+
79
159
  <button
80
160
  type="button"
81
161
  role="menuitem"
82
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left"
162
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
83
163
  onClick={() => {
84
164
  onDelete(element);
85
165
  onClose();
@@ -87,9 +87,6 @@ describe("buildCompositionThumbnailUrl", () => {
87
87
 
88
88
  expect(buildCompositionThumbnailUrl(base)).not.toContain("output=");
89
89
  expect(buildCompositionThumbnailUrl({ ...base, output: "source" })).toContain("output=source");
90
- expect(buildCompositionThumbnailUrl({ ...base, output: "storyboard" })).toContain(
91
- "output=storyboard",
92
- );
93
90
  });
94
91
 
95
92
  it("includes the persisted content revision in the cache identity", () => {
@@ -44,10 +44,9 @@ export function buildCompositionThumbnailUrl({
44
44
  /**
45
45
  * Capture density. Omitted, the route bounds the image to its preview cap —
46
46
  * right for the timeline, where thumbnails are small and numerous and their
47
- * decoded bytes are budgeted. `"storyboard"` caps the longest side at a
48
- * high-density review size; `"source"` uses the composition's own dimensions.
47
+ * decoded bytes are budgeted. `"source"` uses the composition's own dimensions.
49
48
  */
50
- output?: "source" | "storyboard";
49
+ output?: "source";
51
50
  contentRevision?: number;
52
51
  }): string {
53
52
  const thumbnailBase = previewUrl
@@ -159,7 +158,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
159
158
  {Array.from({ length: frameCount }, (_, index) => (
160
159
  <div
161
160
  key={index}
162
- className="relative h-full flex-shrink-0 overflow-hidden"
161
+ className="relative h-full shrink-0 overflow-hidden"
163
162
  style={{ width: frameW }}
164
163
  >
165
164
  <img
@@ -168,7 +168,7 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
168
168
  }}
169
169
  placeholder="What should change?"
170
170
  rows={2}
171
- className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
171
+ className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-hidden focus:border-studio-accent/40 transition-colors"
172
172
  />
173
173
  </div>
174
174
 
@@ -73,7 +73,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({
73
73
  {Array.from({ length: frameCount }, (_, index) => (
74
74
  <div
75
75
  key={index}
76
- className="relative h-full flex-shrink-0 overflow-hidden bg-neutral-900"
76
+ className="relative h-full shrink-0 overflow-hidden bg-neutral-900"
77
77
  style={{ width: frameW }}
78
78
  >
79
79
  <img
@@ -40,9 +40,9 @@ interface KeyframeDiamondContextMenuProps {
40
40
  }
41
41
 
42
42
  const ITEM_CLS =
43
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
43
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
44
44
  const DESTRUCTIVE_ITEM_CLS =
45
- "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
45
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left";
46
46
 
47
47
  export function KeyframeDiamondContextMenu({
48
48
  state,
@@ -96,7 +96,7 @@ export function KeyframeDiamondContextMenu({
96
96
  ref={menuRef}
97
97
  role="menu"
98
98
  aria-label="Keyframe actions"
99
- className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
99
+ className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
100
100
  style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
101
101
  >
102
102
  {onMoveToPlayhead && (
@@ -172,7 +172,7 @@ export function KeyframeDiamondContextMenu({
172
172
  <button
173
173
  type="button"
174
174
  role="menuitem"
175
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-none cursor-pointer text-left"
175
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-hidden cursor-pointer text-left"
176
176
  onClick={() => {
177
177
  onDeleteAll(state.element, state.animationId);
178
178
  onClose();