@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.
- package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-tnfCiVdR.js} +1 -1
- package/dist/assets/{index-BiEtDkae.js → index-Ca6LZlVb.js} +1 -1
- package/dist/assets/index-Cre9X4RS.css +1 -0
- package/dist/assets/{index-BPJRf2X9.js → index-DEDhPmPD.js} +215 -215
- package/dist/assets/{index-DuCZ3pQw.js → index-zpe332hb.js} +1 -1
- package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
- package/dist/chunk-EEXSYY5F.js.map +1 -0
- package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
- package/dist/chunk-NCUBAVGK.js.map +1 -0
- package/dist/{chunk-HIGHVWYD.js → chunk-QADHJFDF.js} +2 -2
- package/dist/index.css +6 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +181 -168
- package/dist/index.html +2 -2
- package/dist/index.js +1018 -919
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/ui.js +2 -2
- package/package.json +8 -8
- package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
- package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
- package/src/captions/components/CaptionTimeline.tsx +3 -3
- package/src/components/AskAgentModal.tsx +1 -1
- package/src/components/LintModal.tsx +35 -35
- package/src/components/StudioErrorBoundary.tsx +1 -1
- package/src/components/StudioHeader.dom.test.tsx +4 -4
- package/src/components/StudioHeader.tsx +2 -1
- package/src/components/StudioToast.tsx +7 -16
- package/src/components/TimelineToolPicker.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +11 -11
- package/src/components/dock/Dock.tsx +1 -1
- package/src/components/dock/dock.css +9 -1
- package/src/components/editor/AnimationCard.tsx +5 -5
- package/src/components/editor/AnimationCardParts.tsx +1 -1
- package/src/components/editor/BlockParamsPanel.tsx +3 -3
- package/src/components/editor/BorderRadiusEditor.tsx +8 -15
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ComputedTweenNotice.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +5 -6
- package/src/components/editor/DomEditRotateHandle.tsx +1 -1
- package/src/components/editor/EaseCurveSection.tsx +14 -19
- package/src/components/editor/EaseModeControls.tsx +2 -2
- package/src/components/editor/EaseParamFields.tsx +8 -8
- package/src/components/editor/FileTree.tsx +1 -1
- package/src/components/editor/FileTreeIcons.tsx +47 -26
- package/src/components/editor/FileTreeNodes.tsx +11 -8
- package/src/components/editor/GestureRecordControl.tsx +3 -3
- package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
- package/src/components/editor/GsapAnimationSection.tsx +2 -2
- package/src/components/editor/InlineTextToolbar.tsx +4 -4
- package/src/components/editor/InspectorHeaderActions.tsx +1 -1
- package/src/components/editor/KeyframeDiamond.tsx +6 -4
- package/src/components/editor/KeyframeNavigation.tsx +4 -4
- package/src/components/editor/LayersPanel.tsx +3 -3
- package/src/components/editor/PromotableControl.tsx +2 -2
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
- package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
- package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
- package/src/components/editor/SnapToolbar.tsx +5 -5
- package/src/components/editor/TimelineFxPopover.tsx +4 -4
- package/src/components/editor/Transform3DCube.tsx +1 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
- package/src/components/editor/domEditOverlayGestures.ts +13 -19
- package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
- package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
- package/src/components/editor/easeCurveSvg.tsx +1 -1
- package/src/components/editor/manualEdits.test.ts +3 -9
- package/src/components/editor/manualEdits.ts +0 -1
- package/src/components/editor/manualEditsDom.ts +3 -13
- package/src/components/editor/manualEditsDomPatches.test.ts +3 -131
- package/src/components/editor/manualEditsDomPatches.ts +1 -53
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualEditsTypes.ts +2 -0
- package/src/components/editor/manualOffsetDrag.test.ts +41 -0
- package/src/components/editor/manualOffsetDrag.ts +6 -27
- package/src/components/editor/persistSeam.integration.test.ts +0 -18
- package/src/components/editor/plainTranslate.ts +42 -6
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
- package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
- package/src/components/editor/propertyPanelFill.tsx +6 -6
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +2 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +2 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
- package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
- package/src/components/editor/propertyPanelValueTier.ts +1 -1
- package/src/components/editor/rotateInlineSave.test.ts +111 -0
- package/src/components/editor/rotationDraft.test.ts +111 -0
- package/src/components/editor/rotationDraft.ts +145 -0
- package/src/components/editor/useDomEditNudge.test.tsx +33 -0
- package/src/components/editor/useDomEditNudge.ts +6 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
- package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
- package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
- package/src/components/nle/AudioMeterStrip.tsx +2 -2
- package/src/components/nle/NLEPreview.test.ts +8 -4
- package/src/components/nle/PreviewOverlays.tsx +1 -1
- package/src/components/nle/PreviewPane.tsx +3 -3
- package/src/components/nle/PreviewPoster.tsx +29 -1
- package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
- package/src/components/panels/SlideshowPanel.tsx +4 -4
- package/src/components/panels/SlideshowSubPanels.tsx +19 -21
- package/src/components/panels/VariablesBindElement.tsx +1 -1
- package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +4 -6
- package/src/components/panels/VariablesRowAction.tsx +1 -1
- package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
- package/src/components/renders/RenderQueue.tsx +2 -2
- package/src/components/renders/RenderQueueItem.tsx +3 -3
- package/src/components/sidebar/AssetCard.tsx +3 -3
- package/src/components/sidebar/AssetContextMenu.tsx +7 -7
- package/src/components/sidebar/AssetsTab.tsx +6 -6
- package/src/components/sidebar/AudioRow.tsx +5 -3
- package/src/components/sidebar/BlocksTab.tsx +6 -6
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
- package/src/components/sidebar/CompositionsTab.tsx +65 -12
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/sidebar/SidebarLintButton.tsx +3 -3
- package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
- package/src/components/timelineToolbarStyles.ts +3 -3
- package/src/components/ui/Button.tsx +4 -4
- package/src/components/ui/HyperframesLogo.tsx +19 -18
- package/src/components/ui/Input.tsx +1 -1
- package/src/components/ui/Menu.tsx +4 -2
- package/src/hooks/draggedGsapPosition.ts +28 -0
- package/src/hooks/elementOffsetStager.ts +34 -13
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
- package/src/hooks/plainRotation.test.ts +122 -0
- package/src/hooks/plainRotation.ts +42 -0
- package/src/hooks/useClipboard.source.test.ts +42 -4
- package/src/hooks/useClipboard.ts +13 -3
- package/src/hooks/useDomEditCommits.ts +0 -3
- package/src/hooks/useDomEditSession.ts +5 -5
- package/src/hooks/useDomGeometryCommit.test.tsx +79 -1
- package/src/hooks/useDomGeometryCommit.ts +16 -17
- package/src/hooks/useDomGeometryCommits.test.tsx +233 -33
- package/src/hooks/useDomGeometryCommits.ts +27 -80
- package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
- package/src/hooks/useGsapAwareEditing.ts +43 -42
- package/src/hooks/useInlineTextEdit.test.tsx +33 -3
- package/src/hooks/useInlineTextEdit.ts +2 -2
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/CompositionThumbnail.tsx +23 -11
- package/src/player/components/EditModal.tsx +6 -6
- package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +3 -3
- package/src/player/components/ShortcutsPanel.tsx +8 -20
- package/src/player/components/TimelineCanvas.tsx +1 -1
- package/src/player/components/TimelineClip.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +2 -2
- package/src/player/components/TimelineLanes.tsx +0 -1
- package/src/player/components/TrackClipCount.tsx +1 -1
- package/src/player/components/VolumeControl.tsx +1 -1
- package/src/player/components/timelineClipChildren.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +6 -22
- package/src/player/components/trackHeaderLabelRows.tsx +16 -16
- package/src/player/lib/thumbnailScheduler.ts +4 -5
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/styles/hex-baseline.json +7 -19
- package/src/styles/studio.css +0 -9
- package/src/styles/tailwind-preset.shared.js +2 -1
- package/src/styles/theme.css +204 -147
- package/src/styles/theme.test.ts +13 -9
- package/src/utils/blockCategories.ts +41 -9
- package/src/utils/clipboardPayload.ts +2 -0
- package/dist/assets/index-CQN3M56N.css +0 -1
- package/dist/chunk-62DDWOSH.js.map +0 -1
- package/dist/chunk-6GAO75IU.js.map +0 -1
- package/src/hooks/gsapTargetCache.ts +0 -62
- /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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
219
|
-
<span className="text-[10px] text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
427
|
-
: "bg-white text-black hover:bg-
|
|
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-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 {
|
|
5
|
+
import { mountCompositionsTab } from "./compositionsTabTestUtils";
|
|
9
6
|
|
|
10
|
-
|
|
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
|
+
});
|