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/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ };