@hyperframes/studio 0.8.58 → 0.8.59

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 (52) hide show
  1. package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +548 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21596 -20725
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/components/TimelineToolbar.tsx +19 -0
  13. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  14. package/src/components/editor/audioInspectorInput.ts +55 -0
  15. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  19. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  20. package/src/components/editor/useColorGradingController.ts +2 -6
  21. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  22. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  23. package/src/components/nle/TimelinePane.tsx +29 -23
  24. package/src/contexts/TimelineEditContext.tsx +4 -0
  25. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  26. package/src/index.ts +16 -0
  27. package/src/player/components/Timeline.test.ts +22 -31
  28. package/src/player/components/Timeline.tsx +34 -7
  29. package/src/player/components/TimelineCanvas.tsx +10 -56
  30. package/src/player/components/TimelineClip.tsx +14 -1
  31. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  32. package/src/player/components/TimelineClipFades.tsx +302 -0
  33. package/src/player/components/TimelineOverlays.tsx +164 -165
  34. package/src/player/components/TimelineParts.test.tsx +101 -0
  35. package/src/player/components/TimelineParts.tsx +74 -0
  36. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  37. package/src/player/components/TimelineProvider.tsx +25 -0
  38. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  39. package/src/player/components/TimelineRulerPart.tsx +22 -0
  40. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  41. package/src/player/components/useTimelineProviderState.tsx +19 -12
  42. package/src/player/lib/timelineElementHelpers.ts +20 -0
  43. package/src/player/store/timelineElement.ts +5 -0
  44. package/src/styles/theme.css +4 -0
  45. package/src/utils/audioMeterMath.test.ts +44 -0
  46. package/src/utils/audioMeterMath.ts +79 -0
  47. package/src/utils/audioMeterVisibility.ts +14 -0
  48. package/src/utils/studioHelpers.ts +21 -0
  49. package/src/utils/studioUiPreferences.test.ts +10 -0
  50. package/src/utils/studioUiPreferences.ts +5 -0
  51. package/src/utils/timelineAssetDrop.test.ts +32 -0
  52. package/src/utils/timelineAssetDrop.ts +6 -1
