@odori/cli 0.0.4 → 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-NYXWEZU2.js → chunk-RHG23EWW.js} +78 -35
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-MSH2EA36.js → registry-snapshot-JEVXYGS2.js} +13 -12
- package/package.json +3 -3
- package/src/cli.ts +26 -4
- package/src/commands/add.ts +12 -1
- package/src/commands/dev.ts +4 -1
- package/src/commands/exportVideo.ts +6 -0
- package/src/commands/update.ts +58 -7
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +13 -13
- package/src/render.ts +8 -1
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Navigator.tsx +5 -1
- 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/settings.ts +87 -0
- package/studio/src/studio.css +96 -1
- package/studio/src/views/VideosView.tsx +12 -5
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/cli.ts
CHANGED
|
@@ -15,6 +15,19 @@ 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;
|
|
@@ -86,7 +99,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
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
|
};
|
|
@@ -168,11 +181,12 @@ const USAGE: Record<string, string> = {
|
|
|
168
181
|
check, for CI.`,
|
|
169
182
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
170
183
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
171
|
-
[--no-frame-skip] [--retry <job>]
|
|
184
|
+
[--no-audio] [--no-frame-skip] [--retry <job>]
|
|
172
185
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
173
186
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
174
187
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
175
|
-
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.`,
|
|
176
190
|
jobs: `odori jobs
|
|
177
191
|
List export jobs and their status.`,
|
|
178
192
|
};
|
|
@@ -302,6 +316,7 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
302
316
|
quality: typeof flags.quality === "string" ? flags.quality : undefined,
|
|
303
317
|
scale: numberFlag(flags, "scale"),
|
|
304
318
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
319
|
+
audio: flags["no-audio"] === true ? false : undefined,
|
|
305
320
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
306
321
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
|
307
322
|
});
|
|
@@ -315,6 +330,13 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
315
330
|
log.info(HELP);
|
|
316
331
|
return 0;
|
|
317
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
|
+
}
|
|
318
340
|
const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
|
|
319
341
|
const suggestion = nearest(command, commands);
|
|
320
342
|
log.error(`Unknown command "${command}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
|
package/src/commands/add.ts
CHANGED
|
@@ -30,6 +30,10 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
30
30
|
|
|
31
31
|
const queue = [...names.map(normalizeComponentName)];
|
|
32
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[] = [];
|
|
33
37
|
|
|
34
38
|
while (queue.length > 0) {
|
|
35
39
|
const name = queue.shift()!;
|
|
@@ -103,7 +107,8 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
103
107
|
const current = hashString(await readFile(destination, "utf8"));
|
|
104
108
|
const recorded = provenance[component.name]?.hashes[name];
|
|
105
109
|
if (current !== recorded) {
|
|
106
|
-
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));
|
|
107
112
|
continue;
|
|
108
113
|
}
|
|
109
114
|
}
|
|
@@ -147,6 +152,12 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
147
152
|
return;
|
|
148
153
|
}
|
|
149
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
|
+
}
|
|
150
161
|
log.detail("Run odori dev to preview the installed component fixtures.");
|
|
151
162
|
};
|
|
152
163
|
|
package/src/commands/dev.ts
CHANGED
|
@@ -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) => {
|
|
@@ -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") {
|
package/src/commands/update.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {mkdir, readFile, writeFile} from "node:fs/promises";
|
|
1
|
+
import {mkdir, readFile, rm, writeFile} from "node:fs/promises";
|
|
2
2
|
import {existsSync} from "node:fs";
|
|
3
|
+
import {readdir} from "node:fs/promises";
|
|
3
4
|
import {relative, resolve} from "node:path";
|
|
4
5
|
import {hashString} from "odori";
|
|
5
6
|
import {loadConfig, type ResolvedConfig} from "../config";
|
|
@@ -28,17 +29,45 @@ export type ComponentStatus = {
|
|
|
28
29
|
}>;
|
|
29
30
|
};
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The record of what was installed, and at which version.
|
|
34
|
+
*
|
|
35
|
+
* It sits beside the config and is meant to be committed. It used to live in
|
|
36
|
+
* `outDir`, which is generated and gitignored, so it did not survive a clone
|
|
37
|
+
* or a cleared render cache: `odori update` and `odori diff` would report an
|
|
38
|
+
* empty project while the components sat right there in the tree, and the
|
|
39
|
+
* suggested fix - run `odori add` - was the one thing that had already been
|
|
40
|
+
* done. A lockfile is only useful to the next person if it is in the repo.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCKFILE = "odori.lock.json";
|
|
43
|
+
|
|
44
|
+
const provenanceFile = (config: ResolvedConfig) => resolve(config.root, LOCKFILE);
|
|
45
|
+
|
|
46
|
+
/** Where it used to live, read once so an existing project keeps its history. */
|
|
47
|
+
const legacyProvenanceFile = (config: ResolvedConfig) =>
|
|
48
|
+
resolve(config.root, config.outDir, "components.json");
|
|
32
49
|
|
|
33
50
|
export const readProvenance = async (config: ResolvedConfig): Promise<Provenance> => {
|
|
34
|
-
const file = provenanceFile(config)
|
|
51
|
+
const file = existsSync(provenanceFile(config))
|
|
52
|
+
? provenanceFile(config)
|
|
53
|
+
: legacyProvenanceFile(config);
|
|
35
54
|
if (!existsSync(file)) return {};
|
|
36
|
-
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(await readFile(file, "utf8")) as Provenance;
|
|
57
|
+
} catch {
|
|
58
|
+
// A truncated or hand-mangled lockfile should cost you the update, not the
|
|
59
|
+
// command: treat it as unknown and let the next install rewrite it.
|
|
60
|
+
log.warn(`${relative(config.root, file)} is not readable JSON. Ignoring it.`);
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
37
63
|
};
|
|
38
64
|
|
|
39
65
|
export const writeProvenance = async (config: ResolvedConfig, provenance: Provenance) => {
|
|
40
|
-
await mkdir(
|
|
66
|
+
await mkdir(config.root, {recursive: true});
|
|
41
67
|
await writeFile(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
|
|
68
|
+
// Moved, not copied: leaving the old one behind means the next reader has to
|
|
69
|
+
// guess which of the two is current.
|
|
70
|
+
await rm(legacyProvenanceFile(config), {force: true});
|
|
42
71
|
};
|
|
43
72
|
|
|
44
73
|
/**
|
|
@@ -109,11 +138,33 @@ const LABELS: Record<ComponentState, string> = {
|
|
|
109
138
|
missing: "files missing",
|
|
110
139
|
};
|
|
111
140
|
|
|
141
|
+
/**
|
|
142
|
+
* What to say when there is nothing to compare against.
|
|
143
|
+
*
|
|
144
|
+
* "Run odori add first" is the wrong advice if the components are already on
|
|
145
|
+
* disk: the missing piece is the lockfile, not the install, and telling
|
|
146
|
+
* somebody to do the thing they just did is how a tool loses their trust.
|
|
147
|
+
*/
|
|
148
|
+
const explainEmpty = async (config: ResolvedConfig, named: string[]) => {
|
|
149
|
+
if (named.length > 0) {
|
|
150
|
+
log.detail(`${named.join(", ")} ${named.length === 1 ? "is" : "are"} not recorded in ${LOCKFILE}.`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const components = resolve(config.root, config.componentsDir);
|
|
154
|
+
const installed = existsSync(components) ? (await readdir(components)).filter((e) => !e.startsWith(".")) : [];
|
|
155
|
+
if (installed.length === 0) {
|
|
156
|
+
log.detail("No registry components are installed yet. Run odori add first.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
log.warn(`${installed.length} components are in ${config.componentsDir} but none are recorded in ${LOCKFILE}.`);
|
|
160
|
+
log.detail("Run odori add <name> to re-record them, or commit the lockfile if a teammate has one.");
|
|
161
|
+
};
|
|
162
|
+
|
|
112
163
|
export const diffCommand = async (names: string[], options: {full?: boolean} = {}) => {
|
|
113
164
|
const config = await loadConfig(process.cwd());
|
|
114
165
|
const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
|
|
115
166
|
if (statuses.length === 0) {
|
|
116
|
-
|
|
167
|
+
await explainEmpty(config, names);
|
|
117
168
|
return;
|
|
118
169
|
}
|
|
119
170
|
|
|
@@ -141,7 +192,7 @@ export const updateCommand = async (names: string[], options: {force?: boolean}
|
|
|
141
192
|
const config = await loadConfig(process.cwd());
|
|
142
193
|
const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
|
|
143
194
|
if (statuses.length === 0) {
|
|
144
|
-
|
|
195
|
+
await explainEmpty(config, names);
|
|
145
196
|
return;
|
|
146
197
|
}
|
|
147
198
|
|
package/src/jobs.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {ExportJob, RenderManifest} from "odori";
|
|
|
5
5
|
import type {ResolvedConfig} from "./config";
|
|
6
6
|
|
|
7
7
|
/** How a job should be encoded, frozen with it so a retry cannot drift. */
|
|
8
|
-
export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string};
|
|
8
|
+
export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean};
|
|
9
9
|
|
|
10
10
|
export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
|
|
11
11
|
|