@hyperframes/studio 0.8.109 → 0.8.111

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 (93) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
  2. package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
  3. package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
  6. package/dist/index.d.ts +22 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5352 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +5 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/studioPendingEdits.test.ts +18 -0
  92. package/src/utils/typingTarget.test.ts +8 -0
  93. package/src/utils/typingTarget.ts +1 -0
@@ -82,6 +82,7 @@ function mountMarquee<T>(extra: Partial<MarqueeGesturesDeps<T>> = {}) {
82
82
  clientX: x,
83
83
  clientY: y,
84
84
  pointerId: 7,
85
+ buttons: 1,
85
86
  shiftKey,
86
87
  currentTarget: overlay,
87
88
  }) as unknown as PointerEvent<HTMLDivElement>;
@@ -9,6 +9,7 @@ import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
9
9
  import { canSplitElement } from "../utils/timelineElementSplit";
10
10
  import { trackStudioEvent } from "../utils/studioTelemetry";
11
11
  import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
12
+ import { openAudioGainDialog } from "../player/components/audioGainDialogStore";
12
13
  import type { LinkShortcutCallbacks } from "./linkShortcuts";
13
14
 
14
15
  // Extracted from useAppHotkeys.ts to keep it under the studio 600-line cap,
@@ -190,6 +191,14 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
190
191
  }
191
192
  }
192
193
 
