@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,417 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Badge, Button, Card, EmptyState, PageShell } from "@rightkit/react-shell/react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { pickFile, pickMedia } from "../lib/dialog.js";
|
|
5
|
+
import { engineCall, onEngineRaw, errorMessage, onEngineEvent, revealPath } from "../lib/engine.js";
|
|
6
|
+
import { MediaPlayer } from "@rightkit/media-ui";
|
|
7
|
+
import { mediaKind, useMediaPlaybackId } from "../lib/mediaAdapters.js";
|
|
8
|
+
import { micAlign } from "../lib/mic.js";
|
|
9
|
+
import { TICKS_PER_SECOND, baseName, projectShow } from "../lib/project.js";
|
|
10
|
+
import { modelsStatus, settingsGet, settingsSet, sourceList, sourceProbe, sourceRelink, sourceVerify, thumbnails, transcribe, transcriptShow, } from "../lib/media.js";
|
|
11
|
+
import { assetUrl } from "../lib/asset.js";
|
|
12
|
+
import { Drawer, Icon, ProgressRow, Thumbnail, fileSize, timecode } from "../ui/index.js";
|
|
13
|
+
const INTENT_LABEL = {
|
|
14
|
+
"talking-head": "Talking head",
|
|
15
|
+
mixed: "Mixed",
|
|
16
|
+
visual: "Visual",
|
|
17
|
+
};
|
|
18
|
+
const INTENT_ORDER = ["talking-head", "mixed", "visual"];
|
|
19
|
+
/** Audio recorders' files (and camera containers carrying audio) for a separate mic. */
|
|
20
|
+
const MIC_EXTENSIONS = ["wav", "mp3", "m4a", "flac", "aac", "ogg", "mp4", "mov"];
|
|
21
|
+
/** §3.2 Media: source cards, drag-drop import, per-card pipeline state, detail drawer. */
|
|
22
|
+
export function SourcesView({ dir, onChanged, onNavigate, }) {
|
|
23
|
+
const [rows, setRows] = useState([]);
|
|
24
|
+
const [show, setShow] = useState(null);
|
|
25
|
+
const [probes, setProbes] = useState({});
|
|
26
|
+
const [checks, setChecks] = useState({});
|
|
27
|
+
const [verifiedCount, setVerifiedCount] = useState(null);
|
|
28
|
+
const [transcribing, setTranscribing] = useState(new Set());
|
|
29
|
+
// Live transcription stage per source ("Queued for the host app: dictation in progress", …).
|
|
30
|
+
const [txStatus, setTxStatus] = useState({});
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let off;
|
|
33
|
+
let dead = false;
|
|
34
|
+
void onEngineEvent((frame) => {
|
|
35
|
+
const ev = frame.event;
|
|
36
|
+
if (!frame.tag?.startsWith("ui-tx-") || ev.event !== "transcribe.progress" || !ev.source)
|
|
37
|
+
return;
|
|
38
|
+
const source = ev.source;
|
|
39
|
+
setTxStatus((m) => ({ ...m, [source]: { message: ev.message ?? "Transcribing", fraction: ev.fraction || undefined } }));
|
|
40
|
+
}).then((fn) => {
|
|
41
|
+
if (dead)
|
|
42
|
+
fn();
|
|
43
|
+
else
|
|
44
|
+
off = fn;
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
dead = true;
|
|
48
|
+
off?.();
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
const [importing, setImporting] = useState(0);
|
|
52
|
+
/** Files whose source.add is in flight; late progress events for others are dropped. */
|
|
53
|
+
const adding = useRef(new Set());
|
|
54
|
+
// §3.2: per-file source.add progress (hashing/copying/probing fractions).
|
|
55
|
+
const [addProgress, setAddProgress] = useState({});
|
|
56
|
+
const [speechReady, setSpeechReady] = useState(null);
|
|
57
|
+
const [intents, setIntents] = useState({});
|
|
58
|
+
const [error, setError] = useState(null);
|
|
59
|
+
const [micState, setMicState] = useState({});
|
|
60
|
+
const [dropActive, setDropActive] = useState(false);
|
|
61
|
+
const [open, setOpen] = useState(null);
|
|
62
|
+
const [thumbSrc, setThumbSrc] = useState({});
|
|
63
|
+
const thumbsRequested = useRef(new Set());
|
|
64
|
+
const infoOf = useCallback((id) => show?.timeline.sources.find((s) => s.id === id), [show]);
|
|
65
|
+
const intentOf = useCallback((r) => {
|
|
66
|
+
const o = intents[r.id];
|
|
67
|
+
if (o)
|
|
68
|
+
return o;
|
|
69
|
+
const hasAudio = infoOf(r.id)?.has_audio ?? false;
|
|
70
|
+
if (!hasAudio)
|
|
71
|
+
return "visual";
|
|
72
|
+
return r.has_transcript ? "talking-head" : "mixed";
|
|
73
|
+
}, [intents, infoOf]);
|
|
74
|
+
const refresh = useCallback(async () => {
|
|
75
|
+
const [list, proj, settings, models] = await Promise.all([
|
|
76
|
+
sourceList(dir),
|
|
77
|
+
projectShow(dir).catch(() => null),
|
|
78
|
+
settingsGet(dir).catch(() => null),
|
|
79
|
+
modelsStatus().catch(() => null),
|
|
80
|
+
]);
|
|
81
|
+
setRows(list.sources);
|
|
82
|
+
setShow(proj);
|
|
83
|
+
setSpeechReady(models ? (models.speech.ready ?? null) : null);
|
|
84
|
+
const saved = settings?.project?.intents;
|
|
85
|
+
if (saved && typeof saved === "object")
|
|
86
|
+
setIntents(saved);
|
|
87
|
+
else
|
|
88
|
+
setIntents({});
|
|
89
|
+
}, [dir]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
refresh().catch((e) => setError(errorMessage(e)));
|
|
92
|
+
}, [refresh]);
|
|
93
|
+
// Integrity check on open: per-card hashing state until its check lands.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
let off;
|
|
97
|
+
void onEngineRaw((payload) => {
|
|
98
|
+
const ev = payload?.event;
|
|
99
|
+
if (ev?.event === "source.verify" && typeof ev.completed === "number") {
|
|
100
|
+
setVerifiedCount(ev.completed);
|
|
101
|
+
}
|
|
102
|
+
}).then((f) => {
|
|
103
|
+
if (cancelled)
|
|
104
|
+
f();
|
|
105
|
+
else
|
|
106
|
+
off = f;
|
|
107
|
+
});
|
|
108
|
+
sourceVerify(dir, `media-verify-${dir}`)
|
|
109
|
+
.then((r) => {
|
|
110
|
+
if (cancelled)
|
|
111
|
+
return;
|
|
112
|
+
const map = {};
|
|
113
|
+
for (const c of r.checks ?? []) {
|
|
114
|
+
map[c.source_id] =
|
|
115
|
+
c.status === "ok"
|
|
116
|
+
? "ok"
|
|
117
|
+
: c.status === "missing" || c.status === "unreadable"
|
|
118
|
+
? "missing"
|
|
119
|
+
: "changed"; // mismatch | moved
|
|
120
|
+
}
|
|
121
|
+
setChecks(map);
|
|
122
|
+
setVerifiedCount(null);
|
|
123
|
+
})
|
|
124
|
+
.catch(() => setVerifiedCount(null));
|
|
125
|
+
return () => {
|
|
126
|
+
cancelled = true;
|
|
127
|
+
off?.();
|
|
128
|
+
};
|
|
129
|
+
}, [dir]);
|
|
130
|
+
// Transcription pipeline state (own calls and recipe-run stages alike).
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
let off;
|
|
133
|
+
let cancelled = false;
|
|
134
|
+
void onEngineRaw((payload) => {
|
|
135
|
+
const ev = payload.event;
|
|
136
|
+
if (ev?.event === "source.add.progress" && ev.file) {
|
|
137
|
+
const { file, phase = "working", fraction } = ev;
|
|
138
|
+
// Events can trail the source.add response; only files still in flight count.
|
|
139
|
+
if (!adding.current.has(file))
|
|
140
|
+
return;
|
|
141
|
+
setAddProgress((m) => ({ ...m, [file]: { phase, fraction } }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const sid = ev?.source;
|
|
145
|
+
if (!sid)
|
|
146
|
+
return;
|
|
147
|
+
if (ev?.event === "transcribe.started") {
|
|
148
|
+
setTranscribing((s) => new Set(s).add(sid));
|
|
149
|
+
}
|
|
150
|
+
else if (ev?.event === "transcribe.done") {
|
|
151
|
+
setTranscribing((s) => {
|
|
152
|
+
const n = new Set(s);
|
|
153
|
+
n.delete(sid);
|
|
154
|
+
return n;
|
|
155
|
+
});
|
|
156
|
+
void refresh();
|
|
157
|
+
}
|
|
158
|
+
}).then((f) => {
|
|
159
|
+
if (cancelled)
|
|
160
|
+
f();
|
|
161
|
+
else
|
|
162
|
+
off = f;
|
|
163
|
+
});
|
|
164
|
+
return () => {
|
|
165
|
+
cancelled = true;
|
|
166
|
+
off?.();
|
|
167
|
+
};
|
|
168
|
+
}, [refresh]);
|
|
169
|
+
// Real thumbnails + probe metadata per card (cached engine-side).
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
for (const r of rows) {
|
|
172
|
+
if (!r.exists || thumbsRequested.current.has(r.id))
|
|
173
|
+
continue;
|
|
174
|
+
thumbsRequested.current.add(r.id);
|
|
175
|
+
void thumbnails(dir, { source: r.id }, { width: 480 })
|
|
176
|
+
.then((t) => {
|
|
177
|
+
const frame = t.frames[0];
|
|
178
|
+
if (frame)
|
|
179
|
+
setThumbSrc((m) => ({ ...m, [r.id]: assetUrl(frame.path) }));
|
|
180
|
+
})
|
|
181
|
+
.catch(() => undefined);
|
|
182
|
+
void sourceProbe(dir, r.id)
|
|
183
|
+
.then((p) => setProbes((m) => ({ ...m, [r.id]: p })))
|
|
184
|
+
.catch(() => undefined);
|
|
185
|
+
}
|
|
186
|
+
}, [rows, dir]);
|
|
187
|
+
const busyOf = useCallback((r, index) => {
|
|
188
|
+
if (transcribing.has(r.id))
|
|
189
|
+
return "transcribing";
|
|
190
|
+
if (verifiedCount !== null && index >= verifiedCount && r.exists)
|
|
191
|
+
return "hashing";
|
|
192
|
+
return null;
|
|
193
|
+
}, [transcribing, verifiedCount]);
|
|
194
|
+
const importFiles = useCallback(async (files) => {
|
|
195
|
+
if (files.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
setImporting((n) => n + 1);
|
|
198
|
+
setError(null);
|
|
199
|
+
try {
|
|
200
|
+
for (const file of files) {
|
|
201
|
+
adding.current.add(file);
|
|
202
|
+
setAddProgress((m) => ({ ...m, [file]: { phase: "hashing", fraction: undefined } }));
|
|
203
|
+
try {
|
|
204
|
+
await engineCall("source.add", { dir, file });
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
adding.current.delete(file);
|
|
208
|
+
setAddProgress((m) => {
|
|
209
|
+
const n = { ...m };
|
|
210
|
+
delete n[file];
|
|
211
|
+
return n;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
await refresh();
|
|
216
|
+
await onChanged();
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
setError(errorMessage(e));
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
setImporting((n) => n - 1);
|
|
223
|
+
}
|
|
224
|
+
}, [dir, refresh, onChanged]);
|
|
225
|
+
const importMedia = useCallback(async () => {
|
|
226
|
+
await importFiles(await pickMedia());
|
|
227
|
+
}, [importFiles]);
|
|
228
|
+
// Drag-and-drop import anywhere on the screen.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
let depth = 0;
|
|
231
|
+
const exts = /\.(mp4|mov|mkv|webm|m4v|wav|mp3|m4a|flac|aac|ogg)$/i;
|
|
232
|
+
const onDrag = (e) => {
|
|
233
|
+
if (!e.dataTransfer?.types.includes("Files"))
|
|
234
|
+
return;
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
if (e.type === "dragenter")
|
|
237
|
+
depth += 1;
|
|
238
|
+
if (e.type === "dragleave")
|
|
239
|
+
depth -= 1;
|
|
240
|
+
if (e.type === "drop")
|
|
241
|
+
depth = 0;
|
|
242
|
+
setDropActive(depth > 0);
|
|
243
|
+
};
|
|
244
|
+
const onDrop = (e) => {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
const files = [...(e.dataTransfer?.files ?? [])]
|
|
247
|
+
.map((f) => f.path ?? "")
|
|
248
|
+
.filter((p) => exts.test(p));
|
|
249
|
+
if (files.length)
|
|
250
|
+
void importFiles(files);
|
|
251
|
+
};
|
|
252
|
+
window.addEventListener("dragenter", onDrag);
|
|
253
|
+
window.addEventListener("dragleave", onDrag);
|
|
254
|
+
window.addEventListener("dragover", onDrag);
|
|
255
|
+
window.addEventListener("drop", onDrop);
|
|
256
|
+
return () => {
|
|
257
|
+
window.removeEventListener("dragenter", onDrag);
|
|
258
|
+
window.removeEventListener("dragleave", onDrag);
|
|
259
|
+
window.removeEventListener("dragover", onDrag);
|
|
260
|
+
window.removeEventListener("drop", onDrop);
|
|
261
|
+
};
|
|
262
|
+
}, [importFiles]);
|
|
263
|
+
const relink = useCallback(async (r) => {
|
|
264
|
+
const picked = await pickMedia();
|
|
265
|
+
const path = picked[0];
|
|
266
|
+
if (!path)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
await sourceRelink(dir, r.id, path);
|
|
270
|
+
thumbsRequested.current.delete(r.id);
|
|
271
|
+
await refresh();
|
|
272
|
+
await onChanged();
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
setError(errorMessage(e));
|
|
276
|
+
}
|
|
277
|
+
}, [dir, refresh, onChanged]);
|
|
278
|
+
const cycleIntent = useCallback((r) => {
|
|
279
|
+
const next = INTENT_ORDER[(INTENT_ORDER.indexOf(intentOf(r)) + 1) % INTENT_ORDER.length];
|
|
280
|
+
const updated = { ...intents, [r.id]: next };
|
|
281
|
+
setIntents(updated);
|
|
282
|
+
settingsSet({ intents: updated }, dir).catch((e) => setError(errorMessage(e)));
|
|
283
|
+
}, [dir, intents, intentOf]);
|
|
284
|
+
/** Picks a separate mic recording, registers it with source.add, then aligns it to `r`. */
|
|
285
|
+
const alignMic = useCallback(async (r) => {
|
|
286
|
+
const file = await pickFile("Choose the separate microphone recording", MIC_EXTENSIONS);
|
|
287
|
+
if (!file)
|
|
288
|
+
return;
|
|
289
|
+
const setFor = (state) => setMicState((m) => ({ ...m, [r.id]: state }));
|
|
290
|
+
setFor({ status: "importing" });
|
|
291
|
+
try {
|
|
292
|
+
const added = await engineCall("source.add", { dir, file });
|
|
293
|
+
await refresh();
|
|
294
|
+
setFor({ status: "aligning" });
|
|
295
|
+
const result = await micAlign(dir, r.id, added.source.id, {}, `ui-mic-${r.id.slice(0, 8)}`);
|
|
296
|
+
setFor({ status: "done", result });
|
|
297
|
+
await onChanged();
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
setFor({ status: "failed", message: errorMessage(e) });
|
|
301
|
+
}
|
|
302
|
+
}, [dir, refresh, onChanged]);
|
|
303
|
+
const runTranscribe = useCallback(async (r) => {
|
|
304
|
+
setError(null);
|
|
305
|
+
setTranscribing((s) => new Set(s).add(r.id));
|
|
306
|
+
try {
|
|
307
|
+
const job = `ui-tx-${r.id.slice(0, 8)}`;
|
|
308
|
+
await transcribe(dir, r.id, job, true, job);
|
|
309
|
+
await refresh();
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
setError(errorMessage(e));
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
setTranscribing((s) => {
|
|
316
|
+
const n = new Set(s);
|
|
317
|
+
n.delete(r.id);
|
|
318
|
+
return n;
|
|
319
|
+
});
|
|
320
|
+
setTxStatus(({ [r.id]: _done, ...rest }) => rest);
|
|
321
|
+
}
|
|
322
|
+
}, [dir, refresh]);
|
|
323
|
+
const totalMs = useMemo(() => rows.reduce((ms, r) => ms + ((infoOf(r.id)?.duration ?? 0) / TICKS_PER_SECOND) * 1000, 0), [rows, infoOf]);
|
|
324
|
+
const readyCount = rows.filter((r) => r.exists && checks[r.id] !== "missing" && checks[r.id] !== "changed").length;
|
|
325
|
+
const openRow = rows.find((r) => r.id === open) ?? null;
|
|
326
|
+
return (_jsxs(PageShell, { title: "Media", actions: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "secondary", onClick: () => void importMedia(), children: [_jsx(Icon, { name: "upload" }), "Import\u2026"] }), _jsxs(Button, { variant: "primary", disabled: readyCount === 0, title: readyCount === 0 ? "Import at least one source first" : undefined, onClick: () => onNavigate?.("recipe"), children: [_jsx(Icon, { name: "wand-sparkles" }), "Make finished edit"] })] }), maxWidth: 0, subtitle: rows.length ? `${rows.length} clips · ${timecode(totalMs)} total` : undefined, children: [dropActive && (_jsx("div", { className: "media-drop", "aria-hidden": true, children: _jsx("span", { children: "Drop to add" }) })), error && (_jsx("p", { role: "alert", style: { margin: 0, color: "var(--rk-bad)", fontSize: 13 }, children: error })), importing > 0 && _jsx(ProgressRow, { label: "Importing", status: "working", fraction: undefined }), rows.length === 0 && importing === 0 ? (_jsx(EmptyState, { title: "No media yet", action: _jsxs(Button, { variant: "primary", onClick: () => void importMedia(), children: [_jsx(Icon, { name: "upload" }), "Import\u2026"] }), icon: _jsx(Icon, { name: "film", width: 28, height: 28 }), children: "Drop clips here or import them." })) : (_jsxs("div", { className: "media-grid", children: [rows.map((r, i) => {
|
|
327
|
+
const busy = busyOf(r, i);
|
|
328
|
+
const check = checks[r.id];
|
|
329
|
+
const missing = check === "missing" || !r.exists;
|
|
330
|
+
const changed = check === "changed";
|
|
331
|
+
const info = infoOf(r.id);
|
|
332
|
+
const probe = probes[r.id];
|
|
333
|
+
const fps = probe?.fps ? Math.round(probe.fps.num / probe.fps.den) : null;
|
|
334
|
+
const dims = probe
|
|
335
|
+
? `${probe.width}×${probe.height}`
|
|
336
|
+
: info?.video
|
|
337
|
+
? `${info.video.width}×${info.video.height}`
|
|
338
|
+
: null;
|
|
339
|
+
const durMs = probe?.duration_ms ?? ((info?.duration ?? 0) / TICKS_PER_SECOND) * 1000;
|
|
340
|
+
const intent = intentOf(r);
|
|
341
|
+
return (_jsxs(Card, { onClick: () => setOpen(r.id), label: r.name || baseName(r.path), className: "media-card", children: [_jsx(Thumbnail, { src: missing || changed ? undefined : thumbSrc[r.id], badge: durMs ? timecode(durMs) : undefined, video: !missing && !changed && r.exists ? assetUrl(r.path) : undefined, corner: missing || changed ? (_jsx("span", { className: "media-card__missing", children: _jsx(Icon, { name: "triangle-alert", width: 28, height: 28 }) })) : undefined }), _jsx("span", { className: "media-card__name", title: r.path, children: r.name || baseName(r.path) }), _jsx("span", { className: "media-card__meta", children: [dims, fps ? `${fps} fps` : null, info?.has_audio === false ? "No audio" : "Audio"]
|
|
342
|
+
.filter(Boolean)
|
|
343
|
+
.join(" · ") }), busy ? (_jsx(ProgressRow, { label: busy === "hashing"
|
|
344
|
+
? "Hashing"
|
|
345
|
+
: busy === "transcribing"
|
|
346
|
+
? (txStatus[r.id]?.message ?? "Transcribing")
|
|
347
|
+
: "Probing", status: "working", fraction: busy === "transcribing" ? txStatus[r.id]?.fraction : undefined })) : (_jsxs("span", { className: "media-card__chips", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: [missing ? (_jsxs(Badge, { tone: "warn", children: [_jsx(Icon, { name: "triangle-alert", width: 12, height: 12 }), "Missing"] })) : changed ? (_jsxs(Badge, { tone: "warn", children: [_jsx(Icon, { name: "triangle-alert", width: 12, height: 12 }), "Changed on disk"] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", className: "cr-chip-btn", onClick: () => cycleIntent(r), title: "Detected type — click to change", children: _jsx(Badge, { tone: intent === "talking-head" ? "ok" : intent === "mixed" ? "accent" : "neutral", children: INTENT_LABEL[intent] }) }), _jsx(TranscriptChip, { row: r, speechReady: speechReady })] })), (missing || changed) && (_jsx(Button, { variant: "ghost", size: "sm", className: "cr-chip-btn", onClick: () => void relink(r), title: "Find the file again", children: _jsxs(Badge, { children: [_jsx(Icon, { name: "link", width: 12, height: 12 }), "Relink\u2026"] }) }))] }))] }, r.id));
|
|
348
|
+
}), Object.entries(addProgress).map(([file, p]) => (_jsxs(Card, { className: "media-card", label: baseName(file), children: [_jsx(Thumbnail, {}), _jsx("span", { className: "media-card__name", title: file, children: baseName(file) }), _jsx(ProgressRow, { label: p.phase === "hashing"
|
|
349
|
+
? "Hashing"
|
|
350
|
+
: p.phase === "probing"
|
|
351
|
+
? "Probing"
|
|
352
|
+
: p.phase === "copying"
|
|
353
|
+
? "Copying"
|
|
354
|
+
: "Adding", status: "working", fraction: p.fraction })] }, `add-${file}`)))] })), openRow != null && (_jsx(SourceDrawer, { dir: dir, row: openRow, probe: probes[openRow.id], info: infoOf(openRow.id), speechReady: speechReady, onClose: () => setOpen(null), onTranscribe: () => void runTranscribe(openRow), mic: micState[openRow.id], onAlignMic: () => void alignMic(openRow) }))] }));
|
|
355
|
+
}
|
|
356
|
+
function TranscriptChip({ row, speechReady }) {
|
|
357
|
+
if (row.has_transcript)
|
|
358
|
+
return _jsx(Badge, { tone: "ok", children: "Transcribed" });
|
|
359
|
+
if (speechReady === false)
|
|
360
|
+
return _jsx(Badge, { tone: "warn", children: "Speech model unavailable" });
|
|
361
|
+
return _jsx(Badge, { tone: "neutral", children: "Not transcribed" });
|
|
362
|
+
}
|
|
363
|
+
function SourceDrawer({ dir, row, probe, info, speechReady, onClose, onTranscribe, mic, onAlignMic, }) {
|
|
364
|
+
const micBusy = mic?.status === "importing" || mic?.status === "aligning";
|
|
365
|
+
const playbackId = useMediaPlaybackId(row.exists ? row.path : null);
|
|
366
|
+
const [excerpt, setExcerpt] = useState(null);
|
|
367
|
+
const [copied, setCopied] = useState(false);
|
|
368
|
+
const durMs = probe?.duration_ms ?? ((info?.duration ?? 0) / TICKS_PER_SECOND) * 1000;
|
|
369
|
+
const fps = probe?.fps ? probe.fps.num / probe.fps.den : null;
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (!row.has_transcript)
|
|
372
|
+
return;
|
|
373
|
+
transcriptShow(dir, row.id)
|
|
374
|
+
.then((t) => setExcerpt(t.transcript.words
|
|
375
|
+
.slice(0, 60)
|
|
376
|
+
.map((w) => w.text)
|
|
377
|
+
.join(" ")))
|
|
378
|
+
.catch(() => setExcerpt(null));
|
|
379
|
+
}, [dir, row]);
|
|
380
|
+
const copyHash = () => {
|
|
381
|
+
void navigator.clipboard?.writeText(row.id).then(() => {
|
|
382
|
+
setCopied(true);
|
|
383
|
+
setTimeout(() => setCopied(false), 1500);
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
const meta = [
|
|
387
|
+
["Duration", timecode(durMs)],
|
|
388
|
+
[
|
|
389
|
+
"Resolution",
|
|
390
|
+
probe ? `${probe.width}×${probe.height}` : info?.video ? `${info.video.width}×${info.video.height}` : "—",
|
|
391
|
+
],
|
|
392
|
+
["Frame rate", fps ? `${fps.toFixed(fps % 1 ? 2 : 0)} fps` : "—"],
|
|
393
|
+
["Video codec", probe?.video_codec || "—"],
|
|
394
|
+
["Audio", probe?.audio_codec ? `${probe.audio_codec}${probe.audio_channels ? ` · ${probe.audio_channels}ch` : ""}` : info?.has_audio ? "yes" : "none"],
|
|
395
|
+
["Colour space", probe?.color_space ?? "—"],
|
|
396
|
+
["Size", row.size ? fileSize(row.size) : "—"],
|
|
397
|
+
];
|
|
398
|
+
return (_jsxs(Drawer, { title: row.name || baseName(row.path), open: true, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsxs(Button, { variant: "ghost", onClick: () => void revealPath(row.path), children: [_jsx(Icon, { name: "folder-open" }), "Reveal in Finder"] }), !row.has_transcript ? (_jsxs(Button, { variant: "primary", disabled: speechReady === false, title: speechReady === false ? "Speech model unavailable" : undefined, onClick: onTranscribe, children: [_jsx(Icon, { name: "mic" }), "Transcribe"] })) : (_jsxs(Button, { variant: "secondary", disabled: speechReady === false, onClick: onTranscribe, children: [_jsx(Icon, { name: "rotate-ccw" }), "Re-transcribe"] })), _jsxs(Button, { variant: "secondary", disabled: micBusy || !row.exists, title: !row.exists ? "Relink this file before aligning a microphone" : undefined, onClick: onAlignMic, children: [_jsx(Icon, { name: "music" }), "Align separate microphone"] })] }), children: [row.exists ? (_jsx(MediaPlayer, { src: row.path, playbackId: playbackId, kind: mediaKind(row.path), title: row.name }, playbackId ?? "pending")) : (_jsx("p", { style: { margin: 0, color: "var(--rk-warn)", fontSize: 13 }, children: "This file is missing \u2014 relink it to play." })), _jsxs("dl", { className: "media-meta", children: [meta.map(([k, v]) => (_jsxs("span", { style: { display: "contents" }, children: [_jsx("dt", { children: k }), _jsx("dd", { children: v })] }, k))), _jsx("dt", { children: "Hash" }), _jsxs("dd", { className: "mono", title: row.id, children: [row.id.slice(0, 12), "\u2026", " ", _jsxs(Button, { size: "sm", variant: "ghost", onClick: copyHash, children: [_jsx(Icon, { name: copied ? "check" : "more-horizontal" }), copied ? "Copied" : "Copy"] })] }), _jsx("dt", { children: "Path" }), _jsx("dd", { title: row.path, style: { color: "var(--rk-ink-2)" }, children: row.path })] }), mic && _jsx(MicAlignStatus, { state: mic }), row.has_transcript ? (_jsx("p", { className: "media-excerpt", children: excerpt ?? "Loading transcript…" })) : (_jsx("p", { className: "media-excerpt", children: speechReady === false
|
|
399
|
+
? "The speech model isn't installed, so this source can't be transcribed yet."
|
|
400
|
+
: "No transcript yet — transcribe to enable word-level editing." }))] }));
|
|
401
|
+
}
|
|
402
|
+
/** Progress, failure, and the recorded offset for "Align separate microphone". */
|
|
403
|
+
function MicAlignStatus({ state }) {
|
|
404
|
+
if (state.status === "importing") {
|
|
405
|
+
return _jsx(ProgressRow, { label: "Adding microphone recording", status: "working", fraction: undefined });
|
|
406
|
+
}
|
|
407
|
+
if (state.status === "aligning") {
|
|
408
|
+
return _jsx(ProgressRow, { label: "Aligning microphone to camera", status: "working", fraction: undefined });
|
|
409
|
+
}
|
|
410
|
+
if (state.status === "failed") {
|
|
411
|
+
return (_jsxs("p", { role: "alert", style: { margin: 0, color: "var(--rk-bad)", fontSize: 13 }, children: ["Microphone alignment failed \u2014 ", state.message] }));
|
|
412
|
+
}
|
|
413
|
+
const { alignment, status } = state.result;
|
|
414
|
+
const ms = alignment.offset_ms;
|
|
415
|
+
const sign = ms < 0 ? "−" : "+";
|
|
416
|
+
return (_jsxs("p", { role: "status", style: { margin: 0, fontSize: 13, color: "var(--rk-ink-2)" }, children: ["Microphone offset ", sign, (Math.abs(ms) / 1000).toFixed(3), " s (mic time minus camera time) \u00B7 confidence", " ", alignment.confidence.toFixed(2), status === "already_attached" ? " · mapping already recorded" : ""] }));
|
|
417
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ProjectShow } from "../lib/project.js";
|
|
2
|
+
import type { ProgressState } from "../hooks/useProgress.js";
|
|
3
|
+
import { type PreviewHost } from "../editor/TimelineEditorView.js";
|
|
4
|
+
/**
|
|
5
|
+
* Finished edit screen (docs/design/app-ui.md §3.4). Thin host: the whole
|
|
6
|
+
* screen — stage, transport, side tabs, timeline — lives in
|
|
7
|
+
* `TimelineEditorView`; render/export controls live in the Exports tab.
|
|
8
|
+
* `project.show` stays the single source of truth; editing goes through the
|
|
9
|
+
* canonical Rust timeline.
|
|
10
|
+
*/
|
|
11
|
+
export declare function TimelineView({ show, progress, previewHost, onChanged, }: {
|
|
12
|
+
show: ProjectShow;
|
|
13
|
+
progress: ProgressState;
|
|
14
|
+
outputPath: string | null;
|
|
15
|
+
rendering: boolean;
|
|
16
|
+
/** Optional revision-bound preview adapter (native compositor / frames). */
|
|
17
|
+
previewHost?: PreviewHost | null;
|
|
18
|
+
onChanged: () => Promise<void>;
|
|
19
|
+
onRender: (preset: "youtube" | "reels") => void;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { TimelineEditorView } from "../editor/TimelineEditorView.js";
|
|
3
|
+
/**
|
|
4
|
+
* Finished edit screen (docs/design/app-ui.md §3.4). Thin host: the whole
|
|
5
|
+
* screen — stage, transport, side tabs, timeline — lives in
|
|
6
|
+
* `TimelineEditorView`; render/export controls live in the Exports tab.
|
|
7
|
+
* `project.show` stays the single source of truth; editing goes through the
|
|
8
|
+
* canonical Rust timeline.
|
|
9
|
+
*/
|
|
10
|
+
export function TimelineView({ show, progress, previewHost, onChanged, }) {
|
|
11
|
+
return (_jsx(TimelineEditorView, { show: show, previewHost: previewHost ?? null, onRefresh: onChanged, busy: progress.running }));
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/video-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Video editor UI: timeline, inspector, preview and project views.",
|
|
5
|
+
"license": "MIT OR Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./styles.css": "./dist/styles.css",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE-MIT",
|
|
22
|
+
"LICENSE-APACHE"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": [
|
|
25
|
+
"*.css"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@rightkit/app-update": "^0.4.2",
|
|
29
|
+
"@rightkit/legal": "^0.6.3",
|
|
30
|
+
"@rightkit/legal-ui": "^0.4.1",
|
|
31
|
+
"@rightkit/license": "^0.3.0",
|
|
32
|
+
"@rightkit/logs": "^0.1.6",
|
|
33
|
+
"@rightkit/media-ui": "^0.3.4",
|
|
34
|
+
"@rightkit/onboarding": "^0.6.3",
|
|
35
|
+
"@rightkit/platform-ui": "^0.4.0",
|
|
36
|
+
"@rightkit/react-shell": "^0.7.2",
|
|
37
|
+
"@rightkit/settings-ui": "^0.7.0",
|
|
38
|
+
"@rightkit/tauri": "^0.3.0",
|
|
39
|
+
"@rightkit/tauri-shell": "^0.1.3",
|
|
40
|
+
"@rightkit/theme": "^0.3.1",
|
|
41
|
+
"@tauri-apps/api": "^2.12.4",
|
|
42
|
+
"@tauri-apps/plugin-dialog": "^2.8.3",
|
|
43
|
+
"@tauri-apps/plugin-process": "^2.4.0",
|
|
44
|
+
"@tauri-apps/plugin-updater": "^2.14.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18 <21"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.6.6",
|
|
51
|
+
"@types/react": "^19.3.0",
|
|
52
|
+
"@types/react-dom": "^19.3.0",
|
|
53
|
+
"react": "^19.3.1",
|
|
54
|
+
"react-dom": "^19.3.1",
|
|
55
|
+
"typescript": "^7.0.2",
|
|
56
|
+
"vite": "^8.3.4",
|
|
57
|
+
"vitest": "^5.0.5"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"registry": "https://registry.npmjs.org",
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/bogusyogi/rightkit.git",
|
|
66
|
+
"directory": "packages/video-ui"
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
|
70
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
71
|
+
"test": "pnpm --filter \"@rightkit/video-ui^...\" run build && vitest run"
|
|
72
|
+
}
|
|
6
73
|
}
|