@docyrus/animaguru 0.8.1 → 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/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.8.1",
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 [open3, close] = styleMethodCodes[name];
2673
+ const [open6, close] = styleMethodCodes[name];
2674
2674
  return [name, {
2675
- open: `\x1B[${open3}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: open3, close } = style2;
3483
- if (result.includes(close)) result = result.replaceAll(close, close + open3);
3484
- return open3 + 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 open3 = "";
3687
+ let open6 = "";
3688
3688
  let close = "";
3689
3689
  for (const step of steps) {
3690
3690
  const pair = stepPair(step, pairDepth);
3691
- open3 += pair.open;
3691
+ open6 += pair.open;
3692
3692
  close = pair.close + close;
3693
3693
  }
3694
3694
  Object.defineProperty(styleFn, "open", {
3695
- value: open3,
3695
+ value: open6,
3696
3696
  writable: false,
3697
3697
  configurable: false,
3698
3698
  enumerable: true
@@ -5076,9 +5076,9 @@ 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 readFile8, writeFile as writeFile8 } from "node:fs/promises";
5080
- import { resolve as resolve9 } from "node:path";
5081
- import { setTimeout as delay2 } from "node:timers/promises";
5079
+ import { readFile as readFile12, writeFile as writeFile9 } from "node:fs/promises";
5080
+ import { resolve as resolve11 } from "node:path";
5081
+ import { setTimeout as delay3 } from "node:timers/promises";
5082
5082
 
5083
5083
  // node_modules/fflate/esm/index.mjs
5084
5084
  import { createRequire } from "module";
@@ -6067,7 +6067,7 @@ function unzipSync(data, opts) {
6067
6067
  }
6068
6068
 
6069
6069
  // shared/auth.ts
6070
- var CLI_CLIENT_ID = "spriteguru-cli";
6070
+ var CLI_CLIENT_ID = "animaguru-cli";
6071
6071
 
6072
6072
  // shared/sprites/styles.ts
6073
6073
  var GAME_SPRITE_STYLES = ["hd-cartoon", "pixel", "painted", "vector"];
@@ -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, resolve10) {
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, resolve10) {
11233
11233
  let result = NONE;
11234
11234
  const check2 = (child) => {
11235
11235
  if (result !== PROVEN && child?._zod) {
11236
- const answer = isRecursive(child, stack, resolve10);
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, resolve10) {
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, resolve10) : 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, resolve10) {
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 ?? (resolve10 ? 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
  }
@@ -25993,12 +25993,10 @@ import { fileURLToPath } from "node:url";
25993
25993
 
25994
25994
  // shared/locales/en/animation-styles.json
25995
25995
  var animation_styles_default = {
25996
- videos: "Videos",
25997
25996
  pageTitle: "Animation styles \xB7 AnimaGuru",
25998
25997
  eyebrow: "THE STYLE GALLERY",
25999
25998
  title: "Find the way\nyour story moves.",
26000
25999
  description: "Explore the visual language of your next animation. Choose a style here, then tell your coding agent which one you like.",
26001
- openStudio: "Open studio",
26002
26000
  available: "AVAILABLE NOW",
26003
26001
  previewLabel: "{{name}} style preview",
26004
26002
  previewError: "The preview could not load. Try reloading the page.",
@@ -26016,7 +26014,6 @@ var animation_styles_default = {
26016
26014
  firstStep: "Create a project",
26017
26015
  secondStep: "Choose the purpose of your video",
26018
26016
  thirdStep: "Pick a style with your agent",
26019
- footer: "AnimaGuru \xB7 Imagine. Create. Animate.",
26020
26017
  categories: {
26021
26018
  explainer: {
26022
26019
  name: "Explainer",
@@ -27002,8 +26999,8 @@ async function createWorkspace(options, installer = installDependencies) {
27002
26999
  await rm2(join5(directory, "runtime/main.ts"));
27003
27000
  await rename3(join5(directory, "film.config.json"), join5(directory, "templates/animation/film.config.json"));
27004
27001
  await rename3(join5(directory, "shared/locales/en/film.json"), join5(directory, "templates/animation/shared/locales/en/film.json"));
27005
- const { createHash: createHash2 } = await import("node:crypto");
27006
- await writeFile6(join5(directory, "templates/animation/starter-film.sha256"), createHash2("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");
27007
27004
  await rm2(join5(directory, "animaguru.project.json"));
27008
27005
  await mkdir6(join5(directory, "animations"));
27009
27006
  await mkdir6(join5(directory, "branding"));
@@ -27187,7 +27184,7 @@ function elevenLabsSpeechCostUsd(model, characters, now = Date.now()) {
27187
27184
  const rate = model === "eleven_v4_turbo" ? promotion ? 0.011 : 0.04 : promotion ? 0.022 : 0.08;
27188
27185
  return Math.max(0, characters) * rate / 1e3;
27189
27186
  }
27190
- var elevenLabsSfxCostUsd = (seconds) => Math.max(0, seconds) * 0.12 / 60;
27187
+ var elevenLabsSfxCostUsd = (seconds2) => Math.max(0, seconds2) * 0.12 / 60;
27191
27188
  var speechCharacters = (text) => Array.from(text).length;
27192
27189
  function assetEstimate(p) {
27193
27190
  if (p.type === "image") return Math.ceil(130 * spriteImageModel(p.model).relCost);
@@ -27199,16 +27196,21 @@ function assetEstimate(p) {
27199
27196
  }
27200
27197
 
27201
27198
  // cli/src/videos.ts
27202
- import { open as open2, readFile as readFile6, stat } from "node:fs/promises";
27203
- 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";
27204
27201
  import { setTimeout as delay } from "node:timers/promises";
27205
27202
  import { spawn as spawn2 } from "node:child_process";
27206
- import { createHash } from "node:crypto";
27203
+ import { createHash as createHash2 } from "node:crypto";
27207
27204
 
27208
27205
  // shared/videos.ts
27209
27206
  var VIDEO_PART_BYTES = 8 * 1024 * 1024;
27210
27207
  var VIDEO_MAX_BYTES = { html5: 64 * 1024 * 1024, mp4: 512 * 1024 * 1024 };
27211
27208
  var VIDEO_VISIBILITIES = ["private", "public"];
27209
+ var VIDEO_SHARING_AGREEMENT_VERSION = "2026-10-10-draft-1";
27210
+ var videoAgreementAcceptance = external_exports.object({
27211
+ version: external_exports.literal(VIDEO_SHARING_AGREEMENT_VERSION),
27212
+ language: external_exports.enum(["en", "tr"])
27213
+ }).strict();
27212
27214
  var recipients = external_exports.array(external_exports.email().trim().toLowerCase()).max(20).transform((values) => [...new Set(values)]).default([]);
27213
27215
  var videoMetadataUpdate = external_exports.object({
27214
27216
  title: external_exports.string().trim().min(1).max(120),
@@ -27216,13 +27218,15 @@ var videoMetadataUpdate = external_exports.object({
27216
27218
  }).strict();
27217
27219
  var videoSharingUpdate = external_exports.object({
27218
27220
  visibility: external_exports.enum(VIDEO_VISIBILITIES),
27219
- recipients
27221
+ recipients,
27222
+ agreement: videoAgreementAcceptance.optional()
27220
27223
  }).strict().refine((value) => value.visibility !== "public" || !value.recipients.length, "Public videos do not use a recipient list.");
27221
27224
  var videoDetails = external_exports.object({
27222
27225
  title: external_exports.string().trim().min(1).max(120),
27223
27226
  description: external_exports.string().trim().max(2e3).default(""),
27224
27227
  visibility: external_exports.enum(VIDEO_VISIBILITIES).default("private"),
27225
- recipients
27228
+ recipients,
27229
+ agreement: videoAgreementAcceptance.optional()
27226
27230
  }).refine((value) => value.visibility !== "public" || !value.recipients.length, "Public videos do not use a recipient list.");
27227
27231
  var videoSubmission = videoDetails.and(external_exports.object({
27228
27232
  format: external_exports.enum(["html5", "mp4"]),
@@ -27238,11 +27242,158 @@ function videoPosterType(bytes) {
27238
27242
  throw new Error("Use a PNG, JPEG or WebP poster up to 2 MiB.");
27239
27243
  }
27240
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
+
27241
27392
  // cli/src/local-project.ts
27242
- import { readFile as readFile5, readdir as readdir2 } from "node:fs/promises";
27243
- 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";
27244
27395
  async function localProject(directory, selected) {
27245
- const root = resolve6(directory);
27396
+ const root = resolve7(directory);
27246
27397
  let workspace;
27247
27398
  try {
27248
27399
  workspace = await findWorkspace(root);
@@ -27257,7 +27408,7 @@ async function localProject(directory, selected) {
27257
27408
  for (const entry of await readdir2(join6(workspace, "animations"), { withFileTypes: true })) {
27258
27409
  if (!entry.isDirectory()) continue;
27259
27410
  try {
27260
- await readFile5(join6(workspace, "animations", entry.name, "animaguru.project.json"));
27411
+ await readFile6(join6(workspace, "animations", entry.name, "animaguru.project.json"));
27261
27412
  names.push(entry.name);
27262
27413
  } catch (error62) {
27263
27414
  if (error62.code !== "ENOENT") throw error62;
@@ -27268,17 +27419,18 @@ async function localProject(directory, selected) {
27268
27419
  }
27269
27420
  if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(animation)) throw new Error("Use a lowercase, hyphenated animation name.");
27270
27421
  const projectRoot = join6(workspace, "animations", animation);
27271
- await readFile5(join6(projectRoot, "animaguru.project.json"));
27422
+ await readFile6(join6(projectRoot, "animaguru.project.json"));
27272
27423
  return { toolsRoot: workspace, projectRoot, animation };
27273
27424
  }
27274
27425
 
27275
27426
  // cli/src/videos.ts
27427
+ var subtitlesBody = (content) => new Blob([new Uint8Array(content)], { type: "text/plain; charset=utf-8" });
27276
27428
  function sharingEmails(value) {
27277
27429
  return value ? value.split(",").map((email3) => email3.trim()).filter(Boolean) : [];
27278
27430
  }
27279
27431
  async function exportHtml(root, animation, signal, log) {
27280
27432
  const script = join7(root, "scripts/export-html.mjs");
27281
- await stat(script).catch(() => {
27433
+ await stat2(script).catch(() => {
27282
27434
  throw new Error("Choose an AnimaGuru video project, or pass --file for an exported HTML5 ZIP or MP4.");
27283
27435
  });
27284
27436
  log?.("Building the portable HTML5 animation\u2026");
@@ -27310,25 +27462,26 @@ async function exportHtml(root, animation, signal, log) {
27310
27462
  }
27311
27463
  async function submitVideo(client, options) {
27312
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.");
27313
- const root = resolve7(options.directory ?? ".");
27465
+ const root = resolve8(options.directory ?? ".");
27314
27466
  const context = await localProject(root, options.animation);
27315
- const file2 = options.file ? resolve7(root, options.file) : await exportHtml(context.toolsRoot, context.animation, options.signal, options.log);
27316
- 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);
27317
27469
  const format = file2.toLowerCase().endsWith(".zip") ? "html5" : file2.toLowerCase().endsWith(".mp4") ? "mp4" : null;
27318
27470
  if (!info.isFile() || !format) throw new Error("Submit an AnimaGuru HTML5 .zip export or an .mp4 file.");
27319
27471
  videoSubmission.parse({ title: options.title ?? "Video", format, bytes: info.size });
27320
27472
  let metadata = {};
27473
+ let receipt = {};
27321
27474
  if (format === "html5") {
27322
- 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 });
27323
27476
  if (!files["manifest.json"]) throw new Error("Use an AnimaGuru HTML5 export with manifest.json.");
27324
27477
  metadata = JSON.parse(strFromU8(files["manifest.json"]));
27325
27478
  if (metadata.format !== "spriteguru-html5" || metadata.version !== 1) throw new Error("Use an AnimaGuru HTML5 export.");
27326
27479
  } else {
27327
27480
  try {
27328
- 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"));
27329
27482
  } catch {
27330
27483
  }
27331
- const handle2 = await open2(file2, "r");
27484
+ const handle2 = await open3(file2, "r");
27332
27485
  try {
27333
27486
  const header = Buffer.alloc(12);
27334
27487
  await handle2.read(header, 0, 12, 0);
@@ -27336,9 +27489,13 @@ async function submitVideo(client, options) {
27336
27489
  } finally {
27337
27490
  await handle2.close();
27338
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 };
27339
27496
  }
27340
27497
  const body = videoSubmission.parse({
27341
- title: options.title ?? metadata.title ?? basename3(file2).replace(/\.(zip|mp4)$/i, ""),
27498
+ title: options.title ?? metadata.title ?? basename4(file2).replace(/\.(zip|mp4)$/i, ""),
27342
27499
  description: options.description ?? "",
27343
27500
  visibility: options.visibility ?? "private",
27344
27501
  recipients: sharingEmails(options.share),
@@ -27348,19 +27505,30 @@ async function submitVideo(client, options) {
27348
27505
  height: metadata.height,
27349
27506
  duration: metadata.duration
27350
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
+ }
27351
27519
  let poster;
27352
27520
  if (options.poster) {
27353
- const path = resolve7(root, options.poster);
27354
- const info2 = await stat(path);
27521
+ const path = resolve8(root, options.poster);
27522
+ const info2 = await stat2(path);
27355
27523
  if (!info2.isFile() || info2.size > 2 * 1024 * 1024) throw new Error("Poster must be a PNG, JPEG or WebP up to 2 MiB.");
27356
- poster = await readFile6(path);
27524
+ poster = await readFile7(path);
27357
27525
  videoPosterType(poster);
27358
27526
  }
27359
- 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 } };
27360
27528
  const draft = await client.request("/api/v1/videos", { method: "POST", body });
27361
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.");
27362
- const handle = await open2(file2, "r");
27363
- let completed = false;
27530
+ const handle = await open3(file2, "r");
27531
+ let completed2 = false;
27364
27532
  try {
27365
27533
  for (let offset = 0, part = 1; offset < info.size; offset += draft.partBytes, part++) {
27366
27534
  options.signal?.throwIfAborted();
@@ -27368,7 +27536,7 @@ async function submitVideo(client, options) {
27368
27536
  const read2 = await handle.read(bytes, 0, bytes.length, offset);
27369
27537
  if (read2.bytesRead !== bytes.length) throw new Error("The export changed while uploading. Retry with a stable exported file.");
27370
27538
  options.log?.(`Uploading part ${part}/${Math.ceil(info.size / draft.partBytes)}\u2026`);
27371
- const sha256 = createHash("sha256").update(bytes).digest("hex");
27539
+ const sha256 = createHash2("sha256").update(bytes).digest("hex");
27372
27540
  for (let attempt = 0; ; attempt++) {
27373
27541
  try {
27374
27542
  await client.request(`/api/v1/videos/${draft.id}/parts/${part}`, { method: "PUT", body: new Blob([bytes]), headers: { "X-Content-SHA256": sha256 }, timeout: 18e4 });
@@ -27380,21 +27548,40 @@ async function submitVideo(client, options) {
27380
27548
  }
27381
27549
  }
27382
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
+ }
27383
27555
  options.log?.("Preparing your AnimaGuru player\u2026");
27384
27556
  const result = await client.request(`/api/v1/videos/${draft.id}/complete`, { method: "POST", timeout: 3e5 });
27385
- completed = true;
27557
+ completed2 = true;
27386
27558
  return { ...result, url: new URL(result.url, client.origin).href };
27387
27559
  } catch (error62) {
27388
- if (!completed) options.log?.(`Submission interrupted. Video ID: ${draft.id}. Inspect it with animaguru videos get ${draft.id}; finish with animaguru videos complete ${draft.id}, or cancel with animaguru videos delete ${draft.id} --yes.`);
27560
+ if (!completed2) options.log?.(`Submission interrupted. Video ID: ${draft.id}. Inspect it with animaguru videos get ${draft.id}; finish with animaguru videos complete ${draft.id}, or cancel with animaguru videos delete ${draft.id} --yes.`);
27389
27561
  throw error62;
27390
27562
  } finally {
27391
27563
  await handle.close();
27392
27564
  }
27393
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
+ }
27394
27581
 
27395
27582
  // cli/src/assets.ts
27396
- import { readFile as readFile7, writeFile as writeFile7 } from "node:fs/promises";
27397
- 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";
27398
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"] };
27399
27586
  function parseAsset(input2) {
27400
27587
  const parsed = assetRequest.safeParse(input2);
@@ -27432,7 +27619,7 @@ function assetFromFlags(flags) {
27432
27619
  return parseAsset({ ...base2, ...flags.type === "speech" ? { voice: flags.voice, delivery: flags.delivery } : {} });
27433
27620
  }
27434
27621
  async function assetFromFile(file2, model) {
27435
- const input2 = JSON.parse(await readFile7(file2, "utf8"));
27622
+ const input2 = JSON.parse(await readFile8(file2, "utf8"));
27436
27623
  if (!input2 || typeof input2 !== "object" || Array.isArray(input2)) throw new Error("The asset file must contain a JSON object.");
27437
27624
  return parseAsset({ ...input2, ...model ? { model } : {} });
27438
27625
  }
@@ -27447,12 +27634,772 @@ async function downloadAsset(client, project, assetId, output2) {
27447
27634
  if (!asset) throw new Error("Asset not found in this project. Run assets list to find its id.");
27448
27635
  if (!library.ownerId || !/^[a-f0-9]{64}$/.test(asset.file)) throw new Error("Invalid asset download metadata.");
27449
27636
  const response = await client.request(`/api/v1/blobs/${encodeURIComponent(library.ownerId)}/${asset.file}`, { raw: true });
27450
- const path = resolve8(output2);
27637
+ const path = resolve9(output2);
27451
27638
  const bytes = new Uint8Array(await response.arrayBuffer());
27452
27639
  await writeFile7(path, bytes, { flag: "wx" });
27453
27640
  return { path, id: asset.id, type: asset.type, mimeType: asset.mimeType, size: bytes.byteLength };
27454
27641
  }
27455
27642
 
27643
+ // cli/src/youtube-auth.ts
27644
+ import { createServer } from "node:http";
27645
+ import { randomBytes, createHash as createHash3, timingSafeEqual } from "node:crypto";
27646
+ import { readFile as readFile10, stat as stat3 } from "node:fs/promises";
27647
+
27648
+ // cli/src/youtube-store.ts
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";
27650
+ import { dirname as dirname6, join as join8 } from "node:path";
27651
+ import { randomUUID as randomUUID2 } from "node:crypto";
27652
+ var youtubeMetadata = external_exports.object({
27653
+ title: external_exports.string().trim().min(1).max(100).refine((value) => !/[<>]/.test(value)),
27654
+ description: external_exports.string().max(5e3).refine((value) => Buffer.byteLength(value, "utf8") <= 5e3 && !/[<>]/.test(value)),
27655
+ privacy: external_exports.enum(["private", "unlisted", "public"]),
27656
+ madeForKids: external_exports.boolean(),
27657
+ syntheticMedia: external_exports.boolean()
27658
+ }).strict();
27659
+ var youtubeCredentials = external_exports.object({
27660
+ clientId: external_exports.string().min(1),
27661
+ clientSecret: external_exports.string().optional(),
27662
+ accessToken: external_exports.string().min(1),
27663
+ refreshToken: external_exports.string().min(1),
27664
+ expiresAt: external_exports.number().finite(),
27665
+ channelId: external_exports.string().min(1),
27666
+ channelTitle: external_exports.string().min(1),
27667
+ connectedAt: external_exports.number(),
27668
+ checkedAt: external_exports.number(),
27669
+ scopes: external_exports.array(external_exports.string()).optional()
27670
+ }).strict();
27671
+ var youtubeCaptions = external_exports.object({
27672
+ file: external_exports.string().min(1),
27673
+ bytes: external_exports.number().int().positive().safe(),
27674
+ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/),
27675
+ language: external_exports.string().regex(/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/),
27676
+ name: external_exports.string().max(150),
27677
+ status: external_exports.enum(["pending", "uploaded"]),
27678
+ captionId: external_exports.string().min(1).max(512).nullable()
27679
+ }).strict();
27680
+ var youtubeThumbnail = external_exports.object({
27681
+ file: external_exports.string().min(1),
27682
+ bytes: external_exports.number().int().positive().safe(),
27683
+ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/),
27684
+ type: external_exports.enum(["image/jpeg", "image/png"]),
27685
+ status: external_exports.enum(["pending", "uploaded"])
27686
+ }).strict();
27687
+ var youtubeJob = external_exports.object({
27688
+ id: external_exports.uuid(),
27689
+ file: external_exports.string().min(1),
27690
+ bytes: external_exports.number().int().positive().safe(),
27691
+ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/),
27692
+ clientId: external_exports.string().min(1),
27693
+ channelId: external_exports.string().min(1),
27694
+ metadata: youtubeMetadata,
27695
+ status: external_exports.enum(["uploading", "interrupted", "completed", "expired"]),
27696
+ sessionUrl: external_exports.string().nullable(),
27697
+ bytesUploaded: external_exports.number().int().nonnegative(),
27698
+ youtubeId: external_exports.string().regex(/^[a-zA-Z0-9_-]{11}$/).nullable(),
27699
+ privacy: external_exports.enum(["private", "unlisted", "public"]),
27700
+ createdAt: external_exports.number(),
27701
+ updatedAt: external_exports.number(),
27702
+ captions: youtubeCaptions.optional(),
27703
+ thumbnail: youtubeThumbnail.optional()
27704
+ }).strict();
27705
+ var youtubeDirectory = () => join8(dirname6(credentialsPath()), "youtube");
27706
+ var credentialFile = () => join8(youtubeDirectory(), "credentials.json");
27707
+ function jobFile(id2) {
27708
+ if (!external_exports.uuid().safeParse(id2).success) throw new Error("Use an upload ID returned by animaguru youtube uploads list.");
27709
+ return join8(youtubeDirectory(), "uploads", `${id2}.json`);
27710
+ }
27711
+ async function privateDirectory(directory) {
27712
+ await mkdir7(directory, { recursive: true, mode: 448 });
27713
+ const info = await lstat(directory);
27714
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("YouTube configuration must use a real directory.");
27715
+ await chmod3(directory, 448);
27716
+ }
27717
+ async function existingDirectory(directory) {
27718
+ try {
27719
+ const info = await lstat(directory);
27720
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("YouTube configuration must use a real directory.");
27721
+ return true;
27722
+ } catch (error62) {
27723
+ if (error62.code === "ENOENT") return false;
27724
+ throw error62;
27725
+ }
27726
+ }
27727
+ async function readPrivate(file2) {
27728
+ try {
27729
+ if (!await existingDirectory(youtubeDirectory()) || !await existingDirectory(dirname6(file2))) return null;
27730
+ const info = await lstat(file2);
27731
+ if (info.isSymbolicLink() || !info.isFile() || info.size > 65536) throw new Error("Invalid local YouTube state file.");
27732
+ await chmod3(file2, 384);
27733
+ return JSON.parse(await readFile9(file2, "utf8"));
27734
+ } catch (error62) {
27735
+ if (error62.code === "ENOENT") return null;
27736
+ throw new Error("Cannot read local YouTube state. Check your AnimaGuru configuration directory.");
27737
+ }
27738
+ }
27739
+ async function writePrivate(file2, value) {
27740
+ await privateDirectory(youtubeDirectory());
27741
+ await privateDirectory(dirname6(file2));
27742
+ const temporary = `${file2}.${randomUUID2()}.tmp`;
27743
+ try {
27744
+ await writeFile8(temporary, JSON.stringify(value) + "\n", { flag: "wx", mode: 384 });
27745
+ await chmod3(temporary, 384);
27746
+ await rename4(temporary, file2);
27747
+ } finally {
27748
+ await unlink(temporary).catch(() => {
27749
+ });
27750
+ }
27751
+ }
27752
+ async function readYouTubeCredentials() {
27753
+ const value = await readPrivate(credentialFile());
27754
+ if (value === null) return null;
27755
+ const parsed = youtubeCredentials.safeParse(value);
27756
+ if (!parsed.success) throw new Error("Invalid saved YouTube credentials. Run youtube auth logout, then login again.");
27757
+ return parsed.data;
27758
+ }
27759
+ var saveYouTubeCredentials = (value) => writePrivate(credentialFile(), youtubeCredentials.parse(value));
27760
+ var saveYouTubeJob = (value) => writePrivate(jobFile(value.id), youtubeJob.parse(value));
27761
+ async function readYouTubeJob(id2) {
27762
+ const parsed = youtubeJob.safeParse(await readPrivate(jobFile(id2)));
27763
+ if (!parsed.success || parsed.data.id !== id2) throw new Error("Upload not found or local upload state is invalid.");
27764
+ return parsed.data;
27765
+ }
27766
+ function youtubeJobSummary(job) {
27767
+ return { id: job.id, file: job.file, title: job.metadata.title, channelId: job.channelId, status: job.status, bytes: job.bytes, bytesUploaded: job.bytesUploaded, privacy: job.privacy, youtubeId: job.youtubeId, url: job.youtubeId ? `https://www.youtube.com/watch?v=${job.youtubeId}` : null, createdAt: job.createdAt, captions: job.captions ? { file: job.captions.file, language: job.captions.language, name: job.captions.name, status: job.captions.status } : null, thumbnail: job.thumbnail ? { file: job.thumbnail.file, status: job.thumbnail.status } : null };
27768
+ }
27769
+ async function localYouTubeJobs() {
27770
+ const directory = join8(youtubeDirectory(), "uploads");
27771
+ if (!await existingDirectory(directory)) return [];
27772
+ let files;
27773
+ try {
27774
+ files = await readdir3(directory);
27775
+ } catch (error62) {
27776
+ if (error62.code === "ENOENT") return [];
27777
+ throw error62;
27778
+ }
27779
+ const jobs = [];
27780
+ for (const file2 of files.filter((name) => name.endsWith(".json") && external_exports.uuid().safeParse(name.slice(0, -5)).success)) {
27781
+ const job = await readYouTubeJob(file2.slice(0, -5));
27782
+ if (job.updatedAt < Date.now() - 30 * 864e5) await unlink(jobFile(job.id));
27783
+ else jobs.push(job);
27784
+ }
27785
+ return jobs.sort((a, b) => b.createdAt - a.createdAt);
27786
+ }
27787
+ async function clearYouTubeData() {
27788
+ await unlink(credentialFile()).catch((error62) => {
27789
+ if (error62.code !== "ENOENT") throw error62;
27790
+ });
27791
+ if (!await existingDirectory(join8(youtubeDirectory(), "uploads"))) return;
27792
+ let files;
27793
+ try {
27794
+ files = await readdir3(join8(youtubeDirectory(), "uploads"));
27795
+ } catch (error62) {
27796
+ if (error62.code === "ENOENT") return;
27797
+ throw error62;
27798
+ }
27799
+ for (const file2 of files.filter((name) => name.endsWith(".json") && external_exports.uuid().safeParse(name.slice(0, -5)).success)) await unlink(jobFile(file2.slice(0, -5)));
27800
+ }
27801
+ async function withYouTubeLock(operation) {
27802
+ await privateDirectory(youtubeDirectory());
27803
+ const file2 = join8(youtubeDirectory(), "operation.lock");
27804
+ const lock = await open4(file2, "wx", 384).catch((error62) => {
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}.`);
27806
+ throw error62;
27807
+ });
27808
+ try {
27809
+ await lock.writeFile(String(process.pid));
27810
+ return await operation();
27811
+ } finally {
27812
+ await lock.close();
27813
+ await unlink(file2);
27814
+ }
27815
+ }
27816
+
27817
+ // cli/src/youtube-auth.ts
27818
+ var YOUTUBE_SCOPES = ["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube.readonly"];
27819
+ var YOUTUBE_CAPTION_SCOPE = "https://www.googleapis.com/auth/youtube.force-ssl";
27820
+ var canAddCaptions = (credentials) => credentials.scopes?.includes(YOUTUBE_CAPTION_SCOPE) ?? false;
27821
+ var YouTubeError = class extends Error {
27822
+ constructor(message, retryable = false, expired = false, reason) {
27823
+ super(message);
27824
+ this.retryable = retryable;
27825
+ this.expired = expired;
27826
+ this.reason = reason;
27827
+ }
27828
+ retryable;
27829
+ expired;
27830
+ reason;
27831
+ };
27832
+ async function googleJson(response) {
27833
+ const reader = response.body?.getReader();
27834
+ if (!reader) return {};
27835
+ const buffers = [];
27836
+ let size = 0;
27837
+ try {
27838
+ while (true) {
27839
+ const { value: value2, done } = await reader.read();
27840
+ if (done) break;
27841
+ size += value2.length;
27842
+ if (size > 65536) throw new Error();
27843
+ buffers.push(value2);
27844
+ }
27845
+ const value = JSON.parse(Buffer.concat(buffers).toString("utf8"));
27846
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
27847
+ } catch {
27848
+ return {};
27849
+ } finally {
27850
+ await reader.cancel().catch(() => {
27851
+ });
27852
+ }
27853
+ }
27854
+ async function providerError(response) {
27855
+ const body = await googleJson(response);
27856
+ const error62 = body.error;
27857
+ const raw = typeof error62 === "object" ? error62?.errors?.[0]?.reason : error62;
27858
+ const reason = typeof raw === "string" && /^[A-Za-z_]{1,64}$/.test(raw) ? raw : void 0;
27859
+ if (reason === "invalid_grant" || response.status === 401) return new YouTubeError("YouTube authorization expired or was revoked. Run youtube auth login again.");
27860
+ if (["quotaExceeded", "dailyLimitExceeded", "uploadLimitExceeded"].includes(reason ?? "")) return new YouTubeError("YouTube upload quota exceeded. Resume this upload after your quota resets.");
27861
+ if (reason === "captionExists") return new YouTubeError("The video already has a caption track with this language and name.", false, false, reason);
27862
+ if (reason === "videoNotFound") return new YouTubeError("YouTube cannot find the uploaded video yet.", true, false, reason);
27863
+ if (reason === "invalidImage") return new YouTubeError("YouTube rejected the thumbnail image.", false, false, reason);
27864
+ if (reason === "invalidMetadata") return new YouTubeError("YouTube rejected the caption language or track name.", false, false, reason);
27865
+ if ([404, 410].includes(response.status)) return new YouTubeError("YouTube upload session expired. Inspect your channel before starting a new upload.", false, true);
27866
+ if (response.status === 403) return new YouTubeError("YouTube denied this request. Check channel permissions and your Google project's YouTube API configuration.", false, false, reason);
27867
+ return new YouTubeError(`YouTube request failed (${response.status}${reason ? `: ${reason}` : ""}).`, response.status === 429 || response.status >= 500, false, reason);
27868
+ }
27869
+ async function googleFetch(url2, init, signal) {
27870
+ signal?.throwIfAborted();
27871
+ try {
27872
+ return await fetch(url2, { ...init, redirect: "manual", signal: AbortSignal.any([AbortSignal.timeout(18e4), ...signal ? [signal] : []]) });
27873
+ } catch {
27874
+ signal?.throwIfAborted();
27875
+ throw new YouTubeError("YouTube connection interrupted.", true);
27876
+ }
27877
+ }
27878
+ async function tokenRequest(client, params, signal) {
27879
+ const response = await googleFetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: client.clientId, ...client.clientSecret ? { client_secret: client.clientSecret } : {}, ...params }) }, signal);
27880
+ if (!response.ok) {
27881
+ const failure2 = await providerError(response);
27882
+ if (failure2.message.includes("authorization expired")) await clearYouTubeData();
27883
+ throw failure2;
27884
+ }
27885
+ return googleJson(response);
27886
+ }
27887
+ async function accessToken(credentials, signal, force = false) {
27888
+ if (!force && credentials.expiresAt > Date.now() + 6e4) return credentials.accessToken;
27889
+ const body = await tokenRequest(credentials, { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, signal);
27890
+ const scopes = typeof body.scope === "string" ? body.scope.split(" ") : null;
27891
+ if (scopes && YOUTUBE_SCOPES.some((scope) => !scopes.includes(scope))) {
27892
+ await clearYouTubeData();
27893
+ throw new YouTubeError("YouTube permissions changed. Run youtube auth login again.");
27894
+ }
27895
+ if (typeof body.access_token !== "string" || typeof body.expires_in !== "number" || body.expires_in <= 0) throw new YouTubeError("Google returned an invalid authorization response.");
27896
+ if (scopes) credentials.scopes = scopes;
27897
+ credentials.accessToken = body.access_token;
27898
+ credentials.expiresAt = Date.now() + body.expires_in * 1e3;
27899
+ if (typeof body.refresh_token === "string") credentials.refreshToken = body.refresh_token;
27900
+ await saveYouTubeCredentials(credentials);
27901
+ return credentials.accessToken;
27902
+ }
27903
+ async function youtubeRequest(credentials, url2, init = {}, signal) {
27904
+ const send = (token) => googleFetch(url2, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` } }, signal);
27905
+ let response = await send(await accessToken(credentials, signal));
27906
+ if (response.status === 401) {
27907
+ await response.body?.cancel();
27908
+ response = await send(await accessToken(credentials, signal, true));
27909
+ }
27910
+ if (!response.ok && response.status !== 308) throw await providerError(response);
27911
+ return response;
27912
+ }
27913
+ async function channelFor(credentials, signal) {
27914
+ const response = await youtubeRequest(credentials, "https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true&fields=items(id,snippet(title))", {}, signal);
27915
+ const body = await googleJson(response);
27916
+ const items = body.items;
27917
+ if (!Array.isArray(items) || items.length !== 1 || typeof items[0]?.id !== "string" || typeof items[0]?.snippet?.title !== "string") throw new YouTubeError("Choose a Google account with one YouTube channel in the authorization screen.");
27918
+ return { id: items[0].id, title: items[0].snippet.title };
27919
+ }
27920
+ async function connectedYouTube(signal) {
27921
+ const credentials = await readYouTubeCredentials();
27922
+ if (!credentials) throw new Error("Connect your channel with animaguru youtube auth login --client-file PATH --yes.");
27923
+ if (credentials.checkedAt < Date.now() - 864e5) await accessToken(credentials, signal, true);
27924
+ const channel = await channelFor(credentials, signal);
27925
+ if (channel.id !== credentials.channelId) throw new Error("The connected YouTube channel changed. Disconnect and reconnect before uploading.");
27926
+ credentials.channelTitle = channel.title;
27927
+ credentials.checkedAt = Date.now();
27928
+ await saveYouTubeCredentials(credentials);
27929
+ return credentials;
27930
+ }
27931
+ async function revoke(token, signal) {
27932
+ try {
27933
+ const response = await googleFetch("https://oauth2.googleapis.com/revoke", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token }) }, signal);
27934
+ await response.body?.cancel();
27935
+ return response.ok || response.status === 400;
27936
+ } catch {
27937
+ return false;
27938
+ }
27939
+ }
27940
+ async function youtubeLogin(options) {
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.");
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.");
27944
+ const requested = options.captions ? [...YOUTUBE_SCOPES, YOUTUBE_CAPTION_SCOPE] : YOUTUBE_SCOPES;
27945
+ return withYouTubeLock(async () => {
27946
+ const existing = await readYouTubeCredentials();
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.");
27948
+ if (existing) throw new Error("A YouTube channel is already connected. Run youtube auth logout --yes before switching channels.");
27949
+ const file2 = options["client-file"];
27950
+ const info = await stat3(file2);
27951
+ if (!info.isFile() || info.size > 65536) throw new Error("Use Google's downloaded Desktop app OAuth client JSON.");
27952
+ let installed;
27953
+ try {
27954
+ installed = JSON.parse(await readFile10(file2, "utf8")).installed;
27955
+ } catch {
27956
+ }
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.");
27958
+ const client = { clientId: installed.client_id, ...typeof installed.client_secret === "string" ? { clientSecret: installed.client_secret } : {} };
27959
+ const state = randomBytes(32).toString("base64url");
27960
+ const verifier = randomBytes(32).toString("base64url");
27961
+ let receive;
27962
+ let fail;
27963
+ let used = false;
27964
+ const callback = new Promise((resolve12, reject) => {
27965
+ receive = resolve12;
27966
+ fail = reject;
27967
+ });
27968
+ void callback.catch(() => {
27969
+ });
27970
+ let redirectUri = "";
27971
+ const server = createServer((request, response) => {
27972
+ response.setHeader("Cache-Control", "no-store");
27973
+ response.setHeader("Referrer-Policy", "no-referrer");
27974
+ response.setHeader("Content-Type", "text/plain; charset=UTF-8");
27975
+ response.setHeader("Content-Security-Policy", "default-src 'none'");
27976
+ let url2;
27977
+ try {
27978
+ url2 = new URL(request.url ?? "/", redirectUri);
27979
+ } catch {
27980
+ response.writeHead(400).end("Invalid request.");
27981
+ return;
27982
+ }
27983
+ if (request.method !== "GET" || request.headers.host !== new URL(redirectUri).host || url2.pathname !== "/oauth2callback" || (request.url?.length ?? 0) > 8192) {
27984
+ response.writeHead(404).end("Not found.");
27985
+ return;
27986
+ }
27987
+ const returnedState = Buffer.from(url2.searchParams.get("state") ?? "");
27988
+ const expectedState = Buffer.from(state);
27989
+ if (used || returnedState.length !== expectedState.length || !timingSafeEqual(returnedState, expectedState)) {
27990
+ response.writeHead(400).end("Invalid authorization state.");
27991
+ return;
27992
+ }
27993
+ used = true;
27994
+ const code = url2.searchParams.get("code");
27995
+ if (url2.searchParams.has("error") || !code || code.length > 4096) {
27996
+ response.writeHead(400).end("Authorization cancelled. Return to your terminal.");
27997
+ fail(new Error("YouTube authorization cancelled."));
27998
+ return;
27999
+ }
28000
+ response.end("Authorization received. Return to your AnimaGuru terminal.");
28001
+ receive(code);
28002
+ });
28003
+ await new Promise((resolve12, reject) => {
28004
+ server.once("error", reject);
28005
+ server.listen(0, "127.0.0.1", resolve12);
28006
+ });
28007
+ const address = server.address();
28008
+ if (!address || typeof address === "string") throw new Error("Cannot open the local YouTube authorization listener.");
28009
+ redirectUri = `http://127.0.0.1:${address.port}/oauth2callback`;
28010
+ const timer = setTimeout(() => fail(new Error("YouTube authorization timed out. Run login again.")), seconds2 * 1e3);
28011
+ const abort = () => fail(new Error("YouTube authorization cancelled."));
28012
+ options.signal?.addEventListener("abort", abort, { once: true });
28013
+ let token;
28014
+ try {
28015
+ options.signal?.throwIfAborted();
28016
+ const url2 = new URL("https://accounts.google.com/o/oauth2/v2/auth");
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();
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");
28019
+ options.log?.(`Open ${url2.href}`);
28020
+ if (options.browser !== false) await options.openBrowser?.(url2.href);
28021
+ const body = await tokenRequest(client, { grant_type: "authorization_code", code: await callback, code_verifier: verifier, redirect_uri: redirectUri }, options.signal);
28022
+ token = typeof body.refresh_token === "string" ? body.refresh_token : typeof body.access_token === "string" ? body.access_token : void 0;
28023
+ const scopes = typeof body.scope === "string" ? body.scope.split(" ") : [];
28024
+ if (requested.some((scope) => !scopes.includes(scope))) throw new Error(options.captions ? "Approve all requested YouTube permissions, including captions, or log in without --captions." : "Approve both YouTube permissions to connect this channel.");
28025
+ if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string" || typeof body.expires_in !== "number" || body.expires_in <= 0) throw new Error("Google did not grant offline access. Reconnect and approve access.");
28026
+ const now = Date.now();
28027
+ const credentials = { ...client, accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: now + body.expires_in * 1e3, channelId: "pending", channelTitle: "pending", connectedAt: now, checkedAt: now, scopes };
28028
+ const channel = await channelFor(credentials, options.signal);
28029
+ credentials.channelId = channel.id;
28030
+ credentials.channelTitle = channel.title;
28031
+ await saveYouTubeCredentials(credentials);
28032
+ return { authenticated: true, channelId: channel.id, channelTitle: channel.title, captions: canAddCaptions(credentials) };
28033
+ } catch (error62) {
28034
+ if (token) await revoke(token);
28035
+ throw error62;
28036
+ } finally {
28037
+ clearTimeout(timer);
28038
+ options.signal?.removeEventListener("abort", abort);
28039
+ server.closeAllConnections();
28040
+ await new Promise((resolve12) => server.close(() => resolve12()));
28041
+ }
28042
+ });
28043
+ }
28044
+ async function youtubeStatus(options = {}) {
28045
+ return withYouTubeLock(async () => {
28046
+ if (!await readYouTubeCredentials()) return { authenticated: false };
28047
+ const credentials = await connectedYouTube(options.signal);
28048
+ return { authenticated: true, channelId: credentials.channelId, channelTitle: credentials.channelTitle, captions: canAddCaptions(credentials) };
28049
+ });
28050
+ }
28051
+ async function youtubeLogout(options) {
28052
+ if (!options.yes) throw new Error("Pass --yes to revoke YouTube authorization and remove local credentials and upload receipts.");
28053
+ return withYouTubeLock(async () => {
28054
+ let credentials = null;
28055
+ let readable = true;
28056
+ try {
28057
+ credentials = await readYouTubeCredentials();
28058
+ } catch {
28059
+ readable = false;
28060
+ }
28061
+ const revoked = readable && (credentials ? await revoke(credentials.refreshToken, options.signal) : true);
28062
+ await clearYouTubeData();
28063
+ if (!revoked) options.log?.("Local YouTube data removed. Also revoke access at https://security.google.com/settings/security/permissions because Google could not confirm revocation.");
28064
+ return { authenticated: false, revoked };
28065
+ });
28066
+ }
28067
+
28068
+ // cli/src/youtube-upload.ts
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";
28072
+ import { setTimeout as delay2 } from "node:timers/promises";
28073
+ var YOUTUBE_CHUNK_BYTES = 8 * 1024 * 1024;
28074
+ function safeYouTubeSession(value) {
28075
+ let url2;
28076
+ try {
28077
+ url2 = new URL(value);
28078
+ } catch {
28079
+ throw new YouTubeError("Google returned an invalid upload session.");
28080
+ }
28081
+ if (url2.protocol !== "https:" || !["www.googleapis.com", "youtube.googleapis.com"].includes(url2.hostname) || url2.port || url2.username || url2.password || url2.hash || !/^\/upload\/youtube\/v3\/videos\/?$/.test(url2.pathname) || !url2.searchParams.get("upload_id")) throw new YouTubeError("Google returned an invalid upload session.");
28082
+ return url2.href;
28083
+ }
28084
+ async function inspectMp4(file2, signal) {
28085
+ const handle = await open5(file2, "r");
28086
+ try {
28087
+ const info = await handle.stat();
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.");
28089
+ const header = Buffer.alloc(12);
28090
+ await handle.read(header, 0, header.length, 0);
28091
+ if (header.toString("ascii", 4, 8) !== "ftyp") throw new Error("Use a valid MP4 file.");
28092
+ const hash2 = createHash4("sha256");
28093
+ for await (const buffer of handle.createReadStream({ start: 0, autoClose: false, signal })) hash2.update(buffer);
28094
+ const fingerprint = (value) => `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
28095
+ const check2 = async () => {
28096
+ signal?.throwIfAborted();
28097
+ if (fingerprint(await handle.stat()) !== fingerprint(info)) throw new Error("The MP4 changed during upload. Restore the original export before resuming.");
28098
+ };
28099
+ await check2();
28100
+ return { handle, bytes: info.size, sha256: hash2.digest("hex"), check: check2 };
28101
+ } catch (error62) {
28102
+ await handle.close();
28103
+ throw error62;
28104
+ }
28105
+ }
28106
+ var readCaptionFile = (file2) => readSubtitleFile(file2, 5 * 1024 * 1024);
28107
+ async function planCaptions(root, mp4, options, fallbackLanguage) {
28108
+ const choice = options.captions ?? "auto";
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);
28113
+ const language = options["caption-language"] ?? header ?? fallbackLanguage;
28114
+ const name = options["caption-name"] ?? "";
28115
+ const plan = youtubeCaptions.safeParse({ file: file2, bytes: content.length, sha256, language, name, status: "pending", captionId: null });
28116
+ if (!plan.success) throw new Error("Pass --caption-language with the subtitles' language code, such as en or tr, and a --caption-name up to 150 characters.");
28117
+ return { file: file2, bytes: content.length, sha256, language: plan.data.language, name, explicit: choice !== "auto" };
28118
+ }
28119
+ async function readThumbnail(file2) {
28120
+ let info;
28121
+ try {
28122
+ info = await stat4(file2);
28123
+ } catch {
28124
+ throw new Error(`Cannot read the thumbnail ${file2}.`);
28125
+ }
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.");
28127
+ const content = await readFile11(file2);
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;
28129
+ if (!type) throw new Error("Use a JPEG or PNG thumbnail.");
28130
+ return { content, type, sha256: createHash4("sha256").update(content).digest("hex") };
28131
+ }
28132
+ async function planThumbnail(root, mp4, choice = "auto") {
28133
+ if (choice === "none") return null;
28134
+ let file2 = choice === "auto" ? void 0 : resolve10(root, choice);
28135
+ if (!file2) {
28136
+ for (const extension of [".jpg", ".jpeg", ".png"]) {
28137
+ const candidate = mp4.replace(/\.mp4$/i, extension);
28138
+ if (await stat4(candidate).then((info) => info.isFile(), () => false)) {
28139
+ file2 = candidate;
28140
+ break;
28141
+ }
28142
+ }
28143
+ if (!file2) return null;
28144
+ }
28145
+ const { content, type, sha256 } = await readThumbnail(file2);
28146
+ return { file: file2, bytes: content.length, sha256, type, status: "pending" };
28147
+ }
28148
+ async function addThumbnail(credentials, job, options) {
28149
+ const thumbnail = job.thumbnail;
28150
+ if (!thumbnail || thumbnail.status === "uploaded" || !job.youtubeId) return null;
28151
+ try {
28152
+ const { content, sha256 } = await readThumbnail(thumbnail.file);
28153
+ if (sha256 !== thumbnail.sha256) throw new Error("The thumbnail changed after this upload started; restore it first.");
28154
+ for (let attempts = 0; ; attempts++) {
28155
+ try {
28156
+ const response = await youtubeRequest(credentials, `https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${encodeURIComponent(job.youtubeId)}&uploadType=media`, { method: "POST", headers: { "Content-Type": thumbnail.type }, body: new Uint8Array(content) }, options.signal);
28157
+ const body = await googleJson(response);
28158
+ if (!Array.isArray(body.items) || !body.items.length) throw new YouTubeError("Google returned an invalid thumbnail confirmation. Check the video in YouTube Studio.");
28159
+ break;
28160
+ } catch (error62) {
28161
+ if (!(error62 instanceof YouTubeError)) throw error62;
28162
+ if (error62.reason === "forbidden") throw new Error("YouTube did not allow a custom thumbnail. Verify the channel at https://www.youtube.com/verify, then resume.");
28163
+ if (options.signal?.aborted || !error62.retryable || attempts >= 2) throw error62;
28164
+ await delay2(1e3 * 2 ** attempts, void 0, { signal: options.signal });
28165
+ }
28166
+ }
28167
+ thumbnail.status = "uploaded";
28168
+ job.updatedAt = Date.now();
28169
+ await saveYouTubeJob(job);
28170
+ options.log?.(`Set the thumbnail from ${thumbnail.file}.`);
28171
+ return null;
28172
+ } catch (error62) {
28173
+ if (options.signal?.aborted) throw error62;
28174
+ const message = error62 instanceof Error ? error62.message : String(error62);
28175
+ options.log?.(`The video is on YouTube, but its thumbnail was not set: ${message} Retry with animaguru youtube uploads resume ${job.id} --yes, or set ${thumbnail.file} in YouTube Studio.`);
28176
+ return message;
28177
+ }
28178
+ }
28179
+ async function finishUpload(credentials, job, options) {
28180
+ const summary = await addCaptions(credentials, job, options);
28181
+ const thumbnailError = await addThumbnail(credentials, job, options);
28182
+ return { ...summary, ...youtubeJobSummary(job), ...thumbnailError ? { thumbnailError } : {} };
28183
+ }
28184
+ function languageName(code) {
28185
+ try {
28186
+ return new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code;
28187
+ } catch {
28188
+ return code;
28189
+ }
28190
+ }
28191
+ async function insertCaption(credentials, videoId, language, name, content, signal) {
28192
+ const boundary = `animaguru-${randomUUID3()}`;
28193
+ const metadata = JSON.stringify({ snippet: { videoId, language, name, isDraft: false } });
28194
+ const body = Buffer.concat([Buffer.from(`--${boundary}\r
28195
+ Content-Type: application/json; charset=UTF-8\r
28196
+ \r
28197
+ ${metadata}\r
28198
+ --${boundary}\r
28199
+ Content-Type: application/octet-stream\r
28200
+ \r
28201
+ `), content, Buffer.from(`\r
28202
+ --${boundary}--\r
28203
+ `)]);
28204
+ const response = await youtubeRequest(credentials, "https://www.googleapis.com/upload/youtube/v3/captions?uploadType=multipart&part=snippet", { method: "POST", headers: { "Content-Type": `multipart/related; boundary=${boundary}` }, body: new Uint8Array(body) }, signal);
28205
+ const caption = await googleJson(response);
28206
+ const snippet = caption.snippet;
28207
+ if (typeof caption.id !== "string" || !caption.id || caption.id.length > 512 || snippet?.videoId !== videoId) throw new YouTubeError("Google returned an invalid caption confirmation. Check the video's subtitles in YouTube Studio.");
28208
+ return caption.id;
28209
+ }
28210
+ async function addCaptions(credentials, job, options) {
28211
+ const captions = job.captions;
28212
+ if (!captions || captions.status === "uploaded" || !job.youtubeId) return youtubeJobSummary(job);
28213
+ try {
28214
+ if (!canAddCaptions(credentials)) throw new Error("The channel was connected without caption permission; reconnect with youtube auth login --captions.");
28215
+ const { content, sha256 } = await readCaptionFile(captions.file);
28216
+ if (sha256 !== captions.sha256) throw new Error("The subtitle file changed after this upload started; restore it first.");
28217
+ const names = captions.name ? [captions.name] : ["", languageName(captions.language)];
28218
+ let index = 0;
28219
+ let attempts = 0;
28220
+ let captionId = null;
28221
+ while (true) {
28222
+ try {
28223
+ captionId = await insertCaption(credentials, job.youtubeId, captions.language, names[index], content, options.signal);
28224
+ break;
28225
+ } catch (error62) {
28226
+ if (!(error62 instanceof YouTubeError)) throw error62;
28227
+ if (error62.reason === "invalidMetadata" && index + 1 < names.length) {
28228
+ index++;
28229
+ continue;
28230
+ }
28231
+ if (error62.reason === "captionExists") {
28232
+ options.log?.(`YouTube already has a ${captions.language} caption track${names[index] ? ` named ${names[index]}` : ""} on this video.`);
28233
+ break;
28234
+ }
28235
+ if (options.signal?.aborted || !error62.retryable || attempts >= 2) throw error62;
28236
+ await delay2(1e3 * 2 ** attempts++, void 0, { signal: options.signal });
28237
+ }
28238
+ }
28239
+ captions.name = names[index];
28240
+ captions.status = "uploaded";
28241
+ captions.captionId = captionId;
28242
+ job.updatedAt = Date.now();
28243
+ await saveYouTubeJob(job);
28244
+ options.log?.(`Added ${captions.language} captions from ${captions.file}.`);
28245
+ return youtubeJobSummary(job);
28246
+ } catch (error62) {
28247
+ if (options.signal?.aborted) throw error62;
28248
+ const message = error62 instanceof Error ? error62.message : String(error62);
28249
+ options.log?.(`The video is on YouTube, but its captions were not added: ${message} Retry with animaguru youtube uploads resume ${job.id} --yes, or add ${captions.file} in YouTube Studio.`);
28250
+ return { ...youtubeJobSummary(job), captionsError: message };
28251
+ }
28252
+ }
28253
+ function receivedBytes(response, total) {
28254
+ const range = response.headers.get("Range");
28255
+ if (!range) return 0;
28256
+ const match = /^bytes=0-(\d+)$/.exec(range);
28257
+ const bytes = match ? Number(match[1]) + 1 : NaN;
28258
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > total) throw new YouTubeError("Google returned invalid upload progress.");
28259
+ return bytes;
28260
+ }
28261
+ async function completed(job, response) {
28262
+ const body = await googleJson(response);
28263
+ const snippet = body.snippet;
28264
+ const status = body.status;
28265
+ if (typeof body.id !== "string" || !/^[a-zA-Z0-9_-]{11}$/.test(body.id) || snippet?.channelId !== job.channelId || !["private", "unlisted", "public"].includes(status?.privacyStatus ?? "")) throw new YouTubeError("Google returned an invalid video confirmation. Inspect YouTube Studio before trying a new upload.");
28266
+ job.youtubeId = body.id;
28267
+ job.privacy = status.privacyStatus;
28268
+ job.bytesUploaded = job.bytes;
28269
+ job.status = "completed";
28270
+ job.sessionUrl = null;
28271
+ job.updatedAt = Date.now();
28272
+ await saveYouTubeJob(job);
28273
+ return youtubeJobSummary(job);
28274
+ }
28275
+ async function transfer(credentials, job, source, options) {
28276
+ const url2 = safeYouTubeSession(job.sessionUrl);
28277
+ let attempts = 0;
28278
+ let stalled = 0;
28279
+ try {
28280
+ while (true) {
28281
+ await source.check();
28282
+ try {
28283
+ const probe = await youtubeRequest(credentials, url2, { method: "PUT", headers: { "Content-Range": `bytes */${job.bytes}`, "Content-Length": "0" }, body: Buffer.alloc(0) }, options.signal);
28284
+ if (probe.ok) return await completed(job, probe);
28285
+ const offset = receivedBytes(probe, job.bytes);
28286
+ await probe.body?.cancel();
28287
+ if (offset >= job.bytes) throw new YouTubeError("YouTube has all bytes but has not confirmed the video. Resume this upload later.", true);
28288
+ const bytes = Buffer.alloc(Math.min(YOUTUBE_CHUNK_BYTES, job.bytes - offset));
28289
+ const read2 = await source.handle.read(bytes, 0, bytes.length, offset);
28290
+ if (read2.bytesRead !== bytes.length) throw new Error("The MP4 changed during upload. Restore it before resuming.");
28291
+ await source.check();
28292
+ const response = await youtubeRequest(credentials, url2, { method: "PUT", headers: { "Content-Type": "video/mp4", "Content-Length": String(bytes.length), "Content-Range": `bytes ${offset}-${offset + bytes.length - 1}/${job.bytes}` }, body: new Uint8Array(bytes) }, options.signal);
28293
+ if (response.ok) return await completed(job, response);
28294
+ const uploaded = receivedBytes(response, job.bytes);
28295
+ await response.body?.cancel();
28296
+ if (uploaded <= offset || uploaded > offset + bytes.length) throw new YouTubeError("Google returned invalid upload progress.", true);
28297
+ stalled = uploaded <= job.bytesUploaded ? stalled + 1 : 0;
28298
+ if (stalled > 3) throw new YouTubeError("YouTube upload progress stalled. Resume this upload later.");
28299
+ job.bytesUploaded = uploaded;
28300
+ job.status = "uploading";
28301
+ job.updatedAt = Date.now();
28302
+ await saveYouTubeJob(job);
28303
+ options.log?.(`YouTube upload ${job.id}: ${Math.floor(uploaded / job.bytes * 100)}% (${uploaded}/${job.bytes} bytes)`);
28304
+ attempts = 0;
28305
+ } catch (error62) {
28306
+ if (options.signal?.aborted || !(error62 instanceof YouTubeError) || !error62.retryable || attempts >= 3) throw error62;
28307
+ options.log?.("YouTube transfer interrupted. Checking the saved upload before retrying\u2026");
28308
+ await delay2(1e3 * 2 ** attempts++, void 0, { signal: options.signal });
28309
+ }
28310
+ }
28311
+ } catch (error62) {
28312
+ job.status = error62 instanceof YouTubeError && error62.expired ? "expired" : "interrupted";
28313
+ job.updatedAt = Date.now();
28314
+ if (await readYouTubeCredentials()) await saveYouTubeJob(job);
28315
+ options.log?.(`Upload ID: ${job.id}. Inspect with animaguru youtube uploads list; resume with animaguru youtube uploads resume ${job.id} --yes. Check YouTube Studio before starting a new upload.`);
28316
+ throw error62;
28317
+ }
28318
+ }
28319
+ async function uploadYouTube(options) {
28320
+ if (!options.yes && !options["dry-run"]) throw new Error("Pass --yes to authorize the YouTube upload, or --dry-run to inspect it locally.");
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.");
28322
+ const root = resolve10(options.directory ?? ".");
28323
+ const file2 = resolve10(root, options.file);
28324
+ const context = await localProject(root, options.animation);
28325
+ let config2 = {};
28326
+ try {
28327
+ config2 = JSON.parse(await readFile11(join9(context.projectRoot, "film.config.json"), "utf8"));
28328
+ } catch {
28329
+ }
28330
+ const receipt = await exportReceipt(file2);
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" });
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.");
28333
+ const metadata = parsed.data;
28334
+ let captions = await planCaptions(root, file2, options, receipt.language ?? config2.language);
28335
+ const thumbnail = await planThumbnail(root, file2, options.thumbnail);
28336
+ const source = await inspectMp4(file2, options.signal);
28337
+ try {
28338
+ if (options["dry-run"]) return { dryRun: true, file: file2, bytes: source.bytes, sha256: source.sha256, channelId: options.channel ?? null, metadata, captions: captions && { file: captions.file, bytes: captions.bytes, language: captions.language, name: captions.name }, thumbnail: thumbnail && { file: thumbnail.file, bytes: thumbnail.bytes, type: thumbnail.type } };
28339
+ return await withYouTubeLock(async () => {
28340
+ const credentials = await connectedYouTube(options.signal);
28341
+ if (options.channel && options.channel !== credentials.channelId) throw new Error("The requested channel does not match the connected YouTube channel.");
28342
+ if (captions && !canAddCaptions(credentials)) {
28343
+ if (captions.explicit) throw new Error("Adding captions needs YouTube caption permission. Run youtube auth logout --yes, then youtube auth login --captions, or pass --captions none.");
28344
+ options.log?.(`Skipping ${captions.file}: the channel was connected without caption permission. Reconnect with youtube auth login --captions to add subtitles.`);
28345
+ captions = null;
28346
+ }
28347
+ const pending = captions && { file: captions.file, bytes: captions.bytes, sha256: captions.sha256, language: captions.language, name: captions.name, status: "pending", captionId: null };
28348
+ const jobs = (await localYouTubeJobs()).filter((job2) => job2.channelId === credentials.channelId && job2.sha256 === source.sha256);
28349
+ if (jobs.some((job2) => ["uploading", "interrupted"].includes(job2.status))) throw new Error("This file has an unfinished upload. Use youtube uploads list and resume its ID instead of creating a duplicate.");
28350
+ const previous = jobs.find((job2) => job2.status === "completed" && JSON.stringify(job2.metadata) === JSON.stringify(metadata));
28351
+ if (previous) {
28352
+ if (pending && previous.captions?.status !== "uploaded") previous.captions = pending;
28353
+ else if (pending && previous.captions?.sha256 !== pending.sha256) options.log?.(`This video already has captions from ${previous.captions.file}. Replace them in YouTube Studio.`);
28354
+ if (thumbnail && previous.thumbnail?.status !== "uploaded") previous.thumbnail = thumbnail;
28355
+ else if (thumbnail && previous.thumbnail?.sha256 !== thumbnail.sha256) options.log?.(`This video already has the thumbnail from ${previous.thumbnail.file}. Replace it in YouTube Studio.`);
28356
+ await saveYouTubeJob(previous);
28357
+ return { ...await finishUpload(credentials, previous, options), alreadyUploaded: true };
28358
+ }
28359
+ await source.check();
28360
+ options.log?.(`Uploading ${file2} to ${credentials.channelTitle} (${credentials.channelId}) with ${metadata.privacy} visibility${pending ? `, then adding ${pending.language} captions from ${pending.file}` : ""}${thumbnail ? `${pending ? " and" : ", then setting"} the thumbnail ${thumbnail.file}` : ""}. You confirm you have the rights to upload this video. YouTube terms: https://www.youtube.com/t/terms`);
28361
+ const response = await youtubeRequest(credentials, "https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status&notifySubscribers=false", { method: "POST", headers: { "Content-Type": "application/json; charset=UTF-8", "X-Upload-Content-Length": String(source.bytes), "X-Upload-Content-Type": "video/mp4" }, body: JSON.stringify({ snippet: { title: metadata.title, description: metadata.description, categoryId: "22" }, status: { privacyStatus: metadata.privacy, selfDeclaredMadeForKids: metadata.madeForKids, containsSyntheticMedia: metadata.syntheticMedia } }) }, options.signal);
28362
+ const location = response.headers.get("Location");
28363
+ await response.body?.cancel();
28364
+ if (!location) throw new YouTubeError("Google did not return an upload session. No video bytes were sent.");
28365
+ const now = Date.now();
28366
+ const job = { id: randomUUID3(), file: file2, bytes: source.bytes, sha256: source.sha256, clientId: credentials.clientId, channelId: credentials.channelId, metadata, status: "uploading", sessionUrl: safeYouTubeSession(location), bytesUploaded: 0, youtubeId: null, privacy: metadata.privacy, createdAt: now, updatedAt: now, ...pending ? { captions: pending } : {}, ...thumbnail ? { thumbnail } : {} };
28367
+ await saveYouTubeJob(job);
28368
+ options.log?.(`Upload ID: ${job.id}`);
28369
+ await transfer(credentials, job, source, options);
28370
+ return finishUpload(credentials, job, options);
28371
+ });
28372
+ } finally {
28373
+ await source.handle.close();
28374
+ }
28375
+ }
28376
+ async function resumeYouTube(id2, options) {
28377
+ if (!options.yes) throw new Error("Pass --yes to resume the previously authorized YouTube upload with its saved destination and settings.");
28378
+ return withYouTubeLock(async () => {
28379
+ const job = await readYouTubeJob(id2);
28380
+ if (job.status === "completed") {
28381
+ if (job.captions?.status !== "pending" && job.thumbnail?.status !== "pending") return youtubeJobSummary(job);
28382
+ const credentials = await connectedYouTube(options.signal);
28383
+ if (credentials.channelId !== job.channelId) throw new Error("Connect the original YouTube channel before adding this video's captions or thumbnail.");
28384
+ return finishUpload(credentials, job, options);
28385
+ }
28386
+ if (job.status === "expired" || !job.sessionUrl) throw new Error("This YouTube session expired. Inspect your channel before starting a new upload.");
28387
+ safeYouTubeSession(job.sessionUrl);
28388
+ const source = await inspectMp4(job.file, options.signal);
28389
+ try {
28390
+ if (source.bytes !== job.bytes || source.sha256 !== job.sha256) throw new Error("The MP4 differs from the original upload. Restore the original file before resuming.");
28391
+ const credentials = await connectedYouTube(options.signal);
28392
+ if (credentials.clientId !== job.clientId || credentials.channelId !== job.channelId) throw new Error("Connect the original Google client and YouTube channel before resuming.");
28393
+ options.log?.(`Resuming ${job.id} to ${credentials.channelTitle} (${job.channelId}) with ${job.metadata.privacy} visibility.`);
28394
+ await transfer(credentials, job, source, options);
28395
+ return await finishUpload(credentials, job, options);
28396
+ } finally {
28397
+ await source.handle.close();
28398
+ }
28399
+ });
28400
+ }
28401
+ var listYouTubeUploads = () => withYouTubeLock(async () => ({ uploads: (await localYouTubeJobs()).map(youtubeJobSummary) }));
28402
+
27456
28403
  // cli/src/commands.ts
