@hyperframes/studio 0.7.55 → 0.7.56
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/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CPetwHFV.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40414 -37680
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../player";
|
|
6
|
+
import { usePlayerStore } from "../player";
|
|
7
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
8
|
+
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
|
|
9
|
+
import { createEmptyEditHistory } from "../utils/editHistory";
|
|
10
|
+
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
|
|
11
|
+
import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
|
|
12
|
+
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
const ROOT_FILE = "index.html";
|
|
16
|
+
const SUBCOMP_FILE = "scenes/intro.html";
|
|
17
|
+
|
|
18
|
+
// A root-level clip lives in index.html and is authored in local time already.
|
|
19
|
+
const rootElement: TimelineElement = {
|
|
20
|
+
id: "root-clip",
|
|
21
|
+
tag: "div",
|
|
22
|
+
start: 0,
|
|
23
|
+
duration: 10,
|
|
24
|
+
track: 0,
|
|
25
|
+
domId: "root-clip",
|
|
26
|
+
sourceFile: ROOT_FILE,
|
|
27
|
+
timingSource: "authored",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
|
|
31
|
+
// host's master start), `sourceFile` is the sub-comp, and `expandedParentStart`
|
|
32
|
+
// is that host master start. Its authored time in the file is start - basis.
|
|
33
|
+
const expandedChild: TimelineElement = {
|
|
34
|
+
id: "child-clip",
|
|
35
|
+
tag: "div",
|
|
36
|
+
start: 2,
|
|
37
|
+
duration: 6,
|
|
38
|
+
track: 1,
|
|
39
|
+
domId: "child-clip",
|
|
40
|
+
sourceFile: SUBCOMP_FILE,
|
|
41
|
+
timingSource: "authored",
|
|
42
|
+
expandedParentStart: 2,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface SplitRequest {
|
|
46
|
+
path: string;
|
|
47
|
+
splitTime: number;
|
|
48
|
+
elementStart: number;
|
|
49
|
+
elementDuration: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type SingleSplit = (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
53
|
+
type SplitAll = (splitTime: number) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
interface Harness {
|
|
56
|
+
splitRequests: SplitRequest[];
|
|
57
|
+
singleRef: { current: SingleSplit | undefined };
|
|
58
|
+
allRef: { current: SplitAll | undefined };
|
|
59
|
+
root: ReturnType<typeof mountProbe>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mountRazorSplit(): Harness {
|
|
63
|
+
const disk: Record<string, string> = {
|
|
64
|
+
[ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
|
|
65
|
+
[SUBCOMP_FILE]: `<div class="clip" id="child-clip" data-start="0" data-duration="6"></div>`,
|
|
66
|
+
};
|
|
67
|
+
const splitRequests: SplitRequest[] = [];
|
|
68
|
+
|
|
69
|
+
const fetchMock = createSplitFetchMock(disk, (path, body) => {
|
|
70
|
+
splitRequests.push({
|
|
71
|
+
path,
|
|
72
|
+
splitTime: body.splitTime,
|
|
73
|
+
elementStart: body.elementStart,
|
|
74
|
+
elementDuration: body.elementDuration,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
78
|
+
|
|
79
|
+
const singleRef: { current: SingleSplit | undefined } = { current: undefined };
|
|
80
|
+
const allRef: { current: SplitAll | undefined } = { current: undefined };
|
|
81
|
+
|
|
82
|
+
function Component() {
|
|
83
|
+
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
|
84
|
+
projectId: "p1",
|
|
85
|
+
activeCompPath: ROOT_FILE,
|
|
86
|
+
showToast: () => {},
|
|
87
|
+
writeProjectFile: async (path, content) => {
|
|
88
|
+
disk[path] = content;
|
|
89
|
+
},
|
|
90
|
+
recordEdit: async () => {},
|
|
91
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
92
|
+
reloadPreview: () => {},
|
|
93
|
+
});
|
|
94
|
+
singleRef.current = handleRazorSplit;
|
|
95
|
+
allRef.current = handleRazorSplitAll;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const root = mountProbe(Component);
|
|
100
|
+
return { splitRequests, singleRef, allRef, root };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
document.body.innerHTML = "";
|
|
105
|
+
usePlayerStore.setState({ elements: [] });
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("useRazorSplit — sub-comp coordinate rebasing", () => {
|
|
110
|
+
let harness: Harness;
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
harness = mountRazorSplit();
|
|
113
|
+
});
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
act(() => harness.root.unmount());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rebases an expanded sub-comp child split into the sub-comp's local time", async () => {
|
|
119
|
+
// Master split time T = 5; host starts at 2, so local time is 3.
|
|
120
|
+
await act(async () => {
|
|
121
|
+
await harness.singleRef.current!(expandedChild, 5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(harness.splitRequests).toHaveLength(1);
|
|
125
|
+
const req = harness.splitRequests[0];
|
|
126
|
+
expect(req.path).toBe(SUBCOMP_FILE);
|
|
127
|
+
expect(req.splitTime).toBe(3); // 5 - expandedParentStart(2), NOT 5
|
|
128
|
+
expect(req.elementStart).toBe(0); // 2 - 2, NOT the master start 2
|
|
129
|
+
expect(req.elementDuration).toBe(6);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("leaves a root-level clip's coordinates unchanged", async () => {
|
|
133
|
+
// Master split time T = 4; no expandedParentStart, so nothing is rebased.
|
|
134
|
+
await act(async () => {
|
|
135
|
+
await harness.singleRef.current!(rootElement, 4);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(harness.splitRequests).toHaveLength(1);
|
|
139
|
+
const req = harness.splitRequests[0];
|
|
140
|
+
expect(req.path).toBe(ROOT_FILE);
|
|
141
|
+
expect(req.splitTime).toBe(4);
|
|
142
|
+
expect(req.elementStart).toBe(0);
|
|
143
|
+
expect(req.elementDuration).toBe(10);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rebases each element individually in a mixed razor-split-all gesture", async () => {
|
|
147
|
+
usePlayerStore.setState({ elements: [rootElement, expandedChild] });
|
|
148
|
+
|
|
149
|
+
// Master split time T = 3 lies inside both clips.
|
|
150
|
+
await act(async () => {
|
|
151
|
+
await harness.allRef.current!(3);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(harness.splitRequests).toHaveLength(2);
|
|
155
|
+
const rootReq = harness.splitRequests.find((r) => r.path === ROOT_FILE)!;
|
|
156
|
+
const childReq = harness.splitRequests.find((r) => r.path === SUBCOMP_FILE)!;
|
|
157
|
+
|
|
158
|
+
// Root clip: already local — master coordinates pass through untouched.
|
|
159
|
+
expect(rootReq.splitTime).toBe(3);
|
|
160
|
+
expect(rootReq.elementStart).toBe(0);
|
|
161
|
+
|
|
162
|
+
// Expanded child: rebased by its OWN expandedParentStart, not the root's.
|
|
163
|
+
expect(childReq.splitTime).toBe(1); // 3 - 2
|
|
164
|
+
expect(childReq.elementStart).toBe(0); // 2 - 2
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Bug 1: split must resync the SDK session so undo isn't refused ────────────
|
|
169
|
+
|
|
170
|
+
const memoryStorage = (): EditHistoryStorageAdapter => {
|
|
171
|
+
const store = new Map<string, string>();
|
|
172
|
+
return {
|
|
173
|
+
load: async (k) => store.get(k) ?? null,
|
|
174
|
+
save: async (k, v) => {
|
|
175
|
+
store.set(k, v);
|
|
176
|
+
},
|
|
177
|
+
} as unknown as EditHistoryStorageAdapter;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
interface UndoHarness {
|
|
181
|
+
singleRef: { current: SingleSplit | undefined };
|
|
182
|
+
disk: Record<string, string>;
|
|
183
|
+
store: ReturnType<typeof createPersistentEditHistoryStore>;
|
|
184
|
+
forceReloadSdkSession: ReturnType<typeof vi.fn>;
|
|
185
|
+
root: ReturnType<typeof mountProbe>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mountRazorSplitWithHistory(): UndoHarness {
|
|
189
|
+
const disk: Record<string, string> = {
|
|
190
|
+
[ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
|
|
191
|
+
};
|
|
192
|
+
const store = createPersistentEditHistoryStore({
|
|
193
|
+
projectId: "p1",
|
|
194
|
+
storage: memoryStorage(),
|
|
195
|
+
initialState: createEmptyEditHistory(),
|
|
196
|
+
now: () => Date.now(),
|
|
197
|
+
onChange: () => {},
|
|
198
|
+
});
|
|
199
|
+
const forceReloadSdkSession = vi.fn();
|
|
200
|
+
|
|
201
|
+
const fetchMock = createSplitFetchMock(disk);
|
|
202
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
203
|
+
|
|
204
|
+
const singleRef: { current: SingleSplit | undefined } = { current: undefined };
|
|
205
|
+
function Component() {
|
|
206
|
+
const { handleRazorSplit } = useRazorSplit({
|
|
207
|
+
projectId: "p1",
|
|
208
|
+
activeCompPath: ROOT_FILE,
|
|
209
|
+
showToast: () => {},
|
|
210
|
+
writeProjectFile: async (path, content) => {
|
|
211
|
+
disk[path] = content;
|
|
212
|
+
},
|
|
213
|
+
recordEdit: (input) => store.recordEdit(input),
|
|
214
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
215
|
+
reloadPreview: () => {},
|
|
216
|
+
forceReloadSdkSession,
|
|
217
|
+
});
|
|
218
|
+
singleRef.current = handleRazorSplit;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const root = mountProbe(Component);
|
|
222
|
+
return { singleRef, disk, store, forceReloadSdkSession, root };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
|
|
226
|
+
let h: UndoHarness;
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
h = mountRazorSplitWithHistory();
|
|
229
|
+
});
|
|
230
|
+
afterEach(() => {
|
|
231
|
+
act(() => h.root.unmount());
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const readFile = () => ({
|
|
235
|
+
readFile: async (p: string) => h.disk[p],
|
|
236
|
+
writeFile: async (p: string, c: string) => {
|
|
237
|
+
h.disk[p] = c;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("resyncs the SDK session after a split (matches every other server-write path)", async () => {
|
|
242
|
+
await act(async () => {
|
|
243
|
+
await h.singleRef.current!(rootElement, 4);
|
|
244
|
+
});
|
|
245
|
+
expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("applies undo after a split without an external-change refusal", async () => {
|
|
249
|
+
await act(async () => {
|
|
250
|
+
await h.singleRef.current!(rootElement, 4);
|
|
251
|
+
});
|
|
252
|
+
const result = await h.store.undo(readFile());
|
|
253
|
+
expect(result.ok).toBe(true);
|
|
254
|
+
expect(result.reason).toBeUndefined();
|
|
255
|
+
// The file is restored to its pre-split bytes.
|
|
256
|
+
expect(h.disk[ROOT_FILE]).not.toContain("<!--split-->");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("still trips the guard when the file is edited externally after a split", async () => {
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await h.singleRef.current!(rootElement, 4);
|
|
262
|
+
});
|
|
263
|
+
// Simulate the user editing the file in their own editor after the split.
|
|
264
|
+
h.disk[ROOT_FILE] = `${h.disk[ROOT_FILE]}<!--hand-edit-->`;
|
|
265
|
+
const result = await h.store.undo(readFile());
|
|
266
|
+
expect(result.ok).toBe(false);
|
|
267
|
+
expect(result.reason).toBe("content-mismatch");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { act } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
export interface SplitBody {
|
|
6
|
+
splitTime: number;
|
|
7
|
+
elementStart: number;
|
|
8
|
+
elementDuration: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function decodePathFromUrl(url: string, marker: string): string {
|
|
12
|
+
const encoded = url.slice(url.indexOf(marker) + marker.length);
|
|
13
|
+
return decodeURIComponent(encoded);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures),
|
|
18
|
+
* split-element writes a `<!--split-->` marker so `changed` is true, and file reads
|
|
19
|
+
* echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
|
|
20
|
+
*/
|
|
21
|
+
export function createSplitFetchMock(
|
|
22
|
+
disk: Record<string, string>,
|
|
23
|
+
onSplit?: (path: string, body: SplitBody) => void,
|
|
24
|
+
) {
|
|
25
|
+
return vi.fn(async (url: string, init?: RequestInit) => {
|
|
26
|
+
const u = String(url);
|
|
27
|
+
if (u.includes("/gsap-mutations/")) {
|
|
28
|
+
// No GSAP script in the fixtures — mirror the server's 400 response.
|
|
29
|
+
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
|
|
30
|
+
status: 400,
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (u.includes("/file-mutations/split-element/")) {
|
|
35
|
+
const path = decodePathFromUrl(u, "/file-mutations/split-element/");
|
|
36
|
+
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
|
|
37
|
+
// Return content that differs from the original so `changed` is true.
|
|
38
|
+
const after = `${disk[path]}<!--split-->`;
|
|
39
|
+
disk[path] = after; // server writes the split to disk
|
|
40
|
+
return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
|
|
41
|
+
status: 200,
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (u.includes("/files/")) {
|
|
46
|
+
const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
|
|
47
|
+
return new Response(JSON.stringify({ content: disk[path] ?? "" }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
void init;
|
|
53
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mount a render-only probe component into a fresh host and return its root. */
|
|
58
|
+
export function mountProbe(Component: React.ComponentType): ReturnType<typeof createRoot> {
|
|
59
|
+
const host = document.createElement("div");
|
|
60
|
+
document.body.append(host);
|
|
61
|
+
const root = createRoot(host);
|
|
62
|
+
act(() => root.render(React.createElement(Component)));
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
@@ -20,6 +20,14 @@ interface UseRazorSplitOptions {
|
|
|
20
20
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
21
21
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
22
22
|
reloadPreview: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Resync the in-memory SDK session after the server-side split write (the
|
|
25
|
+
* split-element / split-gsap endpoints write the file directly, so the SDK's
|
|
26
|
+
* linkedom doc is now stale). This reload is read-only; the split endpoint owns
|
|
27
|
+
* the final on-disk bytes and history baseline. Every other server-side-write
|
|
28
|
+
* timeline path (move / resize / delete / drop / visibility) also resyncs.
|
|
29
|
+
*/
|
|
30
|
+
forceReloadSdkSession?: () => void;
|
|
23
31
|
isRecordingRef?: React.RefObject<boolean>;
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -101,6 +109,62 @@ async function splitGsapAnimations(
|
|
|
101
109
|
};
|
|
102
110
|
}
|
|
103
111
|
|
|
112
|
+
function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
|
|
113
|
+
const original = originals.get(path);
|
|
114
|
+
if (original === undefined) {
|
|
115
|
+
throw new Error(`Missing original contents for ${path}`);
|
|
116
|
+
}
|
|
117
|
+
return original;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function restoreFilesToOriginal(
|
|
121
|
+
originals: ReadonlyMap<string, string>,
|
|
122
|
+
paths: Iterable<string>,
|
|
123
|
+
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
for (const path of paths) {
|
|
126
|
+
await writeProjectFile(path, getOriginalContent(originals, path));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readOriginalFiles(
|
|
131
|
+
pid: string,
|
|
132
|
+
elements: TimelineElement[],
|
|
133
|
+
activeCompPath: string | null,
|
|
134
|
+
): Promise<Map<string, string>> {
|
|
135
|
+
const originals = new Map<string, string>();
|
|
136
|
+
for (const element of elements) {
|
|
137
|
+
const path = element.sourceFile || activeCompPath || "index.html";
|
|
138
|
+
if (!originals.has(path)) {
|
|
139
|
+
originals.set(path, await readFileContent(pid, path));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return originals;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function splitElementsAtTime(
|
|
146
|
+
pid: string,
|
|
147
|
+
elements: TimelineElement[],
|
|
148
|
+
splitTime: number,
|
|
149
|
+
activeCompPath: string | null,
|
|
150
|
+
originals: ReadonlyMap<string, string>,
|
|
151
|
+
snapshots: Map<string, { before: string; after: string }>,
|
|
152
|
+
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
153
|
+
): Promise<number> {
|
|
154
|
+
let count = 0;
|
|
155
|
+
for (const element of elements) {
|
|
156
|
+
const result = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
|
|
157
|
+
if (!result.changed) continue;
|
|
158
|
+
snapshots.set(result.targetPath, {
|
|
159
|
+
before: getOriginalContent(originals, result.targetPath),
|
|
160
|
+
after: result.patchedContent,
|
|
161
|
+
});
|
|
162
|
+
await writeProjectFile(result.targetPath, result.patchedContent);
|
|
163
|
+
count++;
|
|
164
|
+
}
|
|
165
|
+
return count;
|
|
166
|
+
}
|
|
167
|
+
|
|
104
168
|
// fallow-ignore-next-line complexity
|
|
105
169
|
async function executeSplit(
|
|
106
170
|
pid: string,
|
|
@@ -122,13 +186,23 @@ async function executeSplit(
|
|
|
122
186
|
const originalContent = await readFileContent(pid, targetPath);
|
|
123
187
|
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
|
|
124
188
|
|
|
189
|
+
// An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
|
|
190
|
+
// `start` and the incoming `splitTime` are offset by the host's master start
|
|
191
|
+
// (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
|
|
192
|
+
// authored in LOCAL time. Rebase both onto local time before the server patches
|
|
193
|
+
// the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
|
|
194
|
+
// Root-level clips (no expandedParentStart) are already local, so pass through.
|
|
195
|
+
const basis = element.expandedParentStart;
|
|
196
|
+
const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
|
|
197
|
+
const localElementStart = basis === undefined ? element.start : element.start - basis;
|
|
198
|
+
|
|
125
199
|
const splitResult = await splitHtmlElement(
|
|
126
200
|
pid,
|
|
127
201
|
targetPath,
|
|
128
202
|
patchTarget,
|
|
129
|
-
|
|
203
|
+
localSplitTime,
|
|
130
204
|
newId,
|
|
131
|
-
|
|
205
|
+
localElementStart,
|
|
132
206
|
element.duration,
|
|
133
207
|
);
|
|
134
208
|
if (!splitResult.ok) throw new Error("Failed to split clip.");
|
|
@@ -147,8 +221,8 @@ async function executeSplit(
|
|
|
147
221
|
targetPath,
|
|
148
222
|
element.domId,
|
|
149
223
|
newId,
|
|
150
|
-
|
|
151
|
-
|
|
224
|
+
localSplitTime,
|
|
225
|
+
localElementStart,
|
|
152
226
|
element.duration,
|
|
153
227
|
);
|
|
154
228
|
if (gsapResult.content) patchedContent = gsapResult.content;
|
|
@@ -172,6 +246,7 @@ export function useRazorSplit({
|
|
|
172
246
|
recordEdit,
|
|
173
247
|
domEditSaveTimestampRef,
|
|
174
248
|
reloadPreview,
|
|
249
|
+
forceReloadSdkSession,
|
|
175
250
|
isRecordingRef,
|
|
176
251
|
}: UseRazorSplitOptions) {
|
|
177
252
|
const projectIdRef = useRef(projectId);
|
|
@@ -208,6 +283,9 @@ export function useRazorSplit({
|
|
|
208
283
|
recordEdit,
|
|
209
284
|
});
|
|
210
285
|
|
|
286
|
+
// Server writes bypass the SDK session, so reopen it before refreshing
|
|
287
|
+
// the preview. The split response already owns the final persisted bytes.
|
|
288
|
+
forceReloadSdkSession?.();
|
|
211
289
|
reloadPreview();
|
|
212
290
|
trackStudioRazorSplit({ mode: "single", count: 1 });
|
|
213
291
|
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
|
@@ -229,6 +307,7 @@ export function useRazorSplit({
|
|
|
229
307
|
writeProjectFile,
|
|
230
308
|
domEditSaveTimestampRef,
|
|
231
309
|
reloadPreview,
|
|
310
|
+
forceReloadSdkSession,
|
|
232
311
|
isRecordingRef,
|
|
233
312
|
],
|
|
234
313
|
);
|
|
@@ -247,51 +326,42 @@ export function useRazorSplit({
|
|
|
247
326
|
const splittable = selectSplittableElements(elements, splitTime);
|
|
248
327
|
if (splittable.length === 0) return;
|
|
249
328
|
|
|
329
|
+
let originals = new Map<string, string>();
|
|
330
|
+
const finalSnapshots = new Map<string, { before: string; after: string }>();
|
|
250
331
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
for (const element of splittable) {
|
|
263
|
-
const result = await executeSplit(
|
|
264
|
-
pid,
|
|
265
|
-
element,
|
|
266
|
-
splitTime,
|
|
267
|
-
activeCompPath,
|
|
268
|
-
writeProjectFile,
|
|
269
|
-
);
|
|
270
|
-
if (result.changed) {
|
|
271
|
-
finalContent.set(result.targetPath, result.patchedContent);
|
|
272
|
-
await writeProjectFile(result.targetPath, result.patchedContent);
|
|
273
|
-
splitCount++;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
332
|
+
originals = await readOriginalFiles(pid, splittable, activeCompPath);
|
|
333
|
+
const splitCount = await splitElementsAtTime(
|
|
334
|
+
pid,
|
|
335
|
+
splittable,
|
|
336
|
+
splitTime,
|
|
337
|
+
activeCompPath,
|
|
338
|
+
originals,
|
|
339
|
+
finalSnapshots,
|
|
340
|
+
writeProjectFile,
|
|
341
|
+
);
|
|
277
342
|
if (splitCount === 0) return;
|
|
278
343
|
|
|
279
344
|
domEditSaveTimestampRef.current = Date.now();
|
|
280
345
|
await recordEdit({
|
|
281
346
|
label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
|
|
282
347
|
kind: "timeline",
|
|
283
|
-
files: Object.fromEntries(
|
|
284
|
-
[...finalContent].map(([path, after]) => [
|
|
285
|
-
path,
|
|
286
|
-
{ before: originals.get(path) ?? "", after },
|
|
287
|
-
]),
|
|
288
|
-
),
|
|
348
|
+
files: Object.fromEntries(finalSnapshots),
|
|
289
349
|
});
|
|
290
350
|
|
|
351
|
+
// Resync the stale SDK doc after the batched server write (see the
|
|
352
|
+
// single-split path above for why this precedes the reload).
|
|
353
|
+
forceReloadSdkSession?.();
|
|
291
354
|
reloadPreview();
|
|
292
355
|
trackStudioRazorSplit({ mode: "all", count: splitCount });
|
|
293
356
|
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
|
294
357
|
} catch (error) {
|
|
358
|
+
// Best-effort rollback — a failing restore write must not swallow the
|
|
359
|
+
// original error's toast, which is what tells the user the split failed.
|
|
360
|
+
try {
|
|
361
|
+
await restoreFilesToOriginal(originals, finalSnapshots.keys(), writeProjectFile);
|
|
362
|
+
} catch {
|
|
363
|
+
/* leave disk as-is; the original failure is reported below */
|
|
364
|
+
}
|
|
295
365
|
const message = error instanceof Error ? error.message : "Failed to split clips";
|
|
296
366
|
showToast(message, "error");
|
|
297
367
|
}
|
|
@@ -303,6 +373,7 @@ export function useRazorSplit({
|
|
|
303
373
|
writeProjectFile,
|
|
304
374
|
domEditSaveTimestampRef,
|
|
305
375
|
reloadPreview,
|
|
376
|
+
forceReloadSdkSession,
|
|
306
377
|
isRecordingRef,
|
|
307
378
|
],
|
|
308
379
|
);
|
|
@@ -3,6 +3,8 @@ import { createElement } from "react";
|
|
|
3
3
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
4
4
|
import type { TimelineElement } from "../player";
|
|
5
5
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
6
|
+
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
7
|
+
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
6
8
|
|
|
7
9
|
export function normalizeCompositionSrc(
|
|
8
10
|
compSrc: string,
|
|
@@ -51,8 +53,16 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
|
|
|
51
53
|
*/
|
|
52
54
|
function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
|
|
53
55
|
const srcRelative = resolvePreviewRelative(el.src, pid);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
// Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
|
|
57
|
+
// what the assets panel loads — a raw segment 404s. resolvePreviewRelative
|
|
58
|
+
// returns the DECODED path, so it must be re-encoded here.
|
|
59
|
+
const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
|
|
60
|
+
const audioUrl = encodedRelative
|
|
61
|
+
? `/api/projects/${pid}/preview/${encodedRelative}`
|
|
62
|
+
: (el.src ?? "");
|
|
63
|
+
const waveformUrl = encodedRelative
|
|
64
|
+
? `/api/projects/${pid}/waveform/${encodedRelative}`
|
|
65
|
+
: undefined;
|
|
56
66
|
const { start, end } = trimFractions(el);
|
|
57
67
|
return createElement(AudioWaveform, {
|
|
58
68
|
audioUrl,
|
|
@@ -100,7 +110,7 @@ export function useRenderClipContent({
|
|
|
100
110
|
// instead of capturing the master at a time when the comp is fading in.
|
|
101
111
|
if (compSrc) {
|
|
102
112
|
return createElement(CompositionThumbnail, {
|
|
103
|
-
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
|
|
113
|
+
previewUrl: `/api/projects/${pid}/preview/comp/${encodePreviewPath(compSrc)}`,
|
|
104
114
|
label: "",
|
|
105
115
|
labelColor: style.label,
|
|
106
116
|
|
|
@@ -138,9 +148,17 @@ export function useRenderClipContent({
|
|
|
138
148
|
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
|
139
149
|
|
|
140
150
|
if ((el.tag === "video" || el.tag === "img") && el.src) {
|
|
141
|
-
const mediaSrc = el.src
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
const mediaSrc = resolveMediaPreviewUrl(el.src, pid);
|
|
152
|
+
// Still images can't be decoded by VideoThumbnail's <video> extractor
|
|
153
|
+
// (the error event fires and the shimmer never resolves) — render the
|
|
154
|
+
// image itself as the strip.
|
|
155
|
+
if (el.tag === "img") {
|
|
156
|
+
return createElement(ImageThumbnail, {
|
|
157
|
+
imageSrc: mediaSrc,
|
|
158
|
+
label: "",
|
|
159
|
+
labelColor: style.label,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
144
162
|
return createElement(VideoThumbnail, {
|
|
145
163
|
videoSrc: mediaSrc,
|
|
146
164
|
label: "",
|
|
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
|
|
|
2
2
|
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
3
3
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
4
4
|
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
|
5
|
+
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
|
|
6
|
+
import { usePlayerStore } from "../player";
|
|
5
7
|
|
|
6
8
|
interface StudioContextInput {
|
|
7
9
|
projectId: string;
|
|
@@ -34,8 +36,6 @@ interface StudioContextInput {
|
|
|
34
36
|
waitForPendingDomEditSaves: () => Promise<void>;
|
|
35
37
|
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
|
|
36
38
|
refreshPreviewDocumentVersion: () => void;
|
|
37
|
-
timelineVisible: boolean;
|
|
38
|
-
toggleTimelineVisibility: () => void;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// fallow-ignore-next-line complexity
|
|
@@ -61,8 +61,6 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
|
|
|
61
61
|
waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
|
|
62
62
|
handlePreviewIframeRef: input.handlePreviewIframeRef,
|
|
63
63
|
refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
|
|
64
|
-
timelineVisible: input.timelineVisible,
|
|
65
|
-
toggleTimelineVisibility: input.toggleTimelineVisibility,
|
|
66
64
|
};
|
|
67
65
|
}
|
|
68
66
|
|
|
@@ -108,7 +106,7 @@ export function useInspectorState(
|
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
// fallow-ignore-next-line complexity
|
|
111
|
-
|
|
109
|
+
function useDragOverlay(onImportFiles: (files: FileList) => void) {
|
|
112
110
|
const [active, setActive] = useState(false);
|
|
113
111
|
const counterRef = useRef(0);
|
|
114
112
|
const onDragOver = useCallback((e: DragEvent) => {
|
|
@@ -137,3 +135,15 @@ export function useDragOverlay(onImportFiles: (files: FileList) => void) {
|
|
|
137
135
|
);
|
|
138
136
|
return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
|
|
139
137
|
}
|
|
138
|
+
|
|
139
|
+
/** Global OS file drop: imports and places at the playhead position. */
|
|
140
|
+
export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) {
|
|
141
|
+
const onDrop = useCallback(
|
|
142
|
+
(files: FileList) => {
|
|
143
|
+
const start = usePlayerStore.getState().currentTime;
|
|
144
|
+
void handleTimelineFileDrop(Array.from(files), { start, track: 0 });
|
|
145
|
+
},
|
|
146
|
+
[handleTimelineFileDrop],
|
|
147
|
+
);
|
|
148
|
+
return useDragOverlay(onDrop);
|
|
149
|
+
}
|
|
@@ -20,7 +20,6 @@ interface UseStudioUrlStateParams {
|
|
|
20
20
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
21
21
|
rightPanelTab: RightPanelTab;
|
|
22
22
|
rightCollapsed: boolean;
|
|
23
|
-
timelineVisible: boolean;
|
|
24
23
|
activeCompPathHydrated: boolean;
|
|
25
24
|
domEditSelection: DomEditSelection | null;
|
|
26
25
|
buildDomSelectionFromTarget: (
|
|
@@ -66,7 +65,6 @@ export function useStudioUrlState({
|
|
|
66
65
|
previewIframeRef,
|
|
67
66
|
rightPanelTab,
|
|
68
67
|
rightCollapsed,
|
|
69
|
-
timelineVisible,
|
|
70
68
|
activeCompPathHydrated,
|
|
71
69
|
domEditSelection,
|
|
72
70
|
buildDomSelectionFromTarget,
|
|
@@ -91,12 +89,12 @@ export function useStudioUrlState({
|
|
|
91
89
|
currentTime: stableTimeRef.current,
|
|
92
90
|
rightPanelTab,
|
|
93
91
|
rightCollapsed,
|
|
94
|
-
timelineVisible,
|
|
92
|
+
timelineVisible: null,
|
|
95
93
|
selection: hydratedSelectionRef.current
|
|
96
94
|
? toPersistedSelection(domEditSelection)
|
|
97
95
|
: pendingSelectionRef.current,
|
|
98
96
|
}),
|
|
99
|
-
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab
|
|
97
|
+
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab],
|
|
100
98
|
);
|
|
101
99
|
|
|
102
100
|
// Resolve a URL selection to a live element and apply it. Shared by the initial
|