@studiomeyer/mcp-video 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  3. package/.github/workflows/ci.yml +34 -0
  4. package/CHANGELOG.md +24 -0
  5. package/CONTRIBUTING.md +75 -0
  6. package/LICENSE +21 -0
  7. package/README.md +198 -0
  8. package/USAGE.md +144 -0
  9. package/dist/handlers/capcut.d.ts +6 -0
  10. package/dist/handlers/capcut.js +229 -0
  11. package/dist/handlers/capcut.js.map +1 -0
  12. package/dist/handlers/editing.d.ts +6 -0
  13. package/dist/handlers/editing.js +242 -0
  14. package/dist/handlers/editing.js.map +1 -0
  15. package/dist/handlers/index.d.ts +2 -0
  16. package/dist/handlers/index.js +33 -0
  17. package/dist/handlers/index.js.map +1 -0
  18. package/dist/handlers/post-production.d.ts +5 -0
  19. package/dist/handlers/post-production.js +109 -0
  20. package/dist/handlers/post-production.js.map +1 -0
  21. package/dist/handlers/smart-screenshot.d.ts +5 -0
  22. package/dist/handlers/smart-screenshot.js +83 -0
  23. package/dist/handlers/smart-screenshot.js.map +1 -0
  24. package/dist/handlers/tts.d.ts +5 -0
  25. package/dist/handlers/tts.js +83 -0
  26. package/dist/handlers/tts.js.map +1 -0
  27. package/dist/handlers/video.d.ts +5 -0
  28. package/dist/handlers/video.js +127 -0
  29. package/dist/handlers/video.js.map +1 -0
  30. package/dist/lib/dual-transport.d.ts +42 -0
  31. package/dist/lib/dual-transport.js +208 -0
  32. package/dist/lib/dual-transport.js.map +1 -0
  33. package/dist/lib/logger.d.ts +12 -0
  34. package/dist/lib/logger.js +42 -0
  35. package/dist/lib/logger.js.map +1 -0
  36. package/dist/lib/types.d.ts +16 -0
  37. package/dist/lib/types.js +15 -0
  38. package/dist/lib/types.js.map +1 -0
  39. package/dist/schemas/capcut.d.ts +608 -0
  40. package/dist/schemas/capcut.js +411 -0
  41. package/dist/schemas/capcut.js.map +1 -0
  42. package/dist/schemas/editing.d.ts +822 -0
  43. package/dist/schemas/editing.js +466 -0
  44. package/dist/schemas/editing.js.map +1 -0
  45. package/dist/schemas/index.d.ts +2366 -0
  46. package/dist/schemas/index.js +15 -0
  47. package/dist/schemas/index.js.map +1 -0
  48. package/dist/schemas/post-production.d.ts +379 -0
  49. package/dist/schemas/post-production.js +268 -0
  50. package/dist/schemas/post-production.js.map +1 -0
  51. package/dist/schemas/smart-screenshot.d.ts +127 -0
  52. package/dist/schemas/smart-screenshot.js +122 -0
  53. package/dist/schemas/smart-screenshot.js.map +1 -0
  54. package/dist/schemas/tts.d.ts +220 -0
  55. package/dist/schemas/tts.js +194 -0
  56. package/dist/schemas/tts.js.map +1 -0
  57. package/dist/schemas/video.d.ts +236 -0
  58. package/dist/schemas/video.js +210 -0
  59. package/dist/schemas/video.js.map +1 -0
  60. package/dist/server.d.ts +11 -0
  61. package/dist/server.js +239 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/server.test.d.ts +1 -0
  64. package/dist/server.test.js +87 -0
  65. package/dist/server.test.js.map +1 -0
  66. package/dist/tools/engine/audio-mixer.d.ts +40 -0
  67. package/dist/tools/engine/audio-mixer.js +169 -0
  68. package/dist/tools/engine/audio-mixer.js.map +1 -0
  69. package/dist/tools/engine/audio.d.ts +22 -0
  70. package/dist/tools/engine/audio.js +73 -0
  71. package/dist/tools/engine/audio.js.map +1 -0
  72. package/dist/tools/engine/beat-sync.d.ts +31 -0
  73. package/dist/tools/engine/beat-sync.js +270 -0
  74. package/dist/tools/engine/beat-sync.js.map +1 -0
  75. package/dist/tools/engine/capture.d.ts +12 -0
  76. package/dist/tools/engine/capture.js +290 -0
  77. package/dist/tools/engine/capture.js.map +1 -0
  78. package/dist/tools/engine/chroma-key.d.ts +27 -0
  79. package/dist/tools/engine/chroma-key.js +154 -0
  80. package/dist/tools/engine/chroma-key.js.map +1 -0
  81. package/dist/tools/engine/concat.d.ts +49 -0
  82. package/dist/tools/engine/concat.js +149 -0
  83. package/dist/tools/engine/concat.js.map +1 -0
  84. package/dist/tools/engine/cursor.d.ts +26 -0
  85. package/dist/tools/engine/cursor.js +185 -0
  86. package/dist/tools/engine/cursor.js.map +1 -0
  87. package/dist/tools/engine/easing.d.ts +15 -0
  88. package/dist/tools/engine/easing.js +100 -0
  89. package/dist/tools/engine/easing.js.map +1 -0
  90. package/dist/tools/engine/editing.d.ts +158 -0
  91. package/dist/tools/engine/editing.js +541 -0
  92. package/dist/tools/engine/editing.js.map +1 -0
  93. package/dist/tools/engine/encoder.d.ts +31 -0
  94. package/dist/tools/engine/encoder.js +154 -0
  95. package/dist/tools/engine/encoder.js.map +1 -0
  96. package/dist/tools/engine/index.d.ts +30 -0
  97. package/dist/tools/engine/index.js +23 -0
  98. package/dist/tools/engine/index.js.map +1 -0
  99. package/dist/tools/engine/lut-presets.d.ts +25 -0
  100. package/dist/tools/engine/lut-presets.js +141 -0
  101. package/dist/tools/engine/lut-presets.js.map +1 -0
  102. package/dist/tools/engine/narrated-video.d.ts +63 -0
  103. package/dist/tools/engine/narrated-video.js +163 -0
  104. package/dist/tools/engine/narrated-video.js.map +1 -0
  105. package/dist/tools/engine/scenes.d.ts +17 -0
  106. package/dist/tools/engine/scenes.js +223 -0
  107. package/dist/tools/engine/scenes.js.map +1 -0
  108. package/dist/tools/engine/smart-screenshot.d.ts +80 -0
  109. package/dist/tools/engine/smart-screenshot.js +744 -0
  110. package/dist/tools/engine/smart-screenshot.js.map +1 -0
  111. package/dist/tools/engine/social-format.d.ts +66 -0
  112. package/dist/tools/engine/social-format.js +107 -0
  113. package/dist/tools/engine/social-format.js.map +1 -0
  114. package/dist/tools/engine/template-renderer.d.ts +45 -0
  115. package/dist/tools/engine/template-renderer.js +233 -0
  116. package/dist/tools/engine/template-renderer.js.map +1 -0
  117. package/dist/tools/engine/templates.d.ts +87 -0
  118. package/dist/tools/engine/templates.js +272 -0
  119. package/dist/tools/engine/templates.js.map +1 -0
  120. package/dist/tools/engine/text-animations.d.ts +33 -0
  121. package/dist/tools/engine/text-animations.js +192 -0
  122. package/dist/tools/engine/text-animations.js.map +1 -0
  123. package/dist/tools/engine/text-overlay.d.ts +27 -0
  124. package/dist/tools/engine/text-overlay.js +84 -0
  125. package/dist/tools/engine/text-overlay.js.map +1 -0
  126. package/dist/tools/engine/tts.d.ts +54 -0
  127. package/dist/tools/engine/tts.js +186 -0
  128. package/dist/tools/engine/tts.js.map +1 -0
  129. package/dist/tools/engine/types.d.ts +166 -0
  130. package/dist/tools/engine/types.js +13 -0
  131. package/dist/tools/engine/types.js.map +1 -0
  132. package/dist/tools/engine/voice-effects.d.ts +18 -0
  133. package/dist/tools/engine/voice-effects.js +215 -0
  134. package/dist/tools/engine/voice-effects.js.map +1 -0
  135. package/dist/tools/index.d.ts +32 -0
  136. package/dist/tools/index.js +23 -0
  137. package/dist/tools/index.js.map +1 -0
  138. package/package.json +56 -0
  139. package/scripts/check-deps.js +39 -0
  140. package/src/handlers/capcut.ts +245 -0
  141. package/src/handlers/editing.ts +260 -0
  142. package/src/handlers/index.ts +34 -0
  143. package/src/handlers/post-production.ts +136 -0
  144. package/src/handlers/smart-screenshot.ts +86 -0
  145. package/src/handlers/tts.ts +103 -0
  146. package/src/handlers/video.ts +137 -0
  147. package/src/lib/dual-transport.ts +272 -0
  148. package/src/lib/logger.ts +59 -0
  149. package/src/lib/types.ts +25 -0
  150. package/src/schemas/capcut.ts +418 -0
  151. package/src/schemas/editing.ts +476 -0
  152. package/src/schemas/index.ts +15 -0
  153. package/src/schemas/post-production.ts +273 -0
  154. package/src/schemas/smart-screenshot.ts +122 -0
  155. package/src/schemas/tts.ts +197 -0
  156. package/src/schemas/video.ts +211 -0
  157. package/src/server.test.ts +99 -0
  158. package/src/server.ts +289 -0
  159. package/src/tools/engine/audio-mixer.ts +244 -0
  160. package/src/tools/engine/audio.ts +115 -0
  161. package/src/tools/engine/beat-sync.ts +356 -0
  162. package/src/tools/engine/capture.ts +360 -0
  163. package/src/tools/engine/chroma-key.ts +202 -0
  164. package/src/tools/engine/concat.ts +242 -0
  165. package/src/tools/engine/cursor.ts +222 -0
  166. package/src/tools/engine/easing.ts +120 -0
  167. package/src/tools/engine/editing.ts +809 -0
  168. package/src/tools/engine/encoder.ts +208 -0
  169. package/src/tools/engine/index.ts +33 -0
  170. package/src/tools/engine/lut-presets.ts +235 -0
  171. package/src/tools/engine/narrated-video.ts +267 -0
  172. package/src/tools/engine/scenes.ts +309 -0
  173. package/src/tools/engine/smart-screenshot.ts +923 -0
  174. package/src/tools/engine/social-format.ts +146 -0
  175. package/src/tools/engine/template-renderer.ts +294 -0
  176. package/src/tools/engine/templates.ts +370 -0
  177. package/src/tools/engine/text-animations.ts +282 -0
  178. package/src/tools/engine/text-overlay.ts +143 -0
  179. package/src/tools/engine/tts.ts +284 -0
  180. package/src/tools/engine/types.ts +191 -0
  181. package/src/tools/engine/voice-effects.ts +258 -0
  182. package/src/tools/index.ts +67 -0
  183. package/tsconfig.json +19 -0
  184. package/vitest.config.ts +7 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Frame-by-Frame Capture Engine
