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/cli/index.js
CHANGED
|
@@ -178,8 +178,13 @@ var init_types = __esm({
|
|
|
178
178
|
format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
|
|
179
179
|
width: z.number().default(1280),
|
|
180
180
|
height: z.number().default(800),
|
|
181
|
-
fps: z.number().min(1).max(60).default(
|
|
181
|
+
fps: z.number().min(1).max(60).default(30),
|
|
182
182
|
quality: z.number().min(1).max(100).default(80),
|
|
183
|
+
// Encoding preset for MP4 output. Overrides quality when set.
|
|
184
|
+
// social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
|
|
185
|
+
// balanced — general-purpose, good quality/size trade-off (CRF 20)
|
|
186
|
+
// archive — high-fidelity storage, larger file (CRF 15)
|
|
187
|
+
preset: z.enum(["social", "balanced", "archive"]).optional(),
|
|
183
188
|
outputDir: z.string().default("./output"),
|
|
184
189
|
filename: z.string().default("clipwise-recording")
|
|
185
190
|
});
|
|
@@ -386,16 +391,45 @@ async function getElementCenter(page, selector, timeout) {
|
|
|
386
391
|
|
|
387
392
|
// src/core/recorder.ts
|
|
388
393
|
var CLICK_EFFECT_DURATION_MS = 500;
|
|
389
|
-
var REPAINT_INTERVAL_MS =
|
|
394
|
+
var REPAINT_INTERVAL_MS = 25;
|
|
390
395
|
var ACTION_GAP_MS = 30;
|
|
391
396
|
var CURSOR_SPEED_PRESETS = {
|
|
392
|
-
fast: { steps:
|
|
393
|
-
// ~
|
|
394
|
-
normal: { steps:
|
|
395
|
-
// ~
|
|
396
|
-
slow: { steps:
|
|
397
|
-
// ~
|
|
397
|
+
fast: { steps: 10, delay: 22 },
|
|
398
|
+
// ~220ms, ~9 frames captured
|
|
399
|
+
normal: { steps: 14, delay: 25 },
|
|
400
|
+
// ~350ms, ~14 frames captured
|
|
401
|
+
slow: { steps: 20, delay: 25 }
|
|
402
|
+
// ~500ms, ~20 frames captured
|
|
398
403
|
};
|
|
404
|
+
var FrameChannel = class {
|
|
405
|
+
buffer = [];
|
|
406
|
+
resolve = null;
|
|
407
|
+
closed = false;
|
|
408
|
+
push(frame) {
|
|
409
|
+
if (this.closed) return;
|
|
410
|
+
this.buffer.push(frame);
|
|
411
|
+
this.resolve?.();
|
|
412
|
+
this.resolve = null;
|
|
413
|
+
}
|
|
414
|
+
close() {
|
|
415
|
+
if (this.closed) return;
|
|
416
|
+
this.closed = true;
|
|
417
|
+
this.resolve?.();
|
|
418
|
+
this.resolve = null;
|
|
419
|
+
}
|
|
420
|
+
async *[Symbol.asyncIterator]() {
|
|
421
|
+
while (true) {
|
|
422
|
+
while (this.buffer.length > 0) {
|
|
423
|
+
yield this.buffer.shift();
|
|
424
|
+
}
|
|
425
|
+
if (this.closed) return;
|
|
426
|
+
await new Promise((r) => {
|
|
427
|
+
this.resolve = r;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
var DEDUP_SIGNATURE_BYTES = 2048;
|
|
399
433
|
var ClipwiseRecorder = class {
|
|
400
434
|
browser = null;
|
|
401
435
|
context = null;
|
|
@@ -408,11 +442,21 @@ var ClipwiseRecorder = class {
|
|
|
408
442
|
currentStepIndex = 0;
|
|
409
443
|
cursorPosition = { x: 0, y: 0 };
|
|
410
444
|
viewport = { width: 1280, height: 800 };
|
|
445
|
+
deviceScaleFactor = 1;
|
|
411
446
|
isCapturing = false;
|
|
412
447
|
targetFps = 30;
|
|
413
448
|
cursorSpeed = "fast";
|
|
414
449
|
firstContentTimestamp = 0;
|
|
415
450
|
pendingResponsePromises = /* @__PURE__ */ new Map();
|
|
451
|
+
// ── 중복 프레임 제거 (Phase 1-A) ──────────────────────────────────────────
|
|
452
|
+
// 직전 저장된 프레임의 앞부분 시그니처. 동일하면 화면 내용이 바뀌지 않은 것.
|
|
453
|
+
lastFrameSignature = null;
|
|
454
|
+
dedupStats = { received: 0, stored: 0, skipped: 0 };
|
|
455
|
+
// ── 스트리밍 채널 (Phase 3-B) ───────────────────────────────────────────
|
|
456
|
+
// Set during recordToChannel(); null in normal record() mode.
|
|
457
|
+
frameChannel = null;
|
|
458
|
+
channelIndex = 0;
|
|
459
|
+
// sequential index for channel-pushed frames
|
|
416
460
|
/**
|
|
417
461
|
* Launch the browser and create a page with the scenario viewport.
|
|
418
462
|
*/
|
|
@@ -436,6 +480,10 @@ var ClipwiseRecorder = class {
|
|
|
436
480
|
this.cursorPosition = { x: 0, y: 0 };
|
|
437
481
|
this.isCapturing = false;
|
|
438
482
|
this.firstContentTimestamp = 0;
|
|
483
|
+
this.lastFrameSignature = null;
|
|
484
|
+
this.dedupStats = { received: 0, stored: 0, skipped: 0 };
|
|
485
|
+
this.frameChannel = null;
|
|
486
|
+
this.channelIndex = 0;
|
|
439
487
|
}
|
|
440
488
|
/**
|
|
441
489
|
* Start CDP screencast for continuous frame capture.
|
|
@@ -450,10 +498,24 @@ var ClipwiseRecorder = class {
|
|
|
450
498
|
async (event) => {
|
|
451
499
|
if (!this.isCapturing || !this.cdpClient) return;
|
|
452
500
|
const buffer = Buffer.from(event.data, "base64");
|
|
453
|
-
this.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
501
|
+
this.dedupStats.received++;
|
|
502
|
+
const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
|
|
503
|
+
const isDuplicate = this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
|
|
504
|
+
if (isDuplicate) {
|
|
505
|
+
this.dedupStats.skipped++;
|
|
506
|
+
} else {
|
|
507
|
+
this.lastFrameSignature = Buffer.from(signature);
|
|
508
|
+
const captureTime = Date.now();
|
|
509
|
+
this.rawFrames.push({ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex });
|
|
510
|
+
this.dedupStats.stored++;
|
|
511
|
+
if (this.frameChannel && this.firstContentTimestamp > 0) {
|
|
512
|
+
const frame = this.buildFrameOnline(
|
|
513
|
+
{ buffer, timestamp: captureTime, stepIndex: this.currentStepIndex },
|
|
514
|
+
this.channelIndex++
|
|
515
|
+
);
|
|
516
|
+
this.frameChannel.push(frame);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
457
519
|
await this.cdpClient.send("Page.screencastFrameAck", {
|
|
458
520
|
sessionId: event.sessionId
|
|
459
521
|
}).catch(() => {
|
|
@@ -461,10 +523,9 @@ var ClipwiseRecorder = class {
|
|
|
461
523
|
}
|
|
462
524
|
);
|
|
463
525
|
await this.cdpClient.send("Page.startScreencast", {
|
|
464
|
-
format: "
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
maxHeight: this.viewport.height,
|
|
526
|
+
format: "png",
|
|
527
|
+
maxWidth: this.viewport.width * this.deviceScaleFactor,
|
|
528
|
+
maxHeight: this.viewport.height * this.deviceScaleFactor,
|
|
468
529
|
everyNthFrame: 1
|
|
469
530
|
});
|
|
470
531
|
this.cursorTimeline.push({
|
|
@@ -493,13 +554,23 @@ var ClipwiseRecorder = class {
|
|
|
493
554
|
await this.init(scenario);
|
|
494
555
|
const startTime = Date.now();
|
|
495
556
|
try {
|
|
557
|
+
if (scenario.steps.length > 0) {
|
|
558
|
+
const s0 = scenario.steps[0];
|
|
559
|
+
this.currentStepIndex = 0;
|
|
560
|
+
this.preRegisterResponseListeners(s0.actions);
|
|
561
|
+
for (let ai = 0; ai < s0.actions.length; ai++) {
|
|
562
|
+
await this.executeAction(s0.actions[ai], ai);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
496
565
|
await this.startCapture();
|
|
497
566
|
for (let si = 0; si < scenario.steps.length; si++) {
|
|
498
567
|
const step = scenario.steps[si];
|
|
499
568
|
this.currentStepIndex = si;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
569
|
+
if (si > 0) {
|
|
570
|
+
this.preRegisterResponseListeners(step.actions);
|
|
571
|
+
for (let ai = 0; ai < step.actions.length; ai++) {
|
|
572
|
+
await this.executeAction(step.actions[ai], ai);
|
|
573
|
+
}
|
|
503
574
|
}
|
|
504
575
|
if (step.captureDelay > 0) {
|
|
505
576
|
await this.waitWithRepaints(step.captureDelay);
|
|
@@ -520,7 +591,8 @@ var ClipwiseRecorder = class {
|
|
|
520
591
|
scenario,
|
|
521
592
|
frames,
|
|
522
593
|
startTime,
|
|
523
|
-
endTime: Date.now()
|
|
594
|
+
endTime: Date.now(),
|
|
595
|
+
dedupStats: { ...this.dedupStats }
|
|
524
596
|
};
|
|
525
597
|
} catch (error) {
|
|
526
598
|
await this.stopCapture().catch(() => {
|
|
@@ -536,13 +608,116 @@ var ClipwiseRecorder = class {
|
|
|
536
608
|
scenario,
|
|
537
609
|
frames,
|
|
538
610
|
startTime,
|
|
539
|
-
endTime: Date.now()
|
|
611
|
+
endTime: Date.now(),
|
|
612
|
+
dedupStats: { ...this.dedupStats }
|
|
540
613
|
};
|
|
541
614
|
throw err;
|
|
542
615
|
} finally {
|
|
543
616
|
await this.cleanup();
|
|
544
617
|
}
|
|
545
618
|
}
|
|
619
|
+
// ─── Streaming recording API (Phase 3-B) ──────────────────────────────────
|
|
620
|
+
/**
|
|
621
|
+
* Start recording concurrently and return a RecordingHandle immediately.
|
|
622
|
+
*
|
|
623
|
+
* frameStream: yields CapturedFrames as each unique frame arrives from CDP
|
|
624
|
+
* (post-dedup, sequential indices starting at 0, NO FPS resampling).
|
|
625
|
+
* Closes when recording ends.
|
|
626
|
+
*
|
|
627
|
+
* done: resolves with the full RecordingSession (FPS-resampled) once
|
|
628
|
+
* all steps have completed and the browser has been cleaned up.
|
|
629
|
+
*
|
|
630
|
+
* Use this with CanvasRenderer.composeStreamOnline() to overlap recording
|
|
631
|
+
* time with composition time — total wall-clock ≈ max(recordingMs, composeMs).
|
|
632
|
+
*/
|
|
633
|
+
recordToChannel(scenario) {
|
|
634
|
+
const channel = new FrameChannel();
|
|
635
|
+
const done = (async () => {
|
|
636
|
+
try {
|
|
637
|
+
await this.init(scenario);
|
|
638
|
+
this.frameChannel = channel;
|
|
639
|
+
const startTime = Date.now();
|
|
640
|
+
if (scenario.steps.length > 0) {
|
|
641
|
+
const s0 = scenario.steps[0];
|
|
642
|
+
this.currentStepIndex = 0;
|
|
643
|
+
this.preRegisterResponseListeners(s0.actions);
|
|
644
|
+
for (let ai = 0; ai < s0.actions.length; ai++) {
|
|
645
|
+
await this.executeAction(s0.actions[ai], ai);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
await this.startCapture();
|
|
649
|
+
for (let si = 0; si < scenario.steps.length; si++) {
|
|
650
|
+
const step = scenario.steps[si];
|
|
651
|
+
this.currentStepIndex = si;
|
|
652
|
+
if (si > 0) {
|
|
653
|
+
this.preRegisterResponseListeners(step.actions);
|
|
654
|
+
for (let ai = 0; ai < step.actions.length; ai++) {
|
|
655
|
+
await this.executeAction(step.actions[ai], ai);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (step.captureDelay > 0) await this.waitWithRepaints(step.captureDelay);
|
|
659
|
+
if (step.holdDuration > 0) await this.waitWithRepaints(step.holdDuration);
|
|
660
|
+
}
|
|
661
|
+
await this.stopCapture();
|
|
662
|
+
channel.close();
|
|
663
|
+
const rawFrames = this.buildCapturedFrames();
|
|
664
|
+
const recordingDurationMs = Date.now() - startTime;
|
|
665
|
+
const frames = this.resampleToTargetFps(rawFrames, recordingDurationMs);
|
|
666
|
+
return {
|
|
667
|
+
scenario,
|
|
668
|
+
frames,
|
|
669
|
+
startTime,
|
|
670
|
+
endTime: Date.now(),
|
|
671
|
+
dedupStats: { ...this.dedupStats }
|
|
672
|
+
};
|
|
673
|
+
} catch (error) {
|
|
674
|
+
channel.close();
|
|
675
|
+
await this.stopCapture().catch(() => {
|
|
676
|
+
});
|
|
677
|
+
const rawFrames = this.buildCapturedFrames();
|
|
678
|
+
const session = {
|
|
679
|
+
scenario,
|
|
680
|
+
frames: rawFrames,
|
|
681
|
+
startTime: Date.now(),
|
|
682
|
+
dedupStats: { ...this.dedupStats }
|
|
683
|
+
};
|
|
684
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
685
|
+
err.partialSession = session;
|
|
686
|
+
throw err;
|
|
687
|
+
} finally {
|
|
688
|
+
await this.cleanup();
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
691
|
+
return { frameStream: channel, done };
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Build a single CapturedFrame from a RawFrame in real-time.
|
|
695
|
+
* Used by recordToChannel() to emit frames as they arrive.
|
|
696
|
+
* Cursor/click data reflects the timeline up to this moment.
|
|
697
|
+
*/
|
|
698
|
+
buildFrameOnline(raw, sequentialIndex) {
|
|
699
|
+
const cursorPos = this.interpolateCursorAt(raw.timestamp);
|
|
700
|
+
const clickEvent = this.clickTimeline.find(
|
|
701
|
+
(click) => raw.timestamp >= click.timestamp && raw.timestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
702
|
+
);
|
|
703
|
+
let clickProgress;
|
|
704
|
+
if (clickEvent) {
|
|
705
|
+
clickProgress = Math.min(1, (raw.timestamp - clickEvent.timestamp) / CLICK_EFFECT_DURATION_MS);
|
|
706
|
+
}
|
|
707
|
+
const frameKeystrokes = this.keystrokeTimeline.filter((k) => k.timestamp <= raw.timestamp);
|
|
708
|
+
return {
|
|
709
|
+
index: sequentialIndex,
|
|
710
|
+
screenshot: raw.buffer,
|
|
711
|
+
timestamp: raw.timestamp,
|
|
712
|
+
cursorPosition: cursorPos,
|
|
713
|
+
clickPosition: clickEvent?.position ?? null,
|
|
714
|
+
clickProgress,
|
|
715
|
+
viewport: { ...this.viewport },
|
|
716
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
717
|
+
stepIndex: raw.stepIndex,
|
|
718
|
+
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
719
|
+
};
|
|
720
|
+
}
|
|
546
721
|
/**
|
|
547
722
|
* Wait for a given duration while forcing periodic repaints
|
|
548
723
|
* so CDP screencast keeps sending frames even on static pages.
|
|
@@ -778,8 +953,10 @@ var ClipwiseRecorder = class {
|
|
|
778
953
|
clickPosition: clickEvent?.position ?? null,
|
|
779
954
|
clickProgress,
|
|
780
955
|
viewport: { ...this.viewport },
|
|
956
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
781
957
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
782
|
-
stepIndex:
|
|
958
|
+
stepIndex: raw.stepIndex
|
|
959
|
+
// use per-frame step index captured at event time
|
|
783
960
|
};
|
|
784
961
|
});
|
|
785
962
|
}
|
|
@@ -806,15 +983,9 @@ var ClipwiseRecorder = class {
|
|
|
806
983
|
for (let i = 0; i < targetFrameCount; i++) {
|
|
807
984
|
const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
|
|
808
985
|
const targetTimestamp = startTime + t * duration;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const dist = Math.abs(frames[j].timestamp - targetTimestamp);
|
|
813
|
-
if (dist < minDist) {
|
|
814
|
-
minDist = dist;
|
|
815
|
-
nearestIdx = j;
|
|
816
|
-
}
|
|
817
|
-
}
|
|
986
|
+
const lo = this.binarySearchTimeline(frames, targetTimestamp);
|
|
987
|
+
const hi = Math.min(lo + 1, frames.length - 1);
|
|
988
|
+
const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
|
|
818
989
|
const cursorPos = this.interpolateCursorAt(targetTimestamp);
|
|
819
990
|
const clickEvent = this.clickTimeline.find(
|
|
820
991
|
(click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
@@ -835,6 +1006,7 @@ var ClipwiseRecorder = class {
|
|
|
835
1006
|
clickPosition: clickEvent?.position ?? null,
|
|
836
1007
|
clickProgress,
|
|
837
1008
|
viewport: { ...this.viewport },
|
|
1009
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
838
1010
|
stepName: frames[nearestIdx].stepName,
|
|
839
1011
|
stepIndex: frames[nearestIdx].stepIndex,
|
|
840
1012
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
@@ -850,15 +1022,9 @@ var ClipwiseRecorder = class {
|
|
|
850
1022
|
if (this.cursorTimeline.length === 1) {
|
|
851
1023
|
return { ...this.cursorTimeline[0].position };
|
|
852
1024
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
|
|
857
|
-
before = this.cursorTimeline[i];
|
|
858
|
-
after = this.cursorTimeline[i + 1];
|
|
859
|
-
break;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
1025
|
+
const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
|
|
1026
|
+
const before = this.cursorTimeline[idx];
|
|
1027
|
+
const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
|
|
862
1028
|
if (timestamp <= before.timestamp) return { ...before.position };
|
|
863
1029
|
if (timestamp >= after.timestamp) return { ...after.position };
|
|
864
1030
|
const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
|
|
@@ -871,6 +1037,23 @@ var ClipwiseRecorder = class {
|
|
|
871
1037
|
)
|
|
872
1038
|
};
|
|
873
1039
|
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Binary search: returns the index of the last entry whose timestamp <= target.
|
|
1042
|
+
* Assumes the array is sorted by timestamp in ascending order.
|
|
1043
|
+
*/
|
|
1044
|
+
binarySearchTimeline(timeline, target) {
|
|
1045
|
+
let lo = 0;
|
|
1046
|
+
let hi = timeline.length - 1;
|
|
1047
|
+
while (lo < hi) {
|
|
1048
|
+
const mid = lo + hi + 1 >> 1;
|
|
1049
|
+
if (timeline[mid].timestamp <= target) {
|
|
1050
|
+
lo = mid;
|
|
1051
|
+
} else {
|
|
1052
|
+
hi = mid - 1;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return lo;
|
|
1056
|
+
}
|
|
874
1057
|
/**
|
|
875
1058
|
* Clean up browser resources. Always called after recording.
|
|
876
1059
|
*/
|
|
@@ -895,7 +1078,13 @@ var ClipwiseRecorder = class {
|
|
|
895
1078
|
};
|
|
896
1079
|
|
|
897
1080
|
// src/compose/canvas-renderer.ts
|
|
898
|
-
import
|
|
1081
|
+
import { Worker } from "worker_threads";
|
|
1082
|
+
import os from "os";
|
|
1083
|
+
import { existsSync } from "fs";
|
|
1084
|
+
import { fileURLToPath } from "url";
|
|
1085
|
+
|
|
1086
|
+
// src/compose/compose-frame.ts
|
|
1087
|
+
import sharp7 from "sharp";
|
|
899
1088
|
|
|
900
1089
|
// src/effects/frame.ts
|
|
901
1090
|
import sharp from "sharp";
|
|
@@ -918,91 +1107,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
|
918
1107
|
var ANDROID_OUTER_RADIUS = 35;
|
|
919
1108
|
var ANDROID_INNER_RADIUS = 30;
|
|
920
1109
|
var ANDROID_CAMERA_RADIUS = 6;
|
|
921
|
-
function buildBrowserChromeSvg(width, darkMode) {
|
|
1110
|
+
function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
|
|
922
1111
|
const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
|
|
923
1112
|
const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
|
|
924
1113
|
const addressBorder = darkMode ? "#444444" : "#d0d0d0";
|
|
925
1114
|
const textColor = darkMode ? "#999999" : "#666666";
|
|
1115
|
+
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
1116
|
+
const tlY = TRAFFIC_LIGHT_Y * dpr;
|
|
1117
|
+
const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
|
|
1118
|
+
const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
|
|
1119
|
+
const tlGap = TRAFFIC_LIGHT_GAP * dpr;
|
|
1120
|
+
const aBarH = ADDRESS_BAR_HEIGHT * dpr;
|
|
1121
|
+
const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
|
|
1122
|
+
const fontSize = 12 * dpr;
|
|
926
1123
|
const trafficLights = [
|
|
927
|
-
{ cx:
|
|
928
|
-
{ cx:
|
|
929
|
-
{ cx:
|
|
1124
|
+
{ cx: tlStartX, fill: "#ff5f57" },
|
|
1125
|
+
{ cx: tlStartX + tlGap, fill: "#febc2e" },
|
|
1126
|
+
{ cx: tlStartX + tlGap * 2, fill: "#28c840" }
|
|
930
1127
|
].map(
|
|
931
|
-
(light) => `<circle cx="${light.cx}" cy="${
|
|
1128
|
+
(light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
|
|
932
1129
|
).join("\n ");
|
|
933
|
-
const addressBarWidth = width -
|
|
934
|
-
const addressBarX =
|
|
935
|
-
const addressBarY = (
|
|
936
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${
|
|
937
|
-
<rect width="${width}" height="${
|
|
1130
|
+
const addressBarWidth = width - aBarMargin * 2;
|
|
1131
|
+
const addressBarX = aBarMargin;
|
|
1132
|
+
const addressBarY = (tbarH - aBarH) / 2;
|
|
1133
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
|
|
1134
|
+
<rect width="${width}" height="${tbarH}" fill="${bg}"/>
|
|
938
1135
|
${trafficLights}
|
|
939
|
-
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${
|
|
940
|
-
rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="
|
|
941
|
-
<text x="${width / 2}" y="${
|
|
942
|
-
font-family="system-ui, -apple-system, sans-serif" font-size="
|
|
1136
|
+
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
|
|
1137
|
+
rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
|
|
1138
|
+
<text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
|
|
1139
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
|
|
943
1140
|
localhost
|
|
944
1141
|
</text>
|
|
945
1142
|
</svg>`;
|
|
946
1143
|
}
|
|
947
|
-
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
1144
|
+
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
948
1145
|
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
949
1146
|
const islandColor = darkMode ? "#000000" : "#1a1a1a";
|
|
950
1147
|
const homeBarColor = darkMode ? "#555555" : "#333333";
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
const
|
|
955
|
-
const
|
|
956
|
-
const
|
|
1148
|
+
const bezelTop = IPHONE_BEZEL.top * dpr;
|
|
1149
|
+
const bezelBottom = IPHONE_BEZEL.bottom * dpr;
|
|
1150
|
+
const bezelSides = IPHONE_BEZEL.sides * dpr;
|
|
1151
|
+
const outerRadius = IPHONE_OUTER_RADIUS * dpr;
|
|
1152
|
+
const innerRadius = IPHONE_INNER_RADIUS * dpr;
|
|
1153
|
+
const islandW = IPHONE_ISLAND.width * dpr;
|
|
1154
|
+
const islandH = IPHONE_ISLAND.height * dpr;
|
|
1155
|
+
const homeBarW = IPHONE_HOME_BAR.width * dpr;
|
|
1156
|
+
const homeBarH = IPHONE_HOME_BAR.height * dpr;
|
|
1157
|
+
const islandX = (totalWidth - islandW) / 2;
|
|
1158
|
+
const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
|
|
1159
|
+
const homeBarX = (totalWidth - homeBarW) / 2;
|
|
1160
|
+
const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
|
|
1161
|
+
const screenX = bezelSides;
|
|
1162
|
+
const screenY = bezelTop;
|
|
957
1163
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
958
1164
|
<!-- Device body -->
|
|
959
1165
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
960
|
-
rx="${
|
|
1166
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
961
1167
|
<!-- Screen cutout (transparent) -->
|
|
962
1168
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
963
|
-
rx="${
|
|
1169
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
964
1170
|
<!-- Dynamic Island pill -->
|
|
965
|
-
<rect x="${islandX}" y="${islandY}" width="${
|
|
966
|
-
rx="${
|
|
1171
|
+
<rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
|
|
1172
|
+
rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
|
|
967
1173
|
<!-- Home indicator bar -->
|
|
968
|
-
<rect x="${homeBarX}" y="${homeBarY}" width="${
|
|
969
|
-
rx="${
|
|
1174
|
+
<rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
|
|
1175
|
+
rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
|
|
970
1176
|
</svg>`;
|
|
971
1177
|
}
|
|
972
|
-
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
1178
|
+
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
973
1179
|
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
974
1180
|
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
975
|
-
const screenX = IPAD_BEZEL.sides;
|
|
976
|
-
const screenY = IPAD_BEZEL.top;
|
|
1181
|
+
const screenX = IPAD_BEZEL.sides * dpr;
|
|
1182
|
+
const screenY = IPAD_BEZEL.top * dpr;
|
|
977
1183
|
const cameraCx = totalWidth / 2;
|
|
978
|
-
const cameraCy = IPAD_BEZEL.top / 2;
|
|
1184
|
+
const cameraCy = IPAD_BEZEL.top * dpr / 2;
|
|
1185
|
+
const outerRadius = IPAD_OUTER_RADIUS * dpr;
|
|
1186
|
+
const innerRadius = IPAD_INNER_RADIUS * dpr;
|
|
979
1187
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
980
1188
|
<!-- Device body -->
|
|
981
1189
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
982
|
-
rx="${
|
|
1190
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
983
1191
|
<!-- Screen cutout -->
|
|
984
1192
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
985
|
-
rx="${
|
|
1193
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
986
1194
|
<!-- Front camera dot -->
|
|
987
|
-
<circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
|
|
1195
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
|
|
988
1196
|
</svg>`;
|
|
989
1197
|
}
|
|
990
|
-
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
1198
|
+
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
991
1199
|
const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
|
|
992
1200
|
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
993
|
-
const screenX = ANDROID_BEZEL.sides;
|
|
994
|
-
const screenY = ANDROID_BEZEL.top;
|
|
1201
|
+
const screenX = ANDROID_BEZEL.sides * dpr;
|
|
1202
|
+
const screenY = ANDROID_BEZEL.top * dpr;
|
|
995
1203
|
const cameraCx = totalWidth / 2;
|
|
996
|
-
const cameraCy = ANDROID_BEZEL.top / 2;
|
|
1204
|
+
const cameraCy = ANDROID_BEZEL.top * dpr / 2;
|
|
1205
|
+
const outerRadius = ANDROID_OUTER_RADIUS * dpr;
|
|
1206
|
+
const innerRadius = ANDROID_INNER_RADIUS * dpr;
|
|
1207
|
+
const cameraR = ANDROID_CAMERA_RADIUS * dpr;
|
|
997
1208
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
998
1209
|
<!-- Device body -->
|
|
999
1210
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
1000
|
-
rx="${
|
|
1211
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
1001
1212
|
<!-- Screen cutout -->
|
|
1002
1213
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
1003
|
-
rx="${
|
|
1214
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
1004
1215
|
<!-- Punch-hole camera -->
|
|
1005
|
-
<circle cx="${cameraCx}" cy="${cameraCy}" r="${
|
|
1216
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
|
|
1006
1217
|
</svg>`;
|
|
1007
1218
|
}
|
|
1008
1219
|
function buildScreenMaskSvg(width, height, radius) {
|
|
@@ -1010,21 +1221,33 @@ function buildScreenMaskSvg(width, height, radius) {
|
|
|
1010
1221
|
<rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
|
|
1011
1222
|
</svg>`;
|
|
1012
1223
|
}
|
|
1013
|
-
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
|
|
1224
|
+
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
|
|
1014
1225
|
let bezel;
|
|
1015
1226
|
let innerRadius;
|
|
1016
1227
|
switch (deviceType) {
|
|
1017
1228
|
case "iphone":
|
|
1018
|
-
bezel =
|
|
1019
|
-
|
|
1229
|
+
bezel = {
|
|
1230
|
+
sides: IPHONE_BEZEL.sides * dpr,
|
|
1231
|
+
top: IPHONE_BEZEL.top * dpr,
|
|
1232
|
+
bottom: IPHONE_BEZEL.bottom * dpr
|
|
1233
|
+
};
|
|
1234
|
+
innerRadius = IPHONE_INNER_RADIUS * dpr;
|
|
1020
1235
|
break;
|
|
1021
1236
|
case "ipad":
|
|
1022
|
-
bezel =
|
|
1023
|
-
|
|
1237
|
+
bezel = {
|
|
1238
|
+
sides: IPAD_BEZEL.sides * dpr,
|
|
1239
|
+
top: IPAD_BEZEL.top * dpr,
|
|
1240
|
+
bottom: IPAD_BEZEL.bottom * dpr
|
|
1241
|
+
};
|
|
1242
|
+
innerRadius = IPAD_INNER_RADIUS * dpr;
|
|
1024
1243
|
break;
|
|
1025
1244
|
case "android":
|
|
1026
|
-
bezel =
|
|
1027
|
-
|
|
1245
|
+
bezel = {
|
|
1246
|
+
sides: ANDROID_BEZEL.sides * dpr,
|
|
1247
|
+
top: ANDROID_BEZEL.top * dpr,
|
|
1248
|
+
bottom: ANDROID_BEZEL.bottom * dpr
|
|
1249
|
+
};
|
|
1250
|
+
innerRadius = ANDROID_INNER_RADIUS * dpr;
|
|
1028
1251
|
break;
|
|
1029
1252
|
}
|
|
1030
1253
|
const totalWidth = frameWidth + bezel.sides * 2;
|
|
@@ -1032,13 +1255,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
|
|
|
1032
1255
|
let frameSvg;
|
|
1033
1256
|
switch (deviceType) {
|
|
1034
1257
|
case "iphone":
|
|
1035
|
-
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
1258
|
+
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
1036
1259
|
break;
|
|
1037
1260
|
case "ipad":
|
|
1038
|
-
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
1261
|
+
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
1039
1262
|
break;
|
|
1040
1263
|
case "android":
|
|
1041
|
-
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
1264
|
+
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
1042
1265
|
break;
|
|
1043
1266
|
}
|
|
1044
1267
|
const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
|
|
@@ -1061,12 +1284,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
|
|
|
1061
1284
|
{ input: maskedScreen, left: bezel.sides, top: bezel.top }
|
|
1062
1285
|
]).png().toBuffer();
|
|
1063
1286
|
}
|
|
1064
|
-
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
1287
|
+
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
|
|
1065
1288
|
if (!config.enabled || config.type === "none") return frameBuffer;
|
|
1066
1289
|
switch (config.type) {
|
|
1067
1290
|
case "browser": {
|
|
1068
|
-
const
|
|
1069
|
-
const
|
|
1291
|
+
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
1292
|
+
const totalHeight = frameHeight + tbarH;
|
|
1293
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
|
|
1070
1294
|
const chromeBuffer = Buffer.from(chromeSvg);
|
|
1071
1295
|
const canvas = await sharp({
|
|
1072
1296
|
create: {
|
|
@@ -1078,13 +1302,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1078
1302
|
}).png().toBuffer();
|
|
1079
1303
|
return sharp(canvas).composite([
|
|
1080
1304
|
{ input: chromeBuffer, left: 0, top: 0 },
|
|
1081
|
-
{ input: frameBuffer, left: 0, top:
|
|
1305
|
+
{ input: frameBuffer, left: 0, top: tbarH }
|
|
1082
1306
|
]).png().toBuffer();
|
|
1083
1307
|
}
|
|
1084
1308
|
case "iphone":
|
|
1085
1309
|
case "ipad":
|
|
1086
1310
|
case "android":
|
|
1087
|
-
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
|
|
1311
|
+
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
|
|
1088
1312
|
default:
|
|
1089
1313
|
return frameBuffer;
|
|
1090
1314
|
}
|
|
@@ -1094,7 +1318,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1094
1318
|
import sharp2 from "sharp";
|
|
1095
1319
|
function buildCursorSvg(size, color) {
|
|
1096
1320
|
const s = size;
|
|
1097
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
|
|
1321
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
|
|
1098
1322
|
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
1099
1323
|
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
1100
1324
|
</svg>`;
|
|
@@ -1103,7 +1327,7 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
1103
1327
|
const currentRadius = radius * progress;
|
|
1104
1328
|
const opacity = Math.max(0, 1 - progress);
|
|
1105
1329
|
const size = Math.ceil(radius * 2 + 4);
|
|
1106
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
1330
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1107
1331
|
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
1108
1332
|
fill="none" stroke="${color}" stroke-width="2"
|
|
1109
1333
|
opacity="${opacity.toFixed(3)}"/>
|
|
@@ -1111,47 +1335,35 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
1111
1335
|
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
1112
1336
|
</svg>`;
|
|
1113
1337
|
}
|
|
1114
|
-
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
1338
|
+
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1115
1339
|
if (!config.enabled) return frameBuffer;
|
|
1116
|
-
const
|
|
1340
|
+
const size = Math.round(config.size * dpr);
|
|
1341
|
+
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1117
1342
|
const cursorBuffer = Buffer.from(cursorSvg);
|
|
1118
|
-
const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
|
|
1119
|
-
const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
|
|
1343
|
+
const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
|
|
1344
|
+
const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
|
|
1120
1345
|
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
1121
1346
|
}
|
|
1122
|
-
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
|
|
1347
|
+
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
1123
1348
|
if (!config.enabled || !config.clickEffect) return frameBuffer;
|
|
1349
|
+
const radius = config.clickRadius * dpr;
|
|
1124
1350
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
1125
|
-
const rippleSvg = buildClickRippleSvg(
|
|
1126
|
-
config.clickRadius,
|
|
1127
|
-
config.clickColor,
|
|
1128
|
-
clampedProgress
|
|
1129
|
-
);
|
|
1351
|
+
const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
|
|
1130
1352
|
const rippleBuffer = Buffer.from(rippleSvg);
|
|
1131
|
-
const rippleSize = Math.ceil(
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
frameWidth - rippleSize
|
|
1137
|
-
)
|
|
1138
|
-
);
|
|
1139
|
-
const top = Math.max(
|
|
1140
|
-
0,
|
|
1141
|
-
Math.min(
|
|
1142
|
-
Math.round(position.y - rippleSize / 2),
|
|
1143
|
-
frameHeight - rippleSize
|
|
1144
|
-
)
|
|
1145
|
-
);
|
|
1353
|
+
const rippleSize = Math.ceil(radius * 2 + 4);
|
|
1354
|
+
const px = Math.round(position.x * dpr);
|
|
1355
|
+
const py = Math.round(position.y * dpr);
|
|
1356
|
+
const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
|
|
1357
|
+
const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
|
|
1146
1358
|
return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
|
|
1147
1359
|
}
|
|
1148
|
-
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
1360
|
+
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1149
1361
|
if (!config.enabled || !config.highlight) return frameBuffer;
|
|
1150
|
-
const r = config.highlightRadius;
|
|
1362
|
+
const r = config.highlightRadius * dpr;
|
|
1151
1363
|
const size = Math.ceil(r * 2 + 4);
|
|
1152
1364
|
const cx = size / 2;
|
|
1153
1365
|
const cy = size / 2;
|
|
1154
|
-
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
1366
|
+
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1155
1367
|
<defs>
|
|
1156
1368
|
<radialGradient id="glow">
|
|
1157
1369
|
<stop offset="0%" stop-color="${config.highlightColor}" />
|
|
@@ -1161,27 +1373,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
|
|
|
1161
1373
|
</defs>
|
|
1162
1374
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
|
|
1163
1375
|
</svg>`;
|
|
1164
|
-
const
|
|
1165
|
-
const
|
|
1376
|
+
const px = Math.round(position.x * dpr);
|
|
1377
|
+
const py = Math.round(position.y * dpr);
|
|
1378
|
+
const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
|
|
1379
|
+
const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
|
|
1166
1380
|
return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
|
|
1167
1381
|
}
|
|
1168
|
-
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
|
|
1382
|
+
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
|
|
1169
1383
|
if (!config.enabled || !config.trail || positions.length < 2) {
|
|
1170
1384
|
return frameBuffer;
|
|
1171
1385
|
}
|
|
1172
1386
|
const segments = [];
|
|
1173
1387
|
for (let i = 1; i < positions.length; i++) {
|
|
1174
1388
|
const opacity = i / positions.length * 0.6;
|
|
1175
|
-
const strokeWidth = 1 + i / positions.length * 2;
|
|
1389
|
+
const strokeWidth = (1 + i / positions.length * 2) * dpr;
|
|
1176
1390
|
const p1 = positions[i - 1];
|
|
1177
1391
|
const p2 = positions[i];
|
|
1178
1392
|
segments.push(
|
|
1179
|
-
`<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
|
|
1393
|
+
`<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
|
|
1180
1394
|
stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
|
|
1181
1395
|
stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
|
|
1182
1396
|
);
|
|
1183
1397
|
}
|
|
1184
|
-
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1398
|
+
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
|
|
1185
1399
|
${segments.join("\n ")}
|
|
1186
1400
|
</svg>`;
|
|
1187
1401
|
return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
@@ -1199,22 +1413,43 @@ async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight
|
|
|
1199
1413
|
top = Math.max(0, Math.min(top, frameHeight - cropHeight));
|
|
1200
1414
|
return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
|
|
1201
1415
|
}
|
|
1202
|
-
function
|
|
1203
|
-
|
|
1204
|
-
let minDistance = Infinity;
|
|
1416
|
+
function buildZoomClickLookup(frames) {
|
|
1417
|
+
const indices = [];
|
|
1205
1418
|
for (let i = 0; i < frames.length; i++) {
|
|
1206
|
-
if (frames[i].clickPosition) {
|
|
1207
|
-
|
|
1208
|
-
minDistance = Math.min(minDistance, distance);
|
|
1419
|
+
if (frames[i].clickPosition !== null && frames[i].clickPosition !== void 0) {
|
|
1420
|
+
indices.push(i);
|
|
1209
1421
|
}
|
|
1210
1422
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1423
|
+
return indices;
|
|
1424
|
+
}
|
|
1425
|
+
function calculateAdaptiveZoomFromLookup(clickLookup, currentIndex, maxScale, transitionFrames) {
|
|
1426
|
+
if (maxScale <= 1 || clickLookup.length === 0) return 1;
|
|
1427
|
+
let lo = 0;
|
|
1428
|
+
let hi = clickLookup.length;
|
|
1429
|
+
while (lo < hi) {
|
|
1430
|
+
const mid = lo + hi >>> 1;
|
|
1431
|
+
if (clickLookup[mid] < currentIndex) lo = mid + 1;
|
|
1432
|
+
else hi = mid;
|
|
1216
1433
|
}
|
|
1217
|
-
|
|
1434
|
+
const distBefore = lo > 0 ? currentIndex - clickLookup[lo - 1] : Infinity;
|
|
1435
|
+
const distAfter = lo < clickLookup.length ? clickLookup[lo] - currentIndex : Infinity;
|
|
1436
|
+
const minDistance = Math.min(distBefore, distAfter);
|
|
1437
|
+
if (minDistance > transitionFrames) return 1;
|
|
1438
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1439
|
+
return 1 + (maxScale - 1) * easeInOutCubic2(t);
|
|
1440
|
+
}
|
|
1441
|
+
function calculateAdaptiveZoomInWindow(windowFrames, windowStart, currentIndex, maxScale, transitionFrames) {
|
|
1442
|
+
if (maxScale <= 1) return 1;
|
|
1443
|
+
let minDistance = Infinity;
|
|
1444
|
+
for (let j = 0; j < windowFrames.length; j++) {
|
|
1445
|
+
if (windowFrames[j].clickPosition !== null && windowFrames[j].clickPosition !== void 0) {
|
|
1446
|
+
const dist = Math.abs(windowStart + j - currentIndex);
|
|
1447
|
+
if (dist < minDistance) minDistance = dist;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (minDistance > transitionFrames) return 1;
|
|
1451
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1452
|
+
return 1 + (maxScale - 1) * easeInOutCubic2(t);
|
|
1218
1453
|
}
|
|
1219
1454
|
function easeInOutCubic2(t) {
|
|
1220
1455
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
@@ -1314,7 +1549,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
|
1314
1549
|
|
|
1315
1550
|
// src/effects/keystroke.ts
|
|
1316
1551
|
import sharp5 from "sharp";
|
|
1317
|
-
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
|
|
1552
|
+
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
|
|
1318
1553
|
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
1319
1554
|
const recentKeys = keystrokes.filter(
|
|
1320
1555
|
(k) => frameTimestamp - k.timestamp < config.fadeAfter
|
|
@@ -1322,25 +1557,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1322
1557
|
if (recentKeys.length === 0) return frameBuffer;
|
|
1323
1558
|
const displayText = recentKeys.map((k) => k.key).join("");
|
|
1324
1559
|
if (displayText.length === 0) return frameBuffer;
|
|
1325
|
-
const
|
|
1560
|
+
const fontSize = config.fontSize * dpr;
|
|
1561
|
+
const padding = config.padding * dpr;
|
|
1562
|
+
const charWidth = fontSize * 0.62;
|
|
1326
1563
|
const textWidth = Math.ceil(displayText.length * charWidth);
|
|
1327
|
-
const hudPadH =
|
|
1328
|
-
const hudPadV =
|
|
1329
|
-
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
|
|
1330
|
-
const hudHeight = Math.ceil(
|
|
1564
|
+
const hudPadH = padding * 2;
|
|
1565
|
+
const hudPadV = padding * 1.5;
|
|
1566
|
+
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
|
|
1567
|
+
const hudHeight = Math.ceil(fontSize + hudPadV * 2);
|
|
1331
1568
|
const newest = recentKeys[recentKeys.length - 1];
|
|
1332
1569
|
const age = frameTimestamp - newest.timestamp;
|
|
1333
1570
|
const fadeStart = config.fadeAfter * 0.6;
|
|
1334
1571
|
const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
1335
1572
|
if (opacity <= 0) return frameBuffer;
|
|
1573
|
+
const margin = 30 * dpr;
|
|
1336
1574
|
let hudX;
|
|
1337
|
-
const hudY = frameHeight - hudHeight -
|
|
1575
|
+
const hudY = frameHeight - hudHeight - margin;
|
|
1338
1576
|
switch (config.position) {
|
|
1339
1577
|
case "bottom-left":
|
|
1340
|
-
hudX =
|
|
1578
|
+
hudX = margin;
|
|
1341
1579
|
break;
|
|
1342
1580
|
case "bottom-right":
|
|
1343
|
-
hudX = frameWidth - hudWidth -
|
|
1581
|
+
hudX = frameWidth - hudWidth - margin;
|
|
1344
1582
|
break;
|
|
1345
1583
|
case "bottom-center":
|
|
1346
1584
|
default:
|
|
@@ -1350,43 +1588,20 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1350
1588
|
const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
|
|
1351
1589
|
const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
|
|
1352
1590
|
const escaped = truncated.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1353
|
-
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1591
|
+
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1354
1592
|
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
1355
|
-
rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
1356
|
-
<text x="${hudX + hudPadH}" y="${hudY + hudPadV +
|
|
1357
|
-
font-family="monospace, Menlo, Consolas" font-size="${
|
|
1593
|
+
rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
1594
|
+
<text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
|
|
1595
|
+
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1358
1596
|
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
1359
1597
|
</svg>`;
|
|
1360
1598
|
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1361
1599
|
}
|
|
1362
1600
|
|
|
1363
|
-
// src/effects/transition.ts
|
|
1364
|
-
import sharp6 from "sharp";
|
|
1365
|
-
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
1366
|
-
const t = Math.max(0, Math.min(1, progress));
|
|
1367
|
-
if (t <= 0) return fromBuffer;
|
|
1368
|
-
if (t >= 1) return toBuffer;
|
|
1369
|
-
const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1370
|
-
const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1371
|
-
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1372
|
-
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1373
|
-
pixels[i] = Math.round(
|
|
1374
|
-
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1375
|
-
);
|
|
1376
|
-
}
|
|
1377
|
-
return sharp6(pixels, {
|
|
1378
|
-
raw: {
|
|
1379
|
-
width: fromRaw.info.width,
|
|
1380
|
-
height: fromRaw.info.height,
|
|
1381
|
-
channels: 4
|
|
1382
|
-
}
|
|
1383
|
-
}).png().toBuffer();
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
1601
|
// src/effects/watermark.ts
|
|
1387
|
-
import
|
|
1388
|
-
|
|
1389
|
-
if (!config.enabled || !config.text) return
|
|
1602
|
+
import sharp6 from "sharp";
|
|
1603
|
+
function buildWatermarkSvg(config, frameWidth, frameHeight) {
|
|
1604
|
+
if (!config.enabled || !config.text) return "";
|
|
1390
1605
|
const charWidth = config.fontSize * 0.62;
|
|
1391
1606
|
const textWidth = Math.ceil(config.text.length * charWidth);
|
|
1392
1607
|
const margin = 16;
|
|
@@ -1412,31 +1627,228 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1412
1627
|
break;
|
|
1413
1628
|
}
|
|
1414
1629
|
const escaped = config.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1415
|
-
|
|
1630
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1416
1631
|
<text x="${x}" y="${y}"
|
|
1417
1632
|
font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
|
|
1418
1633
|
font-weight="600" fill="${config.color}"
|
|
1419
1634
|
opacity="${config.opacity.toFixed(3)}">${escaped}</text>
|
|
1420
1635
|
</svg>`;
|
|
1421
|
-
|
|
1636
|
+
}
|
|
1637
|
+
async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
1638
|
+
if (!config.enabled || !config.text) return frameBuffer;
|
|
1639
|
+
const svg = buildWatermarkSvg(config, frameWidth, frameHeight);
|
|
1640
|
+
return sharp6(frameBuffer).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toBuffer();
|
|
1422
1641
|
}
|
|
1423
1642
|
|
|
1424
|
-
// src/compose/
|
|
1425
|
-
function getFrameOffset(config) {
|
|
1643
|
+
// src/compose/compose-frame.ts
|
|
1644
|
+
function getFrameOffset(config, dpr = 1) {
|
|
1426
1645
|
if (!config.enabled) return { left: 0, top: 0 };
|
|
1427
1646
|
switch (config.type) {
|
|
1428
1647
|
case "browser":
|
|
1429
|
-
return { left: 0, top: 40 };
|
|
1648
|
+
return { left: 0, top: 40 * dpr };
|
|
1430
1649
|
case "iphone":
|
|
1431
|
-
return { left: 12, top: 50 };
|
|
1650
|
+
return { left: 12 * dpr, top: 50 * dpr };
|
|
1432
1651
|
case "ipad":
|
|
1433
|
-
return { left: 20, top: 24 };
|
|
1652
|
+
return { left: 20 * dpr, top: 24 * dpr };
|
|
1434
1653
|
case "android":
|
|
1435
|
-
return { left: 8, top: 32 };
|
|
1654
|
+
return { left: 8 * dpr, top: 32 * dpr };
|
|
1436
1655
|
default:
|
|
1437
1656
|
return { left: 0, top: 0 };
|
|
1438
1657
|
}
|
|
1439
1658
|
}
|
|
1659
|
+
async function composeFrame(frame, effects, output, context) {
|
|
1660
|
+
let buffer = frame.screenshot;
|
|
1661
|
+
const meta = await sharp7(buffer).metadata();
|
|
1662
|
+
let width = meta.width ?? frame.viewport.width;
|
|
1663
|
+
let height = meta.height ?? frame.viewport.height;
|
|
1664
|
+
const dpr = Math.round(width / frame.viewport.width);
|
|
1665
|
+
const ctx = {
|
|
1666
|
+
zoomScale: context?.zoomScale ?? 1,
|
|
1667
|
+
clickProgress: context?.clickProgress ?? null,
|
|
1668
|
+
cursorTrail: context?.cursorTrail ?? []
|
|
1669
|
+
};
|
|
1670
|
+
if (effects.deviceFrame.enabled) {
|
|
1671
|
+
const sl2 = ctx.staticLayers;
|
|
1672
|
+
if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
|
|
1673
|
+
buffer = await sharp7(buffer).extend({
|
|
1674
|
+
top: sl2.browserChromeHeight,
|
|
1675
|
+
bottom: 0,
|
|
1676
|
+
left: 0,
|
|
1677
|
+
right: 0,
|
|
1678
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
1679
|
+
}).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
|
|
1680
|
+
} else {
|
|
1681
|
+
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1682
|
+
}
|
|
1683
|
+
const meta2 = await sharp7(buffer).metadata();
|
|
1684
|
+
width = meta2.width ?? width;
|
|
1685
|
+
height = meta2.height ?? height;
|
|
1686
|
+
}
|
|
1687
|
+
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1688
|
+
buffer = await renderCursorHighlight(
|
|
1689
|
+
buffer,
|
|
1690
|
+
frame.cursorPosition,
|
|
1691
|
+
effects.cursor,
|
|
1692
|
+
width,
|
|
1693
|
+
height,
|
|
1694
|
+
dpr
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1698
|
+
buffer = await renderCursorTrail(
|
|
1699
|
+
buffer,
|
|
1700
|
+
ctx.cursorTrail,
|
|
1701
|
+
effects.cursor,
|
|
1702
|
+
width,
|
|
1703
|
+
height,
|
|
1704
|
+
dpr
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1708
|
+
buffer = await renderCursor(
|
|
1709
|
+
buffer,
|
|
1710
|
+
frame.cursorPosition,
|
|
1711
|
+
effects.cursor,
|
|
1712
|
+
width,
|
|
1713
|
+
height,
|
|
1714
|
+
dpr
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
|
|
1718
|
+
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1719
|
+
buffer = await renderClickEffect(
|
|
1720
|
+
buffer,
|
|
1721
|
+
frame.clickPosition,
|
|
1722
|
+
effects.cursor,
|
|
1723
|
+
progress,
|
|
1724
|
+
width,
|
|
1725
|
+
height,
|
|
1726
|
+
dpr
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
if (effects.keystroke.enabled && frame.keystrokes) {
|
|
1730
|
+
buffer = await renderKeystrokeHud(
|
|
1731
|
+
buffer,
|
|
1732
|
+
frame.keystrokes,
|
|
1733
|
+
frame.timestamp,
|
|
1734
|
+
effects.keystroke,
|
|
1735
|
+
width,
|
|
1736
|
+
height,
|
|
1737
|
+
dpr
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
const scale = ctx.zoomScale;
|
|
1741
|
+
if (effects.zoom.enabled && scale > 1) {
|
|
1742
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1743
|
+
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1744
|
+
const focusPoint = {
|
|
1745
|
+
x: rawFocus.x * dpr + offset.left,
|
|
1746
|
+
y: rawFocus.y * dpr + offset.top
|
|
1747
|
+
};
|
|
1748
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1749
|
+
}
|
|
1750
|
+
const sl = ctx.staticLayers;
|
|
1751
|
+
if (sl) {
|
|
1752
|
+
const padding = effects.background.padding;
|
|
1753
|
+
const contentWidth = output.width - padding * 2;
|
|
1754
|
+
const contentHeight = output.height - padding * 2;
|
|
1755
|
+
if (contentWidth > 0 && contentHeight > 0) {
|
|
1756
|
+
const radius = effects.background.borderRadius;
|
|
1757
|
+
const roundedMask = Buffer.from(
|
|
1758
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
|
|
1759
|
+
<rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
|
|
1760
|
+
</svg>`
|
|
1761
|
+
);
|
|
1762
|
+
const { data: maskedData, info: maskedInfo } = await sharp7(buffer).resize(contentWidth, contentHeight, { fit: "fill" }).composite([{ input: roundedMask, blend: "dest-in" }]).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1763
|
+
const { data: composited, info: compInfo } = await sharp7(sl.backdropRaw, {
|
|
1764
|
+
raw: { width: sl.backdropWidth, height: sl.backdropHeight, channels: 4 }
|
|
1765
|
+
}).composite([{
|
|
1766
|
+
input: Buffer.from(maskedData),
|
|
1767
|
+
raw: { width: maskedInfo.width, height: maskedInfo.height, channels: 4 },
|
|
1768
|
+
left: padding,
|
|
1769
|
+
top: padding
|
|
1770
|
+
}]).raw().toBuffer({ resolveWithObject: true });
|
|
1771
|
+
return {
|
|
1772
|
+
index: frame.index,
|
|
1773
|
+
buffer: Buffer.from(composited),
|
|
1774
|
+
timestamp: frame.timestamp,
|
|
1775
|
+
rawInfo: { width: compInfo.width, height: compInfo.height, channels: 4 }
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
buffer = sl.backdropRaw;
|
|
1779
|
+
} else {
|
|
1780
|
+
buffer = await applyBackground(buffer, effects.background, output.width, output.height);
|
|
1781
|
+
if (effects.watermark.enabled) {
|
|
1782
|
+
buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const { data: finalData, info: finalInfo } = await sharp7(buffer).resize(output.width, output.height, {
|
|
1786
|
+
fit: "fill",
|
|
1787
|
+
kernel: sharp7.kernel.lanczos3
|
|
1788
|
+
}).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1789
|
+
return {
|
|
1790
|
+
index: frame.index,
|
|
1791
|
+
buffer: Buffer.from(finalData),
|
|
1792
|
+
timestamp: frame.timestamp,
|
|
1793
|
+
rawInfo: { width: finalInfo.width, height: finalInfo.height, channels: 4 }
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// src/effects/transition.ts
|
|
1798
|
+
import sharp8 from "sharp";
|
|
1799
|
+
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height, fromRawInfo, toRawInfo) {
|
|
1800
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
1801
|
+
if (t <= 0) {
|
|
1802
|
+
const rawInfo = fromRawInfo ?? { width, height, channels: 4 };
|
|
1803
|
+
if (fromRawInfo) return { buffer: fromBuffer, rawInfo };
|
|
1804
|
+
const { data, info } = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1805
|
+
return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
|
|
1806
|
+
}
|
|
1807
|
+
if (t >= 1) {
|
|
1808
|
+
const rawInfo = toRawInfo ?? { width, height, channels: 4 };
|
|
1809
|
+
if (toRawInfo) return { buffer: toBuffer, rawInfo };
|
|
1810
|
+
const { data, info } = await sharp8(toBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1811
|
+
return { buffer: Buffer.from(data), rawInfo: { width: info.width, height: info.height, channels: 4 } };
|
|
1812
|
+
}
|
|
1813
|
+
const fromSrc = fromRawInfo ? sharp8(fromBuffer, { raw: { width: fromRawInfo.width, height: fromRawInfo.height, channels: fromRawInfo.channels } }) : sharp8(fromBuffer);
|
|
1814
|
+
const fromRaw = await fromSrc.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1815
|
+
const toSrc = toRawInfo ? sharp8(toBuffer, { raw: { width: toRawInfo.width, height: toRawInfo.height, channels: toRawInfo.channels } }) : sharp8(toBuffer);
|
|
1816
|
+
const toRaw = await toSrc.resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1817
|
+
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1818
|
+
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1819
|
+
pixels[i] = Math.round(
|
|
1820
|
+
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
return {
|
|
1824
|
+
buffer: pixels,
|
|
1825
|
+
rawInfo: { width: fromRaw.info.width, height: fromRaw.info.height, channels: 4 }
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/compose/canvas-renderer.ts
|
|
1830
|
+
var MIN_FRAMES_PER_WORKER = 4;
|
|
1831
|
+
var cachedWorkerUrl = null;
|
|
1832
|
+
function getWorkerUrl() {
|
|
1833
|
+
if (cachedWorkerUrl) return cachedWorkerUrl;
|
|
1834
|
+
const base = import.meta.url;
|
|
1835
|
+
const candidates = [
|
|
1836
|
+
new URL("./frame-worker.js", base),
|
|
1837
|
+
// from dist/compose/
|
|
1838
|
+
new URL("../compose/frame-worker.js", base),
|
|
1839
|
+
// from dist/cli/
|
|
1840
|
+
new URL("./compose/frame-worker.js", base)
|
|
1841
|
+
// from dist/
|
|
1842
|
+
];
|
|
1843
|
+
for (const url of candidates) {
|
|
1844
|
+
if (existsSync(fileURLToPath(url))) {
|
|
1845
|
+
cachedWorkerUrl = url;
|
|
1846
|
+
return url;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
cachedWorkerUrl = candidates[1];
|
|
1850
|
+
return cachedWorkerUrl;
|
|
1851
|
+
}
|
|
1440
1852
|
var CanvasRenderer = class {
|
|
1441
1853
|
constructor(effects, output, steps) {
|
|
1442
1854
|
this.effects = effects;
|
|
@@ -1445,118 +1857,11 @@ var CanvasRenderer = class {
|
|
|
1445
1857
|
}
|
|
1446
1858
|
steps;
|
|
1447
1859
|
/**
|
|
1448
|
-
* Apply the full effects pipeline to a single
|
|
1449
|
-
*
|
|
1450
|
-
* Pipeline order:
|
|
1451
|
-
* 1. Device frame (browser chrome / mobile mockup)
|
|
1452
|
-
* 2. Cursor highlight (Screen Studio glow)
|
|
1453
|
-
* 3. Cursor trail
|
|
1454
|
-
* 4. Cursor rendering
|
|
1455
|
-
* 5. Click ripple effect (animated progress)
|
|
1456
|
-
* 6. Keystroke HUD
|
|
1457
|
-
* 7. Zoom (adaptive, cursor-following)
|
|
1458
|
-
* 8. Background (padding, gradient, rounded corners)
|
|
1459
|
-
* 9. Watermark overlay
|
|
1460
|
-
* 10. Final resize
|
|
1860
|
+
* Apply the full effects pipeline to a single frame.
|
|
1861
|
+
* Delegates to the standalone composeFrame function.
|
|
1461
1862
|
*/
|
|
1462
1863
|
async composeFrame(frame, context) {
|
|
1463
|
-
|
|
1464
|
-
let width = frame.viewport.width;
|
|
1465
|
-
let height = frame.viewport.height;
|
|
1466
|
-
const ctx = {
|
|
1467
|
-
zoomScale: context?.zoomScale ?? 1,
|
|
1468
|
-
clickProgress: context?.clickProgress ?? null,
|
|
1469
|
-
cursorTrail: context?.cursorTrail ?? []
|
|
1470
|
-
};
|
|
1471
|
-
if (this.effects.deviceFrame.enabled) {
|
|
1472
|
-
buffer = await applyDeviceFrame(
|
|
1473
|
-
buffer,
|
|
1474
|
-
this.effects.deviceFrame,
|
|
1475
|
-
width,
|
|
1476
|
-
height
|
|
1477
|
-
);
|
|
1478
|
-
const meta = await sharp8(buffer).metadata();
|
|
1479
|
-
width = meta.width ?? width;
|
|
1480
|
-
height = meta.height ?? height;
|
|
1481
|
-
}
|
|
1482
|
-
if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
|
|
1483
|
-
buffer = await renderCursorHighlight(
|
|
1484
|
-
buffer,
|
|
1485
|
-
frame.cursorPosition,
|
|
1486
|
-
this.effects.cursor,
|
|
1487
|
-
width,
|
|
1488
|
-
height
|
|
1489
|
-
);
|
|
1490
|
-
}
|
|
1491
|
-
if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1492
|
-
buffer = await renderCursorTrail(
|
|
1493
|
-
buffer,
|
|
1494
|
-
ctx.cursorTrail,
|
|
1495
|
-
this.effects.cursor,
|
|
1496
|
-
width,
|
|
1497
|
-
height
|
|
1498
|
-
);
|
|
1499
|
-
}
|
|
1500
|
-
if (this.effects.cursor.enabled && frame.cursorPosition) {
|
|
1501
|
-
buffer = await renderCursor(
|
|
1502
|
-
buffer,
|
|
1503
|
-
frame.cursorPosition,
|
|
1504
|
-
this.effects.cursor,
|
|
1505
|
-
width,
|
|
1506
|
-
height
|
|
1507
|
-
);
|
|
1508
|
-
}
|
|
1509
|
-
if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
|
|
1510
|
-
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1511
|
-
buffer = await renderClickEffect(
|
|
1512
|
-
buffer,
|
|
1513
|
-
frame.clickPosition,
|
|
1514
|
-
this.effects.cursor,
|
|
1515
|
-
progress,
|
|
1516
|
-
width,
|
|
1517
|
-
height
|
|
1518
|
-
);
|
|
1519
|
-
}
|
|
1520
|
-
if (this.effects.keystroke.enabled && frame.keystrokes) {
|
|
1521
|
-
buffer = await renderKeystrokeHud(
|
|
1522
|
-
buffer,
|
|
1523
|
-
frame.keystrokes,
|
|
1524
|
-
frame.timestamp,
|
|
1525
|
-
this.effects.keystroke,
|
|
1526
|
-
width,
|
|
1527
|
-
height
|
|
1528
|
-
);
|
|
1529
|
-
}
|
|
1530
|
-
const scale = ctx.zoomScale;
|
|
1531
|
-
if (this.effects.zoom.enabled && scale > 1) {
|
|
1532
|
-
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
|
|
1533
|
-
const offset = getFrameOffset(this.effects.deviceFrame);
|
|
1534
|
-
const focusPoint = {
|
|
1535
|
-
x: rawFocus.x + offset.left,
|
|
1536
|
-
y: rawFocus.y + offset.top
|
|
1537
|
-
};
|
|
1538
|
-
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1539
|
-
}
|
|
1540
|
-
buffer = await applyBackground(
|
|
1541
|
-
buffer,
|
|
1542
|
-
this.effects.background,
|
|
1543
|
-
this.output.width,
|
|
1544
|
-
this.output.height
|
|
1545
|
-
);
|
|
1546
|
-
if (this.effects.watermark.enabled) {
|
|
1547
|
-
buffer = await renderWatermark(
|
|
1548
|
-
buffer,
|
|
1549
|
-
this.effects.watermark,
|
|
1550
|
-
this.output.width,
|
|
1551
|
-
this.output.height
|
|
1552
|
-
);
|
|
1553
|
-
}
|
|
1554
|
-
buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
|
|
1555
|
-
return {
|
|
1556
|
-
index: frame.index,
|
|
1557
|
-
buffer,
|
|
1558
|
-
timestamp: frame.timestamp
|
|
1559
|
-
};
|
|
1864
|
+
return composeFrame(frame, this.effects, this.output, context);
|
|
1560
1865
|
}
|
|
1561
1866
|
/**
|
|
1562
1867
|
* Process an entire sequence of captured frames through the effects pipeline.
|
|
@@ -1564,7 +1869,7 @@ var CanvasRenderer = class {
|
|
|
1564
1869
|
* Multi-pass approach:
|
|
1565
1870
|
* Pass 1: Speed ramping (adjust frame set).
|
|
1566
1871
|
* Pass 2: Calculate per-frame contexts (zoom, click, trail).
|
|
1567
|
-
* Pass 3: Render
|
|
1872
|
+
* Pass 3: Render frames in parallel using worker threads.
|
|
1568
1873
|
* Pass 4: Apply scene transitions at step boundaries.
|
|
1569
1874
|
*/
|
|
1570
1875
|
async composeAll(frames) {
|
|
@@ -1574,10 +1879,19 @@ var CanvasRenderer = class {
|
|
|
1574
1879
|
processFrames = this.applySpeedRamp(frames);
|
|
1575
1880
|
}
|
|
1576
1881
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
1577
|
-
const
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1882
|
+
const cpuCount = os.cpus().length;
|
|
1883
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
1884
|
+
const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
|
|
1885
|
+
let composed;
|
|
1886
|
+
if (useWorkers) {
|
|
1887
|
+
composed = await this.processWithWorkers(processFrames, contexts, workerCount);
|
|
1888
|
+
} else {
|
|
1889
|
+
composed = [];
|
|
1890
|
+
for (let i = 0; i < processFrames.length; i++) {
|
|
1891
|
+
composed.push(
|
|
1892
|
+
await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1581
1895
|
}
|
|
1582
1896
|
if (this.steps.length > 0) {
|
|
1583
1897
|
await this.applyTransitions(composed, processFrames);
|
|
@@ -1585,19 +1899,78 @@ var CanvasRenderer = class {
|
|
|
1585
1899
|
return composed;
|
|
1586
1900
|
}
|
|
1587
1901
|
/**
|
|
1588
|
-
*
|
|
1902
|
+
* Distribute frame composition across a pool of worker threads.
|
|
1903
|
+
* Workers process frames concurrently; results are collected in order.
|
|
1904
|
+
*/
|
|
1905
|
+
processWithWorkers(frames, contexts, workerCount) {
|
|
1906
|
+
return new Promise((resolve2, reject) => {
|
|
1907
|
+
const results = new Array(frames.length);
|
|
1908
|
+
let completed = 0;
|
|
1909
|
+
let nextIndex = 0;
|
|
1910
|
+
let failed = false;
|
|
1911
|
+
const workerUrl = getWorkerUrl();
|
|
1912
|
+
const workers = [];
|
|
1913
|
+
const dispatch = (worker) => {
|
|
1914
|
+
if (nextIndex >= frames.length || failed) return;
|
|
1915
|
+
const i = nextIndex++;
|
|
1916
|
+
worker.postMessage({
|
|
1917
|
+
taskId: i,
|
|
1918
|
+
frame: frames[i],
|
|
1919
|
+
effects: this.effects,
|
|
1920
|
+
output: this.output,
|
|
1921
|
+
context: contexts[i]
|
|
1922
|
+
});
|
|
1923
|
+
};
|
|
1924
|
+
for (let w = 0; w < workerCount; w++) {
|
|
1925
|
+
const worker = new Worker(workerUrl);
|
|
1926
|
+
workers.push(worker);
|
|
1927
|
+
worker.on("message", (msg) => {
|
|
1928
|
+
if (failed) return;
|
|
1929
|
+
if (msg.error) {
|
|
1930
|
+
failed = true;
|
|
1931
|
+
workers.forEach((wk) => wk.terminate());
|
|
1932
|
+
reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
results[msg.taskId] = {
|
|
1936
|
+
index: frames[msg.taskId].index,
|
|
1937
|
+
buffer: Buffer.from(msg.buffer),
|
|
1938
|
+
timestamp: frames[msg.taskId].timestamp,
|
|
1939
|
+
rawInfo: msg.rawInfo
|
|
1940
|
+
};
|
|
1941
|
+
completed++;
|
|
1942
|
+
if (completed === frames.length) {
|
|
1943
|
+
workers.forEach((wk) => wk.terminate());
|
|
1944
|
+
resolve2(results);
|
|
1945
|
+
} else {
|
|
1946
|
+
dispatch(worker);
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
worker.on("error", (err) => {
|
|
1950
|
+
if (failed) return;
|
|
1951
|
+
failed = true;
|
|
1952
|
+
workers.forEach((wk) => wk.terminate());
|
|
1953
|
+
reject(err);
|
|
1954
|
+
});
|
|
1955
|
+
dispatch(worker);
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
|
|
1589
1961
|
*/
|
|
1590
1962
|
calculateFrameContexts(frames) {
|
|
1591
1963
|
const contexts = [];
|
|
1592
1964
|
const transitionFrames = Math.round(
|
|
1593
1965
|
this.output.fps * (this.effects.zoom.duration / 1e3)
|
|
1594
1966
|
);
|
|
1967
|
+
const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
|
|
1595
1968
|
for (let i = 0; i < frames.length; i++) {
|
|
1596
1969
|
const frame = frames[i];
|
|
1597
1970
|
let zoomScale = 1;
|
|
1598
1971
|
if (this.effects.zoom.enabled) {
|
|
1599
|
-
zoomScale =
|
|
1600
|
-
|
|
1972
|
+
zoomScale = calculateAdaptiveZoomFromLookup(
|
|
1973
|
+
clickLookup,
|
|
1601
1974
|
i,
|
|
1602
1975
|
this.effects.zoom.scale,
|
|
1603
1976
|
transitionFrames
|
|
@@ -1617,7 +1990,6 @@ var CanvasRenderer = class {
|
|
|
1617
1990
|
}
|
|
1618
1991
|
/**
|
|
1619
1992
|
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
1620
|
-
* Returns a new frame array with frames duplicated or skipped.
|
|
1621
1993
|
*/
|
|
1622
1994
|
applySpeedRamp(frames) {
|
|
1623
1995
|
const config = this.effects.speedRamp;
|
|
@@ -1648,9 +2020,371 @@ var CanvasRenderer = class {
|
|
|
1648
2020
|
}
|
|
1649
2021
|
return result;
|
|
1650
2022
|
}
|
|
2023
|
+
// ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
|
|
2024
|
+
/**
|
|
2025
|
+
* Returns true when no effect requires the full frame array upfront.
|
|
2026
|
+
*
|
|
2027
|
+
* When true, composeStreamOnline() can be used: frames are composited as they
|
|
2028
|
+
* arrive (no need to wait for all frames to be collected first).
|
|
2029
|
+
*
|
|
2030
|
+
* Currently the only blocking effect is speed ramp, which needs to scan all
|
|
2031
|
+
* frames to compute action-proximity indices. Zoom uses the window-based
|
|
2032
|
+
* calculateAdaptiveZoomInWindow() so it works with a rolling lookahead buffer.
|
|
2033
|
+
*/
|
|
2034
|
+
canStreamOnline() {
|
|
2035
|
+
return !this.effects.speedRamp.enabled;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Online streaming compose — accepts an AsyncIterable of frames (e.g. from
|
|
2039
|
+
* ClipwiseRecorder.recordToChannel()) and begins compositing immediately,
|
|
2040
|
+
* without waiting for all frames to be collected.
|
|
2041
|
+
*
|
|
2042
|
+
* Each frame is dispatched to the worker pool as soon as its zoom lookahead
|
|
2043
|
+
* window is satisfied (i.e. when frame i + transitionFrames has arrived).
|
|
2044
|
+
* This creates a natural pipeline: recording produces frames while workers
|
|
2045
|
+
* consume them in parallel.
|
|
2046
|
+
*
|
|
2047
|
+
* Requires canStreamOnline() === true (speedRamp must be disabled).
|
|
2048
|
+
* Transitions (step boundaries with transition: fade) are applied inline
|
|
2049
|
+
* using the same applyTransitionsToStream() logic as composeStream().
|
|
2050
|
+
*/
|
|
2051
|
+
async *composeStreamOnline(source) {
|
|
2052
|
+
const hasFadeTransitions = this.steps.some((s) => s.transition === "fade");
|
|
2053
|
+
if (!hasFadeTransitions) {
|
|
2054
|
+
const cpuCount = os.cpus().length;
|
|
2055
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
2056
|
+
yield* this.streamOnlineWithWorkers(source, workerCount);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const collected = [];
|
|
2060
|
+
for await (const frame of source) {
|
|
2061
|
+
collected.push(frame);
|
|
2062
|
+
}
|
|
2063
|
+
yield* this.composeStream(collected);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Worker-pool online streaming: dispatches frame i to a worker as soon as
|
|
2067
|
+
* frame i + transitionFrames has arrived from the source.
|
|
2068
|
+
*
|
|
2069
|
+
* Uses a notify-on-progress pattern (same as streamWithWorkers) extended
|
|
2070
|
+
* with an intake coroutine that feeds the growing frames[] buffer.
|
|
2071
|
+
*/
|
|
2072
|
+
async *streamOnlineWithWorkers(source, workerCount) {
|
|
2073
|
+
const transitionFrames = this.effects.zoom.enabled ? Math.round(this.output.fps * (this.effects.zoom.duration / 1e3)) : 0;
|
|
2074
|
+
const trailLength = this.effects.cursor.trailLength;
|
|
2075
|
+
const frames = [];
|
|
2076
|
+
let sourceComplete = false;
|
|
2077
|
+
let workerError = null;
|
|
2078
|
+
let notify = null;
|
|
2079
|
+
const trigger = () => {
|
|
2080
|
+
notify?.();
|
|
2081
|
+
notify = null;
|
|
2082
|
+
};
|
|
2083
|
+
const waitForProgress = () => new Promise((r) => {
|
|
2084
|
+
notify = r;
|
|
2085
|
+
});
|
|
2086
|
+
const completed = /* @__PURE__ */ new Map();
|
|
2087
|
+
const idleWorkers = [];
|
|
2088
|
+
let nextToDispatch = 0;
|
|
2089
|
+
let nextToYield = 0;
|
|
2090
|
+
const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
|
|
2091
|
+
const computeContext = (i) => {
|
|
2092
|
+
const frame = frames[i];
|
|
2093
|
+
let zoomScale = 1;
|
|
2094
|
+
if (this.effects.zoom.enabled) {
|
|
2095
|
+
const lo = Math.max(0, i - transitionFrames);
|
|
2096
|
+
const hi = Math.min(frames.length - 1, i + transitionFrames);
|
|
2097
|
+
zoomScale = calculateAdaptiveZoomInWindow(
|
|
2098
|
+
frames.slice(lo, hi + 1),
|
|
2099
|
+
lo,
|
|
2100
|
+
i,
|
|
2101
|
+
this.effects.zoom.scale,
|
|
2102
|
+
transitionFrames
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
|
|
2106
|
+
const trail = [];
|
|
2107
|
+
for (let j = Math.max(0, i - trailLength); j <= i; j++) {
|
|
2108
|
+
if (frames[j].cursorPosition) trail.push(frames[j].cursorPosition);
|
|
2109
|
+
}
|
|
2110
|
+
return { zoomScale, clickProgress, cursorTrail: trail };
|
|
2111
|
+
};
|
|
2112
|
+
const dispatch = (worker) => {
|
|
2113
|
+
if (canDispatch(nextToDispatch)) {
|
|
2114
|
+
const i = nextToDispatch++;
|
|
2115
|
+
worker.postMessage({
|
|
2116
|
+
taskId: i,
|
|
2117
|
+
frame: frames[i],
|
|
2118
|
+
effects: this.effects,
|
|
2119
|
+
output: this.output,
|
|
2120
|
+
context: computeContext(i)
|
|
2121
|
+
});
|
|
2122
|
+
} else {
|
|
2123
|
+
idleWorkers.push(worker);
|
|
2124
|
+
}
|
|
2125
|
+
};
|
|
2126
|
+
const dispatchToIdle = () => {
|
|
2127
|
+
while (idleWorkers.length > 0 && canDispatch(nextToDispatch)) {
|
|
2128
|
+
dispatch(idleWorkers.shift());
|
|
2129
|
+
}
|
|
2130
|
+
};
|
|
2131
|
+
const workerUrl = getWorkerUrl();
|
|
2132
|
+
const workers = [];
|
|
2133
|
+
for (let w = 0; w < workerCount; w++) {
|
|
2134
|
+
const worker = new Worker(workerUrl);
|
|
2135
|
+
workers.push(worker);
|
|
2136
|
+
worker.on("message", (msg) => {
|
|
2137
|
+
if (workerError) return;
|
|
2138
|
+
if (msg.error) {
|
|
2139
|
+
workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
|
|
2140
|
+
} else {
|
|
2141
|
+
completed.set(msg.taskId, {
|
|
2142
|
+
index: frames[msg.taskId].index,
|
|
2143
|
+
buffer: Buffer.from(msg.buffer),
|
|
2144
|
+
timestamp: frames[msg.taskId].timestamp,
|
|
2145
|
+
rawInfo: msg.rawInfo
|
|
2146
|
+
});
|
|
2147
|
+
dispatch(worker);
|
|
2148
|
+
}
|
|
2149
|
+
trigger();
|
|
2150
|
+
});
|
|
2151
|
+
worker.on("error", (err) => {
|
|
2152
|
+
workerError = err;
|
|
2153
|
+
trigger();
|
|
2154
|
+
});
|
|
2155
|
+
idleWorkers.push(worker);
|
|
2156
|
+
}
|
|
2157
|
+
const intakeTask = (async () => {
|
|
2158
|
+
for await (const frame of source) {
|
|
2159
|
+
frames.push(frame);
|
|
2160
|
+
dispatchToIdle();
|
|
2161
|
+
trigger();
|
|
2162
|
+
}
|
|
2163
|
+
sourceComplete = true;
|
|
2164
|
+
dispatchToIdle();
|
|
2165
|
+
trigger();
|
|
2166
|
+
})();
|
|
2167
|
+
try {
|
|
2168
|
+
while (true) {
|
|
2169
|
+
if (workerError) throw workerError;
|
|
2170
|
+
if (sourceComplete && nextToDispatch >= frames.length && nextToYield >= frames.length) {
|
|
2171
|
+
break;
|
|
2172
|
+
}
|
|
2173
|
+
if (completed.has(nextToYield)) {
|
|
2174
|
+
const frame = completed.get(nextToYield);
|
|
2175
|
+
completed.delete(nextToYield);
|
|
2176
|
+
nextToYield++;
|
|
2177
|
+
yield frame;
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
await waitForProgress();
|
|
2181
|
+
}
|
|
2182
|
+
} finally {
|
|
2183
|
+
await intakeTask;
|
|
2184
|
+
workers.forEach((w) => w.terminate());
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
// ─── Streaming pipeline (Phase 1-B) ────────────────────────────────────────
|
|
2188
|
+
/**
|
|
2189
|
+
* Stream frame composition — yields ComposedFrames as workers finish,
|
|
2190
|
+
* in display order, so the encoder can start before all frames are composed.
|
|
2191
|
+
*
|
|
2192
|
+
* Same 4-pass structure as composeAll():
|
|
2193
|
+
* Pass 1 & 2 run upfront (need the full frame set).
|
|
2194
|
+
* Pass 3 streams via the worker pool (ordered yield).
|
|
2195
|
+
* Pass 4 transitions are buffered inline and applied at step boundaries.
|
|
2196
|
+
*/
|
|
2197
|
+
async *composeStream(frames) {
|
|
2198
|
+
if (frames.length === 0) return;
|
|
2199
|
+
let processFrames = frames;
|
|
2200
|
+
if (this.effects.speedRamp.enabled) {
|
|
2201
|
+
processFrames = this.applySpeedRamp(frames);
|
|
2202
|
+
}
|
|
2203
|
+
const contexts = this.calculateFrameContexts(processFrames);
|
|
2204
|
+
const windows = this.getTransitionWindows(processFrames);
|
|
2205
|
+
const cpuCount = os.cpus().length;
|
|
2206
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
2207
|
+
const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
|
|
2208
|
+
const rawStream = useWorkers ? this.streamWithWorkers(processFrames, contexts, workerCount) : this.streamSequential(processFrames, contexts);
|
|
2209
|
+
yield* this.applyTransitionsToStream(rawStream, windows);
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Worker-pool streaming: dispatches frames to workers and yields results
|
|
2213
|
+
* in display order as soon as each frame is ready.
|
|
2214
|
+
*
|
|
2215
|
+
* Uses a notify-on-progress pattern to bridge event-driven workers
|
|
2216
|
+
* to an ordered AsyncGenerator without busy-polling.
|
|
2217
|
+
*/
|
|
2218
|
+
async *streamWithWorkers(frames, contexts, workerCount) {
|
|
2219
|
+
const completed = new Array(frames.length);
|
|
2220
|
+
let workerError = null;
|
|
2221
|
+
let notify = null;
|
|
2222
|
+
const waitForProgress = () => new Promise((r) => {
|
|
2223
|
+
notify = r;
|
|
2224
|
+
});
|
|
2225
|
+
const workerUrl = getWorkerUrl();
|
|
2226
|
+
const workers = [];
|
|
2227
|
+
let nextToDispatch = 0;
|
|
2228
|
+
const dispatch = (worker) => {
|
|
2229
|
+
if (nextToDispatch >= frames.length || workerError) return;
|
|
2230
|
+
const i = nextToDispatch++;
|
|
2231
|
+
worker.postMessage({
|
|
2232
|
+
taskId: i,
|
|
2233
|
+
frame: frames[i],
|
|
2234
|
+
effects: this.effects,
|
|
2235
|
+
output: this.output,
|
|
2236
|
+
context: contexts[i]
|
|
2237
|
+
});
|
|
2238
|
+
};
|
|
2239
|
+
for (let w = 0; w < workerCount; w++) {
|
|
2240
|
+
const worker = new Worker(workerUrl);
|
|
2241
|
+
workers.push(worker);
|
|
2242
|
+
worker.on("message", (msg) => {
|
|
2243
|
+
if (workerError) return;
|
|
2244
|
+
if (msg.error) {
|
|
2245
|
+
workerError = new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`);
|
|
2246
|
+
} else {
|
|
2247
|
+
completed[msg.taskId] = {
|
|
2248
|
+
index: frames[msg.taskId].index,
|
|
2249
|
+
buffer: Buffer.from(msg.buffer),
|
|
2250
|
+
timestamp: frames[msg.taskId].timestamp,
|
|
2251
|
+
rawInfo: msg.rawInfo
|
|
2252
|
+
};
|
|
2253
|
+
dispatch(worker);
|
|
2254
|
+
}
|
|
2255
|
+
notify?.();
|
|
2256
|
+
notify = null;
|
|
2257
|
+
});
|
|
2258
|
+
worker.on("error", (err) => {
|
|
2259
|
+
workerError = err;
|
|
2260
|
+
notify?.();
|
|
2261
|
+
notify = null;
|
|
2262
|
+
});
|
|
2263
|
+
dispatch(worker);
|
|
2264
|
+
}
|
|
2265
|
+
try {
|
|
2266
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2267
|
+
while (completed[i] === void 0 && !workerError) {
|
|
2268
|
+
await waitForProgress();
|
|
2269
|
+
}
|
|
2270
|
+
if (workerError) throw workerError;
|
|
2271
|
+
const frame = completed[i];
|
|
2272
|
+
completed[i] = void 0;
|
|
2273
|
+
yield frame;
|
|
2274
|
+
}
|
|
2275
|
+
} finally {
|
|
2276
|
+
workers.forEach((w) => w.terminate());
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Sequential streaming fallback for small frame counts where worker
|
|
2281
|
+
* thread overhead would exceed the parallelism benefit.
|
|
2282
|
+
*/
|
|
2283
|
+
async *streamSequential(frames, contexts) {
|
|
2284
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2285
|
+
yield await composeFrame(frames[i], this.effects, this.output, contexts[i]);
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Pre-compute [startIdx, endIdx] windows for every fade transition so that
|
|
2290
|
+
* applyTransitionsToStream can buffer only those frames.
|
|
2291
|
+
*/
|
|
2292
|
+
getTransitionWindows(frames) {
|
|
2293
|
+
if (this.steps.length === 0) return [];
|
|
2294
|
+
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
2295
|
+
const windows = [];
|
|
2296
|
+
for (let i = 1; i < frames.length; i++) {
|
|
2297
|
+
if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
|
|
2298
|
+
const stepIdx = frames[i].stepIndex;
|
|
2299
|
+
const step = this.steps[stepIdx];
|
|
2300
|
+
if (step && step.transition === "fade") {
|
|
2301
|
+
const startIdx = Math.max(0, i - Math.floor(transitionFrames / 2));
|
|
2302
|
+
const endIdx = Math.min(frames.length - 1, i + Math.ceil(transitionFrames / 2));
|
|
2303
|
+
if (endIdx - startIdx >= 2) {
|
|
2304
|
+
windows.push({ startIdx, endIdx });
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
return windows;
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Wrap a ComposedFrame stream with inline transition buffering.
|
|
2313
|
+
*
|
|
2314
|
+
* Non-transition frames are yielded immediately.
|
|
2315
|
+
* Frames inside a fade window are held until both endpoints are available,
|
|
2316
|
+
* then the crossfade is applied and all window frames are flushed in order.
|
|
2317
|
+
* A pending map maintains global display order across window boundaries.
|
|
2318
|
+
*/
|
|
2319
|
+
async *applyTransitionsToStream(source, windows) {
|
|
2320
|
+
if (windows.length === 0) {
|
|
2321
|
+
yield* source;
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
const frameToWindow = /* @__PURE__ */ new Map();
|
|
2325
|
+
for (let wi = 0; wi < windows.length; wi++) {
|
|
2326
|
+
for (let i = windows[wi].startIdx; i <= windows[wi].endIdx; i++) {
|
|
2327
|
+
frameToWindow.set(i, wi);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
const windowState = windows.map((w) => ({
|
|
2331
|
+
frames: new Array(w.endIdx - w.startIdx + 1),
|
|
2332
|
+
received: 0
|
|
2333
|
+
}));
|
|
2334
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2335
|
+
let nextToYield = 0;
|
|
2336
|
+
let frameIdx = 0;
|
|
2337
|
+
for await (const frame of source) {
|
|
2338
|
+
const idx = frameIdx++;
|
|
2339
|
+
const wi = frameToWindow.get(idx);
|
|
2340
|
+
if (wi === void 0) {
|
|
2341
|
+
pending.set(idx, frame);
|
|
2342
|
+
} else {
|
|
2343
|
+
const win = windows[wi];
|
|
2344
|
+
const state = windowState[wi];
|
|
2345
|
+
state.frames[idx - win.startIdx] = frame;
|
|
2346
|
+
state.received++;
|
|
2347
|
+
if (state.received === state.frames.length) {
|
|
2348
|
+
const fromBuf = state.frames[0].buffer;
|
|
2349
|
+
const toBuf = state.frames[state.frames.length - 1].buffer;
|
|
2350
|
+
const range = state.frames.length - 1;
|
|
2351
|
+
const fromRawInfo = state.frames[0].rawInfo;
|
|
2352
|
+
const toRawInfo = state.frames[state.frames.length - 1].rawInfo;
|
|
2353
|
+
for (let j = 1; j < state.frames.length - 1; j++) {
|
|
2354
|
+
const blended = await applyCrossfade(
|
|
2355
|
+
fromBuf,
|
|
2356
|
+
toBuf,
|
|
2357
|
+
j / range,
|
|
2358
|
+
this.output.width,
|
|
2359
|
+
this.output.height,
|
|
2360
|
+
fromRawInfo,
|
|
2361
|
+
toRawInfo
|
|
2362
|
+
);
|
|
2363
|
+
state.frames[j] = {
|
|
2364
|
+
...state.frames[j],
|
|
2365
|
+
buffer: blended.buffer,
|
|
2366
|
+
rawInfo: blended.rawInfo
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
for (let j = 0; j < state.frames.length; j++) {
|
|
2370
|
+
pending.set(win.startIdx + j, state.frames[j]);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
while (pending.has(nextToYield)) {
|
|
2375
|
+
yield pending.get(nextToYield);
|
|
2376
|
+
pending.delete(nextToYield);
|
|
2377
|
+
nextToYield++;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
while (pending.has(nextToYield)) {
|
|
2381
|
+
yield pending.get(nextToYield);
|
|
2382
|
+
pending.delete(nextToYield);
|
|
2383
|
+
nextToYield++;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
1651
2386
|
/**
|
|
1652
2387
|
* Apply crossfade transitions at step boundaries where configured.
|
|
1653
|
-
* Modifies the composed array in-place.
|
|
1654
2388
|
*/
|
|
1655
2389
|
async applyTransitions(composed, frames) {
|
|
1656
2390
|
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
@@ -1671,17 +2405,21 @@ var CanvasRenderer = class {
|
|
|
1671
2405
|
if (range < 2) continue;
|
|
1672
2406
|
const fromBuffer = composed[startIdx].buffer;
|
|
1673
2407
|
const toBuffer = composed[endIdx].buffer;
|
|
1674
|
-
const
|
|
1675
|
-
const
|
|
2408
|
+
const fromRawInfo = composed[startIdx].rawInfo;
|
|
2409
|
+
const toRawInfo = composed[endIdx].rawInfo;
|
|
1676
2410
|
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
1677
2411
|
const progress = (i - startIdx) / range;
|
|
1678
|
-
|
|
2412
|
+
const blended = await applyCrossfade(
|
|
1679
2413
|
fromBuffer,
|
|
1680
2414
|
toBuffer,
|
|
1681
2415
|
progress,
|
|
1682
|
-
width,
|
|
1683
|
-
height
|
|
2416
|
+
this.output.width,
|
|
2417
|
+
this.output.height,
|
|
2418
|
+
fromRawInfo,
|
|
2419
|
+
toRawInfo
|
|
1684
2420
|
);
|
|
2421
|
+
composed[i].buffer = blended.buffer;
|
|
2422
|
+
composed[i].rawInfo = blended.rawInfo;
|
|
1685
2423
|
}
|
|
1686
2424
|
}
|
|
1687
2425
|
}
|
|
@@ -1690,11 +2428,45 @@ var CanvasRenderer = class {
|
|
|
1690
2428
|
// src/compose/video-encoder.ts
|
|
1691
2429
|
import gifenc from "gifenc";
|
|
1692
2430
|
import sharp9 from "sharp";
|
|
1693
|
-
import { writeFile, mkdir, readFile as readFile2, rm
|
|
2431
|
+
import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
|
|
1694
2432
|
import { join } from "path";
|
|
1695
2433
|
import { tmpdir } from "os";
|
|
1696
2434
|
import { spawn } from "child_process";
|
|
1697
2435
|
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
2436
|
+
var ENCODING_PRESETS = {
|
|
2437
|
+
social: { crf: 22, vtQuality: 75 },
|
|
2438
|
+
balanced: { crf: 18, vtQuality: 85 },
|
|
2439
|
+
archive: { crf: 13, vtQuality: 92 }
|
|
2440
|
+
};
|
|
2441
|
+
function resolveEncodingParams(config) {
|
|
2442
|
+
if (config.preset) return ENCODING_PRESETS[config.preset];
|
|
2443
|
+
process.stderr.write(
|
|
2444
|
+
`[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
|
|
2445
|
+
`
|
|
2446
|
+
);
|
|
2447
|
+
if (config.quality >= 75) return ENCODING_PRESETS.social;
|
|
2448
|
+
if (config.quality >= 45) return ENCODING_PRESETS.balanced;
|
|
2449
|
+
return ENCODING_PRESETS.archive;
|
|
2450
|
+
}
|
|
2451
|
+
var encoderDetectionPromise = null;
|
|
2452
|
+
function detectVideoEncoder() {
|
|
2453
|
+
if (!encoderDetectionPromise) {
|
|
2454
|
+
encoderDetectionPromise = new Promise((resolve2) => {
|
|
2455
|
+
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
2456
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2457
|
+
});
|
|
2458
|
+
let out = "";
|
|
2459
|
+
proc.stdout.on("data", (d) => out += d.toString());
|
|
2460
|
+
proc.on("close", () => {
|
|
2461
|
+
if (out.includes("hevc_videotoolbox")) resolve2("hevc_videotoolbox");
|
|
2462
|
+
else if (out.includes("h264_videotoolbox")) resolve2("h264_videotoolbox");
|
|
2463
|
+
else resolve2("libx264");
|
|
2464
|
+
});
|
|
2465
|
+
proc.on("error", () => resolve2("libx264"));
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
return encoderDetectionPromise;
|
|
2469
|
+
}
|
|
1698
2470
|
async function encodeGif(frames, config) {
|
|
1699
2471
|
if (frames.length === 0) {
|
|
1700
2472
|
throw new Error("Cannot encode GIF: no frames provided");
|
|
@@ -1704,66 +2476,95 @@ async function encodeGif(frames, config) {
|
|
|
1704
2476
|
const gif = GIFEncoder();
|
|
1705
2477
|
const delay = Math.round(1e3 / config.fps);
|
|
1706
2478
|
for (const frame of frames) {
|
|
1707
|
-
const
|
|
2479
|
+
const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
|
|
2480
|
+
const { data, info } = await src.resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1708
2481
|
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1709
2482
|
const palette = quantize(rgba, 256);
|
|
1710
2483
|
const indexed = applyPalette(rgba, palette);
|
|
1711
|
-
gif.writeFrame(indexed, width, height, {
|
|
1712
|
-
palette,
|
|
1713
|
-
delay
|
|
1714
|
-
});
|
|
2484
|
+
gif.writeFrame(indexed, width, height, { palette, delay });
|
|
1715
2485
|
}
|
|
1716
2486
|
gif.finish();
|
|
1717
2487
|
return Buffer.from(gif.bytes());
|
|
1718
2488
|
}
|
|
1719
|
-
async function
|
|
1720
|
-
|
|
1721
|
-
throw new Error("Cannot encode MP4: no frames provided");
|
|
1722
|
-
}
|
|
1723
|
-
const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
|
|
2489
|
+
async function encodeMp4Stream(frames, config) {
|
|
2490
|
+
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
1724
2491
|
try {
|
|
1725
|
-
const
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
|
|
1729
|
-
await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
|
|
1730
|
-
}
|
|
1731
|
-
const outputPath = join(tmpDir, "output.mp4");
|
|
1732
|
-
const crf = Math.round(51 - config.quality / 100 * 51);
|
|
1733
|
-
await runFfmpeg([
|
|
1734
|
-
"-y",
|
|
1735
|
-
"-framerate",
|
|
1736
|
-
String(config.fps),
|
|
1737
|
-
"-i",
|
|
1738
|
-
join(tmpDir, `frame-%0${padLength}d.png`),
|
|
1739
|
-
"-c:v",
|
|
1740
|
-
"libx264",
|
|
1741
|
-
"-pix_fmt",
|
|
1742
|
-
"yuv420p",
|
|
1743
|
-
"-crf",
|
|
1744
|
-
String(crf),
|
|
1745
|
-
"-preset",
|
|
1746
|
-
"slow",
|
|
1747
|
-
"-tune",
|
|
1748
|
-
"animation",
|
|
1749
|
-
"-movflags",
|
|
1750
|
-
"+faststart",
|
|
1751
|
-
outputPath
|
|
1752
|
-
]);
|
|
2492
|
+
const encoder = await detectVideoEncoder();
|
|
2493
|
+
const params = resolveEncodingParams(config);
|
|
2494
|
+
await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath);
|
|
1753
2495
|
return await readFile2(outputPath);
|
|
1754
2496
|
} finally {
|
|
1755
|
-
await rm(
|
|
2497
|
+
await rm(outputPath, { force: true }).catch(() => {
|
|
1756
2498
|
});
|
|
1757
2499
|
}
|
|
1758
2500
|
}
|
|
1759
|
-
function
|
|
2501
|
+
async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
2502
|
+
const videoArgs = encoder === "hevc_videotoolbox" ? [
|
|
2503
|
+
"-c:v",
|
|
2504
|
+
"hevc_videotoolbox",
|
|
2505
|
+
"-q:v",
|
|
2506
|
+
String(params.vtQuality),
|
|
2507
|
+
"-pix_fmt",
|
|
2508
|
+
"yuv420p",
|
|
2509
|
+
"-tag:v",
|
|
2510
|
+
"hvc1"
|
|
2511
|
+
] : encoder === "h264_videotoolbox" ? [
|
|
2512
|
+
"-c:v",
|
|
2513
|
+
"h264_videotoolbox",
|
|
2514
|
+
"-q:v",
|
|
2515
|
+
String(params.vtQuality),
|
|
2516
|
+
"-pix_fmt",
|
|
2517
|
+
"yuv420p"
|
|
2518
|
+
] : [
|
|
2519
|
+
"-c:v",
|
|
2520
|
+
"libx264",
|
|
2521
|
+
"-crf",
|
|
2522
|
+
String(params.crf),
|
|
2523
|
+
"-preset",
|
|
2524
|
+
"medium",
|
|
2525
|
+
"-tune",
|
|
2526
|
+
"stillimage",
|
|
2527
|
+
"-profile:v",
|
|
2528
|
+
"high",
|
|
2529
|
+
"-level",
|
|
2530
|
+
"4.1",
|
|
2531
|
+
"-pix_fmt",
|
|
2532
|
+
"yuv420p"
|
|
2533
|
+
];
|
|
1760
2534
|
return new Promise((resolve2, reject) => {
|
|
1761
|
-
const
|
|
2535
|
+
const ffmpeg = spawn(
|
|
2536
|
+
"ffmpeg",
|
|
2537
|
+
[
|
|
2538
|
+
"-y",
|
|
2539
|
+
"-f",
|
|
2540
|
+
"rawvideo",
|
|
2541
|
+
"-pixel_format",
|
|
2542
|
+
"rgb24",
|
|
2543
|
+
"-video_size",
|
|
2544
|
+
`${config.width}x${config.height}`,
|
|
2545
|
+
"-framerate",
|
|
2546
|
+
String(config.fps),
|
|
2547
|
+
"-i",
|
|
2548
|
+
"pipe:0",
|
|
2549
|
+
"-f",
|
|
2550
|
+
"lavfi",
|
|
2551
|
+
"-i",
|
|
2552
|
+
"anullsrc=r=48000:cl=stereo",
|
|
2553
|
+
...videoArgs,
|
|
2554
|
+
"-c:a",
|
|
2555
|
+
"aac",
|
|
2556
|
+
"-b:a",
|
|
2557
|
+
"128k",
|
|
2558
|
+
"-shortest",
|
|
2559
|
+
"-movflags",
|
|
2560
|
+
"+faststart",
|
|
2561
|
+
outputPath
|
|
2562
|
+
],
|
|
2563
|
+
{ stdio: ["pipe", "ignore", "pipe"] }
|
|
2564
|
+
);
|
|
1762
2565
|
let stderr = "";
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
});
|
|
1766
|
-
proc.on("close", (code) => {
|
|
2566
|
+
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
2567
|
+
ffmpeg.on("close", (code) => {
|
|
1767
2568
|
if (code === 0) {
|
|
1768
2569
|
resolve2();
|
|
1769
2570
|
} else {
|
|
@@ -1775,7 +2576,7 @@ function runFfmpeg(args) {
|
|
|
1775
2576
|
);
|
|
1776
2577
|
}
|
|
1777
2578
|
});
|
|
1778
|
-
|
|
2579
|
+
ffmpeg.on("error", (err) => {
|
|
1779
2580
|
if (err.code === "ENOENT") {
|
|
1780
2581
|
reject(
|
|
1781
2582
|
new Error(
|
|
@@ -1786,6 +2587,16 @@ function runFfmpeg(args) {
|
|
|
1786
2587
|
reject(err);
|
|
1787
2588
|
}
|
|
1788
2589
|
});
|
|
2590
|
+
(async () => {
|
|
2591
|
+
for await (const frame of frames) {
|
|
2592
|
+
const src = frame.rawInfo ? sharp9(frame.buffer, { raw: { width: frame.rawInfo.width, height: frame.rawInfo.height, channels: frame.rawInfo.channels } }) : sharp9(frame.buffer);
|
|
2593
|
+
const raw = await src.flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
|
|
2594
|
+
if (!ffmpeg.stdin.write(raw)) {
|
|
2595
|
+
await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
ffmpeg.stdin.end();
|
|
2599
|
+
})().catch(reject);
|
|
1789
2600
|
});
|
|
1790
2601
|
}
|
|
1791
2602
|
async function savePngSequence(frames, config) {
|
|
@@ -1807,6 +2618,76 @@ async function savePngSequence(frames, config) {
|
|
|
1807
2618
|
return paths;
|
|
1808
2619
|
}
|
|
1809
2620
|
|
|
2621
|
+
// src/compose/streaming-session.ts
|
|
2622
|
+
import { EventEmitter } from "events";
|
|
2623
|
+
var ConcurrentSession = class extends EventEmitter {
|
|
2624
|
+
constructor(recorder, scenario, renderer) {
|
|
2625
|
+
super();
|
|
2626
|
+
this.recorder = recorder;
|
|
2627
|
+
this.scenario = scenario;
|
|
2628
|
+
this.renderer = renderer;
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Start recording and compositing concurrently.
|
|
2632
|
+
* Returns when both recording and encoding are complete.
|
|
2633
|
+
*/
|
|
2634
|
+
async run() {
|
|
2635
|
+
const handle = this.recorder.recordToChannel(this.scenario);
|
|
2636
|
+
let composed = 0;
|
|
2637
|
+
const self = this;
|
|
2638
|
+
const buffer = await encodeMp4Stream(
|
|
2639
|
+
(async function* () {
|
|
2640
|
+
for await (const frame of self.renderer.composeStreamOnline(handle.frameStream)) {
|
|
2641
|
+
composed++;
|
|
2642
|
+
self.emit("progress", { composed, total: -1, pct: -1 });
|
|
2643
|
+
yield frame;
|
|
2644
|
+
}
|
|
2645
|
+
})(),
|
|
2646
|
+
this.scenario.output
|
|
2647
|
+
);
|
|
2648
|
+
const session = await handle.done;
|
|
2649
|
+
this.emit("progress", { composed, total: composed, pct: 100 });
|
|
2650
|
+
return { buffer, session };
|
|
2651
|
+
}
|
|
2652
|
+
};
|
|
2653
|
+
var StreamingSession = class extends EventEmitter {
|
|
2654
|
+
constructor(session, renderer) {
|
|
2655
|
+
super();
|
|
2656
|
+
this.session = session;
|
|
2657
|
+
this.renderer = renderer;
|
|
2658
|
+
}
|
|
2659
|
+
/** Total frames in the underlying recording session. */
|
|
2660
|
+
get totalFrames() {
|
|
2661
|
+
return this.session.frames.length;
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Run the compose → encode pipeline.
|
|
2665
|
+
*
|
|
2666
|
+
* Composes frames via the worker pool (Phase 1-B streaming, ordered yield),
|
|
2667
|
+
* forwarding each to FFmpeg as it completes. Emits a 'progress' event after
|
|
2668
|
+
* every composed frame so callers can update a spinner or progress bar.
|
|
2669
|
+
*
|
|
2670
|
+
* @returns The fully-encoded MP4 as a Buffer.
|
|
2671
|
+
*/
|
|
2672
|
+
async run() {
|
|
2673
|
+
const { frames, scenario } = this.session;
|
|
2674
|
+
const total = frames.length;
|
|
2675
|
+
let composed = 0;
|
|
2676
|
+
const self = this;
|
|
2677
|
+
return encodeMp4Stream(
|
|
2678
|
+
(async function* () {
|
|
2679
|
+
for await (const frame of self.renderer.composeStream(frames)) {
|
|
2680
|
+
composed++;
|
|
2681
|
+
const pct = total > 0 ? Math.round(composed / total * 100) : 100;
|
|
2682
|
+
self.emit("progress", { composed, total, pct });
|
|
2683
|
+
yield frame;
|
|
2684
|
+
}
|
|
2685
|
+
})(),
|
|
2686
|
+
scenario.output
|
|
2687
|
+
);
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
|
|
1810
2691
|
// src/cli/index.ts
|
|
1811
2692
|
import { writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
|
|
1812
2693
|
import { join as join2, resolve, dirname } from "path";
|
|
@@ -1871,72 +2752,91 @@ program.command("record").description("Record a demo from a YAML scenario file")
|
|
|
1871
2752
|
process.exit(1);
|
|
1872
2753
|
}
|
|
1873
2754
|
}
|
|
1874
|
-
|
|
1875
|
-
`Recording ${scenario.steps.length} steps...`
|
|
1876
|
-
);
|
|
2755
|
+
await mkdir2(options.output, { recursive: true });
|
|
1877
2756
|
const recorder = new ClipwiseRecorder();
|
|
1878
|
-
const
|
|
1879
|
-
|
|
1880
|
-
|
|
2757
|
+
const renderer = new CanvasRenderer(
|
|
2758
|
+
scenario.effects,
|
|
2759
|
+
scenario.output,
|
|
2760
|
+
scenario.steps
|
|
1881
2761
|
);
|
|
1882
|
-
|
|
1883
|
-
if (
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
);
|
|
1890
|
-
|
|
1891
|
-
spinner.succeed("Effects applied");
|
|
1892
|
-
} else {
|
|
1893
|
-
composedFrames = session.frames.map((f) => ({
|
|
1894
|
-
index: f.index,
|
|
1895
|
-
buffer: f.screenshot,
|
|
1896
|
-
timestamp: f.timestamp
|
|
1897
|
-
}));
|
|
1898
|
-
spinner.info("Effects disabled, using raw frames");
|
|
1899
|
-
}
|
|
1900
|
-
await mkdir2(options.output, { recursive: true });
|
|
1901
|
-
if (scenario.output.format === "png-sequence") {
|
|
1902
|
-
spinner.start("Saving PNG sequence...");
|
|
1903
|
-
const paths = await savePngSequence(
|
|
1904
|
-
composedFrames,
|
|
1905
|
-
scenario.output
|
|
1906
|
-
);
|
|
1907
|
-
spinner.succeed(
|
|
1908
|
-
`Saved ${paths.length} frames to ${chalk.bold(options.output)}`
|
|
1909
|
-
);
|
|
1910
|
-
} else if (scenario.output.format === "mp4") {
|
|
1911
|
-
spinner.start("Encoding MP4...");
|
|
1912
|
-
const mp4Buffer = await encodeMp4(
|
|
1913
|
-
composedFrames,
|
|
1914
|
-
scenario.output
|
|
1915
|
-
);
|
|
1916
|
-
const outputPath = join2(
|
|
1917
|
-
options.output,
|
|
1918
|
-
`${scenario.output.filename}.mp4`
|
|
1919
|
-
);
|
|
2762
|
+
const isConcurrentEligible = scenario.output.format === "mp4" && options.effects !== false && renderer.canStreamOnline();
|
|
2763
|
+
if (isConcurrentEligible) {
|
|
2764
|
+
const pipeline = new ConcurrentSession(recorder, scenario, renderer);
|
|
2765
|
+
pipeline.on("progress", ({ composed, total, pct }) => {
|
|
2766
|
+
spinner.text = total > 0 ? `Recording & composing... ${composed}/${total} (${pct}%)` : `Recording & composing... ${composed} frames`;
|
|
2767
|
+
});
|
|
2768
|
+
spinner.start(`Recording & composing ${scenario.steps.length} steps concurrently...`);
|
|
2769
|
+
const { buffer: mp4Buffer, session } = await pipeline.run();
|
|
2770
|
+
const outputPath = join2(options.output, `${scenario.output.filename}.mp4`);
|
|
1920
2771
|
await writeFile2(outputPath, mp4Buffer);
|
|
1921
2772
|
const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
|
|
1922
2773
|
spinner.succeed(
|
|
1923
|
-
`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`
|
|
2774
|
+
`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB, ${session.frames.length} frames)`
|
|
1924
2775
|
);
|
|
1925
2776
|
} else {
|
|
1926
|
-
spinner.start(
|
|
1927
|
-
const
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
2777
|
+
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
2778
|
+
const session = await recorder.record(scenario);
|
|
2779
|
+
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
2780
|
+
if (scenario.output.format === "png-sequence") {
|
|
2781
|
+
let composedFrames;
|
|
2782
|
+
if (options.effects !== false) {
|
|
2783
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2784
|
+
composedFrames = await renderer.composeAll(session.frames);
|
|
2785
|
+
spinner.succeed("Effects applied");
|
|
2786
|
+
} else {
|
|
2787
|
+
composedFrames = session.frames.map((f) => ({
|
|
2788
|
+
index: f.index,
|
|
2789
|
+
buffer: f.screenshot,
|
|
2790
|
+
timestamp: f.timestamp
|
|
2791
|
+
}));
|
|
2792
|
+
spinner.info("Effects disabled, using raw frames");
|
|
2793
|
+
}
|
|
2794
|
+
spinner.start("Saving PNG sequence...");
|
|
2795
|
+
const paths = await savePngSequence(composedFrames, scenario.output);
|
|
2796
|
+
spinner.succeed(`Saved ${paths.length} frames to ${chalk.bold(options.output)}`);
|
|
2797
|
+
} else if (scenario.output.format === "mp4") {
|
|
2798
|
+
const outputPath = join2(options.output, `${scenario.output.filename}.mp4`);
|
|
2799
|
+
let mp4Buffer;
|
|
2800
|
+
if (options.effects === false) {
|
|
2801
|
+
spinner.start(`Encoding ${session.frames.length} raw frames...`);
|
|
2802
|
+
const rawStream = (async function* () {
|
|
2803
|
+
for (const f of session.frames) {
|
|
2804
|
+
yield { index: f.index, buffer: f.screenshot, timestamp: f.timestamp };
|
|
2805
|
+
}
|
|
2806
|
+
})();
|
|
2807
|
+
mp4Buffer = await encodeMp4Stream(rawStream, scenario.output);
|
|
2808
|
+
} else {
|
|
2809
|
+
const pipeline = new StreamingSession(session, renderer);
|
|
2810
|
+
pipeline.on("progress", ({ composed, total, pct }) => {
|
|
2811
|
+
spinner.text = `Composing & encoding... ${composed}/${total} (${pct}%)`;
|
|
2812
|
+
});
|
|
2813
|
+
spinner.start(`Composing & encoding ${session.frames.length} frames...`);
|
|
2814
|
+
mp4Buffer = await pipeline.run();
|
|
2815
|
+
}
|
|
2816
|
+
await writeFile2(outputPath, mp4Buffer);
|
|
2817
|
+
const sizeMB = (mp4Buffer.length / (1024 * 1024)).toFixed(2);
|
|
2818
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`);
|
|
2819
|
+
} else {
|
|
2820
|
+
let composedFrames;
|
|
2821
|
+
if (options.effects !== false) {
|
|
2822
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2823
|
+
composedFrames = await renderer.composeAll(session.frames);
|
|
2824
|
+
spinner.succeed("Effects applied");
|
|
2825
|
+
} else {
|
|
2826
|
+
composedFrames = session.frames.map((f) => ({
|
|
2827
|
+
index: f.index,
|
|
2828
|
+
buffer: f.screenshot,
|
|
2829
|
+
timestamp: f.timestamp
|
|
2830
|
+
}));
|
|
2831
|
+
spinner.info("Effects disabled, using raw frames");
|
|
2832
|
+
}
|
|
2833
|
+
spinner.start("Encoding GIF...");
|
|
2834
|
+
const gifBuffer = await encodeGif(composedFrames, scenario.output);
|
|
2835
|
+
const outputPath = join2(options.output, `${scenario.output.filename}.gif`);
|
|
2836
|
+
await writeFile2(outputPath, gifBuffer);
|
|
2837
|
+
const sizeMB = (gifBuffer.length / (1024 * 1024)).toFixed(2);
|
|
2838
|
+
spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${sizeMB} MB)`);
|
|
2839
|
+
}
|
|
1940
2840
|
}
|
|
1941
2841
|
console.log(chalk.green("\nDone! \u{1F3AC}"));
|
|
1942
2842
|
} catch (error) {
|
|
@@ -2016,7 +2916,7 @@ effects:
|
|
|
2016
2916
|
output:
|
|
2017
2917
|
format: mp4
|
|
2018
2918
|
fps: 30
|
|
2019
|
-
|
|
2919
|
+
preset: balanced # social | balanced | archive
|
|
2020
2920
|
|
|
2021
2921
|
steps:
|
|
2022
2922
|
- name: "Open app"
|
|
@@ -2194,7 +3094,7 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
|
|
|
2194
3094
|
width: outWidth,
|
|
2195
3095
|
height: outHeight,
|
|
2196
3096
|
fps: 30,
|
|
2197
|
-
|
|
3097
|
+
preset: "social",
|
|
2198
3098
|
outputDir: options.output,
|
|
2199
3099
|
filename: `clipwise-demo-${device}`
|
|
2200
3100
|
},
|
|
@@ -2219,27 +3119,44 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
|
|
|
2219
3119
|
process.exit(1);
|
|
2220
3120
|
}
|
|
2221
3121
|
}
|
|
2222
|
-
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
2223
|
-
const recorder = new ClipwiseRecorder();
|
|
2224
|
-
const session = await recorder.record(scenario);
|
|
2225
|
-
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
2226
|
-
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
2227
|
-
const renderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
|
|
2228
|
-
const composedFrames = await renderer.composeAll(session.frames);
|
|
2229
|
-
spinner.succeed("Effects applied");
|
|
2230
3122
|
await mkdir2(options.output, { recursive: true });
|
|
3123
|
+
const demoRenderer = new CanvasRenderer(scenario.effects, scenario.output, scenario.steps);
|
|
2231
3124
|
const ext = scenario.output.format === "gif" ? "gif" : "mp4";
|
|
2232
3125
|
const outputPath = join2(options.output, `clipwise-demo-${device}.${ext}`);
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
const
|
|
3126
|
+
const isConcurrentEligible = ext === "mp4" && demoRenderer.canStreamOnline();
|
|
3127
|
+
if (isConcurrentEligible) {
|
|
3128
|
+
const recorder = new ClipwiseRecorder();
|
|
3129
|
+
const concPipeline = new ConcurrentSession(recorder, scenario, demoRenderer);
|
|
3130
|
+
concPipeline.on("progress", ({ composed, total, pct }) => {
|
|
3131
|
+
spinner.text = total > 0 ? `Recording & composing... ${composed}/${total} (${pct}%)` : `Recording & composing... ${composed} frames`;
|
|
3132
|
+
});
|
|
3133
|
+
spinner.start(`Recording & composing ${scenario.steps.length} steps concurrently...`);
|
|
3134
|
+
const { buffer: buf, session } = await concPipeline.run();
|
|
2236
3135
|
await writeFile2(outputPath, buf);
|
|
2237
|
-
spinner.succeed(`
|
|
3136
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB, ${session.frames.length} frames)`);
|
|
2238
3137
|
} else {
|
|
2239
|
-
spinner.start(
|
|
2240
|
-
const
|
|
2241
|
-
await
|
|
2242
|
-
spinner.succeed(`
|
|
3138
|
+
spinner.start(`Recording ${scenario.steps.length} steps...`);
|
|
3139
|
+
const recorder = new ClipwiseRecorder();
|
|
3140
|
+
const session = await recorder.record(scenario);
|
|
3141
|
+
spinner.succeed(`Recorded ${session.frames.length} frames`);
|
|
3142
|
+
if (ext === "gif") {
|
|
3143
|
+
spinner.start(`Applying effects to ${session.frames.length} frames...`);
|
|
3144
|
+
const composedFrames = await demoRenderer.composeAll(session.frames);
|
|
3145
|
+
spinner.succeed("Effects applied");
|
|
3146
|
+
spinner.start("Encoding GIF...");
|
|
3147
|
+
const buf = await encodeGif(composedFrames, scenario.output);
|
|
3148
|
+
await writeFile2(outputPath, buf);
|
|
3149
|
+
spinner.succeed(`GIF saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
3150
|
+
} else {
|
|
3151
|
+
const pipeline = new StreamingSession(session, demoRenderer);
|
|
3152
|
+
pipeline.on("progress", ({ composed, total, pct }) => {
|
|
3153
|
+
spinner.text = `Composing & encoding... ${composed}/${total} (${pct}%)`;
|
|
3154
|
+
});
|
|
3155
|
+
spinner.start(`Composing & encoding ${session.frames.length} frames...`);
|
|
3156
|
+
const buf = await pipeline.run();
|
|
3157
|
+
await writeFile2(outputPath, buf);
|
|
3158
|
+
spinner.succeed(`MP4 saved to ${chalk.bold(outputPath)} (${(buf.length / 1048576).toFixed(2)} MB)`);
|
|
3159
|
+
}
|
|
2243
3160
|
}
|
|
2244
3161
|
console.log(chalk.green("\nDemo complete! \u{1F3AC}"));
|
|
2245
3162
|
} catch (error) {
|