clipwise 0.6.0 → 0.7.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.
@@ -240,52 +240,31 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
240
240
 
241
241
  // src/effects/cursor.ts
242
242
  import sharp2 from "sharp";
243
- function buildCursorSvg(size, color) {
244
- const s = size;
245
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
246
- <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
247
- fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
248
- </svg>`;
249
- }
250
- function buildClickRippleSvg(radius, color, progress) {
251
- const currentRadius = radius * progress;
252
- const opacity = Math.max(0, 1 - progress);
253
- const size = Math.ceil(radius * 2 + 4);
254
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
255
- <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
256
- fill="none" stroke="${color}" stroke-width="2"
257
- opacity="${opacity.toFixed(3)}"/>
258
- <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
259
- fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
260
- </svg>`;
261
- }
262
- async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
263
- if (!config.enabled) return frameBuffer;
243
+ function buildCursorOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
244
+ if (!config.enabled) return null;
264
245
  const size = Math.round(config.size * dpr);
265
246
  const cursorSvg = buildCursorSvg(size, config.color);
266
- const cursorBuffer = Buffer.from(cursorSvg);
267
247
  const tipOffsetX = Math.round(4 / 24 * size);
268
248
  const px = Math.round(position.x * dpr);
269
249
  const py = Math.round(position.y * dpr);
270
250
  const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
271
251
  const top = Math.max(0, Math.min(py, frameHeight - size));
272
- return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
252
+ return { input: Buffer.from(cursorSvg), left, top };
273
253
  }
274
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
275
- if (!config.enabled || !config.clickEffect) return frameBuffer;
254
+ function buildClickRippleOverlay(position, config, progress, frameWidth, frameHeight, dpr = 1) {
255
+ if (!config.enabled || !config.clickEffect) return null;
276
256
  const radius = config.clickRadius * dpr;
277
257
  const clampedProgress = Math.max(0, Math.min(1, progress));
278
258
  const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
279
- const rippleBuffer = Buffer.from(rippleSvg);
280
259
  const rippleSize = Math.ceil(radius * 2 + 4);
281
260
  const px = Math.round(position.x * dpr);
282
261
  const py = Math.round(position.y * dpr);
283
262
  const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
284
263
  const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
285
- return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
264
+ return { input: Buffer.from(rippleSvg), left, top };
286
265
  }
287
- async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
288
- if (!config.enabled || !config.highlight) return frameBuffer;
266
+ function buildHighlightOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
267
+ if (!config.enabled || !config.highlight) return null;
289
268
  const r = config.highlightRadius * dpr;
290
269
  const size = Math.ceil(r * 2 + 4);
291
270
  const cx = size / 2;
@@ -304,12 +283,10 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
304
283
  const py = Math.round(position.y * dpr);
305
284
  const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
306
285
  const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
307
- return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
286
+ return { input: Buffer.from(highlightSvg), left, top };
308
287
  }
