@dynamicu/chromedebug-mcp 2.7.2 → 2.7.4

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.
@@ -0,0 +1,917 @@
1
+ /**
2
+ * VideoExporter - Export frame recordings as MP4 videos
3
+ * Uses WebCodecs API with mp4-muxer for browser-side encoding
4
+ *
5
+ * Enhanced features:
6
+ * - Intro/outro frames with custom text
7
+ * - Branding watermarks
8
+ * - Style presets (minimal, detailed, debug)
9
+ * - Custom background colors
10
+ */
11
+ class VideoExporter {
12
+ constructor(options = {}) {
13
+ this.quality = options.quality || 'medium'; // low, medium, high
14
+ this.fps = options.fps || 10;
15
+ this.includeLogs = options.includeLogs !== false;
16
+ this.includeClicks = options.includeClicks !== false;
17
+ this.includeCursor = options.includeCursor !== false;
18
+ this.logPosition = options.logPosition || 'bottom';
19
+ this.logFilter = options.logFilter || 'all'; // all, errors, warnings, info
20
+
21
+ // Enhanced options - Intro/Outro
22
+ this.introText = options.introText || '';
23
+ this.outroText = options.outroText || '';
24
+ this.introDuration = options.introDuration || 2; // seconds
25
+ this.outroDuration = options.outroDuration || 2; // seconds
26
+
27
+ // Watermark options
28
+ this.watermarkText = options.watermarkText || '';
29
+ this.watermarkPosition = options.watermarkPosition || 'bottom-right'; // top-left, top-right, bottom-left, bottom-right
30
+ this.watermarkOpacity = options.watermarkOpacity || 0.5;
31
+
32
+ // Style preset
33
+ this.stylePreset = options.stylePreset || 'detailed'; // minimal, detailed, debug
34
+
35
+ // Background color for overlays
36
+ this.backgroundColor = options.backgroundColor || 'rgba(0, 0, 0, 0.75)';
37
+
38
+ this.onProgress = options.onProgress || (() => {});
39
+ this.onComplete = options.onComplete || (() => {});
40
+ this.onError = options.onError || (() => {});
41
+
42
+ this.isExporting = false;
43
+ this.cancelled = false;
44
+
45
+ // Style preset configurations
46
+ this.styleConfigs = {
47
+ minimal: {
48
+ showTimestamp: false,
49
+ showFrameNumber: false,
50
+ maxLogs: 3,
51
+ logFontSize: 11,
52
+ logPanelOpacity: 0.6,
53
+ cursorStyle: 'simple'
54
+ },
55
+ detailed: {
56
+ showTimestamp: true,
57
+ showFrameNumber: true,
58
+ maxLogs: 5,
59
+ logFontSize: 12,
60
+ logPanelOpacity: 0.75,
61
+ cursorStyle: 'detailed'
62
+ },
63
+ debug: {
64
+ showTimestamp: true,
65
+ showFrameNumber: true,
66
+ maxLogs: 8,
67
+ logFontSize: 11,
68
+ logPanelOpacity: 0.85,
69
+ cursorStyle: 'debug',
70
+ showCoordinates: true
71
+ }
72
+ };
73
+ }
74
+
75
+ // Get current style config
76
+ getStyleConfig() {
77
+ return this.styleConfigs[this.stylePreset] || this.styleConfigs.detailed;
78
+ }
79
+
80
+ // Get bitrate based on quality setting
81
+ getBitrate() {
82
+ const bitrates = {
83
+ low: 1_000_000, // 1 Mbps
84
+ medium: 3_000_000, // 3 Mbps
85
+ high: 5_000_000 // 5 Mbps
86
+ };
87
+ return bitrates[this.quality] || bitrates.medium;
88
+ }
89
+
90
+ // Check WebCodecs support
91
+ static isSupported() {
92
+ return typeof VideoEncoder !== 'undefined' &&
93
+ typeof VideoFrame !== 'undefined';
94
+ }
95
+
96
+ // Main export function
97
+ async exportVideo(frames, interactions, sessionId) {
98
+ // DEBUG: Log export entry point
99
+ console.log('[VideoExporter] exportVideo called:', {
100
+ framesCount: frames?.length || 0,
101
+ hasInteractions: !!interactions,
102
+ interactionsCount: interactions?.length || 0,
103
+ sessionId
104
+ });
105
+
106
+ if (!VideoExporter.isSupported()) {
107
+ this.onError(new Error('WebCodecs API not supported in this browser'));
108
+ return null;
109
+ }
110
+
111
+ if (this.isExporting) {
112
+ this.onError(new Error('Export already in progress'));
113
+ return null;
114
+ }
115
+
116
+ this.isExporting = true;
117
+ this.cancelled = false;
118
+
119
+ try {
120
+ // Dynamically import mp4-muxer (loaded via script tag)
121
+ if (typeof Mp4Muxer === 'undefined') {
122
+ throw new Error('Mp4Muxer not loaded. Please include mp4-muxer library.');
123
+ }
124
+
125
+ // Filter out synthetic frames (1x1 placeholders used for early log capture)
126
+ // These frames have isSynthetic: true and contain placeholder images that can't be exported
127
+ const exportableFrames = frames.filter(f => !f.isSynthetic && f.imageData && f.imageData.length > 200);
128
+
129
+ console.log('[VideoExporter] Frame filtering:', {
130
+ originalCount: frames.length,
131
+ exportableCount: exportableFrames.length,
132
+ filteredOut: frames.length - exportableFrames.length
133
+ });
134
+
135
+ if (exportableFrames.length === 0) {
136
+ throw new Error('No exportable frames found. All frames are either synthetic placeholders or missing image data.');
137
+ }
138
+
139
+ const firstFrame = exportableFrames[0];
140
+
141
+ // DEBUG: Log first frame structure
142
+ console.log('[VideoExporter] First exportable frame analysis:', {
143
+ hasFirstFrame: !!firstFrame,
144
+ firstFrameKeys: firstFrame ? Object.keys(firstFrame) : [],
145
+ hasImageData: !!firstFrame?.imageData,
146
+ imageDataType: typeof firstFrame?.imageData,
147
+ imageDataLength: firstFrame?.imageData?.length || 0,
148
+ imageDataPrefix: firstFrame?.imageData?.substring?.(0, 50) || 'N/A',
149
+ timestamp: firstFrame?.timestamp,
150
+ isSynthetic: firstFrame?.isSynthetic
151
+ });
152
+
153
+ if (!firstFrame || !firstFrame.imageData) {
154
+ throw new Error('No valid frames to export');
155
+ }
156
+
157
+ // Pre-validate image data
158
+ if (typeof firstFrame.imageData !== 'string' || firstFrame.imageData.length < 200) {
159
+ throw new Error(`Invalid image data for first frame. Data length: ${firstFrame.imageData?.length || 0}. Frame may be a placeholder.`);
160
+ }
161
+
162
+ // Get dimensions from first frame
163
+ const img = await this.loadImage(firstFrame.imageData);
164
+
165
+ // Validate that dimensions are available using naturalWidth/naturalHeight
166
+ // These represent the true intrinsic image dimensions
167
+ if (!img.naturalWidth || !img.naturalHeight) {
168
+ console.error('[VideoExporter] Image dimensions invalid:', {
169
+ naturalWidth: img.naturalWidth,
170
+ naturalHeight: img.naturalHeight,
171
+ width: img.width,
172
+ height: img.height,
173
+ complete: img.complete,
174
+ imageDataLength: firstFrame.imageData.length,
175
+ imageDataStart: firstFrame.imageData.substring(0, 50)
176
+ });
177
+ throw new Error(`Invalid image dimensions: ${img.naturalWidth}x${img.naturalHeight}. The screenshot data may be corrupted.`);
178
+ }
179
+
180
+ const width = Math.floor(img.naturalWidth / 2) * 2; // Ensure even dimensions
181
+ const height = Math.floor(img.naturalHeight / 2) * 2;
182
+
183
+ // Final validation after rounding
184
+ if (width === 0 || height === 0) {
185
+ throw new Error(`Invalid video width: ${width}. Must be a positive integer.`);
186
+ }
187
+
188
+ // Create export canvas
189
+ const canvas = document.createElement('canvas');
190
+ canvas.width = width;
191
+ canvas.height = height;
192
+ const ctx = canvas.getContext('2d');
193
+
194
+ // Create muxer
195
+ const muxer = new Mp4Muxer.Muxer({
196
+ target: new Mp4Muxer.ArrayBufferTarget(),
197
+ video: {
198
+ codec: 'avc',
199
+ width: width,
200
+ height: height
201
+ },
202
+ fastStart: 'in-memory'
203
+ });
204
+
205
+ // Create video encoder
206
+ const encoder = new VideoEncoder({
207
+ output: (chunk, meta) => {
208
+ muxer.addVideoChunk(chunk, meta);
209
+ },
210
+ error: (e) => {
211
+ console.error('VideoEncoder error:', e);
212
+ this.onError(e);
213
+ }
214
+ });
215
+
216
+ await encoder.configure({
217
+ codec: 'avc1.42001f', // H.264 baseline
218
+ width: width,
219
+ height: height,
220
+ bitrate: this.getBitrate(),
221
+ framerate: this.fps
222
+ });
223
+
224
+ const frameDuration = 1_000_000 / this.fps; // microseconds per frame
225
+ let currentTimestamp = 0;
226
+ let frameCounter = 0;
227
+
228
+ // Calculate intro/outro frames
229
+ const introFrameCount = this.introText ? Math.ceil(this.introDuration * this.fps) : 0;
230
+ const outroFrameCount = this.outroText ? Math.ceil(this.outroDuration * this.fps) : 0;
231
+ const totalFrameCount = introFrameCount + exportableFrames.length + outroFrameCount;
232
+
233
+ // Render intro frames
234
+ if (introFrameCount > 0) {
235
+ for (let i = 0; i < introFrameCount; i++) {
236
+ if (this.cancelled) {
237
+ encoder.close();
238
+ throw new Error('Export cancelled');
239
+ }
240
+
241
+ this.renderIntroFrame(ctx, width, height, this.introText, sessionId, exportableFrames.length);
242
+
243
+ const videoFrame = new VideoFrame(canvas, {
244
+ timestamp: currentTimestamp,
245
+ duration: frameDuration
246
+ });
247
+
248
+ encoder.encode(videoFrame, { keyFrame: frameCounter % 30 === 0 });
249
+ videoFrame.close();
250
+
251
+ currentTimestamp += frameDuration;
252
+ frameCounter++;
253
+
254
+ this.onProgress((frameCounter / totalFrameCount) * 100, frameCounter, totalFrameCount);
255
+ }
256
+ }
257
+
258
+ // Process main content frames (using filtered exportableFrames)
259
+ for (let i = 0; i < exportableFrames.length; i++) {
260
+ if (this.cancelled) {
261
+ encoder.close();
262
+ throw new Error('Export cancelled');
263
+ }
264
+
265
+ const frame = exportableFrames[i];
266
+
267
+ // Render frame to canvas with overlays
268
+ await this.renderFrameToCanvas(ctx, frame, interactions, width, height, i, exportableFrames.length);
269
+
270
+ // Create VideoFrame from canvas
271
+ const videoFrame = new VideoFrame(canvas, {
272
+ timestamp: currentTimestamp,
273
+ duration: frameDuration
274
+ });
275
+
276
+ // Encode frame
277
+ encoder.encode(videoFrame, { keyFrame: frameCounter % 30 === 0 });
278
+ videoFrame.close();
279
+
280
+ currentTimestamp += frameDuration;
281
+ frameCounter++;
282
+
283
+ // Report progress
284
+ this.onProgress((frameCounter / totalFrameCount) * 100, frameCounter, totalFrameCount);
285
+ }
286
+
287
+ // Render outro frames
288
+ if (outroFrameCount > 0) {
289
+ for (let i = 0; i < outroFrameCount; i++) {
290
+ if (this.cancelled) {
291
+ encoder.close();
292
+ throw new Error('Export cancelled');
293
+ }
294
+
295
+ this.renderOutroFrame(ctx, width, height, this.outroText);
296
+
297
+ const videoFrame = new VideoFrame(canvas, {
298
+ timestamp: currentTimestamp,
299
+ duration: frameDuration
300
+ });
301
+
302
+ encoder.encode(videoFrame, { keyFrame: frameCounter % 30 === 0 });
303
+ videoFrame.close();
304
+
305
+ currentTimestamp += frameDuration;
306
+ frameCounter++;
307
+
308
+ this.onProgress((frameCounter / totalFrameCount) * 100, frameCounter, totalFrameCount);
309
+ }
310
+ }
311
+
312
+ // Finalize encoding
313
+ await encoder.flush();
314
+ encoder.close();
315
+ muxer.finalize();
316
+
317
+ // Get the video data
318
+ const videoBuffer = muxer.target.buffer;
319
+ const blob = new Blob([videoBuffer], { type: 'video/mp4' });
320
+
321
+ // Generate filename
322
+ const filename = `recording_${sessionId}_${Date.now()}.mp4`;
323
+
324
+ this.isExporting = false;
325
+ this.onComplete(blob, filename);
326
+
327
+ return { blob, filename };
328
+
329
+ } catch (error) {
330
+ this.isExporting = false;
331
+ this.onError(error);
332
+ return null;
333
+ }
334
+ }
335
+
336
+ // Render intro frame with title and metadata
337
+ renderIntroFrame(ctx, width, height, text, sessionId, frameCount) {
338
+ // Background gradient
339
+ const gradient = ctx.createLinearGradient(0, 0, 0, height);
340
+ gradient.addColorStop(0, '#1a1a2e');
341
+ gradient.addColorStop(1, '#16213e');
342
+ ctx.fillStyle = gradient;
343
+ ctx.fillRect(0, 0, width, height);
344
+
345
+ // Draw decorative border
346
+ ctx.strokeStyle = '#4A90E2';
347
+ ctx.lineWidth = 4;
348
+ ctx.strokeRect(20, 20, width - 40, height - 40);
349
+
350
+ // Title text
351
+ ctx.fillStyle = '#ffffff';
352
+ ctx.textAlign = 'center';
353
+ ctx.textBaseline = 'middle';
354
+
355
+ // Main title
356
+ ctx.font = 'bold 36px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
357
+ const lines = this.wrapText(ctx, text, width - 80);
358
+ const lineHeight = 44;
359
+ const titleY = height / 2 - (lines.length * lineHeight) / 2;
360
+
361
+ lines.forEach((line, i) => {
362
+ ctx.fillText(line, width / 2, titleY + i * lineHeight);
363
+ });
364
+
365
+ // Session info subtitle
366
+ ctx.fillStyle = '#888888';
367
+ ctx.font = '16px monospace';
368
+ ctx.fillText(`Session: ${sessionId}`, width / 2, height - 80);
369
+ ctx.fillText(`${frameCount} frames`, width / 2, height - 55);
370
+
371
+ // Chrome Debug branding
372
+ ctx.fillStyle = '#4A90E2';
373
+ ctx.font = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
374
+ ctx.fillText('Chrome Debug', width / 2, height - 30);
375
+ }
376
+
377
+ // Render outro frame
378
+ renderOutroFrame(ctx, width, height, text) {
379
+ // Background gradient
380
+ const gradient = ctx.createLinearGradient(0, 0, 0, height);
381
+ gradient.addColorStop(0, '#16213e');
382
+ gradient.addColorStop(1, '#1a1a2e');
383
+ ctx.fillStyle = gradient;
384
+ ctx.fillRect(0, 0, width, height);
385
+
386
+ // Draw decorative border
387
+ ctx.strokeStyle = '#4A90E2';
388
+ ctx.lineWidth = 4;
389
+ ctx.strokeRect(20, 20, width - 40, height - 40);
390
+
391
+ // Outro text
392
+ ctx.fillStyle = '#ffffff';
393
+ ctx.textAlign = 'center';
394
+ ctx.textBaseline = 'middle';
395
+
396
+ ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
397
+ const lines = this.wrapText(ctx, text, width - 80);
398
+ const lineHeight = 36;
399
+ const textY = height / 2 - (lines.length * lineHeight) / 2;
400
+
401
+ lines.forEach((line, i) => {
402
+ ctx.fillText(line, width / 2, textY + i * lineHeight);
403
+ });
404
+
405
+ // Chrome Debug branding
406
+ ctx.fillStyle = '#4A90E2';
407
+ ctx.font = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
408
+ ctx.fillText('Chrome Debug', width / 2, height - 30);
409
+ }
410
+
411
+ // Helper to wrap text into lines
412
+ wrapText(ctx, text, maxWidth) {
413
+ const words = text.split(' ');
414
+ const lines = [];
415
+ let currentLine = '';
416
+
417
+ for (const word of words) {
418
+ const testLine = currentLine ? currentLine + ' ' + word : word;
419
+ const metrics = ctx.measureText(testLine);
420
+
421
+ if (metrics.width > maxWidth && currentLine) {
422
+ lines.push(currentLine);
423
+ currentLine = word;
424
+ } else {
425
+ currentLine = testLine;
426
+ }
427
+ }
428
+
429
+ if (currentLine) {
430
+ lines.push(currentLine);
431
+ }
432
+
433
+ return lines;
434
+ }
435
+
436
+ // Render a single frame to the export canvas with overlays
437
+ async renderFrameToCanvas(ctx, frame, interactions, width, height, frameIndex = 0, totalFrames = 1) {
438
+ const styleConfig = this.getStyleConfig();
439
+
440
+ // Apply watermark for FREE version at EXPORT time (not storage time)
441
+ let imageDataToUse = frame.imageData;
442
+ const imageProcessor = window.ChromeDebugImageProcessor;
443
+ if (imageProcessor && imageProcessor.shouldWatermark()) {
444
+ try {
445
+ imageDataToUse = await imageProcessor.processImageForExport(frame.imageData);
446
+ } catch (err) {
447
+ console.warn('[VideoExporter] Failed to apply watermark:', err);
448
+ // Continue with original image if watermarking fails
449
+ }
450
+ }
451
+
452
+ // Load and draw frame image
453
+ const img = await this.loadImage(imageDataToUse);
454
+ ctx.clearRect(0, 0, width, height);
455
+ ctx.drawImage(img, 0, 0, width, height);
456
+
457
+ const timestamp = frame.timestamp;
458
+
459
+ // Use viewport dimensions from interactions for accurate coordinate scaling
460
+ // This is crucial because clicks/mouse moves are recorded in viewport coordinates
461
+ const interactionWithViewport = interactions.find(i =>
462
+ (i.type === 'mousemove' || i.type === 'click') && i.viewportWidth && i.viewportHeight
463
+ );
464
+ // Default reference dimensions (used for mouse cursor which interpolates between positions)
465
+ const defaultRefWidth = interactionWithViewport ? interactionWithViewport.viewportWidth : img.naturalWidth;
466
+ const defaultRefHeight = interactionWithViewport ? interactionWithViewport.viewportHeight : img.naturalHeight;
467
+
468
+ const defaultScaleX = width / defaultRefWidth;
469
+ const defaultScaleY = height / defaultRefHeight;
470
+
471
+ // Draw click indicators using per-click viewport dimensions for accurate scaling
472
+ if (this.includeClicks) {
473
+ const clicks = interactions.filter(i =>
474
+ i.type === 'click' && i.x && i.y &&
475
+ Math.abs(i.timestamp - timestamp) <= 500
476
+ );
477
+ clicks.forEach(click => {
478
+ const age = Math.abs(timestamp - click.timestamp);
479
+ // Use per-click viewport dimensions if available, fall back to defaults
480
+ const clickRefWidth = click.viewportWidth || defaultRefWidth;
481
+ const clickRefHeight = click.viewportHeight || defaultRefHeight;
482
+ const clickScaleX = width / clickRefWidth;
483
+ const clickScaleY = height / clickRefHeight;
484
+ this.drawClickRipple(ctx, click.x * clickScaleX, click.y * clickScaleY, age);
485
+ });
486
+ }
487
+
488
+ // Draw mouse cursor (uses default scale since cursor interpolates between positions)
489
+ if (this.includeCursor) {
490
+ const mousePos = this.getMousePositionAtTimestamp(interactions, timestamp);
491
+ if (mousePos) {
492
+ this.drawMouseCursor(ctx, mousePos.x * defaultScaleX, mousePos.y * defaultScaleY, styleConfig);
493
+ }
494
+ }
495
+
496
+ // Draw console logs
497
+ if (this.includeLogs && frame.logs && frame.logs.length > 0) {
498
+ this.drawLogsOnCanvas(ctx, frame.logs, width, height, styleConfig);
499
+ }
500
+
501
+ // Draw timestamp/frame overlay based on style preset
502
+ if (styleConfig.showTimestamp || styleConfig.showFrameNumber) {
503
+ this.drawFrameInfo(ctx, frame, frameIndex, totalFrames, width, height, styleConfig);
504
+ }
505
+
506
+ // Draw watermark if set
507
+ if (this.watermarkText) {
508
+ this.drawWatermark(ctx, width, height);
509
+ }
510
+ }
511
+
512
+ // Draw frame information overlay (timestamp, frame number)
513
+ drawFrameInfo(ctx, frame, frameIndex, totalFrames, canvasWidth, canvasHeight, styleConfig) {
514
+ ctx.save();
515
+
516
+ const padding = 8;
517
+ const fontSize = 12;
518
+ ctx.font = `${fontSize}px monospace`;
519
+
520
+ let infoText = '';
521
+ if (styleConfig.showFrameNumber) {
522
+ infoText = `Frame ${frameIndex + 1}/${totalFrames}`;
523
+ }
524
+ if (styleConfig.showTimestamp) {
525
+ const timeStr = (frame.timestamp / 1000).toFixed(2) + 's';
526
+ infoText = infoText ? `${infoText} | ${timeStr}` : timeStr;
527
+ }
528
+
529
+ const textMetrics = ctx.measureText(infoText);
530
+ const boxWidth = textMetrics.width + padding * 2;
531
+ const boxHeight = fontSize + padding * 2;
532
+
533
+ // Position in top-left corner
534
+ const x = 10;
535
+ const y = 10;
536
+
537
+ // Background
538
+ ctx.fillStyle = this.backgroundColor;
539
+ ctx.fillRect(x, y, boxWidth, boxHeight);
540
+
541
+ // Text
542
+ ctx.fillStyle = '#ffffff';
543
+ ctx.textAlign = 'left';
544
+ ctx.textBaseline = 'middle';
545
+ ctx.fillText(infoText, x + padding, y + boxHeight / 2);
546
+
547
+ ctx.restore();
548
+ }
549
+
550
+ // Draw watermark
551
+ drawWatermark(ctx, canvasWidth, canvasHeight) {
552
+ ctx.save();
553
+
554
+ const padding = 15;
555
+ ctx.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
556
+ ctx.globalAlpha = this.watermarkOpacity;
557
+ ctx.fillStyle = '#ffffff';
558
+
559
+ const textMetrics = ctx.measureText(this.watermarkText);
560
+ let x, y;
561
+
562
+ switch (this.watermarkPosition) {
563
+ case 'top-left':
564
+ ctx.textAlign = 'left';
565
+ ctx.textBaseline = 'top';
566
+ x = padding;
567
+ y = padding;
568
+ break;
569
+ case 'top-right':
570
+ ctx.textAlign = 'right';
571
+ ctx.textBaseline = 'top';
572
+ x = canvasWidth - padding;
573
+ y = padding;
574
+ break;
575
+ case 'bottom-left':
576
+ ctx.textAlign = 'left';
577
+ ctx.textBaseline = 'bottom';
578
+ x = padding;
579
+ y = canvasHeight - padding;
580
+ break;
581
+ case 'bottom-right':
582
+ default:
583
+ ctx.textAlign = 'right';
584
+ ctx.textBaseline = 'bottom';
585
+ x = canvasWidth - padding;
586
+ y = canvasHeight - padding;
587
+ break;
588
+ }
589
+
590
+ // Optional: draw semi-transparent background behind watermark
591
+ const bgPadding = 4;
592
+ ctx.globalAlpha = this.watermarkOpacity * 0.3;
593
+ ctx.fillStyle = '#000000';
594
+
595
+ let bgX = x - bgPadding;
596
+ let bgWidth = textMetrics.width + bgPadding * 2;
597
+ if (this.watermarkPosition.includes('right')) {
598
+ bgX = x - textMetrics.width - bgPadding;
599
+ }
600
+
601
+ let bgY = y - bgPadding;
602
+ if (this.watermarkPosition.includes('bottom')) {
603
+ bgY = y - 18;
604
+ }
605
+
606
+ ctx.fillRect(bgX, bgY, bgWidth, 22);
607
+
608
+ // Draw text
609
+ ctx.globalAlpha = this.watermarkOpacity;
610
+ ctx.fillStyle = '#ffffff';
611
+ ctx.fillText(this.watermarkText, x, y);
612
+
613
+ ctx.restore();
614
+ }
615
+
616
+ // Draw click ripple on export canvas
617
+ drawClickRipple(ctx, x, y, age, maxAge = 500) {
618
+ if (age > maxAge) return;
619
+
620
+ const progress = age / maxAge;
621
+ const radius = 10 + (30 * progress);
622
+ const alpha = 1 - progress;
623
+
624
+ ctx.beginPath();
625
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
626
+ ctx.strokeStyle = `rgba(255, 87, 34, ${alpha})`;
627
+ ctx.lineWidth = 3;
628
+ ctx.stroke();
629
+
630
+ ctx.beginPath();
631
+ ctx.arc(x, y, 5, 0, Math.PI * 2);
632
+ ctx.fillStyle = `rgba(255, 87, 34, ${alpha * 0.8})`;
633
+ ctx.fill();
634
+ }
635
+
636
+ // Draw mouse cursor on export canvas
637
+ drawMouseCursor(ctx, x, y, styleConfig = {}) {
638
+ ctx.save();
639
+ ctx.translate(x, y);
640
+
641
+ const cursorStyle = styleConfig.cursorStyle || 'detailed';
642
+
643
+ if (cursorStyle === 'simple') {
644
+ // Simple dot cursor
645
+ ctx.beginPath();
646
+ ctx.arc(0, 0, 6, 0, Math.PI * 2);
647
+ ctx.fillStyle = 'rgba(74, 144, 226, 0.8)';
648
+ ctx.fill();
649
+ ctx.strokeStyle = 'white';
650
+ ctx.lineWidth = 2;
651
+ ctx.stroke();
652
+ } else if (cursorStyle === 'debug') {
653
+ // Debug cursor with coordinates
654
+ ctx.fillStyle = 'white';
655
+ ctx.strokeStyle = 'black';
656
+ ctx.lineWidth = 1;
657
+
658
+ // Arrow cursor
659
+ ctx.beginPath();
660
+ ctx.moveTo(0, 0);
661
+ ctx.lineTo(0, 18);
662
+ ctx.lineTo(4, 14);
663
+ ctx.lineTo(8, 22);
664
+ ctx.lineTo(11, 21);
665
+ ctx.lineTo(7, 13);
666
+ ctx.lineTo(13, 13);
667
+ ctx.closePath();
668
+ ctx.fill();
669
+ ctx.stroke();
670
+
671
+ // Coordinate display (if showCoordinates enabled)
672
+ if (styleConfig.showCoordinates) {
673
+ ctx.font = '10px monospace';
674
+ ctx.fillStyle = 'rgba(0,0,0,0.7)';
675
+ ctx.fillRect(15, 5, 70, 16);
676
+ ctx.fillStyle = '#00ff00';
677
+ ctx.textAlign = 'left';
678
+ ctx.textBaseline = 'middle';
679
+ ctx.fillText(`${Math.round(x)},${Math.round(y)}`, 18, 13);
680
+ }
681
+ } else {
682
+ // Detailed cursor (default)
683
+ ctx.fillStyle = 'white';
684
+ ctx.strokeStyle = 'black';
685
+ ctx.lineWidth = 1;
686
+
687
+ ctx.beginPath();
688
+ ctx.moveTo(0, 0);
689
+ ctx.lineTo(0, 18);
690
+ ctx.lineTo(4, 14);
691
+ ctx.lineTo(8, 22);
692
+ ctx.lineTo(11, 21);
693
+ ctx.lineTo(7, 13);
694
+ ctx.lineTo(13, 13);
695
+ ctx.closePath();
696
+
697
+ ctx.fill();
698
+ ctx.stroke();
699
+ }
700
+
701
+ ctx.restore();
702
+ }
703
+
704
+ // Draw logs onto canvas for export
705
+ drawLogsOnCanvas(ctx, logs, canvasWidth, canvasHeight, styleConfig = {}) {
706
+ const maxLogs = styleConfig.maxLogs || 5;
707
+ const fontSize = styleConfig.logFontSize || 12;
708
+ const panelOpacity = styleConfig.logPanelOpacity || 0.75;
709
+
710
+ // Filter logs by level based on logFilter setting
711
+ let filteredLogs = logs;
712
+ if (this.logFilter !== 'all') {
713
+ const levelPriority = { error: 1, warn: 2, info: 3, log: 4, debug: 5 };
714
+ const filterThresholds = {
715
+ errors: 1, // Only errors
716
+ warnings: 2, // Errors and warnings
717
+ info: 3 // Errors, warnings, and info
718
+ };
719
+ const threshold = filterThresholds[this.logFilter] || 5;
720
+ filteredLogs = logs.filter(log => {
721
+ const level = (log.level || 'log').toLowerCase();
722
+ return (levelPriority[level] || 4) <= threshold;
723
+ });
724
+ }
725
+
726
+ // Don't draw panel if no logs pass the filter
727
+ if (filteredLogs.length === 0) return;
728
+
729
+ const logsToShow = filteredLogs.slice(-maxLogs);
730
+ const padding = 10;
731
+ const lineHeight = fontSize + 4;
732
+ const panelHeight = logsToShow.length * lineHeight + padding * 2;
733
+
734
+ // Draw semi-transparent background
735
+ let panelY;
736
+ if (this.logPosition === 'top') {
737
+ panelY = 0;
738
+ } else {
739
+ panelY = canvasHeight - panelHeight;
740
+ }
741
+
742
+ // Use configured background color with opacity
743
+ const bgColor = this.backgroundColor.replace(/[\d.]+\)$/, `${panelOpacity})`);
744
+ ctx.fillStyle = bgColor;
745
+ ctx.fillRect(0, panelY, canvasWidth, panelHeight);
746
+
747
+ // Draw log text
748
+ ctx.font = `${fontSize}px monospace`;
749
+
750
+ logsToShow.forEach((log, i) => {
751
+ const y = panelY + padding + (i * lineHeight) + fontSize;
752
+ const level = (log.level || 'log').toLowerCase();
753
+
754
+ // Set color based on level
755
+ const colors = {
756
+ error: '#ef5350',
757
+ warn: '#ffb74d',
758
+ info: '#64b5f6',
759
+ log: '#888888',
760
+ debug: '#9575cd'
761
+ };
762
+ ctx.fillStyle = colors[level] || colors.log;
763
+
764
+ const time = log.relativeTime ? `[${(log.relativeTime / 1000).toFixed(2)}s]` : '';
765
+ const text = `${time} ${level.toUpperCase()}: ${log.message || ''}`;
766
+
767
+ // Truncate if too long - estimate character width based on font size
768
+ const charWidth = fontSize * 0.6;
769
+ const maxChars = Math.floor((canvasWidth - padding * 2) / charWidth);
770
+ const displayText = text.length > maxChars ? text.substring(0, maxChars - 3) + '...' : text;
771
+
772
+ ctx.fillText(displayText, padding, y);
773
+ });
774
+ }
775
+
776
+ // Get interpolated mouse position
777
+ getMousePositionAtTimestamp(interactions, timestamp) {
778
+ const mouseMoves = interactions.filter(i => i.type === 'mousemove');
779
+ if (mouseMoves.length === 0) return null;
780
+
781
+ let before = null;
782
+ let after = null;
783
+
784
+ for (const move of mouseMoves) {
785
+ if (move.timestamp <= timestamp) {
786
+ before = move;
787
+ } else if (!after && move.timestamp > timestamp) {
788
+ after = move;
789
+ break;
790
+ }
791
+ }
792
+
793
+ if (!before) return after ? { x: after.x, y: after.y } : null;
794
+ if (!after) return { x: before.x, y: before.y };
795
+
796
+ const t = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
797
+ return {
798
+ x: before.x + (after.x - before.x) * t,
799
+ y: before.y + (after.y - before.y) * t
800
+ };
801
+ }
802
+
803
+ // Load image from base64 with robust dimension handling
804
+ // Uses img.decode() for modern browsers to ensure image is fully decoded
805
+ // Falls back to polling for older browsers
806
+ loadImage(imageData) {
807
+ // DEBUG: Log loadImage call
808
+ console.log('[VideoExporter] loadImage called:', {
809
+ imageDataType: typeof imageData,
810
+ imageDataLength: imageData?.length || 0,
811
+ startsWithData: imageData?.startsWith?.('data:') || false,
812
+ prefix: imageData?.substring?.(0, 80) || 'N/A'
813
+ });
814
+
815
+ return new Promise((resolve, reject) => {
816
+ const img = new Image();
817
+
818
+ // Use naturalWidth/naturalHeight for true intrinsic dimensions
819
+ // These are unaffected by CSS or explicit attributes
820
+ const validateDimensions = () => {
821
+ return img.naturalWidth > 0 && img.naturalHeight > 0;
822
+ };
823
+
824
+ img.onload = async () => {
825
+ // DEBUG: Log onload event
826
+ console.log('[VideoExporter] img.onload fired:', {
827
+ width: img.width,
828
+ height: img.height,
829
+ naturalWidth: img.naturalWidth,
830
+ naturalHeight: img.naturalHeight,
831
+ complete: img.complete
832
+ });
833
+
834
+ // Modern browsers: use decode() to ensure image is fully ready
835
+ // decode() waits until the image is fully decoded and ready to render
836
+ if (typeof img.decode === 'function') {
837
+ try {
838
+ await img.decode();
839
+ console.log('[VideoExporter] decode() completed:', {
840
+ naturalWidth: img.naturalWidth,
841
+ naturalHeight: img.naturalHeight
842
+ });
843
+ if (validateDimensions()) {
844
+ resolve(img);
845
+ return;
846
+ }
847
+ // decode() succeeded but dimensions still zero - very unusual
848
+ // Fall through to polling as last resort
849
+ console.warn('[VideoExporter] decode() succeeded but dimensions still zero');
850
+ } catch (decodeError) {
851
+ // Decode failed - could be corrupt image data
852
+ // Fall through to polling, but this likely won't help
853
+ console.warn('[VideoExporter] Image decode() failed:', decodeError.message);
854
+ }
855
+ }
856
+
857
+ // Fallback: poll for dimensions with timeout
858
+ // This handles older browsers or unusual decode timing issues
859
+ const maxAttempts = 10;
860
+ const pollInterval = 50; // ms (total max wait: 500ms)
861
+ let attempts = 0;
862
+
863
+ const checkDimensions = () => {
864
+ attempts++;
865
+ if (validateDimensions()) {
866
+ resolve(img);
867
+ } else if (attempts >= maxAttempts) {
868
+ // Include diagnostic info for debugging
869
+ const dataPreview = imageData.substring(0, 100);
870
+ reject(new Error(
871
+ `Image dimensions not available after ${maxAttempts * pollInterval}ms. ` +
872
+ `naturalWidth=${img.naturalWidth}, naturalHeight=${img.naturalHeight}, ` +
873
+ `complete=${img.complete}, data starts with: ${dataPreview}...`
874
+ ));
875
+ } else {
876
+ setTimeout(checkDimensions, pollInterval);
877
+ }
878
+ };
879
+
880
+ // Start polling after a brief delay to avoid immediate re-check
881
+ setTimeout(checkDimensions, pollInterval);
882
+ };
883
+
884
+ img.onerror = (e) => {
885
+ const dataPreview = imageData.substring(0, 100);
886
+ reject(new Error(`Failed to load image. Data starts with: ${dataPreview}...`));
887
+ };
888
+
889
+ // Ensure proper data URL format
890
+ img.src = imageData.startsWith('data:')
891
+ ? imageData
892
+ : `data:image/jpeg;base64,${imageData}`;
893
+ });
894
+ }
895
+
896
+ // Cancel ongoing export
897
+ cancel() {
898
+ this.cancelled = true;
899
+ }
900
+
901
+ // Download blob as file
902
+ static downloadBlob(blob, filename) {
903
+ const url = URL.createObjectURL(blob);
904
+ const a = document.createElement('a');
905
+ a.href = url;
906
+ a.download = filename;
907
+ document.body.appendChild(a);
908
+ a.click();
909
+ document.body.removeChild(a);
910
+ URL.revokeObjectURL(url);
911
+ }
912
+ }
913
+
914
+ // Export for use
915
+ if (typeof module !== 'undefined' && module.exports) {
916
+ module.exports = VideoExporter;
917
+ }