@x12i/simple-video-generator-cli
v1.5.1
Published
CLI tool to generate MP4 videos by matching image (.png/.jpg/.jpeg) and audio (.wav/.mp3) pairs, plus compose multi-part timelines with optional audio mix and per-cut transitions.
Maintainers
Readme
@x12i/simple-video-generator-cli 🎬
A smart, zero-config CLI and Node.js tool to batch convert audio files into MP4 videos with matching cover images.
Automatically finds and pairs image files (.png, .jpg, .jpeg) with audio files (.wav, .mp3) sharing the exact same name in any directory, and generates .mp4 videos using embedded FFmpeg.
✨ Smart Skip: Automatically skips pairs that already have a generated video in the target directory, saving you time and compute!
🔊 Audio Fade-Out: Built-in exponential audio fade-out support, configurable via CLI, JSON configuration (metadata.json), or programmatic API!
🎤 Lyrics Burn-In: Synced lyric-video captions from .lyrics.json / .lrc / .txt / Whisper / LRCLIB.
🎞️ Compose Timeline: Join images and/or video clips into one MP4 with optional extra audio and per-cut transitions (dissolve, fadewhite, hard cuts).
🚀 Features
- 🎯 Smart Pair Matching: Matches images (
.png,.jpg,.jpeg) with audio (.wav,.mp3) by basename (e.g.,track01.png+track01.wav→track01.mp4). - ⚡ Smart Skipping: Detects existing
.mp4files and skips them automatically. Use-f/--forceto re-generate. - 🔊 Exponential Audio Fade-Out: Smooth, natural logarithmic/exponential audio fade at the end of each track (default: 4s with
curve=exp). - ⏱️ 2s End Silence Extension: Automatically extends the end of the video by 2 seconds with clean silence padding so videos don't abruptly cut off when the music ends (default: 2s with
--pad-end). - 🎤 Lyrics Subtitles: Burns short lower-third lyric captions (no karaoke flash by default). Untimed
.txtlyrics are force-aligned with Whisper; audio can also be auto-transcribed. Toggle with--subtitles/--no-subtitles. - 🎞️ Multi-Part Compose:
svg-cli composeconcatenates images/video parts with per-gap transitions (dissolve,fadewhite, hardcut), optional extra audio (keep/replace/overlay), and JSON timelines. - ⚙️ Flexible JSON Config: Optional config via
metadata.json,config.json,svg.config.json, orvideo-generator.json(album-level defaults and per-song overrides). - 📦 Bundled FFmpeg: Ships with
ffmpeg-staticandffprobe-static— works on macOS, Linux, and Windows without a system FFmpeg install. - 🛡️ Auto Odd-Dimension Fix: Automatically pads odd image dimensions (e.g., 1081px) to prevent FFmpeg H.264
yuv420pencoding errors. - 🔍 Dry Run Mode: Preview detected pairs, ready jobs, and skipped files with
-n/--dry-run. - ⚡ Concurrency Control: Process multiple files in parallel with
-c/--concurrency. - 💻 CLI & Programmatic API: Use from terminal via
npxor import directly in your Node.js scripts.
📦 Installation & Quick Start
Run immediately via npx (No installation needed)
# In the current folder containing your images and audio:
npx @x12i/simple-video-generator-cli
# With audio fade-out (default 4s exponential fade + 2s end padding):
npx @x12i/simple-video-generator-cli --fade
# In a specific folder with custom 6-second fade and 3-second end pad:
npx @x12i/simple-video-generator-cli /path/to/my-music-folder --fade 6 --pad-end 3
# Disable lyrics burn-in:
npx @x12i/simple-video-generator-cli --fade --no-subtitlesInstall globally
npm install -g @x12i/simple-video-generator-cli
# Now you can use any of these commands anywhere:
simple-video-generator [folder]
svg-cli [folder]
wav2mp4 [folder]Install as a project dependency
npm install @x12i/simple-video-generator-cli🔊 Audio Fade-Out & End Padding Configuration
Human hearing perceives volume logarithmically. Our audio fade-out applies an exponential decay curve (curve=exp) over the final seconds of each track, paired with 2 seconds of clean silence padding at the end so the video holds on the artwork with breathing room before concluding.
1. Via CLI Flags
# Enable default 4-second exponential fade + 2-second end silence padding
npx @x12i/simple-video-generator-cli --fade
# Custom fade duration (e.g. 6 seconds) with default 2s end padding
npx @x12i/simple-video-generator-cli --fade 6
# Custom fade and custom end padding (e.g. 3.5 seconds)
npx @x12i/simple-video-generator-cli --fade 5 --pad-end 3.5
# Custom curve type (exp, log, tri, qsin, hsin, etc.)
npx @x12i/simple-video-generator-cli --fade 5 --fade-curve log
# Disable end padding only
npx @x12i/simple-video-generator-cli --fade --no-pad-end
# Explicitly disable fade (overriding metadata.json)
npx @x12i/simple-video-generator-cli --no-fade2. Via metadata.json / config.json (Optional)
You can define audio fade and end padding settings directly in metadata.json in the target folder:
{
"album": {
"title": "GRAY TO LIGHT"
},
"audioFade": {
"enabled": true,
"duration": 4,
"curve": "exp",
"padEnd": 2,
"lowpass": false
},
"songs": {
"The Room Stayed Grey.mp4": {
"audioFade": {
"duration": 6,
"curve": "exp",
"padEnd": 3
}
},
"Make the Dark Earn Its Keep.mp4": {
"audioFade": false
}
}
}Shorthand is also supported:
{
"audioFade": 4,
"padEnd": 2
}Optional lowpass (boolean or cutoff Hz, e.g. 1000) applies a low-pass filter during the fade window. Configure it in JSON / the programmatic API (no dedicated CLI flag).
🎤 Lyrics Subtitles (Lyric-Video Captions)
The generator burns synced lyrics as styled ASS captions — one lower-third cue per sung line. Lyrics are resolved per song:
- Official untimed lyrics —
<stem>.txtor<stem>.lyrics.txtnext to the audio (orsongs[file].lyrics.textin metadata). Each sung line is one caption, copied verbatim.[Chorus]/[Verse 2]/[Big Chorus]headers are ignored. Whisper (or a cached word timeline) only supplies when each line appears. Result is cached as<stem>.lyrics.jsonwithsource: "aligned". <stem>.lyrics.jsonsidecar — used as-is when no official.txtis present (or when the cache is already aligned and the.txthas not changed)<stem>.lrcsidecar — classic line-level synced lyrics- LRCLIB lookup by track/artist — cached as
<stem>.lyrics.json - Auto-transcribe from audio — if Whisper is available (
whisper-cliorOPENAI_API_KEY) and there is no official.txt. Junk (♪,[music]) is stripped. Weaker on singing than a.txt.
When no lyrics are found (and no Whisper backend is available), the subtitle stage is silently skipped.
A .txt always wins over a Whisper/LRCLIB cache. Filenames need not match the audio stem exactly: matching is case-insensitive, treats grey/gray as the same, and accepts a unique small typo (Wold → World). --force-lyrics / lyrics -f re-runs Whisper; a normal generate reuses cached word times.
Automatic from audio (no lyrics file needed)
With whisper-cli installed (or an OpenAI key), just generate — Whisper extracts timed lyrics from each track:
svg-cli lyrics ./album # write .lyrics.json for every song (from .txt, or from audio)
svg-cli ./album --fade --force # burn them into MP4sDisable with --no-from-audio if you only want official .txt / .lrc / LRCLIB.
Wording from auto-transcribe follows what Whisper hears (weaker on singing than official lyrics). Drop a .txt next to the audio anytime — generate will re-align onto the official lines. Use lyrics -f or --force-lyrics only if you want a fresh Whisper pass.
Official lyrics without timestamps (recommended for original music)
Drop a .txt file next to each song (same stem as the audio, or close — case, grey/gray, small typos are OK):
The Room Stayed Grey.wav
The Room Stayed Grey.jpeg
The Room Stayed Grey.txt ← untimed lyrics, one sung line per captionThen either:
# 1. Align now (writes .lyrics.json), inspect, then re-encode
svg-cli lyrics ./album
svg-cli ./album --fade --force
# 2. Or do both in one generate pass
svg-cli ./album --fade --forceTiming needs Whisper, in this order:
OPENAI_API_KEY(or--openai-api-key) — OpenAI Whisper API, best for sung vocalswhisper-clion PATH (brew install whisper-cpp) — local, downloads a ~150 MBggml-base.en.binmodel on first run to~/.cache/simple-video-generator/whisper/
Each non-header line in the .txt is burned as one caption. Section headers like [Chorus] / [Verse 2] are ignored. Re-saving a .txt newer than an aligned .lyrics.json triggers a re-align; --force-lyrics (generate) or lyrics -f (subcommand) also re-transcribes.
Track/artist discovery for LRCLIB
Set lyrics per song (or subtitles at the root) in metadata.json:
{
"album": { "title": "GRAY TO LIGHT", "artist": "The White Stoic" },
"songs": {
"01-first-song.mp4": {
"lyrics": { "track": "First Song", "artist": "The White Stoic" }
}
}
}If no explicit lyrics key exists, track/artist are derived automatically from the song's youtube.snippet.title ("Song — Artist | Album" format) plus album.artist.
CLI flags
svg-cli --no-subtitles # disable subtitles entirely
svg-cli --subtitles # explicit (default: on when lyrics are found)
svg-cli lyrics ./album # align .txt lyrics only (no MP4 encode)
svg-cli lyrics ./album -f # re-align even if .lyrics.json existsStyling overrides
Default look is a lower-third caption (Helvetica Neue, ~50pt, dark box, no word-flash). Per song (lyrics.style) or root (subtitles.style):
{ "fontName": "Helvetica Neue", "fontSize": 50, "karaoke": false,
"borderStyle": 3, "fadeMs": 380, "marginVRatio": 0.12 }Set "karaoke": true to restore word-by-word {\k} highlighting. The generated .ass file is written next to the output MP4 so timing can be debugged independently.
⚙️ CLI Options Reference
Usage: simple-video-generator [options] [directory]
Arguments:
directory Target folder containing audio and image files (default: ".")
Options:
-v, --version Output current version
-d, --dir <path> Explicit target directory path
-o, --out-dir <path> Custom output directory (default: same as input directory)
--config <path> Custom configuration JSON file (e.g. metadata.json)
-f, --force Force overwrite of existing MP4 video files (default: false)
-n, --dry-run Preview matched pairs and skipped files without encoding (default: false)
-c, --concurrency <number> Number of simultaneous conversions (default: 1)
--fps <number> Video frame rate (default: 1)
--bitrate <rate> Audio bitrate (default: "192k")
--fade [seconds] Fade out audio at the end of each track (default: 4s)
--audio-fade [seconds] Alias for --fade
--fade-curve <curve> Audio fade curve type (exp, log, tri, qsin, default: exp)
--pad-end [seconds] Extend video end with silence padding (default: 2s)
--end-pad [seconds] Alias for --pad-end
--no-pad-end Disable video end padding extension
--no-end-pad Alias for --no-pad-end
--no-fade Disable audio fade out (overrides config in metadata.json)
--no-audio-fade Alias for --no-fade
--subtitles Burn in synced lyrics when available (default: on when found)
--no-subtitles Disable lyrics subtitles (overrides config in metadata.json)
--force-lyrics Re-align/re-transcribe even if .lyrics.json exists
--from-audio Auto-transcribe from audio when no .txt/.lrc (default)
--no-from-audio Disable automatic Whisper transcription
--openai-api-key <key> OpenAI API key for Whisper timing
--whisper-bin <path> Path to whisper.cpp binary (whisper-cli)
--whisper-model <path> Path to a whisper.cpp ggml model
--ffmpeg-path <path> Path to custom FFmpeg binary
-q, --quiet Minimal output mode (default: false)
-h, --help Display help
Commands:
lyrics [directory] Build .lyrics.json (align .txt, or auto-transcribe from audio)🛠️ Programmatic Node.js API
import {
findPairs,
generateVideos,
convertPair,
applyAudioFadeOut,
getAudioDuration,
} from "@x12i/simple-video-generator-cli";
// 1. Generate videos with exponential audio fade & 2s end padding
const result = await generateVideos("./my-folder", {
force: false,
concurrency: 2,
audioFade: {
duration: 4,
curve: "exp",
padEnd: 2,
},
onEvent: (event) => {
if (event.type === "converting_start") {
console.log(`Starting ${event.pair.stem}...`);
} else if (event.type === "converting_success") {
console.log(`Completed ${event.pair.stem} in ${event.durationMs}ms`);
}
},
});
// 2. Standalone audio fading & padding for WAV/MP3 files
await applyAudioFadeOut("./track.wav", "./track_faded.wav", {
duration: 6,
curve: "exp",
padEnd: 2,
});
// 3. Probe audio duration
const duration = await getAudioDuration("./track.wav");
console.log(`Track duration: ${duration}s`);🎞️ Compose Images & Video Clips
Join stills and/or clips into one MP4. Visual parts own the timeline. Extra audio is optional (keep / replace / overlay). Transitions are per-gap — not one global wipe.
# Hard-cut parts from the CLI
svg-cli compose --part intro.jpg --part scene.mp4 --part outro.png -o out.mp4
# JSON timeline with tone-matched transitions
svg-cli compose --spec timeline.json -o out.mp4 -n # dry-run
svg-cli compose --spec timeline.json -o out.mp4 -fExample timeline.json:
{
"width": 1280,
"height": 704,
"fps": 24,
"fit": "cover",
"mix": "keep",
"segments": [
{ "path": "vid-1.mp4", "label": "Cold Open", "duration": 15 },
{ "path": "vid-2.mp4", "label": "Beyond Art" },
{
"label": "Song Segment",
"concat": ["beat-1.mp4", "beat-2.mp4", "beat-3.mp4"]
},
{ "path": "closing.mp4" }
],
"transitions": [
{ "type": "dissolve", "duration": 0.7 },
{ "type": "fadewhite", "duration": 0.25 },
{ "type": "fadewhite", "duration": 1.1 }
]
}| Transition | Effect |
|---|---|
| cut / omit | Hard cut (also used inside concat montage groups) |
| dissolve | Cross-dissolve via ffmpeg xfade + acrossfade |
| fadewhite | Fade through white (short flash or slow land) |
Per-segment trim — keep only part of a clip:
{ "path": "vid-1.mp4", "duration": 15 }
{ "path": "scene.mp4", "in": 2, "duration": 8 }
{ "path": "scene.mp4", "in": 2, "out": 10 }duration keeps the first N seconds (after optional in). out is an end timestamp; duration = out - in.
Background bed under voiceover (mix: overlay):
{
"mix": "overlay",
"partsVolume": 1,
"audio": [
{
"path": "bed.wav",
"volume": 0.3,
"fadeIn": 2.5,
"fadeOut": 4,
"duck": true
}
]
}fadeIn / fadeOut shape the bed at the timeline edges. duck: true sidechain-compresses the bed under part audio so voice stays forward (single bed track only). CLI equivalent for one --audio file: --mix overlay --bed-fade-in 2.5 --bed-fade-out 4 --bed-duck.
Programmatic:
import { composeFromParts } from "@x12i/simple-video-generator-cli";
await composeFromParts({
specPath: "./timeline.json",
force: true,
});📜 License
MIT License.
