@hyperframes/studio 0.7.60 → 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-CWpmBjzv.js +459 -0
- package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-Dh_WhagG.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 +4392 -3314
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -15
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/StudioRightPanel.tsx +34 -26
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +50 -42
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- 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 +44 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
- 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/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- 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/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- 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 +9 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +26 -12
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- 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/hyperframes-player-3XTTaVNf.js +0 -459
- package/dist/assets/index-DXbu6IPT.css +0 -1
- package/dist/assets/index-cH6NfVV_.js +0 -426
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -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,96 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { useSlideshowTabState } from "./useSlideshowTabState";
|
|
7
|
+
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const SLIDESHOW_HTML = `<html><body><script type="application/hyperframes-slideshow+json">{"slides":[]}</script></body></html>`;
|
|
12
|
+
const PLAIN_HTML = `<html><body><div id="title">hi</div></body></html>`;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function renderHook(params: {
|
|
19
|
+
editingFileContent: string | null | undefined;
|
|
20
|
+
rightPanelTab: RightPanelTab;
|
|
21
|
+
}) {
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
document.body.append(host);
|
|
24
|
+
const root = createRoot(host);
|
|
25
|
+
const setRightPanelTabCalls: RightPanelTab[] = [];
|
|
26
|
+
let current: ReturnType<typeof useSlideshowTabState> | null = null;
|
|
27
|
+
|
|
28
|
+
function Harness() {
|
|
29
|
+
current = useSlideshowTabState({
|
|
30
|
+
editingFileContent: params.editingFileContent,
|
|
31
|
+
previewIframeRef: { current: null },
|
|
32
|
+
refreshKey: 0,
|
|
33
|
+
rightPanelTab: params.rightPanelTab,
|
|
34
|
+
setRightPanelTab: (tab) => setRightPanelTabCalls.push(tab),
|
|
35
|
+
});
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
act(() => {
|
|
40
|
+
root.render(React.createElement(Harness));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
getState: (): ReturnType<typeof useSlideshowTabState> => {
|
|
45
|
+
if (!current) throw new Error("useSlideshowTabState did not render");
|
|
46
|
+
return current;
|
|
47
|
+
},
|
|
48
|
+
setRightPanelTabCalls,
|
|
49
|
+
unmount: () => act(() => root.unmount()),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("useSlideshowTabState", () => {
|
|
54
|
+
it("detects a slideshow composition via the JSON island", () => {
|
|
55
|
+
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "design" });
|
|
56
|
+
expect(harness.getState().isSlideshowComposition).toBe(true);
|
|
57
|
+
harness.unmount();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("reports false for a plain (non-slideshow) composition", () => {
|
|
61
|
+
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "design" });
|
|
62
|
+
expect(harness.getState().isSlideshowComposition).toBe(false);
|
|
63
|
+
harness.unmount();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reports false when there is no editing file yet", () => {
|
|
67
|
+
const harness = renderHook({ editingFileContent: undefined, rightPanelTab: "design" });
|
|
68
|
+
expect(harness.getState().isSlideshowComposition).toBe(false);
|
|
69
|
+
harness.unmount();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("still detects a malformed island — presence-only, not full manifest validation", () => {
|
|
73
|
+
const malformed = `<html><body><script type="application/hyperframes-slideshow+json">{not valid json</script></body></html>`;
|
|
74
|
+
const harness = renderHook({ editingFileContent: malformed, rightPanelTab: "design" });
|
|
75
|
+
expect(harness.getState().isSlideshowComposition).toBe(true);
|
|
76
|
+
harness.unmount();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("bounces rightPanelTab off 'slideshow' to 'renders' on a non-slideshow composition", () => {
|
|
80
|
+
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "slideshow" });
|
|
81
|
+
expect(harness.setRightPanelTabCalls).toEqual(["renders"]);
|
|
82
|
+
harness.unmount();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not bounce when the composition is a slideshow", () => {
|
|
86
|
+
const harness = renderHook({ editingFileContent: SLIDESHOW_HTML, rightPanelTab: "slideshow" });
|
|
87
|
+
expect(harness.setRightPanelTabCalls).toEqual([]);
|
|
88
|
+
harness.unmount();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not bounce a tab other than 'slideshow'", () => {
|
|
92
|
+
const harness = renderHook({ editingFileContent: PLAIN_HTML, rightPanelTab: "renders" });
|
|
93
|
+
expect(harness.setRightPanelTabCalls).toEqual([]);
|
|
94
|
+
harness.unmount();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, useMemo, type MutableRefObject } from "react";
|
|
2
|
+
import { SLIDESHOW_ISLAND_TYPE, slideshowIslandRegex } from "@hyperframes/core/slideshow";
|
|
3
|
+
import type { SceneInfo } from "../components/panels/SlideshowPanel";
|
|
4
|
+
import type { IframeWindow } from "../player/lib/playbackTypes";
|
|
5
|
+
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Derives whether the currently-edited composition is a slideshow (carries
|
|
9
|
+
* the slideshow JSON island — the same definitive marker the CLI's `present`
|
|
10
|
+
* command requires; it refuses to run without one) and the live scene list
|
|
11
|
+
* for the Slideshow panel, and bounces `rightPanelTab` off "slideshow" the
|
|
12
|
+
* moment it stops applying (e.g. the user switches to a non-slideshow file
|
|
13
|
+
* while that tab was open) so the panel never shows a dangling active tab
|
|
14
|
+
* whose button is no longer even rendered.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from StudioRightPanel to keep that file under the 600-LOC gate.
|
|
17
|
+
*/
|
|
18
|
+
export function useSlideshowTabState(params: {
|
|
19
|
+
editingFileContent: string | null | undefined;
|
|
20
|
+
previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
|
|
21
|
+
refreshKey: number;
|
|
22
|
+
rightPanelTab: RightPanelTab;
|
|
23
|
+
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
24
|
+
}): { isSlideshowComposition: boolean; slideshowScenes: SceneInfo[] } {
|
|
25
|
+
const { editingFileContent, previewIframeRef, refreshKey, rightPanelTab, setRightPanelTab } =
|
|
26
|
+
params;
|
|
27
|
+
|
|
28
|
+
// Presence-only (not full manifest validation): a malformed island should
|
|
29
|
+
// still surface the Slideshow tab so the user can see/fix it, rather than
|
|
30
|
+
// making the whole panel disappear. The plain substring check short-circuits
|
|
31
|
+
// the regex scan on every non-slideshow file (the common case) without
|
|
32
|
+
// paying for a full-content RegExp pass.
|
|
33
|
+
const isSlideshowComposition = useMemo(() => {
|
|
34
|
+
if (!editingFileContent || !editingFileContent.includes(SLIDESHOW_ISLAND_TYPE)) return false;
|
|
35
|
+
return slideshowIslandRegex("i").test(editingFileContent);
|
|
36
|
+
}, [editingFileContent]);
|
|
37
|
+
|
|
38
|
+
// Derive scene list from the live clip manifest in the preview iframe.
|
|
39
|
+
const slideshowScenes = useMemo<SceneInfo[]>(() => {
|
|
40
|
+
try {
|
|
41
|
+
const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
|
|
42
|
+
return (win?.__clipManifest?.scenes ?? []).map((s) => ({
|
|
43
|
+
id: s.id,
|
|
44
|
+
label: s.label,
|
|
45
|
+
start: s.start,
|
|
46
|
+
duration: s.duration,
|
|
47
|
+
}));
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, [previewIframeRef, rightPanelTab, refreshKey]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (rightPanelTab === "slideshow" && !isSlideshowComposition) {
|
|
56
|
+
setRightPanelTab("renders");
|
|
57
|
+
}
|
|
58
|
+
}, [rightPanelTab, isSlideshowComposition, setRightPanelTab]);
|
|
59
|
+
|
|
60
|
+
return { isSlideshowComposition, slideshowScenes };
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|