@docyrus/animaguru 0.8.1 → 0.9.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.9.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 [open5, close] = styleMethodCodes[name];
2674
2674
  return [name, {
2675
- open: `\x1B[${open3}m`,
2675
+ open: `\x1B[${open5}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: open5, close } = style2;
3483
+ if (result.includes(close)) result = result.replaceAll(close, close + open5);
3484
+ return open5 + 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 open5 = "";
3688
3688
  let close = "";
3689
3689
  for (const step of steps) {
3690
3690
  const pair = stepPair(step, pairDepth);
3691
- open3 += pair.open;
3691
+ open5 += pair.open;
3692
3692
  close = pair.close + close;
3693
3693
  }
3694
3694
  Object.defineProperty(styleFn, "open", {
3695
- value: open3,
3695
+ value: open5,
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 readFile11, writeFile as writeFile9 } from "node:fs/promises";
5080
+ import { resolve as resolve10 } 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, resolve11) {
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, resolve11);
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, resolve11) : 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 ?? (resolve11 ? 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: createHash4 } = await import("node:crypto");
27003
+ await writeFile6(join5(directory, "templates/animation/starter-film.sha256"), createHash4("sha256").update(await readFile4(join5(directory, "templates/animation/src/film.ts"))).digest("hex") + "\n");
27007
27004
  await rm2(join5(directory, "animaguru.project.json"));
27008
27005
  await mkdir6(join5(directory, "animations"));
27009
27006
  await mkdir6(join5(directory, "branding"));
@@ -27209,6 +27206,11 @@ import { createHash } from "node:crypto";
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"]),
@@ -27360,7 +27364,7 @@ async function submitVideo(client, options) {
27360
27364
  const draft = await client.request("/api/v1/videos", { method: "POST", body });
27361
27365
  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
27366
  const handle = await open2(file2, "r");
27363
- let completed = false;
27367
+ let completed2 = false;
27364
27368
  try {
27365
27369
  for (let offset = 0, part = 1; offset < info.size; offset += draft.partBytes, part++) {
27366
27370
  options.signal?.throwIfAborted();
@@ -27382,10 +27386,10 @@ async function submitVideo(client, options) {
27382
27386
  if (poster) await client.request(`/api/v1/videos/${draft.id}/poster`, { method: "PUT", body: new Blob([new Uint8Array(poster)]) });
27383
27387
  options.log?.("Preparing your AnimaGuru player\u2026");
27384
27388
  const result = await client.request(`/api/v1/videos/${draft.id}/complete`, { method: "POST", timeout: 3e5 });
27385
- completed = true;
27389
+ completed2 = true;
27386
27390
  return { ...result, url: new URL(result.url, client.origin).href };
27387
27391
  } 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.`);
27392
+ 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
27393
  throw error62;
27390
27394
  } finally {
27391
27395
  await handle.close();
@@ -27453,6 +27457,797 @@ async function downloadAsset(client, project, assetId, output2) {
27453
27457
  return { path, id: asset.id, type: asset.type, mimeType: asset.mimeType, size: bytes.byteLength };
27454
27458
  }
27455
27459
 
27460
+ // cli/src/youtube-auth.ts
27461
+ import { createServer } from "node:http";
27462
+ import { randomBytes, createHash as createHash2, timingSafeEqual } from "node:crypto";
27463
+ import { readFile as readFile9, stat as stat2 } from "node:fs/promises";
27464
+
27465
+ // cli/src/youtube-store.ts
27466
+ import { chmod as chmod3, lstat, mkdir as mkdir7, open as open3, readFile as readFile8, readdir as readdir3, rename as rename4, unlink, writeFile as writeFile8 } from "node:fs/promises";
27467
+ import { dirname as dirname6, join as join8 } from "node:path";
27468
+ import { randomUUID as randomUUID2 } from "node:crypto";
27469
+ var youtubeMetadata = external_exports.object({
27470
+ title: external_exports.string().trim().min(1).max(100).refine((value) => !/[<>]/.test(value)),
27471
+ description: external_exports.string().max(5e3).refine((value) => Buffer.byteLength(value, "utf8") <= 5e3 && !/[<>]/.test(value)),
27472
+ privacy: external_exports.enum(["private", "unlisted", "public"]),
27473
+ madeForKids: external_exports.boolean(),
27474
+ syntheticMedia: external_exports.boolean()
27475
+ }).strict();
27476
+ var youtubeCredentials = external_exports.object({
27477
+ clientId: external_exports.string().min(1),
27478
+ clientSecret: external_exports.string().optional(),
27479
+ accessToken: external_exports.string().min(1),
27480
+ refreshToken: external_exports.string().min(1),
27481
+ expiresAt: external_exports.number().finite(),
27482
+ channelId: external_exports.string().min(1),
27483
+ channelTitle: external_exports.string().min(1),
27484
+ connectedAt: external_exports.number(),
27485
+ checkedAt: external_exports.number(),
27486
+ scopes: external_exports.array(external_exports.string()).optional()
27487
+ }).strict();
27488
+ var youtubeCaptions = external_exports.object({
27489
+ file: external_exports.string().min(1),
27490
+ bytes: external_exports.number().int().positive().safe(),
27491
+ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/),
27492
+ language: external_exports.string().regex(/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/),
27493
+ name: external_exports.string().max(150),
27494
+ status: external_exports.enum(["pending", "uploaded"]),
27495
+ captionId: external_exports.string().min(1).max(512).nullable()
27496
+ }).strict();
27497
+ var youtubeThumbnail = external_exports.object({
27498
+ file: external_exports.string().min(1),
27499
+ bytes: external_exports.number().int().positive().safe(),
27500
+ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/),
27501
+ type: external_exports.enum(["image/jpeg", "image/png"]),
27502
+ status: external_exports.enum(["pending", "uploaded"])
27503
+ }).strict();
27504
+ var youtubeJob = external_exports.object({
27505
+ id: external_exports.uuid(),
27506
+ file: external_exports.string().min(1),
27507
+ bytes: external_exports.number().int().positive().safe(),
27508
+ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/),
27509
+ clientId: external_exports.string().min(1),
27510
+ channelId: external_exports.string().min(1),
27511
+ metadata: youtubeMetadata,
27512
+ status: external_exports.enum(["uploading", "interrupted", "completed", "expired"]),
27513
+ sessionUrl: external_exports.string().nullable(),
27514
+ bytesUploaded: external_exports.number().int().nonnegative(),
27515
+ youtubeId: external_exports.string().regex(/^[a-zA-Z0-9_-]{11}$/).nullable(),
27516
+ privacy: external_exports.enum(["private", "unlisted", "public"]),
27517
+ createdAt: external_exports.number(),
27518
+ updatedAt: external_exports.number(),
27519
+ captions: youtubeCaptions.optional(),
27520
+ thumbnail: youtubeThumbnail.optional()
27521
+ }).strict();
27522
+ var youtubeDirectory = () => join8(dirname6(credentialsPath()), "youtube");
27523
+ var credentialFile = () => join8(youtubeDirectory(), "credentials.json");
27524
+ function jobFile(id2) {
27525
+ if (!external_exports.uuid().safeParse(id2).success) throw new Error("Use an upload ID returned by animaguru youtube uploads list.");
27526
+ return join8(youtubeDirectory(), "uploads", `${id2}.json`);
27527
+ }
27528
+ async function privateDirectory(directory) {
27529
+ await mkdir7(directory, { recursive: true, mode: 448 });
27530
+ const info = await lstat(directory);
27531
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("YouTube configuration must use a real directory.");
27532
+ await chmod3(directory, 448);
27533
+ }
27534
+ async function existingDirectory(directory) {
27535
+ try {
27536
+ const info = await lstat(directory);
27537
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("YouTube configuration must use a real directory.");
27538
+ return true;
27539
+ } catch (error62) {
27540
+ if (error62.code === "ENOENT") return false;
27541
+ throw error62;
27542
+ }
27543
+ }
27544
+ async function readPrivate(file2) {
27545
+ try {
27546
+ if (!await existingDirectory(youtubeDirectory()) || !await existingDirectory(dirname6(file2))) return null;
27547
+ const info = await lstat(file2);
27548
+ if (info.isSymbolicLink() || !info.isFile() || info.size > 65536) throw new Error("Invalid local YouTube state file.");
27549
+ await chmod3(file2, 384);
27550
+ return JSON.parse(await readFile8(file2, "utf8"));
27551
+ } catch (error62) {
27552
+ if (error62.code === "ENOENT") return null;
27553
+ throw new Error("Cannot read local YouTube state. Check your AnimaGuru configuration directory.");
27554
+ }
27555
+ }
27556
+ async function writePrivate(file2, value) {
27557
+ await privateDirectory(youtubeDirectory());
27558
+ await privateDirectory(dirname6(file2));
27559
+ const temporary = `${file2}.${randomUUID2()}.tmp`;
27560
+ try {
27561
+ await writeFile8(temporary, JSON.stringify(value) + "\n", { flag: "wx", mode: 384 });
27562
+ await chmod3(temporary, 384);
27563
+ await rename4(temporary, file2);
27564
+ } finally {
27565
+ await unlink(temporary).catch(() => {
27566
+ });
27567
+ }
27568
+ }
27569
+ async function readYouTubeCredentials() {
27570
+ const value = await readPrivate(credentialFile());
27571
+ if (value === null) return null;
27572
+ const parsed = youtubeCredentials.safeParse(value);
27573
+ if (!parsed.success) throw new Error("Invalid saved YouTube credentials. Run youtube auth logout, then login again.");
27574
+ return parsed.data;
27575
+ }
27576
+ var saveYouTubeCredentials = (value) => writePrivate(credentialFile(), youtubeCredentials.parse(value));
27577
+ var saveYouTubeJob = (value) => writePrivate(jobFile(value.id), youtubeJob.parse(value));
27578
+ async function readYouTubeJob(id2) {
27579
+ const parsed = youtubeJob.safeParse(await readPrivate(jobFile(id2)));
27580
+ if (!parsed.success || parsed.data.id !== id2) throw new Error("Upload not found or local upload state is invalid.");
27581
+ return parsed.data;
27582
+ }
27583
+ function youtubeJobSummary(job) {
27584
+ 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 };
27585
+ }
27586
+ async function localYouTubeJobs() {
27587
+ const directory = join8(youtubeDirectory(), "uploads");
27588
+ if (!await existingDirectory(directory)) return [];
27589
+ let files;
27590
+ try {
27591
+ files = await readdir3(directory);
27592
+ } catch (error62) {
27593
+ if (error62.code === "ENOENT") return [];
27594
+ throw error62;
27595
+ }
27596
+ const jobs = [];
27597
+ for (const file2 of files.filter((name) => name.endsWith(".json") && external_exports.uuid().safeParse(name.slice(0, -5)).success)) {
27598
+ const job = await readYouTubeJob(file2.slice(0, -5));
27599
+ if (job.updatedAt < Date.now() - 30 * 864e5) await unlink(jobFile(job.id));
27600
+ else jobs.push(job);
27601
+ }
27602
+ return jobs.sort((a, b) => b.createdAt - a.createdAt);
27603
+ }
27604
+ async function clearYouTubeData() {
27605
+ await unlink(credentialFile()).catch((error62) => {
27606
+ if (error62.code !== "ENOENT") throw error62;
27607
+ });
27608
+ if (!await existingDirectory(join8(youtubeDirectory(), "uploads"))) return;
27609
+ let files;
27610
+ try {
27611
+ files = await readdir3(join8(youtubeDirectory(), "uploads"));
27612
+ } catch (error62) {
27613
+ if (error62.code === "ENOENT") return;
27614
+ throw error62;
27615
+ }
27616
+ 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)));
27617
+ }
27618
+ async function withYouTubeLock(operation) {
27619
+ await privateDirectory(youtubeDirectory());
27620
+ const file2 = join8(youtubeDirectory(), "operation.lock");
27621
+ const lock = await open3(file2, "wx", 384).catch((error62) => {
27622
+ if (error62.code === "EEXIST") throw new Error(`Another YouTube command is running. If a previous command was killed, verify it has stopped before removing ${file2}.`);
27623
+ throw error62;
27624
+ });
27625
+ try {
27626
+ await lock.writeFile(String(process.pid));
27627
+ return await operation();
27628
+ } finally {
27629
+ await lock.close();
27630
+ await unlink(file2);
27631
+ }
27632
+ }
27633
+
27634
+ // cli/src/youtube-auth.ts
27635
+ var YOUTUBE_SCOPES = ["https://www.googleapis.com/auth/youtube.upload", "https://www.googleapis.com/auth/youtube.readonly"];
27636
+ var YOUTUBE_CAPTION_SCOPE = "https://www.googleapis.com/auth/youtube.force-ssl";
27637
+ var canAddCaptions = (credentials) => credentials.scopes?.includes(YOUTUBE_CAPTION_SCOPE) ?? false;
27638
+ var YouTubeError = class extends Error {
27639
+ constructor(message, retryable = false, expired = false, reason) {
27640
+ super(message);
27641
+ this.retryable = retryable;
27642
+ this.expired = expired;
27643
+ this.reason = reason;
27644
+ }
27645
+ retryable;
27646
+ expired;
27647
+ reason;
27648
+ };
27649
+ async function googleJson(response) {
27650
+ const reader = response.body?.getReader();
27651
+ if (!reader) return {};
27652
+ const buffers = [];
27653
+ let size = 0;
27654
+ try {
27655
+ while (true) {
27656
+ const { value: value2, done } = await reader.read();
27657
+ if (done) break;
27658
+ size += value2.length;
27659
+ if (size > 65536) throw new Error();
27660
+ buffers.push(value2);
27661
+ }
27662
+ const value = JSON.parse(Buffer.concat(buffers).toString("utf8"));
27663
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
27664
+ } catch {
27665
+ return {};
27666
+ } finally {
27667
+ await reader.cancel().catch(() => {
27668
+ });
27669
+ }
27670
+ }
27671
+ async function providerError(response) {
27672
+ const body = await googleJson(response);
27673
+ const error62 = body.error;
27674
+ const raw = typeof error62 === "object" ? error62?.errors?.[0]?.reason : error62;
27675
+ const reason = typeof raw === "string" && /^[A-Za-z_]{1,64}$/.test(raw) ? raw : void 0;
27676
+ if (reason === "invalid_grant" || response.status === 401) return new YouTubeError("YouTube authorization expired or was revoked. Run youtube auth login again.");
27677
+ if (["quotaExceeded", "dailyLimitExceeded", "uploadLimitExceeded"].includes(reason ?? "")) return new YouTubeError("YouTube upload quota exceeded. Resume this upload after your quota resets.");
27678
+ if (reason === "captionExists") return new YouTubeError("The video already has a caption track with this language and name.", false, false, reason);
27679
+ if (reason === "videoNotFound") return new YouTubeError("YouTube cannot find the uploaded video yet.", true, false, reason);
27680
+ if (reason === "invalidImage") return new YouTubeError("YouTube rejected the thumbnail image.", false, false, reason);
27681
+ if (reason === "invalidMetadata") return new YouTubeError("YouTube rejected the caption language or track name.", false, false, reason);
27682
+ if ([404, 410].includes(response.status)) return new YouTubeError("YouTube upload session expired. Inspect your channel before starting a new upload.", false, true);
27683
+ 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);
27684
+ return new YouTubeError(`YouTube request failed (${response.status}${reason ? `: ${reason}` : ""}).`, response.status === 429 || response.status >= 500, false, reason);
27685
+ }
27686
+ async function googleFetch(url2, init, signal) {
27687
+ signal?.throwIfAborted();
27688
+ try {
27689
+ return await fetch(url2, { ...init, redirect: "manual", signal: AbortSignal.any([AbortSignal.timeout(18e4), ...signal ? [signal] : []]) });
27690
+ } catch {
27691
+ signal?.throwIfAborted();
27692
+ throw new YouTubeError("YouTube connection interrupted.", true);
27693
+ }
27694
+ }
27695
+ async function tokenRequest(client, params, signal) {
27696
+ 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);
27697
+ if (!response.ok) {
27698
+ const failure2 = await providerError(response);
27699
+ if (failure2.message.includes("authorization expired")) await clearYouTubeData();
27700
+ throw failure2;
27701
+ }
27702
+ return googleJson(response);
27703
+ }
27704
+ async function accessToken(credentials, signal, force = false) {
27705
+ if (!force && credentials.expiresAt > Date.now() + 6e4) return credentials.accessToken;
27706
+ const body = await tokenRequest(credentials, { grant_type: "refresh_token", refresh_token: credentials.refreshToken }, signal);
27707
+ const scopes = typeof body.scope === "string" ? body.scope.split(" ") : null;
27708
+ if (scopes && YOUTUBE_SCOPES.some((scope) => !scopes.includes(scope))) {
27709
+ await clearYouTubeData();
27710
+ throw new YouTubeError("YouTube permissions changed. Run youtube auth login again.");
27711
+ }
27712
+ if (typeof body.access_token !== "string" || typeof body.expires_in !== "number" || body.expires_in <= 0) throw new YouTubeError("Google returned an invalid authorization response.");
27713
+ if (scopes) credentials.scopes = scopes;
27714
+ credentials.accessToken = body.access_token;
27715
+ credentials.expiresAt = Date.now() + body.expires_in * 1e3;
27716
+ if (typeof body.refresh_token === "string") credentials.refreshToken = body.refresh_token;
27717
+ await saveYouTubeCredentials(credentials);
27718
+ return credentials.accessToken;
27719
+ }
27720
+ async function youtubeRequest(credentials, url2, init = {}, signal) {
27721
+ const send = (token) => googleFetch(url2, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` } }, signal);
27722
+ let response = await send(await accessToken(credentials, signal));
27723
+ if (response.status === 401) {
27724
+ await response.body?.cancel();
27725
+ response = await send(await accessToken(credentials, signal, true));
27726
+ }
27727
+ if (!response.ok && response.status !== 308) throw await providerError(response);
27728
+ return response;
27729
+ }
27730
+ async function channelFor(credentials, signal) {
27731
+ const response = await youtubeRequest(credentials, "https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true&fields=items(id,snippet(title))", {}, signal);
27732
+ const body = await googleJson(response);
27733
+ const items = body.items;
27734
+ 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.");
27735
+ return { id: items[0].id, title: items[0].snippet.title };
27736
+ }
27737
+ async function connectedYouTube(signal) {
27738
+ const credentials = await readYouTubeCredentials();
27739
+ if (!credentials) throw new Error("Connect your channel with animaguru youtube auth login --client-file PATH --yes.");
27740
+ if (credentials.checkedAt < Date.now() - 864e5) await accessToken(credentials, signal, true);
27741
+ const channel = await channelFor(credentials, signal);
27742
+ if (channel.id !== credentials.channelId) throw new Error("The connected YouTube channel changed. Disconnect and reconnect before uploading.");
27743
+ credentials.channelTitle = channel.title;
27744
+ credentials.checkedAt = Date.now();
27745
+ await saveYouTubeCredentials(credentials);
27746
+ return credentials;
27747
+ }
27748
+ async function revoke(token, signal) {
27749
+ try {
27750
+ const response = await googleFetch("https://oauth2.googleapis.com/revoke", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token }) }, signal);
27751
+ await response.body?.cancel();
27752
+ return response.ok || response.status === 400;
27753
+ } catch {
27754
+ return false;
27755
+ }
27756
+ }
27757
+ async function youtubeLogin(options) {
27758
+ if (!options.yes) throw new Error("Pass --yes to accept YouTube's Terms of Service and authorize saving YouTube credentials locally. Review https://www.youtube.com/t/terms and https://policies.google.com/privacy first.");
27759
+ const seconds = options.timeout ?? 300;
27760
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > 600) throw new Error("Use an authorization timeout between 1 and 600 seconds.");
27761
+ const requested = options.captions ? [...YOUTUBE_SCOPES, YOUTUBE_CAPTION_SCOPE] : YOUTUBE_SCOPES;
27762
+ return withYouTubeLock(async () => {
27763
+ const existing = await readYouTubeCredentials();
27764
+ if (existing && options.captions && !canAddCaptions(existing)) throw new Error("A YouTube channel is already connected without caption permission. Run youtube auth logout --yes, then youtube auth login --captions.");
27765
+ if (existing) throw new Error("A YouTube channel is already connected. Run youtube auth logout --yes before switching channels.");
27766
+ const file2 = options["client-file"];
27767
+ const info = await stat2(file2);
27768
+ if (!info.isFile() || info.size > 65536) throw new Error("Use Google's downloaded Desktop app OAuth client JSON.");
27769
+ let installed;
27770
+ try {
27771
+ installed = JSON.parse(await readFile9(file2, "utf8")).installed;
27772
+ } catch {
27773
+ }
27774
+ 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.");
27775
+ const client = { clientId: installed.client_id, ...typeof installed.client_secret === "string" ? { clientSecret: installed.client_secret } : {} };
27776
+ const state = randomBytes(32).toString("base64url");
27777
+ const verifier = randomBytes(32).toString("base64url");
27778
+ let receive;
27779
+ let fail;
27780
+ let used = false;
27781
+ const callback = new Promise((resolve11, reject) => {
27782
+ receive = resolve11;
27783
+ fail = reject;
27784
+ });
27785
+ void callback.catch(() => {
27786
+ });
27787
+ let redirectUri = "";
27788
+ const server = createServer((request, response) => {
27789
+ response.setHeader("Cache-Control", "no-store");
27790
+ response.setHeader("Referrer-Policy", "no-referrer");
27791
+ response.setHeader("Content-Type", "text/plain; charset=UTF-8");
27792
+ response.setHeader("Content-Security-Policy", "default-src 'none'");
27793
+ let url2;
27794
+ try {
27795
+ url2 = new URL(request.url ?? "/", redirectUri);
27796
+ } catch {
27797
+ response.writeHead(400).end("Invalid request.");
27798
+ return;
27799
+ }
27800
+ if (request.method !== "GET" || request.headers.host !== new URL(redirectUri).host || url2.pathname !== "/oauth2callback" || (request.url?.length ?? 0) > 8192) {
27801
+ response.writeHead(404).end("Not found.");
27802
+ return;
27803
+ }
27804
+ const returnedState = Buffer.from(url2.searchParams.get("state") ?? "");
27805
+ const expectedState = Buffer.from(state);
27806
+ if (used || returnedState.length !== expectedState.length || !timingSafeEqual(returnedState, expectedState)) {
27807
+ response.writeHead(400).end("Invalid authorization state.");
27808
+ return;
27809
+ }
27810
+ used = true;
27811
+ const code = url2.searchParams.get("code");
27812
+ if (url2.searchParams.has("error") || !code || code.length > 4096) {
27813
+ response.writeHead(400).end("Authorization cancelled. Return to your terminal.");
27814
+ fail(new Error("YouTube authorization cancelled."));
27815
+ return;
27816
+ }
27817
+ response.end("Authorization received. Return to your AnimaGuru terminal.");
27818
+ receive(code);
27819
+ });
27820
+ await new Promise((resolve11, reject) => {
27821
+ server.once("error", reject);
27822
+ server.listen(0, "127.0.0.1", resolve11);
27823
+ });
27824
+ const address = server.address();
27825
+ if (!address || typeof address === "string") throw new Error("Cannot open the local YouTube authorization listener.");
27826
+ redirectUri = `http://127.0.0.1:${address.port}/oauth2callback`;
27827
+ const timer = setTimeout(() => fail(new Error("YouTube authorization timed out. Run login again.")), seconds * 1e3);
27828
+ const abort = () => fail(new Error("YouTube authorization cancelled."));
27829
+ options.signal?.addEventListener("abort", abort, { once: true });
27830
+ let token;
27831
+ try {
27832
+ options.signal?.throwIfAborted();
27833
+ const url2 = new URL("https://accounts.google.com/o/oauth2/v2/auth");
27834
+ url2.search = new URLSearchParams({ client_id: client.clientId, redirect_uri: redirectUri, response_type: "code", scope: requested.join(" "), state, code_challenge: createHash2("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", access_type: "offline", prompt: "consent select_account" }).toString();
27835
+ options.log?.("YouTube authorization stays on this machine. Tokens and upload receipts are saved in owner-only local files. Video bytes go directly to Google. Google privacy: https://policies.google.com/privacy \xB7 YouTube terms: https://www.youtube.com/t/terms");
27836
+ options.log?.(`Open ${url2.href}`);
27837
+ if (options.browser !== false) await options.openBrowser?.(url2.href);
27838
+ const body = await tokenRequest(client, { grant_type: "authorization_code", code: await callback, code_verifier: verifier, redirect_uri: redirectUri }, options.signal);
27839
+ token = typeof body.refresh_token === "string" ? body.refresh_token : typeof body.access_token === "string" ? body.access_token : void 0;
27840
+ const scopes = typeof body.scope === "string" ? body.scope.split(" ") : [];
27841
+ 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.");
27842
+ 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.");
27843
+ const now = Date.now();
27844
+ 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 };
27845
+ const channel = await channelFor(credentials, options.signal);
27846
+ credentials.channelId = channel.id;
27847
+ credentials.channelTitle = channel.title;
27848
+ await saveYouTubeCredentials(credentials);
27849
+ return { authenticated: true, channelId: channel.id, channelTitle: channel.title, captions: canAddCaptions(credentials) };
27850
+ } catch (error62) {
27851
+ if (token) await revoke(token);
27852
+ throw error62;
27853
+ } finally {
27854
+ clearTimeout(timer);
27855
+ options.signal?.removeEventListener("abort", abort);
27856
+ server.closeAllConnections();
27857
+ await new Promise((resolve11) => server.close(() => resolve11()));
27858
+ }
27859
+ });
27860
+ }
27861
+ async function youtubeStatus(options = {}) {
27862
+ return withYouTubeLock(async () => {
27863
+ if (!await readYouTubeCredentials()) return { authenticated: false };
27864
+ const credentials = await connectedYouTube(options.signal);
27865
+ return { authenticated: true, channelId: credentials.channelId, channelTitle: credentials.channelTitle, captions: canAddCaptions(credentials) };
27866
+ });
27867
+ }
27868
+ async function youtubeLogout(options) {
27869
+ if (!options.yes) throw new Error("Pass --yes to revoke YouTube authorization and remove local credentials and upload receipts.");
27870
+ return withYouTubeLock(async () => {
27871
+ let credentials = null;
27872
+ let readable = true;
27873
+ try {
27874
+ credentials = await readYouTubeCredentials();
27875
+ } catch {
27876
+ readable = false;
27877
+ }
27878
+ const revoked = readable && (credentials ? await revoke(credentials.refreshToken, options.signal) : true);
27879
+ await clearYouTubeData();
27880
+ 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.");
27881
+ return { authenticated: false, revoked };
27882
+ });
27883
+ }
27884
+
27885
+ // cli/src/youtube-upload.ts
27886
+ import { createHash as createHash3, randomUUID as randomUUID3 } from "node:crypto";
27887
+ import { open as open4, readFile as readFile10, stat as stat3 } from "node:fs/promises";
27888
+ import { basename as basename4, join as join9, resolve as resolve9 } from "node:path";
27889
+ import { setTimeout as delay2 } from "node:timers/promises";
27890
+ var YOUTUBE_CHUNK_BYTES = 8 * 1024 * 1024;
27891
+ function safeYouTubeSession(value) {
27892
+ let url2;
27893
+ try {
27894
+ url2 = new URL(value);
27895
+ } catch {
27896
+ throw new YouTubeError("Google returned an invalid upload session.");
27897
+ }
27898
+ 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.");
27899
+ return url2.href;
27900
+ }
27901
+ async function inspectMp4(file2, signal) {
27902
+ const handle = await open4(file2, "r");
27903
+ try {
27904
+ const info = await handle.stat();
27905
+ if (!info.isFile() || !file2.toLowerCase().endsWith(".mp4") || info.size < 12 || info.size > 256 * 1024 ** 3) throw new Error("Choose an exported .mp4 file up to 256 GiB. Export an HTML5 animation with npm run export:mp4 first.");
27906
+ const header = Buffer.alloc(12);
27907
+ await handle.read(header, 0, header.length, 0);
27908
+ if (header.toString("ascii", 4, 8) !== "ftyp") throw new Error("Use a valid MP4 file.");
27909
+ const hash2 = createHash3("sha256");
27910
+ for await (const buffer of handle.createReadStream({ start: 0, autoClose: false, signal })) hash2.update(buffer);
27911
+ const fingerprint = (value) => `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
27912
+ const check2 = async () => {
27913
+ signal?.throwIfAborted();
27914
+ if (fingerprint(await handle.stat()) !== fingerprint(info)) throw new Error("The MP4 changed during upload. Restore the original export before resuming.");
27915
+ };
27916
+ await check2();
27917
+ return { handle, bytes: info.size, sha256: hash2.digest("hex"), check: check2 };
27918
+ } catch (error62) {
27919
+ await handle.close();
27920
+ throw error62;
27921
+ }
27922
+ }
27923
+ async function exportReceipt(file2) {
27924
+ try {
27925
+ const receipt = JSON.parse(await readFile10(file2.replace(/\.mp4$/i, ".json"), "utf8"));
27926
+ if (receipt.file !== basename4(file2)) return {};
27927
+ return { title: typeof receipt.title === "string" ? receipt.title : void 0, language: typeof receipt.settings?.language === "string" ? receipt.settings.language : void 0 };
27928
+ } catch {
27929
+ return {};
27930
+ }
27931
+ }
27932
+ async function readCaptionFile(file2) {
27933
+ let info;
27934
+ try {
27935
+ info = await stat3(file2);
27936
+ } catch {
27937
+ throw new Error(`Cannot read the subtitle file ${file2}.`);
27938
+ }
27939
+ if (!info.isFile() || !/\.(?:srt|vtt)$/i.test(file2) || info.size < 1 || info.size > 5 * 1024 * 1024) throw new Error("Use a .srt or .vtt subtitle file up to 5 MiB, such as the one npm run export:mp4 writes beside the MP4.");
27940
+ const content = await readFile10(file2);
27941
+ const text = content.toString("utf8");
27942
+ if (text.includes("\uFFFD") || !/\d\d:\d\d[,.]\d{3} --> /.test(text)) throw new Error("Use a UTF-8 .srt or .vtt subtitle file with timed captions.");
27943
+ return { content, sha256: createHash3("sha256").update(content).digest("hex") };
27944
+ }
27945
+ async function planCaptions(root, mp4, options, fallbackLanguage) {
27946
+ const choice = options.captions ?? "auto";
27947
+ if (choice === "none") return null;
27948
+ let file2 = choice === "auto" ? void 0 : resolve9(root, choice);
27949
+ if (!file2) {
27950
+ for (const extension of [".srt", ".vtt"]) {
27951
+ const candidate = mp4.replace(/\.mp4$/i, extension);
27952
+ if (await stat3(candidate).then((info) => info.isFile(), () => false)) {
27953
+ file2 = candidate;
27954
+ break;
27955
+ }
27956
+ }
27957
+ if (!file2) return null;
27958
+ }
27959
+ const { content, sha256 } = await readCaptionFile(file2);
27960
+ const header = /^\uFEFF?WEBVTT[^\n]*\n(?:[^\n]+\n)*?Language:[ \t]*([^\s]+)/.exec(content.toString("utf8"))?.[1];
27961
+ const language = options["caption-language"] ?? header ?? fallbackLanguage;
27962
+ const name = options["caption-name"] ?? "";
27963
+ const plan = youtubeCaptions.safeParse({ file: file2, bytes: content.length, sha256, language, name, status: "pending", captionId: null });
27964
+ 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.");
27965
+ return { file: file2, bytes: content.length, sha256, language: plan.data.language, name, explicit: choice !== "auto" };
27966
+ }
27967
+ async function readThumbnail(file2) {
27968
+ let info;
27969
+ try {
27970
+ info = await stat3(file2);
27971
+ } catch {
27972
+ throw new Error(`Cannot read the thumbnail ${file2}.`);
27973
+ }
27974
+ if (!info.isFile() || info.size < 8 || info.size > 2 * 1024 * 1024) throw new Error("Use a JPEG or PNG thumbnail up to 2 MiB, such as the one npm run export:mp4 saves beside the MP4.");
27975
+ const content = await readFile10(file2);
27976
+ const type = content[0] === 255 && content[1] === 216 && content[2] === 255 ? "image/jpeg" : content.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ? "image/png" : null;
27977
+ if (!type) throw new Error("Use a JPEG or PNG thumbnail.");
27978
+ return { content, type, sha256: createHash3("sha256").update(content).digest("hex") };
27979
+ }
27980
+ async function planThumbnail(root, mp4, choice = "auto") {
27981
+ if (choice === "none") return null;
27982
+ let file2 = choice === "auto" ? void 0 : resolve9(root, choice);
27983
+ if (!file2) {
27984
+ for (const extension of [".jpg", ".jpeg", ".png"]) {
27985
+ const candidate = mp4.replace(/\.mp4$/i, extension);
27986
+ if (await stat3(candidate).then((info) => info.isFile(), () => false)) {
27987
+ file2 = candidate;
27988
+ break;
27989
+ }
27990
+ }
27991
+ if (!file2) return null;
27992
+ }
27993
+ const { content, type, sha256 } = await readThumbnail(file2);
27994
+ return { file: file2, bytes: content.length, sha256, type, status: "pending" };
27995
+ }
27996
+ async function addThumbnail(credentials, job, options) {
27997
+ const thumbnail = job.thumbnail;
27998
+ if (!thumbnail || thumbnail.status === "uploaded" || !job.youtubeId) return null;
27999
+ try {
28000
+ const { content, sha256 } = await readThumbnail(thumbnail.file);
28001
+ if (sha256 !== thumbnail.sha256) throw new Error("The thumbnail changed after this upload started; restore it first.");
28002
+ for (let attempts = 0; ; attempts++) {
28003
+ try {
28004
+ 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);
28005
+ const body = await googleJson(response);
28006
+ if (!Array.isArray(body.items) || !body.items.length) throw new YouTubeError("Google returned an invalid thumbnail confirmation. Check the video in YouTube Studio.");
28007
+ break;
28008
+ } catch (error62) {
28009
+ if (!(error62 instanceof YouTubeError)) throw error62;
28010
+ 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.");
28011
+ if (options.signal?.aborted || !error62.retryable || attempts >= 2) throw error62;
28012
+ await delay2(1e3 * 2 ** attempts, void 0, { signal: options.signal });
28013
+ }
28014
+ }
28015
+ thumbnail.status = "uploaded";
28016
+ job.updatedAt = Date.now();
28017
+ await saveYouTubeJob(job);
28018
+ options.log?.(`Set the thumbnail from ${thumbnail.file}.`);
28019
+ return null;
28020
+ } catch (error62) {
28021
+ if (options.signal?.aborted) throw error62;
28022
+ const message = error62 instanceof Error ? error62.message : String(error62);
28023
+ 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.`);
28024
+ return message;
28025
+ }
28026
+ }
28027
+ async function finishUpload(credentials, job, options) {
28028
+ const summary = await addCaptions(credentials, job, options);
28029
+ const thumbnailError = await addThumbnail(credentials, job, options);
28030
+ return { ...summary, ...youtubeJobSummary(job), ...thumbnailError ? { thumbnailError } : {} };
28031
+ }
28032
+ function languageName(code) {
28033
+ try {
28034
+ return new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code;
28035
+ } catch {
28036
+ return code;
28037
+ }
28038
+ }
28039
+ async function insertCaption(credentials, videoId, language, name, content, signal) {
28040
+ const boundary = `animaguru-${randomUUID3()}`;
28041
+ const metadata = JSON.stringify({ snippet: { videoId, language, name, isDraft: false } });
28042
+ const body = Buffer.concat([Buffer.from(`--${boundary}\r
28043
+ Content-Type: application/json; charset=UTF-8\r
28044
+ \r
28045
+ ${metadata}\r
28046
+ --${boundary}\r
28047
+ Content-Type: application/octet-stream\r
28048
+ \r
28049
+ `), content, Buffer.from(`\r
28050
+ --${boundary}--\r
28051
+ `)]);
28052
+ 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);
28053
+ const caption = await googleJson(response);
28054
+ const snippet = caption.snippet;
28055
+ 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.");
28056
+ return caption.id;
28057
+ }
28058
+ async function addCaptions(credentials, job, options) {
28059
+ const captions = job.captions;
28060
+ if (!captions || captions.status === "uploaded" || !job.youtubeId) return youtubeJobSummary(job);
28061
+ try {
28062
+ if (!canAddCaptions(credentials)) throw new Error("The channel was connected without caption permission; reconnect with youtube auth login --captions.");
28063
+ const { content, sha256 } = await readCaptionFile(captions.file);
28064
+ if (sha256 !== captions.sha256) throw new Error("The subtitle file changed after this upload started; restore it first.");
28065
+ const names = captions.name ? [captions.name] : ["", languageName(captions.language)];
28066
+ let index = 0;
28067
+ let attempts = 0;
28068
+ let captionId = null;
28069
+ while (true) {
28070
+ try {
28071
+ captionId = await insertCaption(credentials, job.youtubeId, captions.language, names[index], content, options.signal);
28072
+ break;
28073
+ } catch (error62) {
28074
+ if (!(error62 instanceof YouTubeError)) throw error62;
28075
+ if (error62.reason === "invalidMetadata" && index + 1 < names.length) {
28076
+ index++;
28077
+ continue;
28078
+ }
28079
+ if (error62.reason === "captionExists") {
28080
+ options.log?.(`YouTube already has a ${captions.language} caption track${names[index] ? ` named ${names[index]}` : ""} on this video.`);
28081
+ break;
28082
+ }
28083
+ if (options.signal?.aborted || !error62.retryable || attempts >= 2) throw error62;
28084
+ await delay2(1e3 * 2 ** attempts++, void 0, { signal: options.signal });
28085
+ }
28086
+ }
28087
+ captions.name = names[index];
28088
+ captions.status = "uploaded";
28089
+ captions.captionId = captionId;
28090
+ job.updatedAt = Date.now();
28091
+ await saveYouTubeJob(job);
28092
+ options.log?.(`Added ${captions.language} captions from ${captions.file}.`);
28093
+ return youtubeJobSummary(job);
28094
+ } catch (error62) {
28095
+ if (options.signal?.aborted) throw error62;
28096
+ const message = error62 instanceof Error ? error62.message : String(error62);
28097
+ 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.`);
28098
+ return { ...youtubeJobSummary(job), captionsError: message };
28099
+ }
28100
+ }
28101
+ function receivedBytes(response, total) {
28102
+ const range = response.headers.get("Range");
28103
+ if (!range) return 0;
28104
+ const match = /^bytes=0-(\d+)$/.exec(range);
28105
+ const bytes = match ? Number(match[1]) + 1 : NaN;
28106
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > total) throw new YouTubeError("Google returned invalid upload progress.");
28107
+ return bytes;
28108
+ }
28109
+ async function completed(job, response) {
28110
+ const body = await googleJson(response);
28111
+ const snippet = body.snippet;
28112
+ const status = body.status;
28113
+ 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.");
28114
+ job.youtubeId = body.id;
28115
+ job.privacy = status.privacyStatus;
28116
+ job.bytesUploaded = job.bytes;
28117
+ job.status = "completed";
28118
+ job.sessionUrl = null;
28119
+ job.updatedAt = Date.now();
28120
+ await saveYouTubeJob(job);
28121
+ return youtubeJobSummary(job);
28122
+ }
28123
+ async function transfer(credentials, job, source, options) {
28124
+ const url2 = safeYouTubeSession(job.sessionUrl);
28125
+ let attempts = 0;
28126
+ let stalled = 0;
28127
+ try {
28128
+ while (true) {
28129
+ await source.check();
28130
+ try {
28131
+ const probe = await youtubeRequest(credentials, url2, { method: "PUT", headers: { "Content-Range": `bytes */${job.bytes}`, "Content-Length": "0" }, body: Buffer.alloc(0) }, options.signal);
28132
+ if (probe.ok) return await completed(job, probe);
28133
+ const offset = receivedBytes(probe, job.bytes);
28134
+ await probe.body?.cancel();
28135
+ if (offset >= job.bytes) throw new YouTubeError("YouTube has all bytes but has not confirmed the video. Resume this upload later.", true);
28136
+ const bytes = Buffer.alloc(Math.min(YOUTUBE_CHUNK_BYTES, job.bytes - offset));
28137
+ const read2 = await source.handle.read(bytes, 0, bytes.length, offset);
28138
+ if (read2.bytesRead !== bytes.length) throw new Error("The MP4 changed during upload. Restore it before resuming.");
28139
+ await source.check();
28140
+ 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);
28141
+ if (response.ok) return await completed(job, response);
28142
+ const uploaded = receivedBytes(response, job.bytes);
28143
+ await response.body?.cancel();
28144
+ if (uploaded <= offset || uploaded > offset + bytes.length) throw new YouTubeError("Google returned invalid upload progress.", true);
28145
+ stalled = uploaded <= job.bytesUploaded ? stalled + 1 : 0;
28146
+ if (stalled > 3) throw new YouTubeError("YouTube upload progress stalled. Resume this upload later.");
28147
+ job.bytesUploaded = uploaded;
28148
+ job.status = "uploading";
28149
+ job.updatedAt = Date.now();
28150
+ await saveYouTubeJob(job);
28151
+ options.log?.(`YouTube upload ${job.id}: ${Math.floor(uploaded / job.bytes * 100)}% (${uploaded}/${job.bytes} bytes)`);
28152
+ attempts = 0;
28153
+ } catch (error62) {
28154
+ if (options.signal?.aborted || !(error62 instanceof YouTubeError) || !error62.retryable || attempts >= 3) throw error62;
28155
+ options.log?.("YouTube transfer interrupted. Checking the saved upload before retrying\u2026");
28156
+ await delay2(1e3 * 2 ** attempts++, void 0, { signal: options.signal });
28157
+ }
28158
+ }
28159
+ } catch (error62) {
28160
+ job.status = error62 instanceof YouTubeError && error62.expired ? "expired" : "interrupted";
28161
+ job.updatedAt = Date.now();
28162
+ if (await readYouTubeCredentials()) await saveYouTubeJob(job);
28163
+ 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.`);
28164
+ throw error62;
28165
+ }
28166
+ }
28167
+ async function uploadYouTube(options) {
28168
+ if (!options.yes && !options["dry-run"]) throw new Error("Pass --yes to authorize the YouTube upload, or --dry-run to inspect it locally.");
28169
+ if (!["yes", "no"].includes(options["made-for-kids"]) || !["yes", "no"].includes(options["synthetic-media"])) throw new Error("Set --made-for-kids yes|no and --synthetic-media yes|no explicitly.");
28170
+ const root = resolve9(options.directory ?? ".");
28171
+ const file2 = resolve9(root, options.file);
28172
+ const context = await localProject(root, options.animation);
28173
+ let config2 = {};
28174
+ try {
28175
+ config2 = JSON.parse(await readFile10(join9(context.projectRoot, "film.config.json"), "utf8"));
28176
+ } catch {
28177
+ }
28178
+ const receipt = await exportReceipt(file2);
28179
+ const parsed = youtubeMetadata.safeParse({ title: options.title ?? receipt.title ?? config2.title ?? basename4(file2).replace(/\.mp4$/i, ""), description: options.description ?? "", privacy: options.privacy ?? "private", madeForKids: options["made-for-kids"] === "yes", syntheticMedia: options["synthetic-media"] === "yes" });
28180
+ if (!parsed.success) throw new Error("Use a title of 1\u2013100 characters and a description up to 5,000 UTF-8 bytes, without < or >, and private, unlisted or public privacy.");
28181
+ const metadata = parsed.data;
28182
+ let captions = await planCaptions(root, file2, options, receipt.language ?? config2.language);
28183
+ const thumbnail = await planThumbnail(root, file2, options.thumbnail);
28184
+ const source = await inspectMp4(file2, options.signal);
28185
+ try {
28186
+ 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 } };
28187
+ return await withYouTubeLock(async () => {
28188
+ const credentials = await connectedYouTube(options.signal);
28189
+ if (options.channel && options.channel !== credentials.channelId) throw new Error("The requested channel does not match the connected YouTube channel.");
28190
+ if (captions && !canAddCaptions(credentials)) {
28191
+ 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.");
28192
+ options.log?.(`Skipping ${captions.file}: the channel was connected without caption permission. Reconnect with youtube auth login --captions to add subtitles.`);
28193
+ captions = null;
28194
+ }
28195
+ const pending = captions && { file: captions.file, bytes: captions.bytes, sha256: captions.sha256, language: captions.language, name: captions.name, status: "pending", captionId: null };
28196
+ const jobs = (await localYouTubeJobs()).filter((job2) => job2.channelId === credentials.channelId && job2.sha256 === source.sha256);
28197
+ 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.");
28198
+ const previous = jobs.find((job2) => job2.status === "completed" && JSON.stringify(job2.metadata) === JSON.stringify(metadata));
28199
+ if (previous) {
28200
+ if (pending && previous.captions?.status !== "uploaded") previous.captions = pending;
28201
+ else if (pending && previous.captions?.sha256 !== pending.sha256) options.log?.(`This video already has captions from ${previous.captions.file}. Replace them in YouTube Studio.`);
28202
+ if (thumbnail && previous.thumbnail?.status !== "uploaded") previous.thumbnail = thumbnail;
28203
+ 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.`);
28204
+ await saveYouTubeJob(previous);
28205
+ return { ...await finishUpload(credentials, previous, options), alreadyUploaded: true };
28206
+ }
28207
+ await source.check();
28208
+ 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`);
28209
+ 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);
28210
+ const location = response.headers.get("Location");
28211
+ await response.body?.cancel();
28212
+ if (!location) throw new YouTubeError("Google did not return an upload session. No video bytes were sent.");
28213
+ const now = Date.now();
28214
+ 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 } : {} };
28215
+ await saveYouTubeJob(job);
28216
+ options.log?.(`Upload ID: ${job.id}`);
28217
+ await transfer(credentials, job, source, options);
28218
+ return finishUpload(credentials, job, options);
28219
+ });
28220
+ } finally {
28221
+ await source.handle.close();
28222
+ }
28223
+ }
28224
+ async function resumeYouTube(id2, options) {
28225
+ if (!options.yes) throw new Error("Pass --yes to resume the previously authorized YouTube upload with its saved destination and settings.");
28226
+ return withYouTubeLock(async () => {
28227
+ const job = await readYouTubeJob(id2);
28228
+ if (job.status === "completed") {
28229
+ if (job.captions?.status !== "pending" && job.thumbnail?.status !== "pending") return youtubeJobSummary(job);
28230
+ const credentials = await connectedYouTube(options.signal);
28231
+ if (credentials.channelId !== job.channelId) throw new Error("Connect the original YouTube channel before adding this video's captions or thumbnail.");
28232
+ return finishUpload(credentials, job, options);
28233
+ }
28234
+ if (job.status === "expired" || !job.sessionUrl) throw new Error("This YouTube session expired. Inspect your channel before starting a new upload.");
28235
+ safeYouTubeSession(job.sessionUrl);
28236
+ const source = await inspectMp4(job.file, options.signal);
28237
+ try {
28238
+ 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.");
28239
+ const credentials = await connectedYouTube(options.signal);
28240
+ if (credentials.clientId !== job.clientId || credentials.channelId !== job.channelId) throw new Error("Connect the original Google client and YouTube channel before resuming.");
28241
+ options.log?.(`Resuming ${job.id} to ${credentials.channelTitle} (${job.channelId}) with ${job.metadata.privacy} visibility.`);
28242
+ await transfer(credentials, job, source, options);
28243
+ return await finishUpload(credentials, job, options);
28244
+ } finally {
28245
+ await source.handle.close();
28246
+ }
28247
+ });
28248
+ }
28249
+ var listYouTubeUploads = () => withYouTubeLock(async () => ({ uploads: (await localYouTubeJobs()).map(youtubeJobSummary) }));
28250
+
27456
28251
  // cli/src/commands.ts
