clipwise 0.5.2 → 0.6.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
@@ -105,12 +105,13 @@ var ClipwiseRecorder = class {
105
105
  * each input field's text on a separate line. */
106
106
  keystrokeSessionId = 0;
107
107
  currentStepIndex = 0;
108
+ isScrolling = false;
108
109
  cursorPosition = { x: 0, y: 0 };
109
110
  viewport = { width: 1280, height: 800 };
110
111
  deviceScaleFactor = 1;
111
112
  isCapturing = false;
112
113
  targetFps = 30;
113
- cursorSpeed = "fast";
114
+ cursorSpeed = "normal";
114
115
  firstContentTimestamp = 0;
115
116
  pendingResponsePromises = /* @__PURE__ */ new Map();
116
117
  // ── 중복 프레임 제거 (Phase 1-A) ──────────────────────────────────────────
@@ -143,6 +144,7 @@ var ClipwiseRecorder = class {
143
144
  this.keystrokeTimeline = [];
144
145
  this.keystrokeSessionId = 0;
145
146
  this.currentStepIndex = 0;
147
+ this.isScrolling = false;
146
148
  this.cursorPosition = { x: 0, y: 0 };
147
149
  this.isCapturing = false;
148
150
  this.firstContentTimestamp = 0;
@@ -172,11 +174,12 @@ var ClipwiseRecorder = class {
172
174
  } else {
173
175
  this.lastFrameSignature = Buffer.from(signature);
174
176
  const captureTime = Date.now();
175
- this.rawFrames.push({ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex });
177
+ const rawFrame = { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex, isScrolling: this.isScrolling };
178
+ this.rawFrames.push(rawFrame);
176
179
  this.dedupStats.stored++;
177
180
  if (this.frameChannel && this.firstContentTimestamp > 0) {
178
181
  const frame = this.buildFrameOnline(
179
- { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex },
182
+ rawFrame,
180
183
  this.channelIndex++
181
184
  );
182
185
  this.frameChannel.push(frame);
@@ -381,7 +384,8 @@ var ClipwiseRecorder = class {
381
384
  viewport: { ...this.viewport },
382
385
  deviceScaleFactor: this.deviceScaleFactor,
383
386
  stepIndex: raw.stepIndex,
384
- keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
387
+ keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
388
+ isScrolling: raw.isScrolling || void 0
385
389
  };
386
390
  }
387
391
  /**
@@ -517,6 +521,7 @@ var ClipwiseRecorder = class {
517
521
  case "scroll": {
518
522
  const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector, action.timeout) : null;
519
523
  const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
524
+ this.isScrolling = true;
520
525
  if (action.smooth && scrollDistance > 0) {
521
526
  const scrollSteps = Math.max(12, Math.round(scrollDistance / 25));
522
527
  const yStep = action.y / scrollSteps;
@@ -556,6 +561,7 @@ var ClipwiseRecorder = class {
556
561
  timestamp: Date.now()
557
562
  });
558
563
  }
564
+ this.isScrolling = false;
559
565
  await this.waitWithRepaints(120);
560
566
  break;
561
567
  }
@@ -721,8 +727,9 @@ var ClipwiseRecorder = class {
721
727
  viewport: { ...this.viewport },
722
728
  deviceScaleFactor: this.deviceScaleFactor,
723
729
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
724
- stepIndex: raw.stepIndex
730
+ stepIndex: raw.stepIndex,
725
731
  // use per-frame step index captured at event time
732
+ isScrolling: raw.isScrolling || void 0
726
733
  };
727
734
  });
728
735
  }
@@ -1573,7 +1580,8 @@ async function composeFrame(frame, effects, output, context) {
1573
1580
  }
1574
1581
  const scale = ctx.zoomScale;
1575
1582
  if (effects.zoom.enabled && scale > 1) {
1576
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
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 };
1577
1585
  const offset = getFrameOffset(effects.deviceFrame, dpr);
1578
1586
  const focusPoint = {
1579
1587
  x: rawFocus.x * dpr + offset.left,
@@ -1641,37 +1649,170 @@ async function composeFrame(frame, effects, output, context) {
1641
1649
 
1642
1650
  // src/effects/transition.ts
1643
1651
  import sharp8 from "sharp";
1652
+ async function decodeToRaw(buf, rawInfo, targetWidth, targetHeight) {
1653
+ const src = rawInfo ? sharp8(buf, { raw: { width: rawInfo.width, height: rawInfo.height, channels: rawInfo.channels } }) : sharp8(buf);
1654
+ const pipeline = targetWidth && targetHeight ? src.resize(targetWidth, targetHeight, { fit: "fill" }).ensureAlpha().raw() : src.ensureAlpha().raw();
1655
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
1656
+ return { data: Buffer.from(data), width: info.width, height: info.height };
1657
+ }
1658
+ function returnRaw(buf, rawInfo, w, h) {
1659
+ if (rawInfo) return { buffer: buf, rawInfo };
1660
+ return null;
1661
+ }
1644
1662
  async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1645
1663
  const t = Math.max(0, Math.min(1, progress));
1646
1664
  if (t <= 0) {
1647
- const rawInfo = fromRawInfo ?? { width, height, channels: 4 };
1648
- if (fromRawInfo) return { buffer: fromBuffer, rawInfo };
1649
- const { data, info } = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1650
- return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
1665
+ const fast = returnRaw(fromBuffer, fromRawInfo, width, height);
1666
+ if (fast) return fast;
1667
+ const d = await decodeToRaw(fromBuffer, void 0);
1668
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1651
1669
  }
1652
1670
  if (t >= 1) {
1653
- const rawInfo = toRawInfo ?? { width, height, channels: 4 };
1654
- if (toRawInfo) return { buffer: toBuffer, rawInfo };
1655
- const { data, info } = await sharp8(toBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1656
- return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
1671
+ const fast = returnRaw(toBuffer, toRawInfo, width, height);
1672
+ if (fast) return fast;
1673
+ const d = await decodeToRaw(toBuffer, void 0);
1674
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1675
+ }
1676
+ const from = await decodeToRaw(fromBuffer, fromRawInfo);
1677
+ const to = await decodeToRaw(toBuffer, toRawInfo, from.width, from.height);
1678
+ const pixels = Buffer.alloc(from.data.length);
1679
+ for (let i = 0; i < from.data.length; i++) {
1680
+ pixels[i] = Math.round(from.data[i] * (1 - t) + to.data[i] * t);
1681
+ }
1682
+ return { buffer: pixels, rawInfo: { width: from.width, height: from.height, channels: 4 } };
1683
+ }
1684
+ async function applySlide(fromBuffer, toBuffer, progress, width, height, direction, fromRawInfo, toRawInfo) {
1685
+ const t = Math.max(0, Math.min(1, progress));
1686
+ if (t <= 0) {
1687
+ const fast = returnRaw(fromBuffer, fromRawInfo, width, height);
1688
+ if (fast) return fast;
1689
+ const d = await decodeToRaw(fromBuffer, void 0);
1690
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1691
+ }
1692
+ if (t >= 1) {
1693
+ const fast = returnRaw(toBuffer, toRawInfo, width, height);
1694
+ if (fast) return fast;
1695
+ const d = await decodeToRaw(toBuffer, void 0);
1696
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1697
+ }
1698
+ const from = await decodeToRaw(fromBuffer, fromRawInfo);
1699
+ const to = await decodeToRaw(toBuffer, toRawInfo, from.width, from.height);
1700
+ const w = from.width;
1701
+ const h = from.height;
1702
+ const pixels = Buffer.alloc(from.data.length);
1703
+ const eased = easeInOutCubic3(t);
1704
+ if (direction === "left") {
1705
+ const offsetX = Math.round(w * (1 - eased));
1706
+ for (let y = 0; y < h; y++) {
1707
+ for (let x = 0; x < w; x++) {
1708
+ const dstIdx = (y * w + x) * 4;
1709
+ const srcX = x + offsetX;
1710
+ if (srcX < w) {
1711
+ const srcIdx = (y * w + srcX) * 4;
1712
+ pixels[dstIdx] = to.data[srcIdx];
1713
+ pixels[dstIdx + 1] = to.data[srcIdx + 1];
1714
+ pixels[dstIdx + 2] = to.data[srcIdx + 2];
1715
+ pixels[dstIdx + 3] = to.data[srcIdx + 3];
1716
+ } else {
1717
+ const fromX = srcX - w;
1718
+ if (fromX < w) {
1719
+ const srcIdx = (y * w + fromX) * 4;
1720
+ pixels[dstIdx] = from.data[srcIdx];
1721
+ pixels[dstIdx + 1] = from.data[srcIdx + 1];
1722
+ pixels[dstIdx + 2] = from.data[srcIdx + 2];
1723
+ pixels[dstIdx + 3] = from.data[srcIdx + 3];
1724
+ }
1725
+ }
1726
+ }
1727
+ }
1728
+ } else {
1729
+ const offsetY = Math.round(h * (1 - eased));
1730
+ for (let y = 0; y < h; y++) {
1731
+ for (let x = 0; x < w; x++) {
1732
+ const dstIdx = (y * w + x) * 4;
1733
+ const srcY = y + offsetY;
1734
+ if (srcY < h) {
1735
+ const srcIdx = (srcY * w + x) * 4;
1736
+ pixels[dstIdx] = to.data[srcIdx];
1737
+ pixels[dstIdx + 1] = to.data[srcIdx + 1];
1738
+ pixels[dstIdx + 2] = to.data[srcIdx + 2];
1739
+ pixels[dstIdx + 3] = to.data[srcIdx + 3];
1740
+ } else {
1741
+ const fromY = srcY - h;
1742
+ if (fromY < h) {
1743
+ const srcIdx = (fromY * w + x) * 4;
1744
+ pixels[dstIdx] = from.data[srcIdx];
1745
+ pixels[dstIdx + 1] = from.data[srcIdx + 1];
1746
+ pixels[dstIdx + 2] = from.data[srcIdx + 2];
1747
+ pixels[dstIdx + 3] = from.data[srcIdx + 3];
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ }
1753
+ return { buffer: pixels, rawInfo: { width: w, height: h, channels: 4 } };
1754
+ }
1755
+ async function applyBlur(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1756
+ const t = Math.max(0, Math.min(1, progress));
1757
+ if (t <= 0) {
1758
+ const fast = returnRaw(fromBuffer, fromRawInfo, width, height);
1759
+ if (fast) return fast;
1760
+ const d = await decodeToRaw(fromBuffer, void 0);
1761
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1657
1762
  }
1763
+ if (t >= 1) {
1764
+ const fast = returnRaw(toBuffer, toRawInfo, width, height);
1765
+ if (fast) return fast;
1766
+ const d = await decodeToRaw(toBuffer, void 0);
1767
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1768
+ }
1769
+ const sigma = t * 20;
1658
1770
  const fromSrc = fromRawInfo ? sharp8(fromBuffer, { raw: { width: fromRawInfo.width, height: fromRawInfo.height, channels: fromRawInfo.channels } }) : sharp8(fromBuffer);
1659
- const fromRaw = await fromSrc.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1660
- const toSrc = toRawInfo ? sharp8(toBuffer, { raw: { width: toRawInfo.width, height: toRawInfo.height, channels: toRawInfo.channels } }) : sharp8(toBuffer);
1661
- const toRaw = await toSrc.resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1662
- const pixels = Buffer.alloc(fromRaw.data.length);
1663
- for (let i = 0; i < fromRaw.data.length; i++) {
1664
- pixels[i] = Math.round(
1665
- fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1666
- );
1771
+ const blurredFrom = await fromSrc.blur(Math.max(0.3, sigma)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1772
+ const to = await decodeToRaw(toBuffer, toRawInfo, blurredFrom.info.width, blurredFrom.info.height);
1773
+ const pixels = Buffer.alloc(blurredFrom.data.length);
1774
+ for (let i = 0; i < blurredFrom.data.length; i++) {
1775
+ pixels[i] = Math.round(blurredFrom.data[i] * (1 - t) + to.data[i] * t);
1667
1776
  }
1668
1777
  return {
1669
1778
  buffer: pixels,
1670
- rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
1779
+ rawInfo: { width: blurredFrom.info.width, height: blurredFrom.info.height, channels: 4 }
1671
1780
  };
1672
1781
  }
1782
+ async function applyTransition(type, fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1783
+ switch (type) {
1784
+ case "fade":
1785
+ return applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo);
1786
+ case "slide-left":
1787
+ return applySlide(fromBuffer, toBuffer, progress, width, height, "left", fromRawInfo, toRawInfo);
1788
+ case "slide-up":
1789
+ return applySlide(fromBuffer, toBuffer, progress, width, height, "up", fromRawInfo, toRawInfo);
1790
+ case "blur":
1791
+ return applyBlur(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo);
1792
+ case "none":
1793
+ default:
1794
+ const d = await decodeToRaw(toBuffer, toRawInfo);
1795
+ return { buffer: d.data, rawInfo: { width: d.width, height: d.height, channels: 4 } };
1796
+ }
1797
+ }
1798
+ function easeInOutCubic3(t) {
1799
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1800
+ }
1673
1801
 
1674
1802
  // src/compose/canvas-renderer.ts
1803
+ function mergeStepEffects(global, stepIndex, steps) {
1804
+ if (stepIndex === void 0 || !steps[stepIndex]?.effects) return global;
1805
+ const override = steps[stepIndex].effects;
1806
+ return {
1807
+ zoom: override.zoom ? { ...global.zoom, ...override.zoom } : global.zoom,
1808
+ cursor: override.cursor ? { ...global.cursor, ...override.cursor } : global.cursor,
1809
+ background: override.background ? { ...global.background, ...override.background } : global.background,
1810
+ deviceFrame: override.deviceFrame ? { ...global.deviceFrame, ...override.deviceFrame } : global.deviceFrame,
1811
+ speedRamp: override.speedRamp ? { ...global.speedRamp, ...override.speedRamp } : global.speedRamp,
1812
+ keystroke: override.keystroke ? { ...global.keystroke, ...override.keystroke } : global.keystroke,
1813
+ watermark: override.watermark ? { ...global.watermark, ...override.watermark } : global.watermark
1814
+ };
1815
+ }
1675
1816
  var MIN_FRAMES_PER_WORKER = 4;
1676
1817
  var cachedWorkerUrl = null;
1677
1818
  function getWorkerUrl() {
@@ -1727,14 +1868,17 @@ var CanvasRenderer = class {
1727
1868
  const cpuCount = os.cpus().length;
1728
1869
  const workerCount = Math.min(cpuCount, 8);
1729
1870
  const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
1871
+ const perFrameEffects = processFrames.map(
1872
+ (f) => mergeStepEffects(this.effects, f.stepIndex, this.steps)
1873
+ );
1730
1874
  let composed;
1731
1875
  if (useWorkers) {
1732
- composed = await this.processWithWorkers(processFrames, contexts, workerCount);
1876
+ composed = await this.processWithWorkers(processFrames, contexts, workerCount, perFrameEffects);
1733
1877
  } else {
1734
1878
  composed = [];
1735
1879
  for (let i = 0; i < processFrames.length; i++) {
1736
1880
  composed.push(
1737
- await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
1881
+ await composeFrame(processFrames[i], perFrameEffects[i], this.output, contexts[i])
1738
1882
  );
1739
1883
  }
1740
1884
  }
@@ -1747,7 +1891,7 @@ var CanvasRenderer = class {
1747
1891
  * Distribute frame composition across a pool of worker threads.
1748
1892
  * Workers process frames concurrently; results are collected in order.
1749
1893
  */
1750
- processWithWorkers(frames, contexts, workerCount) {
1894
+ processWithWorkers(frames, contexts, workerCount, perFrameEffects) {
1751
1895
  return new Promise((resolve, reject) => {
1752
1896
  const results = new Array(frames.length);
1753
1897
  let completed = 0;
@@ -1761,7 +1905,7 @@ var CanvasRenderer = class {
1761
1905
  worker.postMessage({
1762
1906
  taskId: i,
1763
1907
  frame: frames[i],
1764
- effects: this.effects,
1908
+ effects: perFrameEffects ? perFrameEffects[i] : this.effects,
1765
1909
  output: this.output,
1766
1910
  context: contexts[i]
1767
1911
  });
@@ -1824,6 +1968,9 @@ var CanvasRenderer = class {
1824
1968
  effectiveScale,
1825
1969
  transitionFrames
1826
1970
  );
1971
+ if (frame.isScrolling && zoomScale > 1) {
1972
+ zoomScale = 1;
1973
+ }
1827
1974
  }
1828
1975
  const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
1829
1976
  const trailLength = this.effects.cursor.trailLength;
@@ -1898,7 +2045,7 @@ var CanvasRenderer = class {
1898
2045
  * using the same applyTransitionsToStream() logic as composeStream().
1899
2046
  */
1900
2047
  async *composeStreamOnline(source) {
1901
- const hasFadeTransitions = this.steps.some((s) => s.transition === "fade");
2048
+ const hasFadeTransitions = this.steps.some((s) => s.transition !== "none");
1902
2049
  if (!hasFadeTransitions) {
1903
2050
  const cpuCount = os.cpus().length;
1904
2051
  const workerCount = Math.min(cpuCount, 8);
@@ -1954,6 +2101,9 @@ var CanvasRenderer = class {
1954
2101
  effectiveScale,
1955
2102
  transitionFrames
1956
2103
  );
2104
+ if (frame.isScrolling && zoomScale > 1) {
2105
+ zoomScale = 1;
2106
+ }
1957
2107
  }
1958
2108
  const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
1959
2109
  const trail = [];
@@ -1965,10 +2115,11 @@ var CanvasRenderer = class {
1965
2115
  const dispatch = (worker) => {
1966
2116
  if (canDispatch(nextToDispatch)) {
1967
2117
  const i = nextToDispatch++;
2118
+ const frameEffects = mergeStepEffects(this.effects, frames[i].stepIndex, this.steps);
1968
2119
  worker.postMessage({
1969
2120
  taskId: i,
1970
2121
  frame: frames[i],
1971
- effects: this.effects,
2122
+ effects: frameEffects,
1972
2123
  output: this.output,
1973
2124
  context: computeContext(i)
1974
2125
  });
@@ -2078,13 +2229,16 @@ var CanvasRenderer = class {
2078
2229
  const workerUrl = getWorkerUrl();
2079
2230
  const workers = [];
2080
2231
  let nextToDispatch = 0;
2232
+ const perFrameEffects = frames.map(
2233
+ (f) => mergeStepEffects(this.effects, f.stepIndex, this.steps)
2234
+ );
2081
2235
  const dispatch = (worker) => {
2082
2236
  if (nextToDispatch >= frames.length || workerError) return;
2083
2237
  const i = nextToDispatch++;
2084
2238
  worker.postMessage({
2085
2239
  taskId: i,
2086
2240
  frame: frames[i],
2087
- effects: this.effects,
2241
+ effects: perFrameEffects[i],
2088
2242
  output: this.output,
2089
2243
  context: contexts[i]
2090
2244
  });
@@ -2135,7 +2289,8 @@ var CanvasRenderer = class {
2135
2289
  */
2136
2290
  async *streamSequential(frames, contexts) {
2137
2291
  for (let i = 0; i < frames.length; i++) {
2138
- yield await composeFrame(frames[i], this.effects, this.output, contexts[i]);
2292
+ const frameEffects = mergeStepEffects(this.effects, frames[i].stepIndex, this.steps);
2293
+ yield await composeFrame(frames[i], frameEffects, this.output, contexts[i]);
2139
2294
  }
2140
2295
  }
2141
2296
  /**
@@ -2150,11 +2305,11 @@ var CanvasRenderer = class {
2150
2305
  if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
2151
2306
  const stepIdx = frames[i].stepIndex;
2152
2307
  const step = this.steps[stepIdx];
2153
- if (step && step.transition === "fade") {
2308
+ if (step && step.transition !== "none") {
2154
2309
  const startIdx = Math.max(0, i - Math.floor(transitionFrames / 2));
2155
2310
  const endIdx = Math.min(frames.length - 1, i + Math.ceil(transitionFrames / 2));
2156
2311
  if (endIdx - startIdx >= 2) {
2157
- windows.push({ startIdx, endIdx });
2312
+ windows.push({ startIdx, endIdx, type: step.transition });
2158
2313
  }
2159
2314
  }
2160
2315
  }
@@ -2201,10 +2356,12 @@ var CanvasRenderer = class {
2201
2356
  const fromBuf = state.frames[0].buffer;
2202
2357
  const toBuf = state.frames[state.frames.length - 1].buffer;
2203
2358
  const range = state.frames.length - 1;
2359
+ const transType = win.type;
2204
2360
  const fromRawInfo = state.frames[0].rawInfo;
2205
2361
  const toRawInfo = state.frames[state.frames.length - 1].rawInfo;
2206
2362
  for (let j = 1; j < state.frames.length - 1; j++) {
2207
- const blended = await applyCrossfade(
2363
+ const blended = await applyTransition(
2364
+ transType,
2208
2365
  fromBuf,
2209
2366
  toBuf,
2210
2367
  j / range,
@@ -2246,8 +2403,8 @@ var CanvasRenderer = class {
2246
2403
  if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
2247
2404
  const stepIdx = frames[i].stepIndex;
2248
2405
  const step = this.steps[stepIdx];
2249
- if (step && step.transition === "fade") {
2250
- boundaries.push({ index: i, stepIndex: stepIdx });
2406
+ if (step && step.transition !== "none") {
2407
+ boundaries.push({ index: i, stepIndex: stepIdx, type: step.transition });
2251
2408
  }
2252
2409
  }
2253
2410
  }
@@ -2262,7 +2419,8 @@ var CanvasRenderer = class {
2262
2419
  const toRawInfo = composed[endIdx].rawInfo;
2263
2420
  for (let i = startIdx + 1; i < endIdx; i++) {
2264
2421
  const progress = (i - startIdx) / range;
2265
- const blended = await applyCrossfade(
2422
+ const blended = await applyTransition(
2423
+ boundary.type,
2266
2424
  fromBuffer,
2267
2425
  toBuffer,
2268
2426
  progress,
@@ -2339,7 +2497,7 @@ async function encodeGif(frames, config) {
2339
2497
  gif.finish();
2340
2498
  return Buffer.from(gif.bytes());
2341
2499
  }
2342
- async function encodeMp4(frames, config) {
2500
+ async function encodeMp4(frames, config, audio) {
2343
2501
  if (frames.length === 0) {
2344
2502
  throw new Error("Cannot encode MP4: no frames provided");
2345
2503
  }
@@ -2347,14 +2505,14 @@ async function encodeMp4(frames, config) {
2347
2505
  try {
2348
2506
  const encoder = await detectVideoEncoder();
2349
2507
  const params = resolveEncodingParams(config);
2350
- await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
2508
+ await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
2351
2509
  return await readFile(outputPath);
2352
2510
  } finally {
2353
2511
  await rm(outputPath, { force: true }).catch(() => {
2354
2512
  });
2355
2513
  }
2356
2514
  }
2357
- async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
2515
+ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio) {
2358
2516
  const videoArgs = encoder === "hevc_videotoolbox" ? [
2359
2517
  "-c:v",
2360
2518
  "hevc_videotoolbox",
@@ -2388,6 +2546,14 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
2388
2546
  "-pix_fmt",
2389
2547
  "yuv420p"
2390
2548
  ];
2549
+ const audioInputArgs = audio ? ["-i", audio.file] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"];
2550
+ const audioFilters = [];
2551
+ if (audio) {
2552
+ if (audio.volume !== 1) audioFilters.push(`volume=${audio.volume}`);
2553
+ if (audio.fadeIn > 0) audioFilters.push(`afade=t=in:d=${audio.fadeIn / 1e3}`);
2554
+ if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
2555
+ }
2556
+ const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
2391
2557
  return new Promise((resolve, reject) => {
2392
2558
  const ffmpeg = spawn(
2393
2559
  "ffmpeg",
@@ -2404,16 +2570,14 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
2404
2570
  String(config.fps),
2405
2571
  "-i",
2406
2572
  "pipe:0",
2407
- // Silent audio track for platform compatibility
2408
- "-f",
2409
- "lavfi",
2410
- "-i",
2411
- "anullsrc=r=48000:cl=stereo",
2573
+ // Audio input
2574
+ ...audioInputArgs,
2412
2575
  ...videoArgs,
2413
2576
  "-c:a",
2414
2577
  "aac",
2415
2578
  "-b:a",
2416
2579
  "128k",
2580
+ ...audioFilterArgs,
2417
2581
  "-shortest",
2418
2582
  "-movflags",
2419
2583
  "+faststart",
@@ -2458,19 +2622,19 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
2458
2622
  })().catch(reject);
2459
2623
  });
2460
2624
  }
2461
- async function encodeMp4Stream(frames, config) {
2625
+ async function encodeMp4Stream(frames, config, audio) {
2462
2626
  const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
2463
2627
  try {
2464
2628
  const encoder = await detectVideoEncoder();
2465
2629
  const params = resolveEncodingParams(config);
2466
- await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath);
2630
+ await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
2467
2631
  return await readFile(outputPath);
2468
2632
  } finally {
2469
2633
  await rm(outputPath, { force: true }).catch(() => {
2470
2634
  });
2471
2635
  }
2472
2636
  }
2473
- async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
2637
+ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio) {
2474
2638
  const videoArgs = encoder === "hevc_videotoolbox" ? [
2475
2639
  "-c:v",
2476
2640
  "hevc_videotoolbox",
@@ -2503,6 +2667,14 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
2503
2667
  "-pix_fmt",
2504
2668
  "yuv420p"
2505
2669
  ];
2670
+ const audioInputArgs = audio ? ["-i", audio.file] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"];
2671
+ const audioFilters = [];
2672
+ if (audio) {
2673
+ if (audio.volume !== 1) audioFilters.push(`volume=${audio.volume}`);
2674
+ if (audio.fadeIn > 0) audioFilters.push(`afade=t=in:d=${audio.fadeIn / 1e3}`);
2675
+ if (audio.fadeOut > 0) audioFilters.push(`afade=t=out:st=999999:d=${audio.fadeOut / 1e3}`);
2676
+ }
2677
+ const audioFilterArgs = audioFilters.length > 0 ? ["-af", audioFilters.join(",")] : [];
2506
2678
  return new Promise((resolve, reject) => {
2507
2679
  const ffmpeg = spawn(
2508
2680
  "ffmpeg",
@@ -2518,15 +2690,13 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
2518
2690
  String(config.fps),
2519
2691
  "-i",
2520
2692
  "pipe:0",
2521
- "-f",
2522
- "lavfi",
2523
- "-i",
2524
- "anullsrc=r=48000:cl=stereo",
2693
+ ...audioInputArgs,
2525
2694
  ...videoArgs,
2526
2695
  "-c:a",
2527
2696
  "aac",
2528
2697
  "-b:a",
2529
2698
  "128k",
2699
+ ...audioFilterArgs,
2530
2700
  "-shortest",
2531
2701
  "-movflags",
2532
2702
  "+faststart",
@@ -2615,7 +2785,8 @@ var ConcurrentSession = class extends EventEmitter {
2615
2785
  yield frame;
2616
2786
  }
2617
2787
  })(),
2618
- this.scenario.output
2788
+ this.scenario.output,
2789
+ this.scenario.audio
2619
2790
  );
2620
2791
  const session = await handle.done;
2621
2792
  this.emit("progress", { composed, total: composed, pct: 100 });
@@ -2655,7 +2826,8 @@ var StreamingSession = class extends EventEmitter {
2655
2826
  yield frame;
2656
2827
  }
2657
2828
  })(),
2658
- scenario.output
2829
+ scenario.output,
2830
+ scenario.audio
2659
2831
  );
2660
2832
  }
2661
2833
  };
@@ -2770,15 +2942,16 @@ var ZoomEffectSchema = z.object({
2770
2942
  enabled: z.boolean().default(true),
2771
2943
  /**
2772
2944
  * Numeric zoom scale (1.0 = no zoom). Overridden by `intensity` when set.
2773
- * Default lowered from 1.8 → 1.35 to match "moderate" intensity.
2945
+ * Default: 1.25 to match "light" intensity (industry standard).
2774
2946
  */
2775
- scale: z.number().min(1).max(5).default(1.35),
2947
+ scale: z.number().min(1).max(5).default(1.25),
2776
2948
  /**
2777
2949
  * Intensity preset — overrides `scale` when set.
2778
2950
  * Calibrated against Loom (light≈1.25x) and Camtasia (moderate≈1.35x).
2951
+ * Default: "light" (1.25x) — matches industry standard (Screen Studio, Loom).
2779
2952
  */
2780
- intensity: ZoomIntensitySchema.optional(),
2781
- duration: z.number().default(600),
2953
+ intensity: ZoomIntensitySchema.default("light"),
2954
+ duration: z.number().default(800),
2782
2955
  easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
2783
2956
  autoZoom: AutoZoomConfigSchema.default({})
2784
2957
  });
@@ -2786,7 +2959,7 @@ var CursorEffectSchema = z.object({
2786
2959
  enabled: z.boolean().default(true),
2787
2960
  size: z.number().default(20),
2788
2961
  color: z.string().default("#000000"),
2789
- speed: z.enum(["fast", "normal", "slow"]).default("fast"),
2962
+ speed: z.enum(["fast", "normal", "slow"]).default("normal"),
2790
2963
  smoothing: z.boolean().default(true),
2791
2964
  clickEffect: z.boolean().default(true),
2792
2965
  clickColor: z.string().default("rgba(59, 130, 246, 0.3)"),
@@ -2812,7 +2985,7 @@ var DeviceFrameSchema = z.object({
2812
2985
  });
2813
2986
  var SpeedRampConfigSchema = z.object({
2814
2987
  enabled: z.boolean().default(false),
2815
- idleSpeed: z.number().min(0.5).max(8).default(3),
2988
+ idleSpeed: z.number().min(0.5).max(8).default(2),
2816
2989
  actionSpeed: z.number().min(0.25).max(2).default(0.8),
2817
2990
  transitionFrames: z.number().default(15)
2818
2991
  });
@@ -2854,10 +3027,11 @@ var EffectsConfigSchema = z.object({
2854
3027
  watermark: WatermarkConfigSchema.default({})
2855
3028
  });
2856
3029
  var OutputConfigSchema = z.object({
2857
- format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
3030
+ format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("mp4"),
2858
3031
  width: z.number().default(1280),
2859
3032
  height: z.number().default(800),
2860
3033
  fps: z.number().min(1).max(60).default(30),
3034
+ /** @deprecated Use `preset` instead. Will be removed in v0.7. */
2861
3035
  quality: z.number().min(1).max(100).default(80),
2862
3036
  // Encoding preset for MP4 output. Overrides quality when set.
2863
3037
  // social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
@@ -2867,12 +3041,40 @@ var OutputConfigSchema = z.object({
2867
3041
  outputDir: z.string().default("./output"),
2868
3042
  filename: z.string().default("clipwise-recording")
2869
3043
  });
3044
+ var StepEffectsOverrideSchema = z.object({
3045
+ zoom: ZoomEffectSchema.partial().optional(),
3046
+ cursor: CursorEffectSchema.partial().optional(),
3047
+ background: BackgroundSchema.partial().optional(),
3048
+ deviceFrame: DeviceFrameSchema.partial().optional(),
3049
+ speedRamp: SpeedRampConfigSchema.partial().optional(),
3050
+ keystroke: KeystrokeConfigSchema.partial().optional(),
3051
+ watermark: WatermarkConfigSchema.partial().optional()
3052
+ }).optional();
3053
+ var TransitionTypeSchema = z.enum([
3054
+ "none",
3055
+ "fade",
3056
+ "slide-left",
3057
+ "slide-up",
3058
+ "blur"
3059
+ ]);
2870
3060
  var StepSchema = z.object({
2871
3061
  name: z.string().optional(),
2872
3062
  actions: z.array(StepActionSchema),
2873
3063
  captureDelay: z.number().default(300),
2874
3064
  holdDuration: z.number().default(1500),
2875
- transition: z.enum(["fade", "none"]).default("none")
3065
+ transition: TransitionTypeSchema.default("none"),
3066
+ /** Per-step effects override — merges with global effects config. */
3067
+ effects: StepEffectsOverrideSchema
3068
+ });
3069
+ var AudioConfigSchema = z.object({
3070
+ /** Path to the audio file (MP3, WAV, AAC, etc.). */
3071
+ file: z.string().min(1),
3072
+ /** Volume level (0.0 = silent, 1.0 = full). */
3073
+ volume: z.number().min(0).max(1).default(1),
3074
+ /** Fade-in duration in milliseconds. */
3075
+ fadeIn: z.number().min(0).default(0),
3076
+ /** Fade-out duration in milliseconds. */
3077
+ fadeOut: z.number().min(0).default(0)
2876
3078
  });
2877
3079
  var ScenarioSchema = z.object({
2878
3080
  name: z.string(),
@@ -2883,6 +3085,8 @@ var ScenarioSchema = z.object({
2883
3085
  }).default({}),
2884
3086
  effects: EffectsConfigSchema.default({}),
2885
3087
  output: OutputConfigSchema.default({}),
3088
+ /** Optional audio narration — muxed into MP4 output. */
3089
+ audio: AudioConfigSchema.optional(),
2886
3090
  steps: z.array(StepSchema).min(1)
2887
3091
  });
2888
3092
 
@@ -3000,7 +3204,10 @@ export {
3000
3204
  ConcurrentSession,
3001
3205
  StreamingSession,
3002
3206
  ZOOM_INTENSITY_SCALES,
3207
+ applyBlur,
3003
3208
  applyCrossfade,
3209
+ applySlide,
3210
+ applyTransition,
3004
3211
  buildZoomClickLookup,
3005
3212
  calculateAdaptiveZoom,
3006
3213
  calculateAdaptiveZoomFromLookup,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clipwise",
3
- "version": "0.5.2",
3
+ "version": "0.6.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",