clipwise 0.6.0 → 0.7.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 +31 -31
- package/README.md +72 -38
- package/dist/cli/index.js +480 -106
- package/dist/compose/frame-worker.js +85 -76
- package/dist/index.d.ts +477 -34
- package/dist/index.js +582 -129
- package/package.json +1 -1
- package/skills/clipwise.md +51 -13
package/dist/index.js
CHANGED
|
@@ -106,6 +106,12 @@ var ClipwiseRecorder = class {
|
|
|
106
106
|
keystrokeSessionId = 0;
|
|
107
107
|
currentStepIndex = 0;
|
|
108
108
|
isScrolling = false;
|
|
109
|
+
isWaitingPhase = false;
|
|
110
|
+
currentDisplaySpeed;
|
|
111
|
+
/** Tracks active infinite CSS animations (spinners/loaders). Count > 0 → loading state. */
|
|
112
|
+
activeLoaderAnimations = /* @__PURE__ */ new Set();
|
|
113
|
+
/** Whether auto-loader detection is active (derived from smartSpeed.enabled). */
|
|
114
|
+
loaderDetectionEnabled = false;
|
|
109
115
|
cursorPosition = { x: 0, y: 0 };
|
|
110
116
|
viewport = { width: 1280, height: 800 };
|
|
111
117
|
deviceScaleFactor = 1;
|
|
@@ -133,6 +139,7 @@ var ClipwiseRecorder = class {
|
|
|
133
139
|
};
|
|
134
140
|
this.targetFps = scenario.output.fps;
|
|
135
141
|
this.cursorSpeed = scenario.effects.cursor.speed;
|
|
142
|
+
this.loaderDetectionEnabled = scenario.effects.smartSpeed?.enabled ?? false;
|
|
136
143
|
this.browser = await chromium.launch({ headless: true });
|
|
137
144
|
this.context = await this.browser.newContext({
|
|
138
145
|
viewport: this.viewport
|
|
@@ -145,6 +152,9 @@ var ClipwiseRecorder = class {
|
|
|
145
152
|
this.keystrokeSessionId = 0;
|
|
146
153
|
this.currentStepIndex = 0;
|
|
147
154
|
this.isScrolling = false;
|
|
155
|
+
this.isWaitingPhase = false;
|
|
156
|
+
this.currentDisplaySpeed = void 0;
|
|
157
|
+
this.activeLoaderAnimations.clear();
|
|
148
158
|
this.cursorPosition = { x: 0, y: 0 };
|
|
149
159
|
this.isCapturing = false;
|
|
150
160
|
this.firstContentTimestamp = 0;
|
|
@@ -168,13 +178,15 @@ var ClipwiseRecorder = class {
|
|
|
168
178
|
const buffer = Buffer.from(event.data, "base64");
|
|
169
179
|
this.dedupStats.received++;
|
|
170
180
|
const signature = buffer.subarray(0, DEDUP_SIGNATURE_BYTES);
|
|
171
|
-
const
|
|
181
|
+
const isInLoadingState = this.isWaitingPhase || this.loaderDetectionEnabled && this.activeLoaderAnimations.size > 0;
|
|
182
|
+
const isDuplicate = !isInLoadingState && this.lastFrameSignature !== null && this.lastFrameSignature.length === signature.length && this.lastFrameSignature.equals(signature);
|
|
172
183
|
if (isDuplicate) {
|
|
173
184
|
this.dedupStats.skipped++;
|
|
174
185
|
} else {
|
|
175
186
|
this.lastFrameSignature = Buffer.from(signature);
|
|
176
187
|
const captureTime = Date.now();
|
|
177
|
-
const
|
|
188
|
+
const isLoading = this.isWaitingPhase || this.loaderDetectionEnabled && this.activeLoaderAnimations.size > 0;
|
|
189
|
+
const rawFrame = { buffer, timestamp: captureTime, stepIndex: this.currentStepIndex, isScrolling: this.isScrolling, isWaitingPhase: isLoading, displaySpeed: this.currentDisplaySpeed };
|
|
178
190
|
this.rawFrames.push(rawFrame);
|
|
179
191
|
this.dedupStats.stored++;
|
|
180
192
|
if (this.frameChannel && this.firstContentTimestamp > 0) {
|
|
@@ -191,6 +203,23 @@ var ClipwiseRecorder = class {
|
|
|
191
203
|
});
|
|
192
204
|
}
|
|
193
205
|
);
|
|
206
|
+
if (this.loaderDetectionEnabled) {
|
|
207
|
+
this.cdpClient.on("Animation.animationStarted", (event) => {
|
|
208
|
+
const anim = event.animation;
|
|
209
|
+
const iterations = anim?.source?.iterations ?? 0;
|
|
210
|
+
const isInfinite = iterations === -1 || iterations > 100;
|
|
211
|
+
const animName = anim?.name || "";
|
|
212
|
+
const isLoaderPattern = /spin|rotate|pulse|bounce|loading|skeleton|shimmer/i.test(animName);
|
|
213
|
+
if (anim?.type === "CSSAnimation" && isInfinite && isLoaderPattern) {
|
|
214
|
+
this.activeLoaderAnimations.add(anim.id);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
this.cdpClient.on("Animation.animationCanceled", (event) => {
|
|
218
|
+
this.activeLoaderAnimations.delete(event.id);
|
|
219
|
+
});
|
|
220
|
+
await this.cdpClient.send("Animation.enable").catch(() => {
|
|
221
|
+
});
|
|
222
|
+
}
|
|
194
223
|
await this.cdpClient.send("Page.startScreencast", {
|
|
195
224
|
format: "png",
|
|
196
225
|
maxWidth: this.viewport.width * this.deviceScaleFactor,
|
|
@@ -385,7 +414,9 @@ var ClipwiseRecorder = class {
|
|
|
385
414
|
deviceScaleFactor: this.deviceScaleFactor,
|
|
386
415
|
stepIndex: raw.stepIndex,
|
|
387
416
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
388
|
-
isScrolling: raw.isScrolling || void 0
|
|
417
|
+
isScrolling: raw.isScrolling || void 0,
|
|
418
|
+
isWaitingPhase: raw.isWaitingPhase || void 0,
|
|
419
|
+
displaySpeed: raw.displaySpeed
|
|
389
420
|
};
|
|
390
421
|
}
|
|
391
422
|
/**
|
|
@@ -504,6 +535,7 @@ var ClipwiseRecorder = class {
|
|
|
504
535
|
await this.page.click(action.selector);
|
|
505
536
|
this.keystrokeSessionId++;
|
|
506
537
|
const currentSessionId = this.keystrokeSessionId;
|
|
538
|
+
let lastClickRefresh = Date.now();
|
|
507
539
|
let typeRepaintToggle = false;
|
|
508
540
|
for (const char of action.text) {
|
|
509
541
|
await this.page.keyboard.type(char);
|
|
@@ -515,7 +547,19 @@ var ClipwiseRecorder = class {
|
|
|
515
547
|
sessionId: currentSessionId
|
|
516
548
|
});
|
|
517
549
|
await new Promise((resolve) => setTimeout(resolve, action.delay));
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
if (now - lastClickRefresh >= 400) {
|
|
552
|
+
this.clickTimeline.push({
|
|
553
|
+
position: { ...inputTarget },
|
|
554
|
+
timestamp: now
|
|
555
|
+
});
|
|
556
|
+
lastClickRefresh = now;
|
|
557
|
+
}
|
|
518
558
|
}
|
|
559
|
+
this.clickTimeline.push({
|
|
560
|
+
position: { ...inputTarget },
|
|
561
|
+
timestamp: Date.now()
|
|
562
|
+
});
|
|
519
563
|
break;
|
|
520
564
|
}
|
|
521
565
|
case "scroll": {
|
|
@@ -610,6 +654,60 @@ var ClipwiseRecorder = class {
|
|
|
610
654
|
}
|
|
611
655
|
break;
|
|
612
656
|
}
|
|
657
|
+
case "smartWait": {
|
|
658
|
+
this.isWaitingPhase = true;
|
|
659
|
+
this.currentDisplaySpeed = action.displaySpeed;
|
|
660
|
+
try {
|
|
661
|
+
let conditionPromise;
|
|
662
|
+
switch (action.until) {
|
|
663
|
+
case "networkIdle":
|
|
664
|
+
conditionPromise = this.page.waitForLoadState("networkidle", { timeout: action.timeout });
|
|
665
|
+
break;
|
|
666
|
+
case "selector":
|
|
667
|
+
conditionPromise = action.selector ? this.page.locator(action.selector).first().waitFor({ state: "visible", timeout: action.timeout }) : Promise.resolve();
|
|
668
|
+
break;
|
|
669
|
+
case "domStable":
|
|
670
|
+
conditionPromise = this.page.waitForFunction(
|
|
671
|
+
() => new Promise((resolve) => {
|
|
672
|
+
let timer;
|
|
673
|
+
const observer = new MutationObserver(() => {
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
timer = setTimeout(() => {
|
|
676
|
+
observer.disconnect();
|
|
677
|
+
resolve(true);
|
|
678
|
+
}, 500);
|
|
679
|
+
});
|
|
680
|
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
|
681
|
+
timer = setTimeout(() => {
|
|
682
|
+
observer.disconnect();
|
|
683
|
+
resolve(true);
|
|
684
|
+
}, 500);
|
|
685
|
+
}),
|
|
686
|
+
void 0,
|
|
687
|
+
{ timeout: action.timeout }
|
|
688
|
+
);
|
|
689
|
+
break;
|
|
690
|
+
default:
|
|
691
|
+
conditionPromise = Promise.resolve();
|
|
692
|
+
}
|
|
693
|
+
let waitDone = false;
|
|
694
|
+
const repaintLoop = (async () => {
|
|
695
|
+
let toggle = false;
|
|
696
|
+
while (!waitDone && this.isCapturing && this.page) {
|
|
697
|
+
await this.forceRepaint(toggle);
|
|
698
|
+
toggle = !toggle;
|
|
699
|
+
await new Promise((r) => setTimeout(r, REPAINT_INTERVAL_MS));
|
|
700
|
+
}
|
|
701
|
+
})();
|
|
702
|
+
await conditionPromise;
|
|
703
|
+
waitDone = true;
|
|
704
|
+
await repaintLoop;
|
|
705
|
+
} finally {
|
|
706
|
+
this.isWaitingPhase = false;
|
|
707
|
+
this.currentDisplaySpeed = void 0;
|
|
708
|
+
}
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
613
711
|
}
|
|
614
712
|
await this.waitWithRepaints(ACTION_GAP_MS);
|
|
615
713
|
}
|
|
@@ -729,7 +827,9 @@ var ClipwiseRecorder = class {
|
|
|
729
827
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
730
828
|
stepIndex: raw.stepIndex,
|
|
731
829
|
// use per-frame step index captured at event time
|
|
732
|
-
isScrolling: raw.isScrolling || void 0
|
|
830
|
+
isScrolling: raw.isScrolling || void 0,
|
|
831
|
+
isWaitingPhase: raw.isWaitingPhase || void 0,
|
|
832
|
+
displaySpeed: raw.displaySpeed
|
|
733
833
|
};
|
|
734
834
|
});
|
|
735
835
|
}
|
|
@@ -1089,6 +1189,70 @@ async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight, dp
|
|
|
1089
1189
|
|
|
1090
1190
|
// src/effects/cursor.ts
|
|
1091
1191
|
import sharp2 from "sharp";
|
|
1192
|
+
function buildCursorOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1193
|
+
if (!config.enabled) return null;
|
|
1194
|
+
const size = Math.round(config.size * dpr);
|
|
1195
|
+
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1196
|
+
const tipOffsetX = Math.round(4 / 24 * size);
|
|
1197
|
+
const px = Math.round(position.x * dpr);
|
|
1198
|
+
const py = Math.round(position.y * dpr);
|
|
1199
|
+
const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
|
|
1200
|
+
const top = Math.max(0, Math.min(py, frameHeight - size));
|
|
1201
|
+
return { input: Buffer.from(cursorSvg), left, top };
|
|
1202
|
+
}
|
|
1203
|
+
function buildClickRippleOverlay(position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
1204
|
+
if (!config.enabled || !config.clickEffect) return null;
|
|
1205
|
+
const radius = config.clickRadius * dpr;
|
|
1206
|
+
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
1207
|
+
const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
|
|
1208
|
+
const rippleSize = Math.ceil(radius * 2 + 4);
|
|
1209
|
+
const px = Math.round(position.x * dpr);
|
|
1210
|
+
const py = Math.round(position.y * dpr);
|
|
1211
|
+
const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
|
|
1212
|
+
const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
|
|
1213
|
+
return { input: Buffer.from(rippleSvg), left, top };
|
|
1214
|
+
}
|
|
1215
|
+
function buildHighlightOverlay(position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1216
|
+
if (!config.enabled || !config.highlight) return null;
|
|
1217
|
+
const r = config.highlightRadius * dpr;
|
|
1218
|
+
const size = Math.ceil(r * 2 + 4);
|
|
1219
|
+
const cx = size / 2;
|
|
1220
|
+
const cy = size / 2;
|
|
1221
|
+
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" shape-rendering="geometricPrecision">
|
|
1222
|
+
<defs>
|
|
1223
|
+
<radialGradient id="glow">
|
|
1224
|
+
<stop offset="0%" stop-color="${config.highlightColor}" />
|
|
1225
|
+
<stop offset="70%" stop-color="${config.highlightColor}" />
|
|
1226
|
+
<stop offset="100%" stop-color="transparent" />
|
|
1227
|
+
</radialGradient>
|
|
1228
|
+
</defs>
|
|
1229
|
+
<circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
|
|
1230
|
+
</svg>`;
|
|
1231
|
+
const px = Math.round(position.x * dpr);
|
|
1232
|
+
const py = Math.round(position.y * dpr);
|
|
1233
|
+
const left = Math.max(0, Math.min(px - Math.round(cx), frameWidth - size));
|
|
1234
|
+
const top = Math.max(0, Math.min(py - Math.round(cy), frameHeight - size));
|
|
1235
|
+
return { input: Buffer.from(highlightSvg), left, top };
|
|
1236
|
+
}
|
|
1237
|
+
function buildTrailOverlay(positions, config, frameWidth, frameHeight, dpr = 1) {
|
|
1238
|
+
if (!config.enabled || !config.trail || positions.length < 2) return null;
|
|
1239
|
+
const segments = [];
|
|
1240
|
+
for (let i = 1; i < positions.length; i++) {
|
|
1241
|
+
const opacity = i / positions.length * 0.6;
|
|
1242
|
+
const strokeWidth = (1 + i / positions.length * 2) * dpr;
|
|
1243
|
+
const p1 = positions[i - 1];
|
|
1244
|
+
const p2 = positions[i];
|
|
1245
|
+
segments.push(
|
|
1246
|
+
`<line x1="${p1.x * dpr}" y1="${p1.y * dpr}" x2="${p2.x * dpr}" y2="${p2.y * dpr}"
|
|
1247
|
+
stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
|
|
1248
|
+
stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision">
|
|
1252
|
+
${segments.join("\n ")}
|
|
1253
|
+
</svg>`;
|
|
1254
|
+
return { input: Buffer.from(trailSvg), left: 0, top: 0 };
|
|
1255
|
+
}
|
|
1092
1256
|
function buildCursorSvg(size, color) {
|
|
1093
1257
|
const s = size;
|
|
1094
1258
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" shape-rendering="geometricPrecision">
|
|
@@ -1108,31 +1272,6 @@ function buildClickRippleSvg(radius, color, progress) {
|
|
|
1108
1272
|
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
1109
1273
|
</svg>`;
|
|
1110
1274
|
}
|
|
1111
|
-
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1112
|
-
if (!config.enabled) return frameBuffer;
|
|
1113
|
-
const size = Math.round(config.size * dpr);
|
|
1114
|
-
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1115
|
-
const cursorBuffer = Buffer.from(cursorSvg);
|
|
1116
|
-
const tipOffsetX = Math.round(4 / 24 * size);
|
|
1117
|
-
const px = Math.round(position.x * dpr);
|
|
1118
|
-
const py = Math.round(position.y * dpr);
|
|
1119
|
-
const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
|
|
1120
|
-
const top = Math.max(0, Math.min(py, frameHeight - size));
|
|
1121
|
-
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
1122
|
-
}
|
|
1123
|
-
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
1124
|
-
if (!config.enabled || !config.clickEffect) return frameBuffer;
|
|
1125
|
-
const radius = config.clickRadius * dpr;
|
|
1126
|
-
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
1127
|
-
const rippleSvg = buildClickRippleSvg(radius, config.clickColor, clampedProgress);
|
|
1128
|
-
const rippleBuffer = Buffer.from(rippleSvg);
|
|
1129
|
-
const rippleSize = Math.ceil(radius * 2 + 4);
|
|
1130
|
-
const px = Math.round(position.x * dpr);
|
|
1131
|
-
const py = Math.round(position.y * dpr);
|
|
1132
|
-
const left = Math.max(0, Math.min(px - Math.round(rippleSize / 2), frameWidth - rippleSize));
|
|
1133
|
-
const top = Math.max(0, Math.min(py - Math.round(rippleSize / 2), frameHeight - rippleSize));
|
|
1134
|
-
return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
|
|
1135
|
-
}
|
|
1136
1275
|
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight, dpr = 1) {
|
|
1137
1276
|
if (!config.enabled || !config.highlight) return frameBuffer;
|
|
1138
1277
|
const r = config.highlightRadius * dpr;
|
|
@@ -1269,6 +1408,51 @@ function lerpZoom(current, target, factor) {
|
|
|
1269
1408
|
function easeInOutCubic2(t) {
|
|
1270
1409
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
1271
1410
|
}
|
|
1411
|
+
function springEasing(t) {
|
|
1412
|
+
const omega = 6.5;
|
|
1413
|
+
const raw = 1 - (1 + omega * t) * Math.exp(-omega * t);
|
|
1414
|
+
const endVal = 1 - (1 + omega) * Math.exp(-omega);
|
|
1415
|
+
return Math.max(0, Math.min(1, raw / endVal));
|
|
1416
|
+
}
|
|
1417
|
+
function applyZoomEasing(t, easing = "cubic") {
|
|
1418
|
+
return easing === "spring" ? springEasing(t) : easeInOutCubic2(t);
|
|
1419
|
+
}
|
|
1420
|
+
function mergeClickZones(clickLookup, mergeGap) {
|
|
1421
|
+
if (clickLookup.length === 0) return [];
|
|
1422
|
+
const zones = [];
|
|
1423
|
+
let start = clickLookup[0];
|
|
1424
|
+
let end = clickLookup[0];
|
|
1425
|
+
for (let i = 1; i < clickLookup.length; i++) {
|
|
1426
|
+
if (clickLookup[i] - end <= mergeGap) {
|
|
1427
|
+
end = clickLookup[i];
|
|
1428
|
+
} else {
|
|
1429
|
+
zones.push({ start, end });
|
|
1430
|
+
start = clickLookup[i];
|
|
1431
|
+
end = clickLookup[i];
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
zones.push({ start, end });
|
|
1435
|
+
return zones;
|
|
1436
|
+
}
|
|
1437
|
+
function calculateAdaptiveZoomFromZones(zones, currentIndex, maxScale, transitionFrames, easing = "spring") {
|
|
1438
|
+
if (maxScale <= 1 || zones.length === 0) return 1;
|
|
1439
|
+
let lo = 0;
|
|
1440
|
+
let hi = zones.length;
|
|
1441
|
+
while (lo < hi) {
|
|
1442
|
+
const mid = lo + hi >>> 1;
|
|
1443
|
+
if (zones[mid].end < currentIndex) lo = mid + 1;
|
|
1444
|
+
else hi = mid;
|
|
1445
|
+
}
|
|
1446
|
+
if (lo < zones.length && currentIndex >= zones[lo].start && currentIndex <= zones[lo].end) {
|
|
1447
|
+
return maxScale;
|
|
1448
|
+
}
|
|
1449
|
+
const distBefore = lo > 0 ? currentIndex - zones[lo - 1].end : Infinity;
|
|
1450
|
+
const distAfter = lo < zones.length ? zones[lo].start - currentIndex : Infinity;
|
|
1451
|
+
const minDistance = Math.min(distBefore, distAfter);
|
|
1452
|
+
if (minDistance > transitionFrames) return 1;
|
|
1453
|
+
const t = 1 - minDistance / transitionFrames;
|
|
1454
|
+
return 1 + (maxScale - 1) * applyZoomEasing(t, easing);
|
|
1455
|
+
}
|
|
1272
1456
|
|
|
1273
1457
|
// src/effects/background.ts
|
|
1274
1458
|
import sharp4 from "sharp";
|
|
@@ -1515,73 +1699,87 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1515
1699
|
cursorTrail: context?.cursorTrail ?? []
|
|
1516
1700
|
};
|
|
1517
1701
|
const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1518
|
-
const
|
|
1702
|
+
const sl = context?.staticLayers;
|
|
1703
|
+
const preZoomOverlays = [];
|
|
1704
|
+
const hasBrowserChrome = effects.deviceFrame.enabled && effects.deviceFrame.type === "browser" && sl?.browserChromePng;
|
|
1705
|
+
const extTop = hasBrowserChrome ? sl.browserChromeHeight : 0;
|
|
1706
|
+
const extWidth = width;
|
|
1707
|
+
const extHeight = height + extTop;
|
|
1708
|
+
if (hasBrowserChrome) {
|
|
1709
|
+
preZoomOverlays.push({ input: sl.browserChromePng, left: 0, top: 0 });
|
|
1710
|
+
}
|
|
1711
|
+
const withExtFrameOffset = (pos) => ({
|
|
1519
1712
|
x: pos.x + frameOffset.left / Math.max(1, dpr),
|
|
1520
1713
|
y: pos.y + frameOffset.top / Math.max(1, dpr)
|
|
1521
1714
|
});
|
|
1522
|
-
if (effects.deviceFrame.enabled) {
|
|
1523
|
-
const sl2 = ctx.staticLayers;
|
|
1524
|
-
if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
|
|
1525
|
-
buffer = await sharp7(buffer).extend({
|
|
1526
|
-
top: sl2.browserChromeHeight,
|
|
1527
|
-
bottom: 0,
|
|
1528
|
-
left: 0,
|
|
1529
|
-
right: 0,
|
|
1530
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
1531
|
-
}).composite([{ input: sl2.browserChromePng, left: 0, top: 0 }]).png().toBuffer();
|
|
1532
|
-
} else {
|
|
1533
|
-
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1534
|
-
}
|
|
1535
|
-
const meta2 = await sharp7(buffer).metadata();
|
|
1536
|
-
width = meta2.width ?? width;
|
|
1537
|
-
height = meta2.height ?? height;
|
|
1538
|
-
}
|
|
1539
1715
|
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
withFrameOffset(frame.cursorPosition),
|
|
1716
|
+
const overlay = buildHighlightOverlay(
|
|
1717
|
+
withExtFrameOffset(frame.cursorPosition),
|
|
1543
1718
|
effects.cursor,
|
|
1544
|
-
|
|
1545
|
-
|
|
1719
|
+
extWidth,
|
|
1720
|
+
extHeight,
|
|
1546
1721
|
dpr
|
|
1547
1722
|
);
|
|
1723
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1548
1724
|
}
|
|
1549
1725
|
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
ctx.cursorTrail.map(withFrameOffset),
|
|
1726
|
+
const overlay = buildTrailOverlay(
|
|
1727
|
+
ctx.cursorTrail.map(withExtFrameOffset),
|
|
1553
1728
|
effects.cursor,
|
|
1554
|
-
|
|
1555
|
-
|
|
1729
|
+
extWidth,
|
|
1730
|
+
extHeight,
|
|
1556
1731
|
dpr
|
|
1557
1732
|
);
|
|
1733
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1558
1734
|
}
|
|
1559
1735
|
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
withFrameOffset(frame.cursorPosition),
|
|
1736
|
+
const overlay = buildCursorOverlay(
|
|
1737
|
+
withExtFrameOffset(frame.cursorPosition),
|
|
1563
1738
|
effects.cursor,
|
|
1564
|
-
|
|
1565
|
-
|
|
1739
|
+
extWidth,
|
|
1740
|
+
extHeight,
|
|
1566
1741
|
dpr
|
|
1567
1742
|
);
|
|
1743
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1568
1744
|
}
|
|
1569
1745
|
if (effects.cursor.enabled && effects.cursor.clickEffect && frame.clickPosition) {
|
|
1570
1746
|
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
withFrameOffset(frame.clickPosition),
|
|
1747
|
+
const overlay = buildClickRippleOverlay(
|
|
1748
|
+
withExtFrameOffset(frame.clickPosition),
|
|
1574
1749
|
effects.cursor,
|
|
1575
1750
|
progress,
|
|
1576
|
-
|
|
1577
|
-
|
|
1751
|
+
extWidth,
|
|
1752
|
+
extHeight,
|
|
1578
1753
|
dpr
|
|
1579
1754
|
);
|
|
1755
|
+
if (overlay) preZoomOverlays.push(overlay);
|
|
1756
|
+
}
|
|
1757
|
+
if (hasBrowserChrome || preZoomOverlays.length > 0) {
|
|
1758
|
+
let pipeline = sharp7(buffer);
|
|
1759
|
+
if (hasBrowserChrome) {
|
|
1760
|
+
pipeline = pipeline.extend({
|
|
1761
|
+
top: extTop,
|
|
1762
|
+
bottom: 0,
|
|
1763
|
+
left: 0,
|
|
1764
|
+
right: 0,
|
|
1765
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
if (preZoomOverlays.length > 0) {
|
|
1769
|
+
pipeline = pipeline.composite(preZoomOverlays);
|
|
1770
|
+
}
|
|
1771
|
+
buffer = await pipeline.png().toBuffer();
|
|
1772
|
+
width = extWidth;
|
|
1773
|
+
height = extHeight;
|
|
1774
|
+
} else if (effects.deviceFrame.enabled) {
|
|
1775
|
+
buffer = await applyDeviceFrame(buffer, effects.deviceFrame, width, height, dpr);
|
|
1776
|
+
const devMeta = await sharp7(buffer).metadata();
|
|
1777
|
+
width = devMeta.width ?? width;
|
|
1778
|
+
height = devMeta.height ?? height;
|
|
1580
1779
|
}
|
|
1581
1780
|
const scale = ctx.zoomScale;
|
|
1582
1781
|
if (effects.zoom.enabled && scale > 1) {
|
|
1583
|
-
const
|
|
1584
|
-
const rawFocus = followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1782
|
+
const rawFocus = ctx.focusOverride ?? (effects.zoom.autoZoom.followCursor ? frame.cursorPosition ?? frame.clickPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 } : frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 });
|
|
1585
1783
|
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1586
1784
|
const focusPoint = {
|
|
1587
1785
|
x: rawFocus.x * dpr + offset.left,
|
|
@@ -1600,7 +1798,6 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1600
1798
|
dpr
|
|
1601
1799
|
);
|
|
1602
1800
|
}
|
|
1603
|
-
const sl = ctx.staticLayers;
|
|
1604
1801
|
if (sl) {
|
|
1605
1802
|
const padding = effects.background.padding;
|
|
1606
1803
|
const contentWidth = output.width - padding * 2;
|
|
@@ -1809,6 +2006,7 @@ function mergeStepEffects(global, stepIndex, steps) {
|
|
|
1809
2006
|
background: override.background ? { ...global.background, ...override.background } : global.background,
|
|
1810
2007
|
deviceFrame: override.deviceFrame ? { ...global.deviceFrame, ...override.deviceFrame } : global.deviceFrame,
|
|
1811
2008
|
speedRamp: override.speedRamp ? { ...global.speedRamp, ...override.speedRamp } : global.speedRamp,
|
|
2009
|
+
smartSpeed: override.smartSpeed ? { ...global.smartSpeed, ...override.smartSpeed } : global.smartSpeed,
|
|
1812
2010
|
keystroke: override.keystroke ? { ...global.keystroke, ...override.keystroke } : global.keystroke,
|
|
1813
2011
|
watermark: override.watermark ? { ...global.watermark, ...override.watermark } : global.watermark
|
|
1814
2012
|
};
|
|
@@ -1862,7 +2060,10 @@ var CanvasRenderer = class {
|
|
|
1862
2060
|
if (frames.length === 0) return [];
|
|
1863
2061
|
let processFrames = frames;
|
|
1864
2062
|
if (this.effects.speedRamp.enabled) {
|
|
1865
|
-
processFrames = this.applySpeedRamp(
|
|
2063
|
+
processFrames = this.applySpeedRamp(processFrames);
|
|
2064
|
+
}
|
|
2065
|
+
if (this.effects.smartSpeed.enabled) {
|
|
2066
|
+
processFrames = this.applySmartSpeed(processFrames);
|
|
1866
2067
|
}
|
|
1867
2068
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
1868
2069
|
const cpuCount = os.cpus().length;
|
|
@@ -1958,11 +2159,27 @@ var CanvasRenderer = class {
|
|
|
1958
2159
|
this.effects.zoom.scale,
|
|
1959
2160
|
this.effects.zoom.intensity
|
|
1960
2161
|
);
|
|
2162
|
+
const mergeGap = Math.round(transitionFrames * 1.5);
|
|
2163
|
+
const zoomZones = this.effects.zoom.enabled ? mergeClickZones(clickLookup, mergeGap) : [];
|
|
2164
|
+
const zoomEasing = this.effects.zoom.easing === "spring" ? "spring" : "cubic";
|
|
2165
|
+
const clickPositions = /* @__PURE__ */ new Map();
|
|
2166
|
+
if (this.effects.zoom.enabled) {
|
|
2167
|
+
for (const ci of clickLookup) {
|
|
2168
|
+
const pos = frames[ci].clickPosition;
|
|
2169
|
+
if (pos) clickPositions.set(ci, pos);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
1961
2172
|
for (let i = 0; i < frames.length; i++) {
|
|
1962
2173
|
const frame = frames[i];
|
|
1963
2174
|
let zoomScale = 1;
|
|
1964
2175
|
if (this.effects.zoom.enabled) {
|
|
1965
|
-
zoomScale =
|
|
2176
|
+
zoomScale = zoomZones.length > 0 ? calculateAdaptiveZoomFromZones(
|
|
2177
|
+
zoomZones,
|
|
2178
|
+
i,
|
|
2179
|
+
effectiveScale,
|
|
2180
|
+
transitionFrames,
|
|
2181
|
+
zoomEasing
|
|
2182
|
+
) : calculateAdaptiveZoomFromLookup(
|
|
1966
2183
|
clickLookup,
|
|
1967
2184
|
i,
|
|
1968
2185
|
effectiveScale,
|
|
@@ -1980,10 +2197,56 @@ var CanvasRenderer = class {
|
|
|
1980
2197
|
trail.push(frames[j].cursorPosition);
|
|
1981
2198
|
}
|
|
1982
2199
|
}
|
|
1983
|
-
|
|
2200
|
+
let focusOverride;
|
|
2201
|
+
if (zoomScale > 1 && zoomZones.length > 0 && clickLookup.length > 1) {
|
|
2202
|
+
focusOverride = this.interpolateFocusInZone(
|
|
2203
|
+
i,
|
|
2204
|
+
clickLookup,
|
|
2205
|
+
clickPositions,
|
|
2206
|
+
frames
|
|
2207
|
+
);
|
|
2208
|
+
}
|
|
2209
|
+
contexts.push({ zoomScale, clickProgress, cursorTrail: trail, focusOverride });
|
|
1984
2210
|
}
|
|
1985
2211
|
return contexts;
|
|
1986
2212
|
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Interpolate the zoom focus point between adjacent clicks within a zone.
|
|
2215
|
+
*
|
|
2216
|
+
* Without this, the camera jumps instantly from one click position to the
|
|
2217
|
+
* next when a merged zone contains multiple clicks. This method produces
|
|
2218
|
+
* a smooth pan by linearly interpolating between the previous and next
|
|
2219
|
+
* click positions relative to the current frame index.
|
|
2220
|
+
*
|
|
2221
|
+
* Falls back to the nearest click position when the frame is before the
|
|
2222
|
+
* first click or after the last click in the lookup.
|
|
2223
|
+
*/
|
|
2224
|
+
interpolateFocusInZone(frameIndex, clickLookup, clickPositions, frames) {
|
|
2225
|
+
let lo = 0;
|
|
2226
|
+
let hi = clickLookup.length;
|
|
2227
|
+
while (lo < hi) {
|
|
2228
|
+
const mid = lo + hi >>> 1;
|
|
2229
|
+
if (clickLookup[mid] < frameIndex) lo = mid + 1;
|
|
2230
|
+
else hi = mid;
|
|
2231
|
+
}
|
|
2232
|
+
const prevIdx = lo > 0 ? clickLookup[lo - 1] : -1;
|
|
2233
|
+
const nextIdx = lo < clickLookup.length ? clickLookup[lo] : -1;
|
|
2234
|
+
const prevPos = prevIdx >= 0 ? clickPositions.get(prevIdx) : void 0;
|
|
2235
|
+
const nextPos = nextIdx >= 0 ? clickPositions.get(nextIdx) : void 0;
|
|
2236
|
+
if (nextIdx === frameIndex && nextPos) return nextPos;
|
|
2237
|
+
if (prevPos && nextPos && prevIdx < frameIndex && nextIdx > frameIndex) {
|
|
2238
|
+
const span = nextIdx - prevIdx;
|
|
2239
|
+
if (span <= 0) return prevPos;
|
|
2240
|
+
const t = (frameIndex - prevIdx) / span;
|
|
2241
|
+
return {
|
|
2242
|
+
x: prevPos.x + (nextPos.x - prevPos.x) * t,
|
|
2243
|
+
y: prevPos.y + (nextPos.y - prevPos.y) * t
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
if (prevPos) return prevPos;
|
|
2247
|
+
if (nextPos) return nextPos;
|
|
2248
|
+
return void 0;
|
|
2249
|
+
}
|
|
1987
2250
|
/**
|
|
1988
2251
|
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
1989
2252
|
*/
|
|
@@ -2016,6 +2279,69 @@ var CanvasRenderer = class {
|
|
|
2016
2279
|
}
|
|
2017
2280
|
return result;
|
|
2018
2281
|
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Apply smart speed: compress smartWait periods based on per-frame metadata.
|
|
2284
|
+
*
|
|
2285
|
+
* Unlike applySpeedRamp (which uses click proximity heuristics), smartSpeed
|
|
2286
|
+
* reads the `isWaitingPhase` flag set by the recorder during smartWait actions.
|
|
2287
|
+
* Frames in a waiting phase are downsampled by their `displaySpeed` multiplier.
|
|
2288
|
+
*
|
|
2289
|
+
* Streaming-compatible: each frame is independently decidable (no lookahead
|
|
2290
|
+
* needed), so this can run inline during streaming composition.
|
|
2291
|
+
*/
|
|
2292
|
+
applySmartSpeed(frames) {
|
|
2293
|
+
const config = this.effects.smartSpeed;
|
|
2294
|
+
if (!config.enabled) return frames;
|
|
2295
|
+
const transitionMargin = Math.round(
|
|
2296
|
+
this.output.fps * (config.transitionDuration / 1e3)
|
|
2297
|
+
);
|
|
2298
|
+
const segments = [];
|
|
2299
|
+
let segStart = -1;
|
|
2300
|
+
let segSpeed = config.waitSpeed;
|
|
2301
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2302
|
+
if (frames[i].isWaitingPhase) {
|
|
2303
|
+
if (segStart < 0) {
|
|
2304
|
+
segStart = i;
|
|
2305
|
+
segSpeed = frames[i].displaySpeed ?? config.waitSpeed;
|
|
2306
|
+
}
|
|
2307
|
+
} else if (segStart >= 0) {
|
|
2308
|
+
segments.push({ start: segStart, end: i - 1, speed: segSpeed });
|
|
2309
|
+
segStart = -1;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
if (segStart >= 0) segments.push({ start: segStart, end: frames.length - 1, speed: segSpeed });
|
|
2313
|
+
const skipRates = new Array(frames.length).fill(1);
|
|
2314
|
+
for (const seg of segments) {
|
|
2315
|
+
const segLen = seg.end - seg.start + 1;
|
|
2316
|
+
const skipRate = Math.max(1, Math.round(seg.speed));
|
|
2317
|
+
for (let i = seg.start; i <= seg.end; i++) {
|
|
2318
|
+
const fromStart = i - seg.start;
|
|
2319
|
+
const fromEnd = seg.end - i;
|
|
2320
|
+
if (fromStart < transitionMargin || fromEnd < transitionMargin) {
|
|
2321
|
+
skipRates[i] = 1;
|
|
2322
|
+
} else if (segLen < transitionMargin * 3) {
|
|
2323
|
+
skipRates[i] = Math.max(1, Math.round(skipRate / 2));
|
|
2324
|
+
} else {
|
|
2325
|
+
skipRates[i] = skipRate;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
const result = [];
|
|
2330
|
+
let skipCounter = 0;
|
|
2331
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2332
|
+
const rate = skipRates[i];
|
|
2333
|
+
if (rate <= 1) {
|
|
2334
|
+
skipCounter = 0;
|
|
2335
|
+
result.push({ ...frames[i], index: result.length });
|
|
2336
|
+
} else {
|
|
2337
|
+
skipCounter++;
|
|
2338
|
+
if (skipCounter % rate === 1) {
|
|
2339
|
+
result.push({ ...frames[i], index: result.length });
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
return result;
|
|
2344
|
+
}
|
|
2019
2345
|
// ─── Online streaming pipeline (Phase 3-B) ─────────────────────────────────
|
|
2020
2346
|
/**
|
|
2021
2347
|
* Returns true when no effect requires the full frame array upfront.
|
|
@@ -2045,19 +2371,56 @@ var CanvasRenderer = class {
|
|
|
2045
2371
|
* using the same applyTransitionsToStream() logic as composeStream().
|
|
2046
2372
|
*/
|
|
2047
2373
|
async *composeStreamOnline(source) {
|
|
2374
|
+
const filteredSource = this.effects.smartSpeed.enabled ? this.filterSmartSpeedInline(source) : source;
|
|
2048
2375
|
const hasFadeTransitions = this.steps.some((s) => s.transition !== "none");
|
|
2049
2376
|
if (!hasFadeTransitions) {
|
|
2050
2377
|
const cpuCount = os.cpus().length;
|
|
2051
2378
|
const workerCount = Math.min(cpuCount, 8);
|
|
2052
|
-
yield* this.streamOnlineWithWorkers(
|
|
2379
|
+
yield* this.streamOnlineWithWorkers(filteredSource, workerCount);
|
|
2053
2380
|
return;
|
|
2054
2381
|
}
|
|
2055
2382
|
const collected = [];
|
|
2056
|
-
for await (const frame of
|
|
2383
|
+
for await (const frame of filteredSource) {
|
|
2057
2384
|
collected.push(frame);
|
|
2058
2385
|
}
|
|
2059
2386
|
yield* this.composeStream(collected);
|
|
2060
2387
|
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Inline async filter for smartSpeed in streaming pipelines.
|
|
2390
|
+
*
|
|
2391
|
+
* Applies ease-in at the start of a waiting phase: the first
|
|
2392
|
+
* `transitionMargin` frames are kept at normal speed so the loader
|
|
2393
|
+
* is visible, then frames are skipped at displaySpeed rate.
|
|
2394
|
+
* When waiting ends, frames immediately return to normal speed.
|
|
2395
|
+
*/
|
|
2396
|
+
async *filterSmartSpeedInline(source) {
|
|
2397
|
+
const config = this.effects.smartSpeed;
|
|
2398
|
+
const transitionMargin = Math.round(
|
|
2399
|
+
this.output.fps * (config.transitionDuration / 1e3)
|
|
2400
|
+
);
|
|
2401
|
+
let waitFrameCounter = 0;
|
|
2402
|
+
let skipCounter = 0;
|
|
2403
|
+
let outputIndex = 0;
|
|
2404
|
+
for await (const frame of source) {
|
|
2405
|
+
if (frame.isWaitingPhase) {
|
|
2406
|
+
waitFrameCounter++;
|
|
2407
|
+
if (waitFrameCounter <= transitionMargin) {
|
|
2408
|
+
yield { ...frame, index: outputIndex++ };
|
|
2409
|
+
} else {
|
|
2410
|
+
const speed = frame.displaySpeed ?? config.waitSpeed;
|
|
2411
|
+
const skipRate = Math.max(1, Math.round(speed));
|
|
2412
|
+
skipCounter++;
|
|
2413
|
+
if (skipCounter % skipRate === 1) {
|
|
2414
|
+
yield { ...frame, index: outputIndex++ };
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
} else {
|
|
2418
|
+
waitFrameCounter = 0;
|
|
2419
|
+
skipCounter = 0;
|
|
2420
|
+
yield { ...frame, index: outputIndex++ };
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2061
2424
|
/**
|
|
2062
2425
|
* Worker-pool online streaming: dispatches frame i to a worker as soon as
|
|
2063
2426
|
* frame i + transitionFrames has arrived from the source.
|
|
@@ -2202,7 +2565,10 @@ var CanvasRenderer = class {
|
|
|
2202
2565
|
if (frames.length === 0) return;
|
|
2203
2566
|
let processFrames = frames;
|
|
2204
2567
|
if (this.effects.speedRamp.enabled) {
|
|
2205
|
-
processFrames = this.applySpeedRamp(
|
|
2568
|
+
processFrames = this.applySpeedRamp(processFrames);
|
|
2569
|
+
}
|
|
2570
|
+
if (this.effects.smartSpeed.enabled) {
|
|
2571
|
+
processFrames = this.applySmartSpeed(processFrames);
|
|
2206
2572
|
}
|
|
2207
2573
|
const contexts = this.calculateFrameContexts(processFrames);
|
|
2208
2574
|
const windows = this.getTransitionWindows(processFrames);
|
|
@@ -2445,9 +2811,9 @@ import { tmpdir } from "os";
|
|
|
2445
2811
|
import { spawn } from "child_process";
|
|
2446
2812
|
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
2447
2813
|
var ENCODING_PRESETS = {
|
|
2448
|
-
social: { crf: 22, vtQuality: 75 },
|
|
2449
|
-
balanced: { crf: 18, vtQuality: 85 },
|
|
2450
|
-
archive: { crf: 13, vtQuality: 92 }
|
|
2814
|
+
social: { crf: 22, vtQuality: 75, x264Preset: "medium" },
|
|
2815
|
+
balanced: { crf: 18, vtQuality: 85, x264Preset: "slow" },
|
|
2816
|
+
archive: { crf: 13, vtQuality: 92, x264Preset: "veryslow" }
|
|
2451
2817
|
};
|
|
2452
2818
|
function resolveEncodingParams(config) {
|
|
2453
2819
|
if (config.preset) return ENCODING_PRESETS[config.preset];
|
|
@@ -2459,24 +2825,106 @@ function resolveEncodingParams(config) {
|
|
|
2459
2825
|
if (config.quality >= 45) return ENCODING_PRESETS.balanced;
|
|
2460
2826
|
return ENCODING_PRESETS.archive;
|
|
2461
2827
|
}
|
|
2462
|
-
var
|
|
2463
|
-
function
|
|
2464
|
-
if (!
|
|
2465
|
-
|
|
2828
|
+
var encoderScanPromise = null;
|
|
2829
|
+
function scanAvailableEncoders() {
|
|
2830
|
+
if (!encoderScanPromise) {
|
|
2831
|
+
encoderScanPromise = new Promise((resolve) => {
|
|
2466
2832
|
const proc = spawn("ffmpeg", ["-encoders"], {
|
|
2467
2833
|
stdio: ["ignore", "pipe", "ignore"]
|
|
2468
2834
|
});
|
|
2469
2835
|
let out = "";
|
|
2470
2836
|
proc.stdout.on("data", (d) => out += d.toString());
|
|
2471
2837
|
proc.on("close", () => {
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2838
|
+
resolve({
|
|
2839
|
+
hevcHw: out.includes("hevc_videotoolbox"),
|
|
2840
|
+
h264Hw: out.includes("h264_videotoolbox"),
|
|
2841
|
+
av1: out.includes("libsvtav1")
|
|
2842
|
+
});
|
|
2475
2843
|
});
|
|
2476
|
-
proc.on("error", () => resolve(
|
|
2844
|
+
proc.on("error", () => resolve({ hevcHw: false, h264Hw: false, av1: false }));
|
|
2477
2845
|
});
|
|
2478
2846
|
}
|
|
2479
|
-
return
|
|
2847
|
+
return encoderScanPromise;
|
|
2848
|
+
}
|
|
2849
|
+
async function detectVideoEncoder(codec = "auto") {
|
|
2850
|
+
const avail = await scanAvailableEncoders();
|
|
2851
|
+
switch (codec) {
|
|
2852
|
+
case "av1":
|
|
2853
|
+
return avail.av1 ? "libsvtav1" : "libx264";
|
|
2854
|
+
case "hevc":
|
|
2855
|
+
return avail.hevcHw ? "hevc_videotoolbox" : "libx264";
|
|
2856
|
+
case "h264":
|
|
2857
|
+
return avail.h264Hw ? "h264_videotoolbox" : "libx264";
|
|
2858
|
+
case "auto":
|
|
2859
|
+
default:
|
|
2860
|
+
if (avail.hevcHw) return "hevc_videotoolbox";
|
|
2861
|
+
if (avail.h264Hw) return "h264_videotoolbox";
|
|
2862
|
+
return "libx264";
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
function buildVideoArgs(encoder, params) {
|
|
2866
|
+
switch (encoder) {
|
|
2867
|
+
case "hevc_videotoolbox":
|
|
2868
|
+
return [
|
|
2869
|
+
"-c:v",
|
|
2870
|
+
"hevc_videotoolbox",
|
|
2871
|
+
"-q:v",
|
|
2872
|
+
String(params.vtQuality),
|
|
2873
|
+
"-pix_fmt",
|
|
2874
|
+
"p010le",
|
|
2875
|
+
"-tag:v",
|
|
2876
|
+
"hvc1",
|
|
2877
|
+
"-color_primaries",
|
|
2878
|
+
"bt709",
|
|
2879
|
+
"-color_trc",
|
|
2880
|
+
"bt709",
|
|
2881
|
+
"-colorspace",
|
|
2882
|
+
"bt709"
|
|
2883
|
+
];
|
|
2884
|
+
case "h264_videotoolbox":
|
|
2885
|
+
return [
|
|
2886
|
+
"-c:v",
|
|
2887
|
+
"h264_videotoolbox",
|
|
2888
|
+
"-q:v",
|
|
2889
|
+
String(params.vtQuality),
|
|
2890
|
+
"-pix_fmt",
|
|
2891
|
+
"yuv420p"
|
|
2892
|
+
];
|
|
2893
|
+
case "libsvtav1":
|
|
2894
|
+
return [
|
|
2895
|
+
"-c:v",
|
|
2896
|
+
"libsvtav1",
|
|
2897
|
+
"-crf",
|
|
2898
|
+
String(params.crf + 12),
|
|
2899
|
+
// AV1 CRF scale differs: +12 ≈ equivalent quality
|
|
2900
|
+
"-preset",
|
|
2901
|
+
"6",
|
|
2902
|
+
// 6 = good speed/quality balance
|
|
2903
|
+
"-svtav1-params",
|
|
2904
|
+
"scm=2",
|
|
2905
|
+
// Screen Content Mode: optimized for UI/text
|
|
2906
|
+
"-pix_fmt",
|
|
2907
|
+
"yuv420p10le"
|
|
2908
|
+
];
|
|
2909
|
+
case "libx264":
|
|
2910
|
+
default:
|
|
2911
|
+
return [
|
|
2912
|
+
"-c:v",
|
|
2913
|
+
"libx264",
|
|
2914
|
+
"-crf",
|
|
2915
|
+
String(params.crf),
|
|
2916
|
+
"-preset",
|
|
2917
|
+
params.x264Preset,
|
|
2918
|
+
"-tune",
|
|
2919
|
+
"animation",
|
|
2920
|
+
"-profile:v",
|
|
2921
|
+
"high",
|
|
2922
|
+
"-level",
|
|
2923
|
+
"4.1",
|
|
2924
|
+
"-pix_fmt",
|
|
2925
|
+
"yuv420p"
|
|
2926
|
+
];
|
|
2927
|
+
}
|
|
2480
2928
|
}
|
|
2481
2929
|
async function encodeGif(frames, config) {
|
|
2482
2930
|
if (frames.length === 0) {
|
|
@@ -2503,7 +2951,7 @@ async function encodeMp4(frames, config, audio) {
|
|
|
2503
2951
|
}
|
|
2504
2952
|
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
2505
2953
|
try {
|
|
2506
|
-
const encoder = await detectVideoEncoder();
|
|
2954
|
+
const encoder = await detectVideoEncoder(config.codec);
|
|
2507
2955
|
const params = resolveEncodingParams(config);
|
|
2508
2956
|
await pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
2509
2957
|
return await readFile(outputPath);
|
|
@@ -2513,39 +2961,7 @@ async function encodeMp4(frames, config, audio) {
|
|
|
2513
2961
|
}
|
|
2514
2962
|
}
|
|
2515
2963
|
async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, audio) {
|
|
2516
|
-
const videoArgs = encoder
|
|
2517
|
-
"-c:v",
|
|
2518
|
-
"hevc_videotoolbox",
|
|
2519
|
-
"-q:v",
|
|
2520
|
-
String(params.vtQuality),
|
|
2521
|
-
"-pix_fmt",
|
|
2522
|
-
"yuv420p",
|
|
2523
|
-
"-tag:v",
|
|
2524
|
-
"hvc1"
|
|
2525
|
-
// required for playback in QuickTime / Apple devices
|
|
2526
|
-
] : encoder === "h264_videotoolbox" ? [
|
|
2527
|
-
"-c:v",
|
|
2528
|
-
"h264_videotoolbox",
|
|
2529
|
-
"-q:v",
|
|
2530
|
-
String(params.vtQuality),
|
|
2531
|
-
"-pix_fmt",
|
|
2532
|
-
"yuv420p"
|
|
2533
|
-
] : [
|
|
2534
|
-
"-c:v",
|
|
2535
|
-
"libx264",
|
|
2536
|
-
"-crf",
|
|
2537
|
-
String(params.crf),
|
|
2538
|
-
"-preset",
|
|
2539
|
-
"medium",
|
|
2540
|
-
"-tune",
|
|
2541
|
-
"stillimage",
|
|
2542
|
-
"-profile:v",
|
|
2543
|
-
"high",
|
|
2544
|
-
"-level",
|
|
2545
|
-
"4.1",
|
|
2546
|
-
"-pix_fmt",
|
|
2547
|
-
"yuv420p"
|
|
2548
|
-
];
|
|
2964
|
+
const videoArgs = buildVideoArgs(encoder, params);
|
|
2549
2965
|
const audioInputArgs = audio ? ["-i", audio.file] : ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"];
|
|
2550
2966
|
const audioFilters = [];
|
|
2551
2967
|
if (audio) {
|
|
@@ -2625,7 +3041,7 @@ async function pipeFramesToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
2625
3041
|
async function encodeMp4Stream(frames, config, audio) {
|
|
2626
3042
|
const outputPath = join(tmpdir(), `clipwise-${Date.now()}-${Math.random().toString(36).slice(2)}.mp4`);
|
|
2627
3043
|
try {
|
|
2628
|
-
const encoder = await detectVideoEncoder();
|
|
3044
|
+
const encoder = await detectVideoEncoder(config.codec);
|
|
2629
3045
|
const params = resolveEncodingParams(config);
|
|
2630
3046
|
await pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, audio);
|
|
2631
3047
|
return await readFile(outputPath);
|
|
@@ -2641,9 +3057,15 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
2641
3057
|
"-q:v",
|
|
2642
3058
|
String(params.vtQuality),
|
|
2643
3059
|
"-pix_fmt",
|
|
2644
|
-
"
|
|
3060
|
+
"p010le",
|
|
2645
3061
|
"-tag:v",
|
|
2646
|
-
"hvc1"
|
|
3062
|
+
"hvc1",
|
|
3063
|
+
"-color_primaries",
|
|
3064
|
+
"bt709",
|
|
3065
|
+
"-color_trc",
|
|
3066
|
+
"bt709",
|
|
3067
|
+
"-colorspace",
|
|
3068
|
+
"bt709"
|
|
2647
3069
|
] : encoder === "h264_videotoolbox" ? [
|
|
2648
3070
|
"-c:v",
|
|
2649
3071
|
"h264_videotoolbox",
|
|
@@ -2657,9 +3079,9 @@ async function pipeStreamToFfmpeg(frames, config, params, encoder, outputPath, a
|
|
|
2657
3079
|
"-crf",
|
|
2658
3080
|
String(params.crf),
|
|
2659
3081
|
"-preset",
|
|
2660
|
-
|
|
3082
|
+
params.x264Preset,
|
|
2661
3083
|
"-tune",
|
|
2662
|
-
"
|
|
3084
|
+
"animation",
|
|
2663
3085
|
"-profile:v",
|
|
2664
3086
|
"high",
|
|
2665
3087
|
"-level",
|
|
@@ -2910,6 +3332,17 @@ var WaitForResponseActionSchema = z.object({
|
|
|
2910
3332
|
status: z.number().min(100).max(599).optional(),
|
|
2911
3333
|
timeout: z.number().min(0).default(3e4)
|
|
2912
3334
|
});
|
|
3335
|
+
var SmartWaitActionSchema = z.object({
|
|
3336
|
+
action: z.literal("smartWait"),
|
|
3337
|
+
/** Condition to wait for */
|
|
3338
|
+
until: z.enum(["networkIdle", "selector", "domStable"]).default("networkIdle"),
|
|
3339
|
+
/** CSS selector (required when until="selector") */
|
|
3340
|
+
selector: SafeSelectorSchema.optional(),
|
|
3341
|
+
/** Maximum wait in ms */
|
|
3342
|
+
timeout: z.number().min(0).default(3e4),
|
|
3343
|
+
/** Speed multiplier for the wait period in the output video (default: 8×) */
|
|
3344
|
+
displaySpeed: z.number().min(1).max(32).default(8)
|
|
3345
|
+
});
|
|
2913
3346
|
var StepActionSchema = z.discriminatedUnion("action", [
|
|
2914
3347
|
NavigateActionSchema,
|
|
2915
3348
|
ClickActionSchema,
|
|
@@ -2922,7 +3355,8 @@ var StepActionSchema = z.discriminatedUnion("action", [
|
|
|
2922
3355
|
WaitForNavigationActionSchema,
|
|
2923
3356
|
WaitForURLActionSchema,
|
|
2924
3357
|
WaitForFunctionActionSchema,
|
|
2925
|
-
WaitForResponseActionSchema
|
|
3358
|
+
WaitForResponseActionSchema,
|
|
3359
|
+
SmartWaitActionSchema
|
|
2926
3360
|
]);
|
|
2927
3361
|
var ZoomIntensitySchema = z.enum([
|
|
2928
3362
|
"subtle",
|
|
@@ -2952,7 +3386,7 @@ var ZoomEffectSchema = z.object({
|
|
|
2952
3386
|
*/
|
|
2953
3387
|
intensity: ZoomIntensitySchema.default("light"),
|
|
2954
3388
|
duration: z.number().default(800),
|
|
2955
|
-
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
|
|
3389
|
+
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear", "spring"]).default("ease-in-out"),
|
|
2956
3390
|
autoZoom: AutoZoomConfigSchema.default({})
|
|
2957
3391
|
});
|
|
2958
3392
|
var CursorEffectSchema = z.object({
|
|
@@ -2989,6 +3423,17 @@ var SpeedRampConfigSchema = z.object({
|
|
|
2989
3423
|
actionSpeed: z.number().min(0.25).max(2).default(0.8),
|
|
2990
3424
|
transitionFrames: z.number().default(15)
|
|
2991
3425
|
});
|
|
3426
|
+
var SmartSpeedConfigSchema = z.object({
|
|
3427
|
+
enabled: z.boolean().default(false),
|
|
3428
|
+
/** Speed multiplier for frames during smartWait (overridden by per-action displaySpeed) */
|
|
3429
|
+
waitSpeed: z.number().min(1).max(32).default(8),
|
|
3430
|
+
/** Speed multiplier for idle frames (no DOM/network changes) */
|
|
3431
|
+
idleSpeed: z.number().min(1).max(16).default(4),
|
|
3432
|
+
/** Duration (ms) to ease between speed changes (prevents jarring jumps) */
|
|
3433
|
+
transitionDuration: z.number().default(300),
|
|
3434
|
+
/** Minimum segment duration (ms) — don't speed up very short segments */
|
|
3435
|
+
minSegmentDuration: z.number().default(500)
|
|
3436
|
+
});
|
|
2992
3437
|
var KeystrokeConfigSchema = z.object({
|
|
2993
3438
|
enabled: z.boolean().default(false),
|
|
2994
3439
|
/**
|
|
@@ -3023,6 +3468,7 @@ var EffectsConfigSchema = z.object({
|
|
|
3023
3468
|
background: BackgroundSchema.default({}),
|
|
3024
3469
|
deviceFrame: DeviceFrameSchema.default({}),
|
|
3025
3470
|
speedRamp: SpeedRampConfigSchema.default({}),
|
|
3471
|
+
smartSpeed: SmartSpeedConfigSchema.default({}),
|
|
3026
3472
|
keystroke: KeystrokeConfigSchema.default({}),
|
|
3027
3473
|
watermark: WatermarkConfigSchema.default({})
|
|
3028
3474
|
});
|
|
@@ -3038,6 +3484,8 @@ var OutputConfigSchema = z.object({
|
|
|
3038
3484
|
// balanced — general-purpose, good quality/size trade-off (CRF 20)
|
|
3039
3485
|
// archive — high-fidelity storage, larger file (CRF 15)
|
|
3040
3486
|
preset: z.enum(["social", "balanced", "archive"]).optional(),
|
|
3487
|
+
/** Codec override: h264 (default), hevc (10-bit), av1 (smallest files, slow encode) */
|
|
3488
|
+
codec: z.enum(["auto", "h264", "hevc", "av1"]).default("auto"),
|
|
3041
3489
|
outputDir: z.string().default("./output"),
|
|
3042
3490
|
filename: z.string().default("clipwise-recording")
|
|
3043
3491
|
});
|
|
@@ -3047,6 +3495,7 @@ var StepEffectsOverrideSchema = z.object({
|
|
|
3047
3495
|
background: BackgroundSchema.partial().optional(),
|
|
3048
3496
|
deviceFrame: DeviceFrameSchema.partial().optional(),
|
|
3049
3497
|
speedRamp: SpeedRampConfigSchema.partial().optional(),
|
|
3498
|
+
smartSpeed: SmartSpeedConfigSchema.partial().optional(),
|
|
3050
3499
|
keystroke: KeystrokeConfigSchema.partial().optional(),
|
|
3051
3500
|
watermark: WatermarkConfigSchema.partial().optional()
|
|
3052
3501
|
}).optional();
|
|
@@ -3208,9 +3657,11 @@ export {
|
|
|
3208
3657
|
applyCrossfade,
|
|
3209
3658
|
applySlide,
|
|
3210
3659
|
applyTransition,
|
|
3660
|
+
applyZoomEasing,
|
|
3211
3661
|
buildZoomClickLookup,
|
|
3212
3662
|
calculateAdaptiveZoom,
|
|
3213
3663
|
calculateAdaptiveZoomFromLookup,
|
|
3664
|
+
calculateAdaptiveZoomFromZones,
|
|
3214
3665
|
calculateAdaptiveZoomInWindow,
|
|
3215
3666
|
calculatePanOffset,
|
|
3216
3667
|
encodeGif,
|
|
@@ -3218,6 +3669,7 @@ export {
|
|
|
3218
3669
|
encodeMp4Stream,
|
|
3219
3670
|
lerpZoom,
|
|
3220
3671
|
loadScenario,
|
|
3672
|
+
mergeClickZones,
|
|
3221
3673
|
parseScenario,
|
|
3222
3674
|
renderCursorHighlight,
|
|
3223
3675
|
renderCursorTrail,
|
|
@@ -3225,5 +3677,6 @@ export {
|
|
|
3225
3677
|
renderWatermark,
|
|
3226
3678
|
resolveZoomScale,
|
|
3227
3679
|
savePngSequence,
|
|
3680
|
+
springEasing,
|
|
3228
3681
|
validateScenario
|
|
3229
3682
|
};
|