@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { FlatMediaSection } from "./propertyPanelFlatMediaSection";
7
7
  import { MediaSection } from "./propertyPanelMediaSection";
8
8
  import type { DomEditSelection } from "./domEditing";
9
+ import type { PatchOperation } from "../../utils/sourcePatcher";
9
10
  import { formatTimingValue } from "./propertyPanelHelpers";
10
11
  import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
11
12
 
@@ -62,6 +63,7 @@ function renderSection(overrides: Partial<DomEditSelection> = {}) {
62
63
  onSetStyle={vi.fn()}
63
64
  onSetAttribute={vi.fn()}
64
65
  onSetHtmlAttribute={vi.fn()}
66
+ onSetAttributeBatch={vi.fn()}
65
67
  />,
66
68
  );
67
69
  });
@@ -102,6 +104,7 @@ describe("FlatMediaSection — cutout", () => {
102
104
  onSetStyle={vi.fn()}
103
105
  onSetAttribute={onSetAttribute}
104
106
  onSetHtmlAttribute={onSetHtmlAttribute}
107
+ onSetAttributeBatch={vi.fn()}
105
108
  onRemoveBackground={onRemoveBackground}
106
109
  />,
107
110
  );
@@ -149,6 +152,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
149
152
  onSetStyle={vi.fn()}
150
153
  onSetAttribute={onSetAttribute}
151
154
  onSetHtmlAttribute={vi.fn()}
155
+ onSetAttributeBatch={vi.fn()}
152
156
  />,
153
157
  );
154
158
  });
@@ -174,6 +178,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
174
178
  onSetStyle={vi.fn()}
175
179
  onSetAttribute={onSetAttribute}
176
180
  onSetHtmlAttribute={vi.fn()}
181
+ onSetAttributeBatch={vi.fn()}
177
182
  />,
178
183
  );
179
184
  });
@@ -208,6 +213,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
208
213
  onSetStyle={vi.fn()}
209
214
  onSetAttribute={onSetAttribute}
210
215
  onSetHtmlAttribute={vi.fn()}
216
+ onSetAttributeBatch={vi.fn()}
211
217
  volumeAutomated
212
218
  onCommitVolumeAt={onCommitVolumeAt}
213
219
  />,
@@ -242,6 +248,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
242
248
  onSetStyle={vi.fn()}
243
249
  onSetAttribute={onSetAttribute}
244
250
  onSetHtmlAttribute={vi.fn()}
251
+ onSetAttributeBatch={vi.fn()}
245
252
  />,
246
253
  );
247
254
  });
@@ -273,6 +280,7 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
273
280
  onSetStyle={vi.fn()}
274
281
  onSetAttribute={onSetAttribute}
275
282
  onSetHtmlAttribute={vi.fn()}
283
+ onSetAttributeBatch={vi.fn()}
276
284
  />,
277
285
  );
278
286
  });
@@ -308,6 +316,7 @@ describe("FlatMediaSection — loop/muted/has-audio", () => {
308
316
  onSetStyle={vi.fn()}
309
317
  onSetAttribute={onSetAttribute}
310
318
  onSetHtmlAttribute={onSetHtmlAttribute}
319
+ onSetAttributeBatch={vi.fn()}
311
320
  />,
312
321
  );
313
322
  });
@@ -323,92 +332,6 @@ describe("FlatMediaSection — loop/muted/has-audio", () => {
323
332
  expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
324
333
  act(() => root.unmount());
325
334
  });