194
+ // Only with a sound clip selected, so bare G still reaches the canvas grid toggle otherwise.
195
+ if (key === STUDIO_PLAIN_KEYS.audioGain && !event.shiftKey && !event.altKey) {
196
+ if (!cb.readOnlyPreview && openAudioGainDialog()) {
197
+ event.preventDefault();
198
+ return;
199
+ }
200
+ }
201
+
193
202
  if (key === "b" && !event.shiftKey && !event.altKey) {
194
203
  event.preventDefault();
195
204
  const { activeTool, setActiveTool } = usePlayerStore.getState();
@@ -0,0 +1,95 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
7
+ import type { TimelineElement } from "../player";
8
+
9
+ const saved = vi.hoisted(() => ({ inputs: [] as Array<Record<string, unknown>> }));
10
+ vi.mock("../utils/studioFileHistory", () => ({
11
+ saveProjectFilesWithHistory: vi.fn(async (input: Record<string, unknown>) => {
12
+ saved.inputs.push(input);
13
+ return [];
14
+ }),
15
+ }));
16
+
17
+ import { useSetElementsAttribute } from "./timelineElementsAttribute";
18
+
19
+ const clip = (id: string, sourceFile?: string): TimelineElement => ({
20
+ id,
21
+ domId: id,
22
+ tag: "audio",
23
+ start: 0,
24
+ duration: 1,
25
+ track: 0,
26
+ sourceFile,
27
+ });
28
+
29
+ describe("useSetElementsAttribute", () => {
30
+ it("writes every clip's attribute in one history entry, one builder per file", async () => {
31
+ const showToast = vi.fn();
32
+ let write: ReturnType<typeof useSetElementsAttribute> | null = null;
33
+ function Probe() {
34
+ write = useSetElementsAttribute({
35
+ projectIdRef: { current: "p1" },
36
+ activeCompPath: "index.html",
37
+ showToast,
38
+ writeProjectFile: vi.fn(async () => {}),
39
+ recordEdit: vi.fn(async () => {}),
40
+ previewIframeRef: { current: null },
41
+ pendingTimelineEditPathRef: { current: new Set<string>() },
42
+ });
43
+ return null;
44
+ }
45
+ const root = createRoot(document.createElement("div"));
46
+ act(() => root.render(createElement(Probe)));
47
+ await write?.(
48
+ [
49
+ { element: clip("a"), value: "0.5" },
50
+ { element: clip("b"), value: "2" },
51
+ { element: clip("c", "scene.html"), value: "1.5" },
52
+ ],
53
+ "data-volume",
54
+ "Audio Gain",
55
+ );
56
+ act(() => root.unmount());
57
+ expect(showToast).not.toHaveBeenCalled();
58
+ expect(saved.inputs).toHaveLength(1);
59
+ const input = saved.inputs[0];
60
+ expect(input?.["label"]).toBe("Audio Gain");
61
+ const files = input?.["files"];
62
+ if (typeof files !== "object" || files === null) throw new Error("no files");
63
+ expect(Object.keys(files).sort()).toEqual(["index.html", "scene.html"]);
64
+ const build = Reflect.get(files, "index.html");
65
+ const html = '<audio id="a" data-volume="1"></audio><audio id="b"></audio>';
66
+ const out = typeof build === "function" ? String(build(html)) : "";
67
+ expect(out).toContain('id="a" data-volume="0.5"');
68
+ expect(out).toMatch(/id="b"[^>]*data-volume="2"/);
69
+ });
70
+
71
+ it("refuses while recording, like the single-clip write", async () => {
72
+ saved.inputs.length = 0;
73
+ const showToast = vi.fn();
74
+ let write: ReturnType<typeof useSetElementsAttribute> | null = null;
75
+ function Probe() {
76
+ write = useSetElementsAttribute({
77
+ projectIdRef: { current: "p1" },
78
+ activeCompPath: "index.html",
79
+ showToast,
80
+ writeProjectFile: vi.fn(async () => {}),
81
+ recordEdit: vi.fn(async () => {}),
82
+ previewIframeRef: { current: null },
83
+ pendingTimelineEditPathRef: { current: new Set<string>() },
84
+ isRecordingRef: { current: true },
85
+ });
86
+ return null;
87
+ }
88
+ const root = createRoot(document.createElement("div"));
89
+ act(() => root.render(createElement(Probe)));
90
+ await write?.([{ element: clip("a"), value: "0.5" }], "data-volume", "Audio Gain");
91
+ act(() => root.unmount());
92
+ expect(saved.inputs).toHaveLength(0);
93
+ expect(showToast).toHaveBeenCalledWith("Cannot edit timeline while recording", "error");
94
+ });
95
+ });
@@ -0,0 +1,113 @@
1
+ import { useCallback } from "react";
2
+ import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
+ import { syncStoredElementAttribute } from "../player/lib/automationStoreSync";
4
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
+ import { applyPatchByTarget } from "../utils/sourcePatcher";
6
+ import {
7
+ buildPatchTarget,
8
+ findTimelineElementInIframe,
9
+ readFileContent,
10
+ } from "./timelineEditingHelpers";
11
+ import type { UseTimelineElementVisibilityEditingInput } from "./timelineTrackVisibility";
12
+ import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
13
+
14
+ type ElementAttributeEdits = Parameters<
15
+ NonNullable<TimelineEditCallbacks["onSetElementsAttributeQuiet"]>
16
+ >[0];
17
+
18
+ type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
19
+
20
+ function editsByFile(
21
+ edits: ElementAttributeEdits,
22
+ activeCompPath: string | null,
23
+ ): Map<string, Array<{ target: PatchTarget; value: string | null }>> {
24
+ const byFile = new Map<string, Array<{ target: PatchTarget; value: string | null }>>();
25
+ for (const { element, value } of edits) {
26
+ const target = buildPatchTarget(element);
27
+ if (!target) throw new Error("A clip has no id to save it by");
28
+ const path = element.sourceFile || activeCompPath || "index.html";
29
+ byFile.set(path, [...(byFile.get(path) ?? []), { target, value }]);
30
+ }
31
+ return byFile;
32
+ }
33
+
34
+ function patchLive(
35
+ iframe: HTMLIFrameElement | null,
36
+ edits: ElementAttributeEdits,
37
+ attr: string,
38
+ activeCompPath: string | null,
39
+ ): void {
40
+ for (const { element, value } of edits) {
41
+ const node = findTimelineElementInIframe(iframe, element, activeCompPath);
42
+ if (value === null) node?.removeAttribute(attr);
43
+ else node?.setAttribute(attr, value);
44
+ syncStoredElementAttribute(element, attr, value);
45
+ }
46
+ }
47
+
48
+ /** One attribute written on several clips as a single save and a single undo step. */
49
+ export function useSetElementsAttribute({
50
+ projectIdRef,
51
+ activeCompPath,
52
+ showToast,
53
+ writeProjectFile,
54
+ recordEdit,
55
+ previewIframeRef,
56
+ pendingTimelineEditPathRef,
57
+ isRecordingRef,
58
+ }: UseTimelineElementVisibilityEditingInput) {
59
+ return useCallback(
60
+ async (
61
+ edits: ElementAttributeEdits,
62
+ attr: string,
63
+ label: string,
64
+ ): Promise<TimelineEditOutcome> => {
65
+ const projectId = projectForTimelineSave(
66
+ isRecordingRef?.current,
67
+ projectIdRef.current,
68
+ showToast,
69
+ );
70
+ if (typeof projectId !== "string") return projectId;
71
+ if (edits.length === 0) return { status: "saved" };
72
+ try {
73
+ const byFile = editsByFile(edits, activeCompPath);
74
+ const files = Object.fromEntries(
75
+ [...byFile].map(([path, patches]) => [
76
+ path,
77
+ (before: string) =>
78
+ patches.reduce(
79
+ (html, { target, value }) =>
80
+ applyPatchByTarget(html, target, { type: "attribute", property: attr, value }),
81
+ before,
82
+ ),
83
+ ]),
84
+ );
85
+ for (const path of byFile.keys()) pendingTimelineEditPathRef.current.add(path);
86
+ await saveProjectFilesWithHistory({
87
+ projectId,
88
+ label,
89
+ files,
90
+ readFile: (path) => readFileContent(projectId, path),
91
+ writeFile: writeProjectFile,
92
+ recordEdit,
93
+ });
94
+ patchLive(previewIframeRef.current, edits, attr, activeCompPath);
95
+ return { status: "saved" };
96
+ } catch (error) {
97
+ const reason = error instanceof Error ? error.message : "Could not save the clips";
98
+ showToast(reason, "error");
99
+ return { status: "failed", reason };
100
+ }
101
+ },
102
+ [
103
+ projectIdRef,
104
+ activeCompPath,
105
+ showToast,
106
+ writeProjectFile,
107
+ recordEdit,
108
+ previewIframeRef,
109
+ pendingTimelineEditPathRef,
110
+ isRecordingRef,
111
+ ],
112
+ );
113
+ }
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { describe, expect, it } from "vitest";
3
3
  import type { TimelineElement } from "../player";
4
+ import { clipSyncState } from "../player/components/clipSync";
4
5
  import { clipsToUnlink, planLinkEdit } from "./timelineLinkEditPlan";
5
6
 
6
7
  const clip = (id: string, tag: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
@@ -111,3 +112,30 @@ describe("planLinkEdit into sync", () => {
111
112
  expect(html).toBe('<audio id="talk-audio" data-playback-start="0.5"></audio>');
112
113
  });
113
114
  });
