@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/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/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";
|
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
|
|