@hyperframes/studio 0.8.81 → 0.8.83

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-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -54,6 +54,14 @@ interface DomEditOverlayProps {
54
54
  groupSelections?: DomEditSelection[];
55
55
  hoverSelection: DomEditSelection | null;
56
56
  allowCanvasMovement?: boolean;
57
+ /** "host": no hover, marquee or box re-select; Enter with nothing focused still opens text. */
58
+ canvasInput?: "overlay" | "host";
59
+ onTextEditingChange?: (editing: boolean) => void;
60
+ /** A click on a single selection's box, in either mode; the event may be the pointerup. */
61
+ onSelectionBoxClick?: (
62
+ event: React.MouseEvent<HTMLDivElement>,
63
+ selection: DomEditSelection,
64
+ ) => void;
57
65
  onCanvasMouseDown: (
58
66
  event: React.MouseEvent<HTMLDivElement>,
59
67
  options?: PreviewMouseDownOptions,
@@ -117,7 +125,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
117
125
  groupSelections = [],
118
126
  hoverSelection,
119
127
  allowCanvasMovement = true,
120
- onCanvasMouseDown,
128
+ canvasInput = "overlay",
129
+ onTextEditingChange,
130
+ onSelectionBoxClick,
131
+ onCanvasMouseDown: onCanvasMouseDownProp,
121
132
  onCanvasPointerMove,
122
133
  onCanvasPointerLeave,
123
134
  onSelectionChange,
@@ -133,6 +144,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
133
144
  onApplyZIndex,
134
145
  }: DomEditOverlayProps) {
135
146
  const readOnly = usePreviewReadOnly();
147
+ const hostInput = canvasInput === "host";
136
148
  const overlayRef = useRef<HTMLDivElement | null>(null);
137
149
  const boxRef = useRef<HTMLDivElement | null>(null);
138
150
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
@@ -150,6 +162,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
150
162
 
151
163
  const selectionRef = useRef(selection);
152
164
  selectionRef.current = selection;
165
+ const onCanvasMouseDown: typeof onCanvasMouseDownProp = (event, options) => {
166
+ const sel = selectionRef.current;
167
+ if (sel && boxRef.current?.contains(event.target as Node | null)) {
168
+ onSelectionBoxClick?.(event, sel);
169
+ }
170
+ if (!hostInput) onCanvasMouseDownProp(event, options);
171
+ };
153
172
 
154
173
  // Brief highlight on the sibling a forward/backward z step crossed — drawn
155
174
  // in this studio overlay, never in the iframe DOM (see useZOrderCrossedFlash).
@@ -163,7 +182,14 @@ export const DomEditOverlay = memo(function DomEditOverlay({
163
182
  hoverSelectionRef.current = hoverSelection;
164
183
 
165
184
  // Double-click an element to edit its text where it sits.
166
- const inlineText = useInlineTextEditing(selectionRef);
185
+ const inlineText = useInlineTextEditing(selectionRef, { enterFromWindow: hostInput });
186
+ const onTextEditingChangeRef = useRef(onTextEditingChange);
187
+ onTextEditingChangeRef.current = onTextEditingChange;
188
+ useEffect(() => {
189
+ if (!inlineText.editing) return;
190
+ onTextEditingChangeRef.current?.(true);
191
+ return () => onTextEditingChangeRef.current?.(false);
192
+ }, [inlineText.editing]);
167
193
  const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
168
194
  onPathOffsetCommitRef.current = onPathOffsetCommit;
169
195
  const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
@@ -322,7 +348,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
322
348
 
323
349
  // fallow-ignore-next-line complexity
324
350
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
325
- if (!allowCanvasMovement || event.button !== 0) return;
351
+ if (!allowCanvasMovement || hostInput || event.button !== 0) return;
326
352
  if (event.shiftKey) {
327
353
  const shiftIframe = iframeRef.current;
328
354
  const candidate = resolveShiftClickCandidate({
@@ -378,9 +404,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
378
404
  )
379
405
  : null;
380
406
  if (freshTarget) return;
381
- const overlayEl = overlayRef.current;
382
- if (overlayEl) {
383
- const oRect = overlayEl.getBoundingClientRect();
407
+ if (overlayRef.current) {
384
408
  // Anywhere empty on the overlay starts one, not just inside the frame.
385
409
  // An element dragged past the edge sits OUT there in the grey, and a
386
410
  // rubber band that refuses to start there cannot reach it — which left
@@ -390,17 +414,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
390
414
  event.preventDefault();
391
415
  event.stopPropagation();
392
416
  suppressNextOverlayMouseDownRef.current = true;
393
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
394
- const cx = event.clientX - oRect.left;
395
- const cy = event.clientY - oRect.top;
396
- marquee.marqueeRef.current = {
397
- startX: cx,
398
- startY: cy,
399
- currentX: cx,
400
- currentY: cy,
401
- pointerId: event.pointerId,
402
- pastThreshold: false,
403
- };
417
+ marquee.begin(event);
404
418
  return;
405
419
  }
406
420
  }
@@ -440,7 +454,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
440
454
  // Standing aside is the only way the caret below can be reached, and is
441
455
  // what keeps selection, drag and marquee from firing mid-edit.
442
456
  className={`absolute inset-0 z-10 outline-hidden ${
443
- inlineText.editing ? "pointer-events-none" : "pointer-events-auto"
457
+ inlineText.editing || hostInput ? "pointer-events-none" : "pointer-events-auto"
444
458
  }`}
445
459
  data-editing-text={inlineText.editing ? "true" : undefined}
446
460
  tabIndex={-1}
@@ -451,6 +465,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
451
465
  // A pointer gesture supersedes a pending nudge burst — commit it first
452
466
  // so the gesture's member snapshot starts from the nudged position.
453
467
  flushNudge();
468
+ suppressNextBoxClickRef.current = false;
454
469
  // Not while editing: taking focus back would send the keystroke nowhere.
455
470
  if (!inlineText.editing) {
456
471
  focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
@@ -467,9 +482,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
467
482
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
468
483
  onPointerUp={marquee.onPointerUp}
469
484
  onPointerCancel={marquee.onPointerCancel}
470
- onContextMenu={handleContextMenu}
485
+ onContextMenu={hostInput ? undefined : handleContextMenu}
471
486
  >
472
- {hoverSelection && hoverRect && compRect.width > 0 && (
487
+ {!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
473
488
  <div
474
489
  aria-hidden="true"
475
490
  data-dom-edit-hover-box="true"
@@ -516,7 +531,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
516
531
  editing session, which does. */}
517
532
  {inlineText.toolbar}
518
533
  <OffCanvasIndicators
519
- rects={offCanvasRects}
534
+ rects={hostInput ? [] : offCanvasRects}
520
535
  elements={offCanvasElementsRef}
521
536
  compRect={compRect}
522
537
  selection={selection}
@@ -343,7 +343,7 @@ export const LayersPanel = memo(function LayersPanel() {
343
343
 
344
344
  // ONE undo entry for the whole gesture: the z persist and the timeline
345
345
  // lane mirror below share this per-gesture-unique key (same contract as
346
- // the canvas menu's wiring in PreviewOverlays).
346
+ // the canvas menu's wiring in ConnectedDomEditOverlay).
347
347
  const coalesceKey = zReorderCoalesceKey(entries, "layer-drag");
348
348
  const desiredOrderKeys = desiredBottomToTop.map(
349
349
  (l) =>
@@ -1,6 +1,6 @@
1
1
  import type { Rect } from "../../utils/marqueeGeometry";
2
2
 
3
- interface MarqueeOverlayProps {
3
+ export interface MarqueeOverlayProps {
4
4
  /** Elements the marquee currently intersects — outlined live before mouse-up. */
5
5
  candidateRects: Rect[];
6
6
  /** The marquee drag rectangle itself, or null when not marquee-selecting. */
@@ -15,7 +15,7 @@ const GUIDE_COLOR = "rgba(255, 68, 204, 0.85)";
15
15
  const SPACING_COLOR = "rgba(255, 68, 204, 0.6)";
16
16
  const SPACING_BG = "rgba(255, 68, 204, 0.15)";
17
17
 
18
- interface SnapGuideOverlayProps {
18
+ export interface SnapGuideOverlayProps {
19
19
  snapGuidesRef: RefObject<SnapGuidesState | null>;
20
20
  /** Composition rect in overlay space — guide lines span exactly this rect. */
21
21
  compositionLeft: number;
@@ -1,11 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import {
4
- applyStudioBoxSize,
5
- applyStudioPathOffset,
6
- readStudioBoxSize,
7
- reapplyPositionEditsAfterSeek,
8
- } from "./manualEditsDom";
3
+ import { applyStudioBoxSize, applyStudioPathOffset, readStudioBoxSize } from "./manualEditsDom";
4
+ import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
9
5
  import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
10
6
  import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
11
7
  import { computeNextResizeAnchor } from "./domEditResizeLocal";
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clipAudioOnItsClock, readClipClock } from "./clipAudioClock";
3
+
4
+ const RATE = 48000;
5
+ const sine = (seconds: number, hz: number) =>
6
+ Float32Array.from({ length: RATE * seconds }, (_, i) => Math.sin((2 * Math.PI * hz * i) / RATE));
7
+ const zeroCrossingsPerSecond = (x: Float32Array) => {
8
+ let n = 0;
9
+ for (let i = 1; i < x.length; i += 1) if (x[i - 1]! < 0 !== x[i]! < 0) n += 1;
10
+ return n / (x.length / RATE);
11
+ };
12
+
13
+ describe("readClipClock", () => {
14
+ it("reads the in-point, the rate lane over the constant, and a positive duration", () => {
15
+ const attrs: Record<string, string> = {
16
+ "data-playback-start": "1.5",
17
+ "data-media-start": "9",
18
+ "data-playback-rate": "3",
19
+ "data-automation":
20
+ '{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":2},{"t":4,"v":2}]}]}',
21
+ "data-duration": "4",
22
+ };
23
+ const clock = readClipClock((name) => attrs[name]);
24
+ expect({ ...clock, rate: typeof clock.rate }).toEqual({
25
+ inPoint: 1.5,
26
+ rate: "object",
27
+ duration: 4,
28
+ });
29
+ expect(readClipClock((name) => (name === "data-duration" ? "0" : null))).toEqual({
30
+ inPoint: 0,
31
+ rate: 1,
32
+ duration: null,
33
+ });
34
+ });
35
+ });
36
+
37
+ describe("clipAudioOnItsClock", () => {
38
+ it("is the exact slice the clip plays at rate 1", () => {
39
+ const samples = sine(4, 440);
40
+ const out = clipAudioOnItsClock(samples, RATE, { inPoint: 1, rate: 1, duration: 2 });
41
+ expect(Array.from(out)).toEqual(Array.from(samples.subarray(RATE, 3 * RATE)));
42
+ });
43
+
44
+ it("lasts the clip's duration at 2x, consuming twice as much source", () => {
45
+ // Each sample holds its own source time, so the output reads back where it was taken from.
46
+ const samples = Float32Array.from({ length: RATE * 6 }, (_, i) => i / RATE);
47
+ const out = clipAudioOnItsClock(samples, RATE, { inPoint: 1, rate: 2, duration: 2 });
48
+ expect({ length: out.length, atOneSecond: Number(out[RATE]!.toFixed(1)) }).toEqual({
49
+ length: 2 * RATE,
50
+ atOneSecond: 3,
51
+ });
52
+ });
53
+
54
+ it("keeps the pitch at 2x and 0.5x", () => {
55
+ const samples = sine(4, 440);
56
+ for (const rate of [2, 0.5]) {
57
+ const out = clipAudioOnItsClock(samples, RATE, { inPoint: 0, rate, duration: 1.5 });
58
+ expect(zeroCrossingsPerSecond(out) / 880).toBeCloseTo(1, 1);
59
+ }
60
+ });
61
+ });
@@ -0,0 +1,78 @@
1
+ import {
2
+ resolveRateSpec,
3
+ sourceTimeAt,
4
+ timeAtSourceTime,
5
+ type RateSpec,
6
+ } from "@hyperframes/core/speed-ramp";
7
+ import {
8
+ readDataDurationSeconds,
9
+ readMediaOffsetSeconds,
10
+ readPlaybackRate,
11
+ type AttrReader,
12
+ } from "@hyperframes/parsers/media-duration";
13
+
14
+ /** Where a clip's audio starts in its file, how fast it plays, and for how long (null: to the end). */
15
+ export interface ClipClock {
16
+ inPoint: number;
17
+ rate: RateSpec;
18
+ duration: number | null;
19
+ }
20
+
21
+ export function readClipClock(getAttr: AttrReader): ClipClock {
22
+ return {
23
+ inPoint: readMediaOffsetSeconds(getAttr),
24
+ rate: resolveRateSpec(getAttr("data-automation"), readPlaybackRate(getAttr)),
25
+ duration: readDataDurationSeconds(getAttr),
26
+ };
27
+ }
28
+
29
+ /** The source samples a clip plays, still on the file's clock. */
30
+ export function clipSourceWindow(
31
+ samples: Float32Array,
32
+ sampleRate: number,
33
+ clock: ClipClock,
34
+ ): Float32Array {
35
+ const from = Math.min(samples.length, Math.floor(clock.inPoint * sampleRate));
36
+ const to = clock.duration
37
+ ? Math.min(
38
+ samples.length,
39
+ from + Math.ceil(sourceTimeAt(clock.rate, clock.duration) * sampleRate),
40
+ )
41
+ : samples.length;
42
+ return samples.subarray(from, to);
43
+ }
44
+
45
+ const GRAIN = 4096;
46
+ const HOP = GRAIN / 2;
47
+ const HANN = Float32Array.from(
48
+ { length: GRAIN },
49
+ (_, k) => 0.5 - 0.5 * Math.cos((2 * Math.PI * k) / GRAIN),
50
+ );
51
+
52
+ /**
53
+ * The audio heard over clip time [0, duration]. Off rate 1, Hann grains overlap-added at the source
54
+ * time each lands on: pitch kept, like export's atempo, close enough for band and level analysis.
55
+ */
56
+ export function clipAudioOnItsClock(
57
+ samples: Float32Array,
58
+ sampleRate: number,
59
+ clock: ClipClock,
60
+ ): Float32Array {
61
+ const source = clipSourceWindow(samples, sampleRate, clock);
62
+ if (clock.rate === 1) return source;
63
+ const out = new Float32Array(
64
+ Math.round(timeAtSourceTime(clock.rate, source.length / sampleRate) * sampleRate),
65
+ );
66
+ // Grains are not phase-aligned, so off rate 1 the level reads ~1.3 dB low (up to ~3 dB on
67
+ // tones); a WSOLA stretch would keep it.
68
+ for (let at = -HOP; at < out.length; at += HOP) {
69
+ const centre = Math.round(sourceTimeAt(clock.rate, (at + HOP) / sampleRate) * sampleRate);
70
+ for (let k = 0; k < GRAIN; k += 1) {
71
+ const o = at + k;
72
+ const s = centre - HOP + k;
73
+ if (o < 0 || o >= out.length || s < 0 || s >= source.length) continue;
74
+ out[o] = (out[o] ?? 0) + (HANN[k] ?? 0) * (source[s] ?? 0);
75
+ }
76
+ }
77
+ return out;
78
+ }
@@ -432,6 +432,30 @@ describe("studio manual edits", () => {
432
432
  expect(applied).toBe(11);
433
433
  });
434
434
 
435
+ it("reapplies once after a seek that applied, and again once a held seek lands", async () => {
436
+ const previewWindow = new Window() as unknown as Parameters<
437
+ typeof installStudioManualEditSeekReapply
438
+ >[0] & { __player: Record<string, unknown> };
439
+ let landed = () => {};
440
+ const held = new Promise<void>((resolve) => (landed = resolve));
441
+ previewWindow.__player = { seek: (time: number) => (time > 5 ? held : undefined) };
442
+ let applied = 0;
443
+ installStudioManualEditSeekReapply(previewWindow, () => {
444
+ applied += 1;
445
+ });
446
+ const seek = previewWindow.__player.seek as (time: number) => unknown;
447
+
448
+ seek(1);
449
+ await Promise.resolve();
450
+ expect(applied).toBe(1);
451
+ expect(seek(5.5)).toBe(held);
452
+ expect(applied).toBe(2);
453
+ landed();
454
+ await held;
455
+ await Promise.resolve();
456
+ expect(applied).toBe(3);
457
+ });
458
+
435
459
  it("reapplies manual edits while fresh playback is active", () => {
436
460
  const window = new Window();
437
461
  const frames: FrameRequestCallback[] = [];
@@ -30,9 +30,10 @@ export {
30
30
  applyStudioBoxSizeDraft,
31
31
  applyStudioRotation,
32
32
  applyStudioRotationDraft,
33
- reapplyPositionEditsAfterSeek,
34
33
  } from "./manualEditsDom";
35
34
 
35
+ export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
36
+
36
37
  export {
37
38
  captureStudioBoxSize,
38
39
  captureStudioRotation,
@@ -78,6 +79,7 @@ function wrapSeekReapplyFunction(
78
79
  win: StudioManualEditSeekWindow,
79
80
  owner: Record<string, unknown> | undefined,
80
81
  key: string,
82
+ reapplyWhenHeldSeekLands = false,
81
83
  ): boolean {
82
84
  const fn = owner?.[key];
83
85
  if (!owner || typeof fn !== "function") return false;
@@ -87,6 +89,8 @@ function wrapSeekReapplyFunction(
87
89
  const wrappedSeek = function (this: unknown, ...args: unknown[]): unknown {
88
90
  const result = seek.apply(this, args);
89
91
  win.__hfStudioManualEditsApply?.();
92
+ const held = reapplyWhenHeldSeekLands ? (result as PromiseLike<void> | undefined) : undefined;
93
+ if (typeof held?.then === "function") void held.then(() => win.__hfStudioManualEditsApply?.());
90
94
  return result;
91
95
  };
92
96
  markWrapped(wrappedSeek);
@@ -256,7 +260,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi
256
260
  studioWin[STUDIO_MANUAL_EDITS_APPLY_PROP] = apply;
257
261
 
258
262
  const wrappedHfSeek = wrapSeekReapplyFunction(studioWin, studioWin.__hf, "seek");
259
- const wrappedPlayerSeek = wrapSeekReapplyFunction(studioWin, studioWin.__player, "seek");
263
+ const wrappedPlayerSeek = wrapSeekReapplyFunction(studioWin, studioWin.__player, "seek", true);
260
264
  const wrappedPlayerRenderSeek = wrapSeekReapplyFunction(
261
265
  studioWin,
262
266
  studioWin.__player,
@@ -33,7 +33,6 @@ import {
33
33
  STUDIO_ROTATION_TRANSFORM_ORIGIN,
34
34
  } from "./manualEditsTypes";
35
35
  import { roundRotationAngle } from "./manualEditsParsing";
36
- import { applyStudioMotionFromDom } from "./studioMotion";
37
36
  import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
38
37
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
39
38
 
@@ -533,67 +532,3 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
533
532
  composeStudioRotationValue(element, `${roundRotationAngle(rotation.angle)}deg`),
534
533
  );
535
534
  }
536
-
537
- /* ── Seek reapply (position + motion) ────────────────────────────── */
538
- function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
539
- const ctor = doc.defaultView?.HTMLElement;
540
- if (!ctor) return [];
541
- const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
542
- (el): el is HTMLElement => el instanceof ctor,
543
- );
544
- // Handle legacy HTML files where attributes were persisted with a double data- prefix
545
- const legacyAttr = `data-${attr}`;
546
- for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
547
- if (el instanceof ctor && !el.hasAttribute(attr)) {
548
- el.setAttribute(attr, "true");
549
- el.removeAttribute(legacyAttr);
550
- elements.push(el);
551
- }
552
- }
553
- return elements;
554
- }
555
-
556
- function reapplyPathOffsets(doc: Document): void {
557
- for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
558
- // Unlike size below, the offset channels COMPOSE — applying both doubles the move.
559
- if (gsapAnimatesProperty(el, "x", "y")) continue;
560
- const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
561
- const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
562
- if (!x && !y) continue;
563
- const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
564
- applyStudioPathOffset(el, offset, { updateBase: false });
565
- }
566
- }
567
-
568
- /**
569
- * Put the studio's committed size back after a seek, GSAP-sized elements included.
570
- * Size does not compose the way the offset above does: both channels write width
571
- * and height, so the later write wins on the same number. Standing aside meant
572
- * nothing held the size while a soft reload reverted the old timeline (GSAP hands
573
- * back each tween's recorded starting width), so the element sat at its stylesheet
574
- * size until the new one rendered — the jump after a resize.
575
- */
576
- function reapplyBoxSizes(doc: Document): void {
577
- for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
578
- const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
579
- const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
580
- if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
581
- applyStudioBoxSize(el, { width: w, height: h });
582
- }
583
- }
584
- }
585
- function reapplyRotations(doc: Document): void {
586
- for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) {
587
- const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
588
- if (Number.isFinite(angle)) {
589
- applyStudioRotation(el, { angle });
590
- }
591
- }
592
- }
593
-
594
- export function reapplyPositionEditsAfterSeek(doc: Document): void {
595
- reapplyPathOffsets(doc);
596
- reapplyBoxSizes(doc);
597
- reapplyRotations(doc);
598
- applyStudioMotionFromDom(doc);
599
- }
@@ -0,0 +1,136 @@
1
+ // Puts Studio's committed edits back after a timeline seek has rendered over them.
2
+ import {
3
+ STUDIO_BOX_SIZE_ATTR,
4
+ STUDIO_HEIGHT_PROP,
5
+ STUDIO_OFFSET_X_PROP,
6
+ STUDIO_OFFSET_Y_PROP,
7
+ STUDIO_PATH_OFFSET_ATTR,
8
+ STUDIO_ROTATION_ATTR,
9
+ STUDIO_ROTATION_PROP,
10
+ STUDIO_WIDTH_PROP,
11
+ } from "./manualEditsTypes";
12
+ import { applyStudioBoxSize, applyStudioPathOffset, applyStudioRotation } from "./manualEditsDom";
13
+ import { applyStudioMotionFromDom } from "./studioMotion";
14
+ import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTypes";
15
+ import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
16
+
17
+ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
18
+ const ctor = doc.defaultView?.HTMLElement;
19
+ if (!ctor) return [];
20
+ const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
21
+ (el): el is HTMLElement => el instanceof ctor,
22
+ );
23
+ // Handle legacy HTML files where attributes were persisted with a double data- prefix
24
+ const legacyAttr = `data-${attr}`;
25
+ for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
26
+ if (el instanceof ctor && !el.hasAttribute(attr)) {
27
+ el.setAttribute(attr, "true");
28
+ el.removeAttribute(legacyAttr);
29
+ elements.push(el);
30
+ }
31
+ }
32
+ return elements;
33
+ }
34
+
35
+ function reapplyPathOffsets(doc: Document): void {
36
+ for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
37
+ // Unlike size below, the offset channels add up: applying both doubles the move.
38
+ if (gsapAnimatesProperty(el, "x", "y")) continue;
39
+ const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
40
+ const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
41
+ if (!x && !y) continue;
42
+ const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
43
+ applyStudioPathOffset(el, offset, { updateBase: false });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Put the committed size back after a seek, GSAP-sized elements included: both write width and height,
49
+ * so the later write wins. Standing aside let a soft reload revert to the stylesheet size for a frame.
50
+ */
51
+ function reapplyBoxSizes(doc: Document): void {
52
+ for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
53
+ const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
54
+ const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
55
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
56
+ applyStudioBoxSize(el, { width: w, height: h });
57
+ }
58
+ }
59
+ }
60
+ function reapplyRotations(doc: Document): void {
61
+ for (const el of queryStudioElements(doc, STUDIO_ROTATION_ATTR)) {
62
+ const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP));
63
+ if (Number.isFinite(angle)) {
64
+ applyStudioRotation(el, { angle });
65
+ }
66
+ }
67
+ }
68
+
69
+ // Every mark a reapply below acts on, the legacy double-prefixed ones included.
70
+ const STUDIO_EDIT_ATTRS = [STUDIO_PATH_OFFSET_ATTR, STUDIO_BOX_SIZE_ATTR, STUDIO_ROTATION_ATTR]
71
+ .flatMap((attr) => [attr, `data-${attr}`])
72
+ .concat(STUDIO_MOTION_ATTR);
73
+ // One selector each: Chrome answers a lone attribute selector without walking the DOM, a comma list it walks.
74
+ const STUDIO_EDIT_SELECTORS = STUDIO_EDIT_ATTRS.map((attr) =>
75
+ attr === STUDIO_MOTION_ATTR ? `[${attr}]` : `[${attr}="true"]`,
76
+ );
77
+
78
+ interface EditMarkCache {
79
+ observer: MutationObserver;
80
+ stale: boolean;
81
+ has: boolean;
82
+ }
83
+ const editMarkCaches = new WeakMap<Document, EditMarkCache>();
84
+
85
+ function scanForEditMarks(doc: Document): boolean {
86
+ return STUDIO_EDIT_SELECTORS.some((selector) => doc.querySelector(selector) !== null);
87
+ }
88
+
89
+ // Rescans only after a mark attribute or the tree changed, so a seek on a steady film does no lookup.
90
+ function hasEditMarks(doc: Document): boolean {
91
+ const Observer = doc.defaultView?.MutationObserver;
92
+ if (!Observer) return scanForEditMarks(doc);
93
+ let cache = editMarkCaches.get(doc);
94
+ if (!cache) {
95
+ const entry: EditMarkCache = {
96
+ observer: new Observer(() => {
97
+ entry.stale = true;
98
+ }),
99
+ stale: true,
100
+ has: false,
101
+ };
102
+ entry.observer.observe(doc, {
103
+ subtree: true,
104
+ childList: true,
105
+ attributes: true,
106
+ attributeFilter: STUDIO_EDIT_ATTRS,
107
+ });
108
+ editMarkCaches.set(doc, entry);
109
+ cache = entry;
110
+ }
111
+ // A mark set earlier in this task has not reached the observer callback yet.
112
+ if (cache.observer.takeRecords().length > 0) cache.stale = true;
113
+ if (cache.stale) {
114
+ cache.has = scanForEditMarks(doc);
115
+ cache.stale = false;
116
+ }
117
+ return cache.has;
118
+ }
119
+
120
+ function hasStudioEdits(doc: Document): boolean {
121
+ const timelines = (doc.defaultView as { __timelines?: Record<string, unknown> } | null)
122
+ ?.__timelines;
123
+ // A motion timeline outlives its marks until the next reapply kills it.
124
+ return Boolean(timelines?.[STUDIO_MOTION_TIMELINE_ID]) || hasEditMarks(doc);
125
+ }
126
+
127
+ export function reapplyPositionEditsAfterSeek(doc: Document): void {
128
+ // Runs after every seek, so several times per playback frame.
129
+ if (!hasStudioEdits(doc)) return;
130
+ reapplyPathOffsets(doc);
131
+ reapplyBoxSizes(doc);
132
+ reapplyRotations(doc);
133
+ applyStudioMotionFromDom(doc);
134
+ // The reapply rewrites the marks it read; the film still has them, so no rescan is due.
135
+ editMarkCaches.get(doc)?.observer.takeRecords();
136
+ }