27457
28404
  var assetModelIds = [...SPRITE_IMAGE_MODEL_IDS, ...SPRITE_VIDEO_MODEL_IDS, ...MUSIC_MODELS.map((m) => m.id), ...SPEECH_MODELS.map((m) => m.id), ...SFX_MODELS.map((m) => m.id)];
27458
28405
  var assetAuthorizationFlags = [
@@ -27478,17 +28425,38 @@ var workspaceQuery = async (client) => {
27478
28425
  async function openBrowser(url2) {
27479
28426
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
27480
28427
  const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url2] : [url2];
27481
- await new Promise((resolve10) => {
28428
+ await new Promise((resolve12) => {
27482
28429
  const child = spawn3(command, args, { stdio: "ignore", detached: true });
27483
- child.once("error", () => resolve10());
28430
+ child.once("error", () => resolve12());
27484
28431
  child.once("spawn", () => {
27485
28432
  child.unref();
27486
- resolve10();
28433
+ resolve12();
27487
28434
  });
27488
28435
  });
27489
28436
  }
27490
28437
  function createCli() {
27491
- return new Crust("animaguru", { description: "Sprite and asset generation, video workspaces, and public or private video sharing. Successful commands output JSON. Paid jobs and sharing require --yes.", version: package_default.version }).add(defineCommand("init", { description: "Install or refresh the AnimaGuru agent skill in the current folder" }, (command) => command.action(async ({ stdout }) => out(stdout, await initializeSkill())))).add(defineCommand("templates", { description: "List video categories and available starter templates" }, (command) => command.action(({ stdout }) => out(stdout, listTemplates())))).command("create", (creation) => creation.args({ name: "name", type: "string", description: "Legacy standalone project folder; prefer create workspace NAME" }).action(async ({ args, stdout, stderr, signal }) => {
28438
+ return new Crust("animaguru", { description: "Sprite and asset generation, video workspaces, video sharing, and direct local YouTube uploads. Successful commands output JSON. Paid jobs and uploads require --yes.", version: package_default.version }).command("youtube", (youtube) => youtube.command("auth", (auth) => auth.add(defineCommand("login", { description: "Connect a YouTube channel through Google; credentials stay on this computer" }, (command) => command.flags(
28439
+ { name: "client-file", type: "string", required: true, env: { name: "ANIMAGURU_YOUTUBE_CLIENT_FILE" }, description: "Google Desktop app OAuth client JSON" },
28440
+ { name: "browser", type: "boolean", default: true, description: "Open the system browser; --no-browser prints the URL" },
28441
+ { name: "timeout", type: "number", default: 300, description: "Authorization timeout in seconds (1\u2013600)" },
28442
+ { name: "captions", type: "boolean", description: "Also allow adding subtitle tracks; Google's youtube.force-ssl permission also lets the CLI manage your videos" },
28443
+ { name: "yes", type: "boolean", description: "Accept YouTube terms and authorize local credential storage" }
28444
+ ).action(async ({ flags, stdout, stderr, signal }) => out(stdout, await youtubeLogin({ ...flags, log: stderr, signal, openBrowser }))))).add(defineCommand("status", { description: "Verify and show the connected YouTube channel without exposing tokens" }, (command) => command.action(async ({ stdout, signal }) => out(stdout, await youtubeStatus({ signal }))))).add(defineCommand("logout", { description: "Revoke Google access and remove local YouTube credentials and receipts; videos remain" }, (command) => command.flags({ name: "yes", type: "boolean", description: "Authorize revocation and local data removal" }).action(async ({ flags, stdout, stderr, signal }) => out(stdout, await youtubeLogout({ ...flags, log: stderr, signal })))))).add(defineCommand("upload", { description: "Upload a local MP4 directly to your YouTube channel; no AnimaGuru server or sign-in" }, (command) => command.args({ name: "directory", type: "string", default: ".", description: "Project folder used for relative file paths and default title" }).flags(
28445
+ { name: "file", type: "string", required: true, description: "Local exported MP4" },
28446
+ { name: "animation", type: "string", description: "Animation in a multi-animation workspace" },
28447
+ { name: "title", type: "string" },
28448
+ { name: "description", type: "string" },
28449
+ { name: "privacy", type: "string", choices: ["private", "unlisted", "public"], default: "private" },
28450
+ { name: "made-for-kids", type: "string", required: true, choices: ["yes", "no"], description: "Your YouTube audience declaration" },
28451
+ { name: "synthetic-media", type: "string", required: true, choices: ["yes", "no"], description: "Contains realistic altered or synthetic media; follow YouTube disclosure guidance" },
28452
+ { name: "channel", type: "string", description: "Require this exact channel ID instead of relying on the saved connection" },
28453
+ { name: "captions", type: "string", default: "auto", description: "Subtitle file (.srt or .vtt) to add after the upload; auto uses FILM.srt or FILM.vtt beside FILM.mp4; none skips" },
28454
+ { name: "caption-language", type: "string", description: "Subtitle language code such as en or tr; defaults to the MP4's export receipt or film.config.json" },
28455
+ { name: "caption-name", type: "string", description: "Track name in YouTube's subtitle menu; empty by default, like YouTube Studio uploads" },
28456
+ { name: "thumbnail", type: "string", default: "auto", description: "JPEG or PNG thumbnail up to 2 MiB to set after the upload; auto uses FILM.jpg beside FILM.mp4; none skips" },
28457
+ { name: "yes", type: "boolean", description: "Authorize sending this video, its subtitles and thumbnail to the selected channel and privacy; confirm upload rights" },
28458
+ { name: "dry-run", type: "boolean", description: "Inspect MP4 and metadata locally without Google authorization or network calls" }
28459
+ ).action(async ({ args, flags, stdout, stderr, signal }) => out(stdout, await uploadYouTube({ ...flags, directory: args.directory, log: stderr, signal }))))).command("uploads", (uploads) => uploads.add(defineCommand("list", { description: "List local upload receipts without tokens or session URLs" }, (command) => command.action(async ({ stdout }) => out(stdout, await listYouTubeUploads())))).add(defineCommand("resume", { description: "Resume the same local MP4 and Google upload session after an interruption" }, (command) => command.args({ name: "id", type: "string", required: true, description: "Upload ID returned by youtube uploads list" }).flags({ name: "yes", type: "boolean", description: "Authorize resuming the saved upload and visibility" }).action(async ({ args, flags, stdout, stderr, signal }) => out(stdout, await resumeYouTube(args.id, { ...flags, log: stderr, signal }))))))).add(defineCommand("init", { description: "Install or refresh the AnimaGuru agent skill in the current folder" }, (command) => command.action(async ({ stdout }) => out(stdout, await initializeSkill())))).add(defineCommand("templates", { description: "List video categories and available starter templates" }, (command) => command.action(({ stdout }) => out(stdout, listTemplates())))).command("create", (creation) => creation.args({ name: "name", type: "string", description: "Legacy standalone project folder; prefer create workspace NAME" }).action(async ({ args, stdout, stderr, signal }) => {
27492
28460
  if (!args.name) throw new Error("Use animaguru create workspace NAME, or animaguru create animation NAME inside a workspace.");
27493
28461
  out(stdout, await createVideoProject({ directory: args.name, log: stderr, signal }));
27494
28462
  }).add(defineCommand("workspace", { description: "Create a shared-brand workspace with one player, dependencies, skills and export toolchain" }, (command) => command.args({ name: "name", type: "string", required: true }).action(async ({ args, stdout, stderr, signal }) => out(stdout, await createWorkspace({ directory: args.name, log: stderr, signal }))))).add(defineCommand("animation", { description: "Add an animation to a workspace without copying shared runtime, dependencies or skills" }, (command) => command.args({ name: "name", type: "string", required: true }).flags({ name: "workspace", type: "string", description: "Workspace folder; otherwise finds it from the current directory" }).action(async ({ args, flags, stdout, stderr, signal }) => out(stdout, await createAnimation({ name: args.name, workspace: flags.workspace, log: stderr, signal })))))).add(defineCommand("submit", { description: "Upload a finished HTML5 animation or MP4 to AnimaGuru; private unless --visibility public" }, (command) => command.args({ name: "directory", type: "string", default: ".", description: "Video project folder; defaults to current folder" }).flags(
@@ -27500,6 +28468,8 @@ function createCli() {
27500
28468
  { name: "visibility", type: "string", choices: ["private", "public"], default: "private" },
27501
28469
  { name: "share", type: "string", description: "Comma-separated emails of verified AnimaGuru users (private videos)" },
27502
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" },
27503
28473
  { name: "yes", type: "boolean", description: "Authorize upload and chosen sharing" },
27504
28474
  { name: "dry-run", type: "boolean", description: "Inspect locally without uploading" }
27505
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 }) => {
@@ -27507,7 +28477,13 @@ function createCli() {
27507
28477
  const client = new Client(flags.url, signal);
27508
28478
  const { video } = await client.request(`/api/videos/${encodeURIComponent(args.id)}`);
27509
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) }) }));
27510
- }))).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 }) => {
27511
28487
  if (!flags.yes) throw new Error("Pass --yes to delete the video.");
27512
28488
  out(stdout, await new Client(flags.url, signal).request(`/api/v1/videos/${encodeURIComponent(args.id)}`, { method: "DELETE" }));
27513
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 }) => {
@@ -27519,7 +28495,7 @@ Confirm code: ${device.user_code}`);
27519
28495
  const expiresAt = Date.now() + device.expires_in * 1e3;
27520
28496
  let interval = device.interval * 1e3;
27521
28497
  while (Date.now() < expiresAt) {
27522
- await delay2(interval, void 0, { signal });
28498
+ await delay3(interval, void 0, { signal });
27523
28499
  try {
27524
28500
  const session = await client.request("/api/auth/device/token", { method: "POST", authenticated: false, body: { client_id: CLI_CLIENT_ID, device_code: device.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" } });
27525
28501
  const credentials = await readCredentials();
@@ -27596,7 +28572,7 @@ Confirm code: ${device.user_code}`);
27596
28572
  ...assetAuthorizationFlags
