@amg555/frameforge-mcp 0.0.0-stage → 2.3.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.
Files changed (51) hide show
  1. package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
  2. package/.claude-plugin/marketplace.json +17 -0
  3. package/.gitattributes +19 -0
  4. package/.mcp.json +45 -0
  5. package/CHANGELOG.md +431 -0
  6. package/CLAUDE.md +278 -0
  7. package/CONTRIBUTING.md +112 -0
  8. package/LICENSE +21 -0
  9. package/Procfile +1 -0
  10. package/README.md +359 -2
  11. package/SECURITY.md +71 -0
  12. package/START_HERE.md +144 -0
  13. package/assets/metadata/assets.json +5 -0
  14. package/assets/metadata/embeddings.json +5 -0
  15. package/install.sh +64 -0
  16. package/package.json +58 -4
  17. package/src/animation/animation-system.js +229 -0
  18. package/src/asset-library/README.md +188 -0
  19. package/src/asset-library/asset-library.js +639 -0
  20. package/src/audio-mixer/mixer.js +187 -0
  21. package/src/cli/doctor.js +173 -0
  22. package/src/cli/make.js +655 -0
  23. package/src/cli/setup-keys.js +378 -0
  24. package/src/config/load-config.js +72 -0
  25. package/src/fact-checker/checker.js +384 -0
  26. package/src/fact-checker/review-gate.js +306 -0
  27. package/src/fact-checker/validation-set.js +1078 -0
  28. package/src/mcp-server/server.js +1299 -0
  29. package/src/mcp-server/stdio.js +87 -0
  30. package/src/pipeline/artifact-manifest.js +323 -0
  31. package/src/pipeline/artifact-retention.js +189 -0
  32. package/src/pipeline/external-adapter.js +366 -0
  33. package/src/pipeline/providers.js +853 -0
  34. package/src/pipeline/retry-policy.js +134 -0
  35. package/src/pipeline/state-machine.js +297 -0
  36. package/src/pipeline/telemetry.js +162 -0
  37. package/src/quality-gate/gate.js +229 -0
  38. package/src/schema/scene.schema.json +101 -0
  39. package/src/shorts-extractor/extractor.js +343 -0
  40. package/src/utils/log-redaction.js +274 -0
  41. package/src/video-renderer/renderer.js +364 -0
  42. package/src/worker/server-render-free.js +420 -0
  43. package/src/worker/server.js +328 -0
  44. package/src/workflows/assets.js +131 -0
  45. package/src/workflows/ingest.js +299 -0
  46. package/src/workflows/packaging.js +156 -0
  47. package/src/workflows/publishing.js +206 -0
  48. package/src/workflows/render.js +102 -0
  49. package/src/workflows/research.js +151 -0
  50. package/src/workflows/script.js +210 -0
  51. 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
+ }