27457
28252
  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
28253
  var assetAuthorizationFlags = [
@@ -27478,17 +28273,38 @@ var workspaceQuery = async (client) => {
27478
28273
  async function openBrowser(url2) {
27479
28274
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
27480
28275
  const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url2] : [url2];
27481
- await new Promise((resolve10) => {
28276
+ await new Promise((resolve11) => {
27482
28277
  const child = spawn3(command, args, { stdio: "ignore", detached: true });
27483
- child.once("error", () => resolve10());
28278
+ child.once("error", () => resolve11());
27484
28279
  child.once("spawn", () => {
27485
28280
  child.unref();
27486
- resolve10();
28281
+ resolve11();
27487
28282
  });
27488
28283
  });
27489
28284
  }
27490
28285
  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 }) => {
28286
+ 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(
28287
+ { name: "client-file", type: "string", required: true, env: { name: "ANIMAGURU_YOUTUBE_CLIENT_FILE" }, description: "Google Desktop app OAuth client JSON" },
28288
+ { name: "browser", type: "boolean", default: true, description: "Open the system browser; --no-browser prints the URL" },
28289
+ { name: "timeout", type: "number", default: 300, description: "Authorization timeout in seconds (1\u2013600)" },
28290
+ { name: "captions", type: "boolean", description: "Also allow adding subtitle tracks; Google's youtube.force-ssl permission also lets the CLI manage your videos" },
28291
+ { name: "yes", type: "boolean", description: "Accept YouTube terms and authorize local credential storage" }
28292
+ ).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(
28293
+ { name: "file", type: "string", required: true, description: "Local exported MP4" },
28294
+ { name: "animation", type: "string", description: "Animation in a multi-animation workspace" },
28295
+ { name: "title", type: "string" },
28296
+ { name: "description", type: "string" },
28297
+ { name: "privacy", type: "string", choices: ["private", "unlisted", "public"], default: "private" },
28298
+ { name: "made-for-kids", type: "string", required: true, choices: ["yes", "no"], description: "Your YouTube audience declaration" },
28299
+ { name: "synthetic-media", type: "string", required: true, choices: ["yes", "no"], description: "Contains realistic altered or synthetic media; follow YouTube disclosure guidance" },
28300
+ { name: "channel", type: "string", description: "Require this exact channel ID instead of relying on the saved connection" },
28301
+ { 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" },
28302
+ { 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" },
28303
+ { name: "caption-name", type: "string", description: "Track name in YouTube's subtitle menu; empty by default, like YouTube Studio uploads" },
28304
+ { 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" },
28305
+ { name: "yes", type: "boolean", description: "Authorize sending this video, its subtitles and thumbnail to the selected channel and privacy; confirm upload rights" },
28306
+ { name: "dry-run", type: "boolean", description: "Inspect MP4 and metadata locally without Google authorization or network calls" }
28307
+ ).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
28308
  if (!args.name) throw new Error("Use animaguru create workspace NAME, or animaguru create animation NAME inside a workspace.");
27493
28309
  out(stdout, await createVideoProject({ directory: args.name, log: stderr, signal }));
27494
28310
  }).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(
@@ -27519,7 +28335,7 @@ Confirm code: ${device.user_code}`);
27519
28335
  const expiresAt = Date.now() + device.expires_in * 1e3;
27520
28336
  let interval = device.interval * 1e3;
27521
28337
  while (Date.now() < expiresAt) {
27522
- await delay2(interval, void 0, { signal });
28338
+ await delay3(interval, void 0, { signal });
27523
28339
  try {
27524
28340
  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
28341
  const credentials = await readCredentials();
@@ -27596,7 +28412,7 @@ Confirm code: ${device.user_code}`);
27596
28412
  ...assetAuthorizationFlags
27597
28413
  ).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
28414
  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) });
