@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
@@ -506,34 +506,6 @@ export function shouldShowTimelineShortcutHint(
506
506
  return scrollHeight - clientHeight <= 1;
507
507
  }
508
508
 
509
- export function shouldHandleTimelineDeleteKey(input: {
510
- key: string;
511
- metaKey?: boolean;
512
- ctrlKey?: boolean;
513
- altKey?: boolean;
514
- target?: EventTarget | null;
515
- }): boolean {
516
- if (input.key !== "Delete" && input.key !== "Backspace") return false;
517
- if (input.metaKey || input.ctrlKey || input.altKey) return false;
518
- const target =
519
- input.target && typeof input.target === "object"
520
- ? (input.target as {
521
- tagName?: string;
522
- isContentEditable?: boolean;
523
- closest?: (selector: string) => Element | null;
524
- })
525
- : null;
526
- if (target) {
527
- const tag = target.tagName?.toLowerCase() ?? "";
528
- if (target.isContentEditable) return false;
529
- if (["input", "textarea", "select"].includes(tag)) return false;
530
- if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
531
- return false;
532
- }
533
- }
534
- return true;
535
- }
536
-
537
509
  /* ── Asset drop ───────────────────────────────────────────────────── */
538
510
  export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
539
511
  if (trackOrder.length === 0) return 0;
@@ -0,0 +1,92 @@
1
+ import { useEffect, useState } from "react";
2
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
3
+ import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
4
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
5
+ import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { useLivePreviewIframe } from "../store/previewIframeStore";
8
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
9
+ import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
10
+ import { committedClipPath } from "../../components/editor/cropPresetStore";
11
+ import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
12
+ import type { ClipToolState } from "./clipToolAttrs";
13
+
14
+ const LINK_ATTR = "data-link";
15
+
16
+ const WATCHED_ATTRS = [
17
+ HF_AUDIO_FX_ATTR,
18
+ HF_AUDIO_AUTOMATION_ATTR,
19
+ HF_AUDIO_CARVE_ATTR,
20
+ HF_COLOR_GRADING_ATTR,
21
+ LINK_ATTR,
22
+ "data-volume",
23
+ "data-has-audio",
24
+ "muted",
25
+ "style",
26
+ ];
27
+
28
+ function readVolume(node: Element): number | null {
29
+ const volume = Number.parseFloat(node.getAttribute("data-volume") ?? "");
30
+ return Number.isFinite(volume) ? volume : null;
31
+ }
32
+
33
+ function toolStateFromNode(node: Element): ClipToolState {
34
+ const tag = node.tagName.toLowerCase();
35
+ return {
36
+ tag,
37
+ hasSound: tag === "audio" || isAudibleVideoNode(node),
38
+ volume: readVolume(node),
39
+ muted: node.hasAttribute("muted"),
40
+ fxChain: node.getAttribute(HF_AUDIO_FX_ATTR),
41
+ automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR),
42
+ colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
43
+ clipPath: committedClipPath(node),
44
+ carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
45
+ link: node.getAttribute(LINK_ATTR),
46
+ };
47
+ }
48
+
49
+ function toolStateFromElement(el: TimelineElement): ClipToolState {
50
+ const tag = el.tag.trim().toLowerCase();
51
+ return {
52
+ tag,
53
+ hasSound: tag === "audio" || el.hasAudio === true,
54
+ volume: el.volume ?? null,
55
+ muted: el.muted === true,
56
+ fxChain: el.fxChain ?? null,
57
+ automation: el.automation ?? null,
58
+ colorGrading: null,
59
+ clipPath: null,
60
+ carve: null,
61
+ link: null,
62
+ };
63
+ }
64
+
65
+ /** Inline style changes every frame under a tween; only a changed reading re-renders. */
66
+ function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState {
67
+ return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
68
+ }
69
+
70
+ /** A clip's tool attributes as the live preview node holds them, kept current as they change. */
71
+ export function useClipToolState(el: TimelineElement): ClipToolState {
72
+ const iframe = useLivePreviewIframe();
73
+ const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
74
+ const [state, setState] = useState<ClipToolState>(() => toolStateFromElement(el));
75
+
76
+ useEffect(() => {
77
+ const node = findTimelineElementInIframe(iframe, el, activeCompPath);
78
+ if (!node) {
79
+ setState(toolStateFromElement(el));
80
+ return;
81
+ }
82
+ const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node)));
83
+ refresh();
84
+ const view = node.ownerDocument.defaultView;
85
+ if (!view) return;
86
+ const observer = new view.MutationObserver(refresh);
87
+ observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS });
88
+ return () => observer.disconnect();
89
+ }, [iframe, el, activeCompPath]);
90
+
91
+ return state;
92
+ }
@@ -261,3 +261,46 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
261
261
  expect(usePlayerStore.getState().loopEnabled).toBe(false);
