@odori/cli 0.0.2

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.
Files changed (70) hide show
  1. package/LICENSE +22 -0
  2. package/bin/odori.mjs +39 -0
  3. package/dist/chunk-7XJL2BYO.js +3552 -0
  4. package/dist/cli.d.ts +10 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/index.d.ts +622 -0
  7. package/dist/index.js +156 -0
  8. package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
  9. package/package.json +50 -0
  10. package/src/audio-mix.ts +133 -0
  11. package/src/binaries.ts +241 -0
  12. package/src/brand-file.ts +94 -0
  13. package/src/chunk-cache.ts +85 -0
  14. package/src/chunks.ts +78 -0
  15. package/src/cli.ts +319 -0
  16. package/src/commands/add.ts +151 -0
  17. package/src/commands/dev.ts +160 -0
  18. package/src/commands/doctor.ts +162 -0
  19. package/src/commands/exportVideo.ts +198 -0
  20. package/src/commands/init.ts +56 -0
  21. package/src/commands/inspect.ts +72 -0
  22. package/src/commands/list.ts +22 -0
  23. package/src/commands/new.ts +126 -0
  24. package/src/commands/shared.ts +96 -0
  25. package/src/commands/still.ts +40 -0
  26. package/src/commands/test.ts +265 -0
  27. package/src/commands/update.ts +183 -0
  28. package/src/config.ts +84 -0
  29. package/src/contracts.ts +159 -0
  30. package/src/cues.ts +141 -0
  31. package/src/determinism.ts +82 -0
  32. package/src/diff.ts +71 -0
  33. package/src/discovery.ts +216 -0
  34. package/src/formats.ts +119 -0
  35. package/src/index.ts +58 -0
  36. package/src/integrity.ts +101 -0
  37. package/src/jobs.ts +151 -0
  38. package/src/log.ts +17 -0
  39. package/src/open.ts +32 -0
  40. package/src/paths.ts +12 -0
  41. package/src/prepare-cache.ts +58 -0
  42. package/src/project.ts +196 -0
  43. package/src/registry-snapshot.json +3431 -0
  44. package/src/registry-source.ts +269 -0
  45. package/src/render.ts +627 -0
  46. package/src/server.ts +307 -0
  47. package/studio/index.html +41 -0
  48. package/studio/src/Studio.tsx +192 -0
  49. package/studio/src/components/AudioClip.tsx +64 -0
  50. package/studio/src/components/CanvasStage.tsx +79 -0
  51. package/studio/src/components/CommandPalette.tsx +129 -0
  52. package/studio/src/components/Diagnostics.tsx +93 -0
  53. package/studio/src/components/ExportPanel.tsx +234 -0
  54. package/studio/src/components/InputControls.tsx +110 -0
  55. package/studio/src/components/Thumbnail.tsx +71 -0
  56. package/studio/src/components/Transport.tsx +237 -0
  57. package/studio/src/components/Waveform.tsx +114 -0
  58. package/studio/src/components/Wordmark.tsx +449 -0
  59. package/studio/src/components/ui.tsx +138 -0
  60. package/studio/src/lib/mix-loudness.ts +52 -0
  61. package/studio/src/main.tsx +34 -0
  62. package/studio/src/shortcuts.ts +27 -0
  63. package/studio/src/studio.css +1232 -0
  64. package/studio/src/theme.ts +61 -0
  65. package/studio/src/views/AssetsView.tsx +111 -0
  66. package/studio/src/views/BrandsView.tsx +139 -0
  67. package/studio/src/views/ComponentsView.tsx +285 -0
  68. package/studio/src/views/HomeView.tsx +122 -0
  69. package/studio/src/views/VideosView.tsx +343 -0
  70. package/studio/src/virtual.d.ts +25 -0
