@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,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
|
+
}
|