clipwise 0.4.1 → 0.5.1

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
@@ -10,13 +10,17 @@ function interpolatePath(from, to, steps) {
10
10
  if (steps === 1) return [from, to];
11
11
  const dx = to.x - from.x;
12
12
  const dy = to.y - from.y;
13
+ const distance = Math.hypot(dx, dy);
14
+ const perpScale = Math.min(distance * 0.06, 30);
15
+ const normX = distance > 0 ? dy / distance * perpScale : 0;
16
+ const normY = distance > 0 ? -dx / distance * perpScale : 0;
13
17
  const cp1 = {
14
- x: from.x + dx * 0.25 + dy * 0.1,
15
- y: from.y + dy * 0.25 - dx * 0.1
18
+ x: from.x + dx * 0.25 + normX,
19
+ y: from.y + dy * 0.25 + normY
16
20
  };
17
21
  const cp2 = {
18
- x: from.x + dx * 0.75 - dy * 0.1,
19
- y: from.y + dy * 0.75 + dx * 0.1
22
+ x: from.x + dx * 0.75 - normX,
23
+ y: from.y + dy * 0.75 - normY
20
24
  };
21
25
  const points = [];
22
26
  for (let i = 0; i <= steps; i++) {
@@ -55,12 +59,9 @@ var CLICK_EFFECT_DURATION_MS = 500;
55
59
  var REPAINT_INTERVAL_MS = 25;
56
60
  var ACTION_GAP_MS = 30;
57
61
  var CURSOR_SPEED_PRESETS = {
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
62
+ fast: { pixelsPerStep: 22, stepDelayMs: 22, minSteps: 8, maxSteps: 35 },
63
+ normal: { pixelsPerStep: 16, stepDelayMs: 26, minSteps: 10, maxSteps: 45 },
64
+ slow: { pixelsPerStep: 12, stepDelayMs: 32, minSteps: 12, maxSteps: 55 }
64
65
  };
65
66
  var FrameChannel = class {
66
67
  buffer = [];
@@ -100,6 +101,9 @@ var ClipwiseRecorder = class {
100
101
  cursorTimeline = [];
101
102
  clickTimeline = [];
102
103
  keystrokeTimeline = [];
104
+ /** Incremented at the start of each `type` action so the HUD can render
105
+ * each input field's text on a separate line. */
106
+ keystrokeSessionId = 0;
103
107
  currentStepIndex = 0;
104
108
  cursorPosition = { x: 0, y: 0 };
105
109
  viewport = { width: 1280, height: 800 };
@@ -137,6 +141,7 @@ var ClipwiseRecorder = class {
137
141
  this.cursorTimeline = [];
138
142
  this.clickTimeline = [];
139
143
  this.keystrokeTimeline = [];
144
+ this.keystrokeSessionId = 0;
140
145
  this.currentStepIndex = 0;
141
146
  this.cursorPosition = { x: 0, y: 0 };
142
147
  this.isCapturing = false;
@@ -379,6 +384,34 @@ var ClipwiseRecorder = class {
379
384
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
380
385
  };
381
386
  }
387
+ /**
388
+ * Force a unique DOM repaint visible in the top scanlines of the captured PNG.
389
+ *
390
+ * Uses a 1×1 px fixed-position element at z-index MAX, sitting above ALL
391
+ * overlays including modals (position:fixed;z-index:100;backdrop-filter:blur).
392
+ * Alternates background between #000001 and #000100 — two colors that are
393
+ * visually indistinguishable (1/255 difference in R or G channel against a
394
+ * dark page) but produce distinct PNG byte sequences, defeating dedup.
395
+ *
396
+ * This replaces the previous `document.documentElement.style.outline` approach
397
+ * which failed whenever a full-viewport fixed overlay (e.g. modal backdrop)
398
+ * was composited on top of the outline, making y=0 PNG bytes identical across
399
+ * frames and causing dedup to collapse all modal-typing frames into one.
400
+ */
401
+ async forceRepaint(t) {
402
+ if (!this.page) return;
403
+ await this.page.evaluate((toggle) => {
404
+ let el = document.getElementById("__cw_rf__");
405
+ if (!el) {
406
+ el = document.createElement("div");
407
+ el.id = "__cw_rf__";
408
+ el.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;z-index:2147483647;pointer-events:none";
409
+ (document.body ?? document.documentElement).appendChild(el);
410
+ }
411
+ el.style.background = toggle ? "#000001" : "#000100";
412
+ }, t).catch(() => {
413
+ });
414
+ }
382
415
  /**
383
416
  * Wait for a given duration while forcing periodic repaints
384
417
  * so CDP screencast keeps sending frames even on static pages.
@@ -388,10 +421,7 @@ var ClipwiseRecorder = class {
388
421
  const endTime = Date.now() + durationMs;
389
422
  let toggle = false;
390
423
  while (Date.now() < endTime && this.isCapturing) {
391
- await this.page.evaluate((t) => {
392
- document.documentElement.style.outline = t ? "0.001px solid transparent" : "none";
393
- }, toggle).catch(() => {
394
- });
424
+ await this.forceRepaint(toggle);
395
425
  toggle = !toggle;
396
426
  const remaining = endTime - Date.now();
397
427
  if (remaining > 0) {
@@ -468,40 +498,57 @@ var ClipwiseRecorder = class {
468
498
  timestamp: Date.now()
469
499
  });
470
500
  await this.page.click(action.selector);
501
+ this.keystrokeSessionId++;
502
+ const currentSessionId = this.keystrokeSessionId;
503
+ let typeRepaintToggle = false;
471
504
  for (const char of action.text) {
472
- await this.page.keyboard.type(char, { delay: action.delay });
505
+ await this.page.keyboard.type(char);
506
+ typeRepaintToggle = !typeRepaintToggle;
507
+ await this.forceRepaint(typeRepaintToggle);
473
508
  this.keystrokeTimeline.push({
474
509
  key: char,
475
- timestamp: Date.now()
510
+ timestamp: Date.now(),
511
+ sessionId: currentSessionId
476
512
  });
513
+ await new Promise((resolve) => setTimeout(resolve, action.delay));
477
514
  }
478
515
  break;
479
516
  }
480
517
  case "scroll": {
481
518
  const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector, action.timeout) : null;
482
- await this.page.evaluate(
483
- ({ x, y, smooth, selector }) => {
484
- const target = selector ? document.querySelector(selector) : window;
485
- if (target) {
486
- const options = {
487
- left: x,
488
- top: y,
489
- behavior: smooth ? "smooth" : "instant"
490
- };
491
- if (target === window) {
492
- window.scrollBy(options);
493
- } else {
494
- target.scrollBy(options);
495
- }
496
- }
497
- },
498
- {
499
- x: action.x,
500
- y: action.y,
501
- smooth: action.smooth,
502
- selector: action.selector ?? null
519
+ const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
520
+ if (action.smooth && scrollDistance > 0) {
521
+ const scrollSteps = Math.max(12, Math.round(scrollDistance / 25));
522
+ const yStep = action.y / scrollSteps;
523
+ const xStep = action.x / scrollSteps;
524
+ for (let s = 0; s < scrollSteps; s++) {
525
+ await this.page.evaluate(
526
+ ({ dy, dx, sel }) => {
527
+ const el = sel ? document.querySelector(sel) : window;
528
+ if (!el) return;
529
+ const opts = { left: dx, top: dy, behavior: "instant" };
530
+ if (el === window) window.scrollBy(opts);
531
+ else el.scrollBy(opts);
532
+ },
533
+ { dy: yStep, dx: xStep, sel: action.selector ?? null }
534
+ );
535
+ await new Promise((resolve) => setTimeout(resolve, 30));
503
536
  }
504
- );
537
+ await this.waitWithRepaints(150);
538
+ } else {
539
+ await this.page.evaluate(
540
+ ({ x, y, selector }) => {
541
+ const target = selector ? document.querySelector(selector) : window;
542
+ if (target) {
543
+ const options = { left: x, top: y, behavior: "instant" };
544
+ if (target === window) window.scrollBy(options);
545
+ else target.scrollBy(options);
546
+ }
547
+ },
548
+ { x: action.x, y: action.y, selector: action.selector ?? null }
549
+ );
550
+ await this.waitWithRepaints(100);
551
+ }
505
552
  if (scrollTarget) {
506
553
  this.cursorPosition = scrollTarget;
507
554
  this.cursorTimeline.push({
@@ -509,10 +556,7 @@ var ClipwiseRecorder = class {
509
556
  timestamp: Date.now()
510
557
  });
511
558
  }
512
- const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
513
- const scrollWait = action.smooth ? Math.max(600, Math.round(scrollDistance * 0.8)) : 100;
514
- await this.waitWithRepaints(scrollWait);
515
- await this.waitWithRepaints(150);
559
+ await this.waitWithRepaints(120);
516
560
  break;
517
561
  }
518
562
  case "wait": {
@@ -564,25 +608,86 @@ var ClipwiseRecorder = class {
564
608
  await this.waitWithRepaints(ACTION_GAP_MS);
565
609
  }
566
610
  /**
567
- * Move cursor smoothly from current position to target using
568
- * manual step-by-step movement with delays between each step.
569
- * Speed is controlled by the cursor.speed preset (fast/normal/slow).
611
+ * Suppress all CSS transitions and animations on the page during cursor
612
+ * movement. Hover-state transitions (background, transform, box-shadow,
613
+ * etc.) on elements the cursor passes over generate CSS-animation-driven
614
+ * CDP frames that arrive asynchronously relative to our cursor step
615
+ * intervals. Those extra frames are timestamped when they're ACK-drained,
616
+ * which can be many milliseconds after the actual cursor moved — causing
617
+ * interpolateCursorAt() to map them to a newer cursor position while the
618
+ * screenshot still shows older content → visible stutter.
619
+ *
620
+ * Suppressing transitions during movement eliminates these extra frames
621
+ * entirely regardless of which elements the path crosses. Transitions are
622
+ * restored immediately after arrival, so hover effects on the final target
623
+ * element still appear during the subsequent holdDuration.
624
+ */
625
+ async suppressTransitions() {
626
+ if (!this.page) return;
627
+ await this.page.evaluate(() => {
628
+ if (document.getElementById("__cw_notrans__")) return;
629
+ const s = document.createElement("style");
630
+ s.id = "__cw_notrans__";
631
+ s.textContent = "*{transition-duration:0s!important;transition-delay:0s!important}";
632
+ (document.head ?? document.documentElement).appendChild(s);
633
+ }).catch(() => {
634
+ });
635
+ }
636
+ async restoreTransitions() {
637
+ if (!this.page) return;
638
+ await this.page.evaluate(() => {
639
+ document.getElementById("__cw_notrans__")?.remove();
640
+ }).catch(() => {
641
+ });
642
+ }
643
+ /**
644
+ * Move cursor smoothly from current position to target.
645
+ *
646
+ * Key design decisions:
647
+ * 1. Adaptive step count — proportional to travel distance so short and
648
+ * long movements feel equally paced (pixelsPerStep controls speed).
649
+ * 2. Forced repaint per step — moving the mouse in headless Chrome does NOT
650
+ * visually change the screenshot (the cursor is rendered in post-processing).
651
+ * Without a forced repaint, dedup collapses every intermediate frame into
652
+ * the first one, making the cursor appear to teleport.
653
+ * 3. Transition suppression — CSS transitions on hovered elements generate
654
+ * asynchronous CDP frames that desync cursor position from screenshot
655
+ * content. All transitions are suppressed for the duration of movement
656
+ * and restored on arrival (see suppressTransitions / restoreTransitions).
657
+ * 4. Capped bezier curve — perpendicular offset is capped at 30 px regardless
658
+ * of distance, preventing a visible arc on long-distance movements.
570
659
  */
571
660
  async moveCursorSmooth(target) {
572
661
  if (!this.page) return;
573
- const { steps, delay } = CURSOR_SPEED_PRESETS[this.cursorSpeed];
662
+ const preset = CURSOR_SPEED_PRESETS[this.cursorSpeed];
574
663
  const from = { ...this.cursorPosition };
664
+ const distance = Math.hypot(target.x - from.x, target.y - from.y);
665
+ if (distance < 2) {
666
+ this.cursorPosition = { ...target };
667
+ return;
668
+ }
669
+ const steps = Math.round(
670
+ Math.min(Math.max(distance / preset.pixelsPerStep, preset.minSteps), preset.maxSteps)
671
+ );
575
672
  const path = interpolatePath(from, target, steps);
576
- for (const point of path) {
577
- await this.page.mouse.move(point.x, point.y);
578
- this.cursorTimeline.push({
579
- position: { x: point.x, y: point.y },
580
- timestamp: Date.now()
581
- });
582
- await new Promise((resolve) => setTimeout(resolve, delay));
673
+ let repaintToggle = false;
674
+ await this.suppressTransitions();
675
+ try {
676
+ for (const point of path) {
677
+ await this.page.mouse.move(point.x, point.y);
678
+ repaintToggle = !repaintToggle;
679
+ await this.forceRepaint(repaintToggle);
680
+ this.cursorTimeline.push({
681
+ position: { x: point.x, y: point.y },
682
+ timestamp: Date.now()
683
+ });
684
+ await new Promise((resolve) => setTimeout(resolve, preset.stepDelayMs));
685
+ }
686
+ } finally {
687
+ await this.restoreTransitions();
583
688
  }
584
689
  this.cursorPosition = { ...target };
585
- await this.waitWithRepaints(100);
690
+ await this.waitWithRepaints(80);
586
691
  }
587
692
  /**
588
693
  * Build CapturedFrame array from raw screencast frames,
@@ -1001,8 +1106,11 @@ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeig
1001
1106
  const size = Math.round(config.size * dpr);
1002
1107
  const cursorSvg = buildCursorSvg(size, config.color);
1003
1108
  const cursorBuffer = Buffer.from(cursorSvg);
1004
- const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
1005
- const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
1109
+ const tipOffsetX = Math.round(4 / 24 * size);
1110
+ const px = Math.round(position.x * dpr);
1111
+ const py = Math.round(position.y * dpr);
1112
+ const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
1113
+ const top = Math.max(0, Math.min(py, frameHeight - size));
1006
1114
  return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
1007
1115
  }
1008
1116
  async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
@@ -1064,6 +1172,17 @@ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, fra
1064
1172
 
1065
1173
  // src/effects/zoom.ts
1066
1174
  import sharp3 from "sharp";
1175
+ var ZOOM_INTENSITY_SCALES = {
1176
+ subtle: 1.15,
1177
+ light: 1.25,
1178
+ moderate: 1.35,
1179
+ strong: 1.5,
1180
+ dramatic: 1.8
1181
+ };
1182
+ function resolveZoomScale(scale, intensity) {
1183
+ if (intensity !== void 0) return ZOOM_INTENSITY_SCALES[intensity];
1184
+ return scale;
1185
+ }
1067
1186
  async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
1068
1187
  if (scale <= 1) return frameBuffer;
1069
1188
  const cropWidth = Math.round(frameWidth / scale);
@@ -1238,30 +1357,55 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
1238
1357
 
1239
1358
  // src/effects/keystroke.ts
1240
1359
  import sharp5 from "sharp";
1360
+ function escapeXml(s) {
1361
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1362
+ }
1363
+ function buildSessions(keystrokes) {
1364
+ const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
1365
+ if (!hasSessionIds) {
1366
+ const text = keystrokes.map((k) => k.key).join("");
1367
+ return text.length > 0 ? [text] : [];
1368
+ }
1369
+ const map = /* @__PURE__ */ new Map();
1370
+ for (const k of keystrokes) {
1371
+ const sid = k.sessionId ?? 0;
1372
+ map.set(sid, (map.get(sid) ?? "") + k.key);
1373
+ }
1374
+ return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([, text]) => text).filter((t) => t.length > 0);
1375
+ }
1241
1376
  async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
1242
1377
  if (!config.enabled || keystrokes.length === 0) return frameBuffer;
1243
- const recentKeys = keystrokes.filter(
1244
- (k) => frameTimestamp - k.timestamp < config.fadeAfter
1245
- );
1246
- if (recentKeys.length === 0) return frameBuffer;
1247
- const displayText = recentKeys.map((k) => k.key).join("");
1248
- if (displayText.length === 0) return frameBuffer;
1378
+ if (!config.showTyping) return frameBuffer;
1379
+ const lastKeystroke = keystrokes[keystrokes.length - 1];
1380
+ const age = frameTimestamp - lastKeystroke.timestamp;
1381
+ if (age >= config.fadeAfter) return frameBuffer;
1382
+ const fadeStart = config.fadeAfter * 0.6;
1383
+ const globalOpacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1384
+ if (globalOpacity <= 0) return frameBuffer;
1385
+ const allSessions = buildSessions(keystrokes);
1386
+ if (allSessions.length === 0) return frameBuffer;
1387
+ const sessions = allSessions.slice(-3);
1388
+ const lineCount = sessions.length;
1249
1389
  const fontSize = config.fontSize * dpr;
1250
1390
  const padding = config.padding * dpr;
1251
- const charWidth = fontSize * 0.62;
1252
- const textWidth = Math.ceil(displayText.length * charWidth);
1253
1391
  const hudPadH = padding * 2;
1254
- const hudPadV = padding * 1.5;
1255
- const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
1256
- const hudHeight = Math.ceil(fontSize + hudPadV * 2);
1257
- const newest = recentKeys[recentKeys.length - 1];
1258
- const age = frameTimestamp - newest.timestamp;
1259
- const fadeStart = config.fadeAfter * 0.6;
1260
- const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1261
- if (opacity <= 0) return frameBuffer;
1392
+ const hudPadV = padding * 1.4;
1393
+ const lineGap = Math.round(fontSize * 0.45);
1394
+ const charWidth = fontSize * 0.615;
1395
+ const maxHudWidth = frameWidth - 60 * dpr;
1396
+ const maxCharsPerLine = Math.max(10, Math.floor((maxHudWidth - hudPadH * 2) / charWidth));
1397
+ const lines = sessions.map(
1398
+ (text) => text.length > maxCharsPerLine ? text.slice(-maxCharsPerLine) : text
1399
+ );
1400
+ const maxLineLen = Math.max(...lines.map((l) => l.length));
1401
+ const hudWidth = Math.min(
1402
+ Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
1403
+ maxHudWidth
1404
+ );
1405
+ const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
1262
1406
  const margin = 30 * dpr;
1263
- let hudX;
1264
1407
  const hudY = frameHeight - hudHeight - margin;
1408
+ let hudX;
1265
1409
  switch (config.position) {
1266
1410
  case "bottom-left":
1267
1411
  hudX = margin;
@@ -1272,17 +1416,24 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1272
1416
  case "bottom-center":
1273
1417
  default:
1274
1418
  hudX = Math.round((frameWidth - hudWidth) / 2);
1275
- break;
1276
1419
  }
1277
- const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
1278
- const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
1279
- const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1420
+ const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
1421
+ const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
1422
+ const rx = (8 * dpr).toFixed(1);
1423
+ const boxOp = (globalOpacity * 0.92).toFixed(3);
1424
+ const textX = hudX + hudPadH;
1425
+ const baselineY = hudY + hudPadV + fontSize * 0.82;
1426
+ const textElements = lines.map((line, i) => {
1427
+ const op = (globalOpacity * opacityFactors[i]).toFixed(3);
1428
+ const lineY = baselineY + i * (fontSize + lineGap);
1429
+ return `<text x="${textX}" y="${lineY}"
1430
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1431
+ fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
1432
+ }).join("\n ");
1280
1433
  const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1281
1434
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
1282
- rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1283
- <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
1284
- font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1285
- fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
1435
+ rx="${rx}" ry="${rx}" fill="${config.backgroundColor}" opacity="${boxOp}" />
1436
+ ${textElements}
1286
1437
  </svg>`;
1287
1438
  return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
1288
1439
  }
@@ -1356,6 +1507,11 @@ async function composeFrame(frame, effects, output, context) {
1356
1507
  clickProgress: context?.clickProgress ?? null,
1357
1508
  cursorTrail: context?.cursorTrail ?? []
1358
1509
  };
1510
+ const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
1511
+ const withFrameOffset = (pos) => ({
1512
+ x: pos.x + frameOffset.left / Math.max(1, dpr),
1513
+ y: pos.y + frameOffset.top / Math.max(1, dpr)
1514
+ });
1359
1515
  if (effects.deviceFrame.enabled) {
1360
1516
  const sl2 = ctx.staticLayers;
1361
1517
  if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
@@ -1376,7 +1532,7 @@ async function composeFrame(frame, effects, output, context) {
1376
1532
  if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1377
1533
  buffer = await renderCursorHighlight(
1378
1534
  buffer,
1379
- frame.cursorPosition,
1535
+ withFrameOffset(frame.cursorPosition),
1380
1536
  effects.cursor,
1381
1537
  width,
1382
1538
  height,
@@ -1386,7 +1542,7 @@ async function composeFrame(frame, effects, output, context) {
1386
1542
  if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1387
1543
  buffer = await renderCursorTrail(
1388
1544
  buffer,
1389
- ctx.cursorTrail,
1545
+ ctx.cursorTrail.map(withFrameOffset),
1390
1546
  effects.cursor,
1391
1547
  width,
1392
1548
  height,
@@ -1396,7 +1552,7 @@ async function composeFrame(frame, effects, output, context) {
1396
1552
  if (effects.cursor.enabled && frame.cursorPosition) {
1397
1553
  buffer = await renderCursor(
1398
1554
  buffer,
1399
- frame.cursorPosition,
1555
+ withFrameOffset(frame.cursorPosition),
1400
1556
  effects.cursor,
1401
1557
  width,
1402
1558
  height,
@@ -1407,7 +1563,7 @@ async function composeFrame(frame, effects, output, context) {
1407
1563
  const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1408
1564
  buffer = await renderClickEffect(
1409
1565
  buffer,
1410
- frame.clickPosition,
1566
+ withFrameOffset(frame.clickPosition),
1411
1567
  effects.cursor,
1412
1568
  progress,
1413
1569
  width,
@@ -1415,6 +1571,16 @@ async function composeFrame(frame, effects, output, context) {
1415
1571
  dpr
1416
1572
  );
1417
1573
  }
1574
+ const scale = ctx.zoomScale;
1575
+ if (effects.zoom.enabled && scale > 1) {
1576
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1577
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
1578
+ const focusPoint = {
1579
+ x: rawFocus.x * dpr + offset.left,
1580
+ y: rawFocus.y * dpr + offset.top
1581
+ };
1582
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1583
+ }
1418
1584
  if (effects.keystroke.enabled && frame.keystrokes) {
1419
1585
  buffer = await renderKeystrokeHud(
1420
1586
  buffer,
@@ -1426,16 +1592,6 @@ async function composeFrame(frame, effects, output, context) {
1426
1592
  dpr
1427
1593
  );
1428
1594
  }
1429
- const scale = ctx.zoomScale;
1430
- if (effects.zoom.enabled && scale > 1) {
1431
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1432
- const offset = getFrameOffset(effects.deviceFrame, dpr);
1433
- const focusPoint = {
1434
- x: rawFocus.x * dpr + offset.left,
1435
- y: rawFocus.y * dpr + offset.top
1436
- };
1437
- buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1438
- }
1439
1595
  const sl = ctx.staticLayers;
1440
1596
  if (sl) {
1441
1597
  const padding = effects.background.padding;
@@ -1654,6 +1810,10 @@ var CanvasRenderer = class {
1654
1810
  this.output.fps * (this.effects.zoom.duration / 1e3)
1655
1811
  );
1656
1812
  const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
1813
+ const effectiveScale = resolveZoomScale(
1814
+ this.effects.zoom.scale,
1815
+ this.effects.zoom.intensity
1816
+ );
1657
1817
  for (let i = 0; i < frames.length; i++) {
1658
1818
  const frame = frames[i];
1659
1819
  let zoomScale = 1;
@@ -1661,7 +1821,7 @@ var CanvasRenderer = class {
1661
1821
  zoomScale = calculateAdaptiveZoomFromLookup(
1662
1822
  clickLookup,
1663
1823
  i,
1664
- this.effects.zoom.scale,
1824
+ effectiveScale,
1665
1825
  transitionFrames
1666
1826
  );
1667
1827
  }
@@ -1777,6 +1937,10 @@ var CanvasRenderer = class {
1777
1937
  let nextToDispatch = 0;
1778
1938
  let nextToYield = 0;
1779
1939
  const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
1940
+ const effectiveScale = resolveZoomScale(
1941
+ this.effects.zoom.scale,
1942
+ this.effects.zoom.intensity
1943
+ );
1780
1944
  const computeContext = (i) => {
1781
1945
  const frame = frames[i];
1782
1946
  let zoomScale = 1;
@@ -1787,7 +1951,7 @@ var CanvasRenderer = class {
1787
1951
  frames.slice(lo, hi + 1),
1788
1952
  lo,
1789
1953
  i,
1790
- this.effects.zoom.scale,
1954
+ effectiveScale,
1791
1955
  transitionFrames
1792
1956
  );
1793
1957
  }
@@ -2588,15 +2752,32 @@ var StepActionSchema = z.discriminatedUnion("action", [
2588
2752
  WaitForFunctionActionSchema,
2589
2753
  WaitForResponseActionSchema
2590
2754
  ]);
2755
+ var ZoomIntensitySchema = z.enum([
2756
+ "subtle",
2757
+ "light",
2758
+ "moderate",
2759
+ "strong",
2760
+ "dramatic"
2761
+ ]);
2591
2762
  var AutoZoomConfigSchema = z.object({
2592
2763
  followCursor: z.boolean().default(true),
2593
- maxScale: z.number().min(1).max(5).default(2),
2764
+ /** @deprecated Use `intensity` on the parent zoom config instead. */
2765
+ maxScale: z.number().min(1).max(5).default(1.35),
2594
2766
  transitionDuration: z.number().default(400),
2595
2767
  padding: z.number().default(200)
2596
2768
  });
2597
2769
  var ZoomEffectSchema = z.object({
2598
2770
  enabled: z.boolean().default(true),
2599
- scale: z.number().min(1).max(5).default(1.8),
2771
+ /**
2772
+ * Numeric zoom scale (1.0 = no zoom). Overridden by `intensity` when set.
2773
+ * Default lowered from 1.8 → 1.35 to match "moderate" intensity.
2774
+ */
2775
+ scale: z.number().min(1).max(5).default(1.35),
2776
+ /**
2777
+ * Intensity preset — overrides `scale` when set.
2778
+ * Calibrated against Loom (light≈1.25x) and Camtasia (moderate≈1.35x).
2779
+ */
2780
+ intensity: ZoomIntensitySchema.optional(),
2600
2781
  duration: z.number().default(600),
2601
2782
  easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
2602
2783
  autoZoom: AutoZoomConfigSchema.default({})
@@ -2637,6 +2818,17 @@ var SpeedRampConfigSchema = z.object({
2637
2818
  });
2638
2819
  var KeystrokeConfigSchema = z.object({
2639
2820
  enabled: z.boolean().default(false),
2821
+ /**
2822
+ * Show regular typed text (alphabetic/numeric characters) in the HUD.
2823
+ *
2824
+ * Industry default is false — Screen Studio, KeyCastr, and ScreenFlow all
2825
+ * hide regular typing by default, showing only modifier+key shortcuts.
2826
+ * Typed content is already visible inside the focused input element, so
2827
+ * displaying it again in the HUD is redundant and creates overflow issues.
2828
+ *
2829
+ * Set to true to display a 2-line rolling HUD that follows the typed text.
2830
+ */
2831
+ showTyping: z.boolean().default(false),
2640
2832
  position: z.enum(["bottom-center", "bottom-left", "bottom-right"]).default("bottom-center"),
2641
2833
  fontSize: z.number().default(18),
2642
2834
  backgroundColor: z.string().default("rgba(0, 0, 0, 0.75)"),
@@ -2807,6 +2999,7 @@ export {
2807
2999
  ClipwiseRecorder,
2808
3000
  ConcurrentSession,
2809
3001
  StreamingSession,
3002
+ ZOOM_INTENSITY_SCALES,
2810
3003
  applyCrossfade,
2811
3004
  buildZoomClickLookup,
2812
3005
  calculateAdaptiveZoom,
@@ -2823,6 +3016,7 @@ export {
2823
3016
  renderCursorTrail,
2824
3017
  renderKeystrokeHud,
2825
3018
  renderWatermark,
3019
+ resolveZoomScale,
2826
3020
  savePngSequence,
2827
3021
  validateScenario
2828
3022
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Scriptable cinematic screen recorder for product demos — YAML in, polished MP4 out",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",