@odori/cli 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +22 -0
  2. package/bin/odori.mjs +39 -0
  3. package/dist/chunk-7XJL2BYO.js +3552 -0
  4. package/dist/cli.d.ts +10 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/index.d.ts +622 -0
  7. package/dist/index.js +156 -0
  8. package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
  9. package/package.json +50 -0
  10. package/src/audio-mix.ts +133 -0
  11. package/src/binaries.ts +241 -0
  12. package/src/brand-file.ts +94 -0
  13. package/src/chunk-cache.ts +85 -0
  14. package/src/chunks.ts +78 -0
  15. package/src/cli.ts +319 -0
  16. package/src/commands/add.ts +151 -0
  17. package/src/commands/dev.ts +160 -0
  18. package/src/commands/doctor.ts +162 -0
  19. package/src/commands/exportVideo.ts +198 -0
  20. package/src/commands/init.ts +56 -0
  21. package/src/commands/inspect.ts +72 -0
  22. package/src/commands/list.ts +22 -0
  23. package/src/commands/new.ts +126 -0
  24. package/src/commands/shared.ts +96 -0
  25. package/src/commands/still.ts +40 -0
  26. package/src/commands/test.ts +265 -0
  27. package/src/commands/update.ts +183 -0
  28. package/src/config.ts +84 -0
  29. package/src/contracts.ts +159 -0
  30. package/src/cues.ts +141 -0
  31. package/src/determinism.ts +82 -0
  32. package/src/diff.ts +71 -0
  33. package/src/discovery.ts +216 -0
  34. package/src/formats.ts +119 -0
  35. package/src/index.ts +58 -0
  36. package/src/integrity.ts +101 -0
  37. package/src/jobs.ts +151 -0
  38. package/src/log.ts +17 -0
  39. package/src/open.ts +32 -0
  40. package/src/paths.ts +12 -0
  41. package/src/prepare-cache.ts +58 -0
  42. package/src/project.ts +196 -0
  43. package/src/registry-snapshot.json +3431 -0
  44. package/src/registry-source.ts +269 -0
  45. package/src/render.ts +627 -0
  46. package/src/server.ts +307 -0
  47. package/studio/index.html +41 -0
  48. package/studio/src/Studio.tsx +192 -0
  49. package/studio/src/components/AudioClip.tsx +64 -0
  50. package/studio/src/components/CanvasStage.tsx +79 -0
  51. package/studio/src/components/CommandPalette.tsx +129 -0
  52. package/studio/src/components/Diagnostics.tsx +93 -0
  53. package/studio/src/components/ExportPanel.tsx +234 -0
  54. package/studio/src/components/InputControls.tsx +110 -0
  55. package/studio/src/components/Thumbnail.tsx +71 -0
  56. package/studio/src/components/Transport.tsx +237 -0
  57. package/studio/src/components/Waveform.tsx +114 -0
  58. package/studio/src/components/Wordmark.tsx +449 -0
  59. package/studio/src/components/ui.tsx +138 -0
  60. package/studio/src/lib/mix-loudness.ts +52 -0
  61. package/studio/src/main.tsx +34 -0
  62. package/studio/src/shortcuts.ts +27 -0
  63. package/studio/src/studio.css +1232 -0
  64. package/studio/src/theme.ts +61 -0
  65. package/studio/src/views/AssetsView.tsx +111 -0
  66. package/studio/src/views/BrandsView.tsx +139 -0
  67. package/studio/src/views/ComponentsView.tsx +285 -0
  68. package/studio/src/views/HomeView.tsx +122 -0
  69. package/studio/src/views/VideosView.tsx +343 -0
  70. package/studio/src/virtual.d.ts +25 -0
