@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.
Files changed (121) hide show
  1. package/.github/FUNDING.yml +2 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +9 -0
  3. package/.github/dependabot.yml +46 -0
  4. package/.github/workflows/ci.yml +2 -2
  5. package/CHANGELOG.md +157 -0
  6. package/CODE_OF_CONDUCT.md +7 -0
  7. package/ECOSYSTEM.md +35 -0
  8. package/README.md +26 -2
  9. package/SECURITY.md +11 -0
  10. package/dist/handlers/smart-screenshot.js +10 -3
  11. package/dist/handlers/smart-screenshot.js.map +1 -1
  12. package/dist/handlers/tts.js +6 -2
  13. package/dist/handlers/tts.js.map +1 -1
  14. package/dist/handlers/video.js +17 -7
  15. package/dist/handlers/video.js.map +1 -1
  16. package/dist/lib/error-sanitizer.d.ts +26 -0
  17. package/dist/lib/error-sanitizer.js +58 -0
  18. package/dist/lib/error-sanitizer.js.map +1 -0
  19. package/dist/lib/error-sanitizer.test.d.ts +1 -0
  20. package/dist/lib/error-sanitizer.test.js +73 -0
  21. package/dist/lib/error-sanitizer.test.js.map +1 -0
  22. package/dist/lib/ffmpeg-bin.d.ts +64 -0
  23. package/dist/lib/ffmpeg-bin.js +96 -0
  24. package/dist/lib/ffmpeg-bin.js.map +1 -0
  25. package/dist/lib/ffmpeg-bin.test.d.ts +18 -0
  26. package/dist/lib/ffmpeg-bin.test.js +169 -0
  27. package/dist/lib/ffmpeg-bin.test.js.map +1 -0
  28. package/dist/lib/ffmpeg-run.d.ts +43 -0
  29. package/dist/lib/ffmpeg-run.js +67 -0
  30. package/dist/lib/ffmpeg-run.js.map +1 -0
  31. package/dist/lib/ffmpeg-run.test.d.ts +1 -0
  32. package/dist/lib/ffmpeg-run.test.js +66 -0
  33. package/dist/lib/ffmpeg-run.test.js.map +1 -0
  34. package/dist/lib/ffmpeg-safety.d.ts +37 -0
  35. package/dist/lib/ffmpeg-safety.js +67 -0
  36. package/dist/lib/ffmpeg-safety.js.map +1 -0
  37. package/dist/lib/ffmpeg-safety.test.d.ts +1 -0
  38. package/dist/lib/ffmpeg-safety.test.js +72 -0
  39. package/dist/lib/ffmpeg-safety.test.js.map +1 -0
  40. package/dist/lib/temp-dir.d.ts +24 -0
  41. package/dist/lib/temp-dir.js +53 -0
  42. package/dist/lib/temp-dir.js.map +1 -0
  43. package/dist/lib/temp-dir.test.d.ts +1 -0
  44. package/dist/lib/temp-dir.test.js +68 -0
  45. package/dist/lib/temp-dir.test.js.map +1 -0
  46. package/dist/lib/url-guard.d.ts +41 -0
  47. package/dist/lib/url-guard.js +134 -0
  48. package/dist/lib/url-guard.js.map +1 -0
  49. package/dist/lib/url-guard.test.d.ts +10 -0
  50. package/dist/lib/url-guard.test.js +231 -0
  51. package/dist/lib/url-guard.test.js.map +1 -0
  52. package/dist/server.js +9 -4
  53. package/dist/server.js.map +1 -1
  54. package/dist/tools/engine/audio-mixer.js +5 -20
  55. package/dist/tools/engine/audio-mixer.js.map +1 -1
  56. package/dist/tools/engine/audio.js +3 -19
  57. package/dist/tools/engine/audio.js.map +1 -1
  58. package/dist/tools/engine/beat-sync.js +7 -30
  59. package/dist/tools/engine/beat-sync.js.map +1 -1
  60. package/dist/tools/engine/capture.js +7 -0
  61. package/dist/tools/engine/capture.js.map +1 -1
  62. package/dist/tools/engine/chroma-key.js +2 -11
  63. package/dist/tools/engine/chroma-key.js.map +1 -1
  64. package/dist/tools/engine/concat.js +2 -11
  65. package/dist/tools/engine/concat.js.map +1 -1
  66. package/dist/tools/engine/editing.js +12 -35
  67. package/dist/tools/engine/editing.js.map +1 -1
  68. package/dist/tools/engine/encoder.js +2 -12
  69. package/dist/tools/engine/encoder.js.map +1 -1
  70. package/dist/tools/engine/lut-presets.js +2 -11
  71. package/dist/tools/engine/lut-presets.js.map +1 -1
  72. package/dist/tools/engine/narrated-video.js +30 -39
  73. package/dist/tools/engine/narrated-video.js.map +1 -1
  74. package/dist/tools/engine/smart-screenshot.js +7 -0
  75. package/dist/tools/engine/smart-screenshot.js.map +1 -1
  76. package/dist/tools/engine/social-format.js +2 -11
  77. package/dist/tools/engine/social-format.js.map +1 -1
  78. package/dist/tools/engine/template-renderer.js +2 -11
  79. package/dist/tools/engine/template-renderer.js.map +1 -1
  80. package/dist/tools/engine/text-animations.js +2 -11
  81. package/dist/tools/engine/text-animations.js.map +1 -1
  82. package/dist/tools/engine/text-overlay.js +2 -11
  83. package/dist/tools/engine/text-overlay.js.map +1 -1
  84. package/dist/tools/engine/tts.js +11 -6
  85. package/dist/tools/engine/tts.js.map +1 -1
  86. package/dist/tools/engine/voice-effects.js +3 -20
  87. package/dist/tools/engine/voice-effects.js.map +1 -1
  88. package/package.json +6 -6
  89. package/src/handlers/smart-screenshot.ts +8 -3
  90. package/src/handlers/tts.ts +6 -2
  91. package/src/handlers/video.ts +14 -7
  92. package/src/lib/error-sanitizer.test.ts +88 -0
  93. package/src/lib/error-sanitizer.ts +66 -0
  94. package/src/lib/ffmpeg-bin.test.ts +192 -0
  95. package/src/lib/ffmpeg-bin.ts +111 -0
  96. package/src/lib/ffmpeg-run.test.ts +76 -0
  97. package/src/lib/ffmpeg-run.ts +110 -0
  98. package/src/lib/ffmpeg-safety.test.ts +88 -0
  99. package/src/lib/ffmpeg-safety.ts +79 -0
  100. package/src/lib/temp-dir.test.ts +75 -0
  101. package/src/lib/temp-dir.ts +58 -0
  102. package/src/lib/url-guard.test.ts +261 -0
  103. package/src/lib/url-guard.ts +143 -0
  104. package/src/server.ts +10 -5
  105. package/src/tools/engine/audio-mixer.ts +8 -21
  106. package/src/tools/engine/audio.ts +6 -21
  107. package/src/tools/engine/beat-sync.ts +10 -31
  108. package/src/tools/engine/capture.ts +8 -0
  109. package/src/tools/engine/chroma-key.ts +2 -11
  110. package/src/tools/engine/concat.ts +2 -11
  111. package/src/tools/engine/editing.ts +17 -34
  112. package/src/tools/engine/encoder.ts +2 -12
  113. package/src/tools/engine/lut-presets.ts +2 -11
  114. package/src/tools/engine/narrated-video.ts +26 -38
  115. package/src/tools/engine/smart-screenshot.ts +8 -0
  116. package/src/tools/engine/social-format.ts +2 -11
  117. package/src/tools/engine/template-renderer.ts +2 -11
  118. package/src/tools/engine/text-animations.ts +2 -11
  119. package/src/tools/engine/text-overlay.ts +2 -11
  120. package/src/tools/engine/tts.ts +15 -6
  121. 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
