@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.
- package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
- package/dist/assets/index-C05SNrvY.js +360 -0
- package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
- package/dist/assets/index-DUR9qzQM.css +1 -0
- package/dist/index.d.ts +29 -16
- package/dist/index.html +2 -2
- package/dist/index.js +7283 -8606
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +11 -12
- package/src/App.tsx +132 -143
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
- package/src/captions/components/CaptionOverlayUtils.ts +2 -3
- package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
- package/src/captions/components/CaptionTimeline.tsx +1 -1
- package/src/captions/components/shared.tsx +2 -2
- package/src/components/AskAgentModal.tsx +5 -5
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +28 -7
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.tsx +2 -62
- package/src/components/StudioLeftSidebar.tsx +7 -4
- package/src/components/StudioOverlays.tsx +2 -6
- package/src/components/StudioRightPanel.tsx +4 -4
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +7 -7
- package/src/components/dock/Dock.test.tsx +192 -0
- package/src/components/dock/Dock.tsx +264 -0
- package/src/components/dock/dock.css +23 -0
- package/src/components/dock/dockLayout.ts +58 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.ts +91 -0
- package/src/components/dock/panelRegistry.ts +67 -0
- package/src/components/editor/AnimationCard.tsx +4 -4
- package/src/components/editor/AnimationCardParts.tsx +6 -6
- package/src/components/editor/BlockParamsPanel.tsx +4 -4
- package/src/components/editor/BorderRadiusEditor.tsx +1 -1
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ComputedTweenNotice.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/EaseCurveSection.tsx +1 -1
- package/src/components/editor/EaseParamFields.tsx +5 -5
- package/src/components/editor/FileTree.tsx +4 -4
- package/src/components/editor/FileTreeIcons.tsx +1 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -13
- package/src/components/editor/InlineTextToolbar.tsx +1 -1
- package/src/components/editor/InspectorHeaderActions.tsx +3 -3
- package/src/components/editor/KeyframeDiamond.tsx +1 -1
- package/src/components/editor/KeyframeEaseList.tsx +1 -1
- package/src/components/editor/LayersPanel.tsx +3 -3
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/PromotableControl.tsx +2 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
- package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +27 -3
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.tsx +4 -4
- package/src/components/editor/Transform3DCube.tsx +2 -2
- package/src/components/editor/audioFxRevealTarget.ts +1 -0
- package/src/components/editor/domEditing.test.ts +1 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
- package/src/components/editor/domEditingAgentPrompt.ts +9 -3
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/editor/propertyPanelColor.tsx +11 -11
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
- package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
- package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +4 -4
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
- package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
- package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +5 -5
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +6 -6
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
- package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
- package/src/components/editor/propertyPanelSections.tsx +8 -8
- package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
- package/src/components/feedback/feedbackTrigger.ts +0 -1
- package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +12 -7
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +4 -2
- package/src/components/nle/TimelinePane.tsx +17 -3
- package/src/components/nle/TimelineResizeDivider.tsx +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/components/panels/SlideshowPanel.tsx +2 -2
- package/src/components/panels/SlideshowSubPanels.tsx +20 -17
- package/src/components/panels/VariablesBindElement.tsx +2 -2
- package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
- package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +6 -6
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- package/src/components/sidebar/AssetCard.tsx +8 -8
- package/src/components/sidebar/AssetContextMenu.tsx +8 -8
- package/src/components/sidebar/AssetsTab.tsx +2 -2
- package/src/components/sidebar/AudioRow.tsx +6 -6
- package/src/components/sidebar/BlocksTab.tsx +6 -6
- package/src/components/sidebar/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
- package/src/components/sidebar/LeftSidebar.tsx +19 -5
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/ui/Button.test.tsx +60 -0
- package/src/components/ui/Button.tsx +4 -4
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +1 -1
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -1
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +2 -2
- package/src/player/components/ClipContextMenu.tsx +90 -10
- package/src/player/components/CompositionThumbnail.test.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +3 -4
- package/src/player/components/EditModal.tsx +1 -1
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
- package/src/player/components/LayerDisclosureRow.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +1 -1
- package/src/player/components/ShortcutsPanel.tsx +9 -9
- package/src/player/components/SpeedMenu.tsx +3 -5
- package/src/player/components/Timeline.tsx +16 -15
- package/src/player/components/TimelineCanvas.tsx +45 -8
- package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
- package/src/player/components/TimelineEmptyState.tsx +5 -5
- package/src/player/components/TimelineFxButton.tsx +6 -6
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +5 -5
- package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +54 -1
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +6 -1
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/VideoThumbnail.tsx +4 -2
- package/src/player/components/VolumeControl.tsx +2 -2
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +92 -1
- package/src/player/components/timelineDragDrop.ts +82 -33
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineSnapping.test.ts +27 -0
- package/src/player/components/timelineSnapping.ts +14 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +52 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/styles/iconTokens.ts +20 -0
- package/src/styles/palette-reset.css +6 -0
- package/src/styles/studio.css +24 -21
- package/src/styles/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +213 -0
- package/src/styles/theme.test.ts +196 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +34 -6
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/dist/assets/index-Bgmx2h7W.js +0 -441
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
- package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
- package/src/components/storyboard/FramePoster.test.tsx +0 -61
- package/src/components/storyboard/FramePoster.tsx +0 -86
- package/src/components/storyboard/StoryboardDirection.tsx +0 -45
- package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
- package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
- package/src/components/storyboard/StoryboardGrid.tsx +0 -55
- package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
- package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
- package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
- package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
- package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
- package/src/components/storyboard/StoryboardView.tsx +0 -169
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
- package/src/components/storyboard/frameComments.test.ts +0 -95
- package/src/components/storyboard/frameComments.ts +0 -115
- package/src/components/storyboard/frameStatus.ts +0 -36
- package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
- package/src/components/storyboard/storyboardReviewStage.ts +0 -45
- package/src/components/storyboard/useFrameComments.ts +0 -98
- package/src/contexts/ViewModeContext.tsx +0 -142
- package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* linkedom's HTMLCanvasElement constructor calls `createCanvas(300, 150)` from
|
|
9
|
+
* the Node-only `canvas` package. linkedom guards that import in a try/catch —
|
|
10
|
+
* try the native package, fall back to its own bundled shim — but a bundler
|
|
11
|
+
* resolves the require statically, so the catch never fires in the browser
|
|
12
|
+
* build and `createCanvas` lands undefined. Parsing any `<canvas>` tag then
|
|
13
|
+
* throws, `openComposition` rejects, and Studio silently loses its SDK session
|
|
14
|
+
* for the whole composition (no cutover, and the resolver shadow does not run
|
|
15
|
+
* either).
|
|
16
|
+
*
|
|
17
|
+
* Production telemetry, 72h after the v0.8.47 flip: 1065 of 1544
|
|
18
|
+
* `studio:sdk_session_unavailable` events were this crash, spread across a
|
|
19
|
+
* large share of 197 users. Canvas is common in compositions — Three.js,
|
|
20
|
+
* shaders, particle effects, charts.
|
|
21
|
+
*
|
|
22
|
+
* The fix is a `resolve.alias` in the studio's vite config pointing `canvas` at
|
|
23
|
+
* linkedom's own shim. This test pins the behaviour the alias has to preserve.
|
|
24
|
+
*/
|
|
25
|
+
const CANVAS_COMPOSITION = `<!doctype html>
|
|
26
|
+
<html>
|
|
27
|
+
<body data-composition-id="main" data-width="1920" data-height="1080">
|
|
28
|
+
<div class="clip" data-start="0" data-duration="3">
|
|
29
|
+
<canvas id="scene" width="800" height="600"></canvas>
|
|
30
|
+
</div>
|
|
31
|
+
</body>
|
|
32
|
+
</html>`;
|
|
33
|
+
|
|
34
|
+
describe("openComposition with a <canvas> element", () => {
|
|
35
|
+
it("opens a session instead of throwing on createCanvas", async () => {
|
|
36
|
+
const session = await openComposition(CANVAS_COMPOSITION, { history: false });
|
|
37
|
+
expect(session).toBeTruthy();
|
|
38
|
+
session.dispose();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("models the canvas element so edits can target it", async () => {
|
|
42
|
+
const session = await openComposition(CANVAS_COMPOSITION, { history: false });
|
|
43
|
+
// A composition whose elements cannot be modelled is a session the cutover
|
|
44
|
+
// path declines wholesale (sessionEmpty), which is the same silent bypass by
|
|
45
|
+
// a different route.
|
|
46
|
+
expect(session.getElements().length).toBeGreaterThan(0);
|
|
47
|
+
session.dispose();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("canvas browser-stub alias", () => {
|
|
52
|
+
// The two tests above pass with OR without the fix: vitest resolves
|
|
53
|
+
// `require('canvas')` at runtime, so linkedom's own try/catch fallback works
|
|
54
|
+
// here. Only the bundled build breaks. Nothing else in this suite can observe
|
|
55
|
+
// the bundler, so the alias itself is what has to be pinned — without this,
|
|
56
|
+
// deleting the alias reintroduces a crash that is invisible until it reaches
|
|
57
|
+
// production telemetry.
|
|
58
|
+
it("is configured, so the browser build never resolves the Node canvas package", () => {
|
|
59
|
+
const config = readFileSync(resolve(import.meta.dirname, "../../vite.config.ts"), "utf8");
|
|
60
|
+
expect(config).toMatch(
|
|
61
|
+
/\bcanvas:\s*resolve\(__dirname,\s*"src\/shims\/canvasBrowserStub\.js"\)/,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("exports the createCanvas shape linkedom destructures", async () => {
|
|
66
|
+
const stub = await import("../shims/canvasBrowserStub.js");
|
|
67
|
+
// linkedom takes the DEFAULT import and destructures createCanvas off it
|
|
68
|
+
// (`import Canvas from '...'; const {createCanvas} = Canvas`), so the
|
|
69
|
+
// default export is the one that actually has to carry the function.
|
|
70
|
+
expect(typeof stub.default.createCanvas).toBe("function");
|
|
71
|
+
expect(typeof stub.createCanvas).toBe("function");
|
|
72
|
+
const canvas = stub.default.createCanvas(300, 150);
|
|
73
|
+
expect(canvas.width).toBe(300);
|
|
74
|
+
expect(canvas.height).toBe(150);
|
|
75
|
+
expect(canvas.getContext()).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -37,6 +37,21 @@ describe("studio UI preferences", () => {
|
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it("remembers the ruler and safe-margin toggles and drops non-boolean values", () => {
|
|
41
|
+
const storage = createStorage();
|
|
42
|
+
writeStudioUiPreferences({ rulerVisible: true, safeMarginsVisible: false }, storage);
|
|
43
|
+
expect(readStudioUiPreferences(storage)).toEqual({
|
|
44
|
+
rulerVisible: true,
|
|
45
|
+
safeMarginsVisible: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
storage.setItem(
|
|
49
|
+
"hf-studio-ui-preferences",
|
|
50
|
+
JSON.stringify({ rulerVisible: "yes", safeMarginsVisible: 1 }),
|
|
51
|
+
);
|
|
52
|
+
expect(readStudioUiPreferences(storage)).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
|
|
40
55
|
it("ignores malformed stored values", () => {
|
|
41
56
|
const storage = createStorage();
|
|
42
57
|
storage.setItem(
|
|
@@ -101,6 +116,34 @@ describe("thumbnailMode preference", () => {
|
|
|
101
116
|
});
|
|
102
117
|
});
|
|
103
118
|
|
|
119
|
+
describe("per-project scoping", () => {
|
|
120
|
+
it("keeps two projects' preferences independent", () => {
|
|
121
|
+
const storage = createStorage();
|
|
122
|
+
writeStudioUiPreferences({ leftWidth: 300 }, storage, "alpha");
|
|
123
|
+
writeStudioUiPreferences({ leftWidth: 500 }, storage, "beta");
|
|
124
|
+
|
|
125
|
+
expect(readStudioUiPreferences(storage, "alpha").leftWidth).toBe(300);
|
|
126
|
+
expect(readStudioUiPreferences(storage, "beta").leftWidth).toBe(500);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("a project with nothing saved yet inherits the pre-scoping global entry once", () => {
|
|
130
|
+
const storage = createStorage();
|
|
131
|
+
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
|
|
132
|
+
|
|
133
|
+
expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(600);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("stops inheriting the global entry once the project has its own write", () => {
|
|
137
|
+
const storage = createStorage();
|
|
138
|
+
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
|
|
139
|
+
|
|
140
|
+
writeStudioUiPreferences({ leftWidth: 250 }, storage, "gamma");
|
|
141
|
+
|
|
142
|
+
expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(250);
|
|
143
|
+
expect(readStudioUiPreferences(storage, null).leftWidth).toBe(600);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
104
147
|
describe("timeline zoom pin persistence", () => {
|
|
105
148
|
it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
|
|
106
149
|
const storage = createStorage();
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { SerializedDockview } from "dockview-react";
|
|
2
|
+
import { parseDockLayout } from "../components/dock/dockLayoutSchema";
|
|
3
|
+
|
|
1
4
|
export interface StoredPreviewZoomState {
|
|
2
5
|
zoomPercent: number;
|
|
3
6
|
panX: number;
|
|
@@ -20,6 +23,8 @@ export interface StudioUiPreferences {
|
|
|
20
23
|
recentBlocks?: string[];
|
|
21
24
|
snapEnabled?: boolean;
|
|
22
25
|
gridVisible?: boolean;
|
|
26
|
+
rulerVisible?: boolean;
|
|
27
|
+
safeMarginsVisible?: boolean;
|
|
23
28
|
gridSpacing?: number;
|
|
24
29
|
snapToGrid?: boolean;
|
|
25
30
|
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
|
|
@@ -45,6 +50,8 @@ export interface StudioUiPreferences {
|
|
|
45
50
|
* intentionally scoped to one mount.
|
|
46
51
|
*/
|
|
47
52
|
agentToolsEnabled?: boolean;
|
|
53
|
+
/** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
|
|
54
|
+
dockLayout?: SerializedDockview;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
|
@@ -62,11 +69,15 @@ function getBrowserStorage(): Storage | null {
|
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
function storageKeyFor(projectId: string | null): string {
|
|
73
|
+
return projectId ? `${STUDIO_UI_PREFERENCES_KEY}:${projectId}` : STUDIO_UI_PREFERENCES_KEY;
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
// fallow-ignore-next-line complexity
|
|
66
|
-
function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
77
|
+
function readStorage(storage: Storage | null, key: string): StudioUiPreferences {
|
|
67
78
|
if (!storage) return {};
|
|
68
79
|
try {
|
|
69
|
-
const raw = storage.getItem(
|
|
80
|
+
const raw = storage.getItem(key);
|
|
70
81
|
if (!raw) return {};
|
|
71
82
|
const parsed: unknown = JSON.parse(raw);
|
|
72
83
|
if (!isRecord(parsed)) return {};
|
|
@@ -130,6 +141,12 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
130
141
|
if (typeof parsed.gridVisible === "boolean") {
|
|
131
142
|
preferences.gridVisible = parsed.gridVisible;
|
|
132
143
|
}
|
|
144
|
+
if (typeof parsed.rulerVisible === "boolean") {
|
|
145
|
+
preferences.rulerVisible = parsed.rulerVisible;
|
|
146
|
+
}
|
|
147
|
+
if (typeof parsed.safeMarginsVisible === "boolean") {
|
|
148
|
+
preferences.safeMarginsVisible = parsed.safeMarginsVisible;
|
|
149
|
+
}
|
|
133
150
|
if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
|
|
134
151
|
preferences.gridSpacing = parsed.gridSpacing;
|
|
135
152
|
}
|
|
@@ -157,27 +174,38 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
157
174
|
if (typeof parsed.agentToolsEnabled === "boolean") {
|
|
158
175
|
preferences.agentToolsEnabled = parsed.agentToolsEnabled;
|
|
159
176
|
}
|
|
177
|
+
const dockLayout = parseDockLayout(parsed.dockLayout);
|
|
178
|
+
if (dockLayout) preferences.dockLayout = dockLayout;
|
|
160
179
|
return preferences;
|
|
161
180
|
} catch {
|
|
162
181
|
return {};
|
|
163
182
|
}
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
/** `projectId` opts a caller into a per-project entry (falls back once to the
|
|
186
|
+
* shared entry so a project's first read isn't blank). Defaults to `null`:
|
|
187
|
+
* most callers read once at mount, never on a live project switch. */
|
|
188
|
+
export function readStudioUiPreferences(
|
|
189
|
+
storage: Storage | null = getBrowserStorage(),
|
|
190
|
+
projectId: string | null = null,
|
|
191
|
+
): StudioUiPreferences {
|
|
192
|
+
const scoped = readStorage(storage, storageKeyFor(projectId));
|
|
193
|
+
if (!projectId || Object.keys(scoped).length > 0) return scoped;
|
|
194
|
+
return readStorage(storage, STUDIO_UI_PREFERENCES_KEY);
|
|
168
195
|
}
|
|
169
196
|
|
|
170
197
|
export function writeStudioUiPreferences(
|
|
171
198
|
patch: StudioUiPreferences,
|
|
172
199
|
storage: Storage | null = getBrowserStorage(),
|
|
200
|
+
projectId: string | null = null,
|
|
173
201
|
) {
|
|
174
202
|
if (!storage) return;
|
|
175
203
|
try {
|
|
176
204
|
const next = {
|
|
177
|
-
...
|
|
205
|
+
...readStudioUiPreferences(storage, projectId),
|
|
178
206
|
...patch,
|
|
179
207
|
};
|
|
180
|
-
storage.setItem(
|
|
208
|
+
storage.setItem(storageKeyFor(projectId), JSON.stringify(next));
|
|
181
209
|
} catch {
|
|
182
210
|
/* localStorage may be unavailable or full */
|
|
183
211
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import {
|
|
4
|
+
applyTrackRenumbers,
|
|
5
|
+
planDropTrackInsert,
|
|
6
|
+
resolveDropTrack,
|
|
7
|
+
} from "./timelineDropTrackInsert";
|
|
8
|
+
|
|
9
|
+
function clip(id: string, track: number, start: number): TimelineElement {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
key: id,
|
|
13
|
+
tag: "div",
|
|
14
|
+
start,
|
|
15
|
+
duration: 2,
|
|
16
|
+
track,
|
|
17
|
+
authoredTrack: track,
|
|
18
|
+
hfId: `hf-${id}`,
|
|
19
|
+
domId: id,
|
|
20
|
+
// Main-document elements carry no sourceFile, as in production.
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const trackOrder = [0, 1, 2];
|
|
25
|
+
const dropped = { id: "new", tag: "img", start: 5, duration: 3 };
|
|
26
|
+
const elements = [clip("a", 0, 0), clip("b", 1, 0), clip("c", 2, 0)];
|
|
27
|
+
const source = elements
|
|
28
|
+
.map(
|
|
29
|
+
(e) =>
|
|
30
|
+
`<div data-hf-id="${e.hfId}" id="${e.id}" data-start="0" data-track-index="${e.track}"></div>`,
|
|
31
|
+
)
|
|
32
|
+
.join("\n");
|
|
33
|
+
|
|
34
|
+
describe("planDropTrackInsert", () => {
|
|
35
|
+
it("opens a lane between two rows and pushes the lanes below down", () => {
|
|
36
|
+
const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 1, dropped });
|
|
37
|
+
expect(plan?.track).toBe(1);
|
|
38
|
+
expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([
|
|
39
|
+
["b", 2],
|
|
40
|
+
["c", 3],
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("opens a lane above the first row", () => {
|
|
45
|
+
const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 0, dropped });
|
|
46
|
+
expect(plan?.track).toBe(0);
|
|
47
|
+
expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([
|
|
48
|
+
["a", 1],
|
|
49
|
+
["b", 2],
|
|
50
|
+
["c", 3],
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("still renumbers when the peers' own sourceFile is a real path, not undefined", () => {
|
|
55
|
+
const subComp = elements.map((e) => ({ ...e, sourceFile: "sub.html" }));
|
|
56
|
+
const plan = planDropTrackInsert({ elements: subComp, trackOrder, insertRow: 1, dropped });
|
|
57
|
+
expect(plan?.track).toBe(1);
|
|
58
|
+
expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([
|
|
59
|
+
["b", 2],
|
|
60
|
+
["c", 3],
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("planDropTrackInsert against the display row order", () => {
|
|
66
|
+
it("counts an audio-group anchor row the way a clip drag does", () => {
|
|
67
|
+
// Rows shown: group anchor (-0.5), track 0, track 1. Row boundary 2 is between 0 and 1.
|
|
68
|
+
const grouped = elements.slice(0, 2).map((e) => (e.id === "a" ? { ...e, audioGroup: "g" } : e));
|
|
69
|
+
const plan = planDropTrackInsert({
|
|
70
|
+
elements: grouped,
|
|
71
|
+
trackOrder: [-0.5, 0, 1],
|
|
72
|
+
insertRow: 2,
|
|
73
|
+
dropped,
|
|
74
|
+
});
|
|
75
|
+
expect(plan?.track).toBe(1);
|
|
76
|
+
expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([["b", 2]]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("applyTrackRenumbers", () => {
|
|
81
|
+
it("rewrites only the renumbered clips' data-track-index", () => {
|
|
82
|
+
const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 1, dropped });
|
|
83
|
+
const out = applyTrackRenumbers(source, plan!);
|
|
84
|
+
const tracks = [...out.matchAll(/id="(\w)"[^>]*data-track-index="(\d+)"/g)].map((m) => [
|
|
85
|
+
m[1],
|
|
86
|
+
+m[2],
|
|
87
|
+
]);
|
|
88
|
+
expect(tracks).toEqual([
|
|
89
|
+
["a", 0],
|
|
90
|
+
["b", 2],
|
|
91
|
+
["c", 3],
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("throws when a clip's opening tag is missing", () => {
|
|
96
|
+
const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 1, dropped });
|
|
97
|
+
expect(() => applyTrackRenumbers("<div></div>", plan!)).toThrow(/Cannot renumber/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("resolveDropTrack", () => {
|
|
102
|
+
it("keeps the aimed lane and the source when no insert is asked for", () => {
|
|
103
|
+
const out = resolveDropTrack({
|
|
104
|
+
source,
|
|
105
|
+
elements,
|
|
106
|
+
placement: { track: 2 },
|
|
107
|
+
dropped,
|
|
108
|
+
});
|
|
109
|
+
expect(out).toEqual({ source, track: 2 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns the new lane and the source with the lanes below pushed down", () => {
|
|
113
|
+
const out = resolveDropTrack({
|
|
114
|
+
source,
|
|
115
|
+
elements,
|
|
116
|
+
placement: { track: 1, insertRow: 1, trackOrder },
|
|
117
|
+
dropped,
|
|
118
|
+
});
|
|
119
|
+
expect(out.track).toBe(1);
|
|
120
|
+
expect(out.source).toContain('id="b" data-start="0" data-track-index="2"');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("refuses to insert when a locked clip would have to move", () => {
|
|
124
|
+
const locked = elements.map((e) => (e.id === "b" ? { ...e, timelineLocked: true } : e));
|
|
125
|
+
expect(() =>
|
|
126
|
+
resolveDropTrack({
|
|
127
|
+
source,
|
|
128
|
+
elements: locked,
|
|
129
|
+
placement: { track: 1, insertRow: 1, trackOrder },
|
|
130
|
+
dropped,
|
|
131
|
+
}),
|
|
132
|
+
).toThrow(/locked/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { TimelineElement } from "../player";
|
|
2
|
+
import { layoutAfterTrackInsert } from "../player/components/timelineDragLanding";
|
|
3
|
+
import { canMoveTimelineElement } from "../player/components/timelineAuthoredMoveTarget";
|
|
4
|
+
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
5
|
+
import { applyPatchByTarget, readAttributeByTarget } from "./sourcePatcher";
|
|
6
|
+
import { buildPatchTarget } from "../hooks/timelineEditingHelpers";
|
|
7
|
+
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
8
|
+
|
|
9
|
+
export interface DropTrackInsertPlan {
|
|
10
|
+
/** Lane the dropped clip is written on. */
|
|
11
|
+
track: number;
|
|
12
|
+
/** Existing clips whose lane changes to make room. */
|
|
13
|
+
renumbers: Array<{ element: TimelineElement; track: number }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Plan a new lane at boundary `insertRow` with the renumber a clip drag into a gutter uses.
|
|
17
|
+
* Null when a locked clip would have to move. */
|
|
18
|
+
export function planDropTrackInsert(input: {
|
|
19
|
+
elements: TimelineElement[];
|
|
20
|
+
/** The row order the timeline shows (anchor rows included), which `insertRow` indexes. */
|
|
21
|
+
trackOrder: readonly number[];
|
|
22
|
+
insertRow: number;
|
|
23
|
+
dropped: Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
|
|
24
|
+
}): DropTrackInsertPlan | null {
|
|
25
|
+
const { elements, trackOrder, insertRow, dropped } = input;
|
|
26
|
+
const newElement: TimelineElement = {
|
|
27
|
+
...dropped,
|
|
28
|
+
key: dropped.id,
|
|
29
|
+
// Parked on an existing lane so it adds no lane of its own to the topology.
|
|
30
|
+
track: elements[0]?.track ?? 0,
|
|
31
|
+
// sameSourceFile compares this raw field: borrow the peers' value, not the resolved path.
|
|
32
|
+
sourceFile: elements[0]?.sourceFile,
|
|
33
|
+
};
|
|
34
|
+
const layout = layoutAfterTrackInsert(newElement, dropped.start, insertRow, null, {
|
|
35
|
+
elements: [...elements, newElement],
|
|
36
|
+
trackOrder: [...trackOrder],
|
|
37
|
+
});
|
|
38
|
+
if (!layout) return null;
|
|
39
|
+
const byKey = new Map(elements.map((e) => [e.key ?? e.id, e]));
|
|
40
|
+
const renumbers: DropTrackInsertPlan["renumbers"] = [];
|
|
41
|
+
for (const norm of layout.normalized) {
|
|
42
|
+
const key = norm.key ?? norm.id;
|
|
43
|
+
if (key === dropped.id) continue;
|
|
44
|
+
const src = byKey.get(key);
|
|
45
|
+
if (!src || norm.track === (src.authoredTrack ?? src.track)) continue;
|
|
46
|
+
if (!canMoveTimelineElement(src)) return null;
|
|
47
|
+
renumbers.push({ element: src, track: norm.track });
|
|
48
|
+
}
|
|
49
|
+
const track = layout.normalized.find((n) => (n.key ?? n.id) === dropped.id)?.track;
|
|
50
|
+
return track == null ? null : { track, renumbers };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rewrite `data-track-index` on each renumbered clip's opening tag, via the shared source patcher. */
|
|
54
|
+
export function applyTrackRenumbers(source: string, plan: DropTrackInsertPlan): string {
|
|
55
|
+
let out = source;
|
|
56
|
+
for (const { element, track } of plan.renumbers) {
|
|
57
|
+
const target = buildPatchTarget(element);
|
|
58
|
+
if (!target || readAttributeByTarget(out, target, "track-index") === undefined) {
|
|
59
|
+
throw new Error(`Cannot renumber the track of "${element.id}" in the source`);
|
|
60
|
+
}
|
|
61
|
+
out = applyPatchByTarget(out, target, {
|
|
62
|
+
type: "attribute",
|
|
63
|
+
property: "track-index",
|
|
64
|
+
value: formatTimelineAttributeNumber(track),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The lane a dropped clip is written on and the source with any lanes pushed down to make room. */
|
|
71
|
+
export function resolveDropTrack(input: {
|
|
72
|
+
source: string;
|
|
73
|
+
elements: TimelineElement[];
|
|
74
|
+
placement: TimelineDropPlacement;
|
|
75
|
+
dropped: Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
|
|
76
|
+
}): { source: string; track: number } {
|
|
77
|
+
const { source, elements, placement, dropped } = input;
|
|
78
|
+
if (placement.insertRow == null) return { source, track: placement.track };
|
|
79
|
+
const { insertRow, trackOrder } = placement;
|
|
80
|
+
const plan = planDropTrackInsert({ elements, trackOrder, insertRow, dropped });
|
|
81
|
+
if (!plan) throw new Error("Cannot open a new track here: a locked clip would have to move.");
|
|
82
|
+
return { source: applyTrackRenumbers(source, plan), track: plan.track };
|
|
83
|
+
}
|
|
@@ -78,9 +78,8 @@ describe("canSplitElementAt", () => {
|
|
|
78
78
|
).toBe(false);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
it("rejects locked
|
|
81
|
+
it("rejects locked elements while allowing identified compositions", () => {
|
|
82
82
|
expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
|
|
83
|
-
expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
|
|
84
83
|
expect(
|
|
85
84
|
canSplitElementAt(
|
|
86
85
|
element({ kind: "composition", compositionSrc: "child.html", playbackRate: 2 }),
|
|
@@ -27,7 +27,6 @@ export function canSplitElement(el: TimelineElement): boolean {
|
|
|
27
27
|
el.playbackRate == null || (Number.isFinite(el.playbackRate) && el.playbackRate > 0);
|
|
28
28
|
return (
|
|
29
29
|
!el.timelineLocked &&
|
|
30
|
-
el.timingSource !== "implicit" &&
|
|
31
30
|
hasStableIdentity &&
|
|
32
31
|
hasValidRate &&
|
|
33
32
|
!!el.duration &&
|
|
@@ -370,3 +370,43 @@ function sceneDoc(): Document {
|
|
|
370
370
|
</main>`;
|
|
371
371
|
return doc;
|
|
372
372
|
}
|
|
373
|
+
|
|
374
|
+
describe("buildStudioLook clips", () => {
|
|
375
|
+
const clip = (overrides: Partial<TimelineElement>) =>
|
|
376
|
+
element({ tag: "audio", kind: "audio", domId: "sfx", start: 2, duration: 0.5, ...overrides });
|
|
377
|
+
|
|
378
|
+
it("returns timeline clips that are not scene layers, audio included", () => {
|
|
379
|
+
const result = buildStudioLook(
|
|
380
|
+
snapshot({
|
|
381
|
+
elements: [clip({ domId: "late", start: 9 }), clip({ domId: "early", volume: 0.85 })],
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
if (!result.ok) throw new Error("expected ok");
|
|
385
|
+
expect(result.clipCount).toBe(2);
|
|
386
|
+
expect(result.clips.map((c) => c.id)).toEqual(["early", "late"]);
|
|
387
|
+
expect(result.clips[0]).toMatchObject({ kind: "audio", start: 2, volume: 0.85 });
|
|
388
|
+
expect(result.elements).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("filters clips by id, kind or src", () => {
|
|
392
|
+
const result = buildStudioLook(
|
|
393
|
+
snapshot({
|
|
394
|
+
elements: [
|
|
395
|
+
clip({ domId: "click", src: "click.mp3" }),
|
|
396
|
+
clip({ domId: "vo", src: "voice.mp3" }),
|
|
397
|
+
],
|
|
398
|
+
}),
|
|
399
|
+
{ filter: "voice" },
|
|
400
|
+
);
|
|
401
|
+
if (!result.ok) throw new Error("expected ok");
|
|
402
|
+
expect(result.clips.map((c) => c.id)).toEqual(["vo"]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("caps clips at the limit and reports the full count and truncation", () => {
|
|
406
|
+
const elements = Array.from({ length: 5 }, (_, i) => clip({ domId: `c${i}`, start: i }));
|
|
407
|
+
const result = buildStudioLook(snapshot({ elements }), { limit: 2 });
|
|
408
|
+
if (!result.ok) throw new Error("expected ok");
|
|
409
|
+
expect(result.clips.map((c) => c.id)).toEqual(["c0", "c1"]);
|
|
410
|
+
expect([result.clipCount, result.clipsTruncated]).toEqual([5, true]);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { collectDomEditLayerItems } from "../../components/editor/domEditingLayers";
|
|
12
12
|
import type { DomEditLayerItem, DomEditSelection } from "../../components/editor/domEditingTypes";
|
|
13
13
|
import type { TimelineElement } from "../../player/store/timelineElement";
|
|
14
|
+
import { byStart, describeClip, type ClipFact } from "../../player/lib/describeClips";
|
|
14
15
|
import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
|
|
15
16
|
import { toolOk, type ToolResult } from "../toolResult";
|
|
16
17
|
|
|
@@ -107,8 +108,15 @@ export interface StudioLook {
|
|
|
107
108
|
elementCount: number;
|
|
108
109
|
truncated: boolean;
|
|
109
110
|
elements: LookElement[];
|
|
111
|
+
/** Every timeline clip, audio included, whether or not it is a visual layer. */
|
|
112
|
+
clipCount: number;
|
|
113
|
+
clipsTruncated: boolean;
|
|
114
|
+
clips: LookClip[];
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
/** A timeline clip; `handle` is null when the clip has no addressable source element. */
|
|
118
|
+
type LookClip = ClipFact & { handle: string | null };
|
|
119
|
+
|
|
112
120
|
export interface StudioLookInput {
|
|
113
121
|
/** Case-insensitive substring match against label, tag, and handle. */
|
|
114
122
|
filter?: string;
|
|
@@ -154,6 +162,22 @@ function describeSelection(
|
|
|
154
162
|
};
|
|
155
163
|
}
|
|
156
164
|
|
|
165
|
+
function matchesClipFilter(clip: LookClip, needle: string): boolean {
|
|
166
|
+
return [clip.id, clip.label, clip.kind, clip.src].some((field) =>
|
|
167
|
+
field?.toLowerCase().includes(needle),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function describeClipsWithHandles(snapshot: StudioLookSnapshot): LookClip[] {
|
|
172
|
+
const activeCompositionPath = snapshot.compositionPath ?? "index.html";
|
|
173
|
+
return snapshot.elements.map((element) => ({
|
|
174
|
+
...describeClip(element),
|
|
175
|
+
handle: mintElementHandle(
|
|
176
|
+
timelineElementAddress(element, activeCompositionPath, snapshot.projectId),
|
|
177
|
+
),
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
157
181
|
function matchesFilter(element: LookElement, needle: string): boolean {
|
|
158
182
|
return (
|
|
159
183
|
element.label.toLowerCase().includes(needle) ||
|
|
@@ -258,6 +282,8 @@ export function buildStudioLook(
|
|
|
258
282
|
const requested =
|
|
259
283
|
Number.isInteger(input.limit) && input.limit! > 0 ? input.limit! : DEFAULT_LIMIT;
|
|
260
284
|
const limit = Math.min(requested, DEFAULT_LIMIT);
|
|
285
|
+
const clips = describeClipsWithHandles(snapshot).sort(byStart);
|
|
286
|
+
const matchedClips = needle ? clips.filter((clip) => matchesClipFilter(clip, needle)) : clips;
|
|
261
287
|
|
|
262
288
|
return toolOk<StudioLook>({
|
|
263
289
|
projectId: snapshot.projectId,
|
|
@@ -281,6 +307,9 @@ export function buildStudioLook(
|
|
|
281
307
|
elementCount: matched.length,
|
|
282
308
|
truncated: matched.length > limit,
|
|
283
309
|
elements: matched.slice(0, limit),
|
|
310
|
+
clipCount: matchedClips.length,
|
|
311
|
+
clipsTruncated: matchedClips.length > limit,
|
|
312
|
+
clips: matchedClips.slice(0, limit),
|
|
284
313
|
});
|
|
285
314
|
}
|
|
286
315
|
|
|
@@ -296,7 +325,7 @@ export const STUDIO_LOOK_INPUT_SCHEMA = {
|
|
|
296
325
|
type: "integer",
|
|
297
326
|
minimum: 1,
|
|
298
327
|
maximum: DEFAULT_LIMIT,
|
|
299
|
-
description: `Cap the returned elements (default and max ${DEFAULT_LIMIT}). elementCount always
|
|
328
|
+
description: `Cap the returned elements and clips (default and max ${DEFAULT_LIMIT} each). elementCount and clipCount always report the full match count.`,
|
|
300
329
|
},
|
|
301
330
|
},
|
|
302
331
|
additionalProperties: false,
|
|
@@ -307,6 +336,8 @@ export const STUDIO_LOOK_DESCRIPTION = [
|
|
|
307
336
|
"the playhead and duration, what the human currently has selected (including what that",
|
|
308
337
|
"element will and will not accept), and the live nested scene in DOM preorder.",
|
|
309
338
|
"Each scene element includes source ownership, hierarchy, and optional timeline timing.",
|
|
339
|
+
"`clips` lists every timeline clip, audio included: kind, start, duration, track, volume,",
|
|
340
|
+
"volume/effect automation points, playback rate, audio group and role.",
|
|
310
341
|
"Pass a handle back to any tool that edits an element.",
|
|
311
342
|
"Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
|
|
312
343
|
"`history.undoLabel` is worth checkpointing before a batch: if it later names something",
|