@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.
- package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- 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;
|