@studiomeyer/mcp-video 1.0.0 → 1.0.2

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 (122) 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 +34 -3
  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 +7 -6
  89. package/server.json +21 -0
  90. package/src/handlers/smart-screenshot.ts +8 -3
  91. package/src/handlers/tts.ts +6 -2
  92. package/src/handlers/video.ts +14 -7
  93. package/src/lib/error-sanitizer.test.ts +88 -0
  94. package/src/lib/error-sanitizer.ts +66 -0
  95. package/src/lib/ffmpeg-bin.test.ts +192 -0
  96. package/src/lib/ffmpeg-bin.ts +111 -0
  97. package/src/lib/ffmpeg-run.test.ts +76 -0
  98. package/src/lib/ffmpeg-run.ts +110 -0
  99. package/src/lib/ffmpeg-safety.test.ts +88 -0
  100. package/src/lib/ffmpeg-safety.ts +79 -0
  101. package/src/lib/temp-dir.test.ts +75 -0
  102. package/src/lib/temp-dir.ts +58 -0
  103. package/src/lib/url-guard.test.ts +261 -0
  104. package/src/lib/url-guard.ts +143 -0
  105. package/src/server.ts +10 -5
  106. package/src/tools/engine/audio-mixer.ts +8 -21
  107. package/src/tools/engine/audio.ts +6 -21
  108. package/src/tools/engine/beat-sync.ts +10 -31
  109. package/src/tools/engine/capture.ts +8 -0
  110. package/src/tools/engine/chroma-key.ts +2 -11
  111. package/src/tools/engine/concat.ts +2 -11
  112. package/src/tools/engine/editing.ts +17 -34
  113. package/src/tools/engine/encoder.ts +2 -12
  114. package/src/tools/engine/lut-presets.ts +2 -11
  115. package/src/tools/engine/narrated-video.ts +26 -38
  116. package/src/tools/engine/smart-screenshot.ts +8 -0
  117. package/src/tools/engine/social-format.ts +2 -11
  118. package/src/tools/engine/template-renderer.ts +2 -11
  119. package/src/tools/engine/text-animations.ts +2 -11
  120. package/src/tools/engine/text-overlay.ts +2 -11
  121. package/src/tools/engine/tts.ts +15 -6
  122. package/src/tools/engine/voice-effects.ts +3 -17
@@ -9,10 +9,12 @@
9
9
  * 3. Merge video + audio into final output
10
10
  */
11
11
 
12
- import { execFile } from 'child_process';
13
12
  import * as fs from 'fs';
14
13
  import * as path from 'path';
15
14
  import { logger } from '../../lib/logger.js';
15
+ import { withTempDir } from '../../lib/temp-dir.js';
16
+ import { validateFfmpegPath, type FfmpegProtocolSet } from '../../lib/ffmpeg-safety.js';
17
+ import { runFfmpeg as runFfmpegSafe } from '../../lib/ffmpeg-run.js';
16
18
  import { generateSpeech } from './tts.js';
17
19
  import type { TTSProvider, ElevenLabsVoice, ElevenLabsModel, OpenAIVoice, OpenAIModel } from './tts.js';
18
20
  import { recordWebsite } from './capture.js';
@@ -91,10 +93,7 @@ export async function createNarratedVideo(
91
93
  viewport = 'desktop',
92
94
  } = config;
93
95
 
