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.
package/dist/index.js CHANGED
@@ -52,15 +52,15 @@ async function getElementCenter(page, selector, timeout) {
52
52
 
53
53
  // src/core/recorder.ts
54
54
  var CLICK_EFFECT_DURATION_MS = 500;
55
- var REPAINT_INTERVAL_MS = 50;
55
+ var REPAINT_INTERVAL_MS = 25;
56
56
  var ACTION_GAP_MS = 30;
57
57
  var CURSOR_SPEED_PRESETS = {
58
- fast: { steps: 12, delay: 6 },
59
- // ~72ms total
60
- normal: { steps: 18, delay: 8 },
61
- // ~144ms total
62
- slow: { steps: 24, delay: 12 }
63
- // ~288ms total
58
+ fast: { steps: 10, delay: 22 },
59
+ // ~220ms, ~9 frames captured
60
+ normal: { steps: 14, delay: 25 },
61
+ // ~350ms, ~14 frames captured
62
+ slow: { steps: 20, delay: 25 }
63
+ // ~500ms, ~20 frames captured
64
64
  };
65
65
  var ClipwiseRecorder = class {
66
66
  browser = null;
@@ -74,6 +74,7 @@ var ClipwiseRecorder = class {
74
74
  currentStepIndex = 0;
75
75
  cursorPosition = { x: 0, y: 0 };
76
76
  viewport = { width: 1280, height: 800 };
77
+ deviceScaleFactor = 1;
77
78
  isCapturing = false;
78
79
  targetFps = 30;
79
80
  cursorSpeed = "fast";
@@ -127,10 +128,9 @@ var ClipwiseRecorder = class {
127
128
  }
128
129
  );
129
130
  await this.cdpClient.send("Page.startScreencast", {
130
- format: "jpeg",
131
- quality: 95,
132
- maxWidth: this.viewport.width,
133
- maxHeight: this.viewport.height,
131
+ format: "png",
132
+ maxWidth: this.viewport.width * this.deviceScaleFactor,
133
+ maxHeight: this.viewport.height * this.deviceScaleFactor,
134
134
  everyNthFrame: 1
135
135
  });
136
136
  this.cursorTimeline.push({
@@ -444,6 +444,7 @@ var ClipwiseRecorder = class {
444
444
  clickPosition: clickEvent?.position ?? null,
445
445
  clickProgress,
446
446
  viewport: { ...this.viewport },
447
+ deviceScaleFactor: this.deviceScaleFactor,
447
448
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
448
449
  stepIndex: this.currentStepIndex
449
450
  };
@@ -472,15 +473,9 @@ var ClipwiseRecorder = class {
472
473
  for (let i = 0; i < targetFrameCount; i++) {
473
474
  const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
474
475
  const targetTimestamp = startTime + t * duration;
475
- let nearestIdx = 0;
476
- let minDist = Infinity;
477
- for (let j = 0; j < frames.length; j++) {
478
- const dist = Math.abs(frames[j].timestamp - targetTimestamp);
479
- if (dist < minDist) {
480
- minDist = dist;
481
- nearestIdx = j;
482
- }
483
- }
476
+ const lo = this.binarySearchTimeline(frames, targetTimestamp);
477
+ const hi = Math.min(lo + 1, frames.length - 1);
478
+ const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
484
479
  const cursorPos = this.interpolateCursorAt(targetTimestamp);
485
480
  const clickEvent = this.clickTimeline.find(
486
481
  (click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
@@ -501,6 +496,7 @@ var ClipwiseRecorder = class {
501
496
  clickPosition: clickEvent?.position ?? null,
502
497
  clickProgress,
503
498
  viewport: { ...this.viewport },
499
+ deviceScaleFactor: this.deviceScaleFactor,
504
500
  stepName: frames[nearestIdx].stepName,
505
501
  stepIndex: frames[nearestIdx].stepIndex,
506
502
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
@@ -516,15 +512,9 @@ var ClipwiseRecorder = class {
516
512
  if (this.cursorTimeline.length === 1) {
517
513
  return { ...this.cursorTimeline[0].position };
518
514
  }
519
- let before = this.cursorTimeline[0];
520
- let after = this.cursorTimeline[this.cursorTimeline.length - 1];
521
- for (let i = 0; i < this.cursorTimeline.length - 1; i++) {
522
- if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
523
- before = this.cursorTimeline[i];
524
- after = this.cursorTimeline[i + 1];
525
- break;
526
- }
527
- }
515
+ const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
516
+ const before = this.cursorTimeline[idx];
517
+ const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
528
518
  if (timestamp <= before.timestamp) return { ...before.position };
529
519
  if (timestamp >= after.timestamp) return { ...after.position };
530
520
  const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
@@ -537,6 +527,23 @@ var ClipwiseRecorder = class {
537
527
  )
538
528
  };
539
529
  }
530
+ /**
531
+ * Binary search: returns the index of the last entry whose timestamp <= target.
532
+ * Assumes the array is sorted by timestamp in ascending order.
533
+ */
534
+ binarySearchTimeline(timeline, target) {
535
+ let lo = 0;
536
+ let hi = timeline.length - 1;
537
+ while (lo < hi) {
538
+ const mid = lo + hi + 1 >> 1;
539
+ if (timeline[mid].timestamp <= target) {
540
+ lo = mid;
541
+ } else {
542
+ hi = mid - 1;
543
+ }
544
+ }
545
+ return lo;
546
+ }
540
547
  /**
541
548
  * Clean up browser resources. Always called after recording.
542
549
  */
@@ -561,7 +568,13 @@ var ClipwiseRecorder = class {
561
568
  };
562
569
 
563
570
  // src/compose/canvas-renderer.ts
564
- import sharp8 from "sharp";
571
+ import { Worker } from "worker_threads";
572
+ import os from "os";
573
+ import { existsSync } from "fs";
574
+ import { fileURLToPath } from "url";
575
+
576
+ // src/compose/compose-frame.ts
577
+ import sharp7 from "sharp";
565
578
 
566
579
  // src/effects/frame.ts
567
580
  import sharp from "sharp";
@@ -584,91 +597,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
584
597
  var ANDROID_OUTER_RADIUS = 35;
585
598
  var ANDROID_INNER_RADIUS = 30;
586
599
  var ANDROID_CAMERA_RADIUS = 6;
587
- function buildBrowserChromeSvg(width, darkMode) {
600
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
588
601
  const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
589
602
  const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
590
603
  const addressBorder = darkMode ? "#444444" : "#d0d0d0";
591
604
  const textColor = darkMode ? "#999999" : "#666666";
605
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
606
+ const tlY = TRAFFIC_LIGHT_Y * dpr;
607
+ const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
608
+ const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
609
+ const tlGap = TRAFFIC_LIGHT_GAP * dpr;
610
+ const aBarH = ADDRESS_BAR_HEIGHT * dpr;
611
+ const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
612
+ const fontSize = 12 * dpr;
592
613
  const trafficLights = [
593
- { cx: TRAFFIC_LIGHTS_START_X, fill: "#ff5f57" },
594
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP, fill: "#febc2e" },
595
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP * 2, fill: "#28c840" }
614
+ { cx: tlStartX, fill: "#ff5f57" },
615
+ { cx: tlStartX + tlGap, fill: "#febc2e" },
616
+ { cx: tlStartX + tlGap * 2, fill: "#28c840" }
596
617
  ].map(
597
- (light) => `<circle cx="${light.cx}" cy="${TRAFFIC_LIGHT_Y}" r="${TRAFFIC_LIGHT_RADIUS}" fill="${light.fill}"/>`
618
+ (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
598
619
  ).join("\n ");
599
- const addressBarWidth = width - ADDRESS_BAR_MARGIN * 2;
600
- const addressBarX = ADDRESS_BAR_MARGIN;
601
- const addressBarY = (TITLE_BAR_HEIGHT - ADDRESS_BAR_HEIGHT) / 2;
602
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${TITLE_BAR_HEIGHT}">
603
- <rect width="${width}" height="${TITLE_BAR_HEIGHT}" fill="${bg}"/>
620
+ const addressBarWidth = width - aBarMargin * 2;
621
+ const addressBarX = aBarMargin;
622
+ const addressBarY = (tbarH - aBarH) / 2;
623
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
624
+ <rect width="${width}" height="${tbarH}" fill="${bg}"/>
604
625
  ${trafficLights}
605
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${ADDRESS_BAR_HEIGHT}"
606
- rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="1"/>
607
- <text x="${width / 2}" y="${TRAFFIC_LIGHT_Y + 4}" text-anchor="middle"
608
- font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="${textColor}">
626
+ <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
627
+ rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
628
+ <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
629
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
609
630
  localhost
610
631
  </text>
611
632
  </svg>`;
612
633
  }
613
- function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
634
+ function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
614
635
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
615
636
  const islandColor = darkMode ? "#000000" : "#1a1a1a";
616
637
  const homeBarColor = darkMode ? "#555555" : "#333333";
617
- const islandX = (totalWidth - IPHONE_ISLAND.width) / 2;
618
- const islandY = (IPHONE_BEZEL.top - IPHONE_ISLAND.height) / 2 + 4;
619
- const homeBarX = (totalWidth - IPHONE_HOME_BAR.width) / 2;
620
- const homeBarY = totalHeight - IPHONE_BEZEL.bottom / 2 - IPHONE_HOME_BAR.height / 2;
621
- const screenX = IPHONE_BEZEL.sides;
622
- const screenY = IPHONE_BEZEL.top;
638
+ const bezelTop = IPHONE_BEZEL.top * dpr;
639
+ const bezelBottom = IPHONE_BEZEL.bottom * dpr;
640
+ const bezelSides = IPHONE_BEZEL.sides * dpr;
641
+ const outerRadius = IPHONE_OUTER_RADIUS * dpr;
642
+ const innerRadius = IPHONE_INNER_RADIUS * dpr;
643
+ const islandW = IPHONE_ISLAND.width * dpr;
644
+ const islandH = IPHONE_ISLAND.height * dpr;
645
+ const homeBarW = IPHONE_HOME_BAR.width * dpr;
646
+ const homeBarH = IPHONE_HOME_BAR.height * dpr;
647
+ const islandX = (totalWidth - islandW) / 2;
648
+ const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
649
+ const homeBarX = (totalWidth - homeBarW) / 2;
650
+ const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
651
+ const screenX = bezelSides;
652
+ const screenY = bezelTop;
623
653
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
624
654
  <!-- Device body -->
625
655
  <rect width="${totalWidth}" height="${totalHeight}"
626
- rx="${IPHONE_OUTER_RADIUS}" ry="${IPHONE_OUTER_RADIUS}" fill="${bezelColor}"/>
656
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
627
657
  <!-- Screen cutout (transparent) -->
628
658
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
629
- rx="${IPHONE_INNER_RADIUS}" ry="${IPHONE_INNER_RADIUS}" fill="black"/>
659
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
630
660
  <!-- Dynamic Island pill -->
631
- <rect x="${islandX}" y="${islandY}" width="${IPHONE_ISLAND.width}" height="${IPHONE_ISLAND.height}"
632
- rx="${IPHONE_ISLAND.height / 2}" ry="${IPHONE_ISLAND.height / 2}" fill="${islandColor}"/>
661
+ <rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
662
+ rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
633
663
  <!-- Home indicator bar -->
634
- <rect x="${homeBarX}" y="${homeBarY}" width="${IPHONE_HOME_BAR.width}" height="${IPHONE_HOME_BAR.height}"
635
- rx="${IPHONE_HOME_BAR.height / 2}" ry="${IPHONE_HOME_BAR.height / 2}" fill="${homeBarColor}"/>
664
+ <rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
665
+ rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
636
666
  </svg>`;
637
667
  }
638
- function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
668
+ function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
639
669
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
640
670
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
641
- const screenX = IPAD_BEZEL.sides;
642
- const screenY = IPAD_BEZEL.top;
671
+ const screenX = IPAD_BEZEL.sides * dpr;
672
+ const screenY = IPAD_BEZEL.top * dpr;
643
673
  const cameraCx = totalWidth / 2;
644
- const cameraCy = IPAD_BEZEL.top / 2;
674
+ const cameraCy = IPAD_BEZEL.top * dpr / 2;
675
+ const outerRadius = IPAD_OUTER_RADIUS * dpr;
676
+ const innerRadius = IPAD_INNER_RADIUS * dpr;
645
677
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
646
678
  <!-- Device body -->
647
679
  <rect width="${totalWidth}" height="${totalHeight}"
648
- rx="${IPAD_OUTER_RADIUS}" ry="${IPAD_OUTER_RADIUS}" fill="${bezelColor}"/>
680
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
649
681
  <!-- Screen cutout -->
650
682
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
651
- rx="${IPAD_INNER_RADIUS}" ry="${IPAD_INNER_RADIUS}" fill="black"/>
683
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
652
684
  <!-- Front camera dot -->
653
- <circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
685
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
654
686
  </svg>`;
655
687
  }
656
- function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
688
+ function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
657
689
  const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
658
690
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
659
- const screenX = ANDROID_BEZEL.sides;
660
- const screenY = ANDROID_BEZEL.top;
691
+ const screenX = ANDROID_BEZEL.sides * dpr;
692
+ const screenY = ANDROID_BEZEL.top * dpr;
661
693
  const cameraCx = totalWidth / 2;
662
- const cameraCy = ANDROID_BEZEL.top / 2;
694
+ const cameraCy = ANDROID_BEZEL.top * dpr / 2;
695
+ const outerRadius = ANDROID_OUTER_RADIUS * dpr;
696
+ const innerRadius = ANDROID_INNER_RADIUS * dpr;
697
+ const cameraR = ANDROID_CAMERA_RADIUS * dpr;
663
698
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
664
699
  <!-- Device body -->
665
700
  <rect width="${totalWidth}" height="${totalHeight}"
666
- rx="${ANDROID_OUTER_RADIUS}" ry="${ANDROID_OUTER_RADIUS}" fill="${bezelColor}"/>
701
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
667
702
  <!-- Screen cutout -->
668
703
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
669
- rx="${ANDROID_INNER_RADIUS}" ry="${ANDROID_INNER_RADIUS}" fill="black"/>
704
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
670
705
  <!-- Punch-hole camera -->
671
- <circle cx="${cameraCx}" cy="${cameraCy}" r="${ANDROID_CAMERA_RADIUS}" fill="${cameraColor}"/>
706
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
672
707
  </svg>`;
673
708
  }
674
709
  function buildScreenMaskSvg(width, height, radius) {
@@ -676,21 +711,33 @@ function buildScreenMaskSvg(width, height, radius) {
676
711
  <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
677
712
  </svg>`;
678
713
  }
679
- async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
714
+ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
680
715
  let bezel;
681
716
  let innerRadius;
682
717
  switch (deviceType) {
683
718
  case "iphone":
684
- bezel = IPHONE_BEZEL;
685
- innerRadius = IPHONE_INNER_RADIUS;
719
+ bezel = {
720
+ sides: IPHONE_BEZEL.sides * dpr,
721
+ top: IPHONE_BEZEL.top * dpr,
722
+ bottom: IPHONE_BEZEL.bottom * dpr
723
+ };
724
+ innerRadius = IPHONE_INNER_RADIUS * dpr;
686
725
  break;
687
726
  case "ipad":
688
- bezel = IPAD_BEZEL;
689
- innerRadius = IPAD_INNER_RADIUS;
727
+ bezel = {
728
+ sides: IPAD_BEZEL.sides * dpr,
729
+ top: IPAD_BEZEL.top * dpr,
730
+ bottom: IPAD_BEZEL.bottom * dpr
731
+ };
732
+ innerRadius = IPAD_INNER_RADIUS * dpr;
690
733
  break;
691
734
  case "android":
692
- bezel = ANDROID_BEZEL;
693
- innerRadius = ANDROID_INNER_RADIUS;
735
+ bezel = {
736
+ sides: ANDROID_BEZEL.sides * dpr,
737
+ top: ANDROID_BEZEL.top * dpr,
738
+ bottom: ANDROID_BEZEL.bottom * dpr
739
+ };
740
+ innerRadius = ANDROID_INNER_RADIUS * dpr;
694
741
  break;
695
742
  }
696
743
  const totalWidth = frameWidth + bezel.sides * 2;
@@ -698,13 +745,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
698
745
  let frameSvg;
699
746
  switch (deviceType) {
700
747
  case "iphone":
701
- frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
748
+ frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
702
749
  break;
703
750
  case "ipad":
704
- frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
751
+ frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
705
752
  break;
706
753
  case "android":
707
- frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
754
+ frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
708
755
  break;
709
756
  }
710
757
  const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
@@ -727,12 +774,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
727
774
  { input: maskedScreen, left: bezel.sides, top: bezel.top }
728
775
  ]).png().toBuffer();
729
776
  }
730
- async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
777
+ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
731
778
  if (!config.enabled || config.type === "none") return frameBuffer;
732
779
  switch (config.type) {
733
780
  case "browser": {
734
- const totalHeight = frameHeight + TITLE_BAR_HEIGHT;
735
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode);
781
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
782
+ const totalHeight = frameHeight + tbarH;
783
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
736
784
  const chromeBuffer = Buffer.from(chromeSvg);
737
785
  const canvas = await sharp({
738
786
  create: {
@@ -744,13 +792,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
744
792
  }).png().toBuffer();
745
793
  return sharp(canvas).composite([
746
794
  { input: chromeBuffer, left: 0, top: 0 },
747
- { input: frameBuffer, left: 0, top: TITLE_BAR_HEIGHT }
795
+ { input: frameBuffer, left: 0, top: tbarH }
748
796
  ]).png().toBuffer();
749
797
  }
750
798
  case "iphone":
751
799
  case "ipad":
752
800
  case "android":
753
- return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
801
+ return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
754
802
  default:
755
803
  return frameBuffer;
756
804
  }
@@ -760,7 +808,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
760
808
  import sharp2 from "sharp";
761
809
  function buildCursorSvg(size, color) {
762
810
  const s = size;
763
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
811
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
764
812
  <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
765
813
  fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
766
814
  </svg>`;
@@ -769,7 +817,7 @@ function buildClickRippleSvg(radius, color, progress) {
769
817
  const currentRadius = radius * progress;
770
818
  const opacity = Math.max(0, 1 - progress);
771
819
  const size = Math.ceil(radius * 2 + 4);
772
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
820
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
773
821
  <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
774
822
  fill="none" stroke="${color}" stroke-width="2"
775
823
  opacity="${opacity.toFixed(3)}"/>
@@ -777,47 +825,35 @@ function buildClickRippleSvg(radius, color, progress) {
777
825
  fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
778
826
  </svg>`;
779
827
  }
780
- async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
828
+ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
781
829
  if (!config.enabled) return frameBuffer;
782
- const cursorSvg = buildCursorSvg(config.size, config.color);
830
+ const size = Math.round(config.size * dpr);
831
+ const cursorSvg = buildCursorSvg(size, config.color);
783
832
  const cursorBuffer = Buffer.from(cursorSvg);
784
- const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
785
- const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
833
+ const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
834
+ const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
786
835
  return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
787
836
  }
788
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
837
+ async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
789
838
  if (!config.enabled || !config.clickEffect) return frameBuffer;
839
+ const radius = config.clickRadius * dpr;
790
840
  const clampedProgress = Math.max(0, Math.min(1, progress));
791
- const rippleSvg = buildClickRippleSvg(
792
- config.clickRadius,
793
- config.clickColor,
794
- clampedProgress
795
- );
841
+ const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
796
842
  const rippleBuffer = Buffer.from(rippleSvg);
797
- const rippleSize = Math.ceil(config.clickRadius * 2 + 4);
798
- const left = Math.max(
799
- 0,
800
- Math.min(
801
- Math.round(position.x - rippleSize / 2),
802
- frameWidth - rippleSize
803
- )
804
- );
805
- const top = Math.max(
806
- 0,
807
- Math.min(
808
- Math.round(position.y - rippleSize / 2),
809
- frameHeight - rippleSize
810
- )
811
- );
843
+ const rippleSize = Math.ceil(radius * 2 + 4);
844
+ const px = Math.round(position.x * dpr);
845
+ const py = Math.round(position.y * dpr);
846
+ const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
847
+ const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
812
848
  return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
813
849
  }
814
- async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
850
+ async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
815
851
  if (!config.enabled || !config.highlight) return frameBuffer;
816
- const r = config.highlightRadius;
852
+ const r = config.highlightRadius * dpr;
817
853
  const size = Math.ceil(r * 2 + 4);
818
854
  const cx = size / 2;
819
855
  const cy = size / 2;
820
- const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
856
+ const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
821
857
  <defs>
822
858
  <radialGradient id="glow">
823
859
  <stop offset="0%" stop-color="${config.highlightColor}" />
@@ -827,27 +863,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
827
863
  </defs>
828
864
  <circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
829
865
  </svg>`;
830
- const left = Math.max(0, Math.min(Math.round(position.x - cx), frameWidth - size));
831
- const top = Math.max(0, Math.min(Math.round(position.y - cy), frameHeight - size));
866
+ const px = Math.round(position.x * dpr);
867
+ const py = Math.round(position.y * dpr);
868
+ const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
869
+ const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
832
870
  return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
833
871
  }
834
- async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
872
+ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
835
873
  if (!config.enabled || !config.trail || positions.length < 2) {
836
874
  return frameBuffer;
837
875
  }
838
876
  const segments = [];
839
877
  for (let i = 1; i < positions.length; i++) {
840
878
  const opacity = i / positions.length * 0.6;
841
- const strokeWidth = 1 + i / positions.length * 2;
879
+ const strokeWidth = (1 + i / positions.length * 2) * dpr;
842
880
  const p1 = positions[i - 1];
843
881
  const p2 = positions[i];
844
882
  segments.push(
845
- `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
883
+ `<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
846
884
  stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
847
885
  stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
848
886
  );
849
887
  }
850
- const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
888
+ const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
851
889
  ${segments.join("\n ")}
852
890
  </svg>`;
853
891
  return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
@@ -993,7 +1031,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
993
1031
 
994
1032
  // src/effects/keystroke.ts
995
1033
  import sharp5 from "sharp";
996
- async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
1034
+ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
997
1035
  if (!config.enabled || keystrokes.length === 0) return frameBuffer;
998
1036
  const recentKeys = keystrokes.filter(
999
1037
  (k) => frameTimestamp - k.timestamp < config.fadeAfter
@@ -1001,25 +1039,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1001
1039
  if (recentKeys.length === 0) return frameBuffer;
1002
1040
  const displayText = recentKeys.map((k) => k.key).join("");
1003
1041
  if (displayText.length === 0) return frameBuffer;
1004
- const charWidth = config.fontSize * 0.62;
1042
+ const fontSize = config.fontSize * dpr;
1043
+ const padding = config.padding * dpr;
1044
+ const charWidth = fontSize * 0.62;
1005
1045
  const textWidth = Math.ceil(displayText.length * charWidth);
1006
- const hudPadH = config.padding * 2;
1007
- const hudPadV = config.padding * 1.5;
1008
- const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
1009
- const hudHeight = Math.ceil(config.fontSize + hudPadV * 2);
1046
+ const hudPadH = padding * 2;
1047
+ const hudPadV = padding * 1.5;
1048
+ const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
1049
+ const hudHeight = Math.ceil(fontSize + hudPadV * 2);
1010
1050
  const newest = recentKeys[recentKeys.length - 1];
1011
1051
  const age = frameTimestamp - newest.timestamp;
1012
1052
  const fadeStart = config.fadeAfter * 0.6;
1013
1053
  const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1014
1054
  if (opacity <= 0) return frameBuffer;
1055
+ const margin = 30 * dpr;
1015
1056
  let hudX;
1016
- const hudY = frameHeight - hudHeight - 30;
1057
+ const hudY = frameHeight - hudHeight - margin;
1017
1058
  switch (config.position) {
1018
1059
  case "bottom-left":
1019
- hudX = 30;
1060
+ hudX = margin;
1020
1061
  break;
1021
1062
  case "bottom-right":
1022
- hudX = frameWidth - hudWidth - 30;
1063
+ hudX = frameWidth - hudWidth - margin;
1023
1064
  break;
1024
1065
  case "bottom-center":
1025
1066
  default:
@@ -1029,41 +1070,18 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1029
1070
  const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
1030
1071
  const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
1031
1072
  const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1032
- const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1073
+ const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1033
1074
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
1034
- rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1035
- <text x="${hudX + hudPadH}" y="${hudY + hudPadV + config.fontSize * 0.75}"
1036
- font-family="monospace, Menlo, Consolas" font-size="${config.fontSize}"
1075
+ rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1076
+ <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
1077
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1037
1078
  fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
1038
1079
  </svg>`;
1039
1080
  return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
1040
1081
  }
1041
1082
 
1042
- // src/effects/transition.ts
1043
- import sharp6 from "sharp";
1044
- async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1045
- const t = Math.max(0, Math.min(1, progress));
1046
- if (t <= 0) return fromBuffer;
1047
- if (t >= 1) return toBuffer;
1048
- const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1049
- const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1050
- const pixels = Buffer.alloc(fromRaw.data.length);
1051
- for (let i = 0; i < fromRaw.data.length; i++) {
1052
- pixels[i] = Math.round(
1053
- fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1054
- );
1055
- }
1056
- return sharp6(pixels, {
1057
- raw: {
1058
- width: fromRaw.info.width,
1059
- height: fromRaw.info.height,
1060
- channels: 4
1061
- }
1062
- }).png().toBuffer();
1063
- }
1064
-
1065
1083
  // src/effects/watermark.ts
1066
- import sharp7 from "sharp";
1084
+ import sharp6 from "sharp";
1067
1085
  async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1068
1086
  if (!config.enabled || !config.text) return frameBuffer;
1069
1087
  const charWidth = config.fontSize * 0.62;
@@ -1091,31 +1109,168 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1091
1109
  break;
1092
1110
  }
1093
1111
  const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1094
- const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1112
+ const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1095
1113
  <text x="${x}" y="${y}"
1096
1114
  font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
1097
1115
  font-weight="600" fill="${config.color}"
1098
1116
  opacity="${config.opacity.toFixed(3)}">${escaped}</text>
1099
1117
  </svg>`;
1100
- return sharp7(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
1118
+ return sharp6(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
1101
1119
  }
1102
1120
 
1103
- // src/compose/canvas-renderer.ts
1104
- function getFrameOffset(config) {
1121
+ // src/compose/compose-frame.ts
1122
+ function getFrameOffset(config, dpr = 1) {
1105
1123
  if (!config.enabled) return { left: 0, top: 0 };
1106
1124
  switch (config.type) {
1107
1125
  case "browser":
1108
- return { left: 0, top: 40 };
1126
+ return { left: 0, top: 40 * dpr };
1109
1127
  case "iphone":
1110
- return { left: 12, top: 50 };
1128
+ return { left: 12 * dpr, top: 50 * dpr };
1111
1129
  case "ipad":
1112
- return { left: 20, top: 24 };
1130
+ return { left: 20 * dpr, top: 24 * dpr };
1113
1131
  case "android":
1114
- return { left: 8, top: 32 };
1132
+ return { left: 8 * dpr, top: 32 * dpr };
1115
1133
  default:
1116
1134
  return { left: 0, top: 0 };
1117
1135
  }
1118
1136
  }
1137
+ async function composeFrame(frame, effects, output, context) {
1138
+ let buffer = frame.screenshot;
1139
+ const meta = await sharp7(buffer).metadata();
1140
+ let width = meta.width ?? frame.viewport.width;
1141
+ let height = meta.height ?? frame.viewport.height;
1142
+ const dpr = Math.round(width / frame.viewport.width);
1143
+ const ctx = {
1144
+ zoomScale: context?.zoomScale ?? 1,
1145
+ clickProgress: context?.clickProgress ?? null,
1146
+ cursorTrail: context?.cursorTrail ?? []
1147
+ };
1148
+ if (effects.deviceFrame.enabled) {
1149
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1150
+ const meta2 = await sharp7(buffer).metadata();
1151
+ width = meta2.width ?? width;
1152
+ height = meta2.height ?? height;
1153
+ }
1154
+ if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1155
+ buffer = await renderCursorHighlight(
1156
+ buffer,
1157
+ frame.cursorPosition,
1158
+ effects.cursor,
1159
+ width,
1160
+ height,
1161
+ dpr
1162
+ );
1163
+ }
1164
+ if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1165
+ buffer = await renderCursorTrail(
1166
+ buffer,
1167
+ ctx.cursorTrail,
1168
+ effects.cursor,
1169
+ width,
1170
+ height,
1171
+ dpr
1172
+ );
1173
+ }
1174
+ if (effects.cursor.enabled && frame.cursorPosition) {
1175
+ buffer = await renderCursor(
1176
+ buffer,
1177
+ frame.cursorPosition,
1178
+ effects.cursor,
1179
+ width,
1180
+ height,
1181
+ dpr
1182
+ );
1183
+ }
1184
+ if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
1185
+ const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1186
+ buffer = await renderClickEffect(
1187
+ buffer,
1188
+ frame.clickPosition,
1189
+ effects.cursor,
1190
+ progress,
1191
+ width,
1192
+ height,
1193
+ dpr
1194
+ );
1195
+ }
1196
+ if (effects.keystroke.enabled && frame.keystrokes) {
1197
+ buffer = await renderKeystrokeHud(
1198
+ buffer,
1199
+ frame.keystrokes,
1200
+ frame.timestamp,
1201
+ effects.keystroke,
1202
+ width,
1203
+ height,
1204
+ dpr
1205
+ );
1206
+ }
1207
+ const scale = ctx.zoomScale;
1208
+ if (effects.zoom.enabled && scale > 1) {
1209
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1210
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
1211
+ const focusPoint = {
1212
+ x: rawFocus.x * dpr + offset.left,
1213
+ y: rawFocus.y * dpr + offset.top
1214
+ };
1215
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1216
+ }
1217
+ buffer = await applyBackground(buffer, effects.background, output.width, output.height);
1218
+ if (effects.watermark.enabled) {
1219
+ buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
1220
+ }
1221
+ buffer = await sharp7(buffer).resize(output.width, output.height, {
1222
+ fit: "fill",
1223
+ kernel: sharp7.kernel.lanczos3
1224
+ }).png().toBuffer();
1225
+ return { index: frame.index, buffer, timestamp: frame.timestamp };
1226
+ }
1227
+
1228
+ // src/effects/transition.ts
1229
+ import sharp8 from "sharp";
1230
+ async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1231
+ const t = Math.max(0, Math.min(1, progress));
1232
+ if (t <= 0) return fromBuffer;
1233
+ if (t >= 1) return toBuffer;
1234
+ const fromRaw = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1235
+ const toRaw = await sharp8(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1236
+ const pixels = Buffer.alloc(fromRaw.data.length);
1237
+ for (let i = 0; i < fromRaw.data.length; i++) {
1238
+ pixels[i] = Math.round(
1239
+ fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1240
+ );
1241
+ }
1242
+ return sharp8(pixels, {
1243
+ raw: {
1244
+ width: fromRaw.info.width,
1245
+ height: fromRaw.info.height,
1246
+ channels: 4
1247
+ }
1248
+ }).png().toBuffer();
1249
+ }
1250
+
1251
+ // src/compose/canvas-renderer.ts
1252
+ var MIN_FRAMES_PER_WORKER = 4;
1253
+ var cachedWorkerUrl = null;
1254
+ function getWorkerUrl() {
1255
+ if (cachedWorkerUrl) return cachedWorkerUrl;
1256
+ const base = import.meta.url;
1257
+ const candidates = [
1258
+ new URL("./frame-worker.js", base),
1259
+ // from dist/compose/
1260
+ new URL("../compose/frame-worker.js", base),
1261
+ // from dist/cli/
1262
+ new URL("./compose/frame-worker.js", base)
1263
+ // from dist/
1264
+ ];
1265
+ for (const url of candidates) {
1266
+ if (existsSync(fileURLToPath(url))) {
1267
+ cachedWorkerUrl = url;
1268
+ return url;
1269
+ }
1270
+ }
1271
+ cachedWorkerUrl = candidates[1];
1272
+ return cachedWorkerUrl;
1273
+ }
1119
1274
  var CanvasRenderer = class {
1120
1275
  constructor(effects, output, steps) {
1121
1276
  this.effects = effects;
@@ -1124,118 +1279,11 @@ var CanvasRenderer = class {
1124
1279
  }
1125
1280
  steps;
1126
1281
  /**
1127
- * Apply the full effects pipeline to a single captured frame.
1128
- *
1129
- * Pipeline order:
1130
- * 1. Device frame (browser chrome / mobile mockup)
1131
- * 2. Cursor highlight (Screen Studio glow)
1132
- * 3. Cursor trail
1133
- * 4. Cursor rendering
1134
- * 5. Click ripple effect (animated progress)
1135
- * 6. Keystroke HUD
1136
- * 7. Zoom (adaptive, cursor-following)
1137
- * 8. Background (padding, gradient, rounded corners)
1138
- * 9. Watermark overlay
1139
- * 10. Final resize
1282
+ * Apply the full effects pipeline to a single frame.
1283
+ * Delegates to the standalone composeFrame function.
1140
1284
  */
1141
1285
  async composeFrame(frame, context) {
1142
- let buffer = frame.screenshot;
1143
- let width = frame.viewport.width;
1144
- let height = frame.viewport.height;
1145
- const ctx = {
1146
- zoomScale: context?.zoomScale ?? 1,
1147
- clickProgress: context?.clickProgress ?? null,
1148
- cursorTrail: context?.cursorTrail ?? []
1149
- };
1150
- if (this.effects.deviceFrame.enabled) {
1151
- buffer = await applyDeviceFrame(
1152
- buffer,
1153
- this.effects.deviceFrame,
1154
- width,
1155
- height
1156
- );
1157
- const meta = await sharp8(buffer).metadata();
1158
- width = meta.width ?? width;
1159
- height = meta.height ?? height;
1160
- }
1161
- if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
1162
- buffer = await renderCursorHighlight(
1163
- buffer,
1164
- frame.cursorPosition,
1165
- this.effects.cursor,
1166
- width,
1167
- height
1168
- );
1169
- }
1170
- if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1171
- buffer = await renderCursorTrail(
1172
- buffer,
1173
- ctx.cursorTrail,
1174
- this.effects.cursor,
1175
- width,
1176
- height
1177
- );
1178
- }
1179
- if (this.effects.cursor.enabled && frame.cursorPosition) {
1180
- buffer = await renderCursor(
1181
- buffer,
1182
- frame.cursorPosition,
1183
- this.effects.cursor,
1184
- width,
1185
- height
1186
- );
1187
- }
1188
- if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
1189
- const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1190
- buffer = await renderClickEffect(
1191
- buffer,
1192
- frame.clickPosition,
1193
- this.effects.cursor,
1194
- progress,
1195
- width,
1196
- height
1197
- );
1198
- }
1199
- if (this.effects.keystroke.enabled && frame.keystrokes) {
1200
- buffer = await renderKeystrokeHud(
1201
- buffer,
1202
- frame.keystrokes,
1203
- frame.timestamp,
1204
- this.effects.keystroke,
1205
- width,
1206
- height
1207
- );
1208
- }
1209
- const scale = ctx.zoomScale;
1210
- if (this.effects.zoom.enabled && scale > 1) {
1211
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
1212
- const offset = getFrameOffset(this.effects.deviceFrame);
1213
- const focusPoint = {
1214
- x: rawFocus.x + offset.left,
1215
- y: rawFocus.y + offset.top
1216
- };
1217
- buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1218
- }
1219
- buffer = await applyBackground(
1220
- buffer,
1221
- this.effects.background,
1222
- this.output.width,
1223
- this.output.height
1224
- );
1225
- if (this.effects.watermark.enabled) {
1226
- buffer = await renderWatermark(
1227
- buffer,
1228
- this.effects.watermark,
1229
- this.output.width,
1230
- this.output.height
1231
- );
1232
- }
1233
- buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
1234
- return {
1235
- index: frame.index,
1236
- buffer,
1237
- timestamp: frame.timestamp
1238
- };
1286
+ return composeFrame(frame, this.effects, this.output, context);
1239
1287
  }
1240
1288
  /**
1241
1289
  * Process an entire sequence of captured frames through the effects pipeline.
@@ -1243,7 +1291,7 @@ var CanvasRenderer = class {
1243
1291
  * Multi-pass approach:
1244
1292
  * Pass 1: Speed ramping (adjust frame set).
1245
1293
  * Pass 2: Calculate per-frame contexts (zoom, click, trail).
1246
- * Pass 3: Render each frame with effects.
1294
+ * Pass 3: Render frames in parallel using worker threads.
1247
1295
  * Pass 4: Apply scene transitions at step boundaries.
1248
1296
  */
1249
1297
  async composeAll(frames) {
@@ -1253,10 +1301,19 @@ var CanvasRenderer = class {
1253
1301
  processFrames = this.applySpeedRamp(frames);
1254
1302
  }
1255
1303
  const contexts = this.calculateFrameContexts(processFrames);
1256
- const composed = [];
1257
- for (let i = 0; i < processFrames.length; i++) {
1258
- const result = await this.composeFrame(processFrames[i], contexts[i]);
1259
- composed.push(result);
1304
+ const cpuCount = os.cpus().length;
1305
+ const workerCount = Math.min(cpuCount, 8);
1306
+ const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
1307
+ let composed;
1308
+ if (useWorkers) {
1309
+ composed = await this.processWithWorkers(processFrames, contexts, workerCount);
1310
+ } else {
1311
+ composed = [];
1312
+ for (let i = 0; i < processFrames.length; i++) {
1313
+ composed.push(
1314
+ await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
1315
+ );
1316
+ }
1260
1317
  }
1261
1318
  if (this.steps.length > 0) {
1262
1319
  await this.applyTransitions(composed, processFrames);
@@ -1264,7 +1321,64 @@ var CanvasRenderer = class {
1264
1321
  return composed;
1265
1322
  }
1266
1323
  /**
1267
- * Calculate per-frame rendering context (zoom, click progress, cursor trail, tilt).
1324
+ * Distribute frame composition across a pool of worker threads.
1325
+ * Workers process frames concurrently; results are collected in order.
1326
+ */
1327
+ processWithWorkers(frames, contexts, workerCount) {
1328
+ return new Promise((resolve, reject) => {
1329
+ const results = new Array(frames.length);
1330
+ let completed = 0;
1331
+ let nextIndex = 0;
1332
+ let failed = false;
1333
+ const workerUrl = getWorkerUrl();
1334
+ const workers = [];
1335
+ const dispatch = (worker) => {
1336
+ if (nextIndex >= frames.length || failed) return;
1337
+ const i = nextIndex++;
1338
+ worker.postMessage({
1339
+ taskId: i,
1340
+ frame: frames[i],
1341
+ effects: this.effects,
1342
+ output: this.output,
1343
+ context: contexts[i]
1344
+ });
1345
+ };
1346
+ for (let w = 0; w < workerCount; w++) {
1347
+ const worker = new Worker(workerUrl);
1348
+ workers.push(worker);
1349
+ worker.on("message", (msg) => {
1350
+ if (failed) return;
1351
+ if (msg.error) {
1352
+ failed = true;
1353
+ workers.forEach((wk) => wk.terminate());
1354
+ reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
1355
+ return;
1356
+ }
1357
+ results[msg.taskId] = {
1358
+ index: frames[msg.taskId].index,
1359
+ buffer: Buffer.from(msg.buffer),
1360
+ timestamp: frames[msg.taskId].timestamp
1361
+ };
1362
+ completed++;
1363
+ if (completed === frames.length) {
1364
+ workers.forEach((wk) => wk.terminate());
1365
+ resolve(results);
1366
+ } else {
1367
+ dispatch(worker);
1368
+ }
1369
+ });
1370
+ worker.on("error", (err) => {
1371
+ if (failed) return;
1372
+ failed = true;
1373
+ workers.forEach((wk) => wk.terminate());
1374
+ reject(err);
1375
+ });
1376
+ dispatch(worker);
1377
+ }
1378
+ });
1379
+ }
1380
+ /**
1381
+ * Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
1268
1382
  */
1269
1383
  calculateFrameContexts(frames) {
1270
1384
  const contexts = [];
@@ -1296,7 +1410,6 @@ var CanvasRenderer = class {
1296
1410
  }
1297
1411
  /**
1298
1412
  * Apply speed ramping: slow down near actions, speed up during idle.
1299
- * Returns a new frame array with frames duplicated or skipped.
1300
1413
  */
1301
1414
  applySpeedRamp(frames) {
1302
1415
  const config = this.effects.speedRamp;
@@ -1329,7 +1442,6 @@ var CanvasRenderer = class {
1329
1442
  }
1330
1443
  /**
1331
1444
  * Apply crossfade transitions at step boundaries where configured.
1332
- * Modifies the composed array in-place.
1333
1445
  */
1334
1446
  async applyTransitions(composed, frames) {
1335
1447
  const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
@@ -1350,16 +1462,14 @@ var CanvasRenderer = class {
1350
1462
  if (range < 2) continue;
1351
1463
  const fromBuffer = composed[startIdx].buffer;
1352
1464
  const toBuffer = composed[endIdx].buffer;
1353
- const width = this.output.width;
1354
- const height = this.output.height;
1355
1465
  for (let i = startIdx + 1; i < endIdx; i++) {
1356
1466
  const progress = (i - startIdx) / range;
1357
1467
  composed[i].buffer = await applyCrossfade(
1358
1468
  fromBuffer,
1359
1469
  toBuffer,
1360
1470
  progress,
1361
- width,
1362
- height
1471
+ this.output.width,
1472
+ this.output.height
1363
1473
  );
1364
1474
  }
1365
1475
  }
@@ -1369,11 +1479,45 @@ var CanvasRenderer = class {
1369
1479
  // src/compose/video-encoder.ts
1370
1480
  import gifenc from "gifenc";
1371
1481
  import sharp9 from "sharp";
1372
- import { writeFile, mkdir, readFile, rm, mkdtemp } from "fs/promises";
1482
+ import { writeFile, mkdir, readFile, rm } from "fs/promises";
1373
1483
  import { join } from "path";
1374
1484
  import { tmpdir } from "os";
1375
1485
  import { spawn } from "child_process";
1376
1486
  var { GIFEncoder, quantize, applyPalette } = gifenc;
1487
+ var ENCODING_PRESETS = {
1488
+ social: { crf: 22, vtQuality: 75 },
1489
+ balanced: { crf: 18, vtQuality: 85 },
1490
+ archive: { crf: 13, vtQuality: 92 }
1491
+ };
1492
+ function resolveEncodingParams(config) {
1493
+ if (config.preset) return ENCODING_PRESETS[config.preset];
1494
+ process.stderr.write(
1495
+ `[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
1496
+ `
1497
+ );
1498
+ if (config.quality >= 75) return ENCODING_PRESETS.social;
1499
+ if (config.quality >= 45) return ENCODING_PRESETS.balanced;
1500
+ return ENCODING_PRESETS.archive;
1501
+ }
1502
+ var encoderDetectionPromise = null;
1503
+ function detectVideoEncoder() {
1504
+ if (!encoderDetectionPromise) {
1505
+ encoderDetectionPromise = new Promise((resolve) => {
1506
+ const proc = spawn("ffmpeg", ["-encoders"], {
1507
+ stdio: ["ignore", "pipe", "ignore"]
1508
+ });
1509
+ let out = "";
1510
+ proc.stdout.on("data", (d) => out += d.toString());
1511
+ proc.on("close", () => {
1512
+ if (out.includes("hevc_videotoolbox")) resolve("hevc_videotoolbox");
1513
+ else if (out.includes("h264_videotoolbox")) resolve("h264_videotoolbox");
1514
+ else resolve("libx264");
1515
+ });
1516
+ proc.on("error", () => resolve("libx264"));
1517
+ });
1518
+ }
1519
+ return encoderDetectionPromise;
1520
+ }
1377
1521
  async function encodeGif(frames, config) {
1378
1522
  if (frames.length === 0) {
1379
1523
  throw new Error("Cannot encode GIF: no frames provided");
@@ -1387,10 +1531,7 @@ async function encodeGif(frames, config) {
1387
1531
  const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1388
1532
  const palette = quantize(rgba, 256);
1389
1533
  const indexed = applyPalette(rgba, palette);
1390
- gif.writeFrame(indexed, width, height, {
1391
- palette,
1392
- delay
1393
- });
1534
+ gif.writeFrame(indexed, width, height, { palette, delay });
1394
1535
  }
1395
1536
  gif.finish();
1396
1537
  return Buffer.from(gif.bytes());
@@ -1399,50 +1540,87 @@ async function encodeMp4(frames, config) {
1399
1540
  if (frames.length === 0) {
1400
1541
  throw new Error("Cannot encode MP4: no frames provided");
1401
1542
  }
1402
- const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
1543
+ const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
1403
1544
  try {
1404
- const padLength = String(frames.length).length;
1405
- for (const frame of frames) {
1406
- const paddedIndex = String(frame.index).padStart(padLength, "0");
1407
- const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
1408
- await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
1409
- }
1410
- const outputPath = join(tmpDir, "output.mp4");
1411
- const crf = Math.round(51 - config.quality / 100 * 51);
1412
- await runFfmpeg([
1413
- "-y",
1414
- "-framerate",
1415
- String(config.fps),
1416
- "-i",
1417
- join(tmpDir, `frame-%0${padLength}d.png`),
1418
- "-c:v",
1419
- "libx264",
1420
- "-pix_fmt",
1421
- "yuv420p",
1422
- "-crf",
1423
- String(crf),
1424
- "-preset",
1425
- "slow",
1426
- "-tune",
1427
- "animation",
1428
- "-movflags",
1429
- "+faststart",
1430
- outputPath
1431
- ]);
1545
+ const encoder = await detectVideoEncoder();
1546
+ const params = resolveEncodingParams(config);
1547
+ await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
1432
1548
  return await readFile(outputPath);
1433
1549
  } finally {
1434
- await rm(tmpDir, { recursive: true, force: true }).catch(() => {
1550
+ await rm(outputPath, { force: true }).catch(() => {
1435
1551
  });
1436
1552
  }
1437
1553
  }
1438
- function runFfmpeg(args) {
1554
+ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1555
+ const videoArgs = encoder === "hevc_videotoolbox" ? [
1556
+ "-c:v",
1557
+ "hevc_videotoolbox",
1558
+ "-q:v",
1559
+ String(params.vtQuality),
1560
+ "-pix_fmt",
1561
+ "yuv420p",
1562
+ "-tag:v",
1563
+ "hvc1"
1564
+ // required for playback in QuickTime / Apple devices
1565
+ ] : encoder === "h264_videotoolbox" ? [
1566
+ "-c:v",
1567
+ "h264_videotoolbox",
1568
+ "-q:v",
1569
+ String(params.vtQuality),
1570
+ "-pix_fmt",
1571
+ "yuv420p"
1572
+ ] : [
1573
+ "-c:v",
1574
+ "libx264",
1575
+ "-crf",
1576
+ String(params.crf),
1577
+ "-preset",
1578
+ "medium",
1579
+ "-tune",
1580
+ "stillimage",
1581
+ "-profile:v",
1582
+ "high",
1583
+ "-level",
1584
+ "4.1",
1585
+ "-pix_fmt",
1586
+ "yuv420p"
1587
+ ];
1439
1588
  return new Promise((resolve, reject) => {
1440
- const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
1589
+ const ffmpeg = spawn(
1590
+ "ffmpeg",
1591
+ [
1592
+ "-y",
1593
+ // Video input: raw RGB24 from stdin
1594
+ "-f",
1595
+ "rawvideo",
1596
+ "-pixel_format",
1597
+ "rgb24",
1598
+ "-video_size",
1599
+ `${config.width}x${config.height}`,
1600
+ "-framerate",
1601
+ String(config.fps),
1602
+ "-i",
1603
+ "pipe:0",
1604
+ // Silent audio track for platform compatibility
1605
+ "-f",
1606
+ "lavfi",
1607
+ "-i",
1608
+ "anullsrc=r=48000:cl=stereo",
1609
+ ...videoArgs,
1610
+ "-c:a",
1611
+ "aac",
1612
+ "-b:a",
1613
+ "128k",
1614
+ "-shortest",
1615
+ "-movflags",
1616
+ "+faststart",
1617
+ outputPath
1618
+ ],
1619
+ { stdio: ["pipe", "ignore", "pipe"] }
1620
+ );
1441
1621
  let stderr = "";
1442
- proc.stderr.on("data", (data) => {
1443
- stderr += data.toString();
1444
- });
1445
- proc.on("close", (code) => {
1622
+ ffmpeg.stderr.on("data", (d) => stderr += d.toString());
1623
+ ffmpeg.on("close", (code) => {
1446
1624
  if (code === 0) {
1447
1625
  resolve();
1448
1626
  } else {
@@ -1454,7 +1632,7 @@ function runFfmpeg(args) {
1454
1632
  );
1455
1633
  }
1456
1634
  });
1457
- proc.on("error", (err) => {
1635
+ ffmpeg.on("error", (err) => {
1458
1636
  if (err.code === "ENOENT") {
1459
1637
  reject(
1460
1638
  new Error(
@@ -1465,6 +1643,15 @@ function runFfmpeg(args) {
1465
1643
  reject(err);
1466
1644
  }
1467
1645
  });
1646
+ (async () => {
1647
+ for (const frame of frames) {
1648
+ const raw = await sharp9(frame.buffer).flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
1649
+ if (!ffmpeg.stdin.write(raw)) {
1650
+ await new Promise((r) => ffmpeg.stdin.once("drain", r));
1651
+ }
1652
+ }
1653
+ ffmpeg.stdin.end();
1654
+ })().catch(reject);
1468
1655
  });
1469
1656
  }
1470
1657
  async function savePngSequence(frames, config) {
@@ -1655,8 +1842,13 @@ var OutputConfigSchema = z.object({
1655
1842
  format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
1656
1843
  width: z.number().default(1280),
1657
1844
  height: z.number().default(800),
1658
- fps: z.number().min(1).max(60).default(15),
1845
+ fps: z.number().min(1).max(60).default(30),
1659
1846
  quality: z.number().min(1).max(100).default(80),
1847
+ // Encoding preset for MP4 output. Overrides quality when set.
1848
+ // social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
1849
+ // balanced — general-purpose, good quality/size trade-off (CRF 20)
1850
+ // archive — high-fidelity storage, larger file (CRF 15)
1851
+ preset: z.enum(["social", "balanced", "archive"]).optional(),
1660
1852
  outputDir: z.string().default("./output"),
1661
1853
  filename: z.string().default("clipwise-recording")
1662
1854
  });