@odori/cli 0.0.3 → 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-RXLB2CXH.js → chunk-NYXWEZU2.js} +399 -226
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +34 -5
- 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/cli.ts +12 -9
- package/src/commands/add.ts +25 -0
- package/src/commands/dev.ts +28 -1
- package/src/commands/doctor.ts +47 -2
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/discovery.ts +63 -2
- package/src/index.ts +1 -1
- package/src/registry-snapshot.json +1529 -327
- package/src/registry-source.ts +37 -2
- package/src/server.ts +7 -1
- package/studio/src/components/Inspector.tsx +101 -1
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +254 -7
- package/studio/src/views/BrandsView.tsx +18 -1
- package/studio/src/views/ComponentsView.tsx +191 -26
- package/studio/src/views/HomeView.tsx +7 -4
- package/studio/src/views/VideosView.tsx +21 -1
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-BDP6PVYB.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/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,7 +84,7 @@ 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
89
|
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
|
|
90
90
|
jobs: [],
|
|
@@ -160,8 +160,9 @@ 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.`,
|
|
@@ -190,14 +191,14 @@ Usage
|
|
|
190
191
|
odori update [components] Apply upstream component changes
|
|
191
192
|
odori list Print discovered video ids and formats
|
|
192
193
|
odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
|
|
193
|
-
odori
|
|
194
|
+
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
194
195
|
odori test [id] [--json] Validate contracts and representative frames
|
|
195
196
|
odori export <id> [--output f] Render and encode a distributable file
|
|
196
197
|
odori jobs List export jobs and their status
|
|
197
198
|
|
|
198
199
|
Options
|
|
199
200
|
--input '{"headline":"..."}' Serializable input for the video schema
|
|
200
|
-
--output <path> Output path for
|
|
201
|
+
--output <path> Output path for frame and export
|
|
201
202
|
--force Replace locally modified component source
|
|
202
203
|
--concurrency <n> Parallel render workers for export
|
|
203
204
|
--preset <name> x264 preset for export, default medium
|
|
@@ -280,9 +281,11 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
280
281
|
case "inspect":
|
|
281
282
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
282
283
|
return 0;
|
|
283
|
-
case "
|
|
284
|
-
await
|
|
285
|
-
|
|
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),
|
|
286
289
|
output: typeof flags.output === "string" ? flags.output : undefined,
|
|
287
290
|
input: parseInput(flags),
|
|
288
291
|
});
|
package/src/commands/add.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {loadConfig} from "../config";
|
|
|
6
6
|
import {log} from "../log";
|
|
7
7
|
import {registerCueInBrand} from "../brand-file";
|
|
8
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
|
/**
|
|
@@ -46,6 +47,30 @@ 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
75
|
const {item, origin} = await resolveItem(config, component.name);
|
|
51
76
|
// Before anything is written: the bytes have to be the bytes the registry
|
package/src/commands/dev.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {resolve} from "node:path";
|
|
1
|
+
import {relative, resolve} from "node:path";
|
|
2
2
|
import {homedir} from "node:os";
|
|
3
3
|
import {existsSync} from "node:fs";
|
|
4
4
|
import {readFile} from "node:fs/promises";
|
|
@@ -208,6 +208,33 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
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
|
+
|
|
211
238
|
if (request.method === "GET" && url.startsWith("/jobs")) {
|
|
212
239
|
json(response, 200, await listJobs(config));
|
|
213
240
|
return;
|
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
|
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import {resolve} from "node:path";
|
|
2
|
+
import {framesFromOffset, resolveEntryLayout, type Duration} from "odori";
|
|
2
3
|
import {log} from "../log";
|
|
3
4
|
import {findVideo, freezeManifest, outputName} from "../project";
|
|
4
5
|
import {renderStill} from "../render";
|
|
5
6
|
import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* One frame of a video, as a PNG.
|
|
10
|
+
*
|
|
11
|
+
* `at` is a duration like everything else in Odori: a bare number is seconds,
|
|
12
|
+
* and `120f` is frame 120. Which frame that resolves to depends on the
|
|
13
|
+
* video's frame rate, and the frame rate belongs to the video's layout, so
|
|
14
|
+
* the position is checked for shape first and resolved once the video is
|
|
15
|
+
* loaded. A typo still costs nothing: no discovery, no dev server, no
|
|
16
|
+
* browser.
|
|
17
|
+
*/
|
|
18
|
+
export const frameCommand = async (
|
|
8
19
|
id: string,
|
|
9
|
-
options: {
|
|
20
|
+
options: {at?: Duration; output?: string; input?: Record<string, unknown>} = {},
|
|
10
21
|
) => {
|
|
11
|
-
const
|
|
12
|
-
// A
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const at = options.at ?? 0;
|
|
23
|
+
// A position, not a length: frame zero is the first frame, so this is
|
|
24
|
+
// framesFromOffset rather than framesFromDuration, which floors at one. Any
|
|
25
|
+
// frame rate rejects a malformed duration, so this validates the shape
|
|
26
|
+
// before the work starts; the real rate resolves the number below.
|
|
27
|
+
framesFromOffset(at, 30);
|
|
17
28
|
|
|
18
29
|
const {config, graph, videos} = await createContext();
|
|
19
30
|
const video = findVideo(videos, id);
|
|
20
31
|
|
|
32
|
+
const fps = resolveEntryLayout(video.entry).format.fps;
|
|
33
|
+
const frame = framesFromOffset(at, fps);
|
|
34
|
+
|
|
21
35
|
const output = await withServer(config, async (server) => {
|
|
22
36
|
const {durationInFrames, scenes, audio} = await compileInBrowser(server.url, targetFor(video, options.input), config);
|
|
23
37
|
const {manifest, input, prepared} = await freezeManifest(
|
|
@@ -35,6 +49,6 @@ export const stillCommand = async (
|
|
|
35
49
|
return renderStill(server.url, target, frame, file, config);
|
|
36
50
|
});
|
|
37
51
|
|
|
38
|
-
log.success(`
|
|
52
|
+
log.success(`Frame ${frame} written to ${output}`);
|
|
39
53
|
return output;
|
|
40
54
|
};
|
package/src/discovery.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {existsSync} from "node:fs";
|
|
|
3
3
|
import {join, relative, resolve, sep} from "node:path";
|
|
4
4
|
import {hashString} from "odori";
|
|
5
5
|
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {log} from "./log";
|
|
6
7
|
|
|
7
8
|
export type DiscoveredVideo = {
|
|
8
9
|
/** Directory-derived id used before the module is loaded. */
|
|
@@ -19,6 +20,8 @@ export type DiscoveredPreview = {
|
|
|
19
20
|
relativeFile: string;
|
|
20
21
|
importPath: string;
|
|
21
22
|
identifier: string;
|
|
23
|
+
/** Video ids whose composition imports this component. Derived, not declared. */
|
|
24
|
+
usedBy?: string[];
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
export type DiscoveredBrandModule = {
|
|
@@ -37,11 +40,26 @@ export type DiscoveredAudio = {
|
|
|
37
40
|
bytes: number;
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A directory naming itself, from a `category.json` beside the components it
|
|
45
|
+
* holds. The path is what the filesystem already says; this is only how that
|
|
46
|
+
* level should read and where it should sit among its siblings.
|
|
47
|
+
*/
|
|
48
|
+
export type DiscoveredCategory = {
|
|
49
|
+
/** Directory path under the components directory: `product-ui/forms`. */
|
|
50
|
+
path: string;
|
|
51
|
+
/** What to call it. Without one, the directory name is read as words. */
|
|
52
|
+
name?: string;
|
|
53
|
+
/** Sort position among siblings. Unset sorts after, alphabetically. */
|
|
54
|
+
order?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
40
57
|
export type ProjectGraph = {
|
|
41
58
|
videos: DiscoveredVideo[];
|
|
42
59
|
previews: DiscoveredPreview[];
|
|
43
60
|
brands: DiscoveredBrandModule[];
|
|
44
61
|
audio: DiscoveredAudio[];
|
|
62
|
+
categories: DiscoveredCategory[];
|
|
45
63
|
sourceHash: string;
|
|
46
64
|
};
|
|
47
65
|
|
|
@@ -107,7 +125,9 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
107
125
|
const videos: DiscoveredVideo[] = [];
|
|
108
126
|
const previews: DiscoveredPreview[] = [];
|
|
109
127
|
const brands: DiscoveredBrandModule[] = [];
|
|
128
|
+
const categories: DiscoveredCategory[] = [];
|
|
110
129
|
const hashParts: string[] = [];
|
|
130
|
+
const importedBy: Record<string, Set<string>> = {};
|
|
111
131
|
const componentsRoot = resolve(config.root, config.componentsDir);
|
|
112
132
|
|
|
113
133
|
for (const file of files) {
|
|
@@ -119,6 +139,38 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
const base = file.split(sep).pop() ?? "";
|
|
142
|
+
if (base === "category.json") {
|
|
143
|
+
// Beside the components it holds, so a label lives with the thing it
|
|
144
|
+
// labels. A malformed one is reported and skipped: a typo in a display
|
|
145
|
+
// name must not take the project down.
|
|
146
|
+
const path = relative(componentsRoot, resolve(file, "..")).split(sep).join("/");
|
|
147
|
+
if (!path.startsWith("..")) {
|
|
148
|
+
try {
|
|
149
|
+
const declared = JSON.parse(contents) as {name?: unknown; order?: unknown};
|
|
150
|
+
categories.push({
|
|
151
|
+
path,
|
|
152
|
+
...(typeof declared.name === "string" ? {name: declared.name} : {}),
|
|
153
|
+
...(typeof declared.order === "number" ? {order: declared.order} : {}),
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
log.warn(
|
|
157
|
+
`${relativeFile} is not valid JSON, so that directory names itself: ${
|
|
158
|
+
error instanceof Error ? error.message : String(error)
|
|
159
|
+
}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (base === "video.tsx") {
|
|
166
|
+
// The file is already read for the source hash, so the import graph
|
|
167
|
+
// costs nothing to take. A component's audience is a fact about the
|
|
168
|
+
// project rather than something an author should have to restate, and
|
|
169
|
+
// a restated one goes stale the moment a scene is deleted.
|
|
170
|
+
for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
|
|
171
|
+
(importedBy[match[1]] ??= new Set()).add(relative(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
122
174
|
if (base === "video.tsx") {
|
|
123
175
|
// The path under videos/ is the id, the way a route is a path.
|
|
124
176
|
const slug = relative(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
|
|
@@ -166,7 +218,12 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
166
218
|
}
|
|
167
219
|
}
|
|
168
220
|
|
|
169
|
-
|
|
221
|
+
for (const preview of previews) {
|
|
222
|
+
const users = importedBy[preview.name];
|
|
223
|
+
if (users) preview.usedBy = [...users].sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {videos, previews, brands, audio, categories, sourceHash: hashString(hashParts.join("|"))};
|
|
170
227
|
};
|
|
171
228
|
|
|
172
229
|
/**
|
|
@@ -222,7 +279,11 @@ export const writeGenerated = async (config: ResolvedConfig, graph: ProjectGraph
|
|
|
222
279
|
{
|
|
223
280
|
sourceHash: graph.sourceHash,
|
|
224
281
|
videos: graph.videos.map((video) => ({slug: video.slug, file: video.relativeFile})),
|
|
225
|
-
previews: graph.previews.map((preview) => ({
|
|
282
|
+
previews: graph.previews.map((preview) => ({
|
|
283
|
+
name: preview.name,
|
|
284
|
+
file: preview.relativeFile,
|
|
285
|
+
usedBy: preview.usedBy ?? [],
|
|
286
|
+
})),
|
|
226
287
|
brands: graph.brands.map((brand) => ({name: brand.name, file: brand.relativeFile})),
|
|
227
288
|
audio: graph.audio.map((entry) => ({name: entry.name, url: entry.url, file: entry.relativeFile})),
|
|
228
289
|
},
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ export {clearPrepareCache, prepareCacheKey, readPrepareCache, writePrepareCache}
|
|
|
46
46
|
export {devCommand} from "./commands/dev";
|
|
47
47
|
export {cancelJob, exportCommand, jobsCommand, runJob, exportQueue} from "./commands/exportVideo";
|
|
48
48
|
export {chunkFrames, planChunks, type ChunkPlan, type FrameChunk} from "./chunks";
|
|
49
|
-
export {
|
|
49
|
+
export {frameCommand} from "./commands/frame";
|
|
50
50
|
export {testCommand} from "./commands/test";
|
|
51
51
|
export {listCommand} from "./commands/list";
|
|
52
52
|
export {inspectCommand} from "./commands/inspect";
|