@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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {constants} from "node:fs";
|
|
2
|
+
import {access, mkdir, readFile, rm, writeFile} from "node:fs/promises";
|
|
3
|
+
import {existsSync} from "node:fs";
|
|
4
|
+
import {createRequire} from "node:module";
|
|
5
|
+
import {relative, resolve} from "node:path";
|
|
6
|
+
import {loadConfig} from "../config";
|
|
7
|
+
import {CHROME_BUILD, cacheRoot, resolveBrowser, resolveFfmpeg} from "../binaries";
|
|
8
|
+
import {log} from "../log";
|
|
9
|
+
|
|
10
|
+
export type Check = {
|
|
11
|
+
name: string;
|
|
12
|
+
/** What was found. Printed on success and on failure alike. */
|
|
13
|
+
detail: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
/** The command or edit that fixes it. Only read when `ok` is false. */
|
|
16
|
+
fix?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The oldest Node the runtime is exercised on. Below this, nothing is promised. */
|
|
20
|
+
const MINIMUM_NODE = 20;
|
|
21
|
+
|
|
22
|
+
const version = (value: string): number[] => value.replace(/^v/, "").split(".").map(Number);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every fact a render depends on, checked before anything is authored.
|
|
26
|
+
*
|
|
27
|
+
* Chrome is resolved when a render starts and FFmpeg when an encode starts,
|
|
28
|
+
* which means a machine that cannot export says so an hour into the work, at
|
|
29
|
+
* the moment the work was supposed to pay off. This asks all of it up front
|
|
30
|
+
* and prints the fix beside whatever failed.
|
|
31
|
+
*/
|
|
32
|
+
export const runChecks = async (root: string): Promise<Check[]> => {
|
|
33
|
+
const checks: Check[] = [];
|
|
34
|
+
const config = await loadConfig(root);
|
|
35
|
+
const require = createRequire(resolve(root, "package.json"));
|
|
36
|
+
|
|
37
|
+
const [major] = version(process.version);
|
|
38
|
+
checks.push({
|
|
39
|
+
name: "Node",
|
|
40
|
+
detail: process.version,
|
|
41
|
+
ok: major >= MINIMUM_NODE,
|
|
42
|
+
fix: `Odori needs Node ${MINIMUM_NODE} or newer. Install it, for example with: nvm install ${MINIMUM_NODE}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let react = "not found";
|
|
46
|
+
let reactOk = false;
|
|
47
|
+
try {
|
|
48
|
+
const manifest = JSON.parse(await readFile(require.resolve("react/package.json"), "utf8")) as {version: string};
|
|
49
|
+
react = manifest.version;
|
|
50
|
+
reactOk = version(react)[0] >= 19;
|
|
51
|
+
} catch {
|
|
52
|
+
react = "not installed";
|
|
53
|
+
}
|
|
54
|
+
checks.push({
|
|
55
|
+
name: "React",
|
|
56
|
+
detail: react,
|
|
57
|
+
ok: reactOk,
|
|
58
|
+
fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const videosDir = resolve(config.root, config.videosDir);
|
|
62
|
+
checks.push({
|
|
63
|
+
name: "Source root",
|
|
64
|
+
detail: existsSync(videosDir) ? relative(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
|
|
65
|
+
ok: existsSync(videosDir),
|
|
66
|
+
fix: 'Run "odori init" to add the videos source root.',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
checks.push({
|
|
70
|
+
name: "Config",
|
|
71
|
+
detail: config.configPath ? relative(config.root, config.configPath) : "defaults (no odori.config.ts)",
|
|
72
|
+
// Loading got this far, so a config that exists also parsed.
|
|
73
|
+
ok: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Both binaries report where they came from, not only that they exist. Two
|
|
77
|
+
// machines that render differently are usually two machines resolving a
|
|
78
|
+
// different Chrome, and that is invisible unless it is printed.
|
|
79
|
+
const chrome = await resolveBrowser(config);
|
|
80
|
+
checks.push({
|
|
81
|
+
name: "Chrome",
|
|
82
|
+
detail: chrome
|
|
83
|
+
? `${chrome.origin === "managed" ? `pinned ${CHROME_BUILD}` : `${chrome.origin}, version unpinned`} · ${chrome.path}`
|
|
84
|
+
: "not found",
|
|
85
|
+
ok: Boolean(chrome),
|
|
86
|
+
fix: 'Run "odori install" to download the pinned build, or set chromePath in odori.config.ts.',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const ffmpeg = await resolveFfmpeg(config);
|
|
90
|
+
checks.push({
|
|
91
|
+
name: "FFmpeg",
|
|
92
|
+
detail: ffmpeg
|
|
93
|
+
? `${ffmpeg.origin === "managed" || ffmpeg.origin === "package" ? "pinned 5.3.0" : `${ffmpeg.origin}, version unpinned`} · ${ffmpeg.path}`
|
|
94
|
+
: "not found",
|
|
95
|
+
ok: Boolean(ffmpeg),
|
|
96
|
+
fix: 'Run "odori install" to download the pinned build, or set ffmpegPath in odori.config.ts.',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Not a failure, but the thing a reader wants next: an unpinned binary still
|
|
100
|
+
// renders, it just cannot promise the same frames as another machine.
|
|
101
|
+
const unpinned = [chrome, ffmpeg].filter(
|
|
102
|
+
(binary) => binary && binary.origin !== "managed" && binary.origin !== "package",
|
|
103
|
+
);
|
|
104
|
+
checks.push({
|
|
105
|
+
name: "Reproducible",
|
|
106
|
+
detail:
|
|
107
|
+
unpinned.length === 0
|
|
108
|
+
? `pinned binaries from ${cacheRoot()}`
|
|
109
|
+
: `${unpinned.length} of 2 from the host; frames may differ from another machine`,
|
|
110
|
+
ok: true,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// The generated directory holds the import graph, the cue renders, and the
|
|
114
|
+
// build cache. A read-only project fails at the first write, deep inside a
|
|
115
|
+
// render, so it is proved here with an actual write.
|
|
116
|
+
const generated = resolve(config.root, ".odori");
|
|
117
|
+
let writable = false;
|
|
118
|
+
try {
|
|
119
|
+
await mkdir(generated, {recursive: true});
|
|
120
|
+
const probe = resolve(generated, ".doctor");
|
|
121
|
+
await writeFile(probe, "", "utf8");
|
|
122
|
+
await access(probe, constants.W_OK);
|
|
123
|
+
await rm(probe, {force: true});
|
|
124
|
+
writable = true;
|
|
125
|
+
} catch {
|
|
126
|
+
writable = false;
|
|
127
|
+
}
|
|
128
|
+
checks.push({
|
|
129
|
+
name: "Generated cache",
|
|
130
|
+
detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
|
|
131
|
+
ok: writable,
|
|
132
|
+
fix: `Odori writes its import graph and render cache to ${relative(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return checks;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Print the checks. Exits non-zero on any failure, so it is usable as the
|
|
140
|
+
* first step of a CI job as well as the first thing a new project runs.
|
|
141
|
+
*/
|
|
142
|
+
export const doctorCommand = async (root = process.cwd()): Promise<number> => {
|
|
143
|
+
const checks = await runChecks(root);
|
|
144
|
+
const width = Math.max(...checks.map((check) => check.name.length));
|
|
145
|
+
|
|
146
|
+
log.title("odori doctor");
|
|
147
|
+
for (const check of checks) {
|
|
148
|
+
const label = check.name.padEnd(width);
|
|
149
|
+
if (check.ok) log.success(`${label} ${check.detail}`);
|
|
150
|
+
else log.error(`${label} ${check.detail}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const failed = checks.filter((check) => !check.ok);
|
|
154
|
+
if (failed.length === 0) {
|
|
155
|
+
log.detail("Everything a render needs is present.");
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
log.info("");
|
|
160
|
+
for (const check of failed) log.warn(`${check.name}: ${check.fix}`);
|
|
161
|
+
return 1;
|
|
162
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {resolve} from "node:path";
|
|
2
|
+
import {resolveEntryLayout, type ExportJob} from "odori";
|
|
3
|
+
import type {ResolvedConfig} from "../config";
|
|
4
|
+
import {log} from "../log";
|
|
5
|
+
import {JobQueue, appendJobLog, createJob, listJobs, readJob, updateJob, type JobRecord} from "../jobs";
|
|
6
|
+
import {findVideo, freezeManifest, outputName, type LoadedVideo} from "../project";
|
|
7
|
+
import {resolveFormat, type VideoFormat} from "../formats";
|
|
8
|
+
import {materializeCues} from "../cues";
|
|
9
|
+
import {renderMovie} from "../render";
|
|
10
|
+
import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
|
|
11
|
+
|
|
12
|
+
export const exportQueue = new JobQueue();
|
|
13
|
+
|
|
14
|
+
/** Controllers for jobs running in this process, so a request can stop one. */
|
|
15
|
+
const running = new Map<string, AbortController>();
|
|
16
|
+
|
|
17
|
+
export const cancelJob = (id: string): boolean => {
|
|
18
|
+
const controller = running.get(id);
|
|
19
|
+
if (!controller) return false;
|
|
20
|
+
controller.abort();
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run one recorded job. Retries reuse the frozen manifest, so a failed export
|
|
26
|
+
* never silently re-resolves data or reruns prepare.
|
|
27
|
+
*/
|
|
28
|
+
export const runJob = async (
|
|
29
|
+
config: ResolvedConfig,
|
|
30
|
+
origin: string,
|
|
31
|
+
record: JobRecord,
|
|
32
|
+
video: LoadedVideo,
|
|
33
|
+
options: {
|
|
34
|
+
concurrency?: number;
|
|
35
|
+
preset?: string;
|
|
36
|
+
format?: VideoFormat;
|
|
37
|
+
skipUnchangedFrames?: boolean;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
onProgress?: (job: ExportJob) => void;
|
|
40
|
+
} = {},
|
|
41
|
+
): Promise<ExportJob> =>
|
|
42
|
+
exportQueue.enqueue(async () => {
|
|
43
|
+
const {manifest, output} = record;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
running.set(record.job.id, controller);
|
|
46
|
+
if (options.signal) options.signal.addEventListener("abort", () => controller.abort(), {once: true});
|
|
47
|
+
|
|
48
|
+
let current = await updateJob(config, {
|
|
49
|
+
...record.job,
|
|
50
|
+
status: "rendering",
|
|
51
|
+
progress: 0,
|
|
52
|
+
attempts: record.job.attempts + 1,
|
|
53
|
+
pid: process.pid,
|
|
54
|
+
error: undefined,
|
|
55
|
+
});
|
|
56
|
+
options.onProgress?.(current);
|
|
57
|
+
await appendJobLog(config, current.id, `attempt ${current.attempts} started`);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Generated cues become files before the mix asks for them. The score
|
|
61
|
+
// ran in the browser for preview; it runs here for the encode.
|
|
62
|
+
const cues = await materializeCues(config, [resolveEntryLayout(video.entry).brand], manifest.format.fps);
|
|
63
|
+
const rendered = cues.filter((entry: {rendered: boolean}) => entry.rendered).length;
|
|
64
|
+
if (rendered > 0) await appendJobLog(config, current.id, `rendered ${rendered} generated cue(s)`);
|
|
65
|
+
|
|
66
|
+
await renderMovie(
|
|
67
|
+
origin,
|
|
68
|
+
// The frozen scene list drives chunking, so chunks follow scene cuts.
|
|
69
|
+
targetFor(
|
|
70
|
+
{...video, durationInFrames: manifest.format.durationInFrames},
|
|
71
|
+
manifest.input as Record<string, unknown>,
|
|
72
|
+
manifest.prepared,
|
|
73
|
+
manifest.audio,
|
|
74
|
+
manifest.scenes,
|
|
75
|
+
),
|
|
76
|
+
output,
|
|
77
|
+
config,
|
|
78
|
+
(progress, stage) => {
|
|
79
|
+
const rounded = Math.round(progress * 100);
|
|
80
|
+
if (rounded % 5 !== 0 && progress < 1) return;
|
|
81
|
+
// updateJob serializes writes per job, so this cannot interleave.
|
|
82
|
+
void updateJob(config, {...current, status: stage, progress}).then((next) => {
|
|
83
|
+
current = next;
|
|
84
|
+
options.onProgress?.(next);
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
concurrency: options.concurrency,
|
|
89
|
+
preset: options.preset,
|
|
90
|
+
format: options.format,
|
|
91
|
+
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
onTimings: (timings) => {
|
|
94
|
+
log.progressDone();
|
|
95
|
+
const reused = timings.reusedFrames > 0 ? `, ${timings.reusedFrames} reused` : "";
|
|
96
|
+
const cached = timings.cachedChunks > 0 ? `, ${timings.cachedChunks} chunks from cache` : "";
|
|
97
|
+
log.detail(
|
|
98
|
+
`captured ${timings.frames} frames in ${(timings.captureMs / 1000).toFixed(1)}s on ${
|
|
99
|
+
timings.concurrency
|
|
100
|
+
} workers across ${timings.chunks} chunks${reused}${cached}, joined in ${(
|
|
101
|
+
timings.encodeMs / 1000
|
|
102
|
+
).toFixed(1)}s`,
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
current = await updateJob(config, {...current, status: "ready", progress: 1, pid: undefined, output});
|
|
108
|
+
await appendJobLog(config, current.id, `ready: ${output}`);
|
|
109
|
+
options.onProgress?.(current);
|
|
110
|
+
return current;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
current = await updateJob(config, {...current, status: "failed", pid: undefined, error: message});
|
|
114
|
+
await appendJobLog(config, current.id, `failed: ${message}`);
|
|
115
|
+
options.onProgress?.(current);
|
|
116
|
+
throw error;
|
|
117
|
+
} finally {
|
|
118
|
+
running.delete(record.job.id);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const exportCommand = async (
|
|
123
|
+
id: string,
|
|
124
|
+
options: {
|
|
125
|
+
output?: string;
|
|
126
|
+
input?: Record<string, unknown>;
|
|
127
|
+
concurrency?: number;
|
|
128
|
+
preset?: string;
|
|
129
|
+
format?: string;
|
|
130
|
+
skipUnchangedFrames?: boolean;
|
|
131
|
+
retry?: string;
|
|
132
|
+
} = {},
|
|
133
|
+
) => {
|
|
134
|
+
const {config, graph, videos} = await createContext();
|
|
135
|
+
// Resolved before anything renders: an unknown format should cost a sentence,
|
|
136
|
+
// not twenty minutes of capture.
|
|
137
|
+
const format = resolveFormat(options.format ?? config.format, options.output);
|
|
138
|
+
|
|
139
|
+
return withServer(config, async (server) => {
|
|
140
|
+
const record = options.retry
|
|
141
|
+
? await readJob(config, options.retry)
|
|
142
|
+
: await (async () => {
|
|
143
|
+
const video = findVideo(videos, id);
|
|
144
|
+
const compiled = await compileInBrowser(server.url, targetFor(video, options.input), config);
|
|
145
|
+
const {manifest} = await freezeManifest(
|
|
146
|
+
{...video, durationInFrames: compiled.durationInFrames},
|
|
147
|
+
graph,
|
|
148
|
+
config,
|
|
149
|
+
options.input ?? {},
|
|
150
|
+
{scenes: compiled.scenes, audio: compiled.audio},
|
|
151
|
+
);
|
|
152
|
+
const output = resolve(
|
|
153
|
+
config.root,
|
|
154
|
+
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`,
|
|
155
|
+
);
|
|
156
|
+
return createJob(config, manifest, output);
|
|
157
|
+
})();
|
|
158
|
+
|
|
159
|
+
const video = findVideo(videos, record.manifest.videoId);
|
|
160
|
+
log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
|
|
161
|
+
if (options.retry) log.detail(`retrying attempt ${record.job.attempts + 1} from the frozen manifest`);
|
|
162
|
+
if (record.manifest.audio.length > 0) {
|
|
163
|
+
log.detail(`${record.manifest.audio.length} audio cue(s) at ${record.manifest.format.durationInFrames} frames`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const job = await runJob(config, server.url, record, video, {
|
|
167
|
+
concurrency: options.concurrency,
|
|
168
|
+
preset: options.preset,
|
|
169
|
+
format,
|
|
170
|
+
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
171
|
+
onProgress: (next) => {
|
|
172
|
+
if (next.status === "rendering" || next.status === "encoding") {
|
|
173
|
+
log.progress(`${next.status} ${Math.round(next.progress * 100)}%`);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
log.progressDone();
|
|
178
|
+
log.success(`Exported ${job.videoId} to ${record.output}`);
|
|
179
|
+
return job;
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const jobsCommand = async () => {
|
|
184
|
+
const {config} = await createContext();
|
|
185
|
+
const jobs = await listJobs(config);
|
|
186
|
+
if (jobs.length === 0) {
|
|
187
|
+
log.detail("No export jobs recorded yet.");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
log.title(`${jobs.length} export job${jobs.length === 1 ? "" : "s"}`);
|
|
191
|
+
for (const job of jobs) {
|
|
192
|
+
const detail = job.status === "ready" ? job.output : job.error ?? `${Math.round(job.progress * 100)}%`;
|
|
193
|
+
log.info(
|
|
194
|
+
` ${job.id} ${job.videoId.padEnd(20)} ${job.status.padEnd(9)} attempts ${job.attempts} ${detail ?? ""}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
log.detail("Retry a failed job with: odori export --retry <job id>");
|
|
198
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {mkdir, writeFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {relative, resolve} from "node:path";
|
|
4
|
+
import {defaultConfig} from "../config";
|
|
5
|
+
import {log} from "../log";
|
|
6
|
+
import {videoTemplate} from "./new";
|
|
7
|
+
|
|
8
|
+
const CONFIG_TEMPLATE = `import {defineConfig} from "@odori/cli";
|
|
9
|
+
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
videosDir: "videos",
|
|
12
|
+
exportDir: "out",
|
|
13
|
+
port: 4300,
|
|
14
|
+
});
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const LAYOUT_TEMPLATE = `import {defineBrand, defineVideoLayout} from "odori";
|
|
18
|
+
|
|
19
|
+
export const productBrand = defineBrand({
|
|
20
|
+
name: "product",
|
|
21
|
+
colors: {background: "#08090b", surface: "#111318", foreground: "#f7f8fa", accent: "#7c8cff"},
|
|
22
|
+
// Empty on purpose: "odori add" writes installed cues into this block,
|
|
23
|
+
// and without it the only thing it can do is print the lines to paste.
|
|
24
|
+
audio: {cues: {}, targetLufs: -14},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const productLayout = defineVideoLayout({
|
|
28
|
+
format: {width: 1920, height: 1080, fps: 30},
|
|
29
|
+
brand: productBrand,
|
|
30
|
+
safeArea: {x: 96, y: 72},
|
|
31
|
+
});
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
/** Add the videos source root and configuration to an existing project. */
|
|
35
|
+
export const initCommand = async (root = process.cwd()) => {
|
|
36
|
+
const videosDir = resolve(root, defaultConfig.videosDir);
|
|
37
|
+
await mkdir(resolve(videosDir, "components"), {recursive: true});
|
|
38
|
+
|
|
39
|
+
const files: Array<[string, string]> = [
|
|
40
|
+
[resolve(root, "odori.config.ts"), CONFIG_TEMPLATE],
|
|
41
|
+
[resolve(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
|
|
42
|
+
[resolve(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const [file, contents] of files) {
|
|
46
|
+
if (existsSync(file)) {
|
|
47
|
+
log.detail(`Kept existing ${relative(root, file)}`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
await mkdir(resolve(file, ".."), {recursive: true});
|
|
51
|
+
await writeFile(file, contents, "utf8");
|
|
52
|
+
log.success(`Created ${relative(root, file)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
log.detail("Next: odori doctor, then odori add @odori/title-reveal @odori/end-card, then odori dev.");
|
|
56
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {isOdoriSchema, resolveEntryLayout} from "odori";
|
|
2
|
+
import {log} from "../log";
|
|
3
|
+
import {findVideo} from "../project";
|
|
4
|
+
import {freezeManifest} from "../project";
|
|
5
|
+
import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
|
|
6
|
+
|
|
7
|
+
export const inspectCommand = async (id: string, options: {json?: boolean; input?: Record<string, unknown>} = {}) => {
|
|
8
|
+
const {config, graph, videos} = await createContext();
|
|
9
|
+
const video = findVideo(videos, id);
|
|
10
|
+
const layout = resolveEntryLayout(video.entry);
|
|
11
|
+
|
|
12
|
+
const {durationInFrames, scenes, audio} = await withServer(config, (server) =>
|
|
13
|
+
compileInBrowser(server.url, targetFor(video, options.input), config),
|
|
14
|
+
);
|
|
15
|
+
const {manifest, input, prepared} = await freezeManifest(
|
|
16
|
+
{...video, durationInFrames},
|
|
17
|
+
graph,
|
|
18
|
+
config,
|
|
19
|
+
options.input ?? {},
|
|
20
|
+
{scenes, audio},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (options.json) {
|
|
24
|
+
log.info(JSON.stringify({metadata: {...video.entry.metadata, layout: undefined, schema: undefined}, layout, manifest}, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
log.title(video.entry.metadata.title);
|
|
29
|
+
log.info(` id ${video.entry.metadata.id}`);
|
|
30
|
+
log.info(` source ${video.relativeFile}`);
|
|
31
|
+
log.info(` format ${layout.format.width}x${layout.format.height} at ${layout.format.fps} fps`);
|
|
32
|
+
log.info(` duration ${durationInFrames} frames (${(durationInFrames / layout.format.fps).toFixed(2)}s)`);
|
|
33
|
+
log.info(` brand ${layout.brand.name}`);
|
|
34
|
+
log.info(` safe area ${layout.safeArea.x} x ${layout.safeArea.y}`);
|
|
35
|
+
log.info(` manifest ${manifest.manifestHash}`);
|
|
36
|
+
log.info(` source hash ${manifest.sourceHash}`);
|
|
37
|
+
log.title("Scenes");
|
|
38
|
+
for (const scene of scenes) {
|
|
39
|
+
log.info(
|
|
40
|
+
` ${scene.id.padEnd(16)} ${String(scene.start).padStart(5)} to ${String(
|
|
41
|
+
scene.start + scene.durationInFrames - 1,
|
|
42
|
+
).padStart(5)} (${scene.durationInFrames} frames)`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (scenes.length === 0) log.detail(" No structured scenes. The composition drives motion directly.");
|
|
46
|
+
log.title("Inputs");
|
|
47
|
+
if (isOdoriSchema(video.entry.metadata.schema)) {
|
|
48
|
+
for (const [name, field] of Object.entries(video.entry.metadata.schema.describe())) {
|
|
49
|
+
log.info(` ${name.padEnd(16)} ${field.type.padEnd(8)} ${JSON.stringify(input[name])}`);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
log.detail(` ${JSON.stringify(input)}`);
|
|
53
|
+
}
|
|
54
|
+
if (prepared !== undefined) {
|
|
55
|
+
log.title("Prepared");
|
|
56
|
+
log.detail(` ${JSON.stringify(prepared).slice(0, 400)}`);
|
|
57
|
+
}
|
|
58
|
+
log.title("Audio");
|
|
59
|
+
if (manifest.audio.length === 0) log.detail(" Silent. Add <Audio src=\"/audio/bed.mp3\" /> to score it.");
|
|
60
|
+
for (const cue of manifest.audio) {
|
|
61
|
+
log.info(
|
|
62
|
+
` ${cue.src.padEnd(28)} ${String(cue.fromFrame).padStart(5)} to ${String(
|
|
63
|
+
cue.fromFrame + cue.durationInFrames - 1,
|
|
64
|
+
).padStart(5)} gain ${cue.gain}${cue.duckUnder ? " (ducked)" : ""}`,
|
|
65
|
+
);
|
|
66
|
+
log.detail(` ${cue.integrity}`);
|
|
67
|
+
}
|
|
68
|
+
log.title("Assets");
|
|
69
|
+
if (manifest.assets.length === 0 && manifest.fonts.length === 0) log.detail(" None declared.");
|
|
70
|
+
for (const asset of manifest.assets) log.info(` ${asset.reference ?? asset.url}`);
|
|
71
|
+
for (const font of manifest.fonts) log.info(` font ${font.family} ${font.url} ${font.integrity}`);
|
|
72
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {resolveEntryLayout} from "odori";
|
|
2
|
+
import {log} from "../log";
|
|
3
|
+
import {createContext} from "./shared";
|
|
4
|
+
|
|
5
|
+
export const listCommand = async () => {
|
|
6
|
+
const {videos, graph, config} = await createContext();
|
|
7
|
+
log.title(`${videos.length} video${videos.length === 1 ? "" : "s"} in ${config.videosDir}/`);
|
|
8
|
+
for (const video of videos) {
|
|
9
|
+
const layout = resolveEntryLayout(video.entry);
|
|
10
|
+
const seconds = video.durationInFrames / layout.format.fps;
|
|
11
|
+
log.info(
|
|
12
|
+
` ${video.entry.metadata.id} ${layout.format.width}x${layout.format.height} ${layout.format.fps}fps ${
|
|
13
|
+
video.durationInFrames ? `${seconds.toFixed(1)}s` : "duration from scenes"
|
|
14
|
+
}`,
|
|
15
|
+
);
|
|
16
|
+
log.detail(` ${video.relativeFile}`);
|
|
17
|
+
}
|
|
18
|
+
if (graph.previews.length > 0) {
|
|
19
|
+
log.title(`${graph.previews.length} component preview${graph.previews.length === 1 ? "" : "s"}`);
|
|
20
|
+
for (const preview of graph.previews) log.info(` ${preview.name} ${preview.relativeFile}`);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {mkdir, readdir, writeFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {relative, resolve} from "node:path";
|
|
4
|
+
import {loadConfig, type ResolvedConfig} from "../config";
|
|
5
|
+
import {log} from "../log";
|
|
6
|
+
|
|
7
|
+
const titleCase = (value: string) =>
|
|
8
|
+
value
|
|
9
|
+
.split(/[-_\s]+/)
|
|
10
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
11
|
+
.join(" ");
|
|
12
|
+
|
|
13
|
+
const pascalCase = (value: string) => titleCase(value).replace(/\s+/g, "");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The fallback: no registry components installed, so the entry shows the
|
|
17
|
+
* runtime and nothing else. Correct, and deliberately plain.
|
|
18
|
+
*/
|
|
19
|
+
export const videoTemplate = (name: string, hasLayout: boolean) => `import {Scene, Video, defineVideoMetadata} from "odori";
|
|
20
|
+
${hasLayout ? 'import {productLayout} from "../layout";\n' : ""}
|
|
21
|
+
export const metadata = defineVideoMetadata({
|
|
22
|
+
id: "${name}",
|
|
23
|
+
title: "${titleCase(name)}",
|
|
24
|
+
${hasLayout ? " layout: productLayout,\n" : ""} duration: "8s",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default function ${pascalCase(name)}() {
|
|
28
|
+
return (
|
|
29
|
+
<Video>
|
|
30
|
+
<Scene id="opening" duration="5s">
|
|
31
|
+
<h1 style={{fontSize: 112, fontWeight: 650, letterSpacing: "-0.045em", margin: "auto", textAlign: "center"}}>
|
|
32
|
+
${titleCase(name)}
|
|
33
|
+
</h1>
|
|
34
|
+
</Scene>
|
|
35
|
+
<Scene id="end" duration="3s">
|
|
36
|
+
<p style={{fontSize: 48, margin: "auto", opacity: 0.7}}>Built with odori</p>
|
|
37
|
+
</Scene>
|
|
38
|
+
</Video>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The entry a project with components installed should get: the first video
|
|
45
|
+
* someone reads is the one that teaches them how videos are written here, and
|
|
46
|
+
* that is composition, not inline styles on an `h1`.
|
|
47
|
+
*/
|
|
48
|
+
export const composedTemplate = (
|
|
49
|
+
name: string,
|
|
50
|
+
hasLayout: boolean,
|
|
51
|
+
parts: {title: boolean; end: boolean},
|
|
52
|
+
) => {
|
|
53
|
+
const imports = [
|
|
54
|
+
'import {Scene, Video, defineVideoMetadata} from "odori";',
|
|
55
|
+
parts.title ? 'import {TitleReveal} from "../components/title-reveal/title-reveal";' : null,
|
|
56
|
+
parts.end ? 'import {EndCard} from "../components/end-card/end-card";' : null,
|
|
57
|
+
hasLayout ? 'import {productLayout} from "../layout";' : null,
|
|
58
|
+
].filter(Boolean);
|
|
59
|
+
|
|
60
|
+
const opening = parts.title
|
|
61
|
+
? ` <TitleReveal title="${titleCase(name)}" detail="Written in ${name}/video.tsx" />`
|
|
62
|
+
: ` <h1 style={{fontSize: 112, fontWeight: 650, margin: "auto", textAlign: "center"}}>${titleCase(name)}</h1>`;
|
|
63
|
+
const closing = parts.end
|
|
64
|
+
? ` <EndCard title="Ship it" detail="odori export ${name}" />`
|
|
65
|
+
: ` <p style={{fontSize: 48, margin: "auto", opacity: 0.7}}>Built with odori</p>`;
|
|
66
|
+
|
|
67
|
+
return `${imports.join("\n")}
|
|
68
|
+
|
|
69
|
+
export const metadata = defineVideoMetadata({
|
|
70
|
+
id: "${name}",
|
|
71
|
+
title: "${titleCase(name)}",
|
|
72
|
+
${hasLayout ? " layout: productLayout,\n" : ""} duration: "8s",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export default function ${pascalCase(name)}() {
|
|
76
|
+
return (
|
|
77
|
+
<Video>
|
|
78
|
+
<Scene id="opening" duration="5s">
|
|
79
|
+
${opening}
|
|
80
|
+
</Scene>
|
|
81
|
+
<Scene id="end" duration="3s">
|
|
82
|
+
${closing}
|
|
83
|
+
</Scene>
|
|
84
|
+
</Video>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Which of the components this template can use are actually on disk. */
|
|
91
|
+
const installedParts = async (config: ResolvedConfig): Promise<{title: boolean; end: boolean}> => {
|
|
92
|
+
const componentsDir = resolve(config.root, config.componentsDir);
|
|
93
|
+
if (!existsSync(componentsDir)) return {title: false, end: false};
|
|
94
|
+
const entries = (await readdir(componentsDir, {withFileTypes: true}))
|
|
95
|
+
.filter((entry) => entry.isDirectory())
|
|
96
|
+
.map((entry) => entry.name);
|
|
97
|
+
return {title: entries.includes("title-reveal"), end: entries.includes("end-card")};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const newCommand = async (name: string, options: {blank?: boolean} = {}) => {
|
|
101
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
|
|
102
|
+
const config = await loadConfig(process.cwd());
|
|
103
|
+
const directory = resolve(config.root, config.videosDir, name);
|
|
104
|
+
const file = resolve(directory, "video.tsx");
|
|
105
|
+
if (existsSync(file)) throw new Error(`${relative(config.root, file)} already exists.`);
|
|
106
|
+
|
|
107
|
+
const hasLayout = existsSync(resolve(config.root, config.videosDir, "layout.tsx"));
|
|
108
|
+
const parts = options.blank === true ? {title: false, end: false} : await installedParts(config);
|
|
109
|
+
const composed = parts.title || parts.end;
|
|
110
|
+
|
|
111
|
+
await mkdir(directory, {recursive: true});
|
|
112
|
+
await writeFile(
|
|
113
|
+
file,
|
|
114
|
+
composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
|
|
115
|
+
"utf8",
|
|
116
|
+
);
|
|
117
|
+
log.success(`Created ${relative(config.root, file)}`);
|
|
118
|
+
|
|
119
|
+
if (composed) log.detail("Composed from the components this project has installed.");
|
|
120
|
+
else if (options.blank !== true) {
|
|
121
|
+
// Nothing to compose with is worth saying once, with the command that
|
|
122
|
+
// changes it, rather than leaving the plain entry to imply this is the way.
|
|
123
|
+
log.detail("No registry components installed yet: odori add @odori/title-reveal @odori/end-card");
|
|
124
|
+
}
|
|
125
|
+
log.detail("Run odori dev to preview it.");
|
|
126
|
+
};
|