clipwise 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +15 -1
- package/README.md +15 -1
- package/dist/cli/index.js +862 -137
- package/dist/compose/frame-worker.js +142 -13
- package/dist/index.d.ts +306 -8
- package/dist/index.js +877 -48
- package/package.json +1 -1
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.
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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:
|
|
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 =
|
|
1082
|
+
for (let i = lo; i <= hi; i++) {
|
|
910
1083
|
if (frames[i].clickPosition) {
|
|
911
1084
|
const distance = Math.abs(i - currentIndex);
|
|
912
|
-
minDistance =
|
|
1085
|
+
if (distance < minDistance) minDistance = distance;
|
|
913
1086
|
}
|
|
914
1087
|
}
|
|
915
|
-
if (minDistance
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
|
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
|
-
|
|
1086
|
-
if (!config.enabled || !config.text) return
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
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
|
-
|
|
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
|
-
}).
|
|
1225
|
-
return {
|
|
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)
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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 =
|
|
1393
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|