clipwise 0.2.1 → 0.3.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.
@@ -0,0 +1,649 @@
1
+ // src/compose/frame-worker.ts
2
+ import { parentPort } from "worker_threads";
3
+
4
+ // src/compose/compose-frame.ts
5
+ import sharp7 from "sharp";
6
+
7
+ // src/effects/frame.ts
8
+ import sharp from "sharp";
9
+ var TITLE_BAR_HEIGHT = 40;
10
+ var TRAFFIC_LIGHT_Y = 14;
11
+ var TRAFFIC_LIGHT_RADIUS = 6;
12
+ var TRAFFIC_LIGHTS_START_X = 16;
13
+ var TRAFFIC_LIGHT_GAP = 22;
14
+ var ADDRESS_BAR_HEIGHT = 24;
15
+ var ADDRESS_BAR_MARGIN = 70;
16
+ var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
17
+ var IPHONE_OUTER_RADIUS = 47;
18
+ var IPHONE_INNER_RADIUS = 39;
19
+ var IPHONE_ISLAND = { width: 120, height: 36 };
20
+ var IPHONE_HOME_BAR = { width: 134, height: 5 };
21
+ var IPAD_BEZEL = { sides: 20, top: 24, bottom: 24 };
22
+ var IPAD_OUTER_RADIUS = 18;
23
+ var IPAD_INNER_RADIUS = 12;
24
+ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
25
+ var ANDROID_OUTER_RADIUS = 35;
26
+ var ANDROID_INNER_RADIUS = 30;
27
+ var ANDROID_CAMERA_RADIUS = 6;
28
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
29
+ const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
30
+ const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
31
+ const addressBorder = darkMode ? "#444444" : "#d0d0d0";
32
+ const textColor = darkMode ? "#999999" : "#666666";
33
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
34
+ const tlY = TRAFFIC_LIGHT_Y * dpr;
35
+ const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
36
+ const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
37
+ const tlGap = TRAFFIC_LIGHT_GAP * dpr;
38
+ const aBarH = ADDRESS_BAR_HEIGHT * dpr;
39
+ const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
40
+ const fontSize = 12 * dpr;
41
+ const trafficLights = [
42
+ { cx: tlStartX, fill: "#ff5f57" },
43
+ { cx: tlStartX + tlGap, fill: "#febc2e" },
44
+ { cx: tlStartX + tlGap * 2, fill: "#28c840" }
45
+ ].map(
46
+ (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
47
+ ).join("\n ");
48
+ const addressBarWidth = width - aBarMargin * 2;
49
+ const addressBarX = aBarMargin;
50
+ const addressBarY = (tbarH - aBarH) / 2;
51
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
52
+ <rect width="${width}" height="${tbarH}" fill="${bg}"/>
53
+ ${trafficLights}
54
+ <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
55
+ rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
56
+ <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
57
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
58
+ localhost
59
+ </text>
60
+ </svg>`;
61
+ }
62
+ function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
63
+ const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
64
+ const islandColor = darkMode ? "#000000" : "#1a1a1a";
65
+ const homeBarColor = darkMode ? "#555555" : "#333333";
66
+ const bezelTop = IPHONE_BEZEL.top * dpr;
67
+ const bezelBottom = IPHONE_BEZEL.bottom * dpr;
68
+ const bezelSides = IPHONE_BEZEL.sides * dpr;
69
+ const outerRadius = IPHONE_OUTER_RADIUS * dpr;
70
+ const innerRadius = IPHONE_INNER_RADIUS * dpr;
71
+ const islandW = IPHONE_ISLAND.width * dpr;
72
+ const islandH = IPHONE_ISLAND.height * dpr;
73
+ const homeBarW = IPHONE_HOME_BAR.width * dpr;
74
+ const homeBarH = IPHONE_HOME_BAR.height * dpr;
75
+ const islandX = (totalWidth - islandW) / 2;
76
+ const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
77
+ const homeBarX = (totalWidth - homeBarW) / 2;
78
+ const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
79
+ const screenX = bezelSides;
80
+ const screenY = bezelTop;
81
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
82
+ <!-- Device body -->
83
+ <rect width="${totalWidth}" height="${totalHeight}"
84
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
85
+ <!-- Screen cutout (transparent) -->
86
+ <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
87
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
88
+ <!-- Dynamic Island pill -->
89
+ <rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
90
+ rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
91
+ <!-- Home indicator bar -->
92
+ <rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
93
+ rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
94
+ </svg>`;
95
+ }
96
+ function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
97
+ const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
98
+ const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
99
+ const screenX = IPAD_BEZEL.sides * dpr;
100
+ const screenY = IPAD_BEZEL.top * dpr;
101
+ const cameraCx = totalWidth / 2;
102
+ const cameraCy = IPAD_BEZEL.top * dpr / 2;
103
+ const outerRadius = IPAD_OUTER_RADIUS * dpr;
104
+ const innerRadius = IPAD_INNER_RADIUS * dpr;
105
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
106
+ <!-- Device body -->
107
+ <rect width="${totalWidth}" height="${totalHeight}"
108
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
109
+ <!-- Screen cutout -->
110
+ <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
111
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
112
+ <!-- Front camera dot -->
113
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
114
+ </svg>`;
115
+ }
116
+ function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
117
+ const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
118
+ const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
119
+ const screenX = ANDROID_BEZEL.sides * dpr;
120
+ const screenY = ANDROID_BEZEL.top * dpr;
121
+ const cameraCx = totalWidth / 2;
122
+ const cameraCy = ANDROID_BEZEL.top * dpr / 2;
123
+ const outerRadius = ANDROID_OUTER_RADIUS * dpr;
124
+ const innerRadius = ANDROID_INNER_RADIUS * dpr;
125
+ const cameraR = ANDROID_CAMERA_RADIUS * dpr;
126
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
127
+ <!-- Device body -->
128
+ <rect width="${totalWidth}" height="${totalHeight}"
129
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
130
+ <!-- Screen cutout -->
131
+ <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
132
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
133
+ <!-- Punch-hole camera -->
134
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
135
+ </svg>`;
136
+ }
137
+ function buildScreenMaskSvg(width, height, radius) {
138
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
139
+ <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
140
+ </svg>`;
141
+ }
142
+ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
143
+ let bezel;
144
+ let innerRadius;
145
+ switch (deviceType) {
146
+ case "iphone":
147
+ bezel = {
148
+ sides: IPHONE_BEZEL.sides * dpr,
149
+ top: IPHONE_BEZEL.top * dpr,
150
+ bottom: IPHONE_BEZEL.bottom * dpr
151
+ };
152
+ innerRadius = IPHONE_INNER_RADIUS * dpr;
153
+ break;
154
+ case "ipad":
155
+ bezel = {
156
+ sides: IPAD_BEZEL.sides * dpr,
157
+ top: IPAD_BEZEL.top * dpr,
158
+ bottom: IPAD_BEZEL.bottom * dpr
159
+ };
160
+ innerRadius = IPAD_INNER_RADIUS * dpr;
161
+ break;
162
+ case "android":
163
+ bezel = {
164
+ sides: ANDROID_BEZEL.sides * dpr,
165
+ top: ANDROID_BEZEL.top * dpr,
166
+ bottom: ANDROID_BEZEL.bottom * dpr
167
+ };
168
+ innerRadius = ANDROID_INNER_RADIUS * dpr;
169
+ break;
170
+ }
171
+ const totalWidth = frameWidth + bezel.sides * 2;
172
+ const totalHeight = frameHeight + bezel.top + bezel.bottom;
173
+ let frameSvg;
174
+ switch (deviceType) {
175
+ case "iphone":
176
+ frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
177
+ break;
178
+ case "ipad":
179
+ frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
180
+ break;
181
+ case "android":
182
+ frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
183
+ break;
184
+ }
185
+ const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
186
+ const maskedScreen = await sharp(frameBuffer).resize(frameWidth, frameHeight, { fit: "fill" }).composite([
187
+ {
188
+ input: Buffer.from(maskSvg),
189
+ blend: "dest-in"
190
+ }
191
+ ]).png().toBuffer();
192
+ const canvas = await sharp({
193
+ create: {
194
+ width: totalWidth,
195
+ height: totalHeight,
196
+ channels: 4,
197
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
198
+ }
199
+ }).png().toBuffer();
200
+ return sharp(canvas).composite([
201
+ { input: Buffer.from(frameSvg), left: 0, top: 0 },
202
+ { input: maskedScreen, left: bezel.sides, top: bezel.top }
203
+ ]).png().toBuffer();
204
+ }
205
+ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
206
+ if (!config.enabled || config.type === "none") return frameBuffer;
207
+ switch (config.type) {
208
+ case "browser": {
209
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
210
+ const totalHeight = frameHeight + tbarH;
211
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
212
+ const chromeBuffer = Buffer.from(chromeSvg);
213
+ const canvas = await sharp({
214
+ create: {
215
+ width: frameWidth,
216
+ height: totalHeight,
217
+ channels: 4,
218
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
219
+ }
220
+ }).png().toBuffer();
221
+ return sharp(canvas).composite([
222
+ { input: chromeBuffer, left: 0, top: 0 },
223
+ { input: frameBuffer, left: 0, top: tbarH }
224
+ ]).png().toBuffer();
225
+ }
226
+ case "iphone":
227
+ case "ipad":
228
+ case "android":
229
+ return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
230
+ default:
231
+ return frameBuffer;
232
+ }
233
+ }
234
+
235
+ // src/effects/cursor.ts
236
+ import sharp2 from "sharp";
237
+ function buildCursorSvg(size, color) {
238
+ const s = size;
239
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
240
+ <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
241
+ fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
242
+ </svg>`;
243
+ }
244
+ function buildClickRippleSvg(radius, color, progress) {
245
+ const currentRadius = radius * progress;
246
+ const opacity = Math.max(0, 1 - progress);
247
+ const size = Math.ceil(radius * 2 + 4);
248
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
249
+ <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
250
+ fill="none" stroke="${color}" stroke-width="2"
251
+ opacity="${opacity.toFixed(3)}"/>
252
+ <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
253
+ fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
254
+ </svg>`;
255
+ }
256
+ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
257
+ if (!config.enabled) return frameBuffer;
258
+ const size = Math.round(config.size * dpr);
259
+ const cursorSvg = buildCursorSvg(size, config.color);
260
+ const cursorBuffer = Buffer.from(cursorSvg);
261
+ const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
262
+ const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
263
+ return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
264
+ }
265
+ async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
266
+ if (!config.enabled || !config.clickEffect) return frameBuffer;
267
+ const radius = config.clickRadius * dpr;
268
+ const clampedProgress = Math.max(0, Math.min(1, progress));
269
+ const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
270
+ const rippleBuffer = Buffer.from(rippleSvg);
271
+ const rippleSize = Math.ceil(radius * 2 + 4);
272
+ const px = Math.round(position.x * dpr);
273
+ const py = Math.round(position.y * dpr);
274
+ const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
275
+ const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
276
+ return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
277
+ }
278
+ async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
279
+ if (!config.enabled || !config.highlight) return frameBuffer;
280
+ const r = config.highlightRadius * dpr;
281
+ const size = Math.ceil(r * 2 + 4);
282
+ const cx = size / 2;
283
+ const cy = size / 2;
284
+ const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
285
+ <defs>
286
+ <radialGradient id="glow">
287
+ <stop offset="0%" stop-color="${config.highlightColor}" />
288
+ <stop offset="70%" stop-color="${config.highlightColor}" />
289
+ <stop offset="100%" stop-color="transparent" />
290
+ </radialGradient>
291
+ </defs>
292
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
293
+ </svg>`;
294
+ const px = Math.round(position.x * dpr);
295
+ const py = Math.round(position.y * dpr);
296
+ const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
297
+ const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
298
+ return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
299
+ }
300
+ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
301
+ if (!config.enabled || !config.trail || positions.length < 2) {
302
+ return frameBuffer;
303
+ }
304
+ const segments = [];
305
+ for (let i = 1; i < positions.length; i++) {
306
+ const opacity = i / positions.length * 0.6;
307
+ const strokeWidth = (1 + i / positions.length * 2) * dpr;
308
+ const p1 = positions[i - 1];
309
+ const p2 = positions[i];
310
+ segments.push(
311
+ `<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
312
+ stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
313
+ stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
314
+ );
315
+ }
316
+ const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
317
+ ${segments.join("\n ")}
318
+ </svg>`;
319
+ return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
320
+ }
321
+
322
+ // src/effects/zoom.ts
323
+ import sharp3 from "sharp";
324
+ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
325
+ if (scale <= 1) return frameBuffer;
326
+ const cropWidth = Math.round(frameWidth / scale);
327
+ const cropHeight = Math.round(frameHeight / scale);
328
+ let left = Math.round(focusPoint.x - cropWidth / 2);
329
+ let top = Math.round(focusPoint.y - cropHeight / 2);
330
+ left = Math.max(0, Math.min(left, frameWidth - cropWidth));
331
+ top = Math.max(0, Math.min(top, frameHeight - cropHeight));
332
+ return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
333
+ }
334
+
335
+ // src/effects/background.ts
336
+ import sharp4 from "sharp";
337
+ function parseGradient(value) {
338
+ const match = value.match(
339
+ /linear-gradient\(\s*([\d.]+)deg\s*,\s*(.+)\s*\)/
340
+ );
341
+ if (!match) {
342
+ return { angle: 135, stops: [{ color: value, offset: "100%" }] };
343
+ }
344
+ const angle = parseFloat(match[1]);
345
+ const stopsRaw = match[2].split(",").map((s) => s.trim());
346
+ const stops = stopsRaw.map((stop) => {
347
+ const parts = stop.trim().split(/\s+/);
348
+ return {
349
+ color: parts[0],
350
+ offset: parts[1] ?? "0%"
351
+ };
352
+ });
353
+ return { angle, stops };
354
+ }
355
+ function angleToGradientCoords(angle) {
356
+ const rad = (angle - 90) * Math.PI / 180;
357
+ const x1 = 50 - Math.cos(rad) * 50;
358
+ const y1 = 50 - Math.sin(rad) * 50;
359
+ const x2 = 50 + Math.cos(rad) * 50;
360
+ const y2 = 50 + Math.sin(rad) * 50;
361
+ return {
362
+ x1: `${x1.toFixed(1)}%`,
363
+ y1: `${y1.toFixed(1)}%`,
364
+ x2: `${x2.toFixed(1)}%`,
365
+ y2: `${y2.toFixed(1)}%`
366
+ };
367
+ }
368
+ function buildBackgroundSvg(config, width, height) {
369
+ if (config.type === "solid") {
370
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
371
+ <rect width="${width}" height="${height}" fill="${config.value}"/>
372
+ </svg>`;
373
+ }
374
+ const { angle, stops } = parseGradient(config.value);
375
+ const coords = angleToGradientCoords(angle);
376
+ const stopElements = stops.map((s) => `<stop offset="${s.offset}" stop-color="${s.color}"/>`).join("\n ");
377
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
378
+ <defs>
379
+ <linearGradient id="bg" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
380
+ ${stopElements}
381
+ </linearGradient>
382
+ </defs>
383
+ <rect width="${width}" height="${height}" fill="url(#bg)"/>
384
+ </svg>`;
385
+ }
386
+ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
387
+ const padding = config.padding;
388
+ const contentWidth = outputWidth - padding * 2;
389
+ const contentHeight = outputHeight - padding * 2;
390
+ if (contentWidth <= 0 || contentHeight <= 0) {
391
+ return frameBuffer;
392
+ }
393
+ const resizedFrame = await sharp4(frameBuffer).resize(contentWidth, contentHeight, { fit: "fill" }).png().toBuffer();
394
+ const bgSvg = buildBackgroundSvg(config, outputWidth, outputHeight);
395
+ const bgBuffer = Buffer.from(bgSvg);
396
+ const radius = config.borderRadius;
397
+ const roundedMask = Buffer.from(
398
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
399
+ <rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
400
+ </svg>`
401
+ );
402
+ const maskedFrame = await sharp4(resizedFrame).composite([
403
+ {
404
+ input: roundedMask,
405
+ blend: "dest-in"
406
+ }
407
+ ]).png().toBuffer();
408
+ const composites = [];
409
+ if (config.shadow) {
410
+ const shadowSvg = Buffer.from(
411
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${outputWidth}" height="${outputHeight}">
412
+ <defs>
413
+ <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
414
+ <feDropShadow dx="0" dy="4" stdDeviation="16" flood-color="rgba(0,0,0,0.3)"/>
415
+ </filter>
416
+ </defs>
417
+ <rect x="${padding}" y="${padding}" width="${contentWidth}" height="${contentHeight}"
418
+ rx="${radius}" ry="${radius}" fill="rgba(0,0,0,0.15)" filter="url(#shadow)"/>
419
+ </svg>`
420
+ );
421
+ composites.push({ input: shadowSvg, left: 0, top: 0 });
422
+ }
423
+ composites.push({ input: maskedFrame, left: padding, top: padding });
424
+ return sharp4(bgBuffer).resize(outputWidth, outputHeight).composite(composites).png().toBuffer();
425
+ }
426
+
427
+ // src/effects/keystroke.ts
428
+ import sharp5 from "sharp";
429
+ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
430
+ if (!config.enabled || keystrokes.length === 0) return frameBuffer;
431
+ const recentKeys = keystrokes.filter(
432
+ (k) => frameTimestamp - k.timestamp < config.fadeAfter
433
+ );
434
+ if (recentKeys.length === 0) return frameBuffer;
435
+ const displayText = recentKeys.map((k) => k.key).join("");
436
+ if (displayText.length === 0) return frameBuffer;
437
+ const fontSize = config.fontSize * dpr;
438
+ const padding = config.padding * dpr;
439
+ const charWidth = fontSize * 0.62;
440
+ const textWidth = Math.ceil(displayText.length * charWidth);
441
+ const hudPadH = padding * 2;
442
+ const hudPadV = padding * 1.5;
443
+ const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
444
+ const hudHeight = Math.ceil(fontSize + hudPadV * 2);
445
+ const newest = recentKeys[recentKeys.length - 1];
446
+ const age = frameTimestamp - newest.timestamp;
447
+ const fadeStart = config.fadeAfter * 0.6;
448
+ const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
449
+ if (opacity <= 0) return frameBuffer;
450
+ const margin = 30 * dpr;
451
+ let hudX;
452
+ const hudY = frameHeight - hudHeight - margin;
453
+ switch (config.position) {
454
+ case "bottom-left":
455
+ hudX = margin;
456
+ break;
457
+ case "bottom-right":
458
+ hudX = frameWidth - hudWidth - margin;
459
+ break;
460
+ case "bottom-center":
461
+ default:
462
+ hudX = Math.round((frameWidth - hudWidth) / 2);
463
+ break;
464
+ }
465
+ const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
466
+ const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
467
+ const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
468
+ const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
469
+ <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
470
+ rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
471
+ <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
472
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
473
+ fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
474
+ </svg>`;
475
+ return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
476
+ }
477
+
478
+ // src/effects/watermark.ts
479
+ import sharp6 from "sharp";
480
+ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
481
+ if (!config.enabled || !config.text) return frameBuffer;
482
+ const charWidth = config.fontSize * 0.62;
483
+ const textWidth = Math.ceil(config.text.length * charWidth);
484
+ const margin = 16;
485
+ let x;
486
+ let y;
487
+ switch (config.position) {
488
+ case "top-left":
489
+ x = margin;
490
+ y = margin + config.fontSize;
491
+ break;
492
+ case "top-right":
493
+ x = frameWidth - textWidth - margin;
494
+ y = margin + config.fontSize;
495
+ break;
496
+ case "bottom-left":
497
+ x = margin;
498
+ y = frameHeight - margin;
499
+ break;
500
+ case "bottom-right":
501
+ default:
502
+ x = frameWidth - textWidth - margin;
503
+ y = frameHeight - margin;
504
+ break;
505
+ }
506
+ const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
507
+ const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
508
+ <text x="${x}" y="${y}"
509
+ font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
510
+ font-weight="600" fill="${config.color}"
511
+ opacity="${config.opacity.toFixed(3)}">${escaped}</text>
512
+ </svg>`;
513
+ return sharp6(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
514
+ }
515
+
516
+ // src/compose/compose-frame.ts
517
+ function getFrameOffset(config, dpr = 1) {
518
+ if (!config.enabled) return { left: 0, top: 0 };
519
+ switch (config.type) {
520
+ case "browser":
521
+ return { left: 0, top: 40 * dpr };
522
+ case "iphone":
523
+ return { left: 12 * dpr, top: 50 * dpr };
524
+ case "ipad":
525
+ return { left: 20 * dpr, top: 24 * dpr };
526
+ case "android":
527
+ return { left: 8 * dpr, top: 32 * dpr };
528
+ default:
529
+ return { left: 0, top: 0 };
530
+ }
531
+ }
532
+ async function composeFrame(frame, effects, output, context) {
533
+ let buffer = frame.screenshot;
534
+ const meta = await sharp7(buffer).metadata();
535
+ let width = meta.width ?? frame.viewport.width;
536
+ let height = meta.height ?? frame.viewport.height;
537
+ const dpr = Math.round(width / frame.viewport.width);
538
+ const ctx = {
539
+ zoomScale: context?.zoomScale ?? 1,
540
+ clickProgress: context?.clickProgress ?? null,
541
+ cursorTrail: context?.cursorTrail ?? []
542
+ };
543
+ if (effects.deviceFrame.enabled) {
544
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
545
+ const meta2 = await sharp7(buffer).metadata();
546
+ width = meta2.width ?? width;
547
+ height = meta2.height ?? height;
548
+ }
549
+ if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
550
+ buffer = await renderCursorHighlight(
551
+ buffer,
552
+ frame.cursorPosition,
553
+ effects.cursor,
554
+ width,
555
+ height,
556
+ dpr
557
+ );
558
+ }
559
+ if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
560
+ buffer = await renderCursorTrail(
561
+ buffer,
562
+ ctx.cursorTrail,
563
+ effects.cursor,
564
+ width,
565
+ height,
566
+ dpr
567
+ );
568
+ }
569
+ if (effects.cursor.enabled && frame.cursorPosition) {
570
+ buffer = await renderCursor(
571
+ buffer,
572
+ frame.cursorPosition,
573
+ effects.cursor,
574
+ width,
575
+ height,
576
+ dpr
577
+ );
578
+ }
579
+ if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
580
+ const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
581
+ buffer = await renderClickEffect(
582
+ buffer,
583
+ frame.clickPosition,
584
+ effects.cursor,
585
+ progress,
586
+ width,
587
+ height,
588
+ dpr
589
+ );
590
+ }
591
+ if (effects.keystroke.enabled && frame.keystrokes) {
592
+ buffer = await renderKeystrokeHud(
593
+ buffer,
594
+ frame.keystrokes,
595
+ frame.timestamp,
596
+ effects.keystroke,
597
+ width,
598
+ height,
599
+ dpr
600
+ );
601
+ }
602
+ const scale = ctx.zoomScale;
603
+ if (effects.zoom.enabled && scale > 1) {
604
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
605
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
606
+ const focusPoint = {
607
+ x: rawFocus.x * dpr + offset.left,
608
+ y: rawFocus.y * dpr + offset.top
609
+ };
610
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
611
+ }
612
+ buffer = await applyBackground(buffer, effects.background, output.width, output.height);
613
+ if (effects.watermark.enabled) {
614
+ buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
615
+ }
616
+ buffer = await sharp7(buffer).resize(output.width, output.height, {
617
+ fit: "fill",
618
+ kernel: sharp7.kernel.lanczos3
619
+ }).png().toBuffer();
620
+ return { index: frame.index, buffer, timestamp: frame.timestamp };
621
+ }
622
+
623
+ // src/compose/frame-worker.ts
624
+ parentPort.on("message", async (msg) => {
625
+ try {
626
+ const { taskId, frame, effects, output, context } = msg;
627
+ const frameWithBuffer = {
628
+ ...frame,
629
+ screenshot: Buffer.from(frame.screenshot)
630
+ };
631
+ const result = await composeFrame(frameWithBuffer, effects, output, context);
632
+ const reply = {
633
+ taskId,
634
+ index: result.index,
635
+ timestamp: result.timestamp,
636
+ buffer: result.buffer
637
+ };
638
+ parentPort.postMessage(reply);
639
+ } catch (err) {
640
+ const reply = {
641
+ taskId: msg.taskId,
642
+ index: msg.frame.index,
643
+ timestamp: msg.frame.timestamp,
644
+ buffer: Buffer.alloc(0),
645
+ error: err instanceof Error ? err.message : String(err)
646
+ };
647
+ parentPort.postMessage(reply);
648
+ }
649
+ });