@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.
Files changed (78) hide show
  1. package/dist/assets/hyperframes-player-Xvx2hkrc.js +459 -0
  2. package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
  3. package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
  4. package/dist/assets/index-BhWig0mx.css +1 -0
  5. package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1441 -770
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +4 -9
  11. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  12. package/src/components/StudioHeader.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +47 -28
  14. package/src/components/editor/PromotableControl.tsx +4 -2
  15. package/src/components/editor/PropertyPanel.tsx +151 -149
  16. package/src/components/editor/PropertyPanelFlat.tsx +44 -42
  17. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  18. package/src/components/editor/manualEditingAvailability.ts +5 -3
  19. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  20. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
  23. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  24. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  25. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
  26. package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
  27. package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
  28. package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
  29. package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
  30. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  31. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  32. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  33. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  34. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  35. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  36. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  37. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  38. package/src/components/storyboard/useFrameComments.ts +22 -6
  39. package/src/contexts/FileManagerContext.tsx +3 -0
  40. package/src/contexts/PanelLayoutContext.tsx +3 -0
  41. package/src/contexts/ViewModeContext.tsx +60 -7
  42. package/src/hooks/timelineEditingHelpers.ts +2 -2
  43. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  44. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  46. package/src/hooks/useDomEditCommits.ts +2 -2
  47. package/src/hooks/useDomEditSession.ts +1 -1
  48. package/src/hooks/useEditorSave.ts +1 -1
  49. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  50. package/src/hooks/useFileManager.ts +76 -12
  51. package/src/hooks/usePanelLayout.test.ts +118 -0
  52. package/src/hooks/usePanelLayout.ts +24 -1
  53. package/src/hooks/usePreviewPersistence.ts +4 -1
  54. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  55. package/src/hooks/useRazorSplit.test.tsx +88 -0
  56. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  57. package/src/hooks/useRazorSplit.ts +50 -16
  58. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  59. package/src/hooks/useSlideshowTabState.ts +61 -0
  60. package/src/hooks/useTimelineEditing.ts +2 -0
  61. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  62. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  63. package/src/player/lib/playbackTypes.ts +7 -0
  64. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  65. package/src/player/lib/timelineDOM.ts +8 -11
  66. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  67. package/src/utils/domEditSaveQueue.test.ts +19 -0
  68. package/src/utils/domEditSaveQueue.ts +2 -1
  69. package/src/utils/sdkCutover.test.ts +21 -5
  70. package/src/utils/sdkEditTransaction.ts +5 -4
  71. package/src/utils/studioFileHistory.ts +6 -4
  72. package/src/utils/studioFileVersion.test.ts +36 -0
  73. package/src/utils/studioFileVersion.ts +23 -0
  74. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  75. package/src/utils/studioSaveDiagnostics.ts +21 -0
  76. package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
  77. package/dist/assets/index-Dj5p8U_A.css +0 -1
  78. 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
- return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
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
- paths: Iterable<string>,
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 paths) {
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(pid, element, splitTime, activeCompPath, writeProjectFile);
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(pid, element, splitTime, activeCompPath, writeProjectFile);
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.keys(), writeProjectFile);
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: ["seconds-time", "rational-fps", "seek-keep-playing"],
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 startStr = el.getAttribute("data-start");
259
- if (startStr == null) return;
260
- const start = parseFloat(startStr);
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
- const durStr = el.getAttribute("data-duration");
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 trackStr = el.getAttribute("data-track-index");
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: isNaN(track) ? 0 : track,
299
+ track,
303
300
  domId: el.id || undefined,
304
301
  hfId: el.getAttribute("data-hf-id") || undefined,
305
302
  selector,