voxflow 1.18.2 → 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.
- package/dist/index.js +1 -1
- package/lib/commands/card-render.js +25 -8
- package/lib/commands/card-subtitle.js +25 -68
- package/lib/core/ffmpeg.js +69 -0
- package/package.json +1 -1
- package/skills/.claude-plugin/plugin.json +1 -1
- package/skills/card/SKILL.md +22 -0
|
@@ -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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
|
39
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
48
|
-
* with
|
|
45
|
+
* Resolves: { binary, source: 'system' | 'ffmpeg-static' }
|
|
46
|
+
* Rejects: Error with install hint when neither path has libass.
|
|
49
47
|
*/
|
|
50
|
-
function probeSubtitlesCapableFfmpeg() {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
368
|
+
await runCommand('ffmpeg', [
|
|
412
369
|
'-i', mp4Path,
|
|
413
370
|
'-vf', vfArg,
|
|
414
371
|
'-c:a', 'copy',
|
package/lib/core/ffmpeg.js
CHANGED
|
@@ -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
|
+
"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",
|
package/skills/card/SKILL.md
CHANGED
|
@@ -460,6 +460,28 @@ Use `references/design-languages.md` to define the card set's visual grammar ind
|
|
|
460
460
|
- `--input <path>` / `-o, --output <path>` — operate on / write to a different mp4 (otherwise: replace in place).
|
|
461
461
|
- **Quota**: 0 — pure FFmpeg pipeline.
|
|
462
462
|
|
|
463
|
+
**Card-design constraint when planning to burn subtitles** (applies during Workflow steps 4–6, *before* writing the HTML):
|
|
464
|
+
|
|
465
|
+
The default subtitle style sits at ASS `MarginV=14` with `BorderStyle=3` (opaque box) — roughly the bottom **12–18% of the canvas height** on a 1080×1920 card. Anything the card itself places in that band will be overlapped by the captions. Verified during 1.18 dogfooding: page-number footers, citation chrome, italic closing pull-quotes, and "endcap" lines at the bottom edge all got partially covered.
|
|
466
|
+
|
|
467
|
+
Rules of thumb:
|
|
468
|
+
- **Reserve the bottom 15% of every card as a captioning-safe zone**: no body copy, no citation footer, no closing pull-quote there. Move card-level chrome (page number, citation, kicker) to the *top* band, or shrink the live area so it ends at ~85% of canvas height.
|
|
469
|
+
- The editorial chrome (top kicker / series indicator) is always safe — captions live at the bottom only.
|
|
470
|
+
- **Visual anchors that span full height** (oversized type, edge-cropped illustrations, photo-led covers) are fine: subtitles sit on top of the artwork, which is the intended layering. Only legibility-critical text needs to clear the band.
|
|
471
|
+
- When a card legitimately needs a bottom-edge element (e.g. a stamp, big page number, full-bleed image), skip captions for that card by leaving its `narration` empty in `deck.json` — `card subtitle` will silently absorb the empty card into adjacent windows.
|
|
472
|
+
- If you forget and discover the collision in QA: run `card subtitle --dry-run` to inspect `subs.srt` first, then either redesign the offending card's bottom band or pass `--style 'MarginV=24,...'` to push captions higher (cards with a wider safe zone trade some readability — captions get smaller relative to the canvas).
|
|
473
|
+
|
|
474
|
+
**Card-design constraint when planning to burn subtitles** (applies during Workflow steps 4–6, *before* writing the HTML):
|
|
475
|
+
|
|
476
|
+
The default subtitle style sits at ASS `MarginV=14` with `BorderStyle=3` (opaque box) — roughly the bottom **12–18% of the canvas height** on a 1080×1920 card. Anything the card itself places in that band will be overlapped by the captions. Verified during 1.18 dogfooding: page-number footers, citation chrome, italic closing pull-quotes, and "endcap" lines at the bottom edge all got partially covered.
|
|
477
|
+
|
|
478
|
+
Rules of thumb:
|
|
479
|
+
- **Reserve the bottom 15% of every card as a captioning-safe zone**: no body copy, no citation footer, no closing pull-quote there. Move card-level chrome (page number, citation, kicker) to the *top* band, or shrink the live area so it ends at ~85% of canvas height.
|
|
480
|
+
- The editorial chrome (top kicker / series indicator) is always safe — captions live at the bottom only.
|
|
481
|
+
- **Visual anchors that span full height** (oversized type, edge-cropped illustrations, photo-led covers) are fine: subtitles sit on top of the artwork, which is the intended layering. Only legibility-critical text needs to clear the band.
|
|
482
|
+
- When a card legitimately needs a bottom-edge element (e.g. a stamp, big page number, full-bleed image), skip captions for that card by leaving its `narration` empty in `deck.json` — `card subtitle` will silently absorb the empty card into adjacent windows.
|
|
483
|
+
- If you forget and discover the collision in QA: run `card subtitle --dry-run` to inspect `subs.srt` first, then either redesign the offending card's bottom band or pass `--style 'MarginV=24,...'` to push captions higher (cards with a wider safe zone trade some readability — captions get smaller relative to the canvas).
|
|
484
|
+
|
|
463
485
|
Note: `card subtitle` also has a `silencedetect` fallback for old mp4s that pre-date the `timeline.json` emission (introduced in CLI 1.18). Prefer the timeline path; it is exact rather than heuristic.
|
|
464
486
|
|
|
465
487
|
## Asset and Source Discipline
|