@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.
- package/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
- package/dist/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
- package/dist/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
- package/dist/index.d.ts +548 -8
- package/dist/index.html +2 -2
- package/dist/index.js +21596 -20725
- 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/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/TimelinePane.tsx +29 -23
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -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/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/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- 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
|
+
}
|
|
@@ -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 && (
|
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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")];
|