@hyperframes/studio 0.8.108 → 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 (118) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
  5. package/dist/assets/{index-C5dyUHwk.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-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +44 -4
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +7021 -5669
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  24. package/src/components/editor/InlineTextCaret.tsx +211 -0
  25. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  26. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  27. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  28. package/src/components/editor/domEditInlineText.ts +6 -4
  29. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  30. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  31. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  32. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  33. package/src/components/editor/mediaAudioEdits.ts +27 -14
  34. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  35. package/src/components/editor/mediaLinkEdits.ts +282 -0
  36. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  37. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  38. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  39. package/src/components/editor/useVolumeAutomation.ts +29 -7
  40. package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
  41. package/src/components/ui/Menu.test.tsx +3 -22
  42. package/src/components/ui/Menu.tsx +2 -2
  43. package/src/components/ui/Popover.tsx +2 -2
  44. package/src/components/ui/Select.tsx +2 -2
  45. package/src/components/ui/Tooltip.tsx +2 -2
  46. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  47. package/src/components/ui/valueControls.test.tsx +2 -21
  48. package/src/contexts/TimelineEditContext.tsx +3 -0
  49. package/src/hooks/appHotkeysDispatch.ts +2 -1
  50. package/src/hooks/linkShortcuts.test.ts +70 -0
  51. package/src/hooks/linkShortcuts.ts +41 -0
  52. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  53. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  54. package/src/hooks/timelineMoveAdapter.ts +13 -0
  55. package/src/hooks/timelineTimingSync.test.ts +59 -0
  56. package/src/hooks/timelineTimingSync.ts +22 -3
  57. package/src/hooks/useAppHotkeys.ts +9 -0
  58. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  59. package/src/hooks/useGsapScriptCommits.ts +23 -3
  60. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  61. package/src/hooks/usePreviewPersistence.ts +15 -0
  62. package/src/hooks/useRazorSplit.test.tsx +48 -0
  63. package/src/hooks/useRazorSplit.ts +12 -1
  64. package/src/hooks/useRenderClipContent.test.ts +6 -4
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  67. package/src/hooks/useTimelineEditGuard.ts +54 -0
  68. package/src/hooks/useTimelineEditing.ts +36 -55
  69. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  70. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  71. package/src/index.ts +1 -0
  72. package/src/player/components/AudioWaveform.test.tsx +30 -3
  73. package/src/player/components/AudioWaveform.tsx +6 -7
  74. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  75. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  76. package/src/player/components/ClipContextMenu.tsx +115 -90
  77. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  78. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  79. package/src/player/components/TimelineClip.test.tsx +17 -0
  80. package/src/player/components/TimelineClip.tsx +6 -0
  81. package/src/player/components/audioClipLink.test.ts +228 -31
  82. package/src/player/components/audioClipLink.ts +72 -25
  83. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  84. package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
  85. package/src/player/components/clipMenuAudioItems.tsx +31 -23
  86. package/src/player/components/clipMenuDuck.ts +5 -0
  87. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  88. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  89. package/src/player/components/clipMenuToolItems.tsx +1 -3
  90. package/src/player/components/clipSync.test.ts +67 -0
  91. package/src/player/components/clipSync.ts +80 -0
  92. package/src/player/components/linkLabelColor.test.ts +20 -0
  93. package/src/player/components/linkLabelColor.ts +24 -0
  94. package/src/player/components/timelineCallbacks.ts +11 -0
  95. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  96. package/src/player/components/timelineClipDragTypes.ts +2 -0
  97. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  98. package/src/player/components/timelineLinkSelection.ts +23 -0
  99. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  100. package/src/player/components/useTimelineClipDrag.ts +21 -0
  101. package/src/player/components/useTimelineProviderState.tsx +3 -0
  102. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  103. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  104. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  105. package/src/player/lib/timelineDOM.test.ts +31 -0
  106. package/src/player/lib/timelineDOM.ts +13 -0
  107. package/src/player/store/timelineElement.ts +12 -0
  108. package/src/styles/components.css +27 -11
  109. package/src/styles/theme.css +0 -1
  110. package/src/utils/gsapSoftReload.test.ts +238 -2
  111. package/src/utils/gsapSoftReload.ts +89 -74
  112. package/src/utils/gsapUndoRestore.test.ts +4 -3
  113. package/src/utils/gsapUndoRestore.ts +15 -0
  114. package/src/utils/linkedClipPreferences.ts +34 -0
  115. package/src/utils/softReloadTargets.ts +128 -0
  116. package/src/utils/studioUiPreferences.ts +8 -0
  117. package/dist/chunk-AOWVEVEU.js.map +0 -1
  118. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -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
+ }
@@ -879,10 +879,11 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
879
879
  htmlOp("muted", "true"),
