@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.
Files changed (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  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 +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  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/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -0,0 +1,95 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
7
+ import type { TimelineElement } from "../player";
8
+
9
+ const saved = vi.hoisted(() => ({ inputs: [] as Array<Record<string, unknown>> }));
10
+ vi.mock("../utils/studioFileHistory", () => ({
11
+ saveProjectFilesWithHistory: vi.fn(async (input: Record<string, unknown>) => {
12
+ saved.inputs.push(input);
13
+ return [];
14
+ }),
15
+ }));
16
+
17
+ import { useSetElementsAttribute } from "./timelineElementsAttribute";
18
+
19
+ const clip = (id: string, sourceFile?: string): TimelineElement => ({
20
+ id,
21
+ domId: id,
22
+ tag: "audio",
23
+ start: 0,
24
+ duration: 1,
25
+ track: 0,
26
+ sourceFile,
27
+ });
28
+
29
+ describe("useSetElementsAttribute", () => {
30
+ it("writes every clip's attribute in one history entry, one builder per file", async () => {
31
+ const showToast = vi.fn();
32
+ let write: ReturnType<typeof useSetElementsAttribute> | null = null;
33
+ function Probe() {
34
+ write = useSetElementsAttribute({
35
+ projectIdRef: { current: "p1" },
36
+ activeCompPath: "index.html",
37
+ showToast,
38
+ writeProjectFile: vi.fn(async () => {}),
39
+ recordEdit: vi.fn(async () => {}),
40
+ previewIframeRef: { current: null },
41
+ pendingTimelineEditPathRef: { current: new Set<string>() },
42
+ });
43
+ return null;
44
+ }
45
+ const root = createRoot(document.createElement("div"));
46
+ act(() => root.render(createElement(Probe)));
47
+ await write?.(
48
+ [
49
+ { element: clip("a"), value: "0.5" },
50
+ { element: clip("b"), value: "2" },
51
+ { element: clip("c", "scene.html"), value: "1.5" },
52
+ ],
53
+ "data-volume",
54
+ "Audio Gain",
55
+ );
56
+ act(() => root.unmount());
57
+ expect(showToast).not.toHaveBeenCalled();
58
+ expect(saved.inputs).toHaveLength(1);
59
+ const input = saved.inputs[0];
60
+ expect(input?.["label"]).toBe("Audio Gain");
61
+ const files = input?.["files"];
62
+ if (typeof files !== "object" || files === null) throw new Error("no files");
63
+ expect(Object.keys(files).sort()).toEqual(["index.html", "scene.html"]);
64
+ const build = Reflect.get(files, "index.html");
65
+ const html = '<audio id="a" data-volume="1"></audio><audio id="b"></audio>';
66
+ const out = typeof build === "function" ? String(build(html)) : "";
67
+ expect(out).toContain('id="a" data-volume="0.5"');
68
+ expect(out).toMatch(/id="b"[^>]*data-volume="2"/);
69
+ });
70
+
71
+ it("refuses while recording, like the single-clip write", async () => {
72
+ saved.inputs.length = 0;
73
+ const showToast = vi.fn();
74
+ let write: ReturnType<typeof useSetElementsAttribute> | null = null;
75
+ function Probe() {
76
+ write = useSetElementsAttribute({
77
+ projectIdRef: { current: "p1" },
78
+ activeCompPath: "index.html",
79
+ showToast,
80
+ writeProjectFile: vi.fn(async () => {}),
81
+ recordEdit: vi.fn(async () => {}),
82
+ previewIframeRef: { current: null },
83
+ pendingTimelineEditPathRef: { current: new Set<string>() },
84
+ isRecordingRef: { current: true },
85
+ });
86
+ return null;
87
+ }
88
+ const root = createRoot(document.createElement("div"));
89
+ act(() => root.render(createElement(Probe)));
90
+ await write?.([{ element: clip("a"), value: "0.5" }], "data-volume", "Audio Gain");
91
+ act(() => root.unmount());
92
+ expect(saved.inputs).toHaveLength(0);
93
+ expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error");
94
+ });
95
+ });
@@ -0,0 +1,113 @@
1
+ import { useCallback } from "react";
2
+ import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
+ import { syncStoredElementAttribute } from "../player/lib/automationStoreSync";
4
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
+ import { applyPatchByTarget } from "../utils/sourcePatcher";
6
+ import {
7
+ buildPatchTarget,
8
+ findTimelineElementInIframe,
9
+ readFileContent,
10
+ } from "./timelineEditingHelpers";
11
+ import type { UseTimelineElementVisibilityEditingInput } from "./timelineTrackVisibility";
12
+ import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
13
+
14
+ type ElementAttributeEdits = Parameters<
15
+ NonNullable<TimelineEditCallbacks["onSetElementsAttributeQuiet"]>
16
+ >[0];
17
+
18
+ type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
19
+
20
+ function editsByFile(
21
+ edits: ElementAttributeEdits,
22
+ activeCompPath: string | null,
23
+ ): Map<string, Array<{ target: PatchTarget; value: string | null }>> {
24
+ const byFile = new Map<string, Array<{ target: PatchTarget; value: string | null }>>();
25
+ for (const { element, value } of edits) {
26
+ const target = buildPatchTarget(element);
27
+ if (!target) throw new Error("A clip has no id to save it by");
28
+ const path = element.sourceFile || activeCompPath || "index.html";
29
+ byFile.set(path, [...(byFile.get(path) ?? []), { target, value }]);
30
+ }
31
+ return byFile;
32
+ }
33
+
34
+ function patchLive(
35
+ iframe: HTMLIFrameElement | null,
36
+ edits: ElementAttributeEdits,
37
+ attr: string,
38
+ activeCompPath: string | null,
39
+ ): void {
40
+ for (const { element, value } of edits) {
41
+ const node = findTimelineElementInIframe(iframe, element, activeCompPath);
42
+ if (value === null) node?.removeAttribute(attr);
43
+ else node?.setAttribute(attr, value);
44
+ syncStoredElementAttribute(element, attr, value);
45
+ }
46
+ }
47
+
48
+ /** One attribute written on several clips as a single save and a single undo step. */
49
+ export function useSetElementsAttribute({
50
+ projectIdRef,
51
+ activeCompPath,
52
+ showToast,
53
+ writeProjectFile,
54
+ recordEdit,
55
+ previewIframeRef,
56
+ pendingTimelineEditPathRef,
57
+ isRecordingRef,
58
+ }: UseTimelineElementVisibilityEditingInput) {
59
+ return useCallback(
60
+ async (
61
+ edits: ElementAttributeEdits,
62
+ attr: string,
63
+ label: string,
64
+ ): Promise<TimelineEditOutcome> => {
65
+ const projectId = projectForTimelineSave(
66
+ isRecordingRef?.current,
67
+ projectIdRef.current,
68
+ showToast,
69
+ );
70
+ if (typeof projectId !== "string") return projectId;
71
+ if (edits.length === 0) return { status: "saved" };
72
+ try {
73
+ const byFile = editsByFile(edits, activeCompPath);
74
+ const files = Object.fromEntries(
75
+ [...byFile].map(([path, patches]) => [
76
+ path,
77
+ (before: string) =>
78
+ patches.reduce(
79
+ (html, { target, value }) =>
80
+ applyPatchByTarget(html, target, { type: "attribute", property: attr, value }),
81
+ before,
82
+ ),
83
+ ]),
84
+ );
85
+ for (const path of byFile.keys()) pendingTimelineEditPathRef.current.add(path);
86
+ await saveProjectFilesWithHistory({
87
+ projectId,
88
+ label,
89
+ files,
90
+ readFile: (path) => readFileContent(projectId, path),
91
+ writeFile: writeProjectFile,
92
+ recordEdit,
93
+ });
94
+ patchLive(previewIframeRef.current, edits, attr, activeCompPath);
95
+ return { status: "saved" };
96
+ } catch (error) {
97
+ const reason = error instanceof Error ? error.message : "Could not save the clips";
98
+ showToast(reason, "error");
99
+ return { status: "failed", reason };
100
+ }
101
+ },
102
+ [
103
+ projectIdRef,
104
+ activeCompPath,
105
+ showToast,
106
+ writeProjectFile,
107
+ recordEdit,
108
+ previewIframeRef,
109
+ pendingTimelineEditPathRef,
110
+ isRecordingRef,
111
+ ],
112
+ );
113
+ }
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { TimelineElement } from "../player";
4
+ import { clipSyncState } from "../player/components/clipSync";
5
+ import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
6
+
7
+ const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
8
+ id,
9
+ domId: id,
10
+ tag,
11
+ src: "talk.mp4",
12
+ start: 2,
13
+ duration: 6,
14
+ track: tag === "audio" ? 1 : 0,
15
+ ...extra,
16
+ });
17
+
18
+ const video = clip("talk", "video", { link: "lk-1", muted: true });
19
+ const audio = clip("talk-audio", "audio", { link: "lk-1" });
20
+ const title = clip("title", "div");
21
+
22
+ const SOURCE =
23
+ '<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>';
24
+
25
+ describe("clipsToUnlink", () => {
26
+ it("unlinks a lone removed member and its orphaned partner", () => {
27
+ expect(clipsToUnlink([video], [video, audio, title]).map((el) => el.id)).toEqual([
28
+ "talk",
29
+ "talk-audio",
30
+ ]);
31
+ });
32
+
33
+ it("keeps a three-member group linked when one leaves", () => {
34
+ const third = clip("talk-2", "audio", { link: "lk-1" });
35
+ expect(clipsToUnlink([video], [video, audio, third]).map((el) => el.id)).toEqual(["talk"]);
36
+ });
37
+
38
+ it("ignores unlinked clips", () => {
39
+ expect(clipsToUnlink([title], [video, audio, title])).toEqual([]);
40
+ });
41
+
42
+ it("never reaches a same-id link in another source file", () => {
43
+ const childVideo = clip("c", "video", { link: "lk-1", sourceFile: "child.html" });
44
+ const removed = clipsToUnlink([video], [video, audio, childVideo]).map((el) => el.id);
45
+ expect(removed).toEqual(["talk", "talk-audio"]);
46
+ });
47
+ });
48
+
49
+ describe("planLinkEdit", () => {
50
+ const elements = [video, audio, title];
51
+
52
+ it("unlink strips data-link from both members", () => {
53
+ const plan = planLinkEdit({ kind: "unlink", elements: [video, audio] }, elements);
54
+ expect(plan?.label).toBe("Unlink clips");
55
+ expect(plan?.transform(SOURCE)).not.toContain("data-link");
56
+ });
57
+
58
+ it("link writes one fresh id on both", () => {
59
+ const unlinked = SOURCE.replaceAll(' data-link="lk-1"', "");
60
+ const plan = planLinkEdit({ kind: "link", elements: [video, audio] }, elements);
61
+ expect(plan?.transform(unlinked)?.match(/data-link="lk-1"/g)).toHaveLength(2);
62
+ });
63
+
64
+ it("merge folds the audio into the video", () => {
65
+ const plan = planLinkEdit({ kind: "merge", video, audio }, elements);
66
+ const merged = plan?.transform(SOURCE) ?? "";
67
+ expect(merged).not.toContain("<audio");
68
+ expect(merged).toContain('data-volume="0.5"');
69
+ expect(merged).toContain('data-has-audio="true"');
70
+ });
71
+
72
+ it("detach puts the audio on the first free audio track", () => {
73
+ const talk = clip("talk", "video", { hasAudio: true });
74
+ const src =
75
+ '<div><video id="talk" src="talk.mp4" data-has-audio="true" data-start="2" data-duration="6"></video></div>';
76
+ const plan = planLinkEdit({ kind: "detach", element: talk }, [talk, title]);
77
+ const out = plan?.transform(src) ?? "";
78
+ expect(out).toContain('id="talk-audio"');
79
+ expect(out).toContain('data-track-index="1"');
80
+ });
81
+ });
82
+
83
+ describe("planLinkEdit into sync", () => {
84
+ const drifted =
85
+ '<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>';
86
+ const late = clip("talk-audio", "audio", { start: 2.5, playbackStart: 0 });
87
+
88
+ it("moves one clip's start, leaving its partner and link alone", () => {
89
+ const plan = planLinkEdit({ kind: "move-into-sync", element: late, start: 2 }, [video, late]);
90
+ expect(plan?.label).toBe("Move into Sync");
91
+ const html = plan?.transform(drifted) ?? "";
92
+ expect(html).toContain('id="talk-audio" src="talk.mp4" data-sync-origin="lk-1" data-start="2"');
93
+ expect(html).toContain('id="talk" src="talk.mp4" muted data-sync-origin="lk-1" data-start="2"');
94
+ });
95
+
96
+ it("slips one clip's media start in place", () => {
97
+ const plan = planLinkEdit({ kind: "slip-into-sync", element: late, mediaStart: 0.5 }, [
98
+ video,
99
+ late,
100
+ ]);
101
+ expect(plan?.label).toBe("Slip into Sync");
102
+ const html = plan?.transform(drifted) ?? "";
103
+ expect(html).toContain('data-start="2.5" data-duration="6" data-media-start="0.5"');
104
+ });
105
+
106
+ it("slips the legacy playback-start attribute when that is what the clip uses", () => {
107
+ const legacy = { ...late, playbackStartAttr: "playback-start" as const };
108
+ const html =
109
+ planLinkEdit({ kind: "slip-into-sync", element: legacy, mediaStart: 0.5 }, [])?.transform(
110
+ '<audio id="talk-audio" data-playback-start="0"></audio>',
111
+ ) ?? "";
112
+ expect(html).toBe('<audio id="talk-audio" data-playback-start="0.5"></audio>');
113
+ });
114
+ });
115
+
116
+ describe("link edit plan", () => {
117
+ const PAIR =
118
+ '<div><video id="talk" src="talk.mp4" muted data-start="2" data-duration="6"></video><audio id="a" src="%SRC%" data-start="3" data-duration="3" data-media-start="1"></audio></div>';
119
+ const linkPlan = (src: string, audioEl: TimelineElement) => {
120
+ const v = clip("talk", "video", { muted: true });
121
+ const plan = planLinkEdit({ kind: "link", elements: [v, audioEl] }, [v, audioEl]);
122
+ return plan?.transform(PAIR.replace("%SRC%", src)) ?? "";
123
+ };
124
+
125
+ it("links a trimmed same-file audio with a sync origin that shows a nonzero offset", () => {
126
+ const trimmed = clip("a", "audio", { start: 3, duration: 3, playbackStart: 0 });
127
+ const out = linkPlan("talk.mp4", trimmed);
128
+ expect(out).toMatch(/<video[^>]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/);
129
+ expect(out).toMatch(/<audio[^>]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/);
130
+ const v = clip("talk", "video", { muted: true, syncOrigin: "lk-1" });
131
+ const a = { ...trimmed, syncOrigin: "lk-1" };
132
+ expect(clipSyncState(a, [v, a], 30)?.frames).toBe(30);
133
+ });
134
+
135
+ it("links different files without a sync origin", () => {
136
+ const music = clip("a", "audio", { src: "bgm.mp3" });
137
+ const out = linkPlan("bgm.mp3", music);
138
+ expect(out).toContain('data-link="lk-1"');
139
+ expect(out).not.toContain("data-sync-origin");
140
+ });
141
+ });
@@ -0,0 +1,128 @@
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
+ sharesSourceFile,
17
+ } from "../components/editor/mediaLinkEdits";
18
+
19
+ export interface LinkEditPlan {
20
+ label: string;
21
+ anchor: TimelineElement;
22
+ transform: (source: string) => string | null;
23
+ }
24
+
25
+ const keyOf = (el: TimelineElement) => el.key ?? el.id;
26
+
27
+ function targetsOf(elements: readonly TimelineElement[]): PatchTarget[] | null {
28
+ const targets = elements.map(buildPatchTarget);
29
+ return targets.every((target): target is PatchTarget => target !== null) ? targets : null;
30
+ }
31
+
32
+ /**
33
+ * The clips to strip `data-link` from when `removed` leave their groups: the
34
+ * removed clips themselves, plus any partner left alone in its group.
35
+ */
36
+ export function clipsToUnlink(
37
+ removed: readonly TimelineElement[],
38
+ elements: readonly TimelineElement[],
39
+ ): TimelineElement[] {
40
+ const removedKeys = new Set(removed.map(keyOf));
41
+ const orphans = new Map<string, TimelineElement>();
42
+ for (const leaving of removed) {
43
+ const survivors = elements.filter(
44
+ (el) => sharesLinkGroup(el, leaving) && !removedKeys.has(keyOf(el)),
45
+ );
46
+ if (survivors.length === 1 && survivors[0]) orphans.set(keyOf(survivors[0]), survivors[0]);
47
+ }
48
+ return [...removed.filter((el) => el.link), ...orphans.values()];
49
+ }
50
+
51
+ type SyncEdit = Extract<TimelineLinkEdit, { kind: "move-into-sync" | "slip-into-sync" }>;
52
+
53
+ function planSyncEdit(edit: SyncEdit): LinkEditPlan | null {
54
+ const target = buildPatchTarget(edit.element);
55
+ if (!target) return null;
56
+ if (edit.kind === "move-into-sync") {
57
+ const value = formatTimelineMediaOffset(toAuthoredStart(edit.element, edit.start));
58
+ return {
59
+ label: "Move into Sync",
60
+ anchor: edit.element,
61
+ transform: (s) => {
62
+ const moved = applyPatchByTarget(s, target, {
63
+ type: "attribute",
64
+ property: "start",
65
+ value,
66
+ });
67
+ return setCompositionDurationToContent(moved, furthestClipEndFromSource(moved));
68
+ },
69
+ };
70
+ }
71
+ const property = edit.element.playbackStartAttr ?? "media-start";
72
+ const value = formatTimelineMediaOffset(edit.mediaStart);
73
+ return {
74
+ label: "Slip into Sync",
75
+ anchor: edit.element,
76
+ transform: (s) => applyPatchByTarget(s, target, { type: "attribute", property, value }),
77
+ };
78
+ }
79
+
80
+ export function planLinkEdit(
81
+ edit: TimelineLinkEdit,
82
+ elements: readonly TimelineElement[],
83
+ ): LinkEditPlan | null {
84
+ switch (edit.kind) {
85
+ case "unlink": {
86
+ const members = clipsToUnlink(edit.elements, elements);
87
+ const targets = targetsOf(members);
88
+ const anchor = members[0];
89
+ if (!targets || !anchor) return null;
90
+ return { label: "Unlink clips", anchor, transform: (s) => setLinkInSource(s, targets, null) };
91
+ }
92
+ case "link": {
93
+ const targets = targetsOf(edit.elements);
94
+ const anchor = edit.elements[0];
95
+ if (!targets || !anchor) return null;
96
+ return {
97
+ label: "Link clips",
98
+ anchor,
99
+ transform: (s) => linkInSource(s, targets, { syncOrigin: sharesSourceFile(edit.elements) }),
100
+ };
101
+ }
102
+ case "detach": {
103
+ const target = buildPatchTarget(edit.element);
104
+ if (!target) return null;
105
+ const track = pickDetachedAudioTrack(elements, edit.element);
106
+ return {
107
+ label: "Detach audio",
108
+ anchor: edit.element,
109
+ transform: (s) =>
110
+ detachAudioInSource(s, { target, videoId: edit.element.domId ?? null, track })?.html ??
111
+ null,
112
+ };
113
+ }
114
+ case "move-into-sync":
115
+ case "slip-into-sync":
116
+ return planSyncEdit(edit);
117
+ case "merge": {
118
+ const videoTarget = buildPatchTarget(edit.video);
119
+ const audioTarget = buildPatchTarget(edit.audio);
120
+ if (!videoTarget || !audioTarget) return null;
121
+ return {
122
+ label: "Merge audio back into video",
123
+ anchor: edit.video,
124
+ transform: (s) => mergeAudioInSource(s, { videoTarget, audioTarget }),
125
+ };
126
+ }
127
+ }
128
+ }
@@ -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
  }),