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