28415
+ const body = spriteJobBody.parse({ ...JSON.parse(await readFile11(flags.file, "utf8")), ...selectedModels(flags) });
27600
28416
  out(stdout, await new Client(flags.url, signal).request(`${projectPath(args.id)}/jobs`, { method: "POST", body }));
27601
28417
  }))).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
28418
  if (!Number.isFinite(flags.timeout) || flags.timeout <= 0) throw new Error("Timeout must be positive.");
@@ -27609,7 +28425,7 @@ Confirm code: ${device.user_code}`);
27609
28425
  out(stdout, { job });
27610
28426
  return;
27611
28427
  }
27612
- await delay2(3e3, void 0, { signal });
28428
+ await delay3(3e3, void 0, { signal });
27613
28429
  }
27614
28430
  throw new Error("Timed out waiting for the job. It may still be running.");
27615
28431
  })))).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 +28441,7 @@ Confirm code: ${device.user_code}`);
27625
28441
  const extension = flags.file.split(".").at(-1)?.toLowerCase();
27626
28442
  const type = extension === "png" ? "image/png" : extension === "jpg" || extension === "jpeg" ? "image/jpeg" : extension === "webp" ? "image/webp" : void 0;
27627
28443
  if (!type) throw new Error("Use a PNG, JPEG or WebP image.");
27628
- const bytes = await readFile8(flags.file);
28444
+ const bytes = await readFile11(flags.file);
27629
28445
  if (bytes.length > 10 * 1024 * 1024) throw new Error("Image must be at most 10 MB.");
27630
28446
  const form = new FormData();
27631
28447
  form.set("image", new Blob([bytes], { type }), `reference.${extension}`);
@@ -27643,8 +28459,8 @@ Confirm code: ${device.user_code}`);
27643
28459
  const response = await client.request(`/api/v1/blobs/${encodeURIComponent(manifest.ownerId)}/${file2.sha}`, { raw: true });
27644
28460
  entries[path2] = new Uint8Array(await response.arrayBuffer());
27645
28461
  }
27646
- const path = resolve9(flags.output);
27647
- await writeFile8(path, zipSync(entries, { level: 0 }), { flag: "wx" });
28462
+ const path = resolve10(flags.output);
28463
+ await writeFile9(path, zipSync(entries, { level: 0 }), { flag: "wx" });
27648
28464
  out(stdout, { path, files: manifest.files.length });
27649
28465
  })))).extend(version(package_default.version, { format: "plain" })).extend(help()).extend(completion());
27650
28466
  }