309
- async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
310
- if (!config.enabled || !config.trail || positions.length < 2) {
311
- return frameBuffer;
312
- }
288
+ function buildTrailOverlay(positions, config, frameWidth, frameHeight, dpr = 1) {
289
+ if (!config.enabled || !config.trail || positions.length < 2) return null;
313
290
  const segments = [];
314
291
  for (let i = 1; i < positions.length; i++) {
315
292
  const opacity = i / positions.length * 0.6;
@@ -325,7 +302,26 @@ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, fra
325
302
  const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
326
303
  ${segments.join("\n ")}
327
304
  </svg>`;
328
- return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
305
+ return { input: Buffer.from(trailSvg), left: 0, top: 0 };
306
+ }
307
+ function buildCursorSvg(size, color) {
308
+ const s = size;
309
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
310
+ <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
311
+ fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
312
+ </svg>`;
313
+ }
314
+ function buildClickRippleSvg(radius, color, progress) {
315
+ const currentRadius = radius * progress;
316
+ const opacity = Math.max(0, 1 - progress);
317
+ const size = Math.ceil(radius * 2 + 4);
318
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
319
+ <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
320
+ fill="none" stroke="${color}" stroke-width="2"
321
+ opacity="${opacity.toFixed(3)}"/>
322
+ <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
323
+ fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
324
+ </svg>`;
329
325
  }
330
326
 
331
327
  // src/effects/zoom.ts
@@ -639,73 +635,87 @@ async function composeFrame(frame, effects, output, context) {
639
635
  cursorTrail: context?.cursorTrail ?? []
640
636
  };
641
637
  const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
642
- const withFrameOffset = (pos) => ({
638
+ const sl = context?.staticLayers;
639
+ const preZoomOverlays = [];
640
+ const hasBrowserChrome = effects.deviceFrame.enabled && effects.deviceFrame.type === "browser" && sl?.browserChromePng;
641
+ const extTop = hasBrowserChrome ? sl.browserChromeHeight : 0;
642
+ const extWidth = width;
643
+ const extHeight = height + extTop;
644
+ if (hasBrowserChrome) {
645
+ preZoomOverlays.push({ input: sl.browserChromePng, left: 0, top: 0 });
646
+ }
647
+ const withExtFrameOffset = (pos) => ({
643
648
  x: pos.x + frameOffset.left / Math.max(1, dpr),
644
649
  y: pos.y + frameOffset.top / Math.max(1, dpr)
645
650
  });
646
- if (effects.deviceFrame.enabled) {
647
- const sl2 = ctx.staticLayers;
648
- if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
649
- buffer = await sharp7(buffer).extend({
650
- top: sl2.browserChromeHeight,
651
- bottom: 0,
652
- left: 0,
653
- right: 0,
654
- background: { r: 0, g: 0, b: 0, alpha: 0 }
655
- }).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
656
- } else {
657
- buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
658
- }
659
- const meta2 = await sharp7(buffer).metadata();
660
- width = meta2.width ?? width;
661
- height = meta2.height ?? height;
662
- }
663
651
  if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
664
- buffer = await renderCursorHighlight(
665
- buffer,
666
- withFrameOffset(frame.cursorPosition),
652
+ const overlay = buildHighlightOverlay(
653
+ withExtFrameOffset(frame.cursorPosition),
667
654
  effects.cursor,
668
- width,
669
- height,
655
+ extWidth,
656
+ extHeight,
670
657
  dpr
671
658
  );
659
+ if (overlay) preZoomOverlays.push(overlay);
672
660
  }
673
661
  if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
674
- buffer = await renderCursorTrail(
675
- buffer,
676
- ctx.cursorTrail.map(withFrameOffset),
662
+ const overlay = buildTrailOverlay(
663
+ ctx.cursorTrail.map(withExtFrameOffset),
677
664
  effects.cursor,
678
- width,
679
- height,
665
+ extWidth,
666
+ extHeight,
680
667
  dpr
681
668
  );
669
+ if (overlay) preZoomOverlays.push(overlay);
682
670
  }
683
671
  if (effects.cursor.enabled && frame.cursorPosition) {
684
- buffer = await renderCursor(
685
- buffer,
686
- withFrameOffset(frame.cursorPosition),
672
+ const overlay = buildCursorOverlay(
673
+ withExtFrameOffset(frame.cursorPosition),
687
674
  effects.cursor,
688
- width,
689
- height,
675
+ extWidth,
676
+ extHeight,
690
677
  dpr
691
678
  );
679
+ if (overlay) preZoomOverlays.push(overlay);
692
680
  }
693
681
  if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
694
682
  const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
695
- buffer = await renderClickEffect(
696
- buffer,
697
- withFrameOffset(frame.clickPosition),
683
+ const overlay = buildClickRippleOverlay(
684
+ withExtFrameOffset(frame.clickPosition),
698
685
  effects.cursor,
699
686
  progress,
700
- width,
701
- height,
687
+ extWidth,
688
+ extHeight,
702
689
  dpr
703
690
  );
691
+ if (overlay) preZoomOverlays.push(overlay);
692
+ }
693
+ if (hasBrowserChrome || preZoomOverlays.length > 0) {
694
+ let pipeline = sharp7(buffer);
695
+ if (hasBrowserChrome) {
696
+ pipeline = pipeline.extend({
697
+ top: extTop,
698
+ bottom: 0,
699
+ left: 0,
700
+ right: 0,
701
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
702
+ });
703
+ }
704
+ if (preZoomOverlays.length > 0) {
705
+ pipeline = pipeline.composite(preZoomOverlays);
706
+ }
707
+ buffer = await pipeline.png().toBuffer();
708
+ width = extWidth;
709
+ height = extHeight;
710
+ } else if (effects.deviceFrame.enabled) {
711
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
712
+ const devMeta = await sharp7(buffer).metadata();
713
+ width = devMeta.width ?? width;
714
+ height = devMeta.height ?? height;
704
715
  }
705
716
  const scale = ctx.zoomScale;
706
717
  if (effects.zoom.enabled && scale > 1) {
707
- const followCursor = effects.zoom.autoZoom.followCursor;
708
- const rawFocus = followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
718
+ const rawFocus = ctx.focusOverride ?? (effects.zoom.autoZoom.followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 });
709
719
  const offset = getFrameOffset(effects.deviceFrame, dpr);
710
720
  const focusPoint = {
711
721
  x: rawFocus.x * dpr + offset.left,
@@ -724,7 +734,6 @@ async function composeFrame(frame, effects, output, context) {
724
734
  dpr
725
735
  );
726
736
  }
727
- const sl = ctx.staticLayers;
728
737
  if (sl) {
729
738
  const padding = effects.background.padding;
730
739
  const contentWidth = output.width - padding * 2;