@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,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Template Engine — Pre-built video templates for common use cases.
|
|
3
|
+
*
|
|
4
|
+
* Templates define: clip slots, timing, transitions, text placeholders,
|
|
5
|
+
* effects, color grades, and music style.
|
|
6
|
+
*
|
|
7
|
+
* The renderer (template-renderer.ts) takes a template + user assets
|
|
8
|
+
* and produces a finished video using all existing engines.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from '../../lib/logger.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type TemplateCategory =
|
|
16
|
+
| 'social-reel'
|
|
17
|
+
| 'product-demo'
|
|
18
|
+
| 'testimonial'
|
|
19
|
+
| 'before-after'
|
|
20
|
+
| 'slideshow'
|
|
21
|
+
| 'tutorial'
|
|
22
|
+
| 'announcement'
|
|
23
|
+
| 'promo';
|
|
24
|
+
|
|
25
|
+
export interface TemplateSlot {
|
|
26
|
+
/** Slot name (e.g., 'intro-clip', 'product-shot-1') */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Duration of this slot in seconds */
|
|
29
|
+
duration: number;
|
|
30
|
+
/** Whether this slot is required */
|
|
31
|
+
required: boolean;
|
|
32
|
+
/** Description of what should go in this slot */
|
|
33
|
+
description: string;
|
|
34
|
+
/** Slot type */
|
|
35
|
+
type: 'video' | 'image' | 'text';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TemplateTextPlaceholder {
|
|
39
|
+
/** Placeholder name (e.g., 'title', 'subtitle', 'cta') */
|
|
40
|
+
name: string;
|
|
41
|
+
/** Default text */
|
|
42
|
+
defaultText: string;
|
|
43
|
+
/** Text animation to use */
|
|
44
|
+
animation: string;
|
|
45
|
+
/** When to show (seconds from start) */
|
|
46
|
+
startTime: number;
|
|
47
|
+
/** How long to show (seconds) */
|
|
48
|
+
duration: number;
|
|
49
|
+
/** Position on screen */
|
|
50
|
+
position: string;
|
|
51
|
+
/** Font size */
|
|
52
|
+
fontSize: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface VideoTemplate {
|
|
56
|
+
/** Unique template ID */
|
|
57
|
+
id: string;
|
|
58
|
+
/** Human-readable name */
|
|
59
|
+
name: string;
|
|
60
|
+
/** Category for filtering */
|
|
61
|
+
category: TemplateCategory;
|
|
62
|
+
/** Description of the template */
|
|
63
|
+
description: string;
|
|
64
|
+
/** Total duration in seconds */
|
|
65
|
+
totalDuration: number;
|
|
66
|
+
/** Aspect ratio */
|
|
67
|
+
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5';
|
|
68
|
+
/** Resolution */
|
|
69
|
+
resolution: { width: number; height: number };
|
|
70
|
+
/** Clip/asset slots */
|
|
71
|
+
slots: TemplateSlot[];
|
|
72
|
+
/** Text placeholders */
|
|
73
|
+
textPlaceholders: TemplateTextPlaceholder[];
|
|
74
|
+
/** Transition between clips */
|
|
75
|
+
transition: string;
|
|
76
|
+
/** Recommended color grade preset */
|
|
77
|
+
colorGrade?: string;
|
|
78
|
+
/** Recommended music style */
|
|
79
|
+
musicStyle: string;
|
|
80
|
+
/** Tags for search */
|
|
81
|
+
tags: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Template Definitions ───────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const TEMPLATES: VideoTemplate[] = [
|
|
87
|
+
// ─── Social Reel ─────────────────────────────────────────────
|
|
88
|
+
{
|
|
89
|
+
id: 'social-reel-hype',
|
|
90
|
+
name: 'Hype Reel',
|
|
91
|
+
category: 'social-reel',
|
|
92
|
+
description: 'Fast-paced 15-second reel with 5 quick cuts, beat-synced feel, bold text overlays. Perfect for Instagram/TikTok.',
|
|
93
|
+
totalDuration: 15,
|
|
94
|
+
aspectRatio: '9:16',
|
|
95
|
+
resolution: { width: 1080, height: 1920 },
|
|
96
|
+
slots: [
|
|
97
|
+
{ name: 'hook-clip', duration: 3, required: true, description: 'Opening hook — attention-grabbing first 3 seconds', type: 'video' },
|
|
98
|
+
{ name: 'clip-2', duration: 3, required: true, description: 'Second clip — show the product/action', type: 'video' },
|
|
99
|
+
{ name: 'clip-3', duration: 3, required: true, description: 'Third clip — build momentum', type: 'video' },
|
|
100
|
+
{ name: 'clip-4', duration: 3, required: false, description: 'Fourth clip — variety shot', type: 'video' },
|
|
101
|
+
{ name: 'cta-clip', duration: 3, required: true, description: 'Closing CTA clip', type: 'video' },
|
|
102
|
+
],
|
|
103
|
+
textPlaceholders: [
|
|
104
|
+
{ name: 'hook-text', defaultText: 'WATCH THIS', animation: 'pop', startTime: 0.2, duration: 2.5, position: 'center', fontSize: 64 },
|
|
105
|
+
{ name: 'cta-text', defaultText: 'FOLLOW FOR MORE', animation: 'slide-up', startTime: 12, duration: 3, position: 'bottom', fontSize: 48 },
|
|
106
|
+
],
|
|
107
|
+
transition: 'fade',
|
|
108
|
+
colorGrade: 'high-contrast-music',
|
|
109
|
+
musicStyle: 'upbeat, energetic, trending audio',
|
|
110
|
+
tags: ['reel', 'tiktok', 'instagram', 'fast', 'hype', 'trending'],
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
id: 'social-reel-aesthetic',
|
|
115
|
+
name: 'Aesthetic Reel',
|
|
116
|
+
category: 'social-reel',
|
|
117
|
+
description: 'Slow, cinematic 30-second reel with smooth transitions and warm color grading. Great for lifestyle/travel content.',
|
|
118
|
+
totalDuration: 30,
|
|
119
|
+
aspectRatio: '9:16',
|
|
120
|
+
resolution: { width: 1080, height: 1920 },
|
|
121
|
+
slots: [
|
|
122
|
+
{ name: 'opening', duration: 5, required: true, description: 'Slow opening shot — set the mood', type: 'video' },
|
|
123
|
+
{ name: 'scene-2', duration: 5, required: true, description: 'Second scene — establish context', type: 'video' },
|
|
124
|
+
{ name: 'scene-3', duration: 5, required: true, description: 'Third scene — main content', type: 'video' },
|
|
125
|
+
{ name: 'scene-4', duration: 5, required: false, description: 'Fourth scene — detail shot', type: 'video' },
|
|
126
|
+
{ name: 'scene-5', duration: 5, required: false, description: 'Fifth scene — variety', type: 'video' },
|
|
127
|
+
{ name: 'closing', duration: 5, required: true, description: 'Closing scene — satisfying end', type: 'video' },
|
|
128
|
+
],
|
|
129
|
+
textPlaceholders: [
|
|
130
|
+
{ name: 'title', defaultText: 'Golden Hour', animation: 'fade-in', startTime: 1, duration: 4, position: 'center', fontSize: 56 },
|
|
131
|
+
{ name: 'location', defaultText: 'Somewhere Beautiful', animation: 'fade-in-out', startTime: 6, duration: 4, position: 'bottom', fontSize: 36 },
|
|
132
|
+
],
|
|
133
|
+
transition: 'crossfade',
|
|
134
|
+
colorGrade: 'warm-golden',
|
|
135
|
+
musicStyle: 'ambient, lo-fi, chill',
|
|
136
|
+
tags: ['aesthetic', 'cinematic', 'lifestyle', 'travel', 'slow'],
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// ─── Product Demo ────────────────────────────────────────────
|
|
140
|
+
{
|
|
141
|
+
id: 'product-demo-saas',
|
|
142
|
+
name: 'SaaS Product Demo',
|
|
143
|
+
category: 'product-demo',
|
|
144
|
+
description: '60-second product demo: problem → solution → features → CTA. Clean look with screen recordings.',
|
|
145
|
+
totalDuration: 60,
|
|
146
|
+
aspectRatio: '16:9',
|
|
147
|
+
resolution: { width: 1920, height: 1080 },
|
|
148
|
+
slots: [
|
|
149
|
+
{ name: 'problem', duration: 10, required: true, description: 'Show the problem your product solves', type: 'video' },
|
|
150
|
+
{ name: 'intro-screen', duration: 5, required: true, description: 'Product name / logo reveal', type: 'video' },
|
|
151
|
+
{ name: 'feature-1', duration: 12, required: true, description: 'Screen recording of feature 1', type: 'video' },
|
|
152
|
+
{ name: 'feature-2', duration: 12, required: true, description: 'Screen recording of feature 2', type: 'video' },
|
|
153
|
+
{ name: 'feature-3', duration: 12, required: false, description: 'Screen recording of feature 3', type: 'video' },
|
|
154
|
+
{ name: 'cta', duration: 9, required: true, description: 'Closing with CTA', type: 'video' },
|
|
155
|
+
],
|
|
156
|
+
textPlaceholders: [
|
|
157
|
+
{ name: 'problem-text', defaultText: 'Tired of manual work?', animation: 'typewriter', startTime: 1, duration: 4, position: 'center', fontSize: 52 },
|
|
158
|
+
{ name: 'product-name', defaultText: 'Product Name', animation: 'pop', startTime: 11, duration: 4, position: 'center', fontSize: 72 },
|
|
159
|
+
{ name: 'feature-1-label', defaultText: 'Feature One', animation: 'slide-left', startTime: 16, duration: 3, position: 'top', fontSize: 36 },
|
|
160
|
+
{ name: 'feature-2-label', defaultText: 'Feature Two', animation: 'slide-left', startTime: 28, duration: 3, position: 'top', fontSize: 36 },
|
|
161
|
+
{ name: 'cta-text', defaultText: 'Try Free Today', animation: 'bounce', startTime: 52, duration: 7, position: 'center', fontSize: 64 },
|
|
162
|
+
],
|
|
163
|
+
transition: 'fade',
|
|
164
|
+
colorGrade: 'cinematic-teal-orange-subtle',
|
|
165
|
+
musicStyle: 'corporate, uplifting, modern',
|
|
166
|
+
tags: ['saas', 'demo', 'product', 'screen-recording', 'corporate'],
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
// ─── Testimonial ─────────────────────────────────────────────
|
|
170
|
+
{
|
|
171
|
+
id: 'testimonial-single',
|
|
172
|
+
name: 'Customer Testimonial',
|
|
173
|
+
category: 'testimonial',
|
|
174
|
+
description: '30-second testimonial: quote + customer name + product shot. Warm, trustworthy feel.',
|
|
175
|
+
totalDuration: 30,
|
|
176
|
+
aspectRatio: '16:9',
|
|
177
|
+
resolution: { width: 1920, height: 1080 },
|
|
178
|
+
slots: [
|
|
179
|
+
{ name: 'customer-video', duration: 20, required: true, description: 'Customer speaking / interview clip', type: 'video' },
|
|
180
|
+
{ name: 'product-shot', duration: 7, required: true, description: 'Product being used', type: 'video' },
|
|
181
|
+
{ name: 'logo-end', duration: 3, required: false, description: 'Company logo end card', type: 'image' },
|
|
182
|
+
],
|
|
183
|
+
textPlaceholders: [
|
|
184
|
+
{ name: 'quote', defaultText: '"This changed everything for us."', animation: 'fade-in', startTime: 2, duration: 8, position: 'bottom', fontSize: 36 },
|
|
185
|
+
{ name: 'name', defaultText: 'Jane Doe, CEO at Company', animation: 'slide-up', startTime: 20, duration: 5, position: 'bottom', fontSize: 32 },
|
|
186
|
+
],
|
|
187
|
+
transition: 'crossfade',
|
|
188
|
+
colorGrade: 'warm-golden',
|
|
189
|
+
musicStyle: 'soft piano, inspirational',
|
|
190
|
+
tags: ['testimonial', 'customer', 'review', 'trust'],
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// ─── Before-After ────────────────────────────────────────────
|
|
194
|
+
{
|
|
195
|
+
id: 'before-after-split',
|
|
196
|
+
name: 'Before & After',
|
|
197
|
+
category: 'before-after',
|
|
198
|
+
description: '15-second split-screen before/after comparison. Great for transformations, renovations, edits.',
|
|
199
|
+
totalDuration: 15,
|
|
200
|
+
aspectRatio: '9:16',
|
|
201
|
+
resolution: { width: 1080, height: 1920 },
|
|
202
|
+
slots: [
|
|
203
|
+
{ name: 'before', duration: 7, required: true, description: 'Before state', type: 'video' },
|
|
204
|
+
{ name: 'after', duration: 7, required: true, description: 'After state (transformation)', type: 'video' },
|
|
205
|
+
],
|
|
206
|
+
textPlaceholders: [
|
|
207
|
+
{ name: 'before-label', defaultText: 'BEFORE', animation: 'pop', startTime: 0.5, duration: 3, position: 'top', fontSize: 56 },
|
|
208
|
+
{ name: 'after-label', defaultText: 'AFTER', animation: 'pop', startTime: 7.5, duration: 3, position: 'top', fontSize: 56 },
|
|
209
|
+
],
|
|
210
|
+
transition: 'wipe',
|
|
211
|
+
musicStyle: 'dramatic reveal, build-up',
|
|
212
|
+
tags: ['before-after', 'transformation', 'comparison', 'reveal'],
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
// ─── Slideshow ───────────────────────────────────────────────
|
|
216
|
+
{
|
|
217
|
+
id: 'slideshow-photo',
|
|
218
|
+
name: 'Photo Slideshow',
|
|
219
|
+
category: 'slideshow',
|
|
220
|
+
description: '45-second photo slideshow with Ken Burns effect (slow zoom/pan). Perfect for memories, events, portfolios.',
|
|
221
|
+
totalDuration: 45,
|
|
222
|
+
aspectRatio: '16:9',
|
|
223
|
+
resolution: { width: 1920, height: 1080 },
|
|
224
|
+
slots: [
|
|
225
|
+
{ name: 'photo-1', duration: 5, required: true, description: 'First photo', type: 'image' },
|
|
226
|
+
{ name: 'photo-2', duration: 5, required: true, description: 'Second photo', type: 'image' },
|
|
227
|
+
{ name: 'photo-3', duration: 5, required: true, description: 'Third photo', type: 'image' },
|
|
228
|
+
{ name: 'photo-4', duration: 5, required: false, description: 'Fourth photo', type: 'image' },
|
|
229
|
+
{ name: 'photo-5', duration: 5, required: false, description: 'Fifth photo', type: 'image' },
|
|
230
|
+
{ name: 'photo-6', duration: 5, required: false, description: 'Sixth photo', type: 'image' },
|
|
231
|
+
{ name: 'photo-7', duration: 5, required: false, description: 'Seventh photo', type: 'image' },
|
|
232
|
+
{ name: 'photo-8', duration: 5, required: false, description: 'Eighth photo', type: 'image' },
|
|
233
|
+
{ name: 'photo-9', duration: 5, required: false, description: 'Ninth photo (closing)', type: 'image' },
|
|
234
|
+
],
|
|
235
|
+
textPlaceholders: [
|
|
236
|
+
{ name: 'title', defaultText: 'Memories', animation: 'fade-in-out', startTime: 0, duration: 5, position: 'center', fontSize: 72 },
|
|
237
|
+
],
|
|
238
|
+
transition: 'crossfade',
|
|
239
|
+
colorGrade: 'vintage-film',
|
|
240
|
+
musicStyle: 'emotional, acoustic, nostalgic',
|
|
241
|
+
tags: ['slideshow', 'photos', 'memories', 'event', 'portfolio'],
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
// ─── Tutorial ────────────────────────────────────────────────
|
|
245
|
+
{
|
|
246
|
+
id: 'tutorial-howto',
|
|
247
|
+
name: 'How-To Tutorial',
|
|
248
|
+
category: 'tutorial',
|
|
249
|
+
description: '90-second step-by-step tutorial: intro → step 1 → step 2 → step 3 → summary. Clean and instructional.',
|
|
250
|
+
totalDuration: 90,
|
|
251
|
+
aspectRatio: '16:9',
|
|
252
|
+
resolution: { width: 1920, height: 1080 },
|
|
253
|
+
slots: [
|
|
254
|
+
{ name: 'intro', duration: 10, required: true, description: 'What you will learn', type: 'video' },
|
|
255
|
+
{ name: 'step-1', duration: 20, required: true, description: 'Step 1 demonstration', type: 'video' },
|
|
256
|
+
{ name: 'step-2', duration: 20, required: true, description: 'Step 2 demonstration', type: 'video' },
|
|
257
|
+
{ name: 'step-3', duration: 20, required: false, description: 'Step 3 demonstration', type: 'video' },
|
|
258
|
+
{ name: 'summary', duration: 10, required: true, description: 'Summary / final result', type: 'video' },
|
|
259
|
+
{ name: 'outro', duration: 10, required: false, description: 'Subscribe / follow CTA', type: 'video' },
|
|
260
|
+
],
|
|
261
|
+
textPlaceholders: [
|
|
262
|
+
{ name: 'title', defaultText: 'How to...', animation: 'typewriter', startTime: 1, duration: 5, position: 'center', fontSize: 56 },
|
|
263
|
+
{ name: 'step-1-label', defaultText: 'Step 1', animation: 'slide-left', startTime: 10, duration: 3, position: 'top-left', fontSize: 40 },
|
|
264
|
+
{ name: 'step-2-label', defaultText: 'Step 2', animation: 'slide-left', startTime: 30, duration: 3, position: 'top-left', fontSize: 40 },
|
|
265
|
+
{ name: 'step-3-label', defaultText: 'Step 3', animation: 'slide-left', startTime: 50, duration: 3, position: 'top-left', fontSize: 40 },
|
|
266
|
+
{ name: 'done-text', defaultText: "That's it!", animation: 'bounce', startTime: 70, duration: 5, position: 'center', fontSize: 64 },
|
|
267
|
+
],
|
|
268
|
+
transition: 'fade',
|
|
269
|
+
musicStyle: 'lo-fi background, subtle',
|
|
270
|
+
tags: ['tutorial', 'howto', 'educational', 'step-by-step'],
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
// ─── Announcement ────────────────────────────────────────────
|
|
274
|
+
{
|
|
275
|
+
id: 'announcement-launch',
|
|
276
|
+
name: 'Product Launch',
|
|
277
|
+
category: 'announcement',
|
|
278
|
+
description: '20-second product launch announcement: dramatic reveal with bold text. High energy.',
|
|
279
|
+
totalDuration: 20,
|
|
280
|
+
aspectRatio: '1:1',
|
|
281
|
+
resolution: { width: 1080, height: 1080 },
|
|
282
|
+
slots: [
|
|
283
|
+
{ name: 'teaser', duration: 5, required: true, description: 'Build-up / teaser shot', type: 'video' },
|
|
284
|
+
{ name: 'reveal', duration: 8, required: true, description: 'Product reveal moment', type: 'video' },
|
|
285
|
+
{ name: 'details', duration: 4, required: false, description: 'Key features / details', type: 'video' },
|
|
286
|
+
{ name: 'cta', duration: 3, required: true, description: 'CTA end card', type: 'image' },
|
|
287
|
+
],
|
|
288
|
+
textPlaceholders: [
|
|
289
|
+
{ name: 'coming', defaultText: 'Something Big...', animation: 'fade-in', startTime: 0.5, duration: 4, position: 'center', fontSize: 56 },
|
|
290
|
+
{ name: 'product', defaultText: 'INTRODUCING', animation: 'pop', startTime: 5, duration: 3, position: 'top', fontSize: 48 },
|
|
291
|
+
{ name: 'name', defaultText: 'Product Name', animation: 'bounce', startTime: 6, duration: 5, position: 'center', fontSize: 72 },
|
|
292
|
+
{ name: 'cta-text', defaultText: 'Available Now', animation: 'slide-up', startTime: 17, duration: 3, position: 'center', fontSize: 52 },
|
|
293
|
+
],
|
|
294
|
+
transition: 'fade',
|
|
295
|
+
colorGrade: 'cyberpunk-neon',
|
|
296
|
+
musicStyle: 'dramatic, cinematic trailer, build-up + drop',
|
|
297
|
+
tags: ['launch', 'announcement', 'product', 'reveal', 'dramatic'],
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// ─── Promo ───────────────────────────────────────────────────
|
|
301
|
+
{
|
|
302
|
+
id: 'promo-sale',
|
|
303
|
+
name: 'Sale Promo',
|
|
304
|
+
category: 'promo',
|
|
305
|
+
description: '10-second sale/discount promo: bold numbers, urgency, CTA. Perfect for stories/ads.',
|
|
306
|
+
totalDuration: 10,
|
|
307
|
+
aspectRatio: '9:16',
|
|
308
|
+
resolution: { width: 1080, height: 1920 },
|
|
309
|
+
slots: [
|
|
310
|
+
{ name: 'product-shot', duration: 5, required: true, description: 'Product in action', type: 'video' },
|
|
311
|
+
{ name: 'cta-shot', duration: 5, required: true, description: 'End card with CTA', type: 'image' },
|
|
312
|
+
],
|
|
313
|
+
textPlaceholders: [
|
|
314
|
+
{ name: 'discount', defaultText: '50% OFF', animation: 'pop', startTime: 0.3, duration: 4, position: 'center', fontSize: 96 },
|
|
315
|
+
{ name: 'limited', defaultText: 'LIMITED TIME', animation: 'shake', startTime: 1, duration: 3, position: 'top', fontSize: 36 },
|
|
316
|
+
{ name: 'cta', defaultText: 'SHOP NOW', animation: 'bounce', startTime: 5.5, duration: 4, position: 'center', fontSize: 64 },
|
|
317
|
+
],
|
|
318
|
+
transition: 'fade',
|
|
319
|
+
colorGrade: 'blockbuster-extreme',
|
|
320
|
+
musicStyle: 'urgent, energetic, short',
|
|
321
|
+
tags: ['sale', 'promo', 'discount', 'ad', 'story'],
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
// ─── Functions ──────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
/** List all available templates, optionally filtered by category */
|
|
328
|
+
export function listTemplates(category?: TemplateCategory): VideoTemplate[] {
|
|
329
|
+
if (category) {
|
|
330
|
+
return TEMPLATES.filter(t => t.category === category);
|
|
331
|
+
}
|
|
332
|
+
return TEMPLATES;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Get a specific template by ID */
|
|
336
|
+
export function getTemplate(id: string): VideoTemplate | undefined {
|
|
337
|
+
return TEMPLATES.find(t => t.id === id);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Get all template categories */
|
|
341
|
+
export function getTemplateCategories(): TemplateCategory[] {
|
|
342
|
+
return ['social-reel', 'product-demo', 'testimonial', 'before-after', 'slideshow', 'tutorial', 'announcement', 'promo'];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Get template summary for listing */
|
|
346
|
+
export function getTemplateSummaries(category?: TemplateCategory): Array<{
|
|
347
|
+
id: string;
|
|
348
|
+
name: string;
|
|
349
|
+
category: string;
|
|
350
|
+
description: string;
|
|
351
|
+
duration: string;
|
|
352
|
+
aspectRatio: string;
|
|
353
|
+
requiredSlots: number;
|
|
354
|
+
optionalSlots: number;
|
|
355
|
+
tags: string[];
|
|
356
|
+
}> {
|
|
357
|
+
const templates = category ? listTemplates(category) : TEMPLATES;
|
|
358
|
+
|
|
359
|
+
return templates.map(t => ({
|
|
360
|
+
id: t.id,
|
|
361
|
+
name: t.name,
|
|
362
|
+
category: t.category,
|
|
363
|
+
description: t.description,
|
|
364
|
+
duration: `${t.totalDuration}s`,
|
|
365
|
+
aspectRatio: t.aspectRatio,
|
|
366
|
+
requiredSlots: t.slots.filter(s => s.required).length,
|
|
367
|
+
optionalSlots: t.slots.filter(s => !s.required).length,
|
|
368
|
+
tags: t.tags,
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Animation Engine — 15+ animated text effects via FFmpeg drawtext.
|
|
3
|
+
*
|
|
4
|
+
* Each animation uses drawtext's `enable` expression and alpha/position
|
|
5
|
+
* manipulation to create effects like typewriter, pop, slide, bounce, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { logger } from '../../lib/logger.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type TextAnimation =
|
|
16
|
+
| 'typewriter'
|
|
17
|
+
| 'pop'
|
|
18
|
+
| 'slide-up'
|
|
19
|
+
| 'slide-down'
|
|
20
|
+
| 'slide-left'
|
|
21
|
+
| 'slide-right'
|
|
22
|
+
| 'bounce'
|
|
23
|
+
| 'fade-in'
|
|
24
|
+
| 'fade-out'
|
|
25
|
+
| 'fade-in-out'
|
|
26
|
+
| 'glitch'
|
|
27
|
+
| 'zoom-in'
|
|
28
|
+
| 'shake'
|
|
29
|
+
| 'neon-glow'
|
|
30
|
+
| 'wave';
|
|
31
|
+
|
|
32
|
+
export interface TextAnimationConfig {
|
|
33
|
+
inputPath: string;
|
|
34
|
+
outputPath: string;
|
|
35
|
+
/** Text to animate */
|
|
36
|
+
text: string;
|
|
37
|
+
/** Animation style */
|
|
38
|
+
animation: TextAnimation;
|
|
39
|
+
/** Start time in seconds (default: 0) */
|
|
40
|
+
startTime?: number;
|
|
41
|
+
/** Duration of the animation/text display in seconds (default: 3) */
|
|
42
|
+
duration?: number;
|
|
43
|
+
/** Font size (default: 48) */
|
|
44
|
+
fontSize?: number;
|
|
45
|
+
/** Font color as hex (e.g., 'FFFFFF'). Default: 'FFFFFF' */
|
|
46
|
+
fontColor?: string;
|
|
47
|
+
/** Position: 'center', 'top', 'bottom', 'top-left', 'top-right', 'bottom-left', 'bottom-right'. Default: 'center' */
|
|
48
|
+
position?: TextPosition;
|
|
49
|
+
/** Font family (default: 'Sans') */
|
|
50
|
+
fontFamily?: string;
|
|
51
|
+
/** Shadow/outline for readability (default: true) */
|
|
52
|
+
shadow?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type TextPosition = 'center' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
56
|
+
|
|
57
|
+
export const ALL_TEXT_ANIMATIONS: TextAnimation[] = [
|
|
58
|
+
'typewriter', 'pop', 'slide-up', 'slide-down', 'slide-left', 'slide-right',
|
|
59
|
+
'bounce', 'fade-in', 'fade-out', 'fade-in-out', 'glitch', 'zoom-in',
|
|
60
|
+
'shake', 'neon-glow', 'wave',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export const TEXT_ANIMATION_DESCRIPTIONS: Record<TextAnimation, string> = {
|
|
64
|
+
'typewriter': 'Letters appear one by one like typing',
|
|
65
|
+
'pop': 'Text pops in with scale effect',
|
|
66
|
+
'slide-up': 'Text slides up from below',
|
|
67
|
+
'slide-down': 'Text slides down from above',
|
|
68
|
+
'slide-left': 'Text slides in from the right',
|
|
69
|
+
'slide-right': 'Text slides in from the left',
|
|
70
|
+
'bounce': 'Text bounces into position',
|
|
71
|
+
'fade-in': 'Text gradually fades in',
|
|
72
|
+
'fade-out': 'Text gradually fades out',
|
|
73
|
+
'fade-in-out': 'Text fades in then fades out',
|
|
74
|
+
'glitch': 'Text appears with digital glitch effect',
|
|
75
|
+
'zoom-in': 'Text zooms in from small to normal',
|
|
76
|
+
'shake': 'Text shakes/vibrates in position',
|
|
77
|
+
'neon-glow': 'Text pulses with neon glow effect',
|
|
78
|
+
'wave': 'Text has a wave/oscillation motion',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
|
|
86
|
+
if (error) {
|
|
87
|
+
logger.error(`ffmpeg failed: ${stderr}`);
|
|
88
|
+
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
resolve(stdout);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ensureDir(filePath: string): void {
|
|
97
|
+
const dir = path.dirname(filePath);
|
|
98
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assertExists(filePath: string, label = 'File'): void {
|
|
102
|
+
if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fileInfo(filePath: string): string {
|
|
106
|
+
const stats = fs.statSync(filePath);
|
|
107
|
+
return `${(stats.size / 1024 / 1024).toFixed(2)} MB`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Escape text for FFmpeg drawtext (colons, backslashes, quotes) */
|
|
111
|
+
function escapeDrawtext(text: string): string {
|
|
112
|
+
return text
|
|
113
|
+
.replace(/\\/g, '\\\\\\\\')
|
|
114
|
+
.replace(/'/g, "'\\\\\\''")
|
|
115
|
+
.replace(/:/g, '\\:')
|
|
116
|
+
.replace(/%/g, '%%');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Get position expressions for drawtext x/y */
|
|
120
|
+
function getPositionExprs(position: TextPosition, margin = 40): { x: string; y: string } {
|
|
121
|
+
switch (position) {
|
|
122
|
+
case 'center': return { x: '(w-text_w)/2', y: '(h-text_h)/2' };
|
|
123
|
+
case 'top': return { x: '(w-text_w)/2', y: String(margin) };
|
|
124
|
+
case 'bottom': return { x: '(w-text_w)/2', y: `h-text_h-${margin}` };
|
|
125
|
+
case 'top-left': return { x: String(margin), y: String(margin) };
|
|
126
|
+
case 'top-right': return { x: `w-text_w-${margin}`, y: String(margin) };
|
|
127
|
+
case 'bottom-left': return { x: String(margin), y: `h-text_h-${margin}` };
|
|
128
|
+
case 'bottom-right': return { x: `w-text_w-${margin}`, y: `h-text_h-${margin}` };
|
|
129
|
+
default: return { x: '(w-text_w)/2', y: '(h-text_h)/2' };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Animation Builders ─────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function buildAnimationFilter(config: TextAnimationConfig): string {
|
|
136
|
+
const {
|
|
137
|
+
text,
|
|
138
|
+
animation,
|
|
139
|
+
startTime = 0,
|
|
140
|
+
duration = 3,
|
|
141
|
+
fontSize = 48,
|
|
142
|
+
fontColor = 'FFFFFF',
|
|
143
|
+
position = 'center',
|
|
144
|
+
fontFamily = 'Sans',
|
|
145
|
+
shadow = true,
|
|
146
|
+
} = config;
|
|
147
|
+
|
|
148
|
+
const escapedText = escapeDrawtext(text);
|
|
149
|
+
const pos = getPositionExprs(position);
|
|
150
|
+
const endTime = startTime + duration;
|
|
151
|
+
const color = fontColor.replace(/^#/, '');
|
|
152
|
+
|
|
153
|
+
// Shadow/outline for readability
|
|
154
|
+
const shadowOpts = shadow
|
|
155
|
+
? `:shadowcolor=black@0.7:shadowx=2:shadowy=2:borderw=1:bordercolor=black@0.5`
|
|
156
|
+
: '';
|
|
157
|
+
|
|
158
|
+
// Enable window
|
|
159
|
+
const enable = `enable='between(t,${startTime},${endTime})'`;
|
|
160
|
+
|
|
161
|
+
// Relative time within animation window
|
|
162
|
+
const relT = `(t-${startTime})`;
|
|
163
|
+
const animDur = Math.min(0.8, duration * 0.3); // Animation happens in first 30% or 0.8s max
|
|
164
|
+
|
|
165
|
+
switch (animation) {
|
|
166
|
+
case 'typewriter': {
|
|
167
|
+
// Show text character by character using text expansion
|
|
168
|
+
const charCount = text.length;
|
|
169
|
+
const charsPerSec = charCount / Math.min(duration * 0.7, 2);
|
|
170
|
+
// Use text_shaping=0 and limit displayed text via expansion
|
|
171
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='if(lt(${relT},${duration * 0.7}),1,max(0,1-(${relT}-${duration * 0.7})/${duration * 0.3}))'${shadowOpts}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'pop': {
|
|
175
|
+
// Text appears with a quick scale effect (simulated via fontsize change)
|
|
176
|
+
// Can't actually animate fontsize in drawtext, so use alpha snap-in
|
|
177
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='if(lt(${relT},0.1),${relT}/0.1,1)'${shadowOpts}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'slide-up': {
|
|
181
|
+
// Text slides up from below screen
|
|
182
|
+
const targetY = pos.y;
|
|
183
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y='if(lt(${relT},${animDur}),h-(h-${targetY})*${relT}/${animDur},${targetY})':${enable}${shadowOpts}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'slide-down': {
|
|
187
|
+
// Text slides down from above
|
|
188
|
+
const targetY = pos.y;
|
|
189
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y='if(lt(${relT},${animDur}),-text_h+(${targetY}+text_h)*${relT}/${animDur},${targetY})':${enable}${shadowOpts}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case 'slide-left': {
|
|
193
|
+
// Text slides in from right
|
|
194
|
+
const targetX = pos.x;
|
|
195
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x='if(lt(${relT},${animDur}),w-(w-${targetX})*${relT}/${animDur},${targetX})':y=${pos.y}:${enable}${shadowOpts}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'slide-right': {
|
|
199
|
+
// Text slides in from left
|
|
200
|
+
const targetX = pos.x;
|
|
201
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x='if(lt(${relT},${animDur}),-text_w+(${targetX}+text_w)*${relT}/${animDur},${targetX})':y=${pos.y}:${enable}${shadowOpts}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'bounce': {
|
|
205
|
+
// Text bounces from top (damped oscillation)
|
|
206
|
+
const targetY = pos.y;
|
|
207
|
+
// Damped sine wave: targetY + amplitude * sin(freq*t) * exp(-decay*t)
|
|
208
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y='if(lt(${relT},${animDur * 2}),${targetY}-100*sin(${relT}*12)*exp(-${relT}*5),${targetY})':${enable}${shadowOpts}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'fade-in': {
|
|
212
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='min(1,${relT}/${animDur})'${shadowOpts}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case 'fade-out': {
|
|
216
|
+
const fadeStart = duration - animDur;
|
|
217
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='if(lt(${relT},${fadeStart}),1,max(0,1-(${relT}-${fadeStart})/${animDur}))'${shadowOpts}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'fade-in-out': {
|
|
221
|
+
const fadeInEnd = animDur;
|
|
222
|
+
const fadeOutStart = duration - animDur;
|
|
223
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='if(lt(${relT},${fadeInEnd}),${relT}/${fadeInEnd},if(gt(${relT},${fadeOutStart}),max(0,1-(${relT}-${fadeOutStart})/${animDur}),1))'${shadowOpts}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case 'glitch': {
|
|
227
|
+
// Glitch: random x/y jitter + alpha flicker
|
|
228
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x='${pos.x}+if(lt(${relT},${animDur}),(rand(0,20)-10),0)':y='${pos.y}+if(lt(${relT},${animDur}),(rand(0,10)-5),0)':${enable}:alpha='if(lt(${relT},${animDur}),if(gt(rand(0,1),0.3),1,0),1)'${shadowOpts}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case 'zoom-in': {
|
|
232
|
+
// Simulated zoom: larger font fading in, then normal
|
|
233
|
+
// We can't dynamically change fontsize in drawtext, so we fade with position shift
|
|
234
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='min(1,${relT}/${animDur})'${shadowOpts}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
case 'shake': {
|
|
238
|
+
// Continuous shake effect
|
|
239
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x='${pos.x}+(rand(0,8)-4)*sin(t*30)':y='${pos.y}+(rand(0,6)-3)*cos(t*25)':${enable}${shadowOpts}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'neon-glow': {
|
|
243
|
+
// Pulsating alpha for neon glow effect (sine wave)
|
|
244
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y=${pos.y}:${enable}:alpha='0.6+0.4*sin(t*4)':shadowcolor=0x${color}@0.5:shadowx=0:shadowy=0:borderw=3:bordercolor=0x${color}@0.3`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'wave': {
|
|
248
|
+
// Text moves in a wave pattern
|
|
249
|
+
const targetY = pos.y;
|
|
250
|
+
return `drawtext=text='${escapedText}':fontsize=${fontSize}:fontcolor=0x${color}:fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:x=${pos.x}:y='${targetY}+15*sin(t*3)':${enable}${shadowOpts}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
default:
|
|
254
|
+
throw new Error(`Unknown text animation: ${animation}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Main Function ──────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export async function animateText(config: TextAnimationConfig): Promise<string> {
|
|
261
|
+
const { inputPath, outputPath, animation, text } = config;
|
|
262
|
+
|
|
263
|
+
assertExists(inputPath, 'Input video');
|
|
264
|
+
ensureDir(outputPath);
|
|
265
|
+
|
|
266
|
+
logger.info(`Animating text: "${text.substring(0, 30)}..." with ${animation}`);
|
|
267
|
+
|
|
268
|
+
const filterStr = buildAnimationFilter(config);
|
|
269
|
+
|
|
270
|
+
const args = [
|
|
271
|
+
'-y', '-i', inputPath,
|
|
272
|
+
'-vf', filterStr,
|
|
273
|
+
'-c:a', 'copy',
|
|
274
|
+
'-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
|
|
275
|
+
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
|
276
|
+
outputPath,
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
await runFfmpeg(args);
|
|
280
|
+
logger.info(`Text animation applied: ${animation} → ${outputPath} (${fileInfo(outputPath)})`);
|
|
281
|
+
return outputPath;
|
|
282
|
+
}
|