@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio Mixer
|
|
3
|
+
*
|
|
4
|
+
* Professional audio mixing with sidechain ducking.
|
|
5
|
+
* Voice track compresses to -20dB, music ducks by 15dB when voice is active.
|
|
6
|
+
* Final output: -14 LUFS ± 0.5 LU, true peak ≤ -1 dBTP.
|
|
7
|
+
*
|
|
8
|
+
* Requirements implemented:
|
|
9
|
+
* - Req 5.1: Voice compression ratio 3:1, threshold -20dBFS, attack 5ms, release 50ms
|
|
10
|
+
* - Req 5.2: Music attenuation 15dB within 200ms when voice detected
|
|
11
|
+
* - Req 5.3: Music restore within 1s when voice ends
|
|
12
|
+
* - Req 5.4: Normalize final to -14 LUFS ±0.5 LU
|
|
13
|
+
* - Req 5.6: High-pass 80Hz + low-pass 8000Hz on music track
|
|
14
|
+
* - Req 5.7: Peak below -1 dBFS across all tracks
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
|
|
24
|
+
const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
|
|
25
|
+
|
|
26
|
+
class AudioMixer {
|
|
27
|
+
constructor({ filesDir = DEFAULT_FILES_DIR } = {}) {
|
|
28
|
+
this.filesDir = filesDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mix voice track with background music using sidechain ducking.
|
|
33
|
+
* @param {{ voicePath: string, musicPath: string|null, outputPath: string }} opts
|
|
34
|
+
* @returns {string} outputPath
|
|
35
|
+
*/
|
|
36
|
+
mixWithDucking({ voicePath, musicPath = null, outputPath }) {
|
|
37
|
+
if (!musicPath || !fs.existsSync(musicPath) || fs.statSync(musicPath).size < 5000) {
|
|
38
|
+
// No music — just normalise voice
|
|
39
|
+
return this._normaliseVoice(voicePath, outputPath);
|
|
40
|
+
}
|
|
41
|
+
return this._sidechainMix(voicePath, musicPath, outputPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Apply ducking + normalization to an assembled video.
|
|
46
|
+
* Replaces the audio track in-place (creates new file).
|
|
47
|
+
* @param {{ videoPath: string, musicPath?: string, outputPath: string }} opts
|
|
48
|
+
*/
|
|
49
|
+
mixVideo({ videoPath, musicPath = null, outputPath }) {
|
|
50
|
+
if (!musicPath || !fs.existsSync(musicPath)) {
|
|
51
|
+
// Normalise existing audio track
|
|
52
|
+
return this._normaliseVideo(videoPath, outputPath);
|
|
53
|
+
}
|
|
54
|
+
return this._videoWithDucking(videoPath, musicPath, outputPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
_sidechainMix(voicePath, musicPath, outputPath) {
|
|
60
|
+
// Voice: compress → sidechain trigger
|
|
61
|
+
// Music: highpass 80Hz + lowpass 8000Hz → sidechaincompress (duck on voice)
|
|
62
|
+
// Mix: loudnorm -14 LUFS
|
|
63
|
+
const filterComplex = [
|
|
64
|
+
'[0:a]acompressor=ratio=3:threshold=-20dB:attack=5:release=50[voice_comp]',
|
|
65
|
+
'[1:a]highpass=f=80,lowpass=f=8000[music_filtered]',
|
|
66
|
+
'[voice_comp][music_filtered]sidechaincompress=threshold=0.003:ratio=20:attack=0.2:release=1:makeup=2[mixed]',
|
|
67
|
+
'[mixed]loudnorm=I=-14:TP=-1:LRA=11[out]',
|
|
68
|
+
].join(';');
|
|
69
|
+
|
|
70
|
+
const result = spawnSync('ffmpeg', [
|
|
71
|
+
'-nostdin', '-y',
|
|
72
|
+
'-i', voicePath,
|
|
73
|
+
'-i', musicPath,
|
|
74
|
+
'-filter_complex', filterComplex,
|
|
75
|
+
'-map', '[out]',
|
|
76
|
+
'-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k',
|
|
77
|
+
outputPath,
|
|
78
|
+
], { timeout: 120000, encoding: 'utf8' });
|
|
79
|
+
|
|
80
|
+
if (result.status !== 0) {
|
|
81
|
+
throw new Error(`Audio ducking failed: ${(result.stderr || '').slice(-300)}`);
|
|
82
|
+
}
|
|
83
|
+
return outputPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_videoWithDucking(videoPath, musicPath, outputPath) {
|
|
87
|
+
// Measure the video duration upfront so we can use explicit -t instead of -shortest.
|
|
88
|
+
// -shortest causes FFmpeg to hang or silently trim audio on subtitle streams and on
|
|
89
|
+
// some sidechaincompress filter graphs where the shorter stream signals EOF late.
|
|
90
|
+
const videoDur = this._getVideoDuration(videoPath);
|
|
91
|
+
|
|
92
|
+
const filterComplex = [
|
|
93
|
+
'[0:a]acompressor=ratio=3:threshold=-20dB:attack=5:release=50[voice_comp]',
|
|
94
|
+
'[1:a]aformat=sample_rates=48000:channel_layouts=stereo,highpass=f=80,lowpass=f=8000,volume=0.15[music_filtered]',
|
|
95
|
+
'[voice_comp][music_filtered]sidechaincompress=threshold=0.003:ratio=20:attack=0.2:release=1:makeup=2[mixed]',
|
|
96
|
+
'[mixed]loudnorm=I=-14:TP=-1:LRA=11[audio_out]',
|
|
97
|
+
].join(';');
|
|
98
|
+
|
|
99
|
+
const result = spawnSync('ffmpeg', [
|
|
100
|
+
'-nostdin', '-y',
|
|
101
|
+
'-i', videoPath, // input 0: video with voice
|
|
102
|
+
'-stream_loop', '-1',
|
|
103
|
+
'-i', musicPath, // input 1: music (loop to fill)
|
|
104
|
+
'-filter_complex', filterComplex,
|
|
105
|
+
'-map', '0:v',
|
|
106
|
+
'-map', '[audio_out]',
|
|
107
|
+
'-c:v', 'copy', // no re-encode of video
|
|
108
|
+
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
109
|
+
// Use explicit -t instead of -shortest to prevent hang/trim on subtitle streams.
|
|
110
|
+
// videoDur measured by ffprobe; fallback to omitting -t if measurement failed.
|
|
111
|
+
...(videoDur > 0 ? ['-t', String(videoDur)] : []),
|
|
112
|
+
'-movflags', '+faststart',
|
|
113
|
+
outputPath,
|
|
114
|
+
], { timeout: 300000, encoding: 'utf8' });
|
|
115
|
+
|
|
116
|
+
if (result.status !== 0) {
|
|
117
|
+
throw new Error(`Video ducking failed: ${(result.stderr || '').slice(-300)}`);
|
|
118
|
+
}
|
|
119
|
+
return outputPath;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Measure total duration of a video file via ffprobe. Returns 0 on failure. */
|
|
123
|
+
_getVideoDuration(videoPath) {
|
|
124
|
+
try {
|
|
125
|
+
const result = spawnSync('ffprobe', [
|
|
126
|
+
'-v', 'error',
|
|
127
|
+
'-show_entries', 'format=duration',
|
|
128
|
+
'-of', 'default=nk=1:nw=1',
|
|
129
|
+
videoPath,
|
|
130
|
+
], { encoding: 'utf8', timeout: 15000 });
|
|
131
|
+
const parsed = parseFloat((result.stdout || '').trim());
|
|
132
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
133
|
+
} catch { return 0; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_normaliseVoice(voicePath, outputPath) {
|
|
137
|
+
const result = spawnSync('ffmpeg', [
|
|
138
|
+
'-nostdin', '-y',
|
|
139
|
+
'-i', voicePath,
|
|
140
|
+
'-af', 'loudnorm=I=-14:TP=-1:LRA=11',
|
|
141
|
+
'-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k',
|
|
142
|
+
outputPath,
|
|
143
|
+
], { timeout: 60000, encoding: 'utf8' });
|
|
144
|
+
if (result.status !== 0) throw new Error(`Voice normalise failed: ${(result.stderr || '').slice(-200)}`);
|
|
145
|
+
return outputPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_normaliseVideo(videoPath, outputPath) {
|
|
149
|
+
// -vf copy is not valid FFmpeg; use explicit stream maps instead
|
|
150
|
+
const result = spawnSync('ffmpeg', [
|
|
151
|
+
'-nostdin', '-y',
|
|
152
|
+
'-i', videoPath,
|
|
153
|
+
'-map', '0:v', '-map', '0:a',
|
|
154
|
+
'-af', 'loudnorm=I=-14:TP=-1:LRA=11',
|
|
155
|
+
'-c:v', 'copy',
|
|
156
|
+
'-c:a', 'aac', '-b:a', '192k',
|
|
157
|
+
'-movflags', '+faststart',
|
|
158
|
+
outputPath,
|
|
159
|
+
], { timeout: 300000, encoding: 'utf8' });
|
|
160
|
+
if (result.status !== 0) throw new Error(`Video normalise failed: ${(result.stderr || '').slice(-200)}`);
|
|
161
|
+
return outputPath;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Detect pauses in an audio file (for caption sync).
|
|
166
|
+
* Returns array of { start, end } in seconds.
|
|
167
|
+
*/
|
|
168
|
+
detectPauses(audioPath, { noiseTolerance = '-40dB', minPause = 0.3 } = {}) {
|
|
169
|
+
try {
|
|
170
|
+
const result = spawnSync('ffmpeg', [
|
|
171
|
+
'-nostdin', '-i', audioPath,
|
|
172
|
+
'-af', `silencedetect=noise=${noiseTolerance}:d=${minPause}`,
|
|
173
|
+
'-f', 'null', '-',
|
|
174
|
+
], { timeout: 30000, encoding: 'utf8' });
|
|
175
|
+
const output = (result.stderr || result.stdout || '').toString();
|
|
176
|
+
const pauses = [];
|
|
177
|
+
const starts = [...output.matchAll(/silence_start: ([\d.]+)/g)].map(m => parseFloat(m[1]));
|
|
178
|
+
const ends = [...output.matchAll(/silence_end: ([\d.]+)/g)].map(m => parseFloat(m[1]));
|
|
179
|
+
for (let i = 0; i < Math.min(starts.length, ends.length); i++) {
|
|
180
|
+
pauses.push({ start: starts[i], end: ends[i], duration: ends[i] - starts[i] });
|
|
181
|
+
}
|
|
182
|
+
return pauses;
|
|
183
|
+
} catch { return []; }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { AudioMixer };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Load keys from ~/.frameforge/config.json before any env checks
|
|
3
|
+
require('../config/load-config').loadConfig();
|
|
4
|
+
/**
|
|
5
|
+
* FrameForge System Doctor — Pre-flight Environment Diagnostics
|
|
6
|
+
*
|
|
7
|
+
* Verifies all required and optional runtime dependencies for running
|
|
8
|
+
* FrameForge locally on Windows, macOS, or Linux.
|
|
9
|
+
*
|
|
10
|
+
* Run: npm run doctor (or: node src/cli/doctor.js)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require('child_process');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const COLORS = {
|
|
20
|
+
reset: '\x1b[0m',
|
|
21
|
+
green: '\x1b[32m',
|
|
22
|
+
red: '\x1b[31m',
|
|
23
|
+
yellow:'\x1b[33m',
|
|
24
|
+
cyan: '\x1b[36m',
|
|
25
|
+
bold: '\x1b[1m',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function pass(name, detail = '') {
|
|
29
|
+
console.log(` ${COLORS.green}✔${COLORS.reset} ${COLORS.bold}${name}${COLORS.reset} ${COLORS.cyan}${detail}${COLORS.reset}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function warn(name, detail = '', fix = '') {
|
|
33
|
+
console.log(` ${COLORS.yellow}⚠${COLORS.reset} ${COLORS.bold}${name}${COLORS.reset} ${detail}`);
|
|
34
|
+
if (fix) console.log(` ${COLORS.yellow}↳ Fix:${COLORS.reset} ${fix}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fail(name, detail = '', fix = '') {
|
|
38
|
+
console.log(` ${COLORS.red}✖${COLORS.reset} ${COLORS.bold}${name}${COLORS.reset} ${detail}`);
|
|
39
|
+
if (fix) console.log(` ${COLORS.red}↳ Fix:${COLORS.reset} ${fix}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`\n${COLORS.bold}${COLORS.cyan}=== FrameForge System Doctor ===${COLORS.reset}\n`);
|
|
43
|
+
|
|
44
|
+
let hasCriticalFailure = false;
|
|
45
|
+
|
|
46
|
+
// 1. Node.js Version
|
|
47
|
+
const nodeVer = process.version;
|
|
48
|
+
const major = parseInt(nodeVer.replace('v', '').split('.')[0], 10);
|
|
49
|
+
if (major >= 18) {
|
|
50
|
+
pass('Node.js', `${nodeVer} (>= 18 required)`);
|
|
51
|
+
} else {
|
|
52
|
+
fail('Node.js', `${nodeVer} is outdated`, 'Install Node.js 18 or 20+ from https://nodejs.org');
|
|
53
|
+
hasCriticalFailure = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. FFmpeg
|
|
57
|
+
const ffmpeg = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8', timeout: 5000, shell: process.platform === 'win32' });
|
|
58
|
+
if (ffmpeg.status === 0) {
|
|
59
|
+
const firstLine = (ffmpeg.stdout || '').split('\n')[0] || '';
|
|
60
|
+
pass('FFmpeg', firstLine.slice(0, 45));
|
|
61
|
+
} else {
|
|
62
|
+
fail('FFmpeg', 'Not found in PATH', 'Download FFmpeg from https://ffmpeg.org/download.html and add bin/ to PATH');
|
|
63
|
+
hasCriticalFailure = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. FFprobe
|
|
67
|
+
const ffprobe = spawnSync('ffprobe', ['-version'], { encoding: 'utf8', timeout: 5000, shell: process.platform === 'win32' });
|
|
68
|
+
if (ffprobe.status === 0) {
|
|
69
|
+
const firstLine = (ffprobe.stdout || '').split('\n')[0] || '';
|
|
70
|
+
pass('FFprobe', firstLine.slice(0, 45));
|
|
71
|
+
} else {
|
|
72
|
+
fail('FFprobe', 'Not found in PATH', 'FFprobe usually installs alongside FFmpeg. Ensure both are in PATH');
|
|
73
|
+
hasCriticalFailure = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Voice Engine (edge-tts or Cloud API)
|
|
77
|
+
const edgeTts = spawnSync('edge-tts', ['--version'], { encoding: 'utf8', timeout: 5000, shell: process.platform === 'win32' });
|
|
78
|
+
const hasCloudTts = Boolean(process.env.ELEVENLABS_API_KEY || process.env.AZURE_SPEECH_KEY || process.env.OPENAI_API_KEY || process.env.CUSTOM_TTS_URL);
|
|
79
|
+
|
|
80
|
+
if (edgeTts.status === 0) {
|
|
81
|
+
pass('Voice Engine', `edge-tts CLI available (${(edgeTts.stdout || '').trim()})`);
|
|
82
|
+
} else if (hasCloudTts) {
|
|
83
|
+
pass('Voice Engine', 'Cloud TTS API configured via environment');
|
|
84
|
+
} else {
|
|
85
|
+
warn(
|
|
86
|
+
'Voice Engine',
|
|
87
|
+
'Neither edge-tts CLI nor cloud TTS API detected',
|
|
88
|
+
'Run "pip install edge-tts" for free offline voiceover, or set OPENAI_API_KEY / ELEVENLABS_API_KEY in .env'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 5. System Font (for video subtitles/captions)
|
|
93
|
+
const FONT_CANDIDATES = [
|
|
94
|
+
'C:\\Windows\\Fonts\\arialbd.ttf',
|
|
95
|
+
'C:\\Windows\\Fonts\\calibrib.ttf',
|
|
96
|
+
'C:\\Windows\\Fonts\\segoeui.ttf',
|
|
97
|
+
'/System/Library/Fonts/Helvetica.ttc',
|
|
98
|
+
'/Library/Fonts/Arial Bold.ttf',
|
|
99
|
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
|
100
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
|
101
|
+
'/usr/share/fonts/liberation/LiberationSans-Bold.ttf',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const foundFont = process.env.FFMPEG_FONT || FONT_CANDIDATES.find(f => fs.existsSync(f));
|
|
105
|
+
if (foundFont) {
|
|
106
|
+
pass('System Font', foundFont);
|
|
107
|
+
} else {
|
|
108
|
+
warn(
|
|
109
|
+
'System Font',
|
|
110
|
+
'No standard bold font file found',
|
|
111
|
+
'Subtitles may be skipped. Set FFMPEG_FONT=/path/to/font.ttf or install Liberation/Arial font'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 6. LLM Provider (Research & Scriptwriting)
|
|
116
|
+
const llmProvider = (process.env.PROVIDER_LLM || 'groq').toLowerCase();
|
|
117
|
+
|
|
118
|
+
// Built-in key env vars — any unknown provider can also supply LLM_BASE_URL or WEBHOOK_LLM_URL
|
|
119
|
+
const builtinKeyMap = {
|
|
120
|
+
groq: process.env.GROQ_API_KEY,
|
|
121
|
+
openrouter: process.env.OPENROUTER_API_KEY,
|
|
122
|
+
anthropic: process.env.ANTHROPIC_API_KEY,
|
|
123
|
+
gemini: process.env.GEMINI_API_KEY,
|
|
124
|
+
mistral: process.env.MISTRAL_API_KEY,
|
|
125
|
+
together: process.env.TOGETHER_API_KEY,
|
|
126
|
+
cerebras: process.env.CEREBRAS_API_KEY,
|
|
127
|
+
openai: process.env.OPENAI_API_KEY,
|
|
128
|
+
ollama: 'local',
|
|
129
|
+
webhook: process.env.WEBHOOK_LLM_URL,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const hasLLMKey = Boolean(
|
|
133
|
+
builtinKeyMap[llmProvider] || // known built-in provider has a key
|
|
134
|
+
process.env.LLM_BASE_URL || // custom OpenAI-compatible endpoint
|
|
135
|
+
process.env.WEBHOOK_LLM_URL || // webhook provider
|
|
136
|
+
llmProvider === 'ollama' // ollama needs no key
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const customModel = process.env.LLM_MODEL ? ` model=${process.env.LLM_MODEL}` : '';
|
|
140
|
+
const customEndpoint = process.env.LLM_BASE_URL ? ` endpoint=${process.env.LLM_BASE_URL}` : '';
|
|
141
|
+
|
|
142
|
+
if (hasLLMKey) {
|
|
143
|
+
pass('LLM Provider', `${llmProvider}${customModel}${customEndpoint}`);
|
|
144
|
+
} else {
|
|
145
|
+
warn(
|
|
146
|
+
'LLM Provider',
|
|
147
|
+
`PROVIDER_LLM=${llmProvider} but no API key found`,
|
|
148
|
+
`Run: npm run setup OR set the required key in .env\n` +
|
|
149
|
+
` Free options: GROQ_API_KEY (groq) · GEMINI_API_KEY (gemini) · CEREBRAS_API_KEY (cerebras)\n` +
|
|
150
|
+
` Any OpenAI-compatible API: set LLM_BASE_URL + PROVIDER_LLM=webhook`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 7. Output directory check
|
|
155
|
+
const baseDir = process.env.N8N_FILES_DIR || path.join(process.cwd(), '.frameforge-files');
|
|
156
|
+
try {
|
|
157
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
158
|
+
fs.accessSync(baseDir, fs.constants.W_OK);
|
|
159
|
+
pass('Working Dir', baseDir);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
fail('Working Dir', `Cannot write to ${baseDir}: ${err.message}`, 'Check disk permissions or set N8N_FILES_DIR');
|
|
162
|
+
hasCriticalFailure = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log('\n----------------------------------------');
|
|
166
|
+
if (hasCriticalFailure) {
|
|
167
|
+
console.log(`${COLORS.red}${COLORS.bold}✖ FrameForge has missing critical dependencies above.${COLORS.reset}\n`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
} else {
|
|
170
|
+
console.log(`${COLORS.green}${COLORS.bold}✔ FrameForge is ready for local video production!${COLORS.reset}\n`);
|
|
171
|
+
console.log(`Quick run:\n ${COLORS.cyan}npm run make -- --topic "Why Did Ancient Humans Wear Clothes?"${COLORS.reset}\n`);
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|