@hyperframes/studio 0.8.106 → 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-dl6K6ABp.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-CM1xA_jD.js} +195 -200
- package/dist/assets/{index-ooaZZhKf.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/{index-DKT5NF1Y.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-ICTZJPQQ.js → chunk-4OZC426H.js} +12 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +367 -11
- package/dist/index.html +1 -1
- package/dist/index.js +1064 -714
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +12 -18
- 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/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -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/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +41 -23
- 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/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/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- 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.ts +2 -3
- 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/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +7 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- 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/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 +16 -7
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
|
@@ -35,13 +35,26 @@ import {
|
|
|
35
35
|
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
36
36
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
37
37
|
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
|
38
|
+
import { BOX_SIZE_STYLE_PROPS } from "./manualEditsDomPatches";
|
|
38
39
|
|
|
39
40
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
40
41
|
let studioManualEditGestureId = 0;
|
|
41
42
|
|
|
42
|
-
export
|
|
43
|
+
export type StudioGestureDraws = "move" | "resize" | "rotate" | "edit";
|
|
44
|
+
const MOVE_DRAWS = ["translate", STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP];
|
|
45
|
+
const GESTURE_DRAWS: Record<StudioGestureDraws, readonly string[]> = {
|
|
46
|
+
move: MOVE_DRAWS,
|
|
47
|
+
resize: [...MOVE_DRAWS, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, ...BOX_SIZE_STYLE_PROPS],
|
|
48
|
+
rotate: ["rotate", "transform", "transform-origin", "display", STUDIO_ROTATION_PROP],
|
|
49
|
+
edit: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function beginStudioManualEditGesture(
|
|
53
|
+
element: HTMLElement,
|
|
54
|
+
draws: StudioGestureDraws,
|
|
55
|
+
): string {
|
|
43
56
|
studioManualEditGestureId += 1;
|
|
44
|
-
const token = `gesture-${studioManualEditGestureId}`;
|
|
57
|
+
const token = `gesture-${studioManualEditGestureId}:${draws}`;
|
|
45
58
|
element.setAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR, token);
|
|
46
59
|
return token;
|
|
47
60
|
}
|
|
@@ -78,10 +91,20 @@ export function studioManualEditSavesIn(doc: Document): number {
|
|
|
78
91
|
return gestureSaves.get(doc) ?? 0;
|
|
79
92
|
}
|
|
80
93
|
|
|
81
|
-
/**
|
|
82
|
-
export function
|
|
94
|
+
/** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
|
|
95
|
+
export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
|
|
83
96
|
const doc = element.ownerDocument;
|
|
84
|
-
gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
97
|
+
const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
|
|
98
|
+
count();
|
|
99
|
+
const result = save();
|
|
100
|
+
void Promise.resolve(result).then(count, count);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function studioGestureDraws(element: Element): readonly string[] | null {
|
|
105
|
+
const token = element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
|
|
106
|
+
if (token === null) return null;
|
|
107
|
+
return GESTURE_DRAWS[token.split(":")[1] as StudioGestureDraws] ?? [];
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
function isStudioManualEditGestureActive(element: HTMLElement): boolean {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
|
+
import { gsapRendersTransform } from "./gsapAnimatesProperty";
|
|
2
3
|
import {
|
|
3
4
|
STUDIO_OFFSET_X_PROP,
|
|
4
5
|
STUDIO_OFFSET_Y_PROP,
|
|
@@ -127,7 +128,10 @@ const BOX_SIZE_ORIG_ATTRS: ReadonlyArray<[string, string]> = [
|
|
|
127
128
|
export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] {
|
|
128
129
|
const ops: PatchOperation[] = [];
|
|
129
130
|
collectInlineStyleOps(element, [STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP], ops);
|
|
130
|
-
|
|
131
|
+
const withoutGsapScaleMask = gsapRendersTransform(element)
|
|
132
|
+
? BOX_SIZE_STYLE_PROPS.filter((prop) => prop !== "scale")
|
|
133
|
+
: BOX_SIZE_STYLE_PROPS;
|
|
134
|
+
collectInlineStyleOps(element, withoutGsapScaleMask, ops);
|
|
131
135
|
ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" });
|
|
132
136
|
collectAttributeOps(
|
|
133
137
|
element,
|
|
@@ -356,7 +356,10 @@ export function createManualOffsetDragMember(input: {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
const initialPathOffset = captureStudioPathOffset(input.element);
|
|
359
|
-
const gestureToken = beginStudioManualEditGesture(
|
|
359
|
+
const gestureToken = beginStudioManualEditGesture(
|
|
360
|
+
input.element,
|
|
361
|
+
input.gesture === "resize" ? "resize" : "move",
|
|
362
|
+
);
|
|
360
363
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
|
|
361
364
|
scaleX: input.rect.editScaleX,
|
|
362
365
|
scaleY: input.rect.editScaleY,
|
|
@@ -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
|
+
}
|