@hyperframes/studio 0.8.107 → 0.8.108

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 (99) hide show
  1. package/dist/assets/{hyperframes-player-h4mktSdm.js → hyperframes-player-BakGew45.js} +1 -1
  2. package/dist/assets/{index-D8GKc2D9.js → index-C5dyUHwk.js} +1 -1
  3. package/dist/assets/{index-CM1xA_jD.js → index-C842tcVa.js} +165 -165
  4. package/dist/assets/{index-zoqPmBF6.js → index-CPe7d_wO.js} +1 -1
  5. package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
  6. package/dist/chunk-6LUNLTHR.js +611 -0
  7. package/dist/chunk-6LUNLTHR.js.map +1 -0
  8. package/dist/{chunk-4OZC426H.js → chunk-AOWVEVEU.js} +3 -503
  9. package/dist/chunk-AOWVEVEU.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/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
  13. package/dist/index.d.ts +11 -2
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +12416 -11195
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/package.json +8 -8
  20. package/src/App.tsx +1 -0
  21. package/src/components/EditorShell.tsx +2 -0
  22. package/src/components/editor/CropPresetBar.tsx +69 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +123 -95
  25. package/src/components/editor/carveOutput.ts +54 -0
  26. package/src/components/editor/cropPresetStore.ts +33 -0
  27. package/src/components/editor/cropPresets.test.ts +41 -0
  28. package/src/components/editor/cropPresets.ts +24 -0
  29. package/src/components/editor/domEditingLayers.test.ts +21 -0
  30. package/src/components/editor/domEditingLayers.ts +2 -0
  31. package/src/components/editor/dragUndoPaint.test.ts +71 -0
  32. package/src/components/editor/manualOffsetDrag.ts +11 -0
  33. package/src/components/editor/mediaAudioEdits.test.ts +59 -0
  34. package/src/components/editor/mediaAudioEdits.ts +40 -11
  35. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
  36. package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +7 -3
  38. package/src/components/editor/useDomEditNudge.ts +17 -12
  39. package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
  40. package/src/components/editor/useFxCarve.ts +3 -61
  41. package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
  42. package/src/components/editor/useFxCarveGrouping.ts +8 -3
  43. package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
  44. package/src/components/nle/AudioMeterStrip.tsx +60 -2
  45. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  46. package/src/contexts/TimelineEditContext.tsx +1 -0
  47. package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
  48. package/src/hooks/useEditHistoryActions.ts +13 -4
  49. package/src/hooks/useElementPicker.test.ts +62 -13
  50. package/src/hooks/useElementPicker.ts +10 -2
  51. package/src/hooks/useFreezeFrame.test.ts +56 -0
  52. package/src/hooks/useFreezeFrame.ts +121 -0
  53. package/src/hooks/usePersistentEditHistory.test.ts +9 -6
  54. package/src/hooks/usePersistentEditHistory.ts +1 -1
  55. package/src/hooks/useRazorSplit.ts +14 -1
  56. package/src/hooks/useRenderClipContent.test.ts +102 -0
  57. package/src/hooks/useRenderClipContent.ts +30 -23
  58. package/src/hooks/useTimelineEditing.ts +2 -2
  59. package/src/index.ts +1 -0
  60. package/src/player/components/AudibleVideoClipContent.tsx +29 -0
  61. package/src/player/components/ClipBadges.test.tsx +82 -0
  62. package/src/player/components/ClipBadges.tsx +67 -0
  63. package/src/player/components/ClipContextMenu.tsx +22 -0
  64. package/src/player/components/ClipPeakMarks.test.tsx +41 -0
  65. package/src/player/components/ClipPeakMarks.tsx +71 -0
  66. package/src/player/components/TimelineClip.tsx +2 -0
  67. package/src/player/components/TimelineFxButton.test.tsx +2 -2
  68. package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
  69. package/src/player/components/TimelineTrackHeader.tsx +12 -25
  70. package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
  71. package/src/player/components/clipMenuAudioItems.tsx +108 -0
  72. package/src/player/components/clipMenuDuck.test.ts +117 -0
  73. package/src/player/components/clipMenuDuck.ts +142 -0
  74. package/src/player/components/clipMenuNormalize.test.ts +95 -0
  75. package/src/player/components/clipMenuNormalize.ts +96 -0
  76. package/src/player/components/clipMenuToolItems.test.tsx +139 -0
  77. package/src/player/components/clipMenuToolItems.tsx +235 -0
  78. package/src/player/components/clipPeakRuns.test.ts +38 -0
  79. package/src/player/components/clipPeakRuns.ts +59 -0
  80. package/src/player/components/clipToolAttrs.test.ts +161 -0
  81. package/src/player/components/clipToolAttrs.ts +187 -0
  82. package/src/player/components/timelineCallbacks.ts +1 -0
  83. package/src/player/components/useClipToolState.ts +92 -0
  84. package/src/player/lib/timelineElementHelpers.ts +9 -0
  85. package/src/timelineChromeExports.test.tsx +7 -0
  86. package/src/utils/audioMeterMath.test.ts +37 -0
  87. package/src/utils/audioMeterMath.ts +9 -3
  88. package/src/utils/exportPeakCeiling.ts +1 -0
  89. package/src/utils/previewRelativePath.ts +19 -0
  90. package/src/utils/sourcePatcher.ts +1 -1
  91. package/src/utils/studioPendingEdits.test.ts +66 -0
  92. package/src/utils/studioPendingEdits.ts +45 -11
  93. package/src/utils/timelineInspector.test.ts +33 -2
  94. package/src/utils/timelineInspector.ts +16 -4
  95. package/dist/chunk-46XP7J3B.js.map +0 -1
  96. package/dist/chunk-4OZC426H.js.map +0 -1
  97. package/dist/chunk-DI4LCJHL.js +0 -102
  98. package/dist/chunk-DI4LCJHL.js.map +0 -1
  99. /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
