clipwise 0.4.1 → 0.5.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 +26 -11
- package/README.md +23 -10
- package/dist/cli/index.js +292 -100
- package/dist/compose/frame-worker.js +81 -41
- package/dist/index.d.ts +152 -6
- package/dist/index.js +293 -99
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -10,13 +10,17 @@ function interpolatePath(from, to, steps) {
|
|
|
10
10
|
if (steps === 1) return [from, to];
|
|
11
11
|
const dx = to.x - from.x;
|
|
12
12
|
const dy = to.y - from.y;
|
|
13
|
+
const distance = Math.hypot(dx, dy);
|
|
14
|
+
const perpScale = Math.min(distance * 0.06, 30);
|
|
15
|
+
const normX = distance > 0 ? dy / distance * perpScale : 0;
|
|
16
|
+
const normY = distance > 0 ? -dx / distance * perpScale : 0;
|
|
13
17
|
const cp1 = {
|
|
14
|
-
x: from.x + dx * 0.25 +
|
|
15
|
-
y: from.y + dy * 0.25
|
|
18
|
+
x: from.x + dx * 0.25 + normX,
|
|
19
|
+
y: from.y + dy * 0.25 + normY
|
|
16
20
|
};
|
|
17
21
|
const cp2 = {
|
|
18
|
-
x: from.x + dx * 0.75 -
|
|
19
|
-
y: from.y + dy * 0.75
|
|
22
|
+
x: from.x + dx * 0.75 - normX,
|
|
23
|
+
y: from.y + dy * 0.75 - normY
|
|
20
24
|
};
|
|
21
25
|
const points = [];
|
|
22
26
|
for (let i = 0; i <= steps; i++) {
|
|
@@ -55,12 +59,9 @@ var CLICK_EFFECT_DURATION_MS = 500;
|
|
|
55
59
|
var REPAINT_INTERVAL_MS = 25;
|
|
56
60
|
var ACTION_GAP_MS = 30;
|
|
57
61
|
var CURSOR_SPEED_PRESETS = {
|
|
58
|
-
fast: {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// ~350ms, ~14 frames captured
|
|
62
|
-
slow: { steps: 20, delay: 25 }
|
|
63
|
-
// ~500ms, ~20 frames captured
|
|
62
|
+
fast: { pixelsPerStep: 22, stepDelayMs: 22, minSteps: 8, maxSteps: 35 },
|
|
63
|
+
normal: { pixelsPerStep: 16, stepDelayMs: 26, minSteps: 10, maxSteps: 45 },
|
|
64
|
+
slow: { pixelsPerStep: 12, stepDelayMs: 32, minSteps: 12, maxSteps: 55 }
|
|
64
65
|
};
|
|
65
66
|
var FrameChannel = class {
|
|
66
67
|
buffer = [];
|
|
@@ -100,6 +101,9 @@ var ClipwiseRecorder = class {
|
|
|
100
101
|
cursorTimeline = [];
|
|
101
102
|
clickTimeline = [];
|
|
102
103
|
keystrokeTimeline = [];
|
|
104
|
+
/** Incremented at the start of each `type` action so the HUD can render
|
|
105
|
+
* each input field's text on a separate line. */
|
|
106
|
+
keystrokeSessionId = 0;
|
|
103
107
|
currentStepIndex = 0;
|
|
104
108
|
cursorPosition = { x: 0, y: 0 };
|
|
105
109
|
viewport = { width: 1280, height: 800 };
|
|
@@ -137,6 +141,7 @@ var ClipwiseRecorder = class {
|
|
|
137
141
|
this.cursorTimeline = [];
|
|
138
142
|
this.clickTimeline = [];
|
|
139
143
|
this.keystrokeTimeline = [];
|
|
144
|
+
this.keystrokeSessionId = 0;
|
|
140
145
|
this.currentStepIndex = 0;
|
|
141
146
|
this.cursorPosition = { x: 0, y: 0 };
|
|
142
147
|
this.isCapturing = false;
|
|
@@ -379,6 +384,34 @@ var ClipwiseRecorder = class {
|
|
|
379
384
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
380
385
|
};
|
|
381
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Force a unique DOM repaint visible in the top scanlines of the captured PNG.
|
|
389
|
+
*
|
|
390
|
+
* Uses a 1×1 px fixed-position element at z-index MAX, sitting above ALL
|
|
391
|
+
* overlays including modals (position:fixed;z-index:100;backdrop-filter:blur).
|
|
392
|
+
* Alternates background between #000001 and #000100 — two colors that are
|
|
393
|
+
* visually indistinguishable (1/255 difference in R or G channel against a
|
|
394
|
+
* dark page) but produce distinct PNG byte sequences, defeating dedup.
|
|
395
|
+
*
|
|
396
|
+
* This replaces the previous `document.documentElement.style.outline` approach
|
|
397
|
+
* which failed whenever a full-viewport fixed overlay (e.g. modal backdrop)
|
|
398
|
+
* was composited on top of the outline, making y=0 PNG bytes identical across
|
|
399
|
+
* frames and causing dedup to collapse all modal-typing frames into one.
|
|
400
|
+
*/
|
|
401
|
+
async forceRepaint(t) {
|
|
402
|
+
if (!this.page) return;
|
|
403
|
+
await this.page.evaluate((toggle) => {
|
|
404
|
+
let el = document.getElementById("__cw_rf__");
|
|
405
|
+
if (!el) {
|
|
406
|
+
el = document.createElement("div");
|
|
407
|
+
el.id = "__cw_rf__";
|
|
408
|
+
el.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;z-index:2147483647;pointer-events:none";
|
|
409
|
+
(document.body ?? document.documentElement).appendChild(el);
|
|
410
|
+
}
|
|
411
|
+
el.style.background = toggle ? "#000001" : "#000100";
|
|
412
|
+
}, t).catch(() => {
|
|
413
|
+
});
|
|
414
|
+
}
|
|
382
415
|
/**
|
|
383
416
|
* Wait for a given duration while forcing periodic repaints
|
|
384
417
|
* so CDP screencast keeps sending frames even on static pages.
|
|
@@ -388,10 +421,7 @@ var ClipwiseRecorder = class {
|
|
|
388
421
|
const endTime = Date.now() + durationMs;
|
|
389
422
|
let toggle = false;
|
|
390
423
|
while (Date.now() < endTime && this.isCapturing) {
|
|
391
|
-
await this.
|
|
392
|
-
document.documentElement.style.outline = t ? "0.001px solid transparent" : "none";
|
|
393
|
-
}, toggle).catch(() => {
|
|
394
|
-
});
|
|
424
|
+
await this.forceRepaint(toggle);
|
|
395
425
|
toggle = !toggle;
|
|
396
426
|
const remaining = endTime - Date.now();
|
|
397
427
|
if (remaining > 0) {
|
|
@@ -468,40 +498,57 @@ var ClipwiseRecorder = class {
|
|
|
468
498
|
timestamp: Date.now()
|
|
469
499
|
});
|
|
470
500
|
await this.page.click(action.selector);
|
|
501
|
+
this.keystrokeSessionId++;
|
|
502
|
+
const currentSessionId = this.keystrokeSessionId;
|
|
503
|
+
let typeRepaintToggle = false;
|
|
471
504
|
for (const char of action.text) {
|
|
472
|
-
await this.page.keyboard.type(char
|
|
505
|
+
await this.page.keyboard.type(char);
|
|
506
|
+
typeRepaintToggle = !typeRepaintToggle;
|
|
507
|
+
await this.forceRepaint(typeRepaintToggle);
|
|
473
508
|
this.keystrokeTimeline.push({
|
|
474
509
|
key: char,
|
|
475
|
-
timestamp: Date.now()
|
|
510
|
+
timestamp: Date.now(),
|
|
511
|
+
sessionId: currentSessionId
|
|
476
512
|
});
|
|
513
|
+
await new Promise((resolve) => setTimeout(resolve, action.delay));
|
|
477
514
|
}
|
|
478
515
|
break;
|
|
479
516
|
}
|
|
480
517
|
case "scroll": {
|
|
481
518
|
const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector, action.timeout) : null;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
x: action.x,
|
|
500
|
-
y: action.y,
|
|
501
|
-
smooth: action.smooth,
|
|
502
|
-
selector: action.selector ?? null
|
|
519
|
+
const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
|
|
520
|
+
if (action.smooth && scrollDistance > 0) {
|
|
521
|
+
const scrollSteps = Math.max(12, Math.round(scrollDistance / 25));
|
|
522
|
+
const yStep = action.y / scrollSteps;
|
|
523
|
+
const xStep = action.x / scrollSteps;
|
|
524
|
+
for (let s = 0; s < scrollSteps; s++) {
|
|
525
|
+
await this.page.evaluate(
|
|
526
|
+
({ dy, dx, sel }) => {
|
|
527
|
+
const el = sel ? document.querySelector(sel) : window;
|
|
528
|
+
if (!el) return;
|
|
529
|
+
const opts = { left: dx, top: dy, behavior: "instant" };
|
|
530
|
+
if (el === window) window.scrollBy(opts);
|
|
531
|
+
else el.scrollBy(opts);
|
|
532
|
+
},
|
|
533
|
+
{ dy: yStep, dx: xStep, sel: action.selector ?? null }
|
|
534
|
+
);
|
|
535
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
503
536
|
}
|
|
504
|
-
|
|
537
|
+
await this.waitWithRepaints(150);
|
|
538
|
+
} else {
|
|
539
|
+
await this.page.evaluate(
|
|
540
|
+
({ x, y, selector }) => {
|
|
541
|
+
const target = selector ? document.querySelector(selector) : window;
|
|
542
|
+
if (target) {
|
|
543
|
+
const options = { left: x, top: y, behavior: "instant" };
|
|
544
|
+
if (target === window) window.scrollBy(options);
|
|
545
|
+
else target.scrollBy(options);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
{ x: action.x, y: action.y, selector: action.selector ?? null }
|
|
549
|
+
);
|
|
550
|
+
await this.waitWithRepaints(100);
|
|
551
|
+
}
|
|
505
552
|
if (scrollTarget) {
|
|
506
553
|
this.cursorPosition = scrollTarget;
|
|
507
554
|
this.cursorTimeline.push({
|
|
@@ -509,10 +556,7 @@ var ClipwiseRecorder = class {
|
|
|
509
556
|
timestamp: Date.now()
|
|
510
557
|
});
|
|
511
558
|
}
|
|
512
|
-
|
|
513
|
-
const scrollWait = action.smooth ? Math.max(600, Math.round(scrollDistance * 0.8)) : 100;
|
|
514
|
-
await this.waitWithRepaints(scrollWait);
|
|
515
|
-
await this.waitWithRepaints(150);
|
|
559
|
+
await this.waitWithRepaints(120);
|
|
516
560
|
break;
|
|
517
561
|
}
|
|
518
562
|
case "wait": {
|
|
@@ -564,25 +608,86 @@ var ClipwiseRecorder = class {
|
|
|
564
608
|
await this.waitWithRepaints(ACTION_GAP_MS);
|
|
565
609
|
}
|
|
566
610
|
/**
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
611
|
+
* Suppress all CSS transitions and animations on the page during cursor
|
|
612
|
+
* movement. Hover-state transitions (background, transform, box-shadow,
|
|
613
|
+
* etc.) on elements the cursor passes over generate CSS-animation-driven
|
|
614
|
+
* CDP frames that arrive asynchronously relative to our cursor step
|
|
615
|
+
* intervals. Those extra frames are timestamped when they're ACK-drained,
|
|
616
|
+
* which can be many milliseconds after the actual cursor moved — causing
|
|
617
|
+
* interpolateCursorAt() to map them to a newer cursor position while the
|
|
618
|
+
* screenshot still shows older content → visible stutter.
|
|
619
|
+
*
|
|
620
|
+
* Suppressing transitions during movement eliminates these extra frames
|
|
621
|
+
* entirely regardless of which elements the path crosses. Transitions are
|
|
622
|
+
* restored immediately after arrival, so hover effects on the final target
|
|
623
|
+
* element still appear during the subsequent holdDuration.
|
|
624
|
+
*/
|
|
625
|
+
async suppressTransitions() {
|
|
626
|
+
if (!this.page) return;
|
|
627
|
+
await this.page.evaluate(() => {
|
|
628
|
+
if (document.getElementById("__cw_notrans__")) return;
|
|
629
|
+
const s = document.createElement("style");
|
|
630
|
+
s.id = "__cw_notrans__";
|
|
631
|
+
s.textContent = "*{transition-duration:0s!important;transition-delay:0s!important}";
|
|
632
|
+
(document.head ?? document.documentElement).appendChild(s);
|
|
633
|
+
}).catch(() => {
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
async restoreTransitions() {
|
|
637
|
+
if (!this.page) return;
|
|
638
|
+
await this.page.evaluate(() => {
|
|
639
|
+
document.getElementById("__cw_notrans__")?.remove();
|
|
640
|
+
}).catch(() => {
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Move cursor smoothly from current position to target.
|
|
645
|
+
*
|
|
646
|
+
* Key design decisions:
|
|
647
|
+
* 1. Adaptive step count — proportional to travel distance so short and
|
|
648
|
+
* long movements feel equally paced (pixelsPerStep controls speed).
|
|
649
|
+
* 2. Forced repaint per step — moving the mouse in headless Chrome does NOT
|
|
650
|
+
* visually change the screenshot (the cursor is rendered in post-processing).
|
|
651
|
+
* Without a forced repaint, dedup collapses every intermediate frame into
|
|
652
|
+
* the first one, making the cursor appear to teleport.
|
|
653
|
+
* 3. Transition suppression — CSS transitions on hovered elements generate
|
|
654
|
+
* asynchronous CDP frames that desync cursor position from screenshot
|
|
655
|
+
* content. All transitions are suppressed for the duration of movement
|
|
656
|
+
* and restored on arrival (see suppressTransitions / restoreTransitions).
|
|
657
|
+
* 4. Capped bezier curve — perpendicular offset is capped at 30 px regardless
|
|
658
|
+
* of distance, preventing a visible arc on long-distance movements.
|
|
570
659
|
*/
|
|
571
660
|
async moveCursorSmooth(target) {
|
|
572
661
|
if (!this.page) return;
|
|
573
|
-
const
|
|
662
|
+
const preset = CURSOR_SPEED_PRESETS[this.cursorSpeed];
|
|
574
663
|
const from = { ...this.cursorPosition };
|
|
664
|
+
const distance = Math.hypot(target.x - from.x, target.y - from.y);
|
|
665
|
+
if (distance < 2) {
|
|
666
|
+
this.cursorPosition = { ...target };
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const steps = Math.round(
|
|
670
|
+
Math.min(Math.max(distance / preset.pixelsPerStep, preset.minSteps), preset.maxSteps)
|
|
671
|
+
);
|
|
575
672
|
const path = interpolatePath(from, target, steps);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
673
|
+
let repaintToggle = false;
|
|
674
|
+
await this.suppressTransitions();
|
|
675
|
+
try {
|
|
676
|
+
for (const point of path) {
|
|
677
|
+
await this.page.mouse.move(point.x, point.y);
|
|
678
|
+
repaintToggle = !repaintToggle;
|
|
679
|
+
await this.forceRepaint(repaintToggle);
|
|
680
|
+
this.cursorTimeline.push({
|
|
681
|
+
position: { x: point.x, y: point.y },
|
|
682
|
+
timestamp: Date.now()
|
|
683
|
+
});
|
|
684
|
+
await new Promise((resolve) => setTimeout(resolve, preset.stepDelayMs));
|
|
685
|
+
}
|
|
686
|
+
} finally {
|
|
687
|
+
await this.restoreTransitions();
|
|
583
688
|
}
|
|
584
689
|
this.cursorPosition = { ...target };
|
|
585
|
-
await this.waitWithRepaints(
|
|
690
|
+
await this.waitWithRepaints(80);
|
|
586
691
|
}
|
|
587
692
|
/**
|
|
588
693
|
* Build CapturedFrame array from raw screencast frames,
|
|
@@ -1001,8 +1106,11 @@ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeig
|
|
|
1001
1106
|
const size = Math.round(config.size * dpr);
|
|
1002
1107
|
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1003
1108
|
const cursorBuffer = Buffer.from(cursorSvg);
|
|
1004
|
-
const
|
|
1005
|
-
const
|
|
1109
|
+
const tipOffsetX = Math.round(4 / 24 * size);
|
|
1110
|
+
const px = Math.round(position.x * dpr);
|
|
1111
|
+
const py = Math.round(position.y * dpr);
|
|
1112
|
+
const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
|
|
1113
|
+
const top = Math.max(0, Math.min(py, frameHeight - size));
|
|
1006
1114
|
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
1007
1115
|
}
|
|
1008
1116
|
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
@@ -1064,6 +1172,17 @@ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, fra
|
|
|
1064
1172
|
|
|
1065
1173
|
// src/effects/zoom.ts
|
|
1066
1174
|
import sharp3 from "sharp";
|
|
1175
|
+
var ZOOM_INTENSITY_SCALES = {
|
|
1176
|
+
subtle: 1.15,
|
|
1177
|
+
light: 1.25,
|
|
1178
|
+
moderate: 1.35,
|
|
1179
|
+
strong: 1.5,
|
|
1180
|
+
dramatic: 1.8
|
|
1181
|
+
};
|
|
1182
|
+
function resolveZoomScale(scale, intensity) {
|
|
1183
|
+
if (intensity !== void 0) return ZOOM_INTENSITY_SCALES[intensity];
|
|
1184
|
+
return scale;
|
|
1185
|
+
}
|
|
1067
1186
|
async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
|
|
1068
1187
|
if (scale <= 1) return frameBuffer;
|
|
1069
1188
|
const cropWidth = Math.round(frameWidth / scale);
|
|
@@ -1238,30 +1357,55 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
|
1238
1357
|
|
|
1239
1358
|
// src/effects/keystroke.ts
|
|
1240
1359
|
import sharp5 from "sharp";
|
|
1360
|
+
function escapeXml(s) {
|
|
1361
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1362
|
+
}
|
|
1363
|
+
function buildSessions(keystrokes) {
|
|
1364
|
+
const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
|
|
1365
|
+
if (!hasSessionIds) {
|
|
1366
|
+
const text = keystrokes.map((k) => k.key).join("");
|
|
1367
|
+
return text.length > 0 ? [text] : [];
|
|
1368
|
+
}
|
|
1369
|
+
const map = /* @__PURE__ */ new Map();
|
|
1370
|
+
for (const k of keystrokes) {
|
|
1371
|
+
const sid = k.sessionId ?? 0;
|
|
1372
|
+
map.set(sid, (map.get(sid) ?? "") + k.key);
|
|
1373
|
+
}
|
|
1374
|
+
return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([, text]) => text).filter((t) => t.length > 0);
|
|
1375
|
+
}
|
|
1241
1376
|
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
|
|
1242
1377
|
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
if (
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1378
|
+
if (!config.showTyping) return frameBuffer;
|
|
1379
|
+
const lastKeystroke = keystrokes[keystrokes.length - 1];
|
|
1380
|
+
const age = frameTimestamp - lastKeystroke.timestamp;
|
|
1381
|
+
if (age >= config.fadeAfter) return frameBuffer;
|
|
1382
|
+
const fadeStart = config.fadeAfter * 0.6;
|
|
1383
|
+
const globalOpacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
1384
|
+
if (globalOpacity <= 0) return frameBuffer;
|
|
1385
|
+
const allSessions = buildSessions(keystrokes);
|
|
1386
|
+
if (allSessions.length === 0) return frameBuffer;
|
|
1387
|
+
const sessions = allSessions.slice(-3);
|
|
1388
|
+
const lineCount = sessions.length;
|
|
1249
1389
|
const fontSize = config.fontSize * dpr;
|
|
1250
1390
|
const padding = config.padding * dpr;
|
|
1251
|
-
const charWidth = fontSize * 0.62;
|
|
1252
|
-
const textWidth = Math.ceil(displayText.length * charWidth);
|
|
1253
1391
|
const hudPadH = padding * 2;
|
|
1254
|
-
const hudPadV = padding * 1.
|
|
1255
|
-
const
|
|
1256
|
-
const
|
|
1257
|
-
const
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1392
|
+
const hudPadV = padding * 1.4;
|
|
1393
|
+
const lineGap = Math.round(fontSize * 0.45);
|
|
1394
|
+
const charWidth = fontSize * 0.615;
|
|
1395
|
+
const maxHudWidth = frameWidth - 60 * dpr;
|
|
1396
|
+
const maxCharsPerLine = Math.max(10, Math.floor((maxHudWidth - hudPadH * 2) / charWidth));
|
|
1397
|
+
const lines = sessions.map(
|
|
1398
|
+
(text) => text.length > maxCharsPerLine ? text.slice(-maxCharsPerLine) : text
|
|
1399
|
+
);
|
|
1400
|
+
const maxLineLen = Math.max(...lines.map((l) => l.length));
|
|
1401
|
+
const hudWidth = Math.min(
|
|
1402
|
+
Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
|
|
1403
|
+
maxHudWidth
|
|
1404
|
+
);
|
|
1405
|
+
const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
|
|
1262
1406
|
const margin = 30 * dpr;
|
|
1263
|
-
let hudX;
|
|
1264
1407
|
const hudY = frameHeight - hudHeight - margin;
|
|
1408
|
+
let hudX;
|
|
1265
1409
|
switch (config.position) {
|
|
1266
1410
|
case "bottom-left":
|
|
1267
1411
|
hudX = margin;
|
|
@@ -1272,17 +1416,24 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1272
1416
|
case "bottom-center":
|
|
1273
1417
|
default:
|
|
1274
1418
|
hudX = Math.round((frameWidth - hudWidth) / 2);
|
|
1275
|
-
break;
|
|
1276
1419
|
}
|
|
1277
|
-
const
|
|
1278
|
-
const
|
|
1279
|
-
const
|
|
1420
|
+
const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
|
|
1421
|
+
const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
|
|
1422
|
+
const rx = (8 * dpr).toFixed(1);
|
|
1423
|
+
const boxOp = (globalOpacity * 0.92).toFixed(3);
|
|
1424
|
+
const textX = hudX + hudPadH;
|
|
1425
|
+
const baselineY = hudY + hudPadV + fontSize * 0.82;
|
|
1426
|
+
const textElements = lines.map((line, i) => {
|
|
1427
|
+
const op = (globalOpacity * opacityFactors[i]).toFixed(3);
|
|
1428
|
+
const lineY = baselineY + i * (fontSize + lineGap);
|
|
1429
|
+
return `<text x="${textX}" y="${lineY}"
|
|
1430
|
+
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1431
|
+
fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
|
|
1432
|
+
}).join("\n ");
|
|
1280
1433
|
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1281
1434
|
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
1282
|
-
rx="${
|
|
1283
|
-
|
|
1284
|
-
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1285
|
-
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
1435
|
+
rx="${rx}" ry="${rx}" fill="${config.backgroundColor}" opacity="${boxOp}" />
|
|
1436
|
+
${textElements}
|
|
1286
1437
|
</svg>`;
|
|
1287
1438
|
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1288
1439
|
}
|
|
@@ -1356,6 +1507,11 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1356
1507
|
clickProgress: context?.clickProgress ?? null,
|
|
1357
1508
|
cursorTrail: context?.cursorTrail ?? []
|
|
1358
1509
|
};
|
|
1510
|
+
const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1511
|
+
const withFrameOffset = (pos) => ({
|
|
1512
|
+
x: pos.x + frameOffset.left / Math.max(1, dpr),
|
|
1513
|
+
y: pos.y + frameOffset.top / Math.max(1, dpr)
|
|
1514
|
+
});
|
|
1359
1515
|
if (effects.deviceFrame.enabled) {
|
|
1360
1516
|
const sl2 = ctx.staticLayers;
|
|
1361
1517
|
if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
|
|
@@ -1376,7 +1532,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1376
1532
|
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1377
1533
|
buffer = await renderCursorHighlight(
|
|
1378
1534
|
buffer,
|
|
1379
|
-
frame.cursorPosition,
|
|
1535
|
+
withFrameOffset(frame.cursorPosition),
|
|
1380
1536
|
effects.cursor,
|
|
1381
1537
|
width,
|
|
1382
1538
|
height,
|
|
@@ -1386,7 +1542,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1386
1542
|
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1387
1543
|
buffer = await renderCursorTrail(
|
|
1388
1544
|
buffer,
|
|
1389
|
-
ctx.cursorTrail,
|
|
1545
|
+
ctx.cursorTrail.map(withFrameOffset),
|
|
1390
1546
|
effects.cursor,
|
|
1391
1547
|
width,
|
|
1392
1548
|
height,
|
|
@@ -1396,7 +1552,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1396
1552
|
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1397
1553
|
buffer = await renderCursor(
|
|
1398
1554
|
buffer,
|
|
1399
|
-
frame.cursorPosition,
|
|
1555
|
+
withFrameOffset(frame.cursorPosition),
|
|
1400
1556
|
effects.cursor,
|
|
1401
1557
|
width,
|
|
1402
1558
|
height,
|
|
@@ -1407,7 +1563,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1407
1563
|
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1408
1564
|
buffer = await renderClickEffect(
|
|
1409
1565
|
buffer,
|
|
1410
|
-
frame.clickPosition,
|
|
1566
|
+
withFrameOffset(frame.clickPosition),
|
|
1411
1567
|
effects.cursor,
|
|
1412
1568
|
progress,
|
|
1413
1569
|
width,
|
|
@@ -1415,6 +1571,16 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1415
1571
|
dpr
|
|
1416
1572
|
);
|
|
1417
1573
|
}
|
|
1574
|
+
const scale = ctx.zoomScale;
|
|
1575
|
+
if (effects.zoom.enabled && scale > 1) {
|
|
1576
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1577
|
+
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1578
|
+
const focusPoint = {
|
|
1579
|
+
x: rawFocus.x * dpr + offset.left,
|
|
1580
|
+
y: rawFocus.y * dpr + offset.top
|
|
1581
|
+
};
|
|
1582
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1583
|
+
}
|
|
1418
1584
|
if (effects.keystroke.enabled && frame.keystrokes) {
|
|
1419
1585
|
buffer = await renderKeystrokeHud(
|
|
1420
1586
|
buffer,
|
|
@@ -1426,16 +1592,6 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1426
1592
|
dpr
|
|
1427
1593
|
);
|
|
1428
1594
|
}
|
|
1429
|
-
const scale = ctx.zoomScale;
|
|
1430
|
-
if (effects.zoom.enabled && scale > 1) {
|
|
1431
|
-
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1432
|
-
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1433
|
-
const focusPoint = {
|
|
1434
|
-
x: rawFocus.x * dpr + offset.left,
|
|
1435
|
-
y: rawFocus.y * dpr + offset.top
|
|
1436
|
-
};
|
|
1437
|
-
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1438
|
-
}
|
|
1439
1595
|
const sl = ctx.staticLayers;
|
|
1440
1596
|
if (sl) {
|
|
1441
1597
|
const padding = effects.background.padding;
|
|
@@ -1654,6 +1810,10 @@ var CanvasRenderer = class {
|
|
|
1654
1810
|
this.output.fps * (this.effects.zoom.duration / 1e3)
|
|
1655
1811
|
);
|
|
1656
1812
|
const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
|
|
1813
|
+
const effectiveScale = resolveZoomScale(
|
|
1814
|
+
this.effects.zoom.scale,
|
|
1815
|
+
this.effects.zoom.intensity
|
|
1816
|
+
);
|
|
1657
1817
|
for (let i = 0; i < frames.length; i++) {
|
|
1658
1818
|
const frame = frames[i];
|
|
1659
1819
|
let zoomScale = 1;
|
|
@@ -1661,7 +1821,7 @@ var CanvasRenderer = class {
|
|
|
1661
1821
|
zoomScale = calculateAdaptiveZoomFromLookup(
|
|
1662
1822
|
clickLookup,
|
|
1663
1823
|
i,
|
|
1664
|
-
|
|
1824
|
+
effectiveScale,
|
|
1665
1825
|
transitionFrames
|
|
1666
1826
|
);
|
|
1667
1827
|
}
|
|
@@ -1777,6 +1937,10 @@ var CanvasRenderer = class {
|
|
|
1777
1937
|
let nextToDispatch = 0;
|
|
1778
1938
|
let nextToYield = 0;
|
|
1779
1939
|
const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
|
|
1940
|
+
const effectiveScale = resolveZoomScale(
|
|
1941
|
+
this.effects.zoom.scale,
|
|
1942
|
+
this.effects.zoom.intensity
|
|
1943
|
+
);
|
|
1780
1944
|
const computeContext = (i) => {
|
|
1781
1945
|
const frame = frames[i];
|
|
1782
1946
|
let zoomScale = 1;
|
|
@@ -1787,7 +1951,7 @@ var CanvasRenderer = class {
|
|
|
1787
1951
|
frames.slice(lo, hi + 1),
|
|
1788
1952
|
lo,
|
|
1789
1953
|
i,
|
|
1790
|
-
|
|
1954
|
+
effectiveScale,
|
|
1791
1955
|
transitionFrames
|
|
1792
1956
|
);
|
|
1793
1957
|
}
|
|
@@ -2588,15 +2752,32 @@ var StepActionSchema = z.discriminatedUnion("action", [
|
|
|
2588
2752
|
WaitForFunctionActionSchema,
|
|
2589
2753
|
WaitForResponseActionSchema
|
|
2590
2754
|
]);
|
|
2755
|
+
var ZoomIntensitySchema = z.enum([
|
|
2756
|
+
"subtle",
|
|
2757
|
+
"light",
|
|
2758
|
+
"moderate",
|
|
2759
|
+
"strong",
|
|
2760
|
+
"dramatic"
|
|
2761
|
+
]);
|
|
2591
2762
|
var AutoZoomConfigSchema = z.object({
|
|
2592
2763
|
followCursor: z.boolean().default(true),
|
|
2593
|
-
|
|
2764
|
+
/** @deprecated Use `intensity` on the parent zoom config instead. */
|
|
2765
|
+
maxScale: z.number().min(1).max(5).default(1.35),
|
|
2594
2766
|
transitionDuration: z.number().default(400),
|
|
2595
2767
|
padding: z.number().default(200)
|
|
2596
2768
|
});
|
|
2597
2769
|
var ZoomEffectSchema = z.object({
|
|
2598
2770
|
enabled: z.boolean().default(true),
|
|
2599
|
-
|
|
2771
|
+
/**
|
|
2772
|
+
* Numeric zoom scale (1.0 = no zoom). Overridden by `intensity` when set.
|
|
2773
|
+
* Default lowered from 1.8 → 1.35 to match "moderate" intensity.
|
|
2774
|
+
*/
|
|
2775
|
+
scale: z.number().min(1).max(5).default(1.35),
|
|
2776
|
+
/**
|
|
2777
|
+
* Intensity preset — overrides `scale` when set.
|
|
2778
|
+
* Calibrated against Loom (light≈1.25x) and Camtasia (moderate≈1.35x).
|
|
2779
|
+
*/
|
|
2780
|
+
intensity: ZoomIntensitySchema.optional(),
|
|
2600
2781
|
duration: z.number().default(600),
|
|
2601
2782
|
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
|
|
2602
2783
|
autoZoom: AutoZoomConfigSchema.default({})
|
|
@@ -2637,6 +2818,17 @@ var SpeedRampConfigSchema = z.object({
|
|
|
2637
2818
|
});
|
|
2638
2819
|
var KeystrokeConfigSchema = z.object({
|
|
2639
2820
|
enabled: z.boolean().default(false),
|
|
2821
|
+
/**
|
|
2822
|
+
* Show regular typed text (alphabetic/numeric characters) in the HUD.
|
|
2823
|
+
*
|
|
2824
|
+
* Industry default is false — Screen Studio, KeyCastr, and ScreenFlow all
|
|
2825
|
+
* hide regular typing by default, showing only modifier+key shortcuts.
|
|
2826
|
+
* Typed content is already visible inside the focused input element, so
|
|
2827
|
+
* displaying it again in the HUD is redundant and creates overflow issues.
|
|
2828
|
+
*
|
|
2829
|
+
* Set to true to display a 2-line rolling HUD that follows the typed text.
|
|
2830
|
+
*/
|
|
2831
|
+
showTyping: z.boolean().default(false),
|
|
2640
2832
|
position: z.enum(["bottom-center", "bottom-left", "bottom-right"]).default("bottom-center"),
|
|
2641
2833
|
fontSize: z.number().default(18),
|
|
2642
2834
|
backgroundColor: z.string().default("rgba(0, 0, 0, 0.75)"),
|
|
@@ -2807,6 +2999,7 @@ export {
|
|
|
2807
2999
|
ClipwiseRecorder,
|
|
2808
3000
|
ConcurrentSession,
|
|
2809
3001
|
StreamingSession,
|
|
3002
|
+
ZOOM_INTENSITY_SCALES,
|
|
2810
3003
|
applyCrossfade,
|
|
2811
3004
|
buildZoomClickLookup,
|
|
2812
3005
|
calculateAdaptiveZoom,
|
|
@@ -2823,6 +3016,7 @@ export {
|
|
|
2823
3016
|
renderCursorTrail,
|
|
2824
3017
|
renderKeystrokeHud,
|
|
2825
3018
|
renderWatermark,
|
|
3019
|
+
resolveZoomScale,
|
|
2826
3020
|
savePngSequence,
|
|
2827
3021
|
validateScenario
|
|
2828
3022
|
};
|