880
880
  dataOp("has-audio", null),
881
881
  dataOp("volume", null),
882
- dataOp("link", "link"),
882
+ dataOp("link", "lk-1"),
883
+ dataOp("sync-origin", "lk-1"),
883
884
  ]);
884
885
  const source =
885
- '<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="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>';
886
887
  const prepared = options?.prepareContent?.(source) ?? "";
887
888
  const audio = new DOMParser()
888
889
  .parseFromString(prepared, "text/html")
@@ -890,7 +891,7 @@ describe.each<PanelKind>(["flat", "design"])("%s panel — cutout keeps the soun
890
891
  expect(audio?.getAttribute("src")).toBe("assets/intro-loop.mp4");
891
892
  expect(audio?.getAttribute("data-start")).toBe("2");
892
893
  expect(audio?.getAttribute("data-duration")).toBe("3");
893
- expect(audio?.getAttribute("data-link")).toBe("link");
894
+ expect(audio?.getAttribute("data-link")).toBe("lk-1");
894
895
  expect(audio?.getAttribute("data-volume")).toBe("0.5");
895
896
  expect(text).toContain("Background removed. Sound kept on a linked audio track.");
896
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>
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, type ReactNode, type RefObject } from "react";
2
+ import { InlineTextCaret } from "./InlineTextCaret";
2
3
  import { InlineTextToolbar } from "./InlineTextToolbar";
3
4
  import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
5
  import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
