@hyperframes/studio 0.8.108 → 0.8.110
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -0,0 +1,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
|
|
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
|
}),
|