@rightkit/video-ui 0.0.0-stage → 0.1.0
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/CHANGELOG.md +5 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +47 -2
- package/dist/appName.d.ts +2 -0
- package/dist/appName.js +8 -0
- package/dist/components/DesktopWorkspace.d.ts +4 -0
- package/dist/components/DesktopWorkspace.js +527 -0
- package/dist/components/Player.d.ts +49 -0
- package/dist/components/Player.js +335 -0
- package/dist/components/RangePreview.d.ts +9 -0
- package/dist/components/RangePreview.js +22 -0
- package/dist/components/Workspace.css +15 -0
- package/dist/demo/DemoSettings.d.ts +1 -0
- package/dist/demo/DemoSettings.js +43 -0
- package/dist/demo/bindings.d.ts +121 -0
- package/dist/demo/bindings.js +57 -0
- package/dist/demo/demo.css +124 -0
- package/dist/demo/useDemo.d.ts +8 -0
- package/dist/demo/useDemo.js +37 -0
- package/dist/editor/CaptionInspector.d.ts +8 -0
- package/dist/editor/CaptionInspector.js +33 -0
- package/dist/editor/ChangesPanel.d.ts +13 -0
- package/dist/editor/ChangesPanel.js +87 -0
- package/dist/editor/ColourInspector.d.ts +5 -0
- package/dist/editor/ColourInspector.js +341 -0
- package/dist/editor/Inspector.d.ts +15 -0
- package/dist/editor/Inspector.js +202 -0
- package/dist/editor/Timeline.d.ts +39 -0
- package/dist/editor/Timeline.js +506 -0
- package/dist/editor/TimelineEditorView.d.ts +9 -0
- package/dist/editor/TimelineEditorView.js +237 -0
- package/dist/editor/TranscriptPanel.d.ts +20 -0
- package/dist/editor/TranscriptPanel.js +180 -0
- package/dist/editor/actions.d.ts +63 -0
- package/dist/editor/actions.js +124 -0
- package/dist/editor/adapter.d.ts +76 -0
- package/dist/editor/adapter.js +181 -0
- package/dist/editor/capabilities.d.ts +37 -0
- package/dist/editor/capabilities.js +33 -0
- package/dist/editor/gesture.d.ts +29 -0
- package/dist/editor/gesture.js +119 -0
- package/dist/editor/index.d.ts +10 -0
- package/dist/editor/index.js +7 -0
- package/dist/editor/keys.d.ts +15 -0
- package/dist/editor/keys.js +42 -0
- package/dist/editor/model.d.ts +88 -0
- package/dist/editor/model.js +87 -0
- package/dist/editor/transcript.d.ts +27 -0
- package/dist/editor/transcript.js +59 -0
- package/dist/editor/vendor/hyperframes/PlayheadIndicator.d.ts +9 -0
- package/dist/editor/vendor/hyperframes/PlayheadIndicator.js +38 -0
- package/dist/editor/vendor/hyperframes/audioWaveformPeaks.d.ts +7 -0
- package/dist/editor/vendor/hyperframes/audioWaveformPeaks.js +47 -0
- package/dist/editor/vendor/hyperframes/rulerGeometry.d.ts +13 -0
- package/dist/editor/vendor/hyperframes/rulerGeometry.js +133 -0
- package/dist/editor/vendor/hyperframes/useInspectorGestureTransaction.d.ts +33 -0
- package/dist/editor/vendor/hyperframes/useInspectorGestureTransaction.js +126 -0
- package/dist/env.d.ts +66 -0
- package/dist/hooks/useEngine.d.ts +10 -0
- package/dist/hooks/useEngine.js +68 -0
- package/dist/hooks/useHdrToSdr.d.ts +11 -0
- package/dist/hooks/useHdrToSdr.js +19 -0
- package/dist/hooks/useProgress.d.ts +10 -0
- package/dist/hooks/useProgress.js +32 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +57 -0
- package/dist/legal/legalConfig.d.ts +38 -0
- package/dist/legal/legalConfig.js +77 -0
- package/dist/lib/assemble.d.ts +100 -0
- package/dist/lib/assemble.js +72 -0
- package/dist/lib/asset.d.ts +2 -0
- package/dist/lib/asset.js +3 -0
- package/dist/lib/dialog.d.ts +4 -0
- package/dist/lib/dialog.js +44 -0
- package/dist/lib/engine.d.ts +44 -0
- package/dist/lib/engine.js +51 -0
- package/dist/lib/genright.d.ts +90 -0
- package/dist/lib/genright.js +160 -0
- package/dist/lib/media.d.ts +1045 -0
- package/dist/lib/media.js +171 -0
- package/dist/lib/mediaAdapters.d.ts +18 -0
- package/dist/lib/mediaAdapters.js +46 -0
- package/dist/lib/mic.d.ts +38 -0
- package/dist/lib/mic.js +14 -0
- package/dist/lib/models.d.ts +53 -0
- package/dist/lib/models.js +72 -0
- package/dist/lib/modelsBackend.d.ts +3 -0
- package/dist/lib/modelsBackend.js +68 -0
- package/dist/lib/progress.d.ts +77 -0
- package/dist/lib/progress.js +192 -0
- package/dist/lib/project.d.ts +138 -0
- package/dist/lib/project.js +48 -0
- package/dist/lib/services.d.ts +24 -0
- package/dist/lib/services.js +38 -0
- package/dist/lib/settingsBackend.d.ts +2 -0
- package/dist/lib/settingsBackend.js +57 -0
- package/dist/lib/shell.d.ts +171 -0
- package/dist/lib/shell.js +32 -0
- package/dist/styles.css +16 -0
- package/dist/ui/icons.d.ts +7 -0
- package/dist/ui/icons.js +62 -0
- package/dist/ui/index.d.ts +45 -0
- package/dist/ui/index.js +43 -0
- package/dist/ui/ui.css +104 -0
- package/dist/views/Assets.css +99 -0
- package/dist/views/AssetsView.d.ts +6 -0
- package/dist/views/AssetsView.js +371 -0
- package/dist/views/AutoEditView.d.ts +16 -0
- package/dist/views/AutoEditView.js +452 -0
- package/dist/views/BatchDelivery.d.ts +3 -0
- package/dist/views/BatchDelivery.js +331 -0
- package/dist/views/BundleExport.d.ts +3 -0
- package/dist/views/BundleExport.js +81 -0
- package/dist/views/Exports.css +117 -0
- package/dist/views/ExportsView.d.ts +12 -0
- package/dist/views/ExportsView.js +354 -0
- package/dist/views/FinishedEdit.css +546 -0
- package/dist/views/Home.css +97 -0
- package/dist/views/Home.d.ts +14 -0
- package/dist/views/Home.js +192 -0
- package/dist/views/Media.css +65 -0
- package/dist/views/OnboardingGate.d.ts +10 -0
- package/dist/views/OnboardingGate.js +54 -0
- package/dist/views/Phrases.css +45 -0
- package/dist/views/PhrasesView.d.ts +4 -0
- package/dist/views/PhrasesView.js +104 -0
- package/dist/views/ProgressView.d.ts +19 -0
- package/dist/views/ProgressView.js +36 -0
- package/dist/views/Prompt.css +84 -0
- package/dist/views/PromptPanel.d.ts +8 -0
- package/dist/views/PromptPanel.js +169 -0
- package/dist/views/Recipe.css +84 -0
- package/dist/views/RecipeLibrary.d.ts +7 -0
- package/dist/views/RecipeLibrary.js +104 -0
- package/dist/views/Retention.css +49 -0
- package/dist/views/RetentionView.d.ts +7 -0
- package/dist/views/RetentionView.js +145 -0
- package/dist/views/SettingsView.d.ts +9 -0
- package/dist/views/SettingsView.js +235 -0
- package/dist/views/Shorts.css +112 -0
- package/dist/views/ShortsView.d.ts +4 -0
- package/dist/views/ShortsView.js +205 -0
- package/dist/views/SourcesView.d.ts +6 -0
- package/dist/views/SourcesView.js +417 -0
- package/dist/views/TimelineView.d.ts +20 -0
- package/dist/views/TimelineView.js +12 -0
- package/package.json +70 -3
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Colour section of the clip Inspector. Controls edit a local draft and call the
|
|
4
|
+
* engine on pointer-up, key-up or blur; discrete edits (add, remove, reset,
|
|
5
|
+
* select) commit at once. Scopes are the luma waveform and vectorscope at `atMs`.
|
|
6
|
+
*/
|
|
7
|
+
import { Button, Input, SegmentedControl, Select } from "@rightkit/react-shell/react";
|
|
8
|
+
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { assetUrl } from "../lib/asset.js";
|
|
10
|
+
import { errorMessage } from "../lib/engine.js";
|
|
11
|
+
import { HDR_UNAVAILABLE_NOTICE } from "../hooks/useHdrToSdr.js";
|
|
12
|
+
import { colourGet, colourScopes, colourSet, playbackRefreshFinishing, } from "../lib/media.js";
|
|
13
|
+
const MAX_POINTS = 16;
|
|
14
|
+
const MAX_HSL = 6;
|
|
15
|
+
/** Minimum x gap kept between neighbouring curve points. */
|
|
16
|
+
const EPS = 0.001;
|
|
17
|
+
const KEY_STEP = 0.01;
|
|
18
|
+
const NEUTRAL_GRADE = {};
|
|
19
|
+
const NEUTRAL_WHEELS = { lift: [0, 0, 0], gamma: [1, 1, 1], gain: [1, 1, 1] };
|
|
20
|
+
const WHEELS = [
|
|
21
|
+
{ key: "lift", label: "Lift", min: -0.5, max: 0.5 },
|
|
22
|
+
{ key: "gamma", label: "Gamma", min: 0.2, max: 5 },
|
|
23
|
+
{ key: "gain", label: "Gain", min: 0, max: 4 },
|
|
24
|
+
];
|
|
25
|
+
const CHANNELS = [
|
|
26
|
+
{ key: "r", label: "R" },
|
|
27
|
+
{ key: "g", label: "G" },
|
|
28
|
+
{ key: "b", label: "B" },
|
|
29
|
+
];
|
|
30
|
+
const CURVE_TABS = [
|
|
31
|
+
{ value: "master", label: "Master" },
|
|
32
|
+
{ value: "r", label: "R" },
|
|
33
|
+
{ value: "g", label: "G" },
|
|
34
|
+
{ value: "b", label: "B" },
|
|
35
|
+
];
|
|
36
|
+
const CURVE_NAMES = { master: "Master", r: "Red", g: "Green", b: "Blue" };
|
|
37
|
+
const HSL_RANGES = [
|
|
38
|
+
{ value: "reds", label: "Reds" },
|
|
39
|
+
{ value: "yellows", label: "Yellows" },
|
|
40
|
+
{ value: "greens", label: "Greens" },
|
|
41
|
+
{ value: "cyans", label: "Cyans" },
|
|
42
|
+
{ value: "blues", label: "Blues" },
|
|
43
|
+
{ value: "magentas", label: "Magentas" },
|
|
44
|
+
];
|
|
45
|
+
const SCOPE_TABS = [
|
|
46
|
+
{ value: "graded", label: "Graded" },
|
|
47
|
+
{ value: "source", label: "Source" },
|
|
48
|
+
];
|
|
49
|
+
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
50
|
+
const mean = (rgb) => (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
51
|
+
function withChannel(rgb, i, v) {
|
|
52
|
+
return [i === 0 ? v : rgb[0], i === 1 ? v : rgb[1], i === 2 ? v : rgb[2]];
|
|
53
|
+
}
|
|
54
|
+
function shiftRgb(rgb, d, lo, hi) {
|
|
55
|
+
return [clamp(rgb[0] + d, lo, hi), clamp(rgb[1] + d, lo, hi), clamp(rgb[2] + d, lo, hi)];
|
|
56
|
+
}
|
|
57
|
+
/** The grade persisted for this clip (or the project); null when none is set. */
|
|
58
|
+
function storedOf(s, sourceId, toProject) {
|
|
59
|
+
return toProject ? s.grade : (s.by_source[sourceId] ?? null);
|
|
60
|
+
}
|
|
61
|
+
function writeWheel(g, k, rgb) {
|
|
62
|
+
const wheels = { ...(g.wheels ?? NEUTRAL_WHEELS) };
|
|
63
|
+
wheels[k] = rgb;
|
|
64
|
+
return { ...g, wheels };
|
|
65
|
+
}
|
|
66
|
+
function curveOf(g, ch) {
|
|
67
|
+
return g.curves?.[ch] ?? [];
|
|
68
|
+
}
|
|
69
|
+
function withCurve(g, ch, pts) {
|
|
70
|
+
const curves = { ...g.curves };
|
|
71
|
+
if (pts.length === 0)
|
|
72
|
+
delete curves[ch];
|
|
73
|
+
else
|
|
74
|
+
curves[ch] = pts;
|
|
75
|
+
return { ...g, curves };
|
|
76
|
+
}
|
|
77
|
+
/** Allowed x range for point `i`, keeping it strictly between its neighbours. */
|
|
78
|
+
function curveBounds(pts, i) {
|
|
79
|
+
const prev = pts[i - 1];
|
|
80
|
+
const next = pts[i + 1];
|
|
81
|
+
return [prev ? prev.x + EPS : 0, next ? next.x - EPS : 1];
|
|
82
|
+
}
|
|
83
|
+
/** Client coordinates to curve space: x 0..1 left to right, y 0..1 bottom to top. */
|
|
84
|
+
function curvePoint(box, clientX, clientY) {
|
|
85
|
+
const r = box.getBoundingClientRect();
|
|
86
|
+
if (r.width <= 0 || r.height <= 0)
|
|
87
|
+
return { x: 0, y: 0 };
|
|
88
|
+
return {
|
|
89
|
+
x: clamp((clientX - r.left) / r.width, 0, 1),
|
|
90
|
+
y: clamp(1 - (clientY - r.top) / r.height, 0, 1),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Applies a successful `colour.set` reply to the persisted table. */
|
|
94
|
+
function applySaved(prev, sourceId, toProject, r) {
|
|
95
|
+
if (toProject)
|
|
96
|
+
return { ...prev, grade: r.grade };
|
|
97
|
+
const by_source = { ...prev.by_source };
|
|
98
|
+
if (r.grade)
|
|
99
|
+
by_source[sourceId] = r.grade;
|
|
100
|
+
else
|
|
101
|
+
delete by_source[sourceId];
|
|
102
|
+
return { ...prev, by_source };
|
|
103
|
+
}
|
|
104
|
+
function SliderRow({ label, name, testId, min, max, step, value, digits = 2, onInput, onCommit, }) {
|
|
105
|
+
return (_jsxs("label", { className: "colour-row", children: [_jsx("span", { className: "colour-row__label", children: label }), _jsx(Input, { type: "range", className: "colour-row__range", "aria-label": name, "data-testid": testId, min: min, max: max, step: step, value: value, onChange: (e) => onInput(Number(e.target.value)), onPointerUp: onCommit, onKeyUp: onCommit, onBlur: onCommit }), _jsx("output", { className: "colour-row__value", children: value.toFixed(digits) })] }));
|
|
106
|
+
}
|
|
107
|
+
export function ColourInspector({ dir, sourceId, atMs }) {
|
|
108
|
+
const [state, setState] = useState(null);
|
|
109
|
+
const [project, setProject] = useState(false);
|
|
110
|
+
const [draft, setDraft] = useState(NEUTRAL_GRADE);
|
|
111
|
+
const [channel, setChannel] = useState("master");
|
|
112
|
+
const [graded, setGraded] = useState(true);
|
|
113
|
+
const [scopes, setScopes] = useState(null);
|
|
114
|
+
const [scopeTick, setScopeTick] = useState(0);
|
|
115
|
+
const [actionError, setActionError] = useState(null);
|
|
116
|
+
const [scopeError, setScopeError] = useState(null);
|
|
117
|
+
/** Synchronous copy of `draft` so pointer and key handlers never read a stale grade. */
|
|
118
|
+
const draftRef = useRef(NEUTRAL_GRADE);
|
|
119
|
+
/** JSON of the grade persisted for the current edit target; a commit must differ from it. */
|
|
120
|
+
const savedKey = useRef("null");
|
|
121
|
+
/** Bumped per commit and per mode switch; only the newest reply updates draft bookkeeping. */
|
|
122
|
+
const seqRef = useRef(0);
|
|
123
|
+
/** Index of the curve point being dragged, if any. */
|
|
124
|
+
const dragRef = useRef(null);
|
|
125
|
+
// Load the grade table when the clip's source or project changes.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
let cancelled = false;
|
|
128
|
+
seqRef.current += 1;
|
|
129
|
+
setState(null);
|
|
130
|
+
setActionError(null);
|
|
131
|
+
colourGet({ dir })
|
|
132
|
+
.then((r) => {
|
|
133
|
+
if (cancelled)
|
|
134
|
+
return;
|
|
135
|
+
const editable = storedOf(r, sourceId, false) ?? NEUTRAL_GRADE;
|
|
136
|
+
draftRef.current = editable;
|
|
137
|
+
setDraft(editable);
|
|
138
|
+
savedKey.current = JSON.stringify(storedOf(r, sourceId, false));
|
|
139
|
+
setProject(false);
|
|
140
|
+
setState(r);
|
|
141
|
+
})
|
|
142
|
+
.catch((e) => {
|
|
143
|
+
if (!cancelled)
|
|
144
|
+
setActionError(errorMessage(e));
|
|
145
|
+
});
|
|
146
|
+
return () => {
|
|
147
|
+
cancelled = true;
|
|
148
|
+
};
|
|
149
|
+
}, [dir, sourceId]);
|
|
150
|
+
// Scopes for the clip frame at `atMs`; debounced so trims and scrubs do not flood the engine.
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
let cancelled = false;
|
|
153
|
+
const timer = window.setTimeout(() => {
|
|
154
|
+
colourScopes({ dir, source: sourceId, at_ms: atMs, graded })
|
|
155
|
+
.then((r) => {
|
|
156
|
+
if (cancelled)
|
|
157
|
+
return;
|
|
158
|
+
setScopes(r);
|
|
159
|
+
setScopeError(null);
|
|
160
|
+
})
|
|
161
|
+
.catch((e) => {
|
|
162
|
+
if (!cancelled)
|
|
163
|
+
setScopeError(errorMessage(e));
|
|
164
|
+
});
|
|
165
|
+
}, 250);
|
|
166
|
+
return () => {
|
|
167
|
+
cancelled = true;
|
|
168
|
+
window.clearTimeout(timer);
|
|
169
|
+
};
|
|
170
|
+
}, [dir, sourceId, atMs, graded, scopeTick]);
|
|
171
|
+
const setGrade = (next) => {
|
|
172
|
+
draftRef.current = next;
|
|
173
|
+
setDraft(next);
|
|
174
|
+
};
|
|
175
|
+
const send = (grade, toProject) => {
|
|
176
|
+
const key = JSON.stringify(grade);
|
|
177
|
+
if (key === savedKey.current)
|
|
178
|
+
return;
|
|
179
|
+
const seq = ++seqRef.current;
|
|
180
|
+
setActionError(null);
|
|
181
|
+
colourSet({ dir, source: toProject ? undefined : sourceId, grade })
|
|
182
|
+
.then((r) => {
|
|
183
|
+
// Every reply is the persisted truth for its own target, so it always lands.
|
|
184
|
+
setState((prev) => (prev ? applySaved(prev, sourceId, toProject, r) : prev));
|
|
185
|
+
// The preview decodes through the saved grade; rebuild it so it shows this one.
|
|
186
|
+
void playbackRefreshFinishing(dir).catch(() => undefined);
|
|
187
|
+
if (seq !== seqRef.current)
|
|
188
|
+
return;
|
|
189
|
+
savedKey.current = JSON.stringify(r.grade);
|
|
190
|
+
if (!toProject && r.grade === null)
|
|
191
|
+
setGrade(NEUTRAL_GRADE);
|
|
192
|
+
setScopeTick((t) => t + 1);
|
|
193
|
+
})
|
|
194
|
+
.catch((e) => {
|
|
195
|
+
if (seq === seqRef.current)
|
|
196
|
+
setActionError(errorMessage(e));
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
const commitDraft = () => send(draftRef.current, project);
|
|
200
|
+
const commitNow = (next) => {
|
|
201
|
+
setGrade(next);
|
|
202
|
+
send(next, project);
|
|
203
|
+
};
|
|
204
|
+
const setMode = (toProject) => {
|
|
205
|
+
if (!state || toProject === project)
|
|
206
|
+
return;
|
|
207
|
+
seqRef.current += 1;
|
|
208
|
+
setActionError(null);
|
|
209
|
+
setProject(toProject);
|
|
210
|
+
setGrade(storedOf(state, sourceId, toProject) ?? NEUTRAL_GRADE);
|
|
211
|
+
savedKey.current = JSON.stringify(storedOf(state, sourceId, toProject));
|
|
212
|
+
};
|
|
213
|
+
// Wheels: the master moves all three channels by the same delta.
|
|
214
|
+
const wheelInput = (k, i, v) => {
|
|
215
|
+
const g = draftRef.current;
|
|
216
|
+
setGrade(writeWheel(g, k, withChannel((g.wheels ?? NEUTRAL_WHEELS)[k], i, v)));
|
|
217
|
+
};
|
|
218
|
+
const wheelMasterInput = (def, v) => {
|
|
219
|
+
const g = draftRef.current;
|
|
220
|
+
const rgb = (g.wheels ?? NEUTRAL_WHEELS)[def.key];
|
|
221
|
+
setGrade(writeWheel(g, def.key, shiftRgb(rgb, v - mean(rgb), def.min, def.max)));
|
|
222
|
+
};
|
|
223
|
+
const wheelReset = (k) => commitNow(writeWheel(draftRef.current, k, NEUTRAL_WHEELS[k]));
|
|
224
|
+
// Curves: click empty space to add, drag to move, double-click / Delete to remove.
|
|
225
|
+
const onCurveDown = (e) => {
|
|
226
|
+
const g = draftRef.current;
|
|
227
|
+
const pts = curveOf(g, channel);
|
|
228
|
+
if (pts.length >= MAX_POINTS)
|
|
229
|
+
return;
|
|
230
|
+
const p = curvePoint(e.currentTarget, e.clientX, e.clientY);
|
|
231
|
+
const next = [...pts, p].sort((a, b) => a.x - b.x);
|
|
232
|
+
dragRef.current = next.indexOf(p);
|
|
233
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
234
|
+
setGrade(withCurve(g, channel, next));
|
|
235
|
+
};
|
|
236
|
+
const onCurveMove = (e) => {
|
|
237
|
+
const i = dragRef.current;
|
|
238
|
+
if (i === null)
|
|
239
|
+
return;
|
|
240
|
+
const g = draftRef.current;
|
|
241
|
+
const pts = curveOf(g, channel);
|
|
242
|
+
const p = curvePoint(e.currentTarget, e.clientX, e.clientY);
|
|
243
|
+
const [lo, hi] = curveBounds(pts, i);
|
|
244
|
+
const moved = pts.map((q, j) => (j === i ? { x: clamp(p.x, lo, hi), y: p.y } : q));
|
|
245
|
+
setGrade(withCurve(g, channel, moved));
|
|
246
|
+
};
|
|
247
|
+
const onCurveUp = () => {
|
|
248
|
+
if (dragRef.current === null)
|
|
249
|
+
return;
|
|
250
|
+
dragRef.current = null;
|
|
251
|
+
commitDraft();
|
|
252
|
+
};
|
|
253
|
+
const removePoint = (i) => {
|
|
254
|
+
const g = draftRef.current;
|
|
255
|
+
commitNow(withCurve(g, channel, curveOf(g, channel).filter((_, j) => j !== i)));
|
|
256
|
+
};
|
|
257
|
+
const addPoint = () => {
|
|
258
|
+
const g = draftRef.current;
|
|
259
|
+
const pts = curveOf(g, channel);
|
|
260
|
+
if (pts.length >= MAX_POINTS)
|
|
261
|
+
return;
|
|
262
|
+
// Place the point on the identity diagonal at the middle of the widest gap.
|
|
263
|
+
const xs = [0, ...pts.map((p) => p.x), 1];
|
|
264
|
+
let x = 0.5;
|
|
265
|
+
let widest = -1;
|
|
266
|
+
xs.forEach((a, k) => {
|
|
267
|
+
const b = xs[k + 1];
|
|
268
|
+
if (b === undefined || b - a <= widest)
|
|
269
|
+
return;
|
|
270
|
+
widest = b - a;
|
|
271
|
+
x = (a + b) / 2;
|
|
272
|
+
});
|
|
273
|
+
commitNow(withCurve(g, channel, [...pts, { x, y: x }].sort((a, b) => a.x - b.x)));
|
|
274
|
+
};
|
|
275
|
+
const onPointKey = (i, e) => {
|
|
276
|
+
if (e.key === "Delete" || e.key === "Backspace") {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
removePoint(i);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const dx = e.key === "ArrowLeft" ? -KEY_STEP : e.key === "ArrowRight" ? KEY_STEP : 0;
|
|
282
|
+
const dy = e.key === "ArrowDown" ? -KEY_STEP : e.key === "ArrowUp" ? KEY_STEP : 0;
|
|
283
|
+
if (dx === 0 && dy === 0)
|
|
284
|
+
return;
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
const g = draftRef.current;
|
|
287
|
+
const pts = curveOf(g, channel);
|
|
288
|
+
const at = pts[i];
|
|
289
|
+
if (!at)
|
|
290
|
+
return;
|
|
291
|
+
const [lo, hi] = curveBounds(pts, i);
|
|
292
|
+
const moved = pts.map((p, j) => j === i ? { x: clamp(at.x + dx, lo, hi), y: clamp(at.y + dy, 0, 1) } : p);
|
|
293
|
+
setGrade(withCurve(g, channel, moved));
|
|
294
|
+
};
|
|
295
|
+
// HSL secondaries.
|
|
296
|
+
const patchHsl = (i, patch, commit) => {
|
|
297
|
+
const g = draftRef.current;
|
|
298
|
+
const list = (g.hsl ?? []).map((h, j) => (j === i ? { ...h, ...patch } : h));
|
|
299
|
+
if (commit)
|
|
300
|
+
commitNow({ ...g, hsl: list });
|
|
301
|
+
else
|
|
302
|
+
setGrade({ ...g, hsl: list });
|
|
303
|
+
};
|
|
304
|
+
const addHsl = () => {
|
|
305
|
+
const g = draftRef.current;
|
|
306
|
+
const list = g.hsl ?? [];
|
|
307
|
+
if (list.length >= MAX_HSL)
|
|
308
|
+
return;
|
|
309
|
+
const used = new Set(list.map((h) => h.range));
|
|
310
|
+
const range = HSL_RANGES.find((r) => !used.has(r.value))?.value ?? "reds";
|
|
311
|
+
commitNow({ ...g, hsl: [...list, { range, hue_shift: 0, saturation: 0, lightness: 0, strength: 1 }] });
|
|
312
|
+
};
|
|
313
|
+
const removeHsl = (i) => {
|
|
314
|
+
const g = draftRef.current;
|
|
315
|
+
commitNow({ ...g, hsl: (g.hsl ?? []).filter((_, j) => j !== i) });
|
|
316
|
+
};
|
|
317
|
+
const stored = state ? storedOf(state, sourceId, false) : null;
|
|
318
|
+
const hasOwn = stored !== null;
|
|
319
|
+
const error = actionError ?? scopeError;
|
|
320
|
+
const wheels = draft.wheels ?? NEUTRAL_WHEELS;
|
|
321
|
+
const sat = draft.saturation ?? 1;
|
|
322
|
+
const hsl = draft.hsl ?? [];
|
|
323
|
+
const points = curveOf(draft, channel);
|
|
324
|
+
const curvePolyline = points.map((p) => `${p.x * 100},${(1 - p.y) * 100}`).join(" ");
|
|
325
|
+
return (_jsxs("section", { className: "colour-section", "aria-label": "Colour", "data-testid": "colour-section", children: [_jsx("h3", { className: "colour-section__title", children: "Colour" }), state?.capabilities?.hdr_to_sdr === false && (_jsx("p", { className: "cr-muted colour-status", "data-testid": "colour-hdr-notice", children: HDR_UNAVAILABLE_NOTICE })), state === null && actionError === null && _jsx("p", { className: "cr-muted colour-status", children: "Loading colour\u2026" }), state !== null && (_jsxs(_Fragment, { children: [_jsx("p", { className: "cr-muted colour-source", children: project
|
|
326
|
+
? "Editing the project grade."
|
|
327
|
+
: hasOwn
|
|
328
|
+
? "This clip uses its own grade."
|
|
329
|
+
: "This clip uses the project grade." }), _jsxs("div", { className: "colour-actions", children: [hasOwn && !project && (_jsx(Button, { size: "sm", variant: "secondary", "data-testid": "colour-use-project", onClick: () => send(null, false), children: "Use project grade" })), _jsx(Button, { size: "sm", variant: "ghost", "aria-pressed": project, onClick: () => setMode(!project), children: "Edit project grade" })] }), _jsxs("div", { className: "colour-group", children: [_jsx("h4", { className: "colour-group__title", children: "Wheels" }), WHEELS.map((def) => {
|
|
330
|
+
const rgb = wheels[def.key];
|
|
331
|
+
return (_jsxs("div", { className: "colour-wheel", children: [_jsxs("div", { className: "colour-wheel__head", children: [_jsx("span", { className: "colour-wheel__name", children: def.label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => wheelReset(def.key), children: "Reset" })] }), _jsx(SliderRow, { label: "Master", name: `${def.label} master`, testId: `colour-${def.key}-master`, min: def.min, max: def.max, step: 0.01, value: mean(rgb), onInput: (v) => wheelMasterInput(def, v), onCommit: commitDraft }), CHANNELS.map((c, i) => (_jsx(SliderRow, { label: c.label, name: `${def.label} ${c.label}`, testId: `colour-${def.key}-${c.key}`, min: def.min, max: def.max, step: 0.01, value: rgb[i], onInput: (v) => wheelInput(def.key, i, v), onCommit: commitDraft }, c.key)))] }, def.key));
|
|
332
|
+
})] }), _jsxs("div", { className: "colour-group", children: [_jsx("h4", { className: "colour-group__title", children: "Curves" }), _jsx(SegmentedControl, { label: "Curve channel", value: channel, options: CURVE_TABS, onChange: setChannel }), _jsxs("svg", { className: "colour-curve", viewBox: "0 0 100 100", role: "group", "aria-label": `${CURVE_NAMES[channel]} curve`, "data-testid": "colour-curve", onPointerDown: onCurveDown, onPointerMove: onCurveMove, onPointerUp: onCurveUp, onPointerCancel: onCurveUp, children: [_jsx("rect", { className: "colour-curve__hit", x: "0", y: "0", width: "100", height: "100", fill: "transparent" }), _jsx("line", { className: "colour-curve__identity", x1: "0", y1: "100", x2: "100", y2: "0" }), points.length > 0 && _jsx("polyline", { className: "colour-curve__line", fill: "none", points: curvePolyline }), points.map((p, i) => (_jsx("circle", { className: "colour-curve__point", cx: p.x * 100, cy: (1 - p.y) * 100, r: 2.5, tabIndex: 0, role: "slider", "aria-label": `${CURVE_NAMES[channel]} point ${i + 1} of ${points.length}`, "aria-valuetext": `input ${p.x.toFixed(2)}, output ${p.y.toFixed(2)}`, onPointerDown: (e) => {
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
dragRef.current = i;
|
|
335
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
336
|
+
}, onKeyDown: (e) => onPointKey(i, e), onKeyUp: commitDraft, onBlur: commitDraft, onDoubleClick: () => removePoint(i) }, i)))] }), _jsxs("div", { className: "colour-actions", children: [_jsx(Button, { size: "sm", variant: "secondary", disabled: points.length >= MAX_POINTS, onClick: addPoint, children: "Add point" }), _jsx(Button, { size: "sm", variant: "ghost", disabled: points.length === 0, onClick: () => commitNow(withCurve(draftRef.current, channel, [])), children: "Reset curve" })] })] }), _jsxs("div", { className: "colour-group", children: [_jsxs("div", { className: "colour-group__head", children: [_jsx("h4", { className: "colour-group__title", children: "HSL secondary" }), _jsx(Button, { size: "sm", variant: "secondary", disabled: hsl.length >= MAX_HSL, onClick: addHsl, children: "Add secondary" })] }), hsl.map((h, i) => (_jsxs("div", { className: "colour-hsl", children: [_jsxs("div", { className: "colour-hsl__head", children: [_jsx(Select, { label: `Secondary ${i + 1} range`, value: h.range, options: HSL_RANGES, onChange: (value) => {
|
|
337
|
+
const r = HSL_RANGES.find((x) => x.value === value);
|
|
338
|
+
if (r)
|
|
339
|
+
patchHsl(i, { range: r.value }, true);
|
|
340
|
+
} }), _jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Remove secondary ${i + 1}`, onClick: () => removeHsl(i), children: "Remove" })] }), _jsx(SliderRow, { label: "Hue shift", name: `Secondary ${i + 1} hue shift`, min: -180, max: 180, step: 1, value: h.hue_shift, digits: 0, onInput: (v) => patchHsl(i, { hue_shift: v }, false), onCommit: commitDraft }), _jsx(SliderRow, { label: "Saturation", name: `Secondary ${i + 1} saturation`, min: -1, max: 1, step: 0.01, value: h.saturation, onInput: (v) => patchHsl(i, { saturation: v }, false), onCommit: commitDraft }), _jsx(SliderRow, { label: "Lightness", name: `Secondary ${i + 1} lightness`, min: -1, max: 1, step: 0.01, value: h.lightness, onInput: (v) => patchHsl(i, { lightness: v }, false), onCommit: commitDraft }), _jsx(SliderRow, { label: "Strength", name: `Secondary ${i + 1} strength`, min: 0, max: 1, step: 0.01, value: h.strength, onInput: (v) => patchHsl(i, { strength: v }, false), onCommit: commitDraft })] }, i)))] }), _jsxs("div", { className: "colour-group", children: [_jsx("h4", { className: "colour-group__title", children: "Saturation" }), _jsx(SliderRow, { label: "Saturation", name: "Saturation", testId: "colour-saturation", min: 0, max: 2, step: 0.01, value: sat, onInput: (v) => setGrade({ ...draftRef.current, saturation: v }), onCommit: commitDraft })] })] })), _jsxs("div", { className: "colour-group", children: [_jsxs("div", { className: "colour-group__head", children: [_jsx("h4", { className: "colour-group__title", children: "Scopes" }), _jsx(SegmentedControl, { label: "Scope grade", value: graded ? "graded" : "source", options: SCOPE_TABS, onChange: (v) => setGraded(v === "graded") })] }), _jsx("div", { className: "colour-scope__box colour-scope__box--wide", children: scopes ? (_jsx("img", { className: "colour-scope__img", src: assetUrl(scopes.waveform), alt: "Luma waveform", "data-testid": "colour-scope-waveform" })) : (_jsx("span", { className: "cr-muted colour-status", children: "Loading scopes\u2026" })) }), _jsx("div", { className: "colour-scope__box colour-scope__box--square", children: scopes ? (_jsx("img", { className: "colour-scope__img", src: assetUrl(scopes.vectorscope), alt: "Vectorscope", "data-testid": "colour-scope-vectorscope" })) : (_jsx("span", { className: "cr-muted colour-status", children: "Loading scopes\u2026" })) }), scopes && (_jsxs("dl", { className: "colour-stats", children: [_jsxs("div", { children: [_jsx("dt", { children: "Y min" }), _jsx("dd", { children: scopes.stats.y_min.toFixed(2) })] }), _jsxs("div", { children: [_jsx("dt", { children: "Y max" }), _jsx("dd", { children: scopes.stats.y_max.toFixed(2) })] }), _jsxs("div", { children: [_jsx("dt", { children: "Y avg" }), _jsx("dd", { children: scopes.stats.y_avg.toFixed(2) })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sat max" }), _jsx("dd", { children: scopes.stats.sat_max.toFixed(2) })] })] })), scopes && (scopes.clipped.high || scopes.clipped.low) && (_jsxs("div", { className: "colour-clipping", "data-testid": "colour-clipping", role: "status", children: [scopes.clipped.high && _jsx("span", { children: "Clipping: highlights" }), scopes.clipped.low && _jsx("span", { children: "Clipping: shadows" })] }))] }), error !== null && (_jsx("p", { role: "alert", className: "colour-error", "data-testid": "colour-error", children: error }))] }));
|
|
341
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EditorModel } from "./model.js";
|
|
2
|
+
import { type CapabilityReport } from "./capabilities.js";
|
|
3
|
+
import type { EditSession } from "./adapter.js";
|
|
4
|
+
export declare function Inspector({ model, selection, session, caps, dir, playheadS, onCommitted, onError, }: {
|
|
5
|
+
model: EditorModel;
|
|
6
|
+
selection: ReadonlySet<string>;
|
|
7
|
+
session: EditSession;
|
|
8
|
+
caps: CapabilityReport | null;
|
|
9
|
+
/** Project dir for the media.waveform call. */
|
|
10
|
+
dir: string;
|
|
11
|
+
/** Timeline playhead in seconds; drives the colour scope sample frame. */
|
|
12
|
+
playheadS?: number;
|
|
13
|
+
onCommitted: () => void;
|
|
14
|
+
onError: (message: string) => void;
|
|
15
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Selection inspector: continuous controls preview locally and commit once on
|
|
4
|
+
* release/blur through `useInspectorGestureTransaction` (adapted from
|
|
5
|
+
* hyperframes). Every control maps to a real `cutright_timeline::Action` or is
|
|
6
|
+
* rendered disabled with the capability reason.
|
|
7
|
+
*/
|
|
8
|
+
import { Checkbox, Field, Input } from "@rightkit/react-shell/react";
|
|
9
|
+
import { errorText } from "./adapter.js";
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { engineCall } from "../lib/engine.js";
|
|
12
|
+
import { decimatePeaks, loudnessToOpacity } from "./vendor/hyperframes/audioWaveformPeaks.js";
|
|
13
|
+
import { ticksToSeconds } from "../lib/project.js";
|
|
14
|
+
import { useInspectorGestureDraft } from "./vendor/hyperframes/useInspectorGestureTransaction.js";
|
|
15
|
+
import { editTitle, linkNarration, setGain, setMute, positionTitle, swapTake, setUnder, trimAudioClip, trimClip, trimTitle, unlinkNarration, } from "./actions.js";
|
|
16
|
+
import { supports, unsupported } from "./capabilities.js";
|
|
17
|
+
import { ColourInspector } from "./ColourInspector.js";
|
|
18
|
+
export function Inspector({ model, selection, session, caps, dir, playheadS, onCommitted, onError, }) {
|
|
19
|
+
const clip = useMemo(() => {
|
|
20
|
+
for (const id of selection) {
|
|
21
|
+
const hit = model.lanes.flatMap((l) => l.clips).find((c) => c.id === id);
|
|
22
|
+
if (hit)
|
|
23
|
+
return hit;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}, [model, selection]);
|
|
27
|
+
const commit = async (actions) => {
|
|
28
|
+
try {
|
|
29
|
+
await session.apply(actions.filter(Boolean), "Inspector edit");
|
|
30
|
+
onCommitted();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
onError(errorText(e));
|
|
34
|
+
onCommitted();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
if (selection.size === 0) {
|
|
38
|
+
return (_jsx("p", { className: "cr-muted", style: { margin: 0, fontSize: 12 }, children: "Select a clip on the timeline to edit its trim, audio, or title." }));
|
|
39
|
+
}
|
|
40
|
+
if (selection.size > 1) {
|
|
41
|
+
return (_jsxs("p", { className: "cr-muted", style: { margin: 0, fontSize: 12 }, children: [selection.size, " clips selected \u2014 drag to move together; inspect edits one clip."] }));
|
|
42
|
+
}
|
|
43
|
+
if (!clip)
|
|
44
|
+
return null;
|
|
45
|
+
return (_jsxs("div", { className: "fe-inspect", "aria-label": "Clip inspector", children: [clip.ref.kind === "video" && (_jsx(VideoInspector, { clip: clip, model: model, commit: commit, caps: caps, dir: dir, playheadS: playheadS }, `${clip.id}:${model.revisionCursor}`)), clip.ref.kind === "audio" && (_jsx(AudioInspector, { clip: clip, model: model, commit: commit, caps: caps, dir: dir }, `${clip.id}:${model.revisionCursor}`)), clip.ref.kind === "title" && (_jsx(TitleInspector, { clip: clip, model: model, commit: commit, caps: caps }, `${clip.id}:${model.revisionCursor}`))] }));
|
|
46
|
+
}
|
|
47
|
+
function SecondsInput({ label, seconds, onCommit, }) {
|
|
48
|
+
const { draft, setDraft, transaction } = useInspectorGestureDraft({
|
|
49
|
+
sourceValue: seconds,
|
|
50
|
+
onPreview: () => undefined,
|
|
51
|
+
onCommit: (v) => onCommit(v),
|
|
52
|
+
});
|
|
53
|
+
return (_jsx(Field, { label: label, children: (ids) => (_jsx(Input, { ...ids, inputMode: "decimal", value: String(draft.toFixed(2)), onFocus: () => transaction.begin(), onChange: (e) => {
|
|
54
|
+
const v = Number(e.target.value);
|
|
55
|
+
if (Number.isFinite(v)) {
|
|
56
|
+
setDraft(v);
|
|
57
|
+
transaction.preview(v);
|
|
58
|
+
}
|
|
59
|
+
}, onBlur: () => transaction.settle(), onKeyDown: (e) => {
|
|
60
|
+
if (e.key === "Enter")
|
|
61
|
+
transaction.settle();
|
|
62
|
+
if (e.key === "Escape")
|
|
63
|
+
transaction.cancel();
|
|
64
|
+
} })) }));
|
|
65
|
+
}
|
|
66
|
+
function VideoInspector({ clip, model, commit, caps, dir, playheadS, }) {
|
|
67
|
+
if (clip.ref.kind !== "video")
|
|
68
|
+
return null;
|
|
69
|
+
const clipIndex = clip.ref.index;
|
|
70
|
+
const inS = ticksToSeconds(clip.ref.clip.source_in);
|
|
71
|
+
const outS = ticksToSeconds(clip.ref.clip.source_out);
|
|
72
|
+
// Source time under the playhead when it is inside this clip; otherwise source-in.
|
|
73
|
+
const scopeS = playheadS !== undefined && playheadS >= clip.start && playheadS <= clip.start + clip.duration
|
|
74
|
+
? Math.min(outS, Math.max(inS, inS + (playheadS - clip.start)))
|
|
75
|
+
: inS;
|
|
76
|
+
const srcDur = model.sourceDurationS.get(clip.ref.clip.source) ?? outS;
|
|
77
|
+
const [take, setTake] = useState(clip.ref.clip.source);
|
|
78
|
+
const [takeIn, setTakeIn] = useState(inS);
|
|
79
|
+
const [takeOut, setTakeOut] = useState(outS);
|
|
80
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "fe-inspect__row", children: [_jsx(SecondsInput, { label: "Source in (s)", seconds: inS, onCommit: (v) => void commit([trimClip(clip.id, Math.max(0, Math.min(v, outS - 0.016)), outS)]) }), _jsx(SecondsInput, { label: "Source out (s)", seconds: outS, onCommit: (v) => void commit([trimClip(clip.id, inS, Math.min(srcDur, Math.max(v, inS + 0.016)))]) }), _jsx(Field, { label: "Track position", children: (ids) => (_jsxs("span", { ...ids, style: { fontSize: 13 }, children: ["clip ", clipIndex + 1, " \u00B7 ", clip.start.toFixed(2), "s\u2013", (clip.start + clip.duration).toFixed(2), "s"] })) })] }), _jsx(GatedRow, { cap: "edit.split", caps: caps, label: "Split clip" }), _jsx(GatedRow, { cap: "edit.slip", caps: caps, label: "Slip source range" }), _jsx(Field, { label: "Clip gain (dB)", children: () => (_jsx(SecondsInput, { label: "Gain", seconds: (model.corrections.audio[clip.id]?.gain ?? 0) / 1000, onCommit: (v) => void commit([setGain(clip.id, v)]) })) }), _jsx(MuteControl, { clipId: clip.id, model: model, commit: commit }), _jsx(Field, { label: "Alternate take", children: (ids) => (_jsx("select", { ...ids, className: "rk-input cr-select", value: take, "aria-label": "Alternate take source", onChange: (e) => setTake(e.target.value), children: model.sources.filter((s) => s.info.video).map((s) => _jsx("option", { value: s.info.id, children: s.name ?? s.info.id.slice(0, 8) }, s.info.id)) })) }), _jsx(SecondsInput, { label: "Take in (s)", seconds: takeIn, onCommit: setTakeIn }), _jsx(SecondsInput, { label: "Take out (s)", seconds: takeOut, onCommit: setTakeOut }), _jsx("button", { type: "button", className: "rk-button", onClick: () => void commit([swapTake(clip.id, take, takeIn, takeOut)]), children: "Use this take" }), _jsx(ColourInspector, { dir: dir, sourceId: clip.ref.clip.source, atMs: Math.round(scopeS * 1000) })] }));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Per-clip waveform over the source's decoded peaks. Rendered only when the
|
|
84
|
+
* engine advertises `media.waveform`; otherwise the gate reason shows.
|
|
85
|
+
*/
|
|
86
|
+
function Waveform({ dir, source, inS, outS, srcDurS, }) {
|
|
87
|
+
const [peaks, setPeaks] = useState(null);
|
|
88
|
+
const [err, setErr] = useState(null);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
setPeaks(null);
|
|
92
|
+
setErr(null);
|
|
93
|
+
engineCall("media.waveform", { dir, source, bars: 600 })
|
|
94
|
+
.then((r) => !cancelled && setPeaks(r.peaks ?? []))
|
|
95
|
+
.catch((e) => !cancelled && setErr(errorText(e)));
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, [dir, source]);
|
|
100
|
+
if (err)
|
|
101
|
+
return _jsx("span", { className: "cr-muted", style: { fontSize: 12 }, children: err });
|
|
102
|
+
if (!peaks)
|
|
103
|
+
return _jsx("span", { className: "cr-muted", style: { fontSize: 12 }, children: "Loading peaks\u2026" });
|
|
104
|
+
const dur = srcDurS > 0 ? srcDurS : 1;
|
|
105
|
+
const bars = decimatePeaks(peaks, inS / dur, outS / dur, 96);
|
|
106
|
+
return (_jsx("div", { role: "img", "aria-label": "Clip waveform", style: { display: "flex", height: 32, alignItems: "flex-end", gap: 1 }, children: bars.map((p, i) => (_jsx("div", { style: {
|
|
107
|
+
width: 4,
|
|
108
|
+
borderRadius: 2,
|
|
109
|
+
height: `${Math.max(8, p * 100)}%`,
|
|
110
|
+
background: "var(--rk-accent)",
|
|
111
|
+
opacity: loudnessToOpacity(p),
|
|
112
|
+
} }, i))) }));
|
|
113
|
+
}
|
|
114
|
+
function AudioInspector({ clip, model, commit, caps, dir, }) {
|
|
115
|
+
if (clip.ref.kind !== "audio")
|
|
116
|
+
return null;
|
|
117
|
+
const c = clip.ref.clip;
|
|
118
|
+
const gainDb = c.gain / 1000;
|
|
119
|
+
const under = c.under;
|
|
120
|
+
const videoTargets = model.lanes
|
|
121
|
+
.filter((l) => l.kind === "video")
|
|
122
|
+
.flatMap((l) => l.clips);
|
|
123
|
+
const [duckDb, setDuckDb] = useState(12);
|
|
124
|
+
const { draft, setDraft, transaction } = useInspectorGestureDraft({
|
|
125
|
+
sourceValue: gainDb,
|
|
126
|
+
onPreview: () => undefined,
|
|
127
|
+
onCommit: (v) => void commit([setGain(clip.id, v)]),
|
|
128
|
+
});
|
|
129
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "fe-inspect", children: [_jsx(MuteControl, { clipId: clip.id, model: model, commit: commit }), _jsx(Field, { label: `Gain ${draft.toFixed(1)} dB`, children: (ids) => (_jsx(Input, { ...ids, type: "range", min: -60, max: 12, step: 0.5, value: draft, "aria-label": "Clip gain", className: "fe-inspect__range", onPointerDown: () => transaction.begin(), onChange: (e) => {
|
|
130
|
+
const v = Number(e.target.value);
|
|
131
|
+
setDraft(v);
|
|
132
|
+
transaction.preview(v);
|
|
133
|
+
}, onPointerUp: () => transaction.settle(), onKeyDown: (e) => {
|
|
134
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
135
|
+
transaction.begin();
|
|
136
|
+
const v = draft + (e.key === "ArrowRight" ? 0.5 : -0.5);
|
|
137
|
+
setDraft(v);
|
|
138
|
+
transaction.preview(v);
|
|
139
|
+
transaction.settle();
|
|
140
|
+
}
|
|
141
|
+
} })) }), _jsx(Field, { label: "Audio underneath", children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: under.mode, "aria-label": "Under audio mode", onChange: (e) => {
|
|
142
|
+
const mode = e.target.value;
|
|
143
|
+
const next = mode === "mute" ? { mode: "mute" }
|
|
144
|
+
: mode === "duck" ? { mode: "duck", amount: Math.round(duckDb * 1000) }
|
|
145
|
+
: { mode: "keep" };
|
|
146
|
+
void commit([setUnder(clip.id, next)]);
|
|
147
|
+
}, children: [_jsx("option", { value: "keep", children: "Keep" }), _jsx("option", { value: "duck", children: "Duck" }), _jsx("option", { value: "mute", children: "Mute" })] })) }), under.mode === "duck" && (_jsx(Field, { label: "Duck amount (dB)", children: (ids) => (_jsx(Input, { ...ids, inputMode: "decimal", value: duckDb, onChange: (e) => setDuckDb(Number(e.target.value) || 0), onBlur: () => void commit([setUnder(clip.id, { mode: "duck", amount: Math.round(duckDb * 1000) })]) })) })), _jsx(Field, { label: "Linked video clip", children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: c.link ?? "", "aria-label": "Linked video clip", onChange: (e) => {
|
|
148
|
+
const v = e.target.value;
|
|
149
|
+
void commit(v ? [linkNarration(clip.id, v)] : [unlinkNarration(clip.id)]);
|
|
150
|
+
}, children: [_jsx("option", { value: "", children: "None" }), videoTargets.map((v) => (_jsx("option", { value: v.id, children: v.label }, v.id)))] })) })] }), _jsxs("div", { className: "fe-inspect__row", children: [_jsx(SecondsInput, { label: "Trim in (s)", seconds: ticksToSeconds(c.trim_in), onCommit: (v) => void commit([
|
|
151
|
+
trimAudioClip(clip.id, Math.max(0, Math.min(v, ticksToSeconds(c.trim_out) - 0.016)), ticksToSeconds(c.trim_out)),
|
|
152
|
+
]) }), _jsx(SecondsInput, { label: "Trim out (s)", seconds: ticksToSeconds(c.trim_out), onCommit: (v) => void commit([
|
|
153
|
+
trimAudioClip(clip.id, ticksToSeconds(c.trim_in), Math.max(v, ticksToSeconds(c.trim_in) + 0.016)),
|
|
154
|
+
]) }), _jsx(Field, { label: "Waveform", children: () => (supports("media.waveform", caps) ? (_jsx(Waveform, { dir: dir, source: c.source, inS: ticksToSeconds(c.trim_in), outS: ticksToSeconds(c.trim_out), srcDurS: model.sourceDurationS.get(c.source) ?? ticksToSeconds(c.trim_out) })) : (_jsx("span", { className: "cr-muted", style: { fontSize: 12 }, children: unsupported("media.waveform").reason }))) })] })] }));
|
|
155
|
+
}
|
|
156
|
+
function TitleInspector({ clip, model, commit, caps, }) {
|
|
157
|
+
void caps;
|
|
158
|
+
if (clip.ref.kind !== "title")
|
|
159
|
+
return null;
|
|
160
|
+
const c = clip.ref.clip;
|
|
161
|
+
const [template, setTemplate] = useState(c.template);
|
|
162
|
+
const fadeIn = c.fade_in_frames ?? 0;
|
|
163
|
+
const fadeOut = c.fade_out_frames ?? 0;
|
|
164
|
+
const text = template.template === "lower_third" ? template.title : template.text;
|
|
165
|
+
return (_jsxs("div", { className: "fe-inspect", children: [_jsx(Field, { label: "Template", children: (ids) => (_jsxs("select", { ...ids, className: "rk-input cr-select", value: template.template, "aria-label": "Title template", onChange: (e) => {
|
|
166
|
+
const kind = e.target.value;
|
|
167
|
+
const next = kind === "lower_third"
|
|
168
|
+
? { template: "lower_third", title: text }
|
|
169
|
+
: kind === "centered"
|
|
170
|
+
? { template: "centered", text }
|
|
171
|
+
: { template: "caption", text };
|
|
172
|
+
setTemplate(next);
|
|
173
|
+
void commit([
|
|
174
|
+
editTitle(clip.id, next, fadeIn, fadeOut),
|
|
175
|
+
]);
|
|
176
|
+
}, children: [_jsx("option", { value: "lower_third", children: "Lower third" }), _jsx("option", { value: "centered", children: "Centered" }), _jsx("option", { value: "caption", children: "Caption" })] })) }), _jsx(Field, { label: "Text", children: (ids) => (_jsx(Input, { ...ids, value: text, maxLength: 160, "aria-label": "Title text", onChange: (e) => {
|
|
177
|
+
const t = e.target.value;
|
|
178
|
+
setTemplate(template.template === "lower_third"
|
|
179
|
+
? { ...template, title: t }
|
|
180
|
+
: { ...template, text: t });
|
|
181
|
+
}, onBlur: () => void commit([
|
|
182
|
+
editTitle(clip.id, template, fadeIn, fadeOut),
|
|
183
|
+
]) })) }), template.template === "lower_third" && (_jsx(Field, { label: "Subtitle", children: (ids) => (_jsx(Input, { ...ids, value: template.subtitle ?? "", maxLength: 160, "aria-label": "Title subtitle", onChange: (e) => setTemplate({ ...template, subtitle: e.target.value || undefined }), onBlur: () => void commit([
|
|
184
|
+
editTitle(clip.id, template, fadeIn, fadeOut),
|
|
185
|
+
]) })) })), _jsx(SecondsInput, { label: "Horizontal position (%)", seconds: (model.corrections.positions[clip.id]?.x ?? 500) / 10, onCommit: (v) => void commit([positionTitle(clip.id, v * 10, model.corrections.positions[clip.id]?.y ?? 500)]) }), _jsx(SecondsInput, { label: "Vertical position (%)", seconds: (model.corrections.positions[clip.id]?.y ?? 500) / 10, onCommit: (v) => void commit([positionTitle(clip.id, model.corrections.positions[clip.id]?.x ?? 500, v * 10)]) }), _jsxs("div", { className: "fe-inspect__row", children: [_jsx(SecondsInput, { label: "Start (s)", seconds: c.start_frame / model.fps, onCommit: (v) => void commit([trimTitle(clip.id, v * model.fps, c.frames)]) }), _jsx(SecondsInput, { label: "Duration (s)", seconds: c.frames / model.fps, onCommit: (v) => void commit([trimTitle(clip.id, c.start_frame, Math.max(1, v * model.fps))]) }), _jsx(SecondsInput, { label: "Fade in (s)", seconds: fadeIn / model.fps, onCommit: (v) => void commit([editTitle(clip.id, template, Math.max(0, v * model.fps), fadeOut)]) }), _jsx(SecondsInput, { label: "Fade out (s)", seconds: fadeOut / model.fps, onCommit: (v) => void commit([editTitle(clip.id, template, fadeIn, Math.max(0, v * model.fps))]) })] })] }));
|
|
186
|
+
}
|
|
187
|
+
function GatedRow({ cap, caps, label, }) {
|
|
188
|
+
if (supports(cap, caps))
|
|
189
|
+
return null;
|
|
190
|
+
const u = unsupported(cap);
|
|
191
|
+
return (_jsxs("div", { className: "rk-card", style: {
|
|
192
|
+
display: "flex",
|
|
193
|
+
alignItems: "center",
|
|
194
|
+
justifyContent: "space-between",
|
|
195
|
+
gap: "var(--s2)",
|
|
196
|
+
padding: "var(--s2) var(--s3)",
|
|
197
|
+
borderStyle: "dashed",
|
|
198
|
+
}, children: [_jsx("span", { className: "cr-muted", style: { fontSize: 12 }, children: label }), _jsx("span", { className: "cr-muted", style: { fontSize: 10 }, title: u.reason, children: "Not supported by this engine" })] }));
|
|
199
|
+
}
|
|
200
|
+
function MuteControl({ clipId, model, commit }) {
|
|
201
|
+
return _jsx(Checkbox, { label: "Mute clip audio", checked: model.corrections.audio[clipId]?.muted ?? false, onChange: (checked) => void commit([setMute(clipId, checked)]) });
|
|
202
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finished-edit timeline (docs/design/app-ui.md §3.4): ruler, playhead,
|
|
3
|
+
* selectable clips with trim/move/slip gestures in Edit mode, navigation-only
|
|
4
|
+
* in Review mode. All pointer geometry is transient — the canonical timeline
|
|
5
|
+
* updates only when `session.apply` commits a planned action and the host
|
|
6
|
+
* refreshes `project.show`.
|
|
7
|
+
*
|
|
8
|
+
* Lanes follow the spec order: Footage, Titles, Captions (timeline-mapped
|
|
9
|
+
* cues), Voice (waveform), Music, B-roll when a second video track exists.
|
|
10
|
+
* Clip thumbnails come from `media.thumbnail`, waveforms from
|
|
11
|
+
* `media.waveform` — both behind real engine data, never placeholders.
|
|
12
|
+
*
|
|
13
|
+
* Visual structure adapted from hyperframes' separable TimelineRuler pattern,
|
|
14
|
+
* retargeted at the design tokens. The clip menu is the shell's ContextMenu.
|
|
15
|
+
*/
|
|
16
|
+
import { type MenuItem } from "@rightkit/react-shell/react";
|
|
17
|
+
import { type CaptionCue } from "../lib/media.js";
|
|
18
|
+
import type { EditorClip, EditorModel } from "./model.js";
|
|
19
|
+
import { type CapabilityReport } from "./capabilities.js";
|
|
20
|
+
import type { EditSession } from "./adapter.js";
|
|
21
|
+
export type PreviewSeek = (revisionCursor: number, timeS: number) => void;
|
|
22
|
+
export declare function Timeline({ model, session, caps, busy, mode, cues, dir, playheadS, onPlayhead, onCommitted, onSeek, onSelect, onMenuAction, }: {
|
|
23
|
+
model: EditorModel;
|
|
24
|
+
session: EditSession;
|
|
25
|
+
caps: CapabilityReport | null;
|
|
26
|
+
busy: boolean;
|
|
27
|
+
/** `review` = navigate only (click/scrub to seek). `edit` = full gestures. */
|
|
28
|
+
mode: "review" | "edit";
|
|
29
|
+
/** Timeline-mapped caption cues (output timebase). */
|
|
30
|
+
cues: CaptionCue[];
|
|
31
|
+
/** Project dir for thumbnail/waveform calls. */
|
|
32
|
+
dir: string;
|
|
33
|
+
playheadS: number;
|
|
34
|
+
onPlayhead: (timeS: number) => void;
|
|
35
|
+
onCommitted: () => void;
|
|
36
|
+
onSeek: PreviewSeek;
|
|
37
|
+
onSelect: (ids: ReadonlySet<string>) => void;
|
|
38
|
+
onMenuAction?: (clip: EditorClip) => MenuItem[];
|
|
39
|
+
}): import("react").JSX.Element;
|