@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/src/discovery.ts
CHANGED
|
@@ -108,11 +108,13 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
108
108
|
const previews: DiscoveredPreview[] = [];
|
|
109
109
|
const brands: DiscoveredBrandModule[] = [];
|
|
110
110
|
const hashParts: string[] = [];
|
|
111
|
+
const componentsRoot = resolve(config.root, config.componentsDir);
|
|
111
112
|
|
|
112
113
|
for (const file of files) {
|
|
113
114
|
const relativeFile = relative(config.root, file);
|
|
115
|
+
let contents = "";
|
|
114
116
|
if (/\.(tsx|ts|css|json)$/.test(file)) {
|
|
115
|
-
|
|
117
|
+
contents = await readFile(file, "utf8");
|
|
116
118
|
hashParts.push(`${relativeFile}:${hashString(contents)}`);
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -127,13 +129,30 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
127
129
|
importPath: file,
|
|
128
130
|
identifier: toIdentifier(slug, "video"),
|
|
129
131
|
});
|
|
130
|
-
} else if (
|
|
132
|
+
} else if (
|
|
133
|
+
// A brand module is recognized by what it does, not where it sits. The
|
|
134
|
+
// scaffold defines its brand in videos/layout.tsx, so a directory-name
|
|
135
|
+
// rule alone left the default project's brand invisible to everything
|
|
136
|
+
// that reads this list — most visibly the dev server's cue registry,
|
|
137
|
+
// which then answered new cue URLs with 404s until a restart. Installed
|
|
138
|
+
// component source is excluded the way brand-file.ts excludes it: a
|
|
139
|
+
// component may mention defineBrand without being where a brand lives.
|
|
140
|
+
/\.tsx?$/.test(base) &&
|
|
141
|
+
!base.endsWith(".preview.tsx") &&
|
|
142
|
+
!file.startsWith(componentsRoot + sep) &&
|
|
143
|
+
(file.split(sep).includes("brands") || contents.includes("defineBrand("))
|
|
144
|
+
) {
|
|
131
145
|
const name = base.replace(/\.tsx?$/, "");
|
|
132
146
|
brands.push({
|
|
133
147
|
name,
|
|
134
148
|
file,
|
|
135
149
|
relativeFile,
|
|
136
|
-
|
|
150
|
+
// From the whole relative path, like previews: basenames repeat
|
|
151
|
+
// (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
|
|
152
|
+
identifier: toIdentifier(
|
|
153
|
+
`${relative(videosRoot, file).replace(/\.tsx?$/, "").split(sep).join("-")}-module`,
|
|
154
|
+
"brands",
|
|
155
|
+
),
|
|
137
156
|
});
|
|
138
157
|
} else if (base.endsWith(".preview.tsx")) {
|
|
139
158
|
const name = base.replace(/\.preview\.tsx$/, "");
|
package/src/formats.ts
CHANGED
|
@@ -11,6 +11,29 @@
|
|
|
11
11
|
* concatenated without re-encoding. `chunked: false` says a format has to be
|
|
12
12
|
* encoded in one pass, which is slower and correct.
|
|
13
13
|
*/
|
|
14
|
+
/** Compression tiers, named for where the file is going. */
|
|
15
|
+
export type Quality = "studio" | "social" | "web";
|
|
16
|
+
|
|
17
|
+
export const QUALITIES: Quality[] = ["studio", "social", "web"];
|
|
18
|
+
|
|
19
|
+
export type EncodeOptions = {
|
|
20
|
+
/** x264-style speed preset. Orthogonal to quality: it trades time, not pixels. */
|
|
21
|
+
preset: string;
|
|
22
|
+
/** Compression tier. Codecs that are already lossless or mandated ignore it. */
|
|
23
|
+
quality: Quality;
|
|
24
|
+
/** Output scale multiplier. 1 is the composition's own size. */
|
|
25
|
+
scale: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A scale filter that lands on even dimensions, which yuv420p requires.
|
|
30
|
+
* Lanczos, because a product video is text and lines more than it is grain.
|
|
31
|
+
*/
|
|
32
|
+
const scaleFilter = (scale: number): string =>
|
|
33
|
+
`scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos`;
|
|
34
|
+
|
|
35
|
+
const scaleArgs = (scale: number): string[] => (scale === 1 ? [] : ["-vf", scaleFilter(scale)]);
|
|
36
|
+
|
|
14
37
|
export type VideoFormat = {
|
|
15
38
|
name: string;
|
|
16
39
|
extension: string;
|
|
@@ -20,8 +43,8 @@ export type VideoFormat = {
|
|
|
20
43
|
chunked: boolean;
|
|
21
44
|
/** Whether the container carries an audio track at all. */
|
|
22
45
|
audio: boolean;
|
|
23
|
-
/** FFmpeg arguments for the video stream
|
|
24
|
-
args: (
|
|
46
|
+
/** FFmpeg arguments for the video stream. */
|
|
47
|
+
args: (options: EncodeOptions) => string[];
|
|
25
48
|
description: string;
|
|
26
49
|
};
|
|
27
50
|
|
|
@@ -33,7 +56,17 @@ export const FORMATS: Record<string, VideoFormat> = {
|
|
|
33
56
|
chunked: true,
|
|
34
57
|
audio: true,
|
|
35
58
|
description: "H.264 in MP4. Plays everywhere; the default.",
|
|
36
|
-
args: (preset
|
|
59
|
+
args: ({preset, quality, scale}) => [
|
|
60
|
+
...scaleArgs(scale),
|
|
61
|
+
"-c:v",
|
|
62
|
+
"libx264",
|
|
63
|
+
"-crf",
|
|
64
|
+
{studio: "17", social: "21", web: "27"}[quality],
|
|
65
|
+
"-preset",
|
|
66
|
+
preset,
|
|
67
|
+
"-pix_fmt",
|
|
68
|
+
"yuv420p",
|
|
69
|
+
],
|
|
37
70
|
},
|
|
38
71
|
webm: {
|
|
39
72
|
name: "webm",
|
|
@@ -43,7 +76,19 @@ export const FORMATS: Record<string, VideoFormat> = {
|
|
|
43
76
|
chunked: true,
|
|
44
77
|
audio: true,
|
|
45
78
|
description: "VP9 in WebM, with alpha. For the web, and for overlays.",
|
|
46
|
-
args: () => [
|
|
79
|
+
args: ({quality, scale}) => [
|
|
80
|
+
...scaleArgs(scale),
|
|
81
|
+
"-c:v",
|
|
82
|
+
"libvpx-vp9",
|
|
83
|
+
"-crf",
|
|
84
|
+
{studio: "24", social: "31", web: "38"}[quality],
|
|
85
|
+
"-b:v",
|
|
86
|
+
"0",
|
|
87
|
+
"-pix_fmt",
|
|
88
|
+
"yuva420p",
|
|
89
|
+
"-row-mt",
|
|
90
|
+
"1",
|
|
91
|
+
],
|
|
47
92
|
},
|
|
48
93
|
prores: {
|
|
49
94
|
name: "prores",
|
|
@@ -52,7 +97,19 @@ export const FORMATS: Record<string, VideoFormat> = {
|
|
|
52
97
|
chunked: true,
|
|
53
98
|
audio: true,
|
|
54
99
|
description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
|
|
55
|
-
|
|
100
|
+
// Quality is the profile here, and the profile is the point: an editor
|
|
101
|
+
// format that quietly compressed would defeat its own reason to exist.
|
|
102
|
+
args: ({scale}) => [
|
|
103
|
+
...scaleArgs(scale),
|
|
104
|
+
"-c:v",
|
|
105
|
+
"prores_ks",
|
|
106
|
+
"-profile:v",
|
|
107
|
+
"4444",
|
|
108
|
+
"-pix_fmt",
|
|
109
|
+
"yuva444p10le",
|
|
110
|
+
"-alpha_bits",
|
|
111
|
+
"8",
|
|
112
|
+
],
|
|
56
113
|
},
|
|
57
114
|
gif: {
|
|
58
115
|
name: "gif",
|
|
@@ -63,9 +120,11 @@ export const FORMATS: Record<string, VideoFormat> = {
|
|
|
63
120
|
chunked: false,
|
|
64
121
|
audio: false,
|
|
65
122
|
description: "An animated GIF, palette optimised. Silent, by the format.",
|
|
66
|
-
|
|
123
|
+
// The palette pass is a filter graph already, so scaling joins it rather
|
|
124
|
+
// than adding a second -vf that ffmpeg would silently drop.
|
|
125
|
+
args: ({scale}) => [
|
|
67
126
|
"-vf",
|
|
68
|
-
"split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3
|
|
127
|
+
`${scale === 1 ? "" : `${scaleFilter(scale)},`}split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3`,
|
|
69
128
|
"-loop",
|
|
70
129
|
"0",
|
|
71
130
|
],
|
|
@@ -77,7 +136,7 @@ export const FORMATS: Record<string, VideoFormat> = {
|
|
|
77
136
|
chunked: false,
|
|
78
137
|
audio: false,
|
|
79
138
|
description: "A numbered PNG sequence, with alpha. For a compositor.",
|
|
80
|
-
args: () => ["-c:v", "png", "-pix_fmt", "rgba"],
|
|
139
|
+
args: ({scale}) => [...scaleArgs(scale), "-c:v", "png", "-pix_fmt", "rgba"],
|
|
81
140
|
},
|
|
82
141
|
};
|
|
83
142
|
|
package/src/jobs.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {join, resolve} from "node:path";
|
|
|
4
4
|
import type {ExportJob, RenderManifest} from "odori";
|
|
5
5
|
import type {ResolvedConfig} from "./config";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/** How a job should be encoded, frozen with it so a retry cannot drift. */
|
|
8
|
+
export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string};
|
|
9
|
+
|
|
10
|
+
export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
|
|
8
11
|
|
|
9
12
|
const buildsDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "builds");
|
|
10
13
|
const jobFile = (config: ResolvedConfig, id: string) => join(buildsDir(config), `${id}.json`);
|
|
@@ -17,6 +20,7 @@ export const createJob = async (
|
|
|
17
20
|
config: ResolvedConfig,
|
|
18
21
|
manifest: RenderManifest,
|
|
19
22
|
output: string,
|
|
23
|
+
render?: JobRender,
|
|
20
24
|
): Promise<JobRecord> => {
|
|
21
25
|
await mkdir(buildsDir(config), {recursive: true});
|
|
22
26
|
const now = new Date().toISOString();
|
|
@@ -31,7 +35,7 @@ export const createJob = async (
|
|
|
31
35
|
createdAt: now,
|
|
32
36
|
updatedAt: now,
|
|
33
37
|
};
|
|
34
|
-
const record: JobRecord = {job, manifest, output};
|
|
38
|
+
const record: JobRecord = {job, manifest, output, ...(render ? {render} : {})};
|
|
35
39
|
await writeFile(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
36
40
|
return record;
|
|
37
41
|
};
|
|
@@ -400,7 +400,7 @@
|
|
|
400
400
|
},
|
|
401
401
|
{
|
|
402
402
|
"name": "brand-provider",
|
|
403
|
-
"description": "
|
|
403
|
+
"description": "The resolved brand rendered as a sheet: color, type, and motion.",
|
|
404
404
|
"registryDependencies": [],
|
|
405
405
|
"files": [
|
|
406
406
|
{
|
|
@@ -410,7 +410,7 @@
|
|
|
410
410
|
},
|
|
411
411
|
{
|
|
412
412
|
"path": "components/brand-provider/brand-provider.preview.tsx",
|
|
413
|
-
"content": "import {defineComponentPreview} from \"odori/preview\";\nimport {BrandProvider} from \"./brand-provider\";\n\nexport default defineComponentPreview({\n title: \"Brand provider\",\n category: \"Foundation\",\n description: \"
|
|
413
|
+
"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",
|
|
414
414
|
"target": "videos/components/brand-provider/brand-provider.preview.tsx"
|
|
415
415
|
}
|
|
416
416
|
],
|
package/src/registry-source.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {createHash} from "node:crypto";
|
|
2
2
|
import {existsSync} from "node:fs";
|
|
3
3
|
import {mkdir, readFile, writeFile} from "node:fs/promises";
|
|
4
|
-
import {dirname, resolve} from "node:path";
|
|
4
|
+
import {dirname, isAbsolute, relative, resolve, sep} from "node:path";
|
|
5
5
|
import {cacheRoot} from "./binaries";
|
|
6
6
|
import type {ResolvedConfig} from "./config";
|
|
7
7
|
|
|
@@ -65,6 +65,42 @@ export type RegistryComponent = {
|
|
|
65
65
|
/** `@odori/title-reveal` and `title-reveal` name the same component. */
|
|
66
66
|
export const normalizeComponentName = (name: string): string => name.replace(/^@odori\//, "");
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A registry name becomes a URL path segment, a cache filename, and a directory
|
|
70
|
+
* on disk. The index that supplies it is fetched, so it is not ours to trust:
|
|
71
|
+
* a plain slug is the only thing any real entry ever is, and anything else is a
|
|
72
|
+
* malformed or hostile registry trying to reach out of its lane.
|
|
73
|
+
*/
|
|
74
|
+
export const assertSafeName = (name: string): string => {
|
|
75
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Refusing component name ${JSON.stringify(name)}: a name must be a lowercase slug ` +
|
|
78
|
+
"(letters, digits, and single dashes). This registry is malformed or tampered with.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return name;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Confine a fetched write to the project.
|
|
86
|
+
*
|
|
87
|
+
* A registry item dictates its own `target`, and integrity proves the bytes,
|
|
88
|
+
* not the intent — a compromised or malicious origin can publish a matching
|
|
89
|
+
* hash for a document whose target is `../../etc/...` or an absolute path. So
|
|
90
|
+
* every destination is resolved and checked against the root before a single
|
|
91
|
+
* byte is written, and a target that climbs out is refused rather than trusted.
|
|
92
|
+
*/
|
|
93
|
+
export const resolveWithinRoot = (root: string, target: string): string => {
|
|
94
|
+
const destination = resolve(root, target);
|
|
95
|
+
const rel = relative(root, destination);
|
|
96
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return destination;
|
|
102
|
+
};
|
|
103
|
+
|
|
68
104
|
export type RegistryOrigin = "network" | "cache" | "bundled";
|
|
69
105
|
|
|
70
106
|
export type RegistrySource = {
|
|
@@ -208,6 +244,7 @@ export const resolveItem = async (
|
|
|
208
244
|
name: string,
|
|
209
245
|
options: {allowNetwork?: boolean} = {},
|
|
210
246
|
): Promise<{item: RegistryItemDocument; origin: RegistryOrigin}> => {
|
|
247
|
+
assertSafeName(name);
|
|
211
248
|
const url = registryUrl(config);
|
|
212
249
|
const cache = resolve(cacheDir(url), `${name}.json`);
|
|
213
250
|
|
|
@@ -245,9 +282,19 @@ export const resolveItem = async (
|
|
|
245
282
|
* catches truncation, a stale proxy, and a corrupted cache, which are the
|
|
246
283
|
* failures that actually happen.
|
|
247
284
|
*/
|
|
248
|
-
export const verifyIntegrity = (item: RegistryItemDocument): void => {
|
|
285
|
+
export const verifyIntegrity = (item: RegistryItemDocument, origin: RegistryOrigin = "network"): void => {
|
|
249
286
|
const expected = item.meta?.integrity;
|
|
250
|
-
if (!expected)
|
|
287
|
+
if (!expected) {
|
|
288
|
+
// The snapshot ships inside the npm tarball and is covered by its checksum,
|
|
289
|
+
// so it carries no hash of its own. Anything fetched must: a document that
|
|
290
|
+
// arrives over the wire with no integrity is one where the field was
|
|
291
|
+
// dropped, and trusting it would defeat the check entirely.
|
|
292
|
+
if (origin === "bundled") return;
|
|
293
|
+
throw new Error(
|
|
294
|
+
`The registry document for "${item.name}" carries no integrity hash. ` +
|
|
295
|
+
"A fetched item must be verifiable; refusing to write it. Nothing was written.",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
251
298
|
|
|
252
299
|
const hash = createHash("sha256");
|
|
253
300
|
for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
package/src/render.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {chunkFrames, planChunks, type FrameChunk} from "./chunks";
|
|
|
10
10
|
import {chunkKey, readChunkRecord, signaturesMatch, useChunkRecord, writeChunkRecord} from "./chunk-cache";
|
|
11
11
|
import {type ResolvedConfig} from "./config";
|
|
12
12
|
import {installBrowser, installFfmpeg, resolveBrowser, resolveFfmpeg} from "./binaries";
|
|
13
|
-
import {FORMATS, type VideoFormat} from "./formats";
|
|
13
|
+
import {FORMATS, type EncodeOptions, type Quality, type VideoFormat} from "./formats";
|
|
14
14
|
import {log} from "./log";
|
|
15
15
|
|
|
16
16
|
export type RenderTarget = {
|
|
@@ -270,17 +270,34 @@ const sequencePattern = (output: string): string => {
|
|
|
270
270
|
return join(stem, `%05d${extension}`);
|
|
271
271
|
};
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The intermediate for formats that must see every frame at once. FFV1 is
|
|
275
|
+
* genuinely lossless, so the palette or the sequence is computed from the
|
|
276
|
+
* pixels the browser drew — not from an H.264 generation of them, which is
|
|
277
|
+
* what the requested format's own args would have produced here. Scale and
|
|
278
|
+
* quality deliberately do not apply: they belong to the final pass, once.
|
|
279
|
+
*/
|
|
280
|
+
const LOSSLESS: VideoFormat = {
|
|
281
|
+
name: "lossless",
|
|
282
|
+
extension: ".mkv",
|
|
283
|
+
alpha: true,
|
|
284
|
+
chunked: true,
|
|
285
|
+
audio: false,
|
|
286
|
+
description: "FFV1 in MKV, the lossless intermediate for one-pass formats.",
|
|
287
|
+
args: () => ["-c:v", "ffv1", "-level", "3"],
|
|
288
|
+
};
|
|
289
|
+
|
|
273
290
|
const openChunkEncoder = (
|
|
274
291
|
ffmpeg: string,
|
|
275
292
|
file: string,
|
|
276
293
|
fps: number,
|
|
277
|
-
|
|
294
|
+
encode: EncodeOptions,
|
|
278
295
|
format: VideoFormat,
|
|
279
296
|
signal?: AbortSignal,
|
|
280
297
|
) => {
|
|
281
298
|
const child = spawn(
|
|
282
299
|
ffmpeg,
|
|
283
|
-
["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(
|
|
300
|
+
["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(encode), file],
|
|
284
301
|
{stdio: ["pipe", "ignore", "pipe"]},
|
|
285
302
|
);
|
|
286
303
|
|
|
@@ -307,7 +324,7 @@ const openChunkEncoder = (
|
|
|
307
324
|
type LaneOptions = {
|
|
308
325
|
skipUnchanged: boolean;
|
|
309
326
|
signal?: AbortSignal;
|
|
310
|
-
|
|
327
|
+
encode: EncodeOptions;
|
|
311
328
|
format: VideoFormat;
|
|
312
329
|
/** The resolved encoder, so a lane never guesses at what is on PATH. */
|
|
313
330
|
ffmpeg: string;
|
|
@@ -368,7 +385,7 @@ const captureLane = async (
|
|
|
368
385
|
}
|
|
369
386
|
|
|
370
387
|
const file = options.chunkFile(chunk);
|
|
371
|
-
const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.
|
|
388
|
+
const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.encode, options.format, options.signal);
|
|
372
389
|
const signatures: string[] = [];
|
|
373
390
|
let previousSignature: string | null = null;
|
|
374
391
|
let previousFrame: Buffer | null = null;
|
|
@@ -445,6 +462,10 @@ export type RenderTimings = {
|
|
|
445
462
|
export type RenderOptions = {
|
|
446
463
|
concurrency?: number;
|
|
447
464
|
preset?: string;
|
|
465
|
+
/** Compression tier. Defaults to studio, which is what the pipeline always was. */
|
|
466
|
+
quality?: Quality;
|
|
467
|
+
/** Output scale multiplier. Defaults to 1, the composition's own size. */
|
|
468
|
+
scale?: number;
|
|
448
469
|
/** Container and codec. Defaults to H.264 in MP4. */
|
|
449
470
|
format?: VideoFormat;
|
|
450
471
|
skipUnchangedFrames?: boolean;
|
|
@@ -474,12 +495,19 @@ export const renderMovie = async (
|
|
|
474
495
|
const renderer = (await resolveBrowser(config))?.version ?? browserPath;
|
|
475
496
|
|
|
476
497
|
const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
|
|
477
|
-
const
|
|
498
|
+
const encode: EncodeOptions = {
|
|
499
|
+
preset: options.preset ?? config.preset ?? "medium",
|
|
500
|
+
quality: options.quality ?? "studio",
|
|
501
|
+
scale: options.scale ?? 1,
|
|
502
|
+
};
|
|
478
503
|
const format = options.format ?? FORMATS.mp4;
|
|
479
504
|
// A palette or a still sequence is computed across the whole animation, so
|
|
480
505
|
// it cannot be assembled from independently encoded chunks. One lane, one
|
|
481
506
|
// pass: slower, and the only way the output is correct.
|
|
482
507
|
const chunkable = format.chunked;
|
|
508
|
+
// What a lane encodes: the requested codec when chunks can be joined by
|
|
509
|
+
// stream copy, the lossless intermediate when the format needs one pass.
|
|
510
|
+
const chunkFormat = chunkable ? format : LOSSLESS;
|
|
483
511
|
const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
|
|
484
512
|
const cache = options.cache ?? config.cacheChunks ?? true;
|
|
485
513
|
|
|
@@ -511,8 +539,8 @@ export const renderMovie = async (
|
|
|
511
539
|
captureLane(origin, target, config, lane, stats, {
|
|
512
540
|
skipUnchanged,
|
|
513
541
|
signal: options.signal,
|
|
514
|
-
|
|
515
|
-
format,
|
|
542
|
+
encode,
|
|
543
|
+
format: chunkFormat,
|
|
516
544
|
ffmpeg,
|
|
517
545
|
workDir: work,
|
|
518
546
|
chunkFile,
|
|
@@ -521,11 +549,17 @@ export const renderMovie = async (
|
|
|
521
549
|
videoId: target.videoId,
|
|
522
550
|
chunk,
|
|
523
551
|
renderer,
|
|
524
|
-
format:
|
|
552
|
+
format: chunkFormat.name,
|
|
525
553
|
width: target.width,
|
|
526
554
|
height: target.height,
|
|
527
555
|
fps: target.fps,
|
|
528
|
-
preset,
|
|
556
|
+
preset: encode.preset,
|
|
557
|
+
// Both change the encoded bytes, so both are part of what a
|
|
558
|
+
// chunk is: a half-size chunk must never answer for a full
|
|
559
|
+
// one. A lossless intermediate is the exception by design —
|
|
560
|
+
// the final pass applies them, so one capture serves all.
|
|
561
|
+
quality: chunkable ? encode.quality : undefined,
|
|
562
|
+
scale: chunkable ? encode.scale : undefined,
|
|
529
563
|
input: target.input,
|
|
530
564
|
})
|
|
531
565
|
: undefined,
|
|
@@ -557,15 +591,16 @@ export const renderMovie = async (
|
|
|
557
591
|
await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
|
|
558
592
|
}
|
|
559
593
|
|
|
560
|
-
// A format that cannot be chunked was captured
|
|
561
|
-
// here in one pass, where a palette or a
|
|
594
|
+
// A format that cannot be chunked was captured to the lossless
|
|
595
|
+
// intermediate and is encoded here in one pass, where a palette or a
|
|
596
|
+
// sequence can see every frame of the real pixels.
|
|
562
597
|
if (!chunkable) {
|
|
563
598
|
// A sequence is many files. Given one path it writes beside it, using
|
|
564
599
|
// the name as the directory, rather than overwriting a single frame 270
|
|
565
600
|
// times and reporting success.
|
|
566
601
|
const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
|
|
567
602
|
if (destination !== output) await mkdir(dirname(destination), {recursive: true});
|
|
568
|
-
await run(ffmpeg, ["-y", "-i", silent, ...format.args(
|
|
603
|
+
await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
|
|
569
604
|
if (mixInputs.length > 0) {
|
|
570
605
|
log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
|
|
571
606
|
}
|
package/src/server.ts
CHANGED
|
@@ -124,11 +124,6 @@ export const startStudioServer = async (
|
|
|
124
124
|
const odoriSrc = runtimeSource(config.root);
|
|
125
125
|
let graph = await discoverProject(config);
|
|
126
126
|
await writeGenerated(config, graph);
|
|
127
|
-
// The cue middleware below answers out of this registry, so it has to be
|
|
128
|
-
// filled before the first preview plays and refilled whenever discovery
|
|
129
|
-
// runs again: a brand added or edited while the server is up declares
|
|
130
|
-
// sounds the server has never seen.
|
|
131
|
-
await registerProjectCues(graph);
|
|
132
127
|
|
|
133
128
|
const vite = await createServer({
|
|
134
129
|
root: studioRoot,
|
|
@@ -211,6 +206,42 @@ export const startStudioServer = async (
|
|
|
211
206
|
optimizeDeps: {include: ["react", "react-dom", "react/jsx-dev-runtime"]},
|
|
212
207
|
});
|
|
213
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Source modules load through Vite, not Node. Node caches a module for the
|
|
211
|
+
* life of the process and busting the entry does not reach its imports, so
|
|
212
|
+
* a brand whose score lives one file away kept serving the old sound. Vite
|
|
213
|
+
* owns a module graph that invalidates the whole chain on every change —
|
|
214
|
+
* the same reason the browser preview is always current.
|
|
215
|
+
*/
|
|
216
|
+
const loadModule = (file: string) => vite.ssrLoadModule(`/@fs${file}`) as Promise<Record<string, unknown>>;
|
|
217
|
+
|
|
218
|
+
// The cue middleware answers out of the registry filled here, so it has to
|
|
219
|
+
// be full before the first preview plays and refilled whenever a source
|
|
220
|
+
// change can redefine a sound.
|
|
221
|
+
await registerProjectCues(graph, loadModule);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Cue refresh is debounced and serialized. `odori add` writes several files
|
|
225
|
+
* in one gesture and an editor save can fire twice; each burst settles into
|
|
226
|
+
* one refresh, and refreshes never interleave. Old registrations stay: the
|
|
227
|
+
* URLs are content addressed, so an entry can never mean a different sound.
|
|
228
|
+
*/
|
|
229
|
+
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
230
|
+
let refreshChain: Promise<unknown> = Promise.resolve();
|
|
231
|
+
const enqueueRefresh = <Value,>(task: () => Promise<Value>): Promise<Value> => {
|
|
232
|
+
const next = refreshChain.then(task, task);
|
|
233
|
+
refreshChain = next.catch(() => undefined);
|
|
234
|
+
return next;
|
|
235
|
+
};
|
|
236
|
+
const scheduleCueRefresh = () => {
|
|
237
|
+
clearTimeout(refreshTimer);
|
|
238
|
+
refreshTimer = setTimeout(() => {
|
|
239
|
+
void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
|
|
240
|
+
vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
241
|
+
});
|
|
242
|
+
}, 150);
|
|
243
|
+
};
|
|
244
|
+
|
|
214
245
|
// Rediscover when video or preview entries appear or disappear.
|
|
215
246
|
const rediscover = async (file: string) => {
|
|
216
247
|
if (!file.startsWith(resolve(config.root, config.videosDir))) return;
|
|
@@ -219,7 +250,7 @@ export const startStudioServer = async (
|
|
|
219
250
|
try {
|
|
220
251
|
graph = await discoverProject(config);
|
|
221
252
|
await writeGenerated(config, graph);
|
|
222
|
-
await registerProjectCues(graph);
|
|
253
|
+
await enqueueRefresh(() => registerProjectCues(graph, loadModule));
|
|
223
254
|
} catch (error) {
|
|
224
255
|
// A watcher event can arrive after the source root is gone, for example
|
|
225
256
|
// during a branch switch. Keep the last good graph and stay alive.
|
|
@@ -243,7 +274,7 @@ export const startStudioServer = async (
|
|
|
243
274
|
config = await loadConfig(config.root);
|
|
244
275
|
graph = await discoverProject(config);
|
|
245
276
|
await writeGenerated(config, graph);
|
|
246
|
-
await registerProjectCues(graph);
|
|
277
|
+
await enqueueRefresh(() => registerProjectCues(graph, loadModule));
|
|
247
278
|
} catch (error) {
|
|
248
279
|
vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
280
|
return;
|
|
@@ -254,12 +285,17 @@ export const startStudioServer = async (
|
|
|
254
285
|
};
|
|
255
286
|
// Rediscovery is driven by files appearing and disappearing, but a cue is
|
|
256
287
|
// edited in place: the score changes, its content hash changes with it, and
|
|
257
|
-
// the preview then asks for a URL the registry has never heard of.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
288
|
+
// the preview then asks for a URL the registry has never heard of. The
|
|
289
|
+
// guard is deliberately broad — the scaffold's brand lives in
|
|
290
|
+
// videos/layout.tsx and a score can live in a file the brand imports, so
|
|
291
|
+
// any source change under the videos root schedules a refresh and the
|
|
292
|
+
// debounce keeps the cost of that breadth to one pass per burst.
|
|
293
|
+
const refreshCues = (file: string) => {
|
|
294
|
+
if (!file.startsWith(resolve(config.root, config.videosDir) + sep)) return;
|
|
295
|
+
if (!/\.tsx?$/.test(file)) return;
|
|
296
|
+
scheduleCueRefresh();
|
|
261
297
|
};
|
|
262
|
-
vite.watcher.on("change", (file) =>
|
|
298
|
+
vite.watcher.on("change", (file) => refreshCues(file));
|
|
263
299
|
|
|
264
300
|
vite.watcher.on("change", (file) => void reloadConfig(file));
|
|
265
301
|
for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
|
package/studio/src/Studio.tsx
CHANGED
|
@@ -43,7 +43,10 @@ const pathFor = (view: StudioView, selection?: string | null): string =>
|
|
|
43
43
|
export const Studio = () => {
|
|
44
44
|
const [route, setRoute] = useState<Route>(readRoute);
|
|
45
45
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
46
|
-
|
|
46
|
+
// The keydown listener registers once; the ref keeps it reading the live
|
|
47
|
+
// route instead of the one from its first render.
|
|
48
|
+
const routeRef = useRef(route);
|
|
49
|
+
routeRef.current = route;
|
|
47
50
|
const search = useRef<HTMLInputElement>(null);
|
|
48
51
|
const {theme, setTheme} = useTheme();
|
|
49
52
|
|
|
@@ -80,15 +83,20 @@ export const Studio = () => {
|
|
|
80
83
|
search.current?.focus();
|
|
81
84
|
}
|
|
82
85
|
if (event.key.toLowerCase() === "g") {
|
|
83
|
-
|
|
86
|
+
// From a detail back to its gallery; already-gallery routes stay put.
|
|
87
|
+
const current = routeRef.current;
|
|
88
|
+
if (current.selection && current.view !== "home") navigate(current.view);
|
|
84
89
|
}
|
|
85
90
|
};
|
|
86
91
|
window.addEventListener("keydown", onKeyDown);
|
|
87
92
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
88
93
|
}, []);
|
|
89
94
|
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
// The list pages are galleries; a selection is that item's detail, which
|
|
96
|
+
// shows the stage and the inspector with no list beside them. What used to
|
|
97
|
+
// be a modal "gallery mode" is now just the route with no selection.
|
|
98
|
+
const detailCapable = route.view === "videos" || route.view === "components" || route.view === "brands";
|
|
99
|
+
const singleColumn = !detailCapable || route.selection === null;
|
|
92
100
|
|
|
93
101
|
return (
|
|
94
102
|
<div className="studio">
|
|
@@ -144,17 +152,14 @@ export const Studio = () => {
|
|
|
144
152
|
|
|
145
153
|
<main className="main" data-single={singleColumn ? "true" : undefined}>
|
|
146
154
|
{route.view === "videos" ? (
|
|
147
|
-
<VideosView selection={route.selection} onSelect={(id) => navigate("videos", id)}
|
|
155
|
+
<VideosView selection={route.selection} onSelect={(id) => navigate("videos", id)} />
|
|
148
156
|
) : null}
|
|
149
157
|
{route.view === "components" ? (
|
|
150
|
-
<ComponentsView
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
mode={mode}
|
|
155
|
-
/>
|
|
158
|
+
<ComponentsView selection={route.selection} onSelect={(id) => navigate("components", id)} />
|
|
159
|
+
) : null}
|
|
160
|
+
{route.view === "brands" ? (
|
|
161
|
+
<BrandsView selection={route.selection} onSelect={(name) => navigate("brands", name)} />
|
|
156
162
|
) : null}
|
|
157
|
-
{route.view === "brands" ? <BrandsView /> : null}
|
|
158
163
|
{route.view === "assets" ? <AssetsView /> : null}
|
|
159
164
|
{route.view === "home" ? <HomeView onOpen={(view, selection) => navigate(view, selection)} /> : null}
|
|
160
165
|
</main>
|
|
@@ -170,21 +175,13 @@ export const Studio = () => {
|
|
|
170
175
|
<span>arrows step</span>
|
|
171
176
|
<span>- = speed</span>
|
|
172
177
|
<span>[ ] scene</span>
|
|
173
|
-
<span>s safe area</span>
|
|
174
|
-
<span>g gallery</span>
|
|
175
178
|
</footer>
|
|
176
179
|
|
|
177
180
|
<CommandPalette
|
|
178
181
|
open={paletteOpen}
|
|
179
182
|
onClose={() => setPaletteOpen(false)}
|
|
180
|
-
onOpenVideo={(id) =>
|
|
181
|
-
|
|
182
|
-
navigate("videos", id);
|
|
183
|
-
}}
|
|
184
|
-
onOpenComponent={(id) => {
|
|
185
|
-
setMode("player");
|
|
186
|
-
navigate("components", id);
|
|
187
|
-
}}
|
|
183
|
+
onOpenVideo={(id) => navigate("videos", id)}
|
|
184
|
+
onOpenComponent={(id) => navigate("components", id)}
|
|
188
185
|
onView={(view) => navigate(view as StudioView)}
|
|
189
186
|
/>
|
|
190
187
|
</div>
|