clipwise 0.1.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/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +2150 -0
- package/dist/index.d.ts +1631 -0
- package/dist/index.js +1704 -0
- package/package.json +69 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1704 @@
|
|
|
1
|
+
// src/core/recorder.ts
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
|
|
4
|
+
// src/core/cursor-tracker.ts
|
|
5
|
+
function easeInOutCubic(t) {
|
|
6
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
7
|
+
}
|
|
8
|
+
function interpolatePath(from, to, steps) {
|
|
9
|
+
if (steps <= 0) return [to];
|
|
10
|
+
if (steps === 1) return [from, to];
|
|
11
|
+
const dx = to.x - from.x;
|
|
12
|
+
const dy = to.y - from.y;
|
|
13
|
+
const cp1 = {
|
|
14
|
+
x: from.x + dx * 0.25 + dy * 0.1,
|
|
15
|
+
y: from.y + dy * 0.25 - dx * 0.1
|
|
16
|
+
};
|
|
17
|
+
const cp2 = {
|
|
18
|
+
x: from.x + dx * 0.75 - dy * 0.1,
|
|
19
|
+
y: from.y + dy * 0.75 + dx * 0.1
|
|
20
|
+
};
|
|
21
|
+
const points = [];
|
|
22
|
+
for (let i = 0; i <= steps; i++) {
|
|
23
|
+
const rawT = i / steps;
|
|
24
|
+
const t = easeInOutCubic(rawT);
|
|
25
|
+
const oneMinusT = 1 - t;
|
|
26
|
+
const x = oneMinusT * oneMinusT * oneMinusT * from.x + 3 * oneMinusT * oneMinusT * t * cp1.x + 3 * oneMinusT * t * t * cp2.x + t * t * t * to.x;
|
|
27
|
+
const y = oneMinusT * oneMinusT * oneMinusT * from.y + 3 * oneMinusT * oneMinusT * t * cp1.y + 3 * oneMinusT * t * t * cp2.y + t * t * t * to.y;
|
|
28
|
+
points.push({ x: Math.round(x), y: Math.round(y) });
|
|
29
|
+
}
|
|
30
|
+
return points;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/core/screenshot.ts
|
|
34
|
+
async function getElementCenter(page, selector) {
|
|
35
|
+
if (!/^[\w\-#.\[\]="':\s,>+~*()@^$|]+$/.test(selector)) {
|
|
36
|
+
throw new Error(`Invalid selector: ${selector}`);
|
|
37
|
+
}
|
|
38
|
+
const element = page.locator(selector).first();
|
|
39
|
+
await element.waitFor({ state: "visible", timeout: 5e3 });
|
|
40
|
+
const box = await element.boundingBox();
|
|
41
|
+
if (!box) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Element "${selector}" not found or has no bounding box`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
x: Math.round(box.x + box.width / 2),
|
|
48
|
+
y: Math.round(box.y + box.height / 2)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/core/recorder.ts
|
|
53
|
+
var CLICK_EFFECT_DURATION_MS = 500;
|
|
54
|
+
var REPAINT_INTERVAL_MS = 50;
|
|
55
|
+
var ACTION_GAP_MS = 30;
|
|
56
|
+
var CURSOR_SPEED_PRESETS = {
|
|
57
|
+
fast: { steps: 12, delay: 6 },
|
|
58
|
+
// ~72ms total
|
|
59
|
+
normal: { steps: 18, delay: 8 },
|
|
60
|
+
// ~144ms total
|
|
61
|
+
slow: { steps: 24, delay: 12 }
|
|
62
|
+
// ~288ms total
|
|
63
|
+
};
|
|
64
|
+
var ClipwiseRecorder = class {
|
|
65
|
+
browser = null;
|
|
66
|
+
context = null;
|
|
67
|
+
page = null;
|
|
68
|
+
cdpClient = null;
|
|
69
|
+
rawFrames = [];
|
|
70
|
+
cursorTimeline = [];
|
|
71
|
+
clickTimeline = [];
|
|
72
|
+
keystrokeTimeline = [];
|
|
73
|
+
currentStepIndex = 0;
|
|
74
|
+
cursorPosition = { x: 0, y: 0 };
|
|
75
|
+
viewport = { width: 1280, height: 800 };
|
|
76
|
+
isCapturing = false;
|
|
77
|
+
targetFps = 30;
|
|
78
|
+
cursorSpeed = "fast";
|
|
79
|
+
/**
|
|
80
|
+
* Launch the browser and create a page with the scenario viewport.
|
|
81
|
+
*/
|
|
82
|
+
async init(scenario) {
|
|
83
|
+
this.viewport = {
|
|
84
|
+
width: scenario.viewport.width,
|
|
85
|
+
height: scenario.viewport.height
|
|
86
|
+
};
|
|
87
|
+
this.targetFps = scenario.output.fps;
|
|
88
|
+
this.cursorSpeed = scenario.effects.cursor.speed;
|
|
89
|
+
this.browser = await chromium.launch({ headless: true });
|
|
90
|
+
this.context = await this.browser.newContext({
|
|
91
|
+
viewport: this.viewport
|
|
92
|
+
});
|
|
93
|
+
this.page = await this.context.newPage();
|
|
94
|
+
this.rawFrames = [];
|
|
95
|
+
this.cursorTimeline = [];
|
|
96
|
+
this.clickTimeline = [];
|
|
97
|
+
this.keystrokeTimeline = [];
|
|
98
|
+
this.currentStepIndex = 0;
|
|
99
|
+
this.cursorPosition = { x: 0, y: 0 };
|
|
100
|
+
this.isCapturing = false;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Start CDP screencast for continuous frame capture.
|
|
104
|
+
* Frames are received asynchronously and stored in rawFrames.
|
|
105
|
+
*/
|
|
106
|
+
async startCapture() {
|
|
107
|
+
if (!this.page) throw new Error("Page not initialized");
|
|
108
|
+
this.cdpClient = await this.page.context().newCDPSession(this.page);
|
|
109
|
+
this.isCapturing = true;
|
|
110
|
+
this.cdpClient.on(
|
|
111
|
+
"Page.screencastFrame",
|
|
112
|
+
async (event) => {
|
|
113
|
+
if (!this.isCapturing || !this.cdpClient) return;
|
|
114
|
+
const buffer = Buffer.from(event.data, "base64");
|
|
115
|
+
this.rawFrames.push({
|
|
116
|
+
buffer,
|
|
117
|
+
timestamp: Date.now()
|
|
118
|
+
});
|
|
119
|
+
await this.cdpClient.send("Page.screencastFrameAck", {
|
|
120
|
+
sessionId: event.sessionId
|
|
121
|
+
}).catch(() => {
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
await this.cdpClient.send("Page.startScreencast", {
|
|
126
|
+
format: "jpeg",
|
|
127
|
+
quality: 95,
|
|
128
|
+
maxWidth: this.viewport.width,
|
|
129
|
+
maxHeight: this.viewport.height,
|
|
130
|
+
everyNthFrame: 1
|
|
131
|
+
});
|
|
132
|
+
this.cursorTimeline.push({
|
|
133
|
+
position: { ...this.cursorPosition },
|
|
134
|
+
timestamp: Date.now()
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Stop CDP screencast and flush remaining frames.
|
|
139
|
+
*/
|
|
140
|
+
async stopCapture() {
|
|
141
|
+
this.isCapturing = false;
|
|
142
|
+
if (this.cdpClient) {
|
|
143
|
+
await this.cdpClient.send("Page.stopScreencast").catch(() => {
|
|
144
|
+
});
|
|
145
|
+
await this.cdpClient.detach().catch(() => {
|
|
146
|
+
});
|
|
147
|
+
this.cdpClient = null;
|
|
148
|
+
}
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Execute the full scenario with continuous capture and return a RecordingSession.
|
|
153
|
+
*/
|
|
154
|
+
async record(scenario) {
|
|
155
|
+
await this.init(scenario);
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
try {
|
|
158
|
+
await this.startCapture();
|
|
159
|
+
for (let si = 0; si < scenario.steps.length; si++) {
|
|
160
|
+
const step = scenario.steps[si];
|
|
161
|
+
this.currentStepIndex = si;
|
|
162
|
+
for (const action of step.actions) {
|
|
163
|
+
await this.executeAction(action);
|
|
164
|
+
}
|
|
165
|
+
if (step.captureDelay > 0) {
|
|
166
|
+
await this.waitWithRepaints(step.captureDelay);
|
|
167
|
+
}
|
|
168
|
+
const holdMs = step.holdDuration;
|
|
169
|
+
if (holdMs > 0) {
|
|
170
|
+
await this.waitWithRepaints(holdMs);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await this.stopCapture();
|
|
174
|
+
const rawFrames = this.buildCapturedFrames();
|
|
175
|
+
const recordingDurationMs = Date.now() - startTime;
|
|
176
|
+
const frames = this.resampleToTargetFps(
|
|
177
|
+
rawFrames,
|
|
178
|
+
recordingDurationMs
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
scenario,
|
|
182
|
+
frames,
|
|
183
|
+
startTime,
|
|
184
|
+
endTime: Date.now()
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
await this.stopCapture().catch(() => {
|
|
188
|
+
});
|
|
189
|
+
const rawFrames = this.buildCapturedFrames();
|
|
190
|
+
const recordingDurationMs = Date.now() - startTime;
|
|
191
|
+
const frames = this.resampleToTargetFps(
|
|
192
|
+
rawFrames,
|
|
193
|
+
recordingDurationMs
|
|
194
|
+
);
|
|
195
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
196
|
+
err.partialSession = {
|
|
197
|
+
scenario,
|
|
198
|
+
frames,
|
|
199
|
+
startTime,
|
|
200
|
+
endTime: Date.now()
|
|
201
|
+
};
|
|
202
|
+
throw err;
|
|
203
|
+
} finally {
|
|
204
|
+
await this.cleanup();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Wait for a given duration while forcing periodic repaints
|
|
209
|
+
* so CDP screencast keeps sending frames even on static pages.
|
|
210
|
+
*/
|
|
211
|
+
async waitWithRepaints(durationMs) {
|
|
212
|
+
if (!this.page || durationMs <= 0) return;
|
|
213
|
+
const endTime = Date.now() + durationMs;
|
|
214
|
+
let toggle = false;
|
|
215
|
+
while (Date.now() < endTime && this.isCapturing) {
|
|
216
|
+
await this.page.evaluate((t) => {
|
|
217
|
+
document.documentElement.style.outline = t ? "0.001px solid transparent" : "none";
|
|
218
|
+
}, toggle).catch(() => {
|
|
219
|
+
});
|
|
220
|
+
toggle = !toggle;
|
|
221
|
+
const remaining = endTime - Date.now();
|
|
222
|
+
if (remaining > 0) {
|
|
223
|
+
await new Promise(
|
|
224
|
+
(resolve) => setTimeout(resolve, Math.min(REPAINT_INTERVAL_MS, remaining))
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Execute a single action. CDP screencast captures frames continuously
|
|
231
|
+
* in the background while actions are performed.
|
|
232
|
+
*/
|
|
233
|
+
async executeAction(action) {
|
|
234
|
+
if (!this.page) {
|
|
235
|
+
throw new Error("Page not initialized. Call init() first.");
|
|
236
|
+
}
|
|
237
|
+
switch (action.action) {
|
|
238
|
+
case "navigate": {
|
|
239
|
+
await this.page.goto(action.url, { waitUntil: action.waitUntil });
|
|
240
|
+
await this.waitWithRepaints(300);
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "click": {
|
|
244
|
+
const target = await getElementCenter(this.page, action.selector);
|
|
245
|
+
await this.moveCursorSmooth(target);
|
|
246
|
+
this.clickTimeline.push({
|
|
247
|
+
position: { ...target },
|
|
248
|
+
timestamp: Date.now()
|
|
249
|
+
});
|
|
250
|
+
await this.page.click(action.selector, {
|
|
251
|
+
delay: action.delay
|
|
252
|
+
});
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case "type": {
|
|
256
|
+
const inputTarget = await getElementCenter(
|
|
257
|
+
this.page,
|
|
258
|
+
action.selector
|
|
259
|
+
);
|
|
260
|
+
await this.moveCursorSmooth(inputTarget);
|
|
261
|
+
this.clickTimeline.push({
|
|
262
|
+
position: { ...inputTarget },
|
|
263
|
+
timestamp: Date.now()
|
|
264
|
+
});
|
|
265
|
+
await this.page.click(action.selector);
|
|
266
|
+
for (const char of action.text) {
|
|
267
|
+
await this.page.keyboard.type(char, { delay: action.delay });
|
|
268
|
+
this.keystrokeTimeline.push({
|
|
269
|
+
key: char,
|
|
270
|
+
timestamp: Date.now()
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case "scroll": {
|
|
276
|
+
const scrollTarget = action.selector ? await getElementCenter(this.page, action.selector) : null;
|
|
277
|
+
await this.page.evaluate(
|
|
278
|
+
({ x, y, smooth, selector }) => {
|
|
279
|
+
const target = selector ? document.querySelector(selector) : window;
|
|
280
|
+
if (target) {
|
|
281
|
+
const options = {
|
|
282
|
+
left: x,
|
|
283
|
+
top: y,
|
|
284
|
+
behavior: smooth ? "smooth" : "instant"
|
|
285
|
+
};
|
|
286
|
+
if (target === window) {
|
|
287
|
+
window.scrollBy(options);
|
|
288
|
+
} else {
|
|
289
|
+
target.scrollBy(options);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
x: action.x,
|
|
295
|
+
y: action.y,
|
|
296
|
+
smooth: action.smooth,
|
|
297
|
+
selector: action.selector ?? null
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
if (scrollTarget) {
|
|
301
|
+
this.cursorPosition = scrollTarget;
|
|
302
|
+
this.cursorTimeline.push({
|
|
303
|
+
position: { ...scrollTarget },
|
|
304
|
+
timestamp: Date.now()
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const scrollDistance = Math.abs(action.y) + Math.abs(action.x);
|
|
308
|
+
const scrollWait = action.smooth ? Math.max(600, Math.round(scrollDistance * 0.8)) : 100;
|
|
309
|
+
await this.waitWithRepaints(scrollWait);
|
|
310
|
+
await this.waitWithRepaints(150);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "wait": {
|
|
314
|
+
await this.waitWithRepaints(action.duration);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "hover": {
|
|
318
|
+
const hoverTarget = await getElementCenter(
|
|
319
|
+
this.page,
|
|
320
|
+
action.selector
|
|
321
|
+
);
|
|
322
|
+
await this.moveCursorSmooth(hoverTarget);
|
|
323
|
+
await this.page.hover(action.selector);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
case "screenshot": {
|
|
327
|
+
await this.waitWithRepaints(100);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
await this.waitWithRepaints(ACTION_GAP_MS);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Move cursor smoothly from current position to target using
|
|
335
|
+
* manual step-by-step movement with delays between each step.
|
|
336
|
+
* Speed is controlled by the cursor.speed preset (fast/normal/slow).
|
|
337
|
+
*/
|
|
338
|
+
async moveCursorSmooth(target) {
|
|
339
|
+
if (!this.page) return;
|
|
340
|
+
const { steps, delay } = CURSOR_SPEED_PRESETS[this.cursorSpeed];
|
|
341
|
+
const from = { ...this.cursorPosition };
|
|
342
|
+
const path = interpolatePath(from, target, steps);
|
|
343
|
+
for (const point of path) {
|
|
344
|
+
await this.page.mouse.move(point.x, point.y);
|
|
345
|
+
this.cursorTimeline.push({
|
|
346
|
+
position: { x: point.x, y: point.y },
|
|
347
|
+
timestamp: Date.now()
|
|
348
|
+
});
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
350
|
+
}
|
|
351
|
+
this.cursorPosition = { ...target };
|
|
352
|
+
await this.waitWithRepaints(100);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Build CapturedFrame array from raw screencast frames,
|
|
356
|
+
* interpolating cursor positions and mapping click events.
|
|
357
|
+
*/
|
|
358
|
+
buildCapturedFrames() {
|
|
359
|
+
if (this.rawFrames.length === 0) return [];
|
|
360
|
+
return this.rawFrames.map((raw, index) => {
|
|
361
|
+
const cursorPos = this.interpolateCursorAt(raw.timestamp);
|
|
362
|
+
const clickEvent = this.clickTimeline.find(
|
|
363
|
+
(click) => raw.timestamp >= click.timestamp && raw.timestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
364
|
+
);
|
|
365
|
+
let clickProgress;
|
|
366
|
+
if (clickEvent) {
|
|
367
|
+
const elapsed = raw.timestamp - clickEvent.timestamp;
|
|
368
|
+
clickProgress = Math.min(1, elapsed / CLICK_EFFECT_DURATION_MS);
|
|
369
|
+
}
|
|
370
|
+
const frameKeystrokes = this.keystrokeTimeline.filter(
|
|
371
|
+
(k) => k.timestamp <= raw.timestamp
|
|
372
|
+
);
|
|
373
|
+
return {
|
|
374
|
+
index,
|
|
375
|
+
screenshot: raw.buffer,
|
|
376
|
+
timestamp: raw.timestamp,
|
|
377
|
+
cursorPosition: cursorPos,
|
|
378
|
+
clickPosition: clickEvent?.position ?? null,
|
|
379
|
+
clickProgress,
|
|
380
|
+
viewport: { ...this.viewport },
|
|
381
|
+
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0,
|
|
382
|
+
stepIndex: this.currentStepIndex
|
|
383
|
+
};
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Resample captured frames to the target FPS.
|
|
388
|
+
*
|
|
389
|
+
* Even if CDP only sent a few unique screenshots, we generate enough
|
|
390
|
+
* output frames for smooth playback. Each output frame:
|
|
391
|
+
* - Uses the nearest raw screenshot (may be duplicated)
|
|
392
|
+
* - Gets a uniquely interpolated cursor position
|
|
393
|
+
* - Gets properly mapped click effects
|
|
394
|
+
*/
|
|
395
|
+
resampleToTargetFps(frames, recordingDurationMs) {
|
|
396
|
+
if (frames.length === 0) return [];
|
|
397
|
+
const targetFrameCount = Math.max(
|
|
398
|
+
frames.length,
|
|
399
|
+
Math.round(recordingDurationMs / 1e3 * this.targetFps)
|
|
400
|
+
);
|
|
401
|
+
if (targetFrameCount <= frames.length) return frames;
|
|
402
|
+
const startTime = frames[0].timestamp;
|
|
403
|
+
const endTime = frames[frames.length - 1].timestamp;
|
|
404
|
+
const duration = Math.max(1, endTime - startTime);
|
|
405
|
+
const resampled = [];
|
|
406
|
+
for (let i = 0; i < targetFrameCount; i++) {
|
|
407
|
+
const t = targetFrameCount > 1 ? i / (targetFrameCount - 1) : 0;
|
|
408
|
+
const targetTimestamp = startTime + t * duration;
|
|
409
|
+
let nearestIdx = 0;
|
|
410
|
+
let minDist = Infinity;
|
|
411
|
+
for (let j = 0; j < frames.length; j++) {
|
|
412
|
+
const dist = Math.abs(frames[j].timestamp - targetTimestamp);
|
|
413
|
+
if (dist < minDist) {
|
|
414
|
+
minDist = dist;
|
|
415
|
+
nearestIdx = j;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const cursorPos = this.interpolateCursorAt(targetTimestamp);
|
|
419
|
+
const clickEvent = this.clickTimeline.find(
|
|
420
|
+
(click) => targetTimestamp >= click.timestamp && targetTimestamp <= click.timestamp + CLICK_EFFECT_DURATION_MS
|
|
421
|
+
);
|
|
422
|
+
let clickProgress;
|
|
423
|
+
if (clickEvent) {
|
|
424
|
+
const elapsed = targetTimestamp - clickEvent.timestamp;
|
|
425
|
+
clickProgress = Math.min(1, elapsed / CLICK_EFFECT_DURATION_MS);
|
|
426
|
+
}
|
|
427
|
+
const frameKeystrokes = this.keystrokeTimeline.filter(
|
|
428
|
+
(k) => k.timestamp <= targetTimestamp
|
|
429
|
+
);
|
|
430
|
+
resampled.push({
|
|
431
|
+
index: i,
|
|
432
|
+
screenshot: frames[nearestIdx].screenshot,
|
|
433
|
+
timestamp: targetTimestamp,
|
|
434
|
+
cursorPosition: cursorPos,
|
|
435
|
+
clickPosition: clickEvent?.position ?? null,
|
|
436
|
+
clickProgress,
|
|
437
|
+
viewport: { ...this.viewport },
|
|
438
|
+
stepName: frames[nearestIdx].stepName,
|
|
439
|
+
stepIndex: frames[nearestIdx].stepIndex,
|
|
440
|
+
keystrokes: frameKeystrokes.length > 0 ? frameKeystrokes : void 0
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return resampled;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Interpolate cursor position at a given timestamp using the cursor timeline.
|
|
447
|
+
*/
|
|
448
|
+
interpolateCursorAt(timestamp) {
|
|
449
|
+
if (this.cursorTimeline.length === 0) return { x: 0, y: 0 };
|
|
450
|
+
if (this.cursorTimeline.length === 1) {
|
|
451
|
+
return { ...this.cursorTimeline[0].position };
|
|
452
|
+
}
|
|
453
|
+
let before = this.cursorTimeline[0];
|
|
454
|
+
let after = this.cursorTimeline[this.cursorTimeline.length - 1];
|
|
455
|
+
for (let i = 0; i < this.cursorTimeline.length - 1; i++) {
|
|
456
|
+
if (this.cursorTimeline[i].timestamp <= timestamp && this.cursorTimeline[i + 1].timestamp >= timestamp) {
|
|
457
|
+
before = this.cursorTimeline[i];
|
|
458
|
+
after = this.cursorTimeline[i + 1];
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (timestamp <= before.timestamp) return { ...before.position };
|
|
463
|
+
if (timestamp >= after.timestamp) return { ...after.position };
|
|
464
|
+
const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
|
|
465
|
+
return {
|
|
466
|
+
x: Math.round(
|
|
467
|
+
before.position.x + (after.position.x - before.position.x) * t
|
|
468
|
+
),
|
|
469
|
+
y: Math.round(
|
|
470
|
+
before.position.y + (after.position.y - before.position.y) * t
|
|
471
|
+
)
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Clean up browser resources. Always called after recording.
|
|
476
|
+
*/
|
|
477
|
+
async cleanup() {
|
|
478
|
+
if (this.cdpClient) {
|
|
479
|
+
await this.cdpClient.detach().catch(() => {
|
|
480
|
+
});
|
|
481
|
+
this.cdpClient = null;
|
|
482
|
+
}
|
|
483
|
+
if (this.context) {
|
|
484
|
+
await this.context.close().catch(() => {
|
|
485
|
+
});
|
|
486
|
+
this.context = null;
|
|
487
|
+
}
|
|
488
|
+
if (this.browser) {
|
|
489
|
+
await this.browser.close().catch(() => {
|
|
490
|
+
});
|
|
491
|
+
this.browser = null;
|
|
492
|
+
}
|
|
493
|
+
this.page = null;
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// src/compose/canvas-renderer.ts
|
|
498
|
+
import sharp8 from "sharp";
|
|
499
|
+
|
|
500
|
+
// src/effects/frame.ts
|
|
501
|
+
import sharp from "sharp";
|
|
502
|
+
var TITLE_BAR_HEIGHT = 40;
|
|
503
|
+
var TRAFFIC_LIGHT_Y = 14;
|
|
504
|
+
var TRAFFIC_LIGHT_RADIUS = 6;
|
|
505
|
+
var TRAFFIC_LIGHTS_START_X = 16;
|
|
506
|
+
var TRAFFIC_LIGHT_GAP = 22;
|
|
507
|
+
var ADDRESS_BAR_HEIGHT = 24;
|
|
508
|
+
var ADDRESS_BAR_MARGIN = 70;
|
|
509
|
+
var IPHONE_BEZEL = { sides: 12, top: 50, bottom: 34 };
|
|
510
|
+
var IPHONE_OUTER_RADIUS = 47;
|
|
511
|
+
var IPHONE_INNER_RADIUS = 39;
|
|
512
|
+
var IPHONE_ISLAND = { width: 120, height: 36 };
|
|
513
|
+
var IPHONE_HOME_BAR = { width: 134, height: 5 };
|
|
514
|
+
var IPAD_BEZEL = { sides: 20, top: 24, bottom: 24 };
|
|
515
|
+
var IPAD_OUTER_RADIUS = 18;
|
|
516
|
+
var IPAD_INNER_RADIUS = 12;
|
|
517
|
+
var ANDROID_BEZEL = { sides: 8, top: 32, bottom: 20 };
|
|
518
|
+
var ANDROID_OUTER_RADIUS = 35;
|
|
519
|
+
var ANDROID_INNER_RADIUS = 30;
|
|
520
|
+
var ANDROID_CAMERA_RADIUS = 6;
|
|
521
|
+
function buildBrowserChromeSvg(width, darkMode) {
|
|
522
|
+
const bg = darkMode ? "#2d2d2d" : "#e8e8e8";
|
|
523
|
+
const addressBg = darkMode ? "#1a1a1a" : "#ffffff";
|
|
524
|
+
const addressBorder = darkMode ? "#444444" : "#d0d0d0";
|
|
525
|
+
const textColor = darkMode ? "#999999" : "#666666";
|
|
526
|
+
const trafficLights = [
|
|
527
|
+
{ cx: TRAFFIC_LIGHTS_START_X, fill: "#ff5f57" },
|
|
528
|
+
{ cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP, fill: "#febc2e" },
|
|
529
|
+
{ cx: TRAFFIC_LIGHTS_START_X + TRAFFIC_LIGHT_GAP * 2, fill: "#28c840" }
|
|
530
|
+
].map(
|
|
531
|
+
(light) => `<circle cx="${light.cx}" cy="${TRAFFIC_LIGHT_Y}" r="${TRAFFIC_LIGHT_RADIUS}" fill="${light.fill}"/>`
|
|
532
|
+
).join("\n ");
|
|
533
|
+
const addressBarWidth = width - ADDRESS_BAR_MARGIN * 2;
|
|
534
|
+
const addressBarX = ADDRESS_BAR_MARGIN;
|
|
535
|
+
const addressBarY = (TITLE_BAR_HEIGHT - ADDRESS_BAR_HEIGHT) / 2;
|
|
536
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${TITLE_BAR_HEIGHT}">
|
|
537
|
+
<rect width="${width}" height="${TITLE_BAR_HEIGHT}" fill="${bg}"/>
|
|
538
|
+
${trafficLights}
|
|
539
|
+
<rect x="${addressBarX}" y="${addressBarY}" width="${addressBarWidth}" height="${ADDRESS_BAR_HEIGHT}"
|
|
540
|
+
rx="6" ry="6" fill="${addressBg}" stroke="${addressBorder}" stroke-width="1"/>
|
|
541
|
+
<text x="${width / 2}" y="${TRAFFIC_LIGHT_Y + 4}" text-anchor="middle"
|
|
542
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="12" fill="${textColor}">
|
|
543
|
+
localhost
|
|
544
|
+
</text>
|
|
545
|
+
</svg>`;
|
|
546
|
+
}
|
|
547
|
+
function buildIPhoneFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
548
|
+
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
549
|
+
const islandColor = darkMode ? "#000000" : "#1a1a1a";
|
|
550
|
+
const homeBarColor = darkMode ? "#555555" : "#333333";
|
|
551
|
+
const islandX = (totalWidth - IPHONE_ISLAND.width) / 2;
|
|
552
|
+
const islandY = (IPHONE_BEZEL.top - IPHONE_ISLAND.height) / 2 + 4;
|
|
553
|
+
const homeBarX = (totalWidth - IPHONE_HOME_BAR.width) / 2;
|
|
554
|
+
const homeBarY = totalHeight - IPHONE_BEZEL.bottom / 2 - IPHONE_HOME_BAR.height / 2;
|
|
555
|
+
const screenX = IPHONE_BEZEL.sides;
|
|
556
|
+
const screenY = IPHONE_BEZEL.top;
|
|
557
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
558
|
+
<!-- Device body -->
|
|
559
|
+
<rect width="${totalWidth}" height="${totalHeight}"
|
|
560
|
+
rx="${IPHONE_OUTER_RADIUS}" ry="${IPHONE_OUTER_RADIUS}" fill="${bezelColor}"/>
|
|
561
|
+
<!-- Screen cutout (transparent) -->
|
|
562
|
+
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
563
|
+
rx="${IPHONE_INNER_RADIUS}" ry="${IPHONE_INNER_RADIUS}" fill="black"/>
|
|
564
|
+
<!-- Dynamic Island pill -->
|
|
565
|
+
<rect x="${islandX}" y="${islandY}" width="${IPHONE_ISLAND.width}" height="${IPHONE_ISLAND.height}"
|
|
566
|
+
rx="${IPHONE_ISLAND.height / 2}" ry="${IPHONE_ISLAND.height / 2}" fill="${islandColor}"/>
|
|
567
|
+
<!-- Home indicator bar -->
|
|
568
|
+
<rect x="${homeBarX}" y="${homeBarY}" width="${IPHONE_HOME_BAR.width}" height="${IPHONE_HOME_BAR.height}"
|
|
569
|
+
rx="${IPHONE_HOME_BAR.height / 2}" ry="${IPHONE_HOME_BAR.height / 2}" fill="${homeBarColor}"/>
|
|
570
|
+
</svg>`;
|
|
571
|
+
}
|
|
572
|
+
function buildIPadFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
573
|
+
const bezelColor = darkMode ? "#1a1a1a" : "#f5f5f7";
|
|
574
|
+
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
575
|
+
const screenX = IPAD_BEZEL.sides;
|
|
576
|
+
const screenY = IPAD_BEZEL.top;
|
|
577
|
+
const cameraCx = totalWidth / 2;
|
|
578
|
+
const cameraCy = IPAD_BEZEL.top / 2;
|
|
579
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
580
|
+
<!-- Device body -->
|
|
581
|
+
<rect width="${totalWidth}" height="${totalHeight}"
|
|
582
|
+
rx="${IPAD_OUTER_RADIUS}" ry="${IPAD_OUTER_RADIUS}" fill="${bezelColor}"/>
|
|
583
|
+
<!-- Screen cutout -->
|
|
584
|
+
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
585
|
+
rx="${IPAD_INNER_RADIUS}" ry="${IPAD_INNER_RADIUS}" fill="black"/>
|
|
586
|
+
<!-- Front camera dot -->
|
|
587
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="4" fill="${cameraColor}"/>
|
|
588
|
+
</svg>`;
|
|
589
|
+
}
|
|
590
|
+
function buildAndroidFrameSvg(totalWidth, totalHeight, screenWidth, screenHeight, darkMode) {
|
|
591
|
+
const bezelColor = darkMode ? "#1a1a1a" : "#e8e8e8";
|
|
592
|
+
const cameraColor = darkMode ? "#2a2a2a" : "#3a3a3a";
|
|
593
|
+
const screenX = ANDROID_BEZEL.sides;
|
|
594
|
+
const screenY = ANDROID_BEZEL.top;
|
|
595
|
+
const cameraCx = totalWidth / 2;
|
|
596
|
+
const cameraCy = ANDROID_BEZEL.top / 2;
|
|
597
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">
|
|
598
|
+
<!-- Device body -->
|
|
599
|
+
<rect width="${totalWidth}" height="${totalHeight}"
|
|
600
|
+
rx="${ANDROID_OUTER_RADIUS}" ry="${ANDROID_OUTER_RADIUS}" fill="${bezelColor}"/>
|
|
601
|
+
<!-- Screen cutout -->
|
|
602
|
+
<rect x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}"
|
|
603
|
+
rx="${ANDROID_INNER_RADIUS}" ry="${ANDROID_INNER_RADIUS}" fill="black"/>
|
|
604
|
+
<!-- Punch-hole camera -->
|
|
605
|
+
<circle cx="${cameraCx}" cy="${cameraCy}" r="${ANDROID_CAMERA_RADIUS}" fill="${cameraColor}"/>
|
|
606
|
+
</svg>`;
|
|
607
|
+
}
|
|
608
|
+
function buildScreenMaskSvg(width, height, radius) {
|
|
609
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
610
|
+
<rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
|
|
611
|
+
</svg>`;
|
|
612
|
+
}
|
|
613
|
+
async function applyMobileFrame(frameBuffer, deviceType, darkMode, frameWidth, frameHeight) {
|
|
614
|
+
let bezel;
|
|
615
|
+
let innerRadius;
|
|
616
|
+
switch (deviceType) {
|
|
617
|
+
case "iphone":
|
|
618
|
+
bezel = IPHONE_BEZEL;
|
|
619
|
+
innerRadius = IPHONE_INNER_RADIUS;
|
|
620
|
+
break;
|
|
621
|
+
case "ipad":
|
|
622
|
+
bezel = IPAD_BEZEL;
|
|
623
|
+
innerRadius = IPAD_INNER_RADIUS;
|
|
624
|
+
break;
|
|
625
|
+
case "android":
|
|
626
|
+
bezel = ANDROID_BEZEL;
|
|
627
|
+
innerRadius = ANDROID_INNER_RADIUS;
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
const totalWidth = frameWidth + bezel.sides * 2;
|
|
631
|
+
const totalHeight = frameHeight + bezel.top + bezel.bottom;
|
|
632
|
+
let frameSvg;
|
|
633
|
+
switch (deviceType) {
|
|
634
|
+
case "iphone":
|
|
635
|
+
frameSvg = buildIPhoneFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
636
|
+
break;
|
|
637
|
+
case "ipad":
|
|
638
|
+
frameSvg = buildIPadFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
639
|
+
break;
|
|
640
|
+
case "android":
|
|
641
|
+
frameSvg = buildAndroidFrameSvg(totalWidth, totalHeight, frameWidth, frameHeight, darkMode);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
const maskSvg = buildScreenMaskSvg(frameWidth, frameHeight, innerRadius);
|
|
645
|
+
const maskedScreen = await sharp(frameBuffer).resize(frameWidth, frameHeight, { fit: "fill" }).composite([
|
|
646
|
+
{
|
|
647
|
+
input: Buffer.from(maskSvg),
|
|
648
|
+
blend: "dest-in"
|
|
649
|
+
}
|
|
650
|
+
]).png().toBuffer();
|
|
651
|
+
const canvas = await sharp({
|
|
652
|
+
create: {
|
|
653
|
+
width: totalWidth,
|
|
654
|
+
height: totalHeight,
|
|
655
|
+
channels: 4,
|
|
656
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
657
|
+
}
|
|
658
|
+
}).png().toBuffer();
|
|
659
|
+
return sharp(canvas).composite([
|
|
660
|
+
{ input: Buffer.from(frameSvg), left: 0, top: 0 },
|
|
661
|
+
{ input: maskedScreen, left: bezel.sides, top: bezel.top }
|
|
662
|
+
]).png().toBuffer();
|
|
663
|
+
}
|
|
664
|
+
async function applyDeviceFrame(frameBuffer, config, frameWidth, frameHeight) {
|
|
665
|
+
if (!config.enabled || config.type === "none") return frameBuffer;
|
|
666
|
+
switch (config.type) {
|
|
667
|
+
case "browser": {
|
|
668
|
+
const totalHeight = frameHeight + TITLE_BAR_HEIGHT;
|
|
669
|
+
const chromeSvg = buildBrowserChromeSvg(frameWidth, config.darkMode);
|
|
670
|
+
const chromeBuffer = Buffer.from(chromeSvg);
|
|
671
|
+
const canvas = await sharp({
|
|
672
|
+
create: {
|
|
673
|
+
width: frameWidth,
|
|
674
|
+
height: totalHeight,
|
|
675
|
+
channels: 4,
|
|
676
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
677
|
+
}
|
|
678
|
+
}).png().toBuffer();
|
|
679
|
+
return sharp(canvas).composite([
|
|
680
|
+
{ input: chromeBuffer, left: 0, top: 0 },
|
|
681
|
+
{ input: frameBuffer, left: 0, top: TITLE_BAR_HEIGHT }
|
|
682
|
+
]).png().toBuffer();
|
|
683
|
+
}
|
|
684
|
+
case "iphone":
|
|
685
|
+
case "ipad":
|
|
686
|
+
case "android":
|
|
687
|
+
return applyMobileFrame(frameBuffer, config.type, config.darkMode, frameWidth, frameHeight);
|
|
688
|
+
default:
|
|
689
|
+
return frameBuffer;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/effects/cursor.ts
|
|
694
|
+
import sharp2 from "sharp";
|
|
695
|
+
function buildCursorSvg(size, color) {
|
|
696
|
+
const s = size;
|
|
697
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24">
|
|
698
|
+
<path d="M4 0 L4 22 L10 16 L16 24 L20 22 L14 14 L22 14 Z"
|
|
699
|
+
fill="${color}" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
|
|
700
|
+
</svg>`;
|
|
701
|
+
}
|
|
702
|
+
function buildClickRippleSvg(radius, color, progress) {
|
|
703
|
+
const currentRadius = radius * progress;
|
|
704
|
+
const opacity = Math.max(0, 1 - progress);
|
|
705
|
+
const size = Math.ceil(radius * 2 + 4);
|
|
706
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
707
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius}"
|
|
708
|
+
fill="none" stroke="${color}" stroke-width="2"
|
|
709
|
+
opacity="${opacity.toFixed(3)}"/>
|
|
710
|
+
<circle cx="${size / 2}" cy="${size / 2}" r="${currentRadius * 0.6}"
|
|
711
|
+
fill="${color}" opacity="${(opacity * 0.4).toFixed(3)}"/>
|
|
712
|
+
</svg>`;
|
|
713
|
+
}
|
|
714
|
+
async function renderCursor(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
715
|
+
if (!config.enabled) return frameBuffer;
|
|
716
|
+
const cursorSvg = buildCursorSvg(config.size, config.color);
|
|
717
|
+
const cursorBuffer = Buffer.from(cursorSvg);
|
|
718
|
+
const left = Math.max(0, Math.min(Math.round(position.x), frameWidth - 1));
|
|
719
|
+
const top = Math.max(0, Math.min(Math.round(position.y), frameHeight - 1));
|
|
720
|
+
return sharp2(frameBuffer).composite([{ input: cursorBuffer, left, top }]).png().toBuffer();
|
|
721
|
+
}
|
|
722
|
+
async function renderClickEffect(frameBuffer, position, config, progress, frameWidth, frameHeight) {
|
|
723
|
+
if (!config.enabled || !config.clickEffect) return frameBuffer;
|
|
724
|
+
const clampedProgress = Math.max(0, Math.min(1, progress));
|
|
725
|
+
const rippleSvg = buildClickRippleSvg(
|
|
726
|
+
config.clickRadius,
|
|
727
|
+
config.clickColor,
|
|
728
|
+
clampedProgress
|
|
729
|
+
);
|
|
730
|
+
const rippleBuffer = Buffer.from(rippleSvg);
|
|
731
|
+
const rippleSize = Math.ceil(config.clickRadius * 2 + 4);
|
|
732
|
+
const left = Math.max(
|
|
733
|
+
0,
|
|
734
|
+
Math.min(
|
|
735
|
+
Math.round(position.x - rippleSize / 2),
|
|
736
|
+
frameWidth - rippleSize
|
|
737
|
+
)
|
|
738
|
+
);
|
|
739
|
+
const top = Math.max(
|
|
740
|
+
0,
|
|
741
|
+
Math.min(
|
|
742
|
+
Math.round(position.y - rippleSize / 2),
|
|
743
|
+
frameHeight - rippleSize
|
|
744
|
+
)
|
|
745
|
+
);
|
|
746
|
+
return sharp2(frameBuffer).composite([{ input: rippleBuffer, left, top }]).png().toBuffer();
|
|
747
|
+
}
|
|
748
|
+
async function renderCursorHighlight(frameBuffer, position, config, frameWidth, frameHeight) {
|
|
749
|
+
if (!config.enabled || !config.highlight) return frameBuffer;
|
|
750
|
+
const r = config.highlightRadius;
|
|
751
|
+
const size = Math.ceil(r * 2 + 4);
|
|
752
|
+
const cx = size / 2;
|
|
753
|
+
const cy = size / 2;
|
|
754
|
+
const highlightSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
|
755
|
+
<defs>
|
|
756
|
+
<radialGradient id="glow">
|
|
757
|
+
<stop offset="0%" stop-color="${config.highlightColor}" />
|
|
758
|
+
<stop offset="70%" stop-color="${config.highlightColor}" />
|
|
759
|
+
<stop offset="100%" stop-color="transparent" />
|
|
760
|
+
</radialGradient>
|
|
761
|
+
</defs>
|
|
762
|
+
<circle cx="${cx}" cy="${cy}" r="${r}" fill="url(#glow)" />
|
|
763
|
+
</svg>`;
|
|
764
|
+
const left = Math.max(0, Math.min(Math.round(position.x - cx), frameWidth - size));
|
|
765
|
+
const top = Math.max(0, Math.min(Math.round(position.y - cy), frameHeight - size));
|
|
766
|
+
return sharp2(frameBuffer).composite([{ input: Buffer.from(highlightSvg), left, top }]).png().toBuffer();
|
|
767
|
+
}
|
|
768
|
+
async function renderCursorTrail(frameBuffer, positions, config, frameWidth, frameHeight) {
|
|
769
|
+
if (!config.enabled || !config.trail || positions.length < 2) {
|
|
770
|
+
return frameBuffer;
|
|
771
|
+
}
|
|
772
|
+
const segments = [];
|
|
773
|
+
for (let i = 1; i < positions.length; i++) {
|
|
774
|
+
const opacity = i / positions.length * 0.6;
|
|
775
|
+
const strokeWidth = 1 + i / positions.length * 2;
|
|
776
|
+
const p1 = positions[i - 1];
|
|
777
|
+
const p2 = positions[i];
|
|
778
|
+
segments.push(
|
|
779
|
+
`<line x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}"
|
|
780
|
+
stroke="${config.trailColor}" stroke-width="${strokeWidth.toFixed(1)}"
|
|
781
|
+
stroke-linecap="round" opacity="${opacity.toFixed(3)}"/>`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
const trailSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
785
|
+
${segments.join("\n ")}
|
|
786
|
+
</svg>`;
|
|
787
|
+
return sharp2(frameBuffer).composite([{ input: Buffer.from(trailSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/effects/zoom.ts
|
|
791
|
+
import sharp3 from "sharp";
|
|
792
|
+
async function applyZoom(frameBuffer, focusPoint, scale, frameWidth, frameHeight) {
|
|
793
|
+
if (scale <= 1) return frameBuffer;
|
|
794
|
+
const cropWidth = Math.round(frameWidth / scale);
|
|
795
|
+
const cropHeight = Math.round(frameHeight / scale);
|
|
796
|
+
let left = Math.round(focusPoint.x - cropWidth / 2);
|
|
797
|
+
let top = Math.round(focusPoint.y - cropHeight / 2);
|
|
798
|
+
left = Math.max(0, Math.min(left, frameWidth - cropWidth));
|
|
799
|
+
top = Math.max(0, Math.min(top, frameHeight - cropHeight));
|
|
800
|
+
return sharp3(frameBuffer).extract({ left, top, width: cropWidth, height: cropHeight }).resize(frameWidth, frameHeight, { kernel: sharp3.kernel.lanczos3 }).png().toBuffer();
|
|
801
|
+
}
|
|
802
|
+
function calculateAdaptiveZoom(frames, currentIndex, maxScale, transitionFrames) {
|
|
803
|
+
if (maxScale <= 1) return 1;
|
|
804
|
+
let minDistance = Infinity;
|
|
805
|
+
for (let i = 0; i < frames.length; i++) {
|
|
806
|
+
if (frames[i].clickPosition) {
|
|
807
|
+
const distance = Math.abs(i - currentIndex);
|
|
808
|
+
minDistance = Math.min(minDistance, distance);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (minDistance === Infinity) return 1;
|
|
812
|
+
if (minDistance <= transitionFrames) {
|
|
813
|
+
const t = 1 - minDistance / transitionFrames;
|
|
814
|
+
const eased = easeInOutCubic2(t);
|
|
815
|
+
return 1 + (maxScale - 1) * eased;
|
|
816
|
+
}
|
|
817
|
+
return 1;
|
|
818
|
+
}
|
|
819
|
+
function calculatePanOffset(focusPoint, scale, frameWidth, frameHeight) {
|
|
820
|
+
if (scale <= 1) return { x: 0, y: 0 };
|
|
821
|
+
const visibleWidth = frameWidth / scale;
|
|
822
|
+
const visibleHeight = frameHeight / scale;
|
|
823
|
+
let offsetX = focusPoint.x - visibleWidth / 2;
|
|
824
|
+
let offsetY = focusPoint.y - visibleHeight / 2;
|
|
825
|
+
offsetX = Math.max(0, Math.min(offsetX, frameWidth - visibleWidth));
|
|
826
|
+
offsetY = Math.max(0, Math.min(offsetY, frameHeight - visibleHeight));
|
|
827
|
+
return { x: Math.round(offsetX), y: Math.round(offsetY) };
|
|
828
|
+
}
|
|
829
|
+
function lerpZoom(current, target, factor) {
|
|
830
|
+
return current + (target - current) * Math.min(1, Math.max(0, factor));
|
|
831
|
+
}
|
|
832
|
+
function easeInOutCubic2(t) {
|
|
833
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/effects/background.ts
|
|
837
|
+
import sharp4 from "sharp";
|
|
838
|
+
function parseGradient(value) {
|
|
839
|
+
const match = value.match(
|
|
840
|
+
/linear-gradient\(\s*([\d.]+)deg\s*,\s*(.+)\s*\)/
|
|
841
|
+
);
|
|
842
|
+
if (!match) {
|
|
843
|
+
return { angle: 135, stops: [{ color: value, offset: "100%" }] };
|
|
844
|
+
}
|
|
845
|
+
const angle = parseFloat(match[1]);
|
|
846
|
+
const stopsRaw = match[2].split(",").map((s) => s.trim());
|
|
847
|
+
const stops = stopsRaw.map((stop) => {
|
|
848
|
+
const parts = stop.trim().split(/\s+/);
|
|
849
|
+
return {
|
|
850
|
+
color: parts[0],
|
|
851
|
+
offset: parts[1] ?? "0%"
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
return { angle, stops };
|
|
855
|
+
}
|
|
856
|
+
function angleToGradientCoords(angle) {
|
|
857
|
+
const rad = (angle - 90) * Math.PI / 180;
|
|
858
|
+
const x1 = 50 - Math.cos(rad) * 50;
|
|
859
|
+
const y1 = 50 - Math.sin(rad) * 50;
|
|
860
|
+
const x2 = 50 + Math.cos(rad) * 50;
|
|
861
|
+
const y2 = 50 + Math.sin(rad) * 50;
|
|
862
|
+
return {
|
|
863
|
+
x1: `${x1.toFixed(1)}%`,
|
|
864
|
+
y1: `${y1.toFixed(1)}%`,
|
|
865
|
+
x2: `${x2.toFixed(1)}%`,
|
|
866
|
+
y2: `${y2.toFixed(1)}%`
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function buildBackgroundSvg(config, width, height) {
|
|
870
|
+
if (config.type === "solid") {
|
|
871
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
872
|
+
<rect width="${width}" height="${height}" fill="${config.value}"/>
|
|
873
|
+
</svg>`;
|
|
874
|
+
}
|
|
875
|
+
const { angle, stops } = parseGradient(config.value);
|
|
876
|
+
const coords = angleToGradientCoords(angle);
|
|
877
|
+
const stopElements = stops.map((s) => `<stop offset="${s.offset}" stop-color="${s.color}"/>`).join("\n ");
|
|
878
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
879
|
+
<defs>
|
|
880
|
+
<linearGradient id="bg" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
|
|
881
|
+
${stopElements}
|
|
882
|
+
</linearGradient>
|
|
883
|
+
</defs>
|
|
884
|
+
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
|
885
|
+
</svg>`;
|
|
886
|
+
}
|
|
887
|
+
async function applyBackground(frameBuffer, config, outputWidth, outputHeight) {
|
|
888
|
+
const padding = config.padding;
|
|
889
|
+
const contentWidth = outputWidth - padding * 2;
|
|
890
|
+
const contentHeight = outputHeight - padding * 2;
|
|
891
|
+
if (contentWidth <= 0 || contentHeight <= 0) {
|
|
892
|
+
return frameBuffer;
|
|
893
|
+
}
|
|
894
|
+
const resizedFrame = await sharp4(frameBuffer).resize(contentWidth, contentHeight, { fit: "fill" }).png().toBuffer();
|
|
895
|
+
const bgSvg = buildBackgroundSvg(config, outputWidth, outputHeight);
|
|
896
|
+
const bgBuffer = Buffer.from(bgSvg);
|
|
897
|
+
const radius = config.borderRadius;
|
|
898
|
+
const roundedMask = Buffer.from(
|
|
899
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${contentWidth}" height="${contentHeight}">
|
|
900
|
+
<rect width="${contentWidth}" height="${contentHeight}" rx="${radius}" ry="${radius}" fill="#ffffff"/>
|
|
901
|
+
</svg>`
|
|
902
|
+
);
|
|
903
|
+
const maskedFrame = await sharp4(resizedFrame).composite([
|
|
904
|
+
{
|
|
905
|
+
input: roundedMask,
|
|
906
|
+
blend: "dest-in"
|
|
907
|
+
}
|
|
908
|
+
]).png().toBuffer();
|
|
909
|
+
const composites = [];
|
|
910
|
+
if (config.shadow) {
|
|
911
|
+
const shadowSvg = Buffer.from(
|
|
912
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${outputWidth}" height="${outputHeight}">
|
|
913
|
+
<defs>
|
|
914
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
915
|
+
<feDropShadow dx="0" dy="4" stdDeviation="16" flood-color="rgba(0,0,0,0.3)"/>
|
|
916
|
+
</filter>
|
|
917
|
+
</defs>
|
|
918
|
+
<rect x="${padding}" y="${padding}" width="${contentWidth}" height="${contentHeight}"
|
|
919
|
+
rx="${radius}" ry="${radius}" fill="rgba(0,0,0,0.15)" filter="url(#shadow)"/>
|
|
920
|
+
</svg>`
|
|
921
|
+
);
|
|
922
|
+
composites.push({ input: shadowSvg, left: 0, top: 0 });
|
|
923
|
+
}
|
|
924
|
+
composites.push({ input: maskedFrame, left: padding, top: padding });
|
|
925
|
+
return sharp4(bgBuffer).resize(outputWidth, outputHeight).composite(composites).png().toBuffer();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/effects/keystroke.ts
|
|
929
|
+
import sharp5 from "sharp";
|
|
930
|
+
async function renderKeystrokeHud(frameBuffer, keystrokes, frameTimestamp, config, frameWidth, frameHeight) {
|
|
931
|
+
if (!config.enabled || keystrokes.length === 0) return frameBuffer;
|
|
932
|
+
const recentKeys = keystrokes.filter(
|
|
933
|
+
(k) => frameTimestamp - k.timestamp < config.fadeAfter
|
|
934
|
+
);
|
|
935
|
+
if (recentKeys.length === 0) return frameBuffer;
|
|
936
|
+
const displayText = recentKeys.map((k) => k.key).join("");
|
|
937
|
+
if (displayText.length === 0) return frameBuffer;
|
|
938
|
+
const charWidth = config.fontSize * 0.62;
|
|
939
|
+
const textWidth = Math.ceil(displayText.length * charWidth);
|
|
940
|
+
const hudPadH = config.padding * 2;
|
|
941
|
+
const hudPadV = config.padding * 1.5;
|
|
942
|
+
const hudWidth = Math.min(textWidth + hudPadH * 2, frameWidth - 40);
|
|
943
|
+
const hudHeight = Math.ceil(config.fontSize + hudPadV * 2);
|
|
944
|
+
const newest = recentKeys[recentKeys.length - 1];
|
|
945
|
+
const age = frameTimestamp - newest.timestamp;
|
|
946
|
+
const fadeStart = config.fadeAfter * 0.6;
|
|
947
|
+
const opacity = age > fadeStart ? Math.max(0, 1 - (age - fadeStart) / (config.fadeAfter - fadeStart)) : 1;
|
|
948
|
+
if (opacity <= 0) return frameBuffer;
|
|
949
|
+
let hudX;
|
|
950
|
+
const hudY = frameHeight - hudHeight - 30;
|
|
951
|
+
switch (config.position) {
|
|
952
|
+
case "bottom-left":
|
|
953
|
+
hudX = 30;
|
|
954
|
+
break;
|
|
955
|
+
case "bottom-right":
|
|
956
|
+
hudX = frameWidth - hudWidth - 30;
|
|
957
|
+
break;
|
|
958
|
+
case "bottom-center":
|
|
959
|
+
default:
|
|
960
|
+
hudX = Math.round((frameWidth - hudWidth) / 2);
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
const maxChars = Math.floor((hudWidth - hudPadH * 2) / charWidth);
|
|
964
|
+
const truncated = displayText.length > maxChars ? displayText.slice(-maxChars) : displayText;
|
|
965
|
+
const escaped = truncated.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
966
|
+
const hudSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
967
|
+
<rect x="${hudX}" y="${hudY}" width="${hudWidth}" height="${hudHeight}"
|
|
968
|
+
rx="8" ry="8" fill="${config.backgroundColor}" opacity="${opacity.toFixed(3)}" />
|
|
969
|
+
<text x="${hudX + hudPadH}" y="${hudY + hudPadV + config.fontSize * 0.75}"
|
|
970
|
+
font-family="monospace, Menlo, Consolas" font-size="${config.fontSize}"
|
|
971
|
+
fill="${config.textColor}" opacity="${opacity.toFixed(3)}">${escaped}</text>
|
|
972
|
+
</svg>`;
|
|
973
|
+
return sharp5(frameBuffer).composite([{ input: Buffer.from(hudSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/effects/transition.ts
|
|
977
|
+
import sharp6 from "sharp";
|
|
978
|
+
async function applyCrossfade(fromBuffer, toBuffer, progress, width, height) {
|
|
979
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
980
|
+
if (t <= 0) return fromBuffer;
|
|
981
|
+
if (t >= 1) return toBuffer;
|
|
982
|
+
const fromRaw = await sharp6(fromBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
983
|
+
const toRaw = await sharp6(toBuffer).resize(fromRaw.info.width, fromRaw.info.height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
984
|
+
const pixels = Buffer.alloc(fromRaw.data.length);
|
|
985
|
+
for (let i = 0; i < fromRaw.data.length; i++) {
|
|
986
|
+
pixels[i] = Math.round(
|
|
987
|
+
fromRaw.data[i] * (1 - t) + toRaw.data[i] * t
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return sharp6(pixels, {
|
|
991
|
+
raw: {
|
|
992
|
+
width: fromRaw.info.width,
|
|
993
|
+
height: fromRaw.info.height,
|
|
994
|
+
channels: 4
|
|
995
|
+
}
|
|
996
|
+
}).png().toBuffer();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/effects/watermark.ts
|
|
1000
|
+
import sharp7 from "sharp";
|
|
1001
|
+
async function renderWatermark(frameBuffer, config, frameWidth, frameHeight) {
|
|
1002
|
+
if (!config.enabled || !config.text) return frameBuffer;
|
|
1003
|
+
const charWidth = config.fontSize * 0.62;
|
|
1004
|
+
const textWidth = Math.ceil(config.text.length * charWidth);
|
|
1005
|
+
const margin = 16;
|
|
1006
|
+
let x;
|
|
1007
|
+
let y;
|
|
1008
|
+
switch (config.position) {
|
|
1009
|
+
case "top-left":
|
|
1010
|
+
x = margin;
|
|
1011
|
+
y = margin + config.fontSize;
|
|
1012
|
+
break;
|
|
1013
|
+
case "top-right":
|
|
1014
|
+
x = frameWidth - textWidth - margin;
|
|
1015
|
+
y = margin + config.fontSize;
|
|
1016
|
+
break;
|
|
1017
|
+
case "bottom-left":
|
|
1018
|
+
x = margin;
|
|
1019
|
+
y = frameHeight - margin;
|
|
1020
|
+
break;
|
|
1021
|
+
case "bottom-right":
|
|
1022
|
+
default:
|
|
1023
|
+
x = frameWidth - textWidth - margin;
|
|
1024
|
+
y = frameHeight - margin;
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
const escaped = config.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1028
|
+
const watermarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${frameWidth}" height="${frameHeight}">
|
|
1029
|
+
<text x="${x}" y="${y}"
|
|
1030
|
+
font-family="system-ui, -apple-system, sans-serif" font-size="${config.fontSize}"
|
|
1031
|
+
font-weight="600" fill="${config.color}"
|
|
1032
|
+
opacity="${config.opacity.toFixed(3)}">${escaped}</text>
|
|
1033
|
+
</svg>`;
|
|
1034
|
+
return sharp7(frameBuffer).composite([{ input: Buffer.from(watermarkSvg), left: 0, top: 0 }]).png().toBuffer();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/compose/canvas-renderer.ts
|
|
1038
|
+
function getFrameOffset(config) {
|
|
1039
|
+
if (!config.enabled) return { left: 0, top: 0 };
|
|
1040
|
+
switch (config.type) {
|
|
1041
|
+
case "browser":
|
|
1042
|
+
return { left: 0, top: 40 };
|
|
1043
|
+
case "iphone":
|
|
1044
|
+
return { left: 12, top: 50 };
|
|
1045
|
+
case "ipad":
|
|
1046
|
+
return { left: 20, top: 24 };
|
|
1047
|
+
case "android":
|
|
1048
|
+
return { left: 8, top: 32 };
|
|
1049
|
+
default:
|
|
1050
|
+
return { left: 0, top: 0 };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
var CanvasRenderer = class {
|
|
1054
|
+
constructor(effects, output, steps) {
|
|
1055
|
+
this.effects = effects;
|
|
1056
|
+
this.output = output;
|
|
1057
|
+
this.steps = steps ?? [];
|
|
1058
|
+
}
|
|
1059
|
+
steps;
|
|
1060
|
+
/**
|
|
1061
|
+
* Apply the full effects pipeline to a single captured frame.
|
|
1062
|
+
*
|
|
1063
|
+
* Pipeline order:
|
|
1064
|
+
* 1. Device frame (browser chrome / mobile mockup)
|
|
1065
|
+
* 2. Cursor highlight (Screen Studio glow)
|
|
1066
|
+
* 3. Cursor trail
|
|
1067
|
+
* 4. Cursor rendering
|
|
1068
|
+
* 5. Click ripple effect (animated progress)
|
|
1069
|
+
* 6. Keystroke HUD
|
|
1070
|
+
* 7. Zoom (adaptive, cursor-following)
|
|
1071
|
+
* 8. Background (padding, gradient, rounded corners)
|
|
1072
|
+
* 9. Watermark overlay
|
|
1073
|
+
* 10. Final resize
|
|
1074
|
+
*/
|
|
1075
|
+
async composeFrame(frame, context) {
|
|
1076
|
+
let buffer = frame.screenshot;
|
|
1077
|
+
let width = frame.viewport.width;
|
|
1078
|
+
let height = frame.viewport.height;
|
|
1079
|
+
const ctx = {
|
|
1080
|
+
zoomScale: context?.zoomScale ?? 1,
|
|
1081
|
+
clickProgress: context?.clickProgress ?? null,
|
|
1082
|
+
cursorTrail: context?.cursorTrail ?? []
|
|
1083
|
+
};
|
|
1084
|
+
if (this.effects.deviceFrame.enabled) {
|
|
1085
|
+
buffer = await applyDeviceFrame(
|
|
1086
|
+
buffer,
|
|
1087
|
+
this.effects.deviceFrame,
|
|
1088
|
+
width,
|
|
1089
|
+
height
|
|
1090
|
+
);
|
|
1091
|
+
const meta = await sharp8(buffer).metadata();
|
|
1092
|
+
width = meta.width ?? width;
|
|
1093
|
+
height = meta.height ?? height;
|
|
1094
|
+
}
|
|
1095
|
+
if (this.effects.cursor.enabled && this.effects.cursor.highlight && frame.cursorPosition) {
|
|
1096
|
+
buffer = await renderCursorHighlight(
|
|
1097
|
+
buffer,
|
|
1098
|
+
frame.cursorPosition,
|
|
1099
|
+
this.effects.cursor,
|
|
1100
|
+
width,
|
|
1101
|
+
height
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
if (this.effects.cursor.enabled && this.effects.cursor.trail && ctx.cursorTrail.length >= 2) {
|
|
1105
|
+
buffer = await renderCursorTrail(
|
|
1106
|
+
buffer,
|
|
1107
|
+
ctx.cursorTrail,
|
|
1108
|
+
this.effects.cursor,
|
|
1109
|
+
width,
|
|
1110
|
+
height
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
if (this.effects.cursor.enabled && frame.cursorPosition) {
|
|
1114
|
+
buffer = await renderCursor(
|
|
1115
|
+
buffer,
|
|
1116
|
+
frame.cursorPosition,
|
|
1117
|
+
this.effects.cursor,
|
|
1118
|
+
width,
|
|
1119
|
+
height
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
if (this.effects.cursor.enabled && this.effects.cursor.clickEffect && frame.clickPosition) {
|
|
1123
|
+
const progress = ctx.clickProgress ?? frame.clickProgress ?? 0.5;
|
|
1124
|
+
buffer = await renderClickEffect(
|
|
1125
|
+
buffer,
|
|
1126
|
+
frame.clickPosition,
|
|
1127
|
+
this.effects.cursor,
|
|
1128
|
+
progress,
|
|
1129
|
+
width,
|
|
1130
|
+
height
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (this.effects.keystroke.enabled && frame.keystrokes) {
|
|
1134
|
+
buffer = await renderKeystrokeHud(
|
|
1135
|
+
buffer,
|
|
1136
|
+
frame.keystrokes,
|
|
1137
|
+
frame.timestamp,
|
|
1138
|
+
this.effects.keystroke,
|
|
1139
|
+
width,
|
|
1140
|
+
height
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
const scale = ctx.zoomScale;
|
|
1144
|
+
if (this.effects.zoom.enabled && scale > 1) {
|
|
1145
|
+
const rawFocus = frame.clickPosition ?? frame.cursorPosition ?? { x: width / 2, y: height / 2 };
|
|
1146
|
+
const offset = getFrameOffset(this.effects.deviceFrame);
|
|
1147
|
+
const focusPoint = {
|
|
1148
|
+
x: rawFocus.x + offset.left,
|
|
1149
|
+
y: rawFocus.y + offset.top
|
|
1150
|
+
};
|
|
1151
|
+
buffer = await applyZoom(buffer, focusPoint, scale, width, height);
|
|
1152
|
+
}
|
|
1153
|
+
buffer = await applyBackground(
|
|
1154
|
+
buffer,
|
|
1155
|
+
this.effects.background,
|
|
1156
|
+
this.output.width,
|
|
1157
|
+
this.output.height
|
|
1158
|
+
);
|
|
1159
|
+
if (this.effects.watermark.enabled) {
|
|
1160
|
+
buffer = await renderWatermark(
|
|
1161
|
+
buffer,
|
|
1162
|
+
this.effects.watermark,
|
|
1163
|
+
this.output.width,
|
|
1164
|
+
this.output.height
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
buffer = await sharp8(buffer).resize(this.output.width, this.output.height, { fit: "fill" }).png().toBuffer();
|
|
1168
|
+
return {
|
|
1169
|
+
index: frame.index,
|
|
1170
|
+
buffer,
|
|
1171
|
+
timestamp: frame.timestamp
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Process an entire sequence of captured frames through the effects pipeline.
|
|
1176
|
+
*
|
|
1177
|
+
* Multi-pass approach:
|
|
1178
|
+
* Pass 1: Speed ramping (adjust frame set).
|
|
1179
|
+
* Pass 2: Calculate per-frame contexts (zoom, click, trail).
|
|
1180
|
+
* Pass 3: Render each frame with effects.
|
|
1181
|
+
* Pass 4: Apply scene transitions at step boundaries.
|
|
1182
|
+
*/
|
|
1183
|
+
async composeAll(frames) {
|
|
1184
|
+
if (frames.length === 0) return [];
|
|
1185
|
+
let processFrames = frames;
|
|
1186
|
+
if (this.effects.speedRamp.enabled) {
|
|
1187
|
+
processFrames = this.applySpeedRamp(frames);
|
|
1188
|
+
}
|
|
1189
|
+
const contexts = this.calculateFrameContexts(processFrames);
|
|
1190
|
+
const composed = [];
|
|
1191
|
+
for (let i = 0; i < processFrames.length; i++) {
|
|
1192
|
+
const result = await this.composeFrame(processFrames[i], contexts[i]);
|
|
1193
|
+
composed.push(result);
|
|
1194
|
+
}
|
|
1195
|
+
if (this.steps.length > 0) {
|
|
1196
|
+
await this.applyTransitions(composed, processFrames);
|
|
1197
|
+
}
|
|
1198
|
+
return composed;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Calculate per-frame rendering context (zoom, click progress, cursor trail, tilt).
|
|
1202
|
+
*/
|
|
1203
|
+
calculateFrameContexts(frames) {
|
|
1204
|
+
const contexts = [];
|
|
1205
|
+
const transitionFrames = Math.round(
|
|
1206
|
+
this.output.fps * (this.effects.zoom.duration / 1e3)
|
|
1207
|
+
);
|
|
1208
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1209
|
+
const frame = frames[i];
|
|
1210
|
+
let zoomScale = 1;
|
|
1211
|
+
if (this.effects.zoom.enabled) {
|
|
1212
|
+
zoomScale = calculateAdaptiveZoom(
|
|
1213
|
+
frames,
|
|
1214
|
+
i,
|
|
1215
|
+
this.effects.zoom.scale,
|
|
1216
|
+
transitionFrames
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
const clickProgress = frame.clickPosition != null ? frame.clickProgress ?? 0.5 : null;
|
|
1220
|
+
const trailLength = this.effects.cursor.trailLength;
|
|
1221
|
+
const trail = [];
|
|
1222
|
+
for (let j = Math.max(0, i - trailLength); j <= i; j++) {
|
|
1223
|
+
if (frames[j].cursorPosition) {
|
|
1224
|
+
trail.push(frames[j].cursorPosition);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
contexts.push({ zoomScale, clickProgress, cursorTrail: trail });
|
|
1228
|
+
}
|
|
1229
|
+
return contexts;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Apply speed ramping: slow down near actions, speed up during idle.
|
|
1233
|
+
* Returns a new frame array with frames duplicated or skipped.
|
|
1234
|
+
*/
|
|
1235
|
+
applySpeedRamp(frames) {
|
|
1236
|
+
const config = this.effects.speedRamp;
|
|
1237
|
+
if (!config.enabled) return frames;
|
|
1238
|
+
const proximityRadius = Math.round(this.output.fps * 1);
|
|
1239
|
+
const actionIndices = /* @__PURE__ */ new Set();
|
|
1240
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1241
|
+
if (frames[i].clickPosition) {
|
|
1242
|
+
for (let j = Math.max(0, i - proximityRadius); j <= Math.min(frames.length - 1, i + proximityRadius); j++) {
|
|
1243
|
+
actionIndices.add(j);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const result = [];
|
|
1248
|
+
for (let i = 0; i < frames.length; i++) {
|
|
1249
|
+
const isAction = actionIndices.has(i);
|
|
1250
|
+
if (isAction) {
|
|
1251
|
+
const copies = Math.max(1, Math.round(1 / config.actionSpeed));
|
|
1252
|
+
for (let c = 0; c < copies; c++) {
|
|
1253
|
+
result.push({ ...frames[i], index: result.length });
|
|
1254
|
+
}
|
|
1255
|
+
} else {
|
|
1256
|
+
const skipRate = Math.max(1, Math.round(config.idleSpeed));
|
|
1257
|
+
if (i % skipRate === 0) {
|
|
1258
|
+
result.push({ ...frames[i], index: result.length });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return result;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Apply crossfade transitions at step boundaries where configured.
|
|
1266
|
+
* Modifies the composed array in-place.
|
|
1267
|
+
*/
|
|
1268
|
+
async applyTransitions(composed, frames) {
|
|
1269
|
+
const transitionFrames = Math.max(2, Math.round(this.output.fps * 0.3));
|
|
1270
|
+
const boundaries = [];
|
|
1271
|
+
for (let i = 1; i < frames.length; i++) {
|
|
1272
|
+
if (frames[i].stepIndex !== void 0 && frames[i - 1].stepIndex !== void 0 && frames[i].stepIndex !== frames[i - 1].stepIndex) {
|
|
1273
|
+
const stepIdx = frames[i].stepIndex;
|
|
1274
|
+
const step = this.steps[stepIdx];
|
|
1275
|
+
if (step && step.transition === "fade") {
|
|
1276
|
+
boundaries.push({ index: i, stepIndex: stepIdx });
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
for (const boundary of boundaries) {
|
|
1281
|
+
const startIdx = Math.max(0, boundary.index - Math.floor(transitionFrames / 2));
|
|
1282
|
+
const endIdx = Math.min(composed.length - 1, boundary.index + Math.ceil(transitionFrames / 2));
|
|
1283
|
+
const range = endIdx - startIdx;
|
|
1284
|
+
if (range < 2) continue;
|
|
1285
|
+
const fromBuffer = composed[startIdx].buffer;
|
|
1286
|
+
const toBuffer = composed[endIdx].buffer;
|
|
1287
|
+
const width = this.output.width;
|
|
1288
|
+
const height = this.output.height;
|
|
1289
|
+
for (let i = startIdx + 1; i < endIdx; i++) {
|
|
1290
|
+
const progress = (i - startIdx) / range;
|
|
1291
|
+
composed[i].buffer = await applyCrossfade(
|
|
1292
|
+
fromBuffer,
|
|
1293
|
+
toBuffer,
|
|
1294
|
+
progress,
|
|
1295
|
+
width,
|
|
1296
|
+
height
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
// src/compose/video-encoder.ts
|
|
1304
|
+
import gifenc from "gifenc";
|
|
1305
|
+
import sharp9 from "sharp";
|
|
1306
|
+
import { writeFile, mkdir, readFile, rm, mkdtemp } from "fs/promises";
|
|
1307
|
+
import { join } from "path";
|
|
1308
|
+
import { tmpdir } from "os";
|
|
1309
|
+
import { spawn } from "child_process";
|
|
1310
|
+
var { GIFEncoder, quantize, applyPalette } = gifenc;
|
|
1311
|
+
async function encodeGif(frames, config) {
|
|
1312
|
+
if (frames.length === 0) {
|
|
1313
|
+
throw new Error("Cannot encode GIF: no frames provided");
|
|
1314
|
+
}
|
|
1315
|
+
const width = config.width;
|
|
1316
|
+
const height = config.height;
|
|
1317
|
+
const gif = GIFEncoder();
|
|
1318
|
+
const delay = Math.round(1e3 / config.fps);
|
|
1319
|
+
for (const frame of frames) {
|
|
1320
|
+
const { data, info } = await sharp9(frame.buffer).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1321
|
+
const rgba = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
1322
|
+
const palette = quantize(rgba, 256);
|
|
1323
|
+
const indexed = applyPalette(rgba, palette);
|
|
1324
|
+
gif.writeFrame(indexed, width, height, {
|
|
1325
|
+
palette,
|
|
1326
|
+
delay
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
gif.finish();
|
|
1330
|
+
return Buffer.from(gif.bytes());
|
|
1331
|
+
}
|
|
1332
|
+
async function encodeMp4(frames, config) {
|
|
1333
|
+
if (frames.length === 0) {
|
|
1334
|
+
throw new Error("Cannot encode MP4: no frames provided");
|
|
1335
|
+
}
|
|
1336
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "clipwise-"));
|
|
1337
|
+
try {
|
|
1338
|
+
const padLength = String(frames.length).length;
|
|
1339
|
+
for (const frame of frames) {
|
|
1340
|
+
const paddedIndex = String(frame.index).padStart(padLength, "0");
|
|
1341
|
+
const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
|
|
1342
|
+
await writeFile(join(tmpDir, `frame-${paddedIndex}.png`), pngBuffer);
|
|
1343
|
+
}
|
|
1344
|
+
const outputPath = join(tmpDir, "output.mp4");
|
|
1345
|
+
const crf = Math.round(51 - config.quality / 100 * 51);
|
|
1346
|
+
await runFfmpeg([
|
|
1347
|
+
"-y",
|
|
1348
|
+
"-framerate",
|
|
1349
|
+
String(config.fps),
|
|
1350
|
+
"-i",
|
|
1351
|
+
join(tmpDir, `frame-%0${padLength}d.png`),
|
|
1352
|
+
"-c:v",
|
|
1353
|
+
"libx264",
|
|
1354
|
+
"-pix_fmt",
|
|
1355
|
+
"yuv420p",
|
|
1356
|
+
"-crf",
|
|
1357
|
+
String(crf),
|
|
1358
|
+
"-preset",
|
|
1359
|
+
"slow",
|
|
1360
|
+
"-tune",
|
|
1361
|
+
"animation",
|
|
1362
|
+
"-movflags",
|
|
1363
|
+
"+faststart",
|
|
1364
|
+
outputPath
|
|
1365
|
+
]);
|
|
1366
|
+
return await readFile(outputPath);
|
|
1367
|
+
} finally {
|
|
1368
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
function runFfmpeg(args) {
|
|
1373
|
+
return new Promise((resolve, reject) => {
|
|
1374
|
+
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1375
|
+
let stderr = "";
|
|
1376
|
+
proc.stderr.on("data", (data) => {
|
|
1377
|
+
stderr += data.toString();
|
|
1378
|
+
});
|
|
1379
|
+
proc.on("close", (code) => {
|
|
1380
|
+
if (code === 0) {
|
|
1381
|
+
resolve();
|
|
1382
|
+
} else {
|
|
1383
|
+
reject(
|
|
1384
|
+
new Error(
|
|
1385
|
+
`FFmpeg encoding failed (exit code ${code}). Make sure ffmpeg is installed: brew install ffmpeg
|
|
1386
|
+
` + stderr.slice(-500)
|
|
1387
|
+
)
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
proc.on("error", (err) => {
|
|
1392
|
+
if (err.code === "ENOENT") {
|
|
1393
|
+
reject(
|
|
1394
|
+
new Error(
|
|
1395
|
+
"ffmpeg not found. Install it to encode MP4:\n macOS: brew install ffmpeg\n Ubuntu: sudo apt install ffmpeg\n Windows: choco install ffmpeg"
|
|
1396
|
+
)
|
|
1397
|
+
);
|
|
1398
|
+
} else {
|
|
1399
|
+
reject(err);
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
async function savePngSequence(frames, config) {
|
|
1405
|
+
if (frames.length === 0) {
|
|
1406
|
+
throw new Error("Cannot save PNG sequence: no frames provided");
|
|
1407
|
+
}
|
|
1408
|
+
const outputDir = join(config.outputDir, config.filename);
|
|
1409
|
+
await mkdir(outputDir, { recursive: true });
|
|
1410
|
+
const paths = [];
|
|
1411
|
+
const padLength = String(frames.length).length;
|
|
1412
|
+
for (const frame of frames) {
|
|
1413
|
+
const paddedIndex = String(frame.index).padStart(padLength, "0");
|
|
1414
|
+
const filename = `frame-${paddedIndex}.png`;
|
|
1415
|
+
const filePath = join(outputDir, filename);
|
|
1416
|
+
const pngBuffer = await sharp9(frame.buffer).resize(config.width, config.height, { fit: "fill" }).png().toBuffer();
|
|
1417
|
+
await writeFile(filePath, pngBuffer);
|
|
1418
|
+
paths.push(filePath);
|
|
1419
|
+
}
|
|
1420
|
+
return paths;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/script/parser.ts
|
|
1424
|
+
import { parse as parseYaml } from "yaml";
|
|
1425
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1426
|
+
|
|
1427
|
+
// src/script/types.ts
|
|
1428
|
+
import { z } from "zod";
|
|
1429
|
+
var SafeSelectorSchema = z.string().regex(
|
|
1430
|
+
/^[a-zA-Z0-9\-_#.\[\]="':\s~^$|*,>+()@]+$/,
|
|
1431
|
+
"Selector contains invalid characters"
|
|
1432
|
+
);
|
|
1433
|
+
var NavigateActionSchema = z.object({
|
|
1434
|
+
action: z.literal("navigate"),
|
|
1435
|
+
url: z.string().min(1),
|
|
1436
|
+
waitUntil: z.enum(["load", "domcontentloaded", "networkidle"]).default("networkidle")
|
|
1437
|
+
});
|
|
1438
|
+
var ClickActionSchema = z.object({
|
|
1439
|
+
action: z.literal("click"),
|
|
1440
|
+
selector: SafeSelectorSchema,
|
|
1441
|
+
delay: z.number().optional()
|
|
1442
|
+
});
|
|
1443
|
+
var TypeActionSchema = z.object({
|
|
1444
|
+
action: z.literal("type"),
|
|
1445
|
+
selector: SafeSelectorSchema,
|
|
1446
|
+
text: z.string(),
|
|
1447
|
+
delay: z.number().default(50)
|
|
1448
|
+
});
|
|
1449
|
+
var ScrollActionSchema = z.object({
|
|
1450
|
+
action: z.literal("scroll"),
|
|
1451
|
+
selector: SafeSelectorSchema.optional(),
|
|
1452
|
+
y: z.number().default(0),
|
|
1453
|
+
x: z.number().default(0),
|
|
1454
|
+
smooth: z.boolean().default(true)
|
|
1455
|
+
});
|
|
1456
|
+
var WaitActionSchema = z.object({
|
|
1457
|
+
action: z.literal("wait"),
|
|
1458
|
+
duration: z.number().describe("Wait duration in milliseconds")
|
|
1459
|
+
});
|
|
1460
|
+
var HoverActionSchema = z.object({
|
|
1461
|
+
action: z.literal("hover"),
|
|
1462
|
+
selector: SafeSelectorSchema
|
|
1463
|
+
});
|
|
1464
|
+
var ScreenshotActionSchema = z.object({
|
|
1465
|
+
action: z.literal("screenshot"),
|
|
1466
|
+
name: z.string().optional(),
|
|
1467
|
+
fullPage: z.boolean().default(false)
|
|
1468
|
+
});
|
|
1469
|
+
var StepActionSchema = z.discriminatedUnion("action", [
|
|
1470
|
+
NavigateActionSchema,
|
|
1471
|
+
ClickActionSchema,
|
|
1472
|
+
TypeActionSchema,
|
|
1473
|
+
ScrollActionSchema,
|
|
1474
|
+
WaitActionSchema,
|
|
1475
|
+
HoverActionSchema,
|
|
1476
|
+
ScreenshotActionSchema
|
|
1477
|
+
]);
|
|
1478
|
+
var AutoZoomConfigSchema = z.object({
|
|
1479
|
+
followCursor: z.boolean().default(true),
|
|
1480
|
+
maxScale: z.number().min(1).max(5).default(2),
|
|
1481
|
+
transitionDuration: z.number().default(400),
|
|
1482
|
+
padding: z.number().default(200)
|
|
1483
|
+
});
|
|
1484
|
+
var ZoomEffectSchema = z.object({
|
|
1485
|
+
enabled: z.boolean().default(true),
|
|
1486
|
+
scale: z.number().min(1).max(5).default(1.8),
|
|
1487
|
+
duration: z.number().default(600),
|
|
1488
|
+
easing: z.enum(["ease-in-out", "ease-in", "ease-out", "linear"]).default("ease-in-out"),
|
|
1489
|
+
autoZoom: AutoZoomConfigSchema.default({})
|
|
1490
|
+
});
|
|
1491
|
+
var CursorEffectSchema = z.object({
|
|
1492
|
+
enabled: z.boolean().default(true),
|
|
1493
|
+
size: z.number().default(20),
|
|
1494
|
+
color: z.string().default("#000000"),
|
|
1495
|
+
speed: z.enum(["fast", "normal", "slow"]).default("fast"),
|
|
1496
|
+
smoothing: z.boolean().default(true),
|
|
1497
|
+
clickEffect: z.boolean().default(true),
|
|
1498
|
+
clickColor: z.string().default("rgba(59, 130, 246, 0.3)"),
|
|
1499
|
+
clickRadius: z.number().default(30),
|
|
1500
|
+
trail: z.boolean().default(false),
|
|
1501
|
+
trailLength: z.number().default(8),
|
|
1502
|
+
trailColor: z.string().default("rgba(59, 130, 246, 0.2)"),
|
|
1503
|
+
highlight: z.boolean().default(false),
|
|
1504
|
+
highlightRadius: z.number().default(40),
|
|
1505
|
+
highlightColor: z.string().default("rgba(255, 215, 0, 0.18)")
|
|
1506
|
+
});
|
|
1507
|
+
var BackgroundSchema = z.object({
|
|
1508
|
+
type: z.enum(["gradient", "solid", "image"]).default("gradient"),
|
|
1509
|
+
value: z.string().default("linear-gradient(135deg, #667eea 0%, #764ba2 100%)"),
|
|
1510
|
+
padding: z.number().default(60),
|
|
1511
|
+
borderRadius: z.number().default(12),
|
|
1512
|
+
shadow: z.boolean().default(true)
|
|
1513
|
+
});
|
|
1514
|
+
var DeviceFrameSchema = z.object({
|
|
1515
|
+
enabled: z.boolean().default(false),
|
|
1516
|
+
type: z.enum(["browser", "macbook", "iphone", "ipad", "android", "none"]).default("browser"),
|
|
1517
|
+
darkMode: z.boolean().default(false)
|
|
1518
|
+
});
|
|
1519
|
+
var SpeedRampConfigSchema = z.object({
|
|
1520
|
+
enabled: z.boolean().default(false),
|
|
1521
|
+
idleSpeed: z.number().min(0.5).max(8).default(3),
|
|
1522
|
+
actionSpeed: z.number().min(0.25).max(2).default(0.8),
|
|
1523
|
+
transitionFrames: z.number().default(15)
|
|
1524
|
+
});
|
|
1525
|
+
var KeystrokeConfigSchema = z.object({
|
|
1526
|
+
enabled: z.boolean().default(false),
|
|
1527
|
+
position: z.enum(["bottom-center", "bottom-left", "bottom-right"]).default("bottom-center"),
|
|
1528
|
+
fontSize: z.number().default(18),
|
|
1529
|
+
backgroundColor: z.string().default("rgba(0, 0, 0, 0.75)"),
|
|
1530
|
+
textColor: z.string().default("#ffffff"),
|
|
1531
|
+
padding: z.number().default(8),
|
|
1532
|
+
fadeAfter: z.number().default(1500)
|
|
1533
|
+
});
|
|
1534
|
+
var WatermarkConfigSchema = z.object({
|
|
1535
|
+
enabled: z.boolean().default(false),
|
|
1536
|
+
text: z.string().default(""),
|
|
1537
|
+
position: z.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).default("bottom-right"),
|
|
1538
|
+
opacity: z.number().min(0).max(1).default(0.5),
|
|
1539
|
+
fontSize: z.number().default(14),
|
|
1540
|
+
color: z.string().default("#ffffff")
|
|
1541
|
+
});
|
|
1542
|
+
var EffectsConfigSchema = z.object({
|
|
1543
|
+
zoom: ZoomEffectSchema.default({}),
|
|
1544
|
+
cursor: CursorEffectSchema.default({}),
|
|
1545
|
+
background: BackgroundSchema.default({}),
|
|
1546
|
+
deviceFrame: DeviceFrameSchema.default({}),
|
|
1547
|
+
speedRamp: SpeedRampConfigSchema.default({}),
|
|
1548
|
+
keystroke: KeystrokeConfigSchema.default({}),
|
|
1549
|
+
watermark: WatermarkConfigSchema.default({})
|
|
1550
|
+
});
|
|
1551
|
+
var OutputConfigSchema = z.object({
|
|
1552
|
+
format: z.enum(["gif", "mp4", "webm", "png-sequence"]).default("gif"),
|
|
1553
|
+
width: z.number().default(1280),
|
|
1554
|
+
height: z.number().default(800),
|
|
1555
|
+
fps: z.number().min(1).max(60).default(15),
|
|
1556
|
+
quality: z.number().min(1).max(100).default(80),
|
|
1557
|
+
outputDir: z.string().default("./output"),
|
|
1558
|
+
filename: z.string().default("clipwise-recording")
|
|
1559
|
+
});
|
|
1560
|
+
var StepSchema = z.object({
|
|
1561
|
+
name: z.string().optional(),
|
|
1562
|
+
actions: z.array(StepActionSchema),
|
|
1563
|
+
captureDelay: z.number().default(300),
|
|
1564
|
+
holdDuration: z.number().default(1500),
|
|
1565
|
+
transition: z.enum(["fade", "none"]).default("none")
|
|
1566
|
+
});
|
|
1567
|
+
var ScenarioSchema = z.object({
|
|
1568
|
+
name: z.string(),
|
|
1569
|
+
description: z.string().optional(),
|
|
1570
|
+
viewport: z.object({
|
|
1571
|
+
width: z.number().default(1280),
|
|
1572
|
+
height: z.number().default(800)
|
|
1573
|
+
}).default({}),
|
|
1574
|
+
effects: EffectsConfigSchema.default({}),
|
|
1575
|
+
output: OutputConfigSchema.default({}),
|
|
1576
|
+
steps: z.array(StepSchema).min(1)
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
// src/script/parser.ts
|
|
1580
|
+
import { ZodError } from "zod";
|
|
1581
|
+
function parseScenario(yamlContent) {
|
|
1582
|
+
let raw;
|
|
1583
|
+
try {
|
|
1584
|
+
raw = parseYaml(yamlContent);
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
const message = error instanceof Error ? error.message : "Unknown parse error";
|
|
1587
|
+
throw new Error(`YAML parse error: ${message}`);
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
return ScenarioSchema.parse(raw);
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
if (error instanceof ZodError) {
|
|
1593
|
+
const details = error.issues.map((issue) => {
|
|
1594
|
+
const path = issue.path.join(".");
|
|
1595
|
+
return ` - ${path ? `${path}: ` : ""}${issue.message}`;
|
|
1596
|
+
}).join("\n");
|
|
1597
|
+
throw new Error(`Scenario validation failed:
|
|
1598
|
+
${details}`);
|
|
1599
|
+
}
|
|
1600
|
+
throw error;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
async function loadScenario(filePath) {
|
|
1604
|
+
let content;
|
|
1605
|
+
try {
|
|
1606
|
+
content = await readFile2(filePath, "utf-8");
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
const message = error instanceof Error ? error.message : "Unknown file error";
|
|
1609
|
+
throw new Error(`Failed to read scenario file "${filePath}": ${message}`);
|
|
1610
|
+
}
|
|
1611
|
+
return parseScenario(content);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// src/script/validator.ts
|
|
1615
|
+
function validateScenario(scenario) {
|
|
1616
|
+
const errors = [];
|
|
1617
|
+
const warnings = [];
|
|
1618
|
+
if (scenario.steps.length > 0) {
|
|
1619
|
+
const firstStep = scenario.steps[0];
|
|
1620
|
+
const hasNavigate = firstStep.actions.some(
|
|
1621
|
+
(a) => a.action === "navigate"
|
|
1622
|
+
);
|
|
1623
|
+
if (!hasNavigate) {
|
|
1624
|
+
errors.push(
|
|
1625
|
+
'First step must contain a "navigate" action to open a page'
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
for (let i = 0; i < scenario.steps.length; i++) {
|
|
1630
|
+
const step = scenario.steps[i];
|
|
1631
|
+
const stepLabel = step.name ? `"${step.name}"` : `#${i + 1}`;
|
|
1632
|
+
for (let j = 0; j < step.actions.length; j++) {
|
|
1633
|
+
const action = step.actions[j];
|
|
1634
|
+
if ("selector" in action && action.selector !== void 0) {
|
|
1635
|
+
const selector = action.selector;
|
|
1636
|
+
if (selector.trim() === "") {
|
|
1637
|
+
errors.push(
|
|
1638
|
+
`Step ${stepLabel}, action #${j + 1} (${action.action}): selector must not be empty`
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
const { width, height } = scenario.viewport;
|
|
1645
|
+
if (width < 100 || width > 3840) {
|
|
1646
|
+
errors.push(
|
|
1647
|
+
`Viewport width ${width} is out of range (must be 100-3840)`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
if (height < 100 || height > 3840) {
|
|
1651
|
+
errors.push(
|
|
1652
|
+
`Viewport height ${height} is out of range (must be 100-3840)`
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
const output = scenario.output;
|
|
1656
|
+
if (output.width < 100 || output.width > 3840) {
|
|
1657
|
+
errors.push(
|
|
1658
|
+
`Output width ${output.width} is out of range (must be 100-3840)`
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
if (output.height < 100 || output.height > 3840) {
|
|
1662
|
+
errors.push(
|
|
1663
|
+
`Output height ${output.height} is out of range (must be 100-3840)`
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
if (output.fps > 30) {
|
|
1667
|
+
warnings.push(
|
|
1668
|
+
`FPS is set to ${output.fps}. High FPS may produce very large files.`
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
if (output.format === "gif" && output.quality > 90) {
|
|
1672
|
+
warnings.push(
|
|
1673
|
+
"GIF quality above 90 has diminishing returns and increases file size significantly."
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
if (scenario.viewport.width !== output.width || scenario.viewport.height !== output.height) {
|
|
1677
|
+
warnings.push(
|
|
1678
|
+
"Viewport dimensions differ from output dimensions. Output will be scaled."
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
return {
|
|
1682
|
+
valid: errors.length === 0,
|
|
1683
|
+
errors,
|
|
1684
|
+
warnings
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
export {
|
|
1688
|
+
CanvasRenderer,
|
|
1689
|
+
ClipwiseRecorder,
|
|
1690
|
+
applyCrossfade,
|
|
1691
|
+
calculateAdaptiveZoom,
|
|
1692
|
+
calculatePanOffset,
|
|
1693
|
+
encodeGif,
|
|
1694
|
+
encodeMp4,
|
|
1695
|
+
lerpZoom,
|
|
1696
|
+
loadScenario,
|
|
1697
|
+
parseScenario,
|
|
1698
|
+
renderCursorHighlight,
|
|
1699
|
+
renderCursorTrail,
|
|
1700
|
+
renderKeystrokeHud,
|
|
1701
|
+
renderWatermark,
|
|
1702
|
+
savePngSequence,
|
|
1703
|
+
validateScenario
|
|
1704
|
+
};
|