94
- const tempDir = `/tmp/narrated-video-${Date.now()}`;
95
- fs.mkdirSync(tempDir, { recursive: true });
96
-
97
- try {
96
+ return withTempDir('narrated-video', async (tempDir) => {
98
97
  // ─── Step 1: Generate all speech segments ───────────────────
99
98
  logger.info(`Generating ${segments.length} speech segment(s)...`);
100
99
  const audioPaths: string[] = [];
@@ -197,14 +196,11 @@ export async function createNarratedVideo(
197
196
  finalPath,
198
197
  );
199
198
 
200
- await runFfmpeg(mergeArgs);
199
+ await runFfmpeg(mergeArgs, config.backgroundMusicPath ? 'https-input' : 'local-only');
201
200
 
202
201
  const finalStats = fs.statSync(finalPath);
203
202
  const finalDuration = await getMediaDuration(finalPath);
204
203
 
205
- // ─── Cleanup ────────────────────────────────────────────────
206
- fs.rmSync(tempDir, { recursive: true, force: true });
207
-
208
204
  logger.info(`Narrated video ready: ${finalPath} (${finalDuration.toFixed(1)}s, ${(finalStats.size / 1024 / 1024).toFixed(2)} MB)`);
209
205
 
210
206
  return {
@@ -221,11 +217,7 @@ export async function createNarratedVideo(
221
217
  },
222
218
  url,
223
219
  };
224
- } catch (error) {
225
- // Cleanup on error
226
- try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
227
- throw error;
228
- }
220
+ });
229
221
  }
230
222
 
231
223
  // ─── Helpers ────────────────────────────────────────────────────────
@@ -234,34 +226,30 @@ export async function createNarratedVideo(
234
226
  * Concatenate multiple audio files using ffmpeg concat demuxer
235
227
  */
236
228
  async function concatenateAudio(files: string[], outputPath: string): Promise<void> {
229
+ for (const f of files) validateFfmpegPath(f, 'concat-input');
230
+ validateFfmpegPath(outputPath, 'concat-output');
231
+
237
232
  // Create concat list file
238
233
  const listPath = outputPath + '.txt';
239
- const listContent = files.map((f) => `file '${f}'`).join('\n');
234
+ const listContent = files.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join('\n');
240
235
  fs.writeFileSync(listPath, listContent);
241
236
 
242
- await runFfmpeg([
243
- '-y',
244
- '-f', 'concat',
245
- '-safe', '0',
246
- '-i', listPath,
247
- '-c:a', 'libmp3lame',
248
- '-b:a', '192k',
249
- outputPath,
250
- ]);
251
-
252
- // Cleanup list file
253
- fs.unlinkSync(listPath);
237
+ try {
238
+ await runFfmpeg([
239
+ '-y',
240
+ '-f', 'concat',
241
+ '-safe', '0',
242
+ '-i', listPath,
243
+ '-c:a', 'libmp3lame',
244
+ '-b:a', '192k',
245
+ outputPath,
246
+ ]);
247
+ } finally {
248
+ // Cleanup list file
249
+ try { fs.unlinkSync(listPath); } catch { /* already gone */ }
250
+ }
254
251
  }
255
252
 
256
- function runFfmpeg(args: string[]): Promise<string> {
257
- return new Promise((resolve, reject) => {
258
- execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
259
- if (error) {
260
- logger.error(`ffmpeg failed: ${stderr}`);
261
- reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
262
- return;
263
- }
264
- resolve(stdout);
265
- });
266
- });
253
+ function runFfmpeg(args: string[], protocols: FfmpegProtocolSet = 'local-only'): Promise<string> {
254
+ return runFfmpegSafe(args, { maxBuffer: 50 * 1024 * 1024, protocols, label: 'narrated-video' });
267
255
  }
@@ -16,6 +16,7 @@ import type { Page, Browser, BrowserContext, ElementHandle } from 'playwright';
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { logger } from '../../lib/logger.js';
19
+ import { guardFinalUrl } from '../../lib/url-guard.js';
19
20
 
20
21
  const OUTPUT_DIR = process.env.VIDEO_OUTPUT_DIR || './output';
21
22
 
@@ -425,6 +426,13 @@ export async function smartScreenshot(config: SmartScreenshotConfig): Promise<Sm
425
426
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
426
427
  });
427
428
 
429
+ // Post-redirect guard: follow-the-redirect must not land on a private IP.
430
+ const finalUrl = page.url();
431
+ const finalGuard = guardFinalUrl(finalUrl);
432
+ if (!finalGuard.ok) {
433
+ throw new Error(`post-redirect check failed — final URL rejected: ${finalGuard.reason}`);
434
+ }
435
+
428
436
  await page.waitForTimeout(waitAfterLoad);