package/src/render.ts ADDED
@@ -0,0 +1,627 @@
1
+ import {spawn} from "node:child_process";
2
+ import type {Writable} from "node:stream";
3
+ import {copyFile, mkdir, rm, writeFile} from "node:fs/promises";
4
+ import {cpus} from "node:os";
5
+ import {dirname, join, resolve} from "node:path";
6
+ import {chromium, type Browser, type Page} from "playwright-core";
7
+ import type {ManifestAudioCue} from "odori";
8
+ import {buildAudioFilter, resolveCueFile, type MixInput} from "./audio-mix";
9
+ import {chunkFrames, planChunks, type FrameChunk} from "./chunks";
10
+ import {chunkKey, readChunkRecord, signaturesMatch, useChunkRecord, writeChunkRecord} from "./chunk-cache";
11
+ import {type ResolvedConfig} from "./config";
12
+ import {installBrowser, installFfmpeg, resolveBrowser, resolveFfmpeg} from "./binaries";
13
+ import {FORMATS, type VideoFormat} from "./formats";
14
+ import {log} from "./log";
15
+
16
+ export type RenderTarget = {
17
+ videoId: string;
18
+ width: number;
19
+ height: number;
20
+ fps: number;
21
+ durationInFrames: number;
22
+ input?: Record<string, unknown>;
23
+ prepared?: unknown;
24
+ audio?: ManifestAudioCue[];
25
+ targetLufs?: number;
26
+ scenes?: Array<{id: string; start: number; durationInFrames: number}>;
27
+ };
28
+
29
+ const encodeParam = (value: unknown) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
30
+
31
+ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
32
+ const params = new URLSearchParams({render: "1", video: target.videoId, frame: String(frame)});
33
+ if (target.input) params.set("input", encodeParam(target.input));
34
+ if (target.prepared !== undefined) params.set("prepared", encodeParam(target.prepared));
35
+ return `${origin}/?${params.toString()}`;
36
+ };
37
+
38
+ export type RenderPage = {browser: Browser; page: Page; errors: string[]};
39
+
40
+ /** Open one video in render mode and wait for its first frame to mount. */
41
+ export const openRenderPage = async (
42
+ origin: string,
43
+ target: RenderTarget,
44
+ config: ResolvedConfig,
45
+ ): Promise<RenderPage> => {
46
+ const executablePath = await browserExecutable(config);
47
+ const browser = await chromium.launch({executablePath, headless: true});
48
+ const page = await browser.newPage({
49
+ viewport: {width: target.width, height: target.height},
50
+ deviceScaleFactor: 1,
51
+ });
52
+ // Collected for the whole session, not just for the mount, so an exception
53
+ // thrown at frame 200 cannot produce a quietly wrong file.
54
+ const errors: string[] = [];
55
+ page.on("pageerror", (error) => errors.push(error.message));
56
+ await page.goto(renderUrl(origin, target, 0), {waitUntil: "networkidle"});
57
+ try {
58
+ await page.locator('[data-odori-frame="0"]').waitFor({timeout: 20000});
59
+ } catch {
60
+ await browser.close();
61
+ throw new Error(`The video did not mount.${errors.length ? ` ${errors.join(" ")}` : ""}`);
62
+ }
63
+ await page.evaluate(() => document.fonts.ready);
64
+ return {browser, page, errors};
65
+ };
66
+
67
+ /** Seek through the readiness handshake instead of guessing with timeouts. */
68
+ export const seekTo = async (page: Page, frame: number) => {
69
+ await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
70
+ await page.locator(`[data-odori-frame="${frame}"]`).waitFor({timeout: 20000});
71
+ };
72
+
73
+ export const readTimeline = async (page: Page) =>
74
+ page.evaluate(() => window.__ODORI_TIMELINE__ ?? {scenes: [], durationInFrames: 0});
75
+
76
+ export const readAudio = async (page: Page) =>
77
+ page.evaluate(() => window.__ODORI_AUDIO__ ?? {cues: [], durationInFrames: 0});
78
+
79
+ /**
80
+ * A cheap hash of the rendered scene, evaluated as source so no bundler helper
81
+ * leaks into the page. Motion is a pure function of the frame, so two frames
82
+ * with the same signature are the same picture.
83
+ */
84
+ const SIGNATURE_SCRIPT = `(() => {
85
+ var root = document.querySelector("[data-odori-video]");
86
+ if (!root) return "";
87
+ var markup = root.outerHTML;
88
+ var hash = 2166136261;
89
+ for (var index = 0; index < markup.length; index += 1) {
90
+ hash ^= markup.charCodeAt(index);
91
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
92
+ }
93
+ return hash.toString(16) + ":" + markup.length;
94
+ })()`;
95
+
96
+ /**
97
+ * Signatures for a run of frames, computed inside the page.
98
+ *
99
+ * Probing a cached chunk only needs to know whether the pictures changed, and
100
+ * a round trip per frame costs nearly as much as capturing one. This walks the
101
+ * frames in the page and returns the whole run in a single call.
102
+ */
103
+ const PROBE_SCRIPT = `(async (frames) => {
104
+ var signature = function () {
105
+ var root = document.querySelector("[data-odori-video]");
106
+ if (!root) return "";
107
+ var markup = root.outerHTML;
108
+ var hash = 2166136261;
109
+ for (var index = 0; index < markup.length; index += 1) {
110
+ hash ^= markup.charCodeAt(index);
111
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
112
+ }
113
+ return hash.toString(16) + ":" + markup.length;
114
+ };
115
+ var settled = function (frame) {
116
+ return new Promise(function (done, fail) {
117
+ var started = Date.now();
118
+ var check = function () {
119
+ var root = document.querySelector("[data-odori-frame]");
120
+ if (root && root.getAttribute("data-odori-frame") === String(frame)) {
121
+ done(undefined);
122
+ return;
123
+ }
124
+ if (Date.now() - started > 20000) {
125
+ fail(new Error("Timed out waiting for frame " + frame));
126
+ return;
127
+ }
128
+ requestAnimationFrame(check);
129
+ };
130
+ requestAnimationFrame(check);
131
+ });
132
+ };
133
+ var out = [];
134
+ for (var index = 0; index < frames.length; index += 1) {
135
+ window.__ODORI_SET_FRAME__(frames[index]);
136
+ await settled(frames[index]);
137
+ out.push(signature());
138
+ }
139
+ return out;
140
+ })`;
141
+
142
+ export const probeSignatures = async (page: Page, frames: number[]): Promise<string[]> => {
143
+ const signatures: string[] = [];
144
+ // Batched so one evaluate cannot run long enough to look like a hang.
145
+ for (let index = 0; index < frames.length; index += 60) {
146
+ const batch = frames.slice(index, index + 60);
147
+ const result = (await page.evaluate(`(${PROBE_SCRIPT})(${JSON.stringify(batch)})`)) as string[];
148
+ signatures.push(...result);
149
+ }
150
+ return signatures;
151
+ };
152
+
153
+ const run = (command: string, args: string[], signal?: AbortSignal) =>
154
+ new Promise<void>((resolveRun, rejectRun) => {
155
+ if (signal?.aborted) {
156
+ rejectRun(new Error("Render cancelled."));
157
+ return;
158
+ }
159
+ const child = spawn(command, args, {stdio: ["ignore", "ignore", "pipe"]});
160
+ let stderr = "";
161
+ child.stderr?.on("data", (chunk: Buffer) => {
162
+ stderr += chunk.toString();
163
+ });
164
+ const onAbort = () => child.kill("SIGTERM");
165
+ signal?.addEventListener("abort", onAbort, {once: true});
166
+ child.on("error", rejectRun);
167
+ child.on("exit", (code) => {
168
+ signal?.removeEventListener("abort", onAbort);
169
+ if (signal?.aborted) rejectRun(new Error("Render cancelled."));
170
+ else if (code === 0) resolveRun();
171
+ else rejectRun(new Error(`${command} exited with ${code}: ${stderr.slice(-800)}`));
172
+ });
173
+ });
174
+
175
+ /**
176
+ * The Chrome this render will use, downloading the pinned build the first time
177
+ * a machine needs it. A render that waits ninety seconds once is better than a
178
+ * render that silently uses a different browser than the last one did.
179
+ */
180
+ const browserExecutable = async (config: ResolvedConfig): Promise<string> => {
181
+ const resolved = await resolveBrowser(config);
182
+ if (resolved) return resolved.path;
183
+
184
+ log.detail("Downloading the pinned Chrome build. This happens once per machine.");
185
+ try {
186
+ return await installBrowser();
187
+ } catch (error) {
188
+ throw new Error(
189
+ `No Chrome available and the managed build could not be downloaded: ${(error as Error).message}\n` +
190
+ 'Run "odori install" when you have a connection, or set chromePath in odori.config.ts.',
191
+ );
192
+ }
193
+ };
194
+
195
+ /**
196
+ * The FFmpeg this render will use. Same contract as the browser: managed and
197
+ * pinned by default, fetched once, overridable for a machine that has to use
198
+ * its own build.
199
+ */
200
+ export const ffmpegExecutable = async (config: ResolvedConfig): Promise<string> => {
201
+ const resolved = await resolveFfmpeg(config);
202
+ if (resolved) return resolved.path;
203
+
204
+ log.detail("Downloading the pinned FFmpeg build. This happens once per machine.");
205
+ try {
206
+ return await installFfmpeg();
207
+ } catch (error) {
208
+ throw new Error(
209
+ `No FFmpeg available and the managed build could not be downloaded: ${(error as Error).message}\n` +
210
+ 'Run "odori install" when you have a connection, or set ffmpegPath in odori.config.ts.',
211
+ );
212
+ }
213
+ };
214
+
215
+ /** Kept for callers that only want to know an encoder exists. */
216
+ export const ensureFfmpeg = async (config: ResolvedConfig) => {
217
+ await ffmpegExecutable(config);
218
+ };
219
+
220
+ export const renderStill = async (
221
+ origin: string,
222
+ target: RenderTarget,
223
+ frame: number,
224
+ output: string,
225
+ config: ResolvedConfig,
226
+ ): Promise<string> => {
227
+ const {browser, page, errors} = await openRenderPage(origin, target, config);
228
+ try {
229
+ await mkdir(dirname(output), {recursive: true});
230
+ await seekTo(page, frame);
231
+ await page.screenshot({path: output});
232
+ if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
233
+ return output;
234
+ } finally {
235
+ await browser.close();
236
+ }
237
+ };
238
+
239
+ export const defaultConcurrency = (): number => Math.max(1, Math.min(4, cpus().length - 2));
240
+
241
+ type CaptureStats = {captured: number; reused: number; cachedChunks: number};
242
+
243
+ /** Write to a pipe with backpressure, so a slow encoder cannot balloon memory. */
244
+ const writeFrame = (stdin: Writable, frame: Buffer) =>
245
+ new Promise<void>((resolveWrite, rejectWrite) => {
246
+ if (stdin.write(frame)) {
247
+ resolveWrite();
248
+ return;
249
+ }
250
+ stdin.once("drain", resolveWrite);
251
+ stdin.once("error", rejectWrite);
252
+ });
253
+
254
+ /**
255
+ * Encode one chunk from a stream of PNG buffers.
256
+ *
257
+ * Frames never touch the disk, which removes a write and a read per frame and
258
+ * makes a truncated file structurally impossible: the encoder receives exactly
259
+ * the frames the capture produced, in order.
260
+ */
261
+ /**
262
+ * Where a numbered sequence goes. `out/cut.png` becomes `out/cut/00001.png`,
263
+ * so one export is one directory and a second export does not interleave with
264
+ * the first.
265
+ */
266
+ const sequencePattern = (output: string): string => {
267
+ const dot = output.lastIndexOf(".");
268
+ const stem = dot > 0 ? output.slice(0, dot) : output;
269
+ const extension = dot > 0 ? output.slice(dot) : ".png";
270
+ return join(stem, `%05d${extension}`);
271
+ };
272
+
273
+ const openChunkEncoder = (
274
+ ffmpeg: string,
275
+ file: string,
276
+ fps: number,
277
+ preset: string,
278
+ format: VideoFormat,
279
+ signal?: AbortSignal,
280
+ ) => {
281
+ const child = spawn(
282
+ ffmpeg,
283
+ ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(preset), file],
284
+ {stdio: ["pipe", "ignore", "pipe"]},
285
+ );
286
+
287
+ let stderr = "";
288
+ child.stderr?.on("data", (chunk: Buffer) => {
289
+ stderr += chunk.toString();
290
+ });
291
+ const onAbort = () => child.kill("SIGTERM");
292
+ signal?.addEventListener("abort", onAbort, {once: true});
293
+
294
+ const done = new Promise<void>((resolveDone, rejectDone) => {
295
+ child.on("error", rejectDone);
296
+ child.on("exit", (code) => {
297
+ signal?.removeEventListener("abort", onAbort);
298
+ if (signal?.aborted) rejectDone(new Error("Render cancelled."));
299
+ else if (code === 0) resolveDone();
300
+ else rejectDone(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-800)}`));
301
+ });
302
+ });
303
+
304
+ return {child, done};
305
+ };
306
+
307
+ type LaneOptions = {
308
+ skipUnchanged: boolean;
309
+ signal?: AbortSignal;
310
+ preset: string;
311
+ format: VideoFormat;
312
+ /** The resolved encoder, so a lane never guesses at what is on PATH. */
313
+ ffmpeg: string;
314
+ workDir: string;
315
+ onFrame: () => void;
316
+ chunkFile: (chunk: FrameChunk) => string;
317
+ cacheIdentity?: (chunk: FrameChunk) => Parameters<typeof chunkKey>[0];
318
+ };
319
+
320
+ /**
321
+ * Capture one lane of chunks in a dedicated browser process.
322
+ *
323
+ * Screenshots are serialized per browser, so a lane owns a browser rather than
324
+ * a tab. A frame that fails is retried once on a fresh page, because frames are
325
+ * independent and losing a whole render to one timeout is a bad trade.
326
+ */
327
+ const captureLane = async (
328
+ origin: string,
329
+ target: RenderTarget,
330
+ config: ResolvedConfig,
331
+ lane: FrameChunk[],
332
+ stats: CaptureStats,
333
+ options: LaneOptions,
334
+ ): Promise<void> => {
335
+ let session = await openRenderPage(origin, target, config);
336
+ const errors = session.errors;
337
+
338
+ const reopen = async () => {
339
+ await session.browser.close().catch(() => undefined);
340
+ session = await openRenderPage(origin, target, config);
341
+ session.errors.push(...errors);
342
+ };
343
+
344
+ const signatureOf = async () => (await session.page.evaluate(SIGNATURE_SCRIPT)) as string;
345
+
346
+ try {
347
+ for (const chunk of lane) {
348
+ if (options.signal?.aborted) throw new Error("Render cancelled.");
349
+ const frames = chunkFrames(chunk);
350
+ const identity = options.cacheIdentity?.(chunk);
351
+ const key = identity ? chunkKey(identity) : null;
352
+
353
+ // A cached chunk is reused only when every frame still looks the same,
354
+ // which is what makes the cache exact rather than hopeful.
355
+ if (key) {
356
+ const record = await readChunkRecord(config, key);
357
+ if (record) {
358
+ const observed = await probeSignatures(session.page, frames);
359
+ if (signaturesMatch(record.signatures, observed)) {
360
+ await useChunkRecord(config, key, options.chunkFile(chunk));
361
+ stats.cachedChunks += 1;
362
+ stats.captured += frames.length;
363
+ stats.reused += frames.length;
364
+ for (const _frame of frames) options.onFrame();
365
+ continue;
366
+ }
367
+ }
368
+ }
369
+
370
+ const file = options.chunkFile(chunk);
371
+ const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.preset, options.format, options.signal);
372
+ const signatures: string[] = [];
373
+ let previousSignature: string | null = null;
374
+ let previousFrame: Buffer | null = null;
375
+ let written = 0;
376
+
377
+ try {
378
+ for (const frame of frames) {
379
+ if (options.signal?.aborted) throw new Error("Render cancelled.");
380
+
381
+ for (let attempt = 0; ; attempt += 1) {
382
+ try {
383
+ await seekTo(session.page, frame);
384
+ const signature = await signatureOf();
385
+ let image: Buffer;
386
+ if (options.skipUnchanged && previousFrame && signature === previousSignature) {
387
+ // The picture did not change, so the previous bytes still hold.
388
+ image = previousFrame;
389
+ stats.reused += 1;
390
+ } else {
391
+ image = await session.page.screenshot();
392
+ }
393
+ await writeFrame(encoder.child.stdin!, image);
394
+ signatures.push(signature);
395
+ previousSignature = signature;
396
+ previousFrame = image;
397
+ written += 1;
398
+ break;
399
+ } catch (error) {
400
+ if (options.signal?.aborted) throw new Error("Render cancelled.");
401
+ if (attempt >= 1) throw error;
402
+ log.detail(`Frame ${frame} failed, retrying on a fresh page.`);
403
+ await reopen();
404
+ previousSignature = null;
405
+ previousFrame = null;
406
+ }
407
+ }
408
+
409
+ stats.captured += 1;
410
+ options.onFrame();
411
+ }
412
+
413
+ encoder.child.stdin?.end();
414
+ await encoder.done;
415
+
416
+ if (written !== frames.length) {
417
+ throw new Error(`Chunk ${chunk.index} wrote ${written} of ${frames.length} frames.`);
418
+ }
419
+ if (key) await writeChunkRecord(config, key, signatures, file);
420
+ } catch (error) {
421
+ encoder.child.kill("SIGTERM");
422
+ throw error;
423
+ }
424
+ }
425
+ } finally {
426
+ await session.browser.close().catch(() => undefined);
427
+ if (session.errors.length > 0) {
428
+ throw new Error(`The page reported an error during capture: ${session.errors.slice(0, 3).join(" ")}`);
429
+ }
430
+ }
431
+ };
432
+
433
+ export type RenderProgress = (progress: number, stage: "rendering" | "encoding") => void;
434
+
435
+ export type RenderTimings = {
436
+ captureMs: number;
437
+ encodeMs: number;
438
+ frames: number;
439
+ reusedFrames: number;
440
+ cachedChunks: number;
441
+ chunks: number;
442
+ concurrency: number;
443
+ };
444
+
445
+ export type RenderOptions = {
446
+ concurrency?: number;
447
+ preset?: string;
448
+ /** Container and codec. Defaults to H.264 in MP4. */
449
+ format?: VideoFormat;
450
+ skipUnchangedFrames?: boolean;
451
+ /** Reuse encoded chunks whose frames still look identical. */
452
+ cache?: boolean;
453
+ signal?: AbortSignal;
454
+ /** Where frames and chunk files are written. Defaults to the generated directory. */
455
+ workDir?: string;
456
+ onTimings?: (timings: RenderTimings) => void;
457
+ };
458
+
459
+
460
+
461
+ export const renderMovie = async (
462
+ origin: string,
463
+ target: RenderTarget,
464
+ output: string,
465
+ config: ResolvedConfig,
466
+ onProgress?: RenderProgress,
467
+ options: RenderOptions = {},
468
+ ): Promise<string> => {
469
+ // Both binaries are resolved before a single frame is captured: a download
470
+ // that starts an hour into a render is a render that failed an hour ago.
471
+ const ffmpeg = await ffmpegExecutable(config);
472
+ const browserPath = await browserExecutable(config);
473
+ // Cached frames belong to the browser that drew them.
474
+ const renderer = (await resolveBrowser(config))?.version ?? browserPath;
475
+
476
+ const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
477
+ const preset = options.preset ?? config.preset ?? "medium";
478
+ const format = options.format ?? FORMATS.mp4;
479
+ // A palette or a still sequence is computed across the whole animation, so
480
+ // it cannot be assembled from independently encoded chunks. One lane, one
481
+ // pass: slower, and the only way the output is correct.
482
+ const chunkable = format.chunked;
483
+ const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
484
+ const cache = options.cache ?? config.cacheChunks ?? true;
485
+
486
+ // Chunk files live in the project rather than the OS temp directory, so a
487
+ // killed render leaves something attributable and inspectable behind.
488
+ const work =
489
+ options.workDir ??
490
+ resolve(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
491
+ await mkdir(work, {recursive: true});
492
+
493
+ const concurrency = chunkable ? requested : 1;
494
+ const {chunks, lanes} = planChunks({
495
+ durationInFrames: target.durationInFrames,
496
+ scenes: target.scenes,
497
+ concurrency,
498
+ });
499
+
500
+ const chunkFile = (chunk: FrameChunk) =>
501
+ join(work, `chunk-${String(chunk.index).padStart(4, "0")}${chunkable ? format.extension : ".mkv"}`);
502
+ const stats: CaptureStats = {captured: 0, reused: 0, cachedChunks: 0};
503
+ let succeeded = false;
504
+
505
+ try {
506
+ await mkdir(dirname(output), {recursive: true});
507
+
508
+ const captureStart = performance.now();
509
+ await Promise.all(
510
+ lanes.map((lane) =>
511
+ captureLane(origin, target, config, lane, stats, {
512
+ skipUnchanged,
513
+ signal: options.signal,
514
+ preset,
515
+ format,
516
+ ffmpeg,
517
+ workDir: work,
518
+ chunkFile,
519
+ cacheIdentity: cache
520
+ ? (chunk) => ({
521
+ videoId: target.videoId,
522
+ chunk,
523
+ renderer,
524
+ format: format.name,
525
+ width: target.width,
526
+ height: target.height,
527
+ fps: target.fps,
528
+ preset,
529
+ input: target.input,
530
+ })
531
+ : undefined,
532
+ onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering"),
533
+ }),
534
+ ),
535
+ );
536
+ // Encoding happened inside capture, so what remains is joining and sound.
537
+ const captureMs = performance.now() - captureStart;
538
+ onProgress?.(1, "encoding");
539
+
540
+ const mixInputs: MixInput[] = (target.audio ?? [])
541
+ .map((cue) => {
542
+ const file = resolveCueFile(config, cue.src);
543
+ if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
544
+ return file ? {file, cue} : null;
545
+ })
546
+ .filter((item): item is MixInput => item !== null);
547
+
548
+ const muxStart = performance.now();
549
+ const ordered = chunks.map(chunkFile);
550
+ const silent = join(work, `video${chunkable ? format.extension : ".mkv"}`);
551
+
552
+ if (ordered.length === 1) {
553
+ await copyFile(ordered[0], silent);
554
+ } else {
555
+ const list = join(work, "chunks.txt");
556
+ await writeFile(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
557
+ await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
558
+ }
559
+
560
+ // A format that cannot be chunked was captured losslessly and is encoded
561
+ // here in one pass, where a palette or a sequence can see every frame.
562
+ if (!chunkable) {
563
+ // A sequence is many files. Given one path it writes beside it, using
564
+ // the name as the directory, rather than overwriting a single frame 270
565
+ // times and reporting success.
566
+ const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
567
+ if (destination !== output) await mkdir(dirname(destination), {recursive: true});
568
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(preset), destination], options.signal);
569
+ if (mixInputs.length > 0) {
570
+ log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
571
+ }
572
+ } else if (mixInputs.length === 0 || !format.audio) {
573
+ const faststart = format.extension === ".mp4" || format.extension === ".mov";
574
+ await run(
575
+ ffmpeg,
576
+ ["-y", "-i", silent, "-c", "copy", ...(faststart ? ["-movflags", "+faststart"] : []), output],
577
+ options.signal,
578
+ );
579
+ } else {
580
+ const {filter, label} = buildAudioFilter(mixInputs, {
581
+ fps: target.fps,
582
+ durationInFrames: target.durationInFrames,
583
+ targetLufs: target.targetLufs ?? -14,
584
+ });
585
+ const args = ["-y", "-i", silent];
586
+ for (const {cue, file} of mixInputs) args.push(...(cue.loop ? ["-stream_loop", "-1"] : []), "-i", file);
587
+ args.push(
588
+ "-filter_complex",
589
+ filter,
590
+ "-map",
591
+ "0:v",
592
+ "-map",
593
+ label,
594
+ "-c:v",
595
+ "copy",
596
+ // WebM cannot carry AAC; every other container here can.
597
+ "-c:a",
598
+ format.extension === ".webm" ? "libopus" : "aac",
599
+ "-b:a",
600
+ "192k",
601
+ "-ar",
602
+ "48000",
603
+ "-shortest",
604
+ ...(format.extension === ".mp4" || format.extension === ".mov" ? ["-movflags", "+faststart"] : []),
605
+ output,
606
+ );
607
+ await run(ffmpeg, args, options.signal);
608
+ }
609
+
610
+ options.onTimings?.({
611
+ captureMs: Math.round(captureMs),
612
+ encodeMs: Math.round(performance.now() - muxStart),
613
+ frames: target.durationInFrames,
614
+ reusedFrames: stats.reused,
615
+ cachedChunks: stats.cachedChunks,
616
+ chunks: chunks.length,
617
+ concurrency: lanes.length,
618
+ });
619
+ succeeded = true;
620
+ return output;
621
+ } finally {
622
+ // Keep the working directory when something went wrong: it is the only
623
+ // record of which chunks landed.
624
+ if (succeeded && !options.workDir) await rm(work, {recursive: true, force: true});
625
+ else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
626
+ }
627
+ };