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/index.js CHANGED
@@ -52,16 +52,45 @@ async function getElementCenter(page, selector, timeout) {
52
52
 
53
53
  // src/core/recorder.ts
54
54
  var CLICK_EFFECT_DURATION_MS = 500;
55
- var REPAINT_INTERVAL_MS = 50;
55
+ var REPAINT_INTERVAL_MS = 25;
56
56
  var ACTION_GAP_MS = 30;
57
57
  var CURSOR_SPEED_PRESETS = {
58
- fast: { steps: 12, delay: 6 },
59
- // ~72ms total
60
- normal: { steps: 18, delay: 8 },
61
- // ~144ms total
62
- slow: { steps: 24, delay: 12 }
63
- // ~288ms total
58
+ fast: { steps: 10, delay: 22 },
59
+ // ~220ms, ~9 frames captured
60
+ normal: { steps: 14, delay: 25 },
61
+ // ~350ms, ~14 frames captured
62
+ slow: { steps: 20, delay: 25 }
63
+ // ~500ms, ~20 frames captured
64
64
  };
65
+ var FrameChannel = class {
66
+ buffer = [];
67
+ resolve = null;
68
+ closed = false;
69
+ push(frame) {
70
+ if (this.closed) return;
71
+ this.buffer.push(frame);
72
+ this.resolve?.();
73
+ this.resolve = null;
74
+ }
75
+ close() {
76
+ if (this.closed) return;
77
+ this.closed = true;
78
+ this.resolve?.();
79
+ this.resolve = null;
80
+ }
81
+ async *[Symbol.asyncIterator]() {
82
+ while (true) {
83
+ while (this.buffer.length > 0) {
84
+ yield this.buffer.shift();
85
+ }
86
+ if (this.closed) return;
87
+ await new Promise((r) => {
88
+ this.resolve = r;
89
+ });
90
+ }
91
+ }
92
+ };
93
+ var DEDUP_SIGNATURE_BYTES = 2048;
65
94
  var ClipwiseRecorder = class {
66
95
  browser = null;
67
96
  context = null;
@@ -74,11 +103,21 @@ var ClipwiseRecorder = class {
74
103
  currentStepIndex = 0;
75
104
  cursorPosition = { x: 0, y: 0 };
76
105
  viewport = { width: 1280, height: 800 };
106
+ deviceScaleFactor = 1;
77
107
  isCapturing = false;
78
108
  targetFps = 30;
79
109
  cursorSpeed = "fast";
80
110
  firstContentTimestamp = 0;
81
111
  pendingResponsePromises = /* @__PURE__ */ new Map();
112
+ // ── 중복 프레임 제거 (Phase 1-A) ──────────────────────────────────────────
113
+ // 직전 저장된 프레임의 앞부분 시그니처. 동일하면 화면 내용이 바뀌지 않은 것.
114
+ lastFrameSignature = null;
115
+ dedupStats = { received: 0, stored: 0, skipped: 0 };
116
+ // ── 스트리밍 채널 (Phase 3-B) ───────────────────────────────────────────
117
+ // Set during recordToChannel(); null in normal record() mode.
118
+ frameChannel = null;
119
+ channelIndex = 0;
120
+ // sequential index for channel-pushed frames
82
121
  /**
83
122
  * Launch the browser and create a page with the scenario viewport.
84
123
  */
@@ -102,6 +141,10 @@ var ClipwiseRecorder = class {
102
141
  this.cursorPosition = { x: 0, y: 0 };
103
142
  this.isCapturing = false;
104
143
  this.firstContentTimestamp = 0;
144
+ this.lastFrameSignature = null;
145
+ this.dedupStats = { received: 0, stored: 0, skipped: 0 };
146
+ this.frameChannel = null;
147
+ this.channelIndex = 0;
105
148
  }
106
149
  /**
107
150
  * Start CDP screencast for continuous frame capture.
@@ -116,10 +159,24 @@ var ClipwiseRecorder = class {
116
159
  async (event) => {
117
160
  if (!this.isCapturing || !this.cdpClient) return;
118
161
  const buffer = Buffer.from(event.data, "base64");
119
- this.rawFrames.push({
120
- buffer,
121
- timestamp: Date.now()
122
- });
162
+ this.dedupStats.received++;
163
+ const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
164
+ const isDuplicate = this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
165
+ if (isDuplicate) {
166
+ this.dedupStats.skipped++;
167
+ } else {
168
+ this.lastFrameSignature = Buffer.from(signature);
169
+ const captureTime = Date.now();
170
+ this.rawFrames.push({ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex });
171
+ this.dedupStats.stored++;
172
+ if (this.frameChannel && this.firstContentTimestamp > 0) {
173
+ const frame = this.buildFrameOnline(
174
+ { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex },
175
+ this.channelIndex++
176
+ );
177
+ this.frameChannel.push(frame);
178
+ }
179
+ }
123
180
  await this.cdpClient.send("Page.screencastFrameAck", {
124
181
  sessionId: event.sessionId
125
182
  }).catch(() => {
@@ -127,10 +184,9 @@ var ClipwiseRecorder = class {
127
184
  }
128
185
  );
129
186
  await this.cdpClient.send("Page.startScreencast", {
130
- format: "jpeg",
131
- quality: 95,
132
- maxWidth: this.viewport.width,
133
- maxHeight: this.viewport.height,
187
+ format: "png",
188
+ maxWidth: this.viewport.width * this.deviceScaleFactor,
189
+ maxHeight: this.viewport.height * this.deviceScaleFactor,
134
190
  everyNthFrame: 1
135
191
  });
136
192
  this.cursorTimeline.push({
@@ -159,13 +215,23 @@ var ClipwiseRecorder = class {
159
215
  await this.init(scenario);
160
216
  const startTime = Date.now();
161
217
  try {
218
+ if (scenario.steps.length > 0) {
219
+ const s0 = scenario.steps[0];
220
+ this.currentStepIndex = 0;
221
+ this.preRegisterResponseListeners(s0.actions);
222
+ for (let ai = 0; ai < s0.actions.length; ai++) {
223
+ await this.executeAction(s0.actions[ai], ai);
224
+ }
225
+ }
162
226
  await this.startCapture();
163
227
  for (let si = 0; si < scenario.steps.length; si++) {
164
228
  const step = scenario.steps[si];
165
229
  this.currentStepIndex = si;
166
- this.preRegisterResponseListeners(step.actions);
167
- for (let ai = 0; ai < step.actions.length; ai++) {
168
- await this.executeAction(step.actions[ai], ai);
230
+ if (si > 0) {
231
+ this.preRegisterResponseListeners(step.actions);
232
+ for (let ai = 0; ai < step.actions.length; ai++) {
233
+ await this.executeAction(step.actions[ai], ai);
234
+ }
169
235
  }
170
236
  if (step.captureDelay > 0) {
171
237
  await this.waitWithRepaints(step.captureDelay);
@@ -186,7 +252,8 @@ var ClipwiseRecorder = class {
186
252
  scenario,
187
253
  frames,
188
254
  startTime,
189
- endTime: Date.now()
255
+ endTime: Date.now(),
256
+ dedupStats: { ...this.dedupStats }
190
257
  };
191
258
  } catch (error) {
192
259
  await this.stopCapture().catch(() => {
@@ -202,13 +269,116 @@ var ClipwiseRecorder = class {
202
269
  scenario,
203
270
  frames,
204
271
  startTime,
205
- endTime: Date.now()
272
+ endTime: Date.now(),
273
+ dedupStats: { ...this.dedupStats }
206
274
  };
207
275
  throw err;
208
276
  } finally {
209
277
  await this.cleanup();
210
278
  }
211
279
  }
280
+ // ─── Streaming recording API (Phase 3-B) ──────────────────────────────────
281
+ /**
282
+ * Start recording concurrently and return a RecordingHandle immediately.
283
+ *
284
+ * frameStream: yields CapturedFrames as each unique frame arrives from CDP
285
+ * (post-dedup, sequential indices starting at 0, NO FPS resampling).
286
+ * Closes when recording ends.
287
+ *
288
+ * done: resolves with the full RecordingSession (FPS-resampled) once
289
+ * all steps have completed and the browser has been cleaned up.
290
+ *
291
+ * Use this with CanvasRenderer.composeStreamOnline() to overlap recording
292
+ * time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
293
+ */
294
+ recordToChannel(scenario) {
295
+ const channel = new FrameChannel();
296
+ const done = (async () => {
297
+ try {
298
+ await this.init(scenario);
299
+ this.frameChannel = channel;
300
+ const startTime = Date.now();
301
+ if (scenario.steps.length > 0) {
302
+ const s0 = scenario.steps[0];
303
+ this.currentStepIndex = 0;
304
+ this.preRegisterResponseListeners(s0.actions);
305
+ for (let ai = 0; ai < s0.actions.length; ai++) {
306
+ await this.executeAction(s0.actions[ai], ai);
307
+ }
308
+ }
309
+ await this.startCapture();
310
+ for (let si = 0; si < scenario.steps.length; si++) {
311
+ const step = scenario.steps[si];
312
+ this.currentStepIndex = si;
313
+ if (si > 0) {
314
+ this.preRegisterResponseListeners(step.actions);
315
+ for (let ai = 0; ai < step.actions.length; ai++) {
316
+ await this.executeAction(step.actions[ai], ai);
317
+ }
318
+ }
319
+ if (step.captureDelay > 0) await this.waitWithRepaints(step.captureDelay);
320
+ if (step.holdDuration > 0) await this.waitWithRepaints(step.holdDuration);
321
+ }
322
+ await this.stopCapture();
323
+ channel.close();
324
+ const rawFrames = this.buildCapturedFrames();
325
+ const recordingDurationMs = Date.now() - startTime;
326
+ const frames = this.resampleToTargetFps(rawFrames, recordingDurationMs);
327
+ return {
328
+ scenario,
329
+ frames,
330
+ startTime,
331
+ endTime: Date.now(),
332
+ dedupStats: { ...this.dedupStats }
333
+ };
334
+ } catch (error) {
335
+ channel.close();
336
+ await this.stopCapture().catch(() => {
337
+ });
338
+ const rawFrames = this.buildCapturedFrames();
339
+ const session = {
340
+ scenario,
341
+ frames: rawFrames,
342
+ startTime: Date.now(),
343
+ dedupStats: { ...this.dedupStats }
344
+ };
345
+ const err = error instanceof Error ? error : new Error(String(error));
346
+ err.partialSession = session;
347
+ throw err;
348
+ } finally {
349
+ await this.cleanup();
350
+ }
351
+ })();
352
+ return { frameStream: channel, done };
353
+ }
354
+ /**
355
+ * Build a single CapturedFrame from a RawFrame in real-time.
356
+ * Used by recordToChannel() to emit frames as they arrive.
357
+ * Cursor/click data reflects the timeline up to this moment.
358
+ */
359
+ buildFrameOnline(raw, sequentialIndex) {
360
+ const cursorPos = this.interpolateCursorAt(raw.timestamp);
361
+ const clickEvent = this.clickTimeline.find(
362
+ (click) => raw.timestamp >= click.timestamp && raw.timestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
363
+ );
364
+ let clickProgress;
365
+ if (clickEvent) {
366
+ clickProgress = Math.min(1, (raw.timestamp - clickEvent.timestamp) / CLICK_EFFECT_DURATION_MS);
367
+ }
368
+ const frameKeystrokes = this.keystrokeTimeline.filter((k) => k.timestamp <= raw.timestamp);
369
+ return {
370
+ index: sequentialIndex,
371
+ screenshot: raw.buffer,
372
+ timestamp: raw.timestamp,
373
+ cursorPosition: cursorPos,
374
+ clickPosition: clickEvent?.position ?? null,
375
+ clickProgress,
376
+ viewport: { ...this.viewport },
377
+ deviceScaleFactor: this.deviceScaleFactor,
378
+ stepIndex: raw.stepIndex,
379
+ keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
380
+ };
381
+ }
212
382
  /**
213
383
  * Wait for a given duration while forcing periodic repaints
214
384
  * so CDP screencast keeps sending frames even on static pages.
@@ -444,8 +614,10 @@ var ClipwiseRecorder = class {
444
614
  clickPosition: clickEvent?.position ?? null,
445
615
  clickProgress,
446
616
  viewport: { ...this.viewport },
617
+ deviceScaleFactor: this.deviceScaleFactor,
447
618
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
448
- stepIndex: this.currentStepIndex
619
+ stepIndex: raw.stepIndex
620
+ // use per-frame step index captured at event time
449
621
  };
450
622
  });
451
623
  }
@@ -472,15 +644,9 @@ var ClipwiseRecorder = class {
472
644
  for (let i = 0; i < targetFrameCount; i++) {
473
645
  const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
474
646
  const targetTimestamp = startTime + t * duration;
475
- let nearestIdx = 0;
476
- let minDist = Infinity;
477
- for (let j = 0; j < frames.length; j++) {
478
- const dist = Math.abs(frames[j].timestamp - targetTimestamp);
479
- if (dist < minDist) {
480
- minDist = dist;
481
- nearestIdx = j;
482
- }
483
- }
647
+ const lo = this.binarySearchTimeline(frames, targetTimestamp);
648
+ const hi = Math.min(lo + 1, frames.length - 1);
649
+ const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
484
650
  const cursorPos = this.interpolateCursorAt(targetTimestamp);
485
651
  const clickEvent = this.clickTimeline.find(
486
652
  (click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
@@ -501,6 +667,7 @@ var ClipwiseRecorder = class {
501
667
  clickPosition: clickEvent?.position ?? null,
502
668
  clickProgress,
503
669
  viewport: { ...this.viewport },
670
+ deviceScaleFactor: this.deviceScaleFactor,
504
671
  stepName: frames[nearestIdx].stepName,
505
672
  stepIndex: frames[nearestIdx].stepIndex,
506
673
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
@@ -516,15 +683,9 @@ var ClipwiseRecorder = class {
516
683
  if (this.cursorTimeline.length === 1) {
517
684
  return { ...this.cursorTimeline[0].position };
518
685
  }
519
- let before = this.cursorTimeline[0];
520
- let after = this.cursorTimeline[this.cursorTimeline.length - 1];
521
- for (let i = 0; i < this.cursorTimeline.length - 1; i++) {
522
- if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
523
- before = this.cursorTimeline[i];
524
- after = this.cursorTimeline[i + 1];
525
- break;
526
- }
527
- }
686
+ const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
687
+ const before = this.cursorTimeline[idx];
688
+ const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
528
689
  if (timestamp <= before.timestamp) return { ...before.position };
529
690
  if (timestamp >= after.timestamp) return { ...after.position };
530
691
  const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
@@ -537,6 +698,23 @@ var ClipwiseRecorder = class {
537
698
  )
538
699
  };
539
700
  }
701
+ /**
702
+ * Binary search: returns the index of the last entry whose timestamp <= target.
703
+ * Assumes the array is sorted by timestamp in ascending order.
704
+ */
705
+ binarySearchTimeline(timeline, target) {
706
+ let lo = 0;
707
+ let hi = timeline.length - 1;
708
+ while (lo < hi) {
709
+ const mid = lo + hi + 1 >> 1;
710
+ if (timeline[mid].timestamp <= target) {
711
+ lo = mid;
712
+ } else {
713
+ hi = mid - 1;
714
+ }
715
+ }
716
+ return lo;
717
+ }
540
718
  /**
541
719
  * Clean up browser resources. Always called after recording.
542
720
  */
@@ -561,7 +739,13 @@ var ClipwiseRecorder = class {
561
739
  };
562
740
 
563
741
  // src/compose/canvas-renderer.ts
564
- import sharp8 from "sharp";
742
+ import { Worker } from "worker_threads";
743
+ import os from "os";
744
+ import { existsSync } from "fs";
745
+ import { fileURLToPath } from "url";
746
+
747
+ // src/compose/compose-frame.ts
748
+ import sharp7 from "sharp";
565
749
 
566
750
  // src/effects/frame.ts
567
751
  import sharp from "sharp";
@@ -584,91 +768,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
584
768
  var ANDROID_OUTER_RADIUS = 35;
585
769
  var ANDROID_INNER_RADIUS = 30;
586
770
  var ANDROID_CAMERA_RADIUS = 6;
587
- function buildBrowserChromeSvg(width, darkMode) {
771
+ function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
588
772
  const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
589
773
  const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
590
774
  const addressBorder = darkMode ? "#444444" : "#d0d0d0";
591
775
  const textColor = darkMode ? "#999999" : "#666666";
776
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
777
+ const tlY = TRAFFIC_LIGHT_Y * dpr;
778
+ const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
779
+ const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
780
+ const tlGap = TRAFFIC_LIGHT_GAP * dpr;
781
+ const aBarH = ADDRESS_BAR_HEIGHT * dpr;
782
+ const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
783
+ const fontSize = 12 * dpr;
592
784
  const trafficLights = [
593
- { cx: TRAFFIC_LIGHTS_START_X, fill: "#ff5f57" },
594
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP, fill: "#febc2e" },
595
- { cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP * 2, fill: "#28c840" }
785
+ { cx: tlStartX, fill: "#ff5f57" },
786
+ { cx: tlStartX + tlGap, fill: "#febc2e" },
787
+ { cx: tlStartX + tlGap * 2, fill: "#28c840" }
596
788
  ].map(
597
- (light) => `<circle cx="${light.cx}" cy="${TRAFFIC_LIGHT_Y}" r="${TRAFFIC_LIGHT_RADIUS}" fill="${light.fill}"/>`
789
+ (light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
598
790
  ).join("\n ");
599
- const addressBarWidth = width - ADDRESS_BAR_MARGIN * 2;
600
- const addressBarX = ADDRESS_BAR_MARGIN;
601
- const addressBarY = (TITLE_BAR_HEIGHT - ADDRESS_BAR_HEIGHT) / 2;
602
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${TITLE_BAR_HEIGHT}">
603
- <rect width="${width}" height="${TITLE_BAR_HEIGHT}" fill="${bg}"/>
791
+ const addressBarWidth = width - aBarMargin * 2;
792
+ const addressBarX = aBarMargin;
793
+ const addressBarY = (tbarH - aBarH) / 2;
794
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
795
+ <rect width="${width}" height="${tbarH}" fill="${bg}"/>
604
796
  ${trafficLights}
605
- <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${ADDRESS_BAR_HEIGHT}"
606
- rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="1"/>
607
- <text x="${width / 2}" y="${TRAFFIC_LIGHT_Y + 4}" text-anchor="middle"
608
- font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="${textColor}">
797
+ <rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
798
+ rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
799
+ <text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
800
+ font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
609
801
  localhost
610
802
  </text>
611
803
  </svg>`;
612
804
  }
613
- function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
805
+ function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
614
806
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
615
807
  const islandColor = darkMode ? "#000000" : "#1a1a1a";
616
808
  const homeBarColor = darkMode ? "#555555" : "#333333";
617
- const islandX = (totalWidth - IPHONE_ISLAND.width) / 2;
618
- const islandY = (IPHONE_BEZEL.top - IPHONE_ISLAND.height) / 2 + 4;
619
- const homeBarX = (totalWidth - IPHONE_HOME_BAR.width) / 2;
620
- const homeBarY = totalHeight - IPHONE_BEZEL.bottom / 2 - IPHONE_HOME_BAR.height / 2;
621
- const screenX = IPHONE_BEZEL.sides;
622
- const screenY = IPHONE_BEZEL.top;
809
+ const bezelTop = IPHONE_BEZEL.top * dpr;
810
+ const bezelBottom = IPHONE_BEZEL.bottom * dpr;
811
+ const bezelSides = IPHONE_BEZEL.sides * dpr;
812
+ const outerRadius = IPHONE_OUTER_RADIUS * dpr;
813
+ const innerRadius = IPHONE_INNER_RADIUS * dpr;
814
+ const islandW = IPHONE_ISLAND.width * dpr;
815
+ const islandH = IPHONE_ISLAND.height * dpr;
816
+ const homeBarW = IPHONE_HOME_BAR.width * dpr;
817
+ const homeBarH = IPHONE_HOME_BAR.height * dpr;
818
+ const islandX = (totalWidth - islandW) / 2;
819
+ const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
820
+ const homeBarX = (totalWidth - homeBarW) / 2;
821
+ const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
822
+ const screenX = bezelSides;
823
+ const screenY = bezelTop;
623
824
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
624
825
  <!-- Device body -->
625
826
  <rect width="${totalWidth}" height="${totalHeight}"
626
- rx="${IPHONE_OUTER_RADIUS}" ry="${IPHONE_OUTER_RADIUS}" fill="${bezelColor}"/>
827
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
627
828
  <!-- Screen cutout (transparent) -->
628
829
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
629
- rx="${IPHONE_INNER_RADIUS}" ry="${IPHONE_INNER_RADIUS}" fill="black"/>
830
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
630
831
  <!-- Dynamic Island pill -->
631
- <rect x="${islandX}" y="${islandY}" width="${IPHONE_ISLAND.width}" height="${IPHONE_ISLAND.height}"
632
- rx="${IPHONE_ISLAND.height / 2}" ry="${IPHONE_ISLAND.height / 2}" fill="${islandColor}"/>
832
+ <rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
833
+ rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
633
834
  <!-- Home indicator bar -->
634
- <rect x="${homeBarX}" y="${homeBarY}" width="${IPHONE_HOME_BAR.width}" height="${IPHONE_HOME_BAR.height}"
635
- rx="${IPHONE_HOME_BAR.height / 2}" ry="${IPHONE_HOME_BAR.height / 2}" fill="${homeBarColor}"/>
835
+ <rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
836
+ rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
636
837
  </svg>`;
637
838
  }
638
- function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
839
+ function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
639
840
  const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
640
841
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
641
- const screenX = IPAD_BEZEL.sides;
642
- const screenY = IPAD_BEZEL.top;
842
+ const screenX = IPAD_BEZEL.sides * dpr;
843
+ const screenY = IPAD_BEZEL.top * dpr;
643
844
  const cameraCx = totalWidth / 2;
644
- const cameraCy = IPAD_BEZEL.top / 2;
845
+ const cameraCy = IPAD_BEZEL.top * dpr / 2;
846
+ const outerRadius = IPAD_OUTER_RADIUS * dpr;
847
+ const innerRadius = IPAD_INNER_RADIUS * dpr;
645
848
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
646
849
  <!-- Device body -->
647
850
  <rect width="${totalWidth}" height="${totalHeight}"
648
- rx="${IPAD_OUTER_RADIUS}" ry="${IPAD_OUTER_RADIUS}" fill="${bezelColor}"/>
851
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
649
852
  <!-- Screen cutout -->
650
853
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
651
- rx="${IPAD_INNER_RADIUS}" ry="${IPAD_INNER_RADIUS}" fill="black"/>
854
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
652
855
  <!-- Front camera dot -->
653
- <circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
856
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
654
857
  </svg>`;
655
858
  }
656
- function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
859
+ function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
657
860
  const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
658
861
  const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
659
- const screenX = ANDROID_BEZEL.sides;
660
- const screenY = ANDROID_BEZEL.top;
862
+ const screenX = ANDROID_BEZEL.sides * dpr;
863
+ const screenY = ANDROID_BEZEL.top * dpr;
661
864
  const cameraCx = totalWidth / 2;
662
- const cameraCy = ANDROID_BEZEL.top / 2;
865
+ const cameraCy = ANDROID_BEZEL.top * dpr / 2;
866
+ const outerRadius = ANDROID_OUTER_RADIUS * dpr;
867
+ const innerRadius = ANDROID_INNER_RADIUS * dpr;
868
+ const cameraR = ANDROID_CAMERA_RADIUS * dpr;
663
869
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
664
870
  <!-- Device body -->
665
871
  <rect width="${totalWidth}" height="${totalHeight}"
666
- rx="${ANDROID_OUTER_RADIUS}" ry="${ANDROID_OUTER_RADIUS}" fill="${bezelColor}"/>
872
+ rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
667
873
  <!-- Screen cutout -->
668
874
  <rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
669
- rx="${ANDROID_INNER_RADIUS}" ry="${ANDROID_INNER_RADIUS}" fill="black"/>
875
+ rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
670
876
  <!-- Punch-hole camera -->
671
- <circle cx="${cameraCx}" cy="${cameraCy}" r="${ANDROID_CAMERA_RADIUS}" fill="${cameraColor}"/>
877
+ <circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
672
878
  </svg>`;
673
879
  }
674
880
  function buildScreenMaskSvg(width, height, radius) {
@@ -676,21 +882,33 @@ function buildScreenMaskSvg(width, height, radius) {
676
882
  <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
677
883
  </svg>`;
678
884
  }
679
- async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
885
+ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
680
886
  let bezel;
681
887
  let innerRadius;
682
888
  switch (deviceType) {
683
889
  case "iphone":
684
- bezel = IPHONE_BEZEL;
685
- innerRadius = IPHONE_INNER_RADIUS;
890
+ bezel = {
891
+ sides: IPHONE_BEZEL.sides * dpr,
892
+ top: IPHONE_BEZEL.top * dpr,
893
+ bottom: IPHONE_BEZEL.bottom * dpr
894
+ };
895
+ innerRadius = IPHONE_INNER_RADIUS * dpr;
686
896
  break;
687
897
  case "ipad":
688
- bezel = IPAD_BEZEL;
689
- innerRadius = IPAD_INNER_RADIUS;
898
+ bezel = {
899
+ sides: IPAD_BEZEL.sides * dpr,
900
+ top: IPAD_BEZEL.top * dpr,
901
+ bottom: IPAD_BEZEL.bottom * dpr
902
+ };
903
+ innerRadius = IPAD_INNER_RADIUS * dpr;
690
904
  break;
691
905
  case "android":
692
- bezel = ANDROID_BEZEL;
693
- innerRadius = ANDROID_INNER_RADIUS;
906
+ bezel = {
907
+ sides: ANDROID_BEZEL.sides * dpr,
908
+ top: ANDROID_BEZEL.top * dpr,
909
+ bottom: ANDROID_BEZEL.bottom * dpr
910
+ };
911
+ innerRadius = ANDROID_INNER_RADIUS * dpr;
694
912
  break;
695
913
  }
696
914
  const totalWidth = frameWidth + bezel.sides * 2;
@@ -698,13 +916,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
698
916
  let frameSvg;
699
917
  switch (deviceType) {
700
918
  case "iphone":
701
- frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
919
+ frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
702
920
  break;
703
921
  case "ipad":
704
- frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
922
+ frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
705
923
  break;
706
924
  case "android":
707
- frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
925
+ frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
708
926
  break;
709
927
  }
710
928
  const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
@@ -727,12 +945,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
727
945
  { input: maskedScreen, left: bezel.sides, top: bezel.top }
728
946
  ]).png().toBuffer();
729
947
  }
730
- async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
948
+ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
731
949
  if (!config.enabled || config.type === "none") return frameBuffer;
732
950
  switch (config.type) {
733
951
  case "browser": {
734
- const totalHeight = frameHeight + TITLE_BAR_HEIGHT;
735
- const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode);
952
+ const tbarH = TITLE_BAR_HEIGHT * dpr;
953
+ const totalHeight = frameHeight + tbarH;
954
+ const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
736
955
  const chromeBuffer = Buffer.from(chromeSvg);
737
956
  const canvas = await sharp({
738
957
  create: {
@@ -744,13 +963,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
744
963
  }).png().toBuffer();
745
964
  return sharp(canvas).composite([
746
965
  { input: chromeBuffer, left: 0, top: 0 },
747
- { input: frameBuffer, left: 0, top: TITLE_BAR_HEIGHT }
966
+ { input: frameBuffer, left: 0, top: tbarH }
748
967
  ]).png().toBuffer();
749
968
  }
750
969
  case "iphone":
751
970
  case "ipad":
752
971
  case "android":
753
- return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
972
+ return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
754
973
  default:
755
974
  return frameBuffer;
756
975
  }
@@ -760,7 +979,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
760
979
  import sharp2 from "sharp";
761
980
  function buildCursorSvg(size, color) {
762
981
  const s = size;
763
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
982
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
764
983
  <path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
765
984
  fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
766
985
  </svg>`;
@@ -769,7 +988,7 @@ function buildClickRippleSvg(radius, color, progress) {
769
988
  const currentRadius = radius * progress;
770
989
  const opacity = Math.max(0, 1 - progress);
771
990
  const size = Math.ceil(radius * 2 + 4);
772
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
991
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
773
992
  <circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
774
993
  fill="none" stroke="${color}" stroke-width="2"
775
994
  opacity="${opacity.toFixed(3)}"/>
@@ -777,47 +996,35 @@ function buildClickRippleSvg(radius, color, progress) {
777
996
  fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
778
997
  </svg>`;
779
998
  }
780
- async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
999
+ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
781
1000
  if (!config.enabled) return frameBuffer;
782
- const cursorSvg = buildCursorSvg(config.size, config.color);
1001
+ const size = Math.round(config.size * dpr);
1002
+ const cursorSvg = buildCursorSvg(size, config.color);
783
1003
  const cursorBuffer = Buffer.from(cursorSvg);
784
- const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
785
- const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
1004
+ const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
1005
+ const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
786
1006
  return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
787
1007
  }
788
- async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
1008
+ async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
789
1009
  if (!config.enabled || !config.clickEffect) return frameBuffer;
1010
+ const radius = config.clickRadius * dpr;
790
1011
  const clampedProgress = Math.max(0, Math.min(1, progress));
791
- const rippleSvg = buildClickRippleSvg(
792
- config.clickRadius,
793
- config.clickColor,
794
- clampedProgress
795
- );
1012
+ const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
796
1013
  const rippleBuffer = Buffer.from(rippleSvg);
797
- const rippleSize = Math.ceil(config.clickRadius * 2 + 4);
798
- const left = Math.max(
799
- 0,
800
- Math.min(
801
- Math.round(position.x - rippleSize / 2),
802
- frameWidth - rippleSize
803
- )
804
- );
805
- const top = Math.max(
806
- 0,
807
- Math.min(
808
- Math.round(position.y - rippleSize / 2),
809
- frameHeight - rippleSize
810
- )
811
- );
1014
+ const rippleSize = Math.ceil(radius * 2 + 4);
1015
+ const px = Math.round(position.x * dpr);
1016
+ const py = Math.round(position.y * dpr);
1017
+ const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
1018
+ const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
812
1019
  return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
813
1020
  }
814
- async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
1021
+ async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
815
1022
  if (!config.enabled || !config.highlight) return frameBuffer;
816
- const r = config.highlightRadius;
1023
+ const r = config.highlightRadius * dpr;
817
1024
  const size = Math.ceil(r * 2 + 4);
818
1025
  const cx = size / 2;
819
1026
  const cy = size / 2;
820
- const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
1027
+ const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
821
1028
  <defs>
822
1029
  <radialGradient id="glow">
823
1030
  <stop offset="0%" stop-color="${config.highlightColor}" />
@@ -827,27 +1034,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
827
1034
  </defs>
828
1035
  <circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
829
1036
  </svg>`;
830
- const left = Math.max(0, Math.min(Math.round(position.x - cx), frameWidth - size));
831
- const top = Math.max(0, Math.min(Math.round(position.y - cy), frameHeight - size));
1037
+ const px = Math.round(position.x * dpr);
1038
+ const py = Math.round(position.y * dpr);
1039
+ const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
1040
+ const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
832
1041
  return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
833
1042
  }
834
- async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
1043
+ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
835
1044
  if (!config.enabled || !config.trail || positions.length < 2) {
836
1045
  return frameBuffer;
837
1046
  }
838
1047
  const segments = [];
839
1048
  for (let i = 1; i < positions.length; i++) {
840
1049
  const opacity = i / positions.length * 0.6;
841
- const strokeWidth = 1 + i / positions.length * 2;
1050
+ const strokeWidth = (1 + i / positions.length * 2) * dpr;
842
1051
  const p1 = positions[i - 1];
843
1052
  const p2 = positions[i];
844
1053
  segments.push(
845
- `<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
1054
+ `<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
846
1055
  stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
847
1056
  stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
848
1057
  );
849
1058
  }
850
- const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1059
+ const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
851
1060
  ${segments.join("\n ")}
852
1061
  </svg>`;
853
1062
  return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
@@ -867,20 +1076,56 @@ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight
867
1076
  }
868
1077
  function calculateAdaptiveZoom(frames, currentIndex, maxScale, transitionFrames) {
869
1078
  if (maxScale <= 1) return 1;
1079
+ const lo = Math.max(0, currentIndex - transitionFrames);
1080
+ const hi = Math.min(frames.length - 1, currentIndex + transitionFrames);
870
1081
  let minDistance = Infinity;
871
- for (let i = 0; i < frames.length; i++) {
1082
+ for (let i = lo; i <= hi; i++) {
872
1083
  if (frames[i].clickPosition) {
873
1084
  const distance = Math.abs(i - currentIndex);
874
- minDistance = Math.min(minDistance, distance);
1085
+ if (distance < minDistance) minDistance = distance;
1086
+ }
1087
+ }
1088
+ if (minDistance > transitionFrames) return 1;
1089
+ const t = 1 - minDistance / transitionFrames;
1090
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
1091
+ }
1092
+ function buildZoomClickLookup(frames) {
1093
+ const indices = [];
1094
+ for (let i = 0; i < frames.length; i++) {
1095
+ if (frames[i].clickPosition !== null && frames[i].clickPosition !== void 0) {
1096
+ indices.push(i);
875
1097
  }
876
1098
  }
877
- if (minDistance === Infinity) return 1;
878
- if (minDistance <= transitionFrames) {
879
- const t = 1 - minDistance / transitionFrames;
880
- const eased = easeInOutCubic2(t);
881
- return 1 + (maxScale - 1) * eased;
1099
+ return indices;
1100
+ }
1101
+ function calculateAdaptiveZoomFromLookup(clickLookup, currentIndex, maxScale, transitionFrames) {
1102
+ if (maxScale <= 1 || clickLookup.length === 0) return 1;
1103
+ let lo = 0;
1104
+ let hi = clickLookup.length;
1105
+ while (lo < hi) {
1106
+ const mid = lo + hi >>> 1;
1107
+ if (clickLookup[mid] < currentIndex) lo = mid + 1;
1108
+ else hi = mid;
1109
+ }
1110
+ const distBefore = lo > 0 ? currentIndex - clickLookup[lo - 1] : Infinity;
1111
+ const distAfter = lo < clickLookup.length ? clickLookup[lo] - currentIndex : Infinity;
1112
+ const minDistance = Math.min(distBefore, distAfter);
1113
+ if (minDistance > transitionFrames) return 1;
1114
+ const t = 1 - minDistance / transitionFrames;
1115
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
1116
+ }
1117
+ function calculateAdaptiveZoomInWindow(windowFrames, windowStart, currentIndex, maxScale, transitionFrames) {
1118
+ if (maxScale <= 1) return 1;
1119
+ let minDistance = Infinity;
1120
+ for (let j = 0; j < windowFrames.length; j++) {
1121
+ if (windowFrames[j].clickPosition !== null && windowFrames[j].clickPosition !== void 0) {
1122
+ const dist = Math.abs(windowStart + j - currentIndex);
1123
+ if (dist < minDistance) minDistance = dist;
1124
+ }
882
1125
  }
883
- return 1;
1126
+ if (minDistance > transitionFrames) return 1;
1127
+ const t = 1 - minDistance / transitionFrames;
1128
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
884
1129
  }
885
1130
  function calculatePanOffset(focusPoint, scale, frameWidth, frameHeight) {
886
1131
  if (scale <= 1) return { x: 0, y: 0 };
@@ -993,7 +1238,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
993
1238
 
994
1239
  // src/effects/keystroke.ts
995
1240
  import sharp5 from "sharp";
996
- async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
1241
+ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
997
1242
  if (!config.enabled || keystrokes.length === 0) return frameBuffer;
998
1243
  const recentKeys = keystrokes.filter(
999
1244
  (k) => frameTimestamp - k.timestamp < config.fadeAfter
@@ -1001,25 +1246,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1001
1246
  if (recentKeys.length === 0) return frameBuffer;
1002
1247
  const displayText = recentKeys.map((k) => k.key).join("");
1003
1248
  if (displayText.length === 0) return frameBuffer;
1004
- const charWidth = config.fontSize * 0.62;
1249
+ const fontSize = config.fontSize * dpr;
1250
+ const padding = config.padding * dpr;
1251
+ const charWidth = fontSize * 0.62;
1005
1252
  const textWidth = Math.ceil(displayText.length * charWidth);
1006
- const hudPadH = config.padding * 2;
1007
- const hudPadV = config.padding * 1.5;
1008
- const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
1009
- const hudHeight = Math.ceil(config.fontSize + hudPadV * 2);
1253
+ const hudPadH = padding * 2;
1254
+ const hudPadV = padding * 1.5;
1255
+ const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
1256
+ const hudHeight = Math.ceil(fontSize + hudPadV * 2);
1010
1257
  const newest = recentKeys[recentKeys.length - 1];
1011
1258
  const age = frameTimestamp - newest.timestamp;
1012
1259
  const fadeStart = config.fadeAfter * 0.6;
1013
1260
  const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
1014
1261
  if (opacity <= 0) return frameBuffer;
1262
+ const margin = 30 * dpr;
1015
1263
  let hudX;
1016
- const hudY = frameHeight - hudHeight - 30;
1264
+ const hudY = frameHeight - hudHeight - margin;
1017
1265
  switch (config.position) {
1018
1266
  case "bottom-left":
1019
- hudX = 30;
1267
+ hudX = margin;
1020
1268
  break;
1021
1269
  case "bottom-right":
1022
- hudX = frameWidth - hudWidth - 30;
1270
+ hudX = frameWidth - hudWidth - margin;
1023
1271
  break;
1024
1272
  case "bottom-center":
1025
1273
  default:
@@ -1029,43 +1277,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1029
1277
  const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
1030
1278
  const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
1031
1279
  const escaped = truncated.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1032
- const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1280
+ const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1033
1281
  <rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
1034
- rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1035
- <text x="${hudX + hudPadH}" y="${hudY + hudPadV + config.fontSize * 0.75}"
1036
- font-family="monospace, Menlo, Consolas" font-size="${config.fontSize}"
1282
+ rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
1283
+ <text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
1284
+ font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
1037
1285
  fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
1038
1286
  </svg>`;
1039
1287
  return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
1040
1288
  }
1041
1289
 
1042
- // src/effects/transition.ts
1043
- import sharp6 from "sharp";
1044
- async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1045
- const t = Math.max(0, Math.min(1, progress));
1046
- if (t <= 0) return fromBuffer;
1047
- if (t >= 1) return toBuffer;
1048
- const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1049
- const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1050
- const pixels = Buffer.alloc(fromRaw.data.length);
1051
- for (let i = 0; i < fromRaw.data.length; i++) {
1052
- pixels[i] = Math.round(
1053
- fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1054
- );
1055
- }
1056
- return sharp6(pixels, {
1057
- raw: {
1058
- width: fromRaw.info.width,
1059
- height: fromRaw.info.height,
1060
- channels: 4
1061
- }
1062
- }).png().toBuffer();
1063
- }
1064
-
1065
1290
  // src/effects/watermark.ts
1066
- import sharp7 from "sharp";
1067
- async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1068
- if (!config.enabled || !config.text) return frameBuffer;
1291
+ import sharp6 from "sharp";
1292
+ function buildWatermarkSvg(config, frameWidth, frameHeight) {
1293
+ if (!config.enabled || !config.text) return "";
1069
1294
  const charWidth = config.fontSize * 0.62;
1070
1295
  const textWidth = Math.ceil(config.text.length * charWidth);
1071
1296
  const margin = 16;
@@ -1091,31 +1316,228 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1091
1316
  break;
1092
1317
  }
1093
1318
  const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1094
- const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
1319
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1095
1320
  <text x="${x}" y="${y}"
1096
1321
  font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
1097
1322
  font-weight="600" fill="${config.color}"
1098
1323
  opacity="${config.opacity.toFixed(3)}">${escaped}</text>
1099
1324
  </svg>`;
1100
- return sharp7(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
1325
+ }
1326
+ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1327
+ if (!config.enabled || !config.text) return frameBuffer;
1328
+ const svg = buildWatermarkSvg(config, frameWidth, frameHeight);
1329
+ return sharp6(frameBuffer).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toBuffer();
1101
1330
  }
1102
1331
 
1103
- // src/compose/canvas-renderer.ts
1104
- function getFrameOffset(config) {
1332
+ // src/compose/compose-frame.ts
1333
+ function getFrameOffset(config, dpr = 1) {
1105
1334
  if (!config.enabled) return { left: 0, top: 0 };
1106
1335
  switch (config.type) {
1107
1336
  case "browser":
1108
- return { left: 0, top: 40 };
1337
+ return { left: 0, top: 40 * dpr };
1109
1338
  case "iphone":
1110
- return { left: 12, top: 50 };
1339
+ return { left: 12 * dpr, top: 50 * dpr };
1111
1340
  case "ipad":
1112
- return { left: 20, top: 24 };
1341
+ return { left: 20 * dpr, top: 24 * dpr };
1113
1342
  case "android":
1114
- return { left: 8, top: 32 };
1343
+ return { left: 8 * dpr, top: 32 * dpr };
1115
1344
  default:
1116
1345
  return { left: 0, top: 0 };
1117
1346
  }
1118
1347
  }
1348
+ async function composeFrame(frame, effects, output, context) {
1349
+ let buffer = frame.screenshot;
1350
+ const meta = await sharp7(buffer).metadata();
1351
+ let width = meta.width ?? frame.viewport.width;
1352
+ let height = meta.height ?? frame.viewport.height;
1353
+ const dpr = Math.round(width / frame.viewport.width);
1354
+ const ctx = {
1355
+ zoomScale: context?.zoomScale ?? 1,
1356
+ clickProgress: context?.clickProgress ?? null,
1357
+ cursorTrail: context?.cursorTrail ?? []
1358
+ };
1359
+ if (effects.deviceFrame.enabled) {
1360
+ const sl2 = ctx.staticLayers;
1361
+ if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
1362
+ buffer = await sharp7(buffer).extend({
1363
+ top: sl2.browserChromeHeight,
1364
+ bottom: 0,
1365
+ left: 0,
1366
+ right: 0,
1367
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
1368
+ }).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
1369
+ } else {
1370
+ buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
1371
+ }
1372
+ const meta2 = await sharp7(buffer).metadata();
1373
+ width = meta2.width ?? width;
1374
+ height = meta2.height ?? height;
1375
+ }
1376
+ if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
1377
+ buffer = await renderCursorHighlight(
1378
+ buffer,
1379
+ frame.cursorPosition,
1380
+ effects.cursor,
1381
+ width,
1382
+ height,
1383
+ dpr
1384
+ );
1385
+ }
1386
+ if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1387
+ buffer = await renderCursorTrail(
1388
+ buffer,
1389
+ ctx.cursorTrail,
1390
+ effects.cursor,
1391
+ width,
1392
+ height,
1393
+ dpr
1394
+ );
1395
+ }
1396
+ if (effects.cursor.enabled && frame.cursorPosition) {
1397
+ buffer = await renderCursor(
1398
+ buffer,
1399
+ frame.cursorPosition,
1400
+ effects.cursor,
1401
+ width,
1402
+ height,
1403
+ dpr
1404
+ );
1405
+ }
1406
+ if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
1407
+ const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1408
+ buffer = await renderClickEffect(
1409
+ buffer,
1410
+ frame.clickPosition,
1411
+ effects.cursor,
1412
+ progress,
1413
+ width,
1414
+ height,
1415
+ dpr
1416
+ );
1417
+ }
1418
+ if (effects.keystroke.enabled && frame.keystrokes) {
1419
+ buffer = await renderKeystrokeHud(
1420
+ buffer,
1421
+ frame.keystrokes,
1422
+ frame.timestamp,
1423
+ effects.keystroke,
1424
+ width,
1425
+ height,
1426
+ dpr
1427
+ );
1428
+ }
1429
+ const scale = ctx.zoomScale;
1430
+ if (effects.zoom.enabled && scale > 1) {
1431
+ const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
1432
+ const offset = getFrameOffset(effects.deviceFrame, dpr);
1433
+ const focusPoint = {
1434
+ x: rawFocus.x * dpr + offset.left,
1435
+ y: rawFocus.y * dpr + offset.top
1436
+ };
1437
+ buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1438
+ }
1439
+ const sl = ctx.staticLayers;
1440
+ if (sl) {
1441
+ const padding = effects.background.padding;
1442
+ const contentWidth = output.width - padding * 2;
1443
+ const contentHeight = output.height - padding * 2;
1444
+ if (contentWidth > 0 && contentHeight > 0) {
1445
+ const radius = effects.background.borderRadius;
1446
+ const roundedMask = Buffer.from(
1447
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
1448
+ <rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
1449
+ </svg>`
1450
+ );
1451
+ const { data: maskedData, info: maskedInfo } = await sharp7(buffer).resize(contentWidth, contentHeight, { fit: "fill" }).composite([{ input: roundedMask, blend: "dest-in" }]).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1452
+ const { data: composited, info: compInfo } = await sharp7(sl.backdropRaw, {
1453
+ raw: { width: sl.backdropWidth, height: sl.backdropHeight, channels: 4 }
1454
+ }).composite([{
1455
+ input: Buffer.from(maskedData),
1456
+ raw: { width: maskedInfo.width, height: maskedInfo.height, channels: 4 },
1457
+ left: padding,
1458
+ top: padding
1459
+ }]).raw().toBuffer({ resolveWithObject: true });
1460
+ return {
1461
+ index: frame.index,
1462
+ buffer: Buffer.from(composited),
1463
+ timestamp: frame.timestamp,
1464
+ rawInfo: { width: compInfo.width, height: compInfo.height, channels: 4 }
1465
+ };
1466
+ }
1467
+ buffer = sl.backdropRaw;
1468
+ } else {
1469
+ buffer = await applyBackground(buffer, effects.background, output.width, output.height);
1470
+ if (effects.watermark.enabled) {
1471
+ buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
1472
+ }
1473
+ }
1474
+ const { data: finalData, info: finalInfo } = await sharp7(buffer).resize(output.width, output.height, {
1475
+ fit: "fill",
1476
+ kernel: sharp7.kernel.lanczos3
1477
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1478
+ return {
1479
+ index: frame.index,
1480
+ buffer: Buffer.from(finalData),
1481
+ timestamp: frame.timestamp,
1482
+ rawInfo: { width: finalInfo.width, height: finalInfo.height, channels: 4 }
1483
+ };
1484
+ }
1485
+
1486
+ // src/effects/transition.ts
1487
+ import sharp8 from "sharp";
1488
+ async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1489
+ const t = Math.max(0, Math.min(1, progress));
1490
+ if (t <= 0) {
1491
+ const rawInfo = fromRawInfo ?? { width, height, channels: 4 };
1492
+ if (fromRawInfo) return { buffer: fromBuffer, rawInfo };
1493
+ const { data, info } = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1494
+ return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
1495
+ }
1496
+ if (t >= 1) {
1497
+ const rawInfo = toRawInfo ?? { width, height, channels: 4 };
1498
+ if (toRawInfo) return { buffer: toBuffer, rawInfo };
1499
+ const { data, info } = await sharp8(toBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1500
+ return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
1501
+ }
1502
+ const fromSrc = fromRawInfo ? sharp8(fromBuffer, { raw: { width: fromRawInfo.width, height: fromRawInfo.height, channels: fromRawInfo.channels } }) : sharp8(fromBuffer);
1503
+ const fromRaw = await fromSrc.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1504
+ const toSrc = toRawInfo ? sharp8(toBuffer, { raw: { width: toRawInfo.width, height: toRawInfo.height, channels: toRawInfo.channels } }) : sharp8(toBuffer);
1505
+ const toRaw = await toSrc.resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1506
+ const pixels = Buffer.alloc(fromRaw.data.length);
1507
+ for (let i = 0; i < fromRaw.data.length; i++) {
1508
+ pixels[i] = Math.round(
1509
+ fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1510
+ );
1511
+ }
1512
+ return {
1513
+ buffer: pixels,
1514
+ rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
1515
+ };
1516
+ }
1517
+
1518
+ // src/compose/canvas-renderer.ts
1519
+ var MIN_FRAMES_PER_WORKER = 4;
1520
+ var cachedWorkerUrl = null;
1521
+ function getWorkerUrl() {
1522
+ if (cachedWorkerUrl) return cachedWorkerUrl;
1523
+ const base = import.meta.url;
1524
+ const candidates = [
1525
+ new URL("./frame-worker.js", base),
1526
+ // from dist/compose/
1527
+ new URL("../compose/frame-worker.js", base),
1528
+ // from dist/cli/
1529
+ new URL("./compose/frame-worker.js", base)
1530
+ // from dist/
1531
+ ];
1532
+ for (const url of candidates) {
1533
+ if (existsSync(fileURLToPath(url))) {
1534
+ cachedWorkerUrl = url;
1535
+ return url;
1536
+ }
1537
+ }
1538
+ cachedWorkerUrl = candidates[1];
1539
+ return cachedWorkerUrl;
1540
+ }
1119
1541
  var CanvasRenderer = class {
1120
1542
  constructor(effects, output, steps) {
1121
1543
  this.effects = effects;
@@ -1124,118 +1546,11 @@ var CanvasRenderer = class {
1124
1546
  }
1125
1547
  steps;
1126
1548
  /**
1127
- * Apply the full effects pipeline to a single captured frame.
1128
- *
1129
- * Pipeline order:
1130
- * 1. Device frame (browser chrome / mobile mockup)
1131
- * 2. Cursor highlight (Screen Studio glow)
1132
- * 3. Cursor trail
1133
- * 4. Cursor rendering
1134
- * 5. Click ripple effect (animated progress)
1135
- * 6. Keystroke HUD
1136
- * 7. Zoom (adaptive, cursor-following)
1137
- * 8. Background (padding, gradient, rounded corners)
1138
- * 9. Watermark overlay
1139
- * 10. Final resize
1549
+ * Apply the full effects pipeline to a single frame.
1550
+ * Delegates to the standalone composeFrame function.
1140
1551
  */
1141
1552
  async composeFrame(frame, context) {
1142
- let buffer = frame.screenshot;
1143
- let width = frame.viewport.width;
1144
- let height = frame.viewport.height;
1145
- const ctx = {
1146
- zoomScale: context?.zoomScale ?? 1,
1147
- clickProgress: context?.clickProgress ?? null,
1148
- cursorTrail: context?.cursorTrail ?? []
1149
- };
1150
- if (this.effects.deviceFrame.enabled) {
1151
- buffer = await applyDeviceFrame(
1152
- buffer,
1153
- this.effects.deviceFrame,
1154
- width,
1155
- height
1156
- );
1157
- const meta = await sharp8(buffer).metadata();
1158
- width = meta.width ?? width;
1159
- height = meta.height ?? height;
1160
- }
1161
- if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
1162
- buffer = await renderCursorHighlight(
1163
- buffer,
1164
- frame.cursorPosition,
1165
- this.effects.cursor,
1166
- width,
1167
- height
1168
- );
1169
- }
1170
- if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
1171
- buffer = await renderCursorTrail(
1172
- buffer,
1173
- ctx.cursorTrail,
1174
- this.effects.cursor,
1175
- width,
1176
- height
1177
- );
1178
- }
1179
- if (this.effects.cursor.enabled && frame.cursorPosition) {
1180
- buffer = await renderCursor(
1181
- buffer,
1182
- frame.cursorPosition,
1183
- this.effects.cursor,
1184
- width,
1185
- height
1186
- );
1187
- }
1188
- if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
1189
- const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
1190
- buffer = await renderClickEffect(
1191
- buffer,
1192
- frame.clickPosition,
1193
- this.effects.cursor,
1194
- progress,
1195
- width,
1196
- height
1197
- );
1198
- }
1199
- if (this.effects.keystroke.enabled && frame.keystrokes) {
1200
- buffer = await renderKeystrokeHud(
1201
- buffer,
1202
- frame.keystrokes,
1203
- frame.timestamp,
1204
- this.effects.keystroke,
1205
- width,
1206
- height
1207
- );
1208
- }
1209
- const scale = ctx.zoomScale;
1210
- if (this.effects.zoom.enabled && scale > 1) {
1211
- const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
1212
- const offset = getFrameOffset(this.effects.deviceFrame);
1213
- const focusPoint = {
1214
- x: rawFocus.x + offset.left,
1215
- y: rawFocus.y + offset.top
1216
- };
1217
- buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1218
- }
1219
- buffer = await applyBackground(
1220
- buffer,
1221
- this.effects.background,
1222
- this.output.width,
1223
- this.output.height
1224
- );
1225
- if (this.effects.watermark.enabled) {
1226
- buffer = await renderWatermark(
1227
- buffer,
1228
- this.effects.watermark,
1229
- this.output.width,
1230
- this.output.height
1231
- );
1232
- }
1233
- buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
1234
- return {
1235
- index: frame.index,
1236
- buffer,
1237
- timestamp: frame.timestamp
1238
- };
1553
+ return composeFrame(frame, this.effects, this.output, context);
1239
1554
  }
