@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 +1 -1
- package/dist/animaguru.js +23 -8
- package/package.json +1 -1
- package/templates/video/README.md +3 -3
- package/templates/video/package.json +0 -2
- package/templates/video/scripts/audio-mix.mjs +31 -5
- package/templates/video/scripts/audio-tools.mjs +15 -19
- package/templates/video/scripts/export-mp4.mjs +24 -10
- package/templates/video/scripts/ffmpeg-binaries.mjs +54 -0
- package/templates/video/scripts/mp4-tools.mjs +33 -0
- package/templates/video/scripts/review-audio.mjs +4 -4
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -10,7 +10,7 @@ Example prompt:
|
|
|
10
10
|
|
|
11
11
|
## Preview
|
|
12
12
|
|
|
13
|
-
Requires Node.js 24
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
52
|
-
export
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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 = (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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);
|