@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
|
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
|
|
|
6
6
|
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
|
7
7
|
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
8
8
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
9
|
+
import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
|
|
9
10
|
|
|
10
11
|
/** Minimal valid fixture — TimelineElement only requires these five fields. */
|
|
11
12
|
const bgmElement: TimelineElement = {
|
|
@@ -474,3 +475,36 @@ describe("hotkeys with the preview read-only", () => {
|
|
|
474
475
|
expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
|
|
475
476
|
});
|
|
476
477
|
});
|
|
478
|
+
|
|
479
|
+
describe("dispatchPlainKey — G opens Audio Gain", () => {
|
|
480
|
+
const video: TimelineElement = {
|
|
481
|
+
id: "b-roll",
|
|
482
|
+
key: "b-roll",
|
|
483
|
+
tag: "video",
|
|
484
|
+
start: 0,
|
|
485
|
+
duration: 4,
|
|
486
|
+
track: 1,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
afterEach(() => useAudioGainDialogStore.getState().close());
|
|
490
|
+
|
|
491
|
+
it("opens the dialog for the selected clips with sound and owns the key", () => {
|
|
492
|
+
usePlayerStore.setState({
|
|
493
|
+
elements: [bgmElement, video],
|
|
494
|
+
selectedElementId: "bgm",
|
|
495
|
+
selectedElementIds: new Set(["bgm", "b-roll"]),
|
|
496
|
+
});
|
|
497
|
+
const event = press("g");
|
|
498
|
+
dispatchPlainKey(event, "g", callbacks());
|
|
499
|
+
expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["bgm"]);
|
|
500
|
+
expect(event.defaultPrevented).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("leaves G to the grid toggle when nothing selected has sound", () => {
|
|
504
|
+
usePlayerStore.setState({ elements: [video], selectedElementId: "b-roll" });
|
|
505
|
+
const event = press("g");
|
|
506
|
+
dispatchPlainKey(event, "g", callbacks());
|
|
507
|
+
expect(useAudioGainDialogStore.getState().targetKeys).toBeNull();
|
|
508
|
+
expect(event.defaultPrevented).toBe(false);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
6
|
+
import { dispatchLinkShortcut, type LinkShortcutCallbacks } from "./linkShortcuts";
|
|
5
7
|
import { useCaptionStore } from "../captions/store";
|
|
6
8
|
import {
|
|
7
9
|
applyCaptionModelToIframe,
|
|
@@ -71,6 +73,8 @@ function tryApplyBeatHistory(
|
|
|
71
73
|
|
|
72
74
|
interface UseAppHotkeysParams {
|
|
73
75
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
76
|
+
handleLinkEdit?: LinkShortcutCallbacks["handleLinkEdit"];
|
|
77
|
+
handleTimelineElementDeleteOnly?: LinkShortcutCallbacks["handleTimelineElementDeleteOnly"];
|
|
74
78
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
75
79
|
handleDomEditElementDelete: (
|
|
76
80
|
selection: DomEditSelection,
|
|
@@ -114,6 +118,8 @@ interface UseAppHotkeysParams {
|
|
|
114
118
|
|
|
115
119
|
export function useAppHotkeys({
|
|
116
120
|
handleTimelineElementsDelete,
|
|
121
|
+
handleLinkEdit,
|
|
122
|
+
handleTimelineElementDeleteOnly,
|
|
117
123
|
handleTimelineElementSplit,
|
|
118
124
|
handleDomEditElementDelete,
|
|
119
125
|
domEditSelectionRef,
|
|
@@ -192,6 +198,8 @@ export function useAppHotkeys({
|
|
|
192
198
|
const cbRef = useRef<HotkeyCallbacks>(null!);
|
|
193
199
|
cbRef.current = {
|
|
194
200
|
handleTimelineElementsDelete,
|
|
201
|
+
handleLinkEdit,
|
|
202
|
+
handleTimelineElementDeleteOnly,
|
|
195
203
|
handleTimelineElementSplit,
|
|
196
204
|
handleDomEditElementDelete,
|
|
197
205
|
handleUndo,
|
|
@@ -215,6 +223,7 @@ export function useAppHotkeys({
|
|
|
215
223
|
const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
|
|
216
224
|
const cb = cbRef.current;
|
|
217
225
|
const key = event.key.toLowerCase();
|
|
226
|
+
if (!isTypingTarget(event.target) && dispatchLinkShortcut(event, cb)) return;
|
|
218
227
|
if (event.metaKey || event.ctrlKey) {
|
|
219
228
|
dispatchModifierKey(event, key, cb);
|
|
220
229
|
return;
|
|
@@ -443,7 +443,14 @@ describe("useDomGeometryCommits resize of a cropped element", () => {
|
|
|
443
443
|
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
444
444
|
const press = (type: string, clientX: number) =>
|
|
445
445
|
act(() =>
|
|
446
|
-
handle.dispatchEvent(
|
|
446
|
+
handle.dispatchEvent(
|
|
447
|
+
new PointerEvent(type, {
|
|
448
|
+
bubbles: true,
|
|
449
|
+
pointerId: 3,
|
|
450
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
451
|
+
clientX,
|
|
452
|
+
}),
|
|
453
|
+
),
|
|
447
454
|
);
|
|
448
455
|
press("pointerdown", 100);
|
|
449
456
|
press("pointermove", 100 - by);
|
|
@@ -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", () => {
|
|
@@ -14,6 +14,7 @@ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWavefo
|
|
|
14
14
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
15
15
|
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
16
16
|
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
17
|
+
import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
|
|
17
18
|
import { clipHasSound } from "../player/components/clipMenuNormalize";
|
|
18
19
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
19
20
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
@@ -63,6 +64,7 @@ function renderAudioClip(
|
|
|
63
64
|
labelColor: string,
|
|
64
65
|
context: TimelineClipRenderContext,
|
|
65
66
|
elements: readonly TimelineElement[],
|
|
67
|
+
labelInset?: number,
|
|
66
68
|
): ReactNode {
|
|
67
69
|
const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
|
|
68
70
|
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
|
|
@@ -84,16 +86,14 @@ function renderAudioClip(
|
|
|
84
86
|
projectId: pid,
|
|
85
87
|
sessionEpoch,
|
|
86
88
|
priority: context.priority,
|
|
89
|
+
labelInset,
|
|
87
90
|
...audioPillFlags(el, elements),
|
|
88
91
|
});
|
|
89
92
|
return createElement(
|
|
90
93
|
ClipPeakMarks,
|
|
91
94
|
{
|
|
92
|
-
peaksUrl:
|
|
93
|
-
sourceWindow:
|
|
94
|
-
mediaStart: el.playbackStart ?? 0,
|
|
95
|
-
sourceSpan: el.duration * (el.playbackRate ?? 1),
|
|
96
|
-
},
|
|
95
|
+
peaksUrl: clipPeaksUrl(el.src, pid),
|
|
96
|
+
sourceWindow: clipSourceWindow(el),
|
|
97
97
|
gain: el.volume ?? 1,
|
|
98
98
|
},
|
|
99
99
|
waveform,
|
|
@@ -103,10 +103,10 @@ function renderAudioClip(
|
|
|
103
103
|
function withSoundStrip(
|
|
104
104
|
el: TimelineElement,
|
|
105
105
|
thumbnail: ReactNode,
|
|
106
|
-
waveform: () => ReactNode,
|
|
106
|
+
waveform: (labelInset: number) => ReactNode,
|
|
107
107
|
): ReactNode {
|
|
108
108
|
if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
|
|
109
|
-
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
|
|
109
|
+
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform(0) });
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
export interface UseRenderClipContentOptions {
|
|
@@ -141,7 +141,8 @@ export function useRenderClipContent({
|
|
|
141
141
|
|
|
142
142
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
143
143
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
144
|
-
const waveform = () =>
|
|
144
|
+
const waveform = (labelInset?: number) =>
|
|
145
|
+
renderAudioClip(el, pid, sessionEpoch, style.label, context, elements, labelInset);
|
|
145
146
|
if (effectiveMode === "hidden") {
|
|
146
147
|
return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
|
|
147
148
|
}
|
|
@@ -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).
|