27597
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 }) => {
27598
28574
  if (!flags.yes) throw new Error("Pass --yes to authorize spending credits.");
27599
- const body = spriteJobBody.parse({ ...JSON.parse(await readFile8(flags.file, "utf8")), ...selectedModels(flags) });
28575
+ const body = spriteJobBody.parse({ ...JSON.parse(await readFile12(flags.file, "utf8")), ...selectedModels(flags) });
27600
28576
  out(stdout, await new Client(flags.url, signal).request(`${projectPath(args.id)}/jobs`, { method: "POST", body }));
27601
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 }) => {
27602
28578
  if (!Number.isFinite(flags.timeout) || flags.timeout <= 0) throw new Error("Timeout must be positive.");
@@ -27609,7 +28585,7 @@ Confirm code: ${device.user_code}`);
27609
28585
  out(stdout, { job });
27610
28586
  return;
27611
28587
  }
27612
- await delay2(3e3, void 0, { signal });
28588
+ await delay3(3e3, void 0, { signal });
27613
28589
  }
27614
28590
  throw new Error("Timed out waiting for the job. It may still be running.");
27615
28591
  })))).add(defineCommand("models", { description: "Set default image and/or video models for future project jobs" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id).flags({ ...modelFlags[0], aliases: ["model"] }, modelFlags[1]).action(async ({ args, flags, stdout, signal }) => {
@@ -27625,7 +28601,7 @@ Confirm code: ${device.user_code}`);
27625
28601
  const extension = flags.file.split(".").at(-1)?.toLowerCase();
