@docyrus/animaguru 0.32.0 → 0.32.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/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.32.
|
|
19764
|
+
version: "0.32.1",
|
|
19765
19765
|
description: "Agent-friendly AnimaGuru CLI built with CrustJS",
|
|
19766
19766
|
type: "module",
|
|
19767
19767
|
bin: {
|
package/package.json
CHANGED
|
@@ -73,7 +73,7 @@ npm run export:mp4 -- --watermark
|
|
|
73
73
|
|
|
74
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. 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
|
|
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 fifteen minutes. Set duration to an exact number of frames.
|
|
77
77
|
|
|
78
78
|
## Capture API
|
|
79
79
|
|
|
@@ -92,6 +92,29 @@ export function placeVoiceover(segments, { start = 0.25, gap = 0.35, tempo = 1,
|
|
|
92
92
|
return { tempo, segments: placed, captions, voEnd: round(previousEnd ?? start) };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The filter graph that cuts each placed segment's pieces from its input (input N is segment N) and mixes them into one
|
|
97
|
+
* mono track of the film's length, labelled [out].
|
|
98
|
+
*/
|
|
99
|
+
export function voiceTrackGraph(segments, { tempo = 1, duration }) {
|
|
100
|
+
const filters = [], labels = [];
|
|
101
|
+
for (const [index, segment] of segments.entries()) {
|
|
102
|
+
const parts = segment.pieces.map((_, piece) => `[s${index}p${piece}]`);
|
|
103
|
+
filters.push(`[${index}:a]aresample=${SAMPLE_RATE},aformat=channel_layouts=mono,asplit=${parts.length}${parts.join("")}`);
|
|
104
|
+
for (const [pieceIndex, piece] of segment.pieces.entries()) {
|
|
105
|
+
const label = `[v${labels.length}]`;
|
|
106
|
+
// Silence padded onto the reversed piece becomes its lead-in. FFmpeg 9's adelay leaves its own lead-in untimed
|
|
107
|
+
// after atrim, so amix started at the first real sound and every line came early by the first line's delay.
|
|
108
|
+
// atempo drops its first 1024 samples (21 ms), so it runs only when the tempo changes.
|
|
109
|
+
const lead = Math.round(piece.at * SAMPLE_RATE), padLead = lead ? `apad=pad_len=${lead},` : "", speed = tempo === 1 ? "" : `atempo=${tempo},`;
|
|
110
|
+
filters.push(`[s${index}p${pieceIndex}]atrim=${piece.trim[0]}:${piece.trim[1]},asetpts=PTS-STARTPTS,${speed}afade=t=in:d=0.015,areverse,afade=t=in:d=0.04,${padLead}areverse${label}`);
|
|
111
|
+
labels.push(label);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0,apad=whole_len=${Math.round(duration * SAMPLE_RATE)},atrim=0:${duration}[out]`);
|
|
115
|
+
return filters.join(";");
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
async function existing(...files) {
|
|
96
119
|
for (const file of files) { try { await access(file); return file; } catch { /* try the next candidate */ } }
|
|
97
120
|
return null;
|
|
@@ -129,22 +152,10 @@ async function main() {
|
|
|
129
152
|
const lastPiece = timing.segments.at(-1).pieces.at(-1);
|
|
130
153
|
if (lastPiece.end > duration) throw new Error(`The voiceover runs to ${lastPiece.end.toFixed(2)} s, past the ${duration} s film. Shorten gaps, raise tempo or trim the script.`);
|
|
131
154
|
|
|
132
|
-
const inputs = [], filters = [], labels = [];
|
|
133
|
-
for (const [index, segment] of timing.segments.entries()) {
|
|
134
|
-
inputs.push("-i", segments[index].file);
|
|
135
|
-
const parts = segment.pieces.map((_, piece) => `[s${index}p${piece}]`);
|
|
136
|
-
filters.push(`[${index}:a]aresample=${SAMPLE_RATE},aformat=channel_layouts=mono,asplit=${parts.length}${parts.join("")}`);
|
|
137
|
-
for (const [pieceIndex, piece] of segment.pieces.entries()) {
|
|
138
|
-
const label = `[v${labels.length}]`;
|
|
139
|
-
filters.push(`[s${index}p${pieceIndex}]atrim=${piece.trim[0]}:${piece.trim[1]},asetpts=PTS-STARTPTS,atempo=${tempo},afade=t=in:d=0.015,areverse,afade=t=in:d=0.04,areverse,adelay=${Math.round(piece.at * 1000)}:all=1${label}`);
|
|
140
|
-
labels.push(label);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0,apad=whole_len=${Math.round(duration * SAMPLE_RATE)},atrim=0:${duration}[out]`);
|
|
144
155
|
const work = path.join(base, "production/audio/work");
|
|
145
156
|
await mkdir(work, { recursive: true });
|
|
146
157
|
const track = path.join(work, "vo-track.wav");
|
|
147
|
-
await ffmpeg(["-y", "-loglevel", "error", ...
|
|
158
|
+
await ffmpeg(["-y", "-loglevel", "error", ...segments.flatMap((segment) => ["-i", segment.file]), "-filter_complex", voiceTrackGraph(timing.segments, { tempo, duration }), "-map", "[out]", "-ac", "1", "-ar", String(SAMPLE_RATE), "-c:a", "pcm_s16le", track]);
|
|
148
159
|
const timingFile = path.join(base, "production/audio/timing.json");
|
|
149
160
|
await writeJson(timingFile, { tempo, start: timing.segments[0].start, segments: timing.segments, captions: timing.captions, voEnd: timing.voEnd });
|
|
150
161
|
|
|
@@ -38,7 +38,7 @@ if (context.story) throw new Error("This film is a branching story (story.json):
|
|
|
38
38
|
if (context.quiz) throw new Error("This film has a quiz (quiz.json): it plays as HTML5 only, because its questions wait for a viewer's answers. Deliver it with npm run export:html or animaguru submit.");
|
|
39
39
|
const config = context.config;
|
|
40
40
|
const { width, height, fps, duration, language } = config;
|
|
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 >
|
|
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 > 900 || 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 900s in film.config.json.");
|
|
42
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.");
|
|
43
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).");
|
|
44
44
|
const languageTag = mp4Language(language);
|