@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,527 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, IconButton, Notice, useToast } from "@rightkit/react-shell/react";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
|
5
|
+
import { useEngine } from "../hooks/useEngine.js";
|
|
6
|
+
import { useProgress } from "../hooks/useProgress.js";
|
|
7
|
+
import { buildAssembleInput, clearPending, loadPending, newAssembleId, savePending, } from "../lib/assemble.js";
|
|
8
|
+
import { pickFolder } from "../lib/dialog.js";
|
|
9
|
+
import { engineCall, errorCode, errorMessage } from "../lib/engine.js";
|
|
10
|
+
import { SETTINGS_CHANGED, bundleImport, projectsList, rendersList, settingsGet, storageStatus } from "../lib/media.js";
|
|
11
|
+
import { baseName, projectInit, projectShow } from "../lib/project.js";
|
|
12
|
+
import { Home, loadRecent, rememberProject } from "../views/Home.js";
|
|
13
|
+
import { ProgressView } from "../views/ProgressView.js";
|
|
14
|
+
import { PromptPanel } from "../views/PromptPanel.js";
|
|
15
|
+
import { TimelineView } from "../views/TimelineView.js";
|
|
16
|
+
import { SourcesView } from "../views/SourcesView.js";
|
|
17
|
+
import { AutoEditView } from "../views/AutoEditView.js";
|
|
18
|
+
import { ShortsView } from "../views/ShortsView.js";
|
|
19
|
+
import { PhrasesView } from "../views/PhrasesView.js";
|
|
20
|
+
import { RetentionView } from "../views/RetentionView.js";
|
|
21
|
+
import { AssetsView } from "../views/AssetsView.js";
|
|
22
|
+
import { ExportsView } from "../views/ExportsView.js";
|
|
23
|
+
import { SettingsView } from "../views/SettingsView.js";
|
|
24
|
+
import { AppShell, StatusRow, useShortcuts } from "@rightkit/react-shell/react";
|
|
25
|
+
import { shellBridge } from "../lib/shell.js";
|
|
26
|
+
import { setUpdateBusy } from "../lib/services.js";
|
|
27
|
+
import { mediaScopeAllow } from "../lib/media.js";
|
|
28
|
+
import { editorShortcuts } from "../editor/keys.js";
|
|
29
|
+
import { useTimelinePreview } from "./Player.js";
|
|
30
|
+
import { Icon } from "../ui/index.js";
|
|
31
|
+
import { hostAppName } from "../appName.js";
|
|
32
|
+
/** Project tabs in nav order; ⌘1–4 select them by position. */
|
|
33
|
+
const PROJECT_TABS = [
|
|
34
|
+
{ tab: "media", label: "Media" },
|
|
35
|
+
{ tab: "recipe", label: "Edit recipe" },
|
|
36
|
+
{ tab: "finished", label: "Finished edit" },
|
|
37
|
+
{ tab: "exports", label: "Exports" },
|
|
38
|
+
];
|
|
39
|
+
/** Display name for a recent-projects row: stored project name, else the
|
|
40
|
+
* folder name de-slugged. Mirrors Home's `displayName`. */
|
|
41
|
+
export function recentLabel(dir, name) {
|
|
42
|
+
if (name)
|
|
43
|
+
return name;
|
|
44
|
+
const folder = baseName(dir).replace(/\.cutright$/, "");
|
|
45
|
+
const words = folder.replace(/[-_]+/g, " ").trim();
|
|
46
|
+
return words.length > 0 ? words[0].toUpperCase() + words.slice(1) : folder;
|
|
47
|
+
}
|
|
48
|
+
export function DesktopWorkspace() {
|
|
49
|
+
const toast = useToast();
|
|
50
|
+
const { status, doctor, doctorError, statusError } = useEngine();
|
|
51
|
+
const { progress, begin, patch, seed, reset } = useProgress();
|
|
52
|
+
const [pending, setPending] = useState(null);
|
|
53
|
+
const [resumable, setResumable] = useState(false);
|
|
54
|
+
/** The recovered run's stored render file is gone; its render step must run again. */
|
|
55
|
+
const [renderMissing, setRenderMissing] = useState(false);
|
|
56
|
+
const [dir, setDir] = useState(null);
|
|
57
|
+
const [show, setShow] = useState(null);
|
|
58
|
+
const [exportsCount, setExportsCount] = useState(0);
|
|
59
|
+
const [tab, setTab] = useState("projects");
|
|
60
|
+
// Exports ⋯ → Import retention… routes here with the render preselected (R5).
|
|
61
|
+
const [retentionPreselect, setRetentionPreselect] = useState(undefined);
|
|
62
|
+
/** R16: where dropped-footage projects are created (never the footage dir). */
|
|
63
|
+
const [projectRoot, setProjectRoot] = useState(null);
|
|
64
|
+
/** Recent rows: display names + missing-media state from `projects.list`. */
|
|
65
|
+
const [recentInfo, setRecentInfo] = useState({});
|
|
66
|
+
/** Settings section to land on (status dot → Models). */
|
|
67
|
+
const [settingsSection, setSettingsSection] = useState(undefined);
|
|
68
|
+
const [assembling, setAssembling] = useState(false);
|
|
69
|
+
const [rendering, setRendering] = useState(false);
|
|
70
|
+
const [jobError, setJobError] = useState(null);
|
|
71
|
+
const [output, setOutput] = useState(null);
|
|
72
|
+
/** Names missing on this computer after a bundle import; shown until dismissed. */
|
|
73
|
+
const [importMissing, setImportMissing] = useState(null);
|
|
74
|
+
const [importUnverified, setImportUnverified] = useState(null);
|
|
75
|
+
const [dropActive, setDropActive] = useState(false);
|
|
76
|
+
const error = statusError ?? status?.last_error ?? doctorError;
|
|
77
|
+
const previewHost = useTimelinePreview(dir, show?.revision.cursor ?? 0);
|
|
78
|
+
const [recents, setRecents] = useState([]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (status?.state !== "ready")
|
|
81
|
+
return;
|
|
82
|
+
let live = true;
|
|
83
|
+
const load = () => void loadRecent()
|
|
84
|
+
.then((dirs) => { if (live)
|
|
85
|
+
setRecents(dirs.slice(0, 5)); })
|
|
86
|
+
.catch((e) => { if (live)
|
|
87
|
+
setJobError(errorMessage(e)); });
|
|
88
|
+
load();
|
|
89
|
+
window.addEventListener(SETTINGS_CHANGED, load);
|
|
90
|
+
return () => {
|
|
91
|
+
live = false;
|
|
92
|
+
window.removeEventListener(SETTINGS_CHANGED, load);
|
|
93
|
+
};
|
|
94
|
+
}, [dir, tab === "projects", status?.state]);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
setUpdateBusy(dir !== null || assembling || rendering || progress.running);
|
|
97
|
+
return () => setUpdateBusy(false);
|
|
98
|
+
}, [dir, assembling, rendering, progress.running]);
|
|
99
|
+
// Dynamic asset scope: project dir + its renders/media dirs. The scope glob skips
|
|
100
|
+
// dot-directories unless named, so the thumbnail cache (.cache/thumbs) is listed.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!dir)
|
|
103
|
+
return;
|
|
104
|
+
void mediaScopeAllow([dir, `${dir}/media`, `${dir}/renders`, `${dir}/variants`, `${dir}/.cache`], true).catch(() => undefined);
|
|
105
|
+
}, [dir]);
|
|
106
|
+
// R16: resolve the drop project folder once — the configured app setting,
|
|
107
|
+
// else the engine's platform default (~/Movies/the host app).
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
let live = true;
|
|
110
|
+
(async () => {
|
|
111
|
+
try {
|
|
112
|
+
const [s, st] = await Promise.all([settingsGet(), storageStatus()]);
|
|
113
|
+
if (!live)
|
|
114
|
+
return;
|
|
115
|
+
const configured = s.app.project_root;
|
|
116
|
+
setProjectRoot(typeof configured === "string" && configured.trim()
|
|
117
|
+
? configured
|
|
118
|
+
: st.default_project_root ?? null);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
/* engine still starting; drops are ignored until this resolves */
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
return () => {
|
|
125
|
+
live = false;
|
|
126
|
+
};
|
|
127
|
+
}, []);
|
|
128
|
+
// Recent-project rows show the stored name and a warn dot for missing media.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (recents.length === 0)
|
|
131
|
+
return;
|
|
132
|
+
let live = true;
|
|
133
|
+
projectsList(recents)
|
|
134
|
+
.then((r) => {
|
|
135
|
+
if (!live)
|
|
136
|
+
return;
|
|
137
|
+
const info = {};
|
|
138
|
+
for (const dir of recents)
|
|
139
|
+
info[dir] = { missing: true };
|
|
140
|
+
for (const p of r.projects) {
|
|
141
|
+
info[p.dir] = {
|
|
142
|
+
name: p.name,
|
|
143
|
+
missing: (p.missing_sources ?? 0) > 0 || p.status === "missing_media",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
setRecentInfo(info);
|
|
147
|
+
})
|
|
148
|
+
.catch(() => undefined);
|
|
149
|
+
return () => {
|
|
150
|
+
live = false;
|
|
151
|
+
};
|
|
152
|
+
}, [recents]);
|
|
153
|
+
const refresh = useCallback(async () => {
|
|
154
|
+
if (!dir)
|
|
155
|
+
return;
|
|
156
|
+
setShow(await projectShow(dir));
|
|
157
|
+
rendersList(dir)
|
|
158
|
+
.then((r) => setExportsCount(r.renders.filter((x) => x.name.endsWith(".mp4")).length))
|
|
159
|
+
.catch(() => undefined);
|
|
160
|
+
}, [dir]);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!dir)
|
|
163
|
+
return;
|
|
164
|
+
let cancelled = false;
|
|
165
|
+
projectShow(dir)
|
|
166
|
+
.then((s) => !cancelled && setShow(s))
|
|
167
|
+
.catch((e) => !cancelled && setJobError(errorMessage(e)));
|
|
168
|
+
rendersList(dir)
|
|
169
|
+
.then((r) => !cancelled && setExportsCount(r.renders.filter((x) => x.name.endsWith(".mp4")).length))
|
|
170
|
+
.catch(() => undefined);
|
|
171
|
+
return () => {
|
|
172
|
+
cancelled = true;
|
|
173
|
+
};
|
|
174
|
+
}, [dir]);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (progress.output)
|
|
177
|
+
setOutput(progress.output);
|
|
178
|
+
if (progress.done || progress.error)
|
|
179
|
+
void refresh().catch(() => undefined);
|
|
180
|
+
}, [progress.output, progress.done, progress.error, refresh]);
|
|
181
|
+
const open = useCallback((next, firstTab = "media") => {
|
|
182
|
+
setShow(null);
|
|
183
|
+
setOutput(null);
|
|
184
|
+
setJobError(null);
|
|
185
|
+
// importBundle calls open before it sets its own notice, so the notice it shows survives this clear.
|
|
186
|
+
setImportMissing(null);
|
|
187
|
+
setImportUnverified(null);
|
|
188
|
+
reset();
|
|
189
|
+
setTab(firstTab);
|
|
190
|
+
setPending(null);
|
|
191
|
+
setResumable(false);
|
|
192
|
+
setRenderMissing(false);
|
|
193
|
+
setDir(next);
|
|
194
|
+
void rememberProject(next).catch((e) => toast(errorMessage(e), { kind: "error", ttl: 0 }));
|
|
195
|
+
}, [reset, toast]);
|
|
196
|
+
const closeProject = useCallback(() => {
|
|
197
|
+
setDir(null);
|
|
198
|
+
setShow(null);
|
|
199
|
+
setImportMissing(null);
|
|
200
|
+
setImportUnverified(null);
|
|
201
|
+
setTab("projects");
|
|
202
|
+
}, []);
|
|
203
|
+
const createProject = useCallback(async () => {
|
|
204
|
+
const dir = await pickFolder("Choose or create a folder for the new project");
|
|
205
|
+
if (!dir)
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
await projectInit(dir, baseName(dir));
|
|
209
|
+
open(dir);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
toast(errorMessage(e), { kind: "error", ttl: 0 });
|
|
213
|
+
}
|
|
214
|
+
}, [open, toast]);
|
|
215
|
+
/** Checks an existing project folder with the engine, then opens it. Shared by Open folder… and Import bundle. */
|
|
216
|
+
const openExisting = useCallback(async (path) => {
|
|
217
|
+
await projectShow(path);
|
|
218
|
+
open(path);
|
|
219
|
+
}, [open]);
|
|
220
|
+
const openFolder = useCallback(async () => {
|
|
221
|
+
const picked = await pickFolder(`Open a ${hostAppName()} project folder`);
|
|
222
|
+
if (!picked)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
await openExisting(picked);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
toast(errorMessage(e), { kind: "error", ttl: 0 });
|
|
229
|
+
}
|
|
230
|
+
}, [openExisting, toast]);
|
|
231
|
+
/** Two folder picks (bundle, then new project folder), `bundle.import`, then the Open folder… path.
|
|
232
|
+
* Resolves with the error to show on Home, or null when nothing failed. */
|
|
233
|
+
const importBundle = useCallback(async () => {
|
|
234
|
+
const bundle = await pickFolder("Choose the bundle folder to import");
|
|
235
|
+
if (!bundle)
|
|
236
|
+
return null;
|
|
237
|
+
const target = await pickFolder("Choose an empty folder for the imported project");
|
|
238
|
+
if (!target)
|
|
239
|
+
return null;
|
|
240
|
+
let report;
|
|
241
|
+
try {
|
|
242
|
+
report = await bundleImport({ bundle, dir: target });
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
return errorMessage(e);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await openExisting(report.dir);
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
toast(errorMessage(e), { kind: "error", ttl: 0 });
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const missing = [
|
|
255
|
+
...report.missing_media.map((m) => m.name ?? baseName(m.path)),
|
|
256
|
+
...report.missing_assets.map((path) => baseName(path)),
|
|
257
|
+
...(report.missing_generated_sources ?? []).map((g) => baseName(g.path)),
|
|
258
|
+
];
|
|
259
|
+
const unverified = [
|
|
260
|
+
...(report.unverified_assets ?? []).map((path) => baseName(path)),
|
|
261
|
+
...(report.unverified_generated_sources ?? []).map((g) => baseName(g.path)),
|
|
262
|
+
];
|
|
263
|
+
if (unverified.length > 0)
|
|
264
|
+
setImportUnverified(unverified);
|
|
265
|
+
if (missing.length > 0)
|
|
266
|
+
setImportMissing(missing);
|
|
267
|
+
else if (unverified.length === 0)
|
|
268
|
+
toast("Bundle imported", { kind: "success" });
|
|
269
|
+
return null;
|
|
270
|
+
}, [openExisting, toast]);
|
|
271
|
+
/** §3.1 + R16: dropped media creates a project under the configured project
|
|
272
|
+
* folder (Settings › Storage › Project folder; default ~/Movies/the host app) —
|
|
273
|
+
* never inside the user's footage folder. The project is named after the
|
|
274
|
+
* first dropped file's stem (`Interview`, then `Interview 2`, …) and the
|
|
275
|
+
* originals are imported in place. */
|
|
276
|
+
const openFiles = useCallback(async (paths) => {
|
|
277
|
+
const first = paths[0];
|
|
278
|
+
if (!first || !projectRoot)
|
|
279
|
+
return;
|
|
280
|
+
const stem = baseName(first).replace(/\.[^.]+$/, "").replace(/[\\/]/g, "").trim() || "Untitled";
|
|
281
|
+
// `projects.create` picks `stem`, `stem 2`, … — never an occupied dir.
|
|
282
|
+
let created;
|
|
283
|
+
try {
|
|
284
|
+
const r = await engineCall("projects.create", { root: projectRoot, name: stem });
|
|
285
|
+
created = r.dir;
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
toast(`Could not create a project folder: ${errorMessage(e)}`, { kind: "error", ttl: 0 });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
open(created);
|
|
293
|
+
for (const file of paths) {
|
|
294
|
+
await engineCall("source.add", { dir: created, file });
|
|
295
|
+
}
|
|
296
|
+
toast(`Imported ${paths.length} file${paths.length === 1 ? "" : "s"}`, { kind: "success" });
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
toast(errorMessage(e), { kind: "error", ttl: 0 });
|
|
300
|
+
}
|
|
301
|
+
}, [open, toast, projectRoot]);
|
|
302
|
+
// File drops land on the Projects screen only; inside a project the Media
|
|
303
|
+
// screen owns its own drop handling.
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
let un;
|
|
306
|
+
void getCurrentWebview()
|
|
307
|
+
.onDragDropEvent((e) => {
|
|
308
|
+
if (dir)
|
|
309
|
+
return;
|
|
310
|
+
if (e.payload.type === "enter" || e.payload.type === "over")
|
|
311
|
+
setDropActive(true);
|
|
312
|
+
else if (e.payload.type === "leave")
|
|
313
|
+
setDropActive(false);
|
|
314
|
+
else if (e.payload.type === "drop") {
|
|
315
|
+
setDropActive(false);
|
|
316
|
+
void openFiles(e.payload.paths);
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
.then((u) => (un = u))
|
|
320
|
+
.catch(() => undefined);
|
|
321
|
+
return () => un?.();
|
|
322
|
+
}, [dir, openFiles]);
|
|
323
|
+
// Global keyboard (§2): the shell's shortcut registry owns the chords, the help overlay and dispatch.
|
|
324
|
+
// Project tabs apply only with a project open; a skipped binding leaves the key to the page.
|
|
325
|
+
useShortcuts([
|
|
326
|
+
{ id: "cr.new-project", chord: "mod+n", label: "New project", group: "Projects", run: () => void createProject() },
|
|
327
|
+
{ id: "cr.open-project", chord: "mod+o", label: "Open project folder", group: "Projects", run: () => void openFolder() },
|
|
328
|
+
{ id: "cr.settings", chord: "mod+,", label: "Settings", group: "Navigation", run: () => setTab("settings") },
|
|
329
|
+
...PROJECT_TABS.map(({ tab, label }, i) => ({
|
|
330
|
+
id: `cr.tab.${tab}`,
|
|
331
|
+
chord: `mod+${i + 1}`,
|
|
332
|
+
label: `Go to ${label}`,
|
|
333
|
+
group: "Navigation",
|
|
334
|
+
when: () => Boolean(dir),
|
|
335
|
+
run: () => setTab(tab),
|
|
336
|
+
})),
|
|
337
|
+
...editorShortcuts(),
|
|
338
|
+
]);
|
|
339
|
+
// On open: if this project has an unfinished assemble, ask the engine where it stands.
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!dir)
|
|
342
|
+
return;
|
|
343
|
+
const saved = loadPending(dir);
|
|
344
|
+
if (!saved)
|
|
345
|
+
return;
|
|
346
|
+
let cancelled = false;
|
|
347
|
+
engineCall("assemble.status", { dir, assemble_id: saved.assembleId })
|
|
348
|
+
.then((st) => {
|
|
349
|
+
if (cancelled)
|
|
350
|
+
return;
|
|
351
|
+
// A stored render whose file is gone must run again, so the run is not cleared as finished.
|
|
352
|
+
const missing = !!st.render && st.render_exists === false;
|
|
353
|
+
if (st.status === "done" && !missing) {
|
|
354
|
+
clearPending(dir);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
setPending(saved);
|
|
358
|
+
setResumable(true);
|
|
359
|
+
setRenderMissing(missing);
|
|
360
|
+
seed(st);
|
|
361
|
+
// Recovered run: land on Progress so the user can resume it.
|
|
362
|
+
setTab("jobs");
|
|
363
|
+
})
|
|
364
|
+
.catch((e) => {
|
|
365
|
+
if (cancelled)
|
|
366
|
+
return;
|
|
367
|
+
// not_found: the engine never recorded this run; the saved request can still be sent.
|
|
368
|
+
if (errorCode(e) === "not_found") {
|
|
369
|
+
setPending(saved);
|
|
370
|
+
setResumable(true);
|
|
371
|
+
setTab("jobs");
|
|
372
|
+
}
|
|
373
|
+
else
|
|
374
|
+
setJobError(errorMessage(e));
|
|
375
|
+
});
|
|
376
|
+
return () => {
|
|
377
|
+
cancelled = true;
|
|
378
|
+
};
|
|
379
|
+
}, [dir, seed]);
|
|
380
|
+
const run = async (record) => {
|
|
381
|
+
if (!dir)
|
|
382
|
+
return;
|
|
383
|
+
setAssembling(true);
|
|
384
|
+
setResumable(false);
|
|
385
|
+
setRenderMissing(false);
|
|
386
|
+
setJobError(null);
|
|
387
|
+
setTab("jobs");
|
|
388
|
+
const tag = begin("assemble", record.assembleId);
|
|
389
|
+
try {
|
|
390
|
+
const result = await engineCall("assemble", { dir, input: record.input }, tag);
|
|
391
|
+
if (result?.render?.output)
|
|
392
|
+
setOutput(result.render.output);
|
|
393
|
+
patch({ running: false, done: true });
|
|
394
|
+
clearPending(dir);
|
|
395
|
+
setPending(null);
|
|
396
|
+
await refresh();
|
|
397
|
+
setTab("finished");
|
|
398
|
+
toast("Finished edit ready", { kind: "success" });
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
const code = errorCode(e);
|
|
402
|
+
if (code === "cancelled" || code === "render_cancelled")
|
|
403
|
+
patch({ running: false, cancelled: true });
|
|
404
|
+
else {
|
|
405
|
+
setJobError(errorMessage(e));
|
|
406
|
+
patch({ running: false });
|
|
407
|
+
}
|
|
408
|
+
setResumable(true);
|
|
409
|
+
await refresh().catch(() => undefined);
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
setAssembling(false);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const submit = (form) => {
|
|
416
|
+
// The id is fixed here and persisted with the exact input: re-sending both resumes.
|
|
417
|
+
const assembleId = newAssembleId();
|
|
418
|
+
const input = buildAssembleInput(form, assembleId);
|
|
419
|
+
const record = { assembleId, input, startedAt: Date.now() };
|
|
420
|
+
if (dir)
|
|
421
|
+
savePending(dir, record);
|
|
422
|
+
setPending(record);
|
|
423
|
+
void run(record);
|
|
424
|
+
};
|
|
425
|
+
const resume = () => {
|
|
426
|
+
if (pending)
|
|
427
|
+
void run(pending);
|
|
428
|
+
};
|
|
429
|
+
const render = async (preset) => {
|
|
430
|
+
if (!dir)
|
|
431
|
+
return;
|
|
432
|
+
setRendering(true);
|
|
433
|
+
setJobError(null);
|
|
434
|
+
const tag = begin("render");
|
|
435
|
+
const job = `ui-${tag}`;
|
|
436
|
+
patch({ render: { job }, running: true });
|
|
437
|
+
try {
|
|
438
|
+
const result = await engineCall("render", { dir, preset, job }, tag);
|
|
439
|
+
if (result?.output)
|
|
440
|
+
setOutput(result.output);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
setJobError(errorMessage(e));
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
setRendering(false);
|
|
447
|
+
patch({ render: null, running: false });
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
// `assemble.cancel` also stops the assemble's in-flight render (engine side).
|
|
451
|
+
const cancel = () => {
|
|
452
|
+
const report = (e) => setJobError(errorMessage(e));
|
|
453
|
+
const aid = progress.assembleId;
|
|
454
|
+
if (aid) {
|
|
455
|
+
void engineCall("assemble.cancel", { assemble_id: aid }).catch(report);
|
|
456
|
+
}
|
|
457
|
+
else if (progress.render?.job) {
|
|
458
|
+
void engineCall("render.cancel", { job: progress.render.job }).catch(report);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
const projectName = show?.project.name ?? (dir ? baseName(dir) : undefined);
|
|
462
|
+
const mediaCount = show?.summary.sources ?? 0;
|
|
463
|
+
const projectGroups = dir
|
|
464
|
+
? [
|
|
465
|
+
{
|
|
466
|
+
title: "Edit footage",
|
|
467
|
+
items: [
|
|
468
|
+
{ id: "media", label: "Media", icon: _jsx(Icon, { name: "film" }), badge: mediaCount > 0 ? _jsx("span", { className: "ws-count", children: mediaCount }) : undefined },
|
|
469
|
+
{ id: "recipe", label: "Edit recipe", icon: _jsx(Icon, { name: "sliders-horizontal" }) },
|
|
470
|
+
{ id: "finished", label: "Finished edit", icon: _jsx(Icon, { name: "list-video" }) },
|
|
471
|
+
{ id: "exports", label: "Exports", icon: _jsx(Icon, { name: "download" }), badge: exportsCount > 0 ? _jsx("span", { className: "ws-count", children: exportsCount }) : undefined },
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
title: "Clip & repurpose",
|
|
476
|
+
items: [
|
|
477
|
+
{ id: "shorts", label: "Shorts", icon: _jsx(Icon, { name: "scissors" }) },
|
|
478
|
+
{ id: "phrases", label: "Find phrases", icon: _jsx(Icon, { name: "text-quote" }) },
|
|
479
|
+
{ id: "retention", label: "Retention", icon: _jsx(Icon, { name: "chart-line" }) },
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
title: "Create",
|
|
484
|
+
items: [
|
|
485
|
+
{ id: "assets", label: "Assets", icon: _jsx(Icon, { name: "image" }) },
|
|
486
|
+
{ id: "prompt", label: "From prompt", icon: _jsx(Icon, { name: "wand-sparkles" }) },
|
|
487
|
+
],
|
|
488
|
+
},
|
|
489
|
+
]
|
|
490
|
+
: [{ items: [{ id: "projects", label: "Projects", icon: _jsx(Icon, { name: "folder" }) }] }];
|
|
491
|
+
const groups = recents.length > 0
|
|
492
|
+
? [
|
|
493
|
+
...projectGroups,
|
|
494
|
+
{
|
|
495
|
+
title: "Recent projects",
|
|
496
|
+
items: recents.map((r) => ({
|
|
497
|
+
id: `recent:${r}`,
|
|
498
|
+
label: recentLabel(r, recentInfo[r]?.name),
|
|
499
|
+
icon: _jsx("span", { className: `ws-dot${dir === r ? " ws-dot--cur" : recentInfo[r]?.missing ? " ws-dot--missing" : ""}` }),
|
|
500
|
+
keywords: ["recent", r],
|
|
501
|
+
})),
|
|
502
|
+
},
|
|
503
|
+
]
|
|
504
|
+
: projectGroups;
|
|
505
|
+
const activeId = tab === "settings" ? "" : dir ? tab : "projects";
|
|
506
|
+
const navigate = (id) => {
|
|
507
|
+
if (id.startsWith("recent:"))
|
|
508
|
+
open(id.slice("recent:".length));
|
|
509
|
+
else if (id === "projects")
|
|
510
|
+
closeProject();
|
|
511
|
+
else
|
|
512
|
+
setTab(id);
|
|
513
|
+
};
|
|
514
|
+
const commands = [
|
|
515
|
+
{ id: "cr.new-project", label: "New project", shortcut: "cr.new-project", run: () => void createProject() },
|
|
516
|
+
{ id: "cr.open-project", label: "Open project folder…", shortcut: "cr.open-project", run: () => void openFolder() },
|
|
517
|
+
...(dir ? [{ id: "cr.close-project", label: "Close project", run: closeProject }] : []),
|
|
518
|
+
];
|
|
519
|
+
const engineTone = status?.state === "ready" ? "ok" : error ? "bad" : "warn";
|
|
520
|
+
return (_jsx(AppShell, { groups: groups, activeId: activeId, onNavigate: navigate, title: dir ? (_jsxs("span", { className: "ws-crumb", children: [_jsx("button", { type: "button", className: "ws-crumb__link", onClick: closeProject, children: "Projects" }), _jsx("span", { "aria-hidden": true, children: "/" }), _jsx("strong", { children: projectName })] })) : undefined, sidebarTop: _jsx("div", { className: "ws-side-top", children: _jsxs(Button, { variant: "secondary", onClick: () => void createProject(), className: "cr-btn--block", children: [_jsx(Icon, { name: "plus" }), "New project"] }) }), wordmark: _jsxs("span", { className: "ws-wordmark", children: ["Cut", _jsx("span", { children: "Right" })] }), statusFooter: _jsxs(_Fragment, { children: [_jsx("p", { className: "ws-foot-note", children: "Originals stay untouched \u00B7 every edit is reversible" }), _jsx("button", { type: "button", className: "ws-status", title: error ?? undefined, onClick: () => {
|
|
521
|
+
setSettingsSection("models");
|
|
522
|
+
setTab("settings");
|
|
523
|
+
}, children: _jsx(StatusRow, { tone: engineTone, label: "Engine", value: status?.state === "ready" ? "Ready" : error ? "Needs attention" : "Starting" }) })] }), onOpenSettings: () => setTab("settings"), settingsActive: tab === "settings", commands: commands, bridge: shellBridge, children: _jsxs("div", { className: "ws-plane", children: [importMissing && (_jsxs(Notice, { action: _jsx(IconButton, { label: "Dismiss", onClick: () => setImportMissing(null), children: _jsx(Icon, { name: "x" }) }), children: ["Imported. Missing on this computer: ", _jsx("strong", { children: importMissing.join(", ") })] })), importUnverified && (_jsxs(Notice, { action: _jsx(IconButton, { label: "Dismiss", onClick: () => setImportUnverified(null), children: _jsx(Icon, { name: "x" }) }), children: ["Imported. Found on this computer but could not be checked against the original:", " ", _jsx("strong", { children: importUnverified.join(", ") })] })), tab === "settings" ? (_jsx(SettingsView, { dir: dir, status: status, doctor: doctor, error: error, section: settingsSection })) : !dir ? (_jsx(Home, { ready: status?.state === "ready", dropActive: dropActive, onOpen: open, onCreate: () => void createProject(), onOpenFolder: () => void openFolder(), onImportBundle: importBundle, onFiles: (paths) => void openFiles(paths) })) : tab === "media" ? (_jsx(SourcesView, { dir: dir, onChanged: refresh, onNavigate: (to) => setTab(to === "recipe" ? "recipe" : "finished") })) : tab === "recipe" ? (_jsx(AutoEditView, { dir: dir, onChanged: refresh, onNavigate: (to) => setTab(to === "recipe" ? "recipe" : "finished") })) : tab === "finished" ? (show ? (_jsx(TimelineView, { show: show, progress: progress, outputPath: output, rendering: rendering || (progress.running && !!progress.render), previewHost: previewHost, onChanged: refresh, onRender: (p) => void render(p) })) : (_jsx(ProgressView, { progress: progress, jobError: jobError, assembling: assembling, onCancel: cancel, resumable: resumable, renderMissing: renderMissing, recovered: pending?.input.shots.map((s, i) => s.prompt || `Shot ${i + 1}`), onResume: resume }))) : tab === "exports" ? (_jsx(ExportsView, { dir: dir, progress: progress, rendering: rendering || (progress.running && !!progress.render), outputPath: output, onRender: (p) => void render(p), onChanged: refresh, onImportRetention: (render) => {
|
|
524
|
+
setRetentionPreselect(render);
|
|
525
|
+
setTab("retention");
|
|
526
|
+
} })) : tab === "shorts" ? (_jsx(ShortsView, { dir: dir, onChanged: refresh })) : tab === "phrases" ? (_jsx(PhrasesView, { dir: dir, onChanged: refresh })) : tab === "retention" ? (_jsx(RetentionView, { dir: dir, onChanged: refresh, onExportRevised: () => setTab("exports"), preselectRender: retentionPreselect })) : tab === "assets" ? (_jsx(AssetsView, { dir: dir, onNewVideo: () => setTab("prompt") })) : tab === "prompt" ? (_jsx(PromptPanel, { busy: assembling, progress: progress, onSubmit: submit, onOpenFinished: () => setTab("finished") })) : (_jsx(ProgressView, { progress: progress, jobError: jobError, assembling: assembling, onCancel: cancel, resumable: resumable, renderMissing: renderMissing, recovered: pending?.input.shots.map((s, i) => s.prompt || `Shot ${i + 1}`), onResume: resume }))] }) }));
|
|
527
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type PlaybackMeta } from "../lib/media.js";
|
|
2
|
+
/**
|
|
3
|
+
* Native playback surface: frames are decoded and composited in Rust (FFmpeg +
|
|
4
|
+
* wgpu) and delivered here as RGBA; the browser never decodes the timeline.
|
|
5
|
+
* `dir` plays the canonical project revision.
|
|
6
|
+
*/
|
|
7
|
+
export declare function NativePlayer({ dir, label, seekRequest, onStatus, onError, }: {
|
|
8
|
+
dir?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
seekRequest?: {
|
|
11
|
+
ms: number;
|
|
12
|
+
n: number;
|
|
13
|
+
} | null;
|
|
14
|
+
onStatus?: (meta: PlaybackMeta) => void;
|
|
15
|
+
onError?: (message: string) => void;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
/**
|
|
18
|
+
* Preview host for the editor: revision-bound native playback of the project
|
|
19
|
+
* timeline. `seek(revisionCursor, timeS)` scrubs to `timeS`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useTimelinePreview(dir: string | null, revision: number): {
|
|
22
|
+
seek: (_revisionCursor: number, timeS: number) => void;
|
|
23
|
+
slot: import("react").JSX.Element | null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Stage player for the Finished edit screen (§3.4): a letterboxed canvas fed
|
|
27
|
+
* by the Rust compositor playback session for the CURRENT project revision,
|
|
28
|
+
* plus the spec transport (play circle, ±1f, mono timecode, speed, loop,
|
|
29
|
+
* scrubber). While the session opens — or if playback fails — it shows the
|
|
30
|
+
* caller-provided fallback frame (`media.thumbnail`), never a black box.
|
|
31
|
+
*/
|
|
32
|
+
export declare function StagePlayer({ dir, revision, seekRequest, captionsOn, caption, aspect, fallback, onPosition, onToggleCaptions, }: {
|
|
33
|
+
dir: string;
|
|
34
|
+
/** Reopen the session when the revision changes (edit landed). */
|
|
35
|
+
revision: number;
|
|
36
|
+
seekRequest?: {
|
|
37
|
+
ms: number;
|
|
38
|
+
n: number;
|
|
39
|
+
} | null;
|
|
40
|
+
captionsOn: boolean;
|
|
41
|
+
caption?: string | null;
|
|
42
|
+
aspect?: string | null;
|
|
43
|
+
/** Asset URL of the nearest real frame to show while loading/on error. */
|
|
44
|
+
fallback?: string | null;
|
|
45
|
+
onPosition?: (ms: number) => void;
|
|
46
|
+
onToggleCaptions: (v: boolean) => void;
|
|
47
|
+
}): import("react").JSX.Element;
|
|
48
|
+
/** Shape consumed by the editor lane (`TimelineView` / `TimelineEditorView`). */
|
|
49
|
+
export type PreviewHost = ReturnType<typeof useTimelinePreview>;
|