clipwise 0.3.0 → 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
@@ -62,6 +62,35 @@ var CURSOR_SPEED_PRESETS = {
62
62
  slow: { steps: 20, delay: 25 }
63
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;
@@ -80,6 +109,15 @@ var ClipwiseRecorder = class {
80
109
  cursorSpeed = "fast";
81
110
  firstContentTimestamp = 0;
82
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
83
121
  /**
84
122
  * Launch the browser and create a page with the scenario viewport.
85
123
  */
@@ -103,6 +141,10 @@ var ClipwiseRecorder = class {
103
141
  this.cursorPosition = { x: 0, y: 0 };
104
142
  this.isCapturing = false;
105
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;
106
148
  }
107
149
  /**
108
150
  * Start CDP screencast for continuous frame capture.
@@ -117,10 +159,24 @@ var ClipwiseRecorder = class {
117
159
  async (event) => {
118
160
  if (!this.isCapturing || !this.cdpClient) return;
119
161
  const buffer = Buffer.from(event.data, "base64");
120
- this.rawFrames.push({
121
- buffer,
122
- timestamp: Date.now()
123
- });
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
+ }
124
180
  await this.cdpClient.send("Page.screencastFrameAck", {
125
181
  sessionId: event.sessionId
126
182
  }).catch(() => {
@@ -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.
@@ -446,7 +616,8 @@ var ClipwiseRecorder = class {
446
616
  viewport: { ...this.viewport },
447
617
  deviceScaleFactor: this.deviceScaleFactor,
448
618
  keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
449
- stepIndex: this.currentStepIndex
619
+ stepIndex: raw.stepIndex
620
+ // use per-frame step index captured at event time
450
621
  };
451
622
  });
452
623
  }
@@ -905,20 +1076,56 @@ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight
905
1076
  }
906
1077
  function calculateAdaptiveZoom(frames, currentIndex, maxScale, transitionFrames) {
907
1078
  if (maxScale <= 1) return 1;
1079
+ const lo = Math.max(0, currentIndex - transitionFrames);
1080
+ const hi = Math.min(frames.length - 1, currentIndex + transitionFrames);
908
1081
  let minDistance = Infinity;
909
- for (let i = 0; i < frames.length; i++) {
1082
+ for (let i = lo; i <= hi; i++) {
910
1083
  if (frames[i].clickPosition) {
911
1084
  const distance = Math.abs(i - currentIndex);
912
- minDistance = Math.min(minDistance, distance);
1085
+ if (distance < minDistance) minDistance = distance;
913
1086
  }
914
1087
  }
915
- if (minDistance === Infinity) return 1;
916
- if (minDistance <= transitionFrames) {
917
- const t = 1 - minDistance / transitionFrames;
918
- const eased = easeInOutCubic2(t);
919
- return 1 + (maxScale - 1) * eased;
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);
1097
+ }
920
1098
  }
921
- return 1;
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
+ }
1125
+ }
1126
+ if (minDistance > transitionFrames) return 1;
1127
+ const t = 1 - minDistance / transitionFrames;
1128
+ return 1 + (maxScale - 1) * easeInOutCubic2(t);
922
1129
  }
923
1130
  function calculatePanOffset(focusPoint, scale, frameWidth, frameHeight) {
924
1131
  if (scale <= 1) return { x: 0, y: 0 };
@@ -1082,8 +1289,8 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
1082
1289
 
1083
1290
  // src/effects/watermark.ts
1084
1291
  import sharp6 from "sharp";
1085
- async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1086
- if (!config.enabled || !config.text) return frameBuffer;
1292
+ function buildWatermarkSvg(config, frameWidth, frameHeight) {
1293
+ if (!config.enabled || !config.text) return "";
1087
1294
  const charWidth = config.fontSize * 0.62;
1088
1295
  const textWidth = Math.ceil(config.text.length * charWidth);
1089
1296
  const margin = 16;
@@ -1109,13 +1316,17 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
1109
1316
  break;
1110
1317
  }
1111
1318
  const escaped = config.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1112
- const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1319
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
1113
1320
  <text x="${x}" y="${y}"
1114
1321
  font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
1115
1322
  font-weight="600" fill="${config.color}"
1116
1323
  opacity="${config.opacity.toFixed(3)}">${escaped}</text>
1117
1324
  </svg>`;
1118
- return sharp6(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();
1119
1330
  }
1120
1331
 
1121
1332
  // src/compose/compose-frame.ts
@@ -1146,7 +1357,18 @@ async function composeFrame(frame, effects, output, context) {
1146
1357
  cursorTrail: context?.cursorTrail ?? []
1147
1358
  };
1148
1359
  if (effects.deviceFrame.enabled) {
1149
- buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
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
+ }
1150
1372
  const meta2 = await sharp7(buffer).metadata();
1151
1373
  width = meta2.width ?? width;
1152
1374
  height = meta2.height ?? height;
@@ -1214,38 +1436,83 @@ async function composeFrame(frame, effects, output, context) {
1214
1436
  };
1215
1437
  buffer = await applyZoom(buffer, focusPoint, scale, width, height);
1216
1438
  }
1217
- buffer = await applyBackground(buffer, effects.background, output.width, output.height);
1218
- if (effects.watermark.enabled) {
1219
- buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
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
+ }
1220
1473
  }
1221
- buffer = await sharp7(buffer).resize(output.width, output.height, {
1474
+ const { data: finalData, info: finalInfo } = await sharp7(buffer).resize(output.width, output.height, {
1222
1475
  fit: "fill",
1223
1476
  kernel: sharp7.kernel.lanczos3
1224
- }).png().toBuffer();
1225
- return { index: frame.index, buffer, timestamp: frame.timestamp };
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
+ };
1226
1484
  }
1227
1485
 
1228
1486
  // src/effects/transition.ts
1229
1487
  import sharp8 from "sharp";
1230
- async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
1488
+ async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
1231
1489
  const t = Math.max(0, Math.min(1, progress));
1232
- if (t <= 0) return fromBuffer;
1233
- if (t >= 1) return toBuffer;
1234
- const fromRaw = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1235
- const toRaw = await sharp8(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
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 });
1236
1506
  const pixels = Buffer.alloc(fromRaw.data.length);
1237
1507
  for (let i = 0; i < fromRaw.data.length; i++) {
1238
1508
  pixels[i] = Math.round(
1239
1509
  fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
1240
1510
  );
1241
1511
  }
1242
- return sharp8(pixels, {
1243
- raw: {
1244
- width: fromRaw.info.width,
1245
- height: fromRaw.info.height,
1246
- channels: 4
1247
- }
1248
- }).png().toBuffer();
1512
+ return {
1513
+ buffer: pixels,
1514
+ rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
1515
+ };
1249
1516
  }
1250
1517
 
1251
1518
  // src/compose/canvas-renderer.ts
@@ -1357,7 +1624,8 @@ var CanvasRenderer = class {
1357
1624
  results[msg.taskId] = {
1358
1625
  index: frames[msg.taskId].index,
1359
1626
  buffer: Buffer.from(msg.buffer),
1360
- timestamp: frames[msg.taskId].timestamp
1627
+ timestamp: frames[msg.taskId].timestamp,
1628
+ rawInfo: msg.rawInfo
1361
1629
  };
1362
1630
  completed++;
1363
1631
  if (completed === frames.length) {
@@ -1385,12 +1653,13 @@ var CanvasRenderer = class {
1385
1653
  const transitionFrames = Math.round(
1386
1654
  this.output.fps * (this.effects.zoom.duration / 1e3)
1387
1655
  );
1656
+ const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
1388
1657
  for (let i = 0; i < frames.length; i++) {
1389
1658
  const frame = frames[i];
1390
1659
  let zoomScale = 1;
1391
1660
  if (this.effects.zoom.enabled) {
1392
- zoomScale = calculateAdaptiveZoom(
1393
- frames,
1661
+ zoomScale = calculateAdaptiveZoomFromLookup(
1662
+ clickLookup,
1394
1663
  i,
1395
1664
  this.effects.zoom.scale,
1396
1665
  transitionFrames
@@ -1440,6 +1709,369 @@ var CanvasRenderer = class {
1440
1709
  }
1441
1710
  return result;
1442
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
+ }
1443
2075
  /**
1444
2076
  * Apply crossfade transitions at step boundaries where configured.
1445
2077
  */
@@ -1462,15 +2094,21 @@ var CanvasRenderer = class {
1462
2094
  if (range < 2) continue;
1463
2095
  const fromBuffer = composed[startIdx].buffer;
1464
2096
  const toBuffer = composed[endIdx].buffer;
2097
+ const fromRawInfo = composed[startIdx].rawInfo;
2098
+ const toRawInfo = composed[endIdx].rawInfo;
1465
2099
  for (let i = startIdx + 1; i < endIdx; i++) {
1466
2100
  const progress = (i - startIdx) / range;
1467
- composed[i].buffer = await applyCrossfade(
2101
+ const blended = await applyCrossfade(
1468
2102
  fromBuffer,
1469
2103
  toBuffer,
1470
2104
  progress,
1471
2105
  this.output.width,
1472
- this.output.height
2106
+ this.output.height,
2107
+ fromRawInfo,
2108
+ toRawInfo
1473
2109
  );
2110
+ composed[i].buffer = blended.buffer;
2111
+ composed[i].rawInfo = blended.rawInfo;
1474
2112
  }
1475
2113
  }
1476
2114
  }
@@ -1527,7 +2165,8 @@ async function encodeGif(frames, config) {
1527
2165
  const gif = GIFEncoder();
1528
2166
  const delay = Math.round(1e3 / config.fps);
1529
2167
  for (const frame of frames) {
1530
- 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 });
1531
2170
  const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1532
2171
  const palette = quantize(rgba, 256);
1533
2172
  const indexed = applyPalette(rgba, palette);
@@ -1645,7 +2284,121 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
1645
2284
  });