@@ -0,0 +1,106 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /** Click-to-type readout. Enter/blur commits; a refused value stays open and turns red. */
4
+ export function FlatSliderReadout({
5
+ label,
6
+ displayValue,
7
+ tier,
8
+ disabled,
9
+ onCommitText,
10
+ onCommitted,
11
+ }: {
12
+ label: string;
13
+ displayValue: string;
14
+ tier: "default" | "explicitCustom";
15
+ disabled?: boolean;
16
+ /** Receives the raw text; return false to refuse it and keep the field open. */
17
+ onCommitText?: (text: string) => boolean | void;
18
+ /** Fires after an accepted commit, for telemetry. */
19
+ onCommitted?: () => void;
20
+ }) {
21
+ const [editing, setEditing] = useState(false);
22
+ const [text, setText] = useState("");
23
+ const [invalid, setInvalid] = useState(false);
24
+ const inputRef = useRef<HTMLInputElement | null>(null);
25
+ const editable = Boolean(onCommitText) && !disabled;
26
+
27
+ useEffect(() => {
28
+ if (!editing) return;
29
+ inputRef.current?.focus();
30
+ inputRef.current?.select();
31
+ }, [editing]);
32
+
33
+ const begin = () => {
34
+ if (!editable) return;
35
+ setText(displayValue);
36
+ setInvalid(false);
37
+ setEditing(true);
38
+ };
39
+ // Enter and blur both commit, so a value typed and then clicked away from is
40
+ // not silently dropped. A refused value stays open on Enter (the author is
41
+ // still there to fix it) and is discarded on blur (they have moved on).
42
+ const commit = (keepOpenIfRefused: boolean) => {
43
+ const accepted = onCommitText?.(text.trim());
44
+ if (accepted === false && keepOpenIfRefused) {
45
+ setInvalid(true);
46
+ inputRef.current?.select();
47
+ return;
48
+ }
49
+ setEditing(false);
50
+ if (accepted !== false) onCommitted?.();
51
+ };
52
+
53
+ if (editing) {
54
+ return (
55
+ <input
56
+ ref={inputRef}
57
+ data-flat-slider-input="true"
58
+ aria-label={`${label} value`}
59
+ aria-invalid={invalid || undefined}
60
+ value={text}
61
+ spellCheck={false}
62
+ onChange={(e) => {
63
+ setText(e.target.value);
64
+ setInvalid(false);
65
+ }}
66
+ onKeyDown={(e) => {
67
+ if (e.key === "Enter") {
68
+ e.preventDefault();
69
+ commit(true);
70
+ } else if (e.key === "Escape") {
71
+ e.preventDefault();
72
+ setEditing(false);
73
+ }
74
+ // Keep the timeline's own shortcuts from firing on every keystroke.
75
+ e.stopPropagation();
76
+ }}
77
+ onBlur={() => commit(false)}
78
+ className={`w-11 shrink-0 rounded-[3px] border bg-panel-surface px-1 text-right font-mono text-[10px] text-panel-text-0 outline-none ${
79
+ invalid ? "border-red-400" : "border-panel-accent"
80
+ }`}
81
+ />
82
+ );
83
+ }
84
+
85
+ return (
86
+ <span
87
+ data-flat-slider-value="true"
88
+ role={editable ? "button" : undefined}
89
+ tabIndex={editable ? 0 : undefined}
90
+ title={editable ? "Click to type a value" : undefined}
91
+ onClick={begin}
92
+ onKeyDown={(e) => {
93
+ if (!editable) return;
94
+ if (e.key === "Enter" || e.key === " ") {
95
+ e.preventDefault();
96
+ begin();
97
+ }
98
+ }}
99
+ className={`w-11 shrink-0 text-right font-mono text-[10px] ${
100
+ tier === "explicitCustom" ? "text-panel-text-0" : "text-panel-text-3"
101
+ } ${editable ? "cursor-text rounded-[3px] hover:bg-panel-hover hover:text-panel-text-0" : ""}`}
102
+ >
103
+ {displayValue}
104
+ </span>
105
+ );
106
+ }
@@ -1,3 +1,4 @@
1
+ import { mediaMetadataUrl } from "../../utils/studioHelpers";
1
2
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
2
3
  import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
