@docyrus/animaguru 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,7 @@ animaguru templates
66
66
  animaguru create my-video
67
67
  ```
68
68
 
69
- `create` takes only the project folder name. Use a new folder whose parent already exists; the command refuses existing destinations, including empty folders and symlinks. It installs PixiJS, Vite, TypeScript and FFmpeg/FFprobe tools. npm progress goes to stderr and successful results are JSON. If installation fails, the generated files remain; enter the folder and run `npm install` to resume.
69
+ `create` takes only the project folder name. Use a new folder whose parent already exists; the command refuses existing destinations, including empty folders and symlinks. It installs PixiJS, Vite and TypeScript. The audio tools and MP4 export use FFmpeg and FFprobe 7.1 or newer from your PATH (or `FFMPEG_PATH` and `FFPROBE_PATH`); `create` checks them and, when they are missing or older, says how to install a current build (macOS: `brew install ffmpeg`) in its notes and `ffmpeg` result. CLI 0.29.1 made that change: earlier workspaces installed their own FFmpeg 4.x builds from npm. npm progress goes to stderr and successful results are JSON. If installation fails, the generated files remain; enter the folder and run `npm install` to resume.
70
70
 
71
71
  Open the generated folder in **Codex or Claude Code**, then describe the video you want. It includes:
72
72
 
@@ -581,6 +581,13 @@ animaguru videos subtitles VIDEO_ID --remove de --yes
581
581
 
582
582
  Each upload of up to 1 MiB is stored as WebVTT, up to 20 languages per video. HTML5 films draw their own captions and take no subtitle files. New subtitles on a public video send it back for staff review, like a new poster; removing a language does not.
583
583
 
584
+ CLI 0.29.0 lets admins tag a film for the showcase as they upload it: the public gallery films the lead invitation page shows under "Films made with AnimaGuru", grouped by sector with the lead's own first. `--showcase SECTOR` adds it (`cafe`, `restaurant`, `food`, `retail`, `beauty`, `fitness`, `workspace`, `hospitality`, `realestate`, `education`, `health`, `events`, `technology` or `services`). `--showcase-tags` says what it is for (comma-separated: `social`, `website`, `product`, `space`, `offer`, `brand`, `explainer`, `event`). `--showcase-language en|tr|sv|none` gives the language it speaks or shows, and `--showcase-position N` its order (lower first). The film shows there once it is public and approved; admins can change the tags later on Staff → Video reviews. Other accounts get a 403.
585
+
586
+ ```sh
587
+ animaguru submit --file production/exports/film.mp4 --poster production/exports/poster.jpg \
588
+ --showcase cafe --showcase-tags social,offer --showcase-language tr --showcase-position 10 --yes
589
+ ```
590
+
584
591
  To request public gallery publication, upload privately, open the video in My videos on the website, choose Share → Public, and accept the public video sharing agreement. Staff must approve the submission before anyone else can access its public link, download or embed. Direct CLI public-sharing requests without agreement acceptance are refused by the API.
585
592
 
586
593
  `videos list --scope mine|shared|public` lists films. Owners can edit metadata, change visibility and replace recipients on the website, or use `videos share VIDEO_ID --visibility private --share "alex@example.com" --yes`. `videos delete VIDEO_ID --yes` removes the film and files. Making a film private or removing a recipient revokes access to existing playback links.
package/dist/animaguru.js CHANGED
@@ -19761,7 +19761,7 @@ var require_ignore2 = __commonJS({
19761
19761
  // cli/package.json
19762
19762
  var package_default = {
19763
19763
  name: "@docyrus/animaguru",
19764
- version: "0.28.0",
19764
+ version: "0.29.1",
19765
19765
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
19766
19766
  type: "module",
19767
19767
  bin: {
@@ -25288,208 +25288,6 @@ function unzipSync(data, opts) {
25288
25288
  // shared/auth.ts
25289
25289
  var CLI_CLIENT_ID = "animaguru-cli";
25290
25290
 
25291
- // shared/sprites/styles.ts
25292
- var GAME_SPRITE_STYLES = ["hd-cartoon", "pixel", "painted", "vector"];
25293
- var VIDEO_SPRITE_STYLES = [
25294
- "photoreal-cutout",
25295
- "photo-collage",
25296
- "paper-cutout",
25297
- "photoreal-stop-motion",
25298
- "cutout-motion-graphics"
25299
- ];
25300
- var SPRITE_STYLES = [...GAME_SPRITE_STYLES, ...VIDEO_SPRITE_STYLES];
25301
-
25302
- // shared/credits.ts
25303
- var UNITS_PER_CREDIT = 10;
25304
- var toCredits = (units) => Math.round(units) / UNITS_PER_CREDIT;
25305
- var toUnits = (credits) => Math.round(credits * UNITS_PER_CREDIT);
25306
-
25307
- // shared/sprites/types.ts
25308
- var SPRITE_KINDS = ["character", "vehicle", "machine", "effect"];
25309
- var SPRITE_ACTIONS = ["idle", "walk", "run", "skid-stop", "shoot", "jump", "hurt", "death", "slide", "punch", "kick"];
25310
- var SPRITE_KIND_ACTIONS = {
25311
- character: SPRITE_ACTIONS,
25312
- vehicle: ["idle", "move", "fire", "hurt", "destroyed"],
25313
- machine: ["idle", "work", "activate", "fire", "hurt", "break"],
25314
- effect: ["projectile", "charge", "impact", "explosion", "aura"]
25315
- };
25316
- var ALL_SPRITE_ACTIONS = [...new Set(Object.values(SPRITE_KIND_ACTIONS).flat())];
25317
- var SPRITE_TOP_ACTIONS = {
25318
- vehicle: ["hull-move", "hull-destroyed", "turret-fire"],
25319
- machine: ["hull-break", "turret-fire"]
25320
- };
25321
- var ALL_TOP_ACTIONS = [...new Set(Object.values(SPRITE_TOP_ACTIONS).flat())];
25322
- var CUSTOM_FRAMES = { min: 2, max: 12 };
25323
-
25324
- // shared/sprites/models.ts
25325
- var DEFAULT_SPRITE_IMAGE_MODEL = "fal/gpt-image-2.5-medium";
25326
- var SPRITE_IMAGE_MODELS = [
25327
- { id: DEFAULT_SPRITE_IMAGE_MODEL, label: "GPT Image 2.5 \xB7 medium (fal)", provider: "fal", edit: true, nativeAlpha: true, maxImages: 16, relCost: 0.3, quality: "medium", endpoints: { generate: "openai/gpt-image-2.5/flare/text-to-image", edit: "openai/gpt-image-2.5/flare/edit" } },
25328
- { id: "fal/gpt-image-2-low", label: "GPT Image 2 \xB7 low (fal)", provider: "fal", edit: true, spriteBackupCutout: true, maxImages: 16, relCost: 0.2, quality: "low", endpoints: { generate: "openai/gpt-image-2", edit: "openai/gpt-image-2/edit" } },
25329
- { id: "fal/muse-image", label: "Muse Image (fal)", provider: "fal", edit: true, maxImages: 10, relCost: 0.2, endpoints: { generate: "meta/muse-image/text-to-image", edit: "meta/muse-image/edit" } },
25330
- { id: "fal/krea-2-turbo", label: "Krea 2 Turbo (fal)", provider: "fal", edit: false, maxImages: 0, relCost: 0.16, endpoints: { generate: "fal-ai/krea-2/turbo" } },
25331
- { id: "gpt-image-2.5", label: "GPT Image 2.5 (OpenAI)", provider: "openai", edit: true, nativeAlpha: true, maxImages: 16, relCost: 1 },
25332
- { id: "black-forest-labs/flux-2-max", label: "FLUX.2 [max]", provider: "workers-ai", edit: true, maxImages: 8, relCost: 3 },
25333
- { id: "bytedance/seedream-5-pro", label: "Seedream 5 Pro", provider: "workers-ai", edit: true, maxImages: 10, relCost: 1.5 },
25334
- { id: "xai/grok-imagine-image-2.0", label: "Grok Imagine Image 2.0", provider: "workers-ai", edit: true, maxImages: 5, relCost: 1 },
25335
- { id: "alibaba/qwen-image-3.0-pro", label: "Qwen Image 3.0 Pro", provider: "workers-ai", edit: false, maxImages: 0, relCost: 0.5 },
25336
- { id: "krea/krea-2-medium-turbo", label: "Krea 2 Medium Turbo", provider: "workers-ai", edit: false, maxImages: 0, relCost: 0.25 }
25337
- ];
25338
- var SPRITE_IMAGE_MODEL_IDS = SPRITE_IMAGE_MODELS.map((m) => m.id);
25339
- function spriteImageModel(id2) {
25340
- return SPRITE_IMAGE_MODELS.find((m) => m.id === id2) ?? SPRITE_IMAGE_MODELS[0];
25341
- }
25342
- var VIDEO_WORKFLOWS = ["image-to-video", "text-to-video", "extend-video", "reference-to-video", "lip-sync", "camera-controls", "insert-video", "recast", "relight", "3d-to-video", "style", "keyframes-to-video"];
25343
- var STUDIO_VIDEO_WORKFLOWS = ["image-to-video", "text-to-video", "extend-video"];
25344
- var MEDIA_LENGTH_WORKFLOWS = ["lip-sync", "recast", "relight", "3d-to-video"];
25345
- var lengthFromMedia = (workflow) => MEDIA_LENGTH_WORKFLOWS.includes(workflow);
25346
- var VIDEO_STYLES = ["vhs", "retro-toon-70s", "low-poly", "hand-drawn", "16bit-pixel"];
25347
- var VHS_DAMAGE_LEVELS = ["light", "medium", "heavy"];
25348
- var VIDEO_RESOLUTIONS = ["480p", "720p", "768p", "1080p", "2k"];
25349
- var H3_RESOLUTIONS = ["480p", "768p", "1080p", "2k"];
25350
- var VIDEO_ASPECT_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
25351
- var PROMPT_EXPANSION_MODES = ["disabled", "balanced", "quality"];
25352
- var EXTEND_OUTPUTS = ["extended", "continuation"];
25353
- var SOUNDTRACK_MAX_BYTES = 15 * 1024 * 1024;
25354
- var EXTEND_SOURCE_MAX_BYTES = 50 * 1024 * 1024;
25355
- var MAX_AUDIO_REFERENCES = 3;
25356
- var MAX_VIDEO_REFERENCE_IMAGES = 9;
25357
- var MAX_REFERENCE_VIDEOS = 3;
25358
- var MAX_REFERENCE_FILES = 12;
25359
- var MAX_RECAST_PEOPLE = 4;
25360
- var MAX_3D_REFERENCES = 8;
25361
- var MAX_CAMERA_KEYFRAMES = 12;
25362
- var MAX_CAMERA_TURNS = 32;
25363
- var MAX_IMAGE_KEYFRAMES = 10;
25364
- var KEYFRAME_FPS = 24;
25365
- var H3_MAX = "minimax/h3-max";
25366
- var FLUX_3_MAX = "blackforestlabs/flux-3-max";
25367
- var h3Endpoints = (route) => ({ "image-to-video": `${route}/image-to-video`, "text-to-video": `${route}/text-to-video`, "extend-video": `${route}/extend-video` });
25368
- var SPRITE_VIDEO_MODELS = [
25369
- {
25370
- id: "pruna/p-video-2-pro",
25371
- label: "Pruna P-Video 2 Pro",
25372
- provider: "pruna",
25373
- workflows: ["image-to-video"],
25374
- resolutions: ["480p", "768p"],
25375
- modes: ["cost", "speed", "quality"],
25376
- lastFrame: true,
25377
- estimatedCreditsPerSecond: { "480p": { cost: 1, speed: 2, quality: 4 }, "768p": { cost: 2.5, speed: 3.5, quality: 7.5 } },
25378
- seconds: { min: 5, max: 15 }
25379
- },
25380
- {
25381
- id: "heygen/heygen-video-1",
25382
- label: "HeyGen Video 1",
25383
- provider: "heygen",
25384
- workflows: ["image-to-video"],
25385
- resolutions: ["480p", "768p"],
25386
- modes: ["cost"],
25387
- lastFrame: false,
25388
- estimatedCreditsPerSecond: { "480p": { cost: 1 }, "768p": { cost: 1.5 } },
25389
- seconds: { min: 5, max: 15 }
25390
- },
25391
- {
25392
- id: "fal/minimax-h3-max",
25393
- label: "MiniMax H3 Max (fal.ai)",
25394
- provider: "fal",
25395
- // Every workflow but FLUX 3 Max's keyframes.
25396
- workflows: VIDEO_WORKFLOWS.filter((w) => w !== "keyframes-to-video"),
25397
- endpoints: {
25398
- ...h3Endpoints(H3_MAX),
25399
- "reference-to-video": `${H3_MAX}/reference-to-video`,
25400
- "lip-sync": `${H3_MAX}/lip-sync/image-to-video`,
25401
- "camera-controls": `${H3_MAX}/camera-controls`,
25402
- "insert-video": `${H3_MAX}/insert-video`,
25403
- recast: `${H3_MAX}/recast`,
25404
- relight: `${H3_MAX}/relight`,
25405
- "3d-to-video": `${H3_MAX}/3d-to-video`,
25406
- style: `${H3_MAX}/styles`
25407
- },
25408
- resolutions: ["480p", "768p", "1080p"],
25409
- workflowOptions: {
25410
- "extend-video": { resolutions: H3_RESOLUTIONS },
25411
- "lip-sync": { resolutions: H3_RESOLUTIONS, seconds: { min: 5, max: 900, fractional: true } },
25412
- "insert-video": { resolutions: ["480p", "768p"], seconds: { min: 5, max: 13, fractional: true }, creditsPerSecond: { "480p": 5, "768p": 6 } },
25413
- recast: { resolutions: ["768p", "1080p"], seconds: { min: 5, max: 30, fractional: true }, creditsPerSecond: { "768p": 30, "1080p": 45 } },
25414
- relight: { resolutions: H3_RESOLUTIONS, seconds: { min: 2.33, max: 15, fractional: true } },
25415
- "3d-to-video": { seconds: { min: 1, max: 15, fractional: true } },
25416
- style: { resolutions: ["768p"], seconds: { min: 5, max: 15 } }
25417
- },
25418
- modes: ["quality"],
25419
- lastFrame: true,
25420
- endFrameOnly: true,
25421
- seed: true,
25422
- promptExpansion: PROMPT_EXPANSION_MODES,
25423
- audio: true,
25424
- estimatedCreditsPerSecond: { "480p": { quality: 5 }, "768p": { quality: 8 }, "1080p": { quality: 16 }, "2k": { quality: 32 } },
25425
- seconds: { min: 1, max: 15, fractional: true }
25426
- },
25427
- {
25428
- id: "fal/minimax-h3-max-turbo",
25429
- label: "MiniMax H3 Max Turbo (fal.ai)",
25430
- provider: "fal",
25431
- workflows: STUDIO_VIDEO_WORKFLOWS,
25432
- endpoints: h3Endpoints("minimax/h3-max-turbo"),
25433
- resolutions: ["480p", "768p", "1080p"],
25434
- workflowOptions: { "extend-video": { resolutions: H3_RESOLUTIONS } },
25435
- modes: ["speed"],
25436
- lastFrame: true,
25437
- endFrameOnly: true,
25438
- seed: true,
25439
- promptExpansion: PROMPT_EXPANSION_MODES,
25440
- audio: true,
25441
- estimatedCreditsPerSecond: { "480p": { speed: 2.5 }, "768p": { speed: 4 }, "1080p": { speed: 8 }, "2k": { speed: 16 } },
25442
- seconds: { min: 1, max: 15, fractional: true }
25443
- },
25444
- {
25445
- id: "fal/flux-3-max",
25446
- label: "FLUX 3 Max (fal.ai)",
25447
- provider: "fal",
25448
- workflows: ["image-to-video", "text-to-video", "keyframes-to-video"],
25449
- endpoints: { "image-to-video": `${FLUX_3_MAX}/image-to-video`, "text-to-video": `${FLUX_3_MAX}/text-to-video`, "keyframes-to-video": `${FLUX_3_MAX}/keyframes-to-video` },
25450
- falInputs: "flux",
25451
- resolutions: ["720p", "1080p"],
25452
- modes: ["quality"],
25453
- lastFrame: true,
25454
- seed: true,
25455
- promptExpansion: ["balanced", "quality"],
25456
- estimatedCreditsPerSecond: { "720p": { quality: 17 }, "1080p": { quality: 29 } },
25457
- seconds: { min: 5, max: 20 }
25458
- }
25459
- ];
25460
- var SPRITE_VIDEO_MODEL_IDS = SPRITE_VIDEO_MODELS.map((m) => m.id);
25461
- var DEFAULT_SPRITE_VIDEO_MODEL = "pruna/p-video-2-pro";
25462
- function spriteVideoModel(id2) {
25463
- return SPRITE_VIDEO_MODELS.find((m) => m.id === id2) ?? SPRITE_VIDEO_MODELS[0];
25464
- }
25465
- function videoResolutions(model, workflow = "image-to-video") {
25466
- return model.workflowOptions?.[workflow]?.resolutions ?? model.resolutions;
25467
- }
25468
- function videoSeconds(model, workflow = "image-to-video") {
25469
- return model.workflowOptions?.[workflow]?.seconds ?? model.seconds;
25470
- }
25471
- var INCLUDED_REFERENCE_TOKENS = 4096;
25472
- var imageReferenceTokens = (size) => size && size[0] > 0 && size[1] > 0 ? 32 * Math.ceil(32 * Math.max(...size) / Math.min(...size)) : 1824;
25473
- var clipReferenceTokens = (seconds2, resolution) => Math.ceil(Math.max(0, seconds2) * (resolution === "480p" ? 2700 : 6900));
25474
- var audioReferenceTokens = (seconds2) => Math.ceil(Math.max(0, seconds2) * 80);
25475
- var DEFAULT_GENERATED_REFERENCES = 2;
25476
- function billedVideoSeconds(model, workflow, seconds2) {
25477
- const s = videoSeconds(model, workflow).fractional ? seconds2 : Math.round(seconds2);
25478
- if (workflow === "3d-to-video") return Math.max(5, s);
25479
- return workflow === "lip-sync" && s > 15 ? s * 1.2 : s;
25480
- }
25481
- function videoCreditsPerSecond(model, p) {
25482
- const override = model.workflowOptions?.[p.workflow ?? "image-to-video"]?.creditsPerSecond?.[p.resolution];
25483
- if (override !== void 0) return override;
25484
- const byMode = model.estimatedCreditsPerSecond[p.resolution] ?? model.estimatedCreditsPerSecond[model.resolutions[0]];
25485
- return byMode[p.mode] ?? byMode[model.modes[0]] ?? 0;
25486
- }
25487
- function spriteVideoEstimate(model, p, extras2 = {}) {
25488
- const seconds2 = billedVideoSeconds(model, p.workflow ?? "image-to-video", p.seconds);
25489
- const beyond = Math.max(0, (extras2.referenceTokens ?? 0) - INCLUDED_REFERENCE_TOKENS) * 2e-3 + (extras2.generatedReferences ?? 0) * 3;
25490
- return Math.max(1, Math.ceil((videoCreditsPerSecond(model, p) * seconds2 + beyond) * UNITS_PER_CREDIT));
25491
- }
25492
-
25493
25291
  // node_modules/zod/v4/classic/external.js
25494
25292
  var external_exports = {};
25495
25293
  __export(external_exports, {
@@ -45158,6 +44956,220 @@ function date4(params) {
45158
44956
  return _coercedDate(ZodDate, params);
45159
44957
  }
45160
44958
 
44959
+ // shared/showcase.ts
44960
+ var SHOWCASE_SECTORS = ["cafe", "restaurant", "food", "retail", "beauty", "fitness", "workspace", "hospitality", "realestate", "education", "health", "events", "technology", "services"];
44961
+ var SHOWCASE_TAGS = ["social", "website", "product", "space", "offer", "brand", "explainer", "event"];
44962
+ var SHOWCASE_LOCALES = ["", "en", "tr", "sv"];
44963
+ var showcaseEntry = external_exports.object({
44964
+ sector: external_exports.enum(SHOWCASE_SECTORS),
44965
+ tags: external_exports.array(external_exports.enum(SHOWCASE_TAGS)).max(SHOWCASE_TAGS.length).default([]).transform((tags) => [...new Set(tags)]),
44966
+ locale: external_exports.enum(SHOWCASE_LOCALES).default(""),
44967
+ /** Lower comes first; films with the same position show newest first. */
44968
+ position: external_exports.number().int().min(0).max(1e3).default(0)
44969
+ }).strict();
44970
+
44971
+ // shared/sprites/styles.ts
44972
+ var GAME_SPRITE_STYLES = ["hd-cartoon", "pixel", "painted", "vector"];
44973
+ var VIDEO_SPRITE_STYLES = [
44974
+ "photoreal-cutout",
44975
+ "photo-collage",
44976
+ "paper-cutout",
44977
+ "photoreal-stop-motion",
44978
+ "cutout-motion-graphics"
44979
+ ];
44980
+ var SPRITE_STYLES = [...GAME_SPRITE_STYLES, ...VIDEO_SPRITE_STYLES];
44981
+
44982
+ // shared/credits.ts
44983
+ var UNITS_PER_CREDIT = 10;
44984
+ var toCredits = (units) => Math.round(units) / UNITS_PER_CREDIT;
44985
+ var toUnits = (credits) => Math.round(credits * UNITS_PER_CREDIT);
44986
+
44987
+ // shared/sprites/types.ts
44988
+ var SPRITE_KINDS = ["character", "vehicle", "machine", "effect"];
44989
+ var SPRITE_ACTIONS = ["idle", "walk", "run", "skid-stop", "shoot", "jump", "hurt", "death", "slide", "punch", "kick"];
44990
+ var SPRITE_KIND_ACTIONS = {
44991
+ character: SPRITE_ACTIONS,
44992
+ vehicle: ["idle", "move", "fire", "hurt", "destroyed"],
44993
+ machine: ["idle", "work", "activate", "fire", "hurt", "break"],
44994
+ effect: ["projectile", "charge", "impact", "explosion", "aura"]
44995
+ };
44996
+ var ALL_SPRITE_ACTIONS = [...new Set(Object.values(SPRITE_KIND_ACTIONS).flat())];
44997
+ var SPRITE_TOP_ACTIONS = {
44998
+ vehicle: ["hull-move", "hull-destroyed", "turret-fire"],
44999
+ machine: ["hull-break", "turret-fire"]
45000
+ };
45001
+ var ALL_TOP_ACTIONS = [...new Set(Object.values(SPRITE_TOP_ACTIONS).flat())];
45002
+ var CUSTOM_FRAMES = { min: 2, max: 12 };
45003
+
45004
+ // shared/sprites/models.ts
45005
+ var DEFAULT_SPRITE_IMAGE_MODEL = "fal/gpt-image-2.5-medium";
45006
+ var SPRITE_IMAGE_MODELS = [
45007
+ { id: DEFAULT_SPRITE_IMAGE_MODEL, label: "GPT Image 2.5 \xB7 medium (fal)", provider: "fal", edit: true, nativeAlpha: true, maxImages: 16, relCost: 0.3, quality: "medium", endpoints: { generate: "openai/gpt-image-2.5/flare/text-to-image", edit: "openai/gpt-image-2.5/flare/edit" } },
45008
+ { id: "fal/gpt-image-2-low", label: "GPT Image 2 \xB7 low (fal)", provider: "fal", edit: true, spriteBackupCutout: true, maxImages: 16, relCost: 0.2, quality: "low", endpoints: { generate: "openai/gpt-image-2", edit: "openai/gpt-image-2/edit" } },
45009
+ { id: "fal/muse-image", label: "Muse Image (fal)", provider: "fal", edit: true, maxImages: 10, relCost: 0.2, endpoints: { generate: "meta/muse-image/text-to-image", edit: "meta/muse-image/edit" } },
45010
+ { id: "fal/krea-2-turbo", label: "Krea 2 Turbo (fal)", provider: "fal", edit: false, maxImages: 0, relCost: 0.16, endpoints: { generate: "fal-ai/krea-2/turbo" } },
45011
+ { id: "gpt-image-2.5", label: "GPT Image 2.5 (OpenAI)", provider: "openai", edit: true, nativeAlpha: true, maxImages: 16, relCost: 1 },
45012
+ { id: "black-forest-labs/flux-2-max", label: "FLUX.2 [max]", provider: "workers-ai", edit: true, maxImages: 8, relCost: 3 },
45013
+ { id: "bytedance/seedream-5-pro", label: "Seedream 5 Pro", provider: "workers-ai", edit: true, maxImages: 10, relCost: 1.5 },
45014
+ { id: "xai/grok-imagine-image-2.0", label: "Grok Imagine Image 2.0", provider: "workers-ai", edit: true, maxImages: 5, relCost: 1 },
45015
+ { id: "alibaba/qwen-image-3.0-pro", label: "Qwen Image 3.0 Pro", provider: "workers-ai", edit: false, maxImages: 0, relCost: 0.5 },
45016
+ { id: "krea/krea-2-medium-turbo", label: "Krea 2 Medium Turbo", provider: "workers-ai", edit: false, maxImages: 0, relCost: 0.25 }
45017
+ ];
45018
+ var SPRITE_IMAGE_MODEL_IDS = SPRITE_IMAGE_MODELS.map((m) => m.id);
45019
+ function spriteImageModel(id2) {
45020
+ return SPRITE_IMAGE_MODELS.find((m) => m.id === id2) ?? SPRITE_IMAGE_MODELS[0];
45021
+ }
45022
+ var VIDEO_WORKFLOWS = ["image-to-video", "text-to-video", "extend-video", "reference-to-video", "lip-sync", "camera-controls", "insert-video", "recast", "relight", "3d-to-video", "style", "keyframes-to-video"];
45023
+ var STUDIO_VIDEO_WORKFLOWS = ["image-to-video", "text-to-video", "extend-video"];
45024
+ var MEDIA_LENGTH_WORKFLOWS = ["lip-sync", "recast", "relight", "3d-to-video"];
45025
+ var lengthFromMedia = (workflow) => MEDIA_LENGTH_WORKFLOWS.includes(workflow);
45026
+ var VIDEO_STYLES = ["vhs", "retro-toon-70s", "low-poly", "hand-drawn", "16bit-pixel"];
45027
+ var VHS_DAMAGE_LEVELS = ["light", "medium", "heavy"];
45028
+ var VIDEO_RESOLUTIONS = ["480p", "720p", "768p", "1080p", "2k"];
45029
+ var H3_RESOLUTIONS = ["480p", "768p", "1080p", "2k"];
45030
+ var VIDEO_ASPECT_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
45031
+ var PROMPT_EXPANSION_MODES = ["disabled", "balanced", "quality"];
45032
+ var EXTEND_OUTPUTS = ["extended", "continuation"];
45033
+ var SOUNDTRACK_MAX_BYTES = 15 * 1024 * 1024;
45034
+ var EXTEND_SOURCE_MAX_BYTES = 50 * 1024 * 1024;
45035
+ var MAX_AUDIO_REFERENCES = 3;
45036
+ var MAX_VIDEO_REFERENCE_IMAGES = 9;
45037
+ var MAX_REFERENCE_VIDEOS = 3;
45038
+ var MAX_REFERENCE_FILES = 12;
45039
+ var MAX_RECAST_PEOPLE = 4;
45040
+ var MAX_3D_REFERENCES = 8;
45041
+ var MAX_CAMERA_KEYFRAMES = 12;
45042
+ var MAX_CAMERA_TURNS = 32;
45043
+ var MAX_IMAGE_KEYFRAMES = 10;
45044
+ var KEYFRAME_FPS = 24;
45045
+ var H3_MAX = "minimax/h3-max";
45046
+ var FLUX_3_MAX = "blackforestlabs/flux-3-max";
45047
+ var h3Endpoints = (route) => ({ "image-to-video": `${route}/image-to-video`, "text-to-video": `${route}/text-to-video`, "extend-video": `${route}/extend-video` });
45048
+ var SPRITE_VIDEO_MODELS = [
45049
+ {
45050
+ id: "pruna/p-video-2-pro",
45051
+ label: "Pruna P-Video 2 Pro",
45052
+ provider: "pruna",
45053
+ workflows: ["image-to-video"],
45054
+ resolutions: ["480p", "768p"],
45055
+ modes: ["cost", "speed", "quality"],
45056
+ lastFrame: true,
45057
+ estimatedCreditsPerSecond: { "480p": { cost: 1, speed: 2, quality: 4 }, "768p": { cost: 2.5, speed: 3.5, quality: 7.5 } },
45058
+ seconds: { min: 5, max: 15 }
45059
+ },
45060
+ {
45061
+ id: "heygen/heygen-video-1",
45062
+ label: "HeyGen Video 1",
45063
+ provider: "heygen",
45064
+ workflows: ["image-to-video"],
45065
+ resolutions: ["480p", "768p"],
45066
+ modes: ["cost"],
45067
+ lastFrame: false,
45068
+ estimatedCreditsPerSecond: { "480p": { cost: 1 }, "768p": { cost: 1.5 } },
45069
+ seconds: { min: 5, max: 15 }
45070
+ },
45071
+ {
45072
+ id: "fal/minimax-h3-max",
45073
+ label: "MiniMax H3 Max (fal.ai)",
45074
+ provider: "fal",
45075
+ // Every workflow but FLUX 3 Max's keyframes.
45076
+ workflows: VIDEO_WORKFLOWS.filter((w) => w !== "keyframes-to-video"),
45077
+ endpoints: {
45078
+ ...h3Endpoints(H3_MAX),
45079
+ "reference-to-video": `${H3_MAX}/reference-to-video`,
45080
+ "lip-sync": `${H3_MAX}/lip-sync/image-to-video`,
45081
+ "camera-controls": `${H3_MAX}/camera-controls`,
45082
+ "insert-video": `${H3_MAX}/insert-video`,
45083
+ recast: `${H3_MAX}/recast`,
45084
+ relight: `${H3_MAX}/relight`,
45085
+ "3d-to-video": `${H3_MAX}/3d-to-video`,
45086
+ style: `${H3_MAX}/styles`
45087
+ },
45088
+ resolutions: ["480p", "768p", "1080p"],
45089
+ workflowOptions: {
45090
+ "extend-video": { resolutions: H3_RESOLUTIONS },
45091
+ "lip-sync": { resolutions: H3_RESOLUTIONS, seconds: { min: 5, max: 900, fractional: true } },
45092
+ "insert-video": { resolutions: ["480p", "768p"], seconds: { min: 5, max: 13, fractional: true }, creditsPerSecond: { "480p": 5, "768p": 6 } },
45093
+ recast: { resolutions: ["768p", "1080p"], seconds: { min: 5, max: 30, fractional: true }, creditsPerSecond: { "768p": 30, "1080p": 45 } },
45094
+ relight: { resolutions: H3_RESOLUTIONS, seconds: { min: 2.33, max: 15, fractional: true } },
45095
+ "3d-to-video": { seconds: { min: 1, max: 15, fractional: true } },
45096
+ style: { resolutions: ["768p"], seconds: { min: 5, max: 15 } }
45097
+ },
45098
+ modes: ["quality"],
45099
+ lastFrame: true,
45100
+ endFrameOnly: true,
45101
+ seed: true,
45102
+ promptExpansion: PROMPT_EXPANSION_MODES,
45103
+ audio: true,
45104
+ estimatedCreditsPerSecond: { "480p": { quality: 5 }, "768p": { quality: 8 }, "1080p": { quality: 16 }, "2k": { quality: 32 } },
45105
+ seconds: { min: 1, max: 15, fractional: true }
45106
+ },
45107
+ {
45108
+ id: "fal/minimax-h3-max-turbo",
45109
+ label: "MiniMax H3 Max Turbo (fal.ai)",
45110
+ provider: "fal",
45111
+ workflows: STUDIO_VIDEO_WORKFLOWS,
45112
+ endpoints: h3Endpoints("minimax/h3-max-turbo"),
45113
+ resolutions: ["480p", "768p", "1080p"],
45114
+ workflowOptions: { "extend-video": { resolutions: H3_RESOLUTIONS } },
45115
+ modes: ["speed"],
45116
+ lastFrame: true,
45117
+ endFrameOnly: true,
45118
+ seed: true,
45119
+ promptExpansion: PROMPT_EXPANSION_MODES,
45120
+ audio: true,
45121
+ estimatedCreditsPerSecond: { "480p": { speed: 2.5 }, "768p": { speed: 4 }, "1080p": { speed: 8 }, "2k": { speed: 16 } },
45122
+ seconds: { min: 1, max: 15, fractional: true }
45123
+ },
45124
+ {
45125
+ id: "fal/flux-3-max",
45126
+ label: "FLUX 3 Max (fal.ai)",
45127
+ provider: "fal",
45128
+ workflows: ["image-to-video", "text-to-video", "keyframes-to-video"],
45129
+ endpoints: { "image-to-video": `${FLUX_3_MAX}/image-to-video`, "text-to-video": `${FLUX_3_MAX}/text-to-video`, "keyframes-to-video": `${FLUX_3_MAX}/keyframes-to-video` },
45130
+ falInputs: "flux",
45131
+ resolutions: ["720p", "1080p"],
45132
+ modes: ["quality"],
45133
+ lastFrame: true,
45134
+ seed: true,
45135
+ promptExpansion: ["balanced", "quality"],
45136
+ estimatedCreditsPerSecond: { "720p": { quality: 17 }, "1080p": { quality: 29 } },
45137
+ seconds: { min: 5, max: 20 }
45138
+ }
45139
+ ];
45140
+ var SPRITE_VIDEO_MODEL_IDS = SPRITE_VIDEO_MODELS.map((m) => m.id);
45141
+ var DEFAULT_SPRITE_VIDEO_MODEL = "pruna/p-video-2-pro";
45142
+ function spriteVideoModel(id2) {
45143
+ return SPRITE_VIDEO_MODELS.find((m) => m.id === id2) ?? SPRITE_VIDEO_MODELS[0];
45144
+ }
45145
+ function videoResolutions(model, workflow = "image-to-video") {
45146
+ return model.workflowOptions?.[workflow]?.resolutions ?? model.resolutions;
45147
+ }
45148
+ function videoSeconds(model, workflow = "image-to-video") {
45149
+ return model.workflowOptions?.[workflow]?.seconds ?? model.seconds;
45150
+ }
45151
+ var INCLUDED_REFERENCE_TOKENS = 4096;
45152
+ var imageReferenceTokens = (size) => size && size[0] > 0 && size[1] > 0 ? 32 * Math.ceil(32 * Math.max(...size) / Math.min(...size)) : 1824;
45153
+ var clipReferenceTokens = (seconds2, resolution) => Math.ceil(Math.max(0, seconds2) * (resolution === "480p" ? 2700 : 6900));
45154
+ var audioReferenceTokens = (seconds2) => Math.ceil(Math.max(0, seconds2) * 80);
45155
+ var DEFAULT_GENERATED_REFERENCES = 2;
45156
+ function billedVideoSeconds(model, workflow, seconds2) {
45157
+ const s = videoSeconds(model, workflow).fractional ? seconds2 : Math.round(seconds2);
45158
+ if (workflow === "3d-to-video") return Math.max(5, s);
45159
+ return workflow === "lip-sync" && s > 15 ? s * 1.2 : s;
45160
+ }
45161
+ function videoCreditsPerSecond(model, p) {
45162
+ const override = model.workflowOptions?.[p.workflow ?? "image-to-video"]?.creditsPerSecond?.[p.resolution];
45163
+ if (override !== void 0) return override;
45164
+ const byMode = model.estimatedCreditsPerSecond[p.resolution] ?? model.estimatedCreditsPerSecond[model.resolutions[0]];
45165
+ return byMode[p.mode] ?? byMode[model.modes[0]] ?? 0;
45166
+ }
45167
+ function spriteVideoEstimate(model, p, extras2 = {}) {
45168
+ const seconds2 = billedVideoSeconds(model, p.workflow ?? "image-to-video", p.seconds);
45169
+ const beyond = Math.max(0, (extras2.referenceTokens ?? 0) - INCLUDED_REFERENCE_TOKENS) * 2e-3 + (extras2.generatedReferences ?? 0) * 3;
45170
+ return Math.max(1, Math.ceil((videoCreditsPerSecond(model, p) * seconds2 + beyond) * UNITS_PER_CREDIT));
45171
+ }
45172
+
45161
45173
  // shared/sprites/generation-models.ts
45162
45174
  var generationModelShape = {
45163
45175
  videoModel: external_exports.enum(SPRITE_VIDEO_MODEL_IDS).optional()
@@ -45349,7 +45361,7 @@ async function installSkills(options) {
45349
45361
  import { spawn } from "node:child_process";
45350
45362
  import { cp, mkdir as mkdir7, readFile as readFile5, readdir as readdir3, access as access2, writeFile as writeFile6, rename as rename3 } from "node:fs/promises";
45351
45363
  import { basename as basename2, dirname as dirname6, delimiter as delimiter2, join as join7, resolve as resolve6 } from "node:path";
45352
- import { fileURLToPath } from "node:url";
45364
+ import { fileURLToPath, pathToFileURL } from "node:url";
45353
45365
 
45354
45366
  // shared/locales/en/animation-styles.json
45355
45367
  var animation_styles_default = {
@@ -46341,7 +46353,7 @@ var animation_styles_default = {
46341
46353
  "Rack focus between details",
46342
46354
  "Callouts that track parts"
46343
46355
  ],
46344
- previewCaption: "Generated headphones turn on a stone plinth as the camera pulls back from a macro of the brushed yoke, focus racking from part to part under pinned callouts, ending on a lockup for an invented brand. Model generated with Tripo H3.1, rendered in three.js.",
46356
+ previewCaption: "Generated headphones turn on a stone plinth as the camera pulls back from a macro of the brushed yoke, focus racking from part to part under pinned callouts, ending on a lockup for an invented brand.",
46345
46357
  family: "3d",
46346
46358
  example: true
46347
46359
  },
@@ -46358,7 +46370,7 @@ var animation_styles_default = {
46358
46370
  "Crossfaded gestures",
46359
46371
  "A camera that follows the character"
46360
46372
  ],
46361
- previewCaption: "A generated, auto-rigged baker walks across a painted square, turns to camera, waves and explains, his shadow falling over the painting. Character made with Tripo H3.1 and Meshy's auto-rig, rendered in three.js.",
46373
+ previewCaption: "A generated, auto-rigged baker walks across a painted square, turns to camera, waves and explains, his shadow falling over the painting.",
46362
46374
  family: "3d",
46363
46375
  example: true
46364
46376
  },
@@ -46375,7 +46387,7 @@ var animation_styles_default = {
46375
46387
  "Little cars and figures on loops",
46376
46388
  "Focus pulled through the scene"
46377
46389
  ],
46378
- previewCaption: "A camera glides over a toy harbour town as little cars and boats run their loops, everything sharp only in a thin band of focus. Props generated with Tripo H3.1, rendered in three.js.",
46390
+ previewCaption: "A camera glides over a toy harbour town as little cars and boats run their loops, everything sharp only in a thin band of focus.",
46379
46391
  family: "3d",
46380
46392
  example: true
46381
46393
  },
@@ -46630,6 +46642,15 @@ async function installDependencies(directory, options) {
46630
46642
  child.once("close", (code) => code === 0 ? resolveInstall() : reject(new Error(`npm install exited with code ${code}.`)));
46631
46643
  });
46632
46644
  }
46645
+ async function ffmpegProblem() {
46646
+ const { findFfmpeg } = await import(pathToFileURL(join7(packageRoot, "templates/video/scripts/ffmpeg-binaries.mjs")).href);
46647
+ try {
46648
+ await findFfmpeg();
46649
+ return null;
46650
+ } catch (error62) {
46651
+ return error62 instanceof Error ? error62.message : String(error62);
46652
+ }
46653
+ }
46633
46654
  var STARTER_STORY = {
46634
46655
  version: 1,
46635
46656
  start: "opening",
@@ -46711,13 +46732,15 @@ async function createVideoProject(options, installer = installDependencies) {
46711
46732
  if (options.story) await writeStarterStory(directory);
46712
46733
  if (options.quiz) await writeStarterQuiz(directory, options.story);
46713
46734
  if (options.install !== false) {
46714
- options.log?.("Installing PixiJS, Vite, TypeScript, and the MP4 tools\u2026");
46735
+ options.log?.("Installing PixiJS, Vite and TypeScript\u2026");
46715
46736
  try {
46716
46737
  await installer(directory, options);
46717
46738
  } catch (error62) {
46718
46739
  throw new Error(`Project files are ready at ${directory}, but dependency installation failed: ${error62 instanceof Error ? error62.message : String(error62)} Open that folder and run npm install to resume; do not rerun create into it.`);
46719
46740
  }
46720
46741
  }
46742
+ const ffmpeg = await ffmpegProblem();
46743
+ if (ffmpeg) options.log?.(`Note: ${ffmpeg}`);
46721
46744
  options.log?.(`Ready. Open ${directory} in Codex or Claude Code and describe your video.