@@ -110,10 +111,16 @@ export function useInlineTextEditing(
110
111
  return {
111
112
  editing: inlineText.session !== null,
112
113
  toolbar: (
113
- <InlineTextToolbar
114
- session={inlineText.session}
115
- iframe={actions?.previewIframeRef?.current ?? null}
116
- />
114
+ <>
115
+ <InlineTextToolbar
116
+ session={inlineText.session}
117
+ iframe={actions?.previewIframeRef?.current ?? null}
118
+ />
119
+ <InlineTextCaret
120
+ session={inlineText.session}
121
+ iframe={actions?.previewIframeRef?.current ?? null}
122
+ />
123
+ </>
117
124
  ),
118
125
  handleKeyDown,
119
126
  startFromPress: (event) => {
@@ -26,6 +26,8 @@ import {
26
26
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
27
27
  import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp";
28
28
  import { clampNumber } from "../../utils/studioHelpers";
29
+ import type { HfAutomation } from "@hyperframes/core/audio-automation";
30
+ import type { LinkedSpeedCommit } from "./linkedSpeedEdits";
29
31
 
30
32
  export interface LaneBinding {
31
33
  automated: boolean;
@@ -59,6 +61,7 @@ export function useVolumeAutomation(
59
61
  element: DomEditSelection,
60
62
  currentTime: number,
61
63
  onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
64
+ linkedSpeed: LinkedSpeedCommit | null = null,
62
65
  ): VolumeAutomationBinding {
63
66
  // The chain is not needed to resolve a volume or rate lane: both are always valid
64
67
  // targets, so this deliberately does not parse it.
@@ -77,7 +80,22 @@ export function useVolumeAutomation(
77
80
  // playing track, while the same click on an effect parameter did not.
78
81
  void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
79
82
  };
80
- const laneBinding = (target: string, seed: number): LaneBinding => {
83
+ type Edit = (current: HfAutomation) => HfAutomation;
84
+ const writeOwn = (edit: Edit) => write(edit(automation));
85
+ // Speed moves every data-link member together, so a linked clip's rate lane edits fan out.
86
+ const writeRate = (edit: Edit) => {
87
+ if (!linkedSpeed) return writeOwn(edit);
88
+ void linkedSpeed.commitAttribute(
89
+ HF_AUDIO_AUTOMATION_ATTR,
90
+ (raw) => automationAttrValue(edit(readPanelAutomation(raw, undefined))) || null,
91
+ "Edit speed",
92
+ );
93
+ };
94
+ const laneBinding = (
95
+ target: string,
96
+ seed: number,
97
+ writeLane: (edit: Edit) => void,
98
+ ): LaneBinding => {
81
99
  const lane = automation.lanes.find((l) => l.target === target);
82
100
  return {
83
101
  automated: lane !== undefined,
@@ -85,9 +103,9 @@ export function useVolumeAutomation(
85
103
  ? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale)
86
104
  : undefined,
87
105
  // Seeded at the level the control already shows, so automating does not change it.
88
- onAutomate: () => write(withSeededLane(automation, target, seed)),
89
- onRemoveAutomation: () => write(withoutLane(automation, target)),
90
- onCommitAt: (v: number) => write(withPointAt(automation, target, clipTimeSec, v)),
106
+ onAutomate: () => writeLane((a) => withSeededLane(a, target, seed)),
107
+ onRemoveAutomation: () => writeLane((a) => withoutLane(a, target)),
108
+ onCommitAt: (v: number) => writeLane((a) => withPointAt(a, target, clipTimeSec, v)),
91
109
  };
92
110
  };
93
111
  // `??` alone would let an empty `data-volume` through as Number("") === 0, so
@@ -96,9 +114,13 @@ export function useVolumeAutomation(
96
114
  const raw = element.dataAttributes?.["volume"];
97
115
  const parsed = raw ? Number(raw) : 1;
98
116
  const current = Number.isFinite(parsed) ? parsed : 1;
99
- const volume = laneBinding(VOLUME_TARGET, current);
117
+ const volume = laneBinding(VOLUME_TARGET, current, writeOwn);
100
118
  const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
101
- const rate = laneBinding(RATE_TARGET, Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1);
119
+ const rate = laneBinding(
120
+ RATE_TARGET,
121
+ Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1,
122
+ writeRate,
123
+ );
102
124
  return {
103
125
  volumeAutomated: volume.automated,
104
126
  automatedVolumeValue: volume.automatedValue,
@@ -109,7 +131,7 @@ export function useVolumeAutomation(
109
131
  ...rate,
110
132
  canApplyPreset: elDuration > 0,
111
133
  onApplyPreset: (id) => {
112
- if (elDuration > 0) write(withLane(automation, speedPresetLane(id, elDuration)));
134
+ if (elDuration > 0) writeRate((a) => withLane(a, speedPresetLane(id, elDuration)));
113
135
  },
114
136
  },
115
137
  };
@@ -54,6 +54,8 @@ export interface TimelineEditCallbackDeps {
54
54
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
55
55
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
56
56
  handleFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
57
+ handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
58
+ handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
57
59
  /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
58
60
  handleGroupClips?: (
59
61
  clipIds: readonly string[],
@@ -134,6 +136,8 @@ export function useTimelineEditCallbacks({
134
136
  handleRazorSplit,
135
137
  handleRazorSplitAll,
136
138
  handleFreezeFrame,
139
+ handleLinkEdit,
140
+ handleTimelineElementDeleteOnly,
137
141
  handleGroupClips,
138
142
  setElementFxAttribute,
139
143
  }: TimelineEditCallbackDeps): TimelineEditCallbacks {
@@ -230,6 +234,8 @@ export function useTimelineEditCallbacks({
230
234
  onRazorSplit: handleRazorSplit,
231
235
  onRazorSplitAll: handleRazorSplitAll,
232
236
  onFreezeFrame: handleFreezeFrame,
237
+ onLinkEdit: handleLinkEdit,
238
+ onDeleteElementOnly: handleTimelineElementDeleteOnly,
233
239
  onDeleteAllKeyframes: (element, animationId) => {
234
240
  // Hold the element where it is (collapse keyframes to a static set) rather
235
241
  // than deleting the whole animation — deleting strands a stale GSAP base
@@ -427,6 +433,8 @@ export function useTimelineEditCallbacks({
427
433
  handleRazorSplit,
428
434
  handleRazorSplitAll,
429
435
  handleFreezeFrame,
436
+ handleLinkEdit,
437
+ handleTimelineElementDeleteOnly,
430
438
  handleGsapRemoveAllKeyframes,
431
439
  resolveElementAnimations,
432
440
  resolveKeyframeTarget,
@@ -3,7 +3,6 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import React, { act, useState } from "react";
6
- import { createRoot, type Root } from "react-dom/client";
7
6
  import { compile } from "tailwindcss";
8
7
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
9
8
  import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
@@ -11,27 +10,11 @@ import { ContextMenu, Menu, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparat
11
10
  import { Popover } from "./Popover";
12
11
  import { isTypingTarget } from "../../utils/typingTarget";
13
12
  import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
13
+ import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
14
14
 
15
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
16
 
17
- let mounted: { root: Root; host: HTMLElement } | null = null;
18
-
19
- function render(element: React.ReactElement): HTMLElement {
20
- const host = document.createElement("div");
21
- document.body.append(host);
22
- const root = createRoot(host);
23
- mounted = { root, host };
24
- act(() => root.render(element));
25
- return host;
26
- }
27
-
28
- afterEach(() => {
29
- if (!mounted) return;
30
- const { root, host } = mounted;
31
- mounted = null;
32
- act(() => root.unmount());
33
- host.remove();
34
- });
17
+ afterEach(cleanupMounted);
35
18
 
36
19
  /** Base UI moves focus into the popup one task after open, not synchronously. */
37
20
  async function settle(): Promise<void> {
@@ -310,9 +293,7 @@ describe("Popover", () => {
310
293
  renderOpen(false);
311
294
  await settle();
312
295
  expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
313
- act(() => mounted?.root.unmount());
314
- mounted?.host.remove();
315
- mounted = null;
296
+ cleanupMounted();
316
297
 
317
298
  renderOpen(true);
318
299
  await settle();
@@ -52,8 +52,8 @@ export const popupSurface = cn(
52
52
  "data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
53
53
  );
54
54
 
55
- /** The popup's own layer: menus sit above panel chrome and below a modal. */
56
- const POPUP_LAYER = "z-200";
55
+ /** Every floating list, menu, popover and tooltip sits on this layer, above panel chrome and modals. */
56
+ export const POPUP_LAYER = "z-200";
57
57
 
58
58
  const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
59
59
 
@@ -6,7 +6,7 @@
6
6
  import { Popover as BasePopover } from "@base-ui/react/popover";
7
7
  import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
8
8
  import { cn } from "./cn";
9
- import { popupSurface, type PopupPreviewState } from "./Menu";
9
+ import { POPUP_LAYER, popupSurface, type PopupPreviewState } from "./Menu";
10
10
 
11
11
  type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["container"];
12
12
 
@@ -70,7 +70,7 @@ export function Popover({
70
70
  sideOffset={sideOffset}
71
71
  collisionPadding={VIEWPORT_MARGIN}
72
72
  arrowPadding={ARROW_CORNER_CLEARANCE}
73
- className="z-200"
73
+ className={POPUP_LAYER}
74
74
  >
75
75
  <BasePopover.Popup
76
76
  aria-label={ariaLabel}
@@ -6,7 +6,7 @@
6
6
  import { Select as BaseSelect } from "@base-ui/react/select";
7
7
  import { cn } from "./cn";
8
8
  import { fieldBase } from "./Input";
9
- import { floatingMotion } from "./Menu";
9
+ import { floatingMotion, POPUP_LAYER } from "./Menu";
10
10
  import type { PreviewState } from "./Button";
11
11
 
12
12
  export interface SelectOption {
@@ -77,7 +77,7 @@ export function Select({
77
77
  </BaseSelect.Trigger>
78
78
 
79
79
  <BaseSelect.Portal>
80
- <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
80
+ <BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false} className={POPUP_LAYER}>
81
81
  <BaseSelect.Popup
82
82
  className={cn(
83
83
  "min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
@@ -6,7 +6,7 @@
6
6
  import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
7
7
  import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
8
8
  import { cn } from "./cn";
9
- import { floatingMotion } from "./Menu";
9
+ import { floatingMotion, POPUP_LAYER } from "./Menu";
10
10
 
11
11
  interface TooltipProps {
12
12
  label: string;
@@ -39,7 +39,7 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
39
39
  side={side}
40
40
  sideOffset={SIDE_OFFSET}
41
41
  collisionPadding={VIEWPORT_MARGIN}
42
- className="z-200"
42
+ className={POPUP_LAYER}
43
43
  >
44
44
  <BaseTooltip.Popup
45
45
  id={tooltipId}
@@ -4,6 +4,13 @@ import { createRoot, type Root } from "react-dom/client";
4
4
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
5
5
 
6
6
  let mounted: { root: Root; host: HTMLElement } | null = null;
7
+ const tracked: Root[] = [];
8
+
9
+ export function trackedRoot(host: HTMLElement): Root {
10
+ const root = createRoot(host);
11
+ tracked.push(root);
12
+ return root;
13
+ }
7
14
 
8
15
  /** Renders into a fresh host attached to the body; `cleanupMounted` tears it down. */
9
16
  export function mountHost(element: ReactElement): HTMLElement {
@@ -16,6 +23,7 @@ export function mountHost(element: ReactElement): HTMLElement {
16
23
  }
17
24
 
18
25
  export function cleanupMounted(): void {
26
+ act(() => tracked.splice(0).forEach((root) => root.unmount()));
19
27
  if (!mounted) return;
20
28
  const { root, host } = mounted;
21
29
  mounted = null;
@@ -4,7 +4,6 @@ import { createRequire } from "node:module";
4
4
  import { readFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import React, { act } from "react";
7
- import { createRoot, type Root } from "react-dom/client";
8
7
  import { compile } from "tailwindcss";
9
8
  import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
10
9
 
@@ -21,33 +20,15 @@ import { Toggle } from "./Toggle";
21
20
  import { isTypingTarget } from "../../utils/typingTarget";
22
21
  import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
23
22
  import { __resetDesignInputThrottle, trackDesignInput } from "../../utils/designInputTracking";
23
+ import { cleanupMounted, mountHost as render } from "./mountHost.testHelpers";
24
24
 
25
25
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
26
26
 
27
- let mounted: { root: Root; host: HTMLElement } | null = null;
28
-
29
- function render(element: React.ReactElement): HTMLElement {
30
- const host = document.createElement("div");
31
- document.body.append(host);
32
- const root = createRoot(host);
33
- mounted = { root, host };
34
- act(() => root.render(element));
35
- return host;
36
- }
37
-
38
- function unmount() {
39
- if (!mounted) return;
40
- const { root, host } = mounted;
41
- mounted = null;
42
- act(() => root.unmount());
43
- host.remove();
44
- }
45
-
46
27
  beforeEach(() => {
47
28
  trackStudioEvent.mockReset();
48
29
  __resetDesignInputThrottle();
49
30
  });
50
- afterEach(unmount);
31
+ afterEach(cleanupMounted);
51
32
 
52
33
  /** Base UI moves focus a task later than React renders; happy-dom is no faster. */
53
34
  const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
@@ -39,10 +39,13 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
39
39
  onSetElementAttributeQuiet: true,
40
40
  onRevertElementAttributeLive: true,
41
41
  onBlockedEditAttempt: true,
42
+ onLinkEdit: true,
43
+ onDeleteElementOnly: true,
42
44
  onSplitElement: true,
43
45
  onRazorSplit: true,
44
46
  onRazorSplitAll: true,
45
47
  onFreezeFrame: true,
48
+ onNotice: true,
46
49
  onDeleteKeyframe: true,
47
50
  onDeleteAllKeyframes: true,
48
51
  onMoveKeyframeToPlayhead: true,