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/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,15 +391,15 @@ 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
|
};
|
|
399
404
|
var ClipwiseRecorder = class {
|
|
400
405
|
browser = null;
|
|
@@ -408,6 +413,7 @@ var ClipwiseRecorder = class {
|
|
|
408
413
|
currentStepIndex = 0;
|
|
409
414
|
cursorPosition = { x: 0, y: 0 };
|
|
410
415
|
viewport = { width: 1280, height: 800 };
|
|
416
|
+
deviceScaleFactor = 1;
|
|
411
417
|
isCapturing = false;
|
|
412
418
|
targetFps = 30;
|
|
413
419
|
cursorSpeed = "fast";
|
|
@@ -461,10 +467,9 @@ var ClipwiseRecorder = class {
|
|
|
461
467
|
}
|
|
462
468
|
);
|
|
463
469
|
await this.cdpClient.send("Page.startScreencast", {
|
|
464
|
-
format: "
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
maxHeight: this.viewport.height,
|
|
470
|
+
format: "png",
|
|
471
|
+
maxWidth: this.viewport.width * this.deviceScaleFactor,
|
|
472
|
+
maxHeight: this.viewport.height * this.deviceScaleFactor,
|
|
468
473
|
everyNthFrame: 1
|
|
469
474
|
});
|
|
470
475
|
this.cursorTimeline.push({
|
|
@@ -778,6 +783,7 @@ var ClipwiseRecorder = class {
|
|
|
778
783
|
clickPosition: clickEvent?.position ?? null,
|
|
779
784
|
clickProgress,
|
|
780
785
|
viewport: { ...this.viewport },
|
|
786
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
781
787
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
782
788
|
stepIndex: this.currentStepIndex
|
|
783
789
|
};
|
|
@@ -806,15 +812,9 @@ var ClipwiseRecorder = class {
|
|
|
806
812
|
for (let i = 0; i < targetFrameCount; i++) {
|
|
807
813
|
const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
|
|
808
814
|
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
|
-
}
|
|
815
|
+
const lo = this.binarySearchTimeline(frames, targetTimestamp);
|
|
816
|
+
const hi = Math.min(lo + 1, frames.length - 1);
|
|
817
|
+
const nearestIdx = Math.abs(frames[hi].timestamp - targetTimestamp) < Math.abs(frames[lo].timestamp - targetTimestamp) ? hi : lo;
|
|
818
818
|
const cursorPos = this.interpolateCursorAt(targetTimestamp);
|
|
819
819
|
const clickEvent = this.clickTimeline.find(
|
|
820
820
|
(click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
@@ -835,6 +835,7 @@ var ClipwiseRecorder = class {
|
|
|
835
835
|
clickPosition: clickEvent?.position ?? null,
|
|
836
836
|
clickProgress,
|
|
837
837
|
viewport: { ...this.viewport },
|
|
838
|
+
deviceScaleFactor: this.deviceScaleFactor,
|
|
838
839
|
stepName: frames[nearestIdx].stepName,
|
|
839
840
|
stepIndex: frames[nearestIdx].stepIndex,
|
|
840
841
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
@@ -850,15 +851,9 @@ var ClipwiseRecorder = class {
|
|
|
850
851
|
if (this.cursorTimeline.length === 1) {
|
|
851
852
|
return { ...this.cursorTimeline[0].position };
|
|
852
853
|
}
|
|
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
|
-
}
|
|
854
|
+
const idx = this.binarySearchTimeline(this.cursorTimeline, timestamp);
|
|
855
|
+
const before = this.cursorTimeline[idx];
|
|
856
|
+
const after = this.cursorTimeline[Math.min(idx + 1, this.cursorTimeline.length - 1)];
|
|
862
857
|
if (timestamp <= before.timestamp) return { ...before.position };
|
|
863
858
|
if (timestamp >= after.timestamp) return { ...after.position };
|
|
864
859
|
const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
|
|
@@ -871,6 +866,23 @@ var ClipwiseRecorder = class {
|
|
|
871
866
|
)
|
|
872
867
|
};
|
|
873
868
|
}
|
|
869
|
+
/**
|
|
870
|
+
* Binary search: returns the index of the last entry whose timestamp <= target.
|
|
871
|
+
* Assumes the array is sorted by timestamp in ascending order.
|
|
872
|
+
*/
|
|
873
|
+
binarySearchTimeline(timeline, target) {
|
|
874
|
+
let lo = 0;
|
|
875
|
+
let hi = timeline.length - 1;
|
|
876
|
+
while (lo < hi) {
|
|
877
|
+
const mid = lo + hi + 1 >> 1;
|
|
878
|
+
if (timeline[mid].timestamp <= target) {
|
|
879
|
+
lo = mid;
|
|
880
|
+
} else {
|
|
881
|
+
hi = mid - 1;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return lo;
|
|
885
|
+
}
|
|
874
886
|
/**
|
|
875
887
|
* Clean up browser resources. Always called after recording.
|
|
876
888
|
*/
|
|
@@ -895,7 +907,13 @@ var ClipwiseRecorder = class {
|
|
|
895
907
|
};
|
|
896
908
|
|
|
897
909
|
// src/compose/canvas-renderer.ts
|
|
898
|
-
import
|
|
910
|
+
import { Worker } from "worker_threads";
|
|
911
|
+
import os from "os";
|
|
912
|
+
import { existsSync } from "fs";
|
|
913
|
+
import { fileURLToPath } from "url";
|
|
914
|
+
|
|
915
|
+
// src/compose/compose-frame.ts
|
|
916
|
+
import sharp7 from "sharp";
|
|
899
917
|
|
|
900
918
|
// src/effects/frame.ts
|
|
901
919
|
import sharp from "sharp";
|
|
@@ -918,91 +936,113 @@ var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
|
918
936
|
var ANDROID_OUTER_RADIUS = 35;
|
|
919
937
|
var ANDROID_INNER_RADIUS = 30;
|
|
920
938
|
var ANDROID_CAMERA_RADIUS = 6;
|
|
921
|
-
function buildBrowserChromeSvg(width, darkMode) {
|
|
939
|
+
function buildBrowserChromeSvg(width, darkMode, dpr = 1) {
|
|
922
940
|
const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
|
|
923
941
|
const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
|
|
924
942
|
const addressBorder = darkMode ? "#444444" : "#d0d0d0";
|
|
925
943
|
const textColor = darkMode ? "#999999" : "#666666";
|
|
944
|
+
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
945
|
+
const tlY = TRAFFIC_LIGHT_Y * dpr;
|
|
946
|
+
const tlR = TRAFFIC_LIGHT_RADIUS * dpr;
|
|
947
|
+
const tlStartX = TRAFFIC_LIGHTS_START_X * dpr;
|
|
948
|
+
const tlGap = TRAFFIC_LIGHT_GAP * dpr;
|
|
949
|
+
const aBarH = ADDRESS_BAR_HEIGHT * dpr;
|
|
950
|
+
const aBarMargin = ADDRESS_BAR_MARGIN * dpr;
|
|
951
|
+
const fontSize = 12 * dpr;
|
|
926
952
|
const trafficLights = [
|
|
927
|
-
{ cx:
|
|
928
|
-
{ cx:
|
|
929
|
-
{ cx:
|
|
953
|
+
{ cx: tlStartX, fill: "#ff5f57" },
|
|
954
|
+
{ cx: tlStartX + tlGap, fill: "#febc2e" },
|
|
955
|
+
{ cx: tlStartX + tlGap * 2, fill: "#28c840" }
|
|
930
956
|
].map(
|
|
931
|
-
(light) => `<circle cx="${light.cx}" cy="${
|
|
957
|
+
(light) => `<circle cx="${light.cx}" cy="${tlY}" r="${tlR}" fill="${light.fill}"/>`
|
|
932
958
|
).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="${
|
|
959
|
+
const addressBarWidth = width - aBarMargin * 2;
|
|
960
|
+
const addressBarX = aBarMargin;
|
|
961
|
+
const addressBarY = (tbarH - aBarH) / 2;
|
|
962
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${tbarH}">
|
|
963
|
+
<rect width="${width}" height="${tbarH}" fill="${bg}"/>
|
|
938
964
|
${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="
|
|
965
|
+
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${aBarH}"
|
|
966
|
+
rx="${6 * dpr}" ry="${6 * dpr}" fill="${addressBg}" stroke="${addressBorder}" stroke-width="${dpr}"/>
|
|
967
|
+
<text x="${width / 2}" y="${tlY + 4 * dpr}" text-anchor="middle"
|
|
968
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${fontSize}" fill="${textColor}">
|
|
943
969
|
localhost
|
|
944
970
|
</text>
|
|
945
971
|
</svg>`;
|
|
946
972
|
}
|
|
947
|
-
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
973
|
+
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
948
974
|
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
949
975
|
const islandColor = darkMode ? "#000000" : "#1a1a1a";
|
|
950
976
|
const homeBarColor = darkMode ? "#555555" : "#333333";
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
const
|
|
954
|
-
const
|
|
955
|
-
const
|
|
956
|
-
const
|
|
977
|
+
const bezelTop = IPHONE_BEZEL.top * dpr;
|
|
978
|
+
const bezelBottom = IPHONE_BEZEL.bottom * dpr;
|
|
979
|
+
const bezelSides = IPHONE_BEZEL.sides * dpr;
|
|
980
|
+
const outerRadius = IPHONE_OUTER_RADIUS * dpr;
|
|
981
|
+
const innerRadius = IPHONE_INNER_RADIUS * dpr;
|
|
982
|
+
const islandW = IPHONE_ISLAND.width * dpr;
|
|
983
|
+
const islandH = IPHONE_ISLAND.height * dpr;
|
|
984
|
+
const homeBarW = IPHONE_HOME_BAR.width * dpr;
|
|
985
|
+
const homeBarH = IPHONE_HOME_BAR.height * dpr;
|
|
986
|
+
const islandX = (totalWidth - islandW) / 2;
|
|
987
|
+
const islandY = (bezelTop - islandH) / 2 + 4 * dpr;
|
|
988
|
+
const homeBarX = (totalWidth - homeBarW) / 2;
|
|
989
|
+
const homeBarY = totalHeight - bezelBottom / 2 - homeBarH / 2;
|
|
990
|
+
const screenX = bezelSides;
|
|
991
|
+
const screenY = bezelTop;
|
|
957
992
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
958
993
|
<!-- Device body -->
|
|
959
994
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
960
|
-
rx="${
|
|
995
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
961
996
|
<!-- Screen cutout (transparent) -->
|
|
962
997
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
963
|
-
rx="${
|
|
998
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
964
999
|
<!-- Dynamic Island pill -->
|
|
965
|
-
<rect x="${islandX}" y="${islandY}" width="${
|
|
966
|
-
rx="${
|
|
1000
|
+
<rect x="${islandX}" y="${islandY}" width="${islandW}" height="${islandH}"
|
|
1001
|
+
rx="${islandH / 2}" ry="${islandH / 2}" fill="${islandColor}"/>
|
|
967
1002
|
<!-- Home indicator bar -->
|
|
968
|
-
<rect x="${homeBarX}" y="${homeBarY}" width="${
|
|
969
|
-
rx="${
|
|
1003
|
+
<rect x="${homeBarX}" y="${homeBarY}" width="${homeBarW}" height="${homeBarH}"
|
|
1004
|
+
rx="${homeBarH / 2}" ry="${homeBarH / 2}" fill="${homeBarColor}"/>
|
|
970
1005
|
</svg>`;
|
|
971
1006
|
}
|
|
972
|
-
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
1007
|
+
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
973
1008
|
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
974
1009
|
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
975
|
-
const screenX = IPAD_BEZEL.sides;
|
|
976
|
-
const screenY = IPAD_BEZEL.top;
|
|
1010
|
+
const screenX = IPAD_BEZEL.sides * dpr;
|
|
1011
|
+
const screenY = IPAD_BEZEL.top * dpr;
|
|
977
1012
|
const cameraCx = totalWidth / 2;
|
|
978
|
-
const cameraCy = IPAD_BEZEL.top / 2;
|
|
1013
|
+
const cameraCy = IPAD_BEZEL.top * dpr / 2;
|
|
1014
|
+
const outerRadius = IPAD_OUTER_RADIUS * dpr;
|
|
1015
|
+
const innerRadius = IPAD_INNER_RADIUS * dpr;
|
|
979
1016
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
980
1017
|
<!-- Device body -->
|
|
981
1018
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
982
|
-
rx="${
|
|
1019
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
983
1020
|
<!-- Screen cutout -->
|
|
984
1021
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
985
|
-
rx="${
|
|
1022
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
986
1023
|
<!-- Front camera dot -->
|
|
987
|
-
<circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
|
|
1024
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${4 * dpr}" fill="${cameraColor}"/>
|
|
988
1025
|
</svg>`;
|
|
989
1026
|
}
|
|
990
|
-
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
1027
|
+
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode, dpr = 1) {
|
|
991
1028
|
const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
|
|
992
1029
|
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
993
|
-
const screenX = ANDROID_BEZEL.sides;
|
|
994
|
-
const screenY = ANDROID_BEZEL.top;
|
|
1030
|
+
const screenX = ANDROID_BEZEL.sides * dpr;
|
|
1031
|
+
const screenY = ANDROID_BEZEL.top * dpr;
|
|
995
1032
|
const cameraCx = totalWidth / 2;
|
|
996
|
-
const cameraCy = ANDROID_BEZEL.top / 2;
|
|
1033
|
+
const cameraCy = ANDROID_BEZEL.top * dpr / 2;
|
|
1034
|
+
const outerRadius = ANDROID_OUTER_RADIUS * dpr;
|
|
1035
|
+
const innerRadius = ANDROID_INNER_RADIUS * dpr;
|
|
1036
|
+
const cameraR = ANDROID_CAMERA_RADIUS * dpr;
|
|
997
1037
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
998
1038
|
<!-- Device body -->
|
|
999
1039
|
<rect width="${totalWidth}" height="${totalHeight}"
|
|
1000
|
-
rx="${
|
|
1040
|
+
rx="${outerRadius}" ry="${outerRadius}" fill="${bezelColor}"/>
|
|
1001
1041
|
<!-- Screen cutout -->
|
|
1002
1042
|
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
1003
|
-
rx="${
|
|
1043
|
+
rx="${innerRadius}" ry="${innerRadius}" fill="black"/>
|
|
1004
1044
|
<!-- Punch-hole camera -->
|
|
1005
|
-
<circle cx="${cameraCx}" cy="${cameraCy}" r="${
|
|
1045
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${cameraR}" fill="${cameraColor}"/>
|
|
1006
1046
|
</svg>`;
|
|
1007
1047
|
}
|
|
1008
1048
|
function buildScreenMaskSvg(width, height, radius) {
|
|
@@ -1010,21 +1050,33 @@ function buildScreenMaskSvg(width, height, radius) {
|
|
|
1010
1050
|
<rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
|
|
1011
1051
|
</svg>`;
|
|
1012
1052
|
}
|
|
1013
|
-
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
|
|
1053
|
+
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight, dpr = 1) {
|
|
1014
1054
|
let bezel;
|
|
1015
1055
|
let innerRadius;
|
|
1016
1056
|
switch (deviceType) {
|
|
1017
1057
|
case "iphone":
|
|
1018
|
-
bezel =
|
|
1019
|
-
|
|
1058
|
+
bezel = {
|
|
1059
|
+
sides: IPHONE_BEZEL.sides * dpr,
|
|
1060
|
+
top: IPHONE_BEZEL.top * dpr,
|
|
1061
|
+
bottom: IPHONE_BEZEL.bottom * dpr
|
|
1062
|
+
};
|
|
1063
|
+
innerRadius = IPHONE_INNER_RADIUS * dpr;
|
|
1020
1064
|
break;
|
|
1021
1065
|
case "ipad":
|
|
1022
|
-
bezel =
|
|
1023
|
-
|
|
1066
|
+
bezel = {
|
|
1067
|
+
sides: IPAD_BEZEL.sides * dpr,
|
|
1068
|
+
top: IPAD_BEZEL.top * dpr,
|
|
1069
|
+
bottom: IPAD_BEZEL.bottom * dpr
|
|
1070
|
+
};
|
|
1071
|
+
innerRadius = IPAD_INNER_RADIUS * dpr;
|
|
1024
1072
|
break;
|
|
1025
1073
|
case "android":
|
|
1026
|
-
bezel =
|
|
1027
|
-
|
|
1074
|
+
bezel = {
|
|
1075
|
+
sides: ANDROID_BEZEL.sides * dpr,
|
|
1076
|
+
top: ANDROID_BEZEL.top * dpr,
|
|
1077
|
+
bottom: ANDROID_BEZEL.bottom * dpr
|
|
1078
|
+
};
|
|
1079
|
+
innerRadius = ANDROID_INNER_RADIUS * dpr;
|
|
1028
1080
|
break;
|
|
1029
1081
|
}
|
|
1030
1082
|
const totalWidth = frameWidth + bezel.sides * 2;
|
|
@@ -1032,13 +1084,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
|
|
|
1032
1084
|
let frameSvg;
|
|
1033
1085
|
switch (deviceType) {
|
|
1034
1086
|
case "iphone":
|
|
1035
|
-
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
1087
|
+
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
1036
1088
|
break;
|
|
1037
1089
|
case "ipad":
|
|
1038
|
-
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
1090
|
+
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
1039
1091
|
break;
|
|
1040
1092
|
case "android":
|
|
1041
|
-
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
1093
|
+
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode, dpr);
|
|
1042
1094
|
break;
|
|
1043
1095
|
}
|
|
1044
1096
|
const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
|
|
@@ -1061,12 +1113,13 @@ async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, f
|
|
|
1061
1113
|
{ input: maskedScreen, left: bezel.sides, top: bezel.top }
|
|
1062
1114
|
]).png().toBuffer();
|
|
1063
1115
|
}
|
|
1064
|
-
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
1116
|
+
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dpr = 1) {
|
|
1065
1117
|
if (!config.enabled || config.type === "none") return frameBuffer;
|
|
1066
1118
|
switch (config.type) {
|
|
1067
1119
|
case "browser": {
|
|
1068
|
-
const
|
|
1069
|
-
const
|
|
1120
|
+
const tbarH = TITLE_BAR_HEIGHT * dpr;
|
|
1121
|
+
const totalHeight = frameHeight + tbarH;
|
|
1122
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode, dpr);
|
|
1070
1123
|
const chromeBuffer = Buffer.from(chromeSvg);
|
|
1071
1124
|
const canvas = await sharp({
|
|
1072
1125
|
create: {
|
|
@@ -1078,13 +1131,13 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1078
1131
|
}).png().toBuffer();
|
|
1079
1132
|
return sharp(canvas).composite([
|
|
1080
1133
|
{ input: chromeBuffer, left: 0, top: 0 },
|
|
1081
|
-
{ input: frameBuffer, left: 0, top:
|
|
1134
|
+
{ input: frameBuffer, left: 0, top: tbarH }
|
|
1082
1135
|
]).png().toBuffer();
|
|
1083
1136
|
}
|
|
1084
1137
|
case "iphone":
|
|
1085
1138
|
case "ipad":
|
|
1086
1139
|
case "android":
|
|
1087
|
-
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
|
|
1140
|
+
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight, dpr);
|
|
1088
1141
|
default:
|
|
1089
1142
|
return frameBuffer;
|
|
1090
1143
|
}
|
|
@@ -1094,7 +1147,7 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1094
1147
|
import sharp2 from "sharp";
|
|
1095
1148
|
function buildCursorSvg(size, color) {
|
|
1096
1149
|
const s = size;
|
|
1097
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
|
|
1150
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
|
|
1098
1151
|
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
1099
1152
|
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
1100
1153
|
</svg>`;
|
|
@@ -1103,7 +1156,7 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
1103
1156
|
const currentRadius = radius * progress;
|
|
1104
1157
|
const opacity = Math.max(0, 1 - progress);
|
|
1105
1158
|
const size = Math.ceil(radius * 2 + 4);
|
|
1106
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
1159
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1107
1160
|
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
1108
1161
|
fill="none" stroke="${color}" stroke-width="2"
|
|
1109
1162
|
opacity="${opacity.toFixed(3)}"/>
|
|
@@ -1111,47 +1164,35 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
1111
1164
|
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
1112
1165
|
</svg>`;
|
|
1113
1166
|
}
|
|
1114
|
-
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
1167
|
+
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1115
1168
|
if (!config.enabled) return frameBuffer;
|
|
1116
|
-
const
|
|
1169
|
+
const size = Math.round(config.size * dpr);
|
|
1170
|
+
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1117
1171
|
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));
|
|
1172
|
+
const left = Math.max(0, Math.min(Math.round(position.x * dpr), frameWidth - 1));
|
|
1173
|
+
const top = Math.max(0, Math.min(Math.round(position.y * dpr), frameHeight - 1));
|
|
1120
1174
|
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
1121
1175
|
}
|
|
1122
|
-
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
|
|
1176
|
+
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
1123
1177
|
if (!config.enabled || !config.clickEffect) return frameBuffer;
|
|
1178
|
+
const radius = config.clickRadius * dpr;
|
|
1124
1179
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
1125
|
-
const rippleSvg = buildClickRippleSvg(
|
|
1126
|
-
config.clickRadius,
|
|
1127
|
-
config.clickColor,
|
|
1128
|
-
clampedProgress
|
|
1129
|
-
);
|
|
1180
|
+
const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
|
|
1130
1181
|
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
|
-
);
|
|
1182
|
+
const rippleSize = Math.ceil(radius * 2 + 4);
|
|
1183
|
+
const px = Math.round(position.x * dpr);
|
|
1184
|
+
const py = Math.round(position.y * dpr);
|
|
1185
|
+
const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
|
|
1186
|
+
const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
|
|
1146
1187
|
return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
|
|
1147
1188
|
}
|
|
1148
|
-
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
1189
|
+
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1149
1190
|
if (!config.enabled || !config.highlight) return frameBuffer;
|
|
1150
|
-
const r = config.highlightRadius;
|
|
1191
|
+
const r = config.highlightRadius * dpr;
|
|
1151
1192
|
const size = Math.ceil(r * 2 + 4);
|
|
1152
1193
|
const cx = size / 2;
|
|
1153
1194
|
const cy = size / 2;
|
|
1154
|
-
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
1195
|
+
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1155
1196
|
<defs>
|
|
1156
1197
|
<radialGradient id="glow">
|
|
1157
1198
|
<stop offset="0%" stop-color="${config.highlightColor}" />
|
|
@@ -1161,27 +1202,29 @@ async function renderCursorHighlight(frameBuffer, position, config, frameWidth,
|
|
|
1161
1202
|
</defs>
|
|
1162
1203
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
|
|
1163
1204
|
</svg>`;
|
|
1164
|
-
const
|
|
1165
|
-
const
|
|
1205
|
+
const px = Math.round(position.x * dpr);
|
|
1206
|
+
const py = Math.round(position.y * dpr);
|
|
1207
|
+
const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
|
|
1208
|
+
const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
|
|
1166
1209
|
return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
|
|
1167
1210
|
}
|
|
1168
|
-
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
|
|
1211
|
+
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight, dpr = 1) {
|
|
1169
1212
|
if (!config.enabled || !config.trail || positions.length < 2) {
|
|
1170
1213
|
return frameBuffer;
|
|
1171
1214
|
}
|
|
1172
1215
|
const segments = [];
|
|
1173
1216
|
for (let i = 1; i < positions.length; i++) {
|
|
1174
1217
|
const opacity = i / positions.length * 0.6;
|
|
1175
|
-
const strokeWidth = 1 + i / positions.length * 2;
|
|
1218
|
+
const strokeWidth = (1 + i / positions.length * 2) * dpr;
|
|
1176
1219
|
const p1 = positions[i - 1];
|
|
1177
1220
|
const p2 = positions[i];
|
|
1178
1221
|
segments.push(
|
|
1179
|
-
`<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
|
|
1222
|
+
`<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
|
|
1180
1223
|
stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
|
|
1181
1224
|
stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
|
|
1182
1225
|
);
|
|
1183
1226
|
}
|
|
1184
|
-
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1227
|
+
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
|
|
1185
1228
|
${segments.join("\n ")}
|
|
1186
1229
|
</svg>`;
|
|
1187
1230
|
return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
@@ -1314,7 +1357,7 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
|
1314
1357
|
|
|
1315
1358
|
// src/effects/keystroke.ts
|
|
1316
1359
|
import sharp5 from "sharp";
|
|
1317
|
-
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
|
|
1360
|
+
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
|
|
1318
1361
|
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
1319
1362
|
const recentKeys = keystrokes.filter(
|
|
1320
1363
|
(k) => frameTimestamp - k.timestamp < config.fadeAfter
|
|
@@ -1322,25 +1365,28 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1322
1365
|
if (recentKeys.length === 0) return frameBuffer;
|
|
1323
1366
|
const displayText = recentKeys.map((k) => k.key).join("");
|
|
1324
1367
|
if (displayText.length === 0) return frameBuffer;
|
|
1325
|
-
const
|
|
1368
|
+
const fontSize = config.fontSize * dpr;
|
|
1369
|
+
const padding = config.padding * dpr;
|
|
1370
|
+
const charWidth = fontSize * 0.62;
|
|
1326
1371
|
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(
|
|
1372
|
+
const hudPadH = padding * 2;
|
|
1373
|
+
const hudPadV = padding * 1.5;
|
|
1374
|
+
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40 * dpr);
|
|
1375
|
+
const hudHeight = Math.ceil(fontSize + hudPadV * 2);
|
|
1331
1376
|
const newest = recentKeys[recentKeys.length - 1];
|
|
1332
1377
|
const age = frameTimestamp - newest.timestamp;
|
|
1333
1378
|
const fadeStart = config.fadeAfter * 0.6;
|
|
1334
1379
|
const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
1335
1380
|
if (opacity <= 0) return frameBuffer;
|
|
1381
|
+
const margin = 30 * dpr;
|
|
1336
1382
|
let hudX;
|
|
1337
|
-
const hudY = frameHeight - hudHeight -
|
|
1383
|
+
const hudY = frameHeight - hudHeight - margin;
|
|
1338
1384
|
switch (config.position) {
|
|
1339
1385
|
case "bottom-left":
|
|
1340
|
-
hudX =
|
|
1386
|
+
hudX = margin;
|
|
1341
1387
|
break;
|
|
1342
1388
|
case "bottom-right":
|
|
1343
|
-
hudX = frameWidth - hudWidth -
|
|
1389
|
+
hudX = frameWidth - hudWidth - margin;
|
|
1344
1390
|
break;
|
|
1345
1391
|
case "bottom-center":
|
|
1346
1392
|
default:
|
|
@@ -1350,41 +1396,18 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1350
1396
|
const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
|
|
1351
1397
|
const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
|
|
1352
1398
|
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}">
|
|
1399
|
+
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1354
1400
|
<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="${
|
|
1401
|
+
rx="${8 * dpr}" ry="${8 * dpr}" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
1402
|
+
<text x="${hudX + hudPadH}" y="${hudY + hudPadV + fontSize * 0.75}"
|
|
1403
|
+
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1358
1404
|
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
1359
1405
|
</svg>`;
|
|
1360
1406
|
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1361
1407
|
}
|
|
1362
1408
|
|
|
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
1409
|
// src/effects/watermark.ts
|
|
1387
|
-
import
|
|
1410
|
+
import sharp6 from "sharp";
|
|
1388
1411
|
async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
1389
1412
|
if (!config.enabled || !config.text) return frameBuffer;
|
|
1390
1413
|
const charWidth = config.fontSize * 0.62;
|
|
@@ -1412,31 +1435,168 @@ async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
|
1412
1435
|
break;
|
|
1413
1436
|
}
|
|
1414
1437
|
const escaped = config.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1415
|
-
const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1438
|
+
const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1416
1439
|
<text x="${x}" y="${y}"
|
|
1417
1440
|
font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
|
|
1418
1441
|
font-weight="600" fill="${config.color}"
|
|
1419
1442
|
opacity="${config.opacity.toFixed(3)}">${escaped}</text>
|
|
1420
1443
|
</svg>`;
|
|
1421
|
-
return
|
|
1444
|
+
return sharp6(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1422
1445
|
}
|
|
1423
1446
|
|
|
1424
|
-
// src/compose/
|
|
1425
|
-
function getFrameOffset(config) {
|
|
1447
|
+
// src/compose/compose-frame.ts
|
|
1448
|
+
function getFrameOffset(config, dpr = 1) {
|
|
1426
1449
|
if (!config.enabled) return { left: 0, top: 0 };
|
|
1427
1450
|
switch (config.type) {
|
|
1428
1451
|
case "browser":
|
|
1429
|
-
return { left: 0, top: 40 };
|
|
1452
|
+
return { left: 0, top: 40 * dpr };
|
|
1430
1453
|
case "iphone":
|
|
1431
|
-
return { left: 12, top: 50 };
|
|
1454
|
+
return { left: 12 * dpr, top: 50 * dpr };
|
|
1432
1455
|
case "ipad":
|
|
1433
|
-
return { left: 20, top: 24 };
|
|
1456
|
+
return { left: 20 * dpr, top: 24 * dpr };
|
|
1434
1457
|
case "android":
|
|
1435
|
-
return { left: 8, top: 32 };
|
|
1458
|
+
return { left: 8 * dpr, top: 32 * dpr };
|
|
1436
1459
|
default:
|
|
1437
1460
|
return { left: 0, top: 0 };
|
|
1438
1461
|
}
|
|
1439
1462
|
}
|
|
1463
|
+
async function composeFrame(frame, effects, output, context) {
|
|
1464
|
+
let buffer = frame.screenshot;
|
|
1465
|
+
const meta = await sharp7(buffer).metadata();
|
|
1466
|
+
let width = meta.width ?? frame.viewport.width;
|
|
1467
|
+
let height = meta.height ?? frame.viewport.height;
|
|
1468
|
+
const dpr = Math.round(width / frame.viewport.width);
|
|
1469
|
+
const ctx = {
|
|
1470
|
+
zoomScale: context?.zoomScale ?? 1,
|
|
1471
|
+
clickProgress: context?.clickProgress ?? null,
|
|
1472
|
+
cursorTrail: context?.cursorTrail ?? []
|
|
1473
|
+
};
|
|
1474
|
+
if (effects.deviceFrame.enabled) {
|
|
1475
|
+
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1476
|
+
const meta2 = await sharp7(buffer).metadata();
|
|
1477
|
+
width = meta2.width ?? width;
|
|
1478
|
+
height = meta2.height ?? height;
|
|
1479
|
+
}
|
|
1480
|
+
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1481
|
+
buffer = await renderCursorHighlight(
|
|
1482
|
+
buffer,
|
|
1483
|
+
frame.cursorPosition,
|
|
1484
|
+
effects.cursor,
|
|
1485
|
+
width,
|
|
1486
|
+
height,
|
|
1487
|
+
dpr
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1491
|
+
buffer = await renderCursorTrail(
|
|
1492
|
+
buffer,
|
|
1493
|
+
ctx.cursorTrail,
|
|
1494
|
+
effects.cursor,
|
|
1495
|
+
width,
|
|
1496
|
+
height,
|
|
1497
|
+
dpr
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1501
|
+
buffer = await renderCursor(
|
|
1502
|
+
buffer,
|
|
1503
|
+
frame.cursorPosition,
|
|
1504
|
+
effects.cursor,
|
|
1505
|
+
width,
|
|
1506
|
+
height,
|
|
1507
|
+
dpr
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
|
|
1511
|
+
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1512
|
+
buffer = await renderClickEffect(
|
|
1513
|
+
buffer,
|
|
1514
|
+
frame.clickPosition,
|
|
1515
|
+
effects.cursor,
|
|
1516
|
+
progress,
|
|
1517
|
+
width,
|
|
1518
|
+
height,
|
|
1519
|
+
dpr
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
if (effects.keystroke.enabled && frame.keystrokes) {
|
|
1523
|
+
buffer = await renderKeystrokeHud(
|
|
1524
|
+
buffer,
|
|
1525
|
+
frame.keystrokes,
|
|
1526
|
+
frame.timestamp,
|
|
1527
|
+
effects.keystroke,
|
|
1528
|
+
width,
|
|
1529
|
+
height,
|
|
1530
|
+
dpr
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
const scale = ctx.zoomScale;
|
|
1534
|
+
if (effects.zoom.enabled && scale > 1) {
|
|
1535
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1536
|
+
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1537
|
+
const focusPoint = {
|
|
1538
|
+
x: rawFocus.x * dpr + offset.left,
|
|
1539
|
+
y: rawFocus.y * dpr + offset.top
|
|
1540
|
+
};
|
|
1541
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1542
|
+
}
|
|
1543
|
+
buffer = await applyBackground(buffer, effects.background, output.width, output.height);
|
|
1544
|
+
if (effects.watermark.enabled) {
|
|
1545
|
+
buffer = await renderWatermark(buffer, effects.watermark, output.width, output.height);
|
|
1546
|
+
}
|
|
1547
|
+
buffer = await sharp7(buffer).resize(output.width, output.height, {
|
|
1548
|
+
fit: "fill",
|
|
1549
|
+
kernel: sharp7.kernel.lanczos3
|
|
1550
|
+
}).png().toBuffer();
|
|
1551
|
+
return { index: frame.index, buffer, timestamp: frame.timestamp };
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// src/effects/transition.ts
|
|
1555
|
+
import sharp8 from "sharp";
|
|
1556
|
+
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
1557
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
1558
|
+
if (t <= 0) return fromBuffer;
|
|
1559
|
+
if (t >= 1) return toBuffer;
|
|
1560
|
+
const fromRaw = await sharp8(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1561
|
+
const toRaw = await sharp8(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1562
|
+
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
1563
|
+
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
1564
|
+
pixels[i] = Math.round(
|
|
1565
|
+
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
return sharp8(pixels, {
|
|
1569
|
+
raw: {
|
|
1570
|
+
width: fromRaw.info.width,
|
|
1571
|
+
height: fromRaw.info.height,
|
|
1572
|
+
channels: 4
|
|
1573
|
+
}
|
|
1574
|
+
}).png().toBuffer();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// src/compose/canvas-renderer.ts
|
|
1578
|
+
var MIN_FRAMES_PER_WORKER = 4;
|
|
1579
|
+
var cachedWorkerUrl = null;
|
|
1580
|
+
function getWorkerUrl() {
|
|
1581
|
+
if (cachedWorkerUrl) return cachedWorkerUrl;
|
|
1582
|
+
const base = import.meta.url;
|
|
1583
|
+
const candidates = [
|
|
1584
|
+
new URL("./frame-worker.js", base),
|
|
1585
|
+
// from dist/compose/
|
|
1586
|
+
new URL("../compose/frame-worker.js", base),
|
|
1587
|
+
// from dist/cli/
|
|
1588
|
+
new URL("./compose/frame-worker.js", base)
|
|
1589
|
+
// from dist/
|
|
1590
|
+
];
|
|
1591
|
+
for (const url of candidates) {
|
|
1592
|
+
if (existsSync(fileURLToPath(url))) {
|
|
1593
|
+
cachedWorkerUrl = url;
|
|
1594
|
+
return url;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
cachedWorkerUrl = candidates[1];
|
|
1598
|
+
return cachedWorkerUrl;
|
|
1599
|
+
}
|
|
1440
1600
|
var CanvasRenderer = class {
|
|
1441
1601
|
constructor(effects, output, steps) {
|
|
1442
1602
|
this.effects = effects;
|
|
@@ -1445,118 +1605,11 @@ var CanvasRenderer = class {
|
|
|
1445
1605
|
}
|
|
1446
1606
|
steps;
|
|
1447
1607
|
/**
|
|
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
|
|
1608
|
+
* Apply the full effects pipeline to a single frame.
|
|
1609
|
+
* Delegates to the standalone composeFrame function.
|
|
1461
1610
|
*/
|
|
1462
1611
|
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
|
-
};
|
|
1612
|
+
return composeFrame(frame, this.effects, this.output, context);
|
|
1560
1613
|
}
|
|
1561
1614
|
/**
|
|
1562
1615
|
* Process an entire sequence of captured frames through the effects pipeline.
|
|
@@ -1564,7 +1617,7 @@ var CanvasRenderer = class {
|
|
|
1564
1617
|
* Multi-pass approach:
|
|
1565
1618
|
* Pass 1: Speed ramping (adjust frame set).
|
|
1566
1619
|
* Pass 2: Calculate per-frame contexts (zoom, click, trail).
|
|
1567
|
-
* Pass 3: Render
|
|
1620
|
+
* Pass 3: Render frames in parallel using worker threads.
|
|
1568
1621
|
* Pass 4: Apply scene transitions at step boundaries.
|
|
1569
1622
|
*/
|
|
1570
1623
|
async composeAll(frames) {
|
|
@@ -1574,10 +1627,19 @@ var CanvasRenderer = class {
|
|
|
1574
1627
|
processFrames = this.applySpeedRamp(frames);
|
|
1575
1628
|
}
|
|
1576
1629
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
1577
|
-
const
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1630
|
+
const cpuCount = os.cpus().length;
|
|
1631
|
+
const workerCount = Math.min(cpuCount, 8);
|
|
1632
|
+
const useWorkers = workerCount >= 2 && processFrames.length >= workerCount * MIN_FRAMES_PER_WORKER;
|
|
1633
|
+
let composed;
|
|
1634
|
+
if (useWorkers) {
|
|
1635
|
+
composed = await this.processWithWorkers(processFrames, contexts, workerCount);
|
|
1636
|
+
} else {
|
|
1637
|
+
composed = [];
|
|
1638
|
+
for (let i = 0; i < processFrames.length; i++) {
|
|
1639
|
+
composed.push(
|
|
1640
|
+
await composeFrame(processFrames[i], this.effects, this.output, contexts[i])
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1581
1643
|
}
|
|
1582
1644
|
if (this.steps.length > 0) {
|
|
1583
1645
|
await this.applyTransitions(composed, processFrames);
|
|
@@ -1585,7 +1647,64 @@ var CanvasRenderer = class {
|
|
|
1585
1647
|
return composed;
|
|
1586
1648
|
}
|
|
1587
1649
|
/**
|
|
1588
|
-
*
|
|
1650
|
+
* Distribute frame composition across a pool of worker threads.
|
|
1651
|
+
* Workers process frames concurrently; results are collected in order.
|
|
1652
|
+
*/
|
|
1653
|
+
processWithWorkers(frames, contexts, workerCount) {
|
|
1654
|
+
return new Promise((resolve2, reject) => {
|
|
1655
|
+
const results = new Array(frames.length);
|
|
1656
|
+
let completed = 0;
|
|
1657
|
+
let nextIndex = 0;
|
|
1658
|
+
let failed = false;
|
|
1659
|
+
const workerUrl = getWorkerUrl();
|
|
1660
|
+
const workers = [];
|
|
1661
|
+
const dispatch = (worker) => {
|
|
1662
|
+
if (nextIndex >= frames.length || failed) return;
|
|
1663
|
+
const i = nextIndex++;
|
|
1664
|
+
worker.postMessage({
|
|
1665
|
+
taskId: i,
|
|
1666
|
+
frame: frames[i],
|
|
1667
|
+
effects: this.effects,
|
|
1668
|
+
output: this.output,
|
|
1669
|
+
context: contexts[i]
|
|
1670
|
+
});
|
|
1671
|
+
};
|
|
1672
|
+
for (let w = 0; w < workerCount; w++) {
|
|
1673
|
+
const worker = new Worker(workerUrl);
|
|
1674
|
+
workers.push(worker);
|
|
1675
|
+
worker.on("message", (msg) => {
|
|
1676
|
+
if (failed) return;
|
|
1677
|
+
if (msg.error) {
|
|
1678
|
+
failed = true;
|
|
1679
|
+
workers.forEach((wk) => wk.terminate());
|
|
1680
|
+
reject(new Error(`Worker failed on frame ${msg.taskId}: ${msg.error}`));
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
results[msg.taskId] = {
|
|
1684
|
+
index: frames[msg.taskId].index,
|
|
1685
|
+
buffer: Buffer.from(msg.buffer),
|
|
1686
|
+
timestamp: frames[msg.taskId].timestamp
|
|
1687
|
+
};
|
|
1688
|
+
completed++;
|
|
1689
|
+
if (completed === frames.length) {
|
|
1690
|
+
workers.forEach((wk) => wk.terminate());
|
|
1691
|
+
resolve2(results);
|
|
1692
|
+
} else {
|
|
1693
|
+
dispatch(worker);
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
worker.on("error", (err) => {
|
|
1697
|
+
if (failed) return;
|
|
1698
|
+
failed = true;
|
|
1699
|
+
workers.forEach((wk) => wk.terminate());
|
|
1700
|
+
reject(err);
|
|
1701
|
+
});
|
|
1702
|
+
dispatch(worker);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Calculate per-frame rendering context (zoom scale, click progress, cursor trail).
|
|
1589
1708
|
*/
|
|
1590
1709
|
calculateFrameContexts(frames) {
|
|
1591
1710
|
const contexts = [];
|
|
@@ -1617,7 +1736,6 @@ var CanvasRenderer = class {
|
|
|
1617
1736
|
}
|
|
1618
1737
|
/**
|
|
1619
1738
|
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
1620
|
-
* Returns a new frame array with frames duplicated or skipped.
|
|
1621
1739
|
*/
|
|
1622
1740
|
applySpeedRamp(frames) {
|
|
1623
1741
|
const config = this.effects.speedRamp;
|
|
@@ -1650,7 +1768,6 @@ var CanvasRenderer = class {
|
|
|
1650
1768
|
}
|
|
1651
1769
|
/**
|
|
1652
1770
|
* Apply crossfade transitions at step boundaries where configured.
|
|
1653
|
-
* Modifies the composed array in-place.
|
|
1654
1771
|
*/
|
|
1655
1772
|
async applyTransitions(composed, frames) {
|
|
1656
1773
|
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
@@ -1671,16 +1788,14 @@ var CanvasRenderer = class {
|
|
|
1671
1788
|
if (range < 2) continue;
|
|
1672
1789
|
const fromBuffer = composed[startIdx].buffer;
|
|
1673
1790
|
const toBuffer = composed[endIdx].buffer;
|
|
1674
|
-
const width = this.output.width;
|
|
1675
|
-
const height = this.output.height;
|
|
1676
1791
|
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
1677
1792
|
const progress = (i - startIdx) / range;
|
|
1678
1793
|
composed[i].buffer = await applyCrossfade(
|
|
1679
1794
|
fromBuffer,
|
|
1680
1795
|
toBuffer,
|
|
1681
1796
|
progress,
|
|
1682
|
-
width,
|
|
1683
|
-
height
|
|
1797
|
+
this.output.width,
|
|
1798
|
+
this.output.height
|
|
1684
1799
|
);
|
|
1685
1800
|
}
|
|
1686
1801
|
}
|
|
@@ -1690,11 +1805,45 @@ var CanvasRenderer = class {
|
|
|
1690
1805
|
// src/compose/video-encoder.ts
|
|
1691
1806
|
import gifenc from "gifenc";
|
|
1692
1807
|
import sharp9 from "sharp";
|
|
1693
|
-
import { writeFile, mkdir, readFile as readFile2, rm
|
|
1808
|
+
import { writeFile, mkdir, readFile as readFile2, rm } from "fs/promises";
|
|
1694
1809
|
import { join } from "path";
|
|
1695
1810
|
import { tmpdir } from "os";
|
|
1696
1811
|
import { spawn } from "child_process";
|
|
1697
1812
|
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
1813
|
+
var ENCODING_PRESETS = {
|
|
1814
|
+
social: { crf: 22, vtQuality: 75 },
|
|
1815
|
+
balanced: { crf: 18, vtQuality: 85 },
|
|
1816
|
+
archive: { crf: 13, vtQuality: 92 }
|
|
1817
|
+
};
|
|
1818
|
+
function resolveEncodingParams(config) {
|
|
1819
|
+
if (config.preset) return ENCODING_PRESETS[config.preset];
|
|
1820
|
+
process.stderr.write(
|
|
1821
|
+
`[clipwise] Deprecation: "quality" is deprecated. Use "preset: social | balanced | archive" instead.
|
|
1822
|
+
`
|
|
1823
|
+
);
|
|
1824
|
+
if (config.quality >= 75) return ENCODING_PRESETS.social;
|
|
1825
|
+
if (config.quality >= 45) return ENCODING_PRESETS.balanced;
|
|
1826
|
+
return ENCODING_PRESETS.archive;
|
|
1827
|
+
}
|
|
1828
|
+
var encoderDetectionPromise = null;
|
|
1829
|
+
function detectVideoEncoder() {
|
|
1830
|
+
if (!encoderDetectionPromise) {
|
|
1831
|
+
encoderDetectionPromise = new Promise((resolve2) => {
|
|
1832
|
+
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
1833
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1834
|
+
});
|
|
1835
|
+
let out = "";
|
|
1836
|
+
proc.stdout.on("data", (d) => out += d.toString());
|
|
1837
|
+
proc.on("close", () => {
|
|
1838
|
+
if (out.includes("hevc_videotoolbox")) resolve2("hevc_videotoolbox");
|
|
1839
|
+
else if (out.includes("h264_videotoolbox")) resolve2("h264_videotoolbox");
|
|
1840
|
+
else resolve2("libx264");
|
|
1841
|
+
});
|
|
1842
|
+
proc.on("error", () => resolve2("libx264"));
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
return encoderDetectionPromise;
|
|
1846
|
+
}
|
|
1698
1847
|
async function encodeGif(frames, config) {
|
|
1699
1848
|
if (frames.length === 0) {
|
|
1700
1849
|
throw new Error("Cannot encode GIF: no frames provided");
|
|
@@ -1708,10 +1857,7 @@ async function encodeGif(frames, config) {
|
|
|
1708
1857
|
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1709
1858
|
const palette = quantize(rgba, 256);
|
|
1710
1859
|
const indexed = applyPalette(rgba, palette);
|
|
1711
|
-
gif.writeFrame(indexed, width, height, {
|
|
1712
|
-
palette,
|
|
1713
|
-
delay
|
|
1714
|
-
});
|
|
1860
|
+
gif.writeFrame(indexed, width, height, { palette, delay });
|
|
1715
1861
|
}
|
|
1716
1862
|
gif.finish();
|
|
1717
1863
|
return Buffer.from(gif.bytes());
|
|
@@ -1720,50 +1866,87 @@ async function encodeMp4(frames, config) {
|
|
|
1720
1866
|
if (frames.length === 0) {
|
|
1721
1867
|
throw new Error("Cannot encode MP4: no frames provided");
|
|
1722
1868
|
}
|
|
1723
|
-
const
|
|
1869
|
+
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
1724
1870
|
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
|
-
]);
|
|
1871
|
+
const encoder = await detectVideoEncoder();
|
|
1872
|
+
const params = resolveEncodingParams(config);
|
|
1873
|
+
await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath);
|
|
1753
1874
|
return await readFile2(outputPath);
|
|
1754
1875
|
} finally {
|
|
1755
|
-
await rm(
|
|
1876
|
+
await rm(outputPath, { force: true }).catch(() => {
|
|
1756
1877
|
});
|
|
1757
1878
|
}
|
|
1758
1879
|
}
|
|
1759
|
-
function
|
|
1880
|
+
async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath) {
|
|
1881
|
+
const videoArgs = encoder === "hevc_videotoolbox" ? [
|
|
1882
|
+
"-c:v",
|
|
1883
|
+
"hevc_videotoolbox",
|
|
1884
|
+
"-q:v",
|
|
1885
|
+
String(params.vtQuality),
|
|
1886
|
+
"-pix_fmt",
|
|
1887
|
+
"yuv420p",
|
|
1888
|
+
"-tag:v",
|
|
1889
|
+
"hvc1"
|
|
1890
|
+
// required for playback in QuickTime / Apple devices
|
|
1891
|
+
] : encoder === "h264_videotoolbox" ? [
|
|
1892
|
+
"-c:v",
|
|
1893
|
+
"h264_videotoolbox",
|
|
1894
|
+
"-q:v",
|
|
1895
|
+
String(params.vtQuality),
|
|
1896
|
+
"-pix_fmt",
|
|
1897
|
+
"yuv420p"
|
|
1898
|
+
] : [
|
|
1899
|
+
"-c:v",
|
|
1900
|
+
"libx264",
|
|
1901
|
+
"-crf",
|
|
1902
|
+
String(params.crf),
|
|
1903
|
+
"-preset",
|
|
1904
|
+
"medium",
|
|
1905
|
+
"-tune",
|
|
1906
|
+
"stillimage",
|
|
1907
|
+
"-profile:v",
|
|
1908
|
+
"high",
|
|
1909
|
+
"-level",
|
|
1910
|
+
"4.1",
|
|
1911
|
+
"-pix_fmt",
|
|
1912
|
+
"yuv420p"
|
|
1913
|
+
];
|
|
1760
1914
|
return new Promise((resolve2, reject) => {
|
|
1761
|
-
const
|
|
1915
|
+
const ffmpeg = spawn(
|
|
1916
|
+
"ffmpeg",
|
|
1917
|
+
[
|
|
1918
|
+
"-y",
|
|
1919
|
+
// Video input: raw RGB24 from stdin
|
|
1920
|
+
"-f",
|
|
1921
|
+
"rawvideo",
|
|
1922
|
+
"-pixel_format",
|
|
1923
|
+
"rgb24",
|
|
1924
|
+
"-video_size",
|
|
1925
|
+
`${config.width}x${config.height}`,
|
|
1926
|
+
"-framerate",
|
|
1927
|
+
String(config.fps),
|
|
1928
|
+
"-i",
|
|
1929
|
+
"pipe:0",
|
|
1930
|
+
// Silent audio track for platform compatibility
|
|
1931
|
+
"-f",
|
|
1932
|
+
"lavfi",
|
|
1933
|
+
"-i",
|
|
1934
|
+
"anullsrc=r=48000:cl=stereo",
|
|
1935
|
+
...videoArgs,
|
|
1936
|
+
"-c:a",
|
|
1937
|
+
"aac",
|
|
1938
|
+
"-b:a",
|
|
1939
|
+
"128k",
|
|
1940
|
+
"-shortest",
|
|
1941
|
+
"-movflags",
|
|
1942
|
+
"+faststart",
|
|
1943
|
+
outputPath
|
|
1944
|
+
],
|
|
1945
|
+
{ stdio: ["pipe", "ignore", "pipe"] }
|
|
1946
|
+
);
|
|
1762
1947
|
let stderr = "";
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
});
|
|
1766
|
-
proc.on("close", (code) => {
|
|
1948
|
+
ffmpeg.stderr.on("data", (d) => stderr += d.toString());
|
|
1949
|
+
ffmpeg.on("close", (code) => {
|
|
1767
1950
|
if (code === 0) {
|
|
1768
1951
|
resolve2();
|
|
1769
1952
|
} else {
|
|
@@ -1775,7 +1958,7 @@ function runFfmpeg(args) {
|
|
|
1775
1958
|
);
|
|
1776
1959
|
}
|
|
1777
1960
|
});
|
|
1778
|
-
|
|
1961
|
+
ffmpeg.on("error", (err) => {
|
|
1779
1962
|
if (err.code === "ENOENT") {
|
|
1780
1963
|
reject(
|
|
1781
1964
|
new Error(
|
|
@@ -1786,6 +1969,15 @@ function runFfmpeg(args) {
|
|
|
1786
1969
|
reject(err);
|
|
1787
1970
|
}
|
|
1788
1971
|
});
|
|
1972
|
+
(async () => {
|
|
1973
|
+
for (const frame of frames) {
|
|
1974
|
+
const raw = await sharp9(frame.buffer).flatten({ background: { r: 0, g: 0, b: 0 } }).raw().toBuffer();
|
|
1975
|
+
if (!ffmpeg.stdin.write(raw)) {
|
|
1976
|
+
await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
ffmpeg.stdin.end();
|
|
1980
|
+
})().catch(reject);
|
|
1789
1981
|
});
|
|
1790
1982
|
}
|
|
1791
1983
|
async function savePngSequence(frames, config) {
|
|
@@ -2016,7 +2208,7 @@ effects:
|
|
|
2016
2208
|
output:
|
|
2017
2209
|
format: mp4
|
|
2018
2210
|
fps: 30
|
|
2019
|
-
|
|
2211
|
+
preset: balanced # social | balanced | archive
|
|
2020
2212
|
|
|
2021
2213
|
steps:
|
|
2022
2214
|
- name: "Open app"
|
|
@@ -2194,7 +2386,7 @@ program.command("demo").description("Record a demo video of the Clipwise showcas
|
|
|
2194
2386
|
width: outWidth,
|
|
2195
2387
|
height: outHeight,
|
|
2196
2388
|
fps: 30,
|
|
2197
|
-
|
|
2389
|
+
preset: "social",
|
|
2198
2390
|
outputDir: options.output,
|
|
2199
2391
|
filename: `clipwise-demo-${device}`
|
|
2200
2392
|
},
|