@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.
- package/dist/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +577 -8
- package/dist/index.html +2 -2
- package/dist/index.js +22537 -21600
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.tsx +19 -0
- package/src/components/editor/MotionPathOverlay.test.tsx +0 -1
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/nle/AudioMeterStrip.test.tsx +124 -0
- package/src/components/nle/AudioMeterStrip.tsx +213 -0
- package/src/components/nle/NLEContext.tsx +8 -2
- package/src/components/nle/NLEPreview.test.ts +1 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +28 -0
- package/src/hooks/useSdkSession.ts +31 -4
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +25 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +34 -0
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineCanvas.tsx +10 -56
- package/src/player/components/TimelineClip.tsx +14 -1
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/index.ts +7 -0
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/theme.css +4 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- 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
|
|
4
|
-
import type
|
|
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
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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",
|