@hyperframes/studio 0.7.61 → 0.7.62
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-Xvx2hkrc.js → hyperframes-player-CWpmBjzv.js} +1 -1
- package/dist/assets/{index-BCpoiv9S.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-D-uFclFj.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3504 -2955
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -10
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -0
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +18 -1
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/index-2mxh_HSy.js +0 -426
- package/dist/assets/index-BhWig0mx.css +0 -1
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
|
-
import {
|
|
5
|
-
import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
|
|
4
|
+
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
6
5
|
import { trackStudioRazorSplit } from "../telemetry/events";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
selectSplittableElements,
|
|
10
|
-
buildPatchTarget,
|
|
11
|
-
readFileContent,
|
|
12
|
-
} from "../utils/timelineElementSplit";
|
|
6
|
+
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
|
|
7
|
+
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
|
|
13
8
|
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
14
9
|
|
|
15
10
|
interface UseRazorSplitOptions {
|
|
@@ -21,246 +16,10 @@ interface UseRazorSplitOptions {
|
|
|
21
16
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
22
17
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
23
18
|
reloadPreview: () => void;
|
|
24
|
-
/**
|
|
25
|
-
* Resync the in-memory SDK session after the server-side split write (the
|
|
26
|
-
* split-element / split-gsap endpoints write the file directly, so the SDK's
|
|
27
|
-
* linkedom doc is now stale). This reload is read-only; the split endpoint owns
|
|
28
|
-
* the final on-disk bytes and history baseline. Every other server-side-write
|
|
29
|
-
* timeline path (move / resize / delete / drop / visibility) also resyncs.
|
|
30
|
-
*/
|
|
31
19
|
forceReloadSdkSession?: () => void;
|
|
32
20
|
isRecordingRef?: React.RefObject<boolean>;
|
|
33
21
|
}
|
|
34
22
|
|
|
35
|
-
function generateSplitId(existingIds: string[], baseId: string): string {
|
|
36
|
-
let newId = `${baseId}-split`;
|
|
37
|
-
let suffix = 2;
|
|
38
|
-
while (existingIds.includes(newId)) {
|
|
39
|
-
newId = `${baseId}-split-${suffix++}`;
|
|
40
|
-
}
|
|
41
|
-
return newId;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function splitHtmlElement(
|
|
45
|
-
projectId: string,
|
|
46
|
-
targetPath: string,
|
|
47
|
-
patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
|
|
48
|
-
splitTime: number,
|
|
49
|
-
newId: string,
|
|
50
|
-
elementStart: number,
|
|
51
|
-
elementDuration: number,
|
|
52
|
-
): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
|
|
53
|
-
const response = await fetch(
|
|
54
|
-
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
|
55
|
-
{
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: { "Content-Type": "application/json" },
|
|
58
|
-
body: JSON.stringify({
|
|
59
|
-
target: patchTarget,
|
|
60
|
-
splitTime,
|
|
61
|
-
newId,
|
|
62
|
-
elementStart,
|
|
63
|
-
elementDuration,
|
|
64
|
-
}),
|
|
65
|
-
},
|
|
66
|
-
);
|
|
67
|
-
if (!response.ok) throw new Error("Split request failed");
|
|
68
|
-
const data = (await response.json()) as {
|
|
69
|
-
ok: boolean;
|
|
70
|
-
changed?: boolean;
|
|
71
|
-
content?: string;
|
|
72
|
-
version?: string;
|
|
73
|
-
};
|
|
74
|
-
const version = data.version ?? response.headers.get("etag");
|
|
75
|
-
if (!version) throw new Error("Split response did not include a content version");
|
|
76
|
-
return { ...data, version };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// fallow-ignore-next-line complexity
|
|
80
|
-
async function splitGsapAnimations(
|
|
81
|
-
projectId: string,
|
|
82
|
-
targetPath: string,
|
|
83
|
-
originalId: string,
|
|
84
|
-
newId: string,
|
|
85
|
-
splitTime: number,
|
|
86
|
-
elementStart: number,
|
|
87
|
-
elementDuration: number,
|
|
88
|
-
): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
|
|
89
|
-
const response = await fetch(
|
|
90
|
-
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
|
91
|
-
{
|
|
92
|
-
method: "POST",
|
|
93
|
-
headers: { "Content-Type": "application/json" },
|
|
94
|
-
body: JSON.stringify({
|
|
95
|
-
type: "split-animations",
|
|
96
|
-
originalId,
|
|
97
|
-
newId,
|
|
98
|
-
splitTime,
|
|
99
|
-
elementStart,
|
|
100
|
-
elementDuration,
|
|
101
|
-
}),
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
if (!response.ok) {
|
|
105
|
-
const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
106
|
-
if (errorBody?.error === "no GSAP script found in file") {
|
|
107
|
-
return { content: null };
|
|
108
|
-
}
|
|
109
|
-
throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
|
|
110
|
-
}
|
|
111
|
-
const data = (await response.json()) as {
|
|
112
|
-
ok?: boolean;
|
|
113
|
-
after?: string;
|
|
114
|
-
version?: string;
|
|
115
|
-
skippedSelectors?: string[];
|
|
116
|
-
};
|
|
117
|
-
return {
|
|
118
|
-
content: data.ok && data.after ? data.after : null,
|
|
119
|
-
version: data.version ?? response.headers.get("etag") ?? undefined,
|
|
120
|
-
skippedSelectors: data.skippedSelectors,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
|
|
125
|
-
const original = originals.get(path);
|
|
126
|
-
if (original === undefined) {
|
|
127
|
-
throw new Error(`Missing original contents for ${path}`);
|
|
128
|
-
}
|
|
129
|
-
return original;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
async function restoreFilesToOriginal(
|
|
133
|
-
originals: ReadonlyMap<string, string>,
|
|
134
|
-
snapshots: ReadonlyMap<string, { before: string; after: string }>,
|
|
135
|
-
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
136
|
-
): Promise<void> {
|
|
137
|
-
for (const [path, snapshot] of snapshots) {
|
|
138
|
-
await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function readOriginalFiles(
|
|
143
|
-
pid: string,
|
|
144
|
-
elements: TimelineElement[],
|
|
145
|
-
activeCompPath: string | null,
|
|
146
|
-
): Promise<Map<string, string>> {
|
|
147
|
-
const originals = new Map<string, string>();
|
|
148
|
-
for (const element of elements) {
|
|
149
|
-
const path = element.sourceFile || activeCompPath || "index.html";
|
|
150
|
-
if (!originals.has(path)) {
|
|
151
|
-
originals.set(path, await readFileContent(pid, path));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return originals;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function splitElementsAtTime(
|
|
158
|
-
pid: string,
|
|
159
|
-
elements: TimelineElement[],
|
|
160
|
-
splitTime: number,
|
|
161
|
-
activeCompPath: string | null,
|
|
162
|
-
originals: ReadonlyMap<string, string>,
|
|
163
|
-
snapshots: Map<string, { before: string; after: string }>,
|
|
164
|
-
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
165
|
-
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
166
|
-
): Promise<number> {
|
|
167
|
-
let count = 0;
|
|
168
|
-
for (const element of elements) {
|
|
169
|
-
const result = await executeSplit(
|
|
170
|
-
pid,
|
|
171
|
-
element,
|
|
172
|
-
splitTime,
|
|
173
|
-
activeCompPath,
|
|
174
|
-
writeProjectFile,
|
|
175
|
-
observeProjectFileVersion,
|
|
176
|
-
);
|
|
177
|
-
if (!result.changed) continue;
|
|
178
|
-
snapshots.set(result.targetPath, {
|
|
179
|
-
before: getOriginalContent(originals, result.targetPath),
|
|
180
|
-
after: result.patchedContent,
|
|
181
|
-
});
|
|
182
|
-
await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
|
|
183
|
-
count++;
|
|
184
|
-
}
|
|
185
|
-
return count;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// fallow-ignore-next-line complexity
|
|
189
|
-
async function executeSplit(
|
|
190
|
-
pid: string,
|
|
191
|
-
element: TimelineElement,
|
|
192
|
-
splitTime: number,
|
|
193
|
-
activeCompPath: string | null,
|
|
194
|
-
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
195
|
-
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
196
|
-
): Promise<{
|
|
197
|
-
targetPath: string;
|
|
198
|
-
originalContent: string;
|
|
199
|
-
patchedContent: string;
|
|
200
|
-
changed: boolean;
|
|
201
|
-
skippedSelectors?: string[];
|
|
202
|
-
}> {
|
|
203
|
-
const patchTarget = buildPatchTarget(element);
|
|
204
|
-
if (!patchTarget) throw new Error("Clip is missing a patchable target.");
|
|
205
|
-
|
|
206
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
207
|
-
const originalContent = await readFileContent(pid, targetPath);
|
|
208
|
-
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
|
|
209
|
-
|
|
210
|
-
// An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
|
|
211
|
-
// `start` and the incoming `splitTime` are offset by the host's master start
|
|
212
|
-
// (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
|
|
213
|
-
// authored in LOCAL time. Rebase both onto local time before the server patches
|
|
214
|
-
// the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
|
|
215
|
-
// Root-level clips (no expandedParentStart) are already local, so pass through.
|
|
216
|
-
const basis = element.expandedParentStart;
|
|
217
|
-
const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
|
|
218
|
-
const localElementStart = basis === undefined ? element.start : element.start - basis;
|
|
219
|
-
|
|
220
|
-
const splitResult = await splitHtmlElement(
|
|
221
|
-
pid,
|
|
222
|
-
targetPath,
|
|
223
|
-
patchTarget,
|
|
224
|
-
localSplitTime,
|
|
225
|
-
newId,
|
|
226
|
-
localElementStart,
|
|
227
|
-
element.duration,
|
|
228
|
-
);
|
|
229
|
-
if (!splitResult.ok) throw new Error("Failed to split clip.");
|
|
230
|
-
if (!splitResult.changed) {
|
|
231
|
-
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
|
232
|
-
}
|
|
233
|
-
observeProjectFileVersion?.(targetPath, splitResult.version);
|
|
234
|
-
|
|
235
|
-
let patchedContent =
|
|
236
|
-
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
|
237
|
-
|
|
238
|
-
let skippedSelectors: string[] | undefined;
|
|
239
|
-
if (element.domId) {
|
|
240
|
-
try {
|
|
241
|
-
const gsapResult = await splitGsapAnimations(
|
|
242
|
-
pid,
|
|
243
|
-
targetPath,
|
|
244
|
-
element.domId,
|
|
245
|
-
newId,
|
|
246
|
-
localSplitTime,
|
|
247
|
-
localElementStart,
|
|
248
|
-
element.duration,
|
|
249
|
-
);
|
|
250
|
-
if (gsapResult.content) patchedContent = gsapResult.content;
|
|
251
|
-
if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
|
|
252
|
-
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
|
253
|
-
} catch (gsapError) {
|
|
254
|
-
// GSAP mutation failed — the HTML split already wrote to disk.
|
|
255
|
-
// Restore the original content to avoid a corrupt half-split state.
|
|
256
|
-
await writeProjectFile(targetPath, originalContent, patchedContent);
|
|
257
|
-
throw gsapError;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
23
|
export function useRazorSplit({
|
|
265
24
|
projectId,
|
|
266
25
|
activeCompPath,
|
|
@@ -276,140 +35,109 @@ export function useRazorSplit({
|
|
|
276
35
|
const projectIdRef = useRef(projectId);
|
|
277
36
|
projectIdRef.current = projectId;
|
|
278
37
|
|
|
38
|
+
const synchronize = useCallback(() => {
|
|
39
|
+
let failure: unknown;
|
|
40
|
+
try {
|
|
41
|
+
forceReloadSdkSession?.();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
failure = error;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
reloadPreview();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
failure ??= error;
|
|
49
|
+
}
|
|
50
|
+
if (failure) throw failure;
|
|
51
|
+
}, [forceReloadSdkSession, reloadPreview]);
|
|
52
|
+
|
|
53
|
+
const runCut = useCallback(
|
|
54
|
+
async (elements: readonly TimelineElement[], splitTime: number, mode: "single" | "all") => {
|
|
55
|
+
const pid = projectIdRef.current;
|
|
56
|
+
if (!pid || elements.length === 0) return;
|
|
57
|
+
const intents = buildAtomicCutIntents(elements, splitTime, activeCompPath);
|
|
58
|
+
const requestedCount = intents.reduce((count, file) => count + file.targets.length, 0);
|
|
59
|
+
const label =
|
|
60
|
+
mode === "single"
|
|
61
|
+
? "Split timeline clip"
|
|
62
|
+
: `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
|
|
63
|
+
|
|
64
|
+
// Server writes arrive through the watcher before React can refresh. Keep
|
|
65
|
+
// the existing short self-write window active for this owned transaction.
|
|
66
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
67
|
+
const result = await runAtomicCutTransaction({
|
|
68
|
+
projectId: pid,
|
|
69
|
+
intents,
|
|
70
|
+
label,
|
|
71
|
+
writeProjectFile,
|
|
72
|
+
recordEdit,
|
|
73
|
+
observeProjectFileVersion,
|
|
74
|
+
synchronize,
|
|
75
|
+
});
|
|
76
|
+
trackStudioRazorSplit({ mode, count: result.splitCount });
|
|
77
|
+
if (result.syncFailed) {
|
|
78
|
+
showToast(
|
|
79
|
+
"Cut was saved, but Studio could not refresh it. Reload the preview to resynchronize.",
|
|
80
|
+
"error",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (result.skippedSelectors.length > 0) {
|
|
84
|
+
showToast(
|
|
85
|
+
`Some animations use non-ID selectors (${result.skippedSelectors.join(", ")}) and were not retargeted`,
|
|
86
|
+
"info",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
},
|
|
91
|
+
[
|
|
92
|
+
activeCompPath,
|
|
93
|
+
domEditSaveTimestampRef,
|
|
94
|
+
observeProjectFileVersion,
|
|
95
|
+
recordEdit,
|
|
96
|
+
showToast,
|
|
97
|
+
synchronize,
|
|
98
|
+
writeProjectFile,
|
|
99
|
+
],
|
|
100
|
+
);
|
|
101
|
+
|
|
279
102
|
const handleRazorSplit = useCallback(
|
|
280
|
-
// fallow-ignore-next-line complexity
|
|
281
103
|
async (element: TimelineElement, splitTime: number) => {
|
|
282
104
|
if (isRecordingRef?.current) {
|
|
283
105
|
showToast("Cannot edit timeline while recording", "error");
|
|
284
106
|
return;
|
|
285
107
|
}
|
|
286
|
-
|
|
287
|
-
const pid = projectIdRef.current;
|
|
288
|
-
if (!pid || !canSplitElementAt(element, splitTime)) return;
|
|
289
|
-
|
|
108
|
+
if (!canSplitElementAt(element, splitTime)) return;
|
|
290
109
|
try {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
element,
|
|
295
|
-
splitTime,
|
|
296
|
-
activeCompPath,
|
|
297
|
-
writeProjectFile,
|
|
298
|
-
observeProjectFileVersion,
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
if (!changed) {
|
|
302
|
-
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
307
|
-
await saveProjectFilesWithHistory({
|
|
308
|
-
projectId: pid,
|
|
309
|
-
label: "Split timeline clip",
|
|
310
|
-
kind: "timeline",
|
|
311
|
-
files: { [targetPath]: patchedContent },
|
|
312
|
-
readFile: async () => originalContent,
|
|
313
|
-
writeFile: writeProjectFile,
|
|
314
|
-
recordEdit,
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
// Server writes bypass the SDK session, so reopen it before refreshing
|
|
318
|
-
// the preview. The split response already owns the final persisted bytes.
|
|
319
|
-
forceReloadSdkSession?.();
|
|
320
|
-
reloadPreview();
|
|
321
|
-
trackStudioRazorSplit({ mode: "single", count: 1 });
|
|
110
|
+
const result = await runCut([element], splitTime, "single");
|
|
111
|
+
if (!result) return;
|
|
112
|
+
if (result.syncFailed) return;
|
|
322
113
|
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
|
323
|
-
if (skippedSelectors?.length) {
|
|
324
|
-
showToast(
|
|
325
|
-
`Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
|
|
326
|
-
"info",
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
114
|
} catch (error) {
|
|
330
115
|
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
|
|
331
116
|
showToast(message, "error");
|
|
332
117
|
}
|
|
333
118
|
},
|
|
334
|
-
[
|
|
335
|
-
activeCompPath,
|
|
336
|
-
recordEdit,
|
|
337
|
-
showToast,
|
|
338
|
-
writeProjectFile,
|
|
339
|
-
observeProjectFileVersion,
|
|
340
|
-
domEditSaveTimestampRef,
|
|
341
|
-
reloadPreview,
|
|
342
|
-
forceReloadSdkSession,
|
|
343
|
-
isRecordingRef,
|
|
344
|
-
],
|
|
119
|
+
[isRecordingRef, runCut, showToast],
|
|
345
120
|
);
|
|
346
121
|
|
|
347
122
|
const handleRazorSplitAll = useCallback(
|
|
348
|
-
// fallow-ignore-next-line complexity
|
|
349
123
|
async (splitTime: number) => {
|
|
350
124
|
if (isRecordingRef?.current) {
|
|
351
125
|
showToast("Cannot edit timeline while recording", "error");
|
|
352
126
|
return;
|
|
353
127
|
}
|
|
354
|
-
|
|
355
|
-
const pid = projectIdRef.current;
|
|
356
|
-
if (!pid) return;
|
|
357
|
-
const { elements } = usePlayerStore.getState();
|
|
358
|
-
const splittable = selectSplittableElements(elements, splitTime);
|
|
128
|
+
const splittable = selectSplittableElements(usePlayerStore.getState().elements, splitTime);
|
|
359
129
|
if (splittable.length === 0) return;
|
|
360
|
-
|
|
361
|
-
let originals = new Map<string, string>();
|
|
362
|
-
const finalSnapshots = new Map<string, { before: string; after: string }>();
|
|
363
130
|
try {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
splitTime,
|
|
369
|
-
activeCompPath,
|
|
370
|
-
originals,
|
|
371
|
-
finalSnapshots,
|
|
372
|
-
writeProjectFile,
|
|
373
|
-
observeProjectFileVersion,
|
|
374
|
-
);
|
|
375
|
-
if (splitCount === 0) return;
|
|
376
|
-
|
|
377
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
378
|
-
await recordEdit({
|
|
379
|
-
label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
|
|
380
|
-
kind: "timeline",
|
|
381
|
-
files: Object.fromEntries(finalSnapshots),
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// Resync the stale SDK doc after the batched server write (see the
|
|
385
|
-
// single-split path above for why this precedes the reload).
|
|
386
|
-
forceReloadSdkSession?.();
|
|
387
|
-
reloadPreview();
|
|
388
|
-
trackStudioRazorSplit({ mode: "all", count: splitCount });
|
|
389
|
-
showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
|
131
|
+
const result = await runCut(splittable, splitTime, "all");
|
|
132
|
+
if (!result) return;
|
|
133
|
+
if (result.syncFailed) return;
|
|
134
|
+
showToast(`Split ${result.splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
|
|
390
135
|
} catch (error) {
|
|
391
|
-
// Best-effort rollback — a failing restore write must not swallow the
|
|
392
|
-
// original error's toast, which is what tells the user the split failed.
|
|
393
|
-
try {
|
|
394
|
-
await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
|
|
395
|
-
} catch {
|
|
396
|
-
/* leave disk as-is; the original failure is reported below */
|
|
397
|
-
}
|
|
398
136
|
const message = error instanceof Error ? error.message : "Failed to split clips";
|
|
399
137
|
showToast(message, "error");
|
|
400
138
|
}
|
|
401
139
|
},
|
|
402
|
-
[
|
|
403
|
-
activeCompPath,
|
|
404
|
-
recordEdit,
|
|
405
|
-
showToast,
|
|
406
|
-
writeProjectFile,
|
|
407
|
-
observeProjectFileVersion,
|
|
408
|
-
domEditSaveTimestampRef,
|
|
409
|
-
reloadPreview,
|
|
410
|
-
forceReloadSdkSession,
|
|
411
|
-
isRecordingRef,
|
|
412
|
-
],
|
|
140
|
+
[isRecordingRef, runCut, showToast],
|
|
413
141
|
);
|
|
414
142
|
|
|
415
143
|
return { handleRazorSplit, handleRazorSplitAll };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player";
|
|
3
|
+
|
|
4
|
+
type AddAtPlacement = (path: string, placement: { start: number; track: number }) => unknown;
|
|
5
|
+
|
|
6
|
+
export function useTimelineAddAtPlayhead(addAsset: AddAtPlacement, addComposition: AddAtPlacement) {
|
|
7
|
+
const placement = () => ({ start: usePlayerStore.getState().currentTime, track: 0 });
|
|
8
|
+
return {
|
|
9
|
+
addAssetAtPlayhead: useCallback((path: string) => addAsset(path, placement()), [addAsset]),
|
|
10
|
+
addCompositionAtPlayhead: useCallback(
|
|
11
|
+
(path: string) => addComposition(path, placement()),
|
|
12
|
+
[addComposition],
|
|
13
|
+
),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -19,19 +19,22 @@ import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/stud
|
|
|
19
19
|
import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
|
|
20
20
|
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
21
21
|
import { readFileContent } from "./timelineTimingSync";
|
|
22
|
+
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
23
|
+
import { usePlayerStore } from "../player";
|
|
22
24
|
|
|
23
25
|
interface UseTimelineAssetDropOpsOptions {
|
|
24
26
|
projectIdRef: MutableRefObject<string | null>;
|
|
25
27
|
activeCompPath: string | null;
|
|
26
28
|
timelineElements: TimelineElement[];
|
|
27
29
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
28
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
29
31
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
30
32
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
31
33
|
reloadPreview: () => void;
|
|
32
34
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
33
35
|
isRecordingRef?: RefObject<boolean>;
|
|
34
36
|
forceReloadSdkSession?: () => void;
|
|
37
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function useTimelineAssetDropOps({
|
|
@@ -46,6 +49,7 @@ export function useTimelineAssetDropOps({
|
|
|
46
49
|
uploadProjectFiles,
|
|
47
50
|
isRecordingRef,
|
|
48
51
|
forceReloadSdkSession,
|
|
52
|
+
observeProjectFileVersion,
|
|
49
53
|
}: UseTimelineAssetDropOpsOptions) {
|
|
50
54
|
// fallow-ignore-next-line complexity
|
|
51
55
|
const handleTimelineAssetDrop = useCallback(
|
|
@@ -171,5 +175,51 @@ export function useTimelineAssetDropOps({
|
|
|
171
175
|
[handleTimelineAssetDrop, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
|
|
172
176
|
);
|
|
173
177
|
|
|
174
|
-
|
|
178
|
+
const handleTimelineCompositionDrop = useCallback(
|
|
179
|
+
async (sourcePath: string, placement: Pick<TimelineElement, "start" | "track">) => {
|
|
180
|
+
if (isRecordingRef?.current) {
|
|
181
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const pid = projectIdRef.current;
|
|
185
|
+
if (!pid) throw new Error("No active project");
|
|
186
|
+
const targetPath = activeCompPath || "index.html";
|
|
187
|
+
try {
|
|
188
|
+
await commitTimelineCompositionInsertion({
|
|
189
|
+
projectId: pid,
|
|
190
|
+
targetPath,
|
|
191
|
+
sourcePath,
|
|
192
|
+
start: placement.start,
|
|
193
|
+
track: placement.track,
|
|
194
|
+
writeFile: writeProjectFile,
|
|
195
|
+
recordEdit,
|
|
196
|
+
observeVersion: observeProjectFileVersion,
|
|
197
|
+
selectHost: (key) => usePlayerStore.getState().setSelectedElementId(key),
|
|
198
|
+
resync: forceReloadSdkSession,
|
|
199
|
+
refresh: reloadPreview,
|
|
200
|
+
});
|
|
201
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
202
|
+
showToast("Composition added to the timeline.", "info");
|
|
203
|
+
} catch (error) {
|
|
204
|
+
showToast(
|
|
205
|
+
error instanceof Error ? error.message : "Failed to add composition to timeline",
|
|
206
|
+
"error",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
[
|
|
211
|
+
activeCompPath,
|
|
212
|
+
domEditSaveTimestampRef,
|
|
213
|
+
forceReloadSdkSession,
|
|
214
|
+
isRecordingRef,
|
|
215
|
+
observeProjectFileVersion,
|
|
216
|
+
projectIdRef,
|
|
217
|
+
recordEdit,
|
|
218
|
+
reloadPreview,
|
|
219
|
+
showToast,
|
|
220
|
+
writeProjectFile,
|
|
221
|
+
],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop };
|
|
175
225
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
applyTimelineStackingReorder,
|
|
13
13
|
buildPatchTarget,
|
|
14
14
|
patchIframeDomTiming,
|
|
15
|
+
playbackStartAttributeForElement,
|
|
15
16
|
persistTimelineEdit,
|
|
16
17
|
formatTimelineAttributeNumber,
|
|
17
18
|
extendRootDurationIfNeeded,
|
|
@@ -263,10 +264,7 @@ export function useTimelineEditing({
|
|
|
263
264
|
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
264
265
|
];
|
|
265
266
|
if (updates.playbackStart != null) {
|
|
266
|
-
const liveAttr =
|
|
267
|
-
element.playbackStartAttr === "playback-start"
|
|
268
|
-
? "data-playback-start"
|
|
269
|
-
: "data-media-start";
|
|
267
|
+
const liveAttr = playbackStartAttributeForElement(element);
|
|
270
268
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
|
|
271
269
|
}
|
|
272
270
|
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
|
|
@@ -475,19 +473,21 @@ export function useTimelineEditing({
|
|
|
475
473
|
],
|
|
476
474
|
);
|
|
477
475
|
|
|
478
|
-
const { handleTimelineAssetDrop, handleTimelineFileDrop } =
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
476
|
+
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
|
477
|
+
useTimelineAssetDropOps({
|
|
478
|
+
projectIdRef,
|
|
479
|
+
activeCompPath,
|
|
480
|
+
timelineElements,
|
|
481
|
+
showToast,
|
|
482
|
+
writeProjectFile,
|
|
483
|
+
recordEdit,
|
|
484
|
+
domEditSaveTimestampRef,
|
|
485
|
+
reloadPreview,
|
|
486
|
+
uploadProjectFiles,
|
|
487
|
+
isRecordingRef,
|
|
488
|
+
forceReloadSdkSession,
|
|
489
|
+
observeProjectFileVersion,
|
|
490
|
+
});
|
|
491
491
|
|
|
492
492
|
const handleBlockedTimelineEdit = useCallback(
|
|
493
493
|
(_element: TimelineElement) => {
|
|
@@ -509,6 +509,7 @@ export function useTimelineEditing({
|
|
|
509
509
|
domEditSaveTimestampRef,
|
|
510
510
|
reloadPreview,
|
|
511
511
|
isRecordingRef,
|
|
512
|
+
forceReloadSdkSession,
|
|
512
513
|
});
|
|
513
514
|
|
|
514
515
|
return {
|
|
@@ -522,6 +523,7 @@ export function useTimelineEditing({
|
|
|
522
523
|
handleRazorSplitAll,
|
|
523
524
|
handleTimelineAssetDrop,
|
|
524
525
|
handleTimelineFileDrop,
|
|
526
|
+
handleTimelineCompositionDrop,
|
|
525
527
|
handleBlockedTimelineEdit,
|
|
526
528
|
...groupEditing,
|
|
527
529
|
};
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
extendRootDurationIfNeeded,
|
|
13
13
|
formatTimelineAttributeNumber,
|
|
14
14
|
patchIframeDomTiming,
|
|
15
|
+
playbackStartAttributeForElement,
|
|
15
16
|
persistTimelineBatchEdit,
|
|
16
17
|
type PersistTimelineBatchChange,
|
|
17
18
|
type RecordEditInput,
|
|
@@ -242,9 +243,10 @@ export function useTimelineGroupEditing({
|
|
|
242
243
|
// duration change), so nothing timing-related changed — the batch only
|
|
243
244
|
// rewrites data-track-index, which the renderer never reads (documented
|
|
244
245
|
// in core runtime/timeline.ts; track is a studio lane concept). The live
|
|
245
|
-
// DOM patch above + the gesture owner's optimistic store update
|
|
246
|
-
//
|
|
247
|
-
//
|
|
246
|
+
// DOM patch above + the gesture owner's optimistic store update cover the
|
|
247
|
+
// in-flight UI; after the complete lane + z transaction, that owner
|
|
248
|
+
// refreshes the preview so its runtime manifest converges to disk. There
|
|
249
|
+
// is still nothing to GSAP-shift here, so skip this fallback entirely.
|
|
248
250
|
// Running it anyway is what made the mirrored z-order lane move blink —
|
|
249
251
|
// a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
|
|
250
252
|
// used to full-reload the iframe when there was no script to soft-swap
|
|
@@ -348,10 +350,7 @@ export function useTimelineGroupEditing({
|
|
|
348
350
|
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
|
349
351
|
];
|
|
350
352
|
if (change.playbackStart != null) {
|
|
351
|
-
const liveAttr =
|
|
352
|
-
change.element.playbackStartAttr === "playback-start"
|
|
353
|
-
? "data-playback-start"
|
|
354
|
-
: "data-media-start";
|
|
353
|
+
const liveAttr = playbackStartAttributeForElement(change.element);
|
|
355
354
|
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
|
|
356
355
|
}
|
|
357
356
|
patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
|