1240
1555
  /**
1241
1556
  * Process an entire sequence of captured frames through the effects pipeline.
@@ -1243,7 +1558,7 @@ var CanvasRenderer = class {
1243
1558
  * Multi-pass approach:
1244
1559
  * Pass 1: Speed ramping (adjust frame set).
1245
1560
  * Pass 2: Calculate per-frame contexts (zoom, click, trail).
1246
- * Pass 3: Render each frame with effects.
1561
+ * Pass 3: Render frames in parallel using worker threads.
1247
1562
  * Pass 4: Apply scene transitions at step boundaries.
1248
1563
  */
1249
1564
  async composeAll(frames) {
@@ -1253,10 +1568,19 @@ var CanvasRenderer = class {
1253
1568
  processFrames = this.applySpeedRamp(frames);
1254
1569
  }
1255
1570
  const contexts = this.calculateFrameContexts(processFrames);
1256
- const composed = [];
1257
- for (let i = 0; i < processFrames.length; i++) {
1258
- const result = await this.composeFrame(processFrames[i], contexts[i]);
1259
- composed.push(result);
1571
+ const cpuCount = os.cpus().length;
1572
+ const workerCount = Math.min(cpuCount, 8);
1573
+ const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
1574
+ let composed;
1575
+ if (useWorkers) {
1576
+ composed = await this.processWithWorkers(processFrames, contexts, workerCount);
1577
+ } else {
1578
+ composed = [];
1579
+ for (let i = 0; i < processFrames.length; i++) {
1580
+ composed.push(
1581
+ await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
1582
+ );
1583
+ }
1260
1584
  }
1261
1585
  if (this.steps.length > 0) {
1262
1586
  await this.applyTransitions(composed, processFrames);
@@ -1264,19 +1588,78 @@ var CanvasRenderer = class {
1264
1588
  return composed;
1265
1589
  }
1266
1590
  /**
1267
- * Calculate per-frame rendering context (zoom, click progress, cursor trail, tilt).
1591
+ * Distribute frame composition across a pool of worker threads.
1592
+ * Workers process frames concurrently; results are collected in order.
1593
+ */
1594
+ processWithWorkers(frames, contexts, workerCount) {
1595
+ return new Promise((resolve, reject) => {
1596
+ const results = new Array(frames.length);
1597
+ let completed = 0;
1598
+ let nextIndex = 0;
1599
+ let failed = false;
1600
+ const workerUrl = getWorkerUrl();
1601
+ const workers = [];
1602
+ const dispatch = (worker) => {
1603
+ if (nextIndex >= frames.length || failed) return;
1604
+ const i = nextIndex++;
1605
+ worker.postMessage({
1606
+ taskId: i,
1607
+ frame: frames[i],
1608
+ effects: this.effects,
1609
+ output: this.output,
1610
+ context: contexts[i]
1611
+ });
1612
+ };
1613
+ for (let w = 0; w < workerCount; w++) {
1614
+ const worker = new Worker(workerUrl);
1615
+ workers.push(worker);
1616
+ worker.on("message", (msg) => {
1617
+ if (failed) return;
1618
+ if (msg.error) {
1619
+ failed = true;
1620
+ workers.forEach((wk) => wk.terminate());
1621
+ reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
1622
+ return;
1623
+ }
1624
+ results[msg.taskId] = {
1625
+ index: frames[msg.taskId].index,
1626
+ buffer: Buffer.from(msg.buffer),
1627
+ timestamp: frames[msg.taskId].timestamp,
1628
+ rawInfo: msg.rawInfo
1629
+ };
1630
+ completed++;
1631
+ if (completed === frames.length) {
1632
+ workers.forEach((wk) => wk.terminate());
1633
+ resolve(results);
1634
+ } else {
1635
+ dispatch(worker);
1636
+ }
1637
+ });
1638
+ worker.on("error", (err) => {
1639
+ if (failed) return;
1640
+ failed = true;
1641
+ workers.forEach((wk) => wk.terminate());
1642
+ reject(err);
1643
+ });
1644
+ dispatch(worker);
1645
+ }
1646
+ });
1647
+ }
1648
+ /**
1649
+ * Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
1268
1650
  */
1269
1651
  calculateFrameContexts(frames) {
1270
1652
  const contexts = [];
1271
1653
  const transitionFrames = Math.round(
1272
1654
  this.output.fps * (this.effects.zoom.duration / 1e3)
1273
1655
  );
1656
+ const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
1274
1657
  for (let i = 0; i < frames.length; i++) {
1275
1658
  const frame = frames[i];
1276
1659
  let zoomScale = 1;
1277
1660
  if (this.effects.zoom.enabled) {
1278
- zoomScale = calculateAdaptiveZoom(
1279
- frames,
1661
+ zoomScale = calculateAdaptiveZoomFromLookup(
1662
+ clickLookup,
1280
1663
  i,
1281
1664
  this.effects.zoom.scale,
1282
1665
  transitionFrames
@@ -1296,7 +1679,6 @@ var CanvasRenderer = class {
1296
1679
  }
1297
1680
  /**
1298
1681
  * Apply speed ramping: slow down near actions, speed up during idle.
1299
- * Returns a new frame array with frames duplicated or skipped.
1300
1682
  */
1301
1683
  applySpeedRamp(frames) {
1302
1684
  const config = this.effects.speedRamp;
@@ -1327,9 +1709,371 @@ var CanvasRenderer = class {
1327
1709
  }
1328
1710
  return result;
1329
1711
  }
1712
+ // ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
1713
+ /**
1714
+ * Returns true when no effect requires the full frame array upfront.
1715
+ *
1716
+ * When true, composeStreamOnline() can be used: frames are composited as they
1717
+ * arrive (no need to wait for all frames to be collected first).
1718
+ *
1719
+ * Currently the only blocking effect is speed ramp, which needs to scan all
1720
+ * frames to compute action-proximity indices. Zoom uses the window-based
1721
+ * calculateAdaptiveZoomInWindow() so it works with a rolling lookahead buffer.
1722
+ */
1723
+ canStreamOnline() {
1724
+ return !this.effects.speedRamp.enabled;
1725
+ }
1726
+ /**
1727
+ * Online streaming compose — accepts an AsyncIterable of frames (e.g. from
1728
+ * ClipwiseRecorder.recordToChannel()) and begins compositing immediately,
1729
+ * without waiting for all frames to be collected.
1730
+ *
1731
+ * Each frame is dispatched to the worker pool as soon as its zoom lookahead
1732
+ * window is satisfied (i.e. when frame i + transitionFrames has arrived).
1733
+ * This creates a natural pipeline: recording produces frames while workers
1734
+ * consume them in parallel.
1735
+ *
1736
+ * Requires canStreamOnline() === true (speedRamp must be disabled).
1737
+ * Transitions (step boundaries with transition: fade) are applied inline
1738
+ * using the same applyTransitionsToStream() logic as composeStream().
1739
+ */
1740
+ async *composeStreamOnline(source) {
1741
+ const hasFadeTransitions = this.steps.some((s) => s.transition === "fade");
1742
+ if (!hasFadeTransitions) {
1743
+ const cpuCount = os.cpus().length;
1744
+ const workerCount = Math.min(cpuCount, 8);
1745
+ yield* this.streamOnlineWithWorkers(source, workerCount);
1746
+ return;
1747
+ }
1748
+ const collected = [];
1749
+ for await (const frame of source) {
1750
+ collected.push(frame);
1751
+ }
1752
+ yield* this.composeStream(collected);
1753
+ }
1754
+ /**
1755
+ * Worker-pool online streaming: dispatches frame i to a worker as soon as
1756
+ * frame i + transitionFrames has arrived from the source.
1757
+ *
1758
+ * Uses a notify-on-progress pattern (same as streamWithWorkers) extended
1759
+ * with an intake coroutine that feeds the growing frames[] buffer.
1760
+ */
1761
+ async *streamOnlineWithWorkers(source, workerCount) {
1762
+ const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
1763
+ const trailLength = this.effects.cursor.trailLength;
1764
+ const frames = [];
1765
+ let sourceComplete = false;
1766
+ let workerError = null;
1767
+ let notify = null;
1768
+ const trigger = () => {
1769
+ notify?.();
1770
+ notify = null;
1771
+ };
1772
+ const waitForProgress = () => new Promise((r) => {
1773
+ notify = r;
1774
+ });
1775
+ const completed = /* @__PURE__ */ new Map();
1776
+ const idleWorkers = [];
1777
+ let nextToDispatch = 0;
1778
+ let nextToYield = 0;
1779
+ const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
1780
+ const computeContext = (i) => {
1781
+ const frame = frames[i];
1782
+ let zoomScale = 1;
1783
+ if (this.effects.zoom.enabled) {
1784
+ const lo = Math.max(0, i - transitionFrames);
1785
+ const hi = Math.min(frames.length - 1, i + transitionFrames);
1786
+ zoomScale = calculateAdaptiveZoomInWindow(
1787
+ frames.slice(lo, hi + 1),
1788
+ lo,
1789
+ i,
1790
+ this.effects.zoom.scale,
1791
+ transitionFrames
1792
+ );
1793
+ }
1794
+ const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
1795
+ const trail = [];
1796
+ for (let j = Math.max(0, i - trailLength); j <= i; j++) {
1797
+ if (frames[j].cursorPosition) trail.push(frames[j].cursorPosition);
1798
+ }
1799
+ return { zoomScale, clickProgress, cursorTrail: trail };
1800
+ };
1801
+ const dispatch = (worker) => {
1802
+ if (canDispatch(nextToDispatch)) {
1803
+ const i = nextToDispatch++;
1804
+ worker.postMessage({
1805
+ taskId: i,
1806
+ frame: frames[i],
1807
+ effects: this.effects,
1808
+ output: this.output,
1809
+ context: computeContext(i)
1810
+ });
1811
+ } else {
1812
+ idleWorkers.push(worker);
1813
+ }
1814
+ };
1815
+ const dispatchToIdle = () => {
1816
+ while (idleWorkers.length > 0 && canDispatch(nextToDispatch)) {
1817
+ dispatch(idleWorkers.shift());
1818
+ }
1819
+ };
1820
+ const workerUrl = getWorkerUrl();
1821
+ const workers = [];
1822
+ for (let w = 0; w < workerCount; w++) {
1823
+ const worker = new Worker(workerUrl);
1824
+ workers.push(worker);
1825
+ worker.on("message", (msg) => {
1826
+ if (workerError) return;
1827
+ if (msg.error) {
1828
+ workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
1829
+ } else {
1830
+ completed.set(msg.taskId, {
1831
+ index: frames[msg.taskId].index,
1832
+ buffer: Buffer.from(msg.buffer),
1833
+ timestamp: frames[msg.taskId].timestamp,
1834
+ rawInfo: msg.rawInfo
1835
+ });
1836
+ dispatch(worker);
1837
+ }
1838
+ trigger();
1839
+ });
1840
+ worker.on("error", (err) => {
1841
+ workerError = err;
1842
+ trigger();
1843
+ });
1844
+ idleWorkers.push(worker);
1845
+ }
1846
+ const intakeTask = (async () => {
1847
+ for await (const frame of source) {
1848
+ frames.push(frame);
1849
+ dispatchToIdle();
1850
+ trigger();
1851
+ }
1852
+ sourceComplete = true;
1853
+ dispatchToIdle();
1854
+ trigger();
1855
+ })();
1856
+ try {
1857
+ while (true) {
1858
+ if (workerError) throw workerError;
1859
+ if (sourceComplete && nextToDispatch >= frames.length && nextToYield >= frames.length) {
1860
+ break;
1861
+ }
1862
+ if (completed.has(nextToYield)) {
1863
+ const frame = completed.get(nextToYield);
1864
+ completed.delete(nextToYield);
1865
+ nextToYield++;
1866
+ yield frame;
1867
+ continue;
1868
+ }
1869
+ await waitForProgress();
1870
+ }
1871
+ } finally {
1872
+ await intakeTask;
1873
+ workers.forEach((w) => w.terminate());
1874
+ }
1875
+ }
1876
+ // ─── Streaming pipeline (Phase 1-B) ────────────────────────────────────────
1877
+ /**
1878
+ * Stream frame composition — yields ComposedFrames as workers finish,
1879
+ * in display order, so the encoder can start before all frames are composed.
1880
+ *
1881
+ * Same 4-pass structure as composeAll():
1882
+ * Pass 1 & 2 run upfront (need the full frame set).
1883
+ * Pass 3 streams via the worker pool (ordered yield).
1884
+ * Pass 4 transitions are buffered inline and applied at step boundaries.
1885
+ */
1886
+ async *composeStream(frames) {
1887
+ if (frames.length === 0) return;
1888
+ let processFrames = frames;
1889
+ if (this.effects.speedRamp.enabled) {
1890
+ processFrames = this.applySpeedRamp(frames);
1891
+ }
1892
+ const contexts = this.calculateFrameContexts(processFrames);
1893
+ const windows = this.getTransitionWindows(processFrames);
1894
+ const cpuCount = os.cpus().length;
1895
+ const workerCount = Math.min(cpuCount, 8);
1896
+ const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
1897
+ const rawStream = useWorkers ? this.streamWithWorkers(processFrames, contexts, workerCount) : this.streamSequential(processFrames, contexts);
1898
+ yield* this.applyTransitionsToStream(rawStream, windows);
1899
+ }
1900
+ /**
1901
+ * Worker-pool streaming: dispatches frames to workers and yields results
1902
+ * in display order as soon as each frame is ready.
1903
+ *
1904
+ * Uses a notify-on-progress pattern to bridge event-driven workers
1905
+ * to an ordered AsyncGenerator without busy-polling.
1906
+ */
1907
+ async *streamWithWorkers(frames, contexts, workerCount) {
1908
+ const completed = new Array(frames.length);
1909
+ let workerError = null;
1910
+ let notify = null;
1911
+ const waitForProgress = () => new Promise((r) => {
1912
+ notify = r;
1913
+ });
1914
+ const workerUrl = getWorkerUrl();
1915
+ const workers = [];
1916
+ let nextToDispatch = 0;
1917
+ const dispatch = (worker) => {
1918
+ if (nextToDispatch >= frames.length || workerError) return;
1919
+ const i = nextToDispatch++;
1920
+ worker.postMessage({
1921
+ taskId: i,
1922
+ frame: frames[i],
1923
+ effects: this.effects,
1924
+ output: this.output,
1925
+ context: contexts[i]
1926
+ });
1927
+ };
1928
+ for (let w = 0; w < workerCount; w++) {
1929
+ const worker = new Worker(workerUrl);
1930
+ workers.push(worker);
1931
+ worker.on("message", (msg) => {
1932
+ if (workerError) return;
1933
+ if (msg.error) {
1934
+ workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
1935
+ } else {
1936
+ completed[msg.taskId] = {
1937
+ index: frames[msg.taskId].index,
1938
+ buffer: Buffer.from(msg.buffer),
1939
+ timestamp: frames[msg.taskId].timestamp,
1940
+ rawInfo: msg.rawInfo
1941
+ };
1942
+ dispatch(worker);
1943
+ }
1944
+ notify?.();
1945
+ notify = null;
1946
+ });
1947
+ worker.on("error", (err) => {
1948
+ workerError = err;
1949
+ notify?.();
1950
+ notify = null;
1951
+ });
1952
+ dispatch(worker);
1953
+ }
1954
+ try {
1955
+ for (let i = 0; i < frames.length; i++) {
1956
+ while (completed[i] === void 0 && !workerError) {
1957
+ await waitForProgress();
1958
+ }
1959
+ if (workerError) throw workerError;
1960
+ const frame = completed[i];
1961
+ completed[i] = void 0;
1962
+ yield frame;
1963
+ }
1964
+ } finally {
1965
+ workers.forEach((w) => w.terminate());
1966
+ }
1967
+ }
1968
+ /**
1969
+ * Sequential streaming fallback for small frame counts where worker
1970
+ * thread overhead would exceed the parallelism benefit.
1971
+ */
1972
+ async *streamSequential(frames, contexts) {
1973
+ for (let i = 0; i < frames.length; i++) {
1974
+ yield await composeFrame(frames[i], this.effects, this.output, contexts[i]);
1975
+ }
1976
+ }
1977
+ /**
1978
+ * Pre-compute [startIdx, endIdx] windows for every fade transition so that
1979
+ * applyTransitionsToStream can buffer only those frames.
1980
+ */
1981
+ getTransitionWindows(frames) {
1982
+ if (this.steps.length === 0) return [];
1983
+ const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
1984
+ const windows = [];
1985
+ for (let i = 1; i < frames.length; i++) {
1986
+ if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
1987
+ const stepIdx = frames[i].stepIndex;
1988
+ const step = this.steps[stepIdx];
1989
+ if (step && step.transition === "fade") {
1990
+ const startIdx = Math.max(0, i - Math.floor(transitionFrames / 2));
1991
+ const endIdx = Math.min(frames.length - 1, i + Math.ceil(transitionFrames / 2));
1992
+ if (endIdx - startIdx >= 2) {
1993
+ windows.push({ startIdx, endIdx });
1994
+ }
1995
+ }
1996
+ }
1997
+ }
1998
+ return windows;
1999
+ }
2000
+ /**
2001
+ * Wrap a ComposedFrame stream with inline transition buffering.
2002
+ *
2003
+ * Non-transition frames are yielded immediately.
2004
+ * Frames inside a fade window are held until both endpoints are available,
2005
+ * then the crossfade is applied and all window frames are flushed in order.
2006
+ * A pending map maintains global display order across window boundaries.
2007
+ */
2008
+ async *applyTransitionsToStream(source, windows) {
2009
+ if (windows.length === 0) {
2010
+ yield* source;
2011
+ return;
2012
+ }
2013
+ const frameToWindow = /* @__PURE__ */ new Map();
2014
+ for (let wi = 0; wi < windows.length; wi++) {
2015
+ for (let i = windows[wi].startIdx; i <= windows[wi].endIdx; i++) {
2016
+ frameToWindow.set(i, wi);
2017
+ }
2018
+ }
2019
+ const windowState = windows.map((w) => ({
2020
+ frames: new Array(w.endIdx - w.startIdx + 1),
2021
+ received: 0
2022
+ }));
2023
+ const pending = /* @__PURE__ */ new Map();
2024
+ let nextToYield = 0;
2025
+ let frameIdx = 0;
2026
+ for await (const frame of source) {
2027
+ const idx = frameIdx++;
2028
+ const wi = frameToWindow.get(idx);
2029
+ if (wi === void 0) {
2030
+ pending.set(idx, frame);
2031
+ } else {
2032
+ const win = windows[wi];
2033
+ const state = windowState[wi];
2034
+ state.frames[idx - win.startIdx] = frame;
2035
+ state.received++;
2036
+ if (state.received === state.frames.length) {
2037
+ const fromBuf = state.frames[0].buffer;
2038
+ const toBuf = state.frames[state.frames.length - 1].buffer;
2039
+ const range = state.frames.length - 1;
2040
+ const fromRawInfo = state.frames[0].rawInfo;
2041
+ const toRawInfo = state.frames[state.frames.length - 1].rawInfo;
2042
+ for (let j = 1; j < state.frames.length - 1; j++) {
2043
+ const blended = await applyCrossfade(
2044
+ fromBuf,
2045
+ toBuf,
2046
+ j / range,
2047
+ this.output.width,
2048
+ this.output.height,
2049
+ fromRawInfo,
2050
+ toRawInfo
2051
+ );
2052
+ state.frames[j] = {
2053
+ ...state.frames[j],
2054
+ buffer: blended.buffer,
2055
+ rawInfo: blended.rawInfo
2056
+ };
2057
+ }
2058
+ for (let j = 0; j < state.frames.length; j++) {
2059
+ pending.set(win.startIdx + j, state.frames[j]);
2060
+ }
2061
+ }
2062
+ }
2063
+ while (pending.has(nextToYield)) {
2064
+ yield pending.get(nextToYield);
2065
+ pending.delete(nextToYield);
2066
+ nextToYield++;
2067
+ }
2068
+ }
2069
+ while (pending.has(nextToYield)) {
2070
+ yield pending.get(nextToYield);
2071
+ pending.delete(nextToYield);
2072
+ nextToYield++;
2073
+ }
2074
+ }
1330
2075
  /**
1331
2076
  * Apply crossfade transitions at step boundaries where configured.
1332
- * Modifies the composed array in-place.
1333
2077
  */
1334
2078
  async applyTransitions(composed, frames) {
1335
2079
  const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
@@ -1350,17 +2094,21 @@ var CanvasRenderer = class {
1350
2094
  if (range < 2) continue;
1351
2095
  const fromBuffer = composed[startIdx].buffer;
1352
2096
  const toBuffer = composed[endIdx].buffer;
1353
- const width = this.output.width;
1354
- const height = this.output.height;
2097
+ const fromRawInfo = composed[startIdx].rawInfo;
2098
+ const toRawInfo = composed[endIdx].rawInfo;
1355
2099
  for (let i = startIdx + 1; i < endIdx; i++) {
1356
2100
  const progress = (i - startIdx) / range;
1357
- composed[i].buffer = await applyCrossfade(
2101
+ const blended = await applyCrossfade(
1358
2102
  fromBuffer,
1359
2103
  toBuffer,
1360
2104
  progress,
1361
- width,
1362
- height
2105
+ this.output.width,
2106
+ this.output.height,
2107
+ fromRawInfo,
2108
+ toRawInfo
1363
2109
  );
2110
+ composed[i].buffer = blended.buffer;
2111
+ composed[i].rawInfo = blended.rawInfo;
1364
2112
  }
1365
2113
  }
1366
2114
  }
@@ -1369,11 +2117,45 @@ var CanvasRenderer = class {
1369
2117
  // src/compose/video-encoder.ts
1370
2118
  import gifenc from "gifenc";
1371
2119
  import sharp9 from "sharp";
1372
- import { writeFile, mkdir, readFile, rm, mkdtemp } from "fs/promises";
2120
+ import { writeFile, mkdir, readFile, rm } from "fs/promises";
1373
2121
  import { join } from "path";
1374
2122
  import { tmpdir } from "os";
1375
2123
  import { spawn } from "child_process";
1376
2124
  var { GIFEncoder, quantize, applyPalette } = gifenc;
2125
+ var ENCODING_PRESETS = {
2126
+ social: { crf: 22, vtQuality: 75 },
2127
+ balanced: { crf: 18, vtQuality: 85 },
2128
+ archive: { crf: 13, vtQuality: 92 }
2129
+ };
2130
+ function resolveEncodingParams(config) {
2131
+ if (config.preset) return ENCODING_PRESETS[config.preset];
2132
+ process.stderr.write(
2133
+ `[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
2134
+ `
2135
+ );
2136
+ if (config.quality >= 75) return ENCODING_PRESETS.social;
2137
+ if (config.quality >= 45) return ENCODING_PRESETS.balanced;
2138
+ return ENCODING_PRESETS.archive;
2139
+ }
2140
+ var encoderDetectionPromise = null;
2141
+ function detectVideoEncoder() {
2142
+ if (!encoderDetectionPromise) {
2143
+ encoderDetectionPromise = new Promise((resolve) => {
2144
+ const proc = spawn("ffmpeg", ["-encoders"], {
2145
+ stdio: ["ignore", "pipe", "ignore"]
2146
+ });
2147
+ let out = "";
2148
+ proc.stdout.on("data", (d) => out += d.toString());
2149
+ proc.on("close", () => {
2150
+ if (out.includes("hevc_videotoolbox")) resolve("hevc_videotoolbox");
2151
+ else if (out.includes("h264_videotoolbox")) resolve("h264_videotoolbox");
2152
+ else resolve("libx264");
2153
+ });
2154
+ proc.on("error", () => resolve("libx264"));
2155
+ });
2156
+ }
2157
+ return encoderDetectionPromise;
2158
+ }
1377
2159
  async function encodeGif(frames, config) {
1378
2160
  if (frames.length === 0) {
1379
2161
  throw new Error("Cannot encode GIF: no frames provided");
@@ -1383,14 +2165,12 @@ async function encodeGif(frames, config) {
1383
2165
  const gif = GIFEncoder();
1384
2166
  const delay = Math.round(1e3 / config.fps);
1385
2167
  for (const frame of frames) {
1386
- const { data, info } = await sharp9(frame.buffer).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
2168
+ const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
2169
+ const { data, info } = await src.resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1387
2170
  const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1388
2171
  const palette = quantize(rgba, 256);
1389
2172
  const indexed = applyPalette(rgba, palette);
1390
- gif.writeFrame(indexed, width, height, {
1391
- palette,
1392
- delay
1393
- });
2173
+ gif.writeFrame(indexed, width, height, { palette, delay });
1394
2174
  }
1395
2175
  gif.finish();
1396
2176
  return Buffer.from(gif.bytes());
@@ -1399,50 +2179,200 @@ async function encodeMp4(frames, config) {
1399
2179
  if (frames.length === 0) {
1400
2180
  throw new Error("Cannot encode MP4: no frames provided");
1401
2181
  }
1402
- const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
2182
+ const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
1403
2183
  try {
1404
- const padLength = String(frames.length).length;
1405
- for (const frame of frames) {
1406
- const paddedIndex = String(frame.index).padStart(padLength, "0");
1407
- const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
1408
- await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
1409
- }
1410
- const outputPath = join(tmpDir, "output.mp4");
1411
- const crf = Math.round(51 - config.quality / 100 * 51);
1412
- await runFfmpeg([
1413
- "-y",
1414
- "-framerate",
1415
- String(config.fps),
1416
- "-i",
1417
- join(tmpDir, `frame-%0${padLength}d.png`),
1418
- "-c:v",
1419
- "libx264",
1420
- "-pix_fmt",
1421
- "yuv420p",
1422
- "-crf",
1423
- String(crf),
1424
- "-preset",
1425
- "slow",
1426
- "-tune",
1427
- "animation",
1428
- "-movflags",
1429
- "+faststart",
1430
- outputPath
1431
- ]);
2184
+ const encoder = await detectVideoEncoder();
2185
+ const params = resolveEncodingParams(config);
2186
+ await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
1432
2187
  return await readFile(outputPath);
1433
2188
  } finally {
1434
- await rm(tmpDir, { recursive: true, force: true }).catch(() => {
2189
+ await rm(outputPath, { force: true }).catch(() => {
1435
2190
  });
1436
2191
  }
1437
2192
  }
1438
- function runFfmpeg(args) {
2193
+ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
2194
+ const videoArgs = encoder === "hevc_videotoolbox" ? [
2195
+ "-c:v",
2196
+ "hevc_videotoolbox",
2197
+ "-q:v",
2198
+ String(params.vtQuality),
2199
+ "-pix_fmt",
2200
+ "yuv420p",
2201
+ "-tag:v",
2202
+ "hvc1"
2203
+ // required for playback in QuickTime / Apple devices
2204
+ ] : encoder === "h264_videotoolbox" ? [
2205
+ "-c:v",
2206
+ "h264_videotoolbox",
2207
+ "-q:v",
2208
+ String(params.vtQuality),
2209
+ "-pix_fmt",
2210
+ "yuv420p"
2211
+ ] : [
2212
+ "-c:v",
2213
+ "libx264",
2214
+ "-crf",
2215
+ String(params.crf),
2216
+ "-preset",
2217
+ "medium",
2218
+ "-tune",
2219
+ "stillimage",
2220
+ "-profile:v",
2221
+ "high",
2222
+ "-level",
2223
+ "4.1",
2224
+ "-pix_fmt",
2225
+ "yuv420p"
2226
+ ];
1439
2227
  return new Promise((resolve, reject) => {
1440
- const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
2228
+ const ffmpeg = spawn(
2229
+ "ffmpeg",
2230
+ [
2231
+ "-y",
2232
+ // Video input: raw RGB24 from stdin
2233
+ "-f",
2234
+ "rawvideo",
2235
+ "-pixel_format",
2236
+ "rgb24",
2237
+ "-video_size",
2238
+ `${config.width}x${config.height}`,
2239
+ "-framerate",
2240
+ String(config.fps),
2241
+ "-i",
2242
+ "pipe:0",
2243
+ // Silent audio track for platform compatibility
2244
+ "-f",
2245
+ "lavfi",
2246
+ "-i",
2247
+ "anullsrc=r=48000:cl=stereo",
2248
+ ...videoArgs,
2249
+ "-c:a",
2250
+ "aac",
2251
+ "-b:a",
2252
+ "128k",
2253
+ "-shortest",
2254
+ "-movflags",
2255
+ "+faststart",
2256
+ outputPath
2257
+ ],
2258
+ { stdio: ["pipe", "ignore", "pipe"] }
2259
+ );
1441
2260
  let stderr = "";
1442
- proc.stderr.on("data", (data) => {
1443
- stderr += data.toString();
2261
+ ffmpeg.stderr.on("data", (d) => stderr += d.toString());
2262
+ ffmpeg.on("close", (code) => {
2263
+ if (code === 0) {
2264
+ resolve();
2265
+ } else {
2266
+ reject(
2267
+ new Error(
2268
+ `FFmpeg encoding failed (exit code ${code}). Make sure ffmpeg is installed: brew install ffmpeg
2269
+ ` + stderr.slice(-500)
2270
+ )
2271
+ );
2272
+ }
1444
2273
  });
1445
- proc.on("close", (code) => {
2274
+ ffmpeg.on("error", (err) => {
2275
+ if (err.code === "ENOENT") {
2276
+ reject(
2277
+ new Error(
2278
+ "ffmpeg not found. Install it to encode MP4:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: choco install ffmpeg"
2279
+ )
2280
+ );
2281
+ } else {
2282
+ reject(err);
2283
+ }
2284
+ });
2285
+ (async () => {
2286
+ for (const frame of frames) {
2287
+ const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
2288
+ const raw = await src.flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
2289
+ if (!ffmpeg.stdin.write(raw)) {
2290
+ await new Promise((r) => ffmpeg.stdin.once("drain", r));
2291
+ }
2292
+ }
2293
+ ffmpeg.stdin.end();
2294
+ })().catch(reject);
2295
+ });
2296
+ }
2297
+ async function encodeMp4Stream(frames, config) {
2298
+ const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
2299
+ try {
2300
+ const encoder = await detectVideoEncoder();
2301
+ const params = resolveEncodingParams(config);
2302
+ await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath);
2303
+ return await readFile(outputPath);
2304
+ } finally {
2305
+ await rm(outputPath, { force: true }).catch(() => {
2306
+ });
2307
+ }
2308
+ }
2309
+ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
2310
+ const videoArgs = encoder === "hevc_videotoolbox" ? [
2311
+ "-c:v",
2312
+ "hevc_videotoolbox",
2313
+ "-q:v",
2314
+ String(params.vtQuality),
2315
+ "-pix_fmt",
2316
+ "yuv420p",
2317
+ "-tag:v",
2318
+ "hvc1"
2319
+ ] : encoder === "h264_videotoolbox" ? [
2320
+ "-c:v",
2321
+ "h264_videotoolbox",
2322
+ "-q:v",
2323
+ String(params.vtQuality),
2324
+ "-pix_fmt",
2325
+ "yuv420p"
2326
+ ] : [
2327
+ "-c:v",
2328
+ "libx264",
2329
+ "-crf",
2330
+ String(params.crf),
2331
+ "-preset",
2332
+ "medium",
2333
+ "-tune",
2334
+ "stillimage",
2335
+ "-profile:v",
2336
+ "high",
2337
+ "-level",
2338
+ "4.1",
2339
+ "-pix_fmt",
2340
+ "yuv420p"
2341
+ ];
2342
+ return new Promise((resolve, reject) => {
2343
+ const ffmpeg = spawn(
2344
+ "ffmpeg",
2345
+ [
2346
+ "-y",
2347
+ "-f",
2348
+ "rawvideo",
2349
+ "-pixel_format",
2350
+ "rgb24",
2351
+ "-video_size",
2352
+ `${config.width}x${config.height}`,
2353
+ "-framerate",
2354
+ String(config.fps),
2355
+ "-i",
2356
+ "pipe:0",
2357
+ "-f",
2358
+ "lavfi",
2359
+ "-i",
2360
+ "anullsrc=r=48000:cl=stereo",
2361
+ ...videoArgs,
2362
+ "-c:a",
2363
+ "aac",
2364
+ "-b:a",
2365
+ "128k",
2366
+ "-shortest",
2367
+ "-movflags",
2368
+ "+faststart",
2369
+ outputPath
2370
+ ],
2371
+ { stdio: ["pipe", "ignore", "pipe"] }
2372
+ );
2373
+ let stderr = "";
2374
+ ffmpeg.stderr.on("data", (d) => stderr += d.toString());
2375
+ ffmpeg.on("close", (code) => {
1446
2376
  if (code === 0) {
1447
2377
  resolve();
1448
2378
  } else {
@@ -1454,7 +2384,7 @@ function runFfmpeg(args) {
1454
2384
  );
1455
2385
  }
1456
2386
  });
1457
- proc.on("error", (err) => {
2387
+ ffmpeg.on("error", (err) => {
1458
2388
  if (err.code === "ENOENT") {
1459
2389
  reject(
1460
2390
  new Error(
@@ -1465,6 +2395,16 @@ function runFfmpeg(args) {
1465
2395
  reject(err);
1466
2396
  }
1467
2397
  });
2398
+ (async () => {
2399
+ for await (const frame of frames) {
2400
+ const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
2401
+ const raw = await src.flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
2402
+ if (!ffmpeg.stdin.write(raw)) {
2403
+ await new Promise((r) => ffmpeg.stdin.once("drain", r));
2404
+ }
2405
+ }
2406
+ ffmpeg.stdin.end();
2407
+ })().catch(reject);
1468
2408
  });
1469
2409
  }
1470
2410
  async function savePngSequence(frames, config) {
@@ -1486,6 +2426,76 @@ async function savePngSequence(frames, config) {
1486
2426
  return paths;
1487
2427
  }
1488
2428
 
2429
+ // src/compose/streaming-session.ts
2430
+ import { EventEmitter } from "events";
2431
+ var ConcurrentSession = class extends EventEmitter {
2432
+ constructor(recorder, scenario, renderer) {
2433
+ super();
2434
+ this.recorder = recorder;
2435
+ this.scenario = scenario;
2436
+ this.renderer = renderer;
2437
+ }
2438
+ /**
2439
+ * Start recording and compositing concurrently.
2440
+ * Returns when both recording and encoding are complete.
2441
+ */
2442
+ async run() {
2443
+ const handle = this.recorder.recordToChannel(this.scenario);
2444
+ let composed = 0;
2445
+ const self = this;
2446
+ const buffer = await encodeMp4Stream(
2447
+ (async function* () {
2448
+ for await (const frame of self.renderer.composeStreamOnline(handle.frameStream)) {
2449
+ composed++;
2450
+ self.emit("progress", { composed, total: -1, pct: -1 });
2451
+ yield frame;
2452
+ }
2453
+ })(),
2454
+ this.scenario.output
2455
+ );
2456
+ const session = await handle.done;
2457
+ this.emit("progress", { composed, total: composed, pct: 100 });
2458
+ return { buffer, session };
2459
+ }
2460
+ };
2461
+ var StreamingSession = class extends EventEmitter {
2462
+ constructor(session, renderer) {
2463
+ super();
2464
+ this.session = session;
2465
+ this.renderer = renderer;
2466
+ }
2467
+ /** Total frames in the underlying recording session. */
2468
+ get totalFrames() {
2469
+ return this.session.frames.length;
2470
+ }
2471
+ /**
2472
+ * Run the compose → encode pipeline.
2473
+ *
2474
+ * Composes frames via the worker pool (Phase 1-B streaming, ordered yield),
2475
+ * forwarding each to FFmpeg as it completes. Emits a 'progress' event after
2476
+ * every composed frame so callers can update a spinner or progress bar.
2477
+ *
2478
+ * @returns The fully-encoded MP4 as a Buffer.
2479
+ */
2480
+ async run() {
2481
+ const { frames, scenario } = this.session;
2482
+ const total = frames.length;
2483
+ let composed = 0;
2484
+ const self = this;
2485
+ return encodeMp4Stream(
2486
+ (async function* () {
2487
+ for await (const frame of self.renderer.composeStream(frames)) {
2488
+ composed++;
2489
+ const pct = total > 0 ? Math.round(composed / total * 100) : 100;
2490
+ self.emit("progress", { composed, total, pct });
2491
+ yield frame;
2492
+ }
2493
+ })(),
2494
+ scenario.output
2495
+ );
2496
+ }
2497
+ };
2498
+
1489
2499
  // src/script/parser.ts
1490
2500
  import { parse as parseYaml } from "yaml";
1491
2501
  import { readFile as readFile2 } from "fs/promises";
@@ -1655,8 +2665,13 @@ var OutputConfigSchema = z.object({
1655
2665
  format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
1656
2666
  width: z.number().default(1280),
1657
2667
  height: z.number().default(800),
1658
- fps: z.number().min(1).max(60).default(15),
2668
+ fps: z.number().min(1).max(60).default(30),
1659
2669
  quality: z.number().min(1).max(100).default(80),
2670
+ // Encoding preset for MP4 output. Overrides quality when set.
2671
+ // social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
2672
+ // balanced — general-purpose, good quality/size trade-off (CRF 20)
2673
+ // archive — high-fidelity storage, larger file (CRF 15)
2674
+ preset: z.enum(["social", "balanced", "archive"]).optional(),
1660
2675
  outputDir: z.string().default("./output"),
1661
2676
  filename: z.string().default("clipwise-recording")
1662
2677
  });
@@ -1790,11 +2805,17 @@ function validateScenario(scenario) {
1790
2805
  export {
1791
2806
  CanvasRenderer,
1792
2807
  ClipwiseRecorder,
2808
+ ConcurrentSession,
2809
+ StreamingSession,
1793
2810
  applyCrossfade,
2811
+ buildZoomClickLookup,
1794
2812
  calculateAdaptiveZoom,
2813
+ calculateAdaptiveZoomFromLookup,
2814
+ calculateAdaptiveZoomInWindow,
1795
2815
  calculatePanOffset,
1796
2816
  encodeGif,
1797
2817
  encodeMp4,
2818
+ encodeMp4Stream,
1798
2819
  lerpZoom,
1799
2820
  loadScenario,
1800
2821
  parseScenario,