429
437
 
430
438
  // ─── 3. Dismiss overlays ────────────────────────────────────
@@ -2,10 +2,10 @@
2
2
  * Social media format converter — crop, scale, pad for every platform
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
 
10
10
  // ─── Format Definitions ─────────────────────────────────────────────
11
11
 
@@ -133,14 +133,5 @@ export async function convertToAllFormats(
133
133
  // ─── Helper ─────────────────────────────────────────────────────────
134
134
 
135
135
  function runFfmpeg(args: string[]): Promise<string> {
136
- return new Promise((resolve, reject) => {
137
- execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
138
- if (error) {
139
- logger.error(`ffmpeg failed: ${stderr}`);
140
- reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
141
- return;
142
- }
143
- resolve(stdout);
144
- });
145
- });
136
+ return runFfmpegSafe(args, { maxBuffer: 50 * 1024 * 1024, label: 'social-format' });
146
137
  }
@@ -8,10 +8,10 @@
8
8
  * concat, audio, social-format.
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
  import { getTemplate } from './templates.js';
16
16
  import type { VideoTemplate, TemplateSlot } from './templates.js';
17
17
 
@@ -55,16 +55,7 @@ export interface RenderResult {
55
55
  // ─── Helpers ────────────────────────────────────────────────────────
56
56
 
57
57
  function runFfmpeg(args: string[], timeoutMs = 600_000): Promise<string> {
58
- return new Promise((resolve, reject) => {
59
- execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
60
- if (error) {
61
- logger.error(`ffmpeg failed: ${stderr}`);
62
- reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
63
- return;
64
- }
65
- resolve(stdout);
66
- });
67
- });
58
+ return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'template-renderer' });
68
59
  }
69
60
 
70
61
  function ensureDir(filePath: string): void {
@@ -5,10 +5,10 @@
5
5
  * manipulation to create effects like typewriter, pop, slide, bounce, etc.
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 } from '../../lib/ffmpeg-run.js';
12
12
 
13
13
  // ─── Types ──────────────────────────────────────────────────────────
14
14
 
@@ -81,16 +81,7 @@ export const TEXT_ANIMATION_DESCRIPTIONS: Record<TextAnimation, string> = {
81
81
  // ─── Helpers ────────────────────────────────────────────────────────
82
82
 
83
83
  function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
84
- return new Promise((resolve, reject) => {
85
- execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
86
- if (error) {
87
- logger.error(`ffmpeg failed: ${stderr}`);
88
- reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
89
- return;
90
- }
91
- resolve(stdout);
92
- });
93
- });
84
+ return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'text-animations' });
94
85
  }
95
86
 
96
87
  function ensureDir(filePath: string): void {
@@ -2,9 +2,9 @@
2
2
  * Text overlay engine — animated titles, subtitles, watermarks
3
3
  */
4
4
 
5
- import { execFile } from 'child_process';
6
5
  import * as fs from 'fs';
7
6
  import { logger } from '../../lib/logger.js';
7
+ import { runFfmpeg as runFfmpegSafe } from '../../lib/ffmpeg-run.js';
8
8
 
9
9
  // ─── Types ──────────────────────────────────────────────────────────
10
10
 
@@ -130,14 +130,5 @@ function findFont(): string {
130
130
  }
131
131
 
132
132
  function runFfmpeg(args: string[]): Promise<string> {
133
- return new Promise((resolve, reject) => {
134
- execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
135
- if (error) {
136
- logger.error(`ffmpeg failed: ${stderr}`);
137
- reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
138
- return;
139
- }
140
- resolve(stdout);
141
- });
142
- });
133
+ return runFfmpegSafe(args, { maxBuffer: 50 * 1024 * 1024, label: 'text-overlay' });
143
134
  }
@@ -10,6 +10,7 @@
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import { logger } from '../../lib/logger.js';
13
+ import { sanitizeErrorMessage } from '../../lib/error-sanitizer.js';
13
14
  import { getMediaDuration } from './audio.js';
