@odori/cli 0.0.6 → 0.0.8
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-6CE7U5KB.js → chunk-UPYHTDXP.js} +1498 -1080
- 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-GV6FFZXV.js} +361 -24
- package/package.json +3 -3
- package/src/audio-mix.ts +6 -1
- package/src/cli.ts +37 -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/test.ts +95 -25
- package/src/config.ts +5 -0
- package/src/jobs.ts +1 -1
- package/src/keystore.ts +76 -0
- package/src/providers.ts +90 -0
- package/src/registry-snapshot.json +361 -24
- package/src/render.ts +74 -10
- package/studio/src/Studio.tsx +3 -1
- package/studio/src/studio.css +106 -0
- package/studio/src/views/IntegrationsView.tsx +270 -0
package/src/render.ts
CHANGED
|
@@ -35,6 +35,43 @@ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
|
|
|
35
35
|
return `${origin}/?${params.toString()}`;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Which graphics backend the render browser draws with.
|
|
40
|
+
*
|
|
41
|
+
* Not a menu of backend names. Remotion offers angle, swangle, egl, vulkan and
|
|
42
|
+
* two more, and the author has to learn ANGLE's taxonomy and then guess. The
|
|
43
|
+
* only question worth asking is whether this render is the artifact or a look
|
|
44
|
+
* at it, so that is the only question asked.
|
|
45
|
+
*
|
|
46
|
+
* `software` is ANGLE's SwiftShader: the same pixels on every machine, which
|
|
47
|
+
* is what a cache, a parallel render and a byte-for-byte test all depend on.
|
|
48
|
+
* `gpu` uses whatever the machine has.
|
|
49
|
+
*
|
|
50
|
+
* Measured on this pipeline at 1080p, 60 frames, frame skip off, and the
|
|
51
|
+
* reason both exist:
|
|
52
|
+
*
|
|
53
|
+
* five full-screen post-processing passes 86ms software 81ms gpu
|
|
54
|
+
* a raymarch that exits early on most rays 46ms software 38ms gpu
|
|
55
|
+
* 1500 fixed iterations, no early exit 507ms software 37ms gpu
|
|
56
|
+
*
|
|
57
|
+
* The GPU sits at under 40ms whatever the shader, because it is still waiting
|
|
58
|
+
* on the DOM capture and the encode rather than on itself. Software tracks the
|
|
59
|
+
* shader. So for post-processing the choice is worth nothing, and for work
|
|
60
|
+
* that is genuinely per-pixel expensive it is worth fourteen times, which is
|
|
61
|
+
* the difference between iterating and not.
|
|
62
|
+
*/
|
|
63
|
+
export type Graphics = "software" | "gpu";
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_GRAPHICS: Graphics = "software";
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* Measured rather than guessed: `--use-gl=angle --use-angle=swiftshader`, the
|
|
69
|
+
* combination that reads as obvious, leaves WebGL2 unavailable in the pinned
|
|
70
|
+
* build. These are the two that work.
|
|
71
|
+
*/
|
|
72
|
+
export const browserArgs = (graphics: Graphics = DEFAULT_GRAPHICS): string[] =>
|
|
73
|
+
graphics === "gpu" ? ["--use-gl=angle", "--use-angle=default"] : ["--enable-unsafe-swiftshader"];
|
|
74
|
+
|
|
38
75
|
export type RenderPage = {browser: Browser; page: Page; errors: string[]};
|
|
39
76
|
|
|
40
77
|
/** Open one video in render mode and wait for its first frame to mount. */
|
|
@@ -42,9 +79,10 @@ export const openRenderPage = async (
|
|
|
42
79
|
origin: string,
|
|
43
80
|
target: RenderTarget,
|
|
44
81
|
config: ResolvedConfig,
|
|
82
|
+
graphics: Graphics = DEFAULT_GRAPHICS,
|
|
45
83
|
): Promise<RenderPage> => {
|
|
46
84
|
const executablePath = await browserExecutable(config);
|
|
47
|
-
const browser = await chromium.launch({executablePath, headless: true});
|
|
85
|
+
const browser = await chromium.launch({executablePath, headless: true, args: browserArgs(graphics)});
|
|
48
86
|
const page = await browser.newPage({
|
|
49
87
|
viewport: {width: target.width, height: target.height},
|
|
50
88
|
deviceScaleFactor: 1,
|
|
@@ -64,10 +102,24 @@ export const openRenderPage = async (
|
|
|
64
102
|
return {browser, page, errors};
|
|
65
103
|
};
|
|
66
104
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Seek through the readiness handshake instead of guessing with timeouts.
|
|
107
|
+
*
|
|
108
|
+
* A frame that throws never publishes its marker, so the wait runs out and
|
|
109
|
+
* Playwright reports that a selector did not appear, which says nothing about
|
|
110
|
+
* why. The page errors gathered since the mount are the actual answer, and
|
|
111
|
+
* passing them in is the difference between "locator timed out" and the name
|
|
112
|
+
* of the function that threw. One malformed interpolate() call cost an
|
|
113
|
+
* afternoon to a message that had already been collected and thrown away.
|
|
114
|
+
*/
|
|
115
|
+
export const seekTo = async (page: Page, frame: number, errors?: string[]) => {
|
|
69
116
|
await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
|
|
70
|
-
|
|
117
|
+
try {
|
|
118
|
+
await page.locator(`[data-odori-frame="${frame}"]`).waitFor({timeout: 20000});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const reported = errors?.length ? ` ${[...new Set(errors)].join(" ")}` : "";
|
|
121
|
+
throw new Error(`Frame ${frame} never became ready.${reported}`, {cause: error});
|
|
122
|
+
}
|
|
71
123
|
};
|
|
72
124
|
|
|
73
125
|
export const readTimeline = async (page: Page) =>
|
|
@@ -223,11 +275,12 @@ export const renderStill = async (
|
|
|
223
275
|
frame: number,
|
|
224
276
|
output: string,
|
|
225
277
|
config: ResolvedConfig,
|
|
278
|
+
graphics: Graphics = DEFAULT_GRAPHICS,
|
|
226
279
|
): Promise<string> => {
|
|
227
|
-
const {browser, page, errors} = await openRenderPage(origin, target, config);
|
|
280
|
+
const {browser, page, errors} = await openRenderPage(origin, target, config, graphics);
|
|
228
281
|
try {
|
|
229
282
|
await mkdir(dirname(output), {recursive: true});
|
|
230
|
-
await seekTo(page, frame);
|
|
283
|
+
await seekTo(page, frame, errors);
|
|
231
284
|
await page.screenshot({path: output});
|
|
232
285
|
if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
|
|
233
286
|
return output;
|
|
@@ -323,6 +376,7 @@ const openChunkEncoder = (
|
|
|
323
376
|
|
|
324
377
|
type LaneOptions = {
|
|
325
378
|
skipUnchanged: boolean;
|
|
379
|
+
graphics: Graphics;
|
|
326
380
|
signal?: AbortSignal;
|
|
327
381
|
encode: EncodeOptions;
|
|
328
382
|
format: VideoFormat;
|
|
@@ -349,12 +403,12 @@ const captureLane = async (
|
|
|
349
403
|
stats: CaptureStats,
|
|
350
404
|
options: LaneOptions,
|
|
351
405
|
): Promise<void> => {
|
|
352
|
-
let session = await openRenderPage(origin, target, config);
|
|
406
|
+
let session = await openRenderPage(origin, target, config, options.graphics);
|
|
353
407
|
const errors = session.errors;
|
|
354
408
|
|
|
355
409
|
const reopen = async () => {
|
|
356
410
|
await session.browser.close().catch(() => undefined);
|
|
357
|
-
session = await openRenderPage(origin, target, config);
|
|
411
|
+
session = await openRenderPage(origin, target, config, options.graphics);
|
|
358
412
|
session.errors.push(...errors);
|
|
359
413
|
};
|
|
360
414
|
|
|
@@ -397,7 +451,7 @@ const captureLane = async (
|
|
|
397
451
|
|
|
398
452
|
for (let attempt = 0; ; attempt += 1) {
|
|
399
453
|
try {
|
|
400
|
-
await seekTo(session.page, frame);
|
|
454
|
+
await seekTo(session.page, frame, session.errors);
|
|
401
455
|
const signature = await signatureOf();
|
|
402
456
|
let image: Buffer;
|
|
403
457
|
if (options.skipUnchanged && previousFrame && signature === previousSignature) {
|
|
@@ -469,6 +523,8 @@ export type RenderOptions = {
|
|
|
469
523
|
/** Container and codec. Defaults to H.264 in MP4. */
|
|
470
524
|
format?: VideoFormat;
|
|
471
525
|
skipUnchangedFrames?: boolean;
|
|
526
|
+
/** Which graphics backend draws the frames. Defaults to software. */
|
|
527
|
+
graphics?: Graphics;
|
|
472
528
|
/**
|
|
473
529
|
* Mix the composition's cues into the file. Defaults to true, because the
|
|
474
530
|
* score is part of the video. Set false for a silent cut: a loop for a
|
|
@@ -499,7 +555,14 @@ export const renderMovie = async (
|
|
|
499
555
|
const ffmpeg = await ffmpegExecutable(config);
|
|
500
556
|
const browserPath = await browserExecutable(config);
|
|
501
557
|
// Cached frames belong to the browser that drew them.
|
|
502
|
-
|
|
558
|
+
/*
|
|
559
|
+
* The backend is part of what a frame is, so it is part of the key. Without
|
|
560
|
+
* it a --fast render would reuse chunks a software render drew, and the two
|
|
561
|
+
* are only similar, not identical: measured at 0.999993 SSIM. A video joined
|
|
562
|
+
* from both would carry a seam nothing could explain.
|
|
563
|
+
*/
|
|
564
|
+
const graphics = options.graphics ?? DEFAULT_GRAPHICS;
|
|
565
|
+
const renderer = `${(await resolveBrowser(config))?.version ?? browserPath}:${graphics}`;
|
|
503
566
|
|
|
504
567
|
const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
|
|
505
568
|
const encode: EncodeOptions = {
|
|
@@ -545,6 +608,7 @@ export const renderMovie = async (
|
|
|
545
608
|
lanes.map((lane) =>
|
|
546
609
|
captureLane(origin, target, config, lane, stats, {
|
|
547
610
|
skipUnchanged,
|
|
611
|
+
graphics,
|
|
548
612
|
signal: options.signal,
|
|
549
613
|
encode,
|
|
550
614
|
format: chunkFormat,
|
package/studio/src/Studio.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import {VideosView} from "./views/VideosView";
|
|
|
4
4
|
import {ComponentsView} from "./views/ComponentsView";
|
|
5
5
|
import {BrandsView} from "./views/BrandsView";
|
|
6
6
|
import {AssetsView} from "./views/AssetsView";
|
|
7
|
+
import {IntegrationsView} from "./views/IntegrationsView";
|
|
7
8
|
import {HomeView} from "./views/HomeView";
|
|
8
9
|
import {CommandPalette} from "./components/CommandPalette";
|
|
9
10
|
import {Wordmark} from "./components/Wordmark";
|
|
@@ -11,7 +12,7 @@ import {Button, Icon, Kbd} from "./components/ui";
|
|
|
11
12
|
import {Settings} from "./components/Settings";
|
|
12
13
|
import {useTheme} from "./theme";
|
|
13
14
|
|
|
14
|
-
const VIEWS = ["videos", "components", "brands", "assets"] as const;
|
|
15
|
+
const VIEWS = ["videos", "components", "brands", "assets", "integrations"] as const;
|
|
15
16
|
|
|
16
17
|
export type StudioView = (typeof VIEWS)[number] | "home";
|
|
17
18
|
|
|
@@ -145,6 +146,7 @@ export const Studio = () => {
|
|
|
145
146
|
<BrandsView selection={route.selection} onSelect={(name) => navigate("brands", name)} />
|
|
146
147
|
) : null}
|
|
147
148
|
{route.view === "assets" ? <AssetsView /> : null}
|
|
149
|
+
{route.view === "integrations" ? <IntegrationsView /> : null}
|
|
148
150
|
{route.view === "home" ? <HomeView onOpen={(view, selection) => navigate(view, selection)} /> : null}
|
|
149
151
|
</main>
|
|
150
152
|
|
package/studio/src/studio.css
CHANGED
|
@@ -1894,3 +1894,109 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
|
|
|
1894
1894
|
margin-left: auto;
|
|
1895
1895
|
text-transform: uppercase;
|
|
1896
1896
|
}
|
|
1897
|
+
|
|
1898
|
+
/* Integrations: one provider per row, the key one-way by design. */
|
|
1899
|
+
.integrations-view {
|
|
1900
|
+
max-width: 640px;
|
|
1901
|
+
padding: 24px;
|
|
1902
|
+
display: grid;
|
|
1903
|
+
gap: 16px;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
.integration-list {
|
|
1907
|
+
list-style: none;
|
|
1908
|
+
margin: 12px 0 0;
|
|
1909
|
+
padding: 0;
|
|
1910
|
+
display: grid;
|
|
1911
|
+
gap: 12px;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
.integration {
|
|
1915
|
+
border: 1px solid var(--border);
|
|
1916
|
+
border-radius: 8px;
|
|
1917
|
+
padding: 14px 16px;
|
|
1918
|
+
display: grid;
|
|
1919
|
+
gap: 10px;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.integration-head {
|
|
1923
|
+
display: flex;
|
|
1924
|
+
align-items: center;
|
|
1925
|
+
gap: 10px;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.integration-title {
|
|
1929
|
+
font-weight: 600;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
.integration-docs {
|
|
1933
|
+
margin-left: auto;
|
|
1934
|
+
color: var(--muted);
|
|
1935
|
+
font-size: 12px;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
.integration-form {
|
|
1939
|
+
display: flex;
|
|
1940
|
+
gap: 8px;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
.integration-input {
|
|
1944
|
+
flex: 1;
|
|
1945
|
+
min-width: 0;
|
|
1946
|
+
background: var(--field, transparent);
|
|
1947
|
+
border: 1px solid var(--border);
|
|
1948
|
+
border-radius: 6px;
|
|
1949
|
+
padding: 6px 10px;
|
|
1950
|
+
color: inherit;
|
|
1951
|
+
font: inherit;
|
|
1952
|
+
font-size: 13px;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
.integration-usage {
|
|
1956
|
+
margin: 0;
|
|
1957
|
+
font-size: 12px;
|
|
1958
|
+
color: var(--muted);
|
|
1959
|
+
overflow-x: auto;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/* Generate: prompt to registered role, the terminal's numbers rendered. */
|
|
1963
|
+
.generate-prompt {
|
|
1964
|
+
width: 100%;
|
|
1965
|
+
resize: vertical;
|
|
1966
|
+
background: var(--field, transparent);
|
|
1967
|
+
border: 1px solid var(--border);
|
|
1968
|
+
border-radius: 6px;
|
|
1969
|
+
padding: 8px 10px;
|
|
1970
|
+
color: inherit;
|
|
1971
|
+
font: inherit;
|
|
1972
|
+
font-size: 13px;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
.generate-controls {
|
|
1976
|
+
display: flex;
|
|
1977
|
+
align-items: end;
|
|
1978
|
+
gap: 10px;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
.generate-field {
|
|
1982
|
+
display: grid;
|
|
1983
|
+
gap: 4px;
|
|
1984
|
+
font-size: 11px;
|
|
1985
|
+
color: var(--muted);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
.generate-field input {
|
|
1989
|
+
background: var(--field, transparent);
|
|
1990
|
+
border: 1px solid var(--border);
|
|
1991
|
+
border-radius: 6px;
|
|
1992
|
+
padding: 5px 8px;
|
|
1993
|
+
color: inherit;
|
|
1994
|
+
font: inherit;
|
|
1995
|
+
font-size: 13px;
|
|
1996
|
+
width: 120px;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
.generate-report {
|
|
2000
|
+
display: grid;
|
|
2001
|
+
gap: 8px;
|
|
2002
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {useEffect, useState} from "react";
|
|
2
|
+
import {Badge, Button, Empty, SectionTitle} from "../components/ui";
|
|
3
|
+
|
|
4
|
+
type ProviderStatus = {
|
|
5
|
+
name: string;
|
|
6
|
+
title: string;
|
|
7
|
+
kind: string;
|
|
8
|
+
keyVariable: string;
|
|
9
|
+
docsUrl: string;
|
|
10
|
+
source: "environment" | "stored" | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where generation providers are connected.
|
|
15
|
+
*
|
|
16
|
+
* The page is deliberately one-way about secrets: a key can be typed here and
|
|
17
|
+
* sent to the local server once, but it never comes back — status is a source
|
|
18
|
+
* ("environment", "stored") and nothing else. A key from the environment wins
|
|
19
|
+
* over a stored one and cannot be edited here, because the place to change an
|
|
20
|
+
* environment is the environment.
|
|
21
|
+
*/
|
|
22
|
+
export const IntegrationsView = () => {
|
|
23
|
+
const [providers, setProviders] = useState<ProviderStatus[] | null>(null);
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
const load = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch("/__odori/integrations");
|
|
29
|
+
if (!response.ok) throw new Error(`${response.status}`);
|
|
30
|
+
const body = (await response.json()) as {providers: ProviderStatus[]};
|
|
31
|
+
setProviders(body.providers);
|
|
32
|
+
} catch {
|
|
33
|
+
setError("Studio could not reach its own server. Is odori dev still running?");
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
void load();
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
if (error) return <Empty title="Integrations unavailable">{error}</Empty>;
|
|
42
|
+
if (!providers) return <Empty title="Loading" />;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="integrations-view">
|
|
46
|
+
<section>
|
|
47
|
+
<SectionTitle>Music</SectionTitle>
|
|
48
|
+
<p className="hint">
|
|
49
|
+
A provider generates at author time only: the track lands in <code>public/audio</code>, prepared and
|
|
50
|
+
committed, and the render never calls a network. Keys live in <code>~/.config/odori</code> on this machine —
|
|
51
|
+
never in the project, and never readable from this page.
|
|
52
|
+
</p>
|
|
53
|
+
<ul className="integration-list">
|
|
54
|
+
{providers.map((provider) => (
|
|
55
|
+
<IntegrationRow key={provider.name} provider={provider} onChanged={load} />
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
</section>
|
|
59
|
+
{providers.some((provider) => provider.source !== null) ? (
|
|
60
|
+
<section>
|
|
61
|
+
<SectionTitle>Generate a bed</SectionTitle>
|
|
62
|
+
<GeneratePanel />
|
|
63
|
+
</section>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type GenerateReport = {
|
|
70
|
+
destination: string;
|
|
71
|
+
role: string;
|
|
72
|
+
url: string | null;
|
|
73
|
+
registered: {file: string; already: boolean} | null;
|
|
74
|
+
before: {lufs: number; peak: number; range: number};
|
|
75
|
+
after: {lufs: number; peak: number};
|
|
76
|
+
warnings: string[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prompt to registered role, without a terminal. The server walks the same
|
|
81
|
+
* path the CLI does — provider, prepare, register — and the numbers it prints
|
|
82
|
+
* there render here, warnings included, with the result playable in place.
|
|
83
|
+
*/
|
|
84
|
+
const GeneratePanel = () => {
|
|
85
|
+
const [prompt, setPrompt] = useState("");
|
|
86
|
+
const [seconds, setSeconds] = useState(60);
|
|
87
|
+
const [role, setRole] = useState("");
|
|
88
|
+
const [busy, setBusy] = useState(false);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [report, setReport] = useState<GenerateReport | null>(null);
|
|
91
|
+
|
|
92
|
+
const generate = async () => {
|
|
93
|
+
setBusy(true);
|
|
94
|
+
setError(null);
|
|
95
|
+
setReport(null);
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch("/__odori/generate", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {"content-type": "application/json"},
|
|
100
|
+
body: JSON.stringify({prompt: prompt.trim(), seconds, role: role.trim() || undefined}),
|
|
101
|
+
});
|
|
102
|
+
const body = (await response.json()) as GenerateReport & {error?: string};
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
setError(body.error ?? "Generation failed.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
setReport(body);
|
|
108
|
+
} catch {
|
|
109
|
+
setError("Studio could not reach its own server. Is odori dev still running?");
|
|
110
|
+
} finally {
|
|
111
|
+
setBusy(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="integration generate-panel">
|
|
117
|
+
<textarea
|
|
118
|
+
className="generate-prompt"
|
|
119
|
+
rows={2}
|
|
120
|
+
placeholder="steady ambient bed, no drums, warm"
|
|
121
|
+
value={prompt}
|
|
122
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
123
|
+
disabled={busy}
|
|
124
|
+
aria-label="Prompt"
|
|
125
|
+
/>
|
|
126
|
+
<div className="generate-controls">
|
|
127
|
+
<label className="generate-field">
|
|
128
|
+
seconds
|
|
129
|
+
<input
|
|
130
|
+
type="number"
|
|
131
|
+
min={5}
|
|
132
|
+
max={300}
|
|
133
|
+
value={seconds}
|
|
134
|
+
onChange={(event) => setSeconds(Number(event.target.value) || 60)}
|
|
135
|
+
disabled={busy}
|
|
136
|
+
/>
|
|
137
|
+
</label>
|
|
138
|
+
<label className="generate-field">
|
|
139
|
+
role
|
|
140
|
+
<input
|
|
141
|
+
type="text"
|
|
142
|
+
placeholder="bed.main"
|
|
143
|
+
value={role}
|
|
144
|
+
onChange={(event) => setRole(event.target.value)}
|
|
145
|
+
disabled={busy}
|
|
146
|
+
/>
|
|
147
|
+
</label>
|
|
148
|
+
<Button variant="primary" disabled={busy || !prompt.trim()} onClick={() => void generate()}>
|
|
149
|
+
{busy ? "Generating…" : "Generate"}
|
|
150
|
+
</Button>
|
|
151
|
+
</div>
|
|
152
|
+
{busy ? <p className="hint">The provider is composing, then the track is levelled to the stem target. A minute is normal.</p> : null}
|
|
153
|
+
{error ? <p className="hint">{error}</p> : null}
|
|
154
|
+
{report ? (
|
|
155
|
+
<div className="generate-report">
|
|
156
|
+
{report.url ? <audio controls src={report.url} style={{width: "100%"}} /> : null}
|
|
157
|
+
<p className="hint">
|
|
158
|
+
{report.destination} · {report.before.lufs.toFixed(1)} → {report.after.lufs.toFixed(1)} LUFS · range{" "}
|
|
159
|
+
{report.before.range.toFixed(1)} LU
|
|
160
|
+
{report.registered
|
|
161
|
+
? report.registered.already
|
|
162
|
+
? ` · "${report.role}" was already registered`
|
|
163
|
+
: ` · registered "${report.role}" in ${report.registered.file}`
|
|
164
|
+
: ""}
|
|
165
|
+
</p>
|
|
166
|
+
{report.warnings.map((warning) => (
|
|
167
|
+
<p key={warning} className="hint">
|
|
168
|
+
{warning}
|
|
169
|
+
</p>
|
|
170
|
+
))}
|
|
171
|
+
<p className="integration-usage">
|
|
172
|
+
<code>{`<Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`}</code>
|
|
173
|
+
</p>
|
|
174
|
+
</div>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const IntegrationRow = ({provider, onChanged}: {provider: ProviderStatus; onChanged: () => Promise<void>}) => {
|
|
181
|
+
const [draft, setDraft] = useState("");
|
|
182
|
+
const [busy, setBusy] = useState(false);
|
|
183
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
184
|
+
|
|
185
|
+
const submit = async (key: string) => {
|
|
186
|
+
setBusy(true);
|
|
187
|
+
setMessage(null);
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetch("/__odori/integrations", {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {"content-type": "application/json"},
|
|
192
|
+
body: JSON.stringify({provider: provider.name, key}),
|
|
193
|
+
});
|
|
194
|
+
const body = (await response.json()) as {error?: string; verified?: boolean | null};
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
setMessage(body.error ?? "The key was not accepted.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
setDraft("");
|
|
200
|
+
setMessage(
|
|
201
|
+
key === ""
|
|
202
|
+
? "Key removed."
|
|
203
|
+
: body.verified
|
|
204
|
+
? "Connected. The key was checked against the provider and stored on this machine."
|
|
205
|
+
: "Stored. The provider could not be reached to check it, so the first generation will tell.",
|
|
206
|
+
);
|
|
207
|
+
await onChanged();
|
|
208
|
+
} catch {
|
|
209
|
+
setMessage("Studio could not reach its own server.");
|
|
210
|
+
} finally {
|
|
211
|
+
setBusy(false);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const connected = provider.source !== null;
|
|
216
|
+
const fromEnvironment = provider.source === "environment";
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<li className="integration">
|
|
220
|
+
<div className="integration-head">
|
|
221
|
+
<span className="integration-title">{provider.title}</span>
|
|
222
|
+
{connected ? (
|
|
223
|
+
<Badge tone="success">{fromEnvironment ? `connected · ${provider.keyVariable}` : "connected"}</Badge>
|
|
224
|
+
) : (
|
|
225
|
+
<Badge tone="warning">not configured</Badge>
|
|
226
|
+
)}
|
|
227
|
+
<a className="integration-docs" href={provider.docsUrl} target="_blank" rel="noreferrer">
|
|
228
|
+
docs
|
|
229
|
+
</a>
|
|
230
|
+
</div>
|
|
231
|
+
{fromEnvironment ? (
|
|
232
|
+
<p className="hint">
|
|
233
|
+
The key comes from <code>{provider.keyVariable}</code> in the environment, which always wins over a stored
|
|
234
|
+
one. Change it where the environment is set.
|
|
235
|
+
</p>
|
|
236
|
+
) : (
|
|
237
|
+
<form
|
|
238
|
+
className="integration-form"
|
|
239
|
+
onSubmit={(event) => {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
if (draft.trim()) void submit(draft.trim());
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<input
|
|
245
|
+
className="integration-input"
|
|
246
|
+
type="password"
|
|
247
|
+
autoComplete="off"
|
|
248
|
+
placeholder={connected ? "Replace the stored key" : `Paste an API key (${provider.keyVariable})`}
|
|
249
|
+
value={draft}
|
|
250
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
251
|
+
disabled={busy}
|
|
252
|
+
aria-label={`${provider.title} API key`}
|
|
253
|
+
/>
|
|
254
|
+
<Button disabled={busy || !draft.trim()} onClick={() => void submit(draft.trim())}>
|
|
255
|
+
{connected ? "Replace" : "Connect"}
|
|
256
|
+
</Button>
|
|
257
|
+
{connected ? (
|
|
258
|
+
<Button disabled={busy} onClick={() => void submit("")}>
|
|
259
|
+
Disconnect
|
|
260
|
+
</Button>
|
|
261
|
+
) : null}
|
|
262
|
+
</form>
|
|
263
|
+
)}
|
|
264
|
+
{message ? <p className="hint">{message}</p> : null}
|
|
265
|
+
<p className="integration-usage">
|
|
266
|
+
<code>odori bed "steady ambient, no drums" --generate --role bed.main</code>
|
|
267
|
+
</p>
|
|
268
|
+
</li>
|
|
269
|
+
);
|
|
270
|
+
};
|