115
+
116
+ describe("link edit plan", () => {
117
+ const PAIR =
118
+ '<div><video id="talk" src="talk.mp4" muted data-start="2" data-duration="6"></video><audio id="a" src="%SRC%" data-start="3" data-duration="3" data-media-start="1"></audio></div>';
119
+ const linkPlan = (src: string, audioEl: TimelineElement) => {
120
+ const v = clip("talk", "video", { muted: true });
121
+ const plan = planLinkEdit({ kind: "link", elements: [v, audioEl] }, [v, audioEl]);
122
+ return plan?.transform(PAIR.replace("%SRC%", src)) ?? "";
123
+ };
124
+
125
+ it("links a trimmed same-file audio with a sync origin that shows a nonzero offset", () => {
126
+ const trimmed = clip("a", "audio", { start: 3, duration: 3, playbackStart: 0 });
127
+ const out = linkPlan("talk.mp4", trimmed);
128
+ expect(out).toMatch(/<video[^>]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/);
129
+ expect(out).toMatch(/<audio[^>]*data-link="lk-1"[^>]*data-sync-origin="lk-1"/);
130
+ const v = clip("talk", "video", { muted: true, syncOrigin: "lk-1" });
131
+ const a = { ...trimmed, syncOrigin: "lk-1" };
132
+ expect(clipSyncState(a, [v, a], 30)?.frames).toBe(30);
133
+ });
134
+
135
+ it("links different files without a sync origin", () => {
136
+ const music = clip("a", "audio", { src: "bgm.mp3" });
137
+ const out = linkPlan("bgm.mp3", music);
138
+ expect(out).toContain('data-link="lk-1"');
139
+ expect(out).not.toContain("data-sync-origin");
140
+ });
141
+ });
@@ -13,6 +13,7 @@ import {
13
13
  mergeAudioInSource,
14
14
  pickDetachedAudioTrack,
15
15
  setLinkInSource,
16
+ sharesSourceFile,
16
17
  } from "../components/editor/mediaLinkEdits";
