@hyperframes/studio 0.8.107 → 0.8.109

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 (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.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/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.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 +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -0,0 +1,282 @@
1
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
2
+ import { MEDIA_LINK_ATTR, SYNC_ORIGIN_ATTR, mintLinkId } from "@hyperframes/core/media-link";
3
+ import { sameCompositionScope, type TimelineElement } from "../../player/store/timelineElement";
4
+ import { mediaAssetIdentity, sharesLinkGroup } from "../../player/components/audioClipLink";
5
+ import {
6
+ applyPatchByTarget,
7
+ findTagByTarget,
8
+ readAttributeByTarget,
9
+ type PatchOperation,
10
+ type PatchTarget,
11
+ } from "../../utils/sourcePatcher";
12
+ import { collectHtmlIds } from "../../utils/studioHelpers";
13
+ import { generateId } from "../../utils/generateId";
14
+ import {
15
+ COPIED_TIMING_ATTRS,
16
+ MOVED_SOUND_ATTRS,
17
+ firstFreeName,
18
+ formatAttrs,
19
+ insertBeforeTarget,
20
+ readAuthoredSrc,
21
+ splitAutomation,
22
+ } from "./mediaAudioEdits";
23
+
24
+ const LINK_PROPERTY = MEDIA_LINK_ATTR.slice("data-".length);
25
+ const HIDDEN_ATTR = "data-hidden";
26
+ const SYNC_ORIGIN_PROPERTY = SYNC_ORIGIN_ATTR.slice("data-".length);
27
+ const SYNC_TOLERANCE_S = 1e-3;
28
+
29
+ const dataOp = (property: string, value: string | null): PatchOperation => ({
30
+ type: "attribute",
31
+ property: property.startsWith("data-") ? property.slice("data-".length) : property,
32
+ value,
33
+ });
34
+
35
+ const htmlOp = (property: string, value: string | null): PatchOperation => ({
36
+ type: "html-attribute",
37
+ property,
38
+ value,
39
+ });
40
+
41
+ function applyOps(source: string, target: PatchTarget, ops: readonly PatchOperation[]): string {
42
+ return ops.reduce((html, op) => applyPatchByTarget(html, target, op), source);
43
+ }
44
+
45
+ function mintLinkIdForSource(source: string): string {
46
+ const links = Array.from(
47
+ source.matchAll(/\bdata-(?:link|sync-origin)=["']([^"']+)["']/g),
48
+ (match) => match[1] ?? "",
49
+ );
50
+ return mintLinkId([...collectHtmlIds(source), ...links]);
51
+ }
52
+
53
+ /** Write (or with `null`, remove) `data-link` on every target. */
54
+ export function setLinkInSource(
55
+ source: string,
56
+ targets: readonly PatchTarget[],
57
+ linkId: string | null,
58
+ ): string {
59
+ return targets.reduce(
60
+ (html, target) => applyPatchByTarget(html, target, dataOp(LINK_PROPERTY, linkId)),
61
+ source,
62
+ );
63
+ }
64
+
65
+ /** Link the targets under one freshly minted id, which is also their sync origin. */
66
+ export function linkInSource(source: string, targets: readonly PatchTarget[]): string {
67
+ const linkId = mintLinkIdForSource(source);
68
+ return targets.reduce(
69
+ (html, target) =>
70
+ applyOps(html, target, [dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId)]),
71
+ source,
72
+ );
73
+ }
74
+
75
+ /** Remove one element (open tag through its matching close tag) and its line's indentation. */
76
+ export function removeElementInSource(source: string, target: PatchTarget): string {
77
+ const match = findTagByTarget(source, target);
78
+ if (!match) return source;
79
+ const tagName = /^<([a-zA-Z][\w-]*)/.exec(match.tag)?.[1]?.toLowerCase() ?? "";
80
+ const closing = `</${tagName}>`;
81
+ const closeAt = match.tag.endsWith("/>")
82
+ ? -1
83
+ : source.toLowerCase().indexOf(closing, match.end + 1);
84
+ let end = closeAt >= 0 ? closeAt + closing.length : match.end + 1;
85
+ let start = match.start;
86
+ const lineStart = source.lastIndexOf("\n", start) + 1;
87
+ if (/^[ \t]*$/.test(source.slice(lineStart, start)) && source[end] === "\n") {
88
+ start = lineStart;
89
+ end += 1;
90
+ }
91
+ return source.slice(0, start) + source.slice(end);
92
+ }
93
+
94
+ function hasBooleanAttr(source: string, target: PatchTarget, name: string): boolean {
95
+ const tag = findTagByTarget(source, target)?.tag ?? "";
96
+ return new RegExp(`\\s${name}(?=[\\s=/>]|$)`, "i").test(tag);
97
+ }
98
+
99
+ export interface DetachAudioResult {
100
+ html: string;
101
+ audioId: string;
102
+ linkId: string;
103
+ }
104
+
105
+ /**
106
+ * Detach audio: insert a linked `<audio>` over the same file and window, move the
107
+ * sound attributes onto it, and mute the video. Renders identically.
108
+ */
109
+ export function detachAudioInSource(
110
+ source: string,
111
+ input: { target: PatchTarget; videoId: string | null; track: number },
112
+ ): DetachAudioResult | null {
113
+ const { target } = input;
114
+ if (!findTagByTarget(source, target)) return null;
115
+ const linkId = mintLinkIdForSource(source);
116
+ const audioId = firstFreeName(
117
+ `${input.videoId || "video"}-audio`,
118
+ new Set(collectHtmlIds(source)),
119
+ );
120
+
121
+ const moved: Array<[string, string]> = [];
122
+ const videoOps: PatchOperation[] = [htmlOp("muted", "true"), dataOp("has-audio", null)];
123
+ for (const name of MOVED_SOUND_ATTRS) {
124
+ const value = readAttributeByTarget(source, target, name);
125
+ if (value === undefined) continue;
126
+ moved.push([name, value]);
127
+ videoOps.push(dataOp(name, null));
128
+ }
129
+ const automation = readAttributeByTarget(source, target, HF_AUDIO_AUTOMATION_ATTR);
130
+ if (automation !== undefined) {
131
+ const { videoKeeps, audioTakes } = splitAutomation(automation);
132
+ moved.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
133
+ videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, videoKeeps));
134
+ }
135
+ videoOps.push(dataOp(LINK_PROPERTY, linkId), dataOp(SYNC_ORIGIN_PROPERTY, linkId));
136
+
137
+ const timing: Array<[string, string]> = [];
138
+ for (const name of COPIED_TIMING_ATTRS) {
139
+ const value = readAttributeByTarget(source, target, name);
140
+ if (value !== undefined) timing.push([`data-${name}`, value]);
141
+ }
142
+ const attrs: Array<[string, string]> = [
143
+ ["id", audioId],
144
+ ["data-hf-id", `hf-${generateId()}`],
145
+ ["class", "clip"],
146
+ ["src", readAuthoredSrc(source, target)],
147
+ ...timing,
148
+ ["data-track-index", String(input.track)],
149
+ [MEDIA_LINK_ATTR, linkId],
150
+ [SYNC_ORIGIN_ATTR, linkId],
151
+ ...moved,
152
+ ];
153
+ if (hasBooleanAttr(source, target, "loop")) attrs.push(["loop", ""]);
154
+ if (hasBooleanAttr(source, target, HIDDEN_ATTR)) attrs.push([HIDDEN_ATTR, ""]);
155
+ const inserted = insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
156
+ return { html: applyOps(inserted, target, videoOps), audioId, linkId };
157
+ }
158
+
159
+ /**
160
+ * Merge back: move the audio's sound attributes onto its video, unmute the
161
+ * video, drop the link, and delete the audio. The inverse of detach.
162
+ */
163
+ export function mergeAudioInSource(
164
+ source: string,
165
+ input: { videoTarget: PatchTarget; audioTarget: PatchTarget },
166
+ ): string | null {
167
+ const { videoTarget, audioTarget } = input;
168
+ if (!findTagByTarget(source, videoTarget) || !findTagByTarget(source, audioTarget)) return null;
169
+ const assetOf = (target: PatchTarget) =>
170
+ mediaAssetIdentity({ src: readAuthoredSrc(source, target) });
171
+ const videoAsset = assetOf(videoTarget);
172
+ if (!videoAsset || videoAsset !== assetOf(audioTarget)) return null;
173
+ const hiddenOf = (target: PatchTarget) => hasBooleanAttr(source, target, HIDDEN_ATTR);
174
+ if (hiddenOf(videoTarget) !== hiddenOf(audioTarget)) return null;
175
+ const videoOps: PatchOperation[] = [];
176
+ for (const name of MOVED_SOUND_ATTRS) {
177
+ const value = readAttributeByTarget(source, audioTarget, name);
178
+ videoOps.push(dataOp(name, value ?? null));
179
+ }
180
+ const automation = readAttributeByTarget(source, audioTarget, HF_AUDIO_AUTOMATION_ATTR);
181
+ if (automation !== undefined) videoOps.push(dataOp(HF_AUDIO_AUTOMATION_ATTR, automation));
182
+ videoOps.push(
183
+ htmlOp("muted", null),
184
+ dataOp(LINK_PROPERTY, null),
185
+ dataOp(SYNC_ORIGIN_PROPERTY, null),
186
+ dataOp("has-audio", "true"),
187
+ );
188
+ return applyOps(removeElementInSource(source, audioTarget), videoTarget, videoOps);
189
+ }
190
+
191
+ type TimedElement = Pick<
192
+ TimelineElement,
193
+ "id" | "key" | "tag" | "src" | "start" | "duration" | "playbackStart" | "playbackRate" | "link"
194
+ > &
195
+ Pick<TimelineElement, "muted" | "hasAudio" | "hidden" | "sourceFile" | "compositionScope">;
196
+
197
+ const tagOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
198
+ const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
199
+ const near = (a: number, b: number) => Math.abs(a - b) <= SYNC_TOLERANCE_S;
200
+
201
+ function hasIdenticalTiming(a: TimedElement, b: TimedElement): boolean {
202
+ return (
203
+ near(a.start, b.start) &&
204
+ near(a.duration, b.duration) &&
205
+ near(a.playbackStart ?? 0, b.playbackStart ?? 0) &&
206
+ near(a.playbackRate ?? 1, b.playbackRate ?? 1)
207
+ );
208
+ }
209
+
210
+ const sameAssetInScope = (a: TimedElement, b: TimedElement) => {
211
+ const asset = mediaAssetIdentity(a);
212
+ return asset !== null && asset === mediaAssetIdentity(b) && sameCompositionScope(a, b);
213
+ };
214
+
215
+ /** A video whose sound is on the video itself: what Detach audio acts on. */
216
+ export function canDetachAudio(el: TimedElement): boolean {
217
+ return tagOf(el) === "video" && el.muted !== true && el.hasAudio === true;
218
+ }
219
+
220
+ /**
221
+ * The (muted video, audio) pair Merge back acts on, found from either member:
222
+ * same file, and linked to each other or with identical timing.
223
+ */
224
+ export function findMergePair<T extends TimedElement>(
225
+ element: T,
226
+ elements: readonly T[],
227
+ ): { video: T; audio: T } | null {
228
+ const isVideo = tagOf(element) === "video";
229
+ if (!isVideo && tagOf(element) !== "audio") return null;
230
+ const partnerTag = isVideo ? "audio" : "video";
231
+ const candidates = elements.filter(
232
+ (el) =>
233
+ keyOf(el) !== keyOf(element) && tagOf(el) === partnerTag && sameAssetInScope(el, element),
234
+ );
235
+ const partner =
236
+ candidates.find((el) => sharesLinkGroup(el, element)) ??
237
+ candidates.find((el) => hasIdenticalTiming(el, element));
238
+ if (!partner) return null;
239
+ const video = isVideo ? element : partner;
240
+ const audio = isVideo ? partner : element;
241
+ if ((video.hidden === true) !== (audio.hidden === true)) return null;
242
+ return video.muted === true ? { video, audio } : null;
243
+ }
244
+
245
+ /** Exactly one video and one audio, same file, identical timing, not already linked together. */
246
+ export function canLinkPair(selected: readonly TimedElement[]): boolean {
247
+ if (selected.length !== 2) return false;
248
+ const [a, b] = selected;
249
+ if (!a || !b) return false;
250
+ const tags = new Set([tagOf(a), tagOf(b)]);
251
+ if (!tags.has("video") || !tags.has("audio")) return false;
252
+ if (sharesLinkGroup(a, b)) return false;
253
+ return sameAssetInScope(a, b) && hasIdenticalTiming(a, b);
254
+ }
255
+
256
+ type TrackedElement = Pick<TimelineElement, "tag" | "track" | "start" | "duration"> & {
257
+ authoredTrack?: number;
258
+ };
259
+
260
+ /**
261
+ * The detached audio's track: the first audio-only track below every visual
262
+ * track that is free for the clip's window, else a new track at the bottom.
263
+ */
264
+ export function pickDetachedAudioTrack(
265
+ elements: readonly TrackedElement[],
266
+ window: { start: number; duration: number },
267
+ ): number {
268
+ const trackOf = (el: TrackedElement) => el.authoredTrack ?? el.track;
269
+ const visualTracks = elements.filter((el) => tagOf(el) !== "audio").map(trackOf);
270
+ const lowestAudio = Math.max(-1, ...visualTracks) + 1;
271
+ const end = window.start + window.duration;
272
+ const allTracks = elements.map(trackOf);
273
+ const bottom = Math.max(lowestAudio - 1, ...allTracks) + 1;
274
+ for (let track = lowestAudio; track < bottom; track += 1) {
275
+ const occupants = elements.filter((el) => trackOf(el) === track);
276
+ const free = occupants.every(
277
+ (el) => tagOf(el) === "audio" && (el.start >= end || el.start + el.duration <= window.start),
278
+ );
279
+ if (free) return track;
280
+ }
281
+ return bottom;
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,
@@ -652,7 +652,12 @@ function makeHandlers() {
652
652
  ops: PatchOperation[],
653
653
  options: BatchOptions,
654
654
  ) => Promise<boolean>
655
- >(async () => true),
655
+ >(async (_selection, _ops, options) => {
656
+ options.prepareContent?.(
657
+ '<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" data-start="2"></video></div>',
658
+ );
659
+ return true;
660
+ }),
656
661
  };
