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/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ var __export = (target, all) => {
|
|
|
11
11
|
|
|
12
12
|
// src/script/types.ts
|
|
13
13
|
import { z } from "zod";
|
|
14
|
-
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, StepActionSchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
|
|
14
|
+
var SafeSelectorSchema, NavigateActionSchema, ClickActionSchema, TypeActionSchema, ScrollActionSchema, WaitActionSchema, HoverActionSchema, ScreenshotActionSchema, WaitForSelectorActionSchema, WaitForNavigationActionSchema, WaitForURLActionSchema, WaitForFunctionActionSchema, WaitForResponseActionSchema, StepActionSchema, ZoomIntensitySchema, AutoZoomConfigSchema, ZoomEffectSchema, CursorEffectSchema, BackgroundSchema, DeviceFrameSchema, SpeedRampConfigSchema, KeystrokeConfigSchema, WatermarkConfigSchema, EffectsConfigSchema, OutputConfigSchema, StepSchema, ScenarioSchema;
|
|
15
15
|
var init_types = __esm({
|
|
16
16
|
"src/script/types.ts"() {
|
|
17
17
|
"use strict";
|
|
@@ -101,15 +101,32 @@ var init_types = __esm({
|
|
|
101
101
|
WaitForFunctionActionSchema,
|
|
102
102
|
WaitForResponseActionSchema
|
|
103
103
|
]);
|
|
104
|
+
ZoomIntensitySchema = z.enum([
|
|
105
|
+
"subtle",
|
|
106
|
+
"light",
|
|
107
|
+
"moderate",
|
|
108
|
+
"strong",
|
|
109
|
+
"dramatic"
|
|
110
|
+
]);
|
|
104
111
|
AutoZoomConfigSchema = z.object({
|
|
105
112
|
followCursor: z.boolean().default(true),
|
|
106
|
-
|
|
113
|
+
/** @deprecated Use `intensity` on the parent zoom config instead. */
|
|
114
|
+
maxScale: z.number().min(1).max(5).default(1.35),
|
|
107
115
|
transitionDuration: z.number().default(400),
|
|
108
116
|
padding: z.number().default(200)
|
|
109
117
|
});
|
|
110
118
|
ZoomEffectSchema = z.object({
|
|
111
119
|
enabled: z.boolean().default(true),
|
|
112
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Numeric zoom scale (1.0 = no zoom). Overridden by `intensity` when set.
|
|
122
|
+
* Default lowered from 1.8 → 1.35 to match "moderate" intensity.
|
|
123
|
+
*/
|
|
124
|
+
scale: z.number().min(1).max(5).default(1.35),
|
|
125
|
+
/**
|
|
126
|
+
* Intensity preset — overrides `scale` when set.
|
|
127
|
+
* Calibrated against Loom (light≈1.25x) and Camtasia (moderate≈1.35x).
|
|
128
|
+
*/
|
|
129
|
+
intensity: ZoomIntensitySchema.optional(),
|
|
113
130
|
duration: z.number().default(600),
|
|
114
131
|
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
|
|
115
132
|
autoZoom: AutoZoomConfigSchema.default({})
|
|
@@ -150,6 +167,17 @@ var init_types = __esm({
|
|
|
150
167
|
});
|
|
151
168
|
KeystrokeConfigSchema = z.object({
|
|
152
169
|
enabled: z.boolean().default(false),
|
|
170
|
+
/**
|
|
171
|
+
* Show regular typed text (alphabetic/numeric characters) in the HUD.
|
|
172
|
+
*
|
|
173
|
+
* Industry default is false — Screen Studio, KeyCastr, and ScreenFlow all
|
|
174
|
+
* hide regular typing by default, showing only modifier+key shortcuts.
|
|
175
|
+
* Typed content is already visible inside the focused input element, so
|
|
176
|
+
* displaying it again in the HUD is redundant and creates overflow issues.
|
|
177
|
+
*
|
|
178
|
+
* Set to true to display a 2-line rolling HUD that follows the typed text.
|
|
179
|
+
*/
|
|
180
|
+
showTyping: z.boolean().default(false),
|
|
153
181
|
position: z.enum(["bottom-center", "bottom-left", "bottom-right"]).default("bottom-center"),
|
|
154
182
|
fontSize: z.number().default(18),
|
|
155
183
|
backgroundColor: z.string().default("rgba(0, 0, 0, 0.75)"),
|
|
@@ -349,13 +377,17 @@ function interpolatePath(from, to, steps) {
|
|
|
349
377
|
if (steps === 1) return [from, to];
|
|
350
378
|
const dx = to.x - from.x;
|
|
351
379
|
const dy = to.y - from.y;
|
|
380
|
+
const distance = Math.hypot(dx, dy);
|
|
381
|
+
const perpScale = Math.min(distance * 0.06, 30);
|
|
382
|
+
const normX = distance > 0 ? dy / distance * perpScale : 0;
|
|
383
|
+
const normY = distance > 0 ? -dx / distance * perpScale : 0;
|
|
352
384
|
const cp1 = {
|
|
353
|
-
x: from.x + dx * 0.25 +
|
|
354
|
-
y: from.y + dy * 0.25
|
|
385
|
+
x: from.x + dx * 0.25 + normX,
|
|
386
|
+
y: from.y + dy * 0.25 + normY
|
|
355
387
|
};
|
|
356
388
|
const cp2 = {
|
|
357
|
-
x: from.x + dx * 0.75 -
|
|
358
|
-
y: from.y + dy * 0.75
|
|
389
|
+
x: from.x + dx * 0.75 - normX,
|
|
390
|
+
y: from.y + dy * 0.75 - normY
|
|
359
391
|
};
|
|
360
392
|
const points = [];
|
|
361
393
|
for (let i = 0; i <= steps; i++) {
|
|
@@ -394,12 +426,9 @@ var CLICK_EFFECT_DURATION_MS = 500;
|
|
|
394
426
|
var REPAINT_INTERVAL_MS = 25;
|
|
395
427
|
var ACTION_GAP_MS = 30;
|
|
396
428
|
var CURSOR_SPEED_PRESETS = {
|
|
397
|
-
fast: {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
// ~350ms, ~14 frames captured
|
|
401
|
-
slow: { steps: 20, delay: 25 }
|
|
402
|
-
// ~500ms, ~20 frames captured
|
|
429
|
+
fast: { pixelsPerStep: 22, stepDelayMs: 22, minSteps: 8, maxSteps: 35 },
|
|
430
|
+
normal: { pixelsPerStep: 16, stepDelayMs: 26, minSteps: 10, maxSteps: 45 },
|
|
431
|
+
slow: { pixelsPerStep: 12, stepDelayMs: 32, minSteps: 12, maxSteps: 55 }
|
|
403
432
|
};
|
|
404
433
|
var FrameChannel = class {
|
|
405
434
|
buffer = [];
|
|
@@ -439,6 +468,9 @@ var ClipwiseRecorder = class {
|
|
|
439
468
|
cursorTimeline = [];
|
|
440
469
|
clickTimeline = [];
|
|
441
470
|
keystrokeTimeline = [];
|
|
471
|
+
/** Incremented at the start of each `type` action so the HUD can render
|
|
472
|
+
* each input field's text on a separate line. */
|
|
473
|
+
keystrokeSessionId = 0;
|
|
442
474
|
currentStepIndex = 0;
|
|
443
475
|
cursorPosition = { x: 0, y: 0 };
|
|
444
476
|
viewport = { width: 1280, height: 800 };
|
|
@@ -476,6 +508,7 @@ var ClipwiseRecorder = class {
|
|
|
476
508
|
this.cursorTimeline = [];
|
|
477
509
|
this.clickTimeline = [];
|
|
478
510
|
this.keystrokeTimeline = [];
|
|
511
|
+
this.keystrokeSessionId = 0;
|
|
479
512
|
this.currentStepIndex = 0;
|
|
480
513
|
this.cursorPosition = { x: 0, y: 0 };
|
|
481
514
|
this.isCapturing = false;
|
|
@@ -718,6 +751,34 @@ var ClipwiseRecorder = class {
|
|
|
718
751
|
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
719
752
|
};
|
|
720
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* Force a unique DOM repaint visible in the top scanlines of the captured PNG.
|
|
756
|
+
*
|
|
757
|
+
* Uses a 1×1 px fixed-position element at z-index MAX, sitting above ALL
|
|
758
|
+
* overlays including modals (position:fixed;z-index:100;backdrop-filter:blur).
|
|
759
|
+
* Alternates background between #000001 and #000100 — two colors that are
|
|
760
|
+
* visually indistinguishable (1/255 difference in R or G channel against a
|
|
761
|
+
* dark page) but produce distinct PNG byte sequences, defeating dedup.
|
|
762
|
+
*
|
|
763
|
+
* This replaces the previous `document.documentElement.style.outline` approach
|
|
764
|
+
* which failed whenever a full-viewport fixed overlay (e.g. modal backdrop)
|
|
765
|
+
* was composited on top of the outline, making y=0 PNG bytes identical across
|
|
766
|
+
* frames and causing dedup to collapse all modal-typing frames into one.
|
|
767
|
+
*/
|
|
768
|
+
async forceRepaint(t) {
|
|
769
|
+
if (!this.page) return;
|
|
770
|
+
await this.page.evaluate((toggle) => {
|
|
771
|
+
let el = document.getElementById("__cw_rf__");
|
|
772
|
+
if (!el) {
|
|
773
|
+
el = document.createElement("div");
|
|
774
|
+
el.id = "__cw_rf__";
|
|
775
|
+
el.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;z-index:2147483647;pointer-events:none";
|
|
776
|
+
(document.body ?? document.documentElement).appendChild(el);
|
|
777
|
+
}
|
|
778
|
+
el.style.background = toggle ? "#000001" : "#000100";
|
|
779
|
+
}, t).catch(() => {
|
|
780
|
+
});
|
|
781
|
+
}
|
|
721
782
|
/**
|
|
722
783
|
* Wait for a given duration while forcing periodic repaints
|
|
723
784
|
* so CDP screencast keeps sending frames even on static pages.
|
|
@@ -727,10 +788,7 @@ var ClipwiseRecorder = class {
|
|
|
727
788
|
const endTime = Date.now() + durationMs;
|
|
728
789
|
let toggle = false;
|
|
729
790
|
while (Date.now() < endTime && this.isCapturing) {
|
|
730
|
-
await this.
|
|
731
|
-
document.documentElement.style.outline = t ? "0.001px solid transparent" : "none";
|
|
732
|
-
}, toggle).catch(() => {
|
|
733
|
-
});
|
|
791
|
+
await this.forceRepaint(toggle);
|
|
734
792
|
toggle = !toggle;
|
|
735
793
|
const remaining = endTime - Date.now();
|
|
736
794
|
if (remaining > 0) {
|
|
@@ -807,40 +865,57 @@ var ClipwiseRecorder = class {
|
|
|
807
865
|
timestamp: Date.now()
|
|
808
866
|
});
|
|
809
867
|
await this.page.click(action.selector);
|
|
868
|
+
this.keystrokeSessionId++;
|
|
869
|
+
const currentSessionId = this.keystrokeSessionId;
|
|
870
|
+
let typeRepaintToggle = false;
|
|
810
871
|
for (const char of action.text) {
|
|
811
|
-
await this.page.keyboard.type(char
|
|
872
|
+
await this.page.keyboard.type(char);
|
|
873
|
+
typeRepaintToggle = !typeRepaintToggle;
|
|
874
|
+
await this.forceRepaint(typeRepaintToggle);
|
|
812
875
|
this.keystrokeTimeline.push({
|
|
813
876
|
key: char,
|
|
814
|
-
timestamp: Date.now()
|
|
877
|
+
timestamp: Date.now(),
|
|
878
|
+
sessionId: currentSessionId
|
|
815
879
|
});
|
|
880
|
+
await new Promise((resolve2) => setTimeout(resolve2, action.delay));
|
|
816
881
|
}
|
|
817
882
|
break;
|
|
818
883
|
}
|
|
819
884
|
case "scroll": {
|
|
820
885
|
const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector, action.timeout) : null;
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
x: action.x,
|
|
839
|
-
y: action.y,
|
|
840
|
-
smooth: action.smooth,
|
|
841
|
-
selector: action.selector ?? null
|
|
886
|
+
const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
|
|
887
|
+
if (action.smooth && scrollDistance > 0) {
|
|
888
|
+
const scrollSteps = Math.max(12, Math.round(scrollDistance / 25));
|
|
889
|
+
const yStep = action.y / scrollSteps;
|
|
890
|
+
const xStep = action.x / scrollSteps;
|
|
891
|
+
for (let s = 0; s < scrollSteps; s++) {
|
|
892
|
+
await this.page.evaluate(
|
|
893
|
+
({ dy, dx, sel }) => {
|
|
894
|
+
const el = sel ? document.querySelector(sel) : window;
|
|
895
|
+
if (!el) return;
|
|
896
|
+
const opts = { left: dx, top: dy, behavior: "instant" };
|
|
897
|
+
if (el === window) window.scrollBy(opts);
|
|
898
|
+
else el.scrollBy(opts);
|
|
899
|
+
},
|
|
900
|
+
{ dy: yStep, dx: xStep, sel: action.selector ?? null }
|
|
901
|
+
);
|
|
902
|
+
await new Promise((resolve2) => setTimeout(resolve2, 30));
|
|
842
903
|
}
|
|
843
|
-
|
|
904
|
+
await this.waitWithRepaints(150);
|
|
905
|
+
} else {
|
|
906
|
+
await this.page.evaluate(
|
|
907
|
+
({ x, y, selector }) => {
|
|
908
|
+
const target = selector ? document.querySelector(selector) : window;
|
|
909
|
+
if (target) {
|
|
910
|
+
const options = { left: x, top: y, behavior: "instant" };
|
|
911
|
+
if (target === window) window.scrollBy(options);
|
|
912
|
+
else target.scrollBy(options);
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
{ x: action.x, y: action.y, selector: action.selector ?? null }
|
|
916
|
+
);
|
|
917
|
+
await this.waitWithRepaints(100);
|
|
918
|
+
}
|
|
844
919
|
if (scrollTarget) {
|
|
845
920
|
this.cursorPosition = scrollTarget;
|
|
846
921
|
this.cursorTimeline.push({
|
|
@@ -848,10 +923,7 @@ var ClipwiseRecorder = class {
|
|
|
848
923
|
timestamp: Date.now()
|
|
849
924
|
});
|
|
850
925
|
}
|
|
851
|
-
|
|
852
|
-
const scrollWait = action.smooth ? Math.max(600, Math.round(scrollDistance * 0.8)) : 100;
|
|
853
|
-
await this.waitWithRepaints(scrollWait);
|
|
854
|
-
await this.waitWithRepaints(150);
|
|
926
|
+
await this.waitWithRepaints(120);
|
|
855
927
|
break;
|
|
856
928
|
}
|
|
857
929
|
case "wait": {
|
|
@@ -903,25 +975,86 @@ var ClipwiseRecorder = class {
|
|
|
903
975
|
await this.waitWithRepaints(ACTION_GAP_MS);
|
|
904
976
|
}
|
|
905
977
|
/**
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
978
|
+
* Suppress all CSS transitions and animations on the page during cursor
|
|
979
|
+
* movement. Hover-state transitions (background, transform, box-shadow,
|
|
980
|
+
* etc.) on elements the cursor passes over generate CSS-animation-driven
|
|
981
|
+
* CDP frames that arrive asynchronously relative to our cursor step
|
|
982
|
+
* intervals. Those extra frames are timestamped when they're ACK-drained,
|
|
983
|
+
* which can be many milliseconds after the actual cursor moved — causing
|
|
984
|
+
* interpolateCursorAt() to map them to a newer cursor position while the
|
|
985
|
+
* screenshot still shows older content → visible stutter.
|
|
986
|
+
*
|
|
987
|
+
* Suppressing transitions during movement eliminates these extra frames
|
|
988
|
+
* entirely regardless of which elements the path crosses. Transitions are
|
|
989
|
+
* restored immediately after arrival, so hover effects on the final target
|
|
990
|
+
* element still appear during the subsequent holdDuration.
|
|
991
|
+
*/
|
|
992
|
+
async suppressTransitions() {
|
|
993
|
+
if (!this.page) return;
|
|
994
|
+
await this.page.evaluate(() => {
|
|
995
|
+
if (document.getElementById("__cw_notrans__")) return;
|
|
996
|
+
const s = document.createElement("style");
|
|
997
|
+
s.id = "__cw_notrans__";
|
|
998
|
+
s.textContent = "*{transition-duration:0s!important;transition-delay:0s!important}";
|
|
999
|
+
(document.head ?? document.documentElement).appendChild(s);
|
|
1000
|
+
}).catch(() => {
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
async restoreTransitions() {
|
|
1004
|
+
if (!this.page) return;
|
|
1005
|
+
await this.page.evaluate(() => {
|
|
1006
|
+
document.getElementById("__cw_notrans__")?.remove();
|
|
1007
|
+
}).catch(() => {
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Move cursor smoothly from current position to target.
|
|
1012
|
+
*
|
|
1013
|
+
* Key design decisions:
|
|
1014
|
+
* 1. Adaptive step count — proportional to travel distance so short and
|
|
1015
|
+
* long movements feel equally paced (pixelsPerStep controls speed).
|
|
1016
|
+
* 2. Forced repaint per step — moving the mouse in headless Chrome does NOT
|
|
1017
|
+
* visually change the screenshot (the cursor is rendered in post-processing).
|
|
1018
|
+
* Without a forced repaint, dedup collapses every intermediate frame into
|
|
1019
|
+
* the first one, making the cursor appear to teleport.
|
|
1020
|
+
* 3. Transition suppression — CSS transitions on hovered elements generate
|
|
1021
|
+
* asynchronous CDP frames that desync cursor position from screenshot
|
|
1022
|
+
* content. All transitions are suppressed for the duration of movement
|
|
1023
|
+
* and restored on arrival (see suppressTransitions / restoreTransitions).
|
|
1024
|
+
* 4. Capped bezier curve — perpendicular offset is capped at 30 px regardless
|
|
1025
|
+
* of distance, preventing a visible arc on long-distance movements.
|
|
909
1026
|
*/
|
|
910
1027
|
async moveCursorSmooth(target) {
|
|
911
1028
|
if (!this.page) return;
|
|
912
|
-
const
|
|
1029
|
+
const preset = CURSOR_SPEED_PRESETS[this.cursorSpeed];
|
|
913
1030
|
const from = { ...this.cursorPosition };
|
|
1031
|
+
const distance = Math.hypot(target.x - from.x, target.y - from.y);
|
|
1032
|
+
if (distance < 2) {
|
|
1033
|
+
this.cursorPosition = { ...target };
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const steps = Math.round(
|
|
1037
|
+
Math.min(Math.max(distance / preset.pixelsPerStep, preset.minSteps), preset.maxSteps)
|
|
1038
|
+
);
|
|
914
1039
|
const path = interpolatePath(from, target, steps);
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1040
|
+
let repaintToggle = false;
|
|
1041
|
+
await this.suppressTransitions();
|
|
1042
|
+
try {
|
|
1043
|
+
for (const point of path) {
|
|
1044
|
+
await this.page.mouse.move(point.x, point.y);
|
|
1045
|
+
repaintToggle = !repaintToggle;
|
|
1046
|
+
await this.forceRepaint(repaintToggle);
|
|
1047
|
+
this.cursorTimeline.push({
|
|
1048
|
+
position: { x: point.x, y: point.y },
|
|
1049
|
+
timestamp: Date.now()
|
|
1050
|
+
});
|
|
1051
|
+
await new Promise((resolve2) => setTimeout(resolve2, preset.stepDelayMs));
|
|
1052
|
+
}
|
|
1053
|
+
} finally {
|
|
1054
|
+
await this.restoreTransitions();
|
|
922
1055
|
}
|
|
923
1056
|
this.cursorPosition = { ...target };
|
|
924
|
-
await this.waitWithRepaints(
|
|
1057
|
+
await this.waitWithRepaints(80);
|
|
925
1058
|
}
|
|
926
1059
|
/**
|
|
927
1060
|
* Build CapturedFrame array from raw screencast frames,
|
|
@@ -1340,8 +1473,11 @@ async function renderCursor(frameBuffer, position, config, frameWidth, frameHeig
|
|
|
1340
1473
|
const size = Math.round(config.size * dpr);
|
|
1341
1474
|
const cursorSvg = buildCursorSvg(size, config.color);
|
|
1342
1475
|
const cursorBuffer = Buffer.from(cursorSvg);
|
|
1343
|
-
const
|
|
1344
|
-
const
|
|
1476
|
+
const tipOffsetX = Math.round(4 / 24 * size);
|
|
1477
|
+
const px = Math.round(position.x * dpr);
|
|
1478
|
+
const py = Math.round(position.y * dpr);
|
|
1479
|
+
const left = Math.max(0, Math.min(px - tipOffsetX, frameWidth - size));
|
|
1480
|
+
const top = Math.max(0, Math.min(py, frameHeight - size));
|
|
1345
1481
|
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
1346
1482
|
}
|
|
1347
1483
|
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight, dpr = 1) {
|
|
@@ -1403,6 +1539,17 @@ async function renderCursorTrail(frameBuffer, positions, config, frameWidth, fra
|
|
|
1403
1539
|
|
|
1404
1540
|
// src/effects/zoom.ts
|
|
1405
1541
|
import sharp3 from "sharp";
|
|
1542
|
+
var ZOOM_INTENSITY_SCALES = {
|
|
1543
|
+
subtle: 1.15,
|
|
1544
|
+
light: 1.25,
|
|
1545
|
+
moderate: 1.35,
|
|
1546
|
+
strong: 1.5,
|
|
1547
|
+
dramatic: 1.8
|
|
1548
|
+
};
|
|
1549
|
+
function resolveZoomScale(scale, intensity) {
|
|
1550
|
+
if (intensity !== void 0) return ZOOM_INTENSITY_SCALES[intensity];
|
|
1551
|
+
return scale;
|
|
1552
|
+
}
|
|
1406
1553
|
async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
|
|
1407
1554
|
if (scale <= 1) return frameBuffer;
|
|
1408
1555
|
const cropWidth = Math.round(frameWidth / scale);
|
|
@@ -1549,30 +1696,55 @@ async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
|
1549
1696
|
|
|
1550
1697
|
// src/effects/keystroke.ts
|
|
1551
1698
|
import sharp5 from "sharp";
|
|
1699
|
+
function escapeXml(s) {
|
|
1700
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1701
|
+
}
|
|
1702
|
+
function buildSessions(keystrokes) {
|
|
1703
|
+
const hasSessionIds = keystrokes.some((k) => k.sessionId !== void 0);
|
|
1704
|
+
if (!hasSessionIds) {
|
|
1705
|
+
const text = keystrokes.map((k) => k.key).join("");
|
|
1706
|
+
return text.length > 0 ? [text] : [];
|
|
1707
|
+
}
|
|
1708
|
+
const map = /* @__PURE__ */ new Map();
|
|
1709
|
+
for (const k of keystrokes) {
|
|
1710
|
+
const sid = k.sessionId ?? 0;
|
|
1711
|
+
map.set(sid, (map.get(sid) ?? "") + k.key);
|
|
1712
|
+
}
|
|
1713
|
+
return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([, text]) => text).filter((t) => t.length > 0);
|
|
1714
|
+
}
|
|
1552
1715
|
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight, dpr = 1) {
|
|
1553
1716
|
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
if (
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1717
|
+
if (!config.showTyping) return frameBuffer;
|
|
1718
|
+
const lastKeystroke = keystrokes[keystrokes.length - 1];
|
|
1719
|
+
const age = frameTimestamp - lastKeystroke.timestamp;
|
|
1720
|
+
if (age >= config.fadeAfter) return frameBuffer;
|
|
1721
|
+
const fadeStart = config.fadeAfter * 0.6;
|
|
1722
|
+
const globalOpacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
1723
|
+
if (globalOpacity <= 0) return frameBuffer;
|
|
1724
|
+
const allSessions = buildSessions(keystrokes);
|
|
1725
|
+
if (allSessions.length === 0) return frameBuffer;
|
|
1726
|
+
const sessions = allSessions.slice(-3);
|
|
1727
|
+
const lineCount = sessions.length;
|
|
1560
1728
|
const fontSize = config.fontSize * dpr;
|
|
1561
1729
|
const padding = config.padding * dpr;
|
|
1562
|
-
const charWidth = fontSize * 0.62;
|
|
1563
|
-
const textWidth = Math.ceil(displayText.length * charWidth);
|
|
1564
1730
|
const hudPadH = padding * 2;
|
|
1565
|
-
const hudPadV = padding * 1.
|
|
1566
|
-
const
|
|
1567
|
-
const
|
|
1568
|
-
const
|
|
1569
|
-
const
|
|
1570
|
-
const
|
|
1571
|
-
|
|
1572
|
-
|
|
1731
|
+
const hudPadV = padding * 1.4;
|
|
1732
|
+
const lineGap = Math.round(fontSize * 0.45);
|
|
1733
|
+
const charWidth = fontSize * 0.615;
|
|
1734
|
+
const maxHudWidth = frameWidth - 60 * dpr;
|
|
1735
|
+
const maxCharsPerLine = Math.max(10, Math.floor((maxHudWidth - hudPadH * 2) / charWidth));
|
|
1736
|
+
const lines = sessions.map(
|
|
1737
|
+
(text) => text.length > maxCharsPerLine ? text.slice(-maxCharsPerLine) : text
|
|
1738
|
+
);
|
|
1739
|
+
const maxLineLen = Math.max(...lines.map((l) => l.length));
|
|
1740
|
+
const hudWidth = Math.min(
|
|
1741
|
+
Math.ceil(maxLineLen * charWidth) + hudPadH * 2,
|
|
1742
|
+
maxHudWidth
|
|
1743
|
+
);
|
|
1744
|
+
const hudHeight = Math.ceil(fontSize * lineCount + lineGap * (lineCount - 1) + hudPadV * 2);
|
|
1573
1745
|
const margin = 30 * dpr;
|
|
1574
|
-
let hudX;
|
|
1575
1746
|
const hudY = frameHeight - hudHeight - margin;
|
|
1747
|
+
let hudX;
|
|
1576
1748
|
switch (config.position) {
|
|
1577
1749
|
case "bottom-left":
|
|
1578
1750
|
hudX = margin;
|
|
@@ -1583,17 +1755,24 @@ async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, confi
|
|
|
1583
1755
|
case "bottom-center":
|
|
1584
1756
|
default:
|
|
1585
1757
|
hudX = Math.round((frameWidth - hudWidth) / 2);
|
|
1586
|
-
break;
|
|
1587
1758
|
}
|
|
1588
|
-
const
|
|
1589
|
-
const
|
|
1590
|
-
const
|
|
1759
|
+
const LINE_OPACITY_FACTORS = [0.45, 0.7, 1];
|
|
1760
|
+
const opacityFactors = LINE_OPACITY_FACTORS.slice(-lineCount);
|
|
1761
|
+
const rx = (8 * dpr).toFixed(1);
|
|
1762
|
+
const boxOp = (globalOpacity * 0.92).toFixed(3);
|
|
1763
|
+
const textX = hudX + hudPadH;
|
|
1764
|
+
const baselineY = hudY + hudPadV + fontSize * 0.82;
|
|
1765
|
+
const textElements = lines.map((line, i) => {
|
|
1766
|
+
const op = (globalOpacity * opacityFactors[i]).toFixed(3);
|
|
1767
|
+
const lineY = baselineY + i * (fontSize + lineGap);
|
|
1768
|
+
return `<text x="${textX}" y="${lineY}"
|
|
1769
|
+
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1770
|
+
fill="${config.textColor}" opacity="${op}">${escapeXml(line)}</text>`;
|
|
1771
|
+
}).join("\n ");
|
|
1591
1772
|
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
|
|
1592
1773
|
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
1593
|
-
rx="${
|
|
1594
|
-
|
|
1595
|
-
font-family="monospace, Menlo, Consolas" font-size="${fontSize}"
|
|
1596
|
-
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
1774
|
+
rx="${rx}" ry="${rx}" fill="${config.backgroundColor}" opacity="${boxOp}" />
|
|
1775
|
+
${textElements}
|
|
1597
1776
|
</svg>`;
|
|
1598
1777
|
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1599
1778
|
}
|
|
@@ -1667,6 +1846,11 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1667
1846
|
clickProgress: context?.clickProgress ?? null,
|
|
1668
1847
|
cursorTrail: context?.cursorTrail ?? []
|
|
1669
1848
|
};
|
|
1849
|
+
const frameOffset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1850
|
+
const withFrameOffset = (pos) => ({
|
|
1851
|
+
x: pos.x + frameOffset.left / Math.max(1, dpr),
|
|
1852
|
+
y: pos.y + frameOffset.top / Math.max(1, dpr)
|
|
1853
|
+
});
|
|
1670
1854
|
if (effects.deviceFrame.enabled) {
|
|
1671
1855
|
const sl2 = ctx.staticLayers;
|
|
1672
1856
|
if (sl2?.browserChromePng && effects.deviceFrame.type === "browser") {
|
|
@@ -1687,7 +1871,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1687
1871
|
if (effects.cursor.enabled && effects.cursor.highlight && frame.cursorPosition) {
|
|
1688
1872
|
buffer = await renderCursorHighlight(
|
|
1689
1873
|
buffer,
|
|
1690
|
-
frame.cursorPosition,
|
|
1874
|
+
withFrameOffset(frame.cursorPosition),
|
|
1691
1875
|
effects.cursor,
|
|
1692
1876
|
width,
|
|
1693
1877
|
height,
|
|
@@ -1697,7 +1881,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1697
1881
|
if (effects.cursor.enabled && effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1698
1882
|
buffer = await renderCursorTrail(
|
|
1699
1883
|
buffer,
|
|
1700
|
-
ctx.cursorTrail,
|
|
1884
|
+
ctx.cursorTrail.map(withFrameOffset),
|
|
1701
1885
|
effects.cursor,
|
|
1702
1886
|
width,
|
|
1703
1887
|
height,
|
|
@@ -1707,7 +1891,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1707
1891
|
if (effects.cursor.enabled && frame.cursorPosition) {
|
|
1708
1892
|
buffer = await renderCursor(
|
|
1709
1893
|
buffer,
|
|
1710
|
-
frame.cursorPosition,
|
|
1894
|
+
withFrameOffset(frame.cursorPosition),
|
|
1711
1895
|
effects.cursor,
|
|
1712
1896
|
width,
|
|
1713
1897
|
height,
|
|
@@ -1718,7 +1902,7 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1718
1902
|
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1719
1903
|
buffer = await renderClickEffect(
|
|
1720
1904
|
buffer,
|
|
1721
|
-
frame.clickPosition,
|
|
1905
|
+
withFrameOffset(frame.clickPosition),
|
|
1722
1906
|
effects.cursor,
|
|
1723
1907
|
progress,
|
|
1724
1908
|
width,
|
|
@@ -1726,6 +1910,16 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1726
1910
|
dpr
|
|
1727
1911
|
);
|
|
1728
1912
|
}
|
|
1913
|
+
const scale = ctx.zoomScale;
|
|
1914
|
+
if (effects.zoom.enabled && scale > 1) {
|
|
1915
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1916
|
+
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1917
|
+
const focusPoint = {
|
|
1918
|
+
x: rawFocus.x * dpr + offset.left,
|
|
1919
|
+
y: rawFocus.y * dpr + offset.top
|
|
1920
|
+
};
|
|
1921
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1922
|
+
}
|
|
1729
1923
|
if (effects.keystroke.enabled && frame.keystrokes) {
|
|
1730
1924
|
buffer = await renderKeystrokeHud(
|
|
1731
1925
|
buffer,
|
|
@@ -1737,16 +1931,6 @@ async function composeFrame(frame, effects, output, context) {
|
|
|
1737
1931
|
dpr
|
|
1738
1932
|
);
|
|
1739
1933
|
}
|
|
1740
|
-
const scale = ctx.zoomScale;
|
|
1741
|
-
if (effects.zoom.enabled && scale > 1) {
|
|
1742
|
-
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: frame.viewport.width / 2, y: frame.viewport.height / 2 };
|
|
1743
|
-
const offset = getFrameOffset(effects.deviceFrame, dpr);
|
|
1744
|
-
const focusPoint = {
|
|
1745
|
-
x: rawFocus.x * dpr + offset.left,
|
|
1746
|
-
y: rawFocus.y * dpr + offset.top
|
|
1747
|
-
};
|
|
1748
|
-
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1749
|
-
}
|
|
1750
1934
|
const sl = ctx.staticLayers;
|
|
1751
1935
|
if (sl) {
|
|
1752
1936
|
const padding = effects.background.padding;
|
|
@@ -1965,6 +2149,10 @@ var CanvasRenderer = class {
|
|
|
1965
2149
|
this.output.fps * (this.effects.zoom.duration / 1e3)
|
|
1966
2150
|
);
|
|
1967
2151
|
const clickLookup = this.effects.zoom.enabled ? buildZoomClickLookup(frames) : [];
|
|
2152
|
+
const effectiveScale = resolveZoomScale(
|
|
2153
|
+
this.effects.zoom.scale,
|
|
2154
|
+
this.effects.zoom.intensity
|
|
2155
|
+
);
|
|
1968
2156
|
for (let i = 0; i < frames.length; i++) {
|
|
1969
2157
|
const frame = frames[i];
|
|
1970
2158
|
let zoomScale = 1;
|
|
@@ -1972,7 +2160,7 @@ var CanvasRenderer = class {
|
|
|
1972
2160
|
zoomScale = calculateAdaptiveZoomFromLookup(
|
|
1973
2161
|
clickLookup,
|
|
1974
2162
|
i,
|
|
1975
|
-
|
|
2163
|
+
effectiveScale,
|
|
1976
2164
|
transitionFrames
|
|
1977
2165
|
);
|
|
1978
2166
|
}
|
|
@@ -2088,6 +2276,10 @@ var CanvasRenderer = class {
|
|
|
2088
2276
|
let nextToDispatch = 0;
|
|
2089
2277
|
let nextToYield = 0;
|
|
2090
2278
|
const canDispatch = (i) => i < frames.length && (sourceComplete || frames.length > i + transitionFrames);
|
|
2279
|
+
const effectiveScale = resolveZoomScale(
|
|
2280
|
+
this.effects.zoom.scale,
|
|
2281
|
+
this.effects.zoom.intensity
|
|
2282
|
+
);
|
|
2091
2283
|
const computeContext = (i) => {
|
|
2092
2284
|
const frame = frames[i];
|
|
2093
2285
|
let zoomScale = 1;
|
|
@@ -2098,7 +2290,7 @@ var CanvasRenderer = class {
|
|
|
2098
2290
|
frames.slice(lo, hi + 1),
|
|
2099
2291
|
lo,
|
|
2100
2292
|
i,
|
|
2101
|
-
|
|
2293
|
+
effectiveScale,
|
|
2102
2294
|
transitionFrames
|
|
2103
2295
|
);
|
|
2104
2296
|
}
|