17
18
 
18
19
  export interface LinkEditPlan {
@@ -92,7 +93,11 @@ export function planLinkEdit(
92
93
  const targets = targetsOf(edit.elements);
93
94
  const anchor = edit.elements[0];
94
95
  if (!targets || !anchor) return null;
95
- return { label: "Link clips", anchor, transform: (s) => linkInSource(s, targets) };
96
+ return {
97
+ label: "Link clips",
98
+ anchor,
99
+ transform: (s) => linkInSource(s, targets, { syncOrigin: sharesSourceFile(edit.elements) }),
100
+ };
96
101
  }
97
102
  case "detach": {
98
103
  const target = buildPatchTarget(edit.element);
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
6
6
  import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
7
7
  import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
8
8
  import type { TimelineElement } from "../player/store/timelineElement";
9
+ import { useAudioGainDialogStore } from "../player/components/audioGainDialogStore";
9
10
 
10
11
  /** Minimal valid fixture — TimelineElement only requires these five fields. */
11
12
  const bgmElement: TimelineElement = {
@@ -474,3 +475,36 @@ describe("hotkeys with the preview read-only", () => {
474
475
  expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
475
476
  });
476
477
  });
478
+
479
+ describe("dispatchPlainKey — G opens Audio Gain", () => {
480
+ const video: TimelineElement = {
481
+ id: "b-roll",
482
+ key: "b-roll",
483
+ tag: "video",
484
+ start: 0,
485
+ duration: 4,
486
+ track: 1,
487
+ };
488
+
489
+ afterEach(() => useAudioGainDialogStore.getState().close());
490
+
491
+ it("opens the dialog for the selected clips with sound and owns the key", () => {
492
+ usePlayerStore.setState({
493
+ elements: [bgmElement, video],
494
+ selectedElementId: "bgm",
495
+ selectedElementIds: new Set(["bgm", "b-roll"]),
496
+ });
497
+ const event = press("g");
498
+ dispatchPlainKey(event, "g", callbacks());
499
+ expect(useAudioGainDialogStore.getState().targetKeys).toEqual(["bgm"]);
500
+ expect(event.defaultPrevented).toBe(true);
501
+ });
502
+
503
+ it("leaves G to the grid toggle when nothing selected has sound", () => {
504
+ usePlayerStore.setState({ elements: [video], selectedElementId: "b-roll" });
505
+ const event = press("g");
506
+ dispatchPlainKey(event, "g", callbacks());
507
+ expect(useAudioGainDialogStore.getState().targetKeys).toBeNull();
508
+ expect(event.defaultPrevented).toBe(false);
509
+ });
510
+ });
@@ -443,7 +443,14 @@ describe("useDomGeometryCommits resize of a cropped element", () => {
443
443
  const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
444
444
  const press = (type: string, clientX: number) =>
445
445
  act(() =>
446
- handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, clientX })),
446
+ handle.dispatchEvent(
447
+ new PointerEvent(type, {
448
+ bubbles: true,
449
+ pointerId: 3,
450
+ buttons: type === "pointerup" ? 0 : 1,
451
+ clientX,
452
+ }),
453
+ ),
447
454
  );