@@ -0,0 +1,121 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { toAuthoredStart } from "../player/store/timelineElement";
4
+ import { buildPatchTarget } from "../utils/timelineElementSplit";
5
+ import { buildProjectApiPath } from "../utils/projectRouting";
6
+ import { markStudioWriteToken } from "../utils/studioFileVersion";
7
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
8
+ import type { RecordEditInput } from "../utils/studioFileHistory";
9
+
10
+ type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
+
12
+ interface FreezeFrameResponse {
13
+ before: string;
14
+ after: string;
15
+ version: string;
16
+ }
17
+
18
+ function isFreezeFrameResponse(value: unknown): value is FreezeFrameResponse {
19
+ if (typeof value !== "object" || value === null) return false;
20
+ const body: Partial<Record<keyof FreezeFrameResponse, unknown>> = value;
21
+ return (
22
+ typeof body.before === "string" &&
23
+ typeof body.after === "string" &&
24
+ typeof body.version === "string"
25
+ );
26
+ }
27
+
28
+ function errorOf(value: unknown): string | null {
29
+ if (typeof value !== "object" || value === null || !("error" in value)) return null;
30
+ return typeof value.error === "string" ? value.error : null;
31
+ }
32
+
33
+ async function readVersion(projectId: string, path: string): Promise<string> {
34
+ const response = await fetch(
35
+ buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
36
+ );
37
+ const body: unknown = await response.json().catch(() => null);
38
+ const version =
39
+ typeof body === "object" && body !== null && "version" in body ? body.version : null;
40
+ if (!response.ok || typeof version !== "string") throw new Error(`Could not read ${path}`);
41
+ return version;
42
+ }
43
+
44
+ /** POST the freeze to studio-server, which extracts the still and writes the whole edit at once. */
45
+ export async function requestFreezeFrame(input: {
46
+ projectId: string;
47
+ path: string;
48
+ element: TimelineElement;
49
+ playhead: number;
50
+ }): Promise<FreezeFrameResponse> {
51
+ const target = buildPatchTarget(input.element);
52
+ if (!target) throw new Error("This clip has no id to freeze it by");
53
+ const expectedVersion = await readVersion(input.projectId, input.path);
54
+ const transactionToken = `freeze:${crypto.randomUUID()}`;
55
+ markStudioWriteToken(transactionToken);
56
+ const response = await fetch(
57
+ buildProjectApiPath(input.projectId, "/file-mutations/freeze-frame"),
58
+ {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ "X-Hyperframes-Write-Token": transactionToken,
63
+ },
64
+ body: JSON.stringify({
65
+ path: input.path,
66
+ expectedVersion,
67
+ target,
68
+ playhead: toAuthoredStart(input.element, input.playhead),
69
+ transactionToken,
70
+ }),
71
+ },
72
+ );
73
+ const body: unknown = await response.json().catch(() => null);
74
+ if (!response.ok || !isFreezeFrameResponse(body)) {
75
+ throw new Error(errorOf(body) ?? `Freeze frame failed (${response.status})`);
76
+ }
77
+ return body;
78
+ }
79
+
80
+ interface UseFreezeFrameOptions {
81
+ projectId: string | null;
82
+ activeCompPath: string | null;
83
+ showToast: (message: string, tone?: "error" | "info") => void;
84
+ writeProjectFile: ProjectFileWriter;
85
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
86
+ recordEdit: (input: RecordEditInput) => Promise<void>;
87
+ reloadPreview: () => void;
88
+ forceReloadSdkSession?: () => void;
89
+ isRecordingRef?: React.RefObject<boolean>;
90
+ }
91
+
92
+ export function useFreezeFrame(options: UseFreezeFrameOptions) {
93
+ const optionsRef = useRef(options);
94
+ optionsRef.current = options;
95
+
96
+ return useCallback(async (element: TimelineElement, playhead: number) => {
97
+ const opts = optionsRef.current;
98
+ if (opts.isRecordingRef?.current) {
99
+ opts.showToast("Cannot edit timeline while recording", "error");
100
+ return;
101
+ }
102
+ const projectId = opts.projectId;
103
+ if (!projectId) return;
104
+ const path = element.sourceFile || opts.activeCompPath || "index.html";
105
+ try {
106
+ await serializeStudioFileMutations(opts.writeProjectFile, [path], async () => {
107
+ const result = await requestFreezeFrame({ projectId, path, element, playhead });
108
+ await opts.recordEdit({
109
+ label: "Freeze frame",
110
+ files: { [path]: { before: result.before, after: result.after } },
111
+ });
112
+ opts.observeProjectFileVersion?.(path, result.version);
113
+ });
114
+ opts.forceReloadSdkSession?.();
115
+ opts.reloadPreview();
116
+ opts.showToast(`Froze a 2 s still at ${playhead.toFixed(2)}s`, "info");
117
+ } catch (error) {
118
+ opts.showToast(error instanceof Error ? error.message : "Freeze frame failed", "error");
119
+ }
120
+ }, []);
121
+ }
@@ -243,8 +243,14 @@ it("a step that cannot reach the server says so", async () => {
243
243
  });
