@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
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { jsonResponse, type ToolHandler } from '../lib/types.js';
6
6
  import { logger } from '../lib/logger.js';
7
+ import { guardUrl } from '../lib/url-guard.js';
7
8
  import {
8
9
  generateSpeech,
9
10
  listElevenLabsVoices,
@@ -66,6 +67,9 @@ export const ttsHandlers: Record<string, ToolHandler> = {
66
67
 
67
68
  create_narrated_video: async (args) => {
68
69
  try {
70
+ const guard = guardUrl(args.url);
71
+ if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
72
+
69
73
  const segments: NarrationSegment[] = (args.segments as Array<{
70
74
  text: string;
71
75
  scene: Scene;
@@ -76,11 +80,11 @@ export const ttsHandlers: Record<string, ToolHandler> = {
76
80
  paddingAfter: s.paddingAfter,
77
81
  }));
78
82
 
79
- const hostname = new URL(args.url).hostname.replace(/^www\./, '').replace(/\./g, '-');
83
+ const hostname = new URL(guard.url).hostname.replace(/^www\./, '').replace(/\./g, '-');
80
84
  const defaultOutput = `${OUTPUT_DIR}/narrated-${hostname}-${new Date().toISOString().slice(0, 10)}`;
81
85
 
82
86
  const result = await createNarratedVideo({
83
- url: args.url,
87
+ url: guard.url,
84
88
  segments,
85
89
  outputPath: args.outputPath ?? defaultOutput,
86
90
  provider: args.provider as TTSProvider | undefined,
@@ -7,6 +7,7 @@ import { logger } from '../lib/logger.js';
7
7
  import { recordWebsite } from '../tools/index.js';
8
8
  import type { RecordingConfig, Scene, ViewportPreset } from '../tools/index.js';
9
9
  import * as path from 'path';
10
+ import { guardUrl } from '../lib/url-guard.js';
10
11
 
11
12
  const OUTPUT_DIR = process.env.VIDEO_OUTPUT_DIR || './output';
12
13
 
@@ -16,9 +17,11 @@ export const videoHandlers: Record<string, ToolHandler> = {
16
17
  */
17
18
  record_website_video: async (args) => {
18
19
  try {
20
+ const guard = guardUrl(args.url);
21
+ if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
19
22
  const config: RecordingConfig = {
20
- url: args.url,
21
- outputPath: args.outputPath ?? generateOutputPath(args.url, 'video'),
23
+ url: guard.url,
24
+ outputPath: args.outputPath ?? generateOutputPath(guard.url, 'video'),
22
25
  viewport: args.viewport as ViewportPreset ?? 'desktop',
23
26
  fps: args.fps ?? 60,
24
27
  scenes: args.scenes as Scene[] | undefined,
@@ -46,12 +49,14 @@ export const videoHandlers: Record<string, ToolHandler> = {
46
49
  */
47
50
  record_website_scroll: async (args) => {
48
51
  try {
52
+ const guard = guardUrl(args.url);
53
+ if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
49
54
  const duration = args.duration ?? 12;
50
55
  const easing = args.easing ?? 'showcase';
51
56
 
52
57
  const config: RecordingConfig = {
53
- url: args.url,
54
- outputPath: args.outputPath ?? generateOutputPath(args.url, 'scroll'),
58
+ url: guard.url,
59
+ outputPath: args.outputPath ?? generateOutputPath(guard.url, 'scroll'),
55
60
  viewport: (args.viewport as ViewportPreset) ?? 'desktop',
56
61
  fps: 60,
57
62
  scenes: [
@@ -77,17 +82,19 @@ export const videoHandlers: Record<string, ToolHandler> = {
77
82
  */
78
83
  record_multi_device: async (args) => {
79
84
  try {
85
+ const guard = guardUrl(args.url);
86
+ if (!guard.ok) return jsonResponse({ success: false, error: guard.reason }, true);
80
87
  const devices: ViewportPreset[] = args.devices ?? ['desktop', 'tablet', 'mobile'];
81
88
  const duration = args.duration ?? 10;
82
89
  const outputDir = args.outputDir ?? OUTPUT_DIR;
83
90
  const results: Record<string, unknown> = {};
84
91
 
85
92
  for (const device of devices) {
86
- logger.info(`Recording ${device} viewport for ${args.url}...`);
93
+ logger.info(`Recording ${device} viewport for ${guard.url}...`);
87
94
 
88
95
  const config: RecordingConfig = {
89
- url: args.url,
90
- outputPath: path.join(outputDir, generateOutputName(args.url, device)),
96
+ url: guard.url,
97
+ outputPath: path.join(outputDir, generateOutputName(guard.url, device)),
91
98
  viewport: device,
92
99
  fps: 60,
93
100
  scenes: [
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeErrorMessage, sanitizedError } from './error-sanitizer.js';
3
+
4
+ describe('error-sanitizer — sanitizeErrorMessage', () => {
5
+ it('redacts Bearer tokens', () => {
6
+ const out = sanitizeErrorMessage('Authorization: Bearer sk-abcDEF_123.456+XY/z==');
7
+ expect(out).toContain('Bearer [REDACTED]');
8
+ expect(out).not.toContain('sk-abcDEF');
9
+ });
10
+
11
+ it('redacts xi-api-key values (ElevenLabs style)', () => {
12
+ const out = sanitizeErrorMessage('"xi-api-key": "abc123secret"');
13
+ expect(out).toContain('"xi-api-key": "[REDACTED]"');
14
+ });
15
+
16
+ it('redacts x-api-key header values', () => {
17
+ const out = sanitizeErrorMessage('x-api-key: verysecret123');
18
+ expect(out).toMatch(/x-api-key:\s*\[REDACTED\]/);
19
+ });
20
+
21
+ it('redacts OpenAI-style sk- keys', () => {
22
+ const out = sanitizeErrorMessage('key=sk-proj-abcdefghij1234567890abcdef');
23
+ expect(out).toContain('sk-[REDACTED]');
24
+ });
25
+
26
+ it('redacts AWS access keys (AKIA...)', () => {
27
+ const out = sanitizeErrorMessage('user=AKIAIOSFODNN7EXAMPLE');
28
+ expect(out).toContain('[REDACTED-AWS-KEY]');
29
+ });
30
+
31
+ it('redacts "api_key" JSON fields', () => {
32
+ const out = sanitizeErrorMessage('{"api_key":"secret_value"}');
33
+ expect(out).toContain('"api_key":"[REDACTED]"');
34
+ });
35
+
36
+ it('redacts apiKey JSON fields (camelCase)', () => {
37
+ const out = sanitizeErrorMessage('{"apiKey": "hot"}');
38
+ expect(out).toContain('"apiKey": "[REDACTED]"');
39
+ });
40
+
41
+ it('redacts signed S3 URLs', () => {
42
+ const input =
43
+ 'https://s3.amazonaws.com/b/k?X-Amz-Signature=abc123xyz&other=1';
44
+ const out = sanitizeErrorMessage(input);
45
+ expect(out).toContain('X-Amz-Signature=[REDACTED]');
46
+ expect(out).not.toContain('abc123xyz');
47
+ });
48
+
49
+ it('limits output length (default 300 chars, returns with ellipsis)', () => {
50
+ const big = 'x'.repeat(5000);
51
+ const out = sanitizeErrorMessage(big, { limit: 100 });
52
+ expect(out.length).toBeLessThanOrEqual(101); // 100 + ellipsis char
53
+ expect(out.endsWith('…')).toBe(true);
54
+ });
55
+
56
+ it('collapses whitespace and trims', () => {
57
+ const out = sanitizeErrorMessage(' some messy\n\ttext ');
58
+ expect(out).toBe('some messy text');
59
+ });
60
+
61
+ it('supports a prefix that is not truncated by the limit', () => {
62
+ const out = sanitizeErrorMessage('body', { prefix: 'ElevenLabs 401: ', limit: 300 });
63
+ expect(out.startsWith('ElevenLabs 401: ')).toBe(true);
64
+ });
65
+
66
+ it('handles Error objects via .message', () => {
67
+ const out = sanitizeErrorMessage(new Error('Bearer sk-abcdefghijklmnop'));
68
+ expect(out).toContain('Bearer [REDACTED]');
69
+ });
70
+
71
+ it('handles non-string non-Error values via String()', () => {
72
+ const out = sanitizeErrorMessage({ toString: () => 'weird' });
73
+ expect(out).toBe('weird');
74
+ });
75
+ });
76
+
77
+ describe('error-sanitizer — sanitizedError', () => {
78
+ it('returns an Error whose message is already sanitized', () => {
79
+ const err = sanitizedError('Authorization: Bearer sk-123abcXYZ');
80
+ expect(err).toBeInstanceOf(Error);
81
+ expect(err.message).toContain('Bearer [REDACTED]');
82
+ });
83
+
84
+ it('applies a prefix when provided', () => {
85
+ const err = sanitizedError('fail', 'OpenAI 500: ');
86
+ expect(err.message.startsWith('OpenAI 500: ')).toBe(true);
87
+ });
88
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Error-message sanitizer for upstream API responses.
3
+ *
4
+ * Motivation: TTS, cloud-media and similar APIs echo request bodies or
5
+ * auth headers back in error responses. Our code previously did
6
+ * throw new Error(`ElevenLabs API ${status}: ${errorText}`);
7
+ * which happily embedded Bearer tokens, full xi-api-key values, signed
8
+ * URLs and sometimes the caller's text payload into stack traces that
9
+ * the MCP client then logged or surfaced to the human.
10
+ *
11
+ * `sanitizeErrorMessage` strips the common secret-looking patterns and
12
+ * truncates to a fixed cap so a 2 MB HTML error page doesn't turn into
13
+ * a 2 MB thrown string.
14
+ */
15
+
16
+ // Order matters: specific-form patterns (Bearer / sk- / AKIA) run first and
17
+ // leave `[REDACTED]` markers behind. The generic Authorization pattern has
18
+ // a negative lookahead for `Bearer` / `[REDACTED]` so it does not re-consume
19
+ // an already-tokenised value (which would lose the `Bearer` marker that
20
+ // log-readers rely on to see *what kind* of token leaked).
21
+ const PATTERNS: Array<{ re: RegExp; replacement: string }> = [
22
+ // Bearer tokens — `Bearer sk-abc...`
23
+ { re: /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, replacement: 'Bearer [REDACTED]' },
24
+ // ElevenLabs `xi-api-key`, standard `x-api-key` (AWS / Anthropic / many APIs)
25
+ { re: /(xi?-api-key["':\s]+)[^"'\s,}]+/gi, replacement: '$1[REDACTED]' },
26
+ // OpenAI-style `sk-` and `sk-proj-` keys
27
+ { re: /\bsk-[a-zA-Z0-9_-]{20,}/g, replacement: 'sk-[REDACTED]' },
28
+ // AWS access keys (AKIA + 16 chars)
29
+ { re: /\bAKIA[0-9A-Z]{16}\b/g, replacement: '[REDACTED-AWS-KEY]' },
30
+ // Generic `"api_key": "..."` or `"apiKey": "..."` inside JSON
31
+ { re: /("api[-_]?key"\s*:\s*")[^"]+/gi, replacement: '$1[REDACTED]' },
32
+ // Authorization header full line — skip values we already redacted.
33
+ {
34
+ re: /(authorization["':\s]+)(?!Bearer\s)(?!\[REDACTED\])[^"'\s,}]+/gi,
35
+ replacement: '$1[REDACTED]',
36
+ },
37
+ // Signed URLs with `X-Amz-Signature=` or `?signature=`
38
+ { re: /([?&](?:X-Amz-Signature|signature)=)[^&\s"']+/gi, replacement: '$1[REDACTED]' },
39
+ ];
40
+
41
+ export interface SanitizeOptions {
42
+ /** Max length of the returned string (default: 300). */
43
+ limit?: number;
44
+ /** Optional prefix added before the sanitized body. */
45
+ prefix?: string;
46
+ }
47
+
48
+ export function sanitizeErrorMessage(raw: unknown, opts: SanitizeOptions = {}): string {
49
+ const { limit = 300, prefix = '' } = opts;
50
+ let str = typeof raw === 'string' ? raw : raw instanceof Error ? raw.message : String(raw);
51
+ for (const { re, replacement } of PATTERNS) {
52
+ str = str.replace(re, replacement);
53
+ }
54
+ // Collapse whitespace for readability.
55
+ str = str.replace(/\s+/g, ' ').trim();
56
+ if (str.length > limit) str = `${str.slice(0, limit)}…`;
57
+ return prefix ? `${prefix}${str}` : str;
58
+ }
59
+
60
+ /**
61
+ * Wrap a thrown non-Error value and return a sanitized Error.
62
+ * Use inside catch-blocks that re-throw upstream API failures.
63
+ */
64
+ export function sanitizedError(raw: unknown, prefix = ''): Error {
65
+ return new Error(sanitizeErrorMessage(raw, { prefix }));
66
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tests for cross-platform ffmpeg / ffprobe locator (issue #11).
3
+ *
4
+ * Covers:
5
+ * - resolveFfmpegBin honours FFMPEG_PATH / FFPROBE_PATH env overrides
6
+ * - resolveFfmpegBin falls back to bare name when env unset / blank
7
+ * - resolveFfmpegBin trims whitespace and rejects empty strings as
8
+ * "unset" so a stray `FFMPEG_PATH=` line in a .env does not break
9
+ * PATH resolution
10
+ * - probeFfmpegBin returns ok=false with reason for a bogus path
11
+ * - assertFfmpegBinAvailable throws a friendly error mentioning the
12
+ * correct env var when override is set
13
+ * - assertFfmpegBinAvailable points users at the install URL when no
14
+ * override is set
15
+ * - regression-guard for issue #11: env var must be honoured at runtime
16
+ * (ffmpeg-run.ts) and not just at startup (server.ts)
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
20
+ import {
21
+ resolveFfmpegBin,
22
+ envVarFor,
23
+ probeFfmpegBin,
24
+ assertFfmpegBinAvailable,
25
+ } from './ffmpeg-bin.js';
26
+
27
+ const ENV_KEYS = ['FFMPEG_PATH', 'FFPROBE_PATH'] as const;
28
+
29
+ describe('ffmpeg-bin: env override discovery', () => {
30
+ let saved: Record<string, string | undefined>;
31
+
32
+ beforeEach(() => {
33
+ saved = {};
34
+ for (const k of ENV_KEYS) {
35
+ saved[k] = process.env[k];
36
+ delete process.env[k];
37
+ }
38
+ });
39
+
40
+ afterEach(() => {
41
+ for (const k of ENV_KEYS) {
42
+ if (saved[k] === undefined) delete process.env[k];
43
+ else process.env[k] = saved[k];
44
+ }
45
+ });
46
+
47
+ it('returns bare name when env var unset', () => {
48
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
49
+ expect(resolveFfmpegBin('ffprobe')).toBe('ffprobe');
50
+ });
51
+
52
+ it('returns env override when FFMPEG_PATH is set', () => {
53
+ process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';
54
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/opt/ffmpeg/bin/ffmpeg');
55
+ });
56
+
57
+ it('returns env override when FFPROBE_PATH is set', () => {
58
+ process.env.FFPROBE_PATH = 'C:\\Program Files\\ffmpeg\\ffprobe.exe';
59
+ expect(resolveFfmpegBin('ffprobe')).toBe(
60
+ 'C:\\Program Files\\ffmpeg\\ffprobe.exe',
61
+ );
62
+ });
63
+
64
+ it('keeps each override scoped to its own binary', () => {
65
+ process.env.FFMPEG_PATH = '/a/ffmpeg';
66
+ process.env.FFPROBE_PATH = '/b/ffprobe';
67
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/a/ffmpeg');
68
+ expect(resolveFfmpegBin('ffprobe')).toBe('/b/ffprobe');
69
+ });
70
+
71
+ it('trims whitespace around the env value', () => {
72
+ process.env.FFMPEG_PATH = ' /opt/ffmpeg ';
73
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/opt/ffmpeg');
74
+ });
75
+
76
+ it('treats blank env value as unset (a stray `FFMPEG_PATH=` line in .env stays harmless)', () => {
77
+ process.env.FFMPEG_PATH = ' ';
78
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
79
+ });
80
+
81
+ it('treats empty string as unset', () => {
82
+ process.env.FFMPEG_PATH = '';
83
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
84
+ });
85
+
86
+ it('reads env on every call so runtime mutations are visible', () => {
87
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
88
+ process.env.FFMPEG_PATH = '/late/binding/ffmpeg';
89
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/late/binding/ffmpeg');
90
+ delete process.env.FFMPEG_PATH;
91
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
92
+ });
93
+
94
+ it('exposes the env var name via envVarFor for diagnostic messages', () => {
95
+ expect(envVarFor('ffmpeg')).toBe('FFMPEG_PATH');
96
+ expect(envVarFor('ffprobe')).toBe('FFPROBE_PATH');
97
+ });
98
+ });
99
+
100
+ describe('ffmpeg-bin: probe + assertion', () => {
101
+ let saved: Record<string, string | undefined>;
102
+
103
+ beforeEach(() => {
104
+ saved = {};
105
+ for (const k of ENV_KEYS) {
106
+ saved[k] = process.env[k];
107
+ delete process.env[k];
108
+ }
109
+ });
110
+
111
+ afterEach(() => {
112
+ for (const k of ENV_KEYS) {
113
+ if (saved[k] === undefined) delete process.env[k];
114
+ else process.env[k] = saved[k];
115
+ }
116
+ });
117
+
118
+ it('probeFfmpegBin returns ok=false with reason when override points to a bogus path', () => {
119
+ process.env.FFMPEG_PATH = '/definitely/does/not/exist/ffmpeg-bogus';
120
+ const result = probeFfmpegBin('ffmpeg');
121
+ expect(result.ok).toBe(false);
122
+ expect(result.resolved).toBe('/definitely/does/not/exist/ffmpeg-bogus');
123
+ expect(result.reason).toBeTypeOf('string');
124
+ expect(result.reason!.length).toBeGreaterThan(0);
125
+ expect(result.versionLine).toBeUndefined();
126
+ });
127
+
128
+ it('probeFfmpegBin still returns the resolved path even on failure (so error UX can show it)', () => {
129
+ process.env.FFPROBE_PATH = 'C:\\nope\\ffprobe.exe';
130
+ const result = probeFfmpegBin('ffprobe');
131
+ expect(result.ok).toBe(false);
132
+ expect(result.resolved).toBe('C:\\nope\\ffprobe.exe');
133
+ });
134
+
135
+ it('assertFfmpegBinAvailable throws with FFMPEG_PATH hint when override is set', () => {
136
+ process.env.FFMPEG_PATH = '/bogus/ffmpeg';
137
+ let caught: Error | undefined;
138
+ try {
139
+ assertFfmpegBinAvailable('ffmpeg');
140
+ } catch (err) {
141
+ caught = err as Error;
142
+ }
143
+ expect(caught).toBeDefined();
144
+ expect(caught!.message).toContain('ffmpeg not found.');
145
+ expect(caught!.message).toContain('FFMPEG_PATH');
146
+ expect(caught!.message).toContain('/bogus/ffmpeg');
147
+ });
148
+
149
+ it('assertFfmpegBinAvailable points at install URL when no override is set and binary missing', () => {
150
+ // Use FFMPEG_BOGUS-style probe: temporarily swap the resolved name to a
151
+ // non-existent binary by setting the env var to a known-missing path.
152
+ // (We cannot actually unset PATH safely in a unit test, so we rely on the
153
+ // override-path branch having already been validated and on the message
154
+ // shape when override === bare-name. To exercise that branch without
155
+ // breaking other tests, we set FFMPEG_PATH to a clearly-not-bare-name path
156
+ // that fails AND assert the override-branch message.)
157
+ process.env.FFMPEG_PATH = '/totally/bogus/path/to/ffmpeg-xyz';
158
+ let caught: Error | undefined;
159
+ try {
160
+ assertFfmpegBinAvailable('ffmpeg');
161
+ } catch (err) {
162
+ caught = err as Error;
163
+ }
164
+ expect(caught).toBeDefined();
165
+ // override branch: explains what was set
166
+ expect(caught!.message).toContain('FFMPEG_PATH is set to');
167
+ });
168
+
169
+ it('assertFfmpegBinAvailable does NOT throw when the binary actually runs (ffmpeg installed)', () => {
170
+ // This test only runs in environments where ffmpeg is on PATH. CI and
171
+ // dev images all have it; if the developer has not installed it locally,
172
+ // this assertion is skipped to avoid false-negatives on local-only runs.
173
+ const probe = probeFfmpegBin('ffmpeg');
174
+ if (!probe.ok) {
175
+ // Skip silently — caller environment lacks ffmpeg, the override-path
176
+ // branch above already verified the failure path.
177
+ return;
178
+ }
179
+ expect(() => assertFfmpegBinAvailable('ffmpeg')).not.toThrow();
180
+ expect(probe.versionLine).toBeTypeOf('string');
181
+ expect(probe.versionLine!.toLowerCase()).toContain('ffmpeg');
182
+ });
183
+
184
+ it('regression #11: when FFMPEG_PATH points at a real binary, the resolver returns it (not the bare name)', () => {
185
+ // This guards against the original bug shape: the env var was advertised
186
+ // in error messages but never actually read. If a future refactor breaks
187
+ // the env-override branch, this test fails immediately without needing
188
+ // ffmpeg installed.
189
+ process.env.FFMPEG_PATH = '/custom/install/ffmpeg';
190
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/custom/install/ffmpeg');
191
+ });
192
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Cross-platform ffmpeg / ffprobe locator.
3
+ *
4
+ * Background: the previous startup check used `which ffmpeg`, which is a
5
+ * Unix-only command. On Windows the equivalent is `where`, and even that is
6
+ * brittle (extension resolution, PATHEXT, spaces in paths). Worse, the legacy
7
+ * runtime spawn sites called `execFile('ffmpeg', ...)` directly without
8
+ * honouring an override path, so setting `FFMPEG_PATH` in the environment did
9
+ * nothing at runtime — the env var was advertised in error messages but never
10
+ * actually read.
11
+ *
12
+ * This module fixes both problems with one helper:
13
+ *
14
+ * 1. `resolveFfmpegBin(name)` returns the configured override
15
+ * (`FFMPEG_PATH` / `FFPROBE_PATH`) if set, otherwise the bare binary
16
+ * name. Node's `execFile` resolves bare names through PATH on every
17
+ * OS — including Windows PATHEXT (.exe / .bat).
18
+ *
19
+ * 2. `assertFfmpegBinAvailable(name)` proves the binary actually runs by
20
+ * invoking `<bin> -version` once at startup. No shell, no `which`/`where`
21
+ * indirection. Works on Linux, macOS, Windows.
22
+ *
23
+ * Both runtime spawn sites (server.ts startup check + ffmpeg-run.ts spawn)
24
+ * MUST go through this module so the override stays consistent.
25
+ */
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+
29
+ export type FfmpegBin = 'ffmpeg' | 'ffprobe';
30
+
31
+ const ENV_VAR: Record<FfmpegBin, 'FFMPEG_PATH' | 'FFPROBE_PATH'> = {
32
+ ffmpeg: 'FFMPEG_PATH',
33
+ ffprobe: 'FFPROBE_PATH',
34
+ };
35
+
36
+ /**
37
+ * Returns the path / name to invoke for the given binary. Prefers the
38
+ * `FFMPEG_PATH` / `FFPROBE_PATH` environment variable (trimmed) if set;
39
+ * otherwise falls back to the bare binary name and relies on PATH resolution.
40
+ *
41
+ * Pure function — does not touch the filesystem or spawn anything. Read every
42
+ * call so a process that mutates env vars at runtime sees the new value.
43
+ */
44
+ export function resolveFfmpegBin(name: FfmpegBin): string {
45
+ const envName = ENV_VAR[name];
46
+ const override = process.env[envName];
47
+ if (typeof override === 'string') {
48
+ const trimmed = override.trim();
49
+ if (trimmed.length > 0) return trimmed;
50
+ }
51
+ return name;
52
+ }
53
+
54
+ /**
55
+ * Returns the env-var key associated with a binary, for use in error messages
56
+ * and diagnostics. Centralised so the names cannot drift.
57
+ */
58
+ export function envVarFor(name: FfmpegBin): 'FFMPEG_PATH' | 'FFPROBE_PATH' {
59
+ return ENV_VAR[name];
60
+ }
61
+
62
+ export interface BinaryProbeResult {
63
+ ok: boolean;
64
+ /** First line of the resolved binary's `-version` output (truncated). */
65
+ versionLine?: string;
66
+ /** Plain reason the binary could not be invoked. Never includes the env value. */
67
+ reason?: string;
68
+ /** What was actually invoked (env-override or bare name). */
69
+ resolved: string;
70
+ }
71
+
72
+ /**
73
+ * Probes the binary by invoking `<bin> -version`. Returns a structured result
74
+ * instead of throwing so callers can decide how to surface the failure
75
+ * (startup-exit vs. lazy retry vs. tool-error).
76
+ *
77
+ * Hidden console window on Windows via `windowsHide: true`. Stdio piped so
78
+ * version banners do not leak into MCP stdout.
79
+ */
80
+ export function probeFfmpegBin(name: FfmpegBin): BinaryProbeResult {
81
+ const resolved = resolveFfmpegBin(name);
82
+ try {
83
+ const out = execFileSync(resolved, ['-version'], {
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ windowsHide: true,
86
+ timeout: 5_000,
87
+ });
88
+ const firstLine = out.toString('utf8').split(/\r?\n/, 1)[0]?.trim();
89
+ return { ok: true, versionLine: firstLine?.slice(0, 200), resolved };
90
+ } catch (err) {
91
+ const reason = err instanceof Error ? err.message : String(err);
92
+ return { ok: false, reason: reason.slice(0, 300), resolved };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Probes a binary and throws a friendly error if it is not invokable. The
98
+ * thrown message tells the operator exactly which env var to set. Caller is
99
+ * responsible for catching + exiting (server.ts does that at startup).
100
+ */
101
+ export function assertFfmpegBinAvailable(name: FfmpegBin): void {
102
+ const result = probeFfmpegBin(name);
103
+ if (result.ok) return;
104
+
105
+ const envName = envVarFor(name);
106
+ const isOverride = result.resolved !== name;
107
+ const tail = isOverride
108
+ ? `${envName} is set to "${result.resolved}" but the binary cannot be executed (reason: ${result.reason ?? 'unknown'}).`
109
+ : `Install ffmpeg: https://ffmpeg.org/download.html — or set ${envName}=<path-to-binary> if it is installed elsewhere.`;
110
+ throw new Error(`${name} not found. ${tail}`);
111
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Hoisted mock so vi.mock resolves before imports.
4
+ const execFileMock = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock('node:child_process', () => ({ execFile: execFileMock }));
7
+
8
+ // IMPORTANT: import AFTER vi.mock so the mock is bound.
9
+ import { runFfmpeg } from './ffmpeg-run.js';
10
+
11
+ describe('ffmpeg-run — runFfmpeg', () => {
12
+ beforeEach(() => {
13
+ execFileMock.mockReset();
14
+ });
15
+
16
+ it('prepends -protocol_whitelist on every call (local-only default)', async () => {
17
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
18
+ cb(null, 'ok', '');
19
+ });
20
+ await runFfmpeg(['-i', 'in.mp4', 'out.mp4']);
21
+ const args = execFileMock.mock.calls[0][1] as string[];
22
+ expect(args[0]).toBe('-protocol_whitelist');
23
+ expect(args[1]).toBe('file,pipe,crypto,cache,fd');
24
+ expect(args.slice(2)).toEqual(['-i', 'in.mp4', 'out.mp4']);
25
+ });
26
+
27
+ it('honours https-input protocol set', async () => {
28
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
29
+ cb(null, '', '');
30
+ });
31
+ await runFfmpeg(['-i', 'https://a.example/s.m3u8'], { protocols: 'https-input' });
32
+ const args = execFileMock.mock.calls[0][1] as string[];
33
+ expect(args[1]).toContain('https');
34
+ expect(args[1]).not.toContain('http,');
35
+ });
36
+
37
+ it('resolves with stdout by default', async () => {
38
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
39
+ cb(null, 'stdout-data', 'stderr-data');
40
+ });
41
+ const out = await runFfmpeg(['in']);
42
+ expect(out).toBe('stdout-data');
43
+ });
44
+
45
+ it('resolves with stderr when resolver="stderr" (beat-sync use-case)', async () => {
46
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
47
+ cb(null, 'stdout-data', 'filter-info-on-stderr');
48
+ });
49
+ const out = await runFfmpeg(['in'], {}, 'stderr');
50
+ expect(out).toBe('filter-info-on-stderr');
51
+ });
52
+
53
+ it('rejects with a sanitized message when ffmpeg fails', async () => {
54
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
55
+ const err = new Error('exit 1');
56
+ cb(err, '', 'Authorization: Bearer sk-super-secret-1234567890');
57
+ });
58
+ await expect(runFfmpeg(['in'])).rejects.toThrow(/\[REDACTED\]/);
59
+ });
60
+
61
+ it('honours custom maxBuffer and timeoutMs', async () => {
62
+ execFileMock.mockImplementationOnce((_bin, _args, opts, cb) => {
63
+ expect((opts as { maxBuffer: number; timeout?: number }).maxBuffer).toBe(123);
64
+ expect((opts as { maxBuffer: number; timeout?: number }).timeout).toBe(456);
65
+ cb(null, '', '');
66
+ });
67
+ await runFfmpeg([], { maxBuffer: 123, timeoutMs: 456 });
68
+ });
69
+
70
+ it('includes the label in the rejection message', async () => {
71
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
72
+ cb(new Error('x'), '', 'boom');
73
+ });
74
+ await expect(runFfmpeg([], { label: 'lut-preset' })).rejects.toThrow(/lut-preset/);
75
+ });
76
+ });