@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.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 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 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", ...inputs, "-filter_complex", filters.join(";"), "-map", "[out]", "-ac", "1", "-ar", String(SAMPLE_RATE), "-c:a", "pcm_s16le", track]);
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 > 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
+ 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);