326
-
327
- it("toggles muted via onSetHtmlAttribute", () => {
328
- const onSetHtmlAttribute = vi.fn();
329
- const onSetAttribute = vi.fn();
330
- const element = makeVideoElement();
331
- const host = document.createElement("div");
332
- document.body.append(host);
333
- const root = createRoot(host);
334
- act(() => {
335
- root.render(
336
- <FlatMediaSection
337
- projectDir={null}
338
- element={element}
339
- styles={{}}
340
- onSetStyle={vi.fn()}
341
- onSetAttribute={onSetAttribute}
342
- onSetHtmlAttribute={onSetHtmlAttribute}
343
- />,
344
- );
345
- });
346
- const mutedToggle = host.querySelector<HTMLButtonElement>(
347
- '[data-flat-toggle="true"][aria-label="Muted"]',
348
- );
349
- expect(mutedToggle?.getAttribute("aria-checked")).toBe("false");
350
- act(() => mutedToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
351
- expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
352
- act(() => root.unmount());
353
- });
354
-
355
- it("enables has-audio-track and clears muted on click", () => {
356
- const onSetHtmlAttribute = vi.fn();
357
- const onSetAttribute = vi.fn();
358
- const element = makeVideoElement();
359
- const host = document.createElement("div");
360
- document.body.append(host);
361
- const root = createRoot(host);
362
- act(() => {
363
- root.render(
364
- <FlatMediaSection
365
- projectDir={null}
366
- element={element}
367
- styles={{}}
368
- onSetStyle={vi.fn()}
369
- onSetAttribute={onSetAttribute}
370
- onSetHtmlAttribute={onSetHtmlAttribute}
371
- />,
372
- );
373
- });
374
- const hasAudioToggle = host.querySelector<HTMLButtonElement>(
375
- '[data-flat-toggle="true"][aria-label="Has audio track"]',
376
- );
377
- expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("false");
378
- act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
379
- expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "true");
380
- expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", null);
381
- act(() => root.unmount());
382
- });
383
-
384
- it("disables has-audio-track and sets muted on click", () => {
385
- const onSetHtmlAttribute = vi.fn();
386
- const onSetAttribute = vi.fn();
387
- const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
388
- const host = document.createElement("div");
389
- document.body.append(host);
390
- const root = createRoot(host);
391
- act(() => {
392
- root.render(
393
- <FlatMediaSection
394
- projectDir={null}
395
- element={element}
396
- styles={{}}
397
- onSetStyle={vi.fn()}
398
- onSetAttribute={onSetAttribute}
399
- onSetHtmlAttribute={onSetHtmlAttribute}
400
- />,
401
- );
402
- });
403
- const hasAudioToggle = host.querySelector<HTMLButtonElement>(
404
- '[data-flat-toggle="true"][aria-label="Has audio track"]',
405
- );
406
- expect(hasAudioToggle?.getAttribute("aria-checked")).toBe("true");
407
- act(() => hasAudioToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
408
- expect(onSetAttribute).toHaveBeenCalledWith("has-audio", "");
409
- expect(onSetHtmlAttribute).toHaveBeenCalledWith("muted", "true");
410
- act(() => root.unmount());
411
- });
412
335
  });
413
336
 
414
337
  describe("FlatMediaSection — fit/position", () => {
@@ -428,6 +351,7 @@ describe("FlatMediaSection — fit/position", () => {
428
351
  onSetStyle={onSetStyle}
429
352
  onSetAttribute={vi.fn()}
430
353
  onSetHtmlAttribute={vi.fn()}
354
+ onSetAttributeBatch={vi.fn()}
431
355
  />,
432
356
  );
433
357
  });
@@ -461,6 +385,7 @@ describe("FlatMediaSection — fit/position", () => {
461
385
  onSetStyle={onSetStyle}
462
386
  onSetAttribute={vi.fn()}
463
387
  onSetHtmlAttribute={vi.fn()}
388
+ onSetAttributeBatch={vi.fn()}
464
389
  />,
465
390
  );
466
391
  });