657
662
  }
658
663
 
@@ -871,14 +876,14 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
871
876
  const [selection, ops, options] = onSetAttributeBatch.mock.calls[0] ?? [];
872
877
  expect(selection).toBe(element);
873
878
  expect(ops).toEqual([
874
- htmlOp("src", "assets/cut.webm"),
875
879
  htmlOp("muted", "true"),
876
880
  dataOp("has-audio", null),
877
881
  dataOp("volume", null),
878
- dataOp("link", "link"),
882
+ dataOp("link", "lk-1"),
883
+ dataOp("sync-origin", "lk-1"),
879
884
  ]);
880
885
  const source =
881
- '<div data-composition-id="main"><video id="s1-bg" src="assets/cut.webm" muted data-start="2" data-duration="3" data-track-index="0" data-link="link"></video></div>';
886
+ '<div data-composition-id="main"><video id="s1-bg" src="assets/intro-loop.mp4" muted data-start="2" data-duration="3" data-track-index="0" data-link="lk-1"></video></div>';
882
887
  const prepared = options?.prepareContent?.(source) ?? "";
883
888
  const audio = new DOMParser()
884
889
  .parseFromString(prepared, "text/html")
@@ -886,7 +891,7 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
886
891
  expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
887
892
  expect(audio?.getAttribute("data-start")).toBe("2");