@@ -0,0 +1,96 @@
1
+ import {resolveEntryLayout, type AudioCue, type ManifestAudioCue, type Brand} from "odori";
2
+ import {loadConfig, type ResolvedConfig} from "../config";
3
+ import {registerCues} from "../cues";
4
+ import {discoverProject, type ProjectGraph} from "../discovery";
5
+ import {loadVideos, type LoadedVideo} from "../project";
6
+ import {openRenderPage, readAudio, readTimeline, type RenderTarget} from "../render";
7
+ import {startStudioServer, type StudioServer} from "../server";
8
+
9
+ export type Context = {config: ResolvedConfig; graph: ProjectGraph; videos: LoadedVideo[]};
10
+
11
+ export const createContext = async (root = process.cwd()): Promise<Context> => {
12
+ const config = await loadConfig(root);
13
+ const graph = await discoverProject(config);
14
+ const videos = await loadVideos(graph);
15
+ // Generated cues are served by their content hash, so the server has to know
16
+ // which scores exist before a preview asks for one.
17
+ const brands = new Map<string, Brand>();
18
+ for (const video of videos) {
19
+ const layout = resolveEntryLayout(video.entry);
20
+ brands.set(layout.brand.name, layout.brand);
21
+ registerCues([layout.brand], layout.format.fps);
22
+ }
23
+ return {config, graph, videos};
24
+ };
25
+
26
+ export const targetFor = (
27
+ video: LoadedVideo,
28
+ input?: Record<string, unknown>,
29
+ prepared?: unknown,
30
+ audio?: ManifestAudioCue[],
31
+ scenes?: Array<{id: string; start: number; durationInFrames: number}>,
32
+ ): RenderTarget => {
33
+ const layout = resolveEntryLayout(video.entry);
34
+ return {
35
+ videoId: video.entry.metadata.id,
36
+ width: layout.format.width,
37
+ height: layout.format.height,
38
+ fps: layout.format.fps,
39
+ durationInFrames: video.durationInFrames,
40
+ input,
41
+ prepared,
42
+ audio,
43
+ scenes,
44
+ targetLufs: layout.audio.targetLufs,
45
+ };
46
+ };
47
+
48
+ /**
49
+ * `metadata.duration` is authoritative. When it is absent, the compiled
50
+ * timeline reported by the runtime is used instead.
51
+ */
52
+ export type CompileResult = {
53
+ durationInFrames: number;
54
+ scenes: Array<{id: string; start: number; durationInFrames: number}>;
55
+ audio: AudioCue[];
56
+ };
57
+
58
+ /**
59
+ * Compile the timeline and the audio track in the browser, where the runtime
60
+ * already knows how to lay both out. `metadata.duration` stays authoritative
61
+ * when it is declared.
62
+ */
63
+ export const compileInBrowser = async (
64
+ origin: string,
65
+ target: RenderTarget,
66
+ config: ResolvedConfig,
67
+ ): Promise<CompileResult> => {
68
+ const {browser, page} = await openRenderPage(origin, target, config);
69
+ try {
70
+ const timeline = await readTimeline(page);
71
+ const track = await readAudio(page);
72
+ return {
73
+ durationInFrames: target.durationInFrames || timeline.durationInFrames,
74
+ scenes: timeline.scenes.map((scene) => ({
75
+ id: scene.id,
76
+ start: scene.start,
77
+ durationInFrames: scene.durationInFrames,
78
+ })),
79
+ audio: track.cues,
80
+ };
81
+ } finally {
82
+ await browser.close();
83
+ }
84
+ };
85
+
86
+ export const withServer = async <Value>(
87
+ config: ResolvedConfig,
88
+ handler: (server: StudioServer) => Promise<Value>,
89
+ ): Promise<Value> => {
90
+ const server = await startStudioServer(config, {port: 0});
91
+ try {
92
+ return await handler(server);
93
+ } finally {
94
+ await server.close();
95
+ }
96
+ };
@@ -0,0 +1,40 @@
1
+ import {resolve} from "node:path";
2
+ import {log} from "../log";
3
+ import {findVideo, freezeManifest, outputName} from "../project";
4
+ import {renderStill} from "../render";
5
+ import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
6
+
7
+ export const stillCommand = async (
8
+ id: string,
9
+ options: {frame?: number; output?: string; input?: Record<string, unknown>} = {},
10
+ ) => {
11
+ const frame = options.frame ?? 0;
12
+ // A frame is an index into the timeline. Checking it first means a typo
13
+ // costs nothing: no discovery, no dev server, no browser.
14
+ if (!Number.isInteger(frame) || frame < 0) {
15
+ throw new Error(`Frame must be a whole number of frames from the start, got ${String(frame)}.`);
16
+ }
17
+
18
+ const {config, graph, videos} = await createContext();
19
+ const video = findVideo(videos, id);
20
+
21
+ const output = await withServer(config, async (server) => {
22
+ const {durationInFrames, scenes, audio} = await compileInBrowser(server.url, targetFor(video, options.input), config);
23
+ const {manifest, input, prepared} = await freezeManifest(
24
+ {...video, durationInFrames},
25
+ graph,
26
+ config,
27
+ options.input ?? {},
28
+ {scenes, audio},
29
+ );
30
+ if (frame >= manifest.format.durationInFrames) {
31
+ throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
32
+ }
33
+ const target = targetFor({...video, durationInFrames: manifest.format.durationInFrames}, input, prepared);
34
+ const file = resolve(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
35
+ return renderStill(server.url, target, frame, file, config);
36
+ });
37
+
38
+ log.success(`Still frame ${frame} written to ${output}`);
39
+ return output;
40
+ };
@@ -0,0 +1,265 @@
1
+ import {isOdoriSchema, resolveEntryLayout} from "odori";
2
+ import {log} from "../log";
3
+ import {openRenderPage, readAudio, readTimeline, seekTo} from "../render";
4
+ import {checkAudioWindows, checkInstalledContracts} from "../contracts";
5
+ import {checkDeterminism} from "../determinism";
6
+ import {createContext, targetFor, withServer} from "./shared";
7
+ import type {LoadedVideo} from "../project";
8
+ import type {ResolvedConfig} from "../config";
9
+
10
+ type Failure = {video: string; message: string};
11
+
12
+ type FrameReport = {overflow: string[]; small: string[]; empty: boolean; painted: number};
13
+
14
+ /**
15
+ * A fingerprint of every canvas on the frame.
16
+ *
17
+ * A canvas is the one surface whose contents the DOM cannot describe, so the
18
+ * only way to know it drew what the frame asked for is to read the pixels. The
19
+ * hash is cheap and coarse — a sample grid, not the whole buffer — which is
20
+ * enough to catch the failure that matters: a canvas that paints something
21
+ * different the second time the same frame is rendered.
22
+ */
23
+ const CANVAS_SCRIPT = `(() => {
24
+ var root = document.querySelector("[data-odori-video]");
25
+ if (!root) return [];
26
+
27
+ // Only canvases that are actually on screen. The runtime mounts every scene
28
+ // a second time, hidden, to collect audio cues, and a hidden copy is not
29
+ // what the export captures.
30
+ var canvases = Array.prototype.slice.call(root.querySelectorAll("canvas")).filter(function (canvas) {
31
+ var box = canvas.getBoundingClientRect();
32
+ if (box.width === 0 || box.height === 0) return false;
33
+ var style = getComputedStyle(canvas);
34
+ return style.visibility !== "hidden" && Number(style.opacity) > 0.02;
35
+ });
36
+
37
+ return canvases.map(function (canvas, index) {
38
+ var context = canvas.getContext("2d");
39
+ if (!context || canvas.width === 0 || canvas.height === 0) {
40
+ return {index: index, hash: "no-context", blank: true};
41
+ }
42
+ var data;
43
+ try {
44
+ data = context.getImageData(0, 0, canvas.width, canvas.height).data;
45
+ } catch (error) {
46
+ return {index: index, hash: "tainted", blank: false};
47
+ }
48
+
49
+ // A prime stride over the whole buffer rather than a coarse grid: a grid
50
+ // steps over thin content and calls a drawn canvas blank, which is a
51
+ // failure report about the checker rather than about the video.
52
+ var pixels = canvas.width * canvas.height;
53
+ var stride = 97;
54
+ var hash = 5381;
55
+ var opaque = 0;
56
+ for (var pixel = 0; pixel < pixels; pixel += stride) {
57
+ var offset = pixel * 4;
58
+ if (data[offset + 3] > 8) opaque += 1;
59
+ hash =
60
+ ((hash << 5) + hash + data[offset] + data[offset + 1] * 3 + data[offset + 2] * 7 + data[offset + 3] * 11) | 0;
61
+ }
62
+ return {index: index, hash: String(hash), blank: opaque === 0};
63
+ });
64
+ })()`;
65
+
66
+ type CanvasReport = Array<{index: number; hash: string; blank: boolean}>;
67
+
68
+ /**
69
+ * Evaluated as source in the page so no bundler helpers leak into the browser.
70
+ * Only content a viewer can actually read or see is worth flagging.
71
+ */
72
+ const FRAME_SCRIPT = `(() => {
73
+ var root = document.querySelector("[data-odori-video]");
74
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0};
75
+
76
+ var bounds = root.getBoundingClientRect();
77
+ var overflow = [];
78
+ var small = [];
79
+ var painted = 0;
80
+ var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
81
+
82
+ for (var index = 0; index < nodes.length; index += 1) {
83
+ var node = nodes[index];
84
+ var box = node.getBoundingClientRect();
85
+ if (box.width === 0 || box.height === 0) continue;
86
+ var style = getComputedStyle(node);
87
+ if (style.visibility === "hidden" || Number(style.opacity) < 0.02) continue;
88
+ painted += 1;
89
+
90
+ var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
91
+ var text = "";
92
+ for (var child = 0; child < node.childNodes.length; child += 1) {
93
+ var childNode = node.childNodes[child];
94
+ if (childNode.nodeType === 3) text += childNode.textContent || "";
95
+ }
96
+ text = text.trim();
97
+ if (!media && text.length === 0) continue;
98
+
99
+ var label = text.length > 0 ? '"' + text.slice(0, 32) + '"' : "<" + node.tagName.toLowerCase() + ">";
100
+ if (
101
+ box.right > bounds.right + 1 ||
102
+ box.left < bounds.left - 1 ||
103
+ box.bottom > bounds.bottom + 1 ||
104
+ box.top < bounds.top - 1
105
+ ) {
106
+ if (overflow.indexOf(label) < 0) overflow.push(label);
107
+ }
108
+
109
+ // Normalize against the shorter side, the same reference useDesignScale
110
+ // uses, so a vertical cut is not judged as if it were letterboxed.
111
+ var reference = Math.min(bounds.width, bounds.height);
112
+ var relative = (parseFloat(style.fontSize) / reference) * 1080;
113
+ if (text.length > 0 && relative > 0 && relative < 20) {
114
+ var note = label + " at " + Math.round(relative) + "px";
115
+ if (small.indexOf(note) < 0) small.push(note);
116
+ }
117
+ }
118
+
119
+ return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted};
120
+ })()`;
121
+
122
+ /**
123
+ * Contract checks plus representative frames: mount, sample, and look for
124
+ * empty frames and content that escapes the canvas.
125
+ */
126
+ const testVideo = async (
127
+ origin: string,
128
+ video: LoadedVideo,
129
+ config: ResolvedConfig,
130
+ failures: Failure[],
131
+ quiet = false,
132
+ ): Promise<void> => {
133
+ const id = video.entry.metadata.id;
134
+ const layout = resolveEntryLayout(video.entry);
135
+
136
+ if (isOdoriSchema(video.entry.metadata.schema)) {
137
+ const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
138
+ if (!result.success) failures.push({video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}`});
139
+ }
140
+
141
+ const {browser, page} = await openRenderPage(origin, targetFor(video), config);
142
+ try {
143
+ const timeline = await readTimeline(page);
144
+ // The track is compiled by the same pass the player and the encoder read,
145
+ // so a window that outlasts its sound is caught here rather than heard.
146
+ failures.push(...checkAudioWindows((await readAudio(page)).cues, layout.brand, id));
147
+ const total = video.durationInFrames || timeline.durationInFrames;
148
+ if (!total) {
149
+ failures.push({video: id, message: "No duration could be resolved."});
150
+ return;
151
+ }
152
+ if (video.durationInFrames && timeline.durationInFrames && video.durationInFrames !== timeline.durationInFrames) {
153
+ failures.push({
154
+ video: id,
155
+ message: `metadata.duration is ${video.durationInFrames} frames but scenes total ${timeline.durationInFrames}.`,
156
+ });
157
+ }
158
+
159
+ const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor((total * 3) / 4), total - 1];
160
+ for (const frame of [...new Set(samples)]) {
161
+ await seekTo(page, frame);
162
+ const result = (await page.evaluate(FRAME_SCRIPT)) as FrameReport;
163
+ if (result.empty) failures.push({video: id, message: `Frame ${frame} rendered no video root.`});
164
+ if (!result.empty && result.painted < 2) {
165
+ failures.push({video: id, message: `Frame ${frame} is blank.`});
166
+ }
167
+ for (const item of result.overflow) {
168
+ failures.push({
169
+ video: id,
170
+ message: `Frame ${frame}: ${item} escapes the ${layout.format.width}x${layout.format.height} canvas.`,
171
+ });
172
+ }
173
+ for (const item of result.small) {
174
+ failures.push({video: id, message: `Frame ${frame}: ${item} is too small to read at 1080p.`});
175
+ }
176
+
177
+ // Canvas parity: the same frame, rendered twice, has to produce the same
178
+ // pixels. A canvas animated by requestAnimationFrame or a wall clock
179
+ // passes every other check here and then differs between the workers
180
+ // that render neighbouring chunks of the export.
181
+ const canvases = (await page.evaluate(CANVAS_SCRIPT)) as CanvasReport;
182
+ if (canvases.length > 0) {
183
+ // Seek away and back, so the second read is a real re-render rather
184
+ // than a second look at the same paint.
185
+ await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
186
+ await seekTo(page, frame);
187
+ const again = (await page.evaluate(CANVAS_SCRIPT)) as CanvasReport;
188
+
189
+ for (const canvas of canvases) {
190
+ const second = again.find((item) => item.index === canvas.index);
191
+ if (canvas.blank) {
192
+ failures.push({video: id, message: `Frame ${frame}: canvas ${canvas.index} drew nothing.`});
193
+ continue;
194
+ }
195
+ if (canvas.hash === "tainted") {
196
+ failures.push({
197
+ video: id,
198
+ message: `Frame ${frame}: canvas ${canvas.index} is tainted by a cross-origin draw, so the export cannot read it. Serve the image from public/ or inline it as a data URL.`,
199
+ });
200
+ continue;
201
+ }
202
+ if (second && second.hash !== canvas.hash) {
203
+ failures.push({
204
+ video: id,
205
+ message:
206
+ `Frame ${frame}: canvas ${canvas.index} drew differently the second time. ` +
207
+ "A canvas must be a function of the frame: draw from useFrame() through useCanvas(), not from requestAnimationFrame or a clock.",
208
+ });
209
+ }
210
+ }
211
+ }
212
+ }
213
+ if (!quiet) log.success(`${id}: ${[...new Set(samples)].length} frames sampled across ${total} frames`);
214
+ } finally {
215
+ await browser.close();
216
+ }
217
+ };
218
+
219
+ export const testCommand = async (id?: string, options: {json?: boolean} = {}) => {
220
+ const {config, videos} = await createContext();
221
+ const selected = id ? videos.filter((video) => video.entry.metadata.id === id) : videos;
222
+ if (selected.length === 0) throw new Error(id ? `Unknown video "${id}".` : "No videos discovered.");
223
+
224
+ const failures: Failure[] = [];
225
+ // Contracts are checked before a browser starts: a missing cue is a fact
226
+ // about the project, not about a frame.
227
+ failures.push(...(await checkInstalledContracts(config, selected)));
228
+
229
+ // So is a wall clock in a composition. It would render fine here and differ
230
+ // on the next machine, which is the failure mode a test is for.
231
+ for (const finding of await checkDeterminism(config)) {
232
+ failures.push({
233
+ video: `${finding.file}:${finding.line}`,
234
+ message: `${finding.message}\n ${finding.source}`,
235
+ });
236
+ }
237
+
238
+ await withServer(config, async (server) => {
239
+ for (const video of selected) await testVideo(server.url, video, config, failures, options.json === true);
240
+ });
241
+
242
+ if (options.json === true) {
243
+ // One object, so a CI step can read a result without scraping lines. It
244
+ // goes to stdout alone; everything human went to stderr or was suppressed.
245
+ process.stdout.write(
246
+ `${JSON.stringify(
247
+ {
248
+ ok: failures.length === 0,
249
+ videos: selected.map((video) => video.entry.metadata.id),
250
+ failures,
251
+ },
252
+ null,
253
+ 2,
254
+ )}\n`,
255
+ );
256
+ if (failures.length > 0) throw new Error(`${failures.length} check${failures.length === 1 ? "" : "s"} failed.`);
257
+ return;
258
+ }
259
+
260
+ if (failures.length > 0) {
261
+ for (const failure of failures) log.error(`${failure.video}: ${failure.message}`);
262
+ throw new Error(`${failures.length} check${failures.length === 1 ? "" : "s"} failed.`);
263
+ }
264
+ log.success(`All checks passed for ${selected.length} video${selected.length === 1 ? "" : "s"}.`);
265
+ };
@@ -0,0 +1,183 @@
1
+ import {mkdir, readFile, writeFile} from "node:fs/promises";
2
+ import {existsSync} from "node:fs";
3
+ import {relative, resolve} from "node:path";
4
+ import {hashString} from "odori";
5
+ import {loadConfig, type ResolvedConfig} from "../config";
6
+ import {countChanges, diffLines, formatDiff} from "../diff";
7
+ import {normalizeComponentName, resolveItem, resolveRegistry} from "../registry-source";
8
+ import {log} from "../log";
9
+
10
+ export type Provenance = Record<
11
+ string,
12
+ {source: string; version: string; installedAt: string; hashes: Record<string, string>}
13
+ >;
14
+
15
+ export type ComponentState = "pristine" | "modified" | "outdated" | "diverged" | "missing";
16
+
17
+ export type ComponentStatus = {
18
+ name: string;
19
+ state: ComponentState;
20
+ files: Array<{
21
+ file: string;
22
+ localPath: string;
23
+ /** Upstream source itself. The registry is documents now, not a directory. */
24
+ content: string;
25
+ local: string | null;
26
+ installed: string | null;
27
+ upstream: string;
28
+ }>;
29
+ };
30
+
31
+ const provenanceFile = (config: ResolvedConfig) => resolve(config.root, config.outDir, "components.json");
32
+
33
+ export const readProvenance = async (config: ResolvedConfig): Promise<Provenance> => {
34
+ const file = provenanceFile(config);
35
+ if (!existsSync(file)) return {};
36
+ return JSON.parse(await readFile(file, "utf8")) as Provenance;
37
+ };
38
+
39
+ export const writeProvenance = async (config: ResolvedConfig, provenance: Provenance) => {
40
+ await mkdir(resolve(config.root, config.outDir), {recursive: true});
41
+ await writeFile(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
42
+ };
43
+
44
+ /**
45
+ * Compare installed component source with the version that was installed and
46
+ * with the version the registry ships today.
47
+ *
48
+ * - pristine: identical everywhere
49
+ * - modified: the project edited it, upstream has not moved
50
+ * - outdated: upstream moved, the project did not edit it
51
+ * - diverged: both moved, so an update would overwrite local work
52
+ */
53
+ export const componentStatus = async (config: ResolvedConfig, only?: string[]): Promise<ComponentStatus[]> => {
54
+ const {items: registry} = await resolveRegistry(config);
55
+ const provenance = await readProvenance(config);
56
+ const wanted = only?.map(normalizeComponentName);
57
+ const names = Object.keys(provenance).filter((name) => !wanted || wanted.includes(name));
58
+
59
+ const statuses: ComponentStatus[] = [];
60
+ for (const name of names) {
61
+ const component = registry.find((item) => item.name === name);
62
+ if (!component) continue;
63
+
64
+ // One document per component carries every file's contents, so upstream is
65
+ // read once here rather than opened per file.
66
+ const {item} = await resolveItem(config, name);
67
+ const upstreamFiles = new Map(item.files.map((file) => [file.path.split("/").pop() ?? file.path, file.content]));
68
+
69
+ const files = await Promise.all(
70
+ component.files.map(async (file) => {
71
+ const localPath = resolve(config.root, config.componentsDir, name, file);
72
+ const content = upstreamFiles.get(file);
73
+ if (content === undefined) throw new Error(`The registry document for "${name}" has no file named ${file}.`);
74
+ const local = existsSync(localPath) ? hashString(await readFile(localPath, "utf8")) : null;
75
+ return {
76
+ file,
77
+ localPath,
78
+ content,
79
+ local,
80
+ installed: provenance[name]?.hashes[file] ?? null,
81
+ upstream: hashString(content),
82
+ };
83
+ }),
84
+ );
85
+
86
+ const missing = files.some((file) => file.local === null);
87
+ const modified = files.some((file) => file.local !== null && file.local !== file.installed);
88
+ const outdated = files.some((file) => file.installed !== file.upstream);
89
+ const state: ComponentState = missing
90
+ ? "missing"
91
+ : modified && outdated
92
+ ? "diverged"
93
+ : modified
94
+ ? "modified"
95
+ : outdated
96
+ ? "outdated"
97
+ : "pristine";
98
+
99
+ statuses.push({name, state, files});
100
+ }
101
+ return statuses.sort((left, right) => left.name.localeCompare(right.name));
102
+ };
103
+
104
+ const LABELS: Record<ComponentState, string> = {
105
+ pristine: "up to date",
106
+ modified: "modified locally",
107
+ outdated: "update available",
108
+ diverged: "modified locally and updated upstream",
109
+ missing: "files missing",
110
+ };
111
+
112
+ export const diffCommand = async (names: string[], options: {full?: boolean} = {}) => {
113
+ const config = await loadConfig(process.cwd());
114
+ const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
115
+ if (statuses.length === 0) {
116
+ log.detail("No registry components are installed yet. Run odori add first.");
117
+ return;
118
+ }
119
+
120
+ for (const status of statuses) {
121
+ log.title(`@odori/${status.name} ${LABELS[status.state]}`);
122
+ for (const file of status.files) {
123
+ if (file.local === null) {
124
+ log.error(` ${file.file} is missing from ${relative(config.root, resolve(file.localPath, ".."))}`);
125
+ continue;
126
+ }
127
+ if (file.local === file.upstream) {
128
+ log.detail(` ${file.file} identical to upstream`);
129
+ continue;
130
+ }
131
+ const lines = diffLines(await readFile(file.localPath, "utf8"), file.content);
132
+ const {added, removed} = countChanges(lines);
133
+ log.info(` ${file.file} +${added} -${removed} against upstream`);
134
+ if (options.full) for (const line of formatDiff(lines)) log.detail(` ${line}`);
135
+ }
136
+ }
137
+ if (!options.full) log.detail("Pass --full to print the diff.");
138
+ };
139
+
140
+ export const updateCommand = async (names: string[], options: {force?: boolean} = {}) => {
141
+ const config = await loadConfig(process.cwd());
142
+ const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
143
+ if (statuses.length === 0) {
144
+ log.detail("No registry components are installed yet. Run odori add first.");
145
+ return;
146
+ }
147
+
148
+ const provenance = await readProvenance(config);
149
+ let updated = 0;
150
+ let skipped = 0;
151
+
152
+ for (const status of statuses) {
153
+ if (status.state === "pristine") {
154
+ log.detail(`@odori/${status.name} is up to date`);
155
+ continue;
156
+ }
157
+ if (status.state === "modified") {
158
+ log.detail(`@odori/${status.name} is modified locally, and upstream has not changed`);
159
+ continue;
160
+ }
161
+ if ((status.state === "diverged" || status.state === "missing") && !options.force) {
162
+ log.warn(`@odori/${status.name}: ${LABELS[status.state]}. Review with odori diff ${status.name}, then use --force.`);
163
+ skipped += 1;
164
+ continue;
165
+ }
166
+
167
+ for (const file of status.files) {
168
+ await mkdir(resolve(file.localPath, ".."), {recursive: true});
169
+ await writeFile(file.localPath, file.content, "utf8");
170
+ }
171
+ provenance[status.name] = {
172
+ source: `@odori/${status.name}`,
173
+ version: "0.1.0",
174
+ installedAt: new Date().toISOString(),
175
+ hashes: Object.fromEntries(status.files.map((file) => [file.file, file.upstream])),
176
+ };
177
+ updated += 1;
178
+ log.success(`@odori/${status.name} updated`);
179
+ }
180
+
181
+ await writeProvenance(config, provenance);
182
+ log.detail(`${updated} updated, ${skipped} left for review.`);
183
+ };
package/src/config.ts ADDED
@@ -0,0 +1,84 @@
1
+ import {existsSync} from "node:fs";
2
+ import {resolve} from "node:path";
3
+ import {pathToFileURL} from "node:url";
4
+
5
+ export type OdoriConfig = {
6
+ /** Source root that contains video entries and video components. */
7
+ videosDir: string;
8
+ /** Generated output directory. Never edited or committed. */
9
+ outDir: string;
10
+ /** Where exports are written. */
11
+ exportDir: string;
12
+ /** Registry components are copied here. */
13
+ componentsDir: string;
14
+ /** Audio library, discovered so Studio can list and audition it. Served from public/. */
15
+ audioDir: string;
16
+ /** Studio dev server port. */
17
+ port: number;
18
+ /** Where Studio's docs link points. Defaults to the hosted docs site. */
19
+ docsUrl: string;
20
+ /** Open Studio in the default browser when `odori dev` starts. */
21
+ open?: boolean;
22
+ /** Chrome or Chromium executable used by the render worker. */
23
+ chromePath?: string;
24
+ /**
25
+ * FFmpeg executable used to encode. Set it to pin a build; leave it and
26
+ * Odori uses its own managed copy, which is what keeps two machines
27
+ * producing the same file.
28
+ */
29
+ ffmpegPath?: string;
30
+ /** Static asset references available to every video. */
31
+ assets?: Array<{reference: string; url: string}>;
32
+ /** Parallel render workers. Defaults to the machine's spare cores, capped at four. */
33
+ concurrency?: number;
34
+ /** x264 preset for exports. Defaults to medium. */
35
+ preset?: string;
36
+ /** Default container and codec: mp4, webm, prores, gif, or png. */
37
+ format?: string;
38
+ /**
39
+ * Where `odori add` fetches components from. Point it at a fork, a mirror,
40
+ * or a pinned version; `ODORI_REGISTRY` overrides it for one command.
41
+ */
42
+ registryUrl?: string;
43
+ /** Reuse the previous frame when the rendered picture is unchanged. Defaults to true. */
44
+ skipUnchangedFrames?: boolean;
45
+ /** Reuse encoded chunks whose frames still look identical. Defaults to true. */
46
+ cacheChunks?: boolean;
47
+ };
48
+
49
+ export const defaultConfig: OdoriConfig = {
50
+ videosDir: "videos",
51
+ outDir: ".odori",
52
+ exportDir: "out",
53
+ componentsDir: "videos/components",
54
+ audioDir: "public/audio",
55
+ port: 4300,
56
+ docsUrl: "https://odori.dev/docs",
57
+ };
58
+
59
+ const CHROME_CANDIDATES = [
60
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
61
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
62
+ "/usr/bin/google-chrome",
63
+ "/usr/bin/chromium",
64
+ "/usr/bin/chromium-browser",
65
+ ];
66
+
67
+ export const resolveChromePath = (configured?: string): string | undefined => {
68
+ const candidates = [configured, process.env.ODORI_CHROME, ...CHROME_CANDIDATES].filter(Boolean) as string[];
69
+ return candidates.find((candidate) => existsSync(candidate));
70
+ };
71
+
72
+ export type ResolvedConfig = OdoriConfig & {root: string; configPath?: string};
73
+
74
+ export const loadConfig = async (root: string): Promise<ResolvedConfig> => {
75
+ for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
76
+ const configPath = resolve(root, name);
77
+ if (!existsSync(configPath)) continue;
78
+ const loaded = (await import(pathToFileURL(configPath).href)) as {default?: Partial<OdoriConfig>};
79
+ return {...defaultConfig, ...(loaded.default ?? {}), root, configPath};
80
+ }
81
+ return {...defaultConfig, root};
82
+ };
83
+
84
+ export const defineConfig = (config: Partial<OdoriConfig>): Partial<OdoriConfig> => config;