@yassine-bouassida/scenecap 1.0.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.mjs ADDED
@@ -0,0 +1,1420 @@
1
+ // src/annotations/overlay.ts
2
+ var AnnotationOverlay = class {
3
+ constructor(container) {
4
+ this.annotations = /* @__PURE__ */ new Map();
5
+ this.animationFrame = null;
6
+ this.resize = () => {
7
+ const dpr = window.devicePixelRatio || 1;
8
+ const rect = this.canvas.parentElement.getBoundingClientRect();
9
+ this.canvas.width = rect.width * dpr;
10
+ this.canvas.height = rect.height * dpr;
11
+ this.ctx.scale(dpr, dpr);
12
+ };
13
+ this.canvas = document.createElement("canvas");
14
+ this.canvas.style.cssText = `
15
+ position: absolute;
16
+ top: 0;
17
+ left: 0;
18
+ width: 100%;
19
+ height: 100%;
20
+ pointer-events: none;
21
+ z-index: 999999;
22
+ `;
23
+ container.style.position = "relative";
24
+ container.appendChild(this.canvas);
25
+ const ctx = this.canvas.getContext("2d");
26
+ if (!ctx) throw new Error("Canvas 2D context not available");
27
+ this.ctx = ctx;
28
+ this.resize();
29
+ window.addEventListener("resize", this.resize);
30
+ }
31
+ // ── Add annotations ──
32
+ addCircle(id, target, color = "#ff3b30", thickness = 3) {
33
+ this.annotations.set(id, {
34
+ id,
35
+ type: "circle",
36
+ target,
37
+ color,
38
+ thickness,
39
+ opacity: 1,
40
+ animProgress: 0
41
+ });
42
+ this.startAnimation();
43
+ }
44
+ addHighlight(id, target, color = "#ffcc02", thickness = 0) {
45
+ this.annotations.set(id, {
46
+ id,
47
+ type: "highlight",
48
+ target,
49
+ color,
50
+ thickness,
51
+ opacity: 0.3,
52
+ animProgress: 0
53
+ });
54
+ this.startAnimation();
55
+ }
56
+ addArrow(id, target, color = "#ff3b30", thickness = 3) {
57
+ this.annotations.set(id, {
58
+ id,
59
+ type: "arrow",
60
+ target,
61
+ color,
62
+ thickness,
63
+ opacity: 1,
64
+ animProgress: 0
65
+ });
66
+ this.startAnimation();
67
+ }
68
+ addText(id, target, text, color = "#1a1a1a") {
69
+ this.annotations.set(id, {
70
+ id,
71
+ type: "text",
72
+ target,
73
+ color,
74
+ thickness: 0,
75
+ text,
76
+ opacity: 1,
77
+ animProgress: 0
78
+ });
79
+ this.startAnimation();
80
+ }
81
+ addZoomIndicator(id, target) {
82
+ this.annotations.set(id, {
83
+ id,
84
+ type: "zoom-indicator",
85
+ target,
86
+ color: "#007aff",
87
+ thickness: 2,
88
+ opacity: 0.6,
89
+ animProgress: 0
90
+ });
91
+ this.startAnimation();
92
+ }
93
+ remove(id) {
94
+ this.annotations.delete(id);
95
+ if (this.annotations.size === 0) this.stopAnimation();
96
+ else this.render();
97
+ }
98
+ clear() {
99
+ this.annotations.clear();
100
+ this.stopAnimation();
101
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
102
+ }
103
+ // ── Rendering ──
104
+ startAnimation() {
105
+ if (this.animationFrame) return;
106
+ const animate = () => {
107
+ this.update();
108
+ this.render();
109
+ this.animationFrame = requestAnimationFrame(animate);
110
+ };
111
+ this.animationFrame = requestAnimationFrame(animate);
112
+ }
113
+ stopAnimation() {
114
+ if (this.animationFrame) {
115
+ cancelAnimationFrame(this.animationFrame);
116
+ this.animationFrame = null;
117
+ }
118
+ }
119
+ update() {
120
+ for (const ann of this.annotations.values()) {
121
+ if (ann.animProgress < 1) {
122
+ ann.animProgress = Math.min(1, ann.animProgress + 0.04);
123
+ }
124
+ }
125
+ }
126
+ render() {
127
+ const dpr = window.devicePixelRatio || 1;
128
+ this.ctx.clearRect(0, 0, this.canvas.width / dpr, this.canvas.height / dpr);
129
+ for (const ann of this.annotations.values()) {
130
+ switch (ann.type) {
131
+ case "circle":
132
+ this.drawCircle(ann);
133
+ break;
134
+ case "highlight":
135
+ this.drawHighlight(ann);
136
+ break;
137
+ case "arrow":
138
+ this.drawArrow(ann);
139
+ break;
140
+ case "text":
141
+ this.drawText(ann);
142
+ break;
143
+ case "zoom-indicator":
144
+ this.drawZoomIndicator(ann);
145
+ break;
146
+ }
147
+ }
148
+ }
149
+ drawCircle(ann) {
150
+ const { target, color, thickness, animProgress } = ann;
151
+ const ctx = this.ctx;
152
+ const cx = target.x + target.width / 2;
153
+ const cy = target.y + target.height / 2;
154
+ const rx = target.width / 2 + 8;
155
+ const ry = target.height / 2 + 8;
156
+ ctx.save();
157
+ ctx.strokeStyle = color;
158
+ ctx.lineWidth = thickness;
159
+ ctx.globalAlpha = ann.opacity;
160
+ ctx.setLineDash([]);
161
+ ctx.beginPath();
162
+ const endAngle = animProgress * Math.PI * 2;
163
+ ctx.ellipse(cx, cy, rx, ry, 0, 0, endAngle);
164
+ ctx.stroke();
165
+ if (animProgress > 0.5) {
166
+ ctx.shadowColor = color;
167
+ ctx.shadowBlur = 8;
168
+ ctx.globalAlpha = ann.opacity * 0.3;
169
+ ctx.beginPath();
170
+ ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
171
+ ctx.stroke();
172
+ }
173
+ ctx.restore();
174
+ }
175
+ drawHighlight(ann) {
176
+ const { target, color, animProgress } = ann;
177
+ const ctx = this.ctx;
178
+ const pad = 4;
179
+ ctx.save();
180
+ ctx.fillStyle = color;
181
+ ctx.globalAlpha = ann.opacity * animProgress;
182
+ const r = 4;
183
+ const x = target.x - pad;
184
+ const y = target.y - pad;
185
+ const w = target.width + pad * 2;
186
+ const h = target.height + pad * 2;
187
+ ctx.beginPath();
188
+ ctx.moveTo(x + r, y);
189
+ ctx.lineTo(x + w - r, y);
190
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
191
+ ctx.lineTo(x + w, y + h - r);
192
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
193
+ ctx.lineTo(x + r, y + h);
194
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
195
+ ctx.lineTo(x, y + r);
196
+ ctx.quadraticCurveTo(x, y, x + r, y);
197
+ ctx.closePath();
198
+ ctx.fill();
199
+ ctx.restore();
200
+ }
201
+ drawArrow(ann) {
202
+ const { target, color, thickness, animProgress } = ann;
203
+ const ctx = this.ctx;
204
+ const endX = target.x + target.width / 2;
205
+ const endY = target.y + target.height / 2;
206
+ const startX = endX + 80;
207
+ const startY = endY - 60;
208
+ const progress = easeOutCubic(animProgress);
209
+ const currentX = startX + (endX - startX) * progress;
210
+ const currentY = startY + (endY - startY) * progress;
211
+ ctx.save();
212
+ ctx.strokeStyle = color;
213
+ ctx.fillStyle = color;
214
+ ctx.lineWidth = thickness;
215
+ ctx.globalAlpha = ann.opacity;
216
+ ctx.beginPath();
217
+ ctx.moveTo(startX, startY);
218
+ ctx.lineTo(currentX, currentY);
219
+ ctx.stroke();
220
+ if (progress > 0.8) {
221
+ const angle = Math.atan2(endY - startY, endX - startX);
222
+ const headLen = 12;
223
+ ctx.beginPath();
224
+ ctx.moveTo(currentX, currentY);
225
+ ctx.lineTo(
226
+ currentX - headLen * Math.cos(angle - 0.5),
227
+ currentY - headLen * Math.sin(angle - 0.5)
228
+ );
229
+ ctx.lineTo(
230
+ currentX - headLen * Math.cos(angle + 0.5),
231
+ currentY - headLen * Math.sin(angle + 0.5)
232
+ );
233
+ ctx.closePath();
234
+ ctx.fill();
235
+ }
236
+ ctx.restore();
237
+ }
238
+ drawText(ann) {
239
+ const { target, color, text, animProgress } = ann;
240
+ if (!text) return;
241
+ const ctx = this.ctx;
242
+ const x = target.x + target.width + 12;
243
+ const y = target.y + target.height / 2;
244
+ ctx.save();
245
+ ctx.globalAlpha = ann.opacity * animProgress;
246
+ ctx.font = '600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
247
+ const metrics = ctx.measureText(text);
248
+ const padX = 10;
249
+ const padY = 6;
250
+ const bgW = metrics.width + padX * 2;
251
+ const bgH = 24 + padY;
252
+ ctx.fillStyle = "#1a1a1a";
253
+ ctx.beginPath();
254
+ const r = 6;
255
+ ctx.moveTo(x + r, y - bgH / 2);
256
+ ctx.lineTo(x + bgW - r, y - bgH / 2);
257
+ ctx.quadraticCurveTo(x + bgW, y - bgH / 2, x + bgW, y - bgH / 2 + r);
258
+ ctx.lineTo(x + bgW, y + bgH / 2 - r);
259
+ ctx.quadraticCurveTo(x + bgW, y + bgH / 2, x + bgW - r, y + bgH / 2);
260
+ ctx.lineTo(x + r, y + bgH / 2);
261
+ ctx.quadraticCurveTo(x, y + bgH / 2, x, y + bgH / 2 - r);
262
+ ctx.lineTo(x, y - bgH / 2 + r);
263
+ ctx.quadraticCurveTo(x, y - bgH / 2, x + r, y - bgH / 2);
264
+ ctx.closePath();
265
+ ctx.fill();
266
+ ctx.fillStyle = color === "#1a1a1a" ? "#ffffff" : color;
267
+ ctx.textBaseline = "middle";
268
+ ctx.fillText(text, x + padX, y);
269
+ ctx.restore();
270
+ }
271
+ drawZoomIndicator(ann) {
272
+ const { target, color, thickness, animProgress } = ann;
273
+ const ctx = this.ctx;
274
+ ctx.save();
275
+ ctx.strokeStyle = color;
276
+ ctx.lineWidth = thickness;
277
+ ctx.globalAlpha = ann.opacity * (1 - animProgress * 0.3);
278
+ ctx.setLineDash([6, 4]);
279
+ const pad = 12;
280
+ const x = target.x - pad;
281
+ const y = target.y - pad;
282
+ const w = target.width + pad * 2;
283
+ const h = target.height + pad * 2;
284
+ ctx.strokeRect(x, y, w, h);
285
+ const iconX = x + w + 6;
286
+ const iconY = y - 6;
287
+ ctx.setLineDash([]);
288
+ ctx.beginPath();
289
+ ctx.arc(iconX, iconY, 8, 0, Math.PI * 2);
290
+ ctx.stroke();
291
+ ctx.beginPath();
292
+ ctx.moveTo(iconX + 6, iconY + 6);
293
+ ctx.lineTo(iconX + 12, iconY + 12);
294
+ ctx.stroke();
295
+ ctx.restore();
296
+ }
297
+ destroy() {
298
+ this.stopAnimation();
299
+ window.removeEventListener("resize", this.resize);
300
+ this.canvas.remove();
301
+ }
302
+ };
303
+ function easeOutCubic(t) {
304
+ return 1 - Math.pow(1 - t, 3);
305
+ }
306
+
307
+ // src/core/recorder.ts
308
+ var DEFAULT_OPTIONS = {
309
+ format: "webm",
310
+ fps: 30,
311
+ videoBitrate: 5e6,
312
+ width: 1280,
313
+ height: 720,
314
+ audio: false,
315
+ backgroundColor: "#ffffff",
316
+ devicePixelRatio: 1
317
+ };
318
+ var VideoRecorder = class {
319
+ constructor(options) {
320
+ this.mediaRecorder = null;
321
+ this.chunks = [];
322
+ this.canvas = null;
323
+ this.stream = null;
324
+ this.captureInterval = null;
325
+ this.options = { ...DEFAULT_OPTIONS, ...options };
326
+ }
327
+ /**
328
+ * Start recording from a canvas element.
329
+ */
330
+ async startFromCanvas(canvas) {
331
+ this.canvas = canvas;
332
+ this.chunks = [];
333
+ const stream = canvas.captureStream(this.options.fps);
334
+ this.stream = stream;
335
+ const mimeType = this.options.format === "mp4" ? "video/mp4" : "video/webm;codecs=vp9";
336
+ const supportedMime = MediaRecorder.isTypeSupported(mimeType) ? mimeType : MediaRecorder.isTypeSupported("video/webm;codecs=vp8") ? "video/webm;codecs=vp8" : "video/webm";
337
+ this.mediaRecorder = new MediaRecorder(stream, {
338
+ mimeType: supportedMime,
339
+ videoBitsPerSecond: this.options.videoBitrate
340
+ });
341
+ this.mediaRecorder.ondataavailable = (e) => {
342
+ if (e.data.size > 0) {
343
+ this.chunks.push(e.data);
344
+ }
345
+ };
346
+ this.mediaRecorder.start(100);
347
+ }
348
+ /**
349
+ * Start recording a DOM element by continuously painting it to a canvas.
350
+ */
351
+ async startFromElement(element) {
352
+ const canvas = document.createElement("canvas");
353
+ const dpr = this.options.devicePixelRatio;
354
+ canvas.width = this.options.width * dpr;
355
+ canvas.height = this.options.height * dpr;
356
+ await this.startFromCanvas(canvas);
357
+ const ctx = canvas.getContext("2d");
358
+ this.captureInterval = window.setInterval(async () => {
359
+ try {
360
+ const data = await domToImage(element, this.options.width, this.options.height);
361
+ if (data) {
362
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
363
+ ctx.drawImage(data, 0, 0, canvas.width, canvas.height);
364
+ }
365
+ } catch {
366
+ }
367
+ }, 1e3 / this.options.fps);
368
+ }
369
+ /**
370
+ * Stop recording and return the video blob.
371
+ */
372
+ async stop() {
373
+ if (this.captureInterval) {
374
+ clearInterval(this.captureInterval);
375
+ this.captureInterval = null;
376
+ }
377
+ return new Promise((resolve, reject) => {
378
+ if (!this.mediaRecorder) {
379
+ reject(new Error("No active recording"));
380
+ return;
381
+ }
382
+ this.mediaRecorder.onstop = () => {
383
+ const mimeType = this.mediaRecorder.mimeType || "video/webm";
384
+ const blob = new Blob(this.chunks, { type: mimeType });
385
+ this.cleanup();
386
+ resolve(blob);
387
+ };
388
+ this.mediaRecorder.onerror = (e) => {
389
+ this.cleanup();
390
+ reject(new Error(`Recording error: ${e}`));
391
+ };
392
+ if (this.mediaRecorder.state !== "inactive") {
393
+ this.mediaRecorder.stop();
394
+ } else {
395
+ const blob = new Blob(this.chunks, { type: "video/webm" });
396
+ this.cleanup();
397
+ resolve(blob);
398
+ }
399
+ });
400
+ }
401
+ cleanup() {
402
+ if (this.stream) {
403
+ this.stream.getTracks().forEach((t) => t.stop());
404
+ this.stream = null;
405
+ }
406
+ this.mediaRecorder = null;
407
+ this.canvas = null;
408
+ }
409
+ get isRecording() {
410
+ return this.mediaRecorder?.state === "recording";
411
+ }
412
+ };
413
+ async function domToImage(element, width, height) {
414
+ const clone = element.cloneNode(true);
415
+ inlineStyles(element, clone);
416
+ const html = new XMLSerializer().serializeToString(clone);
417
+ const svg = `
418
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
419
+ <foreignObject width="100%" height="100%">
420
+ <div xmlns="http://www.w3.org/1999/xhtml" style="width:${width}px;height:${height}px;overflow:hidden;">
421
+ ${html}
422
+ </div>
423
+ </foreignObject>
424
+ </svg>
425
+ `;
426
+ const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
427
+ const url = URL.createObjectURL(svgBlob);
428
+ return new Promise((resolve) => {
429
+ const img = new Image();
430
+ img.onload = () => {
431
+ URL.revokeObjectURL(url);
432
+ resolve(img);
433
+ };
434
+ img.onerror = () => {
435
+ URL.revokeObjectURL(url);
436
+ resolve(null);
437
+ };
438
+ img.src = url;
439
+ });
440
+ }
441
+ function inlineStyles(source, target) {
442
+ const computed = window.getComputedStyle(source);
443
+ const inline = Array.from(computed).map((prop) => `${prop}:${computed.getPropertyValue(prop)}`).join(";");
444
+ target.setAttribute("style", inline);
445
+ const sourceChildren = source.children;
446
+ const targetChildren = target.children;
447
+ for (let i = 0; i < sourceChildren.length; i++) {
448
+ if (targetChildren[i]) {
449
+ inlineStyles(sourceChildren[i], targetChildren[i]);
450
+ }
451
+ }
452
+ }
453
+
454
+ // src/core/zoom.ts
455
+ var ZoomController = class {
456
+ constructor(container) {
457
+ this.currentZoom = 1;
458
+ this.currentTranslateX = 0;
459
+ this.currentTranslateY = 0;
460
+ this.container = container;
461
+ this.container.style.transformOrigin = "0 0";
462
+ this.container.style.transition = "transform 0.6s cubic-bezier(0.4, 0, 0.2, 1)";
463
+ }
464
+ /**
465
+ * Zoom into a specific element.
466
+ */
467
+ async zoomTo(target, level = 2, duration = 600) {
468
+ const containerRect = this.container.getBoundingClientRect();
469
+ const targetCenterX = target.x - containerRect.x + target.width / 2;
470
+ const targetCenterY = target.y - containerRect.y + target.height / 2;
471
+ const viewportCenterX = containerRect.width / 2;
472
+ const viewportCenterY = containerRect.height / 2;
473
+ this.currentTranslateX = viewportCenterX - targetCenterX * level;
474
+ this.currentTranslateY = viewportCenterY - targetCenterY * level;
475
+ this.currentZoom = level;
476
+ this.container.style.transition = `transform ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`;
477
+ this.container.style.transform = `translate(${this.currentTranslateX}px, ${this.currentTranslateY}px) scale(${this.currentZoom})`;
478
+ return new Promise((resolve) => setTimeout(resolve, duration + 50));
479
+ }
480
+ /**
481
+ * Reset zoom to default.
482
+ */
483
+ async resetZoom(duration = 500) {
484
+ this.currentZoom = 1;
485
+ this.currentTranslateX = 0;
486
+ this.currentTranslateY = 0;
487
+ this.container.style.transition = `transform ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`;
488
+ this.container.style.transform = "translate(0, 0) scale(1)";
489
+ return new Promise((resolve) => setTimeout(resolve, duration + 50));
490
+ }
491
+ get zoom() {
492
+ return this.currentZoom;
493
+ }
494
+ destroy() {
495
+ this.container.style.transform = "";
496
+ this.container.style.transition = "";
497
+ this.container.style.transformOrigin = "";
498
+ }
499
+ };
500
+
501
+ // src/utils/cursor.ts
502
+ var VirtualCursor = class {
503
+ constructor(container) {
504
+ this.x = 0;
505
+ this.y = 0;
506
+ this.visible = false;
507
+ this.el = document.createElement("div");
508
+ this.el.innerHTML = `
509
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
510
+ <g filter="url(#shadow)">
511
+ <path d="M5 3L19 12.5L12.5 13.5L9.5 20L5 3Z" fill="white" stroke="black" stroke-width="1.2"/>
512
+ </g>
513
+ <defs>
514
+ <filter id="shadow" x="0" y="0" width="28" height="28" filterUnits="userSpaceOnUse">
515
+ <feDropShadow dx="1" dy="1" stdDeviation="1.5" flood-opacity="0.3"/>
516
+ </filter>
517
+ </defs>
518
+ </svg>
519
+ `;
520
+ this.el.style.cssText = `
521
+ position: absolute;
522
+ pointer-events: none;
523
+ z-index: 1000000;
524
+ transform: translate(-2px, -2px);
525
+ opacity: 0;
526
+ transition: opacity 0.2s ease;
527
+ `;
528
+ container.style.position = "relative";
529
+ container.appendChild(this.el);
530
+ }
531
+ show() {
532
+ this.visible = true;
533
+ this.el.style.opacity = "1";
534
+ }
535
+ hide() {
536
+ this.visible = false;
537
+ this.el.style.opacity = "0";
538
+ }
539
+ /**
540
+ * Smoothly move cursor to a position.
541
+ */
542
+ async moveTo(targetX, targetY, duration = 600) {
543
+ if (!this.visible) this.show();
544
+ const startX = this.x;
545
+ const startY = this.y;
546
+ const startTime = performance.now();
547
+ return new Promise((resolve) => {
548
+ const animate = (now) => {
549
+ const elapsed = now - startTime;
550
+ const progress = Math.min(1, elapsed / duration);
551
+ const eased = easeInOutCubic(progress);
552
+ this.x = startX + (targetX - startX) * eased;
553
+ this.y = startY + (targetY - startY) * eased;
554
+ this.el.style.left = `${this.x}px`;
555
+ this.el.style.top = `${this.y}px`;
556
+ if (progress < 1) {
557
+ requestAnimationFrame(animate);
558
+ } else {
559
+ resolve();
560
+ }
561
+ };
562
+ requestAnimationFrame(animate);
563
+ });
564
+ }
565
+ /**
566
+ * Move to center of a DOM element.
567
+ */
568
+ async moveToElement(el, duration = 600) {
569
+ const rect = el.getBoundingClientRect();
570
+ const container = this.el.parentElement.getBoundingClientRect();
571
+ const targetX = rect.x - container.x + rect.width / 2;
572
+ const targetY = rect.y - container.y + rect.height / 2;
573
+ return this.moveTo(targetX, targetY, duration);
574
+ }
575
+ /**
576
+ * Click animation at current position.
577
+ */
578
+ async clickAnimation() {
579
+ const ripple = document.createElement("div");
580
+ ripple.style.cssText = `
581
+ position: absolute;
582
+ left: ${this.x}px;
583
+ top: ${this.y}px;
584
+ width: 20px;
585
+ height: 20px;
586
+ border-radius: 50%;
587
+ background: rgba(0, 122, 255, 0.3);
588
+ transform: translate(-50%, -50%) scale(0);
589
+ pointer-events: none;
590
+ z-index: 999999;
591
+ `;
592
+ this.el.parentElement.appendChild(ripple);
593
+ ripple.animate(
594
+ [
595
+ { transform: "translate(-50%, -50%) scale(0)", opacity: 0.6 },
596
+ { transform: "translate(-50%, -50%) scale(3)", opacity: 0 }
597
+ ],
598
+ { duration: 400, easing: "ease-out" }
599
+ );
600
+ this.el.style.transform = "translate(-2px, -2px) scale(0.85)";
601
+ await sleep(100);
602
+ this.el.style.transform = "translate(-2px, -2px) scale(1)";
603
+ await sleep(300);
604
+ ripple.remove();
605
+ }
606
+ destroy() {
607
+ this.el.remove();
608
+ }
609
+ };
610
+ function easeInOutCubic(t) {
611
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
612
+ }
613
+ function sleep(ms) {
614
+ return new Promise((resolve) => setTimeout(resolve, ms));
615
+ }
616
+
617
+ // src/utils/element-resolver.ts
618
+ function resolveElement(selector, root = document.documentElement) {
619
+ const hasTextMatch = selector.match(/^(.+?):has-text\(["'](.+?)["']\)$/);
620
+ if (hasTextMatch) {
621
+ const [, baseSelector, searchText] = hasTextMatch;
622
+ return findByText(baseSelector, searchText, root);
623
+ }
624
+ const selectors = selector.split(",").map((s) => s.trim());
625
+ for (const sel of selectors) {
626
+ const subTextMatch = sel.match(/^(.+?):has-text\(["'](.+?)["']\)$/);
627
+ if (subTextMatch) {
628
+ const [, baseSel, txt] = subTextMatch;
629
+ const found = findByText(baseSel, txt, root);
630
+ if (found) return found;
631
+ continue;
632
+ }
633
+ try {
634
+ const el = root.querySelector(sel);
635
+ if (el) return el;
636
+ } catch {
637
+ }
638
+ }
639
+ return findByAccessibility(selector, root);
640
+ }
641
+ function findByText(baseSelector, text, root) {
642
+ const lower = text.toLowerCase();
643
+ try {
644
+ const candidates = root.querySelectorAll(baseSelector);
645
+ for (const el of candidates) {
646
+ const content = el.textContent?.trim().toLowerCase();
647
+ if (content && (content === lower || content.includes(lower))) {
648
+ return el;
649
+ }
650
+ }
651
+ } catch {
652
+ }
653
+ return null;
654
+ }
655
+ function findByAccessibility(text, root) {
656
+ const cleaned = text.replace(/^["']|["']$/g, "").replace(/^the\s+/i, "").trim().toLowerCase();
657
+ const ariaElements = root.querySelectorAll("[aria-label]");
658
+ for (const el of ariaElements) {
659
+ const label = el.getAttribute("aria-label")?.toLowerCase();
660
+ if (label && label.includes(cleaned)) return el;
661
+ }
662
+ const placeholders = root.querySelectorAll("[placeholder]");
663
+ for (const el of placeholders) {
664
+ const ph = el.getAttribute("placeholder")?.toLowerCase();
665
+ if (ph && ph.includes(cleaned)) return el;
666
+ }
667
+ const allElements = root.querySelectorAll("button, a, label, h1, h2, h3, h4, h5, h6, [role]");
668
+ for (const el of allElements) {
669
+ const content = el.textContent?.trim().toLowerCase();
670
+ if (content && content.includes(cleaned)) return el;
671
+ }
672
+ return null;
673
+ }
674
+ function getElementRect(el) {
675
+ return el.getBoundingClientRect();
676
+ }
677
+ function scrollIntoView(el, smooth = true) {
678
+ return new Promise((resolve) => {
679
+ el.scrollIntoView({ behavior: smooth ? "smooth" : "auto", block: "center", inline: "center" });
680
+ setTimeout(resolve, smooth ? 500 : 50);
681
+ });
682
+ }
683
+ function simulateClick(el) {
684
+ const events = ["mouseenter", "mouseover", "mousedown", "mouseup", "click"];
685
+ for (const eventType of events) {
686
+ const rect = el.getBoundingClientRect();
687
+ const event = new MouseEvent(eventType, {
688
+ bubbles: true,
689
+ cancelable: true,
690
+ view: window,
691
+ clientX: rect.x + rect.width / 2,
692
+ clientY: rect.y + rect.height / 2
693
+ });
694
+ el.dispatchEvent(event);
695
+ }
696
+ }
697
+ async function simulateType(el, text, speed = 50) {
698
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
699
+ el.focus();
700
+ el.value = "";
701
+ for (let i = 0; i < text.length; i++) {
702
+ const char = text[i];
703
+ el.value += char;
704
+ el.dispatchEvent(new InputEvent("input", { bubbles: true, data: char, inputType: "insertText" }));
705
+ el.dispatchEvent(new Event("change", { bubbles: true }));
706
+ await sleep2(speed + Math.random() * speed * 0.5);
707
+ }
708
+ }
709
+ }
710
+ function sleep2(ms) {
711
+ return new Promise((resolve) => setTimeout(resolve, ms));
712
+ }
713
+
714
+ // src/parsers/natural-language.ts
715
+ var PATTERNS = [
716
+ // ── Click Actions ──
717
+ {
718
+ pattern: /(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?["'](.+?)["']\s*button/i,
719
+ build: (m) => ({ type: "click", target: `button:has-text("${m[1]}")` }),
720
+ confidence: 0.95
721
+ },
722
+ {
723
+ pattern: /(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?(.+?)\s+button/i,
724
+ build: (m) => ({ type: "click", target: `button:has-text("${m[1]}")` }),
725
+ confidence: 0.9
726
+ },
727
+ {
728
+ pattern: /(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?["'](.+?)["']\s*link/i,
729
+ build: (m) => ({ type: "click", target: `a:has-text("${m[1]}")` }),
730
+ confidence: 0.95
731
+ },
732
+ {
733
+ pattern: /(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?(.+?)\s+link/i,
734
+ build: (m) => ({ type: "click", target: `a:has-text("${m[1]}")` }),
735
+ confidence: 0.85
736
+ },
737
+ {
738
+ pattern: /(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?(.+)/i,
739
+ build: (m) => ({ type: "click", target: resolveSelector(m[1]) }),
740
+ confidence: 0.75
741
+ },
742
+ // ── Type / Input Actions ──
743
+ {
744
+ pattern: /(?:type|enter|input|fill(?:\s+in)?)\s+["'](.+?)["']\s+(?:in(?:to)?|on)\s+(?:the\s+)?(.+?)(?:\s+(?:field|input|box|area))?$/i,
745
+ build: (m) => ({ type: "type", value: m[1], target: resolveInputSelector(m[2]) }),
746
+ confidence: 0.95
747
+ },
748
+ {
749
+ pattern: /(?:type|enter|input|fill(?:\s+in)?)\s+["'](.+?)["']\s+(?:in(?:to)?|on)\s+(?:the\s+)?(.+)/i,
750
+ build: (m) => ({ type: "type", value: m[1], target: resolveInputSelector(m[2]) }),
751
+ confidence: 0.85
752
+ },
753
+ {
754
+ pattern: /(?:type|enter|input)\s+["'](.+?)["']/i,
755
+ build: (m) => ({ type: "type", value: m[1] }),
756
+ confidence: 0.7
757
+ },
758
+ // ── Zoom Actions ──
759
+ {
760
+ pattern: /zoom\s+(?:in(?:to)?|on)\s+(?:the\s+)?(.+?)(?:\s+(?:to|at|by)\s+(\d+)%?)?$/i,
761
+ build: (m) => ({
762
+ type: "zoom",
763
+ target: resolveSelector(m[1]),
764
+ zoomLevel: m[2] ? parseInt(m[2]) / 100 : 2
765
+ }),
766
+ confidence: 0.95
767
+ },
768
+ {
769
+ pattern: /(?:magnify|enlarge|focus\s+on)\s+(?:the\s+)?(.+)/i,
770
+ build: (m) => ({ type: "zoom", target: resolveSelector(m[1]), zoomLevel: 2 }),
771
+ confidence: 0.85
772
+ },
773
+ {
774
+ pattern: /zoom\s+out/i,
775
+ build: () => ({ type: "zoom", zoomLevel: 1 }),
776
+ confidence: 0.95
777
+ },
778
+ // ── Circle / Highlight Actions ──
779
+ {
780
+ pattern: /(?:circle|ring|outline)\s+(?:the\s+)?(.+?)(?:\s+(?:in|with)\s+(\w+))?$/i,
781
+ build: (m) => ({
782
+ type: "circle",
783
+ target: resolveSelector(m[1]),
784
+ color: m[2] || "#ff3b30"
785
+ }),
786
+ confidence: 0.95
787
+ },
788
+ {
789
+ pattern: /(?:draw\s+a\s+circle\s+(?:around|on))\s+(?:the\s+)?(.+)/i,
790
+ build: (m) => ({ type: "circle", target: resolveSelector(m[1]), color: "#ff3b30" }),
791
+ confidence: 0.9
792
+ },
793
+ {
794
+ pattern: /highlight\s+(?:the\s+)?(.+?)(?:\s+(?:in|with)\s+(\w+))?$/i,
795
+ build: (m) => ({
796
+ type: "highlight",
797
+ target: resolveSelector(m[1]),
798
+ color: m[2] || "#ffcc02"
799
+ }),
800
+ confidence: 0.95
801
+ },
802
+ // ── Arrow Actions ──
803
+ {
804
+ pattern: /(?:draw\s+)?(?:an?\s+)?arrow\s+(?:to|towards|pointing\s+(?:to|at))\s+(?:the\s+)?(.+)/i,
805
+ build: (m) => ({ type: "arrow", target: resolveSelector(m[1]), color: "#ff3b30" }),
806
+ confidence: 0.9
807
+ },
808
+ // ── Annotation / Text Actions ──
809
+ {
810
+ pattern: /(?:annotate|label|add\s+(?:a\s+)?(?:text|note|label))\s+["'](.+?)["']\s+(?:on|near|next\s+to|beside)\s+(?:the\s+)?(.+)/i,
811
+ build: (m) => ({ type: "annotate", value: m[1], target: resolveSelector(m[2]) }),
812
+ confidence: 0.9
813
+ },
814
+ {
815
+ pattern: /(?:show\s+(?:a\s+)?(?:tooltip|callout|note))\s+["'](.+?)["']\s+(?:on|near|at)\s+(?:the\s+)?(.+)/i,
816
+ build: (m) => ({ type: "annotate", value: m[1], target: resolveSelector(m[2]) }),
817
+ confidence: 0.85
818
+ },
819
+ // ── Scroll Actions ──
820
+ {
821
+ pattern: /scroll\s+(up|down)\s*(?:(\d+)\s*(?:px|pixels)?)?/i,
822
+ build: (m) => ({
823
+ type: "scroll",
824
+ value: m[1],
825
+ offset: { x: 0, y: m[2] ? parseInt(m[2]) : 300 }
826
+ }),
827
+ confidence: 0.9
828
+ },
829
+ {
830
+ pattern: /scroll\s+(?:to\s+)?(?:the\s+)?(.+)/i,
831
+ build: (m) => ({ type: "scroll", target: resolveSelector(m[1]) }),
832
+ confidence: 0.8
833
+ },
834
+ // ── Wait Actions ──
835
+ {
836
+ pattern: /(?:wait|pause|delay)\s+(?:for\s+)?(\d+(?:\.\d+)?)\s*(?:s(?:ec(?:ond)?s?)?|ms|milliseconds?)/i,
837
+ build: (m) => ({
838
+ type: "wait",
839
+ duration: m[0].includes("ms") ? parseInt(m[1]) : parseFloat(m[1]) * 1e3
840
+ }),
841
+ confidence: 0.95
842
+ },
843
+ {
844
+ pattern: /(?:wait|pause|delay)\s+(?:for\s+)?(\d+)/i,
845
+ build: (m) => ({ type: "wait", duration: parseInt(m[1]) * 1e3 }),
846
+ confidence: 0.8
847
+ },
848
+ // ── Hover Actions ──
849
+ {
850
+ pattern: /(?:hover|move\s+(?:the\s+)?(?:mouse|cursor))\s+(?:over|on(?:to)?)\s+(?:the\s+)?(.+)/i,
851
+ build: (m) => ({ type: "hover", target: resolveSelector(m[1]) }),
852
+ confidence: 0.9
853
+ },
854
+ // ── Navigate Actions ──
855
+ {
856
+ pattern: /(?:navigate|go)\s+to\s+(.+)/i,
857
+ build: (m) => ({ type: "navigate", value: m[1].trim() }),
858
+ confidence: 0.9
859
+ },
860
+ {
861
+ pattern: /(?:open|visit)\s+(?:the\s+)?(?:page\s+)?(.+)/i,
862
+ build: (m) => ({ type: "navigate", value: m[1].trim() }),
863
+ confidence: 0.8
864
+ },
865
+ // ── Screenshot Actions ──
866
+ {
867
+ pattern: /(?:take\s+a\s+)?screenshot/i,
868
+ build: () => ({ type: "screenshot" }),
869
+ confidence: 0.95
870
+ }
871
+ ];
872
+ function resolveSelector(naturalDesc) {
873
+ const desc = naturalDesc.trim().toLowerCase();
874
+ if (desc.startsWith("#") || desc.startsWith(".") || desc.startsWith("[")) {
875
+ return naturalDesc.trim();
876
+ }
877
+ const elementMap = {
878
+ "search bar": 'input[type="search"], input[placeholder*="search" i]',
879
+ "search input": 'input[type="search"], input[placeholder*="search" i]',
880
+ "search box": 'input[type="search"], input[placeholder*="search" i]',
881
+ "email field": 'input[type="email"], input[placeholder*="email" i]',
882
+ "email input": 'input[type="email"], input[placeholder*="email" i]',
883
+ "password field": 'input[type="password"]',
884
+ "password input": 'input[type="password"]',
885
+ "username field": 'input[name*="user" i], input[placeholder*="user" i]',
886
+ "submit button": 'button[type="submit"], input[type="submit"]',
887
+ "login button": 'button:has-text("Log in"), button:has-text("Login"), button:has-text("Sign in")',
888
+ "signup button": 'button:has-text("Sign up"), button:has-text("Register")',
889
+ "navbar": 'nav, [role="navigation"]',
890
+ "navigation": 'nav, [role="navigation"]',
891
+ "header": 'header, [role="banner"]',
892
+ "footer": 'footer, [role="contentinfo"]',
893
+ "sidebar": 'aside, [role="complementary"]',
894
+ "main content": 'main, [role="main"]',
895
+ "logo": '[class*="logo" i], img[alt*="logo" i]',
896
+ "menu": '[role="menu"], nav ul',
897
+ "dropdown": '[role="listbox"], select, [class*="dropdown" i]',
898
+ "modal": '[role="dialog"], [class*="modal" i]',
899
+ "close button": 'button[aria-label="Close"], button:has-text("\xD7"), [class*="close" i]'
900
+ };
901
+ for (const [key, selector] of Object.entries(elementMap)) {
902
+ if (desc === key || desc === `the ${key}`) {
903
+ return selector;
904
+ }
905
+ }
906
+ const buttonMatch = desc.match(/^(?:the\s+)?(.+?)\s+button$/);
907
+ if (buttonMatch) {
908
+ return `button:has-text("${buttonMatch[1]}")`;
909
+ }
910
+ const inputMatch = desc.match(/^(?:the\s+)?(.+?)\s+(?:input|field|box|area)$/);
911
+ if (inputMatch) {
912
+ return resolveInputSelector(inputMatch[1]);
913
+ }
914
+ const linkMatch = desc.match(/^(?:the\s+)?(.+?)\s+link$/);
915
+ if (linkMatch) {
916
+ return `a:has-text("${linkMatch[1]}")`;
917
+ }
918
+ const cleaned = desc.replace(/^the\s+/, "");
919
+ return `[aria-label*="${cleaned}" i], [placeholder*="${cleaned}" i], [title*="${cleaned}" i], :has-text("${cleaned}")`;
920
+ }
921
+ function resolveInputSelector(name) {
922
+ const n = name.trim().toLowerCase().replace(/^the\s+/, "");
923
+ return `input[name*="${n}" i], input[placeholder*="${n}" i], textarea[name*="${n}" i], textarea[placeholder*="${n}" i], [aria-label*="${n}" i]`;
924
+ }
925
+ var COLOR_MAP = {
926
+ red: "#ff3b30",
927
+ blue: "#007aff",
928
+ green: "#34c759",
929
+ yellow: "#ffcc02",
930
+ orange: "#ff9500",
931
+ purple: "#af52de",
932
+ pink: "#ff2d55",
933
+ white: "#ffffff",
934
+ black: "#000000",
935
+ gray: "#8e8e93",
936
+ grey: "#8e8e93",
937
+ cyan: "#32ade6",
938
+ teal: "#5ac8fa"
939
+ };
940
+ function resolveColor(colorStr) {
941
+ if (!colorStr) return void 0;
942
+ if (colorStr.startsWith("#") || colorStr.startsWith("rgb")) return colorStr;
943
+ return COLOR_MAP[colorStr.toLowerCase()] || colorStr;
944
+ }
945
+ function parseInstruction(text) {
946
+ const trimmed = text.trim();
947
+ for (const matcher of PATTERNS) {
948
+ const match = trimmed.match(matcher.pattern);
949
+ if (match) {
950
+ const partial = matcher.build(match);
951
+ const action = {
952
+ type: partial.type,
953
+ ...partial,
954
+ color: resolveColor(partial.color)
955
+ };
956
+ return { raw: trimmed, action, confidence: matcher.confidence };
957
+ }
958
+ }
959
+ return {
960
+ raw: trimmed,
961
+ action: { type: "click", target: resolveSelector(trimmed) },
962
+ confidence: 0.3
963
+ };
964
+ }
965
+ function parseNaturalLanguageScenario(script, options) {
966
+ const lines = script.split(/[\n;]|(?:\d+\.\s+)/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("//") && !l.startsWith("#"));
967
+ const actions = [];
968
+ const lowConfidence = [];
969
+ for (const line of lines) {
970
+ const parsed = parseInstruction(line);
971
+ actions.push(parsed.action);
972
+ if (parsed.confidence < 0.5) {
973
+ lowConfidence.push({ line, confidence: parsed.confidence });
974
+ }
975
+ }
976
+ if (lowConfidence.length > 0) {
977
+ console.warn(
978
+ `[scenecap] ${lowConfidence.length} instruction(s) had low parse confidence:`,
979
+ lowConfidence
980
+ );
981
+ }
982
+ return {
983
+ name: options?.name || "Untitled Scenario",
984
+ description: options?.description,
985
+ startUrl: options?.startUrl,
986
+ viewportWidth: 1280,
987
+ viewportHeight: 720,
988
+ actions
989
+ };
990
+ }
991
+
992
+ // src/core/runner.ts
993
+ var DEFAULT_CONFIG = {
994
+ recording: {
995
+ format: "webm",
996
+ fps: 30,
997
+ videoBitrate: 5e6,
998
+ width: 1280,
999
+ height: 720,
1000
+ audio: false,
1001
+ backgroundColor: "#ffffff",
1002
+ devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1
1003
+ },
1004
+ annotations: {
1005
+ circleColor: "#ff3b30",
1006
+ highlightColor: "#ffcc02",
1007
+ arrowColor: "#ff3b30",
1008
+ textColor: "#1a1a1a",
1009
+ thickness: 3,
1010
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
1011
+ fontSize: 14
1012
+ },
1013
+ animation: {
1014
+ defaultDuration: 800,
1015
+ defaultEasing: "ease-in-out",
1016
+ zoomDuration: 600
1017
+ }
1018
+ };
1019
+ var ScenarioRunner = class {
1020
+ constructor(config) {
1021
+ this.session = null;
1022
+ // Runtime components (created per-run)
1023
+ this.overlay = null;
1024
+ this.recorder = null;
1025
+ this.zoom = null;
1026
+ this.cursor = null;
1027
+ this.config = mergeDeep(DEFAULT_CONFIG, config || {});
1028
+ this.callbacks = config?.callbacks || {};
1029
+ }
1030
+ // ─── Public API ─────────────────────────────────────────────
1031
+ /**
1032
+ * Record a scenario described in natural language.
1033
+ *
1034
+ * @param script - Natural language description of the scenario
1035
+ * @param container - The DOM element to record
1036
+ * @param options - Additional scenario options
1037
+ * @returns The recorded video as a Blob
1038
+ *
1039
+ * @example
1040
+ * ```ts
1041
+ * const blob = await runner.record(`
1042
+ * Click on the "Sign Up" button
1043
+ * Type "john@email.com" in the email field
1044
+ * Zoom in on the submit button
1045
+ * Circle the success message in green
1046
+ * `, document.getElementById('app')!);
1047
+ * ```
1048
+ */
1049
+ async record(script, container, options) {
1050
+ const scenario = parseNaturalLanguageScenario(script, options);
1051
+ return this.recordScenario(scenario, container);
1052
+ }
1053
+ /**
1054
+ * Record a structured scenario object.
1055
+ */
1056
+ async recordScenario(scenario, container) {
1057
+ this.session = {
1058
+ id: generateId(),
1059
+ scenario,
1060
+ options: this.config.recording,
1061
+ status: "preparing",
1062
+ progress: 0
1063
+ };
1064
+ try {
1065
+ this.overlay = new AnnotationOverlay(container);
1066
+ this.zoom = new ZoomController(container);
1067
+ this.cursor = new VirtualCursor(container);
1068
+ this.recorder = new VideoRecorder(this.config.recording);
1069
+ this.session.status = "recording";
1070
+ await this.recorder.startFromElement(container);
1071
+ await sleep3(500);
1072
+ const total = scenario.actions.length;
1073
+ for (let i = 0; i < total; i++) {
1074
+ const action = scenario.actions[i];
1075
+ this.callbacks.onActionStart?.(action, i);
1076
+ if (action.delay) {
1077
+ await sleep3(action.delay);
1078
+ }
1079
+ await this.executeAction(action, container);
1080
+ this.session.progress = (i + 1) / total * 100;
1081
+ this.callbacks.onProgress?.(this.session.progress);
1082
+ this.callbacks.onActionComplete?.(action, i);
1083
+ await sleep3(300);
1084
+ }
1085
+ await sleep3(1e3);
1086
+ this.session.status = "processing";
1087
+ const blob = await this.recorder.stop();
1088
+ this.cleanup();
1089
+ this.session.status = "complete";
1090
+ this.session.result = blob;
1091
+ this.callbacks.onComplete?.(blob);
1092
+ return blob;
1093
+ } catch (error) {
1094
+ this.session.status = "error";
1095
+ this.session.error = error.message;
1096
+ this.cleanup();
1097
+ this.callbacks.onError?.(error);
1098
+ throw error;
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Parse a natural language script without recording (for previewing).
1103
+ */
1104
+ parse(script) {
1105
+ return parseNaturalLanguageScenario(script);
1106
+ }
1107
+ /**
1108
+ * Get current session info.
1109
+ */
1110
+ getSession() {
1111
+ return this.session;
1112
+ }
1113
+ // ─── Action Execution ──────────────────────────────────────
1114
+ async executeAction(action, container) {
1115
+ const duration = action.duration || this.config.animation?.defaultDuration || 800;
1116
+ let target = null;
1117
+ if (action.target) {
1118
+ target = resolveElement(action.target, container);
1119
+ if (!target && action.type !== "wait" && action.type !== "navigate") {
1120
+ console.warn(`[scenecap] Element not found: ${action.target}`);
1121
+ return;
1122
+ }
1123
+ }
1124
+ switch (action.type) {
1125
+ case "click": {
1126
+ if (!target) return;
1127
+ await scrollIntoView(target);
1128
+ await this.cursor?.moveToElement(target, 500);
1129
+ await this.cursor?.clickAnimation();
1130
+ simulateClick(target);
1131
+ await sleep3(200);
1132
+ break;
1133
+ }
1134
+ case "type": {
1135
+ if (!target) return;
1136
+ await scrollIntoView(target);
1137
+ await this.cursor?.moveToElement(target, 500);
1138
+ await this.cursor?.clickAnimation();
1139
+ simulateClick(target);
1140
+ if (action.value) {
1141
+ await simulateType(target, action.value, 60);
1142
+ }
1143
+ break;
1144
+ }
1145
+ case "hover": {
1146
+ if (!target) return;
1147
+ await scrollIntoView(target);
1148
+ await this.cursor?.moveToElement(target, 600);
1149
+ target.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
1150
+ target.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
1151
+ await sleep3(duration);
1152
+ break;
1153
+ }
1154
+ case "scroll": {
1155
+ if (target) {
1156
+ await scrollIntoView(target, true);
1157
+ } else if (action.offset) {
1158
+ const dir = action.value === "up" ? -1 : 1;
1159
+ container.scrollBy({ top: action.offset.y * dir, behavior: "smooth" });
1160
+ await sleep3(500);
1161
+ }
1162
+ break;
1163
+ }
1164
+ case "zoom": {
1165
+ if (action.zoomLevel === 1 || !action.target) {
1166
+ await this.zoom?.resetZoom(duration);
1167
+ this.overlay?.clear();
1168
+ } else if (target) {
1169
+ const rect = getElementRect(target);
1170
+ this.overlay?.addZoomIndicator(`zoom-${Date.now()}`, rect);
1171
+ await this.zoom?.zoomTo(rect, action.zoomLevel || 2, duration);
1172
+ await sleep3(300);
1173
+ }
1174
+ break;
1175
+ }
1176
+ case "circle": {
1177
+ if (!target) return;
1178
+ await scrollIntoView(target);
1179
+ const rect = getElementRect(target);
1180
+ const id = `circle-${Date.now()}`;
1181
+ this.overlay?.addCircle(
1182
+ id,
1183
+ rect,
1184
+ action.color || this.config.annotations?.circleColor,
1185
+ action.thickness || this.config.annotations?.thickness
1186
+ );
1187
+ await sleep3(duration);
1188
+ break;
1189
+ }
1190
+ case "highlight": {
1191
+ if (!target) return;
1192
+ await scrollIntoView(target);
1193
+ const rect = getElementRect(target);
1194
+ const id = `hl-${Date.now()}`;
1195
+ this.overlay?.addHighlight(
1196
+ id,
1197
+ rect,
1198
+ action.color || this.config.annotations?.highlightColor
1199
+ );
1200
+ await sleep3(duration);
1201
+ break;
1202
+ }
1203
+ case "arrow": {
1204
+ if (!target) return;
1205
+ await scrollIntoView(target);
1206
+ const rect = getElementRect(target);
1207
+ const id = `arrow-${Date.now()}`;
1208
+ this.overlay?.addArrow(
1209
+ id,
1210
+ rect,
1211
+ action.color || this.config.annotations?.arrowColor,
1212
+ action.thickness || this.config.annotations?.thickness
1213
+ );
1214
+ await sleep3(duration);
1215
+ break;
1216
+ }
1217
+ case "annotate": {
1218
+ if (!target) return;
1219
+ await scrollIntoView(target);
1220
+ const rect = getElementRect(target);
1221
+ const id = `text-${Date.now()}`;
1222
+ this.overlay?.addText(
1223
+ id,
1224
+ rect,
1225
+ action.value || "",
1226
+ action.color || this.config.annotations?.textColor
1227
+ );
1228
+ await sleep3(duration);
1229
+ break;
1230
+ }
1231
+ case "wait": {
1232
+ await sleep3(action.duration || 1e3);
1233
+ break;
1234
+ }
1235
+ case "navigate": {
1236
+ if (action.value) {
1237
+ window.location.href = action.value;
1238
+ await sleep3(2e3);
1239
+ }
1240
+ break;
1241
+ }
1242
+ case "screenshot": {
1243
+ const flash = document.createElement("div");
1244
+ flash.style.cssText = `
1245
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
1246
+ background: white; z-index: 9999999; pointer-events: none;
1247
+ `;
1248
+ document.body.appendChild(flash);
1249
+ flash.animate([{ opacity: 0.7 }, { opacity: 0 }], { duration: 300 });
1250
+ await sleep3(300);
1251
+ flash.remove();
1252
+ break;
1253
+ }
1254
+ }
1255
+ }
1256
+ // ─── Cleanup ────────────────────────────────────────────────
1257
+ cleanup() {
1258
+ this.overlay?.destroy();
1259
+ this.zoom?.destroy();
1260
+ this.cursor?.destroy();
1261
+ this.overlay = null;
1262
+ this.zoom = null;
1263
+ this.cursor = null;
1264
+ }
1265
+ };
1266
+ function generateId() {
1267
+ return `sc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1268
+ }
1269
+ function sleep3(ms) {
1270
+ return new Promise((resolve) => setTimeout(resolve, ms));
1271
+ }
1272
+ function mergeDeep(target, source) {
1273
+ const result = { ...target };
1274
+ for (const key of Object.keys(source)) {
1275
+ if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key]) && target[key] && typeof target[key] === "object") {
1276
+ result[key] = mergeDeep(target[key], source[key]);
1277
+ } else if (source[key] !== void 0) {
1278
+ result[key] = source[key];
1279
+ }
1280
+ }
1281
+ return result;
1282
+ }
1283
+
1284
+ // src/hooks/useScenecap.ts
1285
+ import { useRef, useState, useCallback } from "react";
1286
+ function useScenecap(config) {
1287
+ const containerRef = useRef(null);
1288
+ const runnerRef = useRef(null);
1289
+ const [isRecording, setIsRecording] = useState(false);
1290
+ const [progress, setProgress] = useState(0);
1291
+ const [session, setSession] = useState(null);
1292
+ const [error, setError] = useState(null);
1293
+ const [blob, setBlob] = useState(null);
1294
+ const getRunner = useCallback(() => {
1295
+ if (!runnerRef.current) {
1296
+ runnerRef.current = new ScenarioRunner({
1297
+ ...config,
1298
+ callbacks: {
1299
+ ...config?.callbacks,
1300
+ onProgress: (p) => {
1301
+ setProgress(p);
1302
+ config?.callbacks?.onProgress?.(p);
1303
+ },
1304
+ onError: (err) => {
1305
+ setError(err.message);
1306
+ setIsRecording(false);
1307
+ config?.callbacks?.onError?.(err);
1308
+ },
1309
+ onComplete: (b) => {
1310
+ setBlob(b);
1311
+ setIsRecording(false);
1312
+ config?.callbacks?.onComplete?.(b);
1313
+ }
1314
+ }
1315
+ });
1316
+ }
1317
+ return runnerRef.current;
1318
+ }, [config]);
1319
+ const record = useCallback(
1320
+ async (script, options) => {
1321
+ if (!containerRef.current) {
1322
+ setError("Container ref not attached to a DOM element");
1323
+ return null;
1324
+ }
1325
+ setIsRecording(true);
1326
+ setProgress(0);
1327
+ setError(null);
1328
+ setBlob(null);
1329
+ try {
1330
+ const runner = getRunner();
1331
+ const result = await runner.record(script, containerRef.current, options);
1332
+ setSession(runner.getSession());
1333
+ return result;
1334
+ } catch (err) {
1335
+ setError(err.message);
1336
+ return null;
1337
+ } finally {
1338
+ setIsRecording(false);
1339
+ }
1340
+ },
1341
+ [getRunner]
1342
+ );
1343
+ const recordScenario = useCallback(
1344
+ async (scenario) => {
1345
+ if (!containerRef.current) {
1346
+ setError("Container ref not attached to a DOM element");
1347
+ return null;
1348
+ }
1349
+ setIsRecording(true);
1350
+ setProgress(0);
1351
+ setError(null);
1352
+ setBlob(null);
1353
+ try {
1354
+ const runner = getRunner();
1355
+ const result = await runner.recordScenario(scenario, containerRef.current);
1356
+ setSession(runner.getSession());
1357
+ return result;
1358
+ } catch (err) {
1359
+ setError(err.message);
1360
+ return null;
1361
+ } finally {
1362
+ setIsRecording(false);
1363
+ }
1364
+ },
1365
+ [getRunner]
1366
+ );
1367
+ const parse = useCallback(
1368
+ (script) => {
1369
+ return getRunner().parse(script);
1370
+ },
1371
+ [getRunner]
1372
+ );
1373
+ const download = useCallback(
1374
+ (filename = "scenecap-recording.webm") => {
1375
+ if (!blob) {
1376
+ console.warn("[scenecap] No recording to download");
1377
+ return;
1378
+ }
1379
+ const url = URL.createObjectURL(blob);
1380
+ const a = document.createElement("a");
1381
+ a.href = url;
1382
+ a.download = filename;
1383
+ a.click();
1384
+ URL.revokeObjectURL(url);
1385
+ },
1386
+ [blob]
1387
+ );
1388
+ return {
1389
+ containerRef,
1390
+ record,
1391
+ recordScenario,
1392
+ parse,
1393
+ download,
1394
+ isRecording,
1395
+ progress,
1396
+ session,
1397
+ error,
1398
+ blob
1399
+ };
1400
+ }
1401
+
1402
+ // src/index.ts
1403
+ function createScenecap(config) {
1404
+ return new ScenarioRunner(config);
1405
+ }
1406
+ export {
1407
+ AnnotationOverlay,
1408
+ ScenarioRunner,
1409
+ VideoRecorder,
1410
+ VirtualCursor,
1411
+ ZoomController,
1412
+ createScenecap,
1413
+ parseInstruction,
1414
+ parseNaturalLanguageScenario,
1415
+ resolveElement,
1416
+ scrollIntoView,
1417
+ simulateClick,
1418
+ simulateType,
1419
+ useScenecap
1420
+ };