@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,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video concatenation engine — merge multiple clips with cinematic transitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { logger } from '../../lib/logger.js';
|
|
9
|
+
import { getMediaDuration } from './audio.js';
|
|
10
|
+
|
|
11
|
+
// ─── Available Transitions ──────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export const TRANSITIONS = [
|
|
14
|
+
'fade', 'fadeblack', 'fadewhite', 'dissolve',
|
|
15
|
+
'wipeleft', 'wiperight', 'wipeup', 'wipedown',
|
|
16
|
+
'slideleft', 'slideright', 'slideup', 'slidedown',
|
|
17
|
+
'smoothleft', 'smoothright', 'smoothup', 'smoothdown',
|
|
18
|
+
'circlecrop', 'circleopen', 'circleclose',
|
|
19
|
+
'rectcrop', 'vertopen', 'vertclose', 'horzopen', 'horzclose',
|
|
20
|
+
'diagtl', 'diagtr', 'diagbl', 'diagbr',
|
|
21
|
+
'hlslice', 'hrslice', 'vuslice', 'vdslice',
|
|
22
|
+
'radial', 'pixelize',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type TransitionType = typeof TRANSITIONS[number];
|
|
26
|
+
|
|
27
|
+
// ─── Concatenation ──────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface ConcatClip {
|
|
30
|
+
/** Path to video file */
|
|
31
|
+
path: string;
|
|
32
|
+
/** Optional: trim start time (seconds) */
|
|
33
|
+
trimStart?: number;
|
|
34
|
+
/** Optional: trim end time (seconds) */
|
|
35
|
+
trimEnd?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ConcatConfig {
|
|
39
|
+
/** Video clips to concatenate (in order) */
|
|
40
|
+
clips: ConcatClip[];
|
|
41
|
+
/** Output path */
|
|
42
|
+
outputPath: string;
|
|
43
|
+
/** Transition between clips (default: fade) */
|
|
44
|
+
transition?: TransitionType;
|
|
45
|
+
/** Transition duration in seconds (default: 1) */
|
|
46
|
+
transitionDuration?: number;
|
|
47
|
+
/** Normalize all clips to this resolution (default: 1920x1080) */
|
|
48
|
+
targetWidth?: number;
|
|
49
|
+
targetHeight?: number;
|
|
50
|
+
/** Target FPS (default: 60) */
|
|
51
|
+
targetFps?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function concatenateVideos(config: ConcatConfig): Promise<string> {
|
|
55
|
+
const {
|
|
56
|
+
clips,
|
|
57
|
+
outputPath,
|
|
58
|
+
transition = 'fade',
|
|
59
|
+
transitionDuration = 1,
|
|
60
|
+
targetWidth = 1920,
|
|
61
|
+
targetHeight = 1080,
|
|
62
|
+
targetFps = 60,
|
|
63
|
+
} = config;
|
|
64
|
+
|
|
65
|
+
if (clips.length === 0) throw new Error('No clips provided');
|
|
66
|
+
|
|
67
|
+
if (clips.length === 1) {
|
|
68
|
+
fs.copyFileSync(clips[0].path, outputPath);
|
|
69
|
+
return outputPath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Verify all clips exist
|
|
73
|
+
for (const clip of clips) {
|
|
74
|
+
if (!fs.existsSync(clip.path)) throw new Error(`Clip not found: ${clip.path}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Get durations for each clip
|
|
78
|
+
const durations: number[] = [];
|
|
79
|
+
for (const clip of clips) {
|
|
80
|
+
let dur = await getMediaDuration(clip.path);
|
|
81
|
+
if (clip.trimStart) dur -= clip.trimStart;
|
|
82
|
+
if (clip.trimEnd) dur = Math.min(dur, clip.trimEnd - (clip.trimStart ?? 0));
|
|
83
|
+
durations.push(dur);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
logger.info(`Concatenating ${clips.length} clips (${durations.map(d => d.toFixed(1) + 's').join(' + ')}) with ${transition} transition`);
|
|
87
|
+
|
|
88
|
+
const inputs: string[] = [];
|
|
89
|
+
const filterParts: string[] = [];
|
|
90
|
+
|
|
91
|
+
// Add inputs
|
|
92
|
+
for (const clip of clips) {
|
|
93
|
+
if (clip.trimStart !== undefined || clip.trimEnd !== undefined) {
|
|
94
|
+
if (clip.trimStart) inputs.push('-ss', String(clip.trimStart));
|
|
95
|
+
if (clip.trimEnd) inputs.push('-to', String(clip.trimEnd));
|
|
96
|
+
}
|
|
97
|
+
inputs.push('-i', clip.path);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Normalize all inputs to same resolution and fps
|
|
101
|
+
for (let i = 0; i < clips.length; i++) {
|
|
102
|
+
filterParts.push(
|
|
103
|
+
`[${i}:v]scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease,` +
|
|
104
|
+
`pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black,` +
|
|
105
|
+
`setsar=1,fps=${targetFps}[v${i}]`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Chain xfade filters with correct offset calculation
|
|
110
|
+
let cumulativeDuration = durations[0];
|
|
111
|
+
let prevLabel = 'v0';
|
|
112
|
+
|
|
113
|
+
for (let i = 1; i < clips.length; i++) {
|
|
114
|
+
const offset = Math.max(0, cumulativeDuration - transitionDuration);
|
|
115
|
+
const outLabel = i === clips.length - 1 ? 'vout' : `xf${i}`;
|
|
116
|
+
|
|
117
|
+
filterParts.push(
|
|
118
|
+
`[${prevLabel}][v${i}]xfade=transition=${transition}:duration=${transitionDuration}:offset=${offset.toFixed(3)}[${outLabel}]`
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
cumulativeDuration += durations[i] - transitionDuration;
|
|
122
|
+
prevLabel = outLabel;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Ensure output dir exists
|
|
126
|
+
const outDir = path.dirname(outputPath);
|
|
127
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
128
|
+
|
|
129
|
+
const args = [
|
|
130
|
+
'-y',
|
|
131
|
+
...inputs,
|
|
132
|
+
'-filter_complex', filterParts.join(';'),
|
|
133
|
+
'-map', '[vout]',
|
|
134
|
+
'-c:v', 'libx264',
|
|
135
|
+
'-crf', '18',
|
|
136
|
+
'-preset', 'medium',
|
|
137
|
+
'-pix_fmt', 'yuv420p',
|
|
138
|
+
'-movflags', '+faststart',
|
|
139
|
+
outputPath,
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
await runFfmpeg(args);
|
|
143
|
+
|
|
144
|
+
const stats = fs.statSync(outputPath);
|
|
145
|
+
logger.info(`Concatenated: ${outputPath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
|
146
|
+
return outputPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Intro/Outro Generator ─────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface IntroConfig {
|
|
152
|
+
/** Text to display */
|
|
153
|
+
text: string;
|
|
154
|
+
/** Subtitle (optional) */
|
|
155
|
+
subtitle?: string;
|
|
156
|
+
/** Duration in seconds (default: 3) */
|
|
157
|
+
duration?: number;
|
|
158
|
+
/** Background color (default: #0a0a0a) */
|
|
159
|
+
backgroundColor?: string;
|
|
160
|
+
/** Text color (default: white) */
|
|
161
|
+
textColor?: string;
|
|
162
|
+
/** Resolution */
|
|
163
|
+
width?: number;
|
|
164
|
+
height?: number;
|
|
165
|
+
/** FPS (default: 60) */
|
|
166
|
+
fps?: number;
|
|
167
|
+
/** Output path */
|
|
168
|
+
outputPath: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function generateIntro(config: IntroConfig): Promise<string> {
|
|
172
|
+
const {
|
|
173
|
+
text,
|
|
174
|
+
subtitle,
|
|
175
|
+
duration = 3,
|
|
176
|
+
backgroundColor = '#0a0a0a',
|
|
177
|
+
textColor = 'white',
|
|
178
|
+
width = 1920,
|
|
179
|
+
height = 1080,
|
|
180
|
+
fps = 60,
|
|
181
|
+
outputPath,
|
|
182
|
+
} = config;
|
|
183
|
+
|
|
184
|
+
const outDir = path.dirname(outputPath);
|
|
185
|
+
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
// Find a usable font
|
|
188
|
+
const fontPath = await findFont();
|
|
189
|
+
|
|
190
|
+
// Build drawtext filter with fade animation
|
|
191
|
+
const escapedText = text.replace(/'/g, "\\\\'").replace(/:/g, '\\:');
|
|
192
|
+
let vf = `drawtext=text='${escapedText}':fontfile='${fontPath}':fontsize=72:fontcolor=${textColor}:x=(w-text_w)/2:y=(h-text_h)/2-40:alpha='if(lt(t\\,0.8)\\,t/0.8\\,if(gt(t\\,${duration - 0.8})\\,(${duration}-t)/0.8\\,1))'`;
|
|
193
|
+
|
|
194
|
+
if (subtitle) {
|
|
195
|
+
const escapedSub = subtitle.replace(/'/g, "\\\\'").replace(/:/g, '\\:');
|
|
196
|
+
vf += `,drawtext=text='${escapedSub}':fontfile='${fontPath}':fontsize=36:fontcolor=${textColor}@0.7:x=(w-text_w)/2:y=(h-text_h)/2+40:alpha='if(lt(t\\,1.2)\\,0\\,if(lt(t\\,2)\\,(t-1.2)/0.8\\,if(gt(t\\,${duration - 0.8})\\,(${duration}-t)/0.8\\,1)))'`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const args = [
|
|
200
|
+
'-y',
|
|
201
|
+
'-f', 'lavfi',
|
|
202
|
+
'-i', `color=c=${backgroundColor}:s=${width}x${height}:d=${duration}:r=${fps}`,
|
|
203
|
+
'-vf', vf,
|
|
204
|
+
'-c:v', 'libx264',
|
|
205
|
+
'-crf', '18',
|
|
206
|
+
'-pix_fmt', 'yuv420p',
|
|
207
|
+
'-movflags', '+faststart',
|
|
208
|
+
outputPath,
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
await runFfmpeg(args);
|
|
212
|
+
logger.info(`Intro generated: ${outputPath}`);
|
|
213
|
+
return outputPath;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
async function findFont(): Promise<string> {
|
|
219
|
+
const candidates = [
|
|
220
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
|
221
|
+
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
|
222
|
+
'/usr/share/fonts/truetype/ubuntu/Ubuntu-Bold.ttf',
|
|
223
|
+
'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
|
|
224
|
+
];
|
|
225
|
+
for (const f of candidates) {
|
|
226
|
+
if (fs.existsSync(f)) return f;
|
|
227
|
+
}
|
|
228
|
+
return '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function runFfmpeg(args: string[]): Promise<string> {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
234
|
+
if (error) {
|
|
235
|
+
logger.error(`ffmpeg failed: ${stderr}`);
|
|
236
|
+
reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
resolve(stdout);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor simulation — injects a visible, smooth cursor into the page
|
|
3
|
+
* Uses Bézier curves for natural-looking mouse movement
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Page } from 'playwright';
|
|
7
|
+
import type { CursorConfig } from './types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CURSOR: Required<CursorConfig> = {
|
|
10
|
+
enabled: true,
|
|
11
|
+
style: 'dot',
|
|
12
|
+
color: 'rgba(255, 255, 255, 0.9)',
|
|
13
|
+
size: 20,
|
|
14
|
+
clickAnimation: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inject a visible cursor overlay into the page
|
|
19
|
+
*/
|
|
20
|
+
export async function injectCursor(
|
|
21
|
+
page: Page,
|
|
22
|
+
config: Partial<CursorConfig> = {}
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const opts = { ...DEFAULT_CURSOR, ...config };
|
|
25
|
+
if (!opts.enabled) return;
|
|
26
|
+
|
|
27
|
+
await page.evaluate(
|
|
28
|
+
({ style, color, size, clickAnimation }) => {
|
|
29
|
+
// Create cursor element
|
|
30
|
+
const cursor = document.createElement('div');
|
|
31
|
+
cursor.id = '__cinema-cursor';
|
|
32
|
+
|
|
33
|
+
const isArrow = style === 'arrow' || style === 'pointer';
|
|
34
|
+
|
|
35
|
+
if (isArrow) {
|
|
36
|
+
// SVG arrow cursor
|
|
37
|
+
cursor.innerHTML = `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
38
|
+
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="${color}" stroke="rgba(0,0,0,0.4)" stroke-width="1.5"/>
|
|
39
|
+
</svg>`;
|
|
40
|
+
Object.assign(cursor.style, {
|
|
41
|
+
position: 'fixed',
|
|
42
|
+
zIndex: '2147483647',
|
|
43
|
+
pointerEvents: 'none',
|
|
44
|
+
left: '-100px',
|
|
45
|
+
top: '-100px',
|
|
46
|
+
transition: 'none',
|
|
47
|
+
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))',
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
// Dot cursor
|
|
51
|
+
Object.assign(cursor.style, {
|
|
52
|
+
position: 'fixed',
|
|
53
|
+
zIndex: '2147483647',
|
|
54
|
+
pointerEvents: 'none',
|
|
55
|
+
width: `${size}px`,
|
|
56
|
+
height: `${size}px`,
|
|
57
|
+
borderRadius: '50%',
|
|
58
|
+
backgroundColor: color,
|
|
59
|
+
border: '2px solid rgba(0,0,0,0.2)',
|
|
60
|
+
boxShadow: '0 0 12px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.1)',
|
|
61
|
+
left: '-100px',
|
|
62
|
+
top: '-100px',
|
|
63
|
+
transform: 'translate(-50%, -50%)',
|
|
64
|
+
transition: 'none',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
document.body.appendChild(cursor);
|
|
69
|
+
|
|
70
|
+
// Click animation ring
|
|
71
|
+
if (clickAnimation) {
|
|
72
|
+
const ring = document.createElement('div');
|
|
73
|
+
ring.id = '__cinema-cursor-ring';
|
|
74
|
+
Object.assign(ring.style, {
|
|
75
|
+
position: 'fixed',
|
|
76
|
+
zIndex: '2147483646',
|
|
77
|
+
pointerEvents: 'none',
|
|
78
|
+
width: `${size * 2.5}px`,
|
|
79
|
+
height: `${size * 2.5}px`,
|
|
80
|
+
borderRadius: '50%',
|
|
81
|
+
border: `2px solid ${color}`,
|
|
82
|
+
left: '-100px',
|
|
83
|
+
top: '-100px',
|
|
84
|
+
transform: 'translate(-50%, -50%) scale(0)',
|
|
85
|
+
opacity: '0',
|
|
86
|
+
transition: 'none',
|
|
87
|
+
});
|
|
88
|
+
document.body.appendChild(ring);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Store config on window for later use
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
(window as any).__cinemaCursorConfig = { style, color, size, clickAnimation };
|
|
94
|
+
},
|
|
95
|
+
opts
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Move cursor to a specific position with smooth Bézier interpolation
|
|
101
|
+
*/
|
|
102
|
+
export async function moveCursor(
|
|
103
|
+
page: Page,
|
|
104
|
+
targetX: number,
|
|
105
|
+
targetY: number,
|
|
106
|
+
duration: number = 800,
|
|
107
|
+
fps: number = 60
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const frames = Math.max(1, Math.ceil((duration / 1000) * fps));
|
|
110
|
+
|
|
111
|
+
// Get current cursor position
|
|
112
|
+
const startPos = await page.evaluate(() => {
|
|
113
|
+
const cursor = document.getElementById('__cinema-cursor');
|
|
114
|
+
if (!cursor) return { x: 0, y: 0 };
|
|
115
|
+
return {
|
|
116
|
+
x: parseFloat(cursor.style.left) || 0,
|
|
117
|
+
y: parseFloat(cursor.style.top) || 0,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Generate Bézier control points for natural movement
|
|
122
|
+
const dx = targetX - startPos.x;
|
|
123
|
+
const dy = targetY - startPos.y;
|
|
124
|
+
const cp1x = startPos.x + dx * 0.3 + (Math.random() - 0.5) * Math.abs(dx) * 0.2;
|
|
125
|
+
const cp1y = startPos.y + dy * 0.1 + (Math.random() - 0.5) * Math.abs(dy) * 0.3;
|
|
126
|
+
const cp2x = startPos.x + dx * 0.7 + (Math.random() - 0.5) * Math.abs(dx) * 0.15;
|
|
127
|
+
const cp2y = startPos.y + dy * 0.9 + (Math.random() - 0.5) * Math.abs(dy) * 0.2;
|
|
128
|
+
|
|
129
|
+
// Animate through Bézier curve
|
|
130
|
+
for (let i = 0; i <= frames; i++) {
|
|
131
|
+
const t = i / frames;
|
|
132
|
+
// Ease the t parameter for acceleration/deceleration
|
|
133
|
+
const et = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
134
|
+
|
|
135
|
+
// Cubic Bézier
|
|
136
|
+
const mt = 1 - et;
|
|
137
|
+
const x = mt * mt * mt * startPos.x +
|
|
138
|
+
3 * mt * mt * et * cp1x +
|
|
139
|
+
3 * mt * et * et * cp2x +
|
|
140
|
+
et * et * et * targetX;
|
|
141
|
+
const y = mt * mt * mt * startPos.y +
|
|
142
|
+
3 * mt * mt * et * cp1y +
|
|
143
|
+
3 * mt * et * et * cp2y +
|
|
144
|
+
et * et * et * targetY;
|
|
145
|
+
|
|
146
|
+
await page.evaluate(
|
|
147
|
+
({ x, y }) => {
|
|
148
|
+
const cursor = document.getElementById('__cinema-cursor');
|
|
149
|
+
if (cursor) {
|
|
150
|
+
cursor.style.left = `${x}px`;
|
|
151
|
+
cursor.style.top = `${y}px`;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{ x: Math.round(x), y: Math.round(y) }
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Also move the actual mouse for hover effects
|
|
158
|
+
await page.mouse.move(Math.round(x), Math.round(y));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Move cursor to a CSS selector's center
|
|
164
|
+
*/
|
|
165
|
+
export async function moveCursorToElement(
|
|
166
|
+
page: Page,
|
|
167
|
+
selector: string,
|
|
168
|
+
duration: number = 800,
|
|
169
|
+
fps: number = 60
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const pos = await page.evaluate((sel) => {
|
|
172
|
+
const el = document.querySelector(sel);
|
|
173
|
+
if (!el) return null;
|
|
174
|
+
const rect = el.getBoundingClientRect();
|
|
175
|
+
return {
|
|
176
|
+
x: rect.left + rect.width / 2,
|
|
177
|
+
y: rect.top + rect.height / 2,
|
|
178
|
+
};
|
|
179
|
+
}, selector);
|
|
180
|
+
|
|
181
|
+
if (pos) {
|
|
182
|
+
await moveCursor(page, pos.x, pos.y, duration, fps);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Animate a click effect at current cursor position
|
|
188
|
+
*/
|
|
189
|
+
export async function animateClick(page: Page): Promise<void> {
|
|
190
|
+
await page.evaluate(() => {
|
|
191
|
+
const ring = document.getElementById('__cinema-cursor-ring');
|
|
192
|
+
const cursor = document.getElementById('__cinema-cursor');
|
|
193
|
+
if (!ring || !cursor) return;
|
|
194
|
+
|
|
195
|
+
ring.style.left = cursor.style.left;
|
|
196
|
+
ring.style.top = cursor.style.top;
|
|
197
|
+
ring.style.transition = 'transform 0.4s ease-out, opacity 0.4s ease-out';
|
|
198
|
+
ring.style.transform = 'translate(-50%, -50%) scale(0)';
|
|
199
|
+
ring.style.opacity = '1';
|
|
200
|
+
|
|
201
|
+
// Trigger animation
|
|
202
|
+
requestAnimationFrame(() => {
|
|
203
|
+
ring.style.transform = 'translate(-50%, -50%) scale(1)';
|
|
204
|
+
ring.style.opacity = '0';
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Wait for animation
|
|
209
|
+
await new Promise((r) => setTimeout(r, 450));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Hide cursor (move off-screen)
|
|
214
|
+
*/
|
|
215
|
+
export async function hideCursor(page: Page): Promise<void> {
|
|
216
|
+
await page.evaluate(() => {
|
|
217
|
+
const cursor = document.getElementById('__cinema-cursor');
|
|
218
|
+
const ring = document.getElementById('__cinema-cursor-ring');
|
|
219
|
+
if (cursor) cursor.style.left = '-100px';
|
|
220
|
+
if (ring) ring.style.left = '-100px';
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cinema-grade easing functions for smooth scroll animations
|
|
3
|
+
* All functions: t ∈ [0,1] → output ∈ [0,1]
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EasingName } from './types.js';
|
|
7
|
+
|
|
8
|
+
// ─── Core Easing Functions ──────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const linear = (t: number): number => t;
|
|
11
|
+
|
|
12
|
+
const easeInQuad = (t: number): number => t * t;
|
|
13
|
+
const easeOutQuad = (t: number): number => t * (2 - t);
|
|
14
|
+
const easeInOutQuad = (t: number): number =>
|
|
15
|
+
t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
16
|
+
|
|
17
|
+
const easeInCubic = (t: number): number => t * t * t;
|
|
18
|
+
const easeOutCubic = (t: number): number => (--t) * t * t + 1;
|
|
19
|
+
const easeInOutCubic = (t: number): number =>
|
|
20
|
+
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
21
|
+
|
|
22
|
+
const easeInQuart = (t: number): number => t * t * t * t;
|
|
23
|
+
const easeOutQuart = (t: number): number => 1 - (--t) * t * t * t;
|
|
24
|
+
const easeInOutQuart = (t: number): number =>
|
|
25
|
+
t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
|
|
26
|
+
|
|
27
|
+
const easeInQuint = (t: number): number => t * t * t * t * t;
|
|
28
|
+
const easeOutQuint = (t: number): number => 1 + (--t) * t * t * t * t;
|
|
29
|
+
const easeInOutQuint = (t: number): number =>
|
|
30
|
+
t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2;
|
|
31
|
+
|
|
32
|
+
const easeInOutSine = (t: number): number =>
|
|
33
|
+
-(Math.cos(Math.PI * t) - 1) / 2;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cinematic easing: slow start (15%), smooth cruise (70%), slow end (15%)
|
|
37
|
+
* Uses quintic in/out for dramatic deceleration at edges
|
|
38
|
+
*/
|
|
39
|
+
const cinematic = (t: number): number => {
|
|
40
|
+
if (t < 0.15) {
|
|
41
|
+
// Slow ease-in (quintic)
|
|
42
|
+
const local = t / 0.15;
|
|
43
|
+
return 0.15 * (local * local * local);
|
|
44
|
+
} else if (t > 0.85) {
|
|
45
|
+
// Slow ease-out (quintic)
|
|
46
|
+
const local = (t - 0.85) / 0.15;
|
|
47
|
+
return 0.85 + 0.15 * (1 - Math.pow(1 - local, 3));
|
|
48
|
+
} else {
|
|
49
|
+
// Linear cruise in the middle
|
|
50
|
+
const local = (t - 0.15) / 0.70;
|
|
51
|
+
return 0.15 + 0.70 * local;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Showcase easing: dramatic slow start, buttery cruise, elegant deceleration
|
|
57
|
+
* Designed specifically for portfolio showcase videos
|
|
58
|
+
*
|
|
59
|
+
* Distribution: 25% slow start → 50% smooth cruise → 25% slow end
|
|
60
|
+
* The cruise section uses easeInOutSine for a gentle wave-like motion
|
|
61
|
+
* that never feels rushed — perfect for long pages
|
|
62
|
+
*/
|
|
63
|
+
const showcase = (t: number): number => {
|
|
64
|
+
if (t < 0.25) {
|
|
65
|
+
// Very slow ease-in (quintic for dramatic slowness)
|
|
66
|
+
const local = t / 0.25;
|
|
67
|
+
return 0.08 * easeInQuint(local);
|
|
68
|
+
} else if (t > 0.75) {
|
|
69
|
+
// Very slow ease-out (quintic for elegant stop)
|
|
70
|
+
const local = (t - 0.75) / 0.25;
|
|
71
|
+
return 0.92 + 0.08 * easeOutQuint(local);
|
|
72
|
+
} else {
|
|
73
|
+
// Smooth cruise in the middle — easeInOutSine for wave-like feel
|
|
74
|
+
const local = (t - 0.25) / 0.50;
|
|
75
|
+
return 0.08 + 0.84 * easeInOutSine(local);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ─── Easing Registry ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const EASINGS: Record<EasingName, (t: number) => number> = {
|
|
82
|
+
linear,
|
|
83
|
+
easeInQuad,
|
|
84
|
+
easeOutQuad,
|
|
85
|
+
easeInOutQuad,
|
|
86
|
+
easeInCubic,
|
|
87
|
+
easeOutCubic,
|
|
88
|
+
easeInOutCubic,
|
|
89
|
+
easeInQuart,
|
|
90
|
+
easeOutQuart,
|
|
91
|
+
easeInOutQuart,
|
|
92
|
+
easeInQuint,
|
|
93
|
+
easeOutQuint,
|
|
94
|
+
easeInOutQuint,
|
|
95
|
+
easeInOutSine,
|
|
96
|
+
cinematic,
|
|
97
|
+
showcase,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get an easing function by name
|
|
102
|
+
*/
|
|
103
|
+
export function getEasing(name: EasingName): (t: number) => number {
|
|
104
|
+
return EASINGS[name] ?? EASINGS.easeInOutCubic;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Apply easing to a progress value and map to a range
|
|
109
|
+
*/
|
|
110
|
+
export function applyEasing(
|
|
111
|
+
progress: number,
|
|
112
|
+
totalDistance: number,
|
|
113
|
+
easingName: EasingName = 'easeInOutCubic'
|
|
114
|
+
): number {
|
|
115
|
+
const easingFn = getEasing(easingName);
|
|
116
|
+
const clamped = Math.max(0, Math.min(1, progress));
|
|
117
|
+
return Math.round(easingFn(clamped) * totalDistance);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { EASINGS };
|