@odori/cli 0.0.2 → 0.0.3

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.
@@ -22,7 +22,7 @@ var log = {
22
22
  // src/commands/add.ts
23
23
  import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
24
24
  import { existsSync as existsSync6 } from "fs";
25
- import { relative as relative3, resolve as resolve6 } from "path";
25
+ import { relative as relative4, resolve as resolve6 } from "path";
26
26
  import { hashString as hashString2 } from "odori";
27
27
 
28
28
  // src/config.ts
@@ -121,7 +121,7 @@ ${indent}"${cue.name}": ${cue.export}(),`;
121
121
  import { createHash } from "crypto";
122
122
  import { existsSync as existsSync4 } from "fs";
123
123
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
124
- import { dirname as dirname2, resolve as resolve4 } from "path";
124
+ import { dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve4, sep } from "path";
125
125
 
126
126
  // src/binaries.ts
127
127
  import { spawn } from "child_process";
@@ -268,6 +268,24 @@ var installCommand = async () => {
268
268
 
269
269
  // src/registry-source.ts
270
270
  var normalizeComponentName = (name) => name.replace(/^@odori\//, "");
271
+ var assertSafeName = (name) => {
272
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
273
+ throw new Error(
274
+ `Refusing component name ${JSON.stringify(name)}: a name must be a lowercase slug (letters, digits, and single dashes). This registry is malformed or tampered with.`
275
+ );
276
+ }
277
+ return name;
278
+ };
279
+ var resolveWithinRoot = (root, target) => {
280
+ const destination = resolve4(root, target);
281
+ const rel = relative2(root, destination);
282
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
283
+ throw new Error(
284
+ `Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`
285
+ );
286
+ }
287
+ return destination;
288
+ };
271
289
  var DEFAULT_URL = "https://odori.dev/r/v1";
272
290
  var registryUrl = (config) => (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
273
291
  var cacheDir = (url) => resolve4(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
@@ -284,7 +302,7 @@ var toComponent = (item) => ({
284
302
  });
285
303
  var snapshotItems = async () => {
286
304
  try {
287
- const loaded = await import("./registry-snapshot-NIH2JMQ6.js");
305
+ const loaded = await import("./registry-snapshot-BDP6PVYB.js");
288
306
  return loaded.default.items;
289
307
  } catch {
290
308
  throw new Error(
@@ -327,6 +345,7 @@ var resolveRegistry = async (config, options = {}) => {
327
345
  return { items: await bundled(), origin: "bundled", detail: "the copy built into this CLI" };
328
346
  };
329
347
  var resolveItem = async (config, name, options = {}) => {
348
+ assertSafeName(name);
330
349
  const url = registryUrl(config);
331
350
  const cache = resolve4(cacheDir(url), `${name}.json`);
332
351
  if (options.allowNetwork !== false) {
@@ -349,9 +368,14 @@ var resolveItem = async (config, name, options = {}) => {
349
368
  if (!item) throw new Error(`No component named "${name}" in the registry at ${url}, in the cache, or in this CLI.`);
350
369
  return { item, origin: "bundled" };
351
370
  };
352
- var verifyIntegrity = (item) => {
371
+ var verifyIntegrity = (item, origin = "network") => {
353
372
  const expected = item.meta?.integrity;
354
- if (!expected) return;
373
+ if (!expected) {
374
+ if (origin === "bundled") return;
375
+ throw new Error(
376
+ `The registry document for "${item.name}" carries no integrity hash. A fetched item must be verifiable; refusing to write it. Nothing was written.`
377
+ );
378
+ }
355
379
  const hash = createHash("sha256");
356
380
  for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
357
381
  hash.update(file.path);
@@ -372,7 +396,7 @@ Nothing was written. This is a truncated download, a stale proxy, or a tampered
372
396
  // src/commands/update.ts
373
397
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
374
398
  import { existsSync as existsSync5 } from "fs";
375
- import { relative as relative2, resolve as resolve5 } from "path";
399
+ import { relative as relative3, resolve as resolve5 } from "path";
376
400
  import { hashString } from "odori";
377
401
 
378
402
  // src/diff.ts
@@ -499,7 +523,7 @@ var diffCommand = async (names, options = {}) => {
499
523
  log.title(`@odori/${status.name} ${LABELS[status.state]}`);
500
524
  for (const file of status.files) {
501
525
  if (file.local === null) {
502
- log.error(` ${file.file} is missing from ${relative2(config.root, resolve5(file.localPath, ".."))}`);
526
+ log.error(` ${file.file} is missing from ${relative3(config.root, resolve5(file.localPath, ".."))}`);
503
527
  continue;
504
528
  }
505
529
  if (file.local === file.upstream) {
@@ -557,7 +581,7 @@ var updateCommand = async (names, options = {}) => {
557
581
 
558
582
  // src/commands/add.ts
559
583
  var addCommand = async (names, options = {}) => {
560
- if (names.length === 0) throw new Error("Name at least one component, for example @odori/title-reveal.");
584
+ if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
561
585
  const config = await loadConfig(process.cwd());
562
586
  const source = await resolveRegistry(config);
563
587
  const registry = source.items;
@@ -580,14 +604,14 @@ var addCommand = async (names, options = {}) => {
580
604
  if (provider) queue.push(provider.name);
581
605
  else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
582
606
  }
583
- const { item } = await resolveItem(config, component.name);
584
- verifyIntegrity(item);
585
- const target = resolve6(config.root, config.componentsDir, component.name);
607
+ const { item, origin } = await resolveItem(config, component.name);
608
+ verifyIntegrity(item, origin);
609
+ const target = resolve6(config.root, config.componentsDir, assertSafeName(component.name));
586
610
  const hashes = {};
587
611
  for (const file of item.files) {
588
- const destination = resolve6(config.root, file.target);
612
+ const destination = resolveWithinRoot(config.root, file.target);
589
613
  const exists = existsSync6(destination);
590
- log.detail(` ${exists ? "replace" : "create "} ${relative3(config.root, destination)}`);
614
+ log.detail(` ${exists ? "replace" : "create "} ${relative4(config.root, destination)}`);
591
615
  }
592
616
  if (options.dryRun) {
593
617
  installed.push(component.name);
@@ -596,13 +620,13 @@ var addCommand = async (names, options = {}) => {
596
620
  await mkdir4(target, { recursive: true });
597
621
  for (const file of item.files) {
598
622
  const name2 = file.path.split("/").pop() ?? file.path;
599
- const destination = resolve6(config.root, file.target);
623
+ const destination = resolveWithinRoot(config.root, file.target);
600
624
  hashes[name2] = hashString2(file.content);
601
625
  if (existsSync6(destination) && !options.force) {
602
626
  const current = hashString2(await readFile5(destination, "utf8"));
603
627
  const recorded = provenance[component.name]?.hashes[name2];
604
628
  if (current !== recorded) {
605
- log.warn(`${relative3(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
629
+ log.warn(`${relative4(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
606
630
  continue;
607
631
  }
608
632
  }
@@ -616,7 +640,7 @@ var addCommand = async (names, options = {}) => {
616
640
  hashes
617
641
  };
618
642
  installed.push(component.name);
619
- log.success(`${component.namespaced} to ${relative3(config.root, target)}/`);
643
+ log.success(`${component.namespaced} to ${relative4(config.root, target)}/`);
620
644
  if (component.kind === "cue" && component.cue) {
621
645
  log.detail(
622
646
  ` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
@@ -665,6 +689,8 @@ var registryCommand = async () => {
665
689
 
666
690
  // src/commands/dev.ts
667
691
  import { resolve as resolve18 } from "path";
692
+ import { homedir as homedir2 } from "os";
693
+ import { existsSync as existsSync15 } from "fs";
668
694
  import { readFile as readFile12 } from "fs/promises";
669
695
 
670
696
  // src/jobs.ts
@@ -673,7 +699,7 @@ import { existsSync as existsSync7 } from "fs";
673
699
  import { join as join2, resolve as resolve7 } from "path";
674
700
  var buildsDir = (config) => resolve7(config.root, config.outDir, "builds");
675
701
  var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
676
- var createJob = async (config, manifest, output) => {
702
+ var createJob = async (config, manifest, output, render) => {
677
703
  await mkdir5(buildsDir(config), { recursive: true });
678
704
  const now = (/* @__PURE__ */ new Date()).toISOString();
679
705
  const job = {
@@ -687,7 +713,7 @@ var createJob = async (config, manifest, output) => {
687
713
  createdAt: now,
688
714
  updatedAt: now
689
715
  };
690
- const record = { job, manifest, output };
716
+ const record = { job, manifest, output, ...render ? { render } : {} };
691
717
  await writeFile6(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
692
718
  `, "utf8");
693
719
  return record;
@@ -779,7 +805,7 @@ var JobQueue = class {
779
805
  // src/discovery.ts
780
806
  import { mkdir as mkdir6, readdir as readdir3, readFile as readFile7, stat, writeFile as writeFile7 } from "fs/promises";
781
807
  import { existsSync as existsSync8 } from "fs";
782
- import { join as join3, relative as relative4, resolve as resolve8, sep } from "path";
808
+ import { join as join3, relative as relative5, resolve as resolve8, sep as sep2 } from "path";
783
809
  import { hashString as hashString3 } from "odori";
784
810
  var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
785
811
  var walk = async (directory2, files = []) => {
@@ -807,9 +833,9 @@ var discoverAudio = async (config) => {
807
833
  const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
808
834
  return Promise.all(
809
835
  files.map(async (file) => ({
810
- name: relative4(root, file).replace(AUDIO_EXTENSIONS, "").split(sep).join("/"),
811
- url: file.startsWith(`${publicRoot}${sep}`) ? `/${relative4(publicRoot, file).split(sep).join("/")}` : `/${relative4(config.root, file).split(sep).join("/")}`,
812
- relativeFile: relative4(config.root, file),
836
+ name: relative5(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
837
+ url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative5(publicRoot, file).split(sep2).join("/")}` : `/${relative5(config.root, file).split(sep2).join("/")}`,
838
+ relativeFile: relative5(config.root, file),
813
839
  bytes: (await stat(file)).size
814
840
  }))
815
841
  );
@@ -825,15 +851,17 @@ var discoverProject = async (config) => {
825
851
  const previews = [];
826
852
  const brands = [];
827
853
  const hashParts = [];
854
+ const componentsRoot = resolve8(config.root, config.componentsDir);
828
855
  for (const file of files) {
829
- const relativeFile = relative4(config.root, file);
856
+ const relativeFile = relative5(config.root, file);
857
+ let contents = "";
830
858
  if (/\.(tsx|ts|css|json)$/.test(file)) {
831
- const contents = await readFile7(file, "utf8");
859
+ contents = await readFile7(file, "utf8");
832
860
  hashParts.push(`${relativeFile}:${hashString3(contents)}`);
833
861
  }
834
- const base = file.split(sep).pop() ?? "";
862
+ const base = file.split(sep2).pop() ?? "";
835
863
  if (base === "video.tsx") {
836
- const slug = relative4(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
864
+ const slug = relative5(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
837
865
  videos.push({
838
866
  slug,
839
867
  file,
@@ -841,13 +869,27 @@ var discoverProject = async (config) => {
841
869
  importPath: file,
842
870
  identifier: toIdentifier(slug, "video")
843
871
  });
844
- } else if (file.split(sep).includes("brands") && /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx")) {
872
+ } else if (
873
+ // A brand module is recognized by what it does, not where it sits. The
874
+ // scaffold defines its brand in videos/layout.tsx, so a directory-name
875
+ // rule alone left the default project's brand invisible to everything
876
+ // that reads this list — most visibly the dev server's cue registry,
877
+ // which then answered new cue URLs with 404s until a restart. Installed
878
+ // component source is excluded the way brand-file.ts excludes it: a
879
+ // component may mention defineBrand without being where a brand lives.
880
+ /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
881
+ ) {
845
882
  const name = base.replace(/\.tsx?$/, "");
846
883
  brands.push({
847
884
  name,
848
885
  file,
849
886
  relativeFile,
850
- identifier: toIdentifier(`${name}-module`, "brands")
887
+ // From the whole relative path, like previews: basenames repeat
888
+ // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
889
+ identifier: toIdentifier(
890
+ `${relative5(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
891
+ "brands"
892
+ )
851
893
  });
852
894
  } else if (base.endsWith(".preview.tsx")) {
853
895
  const name = base.replace(/\.preview\.tsx$/, "");
@@ -856,7 +898,7 @@ var discoverProject = async (config) => {
856
898
  file,
857
899
  relativeFile,
858
900
  importPath: file,
859
- identifier: toIdentifier(`${relative4(videosRoot, file).split(sep).join("-")}`, "preview")
901
+ identifier: toIdentifier(`${relative5(videosRoot, file).split(sep2).join("-")}`, "preview")
860
902
  });
861
903
  }
862
904
  }
@@ -864,7 +906,7 @@ var discoverProject = async (config) => {
864
906
  };
865
907
  var generateImports = (graph, outDir) => {
866
908
  const importPath = (file) => {
867
- const relativePath = relative4(outDir, file).split(sep).join("/");
909
+ const relativePath = relative5(outDir, file).split(sep2).join("/");
868
910
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
869
911
  };
870
912
  const lines = [
@@ -1162,6 +1204,119 @@ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
1162
1204
  return { manifest, input, prepared };
1163
1205
  };
1164
1206
 
1207
+ // src/formats.ts
1208
+ var QUALITIES = ["studio", "social", "web"];
1209
+ var scaleFilter = (scale) => `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos`;
1210
+ var scaleArgs = (scale) => scale === 1 ? [] : ["-vf", scaleFilter(scale)];
1211
+ var FORMATS = {
1212
+ mp4: {
1213
+ name: "mp4",
1214
+ extension: ".mp4",
1215
+ alpha: false,
1216
+ chunked: true,
1217
+ audio: true,
1218
+ description: "H.264 in MP4. Plays everywhere; the default.",
1219
+ args: ({ preset, quality, scale }) => [
1220
+ ...scaleArgs(scale),
1221
+ "-c:v",
1222
+ "libx264",
1223
+ "-crf",
1224
+ { studio: "17", social: "21", web: "27" }[quality],
1225
+ "-preset",
1226
+ preset,
1227
+ "-pix_fmt",
1228
+ "yuv420p"
1229
+ ]
1230
+ },
1231
+ webm: {
1232
+ name: "webm",
1233
+ extension: ".webm",
1234
+ alpha: true,
1235
+ // VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
1236
+ chunked: true,
1237
+ audio: true,
1238
+ description: "VP9 in WebM, with alpha. For the web, and for overlays.",
1239
+ args: ({ quality, scale }) => [
1240
+ ...scaleArgs(scale),
1241
+ "-c:v",
1242
+ "libvpx-vp9",
1243
+ "-crf",
1244
+ { studio: "24", social: "31", web: "38" }[quality],
1245
+ "-b:v",
1246
+ "0",
1247
+ "-pix_fmt",
1248
+ "yuva420p",
1249
+ "-row-mt",
1250
+ "1"
1251
+ ]
1252
+ },
1253
+ prores: {
1254
+ name: "prores",
1255
+ extension: ".mov",
1256
+ alpha: true,
1257
+ chunked: true,
1258
+ audio: true,
1259
+ description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
1260
+ // Quality is the profile here, and the profile is the point: an editor
1261
+ // format that quietly compressed would defeat its own reason to exist.
1262
+ args: ({ scale }) => [
1263
+ ...scaleArgs(scale),
1264
+ "-c:v",
1265
+ "prores_ks",
1266
+ "-profile:v",
1267
+ "4444",
1268
+ "-pix_fmt",
1269
+ "yuva444p10le",
1270
+ "-alpha_bits",
1271
+ "8"
1272
+ ]
1273
+ },
1274
+ gif: {
1275
+ name: "gif",
1276
+ extension: ".gif",
1277
+ alpha: false,
1278
+ // A GIF's palette is computed across the whole animation, so chunks would
1279
+ // each invent their own and the result would flicker between them.
1280
+ chunked: false,
1281
+ audio: false,
1282
+ description: "An animated GIF, palette optimised. Silent, by the format.",
1283
+ // The palette pass is a filter graph already, so scaling joins it rather
1284
+ // than adding a second -vf that ffmpeg would silently drop.
1285
+ args: ({ scale }) => [
1286
+ "-vf",
1287
+ `${scale === 1 ? "" : `${scaleFilter(scale)},`}split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3`,
1288
+ "-loop",
1289
+ "0"
1290
+ ]
1291
+ },
1292
+ png: {
1293
+ name: "png",
1294
+ extension: ".png",
1295
+ alpha: true,
1296
+ chunked: false,
1297
+ audio: false,
1298
+ description: "A numbered PNG sequence, with alpha. For a compositor.",
1299
+ args: ({ scale }) => [...scaleArgs(scale), "-c:v", "png", "-pix_fmt", "rgba"]
1300
+ }
1301
+ };
1302
+ var formatNames = () => Object.keys(FORMATS);
1303
+ var resolveFormat = (requested, output) => {
1304
+ if (requested) {
1305
+ const format = FORMATS[requested.toLowerCase()];
1306
+ if (!format) {
1307
+ throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
1308
+ }
1309
+ return format;
1310
+ }
1311
+ if (output) {
1312
+ const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
1313
+ const matched = Object.values(FORMATS).find((format) => format.extension === extension);
1314
+ if (matched) return matched;
1315
+ }
1316
+ return FORMATS.mp4;
1317
+ };
1318
+ var alphaWarning = (format, transparent) => transparent && !format.alpha ? `${format.name} has no alpha channel, so the transparent background will render black. Use webm, prores, or png.` : null;
1319
+
1165
1320
  // src/render.ts
1166
1321
  import { spawn as spawn2 } from "child_process";
1167
1322
  import { copyFile as copyFile2, mkdir as mkdir11, rm as rm2, writeFile as writeFile12 } from "fs/promises";
@@ -1234,23 +1389,28 @@ var renderedCue = (url) => {
1234
1389
  return wav;
1235
1390
  };
1236
1391
  var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
1392
+ var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
1237
1393
  var importFresh = async (file) => await import(`${pathToFileURL3(file).href}?odori=${statSync(file).mtimeMs}`);
1238
- var registerProjectCues = async (graph) => {
1394
+ var registerProjectCues = async (graph, load = importFresh) => {
1239
1395
  for (const discovered of graph.brands) {
1240
1396
  try {
1241
- const module = await importFresh(discovered.file);
1242
- registerCues(Object.values(module).filter(isBrand), defaultLayout.format.fps);
1397
+ const values = Object.values(await load(discovered.file));
1398
+ registerCues(values.filter(isBrand), defaultLayout.format.fps);
1399
+ for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
1243
1400
  } catch (error) {
1244
1401
  log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
1245
1402
  }
1246
1403
  }
1247
- try {
1248
- for (const video of await loadVideos(graph)) {
1249
- const layout = resolveEntryLayout2(video.entry);
1404
+ for (const video of graph.videos) {
1405
+ try {
1406
+ const module = await load(video.file);
1407
+ if (!module.default || !module.metadata) continue;
1408
+ const entry = { component: module.default, metadata: module.metadata };
1409
+ const layout = resolveEntryLayout2(entry);
1250
1410
  registerCues([layout.brand], layout.format.fps);
1411
+ } catch (error) {
1412
+ log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1251
1413
  }
1252
- } catch (error) {
1253
- log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
1254
1414
  }
1255
1415
  return known.size;
1256
1416
  };
@@ -1409,6 +1569,8 @@ var chunkKey = (identity) => hashValue2({
1409
1569
  height: identity.height,
1410
1570
  fps: identity.fps,
1411
1571
  preset: identity.preset,
1572
+ quality: identity.quality ?? null,
1573
+ scale: identity.scale ?? null,
1412
1574
  input: identity.input ?? null
1413
1575
  });
1414
1576
  var readChunkRecord = async (config, key) => {
@@ -1434,80 +1596,6 @@ var writeChunkRecord = async (config, key, signatures, file) => {
1434
1596
  };
1435
1597
  var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
1436
1598
 
1437
- // src/formats.ts
1438
- var FORMATS = {
1439
- mp4: {
1440
- name: "mp4",
1441
- extension: ".mp4",
1442
- alpha: false,
1443
- chunked: true,
1444
- audio: true,
1445
- description: "H.264 in MP4. Plays everywhere; the default.",
1446
- args: (preset) => ["-c:v", "libx264", "-crf", "17", "-preset", preset, "-pix_fmt", "yuv420p"]
1447
- },
1448
- webm: {
1449
- name: "webm",
1450
- extension: ".webm",
1451
- alpha: true,
1452
- // VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
1453
- chunked: true,
1454
- audio: true,
1455
- description: "VP9 in WebM, with alpha. For the web, and for overlays.",
1456
- args: () => ["-c:v", "libvpx-vp9", "-crf", "24", "-b:v", "0", "-pix_fmt", "yuva420p", "-row-mt", "1"]
1457
- },
1458
- prores: {
1459
- name: "prores",
1460
- extension: ".mov",
1461
- alpha: true,
1462
- chunked: true,
1463
- audio: true,
1464
- description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
1465
- args: () => ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-alpha_bits", "8"]
1466
- },
1467
- gif: {
1468
- name: "gif",
1469
- extension: ".gif",
1470
- alpha: false,
1471
- // A GIF's palette is computed across the whole animation, so chunks would
1472
- // each invent their own and the result would flicker between them.
1473
- chunked: false,
1474
- audio: false,
1475
- description: "An animated GIF, palette optimised. Silent, by the format.",
1476
- args: () => [
1477
- "-vf",
1478
- "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3",
1479
- "-loop",
1480
- "0"
1481
- ]
1482
- },
1483
- png: {
1484
- name: "png",
1485
- extension: ".png",
1486
- alpha: true,
1487
- chunked: false,
1488
- audio: false,
1489
- description: "A numbered PNG sequence, with alpha. For a compositor.",
1490
- args: () => ["-c:v", "png", "-pix_fmt", "rgba"]
1491
- }
1492
- };
1493
- var formatNames = () => Object.keys(FORMATS);
1494
- var resolveFormat = (requested, output) => {
1495
- if (requested) {
1496
- const format = FORMATS[requested.toLowerCase()];
1497
- if (!format) {
1498
- throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
1499
- }
1500
- return format;
1501
- }
1502
- if (output) {
1503
- const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
1504
- const matched = Object.values(FORMATS).find((format) => format.extension === extension);
1505
- if (matched) return matched;
1506
- }
1507
- return FORMATS.mp4;
1508
- };
1509
- var alphaWarning = (format, transparent) => transparent && !format.alpha ? `${format.name} has no alpha channel, so the transparent background will render black. Use webm, prores, or png.` : null;
1510
-
1511
1599
  // src/render.ts
1512
1600
  var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1513
1601
  var renderUrl = (origin, target, frame) => {
@@ -1675,10 +1763,19 @@ var sequencePattern = (output) => {
1675
1763
  const extension = dot > 0 ? output.slice(dot) : ".png";
1676
1764
  return join6(stem, `%05d${extension}`);
1677
1765
  };
1678
- var openChunkEncoder = (ffmpeg, file, fps, preset, format, signal) => {
1766
+ var LOSSLESS = {
1767
+ name: "lossless",
1768
+ extension: ".mkv",
1769
+ alpha: true,
1770
+ chunked: true,
1771
+ audio: false,
1772
+ description: "FFV1 in MKV, the lossless intermediate for one-pass formats.",
1773
+ args: () => ["-c:v", "ffv1", "-level", "3"]
1774
+ };
1775
+ var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
1679
1776
  const child = spawn2(
1680
1777
  ffmpeg,
1681
- ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(preset), file],
1778
+ ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(encode), file],
1682
1779
  { stdio: ["pipe", "ignore", "pipe"] }
1683
1780
  );
1684
1781
  let stderr = "";
@@ -1728,7 +1825,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
1728
1825
  }
1729
1826
  }
1730
1827
  const file = options.chunkFile(chunk);
1731
- const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.preset, options.format, options.signal);
1828
+ const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.encode, options.format, options.signal);
1732
1829
  const signatures = [];
1733
1830
  let previousSignature = null;
1734
1831
  let previousFrame = null;
@@ -1788,9 +1885,14 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1788
1885
  const browserPath = await browserExecutable(config);
1789
1886
  const renderer = (await resolveBrowser(config))?.version ?? browserPath;
1790
1887
  const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
1791
- const preset = options.preset ?? config.preset ?? "medium";
1888
+ const encode = {
1889
+ preset: options.preset ?? config.preset ?? "medium",
1890
+ quality: options.quality ?? "studio",
1891
+ scale: options.scale ?? 1
1892
+ };
1792
1893
  const format = options.format ?? FORMATS.mp4;
1793
1894
  const chunkable = format.chunked;
1895
+ const chunkFormat = chunkable ? format : LOSSLESS;
1794
1896
  const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1795
1897
  const cache = options.cache ?? config.cacheChunks ?? true;
1796
1898
  const work = options.workDir ?? resolve15(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
@@ -1812,8 +1914,8 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1812
1914
  (lane) => captureLane(origin, target, config, lane, stats, {
1813
1915
  skipUnchanged,
1814
1916
  signal: options.signal,
1815
- preset,
1816
- format,
1917
+ encode,
1918
+ format: chunkFormat,
1817
1919
  ffmpeg,
1818
1920
  workDir: work,
1819
1921
  chunkFile,
@@ -1821,11 +1923,17 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1821
1923
  videoId: target.videoId,
1822
1924
  chunk,
1823
1925
  renderer,
1824
- format: format.name,
1926
+ format: chunkFormat.name,
1825
1927
  width: target.width,
1826
1928
  height: target.height,
1827
1929
  fps: target.fps,
1828
- preset,
1930
+ preset: encode.preset,
1931
+ // Both change the encoded bytes, so both are part of what a
1932
+ // chunk is: a half-size chunk must never answer for a full
1933
+ // one. A lossless intermediate is the exception by design —
1934
+ // the final pass applies them, so one capture serves all.
1935
+ quality: chunkable ? encode.quality : void 0,
1936
+ scale: chunkable ? encode.scale : void 0,
1829
1937
  input: target.input
1830
1938
  }) : void 0,
1831
1939
  onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
@@ -1852,7 +1960,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1852
1960
  if (!chunkable) {
1853
1961
  const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1854
1962
  if (destination !== output) await mkdir11(dirname4(destination), { recursive: true });
1855
- await run(ffmpeg, ["-y", "-i", silent, ...format.args(preset), destination], options.signal);
1963
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
1856
1964
  if (mixInputs.length > 0) {
1857
1965
  log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
1858
1966
  }
@@ -1943,7 +2051,7 @@ import { fileURLToPath } from "url";
1943
2051
  import { createServer } from "vite";
1944
2052
  import react from "@vitejs/plugin-react";
1945
2053
  import { readFile as readFile11 } from "fs/promises";
1946
- import { dirname as dirname5, resolve as resolve16, sep as sep2 } from "path";
2054
+ import { dirname as dirname5, resolve as resolve16, sep as sep3 } from "path";
1947
2055
  var cliRoot = resolve16(dirname5(fileURLToPath(import.meta.url)), "..");
1948
2056
  var studioRoot = resolve16(cliRoot, "studio");
1949
2057
  var studioEntry = resolve16(studioRoot, "index.html");
@@ -2014,7 +2122,6 @@ var startStudioServer = async (initialConfig, options = {}) => {
2014
2122
  const odoriSrc = runtimeSource(config.root);
2015
2123
  let graph = await discoverProject(config);
2016
2124
  await writeGenerated(config, graph);
2017
- await registerProjectCues(graph);
2018
2125
  const vite = await createServer({
2019
2126
  root: studioRoot,
2020
2127
  configFile: false,
@@ -2093,14 +2200,31 @@ var startStudioServer = async (initialConfig, options = {}) => {
2093
2200
  },
2094
2201
  optimizeDeps: { include: ["react", "react-dom", "react/jsx-dev-runtime"] }
2095
2202
  });
2203
+ const loadModule = (file) => vite.ssrLoadModule(`/@fs${file}`);
2204
+ await registerProjectCues(graph, loadModule);
2205
+ let refreshTimer;
2206
+ let refreshChain = Promise.resolve();
2207
+ const enqueueRefresh = (task) => {
2208
+ const next = refreshChain.then(task, task);
2209
+ refreshChain = next.catch(() => void 0);
2210
+ return next;
2211
+ };
2212
+ const scheduleCueRefresh = () => {
2213
+ clearTimeout(refreshTimer);
2214
+ refreshTimer = setTimeout(() => {
2215
+ void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
2216
+ vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
2217
+ });
2218
+ }, 150);
2219
+ };
2096
2220
  const rediscover = async (file) => {
2097
2221
  if (!file.startsWith(resolve16(config.root, config.videosDir))) return;
2098
- const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep2}brands${sep2}`);
2222
+ const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2099
2223
  if (!isEntry) return;
2100
2224
  try {
2101
2225
  graph = await discoverProject(config);
2102
2226
  await writeGenerated(config, graph);
2103
- await registerProjectCues(graph);
2227
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
2104
2228
  } catch (error) {
2105
2229
  vite.config.logger.warn(`[odori] rediscovery skipped: ${error instanceof Error ? error.message : String(error)}`);
2106
2230
  return;
@@ -2118,7 +2242,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2118
2242
  config = await loadConfig(config.root);
2119
2243
  graph = await discoverProject(config);
2120
2244
  await writeGenerated(config, graph);
2121
- await registerProjectCues(graph);
2245
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
2122
2246
  } catch (error) {
2123
2247
  vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
2124
2248
  return;
@@ -2127,11 +2251,12 @@ var startStudioServer = async (initialConfig, options = {}) => {
2127
2251
  if (module) vite.moduleGraph.invalidateModule(module);
2128
2252
  vite.ws.send({ type: "full-reload" });
2129
2253
  };
2130
- const refreshCues = async (file) => {
2131
- if (!file.startsWith(resolve16(config.root, config.videosDir)) || !file.split(sep2).includes("brands")) return;
2132
- await registerProjectCues(graph);
2254
+ const refreshCues = (file) => {
2255
+ if (!file.startsWith(resolve16(config.root, config.videosDir) + sep3)) return;
2256
+ if (!/\.tsx?$/.test(file)) return;
2257
+ scheduleCueRefresh();
2133
2258
  };
2134
- vite.watcher.on("change", (file) => void refreshCues(file));
2259
+ vite.watcher.on("change", (file) => refreshCues(file));
2135
2260
  vite.watcher.on("change", (file) => void reloadConfig(file));
2136
2261
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2137
2262
  vite.watcher.add(resolve16(config.root, name));
@@ -2274,8 +2399,12 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2274
2399
  },
2275
2400
  {
2276
2401
  concurrency: options.concurrency,
2277
- preset: options.preset,
2278
- format: options.format,
2402
+ // The record freezes how it should be encoded alongside what, so a
2403
+ // retry from any surface produces the same file the first run would.
2404
+ preset: options.preset ?? record.render?.preset,
2405
+ quality: options.quality ?? record.render?.quality,
2406
+ scale: options.scale ?? record.render?.scale,
2407
+ format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
2279
2408
  skipUnchangedFrames: options.skipUnchangedFrames,
2280
2409
  signal: controller.signal,
2281
2410
  onTimings: (timings) => {
@@ -2302,9 +2431,23 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2302
2431
  running.delete(record.job.id);
2303
2432
  }
2304
2433
  });
2434
+ var resolveQuality = (requested) => {
2435
+ if (!requested) return "studio";
2436
+ if (QUALITIES.includes(requested)) return requested;
2437
+ throw new Error(`Unknown quality "${requested}". Available: ${QUALITIES.join(", ")}`);
2438
+ };
2439
+ var resolveScale = (requested) => {
2440
+ if (requested === void 0) return 1;
2441
+ if (!Number.isFinite(requested) || requested < 0.25 || requested > 2) {
2442
+ throw new Error(`Scale ${requested} is out of range. Use a value between 0.25 and 2.`);
2443
+ }
2444
+ return requested;
2445
+ };
2305
2446
  var exportCommand = async (id, options = {}) => {
2306
2447
  const { config, graph, videos } = await createContext();
2307
2448
  const format = resolveFormat(options.format ?? config.format, options.output);
2449
+ const quality = resolveQuality(options.quality);
2450
+ const scale = resolveScale(options.scale);
2308
2451
  return withServer(config, async (server) => {
2309
2452
  const record = options.retry ? await readJob(config, options.retry) : await (async () => {
2310
2453
  const video2 = findVideo(videos, id);
@@ -2320,7 +2463,12 @@ var exportCommand = async (id, options = {}) => {
2320
2463
  config.root,
2321
2464
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2322
2465
  );
2323
- return createJob(config, manifest, output);
2466
+ return createJob(config, manifest, output, {
2467
+ format: format.name,
2468
+ quality,
2469
+ scale,
2470
+ ...options.preset ? { preset: options.preset } : {}
2471
+ });
2324
2472
  })();
2325
2473
  const video = findVideo(videos, record.manifest.videoId);
2326
2474
  log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
@@ -2331,7 +2479,10 @@ var exportCommand = async (id, options = {}) => {
2331
2479
  const job = await runJob(config, server.url, record, video, {
2332
2480
  concurrency: options.concurrency,
2333
2481
  preset: options.preset,
2334
- format,
2482
+ // A retry keeps what its record froze; an explicit flag still wins.
2483
+ quality: options.retry && options.quality === void 0 ? void 0 : quality,
2484
+ scale: options.retry && options.scale === void 0 ? void 0 : scale,
2485
+ format: options.retry ? void 0 : format,
2335
2486
  skipUnchangedFrames: options.skipUnchangedFrames,
2336
2487
  onProgress: (next) => {
2337
2488
  if (next.status === "rendering" || next.status === "encoding") {
@@ -2373,6 +2524,35 @@ var json = (response, status, payload) => {
2373
2524
  response.setHeader("content-type", "application/json");
2374
2525
  response.end(JSON.stringify(payload));
2375
2526
  };
2527
+ var exportDestination = (config) => {
2528
+ const downloads = resolve18(homedir2(), "Downloads");
2529
+ return existsSync15(downloads) ? downloads : resolve18(config.root, config.exportDir);
2530
+ };
2531
+ var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2532
+ var hostOf = (value) => {
2533
+ if (!value) return null;
2534
+ const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
2535
+ return withoutPort || null;
2536
+ };
2537
+ var isLocalRequest = (request) => {
2538
+ const host = hostOf(request.headers.host);
2539
+ if (!host || !LOOPBACK.has(host)) return false;
2540
+ const origin = request.headers.origin;
2541
+ if (origin) {
2542
+ try {
2543
+ if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
2544
+ } catch {
2545
+ return false;
2546
+ }
2547
+ }
2548
+ return true;
2549
+ };
2550
+ var safeJobId = (id) => {
2551
+ if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
2552
+ throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
2553
+ }
2554
+ return id;
2555
+ };
2376
2556
  var devCommand = async (options = {}) => {
2377
2557
  const config = await loadConfig(options.root ?? process.cwd());
2378
2558
  const context = async () => {
@@ -2384,6 +2564,10 @@ var devCommand = async (options = {}) => {
2384
2564
  middleware: (vite) => {
2385
2565
  vite.middlewares.use("/__odori", (request, response, next) => {
2386
2566
  const url = request.url ?? "/";
2567
+ if (!isLocalRequest(request)) {
2568
+ json(response, 403, { error: "This endpoint only answers same-origin requests from Studio on localhost." });
2569
+ return;
2570
+ }
2387
2571
  void (async () => {
2388
2572
  try {
2389
2573
  if (request.method === "POST" && url.startsWith("/still")) {
@@ -2401,8 +2585,7 @@ var devCommand = async (options = {}) => {
2401
2585
  );
2402
2586
  const frame = Number(body.frame ?? 0);
2403
2587
  const inline = body.inline === true;
2404
- const directory2 = inline ? config.outDir : config.exportDir;
2405
- const file = resolve18(config.root, `${directory2}/${outputName(video.entry.metadata.id)}-${frame}.png`);
2588
+ const file = inline ? resolve18(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve18(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
2406
2589
  await renderStill(
2407
2590
  origin,
2408
2591
  targetFor(
@@ -2426,6 +2609,9 @@ var devCommand = async (options = {}) => {
2426
2609
  }
2427
2610
  if (request.method === "POST" && url.startsWith("/exports")) {
2428
2611
  const body = await readBody(request);
2612
+ const format = resolveFormat(typeof body.format === "string" ? body.format : void 0, void 0);
2613
+ const quality = resolveQuality(typeof body.quality === "string" ? body.quality : void 0);
2614
+ const scale = resolveScale(typeof body.scale === "number" ? body.scale : void 0);
2429
2615
  const { graph, videos } = await context();
2430
2616
  const video = findVideo(videos, String(body.videoId));
2431
2617
  const input = body.input ?? {};
@@ -2437,8 +2623,11 @@ var devCommand = async (options = {}) => {
2437
2623
  input,
2438
2624
  { scenes: compiled.scenes, audio: compiled.audio }
2439
2625
  );
2440
- const output = resolve18(config.root, `${config.exportDir}/${outputName(video.entry.metadata.id)}.mp4`);
2441
- const record = await createJob(config, manifest, output);
2626
+ const output = resolve18(
2627
+ exportDestination(config),
2628
+ `${outputName(video.entry.metadata.id)}${format.extension}`
2629
+ );
2630
+ const record = await createJob(config, manifest, output, { format: format.name, quality, scale });
2442
2631
  json(response, 202, record.job);
2443
2632
  void runJob(config, origin, record, video).catch((error) => {
2444
2633
  log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -2446,7 +2635,7 @@ var devCommand = async (options = {}) => {
2446
2635
  return;
2447
2636
  }
2448
2637
  if (request.method === "POST" && url.startsWith("/retry/")) {
2449
- const id = url.replace("/retry/", "").split("?")[0];
2638
+ const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
2450
2639
  const record = await readJob(config, id);
2451
2640
  const { videos } = await context();
2452
2641
  const video = findVideo(videos, record.manifest.videoId);
@@ -2457,13 +2646,13 @@ var devCommand = async (options = {}) => {
2457
2646
  return;
2458
2647
  }
2459
2648
  if (request.method === "POST" && url.startsWith("/cancel/")) {
2460
- const id = url.replace("/cancel/", "").split("?")[0];
2649
+ const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
2461
2650
  const cancelled = cancelJob(id);
2462
2651
  json(response, cancelled ? 202 : 404, { id, cancelled });
2463
2652
  return;
2464
2653
  }
2465
2654
  if (request.method === "GET" && url.startsWith("/jobs/")) {
2466
- const id = url.replace("/jobs/", "").split("?")[0];
2655
+ const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
2467
2656
  json(response, 200, (await readJob(config, id)).job);
2468
2657
  return;
2469
2658
  }
@@ -2480,7 +2669,7 @@ var devCommand = async (options = {}) => {
2480
2669
  }
2481
2670
  });
2482
2671
  const origin = server.url;
2483
- const entry = `${origin}/videos`;
2672
+ const entry = `${origin}/`;
2484
2673
  log.title("Odori Studio");
2485
2674
  log.info(` ${entry}`);
2486
2675
  log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
@@ -2492,9 +2681,9 @@ var devCommand = async (options = {}) => {
2492
2681
  // src/commands/doctor.ts
2493
2682
  import { constants } from "fs";
2494
2683
  import { access, mkdir as mkdir12, readFile as readFile13, rm as rm3, writeFile as writeFile13 } from "fs/promises";
2495
- import { existsSync as existsSync15 } from "fs";
2684
+ import { existsSync as existsSync16 } from "fs";
2496
2685
  import { createRequire as createRequire3 } from "module";
2497
- import { relative as relative5, resolve as resolve19 } from "path";
2686
+ import { relative as relative6, resolve as resolve19 } from "path";
2498
2687
  var MINIMUM_NODE = 20;
2499
2688
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2500
2689
  var runChecks = async (root) => {
@@ -2526,13 +2715,13 @@ var runChecks = async (root) => {
2526
2715
  const videosDir = resolve19(config.root, config.videosDir);
2527
2716
  checks.push({
2528
2717
  name: "Source root",
2529
- detail: existsSync15(videosDir) ? relative5(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2530
- ok: existsSync15(videosDir),
2718
+ detail: existsSync16(videosDir) ? relative6(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2719
+ ok: existsSync16(videosDir),
2531
2720
  fix: 'Run "odori init" to add the videos source root.'
2532
2721
  });
2533
2722
  checks.push({
2534
2723
  name: "Config",
2535
- detail: config.configPath ? relative5(config.root, config.configPath) : "defaults (no odori.config.ts)",
2724
+ detail: config.configPath ? relative6(config.root, config.configPath) : "defaults (no odori.config.ts)",
2536
2725
  // Loading got this far, so a config that exists also parsed.
2537
2726
  ok: true
2538
2727
  });
@@ -2574,7 +2763,7 @@ var runChecks = async (root) => {
2574
2763
  name: "Generated cache",
2575
2764
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2576
2765
  ok: writable,
2577
- fix: `Odori writes its import graph and render cache to ${relative5(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2766
+ fix: `Odori writes its import graph and render cache to ${relative6(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2578
2767
  });
2579
2768
  return checks;
2580
2769
  };
@@ -2599,13 +2788,13 @@ var doctorCommand = async (root = process.cwd()) => {
2599
2788
 
2600
2789
  // src/commands/init.ts
2601
2790
  import { mkdir as mkdir14, writeFile as writeFile15 } from "fs/promises";
2602
- import { existsSync as existsSync17 } from "fs";
2603
- import { relative as relative7, resolve as resolve21 } from "path";
2791
+ import { existsSync as existsSync18 } from "fs";
2792
+ import { relative as relative8, resolve as resolve21 } from "path";
2604
2793
 
2605
2794
  // src/commands/new.ts
2606
2795
  import { mkdir as mkdir13, readdir as readdir5, writeFile as writeFile14 } from "fs/promises";
2607
- import { existsSync as existsSync16 } from "fs";
2608
- import { relative as relative6, resolve as resolve20 } from "path";
2796
+ import { existsSync as existsSync17 } from "fs";
2797
+ import { relative as relative7, resolve as resolve20 } from "path";
2609
2798
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2610
2799
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
2611
2800
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -2664,7 +2853,7 @@ ${closing}
2664
2853
  };
2665
2854
  var installedParts = async (config) => {
2666
2855
  const componentsDir = resolve20(config.root, config.componentsDir);
2667
- if (!existsSync16(componentsDir)) return { title: false, end: false };
2856
+ if (!existsSync17(componentsDir)) return { title: false, end: false };
2668
2857
  const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2669
2858
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
2670
2859
  };
@@ -2673,8 +2862,8 @@ var newCommand = async (name, options = {}) => {
2673
2862
  const config = await loadConfig(process.cwd());
2674
2863
  const directory2 = resolve20(config.root, config.videosDir, name);
2675
2864
  const file = resolve20(directory2, "video.tsx");
2676
- if (existsSync16(file)) throw new Error(`${relative6(config.root, file)} already exists.`);
2677
- const hasLayout = existsSync16(resolve20(config.root, config.videosDir, "layout.tsx"));
2865
+ if (existsSync17(file)) throw new Error(`${relative7(config.root, file)} already exists.`);
2866
+ const hasLayout = existsSync17(resolve20(config.root, config.videosDir, "layout.tsx"));
2678
2867
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
2679
2868
  const composed = parts.title || parts.end;
2680
2869
  await mkdir13(directory2, { recursive: true });
@@ -2683,10 +2872,10 @@ var newCommand = async (name, options = {}) => {
2683
2872
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
2684
2873
  "utf8"
2685
2874
  );
2686
- log.success(`Created ${relative6(config.root, file)}`);
2875
+ log.success(`Created ${relative7(config.root, file)}`);
2687
2876
  if (composed) log.detail("Composed from the components this project has installed.");
2688
2877
  else if (options.blank !== true) {
2689
- log.detail("No registry components installed yet: odori add @odori/title-reveal @odori/end-card");
2878
+ log.detail("No registry components installed yet: odori add title-reveal end-card");
2690
2879
  }
2691
2880
  log.detail("Run odori dev to preview it.");
2692
2881
  };
@@ -2725,15 +2914,15 @@ var initCommand = async (root = process.cwd()) => {
2725
2914
  [resolve21(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
2726
2915
  ];
2727
2916
  for (const [file, contents] of files) {
2728
- if (existsSync17(file)) {
2729
- log.detail(`Kept existing ${relative7(root, file)}`);
2917
+ if (existsSync18(file)) {
2918
+ log.detail(`Kept existing ${relative8(root, file)}`);
2730
2919
  continue;
2731
2920
  }
2732
2921
  await mkdir14(resolve21(file, ".."), { recursive: true });
2733
2922
  await writeFile15(file, contents, "utf8");
2734
- log.success(`Created ${relative7(root, file)}`);
2923
+ log.success(`Created ${relative8(root, file)}`);
2735
2924
  }
2736
- log.detail("Next: odori doctor, then odori add @odori/title-reveal @odori/end-card, then odori dev.");
2925
+ log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
2737
2926
  };
2738
2927
 
2739
2928
  // src/commands/inspect.ts
@@ -2855,7 +3044,7 @@ var stillCommand = async (id, options = {}) => {
2855
3044
  import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
2856
3045
 
2857
3046
  // src/contracts.ts
2858
- import { existsSync as existsSync18 } from "fs";
3047
+ import { existsSync as existsSync19 } from "fs";
2859
3048
  import { readdir as readdir6 } from "fs/promises";
2860
3049
  import { resolve as resolve23 } from "path";
2861
3050
  import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout7 } from "odori";
@@ -2925,7 +3114,7 @@ var checkAudioWindows = (cues, brand, videoId) => {
2925
3114
  };
2926
3115
  var checkInstalledContracts = async (config, videos) => {
2927
3116
  const componentsDir = resolve23(config.root, config.componentsDir);
2928
- const onDisk = existsSync18(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3117
+ const onDisk = existsSync19(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
2929
3118
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
2930
3119
  if (names.size === 0) return [];
2931
3120
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -2947,8 +3136,8 @@ var checkInstalledContracts = async (config, videos) => {
2947
3136
 
2948
3137
  // src/determinism.ts
2949
3138
  import { readdir as readdir7, readFile as readFile14 } from "fs/promises";
2950
- import { existsSync as existsSync19 } from "fs";
2951
- import { join as join7, relative as relative8, resolve as resolve24 } from "path";
3139
+ import { existsSync as existsSync20 } from "fs";
3140
+ import { join as join7, relative as relative9, resolve as resolve24 } from "path";
2952
3141
  var FORBIDDEN = [
2953
3142
  {
2954
3143
  pattern: /\bMath\.random\s*\(/,
@@ -2990,10 +3179,10 @@ var walk2 = async (directory2, files = []) => {
2990
3179
  };
2991
3180
  var checkDeterminism = async (config) => {
2992
3181
  const root = resolve24(config.root, config.videosDir);
2993
- if (!existsSync19(root)) return [];
3182
+ if (!existsSync20(root)) return [];
2994
3183
  const files = await walk2(root);
2995
3184
  const findings = await Promise.all(
2996
- files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative8(config.root, file)))
3185
+ files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative9(config.root, file)))
2997
3186
  );
2998
3187
  return findings.flat();
2999
3188
  };
@@ -3259,7 +3448,7 @@ var COMMAND_FLAGS = {
3259
3448
  inspect: ["json", "input"],
3260
3449
  still: ["frame", "output", "input"],
3261
3450
  test: ["json"],
3262
- export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
3451
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
3263
3452
  jobs: [],
3264
3453
  help: []
3265
3454
  };
@@ -3326,9 +3515,12 @@ var USAGE = {
3326
3515
  Validate contracts and representative frames. --json emits one object per
3327
3516
  check, for CI.`,
3328
3517
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3329
- [--preset <name>] [--format <name>] [--no-frame-skip] [--retry <job>]
3518
+ [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
3519
+ [--no-frame-skip] [--retry <job>]
3330
3520
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
3331
- or png; without it the output's extension decides, and mp4 is the default.`,
3521
+ or png; without it the output's extension decides, and mp4 is the default.
3522
+ --quality is studio, social, or web. --scale multiplies the output size,
3523
+ 0.25 to 2. A retry keeps the settings its job was created with.`,
3332
3524
  jobs: `odori jobs
3333
3525
  List export jobs and their status.`
3334
3526
  };
@@ -3358,6 +3550,8 @@ Options
3358
3550
  --concurrency <n> Parallel render workers for export
3359
3551
  --preset <name> x264 preset for export, default medium
3360
3552
  --format <name> mp4, webm, prores, gif, or png
3553
+ --quality <tier> studio, social, or web compression
3554
+ --scale <n> Output size multiplier, 0.25 to 2
3361
3555
  --no-frame-skip Capture every frame, even unchanged ones
3362
3556
  --retry <job id> Re-run a recorded job from its frozen manifest
3363
3557
  --no-open Start dev without opening Studio in a browser
@@ -3444,6 +3638,8 @@ var run2 = async (argv) => {
3444
3638
  input: parseInput(flags),
3445
3639
  concurrency: numberFlag(flags, "concurrency"),
3446
3640
  preset: typeof flags.preset === "string" ? flags.preset : void 0,
3641
+ quality: typeof flags.quality === "string" ? flags.quality : void 0,
3642
+ scale: numberFlag(flags, "scale"),
3447
3643
  format: typeof flags.format === "string" ? flags.format : void 0,
3448
3644
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3449
3645
  retry: typeof flags.retry === "string" ? flags.retry : void 0
@@ -3478,6 +3674,7 @@ export {
3478
3674
  discoverProject,
3479
3675
  generateImports,
3480
3676
  writeGenerated,
3677
+ startStudioServer,
3481
3678
  localCandidates,
3482
3679
  createIntegrityResolver,
3483
3680
  outputName,
@@ -3497,7 +3694,6 @@ export {
3497
3694
  findVideo,
3498
3695
  runPrepare,
3499
3696
  freezeManifest,
3500
- startStudioServer,
3501
3697
  resolveCueFile,
3502
3698
  buildAudioFilter,
3503
3699
  planChunks,