@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
package/src/render.ts
CHANGED
|
@@ -36,21 +36,41 @@ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
39
|
+
* Which graphics backend the render browser draws with.
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the same picture on any machine and is therefore the right one for a
|
|
46
|
-
* renderer that has to agree with itself across parallel workers.
|
|
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.
|
|
47
45
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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.
|
|
52
62
|
*/
|
|
53
|
-
export
|
|
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"];
|
|
54
74
|
|
|
55
75
|
export type RenderPage = {browser: Browser; page: Page; errors: string[]};
|
|
56
76
|
|
|
@@ -59,9 +79,10 @@ export const openRenderPage = async (
|
|
|
59
79
|
origin: string,
|
|
60
80
|
target: RenderTarget,
|
|
61
81
|
config: ResolvedConfig,
|
|
82
|
+
graphics: Graphics = DEFAULT_GRAPHICS,
|
|
62
83
|
): Promise<RenderPage> => {
|
|
63
84
|
const executablePath = await browserExecutable(config);
|
|
64
|
-
const browser = await chromium.launch({executablePath, headless: true, args:
|
|
85
|
+
const browser = await chromium.launch({executablePath, headless: true, args: browserArgs(graphics)});
|
|
65
86
|
const page = await browser.newPage({
|
|
66
87
|
viewport: {width: target.width, height: target.height},
|
|
67
88
|
deviceScaleFactor: 1,
|
|
@@ -81,10 +102,24 @@ export const openRenderPage = async (
|
|
|
81
102
|
return {browser, page, errors};
|
|
82
103
|
};
|
|
83
104
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
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[]) => {
|
|
86
116
|
await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
|
|
87
|
-
|
|
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
|
+
}
|
|
88
123
|
};
|
|
89
124
|
|
|
90
125
|
export const readTimeline = async (page: Page) =>
|
|
@@ -240,11 +275,12 @@ export const renderStill = async (
|
|
|
240
275
|
frame: number,
|
|
241
276
|
output: string,
|
|
242
277
|
config: ResolvedConfig,
|
|
278
|
+
graphics: Graphics = DEFAULT_GRAPHICS,
|
|
243
279
|
): Promise<string> => {
|
|
244
|
-
const {browser, page, errors} = await openRenderPage(origin, target, config);
|
|
280
|
+
const {browser, page, errors} = await openRenderPage(origin, target, config, graphics);
|
|
245
281
|
try {
|
|
246
282
|
await mkdir(dirname(output), {recursive: true});
|
|
247
|
-
await seekTo(page, frame);
|
|
283
|
+
await seekTo(page, frame, errors);
|
|
248
284
|
await page.screenshot({path: output});
|
|
249
285
|
if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
|
|
250
286
|
return output;
|
|
@@ -340,6 +376,7 @@ const openChunkEncoder = (
|
|
|
340
376
|
|
|
341
377
|
type LaneOptions = {
|
|
342
378
|
skipUnchanged: boolean;
|
|
379
|
+
graphics: Graphics;
|
|
343
380
|
signal?: AbortSignal;
|
|
344
381
|
encode: EncodeOptions;
|
|
345
382
|
format: VideoFormat;
|
|
@@ -366,12 +403,12 @@ const captureLane = async (
|
|
|
366
403
|
stats: CaptureStats,
|
|
367
404
|
options: LaneOptions,
|
|
368
405
|
): Promise<void> => {
|
|
369
|
-
let session = await openRenderPage(origin, target, config);
|
|
406
|
+
let session = await openRenderPage(origin, target, config, options.graphics);
|
|
370
407
|
const errors = session.errors;
|
|
371
408
|
|
|
372
409
|
const reopen = async () => {
|
|
373
410
|
await session.browser.close().catch(() => undefined);
|
|
374
|
-
session = await openRenderPage(origin, target, config);
|
|
411
|
+
session = await openRenderPage(origin, target, config, options.graphics);
|
|
375
412
|
session.errors.push(...errors);
|
|
376
413
|
};
|
|
377
414
|
|
|
@@ -414,7 +451,7 @@ const captureLane = async (
|
|
|
414
451
|
|
|
415
452
|
for (let attempt = 0; ; attempt += 1) {
|
|
416
453
|
try {
|
|
417
|
-
await seekTo(session.page, frame);
|
|
454
|
+
await seekTo(session.page, frame, session.errors);
|
|
418
455
|
const signature = await signatureOf();
|
|
419
456
|
let image: Buffer;
|
|
420
457
|
if (options.skipUnchanged && previousFrame && signature === previousSignature) {
|
|
@@ -486,6 +523,8 @@ export type RenderOptions = {
|
|
|
486
523
|
/** Container and codec. Defaults to H.264 in MP4. */
|
|
487
524
|
format?: VideoFormat;
|
|
488
525
|
skipUnchangedFrames?: boolean;
|
|
526
|
+
/** Which graphics backend draws the frames. Defaults to software. */
|
|
527
|
+
graphics?: Graphics;
|
|
489
528
|
/**
|
|
490
529
|
* Mix the composition's cues into the file. Defaults to true, because the
|
|
491
530
|
* score is part of the video. Set false for a silent cut: a loop for a
|
|
@@ -516,7 +555,14 @@ export const renderMovie = async (
|
|
|
516
555
|
const ffmpeg = await ffmpegExecutable(config);
|
|
517
556
|
const browserPath = await browserExecutable(config);
|
|
518
557
|
// Cached frames belong to the browser that drew them.
|
|
519
|
-
|
|
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}`;
|
|
520
566
|
|
|
521
567
|
const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
|
|
522
568
|
const encode: EncodeOptions = {
|
|
@@ -562,6 +608,7 @@ export const renderMovie = async (
|
|
|
562
608
|
lanes.map((lane) =>
|
|
563
609
|
captureLane(origin, target, config, lane, stats, {
|
|
564
610
|
skipUnchanged,
|
|
611
|
+
graphics,
|
|
565
612
|
signal: options.signal,
|
|
566
613
|
encode,
|
|
567
614
|
format: chunkFormat,
|
package/src/server.ts
CHANGED
|
@@ -42,6 +42,15 @@ const RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
|
|
42
42
|
* the aliases below are simply not installed — Vite then resolves `odori`
|
|
43
43
|
* through its own exports map, which is what should happen.
|
|
44
44
|
*/
|
|
45
|
+
/** The CLI's own published version, for the status bar. */
|
|
46
|
+
const cliVersion = (): string => {
|
|
47
|
+
try {
|
|
48
|
+
return (createRequire(import.meta.url)("../package.json") as {version: string}).version;
|
|
49
|
+
} catch {
|
|
50
|
+
return "dev";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
45
54
|
const runtimeSource = (root: string): string | null => {
|
|
46
55
|
for (const from of [resolve(root, "package.json"), import.meta.url]) {
|
|
47
56
|
try {
|
|
@@ -99,6 +108,7 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
|
|
|
99
108
|
audioDir: config.audioDir,
|
|
100
109
|
docsUrl: config.docsUrl,
|
|
101
110
|
audio: graph.audio,
|
|
111
|
+
version: cliVersion(),
|
|
102
112
|
sourceHash: graph.sourceHash,
|
|
103
113
|
assets: config.assets ?? [],
|
|
104
114
|
files: {
|
package/studio/src/Studio.tsx
CHANGED
|
@@ -149,7 +149,7 @@ export const Studio = () => {
|
|
|
149
149
|
</main>
|
|
150
150
|
|
|
151
151
|
<footer className="statusbar">
|
|
152
|
-
<span>
|
|
152
|
+
<span>odori {project.version}</span>
|
|
153
153
|
<a className="statusbar-link" href={project.docsUrl} target="_blank" rel="noreferrer">
|
|
154
154
|
Documentation
|
|
155
155
|
<Icon name="external" />
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {useEffect, useState} from "react";
|
|
2
|
+
import {Button} from "./ui";
|
|
3
|
+
import {INTEGRATIONS_CHANGED, loadProviders} from "../integrations";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prompt to registered role, without a terminal. The server walks the same
|
|
7
|
+
* path the CLI does — provider, prepare, register — and the numbers it prints
|
|
8
|
+
* there render here, warnings included, with the result playable in place.
|
|
9
|
+
*
|
|
10
|
+
* This lives with the audio library because that is where the result lands:
|
|
11
|
+
* a generated bed is an asset the moment the pipeline finishes with it. Keys
|
|
12
|
+
* are configured in Settings, under Audio; the panel gates on a provider
|
|
13
|
+
* being connected and unlocks in place when one is stored, announced over a
|
|
14
|
+
* window event so no reload sits between pasting a key and using it.
|
|
15
|
+
*/
|
|
16
|
+
export const GenerateBed = () => {
|
|
17
|
+
const [connected, setConnected] = useState<boolean | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const load = () => {
|
|
21
|
+
loadProviders()
|
|
22
|
+
.then((providers) => setConnected(providers.some((provider) => provider.source !== null)))
|
|
23
|
+
.catch(() => setConnected(null));
|
|
24
|
+
};
|
|
25
|
+
load();
|
|
26
|
+
window.addEventListener(INTEGRATIONS_CHANGED, load);
|
|
27
|
+
return () => window.removeEventListener(INTEGRATIONS_CHANGED, load);
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
if (connected === null) return null;
|
|
31
|
+
if (!connected) {
|
|
32
|
+
return (
|
|
33
|
+
<p className="hint">
|
|
34
|
+
A provider can compose a bed from a prompt, prepared and levelled like any file you drop in. Open Settings
|
|
35
|
+
from the gear in the header and paste an API key under Audio; the panel unlocks here the moment one is
|
|
36
|
+
stored.
|
|
37
|
+
</p>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return <GeneratePanel />;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type GenerateReport = {
|
|
45
|
+
destination: string;
|
|
46
|
+
role: string;
|
|
47
|
+
url: string | null;
|
|
48
|
+
registered: {file: string; already: boolean} | null;
|
|
49
|
+
before: {lufs: number; peak: number; range: number};
|
|
50
|
+
after: {lufs: number; peak: number};
|
|
51
|
+
warnings: string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const GeneratePanel = () => {
|
|
55
|
+
const [prompt, setPrompt] = useState("");
|
|
56
|
+
const [seconds, setSeconds] = useState(60);
|
|
57
|
+
const [role, setRole] = useState("");
|
|
58
|
+
const [busy, setBusy] = useState(false);
|
|
59
|
+
const [error, setError] = useState<string | null>(null);
|
|
60
|
+
const [report, setReport] = useState<GenerateReport | null>(null);
|
|
61
|
+
|
|
62
|
+
const generate = async () => {
|
|
63
|
+
setBusy(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
setReport(null);
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch("/__odori/generate", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {"content-type": "application/json"},
|
|
70
|
+
body: JSON.stringify({prompt: prompt.trim(), seconds, role: role.trim() || undefined}),
|
|
71
|
+
});
|
|
72
|
+
const body = (await response.json()) as GenerateReport & {error?: string};
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
setError(body.error ?? "Generation failed.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setReport(body);
|
|
78
|
+
} catch {
|
|
79
|
+
setError("Studio could not reach its own server. Is odori dev still running?");
|
|
80
|
+
} finally {
|
|
81
|
+
setBusy(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="integration generate-panel">
|
|
87
|
+
<textarea
|
|
88
|
+
className="generate-prompt"
|
|
89
|
+
rows={2}
|
|
90
|
+
placeholder="steady ambient bed, no drums, warm"
|
|
91
|
+
value={prompt}
|
|
92
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
93
|
+
disabled={busy}
|
|
94
|
+
aria-label="Prompt"
|
|
95
|
+
/>
|
|
96
|
+
<div className="generate-controls">
|
|
97
|
+
<label className="generate-field">
|
|
98
|
+
seconds
|
|
99
|
+
<input
|
|
100
|
+
type="number"
|
|
101
|
+
min={5}
|
|
102
|
+
max={300}
|
|
103
|
+
value={seconds}
|
|
104
|
+
onChange={(event) => setSeconds(Number(event.target.value) || 60)}
|
|
105
|
+
disabled={busy}
|
|
106
|
+
/>
|
|
107
|
+
</label>
|
|
108
|
+
<label className="generate-field">
|
|
109
|
+
role
|
|
110
|
+
<input
|
|
111
|
+
type="text"
|
|
112
|
+
placeholder="bed.main"
|
|
113
|
+
value={role}
|
|
114
|
+
onChange={(event) => setRole(event.target.value)}
|
|
115
|
+
disabled={busy}
|
|
116
|
+
/>
|
|
117
|
+
</label>
|
|
118
|
+
<Button variant="primary" disabled={busy || !prompt.trim()} onClick={() => void generate()}>
|
|
119
|
+
{busy ? "Generating…" : "Generate"}
|
|
120
|
+
</Button>
|
|
121
|
+
</div>
|
|
122
|
+
{busy ? <p className="hint">The provider is composing, then the track is levelled to the stem target. A minute is normal.</p> : null}
|
|
123
|
+
{error ? <p className="hint">{error}</p> : null}
|
|
124
|
+
{report ? (
|
|
125
|
+
<div className="generate-report">
|
|
126
|
+
{report.url ? <audio controls src={report.url} style={{width: "100%"}} /> : null}
|
|
127
|
+
<p className="hint">
|
|
128
|
+
{report.destination} · {report.before.lufs.toFixed(1)} → {report.after.lufs.toFixed(1)} LUFS · range{" "}
|
|
129
|
+
{report.before.range.toFixed(1)} LU
|
|
130
|
+
{report.registered
|
|
131
|
+
? report.registered.already
|
|
132
|
+
? ` · "${report.role}" was already registered`
|
|
133
|
+
: ` · registered "${report.role}" in ${report.registered.file}`
|
|
134
|
+
: ""}
|
|
135
|
+
</p>
|
|
136
|
+
{report.warnings.map((warning) => (
|
|
137
|
+
<p key={warning} className="hint">
|
|
138
|
+
{warning}
|
|
139
|
+
</p>
|
|
140
|
+
))}
|
|
141
|
+
<p className="integration-usage">
|
|
142
|
+
<code>{`<Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`}</code>
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
) : null}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
};
|