@hyperframes/studio 0.8.108 → 0.8.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-CYP6dMd7.js} +208 -206
- package/dist/assets/{index-C5dyUHwk.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +44 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7021 -5669
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +3 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +36 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +1 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +6 -0
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +52 -9
- package/src/player/components/clipMenuAudioItems.tsx +31 -23
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuToolItems.tsx +1 -3
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +11 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -10,13 +10,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
10
10
|
const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
|
|
11
11
|
const applySoftReload = vi.fn<(...args: unknown[]) => string>();
|
|
12
12
|
const trackStudioEvent = vi.fn();
|
|
13
|
+
const readNestedFiles = vi.fn<(...args: unknown[]) => unknown>(() => null);
|
|
13
14
|
|
|
14
15
|
vi.mock("./gsapRuntimePatch", () => ({
|
|
15
16
|
patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
|
|
16
17
|
}));
|
|
17
|
-
vi.mock("../utils/gsapSoftReload", () => ({
|
|
18
|
+
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
|
|
19
|
+
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
|
|
18
20
|
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
|
|
19
21
|
extractGsapScriptText: () => "",
|
|
22
|
+
readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
|
|
20
23
|
}));
|
|
21
24
|
vi.mock("../utils/studioTelemetry", () => ({
|
|
22
25
|
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
|
|
@@ -364,6 +367,7 @@ let cleanup: (() => void) | null = null;
|
|
|
364
367
|
function renderCommitHook(
|
|
365
368
|
options: {
|
|
366
369
|
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
370
|
+
iframe?: HTMLIFrameElement;
|
|
367
371
|
} = {},
|
|
368
372
|
) {
|
|
369
373
|
const reloadPreview = vi.fn();
|
|
@@ -378,7 +382,7 @@ function renderCommitHook(
|
|
|
378
382
|
captured.api = useGsapScriptCommits({
|
|
379
383
|
projectIdRef: { current: "proj-1" },
|
|
380
384
|
activeCompPath: "index.html",
|
|
381
|
-
previewIframeRef: { current: FAKE_IFRAME },
|
|
385
|
+
previewIframeRef: { current: options.iframe ?? FAKE_IFRAME },
|
|
382
386
|
editHistory: { recordEdit },
|
|
383
387
|
reloadPreview,
|
|
384
388
|
onCacheInvalidate,
|
|
@@ -578,6 +582,7 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
578
582
|
patchRuntimeTweenInPlace.mockReset();
|
|
579
583
|
applySoftReload.mockReset();
|
|
580
584
|
trackStudioEvent.mockReset();
|
|
585
|
+
readNestedFiles.mockReset().mockReturnValue(null);
|
|
581
586
|
});
|
|
582
587
|
afterEach(() => {
|
|
583
588
|
cleanup?.();
|
|
@@ -585,6 +590,83 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
585
590
|
vi.unstubAllGlobals();
|
|
586
591
|
});
|
|
587
592
|
|
|
593
|
+
const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
|
|
594
|
+
const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
|
|
595
|
+
|
|
596
|
+
// A preview whose top-level timeline tweens an element written in a nested composition file.
|
|
597
|
+
function nestedPreview(): HTMLIFrameElement {
|
|
598
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
599
|
+
doc.body.innerHTML = `<div data-composition-id="root"><div data-composition-file="compositions/sub.html"><div id="nwid"></div></div></div>`;
|
|
600
|
+
const nwid = doc.getElementById("nwid")!;
|
|
601
|
+
const tween = { targets: () => [nwid], vars: { width: 400 } };
|
|
602
|
+
return {
|
|
603
|
+
contentDocument: doc,
|
|
604
|
+
contentWindow: { __timelines: { root: { getChildren: () => [tween] } } },
|
|
605
|
+
} as unknown as HTMLIFrameElement;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The mutation endpoint answers the commit; the files endpoint serves the nested file, or fails.
|
|
609
|
+
function mockServer(fileOk: boolean): ReturnType<typeof vi.fn> {
|
|
610
|
+
const body = {
|
|
611
|
+
ok: true,
|
|
612
|
+
changed: true,
|
|
613
|
+
before: "BEFORE",
|
|
614
|
+
after: "AFTER",
|
|
615
|
+
scriptText: NESTED_SCRIPT,
|
|
616
|
+
};
|
|
617
|
+
const fetch = vi.fn(async (url: string) =>
|
|
618
|
+
url.includes("/files/")
|
|
619
|
+
? ({ ok: fileOk, json: async () => ({ content: SUB }) } as unknown as Response)
|
|
620
|
+
: ({ ok: true, json: async () => body } as unknown as Response),
|
|
621
|
+
);
|
|
622
|
+
vi.stubGlobal("fetch", fetch);
|
|
623
|
+
return fetch;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function useRealNestedRead() {
|
|
627
|
+
const actual =
|
|
628
|
+
await vi.importActual<typeof import("../utils/gsapSoftReload")>("../utils/gsapSoftReload");
|
|
629
|
+
readNestedFiles.mockImplementation((...args) =>
|
|
630
|
+
actual.readNestedFiles(...(args as Parameters<typeof actual.readNestedFiles>)),
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
it("hands the soft reload the nested composition file it read for a reset element", async () => {
|
|
635
|
+
await useRealNestedRead();
|
|
636
|
+
applySoftReload.mockReturnValue("applied");
|
|
637
|
+
const fetch = mockServer(true);
|
|
638
|
+
const deps = renderCommitHook({ iframe: nestedPreview() });
|
|
639
|
+
|
|
640
|
+
await act(async () => {
|
|
641
|
+
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
|
|
645
|
+
expect(applySoftReload).toHaveBeenCalledWith(
|
|
646
|
+
expect.anything(),
|
|
647
|
+
NESTED_SCRIPT,
|
|
648
|
+
expect.objectContaining({ nestedFiles: new Map([["compositions/sub.html", SUB]]) }),
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("reloads the preview in full when that nested file cannot be read", async () => {
|
|
653
|
+
await useRealNestedRead();
|
|
654
|
+
applySoftReload.mockReturnValue("cannot-soft-reload");
|
|
655
|
+
mockServer(false);
|
|
656
|
+
const deps = renderCommitHook({ iframe: nestedPreview() });
|
|
657
|
+
|
|
658
|
+
await act(async () => {
|
|
659
|
+
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(applySoftReload).toHaveBeenCalledWith(
|
|
663
|
+
expect.anything(),
|
|
664
|
+
NESTED_SCRIPT,
|
|
665
|
+
expect.objectContaining({ nestedFiles: null }),
|
|
666
|
+
);
|
|
667
|
+
expect(deps.reloadPreview).toHaveBeenCalledTimes(1);
|
|
668
|
+
});
|
|
669
|
+
|
|
588
670
|
it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
|
|
589
671
|
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
590
672
|
mockFetchResult();
|
|
@@ -3,7 +3,12 @@ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mu
|
|
|
3
3
|
import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applySoftReload,
|
|
8
|
+
extractGsapScriptText,
|
|
9
|
+
readNestedFiles,
|
|
10
|
+
settleNestedReads,
|
|
11
|
+
} from "../utils/gsapSoftReload";
|
|
7
12
|
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
|
8
13
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
14
|
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
|
|
@@ -142,9 +147,10 @@ function refreshMutationPreview(
|
|
|
142
147
|
options: CommitMutationOptions,
|
|
143
148
|
reloadPreview: () => void,
|
|
144
149
|
onCacheInvalidate: () => void,
|
|
150
|
+
nestedFiles?: Map<string, string> | null,
|
|
145
151
|
): void {
|
|
146
152
|
options.beforeReload?.();
|
|
147
|
-
applyPreviewSync(iframe, result, options, reloadPreview);
|
|
153
|
+
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
|
|
148
154
|
onCacheInvalidate();
|
|
149
155
|
}
|
|
150
156
|
|
|
@@ -168,6 +174,7 @@ function syncCommittedGsapMutation({
|
|
|
168
174
|
forceReloadSdkSession,
|
|
169
175
|
reloadPreview,
|
|
170
176
|
onCacheInvalidate,
|
|
177
|
+
nestedFiles,
|
|
171
178
|
}: {
|
|
172
179
|
iframe: HTMLIFrameElement | null;
|
|
173
180
|
selection: DomEditSelection;
|
|
@@ -179,6 +186,7 @@ function syncCommittedGsapMutation({
|
|
|
179
186
|
forceReloadSdkSession?: () => void;
|
|
180
187
|
reloadPreview: () => void;
|
|
181
188
|
onCacheInvalidate: () => void;
|
|
189
|
+
nestedFiles?: Map<string, string> | null;
|
|
182
190
|
}): void {
|
|
183
191
|
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
|
|
184
192
|
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
|
|
@@ -196,7 +204,7 @@ function syncCommittedGsapMutation({
|
|
|
196
204
|
iframe?.contentDocument,
|
|
197
205
|
);
|
|
198
206
|
}
|
|
199
|
-
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
|
|
207
|
+
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate, nestedFiles);
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
/**
|
|
@@ -219,6 +227,7 @@ function softReloadOrEscalate(
|
|
|
219
227
|
reloadPreview: () => void,
|
|
220
228
|
origin: "preview_sync" | "sdk_refresh",
|
|
221
229
|
authoredHtml?: string,
|
|
230
|
+
nestedFiles?: Map<string, string> | null,
|
|
222
231
|
): void {
|
|
223
232
|
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
|
|
224
233
|
// not the iframe's raw `__player.getTime()` — see the comment in
|
|
@@ -228,6 +237,7 @@ function softReloadOrEscalate(
|
|
|
228
237
|
onAsyncFailure: reloadPreview,
|
|
229
238
|
currentTimeOverride: currentTime,
|
|
230
239
|
authoredHtml,
|
|
240
|
+
nestedFiles,
|
|
231
241
|
});
|
|
232
242
|
if (result === "applied") return;
|
|
233
243
|
trackStudioEvent("gsap_soft_reload_outcome", {
|
|
@@ -252,6 +262,7 @@ export function applyPreviewSync(
|
|
|
252
262
|
result: MutationResult,
|
|
253
263
|
options: CommitMutationOptions,
|
|
254
264
|
reloadPreview: () => void,
|
|
265
|
+
nestedFiles?: Map<string, string> | null,
|
|
255
266
|
): void {
|
|
256
267
|
const patches = instantPatchesFor(options);
|
|
257
268
|
let needsFallback = options.previewFallbackLatch?.pending === true;
|
|
@@ -297,6 +308,7 @@ export function applyPreviewSync(
|
|
|
297
308
|
reloadPreview,
|
|
298
309
|
"preview_sync",
|
|
299
310
|
result.after ?? undefined,
|
|
311
|
+
nestedFiles,
|
|
300
312
|
);
|
|
301
313
|
} else {
|
|
302
314
|
reloadPreview();
|
|
@@ -349,6 +361,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
349
361
|
return;
|
|
350
362
|
}
|
|
351
363
|
await recordMutationEdit(targetPath, result, options);
|
|
364
|
+
const nestedFiles = await settleNestedReads(
|
|
365
|
+
result.scriptText
|
|
366
|
+
? readNestedFiles(previewIframeRef.current, result.scriptText, (path) =>
|
|
367
|
+
readSharedProjectFileContent(projectId, path),
|
|
368
|
+
)
|
|
369
|
+
: null,
|
|
370
|
+
);
|
|
352
371
|
// The durable mutation belongs to the project captured when it was queued.
|
|
353
372
|
// A later project must never receive its file state or preview refresh.
|
|
354
373
|
if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
|
|
@@ -363,6 +382,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
363
382
|
forceReloadSdkSession,
|
|
364
383
|
reloadPreview,
|
|
365
384
|
onCacheInvalidate,
|
|
385
|
+
nestedFiles,
|
|
366
386
|
});
|
|
367
387
|
}, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
|
368
388
|
|
|
@@ -45,3 +45,79 @@ describe("undo that reloads the preview", () => {
|
|
|
45
45
|
expect([player.elements, player.timelineReady]).toEqual([clips, true]);
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
describe("undo that re-runs the top-level script over an element of a nested composition", () => {
|
|
50
|
+
const script = (width: number) =>
|
|
51
|
+
`var tl = gsap.timeline({ paused: true }); tl.to("#nwid", { width: ${width} }); window.__timelines["root"] = tl;`;
|
|
52
|
+
const page = (width: number) =>
|
|
53
|
+
`<html><body><div id="root" data-composition-id="root"></div><script>${script(width)}</script></body></html>`;
|
|
54
|
+
const SUB = `<template><div data-composition-id="sub"><div id="nwid" data-hf-id="hf-n" style="left: 40px"></div></div></template>`;
|
|
55
|
+
|
|
56
|
+
// The live preview: the top-level timeline tweened the nested element's width inline.
|
|
57
|
+
function nestedPreview() {
|
|
58
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
59
|
+
doc.body.innerHTML =
|
|
60
|
+
`<div id="root" data-composition-id="root"><div data-composition-file="compositions/sub.html">` +
|
|
61
|
+
`<div id="nwid" data-hf-id="hf-n" style="left: 40px; width: 337px"></div></div></div>` +
|
|
62
|
+
`<script>${script(400)}</script>`;
|
|
63
|
+
const nwid = doc.getElementById("nwid")!;
|
|
64
|
+
const contentWindow = {
|
|
65
|
+
gsap: { timeline: vi.fn(), set: vi.fn() },
|
|
66
|
+
__hfForceTimelineRebind: vi.fn(),
|
|
67
|
+
__timelines: {
|
|
68
|
+
root: {
|
|
69
|
+
kill: vi.fn(),
|
|
70
|
+
getChildren: () => [{ targets: () => [nwid], vars: { width: 400 } }],
|
|
71
|
+
},
|
|
72
|
+
} as Record<string, unknown>,
|
|
73
|
+
__player: { getTime: () => 1, seek: vi.fn() },
|
|
74
|
+
__hfStudioManualEditsApply: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
|
|
78
|
+
nwid,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function undo(fileOk: boolean) {
|
|
83
|
+
const { iframe, nwid } = nestedPreview();
|
|
84
|
+
const reloadPreview = vi.fn();
|
|
85
|
+
const fetch = vi.fn(async () => ({ ok: fileOk, json: async () => ({ content: SUB }) }));
|
|
86
|
+
vi.stubGlobal("fetch", fetch);
|
|
87
|
+
usePlayerStore.getState().beginTimelineSession("p1");
|
|
88
|
+
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
|
|
89
|
+
null;
|
|
90
|
+
function Harness() {
|
|
91
|
+
sync = usePreviewPersistence({
|
|
92
|
+
showToast: () => {},
|
|
93
|
+
readOptionalProjectFile: async () => "",
|
|
94
|
+
writeProjectFile: async () => {},
|
|
95
|
+
recordEdit: async () => {},
|
|
96
|
+
previewIframeRef: { current: iframe },
|
|
97
|
+
activeCompPathRef: { current: "index.html" },
|
|
98
|
+
reloadPreview,
|
|
99
|
+
}).syncHistoryPreviewAfterApply;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
mountReactHarness(<Harness />);
|
|
103
|
+
const files = { "index.html": { previous: page(400), restored: page(450) } };
|
|
104
|
+
await act(async () => sync!({ paths: Object.keys(files), files }));
|
|
105
|
+
vi.unstubAllGlobals();
|
|
106
|
+
return { nwid, reloadPreview, fetch };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it("restores the element from its own composition file", async () => {
|
|
110
|
+
const { nwid, reloadPreview, fetch } = await undo(true);
|
|
111
|
+
|
|
112
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
|
|
113
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
114
|
+
expect(nwid.getAttribute("style")).toBe("left: 40px;");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("reloads the preview in full when that file cannot be read", async () => {
|
|
118
|
+
const { nwid, reloadPreview } = await undo(false);
|
|
119
|
+
|
|
120
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -14,9 +14,12 @@ import {
|
|
|
14
14
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
15
15
|
import {
|
|
16
16
|
applyUndoRestoreToPreview,
|
|
17
|
+
readUndoNestedFiles,
|
|
17
18
|
showRestoreInPlace,
|
|
18
19
|
type RestoreFiles,
|
|
19
20
|
} from "../utils/gsapUndoRestore";
|
|
21
|
+
import { settleNestedReads } from "../utils/gsapSoftReload";
|
|
22
|
+
import { readProjectFileContent } from "../utils/studioFileHistory";
|
|
20
23
|
import { usePlayerStore } from "../player";
|
|
21
24
|
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
|
22
25
|
|
|
@@ -204,12 +207,24 @@ export function usePreviewPersistence({
|
|
|
204
207
|
// attributes onto the live DOM and re-runs the timeline at the SAME playhead,
|
|
205
208
|
// falling back to reloadPreview for anything structural (split/delete undo),
|
|
206
209
|
// multi-file, sub-comp, or a permanent soft-reload failure.
|
|
210
|
+
const projectId = usePlayerStore.getState().timelineProjectId;
|
|
211
|
+
const nestedFiles = await settleNestedReads(
|
|
212
|
+
projectId
|
|
213
|
+
? readUndoNestedFiles(
|
|
214
|
+
previewIframeRef.current,
|
|
215
|
+
activeCompPathRef.current,
|
|
216
|
+
restore.files,
|
|
217
|
+
(path) => readProjectFileContent(projectId, path),
|
|
218
|
+
)
|
|
219
|
+
: null,
|
|
220
|
+
);
|
|
207
221
|
const strategy = applyUndoRestoreToPreview(
|
|
208
222
|
previewIframeRef.current,
|
|
209
223
|
activeCompPathRef.current,
|
|
210
224
|
restore.files,
|
|
211
225
|
usePlayerStore.getState().currentTime,
|
|
212
226
|
reloadPreview,
|
|
227
|
+
nestedFiles,
|
|
213
228
|
);
|
|
214
229
|
if (strategy === "full") {
|
|
215
230
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
@@ -95,3 +95,51 @@ describe("useRazorSplit mutation versions", () => {
|
|
|
95
95
|
container.remove();
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
describe("useRazorSplit linked clips", () => {
|
|
100
|
+
afterEach(() => vi.restoreAllMocks());
|
|
101
|
+
|
|
102
|
+
it("splits every member of a linked pair in one cut", async () => {
|
|
103
|
+
const { usePlayerStore } = await import("../player");
|
|
104
|
+
const clip = (id: string, tag: string): TimelineElement => ({
|
|
105
|
+
id,
|
|
106
|
+
domId: id,
|
|
107
|
+
tag,
|
|
108
|
+
start: 0,
|
|
109
|
+
duration: 4,
|
|
110
|
+
track: tag === "audio" ? 1 : 0,
|
|
111
|
+
link: "lk-1",
|
|
112
|
+
});
|
|
113
|
+
const video = clip("talk", "video");
|
|
114
|
+
usePlayerStore.getState().setElements([video, clip("talk-audio", "audio")]);
|
|
115
|
+
const bodies: string[] = [];
|
|
116
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
|
117
|
+
const url = String(input);
|
|
118
|
+
if (url.includes("/files/")) return jsonResponse({ content: "", version: '"v0"' });
|
|
119
|
+
bodies.push(String(init?.body ?? ""));
|
|
120
|
+
return jsonResponse({ error: "stop" }, 409);
|
|
121
|
+
});
|
|
122
|
+
let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
|
|
123
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
124
|
+
function Harness() {
|
|
125
|
+
split = useRazorSplit({
|
|
126
|
+
projectId: "p1",
|
|
127
|
+
activeCompPath: "index.html",
|
|
128
|
+
showToast: vi.fn(),
|
|
129
|
+
writeProjectFile: vi.fn(),
|
|
130
|
+
recordEdit: vi.fn(),
|
|
131
|
+
reloadPreview: vi.fn(),
|
|
132
|
+
}).handleRazorSplit;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
await act(async () => root.render(React.createElement(Harness)));
|
|
136
|
+
await act(async () => {
|
|
137
|
+
await split?.(video, 2);
|
|
138
|
+
});
|
|
139
|
+
const targets = JSON.parse(bodies[0] ?? "{}").files[0].targets.map(
|
|
140
|
+
(cut: { originalId: string }) => cut.originalId,
|
|
141
|
+
);
|
|
142
|
+
expect(targets).toEqual(["talk", "talk-audio"]);
|
|
143
|
+
act(() => root.unmount());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -4,6 +4,8 @@ import { usePlayerStore } from "../player";
|
|
|
4
4
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
5
5
|
import { trackStudioRazorSplit } from "../telemetry/events";
|
|
6
6
|
import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
|
|
7
|
+
import { linkedMembersOf } from "../player/components/audioClipLink";
|
|
8
|
+
import { isLinkedSelectionOn } from "../utils/linkedClipPreferences";
|
|
7
9
|
import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
|
|
8
10
|
import type { RecordEditInput } from "./timelineEditingHelpers";
|
|
9
11
|
import { useFreezeFrame } from "./useFreezeFrame";
|
|
@@ -103,7 +105,16 @@ export function useRazorSplit({
|
|
|
103
105
|
}
|
|
104
106
|
if (!canSplitElementAt(element, splitTime)) return;
|
|
105
107
|
try {
|
|
106
|
-
const
|
|
108
|
+
const members = linkedMembersOf(
|
|
109
|
+
element,
|
|
110
|
+
usePlayerStore.getState().elements,
|
|
111
|
+
isLinkedSelectionOn(),
|
|
112
|
+
);
|
|
113
|
+
const result = await runCut(
|
|
114
|
+
members.filter((member) => canSplitElementAt(member, splitTime)),
|
|
115
|
+
splitTime,
|
|
116
|
+
"single",
|
|
117
|
+
);
|
|
107
118
|
if (!result) return;
|
|
108
119
|
if (result.syncFailed) return;
|
|
109
120
|
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
|
@@ -196,7 +196,7 @@ describe("useRenderClipContent", () => {
|
|
|
196
196
|
}
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
-
it("marks audio
|
|
199
|
+
it("marks hidden audio muted and leaves the link to the chain badge", () => {
|
|
200
200
|
usePlayerStore.setState({
|
|
201
201
|
thumbnailMode: "hidden",
|
|
202
202
|
elements: [
|
|
@@ -217,11 +217,12 @@ describe("useRenderClipContent", () => {
|
|
|
217
217
|
duration: 4,
|
|
218
218
|
track: 1,
|
|
219
219
|
src: "assets/clip.mp4",
|
|
220
|
+
link: "lk-1",
|
|
220
221
|
hidden: true,
|
|
221
222
|
});
|
|
222
|
-
expect(isValidElement<{ linked
|
|
223
|
-
if (isValidElement<{ linked
|
|
224
|
-
expect(linked.props.linked).
|
|
223
|
+
expect(isValidElement<{ linked?: boolean; muted: boolean }>(linked)).toBe(true);
|
|
224
|
+
if (isValidElement<{ linked?: boolean; muted: boolean }>(linked)) {
|
|
225
|
+
expect(linked.props.linked).toBeUndefined();
|
|
225
226
|
expect(linked.props.muted).toBe(true);
|
|
226
227
|
}
|
|
227
228
|
});
|
|
@@ -249,6 +250,7 @@ describe("useRenderClipContent", () => {
|
|
|
249
250
|
expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
|
|
250
251
|
"/api/projects/my-project/waveform/talk.mp4",
|
|
251
252
|
);
|
|
253
|
+
expect(isValidElement<{ labelInset?: number }>(waveform) && waveform.props.labelInset).toBe(0);
|
|
252
254
|
});
|
|
253
255
|
|
|
254
256
|
it("keeps a muted or silent video to thumbnails only", () => {
|
|
@@ -63,6 +63,7 @@ function renderAudioClip(
|
|
|
63
63
|
labelColor: string,
|
|
64
64
|
context: TimelineClipRenderContext,
|
|
65
65
|
elements: readonly TimelineElement[],
|
|
66
|
+
labelInset?: number,
|
|
66
67
|
): ReactNode {
|
|
67
68
|
const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
|
|
68
69
|
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
|
|
@@ -84,6 +85,7 @@ function renderAudioClip(
|
|
|
84
85
|
projectId: pid,
|
|
85
86
|
sessionEpoch,
|
|
86
87
|
priority: context.priority,
|
|
88
|
+
labelInset,
|
|
87
89
|
...audioPillFlags(el, elements),
|
|
88
90
|
});
|
|
89
91
|
return createElement(
|
|
@@ -103,10 +105,10 @@ function renderAudioClip(
|
|
|
103
105
|
function withSoundStrip(
|
|
104
106
|
el: TimelineElement,
|
|
105
107
|
thumbnail: ReactNode,
|
|
106
|
-
waveform: () => ReactNode,
|
|
108
|
+
waveform: (labelInset: number) => ReactNode,
|
|
107
109
|
): ReactNode {
|
|
108
110
|
if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
|
|
109
|
-
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
|
|
111
|
+
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform(0) });
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
export interface UseRenderClipContentOptions {
|
|
@@ -141,7 +143,8 @@ export function useRenderClipContent({
|
|
|
141
143
|
|
|
142
144
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
143
145
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
144
|
-
const waveform = () =>
|
|
146
|
+
const waveform = (labelInset?: number) =>
|
|
147
|
+
renderAudioClip(el, pid, sessionEpoch, style.label, context, elements, labelInset);
|
|
145
148
|
if (effectiveMode === "hidden") {
|
|
146
149
|
return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
|
|
147
150
|
}
|
|
@@ -10,6 +10,7 @@ import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
11
|
import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
|
|
12
12
|
import { captureDurationRollback } from "./timelineTimingSync";
|
|
13
|
+
import { setLinkInSource } from "../components/editor/mediaLinkEdits";
|
|
13
14
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
14
15
|
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
15
16
|
import {
|
|
@@ -60,6 +61,11 @@ interface UseTimelineDeleteOpsOptions {
|
|
|
60
61
|
// timelineGapCommit.ts.
|
|
61
62
|
let deleteGestureSeq = 0;
|
|
62
63
|
|
|
64
|
+
function unlinkInSource(source: string, survivors: readonly TimelineElement[]): string {
|
|
65
|
+
const targets = survivors.map(buildPatchTarget).filter((target) => target !== null);
|
|
66
|
+
return targets.length > 0 ? setLinkInSource(source, targets, null) : source;
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
export function useTimelineDeleteOps({
|
|
64
70
|
projectIdRef,
|
|
65
71
|
activeCompPath,
|
|
@@ -80,7 +86,7 @@ export function useTimelineDeleteOps({
|
|
|
80
86
|
// fallow-ignore-next-line complexity
|
|
81
87
|
const handleTimelineElementsDelete = useCallback(
|
|
82
88
|
// fallow-ignore-next-line complexity
|
|
83
|
-
async (selection: TimelineElement[]) => {
|
|
89
|
+
async (selection: TimelineElement[], alsoUnlink: readonly TimelineElement[] = []) => {
|
|
84
90
|
if (isRecordingRef?.current) {
|
|
85
91
|
showToast("Cannot edit timeline while recording", "error");
|
|
86
92
|
return;
|
|
@@ -150,9 +156,9 @@ export function useTimelineDeleteOps({
|
|
|
150
156
|
// Shrink to the furthest remaining clip end, read from the post-removal source:
|
|
151
157
|
// store durations are runtime-truncated.
|
|
152
158
|
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
|
153
|
-
const patchedContent =
|
|
154
|
-
removedContent,
|
|
155
|
-
|
|
159
|
+
const patchedContent = unlinkInSource(
|
|
160
|
+
setCompositionDurationToContent(removedContent, deleteContentEnd),
|
|
161
|
+
alsoUnlink,
|
|
156
162
|
);
|
|
157
163
|
// Optimistically reflect the shrunk length in the readout/seek bar,
|
|
158
164
|
// rolling it back if the persist below fails (see captureDurationRollback).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { useTimelineEditRefusal } from "./timelineEditPermission";
|
|
4
|
+
|
|
5
|
+
type GuardedTimelineHandler = (...args: never[]) => Promise<unknown>;
|
|
6
|
+
type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
|
|
7
|
+
type GuardedTimelineRefusal = (reason: string, ...args: never[]) => unknown;
|
|
8
|
+
|
|
9
|
+
interface GuardedTimelineEntry {
|
|
10
|
+
resolveTargets: GuardedTimelineResolver;
|
|
11
|
+
onRefused?: GuardedTimelineRefusal;
|
|
12
|
+
wrapped: GuardedTimelineHandler;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useTimelineEditGuard(
|
|
16
|
+
canEdit: Parameters<typeof useTimelineEditRefusal>[0],
|
|
17
|
+
showToast: Parameters<typeof useTimelineEditRefusal>[1],
|
|
18
|
+
) {
|
|
19
|
+
const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
|
|
20
|
+
const refuseEditRef = useRef(refuseEdit);
|
|
21
|
+
refuseEditRef.current = refuseEdit;
|
|
22
|
+
const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
|
|
23
|
+
// Refuses (no call, no write, no history entry) when any target is
|
|
24
|
+
// blocked; otherwise runs fn as before. Cached by fn identity — like
|
|
25
|
+
// track() — so a fresh closure here doesn't defeat track's own cache.
|
|
26
|
+
const guard = useCallback(
|
|
27
|
+
<H extends (...args: never[]) => Promise<unknown>>(
|
|
28
|
+
resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
|
|
29
|
+
fn: H,
|
|
30
|
+
onRefused?: (reason: string, ...args: Parameters<H>) => Awaited<ReturnType<H>>,
|
|
31
|
+
): H => {
|
|
32
|
+
const key = fn as unknown as GuardedTimelineHandler;
|
|
33
|
+
const cached = guardedRef.current.get(key);
|
|
34
|
+
if (cached) {
|
|
35
|
+
cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
36
|
+
cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
37
|
+
return cached.wrapped as H;
|
|
38
|
+
}
|
|
39
|
+
const entry = {} as GuardedTimelineEntry;
|
|
40
|
+
entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
|
|
41
|
+
entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
|
|
42
|
+
entry.wrapped = ((...args: Parameters<H>) => {
|
|
43
|
+
const reason = refuseEditRef.current(entry.resolveTargets(...(args as never[])));
|
|
44
|
+
if (reason !== null)
|
|
45
|
+
return Promise.resolve(entry.onRefused?.(reason, ...(args as never[])));
|
|
46
|
+
return fn(...args);
|
|
47
|
+
}) as H as unknown as GuardedTimelineHandler;
|
|
48
|
+
guardedRef.current.set(key, entry);
|
|
49
|
+
return entry.wrapped as H;
|
|
50
|
+
},
|
|
51
|
+
[],
|
|
52
|
+
);
|
|
53
|
+
return guard;
|
|
54
|
+
}
|