@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,269 @@
1
+ // Soft-apply of undo/redo restores to the live preview: diff a restored file
2
+ // against the live one, sync attribute-only changes onto the live DOM, and
3
+ // refresh the runtime in place — avoiding the full iframe remount (black flash
4
+ // + WebGL context loss) whenever the restore is expressible without one.
5
+ import {
6
+ applySoftReload,
7
+ applySoftReloadFinalization,
8
+ extractGsapScriptText,
9
+ findGsapScriptElements,
10
+ } from "./gsapSoftReload";
11
+
12
+ type PreviewWindow = Window & {
13
+ __player?: { seek?: (t: number) => void };
14
+ __hfStudioManualEditsApply?: () => void;
15
+ };
16
+
17
+ /** One file's restore from the edit-history store: before (live) / after (target) bytes. */
18
+ export interface UndoRestoreFile {
19
+ previous: string;
20
+ restored: string;
21
+ }
22
+
23
+ /**
24
+ * Identity for the soft diff: `data-hf-id` when present, else `id`. Nearly
25
+ * every studio-editable element carries one of the two — z-order commits and
26
+ * timeline patches target by id OR hf-id OR stable selector, and hf-ids are
27
+ * stamped uniquely by the SDK — so preferring them keeps duplicate authored
28
+ * ids distinct and selector-targeted clips inside soft-undo's reach.
29
+ */
30
+ function elementIdentityKey(el: Element): string | null {
31
+ const hfId = el.getAttribute("data-hf-id");
32
+ if (hfId) return `hf:${hfId}`;
33
+ const id = el.getAttribute("id");
34
+ if (id) return `id:${id}`;
35
+ return null;
36
+ }
37
+
38
+ const IDENTITY_SELECTOR = "[id], [data-hf-id]";
39
+
40
+ function identityElementMap(doc: Document): Map<string, Element> | null {
41
+ const map = new Map<string, Element>();
42
+ for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
43
+ const key = elementIdentityKey(el);
44
+ if (!key) continue;
45
+ // Ambiguous identity must full-reload; silently overwriting would restore
46
+ // one element's attributes onto another element sharing the same key.
47
+ if (map.has(key)) return null;
48
+ map.set(key, el);
49
+ }
50
+ return map;
51
+ }
52
+
53
+ // Strip identified elements to their bare identity attributes and blank GSAP
54
+ // scripts, in place: docs that differ only in identified-element attributes/
55
+ // inline-style/script text normalize equal; any residual difference is beyond
56
+ // soft-reload's reach → caller full-reloads. Both identity attributes are
57
+ // KEPT, so a change to `id`/`data-hf-id` themselves stays a residual
58
+ // (structural) difference.
59
+ function normalizeSoftResidual(doc: Document): void {
60
+ for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
61
+ const id = el.getAttribute("id");
62
+ const hfId = el.getAttribute("data-hf-id");
63
+ for (const name of [...el.getAttributeNames()]) {
64
+ if (name !== "id" && name !== "data-hf-id") el.removeAttribute(name);
65
+ }
66
+ if (id) el.setAttribute("id", id);
67
+ if (hfId) el.setAttribute("data-hf-id", hfId);
68
+ }
69
+ for (const script of findGsapScriptElements(doc)) script.textContent = "";
70
+ }
71
+
72
+ /** Same attribute set with identical values (order-insensitive). */
73
+ function attributesEqual(a: Element, b: Element): boolean {
74
+ const aNames = a.getAttributeNames();
75
+ if (aNames.length !== b.getAttributeNames().length) return false;
76
+ for (const name of aNames) {
77
+ if (a.getAttribute(name) !== b.getAttribute(name)) return false;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ // Soft-reloadable iff the docs differ SOLELY in identified-element attributes/
83
+ // inline style and/or the GSAP script; returns the changed identity keys to
84
+ // sync onto the live DOM. Structural/text diffs → null → the caller
85
+ // full-reloads. Pure.
86
+ //
87
+ // Change detection deliberately compares each identified element's OWN
88
+ // attribute surface — never its innerHTML. Identified elements NEST (the
89
+ // composition root wraps every clip), so an innerHTML comparison at the parent
90
+ // re-detects every descendant change and rejects the restore; that was the
91
+ // original always-full-reload undo blink. Structure/text integrity is instead
92
+ // guaranteed by the normalize-residual pass below: with identified-element
93
+ // attributes stripped and scripts blanked, ANY remaining difference (text,
94
+ // added/removed/reordered nodes, un-identified element attrs) still fails the
95
+ // docs-equal check and escalates to a full reload.
96
+ export function diffSoftReloadableRestore(
97
+ previous: string,
98
+ restored: string,
99
+ ): { changedElementKeys: string[] } | null {
100
+ let prevDoc: Document;
101
+ let nextDoc: Document;
102
+ try {
103
+ prevDoc = new DOMParser().parseFromString(previous, "text/html");
104
+ nextDoc = new DOMParser().parseFromString(restored, "text/html");
105
+ } catch {
106
+ return null;
107
+ }
108
+ const prevByKey = identityElementMap(prevDoc);
109
+ const nextByKey = identityElementMap(nextDoc);
110
+ if (!prevByKey || !nextByKey) return null;
111
+ // A different identity set means an element was added or removed (e.g. a
112
+ // split, a delete) — structural, so soft-reload can't express it.
113
+ if (prevByKey.size !== nextByKey.size) return null;
114
+ const changedElementKeys: string[] = [];
115
+ for (const [key, nextEl] of nextByKey) {
116
+ const prevEl = prevByKey.get(key);
117
+ if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
118
+ if (!attributesEqual(prevEl, nextEl)) changedElementKeys.push(key);
119
+ }
120
+ // Confirm nothing OUTSIDE identified-element attributes and GSAP scripts changed.
121
+ normalizeSoftResidual(prevDoc);
122
+ normalizeSoftResidual(nextDoc);
123
+ if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
124
+ return { changedElementKeys };
125
+ }
126
+
127
+ /** Copy every attribute from `source` onto the live `target`, dropping extras. */
128
+ function syncElementAttributes(target: Element, source: Element): void {
129
+ for (const name of [...target.getAttributeNames()]) {
130
+ if (!source.hasAttribute(name)) target.removeAttribute(name);
131
+ }
132
+ for (const name of source.getAttributeNames()) {
133
+ target.setAttribute(name, source.getAttribute(name) ?? "");
134
+ }
135
+ }
136
+
137
+ function readGsapScriptTexts(html: string): string[] {
138
+ const doc = new DOMParser().parseFromString(html, "text/html");
139
+ return findGsapScriptElements(doc).map((script) => script.textContent ?? "");
140
+ }
141
+
142
+ function hasAmbiguousGsapScriptChange(previous: string, restored: string): boolean {
143
+ const previousScripts = readGsapScriptTexts(previous);
144
+ const restoredScripts = readGsapScriptTexts(restored);
145
+ if (previousScripts.length <= 1 && restoredScripts.length <= 1) return false;
146
+ return (
147
+ previousScripts.length !== restoredScripts.length ||
148
+ previousScripts.some((script, index) => script !== restoredScripts[index])
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
154
+ * remount (which blanks the frame black and re-flashes the WebGL context). Only
155
+ * the active composition — the document living in the root iframe — is eligible;
156
+ * a sub-comp or multi-file restore falls back to `reloadPreview`.
157
+ *
158
+ * The restore is soft-applied when its only differences are identified-element
159
+ * (id / data-hf-id) attributes / inline-style and/or the GSAP script (see
160
+ * diffSoftReloadableRestore):
161
+ * 1. Each changed element's attribute surface (inline style, data-start /
162
+ * -duration, the studio manual-offset props + flags) is synced onto the live
163
+ * element — so a canvas-position revert lands on the live DOM the runtime's
164
+ * seek-reapply reads from, not just on disk.
165
+ * 2. The runtime refresh depends on what changed:
166
+ * - GSAP script text actually CHANGED between previous and restored → the
167
+ * restored script is re-run in place via applySoftReload (re-seeks to
168
+ * `currentTime`, re-folds manual edits).
169
+ * - Script unchanged or absent (the overwhelmingly common undo: z-order,
170
+ * lane move, timing shift, style tweak) → NO script execution — the
171
+ * blink-free finalization only (seek + __hfForceTimelineRebind + manual
172
+ * reapply, exactly the rebindPreviewTiming path), so timing-attribute
173
+ * reverts refresh their visibility windows. Re-running an unchanged
174
+ * script here used to be the biggest undo blink source: it tore down
175
+ * and rebuilt live timelines (and full-reloaded whenever the script
176
+ * couldn't be scoped) for restores that never touched it.
177
+ *
178
+ * Returns "soft" when applied in place, "full" when it escalated to reloadPreview
179
+ * (ineligible restore, missing target, or a permanent soft-reload failure).
180
+ */
181
+ // fallow-ignore-next-line complexity
182
+ export function applyUndoRestoreToPreview(
183
+ iframe: HTMLIFrameElement | null,
184
+ activeCompPath: string | null,
185
+ files: Record<string, UndoRestoreFile> | undefined,
186
+ currentTime: number,
187
+ reloadPreview: () => void,
188
+ ): "soft" | "full" {
189
+ // The master view carries a NULL activeCompPath but the root iframe shows
190
+ // index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
191
+ // Without this normalization every master-view undo failed the path gate and
192
+ // full-reloaded: the original "undo always blinks".
193
+ const activeDocPath = activeCompPath ?? "index.html";
194
+ const paths = files ? Object.keys(files) : [];
195
+ // Soft path only covers the single active-comp document in the root iframe.
196
+ if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
197
+ reloadPreview();
198
+ return "full";
199
+ }
200
+ const doc = iframe.contentDocument;
201
+ const win = iframe.contentWindow as PreviewWindow | null;
202
+ if (!doc || !win) {
203
+ reloadPreview();
204
+ return "full";
205
+ }
206
+ const { previous, restored } = files[activeDocPath]!;
207
+ const diff = diffSoftReloadableRestore(previous, restored);
208
+ if (!diff) {
209
+ reloadPreview();
210
+ return "full";
211
+ }
212
+ // A serialized snapshot cannot identify which of several GSAP scripts owns a
213
+ // rewrite. Keep attribute-only restores soft when every script byte is equal,
214
+ // but fail closed before touching the live DOM when an ambiguous script changed.
215
+ if (hasAmbiguousGsapScriptChange(previous, restored)) {
216
+ reloadPreview();
217
+ return "full";
218
+ }
219
+
220
+ // Resolve every changed pair BEFORE touching the live DOM. A missing target
221
+ // makes the soft restore incomplete, so escalate without leaving a partially
222
+ // restored preview behind.
223
+ const liveByKey = identityElementMap(doc);
224
+ const restoredByKey = identityElementMap(new DOMParser().parseFromString(restored, "text/html"));
225
+ if (!liveByKey || !restoredByKey) {
226
+ reloadPreview();
227
+ return "full";
228
+ }
229
+ const changedTargets: Array<{ live: Element; restored: Element }> = [];
230
+ for (const key of diff.changedElementKeys) {
231
+ const liveEl = liveByKey.get(key);
232
+ const restoredEl = restoredByKey.get(key);
233
+ if (!liveEl || !restoredEl) {
234
+ reloadPreview();
235
+ return "full";
236
+ }
237
+ changedTargets.push({ live: liveEl, restored: restoredEl });
238
+ }
239
+ // Sync each changed element's attributes onto the live DOM from the restored
240
+ // markup, so the runtime's seek-reapply reads the reverted values.
241
+ for (const target of changedTargets) syncElementAttributes(target.live, target.restored);
242
+
243
+ const restoredScript = extractGsapScriptText(restored);
244
+ const previousScript = extractGsapScriptText(previous);
245
+ if (restoredScript && restoredScript !== previousScript) {
246
+ const result = applySoftReload(iframe, restoredScript, {
247
+ onAsyncFailure: reloadPreview,
248
+ currentTimeOverride: currentTime,
249
+ });
250
+ if (result === "cannot-soft-reload") {
251
+ reloadPreview();
252
+ return "full";
253
+ }
254
+ return "soft";
255
+ }
256
+ // Script unchanged or absent — the live timelines are still valid; only the
257
+ // synced attributes need to take effect. Rebind-only finalization (zero
258
+ // script execution); plain seek + manual reapply as a degraded fallback when
259
+ // the runtime rebind hook is unavailable.
260
+ if (applySoftReloadFinalization(iframe, currentTime)) return "soft";
261
+ try {
262
+ win.__player?.seek?.(currentTime);
263
+ win.__hfStudioManualEditsApply?.();
264
+ } catch {
265
+ reloadPreview();
266
+ return "full";
267
+ }
268
+ return "soft";
269
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Occurrence index for a selector within its source document.
3
+ *
4
+ * The preview flattens multiple composition files into one DOM, so a raw
5
+ * `querySelectorAll` index is not a stable source-file identity. Callers supply
6
+ * their existing source resolver; this helper alone owns occurrence scoping.
7
+ */
8
+ export function getSourceScopedSelectorIndex(
9
+ doc: Document,
10
+ el: Element,
11
+ selector: string | undefined,
12
+ sourceFile: string | undefined,
13
+ resolveSourceFile: (candidate: Element) => string | undefined,
14
+ ): number | undefined {
15
+ if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
16
+ return undefined;
17
+ }
18
+
19
+ try {
20
+ const scope = sourceFile ?? "index.html";
21
+ const matches = Array.from(doc.querySelectorAll(selector)).filter(
22
+ (candidate) => (resolveSourceFile(candidate) ?? "index.html") === scope,
23
+ );
24
+ const matchIndex = matches.indexOf(el);
25
+ return matchIndex >= 0 ? matchIndex : undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
@@ -1,13 +1,80 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, expect, it } from "vitest";
2
3
  import {
3
4
  buildTimelineFileDropPlacements,
4
5
  buildTimelineAssetInsertHtml,
6
+ extendCompositionDurationIfNeeded,
7
+ fitTimelineAssetGeometry,
5
8
  getTimelineAssetKind,
6
9
  insertTimelineAssetIntoSource,
7
- resolveTimelineAssetInitialGeometry,
10
+ resolveTimelineAssetCompositionSize,
8
11
  resolveTimelineAssetSrc,
12
+ setCompositionDurationToContent,
9
13
  } from "./timelineAssetDrop";
10
14
 
15
+ describe("setCompositionDurationToContent", () => {
16
+ const src = (dur: number) =>
17
+ `<div id="root" data-composition-id="c" data-duration="${dur}">x</div>`;
18
+
19
+ it("shrinks the root duration to the content end", () => {
20
+ expect(setCompositionDurationToContent(src(20), 8)).toContain('data-duration="8"');
21
+ });
22
+
23
+ it("grows the root duration to the content end", () => {
24
+ expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
25
+ });
26
+
27
+ it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
28
+ expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
29
+ });
30
+
31
+ it("is a no-op when already equal", () => {
32
+ expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
33
+ });
34
+
35
+ // Reviewer round-2 finding #3: attribute-order and single-quote variants that
36
+ // the old order-dependent, double-quotes-only regex silently ignored.
37
+ it("patches when data-duration precedes data-composition-id", () => {
38
+ const source = `<div data-duration="20" data-composition-id="c">x</div>`;
39
+ expect(setCompositionDurationToContent(source, 8)).toBe(
40
+ `<div data-duration="8" data-composition-id="c">x</div>`,
41
+ );
42
+ });
43
+
44
+ it("patches single-quoted attributes and keeps the quote style", () => {
45
+ const source = `<div data-composition-id='c' data-duration='20'>x</div>`;
46
+ expect(setCompositionDurationToContent(source, 8)).toBe(
47
+ `<div data-composition-id='c' data-duration='8'>x</div>`,
48
+ );
49
+ });
50
+ });
51
+
52
+ describe("extendCompositionDurationIfNeeded", () => {
53
+ it("grows the root duration when a clip lands past the end", () => {
54
+ const source = `<div data-composition-id="c" data-duration="5">x</div>`;
55
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
56
+ `<div data-composition-id="c" data-duration="8">x</div>`,
57
+ );
58
+ });
59
+
60
+ it("is a no-op when the required end fits within the current duration", () => {
61
+ const source = `<div data-composition-id="c" data-duration="10">x</div>`;
62
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
63
+ });
64
+
65
+ it("grows even when the attribute order is swapped and quotes are single", () => {
66
+ const source = `<div data-duration='5' data-composition-id='c'>x</div>`;
67
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
68
+ `<div data-duration='8' data-composition-id='c'>x</div>`,
69
+ );
70
+ });
71
+
72
+ it("is a no-op when there is no composition root", () => {
73
+ const source = `<div data-duration="5">x</div>`;
74
+ expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
75
+ });
76
+ });
77
+
11
78
  describe("getTimelineAssetKind", () => {
12
79
  it("detects image, video, and audio assets", () => {
13
80
  expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
@@ -16,12 +83,28 @@ describe("getTimelineAssetKind", () => {
16
83
  expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
17
84
  expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
18
85
  });
86
+
87
+ it("classifies svg as image", () => {
88
+ expect(getTimelineAssetKind("assets/logo.svg")).toBe("image");
89
+ expect(getTimelineAssetKind("assets/ICON.SVG")).toBe("image");
90
+ });
91
+
92
+ it("classifies avif and webp as image", () => {
93
+ expect(getTimelineAssetKind("assets/photo.avif")).toBe("image");
94
+ expect(getTimelineAssetKind("assets/photo.webp")).toBe("image");
95
+ });
96
+
97
+ it("returns null for unknown extensions", () => {
98
+ expect(getTimelineAssetKind("assets/data.json")).toBeNull();
99
+ expect(getTimelineAssetKind("assets/font.woff2")).toBeNull();
100
+ });
19
101
  });
20
102
 
21
103
  describe("buildTimelineAssetInsertHtml", () => {
22
104
  it("builds an image clip with explicit timing and track", () => {
23
105
  const html = buildTimelineAssetInsertHtml({
24
106
  id: "photo_asset",
107
+ hfId: "hf-abc123",
25
108
  assetPath: "assets/photo.png",
26
109
  kind: "image",
27
110
  start: 1.25,
@@ -40,6 +123,7 @@ describe("buildTimelineAssetInsertHtml", () => {
40
123
  it("builds an audio clip without visual layout styles", () => {
41
124
  const html = buildTimelineAssetInsertHtml({
42
125
  id: "music_asset",
126
+ hfId: "hf-xyz789",
43
127
  assetPath: "assets/music.wav",
44
128
  kind: "audio",
45
129
  start: 0.5,
@@ -52,15 +136,13 @@ describe("buildTimelineAssetInsertHtml", () => {
52
136
  });
53
137
  });
54
138
 
55
- describe("resolveTimelineAssetInitialGeometry", () => {
139
+ describe("resolveTimelineAssetCompositionSize", () => {
56
140
  it("uses the target composition dimensions for visual media", () => {
57
141
  expect(
58
- resolveTimelineAssetInitialGeometry(
142
+ resolveTimelineAssetCompositionSize(
59
143
  `<div data-composition-id="main" data-width="330" data-height="228"></div>`,
60
144
  ),
61
145
  ).toEqual({
62
- left: 0,
63
- top: 0,
64
146
  width: 330,
65
147
  height: 228,
66
148
  });
@@ -84,7 +166,9 @@ describe("buildTimelineFileDropPlacements", () => {
84
166
  expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
85
167
  });
86
168
 
87
- it("uses the dropped start and spaces multiple files by duration on the same track", () => {
169
+ it("spaces multiple files by duration and keeps every one on the dropped track", () => {
170
+ // A clip placed onto an occupied track stays there (overlap is allowed); it is
171
+ // NOT bumped to a new track — that produced surprise empty tracks for users.
88
172
  expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
89
173
  { start: 1.5, track: 2 },
90
174
  { start: 2.7, track: 2 },
@@ -99,52 +183,6 @@ describe("buildTimelineFileDropPlacements", () => {
99
183
  { start: 7.7, track: 2 },
100
184
  ]);
101
185
  });
102
-
103
- it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
104
- expect(
105
- buildTimelineFileDropPlacements(
106
- { start: 1.5, track: 2 },
107
- [1.2, 1.6, 1.1],
108
- [
109
- { start: 0, duration: 8, track: 2 },
110
- { start: 0, duration: 4, track: 5 },
111
- ],
112
- ),
113
- ).toEqual([
114
- { start: 1.5, track: 6 },
115
- { start: 2.7, track: 6 },
116
- { start: 4.3, track: 6 },
117
- ]);
118
- });
119
-
120
- it("keeps a requested track above occupied rows when that track is clear", () => {
121
- expect(
122
- buildTimelineFileDropPlacements(
123
- { start: 1.5, track: 8 },
124
- [1.2, 1.6],
125
- [
126
- { start: 0, duration: 8, track: 2 },
127
- { start: 0, duration: 4, track: 5 },
128
- ],
129
- ),
130
- ).toEqual([
131
- { start: 1.5, track: 8 },
132
- { start: 2.7, track: 8 },
133
- ]);
134
- });
135
-
136
- it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
137
- expect(
138
- buildTimelineFileDropPlacements(
139
- { start: 0, track: 0 },
140
- [1.2, 1.6],
141
- [{ start: 0, duration: 8, track: 0 }],
142
- ),
143
- ).toEqual([
144
- { start: 0, track: 1 },
145
- { start: 1.2, track: 1 },
146
- ]);
147
- });
148
186
  });
149
187
 
150
188
  describe("insertTimelineAssetIntoSource", () => {
@@ -159,3 +197,57 @@ describe("insertTimelineAssetIntoSource", () => {
159
197
  expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
160
198
  });
161
199
  });
200
+
201
+ describe("buildTimelineAssetInsertHtml markup quality", () => {
202
+ const base = {
203
+ id: "clip_1",
204
+ hfId: "hf-test-1",
205
+ assetPath: "assets/a.mp4",
206
+ start: 1,
207
+ duration: 4,
208
+ track: 2,
209
+ zIndex: 3,
210
+ };
211
+
212
+ it("stamps data-hf-id on all kinds", () => {
213
+ for (const kind of ["image", "video", "audio"] as const) {
214
+ expect(buildTimelineAssetInsertHtml({ ...base, kind })).toContain('data-hf-id="hf-test-1"');
215
+ }
216
+ });
217
+
218
+ it("audio gets an explicit data-volume", () => {
219
+ expect(buildTimelineAssetInsertHtml({ ...base, kind: "audio" })).toContain('data-volume="1"');
220
+ });
221
+ });
222
+
223
+ describe("fitTimelineAssetGeometry", () => {
224
+ const comp = { width: 1920, height: 1080 };
225
+
226
+ it("centers a smaller-than-comp asset at natural size", () => {
227
+ expect(fitTimelineAssetGeometry({ width: 640, height: 360 }, comp)).toEqual({
228
+ left: 640,
229
+ top: 360,
230
+ width: 640,
231
+ height: 360,
232
+ });
233
+ });
234
+
235
+ it("scales an oversized asset down to fit, preserving aspect, centered", () => {
236
+ // 4000x1000 → capped to 1920 wide → 1920x480, centered vertically
237
+ expect(fitTimelineAssetGeometry({ width: 4000, height: 1000 }, comp)).toEqual({
238
+ left: 0,
239
+ top: 300,
240
+ width: 1920,
241
+ height: 480,
242
+ });
243
+ });
244
+
245
+ it("falls back to full-frame when natural size is unknown", () => {
246
+ expect(fitTimelineAssetGeometry(null, comp)).toEqual({
247
+ left: 0,
248
+ top: 0,
249
+ width: 1920,
250
+ height: 1080,
251
+ });
252
+ });
253
+ });