@hyperframes/studio 0.8.105 → 0.8.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -0,0 +1,212 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { applyPatchByTarget } from "../../utils/sourcePatcher";
4
+ import {
5
+ buildKeepSoundCutoutEdit,
6
+ cutoutOps,
7
+ hasAudioToggleOps,
8
+ mintLinkId,
9
+ mutedToggleOps,
10
+ } from "./mediaAudioEdits";
11
+
12
+ const attr = (property: string, value: string | null) => ({
13
+ type: "attribute" as const,
14
+ property,
15
+ value,
16
+ });
17
+ const html = (property: string, value: string | null) => ({
18
+ type: "html-attribute" as const,
19
+ property,
20
+ value,
21
+ });
22
+
23
+ describe("mutedToggleOps", () => {
24
+ it("mutes a video and removes has-audio instead of writing an empty marker", () => {
25
+ expect(mutedToggleOps({ isVideo: true, nextMuted: true, probedHasAudio: null })).toEqual([
26
+ html("muted", "true"),
27
+ attr("has-audio", null),
28
+ ]);
29
+ });
30
+
31
+ it("unmutes a video the probe hears as audible", () => {
32
+ expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: true })).toEqual([
33
+ html("muted", null),
34
+ attr("has-audio", "true"),
35
+ ]);
36
+ });
37
+
38
+ it("unmutes a silent-probe video with an explicit has-audio=false", () => {
39
+ expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: false })).toEqual([
40
+ html("muted", null),
41
+ attr("has-audio", "false"),
42
+ ]);
43
+ });
44
+
45
+ it("leaves has-audio absent when the probe fails", () => {
46
+ expect(mutedToggleOps({ isVideo: true, nextMuted: false, probedHasAudio: null })).toEqual([
47
+ html("muted", null),
48
+ attr("has-audio", null),
49
+ ]);
50
+ });
51
+
52
+ it("toggles only muted on an audio element", () => {
53
+ expect(mutedToggleOps({ isVideo: false, nextMuted: true, probedHasAudio: null })).toEqual([
54
+ html("muted", "true"),
55
+ ]);
56
+ expect(mutedToggleOps({ isVideo: false, nextMuted: false, probedHasAudio: null })).toEqual([
57
+ html("muted", null),
58
+ ]);
59
+ });
60
+ });
61
+
62
+ describe("hasAudioToggleOps", () => {
63
+ it("turns sound on by stamping has-audio and clearing muted", () => {
64
+ expect(hasAudioToggleOps(true)).toEqual([attr("has-audio", "true"), html("muted", null)]);
65
+ });
66
+
67
+ it("turns sound off by removing has-audio rather than writing an empty value", () => {
68
+ expect(hasAudioToggleOps(false)).toEqual([attr("has-audio", null), html("muted", "true")]);
69
+ });
70
+ });
71
+
72
+ describe("cutoutOps", () => {
73
+ it("swaps the src of an image and nothing else", () => {
74
+ expect(cutoutOps({ isVideo: false, cutoutSrc: "assets/cut.png" })).toEqual([
75
+ html("src", "assets/cut.png"),
76
+ ]);
77
+ });
78
+
79
+ it("swaps a silent video's src and keeps it muted without a has-audio marker", () => {
80
+ expect(cutoutOps({ isVideo: true, cutoutSrc: "assets/cut.webm" })).toEqual([
81
+ html("src", "assets/cut.webm"),
82
+ html("muted", "true"),
83
+ attr("has-audio", null),
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("mintLinkId", () => {
89
+ it("dedupes against element ids and existing links", () => {
90
+ const doc = document.implementation.createHTMLDocument("t");
91
+ expect(mintLinkId(doc)).toBe("link");
92
+ doc.body.innerHTML = '<div id="link"></div><video data-link="link-2"></video>';
93
+ expect(mintLinkId(doc)).toBe("link-3");
94
+ });
95
+ });
96
+
97
+ function makeVideo(attrs: Record<string, string>): HTMLVideoElement {
98
+ const doc = document.implementation.createHTMLDocument("t");
99
+ const video = doc.createElement("video");
100
+ for (const [name, value] of Object.entries(attrs)) video.setAttribute(name, value);
101
+ doc.body.append(video);
102
+ return video;
103
+ }
104
+
105
+ function applyEdit(source: string, edit: ReturnType<typeof buildKeepSoundCutoutEdit>): string {
106
+ const target = { id: "clip" };
107
+ const patched = edit.ops.reduce((acc, op) => applyPatchByTarget(acc, target, op), source);
108
+ return edit.prepareContent(patched);
109
+ }
110
+
111
+ function parse(source: string): Document {
112
+ return new DOMParser().parseFromString(source, "text/html");
113
+ }
114
+
115
+ describe("buildKeepSoundCutoutEdit", () => {
116
+ const automation = JSON.stringify({
117
+ version: 1,
118
+ lanes: [
119
+ { target: "volume", points: [{ t: 0, v: 1 }] },
120
+ { target: "rate", points: [{ t: 0, v: 2 }] },
121
+ ],
122
+ });
123
+
124
+ const source = [
125
+ '<div data-composition-id="main" data-duration="10">',
126
+ ' <video id="clip" class="clip" src="assets/talk.mp4" data-start="1" data-duration="4" data-media-start="2" data-track-index="0" data-has-audio="true" data-volume="0.5" data-fade-in="0.3" data-audio-group="voiceover"></video>',
127
+ ' <audio id="music" class="clip" src="assets/bgm.mp3" data-start="0" data-duration="10" data-track-index="1"></audio>',
128
+ "</div>",
129
+ ].join("\n");
130
+
131
+ const liveVideo = () =>
132
+ makeVideo({
133
+ id: "clip",
134
+ src: "assets/talk.mp4",
135
+ "data-has-audio": "true",
136
+ "data-volume": "0.5",
137
+ "data-fade-in": "0.3",
138
+ "data-audio-group": "voiceover",
139
+ "data-automation": automation,
140
+ });
141
+
142
+ it("mutes the video onto the cutout and moves its sound to a linked sibling audio", () => {
143
+ const edit = buildKeepSoundCutoutEdit({
144
+ video: liveVideo(),
145
+ videoId: "clip",
146
+ target: { id: "clip" },
147
+ cutoutSrc: "assets/talk-cutout.webm",
148
+ });
149
+ const doc = parse(applyEdit(source, edit));
150
+ const video = doc.getElementById("clip");
151
+ const audio = doc.getElementById("clip-audio");
152
+ if (!video || !audio) throw new Error("expected both clips");
153
+
154
+ expect(video.getAttribute("src")).toBe("assets/talk-cutout.webm");
155
+ expect(video.hasAttribute("muted")).toBe(true);
156
+ expect(video.hasAttribute("data-has-audio")).toBe(false);
157
+ expect(video.hasAttribute("data-volume")).toBe(false);
158
+ expect(video.hasAttribute("data-fade-in")).toBe(false);
159
+ expect(video.hasAttribute("data-audio-group")).toBe(false);
160
+ expect(JSON.parse(video.getAttribute("data-automation") ?? "").lanes).toEqual([
161
+ { target: "rate", points: [{ t: 0, v: 2 }] },
162
+ ]);
163
+
164
+ expect(audio.tagName).toBe("AUDIO");
165
+ expect(audio.getAttribute("src")).toBe("assets/talk.mp4");
166
+ expect(audio.getAttribute("data-start")).toBe("1");
167
+ expect(audio.getAttribute("data-duration")).toBe("4");
168
+ expect(audio.getAttribute("data-media-start")).toBe("2");
169
+ expect(audio.hasAttribute("data-playback-rate")).toBe(false);
170
+ expect(audio.getAttribute("data-track-index")).toBe("2");
171
+ expect(audio.getAttribute("data-volume")).toBe("0.5");
172
+ expect(audio.getAttribute("data-fade-in")).toBe("0.3");
173
+ expect(audio.getAttribute("data-audio-group")).toBe("voiceover");
174
+ expect(JSON.parse(audio.getAttribute("data-automation") ?? "").lanes).toEqual([
175
+ { target: "volume", points: [{ t: 0, v: 1 }] },
176
+ { target: "rate", points: [{ t: 0, v: 2 }] },
177
+ ]);
178
+ expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link"));
179
+ expect(video.getAttribute("data-link")).toBe("link");
180
+ expect(audio.parentElement).toBe(video.parentElement);
181
+ });
182
+
183
+ it("drops the video's automation attribute when it held no rate lane", () => {
184
+ const video = makeVideo({
185
+ id: "clip",
186
+ src: "assets/talk.mp4",
187
+ "data-automation": JSON.stringify({
188
+ version: 1,
189
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
190
+ }),
191
+ });
192
+ const edit = buildKeepSoundCutoutEdit({
193
+ video,
194
+ videoId: "clip",
195
+ target: { id: "clip" },
196
+ cutoutSrc: "assets/cut.webm",
197
+ });
198
+ expect(edit.ops).toContainEqual(attr("automation", null));
199
+ });
200
+
201
+ it("suffixes the audio id when <video-id>-audio is taken", () => {
202
+ const edit = buildKeepSoundCutoutEdit({
203
+ video: liveVideo(),
204
+ videoId: "clip",
205
+ target: { id: "clip" },
206
+ cutoutSrc: "assets/cut.webm",
207
+ });
208
+ const taken = source.replace('id="music"', 'id="clip-audio"');
209
+ const doc = parse(applyEdit(taken, edit));
210
+ expect(doc.getElementById("clip-audio-2")?.getAttribute("src")).toBe("assets/talk.mp4");
211
+ });
212
+ });
@@ -0,0 +1,253 @@
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
+ readAttributeByTarget,
16
+ type PatchOperation,
17
+ type PatchTarget,
18
+ } from "../../utils/sourcePatcher";
19
+ import { collectHtmlIds, resolveAssetHasAudio } from "../../utils/studioHelpers";
20
+ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
21
+ import { buildDomEditPatchTarget, type DomEditSelection } from "./domEditing";
22
+ import { generateId } from "../../utils/generateId";
23
+
24
+ const MEDIA_LINK_ATTR = "data-link";
25
+ const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
26
+
27
+ const MOVED_SOUND_ATTRS = [
28
+ "data-volume",
29
+ HF_AUDIO_FADE_IN_ATTR,
30
+ HF_AUDIO_FADE_OUT_ATTR,
31
+ HF_AUDIO_FX_ATTR,
32
+ HF_AUDIO_CARVE_ATTR,
33
+ HF_AUDIO_GROUP_ATTR,
34
+ ];
35
+
36
+ const COPIED_TIMING_ATTRS = [
37
+ "start",
38
+ "end",
39
+ "duration",
40
+ "media-start",
41
+ "playback-start",
42
+ "playback-rate",
43
+ "source-duration",
44
+ ];
45
+
46
+ const htmlAttr = (property: string, value: string | null): PatchOperation => ({
47
+ type: "html-attribute",
48
+ property,
49
+ value,
50
+ });
51
+
52
+ const dataAttr = (property: string, value: string | null): PatchOperation => ({
53
+ type: "attribute",
54
+ property,
55
+ value,
56
+ });
57
+
58
+ function stripDataPrefix(name: string): string {
59
+ return name.startsWith("data-") ? name.slice("data-".length) : name;
60
+ }
61
+
62
+ function probedHasAudioValue(probedHasAudio: boolean | null): string | null {
63
+ if (probedHasAudio === null) return null;
64
+ return probedHasAudio ? "true" : "false";
65
+ }
66
+
67
+ export function mutedToggleOps(input: {
68
+ isVideo: boolean;
69
+ nextMuted: boolean;
70
+ probedHasAudio: boolean | null;
71
+ }): PatchOperation[] {
72
+ const muted = htmlAttr("muted", input.nextMuted ? "true" : null);
73
+ if (!input.isVideo) return [muted];
74
+ const hasAudio = input.nextMuted ? null : probedHasAudioValue(input.probedHasAudio);
75
+ return [muted, dataAttr("has-audio", hasAudio)];
76
+ }
77
+
78
+ export function hasAudioToggleOps(next: boolean): PatchOperation[] {
79
+ return next
80
+ ? [dataAttr("has-audio", "true"), htmlAttr("muted", null)]
81
+ : [dataAttr("has-audio", null), htmlAttr("muted", "true")];
82
+ }
83
+
84
+ export function cutoutOps(input: { isVideo: boolean; cutoutSrc: string }): PatchOperation[] {
85
+ const src = htmlAttr("src", input.cutoutSrc);
86
+ return input.isVideo ? [src, htmlAttr("muted", "true"), dataAttr("has-audio", null)] : [src];
87
+ }
88
+
89
+ function firstFreeName(base: string, taken: Set<string>): string {
90
+ if (!taken.has(base)) return base;
91
+ let n = 2;
92
+ while (taken.has(`${base}-${n}`)) n += 1;
93
+ return `${base}-${n}`;
94
+ }
95
+
96
+ export function mintLinkId(doc: Document): string {
97
+ const taken = new Set<string>();
98
+ for (const el of Array.from(doc.querySelectorAll(`[id], [${MEDIA_LINK_ATTR}]`))) {
99
+ if (el.id) taken.add(el.id);
100
+ const link = el.getAttribute(MEDIA_LINK_ATTR);
101
+ if (link) taken.add(link);
102
+ }
103
+ return firstFreeName("link", taken);
104
+ }
105
+
106
+ function firstFreeTrackIndex(source: string): number {
107
+ const used = new Set(
108
+ Array.from(source.matchAll(/\bdata-track-index=["'](-?\d+)["']/g), (m) => Number(m[1])),
109
+ );
110
+ let track = 0;
111
+ while (used.has(track)) track += 1;
112
+ return track;
113
+ }
114
+
115
+ function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
116
+ let lanes: HfAutomationLane[];
117
+ try {
118
+ lanes = parseAutomation(raw).lanes;
119
+ } catch {
120
+ return { videoKeeps: null, audioTakes: raw };
121
+ }
122
+ const rateLanes = lanes.filter((lane) => lane.target === RATE_TARGET);
123
+ const soundLanes = lanes.filter((lane) => lane.target !== RATE_TARGET);
124
+ return {
125
+ videoKeeps: rateLanes.length > 0 ? serializeAutomation({ version: 1, lanes: rateLanes }) : null,
126
+ audioTakes: serializeAutomation({ version: 1, lanes: [...soundLanes, ...rateLanes] }),
127
+ };
128
+ }
129
+
130
+ function formatAttrs(attrs: Array<[string, string]>): string {
131
+ return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
132
+ }
133
+
134
+ function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
135
+ const match = findTagByTarget(source, target);
136
+ if (!match) return source;
137
+ const lineStart = source.lastIndexOf("\n", match.start) + 1;
138
+ const indent = /^[ \t]*$/.test(source.slice(lineStart, match.start))
139
+ ? source.slice(lineStart, match.start)
140
+ : "";
141
+ const separator = indent ? `\n${indent}` : "";
142
+ return `${source.slice(0, match.start)}${markup}${separator}${source.slice(match.start)}`;
143
+ }
144
+
145
+ export interface KeepSoundCutoutEdit {
146
+ ops: PatchOperation[];
147
+ prepareContent: (source: string) => string;
148
+ }
149
+
150
+ export function buildKeepSoundCutoutEdit(input: {
151
+ video: Element;
152
+ videoId: string | null;
153
+ target: PatchTarget;
154
+ cutoutSrc: string;
155
+ }): KeepSoundCutoutEdit {
156
+ const { video, target } = input;
157
+ const linkId = mintLinkId(video.ownerDocument);
158
+ const originalSrc = video.getAttribute("src") ?? "";
159
+
160
+ const movedSound: Array<[string, string]> = [];
161
+ const ops: PatchOperation[] = [...cutoutOps({ isVideo: true, cutoutSrc: input.cutoutSrc })];
162
+ for (const name of MOVED_SOUND_ATTRS) {
163
+ const value = video.getAttribute(name);
164
+ if (value === null) continue;
165
+ movedSound.push([name, value]);
166
+ ops.push(dataAttr(stripDataPrefix(name), null));
167
+ }
168
+ const automation = video.getAttribute(HF_AUDIO_AUTOMATION_ATTR);
169
+ if (automation !== null) {
170
+ const { videoKeeps, audioTakes } = splitAutomation(automation);
171
+ movedSound.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
172
+ ops.push(dataAttr(stripDataPrefix(HF_AUDIO_AUTOMATION_ATTR), videoKeeps));
173
+ }
174
+ ops.push(dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId));
175
+
176
+ const prepareContent = (source: string): string => {
177
+ const audioId = firstFreeName(
178
+ `${input.videoId || "video"}-audio`,
179
+ new Set(collectHtmlIds(source)),
180
+ );
181
+ const timing: Array<[string, string]> = [];
182
+ for (const name of COPIED_TIMING_ATTRS) {
183
+ const value = readAttributeByTarget(source, target, name);
184
+ if (value !== undefined) timing.push([`data-${name}`, value]);
185
+ }
186
+ const attrs: Array<[string, string]> = [
187
+ ["id", audioId],
188
+ ["data-hf-id", `hf-${generateId()}`],
189
+ ["class", "clip"],
190
+ ["src", originalSrc],
191
+ ...timing,
192
+ ["data-track-index", String(firstFreeTrackIndex(source))],
193
+ [MEDIA_LINK_ATTR, linkId],
194
+ ...movedSound,
195
+ ];
196
+ return insertBeforeTarget(source, target, `<audio ${formatAttrs(attrs)}></audio>`);
197
+ };
198
+
199
+ return { ops, prepareContent };
200
+ }
201
+
202
+ export interface MediaEditContext {
203
+ element: DomEditSelection;
204
+ projectId: string | null;
205
+ projectSrc: string;
206
+ commit: CommitDomAttributeBatch;
207
+ }
208
+
209
+ const isVideoSelection = (element: DomEditSelection) => element.tagName === "video";
210
+
211
+ export async function commitMutedToggle(ctx: MediaEditContext, nextMuted: boolean): Promise<void> {
212
+ const isVideo = isVideoSelection(ctx.element);
213
+ const probedHasAudio =
214
+ isVideo && !nextMuted && ctx.projectId && ctx.projectSrc
215
+ ? await resolveAssetHasAudio(ctx.projectId, ctx.projectSrc)
216
+ : null;
217
+ await ctx.commit(ctx.element, mutedToggleOps({ isVideo, nextMuted, probedHasAudio }), {
218
+ label: "Edit muted",
219
+ });
220
+ }
221
+
222
+ export async function commitHasAudioToggle(ctx: MediaEditContext, next: boolean): Promise<void> {
223
+ await ctx.commit(ctx.element, hasAudioToggleOps(next), { label: "Edit has audio" });
224
+ }
225
+
226
+ export async function commitCutout(
227
+ ctx: MediaEditContext,
228
+ cutoutSrc: string,
229
+ keepSound: boolean,
230
+ ): Promise<string> {
231
+ const isVideo = isVideoSelection(ctx.element);
232
+ const label = "Remove background";
233
+ const keep = isVideo && keepSound;
234
+ const landed = keep
235
+ ? await commitKeepSoundCutout(ctx, cutoutSrc, label)
236
+ : await ctx.commit(ctx.element, cutoutOps({ isVideo, cutoutSrc }), { label });
237
+ if (!landed) throw new Error("Couldn't apply the cutout");
238
+ return keep ? SOUND_KEPT_ON_LINKED_AUDIO_STAGE : "Applied cutout";
239
+ }
240
+
241
+ function commitKeepSoundCutout(
242
+ ctx: MediaEditContext,
243
+ cutoutSrc: string,
244
+ label: string,
245
+ ): Promise<boolean> {
246
+ const edit = buildKeepSoundCutoutEdit({
247
+ video: ctx.element.element,
248
+ videoId: ctx.element.id ?? null,
249
+ target: buildDomEditPatchTarget(ctx.element),
250
+ cutoutSrc,
251
+ });
252
+ return ctx.commit(ctx.element, edit.ops, { label, prepareContent: edit.prepareContent });
253
+ }
@@ -3,8 +3,10 @@
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import {
5
5
  IDLE_POLL_MS,
6
+ IDLE_SLICE_MS,
6
7
  requestOverlayFrames,
7
8
  resetOverlayFrameLoopForTests,
9
+ runWhenInputIdle,
8
10
  subscribeOverlayFrame,
9
11
  } from "./overlayFrameLoop";
10
12
  import { usePlayerStore } from "../../player/store/playerStore";
@@ -71,6 +73,31 @@ describe("overlay frame loop", () => {
71
73
  expect(runs).toBeGreaterThan(0);
72
74
  });
73
75
 
76
+ it("runs idle work in capped slices, only while no input arrives", () => {
77
+ const budgets: number[] = [];
78
+ requestOverlayFrames();
79
+ runWhenInputIdle((timeLeft) => {
80
+ budgets.push(timeLeft());
81
+ return budgets.length === 3;
82
+ });
83
+ vi.advanceTimersByTime(300);
84
+ expect(budgets).toEqual([]);
85
+ vi.advanceTimersByTime(200);
86
+ expect(budgets).toHaveLength(3);
87
+ expect(Math.max(...budgets)).toBeLessThanOrEqual(IDLE_SLICE_MS);
88
+
89
+ // Input during a slice holds the next one until the loop has been idle again.
90
+ const slices: number[] = [];
91
+ runWhenInputIdle(() => {
92
+ slices.push(performance.now());
93
+ if (slices.length === 1) requestOverlayFrames();
94
+ return slices.length === 2;
95
+ });
96
+ vi.advanceTimersByTime(1000);
97
+ expect(slices).toHaveLength(2);
98
+ expect(slices[1] - slices[0]).toBeGreaterThanOrEqual(400);
99
+ });
100
+
74
101
  it("runs every subscriber on one frame, not one frame each", () => {
75
102
  const calls: string[] = [];
76
103
  subscribeOverlayFrame(() => calls.push("a"));
@@ -143,6 +143,27 @@ function schedule(): void {
143
143
  }, IDLE_POLL_MS);
144
144
  }
145
145
 
146
+ /** Longest idle slice, so input that arrives during one waits at most this long for its frame. */
147
+ export const IDLE_SLICE_MS = 3;
148
+
149
+ /**
150
+ * Runs `step` in idle slices once no input has woken the loop for AWAKE_MS, so it never shares a gesture's
151
+ * frames. `step` works while `timeLeft()` is positive and returns true when it has finished.
152
+ */
153
+ export function runWhenInputIdle(step: (timeLeft: () => number) => boolean): void {
154
+ const wait = awakeUntil - performance.now();
155
+ if (wait > 0) {
156
+ setTimeout(() => runWhenInputIdle(step), wait);
157
+ return;
158
+ }
159
+ const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 0));
160
+ idle(() => {
161
+ const end = performance.now() + IDLE_SLICE_MS;
162
+ if (performance.now() < awakeUntil || !step(() => end - performance.now()))
163
+ runWhenInputIdle(step);
164
+ });
165
+ }
166
+
146
167
  /** Something moved, or might have. Run frames at full rate for a moment. */
147
168
  export function requestOverlayFrames(): void {
148
169
  awakeUntil = performance.now() + AWAKE_MS;