@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.
Files changed (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -48,9 +48,51 @@ describe("buildExpandedElements", () => {
48
48
  const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
49
49
  const child = out.find((e) => e.domId === "stat-1")!;
50
50
  expect(child.expandedParentStart).toBe(16);
51
+ expect(child.expandedHostKey).toBe("s3");
51
52
  expect(child.sourceFile).toBe("stats.html");
52
53
  });
53
54
 
55
+ it("keeps repeated same-source composition hosts as distinct move identities", () => {
56
+ const elements = [
57
+ el({
58
+ id: "host-a",
59
+ key: "index.html#host-a",
60
+ start: 0,
61
+ duration: 5,
62
+ compositionSrc: "scene.html",
63
+ }),
64
+ el({
65
+ id: "host-b",
66
+ key: "index.html#host-b",
67
+ start: 8,
68
+ duration: 5,
69
+ compositionSrc: "scene.html",
70
+ }),
71
+ ];
72
+ const manifest = [
73
+ clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
74
+ clip({ id: "child-a", start: 1, duration: 2 }),
75
+ clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
76
+ clip({ id: "child-b", start: 9, duration: 2 }),
77
+ ];
78
+ const parentMap = new Map([
79
+ ["child-a", "host-a"],
80
+ ["child-b", "host-b"],
81
+ ]);
82
+
83
+ const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
84
+ (element) => element.domId === "child-a",
85
+ );
86
+ const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
87
+ (element) => element.domId === "child-b",
88
+ );
89
+
90
+ expect(childA?.sourceFile).toBe("scene.html");
91
+ expect(childB?.sourceFile).toBe("scene.html");
92
+ expect(childA?.expandedHostKey).toBe("index.html#host-a");
93
+ expect(childB?.expandedHostKey).toBe("index.html#host-b");
94
+ });
95
+
54
96
  // fallow-ignore-next-line code-duplication
55
97
  it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
56
98
  // top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
@@ -133,6 +133,7 @@ function buildChildElements(
133
133
  siblings: ClipManifestClip[],
134
134
  display: DisplayBounds,
135
135
  editBasis: { start: number; sourceFile: string | undefined },
136
+ expandedHostKey: string,
136
137
  ): TimelineElement[] {
137
138
  const result: TimelineElement[] = [];
138
139
  for (const child of siblings) {
@@ -182,6 +183,7 @@ function buildChildElements(
182
183
  authoredTrack: base.authoredTrack,
183
184
  stackingContextId: base.stackingContextId,
184
185
  expandedParentStart: editBasis.start,
186
+ expandedHostKey,
185
187
  domId,
186
188
  selector,
187
189
  sourceFile: editBasis.sourceFile,
@@ -260,6 +262,7 @@ export function buildExpandedElements(
260
262
  track: topLevelElement.track,
261
263
  },
262
264
  editBasis,
265
+ parentKey,
263
266
  );
264
267
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
265
268
 
@@ -46,13 +46,22 @@ export interface ClipManifestClip {
46
46
  compositionAncestors?: string[];
47
47
  parentCompositionId: string | null;
48
48
  compositionSrc: string | null;
49
+ playbackStart?: number;
50
+ playbackRate?: number;
49
51
  assetUrl: string | null;
50
52
  }
51
53
 
52
54
  export interface ClipManifest {
55
+ protocolVersion?: number;
56
+ compositionContractVersion?: number;
57
+ capabilities?: readonly string[];
58
+ fps?: { numerator: number; denominator: number };
59
+ durationSeconds?: number;
53
60
  clips: ClipManifestClip[];
54
61
  scenes: Array<{ id: string; label: string; start: number; duration: number }>;
55
62
  durationInFrames: number;
63
+ compositionWidth?: number;
64
+ compositionHeight?: number;
56
65
  }
57
66
 
58
67
  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
  });