888
893
  expect(audio?.getAttribute("data-duration")).toBe("3");
889
- expect(audio?.getAttribute("data-link")).toBe("link");
894
+ expect(audio?.getAttribute("data-link")).toBe("lk-1");
890
895
  expect(audio?.getAttribute("data-volume")).toBe("0.5");
891
896
  expect(text).toContain("Background removed. Sound kept on a linked audio track.");
892
897
  });
@@ -24,6 +24,7 @@ import {
24
24
  audioGainToText,
25
25
  } from "@hyperframes/core/audio-gain";
26
26
  import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
27
+ import { useLinkedSpeedCommit, withLinkedPlaybackRate } from "./linkedSpeedEdits";
27
28
  import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
28
29
 
29
30
  // fallow-ignore-next-line complexity
@@ -56,6 +57,10 @@ export function MediaSection({
56
57
  ) => Promise<BackgroundRemovalResult>;
57
58
  }) {
58
59
  const track = useTrackDesignInput();
60
+ const setRate = withLinkedPlaybackRate(
61
+ onSetAttribute,
62
+ useLinkedSpeedCommit(element, onSetAttributeBatch),
63
+ );
59
64
  const isVideo = element.tagName === "video";
60
65
  const isAudio = element.tagName === "audio";
61
66
  const isImage = element.tagName === "img";
@@ -279,7 +284,7 @@ export function MediaSection({
279
284
  displayValue={`${formatNumericValue(playbackRate)}x`}
280
285
  formatDisplayValue={(next) => `${formatNumericValue(next / 100)}x`}
281
286
  onCommit={(next) => {
282
- void onSetAttribute("playback-rate", formatNumericValue(next / 100));
287
+ void setRate("playback-rate", formatNumericValue(next / 100));
283
288
  }}
284
289
  />
285
290
  </div>
@@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget";
11
11
  import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
12
  import {
13
13
  addStudioPendingEditFlushListener,
14
- trackStudioPendingEdit,
14
+ beginStudioPendingEdit,
15
15
  } from "../../utils/studioPendingEdits";
16
16
  import type { DomEditSelection } from "./domEditing";
17
17
  import {
@@ -31,6 +31,7 @@ import {
31
31
  applyManualOffsetNudgeDraft,
32
32
  createManualOffsetDragMember,
33
33
  endManualOffsetDragMembers,
34
+ manualOffsetMoveRevert,
34
35
  restoreManualOffsetDragMembers,
35
36
  type ManualOffsetDragMember,
36
37
  } from "./manualOffsetDrag";
@@ -47,7 +48,7 @@ interface NudgeSession {
47
48
  /** Accumulated delta of the burst, in composition px. */
48
49
  accum: { x: number; y: number };
49
50
  timer: ReturnType<typeof setTimeout> | null;
50
- endPendingEdit: (saved?: Promise<unknown>) => void;
51
+ edit: ReturnType<typeof beginStudioPendingEdit>;
51
52
  }
52
53
 
53
54
  export interface UseDomEditNudgeParams {
@@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
161
162
  next: applyManualOffsetNudgeCommit(member, session.accum),
162
163
  plainTranslate: member.plainTranslate,
163
164
  }));
164
- const commit = session.isGroup
165
- ? p.onGroupPathOffsetCommitRef.current(updates)
166
- : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
167
- plainTranslate: updates[0].plainTranslate,
168
- });
165
+ const commit = session.edit.adopt(() =>
166
+ session.isGroup
167
+ ? p.onGroupPathOffsetCommitRef.current(updates)
168
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
169
+ plainTranslate: updates[0].plainTranslate,
170
+ }),
171
+ );
172
+ if (session.edit.reverted())
173
+ for (const member of session.members)
174
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
169
175
  saved = Promise.resolve(commit)
