@hyperframes/studio 0.8.57 → 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 (68) hide show
  1. package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  2. package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  3. package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  4. package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  5. package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  6. package/dist/index.d.ts +577 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +22537 -21600
  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/MotionPathOverlay.test.tsx +0 -1
  14. package/src/components/editor/audioInspectorInput.test.ts +55 -0
  15. package/src/components/editor/audioInspectorInput.ts +55 -0
  16. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
  17. package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
  18. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
  19. package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
  20. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
  21. package/src/components/editor/useColorGradingController.ts +2 -6
  22. package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
  23. package/src/components/nle/AudioMeterStrip.tsx +213 -0
  24. package/src/components/nle/NLEContext.tsx +8 -2
  25. package/src/components/nle/NLEPreview.test.ts +1 -0
  26. package/src/components/nle/TimelinePane.tsx +29 -23
  27. package/src/components/nle/useCompositionStack.ts +1 -1
  28. package/src/contexts/TimelineEditContext.tsx +4 -0
  29. package/src/hooks/useCompositionDimensions.test.tsx +0 -1
  30. package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
  31. package/src/hooks/useSdkSession.ts +31 -4
  32. package/src/hooks/useTimelineAssetDropOps.ts +9 -1
  33. package/src/index.ts +25 -1
  34. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  35. package/src/player/components/Player.test.ts +34 -0
  36. package/src/player/components/Timeline.test.ts +22 -31
  37. package/src/player/components/Timeline.tsx +34 -7
  38. package/src/player/components/TimelineCanvas.tsx +10 -56
  39. package/src/player/components/TimelineClip.tsx +14 -1
  40. package/src/player/components/TimelineClipFades.test.tsx +171 -0
  41. package/src/player/components/TimelineClipFades.tsx +302 -0
  42. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  43. package/src/player/components/TimelineOverlays.tsx +164 -165
  44. package/src/player/components/TimelineParts.test.tsx +101 -0
  45. package/src/player/components/TimelineParts.tsx +74 -0
  46. package/src/player/components/TimelinePartsParity.test.tsx +58 -0
  47. package/src/player/components/TimelineProvider.tsx +25 -0
  48. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  49. package/src/player/components/TimelineRulerPart.tsx +22 -0
  50. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  51. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  52. package/src/player/components/timelineProviderStateBuilders.ts +20 -0
  53. package/src/player/components/useTimelineProviderState.tsx +19 -12
  54. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  55. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  56. package/src/player/hooks/usePlayerHandle.ts +94 -0
  57. package/src/player/index.ts +7 -0
  58. package/src/player/lib/timelineElementHelpers.ts +20 -0
  59. package/src/player/store/timelineElement.ts +5 -0
  60. package/src/styles/theme.css +4 -0
  61. package/src/utils/audioMeterMath.test.ts +44 -0
  62. package/src/utils/audioMeterMath.ts +79 -0
  63. package/src/utils/audioMeterVisibility.ts +14 -0
  64. package/src/utils/studioHelpers.ts +21 -0
  65. package/src/utils/studioUiPreferences.test.ts +10 -0
  66. package/src/utils/studioUiPreferences.ts +5 -0
  67. package/src/utils/timelineAssetDrop.test.ts +32 -0
  68. 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
+ }
@@ -1,7 +1,7 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
3
- import { useTimelinePlayer, usePlayerStore } from "../../player";
4
- import type { TimelineElement } from "../../player";
3
+ import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
4
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
5
5
  import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
6
6
  import type { CompositionLevel } from "./CompositionBreadcrumb";
7
7
  import { useCompositionStack } from "./useCompositionStack";
@@ -18,6 +18,8 @@ export interface NLEContextValue {
18
18
  projectId: string;
19
19
  // player (from useTimelinePlayer — single instance for the whole shell)
20
20
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
21
+ play: () => void;
22
+ pause: () => void;
21
23
  togglePlay: () => void;
22
24
  seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
23
25
  refreshPlayer: () => void;
@@ -84,6 +86,8 @@ export function NLEProvider({
84
86
  const containerRef = useRef<HTMLDivElement>(null);
85
87
  const {
86
88
  iframeRef,
89
+ play,
90
+ pause,
87
91
  togglePlay,
88
92
  seek,
89
93
  onIframeLoad: baseOnIframeLoad,
@@ -298,6 +302,8 @@ export function NLEProvider({
298
302
  const value: NLEContextValue = {
299
303
  projectId,
300
304
  iframeRef,
305
+ play,
306
+ pause,
301
307
  containerRef,
302
308
  togglePlay,
303
309
  seek,
@@ -261,6 +261,7 @@ describe("NLEPreview", () => {
261
261
  expect(players[0].style.clipPath).toBe("");
262
262
  expect(players[1].style.clipPath).toBe("inset(100%)");
263
263
  expect(players[1].style.visibility).toBe("hidden");
264
+ expect(players[1].style.pointerEvents).toBe("none");
264
265
  view.cleanup();
265
266
  });
266
267
 
@@ -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 && (
@@ -1,7 +1,7 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  // Composition drill-down stack management for NLEContext/EditorShell
3
3
  import { useState, useCallback, useRef, useEffect } from "react";
4
- import { usePlayerStore } from "../../player";
4
+ import { usePlayerStore } from "../../player/store/playerStore";
5
5
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
6
  import { encodePreviewPath } from "../../player/components/thumbnailUtils";
7
7
 
@@ -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,
@@ -71,7 +71,6 @@ it("re-reads the size from the promoted iframe's document", () => {
71
71
  act(() => root.render(<Harness />));
72
72
  expect(host.textContent).toBe('{"width":1920,"height":1080}');
73
73
  act(() => {
74
- ref.current = b;
75
74
  usePreviewIframeStore.getState().setIframe(b);
76
75
  });
77
76
  expect(host.textContent).toBe('{"width":1080,"height":1920}');
@@ -267,6 +267,34 @@ describe("useSdkSession unavailable telemetry", () => {
267
267
  await act(async () => root.unmount());
268
268
  });
269
269
 
270
+ // A fetch that REJECTS produces no response at all. It used to escape this
271
+ // function and be caught by the effect's outer `.catch`, which reported it as
272
+ // `stage: "open"` — a label that means openComposition threw. Every `stage:
273
+ // open` event on 0.8.56/0.8.57 carries a fetch-rejection message, so the
274
+ // largest failure class was a network problem reported as a parser one and
275
+ // was unaddressable in that bucket.
276
+ it("reports a rejected request as a read failure, not an open failure", async () => {
277
+ vi.stubGlobal(
278
+ "fetch",
279
+ vi.fn(async () => {
280
+ throw new TypeError("Failed to fetch");
281
+ }),
282
+ );
283
+ const root = createRoot(document.createElement("div"));
284
+ await act(async () => root.render(<Probe projectId="project-a" />));
285
+ await flushAsyncEffects();
286
+
287
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
288
+ stage: "read",
289
+ reason: "network",
290
+ });
291
+ expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", {
292
+ stage: "open",
293
+ error: expect.anything(),
294
+ });
295
+ await act(async () => root.unmount());
296
+ });
297
+
270
298
  it("separates an unexpected response shape from a failed request", async () => {
271
299
  vi.stubGlobal(
272
300
  "fetch",