@@ -504,6 +429,7 @@ function renderWithRate(element: DomEditSelection, onSetAttribute = vi.fn()) {
504
429
  onSetStyle={vi.fn()}
505
430
  onSetAttribute={onSetAttribute}
506
431
  onSetHtmlAttribute={vi.fn()}
432
+ onSetAttributeBatch={vi.fn()}
507
433
  rate={{
508
434
  automated: false,
509
435
  automatedValue: undefined,
@@ -543,7 +469,9 @@ describe("FlatMediaSection — audio clips", () => {
543
469
  );
544
470
  act(() => audio.root.unmount());
545
471
 
546
- const silentVideo = renderWithRate(makeVideoElement({ dataAttributes: { duration: "10" } }));
472
+ const mutedSelection = makeVideoElement({ dataAttributes: { duration: "10" } });
473
+ mutedSelection.element.setAttribute("muted", "");
474
+ const silentVideo = renderWithRate(mutedSelection);
547
475
  expect(labelsOf(silentVideo.host)).not.toContain("Fade in");
548
476
  act(() => silentVideo.root.unmount());
549
477
 
@@ -659,6 +587,7 @@ describe("FlatMediaSection — audio clips", () => {
659
587
  onSetStyle={vi.fn()}
660
588
  onSetAttribute={onSetAttribute}
661
589
  onSetHtmlAttribute={vi.fn()}
590
+ onSetAttributeBatch={vi.fn()}
662
591
  />,
663
592
  );
664
593
  });
@@ -708,3 +637,273 @@ function typeInto(input: HTMLInputElement, value: string) {
708
637
  setter?.call(input, value);
709
638
  input.dispatchEvent(new Event("input", { bubbles: true }));
710
639
  }
640
+
641
+ type PanelKind = "flat" | "design";
642
+
643
+ type BatchOptions = { label: string; prepareContent?: (html: string) => string };
644
+
645
+ function makeHandlers() {
646
+ return {
647
+ onSetAttribute: vi.fn(),
648
+ onSetHtmlAttribute: vi.fn(),
649
+ onSetAttributeBatch: vi.fn<
650
+ (
651
+ selection: DomEditSelection,
652
+ ops: PatchOperation[],
653
+ options: BatchOptions,
654
+ ) => Promise<boolean>
655
+ >(async () => true),
656
+ };
657
+ }
658
+
659
+ function mountPanel(
660
+ kind: PanelKind,
661
+ element: DomEditSelection,
662
+ handlers: ReturnType<typeof makeHandlers> & {
663
+ onRemoveBackground?: () => Promise<{ outputPath: string }>;
664
+ },
665
+ ) {
666
+ const host = document.createElement("div");
667
+ document.body.append(host);
668
+ const root = createRoot(host);
669
+ const Panel = kind === "flat" ? FlatMediaSection : MediaSection;
670
+ act(() => {
671
+ root.render(
672
+ <Panel
673
+ projectId="p1"
674
+ projectDir={null}
675
+ element={element}
676
+ styles={{}}
677
+ onSetStyle={vi.fn()}
678
+ {...handlers}
679
+ />,
680
+ );
681
+ });
682
+ return { host, root };
683
+ }
684
+
685
+ async function flush() {
686
+ for (let i = 0; i < 8; i++) await Promise.resolve();
687
+ }
688
+
689
+ async function clickToggle(kind: PanelKind, host: HTMLElement, label: string, option: string) {
690
+ if (kind === "flat") {
691
+ const toggle = host.querySelector<HTMLButtonElement>(
692
+ `[data-flat-toggle="true"][aria-label="${label}"]`,
693
+ );
694
+ if (!toggle) throw new Error(`expected ${label} toggle`);
695
+ await act(async () => {
696
+ toggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
697
+ await flush();
698
+ });
699
+ return;
700
+ }
701
+ const row = Array.from(host.querySelectorAll("span")).find(
702
+ (n) => n.textContent === label,
703
+ )?.parentElement;
704
+ const button = Array.from(row?.querySelectorAll("button") ?? []).find(
705
+ (b) => b.textContent === option,
706
+ );
707
+ if (!button) throw new Error(`expected ${label} segmented button`);
708
+ await act(async () => {
709
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
710
+ await flush();
711
+ });
712
+ }
713
+
714
+ function stubHasAudioProbe(hasAudio: boolean | "fail") {
715
+ const fetchMock = vi.fn(async (_input: Parameters<typeof fetch>[0]) =>
716
+ hasAudio === "fail"
717
+ ? new Response("nope", { status: 500 })
718
+ : new Response(JSON.stringify({ metadata: { hasAudio } })),
719
+ );
720
+ vi.stubGlobal("fetch", fetchMock);
721
+ return fetchMock;
722
+ }
723
+
724
+ const htmlOp = (property: string, value: string | null) => ({
725
+ type: "html-attribute",
726
+ property,
727
+ value,
728
+ });
729
+ const dataOp = (property: string, value: string | null) => ({
730
+ type: "attribute",
731
+ property,
732
+ value,
733
+ });
734
+
735
+ describe.each<PanelKind>(["flat", "design"])("%s panel — Muted is one atomic edit", (kind) => {
736
+ afterEach(() => vi.unstubAllGlobals());
737
+
738
+ it("muting an audible video sets muted and removes has-audio in ONE write", async () => {
739
+ const handlers = makeHandlers();
740
+ const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
741
+ const { host, root } = mountPanel(kind, element, handlers);
742
+ await clickToggle(kind, host, "Muted", "On");
743
+ expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
744
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
745
+ htmlOp("muted", "true"),
746
+ dataOp("has-audio", null),
747
+ ]);
748
+ expect(handlers.onSetAttribute).not.toHaveBeenCalled();
749
+ expect(handlers.onSetHtmlAttribute).not.toHaveBeenCalled();
750
+ act(() => root.unmount());
751
+ });
752
+
753
+ it("unmuting a video the probe hears stamps has-audio=true in the same write", async () => {
754
+ const probe = stubHasAudioProbe(true);
755
+ const handlers = makeHandlers();
756
+ const element = makeVideoElement();
757
+ element.element.setAttribute("muted", "");
758
+ const { host, root } = mountPanel(kind, element, handlers);
759
+ await clickToggle(kind, host, "Muted", "Off");
760
+ expect(String(probe.mock.calls[0]?.[0])).toContain("assets%2Fintro-loop.mp4");
761
+ expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
762
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
763
+ htmlOp("muted", null),
764
+ dataOp("has-audio", "true"),
765
+ ]);
766
+ act(() => root.unmount());
767
+ });
768
+
769
+ it("unmuting a silent-probe video writes has-audio=false", async () => {
770
+ stubHasAudioProbe(false);
771
+ const handlers = makeHandlers();
772
+ const element = makeVideoElement();
773
+ element.element.setAttribute("muted", "");
774
+ const { host, root } = mountPanel(kind, element, handlers);
775
+ await clickToggle(kind, host, "Muted", "Off");
776
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
777
+ htmlOp("muted", null),
778
+ dataOp("has-audio", "false"),
779
+ ]);
780
+ act(() => root.unmount());
781
+ });
782
+
783
+ it("unmuting when the probe fails leaves has-audio absent", async () => {
784
+ stubHasAudioProbe("fail");
785
+ const handlers = makeHandlers();
786
+ const element = makeVideoElement();
787
+ element.element.setAttribute("muted", "");
788
+ const { host, root } = mountPanel(kind, element, handlers);
789
+ await clickToggle(kind, host, "Muted", "Off");
790
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
791
+ htmlOp("muted", null),
792
+ dataOp("has-audio", null),
793
+ ]);
794
+ act(() => root.unmount());
795
+ });
796
+
797
+ it("audio Muted toggles only the muted attribute", async () => {
798
+ const handlers = makeHandlers();
799
+ const { host, root } = mountPanel(kind, makeAudioElement({}), handlers);
800
+ await clickToggle(kind, host, "Muted", "On");
801
+ expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
802
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([htmlOp("muted", "true")]);
803
+ act(() => root.unmount());
804
+ });
805
+
806
+ it("Has audio track No removes has-audio and mutes in ONE write", async () => {
807
+ const handlers = makeHandlers();
808
+ const element = makeVideoElement({ dataAttributes: { "has-audio": "true" } });
809
+ const { host, root } = mountPanel(kind, element, handlers);
810
+ await clickToggle(kind, host, "Has audio track", "No");
811
+ expect(handlers.onSetAttributeBatch).toHaveBeenCalledTimes(1);
812
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
813
+ dataOp("has-audio", null),
814
+ htmlOp("muted", "true"),
815
+ ]);
816
+ act(() => root.unmount());
817
+ });
818
+
819
+ it("Has audio track Yes stamps has-audio and clears muted in ONE write", async () => {
820
+ const handlers = makeHandlers();
821
+ const element = makeVideoElement();
822
+ element.element.setAttribute("muted", "");
823
+ const { host, root } = mountPanel(kind, element, handlers);
824
+ await clickToggle(kind, host, "Has audio track", "Yes");
825
+ expect(handlers.onSetAttributeBatch.mock.calls[0]?.[1]).toEqual([
826
+ dataOp("has-audio", "true"),
827
+ htmlOp("muted", null),
828
+ ]);
829
+ act(() => root.unmount());
830
+ });
831
+ });
832
+
833
+ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the sound", (kind) => {
834
+ async function runCutout(element: DomEditSelection) {
835
+ const handlers = makeHandlers();
836
+ const onRemoveBackground = vi.fn().mockResolvedValue({ outputPath: "assets/cut.webm" });
837
+ const confirm = vi.spyOn(window, "confirm");
838
+ const { host, root } = mountPanel(kind, element, { ...handlers, onRemoveBackground });
839
+ const button =
840
+ kind === "flat"
841
+ ? host.querySelector<HTMLButtonElement>('[data-flat-media-remove-bg="true"]')
842
+ : Array.from(host.querySelectorAll("button")).find((b) =>
843
+ /remove bg/i.test(b.textContent ?? ""),
844
+ );
845
+ if (!button) throw new Error("expected remove background button");
846
+ await act(async () => {
847
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
848
+ await flush();
849
+ });
850
+ const text = host.textContent ?? "";
851
+ act(() => root.unmount());
852
+ return { ...handlers, confirm, text };
853
+ }
854
+
855
+ afterEach(() => vi.restoreAllMocks());
856
+
857
+ it("audible video: one write mutes onto the cutout and inserts a linked sibling audio", async () => {
858
+ const element = makeVideoElement({
859
+ dataAttributes: { "has-audio": "true", volume: "0.5" },
860
+ });
861
+ element.element.setAttribute("id", "s1-bg");
862
+ element.element.setAttribute("data-has-audio", "true");
863
+ element.element.setAttribute("data-volume", "0.5");
864
+ const { onSetAttributeBatch, onSetAttribute, onSetHtmlAttribute, confirm, text } =
865
+ await runCutout(element);
866
+
867
+ expect(confirm).not.toHaveBeenCalled();
868
+ expect(onSetAttribute).not.toHaveBeenCalled();
869
+ expect(onSetHtmlAttribute).not.toHaveBeenCalled();
870
+ expect(onSetAttributeBatch).toHaveBeenCalledTimes(1);
871
+ const [selection, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
872
+ expect(selection).toBe(element);
873
+ expect(ops).toEqual([
874
+ htmlOp("src", "assets/cut.webm"),
875
+ htmlOp("muted", "true"),
876
+ dataOp("has-audio", null),
877
+ dataOp("volume", null),
878
+ dataOp("link", "link"),
879
+ ]);
880
+ const source =
881
+ '<div data-composition-id="main"><video id="s1-bg" src="assets/cut.webm" muted data-start="2" data-duration="3" data-track-index="0" data-link="link"></video></div>';
882
+ const prepared = options?.prepareContent?.(source) ?? "";
883
+ const audio = new DOMParser()
884
+ .parseFromString(prepared, "text/html")
885
+ .getElementById("s1-bg-audio");
886
+ expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
887
+ expect(audio?.getAttribute("data-start")).toBe("2");
888
+ expect(audio?.getAttribute("data-duration")).toBe("3");
889
+ expect(audio?.getAttribute("data-link")).toBe("link");
890
+ expect(audio?.getAttribute("data-volume")).toBe("0.5");
891
+ expect(text).toContain("Background removed. Sound kept on a linked audio track.");
892
+ });
893
+
894
+ it("silent video: one write swaps the src and inserts no audio", async () => {
895
+ const silent = makeVideoElement();
896
+ silent.element.setAttribute("muted", "");
897
+ const { onSetAttributeBatch, confirm, text } = await runCutout(silent);
898
+ expect(confirm).not.toHaveBeenCalled();
899
+ expect(onSetAttributeBatch).toHaveBeenCalledTimes(1);
900
+ const [, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
901
+ expect(ops).toEqual([
902
+ htmlOp("src", "assets/cut.webm"),
903
+ htmlOp("muted", "true"),
904
+ dataOp("has-audio", null),
905
+ ]);
906
+ expect(options?.prepareContent).toBeUndefined();
907
+ expect(text).toContain("Applied cutout");
908
+ });
909
+ });
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { isVideoAudible } from "../../player/lib/timelineElementHelpers";
2
3
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
4
  import { Check, ClipboardList } from "../../icons/SystemIcons";