- for (const bin of ['ffmpeg', 'ffprobe']) {
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
- execSync(`which ${bin}`, { stdio: 'pipe' });
264
- } catch {
265
- logger.error(`${bin} not found. Install ffmpeg: https://ffmpeg.org/download.html`);
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 new Promise((resolve, reject) => {
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 new Promise((resolve, reject) => {
82
- execFile(
83
- 'ffprobe',
84
- ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
85
- (error, stdout) => {
86
- if (error) { reject(new Error(`ffprobe failed: ${error.message}`)); return; }
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 new Promise((resolve, reject) => {
15
- execFile(
16
- 'ffprobe',
17
- ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
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 new Promise((resolve, reject) => {
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
- return new Promise((resolve, reject) => {
45
- execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
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 new Promise((resolve, reject) => {
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 new Promise((resolve, reject) => {
85
- execFile(
86
- 'ffprobe',
87
- ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
88
- (error, stdout) => {
89
- if (error) { reject(new Error(`ffprobe failed: ${error.message}`)); return; }
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 new Promise((resolve, reject) => {
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 new Promise((resolve, reject) => {
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 new Promise((resolve, reject) => {
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 new Promise((resolve) => {
41
- execFile(
42
- 'ffprobe',
43
- ['-v', 'quiet', '-select_streams', 'a', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath],
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 new Promise((resolve, reject) => {
664
- execFile(
665
- 'ffprobe',
666
- ['-v', 'quiet', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', filePath],
667
- (error, stdout) => {
668
- if (error) { reject(new Error(`ffprobe failed: ${error.message}`)); return; }
669
- try {
670
- const data = JSON.parse(stdout);
671
- const stream = data.streams?.[0];
672
- resolve({ width: stream?.width ?? 1920, height: stream?.height ?? 1080 });
673
- } catch { resolve({ width: 1920, height: 1080 }); }
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 new Promise((resolve, reject) => {
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 new Promise((resolve, reject) => {
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 {