@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.
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +431 -0
- package/CLAUDE.md +278 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +359 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +144 -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 +378 -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,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 };
|