@hyperframes/studio 0.7.59 → 0.7.61
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 +459 -0
- package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
- package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
- package/dist/assets/index-BhWig0mx.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1441 -770
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -9
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +47 -28
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +44 -42
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
- package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
- 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/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.ts +8 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
- package/dist/assets/index-Dj5p8U_A.css +0 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -16,7 +16,8 @@ interface UseRazorSplitOptions {
|
|
|
16
16
|
projectId: string | null;
|
|
17
17
|
activeCompPath: string | null;
|
|
18
18
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
19
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
19
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
20
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
20
21
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
21
22
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
22
23
|
reloadPreview: () => void;
|
|
@@ -48,7 +49,7 @@ async function splitHtmlElement(
|
|
|
48
49
|
newId: string,
|
|
49
50
|
elementStart: number,
|
|
50
51
|
elementDuration: number,
|
|
51
|
-
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
|
52
|
+
): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
|
|
52
53
|
const response = await fetch(
|
|
53
54
|
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
|
54
55
|
{
|
|
@@ -64,9 +65,18 @@ async function splitHtmlElement(
|
|
|
64
65
|
},
|
|
65
66
|
);
|
|
66
67
|
if (!response.ok) throw new Error("Split request failed");
|
|
67
|
-
|
|
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 };
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
// fallow-ignore-next-line complexity
|
|
70
80
|
async function splitGsapAnimations(
|
|
71
81
|
projectId: string,
|
|
72
82
|
targetPath: string,
|
|
@@ -75,7 +85,7 @@ async function splitGsapAnimations(
|
|
|
75
85
|
splitTime: number,
|
|
76
86
|
elementStart: number,
|
|
77
87
|
elementDuration: number,
|
|
78
|
-
): Promise<{ content: string | null; skippedSelectors?: string[] }> {
|
|
88
|
+
): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
|
|
79
89
|
const response = await fetch(
|
|
80
90
|
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
|
81
91
|
{
|
|
@@ -101,10 +111,12 @@ async function splitGsapAnimations(
|
|
|
101
111
|
const data = (await response.json()) as {
|
|
102
112
|
ok?: boolean;
|
|
103
113
|
after?: string;
|
|
114
|
+
version?: string;
|
|
104
115
|
skippedSelectors?: string[];
|
|
105
116
|
};
|
|
106
117
|
return {
|
|
107
118
|
content: data.ok && data.after ? data.after : null,
|
|
119
|
+
version: data.version ?? response.headers.get("etag") ?? undefined,
|
|
108
120
|
skippedSelectors: data.skippedSelectors,
|
|
109
121
|
};
|
|
110
122
|
}
|
|
@@ -119,11 +131,11 @@ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string
|
|
|
119
131
|
|
|
120
132
|
async function restoreFilesToOriginal(
|
|
121
133
|
originals: ReadonlyMap<string, string>,
|
|
122
|
-
|
|
123
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
134
|
+
snapshots: ReadonlyMap<string, { before: string; after: string }>,
|
|
135
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
124
136
|
): Promise<void> {
|
|
125
|
-
for (const path of
|
|
126
|
-
await writeProjectFile(path, getOriginalContent(originals, path));
|
|
137
|
+
for (const [path, snapshot] of snapshots) {
|
|
138
|
+
await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
|
|
127
139
|
}
|
|
128
140
|
}
|
|
129
141
|
|
|
@@ -149,17 +161,25 @@ async function splitElementsAtTime(
|
|
|
149
161
|
activeCompPath: string | null,
|
|
150
162
|
originals: ReadonlyMap<string, string>,
|
|
151
163
|
snapshots: Map<string, { before: string; after: string }>,
|
|
152
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
164
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
165
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
153
166
|
): Promise<number> {
|
|
154
167
|
let count = 0;
|
|
155
168
|
for (const element of elements) {
|
|
156
|
-
const result = await executeSplit(
|
|
169
|
+
const result = await executeSplit(
|
|
170
|
+
pid,
|
|
171
|
+
element,
|
|
172
|
+
splitTime,
|
|
173
|
+
activeCompPath,
|
|
174
|
+
writeProjectFile,
|
|
175
|
+
observeProjectFileVersion,
|
|
176
|
+
);
|
|
157
177
|
if (!result.changed) continue;
|
|
158
178
|
snapshots.set(result.targetPath, {
|
|
159
179
|
before: getOriginalContent(originals, result.targetPath),
|
|
160
180
|
after: result.patchedContent,
|
|
161
181
|
});
|
|
162
|
-
await writeProjectFile(result.targetPath, result.patchedContent);
|
|
182
|
+
await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
|
|
163
183
|
count++;
|
|
164
184
|
}
|
|
165
185
|
return count;
|
|
@@ -171,7 +191,8 @@ async function executeSplit(
|
|
|
171
191
|
element: TimelineElement,
|
|
172
192
|
splitTime: number,
|
|
173
193
|
activeCompPath: string | null,
|
|
174
|
-
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
194
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
195
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
|
175
196
|
): Promise<{
|
|
176
197
|
targetPath: string;
|
|
177
198
|
originalContent: string;
|
|
@@ -209,6 +230,7 @@ async function executeSplit(
|
|
|
209
230
|
if (!splitResult.changed) {
|
|
210
231
|
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
|
211
232
|
}
|
|
233
|
+
observeProjectFileVersion?.(targetPath, splitResult.version);
|
|
212
234
|
|
|
213
235
|
let patchedContent =
|
|
214
236
|
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
|
@@ -226,11 +248,12 @@ async function executeSplit(
|
|
|
226
248
|
element.duration,
|
|
227
249
|
);
|
|
228
250
|
if (gsapResult.content) patchedContent = gsapResult.content;
|
|
251
|
+
if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
|
|
229
252
|
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
|
230
253
|
} catch (gsapError) {
|
|
231
254
|
// GSAP mutation failed — the HTML split already wrote to disk.
|
|
232
255
|
// Restore the original content to avoid a corrupt half-split state.
|
|
233
|
-
await writeProjectFile(targetPath, originalContent);
|
|
256
|
+
await writeProjectFile(targetPath, originalContent, patchedContent);
|
|
234
257
|
throw gsapError;
|
|
235
258
|
}
|
|
236
259
|
}
|
|
@@ -243,6 +266,7 @@ export function useRazorSplit({
|
|
|
243
266
|
activeCompPath,
|
|
244
267
|
showToast,
|
|
245
268
|
writeProjectFile,
|
|
269
|
+
observeProjectFileVersion,
|
|
246
270
|
recordEdit,
|
|
247
271
|
domEditSaveTimestampRef,
|
|
248
272
|
reloadPreview,
|
|
@@ -265,7 +289,14 @@ export function useRazorSplit({
|
|
|
265
289
|
|
|
266
290
|
try {
|
|
267
291
|
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
|
268
|
-
await executeSplit(
|
|
292
|
+
await executeSplit(
|
|
293
|
+
pid,
|
|
294
|
+
element,
|
|
295
|
+
splitTime,
|
|
296
|
+
activeCompPath,
|
|
297
|
+
writeProjectFile,
|
|
298
|
+
observeProjectFileVersion,
|
|
299
|
+
);
|
|
269
300
|
|
|
270
301
|
if (!changed) {
|
|
271
302
|
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
|
@@ -305,6 +336,7 @@ export function useRazorSplit({
|
|
|
305
336
|
recordEdit,
|
|
306
337
|
showToast,
|
|
307
338
|
writeProjectFile,
|
|
339
|
+
observeProjectFileVersion,
|
|
308
340
|
domEditSaveTimestampRef,
|
|
309
341
|
reloadPreview,
|
|
310
342
|
forceReloadSdkSession,
|
|
@@ -312,8 +344,8 @@ export function useRazorSplit({
|
|
|
312
344
|
],
|
|
313
345
|
);
|
|
314
346
|
|
|
315
|
-
// fallow-ignore-next-line complexity
|
|
316
347
|
const handleRazorSplitAll = useCallback(
|
|
348
|
+
// fallow-ignore-next-line complexity
|
|
317
349
|
async (splitTime: number) => {
|
|
318
350
|
if (isRecordingRef?.current) {
|
|
319
351
|
showToast("Cannot edit timeline while recording", "error");
|
|
@@ -338,6 +370,7 @@ export function useRazorSplit({
|
|
|
338
370
|
originals,
|
|
339
371
|
finalSnapshots,
|
|
340
372
|
writeProjectFile,
|
|
373
|
+
observeProjectFileVersion,
|
|
341
374
|
);
|
|
342
375
|
if (splitCount === 0) return;
|
|
343
376
|
|
|
@@ -358,7 +391,7 @@ export function useRazorSplit({
|
|
|
358
391
|
// Best-effort rollback — a failing restore write must not swallow the
|
|
359
392
|
// original error's toast, which is what tells the user the split failed.
|
|
360
393
|
try {
|
|
361
|
-
await restoreFilesToOriginal(originals, finalSnapshots
|
|
394
|
+
await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
|
|
362
395
|
} catch {
|
|
363
396
|
/* leave disk as-is; the original failure is reported below */
|
|
364
397
|
}
|
|
@@ -371,6 +404,7 @@ export function useRazorSplit({
|
|
|
371
404
|
recordEdit,
|
|
372
405
|
showToast,
|
|
373
406
|
writeProjectFile,
|
|
407
|
+
observeProjectFileVersion,
|
|
374
408
|
domEditSaveTimestampRef,
|
|
375
409
|
reloadPreview,
|
|
376
410
|
forceReloadSdkSession,
|
|
@@ -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
|
+
}
|
|
@@ -46,6 +46,7 @@ export function useTimelineEditing({
|
|
|
46
46
|
timelineElements,
|
|
47
47
|
showToast,
|
|
48
48
|
writeProjectFile,
|
|
49
|
+
observeProjectFileVersion,
|
|
49
50
|
recordEdit,
|
|
50
51
|
domEditSaveTimestampRef,
|
|
51
52
|
reloadPreview,
|
|
@@ -503,6 +504,7 @@ export function useTimelineEditing({
|
|
|
503
504
|
activeCompPath,
|
|
504
505
|
showToast,
|
|
505
506
|
writeProjectFile,
|
|
507
|
+
observeProjectFileVersion,
|
|
506
508
|
recordEdit,
|
|
507
509
|
domEditSaveTimestampRef,
|
|
508
510
|
reloadPreview,
|
|
@@ -31,7 +31,8 @@ export interface UseTimelineEditingOptions {
|
|
|
31
31
|
activeCompPath: string | null;
|
|
32
32
|
timelineElements: TimelineElement[];
|
|
33
33
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
34
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
35
|
+
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
35
36
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
36
37
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
37
38
|
reloadPreview: () => void;
|
|
@@ -60,7 +60,7 @@ interface UseTimelineGroupEditingOptions {
|
|
|
60
60
|
sdkSession?: Composition | null;
|
|
61
61
|
publishSdkSession?: PublishSdkSession;
|
|
62
62
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
63
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
63
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
|
|
@@ -50,9 +50,16 @@ export interface ClipManifestClip {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export interface ClipManifest {
|
|
53
|
+
protocolVersion?: number;
|
|
54
|
+
compositionContractVersion?: number;
|
|
55
|
+
capabilities?: readonly string[];
|
|
56
|
+
fps?: { numerator: number; denominator: number };
|
|
57
|
+
durationSeconds?: number;
|
|
53
58
|
clips: ClipManifestClip[];
|
|
54
59
|
scenes: Array<{ id: string; label: string; start: number; duration: number }>;
|
|
55
60
|
durationInFrames: number;
|
|
61
|
+
compositionWidth?: number;
|
|
62
|
+
compositionHeight?: number;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export type IframeWindow = Window & {
|
|
@@ -14,7 +14,12 @@ describe("Studio runtime protocol", () => {
|
|
|
14
14
|
type: "control",
|
|
15
15
|
action: "seek",
|
|
16
16
|
protocolVersion: 1,
|
|
17
|
-
capabilities: [
|
|
17
|
+
capabilities: [
|
|
18
|
+
"seconds-time",
|
|
19
|
+
"rational-fps",
|
|
20
|
+
"seek-keep-playing",
|
|
21
|
+
"composition-manifest-v1",
|
|
22
|
+
],
|
|
18
23
|
fps: { numerator: 60, denominator: 1 },
|
|
19
24
|
timeSeconds: 1.25,
|
|
20
25
|
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
12
|
import type { ClipManifestClip } from "./playbackTypes";
|
|
13
13
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
14
|
+
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
|
14
15
|
import {
|
|
15
16
|
resolveMediaElement,
|
|
16
17
|
applyMediaMetadataFromElement,
|
|
@@ -255,24 +256,20 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
255
256
|
if (node === rootComp) return;
|
|
256
257
|
if (isTimelineIgnoredElement(node)) return;
|
|
257
258
|
const el = node as HTMLElement;
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (isNaN(start)) return;
|
|
259
|
+
const timing = readClipTiming(el);
|
|
260
|
+
const start = timing.start;
|
|
261
|
+
if (start == null) return;
|
|
262
262
|
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
|
|
263
263
|
|
|
264
264
|
const tagLower = el.tagName.toLowerCase();
|
|
265
|
-
let dur = 0;
|
|
266
|
-
|
|
267
|
-
if (durStr != null) dur = parseFloat(durStr);
|
|
268
|
-
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
|
265
|
+
let dur = timing.duration ?? 0;
|
|
266
|
+
if (dur <= 0) dur = Math.max(0, rootDuration - start);
|
|
269
267
|
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
270
268
|
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
271
269
|
}
|
|
272
270
|
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
273
271
|
|
|
274
|
-
const
|
|
275
|
-
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
|
272
|
+
const track = timing.trackSource === "default" ? trackCounter++ : timing.trackIndex;
|
|
276
273
|
// fallow-ignore-next-line code-duplication
|
|
277
274
|
const compId = el.getAttribute("data-composition-id");
|
|
278
275
|
const selector = getTimelineElementSelector(el);
|
|
@@ -299,7 +296,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
299
296
|
tag: tagLower,
|
|
300
297
|
start,
|
|
301
298
|
duration: dur,
|
|
302
|
-
track
|
|
299
|
+
track,
|
|
303
300
|
domId: el.id || undefined,
|
|
304
301
|
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
305
302
|
selector,
|