244
244
  });
245
245
 
246
- it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
246
+ it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => {
247
247
  const { hook, save, readFile } = await studio();
248
+ const real = globalThis.fetch;
249
+ const slowView = (url: string, init?: RequestInit) =>
250
+ url.endsWith("/history")
251
+ ? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init))
252
+ : real(url, init);
253
+ vi.stubGlobal("fetch", slowView);
248
254
  save("B");
249
255
  const claim = hook().recordEdit({
250
256
  label: "Moved Title",
@@ -252,11 +258,8 @@ it("predicts a step from what this tab wrote, and not while a claim or step may
252
258
  });
253
259
  expect(hook().predict("undo")).toBeNull();
254
260
  await act(() => claim);
255
- const predicted = await vi.waitFor(() => {
256
- const next = hook().predict("undo");
257
- expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
258
- return next!;
259
- });
261
+ const predicted = hook().predict("undo")!;
262
+ expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
260
263
 
261
264
  hook().noteOutsideChange();
262
265
  expect(hook().predict("undo")).toBeNull();
@@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
223
223
  const claimed = claimHeld(reply, label);
224
224
  if (claimed) own.remember(claimed, files);
225
225
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
226
- void refresh();
226
+ await refresh();
227
227
  },
228
228
  [projectId, refresh, own],