46722
46745
  ${options.install === false ? "Run npm install, then " : "Run "}npm run dev inside that folder to preview.`);
46723
46746
  return {
@@ -46729,7 +46752,8 @@ ${options.install === false ? "Run npm install, then " : "Run "}npm run dev insi
46729
46752
  installed: options.install !== false,
46730
46753
  skills: [...skills, ...pixiSkills],
46731
46754
  agents: ["Codex", "Claude Code"],
46732
- nextSteps: ["Open this folder in Codex or Claude Code and describe your video. The agent asks for Explainer, Advertisement or General Video, then a visual style and a compact creative brief.", ...options.install === false ? ["Run npm install."] : [], "Run npm run dev to preview.", "Run npm run export:mp4 when the film is ready."]
46755
+ ffmpeg: ffmpeg ? { ready: false, problem: ffmpeg } : { ready: true },
46756
+ nextSteps: ["Open this folder in Codex or Claude Code and describe your video. The agent asks for Explainer, Advertisement or General Video, then a visual style and a compact creative brief.", ...options.install === false ? ["Run npm install."] : [], ...ffmpeg ? [ffmpeg] : [], "Run npm run dev to preview.", "Run npm run export:mp4 when the film is ready."]
46733
46757
  };
46734
46758
  }
46735
46759
  async function createWorkspace(options, installer = installDependencies) {
@@ -46782,6 +46806,8 @@ async function createWorkspace(options, installer = installDependencies) {
46782
46806
  throw new Error(`Workspace files are ready at ${directory}, but dependency installation failed: ${error62 instanceof Error ? error62.message : String(error62)} Run npm install there to finish.`);
46783
46807
  }
46784
46808
  }
46809
+ const ffmpeg = result.ffmpeg;
46810
+ if (!ffmpeg.ready) options.log?.(`Note: ${ffmpeg.problem}`);
46785
46811
  options.log?.(`Workspace ready: ${directory}. Open it in Codex or Claude Code with your website or brand guide, then create an animation.`);
46786
46812
  return {
46787
46813
  type: "workspace",
@@ -46792,7 +46818,8 @@ async function createWorkspace(options, installer = installDependencies) {
46792
46818
  installed: options.install !== false,
46793
46819
  branding: join7(directory, "branding"),
46794
46820
  animations: join7(directory, "animations"),
46795
- nextSteps: ["Open this workspace in Codex or Claude Code and provide your website or brand guide; the agent asks if it is missing.", "Build the shared brand kit in branding/.", "Run animaguru create animation my-video.", "Run npm run dev for the animation hub."]
46821
+ ffmpeg,
46822
+ nextSteps: ["Open this workspace in Codex or Claude Code and provide your website or brand guide; the agent asks if it is missing.", "Build the shared brand kit in branding/.", ...ffmpeg.ready ? [] : [ffmpeg.problem], "Run animaguru create animation my-video.", "Run npm run dev for the animation hub."]
46796
46823
  };
46797
46824
  }
46798
46825
  async function findWorkspace(start = process.cwd()) {
@@ -47336,7 +47363,9 @@ var videoSubmission = videoDetails.and(external_exports.object({
47336
47363
  bytes: external_exports.number().int().positive(),
47337
47364
  width: external_exports.number().int().min(1).max(4096).default(1920),
47338
47365
  height: external_exports.number().int().min(1).max(4096).default(1080),
47339
- duration: external_exports.number().positive().max(7200).optional()
47366
+ duration: external_exports.number().positive().max(7200).optional(),
47367
+ /** Admins only: adds the video to the showcase (shared/showcase.ts), where it shows once it is public and approved. */
47368
+ showcase: showcaseEntry.optional()
47340
47369
  })).refine((value) => value.bytes <= VIDEO_MAX_BYTES[value.format], "HTML5 ZIPs support up to 64 MiB; MP4s support up to 512 MiB.").refine((value) => !value.animation || value.project, "Name the project of the animation.");
47341
47370
  var videoMove = external_exports.object({ workspace: videoWorkspace }).strict();
47342
47371
  function videoPosterType(bytes) {
@@ -48282,6 +48311,17 @@ async function restoreCloud(options) {
48282
48311
  }
48283
48312
 
48284
48313
  // cli/src/videos.ts
48314
+ function showcaseOption(options) {
48315
+ const extra = options["showcase-tags"] !== void 0 || options["showcase-language"] !== void 0 || options["showcase-position"] !== void 0;
48316
+ if (!options.showcase) {
48317
+ if (extra) throw new Error("Pass --showcase SECTOR to add the video to the showcase.");
48318
+ return {};
48319
+ }
48320
+ const tags = options["showcase-tags"] ? options["showcase-tags"].split(",").map((tag) => tag.trim()).filter(Boolean) : [];
48321
+ const parsed = showcaseEntry.safeParse({ sector: options.showcase, tags, locale: options["showcase-language"] === "none" ? "" : options["showcase-language"] ?? "", position: options["showcase-position"] ?? 0 });
48322
+ if (!parsed.success) throw new Error(`Check the showcase tags: ${parsed.error.issues[0]?.message ?? "invalid"} (tags are ${SHOWCASE_TAGS.join(", ")}).`);
48323
+ return { showcase: parsed.data };
48324
+ }
48285
48325
  var subtitlesBody = (content) => new Blob([new Uint8Array(content)], { type: "text/plain; charset=utf-8" });
48286
48326
  function libraryWorkspace(value) {
48287
48327
  if (value === "personal" || value === "me") return "me";
@@ -48326,6 +48366,7 @@ async function exportHtml(root, animation, signal, log) {
48326
48366
  async function submitVideo(client, options) {
48327
48367
  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.");
48328
48368
  const workspace3 = options.workspace === void 0 ? void 0 : libraryWorkspace(options.workspace);
48369
+ const showcase = showcaseOption(options);
48329
48370
  const root = resolve10(options.directory ?? ".");
48330
48371
  const context = await localProject(root, options.animation);
48331
48372
  const file2 = options.file ? resolve10(root, options.file) : await exportHtml(context.toolsRoot, context.animation, options.signal, options.log);
@@ -48370,7 +48411,8 @@ async function submitVideo(client, options) {
48370
48411
  bytes: info.size,
48371
48412
  width: metadata.width,
48372
48413
  height: metadata.height,
48373
- duration: metadata.duration
48414
+ duration: metadata.duration,
48415
+ ...showcase
48374
48416
  });
48375
48417
  let subtitles = null;
48376
48418
  const choice = options.subtitles ?? "auto";
@@ -50091,6 +50133,7 @@ var brandCreate = brandFields.extend({ requestId: external_exports.string().uuid
50091
50133
  var brandUpdate = external_exports.object(fieldShape).partial().strict();
50092
50134
  var brandResearch = external_exports.object({ requestId: external_exports.string().uuid(), workspace, url: brandWebsite, name: external_exports.string().trim().min(1).max(120).optional() }).strict();
50093
50135
  var brandRefresh = external_exports.object({ requestId: external_exports.string().uuid(), url: brandWebsite.optional() }).strict();
50136
+ var brandTransfer = external_exports.object({ workspace }).strict();
50094
50137
  var DEFAULT_BRAND_COLORS = { primary: "#2563eb", background: "#ffffff", text: "#111827", accent: null, palette: [] };
50095
50138
  var DEFAULT_BRAND_FONTS = { heading: { family: "Inter, sans-serif", file: null }, body: { family: "Inter, sans-serif", file: null } };
50096
50139
  var brandDraftFields = brandFields.extend({
@@ -50702,6 +50745,10 @@ function createCli() {
50702
50745
  { name: "poster", type: "string", description: "PNG, JPEG or WebP cover image" },
50703
50746
  { 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" },
50704
50747
  { 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" },
50748
+ { name: "showcase", type: "string", choices: [...SHOWCASE_SECTORS], description: "Admins: add the video to the showcase under this sector; it shows on lead pages once public and approved" },
50749
+ { name: "showcase-tags", type: "string", description: `Admins: what the film is for, comma-separated: ${SHOWCASE_TAGS.join(", ")}` },
50750
+ { name: "showcase-language", type: "string", choices: ["en", "tr", "sv", "none"], description: "Admins: the language the film speaks or shows; none for music and pictures only (default)" },
50751
+ { name: "showcase-position", type: "number", description: "Admins: order in the showcase, lower first (default 0)" },
50705
50752
  { name: "yes", type: "boolean", description: "Authorize upload and chosen sharing" },
50706
50753
  { name: "dry-run", type: "boolean", description: "Inspect locally without uploading" }
50707
50754
  ).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 a video library (the selected workspace's by default), privately shared videos, or the public gallery" }, (command) => command.flags(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -112,7 +112,7 @@ Run `npm run build`. Review the actual browser at desktop and mobile sizes, play
112
112
 
113
113
  Deliver the playable HTML film first unless the user asked for MP4 too. A branching story (`story.json`) and a quiz film (`quiz.json`) are HTML5 only. For an embeddable HTML5 deliverable, use the download menu or `npm run export:html`. Unzip and test the static package over HTTP(S), including its iframe example; it should play without the authoring workspace or an AnimaGuru account. `npm run build` includes the same download in the static player. Preserve embed mode and hide authoring controls there.
114
114
 
115
- For MP4 on the user's own computer, run `npm run export:mp4 -- --headless`; it captures in a local headless Chrome (installed Chrome or Edge, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`, or `npx playwright install chromium`). Without `--headless`, open the printed local capture URL in a browser and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step. The cloud Animator makes no MP4s and `export:mp4` refuses to run there: the user applies the draft and exports the MP4 in their browser.
115
+ For MP4 on the user's own computer, run `npm run export:mp4 -- --headless`; it captures in a local headless Chrome (installed Chrome or Edge, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`, or `npx playwright install chromium`). Without `--headless`, open the printed local capture URL in a browser and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step. The cloud Animator makes no MP4s and `export:mp4` refuses to run there: the user exports the MP4 in their browser from the draft page.
116
116
 
