@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 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeErrorMessage, sanitizedError } from './error-sanitizer.js';
3
+ describe('error-sanitizer — sanitizeErrorMessage', () => {
4
+ it('redacts Bearer tokens', () => {
5
+ const out = sanitizeErrorMessage('Authorization: Bearer sk-abcDEF_123.456+XY/z==');
6
+ expect(out).toContain('Bearer [REDACTED]');
7
+ expect(out).not.toContain('sk-abcDEF');
8
+ });
9
+ it('redacts xi-api-key values (ElevenLabs style)', () => {
10
+ const out = sanitizeErrorMessage('"xi-api-key": "abc123secret"');
11
+ expect(out).toContain('"xi-api-key": "[REDACTED]"');
12
+ });
13
+ it('redacts x-api-key header values', () => {
14
+ const out = sanitizeErrorMessage('x-api-key: verysecret123');
15
+ expect(out).toMatch(/x-api-key:\s*\[REDACTED\]/);
16
+ });
17
+ it('redacts OpenAI-style sk- keys', () => {
18
+ const out = sanitizeErrorMessage('key=sk-proj-abcdefghij1234567890abcdef');
19
+ expect(out).toContain('sk-[REDACTED]');
20
+ });
21
+ it('redacts AWS access keys (AKIA...)', () => {
22
+ const out = sanitizeErrorMessage('user=AKIAIOSFODNN7EXAMPLE');
23
+ expect(out).toContain('[REDACTED-AWS-KEY]');
24
+ });
25
+ it('redacts "api_key" JSON fields', () => {
26
+ const out = sanitizeErrorMessage('{"api_key":"secret_value"}');
27
+ expect(out).toContain('"api_key":"[REDACTED]"');
28
+ });
29
+ it('redacts apiKey JSON fields (camelCase)', () => {
30
+ const out = sanitizeErrorMessage('{"apiKey": "hot"}');
31
+ expect(out).toContain('"apiKey": "[REDACTED]"');
32
+ });
33
+ it('redacts signed S3 URLs', () => {
34
+ const input = 'https://s3.amazonaws.com/b/k?X-Amz-Signature=abc123xyz&other=1';
35
+ const out = sanitizeErrorMessage(input);
36
+ expect(out).toContain('X-Amz-Signature=[REDACTED]');
37
+ expect(out).not.toContain('abc123xyz');
38
+ });
39
+ it('limits output length (default 300 chars, returns with ellipsis)', () => {
40
+ const big = 'x'.repeat(5000);
41
+ const out = sanitizeErrorMessage(big, { limit: 100 });
42
+ expect(out.length).toBeLessThanOrEqual(101); // 100 + ellipsis char
43
+ expect(out.endsWith('…')).toBe(true);
44
+ });
45
+ it('collapses whitespace and trims', () => {
46
+ const out = sanitizeErrorMessage(' some messy\n\ttext ');
47
+ expect(out).toBe('some messy text');
48
+ });
49
+ it('supports a prefix that is not truncated by the limit', () => {
50
+ const out = sanitizeErrorMessage('body', { prefix: 'ElevenLabs 401: ', limit: 300 });
51
+ expect(out.startsWith('ElevenLabs 401: ')).toBe(true);
52
+ });
53
+ it('handles Error objects via .message', () => {
54
+ const out = sanitizeErrorMessage(new Error('Bearer sk-abcdefghijklmnop'));
55
+ expect(out).toContain('Bearer [REDACTED]');
56
+ });
57
+ it('handles non-string non-Error values via String()', () => {
58
+ const out = sanitizeErrorMessage({ toString: () => 'weird' });
59
+ expect(out).toBe('weird');
60
+ });
61
+ });
62
+ describe('error-sanitizer — sanitizedError', () => {
63
+ it('returns an Error whose message is already sanitized', () => {
64
+ const err = sanitizedError('Authorization: Bearer sk-123abcXYZ');
65
+ expect(err).toBeInstanceOf(Error);
66
+ expect(err.message).toContain('Bearer [REDACTED]');
67
+ });
68
+ it('applies a prefix when provided', () => {
69
+ const err = sanitizedError('fail', 'OpenAI 500: ');
70
+ expect(err.message.startsWith('OpenAI 500: ')).toBe(true);
71
+ });
72
+ });
73
+ //# sourceMappingURL=error-sanitizer.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-sanitizer.test.js","sourceRoot":"","sources":["../../src/lib/error-sanitizer.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,64 @@
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
+ export type FfmpegBin = 'ffmpeg' | 'ffprobe';
27
+ /**
28
+ * Returns the path / name to invoke for the given binary. Prefers the
29
+ * `FFMPEG_PATH` / `FFPROBE_PATH` environment variable (trimmed) if set;
30
+ * otherwise falls back to the bare binary name and relies on PATH resolution.
31
+ *
32
+ * Pure function — does not touch the filesystem or spawn anything. Read every
33
+ * call so a process that mutates env vars at runtime sees the new value.
34
+ */
35
+ export declare function resolveFfmpegBin(name: FfmpegBin): string;
36
+ /**
37
+ * Returns the env-var key associated with a binary, for use in error messages
38
+ * and diagnostics. Centralised so the names cannot drift.
39
+ */
40
+ export declare function envVarFor(name: FfmpegBin): 'FFMPEG_PATH' | 'FFPROBE_PATH';
41
+ export interface BinaryProbeResult {
42
+ ok: boolean;
43
+ /** First line of the resolved binary's `-version` output (truncated). */
44
+ versionLine?: string;
45
+ /** Plain reason the binary could not be invoked. Never includes the env value. */
46
+ reason?: string;
47
+ /** What was actually invoked (env-override or bare name). */
48
+ resolved: string;
49
+ }
50
+ /**
51
+ * Probes the binary by invoking `<bin> -version`. Returns a structured result
52
+ * instead of throwing so callers can decide how to surface the failure
53
+ * (startup-exit vs. lazy retry vs. tool-error).
54
+ *
55
+ * Hidden console window on Windows via `windowsHide: true`. Stdio piped so
56
+ * version banners do not leak into MCP stdout.
57
+ */
58
+ export declare function probeFfmpegBin(name: FfmpegBin): BinaryProbeResult;
59
+ /**
60
+ * Probes a binary and throws a friendly error if it is not invokable. The
61
+ * thrown message tells the operator exactly which env var to set. Caller is
62
+ * responsible for catching + exiting (server.ts does that at startup).
63
+ */
64
+ export declare function assertFfmpegBinAvailable(name: FfmpegBin): void;
@@ -0,0 +1,96 @@
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
+ import { execFileSync } from 'node:child_process';
27
+ const ENV_VAR = {
28
+ ffmpeg: 'FFMPEG_PATH',
29
+ ffprobe: 'FFPROBE_PATH',
30
+ };
31
+ /**
32
+ * Returns the path / name to invoke for the given binary. Prefers the
33
+ * `FFMPEG_PATH` / `FFPROBE_PATH` environment variable (trimmed) if set;
34
+ * otherwise falls back to the bare binary name and relies on PATH resolution.
35
+ *
36
+ * Pure function — does not touch the filesystem or spawn anything. Read every
37
+ * call so a process that mutates env vars at runtime sees the new value.
38
+ */
39
+ export function resolveFfmpegBin(name) {
40
+ const envName = ENV_VAR[name];
41
+ const override = process.env[envName];
42
+ if (typeof override === 'string') {
43
+ const trimmed = override.trim();
44
+ if (trimmed.length > 0)
45
+ return trimmed;
46
+ }
47
+ return name;
48
+ }
49
+ /**
50
+ * Returns the env-var key associated with a binary, for use in error messages
51
+ * and diagnostics. Centralised so the names cannot drift.
52
+ */
53
+ export function envVarFor(name) {
54
+ return ENV_VAR[name];
55
+ }
56
+ /**
57
+ * Probes the binary by invoking `<bin> -version`. Returns a structured result
58
+ * instead of throwing so callers can decide how to surface the failure
59
+ * (startup-exit vs. lazy retry vs. tool-error).
60
+ *
61
+ * Hidden console window on Windows via `windowsHide: true`. Stdio piped so
62
+ * version banners do not leak into MCP stdout.
63
+ */
64
+ export function probeFfmpegBin(name) {
65
+ const resolved = resolveFfmpegBin(name);
66
+ try {
67
+ const out = execFileSync(resolved, ['-version'], {
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ windowsHide: true,
70
+ timeout: 5_000,
71
+ });
72
+ const firstLine = out.toString('utf8').split(/\r?\n/, 1)[0]?.trim();
73
+ return { ok: true, versionLine: firstLine?.slice(0, 200), resolved };
74
+ }
75
+ catch (err) {
76
+ const reason = err instanceof Error ? err.message : String(err);
77
+ return { ok: false, reason: reason.slice(0, 300), resolved };
78
+ }
79
+ }
80
+ /**
81
+ * Probes a binary and throws a friendly error if it is not invokable. The
82
+ * thrown message tells the operator exactly which env var to set. Caller is
83
+ * responsible for catching + exiting (server.ts does that at startup).
84
+ */
85
+ export function assertFfmpegBinAvailable(name) {
86
+ const result = probeFfmpegBin(name);
87
+ if (result.ok)
88
+ return;
89
+ const envName = envVarFor(name);
90
+ const isOverride = result.resolved !== name;
91
+ const tail = isOverride
92
+ ? `${envName} is set to "${result.resolved}" but the binary cannot be executed (reason: ${result.reason ?? 'unknown'}).`
93
+ : `Install ffmpeg: https://ffmpeg.org/download.html — or set ${envName}=<path-to-binary> if it is installed elsewhere.`;
94
+ throw new Error(`${name} not found. ${tail}`);
95
+ }
96
+ //# sourceMappingURL=ffmpeg-bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg-bin.js","sourceRoot":"","sources":["../../src/lib/ffmpeg-bin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIlD,MAAM,OAAO,GAAsD;IACjE,MAAM,EAAE,aAAa;IACrB,OAAO,EAAE,cAAc;CACxB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAe;IAC9C,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACtC,IAAI,OAAO,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACjC,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,EAAE,CAAC;QAChC,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,OAAO,CAAC;IACzC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,IAAe;IACvC,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC;AACvB,CAAC;AAYD;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,IAAe;IAC5C,MAAM,QAAQ,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACxC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,YAAY,CAAC,QAAQ,EAAE,CAAC,UAAU,CAAC,EAAE;YAC/C,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;YACjC,WAAW,EAAE,IAAI;YACjB,OAAO,EAAE,KAAK;SACf,CAAC,CAAC;QACH,MAAM,SAAS,GAAG,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;QACpE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,QAAQ,EAAE,CAAC;IACvE,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAChE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,QAAQ,EAAE,CAAC;IAC/D,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CAAC,IAAe;IACtD,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,MAAM,CAAC,EAAE;QAAE,OAAO;IAEtB,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,UAAU,GAAG,MAAM,CAAC,QAAQ,KAAK,IAAI,CAAC;IAC5C,MAAM,IAAI,GAAG,UAAU;QACrB,CAAC,CAAC,GAAG,OAAO,eAAe,MAAM,CAAC,QAAQ,gDAAgD,MAAM,CAAC,MAAM,IAAI,SAAS,IAAI;QACxH,CAAC,CAAC,6DAA6D,OAAO,iDAAiD,CAAC;IAC1H,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,eAAe,IAAI,EAAE,CAAC,CAAC;AAChD,CAAC"}
@@ -0,0 +1,18 @@
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
+ export {};
@@ -0,0 +1,169 @@
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
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
19
+ import { resolveFfmpegBin, envVarFor, probeFfmpegBin, assertFfmpegBinAvailable, } from './ffmpeg-bin.js';
20
+ const ENV_KEYS = ['FFMPEG_PATH', 'FFPROBE_PATH'];
21
+ describe('ffmpeg-bin: env override discovery', () => {
22
+ let saved;
23
+ beforeEach(() => {
24
+ saved = {};
25
+ for (const k of ENV_KEYS) {
26
+ saved[k] = process.env[k];
27
+ delete process.env[k];
28
+ }
29
+ });
30
+ afterEach(() => {
31
+ for (const k of ENV_KEYS) {
32
+ if (saved[k] === undefined)
33
+ delete process.env[k];
34
+ else
35
+ process.env[k] = saved[k];
36
+ }
37
+ });
38
+ it('returns bare name when env var unset', () => {
39
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
40
+ expect(resolveFfmpegBin('ffprobe')).toBe('ffprobe');
41
+ });
42
+ it('returns env override when FFMPEG_PATH is set', () => {
43
+ process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';
44
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/opt/ffmpeg/bin/ffmpeg');
45
+ });
46
+ it('returns env override when FFPROBE_PATH is set', () => {
47
+ process.env.FFPROBE_PATH = 'C:\\Program Files\\ffmpeg\\ffprobe.exe';
48
+ expect(resolveFfmpegBin('ffprobe')).toBe('C:\\Program Files\\ffmpeg\\ffprobe.exe');
49
+ });
50
+ it('keeps each override scoped to its own binary', () => {
51
+ process.env.FFMPEG_PATH = '/a/ffmpeg';
52
+ process.env.FFPROBE_PATH = '/b/ffprobe';
53
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/a/ffmpeg');
54
+ expect(resolveFfmpegBin('ffprobe')).toBe('/b/ffprobe');
55
+ });
56
+ it('trims whitespace around the env value', () => {
57
+ process.env.FFMPEG_PATH = ' /opt/ffmpeg ';
58
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/opt/ffmpeg');
59
+ });
60
+ it('treats blank env value as unset (a stray `FFMPEG_PATH=` line in .env stays harmless)', () => {
61
+ process.env.FFMPEG_PATH = ' ';
62
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
63
+ });
64
+ it('treats empty string as unset', () => {
65
+ process.env.FFMPEG_PATH = '';
66
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
67
+ });
68
+ it('reads env on every call so runtime mutations are visible', () => {
69
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
70
+ process.env.FFMPEG_PATH = '/late/binding/ffmpeg';
71
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/late/binding/ffmpeg');
72
+ delete process.env.FFMPEG_PATH;
73
+ expect(resolveFfmpegBin('ffmpeg')).toBe('ffmpeg');
74
+ });
75
+ it('exposes the env var name via envVarFor for diagnostic messages', () => {
76
+ expect(envVarFor('ffmpeg')).toBe('FFMPEG_PATH');
77
+ expect(envVarFor('ffprobe')).toBe('FFPROBE_PATH');
78
+ });
79
+ });
80
+ describe('ffmpeg-bin: probe + assertion', () => {
81
+ let saved;
82
+ beforeEach(() => {
83
+ saved = {};
84
+ for (const k of ENV_KEYS) {
85
+ saved[k] = process.env[k];
86
+ delete process.env[k];
87
+ }
88
+ });
89
+ afterEach(() => {
90
+ for (const k of ENV_KEYS) {
91
+ if (saved[k] === undefined)
92
+ delete process.env[k];
93
+ else
94
+ process.env[k] = saved[k];
95
+ }
96
+ });
97
+ it('probeFfmpegBin returns ok=false with reason when override points to a bogus path', () => {
98
+ process.env.FFMPEG_PATH = '/definitely/does/not/exist/ffmpeg-bogus';
99
+ const result = probeFfmpegBin('ffmpeg');
100
+ expect(result.ok).toBe(false);
101
+ expect(result.resolved).toBe('/definitely/does/not/exist/ffmpeg-bogus');
102
+ expect(result.reason).toBeTypeOf('string');
103
+ expect(result.reason.length).toBeGreaterThan(0);
104
+ expect(result.versionLine).toBeUndefined();
105
+ });
106
+ it('probeFfmpegBin still returns the resolved path even on failure (so error UX can show it)', () => {
107
+ process.env.FFPROBE_PATH = 'C:\\nope\\ffprobe.exe';
108
+ const result = probeFfmpegBin('ffprobe');
109
+ expect(result.ok).toBe(false);
110
+ expect(result.resolved).toBe('C:\\nope\\ffprobe.exe');
111
+ });
112
+ it('assertFfmpegBinAvailable throws with FFMPEG_PATH hint when override is set', () => {
113
+ process.env.FFMPEG_PATH = '/bogus/ffmpeg';
114
+ let caught;
115
+ try {
116
+ assertFfmpegBinAvailable('ffmpeg');
117
+ }
118
+ catch (err) {
119
+ caught = err;
120
+ }
121
+ expect(caught).toBeDefined();
122
+ expect(caught.message).toContain('ffmpeg not found.');
123
+ expect(caught.message).toContain('FFMPEG_PATH');
124
+ expect(caught.message).toContain('/bogus/ffmpeg');
125
+ });
126
+ it('assertFfmpegBinAvailable points at install URL when no override is set and binary missing', () => {
127
+ // Use FFMPEG_BOGUS-style probe: temporarily swap the resolved name to a
128
+ // non-existent binary by setting the env var to a known-missing path.
129
+ // (We cannot actually unset PATH safely in a unit test, so we rely on the
130
+ // override-path branch having already been validated and on the message
131
+ // shape when override === bare-name. To exercise that branch without
132
+ // breaking other tests, we set FFMPEG_PATH to a clearly-not-bare-name path
133
+ // that fails AND assert the override-branch message.)
134
+ process.env.FFMPEG_PATH = '/totally/bogus/path/to/ffmpeg-xyz';
135
+ let caught;
136
+ try {
137
+ assertFfmpegBinAvailable('ffmpeg');
138
+ }
139
+ catch (err) {
140
+ caught = err;
141
+ }
142
+ expect(caught).toBeDefined();
143
+ // override branch: explains what was set
144
+ expect(caught.message).toContain('FFMPEG_PATH is set to');
145
+ });
146
+ it('assertFfmpegBinAvailable does NOT throw when the binary actually runs (ffmpeg installed)', () => {
147
+ // This test only runs in environments where ffmpeg is on PATH. CI and
148
+ // dev images all have it; if the developer has not installed it locally,
149
+ // this assertion is skipped to avoid false-negatives on local-only runs.
150
+ const probe = probeFfmpegBin('ffmpeg');
151
+ if (!probe.ok) {
152
+ // Skip silently — caller environment lacks ffmpeg, the override-path
153
+ // branch above already verified the failure path.
154
+ return;
155
+ }
156
+ expect(() => assertFfmpegBinAvailable('ffmpeg')).not.toThrow();
157
+ expect(probe.versionLine).toBeTypeOf('string');
158
+ expect(probe.versionLine.toLowerCase()).toContain('ffmpeg');
159
+ });
160
+ it('regression #11: when FFMPEG_PATH points at a real binary, the resolver returns it (not the bare name)', () => {
161
+ // This guards against the original bug shape: the env var was advertised
162
+ // in error messages but never actually read. If a future refactor breaks
163
+ // the env-override branch, this test fails immediately without needing
164
+ // ffmpeg installed.
165
+ process.env.FFMPEG_PATH = '/custom/install/ffmpeg';
166
+ expect(resolveFfmpegBin('ffmpeg')).toBe('/custom/install/ffmpeg');
167
+ });
168
+ });
169
+ //# sourceMappingURL=ffmpeg-bin.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg-bin.test.js","sourceRoot":"","sources":["../../src/lib/ffmpeg-bin.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Central ffmpeg runner — every engine should use this instead of calling
3
+ * `execFile('ffmpeg', args, ...)` directly. Two reasons:
4
+ *
5
+ * 1. We inject `-protocol_whitelist` on every call (see ffmpeg-safety.ts).
6
+ * Without it, an HLS playlist — or a user-provided "inputPath" that is
7
+ * secretly an `https://` URL — lets ffmpeg fetch any protocol it was
8
+ * built against, including `file://`, which means SSRF to the local
9
+ * filesystem or to AWS/GCP metadata endpoints.
10
+ *
11
+ * 2. We sanitize stderr before it lands in logs or thrown messages.
12
+ * ffmpeg will happily echo signed URLs, Authorization headers and
13
+ * API keys from failed HTTP fetches.
14
+ *
15
+ * Callers pass their own `maxBuffer`/`timeout`/`protocols` as needed.
16
+ */
17
+ import { type FfmpegProtocolSet } from './ffmpeg-safety.js';
18
+ export interface FfmpegRunOptions {
19
+ /** Bytes of stdout+stderr buffered before killing the process. Default: 50 MB. */
20
+ maxBuffer?: number;
21
+ /** Kill-after timeout in ms. Default: no timeout (ffmpeg exits on its own). */
22
+ timeoutMs?: number;
23
+ /** Which ffmpeg protocols to permit. Default: 'local-only' (file + pipe). */
24
+ protocols?: FfmpegProtocolSet;
25
+ /** Optional label used in the rejection reason, e.g. "lut-preset" */
26
+ label?: string;
27
+ }
28
+ /**
29
+ * Some callers (beat-sync, filter pipelines) need stderr because ffmpeg
30
+ * prints filter-graph info and stream metadata there. Pass `'stderr'` as
31
+ * the third arg to resolve with stderr instead of stdout. Defaults to stdout.
32
+ */
33
+ export declare function runFfmpeg(args: string[], opts?: FfmpegRunOptions, resolver?: 'stdout' | 'stderr'): Promise<string>;
34
+ /**
35
+ * ffprobe runner — same protocol-whitelist + stderr-sanitize discipline as
36
+ * runFfmpeg. ffprobe silently follows HLS/DASH playlists the same way ffmpeg
37
+ * does, so every `execFile('ffprobe', args, ...)` must go through here or
38
+ * the SSRF hardening has a bypass via "just probe the file first".
39
+ *
40
+ * Default resolver is stdout because ffprobe's `-show_entries …` + `-of …`
41
+ * output is what callers need. stderr is error-only.
42
+ */
43
+ export declare function runFfprobe(args: string[], opts?: FfmpegRunOptions, resolver?: 'stdout' | 'stderr'): Promise<string>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Central ffmpeg runner — every engine should use this instead of calling
3
+ * `execFile('ffmpeg', args, ...)` directly. Two reasons:
4
+ *
5
+ * 1. We inject `-protocol_whitelist` on every call (see ffmpeg-safety.ts).
6
+ * Without it, an HLS playlist — or a user-provided "inputPath" that is
7
+ * secretly an `https://` URL — lets ffmpeg fetch any protocol it was
8
+ * built against, including `file://`, which means SSRF to the local
9
+ * filesystem or to AWS/GCP metadata endpoints.
10
+ *
11
+ * 2. We sanitize stderr before it lands in logs or thrown messages.
12
+ * ffmpeg will happily echo signed URLs, Authorization headers and
13
+ * API keys from failed HTTP fetches.
14
+ *
15
+ * Callers pass their own `maxBuffer`/`timeout`/`protocols` as needed.
16
+ */
17
+ import { execFile } from 'node:child_process';
18
+ import { logger } from './logger.js';
19
+ import { buildFfmpegArgs } from './ffmpeg-safety.js';
20
+ import { sanitizeErrorMessage } from './error-sanitizer.js';
21
+ import { resolveFfmpegBin } from './ffmpeg-bin.js';
22
+ /**
23
+ * Some callers (beat-sync, filter pipelines) need stderr because ffmpeg
24
+ * prints filter-graph info and stream metadata there. Pass `'stderr'` as
25
+ * the third arg to resolve with stderr instead of stdout. Defaults to stdout.
26
+ */
27
+ export function runFfmpeg(args, opts = {}, resolver = 'stdout') {
28
+ const { maxBuffer = 50 * 1024 * 1024, timeoutMs, protocols = 'local-only', label = 'ffmpeg', } = opts;
29
+ const safeArgs = buildFfmpegArgs(args, protocols);
30
+ return new Promise((resolve, reject) => {
31
+ execFile(resolveFfmpegBin('ffmpeg'), safeArgs, { maxBuffer, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
32
+ if (error) {
33
+ const safeMsg = sanitizeErrorMessage(stderr || error.message);
34
+ logger.error(`${label} failed: ${safeMsg}`);
35
+ reject(new Error(`${label} failed: ${safeMsg}`));
36
+ return;
37
+ }
38
+ resolve(resolver === 'stderr' ? stderr : stdout);
39
+ });
40
+ });
41
+ }
42
+ /**
43
+ * ffprobe runner — same protocol-whitelist + stderr-sanitize discipline as
44
+ * runFfmpeg. ffprobe silently follows HLS/DASH playlists the same way ffmpeg
45
+ * does, so every `execFile('ffprobe', args, ...)` must go through here or
46
+ * the SSRF hardening has a bypass via "just probe the file first".
47
+ *
48
+ * Default resolver is stdout because ffprobe's `-show_entries …` + `-of …`
49
+ * output is what callers need. stderr is error-only.
50
+ */
51
+ export function runFfprobe(args, opts = {}, resolver = 'stdout') {
52
+ const { maxBuffer = 10 * 1024 * 1024, timeoutMs, protocols = 'local-only', label = 'ffprobe', } = opts;
53
+ // ffprobe honours the same -protocol_whitelist flag as ffmpeg.
54
+ const safeArgs = buildFfmpegArgs(args, protocols);
55
+ return new Promise((resolve, reject) => {
56
+ execFile(resolveFfmpegBin('ffprobe'), safeArgs, { maxBuffer, timeout: timeoutMs, windowsHide: true }, (error, stdout, stderr) => {
57
+ if (error) {
58
+ const safeMsg = sanitizeErrorMessage(stderr || error.message);
59
+ logger.error(`${label} failed: ${safeMsg}`);
60
+ reject(new Error(`${label} failed: ${safeMsg}`));
61
+ return;
62
+ }
63
+ resolve(resolver === 'stderr' ? stderr : stdout);
64
+ });
65
+ });
66
+ }
67
+ //# sourceMappingURL=ffmpeg-run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg-run.js","sourceRoot":"","sources":["../../src/lib/ffmpeg-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,eAAe,EAA0B,MAAM,oBAAoB,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAanD;;;;GAIG;AACH,MAAM,UAAU,SAAS,CACvB,IAAc,EACd,OAAyB,EAAE,EAC3B,WAAgC,QAAQ;IAExC,MAAM,EACJ,SAAS,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,EAC5B,SAAS,EACT,SAAS,GAAG,YAAY,EACxB,KAAK,GAAG,QAAQ,GACjB,GAAG,IAAI,CAAC;IACT,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IAElD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,QAAQ,CACN,gBAAgB,CAAC,QAAQ,CAAC,EAC1B,QAAQ,EACR,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAAE,EACpD,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YACxB,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,OAAO,GAAG,oBAAoB,CAAC,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;gBAC9D,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC;gBAC5C,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC,CAAC;gBACjD,OAAO;YACT,CAAC;YACD,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QACnD,CAAC,CACF,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CACxB,IAAc,EACd,OAAyB,EAAE,EAC3B,WAAgC,QAAQ;IAExC,MAAM,EACJ,SAAS,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,EAC5B,SAAS,EACT,SAAS,GAAG,YAAY,EACxB,KAAK,GAAG,SAAS,GAClB,GAAG,IAAI,CAAC;IACT,+DAA+D;IAC/D,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IAElD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,QAAQ,CACN,gBAAgB,CAAC,SAAS,CAAC,EAC3B,QAAQ,EACR,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAAE,EACpD,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;YACxB,IAAI,KAAK,EAAE,CAAC;gBACV,MAAM,OAAO,GAAG,oBAAoB,CAAC,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;gBAC9D,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC;gBAC5C,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC,CAAC;gBACjD,OAAO;YACT,CAAC;YACD,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QACnD,CAAC,CACF,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // Hoisted mock so vi.mock resolves before imports.
3
+ const execFileMock = vi.hoisted(() => vi.fn());
4
+ vi.mock('node:child_process', () => ({ execFile: execFileMock }));
5
+ // IMPORTANT: import AFTER vi.mock so the mock is bound.
6
+ import { runFfmpeg } from './ffmpeg-run.js';
7
+ describe('ffmpeg-run — runFfmpeg', () => {
8
+ beforeEach(() => {
9
+ execFileMock.mockReset();
10
+ });
11
+ it('prepends -protocol_whitelist on every call (local-only default)', async () => {
12
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
13
+ cb(null, 'ok', '');
14
+ });
15
+ await runFfmpeg(['-i', 'in.mp4', 'out.mp4']);
16
+ const args = execFileMock.mock.calls[0][1];
17
+ expect(args[0]).toBe('-protocol_whitelist');
18
+ expect(args[1]).toBe('file,pipe,crypto,cache,fd');
19
+ expect(args.slice(2)).toEqual(['-i', 'in.mp4', 'out.mp4']);
20
+ });
21
+ it('honours https-input protocol set', async () => {
22
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
23
+ cb(null, '', '');
24
+ });
25
+ await runFfmpeg(['-i', 'https://a.example/s.m3u8'], { protocols: 'https-input' });
26
+ const args = execFileMock.mock.calls[0][1];
27
+ expect(args[1]).toContain('https');
28
+ expect(args[1]).not.toContain('http,');
29
+ });
30
+ it('resolves with stdout by default', async () => {
31
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
32
+ cb(null, 'stdout-data', 'stderr-data');
33
+ });
34
+ const out = await runFfmpeg(['in']);
35
+ expect(out).toBe('stdout-data');
36
+ });
37
+ it('resolves with stderr when resolver="stderr" (beat-sync use-case)', async () => {
38
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
39
+ cb(null, 'stdout-data', 'filter-info-on-stderr');
40
+ });
41
+ const out = await runFfmpeg(['in'], {}, 'stderr');
42
+ expect(out).toBe('filter-info-on-stderr');
43
+ });
44
+ it('rejects with a sanitized message when ffmpeg fails', async () => {
45
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
46
+ const err = new Error('exit 1');
47
+ cb(err, '', 'Authorization: Bearer sk-super-secret-1234567890');
48
+ });
49
+ await expect(runFfmpeg(['in'])).rejects.toThrow(/\[REDACTED\]/);
50
+ });
51
+ it('honours custom maxBuffer and timeoutMs', async () => {
52
+ execFileMock.mockImplementationOnce((_bin, _args, opts, cb) => {
53
+ expect(opts.maxBuffer).toBe(123);
54
+ expect(opts.timeout).toBe(456);
55
+ cb(null, '', '');
56
+ });
57
+ await runFfmpeg([], { maxBuffer: 123, timeoutMs: 456 });
58
+ });
59
+ it('includes the label in the rejection message', async () => {
60
+ execFileMock.mockImplementationOnce((_bin, _args, _opts, cb) => {
61
+ cb(new Error('x'), '', 'boom');
62
+ });
63
+ await expect(runFfmpeg([], { label: 'lut-preset' })).rejects.toThrow(/lut-preset/);
64
+ });
65
+ });
66
+ //# sourceMappingURL=ffmpeg-run.test.js.map