@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/formats.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an export can produce.
|
|
3
|
+
*
|
|
4
|
+
* One codec is enough until it is not: an overlay needs an alpha channel that
|
|
5
|
+
* H.264 cannot carry, an editor wants ProRes, a README wants a GIF, and a
|
|
6
|
+
* thumbnail sheet wants frames. Each of those is a different container and a
|
|
7
|
+
* different pixel format, so the choice belongs in one table rather than in a
|
|
8
|
+
* flag that quietly means five things.
|
|
9
|
+
*
|
|
10
|
+
* Chunked capture is how a render stays fast, and only some codecs can be
|
|
11
|
+
* concatenated without re-encoding. `chunked: false` says a format has to be
|
|
12
|
+
* encoded in one pass, which is slower and correct.
|
|
13
|
+
*/
|
|
14
|
+
export type VideoFormat = {
|
|
15
|
+
name: string;
|
|
16
|
+
extension: string;
|
|
17
|
+
/** Whether the codec supports an alpha channel. */
|
|
18
|
+
alpha: boolean;
|
|
19
|
+
/** Whether chunks can be joined with a stream copy. */
|
|
20
|
+
chunked: boolean;
|
|
21
|
+
/** Whether the container carries an audio track at all. */
|
|
22
|
+
audio: boolean;
|
|
23
|
+
/** FFmpeg arguments for the video stream, given the requested preset. */
|
|
24
|
+
args: (preset: string) => string[];
|
|
25
|
+
description: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const FORMATS: Record<string, VideoFormat> = {
|
|
29
|
+
mp4: {
|
|
30
|
+
name: "mp4",
|
|
31
|
+
extension: ".mp4",
|
|
32
|
+
alpha: false,
|
|
33
|
+
chunked: true,
|
|
34
|
+
audio: true,
|
|
35
|
+
description: "H.264 in MP4. Plays everywhere; the default.",
|
|
36
|
+
args: (preset) => ["-c:v", "libx264", "-crf", "17", "-preset", preset, "-pix_fmt", "yuv420p"],
|
|
37
|
+
},
|
|
38
|
+
webm: {
|
|
39
|
+
name: "webm",
|
|
40
|
+
extension: ".webm",
|
|
41
|
+
alpha: true,
|
|
42
|
+
// VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
|
|
43
|
+
chunked: true,
|
|
44
|
+
audio: true,
|
|
45
|
+
description: "VP9 in WebM, with alpha. For the web, and for overlays.",
|
|
46
|
+
args: () => ["-c:v", "libvpx-vp9", "-crf", "24", "-b:v", "0", "-pix_fmt", "yuva420p", "-row-mt", "1"],
|
|
47
|
+
},
|
|
48
|
+
prores: {
|
|
49
|
+
name: "prores",
|
|
50
|
+
extension: ".mov",
|
|
51
|
+
alpha: true,
|
|
52
|
+
chunked: true,
|
|
53
|
+
audio: true,
|
|
54
|
+
description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
|
|
55
|
+
args: () => ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-alpha_bits", "8"],
|
|
56
|
+
},
|
|
57
|
+
gif: {
|
|
58
|
+
name: "gif",
|
|
59
|
+
extension: ".gif",
|
|
60
|
+
alpha: false,
|
|
61
|
+
// A GIF's palette is computed across the whole animation, so chunks would
|
|
62
|
+
// each invent their own and the result would flicker between them.
|
|
63
|
+
chunked: false,
|
|
64
|
+
audio: false,
|
|
65
|
+
description: "An animated GIF, palette optimised. Silent, by the format.",
|
|
66
|
+
args: () => [
|
|
67
|
+
"-vf",
|
|
68
|
+
"split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3",
|
|
69
|
+
"-loop",
|
|
70
|
+
"0",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
png: {
|
|
74
|
+
name: "png",
|
|
75
|
+
extension: ".png",
|
|
76
|
+
alpha: true,
|
|
77
|
+
chunked: false,
|
|
78
|
+
audio: false,
|
|
79
|
+
description: "A numbered PNG sequence, with alpha. For a compositor.",
|
|
80
|
+
args: () => ["-c:v", "png", "-pix_fmt", "rgba"],
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const formatNames = (): string[] => Object.keys(FORMATS);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The format for an export.
|
|
88
|
+
*
|
|
89
|
+
* An explicit `--format` wins. Otherwise the output's extension decides, so
|
|
90
|
+
* `--output cut.webm` does what it looks like it does rather than writing
|
|
91
|
+
* H.264 into a file named `.webm`.
|
|
92
|
+
*/
|
|
93
|
+
export const resolveFormat = (requested: string | undefined, output: string | undefined): VideoFormat => {
|
|
94
|
+
if (requested) {
|
|
95
|
+
const format = FORMATS[requested.toLowerCase()];
|
|
96
|
+
if (!format) {
|
|
97
|
+
throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
return format;
|
|
100
|
+
}
|
|
101
|
+
if (output) {
|
|
102
|
+
const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
|
|
103
|
+
const matched = Object.values(FORMATS).find((format) => format.extension === extension);
|
|
104
|
+
if (matched) return matched;
|
|
105
|
+
}
|
|
106
|
+
return FORMATS.mp4;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether a composition's transparency can survive into the file.
|
|
111
|
+
*
|
|
112
|
+
* A video with a transparent background exported to MP4 is not an error, it is
|
|
113
|
+
* a black rectangle — the alpha is composited away by the pixel format. Worth
|
|
114
|
+
* saying out loud at the point the choice is made.
|
|
115
|
+
*/
|
|
116
|
+
export const alphaWarning = (format: VideoFormat, transparent: boolean): string | null =>
|
|
117
|
+
transparent && !format.alpha
|
|
118
|
+
? `${format.name} has no alpha channel, so the transparent background will render black. Use webm, prores, or png.`
|
|
119
|
+
: null;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export {defineConfig, loadConfig, resolveChromePath, type ResolvedConfig, type OdoriConfig} from "./config";
|
|
2
|
+
export {discoverProject, generateImports, writeGenerated, type ProjectGraph} from "./discovery";
|
|
3
|
+
export {startStudioServer, type StudioServer} from "./server";
|
|
4
|
+
export {loadVideos, findVideo, freezeManifest, runPrepare, type LoadedVideo} from "./project";
|
|
5
|
+
export {fileKey, outputName} from "./paths";
|
|
6
|
+
export {
|
|
7
|
+
defaultConcurrency,
|
|
8
|
+
ensureFfmpeg,
|
|
9
|
+
openRenderPage,
|
|
10
|
+
readAudio,
|
|
11
|
+
readTimeline,
|
|
12
|
+
renderMovie,
|
|
13
|
+
renderStill,
|
|
14
|
+
seekTo,
|
|
15
|
+
probeSignatures,
|
|
16
|
+
type RenderOptions,
|
|
17
|
+
type RenderTarget,
|
|
18
|
+
type RenderTimings,
|
|
19
|
+
} from "./render";
|
|
20
|
+
export {compileInBrowser, createContext, targetFor, withServer, type CompileResult, type Context} from "./commands/shared";
|
|
21
|
+
export {
|
|
22
|
+
JobQueue,
|
|
23
|
+
appendJobLog,
|
|
24
|
+
createJob,
|
|
25
|
+
listJobs,
|
|
26
|
+
readJob,
|
|
27
|
+
reconcileJobs,
|
|
28
|
+
updateJob,
|
|
29
|
+
type JobRecord,
|
|
30
|
+
} from "./jobs";
|
|
31
|
+
export {buildAudioFilter, resolveCueFile, type MixInput} from "./audio-mix";
|
|
32
|
+
export {checkDeterminism, type DeterminismFinding} from "./determinism";
|
|
33
|
+
export {FORMATS, alphaWarning, formatNames, resolveFormat, type VideoFormat} from "./formats";
|
|
34
|
+
export {
|
|
35
|
+
CHROME_BUILD,
|
|
36
|
+
cacheRoot,
|
|
37
|
+
installBrowser,
|
|
38
|
+
installFfmpeg,
|
|
39
|
+
renderToolchain,
|
|
40
|
+
resolveBrowser,
|
|
41
|
+
resolveFfmpeg,
|
|
42
|
+
type ResolvedBinary,
|
|
43
|
+
} from "./binaries";
|
|
44
|
+
export {createIntegrityResolver, localCandidates} from "./integrity";
|
|
45
|
+
export {clearPrepareCache, prepareCacheKey, readPrepareCache, writePrepareCache} from "./prepare-cache";
|
|
46
|
+
export {devCommand} from "./commands/dev";
|
|
47
|
+
export {cancelJob, exportCommand, jobsCommand, runJob, exportQueue} from "./commands/exportVideo";
|
|
48
|
+
export {chunkFrames, planChunks, type ChunkPlan, type FrameChunk} from "./chunks";
|
|
49
|
+
export {stillCommand} from "./commands/still";
|
|
50
|
+
export {testCommand} from "./commands/test";
|
|
51
|
+
export {listCommand} from "./commands/list";
|
|
52
|
+
export {inspectCommand} from "./commands/inspect";
|
|
53
|
+
export {addCommand} from "./commands/add";
|
|
54
|
+
export {componentStatus, diffCommand, updateCommand, type ComponentStatus} from "./commands/update";
|
|
55
|
+
export {countChanges, diffLines, formatDiff, type DiffLine} from "./diff";
|
|
56
|
+
export {newCommand} from "./commands/new";
|
|
57
|
+
export {initCommand} from "./commands/init";
|
|
58
|
+
export {parseArgs, run} from "./cli";
|
package/src/integrity.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {createHash} from "node:crypto";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {mkdir, readFile, writeFile} from "node:fs/promises";
|
|
4
|
+
import {dirname, resolve} from "node:path";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {log} from "./log";
|
|
7
|
+
|
|
8
|
+
type IntegrityCache = Record<string, {integrity: string; size: number; mtimeMs?: number}>;
|
|
9
|
+
|
|
10
|
+
const cacheFile = (config: ResolvedConfig) => resolve(config.root, config.outDir, "cache", "integrity.json");
|
|
11
|
+
|
|
12
|
+
const readCache = async (config: ResolvedConfig): Promise<IntegrityCache> => {
|
|
13
|
+
const file = cacheFile(config);
|
|
14
|
+
if (!existsSync(file)) return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await readFile(file, "utf8")) as IntegrityCache;
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const writeCache = async (config: ResolvedConfig, cache: IntegrityCache) => {
|
|
23
|
+
const file = cacheFile(config);
|
|
24
|
+
await mkdir(dirname(file), {recursive: true});
|
|
25
|
+
await writeFile(file, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sha256 = (bytes: Uint8Array) => `sha256-${createHash("sha256").update(bytes).digest("base64")}`;
|
|
29
|
+
|
|
30
|
+
/** Local candidates for a project-relative or public-relative URL. */
|
|
31
|
+
export const localCandidates = (config: ResolvedConfig, url: string): string[] => [
|
|
32
|
+
resolve(config.root, "public", url.replace(/^\//, "")),
|
|
33
|
+
resolve(config.root, url.replace(/^\//, "")),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Only `public/` is served, by the dev server and by the render worker alike.
|
|
38
|
+
* A file found anywhere else still hashes, which would otherwise produce a
|
|
39
|
+
* manifest that looks verified for a URL the browser answers with a 404.
|
|
40
|
+
*/
|
|
41
|
+
export const isServed = (config: ResolvedConfig, file: string): boolean =>
|
|
42
|
+
file.startsWith(resolve(config.root, "public") + "/");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Content addressing for everything a render depends on.
|
|
46
|
+
*
|
|
47
|
+
* Local files hash their bytes. Remote files are fetched once and cached by
|
|
48
|
+
* URL, so a manifest stays comparable across runs without refetching on every
|
|
49
|
+
* export. A source that cannot be read is recorded as unresolved rather than
|
|
50
|
+
* silently pretending to be verified.
|
|
51
|
+
*/
|
|
52
|
+
export const createIntegrityResolver = async (config: ResolvedConfig) => {
|
|
53
|
+
const cache = await readCache(config);
|
|
54
|
+
const warned = new Set<string>();
|
|
55
|
+
let dirty = false;
|
|
56
|
+
|
|
57
|
+
const resolveIntegrity = async (url: string): Promise<string> => {
|
|
58
|
+
// A generated cue names itself by the hash of the score that produces it,
|
|
59
|
+
// so the URL is already its integrity. There is no file to read until the
|
|
60
|
+
// render materializes one, and reading it would only restate the name.
|
|
61
|
+
if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
|
|
62
|
+
|
|
63
|
+
const local = localCandidates(config, url).find((candidate) => existsSync(candidate));
|
|
64
|
+
if (local) {
|
|
65
|
+
if (!isServed(config, local) && !warned.has(url)) {
|
|
66
|
+
warned.add(url);
|
|
67
|
+
log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
|
|
68
|
+
}
|
|
69
|
+
const bytes = await readFile(local);
|
|
70
|
+
const {mtimeMs} = await import("node:fs/promises").then((fs) => fs.stat(local));
|
|
71
|
+
const hit = cache[url];
|
|
72
|
+
if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
|
|
73
|
+
const integrity = sha256(bytes);
|
|
74
|
+
cache[url] = {integrity, size: bytes.byteLength, mtimeMs};
|
|
75
|
+
dirty = true;
|
|
76
|
+
return integrity;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!/^https?:\/\//.test(url)) return "unresolved";
|
|
80
|
+
if (cache[url]) return cache[url].integrity;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetch(url, {signal: AbortSignal.timeout(10_000)});
|
|
84
|
+
if (!response.ok) return "unresolved";
|
|
85
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
86
|
+
const integrity = sha256(bytes);
|
|
87
|
+
cache[url] = {integrity, size: bytes.byteLength};
|
|
88
|
+
dirty = true;
|
|
89
|
+
return integrity;
|
|
90
|
+
} catch {
|
|
91
|
+
return "unresolved";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
resolve: resolveIntegrity,
|
|
97
|
+
flush: async () => {
|
|
98
|
+
if (dirty) await writeCache(config, cache);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {mkdir, readFile, readdir, rename, writeFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {join, resolve} from "node:path";
|
|
4
|
+
import type {ExportJob, RenderManifest} from "odori";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
|
|
7
|
+
export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string};
|
|
8
|
+
|
|
9
|
+
const buildsDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "builds");
|
|
10
|
+
const jobFile = (config: ResolvedConfig, id: string) => join(buildsDir(config), `${id}.json`);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An export job records the frozen manifest, progress, attempts, and result,
|
|
14
|
+
* so a retry reuses the approved inputs instead of resolving them again.
|
|
15
|
+
*/
|
|
16
|
+
export const createJob = async (
|
|
17
|
+
config: ResolvedConfig,
|
|
18
|
+
manifest: RenderManifest,
|
|
19
|
+
output: string,
|
|
20
|
+
): Promise<JobRecord> => {
|
|
21
|
+
await mkdir(buildsDir(config), {recursive: true});
|
|
22
|
+
const now = new Date().toISOString();
|
|
23
|
+
const job: ExportJob = {
|
|
24
|
+
id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
|
|
25
|
+
videoId: manifest.videoId,
|
|
26
|
+
manifestHash: manifest.manifestHash,
|
|
27
|
+
status: "queued",
|
|
28
|
+
progress: 0,
|
|
29
|
+
attempts: 0,
|
|
30
|
+
logs: [],
|
|
31
|
+
createdAt: now,
|
|
32
|
+
updatedAt: now,
|
|
33
|
+
};
|
|
34
|
+
const record: JobRecord = {job, manifest, output};
|
|
35
|
+
await writeFile(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
36
|
+
return record;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const readJob = async (config: ResolvedConfig, id: string): Promise<JobRecord> => {
|
|
40
|
+
const file = jobFile(config, id);
|
|
41
|
+
if (!existsSync(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
|
|
42
|
+
return JSON.parse(await readFile(file, "utf8")) as JobRecord;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Job records are updated from the render loop, the log writer, and the HTTP
|
|
47
|
+
* handler at once, so every write is serialized per job and lands atomically
|
|
48
|
+
* through a rename. Otherwise progress updates interleave and corrupt the file.
|
|
49
|
+
*/
|
|
50
|
+
const writeLocks = new Map<string, Promise<unknown>>();
|
|
51
|
+
|
|
52
|
+
const withJobLock = <Value>(id: string, task: () => Promise<Value>): Promise<Value> => {
|
|
53
|
+
const previous = writeLocks.get(id) ?? Promise.resolve();
|
|
54
|
+
const next = previous.then(task, task);
|
|
55
|
+
writeLocks.set(
|
|
56
|
+
id,
|
|
57
|
+
next.catch(() => undefined),
|
|
58
|
+
);
|
|
59
|
+
return next;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const writeRecord = async (config: ResolvedConfig, record: JobRecord) => {
|
|
63
|
+
const file = jobFile(config, record.job.id);
|
|
64
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
65
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
66
|
+
await rename(temporary, file);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const updateJob = async (config: ResolvedConfig, job: ExportJob): Promise<ExportJob> =>
|
|
70
|
+
withJobLock(job.id, async () => {
|
|
71
|
+
const record = await readJob(config, job.id);
|
|
72
|
+
const next = {...job, logs: record.job.logs, updatedAt: new Date().toISOString()};
|
|
73
|
+
await writeRecord(config, {...record, job: next});
|
|
74
|
+
return next;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const appendJobLog = async (config: ResolvedConfig, id: string, message: string): Promise<ExportJob> =>
|
|
78
|
+
withJobLock(id, async () => {
|
|
79
|
+
const record = await readJob(config, id);
|
|
80
|
+
const next = {
|
|
81
|
+
...record.job,
|
|
82
|
+
logs: [...record.job.logs.slice(-49), `${new Date().toISOString()} ${message}`],
|
|
83
|
+
updatedAt: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
await writeRecord(config, {...record, job: next});
|
|
86
|
+
return next;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const alive = (pid?: number): boolean => {
|
|
90
|
+
if (!pid) return false;
|
|
91
|
+
try {
|
|
92
|
+
// Signal 0 tests for existence without touching the process.
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A killed process leaves its job claiming to be rendering forever. Anything
|
|
102
|
+
* that reads job state reconciles first, so `odori jobs` never reports a lie.
|
|
103
|
+
*/
|
|
104
|
+
export const reconcileJobs = async (config: ResolvedConfig): Promise<number> => {
|
|
105
|
+
const stale = (await listJobs(config, {reconcile: false})).filter(
|
|
106
|
+
(job) => (job.status === "rendering" || job.status === "encoding") && !alive(job.pid),
|
|
107
|
+
);
|
|
108
|
+
for (const job of stale) {
|
|
109
|
+
await updateJob(config, {
|
|
110
|
+
...job,
|
|
111
|
+
status: "failed",
|
|
112
|
+
error: "The render process exited before the job finished.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return stale.length;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const listJobs = async (config: ResolvedConfig, options: {reconcile?: boolean} = {}): Promise<ExportJob[]> => {
|
|
119
|
+
if (!existsSync(buildsDir(config))) return [];
|
|
120
|
+
if (options.reconcile !== false) await reconcileJobs(config);
|
|
121
|
+
const files = (await readdir(buildsDir(config))).filter((file) => file.endsWith(".json"));
|
|
122
|
+
const jobs: ExportJob[] = [];
|
|
123
|
+
|
|
124
|
+
// One unreadable record must not hide every other job.
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
try {
|
|
127
|
+
const raw = await readFile(join(buildsDir(config), file), "utf8");
|
|
128
|
+
jobs.push((JSON.parse(raw) as JobRecord).job);
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return jobs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A single-lane queue.
|
|
138
|
+
*
|
|
139
|
+
* Rendering saturates the machine's cores already, so jobs run one at a time
|
|
140
|
+
* in submission order. The queue is a promise chain rather than a daemon, which
|
|
141
|
+
* keeps `odori export` and the Studio export button on exactly the same path.
|
|
142
|
+
*/
|
|
143
|
+
export class JobQueue {
|
|
144
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
145
|
+
|
|
146
|
+
enqueue<Value>(task: () => Promise<Value>): Promise<Value> {
|
|
147
|
+
const result = this.chain.then(task, task);
|
|
148
|
+
this.chain = result.catch(() => undefined);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const ESC = String.fromCharCode(27);
|
|
2
|
+
const wrap = (code: string, value: string) => `${ESC}[${code}m${value}${ESC}[0m`;
|
|
3
|
+
|
|
4
|
+
export const log = {
|
|
5
|
+
info: (message: string) => console.log(message),
|
|
6
|
+
detail: (message: string) => console.log(wrap("2", message)),
|
|
7
|
+
title: (message: string) => console.log(wrap("1", message)),
|
|
8
|
+
success: (message: string) => console.log(`${wrap("32", "ok")} ${message}`),
|
|
9
|
+
warn: (message: string) => console.warn(`${wrap("33", "!")} ${message}`),
|
|
10
|
+
error: (message: string) => console.error(`${wrap("31", "x")} ${message}`),
|
|
11
|
+
progress: (message: string) => {
|
|
12
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${wrap("2", message)}${ESC}[K`);
|
|
13
|
+
},
|
|
14
|
+
progressDone: () => {
|
|
15
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${ESC}[K`);
|
|
16
|
+
},
|
|
17
|
+
};
|
package/src/open.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {spawn} from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const command = (): {bin: string; args: string[]} | undefined => {
|
|
4
|
+
if (process.platform === "darwin") return {bin: "open", args: []};
|
|
5
|
+
if (process.platform === "win32") return {bin: "cmd", args: ["/c", "start", ""]};
|
|
6
|
+
if (process.platform === "linux") return {bin: "xdg-open", args: []};
|
|
7
|
+
return undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Studio is a browser workspace, so `odori dev` opens it. Automation should
|
|
12
|
+
* not steal focus: CI and non-interactive shells are left alone, and the URL
|
|
13
|
+
* is always printed so the browser is a convenience rather than the interface.
|
|
14
|
+
*/
|
|
15
|
+
export const shouldOpenBrowser = (flag?: boolean): boolean => {
|
|
16
|
+
if (flag !== undefined) return flag;
|
|
17
|
+
if (process.env.ODORI_OPEN === "0" || process.env.ODORI_OPEN === "false") return false;
|
|
18
|
+
if (process.env.CI) return false;
|
|
19
|
+
return process.stdout.isTTY === true;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const openInBrowser = (url: string): void => {
|
|
23
|
+
const resolved = command();
|
|
24
|
+
if (!resolved) return;
|
|
25
|
+
try {
|
|
26
|
+
const child = spawn(resolved.bin, [...resolved.args, url], {stdio: "ignore", detached: true});
|
|
27
|
+
child.on("error", () => {});
|
|
28
|
+
child.unref();
|
|
29
|
+
} catch {
|
|
30
|
+
// A missing browser opener is never worth failing the dev server over.
|
|
31
|
+
}
|
|
32
|
+
};
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video ids are paths under `videos/`; files on disk are flat.
|
|
3
|
+
*
|
|
4
|
+
* Two encodings, deliberately different. An output file is something a person
|
|
5
|
+
* hands to someone else, so `social/announcement` becomes the readable
|
|
6
|
+
* `social-announcement`. A cache file is addressed, not read, so it encodes
|
|
7
|
+
* the separator instead of replacing it: `social+announcement` cannot collide
|
|
8
|
+
* with a video genuinely named `social-announcement`.
|
|
9
|
+
*/
|
|
10
|
+
export const outputName = (id: string): string => id.split("/").join("-");
|
|
11
|
+
|
|
12
|
+
export const fileKey = (id: string): string => id.split("/").join("+");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {mkdir, readFile, readdir, rm, writeFile} from "node:fs/promises";
|
|
3
|
+
import {join, resolve} from "node:path";
|
|
4
|
+
import {hashValue} from "odori";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {fileKey} from "./paths";
|
|
7
|
+
|
|
8
|
+
export type PrepareCacheKey = {
|
|
9
|
+
videoId: string;
|
|
10
|
+
sourceHash: string;
|
|
11
|
+
input: unknown;
|
|
12
|
+
version: string;
|
|
13
|
+
dependencies?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PrepareCacheEntry = {key: PrepareCacheKey; value: unknown; createdAt: string};
|
|
17
|
+
|
|
18
|
+
const directory = (config: ResolvedConfig) => resolve(config.root, config.outDir, "cache", "prepare");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Prepared data is cached by video source hash, validated input, prepare
|
|
22
|
+
* version, and declared dependencies. Changing scene styling does not refetch
|
|
23
|
+
* source data, and changing a data dependency invalidates deterministically.
|
|
24
|
+
*/
|
|
25
|
+
export const prepareCacheKey = (key: PrepareCacheKey): string => `${fileKey(key.videoId)}__${hashValue(key)}`;
|
|
26
|
+
|
|
27
|
+
export const readPrepareCache = async (
|
|
28
|
+
config: ResolvedConfig,
|
|
29
|
+
key: PrepareCacheKey,
|
|
30
|
+
): Promise<{hit: boolean; value: unknown}> => {
|
|
31
|
+
const file = join(directory(config), `${prepareCacheKey(key)}.json`);
|
|
32
|
+
if (!existsSync(file)) return {hit: false, value: undefined};
|
|
33
|
+
try {
|
|
34
|
+
const entry = JSON.parse(await readFile(file, "utf8")) as PrepareCacheEntry;
|
|
35
|
+
return {hit: true, value: entry.value};
|
|
36
|
+
} catch {
|
|
37
|
+
return {hit: false, value: undefined};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const writePrepareCache = async (config: ResolvedConfig, key: PrepareCacheKey, value: unknown) => {
|
|
42
|
+
if (value === undefined) return;
|
|
43
|
+
const target = directory(config);
|
|
44
|
+
await mkdir(target, {recursive: true});
|
|
45
|
+
const entry: PrepareCacheEntry = {key, value, createdAt: new Date().toISOString()};
|
|
46
|
+
await writeFile(join(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}\n`, "utf8");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Remove cached preparations for a video, or for the whole project. */
|
|
50
|
+
export const clearPrepareCache = async (config: ResolvedConfig, videoId?: string): Promise<number> => {
|
|
51
|
+
const target = directory(config);
|
|
52
|
+
if (!existsSync(target)) return 0;
|
|
53
|
+
const files = await readdir(target);
|
|
54
|
+
// The separator cannot appear in an id, so "launch" never clears "launch-extra".
|
|
55
|
+
const matches = files.filter((file) => (videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json")));
|
|
56
|
+
await Promise.all(matches.map((file) => rm(join(target, file), {force: true})));
|
|
57
|
+
return matches.length;
|
|
58
|
+
};
|