@docyrus/animaguru 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -273,6 +273,16 @@ animaguru submit --file production/exports/film.zip --dry-run
273
273
 
274
274
  An optional positional directory selects the workspace; file and poster paths are relative to it. HTML5 exports upload the compiled animation and local media; source code, project instructions, credentials and generation receipts are not submitted. MP4 and AnimaGuru HTML5 ZIP are both supported. The CLI prints the video ID and full watch URL. Public HTML5 videos offer embed markup on the watch page; private sharing requires the selected AnimaGuru users to sign in.
275
275
 
276
+ MP4 subtitles: CLI 0.10.0 uploads an MP4's subtitles with it, and the AnimaGuru player shows them over the picture with a CC button to turn them off and a menu when there are several languages. The MP4 itself stays clean. With the default `--subtitles auto`, `submit --file FILM.mp4` adds `FILM.vtt` (or else `FILM.srt`), which `npm run export:mp4` writes beside it. `--subtitles FILE` chooses another `.vtt`/`.srt` file and `--subtitles none` skips them. The language comes from `--subtitle-language`, a WebVTT `Language:` header, the export receipt (`FILM.json`) or `film.config.json`. The file is checked before any video bytes are sent, and the dry run lists it. An MP4's width, height and duration now come from the MP4 itself and its default title from the export receipt, so another language's cut no longer takes the current `film.config.json` values. Burned-in `--burn-captions` exports have no subtitle files, so nothing is added. To add, replace or remove one language on an uploaded MP4:
277
+
278
+ ```sh
279
+ animaguru videos subtitles VIDEO_ID --file production/exports/film-tr.vtt --yes # language from the file or its export receipt
280
+ animaguru videos subtitles VIDEO_ID --file subtitles.srt --language de --yes
281
+ animaguru videos subtitles VIDEO_ID --remove de --yes
282
+ ```
283
+
284
+ Each upload of up to 1 MiB is stored as WebVTT, up to 20 languages per video. HTML5 films draw their own captions and take no subtitle files. New subtitles on a public video send it back for staff review, like a new poster; removing a language does not.
285
+
276
286
  To request public gallery publication, upload privately, open the video in My videos on the website, choose Share → Public, and accept the public video sharing agreement. Staff must approve the submission before anyone else can access its public link, download or embed. Direct CLI public-sharing requests without agreement acceptance are refused by the API.
277
287
 
278
288
  `videos list --scope mine|shared|public` lists films. Owners can edit metadata, change visibility and replace recipients on the website, or use `videos share VIDEO_ID --visibility private --share "alex@example.com" --yes`. `videos delete VIDEO_ID --yes` removes the film and files. Making a film private or removing a recipient revokes access to existing playback links.
