clipwise 0.4.1 → 0.5.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
@@ -11,7 +11,7 @@ var __export = (target, all) => {
11
11
 
12
12
  // src/script/types.ts
13
13
  import { z } from "zod";
14
- var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, StepActionSchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
14
+ var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
15
15
  var init_types = __esm({
16
16
  "src/script/types.ts"() {
17
17
  "use strict";
@@ -101,15 +101,32 @@ var init_types = __esm({
101
101
  WaitForFunctionActionSchema,
102
102
  WaitForResponseActionSchema
103
103
  ]);
104
+ ZoomIntensitySchema = z.enum([
105
+ "subtle",
106
+ "light",
107
+ "moderate",
108
+ "strong",
109
+ "dramatic"
110
+ ]);
104
111
  AutoZoomConfigSchema = z.object({
105
112
  followCursor: z.boolean().default(true),
106
- maxScale: z.number().min(1).max(5).default(2),
113
+ /** @deprecated Use `intensity` on the parent zoom config instead. */
114
+ maxScale: z.number().min(1).max(5).default(1.35),
107
115
  transitionDuration: z.number().default(400),
108
116
  padding: z.number().default(200)
109
117
  });
110
118
  ZoomEffectSchema = z.object({
111
119
  enabled: z.boolean().default(true),
112
- scale: z.number().min(1).max(5).default(1.8),
120
+ /**
121
+ * Numeric zoom scale (1.0 = no zoom). Overridden by `intensity` when set.
122
+ * Default lowered from 1.8 → 1.35 to match "moderate" intensity.
123
+ */
124
+ scale: z.number().min(1).max(5).default(1.35),
125
+ /**
126
+ * Intensity preset — overrides `scale` when set.
127
+ * Calibrated against Loom (light≈1.25x) and Camtasia (moderate≈1.35x).
128
+ */
129
+ intensity: ZoomIntensitySchema.optional(),
113
130
  duration: z.number().default(600),
114
131
  easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
115
132
  autoZoom: AutoZoomConfigSchema.default({})
@@ -150,6 +167,17 @@ var init_types = __esm({
150
167
  });
151
168
  KeystrokeConfigSchema = z.object({
152
169
  enabled: z.boolean().default(false),
170
+ /**
171
+ * Show regular typed text (alphabetic/numeric characters) in the HUD.
172
+ *
173
+ * Industry default is false — Screen Studio, KeyCastr, and ScreenFlow all
174
+ * hide regular typing by default, showing only modifier+key shortcuts.
175
+ * Typed content is already visible inside the focused input element, so
176
+ * displaying it again in the HUD is redundant and creates overflow issues.
177
+ *
178
+ * Set to true to display a 2-line rolling HUD that follows the typed text.
179
+ */
180
+ showTyping: z.boolean().default(false),
153
181
  position: z.enum(["bottom-center", "bottom-left", "bottom-right"]).default("bottom-center"),
154
182
  fontSize: z.number().default(18),
155
183
  backgroundColor: z.string().default("rgba(0, 0, 0, 0.75)"),
@@ -349,13 +377,17 @@ function interpolatePath(from, to, steps) {
349
377
  if (steps === 1) return [from, to];
350
378
  const dx = to.x - from.x;
351
379
  const dy = to.y - from.y;
380
+ const distance = Math.hypot(dx, dy);
381
+ const perpScale = Math.min(distance * 0.06, 30);
382
+ const normX = distance > 0 ? dy / distance * perpScale : 0;
383
+ const normY = distance > 0 ? -dx / distance * perpScale : 0;
352
384
  const cp1 = {
353
- x: from.x + dx * 0.25 + dy * 0.1,
354
- y: from.y + dy * 0.25 - dx * 0.1
385
+ x: from.x + dx * 0.25 + normX,
386
+ y: from.y + dy * 0.25 + normY
355
387
  };
356
388
  const cp2 = {
357
- x: from.x + dx * 0.75 - dy * 0.1,
358
- y: from.y + dy * 0.75 + dx * 0.1
389
+ x: from.x + dx * 0.75 - normX,
390
+ y: from.y + dy * 0.75 - normY
359
391
  };
360
392
  const points = [];
361
393
  for (let i = 0; i <= steps; i++) {
@@ -394,12 +426,9 @@ var CLICK_EFFECT_DURATION_MS = 500;
394
426
  var REPAINT_INTERVAL_MS = 25;
395
427
  var ACTION_GAP_MS = 30;
396
428
  var CURSOR_SPEED_PRESETS = {
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
429
+ fast: { pixelsPerStep: 22, stepDelayMs: 22, minSteps: 8, maxSteps: 35 },
430
+ normal: { pixelsPerStep: 16, stepDelayMs: 26, minSteps: 10, maxSteps: 45 },
431
+ slow: { pixelsPerStep: 12, stepDelayMs: 32, minSteps: 12, maxSteps: 55 }
403
432
  };
404
433
  var FrameChannel = class {
405
434
  buffer = [];
@@ -439,6 +468,9 @@ var ClipwiseRecorder = class {
439
468
  cursorTimeline = [];
440
469
  clickTimeline = [];
441
470
  keystrokeTimeline = [];
471
+ /** Incremented at the start of each `type` action so the HUD can render
472
+ * each input field's text on a separate line. */
473
+ keystrokeSessionId = 0;
442
474
  currentStepIndex = 0;
443
475
  cursorPosition = { x: 0, y: 0 };
444
476
  viewport = { width: 1280, height: 800 };
@@ -476,6 +508,7 @@ var ClipwiseRecorder = class {
476
508
  this.cursorTimeline = [];
477
509
  this.clickTimeline = [];
478
510
  this.keystrokeTimeline = [];
511
+ this.keystrokeSessionId = 0;
479
512
  this.currentStepIndex = 0;
480
513
  this.cursorPosition = { x: 0, y: 0 };
481
514
  this.isCapturing = false;
@@ -718,6 +751,34 @@ var ClipwiseRecorder = class {
718
751
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
719
752
  };
720
753
  }
754
+ /**
755
+ * Force a unique DOM repaint visible in the top scanlines of the captured PNG.
756
+ *
757
+ * Uses a 1×1 px fixed-position element at z-index MAX, sitting above ALL
758
+ * overlays including modals (position:fixed;z-index:100;backdrop-filter:blur).
759
+ * Alternates background between #000001 and #000100 — two colors that are
760
+ * visually indistinguishable (1/255 difference in R or G channel against a
761
+ * dark page) but produce distinct PNG byte sequences, defeating dedup.
762
+ *
763
+ * This replaces the previous `document.documentElement.style.outline` approach
764
+ * which failed whenever a full-viewport fixed overlay (e.g. modal backdrop)
765
+ * was composited on top of the outline, making y=0 PNG bytes identical across
766
+ * frames and causing dedup to collapse all modal-typing frames into one.
767
+ */
768
+ async forceRepaint(t) {
769
+ if (!this.page) return;
770
+ await this.page.evaluate((toggle) => {
771
+ let el = document.getElementById("__cw_rf__");
772
+ if (!el) {
773
+ el = document.createElement("div");
774
+ el.id = "__cw_rf__";
775
+ el.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;z-index:2147483647;pointer-events:none";
776
+ (document.body ?? document.documentElement).appendChild(el);
777
+ }
778
+ el.style.background = toggle ? "#000001" : "#000100";
779
+ }, t).catch(() => {
780
+ });
781
+ }
721
782
  /**
722
783
  * Wait for a given duration while forcing periodic repaints
723
784
  * so CDP screencast keeps sending frames even on static pages.
@@ -727,10 +788,7 @@ var ClipwiseRecorder = class {
727
788
  const endTime = Date.now() + durationMs;
728
789
  let toggle = false;
729
790
  while (Date.now() < endTime && this.isCapturing) {
730
- await this.page.evaluate((t) => {
731
- document.documentElement.style.outline = t ? "0.001px solid transparent" : "none";
732
- }, toggle).catch(() => {
733
- });
791
+ await this.forceRepaint(toggle);
734
792
  toggle = !toggle;
735
793
  const remaining = endTime - Date.now();
736
794
  if (remaining > 0) {
@@ -807,40 +865,57 @@ var ClipwiseRecorder = class {
807
865
  timestamp: Date.now()
808
866
  });
809
867
  await this.page.click(action.selector);
868
+ this.keystrokeSessionId++;
869
+ const currentSessionId = this.keystrokeSessionId;
870
+ let typeRepaintToggle = false;
810
871
  for (const char of action.text) {
811
- await this.page.keyboard.type(char, { delay: action.delay });
872
+ await this.page.keyboard.type(char);
873
+ typeRepaintToggle = !typeRepaintToggle;
874
+ await this.forceRepaint(typeRepaintToggle);
812
875
  this.keystrokeTimeline.push({
813
876
  key: char,
814
- timestamp: Date.now()
877
+ timestamp: Date.now(),
878
+ sessionId: currentSessionId
815
879
  });
880
+ await new Promise((resolve2) => setTimeout(resolve2, action.delay));
816
881
  }
817
882
  break;
818
883
  }
819
884
  case "scroll": {
820
885
  const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector, action.timeout) : null;
821
- await this.page.evaluate(
822
- ({ x, y, smooth, selector }) => {
823
- const target = selector ? document.querySelector(selector) : window;
824
- if (target) {
825
- const options = {
826
- left: x,
827
- top: y,
828
- behavior: smooth ? "smooth" : "instant"
829
- };
830
- if (target === window) {
831
- window.scrollBy(options);
832
- } else {
833
- target.scrollBy(options);
834
- }
835
- }
836
- },
837
- {
838
- x: action.x,
839
- y: action.y,
840
- smooth: action.smooth,
841
- selector: action.selector ?? null
886
+ const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
887
+ if (action.smooth && scrollDistance > 0) {
888
+ const scrollSteps = Math.max(12, Math.round(scrollDistance / 25));
889
+ const yStep = action.y / scrollSteps;
890
+ const xStep = action.x / scrollSteps;
891
+ for (let s = 0; s < scrollSteps; s++) {
892
+ await this.page.evaluate(
893
+ ({ dy, dx, sel }) => {
894
+ const el = sel ? document.querySelector(sel) : window;
895
+ if (!el) return;
896
+ const opts = { left: dx, top: dy, behavior: "instant" };
897
+ if (el === window) window.scrollBy(opts);
898
+ else el.scrollBy(opts);
899
+ },
900
+ { dy: yStep, dx: xStep, sel: action.selector ?? null }
901
+ );
902
+ await new Promise((resolve2) => setTimeout(resolve2, 30));
842
903
  }
843
- );
904
+ await this.waitWithRepaints(150);
905
+ } else {
906
+ await this.page.evaluate(
907
+ ({ x, y, selector }) => {
908
+ const target = selector ? document.querySelector(selector) : window;
909
+ if (target) {
910
+ const options = { left: x, top: y, behavior: "instant" };
911
+ if (target === window) window.scrollBy(options);
912
+ else target.scrollBy(options);
913
+ }
914
+ },
915
+ { x: action.x, y: action.y, selector: action.selector ?? null }
916
+ );
917
+ await this.waitWithRepaints(100);
918
+ }
844
919
  if (scrollTarget) {
845
920
  this.cursorPosition = scrollTarget;
846
921
  this.cursorTimeline.push({
@@ -848,10 +923,7 @@ var ClipwiseRecorder = class {
848
923
  timestamp: Date.now()
849
924
  });
850
925
  }
851
- const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
852
- const scrollWait = action.smooth ? Math.max(600, Math.round(scrollDistance * 0.8)) : 100;
853
- await this.waitWithRepaints(scrollWait);
854
- await this.waitWithRepaints(150);
926
+ await this.waitWithRepaints(120);
855
927
  break;
856
928
  }
857
929
  case "wait": {
@@ -903,25 +975,86 @@ var ClipwiseRecorder = class {
903
975
  await this.waitWithRepaints(ACTION_GAP_MS);
904
976
  }
905
977
  /**
906
- * Move cursor smoothly from current position to target using
907
- * manual step-by-step movement with delays between each step.
908
- * Speed is controlled by the cursor.speed preset (fast/normal/slow).
978
+ * Suppress all CSS transitions and animations on the page during cursor
979
+ * movement. Hover-state transitions (background, transform, box-shadow,
980
+ * etc.) on elements the cursor passes over generate CSS-animation-driven
981
+ * CDP frames that arrive asynchronously relative to our cursor step
982
+ * intervals. Those extra frames are timestamped when they're ACK-drained,
983
+ * which can be many milliseconds after the actual cursor moved — causing
984
+ * interpolateCursorAt() to map them to a newer cursor position while the
985
+ * screenshot still shows older content → visible stutter.
986
+ *
987
+ * Suppressing transitions during movement eliminates these extra frames
988
+ * entirely regardless of which elements the path crosses. Transitions are
989
+ * restored immediately after arrival, so hover effects on the final target
990
+ * element still appear during the subsequent holdDuration.
991
+ */
992
+ async suppressTransitions() {
993
+ if (!this.page) return;
994
+ await this.page.evaluate(() => {
995
+ if (document.getElementById("__cw_notrans__")) return;
996
+ const s = document.createElement("style");
997
+ s.id = "__cw_notrans__";
998
+ s.textContent = "*{transition-duration:0s!important;transition-delay:0s!important}";
999
+ (document.head ?? document.documentElement).appendChild(s);
1000
+ }).catch(() => {
1001
+ });
1002
+ }
1003
+ async restoreTransitions() {
1004
+ if (!this.page) return;
1005
+ await this.page.evaluate(() => {
1006
+ document.getElementById("__cw_notrans__")?.remove();
1007
+ }).catch(() => {
1008
+ });
1009
+ }
1010
+ /**
1011
+ * Move cursor smoothly from current position to target.
1012
+ *
1013
+ * Key design decisions:
1014
+ * 1. Adaptive step count — proportional to travel distance so short and
1015
+ * long movements feel equally paced (pixelsPerStep controls speed).
1016
+ * 2. Forced repaint per step — moving the mouse in headless Chrome does NOT
1017
+ * visually change the screenshot (the cursor is rendered in post-processing).
1018
+ * Without a forced repaint, dedup collapses every intermediate frame into
1019
+ * the first one, making the cursor appear to teleport.
1020
+ * 3. Transition suppression — CSS transitions on hovered elements generate
1021
+ * asynchronous CDP frames that desync cursor position from screenshot
1022
+ * content. All transitions are suppressed for the duration of movement
1023
+ * and restored on arrival (see suppressTransitions / restoreTransitions).
1024
+ * 4. Capped bezier curve — perpendicular offset is capped at 30 px regardless
1025
+ * of distance, preventing a visible arc on long-distance movements.
909
1026
  */
910
1027
  async moveCursorSmooth(target) {
911
1028
  if (!this.page) return;
912
- const { steps, delay } = CURSOR_SPEED_PRESETS[this.cursorSpeed];
1029
+ const preset = CURSOR_SPEED_PRESETS[this.cursorSpeed];
913
1030
  const from = { ...this.cursorPosition };
1031
+ const distance = Math.hypot(target.x - from.x, target.y - from.y);
1032
+ if (distance < 2) {
1033
+ this.cursorPosition = { ...target };
1034
+ return;
1035
+ }
1036
+ const steps = Math.round(
1037
+ Math.min(Math.max(distance / preset.pixelsPerStep, preset.minSteps), preset.maxSteps)
1038
+ );
914
1039
  const path = interpolatePath(from, target, steps);
915
- for (const point of path) {
916
- await this.page.mouse.move(point.x, point.y);
917
- this.cursorTimeline.push({
918
- position: { x: point.x, y: point.y },
919
- timestamp: Date.now()
920
- });
921
- await new Promise((resolve2) => setTimeout(resolve2, delay));
1040
+ let repaintToggle = false;
1041
+ await this.suppressTransitions();
1042
+ try {
1043
+ for (const point of path) {
1044
+ await this.page.mouse.move(point.x, point.y);
1045
+ repaintToggle = !repaintToggle;
1046
+ await this.forceRepaint(repaintToggle);
1047
+ this.cursorTimeline.push({
1048
+ position: { x: point.x, y: point.y },
1049
+ timestamp: Date.now()
1050
+ });
1051
+ await new Promise((resolve2) => setTimeout(resolve2, preset.stepDelayMs));
1052
+ }
1053
+ } finally {
1054
+ await this.restoreTransitions();
922
1055
  }
923
1056
  this.cursorPosition = { ...target };
924
- await this.waitWithRepaints(100);
1057
+ await this.waitWithRepaints(80);
925
1058
  }
926
1059
  /**
927
1060
  * Build CapturedFrame array from raw screencast frames,
@@ -1340,8 +1473,11 @@ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeig
1340
1473
  const size = Math.round(config.size * dpr);
1341
1474
  const cursorSvg = buildCursorSvg(size, config.color);
1342
1475
  const cursorBuffer = Buffer.from(cursorSvg);
1343
- const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
1344
- const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
1476
+ const tipOffsetX = Math.round(4 / 24 * size);
1477
+ const px = Math.round(position.x * dpr);
1478
+ const py = Math.round(position.y * dpr);
1479
+ const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
1480
+ const top = Math.max(0, Math.min(py, frameHeight - size));
1345
1481
  return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
1346
1482
  }
1347
1483
  async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
@@ -1403,6 +1539,17 @@ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, fra
1403
1539
 
1404
1540
  // src/effects/zoom.ts
1405
1541
  import sharp3 from "sharp";
1542
+ var ZOOM_INTENSITY_SCALES = {
1543
+ subtle: 1.15,
1544
+ light: 1.25,
1545
+ moderate: 1.35,
1546
+ strong: 1.5,
1547
+ dramatic: 1.8
1548
+ };
1549
+ function resolveZoomScale(scale, intensity) {
1550
+ if (intensity !== void 0) return ZOOM_INTENSITY_SCALES[intensity];
1551
+ return scale;
1552
+ }
1406
1553
  async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
1407
1554
  if (scale <= 1) return frameBuffer;
1408
1555
  const cropWidth = Math.round(frameWidth / scale);
@@ -1549,30 +1696,55 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
1549
1696
 
1550
1697
  // src/effects/keystroke.ts
1551
1698
  import sharp5 from "sharp";
1699
+ function escapeXml(s) {
1700
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1701
+ }
1702
+ function buildSessions(keystrokes) {
1703
+ const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
1704
+ if (!hasSessionIds) {
1705
+ const text = keystrokes.map((k) => k.key).join("");
1706
+ return text.length > 0 ? [text] : [];
1707
+ }
1708
+ const map = /* @__PURE__ */ new Map();
1709
+ for (const k of keystrokes) {
1710
+ const sid = k.sessionId ?? 0;
1711
+ map.set(sid, (map.get(sid) ?? "") + k.key);
1712
+ }
1713
+ return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([, text]) => text).filter((t) => t.length > 0);
1714
+ }
1552
1715
  async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
1553
1716
  if (!config.enabled || keystrokes.length === 0) return frameBuffer;
1554
- const recentKeys = keystrokes.filter(
1555
- (k) => frameTimestamp - k.timestamp < config.fadeAfter
1556
- );
1557
- if (recentKeys.length === 0) return frameBuffer;
1558
- const displayText = recentKeys.map((k) => k.key).join("");
1559
- if (displayText.length === 0) return frameBuffer;
1717
+ if (!config.showTyping) return frameBuffer;
1718
+ const lastKeystroke = keystrokes[keystrokes.length - 1];
1719
+ const age = frameTimestamp - lastKeystroke.timestamp;
1720
+ if (age >= config.fadeAfter) return frameBuffer;
1721
+ const fadeStart = config.fadeAfter * 0.6;
1722
+ const globalOpacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1723
+ if (globalOpacity <= 0) return frameBuffer;
1724
+ const allSessions = buildSessions(keystrokes);
1725
+ if (allSessions.length === 0) return frameBuffer;
1726
+ const sessions = allSessions.slice(-3);
1727
+ const lineCount = sessions.length;
1560
1728
  const fontSize = config.fontSize * dpr;
1561
1729
  const padding = config.padding * dpr;
1562
- const charWidth = fontSize * 0.62;
1563
- const textWidth = Math.ceil(displayText.length * charWidth);
1564
1730
  const hudPadH = padding * 2;
1565
- const hudPadV = padding * 1.5;
1566
- const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
1567
- const hudHeight = Math.ceil(fontSize + hudPadV * 2);
1568
- const newest = recentKeys[recentKeys.length - 1];
1569
- const age = frameTimestamp - newest.timestamp;
1570
- const fadeStart = config.fadeAfter * 0.6;
1571
- const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1572
- if (opacity <= 0) return frameBuffer;
1731
+ const hudPadV = padding * 1.4;
1732
+ const lineGap = Math.round(fontSize * 0.45);
1733
+ const charWidth = fontSize * 0.615;
1734
+ const maxHudWidth = frameWidth - 60 * dpr;
1735
+ const maxCharsPerLine = Math.max(10, Math.floor((maxHudWidth - hudPadH * 2) / charWidth));
1736
+ const lines = sessions.map(
1737
+ (text) => text.length > maxCharsPerLine ? text.slice(-maxCharsPerLine) : text
1738
+ );
1739
+ const maxLineLen = Math.max(...lines.map((l) => l.length));
1740
+ const hudWidth = Math.min(
1741
+ Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
1742
+ maxHudWidth
1743
+ );
1744
+ const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
1573
1745
  const margin = 30 * dpr;
1574
- let hudX;
1575
1746
  const hudY = frameHeight - hudHeight - margin;
1747
+ let hudX;
1576
1748
  switch (config.position) {
1577
1749
  case "bottom-left":
1578
1750
  hudX = margin;
@@ -1583,17 +1755,24 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1583
1755
  case "bottom-center":
1584
1756
  default:
1585
1757
  hudX = Math.round((frameWidth - hudWidth) / 2);
1586
- break;
1587
1758
  }
1588
- const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
1589
- const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
1590
- const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1759
+ const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
1760
+ const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
1761
+ const rx = (8 * dpr).toFixed(1);
1762
+ const boxOp = (globalOpacity * 0.92).toFixed(3);
1763
+ const textX = hudX + hudPadH;
1764
+ const baselineY = hudY + hudPadV + fontSize * 0.82;
1765
+ const textElements = lines.map((line, i) => {
1766
+ const op = (globalOpacity * opacityFactors[i]).toFixed(3);
1767
+ const lineY = baselineY + i * (fontSize + lineGap);
1768
+ return `<text x="${textX}" y="${lineY}"
1769
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1770
+ fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
1771
+ }).join("\n ");
1591
1772
  const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1592
1773
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
1593
- rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1594
- <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
1595
- font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1596
- fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
1774
+ rx="${rx}" ry="${rx}" fill="${config.backgroundColor}" opacity="${boxOp}" />
1775
+ ${textElements}
1597
1776
  </svg>`;
1598
1777
  return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
1599
1778
  }
@@ -1667,6 +1846,11 @@ async function composeFrame(frame, effects, output, context) {
1667
1846
  clickProgress: context?.clickProgress ?? null,
1668
1847
  cursorTrail: context?.cursorTrail ?? []
1669
1848
  };
1849
+ const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
1850
+ const withFrameOffset = (pos) => ({
1851
+ x: pos.x + frameOffset.left / Math.max(1, dpr),
1852
+ y: pos.y + frameOffset.top / Math.max(1, dpr)
1853
+ });
1670
1854
  if (effects.deviceFrame.enabled) {
1671
1855
  const sl2 = ctx.staticLayers;
1672
1856
  if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
@@ -1687,7 +1871,7 @@ async function composeFrame(frame, effects, output, context) {
1687
1871
  if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1688
1872
  buffer = await renderCursorHighlight(
1689
1873
  buffer,
1690
- frame.cursorPosition,
1874
+ withFrameOffset(frame.cursorPosition),
1691
1875
  effects.cursor,
1692
1876
  width,
1693
1877
  height,
@@ -1697,7 +1881,7 @@ async function composeFrame(frame, effects, output, context) {
1697
1881
  if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1698
1882
  buffer = await renderCursorTrail(
1699
1883
  buffer,
1700
- ctx.cursorTrail,
1884
+ ctx.cursorTrail.map(withFrameOffset),
1701
1885
  effects.cursor,
1702
1886
  width,
1703
1887
  height,
@@ -1707,7 +1891,7 @@ async function composeFrame(frame, effects, output, context) {
1707
1891
  if (effects.cursor.enabled && frame.cursorPosition) {
1708
1892
  buffer = await renderCursor(
1709
1893
  buffer,
1710
- frame.cursorPosition,
1894
+ withFrameOffset(frame.cursorPosition),
1711
1895
  effects.cursor,
1712
1896
  width,
1713
1897
  height,
@@ -1718,7 +1902,7 @@ async function composeFrame(frame, effects, output, context) {
1718
1902
  const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1719
1903
  buffer = await renderClickEffect(
1720
1904
  buffer,
1721
- frame.clickPosition,
1905
+ withFrameOffset(frame.clickPosition),
1722
1906
  effects.cursor,
1723
1907
  progress,
1724
1908
  width,
@@ -1726,6 +1910,16 @@ async function composeFrame(frame, effects, output, context) {
1726
1910
  dpr
1727
1911
  );
1728
1912
  }
1913
+ const scale = ctx.zoomScale;
1914
+ if (effects.zoom.enabled && scale > 1) {
1915
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1916
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
1917
+ const focusPoint = {
1918
+ x: rawFocus.x * dpr + offset.left,
1919
+ y: rawFocus.y * dpr + offset.top
1920
+ };
1921
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1922
+ }
1729
1923
  if (effects.keystroke.enabled && frame.keystrokes) {
1730
1924
  buffer = await renderKeystrokeHud(
1731
1925
  buffer,
@@ -1737,16 +1931,6 @@ async function composeFrame(frame, effects, output, context) {
1737
1931
  dpr
1738
1932
  );
1739
1933
  }
1740
- const scale = ctx.zoomScale;
1741
- if (effects.zoom.enabled && scale > 1) {
1742
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1743
- const offset = getFrameOffset(effects.deviceFrame, dpr);
1744
- const focusPoint = {
1745
- x: rawFocus.x * dpr + offset.left,
1746
- y: rawFocus.y * dpr + offset.top
1747
- };
1748
- buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1749
- }
1750
1934
  const sl = ctx.staticLayers;
1751
1935
  if (sl) {
1752
1936
  const padding = effects.background.padding;
@@ -1965,6 +2149,10 @@ var CanvasRenderer = class {
1965
2149
  this.output.fps * (this.effects.zoom.duration / 1e3)
1966
2150
  );
1967
2151
  const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
2152
+ const effectiveScale = resolveZoomScale(
2153
+ this.effects.zoom.scale,
2154
+ this.effects.zoom.intensity
2155
+ );
1968
2156
  for (let i = 0; i < frames.length; i++) {
1969
2157
  const frame = frames[i];
1970
2158
  let zoomScale = 1;
@@ -1972,7 +2160,7 @@ var CanvasRenderer = class {
1972
2160
  zoomScale = calculateAdaptiveZoomFromLookup(
1973
2161
  clickLookup,
1974
2162
  i,
1975
- this.effects.zoom.scale,
2163
+ effectiveScale,
1976
2164
  transitionFrames
1977
2165
  );
1978
2166
  }
@@ -2088,6 +2276,10 @@ var CanvasRenderer = class {
2088
2276
  let nextToDispatch = 0;
2089
2277
  let nextToYield = 0;
2090
2278
  const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
2279
+ const effectiveScale = resolveZoomScale(
2280
+ this.effects.zoom.scale,
2281
+ this.effects.zoom.intensity
2282
+ );
2091
2283
  const computeContext = (i) => {
2092
2284
  const frame = frames[i];
2093
2285
  let zoomScale = 1;
@@ -2098,7 +2290,7 @@ var CanvasRenderer = class {
2098
2290
  frames.slice(lo, hi + 1),
2099
2291
  lo,
2100
2292
  i,
2101
- this.effects.zoom.scale,
2293
+ effectiveScale,
2102
2294
  transitionFrames
2103
2295
  );
2104
2296
  }