@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,274 @@
1
+ /**
2
+ * Log Redaction Utility
3
+ *
4
+ * Masks sensitive credential values in logs to prevent secret leakage.
5
+ *
6
+ * Usage:
7
+ * const { maskCredentials, createSafeLogger } = require('./log-redaction');
8
+ * console.log(maskCredentials(message));
9
+ */
10
+
11
+ /**
12
+ * Patterns for credential detection
13
+ */
14
+ const CREDENTIAL_PATTERNS = [
15
+ // Groq API keys
16
+ { pattern: /gsk_[a-zA-Z0-9]{32,}/g, replacement: '***GROQ_API_KEY_REDACTED***' },
17
+
18
+ // Anthropic API keys (sk-ant-api03-...)
19
+ { pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, replacement: '***ANTHROPIC_API_KEY_REDACTED***' },
20
+
21
+ // OpenAI API keys (sk-proj-... or sk-[A-Za-z0-9]{48})
22
+ { pattern: /sk-proj-[A-Za-z0-9_-]{20,}/g, replacement: '***OPENAI_API_KEY_REDACTED***' },
23
+ { pattern: /\bsk-[A-Za-z0-9]{48,}\b/g, replacement: '***OPENAI_API_KEY_REDACTED***' },
24
+
25
+ // OpenRouter API keys (sk-or-v1-...)
26
+ { pattern: /sk-or-v1-[A-Za-z0-9_-]{20,}/g, replacement: '***OPENROUTER_API_KEY_REDACTED***' },
27
+
28
+ // Telegram bot tokens (format: 123456789:ABC-DEF...)
29
+ { pattern: /\d{9,}:[A-Za-z0-9_-]{35}/g, replacement: '***TELEGRAM_TOKEN_REDACTED***' },
30
+
31
+ // Google / YouTube / Gemini API keys (AIza...)
32
+ { pattern: /AIza[A-Za-z0-9_-]{35}/g, replacement: '***GOOGLE_API_KEY_REDACTED***' },
33
+
34
+ // ElevenLabs API keys (sk_...)
35
+ { pattern: /sk_[a-zA-Z0-9]{32,}/g, replacement: '***ELEVENLABS_API_KEY_REDACTED***' },
36
+
37
+ // Tavily API keys (tvly-...)
38
+ { pattern: /tvly-[A-Za-z0-9]{20,}/g, replacement: '***TAVILY_API_KEY_REDACTED***' },
39
+
40
+ // Brave Search API keys (BSA...)
41
+ { pattern: /BSA[A-Za-z0-9]{20,}/g, replacement: '***BRAVE_API_KEY_REDACTED***' },
42
+
43
+ // fal.ai API keys (key_...)
44
+ { pattern: /key_[a-zA-Z0-9]{20,}/g, replacement: '***FALAI_API_KEY_REDACTED***' },
45
+
46
+ // Replicate API keys (r8_...)
47
+ { pattern: /r8_[A-Za-z0-9]{30,}/g, replacement: '***REPLICATE_API_KEY_REDACTED***' },
48
+
49
+ // Azure Speech keys (typically 32-char alphanum, caught below; additional explicit guard)
50
+ // IBM watsonx API keys (IAM keys — same pattern as generic Bearer, caught below)
51
+
52
+ // Internal / MCP auth tokens (32-char hex)
53
+ { pattern: /\b[0-9a-f]{32,64}\b/g, replacement: '***INTERNAL_TOKEN_REDACTED***' },
54
+
55
+ // Generic Bearer tokens in Authorization headers
56
+ { pattern: /Bearer\s+[A-Za-z0-9_\-\.]{20,}/gi, replacement: 'Bearer ***REDACTED***' },
57
+
58
+ // Basic auth (base64 encoded username:password)
59
+ { pattern: /Basic\s+[A-Za-z0-9+\/=]{20,}/gi, replacement: 'Basic ***REDACTED***' },
60
+
61
+ // PostgreSQL connection strings
62
+ { pattern: /postgresql:\/\/[^:]+:[^@]+@[^\/]+\/\w+/gi, replacement: 'postgresql://***REDACTED***' },
63
+ { pattern: /postgres:\/\/[^:]+:[^@]+@[^\/]+\/\w+/gi, replacement: 'postgres://***REDACTED***' },
64
+
65
+ // Generic passwords / tokens / secrets / API keys in key=value or key: value format
66
+ { pattern: /(password|pwd|passwd|token|secret|api[_-]?key|webhook[_-]?url)\s*[=:]\s*["']?[^\s"']{8,}["']?/gi, replacement: '$1=***REDACTED***' },
67
+
68
+ // AWS keys
69
+ { pattern: /AKIA[0-9A-Z]{16}/g, replacement: '***AWS_ACCESS_KEY_REDACTED***' },
70
+
71
+ // Private keys (PEM format)
72
+ { pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----.+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/gs, replacement: '***PRIVATE_KEY_REDACTED***' },
73
+ ];
74
+
75
+ /**
76
+ * Additional patterns for JSON credential objects
77
+ */
78
+ const JSON_CREDENTIAL_KEYS = [
79
+ 'password',
80
+ 'token',
81
+ 'apiKey',
82
+ 'api_key',
83
+ 'secret',
84
+ 'accessToken',
85
+ 'access_token',
86
+ 'refreshToken',
87
+ 'refresh_token',
88
+ 'privateKey',
89
+ 'private_key',
90
+ 'clientSecret',
91
+ 'client_secret',
92
+ // Webhook provider URLs (may embed tokens)
93
+ 'webhookUrl',
94
+ 'webhook_url',
95
+ 'webhookLlmUrl',
96
+ 'webhook_llm_url',
97
+ 'webhookTtsUrl',
98
+ 'webhook_tts_url',
99
+ 'webhookImageUrl',
100
+ 'webhook_image_url',
101
+ // Internal / MCP tokens
102
+ 'internalApiToken',
103
+ 'internal_api_token',
104
+ 'mcpAuthToken',
105
+ 'mcp_auth_token',
106
+ // Search API
107
+ 'searchApiKey',
108
+ 'search_api_key',
109
+ ];
110
+
111
+ /**
112
+ * Mask credentials in a string
113
+ *
114
+ * @param {string} input - The input string potentially containing credentials
115
+ * @returns {string} - The input with credentials masked
116
+ */
117
+ function maskCredentials(input) {
118
+ if (typeof input !== 'string') {
119
+ return input;
120
+ }
121
+
122
+ let output = input;
123
+
124
+ // Apply all regex patterns
125
+ CREDENTIAL_PATTERNS.forEach(({ pattern, replacement }) => {
126
+ output = output.replace(pattern, replacement);
127
+ });
128
+
129
+ return output;
130
+ }
131
+
132
+ /**
133
+ * Mask credentials in JSON objects
134
+ *
135
+ * @param {Object} obj - JSON object potentially containing credentials
136
+ * @returns {Object} - Sanitized copy of the object
137
+ */
138
+ function maskCredentialsInObject(obj) {
139
+ if (typeof obj !== 'object' || obj === null) {
140
+ return obj;
141
+ }
142
+
143
+ if (Array.isArray(obj)) {
144
+ return obj.map(item => maskCredentialsInObject(item));
145
+ }
146
+
147
+ const sanitized = {};
148
+
149
+ for (const [key, value] of Object.entries(obj)) {
150
+ const lowerKey = key.toLowerCase();
151
+
152
+ // Check if key matches credential field names
153
+ if (JSON_CREDENTIAL_KEYS.some(credKey => lowerKey.includes(credKey.toLowerCase()))) {
154
+ sanitized[key] = '***REDACTED***';
155
+ } else if (typeof value === 'string') {
156
+ sanitized[key] = maskCredentials(value);
157
+ } else if (typeof value === 'object') {
158
+ sanitized[key] = maskCredentialsInObject(value);
159
+ } else {
160
+ sanitized[key] = value;
161
+ }
162
+ }
163
+
164
+ return sanitized;
165
+ }
166
+
167
+ /**
168
+ * Create a safe logger wrapper that automatically redacts credentials
169
+ *
170
+ * @param {Object} logger - Original logger object (e.g., console, winston)
171
+ * @returns {Object} - Wrapped logger with credential redaction
172
+ */
173
+ function createSafeLogger(logger = console) {
174
+ const safeLogger = {};
175
+
176
+ ['log', 'info', 'warn', 'error', 'debug'].forEach(method => {
177
+ if (typeof logger[method] === 'function') {
178
+ safeLogger[method] = function(...args) {
179
+ const sanitizedArgs = args.map(arg => {
180
+ if (typeof arg === 'string') {
181
+ return maskCredentials(arg);
182
+ } else if (typeof arg === 'object') {
183
+ return maskCredentialsInObject(arg);
184
+ }
185
+ return arg;
186
+ });
187
+
188
+ logger[method](...sanitizedArgs);
189
+ };
190
+ }
191
+ });
192
+
193
+ return safeLogger;
194
+ }
195
+
196
+ /**
197
+ * Express middleware for redacting credentials in HTTP request/response logs
198
+ *
199
+ * @returns {Function} Express middleware
200
+ */
201
+ function expressRedactionMiddleware() {
202
+ return function(req, res, next) {
203
+ // Store original end function
204
+ const originalEnd = res.end;
205
+
206
+ // Override res.end to capture response
207
+ res.end = function(chunk, encoding) {
208
+ // Redact request headers
209
+ const safeHeaders = maskCredentialsInObject(req.headers);
210
+ req.sanitizedHeaders = safeHeaders;
211
+
212
+ // Redact request body if present
213
+ if (req.body) {
214
+ req.sanitizedBody = maskCredentialsInObject(req.body);
215
+ }
216
+
217
+ // Call original end
218
+ originalEnd.call(res, chunk, encoding);
219
+ };
220
+
221
+ next();
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Test function to verify redaction works
227
+ */
228
+ function testRedaction() {
229
+ const testCases = [
230
+ 'Using API key: gsk_abc123def456ghi789jkl012mno345pqr',
231
+ 'Bot token: 1234567890:ABCdefGHI-jklMNOpqrSTUvwxYZ123456',
232
+ 'Authorization: Bearer AIzaSyAbc123def456ghi789jkl012mno345pqr',
233
+ 'Connection: postgresql://user:pass123@localhost:5432/db',
234
+ 'Config: {"apiKey": "secret123", "password": "hunter2"}',
235
+ 'Normal log message with no secrets'
236
+ ];
237
+
238
+ console.log('=== Log Redaction Test ===\n');
239
+
240
+ testCases.forEach((testCase, i) => {
241
+ console.log(`Test ${i + 1}:`);
242
+ console.log(` Original: ${testCase}`);
243
+ console.log(` Redacted: ${maskCredentials(testCase)}`);
244
+ console.log();
245
+ });
246
+
247
+ const testObject = {
248
+ username: 'admin',
249
+ password: 'secret123',
250
+ apiKey: 'gsk_abc123def456ghi789jkl012mno345pqr',
251
+ data: {
252
+ token: '1234567890:ABCdefGHI-jklMNOpqrSTUvwxYZ123456',
253
+ publicInfo: 'This should not be redacted'
254
+ }
255
+ };
256
+
257
+ console.log('Object test:');
258
+ console.log(' Original:', JSON.stringify(testObject, null, 2));
259
+ console.log(' Redacted:', JSON.stringify(maskCredentialsInObject(testObject), null, 2));
260
+ }
261
+
262
+ // Export functions
263
+ module.exports = {
264
+ maskCredentials,
265
+ maskCredentialsInObject,
266
+ createSafeLogger,
267
+ expressRedactionMiddleware,
268
+ testRedaction
269
+ };
270
+
271
+ // Run test if executed directly
272
+ if (require.main === module) {
273
+ testRedaction();
274
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Video Renderer — Tasks #11, #12, #18
3
+ *
4
+ * Assembles scene images + voice tracks into a master MP4.
5
+ * Uses FFmpeg concat demuxer for frame-accurate assembly.
6
+ *
7
+ * Changes vs. initial version:
8
+ * - Task #11: Uses AnimationSystem for all camera movement; removes inline _zoomFilter
9
+ * - Task #18: Calls AudioMixer.mixVideo() for final LUFS normalisation + optional music ducking
10
+ * - ArtifactManifest: accepts optional manifest for idempotency (skip already-rendered scenes)
11
+ *
12
+ * Scene render parameters:
13
+ * - Image: 1920×1080, zoompan animation (AnimationSystem), caption drawtext
14
+ * - Voice: per-scene m4a (produced by TTSProvider)
15
+ * - Output: H.264 CRF 22, yuv420p, 30fps, AAC 192kbps
16
+ * - Safe zones: top 180px, bottom 400px, right 130px
17
+ *
18
+ * Requirements: Req 2 (Animation System), Req 5 (Audio Mixer), Req 6 (Rendering Pipeline)
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { spawnSync } = require('child_process');
24
+ const path = require('path');
25
+ const os = require('os');
26
+ const fs = require('fs');
27
+
28
+ const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
29
+
30
+ const { buildCameraFilter, selectCameraHint } = require('../animation/animation-system');
31
+ const { AudioMixer } = require('../audio-mixer/mixer');
32
+
33
+ // ── Cross-platform font resolver ──────────────────────────────────────────────
34
+ // Checked in priority order. First existing file wins. Covers:
35
+ // Windows 10/11: C:\Windows\Fonts
36
+ // macOS: /System/Library/Fonts, /Library/Fonts, ~/Library/Fonts
37
+ // Linux (Debian/Ubuntu/Alpine/RHEL): various truetype/opentype paths
38
+ const FONTS = [
39
+ // Windows
40
+ 'C:\\Windows\\Fonts\\arialbd.ttf',
41
+ 'C:\\Windows\\Fonts\\Arial Bold.ttf',
42
+ 'C:\\Windows\\Fonts\\calibrib.ttf',
43
+ 'C:\\Windows\\Fonts\\segoeui.ttf',
44
+ 'C:\\Windows\\Fonts\\tahoma.ttf',
45
+ // macOS system
46
+ '/System/Library/Fonts/Helvetica.ttc',
47
+ '/System/Library/Fonts/Arial Bold.ttf',
48
+ '/System/Library/Fonts/Supplemental/Arial Bold.ttf',
49
+ '/Library/Fonts/Arial Bold.ttf',
50
+ // macOS user
51
+ `${process.env.HOME || ''}/Library/Fonts/Arial Bold.ttf`,
52
+ // Linux Debian/Ubuntu
53
+ '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
54
+ '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
55
+ '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
56
+ // Linux RedHat/Alpine
57
+ '/usr/share/fonts/liberation/LiberationSans-Bold.ttf',
58
+ '/usr/share/fonts/opentype/noto/NotoSans-Bold.ttf',
59
+ '/usr/share/fonts/noto/NotoSans-Bold.ttf',
60
+ '/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
61
+ ];
62
+
63
+ /** Return the first font file that exists, or '' if none found. */
64
+ function resolveFont() {
65
+ // Allow explicit override via FFMPEG_FONT env var
66
+ const override = process.env.FFMPEG_FONT;
67
+ if (override && fs.existsSync(override)) return override;
68
+ return FONTS.find(f => f && fs.existsSync(f)) || '';
69
+ }
70
+
71
+ class VideoRenderer {
72
+ constructor({ filesDir = DEFAULT_FILES_DIR } = {}) {
73
+ this.filesDir = filesDir;
74
+ this.vdir = path.join(filesDir, 'video_tmp');
75
+ this.font = resolveFont();
76
+ this.mixer = new AudioMixer({ filesDir });
77
+ if (!this.font) {
78
+ console.warn('[Renderer] WARNING: no font file found — captions will be disabled. Set FFMPEG_FONT=/path/to/font.ttf or install liberation-fonts.');
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Render a single scene: image or video clip + voice → scene_N_out.mp4
84
+ *
85
+ * @param {{
86
+ * sceneIndex: number,
87
+ * imagePath?: string,
88
+ * videoPath?: string, // optional user clip / B-roll
89
+ * voicePath: string,
90
+ * caption?: string,
91
+ * camera?: string, // camera_hint from scene schema v1.0.0
92
+ * focalPoint?: { x: number, y: number },
93
+ * manifest?: ArtifactManifest // optional — enables idempotency
94
+ * renderWidth?: number, // default 1920
95
+ * renderHeight?: number, // default 1080
96
+ * }} opts
97
+ * @returns {string} outputPath
98
+ */
99
+ renderScene({ sceneIndex, imagePath, videoPath, voicePath, caption = '', camera, focalPoint, manifest, renderWidth = 1920, renderHeight = 1080 } = {}) {
100
+ const outputPath = path.join(this.vdir, `scene_${sceneIndex}_out.mp4`);
101
+
102
+ // Idempotency: skip if already rendered and manifest says so
103
+ if (manifest?.isSceneComplete(sceneIndex)) {
104
+ const existingPath = manifest.scenes[sceneIndex].rendered_path;
105
+ if (existingPath && fs.existsSync(existingPath)) return existingPath;
106
+ }
107
+
108
+ fs.mkdirSync(this.vdir, { recursive: true });
109
+
110
+ // Duration from voice file.
111
+ // No hard 60s cap — long scenes (documentary, lecture, podcast) can run as long as
112
+ // the narration dictates. The per-scene ffmpeg timeout (5 min) is the real ceiling.
113
+ //
114
+ // Fix: guard voicePath=null/undefined before calling ffprobe.
115
+ // _getAudioDuration(null) → ffprobe stdout empty → parseFloat('') = NaN → clampedDur=NaN
116
+ // → ffmpeg receives "-t NaN" → scene silently produces 0-byte output → concat breaks A/V sync.
117
+ if (!voicePath || !fs.existsSync(voicePath)) {
118
+ throw new Error(`Scene ${sceneIndex} render failed: voicePath is missing or does not exist (${voicePath})`);
119
+ }
120
+ const dur = this._getAudioDuration(voicePath);
121
+ const clampedDur = Math.max(2, Number.isFinite(dur) ? dur + 0.15 : 3.15);
122
+
123
+ const mediaSource = videoPath || imagePath;
124
+ const captionFilter = this._captionFilter(caption);
125
+
126
+ // No image and no video: generate a solid dark background so TTS-only
127
+ // scenes (e.g. --no-images) still produce a valid video segment.
128
+ if (!mediaSource) {
129
+ const bgColor = '1e1e2e'; // dark navy
130
+ const result = spawnSync('ffmpeg', [
131
+ '-nostdin', '-y', '-threads', '2',
132
+ '-f', 'lavfi', '-i', `color=c=#${bgColor}:s=${renderWidth}x${renderHeight}:r=30`,
133
+ '-i', voicePath,
134
+ '-map', '0:v:0', '-map', '1:a:0',
135
+ // apad=whole_dur pads audio to exactly clampedDur so -t never truncates mid-word.
136
+ // Specifying whole_dur is mandatory: without it -apad on a color source runs forever.
137
+ '-af', `apad=whole_dur=${clampedDur}`,
138
+ '-t', String(clampedDur),
139
+ ...( captionFilter ? ['-vf', captionFilter] : [] ),
140
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
141
+ '-pix_fmt', 'yuv420p',
142
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
143
+ '-movflags', '+faststart',
144
+ outputPath,
145
+ ], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
146
+
147
+ if (result.status !== 0) {
148
+ throw new Error(`Scene ${sceneIndex} (no-image fallback) render failed: ${(result.stderr || '').slice(-300)}`);
149
+ }
150
+ manifest?.setSceneRendered(sceneIndex, outputPath);
151
+ return outputPath;
152
+ }
153
+
154
+ const isVideoInput = Boolean(videoPath) || /\.(mp4|mov|webm|mkv|m4v)$/i.test(mediaSource);
155
+
156
+ let inputArgs = [];
157
+ let vf = '';
158
+
159
+ if (isVideoInput) {
160
+ // Loop video clip if shorter than duration, scale/crop to target resolution
161
+ inputArgs = ['-stream_loop', '-1', '-i', mediaSource, '-i', voicePath];
162
+ const scaleFilter = `scale=${renderWidth}:${renderHeight}:force_original_aspect_ratio=increase:flags=lanczos,crop=${renderWidth}:${renderHeight}`;
163
+ vf = [scaleFilter, captionFilter].filter(Boolean).join(',');
164
+ } else {
165
+ // Image input with AnimationSystem camera motion
166
+ inputArgs = ['-loop', '1', '-i', mediaSource, '-i', voicePath];
167
+ const hint = camera || selectCameraHint(sceneIndex, focalPoint || {});
168
+ const fx = focalPoint ? { focalX: focalPoint.x, focalY: focalPoint.y } : {};
169
+ const cameraFilter = buildCameraFilter(hint, { duration: clampedDur, w: renderWidth, h: renderHeight, ...fx });
170
+ vf = [cameraFilter, captionFilter].filter(Boolean).join(',');
171
+ }
172
+
173
+ const result = spawnSync('ffmpeg', [
174
+ '-nostdin', '-y', '-threads', '2',
175
+ ...inputArgs,
176
+ '-vf', vf || `scale=${renderWidth}:${renderHeight},format=yuv420p`,
177
+ // apad=whole_dur pads audio to exactly clampedDur, preventing mid-word truncation
178
+ // when voice is fractionally shorter than clampedDur and avoiding infinite padding
179
+ // on looped-image inputs. -t then hard-caps output length at clampedDur.
180
+ '-af', `apad=whole_dur=${clampedDur}`,
181
+ '-t', String(clampedDur),
182
+ '-map', '0:v:0', '-map', '1:a:0',
183
+ '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22',
184
+ '-profile:v', 'high', '-level', '4.1',
185
+ '-color_range', 'tv', '-colorspace', 'bt709',
186
+ '-color_primaries', 'bt709', '-color_trc', 'bt709',
187
+ '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
188
+ '-movflags', '+faststart',
189
+ '-pix_fmt', 'yuv420p',
190
+ outputPath,
191
+ ], { timeout: 5 * 60 * 1000, encoding: 'utf8' });
192
+
193
+ if (result.status !== 0) {
194
+ const err = `Scene ${sceneIndex} render failed: ${(result.stderr || '').slice(-300)}`;
195
+ manifest?.setSceneFailed(sceneIndex, err);
196
+ throw new Error(err);
197
+ }
198
+
199
+ manifest?.setSceneRendered(sceneIndex, outputPath);
200
+ return outputPath;
201
+ }
202
+
203
+ /**
204
+ * Assemble all scene_N_out.mp4 files into final_video.mp4,
205
+ * then pass through AudioMixer for sidechain ducking + LUFS normalisation.
206
+ *
207
+ * @param {{
208
+ * productionId?: string,
209
+ * sceneCount?: number,
210
+ * musicPath?: string, // explicit music file; falls back to random from music/ dir
211
+ * withMusic?: boolean,
212
+ * musicVolume?: number,
213
+ * manifest?: ArtifactManifest
214
+ * }} opts
215
+ * @returns {string} path to the final master video
216
+ */
217
+ assembleMain({ productionId, sceneCount = null, musicPath = null, withMusic = true, musicVolume = 0.07, manifest } = {}) {
218
+ fs.mkdirSync(this.vdir, { recursive: true });
219
+
220
+ const rawPath = path.join(this.filesDir, `raw_concat_${productionId || Date.now()}.mp4`);
221
+ const outputPath = path.join(this.filesDir, `final_video_${productionId || Date.now()}.mp4`);
222
+
223
+ // Discover scene files
224
+ const sceneFiles = this._discoverScenes(sceneCount);
225
+ if (sceneFiles.length < 1) throw new Error(`Too few scenes assembled: ${sceneFiles.length}`);
226
+ if (sceneFiles.length < 3) {
227
+ console.warn(`[Renderer] WARNING: only ${sceneFiles.length} scene(s) — video will be short. Minimum 3 recommended.`);
228
+ }
229
+
230
+ // Build concat list (paths relative to vdir so concat demuxer works)
231
+ const concatPath = path.join(this.vdir, 'concat_main.txt');
232
+ fs.writeFileSync(concatPath, sceneFiles.map(f => `file '${path.basename(f)}'`).join('\n'));
233
+
234
+ // Step 1: Concat scenes → raw_concat.mp4 (no audio processing yet)
235
+ const concatResult = spawnSync('ffmpeg', [
236
+ '-nostdin', '-y', '-threads', '2',
237
+ '-f', 'concat', '-safe', '0', '-i', concatPath,
238
+ '-map', '0:v', '-map', '0:a',
239
+ '-c:v', 'copy', '-c:a', 'copy',
240
+ '-movflags', '+faststart',
241
+ rawPath,
242
+ ], { cwd: this.vdir, timeout: 60 * 60 * 1000, encoding: 'utf8' });
243
+
244
+ if (concatResult.status !== 0) {
245
+ throw new Error(`Concat failed: ${(concatResult.stderr || '').slice(-400)}`);
246
+ }
247
+
248
+ // Step 2: AudioMixer — sidechain ducking + LUFS normalisation (Task #18)
249
+ const resolvedMusic = musicPath || (withMusic ? this._findRandomMusicFile() : null);
250
+ this.mixer.mixVideo({
251
+ videoPath: rawPath,
252
+ musicPath: resolvedMusic,
253
+ outputPath,
254
+ });
255
+
256
+ // Clean up raw concat
257
+ fs.unlink(rawPath, () => {});
258
+
259
+ // Verify
260
+ const stat = fs.statSync(outputPath);
261
+ if (stat.size < 100 * 1024) throw new Error(`Assembled video too small: ${stat.size} bytes`);
262
+
263
+ manifest?.setMaster(outputPath, { qualityPassed: false });
264
+ return outputPath;
265
+ }
266
+
267
+ // ── Filter helpers ────────────────────────────────────────────────────────
268
+
269
+ _captionFilter(caption) {
270
+ if (!caption || !this.font) return '';
271
+ // Sanitize caption for ffmpeg drawtext.
272
+ // Strip all characters that need ffmpeg escaping or that break the drawtext
273
+ // filter syntax. After stripping : and %, re-escaping them is a no-op — removed.
274
+ const safe = caption
275
+ .replace(/['"\\:;|<>{}[\]()!@#$%^&*+=]/g, '')
276
+ .replace(/\s+/g, ' ')
277
+ .trim()
278
+ .slice(0, 45)
279
+ .toUpperCase();
280
+ if (!safe) return '';
281
+ const safeFont = this.font.replace(/\\/g, '/').replace(/:/g, '\\:');
282
+ // Bottom-center, safe zone: 100px from bottom + UI button zone
283
+ return `drawtext=fontfile='${safeFont}':text='${safe}':fontsize=68:fontcolor=white:bordercolor=black:borderw=4:x=(w-text_w)/2:y=h-180:line_spacing=10`;
284
+ }
285
+
286
+ // ── Helpers ───────────────────────────────────────────────────────────────
287
+
288
+ _discoverScenes(sceneCount) {
289
+ if (!fs.existsSync(this.vdir)) return [];
290
+ const all = fs.readdirSync(this.vdir)
291
+ .filter(f => /^scene_\d+_out\.mp4$/.test(f))
292
+ // >10KB guards against truly empty 0-byte renders; actual stream-validity
293
+ // is checked below via ffprobe to catch partial writes from failed encodes.
294
+ .filter(f => fs.statSync(path.join(this.vdir, f)).size > 10000)
295
+ .map(f => path.join(this.vdir, f))
296
+ .filter(f => this._hasValidStreams(f)) // Fix 5: drop scenes with no valid A/V streams
297
+ .sort((a, b) => {
298
+ const na = parseInt(path.basename(a).match(/scene_(\d+)_out/)[1], 10);
299
+ const nb = parseInt(path.basename(b).match(/scene_(\d+)_out/)[1], 10);
300
+ return na - nb;
301
+ });
302
+ return sceneCount ? all.slice(0, sceneCount) : all;
303
+ }
304
+
305
+ /**
306
+ * Returns true iff the file has at least one valid video stream AND one valid audio stream.
307
+ * Uses ffprobe -select_streams to check quickly (< 200ms per file).
308
+ * A failed scene render may produce a partial MP4 container (20-40KB) with no streams;
309
+ * including it in the concat list causes A/V desync for all subsequent scenes.
310
+ */
311
+ _hasValidStreams(filePath) {
312
+ try {
313
+ const checkV = spawnSync('ffprobe', [
314
+ '-v', 'error', '-select_streams', 'v:0',
315
+ '-show_entries', 'stream=codec_type',
316
+ '-of', 'default=nw=1:nk=1', filePath,
317
+ ], { encoding: 'utf8', timeout: 8000 });
318
+ const checkA = spawnSync('ffprobe', [
319
+ '-v', 'error', '-select_streams', 'a:0',
320
+ '-show_entries', 'stream=codec_type',
321
+ '-of', 'default=nw=1:nk=1', filePath,
322
+ ], { encoding: 'utf8', timeout: 8000 });
323
+ const hasVideo = (checkV.stdout || '').trim() === 'video';
324
+ const hasAudio = (checkA.stdout || '').trim() === 'audio';
325
+ if (!hasVideo || !hasAudio) {
326
+ console.warn(`[Renderer] Scene file excluded from concat (missing streams — video:${hasVideo} audio:${hasAudio}): ${path.basename(filePath)}`);
327
+ }
328
+ return hasVideo && hasAudio;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ _findRandomMusicFile() {
335
+ const musicDir = path.join(this.filesDir, 'music');
336
+ if (!fs.existsSync(musicDir)) return null;
337
+ const files = fs.readdirSync(musicDir)
338
+ .filter(f => /\.(mp3|m4a)$/i.test(f))
339
+ .filter(f => fs.statSync(path.join(musicDir, f)).size > 50000)
340
+ .map(f => path.join(musicDir, f));
341
+ return files.length > 0 ? files[Math.floor(Math.random() * files.length)] : null;
342
+ }
343
+
344
+ _getAudioDuration(audioPath) {
345
+ // Callers must validate voicePath before calling this method.
346
+ // Passing null/undefined here means ffprobe will receive a null argument and
347
+ // return empty stdout → parseFloat('') = NaN → NaN propagates to clampedDur.
348
+ // The guard in renderScene() prevents that path, but we also sanitize here.
349
+ if (!audioPath) return 3;
350
+ try {
351
+ const result = spawnSync('ffprobe', [
352
+ '-v', 'error',
353
+ '-show_entries', 'format=duration',
354
+ '-of', 'default=nk=1:nw=1',
355
+ audioPath,
356
+ ], { encoding: 'utf8', timeout: 10000 });
357
+ const parsed = parseFloat((result.stdout || '').trim());
358
+ // parseFloat of an empty string is NaN — fall back to 3s rather than propagating NaN
359
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 3;
360
+ } catch { return 3; }
361
+ }
362
+ }
363
+
364
+ module.exports = { VideoRenderer };