@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.
- package/LICENSE +22 -0
- package/bin/odori.mjs +39 -0
- package/dist/chunk-7XJL2BYO.js +3552 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +622 -0
- package/dist/index.js +156 -0
- package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
- package/package.json +50 -0
- package/src/audio-mix.ts +133 -0
- package/src/binaries.ts +241 -0
- package/src/brand-file.ts +94 -0
- package/src/chunk-cache.ts +85 -0
- package/src/chunks.ts +78 -0
- package/src/cli.ts +319 -0
- package/src/commands/add.ts +151 -0
- package/src/commands/dev.ts +160 -0
- package/src/commands/doctor.ts +162 -0
- package/src/commands/exportVideo.ts +198 -0
- package/src/commands/init.ts +56 -0
- package/src/commands/inspect.ts +72 -0
- package/src/commands/list.ts +22 -0
- package/src/commands/new.ts +126 -0
- package/src/commands/shared.ts +96 -0
- package/src/commands/still.ts +40 -0
- package/src/commands/test.ts +265 -0
- package/src/commands/update.ts +183 -0
- package/src/config.ts +84 -0
- package/src/contracts.ts +159 -0
- package/src/cues.ts +141 -0
- package/src/determinism.ts +82 -0
- package/src/diff.ts +71 -0
- package/src/discovery.ts +216 -0
- package/src/formats.ts +119 -0
- package/src/index.ts +58 -0
- package/src/integrity.ts +101 -0
- package/src/jobs.ts +151 -0
- package/src/log.ts +17 -0
- package/src/open.ts +32 -0
- package/src/paths.ts +12 -0
- package/src/prepare-cache.ts +58 -0
- package/src/project.ts +196 -0
- package/src/registry-snapshot.json +3431 -0
- package/src/registry-source.ts +269 -0
- package/src/render.ts +627 -0
- package/src/server.ts +307 -0
- package/studio/index.html +41 -0
- package/studio/src/Studio.tsx +192 -0
- package/studio/src/components/AudioClip.tsx +64 -0
- package/studio/src/components/CanvasStage.tsx +79 -0
- package/studio/src/components/CommandPalette.tsx +129 -0
- package/studio/src/components/Diagnostics.tsx +93 -0
- package/studio/src/components/ExportPanel.tsx +234 -0
- package/studio/src/components/InputControls.tsx +110 -0
- package/studio/src/components/Thumbnail.tsx +71 -0
- package/studio/src/components/Transport.tsx +237 -0
- package/studio/src/components/Waveform.tsx +114 -0
- package/studio/src/components/Wordmark.tsx +449 -0
- package/studio/src/components/ui.tsx +138 -0
- package/studio/src/lib/mix-loudness.ts +52 -0
- package/studio/src/main.tsx +34 -0
- package/studio/src/shortcuts.ts +27 -0
- package/studio/src/studio.css +1232 -0
- package/studio/src/theme.ts +61 -0
- package/studio/src/views/AssetsView.tsx +111 -0
- package/studio/src/views/BrandsView.tsx +139 -0
- package/studio/src/views/ComponentsView.tsx +285 -0
- package/studio/src/views/HomeView.tsx +122 -0
- package/studio/src/views/VideosView.tsx +343 -0
- package/studio/src/virtual.d.ts +25 -0
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {readdir} from "node:fs/promises";
|
|
3
|
+
import {resolve} from "node:path";
|
|
4
|
+
import {cueUrl, isCueDefinition, resolveEntryLayout, type AudioCue, type Brand, type CueDefinition} from "odori";
|
|
5
|
+
import type {LoadedVideo} from "./project";
|
|
6
|
+
import type {ResolvedConfig} from "./config";
|
|
7
|
+
import {readProvenance} from "./commands/update";
|
|
8
|
+
import {resolveRegistry, type RegistryComponent} from "./registry-source";
|
|
9
|
+
|
|
10
|
+
export type ContractFailure = {video: string; message: string};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The first family name in a CSS font stack, which is what a `@font-face`
|
|
14
|
+
* family has to match for the brand to actually load it.
|
|
15
|
+
*/
|
|
16
|
+
const primaryFamily = (stack: string): string =>
|
|
17
|
+
(stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
18
|
+
|
|
19
|
+
/** Faces the platform provides, which never need a file. */
|
|
20
|
+
const SYSTEM_FAMILIES = new Set([
|
|
21
|
+
"system-ui",
|
|
22
|
+
"ui-sans-serif",
|
|
23
|
+
"ui-monospace",
|
|
24
|
+
"ui-serif",
|
|
25
|
+
"ui-rounded",
|
|
26
|
+
"sans-serif",
|
|
27
|
+
"serif",
|
|
28
|
+
"monospace",
|
|
29
|
+
"cursive",
|
|
30
|
+
"fantasy",
|
|
31
|
+
"-apple-system",
|
|
32
|
+
"BlinkMacSystemFont",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const isSystemFamily = (family: string): boolean => SYSTEM_FAMILIES.has(family);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A component's contract names the cues and font roles it needs. The brand is
|
|
39
|
+
* what answers, so the two can only be checked together. Anything missing is
|
|
40
|
+
* reported against the video whose brand fell short, because that is the file
|
|
41
|
+
* an author would open to fix it.
|
|
42
|
+
*/
|
|
43
|
+
export const checkComponentRequirements = (
|
|
44
|
+
installed: RegistryComponent[],
|
|
45
|
+
brand: Brand,
|
|
46
|
+
videoId: string,
|
|
47
|
+
): ContractFailure[] => {
|
|
48
|
+
const failures: ContractFailure[] = [];
|
|
49
|
+
|
|
50
|
+
for (const component of installed) {
|
|
51
|
+
for (const cue of component.contract.requires.audio) {
|
|
52
|
+
if (brand.audio.cues[cue] === undefined) {
|
|
53
|
+
failures.push({
|
|
54
|
+
video: videoId,
|
|
55
|
+
message: `${component.namespaced} needs an audio cue named "${cue}". Add it to brand.audio.cues, or remove the component.`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const role of component.contract.requires.fonts) {
|
|
61
|
+
const stack = brand.typography[role as keyof Brand["typography"]];
|
|
62
|
+
if (!stack) {
|
|
63
|
+
failures.push({
|
|
64
|
+
video: videoId,
|
|
65
|
+
message: `${component.namespaced} needs a "${role}" font. Add brand.typography.${role}.`,
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// A stack with no matching face renders through a system fallback, so the
|
|
70
|
+
// export and the designer's screen quietly disagree. A stack that leads
|
|
71
|
+
// with a system keyword is not making that mistake — it is asking for
|
|
72
|
+
// whatever the machine has, on purpose, and every machine has one.
|
|
73
|
+
const family = primaryFamily(stack);
|
|
74
|
+
if (family && !isSystemFamily(family) && !brand.fonts.some((font) => font.family === family)) {
|
|
75
|
+
failures.push({
|
|
76
|
+
video: videoId,
|
|
77
|
+
message: `${component.namespaced} uses the "${role}" font "${family}", which has no file in brand.fonts. Preview and export will fall back to a system face.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return failures;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A placed cue that outlasts the sound behind it.
|
|
88
|
+
*
|
|
89
|
+
* A generated cue renders exactly one phrase. If the window it is placed in is
|
|
90
|
+
* longer than that phrase and the placement does not repeat, everything after
|
|
91
|
+
* the phrase is silence — which sounds like a bug in the video rather than a
|
|
92
|
+
* decision, and nothing else in the pipeline reports it. Only generated cues
|
|
93
|
+
* can be checked: the length of a file on disk is not knowable here, and a
|
|
94
|
+
* brand may legitimately point a name at a two hour recording.
|
|
95
|
+
*/
|
|
96
|
+
export const checkAudioWindows = (cues: AudioCue[], brand: Brand, videoId: string): ContractFailure[] => {
|
|
97
|
+
const generated = new Map<string, {name: string; definition: CueDefinition}>();
|
|
98
|
+
for (const [name, value] of Object.entries(brand.audio.cues)) {
|
|
99
|
+
if (isCueDefinition(value)) generated.set(cueUrl(value), {name, definition: value});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const failures: ContractFailure[] = [];
|
|
103
|
+
for (const cue of cues) {
|
|
104
|
+
const match = generated.get(cue.src);
|
|
105
|
+
if (!match || cue.loop) continue;
|
|
106
|
+
if (cue.durationInFrames <= match.definition.durationInFrames) continue;
|
|
107
|
+
failures.push({
|
|
108
|
+
video: videoId,
|
|
109
|
+
message:
|
|
110
|
+
`"${match.name}" is ${match.definition.durationInFrames} frames long but is placed over ` +
|
|
111
|
+
`${cue.durationInFrames}. The rest is silence: declare loops on the cue, pass loop to <Audio>, ` +
|
|
112
|
+
`or shorten the window with duration.`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return failures;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every installed component checked against every discovered video's brand. A
|
|
120
|
+
* video does not declare which components it uses, so this is deliberately
|
|
121
|
+
* project wide: a cue that no brand provides is a problem wherever it is used.
|
|
122
|
+
*/
|
|
123
|
+
export const checkInstalledContracts = async (
|
|
124
|
+
config: ResolvedConfig,
|
|
125
|
+
videos: LoadedVideo[],
|
|
126
|
+
): Promise<ContractFailure[]> => {
|
|
127
|
+
// What is installed is what is on disk. Provenance only exists when a
|
|
128
|
+
// component arrived through `odori add`, and a copied directory counts too.
|
|
129
|
+
const componentsDir = resolve(config.root, config.componentsDir);
|
|
130
|
+
const onDisk = existsSync(componentsDir)
|
|
131
|
+
? (await readdir(componentsDir, {withFileTypes: true}))
|
|
132
|
+
.filter((entry) => entry.isDirectory())
|
|
133
|
+
.map((entry) => entry.name)
|
|
134
|
+
: [];
|
|
135
|
+
const names = new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
|
|
136
|
+
if (names.size === 0) return [];
|
|
137
|
+
|
|
138
|
+
// Validation never reaches for the network: whether a video is correct must
|
|
139
|
+
// not depend on a connection. The cache or the built-in snapshot answers.
|
|
140
|
+
const {items} = await resolveRegistry(config, {allowNetwork: false});
|
|
141
|
+
const installed = items.filter((component) => names.has(component.name));
|
|
142
|
+
if (installed.length === 0) return [];
|
|
143
|
+
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
const failures: ContractFailure[] = [];
|
|
146
|
+
|
|
147
|
+
for (const video of videos) {
|
|
148
|
+
const {brand} = resolveEntryLayout(video.entry);
|
|
149
|
+
for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
|
|
150
|
+
// One brand shared by many videos should report once, not once per video.
|
|
151
|
+
const key = `${brand.name}:${failure.message}`;
|
|
152
|
+
if (seen.has(key)) continue;
|
|
153
|
+
seen.add(key);
|
|
154
|
+
failures.push(failure);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return failures;
|
|
159
|
+
};
|
package/src/cues.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {existsSync, statSync} from "node:fs";
|
|
2
|
+
import {mkdir, writeFile} from "node:fs/promises";
|
|
3
|
+
import {basename, resolve} from "node:path";
|
|
4
|
+
import {pathToFileURL} from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
SAMPLE_RATE,
|
|
7
|
+
cueSamples,
|
|
8
|
+
cueUrl,
|
|
9
|
+
defaultLayout,
|
|
10
|
+
encodeWav,
|
|
11
|
+
isCueDefinition,
|
|
12
|
+
resolveEntryLayout,
|
|
13
|
+
type Brand,
|
|
14
|
+
type CueDefinition,
|
|
15
|
+
} from "odori";
|
|
16
|
+
import type {ResolvedConfig} from "./config";
|
|
17
|
+
import type {ProjectGraph} from "./discovery";
|
|
18
|
+
import {log} from "./log";
|
|
19
|
+
import {loadVideos} from "./project";
|
|
20
|
+
|
|
21
|
+
/** Where a rendered cue lands. Content addressed, so it is written once. */
|
|
22
|
+
export const cueCacheDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "cues");
|
|
23
|
+
|
|
24
|
+
export const cueFile = (config: ResolvedConfig, url: string) => resolve(cueCacheDir(config), basename(url));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render every generated cue a brand declares, once, into the build cache.
|
|
28
|
+
*
|
|
29
|
+
* The score runs here exactly as it runs in the browser, so the file the mix
|
|
30
|
+
* reads is the sound the author previewed. A cue whose file already exists is
|
|
31
|
+
* skipped: the name carries a hash of the score, so an unchanged cue is never
|
|
32
|
+
* rendered twice and a changed one can never collide with the old bytes.
|
|
33
|
+
*/
|
|
34
|
+
export const materializeCues = async (
|
|
35
|
+
config: ResolvedConfig,
|
|
36
|
+
brands: Brand[],
|
|
37
|
+
fps: number,
|
|
38
|
+
): Promise<Array<{cue: CueDefinition; file: string; rendered: boolean}>> => {
|
|
39
|
+
const seen = new Map<string, CueDefinition>();
|
|
40
|
+
for (const brand of brands) {
|
|
41
|
+
for (const value of Object.values(brand.audio.cues)) {
|
|
42
|
+
if (isCueDefinition(value)) seen.set(cueUrl(value), value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (seen.size === 0) return [];
|
|
46
|
+
|
|
47
|
+
await mkdir(cueCacheDir(config), {recursive: true});
|
|
48
|
+
const written: Array<{cue: CueDefinition; file: string; rendered: boolean}> = [];
|
|
49
|
+
|
|
50
|
+
for (const [url, cue] of seen) {
|
|
51
|
+
const file = cueFile(config, url);
|
|
52
|
+
if (existsSync(file)) {
|
|
53
|
+
written.push({cue, file, rendered: false});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const samples = cueSamples(cue, fps);
|
|
57
|
+
const signal = cue.render({samples, sampleRate: SAMPLE_RATE});
|
|
58
|
+
await writeFile(file, encodeWav(signal));
|
|
59
|
+
written.push({cue, file, rendered: true});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return written;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Cues the dev server can answer for. Discovery fills this once the project's
|
|
67
|
+
* brands are known; the server renders on first request and keeps the bytes,
|
|
68
|
+
* because the URL is content addressed and can never mean a different sound.
|
|
69
|
+
*/
|
|
70
|
+
const known = new Map<string, {cue: CueDefinition; fps: number}>();
|
|
71
|
+
const rendered = new Map<string, Uint8Array>();
|
|
72
|
+
|
|
73
|
+
export const registerCues = (brands: Brand[], fps: number): number => {
|
|
74
|
+
for (const brand of brands) {
|
|
75
|
+
for (const value of Object.values(brand.audio.cues)) {
|
|
76
|
+
if (isCueDefinition(value)) known.set(cueUrl(value), {cue: value, fps});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return known.size;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const renderedCue = (url: string): Uint8Array | null => {
|
|
83
|
+
const cached = rendered.get(url);
|
|
84
|
+
if (cached) return cached;
|
|
85
|
+
const entry = known.get(url);
|
|
86
|
+
if (!entry) return null;
|
|
87
|
+
const signal = entry.cue.render({samples: cueSamples(entry.cue, entry.fps), sampleRate: SAMPLE_RATE});
|
|
88
|
+
const wav = encodeWav(signal);
|
|
89
|
+
rendered.set(url, wav);
|
|
90
|
+
return wav;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const isBrand = (value: unknown): value is Brand =>
|
|
94
|
+
typeof value === "object" && value !== null && (value as Brand).kind === "odori-brand";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Import a source file fresh.
|
|
98
|
+
*
|
|
99
|
+
* Node caches modules by URL for the life of the process, so a dev server that
|
|
100
|
+
* re-reads a brand after an edit would otherwise keep the scores it saw at
|
|
101
|
+
* boot. The file's mtime in the specifier makes an edited file a new module
|
|
102
|
+
* and an untouched one a cache hit.
|
|
103
|
+
*/
|
|
104
|
+
const importFresh = async (file: string): Promise<Record<string, unknown>> =>
|
|
105
|
+
(await import(`${pathToFileURL(file).href}?odori=${statSync(file).mtimeMs}`)) as Record<string, unknown>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Teach the server every cue the project declares, so a preview can ask for
|
|
109
|
+
* one by content hash the moment Studio loads.
|
|
110
|
+
*
|
|
111
|
+
* Discovery is the source of truth on both halves. The brand modules are what
|
|
112
|
+
* make a cue exist at all, including a brand no video has adopted yet, and
|
|
113
|
+
* they are registered first at the default frame rate. The video entries are
|
|
114
|
+
* then loaded for their layouts, because the frame rate decides how many
|
|
115
|
+
* samples a cue occupies and only an entry knows its own. A project that
|
|
116
|
+
* cannot be loaded — a half-written entry mid-edit — leaves the brand pass in
|
|
117
|
+
* place rather than taking the server down.
|
|
118
|
+
*/
|
|
119
|
+
export const registerProjectCues = async (graph: ProjectGraph): Promise<number> => {
|
|
120
|
+
for (const discovered of graph.brands) {
|
|
121
|
+
try {
|
|
122
|
+
const module = await importFresh(discovered.file);
|
|
123
|
+
registerCues(Object.values(module).filter(isBrand), defaultLayout.format.fps);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
for (const video of await loadVideos(graph)) {
|
|
131
|
+
const layout = resolveEntryLayout(video.entry);
|
|
132
|
+
registerCues([layout.brand], layout.format.fps);
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return known.size;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {readdir, readFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {join, relative, resolve} from "node:path";
|
|
4
|
+
import type {ResolvedConfig} from "./config";
|
|
5
|
+
|
|
6
|
+
export type DeterminismFinding = {file: string; line: number; source: string; message: string};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Calls that make a frame depend on something other than its number.
|
|
10
|
+
*
|
|
11
|
+
* The frame contract is the whole product: the same frame number produces the
|
|
12
|
+
* same pixels, in Studio, in a still, and in every one of the parallel workers
|
|
13
|
+
* an export runs. These break it silently. `Math.random()` gives each worker
|
|
14
|
+
* its own numbers, so particles jump at chunk boundaries. `Date.now()` gives
|
|
15
|
+
* each render a different answer, so a countdown is correct on the machine
|
|
16
|
+
* that made it and wrong everywhere else.
|
|
17
|
+
*
|
|
18
|
+
* Both have deterministic replacements, which is why this is an error rather
|
|
19
|
+
* than a style note.
|
|
20
|
+
*/
|
|
21
|
+
const FORBIDDEN: Array<{pattern: RegExp; message: string}> = [
|
|
22
|
+
{
|
|
23
|
+
pattern: /\bMath\.random\s*\(/,
|
|
24
|
+
message: 'Math.random() differs per render worker. Use random("a-seed") from odori, which is stable for a seed.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
pattern: /\bDate\.now\s*\(/,
|
|
28
|
+
message: "Date.now() makes the frame depend on when it rendered. Derive time from useFrame(), or pass it through the video's input schema.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
pattern: /\bnew\s+Date\s*\(\s*\)/,
|
|
32
|
+
message: "new Date() with no argument reads the wall clock. Pass the date through the video's input schema so a re-render produces the same frame.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
pattern: /\bperformance\.now\s*\(/,
|
|
36
|
+
message: "performance.now() is wall clock. A frame's timing comes from useFrame() and the video's fps.",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Lines that are only a comment cannot call anything. */
|
|
41
|
+
const isComment = (line: string): boolean => /^\s*(\/\/|\*|\/\*)/.test(line);
|
|
42
|
+
|
|
43
|
+
const scanSource = (source: string, file: string): DeterminismFinding[] => {
|
|
44
|
+
const findings: DeterminismFinding[] = [];
|
|
45
|
+
source.split("\n").forEach((text, index) => {
|
|
46
|
+
if (isComment(text)) return;
|
|
47
|
+
// An explicit opt-out, for the rare case where a value genuinely should
|
|
48
|
+
// not be reproducible and the author has thought about it.
|
|
49
|
+
if (/odori-allow-nondeterminism/.test(text)) return;
|
|
50
|
+
for (const {pattern, message} of FORBIDDEN) {
|
|
51
|
+
if (pattern.test(text)) findings.push({file, line: index + 1, source: text.trim(), message});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return findings;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const walk = async (directory: string, files: string[] = []): Promise<string[]> => {
|
|
58
|
+
for (const entry of await readdir(directory, {withFileTypes: true})) {
|
|
59
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
60
|
+
const full = join(directory, entry.name);
|
|
61
|
+
if (entry.isDirectory()) await walk(full, files);
|
|
62
|
+
else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every module under the source root, checked for the calls above.
|
|
69
|
+
*
|
|
70
|
+
* Preview fixtures are skipped: they are development-only, never in a render,
|
|
71
|
+
* and a fixture that wants a throwaway value is not lying to anyone.
|
|
72
|
+
*/
|
|
73
|
+
export const checkDeterminism = async (config: ResolvedConfig): Promise<DeterminismFinding[]> => {
|
|
74
|
+
const root = resolve(config.root, config.videosDir);
|
|
75
|
+
if (!existsSync(root)) return [];
|
|
76
|
+
|
|
77
|
+
const files = await walk(root);
|
|
78
|
+
const findings = await Promise.all(
|
|
79
|
+
files.map(async (file) => scanSource(await readFile(file, "utf8"), relative(config.root, file))),
|
|
80
|
+
);
|
|
81
|
+
return findings.flat();
|
|
82
|
+
};
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal unified diff.
|
|
3
|
+
*
|
|
4
|
+
* Component updates need to show what upstream changed without pulling a diff
|
|
5
|
+
* library into the CLI, and the output only has to be readable in a terminal.
|
|
6
|
+
*/
|
|
7
|
+
const lcs = (left: string[], right: string[]): number[][] => {
|
|
8
|
+
const table = Array.from({length: left.length + 1}, () => new Array<number>(right.length + 1).fill(0));
|
|
9
|
+
for (let i = left.length - 1; i >= 0; i -= 1) {
|
|
10
|
+
for (let j = right.length - 1; j >= 0; j -= 1) {
|
|
11
|
+
table[i][j] = left[i] === right[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return table;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DiffLine = {type: "context" | "add" | "remove"; text: string};
|
|
18
|
+
|
|
19
|
+
export const diffLines = (before: string, after: string): DiffLine[] => {
|
|
20
|
+
const left = before.split("\n");
|
|
21
|
+
const right = after.split("\n");
|
|
22
|
+
const table = lcs(left, right);
|
|
23
|
+
const output: DiffLine[] = [];
|
|
24
|
+
|
|
25
|
+
let i = 0;
|
|
26
|
+
let j = 0;
|
|
27
|
+
while (i < left.length && j < right.length) {
|
|
28
|
+
if (left[i] === right[j]) {
|
|
29
|
+
output.push({type: "context", text: left[i]});
|
|
30
|
+
i += 1;
|
|
31
|
+
j += 1;
|
|
32
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
33
|
+
output.push({type: "remove", text: left[i]});
|
|
34
|
+
i += 1;
|
|
35
|
+
} else {
|
|
36
|
+
output.push({type: "add", text: right[j]});
|
|
37
|
+
j += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
while (i < left.length) output.push({type: "remove", text: left[i++]});
|
|
41
|
+
while (j < right.length) output.push({type: "add", text: right[j++]});
|
|
42
|
+
return output;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const countChanges = (lines: DiffLine[]) => ({
|
|
46
|
+
added: lines.filter((line) => line.type === "add").length,
|
|
47
|
+
removed: lines.filter((line) => line.type === "remove").length,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Collapse unchanged runs so a small edit does not print the whole file. */
|
|
51
|
+
export const formatDiff = (lines: DiffLine[], context = 2): string[] => {
|
|
52
|
+
const keep = new Set<number>();
|
|
53
|
+
lines.forEach((line, index) => {
|
|
54
|
+
if (line.type === "context") return;
|
|
55
|
+
for (let offset = -context; offset <= context; offset += 1) keep.add(index + offset);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const output: string[] = [];
|
|
59
|
+
let skipping = false;
|
|
60
|
+
lines.forEach((line, index) => {
|
|
61
|
+
if (!keep.has(index)) {
|
|
62
|
+
if (!skipping) output.push(" ...");
|
|
63
|
+
skipping = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
skipping = false;
|
|
67
|
+
const marker = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
|
68
|
+
output.push(`${marker} ${line.text}`);
|
|
69
|
+
});
|
|
70
|
+
return output;
|
|
71
|
+
};
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {mkdir, readdir, readFile, stat, writeFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {join, relative, resolve, sep} from "node:path";
|
|
4
|
+
import {hashString} from "odori";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
|
|
7
|
+
export type DiscoveredVideo = {
|
|
8
|
+
/** Directory-derived id used before the module is loaded. */
|
|
9
|
+
slug: string;
|
|
10
|
+
file: string;
|
|
11
|
+
relativeFile: string;
|
|
12
|
+
importPath: string;
|
|
13
|
+
identifier: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DiscoveredPreview = {
|
|
17
|
+
name: string;
|
|
18
|
+
file: string;
|
|
19
|
+
relativeFile: string;
|
|
20
|
+
importPath: string;
|
|
21
|
+
identifier: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type DiscoveredBrandModule = {
|
|
25
|
+
name: string;
|
|
26
|
+
file: string;
|
|
27
|
+
relativeFile: string;
|
|
28
|
+
identifier: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DiscoveredAudio = {
|
|
32
|
+
/** Path under the audio directory, without extension. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** The URL the dev server and the render worker both serve. */
|
|
35
|
+
url: string;
|
|
36
|
+
relativeFile: string;
|
|
37
|
+
bytes: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ProjectGraph = {
|
|
41
|
+
videos: DiscoveredVideo[];
|
|
42
|
+
previews: DiscoveredPreview[];
|
|
43
|
+
brands: DiscoveredBrandModule[];
|
|
44
|
+
audio: DiscoveredAudio[];
|
|
45
|
+
sourceHash: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const IGNORED = new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
|
|
49
|
+
|
|
50
|
+
const walk = async (directory: string, files: string[] = []): Promise<string[]> => {
|
|
51
|
+
const entries = await readdir(directory, {withFileTypes: true});
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
|
|
54
|
+
const full = join(directory, entry.name);
|
|
55
|
+
if (entry.isDirectory()) await walk(full, files);
|
|
56
|
+
else files.push(full);
|
|
57
|
+
}
|
|
58
|
+
return files;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const toIdentifier = (value: string, prefix: string) => {
|
|
62
|
+
const cleaned = value.replace(/[^a-zA-Z0-9]+(.)?/g, (_, character: string | undefined) =>
|
|
63
|
+
character ? character.toUpperCase() : "",
|
|
64
|
+
);
|
|
65
|
+
return `${prefix}${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The audio library is a directory of files, not a module graph, so it is
|
|
72
|
+
* listed rather than imported. It lives under `public/` because that is the
|
|
73
|
+
* one place a URL means the same thing in Studio, in a still, and in the
|
|
74
|
+
* export worker.
|
|
75
|
+
*/
|
|
76
|
+
export const discoverAudio = async (config: ResolvedConfig): Promise<DiscoveredAudio[]> => {
|
|
77
|
+
const root = resolve(config.root, config.audioDir);
|
|
78
|
+
if (!existsSync(root)) return [];
|
|
79
|
+
const publicRoot = resolve(config.root, "public");
|
|
80
|
+
const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
|
|
81
|
+
|
|
82
|
+
return Promise.all(
|
|
83
|
+
files.map(async (file) => ({
|
|
84
|
+
name: relative(root, file).replace(AUDIO_EXTENSIONS, "").split(sep).join("/"),
|
|
85
|
+
url: file.startsWith(`${publicRoot}${sep}`)
|
|
86
|
+
? `/${relative(publicRoot, file).split(sep).join("/")}`
|
|
87
|
+
: `/${relative(config.root, file).split(sep).join("/")}`,
|
|
88
|
+
relativeFile: relative(config.root, file),
|
|
89
|
+
bytes: (await stat(file)).size,
|
|
90
|
+
})),
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Discovery is filesystem based and filename driven, exactly as documented:
|
|
96
|
+
* `videos/**\/video.tsx` are exportable videos and `videos/**\/*.preview.tsx`
|
|
97
|
+
* are development-only component fixtures. Directory names carry no meaning.
|
|
98
|
+
*/
|
|
99
|
+
export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGraph> => {
|
|
100
|
+
const videosRoot = resolve(config.root, config.videosDir);
|
|
101
|
+
if (!existsSync(videosRoot)) {
|
|
102
|
+
throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const files = (await walk(videosRoot)).sort();
|
|
106
|
+
const audio = await discoverAudio(config);
|
|
107
|
+
const videos: DiscoveredVideo[] = [];
|
|
108
|
+
const previews: DiscoveredPreview[] = [];
|
|
109
|
+
const brands: DiscoveredBrandModule[] = [];
|
|
110
|
+
const hashParts: string[] = [];
|
|
111
|
+
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
const relativeFile = relative(config.root, file);
|
|
114
|
+
if (/\.(tsx|ts|css|json)$/.test(file)) {
|
|
115
|
+
const contents = await readFile(file, "utf8");
|
|
116
|
+
hashParts.push(`${relativeFile}:${hashString(contents)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const base = file.split(sep).pop() ?? "";
|
|
120
|
+
if (base === "video.tsx") {
|
|
121
|
+
// The path under videos/ is the id, the way a route is a path.
|
|
122
|
+
const slug = relative(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
|
|
123
|
+
videos.push({
|
|
124
|
+
slug,
|
|
125
|
+
file,
|
|
126
|
+
relativeFile,
|
|
127
|
+
importPath: file,
|
|
128
|
+
identifier: toIdentifier(slug, "video"),
|
|
129
|
+
});
|
|
130
|
+
} else if (file.split(sep).includes("brands") && /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx")) {
|
|
131
|
+
const name = base.replace(/\.tsx?$/, "");
|
|
132
|
+
brands.push({
|
|
133
|
+
name,
|
|
134
|
+
file,
|
|
135
|
+
relativeFile,
|
|
136
|
+
identifier: toIdentifier(`${name}-module`, "brands"),
|
|
137
|
+
});
|
|
138
|
+
} else if (base.endsWith(".preview.tsx")) {
|
|
139
|
+
const name = base.replace(/\.preview\.tsx$/, "");
|
|
140
|
+
previews.push({
|
|
141
|
+
name,
|
|
142
|
+
file,
|
|
143
|
+
relativeFile,
|
|
144
|
+
importPath: file,
|
|
145
|
+
identifier: toIdentifier(`${relative(videosRoot, file).split(sep).join("-")}`, "preview"),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {videos, previews, brands, audio, sourceHash: hashString(hashParts.join("|"))};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Static imports keep the module graph compatible with Next.js, tests,
|
|
155
|
+
* browsers, and Node render workers.
|
|
156
|
+
*/
|
|
157
|
+
export const generateImports = (graph: ProjectGraph, outDir: string): string => {
|
|
158
|
+
const importPath = (file: string) => {
|
|
159
|
+
const relativePath = relative(outDir, file).split(sep).join("/");
|
|
160
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const lines = [
|
|
164
|
+
"// Generated by odori. Do not edit.",
|
|
165
|
+
'import type {VideoEntry} from "odori";',
|
|
166
|
+
"",
|
|
167
|
+
...graph.videos.map(
|
|
168
|
+
(video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from "${importPath(video.file)}";`,
|
|
169
|
+
),
|
|
170
|
+
...graph.previews.map((preview) => `import ${preview.identifier} from "${importPath(preview.file)}";`),
|
|
171
|
+
...graph.brands.map((brand) => `import * as ${brand.identifier} from "${importPath(brand.file)}";`),
|
|
172
|
+
"",
|
|
173
|
+
"export const videos: VideoEntry[] = [",
|
|
174
|
+
...graph.videos.map(
|
|
175
|
+
(video) =>
|
|
176
|
+
` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`,
|
|
177
|
+
),
|
|
178
|
+
"];",
|
|
179
|
+
"",
|
|
180
|
+
"export const componentPreviews = [",
|
|
181
|
+
...graph.previews.map((preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}},`,),
|
|
182
|
+
"];",
|
|
183
|
+
"",
|
|
184
|
+
"export const brands = [",
|
|
185
|
+
...graph.brands.map(
|
|
186
|
+
(brand) =>
|
|
187
|
+
` ...Object.values(${brand.identifier}).filter((value) => (value as {kind?: string})?.kind === "odori-brand"),`,
|
|
188
|
+
),
|
|
189
|
+
"];",
|
|
190
|
+
"",
|
|
191
|
+
];
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const writeGenerated = async (config: ResolvedConfig, graph: ProjectGraph): Promise<string> => {
|
|
196
|
+
const outDir = resolve(config.root, config.outDir);
|
|
197
|
+
await mkdir(outDir, {recursive: true});
|
|
198
|
+
const target = join(outDir, "imports.generated.ts");
|
|
199
|
+
await writeFile(target, generateImports(graph, outDir), "utf8");
|
|
200
|
+
await writeFile(
|
|
201
|
+
join(outDir, "catalog.json"),
|
|
202
|
+
`${JSON.stringify(
|
|
203
|
+
{
|
|
204
|
+
sourceHash: graph.sourceHash,
|
|
205
|
+
videos: graph.videos.map((video) => ({slug: video.slug, file: video.relativeFile})),
|
|
206
|
+
previews: graph.previews.map((preview) => ({name: preview.name, file: preview.relativeFile})),
|
|
207
|
+
brands: graph.brands.map((brand) => ({name: brand.name, file: brand.relativeFile})),
|
|
208
|
+
audio: graph.audio.map((entry) => ({name: entry.name, url: entry.url, file: entry.relativeFile})),
|
|
209
|
+
},
|
|
210
|
+
null,
|
|
211
|
+
2,
|
|
212
|
+
)}\n`,
|
|
213
|
+
"utf8",
|
|
214
|
+
);
|
|
215
|
+
return target;
|
|
216
|
+
};
|