@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,267 @@
1
+ /**
2
+ * Narrated Video Engine
3
+ * Combines TTS voice generation with website recording
4
+ * for fully automated explainer videos
5
+ *
6
+ * Flow:
7
+ * 1. Generate speech from script segments
8
+ * 2. Record website scenes synchronized to speech durations
9
+ * 3. Merge video + audio into final output
10
+ */
11
+
12
+ import { execFile } from 'child_process';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { logger } from '../../lib/logger.js';
16
+ import { generateSpeech } from './tts.js';
17
+ import type { TTSProvider, ElevenLabsVoice, ElevenLabsModel, OpenAIVoice, OpenAIModel } from './tts.js';
18
+ import { recordWebsite } from './capture.js';
19
+ import type { Scene, ViewportPreset } from './types.js';
20
+ import { getMediaDuration } from './audio.js';
21
+
22
+ // ─── Types ──────────────────────────────────────────────────────────
23
+
24
+ export interface NarrationSegment {
25
+ /** Text to speak for this segment */
26
+ text: string;
27
+ /** Scene action during this segment */
28
+ scene: Scene;
29
+ /** Extra padding time after speech (seconds, default: 0.5) */
30
+ paddingAfter?: number;
31
+ }
32
+
33
+ export interface NarratedVideoConfig {
34
+ /** URL to record */
35
+ url: string;
36
+ /** Narration script segments */
37
+ segments: NarrationSegment[];
38
+ /** Output path (without extension) */
39
+ outputPath: string;
40
+ /** TTS provider (default: elevenlabs) */
41
+ provider?: TTSProvider;
42
+ /** Language (default: en) */
43
+ language?: string;
44
+ /** Viewport (default: desktop) */
45
+ viewport?: ViewportPreset;
46
+
47
+ // Voice settings
48
+ /** ElevenLabs voice (default: adam) */
49
+ elevenLabsVoice?: ElevenLabsVoice | string;
50
+ /** ElevenLabs model */
51
+ elevenLabsModel?: ElevenLabsModel;
52
+ /** OpenAI voice */
53
+ openaiVoice?: OpenAIVoice;
54
+ /** OpenAI model */
55
+ openaiModel?: OpenAIModel;
56
+ /** Speaking speed (default: 1.0) */
57
+ speed?: number;
58
+
59
+ /** Music volume if background music provided (default: 0.1) */
60
+ backgroundMusicVolume?: number;
61
+ /** Background music path (optional) */
62
+ backgroundMusicPath?: string;
63
+ }
64
+
65
+ export interface NarratedVideoResult {
66
+ success: boolean;
67
+ video: {
68
+ path: string;
69
+ duration: number;
70
+ sizeMB: string;
71
+ };
72
+ audio: {
73
+ totalSegments: number;
74
+ totalDuration: number;
75
+ provider: TTSProvider;
76
+ };
77
+ url: string;
78
+ }
79
+
80
+ // ─── Main Function ──────────────────────────────────────────────────
81
+
82
+ export async function createNarratedVideo(
83
+ config: NarratedVideoConfig
84
+ ): Promise<NarratedVideoResult> {
85
+ const {
86
+ url,
87
+ segments,
88
+ outputPath,
89
+ provider = 'elevenlabs',
90
+ language = 'en',
91
+ viewport = 'desktop',
92
+ } = config;
93
+
94
+ const tempDir = `/tmp/narrated-video-${Date.now()}`;
95
+ fs.mkdirSync(tempDir, { recursive: true });
96
+
97
+ try {
98
+ // ─── Step 1: Generate all speech segments ───────────────────
99
+ logger.info(`Generating ${segments.length} speech segment(s)...`);
100
+ const audioPaths: string[] = [];
101
+ const audioDurations: number[] = [];
102
+
103
+ for (let i = 0; i < segments.length; i++) {
104
+ const seg = segments[i];
105
+ const audioPath = path.join(tempDir, `segment-${String(i).padStart(3, '0')}.mp3`);
106
+
107
+ const ttsResult = await generateSpeech({
108
+ text: seg.text,
109
+ outputPath: audioPath,
110
+ provider,
111
+ language,
112
+ elevenLabsVoice: config.elevenLabsVoice,
113
+ elevenLabsModel: config.elevenLabsModel,
114
+ openaiVoice: config.openaiVoice,
115
+ openaiModel: config.openaiModel,
116
+ speed: config.speed,
117
+ });
118
+
119
+ audioPaths.push(ttsResult.audioPath);
120
+ audioDurations.push(ttsResult.duration);
121
+ logger.info(`Segment ${i + 1}: "${seg.text.slice(0, 50)}..." → ${ttsResult.duration.toFixed(1)}s`);
122
+ }
123
+
124
+ // ─── Step 2: Concatenate audio segments ─────────────────────
125
+ logger.info('Concatenating audio segments...');
126
+ const fullAudioPath = path.join(tempDir, 'full-narration.mp3');
127
+ await concatenateAudio(audioPaths, fullAudioPath);
128
+
129
+ const totalAudioDuration = await getMediaDuration(fullAudioPath);
130
+ logger.info(`Total narration: ${totalAudioDuration.toFixed(1)}s`);
131
+
132
+ // ─── Step 3: Build scenes with matched durations ────────────
133
+ logger.info('Building synchronized scenes...');
134
+ const scenes: Scene[] = [];
135
+
136
+ for (let i = 0; i < segments.length; i++) {
137
+ const seg = segments[i];
138
+ const padding = seg.paddingAfter ?? 0.5;
139
+ const sceneDuration = audioDurations[i] + padding;
140
+
141
+ // Override scene duration to match audio
142
+ const scene = { ...seg.scene };
143
+ if ('duration' in scene) {
144
+ scene.duration = sceneDuration;
145
+ }
146
+
147
+ scenes.push(scene);
148
+ }
149
+
150
+ // ─── Step 4: Record website with synced scenes ──────────────
151
+ logger.info('Recording website with synchronized scenes...');
152
+ const videoOnlyPath = path.join(tempDir, 'video-only');
153
+
154
+ const recordResult = await recordWebsite({
155
+ url,
156
+ outputPath: videoOnlyPath,
157
+ viewport,
158
+ fps: 60,
159
+ scenes,
160
+ cursor: { enabled: false },
161
+ encoding: { codec: 'h264', crf: 18 },
162
+ });
163
+
164
+ // ─── Step 5: Merge video + audio ────────────────────────────
165
+ logger.info('Merging video + narration...');
166
+ const finalPath = outputPath.endsWith('.mp4') ? outputPath : `${outputPath}.mp4`;
167
+ const outDir = path.dirname(finalPath);
168
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
169
+
170
+ const mergeArgs: string[] = [
171
+ '-y',
172
+ '-i', recordResult.video.path,
173
+ '-i', fullAudioPath,
174
+ ];
175
+
176
+ // Optional background music
177
+ if (config.backgroundMusicPath && fs.existsSync(config.backgroundMusicPath)) {
178
+ const musicVol = config.backgroundMusicVolume ?? 0.1;
179
+ mergeArgs.push('-stream_loop', '-1', '-i', config.backgroundMusicPath);
180
+
181
+ // Mix narration + background music
182
+ mergeArgs.push(
183
+ '-filter_complex',
184
+ `[1:a]volume=1.0[narration];[2:a]volume=${musicVol},afade=t=in:st=0:d=2[music];[narration][music]amix=inputs=2:duration=first[aout]`,
185
+ '-map', '0:v',
186
+ '-map', '[aout]',
187
+ );
188
+ } else {
189
+ mergeArgs.push('-map', '0:v', '-map', '1:a');
190
+ }
191
+
192
+ mergeArgs.push(
193
+ '-c:v', 'copy',
194
+ '-c:a', 'aac', '-b:a', '192k',
195
+ '-shortest',
196
+ '-movflags', '+faststart',
197
+ finalPath,
198
+ );
199
+
200
+ await runFfmpeg(mergeArgs);
201
+
202
+ const finalStats = fs.statSync(finalPath);
203
+ const finalDuration = await getMediaDuration(finalPath);
204
+
205
+ // ─── Cleanup ────────────────────────────────────────────────
206
+ fs.rmSync(tempDir, { recursive: true, force: true });
207
+
208
+ logger.info(`Narrated video ready: ${finalPath} (${finalDuration.toFixed(1)}s, ${(finalStats.size / 1024 / 1024).toFixed(2)} MB)`);
209
+
210
+ return {
211
+ success: true,
212
+ video: {
213
+ path: finalPath,
214
+ duration: finalDuration,
215
+ sizeMB: (finalStats.size / 1024 / 1024).toFixed(2),
216
+ },
217
+ audio: {
218
+ totalSegments: segments.length,
219
+ totalDuration: totalAudioDuration,
220
+ provider,
221
+ },
222
+ url,
223
+ };
224
+ } catch (error) {
225
+ // Cleanup on error
226
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ // ─── Helpers ────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Concatenate multiple audio files using ffmpeg concat demuxer
235
+ */
236
+ async function concatenateAudio(files: string[], outputPath: string): Promise<void> {
237
+ // Create concat list file
238
+ const listPath = outputPath + '.txt';
239
+ const listContent = files.map((f) => `file '${f}'`).join('\n');
240
+ fs.writeFileSync(listPath, listContent);
241
+
242
+ await runFfmpeg([
243
+ '-y',
244
+ '-f', 'concat',
245
+ '-safe', '0',
246
+ '-i', listPath,
247
+ '-c:a', 'libmp3lame',
248
+ '-b:a', '192k',
249
+ outputPath,
250
+ ]);
251
+
252
+ // Cleanup list file
253
+ fs.unlinkSync(listPath);
254
+ }
255
+
256
+ function runFfmpeg(args: string[]): Promise<string> {
257
+ return new Promise((resolve, reject) => {
258
+ execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
259
+ if (error) {
260
+ logger.error(`ffmpeg failed: ${stderr}`);
261
+ reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
262
+ return;
263
+ }
264
+ resolve(stdout);
265
+ });
266
+ });
267
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Scene execution engine — processes scene definitions into frame captures
3
+ */
4
+
5
+ import type { Page } from 'playwright';
6
+ import type { Scene, ScrollScene, HoverScene, ClickScene, TypeScene, WaitScene, EasingName } from './types.js';
7
+ import { applyEasing } from './easing.js';
8
+ import { moveCursorToElement, animateClick } from './cursor.js';
9
+ import { logger } from '../../lib/logger.js';
10
+
11
+ interface FrameCallback {
12
+ (frameIndex: number): Promise<void>;
13
+ }
14
+
15
+ /**
16
+ * Execute all scenes and capture frames
17
+ */
18
+ export async function executeScenes(
19
+ page: Page,
20
+ scenes: Scene[],
21
+ fps: number,
22
+ captureFrame: FrameCallback,
23
+ cursorEnabled: boolean
24
+ ): Promise<number> {
25
+ let totalFramesCaptured = 0;
26
+
27
+ for (let i = 0; i < scenes.length; i++) {
28
+ const scene = scenes[i];
29
+ logger.info(`Scene ${i + 1}/${scenes.length}: ${scene.type}`);
30
+
31
+ let framesInScene = 0;
32
+
33
+ switch (scene.type) {
34
+ case 'scroll':
35
+ framesInScene = await executeScrollScene(page, scene, fps, async (fi) => {
36
+ await captureFrame(totalFramesCaptured + fi);
37
+ });
38
+ break;
39
+
40
+ case 'pause':
41
+ framesInScene = await executePauseScene(page, scene.duration, fps, async (fi) => {
42
+ await captureFrame(totalFramesCaptured + fi);
43
+ });
44
+ break;
45
+
46
+ case 'hover':
47
+ framesInScene = await executeHoverScene(page, scene, fps, cursorEnabled, async (fi) => {
48
+ await captureFrame(totalFramesCaptured + fi);
49
+ });
50
+ break;
51
+
52
+ case 'click':
53
+ framesInScene = await executeClickScene(page, scene, fps, cursorEnabled, async (fi) => {
54
+ await captureFrame(totalFramesCaptured + fi);
55
+ });
56
+ break;
57
+
58
+ case 'type':
59
+ framesInScene = await executeTypeScene(page, scene, fps, async (fi) => {
60
+ await captureFrame(totalFramesCaptured + fi);
61
+ });
62
+ break;
63
+
64
+ case 'wait':
65
+ framesInScene = await executeWaitScene(page, scene, fps, async (fi) => {
66
+ await captureFrame(totalFramesCaptured + fi);
67
+ });
68
+ break;
69
+ }
70
+
71
+ totalFramesCaptured += framesInScene;
72
+ logger.debug(`Scene ${i + 1} captured ${framesInScene} frames (total: ${totalFramesCaptured})`);
73
+ }
74
+
75
+ return totalFramesCaptured;
76
+ }
77
+
78
+ /**
79
+ * Execute a scroll scene — the core of cinema-quality videos
80
+ */
81
+ async function executeScrollScene(
82
+ page: Page,
83
+ scene: ScrollScene,
84
+ fps: number,
85
+ captureFrame: FrameCallback
86
+ ): Promise<number> {
87
+ const easingName: EasingName = scene.easing ?? 'easeInOutCubic';
88
+ const totalFrames = Math.ceil(scene.duration * fps);
89
+
90
+ // Resolve scroll target
91
+ const scrollInfo = await page.evaluate((target) => {
92
+ const docHeight = document.documentElement.scrollHeight;
93
+ const viewportHeight = window.innerHeight;
94
+ const currentScroll = window.scrollY;
95
+ const maxScroll = Math.max(0, docHeight - viewportHeight);
96
+
97
+ if (target === 'bottom') return { from: currentScroll, to: maxScroll };
98
+ if (target === 'top') return { from: currentScroll, to: 0 };
99
+ if (typeof target === 'number') return { from: currentScroll, to: Math.min(target, maxScroll) };
100
+
101
+ // CSS selector — scroll to element
102
+ const el = document.querySelector(target);
103
+ if (el) {
104
+ const rect = el.getBoundingClientRect();
105
+ const targetY = Math.min(currentScroll + rect.top - 100, maxScroll);
106
+ return { from: currentScroll, to: targetY };
107
+ }
108
+
109
+ return { from: currentScroll, to: maxScroll };
110
+ }, scene.to);
111
+
112
+ const scrollDistance = scrollInfo.to - scrollInfo.from;
113
+
114
+ for (let frame = 0; frame <= totalFrames; frame++) {
115
+ const progress = frame / totalFrames;
116
+ const easedScroll = scrollInfo.from + applyEasing(progress, scrollDistance, easingName);
117
+
118
+ await page.evaluate((y) => window.scrollTo(0, y), easedScroll);
119
+
120
+ // Small delay to let scroll-triggered animations render
121
+ await page.waitForTimeout(8);
122
+
123
+ await captureFrame(frame);
124
+ }
125
+
126
+ return totalFrames + 1;
127
+ }
128
+
129
+ /**
130
+ * Execute a pause scene — captures static frames
131
+ */
132
+ async function executePauseScene(
133
+ _page: Page,
134
+ duration: number,
135
+ fps: number,
136
+ captureFrame: FrameCallback
137
+ ): Promise<number> {
138
+ const totalFrames = Math.ceil(duration * fps);
139
+
140
+ for (let frame = 0; frame < totalFrames; frame++) {
141
+ await captureFrame(frame);
142
+ }
143
+
144
+ return totalFrames;
145
+ }
146
+
147
+ /**
148
+ * Execute a hover scene — move cursor to element and hold
149
+ */
150
+ async function executeHoverScene(
151
+ page: Page,
152
+ scene: HoverScene,
153
+ fps: number,
154
+ cursorEnabled: boolean,
155
+ captureFrame: FrameCallback
156
+ ): Promise<number> {
157
+ let frameCount = 0;
158
+
159
+ // Move cursor to element (animated)
160
+ if (cursorEnabled && scene.animateCursor !== false) {
161
+ const moveDuration = 600; // ms
162
+ const moveFrames = Math.ceil((moveDuration / 1000) * fps);
163
+
164
+ await moveCursorToElement(page, scene.selector, moveDuration, fps);
165
+
166
+ // Capture frames during cursor movement — take snapshots
167
+ for (let i = 0; i < moveFrames; i++) {
168
+ await captureFrame(frameCount++);
169
+ }
170
+ } else {
171
+ // Instant hover
172
+ const pos = await page.evaluate((sel) => {
173
+ const el = document.querySelector(sel);
174
+ if (!el) return null;
175
+ const rect = el.getBoundingClientRect();
176
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
177
+ }, scene.selector);
178
+
179
+ if (pos) {
180
+ await page.mouse.move(pos.x, pos.y);
181
+ }
182
+ }
183
+
184
+ // Hold hover — capture frames
185
+ const holdFrames = Math.ceil(scene.duration * fps);
186
+ for (let i = 0; i < holdFrames; i++) {
187
+ await captureFrame(frameCount++);
188
+ }
189
+
190
+ return frameCount;
191
+ }
192
+
193
+ /**
194
+ * Execute a click scene
195
+ */
196
+ async function executeClickScene(
197
+ page: Page,
198
+ scene: ClickScene,
199
+ fps: number,
200
+ cursorEnabled: boolean,
201
+ captureFrame: FrameCallback
202
+ ): Promise<number> {
203
+ let frameCount = 0;
204
+
205
+ // Move cursor to element
206
+ if (cursorEnabled) {
207
+ const moveFrames = Math.ceil(0.5 * fps);
208
+ await moveCursorToElement(page, scene.selector, 500, fps);
209
+ for (let i = 0; i < moveFrames; i++) {
210
+ await captureFrame(frameCount++);
211
+ }
212
+ }
213
+
214
+ // Click animation
215
+ if (cursorEnabled) {
216
+ await animateClick(page);
217
+ }
218
+
219
+ // Actual click
220
+ await page.click(scene.selector);
221
+
222
+ // Wait for navigation/content
223
+ if (scene.waitFor === 'networkidle') {
224
+ await page.waitForLoadState('networkidle').catch(() => {});
225
+ } else if (scene.waitFor === 'load') {
226
+ await page.waitForLoadState('load').catch(() => {});
227
+ } else if (typeof scene.waitFor === 'number') {
228
+ await page.waitForTimeout(scene.waitFor);
229
+ }
230
+
231
+ // Pause after click
232
+ const pauseDuration = scene.pauseAfter ?? 1;
233
+ const pauseFrames = Math.ceil(pauseDuration * fps);
234
+ for (let i = 0; i < pauseFrames; i++) {
235
+ await captureFrame(frameCount++);
236
+ }
237
+
238
+ return frameCount;
239
+ }
240
+
241
+ /**
242
+ * Execute a type scene
243
+ */
244
+ async function executeTypeScene(
245
+ page: Page,
246
+ scene: TypeScene,
247
+ fps: number,
248
+ captureFrame: FrameCallback
249
+ ): Promise<number> {
250
+ let frameCount = 0;
251
+ const delay = scene.delay ?? 80;
252
+ const framesPerKeystroke = Math.max(1, Math.ceil((delay / 1000) * fps));
253
+
254
+ // Focus the input
255
+ await page.click(scene.selector);
256
+ await captureFrame(frameCount++);
257
+
258
+ // Type character by character
259
+ for (const char of scene.text) {
260
+ await page.keyboard.type(char, { delay: 0 });
261
+
262
+ for (let i = 0; i < framesPerKeystroke; i++) {
263
+ await captureFrame(frameCount++);
264
+ }
265
+ }
266
+
267
+ return frameCount;
268
+ }
269
+
270
+ /**
271
+ * Execute a wait scene — wait for selector, capturing frames
272
+ */
273
+ async function executeWaitScene(
274
+ page: Page,
275
+ scene: WaitScene,
276
+ fps: number,
277
+ captureFrame: FrameCallback
278
+ ): Promise<number> {
279
+ const timeout = scene.timeout ?? 5000;
280
+ const maxFrames = Math.ceil((timeout / 1000) * fps);
281
+ let frameCount = 0;
282
+
283
+ const waitPromise = page.waitForSelector(scene.selector, { timeout }).catch(() => null);
284
+
285
+ // Capture frames while waiting
286
+ const startTime = Date.now();
287
+ while (Date.now() - startTime < timeout && frameCount < maxFrames) {
288
+ await captureFrame(frameCount++);
289
+ await page.waitForTimeout(Math.floor(1000 / fps));
290
+
291
+ // Check if element appeared
292
+ const found = await page.$(scene.selector);
293
+ if (found) break;
294
+ }
295
+
296
+ await waitPromise;
297
+ return frameCount;
298
+ }
299
+
300
+ /**
301
+ * Create default scenes for a simple scroll-through video
302
+ */
303
+ export function createDefaultScenes(scrollDuration: number = 18): Scene[] {
304
+ return [
305
+ { type: 'pause', duration: 1.5 },
306
+ { type: 'scroll', to: 'bottom', duration: scrollDuration, easing: 'showcase' },
307
+ { type: 'pause', duration: 2 },
308
+ ];
309
+ }