@getvitops/utils 2.0.0 → 3.0.0

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.
@@ -0,0 +1,533 @@
1
+ import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { promisify } from "node:util";
3
+ import { execFile } from "node:child_process";
4
+ import { dirname, extname, join, relative, resolve, sep } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { tmpdir } from "node:os";
7
+
8
+ //#region src/media/discover.ts
9
+ /**
10
+ * Finding the source videos and fingerprinting them.
11
+ *
12
+ * Deliberately not `scanFiles` from `../scan.ts`, which reads every match into
13
+ * memory as a string. That is right for templates and catastrophic for a video —
14
+ * the files this walks are routinely tens or hundreds of megabytes, which is also
15
+ * why the hash is streamed rather than taken over a `readFileSync`.
16
+ */
17
+ /** Container formats worth accepting as input. */
18
+ const VIDEO_EXT = new Set([
19
+ ".mp4",
20
+ ".mov",
21
+ ".m4v",
22
+ ".mkv",
23
+ ".avi",
24
+ ".webm",
25
+ ".mpg",
26
+ ".mpeg"
27
+ ]);
28
+ /** Directories that never hold hand-managed source assets. */
29
+ const DISCOVER_SKIP = new Set([
30
+ "node_modules",
31
+ "dist",
32
+ ".git"
33
+ ]);
34
+ /** sha256 of the file contents, streamed. Truncated — 16 hex chars is 64 bits. */
35
+ function hashFile(path) {
36
+ return new Promise((resolve, reject) => {
37
+ const hash = createHash("sha256");
38
+ const stream = createReadStream(path);
39
+ stream.on("error", reject);
40
+ stream.on("data", (chunk) => hash.update(chunk));
41
+ stream.on("end", () => resolve(hash.digest("hex").slice(0, 16)));
42
+ });
43
+ }
44
+ function walk(dir, acc) {
45
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
46
+ if (e.name.startsWith(".")) continue;
47
+ const p = join(dir, e.name);
48
+ if (e.isDirectory()) {
49
+ if (!DISCOVER_SKIP.has(e.name)) walk(p, acc);
50
+ } else if (VIDEO_EXT.has(extname(e.name).toLowerCase())) acc.push(p);
51
+ }
52
+ }
53
+ /**
54
+ * Every video under `dir`, with its content hash.
55
+ *
56
+ * Sorted by `id` so a plan — and therefore the manifest and the log — comes out in
57
+ * the same order on every machine, regardless of what the filesystem hands back.
58
+ */
59
+ async function discoverVideos(dir) {
60
+ const paths = [];
61
+ walk(dir, paths);
62
+ return (await Promise.all(paths.map(async (path) => ({
63
+ path,
64
+ id: relative(dir, path).split(sep).join("/"),
65
+ hash: await hashFile(path)
66
+ })))).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
67
+ }
68
+
69
+ //#endregion
70
+ //#region src/media/ffmpeg.ts
71
+ /**
72
+ * The encoder. Executes a job and decides nothing.
73
+ *
74
+ * Every invocation goes through `execFile` with an argv array rather than a shell
75
+ * string: a source path containing a space or a quote is ordinary, and the
76
+ * shell-interpolated form these commands are usually written in breaks on both.
77
+ */
78
+ const exec = promisify(execFile);
79
+ /** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */
80
+ const MAX_BUFFER = 16 * 1024 * 1024;
81
+ /**
82
+ * x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has
83
+ * to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the
84
+ * pairing VP9's encoding guide uses for comparable quality.
85
+ */
86
+ const X264_CRF_RATIO = .72;
87
+ let probe;
88
+ /**
89
+ * Fail early and clearly when ffmpeg isn't installed.
90
+ *
91
+ * Deliberately not the `optimizePng` shape in `favicon.ts`, which swallows a
92
+ * missing `oxipng` and warns once. That is right for a best-effort lossless crush,
93
+ * where skipping costs a few kilobytes. It is wrong here: skipping produces no
94
+ * output at all, and a build that carries on would ship the page referencing a
95
+ * video that was never encoded.
96
+ */
97
+ function requireFfmpeg() {
98
+ probe ??= exec("ffmpeg", ["-version"], { maxBuffer: MAX_BUFFER }).then(() => void 0, () => {
99
+ probe = void 0;
100
+ throw new Error("media: ffmpeg not found on PATH. It is an external tool, not an npm dependency — install it (macOS: `brew install ffmpeg`, Debian/Ubuntu: `apt install ffmpeg`, Windows: `winget install Gyan.FFmpeg`) and run this again.");
101
+ });
102
+ return probe;
103
+ }
104
+ async function run(args) {
105
+ try {
106
+ await exec("ffmpeg", args, { maxBuffer: MAX_BUFFER });
107
+ } catch (err) {
108
+ const tail = String(err.stderr ?? "").trim().split("\n").slice(-6).join("\n");
109
+ throw new Error(`media: ffmpeg failed${tail ? `\n${tail}` : ""}`);
110
+ }
111
+ }
112
+ /**
113
+ * An encode that "succeeds" but writes nothing is the failure mode worth guarding:
114
+ * seeking a poster past the end of a clip does exactly that, and ffmpeg exits 0.
115
+ * Without this the manifest would record the job as done and never retry it.
116
+ */
117
+ function assertWritten(file, what) {
118
+ if (!existsSync(file) || statSync(file).size === 0) throw new Error(`media: ${what} produced no output (${file}). Check --poster-time and the source.`);
119
+ }
120
+ /** Scale filter, capping width and keeping the height even (yuv420p requires it). */
121
+ function scaleArgs(maxWidth) {
122
+ if (maxWidth <= 0) return [];
123
+ return ["-vf", `scale='min(${maxWidth},iw)':-2`];
124
+ }
125
+ /** `2M` → `4M`. A cap needs a buffer, and twice the rate is the usual choice. */
126
+ function bufsize(maxBitrate) {
127
+ const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(maxBitrate.trim());
128
+ if (!m) throw new Error(`media: maxBitrate "${maxBitrate}" is not a bitrate (try "2M" or "800k").`);
129
+ return `${Number(m[1]) * 2}${m[2] ?? ""}`;
130
+ }
131
+ const rateArgs = (maxBitrate) => maxBitrate ? [
132
+ "-maxrate",
133
+ maxBitrate,
134
+ "-bufsize",
135
+ bufsize(maxBitrate)
136
+ ] : [];
137
+ const audioArgs = (config, codec) => config.audio ? [
138
+ "-c:a",
139
+ codec,
140
+ "-b:a",
141
+ "128k"
142
+ ] : ["-an"];
143
+ const BASE = [
144
+ "-y",
145
+ "-nostdin",
146
+ "-loglevel",
147
+ "error"
148
+ ];
149
+ /**
150
+ * VP9/WebM, two passes.
151
+ *
152
+ * The pass log goes into a fresh temp directory rather than the working directory.
153
+ * ffmpeg's default (`ffmpeg2pass-0.log`, in cwd) makes cleanup cwd-dependent and
154
+ * makes two concurrent encodes silently share one log — the second encode's rate
155
+ * control then reads the first clip's statistics.
156
+ */
157
+ async function encodeWebm(input, output, config) {
158
+ const dir = mkdtempSync(join(tmpdir(), "vitops-media-"));
159
+ const passlog = join(dir, "pass");
160
+ const video = [
161
+ ...scaleArgs(config.maxWidth),
162
+ "-c:v",
163
+ "libvpx-vp9",
164
+ "-b:v",
165
+ "0",
166
+ "-crf",
167
+ String(config.crf),
168
+ "-row-mt",
169
+ "1",
170
+ ...rateArgs(config.maxBitrate)
171
+ ];
172
+ try {
173
+ await run([
174
+ ...BASE,
175
+ "-i",
176
+ input,
177
+ ...video,
178
+ "-an",
179
+ "-pass",
180
+ "1",
181
+ "-passlogfile",
182
+ passlog,
183
+ "-f",
184
+ "null",
185
+ "-"
186
+ ]);
187
+ await run([
188
+ ...BASE,
189
+ "-i",
190
+ input,
191
+ ...video,
192
+ ...audioArgs(config, "libopus"),
193
+ "-pass",
194
+ "2",
195
+ "-passlogfile",
196
+ passlog,
197
+ output
198
+ ]);
199
+ } finally {
200
+ rmSync(dir, {
201
+ recursive: true,
202
+ force: true
203
+ });
204
+ }
205
+ assertWritten(output, "webm encode");
206
+ }
207
+ /**
208
+ * H.264/MP4 — the fallback that makes `<video>` work on older iOS and in the
209
+ * in-app webviews that still don't decode VP9.
210
+ *
211
+ * `+faststart` moves the moov atom to the front. Without it the browser has to
212
+ * download the whole file before it can show frame one, which on a hero video
213
+ * looks exactly like the optimisation didn't work.
214
+ */
215
+ async function encodeMp4(input, output, config) {
216
+ await run([
217
+ ...BASE,
218
+ "-i",
219
+ input,
220
+ ...scaleArgs(config.maxWidth),
221
+ "-c:v",
222
+ "libx264",
223
+ "-preset",
224
+ "slow",
225
+ "-crf",
226
+ String(Math.round(config.crf * X264_CRF_RATIO)),
227
+ "-pix_fmt",
228
+ "yuv420p",
229
+ "-movflags",
230
+ "+faststart",
231
+ ...rateArgs(config.maxBitrate),
232
+ ...audioArgs(config, "aac"),
233
+ output
234
+ ]);
235
+ assertWritten(output, "mp4 encode");
236
+ }
237
+ /** The poster frame. `-ss` before `-i` so the seek is a fast one. */
238
+ async function extractPoster(input, output, config) {
239
+ await run([
240
+ ...BASE,
241
+ ...config.posterTime > 0 ? ["-ss", String(config.posterTime)] : [],
242
+ "-i",
243
+ input,
244
+ ...scaleArgs(config.maxWidth),
245
+ "-frames:v",
246
+ "1",
247
+ "-q:v",
248
+ "2",
249
+ output
250
+ ]);
251
+ assertWritten(output, "poster extraction");
252
+ }
253
+
254
+ //#endregion
255
+ //#region src/media/manifest.ts
256
+ /**
257
+ * The cache — what makes a second run free.
258
+ *
259
+ * A committed artifact, unlike `indexing/`'s snapshot: the recommended layout
260
+ * checks in both the processed outputs and this file, so CI never needs ffmpeg
261
+ * and the outputs only change when someone deliberately re-encodes. That is why
262
+ * `writeManifest` sorts and carries no timestamp — an unchanged run has to produce
263
+ * a byte-identical file, or every build shows up as a diff.
264
+ */
265
+ /** Default location, relative to the consumer's project root. */
266
+ const MEDIA_MANIFEST_PATH = ".vitops/media-manifest.json";
267
+ /**
268
+ * Read the manifest, or `undefined` when there isn't a usable one.
269
+ *
270
+ * A corrupt or wrong-version file reads as absent, which re-encodes everything.
271
+ * The opposite reading — treating it as an empty but valid manifest — is the same
272
+ * shape of bug `readSnapshot` avoids, except here it would be silent: nothing
273
+ * would encode, and the build would reference outputs that were never written.
274
+ */
275
+ function readManifest(path) {
276
+ if (!existsSync(path)) return void 0;
277
+ try {
278
+ const raw = JSON.parse(readFileSync(path, "utf8"));
279
+ if (raw?.version !== 1 || typeof raw.entries !== "object" || raw.entries === null) return void 0;
280
+ return raw;
281
+ } catch {
282
+ return;
283
+ }
284
+ }
285
+ /** Build a manifest with its keys and output lists in a stable order. */
286
+ function toManifest(entries) {
287
+ const sorted = {};
288
+ for (const key of Object.keys(entries).sort()) {
289
+ const entry = entries[key];
290
+ sorted[key] = {
291
+ ...entry,
292
+ outputs: [...entry.outputs].sort()
293
+ };
294
+ }
295
+ return {
296
+ version: 1,
297
+ entries: sorted
298
+ };
299
+ }
300
+ /**
301
+ * Write the manifest, creating its directory.
302
+ *
303
+ * Record a job only once its outputs are on disk. Writing eagerly would mark a
304
+ * source as encoded that isn't, and because the next run trusts this file, the
305
+ * result is a missing video that no rebuild ever fixes.
306
+ */
307
+ function writeManifest(path, manifest) {
308
+ mkdirSync(dirname(path), { recursive: true });
309
+ writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
310
+ }
311
+
312
+ //#endregion
313
+ //#region src/media/types.ts
314
+ /** The default set: a modern codec, a universal fallback, and a poster frame. */
315
+ const DEFAULT_OUTPUTS = [
316
+ "webm",
317
+ "mp4",
318
+ "poster"
319
+ ];
320
+ /** File extension per output kind. */
321
+ const OUTPUT_EXT = {
322
+ webm: ".webm",
323
+ mp4: ".mp4",
324
+ poster: ".jpg"
325
+ };
326
+ /**
327
+ * Bumped when the ffmpeg arguments in `ffmpeg.ts` change in a way that alters the
328
+ * output bytes. It is part of the settings hash, so bumping it re-encodes every
329
+ * source on the next run.
330
+ *
331
+ * Bump it deliberately and rarely. Consumers commit their processed outputs (see
332
+ * the README), so every bump is a large binary diff in someone's repository —
333
+ * that cost is worth paying for a real encoder fix and not for a tidy-up.
334
+ */
335
+ const ENCODER_VERSION = 1;
336
+
337
+ //#endregion
338
+ //#region src/media/plan.ts
339
+ /**
340
+ * Every decision `vitops media` makes, as one pure function.
341
+ *
342
+ * Pure in the load-bearing sense: no `fs`, no clock, no subprocess. Whether a
343
+ * source needs re-encoding — the only thing here that can be wrong in a way that
344
+ * costs minutes or ships a stale video — is decided from data passed in, and
345
+ * asserted in `plan.test.ts` on a machine with no ffmpeg installed.
346
+ *
347
+ * That split is the same one `indexing/plan.ts` makes against its I/O modules, and
348
+ * for the same reason: `--dry` should be a complete account of a run rather than
349
+ * an approximation of one.
350
+ */
351
+ const sha = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
352
+ /** Fill in defaults. Exported because the encoder and the hash must agree on them. */
353
+ function resolveConfig(config = {}) {
354
+ return {
355
+ maxWidth: config.maxWidth ?? 1920,
356
+ crf: config.crf ?? 32,
357
+ maxBitrate: config.maxBitrate,
358
+ audio: config.audio ?? false,
359
+ posterTime: config.posterTime ?? 0,
360
+ outputs: config.outputs ?? DEFAULT_OUTPUTS
361
+ };
362
+ }
363
+ /**
364
+ * Hash the settings that decide the output bytes.
365
+ *
366
+ * Order-independent in the one place it could bite (`outputs` is sorted), so
367
+ * re-ordering the list in a config doesn't re-encode a whole library.
368
+ */
369
+ function settingsHash(config) {
370
+ return sha(JSON.stringify({
371
+ v: 1,
372
+ maxWidth: config.maxWidth,
373
+ crf: config.crf,
374
+ maxBitrate: config.maxBitrate ?? null,
375
+ audio: config.audio,
376
+ posterTime: config.posterTime,
377
+ outputs: [...config.outputs].sort()
378
+ }));
379
+ }
380
+ /** `a/b/clip.mp4` → `a/b/clip` */
381
+ function stem(id) {
382
+ const dot = id.lastIndexOf(".");
383
+ return dot > id.lastIndexOf("/") ? id.slice(0, dot) : id;
384
+ }
385
+ function outputsFor(source, config) {
386
+ const base = stem(source.id);
387
+ return config.outputs.map((kind) => ({
388
+ kind,
389
+ file: `${base}${OUTPUT_EXT[kind]}`
390
+ }));
391
+ }
392
+ /**
393
+ * Decide what to encode.
394
+ *
395
+ * A job is skipped only when all three hold: the source contents hash the same as
396
+ * last time, the settings hash the same, and every declared output is still on
397
+ * disk. Anything else re-encodes — including an absent or unreadable manifest,
398
+ * which reads as "encode everything" and never as "everything is done".
399
+ *
400
+ * Throws on an output collision (`hero.mp4` and `hero.mov` in one directory both
401
+ * want `hero.webm`). That is a layout mistake rather than a runtime condition, and
402
+ * the failure it would otherwise cause — two sources overwriting each other every
403
+ * run, each invalidating the other's cache entry — is invisible from the outside.
404
+ */
405
+ function planMedia(input) {
406
+ const config = resolveConfig(input.config);
407
+ const settings = settingsHash(config);
408
+ const probe = input.probe ?? (() => false);
409
+ const notes = [];
410
+ const jobs = [];
411
+ const claimed = /* @__PURE__ */ new Map();
412
+ for (const source of input.sources) {
413
+ const outputs = outputsFor(source, config);
414
+ for (const out of outputs) {
415
+ const owner = claimed.get(out.file);
416
+ if (owner !== void 0 && owner !== source.id) throw new Error(`media: "${source.id}" and "${owner}" both produce "${out.file}". Two sources with the same name in one directory overwrite each other — rename one.`);
417
+ claimed.set(out.file, source.id);
418
+ }
419
+ const prev = input.previous?.entries[source.id];
420
+ const cached = !input.force && prev !== void 0 && prev.source === source.hash && prev.settings === settings && outputs.every((o) => probe(o.file));
421
+ jobs.push({
422
+ source,
423
+ outputs,
424
+ settings,
425
+ ...cached ? { skip: "unchanged" } : {}
426
+ });
427
+ }
428
+ const pending = jobs.filter((j) => !j.skip).length;
429
+ if (input.sources.length === 0) notes.push("no video files found");
430
+ else if (input.force && pending > 0) notes.push(`--force: re-encoding all ${pending}`);
431
+ else if (!input.previous && pending > 0) notes.push("no manifest — encoding everything (a first run, or a cache that was not restored)");
432
+ return {
433
+ jobs,
434
+ notes
435
+ };
436
+ }
437
+ /** The jobs that will actually run. */
438
+ const pendingJobs = (plan) => plan.jobs.filter((j) => !j.skip);
439
+ /** Human-readable account of a plan, for `--dry` and for the run summary. */
440
+ function formatPlan(plan) {
441
+ const lines = [];
442
+ const pending = pendingJobs(plan);
443
+ const skipped = plan.jobs.length - pending.length;
444
+ lines.push(`${plan.jobs.length} source${plan.jobs.length === 1 ? "" : "s"}`);
445
+ lines.push(` encode: ${pending.length}`);
446
+ lines.push(` skipped: ${skipped} (unchanged)`);
447
+ for (const job of pending) lines.push(` • ${job.source.id} → ${job.outputs.map((o) => o.file).join(", ")}`);
448
+ for (const note of plan.notes) lines.push(` ! ${note}`);
449
+ return lines.join("\n");
450
+ }
451
+
452
+ //#endregion
453
+ //#region src/media/index.ts
454
+ /**
455
+ * `vitops media` — turning a directory of raw video into web-ready assets.
456
+ *
457
+ * A separate subpath (`@getvitops/utils/media`) rather than part of the package
458
+ * index, matching `./favicon` and `./indexing`: this module shells out to an
459
+ * external encoder, and a consumer importing the content helpers shouldn't carry
460
+ * it.
461
+ *
462
+ * The layering is the point, and it is the same one `indexing/` makes. `plan.ts`
463
+ * decides everything and touches nothing; `ffmpeg.ts` executes and decides
464
+ * nothing; `discover.ts` and `manifest.ts` are the filesystem either side. That is
465
+ * what lets `--dry` be a complete account of a run, and what lets the cache — the
466
+ * part with real consequences, since getting it wrong means either minutes of
467
+ * needless encoding or a stale video shipped — be tested with no ffmpeg installed.
468
+ */
469
+ /**
470
+ * Encode everything under `raw` that has changed since the last run.
471
+ *
472
+ * Callers get the cache for free because it lives here rather than in either of
473
+ * them: `vitops media` and the Astro integration are equally incremental, since
474
+ * they call this same function.
475
+ */
476
+ async function processMedia(options) {
477
+ const rawDir = resolve(options.raw);
478
+ const outDir = resolve(options.out);
479
+ const manifestPath = resolve(options.manifest ?? ".vitops/media-manifest.json");
480
+ if (!existsSync(rawDir) || !statSync(rawDir).isDirectory()) throw new Error(`media: raw directory not found: ${rawDir}`);
481
+ if (rawDir === outDir) throw new Error(`media: --raw and --out are the same directory (${rawDir}). They must differ.`);
482
+ const config = resolveConfig(options.config);
483
+ const sources = await discoverVideos(rawDir);
484
+ const previous = readManifest(manifestPath);
485
+ const plan = planMedia({
486
+ sources,
487
+ ...options.config ? { config: options.config } : {},
488
+ ...previous ? { previous } : {},
489
+ ...options.force ? { force: options.force } : {},
490
+ probe: (file) => existsSync(join(outDir, file))
491
+ });
492
+ const pending = pendingJobs(plan);
493
+ const skipped = plan.jobs.length - pending.length;
494
+ if (options.dry) return {
495
+ plan,
496
+ written: [],
497
+ skipped
498
+ };
499
+ const entries = {};
500
+ for (const job of plan.jobs) {
501
+ const prev = previous?.entries[job.source.id];
502
+ if (job.skip && prev) entries[job.source.id] = prev;
503
+ }
504
+ if (pending.length > 0) await requireFfmpeg();
505
+ const written = [];
506
+ for (const job of pending) {
507
+ options.onProgress?.(`encoding ${job.source.id}`);
508
+ for (const out of job.outputs) {
509
+ const target = join(outDir, out.file);
510
+ mkdirSync(dirname(target), { recursive: true });
511
+ if (out.kind === "webm") await encodeWebm(job.source.path, target, config);
512
+ else if (out.kind === "mp4") await encodeMp4(job.source.path, target, config);
513
+ else await extractPoster(job.source.path, target, config);
514
+ written.push(out.file);
515
+ }
516
+ entries[job.source.id] = {
517
+ source: job.source.hash,
518
+ settings: job.settings,
519
+ outputs: job.outputs.map((o) => o.file)
520
+ };
521
+ writeManifest(manifestPath, toManifest(entries));
522
+ }
523
+ if (pending.length === 0 && (previous || Object.keys(entries).length > 0)) writeManifest(manifestPath, toManifest(entries));
524
+ return {
525
+ plan,
526
+ written,
527
+ skipped
528
+ };
529
+ }
530
+
531
+ //#endregion
532
+ export { DEFAULT_OUTPUTS, DISCOVER_SKIP, ENCODER_VERSION, MEDIA_MANIFEST_PATH, OUTPUT_EXT, VIDEO_EXT, discoverVideos, encodeMp4, encodeWebm, extractPoster, formatPlan, hashFile, pendingJobs, planMedia, processMedia, readManifest, requireFfmpeg, resolveConfig, settingsHash, toManifest, writeManifest };
533
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/media/discover.ts","../../src/media/ffmpeg.ts","../../src/media/manifest.ts","../../src/media/types.ts","../../src/media/plan.ts","../../src/media/index.ts"],"sourcesContent":["/**\n * Finding the source videos and fingerprinting them.\n *\n * Deliberately not `scanFiles` from `../scan.ts`, which reads every match into\n * memory as a string. That is right for templates and catastrophic for a video —\n * the files this walks are routinely tens or hundreds of megabytes, which is also\n * why the hash is streamed rather than taken over a `readFileSync`.\n */\nimport { createHash } from 'node:crypto';\nimport { createReadStream, readdirSync } from 'node:fs';\nimport { extname, join, relative, sep } from 'node:path';\nimport type { MediaSource } from './types.ts';\n\n/** Container formats worth accepting as input. */\nexport const VIDEO_EXT: ReadonlySet<string> = new Set([\n '.mp4',\n '.mov',\n '.m4v',\n '.mkv',\n '.avi',\n '.webm',\n '.mpg',\n '.mpeg',\n]);\n\n/** Directories that never hold hand-managed source assets. */\nexport const DISCOVER_SKIP: ReadonlySet<string> = new Set(['node_modules', 'dist', '.git']);\n\n/** sha256 of the file contents, streamed. Truncated — 16 hex chars is 64 bits. */\nexport function hashFile(path: string): Promise<string> {\n return new Promise((resolve, reject) => {\n const hash = createHash('sha256');\n const stream = createReadStream(path);\n stream.on('error', reject);\n stream.on('data', (chunk) => hash.update(chunk));\n stream.on('end', () => resolve(hash.digest('hex').slice(0, 16)));\n });\n}\n\nfunction walk(dir: string, acc: string[]): void {\n for (const e of readdirSync(dir, { withFileTypes: true })) {\n if (e.name.startsWith('.')) continue;\n const p = join(dir, e.name);\n if (e.isDirectory()) {\n if (!DISCOVER_SKIP.has(e.name)) walk(p, acc);\n } else if (VIDEO_EXT.has(extname(e.name).toLowerCase())) {\n acc.push(p);\n }\n }\n}\n\n/**\n * Every video under `dir`, with its content hash.\n *\n * Sorted by `id` so a plan — and therefore the manifest and the log — comes out in\n * the same order on every machine, regardless of what the filesystem hands back.\n */\nexport async function discoverVideos(dir: string): Promise<MediaSource[]> {\n const paths: string[] = [];\n walk(dir, paths);\n const sources = await Promise.all(\n paths.map(async (path) => ({\n path,\n id: relative(dir, path).split(sep).join('/'),\n hash: await hashFile(path),\n })),\n );\n return sources.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));\n}\n","/**\n * The encoder. Executes a job and decides nothing.\n *\n * Every invocation goes through `execFile` with an argv array rather than a shell\n * string: a source path containing a space or a quote is ordinary, and the\n * shell-interpolated form these commands are usually written in breaks on both.\n */\nimport { execFile } from 'node:child_process';\nimport { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { promisify } from 'node:util';\nimport type { ResolvedMediaConfig } from './plan.ts';\n\nconst exec = promisify(execFile);\n\n/** `-loglevel error` keeps this small; the ceiling is for a pathological failure. */\nconst MAX_BUFFER = 16 * 1024 * 1024;\n\n/**\n * x264 and VP9 use different CRF scales (0–51 against 0–63), so one `crf` knob has\n * to be mapped. 0.72 puts the default 32 at x264's own default of 23, which is the\n * pairing VP9's encoding guide uses for comparable quality.\n */\nconst X264_CRF_RATIO = 0.72;\n\nlet probe: Promise<void> | undefined;\n\n/**\n * Fail early and clearly when ffmpeg isn't installed.\n *\n * Deliberately not the `optimizePng` shape in `favicon.ts`, which swallows a\n * missing `oxipng` and warns once. That is right for a best-effort lossless crush,\n * where skipping costs a few kilobytes. It is wrong here: skipping produces no\n * output at all, and a build that carries on would ship the page referencing a\n * video that was never encoded.\n */\nexport function requireFfmpeg(): Promise<void> {\n probe ??= exec('ffmpeg', ['-version'], { maxBuffer: MAX_BUFFER }).then(\n () => undefined,\n () => {\n probe = undefined; // so a later call re-probes rather than caching a stale failure\n throw new Error(\n 'media: ffmpeg not found on PATH. It is an external tool, not an npm dependency — ' +\n 'install it (macOS: `brew install ffmpeg`, Debian/Ubuntu: `apt install ffmpeg`, ' +\n 'Windows: `winget install Gyan.FFmpeg`) and run this again.',\n );\n },\n );\n return probe;\n}\n\nasync function run(args: string[]): Promise<void> {\n try {\n await exec('ffmpeg', args, { maxBuffer: MAX_BUFFER });\n } catch (err) {\n const stderr = String((err as { stderr?: string }).stderr ?? '').trim();\n const tail = stderr.split('\\n').slice(-6).join('\\n');\n throw new Error(`media: ffmpeg failed${tail ? `\\n${tail}` : ''}`);\n }\n}\n\n/**\n * An encode that \"succeeds\" but writes nothing is the failure mode worth guarding:\n * seeking a poster past the end of a clip does exactly that, and ffmpeg exits 0.\n * Without this the manifest would record the job as done and never retry it.\n */\nfunction assertWritten(file: string, what: string): void {\n if (!existsSync(file) || statSync(file).size === 0)\n throw new Error(\n `media: ${what} produced no output (${file}). Check --poster-time and the source.`,\n );\n}\n\n/** Scale filter, capping width and keeping the height even (yuv420p requires it). */\nfunction scaleArgs(maxWidth: number): string[] {\n if (maxWidth <= 0) return [];\n // ffmpeg parses the quotes itself, so the commas inside min() aren't read as a\n // filter separator. There is no shell here to strip them.\n return ['-vf', `scale='min(${maxWidth},iw)':-2`];\n}\n\n/** `2M` → `4M`. A cap needs a buffer, and twice the rate is the usual choice. */\nfunction bufsize(maxBitrate: string): string {\n const m = /^(\\d+(?:\\.\\d+)?)([kKmM]?)$/.exec(maxBitrate.trim());\n if (!m)\n throw new Error(`media: maxBitrate \"${maxBitrate}\" is not a bitrate (try \"2M\" or \"800k\").`);\n return `${Number(m[1]) * 2}${m[2] ?? ''}`;\n}\n\nconst rateArgs = (maxBitrate: string | undefined): string[] =>\n maxBitrate ? ['-maxrate', maxBitrate, '-bufsize', bufsize(maxBitrate)] : [];\n\nconst audioArgs = (config: ResolvedMediaConfig, codec: string): string[] =>\n config.audio ? ['-c:a', codec, '-b:a', '128k'] : ['-an'];\n\nconst BASE = ['-y', '-nostdin', '-loglevel', 'error'];\n\n/**\n * VP9/WebM, two passes.\n *\n * The pass log goes into a fresh temp directory rather than the working directory.\n * ffmpeg's default (`ffmpeg2pass-0.log`, in cwd) makes cleanup cwd-dependent and\n * makes two concurrent encodes silently share one log — the second encode's rate\n * control then reads the first clip's statistics.\n */\nexport async function encodeWebm(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n const dir = mkdtempSync(join(tmpdir(), 'vitops-media-'));\n const passlog = join(dir, 'pass');\n const video = [\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libvpx-vp9',\n // -b:v 0 with -crf is VP9's constant-quality mode; with -maxrate it becomes\n // constrained quality. A fixed bitrate would be right at one resolution only.\n '-b:v',\n '0',\n '-crf',\n String(config.crf),\n '-row-mt',\n '1',\n ...rateArgs(config.maxBitrate),\n ];\n try {\n // Pass 1 only gathers statistics, so it never encodes audio and writes no file.\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n '-an',\n '-pass',\n '1',\n '-passlogfile',\n passlog,\n '-f',\n 'null',\n '-',\n ]);\n await run([\n ...BASE,\n '-i',\n input,\n ...video,\n ...audioArgs(config, 'libopus'),\n '-pass',\n '2',\n '-passlogfile',\n passlog,\n output,\n ]);\n } finally {\n rmSync(dir, { recursive: true, force: true });\n }\n assertWritten(output, 'webm encode');\n}\n\n/**\n * H.264/MP4 — the fallback that makes `<video>` work on older iOS and in the\n * in-app webviews that still don't decode VP9.\n *\n * `+faststart` moves the moov atom to the front. Without it the browser has to\n * download the whole file before it can show frame one, which on a hero video\n * looks exactly like the optimisation didn't work.\n */\nexport async function encodeMp4(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-c:v',\n 'libx264',\n '-preset',\n 'slow',\n '-crf',\n String(Math.round(config.crf * X264_CRF_RATIO)),\n // Required by Safari and by every hardware decoder worth naming.\n '-pix_fmt',\n 'yuv420p',\n '-movflags',\n '+faststart',\n ...rateArgs(config.maxBitrate),\n ...audioArgs(config, 'aac'),\n output,\n ]);\n assertWritten(output, 'mp4 encode');\n}\n\n/** The poster frame. `-ss` before `-i` so the seek is a fast one. */\nexport async function extractPoster(\n input: string,\n output: string,\n config: ResolvedMediaConfig,\n): Promise<void> {\n await run([\n ...BASE,\n ...(config.posterTime > 0 ? ['-ss', String(config.posterTime)] : []),\n '-i',\n input,\n ...scaleArgs(config.maxWidth),\n '-frames:v',\n '1',\n '-q:v',\n '2',\n output,\n ]);\n assertWritten(output, 'poster extraction');\n}\n","/**\n * The cache — what makes a second run free.\n *\n * A committed artifact, unlike `indexing/`'s snapshot: the recommended layout\n * checks in both the processed outputs and this file, so CI never needs ffmpeg\n * and the outputs only change when someone deliberately re-encodes. That is why\n * `writeManifest` sorts and carries no timestamp — an unchanged run has to produce\n * a byte-identical file, or every build shows up as a diff.\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { MediaManifest, MediaManifestEntry } from './types.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const MEDIA_MANIFEST_PATH = '.vitops/media-manifest.json';\n\n/**\n * Read the manifest, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, which re-encodes everything.\n * The opposite reading — treating it as an empty but valid manifest — is the same\n * shape of bug `readSnapshot` avoids, except here it would be silent: nothing\n * would encode, and the build would reference outputs that were never written.\n */\nexport function readManifest(path: string): MediaManifest | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as MediaManifest;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Build a manifest with its keys and output lists in a stable order. */\nexport function toManifest(entries: Record<string, MediaManifestEntry>): MediaManifest {\n const sorted: Record<string, MediaManifestEntry> = {};\n for (const key of Object.keys(entries).sort()) {\n const entry = entries[key] as MediaManifestEntry;\n sorted[key] = { ...entry, outputs: [...entry.outputs].sort() };\n }\n return { version: 1, entries: sorted };\n}\n\n/**\n * Write the manifest, creating its directory.\n *\n * Record a job only once its outputs are on disk. Writing eagerly would mark a\n * source as encoded that isn't, and because the next run trusts this file, the\n * result is a missing video that no rebuild ever fixes.\n */\nexport function writeManifest(path: string, manifest: MediaManifest): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\\n`);\n}\n","/**\n * The media module's own option surface.\n *\n * Everything here describes *how to encode*, never *what the site is* — which is\n * why there is no `Config` block behind it. `legal`, `icons` and `indexing`\n * are anchored to a site config because what they emit describes the site; an\n * encoder setting describes a build step.\n */\n\n/** What a source video is turned into. */\nexport type OutputKind = 'webm' | 'mp4' | 'poster';\n\n/** The default set: a modern codec, a universal fallback, and a poster frame. */\nexport const DEFAULT_OUTPUTS: OutputKind[] = ['webm', 'mp4', 'poster'];\n\n/** File extension per output kind. */\nexport const OUTPUT_EXT: Record<OutputKind, string> = {\n webm: '.webm',\n mp4: '.mp4',\n poster: '.jpg',\n};\n\n/**\n * Bumped when the ffmpeg arguments in `ffmpeg.ts` change in a way that alters the\n * output bytes. It is part of the settings hash, so bumping it re-encodes every\n * source on the next run.\n *\n * Bump it deliberately and rarely. Consumers commit their processed outputs (see\n * the README), so every bump is a large binary diff in someone's repository —\n * that cost is worth paying for a real encoder fix and not for a tidy-up.\n */\nexport const ENCODER_VERSION = 1;\n\n/** How to encode. Every field here feeds the settings hash. */\nexport interface MediaConfig {\n /**\n * Cap the output width in pixels, preserving aspect ratio. Default 1920.\n *\n * A source larger than its display size is the usual reason a hero video is\n * enormous, so this is on by default rather than opt-in. `0` disables scaling.\n */\n maxWidth?: number | undefined;\n /**\n * Constant-quality level, on **VP9's CRF scale (0–63, lower is better)**.\n * Default 32, which is the range VP9's own guidance recommends for 1080p.\n *\n * H.264 uses a different scale (0–51), so the MP4 fallback is encoded at\n * `round(crf * 0.72)` — 23 at the default, x264's own default. One knob, because\n * two would drift apart and nobody would know which one mattered.\n */\n crf?: number | undefined;\n /**\n * Optional ceiling, e.g. `'2M'`. Absent by default: a bitrate cap makes the\n * encode constrained-quality rather than constant-quality, which is right when\n * you are protecting a data budget and wrong as a default, because the correct\n * cap depends on the resolution you are capping.\n */\n maxBitrate?: string | undefined;\n /**\n * Keep the audio track. Default false — the common case is a muted autoplay\n * loop, where the audio stream is bytes nobody will ever hear.\n */\n audio?: boolean | undefined;\n /**\n * Timestamp (seconds) the poster frame is taken from. Default 0.\n *\n * Frame 0 is often black on a clip that fades in, which is worth knowing before\n * concluding the poster is broken.\n */\n posterTime?: number | undefined;\n /** Which outputs to produce. Default `DEFAULT_OUTPUTS`. */\n outputs?: OutputKind[] | undefined;\n}\n\n/** A discovered source video. */\nexport interface MediaSource {\n /** Absolute path on disk. */\n path: string;\n /**\n * Path relative to the raw directory, POSIX-separated — the manifest key.\n * Relative so a manifest stays valid when the project moves or is cloned\n * somewhere else, which is the whole point of committing it.\n */\n id: string;\n /** sha256 of the file contents, truncated. */\n hash: string;\n}\n\n/** One file an encode will write. */\nexport interface MediaOutput {\n kind: OutputKind;\n /** Path relative to the output directory, POSIX-separated. */\n file: string;\n}\n\n/** One source and everything decided about it. */\nexport interface MediaJob {\n source: MediaSource;\n outputs: MediaOutput[];\n /** Hash of the settings these outputs were produced under. */\n settings: string;\n /** Why this job needs no work. Present iff it is being skipped. */\n skip?: string | undefined;\n}\n\n/** The complete account of a run, decided before any of it happens. */\nexport interface MediaPlan {\n jobs: MediaJob[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\n/** One manifest record: what these outputs were made from, and under what settings. */\nexport interface MediaManifestEntry {\n /** `MediaSource.hash` at the time of encoding. */\n source: string;\n /** Settings hash at the time of encoding. */\n settings: string;\n /** Output paths relative to the output directory. */\n outputs: string[];\n}\n\n/**\n * The cache. A committed artifact, so it carries no timestamp and its keys are\n * sorted — a rebuild of unchanged sources must produce a byte-identical file, or\n * it shows up as noise in every diff.\n */\nexport interface MediaManifest {\n version: 1;\n entries: Record<string, MediaManifestEntry>;\n}\n","/**\n * Every decision `vitops media` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fs`, no clock, no subprocess. Whether a\n * source needs re-encoding — the only thing here that can be wrong in a way that\n * costs minutes or ships a stale video — is decided from data passed in, and\n * asserted in `plan.test.ts` on a machine with no ffmpeg installed.\n *\n * That split is the same one `indexing/plan.ts` makes against its I/O modules, and\n * for the same reason: `--dry` should be a complete account of a run rather than\n * an approximation of one.\n */\nimport { createHash } from 'node:crypto';\nimport {\n DEFAULT_OUTPUTS,\n ENCODER_VERSION,\n type MediaConfig,\n type MediaJob,\n type MediaManifest,\n type MediaOutput,\n type MediaPlan,\n type MediaSource,\n OUTPUT_EXT,\n type OutputKind,\n} from './types.ts';\n\n/** Defaults, resolved once so the settings hash is computed over real values. */\nexport interface ResolvedMediaConfig {\n maxWidth: number;\n crf: number;\n maxBitrate: string | undefined;\n audio: boolean;\n posterTime: number;\n outputs: OutputKind[];\n}\n\n/**\n * Injected existence probe — `plan.ts` touches no filesystem itself.\n *\n * Takes a path relative to the output directory. The same injection shape as\n * `collectEntries(source, read)` in `indexing/sitemap.ts`.\n */\nexport type OutputProbe = (relativePath: string) => boolean;\n\nexport interface MediaPlanInput {\n sources: MediaSource[];\n config?: MediaConfig | undefined;\n /** The previous run's manifest, if there is a usable one. */\n previous?: MediaManifest | undefined;\n /** Whether an output file is already on disk. Defaults to \"nothing exists\". */\n probe?: OutputProbe | undefined;\n /** `--force`: re-encode regardless of the cache. */\n force?: boolean | undefined;\n}\n\nconst sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16);\n\n/** Fill in defaults. Exported because the encoder and the hash must agree on them. */\nexport function resolveConfig(config: MediaConfig = {}): ResolvedMediaConfig {\n return {\n maxWidth: config.maxWidth ?? 1920,\n crf: config.crf ?? 32,\n maxBitrate: config.maxBitrate,\n audio: config.audio ?? false,\n posterTime: config.posterTime ?? 0,\n outputs: config.outputs ?? DEFAULT_OUTPUTS,\n };\n}\n\n/**\n * Hash the settings that decide the output bytes.\n *\n * Order-independent in the one place it could bite (`outputs` is sorted), so\n * re-ordering the list in a config doesn't re-encode a whole library.\n */\nexport function settingsHash(config: ResolvedMediaConfig): string {\n return sha(\n JSON.stringify({\n v: ENCODER_VERSION,\n maxWidth: config.maxWidth,\n crf: config.crf,\n maxBitrate: config.maxBitrate ?? null,\n audio: config.audio,\n posterTime: config.posterTime,\n outputs: [...config.outputs].sort(),\n }),\n );\n}\n\n/** `a/b/clip.mp4` → `a/b/clip` */\nfunction stem(id: string): string {\n const dot = id.lastIndexOf('.');\n const slash = id.lastIndexOf('/');\n return dot > slash ? id.slice(0, dot) : id;\n}\n\nfunction outputsFor(source: MediaSource, config: ResolvedMediaConfig): MediaOutput[] {\n const base = stem(source.id);\n return config.outputs.map((kind) => ({ kind, file: `${base}${OUTPUT_EXT[kind]}` }));\n}\n\n/**\n * Decide what to encode.\n *\n * A job is skipped only when all three hold: the source contents hash the same as\n * last time, the settings hash the same, and every declared output is still on\n * disk. Anything else re-encodes — including an absent or unreadable manifest,\n * which reads as \"encode everything\" and never as \"everything is done\".\n *\n * Throws on an output collision (`hero.mp4` and `hero.mov` in one directory both\n * want `hero.webm`). That is a layout mistake rather than a runtime condition, and\n * the failure it would otherwise cause — two sources overwriting each other every\n * run, each invalidating the other's cache entry — is invisible from the outside.\n */\nexport function planMedia(input: MediaPlanInput): MediaPlan {\n const config = resolveConfig(input.config);\n const settings = settingsHash(config);\n const probe = input.probe ?? (() => false);\n const notes: string[] = [];\n\n const jobs: MediaJob[] = [];\n const claimed = new Map<string, string>();\n\n for (const source of input.sources) {\n const outputs = outputsFor(source, config);\n for (const out of outputs) {\n const owner = claimed.get(out.file);\n if (owner !== undefined && owner !== source.id)\n throw new Error(\n `media: \"${source.id}\" and \"${owner}\" both produce \"${out.file}\". ` +\n 'Two sources with the same name in one directory overwrite each other — rename one.',\n );\n claimed.set(out.file, source.id);\n }\n\n const prev = input.previous?.entries[source.id];\n const cached =\n !input.force &&\n prev !== undefined &&\n prev.source === source.hash &&\n prev.settings === settings &&\n outputs.every((o) => probe(o.file));\n\n jobs.push({ source, outputs, settings, ...(cached ? { skip: 'unchanged' } : {}) });\n }\n\n const pending = jobs.filter((j) => !j.skip).length;\n if (input.sources.length === 0) notes.push('no video files found');\n else if (input.force && pending > 0) notes.push(`--force: re-encoding all ${pending}`);\n else if (!input.previous && pending > 0)\n notes.push('no manifest — encoding everything (a first run, or a cache that was not restored)');\n\n return { jobs, notes };\n}\n\n/** The jobs that will actually run. */\nexport const pendingJobs = (plan: MediaPlan): MediaJob[] => plan.jobs.filter((j) => !j.skip);\n\n/** Human-readable account of a plan, for `--dry` and for the run summary. */\nexport function formatPlan(plan: MediaPlan): string {\n const lines: string[] = [];\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n\n lines.push(`${plan.jobs.length} source${plan.jobs.length === 1 ? '' : 's'}`);\n lines.push(` encode: ${pending.length}`);\n lines.push(` skipped: ${skipped} (unchanged)`);\n\n for (const job of pending)\n lines.push(` • ${job.source.id} → ${job.outputs.map((o) => o.file).join(', ')}`);\n\n for (const note of plan.notes) lines.push(` ! ${note}`);\n return lines.join('\\n');\n}\n","/**\n * `vitops media` — turning a directory of raw video into web-ready assets.\n *\n * A separate subpath (`@getvitops/utils/media`) rather than part of the package\n * index, matching `./favicon` and `./indexing`: this module shells out to an\n * external encoder, and a consumer importing the content helpers shouldn't carry\n * it.\n *\n * The layering is the point, and it is the same one `indexing/` makes. `plan.ts`\n * decides everything and touches nothing; `ffmpeg.ts` executes and decides\n * nothing; `discover.ts` and `manifest.ts` are the filesystem either side. That is\n * what lets `--dry` be a complete account of a run, and what lets the cache — the\n * part with real consequences, since getting it wrong means either minutes of\n * needless encoding or a stale video shipped — be tested with no ffmpeg installed.\n */\nimport { existsSync, mkdirSync, statSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { discoverVideos } from './discover.ts';\nimport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nimport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nimport { pendingJobs, planMedia, resolveConfig } from './plan.ts';\nimport type { MediaConfig, MediaManifestEntry, MediaPlan } from './types.ts';\n\nexport interface ProcessMediaOptions {\n /** Directory of unprocessed sources. Walked recursively. */\n raw: string;\n /** Where the encoded outputs go. Structure under `raw` is preserved. */\n out: string;\n config?: MediaConfig | undefined;\n /** Cache location. Default `.vitops/media-manifest.json`. */\n manifest?: string | undefined;\n /** Re-encode regardless of the cache. */\n force?: boolean | undefined;\n /** Decide everything, run nothing. */\n dry?: boolean | undefined;\n /** Called before each source is encoded — these take minutes, so say so. */\n onProgress?: ((message: string) => void) | undefined;\n}\n\nexport interface ProcessMediaResult {\n plan: MediaPlan;\n /** Outputs written this run, relative to `out`. */\n written: string[];\n /** Sources that needed no work. */\n skipped: number;\n}\n\n/**\n * Encode everything under `raw` that has changed since the last run.\n *\n * Callers get the cache for free because it lives here rather than in either of\n * them: `vitops media` and the Astro integration are equally incremental, since\n * they call this same function.\n */\nexport async function processMedia(options: ProcessMediaOptions): Promise<ProcessMediaResult> {\n const rawDir = resolve(options.raw);\n const outDir = resolve(options.out);\n const manifestPath = resolve(options.manifest ?? MEDIA_MANIFEST_PATH);\n\n if (!existsSync(rawDir) || !statSync(rawDir).isDirectory())\n throw new Error(`media: raw directory not found: ${rawDir}`);\n // Sharing one directory would make each run's outputs the next run's inputs —\n // re-encoding a re-encode, forever, losing quality every time.\n if (rawDir === outDir)\n throw new Error(`media: --raw and --out are the same directory (${rawDir}). They must differ.`);\n\n const config = resolveConfig(options.config);\n const sources = await discoverVideos(rawDir);\n const previous = readManifest(manifestPath);\n const plan = planMedia({\n sources,\n ...(options.config ? { config: options.config } : {}),\n ...(previous ? { previous } : {}),\n ...(options.force ? { force: options.force } : {}),\n probe: (file) => existsSync(join(outDir, file)),\n });\n\n const pending = pendingJobs(plan);\n const skipped = plan.jobs.length - pending.length;\n if (options.dry) return { plan, written: [], skipped };\n\n // Carry forward what is still valid, so the manifest on disk is a truthful\n // account at every moment — including after a crash halfway through.\n const entries: Record<string, MediaManifestEntry> = {};\n for (const job of plan.jobs) {\n const prev = previous?.entries[job.source.id];\n if (job.skip && prev) entries[job.source.id] = prev;\n }\n\n if (pending.length > 0) await requireFfmpeg();\n\n const written: string[] = [];\n for (const job of pending) {\n options.onProgress?.(`encoding ${job.source.id}`);\n for (const out of job.outputs) {\n const target = join(outDir, out.file);\n mkdirSync(dirname(target), { recursive: true });\n if (out.kind === 'webm') await encodeWebm(job.source.path, target, config);\n else if (out.kind === 'mp4') await encodeMp4(job.source.path, target, config);\n else await extractPoster(job.source.path, target, config);\n written.push(out.file);\n }\n entries[job.source.id] = {\n source: job.source.hash,\n settings: job.settings,\n outputs: job.outputs.map((o) => o.file),\n };\n writeManifest(manifestPath, toManifest(entries));\n }\n\n // Also write when nothing encoded, which is what prunes entries for sources\n // that have since been deleted. Skipped when there is nothing to record either\n // way, so an empty raw directory doesn't create a manifest.\n if (pending.length === 0 && (previous || Object.keys(entries).length > 0))\n writeManifest(manifestPath, toManifest(entries));\n\n return { plan, written, skipped };\n}\n\nexport { DISCOVER_SKIP, VIDEO_EXT, discoverVideos, hashFile } from './discover.ts';\nexport { encodeMp4, encodeWebm, extractPoster, requireFfmpeg } from './ffmpeg.ts';\nexport { MEDIA_MANIFEST_PATH, readManifest, toManifest, writeManifest } from './manifest.ts';\nexport { formatPlan, pendingJobs, planMedia, resolveConfig, settingsHash } from './plan.ts';\nexport type { MediaPlanInput, OutputProbe, ResolvedMediaConfig } from './plan.ts';\nexport { DEFAULT_OUTPUTS, ENCODER_VERSION, OUTPUT_EXT } from './types.ts';\nexport type {\n MediaConfig,\n MediaJob,\n MediaManifest,\n MediaManifestEntry,\n MediaOutput,\n MediaPlan,\n MediaSource,\n OutputKind,\n} from './types.ts';\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/utils",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -21,6 +21,14 @@
21
21
  "./color": {
22
22
  "types": "./dist/color/index.d.mts",
23
23
  "import": "./dist/color/index.mjs"
24
+ },
25
+ "./indexing": {
26
+ "types": "./dist/indexing/index.d.mts",
27
+ "import": "./dist/indexing/index.mjs"
28
+ },
29
+ "./media": {
30
+ "types": "./dist/media/index.d.mts",
31
+ "import": "./dist/media/index.mjs"
24
32
  }
25
33
  },
26
34
  "publishConfig": {