262
262
  });
263
263
  });
264
+
265
+ describe("usePlaybackKeyboard — a focused native player owns its keys", () => {
266
+ const KEYS = [
267
+ { code: "ArrowLeft", key: "ArrowLeft" },
268
+ { code: "ArrowRight", key: "ArrowRight" },
269
+ { code: "Space", key: " " },
270
+ { code: "KeyJ", key: "j" },
271
+ { code: "KeyK", key: "k" },
272
+ { code: "KeyL", key: "l" },
273
+ ];
274
+
275
+ it.each(["video", "audio"])(
276
+ "leaves the timeline alone while a <%s controls> has focus",
277
+ (tag) => {
278
+ const { dispatch, spies } = setupHook();
279
+ const player = document.createElement(tag);
280
+ player.setAttribute("controls", "");
281
+ document.body.append(player);
282
+ const before = usePlayerStore.getState().currentTime;
283
+ for (const init of KEYS) {
284
+ const event = keydown(init);
285
+ Object.defineProperty(event, "target", { value: player });
286
+ dispatch(event);
287
+ expect(event.defaultPrevented, init.code).toBe(false);
288
+ }
289
+ expect(spies.seek).not.toHaveBeenCalled();
290
+ expect(spies.play).not.toHaveBeenCalled();
291
+ expect(spies.playBackward).not.toHaveBeenCalled();
292
+ expect(spies.pause).not.toHaveBeenCalled();
293
+ expect(usePlayerStore.getState().currentTime).toBe(before);
294
+ },
295
+ );
296
+
297
+ it("still steps the timeline from a player without controls", () => {
298
+ const { dispatch } = setupHook();
299
+ const video = document.createElement("video");
300
+ document.body.append(video);
301
+ const event = keydown({ code: "ArrowRight", key: "ArrowRight" });
302
+ Object.defineProperty(event, "target", { value: video });
303
+ dispatch(event);
304
+ expect(event.defaultPrevented).toBe(true);
305
+ });
306
+ });
@@ -6,7 +6,7 @@
6
6
  * is active and the user is navigating caption segments).
7
7
  */
8
8
 
9
- import { isTypingTarget } from "../../utils/typingTarget";
9
+ import { ownsPlainKeys } from "../../utils/typingTarget";
10
10
 
11
11
  const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
12
12
 
@@ -15,21 +15,17 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
15
15
  "a[href]",
16
16
  "[role='button']",
17
17
  "[role='checkbox']",
18
- "[role='combobox']",
19
18
  "[role='menuitem']",
20
19
  // Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
21
20
  "[role='menuitemradio']",
22
21
  "[role='radio']",
23
- "[role='slider']",
24
22
  "[role='spinbutton']",
25
- "[role='switch']",
26
- "[role='textbox']",
27
23
  ].join(",");
28
24
 
29
25
  export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
30
26
  // Anything the user is typing into owns its keys outright, editable elements
31
- // included: a letter claimed here never reaches the text.
32
- if (isTypingTarget(target)) return true;
27
+ // included: a letter claimed here never reaches the text. So does a native player.
28
+ if (ownsPlainKeys(target)) return true;
33
29
  if (!target || typeof target !== "object") return false;