117
117
  `npm run dev` starts the preview. The capture API and file map are documented in the generated project README. Publishing a website, package or campaign is separate from creating the local video.
118
118
 
@@ -10,7 +10,7 @@ Example prompt:
10
10
 
11
11
  ## Preview
12
12
 
13
- Requires Node.js 24+. Dependencies are installed by `animaguru create NAME`. If installation was interrupted, run `npm install` inside this folder to finish it.
13
+ Requires Node.js 24+, and FFmpeg 7.1 or newer with FFprobe for the audio tools and MP4 export: they run the `ffmpeg` and `ffprobe` on PATH, or `FFMPEG_PATH` and `FFPROBE_PATH` (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Debian 13: `sudo apt install ffmpeg`). Dependencies are installed by `animaguru create NAME`. If installation was interrupted, run `npm install` inside this folder to finish it.
14
14
 
15
15
  ```sh
16
16
  npm run dev
@@ -71,9 +71,9 @@ npm run export:mp4 -- --burn-captions
71
71
  npm run export:mp4 -- --watermark
72
72
  ```
73
73
 
74
- Open the local capture URL printed by the command and keep it open until complete. The script runs a separate loopback Vite server (default port 5196), streams high-quality JPEG frames from the Pixi canvas to the bundled FFmpeg binary, and mixes the configured soundtrack. The initial starter exports with a silent AAC track. Exporting local media does not call AnimaGuru or spend credits. FFmpeg/FFprobe binaries are supplied through platform-specific npm dependencies; installation needs a supported platform and network access.
74
+ Open the local capture URL printed by the command and keep it open until complete. The script runs a separate loopback Vite server (default port 5196), streams high-quality JPEG frames from the Pixi canvas to FFmpeg, and mixes the configured soundtrack. An AAC soundtrack at 48 kHz that lasts the whole film, such as `npm run audio:mix` writes, is copied into the MP4 as it is; any other soundtrack is encoded to AAC and padded to length. The initial starter exports with a silent AAC track. Exporting local media does not call AnimaGuru or spend credits. The export stops before capturing when FFmpeg or FFprobe is missing or older than 7.1.
75
75
 