package/dist/animaguru.js CHANGED
@@ -542,7 +542,7 @@ var init_dist = __esm({
542
542
  // cli/package.json
543
543
  var package_default = {
544
544
  name: "@docyrus/animaguru",
545
- version: "0.9.0",
545
+ version: "0.10.0",
546
546
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
547
547
  type: "module",
548
548
  bin: {
@@ -2670,9 +2670,9 @@ var styleMethodCodes = {
2670
2670
  };
2671
2671
  var styleMethodNames = Object.freeze(Object.keys(styleMethodCodes));
2672
2672
  var styleMethodPairs = Object.freeze(Object.fromEntries(styleMethodNames.map((name) => {
2673
- const [open5, close] = styleMethodCodes[name];
2673
+ const [open6, close] = styleMethodCodes[name];
2674
2674
  return [name, {
2675
- open: `\x1B[${open5}m`,
2675
+ open: `\x1B[${open6}m`,
2676
2676
  close: `\x1B[${close}m`
2677
2677
  }];
2678
2678
  })));
@@ -3479,9 +3479,9 @@ function applyStyle(text, style2) {
3479
3479
  if (text == null) return "";
3480
3480
  let result = String(text);
3481
3481
  if (result === "") return "";
3482
- const { open: open5, close } = style2;
3483
- if (result.includes(close)) result = result.replaceAll(close, close + open5);
3484
- return open5 + result + close;
3482
+ const { open: open6, close } = style2;
3483
+ if (result.includes(close)) result = result.replaceAll(close, close + open6);
3484
+ return open6 + result + close;
3485
3485
  }
3486
3486
  var FG_CLOSE = "\x1B[39m";
3487
3487
  var BG_CLOSE = "\x1B[49m";
@@ -3684,15 +3684,15 @@ function buildChainableStyleFactory(resolveCapabilities, runtime) {
3684
3684
  value: (input2) => createChainableStyle([...steps, colorStep(kind, input2)]),
3685
3685
  writable: false
3686
3686
  });
3687
- let open5 = "";
3687
+ let open6 = "";
3688
3688
  let close = "";
3689
3689
  for (const step of steps) {
3690
3690
  const pair = stepPair(step, pairDepth);
3691
- open5 += pair.open;
3691
+ open6 += pair.open;
3692
3692
  close = pair.close + close;
3693
3693
  }
3694
3694
  Object.defineProperty(styleFn, "open", {
3695
- value: open5,
3695
+ value: open6,
3696
3696
  writable: false,
3697
3697
  configurable: false,
3698
3698
  enumerable: true
@@ -5076,8 +5076,8 @@ var version = defineExtension(VERSION).factory((extension, value, options = {})
5076
5076
 
5077
5077
  // cli/src/commands.ts
5078
5078
  import { spawn as spawn3 } from "node:child_process";
5079
- import { readFile as readFile11, writeFile as writeFile9 } from "node:fs/promises";
5080
- import { resolve as resolve10 } from "node:path";
5079
+ import { readFile as readFile12, writeFile as writeFile9 } from "node:fs/promises";
5080
+ import { resolve as resolve11 } from "node:path";
5081
5081
  import { setTimeout as delay3 } from "node:timers/promises";
5082
5082
 
5083
5083
  // node_modules/fflate/esm/index.mjs
@@ -11223,7 +11223,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
11223
11223
  var NONE = 0;
11224
11224
  var ASSUMED = 1;
11225
11225
  var PROVEN = 2;
11226
- function isRecursive(inst, stack, resolve11) {
11226
+ function isRecursive(inst, stack, resolve12) {
11227
11227
  const cached2 = recursive.get(inst);
11228
11228
  if (cached2 !== void 0)
11229
11229
  return cached2 ? PROVEN : NONE;
@@ -11233,7 +11233,7 @@ function isRecursive(inst, stack, resolve11) {
11233
11233
  let result = NONE;
11234
11234
  const check2 = (child) => {
11235
11235
  if (result !== PROVEN && child?._zod) {
11236
- const answer = isRecursive(child, stack, resolve11);
11236
+ const answer = isRecursive(child, stack, resolve12);
11237
11237
  if (answer > result)
11238
11238
  result = answer;
11239
11239
  }
@@ -11244,7 +11244,7 @@ function isRecursive(inst, stack, resolve11) {
11244
11244
  const desc = Object.getOwnPropertyDescriptor(sh, key);
11245
11245
  if (spread && !desc.enumerable)
11246
11246
  continue;
11247
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve11) : NONE;
11247
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve12) : NONE;
11248
11248
  if (child > answer)
11249
11249
  answer = child;
11250
11250
  }
@@ -11308,7 +11308,7 @@ function isRecursive(inst, stack, resolve11) {
11308
11308
  break;
11309
11309
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
11310
11310
  case "lazy": {
11311
- const inner = def._cachedInner ?? (resolve11 ? inst._zod.innerType : void 0);
11311
+ const inner = def._cachedInner ?? (resolve12 ? inst._zod.innerType : void 0);
11312
11312
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
11313
11313
  break;
11314
11314
  }
@@ -26999,8 +26999,8 @@ async function createWorkspace(options, installer = installDependencies) {
26999
26999
  await rm2(join5(directory, "runtime/main.ts"));
27000
27000
  await rename3(join5(directory, "film.config.json"), join5(directory, "templates/animation/film.config.json"));
27001
27001
  await rename3(join5(directory, "shared/locales/en/film.json"), join5(directory, "templates/animation/shared/locales/en/film.json"));
27002
- const { createHash: createHash4 } = await import("node:crypto");
27003
- await writeFile6(join5(directory, "templates/animation/starter-film.sha256"), createHash4("sha256").update(await readFile4(join5(directory, "templates/animation/src/film.ts"))).digest("hex") + "\n");
27002
+ const { createHash: createHash5 } = await import("node:crypto");
27003
+ await writeFile6(join5(directory, "templates/animation/starter-film.sha256"), createHash5("sha256").update(await readFile4(join5(directory, "templates/animation/src/film.ts"))).digest("hex") + "\n");
27004
27004
  await rm2(join5(directory, "animaguru.project.json"));
27005
27005
  await mkdir6(join5(directory, "animations"));
27006
27006
  await mkdir6(join5(directory, "branding"));
@@ -27184,7 +27184,7 @@ function elevenLabsSpeechCostUsd(model, characters, now = Date.now()) {
27184
27184
  const rate = model === "eleven_v4_turbo" ? promotion ? 0.011 : 0.04 : promotion ? 0.022 : 0.08;
27185
27185
  return Math.max(0, characters) * rate / 1e3;
27186
27186
  }
27187
- var elevenLabsSfxCostUsd = (seconds) => Math.max(0, seconds) * 0.12 / 60;
27187
+ var elevenLabsSfxCostUsd = (seconds2) => Math.max(0, seconds2) * 0.12 / 60;
27188
27188
  var speechCharacters = (text) => Array.from(text).length;
27189
27189
  function assetEstimate(p) {
27190
27190
  if (p.type === "image") return Math.ceil(130 * spriteImageModel(p.model).relCost);
@@ -27196,11 +27196,11 @@ function assetEstimate(p) {
27196
27196
  }
27197
27197
 
27198
27198
  // cli/src/videos.ts
27199
- import { open as open2, readFile as readFile6, stat } from "node:fs/promises";
27200
- import { basename as basename3, join as join7, resolve as resolve7 } from "node:path";
27199
+ import { open as open3, readFile as readFile7, stat as stat2 } from "node:fs/promises";
27200
+ import { basename as basename4, join as join7, resolve as resolve8 } from "node:path";
27201
27201
  import { setTimeout as delay } from "node:timers/promises";
27202
27202
  import { spawn as spawn2 } from "node:child_process";
27203
- import { createHash } from "node:crypto";
27203
+ import { createHash as createHash2 } from "node:crypto";
27204
27204
 
27205
27205
  // shared/videos.ts
27206
27206
  var VIDEO_PART_BYTES = 8 * 1024 * 1024;
@@ -27242,11 +27242,158 @@ function videoPosterType(bytes) {
27242
27242
  throw new Error("Use a PNG, JPEG or WebP poster up to 2 MiB.");
27243
27243
  }
27244
27244
 
27245
+ // shared/subtitles.ts
27246
+ var SUBTITLE_MAX_BYTES = 1024 * 1024;
27247
+ var MAX_CUES = 5e3;
27248
+ var MAX_CUE_TEXT = 1e3;
27249
+ var MAX_SECONDS = 7200;
27250
+ var subtitleLanguage = external_exports.string().regex(/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/, "Use a subtitle language code such as en, tr or pt-BR.");
27251
+ var TIME = /^(?:(\d{1,3}):)?(\d{1,2}):(\d{2})[.,](\d{3})$/;
27252
+ function seconds(value) {
27253
+ const match = TIME.exec(value.trim());
27254
+ if (!match || Number(match[2]) > 59 || Number(match[3]) > 59) return NaN;
27255
+ return Number(match[1] ?? 0) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4]) / 1e3;
27256
+ }
27257
+ var subRipText = (text) => text.replace(/<(?!\/?[ibu]>)[^<>]*>/gi, "").replace(/&(?!(?:[a-z][a-z\d]*|#\d+|#x[\da-f]+);)/gi, "&amp;").replace(/<(?!\/?[ibu]>)/gi, "&lt;");
27258
+ function parseSubtitles(input2, duration3) {
27259
+ const text = input2.replace(/^/, "").replace(/\r\n?/g, "\n");
27260
+ if (text.includes("\0") || text.includes("\uFFFD")) throw new Error("Use a UTF-8 text subtitle file.");
27261
+ const vtt = /^WEBVTT(?:[ \t][^\n]*)?(?:\n|$)/.test(text);
27262
+ const cues = [];
27263
+ for (const [index, block] of text.split(/\n[ \t]*\n/).entries()) {
27264
+ const lines = block.split("\n").filter((line) => line.trim());
27265
+ if (!lines.length || vtt && (index === 0 || /^(?:NOTE|STYLE|REGION)(?:[ \t]|$)/.test(lines[0]))) continue;
27266
+ const timing = lines.findIndex((line) => line.includes("-->"));
27267
+ if (timing < 0 || timing > 1) throw new Error(`Subtitle block ${index + 1} has no "start --> end" timing line.`);
27268
+ const [from, rest = ""] = lines[timing].split("-->");
27269
+ const start = seconds(from);
27270
+ let end = seconds(rest.trim().split(/[ \t]+/)[0] ?? "");
27271
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || end > MAX_SECONDS) throw new Error(`Subtitle block ${index + 1} has an invalid timing: ${lines[timing].trim().slice(0, 60)}`);
27272
+ const body = lines.slice(timing + 1).filter((line) => !line.includes("-->")).map((line) => line.trimEnd()).join("\n").trim();
27273
+ if (!body) continue;
27274
+ const cueText = vtt ? body : subRipText(body);
27275
+ if (cueText.length > MAX_CUE_TEXT) throw new Error(`Subtitle block ${index + 1} is longer than ${MAX_CUE_TEXT} characters.`);
27276
+ if (duration3 != null) {
27277
+ if (start >= duration3) continue;
27278
+ end = Math.min(end, duration3);
27279
+ }
27280
+ cues.push({ start, end, text: cueText });
27281
+ if (cues.length > MAX_CUES) throw new Error(`Subtitle files can have up to ${MAX_CUES} captions.`);
27282
+ }
27283
+ if (!cues.length) throw new Error("The subtitle file has no timed captions.");
27284
+ return cues.sort((a, b) => a.start - b.start || a.end - b.end);
27285
+ }
27286
+
27287
+ // cli/src/export-files.ts
27288
+ import { open as open2, readFile as readFile5, stat } from "node:fs/promises";
27289
+ import { basename as basename3, resolve as resolve6 } from "node:path";
27290
+ import { createHash } from "node:crypto";
27291
+ async function exportReceipt(file2) {
27292
+ try {
27293
+ const receipt = JSON.parse(await readFile5(file2.replace(/\.mp4$/i, ".json"), "utf8"));
27294
+ if (receipt.file !== basename3(file2)) return {};
27295
+ return { title: typeof receipt.title === "string" ? receipt.title : void 0, language: typeof receipt.settings?.language === "string" ? receipt.settings.language : void 0 };
27296
+ } catch {
27297
+ return {};
27298
+ }
27299
+ }
27300
+ async function readSubtitleFile(file2, maxBytes) {
27301
+ let info;
27302
+ try {
27303
+ info = await stat(file2);
27304
+ } catch {
27305
+ throw new Error(`Cannot read the subtitle file ${file2}.`);
27306
+ }
27307
+ if (!info.isFile() || !/\.(?:srt|vtt)$/i.test(file2) || info.size < 1 || info.size > maxBytes) throw new Error(`Use a .srt or .vtt subtitle file up to ${maxBytes / 1024 / 1024} MiB, such as the one npm run export:mp4 writes beside the MP4.`);
27308
+ const content = await readFile5(file2);
27309
+ const text = content.toString("utf8");
27310
+ if (text.includes("\uFFFD") || !/\d\d:\d\d[,.]\d{3} --> /.test(text)) throw new Error("Use a UTF-8 .srt or .vtt subtitle file with timed captions.");
27311
+ return { content, sha256: createHash("sha256").update(content).digest("hex"), language: /^\uFEFF?WEBVTT[^\n]*\n(?:[^\n]+\n)*?Language:[ \t]*([^\s]+)/.exec(text)?.[1] };
27312
+ }
27313
+ async function subtitleFile(root, mp4, choice, extensions) {
27314
+ if (choice === "none") return null;
27315
+ if (choice !== "auto") return { file: resolve6(root, choice), explicit: true };
27316
+ for (const extension of extensions) {
27317
+ const file2 = mp4.replace(/\.mp4$/i, extension);
27318
+ if (await stat(file2).then((info) => info.isFile(), () => false)) return { file: file2, explicit: false };
27319
+ }
27320
+ return null;
27321
+ }
27322
+ function* boxes(data) {
27323
+ for (let offset = 0; offset + 8 <= data.length; ) {
27324
+ let size = data.readUInt32BE(offset);
27325
+ let header = 8;
27326
+ if (size === 1) {
27327
+ size = Number(data.readBigUInt64BE(offset + 8));
27328
+ header = 16;
27329
+ } else if (size === 0) size = data.length - offset;
27330
+ if (size < header || offset + size > data.length) return;
27331
+ yield { type: data.toString("latin1", offset + 4, offset + 8), body: data.subarray(offset + header, offset + size) };
27332
+ offset += size;
27333
+ }
27334
+ }
27335
+ async function mp4Info(file2) {
27336
+ const handle = await open2(file2, "r");
27337
+ try {
27338
+ const { size } = await handle.stat();
27339
+ const read2 = async (offset, length) => {
27340
+ const buffer = Buffer.alloc(length);
27341
+ const { bytesRead } = await handle.read(buffer, 0, length, offset);
27342
+ return buffer.subarray(0, bytesRead);
27343
+ };
27344
+ let moov = null;
27345
+ for (let offset = 0; offset + 8 <= size; ) {
27346
+ const header = await read2(offset, 16);
27347
+ let length = header.readUInt32BE(0);
27348
+ let headerSize = 8;
27349
+ if (length === 1) {
27350
+ length = Number(header.readBigUInt64BE(8));
27351
+ headerSize = 16;
27352
+ } else if (length === 0) length = size - offset;
27353
+ if (length < headerSize) break;
27354
+ if (header.toString("latin1", 4, 8) === "moov") {
27355
+ if (length <= 64 * 1024 * 1024) moov = await read2(offset + headerSize, length - headerSize);
27356
+ break;
27357
+ }
27358
+ offset += length;
27359
+ }
27360
+ if (!moov) return null;
27361
+ let duration3 = 0;
27362
+ let width = 0;
27363
+ let height = 0;
27364
+ for (const box of boxes(moov)) {
27365
+ if (box.type === "mvhd" && box.body.length >= 32) {
27366
+ const long = box.body[0] === 1;
27367
+ const timescale = box.body.readUInt32BE(long ? 20 : 12);
27368
+ const units = long ? Number(box.body.readBigUInt64BE(24)) : box.body.readUInt32BE(16);
27369
+ if (timescale) duration3 = units / timescale;
27370
+ } else if (box.type === "trak" && !width) {
27371
+ let tkhd;
27372
+ let handler = "";
27373
+ for (const child of boxes(box.body)) {
27374
+ if (child.type === "tkhd") tkhd = child.body;
27375
+ if (child.type === "mdia") {
27376
+ for (const media of boxes(child.body)) if (media.type === "hdlr" && media.body.length >= 12) handler = media.body.toString("latin1", 8, 12);
27377
+ }
27378
+ }
27379
+ const at = tkhd?.[0] === 1 ? 88 : 76;
27380
+ if (handler !== "vide" || !tkhd || tkhd.length < at + 8) continue;
27381
+ width = Math.round(tkhd.readUInt32BE(at) / 65536);
27382
+ height = Math.round(tkhd.readUInt32BE(at + 4) / 65536);
27383
+ if (tkhd.readInt32BE(at - 36) === 0 && tkhd.readInt32BE(at - 20) === 0) [width, height] = [height, width];
27384
+ }
27385
+ }
27386
+ return width && height && duration3 ? { width, height, duration: Math.round(duration3 * 1e3) / 1e3 } : null;
27387
+ } finally {
27388
+ await handle.close();
27389
+ }
27390
+ }
27391
+
27245
27392
  // cli/src/local-project.ts
27246
- import { readFile as readFile5, readdir as readdir2 } from "node:fs/promises";
27247
- import { join as join6, relative, resolve as resolve6, sep } from "node:path";
27393
+ import { readFile as readFile6, readdir as readdir2 } from "node:fs/promises";
27394
+ import { join as join6, relative, resolve as resolve7, sep } from "node:path";
27248
27395
  async function localProject(directory, selected) {
27249
- const root = resolve6(directory);
27396
+ const root = resolve7(directory);
27250
27397
  let workspace;
27251
27398
  try {
27252
27399
  workspace = await findWorkspace(root);
@@ -27261,7 +27408,7 @@ async function localProject(directory, selected) {
27261
27408
  for (const entry of await readdir2(join6(workspace, "animations"), { withFileTypes: true })) {
27262
27409
  if (!entry.isDirectory()) continue;
27263
27410
  try {
27264
- await readFile5(join6(workspace, "animations", entry.name, "animaguru.project.json"));
27411
+ await readFile6(join6(workspace, "animations", entry.name, "animaguru.project.json"));
27265
27412
  names.push(entry.name);
27266
27413
  } catch (error62) {
27267
27414
  if (error62.code !== "ENOENT") throw error62;
@@ -27272,17 +27419,18 @@ async function localProject(directory, selected) {
27272
27419
  }
27273
27420
  if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(animation)) throw new Error("Use a lowercase, hyphenated animation name.");
27274
27421
  const projectRoot = join6(workspace, "animations", animation);
27275
- await readFile5(join6(projectRoot, "animaguru.project.json"));
27422
+ await readFile6(join6(projectRoot, "animaguru.project.json"));
27276
27423
  return { toolsRoot: workspace, projectRoot, animation };
27277
27424
  }
27278
27425
 
27279
27426
  // cli/src/videos.ts
27427
+ var subtitlesBody = (content) => new Blob([new Uint8Array(content)], { type: "text/plain; charset=utf-8" });
27280
27428
  function sharingEmails(value) {
27281
27429
  return value ? value.split(",").map((email3) => email3.trim()).filter(Boolean) : [];
27282
27430
  }
27283
27431
  async function exportHtml(root, animation, signal, log) {
27284
27432
  const script = join7(root, "scripts/export-html.mjs");
27285
- await stat(script).catch(() => {
27433
+ await stat2(script).catch(() => {
27286
27434
  throw new Error("Choose an AnimaGuru video project, or pass --file for an exported HTML5 ZIP or MP4.");
27287
27435
  });
27288
27436
  log?.("Building the portable HTML5 animation\u2026");
@@ -27314,25 +27462,26 @@ async function exportHtml(root, animation, signal, log) {
27314
27462
  }
27315
27463
  async function submitVideo(client, options) {
27316
27464
  if (!options.yes && !options["dry-run"]) throw new Error("Pass --yes to authorize uploading and sharing this animation, or --dry-run to inspect the submission.");
27317
- const root = resolve7(options.directory ?? ".");
27465
+ const root = resolve8(options.directory ?? ".");
27318
27466
  const context = await localProject(root, options.animation);
27319
- const file2 = options.file ? resolve7(root, options.file) : await exportHtml(context.toolsRoot, context.animation, options.signal, options.log);
27320
- const info = await stat(file2);
27467
+ const file2 = options.file ? resolve8(root, options.file) : await exportHtml(context.toolsRoot, context.animation, options.signal, options.log);
27468
+ const info = await stat2(file2);
27321
27469
  const format = file2.toLowerCase().endsWith(".zip") ? "html5" : file2.toLowerCase().endsWith(".mp4") ? "mp4" : null;
27322
27470
  if (!info.isFile() || !format) throw new Error("Submit an AnimaGuru HTML5 .zip export or an .mp4 file.");
27323
27471
  videoSubmission.parse({ title: options.title ?? "Video", format, bytes: info.size });
27324
27472
  let metadata = {};
27473
+ let receipt = {};
27325
27474
  if (format === "html5") {
27326
- const files = unzipSync(new Uint8Array(await readFile6(file2)), { filter: (entry) => entry.name === "manifest.json" && entry.originalSize <= 256 * 1024 });
27475
+ const files = unzipSync(new Uint8Array(await readFile7(file2)), { filter: (entry) => entry.name === "manifest.json" && entry.originalSize <= 256 * 1024 });
27327
27476
  if (!files["manifest.json"]) throw new Error("Use an AnimaGuru HTML5 export with manifest.json.");
27328
27477
  metadata = JSON.parse(strFromU8(files["manifest.json"]));
27329
27478
  if (metadata.format !== "spriteguru-html5" || metadata.version !== 1) throw new Error("Use an AnimaGuru HTML5 export.");
27330
27479
  } else {
27331
27480
  try {
27332
- metadata = JSON.parse(await readFile6(join7(context.projectRoot, "film.config.json"), "utf8"));
27481
+ metadata = JSON.parse(await readFile7(join7(context.projectRoot, "film.config.json"), "utf8"));
27333
27482
  } catch {
27334
27483
  }
27335
- const handle2 = await open2(file2, "r");
27484
+ const handle2 = await open3(file2, "r");
27336
27485
  try {
27337
27486
  const header = Buffer.alloc(12);
27338
27487
  await handle2.read(header, 0, 12, 0);
@@ -27340,9 +27489,13 @@ async function submitVideo(client, options) {
27340
27489
  } finally {
27341
27490
  await handle2.close();
27342
27491
  }
27492
+ receipt = await exportReceipt(file2);
27493
+ const probe = await mp4Info(file2).catch(() => null);
27494
+ if (!probe) options.log?.("Could not read the MP4's size and duration; using film.config.json.");
27495
+ metadata = { ...metadata, ...receipt.title ? { title: receipt.title } : {}, ...probe };
27343
27496
  }
27344
27497
  const body = videoSubmission.parse({
27345
- title: options.title ?? metadata.title ?? basename3(file2).replace(/\.(zip|mp4)$/i, ""),
27498
+ title: options.title ?? metadata.title ?? basename4(file2).replace(/\.(zip|mp4)$/i, ""),
27346
27499
  description: options.description ?? "",
27347
27500
  visibility: options.visibility ?? "private",
27348
27501
  recipients: sharingEmails(options.share),
@@ -27352,18 +27505,29 @@ async function submitVideo(client, options) {
27352
27505
  height: metadata.height,
27353
27506
  duration: metadata.duration
27354
27507
  });
27508
+ let subtitles = null;
27509
+ const choice = options.subtitles ?? "auto";
27510
+ if (format === "html5" && choice !== "auto" && choice !== "none") throw new Error("Subtitle files are for MP4 uploads; HTML5 films show their own captions.");
27511
+ const found = format === "mp4" ? await subtitleFile(root, file2, choice, [".vtt", ".srt"]) : null;
27512
+ if (found) {
27513
+ const { content, language: header } = await readSubtitleFile(found.file, SUBTITLE_MAX_BYTES);
27514
+ const language = subtitleLanguage.safeParse(options["subtitle-language"] ?? header ?? receipt.language ?? metadata.language);
27515
+ if (!language.success) throw new Error("Pass --subtitle-language with the subtitles' language code, such as en or tr.");
27516
+ parseSubtitles(content.toString("utf8"), body.duration);
27517
+ subtitles = { file: found.file, language: language.data, content };
27518
+ }
27355
27519
  let poster;
27356
27520
  if (options.poster) {
27357
- const path = resolve7(root, options.poster);
27358
- const info2 = await stat(path);
27521
+ const path = resolve8(root, options.poster);
27522
+ const info2 = await stat2(path);
27359
27523
  if (!info2.isFile() || info2.size > 2 * 1024 * 1024) throw new Error("Poster must be a PNG, JPEG or WebP up to 2 MiB.");
27360
- poster = await readFile6(path);
27524
+ poster = await readFile7(path);
27361
27525
  videoPosterType(poster);
27362
27526
  }
27363
- if (options["dry-run"]) return { dryRun: true, file: file2, submission: body, poster: options.poster ?? null };
27527
+ if (options["dry-run"]) return { dryRun: true, file: file2, submission: body, poster: options.poster ?? null, subtitles: subtitles && { file: subtitles.file, language: subtitles.language } };
27364
27528
  const draft = await client.request("/api/v1/videos", { method: "POST", body });
27365
27529
  if (!Number.isInteger(draft.partBytes) || draft.partBytes < 1 || draft.partBytes > 8 * 1024 * 1024 || !/^vid_[a-f0-9]{32}$/.test(draft.id)) throw new Error("The server returned an invalid video upload session.");
27366
- const handle = await open2(file2, "r");
27530
+ const handle = await open3(file2, "r");
27367
27531
  let completed2 = false;
27368
27532
  try {
27369
27533
  for (let offset = 0, part = 1; offset < info.size; offset += draft.partBytes, part++) {
@@ -27372,7 +27536,7 @@ async function submitVideo(client, options) {
27372
27536
  const read2 = await handle.read(bytes, 0, bytes.length, offset);
27373
27537
  if (read2.bytesRead !== bytes.length) throw new Error("The export changed while uploading. Retry with a stable exported file.");
27374
27538
  options.log?.(`Uploading part ${part}/${Math.ceil(info.size / draft.partBytes)}\u2026`);
27375
- const sha256 = createHash("sha256").update(bytes).digest("hex");
27539
+ const sha256 = createHash2("sha256").update(bytes).digest("hex");
27376
27540
  for (let attempt = 0; ; attempt++) {
27377
27541
  try {
27378
27542
  await client.request(`/api/v1/videos/${draft.id}/parts/${part}`, { method: "PUT", body: new Blob([bytes]), headers: { "X-Content-SHA256": sha256 }, timeout: 18e4 });
@@ -27384,6 +27548,10 @@ async function submitVideo(client, options) {
27384
27548
  }
27385
27549
  }
27386
27550
  if (poster) await client.request(`/api/v1/videos/${draft.id}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
27551
+ if (subtitles) {
27552
+ options.log?.(`Adding ${subtitles.language} subtitles from ${subtitles.file}\u2026`);
27553
+ await client.request(`/api/v1/videos/${draft.id}/subtitles/${subtitles.language}`, { method: "PUT", body: subtitlesBody(subtitles.content) });
27554
+ }
27387
27555
  options.log?.("Preparing your AnimaGuru player\u2026");
27388
27556
  const result = await client.request(`/api/v1/videos/${draft.id}/complete`, { method: "POST", timeout: 3e5 });
27389
27557
  completed2 = true;
@@ -27395,10 +27563,25 @@ async function submitVideo(client, options) {
27395
27563
  await handle.close();
27396
27564
  }
27397
27565
  }
27566
+ async function changeVideoSubtitles(client, id2, options) {
27567
+ if (!options.yes) throw new Error("Pass --yes to change this video's subtitles.");
27568
+ const path = `/api/v1/videos/${encodeURIComponent(id2)}/subtitles/`;
27569
+ if (options.remove) {
27570
+ if (options.file) throw new Error("Pass either --file or --remove.");
27571
+ return client.request(path + encodeURIComponent(options.remove), { method: "DELETE" });
27572
+ }
27573
+ if (!options.file) throw new Error("Pass --file with a .srt or .vtt subtitle file, or --remove LANGUAGE.");
27574
+ const file2 = resolve8(options.file);
27575
+ const { content, language: header } = await readSubtitleFile(file2, SUBTITLE_MAX_BYTES);
27576
+ const language = subtitleLanguage.safeParse(options.language ?? header ?? (await exportReceipt(file2.replace(/\.(?:srt|vtt)$/i, ".mp4"))).language);
27577
+ if (!language.success) throw new Error("Pass --language with the subtitles' language code, such as en or tr.");
27578
+ parseSubtitles(content.toString("utf8"));
27579
+ return client.request(path + language.data, { method: "PUT", body: subtitlesBody(content) });
27580
+ }
27398
27581
 
27399
27582
  // cli/src/assets.ts
27400
- import { readFile as readFile7, writeFile as writeFile7 } from "node:fs/promises";
27401
- import { resolve as resolve8 } from "node:path";
27583
+ import { readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
27584
+ import { resolve as resolve9 } from "node:path";
27402
27585
  var extras = { image: ["aspect-ratio", "transparent"], video: ["workflow", "reference", "end-reference", "source", "soundtrack", "audio-references", "seconds", "resolution", "mode", "aspect-ratio", "prompt-expansion", "seed", "extend-output"], music: [], speech: ["voice", "delivery"], sfx: ["seconds", "loop", "prompt-influence"] };
27403
27586
  function parseAsset(input2) {
27404
27587
  const parsed = assetRequest.safeParse(input2);
@@ -27436,7 +27619,7 @@ function assetFromFlags(flags) {
27436
27619
  return parseAsset({ ...base2, ...flags.type === "speech" ? { voice: flags.voice, delivery: flags.delivery } : {} });
27437
27620
  }
27438
27621
  async function assetFromFile(file2, model) {
27439
- const input2 = JSON.parse(await readFile7(file2, "utf8"));
27622
+ const input2 = JSON.parse(await readFile8(file2, "utf8"));
27440
27623
  if (!input2 || typeof input2 !== "object" || Array.isArray(input2)) throw new Error("The asset file must contain a JSON object.");
27441
27624
  return parseAsset({ ...input2, ...model ? { model } : {} });
27442
27625
  }
@@ -27451,7 +27634,7 @@ async function downloadAsset(client, project, assetId, output2) {
27451
27634
  if (!asset) throw new Error("Asset not found in this project. Run assets list to find its id.");
27452
27635
  if (!library.ownerId || !/^[a-f0-9]{64}$/.test(asset.file)) throw new Error("Invalid asset download metadata.");
27453
27636
  const response = await client.request(`/api/v1/blobs/${encodeURIComponent(library.ownerId)}/${asset.file}`, { raw: true });
27454
- const path = resolve8(output2);
27637
+ const path = resolve9(output2);
27455
27638
  const bytes = new Uint8Array(await response.arrayBuffer());
27456
27639
  await writeFile7(path, bytes, { flag: "wx" });
27457
27640
  return { path, id: asset.id, type: asset.type, mimeType: asset.mimeType, size: bytes.byteLength };
@@ -27459,11 +27642,11 @@ async function downloadAsset(client, project, assetId, output2) {
27459
27642
 
27460
27643
  // cli/src/youtube-auth.ts
27461
27644
  import { createServer } from "node:http";
27462
- import { randomBytes, createHash as createHash2, timingSafeEqual } from "node:crypto";
27463
- import { readFile as readFile9, stat as stat2 } from "node:fs/promises";
27645
+ import { randomBytes, createHash as createHash3, timingSafeEqual } from "node:crypto";
27646
+ import { readFile as readFile10, stat as stat3 } from "node:fs/promises";
27464
27647
 
27465
27648
  // cli/src/youtube-store.ts
27466
- import { chmod as chmod3, lstat, mkdir as mkdir7, open as open3, readFile as readFile8, readdir as readdir3, rename as rename4, unlink, writeFile as writeFile8 } from "node:fs/promises";
27649
+ import { chmod as chmod3, lstat, mkdir as mkdir7, open as open4, readFile as readFile9, readdir as readdir3, rename as rename4, unlink, writeFile as writeFile8 } from "node:fs/promises";
27467
27650
  import { dirname as dirname6, join as join8 } from "node:path";
27468
27651
  import { randomUUID as randomUUID2 } from "node:crypto";
27469
27652
  var youtubeMetadata = external_exports.object({
@@ -27547,7 +27730,7 @@ async function readPrivate(file2) {
27547
27730
  const info = await lstat(file2);
27548
27731
  if (info.isSymbolicLink() || !info.isFile() || info.size > 65536) throw new Error("Invalid local YouTube state file.");
27549
27732
  await chmod3(file2, 384);
27550
- return JSON.parse(await readFile8(file2, "utf8"));
27733
+ return JSON.parse(await readFile9(file2, "utf8"));
27551
27734
  } catch (error62) {
27552
27735
  if (error62.code === "ENOENT") return null;
27553
27736
  throw new Error("Cannot read local YouTube state. Check your AnimaGuru configuration directory.");
@@ -27618,7 +27801,7 @@ async function clearYouTubeData() {
27618
27801
  async function withYouTubeLock(operation) {
27619
27802
  await privateDirectory(youtubeDirectory());
27620
27803
  const file2 = join8(youtubeDirectory(), "operation.lock");
27621
- const lock = await open3(file2, "wx", 384).catch((error62) => {
27804
+ const lock = await open4(file2, "wx", 384).catch((error62) => {
27622
27805
  if (error62.code === "EEXIST") throw new Error(`Another YouTube command is running. If a previous command was killed, verify it has stopped before removing ${file2}.`);
27623
27806
  throw error62;
27624
27807
  });
@@ -27756,19 +27939,19 @@ async function revoke(token, signal) {
27756
27939
  }
27757
27940
  async function youtubeLogin(options) {
27758
27941
  if (!options.yes) throw new Error("Pass --yes to accept YouTube's Terms of Service and authorize saving YouTube credentials locally. Review https://www.youtube.com/t/terms and https://policies.google.com/privacy first.");
27759
- const seconds = options.timeout ?? 300;
27760
- if (!Number.isFinite(seconds) || seconds < 1 || seconds > 600) throw new Error("Use an authorization timeout between 1 and 600 seconds.");
27942
+ const seconds2 = options.timeout ?? 300;
27943
+ if (!Number.isFinite(seconds2) || seconds2 < 1 || seconds2 > 600) throw new Error("Use an authorization timeout between 1 and 600 seconds.");
27761
27944
  const requested = options.captions ? [...YOUTUBE_SCOPES, YOUTUBE_CAPTION_SCOPE] : YOUTUBE_SCOPES;
27762
27945
  return withYouTubeLock(async () => {
27763
27946
  const existing = await readYouTubeCredentials();
27764
27947
  if (existing && options.captions && !canAddCaptions(existing)) throw new Error("A YouTube channel is already connected without caption permission. Run youtube auth logout --yes, then youtube auth login --captions.");
27765
27948
  if (existing) throw new Error("A YouTube channel is already connected. Run youtube auth logout --yes before switching channels.");
27766
27949
  const file2 = options["client-file"];
27767
- const info = await stat2(file2);
27950
+ const info = await stat3(file2);
27768
27951
  if (!info.isFile() || info.size > 65536) throw new Error("Use Google's downloaded Desktop app OAuth client JSON.");
27769
27952
  let installed;
27770
27953
  try {
27771
- installed = JSON.parse(await readFile9(file2, "utf8")).installed;
27954
+ installed = JSON.parse(await readFile10(file2, "utf8")).installed;
27772
27955
  } catch {
27773
27956
  }
27774
27957
  if (!installed || typeof installed.client_id !== "string" || !installed.client_id.endsWith(".apps.googleusercontent.com") || installed.client_secret !== void 0 && typeof installed.client_secret !== "string") throw new Error("Use Google's Desktop app OAuth client JSON (installed), not a web client or API key.");
@@ -27778,8 +27961,8 @@ async function youtubeLogin(options) {
27778
27961
  let receive;
27779
27962
  let fail;
27780
27963
  let used = false;
27781
- const callback = new Promise((resolve11, reject) => {
27782
- receive = resolve11;
27964
+ const callback = new Promise((resolve12, reject) => {
27965
+ receive = resolve12;
27783
27966
  fail = reject;
27784
27967
  });
27785
27968
  void callback.catch(() => {
@@ -27817,21 +28000,21 @@ async function youtubeLogin(options) {
27817
28000
  response.end("Authorization received. Return to your AnimaGuru terminal.");
27818
28001
  receive(code);
27819
28002
  });
27820
- await new Promise((resolve11, reject) => {
28003
+ await new Promise((resolve12, reject) => {
27821
28004
  server.once("error", reject);
27822
- server.listen(0, "127.0.0.1", resolve11);
28005
+ server.listen(0, "127.0.0.1", resolve12);
27823
28006
  });
27824
28007
  const address = server.address();
27825
28008
  if (!address || typeof address === "string") throw new Error("Cannot open the local YouTube authorization listener.");
27826
28009
  redirectUri = `http://127.0.0.1:${address.port}/oauth2callback`;
27827
- const timer = setTimeout(() => fail(new Error("YouTube authorization timed out. Run login again.")), seconds * 1e3);
28010
+ const timer = setTimeout(() => fail(new Error("YouTube authorization timed out. Run login again.")), seconds2 * 1e3);
27828
28011
  const abort = () => fail(new Error("YouTube authorization cancelled."));
27829
28012
  options.signal?.addEventListener("abort", abort, { once: true });
27830
28013
  let token;
27831
28014
  try {
27832
28015
  options.signal?.throwIfAborted();
27833
28016
  const url2 = new URL("https://accounts.google.com/o/oauth2/v2/auth");
27834
- url2.search = new URLSearchParams({ client_id: client.clientId, redirect_uri: redirectUri, response_type: "code", scope: requested.join(" "), state, code_challenge: createHash2("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", access_type: "offline", prompt: "consent select_account" }).toString();
28017
+ url2.search = new URLSearchParams({ client_id: client.clientId, redirect_uri: redirectUri, response_type: "code", scope: requested.join(" "), state, code_challenge: createHash3("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", access_type: "offline", prompt: "consent select_account" }).toString();
27835
28018
  options.log?.("YouTube authorization stays on this machine. Tokens and upload receipts are saved in owner-only local files. Video bytes go directly to Google. Google privacy: https://policies.google.com/privacy \xB7 YouTube terms: https://www.youtube.com/t/terms");
27836
28019
  options.log?.(`Open ${url2.href}`);
27837
28020
  if (options.browser !== false) await options.openBrowser?.(url2.href);
@@ -27854,7 +28037,7 @@ async function youtubeLogin(options) {
27854
28037
  clearTimeout(timer);
27855
28038
  options.signal?.removeEventListener("abort", abort);
27856
28039
  server.closeAllConnections();
27857
- await new Promise((resolve11) => server.close(() => resolve11()));
28040
+ await new Promise((resolve12) => server.close(() => resolve12()));
27858
28041
  }
27859
28042
  });
27860
28043
  }
@@ -27883,9 +28066,9 @@ async function youtubeLogout(options) {
27883
28066
  }
27884
28067
 
27885
28068
  // cli/src/youtube-upload.ts
27886
- import { createHash as createHash3, randomUUID as randomUUID3 } from "node:crypto";
27887
- import { open as open4, readFile as readFile10, stat as stat3 } from "node:fs/promises";
27888
- import { basename as basename4, join as join9, resolve as resolve9 } from "node:path";
28069
+ import { createHash as createHash4, randomUUID as randomUUID3 } from "node:crypto";
28070
+ import { open as open5, readFile as readFile11, stat as stat4 } from "node:fs/promises";
28071
+ import { basename as basename5, join as join9, resolve as resolve10 } from "node:path";
27889
28072
  import { setTimeout as delay2 } from "node:timers/promises";
27890
28073
  var YOUTUBE_CHUNK_BYTES = 8 * 1024 * 1024;
27891
28074
  function safeYouTubeSession(value) {
@@ -27899,14 +28082,14 @@ function safeYouTubeSession(value) {
27899
28082
  return url2.href;
27900
28083
  }
27901
28084
  async function inspectMp4(file2, signal) {
27902
- const handle = await open4(file2, "r");
28085
+ const handle = await open5(file2, "r");
27903
28086
  try {
27904
28087
  const info = await handle.stat();
27905
28088
  if (!info.isFile() || !file2.toLowerCase().endsWith(".mp4") || info.size < 12 || info.size > 256 * 1024 ** 3) throw new Error("Choose an exported .mp4 file up to 256 GiB. Export an HTML5 animation with npm run export:mp4 first.");
27906
28089
  const header = Buffer.alloc(12);
27907
28090
  await handle.read(header, 0, header.length, 0);
27908
28091
  if (header.toString("ascii", 4, 8) !== "ftyp") throw new Error("Use a valid MP4 file.");
27909
- const hash2 = createHash3("sha256");
28092
+ const hash2 = createHash4("sha256");
27910
28093
  for await (const buffer of handle.createReadStream({ start: 0, autoClose: false, signal })) hash2.update(buffer);
27911
28094
  const fingerprint = (value) => `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
27912
28095
  const check2 = async () => {
@@ -27920,44 +28103,13 @@ async function inspectMp4(file2, signal) {
27920
28103
  throw error62;
27921
28104
  }
27922
28105
  }
27923
- async function exportReceipt(file2) {
27924
- try {
27925
- const receipt = JSON.parse(await readFile10(file2.replace(/\.mp4$/i, ".json"), "utf8"));
27926
- if (receipt.file !== basename4(file2)) return {};
27927
- return { title: typeof receipt.title === "string" ? receipt.title : void 0, language: typeof receipt.settings?.language === "string" ? receipt.settings.language : void 0 };
27928
- } catch {
27929
- return {};
27930
- }
27931
- }
27932
- async function readCaptionFile(file2) {
27933
- let info;
27934
- try {
27935
- info = await stat3(file2);
27936
- } catch {
27937
- throw new Error(`Cannot read the subtitle file ${file2}.`);
27938
- }
27939
- if (!info.isFile() || !/\.(?:srt|vtt)$/i.test(file2) || info.size < 1 || info.size > 5 * 1024 * 1024) throw new Error("Use a .srt or .vtt subtitle file up to 5 MiB, such as the one npm run export:mp4 writes beside the MP4.");
27940
- const content = await readFile10(file2);
27941
- const text = content.toString("utf8");
27942
- if (text.includes("\uFFFD") || !/\d\d:\d\d[,.]\d{3} --> /.test(text)) throw new Error("Use a UTF-8 .srt or .vtt subtitle file with timed captions.");
27943
- return { content, sha256: createHash3("sha256").update(content).digest("hex") };
27944
- }
28106
+ var readCaptionFile = (file2) => readSubtitleFile(file2, 5 * 1024 * 1024);
27945
28107
  async function planCaptions(root, mp4, options, fallbackLanguage) {
27946
28108
  const choice = options.captions ?? "auto";
27947
- if (choice === "none") return null;
27948
- let file2 = choice === "auto" ? void 0 : resolve9(root, choice);
27949
- if (!file2) {
27950
- for (const extension of [".srt", ".vtt"]) {
27951
- const candidate = mp4.replace(/\.mp4$/i, extension);
27952
- if (await stat3(candidate).then((info) => info.isFile(), () => false)) {
27953
- file2 = candidate;
27954
- break;
27955
- }
27956
- }
27957
- if (!file2) return null;
27958
- }
27959
- const { content, sha256 } = await readCaptionFile(file2);
27960
- const header = /^\uFEFF?WEBVTT[^\n]*\n(?:[^\n]+\n)*?Language:[ \t]*([^\s]+)/.exec(content.toString("utf8"))?.[1];
28109
+ const found = await subtitleFile(root, mp4, choice, [".srt", ".vtt"]);
28110
+ if (!found) return null;
28111
+ const { file: file2 } = found;
28112
+ const { content, sha256, language: header } = await readCaptionFile(file2);
27961
28113
  const language = options["caption-language"] ?? header ?? fallbackLanguage;
27962
28114
  const name = options["caption-name"] ?? "";
27963
28115
  const plan = youtubeCaptions.safeParse({ file: file2, bytes: content.length, sha256, language, name, status: "pending", captionId: null });
@@ -27967,23 +28119,23 @@ async function planCaptions(root, mp4, options, fallbackLanguage) {
27967
28119
  async function readThumbnail(file2) {
27968
28120
  let info;
27969
28121
  try {
27970
- info = await stat3(file2);
28122
+ info = await stat4(file2);
27971
28123
  } catch {
27972
28124
  throw new Error(`Cannot read the thumbnail ${file2}.`);
27973
28125
  }
27974
28126
  if (!info.isFile() || info.size < 8 || info.size > 2 * 1024 * 1024) throw new Error("Use a JPEG or PNG thumbnail up to 2 MiB, such as the one npm run export:mp4 saves beside the MP4.");
27975
- const content = await readFile10(file2);
28127
+ const content = await readFile11(file2);
27976
28128
  const type = content[0] === 255 && content[1] === 216 && content[2] === 255 ? "image/jpeg" : content.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? "image/png" : null;
27977
28129
  if (!type) throw new Error("Use a JPEG or PNG thumbnail.");
27978
- return { content, type, sha256: createHash3("sha256").update(content).digest("hex") };
28130
+ return { content, type, sha256: createHash4("sha256").update(content).digest("hex") };
27979
28131
  }
27980
28132
  async function planThumbnail(root, mp4, choice = "auto") {
27981
28133
  if (choice === "none") return null;
27982
- let file2 = choice === "auto" ? void 0 : resolve9(root, choice);
28134
+ let file2 = choice === "auto" ? void 0 : resolve10(root, choice);
27983
28135
  if (!file2) {
27984
28136
  for (const extension of [".jpg", ".jpeg", ".png"]) {
27985
28137
  const candidate = mp4.replace(/\.mp4$/i, extension);
27986
- if (await stat3(candidate).then((info) => info.isFile(), () => false)) {
28138
+ if (await stat4(candidate).then((info) => info.isFile(), () => false)) {
27987
28139
  file2 = candidate;
27988
28140
  break;
27989
28141
  }
@@ -28167,16 +28319,16 @@ async function transfer(credentials, job, source, options) {
28167
28319
  async function uploadYouTube(options) {
28168
28320
  if (!options.yes && !options["dry-run"]) throw new Error("Pass --yes to authorize the YouTube upload, or --dry-run to inspect it locally.");
28169
28321
  if (!["yes", "no"].includes(options["made-for-kids"]) || !["yes", "no"].includes(options["synthetic-media"])) throw new Error("Set --made-for-kids yes|no and --synthetic-media yes|no explicitly.");
28170
- const root = resolve9(options.directory ?? ".");
28171
- const file2 = resolve9(root, options.file);
28322
+ const root = resolve10(options.directory ?? ".");
28323
+ const file2 = resolve10(root, options.file);
28172
28324
  const context = await localProject(root, options.animation);
28173
28325
  let config2 = {};
28174
28326
  try {
28175
- config2 = JSON.parse(await readFile10(join9(context.projectRoot, "film.config.json"), "utf8"));
28327
+ config2 = JSON.parse(await readFile11(join9(context.projectRoot, "film.config.json"), "utf8"));
28176
28328
  } catch {
28177
28329
  }
28178
28330
  const receipt = await exportReceipt(file2);
28179
- const parsed = youtubeMetadata.safeParse({ title: options.title ?? receipt.title ?? config2.title ?? basename4(file2).replace(/\.mp4$/i, ""), description: options.description ?? "", privacy: options.privacy ?? "private", madeForKids: options["made-for-kids"] === "yes", syntheticMedia: options["synthetic-media"] === "yes" });
28331
+ const parsed = youtubeMetadata.safeParse({ title: options.title ?? receipt.title ?? config2.title ?? basename5(file2).replace(/\.mp4$/i, ""), description: options.description ?? "", privacy: options.privacy ?? "private", madeForKids: options["made-for-kids"] === "yes", syntheticMedia: options["synthetic-media"] === "yes" });
28180
28332
  if (!parsed.success) throw new Error("Use a title of 1\u2013100 characters and a description up to 5,000 UTF-8 bytes, without < or >, and private, unlisted or public privacy.");
28181
28333
  const metadata = parsed.data;
28182
28334
  let captions = await planCaptions(root, file2, options, receipt.language ?? config2.language);
@@ -28273,12 +28425,12 @@ var workspaceQuery = async (client) => {
28273
28425
  async function openBrowser(url2) {
28274
28426
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
28275
28427
  const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url2] : [url2];
28276
- await new Promise((resolve11) => {
28428
+ await new Promise((resolve12) => {
28277
28429
  const child = spawn3(command, args, { stdio: "ignore", detached: true });
28278
- child.once("error", () => resolve11());
28430
+ child.once("error", () => resolve12());
28279
28431
  child.once("spawn", () => {
28280
28432
  child.unref();
28281
- resolve11();
28433
+ resolve12();
28282
28434
  });
28283
28435
  });
28284
28436
  }
@@ -28316,6 +28468,8 @@ function createCli() {
28316
28468
  { name: "visibility", type: "string", choices: ["private", "public"], default: "private" },
28317
28469
  { name: "share", type: "string", description: "Comma-separated emails of verified AnimaGuru users (private videos)" },
28318
28470
  { name: "poster", type: "string", description: "PNG, JPEG or WebP cover image" },
28471
+ { name: "subtitles", type: "string", default: "auto", description: "MP4 subtitle file (.vtt or .srt) the player shows over the picture; auto uses FILM.vtt or FILM.srt beside FILM.mp4; none skips" },
28472
+ { name: "subtitle-language", type: "string", description: "Subtitle language code such as en or tr; defaults to the MP4's export receipt or film.config.json" },
28319
28473
  { name: "yes", type: "boolean", description: "Authorize upload and chosen sharing" },
28320
28474
  { name: "dry-run", type: "boolean", description: "Inspect locally without uploading" }
28321
28475
  ).action(async ({ args, flags, stdout, stderr, signal }) => out(stdout, await submitVideo(new Client(flags.url, signal), { ...flags, directory: args.directory, log: stderr, signal }))))).command("videos", (videos) => videos.add(defineCommand("list", { description: "List your videos, privately shared videos, or the public gallery" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }, { name: "scope", type: "string", choices: ["mine", "shared", "public"], default: "mine" }, { name: "page", type: "number", default: 1 }).action(async ({ flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(flags.scope === "public" ? `/api/videos?page=${flags.page}` : `/api/v1/videos?scope=${flags.scope}&page=${flags.page}`, { authenticated: flags.scope !== "public" }))))).add(defineCommand("get", { description: "Get a video's details or the state of your unfinished upload" }, (command) => command.args({ name: "id", type: "string", required: true }).flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(`/api/v1/videos/${encodeURIComponent(args.id)}`))))).add(defineCommand("complete", { description: "Retry completion of an already uploaded animation" }, (command) => command.args({ name: "id", type: "string", required: true }).flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(`/api/v1/videos/${encodeURIComponent(args.id)}/complete`, { method: "POST", timeout: 3e5 }))))).add(defineCommand("share", { description: "Replace a video's visibility and recipient list" }, (command) => command.args({ name: "id", type: "string", required: true }).flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }, { name: "visibility", type: "string", required: true, choices: ["private", "public"] }, { name: "share", type: "string" }, { name: "yes", type: "boolean", required: true }).action(async ({ args, flags, stdout, signal }) => {
@@ -28323,7 +28477,13 @@ function createCli() {
28323
28477
  const client = new Client(flags.url, signal);
28324
28478
  const { video } = await client.request(`/api/videos/${encodeURIComponent(args.id)}`);
28325
28479
  out(stdout, await client.request(`/api/v1/videos/${encodeURIComponent(args.id)}`, { method: "PATCH", body: videoDetails.parse({ ...video, visibility: flags.visibility, recipients: sharingEmails(flags.share) }) }));
28326
- }))).add(defineCommand("delete", { description: "Remove a video and its hosted files" }, (command) => command.args({ name: "id", type: "string", required: true }).flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }, { name: "yes", type: "boolean", required: true }).action(async ({ args, flags, stdout, signal }) => {
28480
+ }))).add(defineCommand("subtitles", { description: "Add or replace an MP4's subtitles in one language, or remove a language" }, (command) => command.args({ name: "id", type: "string", required: true }).flags(
28481
+ { name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } },
28482
+ { name: "file", type: "string", description: ".srt or .vtt subtitle file" },
28483
+ { name: "language", type: "string", description: "Language code such as en or tr; defaults to a WebVTT Language header or the export receipt" },
28484
+ { name: "remove", type: "string", description: "Language code of the subtitles to remove" },
28485
+ { name: "yes", type: "boolean", required: true }
28486
+ ).action(async ({ args, flags, stdout, signal }) => out(stdout, await changeVideoSubtitles(new Client(flags.url, signal), args.id, flags))))).add(defineCommand("delete", { description: "Remove a video and its hosted files" }, (command) => command.args({ name: "id", type: "string", required: true }).flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }, { name: "yes", type: "boolean", required: true }).action(async ({ args, flags, stdout, signal }) => {
28327
28487
  if (!flags.yes) throw new Error("Pass --yes to delete the video.");
28328
28488
  out(stdout, await new Client(flags.url, signal).request(`/api/v1/videos/${encodeURIComponent(args.id)}`, { method: "DELETE" }));
28329
28489
  })))).command("auth", (auth) => auth.add(defineCommand("login", { description: "Approve a browser sign-in and save a seven-day CLI session" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).flags({ name: "browser", type: "boolean", default: true, description: "Open the browser; --no-browser prints the URL only" }).action(async ({ flags, stdout, stderr, signal }) => {
@@ -28412,7 +28572,7 @@ Confirm code: ${device.user_code}`);
28412
28572
  ...assetAuthorizationFlags
28413
28573
  ).action(async ({ args, flags, stdout, signal }) => out(stdout, await submitAsset(new Client(flags.url, signal), args.id, assetFromFlags(flags), flags))))).add(defineCommand("submit", { description: "Submit a validated JSON asset file; --yes authorizes credits" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).args(id).flags({ name: "file", type: "string", required: true, description: "JSON asset request" }, { name: "model", type: "string", choices: assetModelIds, description: "Override the model in the file" }, ...assetAuthorizationFlags).action(async ({ args, flags, stdout, signal }) => out(stdout, await submitAsset(new Client(flags.url, signal), args.id, await assetFromFile(flags.file, flags.model), flags))))).add(defineCommand("download", { description: "Download a saved asset by id; refuses to overwrite" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).args(id, { name: "asset", type: "string", required: true, description: "Asset id from assets list" }).flags({ name: "output", type: "string", required: true }).action(async ({ args, flags, stdout, signal }) => out(stdout, await downloadAsset(new Client(flags.url, signal), args.id, args.asset, flags.output)))))).command("jobs", (jobs) => jobs.add(defineCommand("list", { description: "List the latest generation jobs" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(`${projectPath(args.id)}/jobs`))))).add(defineCommand("submit", { description: "Submit a typed JSON job: character, animate, custom or redraw; spends credits" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id).flags(...modelFlags).flags({ name: "file", type: "string", required: true, description: "JSON job file" }, { name: "yes", type: "boolean", required: true, description: "Authorize spending credits" }).action(async ({ args, flags, stdout, signal }) => {
28414
28574
  if (!flags.yes) throw new Error("Pass --yes to authorize spending credits.");
28415
- const body = spriteJobBody.parse({ ...JSON.parse(await readFile11(flags.file, "utf8")), ...selectedModels(flags) });
28575
+ const body = spriteJobBody.parse({ ...JSON.parse(await readFile12(flags.file, "utf8")), ...selectedModels(flags) });
28416
28576
  out(stdout, await new Client(flags.url, signal).request(`${projectPath(args.id)}/jobs`, { method: "POST", body }));
28417
28577
  }))).add(defineCommand("wait", { description: "Wait for a specific job; fail if generation fails or times out" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id, { name: "job", type: "string", required: true }).flags({ name: "timeout", type: "number", default: 600, description: "Maximum seconds to wait" }).action(async ({ args, flags, stdout, signal }) => {
28418
28578
  if (!Number.isFinite(flags.timeout) || flags.timeout <= 0) throw new Error("Timeout must be positive.");
@@ -28441,7 +28601,7 @@ Confirm code: ${device.user_code}`);
28441
28601
  const extension = flags.file.split(".").at(-1)?.toLowerCase();
28442
28602
  const type = extension === "png" ? "image/png" : extension === "jpg" || extension === "jpeg" ? "image/jpeg" : extension === "webp" ? "image/webp" : void 0;
28443
28603
  if (!type) throw new Error("Use a PNG, JPEG or WebP image.");
28444
- const bytes = await readFile11(flags.file);
28604
+ const bytes = await readFile12(flags.file);
28445
28605
  if (bytes.length > 10 * 1024 * 1024) throw new Error("Image must be at most 10 MB.");
28446
28606
  const form = new FormData();
28447
28607
  form.set("image", new Blob([bytes], { type }), `reference.${extension}`);
@@ -28459,7 +28619,7 @@ Confirm code: ${device.user_code}`);
28459
28619
  const response = await client.request(`/api/v1/blobs/${encodeURIComponent(manifest.ownerId)}/${file2.sha}`, { raw: true });
28460
28620
  entries[path2] = new Uint8Array(await response.arrayBuffer());
28461
28621
  }
28462
- const path = resolve10(flags.output);
28622
+ const path = resolve11(flags.output);
28463
28623
  await writeFile9(path, zipSync(entries, { level: 0 }), { flag: "wx" });
28464
28624
  out(stdout, { path, files: manifest.files.length });
28465
28625
  })))).extend(version(package_default.version, { format: "plain" })).extend(help()).extend(completion());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,6 +114,8 @@ Submissions default to private. `--share` is a comma-separated list of verified
114
114
 
115
115
  The JSON result returns the video ID and watch URL. Owners manage details and recipients on that page, or run `animaguru videos share VIDEO_ID --visibility private --share "reviewer@example.com" --yes`; this replaces the entire recipient list. Public sharing uses `--visibility public` without `--share`. Removing a recipient or making a public film private blocks previous playback URLs immediately. `animaguru videos delete VIDEO_ID --yes` removes the hosted files.
116
116
 
117
+ For an MP4, `submit` also uploads `FILM.vtt` or `FILM.srt` from beside it as subtitles, which the AnimaGuru player shows over the picture (CLI 0.10.0). The dry run lists the file and language; use `--subtitles FILE`, `--subtitles none` or `--subtitle-language CODE` when needed. Upload the clean MP4 with its subtitles rather than a `-captioned.mp4`, unless the user wants captions in the picture. `animaguru videos subtitles VIDEO_ID --file FILE --yes` adds or replaces one language on an uploaded MP4 and `--remove CODE --yes` removes one. New subtitles on a public video send it back for staff review.
118
+
117
119
  Uploads use checksum-verified parts. After an interrupted submission, inspect the printed ID first. If all parts arrived, `animaguru videos complete VIDEO_ID` safely retries finalization and returns the existing result when already complete. Otherwise cancel that draft with `videos delete --yes` before submitting again. Do not silently delete a potentially successful publication after a network timeout.
118
120
 
119
121
 
@@ -78,7 +78,7 @@ Use `src/studies/timing.ts` for analytic easing, measured stroke reveals and cue
78
78
 
79
79
  ## Sharing a finished film
80
80
 
81
- When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice and include a cover using `--poster` when available. Local creation or export alone does not imply publication.
81
+ When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`; its `.vtt`/`.srt` subtitles beside it are uploaded too and shown by the AnimaGuru player, so submit the clean MP4 rather than a `-captioned.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice and include a cover using `--poster` when available. Local creation or export alone does not imply publication.
82
82
 
83
83
 
84
84
  ## Multi-animation workspace
@@ -31,4 +31,4 @@ Original 2.5D study recipes are in `src/studies/`. In an untouched starter, `npm
31
31
 
32
32
  ## Submit to AnimaGuru
33
33
 
34
- When asked to host or share the finished film, read the local AnimaGuru skill's submission section. `animaguru submit --yes` builds the portable HTML5 export and uploads it privately; `--file` accepts an existing HTML5 ZIP or MP4. `--share "reviewer@example.com"` selects verified AnimaGuru accounts, while `--visibility public` publishes to https://animaguru.com/videos and allows watching, downloading and HTML5 embedding. Include a cover with `--poster public/assets/cover.png` when available. Do not infer public sharing from a request to create or export a film. The CLI returns the hosted watch URL and video ID. Owners can change details and sharing from that page.
34
+ When asked to host or share the finished film, read the local AnimaGuru skill's submission section. `animaguru submit --yes` builds the portable HTML5 export and uploads it privately; `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it, so prefer the clean MP4 over a `-captioned.mp4`. `--share "reviewer@example.com"` selects verified AnimaGuru accounts, while `--visibility public` publishes to https://animaguru.com/videos and allows watching, downloading and HTML5 embedding. Include a cover with `--poster public/assets/cover.png` when available. Do not infer public sharing from a request to create or export a film. The CLI returns the hosted watch URL and video ID. Owners can change details and sharing from that page.
@@ -16,4 +16,4 @@ Accept schema 2 projects and saved `advertorial` as Advertisement with editorial
16
16
 
17
17
  Original 2.5D study recipes are in `src/studies/`. In an untouched starter, `npm run example -- STYLE_ID` installs one; it refuses to overwrite an authored film. Import the recipe directly for an existing production. `npm run review` checks actual visible text bounds and seek consistency, then saves frames and a report. Keep the HTML5-first delivery and use the client's identity in the film. Physically rendered 3D remains deferred.
18
18
 
19
- For requested hosting or sharing, use `animaguru submit --yes` in this workspace (private by default). `--share "reviewer@example.com"` shares with selected verified AnimaGuru accounts; `--visibility public` publishes to https://animaguru.com/videos. `--file` accepts an existing HTML5 ZIP or MP4. Read the local AnimaGuru skill's submission section for audience selection and upload recovery. Creation and local export do not imply publication.
19
+ For requested hosting or sharing, use `animaguru submit --yes` in this workspace (private by default). `--share "reviewer@example.com"` shares with selected verified AnimaGuru accounts; `--visibility public` publishes to https://animaguru.com/videos. `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it. Read the local AnimaGuru skill's submission section for audience selection and upload recovery. Creation and local export do not imply publication.
@@ -100,4 +100,4 @@ Browse `animaguru.templates.json` for all 31 styles and purpose workflows. The e
100
100
 
101
101
  ## Share on AnimaGuru
102
102
 
103
- After signing in with `animaguru auth login`, run `animaguru submit --yes` here to build and host the finished HTML5 animation privately. Add `--share "reviewer@example.com"` to invite selected verified AnimaGuru accounts, or `--visibility public` to list it at [Videos](https://animaguru.com/videos). `--poster public/assets/cover.png` adds a cover. Already exported an MP4? Pass `--file production/exports/film.mp4`. The CLI returns your hosted player URL; public HTML5 animations can be embedded from that page. Owners manage details and access there. Only the exported player/media are uploaded.
103
+ After signing in with `animaguru auth login`, run `animaguru submit --yes` here to build and host the finished HTML5 animation privately. Add `--share "reviewer@example.com"` to invite selected verified AnimaGuru accounts, or `--visibility public` to list it at [Videos](https://animaguru.com/videos). `--poster public/assets/cover.png` adds a cover. Already exported an MP4? Pass `--file production/exports/film.mp4`; the `.vtt` subtitles beside it are uploaded too, and the AnimaGuru player shows them over the picture with a CC button. The CLI returns your hosted player URL; public HTML5 animations can be embedded from that page. Owners manage details and access there. Only the exported player/media are uploaded.