3
4
  import {
@@ -288,12 +289,7 @@ export function useColorGradingController({
288
289
  return;
289
290
  }
290
291
  const controller = new AbortController();
291
- fetch(
292
- `/api/projects/${encodeURIComponent(projectId)}/media/metadata?path=${encodeURIComponent(
293
- selectedAssetPath,
294
- )}`,
295
- { signal: controller.signal },
296
- )
292
+ fetch(mediaMetadataUrl(projectId, selectedAssetPath), { signal: controller.signal })
297
293
  .then(async (response) => {
298
294
  if (!response.ok) return { ok: false as const };
299
295
  const data: MediaMetadataResponse | null = await response.json();
@@ -0,0 +1,124 @@
1
+ // @vitest-environment happy-dom
2
+ // fallow-ignore-file code-duplication
3
+
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { usePlayerStore } from "../../player/store/playerStore";
8
+ import type { TimelineElement } from "../../player/store/timelineElement";
9
+ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
10
+ import { AudioMeterStrip } from "./AudioMeterStrip";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ const iframe = { contentWindow: null as unknown };
15
+ const previewIframeRef = { current: iframe };
16
+ vi.mock("../../contexts/StudioContext", () => ({
17
+ useStudioShellContext: () => ({ previewIframeRef }),
18
+ }));
19
+
20
+ const makeHook = (groups: Record<string, { l: number; r: number }> = {}) => ({
21
+ start: vi.fn(),
22
+ stop: vi.fn(),
23
+ read: vi.fn(() => ({ master: { l: 1, r: 0.5 }, groups })),
24
+ });
25
+ const setHook = (hook: ReturnType<typeof makeHook> | null) => {
26
+ iframe.contentWindow = hook ? { __hf: { audioMeter: hook } } : null;
27
+ };
28
+ const clip = (extra: Partial<TimelineElement>) =>
29
+ ({ id: "a", tag: "audio", ...extra }) as TimelineElement;
30
+
31
+ let frames: FrameRequestCallback[] = [];
32
+ const tick = () => {
33
+ const run = frames;
34
+ frames = [];
35
+ act(() => run.forEach((cb) => cb(performance.now() + 16)));
36
+ };
37
+
38
+ beforeEach(() => {
39
+ frames = [];
40
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
41
+ vi.stubGlobal("cancelAnimationFrame", () => {});
42
+ usePlayerStore.setState({ elements: [] });
43
+ useAudioMetersVisible.setState({ visible: true });
44
+ });
45
+ const roots: Root[] = [];
46
+ afterEach(() => {
47
+ act(() => roots.splice(0).forEach((root) => root.unmount()));
48
+ document.body.innerHTML = "";
49
+ vi.unstubAllGlobals();
50
+ });
51
+
52
+ function mount() {
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ const root = createRoot(host);
56
+ act(() => root.render(<AudioMeterStrip />));
57
+ roots.push(root);
58
+ return { host, root };
59
+ }
60
+
61
+ describe("AudioMeterStrip", () => {
62
+ it("is absent when the project has no audio", () => {
63
+ usePlayerStore.setState({ elements: [clip({ tag: "div" })] });
64
+ expect(mount().host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
65
+ });
66
+
67
+ it("shows one strip per group plus Master, and hides when toggled off", () => {
68
+ usePlayerStore.setState({
69
+ elements: [
70
+ clip({ id: "1", audioGroup: "music", audioGroupLabel: "Music" }),
71
+ clip({ id: "2", audioGroup: "vo" }),
72
+ ],
73
+ });
74
+ const { host } = mount();
75
+ expect(host.textContent).toContain("Music");
76
+ expect(host.textContent).toContain("vo");
77
+ expect(host.textContent).toContain("Master");
78
+ act(() => useAudioMetersVisible.getState().setVisible(false));
79
+ expect(host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
80
+ });
81
+
82
+ it("starts the taps once, drives the bars, follows a swapped preview window, and stops on unmount", () => {
83
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
84
+ const first = makeHook({ vo: { l: 1, r: 1 } });
85
+ setHook(first);
86
+ const { host, root } = mount();
87
+ const fill = host.querySelector<HTMLElement>("[class*=bg-green-500]")!;
88
+ expect(fill.style.transform).toBe("scaleY(0)");
89
+ tick();
90
+ tick();
91
+ expect(first.start).toHaveBeenCalledTimes(1);
92
+ expect(fill.style.transform).toBe("scaleY(1)");
93
+ const peak = host.querySelector<HTMLElement>("[data-testid=meter-peak]")!;
94
+ expect(peak.style.bottom).toBe("100%");
95
+ expect(peak.style.transform).toBe("translateY(1px)");
96
+
97
+ const second = makeHook();
98
+ setHook(second);
99
+ tick();
100
+ expect(first.stop).toHaveBeenCalledTimes(1);
101
+ expect(second.start).toHaveBeenCalledTimes(1);
102
+
103
+ act(() => root.unmount());
104
+ roots.length = 0;
105
+ expect(second.stop).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("follows a new preview hook even when the old stop() throws", () => {
109
+ usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
110
+ const first = makeHook({ vo: { l: 1, r: 1 } });
111
+ first.stop.mockImplementation(() => {
112
+ throw new Error("dead realm");
113
+ });
114
+ setHook(first);
115
+ mount();
116
+ tick();
117
+ const second = makeHook();
118
+ setHook(second);
119
+ tick();
120
+ expect(second.start).toHaveBeenCalledTimes(1);
121
+ tick();
122
+ expect(second.read).toHaveBeenCalled();
123
+ });
124
+ });
@@ -0,0 +1,213 @@
1
+ import { memo, useEffect, useMemo, useRef, type Ref, type RefObject } from "react";
2
+ import { usePlayerStore } from "../../player";
3
+ import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
4
+ import { useStudioShellContext } from "../../contexts/StudioContext";
5
+ import {
6
+ METER_DB_MARKS,
7
+ useProjectHasAudio,
8
+ markFraction,
9
+ stepPair,
10
+ type MeterPair as Pair,
11
+ } from "../../utils/audioMeterMath";
12
+
13
+ interface Levels {
14
+ master: { l: number; r: number };
15
+ groups: Record<string, { l: number; r: number }>;
16
+ }
17
+ interface AudioMeterHook {
18
+ start(): void;
19
+ stop(): void;
20
+ read(): Levels;
21
+ }
22
+ interface Strip {
23
+ id: string | null;
24
+ label: string;
25
+ }
26
+ type Bars = { fill: HTMLElement | null; peak: HTMLElement | null };
27
+ type StripBars = [Bars, Bars];
28
+
29
+ const MASTER: Strip = { id: null, label: "Master" };
30
+
31
+ function useStrips(): Strip[] {
32
+ const elements = usePlayerStore((s) => s.elements);
33
+ return useMemo(() => {
34
+ const labels = new Map<string, string>();
35
+ for (const el of elements) {
36
+ if (el.audioGroup && !labels.has(el.audioGroup)) {
37
+ labels.set(el.audioGroup, el.audioGroupLabel ?? el.audioGroup);
38
+ }
39
+ }
40
+ return [...[...labels].map(([id, label]) => ({ id, label })), MASTER];
41
+ }, [elements]);
42
+ }
43
+
44
+ type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
45
+
46
+ function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
47
+ try {
48
+ return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** 0 dB is bottom 100% plus 1px down, so the tick stays inside overflow-hidden.
55
+ * A calc() bottom does not stick as an inline style on Windows happy-dom. */
56
+ function paintPeak(el: HTMLElement | null, peak: number): void {
57
+ if (!el) return;
58
+ el.style.setProperty("bottom", `${peak * 100}%`);
59
+ el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
60
+ }
61
+
62
+ function paint(bars: StripBars | undefined, channels: Pair): void {
63
+ channels.forEach((ch, i) => {
64
+ bars?.[i]?.fill?.style.setProperty("transform", `scaleY(${ch.level})`);
65
+ paintPeak(bars?.[i]?.peak ?? null, ch.peak);
66
+ });
67
+ }
68
+
69
+ /** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
70
+ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
71
+ const { previewIframeRef } = useStudioShellContext();
72
+ const stripsRef = useRef(strips);
73
+ stripsRef.current = strips;
74
+ useEffect(() => {
75
+ let raf = 0;
76
+ let active: AudioMeterHook | null = null;
77
+ let last = performance.now();
78
+ const state = new Map<string | null, Pair>();
79
+ const tick = (now: number) => {
80
+ raf = requestAnimationFrame(tick);
81
+ const live = readHook(previewIframeRef.current);
82
+ if (live !== active) {
83
+ try {
84
+ active?.stop();
85
+ } catch {
86
+ // Preview iframe was torn down; the old hook is uncallable.
87
+ }
88
+ try {
89
+ live?.start();
90
+ } catch {
91
+ // New preview is not ready to attach yet.
92
+ }
93
+ active = live;
94
+ }
95
+ const levels = active?.read();
96
+ const dt = now - last;
97
+ last = now;
98
+ for (const { id } of stripsRef.current) {
99
+ const next = stepPair(
100
+ state.get(id),
101
+ id === null ? levels?.master : levels?.groups[id],
102
+ now,
103
+ dt,
104
+ );
105
+ if (next === state.get(id)) continue;
106
+ state.set(id, next);
107
+ paint(bars.current.get(id), next);
108
+ }
109
+ };
110
+ raf = requestAnimationFrame(tick);
111
+ return () => {
112
+ cancelAnimationFrame(raf);
113
+ try {
114
+ active?.stop();
115
+ } catch {
116
+ // Preview iframe already gone.
117
+ }
118
+ };
119
+ }, [previewIframeRef, bars]);
120
+ }
121
+
122
+ function Bar({ fillRef, peakRef }: { fillRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
123
+ return (
124
+ <div className="relative h-full w-1.5 overflow-hidden rounded-[1px] bg-neutral-900">
125
+ <div
126
+ ref={fillRef}
127
+ className="absolute inset-0 origin-bottom bg-green-500"
128
+ style={{ transform: "scaleY(0)" }}
129
+ />
130
+ <div
131
+ ref={peakRef}
132
+ data-testid="meter-peak"
133
+ className="absolute inset-x-0 bottom-0 h-px bg-green-400"
134
+ />
135
+ </div>
136
+ );
137
+ }
138
+
139
+ function MeterStrip({
140
+ strip,
141
+ register,
142
+ }: {
143
+ strip: Strip;
144
+ register: (id: string | null, bars: StripBars | null) => void;
145
+ }) {
146
+ const refs = [
147
+ useRef<HTMLDivElement>(null),
148
+ useRef<HTMLDivElement>(null),
149
+ useRef<HTMLDivElement>(null),
150
+ useRef<HTMLDivElement>(null),
151
+ ] as const;
152
+ useEffect(() => {
153
+ register(strip.id, [
154
+ { fill: refs[0].current, peak: refs[1].current },
155
+ { fill: refs[2].current, peak: refs[3].current },
156
+ ]);
157
+ return () => register(strip.id, null);
158
+ // refs are stable objects
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, [strip.id, register]);
161
+ return (
162
+ <div className="flex w-16 shrink-0 flex-col items-center gap-1 px-1 pt-2 pb-1">
163
+ <div
164
+ className="flex min-h-0 flex-1 items-stretch gap-0.5"
165
+ aria-label={`${strip.label} level`}
166
+ >
167
+ <Bar fillRef={refs[0]} peakRef={refs[1]} />
168
+ <Bar fillRef={refs[2]} peakRef={refs[3]} />
169
+ <div className="relative ml-1 w-5 font-mono text-[9px] leading-none text-neutral-500">
170
+ {METER_DB_MARKS.map((db) => (
171
+ <span
172
+ key={db}
173
+ className="absolute right-0 translate-y-1/2"
174
+ style={{ bottom: `${markFraction(db) * 100}%` }}
175
+ >
176
+ {db}
177
+ </span>
178
+ ))}
179
+ </div>
180
+ </div>
181
+ <span className="max-w-full truncate text-[10px] text-neutral-400" title={strip.label}>
182
+ {strip.label}
183
+ </span>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ export const AudioMeterStrip = memo(function AudioMeterStrip() {
189
+ const visible = useAudioMetersVisible((s) => s.visible);
190
+ const projectHasAudio = useProjectHasAudio();
191
+ if (!visible || !projectHasAudio) return null;
192
+ return <MeterStripBody />;
193
+ });
194
+
195
+ function MeterStripBody() {
196
+ const strips = useStrips();
197
+ const bars = useRef(new Map<string | null, StripBars>());
198
+ const register = useRef((id: string | null, b: StripBars | null) => {
199
+ if (b) bars.current.set(id, b);
200
+ else bars.current.delete(id);
201
+ }).current;
202
+ useMeterLoop(strips, bars);
203
+ return (
204
+ <div
205
+ data-testid="audio-meter-strip"
206
+ className="flex shrink-0 overflow-x-auto border-l border-neutral-800/50 bg-neutral-950"
207
+ >
208
+ {strips.map((strip) => (
209
+ <MeterStrip key={strip.id ?? "master"} strip={strip} register={register} />
210
+ ))}
211
+ </div>
212
+ );
213
+ }
@@ -2,6 +2,7 @@ import { useCallback, type ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
3
  import type { TimelineElement, TimelineTimeRange } from "../../player";
4
4
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
+ import { AudioMeterStrip } from "./AudioMeterStrip";
5
6
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
6
7
  import { trackStudioExpandedClipEdit } from "../../telemetry/events";
7
8
  import { useNLEContext } from "./NLEContext";
@@ -271,29 +272,34 @@ export function TimelinePane({
271
272
  }}
272
273
  >
273
274
  <div className="shrink-0">{timelineToolbar}</div>
274
- <Timeline
275
- sessionEpoch={timelineSessionEpoch}
276
- onSeek={seek}
277
- onDrillDown={handleDrillDown}
278
- renderClipContent={renderClipContent}
279
- onFileDrop={onFileDrop}
280
- onDeleteElement={handleDeleteElement}
281
- onAssetDrop={onAssetDrop}
282
- onBlockDrop={onBlockDrop}
283
- onCompositionDrop={onCompositionDrop}
284
- onMoveElement={handleMoveElement}
285
- onMoveElements={handleMoveElements}
286
- onResizeElement={handleResizeElement}
287
- onResizeElements={handleResizeElements}
288
- onBlockedEditAttempt={onBlockedEditAttempt}
289
- onSplitElement={handleSplitElement}
290
- onSelectElement={onSelectTimelineElement}
291
- onRangeSelect={onRangeSelect}
292
- onCopyClip={onCopyClip}
293
- onPasteClip={onPasteClip}
294
- onDuplicateClip={onDuplicateClip}
295
- canPasteClip={canPasteClip}
296
- />
275
+ <div className="flex min-h-0 flex-1">
276
+ <div className="min-w-0 flex-1">
277
+ <Timeline
278
+ sessionEpoch={timelineSessionEpoch}
279
+ onSeek={seek}
280
+ onDrillDown={handleDrillDown}
281
+ renderClipContent={renderClipContent}
282
+ onFileDrop={onFileDrop}
283
+ onDeleteElement={handleDeleteElement}
284
+ onAssetDrop={onAssetDrop}
285
+ onBlockDrop={onBlockDrop}
286
+ onCompositionDrop={onCompositionDrop}
287
+ onMoveElement={handleMoveElement}
288
+ onMoveElements={handleMoveElements}
289
+ onResizeElement={handleResizeElement}
290
+ onResizeElements={handleResizeElements}
291
+ onBlockedEditAttempt={onBlockedEditAttempt}
292
+ onSplitElement={handleSplitElement}
293
+ onSelectElement={onSelectTimelineElement}
294
+ onRangeSelect={onRangeSelect}
295
+ onCopyClip={onCopyClip}
296
+ onPasteClip={onPasteClip}
297
+ onDuplicateClip={onDuplicateClip}
298
+ canPasteClip={canPasteClip}
299
+ />
300
+ </div>
301
+ <AudioMeterStrip />
302
+ </div>
297
303
  </div>
298
304
  {timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
299
305
  {timelineOverlay && (
@@ -21,6 +21,10 @@ export function useTimelineEditContextOptional(): TimelineEditCallbacks {
21
21
  return useContext(TimelineEditContext) ?? {};
22
22
  }
23
23
 
24
+ export function useTimelineEditContextValue(): TimelineEditCallbacks | null {
25
+ return useContext(TimelineEditContext);
26
+ }
27
+
24
28
  export function TimelineEditProvider({
25
29
  value,
26
30
  children,
@@ -18,7 +18,11 @@ import {
18
18
  } from "../utils/timelineAssetDrop";
19
19
  import { generateId } from "../utils/generateId";
20
20
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
21
- import { collectHtmlIds, resolveDroppedAssetDuration } from "../utils/studioHelpers";
21
+ import {
22
+ collectHtmlIds,
23
+ resolveDroppedAssetDuration,
24
+ resolveDroppedAssetHasAudio,
25
+ } from "../utils/studioHelpers";
22
26
  import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
23
27
  import { readFileContent } from "./timelineTimingSync";
24
28
  import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
@@ -113,6 +117,9 @@ export function useTimelineAssetDropOps({
113
117
  ? durationOverride
114
118
  : await resolveDroppedAssetDuration(pid, assetPath, kind);
115
119
  const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
120
+ // A video with an audio stream lands audible; the mixer only hears a
121
+ // <video> marked data-has-audio, and a muted drop was losing the sound.
122
+ const hasAudio = await resolveDroppedAssetHasAudio(pid, assetPath, kind);
116
123
  const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
117
124
  const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
118
125
 
@@ -146,6 +153,7 @@ export function useTimelineAssetDropOps({
146
153
  duration: normalizedDuration,
147
154
  track,
148
155
  zIndex: newElementZIndex,
156
+ hasAudio,
149
157
  geometry: fitTimelineAssetGeometry(
150
158
  null,
151
159
  resolveTimelineAssetCompositionSize(originalContent),
package/src/index.ts CHANGED
@@ -60,6 +60,22 @@ export type {
60
60
  TimelineElement,
61
61
  TimelineTimeRange,
62
62
  } from "./player";
63
+ export {
64
+ TimelineFrame,
65
+ TimelineLanes,
66
+ TimelineOverlays,
67
+ TimelinePlayhead,
68
+ TimelineRazorGuide,
69
+ TimelineRuler,
70
+ TimelineEmptyStatePart,
71
+ TimelineEditPopover,
72
+ TimelineClipMenu,
73
+ TimelineGapMenu,
74
+ TimelineKeyframeMenu,
75
+ TimelineShortcutHint,
76
+ } from "./player/components/TimelineParts";
77
+ export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
78
+ export type { TimelineTheme } from "./player/components/timelineTheme";
63
79
 
64
80
  // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
65
81
  export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
@@ -135,6 +135,26 @@ function renderBasicTimeline() {
135
135
  return { host, root };
136
136
  }
137
137
 
138
+ function renderSharedAutomationTimeline(selectedElementId?: string) {
139
+ const host = createSizedTimelineHost(720);
140
+ const automation = JSON.stringify({
141
+ version: 1,
142
+ lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
143
+ });
144
+ usePlayerStore.setState({
145
+ duration: 8,
146
+ timelineReady: true,
147
+ ...(selectedElementId ? { selectedElementId } : {}),
148
+ elements: [
149
+ { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
150
+ { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
151
+ ],
152
+ });
153
+ const root = createRoot(host);
154
+ act(() => root.render(React.createElement(Timeline)));
155
+ return { host, root };
156
+ }
157
+
138
158
  describe("Timeline provider boundary", () => {
139
159
  it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => {
140
160
  usePlayerStore.setState({
@@ -621,21 +641,7 @@ describe("Timeline provider boundary", () => {
621
641
  // clip left the row's state depending on the selection, and a collapse that
622
642
  // only dropped the active clip left the row stuck open.
623
643
  it("expands and collapses every clip on a shared track together", () => {
624
- const host = createSizedTimelineHost(720);
625
- const automation = JSON.stringify({
626
- version: 1,
627
- lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
628
- });
629
- usePlayerStore.setState({
630
- duration: 8,
631
- timelineReady: true,
632
- elements: [
633
- { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
634
- { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
635
- ],
636
- });
637
- const root = createRoot(host);
638
- act(() => root.render(React.createElement(Timeline)));
644
+ const { host, root } = renderSharedAutomationTimeline();
639
645
 
640
646
  const row = host.querySelector<HTMLElement>('[data-el-id="narration-1"]')?.parentElement
641
647
  ?.parentElement;
@@ -668,22 +674,7 @@ describe("Timeline provider boundary", () => {
668
674
  // a lane to select its clip therefore made the handles vanish under the
669
675
  // pointer, which is the one gesture the read-only lane exists to support.
670
676
  it("keeps the automation lanes mounted when the selection moves along the row", () => {
671
- const host = createSizedTimelineHost(720);
672
- const automation = JSON.stringify({
673
- version: 1,
674
- lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
675
- });
676
- usePlayerStore.setState({
677
- duration: 8,
678
- timelineReady: true,
679
- selectedElementId: "narration-2",
680
- elements: [
681
- { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation },
682
- { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation },
683
- ],
684
- });
685
- const root = createRoot(host);
686
- act(() => root.render(React.createElement(Timeline)));
677
+ const { host, root } = renderSharedAutomationTimeline("narration-2");
687
678
  act(() => host.querySelector<HTMLButtonElement>('button[aria-label$=" lanes"]')?.click());
688
679
 
689
680
  const before = [...host.querySelectorAll(".hf-automation-lane")];