34
30
  const candidate = target as { closest?: unknown };
35
31
  if (typeof candidate.closest !== "function") return false;
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isVideoAudible } from "./timelineElementHelpers";
3
+
4
+ describe("isVideoAudible — the compiler's data-has-audio rule", () => {
5
+ it("explicit data-has-audio wins", () => {
6
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: "true", muted: false })).toBe(true);
7
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: "false", muted: false })).toBe(false);
8
+ });
9
+ it("no attribute: an unmuted video is audible, a muted one is not", () => {
10
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: null, muted: false })).toBe(true);
11
+ expect(isVideoAudible({ tag: "video", hasAudioAttr: undefined, muted: true })).toBe(false);
12
+ });
13
+ it("never true for non-video without the attribute", () => {
14
+ expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false);
15
+ });
16
+ });
@@ -220,12 +220,35 @@ function readVolume(el: Element, media: Element): number | undefined {
220
220
  return Number.isFinite(volume) ? volume : undefined;
221
221
  }
222
222
 
223
+ /** The compiler's rule (timingCompiler): explicit data-has-audio wins; otherwise an unmuted <video> is audible. */
224
+ export function isVideoAudible(opts: {
225
+ tag: string;
226
+ hasAudioAttr: string | null | undefined;
227
+ muted: boolean;
228
+ }): boolean {
229
+ if (opts.hasAudioAttr === "true") return true;
230
+ if (opts.hasAudioAttr === "false" || opts.hasAudioAttr === "") return false;
231
+ return opts.tag.toLowerCase() === "video" && !opts.muted;
232
+ }
233
+
234
+ export function isAudibleVideoNode(el: Element): boolean {
235
+ if (el.tagName.toLowerCase() !== "video" || el.hasAttribute("muted")) return false;
236
+ return isVideoAudible({
237
+ tag: "video",
238
+ hasAudioAttr: el.getAttribute("data-has-audio"),
239
+ muted: false,
240
+ });
241
+ }
242
+
223
243
  /** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
224
244
  function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
225
245
  const media = resolveMediaElement(el) ?? el;
226
246
  const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
227
- const hasAudio = el.getAttribute("data-has-audio");
228
- const sound = hasAudio === null ? el.tagName === "VIDEO" && !muted : hasAudio === "true";
247
+ const sound = isVideoAudible({
248
+ tag: el.tagName,
249
+ hasAudioAttr: el.getAttribute("data-has-audio"),
250
+ muted,
251
+ });
229
252
  setOptional(entry, "hasAudio", sound ? true : undefined);
230
253
  setOptional(entry, "muted", muted ? true : undefined);
231
254
  setOptional(entry, "volume", readVolume(el, media));
@@ -19,6 +19,8 @@ describe("timeline viewport budgets", () => {
19
19
  interactionP95Ms: 50,
20
20
  constrainedInteractionP95Ms: 75,
21
21
  constrainedFrameIntervalP95Ms: 75,
22
+ ciVirtualizedInteractionP95Ms: 58.3,
23
+ ciVirtualizedFrameIntervalP95Ms: 25,
22
24
  longTaskLimitMs: 50,
23
25
  constrainedLongTaskLimitMs: 300,
24
26
  posterCoverageRatio: 0.9,
@@ -28,6 +28,8 @@ export interface TimelineViewportBudgets {
28
28
  frameIntervalP95Ms: number;
29
29
  constrainedInteractionP95Ms: number;
30
30
  constrainedFrameIntervalP95Ms: number;
31
+ ciVirtualizedInteractionP95Ms: number;
32
+ ciVirtualizedFrameIntervalP95Ms: number;
31
33
  longTaskLimitMs: number;
32
34
  constrainedLongTaskLimitMs: number;
33
35
  memoryReturnToleranceRatio: number;
@@ -86,6 +88,8 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
86
88
  frameIntervalP95Ms: 33.3,
87
89
  constrainedInteractionP95Ms: 75,
88
90
  constrainedFrameIntervalP95Ms: 75,
91
+ ciVirtualizedInteractionP95Ms: 58.3,
92
+ ciVirtualizedFrameIntervalP95Ms: 25,
89
93
  longTaskLimitMs: 50,
90
94
  constrainedLongTaskLimitMs: 300,
91
95
  memoryReturnToleranceRatio: 0.15,
@@ -6,6 +6,7 @@ import {
6
6
  AudioMeterStrip,
7
7
  TimelineHistoryButtons,
8
8
  TimelineToolbar,
9
+ useAudioMetersVisible as exportedAudioMetersVisible,
9
10
  useClipboard,
10
11
  usePlayerStore,
11
12
  type UseClipboardOptions,
@@ -51,6 +52,12 @@ describe("timeline chrome package exports, outside Studio's shell", () => {
51
52
  await act(async () => root.unmount());
52
53
  });
53
54
 
55
+ it("exports the meter visibility store a host toggles", () => {
56
+ expect(exportedAudioMetersVisible).toBe(useAudioMetersVisible);
57
+ exportedAudioMetersVisible.getState().setVisible(true);
58
+ expect(useAudioMetersVisible.getState().visible).toBe(true);
59
+ });
60
+
54
61
  it("runs the clipboard with no DOM edit session", async () => {
55
62
  const toasts: string[] = [];
56
63
  const options: UseClipboardOptions = {
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../player";
2
3
  import {
4
+ hasProjectAudio,
3
5
  levelToFraction,
4
6
  markFraction,
5
7
  SILENT_CHANNEL,
@@ -42,3 +44,38 @@ describe("stepPair", () => {
42
44
  expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
43
45
  });
44
46
  });
47
+
48
+ describe("hasProjectAudio", () => {
49
+ const clip = (overrides: Partial<TimelineElement>): TimelineElement => ({
50
+ id: "c",
51
+ tag: "div",
52
+ start: 0,
53
+ duration: 1,
54
+ track: 0,
55
+ ...overrides,
56
+ });
57
+
58
+ it("ignores an audible video left on native output at unity gain", () => {
59
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true })])).toBe(false);
60
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, volume: 1 })])).toBe(false);
61
+ });
62
+
63
+ it.each<[string, Partial<TimelineElement>]>([
64
+ ["an above-unity gain", { volume: 2 }],
65
+ ["an fx chain", { fxChain: "[]" }],
66
+ ["automation", { automation: "{}" }],
67
+ ["a group", { audioGroup: "music" }],
68
+ ])("counts an audible video routed through Web Audio by %s", (_, fields) => {
69
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, ...fields })])).toBe(true);
70
+ });
71
+
72
+ it("ignores a muted video even when it carries processing", () => {
73
+ expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, muted: true, volume: 2 })])).toBe(
74
+ false,
75
+ );
76
+ });
77
+
78
+ it("ignores a silent video", () => {
79
+ expect(hasProjectAudio([clip({ tag: "video" })])).toBe(false);
80
+ });
81
+ });
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
3
+ import { audibleVideoNeedsWebAudio } from "@hyperframes/core/audible-video";
3
4
  import { usePlayerStore, type TimelineElement } from "../player";
4
5
  import { isAudioTimelineElement } from "./timelineInspector";
5
6
 
@@ -67,9 +68,14 @@ export function stepPair(
67
68
  return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
68
69
  }
69
70
 
70
- /** The meters appear only for a project with at least one audio clip or group. */
71
- function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
72
- return elements.some((el) => isAudioTimelineElement(el) || el.audioGroup !== undefined);
71
+ /** The meters appear only for a project with an audio clip, a group, or a video Web Audio carries. */
72
+ export function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
73
+ return elements.some(
74
+ (el) =>
75
+ isAudioTimelineElement(el) ||
76
+ el.audioGroup !== undefined ||
77
+ (el.hasAudio === true && !el.muted && audibleVideoNeedsWebAudio(el)),
78
+ );
73
79
  }
