@studiomeyer/mcp-video 1.0.0 → 1.0.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/.github/FUNDING.yml +2 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +9 -0
- package/.github/dependabot.yml +46 -0
- package/.github/workflows/ci.yml +2 -2
- package/CHANGELOG.md +157 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/ECOSYSTEM.md +35 -0
- package/README.md +26 -2
- package/SECURITY.md +11 -0
- package/dist/handlers/smart-screenshot.js +10 -3
- package/dist/handlers/smart-screenshot.js.map +1 -1
- package/dist/handlers/tts.js +6 -2
- package/dist/handlers/tts.js.map +1 -1
- package/dist/handlers/video.js +17 -7
- package/dist/handlers/video.js.map +1 -1
- package/dist/lib/error-sanitizer.d.ts +26 -0
- package/dist/lib/error-sanitizer.js +58 -0
- package/dist/lib/error-sanitizer.js.map +1 -0
- package/dist/lib/error-sanitizer.test.d.ts +1 -0
- package/dist/lib/error-sanitizer.test.js +73 -0
- package/dist/lib/error-sanitizer.test.js.map +1 -0
- package/dist/lib/ffmpeg-bin.d.ts +64 -0
- package/dist/lib/ffmpeg-bin.js +96 -0
- package/dist/lib/ffmpeg-bin.js.map +1 -0
- package/dist/lib/ffmpeg-bin.test.d.ts +18 -0
- package/dist/lib/ffmpeg-bin.test.js +169 -0
- package/dist/lib/ffmpeg-bin.test.js.map +1 -0
- package/dist/lib/ffmpeg-run.d.ts +43 -0
- package/dist/lib/ffmpeg-run.js +67 -0
- package/dist/lib/ffmpeg-run.js.map +1 -0
- package/dist/lib/ffmpeg-run.test.d.ts +1 -0
- package/dist/lib/ffmpeg-run.test.js +66 -0
- package/dist/lib/ffmpeg-run.test.js.map +1 -0
- package/dist/lib/ffmpeg-safety.d.ts +37 -0
- package/dist/lib/ffmpeg-safety.js +67 -0
- package/dist/lib/ffmpeg-safety.js.map +1 -0
- package/dist/lib/ffmpeg-safety.test.d.ts +1 -0
- package/dist/lib/ffmpeg-safety.test.js +72 -0
- package/dist/lib/ffmpeg-safety.test.js.map +1 -0
- package/dist/lib/temp-dir.d.ts +24 -0
- package/dist/lib/temp-dir.js +53 -0
- package/dist/lib/temp-dir.js.map +1 -0
- package/dist/lib/temp-dir.test.d.ts +1 -0
- package/dist/lib/temp-dir.test.js +68 -0
- package/dist/lib/temp-dir.test.js.map +1 -0
- package/dist/lib/url-guard.d.ts +41 -0
- package/dist/lib/url-guard.js +134 -0
- package/dist/lib/url-guard.js.map +1 -0
- package/dist/lib/url-guard.test.d.ts +10 -0
- package/dist/lib/url-guard.test.js +231 -0
- package/dist/lib/url-guard.test.js.map +1 -0
- package/dist/server.js +9 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/engine/audio-mixer.js +5 -20
- package/dist/tools/engine/audio-mixer.js.map +1 -1
- package/dist/tools/engine/audio.js +3 -19
- package/dist/tools/engine/audio.js.map +1 -1
- package/dist/tools/engine/beat-sync.js +7 -30
- package/dist/tools/engine/beat-sync.js.map +1 -1
- package/dist/tools/engine/capture.js +7 -0
- package/dist/tools/engine/capture.js.map +1 -1
- package/dist/tools/engine/chroma-key.js +2 -11
- package/dist/tools/engine/chroma-key.js.map +1 -1
- package/dist/tools/engine/concat.js +2 -11
- package/dist/tools/engine/concat.js.map +1 -1
- package/dist/tools/engine/editing.js +12 -35
- package/dist/tools/engine/editing.js.map +1 -1
- package/dist/tools/engine/encoder.js +2 -12
- package/dist/tools/engine/encoder.js.map +1 -1
- package/dist/tools/engine/lut-presets.js +2 -11
- package/dist/tools/engine/lut-presets.js.map +1 -1
- package/dist/tools/engine/narrated-video.js +30 -39
- package/dist/tools/engine/narrated-video.js.map +1 -1
- package/dist/tools/engine/smart-screenshot.js +7 -0
- package/dist/tools/engine/smart-screenshot.js.map +1 -1
- package/dist/tools/engine/social-format.js +2 -11
- package/dist/tools/engine/social-format.js.map +1 -1
- package/dist/tools/engine/template-renderer.js +2 -11
- package/dist/tools/engine/template-renderer.js.map +1 -1
- package/dist/tools/engine/text-animations.js +2 -11
- package/dist/tools/engine/text-animations.js.map +1 -1
- package/dist/tools/engine/text-overlay.js +2 -11
- package/dist/tools/engine/text-overlay.js.map +1 -1
- package/dist/tools/engine/tts.js +11 -6
- package/dist/tools/engine/tts.js.map +1 -1
- package/dist/tools/engine/voice-effects.js +3 -20
- package/dist/tools/engine/voice-effects.js.map +1 -1
- package/package.json +6 -6
- package/src/handlers/smart-screenshot.ts +8 -3
- package/src/handlers/tts.ts +6 -2
- package/src/handlers/video.ts +14 -7
- package/src/lib/error-sanitizer.test.ts +88 -0
- package/src/lib/error-sanitizer.ts +66 -0
- package/src/lib/ffmpeg-bin.test.ts +192 -0
- package/src/lib/ffmpeg-bin.ts +111 -0
- package/src/lib/ffmpeg-run.test.ts +76 -0
- package/src/lib/ffmpeg-run.ts +110 -0
- package/src/lib/ffmpeg-safety.test.ts +88 -0
- package/src/lib/ffmpeg-safety.ts +79 -0
- package/src/lib/temp-dir.test.ts +75 -0
- package/src/lib/temp-dir.ts +58 -0
- package/src/lib/url-guard.test.ts +261 -0
- package/src/lib/url-guard.ts +143 -0
- package/src/server.ts +10 -5
- package/src/tools/engine/audio-mixer.ts +8 -21
- package/src/tools/engine/audio.ts +6 -21
- package/src/tools/engine/beat-sync.ts +10 -31
- package/src/tools/engine/capture.ts +8 -0
- package/src/tools/engine/chroma-key.ts +2 -11
- package/src/tools/engine/concat.ts +2 -11
- package/src/tools/engine/editing.ts +17 -34
- package/src/tools/engine/encoder.ts +2 -12
- package/src/tools/engine/lut-presets.ts +2 -11
- package/src/tools/engine/narrated-video.ts +26 -38
- package/src/tools/engine/smart-screenshot.ts +8 -0
- package/src/tools/engine/social-format.ts +2 -11
- package/src/tools/engine/template-renderer.ts +2 -11
- package/src/tools/engine/text-animations.ts +2 -11
- package/src/tools/engine/text-overlay.ts +2 -11
- package/src/tools/engine/tts.ts +15 -6
- package/src/tools/engine/voice-effects.ts +3 -17
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL safety guard for any tool that fetches/navigates user-supplied URLs.
|
|
3
|
+
*
|
|
4
|
+
* Blocks four classes of SSRF abuse that an AI assistant can stumble into
|
|
5
|
+
* when the server is asked to open arbitrary URLs:
|
|
6
|
+
* 1. Non-http(s) schemes (file://, ftp://, gopher://, data: etc.)
|
|
7
|
+
* 2. Loopback + link-local + RFC1918 + cloud metadata endpoints
|
|
8
|
+
* 3. IPv6-mapped IPv4 (::ffff:127.0.0.1) that slips past IPv4 regex
|
|
9
|
+
* 4. DNS-rebinding — hostnames that resolve to internal IPs
|
|
10
|
+
*
|
|
11
|
+
* Three entry points:
|
|
12
|
+
* • guardUrl(raw) sync — scheme + literal-host check
|
|
13
|
+
* • resolveAndGuardUrl(raw) async — adds DNS.lookup(family:0) on the host
|
|
14
|
+
* • guardFinalUrl(raw) sync — post-redirect check, reuses guardUrl
|
|
15
|
+
*
|
|
16
|
+
* Opt-in escape hatch for local dev: set MCP_VIDEO_ALLOW_INTERNAL=1.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { lookup } from 'node:dns/promises';
|
|
20
|
+
|
|
21
|
+
export type UrlGuardResult = { ok: true; url: string } | { ok: false; reason: string };
|
|
22
|
+
|
|
23
|
+
const ALLOWED_SCHEMES = new Set(['https:', 'http:']);
|
|
24
|
+
|
|
25
|
+
const BLOCKED_HOST_PATTERNS: RegExp[] = [
|
|
26
|
+
/^localhost$/i,
|
|
27
|
+
/^(?:127|10)\./,
|
|
28
|
+
/^192\.168\./,
|
|
29
|
+
/^172\.(?:1[6-9]|2\d|3[0-1])\./,
|
|
30
|
+
/^169\.254\./, // link-local + AWS/GCP/Azure metadata (169.254.169.254)
|
|
31
|
+
/^0\./,
|
|
32
|
+
/^::1$/,
|
|
33
|
+
// IPv6 unique-local is fc00::/7 — that covers fc00..fdff.
|
|
34
|
+
/^f[cd][0-9a-f]{2}:/i,
|
|
35
|
+
/^fe80:/i, // IPv6 link-local
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// IPv4 dotted-quad literal (captures the form the OS + URL parser canonicalize to).
|
|
39
|
+
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
40
|
+
|
|
41
|
+
// IPv6-mapped IPv4 in dotted form: ::ffff:127.0.0.1
|
|
42
|
+
const IPV6_MAPPED_IPV4_DOTTED = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
43
|
+
// IPv6-mapped IPv4 in compact hex form: ::ffff:7f00:1
|
|
44
|
+
const IPV6_MAPPED_IPV4_HEX = /^::ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/i;
|
|
45
|
+
// IPv6-mapped IPv4 in fully uncompressed form: 0:0:0:0:0:ffff:7f00:1
|
|
46
|
+
const IPV6_MAPPED_IPV4_FULL = /^0:0:0:0:0:ffff:[0-9a-f]{1,4}:[0-9a-f]{1,4}$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeHost(hostname: string): string {
|
|
49
|
+
// URL parser strips brackets from IPv6 hostnames already; be defensive
|
|
50
|
+
// in case a caller passes the raw host string.
|
|
51
|
+
const unbracketed = hostname.replace(/^\[/, '').replace(/\]$/, '');
|
|
52
|
+
const mapped = unbracketed.match(IPV6_MAPPED_IPV4_DOTTED);
|
|
53
|
+
if (mapped) return mapped[1]; // re-check dotted form against IPv4 patterns
|
|
54
|
+
return unbracketed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isMappedIpv4(host: string): boolean {
|
|
58
|
+
return (
|
|
59
|
+
IPV6_MAPPED_IPV4_DOTTED.test(host) ||
|
|
60
|
+
IPV6_MAPPED_IPV4_HEX.test(host) ||
|
|
61
|
+
IPV6_MAPPED_IPV4_FULL.test(host)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isBlockedHost(hostname: string): string | null {
|
|
66
|
+
const normalized = normalizeHost(hostname);
|
|
67
|
+
if (isMappedIpv4(normalized)) {
|
|
68
|
+
return `host ${hostname} is IPv6-mapped IPv4 — blocked`;
|
|
69
|
+
}
|
|
70
|
+
for (const pat of BLOCKED_HOST_PATTERNS) {
|
|
71
|
+
if (pat.test(normalized)) {
|
|
72
|
+
return `host ${hostname} is private or loopback — set MCP_VIDEO_ALLOW_INTERNAL=1 to override`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function guardUrl(raw: unknown): UrlGuardResult {
|
|
79
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
80
|
+
return { ok: false, reason: 'url must be a non-empty string' };
|
|
81
|
+
}
|
|
82
|
+
let parsed: URL;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, reason: 'url is not a valid URL' };
|
|
87
|
+
}
|
|
88
|
+
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
|
|
89
|
+
return { ok: false, reason: `scheme ${parsed.protocol} is not allowed — use http(s)` };
|
|
90
|
+
}
|
|
91
|
+
if (process.env.MCP_VIDEO_ALLOW_INTERNAL === '1') {
|
|
92
|
+
return { ok: true, url: parsed.toString() };
|
|
93
|
+
}
|
|
94
|
+
const blocked = isBlockedHost(parsed.hostname);
|
|
95
|
+
if (blocked) return { ok: false, reason: blocked };
|
|
96
|
+
return { ok: true, url: parsed.toString() };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Async variant that also resolves the hostname via DNS and checks every
|
|
101
|
+
* returned IP against the block list. Catches the simple rebind case where
|
|
102
|
+
* a public hostname resolves to a loopback or RFC1918 address.
|
|
103
|
+
*
|
|
104
|
+
* NOTE: This does not eliminate TOCTOU windows — the browser/ffmpeg will
|
|
105
|
+
* resolve again at request time. But it blocks the trivial path and forces
|
|
106
|
+
* an attacker to rely on narrow TTL-based flipping.
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveAndGuardUrl(raw: unknown): Promise<UrlGuardResult> {
|
|
109
|
+
const first = guardUrl(raw);
|
|
110
|
+
if (!first.ok) return first;
|
|
111
|
+
if (process.env.MCP_VIDEO_ALLOW_INTERNAL === '1') return first;
|
|
112
|
+
const parsed = new URL(first.url);
|
|
113
|
+
const host = parsed.hostname.replace(/^\[/, '').replace(/\]$/, '');
|
|
114
|
+
|
|
115
|
+
// Literal IPv4/IPv6 — guardUrl already checked, nothing to resolve.
|
|
116
|
+
if (IPV4_LITERAL.test(host) || host.includes(':')) return first;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const addresses = await lookup(host, { all: true, family: 0 });
|
|
120
|
+
for (const addr of addresses) {
|
|
121
|
+
const blocked = isBlockedHost(addr.address);
|
|
122
|
+
if (blocked) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: `host ${host} resolves to private address ${addr.address} — blocked`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
131
|
+
return { ok: false, reason: `DNS lookup failed for ${host}: ${msg}` };
|
|
132
|
+
}
|
|
133
|
+
return first;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Post-navigation check: after `page.goto()` the browser may have followed
|
|
138
|
+
* redirects to an internal host. Pass `page.url()` through this to confirm
|
|
139
|
+
* the final URL is still guard-clean.
|
|
140
|
+
*/
|
|
141
|
+
export function guardFinalUrl(raw: unknown): UrlGuardResult {
|
|
142
|
+
return guardUrl(raw);
|
|
143
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -254,15 +254,20 @@ You have 8 tools for video production. Each has a \`type\` parameter to select t
|
|
|
254
254
|
|
|
255
255
|
// ─── Startup Validation ──────────────────────────────────
|
|
256
256
|
|
|
257
|
-
import { execSync } from 'child_process';
|
|
258
257
|
import * as fs from 'fs';
|
|
258
|
+
import { assertFfmpegBinAvailable } from './lib/ffmpeg-bin.js';
|
|
259
259
|
|
|
260
260
|
function checkDependencies(): void {
|
|
261
|
-
|
|
261
|
+
// Cross-platform binary check (issue #11): the previous version called
|
|
262
|
+
// `which ffmpeg`, which is Unix-only and silently failed on Windows even
|
|
263
|
+
// when ffmpeg was on PATH. assertFfmpegBinAvailable invokes `<bin> -version`
|
|
264
|
+
// directly via execFile (no shell), honours FFMPEG_PATH / FFPROBE_PATH env
|
|
265
|
+
// overrides, and works on Linux, macOS, and Windows.
|
|
266
|
+
for (const bin of ['ffmpeg', 'ffprobe'] as const) {
|
|
262
267
|
try {
|
|
263
|
-
|
|
264
|
-
} catch {
|
|
265
|
-
logger.error(
|
|
268
|
+
assertFfmpegBinAvailable(bin);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
logger.error(err instanceof Error ? err.message : String(err));
|
|
266
271
|
process.exit(1);
|
|
267
272
|
}
|
|
268
273
|
}
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* Per-track: volume, fade in/out.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execFile } from 'child_process';
|
|
10
9
|
import * as fs from 'fs';
|
|
11
10
|
import * as path from 'path';
|
|
12
11
|
import { logger } from '../../lib/logger.js';
|
|
12
|
+
import { runFfmpeg as runFfmpegSafe, runFfprobe as runFfprobeSafe } from '../../lib/ffmpeg-run.js';
|
|
13
13
|
|
|
14
14
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
15
15
|
|
|
@@ -51,16 +51,7 @@ export interface AudioMixResult {
|
|
|
51
51
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
52
52
|
|
|
53
53
|
function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
|
|
54
|
-
return
|
|
55
|
-
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
56
|
-
if (error) {
|
|
57
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
58
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
resolve(stdout);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
54
|
+
return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'audio-mixer' });
|
|
64
55
|
}
|
|
65
56
|
|
|
66
57
|
function ensureDir(filePath: string): void {
|
|
@@ -78,16 +69,12 @@ function fileInfo(filePath: string): string {
|
|
|
78
69
|
}
|
|
79
70
|
|
|
80
71
|
function getMediaDuration(filePath: string): Promise<number> {
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const dur = parseFloat(stdout.trim());
|
|
88
|
-
resolve(isNaN(dur) ? 0 : dur);
|
|
89
|
-
}
|
|
90
|
-
);
|
|
72
|
+
return runFfprobeSafe(
|
|
73
|
+
['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
|
|
74
|
+
{ maxBuffer: 10 * 1024 * 1024, label: 'audio-mixer-probe' },
|
|
75
|
+
).then((s) => {
|
|
76
|
+
const dur = parseFloat(s.trim());
|
|
77
|
+
return isNaN(dur) ? 0 : dur;
|
|
91
78
|
});
|
|
92
79
|
}
|
|
93
80
|
|
|
@@ -3,24 +3,18 @@
|
|
|
3
3
|
* All processing via ffmpeg + ffprobe (no npm dependencies)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { execFile } from 'child_process';
|
|
7
6
|
import * as fs from 'fs';
|
|
8
7
|
import * as path from 'path';
|
|
9
8
|
import { logger } from '../../lib/logger.js';
|
|
9
|
+
import { runFfmpeg as runFfmpegSafe, runFfprobe as runFfprobeSafe } from '../../lib/ffmpeg-run.js';
|
|
10
10
|
|
|
11
11
|
// ─── ffprobe helper ─────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
export function getMediaDuration(filePath: string): Promise<number> {
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
(error, stdout) => {
|
|
19
|
-
if (error) reject(new Error(`ffprobe failed: ${error.message}`));
|
|
20
|
-
else resolve(parseFloat(stdout.trim()) || 0);
|
|
21
|
-
}
|
|
22
|
-
);
|
|
23
|
-
});
|
|
14
|
+
return runFfprobeSafe(
|
|
15
|
+
['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
|
|
16
|
+
{ maxBuffer: 10 * 1024 * 1024, label: 'audio-probe' },
|
|
17
|
+
).then((s) => parseFloat(s.trim()) || 0);
|
|
24
18
|
}
|
|
25
19
|
|
|
26
20
|
// ─── Background Music ───────────────────────────────────────────────
|
|
@@ -102,14 +96,5 @@ export async function addBackgroundMusic(config: AddMusicConfig): Promise<string
|
|
|
102
96
|
// ─── ffmpeg runner ──────────────────────────────────────────────────
|
|
103
97
|
|
|
104
98
|
function runFfmpeg(args: string[]): Promise<string> {
|
|
105
|
-
return
|
|
106
|
-
execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
107
|
-
if (error) {
|
|
108
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
109
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
resolve(stdout);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
99
|
+
return runFfmpegSafe(args, { maxBuffer: 50 * 1024 * 1024, label: 'audio' });
|
|
115
100
|
}
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Flow: Analyze audio → find beat positions → cut clips to beats → concatenate
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { execFile } from 'child_process';
|
|
11
10
|
import * as fs from 'fs';
|
|
12
11
|
import * as path from 'path';
|
|
13
12
|
import { logger } from '../../lib/logger.js';
|
|
13
|
+
import { runFfmpeg as runFfmpegSafe, runFfprobe as runFfprobeSafe } from '../../lib/ffmpeg-run.js';
|
|
14
14
|
|
|
15
15
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -41,29 +41,12 @@ export interface BeatSyncResult {
|
|
|
41
41
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
42
42
|
|
|
43
43
|
function runFfmpeg(args: string[], timeoutMs = 600_000): Promise<string> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (error) {
|
|
47
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
48
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
resolve(stderr); // ffmpeg outputs filter info to stderr
|
|
52
|
-
});
|
|
53
|
-
});
|
|
44
|
+
// ffmpeg outputs filter info to stderr for beat detection; request stderr.
|
|
45
|
+
return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'beat-sync' }, 'stderr');
|
|
54
46
|
}
|
|
55
47
|
|
|
56
48
|
function runFfmpegStdout(args: string[], timeoutMs = 300_000): Promise<string> {
|
|
57
|
-
return
|
|
58
|
-
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
59
|
-
if (error) {
|
|
60
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
61
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
resolve(stdout);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
49
|
+
return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'beat-sync-stdout' });
|
|
67
50
|
}
|
|
68
51
|
|
|
69
52
|
function ensureDir(filePath: string): void {
|
|
@@ -81,16 +64,12 @@ function fileInfo(filePath: string): string {
|
|
|
81
64
|
}
|
|
82
65
|
|
|
83
66
|
function getMediaDuration(filePath: string): Promise<number> {
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const dur = parseFloat(stdout.trim());
|
|
91
|
-
resolve(isNaN(dur) ? 0 : dur);
|
|
92
|
-
}
|
|
93
|
-
);
|
|
67
|
+
return runFfprobeSafe(
|
|
68
|
+
['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
|
|
69
|
+
{ maxBuffer: 10 * 1024 * 1024, label: 'beat-sync-probe' },
|
|
70
|
+
).then((s) => {
|
|
71
|
+
const dur = parseFloat(s.trim());
|
|
72
|
+
return isNaN(dur) ? 0 : dur;
|
|
94
73
|
});
|
|
95
74
|
}
|
|
96
75
|
|
|
@@ -12,6 +12,7 @@ import * as fs from 'fs';
|
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import * as os from 'os';
|
|
14
14
|
import { logger } from '../../lib/logger.js';
|
|
15
|
+
import { guardFinalUrl } from '../../lib/url-guard.js';
|
|
15
16
|
import type { RecordingConfig, RecordingResult, ViewportConfig, Scene } from './types.js';
|
|
16
17
|
import { VIEWPORTS } from './types.js';
|
|
17
18
|
import { injectCursor, hideCursor } from './cursor.js';
|
|
@@ -93,6 +94,13 @@ export async function recordWebsite(config: RecordingConfig): Promise<RecordingR
|
|
|
93
94
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
94
95
|
});
|
|
95
96
|
|
|
97
|
+
// Post-redirect guard: browser may have followed 302/301 to an internal host.
|
|
98
|
+
const finalUrl = page.url();
|
|
99
|
+
const finalGuard = guardFinalUrl(finalUrl);
|
|
100
|
+
if (!finalGuard.ok) {
|
|
101
|
+
throw new Error(`post-redirect check failed — final URL rejected: ${finalGuard.reason}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
// Wait for content to render
|
|
97
105
|
await page.waitForTimeout(2000);
|
|
98
106
|
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* - Composite onto replacement background (video, image, or solid color)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { execFile } from 'child_process';
|
|
12
11
|
import * as fs from 'fs';
|
|
13
12
|
import * as path from 'path';
|
|
14
13
|
import { logger } from '../../lib/logger.js';
|
|
14
|
+
import { runFfmpeg as runFfmpegSafe } from '../../lib/ffmpeg-run.js';
|
|
15
15
|
|
|
16
16
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
17
17
|
|
|
@@ -36,16 +36,7 @@ export interface ChromaKeyConfig {
|
|
|
36
36
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
38
|
function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
|
|
39
|
-
return
|
|
40
|
-
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
41
|
-
if (error) {
|
|
42
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
43
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
resolve(stdout);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
39
|
+
return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'chroma-key' });
|
|
49
40
|
}
|
|
50
41
|
|
|
51
42
|
function ensureDir(filePath: string): void {
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Video concatenation engine — merge multiple clips with cinematic transitions
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { execFile } from 'child_process';
|
|
6
5
|
import * as fs from 'fs';
|
|
7
6
|
import * as path from 'path';
|
|
8
7
|
import { logger } from '../../lib/logger.js';
|
|
8
|
+
import { runFfmpeg as runFfmpegSafe } from '../../lib/ffmpeg-run.js';
|
|
9
9
|
import { getMediaDuration } from './audio.js';
|
|
10
10
|
|
|
11
11
|
// ─── Available Transitions ──────────────────────────────────────────
|
|
@@ -229,14 +229,5 @@ async function findFont(): Promise<string> {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
function runFfmpeg(args: string[]): Promise<string> {
|
|
232
|
-
return
|
|
233
|
-
execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
234
|
-
if (error) {
|
|
235
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
236
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
resolve(stdout);
|
|
240
|
-
});
|
|
241
|
-
});
|
|
232
|
+
return runFfmpegSafe(args, { maxBuffer: 50 * 1024 * 1024, label: 'concat' });
|
|
242
233
|
}
|
|
@@ -5,25 +5,16 @@
|
|
|
5
5
|
* All processing via ffmpeg (no npm dependencies).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { execFile } from 'child_process';
|
|
9
8
|
import * as fs from 'fs';
|
|
10
9
|
import * as path from 'path';
|
|
11
10
|
import { logger } from '../../lib/logger.js';
|
|
11
|
+
import { runFfmpeg as runFfmpegSafe, runFfprobe as runFfprobeSafe } from '../../lib/ffmpeg-run.js';
|
|
12
12
|
import { getMediaDuration } from './audio.js';
|
|
13
13
|
|
|
14
14
|
// ─── Shared ffmpeg runner ────────────────────────────────────────────
|
|
15
15
|
|
|
16
16
|
function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
|
|
17
|
-
return
|
|
18
|
-
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
19
|
-
if (error) {
|
|
20
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
21
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
resolve(stdout);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
17
|
+
return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'editing' });
|
|
27
18
|
}
|
|
28
19
|
|
|
29
20
|
function ensureDir(filePath: string): void {
|
|
@@ -37,16 +28,10 @@ function assertExists(filePath: string, label = 'File'): void {
|
|
|
37
28
|
|
|
38
29
|
/** Check if a media file has an audio stream */
|
|
39
30
|
function hasAudioStream(filePath: string): Promise<boolean> {
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
(error, stdout) => {
|
|
45
|
-
if (error) { resolve(false); return; }
|
|
46
|
-
resolve(stdout.trim().length > 0);
|
|
47
|
-
}
|
|
48
|
-
);
|
|
49
|
-
});
|
|
31
|
+
return runFfprobeSafe(
|
|
32
|
+
['-v', 'quiet', '-select_streams', 'a', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath],
|
|
33
|
+
{ maxBuffer: 10 * 1024 * 1024, label: 'editing-probe-audio' },
|
|
34
|
+
).then((s) => s.trim().length > 0).catch(() => false);
|
|
50
35
|
}
|
|
51
36
|
|
|
52
37
|
function fileInfo(filePath: string): string {
|
|
@@ -660,19 +645,17 @@ function buildInterpolationExpr(keyframes: Keyframe[], prop: 'scale' | 'panX' |
|
|
|
660
645
|
}
|
|
661
646
|
|
|
662
647
|
async function getVideoResolution(filePath: string): Promise<{ width: number; height: number }> {
|
|
663
|
-
return
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
);
|
|
648
|
+
return runFfprobeSafe(
|
|
649
|
+
['-v', 'quiet', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', filePath],
|
|
650
|
+
{ maxBuffer: 10 * 1024 * 1024, label: 'editing-probe-resolution' },
|
|
651
|
+
).then((stdout) => {
|
|
652
|
+
try {
|
|
653
|
+
const data = JSON.parse(stdout);
|
|
654
|
+
const stream = data.streams?.[0];
|
|
655
|
+
return { width: stream?.width ?? 1920, height: stream?.height ?? 1080 };
|
|
656
|
+
} catch {
|
|
657
|
+
return { width: 1920, height: 1080 };
|
|
658
|
+
}
|
|
676
659
|
});
|
|
677
660
|
}
|
|
678
661
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* ffmpeg encoding pipeline — stitches PNG frames into cinema-grade video
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { execFile } from 'child_process';
|
|
6
5
|
import * as fs from 'fs';
|
|
7
6
|
import * as path from 'path';
|
|
8
7
|
import { logger } from '../../lib/logger.js';
|
|
8
|
+
import { runFfmpeg as runFfmpegSafe } from '../../lib/ffmpeg-run.js';
|
|
9
9
|
import type { EncodingConfig, VideoCodec, VideoFormat } from './types.js';
|
|
10
10
|
|
|
11
11
|
interface EncodeResult {
|
|
@@ -180,17 +180,7 @@ export async function concatenateWithTransition(
|
|
|
180
180
|
* Run ffmpeg command and return a promise
|
|
181
181
|
*/
|
|
182
182
|
function runFfmpeg(args: string[]): Promise<string> {
|
|
183
|
-
return
|
|
184
|
-
execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
185
|
-
if (error) {
|
|
186
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
187
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
logger.debug(`ffmpeg output: ${stderr.slice(-200)}`);
|
|
191
|
-
resolve(stdout);
|
|
192
|
-
});
|
|
193
|
-
});
|
|
183
|
+
return runFfmpegSafe(args, { maxBuffer: 50 * 1024 * 1024, label: 'encoder' });
|
|
194
184
|
}
|
|
195
185
|
|
|
196
186
|
/**
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* Intensity parameter (0.0-1.0) blends graded output with the original.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execFile } from 'child_process';
|
|
10
9
|
import * as fs from 'fs';
|
|
11
10
|
import * as path from 'path';
|
|
12
11
|
import { logger } from '../../lib/logger.js';
|
|
12
|
+
import { runFfmpeg as runFfmpegSafe } from '../../lib/ffmpeg-run.js';
|
|
13
13
|
|
|
14
14
|
// ─── Types ──────────────────────────────────────────────────────────
|
|
15
15
|
|
|
@@ -147,16 +147,7 @@ export const ALL_LUT_PRESETS = Object.keys(PRESET_FILTERS) as LutPreset[];
|
|
|
147
147
|
// ─── Helpers ────────────────────────────────────────────────────────
|
|
148
148
|
|
|
149
149
|
function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
|
|
150
|
-
return
|
|
151
|
-
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
152
|
-
if (error) {
|
|
153
|
-
logger.error(`ffmpeg failed: ${stderr}`);
|
|
154
|
-
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
resolve(stdout);
|
|
158
|
-
});
|
|
159
|
-
});
|
|
150
|
+
return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'lut-presets' });
|
|
160
151
|
}
|
|
161
152
|
|
|
162
153
|
function ensureDir(filePath: string): void {
|