14
15
 
15
16
  // ─── Types ──────────────────────────────────────────────────────────
@@ -121,7 +122,7 @@ export async function generateSpeech(config: TTSConfig): Promise<TTSResult> {
121
122
  } catch (error) {
122
123
  // Fallback: try the other provider
123
124
  const fallback = provider === 'elevenlabs' ? 'openai' : 'elevenlabs';
124
- const msg = error instanceof Error ? error.message : String(error);
125
+ const msg = sanitizeErrorMessage(error);
125
126
  logger.warn(`${provider} TTS failed (${msg}), falling back to ${fallback}`);
126
127
 
127
128
  try {
@@ -131,7 +132,7 @@ export async function generateSpeech(config: TTSConfig): Promise<TTSResult> {
131
132
  audioPath = await openaiTTS(config, finalPath);
132
133
  }
133
134
  } catch (fallbackError) {
134
- const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
135
+ const fbMsg = sanitizeErrorMessage(fallbackError);
135
136
  throw new Error(`Both TTS providers failed. ${provider}: ${msg}, ${fallback}: ${fbMsg}`);
136
137
  }
137
138
  }
@@ -197,8 +198,12 @@ async function elevenLabsTTS(config: TTSConfig, outputPath: string): Promise<str
197
198
  });
198
199
 
199
200
  if (!response.ok) {
200
- const errorText = await response.text();
201
- throw new Error(`ElevenLabs API ${response.status}: ${errorText}`);
201
+ const errorText = await response.text().catch(() => '');
202
+ throw new Error(
203
+ sanitizeErrorMessage(errorText, {
204
+ prefix: `ElevenLabs API ${response.status}: `,
205
+ })
206
+ );
202
207
  }
203
208
 
204
209
  const arrayBuffer = await response.arrayBuffer();
@@ -236,8 +241,12 @@ async function openaiTTS(config: TTSConfig, outputPath: string): Promise<string>
236
241
  });
237
242
 
238
243
  if (!response.ok) {
239
- const errorText = await response.text();
240
- throw new Error(`OpenAI TTS API ${response.status}: ${errorText}`);
244
+ const errorText = await response.text().catch(() => '');
245
+ throw new Error(
246
+ sanitizeErrorMessage(errorText, {
247
+ prefix: `OpenAI TTS API ${response.status}: `,
248
+ })
249
+ );
241
250
  }
242
251
 
243
252
  const arrayBuffer = await response.arrayBuffer();
@@ -5,10 +5,10 @@
5
5
  * Works on both audio files and video files (preserves video stream).
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
 
13
13
  // ─── Types ──────────────────────────────────────────────────────────
14
14
 
@@ -141,25 +141,11 @@ function getEffectFilter(effect: VoiceEffect, intensity: number): { filter: stri
141
141
  // ─── Helpers ────────────────────────────────────────────────────────
142
142
 
143
143
  function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
144
- return new Promise((resolve, reject) => {
145
- execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
146
- if (error) {
147
- logger.error(`ffmpeg failed: ${stderr}`);
148
- reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
149
- return;
150
- }
151
- resolve(stdout);
152
- });
153
- });
144
+ return runFfmpegSafe(args, { maxBuffer: 100 * 1024 * 1024, timeoutMs, label: 'voice-effects' });
154
145
  }
155
146
 
156
147
  function runFfprobe(args: string[]): Promise<string> {
157
- return new Promise((resolve, reject) => {
158
- execFile('ffprobe', args, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout) => {
159
- if (error) { reject(new Error(`ffprobe failed: ${error.message}`)); return; }
160
- resolve(stdout.trim());
161
- });
162
- });
148
+ return runFfprobeSafe(args, { maxBuffer: 10 * 1024 * 1024, label: 'voice-effects-probe' }).then((s) => s.trim());
163
149
  }
164
150
 
165
151
  function ensureDir(filePath: string): void {