3
+ * The core of cinema-grade website video recording
4
+ *
5
+ * Pipeline: Playwright → Frame Screenshots → ffmpeg → MP4
6
+ * Result: Perfect 60fps video with zero frame drops
7
+ */
8
+
9
+ import { chromium } from 'playwright';
10
+ import type { Page, Browser, BrowserContext } from 'playwright';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+ import { logger } from '../../lib/logger.js';
15
+ import type { RecordingConfig, RecordingResult, ViewportConfig, Scene } from './types.js';
16
+ import { VIEWPORTS } from './types.js';
17
+ import { injectCursor, hideCursor } from './cursor.js';
18
+ import { executeScenes, createDefaultScenes } from './scenes.js';
19
+ import { encodeFrames, cleanupFrames } from './encoder.js';
20
+
21
+ const OUTPUT_DIR = process.env.VIDEO_OUTPUT_DIR || './output';
22
+
23
+ /**
24
+ * Record a website with cinema-quality frame-by-frame capture
25
+ */
26
+ export async function recordWebsite(config: RecordingConfig): Promise<RecordingResult> {
27
+ const {
28
+ url,
29
+ outputPath = path.join(OUTPUT_DIR, `website-video-${Date.now()}`),
30
+ fps = 60,
31
+ scenes: userScenes,
32
+ cursor = { enabled: true },
33
+ encoding = {},
34
+ dismissOverlays = true,
35
+ preloadContent = true,
36
+ deviceScaleFactor = 1,
37
+ darkMode = false,
38
+ disableSmoothScroll = true,
39
+ } = config;
40
+
41
+ // Resolve viewport
42
+ const viewport: ViewportConfig = typeof config.viewport === 'string'
43
+ ? VIEWPORTS[config.viewport] ?? VIEWPORTS.desktop
44
+ : config.viewport ?? VIEWPORTS.desktop;
45
+
46
+ // Create temp directory for frames
47
+ const framesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cinema-frames-'));
48
+ const framePattern = 'frame_%06d.png';
49
+
50
+ let browser: Browser | undefined;
51
+ let totalFrames = 0;
52
+ const startTime = Date.now();
53
+
54
+ try {
55
+ logger.info(`Starting cinema capture: ${url} (${viewport.width}x${viewport.height}, ${fps}fps)`);
56
+
57
+ // ─── 1. Launch Browser ────────────────────────────────────────
58
+ browser = await chromium.launch({
59
+ headless: true,
60
+ args: [
61
+ '--no-sandbox',
62
+ '--disable-setuid-sandbox',
63
+ '--disable-dev-shm-usage',
64
+ '--disable-gpu',
65
+ '--disable-web-security',
66
+ '--hide-scrollbars',
67
+ ],
68
+ });
69
+
70
+ const contextOptions: Record<string, unknown> = {
71
+ viewport: { width: viewport.width, height: viewport.height },
72
+ deviceScaleFactor,
73
+ colorScheme: darkMode ? 'dark' as const : 'light' as const,
74
+ };
75
+
76
+ // Mobile user agent
77
+ if (config.viewport === 'mobile' || config.viewport === 'mobile-landscape') {
78
+ contextOptions.userAgent = config.userAgent ??
79
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
80
+ contextOptions.isMobile = true;
81
+ contextOptions.hasTouch = true;
82
+ } else if (config.userAgent) {
83
+ contextOptions.userAgent = config.userAgent;
84
+ }
85
+
86
+ const context: BrowserContext = await browser.newContext(contextOptions);
87
+ const page: Page = await context.newPage();
88
+
89
+ // ─── 2. Navigate to URL ───────────────────────────────────────
90
+ logger.info(`Navigating to ${url}...`);
91
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }).catch(async () => {
92
+ // Fallback: try with just domcontentloaded
93
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
94
+ });
95
+
96
+ // Wait for content to render
97
+ await page.waitForTimeout(2000);
98
+
99
+ // ─── 3. Prepare Page ──────────────────────────────────────────
100
+
101
+ // Disable CSS smooth scroll to prevent double-easing
102
+ if (disableSmoothScroll) {
103
+ await page.addStyleTag({
104
+ content: `*, html { scroll-behavior: auto !important; }`,
105
+ });
106
+ }
107
+
108
+ // Hide scrollbar
109
+ await page.addStyleTag({
110
+ content: `::-webkit-scrollbar { display: none !important; } * { scrollbar-width: none !important; }`,
111
+ });
112
+
113
+ // Dismiss overlays and cookie banners
114
+ if (dismissOverlays) {
115
+ await dismissPageOverlays(page);
116
+ // Wait for animations to complete (e.g. Framer Motion exit)
117
+ await page.waitForTimeout(1000);
118
+ }
119
+
120
+ // ─── 4. Pre-scroll to trigger lazy loading ────────────────────
121
+ if (preloadContent) {
122
+ await preloadAllContent(page, viewport.height);
123
+ }
124
+
125
+ // ─── 5. Inject cursor overlay ─────────────────────────────────
126
+ if (cursor.enabled) {
127
+ await injectCursor(page, cursor);
128
+ }
129
+
130
+ // Scroll back to top
131
+ await page.evaluate(() => window.scrollTo(0, 0));
132
+ await page.waitForTimeout(500);
133
+
134
+ // ─── 6. Execute scenes & capture frames ───────────────────────
135
+ const scenes: Scene[] = userScenes && userScenes.length > 0
136
+ ? userScenes
137
+ : createDefaultScenes();
138
+
139
+ logger.info(`Executing ${scenes.length} scene(s)...`);
140
+
141
+ let frameIndex = 0;
142
+ totalFrames = await executeScenes(
143
+ page,
144
+ scenes,
145
+ fps,
146
+ async (_fi: number) => {
147
+ const framePath = path.join(framesDir, `frame_${String(frameIndex).padStart(6, '0')}.png`);
148
+ await page.screenshot({ path: framePath, type: 'png' });
149
+ frameIndex++;
150
+
151
+ // Progress logging every 60 frames (= 1 second at 60fps)
152
+ if (frameIndex % fps === 0) {
153
+ logger.info(`Captured ${frameIndex} frames (${(frameIndex / fps).toFixed(1)}s)`);
154
+ }
155
+ },
156
+ cursor.enabled
157
+ );
158
+
159
+ // Hide cursor for final frame
160
+ if (cursor.enabled) {
161
+ await hideCursor(page);
162
+ }
163
+
164
+ // Take thumbnail from first frame position
165
+ await page.evaluate(() => window.scrollTo(0, 0));
166
+ await page.waitForTimeout(300);
167
+ const thumbnailBuffer = await page.screenshot({ type: 'png' });
168
+ const thumbnailPath = `${outputPath}-thumbnail.png`;
169
+ const thumbnailDir = path.dirname(thumbnailPath);
170
+ if (!fs.existsSync(thumbnailDir)) {
171
+ fs.mkdirSync(thumbnailDir, { recursive: true });
172
+ }
173
+ fs.writeFileSync(thumbnailPath, thumbnailBuffer);
174
+
175
+ // ─── 7. Close browser ─────────────────────────────────────────
176
+ await context.close();
177
+ await browser.close();
178
+ browser = undefined;
179
+
180
+ logger.info(`Capture complete: ${totalFrames} frames in ${((Date.now() - startTime) / 1000).toFixed(1)}s`);
181
+
182
+ // ─── 8. Encode video ──────────────────────────────────────────
183
+ logger.info('Encoding video with ffmpeg...');
184
+ const encodeResult = await encodeFrames(
185
+ framesDir,
186
+ framePattern,
187
+ outputPath,
188
+ totalFrames,
189
+ { ...encoding, fps }
190
+ );
191
+
192
+ // ─── 9. Cleanup frames ────────────────────────────────────────
193
+ cleanupFrames(framesDir);
194
+
195
+ const captureTimeSec = ((Date.now() - startTime) / 1000).toFixed(1);
196
+ logger.info(`Video ready: ${encodeResult.outputPath} (${encodeResult.sizeMB} MB, ${captureTimeSec}s total)`);
197
+
198
+ return {
199
+ success: true,
200
+ video: {
201
+ path: encodeResult.outputPath,
202
+ format: encodeResult.format,
203
+ codec: encodeResult.codec,
204
+ fps: encodeResult.fps,
205
+ duration: encodeResult.duration,
206
+ totalFrames: encodeResult.totalFrames,
207
+ resolution: { width: viewport.width, height: viewport.height },
208
+ sizeBytes: encodeResult.sizeBytes,
209
+ sizeMB: encodeResult.sizeMB,
210
+ },
211
+ thumbnail: {
212
+ path: thumbnailPath,
213
+ width: viewport.width,
214
+ height: viewport.height,
215
+ },
216
+ scenes: scenes.length,
217
+ url,
218
+ captureTime: `${captureTimeSec}s`,
219
+ };
220
+ } catch (error) {
221
+ // Cleanup on error
222
+ cleanupFrames(framesDir);
223
+ if (browser) {
224
+ try { await browser.close(); } catch { /* ignore */ }
225
+ }
226
+
227
+ const message = error instanceof Error ? error.message : String(error);
228
+ logger.error(`Recording failed: ${message}`);
229
+ throw new Error(`Recording failed: ${message}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Dismiss cookie banners, fixed overlays, and popups
235
+ * Uses multiple strategies: localStorage pre-set, button clicks, CSS hiding
236
+ */
237
+ async function dismissPageOverlays(page: Page): Promise<void> {
238
+ logger.info('Dismissing overlays and cookie banners...');
239
+
240
+ // Strategy 1: Pre-set common cookie consent localStorage/cookie values
241
+ // This prevents banners from appearing in the first place
242
+ await page.evaluate(() => {
243
+ // Common cookie consent localStorage key
244
+ localStorage.setItem('cookie-consent', 'accepted');
245
+ // Common cookie consent libraries
246
+ localStorage.setItem('cookieConsent', 'accepted');
247
+ localStorage.setItem('cookie_consent', 'true');
248
+ localStorage.setItem('cookies-accepted', 'true');
249
+ localStorage.setItem('gdpr-consent', 'true');
250
+ localStorage.setItem('CookieConsent', 'true');
251
+ // CookieBot
252
+ localStorage.setItem('CookieConsentV2', '{"stamp":"","necessary":true,"preferences":true,"statistics":true,"marketing":true}');
253
+ // OneTrust
254
+ localStorage.setItem('OptanonAlertBoxClosed', new Date().toISOString());
255
+
256
+ // Set cookies too
257
+ document.cookie = 'cookie-consent=accepted; path=/; max-age=31536000';
258
+ document.cookie = 'cookieconsent_status=dismiss; path=/; max-age=31536000';
259
+
260
+ // Dispatch custom consent event
261
+ window.dispatchEvent(new Event('cookie-consent-accepted'));
262
+ });
263
+
264
+ await page.waitForTimeout(300);
265
+
266
+ // Strategy 2: Try clicking accept buttons (multilingual)
267
+ const acceptButtonSelectors = [
268
+ // By text content (most reliable)
269
+ 'button:has-text("Akzeptieren")',
270
+ 'button:has-text("Accept")',
271
+ 'button:has-text("Aceptar")',
272
+ 'button:has-text("Accept all")',
273
+ 'button:has-text("Alle akzeptieren")',
274
+ 'button:has-text("Got it")',
275
+ 'button:has-text("OK")',
276
+ 'button:has-text("Verstanden")',
277
+ ];
278
+
279
+ for (const selector of acceptButtonSelectors) {
280
+ try {
281
+ const btn = page.locator(selector).first();
282
+ if (await btn.isVisible({ timeout: 500 }).catch(() => false)) {
283
+ await btn.click({ timeout: 1000 });
284
+ logger.info(`Clicked consent button: ${selector}`);
285
+ break;
286
+ }
287
+ } catch {
288
+ // Button not found, try next
289
+ }
290
+ }
291
+
292
+ // Strategy 3: Click by CSS selectors (fallback)
293
+ await page.evaluate(() => {
294
+ const cssSelectors = [
295
+ '[class*="cookie"] button',
296
+ '[class*="consent"] button',
297
+ '[id*="cookie"] button',
298
+ '[id*="consent"] button',
299
+ 'button[class*="accept"]',
300
+ 'button[class*="Accept"]',
301
+ '[data-testid*="cookie"] button',
302
+ ];
303
+
304
+ for (const sel of cssSelectors) {
305
+ const btn = document.querySelector<HTMLElement>(sel);
306
+ if (btn) {
307
+ btn.click();
308
+ break;
309
+ }
310
+ }
311
+ });
312
+
313
+ await page.waitForTimeout(500);
314
+
315
+ // Strategy 4: Force hide any remaining overlays via CSS
316
+ await page.addStyleTag({
317
+ content: `
318
+ [class*="cookie"], [class*="Cookie"],
319
+ [class*="consent"], [class*="Consent"],
320
+ [class*="popup"]:not([class*="menu"]),
321
+ [class*="Popup"]:not([class*="menu"]),
322
+ [id*="cookie"], [id*="consent"],
323
+ [role="dialog"],
324
+ [class*="banner"]:not(header):not(nav):not([class*="hero"]),
325
+ .fixed.bottom-0.left-0.right-0.z-50 {
326
+ display: none !important;
327
+ visibility: hidden !important;
328
+ opacity: 0 !important;
329
+ pointer-events: none !important;
330
+ }
331
+ `,
332
+ });
333
+
334
+ logger.info('Overlays dismissed');
335
+ }
336
+
337
+ /**
338
+ * Pre-scroll entire page to trigger all lazy-loaded content
339
+ */
340
+ async function preloadAllContent(page: Page, viewportHeight: number): Promise<void> {
341
+ logger.info('Pre-scrolling to trigger lazy content...');
342
+
343
+ const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
344
+ const steps = Math.ceil(scrollHeight / (viewportHeight * 0.7));
345
+
346
+ for (let i = 0; i <= steps; i++) {
347
+ const y = Math.min(i * viewportHeight * 0.7, scrollHeight);
348
+ await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
349
+ await page.waitForTimeout(200);
350
+
351
+ // Wait for any network requests to settle
352
+ await page.waitForLoadState('networkidle').catch(() => {});
353
+ }
354
+
355
+ // Scroll back to top
356
+ await page.evaluate(() => window.scrollTo(0, 0));
357
+ await page.waitForTimeout(500);
358
+
359
+ logger.info('Lazy content preloaded');
360
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Chroma Key Engine — Green screen removal and background replacement.
3
+ *
4
+ * Supports:
5
+ * - chromakey (YUV space, best for green/blue screens)
6
+ * - colorkey (RGB space, best for arbitrary key colors)
7
+ * - despill (removes green/blue color spill on edges)
8
+ * - Composite onto replacement background (video, image, or solid color)
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
+
16
+ // ─── Types ──────────────────────────────────────────────────────────
17
+
18
+ export interface ChromaKeyConfig {
19
+ /** Input video with green/blue screen */
20
+ inputPath: string;
21
+ outputPath: string;
22
+ /** Key color in hex (e.g., '00FF00' for green, '0000FF' for blue). Default: 00FF00 */
23
+ keyColor?: string;
24
+ /** How close a color must be to the key: 0.01-1.0. Higher = more removal. Default: 0.15 */
25
+ similarity?: number;
26
+ /** Edge softness: 0.0-1.0. Keep low (0.0-0.08) or entire frame becomes transparent. Default: 0.02 */
27
+ blend?: number;
28
+ /** Enable despill to remove green/blue color contamination on edges. Default: true */
29
+ despill?: boolean;
30
+ /** Background replacement — video file, image file, or hex color (e.g., '000000' for black). Required. */
31
+ background: string;
32
+ /** Use colorkey (RGB) instead of chromakey (YUV). Default: false (chromakey) */
33
+ useColorkey?: boolean;
34
+ }
35
+
36
+ // ─── Helpers ────────────────────────────────────────────────────────
37
+
38
+ function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
39
+ return new Promise((resolve, reject) => {
40
+ execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
41
+ if (error) {
42
+ logger.error(`ffmpeg failed: ${stderr}`);
43
+ reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
44
+ return;
45
+ }
46
+ resolve(stdout);
47
+ });
48
+ });
49
+ }
50
+
51
+ function ensureDir(filePath: string): void {
52
+ const dir = path.dirname(filePath);
53
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
54
+ }
55
+
56
+ function assertExists(filePath: string, label = 'File'): void {
57
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
58
+ }
59
+
60
+ function fileInfo(filePath: string): string {
61
+ const stats = fs.statSync(filePath);
62
+ return `${(stats.size / 1024 / 1024).toFixed(2)} MB`;
63
+ }
64
+
65
+ function isHexColor(s: string): boolean {
66
+ return /^[0-9a-fA-F]{6}$/.test(s);
67
+ }
68
+
69
+ function isImageFile(filePath: string): boolean {
70
+ const ext = path.extname(filePath).toLowerCase();
71
+ return ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff'].includes(ext);
72
+ }
73
+
74
+ // ─── Main Function ──────────────────────────────────────────────────
75
+
76
+ export async function applyChromaKey(config: ChromaKeyConfig): Promise<string> {
77
+ const {
78
+ inputPath,
79
+ outputPath,
80
+ keyColor = '00FF00',
81
+ similarity = 0.15,
82
+ blend = 0.02,
83
+ despill: enableDespill = true,
84
+ background,
85
+ useColorkey = false,
86
+ } = config;
87
+
88
+ assertExists(inputPath, 'Input video');
89
+ ensureDir(outputPath);
90
+
91
+ // Validate key color
92
+ const cleanColor = keyColor.replace(/^#|^0x/, '');
93
+ if (!isHexColor(cleanColor)) {
94
+ throw new Error(`Invalid key color: ${keyColor}. Use 6-digit hex (e.g., 00FF00 for green)`);
95
+ }
96
+
97
+ const sim = Math.max(0.01, Math.min(1, similarity));
98
+ const bld = Math.max(0, Math.min(0.1, blend)); // Cap at 0.1 to prevent full-frame transparency
99
+
100
+ logger.info(`Chroma key: color=0x${cleanColor}, sim=${sim}, blend=${bld}, despill=${enableDespill}, mode=${useColorkey ? 'colorkey' : 'chromakey'}`);
101
+
102
+ // Build the keying filter
103
+ const keyFilter = useColorkey
104
+ ? `colorkey=0x${cleanColor}:${sim}:${bld}`
105
+ : `chromakey=0x${cleanColor}:${sim}:${bld}`;
106
+
107
+ // Optional despill
108
+ const despillFilter = enableDespill
109
+ ? getDespillFilter(cleanColor)
110
+ : '';
111
+
112
+ const fgFilter = despillFilter
113
+ ? `${keyFilter},${despillFilter}`
114
+ : keyFilter;
115
+
116
+ // Determine background type
117
+ const isSolidColor = isHexColor(background.replace(/^#|^0x/, ''));
118
+ const isImage = !isSolidColor && isImageFile(background);
119
+ const isVideo = !isSolidColor && !isImage;
120
+
121
+ if (isVideo) assertExists(background, 'Background video');
122
+ if (isImage) assertExists(background, 'Background image');
123
+
124
+ let args: string[];
125
+
126
+ if (isSolidColor) {
127
+ // Solid color background — use color source
128
+ const bgColor = background.replace(/^#|^0x/, '');
129
+ const filterComplex = [
130
+ `color=c=0x${bgColor}:s=1920x1080:r=30[bg]`,
131
+ `[0:v]${fgFilter}[fg]`,
132
+ `[bg][fg]overlay=shortest=1[out]`,
133
+ ].join(';');
134
+
135
+ args = [
136
+ '-y', '-i', inputPath,
137
+ '-filter_complex', filterComplex,
138
+ '-map', '[out]', '-map', '0:a?',
139
+ '-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
140
+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
141
+ outputPath,
142
+ ];
143
+ } else if (isImage) {
144
+ // Image background — use loop to make it a stream
145
+ const filterComplex = [
146
+ `[1:v]${fgFilter}[fg]`,
147
+ `[0:v][fg]overlay=shortest=1[out]`,
148
+ ].join(';');
149
+
150
+ args = [
151
+ '-y',
152
+ '-loop', '1', '-i', background,
153
+ '-i', inputPath,
154
+ '-filter_complex', filterComplex,
155
+ '-map', '[out]', '-map', '1:a?',
156
+ '-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
157
+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
158
+ '-shortest',
159
+ outputPath,
160
+ ];
161
+ } else {
162
+ // Video background
163
+ const filterComplex = [
164
+ `[1:v]${fgFilter}[fg]`,
165
+ `[0:v][fg]overlay=shortest=1[out]`,
166
+ ].join(';');
167
+
168
+ args = [
169
+ '-y',
170
+ '-i', background,
171
+ '-i', inputPath,
172
+ '-filter_complex', filterComplex,
173
+ '-map', '[out]', '-map', '1:a?',
174
+ '-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
175
+ '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
176
+ '-shortest',
177
+ outputPath,
178
+ ];
179
+ }
180
+
181
+ await runFfmpeg(args, 600_000); // Longer timeout for compositing
182
+ logger.info(`Chroma key applied: ${outputPath} (${fileInfo(outputPath)})`);
183
+ return outputPath;
184
+ }
185
+
186
+ /** Get despill filter based on key color */
187
+ function getDespillFilter(hexColor: string): string {
188
+ const r = parseInt(hexColor.substring(0, 2), 16);
189
+ const g = parseInt(hexColor.substring(2, 4), 16);
190
+ const b = parseInt(hexColor.substring(4, 6), 16);
191
+
192
+ // Determine dominant channel for despill
193
+ if (g > r && g > b) {
194
+ // Green screen — despill green
195
+ return 'despill=type=0:mix=0.5:green=-1';
196
+ } else if (b > r && b > g) {
197
+ // Blue screen — despill blue
198
+ return 'despill=type=0:mix=0.5:blue=-1';
199
+ }
200
+ // For other colors, skip despill
201
+ return '';
202
+ }