@studiomeyer/mcp-video 1.0.0

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 (184) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  3. package/.github/workflows/ci.yml +34 -0
  4. package/CHANGELOG.md +24 -0
  5. package/CONTRIBUTING.md +75 -0
  6. package/LICENSE +21 -0
  7. package/README.md +198 -0
  8. package/USAGE.md +144 -0
  9. package/dist/handlers/capcut.d.ts +6 -0
  10. package/dist/handlers/capcut.js +229 -0
  11. package/dist/handlers/capcut.js.map +1 -0
  12. package/dist/handlers/editing.d.ts +6 -0
  13. package/dist/handlers/editing.js +242 -0
  14. package/dist/handlers/editing.js.map +1 -0
  15. package/dist/handlers/index.d.ts +2 -0
  16. package/dist/handlers/index.js +33 -0
  17. package/dist/handlers/index.js.map +1 -0
  18. package/dist/handlers/post-production.d.ts +5 -0
  19. package/dist/handlers/post-production.js +109 -0
  20. package/dist/handlers/post-production.js.map +1 -0
  21. package/dist/handlers/smart-screenshot.d.ts +5 -0
  22. package/dist/handlers/smart-screenshot.js +83 -0
  23. package/dist/handlers/smart-screenshot.js.map +1 -0
  24. package/dist/handlers/tts.d.ts +5 -0
  25. package/dist/handlers/tts.js +83 -0
  26. package/dist/handlers/tts.js.map +1 -0
  27. package/dist/handlers/video.d.ts +5 -0
  28. package/dist/handlers/video.js +127 -0
  29. package/dist/handlers/video.js.map +1 -0
  30. package/dist/lib/dual-transport.d.ts +42 -0
  31. package/dist/lib/dual-transport.js +208 -0
  32. package/dist/lib/dual-transport.js.map +1 -0
  33. package/dist/lib/logger.d.ts +12 -0
  34. package/dist/lib/logger.js +42 -0
  35. package/dist/lib/logger.js.map +1 -0
  36. package/dist/lib/types.d.ts +16 -0
  37. package/dist/lib/types.js +15 -0
  38. package/dist/lib/types.js.map +1 -0
  39. package/dist/schemas/capcut.d.ts +608 -0
  40. package/dist/schemas/capcut.js +411 -0
  41. package/dist/schemas/capcut.js.map +1 -0
  42. package/dist/schemas/editing.d.ts +822 -0
  43. package/dist/schemas/editing.js +466 -0
  44. package/dist/schemas/editing.js.map +1 -0
  45. package/dist/schemas/index.d.ts +2366 -0
  46. package/dist/schemas/index.js +15 -0
  47. package/dist/schemas/index.js.map +1 -0
  48. package/dist/schemas/post-production.d.ts +379 -0
  49. package/dist/schemas/post-production.js +268 -0
  50. package/dist/schemas/post-production.js.map +1 -0
  51. package/dist/schemas/smart-screenshot.d.ts +127 -0
  52. package/dist/schemas/smart-screenshot.js +122 -0
  53. package/dist/schemas/smart-screenshot.js.map +1 -0
  54. package/dist/schemas/tts.d.ts +220 -0
  55. package/dist/schemas/tts.js +194 -0
  56. package/dist/schemas/tts.js.map +1 -0
  57. package/dist/schemas/video.d.ts +236 -0
  58. package/dist/schemas/video.js +210 -0
  59. package/dist/schemas/video.js.map +1 -0
  60. package/dist/server.d.ts +11 -0
  61. package/dist/server.js +239 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/server.test.d.ts +1 -0
  64. package/dist/server.test.js +87 -0
  65. package/dist/server.test.js.map +1 -0
  66. package/dist/tools/engine/audio-mixer.d.ts +40 -0
  67. package/dist/tools/engine/audio-mixer.js +169 -0
  68. package/dist/tools/engine/audio-mixer.js.map +1 -0
  69. package/dist/tools/engine/audio.d.ts +22 -0
  70. package/dist/tools/engine/audio.js +73 -0
  71. package/dist/tools/engine/audio.js.map +1 -0
  72. package/dist/tools/engine/beat-sync.d.ts +31 -0
  73. package/dist/tools/engine/beat-sync.js +270 -0
  74. package/dist/tools/engine/beat-sync.js.map +1 -0
  75. package/dist/tools/engine/capture.d.ts +12 -0
  76. package/dist/tools/engine/capture.js +290 -0
  77. package/dist/tools/engine/capture.js.map +1 -0
  78. package/dist/tools/engine/chroma-key.d.ts +27 -0
  79. package/dist/tools/engine/chroma-key.js +154 -0
  80. package/dist/tools/engine/chroma-key.js.map +1 -0
  81. package/dist/tools/engine/concat.d.ts +49 -0
  82. package/dist/tools/engine/concat.js +149 -0
  83. package/dist/tools/engine/concat.js.map +1 -0
  84. package/dist/tools/engine/cursor.d.ts +26 -0
  85. package/dist/tools/engine/cursor.js +185 -0
  86. package/dist/tools/engine/cursor.js.map +1 -0
  87. package/dist/tools/engine/easing.d.ts +15 -0
  88. package/dist/tools/engine/easing.js +100 -0
  89. package/dist/tools/engine/easing.js.map +1 -0
  90. package/dist/tools/engine/editing.d.ts +158 -0
  91. package/dist/tools/engine/editing.js +541 -0
  92. package/dist/tools/engine/editing.js.map +1 -0
  93. package/dist/tools/engine/encoder.d.ts +31 -0
  94. package/dist/tools/engine/encoder.js +154 -0
  95. package/dist/tools/engine/encoder.js.map +1 -0
  96. package/dist/tools/engine/index.d.ts +30 -0
  97. package/dist/tools/engine/index.js +23 -0
  98. package/dist/tools/engine/index.js.map +1 -0
  99. package/dist/tools/engine/lut-presets.d.ts +25 -0
  100. package/dist/tools/engine/lut-presets.js +141 -0
  101. package/dist/tools/engine/lut-presets.js.map +1 -0
  102. package/dist/tools/engine/narrated-video.d.ts +63 -0
  103. package/dist/tools/engine/narrated-video.js +163 -0
  104. package/dist/tools/engine/narrated-video.js.map +1 -0
  105. package/dist/tools/engine/scenes.d.ts +17 -0
  106. package/dist/tools/engine/scenes.js +223 -0
  107. package/dist/tools/engine/scenes.js.map +1 -0
  108. package/dist/tools/engine/smart-screenshot.d.ts +80 -0
  109. package/dist/tools/engine/smart-screenshot.js +744 -0
  110. package/dist/tools/engine/smart-screenshot.js.map +1 -0
  111. package/dist/tools/engine/social-format.d.ts +66 -0
  112. package/dist/tools/engine/social-format.js +107 -0
  113. package/dist/tools/engine/social-format.js.map +1 -0
  114. package/dist/tools/engine/template-renderer.d.ts +45 -0
  115. package/dist/tools/engine/template-renderer.js +233 -0
  116. package/dist/tools/engine/template-renderer.js.map +1 -0
  117. package/dist/tools/engine/templates.d.ts +87 -0
  118. package/dist/tools/engine/templates.js +272 -0
  119. package/dist/tools/engine/templates.js.map +1 -0
  120. package/dist/tools/engine/text-animations.d.ts +33 -0
  121. package/dist/tools/engine/text-animations.js +192 -0
  122. package/dist/tools/engine/text-animations.js.map +1 -0
  123. package/dist/tools/engine/text-overlay.d.ts +27 -0
  124. package/dist/tools/engine/text-overlay.js +84 -0
  125. package/dist/tools/engine/text-overlay.js.map +1 -0
  126. package/dist/tools/engine/tts.d.ts +54 -0
  127. package/dist/tools/engine/tts.js +186 -0
  128. package/dist/tools/engine/tts.js.map +1 -0
  129. package/dist/tools/engine/types.d.ts +166 -0
  130. package/dist/tools/engine/types.js +13 -0
  131. package/dist/tools/engine/types.js.map +1 -0
  132. package/dist/tools/engine/voice-effects.d.ts +18 -0
  133. package/dist/tools/engine/voice-effects.js +215 -0
  134. package/dist/tools/engine/voice-effects.js.map +1 -0
  135. package/dist/tools/index.d.ts +32 -0
  136. package/dist/tools/index.js +23 -0
  137. package/dist/tools/index.js.map +1 -0
  138. package/package.json +56 -0
  139. package/scripts/check-deps.js +39 -0
  140. package/src/handlers/capcut.ts +245 -0
  141. package/src/handlers/editing.ts +260 -0
  142. package/src/handlers/index.ts +34 -0
  143. package/src/handlers/post-production.ts +136 -0
  144. package/src/handlers/smart-screenshot.ts +86 -0
  145. package/src/handlers/tts.ts +103 -0
  146. package/src/handlers/video.ts +137 -0
  147. package/src/lib/dual-transport.ts +272 -0
  148. package/src/lib/logger.ts +59 -0
  149. package/src/lib/types.ts +25 -0
  150. package/src/schemas/capcut.ts +418 -0
  151. package/src/schemas/editing.ts +476 -0
  152. package/src/schemas/index.ts +15 -0
  153. package/src/schemas/post-production.ts +273 -0
  154. package/src/schemas/smart-screenshot.ts +122 -0
  155. package/src/schemas/tts.ts +197 -0
  156. package/src/schemas/video.ts +211 -0
  157. package/src/server.test.ts +99 -0
  158. package/src/server.ts +289 -0
  159. package/src/tools/engine/audio-mixer.ts +244 -0
  160. package/src/tools/engine/audio.ts +115 -0
  161. package/src/tools/engine/beat-sync.ts +356 -0
  162. package/src/tools/engine/capture.ts +360 -0
  163. package/src/tools/engine/chroma-key.ts +202 -0
  164. package/src/tools/engine/concat.ts +242 -0
  165. package/src/tools/engine/cursor.ts +222 -0
  166. package/src/tools/engine/easing.ts +120 -0
  167. package/src/tools/engine/editing.ts +809 -0
  168. package/src/tools/engine/encoder.ts +208 -0
  169. package/src/tools/engine/index.ts +33 -0
  170. package/src/tools/engine/lut-presets.ts +235 -0
  171. package/src/tools/engine/narrated-video.ts +267 -0
  172. package/src/tools/engine/scenes.ts +309 -0
  173. package/src/tools/engine/smart-screenshot.ts +923 -0
  174. package/src/tools/engine/social-format.ts +146 -0
  175. package/src/tools/engine/template-renderer.ts +294 -0
  176. package/src/tools/engine/templates.ts +370 -0
  177. package/src/tools/engine/text-animations.ts +282 -0
  178. package/src/tools/engine/text-overlay.ts +143 -0
  179. package/src/tools/engine/tts.ts +284 -0
  180. package/src/tools/engine/types.ts +191 -0
  181. package/src/tools/engine/voice-effects.ts +258 -0
  182. package/src/tools/index.ts +67 -0
  183. package/tsconfig.json +19 -0
  184. package/vitest.config.ts +7 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * ffmpeg encoding pipeline — stitches PNG frames into cinema-grade video
