voxflow 1.18.3 → 1.18.4

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.
@@ -30,7 +30,7 @@ const { API_BASE } = require('../core/config');
30
30
  const { buildWav, createSilence, PCM_SAMPLE_RATE } = require('../core/audio');
31
31
  const { BYTES_PER_MS } = require('../core/timeline');
32
32
  const { synthesizeTTS } = require('../core/tts-synthesizer');
33
- const { runCommand, checkFfmpeg, concatVideos } = require('../core/ffmpeg');
33
+ const { runCommand, checkFfmpeg, concatVideos, probeFfmpegWithFilter, forceFfmpegBin } = require('../core/ffmpeg');
34
34
 
35
35
  // ── Constants ─────────────────────────────────────────────────────────────────
36
36
 
@@ -371,16 +371,33 @@ async function cardRender(opts) {
371
371
  console.log(` (using ffmpeg-static${pkgVersion ? ` v${pkgVersion}` : ''} — ffmpeg ${ffmpegInfo.version})`);
372
372
  }
373
373
 
374
- // Check drawtext filter availability (needs libfreetype)
374
+ // Check drawtext filter availability (needs libfreetype). drawtext is used
375
+ // by ALL THREE of: subtitle bar, intro card, outro card — so probe whenever
376
+ // any of them is enabled (a pre-1.18.4 oversight only checked when
377
+ // !noSubtitle, leaving --no-subtitle but with-intro/outro decks to
378
+ // silently render text-less title cards on libfreetype-less ffmpeg).
379
+ //
380
+ // Homebrew's default `brew install ffmpeg` formula on macOS lacks
381
+ // libfreetype outright. Probe both system and bundled ffmpeg-static; pick
382
+ // whichever has the filter, then force the rest of this command's ffmpeg
383
+ // calls onto the same binary so half the pipeline doesn't keep hitting
384
+ // the filterless system one.
375
385
  let hasDrawtext = false;
376
386
  let cjkFontPath = null;
