@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.
Files changed (118) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
  5. package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +44 -4
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +7021 -5669
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  24. package/src/components/editor/InlineTextCaret.tsx +211 -0
  25. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  26. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  27. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  28. package/src/components/editor/domEditInlineText.ts +6 -4
  29. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  30. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  31. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  32. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  33. package/src/components/editor/mediaAudioEdits.ts +27 -14
  34. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  35. package/src/components/editor/mediaLinkEdits.ts +282 -0
  36. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  37. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  38. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  39. package/src/components/editor/useVolumeAutomation.ts +29 -7
  40. package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
  41. package/src/components/ui/Menu.test.tsx +3 -22
  42. package/src/components/ui/Menu.tsx +2 -2
  43. package/src/components/ui/Popover.tsx +2 -2
  44. package/src/components/ui/Select.tsx +2 -2
  45. package/src/components/ui/Tooltip.tsx +2 -2
  46. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  47. package/src/components/ui/valueControls.test.tsx +2 -21
  48. package/src/contexts/TimelineEditContext.tsx +3 -0
  49. package/src/hooks/appHotkeysDispatch.ts +2 -1
  50. package/src/hooks/linkShortcuts.test.ts +70 -0
  51. package/src/hooks/linkShortcuts.ts +41 -0
  52. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  53. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  54. package/src/hooks/timelineMoveAdapter.ts +13 -0
  55. package/src/hooks/timelineTimingSync.test.ts +59 -0
  56. package/src/hooks/timelineTimingSync.ts +22 -3
  57. package/src/hooks/useAppHotkeys.ts +9 -0
  58. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  59. package/src/hooks/useGsapScriptCommits.ts +23 -3
  60. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  61. package/src/hooks/usePreviewPersistence.ts +15 -0
  62. package/src/hooks/useRazorSplit.test.tsx +48 -0
  63. package/src/hooks/useRazorSplit.ts +12 -1
  64. package/src/hooks/useRenderClipContent.test.ts +6 -4
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  67. package/src/hooks/useTimelineEditGuard.ts +54 -0
  68. package/src/hooks/useTimelineEditing.ts +36 -55
  69. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  70. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  71. package/src/index.ts +1 -0
  72. package/src/player/components/AudioWaveform.test.tsx +30 -3
  73. package/src/player/components/AudioWaveform.tsx +6 -7
  74. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  75. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  76. package/src/player/components/ClipContextMenu.tsx +115 -90
  77. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  78. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  79. package/src/player/components/TimelineClip.test.tsx +17 -0
  80. package/src/player/components/TimelineClip.tsx +6 -0
  81. package/src/player/components/audioClipLink.test.ts +228 -31
  82. package/src/player/components/audioClipLink.ts +72 -25
  83. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  84. package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
  85. package/src/player/components/clipMenuAudioItems.tsx +31 -23
  86. package/src/player/components/clipMenuDuck.ts +5 -0
  87. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  88. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  89. package/src/player/components/clipMenuToolItems.tsx +1 -3
  90. package/src/player/components/clipSync.test.ts +67 -0
  91. package/src/player/components/clipSync.ts +80 -0
  92. package/src/player/components/linkLabelColor.test.ts +20 -0
  93. package/src/player/components/linkLabelColor.ts +24 -0
  94. package/src/player/components/timelineCallbacks.ts +11 -0
  95. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  96. package/src/player/components/timelineClipDragTypes.ts +2 -0
  97. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  98. package/src/player/components/timelineLinkSelection.ts +23 -0
  99. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  100. package/src/player/components/useTimelineClipDrag.ts +21 -0
  101. package/src/player/components/useTimelineProviderState.tsx +3 -0
  102. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  103. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  104. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  105. package/src/player/lib/timelineDOM.test.ts +31 -0
  106. package/src/player/lib/timelineDOM.ts +13 -0
  107. package/src/player/store/timelineElement.ts +12 -0
  108. package/src/styles/components.css +27 -11
  109. package/src/styles/theme.css +0 -1
  110. package/src/utils/gsapSoftReload.test.ts +238 -2
  111. package/src/utils/gsapSoftReload.ts +89 -74
  112. package/src/utils/gsapUndoRestore.test.ts +4 -3
  113. package/src/utils/gsapUndoRestore.ts +15 -0
  114. package/src/utils/linkedClipPreferences.ts +34 -0
  115. package/src/utils/softReloadTargets.ts +128 -0
  116. package/src/utils/studioUiPreferences.ts +8 -0
  117. package/dist/chunk-AOWVEVEU.js.map +0 -1
  118. 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.scriptText;
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;