@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
package/src/project.ts ADDED
@@ -0,0 +1,196 @@
1
+ import {resolve} from "node:path";
2
+ import {pathToFileURL} from "node:url";
3
+ import {
4
+ createRenderManifest,
5
+ entryDurationInFrames,
6
+ resolveEntryLayout,
7
+ resolveVideoId,
8
+ type AudioCue,
9
+ type ManifestAudioCue,
10
+ type RenderManifest,
11
+ type VideoEntry,
12
+ } from "odori";
13
+ import type {ResolvedConfig} from "./config";
14
+ import type {ProjectGraph} from "./discovery";
15
+ import {createIntegrityResolver} from "./integrity";
16
+ import {readPrepareCache, writePrepareCache} from "./prepare-cache";
17
+ import {log} from "./log";
18
+ import {renderToolchain} from "./binaries";
19
+
20
+ export type LoadedVideo = {
21
+ entry: VideoEntry;
22
+ file: string;
23
+ relativeFile: string;
24
+ durationInFrames: number;
25
+ };
26
+
27
+ /**
28
+ * Load discovered entries in Node so the CLI can inspect metadata, validate
29
+ * inputs, and freeze a manifest without opening a browser.
30
+ */
31
+ export const loadVideos = async (graph: ProjectGraph): Promise<LoadedVideo[]> => {
32
+ const loaded: LoadedVideo[] = [];
33
+ for (const discovered of graph.videos) {
34
+ const module = (await import(pathToFileURL(discovered.file).href)) as {
35
+ default?: VideoEntry["component"];
36
+ metadata?: VideoEntry["metadata"];
37
+ };
38
+ if (!module.default || !module.metadata) {
39
+ throw new Error(`${discovered.relativeFile} must export metadata and a default React component.`);
40
+ }
41
+ const entry: VideoEntry = {
42
+ component: module.default,
43
+ metadata: {...module.metadata, id: resolveVideoId(module.metadata.id, discovered.slug)},
44
+ };
45
+ loaded.push({
46
+ entry,
47
+ file: discovered.file,
48
+ relativeFile: discovered.relativeFile,
49
+ durationInFrames: entryDurationInFrames(entry, resolveEntryLayout(entry)),
50
+ });
51
+ }
52
+ const byId = new Map<string, string>();
53
+ for (const video of loaded) {
54
+ const id = video.entry.metadata.id;
55
+ const first = byId.get(id);
56
+ // Path-derived ids are unique by construction, so a collision here is
57
+ // always an explicit override pointing at another entry's id.
58
+ if (first) throw new Error(`Duplicate video id "${id}":\n ${first}\n ${video.relativeFile}`);
59
+ byId.set(id, video.relativeFile);
60
+ }
61
+ return loaded;
62
+ };
63
+
64
+ export {outputName} from "./paths";
65
+
66
+ export const findVideo = (videos: LoadedVideo[], id: string): LoadedVideo => {
67
+ const found = videos.find((video) => video.entry.metadata.id === id);
68
+ if (!found) {
69
+ throw new Error(
70
+ `Unknown video "${id}". Known videos: ${videos.map((video) => video.entry.metadata.id).join(", ") || "none"}`,
71
+ );
72
+ }
73
+ return found;
74
+ };
75
+
76
+ /**
77
+ * Run prepare.ts beside a video entry, if it exists.
78
+ *
79
+ * Results are cached on disk by source hash, validated input, and prepare
80
+ * version, so repeated stills and exports of an approved cut do not refetch.
81
+ */
82
+ export const runPrepare = async (
83
+ video: LoadedVideo,
84
+ config: ResolvedConfig,
85
+ graph: ProjectGraph,
86
+ input: Record<string, unknown>,
87
+ options: {refresh?: boolean} = {},
88
+ ): Promise<unknown> => {
89
+ const prepareFile = resolve(video.file, "..", "prepare.ts");
90
+ let prepare: {run(context: unknown): Promise<unknown>; version?: string} | undefined;
91
+
92
+ try {
93
+ const module = (await import(pathToFileURL(prepareFile).href)) as {
94
+ prepare?: {run(context: unknown): Promise<unknown>; version?: string};
95
+ default?: {run(context: unknown): Promise<unknown>; version?: string};
96
+ };
97
+ prepare = module.prepare ?? module.default;
98
+ } catch (error) {
99
+ if ((error as {code?: string}).code === "ERR_MODULE_NOT_FOUND") return undefined;
100
+ throw error;
101
+ }
102
+ if (!prepare) return undefined;
103
+
104
+ const key = {
105
+ videoId: video.entry.metadata.id,
106
+ sourceHash: graph.sourceHash,
107
+ input,
108
+ version: prepare.version ?? "1",
109
+ };
110
+
111
+ if (!options.refresh) {
112
+ const cached = await readPrepareCache(config, key);
113
+ if (cached.hit) return cached.value;
114
+ }
115
+
116
+ const memo = new Map<string, unknown>();
117
+ const value = await prepare.run({
118
+ input,
119
+ assets: {
120
+ resolve: async (reference: string) => {
121
+ const asset = (config.assets ?? []).find((item) => item.reference === reference);
122
+ if (!asset) throw new Error(`Unknown asset reference: ${reference}`);
123
+ return asset.url;
124
+ },
125
+ },
126
+ cache: {
127
+ getOrSet: async (cacheKey: string, factory: () => Promise<unknown>) => {
128
+ if (!memo.has(cacheKey)) memo.set(cacheKey, await factory());
129
+ return memo.get(cacheKey);
130
+ },
131
+ },
132
+ });
133
+
134
+ await writePrepareCache(config, key, value);
135
+ return value;
136
+ };
137
+
138
+ export type FreezeOptions = {
139
+ scenes?: RenderManifest["scenes"];
140
+ audio?: AudioCue[];
141
+ refreshPrepare?: boolean;
142
+ };
143
+
144
+ export const freezeManifest = async (
145
+ video: LoadedVideo,
146
+ graph: ProjectGraph,
147
+ config: ResolvedConfig,
148
+ rawInput: Record<string, unknown>,
149
+ options: FreezeOptions = {},
150
+ ): Promise<{manifest: RenderManifest; input: Record<string, unknown>; prepared: unknown}> => {
151
+ const layout = resolveEntryLayout(video.entry);
152
+ const merged = {...video.entry.metadata.defaultProps, ...rawInput};
153
+ const input = video.entry.metadata.schema
154
+ ? (video.entry.metadata.schema.parse(merged) as Record<string, unknown>)
155
+ : merged;
156
+ const prepared = await runPrepare(video, config, graph, input, {refresh: options.refreshPrepare});
157
+
158
+ const integrity = await createIntegrityResolver(config);
159
+ const assets = await Promise.all(
160
+ (config.assets ?? []).map(async (asset) => ({...asset, integrity: await integrity.resolve(asset.url)})),
161
+ );
162
+ const audio: ManifestAudioCue[] = await Promise.all(
163
+ (options.audio ?? []).map(async (cue) => ({...cue, integrity: await integrity.resolve(cue.src)})),
164
+ );
165
+ const fonts = await Promise.all(
166
+ layout.brand.fonts.map(async (font) => ({
167
+ family: font.family,
168
+ url: font.url,
169
+ integrity: await integrity.resolve(font.url),
170
+ })),
171
+ );
172
+ await integrity.flush();
173
+
174
+ for (const cue of audio) {
175
+ if (cue.integrity === "unresolved") log.warn(`Audio source could not be resolved for hashing: ${cue.src}`);
176
+ }
177
+
178
+ const manifest = createRenderManifest({
179
+ entry: video.entry,
180
+ layout,
181
+ input,
182
+ prepared,
183
+ sourceHash: graph.sourceHash,
184
+ durationInFrames: video.durationInFrames,
185
+ scenes: options.scenes ?? [],
186
+ audio,
187
+ assets,
188
+ fonts,
189
+ // Which Chrome drew it and which FFmpeg encoded it. Two files that differ
190
+ // are then a question with an answer rather than a mystery.
191
+ toolchain: await renderToolchain(config),
192
+ createdAt: new Date().toISOString(),
193
+ });
194
+
195
+ return {manifest, input, prepared};
196
+ };