ctx-reels 1.0.0

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/.env.example ADDED
@@ -0,0 +1,38 @@
1
+ # ===========================
2
+ # CTX-Reels Configuration
3
+ # ===========================
4
+
5
+ # --- API Keys ---
6
+ GEMINI_API_KEY=""
7
+ ELEVENLABS_API_KEY=""
8
+
9
+ # --- Voice Engine ---
10
+ # Options: "kokoro" (free, local), "edge-tts" (free, cloud), "elevenlabs" (paid, premium)
11
+ VOICE_ENGINE="kokoro"
12
+
13
+ # Voice IDs (optional, defaults are good)
14
+ # KOKORO_VOICE="af_heart"
15
+ # EDGE_TTS_VOICE="en-US-ChristopherNeural"
16
+ # ELEVENLABS_VOICE_ID="pNInz6obpgDQGcFmaJcg"
17
+
18
+ # --- Paths ---
19
+ FFMPEG_PATH="/usr/local/bin/ffmpeg"
20
+ BG_ASSET="./templates/backgrounds/minecraft.mp4"
21
+ # PYTHON_PATH="python3"
22
+
23
+ # --- Output Directory ---
24
+ # Default: ~/Desktop/ctx
25
+ # CTX_OUTPUT="/path/to/custom/output"
26
+
27
+ # --- Subtitle Styling (1080x1920) ---
28
+ SUB_FONT=Impact
29
+ SUB_SIZE=38
30
+ SUB_COLOR=&H0000FFFF
31
+ SUB_OUTLINE_COLOR=&H00000000
32
+ SUB_BG_COLOR=&H80000000
33
+ SUB_BORDER_STYLE=1
34
+ SUB_ALIGNMENT=10
35
+ SUB_MARGIN_V=480
36
+
37
+ # --- AI Models ---
38
+ GEMINI_MODEL=gemini-2.5-flash
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # 🎬 CTX-Reels
2
+
3
+ **AI-powered faceless short-form video generator.** Create TikTok / Reels / Shorts from a topic in one command.
4
+
5
+ ```
6
+ ctx generate -t "Cristiano Ronaldo"
7
+ ```
8
+
9
+ > Script → Voice → Karaoke Subtitles → Video — all automated.
10
+
11
+ ---
12
+
13
+ ## ✨ Features
14
+
15
+ - **One-command video generation** from any topic
16
+ - **3 voice engines**: Kokoro (free, local), Edge-TTS (free, cloud), ElevenLabs (premium)
17
+ - **Word-level karaoke subtitles** — active word highlights in white against yellow text
18
+ - **Smart script writing** via Gemini AI with viral hooks and punchy delivery
19
+ - **Batch generation** from a JSON file of topics
20
+ - **Customizable** — fonts, colors, backgrounds, voices, output dir
21
+
22
+ ## 📦 Install
23
+
24
+ ```bash
25
+ npm install -g ctx-reels
26
+ ctx setup
27
+ ```
28
+
29
+ ### Prerequisites
30
+
31
+ | Dependency | Required | Install |
32
+ |---|---|---|
33
+ | **Node.js** ≥ 18 | ✅ | [nodejs.org](https://nodejs.org) |
34
+ | **FFmpeg** + FFprobe | ✅ | `brew install ffmpeg` / `apt install ffmpeg` |
35
+ | **Python 3** | ✅ (for subtitles) | [python.org](https://python.org) |
36
+ | **Gemini API Key** | ✅ (for scripts) | [aistudio.google.com](https://aistudio.google.com) |
37
+
38
+ `ctx setup` will create a Python virtualenv and install `faster-whisper` automatically.
39
+
40
+ ## 🚀 Quick Start
41
+
42
+ ```bash
43
+ # 1. Generate a full reel
44
+ ctx generate -t "Elon Musk"
45
+
46
+ # 2. Batch generate from a list
47
+ echo '["AI Revolution", "Space Exploration", "Bitcoin"]' > topics.json
48
+ ctx batch topics.json
49
+
50
+ # 3. Run individual steps
51
+ ctx script -t "My Topic"
52
+ ctx voice -s output/scripts/my-topic.txt
53
+ ctx subs -a output/audio/my-topic.mp3
54
+ ctx render -a output/audio/my-topic.mp3 -s output/subtitles/my-topic.ass
55
+ ```
56
+
57
+ ## 🎤 Voice Engines
58
+
59
+ | Engine | Cost | Quality | Setup |
60
+ |---|---|---|---|
61
+ | **Kokoro** (default) | Free | ⭐⭐⭐⭐ | Automatic (downloads 80MB model on first run) |
62
+ | **Edge-TTS** | Free | ⭐⭐⭐ | `pip install edge-tts` |
63
+ | **ElevenLabs** | Paid | ⭐⭐⭐⭐⭐ | API key in `.env` |
64
+
65
+ ```bash
66
+ # List available voices for your engine
67
+ ctx voices
68
+
69
+ # Switch engine in .env
70
+ VOICE_ENGINE="kokoro" # or "edge-tts" or "elevenlabs"
71
+ ```
72
+
73
+ ## ⚙️ Configuration
74
+
75
+ Edit `.env` (created by `ctx setup`):
76
+
77
+ ```env
78
+ # API Keys
79
+ GEMINI_API_KEY="your-key-here"
80
+
81
+ # Voice
82
+ VOICE_ENGINE="kokoro"
83
+ KOKORO_VOICE="af_heart"
84
+
85
+ # Output directory (default: ~/Desktop/ctx)
86
+ CTX_OUTPUT="/path/to/custom/output"
87
+
88
+ # Background video/image
89
+ BG_ASSET="./templates/backgrounds/minecraft.mp4"
90
+ ```
91
+
92
+ See [.env.example](.env.example) for all options.
93
+
94
+ ## 📁 Output Structure
95
+
96
+ ```
97
+ ~/Desktop/ctx/
98
+ ├── scripts/ # Generated text scripts
99
+ ├── audio/ # Generated voice audio (MP3)
100
+ ├── subtitles/ # Karaoke subtitles (ASS)
101
+ └── reels/ # Final rendered videos (MP4)
102
+ ```
103
+
104
+ ## 🛠 CLI Reference
105
+
106
+ | Command | Description |
107
+ |---|---|
108
+ | `ctx generate -t <topic>` | Full pipeline: script → voice → subs → video |
109
+ | `ctx generate -t <topic> -f` | Force regenerate (overwrite existing) |
110
+ | `ctx batch <file.json>` | Batch generate from JSON array of topics |
111
+ | `ctx script -t <topic>` | Generate script only |
112
+ | `ctx voice -s <script.txt>` | Generate voice only |
113
+ | `ctx subs -a <audio.mp3>` | Generate subtitles only |
114
+ | `ctx render -a <audio> -s <subs>` | Render video only |
115
+ | `ctx voices` | List available voices |
116
+ | `ctx setup` | First-time dependency setup |
117
+
118
+ ## 📄 License
119
+
120
+ MIT — by [cecamarty](https://github.com/cecamarty)
package/bin/ctx.js ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import dotenv from 'dotenv';
5
+ import chalk from 'chalk';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+
9
+ import { generateScript } from '../src/modules/script.js';
10
+ import { generateVoice } from '../src/modules/voice.js';
11
+ import { generateSubs } from '../src/modules/subs.js';
12
+ import { renderReel } from '../src/modules/engine.js';
13
+ import { runPipeline, runBatchPipeline } from '../src/modules/pipeline.js';
14
+
15
+ // Load environment variables
16
+ dotenv.config();
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name('ctx')
22
+ .description('Content Transformation eXecutor - CLI for automated faceless AI video generation')
23
+ .version('1.0.0');
24
+
25
+ // Generate Command (Full Pipeline)
26
+ program
27
+ .command('generate')
28
+ .description('Generate a full reel from a topic')
29
+ .requiredOption('-t, --topic <topic>', 'Topic to generate the script and reel about')
30
+ .option('--script-file <path>', 'Path to an existing script file to skip generation')
31
+ .option('--skip-subs', 'Skip subtitle generation and rendering', false)
32
+ .option('-f, --force', 'Force overwrite existing files', false)
33
+ .action(async (options) => {
34
+ await runPipeline(options.topic, {
35
+ skipSubs: options.skipSubs,
36
+ scriptPath: options.scriptFile,
37
+ force: options.force
38
+ });
39
+ });
40
+
41
+ // Batch Command
42
+ program
43
+ .command('batch')
44
+ .description('Batch generate reels from a JSON file of topics')
45
+ .argument('<inputFile>', 'JSON file containing an array of topics')
46
+ .option('--skip-subs', 'Skip subtitle generation and rendering', false)
47
+ .option('-f, --force', 'Force overwrite existing files', false)
48
+ .action(async (inputFile, options) => {
49
+ try {
50
+ const fullPath = path.resolve(inputFile);
51
+ if (!fs.existsSync(fullPath)) {
52
+ console.error(chalk.red(`[Batch] Error: File not found at ${fullPath}`));
53
+ process.exit(1);
54
+ }
55
+ const data = fs.readFileSync(fullPath, 'utf8');
56
+ const topics = JSON.parse(data);
57
+ if (!Array.isArray(topics)) {
58
+ console.error(chalk.red(`[Batch] Error: JSON file must contain an array of strings (topics).`));
59
+ process.exit(1);
60
+ }
61
+ await runBatchPipeline(topics, {
62
+ skipSubs: options.skipSubs,
63
+ force: options.force
64
+ });
65
+ } catch (err) {
66
+ console.error(chalk.red(`[Batch] Error reading or parsing file: ${err.message}`));
67
+ }
68
+ });
69
+
70
+ // Script-Only Command
71
+ program
72
+ .command('script')
73
+ .description('Generate script only')
74
+ .requiredOption('-t, --topic <topic>', 'Topic to generate the script about')
75
+ .action(async (options) => {
76
+ console.log(chalk.blue(`Generating script for topic: "${options.topic}"`));
77
+ await generateScript(options.topic);
78
+ });
79
+
80
+ // Voice-Only Command
81
+ program
82
+ .command('voice')
83
+ .description('Generate audio only from a script file')
84
+ .requiredOption('-s, --script <scriptFile>', 'Path to script text file')
85
+ .action(async (options) => {
86
+ await generateVoice(options.script);
87
+ });
88
+
89
+ // Subs-Only Command
90
+ program
91
+ .command('subs')
92
+ .description('Generate subtitles only from an audio file')
93
+ .requiredOption('-a, --audio <audioFile>', 'Path to audio file (MP3)')
94
+ .action(async (options) => {
95
+ await generateSubs(options.audio);
96
+ });
97
+
98
+ // Render-Only Command
99
+ program
100
+ .command('render')
101
+ .description('Render a reel from existing audio and optionally subtitles')
102
+ .requiredOption('-a, --audio <audioFile>', 'Path to generated audio (MP3)')
103
+ .option('-s, --subs <subsFile>', 'Path to generated subtitles (SRT or ASS)')
104
+ .action(async (options) => {
105
+ await renderReel(options.audio, options.subs);
106
+ });
107
+
108
+ // Setup Command
109
+ program
110
+ .command('setup')
111
+ .description('Run first-time setup: check dependencies, create Python venv, install faster-whisper')
112
+ .action(async () => {
113
+ const { exec } = await import('child_process');
114
+ const { promisify } = await import('util');
115
+ const { fileURLToPath } = await import('url');
116
+ const execPromise = promisify(exec);
117
+
118
+ const __filename = fileURLToPath(import.meta.url);
119
+ const __dirname = path.dirname(__filename);
120
+ const setupScript = path.resolve(__dirname, '../scripts/setup.sh');
121
+
122
+ if (!fs.existsSync(setupScript)) {
123
+ console.error(chalk.red('[Setup] Error: setup.sh not found. Reinstall ctx-reels.'));
124
+ return;
125
+ }
126
+
127
+ try {
128
+ const { stdout, stderr } = await execPromise(`bash "${setupScript}"`, { stdio: 'inherit' });
129
+ if (stdout) console.log(stdout);
130
+ if (stderr) console.log(stderr);
131
+ } catch (err) {
132
+ console.error(chalk.red(`[Setup] Error: ${err.message}`));
133
+ }
134
+ });
135
+
136
+ // Voices Command
137
+ program
138
+ .command('voices')
139
+ .description('List available voices for the current engine')
140
+ .action(async () => {
141
+ const engine = process.env.VOICE_ENGINE || 'edge-tts';
142
+ console.log(chalk.blue(`\nVoice Engine: ${engine}\n`));
143
+
144
+ if (engine === 'kokoro') {
145
+ console.log(chalk.white('Available Kokoro voices:'));
146
+ const voices = [
147
+ 'af_heart - American Female (Heart) ⭐ Default',
148
+ 'af_bella - American Female (Bella)',
149
+ 'af_sarah - American Female (Sarah)',
150
+ 'af_nicole - American Female (Nicole)',
151
+ 'am_adam - American Male (Adam)',
152
+ 'am_michael - American Male (Michael)',
153
+ 'bf_emma - British Female (Emma)',
154
+ 'bf_isabella - British Female (Isabella)',
155
+ 'bm_george - British Male (George)',
156
+ 'bm_lewis - British Male (Lewis)',
157
+ ];
158
+ voices.forEach(v => console.log(` ${v}`));
159
+ console.log(chalk.gray('\nSet voice: KOKORO_VOICE=af_bella in .env'));
160
+ } else if (engine === 'edge-tts') {
161
+ console.log(chalk.white('Run this to see all Edge-TTS voices:'));
162
+ console.log(chalk.gray(' edge-tts --list-voices | grep en-US'));
163
+ console.log(chalk.gray('\nSet voice: EDGE_TTS_VOICE=en-US-JennyNeural in .env'));
164
+ } else if (engine === 'elevenlabs') {
165
+ console.log(chalk.white('ElevenLabs voices are configured by voice ID.'));
166
+ console.log(chalk.gray('Browse: https://elevenlabs.io/voice-library'));
167
+ console.log(chalk.gray('\nSet voice: ELEVENLABS_VOICE_ID=<id> in .env'));
168
+ }
169
+ console.log('');
170
+ });
171
+
172
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "ctx-reels",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "AI-powered faceless short-form video generator. Create TikTok/Reels/Shorts from a topic in one command.",
6
+ "main": "src/modules/pipeline.js",
7
+ "bin": {
8
+ "ctx": "./bin/ctx.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "templates/",
14
+ "scripts/",
15
+ ".env.example",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "echo \"Error: no test specified\" && exit 1",
20
+ "setup": "bash scripts/setup.sh"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "keywords": [
26
+ "tts",
27
+ "video",
28
+ "reels",
29
+ "shorts",
30
+ "tiktok",
31
+ "ai",
32
+ "faceless",
33
+ "content",
34
+ "kokoro",
35
+ "ffmpeg",
36
+ "text-to-speech"
37
+ ],
38
+ "author": "cecamarty",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/cecamarty/ctx.git"
42
+ },
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "@elevenlabs/elevenlabs-js": "^2.36.0",
46
+ "@google/genai": "^1.42.0",
47
+ "chalk": "^5.6.2",
48
+ "commander": "^14.0.3",
49
+ "dotenv": "^17.3.1",
50
+ "fluent-ffmpeg": "^2.1.3",
51
+ "kokoro-js": "^1.2.1"
52
+ }
53
+ }
@@ -0,0 +1,77 @@
1
+ #!/bin/bash
2
+ # CTX-Reels Setup Script
3
+ # Run: ctx setup OR bash scripts/setup.sh
4
+
5
+ set -e
6
+
7
+ echo "🎬 CTX-Reels Setup"
8
+ echo "=================="
9
+ echo ""
10
+
11
+ # 1. Check Node.js
12
+ if ! command -v node &> /dev/null; then
13
+ echo "❌ Node.js not found. Please install Node.js >= 18"
14
+ exit 1
15
+ fi
16
+ echo "✅ Node.js $(node -v)"
17
+
18
+ # 2. Check FFmpeg
19
+ if command -v ffmpeg &> /dev/null; then
20
+ echo "✅ FFmpeg found: $(which ffmpeg)"
21
+ elif [ -f "/usr/local/bin/ffmpeg" ]; then
22
+ echo "✅ FFmpeg found: /usr/local/bin/ffmpeg"
23
+ else
24
+ echo "⚠️ FFmpeg not found. Please install it:"
25
+ echo " macOS: brew install ffmpeg"
26
+ echo " Linux: sudo apt install ffmpeg"
27
+ echo " Windows: https://ffmpeg.org/download.html"
28
+ fi
29
+
30
+ # 3. Check FFprobe
31
+ if command -v ffprobe &> /dev/null; then
32
+ echo "✅ FFprobe found: $(which ffprobe)"
33
+ elif [ -f "/usr/local/bin/ffprobe" ]; then
34
+ echo "✅ FFprobe found: /usr/local/bin/ffprobe"
35
+ else
36
+ echo "⚠️ FFprobe not found. Install with FFmpeg."
37
+ fi
38
+
39
+ # 4. Check Python3
40
+ if command -v python3 &> /dev/null; then
41
+ echo "✅ Python3 found: $(python3 --version)"
42
+ else
43
+ echo "⚠️ Python3 not found. Subtitles (faster-whisper) require Python 3."
44
+ echo " Install: https://www.python.org/downloads/"
45
+ fi
46
+
47
+ # 5. Setup Python venv + faster-whisper
48
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
49
+ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
50
+ VENV_DIR="$PROJECT_DIR/.venv"
51
+
52
+ if [ ! -d "$VENV_DIR" ]; then
53
+ echo ""
54
+ echo "📦 Creating Python virtual environment..."
55
+ python3 -m venv "$VENV_DIR"
56
+ fi
57
+
58
+ echo "📦 Installing faster-whisper..."
59
+ "$VENV_DIR/bin/pip" install -q faster-whisper
60
+ echo "✅ faster-whisper installed"
61
+
62
+ # 6. Create .env if not exists
63
+ ENV_FILE="$PROJECT_DIR/.env"
64
+ ENV_EXAMPLE="$PROJECT_DIR/.env.example"
65
+ if [ ! -f "$ENV_FILE" ] && [ -f "$ENV_EXAMPLE" ]; then
66
+ cp "$ENV_EXAMPLE" "$ENV_FILE"
67
+ echo "✅ Created .env from .env.example — edit it to add your GEMINI_API_KEY"
68
+ elif [ -f "$ENV_FILE" ]; then
69
+ echo "✅ .env already exists"
70
+ fi
71
+
72
+ echo ""
73
+ echo "🎉 Setup complete! Get started:"
74
+ echo " ctx generate -t \"Your Topic Here\""
75
+ echo ""
76
+ echo " Output will be saved to: ~/Desktop/ctx/"
77
+ echo " Change output dir: set CTX_OUTPUT in .env"
@@ -0,0 +1,194 @@
1
+ import ffmpeg from 'fluent-ffmpeg';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import chalk from 'chalk';
5
+ import { DIRS, ensureDirectories } from '../utils/fileConfig.js';
6
+ import dotenv from 'dotenv';
7
+ dotenv.config();
8
+
9
+ /**
10
+ * Escapes a path for the FFmpeg subtitles filter.
11
+ */
12
+ function escapePathsForSubtitlesFilter(srtPath) {
13
+ // Replace backslashes with escaped forward slashes or double backslashes,
14
+ // and escape colons if present (Windows paths like C: usually need this, but Mac/Linux are fine).
15
+ // For safety, converting to absolute or relative Unix-style path.
16
+ let cleanPath = srtPath.replace(/\\/g, '/');
17
+ // Escape colons because FFmpeg filter syntax uses : as a delimiter
18
+ cleanPath = cleanPath.replace(/:/g, '\\:');
19
+ // Escape single quotes
20
+ cleanPath = cleanPath.replace(/'/g, "\\'");
21
+ return cleanPath;
22
+ }
23
+
24
+ /**
25
+ * Generates the final reel using FFmpeg.
26
+ * @param {string} audioPath The path to the generated audio (MP3).
27
+ * @param {string} subsPath The path to the generated subtitles (SRT).
28
+ * @param {Array} assets List of B-roll assets to overlay.
29
+ * @returns {Promise<string|null>} The path to the final rendered reel (MP4), or null on error.
30
+ */
31
+ export async function renderReel(audioPath, subsPath) {
32
+ return new Promise((resolve, reject) => {
33
+ try {
34
+ ensureDirectories();
35
+
36
+ if (!fs.existsSync(audioPath)) {
37
+ console.error(chalk.red(`[Engine] Error: Audio file not found at ${audioPath}`));
38
+ return resolve(null);
39
+ }
40
+ const hasSubs = subsPath && fs.existsSync(subsPath);
41
+
42
+ // 1. Determine Background Asset
43
+ let bgAsset = process.env.BG_ASSET || './templates/bg.jpg';
44
+
45
+ // If BG_ASSET is a directory, pick a random file from it
46
+ if (fs.existsSync(bgAsset) && fs.statSync(bgAsset).isDirectory()) {
47
+ const files = fs.readdirSync(bgAsset).filter(f =>
48
+ ['.jpg', '.jpeg', '.png', '.mp4'].includes(path.extname(f).toLowerCase())
49
+ );
50
+ if (files.length > 0) {
51
+ const randomFile = files[Math.floor(Math.random() * files.length)];
52
+ bgAsset = path.join(bgAsset, randomFile);
53
+ console.log(chalk.gray(`[Engine] Randomly selected background: ${randomFile}`));
54
+ } else {
55
+ console.warn(chalk.yellow(`[Engine] Warning: Background directory ${bgAsset} is empty. Using default.`));
56
+ bgAsset = './templates/bg.jpg';
57
+ }
58
+ }
59
+
60
+ if (!fs.existsSync(bgAsset)) {
61
+ console.error(chalk.red(`[Engine] Error: Background asset not found at ${bgAsset}.`));
62
+ return resolve(null);
63
+ }
64
+
65
+ const audioBasename = path.basename(audioPath, '.mp3');
66
+ const outputPath = path.join(DIRS.REELS, `${audioBasename}.mp4`);
67
+
68
+ console.log(chalk.blue(`[Engine] Rendering reel for: "${audioBasename}"...`));
69
+
70
+ // 1. Get Audio Duration to prevent hangs
71
+ const ffprobePath = process.env.FFPROBE_PATH || './lib/ffprobe';
72
+ ffmpeg.setFfprobePath(ffprobePath);
73
+ if (process.env.FFMPEG_PATH) {
74
+ ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH);
75
+ }
76
+
77
+ ffmpeg.ffprobe(audioPath, (err, metadata) => {
78
+ if (err) {
79
+ console.error(chalk.yellow(`[Engine] Warning: Could not probe audio duration. -shortest may hang.`));
80
+ }
81
+ const duration = metadata?.format?.duration;
82
+ const audioInputIndex = 1;
83
+
84
+ const command = ffmpeg();
85
+
86
+ // Inputs
87
+ if (bgAsset.toLowerCase().endsWith('.mp4')) {
88
+ command.input(bgAsset).inputOptions('-stream_loop', '-1');
89
+ } else {
90
+ command.input(bgAsset).loop();
91
+ }
92
+ command.input(audioPath);
93
+
94
+ const complexFilters = [];
95
+ // Base background processing
96
+ complexFilters.push({
97
+ filter: 'scale',
98
+ options: '1080:1920:force_original_aspect_ratio=increase',
99
+ inputs: '0:v',
100
+ outputs: 'bg'
101
+ });
102
+ complexFilters.push({
103
+ filter: 'crop',
104
+ options: '1080:1920',
105
+ inputs: 'bg',
106
+ outputs: 'vbase'
107
+ });
108
+
109
+ let lastOutput = 'vbase';
110
+
111
+ // Subtitles
112
+ let finalVideoOutput = lastOutput;
113
+ if (hasSubs) {
114
+ const escapedSubsPath = escapePathsForSubtitlesFilter(path.resolve(subsPath));
115
+ const isASS = subsPath.toLowerCase().endsWith('.ass');
116
+
117
+ if (isASS) {
118
+ // ASS files carry their own styling (karaoke, colors, fonts)
119
+ complexFilters.push({
120
+ filter: 'ass',
121
+ options: escapedSubsPath,
122
+ inputs: lastOutput,
123
+ outputs: 'vfinal'
124
+ });
125
+ } else {
126
+ // SRT fallback with force_style
127
+ const fontName = process.env.SUB_FONT || 'Impact';
128
+ const fontSize = process.env.SUB_SIZE || '28';
129
+ const style = [
130
+ `FontName=${fontName}`,
131
+ `FontSize=${fontSize}`,
132
+ `PrimaryColour=${process.env.SUB_COLOR || '&H0000FFFF'}`,
133
+ `OutlineColour=${process.env.SUB_OUTLINE_COLOR || '&H00000000'}`,
134
+ `BackColour=${process.env.SUB_BG_COLOR || '&H80000000'}`,
135
+ `BorderStyle=${process.env.SUB_BORDER_STYLE || '1'}`,
136
+ `Outline=2`,
137
+ `Shadow=1`,
138
+ `Alignment=${process.env.SUB_ALIGNMENT || '10'}`,
139
+ `MarginV=${process.env.SUB_MARGIN_V || '480'}`,
140
+ `Bold=1`
141
+ ].join(',');
142
+
143
+ complexFilters.push({
144
+ filter: 'subtitles',
145
+ options: {
146
+ filename: escapedSubsPath,
147
+ force_style: style
148
+ },
149
+ inputs: lastOutput,
150
+ outputs: 'vfinal'
151
+ });
152
+ }
153
+ finalVideoOutput = 'vfinal';
154
+ }
155
+
156
+ const outputOptions = [
157
+ '-c:v libx264',
158
+ '-preset veryfast', // Faster rendering
159
+ '-c:a aac',
160
+ '-b:a 192k',
161
+ '-pix_fmt yuv420p',
162
+ '-map', `${audioInputIndex}:a`, // Explicitly map audio
163
+ '-shortest'
164
+ ];
165
+
166
+ if (duration) {
167
+ outputOptions.push('-t', duration); // Hard limit to prevent hangs
168
+ }
169
+
170
+ command
171
+ .complexFilter(complexFilters, finalVideoOutput)
172
+ .outputOptions(outputOptions)
173
+ .save(outputPath)
174
+ .on('start', (cmd) => console.log(chalk.gray(`[Engine] Running FFmpeg...`)))
175
+ .on('progress', (p) => {
176
+ if (p.percent) process.stdout.write(`\r[Engine] Progress: ${p.percent.toFixed(1)}%`);
177
+ })
178
+ .on('end', () => {
179
+ console.log(chalk.green(`\n[Engine] Success: ${outputPath}`));
180
+ resolve(outputPath);
181
+ })
182
+ .on('error', (err, stdout, stderr) => {
183
+ console.error(chalk.red(`\n[Engine] Error: ${err.message}`));
184
+ console.error(chalk.yellow(stderr));
185
+ resolve(null);
186
+ });
187
+ });
188
+
189
+ } catch (error) {
190
+ console.error(chalk.red(`[Engine] General Error: ${error.message}`));
191
+ resolve(null);
192
+ }
193
+ });
194
+ }