4
5
  import type { DomEditSelection } from "./domEditing";
@@ -34,15 +35,19 @@ import {
34
35
  readFadeSeconds,
35
36
  } from "@hyperframes/core/audio-fade";
36
37
  import { parseGainInput, parseRateInput, parseSecondsInput } from "./audioInspectorInput";
38
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
39
+ import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
37
40
 
38
41
  // fallow-ignore-next-line complexity
39
42
  export function FlatMediaSection({
43
+ projectId = null,
40
44
  projectDir,
41
45
  element,
42
46
  styles,
43
47
  onSetStyle,
44
48
  onSetAttribute,
45
49
  onSetHtmlAttribute,
50
+ onSetAttributeBatch,
46
51
  onRemoveBackground,
47
52
  volumeAutomated,
48
53
  onAutomateVolume,
@@ -51,12 +56,14 @@ export function FlatMediaSection({
51
56
  automatedVolumeValue,
52
57
  rate,
53
58
  }: {
59
+ projectId?: string | null;
54
60
  projectDir: string | null;
55
61
  element: DomEditSelection;
56
62
  styles: Record<string, string>;
57
63
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
58
64
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
59
65
  onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
66
+ onSetAttributeBatch: CommitDomAttributeBatch;
60
67
  /** A volume lane in the timeline drives the level; the slider writes a keyframe instead. */
61
68
  volumeAutomated?: boolean;
62
69
  onAutomateVolume?: () => void;
@@ -104,7 +111,11 @@ export function FlatMediaSection({
104
111
  const fadeMax = clipDuration > 0 ? clipDuration : 10;
105
112
  const hasLoop = el.hasAttribute("loop");
106
113
  const hasMuted = el.hasAttribute("muted");
107
- const hasAudio = element.dataAttributes["has-audio"] === "true";
114
+ const hasAudio = isVideoAudible({
115
+ tag: el.tagName,
116
+ hasAudioAttr: element.dataAttributes["has-audio"],
117
+ muted: el.hasAttribute("muted"),
118
+ });
108
119
  const objectFit = styles["object-fit"] || "contain";
109
120
  const objectPosition = styles["object-position"] || "center";
110
121
 
@@ -128,13 +139,7 @@ export function FlatMediaSection({
128
139
  setCreatePlate(false);
129
140
  }, [srcAttr]);
130
141
 
131
- const applyCutoutResult = async (result: BackgroundRemovalResult) => {
132
- await onSetHtmlAttribute("src", result.outputPath);
133
- if (isVideo) {
134
- await onSetAttribute("has-audio", "");
135
- await onSetHtmlAttribute("muted", "true");
136
- }
137
- };
142
+ const mediaEdit = { element, projectId, projectSrc, commit: onSetAttributeBatch };
138
143
 
139
144
  const runBackgroundRemoval = async () => {
140
145
  if (!onRemoveBackground || !projectSrc || removeBusy) return;
@@ -147,8 +152,8 @@ export function FlatMediaSection({
147
152
  quality,
148
153
  onProgress: setRemoveProgress,
149
154
  });
150
- await applyCutoutResult(result);
151
- setRemoveProgress({ status: "complete", progress: 100, stage: "Applied cutout", ...result });
155
+ const stage = await commitCutout(mediaEdit, result.outputPath, hasAudio);
156
+ setRemoveProgress({ status: "complete", progress: 100, stage, ...result });
152
157
  } catch (error) {
153
158
  setRemoveProgress({
154
159
  status: "failed",
@@ -362,21 +367,13 @@ export function FlatMediaSection({
362
367
  <FlatToggle
363
368
  label="Muted"
364
369
  checked={hasMuted}
365
- onChange={(next) => void onSetHtmlAttribute("muted", next ? "true" : null)}
370
+ onChange={(next) => void commitMutedToggle(mediaEdit, next)}
366
371
  />
367
372
  {isVideo && (
368
373
  <FlatToggle
369
374
  label="Has audio track"
370
375
  checked={hasAudio}
371
- onChange={(next) => {
372
- if (next) {
373
- void onSetAttribute("has-audio", "true");
374
- void onSetHtmlAttribute("muted", null);
375
- } else {
376
- void onSetAttribute("has-audio", "");
377
- void onSetHtmlAttribute("muted", "true");
378
- }
379
- }}
376
+ onChange={(next) => void commitHasAudioToggle(mediaEdit, next)}
380
377
  />
381
378
  )}
382
379
  </>
@@ -19,6 +19,7 @@ export type PropertyPanelFlatProps = Pick<
19
19
  | "onSetAttributeQuiet"
20
20
  | "onApplyColorGradingScope"
21
21
  | "onSetHtmlAttribute"
22
+ | "onSetAttributeBatch"
22
23
  | "onRemoveBackground"
23
24
  | "onSetText"
24
25
  | "onSetTextFieldStyle"
@@ -8,7 +8,11 @@ import { sortFontOptions } from "./propertyPanelHelpers";
8
8
 
9
9
  vi.mock("./propertyPanelHelpers", async (importOriginal) => {
10
10
  const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
11
- return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
11
+ return {
12
+ ...actual,
13
+ sortFontOptions: vi.fn(actual.sortFontOptions),
14
+ uniqueFontFamilies: vi.fn(actual.uniqueFontFamilies),
15
+ };
12
16
  });
13
17
 
14
18
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -56,13 +60,14 @@ describe("FontFamilyField font list", () => {
56
60
  root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
57
61
  try {
58
62
  await act(async () => render("Arial"));
59
- // The font lists arrive and the value changes while the dropdown is closed, as during a drag.
63
+ // The value changes while the dropdown is closed, as during a drag.
60
64
  await act(async () => render("Georgia"));
61
65
  expect(sortFontOptions).not.toHaveBeenCalled();
62
66
 
63
67
  const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
64
68
  await act(async () => trigger?.click());
65
- expect(sortFontOptions).toHaveBeenCalledTimes(1);
69
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
70
+ expect(sortFontOptions).toHaveBeenCalled();
66
71
  expect(host.textContent).toContain("Roboto Slab");
67
72
  expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
68
73
  } finally {
@@ -71,3 +76,75 @@ describe("FontFamilyField font list", () => {
71
76
  }
72
77
  });
73
78
  });
79
+
80
+ describe("FontFamilyField session font lists", () => {
81
+ const fresh = async () => {
82
+ vi.resetModules();
83
+ const { FontFamilyField } = await import("./propertyPanelFont");
84
+ const { uniqueFontFamilies } = await import("./propertyPanelHelpers");
85
+ const loop = await import("./overlayFrameLoop");
86
+ vi.mocked(uniqueFontFamilies).mockClear();
87
+ const host = document.createElement("div");
88
+ document.body.append(host);
89
+ const root = createRoot(host);
90
+ const render = (value: string) =>
91
+ root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
92
+ const toggle = () =>
93
+ host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]')?.click();
94
+ const done = () => {
95
+ act(() => root.unmount());
96
+ host.remove();
97
+ loop.resetOverlayFrameLoopForTests();
98
+ };
99
+ return { uniqueFontFamilies, loop, host, render, toggle, done };
100
+ };
101
+
102
+ it("does no list work in a drag's frames when the lists land mid-drag", async () => {
103
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
104
+ const raf = window.requestAnimationFrame;
105
+ window.requestAnimationFrame = (() => 0) as typeof window.requestAnimationFrame;
106
+ const lists = vi.mocked(fetch).getMockImplementation()!;
107
+ vi.mocked(fetch).mockImplementation(async (url) => {
108
+ await new Promise((resolve) => setTimeout(resolve, 200));
109
+ return lists(url);
110
+ });
111
+ const t = await fresh();
112
+ const stopLoop = t.loop.subscribeOverlayFrame(() => undefined);
113
+ try {
114
+ await act(async () => t.render("Georgia"));
115
+ for (let elapsed = 0; elapsed < 600; elapsed += 16) {
116
+ window.dispatchEvent(new Event("pointermove"));
117
+ await act(async () => vi.advanceTimersByTimeAsync(16));
118
+ }
119
+ expect(fetch).toHaveBeenCalledTimes(2);
120
+ expect(t.uniqueFontFamilies).not.toHaveBeenCalled();
121
+
122
+ await act(async () => vi.advanceTimersByTimeAsync(500));
123
+ expect(t.uniqueFontFamilies).toHaveBeenCalledTimes(1);
124
+ await act(async () => t.toggle());
125
+ expect(t.host.textContent).toContain("Roboto Slab");
126
+ } finally {
127
+ stopLoop();
128
+ t.done();
129
+ window.requestAnimationFrame = raf;
130
+ vi.useRealTimers();
131
+ }
132
+ });
133
+
134
+ it("retries a failed list fetch when the picker opens", async () => {
135
+ vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
136
+ const t = await fresh();
137
+ try {
138
+ await act(async () => t.render("Georgia"));
139
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
140
+ expect(t.uniqueFontFamilies).not.toHaveBeenCalled();
141
+
142
+ await act(async () => t.toggle());
143
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 10)));
144
+ expect(fetch).toHaveBeenCalledTimes(4);
145
+ expect(t.host.textContent).toContain("Roboto Slab");
146
+ } finally {
147
+ t.done();
148
+ }
149
+ });
150
+ });