74
80
 
75
81
  /** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
@@ -0,0 +1 @@
1
+ export const EXPORT_PEAK_CEILING = 10 ** (-1 / 20);
@@ -195,6 +195,7 @@ export interface SoftReloadOptions {
195
195
  currentTimeOverride?: number;
196
196
  /** After-write file HTML — the primary source for the authored opacity and transform restore. */
197
197
  authoredHtml?: string;
198
+ reparse?: Element[];
198
199
  }
199
200
 
200
201
  /**
@@ -251,7 +252,7 @@ export function applySoftReload(
251
252
  scriptText: string,
252
253
  options: SoftReloadOptions = {},
253
254
  ): SoftReloadResult {
254
- const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
255
+ const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
255
256
  if (!iframe || !scriptText) return "cannot-soft-reload";
256
257
 
257
258
  const win = iframe.contentWindow as IframeWindow | null;
@@ -329,7 +330,7 @@ export function applySoftReload(
329
330
  // fallow-ignore-next-line complexity
330
331
  const doReload = () => {
331
332
  const timelines = win.__timelines;
332
- const allTargets: Element[] = [];
333
+ const allTargets: Element[] = [...reparse];
333
334
 
334
335
  // Kill ONLY the target composition's timeline(s) — leaving every other
335
336
  // composition's timeline (and its children on the global timeline) intact.
@@ -0,0 +1,94 @@
1
+ // Real Chrome: the box GSAP and a stylesheet translate produce together is what no DOM emulation computes.
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import puppeteer, { type Browser } from "puppeteer-core";
8
+ import { build } from "vite";
9
+ import { afterAll, beforeAll, expect, it } from "vitest";
10
+ import { findSystemChrome } from "../../vite.browser";
11
+ import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const CROP = "inset(0px 40px 0px 0px)";
15
+ let browser: Browser;
16
+ let undoBundle: string;
17
+
18
+ beforeAll(async () => {
19
+ const executablePath = findSystemChrome();
20
+ if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
21
+ browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
22
+ const out = await build({
23
+ configFile: false,
24
+ logLevel: "silent",
25
+ resolve: {
26
+ alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
27
+ },
28
+ build: {
29
+ write: false,
30
+ minify: false,
31
+ lib: {
32
+ entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
33
+ formats: ["iife"],
34
+ name: "hfUndo",
35
+ },
36
+ },
37
+ });
38
+ undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
39
+ .code;
40
+ }, 60_000);
41
+
42
+ afterAll(() => browser?.close());
43
+
44
+ it.each([
45
+ { gsap: "hold", placement: "px", rotation: 0 },
46
+ { gsap: "tween", placement: "px", rotation: 0 },
47
+ { gsap: "tween", placement: "xpercent", rotation: 30 },
48
+ ])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
49
+ const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
50
+ writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
51
+ const restored = readFileSync(join(dir, "index.html"), "utf8");
52
+ const previous = restored.replace(
53
+ `<div id="target"`,
54
+ `<div id="target" style="clip-path: ${CROP}"`,
55
+ );
56
+ const page = await browser.newPage();
57
+ await page.setViewport({ width: 1920, height: 1080 });
58
+ await page.setRequestInterception(true);
59
+ page.on("request", (request) =>
60
+ request.url().endsWith("/gsap.min.js")
61
+ ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
62
+ : request.continue(),
63
+ );
64
+ await page.setContent(restored, { waitUntil: "load" });
65
+ await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
66
+ await page.waitForFunction(() => "__player" in window);
67
+ await page.evaluate(undoBundle);
68
+
69
+ const box = () =>
70
+ page.evaluate(() => {
71
+ const { left, top, width, height } = document
72
+ .getElementById("target")!
73
+ .getBoundingClientRect();
74
+ return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
75
+ });
76
+ await page.evaluate(() =>
77
+ (window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
78
+ );
79
+ const before = await box();
80
+ const outcome = await page.evaluate(
81
+ (files, crop) => {
82
+ document.getElementById("target")!.style.setProperty("clip-path", crop);
83
+ const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
84
+ const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
85
+ return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
86
+ },
87
+ { "index.html": { previous, restored } },
88
+ CROP,
89
+ );
90
+
91
+ expect(outcome).toBe("soft");
92
+ expect(await box()).toEqual(before);
93
+ await page.close();
94
+ });
@@ -1,7 +1,14 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, it, expect, vi } from "vitest";
4
- import { applyUndoRestoreToPreview, diffSoftReloadableRestore } from "./gsapUndoRestore";
4
+ import {
5
+ applyUndoRestoreToPreview,
6
+ diffSoftReloadableRestore,
7
+ showRestoreInPlace,
8
+ } from "./gsapUndoRestore";
9
+ import { applyPatch } from "./sourcePatcher";
10
+ import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
11
+ import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
5
12
 
6
13
  // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
7
14
 
@@ -281,6 +288,25 @@ describe("applyUndoRestoreToPreview", () => {
281
288
  );
282
289
  });
283
290
 
291
+ it("full-reloads a style restore on a GSAP-parsed element when the file has two scripts", () => {
292
+ const scripts = [
293
+ `<script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
294
+ `<script>window.__timelines["captions"]=gsap.timeline().to("#a",{y:1});</script>`,
295
+ ].join("");
296
+ const { iframe, doc } = buildLiveIframe(`<div id="a" style="z-index: 8">t</div>${scripts}`);
297
+ Object.assign(doc.getElementById("a")!, { _gsap: {} });
298
+ const reloadPreview = vi.fn();
299
+ const files = {
300
+ [ROOT]: {
301
+ previous: wrap(`<div id="a" style="z-index: 8">t</div>${scripts}`),
302
+ restored: wrap(`<div id="a" style="z-index: 3">t</div>${scripts}`),
303
+ },
304
+ };
305
+
306
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
307
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
308
+ });
309
+
284
310
  it("full-reloads a multi-file restore", () => {
285
311
  const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
286
312
  const reloadPreview = vi.fn();
@@ -420,3 +446,121 @@ describe("applyUndoRestoreToPreview", () => {
420
446
  expect(reloadPreview).toHaveBeenCalledTimes(1);
421
447
  });
422
448
  });
449
+
450
+ describe("an undo that lands while the layer is being dragged", () => {
451
+ const ROOT = "index.html";
452
+ const undone = `<div id="a" style="width: 120px; translate: 90px 60px" data-start="2" data-hf-studio-original-inline-translate="">t</div>`;
453
+ const restored = wrap(`<div id="a" style="width: 100px" data-start="1">t</div>`);
454
+ const files = { [ROOT]: { previous: wrap(undone), restored } };
455
+ const saveDrop = (el: HTMLElement, file: string) =>
456
+ writePlainMove(el, { x: 20, y: 110 }).reduce((html, op) => applyPatch(html, "a", op), file);
457
+
458
+ function dragging(drag: boolean) {
459
+ const { iframe, doc } = buildLiveIframe(undone);
460
+ const el = doc.getElementById("a")!;
461
+ if (drag) beginStudioManualEditGesture(el, "move");
462
+ if (drag) writeTranslatePx(el, { x: 70, y: 110 });
463
+ else el.style.setProperty("opacity", "0.5");
464
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
465
+ return el;
466
+ }
467
+
468
+ it("keeps the translate the drag is drawing and reverts the rest", () => {
469
+ const el = dragging(true);
470
+
471
+ expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
472
+ expect(el.style.getPropertyValue("width")).toBe("100px");
473
+ expect(el.getAttribute("data-start")).toBe("1");
474
+ expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
475
+ });
476
+
477
+ it("saves the drop onto the undone file, the same bytes as dragging the undone layer", () => {
478
+ const el = dragging(true);
479
+ const fresh = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
480
+
481
+ const saved = saveDrop(el, restored);
482
+
483
+ expect(saved).toBe(saveDrop(fresh, restored));
484
+ expect(saved).toContain("width: 100px; translate: 20px 110px");
485
+ expect(saved).not.toContain("120px");
486
+ });
487
+
488
+ it("keeps the box where it is when the press has not moved it yet", () => {
489
+ const { iframe, doc } = buildLiveIframe(undone);
490
+ const el = doc.getElementById("a")!;
491
+ beginStudioManualEditGesture(el, "move");
492
+
493
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
494
+
495
+ expect(el.style.getPropertyValue("translate")).toBe("90px 60px");
496
+ expect(el.style.getPropertyValue("width")).toBe("100px");
497
+ });
498
+
499
+ it("reverts the translate under a gesture that draws nothing, such as a text edit", () => {
500
+ const { iframe, doc } = buildLiveIframe(undone);
501
+ const el = doc.getElementById("a")!;
502
+ beginStudioManualEditGesture(el, "edit");
503
+
504
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
505
+
506
+ expect(el.style.getPropertyValue("translate")).toBe("");
507
+ expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
508
+ });
509
+
510
+ it("keeps the drag's translate when the undo is shown in place", () => {
511
+ const { iframe, doc } = buildLiveIframe(undone);
512
+ const el = doc.getElementById("a")!;
513
+ beginStudioManualEditGesture(el, "move");
514
+ writeTranslatePx(el, { x: 70, y: 110 });
515
+
516
+ expect(showRestoreInPlace(iframe, ROOT, files, 3)).not.toBeNull();
517
+
518
+ expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
519
+ expect(el.style.getPropertyValue("width")).toBe("100px");
520
+ });
521
+
522
+ it("keeps a drag that started after the undo was shown when the undo is put back", () => {
523
+ const { iframe, doc } = buildLiveIframe(undone);
524
+ const el = doc.getElementById("a")!;
525
+ const putBack = showRestoreInPlace(iframe, ROOT, files, 3)!;
526
+ beginStudioManualEditGesture(el, "move");
527
+ writeTranslatePx(el, { x: 70, y: 110 });
528
+
529
+ putBack(3);
530
+
531
+ expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
532
+ expect(el.style.getPropertyValue("width")).toBe("120px");
533
+ });
534
+
535
+ it("keeps the drag's translate when the undo re-runs a GSAP script", () => {
536
+ const script = (extra: string) =>
537
+ `<script>window.__timelines["root"]=gsap.timeline();${extra}</script>`;
538
+ const layer = `<div id="a" data-hf-studio-path-offset="true" style="translate: 40px 30px">t</div>`;
539
+ const { iframe, contentWindow, doc } = buildLiveIframe(
540
+ `${layer}${script("tl.set('#b',{x:1});")}`,
541
+ );
542
+ Object.assign(contentWindow.gsap, { set: () => {} });
543
+ const el = doc.getElementById("a")!;
544
+ beginStudioManualEditGesture(el, "move");
545
+ writeTranslatePx(el, { x: 70, y: 110 });
546
+ const scripted = {
547
+ [ROOT]: {
548
+ previous: wrap(`<div id="a">t</div>${script("tl.set('#b',{x:1});")}`),
549
+ restored: wrap(`<div id="a">t</div>${script("")}`),
550
+ },
551
+ };
552
+
553
+ expect(applyUndoRestoreToPreview(iframe, ROOT, scripted, 3, vi.fn())).toBe("soft");
554
+
555
+ expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
556
+ });
557
+
558
+ it("restores a layer no gesture is drawing exactly as before", () => {
559
+ const el = dragging(false);
560
+ const want = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
561
+
562
+ expect(el.getAttributeNames().map((n) => [n, el.getAttribute(n)])).toEqual(
563
+ want.getAttributeNames().map((n) => [n, want.getAttribute(n)]),
564
+ );
565
+ });
566
+ });