@higgsfield/video-editor-mcp 1.0.2 → 1.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.
@@ -34,9 +34,13 @@ import {
34
34
  import "./chunk-2VKQHJMM.mjs";
35
35
  import "./chunk-75WO2NOF.mjs";
36
36
 
37
+ import { renderCapabilities, validateRenderRequest } from "./export-settings.mjs";
38
+ import { MEDIA_UPLOAD_TEMP_PREFIX, MediaUploads, listMediaFiles } from "./media-upload.mjs";
39
+
37
40
  // src/dev.ts
38
41
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, statSync as statSync3, watch, writeFileSync as writeFileSync2 } from "node:fs";
39
42
  import { extname, join as join4 } from "node:path";
43
+ import { linkSync, lstatSync, mkdtempSync, rmSync as removeExportStage } from "node:fs";
40
44
 
41
45
  // src/editor-build.ts
42
46
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
@@ -158,132 +162,7 @@ var EditHistory = class {
158
162
  }
159
163
  };
160
164
 
161
- // src/proxy.ts
162
- import { execFile } from "node:child_process";
163
- import { createHash } from "node:crypto";
164
- import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, renameSync as renameSync2, rmSync, statSync as statSync2 } from "node:fs";
165
- import { join as join3 } from "node:path";
166
- import { promisify } from "node:util";
167
- var run = promisify(execFile);
168
- var VIDEO = /\.(mp4|mov|m4v|mkv|webm)$/i;
169
- var SKIP = /(^|\/)(node_modules|\.git|\.higgsedit-cache|renders|dist)(\/|$)/;
170
- var LONG_SIDE = 1920;
171
- var DECODABLE = /* @__PURE__ */ new Set(["h264", "hevc", "vp9", "av1"]);
172
- var needsProxy = (p) => p.width * p.height > LONG_SIDE * 1088 || !DECODABLE.has(p.codec);
173
- var even = (n) => Math.max(2, Math.round(n / 2) * 2);
174
- var Proxies = class {
175
- constructor(dir, onReady, log = () => {
176
- }) {
177
- this.dir = dir;
178
- this.onReady = onReady;
179
- this.log = log;
180
- this.root = join3(dir, ".higgsedit-cache", "proxies");
181
- }
182
- dir;
183
- onReady;
184
- log;
185
- root;
186
- queue = [];
187
- pending = /* @__PURE__ */ new Set();
188
- current;
189
- stopped = false;
190
- key(rel) {
191
- try {
192
- const st = statSync2(join3(this.dir, rel));
193
- return createHash("sha1").update(`${rel}\0${st.size}\0${st.mtimeMs}`).digest("hex").slice(0, 20);
194
- } catch {
195
- return void 0;
196
- }
197
- }
198
- /** The finished proxy for a project-relative video path, if there is one. */
199
- lookup(rel) {
200
- const key = this.key(rel);
201
- const file = key && join3(this.root, `${key}.mp4`);
202
- return file && existsSync2(file) ? file : void 0;
203
- }
204
- /** Queues every video in the project. */
205
- scan(sub = "") {
206
- let entries;
207
- try {
208
- entries = readdirSync2(join3(this.dir, sub), { withFileTypes: true });
209
- } catch {
210
- return;
211
- }
212
- for (const e of entries) {
213
- const rel = sub ? `${sub}/${e.name}` : e.name;
214
- if (e.name.startsWith(".") || SKIP.test(rel)) continue;
215
- if (e.isDirectory()) this.scan(rel);
216
- else this.consider(rel);
217
- }
218
- }
219
- /** Queues `rel` if it is a video that needs a proxy and has none yet. */
220
- consider(rel) {
221
- if (this.stopped || !VIDEO.test(rel) || SKIP.test(rel) || this.pending.has(rel) || this.lookup(rel)) return;
222
- this.pending.add(rel);
223
- this.queue.push(rel);
224
- if (this.queue.length === 1 && !this.current) void this.drain();
225
- }
226
- async drain() {
227
- while (!this.stopped && this.queue.length) {
228
- const rel = this.queue[0];
229
- try {
230
- await this.make(rel);
231
- } catch (error) {
232
- this.log(`proxy for ${rel} failed: ${error instanceof Error ? error.message : String(error)}`);
233
- }
234
- this.queue.shift();
235
- this.pending.delete(rel);
236
- }
237
- }
238
- async make(rel) {
239
- const file = join3(this.dir, rel);
240
- for (let size = -1; ; ) {
241
- const now = statSync2(file).size;
242
- if (now === size) break;
243
- size = now;
244
- await new Promise((r) => setTimeout(r, 400));
245
- }
246
- if (this.lookup(rel)) return;
247
- const facts = await probe(file);
248
- if (!needsProxy(facts)) return;
249
- const { stdout } = await run(requireTool("ffprobe"), ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=time_base", "-of", "csv=p=0", file]);
250
- const timescale = Number(stdout.trim().split("/")[1]) || 9e4;
251
- const scale = LONG_SIDE / Math.max(facts.width, facts.height);
252
- const [w, h] = scale < 1 ? [even(facts.width * scale), even(facts.height * scale)] : [even(facts.width), even(facts.height)];
253
- const key = this.key(rel);
254
- mkdirSync(this.root, { recursive: true });
255
- const out = join3(this.root, `${key}.mp4`);
256
- const part = join3(this.root, `${key}.part.mp4`);
257
- const t0 = performance.now();
258
- this.log(`making a preview proxy for ${rel} (${facts.width}\xD7${facts.height} \u2192 ${w}\xD7${h})\u2026`);
259
- const args = ["-y", "-i", file, "-map", "0:v:0", "-map", "0:a:0?", "-vf", `scale=${w}:${h}`, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-bf", "0", "-g", "12", "-pix_fmt", "yuv420p"];
260
- args.push("-fps_mode", "passthrough", "-video_track_timescale", String(timescale), "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", part);
261
- await new Promise((resolve, reject) => {
262
- const p = ffmpeg(args);
263
- this.current = p;
264
- let err = "";
265
- p.stderr.on("data", (d) => err += d.toString());
266
- p.on("error", reject);
267
- p.on("close", (code) => {
268
- this.current = void 0;
269
- if (code === 0) resolve();
270
- else reject(new Error(err.trim() || `ffmpeg exited with ${code}`));
271
- });
272
- }).catch((error) => {
273
- rmSync(part, { force: true });
274
- throw error;
275
- });
276
- if (this.stopped) return rmSync(part, { force: true });
277
- renameSync2(part, out);
278
- this.log(`proxy for ${rel} ready in ${((performance.now() - t0) / 1e3).toFixed(1)} s`);
279
- this.onReady(`./${rel}`);
280
- }
281
- stop() {
282
- this.stopped = true;
283
- this.queue.length = 0;
284
- this.current?.kill("SIGKILL");
285
- }
286
- };
165
+ import { Proxies } from "./proxy.mjs";
287
166
 
288
167
  // src/dev.ts
289
168
  var escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
@@ -302,7 +181,8 @@ ${entry.styles.map((s) => `<link rel="stylesheet" href="/__higgsedit/editor/${s}
302
181
  <script type="module" src="/__higgsedit/editor/${entry.script}"></script>
303
182
  </body>
304
183
  </html>`;
305
- async function startDev(dir, port = 0) {
184
+ async function startDev(dir, port = 0, options = {}) {
185
+ const mediaUploads = new MediaUploads(dir, options.mediaUploads);
306
186
  const clients = /* @__PURE__ */ new Set();
307
187
  const known = /* @__PURE__ */ new Map();
308
188
  for (const f of sourceFiles(dir)) known.set(f, readFileSync3(join4(dir, f), "utf8"));
@@ -347,19 +227,45 @@ async function startDev(dir, port = 0) {
347
227
  if (changed.length) broadcast({ type: "change", files: changed, origin, detectedAt: firstSeen, mtime, undo: history.state() });
348
228
  };
349
229
  const proxies = new Proxies(dir, (src) => broadcast({ type: "proxy", src }), (line) => console.log(line));
350
- proxies.scan();
351
230
  const watcher = watch(dir, { recursive: true }, (_event, name) => {
352
231
  const file = (name ?? "").split("\\").join("/");
353
232
  if (/(^|\/)(node_modules|\.git|\.higgsedit-cache|renders)(\/|$)/.test(file)) return;
233
+ if (file.split("/").some((part) => part.startsWith(MEDIA_UPLOAD_TEMP_PREFIX))) return;
354
234
  proxies.consider(file);
235
+ if (file === "media" || file.startsWith("media/")) return;
355
236
  if (!timer) firstSeen = Date.now();
356
237
  if (!timer) timer = setTimeout(flush, 15);
357
238
  });
358
239
  const jobs = /* @__PURE__ */ new Map();
359
240
  let jobSeq = 0;
241
+ let stopping = false;
242
+ let mutations = 0;
243
+ let stopped;
244
+ const busy = () => mediaUploads.busy || mutations > 0 || [...jobs.values()].some((job) => job.state === "running");
360
245
  const server = await startServer(
361
246
  dir,
362
247
  [
248
+ (req, res, url) => {
249
+ if (stopping) { res.setHeader("retry-after", "1"); json(res, 503, { error: "editor is stopping" }); return true; }
250
+ if (url.pathname === "/api/idle-stop" && req.method === "POST") {
251
+ if (busy()) { json(res, 409, { busy: true }); return true; }
252
+ stopping = true;
253
+ res.once("finish", () => { options.onIdleStop?.(); void stop(); });
254
+ json(res, 200, { stopping: true }); return true;
255
+ }
256
+ if (req.method !== "GET" && req.method !== "HEAD") {
257
+ mutations++;
258
+ let released = false;
259
+ const release = () => { if (!released) { released = true; mutations--; } };
260
+ res.once("finish", release); res.once("close", release);
261
+ }
262
+ return false;
263
+ },
264
+ (req, res, url) => mediaUploads.handle(req, res, url),
265
+ (req, res, url) => {
266
+ if (url.pathname !== "/api/activity" || req.method !== "GET") return false;
267
+ json(res, 200, { busy: busy() }); return true;
268
+ },
363
269
  async (req, res, url) => {
364
270
  if (url.pathname !== "/" && url.pathname !== "/index.html") return false;
365
271
  const entry = await editorEntry((line) => console.log(line));
@@ -413,39 +319,93 @@ async function startDev(dir, port = 0) {
413
319
  },
414
320
  async (req, res, url) => {
415
321
  if (!url.pathname.startsWith("/api/render")) return false;
322
+ if (url.pathname === "/api/render/capabilities" && req.method === "GET") {
323
+ json(res, 200, await renderCapabilities());
324
+ return true;
325
+ }
416
326
  if (url.pathname === "/api/render" && req.method === "POST") {
327
+ let body;
328
+ try {
329
+ body = validateRenderRequest(JSON.parse((await readBody(req)).toString() || "{}"), await renderCapabilities());
330
+ } catch (error) {
331
+ json(res, 400, { error: error.message });
332
+ return true;
333
+ }
417
334
  if ([...jobs.values()].some((j) => j.state === "running")) {
418
335
  json(res, 409, { error: "a render is already running" });
419
336
  return true;
420
337
  }
421
- const body = JSON.parse((await readBody(req)).toString() || "{}");
422
- const name = (body.fileName ?? "render.mp4").replace(/[/\\]/g, "_");
423
- const out = join4(dir, "renders", name.endsWith(".mp4") ? name : `${name}.mp4`);
424
- mkdirSync2(join4(dir, "renders"), { recursive: true });
338
+ const requested = (body.fileName ?? "render.mp4").replace(/[/\\]/g, "_");
339
+ const name = requested.endsWith(".mp4") ? requested : `${requested}.mp4`;
340
+ const renders = join4(dir, "renders");
341
+ mkdirSync2(renders, { recursive: true });
342
+ const directory = lstatSync(renders);
343
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
344
+ json(res, 400, { error: "renders must be a project directory, not a symbolic link" });
345
+ return true;
346
+ }
347
+ const stage = mkdtempSync(join4(renders, ".export-"));
348
+ const stageIdentity = lstatSync(stage);
349
+ const out = join4(stage, "output.mp4");
425
350
  const id2 = String(++jobSeq);
426
- const job2 = { state: "running", done: 0, total: 0, out: `renders/${out.split("/").at(-1)}`, abort: new AbortController() };
351
+ const job2 = { state: "running", done: 0, total: 0, abort: new AbortController() };
427
352
  jobs.set(id2, job2);
428
- render(dir, {
429
- out,
430
- depth: body.depth ?? 8,
431
- allowFontFallback: body.allowFontFallback === true,
432
- ...body.from ? { from: body.from } : {},
433
- ...body.to ? { to: body.to } : {},
434
- signal: job2.abort.signal,
435
- onProgress: (done, total) => {
436
- job2.done = done;
437
- job2.total = total;
438
- }
439
- }).then(
440
- (report) => {
353
+ void (async () => {
354
+ try {
355
+ const report = await render(dir, {
356
+ out,
357
+ depth: body.depth ?? 8,
358
+ allowFontFallback: body.allowFontFallback === true,
359
+ ...body.from !== undefined ? { from: body.from } : {},
360
+ ...body.to !== undefined ? { to: body.to } : {},
361
+ ...body.spec ? { spec: body.spec } : {},
362
+ ...body.sceneId !== undefined ? { sceneId: body.sceneId } : {},
363
+ signal: job2.abort.signal,
364
+ onProgress: (done, total) => {
365
+ job2.done = done;
366
+ job2.total = total;
367
+ }
368
+ });
369
+ // Cancellation during remux/closing the capture page must not publish a result.
370
+ if (job2.abort.signal.aborted) throw Object.assign(new Error("render cancelled"), { name: "RenderCancelledError" });
371
+ const current = lstatSync(renders);
372
+ if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== directory.dev || current.ino !== directory.ino) throw new Error("renders directory changed during export");
373
+ const output = lstatSync(out);
374
+ if (!output.isFile() || output.isSymbolicLink() || output.size === 0) throw new Error("render did not produce a complete file");
375
+ const occupied = new Set(readdirSync3(renders));
376
+ for (let n = 1; ; n++) {
377
+ const candidate = n === 1 ? name : `${name.slice(0, -4)}-${n}.mp4`;
378
+ if (occupied.has(candidate)) continue;
379
+ try { linkSync(out, join4(renders, candidate)); }
380
+ catch (error) {
381
+ if (error.code === "EEXIST") continue;
382
+ if (!["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "ENOSYS"].includes(error.code)) throw error;
383
+ // Some removable/network volumes do not support hard links. A newly reserved
384
+ // directory gives rename an exclusively owned, nonexistent destination there too.
385
+ const reserved = mkdtempSync(join4(renders, `${name.slice(0, -4)}-`));
386
+ try { renameSync(out, join4(reserved, name)); }
387
+ catch (error) { removeExportStage(reserved, { recursive: true, force: true }); throw error; }
388
+ job2.out = `renders/${reserved.slice(renders.length + 1)}/${name}`;
389
+ break;
390
+ }
391
+ job2.out = `renders/${candidate}`;
392
+ break;
393
+ }
441
394
  job2.state = "done";
442
395
  job2.msPerFrame = report.msPerFrame;
443
- },
444
- (error) => {
445
- job2.state = error.name === "RenderCancelledError" ? "cancelled" : "failed";
396
+ } catch (error) {
397
+ job2.state = job2.abort.signal.aborted || error.name === "RenderCancelledError" ? "cancelled" : "failed";
446
398
  job2.error = error.message;
399
+ } finally {
400
+ // Never delete a published file, or a directory substituted for this job's staging area.
401
+ try {
402
+ const current = lstatSync(stage);
403
+ if (current.dev === stageIdentity.dev && current.ino === stageIdentity.ino && !current.isSymbolicLink()) removeExportStage(stage, { recursive: true, force: true });
404
+ } catch (error) {
405
+ if (error.code !== "ENOENT") console.warn("Could not remove export staging directory:", error.message);
406
+ }
447
407
  }
448
- );
408
+ })();
449
409
  json(res, 200, { id: id2 });
450
410
  return true;
451
411
  }
@@ -455,7 +415,7 @@ async function startDev(dir, port = 0) {
455
415
  json(res, 404, { error: `no render ${id}` });
456
416
  return true;
457
417
  }
458
- if (url.pathname === "/api/render/cancel") job.abort.abort();
418
+ if (url.pathname === "/api/render/cancel" && job.state === "running") job.abort.abort();
459
419
  const { abort: _abort, ...status } = job;
460
420
  void _abort;
461
421
  json(res, 200, status);
@@ -470,6 +430,7 @@ async function startDev(dir, port = 0) {
470
430
  res.end();
471
431
  return true;
472
432
  }
433
+ proxies.consider(rel, true);
473
434
  const proxy = proxies.lookup(rel);
474
435
  if (proxy) res.setHeader("x-higgsedit-proxy", "1");
475
436
  sendFile(req, res, proxy ?? file);
@@ -484,23 +445,7 @@ async function startDev(dir, port = 0) {
484
445
  async (req, res, url) => {
485
446
  if (url.pathname !== "/api/media") return false;
486
447
  const media = join4(dir, "media");
487
- if (req.method === "POST") {
488
- const name = url.searchParams.get("name") ?? "";
489
- if (!/^[^/\\]+$/.test(name) || name.startsWith(".")) {
490
- json(res, 400, { error: `bad media name "${name}"` });
491
- return true;
492
- }
493
- mkdirSync2(media, { recursive: true });
494
- const ext = extname(name);
495
- const stem = name.slice(0, name.length - ext.length);
496
- let file = name;
497
- for (let n = 2; existsSync3(join4(media, file)); n++) file = `${stem}-${n}${ext}`;
498
- const bytes = await readBody(req);
499
- writeFileSync2(join4(media, file), bytes);
500
- json(res, 200, { src: `./media/${file}`, byteSize: bytes.length });
501
- return true;
502
- }
503
- const files = existsSync3(media) ? readdirSync3(media, { withFileTypes: true }).filter((e) => e.isFile() && !e.name.startsWith(".")).map((e) => ({ src: `./media/${e.name}`, byteSize: statSync3(join4(media, e.name)).size })).toSorted((a, b) => a.src.localeCompare(b.src)) : [];
448
+ const files = listMediaFiles(media);
504
449
  json(res, 200, { files });
505
450
  return true;
506
451
  },
@@ -515,21 +460,30 @@ async function startDev(dir, port = 0) {
515
460
  ],
516
461
  port
517
462
  );
518
- return {
519
- ...server,
520
- async stop() {
463
+ function stop() {
464
+ stopping = true;
465
+ return stopped ??= (async () => {
466
+ clearTimeout(timer);
521
467
  watcher.close();
468
+ await mediaUploads.close();
522
469
  proxies.stop();
523
470
  for (const c of clients) c.end();
524
471
  await server.close();
525
- }
526
- };
472
+ })();
473
+ }
474
+ return { ...server, stop };
527
475
  }
528
476
  async function dev(dir, port) {
529
- const server = await startDev(dir, port);
530
- console.log(`higgsedit dev ${dir}
531
- editor ${server.origin}/`);
477
+ const server = await startDev(dir, port, { onIdleStop: () => quit() });
478
+ console.log(`higgsedit dev ${dir}\n editor ${server.origin}/`);
479
+ const quit = () => {
480
+ setTimeout(() => process.exit(0), 3000).unref();
481
+ void server.stop().finally(() => process.exit(0));
482
+ };
483
+ process.once("SIGTERM", quit);
484
+ process.once("SIGINT", quit);
532
485
  }
486
+
533
487
  export {
534
488
  dev,
535
489
  startDev
@@ -0,0 +1,75 @@
1
+ import { ffmpeg } from "./chunk-EKFC4S4M.mjs";
2
+ import { parseTime } from "./chunk-F7YUKEI5.mjs";
3
+
4
+ // This is the native renderer's contract; the iframe's encoders do not constrain it.
5
+ const sampleRates = [44100, 48000, 96000];
6
+ let capabilities;
7
+ export function renderCapabilities() {
8
+ capabilities ??= new Promise((resolve, reject) => {
9
+ const child = ffmpeg(["-encoders"]);
10
+ let output = "";
11
+ child.stdout.on("data", (chunk) => output += chunk.toString());
12
+ child.on("error", reject);
13
+ child.on("close", (code) => {
14
+ if (code !== 0) return reject(new Error("could not inspect native video encoders"));
15
+ const has = (name) => new RegExp(`^\\s*[A-Z.]{6}\\s+${name}\\s`, "m").test(output);
16
+ const videoCodecs = [...has("libx264") ? ["avc"] : [], ...has("libx265") ? ["hevc"] : []];
17
+ resolve({ containers: videoCodecs.length ? ["mp4"] : [], videoCodecs, audioCodecs: has("aac") ? ["aac"] : [], sampleRates, audioOnly: false });
18
+ });
19
+ }).catch((error) => { capabilities = undefined; throw error; });
20
+ return capabilities;
21
+ }
22
+
23
+ const object = (value, label) => {
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
25
+ return value;
26
+ };
27
+ const number = (value, label, min, max, integer = false) => {
28
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max || integer && !Number.isInteger(value)) throw new Error(`${label} must be ${integer ? "an integer" : "a number"} between ${min} and ${max}`);
29
+ return value;
30
+ };
31
+ const member = (value, allowed, label) => {
32
+ if (!allowed.includes(value)) throw new Error(`unsupported ${label}: ${String(value)}`);
33
+ return value;
34
+ };
35
+ export function validateRenderRequest(input, supported) {
36
+ const body = object(input, "render request");
37
+ const out = {};
38
+ if (body.fileName !== undefined) {
39
+ if (typeof body.fileName !== "string" || !body.fileName.trim() || body.fileName.length > 180 || /[\x00-\x1f]/.test(body.fileName)) throw new Error("fileName must be a nonempty filename of at most 180 characters");
40
+ out.fileName = body.fileName;
41
+ }
42
+ if (body.sceneId !== undefined) {
43
+ if (typeof body.sceneId !== "string" || !body.sceneId || body.sceneId.length > 1024) throw new Error("sceneId must be a nonempty scene identifier");
44
+ out.sceneId = body.sceneId;
45
+ }
46
+ for (const key of ["from", "to"]) {
47
+ if (body[key] === undefined) continue;
48
+ const time = parseTime(body[key], 30);
49
+ if (time === undefined || !Number.isFinite(time) || time < 0 || typeof body[key] === "string" && body[key].length > 64) throw new Error(`${key} must be a nonnegative time`);
50
+ out[key] = body[key];
51
+ }
52
+ out.depth = body.depth === undefined ? 8 : member(body.depth, [8, 10], "bit depth");
53
+ out.allowFontFallback = body.allowFontFallback === true;
54
+ if (body.spec === undefined) return out;
55
+ const spec = object(body.spec, "spec");
56
+ const video = object(spec.video, "spec.video (audio-only export is unavailable)");
57
+ const width = number(video.width, "video width", 2, 8192, true);
58
+ const height = number(video.height, "video height", 2, 8192, true);
59
+ if (width % 2 || height % 2 || width * height > 35389440) throw new Error("video dimensions must be even and no larger than 8K");
60
+ const normalized = {
61
+ container: member(spec.container, supported.containers, "container"),
62
+ video: { width, height, fps: number(video.fps, "video fps", 1, 120), codec: member(video.codec, supported.videoCodecs, "video codec") },
63
+ audio: null
64
+ };
65
+ if (video.bitrate !== undefined) normalized.video.bitrate = number(video.bitrate, "video bitrate", 1000, 1000000000, true);
66
+ if (spec.audio !== null) {
67
+ const audio = object(spec.audio, "spec.audio");
68
+ normalized.audio = { codec: member(audio.codec, supported.audioCodecs, "audio codec"), sampleRate: member(audio.sampleRate, supported.sampleRates, "audio sample rate"), channels: member(audio.channels, [1, 2], "audio channels") };
69
+ if (audio.bitrate !== undefined) normalized.audio.bitrate = number(audio.bitrate, "audio bitrate", 16000, 1024000, true);
70
+ }
71
+ // The explicit codec selects its native encoder. Legacy --depth still applies without a spec.
72
+ out.depth = 8;
73
+ out.spec = normalized;
74
+ return out;
75
+ }