@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- package/.github/workflows/ci.yml +34 -0
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/USAGE.md +144 -0
- package/dist/handlers/capcut.d.ts +6 -0
- package/dist/handlers/capcut.js +229 -0
- package/dist/handlers/capcut.js.map +1 -0
- package/dist/handlers/editing.d.ts +6 -0
- package/dist/handlers/editing.js +242 -0
- package/dist/handlers/editing.js.map +1 -0
- package/dist/handlers/index.d.ts +2 -0
- package/dist/handlers/index.js +33 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/post-production.d.ts +5 -0
- package/dist/handlers/post-production.js +109 -0
- package/dist/handlers/post-production.js.map +1 -0
- package/dist/handlers/smart-screenshot.d.ts +5 -0
- package/dist/handlers/smart-screenshot.js +83 -0
- package/dist/handlers/smart-screenshot.js.map +1 -0
- package/dist/handlers/tts.d.ts +5 -0
- package/dist/handlers/tts.js +83 -0
- package/dist/handlers/tts.js.map +1 -0
- package/dist/handlers/video.d.ts +5 -0
- package/dist/handlers/video.js +127 -0
- package/dist/handlers/video.js.map +1 -0
- package/dist/lib/dual-transport.d.ts +42 -0
- package/dist/lib/dual-transport.js +208 -0
- package/dist/lib/dual-transport.js.map +1 -0
- package/dist/lib/logger.d.ts +12 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/types.js +15 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/schemas/capcut.d.ts +608 -0
- package/dist/schemas/capcut.js +411 -0
- package/dist/schemas/capcut.js.map +1 -0
- package/dist/schemas/editing.d.ts +822 -0
- package/dist/schemas/editing.js +466 -0
- package/dist/schemas/editing.js.map +1 -0
- package/dist/schemas/index.d.ts +2366 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/post-production.d.ts +379 -0
- package/dist/schemas/post-production.js +268 -0
- package/dist/schemas/post-production.js.map +1 -0
- package/dist/schemas/smart-screenshot.d.ts +127 -0
- package/dist/schemas/smart-screenshot.js +122 -0
- package/dist/schemas/smart-screenshot.js.map +1 -0
- package/dist/schemas/tts.d.ts +220 -0
- package/dist/schemas/tts.js +194 -0
- package/dist/schemas/tts.js.map +1 -0
- package/dist/schemas/video.d.ts +236 -0
- package/dist/schemas/video.js +210 -0
- package/dist/schemas/video.js.map +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +239 -0
- package/dist/server.js.map +1 -0
- package/dist/server.test.d.ts +1 -0
- package/dist/server.test.js +87 -0
- package/dist/server.test.js.map +1 -0
- package/dist/tools/engine/audio-mixer.d.ts +40 -0
- package/dist/tools/engine/audio-mixer.js +169 -0
- package/dist/tools/engine/audio-mixer.js.map +1 -0
- package/dist/tools/engine/audio.d.ts +22 -0
- package/dist/tools/engine/audio.js +73 -0
- package/dist/tools/engine/audio.js.map +1 -0
- package/dist/tools/engine/beat-sync.d.ts +31 -0
- package/dist/tools/engine/beat-sync.js +270 -0
- package/dist/tools/engine/beat-sync.js.map +1 -0
- package/dist/tools/engine/capture.d.ts +12 -0
- package/dist/tools/engine/capture.js +290 -0
- package/dist/tools/engine/capture.js.map +1 -0
- package/dist/tools/engine/chroma-key.d.ts +27 -0
- package/dist/tools/engine/chroma-key.js +154 -0
- package/dist/tools/engine/chroma-key.js.map +1 -0
- package/dist/tools/engine/concat.d.ts +49 -0
- package/dist/tools/engine/concat.js +149 -0
- package/dist/tools/engine/concat.js.map +1 -0
- package/dist/tools/engine/cursor.d.ts +26 -0
- package/dist/tools/engine/cursor.js +185 -0
- package/dist/tools/engine/cursor.js.map +1 -0
- package/dist/tools/engine/easing.d.ts +15 -0
- package/dist/tools/engine/easing.js +100 -0
- package/dist/tools/engine/easing.js.map +1 -0
- package/dist/tools/engine/editing.d.ts +158 -0
- package/dist/tools/engine/editing.js +541 -0
- package/dist/tools/engine/editing.js.map +1 -0
- package/dist/tools/engine/encoder.d.ts +31 -0
- package/dist/tools/engine/encoder.js +154 -0
- package/dist/tools/engine/encoder.js.map +1 -0
- package/dist/tools/engine/index.d.ts +30 -0
- package/dist/tools/engine/index.js +23 -0
- package/dist/tools/engine/index.js.map +1 -0
- package/dist/tools/engine/lut-presets.d.ts +25 -0
- package/dist/tools/engine/lut-presets.js +141 -0
- package/dist/tools/engine/lut-presets.js.map +1 -0
- package/dist/tools/engine/narrated-video.d.ts +63 -0
- package/dist/tools/engine/narrated-video.js +163 -0
- package/dist/tools/engine/narrated-video.js.map +1 -0
- package/dist/tools/engine/scenes.d.ts +17 -0
- package/dist/tools/engine/scenes.js +223 -0
- package/dist/tools/engine/scenes.js.map +1 -0
- package/dist/tools/engine/smart-screenshot.d.ts +80 -0
- package/dist/tools/engine/smart-screenshot.js +744 -0
- package/dist/tools/engine/smart-screenshot.js.map +1 -0
- package/dist/tools/engine/social-format.d.ts +66 -0
- package/dist/tools/engine/social-format.js +107 -0
- package/dist/tools/engine/social-format.js.map +1 -0
- package/dist/tools/engine/template-renderer.d.ts +45 -0
- package/dist/tools/engine/template-renderer.js +233 -0
- package/dist/tools/engine/template-renderer.js.map +1 -0
- package/dist/tools/engine/templates.d.ts +87 -0
- package/dist/tools/engine/templates.js +272 -0
- package/dist/tools/engine/templates.js.map +1 -0
- package/dist/tools/engine/text-animations.d.ts +33 -0
- package/dist/tools/engine/text-animations.js +192 -0
- package/dist/tools/engine/text-animations.js.map +1 -0
- package/dist/tools/engine/text-overlay.d.ts +27 -0
- package/dist/tools/engine/text-overlay.js +84 -0
- package/dist/tools/engine/text-overlay.js.map +1 -0
- package/dist/tools/engine/tts.d.ts +54 -0
- package/dist/tools/engine/tts.js +186 -0
- package/dist/tools/engine/tts.js.map +1 -0
- package/dist/tools/engine/types.d.ts +166 -0
- package/dist/tools/engine/types.js +13 -0
- package/dist/tools/engine/types.js.map +1 -0
- package/dist/tools/engine/voice-effects.d.ts +18 -0
- package/dist/tools/engine/voice-effects.js +215 -0
- package/dist/tools/engine/voice-effects.js.map +1 -0
- package/dist/tools/index.d.ts +32 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/package.json +56 -0
- package/scripts/check-deps.js +39 -0
- package/src/handlers/capcut.ts +245 -0
- package/src/handlers/editing.ts +260 -0
- package/src/handlers/index.ts +34 -0
- package/src/handlers/post-production.ts +136 -0
- package/src/handlers/smart-screenshot.ts +86 -0
- package/src/handlers/tts.ts +103 -0
- package/src/handlers/video.ts +137 -0
- package/src/lib/dual-transport.ts +272 -0
- package/src/lib/logger.ts +59 -0
- package/src/lib/types.ts +25 -0
- package/src/schemas/capcut.ts +418 -0
- package/src/schemas/editing.ts +476 -0
- package/src/schemas/index.ts +15 -0
- package/src/schemas/post-production.ts +273 -0
- package/src/schemas/smart-screenshot.ts +122 -0
- package/src/schemas/tts.ts +197 -0
- package/src/schemas/video.ts +211 -0
- package/src/server.test.ts +99 -0
- package/src/server.ts +289 -0
- package/src/tools/engine/audio-mixer.ts +244 -0
- package/src/tools/engine/audio.ts +115 -0
- package/src/tools/engine/beat-sync.ts +356 -0
- package/src/tools/engine/capture.ts +360 -0
- package/src/tools/engine/chroma-key.ts +202 -0
- package/src/tools/engine/concat.ts +242 -0
- package/src/tools/engine/cursor.ts +222 -0
- package/src/tools/engine/easing.ts +120 -0
- package/src/tools/engine/editing.ts +809 -0
- package/src/tools/engine/encoder.ts +208 -0
- package/src/tools/engine/index.ts +33 -0
- package/src/tools/engine/lut-presets.ts +235 -0
- package/src/tools/engine/narrated-video.ts +267 -0
- package/src/tools/engine/scenes.ts +309 -0
- package/src/tools/engine/smart-screenshot.ts +923 -0
- package/src/tools/engine/social-format.ts +146 -0
- package/src/tools/engine/template-renderer.ts +294 -0
- package/src/tools/engine/templates.ts +370 -0
- package/src/tools/engine/text-animations.ts +282 -0
- package/src/tools/engine/text-overlay.ts +143 -0
- package/src/tools/engine/tts.ts +284 -0
- package/src/tools/engine/types.ts +191 -0
- package/src/tools/engine/voice-effects.ts +258 -0
- package/src/tools/index.ts +67 -0
- package/tsconfig.json +19 -0
- 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
|
+
}
|