@hyperframes/studio 0.8.108 → 0.8.110
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-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createHappyDomRootHarness } from "../../player/components/testRootHarness";
|
|
5
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
6
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
7
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
8
|
+
import {
|
|
9
|
+
fanOutAttributeInSource,
|
|
10
|
+
linkedPartnerTargets,
|
|
11
|
+
useLinkedSpeedCommit,
|
|
12
|
+
withLinkedPlaybackRate,
|
|
13
|
+
type LinkedSpeedCommit,
|
|
14
|
+
} from "./linkedSpeedEdits";
|
|
15
|
+
import { useVolumeAutomation } from "./useVolumeAutomation";
|
|
16
|
+
import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
|
|
17
|
+
|
|
18
|
+
const clip = (id: string, tag: string, link?: string): TimelineElement => ({
|
|
19
|
+
id,
|
|
20
|
+
domId: id,
|
|
21
|
+
tag,
|
|
22
|
+
start: 0,
|
|
23
|
+
duration: 4,
|
|
24
|
+
track: 0,
|
|
25
|
+
...(link ? { link } : {}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const elements = [
|
|
29
|
+
clip("talk", "video", "lk-1"),
|
|
30
|
+
clip("talk-audio", "audio", "lk-1"),
|
|
31
|
+
clip("music", "audio"),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const source = `<div data-composition-id="main">
|
|
35
|
+
<video id="talk" data-start="0" data-duration="4" data-link="lk-1" muted></video>
|
|
36
|
+
<audio id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{"version":1,"lanes":[]}"></audio>
|
|
37
|
+
<audio id="music" data-start="0" data-duration="4"></audio>
|
|
38
|
+
</div>`;
|
|
39
|
+
|
|
40
|
+
function selection(id: string, dataAttributes: Record<string, string> = {}): DomEditSelection {
|
|
41
|
+
const element = document.createElement("video");
|
|
42
|
+
element.id = id;
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
element,
|
|
46
|
+
label: id,
|
|
47
|
+
tagName: "video",
|
|
48
|
+
sourceFile: "index.html",
|
|
49
|
+
compositionPath: "index.html",
|
|
50
|
+
isCompositionHost: false,
|
|
51
|
+
isInsideLockedComposition: false,
|
|
52
|
+
boundingBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
53
|
+
textContent: null,
|
|
54
|
+
dataAttributes: { start: "0", duration: "4", ...dataAttributes },
|
|
55
|
+
inlineStyles: {},
|
|
56
|
+
computedStyles: {},
|
|
57
|
+
textFields: [],
|
|
58
|
+
capabilities: {
|
|
59
|
+
canSelect: true,
|
|
60
|
+
canEditStyles: true,
|
|
61
|
+
canCrop: true,
|
|
62
|
+
canMove: true,
|
|
63
|
+
canResize: true,
|
|
64
|
+
canApplyManualOffset: true,
|
|
65
|
+
canApplyManualSize: true,
|
|
66
|
+
canApplyManualRotation: true,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("linkedPartnerTargets", () => {
|
|
72
|
+
it("returns the other data-link members and nothing for an unlinked clip", () => {
|
|
73
|
+
expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([{ id: "talk-audio" }]);
|
|
74
|
+
expect(linkedPartnerTargets({ id: "music" }, elements)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reaches no partner with Linked Selection off", () => {
|
|
78
|
+
useLinkedClipPreferences.getState().setLinkedSelection(false);
|
|
79
|
+
try {
|
|
80
|
+
expect(linkedPartnerTargets({ id: "talk" }, elements)).toEqual([]);
|
|
81
|
+
} finally {
|
|
82
|
+
useLinkedClipPreferences.getState().setLinkedSelection(true);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("fanOutAttributeInSource", () => {
|
|
88
|
+
it("writes each partner from its own current value", () => {
|
|
89
|
+
const seen: Array<string | undefined> = [];
|
|
90
|
+
const next = fanOutAttributeInSource(
|
|
91
|
+
source,
|
|
92
|
+
[{ id: "talk-audio" }],
|
|
93
|
+
"data-automation",
|
|
94
|
+
(raw) => {
|
|
95
|
+
seen.push(raw);
|
|
96
|
+
return "rate-lane";
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
expect(seen).toEqual(['{"version":1,"lanes":[]}']);
|
|
100
|
+
expect(next).toContain(
|
|
101
|
+
'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="rate-lane"',
|
|
102
|
+
);
|
|
103
|
+
expect(next).not.toContain('id="music" data-start="0" data-duration="4" data-automation');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const harness = createHappyDomRootHarness();
|
|
108
|
+
|
|
109
|
+
function renderLinked(sel: DomEditSelection, commit: CommitDomAttributeBatch) {
|
|
110
|
+
let linked: LinkedSpeedCommit | null = null;
|
|
111
|
+
let rate: ReturnType<typeof useVolumeAutomation>["rate"] | null = null;
|
|
112
|
+
function Probe() {
|
|
113
|
+
linked = useLinkedSpeedCommit(sel, commit);
|
|
114
|
+
rate = useVolumeAutomation(sel, 0, vi.fn(), linked).rate;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const host = document.createElement("div");
|
|
118
|
+
document.body.appendChild(host);
|
|
119
|
+
act(() => harness.mount(host).render(<Probe />));
|
|
120
|
+
return { linked: () => linked, rate: () => rate };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe("linked speed edits", () => {
|
|
124
|
+
it("fans a playback-rate edit out to the partner in one batch commit", async () => {
|
|
125
|
+
usePlayerStore.getState().setElements(elements);
|
|
126
|
+
const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
|
|
127
|
+
const onSetAttribute = vi.fn();
|
|
128
|
+
const probe = renderLinked(selection("talk"), commit);
|
|
129
|
+
await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2");
|
|
130
|
+
expect(onSetAttribute).not.toHaveBeenCalled();
|
|
131
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
132
|
+
const [, ops, options] = commit.mock.calls[0] ?? [];
|
|
133
|
+
expect(ops).toEqual([{ type: "attribute", property: "playback-rate", value: "2" }]);
|
|
134
|
+
expect(options?.prepareContent?.(source)).toContain(
|
|
135
|
+
'id="talk-audio" data-start="0" data-duration="4" data-link="lk-1" data-automation="{"version":1,"lanes":[]}" data-playback-rate="2"',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("fans a ramp preset out as one commit carrying each member's rate lane", () => {
|
|
140
|
+
usePlayerStore.getState().setElements(elements);
|
|
141
|
+
const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
|
|
142
|
+
const probe = renderLinked(selection("talk"), commit);
|
|
143
|
+
act(() => probe.rate()?.onApplyPreset("ramp-in"));
|
|
144
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
145
|
+
const [, ops, options] = commit.mock.calls[0] ?? [];
|
|
146
|
+
expect(ops?.[0]?.property).toBe("automation");
|
|
147
|
+
expect(String(ops?.[0]?.value)).toContain('"target":"rate"');
|
|
148
|
+
const partner = /id="talk-audio"[^>]*data-automation="([^"]*)"/.exec(
|
|
149
|
+
options?.prepareContent?.(source) ?? "",
|
|
150
|
+
)?.[1];
|
|
151
|
+
expect(partner).toContain("rate");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("leaves an unlinked clip on its usual write path", async () => {
|
|
155
|
+
usePlayerStore.getState().setElements(elements);
|
|
156
|
+
const commit = vi.fn<CommitDomAttributeBatch>(async () => true);
|
|
157
|
+
const onSetAttribute = vi.fn();
|
|
158
|
+
const probe = renderLinked(selection("music"), commit);
|
|
159
|
+
expect(probe.linked()).toBeNull();
|
|
160
|
+
await withLinkedPlaybackRate(onSetAttribute, probe.linked())("playback-rate", "2");
|
|
161
|
+
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "2");
|
|
162
|
+
expect(commit).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
2
|
+
import { expandToLinkedMembers } from "../../player/components/audioClipLink";
|
|
3
|
+
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
4
|
+
import {
|
|
5
|
+
applyPatchByTarget,
|
|
6
|
+
readAttributeByTarget,
|
|
7
|
+
type PatchTarget,
|
|
8
|
+
} from "../../utils/sourcePatcher";
|
|
9
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
10
|
+
import { buildPatchTarget } from "../../hooks/timelineEditingHelpers";
|
|
11
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
12
|
+
|
|
13
|
+
type AttributeValueFor = (current: string | undefined) => string | null;
|
|
14
|
+
|
|
15
|
+
/** Writes a speed attribute on the selection and every `data-link` partner as one undo step. */
|
|
16
|
+
export interface LinkedSpeedCommit {
|
|
17
|
+
commitAttribute: (attr: string, valueFor: AttributeValueFor, label: string) => Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
21
|
+
const dataProperty = (attr: string) => (attr.startsWith("data-") ? attr.slice(5) : attr);
|
|
22
|
+
|
|
23
|
+
function isSelected(el: TimelineElement, selection: PatchTarget): boolean {
|
|
24
|
+
if (selection.hfId && el.hfId) return el.hfId === selection.hfId;
|
|
25
|
+
return selection.id != null && (el.domId ?? el.id) === selection.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function linkedPartnerTargets(
|
|
29
|
+
selection: PatchTarget,
|
|
30
|
+
elements: readonly TimelineElement[],
|
|
31
|
+
): PatchTarget[] {
|
|
32
|
+
const self = elements.find((el) => isSelected(el, selection));
|
|
33
|
+
if (!self?.link) return [];
|
|
34
|
+
const members = expandToLinkedMembers([keyOf(self)], elements, isLinkedSelectionOn());
|
|
35
|
+
members.delete(keyOf(self));
|
|
36
|
+
return elements
|
|
37
|
+
.filter((el) => members.has(keyOf(el)))
|
|
38
|
+
.map(buildPatchTarget)
|
|
39
|
+
.filter((target): target is NonNullable<typeof target> => target !== null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fanOutAttributeInSource(
|
|
43
|
+
source: string,
|
|
44
|
+
targets: readonly PatchTarget[],
|
|
45
|
+
attr: string,
|
|
46
|
+
valueFor: AttributeValueFor,
|
|
47
|
+
): string {
|
|
48
|
+
const property = dataProperty(attr);
|
|
49
|
+
return targets.reduce(
|
|
50
|
+
(html, target) =>
|
|
51
|
+
applyPatchByTarget(html, target, {
|
|
52
|
+
type: "attribute",
|
|
53
|
+
property,
|
|
54
|
+
value: valueFor(readAttributeByTarget(html, target, attr)),
|
|
55
|
+
}),
|
|
56
|
+
source,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Null when the selection has no link partner, so unlinked clips keep their usual write path. */
|
|
61
|
+
export function useLinkedSpeedCommit(
|
|
62
|
+
selection: DomEditSelection,
|
|
63
|
+
commit: CommitDomAttributeBatch | undefined,
|
|
64
|
+
): LinkedSpeedCommit | null {
|
|
65
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
66
|
+
if (!commit) return null;
|
|
67
|
+
const partners = linkedPartnerTargets(selection, elements);
|
|
68
|
+
if (partners.length === 0) return null;
|
|
69
|
+
return {
|
|
70
|
+
commitAttribute: (attr, valueFor, label) => {
|
|
71
|
+
const property = dataProperty(attr);
|
|
72
|
+
return commit(
|
|
73
|
+
selection,
|
|
74
|
+
[{ type: "attribute", property, value: valueFor(selection.dataAttributes[property]) }],
|
|
75
|
+
{
|
|
76
|
+
label,
|
|
77
|
+
prepareContent: (html) => fanOutAttributeInSource(html, partners, attr, valueFor),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Routes `playback-rate` through the linked commit; every other attribute keeps `onSetAttribute`. */
|
|
85
|
+
export function withLinkedPlaybackRate(
|
|
86
|
+
onSetAttribute: (attr: string, value: string) => void | Promise<void>,
|
|
87
|
+
linked: LinkedSpeedCommit | null,
|
|
88
|
+
): (attr: string, value: string) => void | Promise<void> {
|
|
89
|
+
if (!linked) return onSetAttribute;
|
|
90
|
+
return async (attr, value) => {
|
|
91
|
+
if (attr !== "playback-rate") return onSetAttribute(attr, value);
|
|
92
|
+
await linked.commitAttribute("data-playback-rate", () => value, "Edit speed");
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -35,18 +35,23 @@ function Overlay() {
|
|
|
35
35
|
onPointerMove={marquee.onPointerMove}
|
|
36
36
|
onPointerUp={marquee.onPointerUp}
|
|
37
37
|
>
|
|
38
|
-
{marquee.marqueeRect && <div data-band />}
|
|
38
|
+
{marquee.marqueeRect && <div data-band data-width={marquee.marqueeRect.width} />}
|
|
39
39
|
</div>
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const pointer = (type: string, clientX: number, clientY: number) =>
|
|
44
44
|
act(() => {
|
|
45
|
-
document
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
document.querySelector("[data-overlay]")!.dispatchEvent(
|
|
46
|
+
new PointerEvent(type, {
|
|
47
|
+
bubbles: true,
|
|
48
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
49
|
+
button: 0,
|
|
50
|
+
pointerId: 1,
|
|
51
|
+
clientX,
|
|
52
|
+
clientY,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
50
55
|
});
|
|
51
56
|
const escape = () => {
|
|
52
57
|
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
|
@@ -86,6 +91,25 @@ it("an escape cancels a preview band while the pointer is still down, and stops
|
|
|
86
91
|
expect(hostEscapes).toHaveLength(0);
|
|
87
92
|
});
|
|
88
93
|
|
|
94
|
+
it("a buttonless move back at the start does not shrink the band", () => {
|
|
95
|
+
pointer("pointerdown", 10, 10);
|
|
96
|
+
pointer("pointermove", 120, 90);
|
|
97
|
+
const band = () => document.querySelector<HTMLElement>("[data-band]")?.dataset.width;
|
|
98
|
+
expect(band()).toBe("110");
|
|
99
|
+
act(() => {
|
|
100
|
+
document.querySelector("[data-overlay]")!.dispatchEvent(
|
|
101
|
+
new PointerEvent("pointermove", {
|
|
102
|
+
bubbles: true,
|
|
103
|
+
pointerId: 1,
|
|
104
|
+
buttons: 0,
|
|
105
|
+
clientX: 10,
|
|
106
|
+
clientY: 10,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
expect(band()).toBe("110");
|
|
111
|
+
});
|
|
112
|
+
|
|
89
113
|
it("an escape with no band in flight still reaches the host", () => {
|
|
90
114
|
const event = escape();
|
|
91
115
|
|
|
@@ -133,6 +157,7 @@ it("a reload promoted mid-band selects from the preview on screen, not the retir
|
|
|
133
157
|
new PointerEvent(type, {
|
|
134
158
|
bubbles: true,
|
|
135
159
|
button: 0,
|
|
160
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
136
161
|
pointerId: 1,
|
|
137
162
|
clientX: x,
|
|
138
163
|
clientY: y,
|
|
@@ -6,6 +6,7 @@ import { isElementComputedVisible } from "./domEditingElement";
|
|
|
6
6
|
import { coversComposition } from "../../utils/studioPreviewHelpers";
|
|
7
7
|
import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
|
|
8
8
|
import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
|
|
9
|
+
import { movesGesture } from "./domEditOverlayGestures";
|
|
9
10
|
|
|
10
11
|
interface MarqueeState {
|
|
11
12
|
startX: number;
|
|
@@ -226,10 +227,23 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
226
227
|
window.removeEventListener("keydown", cancelBandBeforeHostEscape, { capture: true });
|
|
227
228
|
}, [cancel]);
|
|
228
229
|
|
|
230
|
+
const showCandidates = useCallback(
|
|
231
|
+
(rect: Rect) => {
|
|
232
|
+
const iframe = deps.iframeRef.current;
|
|
233
|
+
const overlay = deps.overlayRef.current;
|
|
234
|
+
if (!iframe || !overlay) return;
|
|
235
|
+
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
236
|
+
candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
|
|
237
|
+
setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
|
|
238
|
+
},
|
|
239
|
+
[deps.overlayRef, deps.iframeRef, deps.activeCompositionPathRef],
|
|
240
|
+
);
|
|
241
|
+
|
|
229
242
|
const onPointerMove = useCallback(
|
|
230
243
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
231
244
|
const m = marqueeRef.current;
|
|
232
245
|
if (m) {
|
|
246
|
+
if (!movesGesture(m, event)) return;
|
|
233
247
|
const oRect = deps.overlayRef.current?.getBoundingClientRect();
|
|
234
248
|
if (!oRect) return;
|
|
235
249
|
m.currentX = event.clientX - oRect.left;
|
|
@@ -248,18 +262,12 @@ export function useMarqueeGestures<T>(deps: MarqueeGesturesDeps<T>): MarqueeGest
|
|
|
248
262
|
height: Math.abs(m.currentY - m.startY),
|
|
249
263
|
};
|
|
250
264
|
setMarqueeRect(rect);
|
|
251
|
-
|
|
252
|
-
const overlay = deps.overlayRef.current;
|
|
253
|
-
if (iframe && overlay) {
|
|
254
|
-
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
255
|
-
candidatesRef.current ??= collectMarqueeCandidates(iframe, overlay, acp);
|
|
256
|
-
setCandidateRects(hitsWithin(rect, candidatesRef.current).map((h) => h.rect));
|
|
257
|
-
}
|
|
265
|
+
showCandidates(rect);
|
|
258
266
|
return;
|
|
259
267
|
}
|
|
260
268
|
deps.gestures?.onPointerMove(event);
|
|
261
269
|
},
|
|
262
|
-
[deps.gestures, deps.overlayRef,
|
|
270
|
+
[deps.gestures, deps.overlayRef, showCandidates],
|
|
263
271
|
);
|
|
264
272
|
|
|
265
273
|
const onPointerUp = useCallback(
|
|
@@ -88,9 +88,9 @@ describe("cutoutOps", () => {
|
|
|
88
88
|
describe("mintLinkId", () => {
|
|
89
89
|
it("dedupes against element ids and existing links", () => {
|
|
90
90
|
const doc = document.implementation.createHTMLDocument("t");
|
|
91
|
-
expect(mintLinkId(doc)).toBe("
|
|
92
|
-
doc.body.innerHTML = '<div id="
|
|
93
|
-
expect(mintLinkId(doc)).toBe("
|
|
91
|
+
expect(mintLinkId(doc)).toBe("lk-1");
|
|
92
|
+
doc.body.innerHTML = '<div id="lk-1"></div><video data-link="lk-2"></video>';
|
|
93
|
+
expect(mintLinkId(doc)).toBe("lk-3");
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
96
|
|
|
@@ -176,10 +176,26 @@ describe("buildKeepSoundCutoutEdit", () => {
|
|
|
176
176
|
{ target: "rate", points: [{ t: 0, v: 2 }] },
|
|
177
177
|
]);
|
|
178
178
|
expect(audio.getAttribute("data-link")).toBe(video.getAttribute("data-link"));
|
|
179
|
-
expect(video.getAttribute("data-link")).toBe("
|
|
179
|
+
expect(video.getAttribute("data-link")).toBe("lk-1");
|
|
180
|
+
expect(video.getAttribute("data-sync-origin")).toBe("lk-1");
|
|
181
|
+
expect(audio.getAttribute("data-sync-origin")).toBe("lk-1");
|
|
180
182
|
expect(audio.parentElement).toBe(video.parentElement);
|
|
181
183
|
});
|
|
182
184
|
|
|
185
|
+
it("keeps a hidden video's sound silent on the linked audio", () => {
|
|
186
|
+
const video = liveVideo();
|
|
187
|
+
video.setAttribute("data-hidden", "");
|
|
188
|
+
const edit = buildKeepSoundCutoutEdit({
|
|
189
|
+
video,
|
|
190
|
+
videoId: "clip",
|
|
191
|
+
target: { id: "clip" },
|
|
192
|
+
cutoutSrc: "assets/talk-cutout.webm",
|
|
193
|
+
});
|
|
194
|
+
const hidden = source.replace('<video id="clip"', '<video id="clip" data-hidden');
|
|
195
|
+
const doc = parse(applyEdit(hidden, edit));
|
|
196
|
+
expect(doc.getElementById("clip-audio")?.hasAttribute("data-hidden")).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
183
199
|
it("drops the video's automation attribute when it held no rate lane", () => {
|
|
184
200
|
const video = makeVideo({
|
|
185
201
|
id: "clip",
|
|
@@ -23,13 +23,17 @@ import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
|
23
23
|
import { buildDomEditPatchTarget, type DomEditSelection } from "./domEditing";
|
|
24
24
|
import { generateId } from "../../utils/generateId";
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
import {
|
|
27
|
+
MEDIA_LINK_ATTR,
|
|
28
|
+
SYNC_ORIGIN_ATTR,
|
|
29
|
+
mintLinkId as mintLinkIdFrom,
|
|
30
|
+
} from "@hyperframes/core/media-link";
|
|
27
31
|
const SOUND_KEPT_ON_LINKED_AUDIO_STAGE = "Background removed. Sound kept on a linked audio track.";
|
|
28
32
|
|
|
29
33
|
const SOUND_NOT_KEPT_MESSAGE =
|
|
30
34
|
"Background removed, but the sound could not be kept on a linked audio track. Undo to restore the original clip.";
|
|
31
35
|
|
|
32
|
-
const MOVED_SOUND_ATTRS = [
|
|
36
|
+
export const MOVED_SOUND_ATTRS = [
|
|
33
37
|
"data-volume",
|
|
34
38
|
HF_AUDIO_FADE_IN_ATTR,
|
|
35
39
|
HF_AUDIO_FADE_OUT_ATTR,
|
|
@@ -38,7 +42,7 @@ const MOVED_SOUND_ATTRS = [
|
|
|
38
42
|
HF_AUDIO_GROUP_ATTR,
|
|
39
43
|
];
|
|
40
44
|
|
|
41
|
-
const COPIED_TIMING_ATTRS = [
|
|
45
|
+
export const COPIED_TIMING_ATTRS = [
|
|
42
46
|
"start",
|
|
43
47
|
"end",
|
|
44
48
|
"duration",
|
|
@@ -91,7 +95,7 @@ export function cutoutOps(input: { isVideo: boolean; cutoutSrc: string }): Patch
|
|
|
91
95
|
return input.isVideo ? [src, htmlAttr("muted", "true"), dataAttr("has-audio", null)] : [src];
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
function firstFreeName(base: string, taken: Set<string>): string {
|
|
98
|
+
export function firstFreeName(base: string, taken: Set<string>): string {
|
|
95
99
|
if (!taken.has(base)) return base;
|
|
96
100
|
let n = 2;
|
|
97
101
|
while (taken.has(`${base}-${n}`)) n += 1;
|
|
@@ -100,12 +104,16 @@ function firstFreeName(base: string, taken: Set<string>): string {
|
|
|
100
104
|
|
|
101
105
|
export function mintLinkId(doc: Document): string {
|
|
102
106
|
const taken = new Set<string>();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
+
const selector = `[id], [${MEDIA_LINK_ATTR}], [${SYNC_ORIGIN_ATTR}]`;
|
|
108
|
+
for (const el of Array.from(doc.querySelectorAll(selector))) {
|
|
109
|
+
for (const value of [
|
|
110
|
+
el.id,
|
|
111
|
+
el.getAttribute(MEDIA_LINK_ATTR),
|
|
112
|
+
el.getAttribute(SYNC_ORIGIN_ATTR),
|
|
113
|
+
])
|
|
114
|
+
if (value) taken.add(value);
|
|
107
115
|
}
|
|
108
|
-
return
|
|
116
|
+
return mintLinkIdFrom(taken);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
function firstFreeTrackIndex(source: string): number {
|
|
@@ -117,7 +125,7 @@ function firstFreeTrackIndex(source: string): number {
|
|
|
117
125
|
return track;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
|
|
128
|
+
export function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes: string } {
|
|
121
129
|
let lanes: HfAutomationLane[];
|
|
122
130
|
try {
|
|
123
131
|
lanes = parseAutomation(raw).lanes;
|
|
@@ -132,11 +140,11 @@ function splitAutomation(raw: string): { videoKeeps: string | null; audioTakes:
|
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
function formatAttrs(attrs: Array<[string, string]>): string {
|
|
143
|
+
export function formatAttrs(attrs: Array<[string, string]>): string {
|
|
136
144
|
return attrs.map(([name, value]) => `${name}="${escapeHtmlAttribute(value)}"`).join(" ");
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
147
|
+
export function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
140
148
|
const tag = findTagByTarget(source, target);
|
|
141
149
|
if (!tag) return "";
|
|
142
150
|
const own = /\bsrc=(["'])([^"']*)\1/.exec(tag.tag);
|
|
@@ -145,7 +153,7 @@ function readAuthoredSrc(source: string, target: PatchTarget): string {
|
|
|
145
153
|
return firstSource?.[2] ? unescapeHtmlAttribute(firstSource[2]) : "";
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
156
|
+
export function insertBeforeTarget(source: string, target: PatchTarget, markup: string): string {
|
|
149
157
|
const match = findTagByTarget(source, target);
|
|
150
158
|
if (!match) return source;
|
|
151
159
|
const lineStart = source.lastIndexOf("\n", match.start) + 1;
|
|
@@ -187,7 +195,10 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
187
195
|
movedSound.push([HF_AUDIO_AUTOMATION_ATTR, audioTakes]);
|
|
188
196
|
ops.push(dataAttr(stripDataPrefix(HF_AUDIO_AUTOMATION_ATTR), videoKeeps));
|
|
189
197
|
}
|
|
190
|
-
ops.push(
|
|
198
|
+
ops.push(
|
|
199
|
+
dataAttr(stripDataPrefix(MEDIA_LINK_ATTR), linkId),
|
|
200
|
+
dataAttr(stripDataPrefix(SYNC_ORIGIN_ATTR), linkId),
|
|
201
|
+
);
|
|
191
202
|
|
|
192
203
|
const prepareContent = (source: string): string => {
|
|
193
204
|
const originalSrc = readAuthoredSrc(source, target);
|
|
@@ -208,9 +219,11 @@ export function buildKeepSoundCutoutEdit(input: {
|
|
|
208
219
|
...timing,
|
|
209
220
|
["data-track-index", String(firstFreeTrackIndex(source))],
|
|
210
221
|
[MEDIA_LINK_ATTR, linkId],
|
|
222
|
+
[SYNC_ORIGIN_ATTR, linkId],
|
|
211
223
|
...movedSound,
|
|
212
224
|
];
|
|
213
225
|
if (video.hasAttribute("loop")) attrs.push(["loop", ""]);
|
|
226
|
+
if (video.hasAttribute("data-hidden")) attrs.push(["data-hidden", ""]);
|
|
214
227
|
const cut = applyPatchByTarget(source, target, cutoutSrcOp);
|
|
215
228
|
const inserted = insertBeforeTarget(cut, target, `<audio ${formatAttrs(attrs)}></audio>`);
|
|
216
229
|
audioInserted = inserted !== cut;
|