@hyperframes/studio 0.8.107 → 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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.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-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -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/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- 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/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -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/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/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- 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 +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -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 +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- 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 +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- 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 +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- 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 +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -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 +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -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/useClipToolState.ts +92 -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/lib/timelineElementHelpers.ts +9 -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/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- 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/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -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,8 +4,11 @@ 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";
|
|
11
|
+
import { useFreezeFrame } from "./useFreezeFrame";
|
|
9
12
|
|
|
10
13
|
interface UseRazorSplitOptions {
|
|
11
14
|
projectId: string | null;
|
|
@@ -102,7 +105,16 @@ export function useRazorSplit({
|
|
|
102
105
|
}
|
|
103
106
|
if (!canSplitElementAt(element, splitTime)) return;
|
|
104
107
|
try {
|
|
105
|
-
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
|
+
);
|
|
106
118
|
if (!result) return;
|
|
107
119
|
if (result.syncFailed) return;
|
|
108
120
|
showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
|
|
@@ -135,5 +147,17 @@ export function useRazorSplit({
|
|
|
135
147
|
[isRecordingRef, runCut, showToast],
|
|
136
148
|
);
|
|
137
149
|
|
|
138
|
-
|
|
150
|
+
const handleFreezeFrame = useFreezeFrame({
|
|
151
|
+
projectId,
|
|
152
|
+
activeCompPath,
|
|
153
|
+
showToast,
|
|
154
|
+
writeProjectFile,
|
|
155
|
+
observeProjectFileVersion,
|
|
156
|
+
recordEdit,
|
|
157
|
+
reloadPreview,
|
|
158
|
+
forceReloadSdkSession,
|
|
159
|
+
isRecordingRef,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame };
|
|
139
163
|
}
|
|
@@ -5,6 +5,14 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
7
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
8
|
+
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
9
|
+
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
10
|
+
|
|
11
|
+
function unwrapPeakMarks(node: ReactNode): ReactNode {
|
|
12
|
+
return isValidElement<{ children?: ReactNode }>(node) && node.type === ClipPeakMarks
|
|
13
|
+
? node.props.children
|
|
14
|
+
: node;
|
|
15
|
+
}
|
|
8
16
|
import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
|
|
9
17
|
import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
|
|
10
18
|
import { buildCompositionThumbnailUrl } from "../player/components/CompositionThumbnail";
|
|
@@ -93,9 +101,51 @@ describe("useRenderClipContent", () => {
|
|
|
93
101
|
root.render(React.createElement(Harness));
|
|
94
102
|
});
|
|
95
103
|
act(() => root.unmount());
|
|
104
|
+
return unwrapPeakMarks(content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderRaw(el: TimelineElement): ReactNode {
|
|
108
|
+
const host = document.createElement("div");
|
|
109
|
+
document.body.append(host);
|
|
110
|
+
const root = createRoot(host);
|
|
111
|
+
let content: ReactNode = null;
|
|
112
|
+
function Harness() {
|
|
113
|
+
const render = useRenderClipContent({
|
|
114
|
+
projectIdRef: { current: "my-project" },
|
|
115
|
+
compIdToSrc: new Map(),
|
|
116
|
+
activePreviewUrl: null,
|
|
117
|
+
effectiveTimelineDuration: 12,
|
|
118
|
+
});
|
|
119
|
+
content = render(el, { clip: "#222", label: "#fff" });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
act(() => root.render(React.createElement(Harness)));
|
|
123
|
+
act(() => root.unmount());
|
|
96
124
|
return content;
|
|
97
125
|
}
|
|
98
126
|
|
|
127
|
+
it("wraps a clip's waveform in peak marks at its own gain over its played source window", () => {
|
|
128
|
+
const content = renderRaw({
|
|
129
|
+
id: "voiceover",
|
|
130
|
+
tag: "audio",
|
|
131
|
+
start: 1,
|
|
132
|
+
duration: 4,
|
|
133
|
+
track: 1,
|
|
134
|
+
src: "assets/voiceover.mp3",
|
|
135
|
+
playbackStart: 2,
|
|
136
|
+
playbackRate: 1.5,
|
|
137
|
+
volume: 2,
|
|
138
|
+
});
|
|
139
|
+
expect(isValidElement(content) && content.type).toBe(ClipPeakMarks);
|
|
140
|
+
if (isValidElement<Record<string, unknown>>(content)) {
|
|
141
|
+
expect(content.props).toMatchObject({
|
|
142
|
+
peaksUrl: "/api/projects/my-project/peaks/assets/voiceover.mp3",
|
|
143
|
+
sourceWindow: { mediaStart: 2, sourceSpan: 6 },
|
|
144
|
+
gain: 2,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
99
149
|
it("renders audio clips as waveforms even when a composition preview URL is active", () => {
|
|
100
150
|
const content = renderClipContent({
|
|
101
151
|
id: "voiceover",
|
|
@@ -146,7 +196,7 @@ describe("useRenderClipContent", () => {
|
|
|
146
196
|
}
|
|
147
197
|
});
|
|
148
198
|
|
|
149
|
-
it("marks audio
|
|
199
|
+
it("marks hidden audio muted and leaves the link to the chain badge", () => {
|
|
150
200
|
usePlayerStore.setState({
|
|
151
201
|
thumbnailMode: "hidden",
|
|
152
202
|
elements: [
|
|
@@ -167,15 +217,69 @@ describe("useRenderClipContent", () => {
|
|
|
167
217
|
duration: 4,
|
|
168
218
|
track: 1,
|
|
169
219
|
src: "assets/clip.mp4",
|
|
220
|
+
link: "lk-1",
|
|
170
221
|
hidden: true,
|
|
171
222
|
});
|
|
172
|
-
expect(isValidElement<{ linked
|
|
173
|
-
if (isValidElement<{ linked
|
|
174
|
-
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();
|
|
175
226
|
expect(linked.props.muted).toBe(true);
|
|
176
227
|
}
|
|
177
228
|
});
|
|
178
229
|
|
|
230
|
+
it("draws a waveform strip under the thumbnails of a video with sound", () => {
|
|
231
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
232
|
+
const content = renderClipContent(
|
|
233
|
+
{
|
|
234
|
+
id: "talk",
|
|
235
|
+
tag: "video",
|
|
236
|
+
start: 0,
|
|
237
|
+
duration: 4,
|
|
238
|
+
track: 0,
|
|
239
|
+
src: "talk.mp4",
|
|
240
|
+
hasAudio: true,
|
|
241
|
+
},
|
|
242
|
+
null,
|
|
243
|
+
);
|
|
244
|
+
expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true);
|
|
245
|
+
if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return;
|
|
246
|
+
expect(content.type).toBe(AudibleVideoClipContent);
|
|
247
|
+
const { thumbnail } = content.props;
|
|
248
|
+
const waveform = unwrapPeakMarks(content.props.waveform);
|
|
249
|
+
expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail);
|
|
250
|
+
expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
|
|
251
|
+
"/api/projects/my-project/waveform/talk.mp4",
|
|
252
|
+
);
|
|
253
|
+
expect(isValidElement<{ labelInset?: number }>(waveform) && waveform.props.labelInset).toBe(0);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps a muted or silent video to thumbnails only", () => {
|
|
257
|
+
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
258
|
+
for (const extra of [{ hasAudio: true, muted: true }, { hasAudio: false }]) {
|
|
259
|
+
const content = renderClipContent(
|
|
260
|
+
{ id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", ...extra },
|
|
261
|
+
null,
|
|
262
|
+
);
|
|
263
|
+
expect(isValidElement(content) && content.type).toBe(VideoThumbnail);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("still shows the sound strip when thumbnails are off", () => {
|
|
268
|
+
const content = renderClipContent(
|
|
269
|
+
{
|
|
270
|
+
id: "talk",
|
|
271
|
+
tag: "video",
|
|
272
|
+
start: 0,
|
|
273
|
+
duration: 4,
|
|
274
|
+
track: 0,
|
|
275
|
+
src: "talk.mp4",
|
|
276
|
+
hasAudio: true,
|
|
277
|
+
},
|
|
278
|
+
null,
|
|
279
|
+
);
|
|
280
|
+
expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent);
|
|
281
|
+
});
|
|
282
|
+
|
|
179
283
|
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
180
284
|
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
181
285
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
|
+
import { resolvePreviewRelative } from "../utils/previewRelativePath";
|
|
2
3
|
import { useCallback, type ReactNode } from "react";
|
|
3
4
|
import { createElement } from "react";
|
|
4
5
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
@@ -11,6 +12,9 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp
|
|
|
11
12
|
import { audioPillFlags } from "../player/components/audioClipLink";
|
|
12
13
|
import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
|
|
13
14
|
import { ImageThumbnail } from "../player/components/ImageThumbnail";
|
|
15
|
+
import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
|
|
16
|
+
import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
|
|
17
|
+
import { clipHasSound } from "../player/components/clipMenuNormalize";
|
|
14
18
|
import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
|
|
15
19
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
16
20
|
import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
|
|
@@ -33,24 +37,6 @@ export function normalizeCompositionSrc(
|
|
|
33
37
|
return compSrc;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/** Resolve a media src to its project-relative preview path, or null. */
|
|
37
|
-
function resolvePreviewRelative(
|
|
38
|
-
src: string | undefined,
|
|
39
|
-
pid: string,
|
|
40
|
-
origin: string,
|
|
41
|
-
): string | null {
|
|
42
|
-
if (!src) return null;
|
|
43
|
-
try {
|
|
44
|
-
const parsed = new URL(src, origin);
|
|
45
|
-
const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
|
|
46
|
-
return parsed.pathname.startsWith(base)
|
|
47
|
-
? decodeURIComponent(parsed.pathname.slice(base.length))
|
|
48
|
-
: null;
|
|
49
|
-
} catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
40
|
/**
|
|
55
41
|
* The trimmed source slice as start/end fractions (0–1) of the source, so the
|
|
56
42
|
* waveform can window its peaks to the clip edges. Undefined when the source
|
|
@@ -77,6 +63,7 @@ function renderAudioClip(
|
|
|
77
63
|
labelColor: string,
|
|
78
64
|
context: TimelineClipRenderContext,
|
|
79
65
|
elements: readonly TimelineElement[],
|
|
66
|
+
labelInset?: number,
|
|
80
67
|
): ReactNode {
|
|
81
68
|
const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
|
|
82
69
|
const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
|
|
@@ -88,7 +75,7 @@ function renderAudioClip(
|
|
|
88
75
|
? buildProjectApiPath(pid, `/waveform/${encodedRelative}`)
|
|
89
76
|
: undefined;
|
|
90
77
|
const { start, end } = trimFractions(el);
|
|
91
|
-
|
|
78
|
+
const waveform = createElement(AudioWaveform, {
|
|
92
79
|
audioUrl,
|
|
93
80
|
waveformUrl,
|
|
94
81
|
label: "",
|
|
@@ -98,8 +85,30 @@ function renderAudioClip(
|
|
|
98
85
|
projectId: pid,
|
|
99
86
|
sessionEpoch,
|
|
100
87
|
priority: context.priority,
|
|
88
|
+
labelInset,
|
|
101
89
|
...audioPillFlags(el, elements),
|
|
102
90
|
});
|
|
91
|
+
return createElement(
|
|
92
|
+
ClipPeakMarks,
|
|
93
|
+
{
|
|
94
|
+
peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
|
|
95
|
+
sourceWindow: {
|
|
96
|
+
mediaStart: el.playbackStart ?? 0,
|
|
97
|
+
sourceSpan: el.duration * (el.playbackRate ?? 1),
|
|
98
|
+
},
|
|
99
|
+
gain: el.volume ?? 1,
|
|
100
|
+
},
|
|
101
|
+
waveform,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function withSoundStrip(
|
|
106
|
+
el: TimelineElement,
|
|
107
|
+
thumbnail: ReactNode,
|
|
108
|
+
waveform: (labelInset: number) => ReactNode,
|
|
109
|
+
): ReactNode {
|
|
110
|
+
if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
|
|
111
|
+
return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform(0) });
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
export interface UseRenderClipContentOptions {
|
|
@@ -134,10 +143,10 @@ export function useRenderClipContent({
|
|
|
134
143
|
|
|
135
144
|
// Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
|
|
136
145
|
// its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
|
|
146
|
+
const waveform = (labelInset?: number) =>
|
|
147
|
+
renderAudioClip(el, pid, sessionEpoch, style.label, context, elements, labelInset);
|
|
137
148
|
if (effectiveMode === "hidden") {
|
|
138
|
-
return rendersWaveform(el)
|
|
139
|
-
? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
|
|
140
|
-
: null;
|
|
149
|
+
return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
let compSrc = el.compositionSrc;
|
|
@@ -222,7 +231,7 @@ export function useRenderClipContent({
|
|
|
222
231
|
rich: context.rich,
|
|
223
232
|
});
|
|
224
233
|
}
|
|
225
|
-
|
|
234
|
+
const thumbnail = createElement(VideoThumbnail, {
|
|
226
235
|
videoSrc: mediaSrc,
|
|
227
236
|
label: "",
|
|
228
237
|
labelColor: style.label,
|
|
@@ -233,6 +242,7 @@ export function useRenderClipContent({
|
|
|
233
242
|
sessionEpoch,
|
|
234
243
|
priority: context.priority,
|
|
235
244
|
});
|
|
245
|
+
return withSoundStrip(el, thumbnail, waveform);
|
|
236
246
|
}
|
|
237
247
|
|
|
238
248
|
if (htmlPreviewEligible) {
|
|
@@ -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
|
+
}
|