27626
28602
  const type = extension === "png" ? "image/png" : extension === "jpg" || extension === "jpeg" ? "image/jpeg" : extension === "webp" ? "image/webp" : void 0;
27627
28603
  if (!type) throw new Error("Use a PNG, JPEG or WebP image.");
27628
- const bytes = await readFile8(flags.file);
28604
+ const bytes = await readFile12(flags.file);
27629
28605
  if (bytes.length > 10 * 1024 * 1024) throw new Error("Image must be at most 10 MB.");
27630
28606
  const form = new FormData();
27631
28607
  form.set("image", new Blob([bytes], { type }), `reference.${extension}`);
@@ -27643,8 +28619,8 @@ Confirm code: ${device.user_code}`);
27643
28619
  const response = await client.request(`/api/v1/blobs/${encodeURIComponent(manifest.ownerId)}/${file2.sha}`, { raw: true });
27644
28620
  entries[path2] = new Uint8Array(await response.arrayBuffer());
27645
28621
  }
27646
- const path = resolve9(flags.output);
27647
- await writeFile8(path, zipSync(entries, { level: 0 }), { flag: "wx" });
28622
+ const path = resolve11(flags.output);
28623
+ await writeFile9(path, zipSync(entries, { level: 0 }), { flag: "wx" });
27648
28624
  out(stdout, { path, files: manifest.files.length });
27649
28625
  })))).extend(version(package_default.version, { format: "plain" })).extend(help()).extend(completion());
27650
28626
  }