@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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social media format converter — crop, scale, pad for every platform
|
|
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
|
+
|
|
10
|
+
// ─── Format Definitions ─────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const SOCIAL_FORMATS = {
|
|
13
|
+
'instagram-reel': { width: 1080, height: 1920, maxDuration: 90, label: 'Instagram Reels (9:16)' },
|
|
14
|
+
'instagram-feed': { width: 1080, height: 1080, maxDuration: 60, label: 'Instagram Feed (1:1)' },
|
|
15
|
+
'instagram-story': { width: 1080, height: 1920, maxDuration: 60, label: 'Instagram Story (9:16)' },
|
|
16
|
+
'youtube': { width: 1920, height: 1080, maxDuration: 0, label: 'YouTube (16:9)' },
|
|
17
|
+
'youtube-short': { width: 1080, height: 1920, maxDuration: 60, label: 'YouTube Shorts (9:16)' },
|
|
18
|
+
'tiktok': { width: 1080, height: 1920, maxDuration: 600, label: 'TikTok (9:16)' },
|
|
19
|
+
'linkedin-landscape': { width: 1920, height: 1080, maxDuration: 600, label: 'LinkedIn (16:9)' },
|
|
20
|
+
'linkedin-square': { width: 1080, height: 1080, maxDuration: 600, label: 'LinkedIn (1:1)' },
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type SocialFormat = keyof typeof SOCIAL_FORMATS;
|
|
24
|
+
|
|
25
|
+
export type CropStrategy = 'crop' | 'pad' | 'blur-background';
|
|
26
|
+
|
|
27
|
+
export interface FormatConvertConfig {
|
|
28
|
+
inputPath: string;
|
|
29
|
+
outputPath: string;
|
|
30
|
+
format: SocialFormat;
|
|
31
|
+
/** How to handle aspect ratio mismatch (default: blur-background) */
|
|
32
|
+
strategy?: CropStrategy;
|
|
33
|
+
/** Override max duration (seconds) */
|
|
34
|
+
maxDuration?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Single Format Conversion ───────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export async function convertToSocialFormat(config: FormatConvertConfig): Promise<string> {
|
|
40
|
+
const {
|
|
41
|
+
inputPath,
|
|
42
|
+
outputPath,
|
|
43
|
+
format,
|
|
44
|
+
strategy = 'blur-background',
|
|
45
|
+
maxDuration,
|
|
46
|
+
} = config;
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(inputPath)) throw new Error(`Input not found: ${inputPath}`);
|
|
49
|
+
|
|
50
|
+
const spec = SOCIAL_FORMATS[format];
|
|
51
|
+
const { width, height } = spec;
|
|
52
|
+
const durLimit = maxDuration ?? (spec.maxDuration > 0 ? spec.maxDuration : undefined);
|
|
53
|
+
|
|
54
|
+
logger.info(`Converting to ${spec.label} (${strategy})`);
|
|
55
|
+
|
|
56
|
+
const outDir = path.dirname(outputPath);
|
|
57
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
58
|
+
|
|
59
|
+
const durArgs = durLimit ? ['-t', String(durLimit)] : [];
|
|
60
|
+
|
|
61
|
+
if (strategy === 'blur-background') {
|
|
62
|
+
// Blurred version as background + sharp foreground centered
|
|
63
|
+
const filterComplex = [
|
|
64
|
+
`[0:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},boxblur=20:20[bg]`,
|
|
65
|
+
`[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease[fg]`,
|
|
66
|
+
`[bg][fg]overlay=(W-w)/2:(H-h)/2[out]`,
|
|
67
|
+
].join(';');
|
|
68
|
+
|
|
69
|
+
const args = [
|
|
70
|
+
'-y', '-i', inputPath,
|
|
71
|
+
'-filter_complex', filterComplex,
|
|
72
|
+
'-map', '[out]', '-map', '0:a?',
|
|
73
|
+
'-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
|
|
74
|
+
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k',
|
|
75
|
+
...durArgs,
|
|
76
|
+
'-movflags', '+faststart',
|
|
77
|
+
outputPath,
|
|
78
|
+
];
|
|
79
|
+
await runFfmpeg(args);
|
|
80
|
+
} else if (strategy === 'crop') {
|
|
81
|
+
const vf = `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
|
|
82
|
+
const args = [
|
|
83
|
+
'-y', '-i', inputPath,
|
|
84
|
+
'-vf', vf,
|
|
85
|
+
'-c:v', 'libx264', '-crf', '18', '-pix_fmt', 'yuv420p',
|
|
86
|
+
'-c:a', 'aac', '-b:a', '192k',
|
|
87
|
+
...durArgs,
|
|
88
|
+
'-movflags', '+faststart',
|
|
89
|
+
outputPath,
|
|
90
|
+
];
|
|
91
|
+
await runFfmpeg(args);
|
|
92
|
+
} else {
|
|
93
|
+
// pad — letterbox/pillarbox with black
|
|
94
|
+
const vf = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black`;
|
|
95
|
+
const args = [
|
|
96
|
+
'-y', '-i', inputPath,
|
|
97
|
+
'-vf', vf,
|
|
98
|
+
'-c:v', 'libx264', '-crf', '18', '-pix_fmt', 'yuv420p',
|
|
99
|
+
'-c:a', 'aac', '-b:a', '192k',
|
|
100
|
+
...durArgs,
|
|
101
|
+
'-movflags', '+faststart',
|
|
102
|
+
outputPath,
|
|
103
|
+
];
|
|
104
|
+
await runFfmpeg(args);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stats = fs.statSync(outputPath);
|
|
108
|
+
logger.info(`Converted: ${outputPath} (${spec.label}, ${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
|
109
|
+
return outputPath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Batch Conversion ───────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export async function convertToAllFormats(
|
|
115
|
+
inputPath: string,
|
|
116
|
+
outputDir: string,
|
|
117
|
+
formats: SocialFormat[] = ['instagram-reel', 'instagram-feed', 'youtube', 'tiktok'],
|
|
118
|
+
strategy: CropStrategy = 'blur-background'
|
|
119
|
+
): Promise<Record<string, string>> {
|
|
120
|
+
const baseName = path.basename(inputPath, path.extname(inputPath));
|
|
121
|
+
const results: Record<string, string> = {};
|
|
122
|
+
|
|
123
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
124
|
+
|
|
125
|
+
for (const format of formats) {
|
|
126
|
+
const outputPath = path.join(outputDir, `${baseName}-${format}.mp4`);
|
|
127
|
+
results[format] = await convertToSocialFormat({ inputPath, outputPath, format, strategy });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Helper ─────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function runFfmpeg(args: string[]): Promise<string> {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
138
|
+
if (error) {
|
|
139
|
+
logger.error(`ffmpeg failed: ${stderr}`);
|
|
140
|
+
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
resolve(stdout);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Renderer — Renders video templates with user-provided assets.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: Validate assets → Trim clips → Apply color grade → Add text animations
|
|
5
|
+
* → Concatenate with transitions → Add music → Export (optional social formats)
|
|
6
|
+
*
|
|
7
|
+
* Uses ALL existing engines: editing, lut-presets, text-animations,
|
|
8
|
+
* concat, audio, social-format.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'child_process';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { logger } from '../../lib/logger.js';
|
|
15
|
+
import { getTemplate } from './templates.js';
|
|
16
|
+
import type { VideoTemplate, TemplateSlot } from './templates.js';
|
|
17
|
+
|
|
18
|
+
// ─── Types ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface TemplateAssets {
|
|
21
|
+
/** Map of slot name → file path */
|
|
22
|
+
clips: Record<string, string>;
|
|
23
|
+
/** Map of placeholder name → custom text (overrides defaults) */
|
|
24
|
+
texts?: Record<string, string>;
|
|
25
|
+
/** Background music file path (optional) */
|
|
26
|
+
musicPath?: string;
|
|
27
|
+
/** Music volume: 0.0-1.0. Default: 0.3 */
|
|
28
|
+
musicVolume?: number;
|
|
29
|
+
/** Logo/watermark image path (optional) */
|
|
30
|
+
logoPath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RenderTemplateConfig {
|
|
34
|
+
/** Template ID */
|
|
35
|
+
templateId: string;
|
|
36
|
+
/** User-provided assets */
|
|
37
|
+
assets: TemplateAssets;
|
|
38
|
+
outputPath: string;
|
|
39
|
+
/** Override color grade preset. Omit to use template default. */
|
|
40
|
+
colorGrade?: string;
|
|
41
|
+
/** Also export social format variants. Default: false */
|
|
42
|
+
socialFormats?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RenderResult {
|
|
46
|
+
outputPath: string;
|
|
47
|
+
template: string;
|
|
48
|
+
duration: number;
|
|
49
|
+
resolution: { width: number; height: number };
|
|
50
|
+
clipsUsed: number;
|
|
51
|
+
textsApplied: number;
|
|
52
|
+
socialVariants?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function runFfmpeg(args: string[], timeoutMs = 600_000): Promise<string> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
60
|
+
if (error) {
|
|
61
|
+
logger.error(`ffmpeg failed: ${stderr}`);
|
|
62
|
+
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
resolve(stdout);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureDir(filePath: string): void {
|
|
71
|
+
const dir = path.dirname(filePath);
|
|
72
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertExists(filePath: string, label = 'File'): void {
|
|
76
|
+
if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fileInfo(filePath: string): string {
|
|
80
|
+
const stats = fs.statSync(filePath);
|
|
81
|
+
return `${(stats.size / 1024 / 1024).toFixed(2)} MB`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isImageFile(filePath: string): boolean {
|
|
85
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
86
|
+
return ['.png', '.jpg', '.jpeg', '.webp', '.bmp'].includes(ext);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Escape text for FFmpeg drawtext */
|
|
90
|
+
function escapeDrawtext(text: string): string {
|
|
91
|
+
return text
|
|
92
|
+
.replace(/\\/g, '\\\\\\\\')
|
|
93
|
+
.replace(/'/g, "'\\\\\\''")
|
|
94
|
+
.replace(/:/g, '\\:')
|
|
95
|
+
.replace(/%/g, '%%');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Main Render Function ───────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export async function renderTemplate(config: RenderTemplateConfig): Promise<RenderResult> {
|
|
101
|
+
const { templateId, assets, outputPath, colorGrade, socialFormats = false } = config;
|
|
102
|
+
|
|
103
|
+
// Step 1: Get template
|
|
104
|
+
const template = getTemplate(templateId);
|
|
105
|
+
if (!template) {
|
|
106
|
+
const available = ['social-reel-hype', 'social-reel-aesthetic', 'product-demo-saas', 'testimonial-single', 'before-after-split', 'slideshow-photo', 'tutorial-howto', 'announcement-launch', 'promo-sale'];
|
|
107
|
+
throw new Error(`Unknown template: ${templateId}. Available: ${available.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Step 2: Validate required assets
|
|
111
|
+
const requiredSlots = template.slots.filter(s => s.required);
|
|
112
|
+
for (const slot of requiredSlots) {
|
|
113
|
+
if (!assets.clips[slot.name]) {
|
|
114
|
+
throw new Error(`Missing required clip for slot "${slot.name}": ${slot.description}`);
|
|
115
|
+
}
|
|
116
|
+
assertExists(assets.clips[slot.name], `Clip for "${slot.name}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
ensureDir(outputPath);
|
|
120
|
+
const tempDir = `/tmp/template-render-${Date.now()}`;
|
|
121
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
122
|
+
|
|
123
|
+
logger.info(`Rendering template: ${template.name} (${template.id})`);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Step 3: Prepare each slot — trim to duration, scale to target resolution
|
|
127
|
+
const segmentPaths: string[] = [];
|
|
128
|
+
let timeOffset = 0;
|
|
129
|
+
const usedSlots: TemplateSlot[] = [];
|
|
130
|
+
|
|
131
|
+
for (const slot of template.slots) {
|
|
132
|
+
const clipPath = assets.clips[slot.name];
|
|
133
|
+
if (!clipPath) continue; // Skip optional empty slots
|
|
134
|
+
|
|
135
|
+
assertExists(clipPath, `Clip for "${slot.name}"`);
|
|
136
|
+
usedSlots.push(slot);
|
|
137
|
+
|
|
138
|
+
const segPath = path.join(tempDir, `seg-${segmentPaths.length}.mp4`);
|
|
139
|
+
const { width, height } = template.resolution;
|
|
140
|
+
|
|
141
|
+
if (isImageFile(clipPath)) {
|
|
142
|
+
// Image → video (still frame for slot duration)
|
|
143
|
+
await runFfmpeg([
|
|
144
|
+
'-y', '-loop', '1', '-i', clipPath,
|
|
145
|
+
'-t', String(slot.duration),
|
|
146
|
+
'-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`,
|
|
147
|
+
'-c:v', 'libx264', '-crf', '18', '-preset', 'fast',
|
|
148
|
+
'-pix_fmt', 'yuv420p', '-r', '30',
|
|
149
|
+
segPath,
|
|
150
|
+
]);
|
|
151
|
+
} else {
|
|
152
|
+
// Video → trim + scale
|
|
153
|
+
await runFfmpeg([
|
|
154
|
+
'-y', '-i', clipPath,
|
|
155
|
+
'-t', String(slot.duration),
|
|
156
|
+
'-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`,
|
|
157
|
+
'-c:v', 'libx264', '-crf', '18', '-preset', 'fast',
|
|
158
|
+
'-pix_fmt', 'yuv420p', '-an', '-r', '30',
|
|
159
|
+
segPath,
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
segmentPaths.push(segPath);
|
|
164
|
+
timeOffset += slot.duration;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (segmentPaths.length === 0) {
|
|
168
|
+
throw new Error('No clips provided for any template slot');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Step 4: Concatenate all segments
|
|
172
|
+
const concatFile = path.join(tempDir, 'concat.txt');
|
|
173
|
+
fs.writeFileSync(concatFile, segmentPaths.map(p => `file '${p}'`).join('\n'));
|
|
174
|
+
|
|
175
|
+
const concatOutput = path.join(tempDir, 'concatenated.mp4');
|
|
176
|
+
await runFfmpeg([
|
|
177
|
+
'-y', '-f', 'concat', '-safe', '0', '-i', concatFile,
|
|
178
|
+
'-c', 'copy', concatOutput,
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
// Step 5: Apply color grade
|
|
182
|
+
let gradedOutput = concatOutput;
|
|
183
|
+
const gradePreset = colorGrade ?? template.colorGrade;
|
|
184
|
+
if (gradePreset) {
|
|
185
|
+
gradedOutput = path.join(tempDir, 'graded.mp4');
|
|
186
|
+
try {
|
|
187
|
+
// Import dynamically to avoid circular deps — use FFmpeg directly
|
|
188
|
+
const { applyLutPreset } = await import('./lut-presets.js');
|
|
189
|
+
await applyLutPreset({
|
|
190
|
+
inputPath: concatOutput,
|
|
191
|
+
outputPath: gradedOutput,
|
|
192
|
+
preset: gradePreset as Parameters<typeof applyLutPreset>[0]['preset'],
|
|
193
|
+
intensity: 0.8, // 80% intensity for templates — not too heavy
|
|
194
|
+
});
|
|
195
|
+
} catch (gradeError) {
|
|
196
|
+
logger.warn(`Color grade failed, using ungraded: ${gradeError}`);
|
|
197
|
+
gradedOutput = concatOutput;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Step 6: Apply text animations
|
|
202
|
+
let textOutput = gradedOutput;
|
|
203
|
+
let textsApplied = 0;
|
|
204
|
+
|
|
205
|
+
for (const placeholder of template.textPlaceholders) {
|
|
206
|
+
const customText = assets.texts?.[placeholder.name] ?? placeholder.defaultText;
|
|
207
|
+
const nextOutput = path.join(tempDir, `text-${textsApplied}.mp4`);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const { animateText } = await import('./text-animations.js');
|
|
211
|
+
await animateText({
|
|
212
|
+
inputPath: textOutput,
|
|
213
|
+
outputPath: nextOutput,
|
|
214
|
+
text: customText,
|
|
215
|
+
animation: placeholder.animation as Parameters<typeof animateText>[0]['animation'],
|
|
216
|
+
startTime: placeholder.startTime,
|
|
217
|
+
duration: placeholder.duration,
|
|
218
|
+
fontSize: placeholder.fontSize,
|
|
219
|
+
position: placeholder.position as Parameters<typeof animateText>[0]['position'],
|
|
220
|
+
});
|
|
221
|
+
textOutput = nextOutput;
|
|
222
|
+
textsApplied++;
|
|
223
|
+
} catch (textError) {
|
|
224
|
+
logger.warn(`Text animation "${placeholder.name}" failed: ${textError}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Step 7: Add music if provided
|
|
229
|
+
let finalOutput = textOutput;
|
|
230
|
+
if (assets.musicPath) {
|
|
231
|
+
assertExists(assets.musicPath, 'Music file');
|
|
232
|
+
finalOutput = path.join(tempDir, 'with-music.mp4');
|
|
233
|
+
const musicVol = (assets.musicVolume ?? 0.3).toFixed(2);
|
|
234
|
+
|
|
235
|
+
await runFfmpeg([
|
|
236
|
+
'-y',
|
|
237
|
+
'-i', textOutput,
|
|
238
|
+
'-i', assets.musicPath,
|
|
239
|
+
'-filter_complex', `[1:a]volume=${musicVol}[music];[music]afade=t=out:st=${Math.max(0, timeOffset - 2)}:d=2[musicfade]`,
|
|
240
|
+
'-map', '0:v', '-map', '[musicfade]',
|
|
241
|
+
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
|
|
242
|
+
'-t', String(timeOffset),
|
|
243
|
+
'-movflags', '+faststart',
|
|
244
|
+
'-shortest',
|
|
245
|
+
finalOutput,
|
|
246
|
+
]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Step 8: Copy to output path
|
|
250
|
+
if (finalOutput !== outputPath) {
|
|
251
|
+
fs.copyFileSync(finalOutput, outputPath);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
logger.info(`Template rendered: ${template.name} → ${outputPath} (${fileInfo(outputPath)})`);
|
|
255
|
+
|
|
256
|
+
// Step 9: Social format variants (optional)
|
|
257
|
+
let socialVariants: string[] | undefined;
|
|
258
|
+
if (socialFormats) {
|
|
259
|
+
socialVariants = [];
|
|
260
|
+
const outputDir = path.dirname(outputPath);
|
|
261
|
+
const baseName = path.basename(outputPath, path.extname(outputPath));
|
|
262
|
+
|
|
263
|
+
const formats = ['instagram-reel', 'tiktok', 'youtube-short'] as const;
|
|
264
|
+
for (const fmt of formats) {
|
|
265
|
+
try {
|
|
266
|
+
const { convertToSocialFormat } = await import('./social-format.js');
|
|
267
|
+
const variantPath = path.join(outputDir, `${baseName}-${fmt}.mp4`);
|
|
268
|
+
await convertToSocialFormat({
|
|
269
|
+
inputPath: outputPath,
|
|
270
|
+
outputPath: variantPath,
|
|
271
|
+
format: fmt as Parameters<typeof convertToSocialFormat>[0]['format'],
|
|
272
|
+
});
|
|
273
|
+
socialVariants.push(variantPath);
|
|
274
|
+
} catch (fmtError) {
|
|
275
|
+
logger.warn(`Social format ${fmt} failed: ${fmtError}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
outputPath,
|
|
282
|
+
template: template.id,
|
|
283
|
+
duration: timeOffset,
|
|
284
|
+
resolution: template.resolution,
|
|
285
|
+
clipsUsed: usedSlots.length,
|
|
286
|
+
textsApplied,
|
|
287
|
+
socialVariants,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
} finally {
|
|
291
|
+
// Cleanup temp files
|
|
292
|
+
try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ }
|
|
293
|
+
}
|
|
294
|
+
}
|