@hyperframes/studio 0.8.106 → 0.8.108

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 (184) hide show
  1. package/dist/assets/{hyperframes-player-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
  4. package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
  9. package/dist/chunk-AOWVEVEU.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +377 -12
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +13445 -11874
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +6 -3
  21. package/src/agentToolsExport.test.ts +8 -0
  22. package/src/captions/keyboard.test.ts +6 -0
  23. package/src/components/EditorShell.tsx +2 -0
  24. package/src/components/StudioRightPanels.tsx +2 -0
  25. package/src/components/TimelineToolbar.test.tsx +27 -0
  26. package/src/components/TimelineToolbar.tsx +2 -5
  27. package/src/components/editor/CropPresetBar.tsx +69 -0
  28. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  29. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  30. package/src/components/editor/DomEditOverlay.tsx +12 -18
  31. package/src/components/editor/PropertyPanel.tsx +1 -14
  32. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  33. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  34. package/src/components/editor/SnapToolbar.tsx +3 -4
  35. package/src/components/editor/carveOutput.ts +54 -0
  36. package/src/components/editor/cropPresetStore.ts +33 -0
  37. package/src/components/editor/cropPresets.test.ts +41 -0
  38. package/src/components/editor/cropPresets.ts +24 -0
  39. package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
  40. package/src/components/editor/domEditingLayers.test.ts +21 -0
  41. package/src/components/editor/domEditingLayers.ts +2 -0
  42. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  43. package/src/components/editor/fontAssets.test.ts +12 -22
  44. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
  45. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  46. package/src/components/editor/manualEdits.test.ts +4 -3
  47. package/src/components/editor/manualEditsDom.ts +28 -5
  48. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  49. package/src/components/editor/manualOffsetDrag.ts +15 -1
  50. package/src/components/editor/mediaAudioEdits.test.ts +271 -0
  51. package/src/components/editor/mediaAudioEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
  55. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  58. package/src/components/editor/propertyPanelTypes.ts +2 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +134 -2
  60. package/src/components/editor/useDomEditNudge.ts +46 -23
  61. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  62. package/src/components/editor/useFxCarve.ts +3 -61
  63. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  64. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  65. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  66. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  67. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  68. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  69. package/src/components/panels/SlideshowPanel.tsx +12 -7
  70. package/src/contexts/DomEditContext.test.tsx +62 -0
  71. package/src/contexts/DomEditContext.tsx +94 -77
  72. package/src/contexts/TimelineEditContext.tsx +1 -0
  73. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  74. package/src/hooks/domEditCommitTypes.ts +10 -0
  75. package/src/hooks/domEditPersistFailure.ts +12 -1
  76. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  77. package/src/hooks/domStyleCommit.ts +2 -8
  78. package/src/hooks/gsapDragCommit.ts +3 -8
  79. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  80. package/src/hooks/gsapEditOutcome.ts +2 -1
  81. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  82. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  83. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  84. package/src/hooks/gsapResizeIntercept.ts +6 -2
  85. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  86. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  87. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  88. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  89. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  90. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  91. package/src/hooks/useAppHotkeys.test.ts +17 -0
  92. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  93. package/src/hooks/useAppHotkeys.ts +3 -3
  94. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  95. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  96. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  97. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  98. package/src/hooks/useDomEditCommits.ts +2 -0
  99. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  100. package/src/hooks/useDomEditPersist.ts +28 -13
  101. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  102. package/src/hooks/useDomEditSession.ts +2 -3
  103. package/src/hooks/useDomEditTextCommits.ts +4 -8
  104. package/src/hooks/useDomGeometryCommits.ts +11 -2
  105. package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
  106. package/src/hooks/useEditHistoryActions.ts +13 -4
  107. package/src/hooks/useElementPicker.test.ts +62 -13
  108. package/src/hooks/useElementPicker.ts +10 -2
  109. package/src/hooks/useFreezeFrame.test.ts +56 -0
  110. package/src/hooks/useFreezeFrame.ts +121 -0
  111. package/src/hooks/useGsapAwareEditing.ts +6 -6
  112. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  113. package/src/hooks/useGsapScriptCommits.ts +5 -4
  114. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  115. package/src/hooks/useInlineTextEdit.ts +9 -7
  116. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  117. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  118. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  119. package/src/hooks/usePersistentEditHistory.ts +1 -1
  120. package/src/hooks/usePreviewPersistence.ts +6 -0
  121. package/src/hooks/useRazorSplit.ts +14 -1
  122. package/src/hooks/useRenderClipContent.test.ts +102 -0
  123. package/src/hooks/useRenderClipContent.ts +30 -23
  124. package/src/hooks/useTimelineEditing.ts +2 -2
  125. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  126. package/src/index.ts +8 -0
  127. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  128. package/src/player/components/ClipBadges.test.tsx +82 -0
  129. package/src/player/components/ClipBadges.tsx +67 -0
  130. package/src/player/components/ClipContextMenu.tsx +22 -0
  131. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  132. package/src/player/components/ClipPeakMarks.tsx +71 -0
  133. package/src/player/components/Timeline.test.ts +0 -21
  134. package/src/player/components/Timeline.tsx +0 -1
  135. package/src/player/components/TimelineClip.tsx +2 -0
  136. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  137. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  138. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  139. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  140. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  141. package/src/player/components/clipMenuDuck.test.ts +117 -0
  142. package/src/player/components/clipMenuDuck.ts +142 -0
  143. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  144. package/src/player/components/clipMenuNormalize.ts +96 -0
  145. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  146. package/src/player/components/clipMenuToolItems.tsx +235 -0
  147. package/src/player/components/clipPeakRuns.test.ts +38 -0
  148. package/src/player/components/clipPeakRuns.ts +59 -0
  149. package/src/player/components/clipToolAttrs.test.ts +161 -0
  150. package/src/player/components/clipToolAttrs.ts +187 -0
  151. package/src/player/components/timelineCallbacks.ts +1 -0
  152. package/src/player/components/timelineLayout.ts +0 -28
  153. package/src/player/components/useClipToolState.ts +92 -0
  154. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  155. package/src/player/lib/playbackShortcuts.ts +3 -7
  156. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  157. package/src/player/lib/timelineElementHelpers.ts +25 -2
  158. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  159. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  160. package/src/timelineChromeExports.test.tsx +7 -0
  161. package/src/utils/audioMeterMath.test.ts +37 -0
  162. package/src/utils/audioMeterMath.ts +9 -3
  163. package/src/utils/exportPeakCeiling.ts +1 -0
  164. package/src/utils/gsapSoftReload.ts +3 -2
  165. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  166. package/src/utils/gsapUndoRestore.test.ts +145 -1
  167. package/src/utils/gsapUndoRestore.ts +70 -21
  168. package/src/utils/previewRelativePath.ts +19 -0
  169. package/src/utils/sourcePatcher.ts +2 -2
  170. package/src/utils/studioFontHelpers.ts +2 -23
  171. package/src/utils/studioHelpers.test.ts +30 -0
  172. package/src/utils/studioHelpers.ts +18 -4
  173. package/src/utils/studioPendingEdits.test.ts +129 -0
  174. package/src/utils/studioPendingEdits.ts +61 -18
  175. package/src/utils/timelineInspector.test.ts +33 -2
  176. package/src/utils/timelineInspector.ts +16 -4
  177. package/src/utils/typingTarget.test.ts +13 -0
  178. package/src/utils/typingTarget.ts +5 -2
  179. package/src/webmcp/useStudioAgentTools.ts +3 -1
  180. package/dist/chunk-46XP7J3B.js.map +0 -1
  181. package/dist/chunk-DI4LCJHL.js +0 -102
  182. package/dist/chunk-DI4LCJHL.js.map +0 -1
  183. package/dist/chunk-ICTZJPQQ.js.map +0 -1
  184. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -12,6 +12,8 @@ import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
12
12
  import { findAuthoredElement, parseSavedSource } from "./authoredSource";
13
13
  import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
14
14
  import { markScenesStale } from "../player/sceneSwap";
15
+ import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
16
+ import { studioGestureDraws } from "../components/editor/manualEditsDom";
15
17
 
16
18
  type PreviewWindow = Window & {
17
19
  __player?: { seek?: (t: number) => void };
@@ -140,23 +142,55 @@ function diffRestoreDocs(prevDoc: Document, nextDoc: Document): string[] | null
140
142
  return prevRest === nextRest ? changedElementKeys : null;
141
143
  }
142
144
 
143
- /** Copy every attribute from `source` onto the live `target`, dropping extras. */
144
- function syncElementAttributes(target: Element, source: Element): void {
145
+ /** Copies `source`'s attributes onto `target`; under a gesture mark it merges them (keepDrawn). */
146
+ function syncElementAttributes(target: Element, source: Element, base?: Element): void {
147
+ const draws = base && studioGestureDraws(target);
148
+ const merged = draws ? keepDrawn(target, source, base, draws) : source;
145
149
  for (const name of [...target.getAttributeNames()]) {
146
- if (!source.hasAttribute(name)) target.removeAttribute(name);
150
+ if (!merged.hasAttribute(name)) target.removeAttribute(name);
147
151
  }
148
- for (const name of source.getAttributeNames()) {
149
- target.setAttribute(name, source.getAttribute(name) ?? "");
152
+ for (const name of merged.getAttributeNames()) {
153
+ target.setAttribute(name, merged.getAttribute(name) ?? "");
154
+ }
155
+ }
156
+
157
+ /** `source`, plus what a gesture in progress draws on `live` (whatever its value) or changed since `base`. */
158
+ function keepDrawn(live: Element, source: Element, base: Element, draws: readonly string[]) {
159
+ const merged = source.cloneNode(false) as Element;
160
+ const keepsMark = draws.includes("translate");
161
+ for (const name of live.getAttributeNames().filter((n) => n !== "style")) {
162
+ const value = live.getAttribute(name)!;
163
+ const mark = keepsMark && name === STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR;
164
+ if (mark || value !== base.getAttribute(name)) merged.setAttribute(name, value);
165
+ }
166
+ keepDrawnStyle(live as HTMLElement, base as HTMLElement, merged as HTMLElement, draws);
167
+ return merged;
168
+ }
169
+
170
+ function keepDrawnStyle(
171
+ live: HTMLElement,
172
+ base: HTMLElement,
173
+ merged: HTMLElement,
174
+ draws: readonly string[],
175
+ ) {
176
+ const value = (prop: string) => live.style.getPropertyValue(prop);
177
+ const drawn = (prop: string) =>
178
+ draws.includes(prop) || value(prop) !== base.style.getPropertyValue(prop);
179
+ const props = new Set([...Array.from(live.style), ...Array.from(base.style), ...draws]);
180
+ for (const prop of [...props].filter(drawn)) {
181
+ if (value(prop))
182
+ merged.style.setProperty(prop, value(prop), live.style.getPropertyPriority(prop));
183
+ else merged.style.removeProperty(prop);
150
184
  }
151
185
  }
152
186
 
153
187
  // A gesture folded into the script leaves marks on the live element that every seek would re-impose.
154
- function syncStaleEditMarks(doc: Document, restored: string): void {
155
- const restoredDoc = parseSavedSource(restored);
188
+ function syncStaleEditMarks(doc: Document, file: UndoRestoreFile): void {
189
+ const [restoredDoc, previousDoc] = [file.restored, file.previous].map(parseSavedSource);
156
190
  for (const live of doc.querySelectorAll(STUDIO_EDIT_ATTRS.map((attr) => `[${attr}]`).join())) {
157
191
  const source = findAuthoredElement(restoredDoc, live);
158
192
  if (source && STUDIO_EDIT_ATTRS.some((a) => live.getAttribute(a) !== source.getAttribute(a))) {
159
- syncElementAttributes(live, source);
193
+ syncElementAttributes(live, source, findAuthoredElement(previousDoc, live) ?? undefined);
160
194
  }
161
195
  }
162
196
  }
@@ -172,7 +206,7 @@ function hasAmbiguousGsapScriptChange(
172
206
  );
173
207
  }
174
208
 
175
- type RestoreTarget = { live: Element; restored: Element };
209
+ type RestoreTarget = { live: Element; restored: Element; previous?: Element };
176
210
  type RestorePlan = { targets: RestoreTarget[]; scripted: boolean };
177
211
 
178
212
  /** Whether either side has a GSAP script, or null when its scripts rule out an in-place restore. */
@@ -196,6 +230,7 @@ function scopeTargets(
196
230
  scope: ParentNode,
197
231
  keys: string[],
198
232
  restoredByKey: Map<string, Element>,
233
+ previousByKey: Map<string, Element>,
199
234
  ): RestoreTarget[] | null {
200
235
  const liveByKey = identityElementMap(scope);
201
236
  if (!liveByKey) return null;
@@ -205,7 +240,7 @@ function scopeTargets(
205
240
  const restored = restoredByKey.get(key);
206
241
  // The preview rewrites a sub-composition's own root, so its attributes are not the file's.
207
242
  if (!live || !restored || live.hasAttribute("data-hf-inner-root")) return null;
208
- targets.push({ live, restored });
243
+ targets.push({ live, restored, previous: previousByKey.get(key) });
209
244
  }
210
245
  return targets;
211
246
  }
@@ -222,9 +257,10 @@ function fileTargets(
222
257
  if (scripted === null) return null;
223
258
  const keys = diffRestoreDocs(prevDoc, nextDoc);
224
259
  const restoredByKey = identityElementMap(nextDoc);
225
- if (!keys || !restoredByKey) return null;
260
+ const previousByKey = identityElementMap(prevDoc);
261
+ if (!keys || !restoredByKey || !previousByKey) return null;
226
262
  const found = liveScopes(doc, path, isActive).map((scope) =>
227
- scopeTargets(scope, keys, restoredByKey),
263
+ scopeTargets(scope, keys, restoredByKey, previousByKey),
228
264
  );
229
265
  if (!found.length || found.includes(null)) return null;
230
266
  return { targets: (found as RestoreTarget[][]).flat(), scripted };
@@ -273,11 +309,14 @@ export function showRestoreInPlace(
273
309
  const win = iframe?.contentWindow as PreviewWindow | null;
274
310
  const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
275
311
  if (!iframe || !win || !plan || plan.scripted) return null;
276
- const before = plan.targets.map(({ live }) => [live, live.cloneNode(false) as Element] as const);
277
- for (const { live, restored } of plan.targets) syncElementAttributes(live, restored);
312
+ const before = plan.targets.map(
313
+ ({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
314
+ );
315
+ for (const { live, restored, previous } of plan.targets)
316
+ syncElementAttributes(live, restored, previous);
278
317
  const putBack = (time: number) => {
279
318
  if (iframe.contentDocument !== doc) return false;
280
- for (const [live, attributes] of before) syncElementAttributes(live, attributes);
319
+ for (const [live, attributes, shown] of before) syncElementAttributes(live, attributes, shown);
281
320
  return finalizeInPlace(iframe, win, time);
282
321
  };
283
322
  if (finalizeInPlace(iframe, win, currentTime)) return putBack;
@@ -299,9 +338,10 @@ export function showRestoreInPlace(
299
338
  * element — so a canvas-position revert lands on the live DOM the runtime's
300
339
  * seek-reapply reads from, not just on disk.
301
340
  * 2. The runtime refresh depends on what changed:
302
- * - GSAP script text actually CHANGED between previous and restored → the
303
- * restored script is re-run in place via applySoftReload (re-seeks to
304
- * `currentTime`, re-folds manual edits).
341
+ * - GSAP script text actually CHANGED between previous and restored, or a
342
+ * synced element was parsed by GSAP → the restored script is re-run in
343
+ * place via applySoftReload (re-seeks to `currentTime`, re-folds manual
344
+ * edits, re-parses those elements); several scripts can't, so it reloads.
305
345
  * - Script unchanged or absent (the overwhelmingly common undo: z-order,
306
346
  * lane move, timing shift, style tweak) → NO script execution — the
307
347
  * blink-free finalization only (seek + __hfForceTimelineRebind + manual
@@ -338,17 +378,26 @@ export function applyUndoRestoreToPreview(
338
378
  }
339
379
  // Sync each changed element's attributes onto the live DOM from the restored
340
380
  // markup, so the runtime's seek-reapply reads the reverted values.
341
- for (const target of plan.targets) syncElementAttributes(target.live, target.restored);
381
+ for (const target of plan.targets)
382
+ syncElementAttributes(target.live, target.restored, target.previous);
342
383
 
343
384
  const active = files[activeDocPath];
344
385
  const restoredScript = active ? extractGsapScriptText(active.restored) : null;
345
386
  const previousScript = active ? extractGsapScriptText(active.previous) : null;
346
- if (restoredScript && restoredScript !== previousScript) {
347
- syncStaleEditMarks(doc, active.restored);
387
+ const reparse = plan.scripted
388
+ ? plan.targets.map(({ live }) => live).filter((el) => "_gsap" in el)
389
+ : [];
390
+ if (reparse.length && !restoredScript) {
391
+ reloadPreview();
392
+ return "full";
393
+ }
394
+ if (restoredScript && (restoredScript !== previousScript || reparse.length)) {
395
+ syncStaleEditMarks(doc, active);
348
396
  const result = applySoftReload(iframe, restoredScript, {
349
397
  onAsyncFailure: reloadPreview,
350
398
  currentTimeOverride: currentTime,
351
399
  authoredHtml: active.restored,
400
+ reparse,
352
401
  });
353
402
  if (result === "cannot-soft-reload") {
354
403
  reloadPreview();
@@ -0,0 +1,19 @@
1
+ import { buildProjectApiPath } from "./projectRouting";
2
+
3
+ /** Resolve a media src to its project-relative preview path, or null. */
4
+ export function resolvePreviewRelative(
5
+ src: string | undefined,
6
+ pid: string,
7
+ origin: string,
8
+ ): string | null {
9
+ if (!src) return null;
10
+ try {
11
+ const parsed = new URL(src, origin);
12
+ const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
13
+ return parsed.pathname.startsWith(base)
14
+ ? decodeURIComponent(parsed.pathname.slice(base.length))
15
+ : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
@@ -12,7 +12,7 @@ function escapeStyleAttributeValue(value: string, quote: string): string {
12
12
  }
13
13
 
14
14
  /** Escape a string for safe use inside a double-quoted HTML attribute. */
15
- function escapeHtmlAttribute(value: string): string {
15
+ export function escapeHtmlAttribute(value: string): string {
16
16
  return value
17
17
  .replace(/&/g, "&amp;")
18
18
  .replace(/"/g, "&quot;")
@@ -21,7 +21,7 @@ function escapeHtmlAttribute(value: string): string {
21
21
  }
22
22
 
23
23
  /** Reverse escapeHtmlAttribute so callers get the original value. */
24
- function unescapeHtmlAttribute(value: string): string {
24
+ export function unescapeHtmlAttribute(value: string): string {
25
25
  return value
26
26
  .replace(/&quot;/g, '"')
27
27
  .replace(/&lt;/g, "<")
@@ -60,27 +60,6 @@ export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsse
60
60
  doc.head.appendChild(style);
61
61
  }
62
62
 
63
- export function ensureImportedFontFace(
64
- html: string,
65
- asset: ImportedFontAsset,
66
- sourceFile: string,
67
- ): string {
68
- const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
69
- if (html.includes(css)) return html;
70
-
71
- const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
72
- const styleMatch = styleRe.exec(html);
73
- if (styleMatch) {
74
- const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
75
- return html.replace(
76
- styleMatch[0],
77
- () => `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`,
78
- );
79
- }
80
-
81
- const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
82
- if (/<\/head>/i.test(html)) {
83
- return html.replace(/<\/head>/i, () => ` ${styleTag}\n </head>`);
84
- }
85
- return `${styleTag}\n${html}`;
63
+ export function importedFontFaceCssFor(asset: ImportedFontAsset, sourceFile: string): string {
64
+ return importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
86
65
  }
@@ -5,6 +5,7 @@ import {
5
5
  historyTooltipLabel,
6
6
  findMatchingTimelineElementId,
7
7
  findTimelineIdByAncestor,
8
+ resolveAssetHasAudio,
8
9
  resolveDroppedAssetDimensions,
9
10
  resolveElementTrack,
10
11
  resolveTimelineIdForSelection,
@@ -235,3 +236,32 @@ describe("historyTooltipLabel", () => {
235
236
  expect(historyTooltipLabel("redo", null)).toMatch(/^Redo \(.+\)$/);
236
237
  });
237
238
  });
239
+
240
+ describe("resolveAssetHasAudio", () => {
241
+ afterEach(() => vi.unstubAllGlobals());
242
+
243
+ const stubMetadata = (response: Response | Error) =>
244
+ vi.stubGlobal(
245
+ "fetch",
246
+ vi.fn(async () => {
247
+ if (response instanceof Error) throw response;
248
+ return response;
249
+ }),
250
+ );
251
+
252
+ it("answers the metadata endpoint's audio flag", async () => {
253
+ stubMetadata(new Response(JSON.stringify({ metadata: { hasAudio: true } })));
254
+ expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBe(true);
255
+ stubMetadata(new Response(JSON.stringify({ metadata: { hasAudio: false } })));
256
+ expect(await resolveAssetHasAudio("p1", "assets/cutout.webm")).toBe(false);
257
+ });
258
+
259
+ it("answers null when the probe cannot tell", async () => {
260
+ stubMetadata(new Response("nope", { status: 500 }));
261
+ expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
262
+ stubMetadata(new Error("offline"));
263
+ expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
264
+ stubMetadata(new Response(JSON.stringify({ metadata: {} })));
265
+ expect(await resolveAssetHasAudio("p1", "assets/talk.mp4")).toBeNull();
266
+ });
267
+ });
@@ -388,13 +388,27 @@ export async function resolveDroppedAssetHasAudio(
388
388
  kind: TimelineAssetKind,
389
389
  ): Promise<boolean> {
390
390
  if (kind !== "video") return false;
391
+ return (await resolveAssetHasAudio(projectId, assetPath)) === true;
392
+ }
393
+
394
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
395
+ return typeof value === "object" && value !== null;
396
+ }
397
+
398
+ /** An asset's audio stream from the metadata endpoint: null when the probe can't tell. */
399
+ export async function resolveAssetHasAudio(
400
+ projectId: string,
401
+ assetPath: string,
402
+ ): Promise<boolean | null> {
391
403
  try {
392
404
  const response = await fetch(mediaMetadataUrl(projectId, assetPath));
393
- if (!response.ok) return false;
394
- const data = (await response.json()) as { metadata?: { hasAudio?: boolean } } | null;
395
- return data?.metadata?.hasAudio === true;
405
+ if (!response.ok) return null;
406
+ const data: unknown = await response.json();
407
+ const metadata = isPlainRecord(data) ? data.metadata : undefined;
408
+ const hasAudio = isPlainRecord(metadata) ? metadata.hasAudio : undefined;
409
+ return typeof hasAudio === "boolean" ? hasAudio : null;
396
410
  } catch {
397
- return false;
411
+ return null;
398
412
  }
399
413
  }
400
414
 
@@ -2,8 +2,12 @@
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  addStudioPendingEditFlushListener,
5
+ beginStudioPendingEdit,
5
6
  flushStudioPendingEdits,
7
+ hasStudioPendingEdits,
8
+ revertNewestStudioPendingEdit,
6
9
  trackStudioPendingEdit,
10
+ trackedStudioEdit,
7
11
  } from "./studioPendingEdits";
8
12
  import { StudioFileConflictError } from "./studioSaveDiagnostics";
9
13
 
@@ -36,6 +40,22 @@ describe("studio pending edit flush", () => {
36
40
  input.remove();
37
41
  });
38
42
 
43
+ it.each([
44
+ ["contenteditable", "plaintext-only"],
45
+ ["role", "combobox"],
46
+ ["role", "searchbox"],
47
+ ["role", "switch"],
48
+ ])("counts a focused [%s=%s] as a pending edit", (attribute, value) => {
49
+ const field = document.createElement("div");
50
+ field.setAttribute(attribute, value);
51
+ field.tabIndex = 0;
52
+ document.body.append(field);
53
+ field.focus();
54
+
55
+ expect(hasStudioPendingEdits()).toBe(true);
56
+ field.remove();
57
+ });
58
+
39
59
  it("waits for a post-blur effect to register its pending edit listener", async () => {
40
60
  const input = document.createElement("textarea");
41
61
  document.body.append(input);
@@ -148,3 +168,112 @@ describe("studio pending edit flush", () => {
148
168
  expect(steps).toEqual(["persisted", "flushed"]);
149
169
  });
150
170
  });
171
+
172
+ describe("a drain that meets a conflict", () => {
173
+ it("still waits for the edits that started while it ran before it reports the conflict", async () => {
174
+ const conflict = new StudioFileConflictError({
175
+ filePath: "index.html",
176
+ currentVersion: "v2",
177
+ currentContent: "external",
178
+ attemptedContent: "studio",
179
+ });
180
+ let finish!: () => void;
181
+ let laterSaved = false;
182
+ trackStudioPendingEdit(
183
+ Promise.resolve().then(() => {
184
+ trackStudioPendingEdit(
185
+ new Promise<void>((resolve) => (finish = resolve)).then(() => (laterSaved = true)),
186
+ );
187
+ throw conflict;
188
+ }),
189
+ );
190
+ let drained = false;
191
+ const drain = flushStudioPendingEdits().then((result) => ((drained = true), result));
192
+ await new Promise((resolve) => setTimeout(resolve, 10));
193
+ expect(drained).toBe(false);
194
+ finish();
195
+ await expect(drain).resolves.toEqual({ status: "conflict", error: conflict });
196
+ expect(laterSaved).toBe(true);
197
+ });
198
+ });
199
+
200
+ describe("trackedStudioEdit", () => {
201
+ it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => {
202
+ let fail!: () => void;
203
+ const failure = new Error("The save failed.");
204
+ const edit = trackedStudioEdit(
205
+ () => new Promise<void>((_, reject) => (fail = () => reject(failure))),
206
+ );
207
+ const saved = edit();
208
+ expect(hasStudioPendingEdits()).toBe(true);
209
+ const drain = flushStudioPendingEdits();
210
+ fail();
211
+ await expect(saved).rejects.toThrow("The save failed.");
212
+ await expect(drain).resolves.toEqual({ status: "failed", error: failure });
213
+ expect(hasStudioPendingEdits()).toBe(false);
214
+ });
215
+ });
216
+
217
+ describe("a pending edit undo can paint back", () => {
218
+ const shown: string[] = [];
219
+ const edit = (name: string) =>
220
+ beginStudioPendingEdit(() => {
221
+ shown.push(`${name} undone`);
222
+ return () => shown.push(`${name} again`);
223
+ });
224
+
225
+ it("paints back only the newest edit, once, and shows it again on request", () => {
226
+ shown.length = 0;
227
+ const first = edit("first");
228
+ const second = edit("second");
229
+ const again = revertNewestStudioPendingEdit();
230
+ expect(shown).toEqual(["second undone"]);
231
+ expect(second.reverted()).toBe(true);
232
+ expect(first.reverted()).toBe(false);
233
+ expect(revertNewestStudioPendingEdit()).toBeNull();
234
+ again!();
235
+ expect(shown).toEqual(["second undone", "second again"]);
236
+ first.settle();
237
+ second.settle();
238
+ });
239
+
240
+ it("paints nothing when the newest edit has no revert", async () => {
241
+ shown.length = 0;
242
+ const move = edit("move");
243
+ let saved!: () => void;
244
+ trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve)));
245
+ expect(revertNewestStudioPendingEdit()).toBeNull();
246
+ expect(shown).toEqual([]);
247
+ saved();
248
+ move.settle();
249
+ await flushStudioPendingEdits();
250
+ });
251
+
252
+ it("counts what the edit starts inside adopt as that edit, not a newer one", async () => {
253
+ shown.length = 0;
254
+ const move = edit("move");
255
+ let saved!: () => void;
256
+ const save = move.adopt(() =>
257
+ trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve))),
258
+ );
259
+ move.settle(save);
260
+ revertNewestStudioPendingEdit();
261
+ expect(shown).toEqual(["move undone"]);
262
+ saved();
263
+ await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
264
+ expect(hasStudioPendingEdits()).toBe(false);
265
+ });
266
+ });
267
+
268
+ describe("a pending edit whose start throws", () => {
269
+ it("ends, so undo and export never wait on it", async () => {
270
+ const edit = beginStudioPendingEdit(null);
271
+ expect(() =>
272
+ edit.adopt(() => {
273
+ throw new Error("The commit threw.");
274
+ }),
275
+ ).toThrow("The commit threw.");
276
+ await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
277
+ expect(hasStudioPendingEdits()).toBe(false);
278
+ });
279
+ });
@@ -1,4 +1,5 @@
1
1
  import { StudioFileConflictError, type StudioSaveDrainResult } from "./studioSaveDiagnostics";
2
+ import { isTypingTarget } from "./typingTarget";
2
3
 
3
4
  const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
4
5
 
@@ -8,7 +9,10 @@ interface StudioFlushPendingEditsDetail {
8
9
 
9
10
  export type StudioPendingEditsDrainResult = StudioSaveDrainResult;
10
11
 
11
- const pendingEditPromises = new Set<Promise<unknown>>();
12
+ export type StudioEditRevert = () => () => void;
13
+
14
+ const pendingEdits = new Map<Promise<unknown>, { revert: StudioEditRevert | null }>();
15
+ let adopting = false;
12
16
 
13
17
  function waitForPostBlurEffects(): Promise<void> {
14
18
  return new Promise((resolve) => setTimeout(resolve, 0));
@@ -29,18 +33,15 @@ function inspectDrainFailures(results: PromiseSettledResult<unknown>[]): {
29
33
 
30
34
  function focusedField(): HTMLElement | null {
31
35
  const active = document.activeElement;
32
- return active instanceof HTMLElement &&
33
- active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]')
34
- ? active
35
- : null;
36
+ return active instanceof HTMLElement && isTypingTarget(active) ? active : null;
36
37
  }
37
38
 
38
39
  export function hasStudioPendingEdits(): boolean {
39
- return pendingEditPromises.size > 0 || focusedField() !== null;
40
+ return pendingEdits.size > 0 || focusedField() !== null;
40
41
  }
41
42
 
42
43
  export function isStudioEditSaving(): boolean {
43
- return pendingEditPromises.size > 0;
44
+ return pendingEdits.size > 0;
44
45
  }
45
46
 
46
47
  export function afterStudioPendingEdits(run: () => void): () => void {
@@ -48,7 +49,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
48
49
  const check = () => {
49
50
  if (!waiting) return;
50
51
  if (isStudioEditSaving()) {
51
- void Promise.allSettled([...pendingEditPromises]).then(check);
52
+ void Promise.allSettled([...pendingEdits.keys()]).then(check);
52
53
  return;
53
54
  }
54
55
  waiting = false;
@@ -65,20 +66,61 @@ export function trackStudioPendingEdit(
65
66
  ): Promise<unknown> | undefined {
66
67
  if (!result) return undefined;
67
68
  const promise = Promise.resolve(result);
68
- pendingEditPromises.add(promise);
69
+ if (adopting) return promise;
70
+ pendingEdits.set(promise, { revert: null });
69
71
  promise.then(
70
- () => pendingEditPromises.delete(promise),
71
- () => pendingEditPromises.delete(promise),
72
+ () => pendingEdits.delete(promise),
73
+ () => pendingEdits.delete(promise),
72
74
  );
73
75
  return promise;
74
76
  }
75
77
 
78
+ export function trackedStudioEdit<Args extends unknown[], R>(
79
+ edit: (...args: Args) => R,
80
+ ): (...args: Args) => R {
81
+ return (...args) => {
82
+ const result = edit(...args);
83
+ if (result instanceof Promise) trackStudioPendingEdit(result);
84
+ return result;
85
+ };
86
+ }
87
+
88
+ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
89
+ let settle!: (saved?: Promise<unknown>) => void;
90
+ const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
91
+ const entry = pendingEdits.get(promise)!;
92
+ entry.revert = revert;
93
+ return {
94
+ settle,
95
+ reverted: () => entry.revert === null && revert !== null,
96
+ // Only what `start` registers synchronously is adopted; a registration after an await is a newer edit.
97
+ adopt<T>(start: () => T): T {
98
+ adopting = true;
99
+ try {
100
+ return start();
101
+ } catch (error) {
102
+ settle();
103
+ throw error;
104
+ } finally {
105
+ adopting = false;
106
+ }
107
+ },
108
+ };
109
+ }
110
+
111
+ export function revertNewestStudioPendingEdit(): (() => void) | null {
112
+ const newest = [...pendingEdits.values()].at(-1);
113
+ const revert = newest?.revert;
114
+ if (!newest || !revert) return null;
115
+ newest.revert = null;
116
+ return revert();
117
+ }
118
+
76
119
  export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
77
120
  const active = focusedField();
78
121
  if (active) {
79
122
  active.blur();
80
- // ponytail: Preserve synchronous/microtask blur commits, then cross one task boundary
81
- // so React effects triggered by the blur can register their flush listener.
123
+ // ponytail: keep blur commits, then cross one task so effects the blur runs can add their listener.
82
124
  await Promise.resolve();
83
125
  await waitForPostBlurEffects();
84
126
  }
@@ -86,15 +128,16 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
86
128
  window.dispatchEvent(
87
129
  new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
88
130
  );
131
+ let conflict: StudioFileConflictError | undefined;
89
132
  let firstFailure: PromiseRejectedResult | undefined;
90
- while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
91
- const promises = [...detail.promises, ...pendingEditPromises];
133
+ while (detail.promises.length > 0 || pendingEdits.size > 0) {
134
+ const promises = [...detail.promises, ...pendingEdits.keys()];
92
135
  detail.promises = [];
93
- const results = await Promise.allSettled(promises);
94
- const batchFailures = inspectDrainFailures(results);
95
- if (batchFailures.conflict) return { status: "conflict", error: batchFailures.conflict };
136
+ const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
137
+ conflict ??= batchFailures.conflict;
96
138
  firstFailure ??= batchFailures.firstFailure;
97
139
  }
140
+ if (conflict) return { status: "conflict", error: conflict };
98
141
  return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
99
142
  }
100
143
 
@@ -4,6 +4,7 @@ import {
4
4
  canHideSelections,
5
5
  isAudioDomElement,
6
6
  isAudioTimelineElement,
7
+ isMusicSourceElement,
7
8
  isMusicTrack,
8
9
  resolveBeatSourceTrack,
9
10
  } from "./timelineInspector";
@@ -12,9 +13,15 @@ import type { TimelineElement } from "../player";
12
13
  // Minimal element factory for tests
13
14
  function el(
14
15
  overrides: Partial<
15
- Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration">
16
+ Pick<
17
+ TimelineElement,
18
+ "tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
19
+ >
16
20
  >,
17
- ): Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"> {
21
+ ): Pick<
22
+ TimelineElement,
23
+ "tag" | "src" | "id" | "domId" | "timelineRole" | "duration" | "hasAudio" | "muted"
24
+ > {
18
25
  return {
19
26
  tag: "audio",
20
27
  src: "assets/track.mp3",
@@ -153,3 +160,27 @@ describe("isAudioDomElement / canHideSelections", () => {
153
160
  expect(canHideSelections([{ element: el("hf-audio-group") }])).toBe(false);
154
161
  });
155
162
  });
163
+
164
+ describe("beat source from a video with sound", () => {
165
+ const talk = { tag: "video", src: "assets/clip.mp4", hasAudio: true };
166
+
167
+ it("counts an audible video as a music source but never a muted or silent one", () => {
168
+ expect(isMusicSourceElement(el(talk))).toBe(true);
169
+ expect(isMusicSourceElement(el({ ...talk, muted: true }))).toBe(false);
170
+ expect(isMusicSourceElement(el({ ...talk, hasAudio: false }))).toBe(false);
171
+ expect(isAudioTimelineElement(el(talk))).toBe(false);
172
+ });
173
+
174
+ it("takes a video tagged as music as the beat source", () => {
175
+ const video = el({ ...talk, timelineRole: "music" });
176
+ expect(isMusicTrack(video)).toBe(true);
177
+ expect(resolveBeatSourceTrack([video])).toEqual({ element: video, isFallback: false });
178
+ });
179
+
180
+ it("falls back to the longest audible video when there is no audio", () => {
181
+ const short = el({ ...talk, id: "a", duration: 3 });
182
+ const long = el({ ...talk, id: "b", duration: 9 });
183
+ const muted = el({ ...talk, id: "c", duration: 20, muted: true });
184
+ expect(resolveBeatSourceTrack([short, long, muted])?.element).toBe(long);
185
+ });
186
+ });