@odori/cli 0.0.7 → 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/src/render.ts CHANGED
@@ -36,21 +36,41 @@ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
36
36
  };
37
37
 
38
38
  /**
39
- * Flags the render browser is launched with.
39
+ * Which graphics backend the render browser draws with.
40
40
  *
41
- * Headless Chrome has no GPU, so a WebGL context is simply refused and
42
- * `getContext("webgl2")` returns null: a video that post-processes through a
43
- * shader renders black in the export and nowhere else, which is the worst
44
- * shape a bug can take. This turns on ANGLE's software backend, which draws
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
- * Measured against the pinned build rather than guessed. The combination that
49
- * reads as obvious, `--use-gl=angle --use-angle=swiftshader`, leaves WebGL2
50
- * off; this flag alone turns it on. Nothing changes for a video that never
51
- * asks for a GL context.
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 const BROWSER_ARGS = ["--enable-unsafe-swiftshader"];
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: BROWSER_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
- /** Seek through the readiness handshake instead of guessing with timeouts. */
85
- export const seekTo = async (page: Page, frame: number) => {
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
- await page.locator(`[data-odori-frame="${frame}"]`).waitFor({timeout: 20000});
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
- const renderer = (await resolveBrowser(config))?.version ?? browserPath;
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,
@@ -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
 
@@ -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
+ };