@docyrus/animaguru 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
 
@@ -311,6 +311,23 @@ animaguru assets generate PROJECT_ID --type video --model fal/flux-3-max \
311
311
 
312
312
  `--keyframes` takes 1–10 `seconds,SHA` pairs, separated by semicolons, of this project's images or sprite art. Each is reached at its second, within `--seconds`; the clip has 24 frames a second and each keyframe needs a frame of its own. `--aspect-ratio auto` (default) follows the first keyframe, or give a ratio. FLUX 3 Max always works through the prompt before filming: `--prompt-expansion quality` asks for its thorough, slower level, any other value its light one. It takes `--seed`. Its dry runs are local estimates.
313
313
 
314
+ CLI 0.30.0 adds HeyGen Video 1's other workflows (`heygen/heygen-video-1`). Besides image-to-video, it writes videos from text (`--workflow text-to-video`, with `--aspect-ratio`) and keeps the subjects of saved files (`--workflow reference-to-video`), for 5–15 whole seconds at 480p (default), 768p, 1080p or 2K. It makes its own sound, takes no `--soundtrack`, and takes `--seed`.
315
+
316
+ ```sh
317
+ # A 30-second scene from text; clips over 15 seconds need prompt rewriting.
318
+ animaguru assets generate PROJECT_ID --type video --model heygen/heygen-video-1 \
319
+ --workflow text-to-video --name "Potter" \
320
+ --prompt "A potter shapes a clay bowl on a wheel in a sunlit studio. One continuous shot, soft workshop sounds, no music" \
321
+ --seconds 30 --prompt-expansion balanced --resolution 1080p --aspect-ratio 16:9 --dry-run
322
+ # Keep a product and a place from saved images.
323
+ animaguru assets generate PROJECT_ID --type video --model heygen/heygen-video-1 \
324
+ --workflow reference-to-video --name "Harness check" \
325
+ --prompt "A supervisor wearing the harness in Image 1 stands still beside the line in Image 2 and speaks to camera" \
326
+ --references HARNESS_SHA,FACTORY_SHA --seconds 10 --resolution 768p --dry-run
327
+ ```
328
+
329
+ From an image or text, HeyGen's clips run to 30 seconds while it rewrites the prompt: `--prompt-expansion balanced` asks for its fastest rewriting and `quality` its fuller one. Every second of a clip over 15 seconds costs its long-clip rate. At 1080p and 2K, text-to-video and reference-to-video are 16:9 or 9:16; from an image, the clip keeps the image's shape. `reference-to-video` takes up to nine `--references` images, three `--reference-videos` (HeyGen reads the first 5 seconds of each) and three `--audio-references`, twelve files in all, named Image 1, Video 1, Audio 1 in the prompt. It takes no `--reference`, `--end-reference` or `--middle-reference`, and `--aspect-ratio auto` (default) follows the first image or clip. HeyGen takes images of up to 5 MB, and clips and audio of up to 16 MB. Up to 5 seconds of each reference clip, and each reference image after the second, add to the cost; audio references are free.
330
+
314
331
  ### Vectors
315
332
 
316
333
  CLI 0.27.0 adds vectors: editable SVG files drawn by Ideogram on fal.ai (`fal/ideogram-vector`), for logos, icons, mascots and flat illustrations that stay sharp at any size and can be animated shape by shape.
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.29.0",
19764
+ version: "0.30.0",
19765
19765
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
19766
19766
  type: "module",
