@amg555/frameforge-mcp 0.0.0-stage → 2.3.1
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/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +45 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +447 -0
- package/CLAUDE.md +320 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +377 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +200 -0
- package/assets/metadata/assets.json +5 -0
- package/assets/metadata/embeddings.json +5 -0
- package/install.sh +64 -0
- package/package.json +58 -4
- package/src/animation/animation-system.js +229 -0
- package/src/asset-library/README.md +188 -0
- package/src/asset-library/asset-library.js +639 -0
- package/src/audio-mixer/mixer.js +187 -0
- package/src/cli/doctor.js +173 -0
- package/src/cli/make.js +655 -0
- package/src/cli/setup-keys.js +392 -0
- package/src/config/load-config.js +72 -0
- package/src/fact-checker/checker.js +384 -0
- package/src/fact-checker/review-gate.js +306 -0
- package/src/fact-checker/validation-set.js +1078 -0
- package/src/mcp-server/server.js +1299 -0
- package/src/mcp-server/stdio.js +87 -0
- package/src/pipeline/artifact-manifest.js +323 -0
- package/src/pipeline/artifact-retention.js +189 -0
- package/src/pipeline/external-adapter.js +366 -0
- package/src/pipeline/providers.js +853 -0
- package/src/pipeline/retry-policy.js +134 -0
- package/src/pipeline/state-machine.js +297 -0
- package/src/pipeline/telemetry.js +162 -0
- package/src/quality-gate/gate.js +229 -0
- package/src/schema/scene.schema.json +101 -0
- package/src/shorts-extractor/extractor.js +343 -0
- package/src/utils/log-redaction.js +274 -0
- package/src/video-renderer/renderer.js +364 -0
- package/src/worker/server-render-free.js +420 -0
- package/src/worker/server.js +328 -0
- package/src/workflows/assets.js +131 -0
- package/src/workflows/ingest.js +299 -0
- package/src/workflows/packaging.js +156 -0
- package/src/workflows/publishing.js +206 -0
- package/src/workflows/render.js +102 -0
- package/src/workflows/research.js +151 -0
- package/src/workflows/script.js +210 -0
- package/src/workflows/shorts.js +113 -0
package/src/cli/make.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* FrameForge Make — Standalone Video Generator
|
|
4
|
+
*
|
|
5
|
+
* ┌─────────────────────────────────────────────────────────────────────────────┐
|
|
6
|
+
* │ MODES │
|
|
7
|
+
* │ │
|
|
8
|
+
* │ full-auto (default) │
|
|
9
|
+
* │ Research → Script → AI Images → TTS → Render → Publish │
|
|
10
|
+
* │ node src/cli/make.js --topic "Your Topic" │
|
|
11
|
+
* │ │
|
|
12
|
+
* │ edit-only │
|
|
13
|
+
* │ Bring your own clips, images, audio — skip all AI generation │
|
|
14
|
+
* │ node src/cli/make.js --mode edit-only --scenes-file ./scenes.json │
|
|
15
|
+
* │ node src/cli/make.js --mode edit-only --media-dir ./my-clips │
|
|
16
|
+
* │ │
|
|
17
|
+
* │ script-only │
|
|
18
|
+
* │ Research → Script → stop. Review/edit the JSON before continuing. │
|
|
19
|
+
* │ node src/cli/make.js --mode script-only --topic "Your Topic" │
|
|
20
|
+
* │ │
|
|
21
|
+
* │ custom (mix-and-match — skip individual stages you don't need) │
|
|
22
|
+
* │ node src/cli/make.js --topic "X" --no-research --script-file s.json │
|
|
23
|
+
* │ node src/cli/make.js --topic "X" --no-images --no-tts (use your own) │
|
|
24
|
+
* │ node src/cli/make.js --script-file script.json --no-publish │
|
|
25
|
+
* └─────────────────────────────────────────────────────────────────────────────┘
|
|
26
|
+
*
|
|
27
|
+
* All options:
|
|
28
|
+
* --mode <mode> full-auto|edit-only|script-only|custom (default: full-auto)
|
|
29
|
+
* --topic <text> Video topic (required for full-auto/script-only)
|
|
30
|
+
* --topics-file <path> Batch mode: text file, one topic per line
|
|
31
|
+
* --scenes-file <path> Skip research+script, load scenes JSON directly
|
|
32
|
+
* --script-file <path> Skip research, load approved script JSON directly
|
|
33
|
+
* --media-dir <path> Auto-discover clips from a directory (edit-only)
|
|
34
|
+
* --language <code> BCP-47 language code for TTS voice (default: en)
|
|
35
|
+
* --style <text> Narrative style (default: "educational comedy")
|
|
36
|
+
* --channels <list> file,webhook (default: file)
|
|
37
|
+
* --no-research Skip research stage (use --topic as raw facts)
|
|
38
|
+
* --no-script Skip script stage (requires --scenes-file)
|
|
39
|
+
* --no-images Skip AI image generation (use video/image_path in scenes)
|
|
40
|
+
* --no-tts Skip TTS synthesis (use audio_path in scenes)
|
|
41
|
+
* --no-shorts Skip Shorts extraction
|
|
42
|
+
* --no-publish Skip publishing (render only)
|
|
43
|
+
* --dry-run Research + script only, stop before render
|
|
44
|
+
* --out-dir <path> Output directory (default: ./output)
|
|
45
|
+
* --env <path> Path to .env file (default: ./.env)
|
|
46
|
+
* --help Print this help
|
|
47
|
+
*
|
|
48
|
+
* No n8n, no IDE, no Docker required. Run 'node src/cli/setup-keys.js' to configure.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
'use strict';
|
|
52
|
+
|
|
53
|
+
const path = require('path');
|
|
54
|
+
const fs = require('fs');
|
|
55
|
+
const crypto = require('crypto');
|
|
56
|
+
const https = require('https');
|
|
57
|
+
const http = require('http');
|
|
58
|
+
|
|
59
|
+
// ── Language → edge-tts voice map ─────────────────────────────────────────────
|
|
60
|
+
// Maps BCP-47 language codes to sensible edge-tts defaults.
|
|
61
|
+
// User can still override with TTS_VOICE env var.
|
|
62
|
+
const LANGUAGE_VOICES = {
|
|
63
|
+
'en': 'en-US-BrianNeural',
|
|
64
|
+
'en-us': 'en-US-BrianNeural',
|
|
65
|
+
'en-gb': 'en-GB-RyanNeural',
|
|
66
|
+
'ml': 'ml-IN-MidhunNeural', // Malayalam
|
|
67
|
+
'hi': 'hi-IN-MadhurNeural', // Hindi
|
|
68
|
+
'ta': 'ta-IN-ValluvarNeural', // Tamil
|
|
69
|
+
'te': 'te-IN-MohanNeural', // Telugu
|
|
70
|
+
'kn': 'kn-IN-GaganNeural', // Kannada
|
|
71
|
+
'es': 'es-ES-AlvaroNeural', // Spanish
|
|
72
|
+
'fr': 'fr-FR-HenriNeural', // French
|
|
73
|
+
'de': 'de-DE-ConradNeural', // German
|
|
74
|
+
'pt': 'pt-BR-AntonioNeural', // Portuguese
|
|
75
|
+
'it': 'it-IT-DiegoNeural', // Italian
|
|
76
|
+
'ja': 'ja-JP-KeitaNeural', // Japanese
|
|
77
|
+
'ko': 'ko-KR-InJoonNeural', // Korean
|
|
78
|
+
'zh': 'zh-CN-YunxiNeural', // Mandarin
|
|
79
|
+
'ar': 'ar-SA-HamedNeural', // Arabic
|
|
80
|
+
'ru': 'ru-RU-DmitryNeural', // Russian
|
|
81
|
+
'tr': 'tr-TR-AhmetNeural', // Turkish
|
|
82
|
+
'id': 'id-ID-ArdiNeural', // Indonesian
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function parseArgs(argv) {
|
|
88
|
+
const args = {
|
|
89
|
+
mode: 'full-auto',
|
|
90
|
+
topic: '', topicsFile: '', language: 'en',
|
|
91
|
+
style: 'educational comedy', channels: ['file'],
|
|
92
|
+
scenesFile: '', scriptFile: '', mediaDir: '',
|
|
93
|
+
shorts: true, dryRun: false,
|
|
94
|
+
noResearch: false, noScript: false, noImages: false, noTts: false, noPublish: false,
|
|
95
|
+
outDir: './output', env: '.env',
|
|
96
|
+
};
|
|
97
|
+
for (let i = 2; i < argv.length; i++) {
|
|
98
|
+
switch (argv[i]) {
|
|
99
|
+
case '--mode': args.mode = (argv[++i] || 'full-auto').toLowerCase(); break;
|
|
100
|
+
case '--topic': args.topic = argv[++i] || ''; break;
|
|
101
|
+
case '--topics-file': args.topicsFile = argv[++i] || ''; break;
|
|
102
|
+
case '--scenes-file': args.scenesFile = argv[++i] || ''; break;
|
|
103
|
+
case '--script-file': args.scriptFile = argv[++i] || ''; break;
|
|
104
|
+
case '--media-dir': args.mediaDir = argv[++i] || ''; break;
|
|
105
|
+
case '--language': args.language = (argv[++i] || 'en').toLowerCase(); break;
|
|
106
|
+
case '--style': args.style = argv[++i] || args.style; break;
|
|
107
|
+
case '--channels': args.channels = (argv[++i] || 'file').split(',').map(s => s.trim()); break;
|
|
108
|
+
case '--no-research': args.noResearch = true; break;
|
|
109
|
+
case '--no-script': args.noScript = true; break;
|
|
110
|
+
case '--no-images': args.noImages = true; break;
|
|
111
|
+
case '--no-tts': args.noTts = true; break;
|
|
112
|
+
case '--no-shorts': args.shorts = false; break;
|
|
113
|
+
case '--no-publish': args.noPublish = true; break;
|
|
114
|
+
case '--dry-run': args.dryRun = true; break;
|
|
115
|
+
case '--out-dir': args.outDir = argv[++i] || args.outDir; break;
|
|
116
|
+
case '--env': args.env = argv[++i] || args.env; break;
|
|
117
|
+
case '--help': case '-h': printHelp(); process.exit(0); break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Mode shorthands → set stage flags
|
|
122
|
+
if (args.mode === 'edit-only') {
|
|
123
|
+
args.noResearch = true;
|
|
124
|
+
args.noScript = true;
|
|
125
|
+
} else if (args.mode === 'script-only') {
|
|
126
|
+
args.dryRun = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function printHelp() {
|
|
133
|
+
console.log(`
|
|
134
|
+
FrameForge Make — Flexible Video Generator
|
|
135
|
+
|
|
136
|
+
MODES:
|
|
137
|
+
full-auto (default) Research → Script → AI Images → TTS → Render → Publish
|
|
138
|
+
edit-only Bring your own clips/images/audio — skip all AI generation
|
|
139
|
+
script-only Research + Script only — review JSON before continuing
|
|
140
|
+
custom Use --no-* flags to skip individual stages
|
|
141
|
+
|
|
142
|
+
USAGE (full-auto):
|
|
143
|
+
npm run make -- --topic "Your Topic"
|
|
144
|
+
npm run make -- --topic "Your Topic" --language ml --channels file,webhook
|
|
145
|
+
npm run make -- --topics-file topics.txt
|
|
146
|
+
|
|
147
|
+
USAGE (edit-only — your own media):
|
|
148
|
+
npm run make -- --mode edit-only --scenes-file ./my-scenes.json
|
|
149
|
+
npm run make -- --mode edit-only --media-dir ./my-clips
|
|
150
|
+
|
|
151
|
+
USAGE (custom — mix AI + your media):
|
|
152
|
+
npm run make -- --topic "X" --script-file approved-script.json --no-research
|
|
153
|
+
npm run make -- --topic "X" --no-images (generate TTS but skip AI images)
|
|
154
|
+
npm run make -- --topic "X" --no-tts (use your own voice files)
|
|
155
|
+
npm run make -- --topic "X" --no-publish (render only, don't upload)
|
|
156
|
+
|
|
157
|
+
OPTIONS:
|
|
158
|
+
--mode full-auto|edit-only|script-only|custom (default: full-auto)
|
|
159
|
+
--topic Video topic text (required for full-auto / script-only)
|
|
160
|
+
--topics-file Batch mode: text file, one topic per line
|
|
161
|
+
--scenes-file Load a scenes.json directly (skips research + script)
|
|
162
|
+
--script-file Load an approved script.json (skips research stage only)
|
|
163
|
+
--media-dir Auto-discover clips from a directory (edit-only)
|
|
164
|
+
--language BCP-47 code: en (default), ml, hi, ta, te, kn, es, fr, de, pt, it, ja, ko, zh, ar, ru, tr, id
|
|
165
|
+
--style Narrative style (default: "educational comedy")
|
|
166
|
+
--channels Delivery: file,webhook (default: file)
|
|
167
|
+
--no-research Skip research (use --topic text directly as context)
|
|
168
|
+
--no-script Skip script generation (requires --scenes-file)
|
|
169
|
+
--no-images Skip AI image generation (supply image_path / video_path in scenes)
|
|
170
|
+
--no-tts Skip TTS synthesis (supply audio_path in scenes)
|
|
171
|
+
--no-shorts Skip 9:16 Shorts extraction
|
|
172
|
+
--no-publish Skip publishing (produce master video only)
|
|
173
|
+
--dry-run Research + script only, stop before render
|
|
174
|
+
--out-dir Output directory (default: ./output)
|
|
175
|
+
--env Path to .env file (default: ./.env)
|
|
176
|
+
--help Show this message
|
|
177
|
+
|
|
178
|
+
FIRST TIME SETUP:
|
|
179
|
+
node src/cli/setup-keys.js Interactive API key wizard
|
|
180
|
+
node src/cli/setup-keys.js --check Validate existing .env
|
|
181
|
+
npm run doctor System dependency check
|
|
182
|
+
|
|
183
|
+
SCENES FILE FORMAT (for --scenes-file / --mode edit-only):
|
|
184
|
+
[
|
|
185
|
+
{
|
|
186
|
+
"narration": "Voice-over text (used for TTS if no audio_path)",
|
|
187
|
+
"caption": "ON-SCREEN TEXT",
|
|
188
|
+
"video_path": "./clips/scene1.mp4", (optional)
|
|
189
|
+
"image_path": "./images/scene1.png", (optional, used if no video_path)
|
|
190
|
+
"audio_path": "./audio/scene1.mp3", (optional, used instead of TTS)
|
|
191
|
+
"camera_hint": "zoom_in",
|
|
192
|
+
"music_mood": "energetic"
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── .env loader ───────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function loadEnv(envPath) {
|
|
201
|
+
const full = path.resolve(process.cwd(), envPath);
|
|
202
|
+
if (!fs.existsSync(full)) return;
|
|
203
|
+
fs.readFileSync(full, 'utf8').split('\n').forEach(line => {
|
|
204
|
+
const stripped = line.trim();
|
|
205
|
+
if (!stripped || stripped.startsWith('#')) return;
|
|
206
|
+
const eq = stripped.indexOf('=');
|
|
207
|
+
if (eq < 1) return;
|
|
208
|
+
const key = stripped.slice(0, eq).trim();
|
|
209
|
+
const val = stripped.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
210
|
+
if (key && val && !process.env[key]) process.env[key] = val;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Progress logger ───────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function log(stage, msg) {
|
|
217
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
218
|
+
console.log(`[${ts}] [${stage}] ${msg}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function logSection(title) {
|
|
222
|
+
console.log(`\n${'─'.repeat(60)}`);
|
|
223
|
+
console.log(` ${title}`);
|
|
224
|
+
console.log('─'.repeat(60));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Thumbnail generator ───────────────────────────────────────────────────────
|
|
228
|
+
// Downloads a Pollinations image for the thumbnail prompt and optionally
|
|
229
|
+
// overlays the video title with FFmpeg drawtext. Saves to <outDir>/thumbnail.jpg.
|
|
230
|
+
// Non-fatal: failures are logged as warnings, not errors.
|
|
231
|
+
|
|
232
|
+
async function generateThumbnail({ thumbnailPrompt, title, outDir }) {
|
|
233
|
+
if (!thumbnailPrompt) return null;
|
|
234
|
+
const outputPath = path.join(outDir, 'thumbnail.jpg');
|
|
235
|
+
try {
|
|
236
|
+
const model = process.env.POLLINATIONS_MODEL || 'flux';
|
|
237
|
+
const prompt = encodeURIComponent(thumbnailPrompt.slice(0, 500));
|
|
238
|
+
const imgUrl = `https://image.pollinations.ai/prompt/${prompt}?width=1280&height=720&model=${model}&nologo=true`;
|
|
239
|
+
// Download image
|
|
240
|
+
const imgPath = path.join(outDir, 'thumbnail_raw.jpg');
|
|
241
|
+
await _downloadFile(imgUrl, imgPath);
|
|
242
|
+
if (!fs.existsSync(imgPath)) return null;
|
|
243
|
+
|
|
244
|
+
// Optionally burn title text with FFmpeg
|
|
245
|
+
const { spawnSync } = require('child_process');
|
|
246
|
+
if (title) {
|
|
247
|
+
// Resolve a usable font (same logic as renderer.js)
|
|
248
|
+
const fontCandidates = process.platform === 'win32'
|
|
249
|
+
? ['C:/Windows/Fonts/arialbd.ttf', 'C:/Windows/Fonts/calibrib.ttf', 'C:/Windows/Fonts/segoeui.ttf']
|
|
250
|
+
: process.platform === 'darwin'
|
|
251
|
+
? ['/System/Library/Fonts/Helvetica.ttc', '/Library/Fonts/Arial Bold.ttf']
|
|
252
|
+
: ['/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf'];
|
|
253
|
+
const font = process.env.FFMPEG_FONT
|
|
254
|
+
|| fontCandidates.find(f => fs.existsSync(f))
|
|
255
|
+
|| '';
|
|
256
|
+
|
|
257
|
+
if (font) {
|
|
258
|
+
const safeTitle = title.replace(/['":\\]/g, ' ').slice(0, 80);
|
|
259
|
+
const result = spawnSync('ffmpeg', [
|
|
260
|
+
'-y', '-i', imgPath,
|
|
261
|
+
'-vf', `drawtext=fontfile='${font}':text='${safeTitle}':fontcolor=white:fontsize=52:x=(w-text_w)/2:y=h-120:shadowx=3:shadowy=3:shadowcolor=black`,
|
|
262
|
+
'-q:v', '3',
|
|
263
|
+
outputPath,
|
|
264
|
+
], { encoding: 'utf8' });
|
|
265
|
+
if (result.status === 0) {
|
|
266
|
+
fs.unlinkSync(imgPath); // remove raw
|
|
267
|
+
return outputPath;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// FFmpeg unavailable or failed — just rename the raw image
|
|
271
|
+
fs.renameSync(imgPath, outputPath);
|
|
272
|
+
} else {
|
|
273
|
+
fs.renameSync(imgPath, outputPath);
|
|
274
|
+
}
|
|
275
|
+
return outputPath;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
log('Thumbnail', `⚠️ Thumbnail generation failed (non-fatal): ${err.message}`);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function _downloadFile(url, destPath) {
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
const client = url.startsWith('https') ? https : http;
|
|
285
|
+
const file = fs.createWriteStream(destPath);
|
|
286
|
+
function follow(u, hops) {
|
|
287
|
+
if (hops > 5) return reject(new Error('Too many redirects'));
|
|
288
|
+
client.get(u, (res) => {
|
|
289
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
290
|
+
res.resume();
|
|
291
|
+
return follow(res.headers.location, hops + 1);
|
|
292
|
+
}
|
|
293
|
+
if (res.statusCode !== 200) {
|
|
294
|
+
res.resume();
|
|
295
|
+
return reject(new Error(`HTTP ${res.statusCode} downloading ${u}`));
|
|
296
|
+
}
|
|
297
|
+
res.pipe(file);
|
|
298
|
+
file.on('finish', () => file.close(resolve));
|
|
299
|
+
file.on('error', reject);
|
|
300
|
+
}).on('error', reject);
|
|
301
|
+
}
|
|
302
|
+
follow(url, 0);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Single video runner (used by both single-topic and batch modes) ───────────
|
|
307
|
+
|
|
308
|
+
async function runSingleVideo(args, topic, outDir, workflows) {
|
|
309
|
+
const { runResearch, runScript, runAssets, runRender, runShorts, runPackaging, runPublishing, runIngest } = workflows;
|
|
310
|
+
|
|
311
|
+
console.log(`\n🎬 FrameForge — Starting Production`);
|
|
312
|
+
console.log(` Mode: ${args.mode}`);
|
|
313
|
+
if (topic) console.log(` Topic: "${topic}"`);
|
|
314
|
+
if (args.scenesFile) console.log(` Scenes: ${args.scenesFile}`);
|
|
315
|
+
if (args.scriptFile) console.log(` Script: ${args.scriptFile}`);
|
|
316
|
+
if (args.mediaDir) console.log(` Media: ${args.mediaDir}`);
|
|
317
|
+
console.log(` Style: ${args.style}`);
|
|
318
|
+
console.log(` Language: ${args.language}`);
|
|
319
|
+
console.log(` Channels: ${args.channels.join(', ')}`);
|
|
320
|
+
console.log(` Output: ${outDir}`);
|
|
321
|
+
const skipFlags = ['noResearch','noScript','noImages','noTts','noPublish'].filter(f => args[f]).map(f => '--' + f.replace(/([A-Z])/g, '-$1').toLowerCase());
|
|
322
|
+
if (args.dryRun) skipFlags.push('--dry-run');
|
|
323
|
+
if (skipFlags.length) console.log(` Skipping: ${skipFlags.join(', ')}`);
|
|
324
|
+
|
|
325
|
+
const startTime = Date.now();
|
|
326
|
+
|
|
327
|
+
let scenes = null;
|
|
328
|
+
let productionId = null;
|
|
329
|
+
let title = topic || 'Video';
|
|
330
|
+
let description = '';
|
|
331
|
+
let tags = [];
|
|
332
|
+
let thumbnailPrompt = null;
|
|
333
|
+
let totalWords = 0;
|
|
334
|
+
|
|
335
|
+
// ── Stage A: Research (skip if --no-research / --scenes-file / --script-file) ─
|
|
336
|
+
if (!args.noResearch && !args.scenesFile && !args.scriptFile) {
|
|
337
|
+
logSection('Stage A: Research');
|
|
338
|
+
const researchResult = await runResearch(topic, { claimCount: 10 });
|
|
339
|
+
productionId = researchResult.productionId;
|
|
340
|
+
log('Research', `✅ ${researchResult.claims.length} claims extracted (production: ${productionId})`);
|
|
341
|
+
|
|
342
|
+
// ── Stage B: Script (uses research output) ──────────────────────────────
|
|
343
|
+
if (!args.noScript) {
|
|
344
|
+
logSection('Stage B: Script');
|
|
345
|
+
const scriptResult = await runScript({
|
|
346
|
+
productionId,
|
|
347
|
+
claims: researchResult.claims,
|
|
348
|
+
topic,
|
|
349
|
+
style: args.style,
|
|
350
|
+
});
|
|
351
|
+
scenes = scriptResult.scenes;
|
|
352
|
+
title = scriptResult.title || topic;
|
|
353
|
+
description = scriptResult.description || '';
|
|
354
|
+
tags = scriptResult.tags || [];
|
|
355
|
+
thumbnailPrompt = scriptResult.thumbnailPrompt;
|
|
356
|
+
totalWords = scriptResult.totalWords || 0;
|
|
357
|
+
log('Script', `✅ ${scenes.length} scenes, ~${totalWords} words`);
|
|
358
|
+
log('Script', ` Title: "${title}"`);
|
|
359
|
+
|
|
360
|
+
if (args.dryRun) {
|
|
361
|
+
logSection('Script-Only / Dry Run Complete');
|
|
362
|
+
console.log(`\n📄 Script saved. Skipping assets/render/publish.\n`);
|
|
363
|
+
const scriptPath = path.join(outDir, `${productionId}-script.json`);
|
|
364
|
+
fs.writeFileSync(scriptPath, JSON.stringify({ research: researchResult, script: scriptResult }, null, 2));
|
|
365
|
+
log('Output', `Script written to: ${scriptPath}`);
|
|
366
|
+
log('TIP', `Resume with: npm run make -- --script-file ${scriptPath}`);
|
|
367
|
+
summarise(startTime);
|
|
368
|
+
return { productionId, topic, dryRun: true, title, scriptPath };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Script file bypass (--script-file): skip research, load approved JSON ──
|
|
374
|
+
if (args.scriptFile && !scenes) {
|
|
375
|
+
logSection('Loading Approved Script');
|
|
376
|
+
const absScript = path.resolve(process.cwd(), args.scriptFile);
|
|
377
|
+
if (!fs.existsSync(absScript)) throw new Error(`Script file not found: ${absScript}`);
|
|
378
|
+
const raw = JSON.parse(fs.readFileSync(absScript, 'utf8'));
|
|
379
|
+
const scriptData = raw.script || raw;
|
|
380
|
+
scenes = scriptData.scenes;
|
|
381
|
+
productionId = scriptData.productionId || raw.productionId || `script_${Date.now()}`;
|
|
382
|
+
title = scriptData.title || topic || 'Video';
|
|
383
|
+
description = scriptData.description || '';
|
|
384
|
+
tags = scriptData.tags || [];
|
|
385
|
+
thumbnailPrompt = scriptData.thumbnailPrompt || null;
|
|
386
|
+
totalWords = scriptData.totalWords || scenes?.reduce((n, s) => n + (s.narration || '').split(/\s+/).filter(Boolean).length, 0) || 0;
|
|
387
|
+
log('Script', `✅ Loaded ${scenes?.length} scenes from: ${absScript}`);
|
|
388
|
+
|
|
389
|
+
if (args.dryRun) {
|
|
390
|
+
logSection('Dry Run Complete');
|
|
391
|
+
summarise(startTime);
|
|
392
|
+
return { productionId, topic, dryRun: true, title };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Scenes file bypass (--scenes-file / --media-dir / edit-only) ──────────
|
|
397
|
+
if ((args.scenesFile || args.mediaDir) && !scenes) {
|
|
398
|
+
logSection('Stage A: Media Ingest (edit-only)');
|
|
399
|
+
const ingestResult = await runIngest({
|
|
400
|
+
productionId,
|
|
401
|
+
scenesFile: args.scenesFile || undefined,
|
|
402
|
+
mediaDir: args.mediaDir || undefined,
|
|
403
|
+
outDir,
|
|
404
|
+
});
|
|
405
|
+
scenes = ingestResult.scenes;
|
|
406
|
+
productionId = ingestResult.productionId;
|
|
407
|
+
log('Ingest', `✅ ${scenes.length} scene(s) ingested`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!scenes || scenes.length === 0) {
|
|
411
|
+
throw new Error('No scenes available. Use --topic, --scenes-file, --script-file, or --media-dir.');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── Stage C: Assets (images + TTS) ────────────────────────────────────────
|
|
415
|
+
logSection('Stage C: Assets (images + TTS)');
|
|
416
|
+
|
|
417
|
+
// Pass stage-skip flags into scenes so runAssets can honour them
|
|
418
|
+
const preparedScenes = scenes.map(s => ({
|
|
419
|
+
...s,
|
|
420
|
+
// --no-images: mark scene as if it has a placeholder image path → renderer skips generation
|
|
421
|
+
_skipImageGen: args.noImages,
|
|
422
|
+
// --no-tts: mark scene as if it has pre-recorded audio → renderer skips TTS
|
|
423
|
+
_skipTts: args.noTts,
|
|
424
|
+
}));
|
|
425
|
+
|
|
426
|
+
const assetsResult = await runAssets({
|
|
427
|
+
productionId,
|
|
428
|
+
scenes: preparedScenes,
|
|
429
|
+
parallelism: 4,
|
|
430
|
+
skipImages: args.noImages,
|
|
431
|
+
skipTts: args.noTts,
|
|
432
|
+
});
|
|
433
|
+
log('Assets', `✅ ${assetsResult.sceneCount} scenes prepared`);
|
|
434
|
+
|
|
435
|
+
// ── Stage D: Render ────────────────────────────────────────────────────────
|
|
436
|
+
logSection('Stage D: Video Render (FFmpeg)');
|
|
437
|
+
const renderResult = await runRender({
|
|
438
|
+
productionId,
|
|
439
|
+
sceneAssets: assetsResult.sceneAssets,
|
|
440
|
+
});
|
|
441
|
+
if (!renderResult.passed) {
|
|
442
|
+
throw new Error(`Quality gate FAILED: ${JSON.stringify(renderResult.qcReport?.checks?.filter(c => !c.passed).map(c => c.name))}`);
|
|
443
|
+
}
|
|
444
|
+
log('Render', `✅ Master: ${renderResult.masterPath}`);
|
|
445
|
+
log('Render', ` Size: ${(renderResult.sizeBytes / 1024 / 1024).toFixed(1)} MB`);
|
|
446
|
+
|
|
447
|
+
// ── Stage D2: Shorts ───────────────────────────────────────────────────────
|
|
448
|
+
let shortsResult = { shorts: [] };
|
|
449
|
+
if (args.shorts) {
|
|
450
|
+
logSection('Stage D2: Shorts Extraction (9:16)');
|
|
451
|
+
shortsResult = await runShorts({
|
|
452
|
+
productionId,
|
|
453
|
+
masterPath: renderResult.masterPath,
|
|
454
|
+
scenes,
|
|
455
|
+
}).catch(err => {
|
|
456
|
+
log('Shorts', `⚠️ Shorts extraction failed (non-blocking): ${err.message}`);
|
|
457
|
+
return { shorts: [] };
|
|
458
|
+
});
|
|
459
|
+
log('Shorts', `✅ ${shortsResult.shorts?.length || 0} shorts extracted`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Stage E: Packaging ────────────────────────────────────────────────────
|
|
463
|
+
logSection('Stage E: Packaging');
|
|
464
|
+
const packageResult = await runPackaging({
|
|
465
|
+
productionId,
|
|
466
|
+
masterPath: renderResult.masterPath,
|
|
467
|
+
shorts: shortsResult.shorts || [],
|
|
468
|
+
scenes,
|
|
469
|
+
title,
|
|
470
|
+
description,
|
|
471
|
+
tags,
|
|
472
|
+
thumbnailPrompt,
|
|
473
|
+
qcReport: renderResult.qcReport,
|
|
474
|
+
totalWords,
|
|
475
|
+
});
|
|
476
|
+
log('Package', `✅ Manifest: ${packageResult.manifestPath || 'saved'}`);
|
|
477
|
+
|
|
478
|
+
// ── Thumbnail ─────────────────────────────────────────────────────────────
|
|
479
|
+
logSection('Thumbnail');
|
|
480
|
+
const thumbPath = await generateThumbnail({
|
|
481
|
+
thumbnailPrompt,
|
|
482
|
+
title,
|
|
483
|
+
outDir,
|
|
484
|
+
});
|
|
485
|
+
if (thumbPath) log('Thumbnail', `✅ Saved: ${thumbPath}`);
|
|
486
|
+
|
|
487
|
+
// ── Stage F: Publish ──────────────────────────────────────────────────────
|
|
488
|
+
if (args.noPublish) {
|
|
489
|
+
logSection('Publish Skipped (--no-publish)');
|
|
490
|
+
log('Output', `Master video: ${renderResult.masterPath}`);
|
|
491
|
+
summarise(startTime);
|
|
492
|
+
return { productionId, topic, masterPath: renderResult.masterPath, thumbnailPath: thumbPath, title, shorts: shortsResult.shorts || [] };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
logSection('Stage F: Publish');
|
|
496
|
+
const publishResult = await runPublishing({
|
|
497
|
+
productionId,
|
|
498
|
+
manifest: packageResult.manifest,
|
|
499
|
+
channels: args.channels,
|
|
500
|
+
});
|
|
501
|
+
for (const [channel, result] of Object.entries(publishResult.results || {})) {
|
|
502
|
+
log('Publish', `${result.ok ? '✅' : '⚠️ '} ${channel}: ${result.ok ? 'delivered' : result.reason || result.error || 'failed'}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ── Done ───────────────────────────────────────────────────────────────────
|
|
506
|
+
logSection('Production Complete');
|
|
507
|
+
console.log(`\n🎉 Video ready!`);
|
|
508
|
+
console.log(` Production ID: ${productionId}`);
|
|
509
|
+
console.log(` Master: ${renderResult.masterPath}`);
|
|
510
|
+
if (thumbPath) console.log(` Thumbnail: ${thumbPath}`);
|
|
511
|
+
if (shortsResult.shorts?.length) {
|
|
512
|
+
shortsResult.shorts.forEach((s, i) => console.log(` Short #${i + 1}: ${s.path || s}`));
|
|
513
|
+
}
|
|
514
|
+
summarise(startTime);
|
|
515
|
+
return {
|
|
516
|
+
productionId,
|
|
517
|
+
topic,
|
|
518
|
+
masterPath: renderResult.masterPath,
|
|
519
|
+
thumbnailPath: thumbPath,
|
|
520
|
+
title,
|
|
521
|
+
shorts: shortsResult.shorts || [],
|
|
522
|
+
publishResult,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── Main pipeline ─────────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
async function main() {
|
|
529
|
+
const args = parseArgs(process.argv);
|
|
530
|
+
|
|
531
|
+
// Validate: must have a topic source OR a media source
|
|
532
|
+
const hasTopic = args.topic || args.topicsFile;
|
|
533
|
+
const hasMedia = args.scenesFile || args.scriptFile || args.mediaDir;
|
|
534
|
+
if (!hasTopic && !hasMedia) {
|
|
535
|
+
console.error(
|
|
536
|
+
'\n❌ A content source is required. Examples:\n' +
|
|
537
|
+
' npm run make -- --topic "Your Topic"\n' +
|
|
538
|
+
' npm run make -- --mode edit-only --scenes-file ./scenes.json\n' +
|
|
539
|
+
' npm run make -- --mode edit-only --media-dir ./my-clips\n' +
|
|
540
|
+
' npm run make -- --script-file approved-script.json\n' +
|
|
541
|
+
'\n Run: npm run make -- --help for all options\n'
|
|
542
|
+
);
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Load env file before requiring modules (they read env at import time)
|
|
547
|
+
loadEnv(args.env);
|
|
548
|
+
|
|
549
|
+
// ── Language → TTS voice auto-select ──────────────────────────────────────
|
|
550
|
+
// Only set if user hasn't already set TTS_VOICE in the env file
|
|
551
|
+
if (args.language && args.language !== 'en' && !process.env.TTS_VOICE) {
|
|
552
|
+
const voice = LANGUAGE_VOICES[args.language];
|
|
553
|
+
if (voice) {
|
|
554
|
+
process.env.TTS_VOICE = voice;
|
|
555
|
+
log('Language', `Auto-selected voice: ${voice} (${args.language})`);
|
|
556
|
+
} else {
|
|
557
|
+
log('Language', `⚠️ Unknown language code "${args.language}" — using default voice`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Set N8N_FILES_DIR to outDir so all artifacts go there
|
|
562
|
+
const outDir = path.resolve(process.cwd(), args.outDir);
|
|
563
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
564
|
+
process.env.N8N_FILES_DIR = outDir;
|
|
565
|
+
|
|
566
|
+
// Lazy-require pipeline modules (after env + language are set)
|
|
567
|
+
const workflows = {
|
|
568
|
+
runResearch: require('../workflows/research').runResearch,
|
|
569
|
+
runScript: require('../workflows/script').runScript,
|
|
570
|
+
runIngest: require('../workflows/ingest').runIngest,
|
|
571
|
+
runAssets: require('../workflows/assets').runAssets,
|
|
572
|
+
runRender: require('../workflows/render').runRender,
|
|
573
|
+
runShorts: require('../workflows/shorts').runShorts,
|
|
574
|
+
runPackaging: require('../workflows/packaging').runPackaging,
|
|
575
|
+
runPublishing: require('../workflows/publishing').runPublishing,
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// ── Batch mode (--topics-file) ────────────────────────────────────────────
|
|
579
|
+
if (args.topicsFile) {
|
|
580
|
+
const topicsPath = path.resolve(process.cwd(), args.topicsFile);
|
|
581
|
+
if (!fs.existsSync(topicsPath)) {
|
|
582
|
+
console.error(`\n❌ Topics file not found: ${topicsPath}\n`);
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
const topics = fs.readFileSync(topicsPath, 'utf8')
|
|
586
|
+
.split('\n')
|
|
587
|
+
.map(l => l.trim())
|
|
588
|
+
.filter(l => l && !l.startsWith('#'));
|
|
589
|
+
|
|
590
|
+
if (topics.length === 0) {
|
|
591
|
+
console.error(`\n❌ Topics file is empty: ${topicsPath}\n`);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
console.log(`\n🎬 FrameForge Batch — ${topics.length} topic(s) queued`);
|
|
596
|
+
console.log(` Language: ${args.language} Channels: ${args.channels.join(', ')}`);
|
|
597
|
+
|
|
598
|
+
const batchStart = Date.now();
|
|
599
|
+
const batchResults = [];
|
|
600
|
+
let succeeded = 0, failed = 0;
|
|
601
|
+
|
|
602
|
+
for (let i = 0; i < topics.length; i++) {
|
|
603
|
+
const topic = topics[i];
|
|
604
|
+
const topicDir = path.join(outDir, `batch_${String(i + 1).padStart(3, '0')}`);
|
|
605
|
+
fs.mkdirSync(topicDir, { recursive: true });
|
|
606
|
+
process.env.N8N_FILES_DIR = topicDir;
|
|
607
|
+
|
|
608
|
+
console.log(`\n${'═'.repeat(60)}`);
|
|
609
|
+
console.log(` Batch ${i + 1}/${topics.length}: "${topic}"`);
|
|
610
|
+
console.log('═'.repeat(60));
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
const result = await runSingleVideo(args, topic, topicDir, workflows);
|
|
614
|
+
batchResults.push({ index: i + 1, topic, status: 'ok', ...result });
|
|
615
|
+
succeeded++;
|
|
616
|
+
} catch (err) {
|
|
617
|
+
log('Batch', `❌ Topic ${i + 1} FAILED: ${err.message}`);
|
|
618
|
+
batchResults.push({ index: i + 1, topic, status: 'failed', error: err.message });
|
|
619
|
+
failed++;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── Batch summary ────────────────────────────────────────────────────────
|
|
624
|
+
const elapsed = Math.round((Date.now() - batchStart) / 1000);
|
|
625
|
+
logSection(`Batch Complete — ${succeeded} succeeded, ${failed} failed`);
|
|
626
|
+
console.log(`⏱️ Total time: ${Math.floor(elapsed / 60)}m ${elapsed % 60}s\n`);
|
|
627
|
+
|
|
628
|
+
// Print per-topic summary
|
|
629
|
+
batchResults.forEach(r => {
|
|
630
|
+
const icon = r.status === 'ok' ? '✅' : '❌';
|
|
631
|
+
const detail = r.status === 'ok' ? r.title || r.masterPath : r.error;
|
|
632
|
+
console.log(` ${icon} [${r.index}] ${r.topic} — ${detail}`);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Exit with error code if any topic failed
|
|
636
|
+
if (failed > 0) process.exit(1);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── Single topic mode ─────────────────────────────────────────────────────
|
|
641
|
+
await runSingleVideo(args, args.topic, outDir, workflows);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function summarise(startTime) {
|
|
645
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
|
646
|
+
const mins = Math.floor(elapsed / 60);
|
|
647
|
+
const secs = elapsed % 60;
|
|
648
|
+
console.log(`\n⏱️ Total time: ${mins > 0 ? `${mins}m ` : ''}${secs}s\n`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
main().catch(err => {
|
|
652
|
+
console.error(`\n❌ Fatal error: ${err.message}`);
|
|
653
|
+
if (process.env.DEBUG_PROVIDERS) console.error(err.stack);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
});
|