@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,235 @@
1
+ import { useRef, useState, type KeyboardEvent } from "react";
2
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
+ import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
4
+ import type { TimelineElement } from "../store/playerStore";
5
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
6
+ import { useCropPresetBarStore } from "../../components/editor/cropPresetStore";
7
+ import {
8
+ CHARACTER_CHOICES,
9
+ LOOK_CHOICES,
10
+ VOICE_CHOICES,
11
+ activeLook,
12
+ activeVoicePreset,
13
+ chainWithVoicePreset,
14
+ lookAttrValue,
15
+ type ClipToolChoice,
16
+ } from "./clipToolAttrs";
17
+ import { useClipToolState } from "./useClipToolState";
18
+
19
+ export type ClipMenuToolGroup = "time" | "sound" | "picture";
20
+
21
+ interface ClipMenuToolItemsProps {
22
+ group: ClipMenuToolGroup;
23
+ element: TimelineElement;
24
+ currentTime: number;
25
+ onClose: () => void;
26
+ }
27
+
28
+ const ROW_CLASS =
29
+ "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer";
30
+ const DISABLED_ROW_CLASS =
31
+ "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
32
+ const SUBMENU_WIDTH = 170;
33
+
34
+ interface ChoiceSection {
35
+ heading?: string;
36
+ choices: readonly ClipToolChoice[];
37
+ }
38
+
39
+ function focusSibling(menu: HTMLElement | null, step: number): void {
40
+ if (!menu) return;
41
+ const items = Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'));
42
+ const index = items.findIndex((item) => item === document.activeElement);
43
+ items[(index + step + items.length) % items.length]?.focus();
44
+ }
45
+
46
+ function ChoiceSubmenu({
47
+ label,
48
+ sections,
49
+ activeId,
50
+ onPick,
51
+ }: {
52
+ label: string;
53
+ sections: readonly ChoiceSection[];
54
+ activeId: string | null;
55
+ onPick: (id: string | null) => void;
56
+ }) {
57
+ const [open, setOpen] = useState(false);
58
+ const [flipLeft, setFlipLeft] = useState(false);
59
+ const rowRef = useRef<HTMLButtonElement | null>(null);
60
+ const submenuRef = useRef<HTMLDivElement | null>(null);
61
+
62
+ const show = (focusFirst: boolean) => {
63
+ const rect = rowRef.current?.getBoundingClientRect();
64
+ setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
65
+ setOpen(true);
66
+ if (focusFirst) requestAnimationFrame(() => focusSibling(submenuRef.current, 1));
67
+ };
68
+
69
+ const onSubmenuKeyDown = (event: KeyboardEvent) => {
70
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
71
+ event.preventDefault();
72
+ event.stopPropagation();
73
+ focusSibling(submenuRef.current, event.key === "ArrowDown" ? 1 : -1);
74
+ } else if (event.key === "ArrowLeft") {
75
+ event.preventDefault();
76
+ event.stopPropagation();
77
+ setOpen(false);
78
+ rowRef.current?.focus();
79
+ }
80
+ };
81
+
82
+ const choiceRow = (id: string | null, text: string) => (
83
+ <button
84
+ key={id ?? "none"}
85
+ type="button"
86
+ role="menuitemradio"
87
+ aria-checked={activeId === id}
88
+ className={ROW_CLASS}
89
+ onClick={() => onPick(id)}
90
+ >
91
+ <span className="flex items-center gap-1.5">
92
+ <span aria-hidden="true" className="w-3 text-center">
93
+ {activeId === id ? "✓" : ""}
94
+ </span>
95
+ {text}
96
+ </span>
97
+ </button>
98
+ );
99
+
100
+ return (
101
+ <div className="relative" onMouseEnter={() => show(false)} onMouseLeave={() => setOpen(false)}>
102
+ <button
103
+ ref={rowRef}
104
+ type="button"
105
+ role="menuitem"
106
+ aria-haspopup="menu"
107
+ aria-expanded={open}
108
+ className={ROW_CLASS}
109
+ onClick={() => show(true)}
110
+ onKeyDown={(event) => {
111
+ if (event.key !== "ArrowRight") return;
112
+ event.preventDefault();
113
+ show(true);
114
+ }}
115
+ >
116
+ <span>{label}</span>
117
+ <span className="text-neutral-500 text-[10px] ml-3">▸</span>
118
+ </button>
119
+ {open && (
120
+ <div
121
+ ref={submenuRef}
122
+ role="menu"
123
+ aria-label={label}
124
+ className="absolute top-0 z-10 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1"
125
+ style={{ width: SUBMENU_WIDTH, ...(flipLeft ? { right: "100%" } : { left: "100%" }) }}
126
+ onKeyDown={onSubmenuKeyDown}
127
+ >
128
+ {choiceRow(null, "None")}
129
+ {sections.map((section, index) => (
130
+ <div key={section.heading ?? index}>
131
+ {section.heading && (
132
+ <>
133
+ <div className="my-1 border-t border-neutral-700/60" />
134
+ <div className="px-3 py-1 text-[9px] uppercase tracking-wide text-neutral-500">
135
+ {section.heading}
136
+ </div>
137
+ </>
138
+ )}
139
+ {section.choices.map((choice) => choiceRow(choice.id, choice.label))}
140
+ </div>
141
+ ))}
142
+ </div>
143
+ )}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ const VOICE_SECTIONS: readonly ChoiceSection[] = [
149
+ { choices: VOICE_CHOICES },
150
+ { heading: "Character", choices: CHARACTER_CHOICES },
151
+ ];
152
+ const LOOK_SECTIONS: readonly ChoiceSection[] = [{ choices: LOOK_CHOICES }];
153
+
154
+ function isPictureClip(tag: string): boolean {
155
+ return tag === "video" || tag === "img";
156
+ }
157
+
158
+ function FreezeFrameItem({ element, currentTime, onClose }: Omit<ClipMenuToolItemsProps, "group">) {
159
+ const { onFreezeFrame } = useTimelineEditContextOptional();
160
+ if (!onFreezeFrame || element.tag.trim().toLowerCase() !== "video") return null;
161
+ const inside = currentTime > element.start && currentTime < element.start + element.duration;
162
+ return (
163
+ <button
164
+ type="button"
165
+ role="menuitem"
166
+ className={inside ? ROW_CLASS : DISABLED_ROW_CLASS}
167
+ disabled={!inside}
168
+ onClick={() => {
169
+ void onFreezeFrame(element, currentTime);
170
+ onClose();
171
+ }}
172
+ >
173
+ <span>{inside ? "Freeze frame" : "Freeze frame (move playhead inside clip)"}</span>
174
+ </button>
175
+ );
176
+ }
177
+
178
+ /** Freeze (time), Voice (sound), Look and Crop (picture); sound and picture end in their own divider. */
179
+ export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
180
+ if (props.group === "time") return <FreezeFrameItem {...props} />;
181
+ return <ClipMenuAttributeItems {...props} />;
182
+ }
183
+
184
+ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
185
+ const { onSetElementAttributeQuiet } = useTimelineEditContextOptional();
186
+ const state = useClipToolState(element);
187
+ const openCropBar = useCropPresetBarStore((s) => s.open);
188
+ if (!onSetElementAttributeQuiet) return null;
189
+
190
+ const write = (attr: string, value: string | null, label: string) => {
191
+ void onSetElementAttributeQuiet(element, attr, value, label);
192
+ onClose();
193
+ };
194
+
195
+ if (group === "sound") {
196
+ if (!state.hasSound) return null;
197
+ return (
198
+ <>
199
+ <ChoiceSubmenu
200
+ label="Voice"
201
+ sections={VOICE_SECTIONS}
202
+ activeId={activeVoicePreset(state.fxChain)}
203
+ onPick={(id) =>
204
+ write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
205
+ }
206
+ />
207
+ <div className="my-1 border-t border-neutral-700/60" />
208
+ </>
209
+ );
210
+ }
211
+
212
+ if (!isPictureClip(state.tag)) return null;
213
+ return (
214
+ <>
215
+ <ChoiceSubmenu
216
+ label="Look"
217
+ sections={LOOK_SECTIONS}
218
+ activeId={activeLook(state.colorGrading)}
219
+ onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
220
+ />
221
+ <button
222
+ type="button"
223
+ role="menuitem"
224
+ className={ROW_CLASS}
225
+ onClick={() => {
226
+ openCropBar({ hfId: element.hfId, id: element.domId ?? element.id });
227
+ onClose();
228
+ }}
229
+ >
230
+ <span>Crop</span>
231
+ </button>
232
+ <div className="my-1 border-t border-neutral-700/60" />
233
+ </>
234
+ );
235
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clipPeakRuns } from "./clipPeakRuns";
3
+
4
+ const map = (bins: number[]) => ({ binSeconds: 1, bins });
5
+
6
+ describe("clipPeakRuns", () => {
7
+ it("marks bins at or above -1 dBFS after the clip's gain and names the loudest", () => {
8
+ const { runs, peakDbfs } = clipPeakRuns(
9
+ map([0.1, 0.95, 0.99, 0.2, 0.9]),
10
+ { mediaStart: 0, sourceSpan: 5 },
11
+ 1,
12
+ );
13
+ expect(runs).toEqual([
14
+ { from: 0.2, to: 0.6 },
15
+ { from: 0.8, to: 1 },
16
+ ]);
17
+ expect(peakDbfs).toBeCloseTo(20 * Math.log10(0.99), 6);
18
+ });
19
+
20
+ it("puts nothing on a -12 dBFS clip, and marks it once boosted by +12 dB", () => {
21
+ const quiet = map([0.25, 0.25]);
22
+ expect(clipPeakRuns(quiet, { mediaStart: 0, sourceSpan: 2 }, 1)).toEqual({
23
+ runs: [],
24
+ peakDbfs: null,
25
+ });
26
+ expect(clipPeakRuns(quiet, { mediaStart: 0, sourceSpan: 2 }, 4).runs).toHaveLength(1);
27
+ });
28
+
29
+ it("only reads the trimmed window, positioned within the clip", () => {
30
+ const { runs } = clipPeakRuns(map([1, 0, 0, 1, 0]), { mediaStart: 2, sourceSpan: 2 }, 1);
31
+ expect(runs).toEqual([{ from: 0.5, to: 1 }]);
32
+ });
33
+
34
+ it("is empty for a muted clip or an empty window", () => {
35
+ expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 1 }, 0).runs).toEqual([]);
36
+ expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]);
37
+ });
38
+ });
@@ -0,0 +1,59 @@
1
+ import { EXPORT_PEAK_CEILING } from "../../utils/exportPeakCeiling";
2
+
3
+ export interface PeakMap {
4
+ binSeconds: number;
5
+ bins: number[];
6
+ }
7
+
8
+ export interface ClipSourceWindow {
9
+ mediaStart: number;
10
+ sourceSpan: number;
11
+ }
12
+
13
+ export interface ClipPeakMarkRuns {
14
+ runs: Array<{ from: number; to: number }>;
15
+ peakDbfs: number | null;
16
+ }
17
+
18
+ /**
19
+ * Where, as fractions of the clip, its source at the clip's own gain reaches the
20
+ * export ceiling, and the loudest point when it does. Adjacent hot bins merge.
21
+ */
22
+ export function clipPeakRuns(
23
+ map: PeakMap,
24
+ window: ClipSourceWindow,
25
+ gain: number,
26
+ ): ClipPeakMarkRuns {
27
+ const runs: ClipPeakMarkRuns["runs"] = [];
28
+ if (!(window.sourceSpan > 0) || !(map.binSeconds > 0) || !(gain > 0))
29
+ return { runs, peakDbfs: null };
30
+ const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds));
31
+ const last = Math.min(
32
+ map.bins.length,
33
+ Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds),
34
+ );
35
+ const toFraction = (bin: number) =>
36
+ Math.min(1, Math.max(0, (bin * map.binSeconds - window.mediaStart) / window.sourceSpan));
37
+ let loudest = 0;
38
+ for (let bin = first; bin < last; bin++) {
39
+ const level = (map.bins[bin] ?? 0) * gain;
40
+ if (level < EXPORT_PEAK_CEILING) continue;
41
+ loudest = Math.max(loudest, level);
42
+ const previous = runs.at(-1);
43
+ const from = toFraction(bin);
44
+ const to = toFraction(bin + 1);
45
+ if (previous && previous.to >= from) previous.to = to;
46
+ else runs.push({ from, to });
47
+ }
48
+ return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null };
49
+ }
50
+
51
+ export function isPeakMap(value: unknown): value is PeakMap {
52
+ if (typeof value !== "object" || value === null) return false;
53
+ const bins: unknown = Reflect.get(value, "bins");
54
+ return (
55
+ typeof Reflect.get(value, "binSeconds") === "number" &&
56
+ Array.isArray(bins) &&
57
+ bins.every((bin) => typeof bin === "number")
58
+ );
59
+ }
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseAudioFxChain, serializeAudioFxChain } from "@hyperframes/core/audio-fx";
3
+ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
4
+ import {
5
+ activeLook,
6
+ activeVoicePreset,
7
+ chainWithVoicePreset,
8
+ hasCrop,
9
+ isDucked,
10
+ lookAttrValue,
11
+ readClipBadges,
12
+ splitVisibleBadges,
13
+ type ClipToolState,
14
+ } from "./clipToolAttrs";
15
+
16
+ const presetsIn = (raw: string | null) =>
17
+ raw ? [...new Set(parseAudioFxChain(raw).nodes.map((n) => n.fromPreset ?? n.type))] : [];
18
+
19
+ const handNode = serializeAudioFxChain({
20
+ version: 1,
21
+ nodes: [{ id: "n1", type: "highpass", enabled: true, params: { frequency: 80 } }],
22
+ });
23
+
24
+ describe("voice presets", () => {
25
+ it("writes the chosen preset's nodes and reads it back", () => {
26
+ const raw = chainWithVoicePreset(null, "voice-clean");
27
+ expect(presetsIn(raw)).toEqual(["voice-clean"]);
28
+ expect(activeVoicePreset(raw)).toBe("voice-clean");
29
+ });
30
+
31
+ it("is single-choice: picking another menu preset replaces the first", () => {
32
+ const raw = chainWithVoicePreset(chainWithVoicePreset(null, "voice-clean"), "telephone");
33
+ expect(presetsIn(raw)).toEqual(["telephone"]);
34
+ });
35
+
36
+ it("None drops the attribute when the chain held only the preset", () => {
37
+ expect(chainWithVoicePreset(chainWithVoicePreset(null, "voice-warm"), null)).toBeNull();
38
+ });
39
+
40
+ it("None keeps nodes that are not from a menu preset", () => {
41
+ const withPreset = chainWithVoicePreset(handNode, "voice-broadcast");
42
+ expect(presetsIn(withPreset)).toEqual(["highpass", "voice-broadcast"]);
43
+ expect(chainWithVoicePreset(withPreset, null)).toBe(handNode);
44
+ });
45
+
46
+ it("re-applying the same preset keeps one copy", () => {
47
+ const once = chainWithVoicePreset(null, "megaphone");
48
+ const twice = chainWithVoicePreset(once, "megaphone");
49
+ expect(parseAudioFxChain(twice ?? "").nodes).toHaveLength(
50
+ parseAudioFxChain(once ?? "").nodes.length,
51
+ );
52
+ });
53
+ });
54
+
55
+ describe("looks", () => {
56
+ it("writes the minimal preset form the runtime resolves", () => {
57
+ const raw = lookAttrValue("warm-daylight");
58
+ expect(raw).toBe('{"preset":"warm-daylight","intensity":1}');
59
+ expect(normalizeHfColorGrading(JSON.parse(raw ?? ""))?.preset).toBe("warm-daylight");
60
+ expect(activeLook(raw)).toBe("warm-daylight");
61
+ });
62
+
63
+ it("None removes the attribute", () => {
64
+ expect(lookAttrValue(null)).toBeNull();
65
+ });
66
+
67
+ it("reads a full inspector-written grading and ignores garbage", () => {
68
+ expect(activeLook('{"preset":"mono-clean","intensity":0.4,"adjust":{"contrast":0.2}}')).toBe(
69
+ "mono-clean",
70
+ );
71
+ expect(activeLook("{nope")).toBeNull();
72
+ expect(activeLook('{"preset":"mono-clean","enabled":false}')).toBeNull();
73
+ });
74
+ });
75
+
76
+ describe("small readers", () => {
77
+ it("treats an inset of zero or none as no crop", () => {
78
+ expect(hasCrop("inset(10px 0px 10px 0px)")).toBe(true);
79
+ expect(hasCrop("inset(0px)")).toBe(false);
80
+ expect(hasCrop("none")).toBe(false);
81
+ expect(hasCrop(null)).toBe(false);
82
+ });
83
+
84
+ it("reads a carve as ducked unless it is switched off", () => {
85
+ expect(isDucked('{"sources":["vo"]}')).toBe(true);
86
+ expect(isDucked('{"enabled":false,"sources":["vo"]}')).toBe(false);
87
+ expect(isDucked(null)).toBe(false);
88
+ });
89
+ });
90
+
91
+ const baseState: ClipToolState = {
92
+ tag: "video",
93
+ hasSound: true,
94
+ volume: null,
95
+ muted: false,
96
+ fxChain: null,
97
+ automation: null,
98
+ colorGrading: null,
99
+ clipPath: null,
100
+ carve: null,
101
+ link: null,
102
+ };
103
+
104
+ const rampAutomation = JSON.stringify({
105
+ version: 1,
106
+ lanes: [
107
+ {
108
+ target: "rate",
109
+ points: [
110
+ { t: 0, v: 0.5 },
111
+ { t: 2, v: 1 },
112
+ ],
113
+ },
114
+ ],
115
+ });
116
+
117
+ describe("readClipBadges", () => {
118
+ it("shows nothing for a plain clip at 100%", () => {
119
+ expect(readClipBadges(baseState)).toEqual([]);
120
+ });
121
+
122
+ it("lists every applied tool in the wireframe's order", () => {
123
+ const labels = readClipBadges({
124
+ ...baseState,
125
+ volume: 1.8,
126
+ fxChain: chainWithVoicePreset(null, "voice-clean"),
127
+ automation: rampAutomation,
128
+ colorGrading: lookAttrValue("warm-daylight"),
129
+ clipPath: "inset(0px 20px)",
130
+ carve: "{}",
131
+ link: "talk",
132
+ }).map((b) => b.label);
133
+ expect(labels).toEqual([
134
+ "Linked",
135
+ "Warm daylight",
136
+ "Voice: Clean",
137
+ "Ramp",
138
+ "Crop",
139
+ "Ducked",
140
+ "180%",
141
+ ]);
142
+ });
143
+
144
+ it("badges a muted audio clip but not muted b-roll", () => {
145
+ expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted");
146
+ expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]);
147
+ });
148
+
149
+ it("caps at two visible badges plus the link badge", () => {
150
+ const badges = readClipBadges({
151
+ ...baseState,
152
+ link: "talk",
153
+ colorGrading: lookAttrValue("mono-clean"),
154
+ clipPath: "inset(5px)",
155
+ volume: 0.6,
156
+ });
157
+ const { visible, hidden } = splitVisibleBadges(badges);
158
+ expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]);
159
+ expect(hidden.map((b) => b.label)).toEqual(["60%"]);
160
+ });
161
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The attribute each clip-menu tool writes, and what a clip's attributes say is applied.
3
+ * Pure over strings so the menu, the badges and an agent's hand edits all agree.
4
+ */
5
+
6
+ import {
7
+ parseAudioFxChain,
8
+ serializeAudioFxChain,
9
+ type HfAudioFxChain,
10
+ } from "@hyperframes/core/audio-fx";
11
+ import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
12
+ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
13
+ import { parseRateLane } from "@hyperframes/core/speed-ramp";
14
+ import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
15
+
16
+ export interface ClipToolChoice {
17
+ id: string;
18
+ label: string;
19
+ }
20
+
21
+ export const VOICE_CHOICES: readonly ClipToolChoice[] = [
22
+ { id: "voice-clean", label: "Clean" },
23
+ { id: "voice-broadcast", label: "Broadcast" },
24
+ { id: "voice-warm", label: "Warm" },
25
+ ];
26
+
27
+ export const CHARACTER_CHOICES: readonly ClipToolChoice[] = [
28
+ { id: "telephone", label: "Telephone" },
29
+ { id: "radio-am", label: "AM Radio" },
30
+ { id: "megaphone", label: "Megaphone" },
31
+ ];
32
+
33
+ export const LOOK_CHOICES: readonly ClipToolChoice[] = [
34
+ { id: "warm-daylight", label: "Warm daylight" },
35
+ { id: "clean-studio", label: "Clean studio" },
36
+ { id: "vintage-wash", label: "Vintage wash" },
37
+ { id: "mono-clean", label: "Mono" },
38
+ { id: "deep-contrast", label: "Deep contrast" },
39
+ { id: "home-movie-8mm", label: "Home movie" },
40
+ ];
41
+
42
+ const VOICE_MENU_IDS = new Set([...VOICE_CHOICES, ...CHARACTER_CHOICES].map((c) => c.id));
43
+
44
+ const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
45
+
46
+ function parseChainOrEmpty(raw: string | null | undefined): HfAudioFxChain {
47
+ if (!raw) return EMPTY_CHAIN;
48
+ try {
49
+ return parseAudioFxChain(raw);
50
+ } catch {
51
+ return EMPTY_CHAIN;
52
+ }
53
+ }
54
+
55
+ function labelFor(choices: readonly ClipToolChoice[], id: string | null): string | null {
56
+ return choices.find((choice) => choice.id === id)?.label ?? null;
57
+ }
58
+
59
+ export function activeVoicePreset(rawChain: string | null | undefined): string | null {
60
+ const ids = activeAudioFxPresetIds(parseChainOrEmpty(rawChain));
61
+ return ids.find((id) => VOICE_MENU_IDS.has(id)) ?? null;
62
+ }
63
+
64
+ function voicePresetLabel(id: string | null): string | null {
65
+ return labelFor(VOICE_CHOICES, id) ?? labelFor(CHARACTER_CHOICES, id);
66
+ }
67
+
68
+ /**
69
+ * The `data-fx-chain` after choosing a voice preset, or `null` to drop the attribute.
70
+ * Single choice: the other menu presets' nodes go; carve, leveller and hand-added nodes stay.
71
+ */
72
+ export function chainWithVoicePreset(
73
+ rawChain: string | null | undefined,
74
+ presetId: string | null,
75
+ ): string | null {
76
+ const chain = parseChainOrEmpty(rawChain);
77
+ const others = chain.nodes.filter(
78
+ (node) =>
79
+ !node.fromPreset || !VOICE_MENU_IDS.has(node.fromPreset) || node.fromPreset === presetId,
80
+ );
81
+ const kept: HfAudioFxChain = { ...chain, nodes: others };
82
+ const next = presetId ? (applyPresetToChain(kept, presetId, undefined) ?? kept) : kept;
83
+ return next.nodes.length > 0 ? serializeAudioFxChain(next) : null;
84
+ }
85
+
86
+ function parseJson(raw: string | null | undefined): unknown {
87
+ if (!raw) return null;
88
+ try {
89
+ return JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export function activeLook(rawGrading: string | null | undefined): string | null {
96
+ return normalizeHfColorGrading(parseJson(rawGrading))?.preset ?? null;
97
+ }
98
+
99
+ function lookLabel(id: string | null): string | null {
100
+ return labelFor(LOOK_CHOICES, id);
101
+ }
102
+
103
+ export function lookAttrValue(presetId: string | null): string | null {
104
+ return presetId ? JSON.stringify({ preset: presetId, intensity: 1 }) : null;
105
+ }
106
+
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return typeof value === "object" && value !== null;
109
+ }
110
+
111
+ export function isDucked(rawCarve: string | null | undefined): boolean {
112
+ const carve = parseJson(rawCarve);
113
+ return isRecord(carve) && carve["enabled"] !== false;
114
+ }
115
+
116
+ function hasRateRamp(rawAutomation: string | null | undefined): boolean {
117
+ return parseRateLane(rawAutomation) !== null;
118
+ }
119
+
120
+ const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
121
+
122
+ export function hasCrop(clipPath: string | null | undefined): boolean {
123
+ const value = clipPath?.trim() ?? "";
124
+ return value !== "" && value !== "none" && !ZERO_INSET.test(value);
125
+ }
126
+
127
+ export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
128
+
129
+ export interface ClipBadge {
130
+ kind: ClipBadgeKind;
131
+ label: string;
132
+ }
133
+
134
+ /** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
135
+ export interface ClipToolState {
136
+ tag: string;
137
+ hasSound: boolean;
138
+ volume: number | null;
139
+ muted: boolean;
140
+ fxChain: string | null;
141
+ automation: string | null;
142
+ colorGrading: string | null;
143
+ clipPath: string | null;
144
+ carve: string | null;
145
+ link: string | null;
146
+ }
147
+
148
+ function volumeBadge(state: ClipToolState): ClipBadge | null {
149
+ const isAudio = state.tag === "audio";
150
+ if (!isAudio && !state.hasSound) return null;
151
+ if (state.muted) return { kind: "volume", label: "Muted" };
152
+ const volume = state.volume ?? 1;
153
+ if (Math.abs(volume - 1) < 0.005) return null;
154
+ return { kind: "volume", label: `${Math.round(volume * 100)}%` };
155
+ }
156
+
157
+ /** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */
158
+ export function readClipBadges(state: ClipToolState): ClipBadge[] {
159
+ const look = lookLabel(activeLook(state.colorGrading));
160
+ const voice = voicePresetLabel(activeVoicePreset(state.fxChain));
161
+ const volume = volumeBadge(state);
162
+ const badges: Array<ClipBadge | null> = [
163
+ state.link ? { kind: "link", label: "Linked" } : null,
164
+ look ? { kind: "look", label: look } : null,
165
+ voice ? { kind: "voice", label: `Voice: ${voice}` } : null,
166
+ hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null,
167
+ hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null,
168
+ isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null,
169
+ volume,
170
+ ];
171
+ return badges.filter((badge): badge is ClipBadge => badge !== null);
172
+ }
173
+
174
+ const MAX_VISIBLE_BADGES = 2;
175
+
176
+ /** The link badge is always shown; the rest cap at two, then `+N`. */
177
+ export function splitVisibleBadges(badges: readonly ClipBadge[]): {
178
+ visible: ClipBadge[];
179
+ hidden: ClipBadge[];
180
+ } {
181
+ const link = badges.filter((badge) => badge.kind === "link");
182
+ const rest = badges.filter((badge) => badge.kind !== "link");
183
+ return {
184
+ visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
185
+ hidden: rest.slice(MAX_VISIBLE_BADGES),
186
+ };
187
+ }
@@ -118,6 +118,7 @@ export interface TimelineEditCallbacks {
118
118
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
119
119
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
120
120
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
121
+ onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
121
122
  onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
122
123
  onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
123
124
  onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;