@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
@@ -0,0 +1,282 @@
1
+ import {
2
+ HF_AUDIO_AUTOMATION_ATTR,
3
+ RATE_TARGET,
4
+ parseAutomation,
5
+ serializeAutomation,
6
+ type HfAutomationLane,
7
+ } from "@hyperframes/core/audio-automation";
8
+ import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
9
+ import { HF_AUDIO_FADE_IN_ATTR, HF_AUDIO_FADE_OUT_ATTR } from "@hyperframes/core/audio-fade";
10
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
11
+ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
12
+ import {
13
+ escapeHtmlAttribute,
14
+ findTagByTarget,
15
+ applyPatchByTarget,
16
+ readAttributeByTarget,
17
+ unescapeHtmlAttribute,
18
+ type PatchOperation,
19
+ type PatchTarget,
20
+ } from "../../utils/sourcePatcher";
21
+ import { collectHtmlIds, resolveAssetHasAudio } from "../../utils/studioHelpers";
22
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
23
+ import { buildDomEditPatchTarget, type DomEditSelection } from "./domEditing";
24
+ import { generateId } from "../../utils/generateId";
25
+
26
+ const MEDIA_LINK_ATTR = "data-link";
27
+ const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
28
+
29
+ const SOUND_NOT_KEPT_MESSAGE =
30
+ "Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
31
+
32
+ const MOVED_SOUND_ATTRS = [
33
+ "data-volume",
34
+ HF_AUDIO_FADE_IN_ATTR,
35
+ HF_AUDIO_FADE_OUT_ATTR,
36
+ HF_AUDIO_FX_ATTR,
37
+ HF_AUDIO_CARVE_ATTR,
38
+ HF_AUDIO_GROUP_ATTR,
39
+ ];
40
+
41
+ const COPIED_TIMING_ATTRS = [
42
+ "start",
43
+ "end",
44
+ "duration",
45
+ "media-start",
46
+ "playback-start",
47
+ "playback-rate",
48
+ "source-duration",
49
+ ];
50
+
51
+ const htmlAttr = (property: string, value: string | null): PatchOperation => ({
52
+ type: "html-attribute",
53
+ property,
54
+ value,
55
+ });
56
+
57
+ const dataAttr = (property: string, value: string | null): PatchOperation => ({
58
+ type: "attribute",
59
+ property,
60
+ value,
61
+ });
62
+
63
+ function stripDataPrefix(name: string): string {
64
+ return name.startsWith("data-") ? name.slice("data-".length) : name;
65
+ }
66
+
67
+ function probedHasAudioValue(probedHasAudio: boolean | null): string | null {
68
+ if (probedHasAudio === null) return null;
69
+ return probedHasAudio ? "true" : "false";
70
+ }
71
+
72
+ export function mutedToggleOps(input: {
73
+ isVideo: boolean;
74
+ nextMuted: boolean;
75
+ probedHasAudio: boolean | null;
76
+ }): PatchOperation[] {
77
+ const muted = htmlAttr("muted", input.nextMuted ? "true" : null);
78
+ if (!input.isVideo) return [muted];
79
+ const hasAudio = input.nextMuted ? null : probedHasAudioValue(input.probedHasAudio);
80
+ return [muted, dataAttr("has-audio", hasAudio)];
81
+ }
82
+
83
+ export function hasAudioToggleOps(next: boolean): PatchOperation[] {
84
+ return next
85
+ ? [dataAttr("has-audio", "true"), htmlAttr("muted", null)]
86
+ : [dataAttr("has-audio", null), htmlAttr("muted", "true")];
87
+ }
88
+
89
+ export function cutoutOps(input: { isVideo: boolean; cutoutSrc: string }): PatchOperation[] {
90
+ const src = htmlAttr("src", input.cutoutSrc);
91
+ return input.isVideo ? [src, htmlAttr("muted", "true"), dataAttr("has-audio", null)] : [src];
92
+ }
93
+
94
+ function firstFreeName(base: string, taken: Set<string>): string {
95
+ if (!taken.has(base)) return base;
96
+ let n = 2;
97
+ while (taken.has(`${base}-${n}`)) n += 1;
98
+ return `${base}-${n}`;
99
+ }
100
+
101
+ export function mintLinkId(doc: Document): string {
102
+ const taken = new Set<string>();
103
+ for (const el of Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`))) {
104
+ if (el.id) taken.add(el.id);
105
+ const link = el.getAttribute(MEDIA_LINK_ATTR);
106
+ if (link) taken.add(link);
107
+ }
108
+ return firstFreeName("link", taken);
109
+ }
110
+
111
+ function firstFreeTrackIndex(source: string): number {
112
+ const used = new Set(
113
+ Array.from(source.matchAll(/\bdata-track-index=["'](-?\d+)["']/g), (m) => Number(m[1])),
114
+ );
115
+ let track = 0;
116
+ while (used.has(track)) track += 1;
117
+ return track;
118
+ }
119
+
120
+ function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
121
+ let lanes: HfAutomationLane[];
122
+ try {
123
+ lanes = parseAutomation(raw).lanes;
124
+ } catch {
125
+ return { videoKeeps: null, audioTakes: raw };
126
+ }
127
+ const rateLanes = lanes.filter((lane) => lane.target === RATE_TARGET);
128
+ const soundLanes = lanes.filter((lane) => lane.target !== RATE_TARGET);
129
+ return {
130
+ videoKeeps: rateLanes.length > 0 ? serializeAutomation({ version: 1, lanes: rateLanes }) : null,
131
+ audioTakes: serializeAutomation({ version: 1, lanes: [...soundLanes, ...rateLanes] }),
132
+ };
133
+ }
134
+
135
+ function formatAttrs(attrs: Array<[string, string]>): string {
136
+ return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
137
+ }
138
+
139
+ function readAuthoredSrc(source: string, target: PatchTarget): string {
140
+ const tag = findTagByTarget(source, target);
141
+ if (!tag) return "";
142
+ const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
143
+ if (own?.[2]) return unescapeHtmlAttribute(own[2]);
144
+ const firstSource = /^\s*<source\b[^>]*?\bsrc=(["'])([^"']*)\1/i.exec(source.slice(tag.end + 1));
145
+ return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
146
+ }
147
+
148
+ function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
149
+ const match = findTagByTarget(source, target);
150
+ if (!match) return source;
151
+ const lineStart = source.lastIndexOf("\n", match.start) + 1;
152
+ const indent = /^[ \t]*$/.test(source.slice(lineStart, match.start))
153
+ ? source.slice(lineStart, match.start)
154
+ : "";
155
+ const separator = indent ? `\n${indent}` : "";
156
+ return `${source.slice(0, match.start)}${markup}${separator}${source.slice(match.start)}`;
157
+ }
158
+
159
+ export interface KeepSoundCutoutEdit {
160
+ ops: PatchOperation[];
161
+ prepareContent: (source: string) => string;
162
+ audioInserted: () => boolean;
163
+ }
164
+
165
+ export function buildKeepSoundCutoutEdit(input: {
166
+ video: Element;
167
+ videoId: string | null;
168
+ target: PatchTarget;
169
+ cutoutSrc: string;
170
+ }): KeepSoundCutoutEdit {
171
+ const { video, target } = input;
172
+ const linkId = mintLinkId(video.ownerDocument);
173
+ let audioInserted = false;
174
+
175
+ const movedSound: Array<[string, string]> = [];
176
+ const cutoutSrcOp = htmlAttr("src", input.cutoutSrc);
177
+ const ops: PatchOperation[] = [htmlAttr("muted", "true"), dataAttr("has-audio", null)];
178
+ for (const name of MOVED_SOUND_ATTRS) {
179
+ const value = video.getAttribute(name);
180
+ if (value === null) continue;
181
+ movedSound.push([name, value]);
182
+ ops.push(dataAttr(stripDataPrefix(name), null));
183
+ }
184
+ const automation = video.getAttribute(HF_AUDIO_AUTOMATION_ATTR);
185
+ if (automation !== null) {
186
+ const { videoKeeps, audioTakes } = splitAutomation(automation);
187
+ movedSound.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
188
+ ops.push(dataAttr(stripDataPrefix(HF_AUDIO_AUTOMATION_ATTR), videoKeeps));
189
+ }
190
+ ops.push(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
191
+
192
+ const prepareContent = (source: string): string => {
193
+ const originalSrc = readAuthoredSrc(source, target);
194
+ const audioId = firstFreeName(
195
+ `${input.videoId || "video"}-audio`,
196
+ new Set(collectHtmlIds(source)),
197
+ );
198
+ const timing: Array<[string, string]> = [];
199
+ for (const name of COPIED_TIMING_ATTRS) {
200
+ const value = readAttributeByTarget(source, target, name);
201
+ if (value !== undefined) timing.push([`data-${name}`, value]);
202
+ }
203
+ const attrs: Array<[string, string]> = [
204
+ ["id", audioId],
205
+ ["data-hf-id", `hf-${generateId()}`],
206
+ ["class", "clip"],
207
+ ["src", originalSrc],
208
+ ...timing,
209
+ ["data-track-index", String(firstFreeTrackIndex(source))],
210
+ [MEDIA_LINK_ATTR, linkId],
211
+ ...movedSound,
212
+ ];
213
+ if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
214
+ const cut = applyPatchByTarget(source, target, cutoutSrcOp);
215
+ const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
216
+ audioInserted = inserted !== cut;
217
+ return inserted;
218
+ };
219
+
220
+ return { ops, prepareContent, audioInserted: () => audioInserted };
221
+ }
222
+
223
+ export interface MediaEditContext {
224
+ element: DomEditSelection;
225
+ projectId: string | null;
226
+ projectSrc: string;
227
+ commit: CommitDomAttributeBatch;
228
+ }
229
+
230
+ const isVideoSelection = (element: DomEditSelection) => element.tagName === "video";
231
+
232
+ export async function commitMutedToggle(ctx: MediaEditContext, nextMuted: boolean): Promise<void> {
233
+ const isVideo = isVideoSelection(ctx.element);
234
+ const probedHasAudio =
235
+ isVideo && !nextMuted && ctx.projectId && ctx.projectSrc
236
+ ? await resolveAssetHasAudio(ctx.projectId, ctx.projectSrc)
237
+ : null;
238
+ await ctx.commit(ctx.element, mutedToggleOps({ isVideo, nextMuted, probedHasAudio }), {
239
+ label: "Edit muted",
240
+ });
241
+ }
242
+
243
+ export async function commitHasAudioToggle(ctx: MediaEditContext, next: boolean): Promise<void> {
244
+ await ctx.commit(ctx.element, hasAudioToggleOps(next), { label: "Edit has audio" });
245
+ }
246
+
247
+ export async function commitCutout(
248
+ ctx: MediaEditContext,
249
+ cutoutSrc: string,
250
+ keepSound: boolean,
251
+ ): Promise<string> {
252
+ const isVideo = isVideoSelection(ctx.element);
253
+ const label = "Remove background";
254
+ const keep = isVideo && keepSound;
255
+ if (!keep) {
256
+ const applied = await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
257
+ if (!applied) throw new Error("Couldn't apply the cutout");
258
+ return "Applied cutout";
259
+ }
260
+ const { landed, soundKept } = await commitKeepSoundCutout(ctx, cutoutSrc, label);
261
+ if (!landed) throw new Error("Couldn't apply the cutout");
262
+ if (!soundKept) throw new Error(SOUND_NOT_KEPT_MESSAGE);
263
+ return SOUND_KEPT_ON_LINKED_AUDIO_STAGE;
264
+ }
265
+
266
+ async function commitKeepSoundCutout(
267
+ ctx: MediaEditContext,
268
+ cutoutSrc: string,
269
+ label: string,
270
+ ): Promise<{ landed: boolean; soundKept: boolean }> {
271
+ const edit = buildKeepSoundCutoutEdit({
272
+ video: ctx.element.element,
273
+ videoId: ctx.element.id ?? null,
274
+ target: buildDomEditPatchTarget(ctx.element),
275
+ cutoutSrc,
276
+ });
277
+ const landed = await ctx.commit(ctx.element, edit.ops, {
278
+ label,
279
+ prepareContent: edit.prepareContent,
280
+ });
281
+ return { landed, soundKept: landed && edit.audioInserted() };
282
+ }
@@ -103,11 +103,11 @@ function mount(dataAttributes: Record<string, string>, alone = false, voices = 2
103
103
  return { host, onSetAttributeQuiet, onSetAttributeLive };
104
104
  }
105
105
 
106
- function mountGroup(memberStart: number) {
106
+ function mountGroup(memberStart: number, memberTag: "audio" | "video" = "audio") {
107
107
  const bus = document.createElement("hf-audio-group");
108
108
  bus.id = "voiceover";
109
109
  document.body.append(bus);
110
- const member = document.createElement("audio");
110
+ const member = document.createElement(memberTag);
111
111
  member.id = "vo-1";
112
112
  member.setAttribute("data-audio-group", "voiceover");
113
113
  member.setAttribute("data-start", String(memberStart));
@@ -649,6 +649,16 @@ describe("AudioFxGroup dynamic carve", () => {
649
649
  leaveShelf(host);
650
650
  });
651
651
 
652
+ it("seeks a group audition to an audible video member's span", () => {
653
+ act(() =>
654
+ usePlayerStore.setState({ isPlaying: false, currentTime: 2, requestedSeekTime: null }),
655
+ );
656
+ const host = mountGroup(7, "video");
657
+ hoverPreset(host);
658
+ expect(store().requestedSeekTime).toBe(7);
659
+ leaveShelf(host);
660
+ });
661
+
652
662
  it("leaves a transport the author started alone", () => {
653
663
  // Stopping their playback because they passed over a preset would be the
654
664
  // panel taking a decision nobody offered it.
@@ -1621,7 +1631,14 @@ describe("AudioFxGroup carve by default", () => {
1621
1631
  describe("AudioFxGroup carve source list", () => {
1622
1632
  /** Mount a bed alongside tracks named however the test needs. */
1623
1633
  const mountWith = (
1624
- tracks: { id: string; src?: string; start?: string; duration?: string }[],
1634
+ tracks: {
1635
+ id: string;
1636
+ src?: string;
1637
+ start?: string;
1638
+ duration?: string;
1639
+ tag?: "audio" | "video";
1640
+ muted?: boolean;
1641
+ }[],
1625
1642
  bedAttrs: Record<string, string> = {},
1626
1643
  ) => {
1627
1644
  const bed = document.createElement("audio");
@@ -1629,8 +1646,9 @@ describe("AudioFxGroup carve source list", () => {
1629
1646
  for (const [k, v] of Object.entries(bedAttrs)) bed.setAttribute(`data-${k}`, v);
1630
1647
  document.body.append(bed);
1631
1648
  for (const t of tracks) {
1632
- const el = document.createElement("audio");
1649
+ const el = document.createElement(t.tag ?? "audio");
1633
1650
  el.id = t.id;
1651
+ if (t.muted) el.setAttribute("muted", "");
1634
1652
  if (t.src) el.setAttribute("src", t.src);
1635
1653
  if (t.start !== undefined) el.setAttribute("data-start", t.start);
1636
1654
  if (t.duration !== undefined) el.setAttribute("data-duration", t.duration);
@@ -1660,6 +1678,16 @@ describe("AudioFxGroup carve source list", () => {
1660
1678
  return { host, options, boxes, onSetAttributeQuiet };
1661
1679
  };
1662
1680
 
1681
+ it("offers an audible video as a voice and never a muted one", () => {
1682
+ const { options } = mountWith([
1683
+ { id: "a-roll", tag: "video" },
1684
+ { id: "b-roll", tag: "video", muted: true },
1685
+ { id: "narration" },
1686
+ ]);
1687
+ expect(options).toContain("a-roll");
1688
+ expect(options).not.toContain("b-roll");
1689
+ });
1690
+
1663
1691
  it("reads the one voice out instead of offering a picker with one entry", () => {
1664
1692
  // A question with one answer is not a question. It is also the common case: a
1665
1693
  // narration and a bed, with a couple of stings that cannot be the voice.
@@ -54,6 +54,7 @@ import {
54
54
  resolveAudioGroups,
55
55
  } from "@hyperframes/core/audio-groups";
56
56
  import { useFxLevelling } from "./useFxLevelling.js";
57
+ import { isCarveVoiceElement } from "./useFxCarveGrouping.js";
57
58
 
58
59
  function auditionSpan(startRaw: string | undefined, durationRaw: string | undefined) {
59
60
  const start = Number.parseFloat(startRaw ?? "");
@@ -70,8 +71,9 @@ function auditionSpansFor(element: DomEditSelection): AuditionSpan[] {
70
71
  if (element.tagName?.toLowerCase() !== HF_AUDIO_GROUP_TAG || !element.id) return [];
71
72
  const doc = element.element?.ownerDocument;
72
73
  if (!doc) return [];
73
- return [...doc.querySelectorAll(`audio[${HF_AUDIO_GROUP_ATTR}]`)]
74
+ return [...doc.querySelectorAll(`[${HF_AUDIO_GROUP_ATTR}]`)]
74
75
  .filter((member) => member.getAttribute(HF_AUDIO_GROUP_ATTR) === element.id)
76
+ .filter(isCarveVoiceElement)
75
77
  .flatMap((member) => {
76
78
  const span = auditionSpan(
77
79
  member.getAttribute("data-start") ?? undefined,