@odori/cli 0.0.2 → 0.0.3
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/dist/{chunk-7XJL2BYO.js → chunk-RXLB2CXH.js} +367 -171
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +29 -3
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-NIH2JMQ6.js → registry-snapshot-BDP6PVYB.js} +2 -2
- package/package.json +3 -3
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +10 -3
- package/src/commands/add.ts +8 -8
- package/src/commands/dev.ts +86 -11
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +22 -3
- package/src/formats.ts +67 -8
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +2 -2
- package/src/registry-source.ts +50 -3
- package/src/render.ts +48 -13
- package/src/server.ts +48 -12
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +121 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/studio.css +194 -26
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +58 -27
- package/studio/src/views/ComponentsView.tsx +23 -37
- package/studio/src/views/HomeView.tsx +39 -17
- package/studio/src/views/VideosView.tsx +34 -49
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -177,6 +177,16 @@ declare const freezeManifest: (video: LoadedVideo, graph: ProjectGraph, config:
|
|
|
177
177
|
* concatenated without re-encoding. `chunked: false` says a format has to be
|
|
178
178
|
* encoded in one pass, which is slower and correct.
|
|
179
179
|
*/
|
|
180
|
+
/** Compression tiers, named for where the file is going. */
|
|
181
|
+
type Quality = "studio" | "social" | "web";
|
|
182
|
+
type EncodeOptions = {
|
|
183
|
+
/** x264-style speed preset. Orthogonal to quality: it trades time, not pixels. */
|
|
184
|
+
preset: string;
|
|
185
|
+
/** Compression tier. Codecs that are already lossless or mandated ignore it. */
|
|
186
|
+
quality: Quality;
|
|
187
|
+
/** Output scale multiplier. 1 is the composition's own size. */
|
|
188
|
+
scale: number;
|
|
189
|
+
};
|
|
180
190
|
type VideoFormat = {
|
|
181
191
|
name: string;
|
|
182
192
|
extension: string;
|
|
@@ -186,8 +196,8 @@ type VideoFormat = {
|
|
|
186
196
|
chunked: boolean;
|
|
187
197
|
/** Whether the container carries an audio track at all. */
|
|
188
198
|
audio: boolean;
|
|
189
|
-
/** FFmpeg arguments for the video stream
|
|
190
|
-
args: (
|
|
199
|
+
/** FFmpeg arguments for the video stream. */
|
|
200
|
+
args: (options: EncodeOptions) => string[];
|
|
191
201
|
description: string;
|
|
192
202
|
};
|
|
193
203
|
declare const FORMATS: Record<string, VideoFormat>;
|
|
@@ -254,6 +264,10 @@ type RenderTimings = {
|
|
|
254
264
|
type RenderOptions = {
|
|
255
265
|
concurrency?: number;
|
|
256
266
|
preset?: string;
|
|
267
|
+
/** Compression tier. Defaults to studio, which is what the pipeline always was. */
|
|
268
|
+
quality?: Quality;
|
|
269
|
+
/** Output scale multiplier. Defaults to 1, the composition's own size. */
|
|
270
|
+
scale?: number;
|
|
257
271
|
/** Container and codec. Defaults to H.264 in MP4. */
|
|
258
272
|
format?: VideoFormat;
|
|
259
273
|
skipUnchangedFrames?: boolean;
|
|
@@ -298,16 +312,24 @@ type CompileResult = {
|
|
|
298
312
|
declare const compileInBrowser: (origin: string, target: RenderTarget, config: ResolvedConfig) => Promise<CompileResult>;
|
|
299
313
|
declare const withServer: <Value>(config: ResolvedConfig, handler: (server: StudioServer) => Promise<Value>) => Promise<Value>;
|
|
300
314
|
|
|
315
|
+
/** How a job should be encoded, frozen with it so a retry cannot drift. */
|
|
316
|
+
type JobRender = {
|
|
317
|
+
format?: string;
|
|
318
|
+
quality?: string;
|
|
319
|
+
scale?: number;
|
|
320
|
+
preset?: string;
|
|
321
|
+
};
|
|
301
322
|
type JobRecord = {
|
|
302
323
|
job: ExportJob;
|
|
303
324
|
manifest: RenderManifest;
|
|
304
325
|
output: string;
|
|
326
|
+
render?: JobRender;
|
|
305
327
|
};
|
|
306
328
|
/**
|
|
307
329
|
* An export job records the frozen manifest, progress, attempts, and result,
|
|
308
330
|
* so a retry reuses the approved inputs instead of resolving them again.
|
|
309
331
|
*/
|
|
310
|
-
declare const createJob: (config: ResolvedConfig, manifest: RenderManifest, output: string) => Promise<JobRecord>;
|
|
332
|
+
declare const createJob: (config: ResolvedConfig, manifest: RenderManifest, output: string, render?: JobRender) => Promise<JobRecord>;
|
|
311
333
|
declare const readJob: (config: ResolvedConfig, id: string) => Promise<JobRecord>;
|
|
312
334
|
declare const updateJob: (config: ResolvedConfig, job: ExportJob) => Promise<ExportJob>;
|
|
313
335
|
declare const appendJobLog: (config: ResolvedConfig, id: string, message: string) => Promise<ExportJob>;
|
|
@@ -494,6 +516,8 @@ declare const cancelJob: (id: string) => boolean;
|
|
|
494
516
|
declare const runJob: (config: ResolvedConfig, origin: string, record: JobRecord, video: LoadedVideo, options?: {
|
|
495
517
|
concurrency?: number;
|
|
496
518
|
preset?: string;
|
|
519
|
+
quality?: Quality;
|
|
520
|
+
scale?: number;
|
|
497
521
|
format?: VideoFormat;
|
|
498
522
|
skipUnchangedFrames?: boolean;
|
|
499
523
|
signal?: AbortSignal;
|
|
@@ -504,6 +528,8 @@ declare const exportCommand: (id: string, options?: {
|
|
|
504
528
|
input?: Record<string, unknown>;
|
|
505
529
|
concurrency?: number;
|
|
506
530
|
preset?: string;
|
|
531
|
+
quality?: string;
|
|
532
|
+
scale?: number;
|
|
507
533
|
format?: string;
|
|
508
534
|
skipUnchangedFrames?: boolean;
|
|
509
535
|
retry?: string;
|
package/dist/index.js
CHANGED
|
@@ -401,7 +401,7 @@ var registry_snapshot_default = {
|
|
|
401
401
|
},
|
|
402
402
|
{
|
|
403
403
|
name: "brand-provider",
|
|
404
|
-
description: "
|
|
404
|
+
description: "The resolved brand rendered as a sheet: color, type, and motion.",
|
|
405
405
|
registryDependencies: [],
|
|
406
406
|
files: [
|
|
407
407
|
{
|
|
@@ -411,7 +411,7 @@ var registry_snapshot_default = {
|
|
|
411
411
|
},
|
|
412
412
|
{
|
|
413
413
|
path: "components/brand-provider/brand-provider.preview.tsx",
|
|
414
|
-
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BrandProvider} from "./brand-provider";\n\nexport default defineComponentPreview({\n title: "Brand provider",\n category: "Foundation",\n description: "
|
|
414
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BrandProvider} from "./brand-provider";\n\nexport default defineComponentPreview({\n title: "Brand provider",\n category: "Foundation",\n description: "The resolved brand rendered as a sheet: color, type, and motion.",\n component: BrandProvider,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Brand tokens"},\n },\n examples: [\n {name: "Everything", props: {}},\n {name: "Colors only", props: {show: ["colors"], title: "Palette"}},\n ],\n});\n',
|
|
415
415
|
target: "videos/components/brand-provider/brand-provider.preview.tsx"
|
|
416
416
|
}
|
|
417
417
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"playwright-core": "1.55.0",
|
|
29
29
|
"tsx": "4.20.5",
|
|
30
30
|
"vite": "7.3.0",
|
|
31
|
-
"odori": "0.0.
|
|
31
|
+
"odori": "0.0.3"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "22.19.0",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@types/react-dom": "19.2.3",
|
|
37
37
|
"tsup": "^8.5.1",
|
|
38
38
|
"typescript": "5.9.3",
|
|
39
|
-
"@odori/registry": "0.0.
|
|
39
|
+
"@odori/registry": "0.0.3"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
package/src/chunk-cache.ts
CHANGED
|
@@ -12,6 +12,10 @@ export type ChunkIdentity = {
|
|
|
12
12
|
height: number;
|
|
13
13
|
fps: number;
|
|
14
14
|
preset: string;
|
|
15
|
+
/** Compression tier the chunk was encoded at. Changes the bytes. */
|
|
16
|
+
quality?: string;
|
|
17
|
+
/** Scale multiplier the chunk was encoded at. Changes the dimensions. */
|
|
18
|
+
scale?: number;
|
|
15
19
|
/** The browser build that captured the frames. See `chunkKey`. */
|
|
16
20
|
renderer?: string;
|
|
17
21
|
/** The codec the chunk was encoded with, which decides what it can join. */
|
|
@@ -49,6 +53,8 @@ export const chunkKey = (identity: ChunkIdentity): string =>
|
|
|
49
53
|
height: identity.height,
|
|
50
54
|
fps: identity.fps,
|
|
51
55
|
preset: identity.preset,
|
|
56
|
+
quality: identity.quality ?? null,
|
|
57
|
+
scale: identity.scale ?? null,
|
|
52
58
|
input: identity.input ?? null,
|
|
53
59
|
});
|
|
54
60
|
|
package/src/cli.ts
CHANGED
|
@@ -86,7 +86,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
86
86
|
inspect: ["json", "input"],
|
|
87
87
|
still: ["frame", "output", "input"],
|
|
88
88
|
test: ["json"],
|
|
89
|
-
export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
|
|
89
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
|
|
90
90
|
jobs: [],
|
|
91
91
|
help: [],
|
|
92
92
|
};
|
|
@@ -166,9 +166,12 @@ const USAGE: Record<string, string> = {
|
|
|
166
166
|
Validate contracts and representative frames. --json emits one object per
|
|
167
167
|
check, for CI.`,
|
|
168
168
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
169
|
-
[--preset <name>] [--format <name>] [--
|
|
169
|
+
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
170
|
+
[--no-frame-skip] [--retry <job>]
|
|
170
171
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
171
|
-
or png; without it the output's extension decides, and mp4 is the default
|
|
172
|
+
or png; without it the output's extension decides, and mp4 is the default.
|
|
173
|
+
--quality is studio, social, or web. --scale multiplies the output size,
|
|
174
|
+
0.25 to 2. A retry keeps the settings its job was created with.`,
|
|
172
175
|
jobs: `odori jobs
|
|
173
176
|
List export jobs and their status.`,
|
|
174
177
|
};
|
|
@@ -199,6 +202,8 @@ Options
|
|
|
199
202
|
--concurrency <n> Parallel render workers for export
|
|
200
203
|
--preset <name> x264 preset for export, default medium
|
|
201
204
|
--format <name> mp4, webm, prores, gif, or png
|
|
205
|
+
--quality <tier> studio, social, or web compression
|
|
206
|
+
--scale <n> Output size multiplier, 0.25 to 2
|
|
202
207
|
--no-frame-skip Capture every frame, even unchanged ones
|
|
203
208
|
--retry <job id> Re-run a recorded job from its frozen manifest
|
|
204
209
|
--no-open Start dev without opening Studio in a browser
|
|
@@ -291,6 +296,8 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
291
296
|
input: parseInput(flags),
|
|
292
297
|
concurrency: numberFlag(flags, "concurrency"),
|
|
293
298
|
preset: typeof flags.preset === "string" ? flags.preset : undefined,
|
|
299
|
+
quality: typeof flags.quality === "string" ? flags.quality : undefined,
|
|
300
|
+
scale: numberFlag(flags, "scale"),
|
|
294
301
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
295
302
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
296
303
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
package/src/commands/add.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {hashString} from "odori";
|
|
|
5
5
|
import {loadConfig} from "../config";
|
|
6
6
|
import {log} from "../log";
|
|
7
7
|
import {registerCueInBrand} from "../brand-file";
|
|
8
|
-
import {normalizeComponentName, registryUrl, resolveItem, resolveRegistry, verifyIntegrity} from "../registry-source";
|
|
8
|
+
import {assertSafeName, normalizeComponentName, registryUrl, resolveItem, resolveRegistry, resolveWithinRoot, verifyIntegrity} from "../registry-source";
|
|
9
9
|
import {readProvenance, writeProvenance} from "./update";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -14,7 +14,7 @@ import {readProvenance, writeProvenance} from "./update";
|
|
|
14
14
|
* replaced without an explicit decision.
|
|
15
15
|
*/
|
|
16
16
|
export const addCommand = async (names: string[], options: {force?: boolean; dryRun?: boolean} = {}) => {
|
|
17
|
-
if (names.length === 0) throw new Error("Name at least one component, for example
|
|
17
|
+
if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
|
|
18
18
|
const config = await loadConfig(process.cwd());
|
|
19
19
|
const source = await resolveRegistry(config);
|
|
20
20
|
const registry = source.items;
|
|
@@ -47,18 +47,18 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// The item document carries the file contents; the index does not.
|
|
50
|
-
const {item} = await resolveItem(config, component.name);
|
|
50
|
+
const {item, origin} = await resolveItem(config, component.name);
|
|
51
51
|
// Before anything is written: the bytes have to be the bytes the registry
|
|
52
|
-
// said it was serving.
|
|
53
|
-
verifyIntegrity(item);
|
|
52
|
+
// said it was serving, and a fetched document has to say so at all.
|
|
53
|
+
verifyIntegrity(item, origin);
|
|
54
54
|
|
|
55
|
-
const target = resolve(config.root, config.componentsDir, component.name);
|
|
55
|
+
const target = resolve(config.root, config.componentsDir, assertSafeName(component.name));
|
|
56
56
|
const hashes: Record<string, string> = {};
|
|
57
57
|
|
|
58
58
|
// What will be written, listed before it is. Installing source into
|
|
59
59
|
// somebody's repository should never be the first they hear of a path.
|
|
60
60
|
for (const file of item.files) {
|
|
61
|
-
const destination =
|
|
61
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
62
62
|
const exists = existsSync(destination);
|
|
63
63
|
log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
|
|
64
64
|
}
|
|
@@ -71,7 +71,7 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
|
|
|
71
71
|
|
|
72
72
|
for (const file of item.files) {
|
|
73
73
|
const name = file.path.split("/").pop() ?? file.path;
|
|
74
|
-
const destination =
|
|
74
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
75
75
|
hashes[name] = hashString(file.content);
|
|
76
76
|
|
|
77
77
|
if (existsSync(destination) && !options.force) {
|
package/src/commands/dev.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {resolve} from "node:path";
|
|
2
|
+
import {homedir} from "node:os";
|
|
3
|
+
import {existsSync} from "node:fs";
|
|
2
4
|
import {readFile} from "node:fs/promises";
|
|
3
5
|
import type {IncomingMessage, ServerResponse} from "node:http";
|
|
4
6
|
import {loadConfig} from "../config";
|
|
@@ -6,10 +8,11 @@ import {log} from "../log";
|
|
|
6
8
|
import {createJob, listJobs, readJob} from "../jobs";
|
|
7
9
|
import {discoverProject} from "../discovery";
|
|
8
10
|
import {findVideo, freezeManifest, loadVideos, outputName} from "../project";
|
|
11
|
+
import {resolveFormat} from "../formats";
|
|
9
12
|
import {renderStill} from "../render";
|
|
10
13
|
import {openInBrowser, shouldOpenBrowser} from "../open";
|
|
11
14
|
import {startStudioServer} from "../server";
|
|
12
|
-
import {cancelJob, runJob} from "./exportVideo";
|
|
15
|
+
import {cancelJob, resolveQuality, resolveScale, runJob} from "./exportVideo";
|
|
13
16
|
import {compileInBrowser, targetFor} from "./shared";
|
|
14
17
|
|
|
15
18
|
const readBody = async (request: IncomingMessage): Promise<Record<string, unknown>> => {
|
|
@@ -25,6 +28,65 @@ const json = (response: ServerResponse, status: number, payload: unknown) => {
|
|
|
25
28
|
response.end(JSON.stringify(payload));
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Where a Studio export lands: the Downloads folder, like any app that hands
|
|
33
|
+
* you a file. The CLI keeps writing into the project's export directory - a
|
|
34
|
+
* build artifact belongs to the build - but a file made by clicking a button
|
|
35
|
+
* belongs where files made by clicking buttons go.
|
|
36
|
+
*/
|
|
37
|
+
const exportDestination = (config: {root: string; exportDir: string}): string => {
|
|
38
|
+
const downloads = resolve(homedir(), "Downloads");
|
|
39
|
+
return existsSync(downloads) ? downloads : resolve(config.root, config.exportDir);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* These endpoints spawn a browser and an encoder and write files, so they are
|
|
44
|
+
* not the harmless read-only surface a dev server usually exposes. The server
|
|
45
|
+
* binds to 127.0.0.1, which keeps other machines out, but a page open in the
|
|
46
|
+
* developer's own browser can still reach a loopback port — directly, or by
|
|
47
|
+
* rebinding a hostname it controls to 127.0.0.1 and POSTing to it.
|
|
48
|
+
*
|
|
49
|
+
* So a request has to look like it came from Studio itself: its Host must be a
|
|
50
|
+
* loopback name, and a cross-site fetch (which the browser stamps with an
|
|
51
|
+
* Origin) must be same-origin. A same-origin XHR from Studio carries neither a
|
|
52
|
+
* foreign Origin nor a foreign Host and passes; a drive-by page fails both.
|
|
53
|
+
*/
|
|
54
|
+
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
55
|
+
|
|
56
|
+
const hostOf = (value: string | undefined): string | null => {
|
|
57
|
+
if (!value) return null;
|
|
58
|
+
// Strip a port without tripping over an IPv6 literal's own colons.
|
|
59
|
+
const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
|
|
60
|
+
return withoutPort || null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const isLocalRequest = (request: IncomingMessage): boolean => {
|
|
64
|
+
const host = hostOf(request.headers.host);
|
|
65
|
+
if (!host || !LOOPBACK.has(host)) return false;
|
|
66
|
+
const origin = request.headers.origin;
|
|
67
|
+
if (origin) {
|
|
68
|
+
try {
|
|
69
|
+
if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A job id is minted as `job-<hash>-<base36>` and then read straight back as a
|
|
79
|
+
* filename, so it is confined to the characters that mint it. This keeps a
|
|
80
|
+
* crafted id like `../../etc/passwd` from ever reaching the filesystem, even
|
|
81
|
+
* though the local-origin guard already stands in front of it.
|
|
82
|
+
*/
|
|
83
|
+
export const safeJobId = (id: string): string => {
|
|
84
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
|
|
85
|
+
throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
|
|
86
|
+
}
|
|
87
|
+
return id;
|
|
88
|
+
};
|
|
89
|
+
|
|
28
90
|
/**
|
|
29
91
|
* Studio previews without encoding. These endpoints exist so an explicit
|
|
30
92
|
* export action in the browser reaches the same queue and render worker the
|
|
@@ -42,6 +104,10 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
42
104
|
middleware: (vite) => {
|
|
43
105
|
vite.middlewares.use("/__odori", (request, response, next) => {
|
|
44
106
|
const url = request.url ?? "/";
|
|
107
|
+
if (!isLocalRequest(request)) {
|
|
108
|
+
json(response, 403, {error: "This endpoint only answers same-origin requests from Studio on localhost."});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
45
111
|
void (async () => {
|
|
46
112
|
try {
|
|
47
113
|
if (request.method === "POST" && url.startsWith("/still")) {
|
|
@@ -61,8 +127,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
61
127
|
// A clipboard grab is transient, so it renders into the generated
|
|
62
128
|
// directory instead of littering the export directory.
|
|
63
129
|
const inline = body.inline === true;
|
|
64
|
-
const
|
|
65
|
-
|
|
130
|
+
const file = inline
|
|
131
|
+
? resolve(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`)
|
|
132
|
+
: resolve(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
|
|
66
133
|
await renderStill(
|
|
67
134
|
origin,
|
|
68
135
|
targetFor(
|
|
@@ -87,6 +154,11 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
87
154
|
|
|
88
155
|
if (request.method === "POST" && url.startsWith("/exports")) {
|
|
89
156
|
const body = await readBody(request);
|
|
157
|
+
// Validated before compiling: a wrong option should cost a
|
|
158
|
+
// sentence, not a browser launch.
|
|
159
|
+
const format = resolveFormat(typeof body.format === "string" ? body.format : undefined, undefined);
|
|
160
|
+
const quality = resolveQuality(typeof body.quality === "string" ? body.quality : undefined);
|
|
161
|
+
const scale = resolveScale(typeof body.scale === "number" ? body.scale : undefined);
|
|
90
162
|
const {graph, videos} = await context();
|
|
91
163
|
const video = findVideo(videos, String(body.videoId));
|
|
92
164
|
const input = (body.input ?? {}) as Record<string, unknown>;
|
|
@@ -98,8 +170,11 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
98
170
|
input,
|
|
99
171
|
{scenes: compiled.scenes, audio: compiled.audio},
|
|
100
172
|
);
|
|
101
|
-
const output = resolve(
|
|
102
|
-
|
|
173
|
+
const output = resolve(
|
|
174
|
+
exportDestination(config),
|
|
175
|
+
`${outputName(video.entry.metadata.id)}${format.extension}`,
|
|
176
|
+
);
|
|
177
|
+
const record = await createJob(config, manifest, output, {format: format.name, quality, scale});
|
|
103
178
|
json(response, 202, record.job);
|
|
104
179
|
|
|
105
180
|
void runJob(config, origin, record, video).catch((error) => {
|
|
@@ -109,7 +184,7 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
109
184
|
}
|
|
110
185
|
|
|
111
186
|
if (request.method === "POST" && url.startsWith("/retry/")) {
|
|
112
|
-
const id = url.replace("/retry/", "").split("?")[0];
|
|
187
|
+
const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
|
|
113
188
|
const record = await readJob(config, id);
|
|
114
189
|
const {videos} = await context();
|
|
115
190
|
const video = findVideo(videos, record.manifest.videoId);
|
|
@@ -121,14 +196,14 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
121
196
|
}
|
|
122
197
|
|
|
123
198
|
if (request.method === "POST" && url.startsWith("/cancel/")) {
|
|
124
|
-
const id = url.replace("/cancel/", "").split("?")[0];
|
|
199
|
+
const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
|
|
125
200
|
const cancelled = cancelJob(id);
|
|
126
201
|
json(response, cancelled ? 202 : 404, {id, cancelled});
|
|
127
202
|
return;
|
|
128
203
|
}
|
|
129
204
|
|
|
130
205
|
if (request.method === "GET" && url.startsWith("/jobs/")) {
|
|
131
|
-
const id = url.replace("/jobs/", "").split("?")[0];
|
|
206
|
+
const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
|
|
132
207
|
json(response, 200, (await readJob(config, id)).job);
|
|
133
208
|
return;
|
|
134
209
|
}
|
|
@@ -148,9 +223,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
148
223
|
});
|
|
149
224
|
|
|
150
225
|
const origin = server.url;
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const entry = `${origin}
|
|
226
|
+
// Studio opens on the home overview: what the project contains, each card
|
|
227
|
+
// playing, with every deeper view one click in.
|
|
228
|
+
const entry = `${origin}/`;
|
|
154
229
|
log.title("Odori Studio");
|
|
155
230
|
log.info(` ${entry}`);
|
|
156
231
|
log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
|
|
@@ -4,7 +4,7 @@ import type {ResolvedConfig} from "../config";
|
|
|
4
4
|
import {log} from "../log";
|
|
5
5
|
import {JobQueue, appendJobLog, createJob, listJobs, readJob, updateJob, type JobRecord} from "../jobs";
|
|
6
6
|
import {findVideo, freezeManifest, outputName, type LoadedVideo} from "../project";
|
|
7
|
-
import {resolveFormat, type VideoFormat} from "../formats";
|
|
7
|
+
import {resolveFormat, QUALITIES, type Quality, type VideoFormat} from "../formats";
|
|
8
8
|
import {materializeCues} from "../cues";
|
|
9
9
|
import {renderMovie} from "../render";
|
|
10
10
|
import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
|
|
@@ -33,6 +33,8 @@ export const runJob = async (
|
|
|
33
33
|
options: {
|
|
34
34
|
concurrency?: number;
|
|
35
35
|
preset?: string;
|
|
36
|
+
quality?: Quality;
|
|
37
|
+
scale?: number;
|
|
36
38
|
format?: VideoFormat;
|
|
37
39
|
skipUnchangedFrames?: boolean;
|
|
38
40
|
signal?: AbortSignal;
|
|
@@ -86,8 +88,12 @@ export const runJob = async (
|
|
|
86
88
|
},
|
|
87
89
|
{
|
|
88
90
|
concurrency: options.concurrency,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
// The record freezes how it should be encoded alongside what, so a
|
|
92
|
+
// retry from any surface produces the same file the first run would.
|
|
93
|
+
preset: options.preset ?? record.render?.preset,
|
|
94
|
+
quality: options.quality ?? (record.render?.quality as Quality | undefined),
|
|
95
|
+
scale: options.scale ?? record.render?.scale,
|
|
96
|
+
format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : undefined),
|
|
91
97
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
92
98
|
signal: controller.signal,
|
|
93
99
|
onTimings: (timings) => {
|
|
@@ -119,6 +125,22 @@ export const runJob = async (
|
|
|
119
125
|
}
|
|
120
126
|
});
|
|
121
127
|
|
|
128
|
+
/** A named tier, or a sentence naming the tiers. */
|
|
129
|
+
export const resolveQuality = (requested: string | undefined): Quality => {
|
|
130
|
+
if (!requested) return "studio";
|
|
131
|
+
if ((QUALITIES as string[]).includes(requested)) return requested as Quality;
|
|
132
|
+
throw new Error(`Unknown quality "${requested}". Available: ${QUALITIES.join(", ")}`);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** A sane multiplier: enough for a half-size preview and a 2x retina cut. */
|
|
136
|
+
export const resolveScale = (requested: number | undefined): number => {
|
|
137
|
+
if (requested === undefined) return 1;
|
|
138
|
+
if (!Number.isFinite(requested) || requested < 0.25 || requested > 2) {
|
|
139
|
+
throw new Error(`Scale ${requested} is out of range. Use a value between 0.25 and 2.`);
|
|
140
|
+
}
|
|
141
|
+
return requested;
|
|
142
|
+
};
|
|
143
|
+
|
|
122
144
|
export const exportCommand = async (
|
|
123
145
|
id: string,
|
|
124
146
|
options: {
|
|
@@ -126,6 +148,8 @@ export const exportCommand = async (
|
|
|
126
148
|
input?: Record<string, unknown>;
|
|
127
149
|
concurrency?: number;
|
|
128
150
|
preset?: string;
|
|
151
|
+
quality?: string;
|
|
152
|
+
scale?: number;
|
|
129
153
|
format?: string;
|
|
130
154
|
skipUnchangedFrames?: boolean;
|
|
131
155
|
retry?: string;
|
|
@@ -135,6 +159,8 @@ export const exportCommand = async (
|
|
|
135
159
|
// Resolved before anything renders: an unknown format should cost a sentence,
|
|
136
160
|
// not twenty minutes of capture.
|
|
137
161
|
const format = resolveFormat(options.format ?? config.format, options.output);
|
|
162
|
+
const quality = resolveQuality(options.quality);
|
|
163
|
+
const scale = resolveScale(options.scale);
|
|
138
164
|
|
|
139
165
|
return withServer(config, async (server) => {
|
|
140
166
|
const record = options.retry
|
|
@@ -153,7 +179,12 @@ export const exportCommand = async (
|
|
|
153
179
|
config.root,
|
|
154
180
|
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`,
|
|
155
181
|
);
|
|
156
|
-
return createJob(config, manifest, output
|
|
182
|
+
return createJob(config, manifest, output, {
|
|
183
|
+
format: format.name,
|
|
184
|
+
quality,
|
|
185
|
+
scale,
|
|
186
|
+
...(options.preset ? {preset: options.preset} : {}),
|
|
187
|
+
});
|
|
157
188
|
})();
|
|
158
189
|
|
|
159
190
|
const video = findVideo(videos, record.manifest.videoId);
|
|
@@ -166,7 +197,10 @@ export const exportCommand = async (
|
|
|
166
197
|
const job = await runJob(config, server.url, record, video, {
|
|
167
198
|
concurrency: options.concurrency,
|
|
168
199
|
preset: options.preset,
|
|
169
|
-
|
|
200
|
+
// A retry keeps what its record froze; an explicit flag still wins.
|
|
201
|
+
quality: options.retry && options.quality === undefined ? undefined : quality,
|
|
202
|
+
scale: options.retry && options.scale === undefined ? undefined : scale,
|
|
203
|
+
format: options.retry ? undefined : format,
|
|
170
204
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
171
205
|
onProgress: (next) => {
|
|
172
206
|
if (next.status === "rendering" || next.status === "encoding") {
|
package/src/commands/init.ts
CHANGED
|
@@ -52,5 +52,5 @@ export const initCommand = async (root = process.cwd()) => {
|
|
|
52
52
|
log.success(`Created ${relative(root, file)}`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
log.detail("Next: odori doctor, then odori add
|
|
55
|
+
log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
|
|
56
56
|
};
|
package/src/commands/new.ts
CHANGED
|
@@ -120,7 +120,7 @@ export const newCommand = async (name: string, options: {blank?: boolean} = {})
|
|
|
120
120
|
else if (options.blank !== true) {
|
|
121
121
|
// Nothing to compose with is worth saying once, with the command that
|
|
122
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
|
|
123
|
+
log.detail("No registry components installed yet: odori add title-reveal end-card");
|
|
124
124
|
}
|
|
125
125
|
log.detail("Run odori dev to preview it.");
|
|
126
126
|
};
|
package/src/cues.ts
CHANGED
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
resolveEntryLayout,
|
|
13
13
|
type Brand,
|
|
14
14
|
type CueDefinition,
|
|
15
|
+
type VideoEntry,
|
|
16
|
+
type VideoLayout,
|
|
15
17
|
} from "odori";
|
|
16
18
|
import type {ResolvedConfig} from "./config";
|
|
17
19
|
import type {ProjectGraph} from "./discovery";
|
|
18
20
|
import {log} from "./log";
|
|
19
|
-
import {loadVideos} from "./project";
|
|
20
21
|
|
|
21
22
|
/** Where a rendered cue lands. Content addressed, so it is written once. */
|
|
22
23
|
export const cueCacheDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "cues");
|
|
@@ -93,15 +94,22 @@ export const renderedCue = (url: string): Uint8Array | null => {
|
|
|
93
94
|
const isBrand = (value: unknown): value is Brand =>
|
|
94
95
|
typeof value === "object" && value !== null && (value as Brand).kind === "odori-brand";
|
|
95
96
|
|
|
97
|
+
const isLayout = (value: unknown): value is VideoLayout =>
|
|
98
|
+
typeof value === "object" && value !== null && (value as VideoLayout).kind === "odori-layout";
|
|
99
|
+
|
|
100
|
+
/** How registration reads a source module. The dev server supplies Vite. */
|
|
101
|
+
export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>;
|
|
102
|
+
|
|
96
103
|
/**
|
|
97
|
-
* Import a source file fresh.
|
|
104
|
+
* Import a source file fresh, for callers with no dev server.
|
|
98
105
|
*
|
|
99
|
-
* Node caches modules by URL for the life of the process
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
106
|
+
* Node caches modules by URL for the life of the process; the file's mtime in
|
|
107
|
+
* the specifier makes an edited file a new module and an untouched one a cache
|
|
108
|
+
* hit. What this cannot do is see through an import: a brand whose score lives
|
|
109
|
+
* in another file keeps that child cached, which is why the dev server loads
|
|
110
|
+
* through Vite's module graph instead — Vite invalidates the whole chain.
|
|
103
111
|
*/
|
|
104
|
-
const importFresh = async (file
|
|
112
|
+
export const importFresh: ModuleLoader = async (file) =>
|
|
105
113
|
(await import(`${pathToFileURL(file).href}?odori=${statSync(file).mtimeMs}`)) as Record<string, unknown>;
|
|
106
114
|
|
|
107
115
|
/**
|
|
@@ -109,30 +117,35 @@ const importFresh = async (file: string): Promise<Record<string, unknown>> =>
|
|
|
109
117
|
* one by content hash the moment Studio loads.
|
|
110
118
|
*
|
|
111
119
|
* 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
|
|
113
|
-
*
|
|
114
|
-
* then
|
|
115
|
-
* samples a cue occupies
|
|
116
|
-
*
|
|
117
|
-
*
|
|
120
|
+
* make a cue exist at all, including a brand no video has adopted yet. They
|
|
121
|
+
* register first at the default frame rate; any layout exported beside a brand
|
|
122
|
+
* then re-registers it at the rate that layout pins, because the frame rate
|
|
123
|
+
* decides how many samples a cue occupies. The video entries come last for the
|
|
124
|
+
* same reason: only an entry knows the layout it actually plays under. A file
|
|
125
|
+
* that cannot be loaded — half-written mid-edit — leaves the passes that
|
|
126
|
+
* succeeded in place rather than taking the server down.
|
|
118
127
|
*/
|
|
119
|
-
export const registerProjectCues = async (graph: ProjectGraph): Promise<number> => {
|
|
128
|
+
export const registerProjectCues = async (graph: ProjectGraph, load: ModuleLoader = importFresh): Promise<number> => {
|
|
120
129
|
for (const discovered of graph.brands) {
|
|
121
130
|
try {
|
|
122
|
-
const
|
|
123
|
-
registerCues(
|
|
131
|
+
const values = Object.values(await load(discovered.file));
|
|
132
|
+
registerCues(values.filter(isBrand), defaultLayout.format.fps);
|
|
133
|
+
for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
|
|
124
134
|
} catch (error) {
|
|
125
135
|
log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
|
|
126
136
|
}
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
139
|
+
for (const video of graph.videos) {
|
|
140
|
+
try {
|
|
141
|
+
const module = await load(video.file);
|
|
142
|
+
if (!module.default || !module.metadata) continue;
|
|
143
|
+
const entry = {component: module.default, metadata: module.metadata} as VideoEntry;
|
|
144
|
+
const layout = resolveEntryLayout(entry);
|
|
132
145
|
registerCues([layout.brand], layout.format.fps);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
|
|
133
148
|
}
|
|
134
|
-
} catch (error) {
|
|
135
|
-
log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
|
|
136
149
|
}
|
|
137
150
|
|
|
138
151
|
return known.size;
|