@odori/cli 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-STVORYOF.js → chunk-TDM65HRW.js} +1575 -1074
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +52 -4
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-ADKSTGDR.js → registry-snapshot-TKH2KAC3.js} +362 -25
- package/package.json +3 -3
- package/src/audio-mix.ts +6 -1
- package/src/cli.ts +53 -4
- package/src/commands/bed.ts +224 -0
- package/src/commands/dev.ts +80 -0
- package/src/commands/doctor.ts +20 -0
- package/src/commands/exportVideo.ts +15 -0
- package/src/commands/integrations.ts +26 -0
- package/src/commands/narrate.ts +74 -0
- package/src/commands/test.ts +55 -16
- package/src/config.ts +5 -0
- package/src/jobs.ts +1 -1
- package/src/keystore.ts +76 -0
- package/src/providers.ts +173 -0
- package/src/registry-snapshot.json +362 -25
- package/src/render.ts +69 -22
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +1 -1
- package/studio/src/components/GenerateBed.tsx +148 -0
- package/studio/src/components/Settings.tsx +266 -50
- package/studio/src/components/ui.tsx +11 -1
- package/studio/src/integrations.ts +29 -0
- package/studio/src/studio.css +276 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
|
@@ -2,15 +2,24 @@ import {useEffect, useRef, useState} from "react";
|
|
|
2
2
|
import {Button, Icon} from "./ui";
|
|
3
3
|
import {useStartSound} from "../settings";
|
|
4
4
|
import {useTheme, type Theme} from "../theme";
|
|
5
|
+
import {INTEGRATIONS_CHANGED, loadProviders, type ProviderStatus} from "../integrations";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The workspace preferences, behind one control in the header.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* A dialog with pages rather than a popover: the menu was right when the
|
|
11
|
+
* choices were two radio groups, and wrong the moment a page's worth of
|
|
12
|
+
* audio configuration — the start-sound default and the provider keys —
|
|
13
|
+
* had to fold into a strip beside the gear. A dialog gives each area a page
|
|
14
|
+
* and room to grow one setting at a time, the way every tool this one sits
|
|
15
|
+
* beside does it.
|
|
16
|
+
*
|
|
17
|
+
* Provider keys live here because a key is configuration of the machine,
|
|
18
|
+
* not material of the project. The form is one-way about secrets: a key can
|
|
19
|
+
* be typed and sent to the local server, but it never comes back — status
|
|
20
|
+
* is a source ("environment", "stored") and nothing else. A key from the
|
|
21
|
+
* environment wins over a stored one and cannot be edited here, because
|
|
22
|
+
* the place to change an environment is the environment.
|
|
14
23
|
*/
|
|
15
24
|
const THEMES: Array<{id: Theme; label: string; hint: string}> = [
|
|
16
25
|
{id: "system", label: "System", hint: "Follow the operating system"},
|
|
@@ -23,87 +32,294 @@ const SOUNDS = [
|
|
|
23
32
|
{id: "off" as const, label: "Off", hint: "Open every video muted"},
|
|
24
33
|
];
|
|
25
34
|
|
|
35
|
+
const PAGES = [
|
|
36
|
+
{id: "appearance" as const, label: "Appearance"},
|
|
37
|
+
{id: "audio" as const, label: "Audio"},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
type Page = (typeof PAGES)[number]["id"];
|
|
41
|
+
|
|
26
42
|
export const Settings = () => {
|
|
27
43
|
const [open, setOpen] = useState(false);
|
|
28
|
-
const {theme, setTheme} = useTheme();
|
|
29
|
-
const [sound, setSound] = useStartSound();
|
|
30
|
-
const wrapper = useRef<HTMLDivElement>(null);
|
|
31
44
|
const trigger = useRef<HTMLButtonElement>(null);
|
|
32
45
|
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (!open) return undefined;
|
|
35
|
-
const onPointerDown = (event: PointerEvent) => {
|
|
36
|
-
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
|
|
37
|
-
};
|
|
38
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
39
|
-
if (event.key !== "Escape") return;
|
|
40
|
-
setOpen(false);
|
|
41
|
-
// Closing with the keyboard has to put the focus somewhere, and the
|
|
42
|
-
// control that opened the menu is the only place that is not a surprise.
|
|
43
|
-
trigger.current?.focus();
|
|
44
|
-
};
|
|
45
|
-
// Capture, so a click that also does something else still closes the menu.
|
|
46
|
-
document.addEventListener("pointerdown", onPointerDown, true);
|
|
47
|
-
document.addEventListener("keydown", onKeyDown);
|
|
48
|
-
return () => {
|
|
49
|
-
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
50
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
51
|
-
};
|
|
52
|
-
}, [open]);
|
|
53
|
-
|
|
54
46
|
return (
|
|
55
|
-
<div className="settings"
|
|
47
|
+
<div className="settings">
|
|
56
48
|
<Button
|
|
57
49
|
ref={trigger}
|
|
58
50
|
icon
|
|
59
51
|
active={open}
|
|
60
52
|
aria-label="Settings"
|
|
61
53
|
aria-expanded={open}
|
|
62
|
-
aria-haspopup="
|
|
54
|
+
aria-haspopup="dialog"
|
|
63
55
|
title="Settings"
|
|
64
|
-
onClick={() => setOpen(
|
|
56
|
+
onClick={() => setOpen(true)}
|
|
65
57
|
>
|
|
66
58
|
<Icon name="settings" />
|
|
67
59
|
</Button>
|
|
68
60
|
|
|
69
61
|
{open ? (
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
<SettingsDialog
|
|
63
|
+
onClose={() => {
|
|
64
|
+
setOpen(false);
|
|
65
|
+
// Closing has to put the focus somewhere, and the control that
|
|
66
|
+
// opened the dialog is the only place that is not a surprise.
|
|
67
|
+
trigger.current?.focus();
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
) : null}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const SettingsDialog = ({onClose}: {onClose: () => void}) => {
|
|
76
|
+
const [page, setPage] = useState<Page>("appearance");
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
80
|
+
if (event.key === "Escape") onClose();
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener("keydown", onKeyDown);
|
|
83
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
84
|
+
}, [onClose]);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="overlay" onPointerDown={onClose}>
|
|
88
|
+
<div
|
|
89
|
+
className="settings-dialog"
|
|
90
|
+
role="dialog"
|
|
91
|
+
aria-modal="true"
|
|
92
|
+
aria-label="Settings"
|
|
93
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
94
|
+
>
|
|
95
|
+
<nav className="settings-nav" aria-label="Settings pages">
|
|
96
|
+
<p className="settings-nav-title">Settings</p>
|
|
97
|
+
{PAGES.map((item) => (
|
|
73
98
|
<button
|
|
74
|
-
key={
|
|
99
|
+
key={item.id}
|
|
75
100
|
type="button"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
onClick={() => setTheme(option.id)}
|
|
101
|
+
className="settings-nav-item"
|
|
102
|
+
data-selected={page === item.id ? "true" : undefined}
|
|
103
|
+
aria-current={page === item.id ? "page" : undefined}
|
|
104
|
+
onClick={() => setPage(item.id)}
|
|
81
105
|
>
|
|
82
|
-
|
|
83
|
-
<span className="menu-hint">{option.hint}</span>
|
|
106
|
+
{item.label}
|
|
84
107
|
</button>
|
|
85
108
|
))}
|
|
109
|
+
</nav>
|
|
86
110
|
|
|
87
|
-
|
|
111
|
+
<div className="settings-body">
|
|
112
|
+
<div className="settings-body-head">
|
|
113
|
+
<h2 className="settings-body-title">{PAGES.find((item) => item.id === page)?.label}</h2>
|
|
114
|
+
<Button icon aria-label="Close settings" title="Close" onClick={onClose}>
|
|
115
|
+
<Icon name="close" />
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
{page === "appearance" ? <AppearancePage /> : null}
|
|
119
|
+
{page === "audio" ? <AudioPage /> : null}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const AppearancePage = () => {
|
|
127
|
+
const {theme, setTheme} = useTheme();
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<section className="settings-group" aria-label="Theme">
|
|
131
|
+
<p className="settings-group-title">Theme</p>
|
|
132
|
+
<div className="settings-options" role="radiogroup" aria-label="Theme">
|
|
133
|
+
{THEMES.map((option) => (
|
|
134
|
+
<button
|
|
135
|
+
key={option.id}
|
|
136
|
+
type="button"
|
|
137
|
+
role="radio"
|
|
138
|
+
aria-checked={theme === option.id}
|
|
139
|
+
className="settings-option"
|
|
140
|
+
data-selected={theme === option.id ? "true" : undefined}
|
|
141
|
+
onClick={() => setTheme(option.id)}
|
|
142
|
+
>
|
|
143
|
+
<span>{option.label}</span>
|
|
144
|
+
<span className="settings-option-hint">{option.hint}</span>
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</section>
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const AudioPage = () => {
|
|
153
|
+
const [sound, setSound] = useStartSound();
|
|
154
|
+
const [providers, setProviders] = useState<ProviderStatus[] | null>(null);
|
|
155
|
+
|
|
156
|
+
/* Fetched when the page shows rather than with Studio: the status can
|
|
157
|
+
change underneath a long session (a key exported in another shell), and
|
|
158
|
+
opening settings is the moment it has to be right. */
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
loadProviders()
|
|
161
|
+
.then(setProviders)
|
|
162
|
+
.catch(() => setProviders(null));
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<section className="settings-group" aria-label="Sound">
|
|
168
|
+
<p className="settings-group-title">Sound</p>
|
|
169
|
+
<div className="settings-options" role="radiogroup" aria-label="Sound">
|
|
88
170
|
{SOUNDS.map((option) => (
|
|
89
171
|
<button
|
|
90
172
|
key={option.id}
|
|
91
173
|
type="button"
|
|
92
|
-
role="
|
|
174
|
+
role="radio"
|
|
93
175
|
aria-checked={sound === option.id}
|
|
94
|
-
className="
|
|
176
|
+
className="settings-option"
|
|
95
177
|
data-selected={sound === option.id ? "true" : undefined}
|
|
96
178
|
onClick={() => setSound(option.id)}
|
|
97
179
|
>
|
|
98
180
|
<span>{option.label}</span>
|
|
99
|
-
<span className="
|
|
181
|
+
<span className="settings-option-hint">{option.hint}</span>
|
|
100
182
|
</button>
|
|
101
183
|
))}
|
|
102
|
-
{/* Changing it now would mute or unmute the video being watched,
|
|
103
|
-
which is not what a default is. */}
|
|
104
|
-
<p className="menu-note">Applies to the next video you open.</p>
|
|
105
184
|
</div>
|
|
185
|
+
{/* Changing it now would mute or unmute the video being watched,
|
|
186
|
+
which is not what a default is. */}
|
|
187
|
+
<p className="settings-note">Applies to the next video you open.</p>
|
|
188
|
+
</section>
|
|
189
|
+
|
|
190
|
+
<section className="settings-group" aria-label="Integrations">
|
|
191
|
+
<p className="settings-group-title">Integrations</p>
|
|
192
|
+
{(providers ?? []).map((provider) => (
|
|
193
|
+
<ProviderKeyRow
|
|
194
|
+
key={provider.name}
|
|
195
|
+
provider={provider}
|
|
196
|
+
onChanged={async () => {
|
|
197
|
+
setProviders(await loadProviders());
|
|
198
|
+
window.dispatchEvent(new Event(INTEGRATIONS_CHANGED));
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</section>
|
|
203
|
+
</>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const ProviderKeyRow = ({provider, onChanged}: {provider: ProviderStatus; onChanged: () => Promise<void>}) => {
|
|
208
|
+
const [editing, setEditing] = useState(false);
|
|
209
|
+
const [draft, setDraft] = useState("");
|
|
210
|
+
const [busy, setBusy] = useState(false);
|
|
211
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
212
|
+
|
|
213
|
+
const submit = async (key: string) => {
|
|
214
|
+
setBusy(true);
|
|
215
|
+
setMessage(null);
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch("/__odori/integrations", {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: {"content-type": "application/json"},
|
|
220
|
+
body: JSON.stringify({provider: provider.name, key}),
|
|
221
|
+
});
|
|
222
|
+
const body = (await response.json()) as {error?: string; verified?: boolean | null};
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
setMessage(body.error ?? "The key was not accepted.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
setDraft("");
|
|
228
|
+
setEditing(false);
|
|
229
|
+
setMessage(
|
|
230
|
+
key === ""
|
|
231
|
+
? "Key removed."
|
|
232
|
+
: body.verified
|
|
233
|
+
? "Checked against the provider and stored on this machine."
|
|
234
|
+
: "Stored. The provider could not be reached to check it, so the first generation will tell.",
|
|
235
|
+
);
|
|
236
|
+
await onChanged();
|
|
237
|
+
} catch {
|
|
238
|
+
setMessage("Studio could not reach its own server.");
|
|
239
|
+
} finally {
|
|
240
|
+
setBusy(false);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const connected = provider.source !== null;
|
|
245
|
+
const fromEnvironment = provider.source === "environment";
|
|
246
|
+
const status = fromEnvironment
|
|
247
|
+
? `Connected from ${provider.keyVariable}`
|
|
248
|
+
: connected
|
|
249
|
+
? "Connected"
|
|
250
|
+
: "Not connected";
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div className="settings-provider">
|
|
254
|
+
<div className="settings-provider-head">
|
|
255
|
+
<div className="settings-provider-id">
|
|
256
|
+
<span className="settings-provider-name">{provider.title}</span>
|
|
257
|
+
<span className="settings-provider-sub" data-tone={connected ? "ok" : undefined}>
|
|
258
|
+
<span className="settings-provider-dot" aria-hidden="true" />
|
|
259
|
+
{status}
|
|
260
|
+
{" · "}
|
|
261
|
+
<a href={provider.docsUrl} target="_blank" rel="noreferrer">
|
|
262
|
+
docs
|
|
263
|
+
</a>
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
{/* One action at a time. The environment case has none: the place to
|
|
267
|
+
change an environment is the environment. */}
|
|
268
|
+
{fromEnvironment ? null : connected ? (
|
|
269
|
+
<Button disabled={busy} onClick={() => void submit("")}>
|
|
270
|
+
Disconnect
|
|
271
|
+
</Button>
|
|
272
|
+
) : editing ? null : (
|
|
273
|
+
<Button
|
|
274
|
+
onClick={() => {
|
|
275
|
+
setEditing(true);
|
|
276
|
+
setMessage(null);
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
Connect
|
|
280
|
+
</Button>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
{editing && !connected ? (
|
|
284
|
+
<>
|
|
285
|
+
<form
|
|
286
|
+
className="settings-provider-form"
|
|
287
|
+
onSubmit={(event) => {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
if (draft.trim()) void submit(draft.trim());
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
<input
|
|
293
|
+
className="settings-integration-input"
|
|
294
|
+
type="password"
|
|
295
|
+
autoFocus
|
|
296
|
+
autoComplete="off"
|
|
297
|
+
placeholder={provider.keyVariable}
|
|
298
|
+
value={draft}
|
|
299
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
300
|
+
disabled={busy}
|
|
301
|
+
aria-label={`${provider.title} API key`}
|
|
302
|
+
/>
|
|
303
|
+
<Button variant="primary" disabled={busy || !draft.trim()} onClick={() => void submit(draft.trim())}>
|
|
304
|
+
{busy ? "Checking…" : "Save"}
|
|
305
|
+
</Button>
|
|
306
|
+
<Button
|
|
307
|
+
disabled={busy}
|
|
308
|
+
onClick={() => {
|
|
309
|
+
setEditing(false);
|
|
310
|
+
setDraft("");
|
|
311
|
+
}}
|
|
312
|
+
>
|
|
313
|
+
Cancel
|
|
314
|
+
</Button>
|
|
315
|
+
</form>
|
|
316
|
+
<p className="settings-note">
|
|
317
|
+
Checked against the provider, then stored in <code>~/.config/odori</code> on this machine — never shown
|
|
318
|
+
again. A key in the environment wins over a stored one.
|
|
319
|
+
</p>
|
|
320
|
+
</>
|
|
106
321
|
) : null}
|
|
322
|
+
{message ? <p className="settings-note">{message}</p> : null}
|
|
107
323
|
</div>
|
|
108
324
|
);
|
|
109
325
|
};
|
|
@@ -72,7 +72,8 @@ export const Icon = ({
|
|
|
72
72
|
| "external"
|
|
73
73
|
| "sidebar"
|
|
74
74
|
| "settings"
|
|
75
|
-
| "search"
|
|
75
|
+
| "search"
|
|
76
|
+
| "close";
|
|
76
77
|
}) => {
|
|
77
78
|
const paths: Record<string, ReactNode> = {
|
|
78
79
|
play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
|
|
@@ -92,6 +93,15 @@ export const Icon = ({
|
|
|
92
93
|
<circle cx="12" cy="12" r="3" />
|
|
93
94
|
</g>
|
|
94
95
|
),
|
|
96
|
+
close: (
|
|
97
|
+
<path
|
|
98
|
+
d="M3.6 3.6l6.8 6.8M10.4 3.6l-6.8 6.8"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
strokeLinecap="round"
|
|
102
|
+
strokeWidth="1.2"
|
|
103
|
+
/>
|
|
104
|
+
),
|
|
95
105
|
sun: (
|
|
96
106
|
<>
|
|
97
107
|
<circle cx="7" cy="7" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider status, shared by the two places that show it.
|
|
3
|
+
*
|
|
4
|
+
* The settings menu owns configuration and the Integrations view owns
|
|
5
|
+
* generation, so both need to know what is connected. Status is a source and
|
|
6
|
+
* nothing else — a key can be sent to the local server, but it never comes
|
|
7
|
+
* back.
|
|
8
|
+
*/
|
|
9
|
+
export type ProviderStatus = {
|
|
10
|
+
name: string;
|
|
11
|
+
title: string;
|
|
12
|
+
kind: string;
|
|
13
|
+
keyVariable: string;
|
|
14
|
+
docsUrl: string;
|
|
15
|
+
source: "environment" | "stored" | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const loadProviders = async (): Promise<ProviderStatus[]> => {
|
|
19
|
+
const response = await fetch("/__odori/integrations");
|
|
20
|
+
if (!response.ok) throw new Error(`${response.status}`);
|
|
21
|
+
const body = (await response.json()) as {providers: ProviderStatus[]};
|
|
22
|
+
return body.providers;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fired on `window` after a key is stored or removed, so a view that gates on
|
|
27
|
+
* connection state catches up without waiting for a reload.
|
|
28
|
+
*/
|
|
29
|
+
export const INTEGRATIONS_CHANGED = "odori:integrations-changed";
|