3
+ */
4
+
5
+ import { execFile } from 'child_process';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { logger } from '../../lib/logger.js';
9
+ import type { EncodingConfig, VideoCodec, VideoFormat } from './types.js';
10
+
11
+ interface EncodeResult {
12
+ outputPath: string;
13
+ format: string;
14
+ codec: string;
15
+ fps: number;
16
+ sizeBytes: number;
17
+ sizeMB: string;
18
+ duration: number;
19
+ totalFrames: number;
20
+ }
21
+
22
+ const CODEC_MAP: Record<VideoCodec, { codec: string; format: VideoFormat; extraArgs: string[] }> = {
23
+ h264: {
24
+ codec: 'libx264',
25
+ format: 'mp4',
26
+ extraArgs: ['-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
27
+ },
28
+ h265: {
29
+ codec: 'libx265',
30
+ format: 'mp4',
31
+ extraArgs: ['-pix_fmt', 'yuv420p', '-tag:v', 'hvc1'],
32
+ },
33
+ vp9: {
34
+ codec: 'libvpx-vp9',
35
+ format: 'webm',
36
+ extraArgs: ['-pix_fmt', 'yuv420p', '-row-mt', '1'],
37
+ },
38
+ };
39
+
40
+ /**
41
+ * Encode a sequence of PNG frames into a video file using ffmpeg
42
+ */
43
+ export async function encodeFrames(
44
+ framesDir: string,
45
+ framePattern: string,
46
+ outputPath: string,
47
+ totalFrames: number,
48
+ config: EncodingConfig = {}
49
+ ): Promise<EncodeResult> {
50
+ const {
51
+ codec: codecName = 'h264',
52
+ crf = 18,
53
+ preset = 'slow',
54
+ fps = 60,
55
+ } = config;
56
+
57
+ const codecConfig = CODEC_MAP[codecName];
58
+ const format = config.format ?? codecConfig.format;
59
+ const finalOutput = outputPath.endsWith(`.${format}`)
60
+ ? outputPath
61
+ : `${outputPath}.${format}`;
62
+
63
+ // Ensure output directory exists
64
+ const outputDir = path.dirname(finalOutput);
65
+ if (!fs.existsSync(outputDir)) {
66
+ fs.mkdirSync(outputDir, { recursive: true });
67
+ }
68
+
69
+ const inputPattern = path.join(framesDir, framePattern);
70
+
71
+ const args = [
72
+ '-y', // Overwrite output
73
+ '-framerate', String(fps), // Input frame rate
74
+ '-i', inputPattern, // Input pattern
75
+ '-c:v', codecConfig.codec, // Video codec
76
+ '-preset', preset, // Encoding speed/quality
77
+ '-crf', String(crf), // Quality factor
78
+ ...codecConfig.extraArgs, // Codec-specific args
79
+ finalOutput, // Output file
80
+ ];
81
+
82
+ logger.info(`Encoding ${totalFrames} frames → ${finalOutput} (${codecName}, CRF ${crf}, ${fps}fps)`);
83
+
84
+ await runFfmpeg(args);
85
+
86
+ // Get file stats
87
+ const stats = fs.statSync(finalOutput);
88
+ const duration = totalFrames / fps;
89
+
90
+ return {
91
+ outputPath: finalOutput,
92
+ format,
93
+ codec: codecName,
94
+ fps,
95
+ sizeBytes: stats.size,
96
+ sizeMB: (stats.size / (1024 * 1024)).toFixed(2),
97
+ duration,
98
+ totalFrames,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Add a fade-in and/or fade-out to an existing video
104
+ */
105
+ export async function addFade(
106
+ inputPath: string,
107
+ outputPath: string,
108
+ fadeInDuration: number = 0.5,
109
+ fadeOutDuration: number = 0.5,
110
+ totalDuration: number
111
+ ): Promise<void> {
112
+ const fadeOutStart = totalDuration - fadeOutDuration;
113
+ const filter = `fade=t=in:st=0:d=${fadeInDuration},fade=t=out:st=${fadeOutStart}:d=${fadeOutDuration}`;
114
+
115
+ const args = [
116
+ '-y',
117
+ '-i', inputPath,
118
+ '-vf', filter,
119
+ '-c:v', 'libx264',
120
+ '-crf', '18',
121
+ '-preset', 'medium',
122
+ '-pix_fmt', 'yuv420p',
123
+ '-movflags', '+faststart',
124
+ outputPath,
125
+ ];
126
+
127
+ await runFfmpeg(args);
128
+ }
129
+
130
+ /**
131
+ * Concatenate multiple video clips with crossfade transitions
132
+ */
133
+ export async function concatenateWithTransition(
134
+ clips: string[],
135
+ outputPath: string,
136
+ transitionDuration: number = 1,
137
+ transitionType: string = 'fade'
138
+ ): Promise<void> {
139
+ if (clips.length < 2) {
140
+ // Single clip — just copy
141
+ if (clips[0] && clips[0] !== outputPath) {
142
+ fs.copyFileSync(clips[0], outputPath);
143
+ }
144
+ return;
145
+ }
146
+
147
+ // Build ffmpeg xfade filter chain for multiple clips
148
+ const inputs: string[] = [];
149
+ const filterParts: string[] = [];
150
+
151
+ for (const clip of clips) {
152
+ inputs.push('-i', clip);
153
+ }
154
+
155
+ // Chain xfade filters
156
+ let prevLabel = '0:v';
157
+ for (let i = 1; i < clips.length; i++) {
158
+ const outLabel = i === clips.length - 1 ? '' : `[v${i}]`;
159
+ const offset = i * 5 - transitionDuration; // Approximate offset
160
+ const filter = `[${prevLabel}][${i}:v]xfade=transition=${transitionType}:duration=${transitionDuration}:offset=${offset}`;
161
+ filterParts.push(filter + (outLabel ? outLabel : ''));
162
+ prevLabel = outLabel ? `v${i}` : '';
163
+ }
164
+
165
+ const args = [
166
+ '-y',
167
+ ...inputs,
168
+ '-filter_complex', filterParts.join(';'),
169
+ '-c:v', 'libx264',
170
+ '-crf', '18',
171
+ '-pix_fmt', 'yuv420p',
172
+ '-movflags', '+faststart',
173
+ outputPath,
174
+ ];
175
+
176
+ await runFfmpeg(args);
177
+ }
178
+
179
+ /**
180
+ * Run ffmpeg command and return a promise
181
+ */
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
+ });
194
+ }
195
+
196
+ /**
197
+ * Clean up temporary frame files
198
+ */
199
+ export function cleanupFrames(framesDir: string): void {
200
+ try {
201
+ if (fs.existsSync(framesDir)) {
202
+ fs.rmSync(framesDir, { recursive: true, force: true });
203
+ logger.debug(`Cleaned up frames: ${framesDir}`);
204
+ }
205
+ } catch (error) {
206
+ logger.warn(`Failed to cleanup frames: ${framesDir}`);
207
+ }
208
+ }
@@ -0,0 +1,33 @@
1
+ export { recordWebsite } from './capture.js';
2
+ export { encodeFrames, addFade, concatenateWithTransition, cleanupFrames } from './encoder.js';
3
+ export { getEasing, applyEasing, EASINGS } from './easing.js';
4
+ export { injectCursor, moveCursor, moveCursorToElement, animateClick, hideCursor } from './cursor.js';
5
+ export { executeScenes, createDefaultScenes } from './scenes.js';
6
+ export { addBackgroundMusic, getMediaDuration } from './audio.js';
7
+ export { concatenateVideos, generateIntro, TRANSITIONS } from './concat.js';
8
+ export { convertToSocialFormat, convertToAllFormats, SOCIAL_FORMATS } from './social-format.js';
9
+ export { addTextOverlays } from './text-overlay.js';
10
+ export { generateSpeech, listElevenLabsVoices } from './tts.js';
11
+ export { createNarratedVideo } from './narrated-video.js';
12
+ export { smartScreenshot } from './smart-screenshot.js';
13
+ export type { SmartScreenshotConfig, SmartScreenshotResult, SmartTarget, DetectedFeature } from './smart-screenshot.js';
14
+
15
+ // CapCut-tier engines
16
+ export { applyLutPreset, listLutPresets, ALL_LUT_PRESETS, PRESET_DESCRIPTIONS } from './lut-presets.js';
17
+ export type { LutPreset, LutPresetConfig } from './lut-presets.js';
18
+ export { applyVoiceEffect, ALL_VOICE_EFFECTS, VOICE_EFFECT_DESCRIPTIONS } from './voice-effects.js';
19
+ export type { VoiceEffect, VoiceEffectConfig } from './voice-effects.js';
20
+ export { applyChromaKey } from './chroma-key.js';
21
+ export type { ChromaKeyConfig } from './chroma-key.js';
22
+ export { syncToBeats } from './beat-sync.js';
23
+ export type { BeatSyncConfig, BeatSyncResult } from './beat-sync.js';
24
+ export { animateText, ALL_TEXT_ANIMATIONS, TEXT_ANIMATION_DESCRIPTIONS } from './text-animations.js';
25
+ export type { TextAnimation, TextAnimationConfig, TextPosition as AnimTextPosition } from './text-animations.js';
26
+ export { mixAudioTracks } from './audio-mixer.js';
27
+ export type { AudioTrack, AudioMixConfig, AudioMixResult } from './audio-mixer.js';
28
+ export { listTemplates, getTemplate, getTemplateSummaries, getTemplateCategories } from './templates.js';
29
+ export type { VideoTemplate, TemplateCategory, TemplateSlot } from './templates.js';
30
+ export { renderTemplate } from './template-renderer.js';
31
+ export type { RenderTemplateConfig, RenderResult, TemplateAssets } from './template-renderer.js';
32
+
33
+ export * from './types.js';
@@ -0,0 +1,235 @@
1
+ /**
2
+ * LUT Preset Engine — 22 cinematic color grade presets via FFmpeg filter chains.
3
+ *
4
+ * No external .cube files needed — each preset is a combination of
5
+ * colorbalance, eq, curves, colorchannelmixer, and hue filters.
6
+ * Intensity parameter (0.0-1.0) blends graded output with the original.
7
+ */
8
+
9
+ import { execFile } from 'child_process';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { logger } from '../../lib/logger.js';
13
+
14
+ // ─── Types ──────────────────────────────────────────────────────────
15
+
16
+ export type LutPreset =
17
+ | 'cinematic-teal-orange'
18
+ | 'cinematic-teal-orange-subtle'
19
+ | 'vintage-film'
20
+ | 'vintage-kodachrome'
21
+ | 'cross-process'
22
+ | 'moody-dark'
23
+ | 'warm-golden'
24
+ | 'cold-blue'
25
+ | 'film-noir'
26
+ | 'noir-blue-tint'
27
+ | 'bleach-bypass'
28
+ | 'cyberpunk-neon'
29
+ | 'cyberpunk-teal-pink'
30
+ | 'desaturated-fincher'
31
+ | 'pastel-dream'
32
+ | 'matrix-green'
33
+ | 'sepia'
34
+ | 'blockbuster-extreme'
35
+ | 'muted-forest'
36
+ | 'high-contrast-music'
37
+ | 'faded-lofi'
38
+ | 'sunset-magic-hour';
39
+
40
+ export interface LutPresetConfig {
41
+ inputPath: string;
42
+ outputPath: string;
43
+ /** The color grade preset to apply */
44
+ preset: LutPreset;
45
+ /** Blend intensity: 0.0 (original) to 1.0 (full effect). Default: 1.0 */
46
+ intensity?: number;
47
+ }
48
+
49
+ // ─── Preset Definitions ─────────────────────────────────────────────
50
+
51
+ const PRESET_FILTERS: Record<LutPreset, string> = {
52
+ 'cinematic-teal-orange':
53
+ "colorbalance=rs=-0.15:gs=-0.05:bs=0.25:rm=0.0:gm=-0.02:bm=0.05:rh=0.15:gh=0.02:bh=-0.2,eq=contrast=1.15:saturation=0.9:gamma=0.95,curves=r='0/0 0.25/0.22 0.5/0.55 0.75/0.80 1/1':b='0/0.05 0.25/0.28 0.5/0.45 0.75/0.70 1/0.9'",
54
+
55
+ 'cinematic-teal-orange-subtle':
56
+ "colorbalance=rs=-0.1:gs=-0.03:bs=0.18:rh=0.12:gh=0.02:bh=-0.15,eq=contrast=1.1:saturation=0.75:brightness=-0.02,curves=preset=medium_contrast",
57
+
58
+ 'vintage-film':
59
+ "curves=r='0/0.11 0.42/0.51 1/0.95':g='0/0 0.50/0.48 1/1':b='0/0.22 0.49/0.44 1/0.8',eq=saturation=0.8:contrast=0.9:gamma=1.1,colorbalance=rs=0.05:gs=0.02:bs=-0.05:rh=0.08:gh=0.05:bh=-0.03",
60
+
61
+ 'vintage-kodachrome':
62
+ "curves=r='0/0 0.15/0.18 0.5/0.58 0.85/0.88 1/1':g='0/0 0.5/0.48 1/0.92':b='0/0.06 0.5/0.44 1/0.85',eq=saturation=1.15:contrast=1.1,colorbalance=rs=0.04:gs=-0.02:bs=0.06:rh=0.06:gh=0.03:bh=-0.08",
63
+
64
+ 'cross-process':
65
+ "curves=r='0/0.2 0.5/0.6 1/0.9':g='0/0 0.5/0.55 1/1':b='0/0.3 0.5/0.4 1/0.8',eq=saturation=1.15:contrast=1.1",
66
+
67
+ 'moody-dark':
68
+ "eq=contrast=1.3:brightness=-0.08:saturation=0.65:gamma=0.85,colorbalance=rs=-0.05:gs=-0.02:bs=0.12:rm=-0.03:gm=-0.02:bm=0.05:rh=0.0:gh=-0.02:bh=0.05,curves=master='0/0 0.15/0.05 0.5/0.42 1/0.95'",
69
+
70
+ 'warm-golden':
71
+ "colorbalance=rs=0.12:gs=0.05:bs=-0.12:rm=0.06:gm=0.03:bm=-0.06:rh=0.1:gh=0.06:bh=-0.1,eq=saturation=1.15:contrast=1.05:brightness=0.03,curves=b='0/0 0.5/0.42 1/0.85'",
72
+
73
+ 'cold-blue':
74
+ "colorbalance=rs=-0.12:gs=-0.03:bs=0.2:rm=-0.06:gm=-0.02:bm=0.1:rh=-0.08:gh=0.0:bh=0.12,eq=saturation=0.7:contrast=1.1:brightness=0.05:gamma=1.1",
75
+
76
+ 'film-noir':
77
+ "colorchannelmixer=.3:.4:.3:0:.3:.4:.3:0:.3:.4:.3:0,eq=contrast=1.5:brightness=-0.05:gamma=0.9,curves=preset=strong_contrast",
78
+
79
+ 'noir-blue-tint':
80
+ "colorchannelmixer=.3:.4:.3:0:.3:.4:.3:0:.35:.45:.35:0,eq=contrast=1.4:brightness=-0.04:gamma=0.9",
81
+
82
+ 'bleach-bypass':
83
+ "eq=contrast=1.4:saturation=0.5:gamma=0.9,curves=preset=strong_contrast,colorbalance=rs=-0.02:gs=-0.02:bs=0.04",
84
+
85
+ 'cyberpunk-neon':
86
+ "eq=contrast=1.25:saturation=1.6:brightness=-0.05:gamma=0.9,colorbalance=rs=-0.1:gs=-0.15:bs=0.2:rm=0.15:gm=-0.1:bm=0.1:rh=0.2:gh=-0.05:bh=0.15,curves=r='0/0 0.3/0.2 0.6/0.7 1/1':b='0/0.05 0.4/0.5 1/1'",
87
+
88
+ 'cyberpunk-teal-pink':
89
+ "colorbalance=rs=-0.15:gs=-0.1:bs=0.25:rm=0.2:gm=-0.12:bm=0.12:rh=0.2:gh=-0.08:bh=0.2,eq=saturation=1.5:contrast=1.3:brightness=-0.06,hue=h=5",
90
+
91
+ 'desaturated-fincher':
92
+ "colorbalance=rs=-0.08:gs=-0.03:bs=0.12:rh=0.08:gh=0.02:bh=-0.1,eq=contrast=1.2:saturation=0.55:brightness=-0.03:gamma=0.92,curves=master='0/0 0.2/0.12 0.5/0.48 0.8/0.82 1/0.95'",
93
+
94
+ 'pastel-dream':
95
+ "eq=contrast=0.8:saturation=0.6:brightness=0.08:gamma=1.2,curves=master='0.0/0.15 0.5/0.55 1/0.9',colorbalance=rs=0.05:gs=0.03:bs=0.06:rh=0.04:gh=0.04:bh=0.02",
96
+
97
+ 'matrix-green':
98
+ "colorchannelmixer=rr=0.3:rg=0.6:rb=0.1:gr=0.1:gg=0.9:gb=0.0:br=0.1:bg=0.4:bb=0.5,eq=contrast=1.2:brightness=-0.03:gamma=0.9,curves=preset=increase_contrast",
99
+
100
+ 'sepia':
101
+ "colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131:0,eq=contrast=1.05:brightness=0.02",
102
+
103
+ 'blockbuster-extreme':
104
+ "colorbalance=rs=-0.2:gs=-0.1:bs=0.35:rm=0.05:gm=-0.03:bm=0.0:rh=0.2:gh=0.05:bh=-0.25,eq=contrast=1.25:saturation=1.1:gamma=0.92,curves=r='0/0 0.25/0.28 0.5/0.58 1/1':b='0/0.08 0.5/0.42 1/0.85'",
105
+
106
+ 'muted-forest':
107
+ "colorbalance=rs=0.03:gs=0.05:bs=-0.05:rm=-0.02:gm=0.04:bm=-0.03:rh=0.02:gh=0.02:bh=-0.04,eq=saturation=0.65:contrast=1.05:gamma=1.05,curves=g='0/0 0.5/0.52 1/0.92':r='0/0 0.5/0.48 1/0.95'",
108
+
109
+ 'high-contrast-music':
110
+ "eq=contrast=1.4:saturation=1.4:brightness=-0.02:gamma=0.85,curves=preset=strong_contrast",
111
+
112
+ 'faded-lofi':
113
+ "curves=master='0/0.08 0.25/0.2 0.75/0.78 1/0.92':r='0/0.05 1/0.95':b='0/0.08 1/0.88',eq=saturation=0.75:contrast=0.95",
114
+
115
+ 'sunset-magic-hour':
116
+ "colorbalance=rs=0.06:gs=-0.02:bs=0.08:rm=0.1:gm=0.04:bm=-0.05:rh=0.15:gh=0.08:bh=-0.12,eq=saturation=1.2:contrast=1.1:brightness=0.02,curves=r='0/0 0.5/0.56 1/1':b='0/0.03 0.5/0.44 1/0.88'",
117
+ };
118
+
119
+ /** Human-readable descriptions for each preset */
120
+ export const PRESET_DESCRIPTIONS: Record<LutPreset, string> = {
121
+ 'cinematic-teal-orange': 'Hollywood blockbuster look — teal shadows, orange highlights (Transformers, Mad Max)',
122
+ 'cinematic-teal-orange-subtle': 'Restrained teal-orange for drama/thriller tone',
123
+ 'vintage-film': 'Faded 70s film — lifted blacks, warm cast, slightly desaturated',
124
+ 'vintage-kodachrome': 'Iconic Kodachrome — saturated reds/yellows, slightly cool shadows',
125
+ 'cross-process': 'Vivid, surreal color shifts — the "wrong chemistry" lab look',
126
+ 'moody-dark': 'Crushed blacks, cold undertone — dark drama atmosphere',
127
+ 'warm-golden': 'Sun-kissed warmth — golden hour / magic hour look',
128
+ 'cold-blue': 'Icy blue, desaturated — arctic / winter feel',
129
+ 'film-noir': 'Classic black & white with dramatic contrast and deep blacks',
130
+ 'noir-blue-tint': 'B&W base with subtle cold blue wash',
131
+ 'bleach-bypass': 'High contrast + desaturated + metallic — analog lab technique',
132
+ 'cyberpunk-neon': 'Vivid blues, magentas, teals — oversaturated neon city',
133
+ 'cyberpunk-teal-pink': 'Teal-pink variant cyberpunk — Blade Runner vibes',
134
+ 'desaturated-fincher': 'Muted, controlled palette — David Fincher style (Gone Girl, Seven)',
135
+ 'pastel-dream': 'Soft, lifted, airy — low contrast pastel feel',
136
+ 'matrix-green': 'Green-tinted computer world from The Matrix',
137
+ 'sepia': 'Classic warm sepia tone — antique photograph look',
138
+ 'blockbuster-extreme': 'Aggressive orange & teal for action/superhero films',
139
+ 'muted-forest': 'Desaturated greens and browns — indie film / A24 aesthetic',
140
+ 'high-contrast-music': 'Punchy, vivid, crushed blacks — music video look',
141
+ 'faded-lofi': 'Instagram-style faded shadows with slight color cast',
142
+ 'sunset-magic-hour': 'Deep warm amber highlights, slightly purple shadows',
143
+ };
144
+
145
+ export const ALL_LUT_PRESETS = Object.keys(PRESET_FILTERS) as LutPreset[];
146
+
147
+ // ─── Helpers ────────────────────────────────────────────────────────
148
+
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
+ });
160
+ }
161
+
162
+ function ensureDir(filePath: string): void {
163
+ const dir = path.dirname(filePath);
164
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
165
+ }
166
+
167
+ function assertExists(filePath: string, label = 'File'): void {
168
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
169
+ }
170
+
171
+ function fileInfo(filePath: string): string {
172
+ const stats = fs.statSync(filePath);
173
+ return `${(stats.size / 1024 / 1024).toFixed(2)} MB`;
174
+ }
175
+
176
+ // ─── Main Function ──────────────────────────────────────────────────
177
+
178
+ export async function applyLutPreset(config: LutPresetConfig): Promise<string> {
179
+ const { inputPath, outputPath, preset, intensity = 1.0 } = config;
180
+
181
+ assertExists(inputPath, 'Input video');
182
+ ensureDir(outputPath);
183
+
184
+ const filterChain = PRESET_FILTERS[preset];
185
+ if (!filterChain) {
186
+ throw new Error(`Unknown LUT preset: ${preset}. Available: ${ALL_LUT_PRESETS.join(', ')}`);
187
+ }
188
+
189
+ const clampedIntensity = Math.max(0, Math.min(1, intensity));
190
+ logger.info(`Applying LUT preset: ${preset} (intensity: ${clampedIntensity})`);
191
+
192
+ let args: string[];
193
+
194
+ if (clampedIntensity >= 0.99) {
195
+ // Full intensity — no blending needed
196
+ args = [
197
+ '-y', '-i', inputPath,
198
+ '-vf', filterChain,
199
+ '-c:a', 'copy',
200
+ '-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
201
+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
202
+ outputPath,
203
+ ];
204
+ } else {
205
+ // Partial intensity — blend with original using split+blend
206
+ const origWeight = (1 - clampedIntensity).toFixed(4);
207
+ const gradedWeight = clampedIntensity.toFixed(4);
208
+ const filterComplex = [
209
+ `[0:v]split[original][tograde]`,
210
+ `[tograde]${filterChain}[graded]`,
211
+ `[original][graded]blend=all_expr='A*${origWeight}+B*${gradedWeight}'[out]`,
212
+ ].join(';');
213
+
214
+ args = [
215
+ '-y', '-i', inputPath,
216
+ '-filter_complex', filterComplex,
217
+ '-map', '[out]', '-map', '0:a?',
218
+ '-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
219
+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
220
+ outputPath,
221
+ ];
222
+ }
223
+
224
+ await runFfmpeg(args);
225
+ logger.info(`LUT preset applied: ${preset} → ${outputPath} (${fileInfo(outputPath)})`);
226
+ return outputPath;
227
+ }
228
+
229
+ /** List all available presets with descriptions */
230
+ export function listLutPresets(): Array<{ name: LutPreset; description: string }> {
231
+ return ALL_LUT_PRESETS.map(name => ({
232
+ name,
233
+ description: PRESET_DESCRIPTIONS[name],
234
+ }));
235
+ }