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