1646
2285
  (async () => {
1647
2286
  for (const frame of frames) {
1648
- const raw = await sharp9(frame.buffer).flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
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) => {
2376
+ if (code === 0) {
2377
+ resolve();
2378
+ } else {
2379
+ reject(
2380
+ new Error(
2381
+ `FFmpeg encoding failed (exit code ${code}). Make sure ffmpeg is installed: brew install ffmpeg
2382
+ ` + stderr.slice(-500)
2383
+ )
2384
+ );
2385
+ }
2386
+ });
2387
+ ffmpeg.on("error", (err) => {
2388
+ if (err.code === "ENOENT") {
2389
+ reject(
2390
+ new Error(
2391
+ "ffmpeg not found. Install it to encode MP4:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: choco install ffmpeg"
2392
+ )
2393
+ );
2394
+ } else {
2395
+ reject(err);
2396
+ }
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();
1649
2402
  if (!ffmpeg.stdin.write(raw)) {
1650
2403
  await new Promise((r) => ffmpeg.stdin.once("drain", r));
1651
2404
  }
@@ -1673,6 +2426,76 @@ async function savePngSequence(frames, config) {
1673
2426
  return paths;
1674
2427
  }
1675
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
+
1676
2499
  // src/script/parser.ts
1677
2500
  import { parse as parseYaml } from "yaml";
1678
2501
  import { readFile as readFile2 } from "fs/promises";
@@ -1982,11 +2805,17 @@ function validateScenario(scenario) {
1982
2805
  export {
1983
2806
  CanvasRenderer,
1984
2807
  ClipwiseRecorder,
2808
+ ConcurrentSession,
2809
+ StreamingSession,
1985
2810
  applyCrossfade,
2811
+ buildZoomClickLookup,
1986
2812
  calculateAdaptiveZoom,
2813
+ calculateAdaptiveZoomFromLookup,
2814
+ calculateAdaptiveZoomInWindow,
1987
2815
  calculatePanOffset,
1988
2816
  encodeGif,
1989
2817
  encodeMp4,
2818
+ encodeMp4Stream,
1990
2819
  lerpZoom,
1991
2820
  loadScenario,
1992
2821
  parseScenario,