@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.
Files changed (41) hide show
  1. package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +63 -8
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-MSH2EA36.js +4867 -0
  6. package/package.json +3 -3
  7. package/src/assets.ts +90 -0
  8. package/src/brand-file.ts +16 -4
  9. package/src/chunk-cache.ts +6 -0
  10. package/src/cli.ts +22 -12
  11. package/src/commands/add.ts +33 -8
  12. package/src/commands/dev.ts +114 -12
  13. package/src/commands/doctor.ts +47 -2
  14. package/src/commands/exportVideo.ts +39 -5
  15. package/src/commands/{still.ts → frame.ts} +23 -9
  16. package/src/commands/init.ts +1 -1
  17. package/src/commands/new.ts +1 -1
  18. package/src/cues.ts +34 -21
  19. package/src/discovery.ts +85 -5
  20. package/src/formats.ts +67 -8
  21. package/src/index.ts +1 -1
  22. package/src/jobs.ts +6 -2
  23. package/src/registry-snapshot.json +1530 -328
  24. package/src/registry-source.ts +87 -5
  25. package/src/render.ts +48 -13
  26. package/src/server.ts +55 -13
  27. package/studio/src/Studio.tsx +19 -22
  28. package/studio/src/components/ExportPanel.tsx +124 -90
  29. package/studio/src/components/Inspector.tsx +221 -0
  30. package/studio/src/components/Navigator.tsx +145 -0
  31. package/studio/src/components/Thumbnail.tsx +65 -23
  32. package/studio/src/components/ui.tsx +9 -2
  33. package/studio/src/lib/highlight.ts +85 -0
  34. package/studio/src/studio.css +435 -20
  35. package/studio/src/views/AssetsView.tsx +14 -1
  36. package/studio/src/views/BrandsView.tsx +74 -26
  37. package/studio/src/views/ComponentsView.tsx +206 -55
  38. package/studio/src/views/HomeView.tsx +44 -19
  39. package/studio/src/views/VideosView.tsx +53 -48
  40. package/studio/src/virtual.d.ts +4 -1
  41. 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
- preset: options.preset,
90
- format: options.format,
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
- format,
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
- export const stillCommand = async (
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: {frame?: number; output?: string; input?: Record<string, unknown>} = {},
20
+ options: {at?: Duration; output?: string; input?: Record<string, unknown>} = {},
10
21
  ) => {
11
- const frame = options.frame ?? 0;
12
- // A frame is an index into the timeline. Checking it first means a typo
13
- // costs nothing: no discovery, no dev server, no browser.
14
- if (!Number.isInteger(frame) || frame < 0) {
15
- throw new Error(`Frame must be a whole number of frames from the start, got ${String(frame)}.`);
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(`Still frame ${frame} written to ${output}`);
52
+ log.success(`Frame ${frame} written to ${output}`);
39
53
  return output;
40
54
  };
@@ -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 @odori/title-reveal @odori/end-card, then odori dev.");
55
+ log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
56
56
  };
@@ -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 @odori/title-reveal @odori/end-card");
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, so a dev server that
100
- * re-reads a brand after an edit would otherwise keep the scores it saw at
101
- * boot. The file's mtime in the specifier makes an edited file a new module
102
- * and an untouched one a cache hit.
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: string): Promise<Record<string, unknown>> =>
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, and
113
- * they are registered first at the default frame rate. The video entries are
114
- * then loaded for their layouts, because the frame rate decides how many
115
- * samples a cue occupies and only an entry knows its own. A project that
116
- * cannot be loaded a half-written entry mid-edit leaves the brand pass in
117
- * place rather than taking the server down.
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 module = await importFresh(discovered.file);
123
- registerCues(Object.values(module).filter(isBrand), defaultLayout.format.fps);
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
- try {
130
- for (const video of await loadVideos(graph)) {
131
- const layout = resolveEntryLayout(video.entry);
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
- const contents = await readFile(file, "utf8");
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 (file.split(sep).includes("brands") && /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx")) {
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
- identifier: toIdentifier(`${name}-module`, "brands"),
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
- return {videos, previews, brands, audio, sourceHash: hashString(hashParts.join("|"))};
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) => ({name: preview.name, file: preview.relativeFile})),
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, given the requested preset. */
24
- args: (preset: string) => string[];
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) => ["-c:v", "libx264", "-crf", "17", "-preset", preset, "-pix_fmt", "yuv420p"],
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: () => ["-c:v", "libvpx-vp9", "-crf", "24", "-b:v", "0", "-pix_fmt", "yuva420p", "-row-mt", "1"],
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
- args: () => ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-alpha_bits", "8"],
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
- args: () => [
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 {stillCommand} from "./commands/still";
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
- export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string};
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
  };