@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,366 @@
1
+ /**
2
+ * External Platform Adapter
3
+ *
4
+ * Generic HTTP adapter that connects FrameForge to any external platform:
5
+ * - VoxForge (amg555/voxforge)
6
+ * - ElevenLabs (voice cloning + synthesis)
7
+ * - Remotion (React-based animation renderer)
8
+ * - Runway, Pika, Kling, or any video generation API
9
+ * - Any OpenAI-compatible endpoint for LLM/TTS/Image
10
+ * - n8n / Make / Zapier webhooks
11
+ * - Custom REST APIs
12
+ *
13
+ * Configuration (all via .env):
14
+ *
15
+ * EXTERNAL_PLATFORM_NAME=voxforge Label (for logging)
16
+ * EXTERNAL_PLATFORM_URL=https://... Base URL
17
+ * EXTERNAL_PLATFORM_KEY=sk-... API key (added as Bearer token)
18
+ * EXTERNAL_PLATFORM_INGEST_PATH=/v1/ingest POST media files here
19
+ * EXTERNAL_PLATFORM_RENDER_PATH=/v1/render POST scenes, returns job
20
+ * EXTERNAL_PLATFORM_STATUS_PATH=/v1/status GET {id} → {status, output}
21
+ *
22
+ * Usage from FrameForge code:
23
+ *
24
+ * const { ExternalAdapter } = require('./external-adapter');
25
+ * const adapter = new ExternalAdapter();
26
+ * if (adapter.isConfigured()) {
27
+ * const job = await adapter.ingestMedia({ files: ['clip.mp4', 'voice.mp3'] });
28
+ * const result = await adapter.renderScenes({ scenes, productionId });
29
+ * // result.outputPath is the finished video
30
+ * }
31
+ *
32
+ * Usage from agentic IDE (MCP tool: external_platform_call):
33
+ *
34
+ * { action: "ingest", files: ["..."], metadata: {} }
35
+ * { action: "render", scenes: [...], productionId: "..." }
36
+ * { action: "status", jobId: "..." }
37
+ * { action: "export", format: "mp4|remotion|premiere", ... }
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const https = require('https');
45
+ const http = require('http');
46
+ const crypto = require('crypto');
47
+
48
+ // ── Multipart form helpers ────────────────────────────────────────────────────
49
+
50
+ function buildFormData(fields, files) {
51
+ const boundary = `----FrameForge${crypto.randomBytes(8).toString('hex')}`;
52
+ const parts = [];
53
+
54
+ // Text fields
55
+ for (const [name, value] of Object.entries(fields || {})) {
56
+ parts.push(
57
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}`
58
+ );
59
+ }
60
+
61
+ // File fields
62
+ for (const { fieldName, filePath, mimeType } of (files || [])) {
63
+ const filename = path.basename(filePath);
64
+ const data = fs.readFileSync(filePath);
65
+ parts.push(
66
+ `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${mimeType || 'application/octet-stream'}\r\n\r\n${data.toString('binary')}`
67
+ );
68
+ }
69
+
70
+ const body = parts.join('\r\n') + `\r\n--${boundary}--\r\n`;
71
+ return { boundary, body: Buffer.from(body, 'binary') };
72
+ }
73
+
74
+ // ── HTTP helpers ──────────────────────────────────────────────────────────────
75
+
76
+ function httpRequest(url, { method = 'POST', headers = {}, body = null, timeoutMs = 60000 } = {}) {
77
+ return new Promise((resolve, reject) => {
78
+ const parsed = new URL(url);
79
+ const lib = parsed.protocol === 'https:' ? https : http;
80
+ const opts = {
81
+ hostname: parsed.hostname,
82
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
83
+ path: parsed.pathname + parsed.search,
84
+ method,
85
+ headers,
86
+ timeout: timeoutMs,
87
+ };
88
+ const req = lib.request(opts, res => {
89
+ const chunks = [];
90
+ res.on('data', c => chunks.push(c));
91
+ res.on('end', () => {
92
+ const raw = Buffer.concat(chunks).toString('utf8');
93
+ if (res.statusCode >= 400) {
94
+ reject(new Error(`External platform HTTP ${res.statusCode}: ${raw.slice(0, 300)}`));
95
+ } else {
96
+ try { resolve({ status: res.statusCode, data: JSON.parse(raw) }); }
97
+ catch { resolve({ status: res.statusCode, data: raw }); }
98
+ }
99
+ });
100
+ });
101
+ req.on('error', reject);
102
+ req.on('timeout', () => { req.destroy(); reject(new Error(`External platform request timeout: ${url}`)); });
103
+ if (body) req.write(body);
104
+ req.end();
105
+ });
106
+ }
107
+
108
+ // ── Polling helper ────────────────────────────────────────────────────────────
109
+
110
+ async function pollUntilDone(statusUrl, authHeader, { maxWaitMs = 10 * 60 * 1000, intervalMs = 5000 } = {}) {
111
+ const deadline = Date.now() + maxWaitMs;
112
+ while (Date.now() < deadline) {
113
+ const resp = await httpRequest(statusUrl, { method: 'GET', headers: authHeader });
114
+ const status = resp.data?.status || resp.data?.state || '';
115
+ if (['succeeded', 'completed', 'done', 'ready', 'finished'].includes(status.toLowerCase())) {
116
+ return resp.data;
117
+ }
118
+ if (['failed', 'error', 'cancelled', 'canceled'].includes(status.toLowerCase())) {
119
+ throw new Error(`External platform job failed: ${resp.data?.error || status}`);
120
+ }
121
+ await new Promise(r => setTimeout(r, intervalMs));
122
+ }
123
+ throw new Error(`External platform job timed out after ${Math.round(maxWaitMs / 60000)} minutes`);
124
+ }
125
+
126
+ // ── ExternalAdapter class ─────────────────────────────────────────────────────
127
+
128
+ class ExternalAdapter {
129
+ constructor(config = {}) {
130
+ this.name = config.name || process.env.EXTERNAL_PLATFORM_NAME || 'external';
131
+ this.baseUrl = (config.baseUrl || process.env.EXTERNAL_PLATFORM_URL || '').replace(/\/$/, '');
132
+ this.apiKey = config.apiKey || process.env.EXTERNAL_PLATFORM_KEY || '';
133
+ this.ingestPath = config.ingestPath || process.env.EXTERNAL_PLATFORM_INGEST_PATH || '/v1/ingest';
134
+ this.renderPath = config.renderPath || process.env.EXTERNAL_PLATFORM_RENDER_PATH || '/v1/render';
135
+ this.statusPath = config.statusPath || process.env.EXTERNAL_PLATFORM_STATUS_PATH || '/v1/status';
136
+
137
+ // Per-provider presets — users can override with env
138
+ this._applyPreset();
139
+ }
140
+
141
+ /** Returns true if a base URL is configured. */
142
+ isConfigured() {
143
+ return !!this.baseUrl;
144
+ }
145
+
146
+ /**
147
+ * Apply well-known presets for popular platforms so users only need to set the API key.
148
+ * Presets are overridden by any explicit EXTERNAL_PLATFORM_* env vars.
149
+ */
150
+ _applyPreset() {
151
+ const name = this.name.toLowerCase();
152
+ const presets = {
153
+ elevenlabs: {
154
+ baseUrl: 'https://api.elevenlabs.io',
155
+ ingestPath: '/v1/voices/add', // voice cloning
156
+ renderPath: '/v1/text-to-speech', // TTS synthesis
157
+ statusPath: '/v1/voices', // list voices (health check)
158
+ },
159
+ remotion: {
160
+ baseUrl: process.env.REMOTION_BASE_URL || 'http://localhost:3001',
161
+ ingestPath: '/compositions',
162
+ renderPath: '/renders',
163
+ statusPath: '/renders',
164
+ },
165
+ voxforge: {
166
+ baseUrl: this.baseUrl || 'https://api.voxforge.io',
167
+ ingestPath: '/v1/ingest',
168
+ renderPath: '/v1/render',
169
+ statusPath: '/v1/status',
170
+ },
171
+ runway: {
172
+ baseUrl: 'https://api.runwayml.com',
173
+ ingestPath: '/v1/assets',
174
+ renderPath: '/v1/image_to_video',
175
+ statusPath: '/v1/tasks',
176
+ },
177
+ pika: {
178
+ baseUrl: 'https://api.pika.art',
179
+ ingestPath: '/generate',
180
+ renderPath: '/generate',
181
+ statusPath: '/generations',
182
+ },
183
+ };
184
+
185
+ const preset = presets[name];
186
+ if (preset) {
187
+ // Only apply preset fields that haven't been explicitly set via env
188
+ if (!process.env.EXTERNAL_PLATFORM_URL && preset.baseUrl) this.baseUrl = preset.baseUrl;
189
+ if (!process.env.EXTERNAL_PLATFORM_INGEST_PATH && preset.ingestPath) this.ingestPath = preset.ingestPath;
190
+ if (!process.env.EXTERNAL_PLATFORM_RENDER_PATH && preset.renderPath) this.renderPath = preset.renderPath;
191
+ if (!process.env.EXTERNAL_PLATFORM_STATUS_PATH && preset.statusPath) this.statusPath = preset.statusPath;
192
+ }
193
+ }
194
+
195
+ /** Build auth header appropriate for the platform. */
196
+ _authHeader() {
197
+ if (!this.apiKey) return {};
198
+ const name = this.name.toLowerCase();
199
+ // ElevenLabs uses xi-api-key header
200
+ if (name === 'elevenlabs') return { 'xi-api-key': this.apiKey };
201
+ // fal.ai uses Key prefix
202
+ if (name === 'falai') return { 'Authorization': `Key ${this.apiKey}` };
203
+ // Replicate uses Token prefix
204
+ if (name === 'replicate') return { 'Authorization': `Token ${this.apiKey}` };
205
+ // Default: Bearer
206
+ return { 'Authorization': `Bearer ${this.apiKey}` };
207
+ }
208
+
209
+ /**
210
+ * Ingest one or more media files (video clips, images, audio) into the platform.
211
+ * Returns the platform's job/asset metadata.
212
+ *
213
+ * @param {{ files: string[], metadata?: object }} opts
214
+ * @returns {Promise<object>}
215
+ */
216
+ async ingestMedia({ files = [], metadata = {} }) {
217
+ this._requireConfigured();
218
+ const url = `${this.baseUrl}${this.ingestPath}`;
219
+ console.log(`[ExternalAdapter:${this.name}] Ingesting ${files.length} file(s) → ${url}`);
220
+
221
+ // Build multipart body
222
+ const fileEntries = files.map(f => ({
223
+ fieldName: path.extname(f).slice(1) || 'file',
224
+ filePath: f,
225
+ mimeType: _guessMime(f),
226
+ }));
227
+ const { boundary, body } = buildFormData(
228
+ Object.fromEntries(Object.entries(metadata).map(([k, v]) => [k, String(v)])),
229
+ fileEntries
230
+ );
231
+
232
+ const resp = await httpRequest(url, {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': `multipart/form-data; boundary=${boundary.replace('----', '')}`,
236
+ 'Content-Length': String(body.length),
237
+ ...this._authHeader(),
238
+ },
239
+ body,
240
+ timeoutMs: 120000,
241
+ });
242
+ console.log(`[ExternalAdapter:${this.name}] Ingest response: ${JSON.stringify(resp.data).slice(0, 200)}`);
243
+ return resp.data;
244
+ }
245
+
246
+ /**
247
+ * Send scenes (as JSON) to the external platform for rendering.
248
+ * Waits for the job to complete and returns the output path/URL.
249
+ *
250
+ * @param {{ scenes: object[], productionId: string, options?: object }} opts
251
+ * @returns {Promise<{ jobId: string, outputUrl?: string, outputPath?: string, raw: object }>}
252
+ */
253
+ async renderScenes({ scenes, productionId, options = {} }) {
254
+ this._requireConfigured();
255
+ const url = `${this.baseUrl}${this.renderPath}`;
256
+ const body = JSON.stringify({ productionId, scenes, ...options, _source: 'frameforge' });
257
+ console.log(`[ExternalAdapter:${this.name}] Sending ${scenes.length} scene(s) to render → ${url}`);
258
+
259
+ const resp = await httpRequest(url, {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json', 'Content-Length': String(body.length), ...this._authHeader() },
262
+ body,
263
+ timeoutMs: 30000,
264
+ });
265
+
266
+ const jobId = resp.data?.id || resp.data?.job_id || resp.data?.jobId || resp.data?.executionId;
267
+
268
+ // If a status URL is provided, poll until done
269
+ if (jobId && this.statusPath) {
270
+ console.log(`[ExternalAdapter:${this.name}] Polling job ${jobId}…`);
271
+ const statusUrl = `${this.baseUrl}${this.statusPath}/${jobId}`;
272
+ const finalStatus = await pollUntilDone(statusUrl, this._authHeader());
273
+ const outputUrl = finalStatus.output?.[0] || finalStatus.output || finalStatus.url || finalStatus.outputUrl;
274
+ return { jobId, outputUrl, raw: finalStatus };
275
+ }
276
+
277
+ // Job done synchronously or no polling needed
278
+ const outputUrl = resp.data?.output?.[0] || resp.data?.output || resp.data?.url;
279
+ return { jobId, outputUrl, raw: resp.data };
280
+ }
281
+
282
+ /**
283
+ * Check the status of a previously submitted job.
284
+ *
285
+ * @param {string} jobId
286
+ * @returns {Promise<{ status: string, outputUrl?: string, raw: object }>}
287
+ */
288
+ async checkStatus(jobId) {
289
+ this._requireConfigured();
290
+ const url = `${this.baseUrl}${this.statusPath}/${jobId}`;
291
+ const resp = await httpRequest(url, { method: 'GET', headers: this._authHeader(), timeoutMs: 15000 });
292
+ const status = resp.data?.status || resp.data?.state || 'unknown';
293
+ const outputUrl = resp.data?.output?.[0] || resp.data?.output || resp.data?.url;
294
+ return { status, outputUrl, raw: resp.data };
295
+ }
296
+
297
+ /**
298
+ * Download a remote URL to a local path.
299
+ * Useful for fetching rendered outputs from the external platform.
300
+ *
301
+ * @param {string} remoteUrl
302
+ * @param {string} localPath
303
+ * @returns {Promise<string>} resolves with localPath
304
+ */
305
+ downloadOutput(remoteUrl, localPath) {
306
+ // ── Path-traversal guard ────────────────────────────────────────────────
307
+ // localPath must resolve inside FILES_DIR so an agent-supplied path like
308
+ // "../../etc/passwd" or "/tmp/../../root/key" cannot escape the sandbox.
309
+ const filesDir = path.resolve(process.env.N8N_FILES_DIR || require('path').join(require('os').homedir(), '.frameforge-files'));
310
+ const resolved = path.resolve(localPath);
311
+ if (!resolved.startsWith(filesDir + path.sep)) {
312
+ throw new Error(`downloadOutput: localPath must be inside FILES_DIR (${filesDir}): ${localPath}`);
313
+ }
314
+
315
+ // Capture auth header once (before the closure) so `this` binding is unambiguous.
316
+ const authHdr = this._authHeader();
317
+ return new Promise((resolve, reject) => {
318
+ const lib = remoteUrl.startsWith('https') ? https : http;
319
+ const file = fs.createWriteStream(localPath);
320
+ const timer = setTimeout(() => { file.destroy(); reject(new Error('Download timeout')); }, 120000);
321
+ function follow(url, hops) {
322
+ if (hops > 5) { clearTimeout(timer); return reject(new Error('Too many redirects')); }
323
+ lib.get(url, { headers: authHdr }, res => {
324
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
325
+ res.resume(); return follow(res.headers.location, hops + 1);
326
+ }
327
+ if (res.statusCode >= 400) {
328
+ clearTimeout(timer); return reject(new Error(`Download HTTP ${res.statusCode}`));
329
+ }
330
+ res.pipe(file);
331
+ file.on('finish', () => { clearTimeout(timer); file.close(); resolve(localPath); });
332
+ file.on('error', err => { clearTimeout(timer); reject(err); });
333
+ }).on('error', err => { clearTimeout(timer); reject(err); });
334
+ }
335
+ follow(remoteUrl, 0);
336
+ });
337
+ }
338
+
339
+ _requireConfigured() {
340
+ if (!this.isConfigured()) {
341
+ throw new Error(
342
+ `External platform adapter not configured. ` +
343
+ `Set EXTERNAL_PLATFORM_URL (and optionally EXTERNAL_PLATFORM_KEY) in your .env, ` +
344
+ `or run: node src/cli/setup-keys.js`
345
+ );
346
+ }
347
+ }
348
+ }
349
+
350
+ // ── MIME guesser ──────────────────────────────────────────────────────────────
351
+
352
+ function _guessMime(filePath) {
353
+ const ext = path.extname(filePath).toLowerCase();
354
+ const table = {
355
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo',
356
+ '.mkv': 'video/x-matroska', '.webm': 'video/webm',
357
+ '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
358
+ '.wav': 'audio/wav', '.ogg': 'audio/ogg',
359
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
360
+ '.webp': 'image/webp', '.gif': 'image/gif',
361
+ '.json': 'application/json', '.txt': 'text/plain',
362
+ };
363
+ return table[ext] || 'application/octet-stream';
364
+ }
365
+
366
+ module.exports = { ExternalAdapter };