clipwise 0.2.1 → 0.3.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 +33 -1
- package/README.md +30 -11
- package/dist/cli/index.js +526 -334
- package/dist/compose/frame-worker.js +649 -0
- package/dist/index.d.ts +41 -23
- package/dist/index.js +524 -332
- package/package.json +1 -1
- package/dist/cli/index.d.ts +0 -1
package/dist/index.js
CHANGED
|
@@ -52,15 +52,15 @@ async function getElementCenter(page, selector, timeout) {
|
|
|
52
52
|
|
|
53
53
|
// src/core/recorder.ts
|
|
54
54
|
var CLICK_EFFECT_DURATION_MS = 500;
|
|
55
|
-
var REPAINT_INTERVAL_MS =
|
|
55
|
+
var REPAINT_INTERVAL_MS = 25;
|
|
56
56
|
var ACTION_GAP_MS = 30;
|
|
57
57
|
var CURSOR_SPEED_PRESETS = {
|
|
58
|
-
fast: { steps:
|
|
59
|
-
// ~
|
|
60
|
-
normal: { steps:
|
|
61
|
-
// ~
|
|
62
|
-
slow: { steps:
|
|
63
|
-
// ~
|
|
58
|
+
fast: { steps: 10, delay: 22 },
|
|
59
|
+
// ~220ms, ~9 frames captured
|
|
60
|
+
normal: { steps: 14, delay: 25 },
|
|
61
|
+
// ~350ms, ~14 frames captured
|
|
62
|
+
slow: { steps: 20, delay: 25 }
|
|
63
|
+
// ~500ms, ~20 frames captured
|
|
64
64
|
};
|
|
65
65
|
var ClipwiseRecorder = class {
|
|
66
66
|
browser = null;
|
|
@@ -74,6 +74,7 @@ var ClipwiseRecorder = class {
|
|
|
74
74
|
currentStepIndex = 0;
|
|
75
75
|
cursorPosition = { x: 0, y: 0 };
|
|
76
76
|
viewport = { width: 1280, height: 800 };
|
|
77
|
+
deviceScaleFactor = 1;
|
|
77
78
|
isCapturing = false;
|
|
78
79
|
targetFps = 30;
|
|
79
80
|
cursorSpeed = "fast";
|
|
@@ -127,10 +128,9 @@ var ClipwiseRecorder = class {
|
|
|
127
128
|
}
|
|
128
129
|
);
|
|
129
130
|
await this.cdpClient.send("Page.startScreencast", {
|
|
130
|
-
format: "
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
maxHeight: this.viewport.height,
|
|
131
|
+
format: "png",
|
|
132
|
+
maxWidth: this.viewport.width * this.deviceScaleFactor,
|
|
133
|
+
maxHeight: this.viewport.height * this.deviceScaleFactor,
|
|
134
134
|
everyNthFrame: 1
|
|
135
135
|
});
|
|
136
136
|
this.cursorTimeline.push({
|
|
@@ -444,6 +444,7 @@ var ClipwiseRecorder = class {
|
|
|
444
444
|
clickPosition: clickEvent?.position ?? null,
|
|
445
445
|
clickProgress,
|
|
446
446
|
viewport: { ...this.viewport },
|
|
447
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
447
448
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
448
449
|
stepIndex: this.currentStepIndex
|
|
449
450
|
};
|
|
@@ -472,15 +473,9 @@ var ClipwiseRecorder = class {
|
|
|
472
473
|
for (let i = 0; i < targetFrameCount; i++) {
|
|
473
474
|
const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
|
|
474
475
|
const targetTimestamp = startTime + t * duration;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const dist = Math.abs(frames[j].timestamp - targetTimestamp);
|
|
479
|
-
if (dist < minDist) {
|
|
480
|
-
minDist = dist;
|
|
481
|
-
nearestIdx = j;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
476
|
+
const lo = this.binarySearchTimeline(frames, targetTimestamp);
|
|
477
|
+
const hi = Math.min(lo + 1, frames.length - 1);
|
|
478
|
+
const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
|
|
484
479
|
const cursorPos = this.interpolateCursorAt(targetTimestamp);
|
|
485
480
|
const clickEvent = this.clickTimeline.find(
|
|
486
481
|
(click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
@@ -501,6 +496,7 @@ var ClipwiseRecorder = class {
|
|
|
501
496
|
clickPosition: clickEvent?.position ?? null,
|
|
502
497
|
clickProgress,
|
|
503
498
|
viewport: { ...this.viewport },
|
|
499
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
504
500
|
stepName: frames[nearestIdx].stepName,
|
|
505
501
|
stepIndex: frames[nearestIdx].stepIndex,
|
|
506
502
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
@@ -516,15 +512,9 @@ var ClipwiseRecorder = class {
|
|
|
516
512
|
if (this.cursorTimeline.length === 1) {
|
|
517
513
|
return { ...this.cursorTimeline[0].position };
|
|
518
514
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
|
|
523
|
-
before = this.cursorTimeline[i];
|
|
524
|
-
after = this.cursorTimeline[i + 1];
|
|
525
|
-
break;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
515
|
+
const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
|
|
516
|
+
const before = this.cursorTimeline[idx];
|
|
517
|
+
const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
|
|
528
518
|
if (timestamp <= before.timestamp) return { ...before.position };
|
|
529
519
|
if (timestamp >= after.timestamp) return { ...after.position };
|
|
530
520
|
const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
|
|
@@ -537,6 +527,23 @@ var ClipwiseRecorder = class {
|
|
|
537
527
|
)
|
|
538
528
|
};
|
|
539
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Binary search: returns the index of the last entry whose timestamp <= target.
|
|
532
|
+
* Assumes the array is sorted by timestamp in ascending order.
|
|
533
|
+
*/
|
|
534
|
+
binarySearchTimeline(timeline, target) {
|
|
535
|
+
let lo = 0;
|
|
536
|
+
let hi = timeline.length - 1;
|
|
537
|
+
while (lo < hi) {
|
|
538
|
+
const mid = lo + hi + 1 >> 1;
|
|
539
|
+
if (timeline[mid].timestamp <= target) {
|
|
540
|
+
lo = mid;
|
|
541
|
+
} else {
|
|
542
|
+
hi = mid - 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return lo;
|
|
546
|
+
}
|
|
540
547
|
/**
|
|
541
548
|
* Clean up browser resources. Always called after recording.
|
|
542
549
|
*/
|
|
@@ -561,7 +568,13 @@ var ClipwiseRecorder = class {
|
|
|
561
568
|
};
|
|
562
569
|
|
|
563
570
|
// src/compose/canvas-renderer.ts
|
|
564
|
-
import
|
|
571
|
+
import { Worker } from "worker_threads";
|
|
572
|
+
import os from "os";
|
|
573
|
+
import { existsSync } from "fs";
|
|
574
|
+
import { fileURLToPath } from "url";
|
|
575
|
+
|
|
576
|
+
// src/compose/compose-frame.ts
|
|
577
|
+
import sharp7 from "sharp";
|
|
565
578
|
|
|
566
579
|
// src/effects/frame.ts
|
|
567
580
|
import sharp from "sharp";
|
|
@@ -584,91 +597,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
|
584
597
|
var ANDROID_OUTER_RADIUS = 35;
|
|
585
598
|
var ANDROID_INNER_RADIUS = 30;
|
|
586
599
|
var ANDROID_CAMERA_RADIUS = 6;
|
|
587
|
-
function buildBrowserChromeSvg(width, darkMode) {
|
|
600
|
+
function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
|
|
588
601
|
const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
|
|
589
602
|
const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
|
|
590
603
|
const addressBorder = darkMode ? "#444444" : "#d0d0d0";
|
|
591
604
|
const textColor = darkMode ? "#999999" : "#666666";
|
|
605
|
+
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
606
|
+
const tlY = TRAFFIC_LIGHT_Y * dpr;
|
|
607
|
+
const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
|
|
608
|
+
const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
|
|
609
|
+
const tlGap = TRAFFIC_LIGHT_GAP * dpr;
|
|
610
|
+
const aBarH = ADDRESS_BAR_HEIGHT * dpr;
|
|
611
|
+
const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
|
|
612
|
+
const fontSize = 12 * dpr;
|
|
592
613
|
const trafficLights = [
|
|
593
|
-
{ cx:
|
|
594
|
-
{ cx:
|
|
595
|
-
{ cx:
|
|
614
|
+
{ cx: tlStartX, fill: "#ff5f57" },
|
|
615
|
+
{ cx: tlStartX + tlGap, fill: "#febc2e" },
|
|
616
|
+
{ cx: tlStartX + tlGap * 2, fill: "#28c840" }
|
|
596
617
|
].map(
|
|
597
|
-
(light) => `<circle cx="${light.cx}" cy="${
|
|
618
|
+
(light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
|
|
598
619
|
).join("\n ");
|
|
599
|
-
const addressBarWidth = width -
|
|
600
|
-
const addressBarX =
|
|
601
|
-
const addressBarY = (
|
|
602
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${
|
|
603
|
-
<rect width="${width}" height="${
|
|
620
|
+
const addressBarWidth = width - aBarMargin * 2;
|
|
621
|
+
const addressBarX = aBarMargin;
|
|
622
|
+
const addressBarY = (tbarH - aBarH) / 2;
|
|
623
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
|
|
624
|
+
<rect width="${width}" height="${tbarH}" fill="${bg}"/>
|
|
604
625
|
${trafficLights}
|
|
605
|
-
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${
|
|
606
|
-
rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="
|
|
607
|
-
<text x="${width / 2}" y="${
|
|
608
|
-
font-family="system-ui, -apple-system, sans-serif" font-size="
|
|
626
|
+
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
|
|
627
|
+
rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
|
|
628
|
+
<text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
|
|
629
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
|
|
609
630
|
localhost
|
|
610
631
|
</text>
|
|
611
632
|
</svg>`;
|
|
612
633
|
}
|
|
613
|
-
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
634
|
+
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
614
635
|
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
615
636
|
const islandColor = darkMode ? "#000000" : "#1a1a1a";
|
|
616
637
|
const homeBarColor = darkMode ? "#555555" : "#333333";
|
|
617
|
-
const
|
|
618
|
-
const
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
const
|
|
622
|
-
const
|
|
638
|
+
const bezelTop = IPHONE_BEZEL.top * dpr;
|
|
639
|
+
const bezelBottom = IPHONE_BEZEL.bottom * dpr;
|
|
640
|
+
const bezelSides = IPHONE_BEZEL.sides * dpr;
|
|
641
|
+
const outerRadius = IPHONE_OUTER_RADIUS * dpr;
|
|
642
|
+
const innerRadius = IPHONE_INNER_RADIUS * dpr;
|
|
643
|
+
const islandW = IPHONE_ISLAND.width * dpr;
|
|
644
|
+
const islandH = IPHONE_ISLAND.height * dpr;
|
|
645
|
+
const homeBarW = IPHONE_HOME_BAR.width * dpr;
|
|
646
|
+
const homeBarH = IPHONE_HOME_BAR.height * dpr;
|
|
647
|
+
const islandX = (totalWidth - islandW) / 2;
|
|
648
|
+
const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
|
|
649
|
+
const homeBarX = (totalWidth - homeBarW) / 2;
|
|
650
|
+
const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
|
|
651
|
+
const screenX = bezelSides;
|
|
652
|
+
const screenY = bezelTop;
|
|
623
653
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
624
654
|
<!-- Device body -->
|
|
625
655
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
626
|
-
rx="${
|
|
656
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
627
657
|
<!-- Screen cutout (transparent) -->
|
|
628
658
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
629
|
-
rx="${
|
|
659
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
630
660
|
<!-- Dynamic Island pill -->
|
|
631
|
-
<rect x="${islandX}" y="${islandY}" width="${
|
|
632
|
-
rx="${
|
|
661
|
+
<rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
|
|
662
|
+
rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
|
|
633
663
|
<!-- Home indicator bar -->
|
|
634
|
-
<rect x="${homeBarX}" y="${homeBarY}" width="${
|
|
635
|
-
rx="${
|
|
664
|
+
<rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
|
|
665
|
+
rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
|
|
636
666
|
</svg>`;
|
|
637
667
|
}
|
|
638
|
-
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
668
|
+
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
639
669
|
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
640
670
|
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
641
|
-
const screenX = IPAD_BEZEL.sides;
|
|
642
|
-
const screenY = IPAD_BEZEL.top;
|
|
671
|
+
const screenX = IPAD_BEZEL.sides * dpr;
|
|
672
|
+
const screenY = IPAD_BEZEL.top * dpr;
|
|
643
673
|
const cameraCx = totalWidth / 2;
|
|
644
|
-
const cameraCy = IPAD_BEZEL.top / 2;
|
|
674
|
+
const cameraCy = IPAD_BEZEL.top * dpr / 2;
|
|
675
|
+
const outerRadius = IPAD_OUTER_RADIUS * dpr;
|
|
676
|
+
const innerRadius = IPAD_INNER_RADIUS * dpr;
|
|
645
677
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
646
678
|
<!-- Device body -->
|
|
647
679
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
648
|
-
rx="${
|
|
680
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
649
681
|
<!-- Screen cutout -->
|
|
650
682
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
651
|
-
rx="${
|
|
683
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
652
684
|
<!-- Front camera dot -->
|
|
653
|
-
<circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
|
|
685
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
|
|
654
686
|
</svg>`;
|
|
655
687
|
}
|
|
656
|
-
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
688
|
+
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
657
689
|
const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
|
|
658
690
|
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
659
|
-
const screenX = ANDROID_BEZEL.sides;
|
|
660
|
-
const screenY = ANDROID_BEZEL.top;
|
|
691
|
+
const screenX = ANDROID_BEZEL.sides * dpr;
|
|
692
|
+
const screenY = ANDROID_BEZEL.top * dpr;
|
|
661
693
|
const cameraCx = totalWidth / 2;
|
|
662
|
-
const cameraCy = ANDROID_BEZEL.top / 2;
|
|
694
|
+
const cameraCy = ANDROID_BEZEL.top * dpr / 2;
|
|
695
|
+
const outerRadius = ANDROID_OUTER_RADIUS * dpr;
|
|
696
|
+
const innerRadius = ANDROID_INNER_RADIUS * dpr;
|
|
697
|
+
const cameraR = ANDROID_CAMERA_RADIUS * dpr;
|
|
663
698
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
664
699
|
<!-- Device body -->
|
|
665
700
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
666
|
-
rx="${
|
|
701
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
667
702
|
<!-- Screen cutout -->
|
|
668
703
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
669
|
-
rx="${
|
|
704
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
670
705
|
<!-- Punch-hole camera -->
|
|
671
|
-
<circle cx="${cameraCx}" cy="${cameraCy}" r="${
|
|
706
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
|
|
672
707
|
</svg>`;
|
|
673
708
|
}
|
|
674
709
|
function buildScreenMaskSvg(width, height, radius) {
|
|
@@ -676,21 +711,33 @@ function buildScreenMaskSvg(width, height, radius) {
|
|
|
676
711
|
<rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
|
|
677
712
|
</svg>`;
|
|
678
713
|
}
|
|
679
|
-
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
|
|
714
|
+
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
|
|
680
715
|
let bezel;
|
|
681
716
|
let innerRadius;
|
|
682
717
|
switch (deviceType) {
|
|
683
718
|
case "iphone":
|
|
684
|
-
bezel =
|
|
685
|
-
|
|
719
|
+
bezel = {
|
|
720
|
+
sides: IPHONE_BEZEL.sides * dpr,
|
|
721
|
+
top: IPHONE_BEZEL.top * dpr,
|
|
722
|
+
bottom: IPHONE_BEZEL.bottom * dpr
|
|
723
|
+
};
|
|
724
|
+
innerRadius = IPHONE_INNER_RADIUS * dpr;
|
|
686
725
|
break;
|
|
687
726
|
case "ipad":
|
|
688
|
-
bezel =
|
|
689
|
-
|
|
727
|
+
bezel = {
|
|
728
|
+
sides: IPAD_BEZEL.sides * dpr,
|
|
729
|
+
top: IPAD_BEZEL.top * dpr,
|
|
730
|
+
bottom: IPAD_BEZEL.bottom * dpr
|
|
731
|
+
};
|
|
732
|
+
innerRadius = IPAD_INNER_RADIUS * dpr;
|
|
690
733
|
break;
|
|
691
734
|
case "android":
|
|
692
|
-
bezel =
|
|
693
|
-
|
|
735
|
+
bezel = {
|
|
736
|
+
sides: ANDROID_BEZEL.sides * dpr,
|
|
737
|
+
top: ANDROID_BEZEL.top * dpr,
|
|
738
|
+
bottom: ANDROID_BEZEL.bottom * dpr
|
|
739
|
+
};
|
|
740
|
+
innerRadius = ANDROID_INNER_RADIUS * dpr;
|
|
694
741
|
break;
|
|
695
742
|
}
|
|
696
743
|
const totalWidth = frameWidth + bezel.sides * 2;
|
|
@@ -698,13 +745,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
|
|
|
698
745
|
let frameSvg;
|
|
699
746
|
switch (deviceType) {
|
|
700
747
|
case "iphone":
|
|
701
|
-
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
748
|
+
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
702
749
|
break;
|
|
703
750
|
case "ipad":
|
|
704
|
-
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
751
|
+
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
705
752
|
break;
|
|
706
753
|
case "android":
|
|
707
|
-
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
754
|
+
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
708
755
|
break;
|
|
709
756
|
}
|
|
710
757
|
const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
|
|
@@ -727,12 +774,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
|
|
|
727
774
|
{ input: maskedScreen, left: bezel.sides, top: bezel.top }
|
|
728
775
|
]).png().toBuffer();
|
|
729
776
|
}
|
|
730
|
-
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
777
|
+
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
|
|
731
778
|
if (!config.enabled || config.type === "none") return frameBuffer;
|
|
732
779
|
switch (config.type) {
|
|
733
780
|
case "browser": {
|
|
734
|
-
const
|
|
735
|
-
const
|
|
781
|
+
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
782
|
+
const totalHeight = frameHeight + tbarH;
|
|
783
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
|
|
736
784
|
const chromeBuffer = Buffer.from(chromeSvg);
|
|
737
785
|
const canvas = await sharp({
|
|
738
786
|
create: {
|
|
@@ -744,13 +792,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
744
792
|
}).png().toBuffer();
|
|
745
793
|
return sharp(canvas).composite([
|
|
746
794
|
{ input: chromeBuffer, left: 0, top: 0 },
|
|
747
|
-
{ input: frameBuffer, left: 0, top:
|
|
795
|
+
{ input: frameBuffer, left: 0, top: tbarH }
|
|
748
796
|
]).png().toBuffer();
|
|
749
797
|
}
|
|
750
798
|
case "iphone":
|
|
751
799
|
case "ipad":
|
|
752
800
|
case "android":
|
|
753
|
-
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
|
|
801
|
+
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
|
|
754
802
|
default:
|
|
755
803
|
return frameBuffer;
|
|
756
804
|
}
|
|
@@ -760,7 +808,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
760
808
|
import sharp2 from "sharp";
|
|
761
809
|
function buildCursorSvg(size, color) {
|
|
762
810
|
const s = size;
|
|
763
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
|
|
811
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
|
|
764
812
|
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
765
813
|
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
766
814
|
</svg>`;
|
|
@@ -769,7 +817,7 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
769
817
|
const currentRadius = radius * progress;
|
|
770
818
|
const opacity = Math.max(0, 1 - progress);
|
|
771
819
|
const size = Math.ceil(radius * 2 + 4);
|
|
772
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
820
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
773
821
|
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
774
822
|
fill="none" stroke="${color}" stroke-width="2"
|
|
775
823
|
opacity="${opacity.toFixed(3)}"/>
|
|
@@ -777,47 +825,35 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
777
825
|
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
778
826
|
</svg>`;
|
|
779
827
|
}
|
|
780
|
-
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
828
|
+
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
781
829
|
if (!config.enabled) return frameBuffer;
|
|
782
|
-
const
|
|
830
|
+
const size = Math.round(config.size * dpr);
|
|
831
|
+
const cursorSvg = buildCursorSvg(size, config.color);
|
|
783
832
|
const cursorBuffer = Buffer.from(cursorSvg);
|
|
784
|
-
const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
|
|
785
|
-
const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
|
|
833
|
+
const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
|
|
834
|
+
const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
|
|
786
835
|
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
787
836
|
}
|
|
788
|
-
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
|
|
837
|
+
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
789
838
|
if (!config.enabled || !config.clickEffect) return frameBuffer;
|
|
839
|
+
const radius = config.clickRadius * dpr;
|
|
790
840
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
791
|
-
const rippleSvg = buildClickRippleSvg(
|
|
792
|
-
config.clickRadius,
|
|
793
|
-
config.clickColor,
|
|
794
|
-
clampedProgress
|
|
795
|
-
);
|
|
841
|
+
const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
|
|
796
842
|
const rippleBuffer = Buffer.from(rippleSvg);
|
|
797
|
-
const rippleSize = Math.ceil(
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
frameWidth - rippleSize
|
|
803
|
-
)
|
|
804
|
-
);
|
|
805
|
-
const top = Math.max(
|
|
806
|
-
0,
|
|
807
|
-
Math.min(
|
|
808
|
-
Math.round(position.y - rippleSize / 2),
|
|
809
|
-
frameHeight - rippleSize
|
|
810
|
-
)
|
|
811
|
-
);
|
|
843
|
+
const rippleSize = Math.ceil(radius * 2 + 4);
|
|
844
|
+
const px = Math.round(position.x * dpr);
|
|
845
|
+
const py = Math.round(position.y * dpr);
|
|
846
|
+
const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
|
|
847
|
+
const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
|
|
812
848
|
return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
|
|
813
849
|
}
|
|
814
|
-
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
850
|
+
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
815
851
|
if (!config.enabled || !config.highlight) return frameBuffer;
|
|
816
|
-
const r = config.highlightRadius;
|
|
852
|
+
const r = config.highlightRadius * dpr;
|
|
817
853
|
const size = Math.ceil(r * 2 + 4);
|
|
818
854
|
const cx = size / 2;
|
|
819
855
|
const cy = size / 2;
|
|
820
|
-
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
856
|
+
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
821
857
|
<defs>
|
|
822
858
|
<radialGradient id="glow">
|
|
823
859
|
<stop offset="0%" stop-color="${config.highlightColor}" />
|
|
@@ -827,27 +863,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
|
|
|
827
863
|
</defs>
|
|
828
864
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
|
|
829
865
|
</svg>`;
|
|
830
|
-
const
|
|
831
|
-
const
|
|
866
|
+
const px = Math.round(position.x * dpr);
|
|
867
|
+
const py = Math.round(position.y * dpr);
|
|
868
|
+
const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
|
|
869
|
+
const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
|
|
832
870
|
return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
|
|
833
871
|
}
|
|
834
|
-
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
|
|
872
|
+
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
|
|
835
873
|
if (!config.enabled || !config.trail || positions.length < 2) {
|
|
836
874
|
return frameBuffer;
|
|
837
875
|
}
|
|
838
876
|
const segments = [];
|
|
839
877
|
for (let i = 1; i < positions.length; i++) {
|
|
840
878
|
const opacity = i / positions.length * 0.6;
|
|
841
|
-
const strokeWidth = 1 + i / positions.length * 2;
|
|
879
|
+
const strokeWidth = (1 + i / positions.length * 2) * dpr;
|
|
842
880
|
const p1 = positions[i - 1];
|
|
843
881
|
const p2 = positions[i];
|
|
844
882
|
segments.push(
|
|
845
|
-
`<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
|
|
883
|
+
`<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
|
|
846
884
|
stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
|
|
847
885
|
stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
|
|
848
886
|
);
|
|
849
887
|
}
|
|
850
|
-
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
888
|
+
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
|
|
851
889
|
${segments.join("\n ")}
|
|
852
890
|
</svg>`;
|
|
853
891
|
return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
@@ -993,7 +1031,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
|
993
1031
|
|
|
994
1032
|
// src/effects/keystroke.ts
|
|
995
1033
|
import sharp5 from "sharp";
|
|
996
|
-
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
|
|
1034
|
+
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
|
|
997
1035
|
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
998
1036
|
const recentKeys = keystrokes.filter(
|
|
999
1037
|
(k) => frameTimestamp - k.timestamp < config.fadeAfter
|
|
@@ -1001,25 +1039,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1001
1039
|
if (recentKeys.length === 0) return frameBuffer;
|
|
1002
1040
|
const displayText = recentKeys.map((k) => k.key).join("");
|
|
1003
1041
|
if (displayText.length === 0) return frameBuffer;
|
|
1004
|
-
const
|
|
1042
|
+
const fontSize = config.fontSize * dpr;
|
|
1043
|
+
const padding = config.padding * dpr;
|
|
1044
|
+
const charWidth = fontSize * 0.62;
|
|
1005
1045
|
const textWidth = Math.ceil(displayText.length * charWidth);
|
|
1006
|
-
const hudPadH =
|
|
1007
|
-
const hudPadV =
|
|
1008
|
-
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
|
|
1009
|
-
const hudHeight = Math.ceil(
|
|
1046
|
+
const hudPadH = padding * 2;
|
|
1047
|
+
const hudPadV = padding * 1.5;
|
|
1048
|
+
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
|
|
1049
|
+
const hudHeight = Math.ceil(fontSize + hudPadV * 2);
|
|
1010
1050
|
const newest = recentKeys[recentKeys.length - 1];
|
|
1011
1051
|
const age = frameTimestamp - newest.timestamp;
|
|
1012
1052
|
const fadeStart = config.fadeAfter * 0.6;
|
|
1013
1053
|
const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
1014
1054
|
if (opacity <= 0) return frameBuffer;
|
|
1055
|
+
const margin = 30 * dpr;
|
|
1015
1056
|
let hudX;
|
|
1016
|
-
const hudY = frameHeight - hudHeight -
|
|
1057
|
+
const hudY = frameHeight - hudHeight - margin;
|
|
1017
1058
|
switch (config.position) {
|
|
1018
1059
|
case "bottom-left":
|
|
1019
|
-
hudX =
|
|
1060
|
+
hudX = margin;
|
|
1020
1061
|
break;
|
|
1021
1062
|
case "bottom-right":
|
|
1022
|
-
hudX = frameWidth - hudWidth -
|
|
1063
|
+
hudX = frameWidth - hudWidth - margin;
|
|
1023
1064
|
break;
|
|
1024
1065
|
case "bottom-center":
|
|
1025
1066
|
default:
|
|
@@ -1029,41 +1070,18 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1029
1070
|
const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
|
|
1030
1071
|
const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
|
|
1031
1072
|
const escaped = truncated.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1032
|
-
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1073
|
+
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1033
1074
|
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
1034
|
-
rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
1035
|
-
<text x="${hudX + hudPadH}" y="${hudY + hudPadV +
|
|
1036
|
-
font-family="monospace, Menlo, Consolas" font-size="${
|
|
1075
|
+
rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
1076
|
+
<text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
|
|
1077
|
+
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1037
1078
|
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
1038
1079
|
</svg>`;
|
|
1039
1080
|
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1040
1081
|
}
|
|
1041
1082
|
|
|
1042
|
-
// src/effects/transition.ts
|
|
1043
|
-
import sharp6 from "sharp";
|
|
1044
|
-
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
1045
|
-
const t = Math.max(0, Math.min(1, progress));
|
|
1046
|
-
if (t <= 0) return fromBuffer;
|
|
1047
|
-
if (t >= 1) return toBuffer;
|
|
1048
|
-
const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1049
|
-
const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1050
|
-
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1051
|
-
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1052
|
-
pixels[i] = Math.round(
|
|
1053
|
-
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1054
|
-
);
|
|
1055
|
-
}
|
|
1056
|
-
return sharp6(pixels, {
|
|
1057
|
-
raw: {
|
|
1058
|
-
width: fromRaw.info.width,
|
|
1059
|
-
height: fromRaw.info.height,
|
|
1060
|
-
channels: 4
|
|
1061
|
-
}
|
|
1062
|
-
}).png().toBuffer();
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
1083
|
// src/effects/watermark.ts
|
|
1066
|
-
import
|
|
1084
|
+
import sharp6 from "sharp";
|
|
1067
1085
|
async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
1068
1086
|
if (!config.enabled || !config.text) return frameBuffer;
|
|
1069
1087
|
const charWidth = config.fontSize * 0.62;
|
|
@@ -1091,31 +1109,168 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1091
1109
|
break;
|
|
1092
1110
|
}
|
|
1093
1111
|
const escaped = config.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1094
|
-
const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1112
|
+
const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1095
1113
|
<text x="${x}" y="${y}"
|
|
1096
1114
|
font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
|
|
1097
1115
|
font-weight="600" fill="${config.color}"
|
|
1098
1116
|
opacity="${config.opacity.toFixed(3)}">${escaped}</text>
|
|
1099
1117
|
</svg>`;
|
|
1100
|
-
return
|
|
1118
|
+
return sharp6(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1101
1119
|
}
|
|
1102
1120
|
|
|
1103
|
-
// src/compose/
|
|
1104
|
-
function getFrameOffset(config) {
|
|
1121
|
+
// src/compose/compose-frame.ts
|
|
1122
|
+
function getFrameOffset(config, dpr = 1) {
|
|
1105
1123
|
if (!config.enabled) return { left: 0, top: 0 };
|
|
1106
1124
|
switch (config.type) {
|
|
1107
1125
|
case "browser":
|
|
1108
|
-
return { left: 0, top: 40 };
|
|
1126
|
+
return { left: 0, top: 40 * dpr };
|
|
1109
1127
|
case "iphone":
|
|
1110
|
-
return { left: 12, top: 50 };
|
|
1128
|
+
return { left: 12 * dpr, top: 50 * dpr };
|
|
1111
1129
|
case "ipad":
|
|
1112
|
-
return { left: 20, top: 24 };
|
|
1130
|
+
return { left: 20 * dpr, top: 24 * dpr };
|
|
1113
1131
|
case "android":
|
|
1114
|
-
return { left: 8, top: 32 };
|
|
1132
|
+
return { left: 8 * dpr, top: 32 * dpr };
|
|
1115
1133
|
default:
|
|
1116
1134
|
return { left: 0, top: 0 };
|
|
1117
1135
|
}
|
|
1118
1136
|
}
|
|
1137
|
+
async function composeFrame(frame, effects, output, context) {
|
|
1138
|
+
let buffer = frame.screenshot;
|
|
1139
|
+
const meta = await sharp7(buffer).metadata();
|
|
1140
|
+
let width = meta.width ?? frame.viewport.width;
|
|
1141
|
+
let height = meta.height ?? frame.viewport.height;
|
|
1142
|
+
const dpr = Math.round(width / frame.viewport.width);
|
|
1143
|
+
const ctx = {
|
|
1144
|
+
zoomScale: context?.zoomScale ?? 1,
|
|
1145
|
+
clickProgress: context?.clickProgress ?? null,
|
|
1146
|
+
cursorTrail: context?.cursorTrail ?? []
|
|
1147
|
+
};
|
|
1148
|
+
if (effects.deviceFrame.enabled) {
|
|
1149
|
+
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1150
|
+
const meta2 = await sharp7(buffer).metadata();
|
|
1151
|
+
width = meta2.width ?? width;
|
|
1152
|
+
height = meta2.height ?? height;
|
|
1153
|
+
}
|
|
1154
|
+
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1155
|
+
buffer = await renderCursorHighlight(
|
|
1156
|
+
buffer,
|
|
1157
|
+
frame.cursorPosition,
|
|
1158
|
+
effects.cursor,
|
|
1159
|
+
width,
|
|
1160
|
+
height,
|
|
1161
|
+
dpr
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1165
|
+
buffer = await renderCursorTrail(
|
|
1166
|
+
buffer,
|
|
1167
|
+
ctx.cursorTrail,
|
|
1168
|
+
effects.cursor,
|
|
1169
|
+
width,
|
|
1170
|
+
height,
|
|
1171
|
+
dpr
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1175
|
+
buffer = await renderCursor(
|
|
1176
|
+
buffer,
|
|
1177
|
+
frame.cursorPosition,
|
|
1178
|
+
effects.cursor,
|
|
1179
|
+
width,
|
|
1180
|
+
height,
|
|
1181
|
+
dpr
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
|
|
1185
|
+
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1186
|
+
buffer = await renderClickEffect(
|
|
1187
|
+
buffer,
|
|
1188
|
+
frame.clickPosition,
|
|
1189
|
+
effects.cursor,
|
|
1190
|
+
progress,
|
|
1191
|
+
width,
|
|
1192
|
+
height,
|
|
1193
|
+
dpr
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
if (effects.keystroke.enabled && frame.keystrokes) {
|
|
1197
|
+
buffer = await renderKeystrokeHud(
|
|
1198
|
+
buffer,
|
|
1199
|
+
frame.keystrokes,
|
|
1200
|
+
frame.timestamp,
|
|
1201
|
+
effects.keystroke,
|
|
1202
|
+
width,
|
|
1203
|
+
height,
|
|
1204
|
+
dpr
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
const scale = ctx.zoomScale;
|
|
1208
|
+
if (effects.zoom.enabled && scale > 1) {
|
|
1209
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1210
|
+
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1211
|
+
const focusPoint = {
|
|
1212
|
+
x: rawFocus.x * dpr + offset.left,
|
|
1213
|
+
y: rawFocus.y * dpr + offset.top
|
|
1214
|
+
};
|
|
1215
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1216
|
+
}
|
|
1217
|
+
buffer = await applyBackground(buffer, effects.background, output.width, output.height);
|
|
1218
|
+
if (effects.watermark.enabled) {
|
|
1219
|
+
buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
|
|
1220
|
+
}
|
|
1221
|
+
buffer = await sharp7(buffer).resize(output.width, output.height, {
|
|
1222
|
+
fit: "fill",
|
|
1223
|
+
kernel: sharp7.kernel.lanczos3
|
|
1224
|
+
}).png().toBuffer();
|
|
1225
|
+
return { index: frame.index, buffer, timestamp: frame.timestamp };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// src/effects/transition.ts
|
|
1229
|
+
import sharp8 from "sharp";
|
|
1230
|
+
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
1231
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
1232
|
+
if (t <= 0) return fromBuffer;
|
|
1233
|
+
if (t >= 1) return toBuffer;
|
|
1234
|
+
const fromRaw = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1235
|
+
const toRaw = await sharp8(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1236
|
+
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1237
|
+
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1238
|
+
pixels[i] = Math.round(
|
|
1239
|
+
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
return sharp8(pixels, {
|
|
1243
|
+
raw: {
|
|
1244
|
+
width: fromRaw.info.width,
|
|
1245
|
+
height: fromRaw.info.height,
|
|
1246
|
+
channels: 4
|
|
1247
|
+
}
|
|
1248
|
+
}).png().toBuffer();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/compose/canvas-renderer.ts
|
|
1252
|
+
var MIN_FRAMES_PER_WORKER = 4;
|
|
1253
|
+
var cachedWorkerUrl = null;
|
|
1254
|
+
function getWorkerUrl() {
|
|
1255
|
+
if (cachedWorkerUrl) return cachedWorkerUrl;
|
|
1256
|
+
const base = import.meta.url;
|
|
1257
|
+
const candidates = [
|
|
1258
|
+
new URL("./frame-worker.js", base),
|
|
1259
|
+
// from dist/compose/
|
|
1260
|
+
new URL("../compose/frame-worker.js", base),
|
|
1261
|
+
// from dist/cli/
|
|
1262
|
+
new URL("./compose/frame-worker.js", base)
|
|
1263
|
+
// from dist/
|
|
1264
|
+
];
|
|
1265
|
+
for (const url of candidates) {
|
|
1266
|
+
if (existsSync(fileURLToPath(url))) {
|
|
1267
|
+
cachedWorkerUrl = url;
|
|
1268
|
+
return url;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
cachedWorkerUrl = candidates[1];
|
|
1272
|
+
return cachedWorkerUrl;
|
|
1273
|
+
}
|
|
1119
1274
|
var CanvasRenderer = class {
|
|
1120
1275
|
constructor(effects, output, steps) {
|
|
1121
1276
|
this.effects = effects;
|
|
@@ -1124,118 +1279,11 @@ var CanvasRenderer = class {
|
|
|
1124
1279
|
}
|
|
1125
1280
|
steps;
|
|
1126
1281
|
/**
|
|
1127
|
-
* Apply the full effects pipeline to a single
|
|
1128
|
-
*
|
|
1129
|
-
* Pipeline order:
|
|
1130
|
-
* 1. Device frame (browser chrome / mobile mockup)
|
|
1131
|
-
* 2. Cursor highlight (Screen Studio glow)
|
|
1132
|
-
* 3. Cursor trail
|
|
1133
|
-
* 4. Cursor rendering
|
|
1134
|
-
* 5. Click ripple effect (animated progress)
|
|
1135
|
-
* 6. Keystroke HUD
|
|
1136
|
-
* 7. Zoom (adaptive, cursor-following)
|
|
1137
|
-
* 8. Background (padding, gradient, rounded corners)
|
|
1138
|
-
* 9. Watermark overlay
|
|
1139
|
-
* 10. Final resize
|
|
1282
|
+
* Apply the full effects pipeline to a single frame.
|
|
1283
|
+
* Delegates to the standalone composeFrame function.
|
|
1140
1284
|
*/
|
|
1141
1285
|
async composeFrame(frame, context) {
|
|
1142
|
-
|
|
1143
|
-
let width = frame.viewport.width;
|
|
1144
|
-
let height = frame.viewport.height;
|
|
1145
|
-
const ctx = {
|
|
1146
|
-
zoomScale: context?.zoomScale ?? 1,
|
|
1147
|
-
clickProgress: context?.clickProgress ?? null,
|
|
1148
|
-
cursorTrail: context?.cursorTrail ?? []
|
|
1149
|
-
};
|
|
1150
|
-
if (this.effects.deviceFrame.enabled) {
|
|
1151
|
-
buffer = await applyDeviceFrame(
|
|
1152
|
-
buffer,
|
|
1153
|
-
this.effects.deviceFrame,
|
|
1154
|
-
width,
|
|
1155
|
-
height
|
|
1156
|
-
);
|
|
1157
|
-
const meta = await sharp8(buffer).metadata();
|
|
1158
|
-
width = meta.width ?? width;
|
|
1159
|
-
height = meta.height ?? height;
|
|
1160
|
-
}
|
|
1161
|
-
if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
|
|
1162
|
-
buffer = await renderCursorHighlight(
|
|
1163
|
-
buffer,
|
|
1164
|
-
frame.cursorPosition,
|
|
1165
|
-
this.effects.cursor,
|
|
1166
|
-
width,
|
|
1167
|
-
height
|
|
1168
|
-
);
|
|
1169
|
-
}
|
|
1170
|
-
if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1171
|
-
buffer = await renderCursorTrail(
|
|
1172
|
-
buffer,
|
|
1173
|
-
ctx.cursorTrail,
|
|
1174
|
-
this.effects.cursor,
|
|
1175
|
-
width,
|
|
1176
|
-
height
|
|
1177
|
-
);
|
|
1178
|
-
}
|
|
1179
|
-
if (this.effects.cursor.enabled && frame.cursorPosition) {
|
|
1180
|
-
buffer = await renderCursor(
|
|
1181
|
-
buffer,
|
|
1182
|
-
frame.cursorPosition,
|
|
1183
|
-
this.effects.cursor,
|
|
1184
|
-
width,
|
|
1185
|
-
height
|
|
1186
|
-
);
|
|
1187
|
-
}
|
|
1188
|
-
if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
|
|
1189
|
-
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1190
|
-
buffer = await renderClickEffect(
|
|
1191
|
-
buffer,
|
|
1192
|
-
frame.clickPosition,
|
|
1193
|
-
this.effects.cursor,
|
|
1194
|
-
progress,
|
|
1195
|
-
width,
|
|
1196
|
-
height
|
|
1197
|
-
);
|
|
1198
|
-
}
|
|
1199
|
-
if (this.effects.keystroke.enabled && frame.keystrokes) {
|
|
1200
|
-
buffer = await renderKeystrokeHud(
|
|
1201
|
-
buffer,
|
|
1202
|
-
frame.keystrokes,
|
|
1203
|
-
frame.timestamp,
|
|
1204
|
-
this.effects.keystroke,
|
|
1205
|
-
width,
|
|
1206
|
-
height
|
|
1207
|
-
);
|
|
1208
|
-
}
|
|
1209
|
-
const scale = ctx.zoomScale;
|
|
1210
|
-
if (this.effects.zoom.enabled && scale > 1) {
|
|
1211
|
-
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
|
|
1212
|
-
const offset = getFrameOffset(this.effects.deviceFrame);
|
|
1213
|
-
const focusPoint = {
|
|
1214
|
-
x: rawFocus.x + offset.left,
|
|
1215
|
-
y: rawFocus.y + offset.top
|
|
1216
|
-
};
|
|
1217
|
-
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1218
|
-
}
|
|
1219
|
-
buffer = await applyBackground(
|
|
1220
|
-
buffer,
|
|
1221
|
-
this.effects.background,
|
|
1222
|
-
this.output.width,
|
|
1223
|
-
this.output.height
|
|
1224
|
-
);
|
|
1225
|
-
if (this.effects.watermark.enabled) {
|
|
1226
|
-
buffer = await renderWatermark(
|
|
1227
|
-
buffer,
|
|
1228
|
-
this.effects.watermark,
|
|
1229
|
-
this.output.width,
|
|
1230
|
-
this.output.height
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
|
|
1234
|
-
return {
|
|
1235
|
-
index: frame.index,
|
|
1236
|
-
buffer,
|
|
1237
|
-
timestamp: frame.timestamp
|
|
1238
|
-
};
|
|
1286
|
+
return composeFrame(frame, this.effects, this.output, context);
|
|
1239
1287
|
}
|
|
1240
1288
|
/**
|
|
1241
1289
|
* Process an entire sequence of captured frames through the effects pipeline.
|
|
@@ -1243,7 +1291,7 @@ var CanvasRenderer = class {
|
|
|
1243
1291
|
* Multi-pass approach:
|
|
1244
1292
|
* Pass 1: Speed ramping (adjust frame set).
|
|
1245
1293
|
* Pass 2: Calculate per-frame contexts (zoom, click, trail).
|
|
1246
|
-
* Pass 3: Render
|
|
1294
|
+
* Pass 3: Render frames in parallel using worker threads.
|
|
1247
1295
|
* Pass 4: Apply scene transitions at step boundaries.
|
|
1248
1296
|
*/
|
|
1249
1297
|
async composeAll(frames) {
|
|
@@ -1253,10 +1301,19 @@ var CanvasRenderer = class {
|
|
|
1253
1301
|
processFrames = this.applySpeedRamp(frames);
|
|
1254
1302
|
}
|
|
1255
1303
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1304
|
+
const cpuCount = os.cpus().length;
|
|
1305
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
1306
|
+
const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
|
|
1307
|
+
let composed;
|
|
1308
|
+
if (useWorkers) {
|
|
1309
|
+
composed = await this.processWithWorkers(processFrames, contexts, workerCount);
|
|
1310
|
+
} else {
|
|
1311
|
+
composed = [];
|
|
1312
|
+
for (let i = 0; i < processFrames.length; i++) {
|
|
1313
|
+
composed.push(
|
|
1314
|
+
await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1260
1317
|
}
|
|
1261
1318
|
if (this.steps.length > 0) {
|
|
1262
1319
|
await this.applyTransitions(composed, processFrames);
|
|
@@ -1264,7 +1321,64 @@ var CanvasRenderer = class {
|
|
|
1264
1321
|
return composed;
|
|
1265
1322
|
}
|
|
1266
1323
|
/**
|
|
1267
|
-
*
|
|
1324
|
+
* Distribute frame composition across a pool of worker threads.
|
|
1325
|
+
* Workers process frames concurrently; results are collected in order.
|
|
1326
|
+
*/
|
|
1327
|
+
processWithWorkers(frames, contexts, workerCount) {
|
|
1328
|
+
return new Promise((resolve, reject) => {
|
|
1329
|
+
const results = new Array(frames.length);
|
|
1330
|
+
let completed = 0;
|
|
1331
|
+
let nextIndex = 0;
|
|
1332
|
+
let failed = false;
|
|
1333
|
+
const workerUrl = getWorkerUrl();
|
|
1334
|
+
const workers = [];
|
|
1335
|
+
const dispatch = (worker) => {
|
|
1336
|
+
if (nextIndex >= frames.length || failed) return;
|
|
1337
|
+
const i = nextIndex++;
|
|
1338
|
+
worker.postMessage({
|
|
1339
|
+
taskId: i,
|
|
1340
|
+
frame: frames[i],
|
|
1341
|
+
effects: this.effects,
|
|
1342
|
+
output: this.output,
|
|
1343
|
+
context: contexts[i]
|
|
1344
|
+
});
|
|
1345
|
+
};
|
|
1346
|
+
for (let w = 0; w < workerCount; w++) {
|
|
1347
|
+
const worker = new Worker(workerUrl);
|
|
1348
|
+
workers.push(worker);
|
|
1349
|
+
worker.on("message", (msg) => {
|
|
1350
|
+
if (failed) return;
|
|
1351
|
+
if (msg.error) {
|
|
1352
|
+
failed = true;
|
|
1353
|
+
workers.forEach((wk) => wk.terminate());
|
|
1354
|
+
reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
results[msg.taskId] = {
|
|
1358
|
+
index: frames[msg.taskId].index,
|
|
1359
|
+
buffer: Buffer.from(msg.buffer),
|
|
1360
|
+
timestamp: frames[msg.taskId].timestamp
|
|
1361
|
+
};
|
|
1362
|
+
completed++;
|
|
1363
|
+
if (completed === frames.length) {
|
|
1364
|
+
workers.forEach((wk) => wk.terminate());
|
|
1365
|
+
resolve(results);
|
|
1366
|
+
} else {
|
|
1367
|
+
dispatch(worker);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
worker.on("error", (err) => {
|
|
1371
|
+
if (failed) return;
|
|
1372
|
+
failed = true;
|
|
1373
|
+
workers.forEach((wk) => wk.terminate());
|
|
1374
|
+
reject(err);
|
|
1375
|
+
});
|
|
1376
|
+
dispatch(worker);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
|
|
1268
1382
|
*/
|
|
1269
1383
|
calculateFrameContexts(frames) {
|
|
1270
1384
|
const contexts = [];
|
|
@@ -1296,7 +1410,6 @@ var CanvasRenderer = class {
|
|
|
1296
1410
|
}
|
|
1297
1411
|
/**
|
|
1298
1412
|
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
1299
|
-
* Returns a new frame array with frames duplicated or skipped.
|
|
1300
1413
|
*/
|
|
1301
1414
|
applySpeedRamp(frames) {
|
|
1302
1415
|
const config = this.effects.speedRamp;
|
|
@@ -1329,7 +1442,6 @@ var CanvasRenderer = class {
|
|
|
1329
1442
|
}
|
|
1330
1443
|
/**
|
|
1331
1444
|
* Apply crossfade transitions at step boundaries where configured.
|
|
1332
|
-
* Modifies the composed array in-place.
|
|
1333
1445
|
*/
|
|
1334
1446
|
async applyTransitions(composed, frames) {
|
|
1335
1447
|
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
@@ -1350,16 +1462,14 @@ var CanvasRenderer = class {
|
|
|
1350
1462
|
if (range < 2) continue;
|
|
1351
1463
|
const fromBuffer = composed[startIdx].buffer;
|
|
1352
1464
|
const toBuffer = composed[endIdx].buffer;
|
|
1353
|
-
const width = this.output.width;
|
|
1354
|
-
const height = this.output.height;
|
|
1355
1465
|
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
1356
1466
|
const progress = (i - startIdx) / range;
|
|
1357
1467
|
composed[i].buffer = await applyCrossfade(
|
|
1358
1468
|
fromBuffer,
|
|
1359
1469
|
toBuffer,
|
|
1360
1470
|
progress,
|
|
1361
|
-
width,
|
|
1362
|
-
height
|
|
1471
|
+
this.output.width,
|
|
1472
|
+
this.output.height
|
|
1363
1473
|
);
|
|
1364
1474
|
}
|
|
1365
1475
|
}
|
|
@@ -1369,11 +1479,45 @@ var CanvasRenderer = class {
|
|
|
1369
1479
|
// src/compose/video-encoder.ts
|
|
1370
1480
|
import gifenc from "gifenc";
|
|
1371
1481
|
import sharp9 from "sharp";
|
|
1372
|
-
import { writeFile, mkdir, readFile, rm
|
|
1482
|
+
import { writeFile, mkdir, readFile, rm } from "fs/promises";
|
|
1373
1483
|
import { join } from "path";
|
|
1374
1484
|
import { tmpdir } from "os";
|
|
1375
1485
|
import { spawn } from "child_process";
|
|
1376
1486
|
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
1487
|
+
var ENCODING_PRESETS = {
|
|
1488
|
+
social: { crf: 22, vtQuality: 75 },
|
|
1489
|
+
balanced: { crf: 18, vtQuality: 85 },
|
|
1490
|
+
archive: { crf: 13, vtQuality: 92 }
|
|
1491
|
+
};
|
|
1492
|
+
function resolveEncodingParams(config) {
|
|
1493
|
+
if (config.preset) return ENCODING_PRESETS[config.preset];
|
|
1494
|
+
process.stderr.write(
|
|
1495
|
+
`[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
|
|
1496
|
+
`
|
|
1497
|
+
);
|
|
1498
|
+
if (config.quality >= 75) return ENCODING_PRESETS.social;
|
|
1499
|
+
if (config.quality >= 45) return ENCODING_PRESETS.balanced;
|
|
1500
|
+
return ENCODING_PRESETS.archive;
|
|
1501
|
+
}
|
|
1502
|
+
var encoderDetectionPromise = null;
|
|
1503
|
+
function detectVideoEncoder() {
|
|
1504
|
+
if (!encoderDetectionPromise) {
|
|
1505
|
+
encoderDetectionPromise = new Promise((resolve) => {
|
|
1506
|
+
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
1507
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1508
|
+
});
|
|
1509
|
+
let out = "";
|
|
1510
|
+
proc.stdout.on("data", (d) => out += d.toString());
|
|
1511
|
+
proc.on("close", () => {
|
|
1512
|
+
if (out.includes("hevc_videotoolbox")) resolve("hevc_videotoolbox");
|
|
1513
|
+
else if (out.includes("h264_videotoolbox")) resolve("h264_videotoolbox");
|
|
1514
|
+
else resolve("libx264");
|
|
1515
|
+
});
|
|
1516
|
+
proc.on("error", () => resolve("libx264"));
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
return encoderDetectionPromise;
|
|
1520
|
+
}
|
|
1377
1521
|
async function encodeGif(frames, config) {
|
|
1378
1522
|
if (frames.length === 0) {
|
|
1379
1523
|
throw new Error("Cannot encode GIF: no frames provided");
|
|
@@ -1387,10 +1531,7 @@ async function encodeGif(frames, config) {
|
|
|
1387
1531
|
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1388
1532
|
const palette = quantize(rgba, 256);
|
|
1389
1533
|
const indexed = applyPalette(rgba, palette);
|
|
1390
|
-
gif.writeFrame(indexed, width, height, {
|
|
1391
|
-
palette,
|
|
1392
|
-
delay
|
|
1393
|
-
});
|
|
1534
|
+
gif.writeFrame(indexed, width, height, { palette, delay });
|
|
1394
1535
|
}
|
|
1395
1536
|
gif.finish();
|
|
1396
1537
|
return Buffer.from(gif.bytes());
|
|
@@ -1399,50 +1540,87 @@ async function encodeMp4(frames, config) {
|
|
|
1399
1540
|
if (frames.length === 0) {
|
|
1400
1541
|
throw new Error("Cannot encode MP4: no frames provided");
|
|
1401
1542
|
}
|
|
1402
|
-
const
|
|
1543
|
+
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
1403
1544
|
try {
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
|
|
1408
|
-
await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
|
|
1409
|
-
}
|
|
1410
|
-
const outputPath = join(tmpDir, "output.mp4");
|
|
1411
|
-
const crf = Math.round(51 - config.quality / 100 * 51);
|
|
1412
|
-
await runFfmpeg([
|
|
1413
|
-
"-y",
|
|
1414
|
-
"-framerate",
|
|
1415
|
-
String(config.fps),
|
|
1416
|
-
"-i",
|
|
1417
|
-
join(tmpDir, `frame-%0${padLength}d.png`),
|
|
1418
|
-
"-c:v",
|
|
1419
|
-
"libx264",
|
|
1420
|
-
"-pix_fmt",
|
|
1421
|
-
"yuv420p",
|
|
1422
|
-
"-crf",
|
|
1423
|
-
String(crf),
|
|
1424
|
-
"-preset",
|
|
1425
|
-
"slow",
|
|
1426
|
-
"-tune",
|
|
1427
|
-
"animation",
|
|
1428
|
-
"-movflags",
|
|
1429
|
-
"+faststart",
|
|
1430
|
-
outputPath
|
|
1431
|
-
]);
|
|
1545
|
+
const encoder = await detectVideoEncoder();
|
|
1546
|
+
const params = resolveEncodingParams(config);
|
|
1547
|
+
await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
|
|
1432
1548
|
return await readFile(outputPath);
|
|
1433
1549
|
} finally {
|
|
1434
|
-
await rm(
|
|
1550
|
+
await rm(outputPath, { force: true }).catch(() => {
|
|
1435
1551
|
});
|
|
1436
1552
|
}
|
|
1437
1553
|
}
|
|
1438
|
-
function
|
|
1554
|
+
async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
1555
|
+
const videoArgs = encoder === "hevc_videotoolbox" ? [
|
|
1556
|
+
"-c:v",
|
|
1557
|
+
"hevc_videotoolbox",
|
|
1558
|
+
"-q:v",
|
|
1559
|
+
String(params.vtQuality),
|
|
1560
|
+
"-pix_fmt",
|
|
1561
|
+
"yuv420p",
|
|
1562
|
+
"-tag:v",
|
|
1563
|
+
"hvc1"
|
|
1564
|
+
// required for playback in QuickTime / Apple devices
|
|
1565
|
+
] : encoder === "h264_videotoolbox" ? [
|
|
1566
|
+
"-c:v",
|
|
1567
|
+
"h264_videotoolbox",
|
|
1568
|
+
"-q:v",
|
|
1569
|
+
String(params.vtQuality),
|
|
1570
|
+
"-pix_fmt",
|
|
1571
|
+
"yuv420p"
|
|
1572
|
+
] : [
|
|
1573
|
+
"-c:v",
|
|
1574
|
+
"libx264",
|
|
1575
|
+
"-crf",
|
|
1576
|
+
String(params.crf),
|
|
1577
|
+
"-preset",
|
|
1578
|
+
"medium",
|
|
1579
|
+
"-tune",
|
|
1580
|
+
"stillimage",
|
|
1581
|
+
"-profile:v",
|
|
1582
|
+
"high",
|
|
1583
|
+
"-level",
|
|
1584
|
+
"4.1",
|
|
1585
|
+
"-pix_fmt",
|
|
1586
|
+
"yuv420p"
|
|
1587
|
+
];
|
|
1439
1588
|
return new Promise((resolve, reject) => {
|
|
1440
|
-
const
|
|
1589
|
+
const ffmpeg = spawn(
|
|
1590
|
+
"ffmpeg",
|
|
1591
|
+
[
|
|
1592
|
+
"-y",
|
|
1593
|
+
// Video input: raw RGB24 from stdin
|
|
1594
|
+
"-f",
|
|
1595
|
+
"rawvideo",
|
|
1596
|
+
"-pixel_format",
|
|
1597
|
+
"rgb24",
|
|
1598
|
+
"-video_size",
|
|
1599
|
+
`${config.width}x${config.height}`,
|
|
1600
|
+
"-framerate",
|
|
1601
|
+
String(config.fps),
|
|
1602
|
+
"-i",
|
|
1603
|
+
"pipe:0",
|
|
1604
|
+
// Silent audio track for platform compatibility
|
|
1605
|
+
"-f",
|
|
1606
|
+
"lavfi",
|
|
1607
|
+
"-i",
|
|
1608
|
+
"anullsrc=r=48000:cl=stereo",
|
|
1609
|
+
...videoArgs,
|
|
1610
|
+
"-c:a",
|
|
1611
|
+
"aac",
|
|
1612
|
+
"-b:a",
|
|
1613
|
+
"128k",
|
|
1614
|
+
"-shortest",
|
|
1615
|
+
"-movflags",
|
|
1616
|
+
"+faststart",
|
|
1617
|
+
outputPath
|
|
1618
|
+
],
|
|
1619
|
+
{ stdio: ["pipe", "ignore", "pipe"] }
|
|
1620
|
+
);
|
|
1441
1621
|
let stderr = "";
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
});
|
|
1445
|
-
proc.on("close", (code) => {
|
|
1622
|
+
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
1623
|
+
ffmpeg.on("close", (code) => {
|
|
1446
1624
|
if (code === 0) {
|
|
1447
1625
|
resolve();
|
|
1448
1626
|
} else {
|
|
@@ -1454,7 +1632,7 @@ function runFfmpeg(args) {
|
|
|
1454
1632
|
);
|
|
1455
1633
|
}
|
|
1456
1634
|
});
|
|
1457
|
-
|
|
1635
|
+
ffmpeg.on("error", (err) => {
|
|
1458
1636
|
if (err.code === "ENOENT") {
|
|
1459
1637
|
reject(
|
|
1460
1638
|
new Error(
|
|
@@ -1465,6 +1643,15 @@ function runFfmpeg(args) {
|
|
|
1465
1643
|
reject(err);
|
|
1466
1644
|
}
|
|
1467
1645
|
});
|
|
1646
|
+
(async () => {
|
|
1647
|
+
for (const frame of frames) {
|
|
1648
|
+
const raw = await sharp9(frame.buffer).flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
|
|
1649
|
+
if (!ffmpeg.stdin.write(raw)) {
|
|
1650
|
+
await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
ffmpeg.stdin.end();
|
|
1654
|
+
})().catch(reject);
|
|
1468
1655
|
});
|
|
1469
1656
|
}
|
|
1470
1657
|
async function savePngSequence(frames, config) {
|
|
@@ -1655,8 +1842,13 @@ var OutputConfigSchema = z.object({
|
|
|
1655
1842
|
format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
|
|
1656
1843
|
width: z.number().default(1280),
|
|
1657
1844
|
height: z.number().default(800),
|
|
1658
|
-
fps: z.number().min(1).max(60).default(
|
|
1845
|
+
fps: z.number().min(1).max(60).default(30),
|
|
1659
1846
|
quality: z.number().min(1).max(100).default(80),
|
|
1847
|
+
// Encoding preset for MP4 output. Overrides quality when set.
|
|
1848
|
+
// social — optimized for Twitter/X and YouTube (CRF 25, capped bitrate)
|
|
1849
|
+
// balanced — general-purpose, good quality/size trade-off (CRF 20)
|
|
1850
|
+
// archive — high-fidelity storage, larger file (CRF 15)
|
|
1851
|
+
preset: z.enum(["social", "balanced", "archive"]).optional(),
|
|
1660
1852
|
outputDir: z.string().default("./output"),
|
|
1661
1853
|
filename: z.string().default("clipwise-recording")
|
|
1662
1854
|
});
|