@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.
Files changed (183) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
  2. package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
  3. package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
  4. package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
  5. package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/chunk-6LUNLTHR.js +611 -0
  9. package/dist/chunk-6LUNLTHR.js.map +1 -0
  10. package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
  11. package/dist/chunk-PQFEBX32.js.map +1 -0
  12. package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
  13. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  14. package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  15. package/dist/index.d.ts +53 -4
  16. package/dist/index.html +2 -2
  17. package/dist/index.js +15746 -13173
  18. package/dist/index.js.map +1 -1
  19. package/dist/player.js +3 -3
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +1 -1
  22. package/package.json +8 -8
  23. package/src/App.tsx +8 -11
  24. package/src/components/EditorShell.tsx +6 -0
  25. package/src/components/LinkedSelectionToggle.tsx +73 -0
  26. package/src/components/TimelineToolbar.test.tsx +31 -0
  27. package/src/components/TimelineToolbar.tsx +2 -0
  28. package/src/components/editor/CropPresetBar.tsx +69 -0
  29. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  30. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  31. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  32. package/src/components/editor/InlineTextCaret.tsx +211 -0
  33. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  34. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  35. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  36. package/src/components/editor/carveOutput.ts +54 -0
  37. package/src/components/editor/cropPresetStore.ts +33 -0
  38. package/src/components/editor/cropPresets.test.ts +41 -0
  39. package/src/components/editor/cropPresets.ts +24 -0
  40. package/src/components/editor/domEditInlineText.ts +6 -4
  41. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  42. package/src/components/editor/domEditingLayers.test.ts +21 -0
  43. package/src/components/editor/domEditingLayers.ts +2 -0
  44. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  45. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  46. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  47. package/src/components/editor/manualOffsetDrag.ts +11 -0
  48. package/src/components/editor/mediaAudioEdits.test.ts +79 -4
  49. package/src/components/editor/mediaAudioEdits.ts +66 -24
  50. package/src/components/editor/mediaLinkEdits.test.ts +321 -0
  51. package/src/components/editor/mediaLinkEdits.ts +282 -0
  52. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  53. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  54. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
  55. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  56. package/src/components/editor/useDomEditNudge.ts +17 -12
  57. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  58. package/src/components/editor/useFxCarve.ts +3 -61
  59. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  60. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  61. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  62. package/src/components/editor/useVolumeAutomation.ts +29 -7
  63. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  64. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  65. package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
  66. package/src/components/ui/Menu.test.tsx +3 -22
  67. package/src/components/ui/Menu.tsx +2 -2
  68. package/src/components/ui/Popover.tsx +2 -2
  69. package/src/components/ui/Select.tsx +2 -2
  70. package/src/components/ui/Tooltip.tsx +2 -2
  71. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  72. package/src/components/ui/valueControls.test.tsx +2 -21
  73. package/src/contexts/TimelineEditContext.tsx +4 -0
  74. package/src/hooks/appHotkeysDispatch.ts +2 -1
  75. package/src/hooks/linkShortcuts.test.ts +70 -0
  76. package/src/hooks/linkShortcuts.ts +41 -0
  77. package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
  78. package/src/hooks/timelineLinkEditPlan.ts +123 -0
  79. package/src/hooks/timelineMoveAdapter.ts +13 -0
  80. package/src/hooks/timelineTimingSync.test.ts +59 -0
  81. package/src/hooks/timelineTimingSync.ts +22 -3
  82. package/src/hooks/useAppHotkeys.ts +9 -0
  83. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  84. package/src/hooks/useEditHistoryActions.ts +13 -4
  85. package/src/hooks/useElementPicker.test.ts +62 -13
  86. package/src/hooks/useElementPicker.ts +10 -2
  87. package/src/hooks/useFreezeFrame.test.ts +56 -0
  88. package/src/hooks/useFreezeFrame.ts +121 -0
  89. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  90. package/src/hooks/useGsapScriptCommits.ts +23 -3
  91. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  92. package/src/hooks/usePersistentEditHistory.ts +1 -1
  93. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  94. package/src/hooks/usePreviewPersistence.ts +15 -0
  95. package/src/hooks/useRazorSplit.test.tsx +48 -0
  96. package/src/hooks/useRazorSplit.ts +26 -2
  97. package/src/hooks/useRenderClipContent.test.ts +108 -4
  98. package/src/hooks/useRenderClipContent.ts +33 -23
  99. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  100. package/src/hooks/useTimelineEditGuard.ts +54 -0
  101. package/src/hooks/useTimelineEditing.ts +38 -57
  102. package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
  103. package/src/hooks/useTimelineLinkEditing.ts +153 -0
  104. package/src/index.ts +2 -0
  105. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  106. package/src/player/components/AudioWaveform.test.tsx +30 -3
  107. package/src/player/components/AudioWaveform.tsx +6 -7
  108. package/src/player/components/ClipBadges.test.tsx +82 -0
  109. package/src/player/components/ClipBadges.tsx +67 -0
  110. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  111. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  112. package/src/player/components/ClipContextMenu.tsx +124 -77
  113. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  114. package/src/player/components/ClipPeakMarks.tsx +71 -0
  115. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  116. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  117. package/src/player/components/TimelineClip.test.tsx +17 -0
  118. package/src/player/components/TimelineClip.tsx +8 -0
  119. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  120. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  121. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  122. package/src/player/components/audioClipLink.test.ts +228 -31
  123. package/src/player/components/audioClipLink.ts +72 -25
  124. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
  125. package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
  126. package/src/player/components/clipMenuAudioItems.tsx +116 -0
  127. package/src/player/components/clipMenuDuck.test.ts +117 -0
  128. package/src/player/components/clipMenuDuck.ts +147 -0
  129. package/src/player/components/clipMenuLinkItems.test.ts +90 -0
  130. package/src/player/components/clipMenuLinkItems.tsx +121 -0
  131. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  132. package/src/player/components/clipMenuNormalize.ts +96 -0
  133. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  134. package/src/player/components/clipMenuToolItems.tsx +233 -0
  135. package/src/player/components/clipPeakRuns.test.ts +38 -0
  136. package/src/player/components/clipPeakRuns.ts +59 -0
  137. package/src/player/components/clipSync.test.ts +67 -0
  138. package/src/player/components/clipSync.ts +80 -0
  139. package/src/player/components/clipToolAttrs.test.ts +161 -0
  140. package/src/player/components/clipToolAttrs.ts +187 -0
  141. package/src/player/components/linkLabelColor.test.ts +20 -0
  142. package/src/player/components/linkLabelColor.ts +24 -0
  143. package/src/player/components/timelineCallbacks.ts +12 -0
  144. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  145. package/src/player/components/timelineClipDragTypes.ts +2 -0
  146. package/src/player/components/timelineClipGestureHandlers.ts +4 -1
  147. package/src/player/components/timelineLinkSelection.ts +23 -0
  148. package/src/player/components/useClipToolState.ts +92 -0
  149. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  150. package/src/player/components/useTimelineClipDrag.ts +21 -0
  151. package/src/player/components/useTimelineProviderState.tsx +3 -0
  152. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  153. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  154. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  155. package/src/player/lib/timelineDOM.test.ts +31 -0
  156. package/src/player/lib/timelineDOM.ts +13 -0
  157. package/src/player/lib/timelineElementHelpers.ts +9 -0
  158. package/src/player/store/timelineElement.ts +12 -0
  159. package/src/styles/components.css +27 -11
  160. package/src/styles/theme.css +0 -1
  161. package/src/timelineChromeExports.test.tsx +7 -0
  162. package/src/utils/audioMeterMath.test.ts +37 -0
  163. package/src/utils/audioMeterMath.ts +9 -3
  164. package/src/utils/exportPeakCeiling.ts +1 -0
  165. package/src/utils/gsapSoftReload.test.ts +238 -2
  166. package/src/utils/gsapSoftReload.ts +89 -74
  167. package/src/utils/gsapUndoRestore.test.ts +4 -3
  168. package/src/utils/gsapUndoRestore.ts +15 -0
  169. package/src/utils/linkedClipPreferences.ts +34 -0
  170. package/src/utils/previewRelativePath.ts +19 -0
  171. package/src/utils/softReloadTargets.ts +128 -0
  172. package/src/utils/sourcePatcher.ts +1 -1
  173. package/src/utils/studioPendingEdits.test.ts +66 -0
  174. package/src/utils/studioPendingEdits.ts +45 -11
  175. package/src/utils/studioUiPreferences.ts +8 -0
  176. package/src/utils/timelineInspector.test.ts +33 -2
  177. package/src/utils/timelineInspector.ts +16 -4
  178. package/dist/chunk-46XP7J3B.js.map +0 -1
  179. package/dist/chunk-4OZC426H.js.map +0 -1
  180. package/dist/chunk-DI4LCJHL.js +0 -102
  181. package/dist/chunk-DI4LCJHL.js.map +0 -1
  182. package/dist/chunk-NCUBAVGK.js.map +0 -1
  183. /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 result = await runCut([element], splitTime, "single");
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
- return { handleRazorSplit, handleRazorSplitAll };
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 linked when a video clip uses the same file, and muted when hidden", () => {
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: boolean; muted: boolean }>(linked)).toBe(true);
173
- if (isValidElement<{ linked: boolean; muted: boolean }>(linked)) {
174
- expect(linked.props.linked).toBe(true);
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
- return createElement(AudioWaveform, {
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
- return createElement(VideoThumbnail, {
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 = setCompositionDurationToContent(
154
- removedContent,
155
- deleteContentEnd,
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
+ }