@amg555/frameforge-mcp 0.0.0-stage → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bob/artifacts/enterprise-ai-video-agent-platform-agnostic-implementation-guide.html +464 -0
- package/.claude-plugin/marketplace.json +45 -0
- package/.gitattributes +19 -0
- package/.mcp.json +45 -0
- package/CHANGELOG.md +447 -0
- package/CLAUDE.md +320 -0
- package/CONTRIBUTING.md +112 -0
- package/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +377 -2
- package/SECURITY.md +71 -0
- package/START_HERE.md +200 -0
- package/assets/metadata/assets.json +5 -0
- package/assets/metadata/embeddings.json +5 -0
- package/install.sh +64 -0
- package/package.json +58 -4
- package/src/animation/animation-system.js +229 -0
- package/src/asset-library/README.md +188 -0
- package/src/asset-library/asset-library.js +639 -0
- package/src/audio-mixer/mixer.js +187 -0
- package/src/cli/doctor.js +173 -0
- package/src/cli/make.js +655 -0
- package/src/cli/setup-keys.js +392 -0
- package/src/config/load-config.js +72 -0
- package/src/fact-checker/checker.js +384 -0
- package/src/fact-checker/review-gate.js +306 -0
- package/src/fact-checker/validation-set.js +1078 -0
- package/src/mcp-server/server.js +1299 -0
- package/src/mcp-server/stdio.js +87 -0
- package/src/pipeline/artifact-manifest.js +323 -0
- package/src/pipeline/artifact-retention.js +189 -0
- package/src/pipeline/external-adapter.js +366 -0
- package/src/pipeline/providers.js +853 -0
- package/src/pipeline/retry-policy.js +134 -0
- package/src/pipeline/state-machine.js +297 -0
- package/src/pipeline/telemetry.js +162 -0
- package/src/quality-gate/gate.js +229 -0
- package/src/schema/scene.schema.json +101 -0
- package/src/shorts-extractor/extractor.js +343 -0
- package/src/utils/log-redaction.js +274 -0
- package/src/video-renderer/renderer.js +364 -0
- package/src/worker/server-render-free.js +420 -0
- package/src/worker/server.js +328 -0
- package/src/workflows/assets.js +131 -0
- package/src/workflows/ingest.js +299 -0
- package/src/workflows/packaging.js +156 -0
- package/src/workflows/publishing.js +206 -0
- package/src/workflows/render.js +102 -0
- package/src/workflows/research.js +151 -0
- package/src/workflows/script.js +210 -0
- package/src/workflows/shorts.js +113 -0
|
@@ -0,0 +1,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 };
|