170
176
  .catch(() => {
171
177
  for (const member of session.members) {
@@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
177
183
  .finally(() => endManualOffsetDragMembers(session.members));
178
184
  return saved;
179
185
  } finally {
180
- session.endPendingEdit(saved);
186
+ session.edit.settle(saved);
181
187
  }
182
188
  };
183
189
  const commitSessionRef = useRef(commitSession);
@@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
190
196
  if (session.timer) clearTimeout(session.timer);
191
197
  restoreManualOffsetDragMembers(session.members);
192
198
  endManualOffsetDragMembers(session.members);
193
- session.endPendingEdit();
199
+ session.edit.settle();
194
200
  }, [params.allowCanvasMovement]);
195
201
 
196
202
  // Build drag members for the current target set — the same member snapshot a
@@ -217,9 +223,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
217
223
  if (members.length === 0) return null;
218
224
  // Same side effect a drag start has (pauses preview playback).
219
225
  p.onManualDragStartRef.current?.();
220
- let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
221
- trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
222
- return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
226
+ const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
227
+ return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
223
228
  };
224
229
 
225
230
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -11,6 +11,7 @@ import {
11
11
  applyManualOffsetDragDraft,
12
12
  endManualOffsetDragMembers,
13
13
  restoreManualOffsetDragMembers,
14
+ manualOffsetMoveRevert,
14
15
  } from "./manualOffsetDrag";
