clipwise 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -178,8 +178,13 @@ var init_types = __esm({
178
178
  format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
179
179
  width: z.number().default(1280),
180
180
  height: z.number().default(800),
181
- fps: z.number().min(1).max(60).default(15),
181
+ fps: z.number().min(1).max(60).default(30),
182
182
  quality: z.number().min(1).max(100).default(80),
183
+ // Encoding preset for MP4 output. Overrides quality when set.
184
+ // social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
185
+ // balanced — general-purpose, good quality/size trade-off (CRF 20)
186
+ // archive — high-fidelity storage, larger file (CRF 15)
187
+ preset: z.enum(["social", "balanced", "archive"]).optional(),
183
188
  outputDir: z.string().default("./output"),
184
189
  filename: z.string().default("clipwise-recording")
185
190
  });
@@ -386,16 +391,45 @@ async function getElementCenter(page, selector, timeout) {
386
391
 
387
392
  // src/core/recorder.ts
388
393
  var CLICK_EFFECT_DURATION_MS = 500;
389
- var REPAINT_INTERVAL_MS = 50;
394
+ var REPAINT_INTERVAL_MS = 25;
390
395
  var ACTION_GAP_MS = 30;
391
396
  var CURSOR_SPEED_PRESETS = {
392
- fast: { steps: 12, delay: 6 },
393
- // ~72ms total
394
- normal: { steps: 18, delay: 8 },
395
- // ~144ms total
396
- slow: { steps: 24, delay: 12 }
397
- // ~288ms total
397
+ fast: { steps: 10, delay: 22 },
398
+ // ~220ms, ~9 frames captured
399
+ normal: { steps: 14, delay: 25 },
400
+ // ~350ms, ~14 frames captured
401
+ slow: { steps: 20, delay: 25 }
402
+ // ~500ms, ~20 frames captured
398
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;
399
433
  var ClipwiseRecorder = class {
400
434
  browser = null;
401
435
  context = null;
@@ -408,11 +442,21 @@ var ClipwiseRecorder = class {
408
442
  currentStepIndex = 0;
409
443
  cursorPosition = { x: 0, y: 0 };
410
444
  viewport = { width: 1280, height: 800 };
445
+ deviceScaleFactor = 1;
411
446
  isCapturing = false;
412
447
  targetFps = 30;
413
448
  cursorSpeed = "fast";
414
449
  firstContentTimestamp = 0;
415
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
416
460
  /**
417
461
  * Launch the browser and create a page with the scenario viewport.
418
462
  */
@@ -436,6 +480,10 @@ var ClipwiseRecorder = class {
436
480
  this.cursorPosition = { x: 0, y: 0 };
437
481
  this.isCapturing = false;
438
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;
439
487
  }
440
488
  /**
441
489
  * Start CDP screencast for continuous frame capture.
@@ -450,10 +498,24 @@ var ClipwiseRecorder = class {
450
498
  async (event) => {
451
499
  if (!this.isCapturing || !this.cdpClient) return;
452
500
  const buffer = Buffer.from(event.data, "base64");
453
- this.rawFrames.push({
454
- buffer,
455
- timestamp: Date.now()
456
- });
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
+ }
457
519
  await this.cdpClient.send("Page.screencastFrameAck", {
458
520
  sessionId: event.sessionId
459
521
  }).catch(() => {
@@ -461,10 +523,9 @@ var ClipwiseRecorder = class {
461
523
  }
462
524
  );
463
525
  await this.cdpClient.send("Page.startScreencast", {
464
- format: "jpeg",
465
- quality: 95,
466
- maxWidth: this.viewport.width,
467
- maxHeight: this.viewport.height,
526
+ format: "png",
527
+ maxWidth: this.viewport.width * this.deviceScaleFactor,
528
+ maxHeight: this.viewport.height * this.deviceScaleFactor,
468
529
  everyNthFrame: 1
469
530
  });
470
531
  this.cursorTimeline.push({
@@ -493,13 +554,23 @@ var ClipwiseRecorder = class {
493
554
  await this.init(scenario);
494
555
  const startTime = Date.now();
495
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
+ }
496
565
  await this.startCapture();
497
566
  for (let si = 0; si < scenario.steps.length; si++) {
498
567
  const step = scenario.steps[si];
499
568
  this.currentStepIndex = si;
500
- this.preRegisterResponseListeners(step.actions);
501
- for (let ai = 0; ai < step.actions.length; ai++) {
502
- 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
+ }
503
574
  }
504
575
  if (step.captureDelay > 0) {
505
576
  await this.waitWithRepaints(step.captureDelay);
@@ -520,7 +591,8 @@ var ClipwiseRecorder = class {
520
591
  scenario,
521
592
  frames,
522
593
  startTime,
523
- endTime: Date.now()
594
+ endTime: Date.now(),
595
+ dedupStats: { ...this.dedupStats }
524
596
  };
525
597
  } catch (error) {
526
598
  await this.stopCapture().catch(() => {
@@ -536,13 +608,116 @@ var ClipwiseRecorder = class {
536
608
  scenario,
537
609
  frames,
538
610
  startTime,
539
- endTime: Date.now()
611
+ endTime: Date.now(),
612
+ dedupStats: { ...this.dedupStats }
540
613
  };
541
614
  throw err;
542
615
  } finally {
543
616
  await this.cleanup();
544
617
  }
545
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
+ }
546
721
  /**
547
722
  * Wait for a given duration while forcing periodic repaints
548
723
  * so CDP screencast keeps sending frames even on static pages.
@@ -778,8 +953,10 @@ var ClipwiseRecorder = class {
778
953
  clickPosition: clickEvent?.position ?? null,
779
954
  clickProgress,
780
955
  viewport: { ...this.viewport },
956
+ deviceScaleFactor: this.deviceScaleFactor,
781
957
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
782
- stepIndex: this.currentStepIndex
958
+ stepIndex: raw.stepIndex
959
+ // use per-frame step index captured at event time
783
960
  };
784
961
  });
785
962
  }
@@ -806,15 +983,9 @@ var ClipwiseRecorder = class {
806
983
  for (let i = 0; i < targetFrameCount; i++) {
807
984
  const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
808
985
  const targetTimestamp = startTime + t * duration;
809
- let nearestIdx = 0;
810
- let minDist = Infinity;
811
- for (let j = 0; j < frames.length; j++) {
812
- const dist = Math.abs(frames[j].timestamp - targetTimestamp);
813
- if (dist < minDist) {
814
- minDist = dist;
815
- nearestIdx = j;
816
- }
817
- }
986
+ const lo = this.binarySearchTimeline(frames, targetTimestamp);
987
+ const hi = Math.min(lo + 1, frames.length - 1);
988
+ const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
818
989
  const cursorPos = this.interpolateCursorAt(targetTimestamp);
819
990
  const clickEvent = this.clickTimeline.find(
820
991
  (click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
@@ -835,6 +1006,7 @@ var ClipwiseRecorder = class {
835
1006
  clickPosition: clickEvent?.position ?? null,
836
1007
  clickProgress,
837
1008
  viewport: { ...this.viewport },
1009
+ deviceScaleFactor: this.deviceScaleFactor,
838
1010
  stepName: frames[nearestIdx].stepName,
839
1011
  stepIndex: frames[nearestIdx].stepIndex,
840
1012
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
@@ -850,15 +1022,9 @@ var ClipwiseRecorder = class {
850
1022
  if (this.cursorTimeline.length === 1) {
851
1023
  return { ...this.cursorTimeline[0].position };
852
1024
  }
853
- let before = this.cursorTimeline[0];
854
- let after = this.cursorTimeline[this.cursorTimeline.length - 1];
855
- for (let i = 0; i < this.cursorTimeline.length - 1; i++) {
856
- if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
857
- before = this.cursorTimeline[i];
858
- after = this.cursorTimeline[i + 1];
859
- break;
860
- }
861
- }
1025
+ const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
1026
+ const before = this.cursorTimeline[idx];
1027
+ const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
862
1028
  if (timestamp <= before.timestamp) return { ...before.position };
863
1029
  if (timestamp >= after.timestamp) return { ...after.position };
864
1030
  const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
@@ -871,6 +1037,23 @@ var ClipwiseRecorder = class {
871
1037
  )
872
1038
  };
873
1039
  }
1040
+ /**
1041
+ * Binary search: returns the index of the last entry whose timestamp <= target.
1042
+ * Assumes the array is sorted by timestamp in ascending order.
1043
+ */
1044
+ binarySearchTimeline(timeline, target) {
1045
+ let lo = 0;
1046
+ let hi = timeline.length - 1;
1047
+ while (lo < hi) {
1048
+ const mid = lo + hi + 1 >> 1;
1049
+ if (timeline[mid].timestamp <= target) {
1050
+ lo = mid;
1051
+ } else {
1052
+ hi = mid - 1;
1053
+ }
1054
+ }
1055
+ return lo;
1056
+ }
874
1057
  /**
875
1058
  * Clean up browser resources. Always called after recording.
876
1059
  */
@@ -895,7 +1078,13 @@ var ClipwiseRecorder = class {
895
1078
  };
896
1079
 
897
1080
  // src/compose/canvas-renderer.ts
898
- import sharp8 from "sharp";
1081
+ import { Worker } from "worker_threads";
1082
+ import os from "os";
1083
+ import { existsSync } from "fs";
1084
+ import { fileURLToPath } from "url";
1085
+
1086
+ // src/compose/compose-frame.ts
1087
+ import sharp7 from "sharp";
899
1088
 
900
1089
  // src/effects/frame.ts
901
1090
  import sharp from "sharp";
@@ -918,91 +1107,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
918
1107
  var ANDROID_OUTER_RADIUS = 35;
919
1108
  var ANDROID_INNER_RADIUS = 30;
920
1109
  var ANDROID_CAMERA_RADIUS = 6;
921
- function buildBrowserChromeSvg(width, darkMode) {
1110
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
922
1111
  const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
923
1112
  const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
924
1113
  const addressBorder = darkMode ? "#444444" : "#d0d0d0";
925
1114
  const textColor = darkMode ? "#999999" : "#666666";
1115
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
1116
+ const tlY = TRAFFIC_LIGHT_Y * dpr;
1117
+ const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
1118
+ const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
1119
+ const tlGap = TRAFFIC_LIGHT_GAP * dpr;
1120
+ const aBarH = ADDRESS_BAR_HEIGHT * dpr;
1121
+ const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
1122
+ const fontSize = 12 * dpr;
926
1123
  const trafficLights = [
927
- { cx: TRAFFIC_LIGHTS_START_X, fill: "#ff5f57" },
928
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP, fill: "#febc2e" },
929
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP * 2, fill: "#28c840" }
1124
+ { cx: tlStartX, fill: "#ff5f57" },
1125
+ { cx: tlStartX + tlGap, fill: "#febc2e" },
1126
+ { cx: tlStartX + tlGap * 2, fill: "#28c840" }
930
1127
  ].map(
931
- (light) => `<circle cx="${light.cx}" cy="${TRAFFIC_LIGHT_Y}" r="${TRAFFIC_LIGHT_RADIUS}" fill="${light.fill}"/>`
1128
+ (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
932
1129
  ).join("\n ");
933
- const addressBarWidth = width - ADDRESS_BAR_MARGIN * 2;
934
- const addressBarX = ADDRESS_BAR_MARGIN;
935
- const addressBarY = (TITLE_BAR_HEIGHT - ADDRESS_BAR_HEIGHT) / 2;
936
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${TITLE_BAR_HEIGHT}">
937
- <rect width="${width}" height="${TITLE_BAR_HEIGHT}" fill="${bg}"/>
1130
+ const addressBarWidth = width - aBarMargin * 2;
1131
+ const addressBarX = aBarMargin;
1132
+ const addressBarY = (tbarH - aBarH) / 2;
1133
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
1134
+ <rect width="${width}" height="${tbarH}" fill="${bg}"/>
938
1135
  ${trafficLights}
939
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${ADDRESS_BAR_HEIGHT}"
940
- rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="1"/>
941
- <text x="${width / 2}" y="${TRAFFIC_LIGHT_Y + 4}" text-anchor="middle"
942
- font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="${textColor}">
1136
+ <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
1137
+ rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
1138
+ <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
1139
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
943
1140
  localhost
944
1141
  </text>
945
1142
  </svg>`;
946
1143
  }
947
- function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
1144
+ function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
948
1145
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
949
1146
  const islandColor = darkMode ? "#000000" : "#1a1a1a";
950
1147
  const homeBarColor = darkMode ? "#555555" : "#333333";
951
- const islandX = (totalWidth - IPHONE_ISLAND.width) / 2;
952
- const islandY = (IPHONE_BEZEL.top - IPHONE_ISLAND.height) / 2 + 4;
953
- const homeBarX = (totalWidth - IPHONE_HOME_BAR.width) / 2;
954
- const homeBarY = totalHeight - IPHONE_BEZEL.bottom / 2 - IPHONE_HOME_BAR.height / 2;
955
- const screenX = IPHONE_BEZEL.sides;
956
- const screenY = IPHONE_BEZEL.top;
1148
+ const bezelTop = IPHONE_BEZEL.top * dpr;
1149
+ const bezelBottom = IPHONE_BEZEL.bottom * dpr;
1150
+ const bezelSides = IPHONE_BEZEL.sides * dpr;
1151
+ const outerRadius = IPHONE_OUTER_RADIUS * dpr;
1152
+ const innerRadius = IPHONE_INNER_RADIUS * dpr;
1153
+ const islandW = IPHONE_ISLAND.width * dpr;
1154
+ const islandH = IPHONE_ISLAND.height * dpr;
1155
+ const homeBarW = IPHONE_HOME_BAR.width * dpr;
1156
+ const homeBarH = IPHONE_HOME_BAR.height * dpr;
1157
+ const islandX = (totalWidth - islandW) / 2;
1158
+ const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
1159
+ const homeBarX = (totalWidth - homeBarW) / 2;
1160
+ const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
1161
+ const screenX = bezelSides;
1162
+ const screenY = bezelTop;
957
1163
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
958
1164
  <!-- Device body -->
959
1165
  <rect width="${totalWidth}" height="${totalHeight}"
960
- rx="${IPHONE_OUTER_RADIUS}" ry="${IPHONE_OUTER_RADIUS}" fill="${bezelColor}"/>
1166
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
961
1167
  <!-- Screen cutout (transparent) -->
962
1168
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
963
- rx="${IPHONE_INNER_RADIUS}" ry="${IPHONE_INNER_RADIUS}" fill="black"/>
1169
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
964
1170
  <!-- Dynamic Island pill -->
965
- <rect x="${islandX}" y="${islandY}" width="${IPHONE_ISLAND.width}" height="${IPHONE_ISLAND.height}"
966
- rx="${IPHONE_ISLAND.height / 2}" ry="${IPHONE_ISLAND.height / 2}" fill="${islandColor}"/>
1171
+ <rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
1172
+ rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
967
1173
  <!-- Home indicator bar -->
968
- <rect x="${homeBarX}" y="${homeBarY}" width="${IPHONE_HOME_BAR.width}" height="${IPHONE_HOME_BAR.height}"
969
- rx="${IPHONE_HOME_BAR.height / 2}" ry="${IPHONE_HOME_BAR.height / 2}" fill="${homeBarColor}"/>
1174
+ <rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
1175
+ rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
970
1176
  </svg>`;
971
1177
  }
972
- function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
1178
+ function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
973
1179
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
974
1180
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
975
- const screenX = IPAD_BEZEL.sides;
976
- const screenY = IPAD_BEZEL.top;
1181
+ const screenX = IPAD_BEZEL.sides * dpr;
1182
+ const screenY = IPAD_BEZEL.top * dpr;
977
1183
  const cameraCx = totalWidth / 2;
978
- const cameraCy = IPAD_BEZEL.top / 2;
1184
+ const cameraCy = IPAD_BEZEL.top * dpr / 2;
1185
+ const outerRadius = IPAD_OUTER_RADIUS * dpr;
1186
+ const innerRadius = IPAD_INNER_RADIUS * dpr;
979
1187
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
980
1188
  <!-- Device body -->
981
1189
  <rect width="${totalWidth}" height="${totalHeight}"
982
- rx="${IPAD_OUTER_RADIUS}" ry="${IPAD_OUTER_RADIUS}" fill="${bezelColor}"/>
1190
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
983
1191
  <!-- Screen cutout -->
984
1192
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
985
- rx="${IPAD_INNER_RADIUS}" ry="${IPAD_INNER_RADIUS}" fill="black"/>
1193
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
986
1194
  <!-- Front camera dot -->
987
- <circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
1195
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
988
1196
  </svg>`;
989
1197
  }
990
- function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
1198
+ function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
991
1199
  const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
992
1200
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
993
- const screenX = ANDROID_BEZEL.sides;
994
- const screenY = ANDROID_BEZEL.top;
1201
+ const screenX = ANDROID_BEZEL.sides * dpr;
1202
+ const screenY = ANDROID_BEZEL.top * dpr;
995
1203
  const cameraCx = totalWidth / 2;
996
- const cameraCy = ANDROID_BEZEL.top / 2;
1204
+ const cameraCy = ANDROID_BEZEL.top * dpr / 2;
1205
+ const outerRadius = ANDROID_OUTER_RADIUS * dpr;
1206
+ const innerRadius = ANDROID_INNER_RADIUS * dpr;
1207
+ const cameraR = ANDROID_CAMERA_RADIUS * dpr;
997
1208
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
998
1209
  <!-- Device body -->
999
1210
  <rect width="${totalWidth}" height="${totalHeight}"
1000
- rx="${ANDROID_OUTER_RADIUS}" ry="${ANDROID_OUTER_RADIUS}" fill="${bezelColor}"/>
1211
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
1001
1212
  <!-- Screen cutout -->
1002
1213
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
1003
- rx="${ANDROID_INNER_RADIUS}" ry="${ANDROID_INNER_RADIUS}" fill="black"/>
1214
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
1004
1215
  <!-- Punch-hole camera -->
1005
- <circle cx="${cameraCx}" cy="${cameraCy}" r="${ANDROID_CAMERA_RADIUS}" fill="${cameraColor}"/>
1216
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
1006
1217
  </svg>`;
1007
1218
  }
1008
1219
  function buildScreenMaskSvg(width, height, radius) {
@@ -1010,21 +1221,33 @@ function buildScreenMaskSvg(width, height, radius) {
1010
1221
  <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
1011
1222
  </svg>`;
1012
1223
  }
1013
- async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
1224
+ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
1014
1225
  let bezel;
1015
1226
  let innerRadius;
1016
1227
  switch (deviceType) {
1017
1228
  case "iphone":
1018
- bezel = IPHONE_BEZEL;
1019
- innerRadius = IPHONE_INNER_RADIUS;
1229
+ bezel = {
1230
+ sides: IPHONE_BEZEL.sides * dpr,
1231
+ top: IPHONE_BEZEL.top * dpr,
1232
+ bottom: IPHONE_BEZEL.bottom * dpr
1233
+ };
1234
+ innerRadius = IPHONE_INNER_RADIUS * dpr;
1020
1235
  break;
1021
1236
  case "ipad":
1022
- bezel = IPAD_BEZEL;
1023
- innerRadius = IPAD_INNER_RADIUS;
1237
+ bezel = {
1238
+ sides: IPAD_BEZEL.sides * dpr,
1239
+ top: IPAD_BEZEL.top * dpr,
1240
+ bottom: IPAD_BEZEL.bottom * dpr
1241
+ };
1242
+ innerRadius = IPAD_INNER_RADIUS * dpr;
1024
1243
  break;
1025
1244
  case "android":
1026
- bezel = ANDROID_BEZEL;
1027
- innerRadius = ANDROID_INNER_RADIUS;
1245
+ bezel = {
1246
+ sides: ANDROID_BEZEL.sides * dpr,
1247
+ top: ANDROID_BEZEL.top * dpr,
1248
+ bottom: ANDROID_BEZEL.bottom * dpr
1249
+ };
1250
+ innerRadius = ANDROID_INNER_RADIUS * dpr;
1028
1251
  break;
1029
1252
  }
1030
1253
  const totalWidth = frameWidth + bezel.sides * 2;
@@ -1032,13 +1255,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
1032
1255
  let frameSvg;
1033
1256
  switch (deviceType) {
1034
1257
  case "iphone":
1035
- frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
1258
+ frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
1036
1259
  break;
1037
1260
  case "ipad":
1038
- frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
1261
+ frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
1039
1262
  break;
1040
1263
  case "android":
1041
- frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
1264
+ frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
1042
1265
  break;
1043
1266
  }
1044
1267
  const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
@@ -1061,12 +1284,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
1061
1284
  { input: maskedScreen, left: bezel.sides, top: bezel.top }
1062
1285
  ]).png().toBuffer();
1063
1286
  }
1064
- async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
1287
+ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
1065
1288
  if (!config.enabled || config.type === "none") return frameBuffer;
1066
1289
  switch (config.type) {
1067
1290
  case "browser": {
1068
- const totalHeight = frameHeight + TITLE_BAR_HEIGHT;
1069
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode);
1291
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
1292
+ const totalHeight = frameHeight + tbarH;
1293
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
1070
1294
  const chromeBuffer = Buffer.from(chromeSvg);
1071
1295
  const canvas = await sharp({
1072
1296
  create: {
@@ -1078,13 +1302,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
1078
1302
  }).png().toBuffer();
1079
1303
  return sharp(canvas).composite([
1080
1304
  { input: chromeBuffer, left: 0, top: 0 },
1081
- { input: frameBuffer, left: 0, top: TITLE_BAR_HEIGHT }
1305
+ { input: frameBuffer, left: 0, top: tbarH }
1082
1306
  ]).png().toBuffer();
1083
1307
  }
1084
1308
  case "iphone":
1085
1309
  case "ipad":
1086
1310
  case "android":
1087
- return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
1311
+ return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
1088
1312
  default:
1089
1313
  return frameBuffer;
1090
1314
  }
@@ -1094,7 +1318,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
1094
1318
  import sharp2 from "sharp";
1095
1319
  function buildCursorSvg(size, color) {
1096
1320
  const s = size;
1097
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
1321
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
1098
1322
  <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
1099
1323
  fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
1100
1324
  </svg>`;
@@ -1103,7 +1327,7 @@ function buildClickRippleSvg(radius, color, progress) {
1103
1327
  const currentRadius = radius * progress;
1104
1328
  const opacity = Math.max(0, 1 - progress);
1105
1329
  const size = Math.ceil(radius * 2 + 4);
1106
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
1330
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
1107
1331
  <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
1108
1332
  fill="none" stroke="${color}" stroke-width="2"
1109
1333
  opacity="${opacity.toFixed(3)}"/>
@@ -1111,47 +1335,35 @@ function buildClickRippleSvg(radius, color, progress) {
1111
1335
  fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
1112
1336
  </svg>`;
1113
1337
  }
1114
- async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
1338
+ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1115
1339
  if (!config.enabled) return frameBuffer;
1116
- const cursorSvg = buildCursorSvg(config.size, config.color);
1340
+ const size = Math.round(config.size * dpr);
1341
+ const cursorSvg = buildCursorSvg(size, config.color);
1117
1342
  const cursorBuffer = Buffer.from(cursorSvg);
1118
- const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
1119
- const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
1343
+ const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
1344
+ const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
1120
1345
  return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
1121
1346
  }
1122
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
1347
+ async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
1123
1348
  if (!config.enabled || !config.clickEffect) return frameBuffer;
1349
+ const radius = config.clickRadius * dpr;
1124
1350
  const clampedProgress = Math.max(0, Math.min(1, progress));
1125
- const rippleSvg = buildClickRippleSvg(
1126
- config.clickRadius,
1127
- config.clickColor,
1128
- clampedProgress
1129
- );
1351
+ const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
1130
1352
  const rippleBuffer = Buffer.from(rippleSvg);
1131
- const rippleSize = Math.ceil(config.clickRadius * 2 + 4);
1132
- const left = Math.max(
1133
- 0,
1134
- Math.min(
1135
- Math.round(position.x - rippleSize / 2),
1136
- frameWidth - rippleSize
1137
- )
1138
- );
1139
- const top = Math.max(
1140
- 0,
1141
- Math.min(
1142
- Math.round(position.y - rippleSize / 2),
1143
- frameHeight - rippleSize
1144
- )
1145
- );
1353
+ const rippleSize = Math.ceil(radius * 2 + 4);
1354
+ const px = Math.round(position.x * dpr);
1355
+ const py = Math.round(position.y * dpr);
1356
+ const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
1357
+ const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
1146
1358
  return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
1147
1359
  }
1148
- async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
1360
+ async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
1149
1361
  if (!config.enabled || !config.highlight) return frameBuffer;
1150
- const r = config.highlightRadius;
1362
+ const r = config.highlightRadius * dpr;
1151
1363
  const size = Math.ceil(r * 2 + 4);
1152
1364
  const cx = size / 2;
1153
1365
  const cy = size / 2;
1154
- const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
1366
+ const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
1155
1367
  <defs>
1156
1368
  <radialGradient id="glow">
1157
1369
  <stop offset="0%" stop-color="${config.highlightColor}" />
@@ -1161,27 +1373,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
1161
1373
  </defs>
1162
1374
  <circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
1163
1375
  </svg>`;
1164
- const left = Math.max(0, Math.min(Math.round(position.x - cx), frameWidth - size));
1165
- const top = Math.max(0, Math.min(Math.round(position.y - cy), frameHeight - size));
1376
+ const px = Math.round(position.x * dpr);
1377
+ const py = Math.round(position.y * dpr);
1378
+ const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
1379
+ const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
1166
1380
  return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
1167
1381
  }
1168
- async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
1382
+ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
1169
1383
  if (!config.enabled || !config.trail || positions.length < 2) {
1170
1384
  return frameBuffer;
1171
1385
  }
1172
1386
  const segments = [];
1173
1387
  for (let i = 1; i < positions.length; i++) {
1174
1388
  const opacity = i / positions.length * 0.6;
1175
- const strokeWidth = 1 + i / positions.length * 2;
1389
+ const strokeWidth = (1 + i / positions.length * 2) * dpr;
1176
1390
  const p1 = positions[i - 1];
1177
1391
  const p2 = positions[i];
1178
1392
  segments.push(
1179
- `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
1393
+ `<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
1180
1394
  stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
1181
1395
  stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
1182
1396
  );
1183
1397
  }
1184
- const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1398
+ const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
1185
1399
  ${segments.join("\n ")}
1186
1400
  </svg>`;
1187
1401
  return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
@@ -1199,22 +1413,43 @@ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight
1199
1413
  top = Math.max(0, Math.min(top, frameHeight - cropHeight));
1200
1414
  return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
1201
1415
  }
1202
- function calculateAdaptiveZoom(frames, currentIndex, maxScale, transitionFrames) {
1203
- if (maxScale <= 1) return 1;
1204
- let minDistance = Infinity;
1416
+ function buildZoomClickLookup(frames) {
1417
+ const indices = [];
1205
1418
  for (let i = 0; i < frames.length; i++) {
1206
- if (frames[i].clickPosition) {
1207
- const distance = Math.abs(i - currentIndex);
1208
- minDistance = Math.min(minDistance, distance);
1419
+ if (frames[i].clickPosition !== null && frames[i].clickPosition !== void 0) {
1420
+ indices.push(i);
1209
1421
  }
1210
1422
  }
1211
- if (minDistance === Infinity) return 1;
1212
- if (minDistance <= transitionFrames) {
1213
- const t = 1 - minDistance / transitionFrames;
1214
- const eased = easeInOutCubic2(t);
1215
- 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;
1216
1433
  }
1217
- return 1;
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
+ }
1449
+ }
1450
+ if (minDistance > transitionFrames) return 1;
1451
+ const t = 1 - minDistance / transitionFrames;
1452
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
1218
1453
  }
1219
1454
  function easeInOutCubic2(t) {
1220
1455
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
@@ -1314,7 +1549,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
1314
1549
 
1315
1550
  // src/effects/keystroke.ts
1316
1551
  import sharp5 from "sharp";
1317
- async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
1552
+ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
1318
1553
  if (!config.enabled || keystrokes.length === 0) return frameBuffer;
1319
1554
  const recentKeys = keystrokes.filter(
1320
1555
  (k) => frameTimestamp - k.timestamp < config.fadeAfter
@@ -1322,25 +1557,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1322
1557
  if (recentKeys.length === 0) return frameBuffer;
1323
1558
  const displayText = recentKeys.map((k) => k.key).join("");
1324
1559
  if (displayText.length === 0) return frameBuffer;
1325
- const charWidth = config.fontSize * 0.62;
1560
+ const fontSize = config.fontSize * dpr;
1561
+ const padding = config.padding * dpr;
1562
+ const charWidth = fontSize * 0.62;
1326
1563
  const textWidth = Math.ceil(displayText.length * charWidth);
1327
- const hudPadH = config.padding * 2;
1328
- const hudPadV = config.padding * 1.5;
1329
- const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
1330
- const hudHeight = Math.ceil(config.fontSize + hudPadV * 2);
1564
+ const hudPadH = padding * 2;
1565
+ const hudPadV = padding * 1.5;
1566
+ const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
1567
+ const hudHeight = Math.ceil(fontSize + hudPadV * 2);
1331
1568
  const newest = recentKeys[recentKeys.length - 1];
1332
1569
  const age = frameTimestamp - newest.timestamp;
1333
1570
  const fadeStart = config.fadeAfter * 0.6;
1334
1571
  const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1335
1572
  if (opacity <= 0) return frameBuffer;
1573
+ const margin = 30 * dpr;
1336
1574
  let hudX;
1337
- const hudY = frameHeight - hudHeight - 30;
1575
+ const hudY = frameHeight - hudHeight - margin;
1338
1576
  switch (config.position) {
1339
1577
  case "bottom-left":
1340
- hudX = 30;
1578
+ hudX = margin;
1341
1579
  break;
1342
1580
  case "bottom-right":
1343
- hudX = frameWidth - hudWidth - 30;
1581
+ hudX = frameWidth - hudWidth - margin;
1344
1582
  break;
1345
1583
  case "bottom-center":
1346
1584
  default:
@@ -1350,43 +1588,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1350
1588
  const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
1351
1589
  const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
1352
1590
  const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1353
- const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1591
+ const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1354
1592
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
1355
- rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1356
- <text x="${hudX + hudPadH}" y="${hudY + hudPadV + config.fontSize * 0.75}"
1357
- font-family="monospace, Menlo, Consolas" font-size="${config.fontSize}"
1593
+ rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1594
+ <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
1595
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1358
1596
  fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
1359
1597
  </svg>`;
1360
1598
  return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
1361
1599
  }
1362
1600
 
1363
- // src/effects/transition.ts
1364
- import sharp6 from "sharp";
1365
- async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1366
- const t = Math.max(0, Math.min(1, progress));
1367
- if (t <= 0) return fromBuffer;
1368
- if (t >= 1) return toBuffer;
1369
- const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1370
- const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1371
- const pixels = Buffer.alloc(fromRaw.data.length);
1372
- for (let i = 0; i < fromRaw.data.length; i++) {
1373
- pixels[i] = Math.round(
1374
- fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1375
- );
1376
- }
1377
- return sharp6(pixels, {
1378
- raw: {
1379
- width: fromRaw.info.width,
1380
- height: fromRaw.info.height,
1381
- channels: 4
1382
- }
1383
- }).png().toBuffer();
1384
- }
1385
-
1386
1601
  // src/effects/watermark.ts
1387
- import sharp7 from "sharp";
1388
- async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1389
- if (!config.enabled || !config.text) return frameBuffer;
1602
+ import sharp6 from "sharp";
1603
+ function buildWatermarkSvg(config, frameWidth, frameHeight) {
1604
+ if (!config.enabled || !config.text) return "";
1390
1605
  const charWidth = config.fontSize * 0.62;
1391
1606
  const textWidth = Math.ceil(config.text.length * charWidth);
1392
1607
  const margin = 16;
@@ -1412,31 +1627,228 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1412
1627
  break;
1413
1628
  }
1414
1629
  const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1415
- const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1630
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1416
1631
  <text x="${x}" y="${y}"
1417
1632
  font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
1418
1633
  font-weight="600" fill="${config.color}"
1419
1634
  opacity="${config.opacity.toFixed(3)}">${escaped}</text>
1420
1635
  </svg>`;
1421
- return sharp7(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();
1422
1641
  }
1423
1642
 
1424
- // src/compose/canvas-renderer.ts
1425
- function getFrameOffset(config) {
1643
+ // src/compose/compose-frame.ts
1644
+ function getFrameOffset(config, dpr = 1) {
1426
1645
  if (!config.enabled) return { left: 0, top: 0 };
1427
1646
  switch (config.type) {
1428
1647
  case "browser":
1429
- return { left: 0, top: 40 };
1648
+ return { left: 0, top: 40 * dpr };
1430
1649
  case "iphone":
1431
- return { left: 12, top: 50 };
1650
+ return { left: 12 * dpr, top: 50 * dpr };
1432
1651
  case "ipad":
1433
- return { left: 20, top: 24 };
1652
+ return { left: 20 * dpr, top: 24 * dpr };
1434
1653
  case "android":
1435
- return { left: 8, top: 32 };
1654
+ return { left: 8 * dpr, top: 32 * dpr };
1436
1655
  default:
1437
1656
  return { left: 0, top: 0 };
1438
1657
  }
1439
1658
  }
1659
+ async function composeFrame(frame, effects, output, context) {
1660
+ let buffer = frame.screenshot;
1661
+ const meta = await sharp7(buffer).metadata();
1662
+ let width = meta.width ?? frame.viewport.width;
1663
+ let height = meta.height ?? frame.viewport.height;
1664
+ const dpr = Math.round(width / frame.viewport.width);
1665
+ const ctx = {
1666
+ zoomScale: context?.zoomScale ?? 1,
1667
+ clickProgress: context?.clickProgress ?? null,
1668
+ cursorTrail: context?.cursorTrail ?? []
1669
+ };
1670
+ if (effects.deviceFrame.enabled) {
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
+ }
1683
+ const meta2 = await sharp7(buffer).metadata();
1684
+ width = meta2.width ?? width;
1685
+ height = meta2.height ?? height;
1686
+ }
1687
+ if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1688
+ buffer = await renderCursorHighlight(
1689
+ buffer,
1690
+ frame.cursorPosition,
1691
+ effects.cursor,
1692
+ width,
1693
+ height,
1694
+ dpr
1695
+ );
1696
+ }
1697
+ if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1698
+ buffer = await renderCursorTrail(
1699
+ buffer,
1700
+ ctx.cursorTrail,
1701
+ effects.cursor,
1702
+ width,
1703
+ height,
1704
+ dpr
1705
+ );
1706
+ }
1707
+ if (effects.cursor.enabled && frame.cursorPosition) {
1708
+ buffer = await renderCursor(
1709
+ buffer,
1710
+ frame.cursorPosition,
1711
+ effects.cursor,
1712
+ width,
1713
+ height,
1714
+ dpr
1715
+ );
1716
+ }
1717
+ if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
1718
+ const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1719
+ buffer = await renderClickEffect(
1720
+ buffer,
1721
+ frame.clickPosition,
1722
+ effects.cursor,
1723
+ progress,
1724
+ width,
1725
+ height,
1726
+ dpr
1727
+ );
1728
+ }
1729
+ if (effects.keystroke.enabled && frame.keystrokes) {
1730
+ buffer = await renderKeystrokeHud(
1731
+ buffer,
1732
+ frame.keystrokes,
1733
+ frame.timestamp,
1734
+ effects.keystroke,
1735
+ width,
1736
+ height,
1737
+ dpr
1738
+ );
1739
+ }
1740
+ const scale = ctx.zoomScale;
1741
+ if (effects.zoom.enabled && scale > 1) {
1742
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1743
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
1744
+ const focusPoint = {
1745
+ x: rawFocus.x * dpr + offset.left,
1746
+ y: rawFocus.y * dpr + offset.top
1747
+ };
1748
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1749
+ }
1750
+ 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
+ }
1784
+ }
1785
+ const { data: finalData, info: finalInfo } = await sharp7(buffer).resize(output.width, output.height, {
1786
+ fit: "fill",
1787
+ kernel: sharp7.kernel.lanczos3
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
+ };
1795
+ }
1796
+
1797
+ // src/effects/transition.ts
1798
+ import sharp8 from "sharp";
1799
+ async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1800
+ const t = Math.max(0, Math.min(1, progress));
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 });
1817
+ const pixels = Buffer.alloc(fromRaw.data.length);
1818
+ for (let i = 0; i < fromRaw.data.length; i++) {
1819
+ pixels[i] = Math.round(
1820
+ fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1821
+ );
1822
+ }
1823
+ return {
1824
+ buffer: pixels,
1825
+ rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
1826
+ };
1827
+ }
1828
+
1829
+ // src/compose/canvas-renderer.ts
1830
+ var MIN_FRAMES_PER_WORKER = 4;
1831
+ var cachedWorkerUrl = null;
1832
+ function getWorkerUrl() {
1833
+ if (cachedWorkerUrl) return cachedWorkerUrl;
1834
+ const base = import.meta.url;
1835
+ const candidates = [
1836
+ new URL("./frame-worker.js", base),
1837
+ // from dist/compose/
1838
+ new URL("../compose/frame-worker.js", base),
1839
+ // from dist/cli/
1840
+ new URL("./compose/frame-worker.js", base)
1841
+ // from dist/
1842
+ ];
1843
+ for (const url of candidates) {
1844
+ if (existsSync(fileURLToPath(url))) {
1845
+ cachedWorkerUrl = url;
1846
+ return url;
1847
+ }
1848
+ }
1849
+ cachedWorkerUrl = candidates[1];
1850
+ return cachedWorkerUrl;
1851
+ }
1440
1852
  var CanvasRenderer = class {
1441
1853
  constructor(effects, output, steps) {
1442
1854
  this.effects = effects;
@@ -1445,118 +1857,11 @@ var CanvasRenderer = class {
1445
1857
  }
1446
1858
  steps;
1447
1859
  /**
1448
- * Apply the full effects pipeline to a single captured frame.
1449
- *
1450
- * Pipeline order:
1451
- * 1. Device frame (browser chrome / mobile mockup)
1452
- * 2. Cursor highlight (Screen Studio glow)
1453
- * 3. Cursor trail
1454
- * 4. Cursor rendering
1455
- * 5. Click ripple effect (animated progress)
1456
- * 6. Keystroke HUD
1457
- * 7. Zoom (adaptive, cursor-following)
1458
- * 8. Background (padding, gradient, rounded corners)
1459
- * 9. Watermark overlay
1460
- * 10. Final resize
1860
+ * Apply the full effects pipeline to a single frame.
1861
+ * Delegates to the standalone composeFrame function.
1461
1862
  */
1462
1863
  async composeFrame(frame, context) {
1463
- let buffer = frame.screenshot;
1464
- let width = frame.viewport.width;
1465
- let height = frame.viewport.height;
1466
- const ctx = {
1467
- zoomScale: context?.zoomScale ?? 1,
1468
- clickProgress: context?.clickProgress ?? null,
1469
- cursorTrail: context?.cursorTrail ?? []
1470
- };
1471
- if (this.effects.deviceFrame.enabled) {
1472
- buffer = await applyDeviceFrame(
1473
- buffer,
1474
- this.effects.deviceFrame,
1475
- width,
1476
- height
1477
- );
1478
- const meta = await sharp8(buffer).metadata();
1479
- width = meta.width ?? width;
1480
- height = meta.height ?? height;
1481
- }
1482
- if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
1483
- buffer = await renderCursorHighlight(
1484
- buffer,
1485
- frame.cursorPosition,
1486
- this.effects.cursor,
1487
- width,
1488
- height
1489
- );
1490
- }
1491
- if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1492
- buffer = await renderCursorTrail(
1493
- buffer,
1494
- ctx.cursorTrail,
1495
- this.effects.cursor,
1496
- width,
1497
- height
1498
- );
1499
- }
1500
- if (this.effects.cursor.enabled && frame.cursorPosition) {
1501
- buffer = await renderCursor(
1502
- buffer,
1503
- frame.cursorPosition,
1504
- this.effects.cursor,
1505
- width,
1506
- height
1507
- );
1508
- }
1509
- if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
1510
- const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1511
- buffer = await renderClickEffect(
1512
- buffer,
1513
- frame.clickPosition,
1514
- this.effects.cursor,
1515
- progress,
1516
- width,
1517
- height
1518
- );
1519
- }
1520
- if (this.effects.keystroke.enabled && frame.keystrokes) {
1521
- buffer = await renderKeystrokeHud(
1522
- buffer,
1523
- frame.keystrokes,
1524
- frame.timestamp,
1525
- this.effects.keystroke,
1526
- width,
1527
- height
1528
- );
1529
- }
1530
- const scale = ctx.zoomScale;
1531
- if (this.effects.zoom.enabled && scale > 1) {
1532
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
1533
- const offset = getFrameOffset(this.effects.deviceFrame);
1534
- const focusPoint = {
1535
- x: rawFocus.x + offset.left,
1536
- y: rawFocus.y + offset.top
1537
- };
1538
- buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1539
- }
1540
- buffer = await applyBackground(
1541
- buffer,
1542
- this.effects.background,
1543
- this.output.width,
1544
- this.output.height
1545
- );
1546
- if (this.effects.watermark.enabled) {
1547
- buffer = await renderWatermark(
1548
- buffer,
1549
- this.effects.watermark,
1550
- this.output.width,
1551
- this.output.height
1552
- );
1553
- }
1554
- buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
1555
- return {
1556
- index: frame.index,
1557
- buffer,
1558
- timestamp: frame.timestamp
1559
- };
1864
+ return composeFrame(frame, this.effects, this.output, context);
1560
1865
  }
1561
1866
  /**
1562
1867
  * Process an entire sequence of captured frames through the effects pipeline.
@@ -1564,7 +1869,7 @@ var CanvasRenderer = class {
1564
1869
  * Multi-pass approach:
1565
1870
  * Pass 1: Speed ramping (adjust frame set).
1566
1871
  * Pass 2: Calculate per-frame contexts (zoom, click, trail).
1567
- * Pass 3: Render each frame with effects.
1872
+ * Pass 3: Render frames in parallel using worker threads.
1568
1873
  * Pass 4: Apply scene transitions at step boundaries.
1569
1874
  */
1570
1875
  async composeAll(frames) {
@@ -1574,10 +1879,19 @@ var CanvasRenderer = class {
1574
1879
  processFrames = this.applySpeedRamp(frames);
1575
1880
  }
1576
1881
  const contexts = this.calculateFrameContexts(processFrames);
1577
- const composed = [];
1578
- for (let i = 0; i < processFrames.length; i++) {
1579
- const result = await this.composeFrame(processFrames[i], contexts[i]);
1580
- composed.push(result);
1882
+ const cpuCount = os.cpus().length;
1883
+ const workerCount = Math.min(cpuCount, 8);
1884
+ const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
1885
+ let composed;
1886
+ if (useWorkers) {
1887
+ composed = await this.processWithWorkers(processFrames, contexts, workerCount);
1888
+ } else {
1889
+ composed = [];
1890
+ for (let i = 0; i < processFrames.length; i++) {
1891
+ composed.push(
1892
+ await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
1893
+ );
1894
+ }
1581
1895
  }
1582
1896
  if (this.steps.length > 0) {
1583
1897
  await this.applyTransitions(composed, processFrames);
@@ -1585,19 +1899,78 @@ var CanvasRenderer = class {
1585
1899
  return composed;
1586
1900
  }
1587
1901
  /**
1588
- * Calculate per-frame rendering context (zoom, click progress, cursor trail, tilt).
1902
+ * Distribute frame composition across a pool of worker threads.
1903
+ * Workers process frames concurrently; results are collected in order.
1904
+ */
1905
+ processWithWorkers(frames, contexts, workerCount) {
1906
+ return new Promise((resolve2, reject) => {
1907
+ const results = new Array(frames.length);
1908
+ let completed = 0;
1909
+ let nextIndex = 0;
1910
+ let failed = false;
1911
+ const workerUrl = getWorkerUrl();
1912
+ const workers = [];
1913
+ const dispatch = (worker) => {
1914
+ if (nextIndex >= frames.length || failed) return;
1915
+ const i = nextIndex++;
1916
+ worker.postMessage({
1917
+ taskId: i,
1918
+ frame: frames[i],
1919
+ effects: this.effects,
1920
+ output: this.output,
1921
+ context: contexts[i]
1922
+ });
1923
+ };
1924
+ for (let w = 0; w < workerCount; w++) {
1925
+ const worker = new Worker(workerUrl);
1926
+ workers.push(worker);
1927
+ worker.on("message", (msg) => {
1928
+ if (failed) return;
1929
+ if (msg.error) {
1930
+ failed = true;
1931
+ workers.forEach((wk) => wk.terminate());
1932
+ reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
1933
+ return;
1934
+ }
1935
+ results[msg.taskId] = {
1936
+ index: frames[msg.taskId].index,
1937
+ buffer: Buffer.from(msg.buffer),
1938
+ timestamp: frames[msg.taskId].timestamp,
1939
+ rawInfo: msg.rawInfo
1940
+ };
1941
+ completed++;
1942
+ if (completed === frames.length) {
1943
+ workers.forEach((wk) => wk.terminate());
1944
+ resolve2(results);
1945
+ } else {
1946
+ dispatch(worker);
1947
+ }
1948
+ });
1949
+ worker.on("error", (err) => {
1950
+ if (failed) return;
1951
+ failed = true;
1952
+ workers.forEach((wk) => wk.terminate());
1953
+ reject(err);
1954
+ });
1955
+ dispatch(worker);
1956
+ }
1957
+ });
1958
+ }
1959
+ /**
1960
+ * Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
1589
1961
  */
1590
1962
  calculateFrameContexts(frames) {
1591
1963
  const contexts = [];
1592
1964
  const transitionFrames = Math.round(
1593
1965
  this.output.fps * (this.effects.zoom.duration / 1e3)
1594
1966
  );
1967
+ const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
1595
1968
  for (let i = 0; i < frames.length; i++) {
1596
1969
  const frame = frames[i];
1597
1970
  let zoomScale = 1;
1598
1971
  if (this.effects.zoom.enabled) {
1599
- zoomScale = calculateAdaptiveZoom(
1600
- frames,
1972
+ zoomScale = calculateAdaptiveZoomFromLookup(
1973
+ clickLookup,
1601
1974
  i,
1602
1975
  this.effects.zoom.scale,
1603
1976
  transitionFrames
@@ -1617,7 +1990,6 @@ var CanvasRenderer = class {
1617
1990
  }
1618
1991
  /**
1619
1992
  * Apply speed ramping: slow down near actions, speed up during idle.
1620
- * Returns a new frame array with frames duplicated or skipped.
1621
1993
  */
1622
1994
  applySpeedRamp(frames) {
1623
1995
  const config = this.effects.speedRamp;
@@ -1648,9 +2020,371 @@ var CanvasRenderer = class {
1648
2020
  }
1649
2021
  return result;
1650
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
+ }
1651
2386
  /**
1652
2387
  * Apply crossfade transitions at step boundaries where configured.
1653
- * Modifies the composed array in-place.
1654
2388
  */
1655
2389
  async applyTransitions(composed, frames) {
1656
2390
  const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
@@ -1671,17 +2405,21 @@ var CanvasRenderer = class {
1671
2405
  if (range < 2) continue;
1672
2406
  const fromBuffer = composed[startIdx].buffer;
1673
2407
  const toBuffer = composed[endIdx].buffer;
1674
- const width = this.output.width;
1675
- const height = this.output.height;
2408
+ const fromRawInfo = composed[startIdx].rawInfo;
2409
+ const toRawInfo = composed[endIdx].rawInfo;
1676
2410
  for (let i = startIdx + 1; i < endIdx; i++) {
1677
2411
  const progress = (i - startIdx) / range;
1678
- composed[i].buffer = await applyCrossfade(
2412
+ const blended = await applyCrossfade(
1679
2413
  fromBuffer,
1680
2414
  toBuffer,
1681
2415
  progress,
1682
- width,
1683
- height
2416
+ this.output.width,
2417
+ this.output.height,
2418
+ fromRawInfo,
2419
+ toRawInfo
1684
2420
  );
2421
+ composed[i].buffer = blended.buffer;
2422
+ composed[i].rawInfo = blended.rawInfo;
1685
2423
  }
1686
2424
  }
1687
2425
  }
@@ -1690,11 +2428,45 @@ var CanvasRenderer = class {
1690
2428
  // src/compose/video-encoder.ts
1691
2429
  import gifenc from "gifenc";
1692
2430
  import sharp9 from "sharp";
1693
- import { writeFile, mkdir, readFile as readFile2, rm, mkdtemp } from "fs/promises";
2431
+ import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
1694
2432
  import { join } from "path";
1695
2433
  import { tmpdir } from "os";
1696
2434
  import { spawn } from "child_process";
1697
2435
  var { GIFEncoder, quantize, applyPalette } = gifenc;
2436
+ var ENCODING_PRESETS = {
2437
+ social: { crf: 22, vtQuality: 75 },
2438
+ balanced: { crf: 18, vtQuality: 85 },
2439
+ archive: { crf: 13, vtQuality: 92 }
2440
+ };
2441
+ function resolveEncodingParams(config) {
2442
+ if (config.preset) return ENCODING_PRESETS[config.preset];
2443
+ process.stderr.write(
2444
+ `[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
2445
+ `
2446
+ );
2447
+ if (config.quality >= 75) return ENCODING_PRESETS.social;
2448
+ if (config.quality >= 45) return ENCODING_PRESETS.balanced;
2449
+ return ENCODING_PRESETS.archive;
2450
+ }
2451
+ var encoderDetectionPromise = null;
2452
+ function detectVideoEncoder() {
2453
+ if (!encoderDetectionPromise) {
2454
+ encoderDetectionPromise = new Promise((resolve2) => {
2455
+ const proc = spawn("ffmpeg", ["-encoders"], {
2456
+ stdio: ["ignore", "pipe", "ignore"]
2457
+ });
2458
+ let out = "";
2459
+ proc.stdout.on("data", (d) => out += d.toString());
2460
+ proc.on("close", () => {
2461
+ if (out.includes("hevc_videotoolbox")) resolve2("hevc_videotoolbox");
2462
+ else if (out.includes("h264_videotoolbox")) resolve2("h264_videotoolbox");
2463
+ else resolve2("libx264");
2464
+ });
2465
+ proc.on("error", () => resolve2("libx264"));
2466
+ });
2467
+ }
2468
+ return encoderDetectionPromise;
2469
+ }
1698
2470
  async function encodeGif(frames, config) {
1699
2471
  if (frames.length === 0) {
1700
2472
  throw new Error("Cannot encode GIF: no frames provided");
@@ -1704,66 +2476,95 @@ async function encodeGif(frames, config) {
1704
2476
  const gif = GIFEncoder();
1705
2477
  const delay = Math.round(1e3 / config.fps);
1706
2478
  for (const frame of frames) {
1707
- 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 });
1708
2481
  const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1709
2482
  const palette = quantize(rgba, 256);
1710
2483
  const indexed = applyPalette(rgba, palette);
1711
- gif.writeFrame(indexed, width, height, {
1712
- palette,
1713
- delay
1714
- });
2484
+ gif.writeFrame(indexed, width, height, { palette, delay });
1715
2485
  }
1716
2486
  gif.finish();
1717
2487
  return Buffer.from(gif.bytes());
1718
2488
  }
1719
- async function encodeMp4(frames, config) {
1720
- if (frames.length === 0) {
1721
- throw new Error("Cannot encode MP4: no frames provided");
1722
- }
1723
- const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
2489
+ async function encodeMp4Stream(frames, config) {
2490
+ const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
1724
2491
  try {
1725
- const padLength = String(frames.length).length;
1726
- for (const frame of frames) {
1727
- const paddedIndex = String(frame.index).padStart(padLength, "0");
1728
- const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
1729
- await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
1730
- }
1731
- const outputPath = join(tmpDir, "output.mp4");
1732
- const crf = Math.round(51 - config.quality / 100 * 51);
1733
- await runFfmpeg([
1734
- "-y",
1735
- "-framerate",
1736
- String(config.fps),
1737
- "-i",
1738
- join(tmpDir, `frame-%0${padLength}d.png`),
1739
- "-c:v",
1740
- "libx264",
1741
- "-pix_fmt",
1742
- "yuv420p",
1743
- "-crf",
1744
- String(crf),
1745
- "-preset",
1746
- "slow",
1747
- "-tune",
1748
- "animation",
1749
- "-movflags",
1750
- "+faststart",
1751
- outputPath
1752
- ]);
2492
+ const encoder = await detectVideoEncoder();
2493
+ const params = resolveEncodingParams(config);
2494
+ await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath);
1753
2495
  return await readFile2(outputPath);
1754
2496
  } finally {
1755
- await rm(tmpDir, { recursive: true, force: true }).catch(() => {
2497
+ await rm(outputPath, { force: true }).catch(() => {
1756
2498
  });
1757
2499
  }
1758
2500
  }
1759
- function runFfmpeg(args) {
2501
+ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
2502
+ const videoArgs = encoder === "hevc_videotoolbox" ? [
2503
+ "-c:v",
2504
+ "hevc_videotoolbox",
2505
+ "-q:v",
2506
+ String(params.vtQuality),
2507
+ "-pix_fmt",
2508
+ "yuv420p",
2509
+ "-tag:v",
2510
+ "hvc1"
2511
+ ] : encoder === "h264_videotoolbox" ? [
2512
+ "-c:v",
2513
+ "h264_videotoolbox",
2514
+ "-q:v",
2515
+ String(params.vtQuality),
2516
+ "-pix_fmt",
2517
+ "yuv420p"
2518
+ ] : [
2519
+ "-c:v",
2520
+ "libx264",
2521
+ "-crf",
2522
+ String(params.crf),
2523
+ "-preset",
2524
+ "medium",
2525
+ "-tune",
2526
+ "stillimage",
2527
+ "-profile:v",
2528
+ "high",
2529
+ "-level",
2530
+ "4.1",
2531
+ "-pix_fmt",
2532
+ "yuv420p"
2533
+ ];
1760
2534
  return new Promise((resolve2, reject) => {
1761
- const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
2535
+ const ffmpeg = spawn(
2536
+ "ffmpeg",
2537
+ [
2538
+ "-y",
2539
+ "-f",
2540
+ "rawvideo",
2541
+ "-pixel_format",
2542
+ "rgb24",
2543
+ "-video_size",
2544
+ `${config.width}x${config.height}`,
2545
+ "-framerate",
2546
+ String(config.fps),
2547
+ "-i",
2548
+ "pipe:0",
2549
+ "-f",
2550
+ "lavfi",
2551
+ "-i",
2552
+ "anullsrc=r=48000:cl=stereo",
2553
+ ...videoArgs,
2554
+ "-c:a",
2555
+ "aac",
2556
+ "-b:a",
2557
+ "128k",
2558
+ "-shortest",
2559
+ "-movflags",
2560
+ "+faststart",
2561
+ outputPath
2562
+ ],
2563
+ { stdio: ["pipe", "ignore", "pipe"] }
2564
+ );
1762
2565
  let stderr = "";
1763
- proc.stderr.on("data", (data) => {
1764
- stderr += data.toString();
1765
- });
1766
- proc.on("close", (code) => {
2566
+ ffmpeg.stderr.on("data", (d) => stderr += d.toString());
2567
+ ffmpeg.on("close", (code) => {
1767
2568
  if (code === 0) {
1768
2569
  resolve2();
1769
2570
  } else {
@@ -1775,7 +2576,7 @@ function runFfmpeg(args) {
1775
2576
  );
1776
2577
  }
1777
2578
  });
1778
- proc.on("error", (err) => {
2579
+ ffmpeg.on("error", (err) => {
1779
2580
  if (err.code === "ENOENT") {
1780
2581
  reject(
1781
2582
  new Error(
@@ -1786,6 +2587,16 @@ function runFfmpeg(args) {
1786
2587
  reject(err);
1787
2588
  }
1788
2589
  });
2590
+ (async () => {
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();
2594
+ if (!ffmpeg.stdin.write(raw)) {
2595
+ await new Promise((r) => ffmpeg.stdin.once("drain", r));
2596
+ }
2597
+ }
2598
+ ffmpeg.stdin.end();
2599
+ })().catch(reject);
1789
2600
  });
1790
2601
  }
1791
2602
  async function savePngSequence(frames, config) {
@@ -1807,6 +2618,76 @@ async function savePngSequence(frames, config) {
1807
2618
  return paths;
1808
2619
  }
1809
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
+
1810
2691
  // src/cli/index.ts
1811
2692
  import { writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
1812
2693
  import { join as join2, resolve, dirname } from "path";
@@ -1871,72 +2752,91 @@ program.command("record").description("Record a demo from a YAML scenario file")
1871
2752
  process.exit(1);
1872
2753
  }
1873
2754
  }
1874
- spinner.start(
1875
- `Recording ${scenario.steps.length} steps...`
1876
- );
2755
+ await mkdir2(options.output, { recursive: true });
1877
2756
  const recorder = new ClipwiseRecorder();
1878
- const session = await recorder.record(scenario);
1879
- spinner.succeed(
1880
- `Recorded ${session.frames.length} frames`
2757
+ const renderer = new CanvasRenderer(
2758
+ scenario.effects,
2759
+ scenario.output,
2760
+ scenario.steps
1881
2761
  );
1882
- let composedFrames;
1883
- if (options.effects !== false) {
1884
- spinner.start(`Applying effects to ${session.frames.length} frames...`);
1885
- const renderer = new CanvasRenderer(
1886
- scenario.effects,
1887
- scenario.output,
1888
- scenario.steps
1889
- );
1890
- composedFrames = await renderer.composeAll(session.frames);
1891
- spinner.succeed("Effects applied");
1892
- } else {
1893
- composedFrames = session.frames.map((f) => ({
1894
- index: f.index,
1895
- buffer: f.screenshot,
1896
- timestamp: f.timestamp
1897
- }));
1898
- spinner.info("Effects disabled, using raw frames");
1899
- }
1900
- await mkdir2(options.output, { recursive: true });
1901
- if (scenario.output.format === "png-sequence") {
1902
- spinner.start("Saving PNG sequence...");
1903
- const paths = await savePngSequence(
1904
- composedFrames,
1905
- scenario.output
1906
- );
1907
- spinner.succeed(
1908
- `Saved ${paths.length} frames to ${chalk.bold(options.output)}`
1909
- );
1910
- } else if (scenario.output.format === "mp4") {
1911
- spinner.start("Encoding MP4...");
1912
- const mp4Buffer = await encodeMp4(
1913
- composedFrames,
1914
- scenario.output
1915
- );
1916
- const outputPath = join2(
1917
- options.output,
1918
- `${scenario.output.filename}.mp4`
1919
- );
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`);
1920
2771
  await writeFile2(outputPath, mp4Buffer);
1921
2772
  const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
1922
2773
  spinner.succeed(
1923
- `MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
2774
+ `MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB, ${session.frames.length} frames)`
1924
2775
  );
1925
2776
  } else {
1926
- spinner.start("Encoding GIF...");
1927
- const gifBuffer = await encodeGif(
1928
- composedFrames,
1929
- scenario.output
1930
- );
1931
- const outputPath = join2(
1932
- options.output,
1933
- `${scenario.output.filename}.gif`
1934
- );
1935
- await writeFile2(outputPath, gifBuffer);
1936
- const sizeMB = (gifBuffer.length / (1024 * 1024)).toFixed(2);
1937
- spinner.succeed(
1938
- `GIF saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
1939
- );
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
+ }
1940
2840
  }
