npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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.wavtrack01.mp4).
  • ⚡ Smart Skipping: Detects existing .mp4 files and skips them automatically. Use -f / --force to 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 .txt lyrics are force-aligned with Whisper; audio can also be auto-transcribed. Toggle with --subtitles / --no-subtitles.
  • 🎞️ Multi-Part Compose: svg-cli compose concatenates images/video parts with per-gap transitions (dissolve, fadewhite, hard cut), optional extra audio (keep / replace / overlay), and JSON timelines.
  • ⚙️ Flexible JSON Config: Optional config via metadata.json, config.json, svg.config.json, or video-generator.json (album-level defaults and per-song overrides).
  • 📦 Bundled FFmpeg: Ships with ffmpeg-static and ffprobe-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 yuv420p encoding 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 npx or 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-subtitles

Install 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-fade

2. 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:

  1. Official untimed lyrics<stem>.txt or <stem>.lyrics.txt next to the audio (or songs[file].lyrics.text in 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.json with source: "aligned".
  2. <stem>.lyrics.json sidecar — used as-is when no official .txt is present (or when the cache is already aligned and the .txt has not changed)
  3. <stem>.lrc sidecar — classic line-level synced lyrics
  4. LRCLIB lookup by track/artist — cached as <stem>.lyrics.json
  5. Auto-transcribe from audio — if Whisper is available (whisper-cli or OPENAI_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 (WoldWorld). --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 MP4s

Disable 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 caption

Then 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 --force

Timing needs Whisper, in this order:

  • OPENAI_API_KEY (or --openai-api-key) — OpenAI Whisper API, best for sung vocals
  • whisper-cli on PATH (brew install whisper-cpp) — local, downloads a ~150 MB ggml-base.en.bin model 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 exists

Styling 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 -f

Example 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.