377
- if (!noSubtitle) {
378
- try {
379
- const { stdout } = await runCommand('ffmpeg', ['-hide_banner', '-filters']);
380
- hasDrawtext = stdout.includes('drawtext');
381
- } catch { /* unavailable */ }
387
+ const needDrawtext = !noSubtitle || !noIntro || !noOutro;
388
+ if (needDrawtext) {
389
+ const probe = await probeFfmpegWithFilter('drawtext');
390
+ hasDrawtext = !!probe;
391
+ if (probe && probe.source === 'ffmpeg-static' && ffmpegInfo.source !== 'ffmpeg-static') {
392
+ forceFfmpegBin(probe.binary);
393
+ console.log(` (system ffmpeg lacks libfreetype — using bundled ffmpeg-static for drawtext)`);
394
+ }
382
395
  if (!hasDrawtext) {
383
- console.log(` (drawtext unavailable subtitles disabled)`);
396
+ const disabledFeatures = [];
397
+ if (!noSubtitle) disabledFeatures.push('subtitles');
398
+ if (!noIntro) disabledFeatures.push('intro');
399
+ if (!noOutro) disabledFeatures.push('outro');
400
+ console.log(` (drawtext unavailable — ${disabledFeatures.join('/')} text disabled)`);
384
401
  } else {
385
402
  // Detect CJK content in titles/narrations and locate a CJK fontfile if needed.
386
403
  // ffmpeg's default drawtext font is Latin-1 only; without an explicit fontfile
@@ -26,82 +26,36 @@
26
26
 
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
- const { execFile } = require('child_process');
30
29
 
31
30
  const { parseFlag } = require('../core/args');
32
- const { runCommand, checkFfmpeg, resolveFfmpegStaticBin } = require('../core/ffmpeg');
31
+ const { runCommand, checkFfmpeg, probeFfmpegWithFilter, forceFfmpegBin } = require('../core/ffmpeg');
33
32
 
34
33
  // ── ffmpeg binary capability probe ────────────────────────────────────────────
35
34
 
36
35
  /**
37
36
  * Find an ffmpeg binary that has the `subtitles` filter (i.e. is built with
38
- * libass). Many minimal builds notably Homebrew's default `ffmpeg` formula
39
- * on macOS ship without libass; the `subtitles=` filter then fails to even
40
- * parse, with a misleading "Error parsing filterchain" message.
37
+ * libass), thin shim around the shared `probeFfmpegWithFilter` so this module
38
+ * still exports the named symbol used by tests and external callers.
41
39
  *
42
- * Strategy:
43
- * 1. Probe whatever the core ffmpeg helper resolves (system ffmpeg first).
44
- * 2. If that binary lacks the `subtitles` filter, fall back to the bundled
45
- * `ffmpeg-static` package, which is built with libass.
40
+ * Many minimal builds — notably Homebrew's default `ffmpeg` formula on macOS
41
+ * ship without libass; the `subtitles=` filter then fails to even parse,
42
+ * with a misleading "Error parsing filterchain" message. The shared probe
43
+ * tries system ffmpeg first, falls back to bundled ffmpeg-static.
46
44
  *
47
- * Returns: { binary: string, source: 'system'|'ffmpeg-static' } or throws
48
- * with a helpful message when neither path has libass.
45
+ * Resolves: { binary, source: 'system' | 'ffmpeg-static' }
46
+ * Rejects: Error with install hint when neither path has libass.
49
47
  */
50
- function probeSubtitlesCapableFfmpeg() {
51
- return new Promise((resolve, reject) => {
52
- const tryBinary = (binary, source, onFail) => {
53
- execFile(binary, ['-hide_banner', '-filters'], (err, stdout) => {
54
- if (err) return onFail();
55
- if (/\bsubtitles\b/.test(stdout || '')) {
56
- resolve({ binary, source });
57
- } else {
58
- onFail();
59
- }
60
- });
61
- };
62
-
63
- // 1. System ffmpeg via PATH (or whatever core/ffmpeg.js already resolved)
64
- tryBinary('ffmpeg', 'system', () => {
65
- // 2. Bundled ffmpeg-static.
66
- // NOTE: cannot use `require('ffmpeg-static')` directly here — when this
67
- // module is ncc-bundled into dist/index.js, ffmpeg-static's own
68
- // `path.join(__dirname, ...)` collapses to the bundle's directory and
69
- // returns a non-existent path. resolveFfmpegStaticBin() handles the
70
- // ncc-safe recovery via require.resolve('ffmpeg-static/package.json').
71
- const staticPath = resolveFfmpegStaticBin();
72
- if (!staticPath) {
73
- return reject(new Error(
74
- 'No ffmpeg with libass / `subtitles` filter found.\n' +
75
- ' System ffmpeg lacks libass (e.g. Homebrew default formula).\n' +
76
- ' Install ffmpeg-static: `npm install ffmpeg-static` (any project),\n' +
77
- ' or rebuild ffmpeg with --enable-libass.',
78
- ));
79
- }
80
- tryBinary(staticPath, 'ffmpeg-static', () => {
81
- reject(new Error(
82
- `ffmpeg-static at ${staticPath} also lacks libass. Reinstall ffmpeg-static.`,
83
- ));
84
- });
85
- });
86
- });
87
- }
88
-
89
- /**
90
- * Run a specific ffmpeg binary directly (bypassing core/ffmpeg's resolveFfmpegBin
91
- * cache, which prefers system ffmpeg). Used by the subtitle burn step when the
92
- * system binary lacks libass and we have to force ffmpeg-static.
93
- */
94
- function runSpecificFfmpeg(binary, args) {
95
- return new Promise((resolve, reject) => {
96
- execFile(binary, args, { timeout: 600_000 }, (err, stdout, stderr) => {
97
- if (err) {
98
- err.stderr = stderr;
99
- err.stdout = stdout;
100
- return reject(err);
101
- }
102
- resolve({ stdout, stderr });
103
- });
104
- });
48
+ async function probeSubtitlesCapableFfmpeg() {
49
+ const result = await probeFfmpegWithFilter('subtitles');
50
+ if (!result) {
51
+ throw new Error(
52
+ 'No ffmpeg with libass / `subtitles` filter found.\n' +
53
+ ' System ffmpeg lacks libass (e.g. Homebrew default formula).\n' +
54
+ ' Install ffmpeg-static: `npm install ffmpeg-static` (any project),\n' +
55
+ ' or rebuild ffmpeg with --enable-libass.',
56
+ );
57
+ }
58
+ return result;
105
59
  }
106
60
 
107
61
  // ── Constants ─────────────────────────────────────────────────────────────────
@@ -402,13 +356,16 @@ async function cardSubtitle(opts) {
402
356
  // (notably Homebrew's default formula on macOS) ship without it and fail
403
357
  // at filtergraph parse time with a misleading "Error parsing filterchain"
404
358
  // message. Probe both system ffmpeg and ffmpeg-static; pick the one that
405
- // actually has the filter.
359
+ // actually has the filter, then force the rest of the run onto it via
360
+ // forceFfmpegBin so plain runCommand('ffmpeg', ...) calls land on the
361
+ // libass-capable binary.
406
362
  const { binary: ffmpegBin, source: ffmpegSource } = await probeSubtitlesCapableFfmpeg();
407
363
  if (ffmpegSource === 'ffmpeg-static') {
408
364
  console.log(` (system ffmpeg lacks libass — using bundled ffmpeg-static)`);
409
365
  }
366
+ forceFfmpegBin(ffmpegBin);
410
367
 
411
- await runSpecificFfmpeg(ffmpegBin, [
368
+ await runCommand('ffmpeg', [
412
369
  '-i', mp4Path,
413
370
  '-vf', vfArg,
414
371
  '-c:a', 'copy',
@@ -68,6 +68,70 @@ function resolveFfmpegBin() {
68
68
  return _resolvedFfmpegPath;
69
69
  }
70
70
 
71
+ /**
72
+ * Override the cached ffmpeg binary used by `runCommand('ffmpeg', ...)` for
73
+ * the rest of this process.
74
+ *
75
+ * Used when a command (`card render`, `card subtitle`) discovers via
76
+ * `probeFfmpegWithFilter` that the resolved system ffmpeg lacks a required
77
+ * filter (libfreetype/drawtext, libass/subtitles) and needs to switch the
78
+ * whole pipeline to bundled ffmpeg-static. Setting it once at the top of the
79
+ * command keeps every downstream `runCommand`/`concatVideos` call on the
80
+ * same binary — without that, half the pipeline would keep hitting the
81
+ * filterless system ffmpeg.
82
+ *
83
+ * Idempotent. Pass null to reset (used by tests).
84
+ */
85
+ function forceFfmpegBin(binary) {
86
+ _resolvedFfmpegPath = binary;
87
+ }
88
+
89
+ /**
90
+ * Find an ffmpeg binary that has the named filter compiled in. Probes the
91
+ * resolved system ffmpeg first; if it lacks the filter, falls back to the
92
+ * bundled ffmpeg-static. Used to recover from minimal ffmpeg builds — most
93
+ * importantly Homebrew's default formula on macOS, which ships without
94
+ * libfreetype (drawtext) AND without libass (subtitles), so any command
95
+ * relying on those filters silently degrades or fatally fails on stock
96
+ * `brew install ffmpeg` machines.
97
+ *
98
+ * Returns: { binary: string, source: 'system' | 'ffmpeg-static' } when a
99
+ * capable binary is found, or null when no installed ffmpeg has
100
+ * the requested filter.
101
+ *
102
+ * @param {string} filterName e.g. 'drawtext', 'subtitles'
103
+ * @returns {Promise<{binary: string, source: 'system'|'ffmpeg-static'} | null>}
104
+ */
105
+ function probeFfmpegWithFilter(filterName) {
106
+ const filterRe = new RegExp(`\\b${filterName}\\b`);
107
+
108
+ return new Promise((resolve) => {
109
+ const tryBinary = (binary, source, onMiss) => {
110
+ execFile(binary, ['-hide_banner', '-filters'], { timeout: 10_000 }, (err, stdout) => {
111
+ if (err) return onMiss();
112
+ if (filterRe.test(stdout || '')) resolve({ binary, source });
113
+ else onMiss();
114
+ });
115
+ };
116
+
117
+ // 1. Whatever resolveFfmpegBin already picked (system if on PATH, else
118
+ // static). Re-using the cache keeps a single source of truth for
119
+ // "which ffmpeg are we using right now".
120
+ const primary = resolveFfmpegBin();
121
+ const primarySource = primary === 'ffmpeg' ? 'system' : 'ffmpeg-static';
122
+
123
+ tryBinary(primary, primarySource, () => {
124
+ // 2. If the primary missed AND it wasn't already ffmpeg-static, try
125
+ // ffmpeg-static. This is the common Homebrew case: system ffmpeg
126
+ // on PATH but missing libfreetype/libass.
127
+ if (primarySource === 'ffmpeg-static') return resolve(null);
128
+ const staticPath = resolveFfmpegStaticBin();
129
+ if (!staticPath) return resolve(null);
130
+ tryBinary(staticPath, 'ffmpeg-static', () => resolve(null));
131
+ });
132
+ });
133
+ }
134
+
71
135
  /**
72
136
  * Run a command via execFile and return { stdout, stderr }.
73
137
  * Rejects on non-zero exit code or spawn error.
@@ -563,4 +627,9 @@ module.exports = {
563
627
  detectCjkFont,
564
628
  // ffmpeg-static path resolution (used by card-subtitle's libass fallback)
565
629
  resolveFfmpegStaticBin,
630
+ // Filter-capability probe + binary override (used by card-render / card-subtitle
631
+ // to recover from filterless ffmpeg builds — Homebrew default lacks both
632
+ // libfreetype/drawtext and libass/subtitles)
633
+ probeFfmpegWithFilter,
634
+ forceFfmpegBin,
566
635
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voxflow",
3
- "version": "1.18.3",
3
+ "version": "1.18.4",
4
4
  "description": "AI audio content creation CLI — stories, podcasts, narration, dubbing, transcription, translation, and video translation with TTS",
5
5
  "bin": {
6
6
  "voxflow": "./dist/index.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voxflow",
3
- "version": "1.18.3",
3
+ "version": "1.18.4",
4
4
  "description": "AI voice CLI bundled as 6 skills (hub, podcast, transcribe, video, slice, card). Synthesize speech in 200+ voices across 40+ languages, generate multi-speaker AI podcasts, transcribe audio/video with word-level timestamps, dub videos from SRT subtitles, run end-to-end video translation, turn long articles into vertical card video reels via Remotion, and turn text into polished shareable card images or narrated card videos. Backed by a hosted TTS/ASR/LLM/render service with per-user quota (free tier 10K/mo).",
5
5
  "author": {
6
6
  "name": "VoxFlow",