clipwise 0.2.0 → 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/cli/index.js CHANGED
@@ -178,8 +178,13 @@ var init_types = __esm({
178
178
  format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
179
179
  width: z.number().default(1280),
180
180
  height: z.number().default(800),
181
- fps: z.number().min(1).max(60).default(15),
181
+ fps: z.number().min(1).max(60).default(30),
182
182
  quality: z.number().min(1).max(100).default(80),
183
+ // Encoding preset for MP4 output. Overrides quality when set.
184
+ // social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
185
+ // balanced — general-purpose, good quality/size trade-off (CRF 20)
186
+ // archive — high-fidelity storage, larger file (CRF 15)
187
+ preset: z.enum(["social", "balanced", "archive"]).optional(),
183
188
  outputDir: z.string().default("./output"),
184
189
  filename: z.string().default("clipwise-recording")
185
190
  });
@@ -386,15 +391,15 @@ async function getElementCenter(page, selector, timeout) {
386
391
 
387
392
  // src/core/recorder.ts
388
393
  var CLICK_EFFECT_DURATION_MS = 500;
389
- var REPAINT_INTERVAL_MS = 50;
394
+ var REPAINT_INTERVAL_MS = 25;
390
395
  var ACTION_GAP_MS = 30;
391
396
  var CURSOR_SPEED_PRESETS = {
392
- fast: { steps: 12, delay: 6 },
393
- // ~72ms total
394
- normal: { steps: 18, delay: 8 },
395
- // ~144ms total
396
- slow: { steps: 24, delay: 12 }
397
- // ~288ms total
397
+ fast: { steps: 10, delay: 22 },
398
+ // ~220ms, ~9 frames captured
399
+ normal: { steps: 14, delay: 25 },
400
+ // ~350ms, ~14 frames captured
401
+ slow: { steps: 20, delay: 25 }
402
+ // ~500ms, ~20 frames captured
398
403
  };
399
404
  var ClipwiseRecorder = class {
400
405
  browser = null;
@@ -408,6 +413,7 @@ var ClipwiseRecorder = class {
408
413
  currentStepIndex = 0;
409
414
  cursorPosition = { x: 0, y: 0 };
410
415
  viewport = { width: 1280, height: 800 };
416
+ deviceScaleFactor = 1;
411
417
  isCapturing = false;
412
418
  targetFps = 30;
413
419
  cursorSpeed = "fast";
@@ -461,10 +467,9 @@ var ClipwiseRecorder = class {
461
467
  }
462
468
  );
463
469
  await this.cdpClient.send("Page.startScreencast", {
464
- format: "jpeg",
465
- quality: 95,
466
- maxWidth: this.viewport.width,
467
- maxHeight: this.viewport.height,
470
+ format: "png",
471
+ maxWidth: this.viewport.width * this.deviceScaleFactor,
472
+ maxHeight: this.viewport.height * this.deviceScaleFactor,
468
473
  everyNthFrame: 1
469
474
  });
470
475
  this.cursorTimeline.push({
@@ -778,6 +783,7 @@ var ClipwiseRecorder = class {
778
783
  clickPosition: clickEvent?.position ?? null,
779
784
  clickProgress,
780
785
  viewport: { ...this.viewport },
786
+ deviceScaleFactor: this.deviceScaleFactor,
781
787
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
782
788
  stepIndex: this.currentStepIndex
783
789
  };
@@ -806,15 +812,9 @@ var ClipwiseRecorder = class {
806
812
  for (let i = 0; i < targetFrameCount; i++) {
807
813
  const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
808
814
  const targetTimestamp = startTime + t * duration;
809
- let nearestIdx = 0;
810
- let minDist = Infinity;
811
- for (let j = 0; j < frames.length; j++) {
812
- const dist = Math.abs(frames[j].timestamp - targetTimestamp);
813
- if (dist < minDist) {
814
- minDist = dist;
815
- nearestIdx = j;
816
- }
817
- }
815
+ const lo = this.binarySearchTimeline(frames, targetTimestamp);
816
+ const hi = Math.min(lo + 1, frames.length - 1);
817
+ const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
818
818
  const cursorPos = this.interpolateCursorAt(targetTimestamp);
819
819
  const clickEvent = this.clickTimeline.find(
820
820
  (click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
@@ -835,6 +835,7 @@ var ClipwiseRecorder = class {
835
835
  clickPosition: clickEvent?.position ?? null,
836
836
  clickProgress,
837
837
  viewport: { ...this.viewport },
838
+ deviceScaleFactor: this.deviceScaleFactor,
838
839
  stepName: frames[nearestIdx].stepName,
839
840
  stepIndex: frames[nearestIdx].stepIndex,
840
841
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
@@ -850,15 +851,9 @@ var ClipwiseRecorder = class {
850
851
  if (this.cursorTimeline.length === 1) {
851
852
  return { ...this.cursorTimeline[0].position };
852
853
  }
853
- let before = this.cursorTimeline[0];
854
- let after = this.cursorTimeline[this.cursorTimeline.length - 1];
855
- for (let i = 0; i < this.cursorTimeline.length - 1; i++) {
856
- if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
857
- before = this.cursorTimeline[i];
858
- after = this.cursorTimeline[i + 1];
859
- break;
860
- }
861
- }
854
+ const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
855
+ const before = this.cursorTimeline[idx];
856
+ const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
862
857
  if (timestamp <= before.timestamp) return { ...before.position };
863
858
  if (timestamp >= after.timestamp) return { ...after.position };
864
859
  const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
@@ -871,6 +866,23 @@ var ClipwiseRecorder = class {
871
866
  )
872
867
  };
873
868
  }
869
+ /**
870
+ * Binary search: returns the index of the last entry whose timestamp <= target.
871
+ * Assumes the array is sorted by timestamp in ascending order.
872
+ */
873
+ binarySearchTimeline(timeline, target) {
874
+ let lo = 0;
875
+ let hi = timeline.length - 1;
876
+ while (lo < hi) {
877
+ const mid = lo + hi + 1 >> 1;
878
+ if (timeline[mid].timestamp <= target) {
879
+ lo = mid;
880
+ } else {
881
+ hi = mid - 1;
882
+ }
883
+ }
884
+ return lo;
885
+ }
874
886
  /**
875
887
  * Clean up browser resources. Always called after recording.
876
888
  */
@@ -895,7 +907,13 @@ var ClipwiseRecorder = class {
895
907
  };
896
908
 
897
909
  // src/compose/canvas-renderer.ts
898
- import sharp8 from "sharp";
910
+ import { Worker } from "worker_threads";
911
+ import os from "os";
912
+ import { existsSync } from "fs";
913
+ import { fileURLToPath } from "url";
914
+
915
+ // src/compose/compose-frame.ts
916
+ import sharp7 from "sharp";
899
917
 
900
918
  // src/effects/frame.ts
901
919
  import sharp from "sharp";
@@ -918,91 +936,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
918
936
  var ANDROID_OUTER_RADIUS = 35;
919
937
  var ANDROID_INNER_RADIUS = 30;
920
938
  var ANDROID_CAMERA_RADIUS = 6;
921
- function buildBrowserChromeSvg(width, darkMode) {
939
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
922
940
  const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
923
941
  const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
924
942
  const addressBorder = darkMode ? "#444444" : "#d0d0d0";
925
943
  const textColor = darkMode ? "#999999" : "#666666";
944
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
945
+ const tlY = TRAFFIC_LIGHT_Y * dpr;
946
+ const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
947
+ const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
948
+ const tlGap = TRAFFIC_LIGHT_GAP * dpr;
949
+ const aBarH = ADDRESS_BAR_HEIGHT * dpr;
950
+ const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
951
+ const fontSize = 12 * dpr;
926
952
  const trafficLights = [
927
- { cx: TRAFFIC_LIGHTS_START_X, fill: "#ff5f57" },
928
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP, fill: "#febc2e" },
929
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP * 2, fill: "#28c840" }
953
+ { cx: tlStartX, fill: "#ff5f57" },
954
+ { cx: tlStartX + tlGap, fill: "#febc2e" },
955
+ { cx: tlStartX + tlGap * 2, fill: "#28c840" }
930
956
  ].map(
931
- (light) => `<circle cx="${light.cx}" cy="${TRAFFIC_LIGHT_Y}" r="${TRAFFIC_LIGHT_RADIUS}" fill="${light.fill}"/>`
957
+ (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
932
958
  ).join("\n ");
933
- const addressBarWidth = width - ADDRESS_BAR_MARGIN * 2;
934
- const addressBarX = ADDRESS_BAR_MARGIN;
935
- const addressBarY = (TITLE_BAR_HEIGHT - ADDRESS_BAR_HEIGHT) / 2;
936
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${TITLE_BAR_HEIGHT}">
937
- <rect width="${width}" height="${TITLE_BAR_HEIGHT}" fill="${bg}"/>
959
+ const addressBarWidth = width - aBarMargin * 2;
960
+ const addressBarX = aBarMargin;
961
+ const addressBarY = (tbarH - aBarH) / 2;
962
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
963
+ <rect width="${width}" height="${tbarH}" fill="${bg}"/>
938
964
  ${trafficLights}
939
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${ADDRESS_BAR_HEIGHT}"
940
- rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="1"/>
941
- <text x="${width / 2}" y="${TRAFFIC_LIGHT_Y + 4}" text-anchor="middle"
942
- font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="${textColor}">
965
+ <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
966
+ rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
967
+ <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
968
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
943
969
  localhost
944
970
  </text>
945
971
  </svg>`;
946
972
  }
947
- function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
973
+ function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
948
974
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
949
975
  const islandColor = darkMode ? "#000000" : "#1a1a1a";
950
976
  const homeBarColor = darkMode ? "#555555" : "#333333";
951
- const islandX = (totalWidth - IPHONE_ISLAND.width) / 2;
952
- const islandY = (IPHONE_BEZEL.top - IPHONE_ISLAND.height) / 2 + 4;
953
- const homeBarX = (totalWidth - IPHONE_HOME_BAR.width) / 2;
954
- const homeBarY = totalHeight - IPHONE_BEZEL.bottom / 2 - IPHONE_HOME_BAR.height / 2;
955
- const screenX = IPHONE_BEZEL.sides;
956
- const screenY = IPHONE_BEZEL.top;
977
+ const bezelTop = IPHONE_BEZEL.top * dpr;
978
+ const bezelBottom = IPHONE_BEZEL.bottom * dpr;
979
+ const bezelSides = IPHONE_BEZEL.sides * dpr;
980
+ const outerRadius = IPHONE_OUTER_RADIUS * dpr;
981
+ const innerRadius = IPHONE_INNER_RADIUS * dpr;
982
+ const islandW = IPHONE_ISLAND.width * dpr;
983
+ const islandH = IPHONE_ISLAND.height * dpr;
984
+ const homeBarW = IPHONE_HOME_BAR.width * dpr;
985
+ const homeBarH = IPHONE_HOME_BAR.height * dpr;
986
+ const islandX = (totalWidth - islandW) / 2;
987
+ const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
988
+ const homeBarX = (totalWidth - homeBarW) / 2;
989
+ const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
990
+ const screenX = bezelSides;
991
+ const screenY = bezelTop;
957
992
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
958
993
  <!-- Device body -->
959
994
  <rect width="${totalWidth}" height="${totalHeight}"
960
- rx="${IPHONE_OUTER_RADIUS}" ry="${IPHONE_OUTER_RADIUS}" fill="${bezelColor}"/>
995
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
961
996
  <!-- Screen cutout (transparent) -->
962
997
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
963
- rx="${IPHONE_INNER_RADIUS}" ry="${IPHONE_INNER_RADIUS}" fill="black"/>
998
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
964
999
  <!-- Dynamic Island pill -->
965
- <rect x="${islandX}" y="${islandY}" width="${IPHONE_ISLAND.width}" height="${IPHONE_ISLAND.height}"
966
- rx="${IPHONE_ISLAND.height / 2}" ry="${IPHONE_ISLAND.height / 2}" fill="${islandColor}"/>
1000
+ <rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
1001
+ rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
967
1002
  <!-- Home indicator bar -->
968
- <rect x="${homeBarX}" y="${homeBarY}" width="${IPHONE_HOME_BAR.width}" height="${IPHONE_HOME_BAR.height}"
969
- rx="${IPHONE_HOME_BAR.height / 2}" ry="${IPHONE_HOME_BAR.height / 2}" fill="${homeBarColor}"/>
1003
+ <rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
1004
+ rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
970
1005
  </svg>`;
971
1006
  }
972
- function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
1007
+ function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
973
1008
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
974
1009
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
975
- const screenX = IPAD_BEZEL.sides;
976
- const screenY = IPAD_BEZEL.top;
1010
+ const screenX = IPAD_BEZEL.sides * dpr;
1011
+ const screenY = IPAD_BEZEL.top * dpr;
977
1012
  const cameraCx = totalWidth / 2;
978
- const cameraCy = IPAD_BEZEL.top / 2;
1013
+ const cameraCy = IPAD_BEZEL.top * dpr / 2;
1014
+ const outerRadius = IPAD_OUTER_RADIUS * dpr;
1015
+ const innerRadius = IPAD_INNER_RADIUS * dpr;
979
1016
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
980
1017
  <!-- Device body -->
981
1018
  <rect width="${totalWidth}" height="${totalHeight}"
982
- rx="${IPAD_OUTER_RADIUS}" ry="${IPAD_OUTER_RADIUS}" fill="${bezelColor}"/>
1019
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
983
1020
  <!-- Screen cutout -->
984
1021
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
985
- rx="${IPAD_INNER_RADIUS}" ry="${IPAD_INNER_RADIUS}" fill="black"/>
1022
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
986
1023
  <!-- Front camera dot -->
987
- <circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
1024
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
988
1025
  </svg>`;
989
1026
  }
990
- function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
1027
+ function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
991
1028
  const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
992
1029
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
993
- const screenX = ANDROID_BEZEL.sides;
994
- const screenY = ANDROID_BEZEL.top;
1030
+ const screenX = ANDROID_BEZEL.sides * dpr;
1031
+ const screenY = ANDROID_BEZEL.top * dpr;
995
1032
  const cameraCx = totalWidth / 2;
996
- const cameraCy = ANDROID_BEZEL.top / 2;
1033
+ const cameraCy = ANDROID_BEZEL.top * dpr / 2;
1034
+ const outerRadius = ANDROID_OUTER_RADIUS * dpr;
1035
+ const innerRadius = ANDROID_INNER_RADIUS * dpr;
1036
+ const cameraR = ANDROID_CAMERA_RADIUS * dpr;
997
1037
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
998
1038
  <!-- Device body -->
999
1039
  <rect width="${totalWidth}" height="${totalHeight}"
1000
- rx="${ANDROID_OUTER_RADIUS}" ry="${ANDROID_OUTER_RADIUS}" fill="${bezelColor}"/>
1040
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
1001
1041
  <!-- Screen cutout -->
1002
1042
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
1003
- rx="${ANDROID_INNER_RADIUS}" ry="${ANDROID_INNER_RADIUS}" fill="black"/>
1043
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
1004
1044
  <!-- Punch-hole camera -->
1005
- <circle cx="${cameraCx}" cy="${cameraCy}" r="${ANDROID_CAMERA_RADIUS}" fill="${cameraColor}"/>
1045
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
1006
1046
  </svg>`;
1007
1047
  }
1008
1048
  function buildScreenMaskSvg(width, height, radius) {
@@ -1010,21 +1050,33 @@ function buildScreenMaskSvg(width, height, radius) {
1010
1050
  <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
1011
1051
  </svg>`;
1012
1052
  }
1013
- async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
1053
+ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
1014
1054
  let bezel;
1015
1055
  let innerRadius;
1016
1056
  switch (deviceType) {
1017
1057
  case "iphone":
1018
- bezel = IPHONE_BEZEL;
1019
- innerRadius = IPHONE_INNER_RADIUS;
1058
+ bezel = {
1059
+ sides: IPHONE_BEZEL.sides * dpr,
1060
+ top: IPHONE_BEZEL.top * dpr,
1061
+ bottom: IPHONE_BEZEL.bottom * dpr
1062
+ };
1063
+ innerRadius = IPHONE_INNER_RADIUS * dpr;
1020
1064
  break;
1021
1065
  case "ipad":
1022
- bezel = IPAD_BEZEL;
1023
- innerRadius = IPAD_INNER_RADIUS;
1066
+ bezel = {
1067
+ sides: IPAD_BEZEL.sides * dpr,
1068
+ top: IPAD_BEZEL.top * dpr,
1069
+ bottom: IPAD_BEZEL.bottom * dpr
1070
+ };
1071
+ innerRadius = IPAD_INNER_RADIUS * dpr;
1024
1072
  break;
1025
1073
  case "android":
1026
- bezel = ANDROID_BEZEL;
1027
- innerRadius = ANDROID_INNER_RADIUS;
1074
+ bezel = {
1075
+ sides: ANDROID_BEZEL.sides * dpr,
1076
+ top: ANDROID_BEZEL.top * dpr,
1077
+ bottom: ANDROID_BEZEL.bottom * dpr
1078
+ };
1079
+ innerRadius = ANDROID_INNER_RADIUS * dpr;
1028
1080
  break;
1029
1081
  }
1030
1082
  const totalWidth = frameWidth + bezel.sides * 2;
@@ -1032,13 +1084,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
1032
1084
  let frameSvg;
1033
1085
  switch (deviceType) {
1034
1086
  case "iphone":
1035
- frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
1087
+ frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
1036
1088
  break;
1037
1089
  case "ipad":
1038
- frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
1090
+ frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
1039
1091
  break;
1040
1092
  case "android":
1041
- frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
1093
+ frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
1042
1094
  break;
1043
1095
  }
1044
1096
  const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
@@ -1061,12 +1113,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
1061
1113
  { input: maskedScreen, left: bezel.sides, top: bezel.top }
1062
1114
  ]).png().toBuffer();
1063
1115
  }
1064
- async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
1116
+ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
1065
1117
  if (!config.enabled || config.type === "none") return frameBuffer;
1066
1118
  switch (config.type) {
1067
1119
  case "browser": {
1068
- const totalHeight = frameHeight + TITLE_BAR_HEIGHT;
1069
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode);
1120
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
1121
+ const totalHeight = frameHeight + tbarH;
1122
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
1070
1123
  const chromeBuffer = Buffer.from(chromeSvg);
1071
1124
  const canvas = await sharp({
1072
1125
  create: {
@@ -1078,13 +1131,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
1078
1131
  }).png().toBuffer();
1079
1132
  return sharp(canvas).composite([
1080
1133
  { input: chromeBuffer, left: 0, top: 0 },
1081
- { input: frameBuffer, left: 0, top: TITLE_BAR_HEIGHT }
1134
+ { input: frameBuffer, left: 0, top: tbarH }
1082
1135
  ]).png().toBuffer();
1083
1136
  }
1084
1137
  case "iphone":
1085
1138
  case "ipad":
1086
1139
  case "android":
1087
- return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
1140
+ return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
1088
1141
  default:
1089
1142
  return frameBuffer;
1090
1143
  }
@@ -1094,7 +1147,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
1094
1147
  import sharp2 from "sharp";
1095
1148
  function buildCursorSvg(size, color) {
1096
1149
  const s = size;
1097
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
1150
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
1098
1151
  <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
1099
1152
  fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
1100
1153
  </svg>`;
@@ -1103,7 +1156,7 @@ function buildClickRippleSvg(radius, color, progress) {
1103
1156
  const currentRadius = radius * progress;
1104
1157
  const opacity = Math.max(0, 1 - progress);
1105
1158
  const size = Math.ceil(radius * 2 + 4);
1106
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
1159
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
1107
1160
  <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
1108
1161
  fill="none" stroke="${color}" stroke-width="2"
1109
1162
  opacity="${opacity.toFixed(3)}"/>
@@ -1111,47 +1164,35 @@ function buildClickRippleSvg(radius, color, progress) {
1111
1164
  fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
1112
1165
  </svg>`;
1113
1166
  }
1114
- async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
1167
+ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1115
1168
  if (!config.enabled) return frameBuffer;
1116
- const cursorSvg = buildCursorSvg(config.size, config.color);
1169
+ const size = Math.round(config.size * dpr);
1170
+ const cursorSvg = buildCursorSvg(size, config.color);
1117
1171
  const cursorBuffer = Buffer.from(cursorSvg);
1118
- const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
1119
- const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
1172
+ const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
1173
+ const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
1120
1174
  return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
1121
1175
  }
1122
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
1176
+ async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
1123
1177
  if (!config.enabled || !config.clickEffect) return frameBuffer;
1178
+ const radius = config.clickRadius * dpr;
1124
1179
  const clampedProgress = Math.max(0, Math.min(1, progress));
1125
- const rippleSvg = buildClickRippleSvg(
1126
- config.clickRadius,
1127
- config.clickColor,
1128
- clampedProgress
1129
- );
1180
+ const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
1130
1181
  const rippleBuffer = Buffer.from(rippleSvg);
1131
- const rippleSize = Math.ceil(config.clickRadius * 2 + 4);
1132
- const left = Math.max(
1133
- 0,
1134
- Math.min(
1135
- Math.round(position.x - rippleSize / 2),
1136
- frameWidth - rippleSize
1137
- )
1138
- );
1139
- const top = Math.max(
1140
- 0,
1141
- Math.min(
1142
- Math.round(position.y - rippleSize / 2),
1143
- frameHeight - rippleSize
1144
- )
1145
- );
1182
+ const rippleSize = Math.ceil(radius * 2 + 4);
1183
+ const px = Math.round(position.x * dpr);
1184
+ const py = Math.round(position.y * dpr);
1185
+ const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
1186
+ const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
1146
1187
  return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
1147
1188
  }
1148
- async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
1189
+ async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1149
1190
  if (!config.enabled || !config.highlight) return frameBuffer;
1150
- const r = config.highlightRadius;
1191
+ const r = config.highlightRadius * dpr;
1151
1192
  const size = Math.ceil(r * 2 + 4);
1152
1193
  const cx = size / 2;
1153
1194
  const cy = size / 2;
1154
- const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
1195
+ const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
1155
1196
  <defs>
1156
1197
  <radialGradient id="glow">
1157
1198
  <stop offset="0%" stop-color="${config.highlightColor}" />
@@ -1161,27 +1202,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
1161
1202
  </defs>
1162
1203
  <circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
1163
1204
  </svg>`;
1164
- const left = Math.max(0, Math.min(Math.round(position.x - cx), frameWidth - size));
1165
- const top = Math.max(0, Math.min(Math.round(position.y - cy), frameHeight - size));
1205
+ const px = Math.round(position.x * dpr);
1206
+ const py = Math.round(position.y * dpr);
1207
+ const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
1208
+ const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
1166
1209
  return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
1167
1210
  }
1168
- async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
1211
+ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
1169
1212
  if (!config.enabled || !config.trail || positions.length < 2) {
1170
1213
  return frameBuffer;
1171
1214
  }
1172
1215
  const segments = [];
1173
1216
  for (let i = 1; i < positions.length; i++) {
1174
1217
  const opacity = i / positions.length * 0.6;
1175
- const strokeWidth = 1 + i / positions.length * 2;
1218
+ const strokeWidth = (1 + i / positions.length * 2) * dpr;
1176
1219
  const p1 = positions[i - 1];
1177
1220
  const p2 = positions[i];
1178
1221
  segments.push(
1179
- `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
1222
+ `<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
1180
1223
  stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
1181
1224
  stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
1182
1225
  );
1183
1226
  }
1184
- const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1227
+ const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
1185
1228
  ${segments.join("\n ")}
1186
1229
  </svg>`;
1187
1230
  return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
@@ -1314,7 +1357,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
1314
1357
 
1315
1358
  // src/effects/keystroke.ts
1316
1359
  import sharp5 from "sharp";
1317
- async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
1360
+ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
1318
1361
  if (!config.enabled || keystrokes.length === 0) return frameBuffer;
1319
1362
  const recentKeys = keystrokes.filter(
1320
1363
  (k) => frameTimestamp - k.timestamp < config.fadeAfter
@@ -1322,25 +1365,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1322
1365
  if (recentKeys.length === 0) return frameBuffer;
1323
1366
  const displayText = recentKeys.map((k) => k.key).join("");
1324
1367
  if (displayText.length === 0) return frameBuffer;
1325
- const charWidth = config.fontSize * 0.62;
1368
+ const fontSize = config.fontSize * dpr;
1369
+ const padding = config.padding * dpr;
1370
+ const charWidth = fontSize * 0.62;
1326
1371
  const textWidth = Math.ceil(displayText.length * charWidth);
1327
- const hudPadH = config.padding * 2;
1328
- const hudPadV = config.padding * 1.5;
1329
- const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
1330
- const hudHeight = Math.ceil(config.fontSize + hudPadV * 2);
1372
+ const hudPadH = padding * 2;
1373
+ const hudPadV = padding * 1.5;
1374
+ const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
1375
+ const hudHeight = Math.ceil(fontSize + hudPadV * 2);
1331
1376
  const newest = recentKeys[recentKeys.length - 1];
1332
1377
  const age = frameTimestamp - newest.timestamp;
1333
1378
  const fadeStart = config.fadeAfter * 0.6;
1334
1379
  const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1335
1380
  if (opacity <= 0) return frameBuffer;
1381
+ const margin = 30 * dpr;
1336
1382
  let hudX;
1337
- const hudY = frameHeight - hudHeight - 30;
1383
+ const hudY = frameHeight - hudHeight - margin;
1338
1384
  switch (config.position) {
1339
1385
  case "bottom-left":
1340
- hudX = 30;
1386
+ hudX = margin;
1341
1387
  break;
1342
1388
  case "bottom-right":
1343
- hudX = frameWidth - hudWidth - 30;
1389
+ hudX = frameWidth - hudWidth - margin;
1344
1390
  break;
1345
1391
  case "bottom-center":
1346
1392
  default:
@@ -1350,41 +1396,18 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1350
1396
  const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
1351
1397
  const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
1352
1398
  const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1353
- const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1399
+ const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1354
1400
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
1355
- rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1356
- <text x="${hudX + hudPadH}" y="${hudY + hudPadV + config.fontSize * 0.75}"
1357
- font-family="monospace, Menlo, Consolas" font-size="${config.fontSize}"
1401
+ rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1402
+ <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
1403
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1358
1404
  fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
1359
1405
  </svg>`;
1360
1406
  return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
1361
1407
  }
1362
1408
 
1363
- // src/effects/transition.ts
1364
- import sharp6 from "sharp";
1365
- async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1366
- const t = Math.max(0, Math.min(1, progress));
1367
- if (t <= 0) return fromBuffer;
1368
- if (t >= 1) return toBuffer;
1369
- const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1370
- const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1371
- const pixels = Buffer.alloc(fromRaw.data.length);
1372
- for (let i = 0; i < fromRaw.data.length; i++) {
1373
- pixels[i] = Math.round(
1374
- fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1375
- );
1376
- }
1377
- return sharp6(pixels, {
1378
- raw: {
1379
- width: fromRaw.info.width,
1380
- height: fromRaw.info.height,
1381
- channels: 4
1382
- }
1383
- }).png().toBuffer();
1384
- }
1385
-
1386
1409
  // src/effects/watermark.ts
1387
- import sharp7 from "sharp";
1410
+ import sharp6 from "sharp";
1388
1411
  async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1389
1412
  if (!config.enabled || !config.text) return frameBuffer;
1390
1413
  const charWidth = config.fontSize * 0.62;
@@ -1412,31 +1435,168 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1412
1435
  break;
1413
1436
  }
1414
1437
  const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1415
- const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1438
+ const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1416
1439
  <text x="${x}" y="${y}"
1417
1440
  font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
1418
1441
  font-weight="600" fill="${config.color}"
1419
1442
  opacity="${config.opacity.toFixed(3)}">${escaped}</text>
1420
1443
  </svg>`;
1421
- return sharp7(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
1444
+ return sharp6(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
1422
1445
  }
1423
1446
 
1424
- // src/compose/canvas-renderer.ts
1425
- function getFrameOffset(config) {
1447
+ // src/compose/compose-frame.ts
1448
+ function getFrameOffset(config, dpr = 1) {
1426
1449
  if (!config.enabled) return { left: 0, top: 0 };
1427
1450
  switch (config.type) {
1428
1451
  case "browser":
1429
- return { left: 0, top: 40 };
1452
+ return { left: 0, top: 40 * dpr };
1430
1453
  case "iphone":
1431
- return { left: 12, top: 50 };
1454
+ return { left: 12 * dpr, top: 50 * dpr };
1432
1455
  case "ipad":
1433
- return { left: 20, top: 24 };
1456
+ return { left: 20 * dpr, top: 24 * dpr };
1434
1457
  case "android":
1435
- return { left: 8, top: 32 };
1458
+ return { left: 8 * dpr, top: 32 * dpr };
1436
1459
  default:
1437
1460
  return { left: 0, top: 0 };
1438
1461
  }
1439
1462
  }
1463
+ async function composeFrame(frame, effects, output, context) {
1464
+ let buffer = frame.screenshot;
1465
+ const meta = await sharp7(buffer).metadata();
1466
+ let width = meta.width ?? frame.viewport.width;
1467
+ let height = meta.height ?? frame.viewport.height;
1468
+ const dpr = Math.round(width / frame.viewport.width);
1469
+ const ctx = {
1470
+ zoomScale: context?.zoomScale ?? 1,
1471
+ clickProgress: context?.clickProgress ?? null,
1472
+ cursorTrail: context?.cursorTrail ?? []
1473
+ };
1474
+ if (effects.deviceFrame.enabled) {
1475
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1476
+ const meta2 = await sharp7(buffer).metadata();
1477
+ width = meta2.width ?? width;
1478
+ height = meta2.height ?? height;
1479
+ }
1480
+ if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1481
+ buffer = await renderCursorHighlight(
1482
+ buffer,
1483
+ frame.cursorPosition,
1484
+ effects.cursor,
1485
+ width,
1486
+ height,
1487
+ dpr
1488
+ );
1489
+ }
1490
+ if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1491
+ buffer = await renderCursorTrail(
1492
+ buffer,
1493
+ ctx.cursorTrail,
1494
+ effects.cursor,
1495
+ width,
1496
+ height,
1497
+ dpr
1498
+ );
1499
+ }
1500
+ if (effects.cursor.enabled && frame.cursorPosition) {
1501
+ buffer = await renderCursor(
1502
+ buffer,
1503
+ frame.cursorPosition,
1504
+ effects.cursor,
1505
+ width,
1506
+ height,
1507
+ dpr
1508
+ );
1509
+ }
1510
+ if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
1511
+ const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1512
+ buffer = await renderClickEffect(
1513
+ buffer,
1514
+ frame.clickPosition,
1515
+ effects.cursor,
1516
+ progress,
1517
+ width,
1518
+ height,
1519
+ dpr
1520
+ );
1521
+ }
1522
+ if (effects.keystroke.enabled && frame.keystrokes) {
1523
+ buffer = await renderKeystrokeHud(
1524
+ buffer,
1525
+ frame.keystrokes,
1526
+ frame.timestamp,
1527
+ effects.keystroke,
1528
+ width,
1529
+ height,
1530
+ dpr
1531
+ );
1532
+ }
1533
+ const scale = ctx.zoomScale;
1534
+ if (effects.zoom.enabled && scale > 1) {
1535
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1536
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
1537
+ const focusPoint = {
1538
+ x: rawFocus.x * dpr + offset.left,
1539
+ y: rawFocus.y * dpr + offset.top
1540
+ };
1541
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1542
+ }
1543
+ buffer = await applyBackground(buffer, effects.background, output.width, output.height);
1544
+ if (effects.watermark.enabled) {
1545
+ buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
1546
+ }
1547
+ buffer = await sharp7(buffer).resize(output.width, output.height, {
1548
+ fit: "fill",
1549
+ kernel: sharp7.kernel.lanczos3
1550
+ }).png().toBuffer();
1551
+ return { index: frame.index, buffer, timestamp: frame.timestamp };
1552
+ }
1553
+
1554
+ // src/effects/transition.ts
1555
+ import sharp8 from "sharp";
1556
+ async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1557
+ const t = Math.max(0, Math.min(1, progress));
1558
+ if (t <= 0) return fromBuffer;
1559
+ if (t >= 1) return toBuffer;
1560
+ const fromRaw = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1561
+ const toRaw = await sharp8(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1562
+ const pixels = Buffer.alloc(fromRaw.data.length);
1563
+ for (let i = 0; i < fromRaw.data.length; i++) {
1564
+ pixels[i] = Math.round(
1565
+ fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1566
+ );
1567
+ }
1568
+ return sharp8(pixels, {
1569
+ raw: {
1570
+ width: fromRaw.info.width,
1571
+ height: fromRaw.info.height,
1572
+ channels: 4
1573
+ }
1574
+ }).png().toBuffer();
1575
+ }
1576
+
1577
+ // src/compose/canvas-renderer.ts
1578
+ var MIN_FRAMES_PER_WORKER = 4;
1579
+ var cachedWorkerUrl = null;
1580
+ function getWorkerUrl() {
1581
+ if (cachedWorkerUrl) return cachedWorkerUrl;
1582
+ const base = import.meta.url;
1583
+ const candidates = [
1584
+ new URL("./frame-worker.js", base),
1585
+ // from dist/compose/
1586
+ new URL("../compose/frame-worker.js", base),
1587
+ // from dist/cli/
1588
+ new URL("./compose/frame-worker.js", base)
1589
+ // from dist/
1590
+ ];
1591
+ for (const url of candidates) {
1592
+ if (existsSync(fileURLToPath(url))) {
1593
+ cachedWorkerUrl = url;
1594
+ return url;
1595
+ }
1596
+ }
1597
+ cachedWorkerUrl = candidates[1];
1598
+ return cachedWorkerUrl;
1599
+ }
1440
1600
  var CanvasRenderer = class {
1441
1601
  constructor(effects, output, steps) {
1442
1602
  this.effects = effects;
@@ -1445,118 +1605,11 @@ var CanvasRenderer = class {
1445
1605
  }
1446
1606
  steps;
1447
1607
  /**
1448
- * Apply the full effects pipeline to a single captured frame.
1449
- *
1450
- * Pipeline order:
1451
- * 1. Device frame (browser chrome / mobile mockup)
1452
- * 2. Cursor highlight (Screen Studio glow)
1453
- * 3. Cursor trail
1454
- * 4. Cursor rendering
1455
- * 5. Click ripple effect (animated progress)
1456
- * 6. Keystroke HUD
1457
- * 7. Zoom (adaptive, cursor-following)
1458
- * 8. Background (padding, gradient, rounded corners)
1459
- * 9. Watermark overlay
1460
- * 10. Final resize
1608
+ * Apply the full effects pipeline to a single frame.
1609
+ * Delegates to the standalone composeFrame function.
1461
1610
  */
1462
1611
  async composeFrame(frame, context) {
1463
- let buffer = frame.screenshot;
1464
- let width = frame.viewport.width;
1465
- let height = frame.viewport.height;
1466
- const ctx = {
1467
- zoomScale: context?.zoomScale ?? 1,
1468
- clickProgress: context?.clickProgress ?? null,
1469
- cursorTrail: context?.cursorTrail ?? []
1470
- };
1471
- if (this.effects.deviceFrame.enabled) {
1472
- buffer = await applyDeviceFrame(
1473
- buffer,
1474
- this.effects.deviceFrame,
1475
- width,
1476
- height
1477
- );
1478
- const meta = await sharp8(buffer).metadata();
1479
- width = meta.width ?? width;
1480
- height = meta.height ?? height;
1481
- }
1482
- if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
1483
- buffer = await renderCursorHighlight(
1484
- buffer,
1485
- frame.cursorPosition,
1486
- this.effects.cursor,
1487
- width,
1488
- height
1489
- );
1490
- }
1491
- if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1492
- buffer = await renderCursorTrail(
1493
- buffer,
1494
- ctx.cursorTrail,
1495
- this.effects.cursor,
1496
- width,
1497
- height
1498
- );
1499
- }
1500
- if (this.effects.cursor.enabled && frame.cursorPosition) {
1501
- buffer = await renderCursor(
1502
- buffer,
1503
- frame.cursorPosition,
1504
- this.effects.cursor,
1505
- width,
1506
- height
1507
- );
1508
- }
1509
- if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
1510
- const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1511
- buffer = await renderClickEffect(
1512
- buffer,
1513
- frame.clickPosition,
1514
- this.effects.cursor,
1515
- progress,
1516
- width,
1517
- height
1518
- );
1519
- }
1520
- if (this.effects.keystroke.enabled && frame.keystrokes) {
1521
- buffer = await renderKeystrokeHud(
1522
- buffer,
1523
- frame.keystrokes,
1524
- frame.timestamp,
1525
- this.effects.keystroke,
1526
- width,
1527
- height
1528
- );
1529
- }
1530
- const scale = ctx.zoomScale;
1531
- if (this.effects.zoom.enabled && scale > 1) {
1532
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
1533
- const offset = getFrameOffset(this.effects.deviceFrame);
1534
- const focusPoint = {
1535
- x: rawFocus.x + offset.left,
1536
- y: rawFocus.y + offset.top
1537
- };
1538
- buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1539
- }
1540
- buffer = await applyBackground(
1541
- buffer,
1542
- this.effects.background,
1543
- this.output.width,
1544
- this.output.height
1545
- );
1546
- if (this.effects.watermark.enabled) {
1547
- buffer = await renderWatermark(
1548
- buffer,
1549
- this.effects.watermark,
1550
- this.output.width,
1551
- this.output.height
1552
- );
1553
- }
1554
- buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
1555
- return {
1556
- index: frame.index,
1557
- buffer,
1558
- timestamp: frame.timestamp
1559
- };
1612
+ return composeFrame(frame, this.effects, this.output, context);
1560
1613
  }
1561
1614
  /**
1562
1615
  * Process an entire sequence of captured frames through the effects pipeline.
@@ -1564,7 +1617,7 @@ var CanvasRenderer = class {
1564
1617
  * Multi-pass approach:
1565
1618
  * Pass 1: Speed ramping (adjust frame set).
1566
1619
  * Pass 2: Calculate per-frame contexts (zoom, click, trail).
1567
- * Pass 3: Render each frame with effects.
1620
+ * Pass 3: Render frames in parallel using worker threads.
1568
1621
  * Pass 4: Apply scene transitions at step boundaries.
1569
1622
  */
1570
1623
  async composeAll(frames) {
@@ -1574,10 +1627,19 @@ var CanvasRenderer = class {
1574
1627
  processFrames = this.applySpeedRamp(frames);
1575
1628
  }
1576
1629
  const contexts = this.calculateFrameContexts(processFrames);
1577
- const composed = [];
1578
- for (let i = 0; i < processFrames.length; i++) {
1579
- const result = await this.composeFrame(processFrames[i], contexts[i]);
1580
- composed.push(result);
1630
+ const cpuCount = os.cpus().length;
1631
+ const workerCount = Math.min(cpuCount, 8);
1632
+ const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
1633
+ let composed;
1634
+ if (useWorkers) {
1635
+ composed = await this.processWithWorkers(processFrames, contexts, workerCount);
1636
+ } else {
1637
+ composed = [];
1638
+ for (let i = 0; i < processFrames.length; i++) {
1639
+ composed.push(
1640
+ await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
1641
+ );
1642
+ }
1581
1643
  }
1582
1644
  if (this.steps.length > 0) {
1583
1645
  await this.applyTransitions(composed, processFrames);
@@ -1585,7 +1647,64 @@ var CanvasRenderer = class {
1585
1647
  return composed;
1586
1648
  }
1587
1649
  /**
1588
- * Calculate per-frame rendering context (zoom, click progress, cursor trail, tilt).
1650
+ * Distribute frame composition across a pool of worker threads.
1651
+ * Workers process frames concurrently; results are collected in order.
1652
+ */
1653
+ processWithWorkers(frames, contexts, workerCount) {
1654
+ return new Promise((resolve2, reject) => {
1655
+ const results = new Array(frames.length);
1656
+ let completed = 0;
1657
+ let nextIndex = 0;
1658
+ let failed = false;
1659
+ const workerUrl = getWorkerUrl();
1660
+ const workers = [];
1661
+ const dispatch = (worker) => {
1662
+ if (nextIndex >= frames.length || failed) return;
1663
+ const i = nextIndex++;
1664
+ worker.postMessage({
1665
+ taskId: i,
1666
+ frame: frames[i],
1667
+ effects: this.effects,
1668
+ output: this.output,
1669
+ context: contexts[i]
1670
+ });
1671
+ };
1672
+ for (let w = 0; w < workerCount; w++) {
1673
+ const worker = new Worker(workerUrl);
1674
+ workers.push(worker);
1675
+ worker.on("message", (msg) => {
1676
+ if (failed) return;
1677
+ if (msg.error) {
1678
+ failed = true;
1679
+ workers.forEach((wk) => wk.terminate());
1680
+ reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
1681
+ return;
1682
+ }
1683
+ results[msg.taskId] = {
1684
+ index: frames[msg.taskId].index,
1685
+ buffer: Buffer.from(msg.buffer),
1686
+ timestamp: frames[msg.taskId].timestamp
1687
+ };
1688
+ completed++;
1689
+ if (completed === frames.length) {
1690
+ workers.forEach((wk) => wk.terminate());
1691
+ resolve2(results);
1692
+ } else {
1693
+ dispatch(worker);
1694
+ }
1695
+ });
1696
+ worker.on("error", (err) => {
1697
+ if (failed) return;
1698
+ failed = true;
1699
+ workers.forEach((wk) => wk.terminate());
1700
+ reject(err);
1701
+ });
1702
+ dispatch(worker);
1703
+ }
1704
+ });
1705
+ }
1706
+ /**
1707
+ * Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
1589
1708
  */
1590
1709
  calculateFrameContexts(frames) {
1591
1710
  const contexts = [];
@@ -1617,7 +1736,6 @@ var CanvasRenderer = class {
1617
1736
  }
1618
1737
  /**
1619
1738
  * Apply speed ramping: slow down near actions, speed up during idle.
1620
- * Returns a new frame array with frames duplicated or skipped.
1621
1739
  */
1622
1740
  applySpeedRamp(frames) {
1623
1741
  const config = this.effects.speedRamp;
@@ -1650,7 +1768,6 @@ var CanvasRenderer = class {
1650
1768
  }
1651
1769
  /**
1652
1770
  * Apply crossfade transitions at step boundaries where configured.
1653
- * Modifies the composed array in-place.
1654
1771
  */
1655
1772
  async applyTransitions(composed, frames) {
1656
1773
  const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
@@ -1671,16 +1788,14 @@ var CanvasRenderer = class {
1671
1788
  if (range < 2) continue;
1672
1789
  const fromBuffer = composed[startIdx].buffer;
1673
1790
  const toBuffer = composed[endIdx].buffer;
1674
- const width = this.output.width;
1675
- const height = this.output.height;
1676
1791
  for (let i = startIdx + 1; i < endIdx; i++) {
1677
1792
  const progress = (i - startIdx) / range;
1678
1793
  composed[i].buffer = await applyCrossfade(
1679
1794
  fromBuffer,
1680
1795
  toBuffer,
1681
1796
  progress,
1682
- width,
1683
- height
1797
+ this.output.width,
1798
+ this.output.height
1684
1799
  );
1685
1800
  }
1686
1801
  }
@@ -1690,11 +1805,45 @@ var CanvasRenderer = class {
1690
1805
  // src/compose/video-encoder.ts
1691
1806
  import gifenc from "gifenc";
1692
1807
  import sharp9 from "sharp";
1693
- import { writeFile, mkdir, readFile as readFile2, rm, mkdtemp } from "fs/promises";
1808
+ import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
1694
1809
  import { join } from "path";
1695
1810
  import { tmpdir } from "os";
1696
1811
  import { spawn } from "child_process";
1697
1812
  var { GIFEncoder, quantize, applyPalette } = gifenc;
1813
+ var ENCODING_PRESETS = {
1814
+ social: { crf: 22, vtQuality: 75 },
1815
+ balanced: { crf: 18, vtQuality: 85 },
1816
+ archive: { crf: 13, vtQuality: 92 }
1817
+ };
1818
+ function resolveEncodingParams(config) {
1819
+ if (config.preset) return ENCODING_PRESETS[config.preset];
1820
+ process.stderr.write(
1821
+ `[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
1822
+ `
1823
+ );
1824
+ if (config.quality >= 75) return ENCODING_PRESETS.social;
1825
+ if (config.quality >= 45) return ENCODING_PRESETS.balanced;
1826
+ return ENCODING_PRESETS.archive;
1827
+ }
1828
+ var encoderDetectionPromise = null;
1829
+ function detectVideoEncoder() {
1830
+ if (!encoderDetectionPromise) {
1831
+ encoderDetectionPromise = new Promise((resolve2) => {
1832
+ const proc = spawn("ffmpeg", ["-encoders"], {
1833
+ stdio: ["ignore", "pipe", "ignore"]
1834
+ });
1835
+ let out = "";
1836
+ proc.stdout.on("data", (d) => out += d.toString());
1837
+ proc.on("close", () => {
1838
+ if (out.includes("hevc_videotoolbox")) resolve2("hevc_videotoolbox");
1839
+ else if (out.includes("h264_videotoolbox")) resolve2("h264_videotoolbox");
1840
+ else resolve2("libx264");
1841
+ });
1842
+ proc.on("error", () => resolve2("libx264"));
1843
+ });
1844
+ }
1845
+ return encoderDetectionPromise;
1846
+ }
1698
1847
  async function encodeGif(frames, config) {
1699
1848
  if (frames.length === 0) {
1700
1849
  throw new Error("Cannot encode GIF: no frames provided");
@@ -1708,10 +1857,7 @@ async function encodeGif(frames, config) {
1708
1857
  const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1709
1858
  const palette = quantize(rgba, 256);
1710
1859
  const indexed = applyPalette(rgba, palette);
1711
- gif.writeFrame(indexed, width, height, {
1712
- palette,
1713
- delay
1714
- });
1860
+ gif.writeFrame(indexed, width, height, { palette, delay });
1715
1861
  }
1716
1862
  gif.finish();
1717
1863
  return Buffer.from(gif.bytes());
@@ -1720,50 +1866,87 @@ async function encodeMp4(frames, config) {
1720
1866
  if (frames.length === 0) {
1721
1867
  throw new Error("Cannot encode MP4: no frames provided");
1722
1868
  }
1723
- const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
1869
+ const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
1724
1870
  try {
1725
- const padLength = String(frames.length).length;
1726
- for (const frame of frames) {
1727
- const paddedIndex = String(frame.index).padStart(padLength, "0");
1728
- const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
1729
- await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
1730
- }
1731
- const outputPath = join(tmpDir, "output.mp4");
1732
- const crf = Math.round(51 - config.quality / 100 * 51);
1733
- await runFfmpeg([
1734
- "-y",
1735
- "-framerate",
1736
- String(config.fps),
1737
- "-i",
1738
- join(tmpDir, `frame-%0${padLength}d.png`),
1739
- "-c:v",
1740
- "libx264",
1741
- "-pix_fmt",
1742
- "yuv420p",
1743
- "-crf",
1744
- String(crf),
1745
- "-preset",
1746
- "slow",
1747
- "-tune",
1748
- "animation",
1749
- "-movflags",
1750
- "+faststart",
1751
- outputPath
1752
- ]);
1871
+ const encoder = await detectVideoEncoder();
1872
+ const params = resolveEncodingParams(config);
1873
+ await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
1753
1874
  return await readFile2(outputPath);
1754
1875
  } finally {
1755
- await rm(tmpDir, { recursive: true, force: true }).catch(() => {
1876
+ await rm(outputPath, { force: true }).catch(() => {
1756
1877
  });
1757
1878
  }
1758
1879
  }
1759
- function runFfmpeg(args) {
1880
+ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1881
+ const videoArgs = encoder === "hevc_videotoolbox" ? [
1882
+ "-c:v",
1883
+ "hevc_videotoolbox",
1884
+ "-q:v",
1885
+ String(params.vtQuality),
1886
+ "-pix_fmt",
1887
+ "yuv420p",
1888
+ "-tag:v",
1889
+ "hvc1"
1890
+ // required for playback in QuickTime / Apple devices
1891
+ ] : encoder === "h264_videotoolbox" ? [
1892
+ "-c:v",
1893
+ "h264_videotoolbox",
1894
+ "-q:v",
1895
+ String(params.vtQuality),
1896
+ "-pix_fmt",
1897
+ "yuv420p"
1898
+ ] : [
1899
+ "-c:v",
1900
+ "libx264",
1901
+ "-crf",
1902
+ String(params.crf),
1903
+ "-preset",
1904
+ "medium",
1905
+ "-tune",
1906
+ "stillimage",
1907
+ "-profile:v",
1908
+ "high",
1909
+ "-level",
1910
+ "4.1",
1911
+ "-pix_fmt",
1912
+ "yuv420p"
1913
+ ];
1760
1914
  return new Promise((resolve2, reject) => {
1761
- const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
1915
+ const ffmpeg = spawn(
1916
+ "ffmpeg",
1917
+ [
1918
+ "-y",
1919
+ // Video input: raw RGB24 from stdin
1920
+ "-f",
1921
+ "rawvideo",
1922
+ "-pixel_format",
1923
+ "rgb24",
1924
+ "-video_size",
1925
+ `${config.width}x${config.height}`,
1926
+ "-framerate",
1927
+ String(config.fps),
1928
+ "-i",
1929
+ "pipe:0",
1930
+ // Silent audio track for platform compatibility
1931
+ "-f",
1932
+ "lavfi",
1933
+ "-i",
1934
+ "anullsrc=r=48000:cl=stereo",
1935
+ ...videoArgs,
1936
+ "-c:a",
1937
+ "aac",
1938
+ "-b:a",
1939
+ "128k",
1940
+ "-shortest",
1941
+ "-movflags",
1942
+ "+faststart",
1943
+ outputPath
1944
+ ],
1945
+ { stdio: ["pipe", "ignore", "pipe"] }
1946
+ );
1762
1947
  let stderr = "";
1763
- proc.stderr.on("data", (data) => {
1764
- stderr += data.toString();
1765
- });
1766
- proc.on("close", (code) => {
1948
+ ffmpeg.stderr.on("data", (d) => stderr += d.toString());
1949
+ ffmpeg.on("close", (code) => {
1767
1950
  if (code === 0) {
1768
1951
  resolve2();
1769
1952
  } else {
@@ -1775,7 +1958,7 @@ function runFfmpeg(args) {
1775
1958
  );
1776
1959
  }
1777
1960
  });
1778
- proc.on("error", (err) => {
1961
+ ffmpeg.on("error", (err) => {
1779
1962
  if (err.code === "ENOENT") {
1780
1963
  reject(
1781
1964
  new Error(
@@ -1786,6 +1969,15 @@ function runFfmpeg(args) {
1786
1969
  reject(err);
1787
1970
  }
1788
1971
  });
1972
+ (async () => {
1973
+ for (const frame of frames) {
1974
+ const raw = await sharp9(frame.buffer).flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
1975
+ if (!ffmpeg.stdin.write(raw)) {
1976
+ await new Promise((r) => ffmpeg.stdin.once("drain", r));
1977
+ }
1978
+ }
1979
+ ffmpeg.stdin.end();
1980
+ })().catch(reject);
1789
1981
  });
1790
1982
  }
1791
1983
  async function savePngSequence(frames, config) {
@@ -2016,7 +2208,7 @@ effects:
2016
2208
  output:
2017
2209
  format: mp4
2018
2210
  fps: 30
2019
- quality: 80
2211
+ preset: balanced # social | balanced | archive
2020
2212
 
2021
2213
  steps:
2022
2214
  - name: "Open app"
@@ -2194,7 +2386,7 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
2194
2386
  width: outWidth,
2195
2387
  height: outHeight,
2196
2388
  fps: 30,
2197
- quality: 80,
2389
+ preset: "social",
2198
2390
  outputDir: options.output,
2199
2391
  filename: `clipwise-demo-${device}`
2200
2392
  },