19767
19767
  bin: {
@@ -45042,6 +45042,9 @@ var MAX_CAMERA_KEYFRAMES = 12;
45042
45042
  var MAX_CAMERA_TURNS = 32;
45043
45043
  var MAX_IMAGE_KEYFRAMES = 10;
45044
45044
  var KEYFRAME_FPS = 24;
45045
+ var HEYGEN_RESOLUTIONS = ["480p", "768p", "1080p", "2k"];
45046
+ var HEYGEN_WIDE = { "1080p": ["16:9", "9:16"], "2k": ["16:9", "9:16"] };
45047
+ var HEYGEN_LONG = { max: 30, creditsPerSecond: { "480p": 4.5, "768p": 7.5, "1080p": 10.5, "2k": 10.5 } };
45045
45048
  var H3_MAX = "minimax/h3-max";
45046
45049
  var FLUX_3_MAX = "blackforestlabs/flux-3-max";
45047
45050
  var h3Endpoints = (route) => ({ "image-to-video": `${route}/image-to-video`, "text-to-video": `${route}/text-to-video`, "extend-video": `${route}/extend-video` });
@@ -45061,11 +45064,18 @@ var SPRITE_VIDEO_MODELS = [
45061
45064
  id: "heygen/heygen-video-1",
45062
45065
  label: "HeyGen Video 1",
45063
45066
  provider: "heygen",
45064
- workflows: ["image-to-video"],
45067
+ workflows: ["image-to-video", "text-to-video", "reference-to-video"],
45065
45068
  resolutions: ["480p", "768p"],
45069
+ workflowOptions: {
45070
+ "image-to-video": { resolutions: HEYGEN_RESOLUTIONS, long: HEYGEN_LONG },
45071
+ "text-to-video": { resolutions: HEYGEN_RESOLUTIONS, long: HEYGEN_LONG, shapes: HEYGEN_WIDE },
45072
+ "reference-to-video": { resolutions: HEYGEN_RESOLUTIONS, shapes: HEYGEN_WIDE, referencesOnly: true, creditsPerSecond: { "480p": 7, "768p": 10, "1080p": 18, "2k": 18 } }
45073
+ },
45066
45074
  modes: ["cost"],
45067
45075
  lastFrame: false,
45068
- estimatedCreditsPerSecond: { "480p": { cost: 1 }, "768p": { cost: 1.5 } },
45076
+ seed: true,
45077
+ promptExpansion: PROMPT_EXPANSION_MODES,
45078
+ estimatedCreditsPerSecond: { "480p": { cost: 2 }, "768p": { cost: 3.6 }, "1080p": { cost: 10 }, "2k": { cost: 10 } },
45069
45079
  seconds: { min: 5, max: 15 }
45070
45080
  },
45071
45081
  {
@@ -45145,21 +45155,29 @@ function spriteVideoModel(id2) {
45145
45155
  function videoResolutions(model, workflow = "image-to-video") {
45146
45156
  return model.workflowOptions?.[workflow]?.resolutions ?? model.resolutions;
45147
45157
  }
45148
- function videoSeconds(model, workflow = "image-to-video") {
45149
- return model.workflowOptions?.[workflow]?.seconds ?? model.seconds;
45158
+ function videoSeconds(model, workflow = "image-to-video", rewrites = false) {
45159
+ const options = model.workflowOptions?.[workflow];
45160
+ const range = options?.seconds ?? model.seconds;
45161
+ return rewrites && options?.long ? { ...range, max: options.long.max } : range;
45150
45162
  }
45163
+ var longVideo = (model, workflow, seconds2) => Boolean(model.workflowOptions?.[workflow]?.long) && Math.round(seconds2) > videoSeconds(model, workflow).max;
45151
45164
  var INCLUDED_REFERENCE_TOKENS = 4096;
45152
45165
  var imageReferenceTokens = (size) => size && size[0] > 0 && size[1] > 0 ? 32 * Math.ceil(32 * Math.max(...size) / Math.min(...size)) : 1824;
45153
45166
  var clipReferenceTokens = (seconds2, resolution) => Math.ceil(Math.max(0, seconds2) * (resolution === "480p" ? 2700 : 6900));
45154
45167
  var audioReferenceTokens = (seconds2) => Math.ceil(Math.max(0, seconds2) * 80);
45155
45168
  var DEFAULT_GENERATED_REFERENCES = 2;
45169
+ var HEYGEN_REFERENCE_CLIP_SECONDS = 5;
45170
+ var HEYGEN_FREE_REFERENCE_IMAGES = 2;
45156
45171
  function billedVideoSeconds(model, workflow, seconds2) {
45157
45172
  const s = videoSeconds(model, workflow).fractional ? seconds2 : Math.round(seconds2);
45158
45173
  if (workflow === "3d-to-video") return Math.max(5, s);
45159
45174
  return workflow === "lip-sync" && s > 15 ? s * 1.2 : s;
45160
45175
  }
45161
45176
  function videoCreditsPerSecond(model, p) {
45162
- const override = model.workflowOptions?.[p.workflow ?? "image-to-video"]?.creditsPerSecond?.[p.resolution];
45177
+ const workflow = p.workflow ?? "image-to-video";
45178
+ const options = model.workflowOptions?.[workflow];
45179
+ const long = p.seconds !== void 0 && longVideo(model, workflow, p.seconds) ? options?.long?.creditsPerSecond[p.resolution] : void 0;
45180
+ const override = long ?? options?.creditsPerSecond?.[p.resolution];
45163
45181
  if (override !== void 0) return override;
45164
45182
  const byMode = model.estimatedCreditsPerSecond[p.resolution] ?? model.estimatedCreditsPerSecond[model.resolutions[0]];
45165
45183
  return byMode[p.mode] ?? byMode[model.modes[0]] ?? 0;
@@ -45167,7 +45185,7 @@ function videoCreditsPerSecond(model, p) {
45167
45185
  function spriteVideoEstimate(model, p, extras2 = {}) {
45168
45186
  const seconds2 = billedVideoSeconds(model, p.workflow ?? "image-to-video", p.seconds);
45169
45187
  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));
45188
+ return Math.max(1, Math.ceil((videoCreditsPerSecond(model, p) * (seconds2 + (extras2.referenceSeconds ?? 0)) + beyond) * UNITS_PER_CREDIT));
45171
45189
  }
45172
45190
 
45173
45191
  // shared/sprites/generation-models.ts
@@ -45361,7 +45379,7 @@ async function installSkills(options) {
45361
45379
  import { spawn } from "node:child_process";
45362
45380
  import { cp, mkdir as mkdir7, readFile as readFile5, readdir as readdir3, access as access2, writeFile as writeFile6, rename as rename3 } from "node:fs/promises";
45363
45381
  import { basename as basename2, dirname as dirname6, delimiter as delimiter2, join as join7, resolve as resolve6 } from "node:path";
45364
- import { fileURLToPath } from "node:url";
45382
+ import { fileURLToPath, pathToFileURL } from "node:url";
45365
45383
 
45366
45384
  // shared/locales/en/animation-styles.json
45367
45385
  var animation_styles_default = {
@@ -46353,7 +46371,7 @@ var animation_styles_default = {
46353
46371
  "Rack focus between details",
46354
46372
  "Callouts that track parts"
46355
46373
  ],
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. Model generated with Tripo H3.1, rendered in three.js.",
46374
+ 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.",
46357
46375
  family: "3d",
46358
46376
  example: true
46359
46377
  },
@@ -46370,7 +46388,7 @@ var animation_styles_default = {
46370
46388
  "Crossfaded gestures",
46371
46389
  "A camera that follows the character"
46372
46390
  ],
46373
- 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.",
46391
+ previewCaption: "A generated, auto-rigged baker walks across a painted square, turns to camera, waves and explains, his shadow falling over the painting.",
46374
46392
  family: "3d",
46375
46393
  example: true
46376
46394
  },
@@ -46387,7 +46405,7 @@ var animation_styles_default = {
46387
46405
  "Little cars and figures on loops",
46388
46406
  "Focus pulled through the scene"
46389
46407
  ],
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. Props generated with Tripo H3.1, rendered in three.js.",
46408
+ 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.",
46391
46409
  family: "3d",
46392
46410
  example: true
46393
46411
  },
@@ -46642,6 +46660,15 @@ async function installDependencies(directory, options) {
46642
46660
  child.once("close", (code) => code === 0 ? resolveInstall() : reject(new Error(`npm install exited with code ${code}.`)));
46643
46661
  });
46644
46662
  }
46663
+ async function ffmpegProblem() {
46664
+ const { findFfmpeg } = await import(pathToFileURL(join7(packageRoot, "templates/video/scripts/ffmpeg-binaries.mjs")).href);
46665
+ try {
46666
+ await findFfmpeg();
46667
+ return null;
46668
+ } catch (error62) {
46669
+ return error62 instanceof Error ? error62.message : String(error62);
46670
+ }
46671
+ }
46645
46672
  var STARTER_STORY = {
46646
46673
  version: 1,
46647
46674
  start: "opening",
@@ -46723,13 +46750,15 @@ async function createVideoProject(options, installer = installDependencies) {
46723
46750
  if (options.story) await writeStarterStory(directory);
46724
46751
  if (options.quiz) await writeStarterQuiz(directory, options.story);
46725
46752
  if (options.install !== false) {
46726
- options.log?.("Installing PixiJS, Vite, TypeScript, and the MP4 tools\u2026");
46753
+ options.log?.("Installing PixiJS, Vite and TypeScript\u2026");
46727
46754
  try {
46728
46755
  await installer(directory, options);
46729
46756
  } catch (error62) {
46730
46757
  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.`);
46731
46758
  }
46732
46759
  }
46760
+ const ffmpeg = await ffmpegProblem();
46761
+ if (ffmpeg) options.log?.(`Note: ${ffmpeg}`);
46733
46762
  options.log?.(`Ready. Open ${directory} in Codex or Claude Code and describe your video.
46734
46763
  ${options.install === false ? "Run npm install, then " : "Run "}npm run dev inside that folder to preview.`);
46735
46764
  return {
@@ -46741,7 +46770,8 @@ ${options.install === false ? "Run npm install, then " : "Run "}npm run dev insi
46741
46770
  installed: options.install !== false,
46742
46771
  skills: [...skills, ...pixiSkills],
46743
46772
  agents: ["Codex", "Claude Code"],
46744
- 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."]
46773
+ ffmpeg: ffmpeg ? { ready: false, problem: ffmpeg } : { ready: true },
46774
+ 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."]
46745
46775
  };
46746
46776
  }
46747
46777
  async function createWorkspace(options, installer = installDependencies) {
@@ -46794,6 +46824,8 @@ async function createWorkspace(options, installer = installDependencies) {
46794
46824
  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.`);
46795
46825
  }
46796
46826
  }
46827
+ const ffmpeg = result.ffmpeg;
46828
+ if (!ffmpeg.ready) options.log?.(`Note: ${ffmpeg.problem}`);
46797
46829
  options.log?.(`Workspace ready: ${directory}. Open it in Codex or Claude Code with your website or brand guide, then create an animation.`);
46798
46830
  return {
46799
46831
  type: "workspace",
@@ -46804,7 +46836,8 @@ async function createWorkspace(options, installer = installDependencies) {
46804
46836
  installed: options.install !== false,
46805
46837
  branding: join7(directory, "branding"),
46806
46838
  animations: join7(directory, "animations"),
46807
- 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."]
46839
+ ffmpeg,
46840
+ 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."]
46808
46841
  };
46809
46842
  }
46810
46843
  async function findWorkspace(start = process.cwd()) {
@@ -47086,14 +47119,19 @@ function videoChecks(p, ctx) {
47086
47119
  if (!model.workflows.includes(p.workflow)) return issue2("workflow", "Choose a way of making video supported by the video model.");
47087
47120
  const w = p.workflow;
47088
47121
  const extend2 = w === "extend-video";
47089
- const range = videoSeconds(model, w);
47122
+ const rewrites = p.promptExpansion !== "disabled";
47123
+ const range = videoSeconds(model, w, rewrites);
47090
47124
  const seconds2 = p.seconds ?? defaultSeconds(model, w);
47091
47125
  const resolution = p.resolution ?? videoResolutions(model, w)[0];
47126
+ const options = model.workflowOptions?.[w];
47092
47127
  const prompt = PROMPT[w];
47093
47128
  if (prompt === "none" && p.prompt) issue2("prompt", `${w} takes no prompt; leave it out.`);
47094
47129
  if (!prompt && p.prompt.length < 3) issue2("prompt", "Describe the video in at least 3 characters.");
47095
47130
  if ((w === "recast" || w === "3d-to-video") && p.prompt.length > 2e3) issue2("prompt", `${w} takes a prompt of at most 2,000 characters.`);
47096
- if (!lengthFromMedia(w) && (seconds2 < range.min || seconds2 > range.max || !range.fractional && !Number.isInteger(seconds2))) issue2("seconds", "Choose a duration supported by the video model.");
47131
+ if (!lengthFromMedia(w) && (seconds2 < range.min || seconds2 > range.max || !range.fractional && !Number.isInteger(seconds2))) {
47132
+ if (!rewrites && options?.long && Number.isInteger(seconds2) && seconds2 > range.max && seconds2 <= options.long.max) issue2("seconds", `Set promptExpansion to balanced or quality for a video over ${range.max} seconds.`);
47133
+ else issue2("seconds", "Choose a duration supported by the video model.");
47134
+ }
47097
47135
  if (!videoResolutions(model, w).includes(resolution)) issue2("resolution", "Choose a resolution supported by the video model.");
47098
47136
  if (p.mode && !model.modes.includes(p.mode)) issue2("mode", "Choose a mode supported by the video model.");
47099
47137
  for (const [field, workflows] of Object.entries(TAKES)) {
@@ -47116,16 +47154,22 @@ function videoChecks(p, ctx) {
47116
47154
  if (p.damage && p.style !== "vhs") issue2("damage", "Only the VHS style takes tape damage.");
47117
47155
  if (p.soundtrack && !model.audio) issue2("soundtrack", "This video model can't use a soundtrack.");
47118
47156
  if (p.soundtrack && w !== "lip-sync" && seconds2 < 2) issue2("seconds", "A video with a soundtrack must be at least 2 seconds long.");
47119
- if (p.audioReferences.length && !model.audio) issue2("audioReferences", "Only an extension takes audio references.");
47157
+ if (p.audioReferences.length && !model.audio && w !== "reference-to-video") issue2("audioReferences", "Only an extension takes audio references.");
47120
47158
  if (p.promptExpansion !== "disabled" && !model.promptExpansion?.includes(p.promptExpansion)) issue2("promptExpansion", "This video model doesn't rewrite prompts.");
47121
47159
  if (p.seed !== void 0 && !model.seed) issue2("seed", "This video model doesn't take a seed.");
47122
47160
  if (p.seed !== void 0 && p.seed >= 2 ** 31 && (w === "lip-sync" || w === "insert-video")) issue2("seed", `${w} takes a seed below 2,147,483,648.`);
47123
47161
  const references = p.references ?? [];
47124
47162
  const unique = (shas2) => new Set(shas2).size === shas2.length;
47125
47163
  if (!unique(references) || !unique(p.referenceVideos ?? []) || !unique(p.audioReferences)) issue2("references", "Choose each reference once.");
47164
+ const shapes = options?.shapes?.[resolution];
47165
+ if (shapes && !shapes.includes(p.aspectRatio ?? (w === "text-to-video" ? "16:9" : "auto"))) issue2("aspectRatio", `At ${resolution === "2k" ? "2K" : resolution}, ${w} with this video model is ${shapes.join(" or ")}; choose one as the aspect ratio.`);
47126
47166
  if (w === "reference-to-video") {
47167
+ if (options?.referencesOnly) {
47168
+ for (const field of ["reference", "endReference", "middleReference"]) if (p[field]) issue2(field, "This video model's reference-to-video takes references only, without a first, last or middle frame.");
47169
+ }
47127
47170
  if (references.length + (p.referenceVideos?.length ?? 0) + p.audioReferences.length > MAX_REFERENCE_FILES) issue2("references", `reference-to-video takes at most ${MAX_REFERENCE_FILES} reference files in all.`);
47128
- if (!references.length && !p.referenceVideos?.length && !p.audioReferences.length && !p.reference && !p.endReference) issue2("references", "reference-to-video needs reference images, videos or audio, or a first or last frame.");
47171
+ const frames = !options?.referencesOnly && (p.reference || p.endReference);
47172
+ if (!references.length && !p.referenceVideos?.length && !p.audioReferences.length && !frames) issue2("references", `reference-to-video needs reference images, videos or audio${options?.referencesOnly ? "" : ", or a first or last frame"}.`);
47129
47173
  if (Boolean(p.middleReference) !== (p.middleSeconds !== void 0)) issue2("middleSeconds", "A middle image needs middleSeconds, and middleSeconds a middle image.");
47130
47174
  if (p.middleReference && (!p.reference || !p.endReference)) issue2("middleReference", "A middle image needs the starting and ending images too.");
47131
47175
  if (p.middleReference && !["480p", "768p"].includes(resolution)) issue2("middleReference", "A middle image needs 480p or 768p.");
@@ -47276,6 +47320,12 @@ var imageEstimate = (p) => Math.ceil((130 + 40 * p.references.length) * spriteIm
47276
47320
  var vectorEstimate = (p) => p.workflow === "image-to-vector" ? 100 : p.quality === "low" ? 130 : p.quality === "high" ? p.references.length ? 320 : 200 : 160;
47277
47321
  function videoExtras(p, facts = {}) {
47278
47322
  const w = p.workflow;
47323
+ if (spriteVideoModel(p.model).provider === "heygen") {
47324
+ if (w !== "reference-to-video") return {};
47325
+ const clips3 = (p.referenceVideos ?? []).reduce((sum, sha2) => sum + Math.min(HEYGEN_REFERENCE_CLIP_SECONDS, facts.seconds?.(sha2) ?? HEYGEN_REFERENCE_CLIP_SECONDS), 0);
47326
+ const seconds2 = clips3 + Math.max(0, (p.references?.length ?? 0) - HEYGEN_FREE_REFERENCE_IMAGES);
47327
+ return seconds2 ? { referenceSeconds: seconds2 } : {};
47328
+ }
47279
47329
  if (w !== "reference-to-video" && w !== "insert-video" && w !== "3d-to-video") return {};
47280
47330
  const images = (p.references ?? []).reduce((sum, sha2) => sum + imageReferenceTokens(facts.size?.(sha2)), 0);
47281
47331
  if (w === "3d-to-video") return { referenceTokens: images + clipReferenceTokens(p.seconds, p.resolution), generatedReferences: p.references?.length ? 0 : p.generatedReferences ?? DEFAULT_GENERATED_REFERENCES };
@@ -50931,15 +50981,15 @@ Confirm code: ${device.user_code}`);
50931
50981
  { name: "transparent", type: "boolean", description: "Image: transparent background" },
50932
50982
  { name: "references", type: "string", description: "Comma-separated blob SHAs of this project's images or sprite art, named Image 1, Image 2\u2026 in the prompt. Image: up to 4 to follow, such as a character sheet. Vector from text: up to 5, the first leading. Video: subjects or looks to keep (reference-to-video and insert-video up to 9, 3d-to-video up to 8), or recast's new people, one photo each (up to 4)" },
50933
50983
  { name: "mask", type: "string", description: "Image: edit only these rectangles of Image 1 (the first --references image), as x,y,width,height fractions of its size from the top left, up to 8 separated by semicolons; the rest is kept" },
50934
- { name: "workflow", type: "string", choices: [...VIDEO_WORKFLOWS, ...VECTOR_WORKFLOWS], description: "Video: image-to-video (default), text-to-video or extend-video; H3 Max also reference-to-video, lip-sync, camera-controls, insert-video, recast, relight, 3d-to-video and style, FLUX 3 Max keyframes-to-video, as the model's videoProfiles allow. Vector: text-to-vector (default) or image-to-vector, which traces --source" },
50984
+ { name: "workflow", type: "string", choices: [...VIDEO_WORKFLOWS, ...VECTOR_WORKFLOWS], description: "Video: image-to-video (default), text-to-video or extend-video; H3 Max also reference-to-video, lip-sync, camera-controls, insert-video, recast, relight, 3d-to-video and style, FLUX 3 Max keyframes-to-video, HeyGen Video 1 reference-to-video, as the model's videoProfiles allow. Vector: text-to-vector (default) or image-to-vector, which traces --source" },
50935
50985
  { name: "reference", type: "string", description: "Video: starting image blob SHA from this project's assets or sprites; the speaking face for lip-sync, the image the camera moves around for camera-controls. Model: the image or sprite art to build it from (image-to-3D); without one the prompt describes it" },
50936
50986
  { name: "end-reference", type: "string", description: "Video: ending image blob SHA; models with endFrameOnly accept it without --reference" },
50937
50987
  { name: "middle-reference", type: "string", description: "Reference-to-video: image blob SHA reached at --middle-seconds; needs --reference and --end-reference, 480p or 768p" },
50938
50988
  { name: "middle-seconds", type: "number", description: "Reference-to-video: when the middle image is reached, in seconds" },
50939
50989
  { name: "source", type: "string", description: "Blob SHA of a video asset in this project: the clip to extend, insert a scene into, recast or relight, or the 3D render 3d-to-video turns into footage. Vector image-to-vector: the image or sprite art to trace" },
50940
50990
  { name: "soundtrack", type: "string", description: "Video from an image or text: blob SHA of a music, speech or SFX asset pinned as the soundtrack (2 s or longer). Lip-sync: the speech to say (5 s\u201315 min), which sets the length" },
50941
- { name: "audio-references", type: "string", description: "Extension or reference-to-video: up to 3 comma-separated audio asset blob SHAs (2\u201315 s each, 15 s total) guiding the sound; refer to them as Audio 1, Audio 2\u2026 in the prompt" },
50942
- { name: "reference-videos", type: "string", description: "Reference-to-video or insert-video: up to 3 comma-separated video asset blob SHAs (2\u201315 s each, 15 s total) guiding motion, named Video 1, Video 2\u2026 in the prompt" },
50991
+ { name: "audio-references", type: "string", description: "Extension or reference-to-video: up to 3 comma-separated audio asset blob SHAs (on fal 2\u201315 s each, 15 s total) guiding the sound; refer to them as Audio 1, Audio 2\u2026 in the prompt" },
50992
+ { name: "reference-videos", type: "string", description: "Reference-to-video or insert-video: up to 3 comma-separated video asset blob SHAs (on fal 2\u201315 s each, 15 s total; HeyGen reads the first 5 s of each) guiding motion, named Video 1, Video 2\u2026 in the prompt" },
50943
50993
  { name: "camera", type: "string", description: 'Camera-controls: 2\u201312 keyframes time,azimuth,elevation,distance (time 0\u20131, angles in degrees, elevation \u221290\u201390) separated by semicolons, e.g. "0,0,0,1;1,90,15,1.2"' },
50944
50994
  { name: "keyframes", type: "string", description: 'Keyframes-to-video (FLUX 3 Max): 1\u201310 seconds,image-SHA pairs separated by semicolons, e.g. "0,SHA;2.5,SHA;5,SHA"; each image is reached at its second, on a frame of its own of the 24 fps clip, within --seconds' },
50945
50995
  { name: "insert-at", type: "number", description: "Insert-video: seconds into --source where the new scene starts (1.625\u201360)" },
@@ -50952,10 +51002,10 @@ Confirm code: ${device.user_code}`);
50952
51002
  { name: "generated-references", type: "number", description: "3d-to-video without --references: how many reference images it may draw, 1\u20138 (default 2)" },
50953
51003
  { name: "style", type: "string", choices: VIDEO_STYLES, description: "Style workflow: the preset look, at 768p for 5\u201315 whole seconds" },
50954
51004
  { name: "damage", type: "string", choices: VHS_DAMAGE_LEVELS, description: "VHS style: tape damage (fal's default medium)" },
50955
- { name: "seconds", type: "number", description: "Video or SFX duration in seconds (default 5); H3 takes fractions (6.5), FLUX 3 Max 5\u201320 whole seconds; extensions add this many seconds, insert-video's scene is 5\u201313; lip-sync, recast, relight and 3d-to-video take their input's length; SFX supports 0.5\u201330" },
51005
+ { name: "seconds", type: "number", description: "Video or SFX duration in seconds (default 5); H3 takes fractions (6.5), FLUX 3 Max 5\u201320 whole seconds, HeyGen Video 1 5\u201315 (up to 30 from an image or text with --prompt-expansion balanced or quality); extensions add this many seconds, insert-video's scene is 5\u201313; lip-sync, recast, relight and 3d-to-video take their input's length; SFX supports 0.5\u201330" },
50956
51006
  { name: "resolution", type: "string", choices: VIDEO_RESOLUTIONS, description: "Video resolution supported by the selected model and workflow (default its first)" },
50957
51007
  { name: "mode", type: "string", choices: ["cost", "speed", "quality"], description: "Video generation mode supported by the selected model (default its first)" },
50958
- { name: "prompt-expansion", type: "string", choices: PROMPT_EXPANSION_MODES, description: "Video: how much the model may rewrite the prompt (default disabled); extensions treat any other value as on. FLUX 3 Max always reasons about the prompt: quality thoroughly, any other value lightly" },
51008
+ { name: "prompt-expansion", type: "string", choices: PROMPT_EXPANSION_MODES, description: "Video: how much the model may rewrite the prompt (default disabled); extensions treat any other value as on. FLUX 3 Max always reasons about the prompt: quality thoroughly, any other value lightly. HeyGen Video 1 needs balanced or quality for clips over 15 s" },
50959
51009
  { name: "seed", type: "number", description: "Video or vector from text: seed for repeatable results, where the model takes one" },
50960
51010
  { name: "extend-output", type: "string", choices: EXTEND_OUTPUTS, description: "Extension: extended (default, source plus new footage) or continuation (a 1.625 s overlap, then the new footage)" },
50961
51011
  { name: "voice", type: "string", description: "Gemini voice name or ElevenLabs voice ID (defaults: Kore / George)" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,7 +62,7 @@ animaguru assets generate PROJECT_ID --type speech --name "Welcome" \
62
62
  --prompt "Welcome, traveler." --voice Kore --delivery "Warm and welcoming" --yes
63
63
  ```
64
64
 
65
- Images use the staff-managed route automatically. Use `--model` to select a model for video, music, speech or sound effects; discover the choices with `catalog` and the server's configured subset with `assets list`. Asset defaults are per type, independently of sprite project defaults. Image backgrounds are retained unless `--transparent` is supplied. CLI 0.17.0 adds `--references`, `--mask` and the 16:9 and 9:16 image shapes. `--references SHA,SHA` (up to four) makes an image follow this project's saved images, uploads or sprite art. To keep a recurring character consistent, generate a character sheet once and reference its `file` SHA in every later image; refer to references in the prompt as Image 1, Image 2 and so on, and say what to take from each. `--aspect-ratio` is 1:1, 3:2, 2:3, 16:9 or 9:16; use 16:9 or 9:16 for video scenes. `--mask x,y,width,height[;…]` (up to eight rectangles, in fractions of Image 1 from the top left) edits only those parts of Image 1, the first reference, and keeps the rest and Image 1's shape. Look at the image before choosing the rectangles. Videos require an image asset's `file` SHA from `assets list`, or a character's `art` SHA from `projects get`, in the same project. Validate video options against the selected model's `videoProfiles` in `catalog`; omitted video settings use five seconds and that provider's first resolution/mode. MiniMax H3 Max and H3 Max Turbo (`fal/minimax-h3-max`, `fal/minimax-h3-max-turbo`) also take `--workflow text-to-video` (with `--aspect-ratio`) and `--workflow extend-video` (with `--source VIDEO_SHA`, 2K resolution, `--extend-output`, and up to three comma-separated `--audio-references`). From images, they accept `--end-reference` with or without `--reference` and a `--soundtrack AUDIO_SHA`. All workflows take `--prompt-expansion` and `--seed`. These need CLI 0.8.0 or newer. CLI 0.18.0 adds H3 Max's (not Turbo's) `--workflow reference-to-video` (keep a character across clips from up to nine `--references`, three `--reference-videos` and three `--audio-references`, named Image 1, Video 1, Audio 1 in the prompt; optional `--reference`, `--end-reference`, `--middle-reference` with `--middle-seconds`), `lip-sync` (the face in `--reference` says `--soundtrack` speech of 5 s–15 min; no `--prompt`; `--transcribe`), `camera-controls` (`--camera "time,azimuth,elevation,distance;…"`, 2–12 keyframes around `--reference`; without a prompt only the camera moves), `insert-video` (a 5–13 s scene inside `--source` from `--insert-at` to `--resume-at`, guided by `--references`/`--reference-videos`), `recast` (people in `--references` replace those in `--source`; pass `--subject-consent` only after each person agreed), `relight` (`--source` relit from a `--lighting` sphere image; no prompt), `3d-to-video` (a 3D render `--source` into footage, from `--references` or `--generated-references`) and `style` (`--style vhs|retro-toon-70s|low-poly|hand-drawn|16bit-pixel`, `--damage` for VHS, 768p, 5–15 whole seconds). Lip-sync, recast, relight and 3d-to-video take their input's length; their dry runs, like reference-to-video's and insert-video's, are server estimates because the price depends on the files. H3 takes fractional `--seconds` (6.5). CLI 0.21.0 adds FLUX 3 Max (`fal/flux-3-max`): image-to-video (with an optional `--end-reference`) and text-to-video for 5–20 whole seconds at 720p or 1080p with its own sound and no `--soundtrack`, and `--workflow keyframes-to-video`, which passes through 1–10 `--keyframes "SECONDS,SHA;…"` images, each reached at its second within `--seconds`. It always reasons about the prompt: `--prompt-expansion quality` thoroughly, other values lightly. For Gemini speech, `--prompt` is spoken verbatim and `--delivery` is a separate instruction. Music uses Lyria; the Gemini TTS models are for speech.
65
+ Images use the staff-managed route automatically. Use `--model` to select a model for video, music, speech or sound effects; discover the choices with `catalog` and the server's configured subset with `assets list`. Asset defaults are per type, independently of sprite project defaults. Image backgrounds are retained unless `--transparent` is supplied. CLI 0.17.0 adds `--references`, `--mask` and the 16:9 and 9:16 image shapes. `--references SHA,SHA` (up to four) makes an image follow this project's saved images, uploads or sprite art. To keep a recurring character consistent, generate a character sheet once and reference its `file` SHA in every later image; refer to references in the prompt as Image 1, Image 2 and so on, and say what to take from each. `--aspect-ratio` is 1:1, 3:2, 2:3, 16:9 or 9:16; use 16:9 or 9:16 for video scenes. `--mask x,y,width,height[;…]` (up to eight rectangles, in fractions of Image 1 from the top left) edits only those parts of Image 1, the first reference, and keeps the rest and Image 1's shape. Look at the image before choosing the rectangles. Videos require an image asset's `file` SHA from `assets list`, or a character's `art` SHA from `projects get`, in the same project. Validate video options against the selected model's `videoProfiles` in `catalog`; omitted video settings use five seconds and that provider's first resolution/mode. MiniMax H3 Max and H3 Max Turbo (`fal/minimax-h3-max`, `fal/minimax-h3-max-turbo`) also take `--workflow text-to-video` (with `--aspect-ratio`) and `--workflow extend-video` (with `--source VIDEO_SHA`, 2K resolution, `--extend-output`, and up to three comma-separated `--audio-references`). From images, they accept `--end-reference` with or without `--reference` and a `--soundtrack AUDIO_SHA`. All workflows take `--prompt-expansion` and `--seed`. These need CLI 0.8.0 or newer. CLI 0.18.0 adds H3 Max's (not Turbo's) `--workflow reference-to-video` (keep a character across clips from up to nine `--references`, three `--reference-videos` and three `--audio-references`, named Image 1, Video 1, Audio 1 in the prompt; optional `--reference`, `--end-reference`, `--middle-reference` with `--middle-seconds`), `lip-sync` (the face in `--reference` says `--soundtrack` speech of 5 s–15 min; no `--prompt`; `--transcribe`), `camera-controls` (`--camera "time,azimuth,elevation,distance;…"`, 2–12 keyframes around `--reference`; without a prompt only the camera moves), `insert-video` (a 5–13 s scene inside `--source` from `--insert-at` to `--resume-at`, guided by `--references`/`--reference-videos`), `recast` (people in `--references` replace those in `--source`; pass `--subject-consent` only after each person agreed), `relight` (`--source` relit from a `--lighting` sphere image; no prompt), `3d-to-video` (a 3D render `--source` into footage, from `--references` or `--generated-references`) and `style` (`--style vhs|retro-toon-70s|low-poly|hand-drawn|16bit-pixel`, `--damage` for VHS, 768p, 5–15 whole seconds). Lip-sync, recast, relight and 3d-to-video take their input's length; their dry runs, like reference-to-video's and insert-video's, are server estimates because the price depends on the files. H3 takes fractional `--seconds` (6.5). CLI 0.21.0 adds FLUX 3 Max (`fal/flux-3-max`): image-to-video (with an optional `--end-reference`) and text-to-video for 5–20 whole seconds at 720p or 1080p with its own sound and no `--soundtrack`, and `--workflow keyframes-to-video`, which passes through 1–10 `--keyframes "SECONDS,SHA;…"` images, each reached at its second within `--seconds`. It always reasons about the prompt: `--prompt-expansion quality` thoroughly, other values lightly. CLI 0.30.0 adds HeyGen Video 1's (`heygen/heygen-video-1`) `--workflow text-to-video` (with `--aspect-ratio`) and `reference-to-video` (up to nine `--references`, three `--reference-videos`, of which it reads the first 5 s, and three `--audio-references`, named Image 1, Video 1, Audio 1 in the prompt; no `--reference`, `--end-reference` or `--middle-reference`), 1080p and 2K (16:9 or 9:16 from text or references), and `--seed`. From an image or text it runs to 30 whole seconds with `--prompt-expansion balanced` or `quality`, and every second past 15 costs more. For Gemini speech, `--prompt` is spoken verbatim and `--delivery` is a separate instruction. Music uses Lyria; the Gemini TTS models are for speech.
66
66
 
67
67
  For ElevenLabs speech, choose `eleven_v4` or `eleven_v4_turbo`, pass an ElevenLabs voice ID with `--voice` (George is the default), and put audio tags in `--prompt` rather than using `--delivery`. For sound effects, use `--type sfx`; the only model is `eleven_text_to_sound_v2`. SFX supports `--seconds` from 0.5 to 30 (default 5), `--loop`, and `--prompt-influence` from 0 to 1 (default 0.3). These additions require CLI 0.5.0 or newer; check `catalog` before using them with an installed CLI.
68
68
 
@@ -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,12 +5,13 @@ 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
16
  // The cloud Animator makes no MP4s: the user exports the film in their own browser, from the draft page.
16
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.");
@@ -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);