15
16
  import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
16
17
  import {
@@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb
57
58
  import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
58
59
  import { createGroupDragMover } from "./groupDragMove";
59
60
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
61
+ import { beginStudioPendingEdit } from "../../utils/studioPendingEdits";
60
62
 
61
63
  function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
62
64
  return (
@@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
344
346
  ),
345
347
  at: readDragPositions(groupG.members),
346
348
  });
347
- void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
349
+ const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members));
350
+ const groupSaved = Promise.resolve(
351
+ groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)),
352
+ )
348
353
  .catch(() => {
349
354
  for (const member of groupG.members) {
350
355
  if (
@@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
363
368
  // so this is where a snap-back would show.
364
369
  logDragSettle("settle", groupG.members);
365
370
  });
371
+ groupEdit.settle(groupSaved);
366
372
  return;
367
373
  }
368
374
 
@@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
469
475
  box.style.left = `${nextBoxLeft}px`;
470
476
  box.style.top = `${nextBoxTop}px`;
471
477
  }
472
- void Promise.resolve(
473
- opts.onPathOffsetCommitRef.current(sel, finalOffset, {
474
- altKey: e.altKey,
475
- plainTranslate: g.pathOffsetMember.plainTranslate,
476
- }),
478
+ const member = g.pathOffsetMember;
479
+ const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member]));
480
+ const saved = Promise.resolve(
481
+ edit.adopt(() =>
482
+ opts.onPathOffsetCommitRef.current(sel, finalOffset, {
483
+ altKey: e.altKey,
484
+ plainTranslate: member.plainTranslate,
485
+ }),
486
+ ),
477
487
  )
478
488
  .catch(() => {
479
489
  if (
@@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
485
495
  .finally(() => {
486
496
  if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]);
487
497
  });
498
+ edit.settle(saved);
488
499
  } else {
489
500
  opts.suppressNextBoxClickRef.current = true;
490
501
  const finalSize = readStudioBoxSize(sel.element);