448
455
  press("pointerdown", 100);
449
456
  press("pointermove", 100 - by);
@@ -14,6 +14,7 @@ import { AudioWaveform, rendersWaveform } from "../player/components/AudioWavefo
14
14
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
15
15
  import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
16
16
  import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
17
+ import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
17
18
  import { clipHasSound } from "../player/components/clipMenuNormalize";
18
19
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
19
20
  import { usePlayerStore } from "../player/store/playerStore";
@@ -91,11 +92,8 @@ function renderAudioClip(
91
92
  return createElement(
92
93
  ClipPeakMarks,
93
94
  {
94
- peaksUrl: encodedRelative ? buildProjectApiPath(pid, `/peaks/${encodedRelative}`) : undefined,
95
- sourceWindow: {
96
- mediaStart: el.playbackStart ?? 0,
97
- sourceSpan: el.duration * (el.playbackRate ?? 1),
98
- },
95
+ peaksUrl: clipPeaksUrl(el.src, pid),
96
+ sourceWindow: clipSourceWindow(el),
99
97
  gain: el.volume ?? 1,
100
98
  },
101
99
  waveform,
@@ -26,6 +26,7 @@ import {
26
26
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
27
27
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
28
28
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
29
+ import { useSetElementsAttribute } from "./timelineElementsAttribute";
29
30
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
30
31
  import { useTimelineEditGuard } from "./useTimelineEditGuard";
31
32
  import {
@@ -409,6 +410,17 @@ export function useTimelineEditing({
409
410
  isRecordingRef,
410
411
  });
411
412
 
413
+ const setElementsAttribute = useSetElementsAttribute({
414
+ projectIdRef,
415
+ activeCompPath,
416
+ showToast,
417
+ writeProjectFile,
418
+ recordEdit,
419
+ previewIframeRef,
420
+ pendingTimelineEditPathRef,
421
+ isRecordingRef,
422
+ });
423
+
412
424
  const setAudioGroupAttribute = useSetAudioGroupAttribute({
413
425
  projectIdRef,
414
426
  activeCompPath,
@@ -532,6 +544,7 @@ export function useTimelineEditing({
532
544
  },
533
545
  setElementFxAttribute: {
534
546
  ...setElementFxAttribute,
547
+ setMany: track(guard((edits) => edits.map((edit) => edit.element), setElementsAttribute)),
535
548
  setQuiet: track(
536
549
  guard(
537
550
  (element) => [element],
@@ -109,4 +109,16 @@ describe("useTimelineLinkEditing", () => {
109
109
  expect(recordEdit.mock.calls[0]?.[0].label).toBe("Unlink clips");
110
110
  unmount();
111
111
  });
112
+
113
+ it("unlink leaves nothing selected, so a later trim moves only the grabbed clip", async () => {
114
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
115
+ new Response(JSON.stringify({ content: SOURCE }), { status: 200 }),
116
+ );
117
+ const { api, unmount } = await renderLinkEditing();
118
+ usePlayerStore.getState().setSelection(["talk", "talk-audio"], "talk");
119
+ await act(async () => api.handleLinkEdit({ kind: "unlink", elements: [video, audio] }));
120
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
121
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
122
+ unmount();
123
+ });
112
124
  });
@@ -49,6 +49,10 @@ export function withLinkPartners(selection: readonly TimelineElement[]): Timelin
49
49
  return [...selection, ...elements.filter((el) => keys.has(keyOf(el)) && !known.has(keyOf(el)))];
50
50
  }
51
51
 
52
+ function clearSelectionOnUnlink(edit: TimelineLinkEdit): void {
53
+ if (edit.kind === "unlink") usePlayerStore.getState().clearSelection();
54
+ }
55
+
52
56
  export function useTimelineLinkEditing({
53
57
  projectIdRef,
54
58
  activeCompPath,
@@ -71,6 +75,7 @@ export function useTimelineLinkEditing({
71
75
  const pid = projectIdRef.current;
72
76
  const plan = planLinkEdit(edit, usePlayerStore.getState().elements);
73
77
  if (!pid || !plan) return;
78
+ clearSelectionOnUnlink(edit);
74
79
  const path = plan.anchor.sourceFile || activeCompPath || "index.html";
75
80
  pendingTimelineEditPathRef.current.add(path);
76
81
  const queued = editQueueRef.current.then(() =>
package/src/index.ts CHANGED
@@ -32,6 +32,10 @@ export {
32
32
  formatTime,
33
33
  } from "./player";
34
34
  export { usePreviewIframeStore } from "./player/store/previewIframeStore";
35
+ export {
36
+ openAudioGainDialog,
37
+ useAudioGainDialogStore,
38
+ } from "./player/components/audioGainDialogStore";
35
39
  export type {
36
40
  PlayerHandle,
37
41
  PlayerHandleElement,
@@ -144,6 +148,7 @@ export type {
144
148
  // A host's own waitForPendingDomEditSaves must also call this, or undo/redo
145
149
  // can race a write still in flight (see useTrackPendingTimelineEdit.ts).
146
150
  export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
151
+ export { revertNewestStudioPendingEdit } from "./utils/studioPendingEdits";
147
152
  export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
148
153
  export type {
149
154
  CanEditTimelineElement,
@@ -0,0 +1,133 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { audioGainToDb } from "@hyperframes/core/audio-gain";
5
+ import { createHappyDomRootHarness } from "./testRootHarness";
6
+ import { AudioGainDialog } from "./AudioGainDialog";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+ import type { TimelineElement } from "../store/timelineElement";
9
+
10
+ const onNotice = vi.fn();
11
+ const setQuiet = vi.fn(async () => {});
12
+ const setMany = vi.fn(
13
+ async (_edits: ReadonlyArray<{ value: string | null }>, _attr: string, _label: string) => {},
14
+ );
15
+
16
+ vi.mock("../../contexts/StudioContext", () => ({
17
+ useStudioShellContextOptional: () => null,
18
+ }));
19
+ vi.mock("../../contexts/TimelineEditContext", () => ({
20
+ useTimelineEditContextOptional: () => ({
21
+ onSetElementAttributeQuiet: setQuiet,
22
+ onSetElementsAttributeQuiet: setMany,
23
+ onNotice,
24
+ }),
25
+ }));
26
+
27
+ const harness = createHappyDomRootHarness();
28
+ const clip = (id: string, extra: Partial<TimelineElement> = {}): TimelineElement => ({
29
+ id,
30
+ tag: "audio",
31
+ start: 0,
32
+ duration: 2,
33
+ track: 0,
34
+ src: `${id}.wav`,
35
+ ...extra,
36
+ });
37
+
38
+ function stubPeaks(peaks: Record<string, number[]>) {
39
+ vi.stubGlobal(
40
+ "fetch",
41
+ vi.fn(async (url: string) => {
42
+ const name = Object.keys(peaks).find((key) => String(url).includes(`/peaks/${key}.wav`));
43
+ return name
44
+ ? Response.json({ binSeconds: 1, bins: peaks[name] })
45
+ : new Response("", { status: 404 });
46
+ }),
47
+ );
48
+ }
49
+
50
+ function open(elements: TimelineElement[]) {
51
+ const host = document.createElement("div");
52
+ document.body.appendChild(host);
53
+ act(() => harness.mount(host).render(<AudioGainDialog elements={elements} onClose={() => {}} />));
54
+ }
55
+
56
+ const row = (text: string) =>
57
+ [...document.querySelectorAll("label")].find((label) => label.textContent?.startsWith(text));
58
+ const choose = (text: string) =>
59
+ act(() => row(text)?.querySelector<HTMLInputElement>("input[type=radio]")?.click());
60
+ function typeDb(text: string, value: string) {
61
+ const input = row(text)?.querySelector<HTMLInputElement>("input[type=number]");
62
+ if (!input) throw new Error(`no field for ${text}`);
63
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
64
+ act(() => {
65
+ setter?.call(input, value);
66
+ input.dispatchEvent(new Event("input", { bubbles: true }));
67
+ });
68
+ }
69
+ const ok = () => [...document.querySelectorAll("button")].find((b) => b.textContent === "OK");
70
+ const peakText = () => document.querySelector("[data-testid='audio-gain-peak']")?.textContent;
71
+
72
+ beforeEach(() => {
73
+ usePlayerStore.getState().beginTimelineSession("p1");
74
+ vi.clearAllMocks();
75
+ });
76
+ afterEach(() => vi.unstubAllGlobals());
77
+
78
+ describe("AudioGainDialog", () => {
79
+ it("shows the selection's peak amplitude at the clips' current gain", async () => {
80
+ stubPeaks({ pk1: [0.25, 0.5] });
81
+ open([clip("pk1", { volume: 2 })]);
82
+ await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: 0.0 dB"));
83
+ });
84
+
85
+ it("Set Gain writes every selected clip in one undo step", async () => {
86
+ stubPeaks({});
87
+ const a = clip("set-a");
88
+ const b = clip("set-b", { volume: 0.5 });
89
+ open([a, b]);
90
+ choose("Set Gain to");
91
+ typeDb("Set Gain to", "-6");
92
+ await act(async () => ok()?.click());
93
+ expect(setMany).toHaveBeenCalledTimes(1);
94
+ const [edits, attr, label] = setMany.mock.calls[0] ?? [];
95
+ expect(attr).toBe("data-volume");
96
+ expect(label).toBe("Audio Gain");
97
+ expect(edits?.map((e) => audioGainToDb(Number(e.value)))).toEqual([
98
+ expect.closeTo(-6, 3),
99
+ expect.closeTo(-6, 3),
100
+ ]);
101
+ expect(setQuiet).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("Normalize All Peaks brings each clip's own peak to the target", async () => {
105
+ stubPeaks({ na1: [0.5], na2: [0.25] });
106
+ open([clip("na1"), clip("na2")]);
107
+ await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
108
+ choose("Normalize All Peaks to");
109
+ typeDb("Normalize All Peaks to", "-3");
110
+ await act(async () => ok()?.click());
111
+ const [edits] = setMany.mock.calls[0] ?? [];
112
+ const gains = edits?.map((e) => Number(e.value)) ?? [];
113
+ expect(audioGainToDb(0.5 * (gains[0] ?? 0))).toBeCloseTo(-3, 3);
114
+ expect(audioGainToDb(0.25 * (gains[1] ?? 0))).toBeCloseTo(-3, 3);
115
+ });
116
+
117
+ it("refuses a peak target above 0 dB", async () => {
118
+ stubPeaks({ over: [0.5] });
119
+ open([clip("over")]);
120
+ await vi.waitFor(() => expect(peakText()).not.toContain("measuring"));
121
+ choose("Normalize Max Peak to");
122
+ typeDb("Normalize Max Peak to", "2");
123
+ expect(ok()?.disabled).toBe(true);
124
+ });
125
+
126
+ it("disables the peak options when the clip cannot be measured", async () => {
127
+ stubPeaks({});
128
+ open([clip("unmeasured")]);
129
+ await vi.waitFor(() => expect(peakText()).toBe("Peak Amplitude: —"));
130
+ expect(row("Normalize Max Peak to")?.querySelector("input")?.disabled).toBe(true);
131
+ expect(row("Adjust Gain by")?.querySelector("input")?.disabled).toBe(false);
132
+ });
133
+ });