@dynamicu/chromedebug-mcp 2.7.1 → 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.
- package/CLAUDE.md +18 -0
- package/README.md +226 -16
- package/chrome-extension/background.js +569 -64
- package/chrome-extension/browser-recording-manager.js +34 -0
- package/chrome-extension/content.js +438 -32
- package/chrome-extension/firebase-config.public-sw.js +1 -1
- package/chrome-extension/firebase-config.public.js +1 -1
- package/chrome-extension/frame-capture.js +31 -10
- package/chrome-extension/image-processor.js +193 -0
- package/chrome-extension/manifest.free.json +1 -1
- package/chrome-extension/options.html +2 -2
- package/chrome-extension/options.js +4 -4
- package/chrome-extension/popup.html +82 -4
- package/chrome-extension/popup.js +1106 -38
- package/chrome-extension/pro/frame-editor.html +259 -6
- package/chrome-extension/pro/frame-editor.js +959 -10
- package/chrome-extension/pro/video-exporter.js +917 -0
- package/chrome-extension/pro/video-player.js +545 -0
- package/dist/chromedebug-extension-free.zip +0 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +1 -1
- package/scripts/webpack.config.free.cjs +6 -0
- package/scripts/webpack.config.pro.cjs +6 -0
- package/src/chrome-controller.js +6 -6
- package/src/database.js +226 -39
- package/src/http-server.js +55 -11
- package/src/validation/schemas.js +20 -5
|
@@ -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
|
+
}
|