@docyrus/animaguru 0.29.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
 
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.29.1",
19765
19765
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
19766
19766
  type: "module",
19767
19767
  bin: {
@@ -45361,7 +45361,7 @@ async function installSkills(options) {
45361
45361
  import { spawn } from "node:child_process";
45362
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";
45363
45363
  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";
45364
+ import { fileURLToPath, pathToFileURL } from "node:url";
45365
45365
 
45366
45366
  // shared/locales/en/animation-styles.json
45367
45367
  var animation_styles_default = {
@@ -46353,7 +46353,7 @@ var animation_styles_default = {
46353
46353
  "Rack focus between details",
46354
46354
  "Callouts that track parts"
46355
46355
  ],
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.",
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.",
46357
46357
  family: "3d",
46358
46358
  example: true
46359
46359
  },
@@ -46370,7 +46370,7 @@ var animation_styles_default = {
46370
46370
  "Crossfaded gestures",
46371
46371
  "A camera that follows the character"
46372
46372
  ],
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.",
46373
+ previewCaption: "A generated, auto-rigged baker walks across a painted square, turns to camera, waves and explains, his shadow falling over the painting.",
46374
46374
  family: "3d",
46375
46375
  example: true
46376
46376
  },
@@ -46387,7 +46387,7 @@ var animation_styles_default = {
46387
46387
  "Little cars and figures on loops",
46388
46388
  "Focus pulled through the scene"
46389
46389
  ],
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.",
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.",
46391
46391
  family: "3d",
46392
46392
  example: true
46393
46393
  },
@@ -46642,6 +46642,15 @@ async function installDependencies(directory, options) {
46642
46642
  child.once("close", (code) => code === 0 ? resolveInstall() : reject(new Error(`npm install exited with code ${code}.`)));
46643
46643
  });
46644
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
+ }
46645
46654
  var STARTER_STORY = {
46646
46655
  version: 1,
46647
46656
  start: "opening",
@@ -46723,13 +46732,15 @@ async function createVideoProject(options, installer = installDependencies) {
46723
46732
  if (options.story) await writeStarterStory(directory);
46724
46733
  if (options.quiz) await writeStarterQuiz(directory, options.story);
46725
46734
  if (options.install !== false) {
46726
- options.log?.("Installing PixiJS, Vite, TypeScript, and the MP4 tools\u2026");
46735
+ options.log?.("Installing PixiJS, Vite and TypeScript\u2026");
46727
46736
  try {
46728
46737
  await installer(directory, options);
46729
46738
  } catch (error62) {
46730
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.`);
46731
46740
  }
46732
46741
  }
46742
+ const ffmpeg = await ffmpegProblem();
46743
+ if (ffmpeg) options.log?.(`Note: ${ffmpeg}`);
46733
46744
  options.log?.(`Ready. Open ${directory} in Codex or Claude Code and describe your video.
46734
46745
  ${options.install === false ? "Run npm install, then " : "Run "}npm run dev inside that folder to preview.`);
46735
46746
  return {
@@ -46741,7 +46752,8 @@ ${options.install === false ? "Run npm install, then " : "Run "}npm run dev insi
46741
46752
  installed: options.install !== false,
46742
46753
  skills: [...skills, ...pixiSkills],
46743
46754
  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."]
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."]
46745
46757
  };
46746
46758
  }
46747
46759
  async function createWorkspace(options, installer = installDependencies) {
@@ -46794,6 +46806,8 @@ async function createWorkspace(options, installer = installDependencies) {
46794
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.`);
46795
46807
  }
46796
46808
  }
46809
+ const ffmpeg = result.ffmpeg;
46810
+ if (!ffmpeg.ready) options.log?.(`Note: ${ffmpeg.problem}`);
46797
46811
  options.log?.(`Workspace ready: ${directory}. Open it in Codex or Claude Code with your website or brand guide, then create an animation.`);
46798
46812
  return {
46799
46813
  type: "workspace",
@@ -46804,7 +46818,8 @@ async function createWorkspace(options, installer = installDependencies) {
46804
46818
  installed: options.install !== false,
46805
46819
  branding: join7(directory, "branding"),
46806
46820
  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."]
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."]
46808
46823
  };
46809
46824
  }
46810
46825
  async function findWorkspace(start = process.cwd()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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);