clipwise 0.3.0 → 0.4.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/cli/index.js CHANGED
@@ -401,6 +401,35 @@ var CURSOR_SPEED_PRESETS = {
401
401
  slow: { steps: 20, delay: 25 }
402
402
  // ~500ms, ~20 frames captured
403
403
  };
404
+ var FrameChannel = class {
405
+ buffer = [];
406
+ resolve = null;
407
+ closed = false;
408
+ push(frame) {
409
+ if (this.closed) return;
410
+ this.buffer.push(frame);
411
+ this.resolve?.();
412
+ this.resolve = null;
413
+ }
414
+ close() {
415
+ if (this.closed) return;
416
+ this.closed = true;
417
+ this.resolve?.();
418
+ this.resolve = null;
419
+ }
420
+ async *[Symbol.asyncIterator]() {
421
+ while (true) {
422
+ while (this.buffer.length > 0) {
423
+ yield this.buffer.shift();
424
+ }
425
+ if (this.closed) return;
426
+ await new Promise((r) => {
427
+ this.resolve = r;
428
+ });
429
+ }
430
+ }
431
+ };
432
+ var DEDUP_SIGNATURE_BYTES = 2048;
404
433
  var ClipwiseRecorder = class {
405
434
  browser = null;
406
435
  context = null;
@@ -419,6 +448,15 @@ var ClipwiseRecorder = class {
419
448
  cursorSpeed = "fast";
420
449
  firstContentTimestamp = 0;
421
450
  pendingResponsePromises = /* @__PURE__ */ new Map();
451
+ // ── 중복 프레임 제거 (Phase 1-A) ──────────────────────────────────────────
452
+ // 직전 저장된 프레임의 앞부분 시그니처. 동일하면 화면 내용이 바뀌지 않은 것.
453
+ lastFrameSignature = null;
454
+ dedupStats = { received: 0, stored: 0, skipped: 0 };
455
+ // ── 스트리밍 채널 (Phase 3-B) ───────────────────────────────────────────
456
+ // Set during recordToChannel(); null in normal record() mode.
457
+ frameChannel = null;
458
+ channelIndex = 0;
459
+ // sequential index for channel-pushed frames
422
460
  /**
423
461
  * Launch the browser and create a page with the scenario viewport.
424
462
  */
@@ -442,6 +480,10 @@ var ClipwiseRecorder = class {
442
480
  this.cursorPosition = { x: 0, y: 0 };
443
481
  this.isCapturing = false;
444
482
  this.firstContentTimestamp = 0;
483
+ this.lastFrameSignature = null;
484
+ this.dedupStats = { received: 0, stored: 0, skipped: 0 };
485
+ this.frameChannel = null;
486
+ this.channelIndex = 0;
445
487
  }
446
488
  /**
447
489
  * Start CDP screencast for continuous frame capture.
@@ -456,10 +498,24 @@ var ClipwiseRecorder = class {
456
498
  async (event) => {
457
499
  if (!this.isCapturing || !this.cdpClient) return;
458
500
  const buffer = Buffer.from(event.data, "base64");
459
- this.rawFrames.push({
460
- buffer,
461
- timestamp: Date.now()
462
- });
501
+ this.dedupStats.received++;
502
+ const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
503
+ const isDuplicate = this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
504
+ if (isDuplicate) {
505
+ this.dedupStats.skipped++;
506
+ } else {
507
+ this.lastFrameSignature = Buffer.from(signature);
508
+ const captureTime = Date.now();
509
+ this.rawFrames.push({ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex });
510
+ this.dedupStats.stored++;
511
+ if (this.frameChannel && this.firstContentTimestamp > 0) {
512
+ const frame = this.buildFrameOnline(
513
+ { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex },
514
+ this.channelIndex++
515
+ );
516
+ this.frameChannel.push(frame);
517
+ }
518
+ }
463
519
  await this.cdpClient.send("Page.screencastFrameAck", {
464
520
  sessionId: event.sessionId
465
521
  }).catch(() => {
@@ -498,13 +554,23 @@ var ClipwiseRecorder = class {
498
554
  await this.init(scenario);
499
555
  const startTime = Date.now();
500
556
  try {
557
+ if (scenario.steps.length > 0) {
558
+ const s0 = scenario.steps[0];
559
+ this.currentStepIndex = 0;
560
+ this.preRegisterResponseListeners(s0.actions);
561
+ for (let ai = 0; ai < s0.actions.length; ai++) {
562
+ await this.executeAction(s0.actions[ai], ai);
563
+ }
564
+ }
501
565
  await this.startCapture();
502
566
  for (let si = 0; si < scenario.steps.length; si++) {
503
567
  const step = scenario.steps[si];
504
568
  this.currentStepIndex = si;
505
- this.preRegisterResponseListeners(step.actions);
506
- for (let ai = 0; ai < step.actions.length; ai++) {
507
- await this.executeAction(step.actions[ai], ai);
569
+ if (si > 0) {
570
+ this.preRegisterResponseListeners(step.actions);
571
+ for (let ai = 0; ai < step.actions.length; ai++) {
572
+ await this.executeAction(step.actions[ai], ai);
573
+ }
508
574
  }
509
575
  if (step.captureDelay > 0) {
510
576
  await this.waitWithRepaints(step.captureDelay);
@@ -525,7 +591,8 @@ var ClipwiseRecorder = class {
525
591
  scenario,
526
592
  frames,
527
593
  startTime,
528
- endTime: Date.now()
594
+ endTime: Date.now(),
595
+ dedupStats: { ...this.dedupStats }
529
596
  };
530
597
  } catch (error) {
531
598
  await this.stopCapture().catch(() => {
@@ -541,13 +608,116 @@ var ClipwiseRecorder = class {
541
608
  scenario,
542
609
  frames,
543
610
  startTime,
544
- endTime: Date.now()
611
+ endTime: Date.now(),
612
+ dedupStats: { ...this.dedupStats }
545
613
  };
546
614
  throw err;
547
615
  } finally {
548
616
  await this.cleanup();
549
617
  }
550
618
  }
619
+ // ─── Streaming recording API (Phase 3-B) ──────────────────────────────────
620
+ /**
621
+ * Start recording concurrently and return a RecordingHandle immediately.
622
+ *
623
+ * frameStream: yields CapturedFrames as each unique frame arrives from CDP
624
+ * (post-dedup, sequential indices starting at 0, NO FPS resampling).
625
+ * Closes when recording ends.
626
+ *
627
+ * done: resolves with the full RecordingSession (FPS-resampled) once
628
+ * all steps have completed and the browser has been cleaned up.
629
+ *
630
+ * Use this with CanvasRenderer.composeStreamOnline() to overlap recording
631
+ * time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
632
+ */
633
+ recordToChannel(scenario) {
634
+ const channel = new FrameChannel();
635
+ const done = (async () => {
636
+ try {
637
+ await this.init(scenario);
638
+ this.frameChannel = channel;
639
+ const startTime = Date.now();
640
+ if (scenario.steps.length > 0) {
641
+ const s0 = scenario.steps[0];
642
+ this.currentStepIndex = 0;
643
+ this.preRegisterResponseListeners(s0.actions);
644
+ for (let ai = 0; ai < s0.actions.length; ai++) {
645
+ await this.executeAction(s0.actions[ai], ai);
646
+ }
647
+ }
648
+ await this.startCapture();
649
+ for (let si = 0; si < scenario.steps.length; si++) {
650
+ const step = scenario.steps[si];
651
+ this.currentStepIndex = si;
652
+ if (si > 0) {
653
+ this.preRegisterResponseListeners(step.actions);
654
+ for (let ai = 0; ai < step.actions.length; ai++) {
655
+ await this.executeAction(step.actions[ai], ai);
656
+ }
657
+ }
658
+ if (step.captureDelay > 0) await this.waitWithRepaints(step.captureDelay);
659
+ if (step.holdDuration > 0) await this.waitWithRepaints(step.holdDuration);
660
+ }
661
+ await this.stopCapture();
662
+ channel.close();
663
+ const rawFrames = this.buildCapturedFrames();
664
+ const recordingDurationMs = Date.now() - startTime;
665
+ const frames = this.resampleToTargetFps(rawFrames, recordingDurationMs);
666
+ return {
667
+ scenario,
668
+ frames,
669
+ startTime,
670
+ endTime: Date.now(),
671
+ dedupStats: { ...this.dedupStats }
672
+ };
673
+ } catch (error) {
674
+ channel.close();
675
+ await this.stopCapture().catch(() => {
676
+ });
677
+ const rawFrames = this.buildCapturedFrames();
678
+ const session = {
679
+ scenario,
680
+ frames: rawFrames,
681
+ startTime: Date.now(),
682
+ dedupStats: { ...this.dedupStats }
683
+ };
684
+ const err = error instanceof Error ? error : new Error(String(error));
685
+ err.partialSession = session;
686
+ throw err;
687
+ } finally {
688
+ await this.cleanup();
689
+ }
690
+ })();
691
+ return { frameStream: channel, done };
692
+ }
693
+ /**
694
+ * Build a single CapturedFrame from a RawFrame in real-time.
695
+ * Used by recordToChannel() to emit frames as they arrive.
696
+ * Cursor/click data reflects the timeline up to this moment.
697
+ */
698
+ buildFrameOnline(raw, sequentialIndex) {
699
+ const cursorPos = this.interpolateCursorAt(raw.timestamp);
700
+ const clickEvent = this.clickTimeline.find(
701
+ (click) => raw.timestamp >= click.timestamp && raw.timestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
702
+ );
703
+ let clickProgress;
704
+ if (clickEvent) {
705
+ clickProgress = Math.min(1, (raw.timestamp - clickEvent.timestamp) / CLICK_EFFECT_DURATION_MS);
706
+ }
707
+ const frameKeystrokes = this.keystrokeTimeline.filter((k) => k.timestamp <= raw.timestamp);
708
+ return {
709
+ index: sequentialIndex,
710
+ screenshot: raw.buffer,
711
+ timestamp: raw.timestamp,
712
+ cursorPosition: cursorPos,
713
+ clickPosition: clickEvent?.position ?? null,
714
+ clickProgress,
715
+ viewport: { ...this.viewport },
716
+ deviceScaleFactor: this.deviceScaleFactor,
717
+ stepIndex: raw.stepIndex,
718
+ keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
719
+ };
720
+ }
551
721
  /**
552
722
  * Wait for a given duration while forcing periodic repaints
553
723
  * so CDP screencast keeps sending frames even on static pages.
@@ -785,7 +955,8 @@ var ClipwiseRecorder = class {
785
955
  viewport: { ...this.viewport },
786
956
  deviceScaleFactor: this.deviceScaleFactor,
787
957
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
788
- stepIndex: this.currentStepIndex
958
+ stepIndex: raw.stepIndex
959
+ // use per-frame step index captured at event time
789
960
  };
790
961
  });
791
962
  }
@@ -1242,22 +1413,43 @@ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight
1242
1413
  top = Math.max(0, Math.min(top, frameHeight - cropHeight));
1243
1414
  return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
1244
1415
  }
1245
- function calculateAdaptiveZoom(frames, currentIndex, maxScale, transitionFrames) {
1246
- if (maxScale <= 1) return 1;
1247
- let minDistance = Infinity;
1416
+ function buildZoomClickLookup(frames) {
1417
+ const indices = [];
1248
1418
  for (let i = 0; i < frames.length; i++) {
1249
- if (frames[i].clickPosition) {
1250
- const distance = Math.abs(i - currentIndex);
1251
- minDistance = Math.min(minDistance, distance);
1419
+ if (frames[i].clickPosition !== null && frames[i].clickPosition !== void 0) {
1420
+ indices.push(i);
1252
1421
  }
1253
1422
  }
1254
- if (minDistance === Infinity) return 1;
1255
- if (minDistance <= transitionFrames) {
1256
- const t = 1 - minDistance / transitionFrames;
1257
- const eased = easeInOutCubic2(t);
1258
- return 1 + (maxScale - 1) * eased;
1423
+ return indices;
1424
+ }
1425
+ function calculateAdaptiveZoomFromLookup(clickLookup, currentIndex, maxScale, transitionFrames) {
1426
+ if (maxScale <= 1 || clickLookup.length === 0) return 1;
1427
+ let lo = 0;
1428
+ let hi = clickLookup.length;
1429
+ while (lo < hi) {
1430
+ const mid = lo + hi >>> 1;
1431
+ if (clickLookup[mid] < currentIndex) lo = mid + 1;
1432
+ else hi = mid;
1433
+ }
1434
+ const distBefore = lo > 0 ? currentIndex - clickLookup[lo - 1] : Infinity;
1435
+ const distAfter = lo < clickLookup.length ? clickLookup[lo] - currentIndex : Infinity;
1436
+ const minDistance = Math.min(distBefore, distAfter);
1437
+ if (minDistance > transitionFrames) return 1;
1438
+ const t = 1 - minDistance / transitionFrames;
1439
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
1440
+ }
1441
+ function calculateAdaptiveZoomInWindow(windowFrames, windowStart, currentIndex, maxScale, transitionFrames) {
1442
+ if (maxScale <= 1) return 1;
1443
+ let minDistance = Infinity;
1444
+ for (let j = 0; j < windowFrames.length; j++) {
1445
+ if (windowFrames[j].clickPosition !== null && windowFrames[j].clickPosition !== void 0) {
1446
+ const dist = Math.abs(windowStart + j - currentIndex);
1447
+ if (dist < minDistance) minDistance = dist;
1448
+ }
1259
1449
  }
1260
- return 1;
1450
+ if (minDistance > transitionFrames) return 1;
1451
+ const t = 1 - minDistance / transitionFrames;
1452
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
1261
1453
  }
1262
1454
  function easeInOutCubic2(t) {
1263
1455
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
@@ -1408,8 +1600,8 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1408
1600
 
1409
1601
  // src/effects/watermark.ts
1410
1602
  import sharp6 from "sharp";
1411
- async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1412
- if (!config.enabled || !config.text) return frameBuffer;
1603
+ function buildWatermarkSvg(config, frameWidth, frameHeight) {
1604
+ if (!config.enabled || !config.text) return "";
1413
1605
  const charWidth = config.fontSize * 0.62;
1414
1606
  const textWidth = Math.ceil(config.text.length * charWidth);
1415
1607
  const margin = 16;
@@ -1435,13 +1627,17 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1435
1627
  break;
1436
1628
  }
1437
1629
  const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1438
- const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1630
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1439
1631
  <text x="${x}" y="${y}"
1440
1632
  font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
1441
1633
  font-weight="600" fill="${config.color}"
1442
1634
  opacity="${config.opacity.toFixed(3)}">${escaped}</text>
1443
1635
  </svg>`;
1444
- return sharp6(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
1636
+ }
1637
+ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1638
+ if (!config.enabled || !config.text) return frameBuffer;
1639
+ const svg = buildWatermarkSvg(config, frameWidth, frameHeight);
1640
+ return sharp6(frameBuffer).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toBuffer();
1445
1641
  }
1446
1642
 
1447
1643
  // src/compose/compose-frame.ts
@@ -1472,7 +1668,18 @@ async function composeFrame(frame, effects, output, context) {
1472
1668
  cursorTrail: context?.cursorTrail ?? []
1473
1669
  };
1474
1670
  if (effects.deviceFrame.enabled) {
1475
- buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1671
+ const sl2 = ctx.staticLayers;
1672
+ if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
1673
+ buffer = await sharp7(buffer).extend({
1674
+ top: sl2.browserChromeHeight,
1675
+ bottom: 0,
1676
+ left: 0,
1677
+ right: 0,
1678
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
1679
+ }).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
1680
+ } else {
1681
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1682
+ }
1476
1683
  const meta2 = await sharp7(buffer).metadata();
1477
1684
  width = meta2.width ?? width;
1478
1685
  height = meta2.height ?? height;
@@ -1540,38 +1747,83 @@ async function composeFrame(frame, effects, output, context) {
1540
1747
  };
1541
1748
  buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1542
1749
  }
1543
- buffer = await applyBackground(buffer, effects.background, output.width, output.height);
1544
- if (effects.watermark.enabled) {
1545
- buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
1750
+ const sl = ctx.staticLayers;
1751
+ if (sl) {
1752
+ const padding = effects.background.padding;
1753
+ const contentWidth = output.width - padding * 2;
1754
+ const contentHeight = output.height - padding * 2;
1755
+ if (contentWidth > 0 && contentHeight > 0) {
1756
+ const radius = effects.background.borderRadius;
1757
+ const roundedMask = Buffer.from(
1758
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
1759
+ <rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
1760
+ </svg>`
1761
+ );
1762
+ const { data: maskedData, info: maskedInfo } = await sharp7(buffer).resize(contentWidth, contentHeight, { fit: "fill" }).composite([{ input: roundedMask, blend: "dest-in" }]).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1763
+ const { data: composited, info: compInfo } = await sharp7(sl.backdropRaw, {
1764
+ raw: { width: sl.backdropWidth, height: sl.backdropHeight, channels: 4 }
1765
+ }).composite([{
1766
+ input: Buffer.from(maskedData),
1767
+ raw: { width: maskedInfo.width, height: maskedInfo.height, channels: 4 },
1768
+ left: padding,
1769
+ top: padding
1770
+ }]).raw().toBuffer({ resolveWithObject: true });
1771
+ return {
1772
+ index: frame.index,
1773
+ buffer: Buffer.from(composited),
1774
+ timestamp: frame.timestamp,
1775
+ rawInfo: { width: compInfo.width, height: compInfo.height, channels: 4 }
1776
+ };
1777
+ }
1778
+ buffer = sl.backdropRaw;
1779
+ } else {
1780
+ buffer = await applyBackground(buffer, effects.background, output.width, output.height);
1781
+ if (effects.watermark.enabled) {
1782
+ buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
1783
+ }
1546
1784
  }
1547
- buffer = await sharp7(buffer).resize(output.width, output.height, {
1785
+ const { data: finalData, info: finalInfo } = await sharp7(buffer).resize(output.width, output.height, {
1548
1786
  fit: "fill",
1549
1787
  kernel: sharp7.kernel.lanczos3
1550
- }).png().toBuffer();
1551
- return { index: frame.index, buffer, timestamp: frame.timestamp };
1788
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1789
+ return {
1790
+ index: frame.index,
1791
+ buffer: Buffer.from(finalData),
1792
+ timestamp: frame.timestamp,
1793
+ rawInfo: { width: finalInfo.width, height: finalInfo.height, channels: 4 }
1794
+ };
1552
1795
  }
1553
1796
 
1554
1797
  // src/effects/transition.ts
1555
1798
  import sharp8 from "sharp";
1556
- async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1799
+ async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1557
1800
  const t = Math.max(0, Math.min(1, progress));
1558
- if (t <= 0) return fromBuffer;
1559
- if (t >= 1) return toBuffer;
1560
- const fromRaw = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1561
- const toRaw = await sharp8(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1801
+ if (t <= 0) {
1802
+ const rawInfo = fromRawInfo ?? { width, height, channels: 4 };
1803
+ if (fromRawInfo) return { buffer: fromBuffer, rawInfo };
1804
+ const { data, info } = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1805
+ return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
1806
+ }
1807
+ if (t >= 1) {
1808
+ const rawInfo = toRawInfo ?? { width, height, channels: 4 };
1809
+ if (toRawInfo) return { buffer: toBuffer, rawInfo };
1810
+ const { data, info } = await sharp8(toBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1811
+ return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
1812
+ }
1813
+ const fromSrc = fromRawInfo ? sharp8(fromBuffer, { raw: { width: fromRawInfo.width, height: fromRawInfo.height, channels: fromRawInfo.channels } }) : sharp8(fromBuffer);
1814
+ const fromRaw = await fromSrc.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1815
+ const toSrc = toRawInfo ? sharp8(toBuffer, { raw: { width: toRawInfo.width, height: toRawInfo.height, channels: toRawInfo.channels } }) : sharp8(toBuffer);
1816
+ const toRaw = await toSrc.resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1562
1817
  const pixels = Buffer.alloc(fromRaw.data.length);
1563
1818
  for (let i = 0; i < fromRaw.data.length; i++) {
1564
1819
  pixels[i] = Math.round(
1565
1820
  fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1566
1821
  );
1567
1822
  }
1568
- return sharp8(pixels, {
1569
- raw: {
1570
- width: fromRaw.info.width,
1571
- height: fromRaw.info.height,
1572
- channels: 4
1573
- }
1574
- }).png().toBuffer();
1823
+ return {
1824
+ buffer: pixels,
1825
+ rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
1826
+ };
1575
1827
  }
1576
1828
 
1577
1829
  // src/compose/canvas-renderer.ts
@@ -1683,7 +1935,8 @@ var CanvasRenderer = class {
1683
1935
  results[msg.taskId] = {
1684
1936
  index: frames[msg.taskId].index,
1685
1937
  buffer: Buffer.from(msg.buffer),
1686
- timestamp: frames[msg.taskId].timestamp
1938
+ timestamp: frames[msg.taskId].timestamp,
1939
+ rawInfo: msg.rawInfo
1687
1940
  };
1688
1941
  completed++;
1689
1942
  if (completed === frames.length) {
@@ -1711,12 +1964,13 @@ var CanvasRenderer = class {
1711
1964
  const transitionFrames = Math.round(
1712
1965
  this.output.fps * (this.effects.zoom.duration / 1e3)
1713
1966
  );
1967
+ const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
1714
1968
  for (let i = 0; i < frames.length; i++) {
1715
1969
  const frame = frames[i];
1716
1970
  let zoomScale = 1;
1717
1971
  if (this.effects.zoom.enabled) {
1718
- zoomScale = calculateAdaptiveZoom(
1719
- frames,
1972
+ zoomScale = calculateAdaptiveZoomFromLookup(
1973
+ clickLookup,
1720
1974
  i,
1721
1975
  this.effects.zoom.scale,
1722
1976
  transitionFrames
@@ -1766,6 +2020,369 @@ var CanvasRenderer = class {
1766
2020
  }
1767
2021
  return result;
1768
2022
  }
2023
+ // ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
2024
+ /**
2025
+ * Returns true when no effect requires the full frame array upfront.
2026
+ *
2027
+ * When true, composeStreamOnline() can be used: frames are composited as they
2028
+ * arrive (no need to wait for all frames to be collected first).
2029
+ *
2030
+ * Currently the only blocking effect is speed ramp, which needs to scan all
2031
+ * frames to compute action-proximity indices. Zoom uses the window-based
2032
+ * calculateAdaptiveZoomInWindow() so it works with a rolling lookahead buffer.
2033
+ */
2034
+ canStreamOnline() {
2035
+ return !this.effects.speedRamp.enabled;
2036
+ }
2037
+ /**
2038
+ * Online streaming compose — accepts an AsyncIterable of frames (e.g. from
2039
+ * ClipwiseRecorder.recordToChannel()) and begins compositing immediately,
2040
+ * without waiting for all frames to be collected.
2041
+ *
2042
+ * Each frame is dispatched to the worker pool as soon as its zoom lookahead
2043
+ * window is satisfied (i.e. when frame i + transitionFrames has arrived).
2044
+ * This creates a natural pipeline: recording produces frames while workers
2045
+ * consume them in parallel.
2046
+ *
2047
+ * Requires canStreamOnline() === true (speedRamp must be disabled).
2048
+ * Transitions (step boundaries with transition: fade) are applied inline
2049
+ * using the same applyTransitionsToStream() logic as composeStream().
2050
+ */
2051
+ async *composeStreamOnline(source) {
2052
+ const hasFadeTransitions = this.steps.some((s) => s.transition === "fade");
2053
+ if (!hasFadeTransitions) {
2054
+ const cpuCount = os.cpus().length;
2055
+ const workerCount = Math.min(cpuCount, 8);
2056
+ yield* this.streamOnlineWithWorkers(source, workerCount);
2057
+ return;
2058
+ }
2059
+ const collected = [];
2060
+ for await (const frame of source) {
2061
+ collected.push(frame);
2062
+ }
2063
+ yield* this.composeStream(collected);
2064
+ }
2065
+ /**
2066
+ * Worker-pool online streaming: dispatches frame i to a worker as soon as
2067
+ * frame i + transitionFrames has arrived from the source.
2068
+ *
2069
+ * Uses a notify-on-progress pattern (same as streamWithWorkers) extended
2070
+ * with an intake coroutine that feeds the growing frames[] buffer.
2071
+ */
2072
+ async *streamOnlineWithWorkers(source, workerCount) {
2073
+ const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
2074
+ const trailLength = this.effects.cursor.trailLength;
2075
+ const frames = [];
2076
+ let sourceComplete = false;
2077
+ let workerError = null;
2078
+ let notify = null;
2079
+ const trigger = () => {
2080
+ notify?.();
2081
+ notify = null;
2082
+ };
2083
+ const waitForProgress = () => new Promise((r) => {
2084
+ notify = r;
2085
+ });
2086
+ const completed = /* @__PURE__ */ new Map();
2087
+ const idleWorkers = [];
2088
+ let nextToDispatch = 0;
2089
+ let nextToYield = 0;
2090
+ const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
2091
+ const computeContext = (i) => {
2092
+ const frame = frames[i];
2093
+ let zoomScale = 1;
2094
+ if (this.effects.zoom.enabled) {
2095
+ const lo = Math.max(0, i - transitionFrames);
2096
+ const hi = Math.min(frames.length - 1, i + transitionFrames);
2097
+ zoomScale = calculateAdaptiveZoomInWindow(
2098
+ frames.slice(lo, hi + 1),
2099
+ lo,
2100
+ i,
2101
+ this.effects.zoom.scale,
2102
+ transitionFrames
2103
+ );
2104
+ }
2105
+ const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
2106
+ const trail = [];
2107
+ for (let j = Math.max(0, i - trailLength); j <= i; j++) {
2108
+ if (frames[j].cursorPosition) trail.push(frames[j].cursorPosition);
2109
+ }
2110
+ return { zoomScale, clickProgress, cursorTrail: trail };
2111
+ };
2112
+ const dispatch = (worker) => {
2113
+ if (canDispatch(nextToDispatch)) {
2114
+ const i = nextToDispatch++;
2115
+ worker.postMessage({
2116
+ taskId: i,
2117
+ frame: frames[i],
2118
+ effects: this.effects,
2119
+ output: this.output,
2120
+ context: computeContext(i)
2121
+ });
2122
+ } else {
2123
+ idleWorkers.push(worker);
2124
+ }
2125
+ };
2126
+ const dispatchToIdle = () => {
2127
+ while (idleWorkers.length > 0 && canDispatch(nextToDispatch)) {
2128
+ dispatch(idleWorkers.shift());
2129
+ }
2130
+ };
2131
+ const workerUrl = getWorkerUrl();
2132
+ const workers = [];
2133
+ for (let w = 0; w < workerCount; w++) {
2134
+ const worker = new Worker(workerUrl);
2135
+ workers.push(worker);
2136
+ worker.on("message", (msg) => {
2137
+ if (workerError) return;
2138
+ if (msg.error) {
2139
+ workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
2140
+ } else {
2141
+ completed.set(msg.taskId, {
2142
+ index: frames[msg.taskId].index,
2143
+ buffer: Buffer.from(msg.buffer),
2144
+ timestamp: frames[msg.taskId].timestamp,
2145
+ rawInfo: msg.rawInfo
2146
+ });
2147
+ dispatch(worker);
2148
+ }
2149
+ trigger();
2150
+ });
2151
+ worker.on("error", (err) => {
2152
+ workerError = err;
2153
+ trigger();
2154
+ });
2155
+ idleWorkers.push(worker);
2156
+ }
2157
+ const intakeTask = (async () => {
2158
+ for await (const frame of source) {
2159
+ frames.push(frame);
2160
+ dispatchToIdle();
2161
+ trigger();
2162
+ }
2163
+ sourceComplete = true;
2164
+ dispatchToIdle();
2165
+ trigger();
2166
+ })();
2167
+ try {
2168
+ while (true) {
2169
+ if (workerError) throw workerError;
2170
+ if (sourceComplete && nextToDispatch >= frames.length && nextToYield >= frames.length) {
2171
+ break;
2172
+ }
2173
+ if (completed.has(nextToYield)) {
2174
+ const frame = completed.get(nextToYield);
2175
+ completed.delete(nextToYield);
2176
+ nextToYield++;
2177
+ yield frame;
2178
+ continue;
2179
+ }
2180
+ await waitForProgress();
2181
+ }
2182
+ } finally {
2183
+ await intakeTask;
2184
+ workers.forEach((w) => w.terminate());
2185
+ }
2186
+ }
2187
+ // ─── Streaming pipeline (Phase 1-B) ────────────────────────────────────────
2188
+ /**
2189
+ * Stream frame composition — yields ComposedFrames as workers finish,
2190
+ * in display order, so the encoder can start before all frames are composed.
2191
+ *
2192
+ * Same 4-pass structure as composeAll():
2193
+ * Pass 1 & 2 run upfront (need the full frame set).
2194
+ * Pass 3 streams via the worker pool (ordered yield).
2195
+ * Pass 4 transitions are buffered inline and applied at step boundaries.
2196
+ */
2197
+ async *composeStream(frames) {
2198
+ if (frames.length === 0) return;
2199
+ let processFrames = frames;
2200
+ if (this.effects.speedRamp.enabled) {
2201
+ processFrames = this.applySpeedRamp(frames);
2202
+ }
2203
+ const contexts = this.calculateFrameContexts(processFrames);
2204
+ const windows = this.getTransitionWindows(processFrames);
2205
+ const cpuCount = os.cpus().length;
2206
+ const workerCount = Math.min(cpuCount, 8);
2207
+ const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
2208
+ const rawStream = useWorkers ? this.streamWithWorkers(processFrames, contexts, workerCount) : this.streamSequential(processFrames, contexts);
2209
+ yield* this.applyTransitionsToStream(rawStream, windows);
2210
+ }
2211
+ /**
2212
+ * Worker-pool streaming: dispatches frames to workers and yields results
2213
+ * in display order as soon as each frame is ready.
2214
+ *
2215
+ * Uses a notify-on-progress pattern to bridge event-driven workers
2216
+ * to an ordered AsyncGenerator without busy-polling.
2217
+ */
2218
+ async *streamWithWorkers(frames, contexts, workerCount) {
2219
+ const completed = new Array(frames.length);
2220
+ let workerError = null;
2221
+ let notify = null;
2222
+ const waitForProgress = () => new Promise((r) => {
2223
+ notify = r;
2224
+ });
2225
+ const workerUrl = getWorkerUrl();
2226
+ const workers = [];
2227
+ let nextToDispatch = 0;
2228
+ const dispatch = (worker) => {
2229
+ if (nextToDispatch >= frames.length || workerError) return;
2230
+ const i = nextToDispatch++;
2231
+ worker.postMessage({
2232
+ taskId: i,
2233
+ frame: frames[i],
2234
+ effects: this.effects,
2235
+ output: this.output,
2236
+ context: contexts[i]
2237
+ });
2238
+ };
2239
+ for (let w = 0; w < workerCount; w++) {
2240
+ const worker = new Worker(workerUrl);
2241
+ workers.push(worker);
2242
+ worker.on("message", (msg) => {
2243
+ if (workerError) return;
2244
+ if (msg.error) {
2245
+ workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
2246
+ } else {
2247
+ completed[msg.taskId] = {
2248
+ index: frames[msg.taskId].index,
2249
+ buffer: Buffer.from(msg.buffer),
2250
+ timestamp: frames[msg.taskId].timestamp,
2251
+ rawInfo: msg.rawInfo
2252
+ };
2253
+ dispatch(worker);
2254
+ }
2255
+ notify?.();
2256
+ notify = null;
2257
+ });
2258
+ worker.on("error", (err) => {
2259
+ workerError = err;
2260
+ notify?.();
2261
+ notify = null;
2262
+ });
2263
+ dispatch(worker);
2264
+ }
2265
+ try {
2266
+ for (let i = 0; i < frames.length; i++) {
2267
+ while (completed[i] === void 0 && !workerError) {
2268
+ await waitForProgress();
2269
+ }
2270
+ if (workerError) throw workerError;
2271
+ const frame = completed[i];
2272
+ completed[i] = void 0;
2273
+ yield frame;
2274
+ }
2275
+ } finally {
2276
+ workers.forEach((w) => w.terminate());
2277
+ }
2278
+ }
2279
+ /**
2280
+ * Sequential streaming fallback for small frame counts where worker
2281
+ * thread overhead would exceed the parallelism benefit.
2282
+ */
2283
+ async *streamSequential(frames, contexts) {
2284
+ for (let i = 0; i < frames.length; i++) {
2285
+ yield await composeFrame(frames[i], this.effects, this.output, contexts[i]);
2286
+ }
2287
+ }
2288
+ /**
2289
+ * Pre-compute [startIdx, endIdx] windows for every fade transition so that
2290
+ * applyTransitionsToStream can buffer only those frames.
2291
+ */
2292
+ getTransitionWindows(frames) {
2293
+ if (this.steps.length === 0) return [];
2294
+ const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
2295
+ const windows = [];
2296
+ for (let i = 1; i < frames.length; i++) {
2297
+ if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
2298
+ const stepIdx = frames[i].stepIndex;
2299
+ const step = this.steps[stepIdx];
2300
+ if (step && step.transition === "fade") {
2301
+ const startIdx = Math.max(0, i - Math.floor(transitionFrames / 2));
2302
+ const endIdx = Math.min(frames.length - 1, i + Math.ceil(transitionFrames / 2));
2303
+ if (endIdx - startIdx >= 2) {
2304
+ windows.push({ startIdx, endIdx });
2305
+ }
2306
+ }
2307
+ }
2308
+ }
2309
+ return windows;
2310
+ }
2311
+ /**
2312
+ * Wrap a ComposedFrame stream with inline transition buffering.
2313
+ *
2314
+ * Non-transition frames are yielded immediately.
2315
+ * Frames inside a fade window are held until both endpoints are available,
2316
+ * then the crossfade is applied and all window frames are flushed in order.
2317
+ * A pending map maintains global display order across window boundaries.
2318
+ */
2319
+ async *applyTransitionsToStream(source, windows) {
2320
+ if (windows.length === 0) {
2321
+ yield* source;
2322
+ return;
2323
+ }
2324
+ const frameToWindow = /* @__PURE__ */ new Map();
2325
+ for (let wi = 0; wi < windows.length; wi++) {
2326
+ for (let i = windows[wi].startIdx; i <= windows[wi].endIdx; i++) {
2327
+ frameToWindow.set(i, wi);
2328
+ }
2329
+ }
2330
+ const windowState = windows.map((w) => ({
2331
+ frames: new Array(w.endIdx - w.startIdx + 1),
2332
+ received: 0
2333
+ }));
2334
+ const pending = /* @__PURE__ */ new Map();
2335
+ let nextToYield = 0;
2336
+ let frameIdx = 0;
2337
+ for await (const frame of source) {
2338
+ const idx = frameIdx++;
2339
+ const wi = frameToWindow.get(idx);
2340
+ if (wi === void 0) {
2341
+ pending.set(idx, frame);
2342
+ } else {
2343
+ const win = windows[wi];
2344
+ const state = windowState[wi];
2345
+ state.frames[idx - win.startIdx] = frame;
2346
+ state.received++;
2347
+ if (state.received === state.frames.length) {
2348
+ const fromBuf = state.frames[0].buffer;
2349
+ const toBuf = state.frames[state.frames.length - 1].buffer;
2350
+ const range = state.frames.length - 1;
2351
+ const fromRawInfo = state.frames[0].rawInfo;
2352
+ const toRawInfo = state.frames[state.frames.length - 1].rawInfo;
2353
+ for (let j = 1; j < state.frames.length - 1; j++) {
2354
+ const blended = await applyCrossfade(
2355
+ fromBuf,
2356
+ toBuf,
2357
+ j / range,
2358
+ this.output.width,
2359
+ this.output.height,
2360
+ fromRawInfo,
2361
+ toRawInfo
2362
+ );
2363
+ state.frames[j] = {
2364
+ ...state.frames[j],
2365
+ buffer: blended.buffer,
2366
+ rawInfo: blended.rawInfo
2367
+ };
2368
+ }
2369
+ for (let j = 0; j < state.frames.length; j++) {
2370
+ pending.set(win.startIdx + j, state.frames[j]);
2371
+ }
2372
+ }
2373
+ }
2374
+ while (pending.has(nextToYield)) {
2375
+ yield pending.get(nextToYield);
2376
+ pending.delete(nextToYield);
2377
+ nextToYield++;
2378
+ }
2379
+ }
2380
+ while (pending.has(nextToYield)) {
2381
+ yield pending.get(nextToYield);
2382
+ pending.delete(nextToYield);
2383
+ nextToYield++;
2384
+ }
2385
+ }
1769
2386
  /**
1770
2387
  * Apply crossfade transitions at step boundaries where configured.
1771
2388
  */
@@ -1788,15 +2405,21 @@ var CanvasRenderer = class {
1788
2405
  if (range < 2) continue;
1789
2406
  const fromBuffer = composed[startIdx].buffer;
1790
2407
  const toBuffer = composed[endIdx].buffer;
2408
+ const fromRawInfo = composed[startIdx].rawInfo;
2409
+ const toRawInfo = composed[endIdx].rawInfo;
1791
2410
  for (let i = startIdx + 1; i < endIdx; i++) {
1792
2411
  const progress = (i - startIdx) / range;
1793
- composed[i].buffer = await applyCrossfade(
2412
+ const blended = await applyCrossfade(
1794
2413
  fromBuffer,
1795
2414
  toBuffer,
1796
2415
  progress,
1797
2416
  this.output.width,
1798
- this.output.height
2417
+ this.output.height,
2418
+ fromRawInfo,
2419
+ toRawInfo
1799
2420
  );
2421
+ composed[i].buffer = blended.buffer;
2422
+ composed[i].rawInfo = blended.rawInfo;
1800
2423
  }
1801
2424
  }
1802
2425
  }
@@ -1853,7 +2476,8 @@ async function encodeGif(frames, config) {
1853
2476
  const gif = GIFEncoder();
1854
2477
  const delay = Math.round(1e3 / config.fps);
1855
2478
  for (const frame of frames) {
1856
- const { data, info } = await sharp9(frame.buffer).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
2479
+ const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
2480
+ const { data, info } = await src.resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1857
2481
  const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1858
2482
  const palette = quantize(rgba, 256);
1859
2483
  const indexed = applyPalette(rgba, palette);
@@ -1862,22 +2486,19 @@ async function encodeGif(frames, config) {
1862
2486
  gif.finish();
1863
2487
  return Buffer.from(gif.bytes());
1864
2488
  }
1865
- async function encodeMp4(frames, config) {
1866
- if (frames.length === 0) {
1867
- throw new Error("Cannot encode MP4: no frames provided");
1868
- }
2489
+ async function encodeMp4Stream(frames, config) {
1869
2490
  const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
1870
2491
  try {
1871
2492
  const encoder = await detectVideoEncoder();
1872
2493
  const params = resolveEncodingParams(config);
1873
- await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
2494
+ await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath);
1874
2495
  return await readFile2(outputPath);
1875
2496
  } finally {
1876
2497
  await rm(outputPath, { force: true }).catch(() => {
1877
2498
  });
1878
2499
  }
1879
2500
  }
1880
- async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
2501
+ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
1881
2502
  const videoArgs = encoder === "hevc_videotoolbox" ? [
1882
2503
  "-c:v",
1883
2504
  "hevc_videotoolbox",
@@ -1887,7 +2508,6 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1887
2508
  "yuv420p",
1888
2509
  "-tag:v",
1889
2510
  "hvc1"
1890
- // required for playback in QuickTime / Apple devices
1891
2511
  ] : encoder === "h264_videotoolbox" ? [
1892
2512
  "-c:v",
1893
2513
  "h264_videotoolbox",
@@ -1916,7 +2536,6 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1916
2536
  "ffmpeg",
1917
2537
  [
1918
2538
  "-y",
1919
- // Video input: raw RGB24 from stdin
1920
2539
  "-f",
1921
2540
  "rawvideo",
1922
2541
  "-pixel_format",
@@ -1927,7 +2546,6 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1927
2546
  String(config.fps),
1928
2547
  "-i",
1929
2548
  "pipe:0",
1930
- // Silent audio track for platform compatibility
1931
2549
  "-f",
1932
2550
  "lavfi",
1933
2551
  "-i",
@@ -1970,8 +2588,9 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1970
2588
  }
1971
2589
  });
1972
2590
  (async () => {
1973
- for (const frame of frames) {
1974
- const raw = await sharp9(frame.buffer).flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
2591
+ for await (const frame of frames) {
2592
+ const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
2593
+ const raw = await src.flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
1975
2594
  if (!ffmpeg.stdin.write(raw)) {
1976
2595
  await new Promise((r) => ffmpeg.stdin.once("drain", r));
1977
2596
  }
@@ -1999,6 +2618,76 @@ async function savePngSequence(frames, config) {
1999
2618
  return paths;
2000
2619
  }
2001
2620
 
2621
+ // src/compose/streaming-session.ts
2622
+ import { EventEmitter } from "events";
2623
+ var ConcurrentSession = class extends EventEmitter {
2624
+ constructor(recorder, scenario, renderer) {
2625
+ super();
2626
+ this.recorder = recorder;
2627
+ this.scenario = scenario;
2628
+ this.renderer = renderer;
2629
+ }
2630
+ /**
2631
+ * Start recording and compositing concurrently.
2632
+ * Returns when both recording and encoding are complete.
2633
+ */
2634
+ async run() {
2635
+ const handle = this.recorder.recordToChannel(this.scenario);
2636
+ let composed = 0;
2637
+ const self = this;
2638
+ const buffer = await encodeMp4Stream(
2639
+ (async function* () {
2640
+ for await (const frame of self.renderer.composeStreamOnline(handle.frameStream)) {
2641
+ composed++;
2642
+ self.emit("progress", { composed, total: -1, pct: -1 });
2643
+ yield frame;
2644
+ }
2645
+ })(),
2646
+ this.scenario.output
2647
+ );
2648
+ const session = await handle.done;
2649
+ this.emit("progress", { composed, total: composed, pct: 100 });
2650
+ return { buffer, session };
2651
+ }
2652
+ };
2653
+ var StreamingSession = class extends EventEmitter {
2654
+ constructor(session, renderer) {
2655
+ super();
2656
+ this.session = session;
2657
+ this.renderer = renderer;
2658
+ }
2659
+ /** Total frames in the underlying recording session. */
2660
+ get totalFrames() {
2661
+ return this.session.frames.length;
2662
+ }
2663
+ /**
2664
+ * Run the compose → encode pipeline.
2665
+ *
2666
+ * Composes frames via the worker pool (Phase 1-B streaming, ordered yield),
2667
+ * forwarding each to FFmpeg as it completes. Emits a 'progress' event after
2668
+ * every composed frame so callers can update a spinner or progress bar.
2669
+ *
2670
+ * @returns The fully-encoded MP4 as a Buffer.
2671
+ */
2672
+ async run() {
2673
+ const { frames, scenario } = this.session;
2674
+ const total = frames.length;
2675
+ let composed = 0;
2676
+ const self = this;
2677
+ return encodeMp4Stream(
2678
+ (async function* () {
2679
+ for await (const frame of self.renderer.composeStream(frames)) {
2680
+ composed++;
2681
+ const pct = total > 0 ? Math.round(composed / total * 100) : 100;
2682
+ self.emit("progress", { composed, total, pct });
2683
+ yield frame;
2684
+ }
2685
+ })(),
2686
+ scenario.output
2687
+ );
2688
+ }
2689
+ };
2690
+
2002
2691
  // src/cli/index.ts
2003
2692
  import { writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
2004
2693
  import { join as join2, resolve, dirname } from "path";
@@ -2063,72 +2752,91 @@ program.command("record").description("Record a demo from a YAML scenario file")
2063
2752
  process.exit(1);
2064
2753
  }
2065
2754
  }
2066
- spinner.start(
2067
- `Recording ${scenario.steps.length} steps...`
2068
- );
2755
+ await mkdir2(options.output, { recursive: true });
2069
2756
  const recorder = new ClipwiseRecorder();
2070
- const session = await recorder.record(scenario);
2071
- spinner.succeed(
2072
- `Recorded ${session.frames.length} frames`
2757
+ const renderer = new CanvasRenderer(
2758
+ scenario.effects,
2759
+ scenario.output,
2760
+ scenario.steps
2073
2761
  );
2074
- let composedFrames;
2075
- if (options.effects !== false) {
2076
- spinner.start(`Applying effects to ${session.frames.length} frames...`);
2077
- const renderer = new CanvasRenderer(
2078
- scenario.effects,
2079
- scenario.output,
2080
- scenario.steps
2081
- );
2082
- composedFrames = await renderer.composeAll(session.frames);
2083
- spinner.succeed("Effects applied");
2084
- } else {
2085
- composedFrames = session.frames.map((f) => ({
2086
- index: f.index,
2087
- buffer: f.screenshot,
2088
- timestamp: f.timestamp
2089
- }));
2090
- spinner.info("Effects disabled, using raw frames");
2091
- }
2092
- await mkdir2(options.output, { recursive: true });
2093
- if (scenario.output.format === "png-sequence") {
2094
- spinner.start("Saving PNG sequence...");
2095
- const paths = await savePngSequence(
2096
- composedFrames,
2097
- scenario.output
2098
- );
2099
- spinner.succeed(
2100
- `Saved ${paths.length} frames to ${chalk.bold(options.output)}`
2101
- );
2102
- } else if (scenario.output.format === "mp4") {
2103
- spinner.start("Encoding MP4...");
2104
- const mp4Buffer = await encodeMp4(
2105
- composedFrames,
2106
- scenario.output
2107
- );
2108
- const outputPath = join2(
2109
- options.output,
2110
- `${scenario.output.filename}.mp4`
2111
- );
2762
+ const isConcurrentEligible = scenario.output.format === "mp4" && options.effects !== false && renderer.canStreamOnline();
2763
+ if (isConcurrentEligible) {
2764
+ const pipeline = new ConcurrentSession(recorder, scenario, renderer);
2765
+ pipeline.on("progress", ({ composed, total, pct }) => {
2766
+ spinner.text = total > 0 ? `Recording & composing... ${composed}/${total} (${pct}%)` : `Recording & composing... ${composed} frames`;
2767
+ });
2768
+ spinner.start(`Recording & composing ${scenario.steps.length} steps concurrently...`);
2769
+ const { buffer: mp4Buffer, session } = await pipeline.run();
2770
+ const outputPath = join2(options.output, `${scenario.output.filename}.mp4`);
2112
2771
  await writeFile2(outputPath, mp4Buffer);
2113
2772
  const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
2114
2773
  spinner.succeed(
2115
- `MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
2774
+ `MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB, ${session.frames.length} frames)`
2116
2775
  );
2117
2776
  } else {
2118
- spinner.start("Encoding GIF...");
2119
- const gifBuffer = await encodeGif(
2120
- composedFrames,
2121
- scenario.output
2122
- );
2123
- const outputPath = join2(
2124
- options.output,
2125
- `${scenario.output.filename}.gif`
2126
- );
2127
- await writeFile2(outputPath, gifBuffer);
2128
- const sizeMB = (gifBuffer.length / (1024 * 1024)).toFixed(2);
2129
- spinner.succeed(
2130
- `GIF saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
2131
- );
2777
+ spinner.start(`Recording ${scenario.steps.length} steps...`);
2778
+ const session = await recorder.record(scenario);
2779
+ spinner.succeed(`Recorded ${session.frames.length} frames`);
2780
+ if (scenario.output.format === "png-sequence") {
2781
+ let composedFrames;
2782
+ if (options.effects !== false) {
2783
+ spinner.start(`Applying effects to ${session.frames.length} frames...`);
2784
+ composedFrames = await renderer.composeAll(session.frames);
2785
+ spinner.succeed("Effects applied");
2786
+ } else {
2787
+ composedFrames = session.frames.map((f) => ({
2788
+ index: f.index,
2789
+ buffer: f.screenshot,
2790
+ timestamp: f.timestamp
2791
+ }));
2792
+ spinner.info("Effects disabled, using raw frames");
2793
+ }
2794
+ spinner.start("Saving PNG sequence...");
2795
+ const paths = await savePngSequence(composedFrames, scenario.output);
2796
+ spinner.succeed(`Saved ${paths.length} frames to ${chalk.bold(options.output)}`);
2797
+ } else if (scenario.output.format === "mp4") {
2798
+ const outputPath = join2(options.output, `${scenario.output.filename}.mp4`);
2799
+ let mp4Buffer;
2800
+ if (options.effects === false) {
2801
+ spinner.start(`Encoding ${session.frames.length} raw frames...`);
2802
+ const rawStream = (async function* () {
2803
+ for (const f of session.frames) {
2804
+ yield { index: f.index, buffer: f.screenshot, timestamp: f.timestamp };
2805
+ }
2806
+ })();
2807
+ mp4Buffer = await encodeMp4Stream(rawStream, scenario.output);
2808
+ } else {
2809
+ const pipeline = new StreamingSession(session, renderer);
2810
+ pipeline.on("progress", ({ composed, total, pct }) => {
2811
+ spinner.text = `Composing & encoding... ${composed}/${total} (${pct}%)`;
2812
+ });
2813
+ spinner.start(`Composing & encoding ${session.frames.length} frames...`);
2814
+ mp4Buffer = await pipeline.run();
2815
+ }
2816
+ await writeFile2(outputPath, mp4Buffer);
2817
+ const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
2818
+ spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`);
2819
+ } else {
2820
+ let composedFrames;
2821
+ if (options.effects !== false) {
2822
+ spinner.start(`Applying effects to ${session.frames.length} frames...`);
2823
+ composedFrames = await renderer.composeAll(session.frames);
2824
+ spinner.succeed("Effects applied");
2825
+ } else {
2826
+ composedFrames = session.frames.map((f) => ({
2827
+ index: f.index,
2828
+ buffer: f.screenshot,
2829
+ timestamp: f.timestamp
2830
+ }));
2831
+ spinner.info("Effects disabled, using raw frames");
2832
+ }
2833
+ spinner.start("Encoding GIF...");
2834
+ const gifBuffer = await encodeGif(composedFrames, scenario.output);
2835
+ const outputPath = join2(options.output, `${scenario.output.filename}.gif`);
2836
+ await writeFile2(outputPath, gifBuffer);
2837
+ const sizeMB = (gifBuffer.length / (1024 * 1024)).toFixed(2);
2838
+ spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`);
2839
+ }
2132
2840
  }
2133
2841
  console.log(chalk.green("\nDone! \u{1F3AC}"));
2134
2842
  } catch (error) {
@@ -2411,27 +3119,44 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
2411
3119
  process.exit(1);
2412
3120
  }
2413
3121
  }
2414
- spinner.start(`Recording ${scenario.steps.length} steps...`);
2415
- const recorder = new ClipwiseRecorder();
2416
- const session = await recorder.record(scenario);
2417
- spinner.succeed(`Recorded ${session.frames.length} frames`);
2418
- spinner.start(`Applying effects to ${session.frames.length} frames...`);
2419
- const renderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
2420
- const composedFrames = await renderer.composeAll(session.frames);
2421
- spinner.succeed("Effects applied");
2422
3122
  await mkdir2(options.output, { recursive: true });
3123
+ const demoRenderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
2423
3124
  const ext = scenario.output.format === "gif" ? "gif" : "mp4";
2424
3125
  const outputPath = join2(options.output, `clipwise-demo-${device}.${ext}`);
2425
- if (ext === "gif") {
2426
- spinner.start("Encoding GIF...");
2427
- const buf = await encodeGif(composedFrames, scenario.output);
3126
+ const isConcurrentEligible = ext === "mp4" && demoRenderer.canStreamOnline();
3127
+ if (isConcurrentEligible) {
3128
+ const recorder = new ClipwiseRecorder();
3129
+ const concPipeline = new ConcurrentSession(recorder, scenario, demoRenderer);
3130
+ concPipeline.on("progress", ({ composed, total, pct }) => {
3131
+ spinner.text = total > 0 ? `Recording & composing... ${composed}/${total} (${pct}%)` : `Recording & composing... ${composed} frames`;
3132
+ });
3133
+ spinner.start(`Recording & composing ${scenario.steps.length} steps concurrently...`);
3134
+ const { buffer: buf, session } = await concPipeline.run();
2428
3135
  await writeFile2(outputPath, buf);
2429
- spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
3136
+ spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB, ${session.frames.length} frames)`);
2430
3137
  } else {
2431
- spinner.start("Encoding MP4...");
2432
- const buf = await encodeMp4(composedFrames, scenario.output);
2433
- await writeFile2(outputPath, buf);
2434
- spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
3138
+ spinner.start(`Recording ${scenario.steps.length} steps...`);
3139
+ const recorder = new ClipwiseRecorder();
3140
+ const session = await recorder.record(scenario);
3141
+ spinner.succeed(`Recorded ${session.frames.length} frames`);
3142
+ if (ext === "gif") {
3143
+ spinner.start(`Applying effects to ${session.frames.length} frames...`);
3144
+ const composedFrames = await demoRenderer.composeAll(session.frames);
3145
+ spinner.succeed("Effects applied");
3146
+ spinner.start("Encoding GIF...");
3147
+ const buf = await encodeGif(composedFrames, scenario.output);
3148
+ await writeFile2(outputPath, buf);
3149
+ spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
3150
+ } else {
3151
+ const pipeline = new StreamingSession(session, demoRenderer);
3152
+ pipeline.on("progress", ({ composed, total, pct }) => {
3153
+ spinner.text = `Composing & encoding... ${composed}/${total} (${pct}%)`;
3154
+ });
3155
+ spinner.start(`Composing & encoding ${session.frames.length} frames...`);
3156
+ const buf = await pipeline.run();
3157
+ await writeFile2(outputPath, buf);
3158
+ spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
3159
+ }
2435
3160
  }
2436
3161
  console.log(chalk.green("\nDemo complete! \u{1F3AC}"));
2437
3162
  } catch (error) {