229
229
  );
@@ -6,6 +6,7 @@ import { trackStudioRazorSplit } from "../telemetry/events";
6
6
  import { canSplitElementAt, selectSplittableElements } from "../utils/timelineElementSplit";
7
7
  import { buildAtomicCutIntents, runAtomicCutTransaction } from "../utils/razorSplitTransaction";
8
8
  import type { RecordEditInput } from "./timelineEditingHelpers";
9
+ import { useFreezeFrame } from "./useFreezeFrame";
9
10
 
10
11
  interface UseRazorSplitOptions {
11
12
  projectId: string | null;
@@ -135,5 +136,17 @@ export function useRazorSplit({
135
136
  [isRecordingRef, runCut, showToast],
136
137
  );
137
138
 
138
- return { handleRazorSplit, handleRazorSplitAll };
139
+ const handleFreezeFrame = useFreezeFrame({
140
+ projectId,
141
+ activeCompPath,
142
+ showToast,
143
+ writeProjectFile,
144
+ observeProjectFileVersion,
145
+ recordEdit,
146
+ reloadPreview,
147
+ forceReloadSdkSession,
148
+ isRecordingRef,
149
+ });
150
+
151
+ return { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame };
139
152
  }
@@ -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",
@@ -176,6 +226,58 @@ describe("useRenderClipContent", () => {
176
226
  }
177
227
  });
178
228
 
229
+ it("draws a waveform strip under the thumbnails of a video with sound", () => {
230
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
231
+ const content = renderClipContent(
232
+ {
233
+ id: "talk",
234
+ tag: "video",
235
+ start: 0,
236
+ duration: 4,
237
+ track: 0,
238
+ src: "talk.mp4",
239
+ hasAudio: true,
240
+ },
241
+ null,
242
+ );
243
+ expect(isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)).toBe(true);
244
+ if (!isValidElement<{ thumbnail: ReactNode; waveform: ReactNode }>(content)) return;
245
+ expect(content.type).toBe(AudibleVideoClipContent);
246
+ const { thumbnail } = content.props;
247
+ const waveform = unwrapPeakMarks(content.props.waveform);
248
+ expect(isValidElement(thumbnail) && thumbnail.type).toBe(VideoThumbnail);
249
+ expect(isValidElement<{ waveformUrl: string }>(waveform) && waveform.props.waveformUrl).toBe(
250
+ "/api/projects/my-project/waveform/talk.mp4",
251
+ );
252
+ });
253
+
254
+ it("keeps a muted or silent video to thumbnails only", () => {
255
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
256
+ for (const extra of [{ hasAudio: true, muted: true }, { hasAudio: false }]) {
257
+ const content = renderClipContent(
258
+ { id: "b", tag: "video", start: 0, duration: 4, track: 0, src: "b.mp4", ...extra },
259
+ null,
260
+ );
261
+ expect(isValidElement(content) && content.type).toBe(VideoThumbnail);
262
+ }
263
+ });
264
+
265
+ it("still shows the sound strip when thumbnails are off", () => {
266
+ const content = renderClipContent(
267
+ {
268
+ id: "talk",
269
+ tag: "video",
270
+ start: 0,
271
+ duration: 4,
272
+ track: 0,
273
+ src: "talk.mp4",
274
+ hasAudio: true,
275
+ },
276
+ null,
277
+ );
278
+ expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent);
279
+ });
280
+
179
281
  it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
