@odori/cli 0.0.3 → 0.0.5
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-RHG23EWW.js} +457 -241
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +45 -5
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-JEVXYGS2.js +4868 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/cli.ts +38 -13
- package/src/commands/add.ts +37 -1
- package/src/commands/dev.ts +32 -2
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +6 -0
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/update.ts +58 -7
- package/src/discovery.ts +63 -2
- package/src/index.ts +1 -1
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +1529 -327
- package/src/registry-source.ts +37 -2
- package/src/render.ts +8 -1
- package/src/server.ts +7 -1
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Inspector.tsx +101 -1
- package/studio/src/components/Navigator.tsx +149 -0
- package/studio/src/components/Settings.tsx +109 -0
- package/studio/src/components/Transport.tsx +98 -55
- package/studio/src/components/ui.tsx +16 -1
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/settings.ts +87 -0
- package/studio/src/studio.css +350 -8
- 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 +33 -6
- 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.5",
|
|
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.5"
|
|
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.5"
|
|
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,11 +10,24 @@ 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>;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Flags that are on or off, and therefore never take the next word.
|
|
20
|
+
*
|
|
21
|
+
* Without this list `odori add --force title-reveal` reads "force" as the
|
|
22
|
+
* string "title-reveal": the flag does nothing and the component quietly
|
|
23
|
+
* vanishes from the list, which is two failures for the price of one and
|
|
24
|
+
* neither of them says anything. A switch has no value to take.
|
|
25
|
+
*/
|
|
26
|
+
const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
|
|
27
|
+
|
|
28
|
+
/** Commands that used to exist under another name. */
|
|
29
|
+
const RENAMED: Record<string, string> = {still: "frame"};
|
|
30
|
+
|
|
18
31
|
export const parseArgs = (argv: string[]): {command: string; positionals: string[]; flags: Flags} => {
|
|
19
32
|
const [command = "help", ...rest] = argv;
|
|
20
33
|
const positionals: string[] = [];
|
|
@@ -31,7 +44,7 @@ export const parseArgs = (argv: string[]): {command: string; positionals: string
|
|
|
31
44
|
}
|
|
32
45
|
const name = token.slice(2);
|
|
33
46
|
const next = rest[index + 1];
|
|
34
|
-
if (next === undefined || next.startsWith("--")) flags[name] = true;
|
|
47
|
+
if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith("--")) flags[name] = true;
|
|
35
48
|
else {
|
|
36
49
|
flags[name] = next;
|
|
37
50
|
index += 1;
|
|
@@ -84,9 +97,9 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
84
97
|
update: ["force"],
|
|
85
98
|
list: [],
|
|
86
99
|
inspect: ["json", "input"],
|
|
87
|
-
|
|
100
|
+
frame: ["at", "output", "input"],
|
|
88
101
|
test: ["json"],
|
|
89
|
-
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
|
|
102
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
|
|
90
103
|
jobs: [],
|
|
91
104
|
help: [],
|
|
92
105
|
};
|
|
@@ -160,18 +173,20 @@ const USAGE: Record<string, string> = {
|
|
|
160
173
|
Print discovered video ids and formats.`,
|
|
161
174
|
inspect: `odori inspect <id> [--json] [--input <json>]
|
|
162
175
|
Show resolved layout, inputs, scenes, and assets.`,
|
|
163
|
-
|
|
164
|
-
Render one deterministic frame
|
|
176
|
+
frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
|
|
177
|
+
Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
|
|
178
|
+
120f for frame 120. A bare number is seconds.`,
|
|
165
179
|
test: `odori test [id] [--json]
|
|
166
180
|
Validate contracts and representative frames. --json emits one object per
|
|
167
181
|
check, for CI.`,
|
|
168
182
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
169
183
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
170
|
-
[--no-frame-skip] [--retry <job>]
|
|
184
|
+
[--no-audio] [--no-frame-skip] [--retry <job>]
|
|
171
185
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
172
186
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
173
187
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
174
|
-
0.25 to 2.
|
|
188
|
+
0.25 to 2. --no-audio writes the picture with no sound. A retry keeps the
|
|
189
|
+
settings its job was created with.`,
|
|
175
190
|
jobs: `odori jobs
|
|
176
191
|
List export jobs and their status.`,
|
|
177
192
|
};
|
|
@@ -190,14 +205,14 @@ Usage
|
|
|
190
205
|
odori update [components] Apply upstream component changes
|
|
191
206
|
odori list Print discovered video ids and formats
|
|
192
207
|
odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
|
|
193
|
-
odori
|
|
208
|
+
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
194
209
|
odori test [id] [--json] Validate contracts and representative frames
|
|
195
210
|
odori export <id> [--output f] Render and encode a distributable file
|
|
196
211
|
odori jobs List export jobs and their status
|
|
197
212
|
|
|
198
213
|
Options
|
|
199
214
|
--input '{"headline":"..."}' Serializable input for the video schema
|
|
200
|
-
--output <path> Output path for
|
|
215
|
+
--output <path> Output path for frame and export
|
|
201
216
|
--force Replace locally modified component source
|
|
202
217
|
--concurrency <n> Parallel render workers for export
|
|
203
218
|
--preset <name> x264 preset for export, default medium
|
|
@@ -280,9 +295,11 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
280
295
|
case "inspect":
|
|
281
296
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
282
297
|
return 0;
|
|
283
|
-
case "
|
|
284
|
-
await
|
|
285
|
-
|
|
298
|
+
case "frame":
|
|
299
|
+
await frameCommand(positionals[0] ?? "", {
|
|
300
|
+
// A duration, so "4s" and "120f" both work; a bare number is
|
|
301
|
+
// seconds, the way every other time value in Odori reads.
|
|
302
|
+
at: typeof flags.at === "string" ? flags.at : (numberFlag(flags, "at") ?? 0),
|
|
286
303
|
output: typeof flags.output === "string" ? flags.output : undefined,
|
|
287
304
|
input: parseInput(flags),
|
|
288
305
|
});
|
|
@@ -299,6 +316,7 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
299
316
|
quality: typeof flags.quality === "string" ? flags.quality : undefined,
|
|
300
317
|
scale: numberFlag(flags, "scale"),
|
|
301
318
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
319
|
+
audio: flags["no-audio"] === true ? false : undefined,
|
|
302
320
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
303
321
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
|
304
322
|
});
|
|
@@ -312,6 +330,13 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
312
330
|
log.info(HELP);
|
|
313
331
|
return 0;
|
|
314
332
|
default: {
|
|
333
|
+
// A rename is not a typo, and edit distance will never connect the old
|
|
334
|
+
// name to the new one. Say what happened instead of listing everything.
|
|
335
|
+
const renamed = RENAMED[command];
|
|
336
|
+
if (renamed) {
|
|
337
|
+
log.error(`"odori ${command}" is now "odori ${renamed}".`);
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
315
340
|
const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
|
|
316
341
|
const suggestion = nearest(command, commands);
|
|
317
342
|
log.error(`Unknown command "${command}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
|
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
|
/**
|
|
@@ -29,6 +30,10 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
29
30
|
|
|
30
31
|
const queue = [...names.map(normalizeComponentName)];
|
|
31
32
|
const installed: string[] = [];
|
|
33
|
+
/* Kept so the summary can repeat itself at the end. Installing a library is
|
|
34
|
+
hundreds of lines of output, and a warning at line 40 is a warning nobody
|
|
35
|
+
read. */
|
|
36
|
+
const kept: string[] = [];
|
|
32
37
|
|
|
33
38
|
while (queue.length > 0) {
|
|
34
39
|
const name = queue.shift()!;
|
|
@@ -46,6 +51,30 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
46
51
|
else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
// An asset is bytes, not source: it is fetched, verified, and written into
|
|
55
|
+
// public/, then registered by URL. Nothing about it is a file to edit, so
|
|
56
|
+
// it never goes through the copy-and-record path below.
|
|
57
|
+
if (component.kind === "asset" && component.asset) {
|
|
58
|
+
const written = await installAsset(config, component, {dryRun: options.dryRun, force: options.force});
|
|
59
|
+
if (!options.dryRun && written) {
|
|
60
|
+
installed.push(component.name);
|
|
61
|
+
const registered = await registerCueInBrand(
|
|
62
|
+
config,
|
|
63
|
+
{name: component.asset.cue, url: component.asset.url},
|
|
64
|
+
component.name,
|
|
65
|
+
);
|
|
66
|
+
if (registered?.already) {
|
|
67
|
+
log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
|
|
68
|
+
} else if (registered) {
|
|
69
|
+
log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
|
|
70
|
+
} else {
|
|
71
|
+
log.warn(` No brand with an audio.cues block found. Add it yourself:`);
|
|
72
|
+
log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
// The item document carries the file contents; the index does not.
|
|
50
79
|
const {item, origin} = await resolveItem(config, component.name);
|
|
51
80
|
// Before anything is written: the bytes have to be the bytes the registry
|
|
@@ -78,7 +107,8 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
78
107
|
const current = hashString(await readFile(destination, "utf8"));
|
|
79
108
|
const recorded = provenance[component.name]?.hashes[name];
|
|
80
109
|
if (current !== recorded) {
|
|
81
|
-
log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version
|
|
110
|
+
log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version.`);
|
|
111
|
+
kept.push(relative(config.root, destination));
|
|
82
112
|
continue;
|
|
83
113
|
}
|
|
84
114
|
}
|
|
@@ -122,6 +152,12 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
122
152
|
return;
|
|
123
153
|
}
|
|
124
154
|
await writeProvenance(config, provenance);
|
|
155
|
+
|
|
156
|
+
if (kept.length > 0) {
|
|
157
|
+
log.warn(`Kept ${kept.length} locally modified ${kept.length === 1 ? "file" : "files"}:`);
|
|
158
|
+
for (const file of kept) log.detail(` ${file}`);
|
|
159
|
+
log.detail(`Run odori diff to see what upstream changed, or odori add <name> --force to replace them.`);
|
|
160
|
+
}
|
|
125
161
|
log.detail("Run odori dev to preview the installed component fixtures.");
|
|
126
162
|
};
|
|
127
163
|
|
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";
|
|
@@ -174,7 +174,10 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
174
174
|
exportDestination(config),
|
|
175
175
|
`${outputName(video.entry.metadata.id)}${format.extension}`,
|
|
176
176
|
);
|
|
177
|
-
|
|
177
|
+
// Frozen with the job: a retry from the CLI has to produce the
|
|
178
|
+
// same file, silence included.
|
|
179
|
+
const audio = body.audio !== false;
|
|
180
|
+
const record = await createJob(config, manifest, output, {format: format.name, quality, scale, audio});
|
|
178
181
|
json(response, 202, record.job);
|
|
179
182
|
|
|
180
183
|
void runJob(config, origin, record, video).catch((error) => {
|
|
@@ -208,6 +211,33 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
208
211
|
return;
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The source that produced the frame you are looking at.
|
|
216
|
+
*
|
|
217
|
+
* Studio shows the path already; a path you cannot read is a
|
|
218
|
+
* riddle. Reading is confined to source files inside the project,
|
|
219
|
+
* because this serves whatever a query string asks for and the
|
|
220
|
+
* only safe answer to "../../.ssh/id_rsa" is no.
|
|
221
|
+
*/
|
|
222
|
+
if (request.method === "GET" && url.startsWith("/source")) {
|
|
223
|
+
const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
|
|
224
|
+
const file = resolve(config.root, asked);
|
|
225
|
+
const inside = relative(config.root, file);
|
|
226
|
+
const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
|
|
227
|
+
if (!inside || inside.startsWith("..") || !readable) {
|
|
228
|
+
json(response, 400, {error: `Refusing to read ${asked}.`});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
response.statusCode = 200;
|
|
233
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
234
|
+
response.end(await readFile(file, "utf8"));
|
|
235
|
+
} catch {
|
|
236
|
+
json(response, 404, {error: `${asked} is not there.`});
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
211
241
|
if (request.method === "GET" && url.startsWith("/jobs")) {
|
|
212
242
|
json(response, 200, await listJobs(config));
|
|
213
243
|
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
|
|
|
@@ -36,6 +36,7 @@ export const runJob = async (
|
|
|
36
36
|
quality?: Quality;
|
|
37
37
|
scale?: number;
|
|
38
38
|
format?: VideoFormat;
|
|
39
|
+
audio?: boolean;
|
|
39
40
|
skipUnchangedFrames?: boolean;
|
|
40
41
|
signal?: AbortSignal;
|
|
41
42
|
onProgress?: (job: ExportJob) => void;
|
|
@@ -94,6 +95,7 @@ export const runJob = async (
|
|
|
94
95
|
quality: options.quality ?? (record.render?.quality as Quality | undefined),
|
|
95
96
|
scale: options.scale ?? record.render?.scale,
|
|
96
97
|
format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : undefined),
|
|
98
|
+
audio: options.audio ?? record.render?.audio,
|
|
97
99
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
98
100
|
signal: controller.signal,
|
|
99
101
|
onTimings: (timings) => {
|
|
@@ -151,6 +153,8 @@ export const exportCommand = async (
|
|
|
151
153
|
quality?: string;
|
|
152
154
|
scale?: number;
|
|
153
155
|
format?: string;
|
|
156
|
+
/** False writes the picture with no audio track. */
|
|
157
|
+
audio?: boolean;
|
|
154
158
|
skipUnchangedFrames?: boolean;
|
|
155
159
|
retry?: string;
|
|
156
160
|
} = {},
|
|
@@ -183,6 +187,7 @@ export const exportCommand = async (
|
|
|
183
187
|
format: format.name,
|
|
184
188
|
quality,
|
|
185
189
|
scale,
|
|
190
|
+
audio: options.audio !== false,
|
|
186
191
|
...(options.preset ? {preset: options.preset} : {}),
|
|
187
192
|
});
|
|
188
193
|
})();
|
|
@@ -201,6 +206,7 @@ export const exportCommand = async (
|
|
|
201
206
|
quality: options.retry && options.quality === undefined ? undefined : quality,
|
|
202
207
|
scale: options.retry && options.scale === undefined ? undefined : scale,
|
|
203
208
|
format: options.retry ? undefined : format,
|
|
209
|
+
audio: options.audio,
|
|
204
210
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
205
211
|
onProgress: (next) => {
|
|
206
212
|
if (next.status === "rendering" || next.status === "encoding") {
|
|
@@ -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
|
};
|