@@ -110,7 +110,65 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("parseTimelineFromDOM — canonical playback rate", () => {
114
+ it.each([
115
+ ["10", 5],
116
+ ["0.01", 0.1],
117
+ ])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
118
+ const doc = makeDoc(`
119
+ <div data-composition-id="root">
120
+ <div id="nested" class="clip" data-composition-src="scene.html"
121
+ data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
122
+ </div>
123
+ `);
124
+
125
+ const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
126
+
127
+ expect(nested?.playbackRate).toBe(expected);
128
+ });
129
+ });
130
+
113
131
  describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => {
132
+ it("preserves composition kind and source timing on first translation", () => {
133
+ const doc = makeDoc(`
134
+ <div data-composition-id="root" data-composition-file="index.html">
135
+ <div id="host" data-composition-id="scene" data-composition-src="scene.html"
136
+ data-playback-start="1.5" data-playback-rate="2"></div>
137
+ </div>
138
+ `);
139
+ const host = doc.getElementById("host");
140
+
141
+ const element = createTimelineElementFromManifestClip({
142
+ clip: {
143
+ id: "host",
144
+ label: "Scene",
145
+ kind: "composition",
146
+ tagName: "div",
147
+ start: 2,
148
+ duration: 4,
149
+ track: 0,
150
+ compositionId: "scene",
151
+ parentCompositionId: "root",
152
+ compositionSrc: "scene.html",
153
+ playbackStart: 1.5,
154
+ playbackRate: 2,
155
+ assetUrl: null,
156
+ },
157
+ fallbackIndex: 0,
158
+ doc,
159
+ hostEl: host,
160
+ });
161
+
162
+ expect(element).toMatchObject({
163
+ kind: "composition",
164
+ compositionSrc: "scene.html",
165
+ playbackStart: 1.5,
166
+ playbackStartAttr: "playback-start",
167
+ playbackRate: 2,
168
+ domId: "host",
169
+ });
170
+ });
171
+
114
172
  it("ignores an index.html duplicate when indexing a scene.html selector", () => {
115
173
  const doc = makeDoc(`
116
174
  <div data-composition-id="root" data-composition-file="index.html">
@@ -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,
@@ -110,6 +111,7 @@ export function createTimelineElementFromManifestClip(params: {
110
111
  id: identity.id,
111
112
  label,
112
113
  key: identity.key,
114
+ kind: clip.kind,
113
115
  tag: resolveClipTag(clip),
114
116
  start: clip.start,
115
117
  duration: clip.duration,
@@ -128,6 +130,8 @@ export function createTimelineElementFromManifestClip(params: {
128
130
  selector,
129
131
  selectorIndex,
130
132
  sourceFile,
133
+ playbackStart: clip.playbackStart,
134
+ playbackRate: clip.playbackRate,
131
135
  };
132
136
 
133
137
  if (hostEl) {
@@ -139,6 +143,8 @@ export function createTimelineElementFromManifestClip(params: {
139
143
  }
140
144
  if (clip.assetUrl) entry.src = clip.assetUrl;
141
145
  if (clip.kind === "composition" && clip.compositionId) {
146
+ entry.playbackStart ??= 0;
147
+ entry.playbackRate ??= 1;
142
148
  let resolvedSrc = clip.compositionSrc;
143
149
  if (!resolvedSrc) {
144
150
  hostEl =
@@ -255,24 +261,20 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
255
261
  if (node === rootComp) return;
256
262
  if (isTimelineIgnoredElement(node)) return;
257
263
  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;
264
+ const timing = readClipTiming(el);
265
+ const start = timing.start;
266
+ if (start == null) return;
262
267
  if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
263
268
 
264
269
  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);
270
+ let dur = timing.duration ?? 0;
271
+ if (dur <= 0) dur = Math.max(0, rootDuration - start);
269
272
  if (Number.isFinite(rootDuration) && rootDuration > 0) {
270
273
  dur = Math.min(dur, Math.max(0, rootDuration - start));
271
274
  }
272
275
  if (!Number.isFinite(dur) || dur <= 0) return;
273
276
 
274
- const trackStr = el.getAttribute("data-track-index");
275
- const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
277
+ const track = timing.trackSource === "default" ? trackCounter++ : timing.trackIndex;
276
278
  // fallow-ignore-next-line code-duplication
277
279
  const compId = el.getAttribute("data-composition-id");
278
280
  const selector = getTimelineElementSelector(el);
@@ -296,10 +298,18 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
296
298
  id: identity.id,
297
299
  label,
298
300
  key: identity.key,
301
+ kind:
302
+ compId && compId !== rootComp?.getAttribute("data-composition-id")
303
+ ? "composition"
304
+ : tagLower === "video" || tagLower === "audio"
305
+ ? tagLower
306
+ : tagLower === "img"
307
+ ? "image"
308
+ : "element",
299
309
  tag: tagLower,
300
310
  start,
301
311
  duration: dur,
302
- track: isNaN(track) ? 0 : track,
312
+ track,
303
313
  domId: el.id || undefined,
304
314
  hfId: el.getAttribute("data-hf-id") || undefined,
305
315
  selector,
@@ -311,13 +321,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
311
321
  };
312
322
 
313
323
  const mediaEl = resolveMediaElement(el);
324
+ applyMediaMetadataFromElement(entry, el);
314
325
  if (mediaEl) {
315
326
  if (mediaEl.tagName === "IMG") {
316
327
  entry.tag = "img";
317
328
  }
318
329
  const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
319
330
  if (vol) entry.volume = parseFloat(vol);
320
- applyMediaMetadataFromElement(entry, el);
321
331
  // Override AFTER the helper (which sets the raw relative attribute) so the
322
332
  // resolved absolute URL wins — the Studio can then fetch the asset
323
333
  // regardless of whether the attribute value was relative or absolute.
@@ -348,6 +358,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
348
358
  entry.tag = "video";
349
359
  }
350
360
  }
361
+ if (entry.kind === "composition") {
362
+ entry.playbackStart ??= 0;
363
+ entry.playbackRate ??= 1;
364
+ }
351
365
 
352
366
  els.push(entry);
353
367
  });
@@ -76,6 +76,10 @@ function readDurationAttribute(el: Element | null | undefined): number {
76
76
  return isFinitePositive(duration) ? duration : 0;
77
77
  }
78
78
 
79
+ function normalizePlaybackRate(raw: number): number {
80
+ return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
81
+ }
82
+
79
83
  export function isTimelineIgnoredElement(el: Element): boolean {
80
84
  return Boolean(
81
85
  el.closest(
@@ -149,19 +153,25 @@ export function resolveMediaElement(el: Element): HTMLMediaElement | HTMLImageEl
149
153
  : null;
150
154
  }
151
155
 
152
- export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
153
- const mediaStartAttr = el.getAttribute("data-playback-start")
154
- ? "playback-start"
155
- : el.getAttribute("data-media-start")
156
- ? "media-start"
157
- : undefined;
158
- const mediaStartValue =
159
- el.getAttribute("data-playback-start") ?? el.getAttribute("data-media-start");
156
+ function applyPlaybackMetadataFromElement(entry: TimelineElement, el: Element): void {
157
+ const playbackStartValue = el.getAttribute("data-playback-start");
158
+ const legacyMediaStartValue = el.getAttribute("data-media-start");
159
+ const mediaStartValue = playbackStartValue ?? legacyMediaStartValue;
160
160
  if (mediaStartValue != null) {
161
161
  const playbackStart = parseFloat(mediaStartValue);
162
162
  if (Number.isFinite(playbackStart)) entry.playbackStart = playbackStart;
163
163
  }
164
- if (mediaStartAttr) entry.playbackStartAttr = mediaStartAttr;
164
+ if (playbackStartValue != null) entry.playbackStartAttr = "playback-start";
165
+ else if (legacyMediaStartValue != null) entry.playbackStartAttr = "media-start";
166
+
167
+ const authoredPlaybackRate = Number.parseFloat(el.getAttribute("data-playback-rate") ?? "");
168
+ if (Number.isFinite(authoredPlaybackRate) && authoredPlaybackRate > 0) {
169
+ entry.playbackRate = normalizePlaybackRate(authoredPlaybackRate);
170
+ }
171
+ }
172
+
173
+ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Element): void {
174
+ applyPlaybackMetadataFromElement(entry, el);
165
175
 
166
176
  const mediaEl = resolveMediaElement(el);
167
177
  if (!mediaEl) return;
@@ -182,8 +192,8 @@ export function applyMediaMetadataFromElement(entry: TimelineElement, el: Elemen
182
192
  }
183
193
 
184
194
  const playbackRate = mediaEl.defaultPlaybackRate;
185
- if (Number.isFinite(playbackRate) && playbackRate > 0) {
186
- entry.playbackRate = playbackRate;
195
+ if (entry.playbackRate == null && Number.isFinite(playbackRate) && playbackRate > 0) {
196
+ entry.playbackRate = normalizePlaybackRate(playbackRate);
187
197
  }
188
198
  }
189
199
 
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { TimelineElement } from "../store/playerStore";
14
14
  import type { IframeWindow } from "./playbackTypes";
15
+ import { readClipTiming } from "@hyperframes/core/composition-contract";
15
16
  import {
16
17
  getTimelineElementSelector,
17
18
  getTimelineElementSourceFile,
@@ -38,6 +39,9 @@ export function normalizePreviewViewport(doc: Document, win: Window): void {
38
39
  win.scrollTo({ top: 0, left: 0, behavior: "auto" });
39
40
  }
40
41
 
42
+ // Legacy recovery retained until versioned composition manifests complete
43
+ // their compatibility soak across published CDN runtimes.
44
+ // fallow-ignore-next-line complexity
41
45
  export function autoHealMissingCompositionIds(doc: Document): void {
42
46
  const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
43
47
  const referencedIds = new Set<string>();
@@ -258,6 +262,141 @@ export function stopScrubPreviewAudio(): void {
258
262
  // Enrich missing compositions from DOM
259
263
  // ---------------------------------------------------------------------------
260
264
 
265
+ function timelineDuration(iframeWin: IframeWindow, compositionId: string): number {
266
+ return (
267
+ (
268
+ iframeWin.__timelines?.[compositionId] as { duration?: () => number } | undefined
269
+ )?.duration?.() ?? 0
270
+ );
271
+ }
272
+
273
+ function createTimedElementLookup(doc: Document): Map<string, Element> {
274
+ const timedById = new Map<string, Element>();
275
+ for (const timed of doc.querySelectorAll("[data-start]")) {
276
+ for (const id of [
277
+ timed.id,
278
+ timed.getAttribute("data-hf-id"),
279
+ timed.getAttribute("data-composition-id"),
280
+ ]) {
281
+ if (id) timedById.set(id, timed);
282
+ }
283
+ }
284
+ return timedById;
285
+ }
286
+
287
+ function createReferenceEndResolver(
288
+ timedById: ReadonlyMap<string, Element>,
289
+ iframeWin: IframeWindow,
290
+ ): (refId: string, visiting: ReadonlySet<string>) => number | null {
291
+ const resolveEnd = (refId: string, visiting: ReadonlySet<string>): number | null => {
292
+ if (visiting.has(refId)) return null;
293
+ const referenced = timedById.get(refId);
294
+ if (!referenced) return null;
295
+ const next = new Set(visiting).add(refId);
296
+ const timing = readClipTiming(referenced, {
297
+ resolveReferenceEnd: (nestedId) => resolveEnd(nestedId, next),
298
+ });
299
+ if (timing.end != null) return timing.end;
300
+ const compositionId = referenced.getAttribute("data-composition-id");
301
+ const duration = compositionId ? timelineDuration(iframeWin, compositionId) : 0;
302
+ return timing.start == null || duration <= 0 ? null : timing.start + duration;
303
+ };
304
+ return resolveEnd;
305
+ }
306
+
307
+ function clampCompositionWindow(
308
+ start: number,
309
+ duration: number,
310
+ rootDuration: number,
311
+ ): { start: number; duration: number } | null {
312
+ if (!Number.isFinite(duration) || duration <= 0) return null;
313
+ const safeStart = Number.isFinite(start) ? start : 0;
314
+ if (!Number.isFinite(rootDuration) || rootDuration <= 0) {
315
+ return { start: safeStart, duration };
316
+ }
317
+ if (safeStart >= rootDuration) return null;
318
+ const clamped = Math.min(duration, Math.max(0, rootDuration - safeStart));
319
+ return clamped > 0 ? { start: safeStart, duration: clamped } : null;
320
+ }
321
+
322
+ function nonEmpty(value: string, fallback: string): string {
323
+ return value || fallback;
324
+ }
325
+
326
+ function optionalNonEmpty(value: string | null): string | undefined {
327
+ return value || undefined;
328
+ }
329
+
330
+ function attachCompositionSource(
331
+ entry: TimelineElement,
332
+ element: HTMLElement,
333
+ compositionSrc: string | null,
334
+ ): TimelineElement {
335
+ if (compositionSrc) return { ...entry, compositionSrc };
336
+ const innerVideo = element.querySelector("video[src]");
337
+ if (!innerVideo) return entry;
338
+ return { ...entry, src: optionalNonEmpty(innerVideo.getAttribute("src")), tag: "video" };
339
+ }
340
+
341
+ function buildMissingCompositionEntry(params: {
342
+ doc: Document;
343
+ iframeWin: IframeWindow;
344
+ element: HTMLElement;
345
+ compositionId: string;
346
+ rootDuration: number;
347
+ fallbackIndex: number;
348
+ resolveEnd: (refId: string, visiting: ReadonlySet<string>) => number | null;
349
+ }): TimelineElement | null {
350
+ const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
351
+ params;
352
+ const timing = readClipTiming(element, {
353
+ resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
354
+ });
355
+ const window = clampCompositionWindow(
356
+ timing.start ?? 0,
357
+ timing.duration ?? timelineDuration(iframeWin, compositionId),
358
+ rootDuration,
359
+ );
360
+ if (!window) return null;
361
+
362
+ const preferredId = nonEmpty(element.id, compositionId);
363
+ const compositionSrc =
364
+ element.getAttribute("data-composition-src") ?? element.getAttribute("data-composition-file");
365
+ const selector = getTimelineElementSelector(element);
366
+ const sourceFile = getTimelineElementSourceFile(element);
367
+ const selectorIndex = getTimelineElementSelectorIndex(doc, element, selector);
368
+ const label = getTimelineElementDisplayLabel({
369
+ id: preferredId,
370
+ label: element.getAttribute("data-timeline-label") ?? element.getAttribute("data-label"),
371
+ tag: element.tagName,
372
+ });
373
+ const identity = buildTimelineElementIdentity({
374
+ preferredId,
375
+ label,
376
+ fallbackIndex,
377
+ domId: optionalNonEmpty(element.id),
378
+ selector,
379
+ selectorIndex,
380
+ sourceFile,
381
+ });
382
+ const entry: TimelineElement = {
383
+ id: identity.id,
384
+ label,
385
+ key: identity.key,
386
+ tag: element.tagName.toLowerCase(),
387
+ start: window.start,
388
+ duration: window.duration,
389
+ track: timing.trackIndex,
390
+ domId: optionalNonEmpty(element.id),
391
+ hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
392
+ selector,
393
+ selectorIndex,
394
+ sourceFile,
395
+ zIndex: readTimelineElementZIndex(element),
396
+ };
397
+ return attachCompositionSource(entry, element, compositionSrc);
398
+ }
399
+
261
400
  /**
262
401
  * Scan the iframe DOM for composition hosts missing from the current
263
402
  * timeline elements and add them. The CDN runtime often fails to resolve
@@ -279,117 +418,24 @@ export function buildMissingCompositionElements(
279
418
  const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
280
419
  const missing: TimelineElement[] = [];
281
420
 
282
- hosts.forEach((host) => {
421
+ const resolveEnd = createReferenceEndResolver(createTimedElementLookup(doc), iframeWin);
422
+
423
+ for (const host of hosts) {
283
424
  const el = host as HTMLElement;
284
425
  const compId = el.getAttribute("data-composition-id");
285
- if (!compId || compId === rootCompId) return;
286
- if (existingIds.has(el.id) || existingIds.has(compId)) return;
287
-
288
- // Resolve start: numeric or element-reference
289
- const startAttr = el.getAttribute("data-start") ?? "0";
290
- let start = parseFloat(startAttr);
291
- if (isNaN(start)) {
292
- const ref =
293
- doc.getElementById(startAttr) ||
294
- doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
295
- if (ref) {
296
- const refStartAttr = ref.getAttribute("data-start") ?? "0";
297
- let refStart = parseFloat(refStartAttr);
298
- // Recursively resolve one level of reference for the ref's own start
299
- if (isNaN(refStart)) {
300
- const refRef =
301
- doc.getElementById(refStartAttr) ||
302
- doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
303
- const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
304
- const rrCompId = refRef?.getAttribute("data-composition-id");
305
- const rrDur =
306
- parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
307
- (rrCompId
308
- ? ((
309
- iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
310
- )?.duration?.() ?? 0)
311
- : 0);
312
- refStart = rrStart + rrDur;
313
- }
314
- const refCompId = ref.getAttribute("data-composition-id");
315
- const refDur =
316
- parseFloat(ref.getAttribute("data-duration") ?? "") ||
317
- (refCompId
318
- ? ((
319
- iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
320
- )?.duration?.() ?? 0)
321
- : 0);
322
- start = refStart + refDur;
323
- } else {
324
- start = 0;
325
- }
326
- }
327
-
328
- // Resolve duration from data-duration or GSAP timeline
329
- let dur = parseFloat(el.getAttribute("data-duration") ?? "");
330
- if (isNaN(dur) || dur <= 0) {
331
- dur =
332
- (
333
- iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
334
- )?.duration?.() ?? 0;
335
- }
336
- if (!Number.isFinite(dur) || dur <= 0) return;
337
- if (!Number.isFinite(start)) start = 0;
338
- if (Number.isFinite(rootDuration) && rootDuration > 0) {
339
- if (start >= rootDuration) return;
340
- dur = Math.min(dur, Math.max(0, rootDuration - start));
341
- if (dur <= 0) return;
342
- }
343
-
344
- const trackStr = el.getAttribute("data-track-index");
345
- const track = trackStr != null ? parseInt(trackStr, 10) : 0;
346
- // fallow-ignore-next-line code-duplication
347
- const compSrc =
348
- el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
349
- const selector = getTimelineElementSelector(el);
350
- const sourceFile = getTimelineElementSourceFile(el);
351
- const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
352
- const label = getTimelineElementDisplayLabel({
353
- id: el.id || compId || null,
354
- label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
355
- tag: el.tagName,
356
- });
357
- const identity = buildTimelineElementIdentity({
358
- preferredId: el.id || compId || null,
359
- label,
426
+ if (!compId || compId === rootCompId) continue;
427
+ if (existingIds.has(el.id) || existingIds.has(compId)) continue;
428
+ const entry = buildMissingCompositionEntry({
429
+ doc,
430
+ iframeWin,
431
+ element: el,
432
+ compositionId: compId,
433
+ rootDuration,
360
434
  fallbackIndex: missing.length,
361
- domId: el.id || undefined,
362
- selector,
363
- selectorIndex,
364
- sourceFile,
435
+ resolveEnd,
365
436
  });
366
- const entry: TimelineElement = {
367
- id: identity.id,
368
- label,
369
- key: identity.key,
370
- tag: el.tagName.toLowerCase(),
371
- start,
372
- duration: dur,
373
- track: isNaN(track) ? 0 : track,
374
- domId: el.id || undefined,
375
- hfId: el.getAttribute("data-hf-id") || undefined,
376
- selector,
377
- selectorIndex,
378
- sourceFile,
379
- zIndex: readTimelineElementZIndex(el),
380
- };
381
- if (compSrc) {
382
- entry.compositionSrc = compSrc;
383
- } else {
384
- // Inline composition — expose inner video for thumbnails
385
- const innerVideo = el.querySelector("video[src]");
386
- if (innerVideo) {
387
- entry.src = innerVideo.getAttribute("src") || undefined;
388
- entry.tag = "video";
389
- }
390
- }
391
- missing.push(entry);
392
- });
437
+ if (entry) missing.push(entry);
438
+ }
393
439
 
394
440
  // Patch existing elements that are missing compositionSrc
395
441
  let patched = false;
@@ -25,6 +25,7 @@ export interface TimelineElement {
25
25
  id: string;
26
26
  label?: string;
27
27
  key?: string;
28
+ kind?: ClipManifestClip["kind"];
28
29
  tag: string;
29
30
  start: number;
30
31
  duration: number;
@@ -82,8 +83,8 @@ export interface TimelineElement {
82
83
  * the child's local (sourceFile-relative) time. Works at any nesting depth.
83
84
  */
84
85
  expandedParentStart?: number;
86
+ expandedHostKey?: string;
85
87
  }
86
-
87
88
  export type ZoomMode = "fit" | "manual";
88
89
  type TimelineTool = "select" | "razor";
89
90