@odori/cli 0.0.2 → 0.0.4
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-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
|
@@ -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") {
|
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import {resolve} from "node:path";
|
|
2
|
+
import {framesFromOffset, resolveEntryLayout, type Duration} from "odori";
|
|
2
3
|
import {log} from "../log";
|
|
3
4
|
import {findVideo, freezeManifest, outputName} from "../project";
|
|
4
5
|
import {renderStill} from "../render";
|
|
5
6
|
import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* One frame of a video, as a PNG.
|
|
10
|
+
*
|
|
11
|
+
* `at` is a duration like everything else in Odori: a bare number is seconds,
|
|
12
|
+
* and `120f` is frame 120. Which frame that resolves to depends on the
|
|
13
|
+
* video's frame rate, and the frame rate belongs to the video's layout, so
|
|
14
|
+
* the position is checked for shape first and resolved once the video is
|
|
15
|
+
* loaded. A typo still costs nothing: no discovery, no dev server, no
|
|
16
|
+
* browser.
|
|
17
|
+
*/
|
|
18
|
+
export const frameCommand = async (
|
|
8
19
|
id: string,
|
|
9
|
-
options: {
|
|
20
|
+
options: {at?: Duration; output?: string; input?: Record<string, unknown>} = {},
|
|
10
21
|
) => {
|
|
11
|
-
const
|
|
12
|
-
// A
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const at = options.at ?? 0;
|
|
23
|
+
// A position, not a length: frame zero is the first frame, so this is
|
|
24
|
+
// framesFromOffset rather than framesFromDuration, which floors at one. Any
|
|
25
|
+
// frame rate rejects a malformed duration, so this validates the shape
|
|
26
|
+
// before the work starts; the real rate resolves the number below.
|
|
27
|
+
framesFromOffset(at, 30);
|
|
17
28
|
|
|
18
29
|
const {config, graph, videos} = await createContext();
|
|
19
30
|
const video = findVideo(videos, id);
|
|
20
31
|
|
|
32
|
+
const fps = resolveEntryLayout(video.entry).format.fps;
|
|
33
|
+
const frame = framesFromOffset(at, fps);
|
|
34
|
+
|
|
21
35
|
const output = await withServer(config, async (server) => {
|
|
22
36
|
const {durationInFrames, scenes, audio} = await compileInBrowser(server.url, targetFor(video, options.input), config);
|
|
23
37
|
const {manifest, input, prepared} = await freezeManifest(
|
|
@@ -35,6 +49,6 @@ export const stillCommand = async (
|
|
|
35
49
|
return renderStill(server.url, target, frame, file, config);
|
|
36
50
|
});
|
|
37
51
|
|
|
38
|
-
log.success(`
|
|
52
|
+
log.success(`Frame ${frame} written to ${output}`);
|
|
39
53
|
return output;
|
|
40
54
|
};
|
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;
|
package/src/discovery.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {existsSync} from "node:fs";
|
|
|
3
3
|
import {join, relative, resolve, sep} from "node:path";
|
|
4
4
|
import {hashString} from "odori";
|
|
5
5
|
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {log} from "./log";
|
|
6
7
|
|
|
7
8
|
export type DiscoveredVideo = {
|
|
8
9
|
/** Directory-derived id used before the module is loaded. */
|
|
@@ -19,6 +20,8 @@ export type DiscoveredPreview = {
|
|
|
19
20
|
relativeFile: string;
|
|
20
21
|
importPath: string;
|
|
21
22
|
identifier: string;
|
|
23
|
+
/** Video ids whose composition imports this component. Derived, not declared. */
|
|
24
|
+
usedBy?: string[];
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
export type DiscoveredBrandModule = {
|
|
@@ -37,11 +40,26 @@ export type DiscoveredAudio = {
|
|
|
37
40
|
bytes: number;
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A directory naming itself, from a `category.json` beside the components it
|
|
45
|
+
* holds. The path is what the filesystem already says; this is only how that
|
|
46
|
+
* level should read and where it should sit among its siblings.
|
|
47
|
+
*/
|
|
48
|
+
export type DiscoveredCategory = {
|
|
49
|
+
/** Directory path under the components directory: `product-ui/forms`. */
|
|
50
|
+
path: string;
|
|
51
|
+
/** What to call it. Without one, the directory name is read as words. */
|
|
52
|
+
name?: string;
|
|
53
|
+
/** Sort position among siblings. Unset sorts after, alphabetically. */
|
|
54
|
+
order?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
40
57
|
export type ProjectGraph = {
|
|
41
58
|
videos: DiscoveredVideo[];
|
|
42
59
|
previews: DiscoveredPreview[];
|
|
43
60
|
brands: DiscoveredBrandModule[];
|
|
44
61
|
audio: DiscoveredAudio[];
|
|
62
|
+
categories: DiscoveredCategory[];
|
|
45
63
|
sourceHash: string;
|
|
46
64
|
};
|
|
47
65
|
|
|
@@ -107,16 +125,52 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
107
125
|
const videos: DiscoveredVideo[] = [];
|
|
108
126
|
const previews: DiscoveredPreview[] = [];
|
|
109
127
|
const brands: DiscoveredBrandModule[] = [];
|
|
128
|
+
const categories: DiscoveredCategory[] = [];
|
|
110
129
|
const hashParts: string[] = [];
|
|
130
|
+
const importedBy: Record<string, Set<string>> = {};
|
|
131
|
+
const componentsRoot = resolve(config.root, config.componentsDir);
|
|
111
132
|
|
|
112
133
|
for (const file of files) {
|
|
113
134
|
const relativeFile = relative(config.root, file);
|
|
135
|
+
let contents = "";
|
|
114
136
|
if (/\.(tsx|ts|css|json)$/.test(file)) {
|
|
115
|
-
|
|
137
|
+
contents = await readFile(file, "utf8");
|
|
116
138
|
hashParts.push(`${relativeFile}:${hashString(contents)}`);
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
const base = file.split(sep).pop() ?? "";
|
|
142
|
+
if (base === "category.json") {
|
|
143
|
+
// Beside the components it holds, so a label lives with the thing it
|
|
144
|
+
// labels. A malformed one is reported and skipped: a typo in a display
|
|
145
|
+
// name must not take the project down.
|
|
146
|
+
const path = relative(componentsRoot, resolve(file, "..")).split(sep).join("/");
|
|
147
|
+
if (!path.startsWith("..")) {
|
|
148
|
+
try {
|
|
149
|
+
const declared = JSON.parse(contents) as {name?: unknown; order?: unknown};
|
|
150
|
+
categories.push({
|
|
151
|
+
path,
|
|
152
|
+
...(typeof declared.name === "string" ? {name: declared.name} : {}),
|
|
153
|
+
...(typeof declared.order === "number" ? {order: declared.order} : {}),
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
log.warn(
|
|
157
|
+
`${relativeFile} is not valid JSON, so that directory names itself: ${
|
|
158
|
+
error instanceof Error ? error.message : String(error)
|
|
159
|
+
}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (base === "video.tsx") {
|
|
166
|
+
// The file is already read for the source hash, so the import graph
|
|
167
|
+
// costs nothing to take. A component's audience is a fact about the
|
|
168
|
+
// project rather than something an author should have to restate, and
|
|
169
|
+
// a restated one goes stale the moment a scene is deleted.
|
|
170
|
+
for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
|
|
171
|
+
(importedBy[match[1]] ??= new Set()).add(relative(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
120
174
|
if (base === "video.tsx") {
|
|
121
175
|
// The path under videos/ is the id, the way a route is a path.
|
|
122
176
|
const slug = relative(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
|
|
@@ -127,13 +181,30 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
127
181
|
importPath: file,
|
|
128
182
|
identifier: toIdentifier(slug, "video"),
|
|
129
183
|
});
|
|
130
|
-
} else if (
|
|
184
|
+
} else if (
|
|
185
|
+
// A brand module is recognized by what it does, not where it sits. The
|
|
186
|
+
// scaffold defines its brand in videos/layout.tsx, so a directory-name
|
|
187
|
+
// rule alone left the default project's brand invisible to everything
|
|
188
|
+
// that reads this list — most visibly the dev server's cue registry,
|
|
189
|
+
// which then answered new cue URLs with 404s until a restart. Installed
|
|
190
|
+
// component source is excluded the way brand-file.ts excludes it: a
|
|
191
|
+
// component may mention defineBrand without being where a brand lives.
|
|
192
|
+
/\.tsx?$/.test(base) &&
|
|
193
|
+
!base.endsWith(".preview.tsx") &&
|
|
194
|
+
!file.startsWith(componentsRoot + sep) &&
|
|
195
|
+
(file.split(sep).includes("brands") || contents.includes("defineBrand("))
|
|
196
|
+
) {
|
|
131
197
|
const name = base.replace(/\.tsx?$/, "");
|
|
132
198
|
brands.push({
|
|
133
199
|
name,
|
|
134
200
|
file,
|
|
135
201
|
relativeFile,
|
|
136
|
-
|
|
202
|
+
// From the whole relative path, like previews: basenames repeat
|
|
203
|
+
// (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
|
|
204
|
+
identifier: toIdentifier(
|
|
205
|
+
`${relative(videosRoot, file).replace(/\.tsx?$/, "").split(sep).join("-")}-module`,
|
|
206
|
+
"brands",
|
|
207
|
+
),
|
|
137
208
|
});
|
|
138
209
|
} else if (base.endsWith(".preview.tsx")) {
|
|
139
210
|
const name = base.replace(/\.preview\.tsx$/, "");
|
|
@@ -147,7 +218,12 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
|
|
|
147
218
|
}
|
|
148
219
|
}
|
|
149
220
|
|
|
150
|
-
|
|
221
|
+
for (const preview of previews) {
|
|
222
|
+
const users = importedBy[preview.name];
|
|
223
|
+
if (users) preview.usedBy = [...users].sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {videos, previews, brands, audio, categories, sourceHash: hashString(hashParts.join("|"))};
|
|
151
227
|
};
|
|
152
228
|
|
|
153
229
|
/**
|
|
@@ -203,7 +279,11 @@ export const writeGenerated = async (config: ResolvedConfig, graph: ProjectGraph
|
|
|
203
279
|
{
|
|
204
280
|
sourceHash: graph.sourceHash,
|
|
205
281
|
videos: graph.videos.map((video) => ({slug: video.slug, file: video.relativeFile})),
|
|
206
|
-
previews: graph.previews.map((preview) => ({
|
|
282
|
+
previews: graph.previews.map((preview) => ({
|
|
283
|
+
name: preview.name,
|
|
284
|
+
file: preview.relativeFile,
|
|
285
|
+
usedBy: preview.usedBy ?? [],
|
|
286
|
+
})),
|
|
207
287
|
brands: graph.brands.map((brand) => ({name: brand.name, file: brand.relativeFile})),
|
|
208
288
|
audio: graph.audio.map((entry) => ({name: entry.name, url: entry.url, file: entry.relativeFile})),
|
|
209
289
|
},
|
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/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ export {clearPrepareCache, prepareCacheKey, readPrepareCache, writePrepareCache}
|
|
|
46
46
|
export {devCommand} from "./commands/dev";
|
|
47
47
|
export {cancelJob, exportCommand, jobsCommand, runJob, exportQueue} from "./commands/exportVideo";
|
|
48
48
|
export {chunkFrames, planChunks, type ChunkPlan, type FrameChunk} from "./chunks";
|
|
49
|
-
export {
|
|
49
|
+
export {frameCommand} from "./commands/frame";
|
|
50
50
|
export {testCommand} from "./commands/test";
|
|
51
51
|
export {listCommand} from "./commands/list";
|
|
52
52
|
export {inspectCommand} from "./commands/inspect";
|
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
|
};
|