@odori/cli 0.0.2 → 0.0.4
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-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"playwright-core": "1.55.0",
|
|
29
29
|
"tsx": "4.20.5",
|
|
30
30
|
"vite": "7.3.0",
|
|
31
|
-
"odori": "0.0.
|
|
31
|
+
"odori": "0.0.4"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "22.19.0",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@types/react-dom": "19.2.3",
|
|
37
37
|
"tsup": "^8.5.1",
|
|
38
38
|
"typescript": "5.9.3",
|
|
39
|
-
"@odori/registry": "0.0.
|
|
39
|
+
"@odori/registry": "0.0.4"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {createHash} from "node:crypto";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {mkdir, readFile, rm, writeFile} from "node:fs/promises";
|
|
4
|
+
import {dirname, relative, resolve} from "node:path";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {log} from "./log";
|
|
7
|
+
import {cacheRoot} from "./binaries";
|
|
8
|
+
import {registryOrigin, resolveWithinRoot, type RegistryComponent} from "./registry-source";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Installing a produced file.
|
|
12
|
+
*
|
|
13
|
+
* Most of the registry is source, and source is what `odori add` was built to
|
|
14
|
+
* copy. Some sound cannot be reached from oscillators — a keyboard, a room, a
|
|
15
|
+
* voice — so those ship as recordings instead, and installing one has to mean
|
|
16
|
+
* fetching bytes rather than writing a module. The rules are the same rules:
|
|
17
|
+
* the destination is confined to the project, the bytes are checked against
|
|
18
|
+
* the hash the registry published before anything is written, and a file the
|
|
19
|
+
* project already has is left alone unless replacing it was asked for.
|
|
20
|
+
*
|
|
21
|
+
* The download is cached beside the browser and the encoder, so a second
|
|
22
|
+
* project on the same machine costs no second round trip.
|
|
23
|
+
*/
|
|
24
|
+
const assetCache = (integrity: string): string =>
|
|
25
|
+
resolve(cacheRoot(), "assets", createHash("sha256").update(integrity).digest("hex").slice(0, 16));
|
|
26
|
+
|
|
27
|
+
const verify = (bytes: Uint8Array, expected: string, source: string): void => {
|
|
28
|
+
const actual = `sha256-${createHash("sha256").update(bytes).digest("base64")}`;
|
|
29
|
+
if (actual === expected) return;
|
|
30
|
+
throw new Error(
|
|
31
|
+
`The bytes at ${source} do not match the hash the registry published.\n` +
|
|
32
|
+
` expected ${expected}\n received ${actual}\n` +
|
|
33
|
+
"Nothing was written. This is a truncated download, a stale proxy, or a tampered file.",
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const installAsset = async (
|
|
38
|
+
config: ResolvedConfig,
|
|
39
|
+
component: RegistryComponent,
|
|
40
|
+
options: {dryRun?: boolean; force?: boolean} = {},
|
|
41
|
+
): Promise<boolean> => {
|
|
42
|
+
const asset = component.asset;
|
|
43
|
+
if (!asset) return false;
|
|
44
|
+
|
|
45
|
+
const destination = resolveWithinRoot(config.root, asset.target);
|
|
46
|
+
const exists = existsSync(destination);
|
|
47
|
+
log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
|
|
48
|
+
if (options.dryRun) return false;
|
|
49
|
+
|
|
50
|
+
if (exists && !options.force) {
|
|
51
|
+
log.warn(`${relative(config.root, destination)} already exists. Keeping it. Use --force to replace it.`);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cached = assetCache(asset.integrity);
|
|
56
|
+
let bytes: Uint8Array | null = null;
|
|
57
|
+
|
|
58
|
+
if (existsSync(cached)) {
|
|
59
|
+
const stored = await readFile(cached);
|
|
60
|
+
// A cache that does not verify is a cache miss, not a failure: the bytes
|
|
61
|
+
// on the machine are the one part of this nobody published. Refusing here
|
|
62
|
+
// would strand a project behind a corrupt file it never asked for.
|
|
63
|
+
try {
|
|
64
|
+
verify(stored, asset.integrity, cached);
|
|
65
|
+
bytes = stored;
|
|
66
|
+
} catch {
|
|
67
|
+
log.detail(" the cached copy did not verify; downloading it again");
|
|
68
|
+
await rm(cached, {force: true});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!bytes) {
|
|
73
|
+
const url = new URL(asset.url, `${registryOrigin(config)}/`).toString();
|
|
74
|
+
const response = await fetch(url, {signal: AbortSignal.timeout(30_000)});
|
|
75
|
+
if (!response.ok) throw new Error(`Could not download ${url}: ${response.status} ${response.statusText}`);
|
|
76
|
+
const downloaded = new Uint8Array(await response.arrayBuffer());
|
|
77
|
+
// A download that does not verify is a failure: this is the check that
|
|
78
|
+
// makes fetching bytes into somebody's repository defensible.
|
|
79
|
+
verify(downloaded, asset.integrity, url);
|
|
80
|
+
await mkdir(dirname(cached), {recursive: true});
|
|
81
|
+
await writeFile(cached, downloaded);
|
|
82
|
+
bytes = downloaded;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await mkdir(dirname(destination), {recursive: true});
|
|
86
|
+
await writeFile(destination, bytes);
|
|
87
|
+
log.success(`${component.namespaced} to ${relative(config.root, destination)}`);
|
|
88
|
+
log.detail(` ${component.family} · ${Math.round(asset.bytes / 1024)} KB · answers to "${asset.cue}"`);
|
|
89
|
+
return true;
|
|
90
|
+
};
|
package/src/brand-file.ts
CHANGED
|
@@ -48,9 +48,13 @@ const brandFiles = async (config: ResolvedConfig): Promise<string[]> => {
|
|
|
48
48
|
*/
|
|
49
49
|
export const registerCueInBrand = async (
|
|
50
50
|
config: ResolvedConfig,
|
|
51
|
-
cue: {name: string; export: string},
|
|
51
|
+
cue: {name: string; export: string} | {name: string; url: string},
|
|
52
52
|
componentName: string,
|
|
53
53
|
): Promise<BrandRegistration | null> => {
|
|
54
|
+
// A score is registered by calling its factory, which needs an import. A
|
|
55
|
+
// recording is registered by its URL, which needs nothing: the value is the
|
|
56
|
+
// path the dev server and the render worker both serve it from.
|
|
57
|
+
const url = "url" in cue ? cue.url : null;
|
|
54
58
|
for (const file of await brandFiles(config)) {
|
|
55
59
|
const source = await readFile(file, "utf8");
|
|
56
60
|
if (source.includes(`"${cue.name}"`) || source.includes(`'${cue.name}'`)) {
|
|
@@ -62,26 +66,34 @@ export const registerCueInBrand = async (
|
|
|
62
66
|
const block = source.match(/(audio:\s*\{[\s\S]*?cues:\s*\{)([\s\S]*?)(\n(\s*)\},)/);
|
|
63
67
|
const inline = source.match(/(audio:\s*\{[^\n}]*cues:\s*\{)([^\n{}]*)(\})/);
|
|
64
68
|
|
|
69
|
+
const value = url ? JSON.stringify(url) : `${(cue as {export: string}).export}()`;
|
|
70
|
+
|
|
65
71
|
let withCue: string;
|
|
66
72
|
if (block) {
|
|
67
73
|
const indent = `${block[4]} `;
|
|
68
|
-
const entry = `\n${indent}"${cue.name}": ${
|
|
74
|
+
const entry = `\n${indent}"${cue.name}": ${value},`;
|
|
69
75
|
withCue = source.replace(block[0], `${block[1]}${block[2]}${entry}${block[3]}`);
|
|
70
76
|
} else if (inline) {
|
|
71
77
|
// An inline block grows in place rather than being reformatted: this
|
|
72
78
|
// edits somebody's source, and reflowing their file is not the job.
|
|
73
79
|
const existing = inline[2].trim();
|
|
74
|
-
const entry = `"${cue.name}": ${
|
|
80
|
+
const entry = `"${cue.name}": ${value}`;
|
|
75
81
|
withCue = source.replace(inline[0], `${inline[1]}${existing ? `${existing.replace(/,$/, "")}, ` : ""}${entry}${inline[3]}`);
|
|
76
82
|
} else {
|
|
77
83
|
continue;
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
// A URL needs no import, so the edit is finished.
|
|
87
|
+
if (url) {
|
|
88
|
+
await writeFile(file, withCue, "utf8");
|
|
89
|
+
return {file: relative(config.root, file), already: false};
|
|
90
|
+
}
|
|
91
|
+
|
|
80
92
|
// The import path is relative to the brand file, which is usually the
|
|
81
93
|
// layout beside videos/components.
|
|
82
94
|
const from = resolve(config.root, config.componentsDir, componentName, componentName);
|
|
83
95
|
const specifier = relative(resolve(file, ".."), from).split("\\").join("/");
|
|
84
|
-
const importLine = `import {${cue.export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
|
|
96
|
+
const importLine = `import {${(cue as {export: string}).export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
|
|
85
97
|
const withImport = withCue.includes(importLine)
|
|
86
98
|
? withCue
|
|
87
99
|
: withCue.replace(/^(import [\s\S]*?;\n)/, `$1${importLine}\n`);
|
package/src/chunk-cache.ts
CHANGED
|
@@ -12,6 +12,10 @@ export type ChunkIdentity = {
|
|
|
12
12
|
height: number;
|
|
13
13
|
fps: number;
|
|
14
14
|
preset: string;
|
|
15
|
+
/** Compression tier the chunk was encoded at. Changes the bytes. */
|
|
16
|
+
quality?: string;
|
|
17
|
+
/** Scale multiplier the chunk was encoded at. Changes the dimensions. */
|
|
18
|
+
scale?: number;
|
|
15
19
|
/** The browser build that captured the frames. See `chunkKey`. */
|
|
16
20
|
renderer?: string;
|
|
17
21
|
/** The codec the chunk was encoded with, which decides what it can join. */
|
|
@@ -49,6 +53,8 @@ export const chunkKey = (identity: ChunkIdentity): string =>
|
|
|
49
53
|
height: identity.height,
|
|
50
54
|
fps: identity.fps,
|
|
51
55
|
preset: identity.preset,
|
|
56
|
+
quality: identity.quality ?? null,
|
|
57
|
+
scale: identity.scale ?? null,
|
|
52
58
|
input: identity.input ?? null,
|
|
53
59
|
});
|
|
54
60
|
|
package/src/cli.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {initCommand} from "./commands/init";
|
|
|
10
10
|
import {inspectCommand} from "./commands/inspect";
|
|
11
11
|
import {listCommand} from "./commands/list";
|
|
12
12
|
import {newCommand} from "./commands/new";
|
|
13
|
-
import {
|
|
13
|
+
import {frameCommand} from "./commands/frame";
|
|
14
14
|
import {testCommand} from "./commands/test";
|
|
15
15
|
|
|
16
16
|
type Flags = Record<string, string | boolean>;
|
|
@@ -84,9 +84,9 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
84
84
|
update: ["force"],
|
|
85
85
|
list: [],
|
|
86
86
|
inspect: ["json", "input"],
|
|
87
|
-
|
|
87
|
+
frame: ["at", "output", "input"],
|
|
88
88
|
test: ["json"],
|
|
89
|
-
export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
|
|
89
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
|
|
90
90
|
jobs: [],
|
|
91
91
|
help: [],
|
|
92
92
|
};
|
|
@@ -160,15 +160,19 @@ const USAGE: Record<string, string> = {
|
|
|
160
160
|
Print discovered video ids and formats.`,
|
|
161
161
|
inspect: `odori inspect <id> [--json] [--input <json>]
|
|
162
162
|
Show resolved layout, inputs, scenes, and assets.`,
|
|
163
|
-
|
|
164
|
-
Render one deterministic frame
|
|
163
|
+
frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
|
|
164
|
+
Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
|
|
165
|
+
120f for frame 120. A bare number is seconds.`,
|
|
165
166
|
test: `odori test [id] [--json]
|
|
166
167
|
Validate contracts and representative frames. --json emits one object per
|
|
167
168
|
check, for CI.`,
|
|
168
169
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
169
|
-
[--preset <name>] [--format <name>] [--
|
|
170
|
+
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
171
|
+
[--no-frame-skip] [--retry <job>]
|
|
170
172
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
171
|
-
or png; without it the output's extension decides, and mp4 is the default
|
|
173
|
+
or png; without it the output's extension decides, and mp4 is the default.
|
|
174
|
+
--quality is studio, social, or web. --scale multiplies the output size,
|
|
175
|
+
0.25 to 2. A retry keeps the settings its job was created with.`,
|
|
172
176
|
jobs: `odori jobs
|
|
173
177
|
List export jobs and their status.`,
|
|
174
178
|
};
|
|
@@ -187,18 +191,20 @@ Usage
|
|
|
187
191
|
odori update [components] Apply upstream component changes
|
|
188
192
|
odori list Print discovered video ids and formats
|
|
189
193
|
odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
|
|
190
|
-
odori
|
|
194
|
+
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
191
195
|
odori test [id] [--json] Validate contracts and representative frames
|
|
192
196
|
odori export <id> [--output f] Render and encode a distributable file
|
|
193
197
|
odori jobs List export jobs and their status
|
|
194
198
|
|
|
195
199
|
Options
|
|
196
200
|
--input '{"headline":"..."}' Serializable input for the video schema
|
|
197
|
-
--output <path> Output path for
|
|
201
|
+
--output <path> Output path for frame and export
|
|
198
202
|
--force Replace locally modified component source
|
|
199
203
|
--concurrency <n> Parallel render workers for export
|
|
200
204
|
--preset <name> x264 preset for export, default medium
|
|
201
205
|
--format <name> mp4, webm, prores, gif, or png
|
|
206
|
+
--quality <tier> studio, social, or web compression
|
|
207
|
+
--scale <n> Output size multiplier, 0.25 to 2
|
|
202
208
|
--no-frame-skip Capture every frame, even unchanged ones
|
|
203
209
|
--retry <job id> Re-run a recorded job from its frozen manifest
|
|
204
210
|
--no-open Start dev without opening Studio in a browser
|
|
@@ -275,9 +281,11 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
275
281
|
case "inspect":
|
|
276
282
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
277
283
|
return 0;
|
|
278
|
-
case "
|
|
279
|
-
await
|
|
280
|
-
|
|
284
|
+
case "frame":
|
|
285
|
+
await frameCommand(positionals[0] ?? "", {
|
|
286
|
+
// A duration, so "4s" and "120f" both work; a bare number is
|
|
287
|
+
// seconds, the way every other time value in Odori reads.
|
|
288
|
+
at: typeof flags.at === "string" ? flags.at : (numberFlag(flags, "at") ?? 0),
|
|
281
289
|
output: typeof flags.output === "string" ? flags.output : undefined,
|
|
282
290
|
input: parseInput(flags),
|
|
283
291
|
});
|
|
@@ -291,6 +299,8 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
291
299
|
input: parseInput(flags),
|
|
292
300
|
concurrency: numberFlag(flags, "concurrency"),
|
|
293
301
|
preset: typeof flags.preset === "string" ? flags.preset : undefined,
|
|
302
|
+
quality: typeof flags.quality === "string" ? flags.quality : undefined,
|
|
303
|
+
scale: numberFlag(flags, "scale"),
|
|
294
304
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
295
305
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
296
306
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
package/src/commands/add.ts
CHANGED
|
@@ -5,7 +5,8 @@ import {hashString} from "odori";
|
|
|
5
5
|
import {loadConfig} from "../config";
|
|
6
6
|
import {log} from "../log";
|
|
7
7
|
import {registerCueInBrand} from "../brand-file";
|
|
8
|
-
import {normalizeComponentName, registryUrl, resolveItem, resolveRegistry, verifyIntegrity} from "../registry-source";
|
|
8
|
+
import {assertSafeName, normalizeComponentName, registryUrl, resolveItem, resolveRegistry, resolveWithinRoot, verifyIntegrity} from "../registry-source";
|
|
9
|
+
import {installAsset} from "../assets";
|
|
9
10
|
import {readProvenance, writeProvenance} from "./update";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -14,7 +15,7 @@ import {readProvenance, writeProvenance} from "./update";
|
|
|
14
15
|
* replaced without an explicit decision.
|
|
15
16
|
*/
|
|
16
17
|
export const addCommand = async (names: string[], options: {force?: boolean; dryRun?: boolean} = {}) => {
|
|
17
|
-
if (names.length === 0) throw new Error("Name at least one component, for example
|
|
18
|
+
if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
|
|
18
19
|
const config = await loadConfig(process.cwd());
|
|
19
20
|
const source = await resolveRegistry(config);
|
|
20
21
|
const registry = source.items;
|
|
@@ -46,19 +47,43 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
46
47
|
else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// An asset is bytes, not source: it is fetched, verified, and written into
|
|
51
|
+
// public/, then registered by URL. Nothing about it is a file to edit, so
|
|
52
|
+
// it never goes through the copy-and-record path below.
|
|
53
|
+
if (component.kind === "asset" && component.asset) {
|
|
54
|
+
const written = await installAsset(config, component, {dryRun: options.dryRun, force: options.force});
|
|
55
|
+
if (!options.dryRun && written) {
|
|
56
|
+
installed.push(component.name);
|
|
57
|
+
const registered = await registerCueInBrand(
|
|
58
|
+
config,
|
|
59
|
+
{name: component.asset.cue, url: component.asset.url},
|
|
60
|
+
component.name,
|
|
61
|
+
);
|
|
62
|
+
if (registered?.already) {
|
|
63
|
+
log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
|
|
64
|
+
} else if (registered) {
|
|
65
|
+
log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
|
|
66
|
+
} else {
|
|
67
|
+
log.warn(` No brand with an audio.cues block found. Add it yourself:`);
|
|
68
|
+
log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
// The item document carries the file contents; the index does not.
|
|
50
|
-
const {item} = await resolveItem(config, component.name);
|
|
75
|
+
const {item, origin} = await resolveItem(config, component.name);
|
|
51
76
|
// Before anything is written: the bytes have to be the bytes the registry
|
|
52
|
-
// said it was serving.
|
|
53
|
-
verifyIntegrity(item);
|
|
77
|
+
// said it was serving, and a fetched document has to say so at all.
|
|
78
|
+
verifyIntegrity(item, origin);
|
|
54
79
|
|
|
55
|
-
const target = resolve(config.root, config.componentsDir, component.name);
|
|
80
|
+
const target = resolve(config.root, config.componentsDir, assertSafeName(component.name));
|
|
56
81
|
const hashes: Record<string, string> = {};
|
|
57
82
|
|
|
58
83
|
// What will be written, listed before it is. Installing source into
|
|
59
84
|
// somebody's repository should never be the first they hear of a path.
|
|
60
85
|
for (const file of item.files) {
|
|
61
|
-
const destination =
|
|
86
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
62
87
|
const exists = existsSync(destination);
|
|
63
88
|
log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
|
|
64
89
|
}
|
|
@@ -71,7 +96,7 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
71
96
|
|
|
72
97
|
for (const file of item.files) {
|
|
73
98
|
const name = file.path.split("/").pop() ?? file.path;
|
|
74
|
-
const destination =
|
|
99
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
75
100
|
hashes[name] = hashString(file.content);
|
|
76
101
|
|
|
77
102
|
if (existsSync(destination) && !options.force) {
|
package/src/commands/dev.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {resolve} from "node:path";
|
|
1
|
+
import {relative, resolve} from "node:path";
|
|
2
|
+
import {homedir} from "node:os";
|
|
3
|
+
import {existsSync} from "node:fs";
|
|
2
4
|
import {readFile} from "node:fs/promises";
|
|
3
5
|
import type {IncomingMessage, ServerResponse} from "node:http";
|
|
4
6
|
import {loadConfig} from "../config";
|
|
@@ -6,10 +8,11 @@ import {log} from "../log";
|
|
|
6
8
|
import {createJob, listJobs, readJob} from "../jobs";
|
|
7
9
|
import {discoverProject} from "../discovery";
|
|
8
10
|
import {findVideo, freezeManifest, loadVideos, outputName} from "../project";
|
|
11
|
+
import {resolveFormat} from "../formats";
|
|
9
12
|
import {renderStill} from "../render";
|
|
10
13
|
import {openInBrowser, shouldOpenBrowser} from "../open";
|
|
11
14
|
import {startStudioServer} from "../server";
|
|
12
|
-
import {cancelJob, runJob} from "./exportVideo";
|
|
15
|
+
import {cancelJob, resolveQuality, resolveScale, runJob} from "./exportVideo";
|
|
13
16
|
import {compileInBrowser, targetFor} from "./shared";
|
|
14
17
|
|
|
15
18
|
const readBody = async (request: IncomingMessage): Promise<Record<string, unknown>> => {
|
|
@@ -25,6 +28,65 @@ const json = (response: ServerResponse, status: number, payload: unknown) => {
|
|
|
25
28
|
response.end(JSON.stringify(payload));
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Where a Studio export lands: the Downloads folder, like any app that hands
|
|
33
|
+
* you a file. The CLI keeps writing into the project's export directory - a
|
|
34
|
+
* build artifact belongs to the build - but a file made by clicking a button
|
|
35
|
+
* belongs where files made by clicking buttons go.
|
|
36
|
+
*/
|
|
37
|
+
const exportDestination = (config: {root: string; exportDir: string}): string => {
|
|
38
|
+
const downloads = resolve(homedir(), "Downloads");
|
|
39
|
+
return existsSync(downloads) ? downloads : resolve(config.root, config.exportDir);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* These endpoints spawn a browser and an encoder and write files, so they are
|
|
44
|
+
* not the harmless read-only surface a dev server usually exposes. The server
|
|
45
|
+
* binds to 127.0.0.1, which keeps other machines out, but a page open in the
|
|
46
|
+
* developer's own browser can still reach a loopback port — directly, or by
|
|
47
|
+
* rebinding a hostname it controls to 127.0.0.1 and POSTing to it.
|
|
48
|
+
*
|
|
49
|
+
* So a request has to look like it came from Studio itself: its Host must be a
|
|
50
|
+
* loopback name, and a cross-site fetch (which the browser stamps with an
|
|
51
|
+
* Origin) must be same-origin. A same-origin XHR from Studio carries neither a
|
|
52
|
+
* foreign Origin nor a foreign Host and passes; a drive-by page fails both.
|
|
53
|
+
*/
|
|
54
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
55
|
+
|
|
56
|
+
const hostOf = (value: string | undefined): string | null => {
|
|
57
|
+
if (!value) return null;
|
|
58
|
+
// Strip a port without tripping over an IPv6 literal's own colons.
|
|
59
|
+
const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
|
|
60
|
+
return withoutPort || null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const isLocalRequest = (request: IncomingMessage): boolean => {
|
|
64
|
+
const host = hostOf(request.headers.host);
|
|
65
|
+
if (!host || !LOOPBACK.has(host)) return false;
|
|
66
|
+
const origin = request.headers.origin;
|
|
67
|
+
if (origin) {
|
|
68
|
+
try {
|
|
69
|
+
if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A job id is minted as `job-<hash>-<base36>` and then read straight back as a
|
|
79
|
+
* filename, so it is confined to the characters that mint it. This keeps a
|
|
80
|
+
* crafted id like `../../etc/passwd` from ever reaching the filesystem, even
|
|
81
|
+
* though the local-origin guard already stands in front of it.
|
|
82
|
+
*/
|
|
83
|
+
export const safeJobId = (id: string): string => {
|
|
84
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
|
|
85
|
+
throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
|
|
86
|
+
}
|
|
87
|
+
return id;
|
|
88
|
+
};
|
|
89
|
+
|
|
28
90
|
/**
|
|
29
91
|
* Studio previews without encoding. These endpoints exist so an explicit
|
|
30
92
|
* export action in the browser reaches the same queue and render worker the
|
|
@@ -42,6 +104,10 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
42
104
|
middleware: (vite) => {
|
|
43
105
|
vite.middlewares.use("/__odori", (request, response, next) => {
|
|
44
106
|
const url = request.url ?? "/";
|
|
107
|
+
if (!isLocalRequest(request)) {
|
|
108
|
+
json(response, 403, {error: "This endpoint only answers same-origin requests from Studio on localhost."});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
45
111
|
void (async () => {
|
|
46
112
|
try {
|
|
47
113
|
if (request.method === "POST" && url.startsWith("/still")) {
|
|
@@ -61,8 +127,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
61
127
|
// A clipboard grab is transient, so it renders into the generated
|
|
62
128
|
// directory instead of littering the export directory.
|
|
63
129
|
const inline = body.inline === true;
|
|
64
|
-
const
|
|
65
|
-
|
|
130
|
+
const file = inline
|
|
131
|
+
? resolve(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`)
|
|
132
|
+
: resolve(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
|
|
66
133
|
await renderStill(
|
|
67
134
|
origin,
|
|
68
135
|
targetFor(
|
|
@@ -87,6 +154,11 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
87
154
|
|
|
88
155
|
if (request.method === "POST" && url.startsWith("/exports")) {
|
|
89
156
|
const body = await readBody(request);
|
|
157
|
+
// Validated before compiling: a wrong option should cost a
|
|
158
|
+
// sentence, not a browser launch.
|
|
159
|
+
const format = resolveFormat(typeof body.format === "string" ? body.format : undefined, undefined);
|
|
160
|
+
const quality = resolveQuality(typeof body.quality === "string" ? body.quality : undefined);
|
|
161
|
+
const scale = resolveScale(typeof body.scale === "number" ? body.scale : undefined);
|
|
90
162
|
const {graph, videos} = await context();
|
|
91
163
|
const video = findVideo(videos, String(body.videoId));
|
|
92
164
|
const input = (body.input ?? {}) as Record<string, unknown>;
|
|
@@ -98,8 +170,11 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
98
170
|
input,
|
|
99
171
|
{scenes: compiled.scenes, audio: compiled.audio},
|
|
100
172
|
);
|
|
101
|
-
const output = resolve(
|
|
102
|
-
|
|
173
|
+
const output = resolve(
|
|
174
|
+
exportDestination(config),
|
|
175
|
+
`${outputName(video.entry.metadata.id)}${format.extension}`,
|
|
176
|
+
);
|
|
177
|
+
const record = await createJob(config, manifest, output, {format: format.name, quality, scale});
|
|
103
178
|
json(response, 202, record.job);
|
|
104
179
|
|
|
105
180
|
void runJob(config, origin, record, video).catch((error) => {
|
|
@@ -109,7 +184,7 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
109
184
|
}
|
|
110
185
|
|
|
111
186
|
if (request.method === "POST" && url.startsWith("/retry/")) {
|
|
112
|
-
const id = url.replace("/retry/", "").split("?")[0];
|
|
187
|
+
const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
|
|
113
188
|
const record = await readJob(config, id);
|
|
114
189
|
const {videos} = await context();
|
|
115
190
|
const video = findVideo(videos, record.manifest.videoId);
|
|
@@ -121,18 +196,45 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
121
196
|
}
|
|
122
197
|
|
|
123
198
|
if (request.method === "POST" && url.startsWith("/cancel/")) {
|
|
124
|
-
const id = url.replace("/cancel/", "").split("?")[0];
|
|
199
|
+
const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
|
|
125
200
|
const cancelled = cancelJob(id);
|
|
126
201
|
json(response, cancelled ? 202 : 404, {id, cancelled});
|
|
127
202
|
return;
|
|
128
203
|
}
|
|
129
204
|
|
|
130
205
|
if (request.method === "GET" && url.startsWith("/jobs/")) {
|
|
131
|
-
const id = url.replace("/jobs/", "").split("?")[0];
|
|
206
|
+
const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
|
|
132
207
|
json(response, 200, (await readJob(config, id)).job);
|
|
133
208
|
return;
|
|
134
209
|
}
|
|
135
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The source that produced the frame you are looking at.
|
|
213
|
+
*
|
|
214
|
+
* Studio shows the path already; a path you cannot read is a
|
|
215
|
+
* riddle. Reading is confined to source files inside the project,
|
|
216
|
+
* because this serves whatever a query string asks for and the
|
|
217
|
+
* only safe answer to "../../.ssh/id_rsa" is no.
|
|
218
|
+
*/
|
|
219
|
+
if (request.method === "GET" && url.startsWith("/source")) {
|
|
220
|
+
const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
|
|
221
|
+
const file = resolve(config.root, asked);
|
|
222
|
+
const inside = relative(config.root, file);
|
|
223
|
+
const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
|
|
224
|
+
if (!inside || inside.startsWith("..") || !readable) {
|
|
225
|
+
json(response, 400, {error: `Refusing to read ${asked}.`});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
response.statusCode = 200;
|
|
230
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
231
|
+
response.end(await readFile(file, "utf8"));
|
|
232
|
+
} catch {
|
|
233
|
+
json(response, 404, {error: `${asked} is not there.`});
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
136
238
|
if (request.method === "GET" && url.startsWith("/jobs")) {
|
|
137
239
|
json(response, 200, await listJobs(config));
|
|
138
240
|
return;
|
|
@@ -148,9 +250,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
148
250
|
});
|
|
149
251
|
|
|
150
252
|
const origin = server.url;
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const entry = `${origin}
|
|
253
|
+
// Studio opens on the home overview: what the project contains, each card
|
|
254
|
+
// playing, with every deeper view one click in.
|
|
255
|
+
const entry = `${origin}/`;
|
|
154
256
|
log.title("Odori Studio");
|
|
155
257
|
log.info(` ${entry}`);
|
|
156
258
|
log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
|
package/src/commands/doctor.ts
CHANGED
|
@@ -12,7 +12,14 @@ export type Check = {
|
|
|
12
12
|
/** What was found. Printed on success and on failure alike. */
|
|
13
13
|
detail: string;
|
|
14
14
|
ok: boolean;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* True for something that passes but is worth saying. A project is not
|
|
17
|
+
* broken by it and CI should not fail on it, which is exactly why it needs
|
|
18
|
+
* somewhere to be said: otherwise the only two volumes are silence and
|
|
19
|
+
* failure, and everything that deserves a word in between gets silence.
|
|
20
|
+
*/
|
|
21
|
+
warn?: boolean;
|
|
22
|
+
/** The command or edit that fixes it. Only read when `ok` is false or warned. */
|
|
16
23
|
fix?: string;
|
|
17
24
|
};
|
|
18
25
|
|
|
@@ -125,6 +132,39 @@ export const runChecks = async (root: string): Promise<Check[]> => {
|
|
|
125
132
|
} catch {
|
|
126
133
|
writable = false;
|
|
127
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* A component nobody can preview.
|
|
137
|
+
*
|
|
138
|
+
* Discovery is filename driven: a sibling `*.preview.tsx` is what makes a
|
|
139
|
+
* component exist to Studio, to the catalog and to the palette. Write one
|
|
140
|
+
* without it and it works perfectly inside its video and is invisible
|
|
141
|
+
* everywhere else, including to the person who has to tune it next week.
|
|
142
|
+
* That is a fine choice for a one-off set piece and an accident the rest of
|
|
143
|
+
* the time, so this counts them rather than ruling on them.
|
|
144
|
+
*/
|
|
145
|
+
const componentsRoot = resolve(root, config.componentsDir);
|
|
146
|
+
const orphans: string[] = [];
|
|
147
|
+
if (existsSync(componentsRoot)) {
|
|
148
|
+
const {readdir} = await import("node:fs/promises");
|
|
149
|
+
for (const entry of await readdir(componentsRoot, {withFileTypes: true})) {
|
|
150
|
+
if (!entry.isDirectory()) continue;
|
|
151
|
+
const files = await readdir(resolve(componentsRoot, entry.name));
|
|
152
|
+
const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
|
|
153
|
+
const fixture = files.some((file) => file.endsWith(".preview.tsx"));
|
|
154
|
+
if (source && !fixture) orphans.push(entry.name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
checks.push({
|
|
158
|
+
name: "Component previews",
|
|
159
|
+
detail:
|
|
160
|
+
orphans.length === 0
|
|
161
|
+
? "every component has a fixture"
|
|
162
|
+
: `${orphans.length} without a fixture: ${orphans.slice(0, 4).join(", ")}${orphans.length > 4 ? ", …" : ""}`,
|
|
163
|
+
ok: true,
|
|
164
|
+
warn: orphans.length > 0,
|
|
165
|
+
fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`,
|
|
166
|
+
});
|
|
167
|
+
|
|
128
168
|
checks.push({
|
|
129
169
|
name: "Generated cache",
|
|
130
170
|
detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
|
|
@@ -146,13 +186,18 @@ export const doctorCommand = async (root = process.cwd()): Promise<number> => {
|
|
|
146
186
|
log.title("odori doctor");
|
|
147
187
|
for (const check of checks) {
|
|
148
188
|
const label = check.name.padEnd(width);
|
|
149
|
-
if (check.ok) log.
|
|
189
|
+
if (check.ok && check.warn) log.warn(`${label} ${check.detail}`);
|
|
190
|
+
else if (check.ok) log.success(`${label} ${check.detail}`);
|
|
150
191
|
else log.error(`${label} ${check.detail}`);
|
|
151
192
|
}
|
|
152
193
|
|
|
194
|
+
const warned = checks.filter((check) => check.ok && check.warn);
|
|
153
195
|
const failed = checks.filter((check) => !check.ok);
|
|
154
196
|
if (failed.length === 0) {
|
|
155
197
|
log.detail("Everything a render needs is present.");
|
|
198
|
+
// A warning is not a failure, so it is said after the all clear rather
|
|
199
|
+
// than instead of it, and it never changes the exit code.
|
|
200
|
+
for (const check of warned) log.detail(` ${check.fix}`);
|
|
156
201
|
return 0;
|
|
157
202
|
}
|
|
158
203
|
|