@hyperframes/studio 0.8.103 → 0.8.105

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 (200) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-tnfCiVdR.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-Ca6LZlVb.js} +1 -1
  3. package/dist/assets/index-Cre9X4RS.css +1 -0
  4. package/dist/assets/{index-BPJRf2X9.js → index-DEDhPmPD.js} +215 -215
  5. package/dist/assets/{index-DuCZ3pQw.js → index-zpe332hb.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  9. package/dist/chunk-NCUBAVGK.js.map +1 -0
  10. package/dist/{chunk-HIGHVWYD.js → chunk-QADHJFDF.js} +2 -2
  11. package/dist/index.css +6 -1
  12. package/dist/index.css.map +1 -1
  13. package/dist/index.d.ts +181 -168
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +1018 -919
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles/tailwind-preset.js.map +1 -1
  19. package/dist/styles.css +1 -1
  20. package/dist/ui.js +2 -2
  21. package/package.json +8 -8
  22. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  23. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  24. package/src/captions/components/CaptionTimeline.tsx +3 -3
  25. package/src/components/AskAgentModal.tsx +1 -1
  26. package/src/components/LintModal.tsx +35 -35
  27. package/src/components/StudioErrorBoundary.tsx +1 -1
  28. package/src/components/StudioHeader.dom.test.tsx +4 -4
  29. package/src/components/StudioHeader.tsx +2 -1
  30. package/src/components/StudioToast.tsx +7 -16
  31. package/src/components/TimelineToolPicker.tsx +1 -1
  32. package/src/components/TimelineToolbar.tsx +11 -11
  33. package/src/components/dock/Dock.tsx +1 -1
  34. package/src/components/dock/dock.css +9 -1
  35. package/src/components/editor/AnimationCard.tsx +5 -5
  36. package/src/components/editor/AnimationCardParts.tsx +1 -1
  37. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  38. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  39. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  40. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  41. package/src/components/editor/DomEditOverlay.tsx +5 -6
  42. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  43. package/src/components/editor/EaseCurveSection.tsx +14 -19
  44. package/src/components/editor/EaseModeControls.tsx +2 -2
  45. package/src/components/editor/EaseParamFields.tsx +8 -8
  46. package/src/components/editor/FileTree.tsx +1 -1
  47. package/src/components/editor/FileTreeIcons.tsx +47 -26
  48. package/src/components/editor/FileTreeNodes.tsx +11 -8
  49. package/src/components/editor/GestureRecordControl.tsx +3 -3
  50. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  51. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  52. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  53. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  54. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  55. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  56. package/src/components/editor/LayersPanel.tsx +3 -3
  57. package/src/components/editor/PromotableControl.tsx +2 -2
  58. package/src/components/editor/PropertyPanel.tsx +3 -3
  59. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  60. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  61. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  62. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  63. package/src/components/editor/SnapToolbar.tsx +5 -5
  64. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  65. package/src/components/editor/Transform3DCube.tsx +1 -1
  66. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  67. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  68. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  69. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  70. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  71. package/src/components/editor/easeCurveSvg.tsx +1 -1
  72. package/src/components/editor/manualEdits.test.ts +3 -9
  73. package/src/components/editor/manualEdits.ts +0 -1
  74. package/src/components/editor/manualEditsDom.ts +3 -13
  75. package/src/components/editor/manualEditsDomPatches.test.ts +3 -131
  76. package/src/components/editor/manualEditsDomPatches.ts +1 -53
  77. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  78. package/src/components/editor/manualEditsTypes.ts +2 -0
  79. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  80. package/src/components/editor/manualOffsetDrag.ts +6 -27
  81. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  82. package/src/components/editor/plainTranslate.ts +42 -6
  83. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  84. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  85. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  86. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  87. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  88. package/src/components/editor/propertyPanelFill.tsx +6 -6
  89. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  90. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  92. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  93. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  94. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  97. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  98. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  99. package/src/components/editor/propertyPanelFont.tsx +2 -2
  100. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  101. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  102. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  103. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  104. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  105. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  106. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  107. package/src/components/editor/propertyPanelSections.tsx +2 -2
  108. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  109. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  110. package/src/components/editor/propertyPanelTypes.ts +1 -0
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  114. package/src/components/editor/rotationDraft.test.ts +111 -0
  115. package/src/components/editor/rotationDraft.ts +145 -0
  116. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  117. package/src/components/editor/useDomEditNudge.ts +6 -2
  118. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  119. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  120. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  121. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  122. package/src/components/nle/NLEPreview.test.ts +8 -4
  123. package/src/components/nle/PreviewOverlays.tsx +1 -1
  124. package/src/components/nle/PreviewPane.tsx +3 -3
  125. package/src/components/nle/PreviewPoster.tsx +29 -1
  126. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  127. package/src/components/panels/SlideshowPanel.tsx +4 -4
  128. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  129. package/src/components/panels/VariablesBindElement.tsx +1 -1
  130. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  131. package/src/components/panels/VariablesPanel.tsx +4 -6
  132. package/src/components/panels/VariablesRowAction.tsx +1 -1
  133. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  134. package/src/components/renders/RenderQueue.tsx +2 -2
  135. package/src/components/renders/RenderQueueItem.tsx +3 -3
  136. package/src/components/sidebar/AssetCard.tsx +3 -3
  137. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  138. package/src/components/sidebar/AssetsTab.tsx +6 -6
  139. package/src/components/sidebar/AudioRow.tsx +5 -3
  140. package/src/components/sidebar/BlocksTab.tsx +6 -6
  141. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  142. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  143. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  144. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  145. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  146. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  147. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  148. package/src/components/timelineToolbarStyles.ts +3 -3
  149. package/src/components/ui/Button.tsx +4 -4
  150. package/src/components/ui/HyperframesLogo.tsx +19 -18
  151. package/src/components/ui/Input.tsx +1 -1
  152. package/src/components/ui/Menu.tsx +4 -2
  153. package/src/hooks/draggedGsapPosition.ts +28 -0
  154. package/src/hooks/elementOffsetStager.ts +34 -13
  155. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  156. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  157. package/src/hooks/plainRotation.test.ts +122 -0
  158. package/src/hooks/plainRotation.ts +42 -0
  159. package/src/hooks/useClipboard.source.test.ts +42 -4
  160. package/src/hooks/useClipboard.ts +13 -3
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.ts +5 -5
  163. package/src/hooks/useDomGeometryCommit.test.tsx +79 -1
  164. package/src/hooks/useDomGeometryCommit.ts +16 -17
  165. package/src/hooks/useDomGeometryCommits.test.tsx +233 -33
  166. package/src/hooks/useDomGeometryCommits.ts +27 -80
  167. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  168. package/src/hooks/useGsapAwareEditing.ts +43 -42
  169. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  170. package/src/hooks/useInlineTextEdit.ts +2 -2
  171. package/src/player/components/ClipContextMenu.tsx +1 -1
  172. package/src/player/components/CompositionThumbnail.tsx +23 -11
  173. package/src/player/components/EditModal.tsx +6 -6
  174. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  175. package/src/player/components/PlayerControls.tsx +3 -3
  176. package/src/player/components/ShortcutsPanel.tsx +8 -20
  177. package/src/player/components/TimelineCanvas.tsx +1 -1
  178. package/src/player/components/TimelineClip.tsx +2 -2
  179. package/src/player/components/TimelineEmptyState.tsx +2 -2
  180. package/src/player/components/TimelineLanes.tsx +0 -1
  181. package/src/player/components/TrackClipCount.tsx +1 -1
  182. package/src/player/components/VolumeControl.tsx +1 -1
  183. package/src/player/components/timelineClipChildren.tsx +1 -1
  184. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  185. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  186. package/src/player/lib/thumbnailScheduler.ts +4 -5
  187. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  188. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  189. package/src/styles/hex-baseline.json +7 -19
  190. package/src/styles/studio.css +0 -9
  191. package/src/styles/tailwind-preset.shared.js +2 -1
  192. package/src/styles/theme.css +204 -147
  193. package/src/styles/theme.test.ts +13 -9
  194. package/src/utils/blockCategories.ts +41 -9
  195. package/src/utils/clipboardPayload.ts +2 -0
  196. package/dist/assets/index-CQN3M56N.css +0 -1
  197. package/dist/chunk-62DDWOSH.js.map +0 -1
  198. package/dist/chunk-6GAO75IU.js.map +0 -1
  199. package/src/hooks/gsapTargetCache.ts +0 -62
  200. /package/dist/{chunk-HIGHVWYD.js.map → chunk-QADHJFDF.js.map} +0 -0
@@ -147,7 +147,7 @@ export function ContextMenu({
147
147
  <button
148
148
  role="menuitem"
149
149
  onClick={() => setMode("confirm-delete")}
150
- className={`${itemCls} text-red-400`}
150
+ className={`${itemCls} text-danger-ink`}
151
151
  >
152
152
  Delete
153
153
  </button>
@@ -181,9 +181,9 @@ export function ContextMenu({
181
181
  }
182
182
  }}
183
183
  aria-label={`Rename ${filename(asset)}`}
184
- className="w-full bg-neutral-800 border border-neutral-600 rounded-sm px-1.5 py-1 text-[11px] text-white focus:border-studio-accent/60 focus:outline-hidden"
184
+ className="w-full bg-neutral-800 border border-neutral-600 rounded-sm px-1.5 py-1 text-[11px] text-text-0 focus:border-studio-accent/60 focus:outline-hidden"
185
185
  />
186
- {renameError && <span className="text-[10px] text-red-400">{renameError}</span>}
186
+ {renameError && <span className="text-[10px] text-danger-ink">{renameError}</span>}
187
187
  <div className="flex items-center justify-end gap-1">
188
188
  <button
189
189
  onClick={() => setMode("menu")}
@@ -193,7 +193,7 @@ export function ContextMenu({
193
193
  </button>
194
194
  <button
195
195
  onClick={commitRename}
196
- className="px-2 py-0.5 text-[10px] rounded-sm bg-studio-accent/80 hover:bg-studio-accent text-white transition-colors"
196
+ className="px-2 py-0.5 text-[10px] rounded-sm bg-accent hover:bg-accent-hover text-on-accent transition-colors"
197
197
  >
198
198
  Rename
199
199
  </button>
@@ -215,12 +215,12 @@ function DeleteConfirm({
215
215
  onCancel: () => void;
216
216
  }) {
217
217
  return (
218
- <div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
219
- <span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
218
+ <div className="px-2 py-1.5 bg-danger/15 border-l-2 border-red-500 flex items-center justify-between gap-2">
219
+ <span className="text-[10px] text-danger-ink truncate">Delete {name}?</span>
220
220
  <div className="flex items-center gap-1 shrink-0">
221
221
  <button
222
222
  onClick={onConfirm}
223
- className="px-2 py-0.5 text-[10px] rounded-sm bg-red-600 text-white hover:bg-red-500 active:bg-red-700 transition-colors"
223
+ className="px-2 py-0.5 text-[10px] rounded-sm bg-danger text-on-danger hover:brightness-95 active:brightness-90 transition-colors"
224
224
  >
225
225
  Delete
226
226
  </button>
@@ -177,7 +177,7 @@ function EmptyState({
177
177
  fill="none"
178
178
  stroke="currentColor"
179
179
  strokeWidth="1.5"
180
- className="text-neutral-700"
180
+ className="text-text-off"
181
181
  >
182
182
  <path
183
183
  d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"
@@ -349,7 +349,7 @@ export const AssetsTab = memo(function AssetsTab({
349
349
  onClick={() => setViewMode(m)}
350
350
  className={`flex-1 px-2 py-1 text-[11px] font-medium rounded transition-colors ${
351
351
  viewMode === m
352
- ? "bg-panel-accent/15 text-panel-accent"
352
+ ? "bg-panel-accent/15 text-accent-ink"
353
353
  : "text-panel-text-3 hover:text-panel-text-1"
354
354
  }`}
355
355
  >
@@ -396,7 +396,7 @@ export const AssetsTab = memo(function AssetsTab({
396
396
  aria-pressed={activeFilter === "all"}
397
397
  className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors active:scale-[0.98] ${
398
398
  activeFilter === "all"
399
- ? "bg-panel-accent/15 text-panel-accent"
399
+ ? "bg-panel-accent/15 text-accent-ink"
400
400
  : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
401
401
  }`}
402
402
  >
@@ -410,7 +410,7 @@ export const AssetsTab = memo(function AssetsTab({
410
410
  aria-pressed={activeFilter === cat}
411
411
  className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors active:scale-[0.98] ${
412
412
  activeFilter === cat
413
- ? "bg-panel-accent/15 text-panel-accent"
413
+ ? "bg-panel-accent/15 text-accent-ink"
414
414
  : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
415
415
  }`}
416
416
  >
@@ -425,7 +425,7 @@ export const AssetsTab = memo(function AssetsTab({
425
425
  onClick={() => setUsageFilter(usageFilter === "used" ? "all" : "used")}
426
426
  className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
427
427
  usageFilter === "used"
428
- ? "bg-panel-accent/15 text-panel-accent"
428
+ ? "bg-panel-accent/15 text-accent-ink"
429
429
  : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
430
430
  }`}
431
431
  >
@@ -435,7 +435,7 @@ export const AssetsTab = memo(function AssetsTab({
435
435
  onClick={() => setUsageFilter(usageFilter === "unused" ? "all" : "unused")}
436
436
  className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
437
437
  usageFilter === "unused"
438
- ? "bg-panel-accent/15 text-panel-accent"
438
+ ? "bg-panel-accent/15 text-accent-ink"
439
439
  : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
440
440
  }`}
441
441
  >
@@ -240,7 +240,7 @@ export function AudioRow({
240
240
  aria-pressed={playing}
241
241
  className={`w-7 h-7 rounded-md shrink-0 flex items-center justify-center transition-colors active:scale-[0.95] ${
242
242
  playing
243
- ? "bg-panel-accent/15 text-panel-accent"
243
+ ? "bg-panel-accent/15 text-accent-ink"
244
244
  : "text-panel-text-5 group-hover:text-panel-text-3"
245
245
  }`}
246
246
  onClick={(e) => {
@@ -273,7 +273,7 @@ export function AudioRow({
273
273
  </span>
274
274
  )}
275
275
  {used && (
276
- <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded-sm shrink-0">
276
+ <span className="text-[9px] font-medium text-accent-ink bg-panel-accent/10 px-1.5 py-px rounded-sm shrink-0">
277
277
  in use
278
278
  </span>
279
279
  )}
@@ -281,7 +281,9 @@ export function AudioRow({
281
281
  <span
282
282
  role="status"
283
283
  className={`shrink-0 text-[9px] font-medium px-1.5 py-px rounded ${
284
- copyFailed ? "text-red-400 bg-red-500/10" : "text-panel-accent bg-panel-accent/10"
284
+ copyFailed
285
+ ? "text-danger-ink bg-red-500/10"
286
+ : "text-accent-ink bg-panel-accent/10"
285
287
  }`}
286
288
  >
287
289
  {copyFailed ? "Copy failed" : "Copied"}
@@ -40,7 +40,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
40
40
 
41
41
  if (error) {
42
42
  return (
43
- <div className="flex flex-1 items-center justify-center text-red-400 text-xs px-4 text-center">
43
+ <div className="flex flex-1 items-center justify-center text-danger-ink text-xs px-4 text-center">
44
44
  {error}
45
45
  </div>
46
46
  );
@@ -77,9 +77,9 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
77
77
  {/* Block grid */}
78
78
  <div className="flex-1 overflow-y-auto min-h-0 px-2 pb-2">
79
79
  {category === "vfx" && (
80
- <div className="mb-2 px-2 py-1.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-[9px] text-purple-300 leading-relaxed">
80
+ <div className="mb-2 px-2 py-1.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-[9px] text-text-2 leading-relaxed">
81
81
  VFX blocks use WebGL via HTML-in-Canvas. Enable{" "}
82
- <span className="font-mono text-purple-200">chrome://flags/#html-in-canvas</span> for
82
+ <span className="font-mono text-text-0">chrome://flags/#html-in-canvas</span> for
83
83
  preview.
84
84
  </div>
85
85
  )}
@@ -423,8 +423,8 @@ function BlockCard({
423
423
  title="Add to composition at current time"
424
424
  className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-semibold transition-colors active:scale-[0.97] ${
425
425
  addState === "failed"
426
- ? "bg-red-500 text-white"
427
- : "bg-white text-black hover:bg-neutral-200"
426
+ ? "bg-danger text-on-danger"
427
+ : "bg-white text-black hover:bg-white/85"
428
428
  }`}
429
429
  >
430
430
  <svg
@@ -453,7 +453,7 @@ function BlockCard({
453
453
  className={`flex items-center gap-1.5 px-3 ${onAdd ? "py-1" : "py-1.5"} rounded-md transition-colors active:scale-[0.97] ${
454
454
  onAdd
455
455
  ? "bg-white/15 text-white/90 hover:bg-white/25 text-[9px]"
456
- : "bg-white text-black hover:bg-neutral-200 text-[10px] font-semibold"
456
+ : "bg-text-0 text-bg-0 hover:bg-text-2 text-[10px] font-semibold"
457
457
  }`}
458
458
  >
459
459
  <svg
@@ -1,84 +1,18 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { act } from "react";
4
- import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
- import { usePlayerStore } from "../../player/store/playerStore";
4
+ import { describe, expect, it, vi } from "vitest";
7
5
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
8
- import { CompositionsTab } from "./CompositionsTab";
9
-
10
- Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
11
- (
12
- window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
13
- ).happyDOM.settings.disableIframePageLoading = true;
14
-
15
- let root: Root | null = null;
16
-
17
- beforeEach(() => {
18
- usePlayerStore.setState({ previewBooted: true });
19
- });
20
-
21
- afterEach(() => {
22
- if (root) act(() => root?.unmount());
23
- root = null;
24
- document.body.innerHTML = "";
25
- usePlayerStore.setState({ thumbnailRevisions: {} });
26
- });
6
+ import { mountCompositionsTab } from "./compositionsTabTestUtils";
27
7
 
28
8
  function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
29
- const host = document.createElement("div");
30
- document.body.append(host);
31
- root = createRoot(host);
32
- act(() => {
33
- root?.render(
34
- <CompositionsTab
35
- projectId="demo"
36
- compositions={["compositions/headline.html"]}
37
- activeComposition={null}
38
- onSelect={onSelect}
39
- onAddToTimeline={onAddToTimeline}
40
- />,
41
- );
42
- });
9
+ const host = mountCompositionsTab({ onSelect, onAddToTimeline });
43
10
  const card = host.querySelector<HTMLElement>('[draggable="true"]');
44
11
  if (!card) throw new Error("composition card did not render");
45
12
  return { host, card, onSelect, onAddToTimeline };
46
13
  }
47
14
 
48
15
  describe("composition card drag", () => {
49
- it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
50
- const { host } = mount();
51
- const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
52
- expect(thumbnail).not.toBeNull();
53
- expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
54
- expect(host.querySelector("iframe")).toBeNull();
55
- });
56
-
57
- it("shows a fallback when the cached thumbnail fails", () => {
58
- const { host } = mount();
59
- const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
60
- if (!thumbnail) throw new Error("composition thumbnail did not render");
61
-
62
- act(() => thumbnail.dispatchEvent(new Event("error")));
63
-
64
- expect(host.textContent).toContain("Preview unavailable");
65
- expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
66
- });
67
-
68
- it("retries a failed thumbnail at the next persisted content revision", () => {
69
- const { host } = mount();
70
- const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
71
- if (!thumbnail) throw new Error("composition thumbnail did not render");
72
- act(() => thumbnail.dispatchEvent(new Event("error")));
73
- expect(host.textContent).toContain("Preview unavailable");
74
-
75
- act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
76
-
77
- const retry = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
78
- expect(retry).not.toBeNull();
79
- expect(new URL(retry?.src ?? "").searchParams.get("revision")).toBe("1");
80
- });
81
-
82
16
  it("mounts one live preview only after sustained hover and removes it on leave", () => {
83
17
  vi.useFakeTimers();
84
18
  const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -1,57 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { act } from "react";
4
- import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { usePlayerStore } from "../../player/store/playerStore";
3
+ import { describe, expect, it } from "vitest";
7
4
  import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
8
- import { CompositionsTab } from "./CompositionsTab";
5
+ import { mountCompositionsTab } from "./compositionsTabTestUtils";
9
6
 
10
- Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
11
- (
12
- window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
13
- ).happyDOM.settings.disableIframePageLoading = true;
14
-
15
- let root: Root | null = null;
16
-
17
- afterEach(() => {
18
- if (root) act(() => root?.unmount());
19
- root = null;
20
- document.body.innerHTML = "";
21
- usePlayerStore.setState({ thumbnailRevisions: {} });
22
- });
23
-
24
- function mount(compositions: string[], masterCompositionPath: string | null) {
25
- const host = document.createElement("div");
26
- document.body.append(host);
27
- root = createRoot(host);
28
- act(() => {
29
- root?.render(
30
- <CompositionsTab
31
- projectId="demo"
32
- compositions={compositions}
33
- activeComposition={null}
34
- masterCompositionPath={masterCompositionPath}
35
- onSelect={vi.fn()}
36
- />,
37
- );
38
- });
39
- return host;
40
- }
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
- });
7
+ const mount = (compositions: string[], masterCompositionPath: string | null) =>
8
+ mountCompositionsTab({ compositions, masterCompositionPath });
55
9
 
56
10
  describe("CompositionsTab root badge", () => {
57
11
  it("marks the composition matching masterCompositionPath as root", () => {
@@ -0,0 +1,256 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { thumbnailScheduler } from "../../player/lib/thumbnailScheduler";
7
+ import { TIMELINE_VIEWPORT_BUDGETS } from "../../player/lib/timelineViewportBudgets";
8
+ import { CompositionThumbnail } from "../../player/components/CompositionThumbnail";
9
+ import { usePlayerStore } from "../../player/store/playerStore";
10
+ import { renderPosterForNextOpen } from "../nle/PreviewPoster";
11
+ import { mountCompositionsTab } from "./compositionsTabTestUtils";
12
+
13
+ class DecodingImage {
14
+ onload: (() => void) | null = null;
15
+ onerror: (() => void) | null = null;
16
+ naturalWidth = 16;
17
+ naturalHeight = 9;
18
+ set src(value: string) {
19
+ if (value) queueMicrotask(() => this.onload?.());
20
+ }
21
+ }
22
+
23
+ let autoIntersect = true;
24
+ const observed: Array<{
25
+ element: Element;
26
+ callback: IntersectionObserverCallback;
27
+ root?: Element | Document | null;
28
+ }> = [];
29
+ class ViewportObserver {
30
+ constructor(
31
+ private readonly callback: IntersectionObserverCallback,
32
+ private readonly options?: IntersectionObserverInit,
33
+ ) {}
34
+ observe(element: Element) {
35
+ observed.push({ element, callback: this.callback, root: this.options?.root });
36
+ if (autoIntersect) this.enter();
37
+ }
38
+ enter() {
39
+ const entry = { isIntersecting: true } as IntersectionObserverEntry;
40
+ this.callback([entry], this as unknown as IntersectionObserver);
41
+ }
42
+ disconnect() {}
43
+ }
44
+
45
+ interface Render {
46
+ url: URL;
47
+ signal: AbortSignal;
48
+ answer: (status?: number) => void;
49
+ }
50
+
51
+ const real = {
52
+ IntersectionObserver: globalThis.IntersectionObserver,
53
+ fetch: globalThis.fetch,
54
+ Image: globalThis.Image,
55
+ create: URL.createObjectURL,
56
+ revoke: URL.revokeObjectURL,
57
+ };
58
+ let renders: Render[] = [];
59
+ let inFlight = 0;
60
+ let peak = 0;
61
+ let frames = 0;
62
+
63
+ beforeEach(() => {
64
+ renders = [];
65
+ [inFlight, peak, frames] = [0, 0, 0];
66
+ globalThis.fetch = vi.fn(
67
+ (input: RequestInfo | URL, init?: RequestInit) =>
68
+ new Promise<Response>((resolve, reject) => {
69
+ const signal = init?.signal ?? new AbortController().signal;
70
+ peak = Math.max(peak, ++inFlight);
71
+ const end = () => (inFlight -= 1);
72
+ signal.addEventListener("abort", () => {
73
+ end();
74
+ reject(new DOMException("Aborted", "AbortError"));
75
+ });
76
+ const answer = (status = 200) => {
77
+ end();
78
+ resolve(new Response(new Blob(["png"]), { status }));
79
+ };
80
+ renders.push({ url: new URL(String(input), window.location.origin), signal, answer });
81
+ }),
82
+ ) as typeof fetch;
83
+ globalThis.Image = DecodingImage as unknown as typeof Image;
84
+ globalThis.IntersectionObserver = ViewportObserver as unknown as typeof IntersectionObserver;
85
+ [autoIntersect, observed.length] = [true, 0];
86
+ URL.createObjectURL = vi.fn(() => `blob:frame-${++frames}`);
87
+ URL.revokeObjectURL = vi.fn();
88
+ usePlayerStore.setState({ previewBooted: true });
89
+ });
90
+
91
+ afterEach(() => {
92
+ Object.assign(globalThis, {
93
+ fetch: real.fetch,
94
+ Image: real.Image,
95
+ IntersectionObserver: real.IntersectionObserver,
96
+ });
97
+ Object.assign(URL, { createObjectURL: real.create, revokeObjectURL: real.revoke });
98
+ });
99
+
100
+ const mount = (compositions?: string[]) => mountCompositionsTab(compositions && { compositions });
101
+ const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 5)));
102
+ const shown = (host: HTMLElement) => host.querySelector("img")?.getAttribute("src") ?? null;
103
+
104
+ async function nextRevisionShows(host: HTMLElement, revision: string, frame: string) {
105
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
106
+ expect(renders.at(-1)!.url.searchParams.get("revision")).toBe(revision);
107
+ renders.at(-1)!.answer();
108
+ await settle();
109
+ expect(shown(host)).toBe(frame);
110
+ }
111
+
112
+ describe("composition card thumbnails", () => {
113
+ it("render through the thumbnail scheduler once the preview boots, and never point an image at the route", async () => {
114
+ usePlayerStore.getState().reset();
115
+ const host = mount();
116
+ expect(renders).toHaveLength(0);
117
+
118
+ act(() => usePlayerStore.getState().markPreviewBooted());
119
+ expect(renders.map((r) => [r.url.pathname, r.url.searchParams.get("t")])).toEqual([
120
+ ["/api/projects/demo/thumbnail/compositions/headline.html", "3.00"],
121
+ ]);
122
+ renders[0]!.answer();
123
+ await settle();
124
+ expect(shown(host)).toBe("blob:frame-1");
125
+ expect(host.querySelector("iframe")).toBeNull();
126
+
127
+ act(() => usePlayerStore.getState().setTimelineReady(false));
128
+ expect(shown(host)).toBe("blob:frame-1");
129
+ });
130
+
131
+ it("wait until a card is near the viewport, so an offscreen card never renders ahead of a visible one", () => {
132
+ autoIntersect = false;
133
+ mount(["compositions/above.html", "compositions/seen.html", "compositions/below.html"]);
134
+ expect(renders).toHaveLength(0);
135
+
136
+ const seen = observed.find((o) =>
137
+ o.element.closest("[draggable]")?.textContent?.includes("seen"),
138
+ )!;
139
+ act(() =>
140
+ seen.callback(
141
+ [{ isIntersecting: true } as IntersectionObserverEntry],
142
+ {} as IntersectionObserver,
143
+ ),
144
+ );
145
+ expect(renders.map((r) => r.url.pathname)).toEqual([
146
+ "/api/projects/demo/thumbnail/compositions/seen.html",
147
+ ]);
148
+ expect(seen.root).toBe(document.querySelector("[data-composition-list]"));
149
+ });
150
+
151
+ it("keep showing the last frame, leased, until the next revision's frame is ready", async () => {
152
+ const host = mount();
153
+ renders[0]!.answer();
154
+ await settle();
155
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
156
+ expect(shown(host)).toBe("blob:frame-1");
157
+ thumbnailScheduler.invalidateProject("demo");
158
+ expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-1");
159
+
160
+ renders.at(-1)!.answer();
161
+ await settle();
162
+ expect(shown(host)).toBe("blob:frame-2");
163
+ });
164
+
165
+ it("keep the last frame through a new revision even when the scheduler has evicted it", async () => {
166
+ const host = mount();
167
+ renders[0]!.answer();
168
+ await settle();
169
+ const fillers = Array.from({ length: 100 }, (_, i) =>
170
+ thumbnailScheduler.acquire(
171
+ {
172
+ key: `filler-${i}`,
173
+ projectId: "demo",
174
+ sessionEpoch: 0,
175
+ kind: "image",
176
+ priority: "visible",
177
+ load: async () => ({
178
+ value: { kind: "image", url: `blob:filler-${i}`, aspect: 1 },
179
+ weight: 1,
180
+ }),
181
+ },
182
+ () => {},
183
+ ),
184
+ );
185
+ while (
186
+ thumbnailScheduler.getDiagnostics().queued + thumbnailScheduler.getDiagnostics().active >
187
+ 0
188
+ ) {
189
+ await settle();
190
+ }
191
+ for (const filler of fillers) filler.release();
192
+
193
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
194
+ expect(renders.map((r) => r.url.searchParams.get("revision"))).toEqual(["0", "1"]);
195
+ expect(shown(host)).toBe("blob:frame-1");
196
+ expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-1");
197
+ });
198
+
199
+ it("share one render with the timeline's thumbnail of the same composition", () => {
200
+ mount();
201
+ const timeline = createRoot(document.body.appendChild(document.createElement("div")));
202
+ try {
203
+ act(() =>
204
+ timeline.render(
205
+ <CompositionThumbnail
206
+ previewUrl="/api/projects/demo/preview/comp/compositions/headline.html"
207
+ label=""
208
+ labelColor=""
209
+ projectId="demo"
210
+ sessionEpoch={usePlayerStore.getState().timelineSessionEpoch}
211
+ seekTime={3}
212
+ duration={0}
213
+ />,
214
+ ),
215
+ );
216
+ expect(renders).toHaveLength(1);
217
+ } finally {
218
+ act(() => timeline.unmount());
219
+ }
220
+ });
221
+
222
+ it("keep the cards and the poster render to the scheduler's cap, so Studio's own requests get a connection", async () => {
223
+ const cap = TIMELINE_VIEWPORT_BUDGETS.concurrentCompositionFetches;
224
+ mount(Array.from({ length: 6 }, (_, i) => `compositions/scene-${i}.html`));
225
+ renderPosterForNextOpen("demo");
226
+ expect(inFlight).toBe(cap);
227
+
228
+ while (renders.some((r) => !r.signal.aborted)) {
229
+ renders.shift()!.answer();
230
+ await settle();
231
+ }
232
+ expect(renders).toHaveLength(0);
233
+ expect(globalThis.fetch).toHaveBeenCalledTimes(7);
234
+ expect(peak).toBe(cap);
235
+ });
236
+
237
+ it("abort a stale revision's render, and free its frame when the scheduler evicts it", async () => {
238
+ const host = mount();
239
+ const first = renders[0]!;
240
+ await nextRevisionShows(host, "1", "blob:frame-1");
241
+ expect(first.signal.aborted).toBe(true);
242
+ await nextRevisionShows(host, "2", "blob:frame-2");
243
+
244
+ thumbnailScheduler.invalidateProject("demo");
245
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:frame-1");
246
+ expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-2");
247
+ });
248
+
249
+ it("show a fallback when the render fails, and retry it at the next content revision", async () => {
250
+ const host = mount();
251
+ renders[0]!.answer(500);
252
+ await settle();
253
+ expect(host.textContent).toContain("Preview unavailable");
254
+ await nextRevisionShows(host, "1", "blob:frame-1");
255
+ });
256
+ });