@hyperframes/studio 0.8.108 → 0.8.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
- 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 +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- 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/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/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -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 +3 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -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.ts +9 -0
- 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 +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- 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/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +6 -0
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- 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/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
|
|
|
9
9
|
import { canSplitElement } from "../utils/timelineElementSplit";
|
|
10
10
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
11
|
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
12
|
+
import type { LinkShortcutCallbacks } from "./linkShortcuts";
|
|
12
13
|
|
|
13
14
|
// Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
|
|
14
15
|
// following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
|
|
@@ -29,7 +30,7 @@ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: ()
|
|
|
29
30
|
return false;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export interface HotkeyCallbacks {
|
|
33
|
+
export interface HotkeyCallbacks extends LinkShortcutCallbacks {
|
|
33
34
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
34
35
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
35
36
|
handleDomEditElementDelete: (
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
|
+
import { dispatchLinkShortcut } from "./linkShortcuts";
|
|
5
|
+
|
|
6
|
+
const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
7
|
+
id,
|
|
8
|
+
tag,
|
|
9
|
+
src: "talk.mp4",
|
|
10
|
+
start: 0,
|
|
11
|
+
duration: 4,
|
|
12
|
+
track: 0,
|
|
13
|
+
...extra,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function key(init: KeyboardEventInit): KeyboardEvent {
|
|
17
|
+
const event = new KeyboardEvent("keydown", { cancelable: true, ...init });
|
|
18
|
+
vi.spyOn(event, "preventDefault");
|
|
19
|
+
return event;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function select(elements: TimelineElement[], id: string) {
|
|
23
|
+
usePlayerStore.getState().setElements(elements);
|
|
24
|
+
usePlayerStore.getState().setSelection([id], id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("dispatchLinkShortcut", () => {
|
|
28
|
+
afterEach(() => vi.restoreAllMocks());
|
|
29
|
+
|
|
30
|
+
it("⌘L unlinks the selected linked clip", () => {
|
|
31
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
32
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
33
|
+
select([video, audio], "talk");
|
|
34
|
+
const handleLinkEdit = vi.fn();
|
|
35
|
+
const event = key({ metaKey: true, key: "l" });
|
|
36
|
+
expect(dispatchLinkShortcut(event, { handleLinkEdit })).toBe(true);
|
|
37
|
+
expect(handleLinkEdit).toHaveBeenCalledWith({ kind: "unlink", elements: [video, audio] });
|
|
38
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("⌥⇧D detaches audio, read by key code since Option changes the character", () => {
|
|
42
|
+
const talk = clip("talk", "video", { hasAudio: true });
|
|
43
|
+
select([talk], "talk");
|
|
44
|
+
const handleLinkEdit = vi.fn();
|
|
45
|
+
expect(
|
|
46
|
+
dispatchLinkShortcut(key({ altKey: true, shiftKey: true, key: "Î", code: "KeyD" }), {
|
|
47
|
+
handleLinkEdit,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(true);
|
|
50
|
+
expect(handleLinkEdit).toHaveBeenCalledWith({ kind: "detach", element: talk });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("⌥⌫ deletes one linked member", () => {
|
|
54
|
+
const video = clip("talk", "video", { muted: true, link: "lk-1" });
|
|
55
|
+
select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
|
|
56
|
+
const handleTimelineElementDeleteOnly = vi.fn();
|
|
57
|
+
dispatchLinkShortcut(key({ altKey: true, key: "Backspace" }), {
|
|
58
|
+
handleLinkEdit: vi.fn(),
|
|
59
|
+
handleTimelineElementDeleteOnly,
|
|
60
|
+
});
|
|
61
|
+
expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves the key alone when no link item applies", () => {
|
|
65
|
+
select([clip("title", "div")], "title");
|
|
66
|
+
const event = key({ metaKey: true, key: "l" });
|
|
67
|
+
expect(dispatchLinkShortcut(event, { handleLinkEdit: vi.fn() })).toBe(false);
|
|
68
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { usePlayerStore } from "../player";
|
|
2
|
+
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
|
|
3
|
+
import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
|
|
4
|
+
|
|
5
|
+
export interface LinkShortcutCallbacks {
|
|
6
|
+
handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
|
|
7
|
+
handleTimelineElementDeleteOnly?: TimelineEditCallbacks["onDeleteElementOnly"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const isDeleteKey = (event: KeyboardEvent) => event.key === "Backspace" || event.key === "Delete";
|
|
11
|
+
const hasMod = (event: KeyboardEvent) => event.metaKey || event.ctrlKey;
|
|
12
|
+
|
|
13
|
+
const SHORTCUTS: ReadonlyArray<[string, (event: KeyboardEvent) => boolean]> = [
|
|
14
|
+
["⌘L", (e) => hasMod(e) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "l"],
|
|
15
|
+
["⌥⇧D", (e) => !hasMod(e) && e.altKey && e.shiftKey && e.code === "KeyD"],
|
|
16
|
+
["⌥⌫", (e) => !hasMod(e) && e.altKey && !e.shiftKey && isDeleteKey(e)],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function shortcutOf(event: KeyboardEvent): string | null {
|
|
20
|
+
return SHORTCUTS.find(([, matches]) => matches(event))?.[0] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
|
|
24
|
+
export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
|
|
25
|
+
const shortcut = shortcutOf(event);
|
|
26
|
+
if (!shortcut) return false;
|
|
27
|
+
const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
|
|
28
|
+
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
29
|
+
if (!element) return false;
|
|
30
|
+
const item = resolveLinkMenuItems({
|
|
31
|
+
element,
|
|
32
|
+
elements,
|
|
33
|
+
selectedKeys: selectedElementIds,
|
|
34
|
+
onLinkEdit: cb.handleLinkEdit,
|
|
35
|
+
onDeleteElementOnly: cb.handleTimelineElementDeleteOnly,
|
|
36
|
+
}).find((candidate) => candidate.shortcut === shortcut);
|
|
37
|
+
if (!item) return false;
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
item.run();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { TimelineElement } from "../player";
|
|
4
|
+
import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
|
|
5
|
+
|
|
6
|
+
const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
|
|
7
|
+
id,
|
|
8
|
+
domId: id,
|
|
9
|
+
tag,
|
|
10
|
+
src: "talk.mp4",
|
|
11
|
+
start: 2,
|
|
12
|
+
duration: 6,
|
|
13
|
+
track: tag === "audio" ? 1 : 0,
|
|
14
|
+
...extra,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const video = clip("talk", "video", { link: "lk-1", muted: true });
|
|
18
|
+
const audio = clip("talk-audio", "audio", { link: "lk-1" });
|
|
19
|
+
const title = clip("title", "div");
|
|
20
|
+
|
|
21
|
+
const SOURCE =
|
|
22
|
+
'<div><video id="talk" src="talk.mp4" muted data-link="lk-1" data-start="2" data-duration="6"></video><audio id="talk-audio" src="talk.mp4" data-link="lk-1" data-start="2" data-duration="6" data-volume="0.5"></audio></div>';
|
|
23
|
+
|
|
24
|
+
describe("clipsToUnlink", () => {
|
|
25
|
+
it("unlinks a lone removed member and its orphaned partner", () => {
|
|
26
|
+
expect(clipsToUnlink([video], [video, audio, title]).map((el) => el.id)).toEqual([
|
|
27
|
+
"talk",
|
|
28
|
+
"talk-audio",
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps a three-member group linked when one leaves", () => {
|
|
33
|
+
const third = clip("talk-2", "audio", { link: "lk-1" });
|
|
34
|
+
expect(clipsToUnlink([video], [video, audio, third]).map((el) => el.id)).toEqual(["talk"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("ignores unlinked clips", () => {
|
|
38
|
+
expect(clipsToUnlink([title], [video, audio, title])).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("never reaches a same-id link in another source file", () => {
|
|
42
|
+
const childVideo = clip("c", "video", { link: "lk-1", sourceFile: "child.html" });
|
|
43
|
+
const removed = clipsToUnlink([video], [video, audio, childVideo]).map((el) => el.id);
|
|
44
|
+
expect(removed).toEqual(["talk", "talk-audio"]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("planLinkEdit", () => {
|
|
49
|
+
const elements = [video, audio, title];
|
|
50
|
+
|
|
51
|
+
it("unlink strips data-link from both members", () => {
|
|
52
|
+
const plan = planLinkEdit({ kind: "unlink", elements: [video, audio] }, elements);
|
|
53
|
+
expect(plan?.label).toBe("Unlink clips");
|
|
54
|
+
expect(plan?.transform(SOURCE)).not.toContain("data-link");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("link writes one fresh id on both", () => {
|
|
58
|
+
const unlinked = SOURCE.replaceAll(' data-link="lk-1"', "");
|
|
59
|
+
const plan = planLinkEdit({ kind: "link", elements: [video, audio] }, elements);
|
|
60
|
+
expect(plan?.transform(unlinked)?.match(/data-link="lk-1"/g)).toHaveLength(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("merge folds the audio into the video", () => {
|
|
64
|
+
const plan = planLinkEdit({ kind: "merge", video, audio }, elements);
|
|
65
|
+
const merged = plan?.transform(SOURCE) ?? "";
|
|
66
|
+
expect(merged).not.toContain("<audio");
|
|
67
|
+
expect(merged).toContain('data-volume="0.5"');
|
|
68
|
+
expect(merged).toContain('data-has-audio="true"');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("detach puts the audio on the first free audio track", () => {
|
|
72
|
+
const talk = clip("talk", "video", { hasAudio: true });
|
|
73
|
+
const src =
|
|
74
|
+
'<div><video id="talk" src="talk.mp4" data-has-audio="true" data-start="2" data-duration="6"></video></div>';
|
|
75
|
+
const plan = planLinkEdit({ kind: "detach", element: talk }, [talk, title]);
|
|
76
|
+
const out = plan?.transform(src) ?? "";
|
|
77
|
+
expect(out).toContain('id="talk-audio"');
|
|
78
|
+
expect(out).toContain('data-track-index="1"');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("planLinkEdit into sync", () => {
|
|
83
|
+
const drifted =
|
|
84
|
+
'<div data-composition-id="main" data-duration="10"><video id="talk" src="talk.mp4" muted data-sync-origin="lk-1" data-start="2" data-duration="6"></video><audio id="talk-audio" src="talk.mp4" data-sync-origin="lk-1" data-start="2.5" data-duration="6" data-media-start="0"></audio></div>';
|
|
85
|
+
const late = clip("talk-audio", "audio", { start: 2.5, playbackStart: 0 });
|
|
86
|
+
|
|
87
|
+
it("moves one clip's start, leaving its partner and link alone", () => {
|
|
88
|
+
const plan = planLinkEdit({ kind: "move-into-sync", element: late, start: 2 }, [video, late]);
|
|
89
|
+
expect(plan?.label).toBe("Move into Sync");
|
|
90
|
+
const html = plan?.transform(drifted) ?? "";
|
|
91
|
+
expect(html).toContain('id="talk-audio" src="talk.mp4" data-sync-origin="lk-1" data-start="2"');
|
|
92
|
+
expect(html).toContain('id="talk" src="talk.mp4" muted data-sync-origin="lk-1" data-start="2"');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("slips one clip's media start in place", () => {
|
|
96
|
+
const plan = planLinkEdit({ kind: "slip-into-sync", element: late, mediaStart: 0.5 }, [
|
|
97
|
+
video,
|
|
98
|
+
late,
|
|
99
|
+
]);
|
|
100
|
+
expect(plan?.label).toBe("Slip into Sync");
|
|
101
|
+
const html = plan?.transform(drifted) ?? "";
|
|
102
|
+
expect(html).toContain('data-start="2.5" data-duration="6" data-media-start="0.5"');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("slips the legacy playback-start attribute when that is what the clip uses", () => {
|
|
106
|
+
const legacy = { ...late, playbackStartAttr: "playback-start" as const };
|
|
107
|
+
const html =
|
|
108
|
+
planLinkEdit({ kind: "slip-into-sync", element: legacy, mediaStart: 0.5 }, [])?.transform(
|
|
109
|
+
'<audio id="talk-audio" data-playback-start="0"></audio>',
|
|
110
|
+
) ?? "";
|
|
111
|
+
expect(html).toBe('<audio id="talk-audio" data-playback-start="0.5"></audio>');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { TimelineElement } from "../player";
|
|
2
|
+
import type { TimelineLinkEdit } from "../player/components/timelineCallbacks";
|
|
3
|
+
import { buildPatchTarget, type PatchTarget } from "./timelineEditingHelpers";
|
|
4
|
+
import { sharesLinkGroup } from "../player/components/audioClipLink";
|
|
5
|
+
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
6
|
+
import { formatTimelineMediaOffset } from "../player/components/timelineEditing";
|
|
7
|
+
import { applyPatchByTarget } from "../utils/sourcePatcher";
|
|
8
|
+
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
9
|
+
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
10
|
+
import {
|
|
11
|
+
detachAudioInSource,
|
|
12
|
+
linkInSource,
|
|
13
|
+
mergeAudioInSource,
|
|
14
|
+
pickDetachedAudioTrack,
|
|
15
|
+
setLinkInSource,
|
|
16
|
+
} from "../components/editor/mediaLinkEdits";
|
|
17
|
+
|
|
18
|
+
export interface LinkEditPlan {
|
|
19
|
+
label: string;
|
|
20
|
+
anchor: TimelineElement;
|
|
21
|
+
transform: (source: string) => string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
25
|
+
|
|
26
|
+
function targetsOf(elements: readonly TimelineElement[]): PatchTarget[] | null {
|
|
27
|
+
const targets = elements.map(buildPatchTarget);
|
|
28
|
+
return targets.every((target): target is PatchTarget => target !== null) ? targets : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The clips to strip `data-link` from when `removed` leave their groups: the
|
|
33
|
+
* removed clips themselves, plus any partner left alone in its group.
|
|
34
|
+
*/
|
|
35
|
+
export function clipsToUnlink(
|
|
36
|
+
removed: readonly TimelineElement[],
|
|
37
|
+
elements: readonly TimelineElement[],
|
|
38
|
+
): TimelineElement[] {
|
|
39
|
+
const removedKeys = new Set(removed.map(keyOf));
|
|
40
|
+
const orphans = new Map<string, TimelineElement>();
|
|
41
|
+
for (const leaving of removed) {
|
|
42
|
+
const survivors = elements.filter(
|
|
43
|
+
(el) => sharesLinkGroup(el, leaving) && !removedKeys.has(keyOf(el)),
|
|
44
|
+
);
|
|
45
|
+
if (survivors.length === 1 && survivors[0]) orphans.set(keyOf(survivors[0]), survivors[0]);
|
|
46
|
+
}
|
|
47
|
+
return [...removed.filter((el) => el.link), ...orphans.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SyncEdit = Extract<TimelineLinkEdit, { kind: "move-into-sync" | "slip-into-sync" }>;
|
|
51
|
+
|
|
52
|
+
function planSyncEdit(edit: SyncEdit): LinkEditPlan | null {
|
|
53
|
+
const target = buildPatchTarget(edit.element);
|
|
54
|
+
if (!target) return null;
|
|
55
|
+
if (edit.kind === "move-into-sync") {
|
|
56
|
+
const value = formatTimelineMediaOffset(toAuthoredStart(edit.element, edit.start));
|
|
57
|
+
return {
|
|
58
|
+
label: "Move into Sync",
|
|
59
|
+
anchor: edit.element,
|
|
60
|
+
transform: (s) => {
|
|
61
|
+
const moved = applyPatchByTarget(s, target, {
|
|
62
|
+
type: "attribute",
|
|
63
|
+
property: "start",
|
|
64
|
+
value,
|
|
65
|
+
});
|
|
66
|
+
return setCompositionDurationToContent(moved, furthestClipEndFromSource(moved));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const property = edit.element.playbackStartAttr ?? "media-start";
|
|
71
|
+
const value = formatTimelineMediaOffset(edit.mediaStart);
|
|
72
|
+
return {
|
|
73
|
+
label: "Slip into Sync",
|
|
74
|
+
anchor: edit.element,
|
|
75
|
+
transform: (s) => applyPatchByTarget(s, target, { type: "attribute", property, value }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function planLinkEdit(
|
|
80
|
+
edit: TimelineLinkEdit,
|
|
81
|
+
elements: readonly TimelineElement[],
|
|
82
|
+
): LinkEditPlan | null {
|
|
83
|
+
switch (edit.kind) {
|
|
84
|
+
case "unlink": {
|
|
85
|
+
const members = clipsToUnlink(edit.elements, elements);
|
|
86
|
+
const targets = targetsOf(members);
|
|
87
|
+
const anchor = members[0];
|
|
88
|
+
if (!targets || !anchor) return null;
|
|
89
|
+
return { label: "Unlink clips", anchor, transform: (s) => setLinkInSource(s, targets, null) };
|
|
90
|
+
}
|
|
91
|
+
case "link": {
|
|
92
|
+
const targets = targetsOf(edit.elements);
|
|
93
|
+
const anchor = edit.elements[0];
|
|
94
|
+
if (!targets || !anchor) return null;
|
|
95
|
+
return { label: "Link clips", anchor, transform: (s) => linkInSource(s, targets) };
|
|
96
|
+
}
|
|
97
|
+
case "detach": {
|
|
98
|
+
const target = buildPatchTarget(edit.element);
|
|
99
|
+
if (!target) return null;
|
|
100
|
+
const track = pickDetachedAudioTrack(elements, edit.element);
|
|
101
|
+
return {
|
|
102
|
+
label: "Detach audio",
|
|
103
|
+
anchor: edit.element,
|
|
104
|
+
transform: (s) =>
|
|
105
|
+
detachAudioInSource(s, { target, videoId: edit.element.domId ?? null, track })?.html ??
|
|
106
|
+
null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case "move-into-sync":
|
|
110
|
+
case "slip-into-sync":
|
|
111
|
+
return planSyncEdit(edit);
|
|
112
|
+
case "merge": {
|
|
113
|
+
const videoTarget = buildPatchTarget(edit.video);
|
|
114
|
+
const audioTarget = buildPatchTarget(edit.audio);
|
|
115
|
+
if (!videoTarget || !audioTarget) return null;
|
|
116
|
+
return {
|
|
117
|
+
label: "Merge audio back into video",
|
|
118
|
+
anchor: edit.video,
|
|
119
|
+
transform: (s) => mergeAudioInSource(s, { videoTarget, audioTarget }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
1
2
|
import type { TimelineElement } from "../player";
|
|
2
3
|
import type {
|
|
3
4
|
TimelineGroupCommitOptions,
|
|
@@ -45,3 +46,15 @@ export function persistTimelineMoveEditsAtomically(
|
|
|
45
46
|
{ coalesceKey, coalesceMs },
|
|
46
47
|
);
|
|
47
48
|
}
|
|
49
|
+
|
|
50
|
+
export function useTimelineMoveEditsHandler(
|
|
51
|
+
handleTimelineGroupMove: AtomicMoveDeps["handleTimelineGroupMove"],
|
|
52
|
+
): TimelineMoveEditsHandler {
|
|
53
|
+
return useCallback(
|
|
54
|
+
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
|
|
55
|
+
const deps = { handleTimelineGroupMove };
|
|
56
|
+
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
|
|
57
|
+
},
|
|
58
|
+
[handleTimelineGroupMove],
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -548,6 +548,65 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
|
|
|
548
548
|
});
|
|
549
549
|
});
|
|
550
550
|
|
|
551
|
+
describe("a timing edit's soft reload restores what GSAP wrote from the files", () => {
|
|
552
|
+
const script = (at: number) =>
|
|
553
|
+
`var tl = gsap.timeline({ paused: true }); tl.to("#wt", { width: 450 }, ${at}); tl.to("#nwid", { width: 400 }, ${at}); window.__timelines["root"] = tl;`;
|
|
554
|
+
const SUB = `<template><div data-composition-id="sub"><div id="nwid" data-hf-id="hf-n" style="left: 40px"></div></div></template>`;
|
|
555
|
+
|
|
556
|
+
// The live preview: the top-level timeline tweened a root element's and a nested element's width inline.
|
|
557
|
+
async function shift(fileOk: boolean) {
|
|
558
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
559
|
+
doc.body.innerHTML =
|
|
560
|
+
`<div id="root" data-composition-id="root"><div id="wt" data-hf-id="hf-w" style="left: 700px; width: 366px"></div>` +
|
|
561
|
+
`<div data-composition-file="compositions/sub.html"><div id="nwid" data-hf-id="hf-n" style="left: 40px; width: 288px"></div></div></div>` +
|
|
562
|
+
`<script>${script(0)}</script>`;
|
|
563
|
+
const [wt, nwid] = [doc.getElementById("wt")!, doc.getElementById("nwid")!];
|
|
564
|
+
const contentWindow = {
|
|
565
|
+
gsap: { timeline: vi.fn(), set: vi.fn() },
|
|
566
|
+
__hfForceTimelineRebind: vi.fn(),
|
|
567
|
+
__timelines: {
|
|
568
|
+
root: {
|
|
569
|
+
kill: vi.fn(),
|
|
570
|
+
getChildren: () => [{ targets: () => [wt, nwid], vars: { width: 450 } }],
|
|
571
|
+
},
|
|
572
|
+
} as Record<string, unknown>,
|
|
573
|
+
__player: { getTime: () => 1, seek: vi.fn() },
|
|
574
|
+
};
|
|
575
|
+
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
576
|
+
const fetchMock = vi.fn(async () =>
|
|
577
|
+
fileOk ? jsonResponse({ content: SUB }) : new Response("{}", { status: 404 }),
|
|
578
|
+
);
|
|
579
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
580
|
+
const after = `<html><body><div id="root" data-composition-id="root"><div id="wt" data-hf-id="hf-w" style="left: 700px"></div></div><script>${script(1)}</script></body></html>`;
|
|
581
|
+
const reloadPreview = vi.fn();
|
|
582
|
+
|
|
583
|
+
await finishClipTimingFallback({
|
|
584
|
+
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
|
|
585
|
+
iframe,
|
|
586
|
+
sdkGsap: { mutated: true, scriptText: script(1), after },
|
|
587
|
+
});
|
|
588
|
+
return { wt, nwid, reloadPreview, fetchMock };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
it("puts back the root element from the file it wrote and the nested one from its own file", async () => {
|
|
592
|
+
const { wt, nwid, reloadPreview, fetchMock } = await shift(true);
|
|
593
|
+
|
|
594
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
595
|
+
expect.stringContaining("/files/compositions%2Fsub.html"),
|
|
596
|
+
);
|
|
597
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
598
|
+
expect(wt.getAttribute("style")).toBe("left: 700px;");
|
|
599
|
+
expect(nwid.getAttribute("style")).toBe("left: 40px;");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("reloads the preview in full when the nested file cannot be read", async () => {
|
|
603
|
+
const { nwid, reloadPreview } = await shift(false);
|
|
604
|
+
|
|
605
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
606
|
+
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 288px");
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
551
610
|
function installOwnedFileServer(
|
|
552
611
|
contents: Map<string, string>,
|
|
553
612
|
options: {
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
applySoftReload,
|
|
8
8
|
applySoftReloadFinalization,
|
|
9
9
|
extractGsapScriptText,
|
|
10
|
+
readNestedFiles,
|
|
11
|
+
settleNestedReads,
|
|
10
12
|
} from "../utils/gsapSoftReload";
|
|
11
13
|
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
|
|
12
14
|
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
@@ -118,6 +120,7 @@ function syncTimingEditPreview(
|
|
|
118
120
|
currentTime: number,
|
|
119
121
|
reloadPreview: () => void,
|
|
120
122
|
rebindWhenUnmutated: boolean,
|
|
123
|
+
nestedFiles: Map<string, string> | null | undefined,
|
|
121
124
|
): void {
|
|
122
125
|
if (!outcome.mutated && rebindWhenUnmutated) {
|
|
123
126
|
if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
|
|
@@ -130,12 +133,15 @@ function syncTimingEditPreview(
|
|
|
130
133
|
const result = applySoftReload(iframe, outcome.scriptText, {
|
|
131
134
|
onAsyncFailure: reloadPreview,
|
|
132
135
|
currentTimeOverride: currentTime,
|
|
136
|
+
authoredHtml: outcome.after,
|
|
137
|
+
nestedFiles,
|
|
133
138
|
});
|
|
134
139
|
if (result === "cannot-soft-reload") reloadPreview();
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
async function finishTimelineTimingFallback(input: {
|
|
138
143
|
iframe: HTMLIFrameElement | null;
|
|
144
|
+
projectId: string | null;
|
|
139
145
|
reloadPreview: () => void;
|
|
140
146
|
gsapMutation?: () => Promise<GsapMutationStatus>;
|
|
141
147
|
onGsapError: (error: unknown) => void;
|
|
@@ -160,12 +166,21 @@ async function finishTimelineTimingFallback(input: {
|
|
|
160
166
|
return;
|
|
161
167
|
}
|
|
162
168
|
}
|
|
169
|
+
const { projectId } = input;
|
|
170
|
+
const nestedFiles = await settleNestedReads(
|
|
171
|
+
projectId && outcome.scriptText
|
|
172
|
+
? readNestedFiles(input.iframe, outcome.scriptText, (path) =>
|
|
173
|
+
readFileContent(projectId, path),
|
|
174
|
+
)
|
|
175
|
+
: null,
|
|
176
|
+
);
|
|
163
177
|
syncTimingEditPreview(
|
|
164
178
|
input.iframe,
|
|
165
179
|
outcome,
|
|
166
180
|
usePlayerStore.getState().currentTime,
|
|
167
181
|
input.reloadPreview,
|
|
168
182
|
input.rebindWhenUnmutated,
|
|
183
|
+
nestedFiles,
|
|
169
184
|
);
|
|
170
185
|
}
|
|
171
186
|
|
|
@@ -384,7 +399,7 @@ export function sdkTimingGsapSync(
|
|
|
384
399
|
return findTimelineScript(scripts)?.textContent ?? null;
|
|
385
400
|
};
|
|
386
401
|
if (timelineText(result.before) === timelineText(result.after)) return null;
|
|
387
|
-
return { mutated: true, scriptText: extractGsapScriptText(result.after) };
|
|
402
|
+
return { mutated: true, scriptText: extractGsapScriptText(result.after), after: result.after };
|
|
388
403
|
}
|
|
389
404
|
|
|
390
405
|
/** Timing delta a single-clip edit applies to its GSAP tweens. */
|
|
@@ -440,6 +455,7 @@ export function finishClipTimingFallback(input: {
|
|
|
440
455
|
console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
|
|
441
456
|
return finishTimelineTimingFallback({
|
|
442
457
|
iframe: input.iframe,
|
|
458
|
+
projectId,
|
|
443
459
|
reloadPreview: input.reloadPreview,
|
|
444
460
|
gsapMutation: sdkGsap
|
|
445
461
|
? () => Promise.resolve(sdkGsap)
|
|
@@ -498,6 +514,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
498
514
|
if (sdkGsap) {
|
|
499
515
|
await finishTimelineTimingFallback({
|
|
500
516
|
iframe: input.iframe,
|
|
517
|
+
projectId: input.projectId,
|
|
501
518
|
reloadPreview: input.reloadPreview,
|
|
502
519
|
gsapMutation: () => Promise.resolve(sdkGsap),
|
|
503
520
|
onGsapError,
|
|
@@ -507,6 +524,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
507
524
|
}
|
|
508
525
|
await finishTimelineTimingFallback({
|
|
509
526
|
iframe: input.iframe,
|
|
527
|
+
projectId: input.projectId,
|
|
510
528
|
reloadPreview: input.reloadPreview,
|
|
511
529
|
gsapMutation: () =>
|
|
512
530
|
foldGsapMutationIntoHistory({
|
|
@@ -519,6 +537,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
519
537
|
gsapMutation: async (runOwnedMutation) => {
|
|
520
538
|
let mutated = false;
|
|
521
539
|
let scriptText: GsapMutationStatus["scriptText"] = null;
|
|
540
|
+
let after: string | undefined;
|
|
522
541
|
for (const change of input.changes) {
|
|
523
542
|
const changePath = input.resolveChangePath(change.element);
|
|
524
543
|
const status = await runOwnedMutation(changePath, () =>
|
|
@@ -527,9 +546,9 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
527
546
|
mutated = mutated || status.mutated;
|
|
528
547
|
// The LAST mutation against the active comp carries the cumulative
|
|
529
548
|
// rewritten script for that file.
|
|
530
|
-
if (changePath === activePath) scriptText = status
|
|
549
|
+
if (changePath === activePath) ({ scriptText, after } = status);
|
|
531
550
|
}
|
|
532
|
-
return { mutated, scriptText: otherFileChanged ? null : scriptText };
|
|
551
|
+
return { mutated, scriptText: otherFileChanged ? null : scriptText, after };
|
|
533
552
|
},
|
|
534
553
|
onRollbackError: onGsapError,
|
|
535
554
|
}),
|
|
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
6
|
+
import { dispatchLinkShortcut, type LinkShortcutCallbacks } from "./linkShortcuts";
|
|
5
7
|
import { useCaptionStore } from "../captions/store";
|
|
6
8
|
import {
|
|
7
9
|
applyCaptionModelToIframe,
|
|
@@ -71,6 +73,8 @@ function tryApplyBeatHistory(
|
|
|
71
73
|
|
|
72
74
|
interface UseAppHotkeysParams {
|
|
73
75
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
76
|
+
handleLinkEdit?: LinkShortcutCallbacks["handleLinkEdit"];
|
|
77
|
+
handleTimelineElementDeleteOnly?: LinkShortcutCallbacks["handleTimelineElementDeleteOnly"];
|
|
74
78
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
75
79
|
handleDomEditElementDelete: (
|
|
76
80
|
selection: DomEditSelection,
|
|
@@ -114,6 +118,8 @@ interface UseAppHotkeysParams {
|
|
|
114
118
|
|
|
115
119
|
export function useAppHotkeys({
|
|
116
120
|
handleTimelineElementsDelete,
|
|
121
|
+
handleLinkEdit,
|
|
122
|
+
handleTimelineElementDeleteOnly,
|
|
117
123
|
handleTimelineElementSplit,
|
|
118
124
|
handleDomEditElementDelete,
|
|
119
125
|
domEditSelectionRef,
|
|
@@ -192,6 +198,8 @@ export function useAppHotkeys({
|
|
|
192
198
|
const cbRef = useRef<HotkeyCallbacks>(null!);
|
|
193
199
|
cbRef.current = {
|
|
194
200
|
handleTimelineElementsDelete,
|
|
201
|
+
handleLinkEdit,
|
|
202
|
+
handleTimelineElementDeleteOnly,
|
|
195
203
|
handleTimelineElementSplit,
|
|
196
204
|
handleDomEditElementDelete,
|
|
197
205
|
handleUndo,
|
|
@@ -215,6 +223,7 @@ export function useAppHotkeys({
|
|
|
215
223
|
const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
|
|
216
224
|
const cb = cbRef.current;
|
|
217
225
|
const key = event.key.toLowerCase();
|
|
226
|
+
if (!isTypingTarget(event.target) && dispatchLinkShortcut(event, cb)) return;
|
|
218
227
|
if (event.metaKey || event.ctrlKey) {
|
|
219
228
|
dispatchModifierKey(event, key, cb);
|
|
220
229
|
return;
|