76
- Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. `--watermark` also saves `SLUG-LANGUAGE-watermarked.mp4`, the same film with AnimaGuru's "Made with AnimaGuru" badge in its bottom-right corner, drawn with the font, logo and layout AnimaGuru's web app uses; it is the preview AnimaGuru's lead pipeline plays before a lead claims the clean film. The picture is encoded again with the badge and verified like the film; the sound is copied. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
76
+ Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. `--watermark` also saves `SLUG-LANGUAGE-watermarked.mp4`, the same film with AnimaGuru's "Made with AnimaGuru" badge in its bottom-right corner, drawn with the font, logo and layout AnimaGuru's web app uses; it is the preview AnimaGuru's lead pipeline plays before a lead claims the clean film. The picture is encoded again with the badge and verified like the film; the sound is copied. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. It also measures the MP4's own sound (integrated loudness and true peak, saved as `soundtrack` in the JSON report) and warns when its true peak is above -1 dBTP. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
77
77
 
78
78
  ## Capture API
79
79
 
@@ -25,8 +25,6 @@
25
25
  "three": "0.186.1"
26
26
  },
27
27
  "devDependencies": {
28
- "@ffmpeg-installer/ffmpeg": "1.1.0",
29
- "@ffprobe-installer/ffprobe": "2.1.2",
30
28
  "@types/three": "0.186.0",
31
29
  "fflate": "0.8.3",
32
30
  "playwright-core": "1.64.0",
@@ -7,13 +7,35 @@ import { mkdir, readFile, writeFile, access } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { projectArguments, projectContext } from "./project-context.mjs";
10
- import { ffmpeg, measureLoudness, parseFlags, projectPath, readJson, round } from "./audio-tools.mjs";
10
+ import { ffmpeg, loudnormReport, measureLoudness, parseFlags, projectPath, readJson, round } from "./audio-tools.mjs";
11
11
 
12
12
  const RATE = 48000;
13
13
  export const MIX_DEFAULTS = { musicDb: -9, duck: { threshold: 0.03, ratio: 6, attack: 20, release: 350 }, loudness: { i: -14, tp: -2, lra: 9 } };
14
+ // The largest LRA target loudnorm accepts (FFmpeg 7.1 and newer).
15
+ const LRA_LIMIT = 50;
16
+
17
+ /**
18
+ * The second loudnorm pass: one linear gain from the first pass's measurements. loudnorm stays linear only while the
19
+ * measured loudness range fits the LRA target and the gain keeps the true peak under TP; otherwise it switches to
20
+ * dynamic mode without an error and rides the gain, which can pump. The LRA target does nothing else on a linear
21
+ * pass, so it is widened to the mix's own range unless mix.json sets loudness.lra. `blockers` say what keeps the pass
22
+ * from staying linear, each with the change that would let it.
23
+ */
24
+ export function loudnormPass(first, loudness, { keepRange = false } = {}) {
25
+ const lra = keepRange ? loudness.lra : Math.min(LRA_LIMIT, Math.max(loudness.lra, Math.ceil(first.range * 10) / 10));
26
+ const gain = loudness.i - first.integrated, peak = first.truePeak + gain, blockers = [];
27
+ if (first.range > lra) blockers.push(keepRange
28
+ ? { reason: `its loudness range of ${round(first.range, 1)} LU is wider than loudness.lra (${lra} LU)`, fix: "raise or remove loudness.lra" }
29
+ : { reason: `its loudness range of ${round(first.range, 1)} LU is wider than the ${LRA_LIMIT} LU loudnorm accepts`, fix: "bring the quietest and loudest passages closer together" });
30
+ // The loudest target one gain reaches without passing the ceiling, rounded down so it stays under it.
31
+ const loudest = Math.floor((loudness.tp - first.truePeak + first.integrated) * 10) / 10;
32
+ if (peak > loudness.tp) blockers.push({ reason: `a ${gain >= 0 ? "+" : ""}${round(gain, 1)} dB gain would lift its true peak to ${round(peak, 1)} dBTP, above the ${loudness.tp} dBTP ceiling`, fix: `lower loudness.i to ${loudest} or below` });
33
+ const filter = `loudnorm=I=${loudness.i}:TP=${loudness.tp}:LRA=${lra}:measured_I=${first.integrated}:measured_TP=${first.truePeak}:measured_LRA=${first.range}:measured_thresh=${first.threshold}:offset=${first.offset}:linear=true:print_format=json`;
34
+ return { filter, lra, gain, blockers };
35
+ }
14
36
 
15
37
  /** The music part of the graph for input `input`, as edited by audio:music (a trim or a bar-aligned cut). */
16
- // Padding counts samples at RATE: the bundled Linux ffmpeg (@ffmpeg-installer) has no apad whole_dur option.
38
+ // Padding counts samples at RATE, so the padded length is exact.
17
39
  export function musicGraph(edit, { input = 0, duration, musicDb = MIX_DEFAULTS.musicDb, label = "music" }) {
18
40
  const headIn = Math.max(0, Number(edit?.headIn ?? 0)), fadeIn = Number(edit?.fadeIn ?? (headIn > 0.05 ? 0.25 : 0.02)), fadeOut = Number(edit?.fadeOut ?? 0.6);
19
41
  const ending = `atrim=0:${duration},apad=whole_len=${Math.round(duration * RATE)},afade=t=out:st=${round(Math.max(0, duration - fadeOut))}:d=${fadeOut},volume=${musicDb}dB,aformat=sample_fmts=fltp:sample_rates=${RATE}:channel_layouts=stereo[${label}]`;
@@ -81,8 +103,10 @@ async function main() {
81
103
  await ffmpeg(["-y", "-loglevel", "error", ...(music ? ["-i", music] : []), ...(vo ? ["-i", vo] : []), "-filter_complex", graph, "-map", "[mix]", "-ar", String(RATE), "-ac", "2", "-c:a", "pcm_s24le", "-t", String(duration), premaster]);
82
104
  // Two-pass loudnorm: measure, then normalise linearly with the measured values.
83
105
  const first = await measureLoudness(premaster, loudness);
84
- const second = `loudnorm=I=${loudness.i}:TP=${loudness.tp}:LRA=${loudness.lra}:measured_I=${first.integrated}:measured_TP=${first.truePeak}:measured_LRA=${first.range}:measured_thresh=${first.threshold}:offset=${first.offset}:linear=true`;
85
- await ffmpeg(["-y", "-loglevel", "error", "-i", premaster, "-af", second, "-ar", String(RATE), "-ac", "2", "-c:a", "pcm_s24le", "-t", String(duration), master]);
106
+ const pass = loudnormPass(first, loudness, { keepRange: mix.loudness?.lra !== undefined });
107
+ // No -loglevel error here: loudnorm reports which mode it used at the info level.
108
+ const { stderr } = await ffmpeg(["-y", "-nostats", "-i", premaster, "-af", pass.filter, "-ar", String(RATE), "-ac", "2", "-c:a", "pcm_s24le", "-t", String(duration), master]);
109
+ const mode = loudnormReport(stderr)?.normalization_type;
86
110
  await ffmpeg(["-y", "-loglevel", "error", "-i", master, "-c:a", "aac", "-b:a", "192k", "-ar", String(RATE), "-ac", "2", "-movflags", "+faststart", output]);
87
111
  const result = await measureLoudness(output, loudness);
88
112
 
@@ -93,7 +117,9 @@ async function main() {
93
117
 
94
118
  const parts = [vo ? `voiceover ${path.relative(base, vo)}` : null, music ? `music ${path.relative(base, music)}${edit?.type === "cut" ? ` (${edit.headIn}–${edit.cutA} s → ${edit.cutB} s)` : edit ? ` (from ${edit.headIn} s)` : ""}` : null].filter(Boolean);
95
119
  console.log(`Mixed ${parts.join(" + ")}${music && vo ? `, music at ${mix.musicDb ?? MIX_DEFAULTS.musicDb} dB, ducked under the voice` : ""}.`);
96
- console.log(`Before normalising: ${round(first.integrated, 1)} LUFS, ${round(first.truePeak, 1)} dBTP. Encoded AAC: ${round(result.integrated, 1)} LUFS, ${round(result.truePeak, 1)} dBTP (target ${loudness.i} LUFS, ${loudness.tp} dBTP).`);
120
+ const how = mode === "linear" ? ` Normalised with one ${pass.gain >= 0 ? "+" : ""}${round(pass.gain, 1)} dB gain.` : mode === "dynamic" ? " Normalised with dynamic gain." : "";
121
+ console.log(`Before normalising: ${round(first.integrated, 1)} LUFS, ${round(first.truePeak, 1)} dBTP, ${round(first.range, 1)} LU range.${how} Encoded AAC: ${round(result.integrated, 1)} LUFS, ${round(result.truePeak, 1)} dBTP (target ${loudness.i} LUFS, ${loudness.tp} dBTP).`);
122
+ if (mode === "dynamic") console.log(`Warning: loudnorm rode the gain instead of applying one gain, which can pump${pass.blockers.length ? `, because ${pass.blockers.map((item) => item.reason).join(" and ")}. To keep one gain, ${pass.blockers.map((item) => item.fix).join(" and ")} in production/audio/mix.json and rerun` : ""}. Listen to the mix before exporting.`);
97
123
  if (result.truePeak > -1) console.log("Warning: the encoded true peak is above -1 dBTP; lower loudness.tp in production/audio/mix.json and rerun.");
98
124
  if (Math.abs(result.integrated - loudness.i) > 1) console.log("Warning: the encoded loudness is more than 1 LU off target; check for long silences or a very dynamic bed.");
99
125
  console.log(`Wrote ${path.relative(base, output)}; film.config.json audio is "${audio}". Listen once with npm run dev before exporting.`);
@@ -1,29 +1,20 @@
1
- /** Shared helpers for audio:vo, audio:music and audio:mix: the bundled ffmpeg, PCM decoding and small DSP routines. */
1
+ /** Shared helpers for audio:vo, audio:music and audio:mix: the installed ffmpeg, PCM decoding and small DSP routines. */
2
2
  import { execFile, spawn } from "node:child_process";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { ffmpegBinaries } from "./ffmpeg-binaries.mjs";
6
7
 
7
8
  const run = promisify(execFile);
8
- let binaries;
9
-
10
- /** The ffmpeg/ffprobe builds installed with the workspace; loaded lazily so the pure helpers import anywhere. */
11
- export async function audioBinaries() {
12
- if (!binaries) {
13
- const [{ default: ffmpeg }, { default: ffprobe }] = await Promise.all([import("@ffmpeg-installer/ffmpeg"), import("@ffprobe-installer/ffprobe")]);
14
- binaries = { ffmpeg: ffmpeg.path, ffprobe: ffprobe.path };
15
- }
16
- return binaries;
17
- }
18
9
 
19
10
  export async function ffmpeg(args) {
20
- const { ffmpeg: binary } = await audioBinaries();
11
+ const { ffmpeg: binary } = await ffmpegBinaries();
21
12
  try { return await run(binary, ["-hide_banner", "-nostdin", ...args], { maxBuffer: 64 * 1024 * 1024 }); }
22
13
  catch (error) { throw new Error(`ffmpeg failed: ${String(error.stderr ?? error.message).trim().split("\n").slice(-3).join(" ")}`); }
23
14
  }
24
15
 
25
16
  export async function mediaDuration(file) {
26
- const { ffprobe } = await audioBinaries();
17
+ const { ffprobe } = await ffmpegBinaries();
27
18
  const { stdout } = await run(ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]);
28
19
  const duration = Number(stdout);
29
20
  if (!Number.isFinite(duration)) throw new Error(`Could not read the duration of ${file}.`);
@@ -32,7 +23,7 @@ export async function mediaDuration(file) {
32
23
 
33
24
  /** Decodes any audio file to mono Float32 samples at the given rate. */
34
25
  export async function decodePcm(file, sampleRate = 22050) {
35
- const { ffmpeg: binary } = await audioBinaries();
26
+ const { ffmpeg: binary } = await ffmpegBinaries();
36
27
  return new Promise((resolve, reject) => {
37
28
  const child = spawn(binary, ["-v", "error", "-nostdin", "-i", file, "-ac", "1", "-ar", String(sampleRate), "-f", "f32le", "pipe:1"], { stdio: ["ignore", "pipe", "pipe"] });
38
29
  const chunks = []; let errors = "";
@@ -48,12 +39,17 @@ export async function decodePcm(file, sampleRate = 22050) {
48
39
  });
49
40
  }
50
41
 
51
- /** EBU R128 measurement with ffmpeg's loudnorm; returns the measured values its second pass needs. */
52
- export async function measureLoudness(file, { i = -14, tp = -2, lra = 9, filter = "" } = {}) {
53
- const { stderr } = await ffmpeg(["-nostats", "-i", file, "-af", `${filter ? `${filter},` : ""}loudnorm=I=${i}:TP=${tp}:LRA=${lra}:print_format=json`, "-f", "null", "-"]);
42
+ /** The report loudnorm prints at the info log level when it finishes with print_format=json, or null. */
43
+ export function loudnormReport(stderr) {
54
44
  const json = stderr.match(/\{\s*"input_i"[\s\S]*?\}/)?.[0];
55
- if (!json) throw new Error(`Loudness could not be measured for ${file}.`);
56
- const values = JSON.parse(json);
45
+ return json ? JSON.parse(json) : null;
46
+ }
47
+
48
+ /** EBU R128 measurement with ffmpeg's loudnorm; returns the measured values its second pass needs. A video's picture is skipped. */
49
+ export async function measureLoudness(file, { i = -14, tp = -2, lra = 9, filter = "" } = {}) {
50
+ const { stderr } = await ffmpeg(["-nostats", "-i", file, "-vn", "-af", `${filter ? `${filter},` : ""}loudnorm=I=${i}:TP=${tp}:LRA=${lra}:print_format=json`, "-f", "null", "-"]);
51
+ const values = loudnormReport(stderr);
52
+ if (!values) throw new Error(`Loudness could not be measured for ${file}.`);
57
53
  return { integrated: Number(values.input_i), truePeak: Number(values.input_tp), range: Number(values.input_lra), threshold: Number(values.input_thresh), offset: Number(values.target_offset) };
58
54
  }
59
55
 
@@ -5,15 +5,16 @@ import { mkdir, access, rename, rm, stat, writeFile, readFile, realpath } from "
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import path from "node:path";
8
- import ffmpeg from "@ffmpeg-installer/ffmpeg";
9
- import ffprobe from "@ffprobe-installer/ffprobe";
8
+ import { ffmpegBinaries } from "./ffmpeg-binaries.mjs";
10
9
  import { projectArguments, projectContext, publicFile } from "./project-context.mjs";
11
10
  import { workspacePlugin } from "./workspace-plugin.mjs";
12
11
  import { subtitleCues, toSrt, toVtt } from "./subtitles.mjs";
13
12
  import { captureHeadless, takeHeadlessFlag } from "./headless-browser.mjs";
13
+ import { BT709_FRAMES, canCopySoundtrack, isBt709, mp4Language } from "./mp4-tools.mjs";
14
+ import { measureLoudness } from "./audio-tools.mjs";
14
15
 
15
- // The cloud Animator makes no MP4s: once the user applies the draft, they export it in their own browser.
16
- if (process.env.ANIMAGURU_CLOUD_WORKSPACE) throw new Error("MP4 export is not available in the cloud Animator. After the user applies the draft, they export the MP4 in their browser from the draft page.");
16
+ // The cloud Animator makes no MP4s: the user exports the film in their own browser, from the draft page.
17
+ if (process.env.ANIMAGURU_CLOUD_WORKSPACE) throw new Error("MP4 export is not available in the cloud Animator. The user exports the MP4 in their browser from the draft page.");
17
18
 
18
19
  const root = fileURLToPath(new URL("../", import.meta.url));
19
20
  const selection = projectArguments(process.argv.slice(2));
@@ -40,7 +41,9 @@ const { width, height, fps, duration, language } = config;
40
41
  if (![width, height].every((n) => Number.isInteger(n) && n >= 2 && n <= 4096 && n % 2 === 0) || ![24, 25, 30, 60].includes(fps) || !Number.isFinite(duration) || duration <= 0 || duration > 600 || Math.abs(duration * fps - Math.round(duration * fps)) > 0.000001) throw new Error("Use even dimensions up to 4096px, 24/25/30/60 fps, and a frame-aligned duration up to 600s in film.config.json.");
41
42
  if (typeof language !== "string" || !/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(language)) throw new Error("Set a language code in film.config.json.");
42
43
  if (typeof config.slug !== "string" || !/^[a-z0-9][a-z0-9-]{0,79}$/.test(config.slug)) throw new Error("Use a lowercase, hyphenated film slug (1–80 characters).");
43
- const languageTag = ({ en: "eng", tr: "tur", de: "deu", fr: "fra", es: "spa" })[language.split("-")[0]] ?? "und";
44
+ const languageTag = mp4Language(language);
45
+ // Before any capture starts: a missing or pre-7.1 FFmpeg stops here with how to install a current one.
46
+ const binaries = await ffmpegBinaries();
44
47
  let audioSource;
45
48
  if (config.audio !== null) {
46
49
  if (typeof config.audio !== "string" || !config.audio) throw new Error("audio must be null or a local path relative to public/.");
@@ -89,6 +92,8 @@ let ownsPartial = false;
89
92
  let server;
90
93
  let lastActivity = Date.now();
91
94
  const run = promisify(execFile);
95
+ // An AAC soundtrack that already fits the film, such as audio:mix's, is copied rather than encoded a second time.
96
+ const copySoundtrack = audioSource ? canCopySoundtrack(JSON.parse((await run(binaries.ffprobe, ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,profile,sample_rate,start_time,duration:format=duration", "-of", "json", audioSource])).stdout), duration) : false;
92
97
 
93
98
  async function readBody(request, limit = 24 * 1024 * 1024) {
94
99
  let bytes = 0;
@@ -121,13 +126,15 @@ function pngDimensions(bytes) {
121
126
  return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
122
127
  }
123
128
 
124
- /** Reads an encoded MP4 back and checks that it is the whole film: frames, duration, dimensions, codecs and language. */
129
+ /** Reads an encoded MP4 back and checks that it is the whole film: frames, duration, dimensions, codecs, colour tags and language. */
125
130
  async function verify(file, what) {
126
- const { stdout } = await run(ffprobe.path, ["-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", file], { maxBuffer: 1024 * 1024 });
131
+ const { stdout } = await run(binaries.ffprobe, ["-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", file], { maxBuffer: 1024 * 1024 });
127
132
  const probe = JSON.parse(stdout);
128
133
  const video = probe.streams.find((stream) => stream.codec_type === "video");
129
134
  const audio = probe.streams.find((stream) => stream.codec_type === "audio");
130
135
  if (!video || video.codec_name !== "h264" || video.width !== width || video.height !== height || Number(video.nb_read_frames) !== settings.frames || video.pix_fmt !== "yuv420p" || !audio || audio.codec_name !== "aac" || audio.tags?.language !== languageTag || Math.abs(Number(probe.format.duration) - settings.duration) > 0.15) throw new Error(`The encoded ${what} did not pass duration, frame count, dimensions, language, or codec verification.`);
136
+ // Players guess the colours of a video without these tags, and some guess differently.
137
+ if (!isBt709(video)) throw new Error(`The encoded ${what} is not tagged BT.709 limited range (${["color_space", "color_primaries", "color_transfer", "color_range"].map((key) => video[key] ?? "unknown").join(", ")}).`);
131
138
  return probe;
132
139
  }
133
140
 
@@ -140,7 +147,7 @@ async function watermarkCopy() {
140
147
  await writeFile(watermarkedPartial, "", { flag: "wx" });
141
148
  ownsWatermarkedPartial = true;
142
149
  // The badge arrives on stdin as a single PNG frame, which the overlay keeps over the whole film.
143
- const child = spawn(ffmpeg.path, [
150
+ const child = spawn(binaries.ffmpeg, [
144
151
  "-hide_banner", "-loglevel", "warning", "-y", "-i", partial, "-f", "png_pipe", "-i", "pipe:0",
145
152
  "-filter_complex", `[1:v]scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[mark];[0:v][mark]overlay=x=${badge.x}:y=${badge.y}:format=yuv420,format=yuv420p[film]`,
146
153
  "-map", "[film]", "-map", "0:a:0",
@@ -201,15 +208,16 @@ async function route(request, response, url) {
201
208
  settings.captions = !subtitles.length ? "none" : burnCaptions ? "burned" : "subtitle-files";
202
209
  await writeFile(partial, "", { flag: "wx" });
203
210
  ownsPartial = true;
204
- encoder = spawn(ffmpeg.path, [
211
+ encoder = spawn(binaries.ffmpeg, [
205
212
  "-hide_banner", "-loglevel", "warning", "-y",
206
213
  "-f", "image2pipe", "-framerate", String(fps), "-vcodec", "mjpeg", "-i", "pipe:0",
207
214
  ...(audioSource ? ["-i", audioSource] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"]),
208
215
  "-map", "0:v:0", "-map", "1:a:0",
209
216
  "-c:v", "libx264", "-preset", "medium", "-crf", "18",
210
- "-vf", `scale=${width}:${height}:in_color_matrix=bt601:out_color_matrix=bt709:in_range=full:out_range=tv,format=yuv420p`,
217
+ // setparams labels the frames: from FFmpeg 7.1 on, the -color_* options below no longer tag frames that do not say so.
218
+ "-vf", `scale=${width}:${height}:in_color_matrix=bt601:out_color_matrix=bt709:in_range=full:out_range=tv,format=yuv420p,${BT709_FRAMES}`,
211
219
  "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
212
- "-af", "apad", "-c:a", "aac", "-b:a", "192k", "-ar", "48000",
220
+ ...(copySoundtrack ? ["-c:a", "copy"] : ["-af", "apad", "-c:a", "aac", "-b:a", "192k", "-ar", "48000"]),
213
221
  "-metadata:s:a:0", `language=${languageTag}`, "-metadata:s:v:0", `language=${languageTag}`,
214
222
  "-t", String(settings.duration), "-movflags", "+faststart", partial,
215
223
  ], { stdio: ["pipe", "ignore", "pipe"] });
@@ -275,6 +283,10 @@ async function route(request, response, url) {
275
283
  await encoderDone;
276
284
  console.log("Checking the encoded video and soundtrack…");
277
285
  const probe = await verify(partial, "file");
286
+ // Players hear the MP4's own sound, so its level is measured from the MP4, not assumed from the soundtrack file.
287
+ const sound = audioSource ? await measureLoudness(partial).then(
288
+ (levels) => ({ file: config.audio, copied: copySoundtrack, integratedLufs: levels.integrated, truePeakDb: levels.truePeak, rangeLu: levels.range }),
289
+ (error) => ({ file: config.audio, copied: copySoundtrack, error: error.message })) : null;
278
290
  if (watermark) {
279
291
  console.log("Making the watermarked copy…");
280
292
  await watermarkCopy();
@@ -287,11 +299,13 @@ async function route(request, response, url) {
287
299
  for (const file of subtitleFiles) await writeFile(file.path, file.text);
288
300
  const thumbnailFile = thumbnailImage ? destination.replace(/\.mp4$/, ".jpg") : null;
289
301
  if (thumbnailFile) await writeFile(thumbnailFile, thumbnailImage);
290
- await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), time: thumbnail.time }, ...(watermark ? { watermarked: { file: path.basename(watermarked), badge: { x: badge.x, y: badge.y, width: badge.width, height: badge.height } } } : {}), settings, verification: probe }, null, 2) + "\n");
302
+ await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), time: thumbnail.time }, soundtrack: sound, ...(watermark ? { watermarked: { file: path.basename(watermarked), badge: { x: badge.x, y: badge.y, width: badge.width, height: badge.height } } } : {}), settings, verification: probe }, null, 2) + "\n");
291
303
  const bytes = (await stat(destination)).size;
292
304
  finished = true;
293
305
  clearInterval(idleCheck);
294
306
  console.log(`Saved: ${destination}\nSize: ${(bytes / 1024 / 1024).toFixed(1)} MiB\nVerified: ${settings.frames} frames, ${settings.duration}s, H.264 video and AAC audio.`);
307
+ if (sound) console.log(`Sound: ${sound.file}, ${sound.copied ? "copied without a second AAC encode" : "encoded to AAC"}; ${sound.error ? `not measured (${sound.error})` : Number.isFinite(sound.integratedLufs) ? `the MP4 measures ${sound.integratedLufs.toFixed(1)} LUFS, ${sound.truePeakDb.toFixed(1)} dBTP` : "the MP4's sound is silent"}.`);
308
+ if (sound?.truePeakDb > -1) console.log("Warning: the MP4's sound peaks above -1 dBTP, where players and platforms can clip it. Lower the soundtrack's true peak (for npm run audio:mix, loudness.tp in production/audio/mix.json) and export again.");
295
309
  if (subtitleFiles.length) console.log(`Subtitles (${subtitles.length} captions, ${language}): ${subtitleFiles.map((file) => file.path).join(", ")}`);
296
310
  console.log(thumbnailFile ? `Thumbnail (${thumbnail.time}s): ${thumbnailFile}` : 'No thumbnail: set "poster" in film.config.json to the seconds of the frame to save as one.');
297
311
  if (watermark) console.log(`Watermarked copy ("Made with AnimaGuru"): ${watermarked}`);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The FFmpeg and FFprobe the audio tools and MP4 export run: the ones on PATH, or FFMPEG_PATH and FFPROBE_PATH.
3
+ * The workspace installs none of its own. Both must be FFmpeg 7.1 or newer, which every script here is tested with.
4
+ */
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+
8
+ const run = promisify(execFile);
9
+ export const MINIMUM_FFMPEG = [7, 1];
10
+
11
+ /** The release in the first line of `ffmpeg -version` (9.0.2, 7.1.5-0+deb13u1, n8.0.1, 8.0-essentials_build…), or null for a git snapshot. */
12
+ export function ffmpegRelease(text) {
13
+ const match = /^ff(?:mpeg|probe) version n?(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text).trim());
14
+ return match ? match.slice(1).map((part) => Number(part ?? 0)) : null;
15
+ }
16
+
17
+ /** How to install a current FFmpeg on this platform. */
18
+ export function ffmpegInstallHint(platform = process.platform) {
19
+ const install = platform === "darwin" ? "Install it with `brew install ffmpeg`"
20
+ : platform === "win32" ? "Install it with `winget install Gyan.FFmpeg`, then open a new terminal"
21
+ : "Install your distribution's ffmpeg package if it is 7.1 or newer (Debian 13: `sudo apt install ffmpeg`), or a static build";
22
+ return `${install}, or point FFMPEG_PATH and FFPROBE_PATH at an ffmpeg and ffprobe of ${MINIMUM_FFMPEG.join(".")} or newer.`;
23
+ }
24
+
25
+ const older = (release, minimum) => release[0] !== minimum[0] ? release[0] < minimum[0] : release[1] < minimum[1];
26
+
27
+ /** Runs `NAME -version` and throws, with how to install a current build, when it is missing or older than 7.1. */
28
+ async function check(binary, name) {
29
+ let stdout;
30
+ try { ({ stdout } = await run(binary, ["-version"])); }
31
+ catch (error) {
32
+ if (error.code === "ENOENT") throw new Error(`${name} was not found ${binary === name ? "on PATH" : `at ${binary}`}. The audio tools and MP4 export need FFmpeg ${MINIMUM_FFMPEG.join(".")} or newer. ${ffmpegInstallHint()}`);
33
+ throw new Error(`${binary} -version failed: ${String(error.stderr || error.message).trim().split("\n").at(-1)}. ${ffmpegInstallHint()}`);
34
+ }
35
+ const release = ffmpegRelease(stdout);
36
+ if (release && older(release, MINIMUM_FFMPEG)) {
37
+ const version = String(stdout).trim().split("\n")[0].split(/\s+/)[2];
38
+ throw new Error(`${name} ${version} at ${binary} is too old: the audio tools and MP4 export need FFmpeg ${MINIMUM_FFMPEG.join(".")} or newer. ${ffmpegInstallHint()}`);
39
+ }
40
+ }
41
+
42
+ /** Finds and checks ffmpeg and ffprobe for the given environment; resolves to the commands to run. */
43
+ export async function findFfmpeg(env = process.env) {
44
+ const ffmpeg = env.FFMPEG_PATH || "ffmpeg", ffprobe = env.FFPROBE_PATH || "ffprobe";
45
+ await Promise.all([check(ffmpeg, "ffmpeg"), check(ffprobe, "ffprobe")]);
46
+ return { ffmpeg, ffprobe };
47
+ }
48
+
49
+ let binaries;
50
+ /** findFfmpeg() for this process, checked once. */
51
+ export function ffmpegBinaries() {
52
+ binaries ??= findFfmpeg();
53
+ return binaries;
54
+ }
@@ -0,0 +1,33 @@
1
+ /** Decisions npm run export:mp4 makes about the MP4's streams, apart from its capture server so they can be tested. */
2
+
3
+ // MP4 language tags are ISO 639-2 codes: FFmpeg writes a two-letter code such as "sv" as no tag at all.
4
+ const LANGUAGE_TAGS = { en: "eng", tr: "tur", sv: "swe", de: "deu", fr: "fra", es: "spa" };
5
+
6
+ /** The MP4 stream language tag for a film.config.json language such as "sv" or "en-GB"; "und" for one not listed. */
7
+ export function mp4Language(language) {
8
+ return LANGUAGE_TAGS[language.split("-")[0]] ?? "und";
9
+ }
10
+
11
+ /**
12
+ * Labels the film's frames BT.709 limited range at the end of its filter chain. FFmpeg 7.1 and newer no longer write
13
+ * -color_primaries and -color_trc as plain tags: frames decoded from the capture's JPEGs carry no primaries or transfer,
14
+ * and without this label the MP4 says "unknown" for both (FFmpeg 9.0.2), which players fill in by guessing.
15
+ */
16
+ export const BT709_FRAMES = "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv";
17
+
18
+ /** Whether ffprobe's video stream is tagged BT.709 limited range, as the film is encoded. */
19
+ export function isBt709(stream) {
20
+ return stream?.color_space === "bt709" && stream.color_primaries === "bt709" && stream.color_transfer === "bt709" && stream.color_range === "tv";
21
+ }
22
+
23
+ /**
24
+ * Whether the soundtrack goes into the MP4 as it is, from ffprobe's JSON for its first audio stream: AAC-LC at 48 kHz
25
+ * that starts at 0 and lasts the whole film, as npm run audio:mix writes it. Copying keeps the loudness and true peak
26
+ * that audio:mix measured; a second AAC encode adds its own overshoot. Anything else is encoded and padded to length.
27
+ */
28
+ export function canCopySoundtrack(probe, duration) {
29
+ const stream = probe?.streams?.[0];
30
+ const length = Number(stream?.duration ?? probe?.format?.duration);
31
+ return stream?.codec_name === "aac" && stream.profile === "LC" && Number(stream.sample_rate) === 48000
32
+ && Math.abs(Number(stream.start_time ?? Number.NaN)) < 0.001 && length >= duration - 0.05;
33
+ }
@@ -1,19 +1,19 @@
1
1
  import path from "node:path";
2
2
  import { execFile } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
- import ffmpeg from "@ffmpeg-installer/ffmpeg";
5
- import ffprobe from "@ffprobe-installer/ffprobe";
4
+ import { ffmpegBinaries } from "./ffmpeg-binaries.mjs";
6
5
 
7
6
  const run=promisify(execFile);
8
7
  export async function inspectAudio(root,config) {
9
8
  if(!config.audio)return {status:"silent",message:"No soundtrack configured; audio checks are not applicable."};
10
9
  const publicRoot=path.resolve(root,"public"),file=path.resolve(publicRoot,config.audio);
11
10
  if(!file.startsWith(publicRoot+path.sep))throw new Error("Soundtrack must be inside public/.");
12
- const {stdout}=await run(ffprobe.path,["-v","error","-show_streams","-show_format","-of","json",file]);
11
+ const {ffmpeg,ffprobe}=await ffmpegBinaries();
12
+ const {stdout}=await run(ffprobe,["-v","error","-show_streams","-show_format","-of","json",file]);
13
13
  const probe=JSON.parse(stdout),stream=probe.streams.find((s)=>s.codec_type==="audio");
14
14
  if(!stream)throw new Error("Configured soundtrack has no audio stream.");
15
15
  // Same targets as npm run audio:mix (−14 LUFS, −2 dBTP); the measured input values do not depend on them.
16
- const {stderr}=await run(ffmpeg.path,["-hide_banner","-nostats","-i",file,"-af","loudnorm=I=-14:TP=-2:LRA=9:print_format=json","-f","null","-"],{maxBuffer:1024*1024});
16
+ const {stderr}=await run(ffmpeg,["-hide_banner","-nostats","-i",file,"-af","loudnorm=I=-14:TP=-2:LRA=9:print_format=json","-f","null","-"],{maxBuffer:1024*1024});
17
17
  const json=stderr.match(/\{\s*"input_i"[\s\S]*?\}/)?.[0];
18
18
  if(!json)throw new Error("Audio loudness could not be measured.");
19
19
  const levels=JSON.parse(json),duration=Number(probe.format.duration);