@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
@@ -23,6 +23,7 @@ import {
23
23
  } from "@hyperframes/core/audio-carve";
24
24
  import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
25
25
  import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
26
+ import { clipAudioOnItsClock, type ClipClock } from "./clipAudioClock.js";
26
27
 
27
28
  /** Decode rate every carve measurement shares — see `measureCarve`. */
28
29
  const DECODE_SAMPLE_RATE = 48000;
@@ -60,6 +61,13 @@ export function mintCarveNodes(
60
61
  };
61
62
  }
62
63
 
64
+ /** A clip to decode, where it starts on the timeline, and how it plays its file. */
65
+ export interface CarveClip {
66
+ src: string;
67
+ start: string | null | undefined;
68
+ clock: ClipClock;
69
+ }
70
+
63
71
  /**
64
72
  * Decode every voice, mix them onto the bed's own clock, and measure the
65
73
  * bands (and, if the profile calls for it, the ducking envelope) from that
@@ -68,10 +76,9 @@ export function mintCarveNodes(
68
76
  */
69
77
  export async function measureCarve(
70
78
  doc: Document,
71
- voices: { src: string; start: string | null }[],
79
+ voices: CarveClip[],
72
80
  strength: number,
73
- bedStartAttr: string | null | undefined,
74
- bedSrc: string | null | undefined,
81
+ bed: Omit<CarveClip, "src"> & { src: string | null | undefined },
75
82
  ): Promise<{
76
83
  bands: ReturnType<typeof analyseCarveBands>;
77
84
  carved: HfAudioFxChain;
@@ -86,11 +93,15 @@ export async function measureCarve(
86
93
  (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
87
94
  .webkitOfflineAudioContext;
88
95
  if (!Ctor) return null;
89
- const decode = async (relative: string): Promise<AudioBuffer> => {
96
+ // Each clip as it plays: trimmed to its in-point and duration, and at its own speed.
97
+ const decode = async (relative: string, clock: ClipClock): Promise<Float32Array> => {
90
98
  const res = await fetch(new URL(relative, doc.baseURI).href);
91
- return new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(await res.arrayBuffer());
99
+ const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
100
+ await res.arrayBuffer(),
101
+ );
102
+ return clipAudioOnItsClock(buffer.getChannelData(0), DECODE_SAMPLE_RATE, clock);
92
103
  };
93
- const bedStart = clipStart(bedStartAttr);
104
+ const bedStart = clipStart(bed.start);
94
105
  // Every voice, summed onto the bed's own clock. One question — where and
95
106
  // when is speech masking this bed — with one answer, even when the answer
96
107
  // comes from three people talking at different times. Doing this before the
@@ -98,23 +109,24 @@ export async function measureCarve(
98
109
  // the chain is fixed, so there is no per-voice filter to switch between.
99
110
  const decoded = await Promise.all(
100
111
  voices.map(async (voice) => ({
101
- samples: (await decode(voice.src)).getChannelData(0),
112
+ samples: await decode(voice.src, voice.clock),
102
113
  offsetSeconds: clipStart(voice.start) - bedStart,
103
114
  })),
104
115
  );
105
- const voiceMix = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
106
- if (voiceMix.length === 0) return null;
116
+ const mixed = mixCarveSources(decoded, DECODE_SAMPLE_RATE);
117
+ if (mixed.length === 0) return null;
107
118
  // Strength is what the author set; these are the numbers it means.
108
119
  const profile = carveProfile(strength);
109
- // The bed as well as the voice, when the carve is asked to match levels:
110
- // "how far over the voice is this bed" cannot be answered by listening to
111
- // one of them.
112
- const bedBuffer = profile.duckDb > 0 && bedSrc ? await decode(bedSrc).catch(() => null) : null;
120
+ // The bed as it plays: its length bounds the speech worth carving, and the duck
121
+ // compares against it ("how far over the voice is this bed" needs both).
122
+ const bedSamples = bed.src ? await decode(bed.src, bed.clock).catch(() => null) : null;
123
+ // Speech after the bed stops has no bed to carve.
124
+ const voiceMix = bedSamples ? mixed.subarray(0, bedSamples.length) : mixed;
113
125
  const bands = analyseCarveBands(voiceMix, DECODE_SAMPLE_RATE, profile);
114
126
  // The level half of the carve, measured against the speech it has to sit
115
127
  // under. No offset to apply: the mix is already on the bed's clock.
116
- const duck = bedBuffer
117
- ? analyseCarveDuck(voiceMix, bedBuffer.getChannelData(0), DECODE_SAMPLE_RATE, profile, 0)
128
+ const duck = bedSamples
129
+ ? analyseCarveDuck(voiceMix, bedSamples, DECODE_SAMPLE_RATE, profile, 0)
118
130
  : [];
119
131
  return { bands, carved: carveBandsToChain(bands), duck, voiceMix };
120
132
  }
@@ -14,7 +14,8 @@ import {
14
14
  type HfAudioFxChain,
15
15
  } from "@hyperframes/core/audio-fx";
16
16
  import { levellingResult, removeLevelling } from "@hyperframes/core/audio-leveller";
17
- import type { HfAutomation } from "@hyperframes/core/audio-automation";
17
+ import type { HfAutomation, HfAutomationPoint } from "@hyperframes/core/audio-automation";
18
+ import { timeAtSourceTime } from "@hyperframes/core/speed-ramp";
18
19
  import {
19
20
  automationAttrValue,
20
21
  HF_AUDIO_AUTOMATION_ATTR,
@@ -23,6 +24,7 @@ import {
23
24
  } from "./propertyPanelAutomation";
24
25
  import { trackLeveller } from "./audioFxTelemetry.js";
25
26
  import type { DomEditSelection } from "./domEditingTypes";
27
+ import { clipSourceWindow, readClipClock } from "./clipAudioClock.js";
26
28
  import { useAuditionTransport } from "./useAuditionTransport.js";
27
29
 
28
30
  /**
@@ -31,11 +33,6 @@ import { useAuditionTransport } from "./useAuditionTransport.js";
31
33
  */
32
34
  const DECODE_SAMPLE_RATE = 48000;
33
35
 
34
- /** A parsed attribute, kept only when it is a usable positive number. */
35
- function positiveFinite(n: number): number | null {
36
- return Number.isFinite(n) && n > 0 ? n : null;
37
- }
38
-
39
36
  export function useFxLevelling(
40
37
  element: DomEditSelection,
41
38
  chain: HfAudioFxChain,
@@ -75,27 +72,19 @@ export function useFxLevelling(
75
72
  return next;
76
73
  };
77
74
 
78
- /**
79
- * The part of the decoded file this clip actually plays.
80
- *
81
- * A lane's `t` is seconds from the start of the CLIP, but the decode is the
82
- * whole file from its first sample — so measuring a trimmed clip produced an
83
- * envelope offset by the trim, and every correction landed early by exactly
84
- * `media-start`. Slicing here is what puts the two clocks back on the same
85
- * zero.
86
- */
87
- const clipWindow = (audio: { samples: Float32Array; sampleRate: number }) => {
88
- const mediaStart = positiveFinite(Number(element.dataAttributes?.["media-start"] ?? 0));
89
- const duration = positiveFinite(Number(element.dataAttributes?.["duration"] ?? Number.NaN));
90
- const from = mediaStart
91
- ? Math.min(audio.samples.length, Math.floor(mediaStart * audio.sampleRate))
92
- : 0;
93
- const to = duration
94
- ? Math.min(audio.samples.length, from + Math.ceil(duration * audio.sampleRate))
95
- : audio.samples.length;
96
- return from === 0 && to === audio.samples.length
97
- ? audio.samples
98
- : audio.samples.subarray(from, to);
75
+ /** The levelled chain and its lane, with the lane moved from the source's clock onto the clip's. */
76
+ const measureLevelling = (audio: { samples: Float32Array; sampleRate: number }) => {
77
+ // Measured on the part of the file the clip plays, so lane times map from that window's zero.
78
+ const clock = readClipClock((name) => element.dataAttributes?.[name.slice(5)]);
79
+ const played = clipSourceWindow(audio.samples, audio.sampleRate, clock);
80
+ const result = levellingResult(chain, played, audio.sampleRate);
81
+ if (!result) return null;
82
+ const lane = result.automation.lanes[0];
83
+ const toClipTime = (point: HfAutomationPoint) => ({
84
+ ...point,
85
+ t: Number(timeAtSourceTime(clock.rate, point.t).toFixed(3)),
86
+ });
87
+ return { chain: result.chain, lane: lane && { ...lane, points: lane.points.map(toClipTime) } };
99
88
  };
100
89
 
101
90
  const runLeveller = async (): Promise<void> => {
@@ -103,14 +92,14 @@ export function useFxLevelling(
103
92
  try {
104
93
  const audio = await decodeTrack();
105
94
  if (!audio) return;
106
- const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
95
+ const result = measureLevelling(audio);
107
96
  if (!result) return;
108
97
  trackLeveller("run");
109
98
  await onSetAttributeQuiet(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
110
99
  // Merged by target, never written wholesale: the script describes its own
111
100
  // lane only, and replacing the attribute would take the carve's lanes and
112
101
  // the volume lane with it.
113
- const lane = result.automation.lanes[0];
102
+ const lane = result.lane;
114
103
  if (lane) {
115
104
  void onSetAttributeQuiet(
116
105
  HF_AUDIO_AUTOMATION_ATTR,
@@ -160,10 +149,10 @@ export function useFxLevelling(
160
149
  const audio = await decodeTrack();
161
150
  // Gone, or superseded by a later hover. Either way this result is stale.
162
151
  if (!audio || run !== auditionRun.current) return;
163
- const result = levellingResult(chain, clipWindow(audio), audio.sampleRate);
152
+ const result = measureLevelling(audio);
164
153
  if (!result || run !== auditionRun.current) return;
165
154
  void onSetAttributeLive(HF_AUDIO_FX_ATTR, serializeAudioFxChain(result.chain));
166
- const lane = result.automation.lanes[0];
155
+ const lane = result.lane;
167
156
  if (lane) {
168
157
  void onSetAttributeLive(
169
158
  HF_AUDIO_AUTOMATION_ATTR,
@@ -1,4 +1,4 @@
1
- import { useRef, type ReactNode, type RefObject } from "react";
1
+ import { useEffect, useRef, type ReactNode, type RefObject } from "react";
2
2
  import { InlineTextToolbar } from "./InlineTextToolbar";
3
3
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
4
  import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
@@ -12,6 +12,8 @@ import {
12
12
  } from "./domEditInlineText";
13
13
  import type { DomEditSelection } from "./domEditingTypes";
14
14
 
15
+ type KeyChord = Pick<KeyboardEvent, "key" | "shiftKey" | "ctrlKey" | "metaKey" | "altKey">;
16
+
15
17
  /**
16
18
  * The canvas' side of editing text where it sits.
17
19
  *
@@ -23,18 +25,15 @@ import type { DomEditSelection } from "./domEditingTypes";
23
25
  * props, the same way the agent surfaces in that overlay read it, and it is
24
26
  * absent in standalone player mounts, which have no project to edit.
25
27
  */
26
- export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection | null>): {
28
+ export function useInlineTextEditing(
29
+ selectionRef: RefObject<DomEditSelection | null>,
30
+ { enterFromWindow = false }: { enterFromWindow?: boolean } = {},
31
+ ): {
27
32
  editing: boolean;
28
33
  /** Open an edit when this press pairs with the last one. */
29
34
  startFromPress: (event: { clientX: number; clientY: number }) => boolean;
30
35
  /** Handle a key on the canvas. Returns true when it opened an edit. */
31
- handleKeyDown: (event: {
32
- key: string;
33
- shiftKey: boolean;
34
- ctrlKey: boolean;
35
- metaKey: boolean;
36
- altKey: boolean;
37
- }) => boolean;
36
+ handleKeyDown: (event: KeyChord) => boolean;
38
37
  /**
39
38
  * The styling controls for the current selection, for the caller to render.
40
39
  *
@@ -84,6 +83,32 @@ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection |
84
83
  return { x: (event.clientX - box.left) / scale, y: (event.clientY - box.top) / scale };
85
84
  };
86
85
 
86
+ // Enter opens the selected element's text, the way every design tool does,
87
+ // and is the dependable way in: a double press has to survive the canvas'
88
+ // gesture machinery, while this is one key on a selection that has settled.
89
+ const handleKeyDown = (event: KeyChord) => {
90
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
91
+ return false;
92
+ const target = selectionRef.current;
93
+ if (inlineText.session || !target || !canEditTextInline(target)) return false;
94
+ return inlineText.start(target.element);
95
+ };
96
+ const handleKeyDownRef = useRef(handleKeyDown);
97
+ handleKeyDownRef.current = handleKeyDown;
98
+ // A host-mode canvas takes no presses of its own, so it is rarely focused;
99
+ // with nothing focused, Enter comes via the window.
100
+ useEffect(() => {
101
+ if (!enterFromWindow) return;
102
+ const onKeyDown = (event: KeyboardEvent) => {
103
+ const { body, documentElement } = window.document;
104
+ if (event.defaultPrevented || (event.target !== body && event.target !== documentElement))
105
+ return;
106
+ if (handleKeyDownRef.current(event)) event.preventDefault();
107
+ };
108
+ window.addEventListener("keydown", onKeyDown);
109
+ return () => window.removeEventListener("keydown", onKeyDown);
110
+ }, [enterFromWindow]);
111
+
87
112
  return {
88
113
  editing: inlineText.session !== null,
89
114
  toolbar: (
@@ -92,16 +117,7 @@ export function useInlineTextEditing(selectionRef: RefObject<DomEditSelection |
92
117
  iframe={actions?.previewIframeRef?.current ?? null}
93
118
  />
94
119
  ),
95
- // Enter opens the selected element's text, the way every design tool does,
96
- // and is the dependable way in: a double press has to survive the canvas'
97
- // gesture machinery, while this is one key on a selection that has settled.
98
- handleKeyDown: (event) => {
99
- if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
100
- return false;
101
- const target = selectionRef.current;
102
- if (inlineText.session || !target || !canEditTextInline(target)) return false;
103
- return inlineText.start(target.element);
104
- },
120
+ handleKeyDown,
105
121
  startFromPress: (event) => {
106
122
  if (inlineText.session) return false;
107
123
  const element = elementUnderPress(event);
@@ -16,6 +16,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
18
  import type { FeedbackContext } from "./feedbackTrigger";
19
+ import { isMediaFile } from "../../utils/mediaTypes";
19
20
 
20
21
  /** Written by `hyperframes init`; absent in a hand-made or copied project. */
21
22
  const CONFIG_FILE = "hyperframes.json";
@@ -23,12 +24,10 @@ const CONFIG_FILE = "hyperframes.json";
23
24
  /** Matches the CLI's own slug gate, so a hand-edited value cannot leak text. */
24
25
  const SKILL_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
26
 
26
- const MEDIA_EXTENSIONS = /\.(mp4|mov|webm|m4v|mp3|wav|m4a|aac|ogg|png|jpe?g|gif|webp|svg|avif)$/i;
27
-
28
27
  let snapshot: FeedbackContext = {};
29
28
 
30
29
  function countMedia(files: string[]): number {
31
- return files.filter((f) => MEDIA_EXTENSIONS.test(f)).length;
30
+ return files.filter(isMediaFile).length;
32
31
  }
33
32
 
34
33
  /**
@@ -11,6 +11,7 @@ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
11
11
  import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
12
12
  import {
13
13
  AudioMeterStrip,
14
+ type AudioMeterStripProps,
14
15
  evictGoneMeterState,
15
16
  followMeterHook,
16
17
  stepAndPaintStrips,
@@ -20,8 +21,9 @@ import {
20
21
 
21
22
  const iframe = { contentWindow: null as unknown };
22
23
  const previewIframeRef = { current: iframe };
24
+ let shell: { previewIframeRef: typeof previewIframeRef } | null = { previewIframeRef };
23
25
  vi.mock("../../contexts/StudioContext", () => ({
24
- useStudioShellContext: () => ({ previewIframeRef }),
26
+ useStudioShellContextOptional: () => shell,
25
27
  }));
26
28
 
27
29
  const onSetAudioGroupAttributeLive = vi.fn();
@@ -88,6 +90,7 @@ const tick = () => {
88
90
  };
89
91
 
90
92
  beforeEach(() => {
93
+ shell = { previewIframeRef };
91
94
  frames = [];
92
95
  vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
93
96
  vi.stubGlobal("cancelAnimationFrame", () => {});
@@ -103,11 +106,11 @@ afterEach(() => {
103
106
  vi.unstubAllGlobals();
104
107
  });
105
108
 
106
- function mount() {
109
+ function mount(props: AudioMeterStripProps = {}) {
107
110
  const host = document.createElement("div");
108
111
  document.body.append(host);
109
112
  const root = createRoot(host);
110
- act(() => root.render(<AudioMeterStrip />));
113
+ act(() => root.render(<AudioMeterStrip {...props} />));
111
114
  roots.push(root);
112
115
  return { host, root };
113
116
  }
@@ -177,6 +180,26 @@ describe("AudioMeterStrip", () => {
177
180
  expect(second.read).toHaveBeenCalled();
178
181
  });
179
182
 
183
+ it("reads a host's preview iframe over the shell's", () => {
184
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
185
+ const shellHook = makeHook();
186
+ setHook(shellHook);
187
+ const hostHook = makeHook();
188
+ const hostIframe = { contentWindow: { __hf: { audioMeter: hostHook } } };
189
+ mount({ previewIframeRef: { current: hostIframe as unknown as HTMLIFrameElement } });
190
+ tick();
191
+ expect(hostHook.start).toHaveBeenCalledTimes(1);
192
+ expect(shellHook.start).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("mounts with no shell and no preview iframe", () => {
196
+ shell = null;
197
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
198
+ const { host } = mount();
199
+ tick();
200
+ expect(host.querySelector("[data-testid=audio-meter-strip]")).not.toBeNull();
201
+ });
202
+
180
203
  it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
181
204
  usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
182
205
  const { host } = mount();
@@ -20,7 +20,7 @@ import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
20
20
  import { usePlayerStore } from "../../player";
21
21
  import { clampNumber } from "../../utils/studioHelpers";
22
22
  import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
23
- import { useStudioShellContext } from "../../contexts/StudioContext";
23
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
24
24
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
25
25
  import {
26
26
  METER_DB_MARKS,
@@ -107,7 +107,7 @@ function useVolumeHandlers(): {
107
107
 
108
108
  type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
109
109
 
110
- function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
110
+ function readHook(iframe: HTMLIFrameElement | null | undefined): AudioMeterHook | null {
111
111
  try {
112
112
  return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
113
113
  } catch {
@@ -123,9 +123,8 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
123
123
  el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
124
124
  }
125
125
 
126
- /** The fill is a fixed green/amber/red backdrop; painting only moves the dark
127
- * mask that covers the unlit top portion, so a loud peak lights the real red
128
- * band instead of tinting a flat colour brighter. */
126
+ /** The fill is a fixed green/amber/red backdrop; painting only moves the dark mask that covers the unlit top
127
+ * portion, so a loud peak lights the real red band instead of tinting a flat colour brighter. */
129
128
  function paint(bars: StripBars | undefined, channels: Pair): void {
130
129
  channels.forEach((ch, i) => {
131
130
  bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
@@ -179,8 +178,13 @@ export function stepAndPaintStrips(
179
178
  }
180
179
 
181
180
  /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
182
- function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
183
- const { previewIframeRef } = useStudioShellContext();
181
+ function useMeterLoop(
182
+ strips: Strip[],
183
+ bars: RefObject<Map<string | null, StripBars>>,
184
+ iframeRef: RefObject<HTMLIFrameElement | null> | undefined,
185
+ ) {
186
+ const shell = useStudioShellContextOptional();
187
+ const previewIframeRef = iframeRef ?? shell?.previewIframeRef;
184
188
  const stripsRef = useRef(strips);
185
189
  stripsRef.current = strips;
186
190
  useEffect(() => {
@@ -190,7 +194,7 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
190
194
  const state = new Map<string | null, Pair>();
191
195
  const tick = (now: number) => {
192
196
  raf = requestAnimationFrame(tick);
193
- active = followMeterHook(active, readHook(previewIframeRef.current));
197
+ active = followMeterHook(active, readHook(previewIframeRef?.current));
194
198
  const levels = active?.read();
195
199
  const dt = now - last;
196
200
  last = now;
@@ -395,14 +399,21 @@ function MeterStrip({
395
399
  );
396
400
  }
397
401
 
398
- export const AudioMeterStrip = memo(function AudioMeterStrip() {
402
+ export interface AudioMeterStripProps {
403
+ /** Pass a stable ref (useRef): a new object each render restarts the meter loop. */
404
+ previewIframeRef?: RefObject<HTMLIFrameElement | null>;
405
+ }
406
+
407
+ export const AudioMeterStrip = memo(function AudioMeterStrip({
408
+ previewIframeRef,
409
+ }: AudioMeterStripProps) {
399
410
  const visible = useAudioMetersVisible((s) => s.visible);
400
411
  const projectHasAudio = useProjectHasAudio();
401
412
  if (!visible || !projectHasAudio) return null;
402
- return <MeterStripBody />;
413
+ return <MeterStripBody previewIframeRef={previewIframeRef} />;
403
414
  });
404
415
 
405
- function MeterStripBody() {
416
+ function MeterStripBody({ previewIframeRef }: AudioMeterStripProps) {
406
417
  const strips = useStrips();
407
418
  const bars = useRef(new Map<string | null, StripBars>());
408
419
  const register = useRef((id: string | null, b: StripBars | null) => {
@@ -410,7 +421,7 @@ function MeterStripBody() {
410
421
  else bars.current.delete(id);
411
422
  }).current;
412
423
  const { onLive, onCommit } = useVolumeHandlers();
413
- useMeterLoop(strips, bars);
424
+ useMeterLoop(strips, bars, previewIframeRef);
414
425
  return (
415
426
  <div
416
427
  data-testid="audio-meter-strip"