clipwise 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -106,6 +106,12 @@ var ClipwiseRecorder = class {
106
106
  keystrokeSessionId = 0;
107
107
  currentStepIndex = 0;
108
108
  isScrolling = false;
109
+ isWaitingPhase = false;
110
+ currentDisplaySpeed;
111
+ /** Tracks active infinite CSS animations (spinners/loaders). Count > 0 → loading state. */
112
+ activeLoaderAnimations = /* @__PURE__ */ new Set();
113
+ /** Whether auto-loader detection is active (derived from smartSpeed.enabled). */
114
+ loaderDetectionEnabled = false;
109
115
  cursorPosition = { x: 0, y: 0 };
110
116
  viewport = { width: 1280, height: 800 };
111
117
  deviceScaleFactor = 1;
@@ -133,6 +139,7 @@ var ClipwiseRecorder = class {
133
139
  };
134
140
  this.targetFps = scenario.output.fps;
135
141
  this.cursorSpeed = scenario.effects.cursor.speed;
142
+ this.loaderDetectionEnabled = scenario.effects.smartSpeed?.enabled ?? false;
136
143
  this.browser = await chromium.launch({ headless: true });
137
144
  this.context = await this.browser.newContext({
138
145
  viewport: this.viewport
@@ -145,6 +152,9 @@ var ClipwiseRecorder = class {
145
152
  this.keystrokeSessionId = 0;
146
153
  this.currentStepIndex = 0;
147
154
  this.isScrolling = false;
155
+ this.isWaitingPhase = false;
156
+ this.currentDisplaySpeed = void 0;
157
+ this.activeLoaderAnimations.clear();
148
158
  this.cursorPosition = { x: 0, y: 0 };
149
159
  this.isCapturing = false;
150
160
  this.firstContentTimestamp = 0;
@@ -168,13 +178,15 @@ var ClipwiseRecorder = class {
168
178
  const buffer = Buffer.from(event.data, "base64");
169
179
  this.dedupStats.received++;
170
180
  const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
171
- const isDuplicate = this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
181
+ const isInLoadingState = this.isWaitingPhase || this.loaderDetectionEnabled && this.activeLoaderAnimations.size > 0;
182
+ const isDuplicate = !isInLoadingState && this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
172
183
  if (isDuplicate) {
173
184
  this.dedupStats.skipped++;
174
185
  } else {
175
186
  this.lastFrameSignature = Buffer.from(signature);
176
187
  const captureTime = Date.now();
177
- const rawFrame = { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex, isScrolling: this.isScrolling };
188
+ const isLoading = this.isWaitingPhase || this.loaderDetectionEnabled && this.activeLoaderAnimations.size > 0;
189
+ const rawFrame = { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex, isScrolling: this.isScrolling, isWaitingPhase: isLoading, displaySpeed: this.currentDisplaySpeed };
178
190
  this.rawFrames.push(rawFrame);
179
191
  this.dedupStats.stored++;
180
192
  if (this.frameChannel && this.firstContentTimestamp > 0) {
@@ -191,6 +203,23 @@ var ClipwiseRecorder = class {
191
203
  });
192
204
  }
193
205
  );
206
+ if (this.loaderDetectionEnabled) {
207
+ this.cdpClient.on("Animation.animationStarted", (event) => {
208
+ const anim = event.animation;
209
+ const iterations = anim?.source?.iterations ?? 0;
210
+ const isInfinite = iterations === -1 || iterations > 100;
211
+ const animName = anim?.name || "";
212
+ const isLoaderPattern = /spin|rotate|pulse|bounce|loading|skeleton|shimmer/i.test(animName);
213
+ if (anim?.type === "CSSAnimation" && isInfinite && isLoaderPattern) {
214
+ this.activeLoaderAnimations.add(anim.id);
215
+ }
216
+ });
217
+ this.cdpClient.on("Animation.animationCanceled", (event) => {
218
+ this.activeLoaderAnimations.delete(event.id);
219
+ });
220
+ await this.cdpClient.send("Animation.enable").catch(() => {
221
+ });
222
+ }
194
223
  await this.cdpClient.send("Page.startScreencast", {
195
224
  format: "png",
196
225
  maxWidth: this.viewport.width * this.deviceScaleFactor,
@@ -385,7 +414,9 @@ var ClipwiseRecorder = class {
385
414
  deviceScaleFactor: this.deviceScaleFactor,
386
415
  stepIndex: raw.stepIndex,
387
416
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
388
- isScrolling: raw.isScrolling || void 0
417
+ isScrolling: raw.isScrolling || void 0,
418
+ isWaitingPhase: raw.isWaitingPhase || void 0,
419
+ displaySpeed: raw.displaySpeed
389
420
  };
390
421
  }
391
422
  /**
@@ -504,6 +535,7 @@ var ClipwiseRecorder = class {
504
535
  await this.page.click(action.selector);
505
536
  this.keystrokeSessionId++;
506
537
  const currentSessionId = this.keystrokeSessionId;
538
+ let lastClickRefresh = Date.now();
507
539
  let typeRepaintToggle = false;
508
540
  for (const char of action.text) {
509
541
  await this.page.keyboard.type(char);
@@ -515,7 +547,19 @@ var ClipwiseRecorder = class {
515
547
  sessionId: currentSessionId
516
548
  });
517
549
  await new Promise((resolve) => setTimeout(resolve, action.delay));
550
+ const now = Date.now();
551
+ if (now - lastClickRefresh >= 400) {
552
+ this.clickTimeline.push({
553
+ position: { ...inputTarget },
554
+ timestamp: now
555
+ });
556
+ lastClickRefresh = now;
557
+ }
518
558
  }
559
+ this.clickTimeline.push({
560
+ position: { ...inputTarget },
561
+ timestamp: Date.now()
562
+ });
519
563
  break;
520
564
  }
521
565
  case "scroll": {
@@ -610,6 +654,60 @@ var ClipwiseRecorder = class {
610
654
  }
611
655
  break;
612
656
  }
657
+ case "smartWait": {
658
+ this.isWaitingPhase = true;
659
+ this.currentDisplaySpeed = action.displaySpeed;
660
+ try {
661
+ let conditionPromise;
662
+ switch (action.until) {
663
+ case "networkIdle":
664
+ conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
665
+ break;
666
+ case "selector":
667
+ conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
668
+ break;
669
+ case "domStable":
670
+ conditionPromise = this.page.waitForFunction(
671
+ () => new Promise((resolve) => {
672
+ let timer;
673
+ const observer = new MutationObserver(() => {
674
+ clearTimeout(timer);
675
+ timer = setTimeout(() => {
676
+ observer.disconnect();
677
+ resolve(true);
678
+ }, 500);
679
+ });
680
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true });
681
+ timer = setTimeout(() => {
682
+ observer.disconnect();
683
+ resolve(true);
684
+ }, 500);
685
+ }),
686
+ void 0,
687
+ { timeout: action.timeout }
688
+ );
689
+ break;
690
+ default:
691
+ conditionPromise = Promise.resolve();
692
+ }
693
+ let waitDone = false;
694
+ const repaintLoop = (async () => {
695
+ let toggle = false;
696
+ while (!waitDone && this.isCapturing && this.page) {
697
+ await this.forceRepaint(toggle);
698
+ toggle = !toggle;
699
+ await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
700
+ }
701
+ })();
702
+ await conditionPromise;
703
+ waitDone = true;
704
+ await repaintLoop;
705
+ } finally {
706
+ this.isWaitingPhase = false;
707
+ this.currentDisplaySpeed = void 0;
708
+ }
709
+ break;
710
+ }
613
711
  }
614
712
  await this.waitWithRepaints(ACTION_GAP_MS);
615
713
  }
@@ -729,7 +827,9 @@ var ClipwiseRecorder = class {
729
827
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
730
828
  stepIndex: raw.stepIndex,
731
829
  // use per-frame step index captured at event time
732
- isScrolling: raw.isScrolling || void 0
830
+ isScrolling: raw.isScrolling || void 0,
831
+ isWaitingPhase: raw.isWaitingPhase || void 0,
832
+ displaySpeed: raw.displaySpeed
733
833
  };
734
834
  });
735
835
  }
@@ -1089,6 +1189,70 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
1089
1189
 
1090
1190
  // src/effects/cursor.ts
1091
1191
  import sharp2 from "sharp";
1192
+ function buildCursorOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
1193
+ if (!config.enabled) return null;
1194
+ const size = Math.round(config.size * dpr);
1195
+ const cursorSvg = buildCursorSvg(size, config.color);
1196
+ const tipOffsetX = Math.round(4 / 24 * size);
1197
+ const px = Math.round(position.x * dpr);
1198
+ const py = Math.round(position.y * dpr);
1199
+ const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
1200
+ const top = Math.max(0, Math.min(py, frameHeight - size));
1201
+ return { input: Buffer.from(cursorSvg), left, top };
1202
+ }
1203
+ function buildClickRippleOverlay(position, config, progress, frameWidth, frameHeight, dpr = 1) {
1204
+ if (!config.enabled || !config.clickEffect) return null;
1205
+ const radius = config.clickRadius * dpr;
1206
+ const clampedProgress = Math.max(0, Math.min(1, progress));
1207
+ const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
1208
+ const rippleSize = Math.ceil(radius * 2 + 4);
1209
+ const px = Math.round(position.x * dpr);
1210
+ const py = Math.round(position.y * dpr);
1211
+ const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
1212
+ const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
1213
+ return { input: Buffer.from(rippleSvg), left, top };
1214
+ }
1215
+ function buildHighlightOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
1216
+ if (!config.enabled || !config.highlight) return null;
1217
+ const r = config.highlightRadius * dpr;
1218
+ const size = Math.ceil(r * 2 + 4);
1219
+ const cx = size / 2;
1220
+ const cy = size / 2;
1221
+ const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
1222
+ <defs>
1223
+ <radialGradient id="glow">
1224
+ <stop offset="0%" stop-color="${config.highlightColor}" />
1225
+ <stop offset="70%" stop-color="${config.highlightColor}" />
1226
+ <stop offset="100%" stop-color="transparent" />
1227
+ </radialGradient>
1228
+ </defs>
1229
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
1230
+ </svg>`;
1231
+ const px = Math.round(position.x * dpr);
1232
+ const py = Math.round(position.y * dpr);
1233
+ const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
1234
+ const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
1235
+ return { input: Buffer.from(highlightSvg), left, top };
1236
+ }
1237
+ function buildTrailOverlay(positions, config, frameWidth, frameHeight, dpr = 1) {
1238
+ if (!config.enabled || !config.trail || positions.length < 2) return null;
1239
+ const segments = [];
1240
+ for (let i = 1; i < positions.length; i++) {
1241
+ const opacity = i / positions.length * 0.6;
1242
+ const strokeWidth = (1 + i / positions.length * 2) * dpr;
1243
+ const p1 = positions[i - 1];
1244
+ const p2 = positions[i];
1245
+ segments.push(
1246
+ `<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
1247
+ stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
1248
+ stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
1249
+ );
1250
+ }
1251
+ const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
1252
+ ${segments.join("\n ")}
1253
+ </svg>`;
1254
+ return { input: Buffer.from(trailSvg), left: 0, top: 0 };
1255
+ }
1092
1256
  function buildCursorSvg(size, color) {
1093
1257
  const s = size;
1094
1258
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
@@ -1108,31 +1272,6 @@ function buildClickRippleSvg(radius, color, progress) {
1108
1272
  fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
1109
1273
  </svg>`;
1110
1274
  }
1111
- async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1112
- if (!config.enabled) return frameBuffer;
1113
- const size = Math.round(config.size * dpr);
1114
- const cursorSvg = buildCursorSvg(size, config.color);
1115
- const cursorBuffer = Buffer.from(cursorSvg);
1116
- const tipOffsetX = Math.round(4 / 24 * size);
1117
- const px = Math.round(position.x * dpr);
1118
- const py = Math.round(position.y * dpr);
1119
- const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
1120
- const top = Math.max(0, Math.min(py, frameHeight - size));
1121
- return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
1122
- }
1123
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
1124
- if (!config.enabled || !config.clickEffect) return frameBuffer;
1125
- const radius = config.clickRadius * dpr;
1126
- const clampedProgress = Math.max(0, Math.min(1, progress));
1127
- const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
1128
- const rippleBuffer = Buffer.from(rippleSvg);
1129
- const rippleSize = Math.ceil(radius * 2 + 4);
1130
- const px = Math.round(position.x * dpr);
1131
- const py = Math.round(position.y * dpr);
1132
- const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
1133
- const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
1134
- return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
1135
- }
1136
1275
  async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1137
1276
  if (!config.enabled || !config.highlight) return frameBuffer;
1138
1277
  const r = config.highlightRadius * dpr;
@@ -1269,6 +1408,51 @@ function lerpZoom(current, target, factor) {
1269
1408
  function easeInOutCubic2(t) {
1270
1409
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1271
1410
  }
1411
+ function springEasing(t) {
1412
+ const omega = 6.5;
1413
+ const raw = 1 - (1 + omega * t) * Math.exp(-omega * t);
1414
+ const endVal = 1 - (1 + omega) * Math.exp(-omega);
1415
+ return Math.max(0, Math.min(1, raw / endVal));
1416
+ }
1417
+ function applyZoomEasing(t, easing = "cubic") {
1418
+ return easing === "spring" ? springEasing(t) : easeInOutCubic2(t);
1419
+ }
1420
+ function mergeClickZones(clickLookup, mergeGap) {
1421
+ if (clickLookup.length === 0) return [];
1422
+ const zones = [];
1423
+ let start = clickLookup[0];
1424
+ let end = clickLookup[0];
1425
+ for (let i = 1; i < clickLookup.length; i++) {
1426
+ if (clickLookup[i] - end <= mergeGap) {
1427
+ end = clickLookup[i];
1428
+ } else {
1429
+ zones.push({ start, end });
1430
+ start = clickLookup[i];
1431
+ end = clickLookup[i];
1432
+ }
1433
+ }
1434
+ zones.push({ start, end });
1435
+ return zones;
1436
+ }
1437
+ function calculateAdaptiveZoomFromZones(zones, currentIndex, maxScale, transitionFrames, easing = "spring") {
1438
+ if (maxScale <= 1 || zones.length === 0) return 1;
1439
+ let lo = 0;
1440
+ let hi = zones.length;
1441
+ while (lo < hi) {
1442
+ const mid = lo + hi >>> 1;
1443
+ if (zones[mid].end < currentIndex) lo = mid + 1;
1444
+ else hi = mid;
1445
+ }
1446
+ if (lo < zones.length && currentIndex >= zones[lo].start && currentIndex <= zones[lo].end) {
1447
+ return maxScale;
1448
+ }
1449
+ const distBefore = lo > 0 ? currentIndex - zones[lo - 1].end : Infinity;
1450
+ const distAfter = lo < zones.length ? zones[lo].start - currentIndex : Infinity;
1451
+ const minDistance = Math.min(distBefore, distAfter);
1452
+ if (minDistance > transitionFrames) return 1;
1453
+ const t = 1 - minDistance / transitionFrames;
1454
+ return 1 + (maxScale - 1) * applyZoomEasing(t, easing);
1455
+ }
1272
1456
 
1273
1457
  // src/effects/background.ts
1274
1458
  import sharp4 from "sharp";
@@ -1515,73 +1699,87 @@ async function composeFrame(frame, effects, output, context) {
1515
1699
  cursorTrail: context?.cursorTrail ?? []
1516
1700
  };
1517
1701
  const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
1518
- const withFrameOffset = (pos) => ({
1702
+ const sl = context?.staticLayers;
1703
+ const preZoomOverlays = [];
1704
+ const hasBrowserChrome = effects.deviceFrame.enabled && effects.deviceFrame.type === "browser" && sl?.browserChromePng;
1705
+ const extTop = hasBrowserChrome ? sl.browserChromeHeight : 0;
1706
+ const extWidth = width;
1707
+ const extHeight = height + extTop;
1708
+ if (hasBrowserChrome) {
1709
+ preZoomOverlays.push({ input: sl.browserChromePng, left: 0, top: 0 });
1710
+ }
1711
+ const withExtFrameOffset = (pos) => ({
1519
1712
  x: pos.x + frameOffset.left / Math.max(1, dpr),
1520
1713
  y: pos.y + frameOffset.top / Math.max(1, dpr)
1521
1714
  });
1522
- if (effects.deviceFrame.enabled) {
1523
- const sl2 = ctx.staticLayers;
1524
- if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
1525
- buffer = await sharp7(buffer).extend({
1526
- top: sl2.browserChromeHeight,
1527
- bottom: 0,
1528
- left: 0,
1529
- right: 0,
1530
- background: { r: 0, g: 0, b: 0, alpha: 0 }
1531
- }).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
1532
- } else {
1533
- buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1534
- }
1535
- const meta2 = await sharp7(buffer).metadata();
1536
- width = meta2.width ?? width;
1537
- height = meta2.height ?? height;
1538
- }
1539
1715
  if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1540
- buffer = await renderCursorHighlight(
1541
- buffer,
1542
- withFrameOffset(frame.cursorPosition),
1716
+ const overlay = buildHighlightOverlay(
1717
+ withExtFrameOffset(frame.cursorPosition),
1543
1718
  effects.cursor,
1544
- width,
1545
- height,
1719
+ extWidth,
1720
+ extHeight,
1546
1721
  dpr
1547
1722
  );
1723
+ if (overlay) preZoomOverlays.push(overlay);
1548
1724
  }
1549
1725
  if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1550
- buffer = await renderCursorTrail(
1551
- buffer,
1552
- ctx.cursorTrail.map(withFrameOffset),
1726
+ const overlay = buildTrailOverlay(
1727
+ ctx.cursorTrail.map(withExtFrameOffset),
1553
1728
  effects.cursor,
1554
- width,
1555
- height,
1729
+ extWidth,
1730
+ extHeight,
1556
1731
  dpr
1557
1732
  );
1733
+ if (overlay) preZoomOverlays.push(overlay);
1558
1734
  }
1559
1735
  if (effects.cursor.enabled && frame.cursorPosition) {
1560
- buffer = await renderCursor(
1561
- buffer,
1562
- withFrameOffset(frame.cursorPosition),
1736
+ const overlay = buildCursorOverlay(
1737
+ withExtFrameOffset(frame.cursorPosition),
1563
1738
  effects.cursor,
1564
- width,
1565
- height,
1739
+ extWidth,
1740
+ extHeight,
1566
1741
  dpr
1567
1742
  );
1743
+ if (overlay) preZoomOverlays.push(overlay);
1568
1744
  }
1569
1745
  if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
1570
1746
  const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1571
- buffer = await renderClickEffect(
1572
- buffer,
1573
- withFrameOffset(frame.clickPosition),
1747
+ const overlay = buildClickRippleOverlay(
1748
+ withExtFrameOffset(frame.clickPosition),
1574
1749
  effects.cursor,
1575
1750
  progress,
1576
- width,
1577
- height,
1751
+ extWidth,
1752
+ extHeight,
1578
1753
  dpr
1579
1754
  );
1755
+ if (overlay) preZoomOverlays.push(overlay);
1756
+ }
1757
+ if (hasBrowserChrome || preZoomOverlays.length > 0) {
1758
+ let pipeline = sharp7(buffer);
1759
+ if (hasBrowserChrome) {
1760
+ pipeline = pipeline.extend({
1761
+ top: extTop,
1762
+ bottom: 0,
1763
+ left: 0,
1764
+ right: 0,
1765
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
1766
+ });
1767
+ }
1768
+ if (preZoomOverlays.length > 0) {
1769
+ pipeline = pipeline.composite(preZoomOverlays);
1770
+ }
1771
+ buffer = await pipeline.png().toBuffer();
1772
+ width = extWidth;
1773
+ height = extHeight;
1774
+ } else if (effects.deviceFrame.enabled) {
1775
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1776
+ const devMeta = await sharp7(buffer).metadata();
1777
+ width = devMeta.width ?? width;
1778
+ height = devMeta.height ?? height;
1580
1779
  }
1581
1780
  const scale = ctx.zoomScale;
1582
1781
  if (effects.zoom.enabled && scale > 1) {
1583
- const followCursor = effects.zoom.autoZoom.followCursor;
1584
- const rawFocus = followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1782
+ const rawFocus = ctx.focusOverride ?? (effects.zoom.autoZoom.followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 });
1585
1783
  const offset = getFrameOffset(effects.deviceFrame, dpr);
1586
1784
  const focusPoint = {
1587
1785
  x: rawFocus.x * dpr + offset.left,
@@ -1600,7 +1798,6 @@ async function composeFrame(frame, effects, output, context) {
1600
1798
  dpr
1601
1799
  );
1602
1800
  }
1603
- const sl = ctx.staticLayers;
1604
1801
  if (sl) {
1605
1802
  const padding = effects.background.padding;
1606
1803
  const contentWidth = output.width - padding * 2;
@@ -1809,6 +2006,7 @@ function mergeStepEffects(global, stepIndex, steps) {
1809
2006
  background: override.background ? { ...global.background, ...override.background } : global.background,
1810
2007
  deviceFrame: override.deviceFrame ? { ...global.deviceFrame, ...override.deviceFrame } : global.deviceFrame,
1811
2008
  speedRamp: override.speedRamp ? { ...global.speedRamp, ...override.speedRamp } : global.speedRamp,
2009
+ smartSpeed: override.smartSpeed ? { ...global.smartSpeed, ...override.smartSpeed } : global.smartSpeed,
1812
2010
  keystroke: override.keystroke ? { ...global.keystroke, ...override.keystroke } : global.keystroke,
1813
2011
  watermark: override.watermark ? { ...global.watermark, ...override.watermark } : global.watermark
1814
2012
  };
@@ -1862,7 +2060,10 @@ var CanvasRenderer = class {
1862
2060
  if (frames.length === 0) return [];
1863
2061
  let processFrames = frames;
1864
2062
  if (this.effects.speedRamp.enabled) {
1865
- processFrames = this.applySpeedRamp(frames);
2063
+ processFrames = this.applySpeedRamp(processFrames);
2064
+ }
2065
+ if (this.effects.smartSpeed.enabled) {
2066
+ processFrames = this.applySmartSpeed(processFrames);
1866
2067
  }
1867
2068
  const contexts = this.calculateFrameContexts(processFrames);
1868
2069
  const cpuCount = os.cpus().length;
@@ -1958,11 +2159,27 @@ var CanvasRenderer = class {
1958
2159
  this.effects.zoom.scale,
1959
2160
  this.effects.zoom.intensity
1960
2161
  );
2162
+ const mergeGap = Math.round(transitionFrames * 1.5);
2163
+ const zoomZones = this.effects.zoom.enabled ? mergeClickZones(clickLookup, mergeGap) : [];
2164
+ const zoomEasing = this.effects.zoom.easing === "spring" ? "spring" : "cubic";
2165
+ const clickPositions = /* @__PURE__ */ new Map();
2166
+ if (this.effects.zoom.enabled) {
2167
+ for (const ci of clickLookup) {
2168
+ const pos = frames[ci].clickPosition;
2169
+ if (pos) clickPositions.set(ci, pos);
2170
+ }
2171
+ }
1961
2172
  for (let i = 0; i < frames.length; i++) {
1962
2173
  const frame = frames[i];
1963
2174
  let zoomScale = 1;
1964
2175
  if (this.effects.zoom.enabled) {
1965
- zoomScale = calculateAdaptiveZoomFromLookup(
2176
+ zoomScale = zoomZones.length > 0 ? calculateAdaptiveZoomFromZones(
2177
+ zoomZones,
2178
+ i,
2179
+ effectiveScale,
2180
+ transitionFrames,
2181
+ zoomEasing
2182
+ ) : calculateAdaptiveZoomFromLookup(
1966
2183
  clickLookup,
1967
2184
  i,
1968
2185
  effectiveScale,
@@ -1980,10 +2197,56 @@ var CanvasRenderer = class {
1980
2197
  trail.push(frames[j].cursorPosition);
1981
2198
  }
1982
2199
  }
1983
- contexts.push({ zoomScale, clickProgress, cursorTrail: trail });
2200
+ let focusOverride;
2201
+ if (zoomScale > 1 && zoomZones.length > 0 && clickLookup.length > 1) {
2202
+ focusOverride = this.interpolateFocusInZone(
2203
+ i,
2204
+ clickLookup,
2205
+ clickPositions,
2206
+ frames
2207
+ );
2208
+ }
2209
+ contexts.push({ zoomScale, clickProgress, cursorTrail: trail, focusOverride });
1984
2210
  }
1985
2211
  return contexts;
1986
2212
  }
2213
+ /**
2214
+ * Interpolate the zoom focus point between adjacent clicks within a zone.
2215
+ *
2216
+ * Without this, the camera jumps instantly from one click position to the
2217
+ * next when a merged zone contains multiple clicks. This method produces
2218
+ * a smooth pan by linearly interpolating between the previous and next
2219
+ * click positions relative to the current frame index.
2220
+ *
2221
+ * Falls back to the nearest click position when the frame is before the
2222
+ * first click or after the last click in the lookup.
2223
+ */
2224
+ interpolateFocusInZone(frameIndex, clickLookup, clickPositions, frames) {
2225
+ let lo = 0;
2226
+ let hi = clickLookup.length;
2227
+ while (lo < hi) {
2228
+ const mid = lo + hi >>> 1;
2229
+ if (clickLookup[mid] < frameIndex) lo = mid + 1;
2230
+ else hi = mid;
2231
+ }
2232
+ const prevIdx = lo > 0 ? clickLookup[lo - 1] : -1;
2233
+ const nextIdx = lo < clickLookup.length ? clickLookup[lo] : -1;
2234
+ const prevPos = prevIdx >= 0 ? clickPositions.get(prevIdx) : void 0;
2235
+ const nextPos = nextIdx >= 0 ? clickPositions.get(nextIdx) : void 0;
2236
+ if (nextIdx === frameIndex && nextPos) return nextPos;
2237
+ if (prevPos && nextPos && prevIdx < frameIndex && nextIdx > frameIndex) {
2238
+ const span = nextIdx - prevIdx;
2239
+ if (span <= 0) return prevPos;
2240
+ const t = (frameIndex - prevIdx) / span;
2241
+ return {
2242
+ x: prevPos.x + (nextPos.x - prevPos.x) * t,
2243
+ y: prevPos.y + (nextPos.y - prevPos.y) * t
2244
+ };
2245
+ }
2246
+ if (prevPos) return prevPos;
2247
+ if (nextPos) return nextPos;
2248
+ return void 0;
2249
+ }
1987
2250
  /**
1988
2251
  * Apply speed ramping: slow down near actions, speed up during idle.
1989
2252
  */
@@ -2016,6 +2279,69 @@ var CanvasRenderer = class {
2016
2279
  }
2017
2280
  return result;
2018
2281
  }
2282
+ /**
2283
+ * Apply smart speed: compress smartWait periods based on per-frame metadata.
2284
+ *
2285
+ * Unlike applySpeedRamp (which uses click proximity heuristics), smartSpeed
2286
+ * reads the `isWaitingPhase` flag set by the recorder during smartWait actions.
2287
+ * Frames in a waiting phase are downsampled by their `displaySpeed` multiplier.
2288
+ *
2289
+ * Streaming-compatible: each frame is independently decidable (no lookahead
2290
+ * needed), so this can run inline during streaming composition.
2291
+ */
2292
+ applySmartSpeed(frames) {
2293
+ const config = this.effects.smartSpeed;
2294
+ if (!config.enabled) return frames;
2295
+ const transitionMargin = Math.round(
2296
+ this.output.fps * (config.transitionDuration / 1e3)
2297
+ );
2298
+ const segments = [];
2299
+ let segStart = -1;
2300
+ let segSpeed = config.waitSpeed;
2301
+ for (let i = 0; i < frames.length; i++) {
2302
+ if (frames[i].isWaitingPhase) {
2303
+ if (segStart < 0) {
2304
+ segStart = i;
2305
+ segSpeed = frames[i].displaySpeed ?? config.waitSpeed;
2306
+ }
2307
+ } else if (segStart >= 0) {
2308
+ segments.push({ start: segStart, end: i - 1, speed: segSpeed });
2309
+ segStart = -1;
2310
+ }
2311
+ }
2312
+ if (segStart >= 0) segments.push({ start: segStart, end: frames.length - 1, speed: segSpeed });
2313
+ const skipRates = new Array(frames.length).fill(1);
2314
+ for (const seg of segments) {
2315
+ const segLen = seg.end - seg.start + 1;
2316
+ const skipRate = Math.max(1, Math.round(seg.speed));
2317
+ for (let i = seg.start; i <= seg.end; i++) {
2318
+ const fromStart = i - seg.start;
2319
+ const fromEnd = seg.end - i;
2320
+ if (fromStart < transitionMargin || fromEnd < transitionMargin) {
2321
+ skipRates[i] = 1;
2322
+ } else if (segLen < transitionMargin * 3) {
2323
+ skipRates[i] = Math.max(1, Math.round(skipRate / 2));
2324
+ } else {
2325
+ skipRates[i] = skipRate;
2326
+ }
2327
+ }
2328
+ }
2329
+ const result = [];
2330
+ let skipCounter = 0;
2331
+ for (let i = 0; i < frames.length; i++) {
2332
+ const rate = skipRates[i];
2333
+ if (rate <= 1) {
2334
+ skipCounter = 0;
2335
+ result.push({ ...frames[i], index: result.length });
2336
+ } else {
2337
+ skipCounter++;
2338
+ if (skipCounter % rate === 1) {
2339
+ result.push({ ...frames[i], index: result.length });
2340
+ }
2341
+ }
2342
+ }
2343
+ return result;
2344
+ }
2019
2345
  // ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
2020
2346
  /**
2021
2347
  * Returns true when no effect requires the full frame array upfront.
@@ -2045,19 +2371,56 @@ var CanvasRenderer = class {
2045
2371
  * using the same applyTransitionsToStream() logic as composeStream().
2046
2372
  */
2047
2373
  async *composeStreamOnline(source) {
2374
+ const filteredSource = this.effects.smartSpeed.enabled ? this.filterSmartSpeedInline(source) : source;
2048
2375
  const hasFadeTransitions = this.steps.some((s) => s.transition !== "none");
2049
2376
  if (!hasFadeTransitions) {
2050
2377
  const cpuCount = os.cpus().length;
2051
2378
  const workerCount = Math.min(cpuCount, 8);
2052
- yield* this.streamOnlineWithWorkers(source, workerCount);
2379
+ yield* this.streamOnlineWithWorkers(filteredSource, workerCount);
2053
2380
  return;
2054
2381
  }
2055
2382
  const collected = [];
2056
- for await (const frame of source) {
2383
+ for await (const frame of filteredSource) {
2057
2384
  collected.push(frame);
2058
2385
  }
2059
2386
  yield* this.composeStream(collected);
2060
2387
  }
2388
+ /**
2389
+ * Inline async filter for smartSpeed in streaming pipelines.
2390
+ *
2391
+ * Applies ease-in at the start of a waiting phase: the first
2392
+ * `transitionMargin` frames are kept at normal speed so the loader
2393
+ * is visible, then frames are skipped at displaySpeed rate.
2394
+ * When waiting ends, frames immediately return to normal speed.
2395
+ */
2396
+ async *filterSmartSpeedInline(source) {
2397
+ const config = this.effects.smartSpeed;
2398
+ const transitionMargin = Math.round(
2399
+ this.output.fps * (config.transitionDuration / 1e3)
2400
+ );
2401
+ let waitFrameCounter = 0;
2402
+ let skipCounter = 0;
2403
+ let outputIndex = 0;
2404
+ for await (const frame of source) {
2405
+ if (frame.isWaitingPhase) {
2406
+ waitFrameCounter++;
2407
+ if (waitFrameCounter <= transitionMargin) {
2408
+ yield { ...frame, index: outputIndex++ };
2409
+ } else {
2410
+ const speed = frame.displaySpeed ?? config.waitSpeed;
2411
+ const skipRate = Math.max(1, Math.round(speed));
2412
+ skipCounter++;
2413
+ if (skipCounter % skipRate === 1) {
2414
+ yield { ...frame, index: outputIndex++ };
2415
+ }
2416
+ }
2417
+ } else {
2418
+ waitFrameCounter = 0;
2419
+ skipCounter = 0;
2420
+ yield { ...frame, index: outputIndex++ };
2421
+ }
2422
+ }
2423
+ }
2061
2424
  /**
2062
2425
  * Worker-pool online streaming: dispatches frame i to a worker as soon as
2063
2426
  * frame i + transitionFrames has arrived from the source.
@@ -2202,7 +2565,10 @@ var CanvasRenderer = class {
2202
2565
  if (frames.length === 0) return;
2203
2566
  let processFrames = frames;
2204
2567
  if (this.effects.speedRamp.enabled) {
2205
- processFrames = this.applySpeedRamp(frames);
2568
+ processFrames = this.applySpeedRamp(processFrames);
2569
+ }
2570
+ if (this.effects.smartSpeed.enabled) {
2571
+ processFrames = this.applySmartSpeed(processFrames);
2206
2572
  }
2207
2573
  const contexts = this.calculateFrameContexts(processFrames);
2208
2574
  const windows = this.getTransitionWindows(processFrames);
@@ -2445,9 +2811,9 @@ import { tmpdir } from "os";
2445
2811
  import { spawn } from "child_process";
2446
2812
  var { GIFEncoder, quantize, applyPalette } = gifenc;
2447
2813
  var ENCODING_PRESETS = {
2448
- social: { crf: 22, vtQuality: 75 },
2449
- balanced: { crf: 18, vtQuality: 85 },
2450
- archive: { crf: 13, vtQuality: 92 }
2814
+ social: { crf: 22, vtQuality: 75, x264Preset: "medium" },
2815
+ balanced: { crf: 18, vtQuality: 85, x264Preset: "slow" },
2816
+ archive: { crf: 13, vtQuality: 92, x264Preset: "veryslow" }
2451
2817
  };
2452
2818
  function resolveEncodingParams(config) {
2453
2819
  if (config.preset) return ENCODING_PRESETS[config.preset];
@@ -2459,24 +2825,106 @@ function resolveEncodingParams(config) {
2459
2825
  if (config.quality >= 45) return ENCODING_PRESETS.balanced;
2460
2826
  return ENCODING_PRESETS.archive;
2461
2827
  }
2462
- var encoderDetectionPromise = null;
2463
- function detectVideoEncoder() {
2464
- if (!encoderDetectionPromise) {
2465
- encoderDetectionPromise = new Promise((resolve) => {
2828
+ var encoderScanPromise = null;
2829
+ function scanAvailableEncoders() {
2830
+ if (!encoderScanPromise) {
2831
+ encoderScanPromise = new Promise((resolve) => {
2466
2832
  const proc = spawn("ffmpeg", ["-encoders"], {
2467
2833
  stdio: ["ignore", "pipe", "ignore"]
2468
2834
  });
2469
2835
  let out = "";
2470
2836
  proc.stdout.on("data", (d) => out += d.toString());
2471
2837
  proc.on("close", () => {
2472
- if (out.includes("hevc_videotoolbox")) resolve("hevc_videotoolbox");
2473
- else if (out.includes("h264_videotoolbox")) resolve("h264_videotoolbox");
2474
- else resolve("libx264");
2838
+ resolve({
2839
+ hevcHw: out.includes("hevc_videotoolbox"),
2840
+ h264Hw: out.includes("h264_videotoolbox"),
2841
+ av1: out.includes("libsvtav1")
2842
+ });
2475
2843
  });
2476
- proc.on("error", () => resolve("libx264"));
2844
+ proc.on("error", () => resolve({ hevcHw: false, h264Hw: false, av1: false }));
2477
2845
  });
2478
2846
  }
2479
- return encoderDetectionPromise;
2847
+ return encoderScanPromise;
2848
+ }
2849
+ async function detectVideoEncoder(codec = "auto") {
2850
+ const avail = await scanAvailableEncoders();
2851
+ switch (codec) {
2852
+ case "av1":
2853
+ return avail.av1 ? "libsvtav1" : "libx264";
2854
+ case "hevc":
2855
+ return avail.hevcHw ? "hevc_videotoolbox" : "libx264";
2856
+ case "h264":
2857
+ return avail.h264Hw ? "h264_videotoolbox" : "libx264";
2858
+ case "auto":
2859
+ default:
2860
+ if (avail.hevcHw) return "hevc_videotoolbox";
2861
+ if (avail.h264Hw) return "h264_videotoolbox";
2862
+ return "libx264";
2863
+ }
2864
+ }
2865
+ function buildVideoArgs(encoder, params) {
2866
+ switch (encoder) {
2867
+ case "hevc_videotoolbox":
2868
+ return [
2869
+ "-c:v",
2870
+ "hevc_videotoolbox",
2871
+ "-q:v",
2872
+ String(params.vtQuality),
2873
+ "-pix_fmt",
2874
+ "p010le",
2875
+ "-tag:v",
2876
+ "hvc1",
2877
+ "-color_primaries",
2878
+ "bt709",
2879
+ "-color_trc",
2880
+ "bt709",
2881
+ "-colorspace",
2882
+ "bt709"
2883
+ ];
2884
+ case "h264_videotoolbox":
2885
+ return [
2886
+ "-c:v",
2887
+ "h264_videotoolbox",
2888
+ "-q:v",
2889
+ String(params.vtQuality),
2890
+ "-pix_fmt",
2891
+ "yuv420p"
2892
+ ];
2893
+ case "libsvtav1":
2894
+ return [
2895
+ "-c:v",
2896
+ "libsvtav1",
2897
+ "-crf",
2898
+ String(params.crf + 12),
2899
+ // AV1 CRF scale differs: +12 ≈ equivalent quality
2900
+ "-preset",
2901
+ "6",
2902
+ // 6 = good speed/quality balance
2903
+ "-svtav1-params",
2904
+ "scm=2",
2905
+ // Screen Content Mode: optimized for UI/text
2906
+ "-pix_fmt",
2907
+ "yuv420p10le"
2908
+ ];
2909
+ case "libx264":
2910
+ default:
2911
+ return [
2912
+ "-c:v",
2913
+ "libx264",
2914
+ "-crf",
2915
+ String(params.crf),
2916
+ "-preset",
2917
+ params.x264Preset,
2918
+ "-tune",
2919
+ "animation",
2920
+ "-profile:v",
2921
+ "high",
2922
+ "-level",
2923
+ "4.1",
2924
+ "-pix_fmt",
2925
+ "yuv420p"
2926
+ ];
2927
+ }
2480
2928
  }
2481
2929
  async function encodeGif(frames, config) {
2482
2930
  if (frames.length === 0) {
@@ -2503,7 +2951,7 @@ async function encodeMp4(frames, config, audio) {
2503
2951
  }
2504
2952
  const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
2505
2953
  try {
2506
- const encoder = await detectVideoEncoder();
2954
+ const encoder = await detectVideoEncoder(config.codec);
2507
2955
  const params = resolveEncodingParams(config);
2508
2956
  await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
2509
2957
  return await readFile(outputPath);
@@ -2513,39 +2961,7 @@ async function encodeMp4(frames, config, audio) {
2513
2961
  }
2514
2962
  }
2515
2963
  async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio) {
2516
- const videoArgs = encoder === "hevc_videotoolbox" ? [
2517
- "-c:v",
2518
- "hevc_videotoolbox",
2519
- "-q:v",
2520
- String(params.vtQuality),
2521
- "-pix_fmt",
2522
- "yuv420p",
2523
- "-tag:v",
2524
- "hvc1"
2525
- // required for playback in QuickTime / Apple devices
2526
- ] : encoder === "h264_videotoolbox" ? [
2527
- "-c:v",
2528
- "h264_videotoolbox",
2529
- "-q:v",
2530
- String(params.vtQuality),
2531
- "-pix_fmt",
2532
- "yuv420p"
2533
- ] : [
2534
- "-c:v",
2535
- "libx264",
2536
- "-crf",
2537
- String(params.crf),
2538
- "-preset",
2539
- "medium",
2540
- "-tune",
2541
- "stillimage",
2542
- "-profile:v",
2543
- "high",
2544
- "-level",
2545
- "4.1",
2546
- "-pix_fmt",
2547
- "yuv420p"
2548
- ];
2964
+ const videoArgs = buildVideoArgs(encoder, params);
2549
2965
  const audioInputArgs = audio ? ["-i", audio.file] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"];
2550
2966
  const audioFilters = [];
2551
2967
  if (audio) {
@@ -2625,7 +3041,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
2625
3041
  async function encodeMp4Stream(frames, config, audio) {
2626
3042
  const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
2627
3043
  try {
2628
- const encoder = await detectVideoEncoder();
3044
+ const encoder = await detectVideoEncoder(config.codec);
2629
3045
  const params = resolveEncodingParams(config);
2630
3046
  await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
2631
3047
  return await readFile(outputPath);
@@ -2641,9 +3057,15 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
2641
3057
  "-q:v",
2642
3058
  String(params.vtQuality),
2643
3059
  "-pix_fmt",
2644
- "yuv420p",
3060
+ "p010le",
2645
3061
  "-tag:v",
2646
- "hvc1"
3062
+ "hvc1",
3063
+ "-color_primaries",
3064
+ "bt709",
3065
+ "-color_trc",
3066
+ "bt709",
3067
+ "-colorspace",
3068
+ "bt709"
2647
3069
  ] : encoder === "h264_videotoolbox" ? [
2648
3070
  "-c:v",
2649
3071
  "h264_videotoolbox",
@@ -2657,9 +3079,9 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
2657
3079
  "-crf",
2658
3080
  String(params.crf),
2659
3081
  "-preset",
2660
- "medium",
3082
+ params.x264Preset,
2661
3083
  "-tune",
2662
- "stillimage",
3084
+ "animation",
2663
3085
  "-profile:v",
2664
3086
  "high",
2665
3087
  "-level",
@@ -2910,6 +3332,17 @@ var WaitForResponseActionSchema = z.object({
2910
3332
  status: z.number().min(100).max(599).optional(),
2911
3333
  timeout: z.number().min(0).default(3e4)
2912
3334
  });
3335
+ var SmartWaitActionSchema = z.object({
3336
+ action: z.literal("smartWait"),
3337
+ /** Condition to wait for */
3338
+ until: z.enum(["networkIdle", "selector", "domStable"]).default("networkIdle"),
3339
+ /** CSS selector (required when until="selector") */
3340
+ selector: SafeSelectorSchema.optional(),
3341
+ /** Maximum wait in ms */
3342
+ timeout: z.number().min(0).default(3e4),
3343
+ /** Speed multiplier for the wait period in the output video (default: 8×) */
3344
+ displaySpeed: z.number().min(1).max(32).default(8)
3345
+ });
2913
3346
  var StepActionSchema = z.discriminatedUnion("action", [
2914
3347
  NavigateActionSchema,
2915
3348
  ClickActionSchema,
@@ -2922,7 +3355,8 @@ var StepActionSchema = z.discriminatedUnion("action", [
2922
3355
  WaitForNavigationActionSchema,
2923
3356
  WaitForURLActionSchema,
2924
3357
  WaitForFunctionActionSchema,
2925
- WaitForResponseActionSchema
3358
+ WaitForResponseActionSchema,
3359
+ SmartWaitActionSchema
2926
3360
  ]);
2927
3361
  var ZoomIntensitySchema = z.enum([
2928
3362
  "subtle",
@@ -2952,7 +3386,7 @@ var ZoomEffectSchema = z.object({
2952
3386
  */
2953
3387
  intensity: ZoomIntensitySchema.default("light"),
2954
3388
  duration: z.number().default(800),
2955
- easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
3389
+ easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear", "spring"]).default("ease-in-out"),
2956
3390
  autoZoom: AutoZoomConfigSchema.default({})
2957
3391
  });
2958
3392
  var CursorEffectSchema = z.object({
@@ -2989,6 +3423,17 @@ var SpeedRampConfigSchema = z.object({
2989
3423
  actionSpeed: z.number().min(0.25).max(2).default(0.8),
2990
3424
  transitionFrames: z.number().default(15)
2991
3425
  });
3426
+ var SmartSpeedConfigSchema = z.object({
3427
+ enabled: z.boolean().default(false),
3428
+ /** Speed multiplier for frames during smartWait (overridden by per-action displaySpeed) */
3429
+ waitSpeed: z.number().min(1).max(32).default(8),
3430
+ /** Speed multiplier for idle frames (no DOM/network changes) */
3431
+ idleSpeed: z.number().min(1).max(16).default(4),
3432
+ /** Duration (ms) to ease between speed changes (prevents jarring jumps) */
3433
+ transitionDuration: z.number().default(300),
3434
+ /** Minimum segment duration (ms) — don't speed up very short segments */
3435
+ minSegmentDuration: z.number().default(500)
3436
+ });
2992
3437
  var KeystrokeConfigSchema = z.object({
2993
3438
  enabled: z.boolean().default(false),
2994
3439
  /**
@@ -3023,6 +3468,7 @@ var EffectsConfigSchema = z.object({
3023
3468
  background: BackgroundSchema.default({}),
3024
3469
  deviceFrame: DeviceFrameSchema.default({}),
3025
3470
  speedRamp: SpeedRampConfigSchema.default({}),
3471
+ smartSpeed: SmartSpeedConfigSchema.default({}),
3026
3472
  keystroke: KeystrokeConfigSchema.default({}),
3027
3473
  watermark: WatermarkConfigSchema.default({})
3028
3474
  });
@@ -3038,6 +3484,8 @@ var OutputConfigSchema = z.object({
3038
3484
  // balanced — general-purpose, good quality/size trade-off (CRF 20)
3039
3485
  // archive — high-fidelity storage, larger file (CRF 15)
3040
3486
  preset: z.enum(["social", "balanced", "archive"]).optional(),
3487
+ /** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
3488
+ codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
3041
3489
  outputDir: z.string().default("./output"),
3042
3490
  filename: z.string().default("clipwise-recording")
3043
3491
  });
@@ -3047,6 +3495,7 @@ var StepEffectsOverrideSchema = z.object({
3047
3495
  background: BackgroundSchema.partial().optional(),
3048
3496
  deviceFrame: DeviceFrameSchema.partial().optional(),
3049
3497
  speedRamp: SpeedRampConfigSchema.partial().optional(),
3498
+ smartSpeed: SmartSpeedConfigSchema.partial().optional(),
3050
3499
  keystroke: KeystrokeConfigSchema.partial().optional(),
3051
3500
  watermark: WatermarkConfigSchema.partial().optional()
3052
3501
  }).optional();
@@ -3208,9 +3657,11 @@ export {
3208
3657
  applyCrossfade,
3209
3658
  applySlide,
3210
3659
  applyTransition,
3660
+ applyZoomEasing,
3211
3661
  buildZoomClickLookup,
3212
3662
  calculateAdaptiveZoom,
3213
3663
  calculateAdaptiveZoomFromLookup,
3664
+ calculateAdaptiveZoomFromZones,
3214
3665
  calculateAdaptiveZoomInWindow,
3215
3666
  calculatePanOffset,
3216
3667
  encodeGif,
@@ -3218,6 +3669,7 @@ export {
3218
3669
  encodeMp4Stream,
3219
3670
  lerpZoom,
3220
3671
  loadScenario,
3672
+ mergeClickZones,
3221
3673
  parseScenario,
3222
3674
  renderCursorHighlight,
3223
3675
  renderCursorTrail,
@@ -3225,5 +3677,6 @@ export {
3225
3677
  renderWatermark,
3226
3678
  resolveZoomScale,
3227
3679
  savePngSequence,
3680
+ springEasing,
3228
3681
  validateScenario
3229
3682
  };