180
282
  usePlayerStore.setState({ thumbnailMode: "adaptive" });
181
283
 
@@ -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
@@ -88,7 +74,7 @@ function renderAudioClip(
88
74
  ? buildProjectApiPath(pid, `/waveform/${encodedRelative}`)
89
75
  : undefined;
90
76
  const { start, end } = trimFractions(el);
91
- return createElement(AudioWaveform, {
77
+ const waveform = createElement(AudioWaveform, {
92
78
  audioUrl,
93
79
  waveformUrl,
94
80
  label: "",
@@ -100,6 +86,27 @@ function renderAudioClip(
100
86
  priority: context.priority,
101
87
  ...audioPillFlags(el, elements),
102
88
  });
89
+ return createElement(
90
+ ClipPeakMarks,
91
+ {
92
+ peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
93
+ sourceWindow: {
94
+ mediaStart: el.playbackStart ?? 0,
95
+ sourceSpan: el.duration * (el.playbackRate ?? 1),
96
+ },
97
+ gain: el.volume ?? 1,
98
+ },
99
+ waveform,
100
+ );
101
+ }
102
+
103
+ function withSoundStrip(
104
+ el: TimelineElement,
105
+ thumbnail: ReactNode,
106
+ waveform: () => ReactNode,
107
+ ): ReactNode {
108
+ if (el.tag !== "video" || !clipHasSound(el)) return thumbnail;
109
+ return createElement(AudibleVideoClipContent, { thumbnail, waveform: waveform() });
103
110
  }
104
111
 
105
112
  export interface UseRenderClipContentOptions {
@@ -134,10 +141,9 @@ export function useRenderClipContent({
134
141
 
135
142
  // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
136
143
  // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
144
+ const waveform = () => renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
137
145
  if (effectiveMode === "hidden") {
138
- return rendersWaveform(el)
139
- ? renderAudioClip(el, pid, sessionEpoch, style.label, context, elements)
140
- : null;
146
+ return rendersWaveform(el) ? waveform() : withSoundStrip(el, null, waveform);
141
147
  }
142
148
 
143
149
  let compSrc = el.compositionSrc;
@@ -222,7 +228,7 @@ export function useRenderClipContent({
222
228
  rich: context.rich,
223
229
  });
224
230
  }
225
- return createElement(VideoThumbnail, {
231
+ const thumbnail = createElement(VideoThumbnail, {
226
232
  videoSrc: mediaSrc,
227
233
  label: "",
228
234
  labelColor: style.label,
@@ -233,6 +239,7 @@ export function useRenderClipContent({
233
239
  sessionEpoch,
234
240
  priority: context.priority,
235
241
  });
242
+ return withSoundStrip(el, thumbnail, waveform);
236
243
  }
237
244
 
238
245
  if (htmlPreviewEligible) {
@@ -492,8 +492,7 @@ export function useTimelineEditing({
492
492
  });
493
493
 
494
494
  const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
495
-
496
- const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
495
+ const { handleRazorSplit, handleRazorSplitAll, handleFreezeFrame } = useRazorSplit({
497
496
  projectId,
498
497
  activeCompPath,
499
498
  showToast,
@@ -575,6 +574,7 @@ export function useTimelineEditing({
575
574
  ),
576
575
  handleTimelineElementSplit: trackedRazorSplit,
577
576
  handleRazorSplit: trackedRazorSplit,
577
+ handleFreezeFrame: track(guard((element) => [element], handleFreezeFrame)),
578
578
  // Same selection the handler itself splits (useRazorSplit.ts).
579
579
  handleRazorSplitAll: track(
580
580
  guard(
package/src/index.ts CHANGED
@@ -175,6 +175,7 @@ export { TimelineHistoryButtons } from "./components/TimelineHistoryButtons";
175
175
  export type { TimelineHistoryButtonsProps } from "./components/TimelineHistoryButtons";
176
176
  export { AudioMeterStrip } from "./components/nle/AudioMeterStrip";
177
177
  export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
178
+ export { useAudioMetersVisible } from "./utils/audioMeterVisibility";
178
179
  export { useClipboard } from "./hooks/useClipboard";
179
180
  export type { UseClipboardOptions } from "./hooks/useClipboard";
180
181
 
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Share of the clip height the sound strip takes under a video's thumbnails. */
4
+ const AUDIBLE_VIDEO_WAVE_SHARE = 0.38;
5
+
6
+ /** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
7
+ export function AudibleVideoClipContent({
8
+ thumbnail,
9
+ waveform,
10
+ }: {
11
+ thumbnail: ReactNode;
12
+ waveform: ReactNode;
13
+ }) {
14
+ const waveHeight = `${AUDIBLE_VIDEO_WAVE_SHARE * 100}%`;
15
+ return (
16
+ <div className="relative h-full w-full" data-testid="audible-video-clip">
17
+ <div className="absolute inset-x-0 top-0" style={{ bottom: waveHeight }}>
18
+ {thumbnail}
19
+ </div>
20
+ <div
21
+ className="absolute inset-x-0 bottom-0 bg-black/30"
22
+ data-testid="audible-video-wave"
23
+ style={{ height: waveHeight }}
24
+ >
25
+ {waveform}
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { usePreviewIframeStore } from "../store/previewIframeStore";
8
+ import { ClipBadges } from "./ClipBadges";
9
+
10
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
11
+ configurable: true,
12
+ value: true,
13
+ });
14
+
15
+ let root: Root | null = null;
16
+
17
+ afterEach(() => {
18
+ act(() => root?.unmount());
19
+ root = null;
20
+ usePreviewIframeStore.getState().setIframe(null);
21
+ document.body.innerHTML = "";
22
+ });
23
+
24
+ const talk: TimelineElement = {
25
+ id: "talk",
26
+ domId: "talk",
27
+ tag: "video",
28
+ start: 0,
29
+ duration: 6,
30
+ track: 0,
31
+ hasAudio: true,
32
+ };
33
+
34
+ function render(el: TimelineElement) {
35
+ const host = document.createElement("div");
36
+ document.body.append(host);
37
+ root = createRoot(host);
38
+ act(() => root?.render(<ClipBadges el={el} />));
39
+ }
40
+
41
+ const labels = () =>
42
+ Array.from(document.querySelectorAll("[data-badge]")).map(
43
+ (badge) => `${badge.getAttribute("data-badge")}:${badge.textContent}`,
44
+ );
45
+
46
+ function mountPreview(html: string): Document {
47
+ const iframe = document.createElement("iframe");
48
+ document.body.append(iframe);
49
+ const doc = iframe.contentDocument;
50
+ if (!doc) throw new Error("no iframe document");
51
+ doc.body.innerHTML = html;
52
+ usePreviewIframeStore.getState().setIframe(iframe);
53
+ return doc;
54
+ }
55
+
56
+ describe("ClipBadges", () => {
57
+ it("renders nothing for an untouched clip", () => {
58
+ render(talk);
59
+ expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
60
+ });
61
+
62
+ it("falls back to the store's volume without a preview", () => {
63
+ render({ ...talk, volume: 1.8 });
64
+ expect(labels()).toEqual(["volume:180%"]);
65
+ });
66
+
67
+ it("reads the live node and follows an agent's attribute edit", async () => {
68
+ const doc = mountPreview(
69
+ `<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
70
+ );
71
+ render(talk);
72
+ expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
73
+ const node = doc.getElementById("talk");
74
+ await act(async () => {
75
+ node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
76
+ node?.setAttribute("data-volume", "0.6");
77
+ await new Promise((resolve) => setTimeout(resolve, 0));
78
+ });
79
+ expect(labels()).toEqual(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
80
+ expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
81
+ });
82
+ });
@@ -0,0 +1,67 @@
1
+ import { memo } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
4
+ import { useClipToolState } from "./useClipToolState";
5
+
6
+ function SpeakerGlyph({ muted }: { muted: boolean }) {
7
+ return (
8
+ <svg width="10" height="10" viewBox="0 0 16 16" aria-hidden="true">
9
+ <path d="M2 6h3l4-3v10l-4-3H2z" fill="currentColor" />
10
+ {muted ? (
11
+ <path d="M11 6l4 4M15 6l-4 4" stroke="currentColor" strokeWidth="1.4" />
12
+ ) : (
13
+ <path
14
+ d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9"
15
+ stroke="currentColor"
16
+ fill="none"
17
+ strokeWidth="1.4"
18
+ />
19
+ )}
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ function BadgeContent({ badge }: { badge: ClipBadge }) {
25
+ if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
26
+ if (badge.kind === "volume") {
27
+ const muted = badge.label === "Muted";
28
+ return (
29
+ <>
30
+ <SpeakerGlyph muted={muted} />
31
+ {muted ? null : <span>{badge.label}</span>}
32
+ </>
33
+ );
34
+ }
35
+ return <span>{badge.label}</span>;
36
+ }
37
+
38
+ const BADGE_CLASS =
39
+ "inline-flex items-center gap-0.5 rounded-[3px] border border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap";
40
+
41
+ /** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
42
+ export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
43
+ const state = useClipToolState(el);
44
+ const { visible, hidden } = splitVisibleBadges(readClipBadges(state));
45
+ if (visible.length === 0) return null;
46
+ return (
47
+ <span
48
+ className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
49
+ data-testid="clip-badges"
50
+ >
51
+ {visible.map((badge) => (
52
+ <span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
53
+ <BadgeContent badge={badge} />
54
+ </span>
55
+ ))}
56
+ {hidden.length > 0 && (
57
+ <span
58
+ className={`${BADGE_CLASS} pointer-events-auto`}
59
+ title={hidden.map((badge) => badge.label).join(", ")}
60
+ data-badge="more"
61
+ >
62
+ +{hidden.length}
63
+ </span>
64
+ )}
65
+ </span>
66
+ );
67
+ });
@@ -5,6 +5,8 @@ import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
6
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
7
7
  import type { TimelineClipMenuItem } from "./TimelineTypes";
8
+ import { ClipMenuToolItems } from "./clipMenuToolItems";
9
+ import { ClipMenuAudioItems } from "./clipMenuAudioItems";
8
10
 
9
11
  interface ClipContextMenuProps {
10
12
  x: number;
@@ -144,10 +146,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
144
146
  <span>{splitLabel}</span>
145
147
  <span className="text-neutral-500 text-[10px] ml-3">S</span>
146
148
  </button>
149
+ <ClipMenuToolItems
150
+ group="time"
151
+ element={element}
152
+ currentTime={currentTime}
153
+ onClose={onClose}
154
+ />
147
155
  <div className="my-1 border-t border-neutral-700/60" />
148
156
  </>
149
157
  )}
150
158
 
159
+ <ClipMenuAudioItems element={element} onClose={onClose} />
160
+
151
161
  {(onCopy || onPaste || onDuplicate) && (
152
162
  <>
153
163
  {onCopy && (
@@ -198,6 +208,18 @@ export const ClipContextMenu = memo(function ClipContextMenu({
198
208
  </>
199
209
  )}
200
210
 
211
+ <ClipMenuToolItems
212
+ group="sound"
213
+ element={element}
214
+ currentTime={currentTime}
215
+ onClose={onClose}
216
+ />
217
+ <ClipMenuToolItems
218
+ group="picture"
219
+ element={element}
220
+ currentTime={currentTime}
221
+ onClose={onClose}
222
+ />
201
223
  <button
202
224
  type="button"
203
225
  role="menuitem"