1941
2841
  console.log(chalk.green("\nDone! \u{1F3AC}"));
1942
2842
  } catch (error) {
@@ -2016,7 +2916,7 @@ effects:
2016
2916
  output:
2017
2917
  format: mp4
2018
2918
  fps: 30
2019
- quality: 80
2919
+ preset: balanced # social | balanced | archive
2020
2920
 
2021
2921
  steps:
2022
2922
  - name: "Open app"
@@ -2194,7 +3094,7 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
2194
3094
  width: outWidth,
2195
3095
  height: outHeight,
2196
3096
  fps: 30,
2197
- quality: 80,
3097
+ preset: "social",
2198
3098
  outputDir: options.output,
2199
3099
  filename: `clipwise-demo-${device}`
2200
3100
  },
@@ -2219,27 +3119,44 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
2219
3119
  process.exit(1);
2220
3120
  }
2221
3121
  }
2222
- spinner.start(`Recording ${scenario.steps.length} steps...`);
2223
- const recorder = new ClipwiseRecorder();
2224
- const session = await recorder.record(scenario);
2225
- spinner.succeed(`Recorded ${session.frames.length} frames`);
2226
- spinner.start(`Applying effects to ${session.frames.length} frames...`);
2227
- const renderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
2228
- const composedFrames = await renderer.composeAll(session.frames);
2229
- spinner.succeed("Effects applied");
2230
3122
  await mkdir2(options.output, { recursive: true });
3123
+ const demoRenderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
2231
3124
  const ext = scenario.output.format === "gif" ? "gif" : "mp4";
2232
3125
  const outputPath = join2(options.output, `clipwise-demo-${device}.${ext}`);
2233
- if (ext === "gif") {
2234
- spinner.start("Encoding GIF...");
2235
- 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();
2236
3135
  await writeFile2(outputPath, buf);
2237
- 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)`);
2238
3137
  } else {
2239
- spinner.start("Encoding MP4...");
2240
- const buf = await encodeMp4(composedFrames, scenario.output);
2241
- await writeFile2(outputPath, buf);
2242
- 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
+ }
2243
3160
  }
2244
3161
  console.log(chalk.green("\nDemo complete! \u{1F3AC}"));
2245
3162
  } catch (error) {