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 +38 -0
- package/README.md +120 -0
- package/bin/ctx.js +172 -0
- package/package.json +53 -0
- package/scripts/setup.sh +77 -0
- package/src/modules/engine.js +194 -0
- package/src/modules/pipeline.js +96 -0
- package/src/modules/script.js +70 -0
- package/src/modules/subs.js +142 -0
- package/src/modules/voice.js +211 -0
- package/src/python/whisper_runner.py +139 -0
- package/src/utils/fileConfig.js +38 -0
- package/templates/backgrounds/default.jpg +0 -0
- package/templates/backgrounds/minecraft.mp4 +0 -0
- package/templates/bg.jpg +0 -0
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
|
+
}
|
package/scripts/setup.sh
ADDED
|
@@ -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
|
+
}
|