File size: 8,487 Bytes
94193b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import html2canvas from 'html2canvas';
import { logger } from '@/lib/utils';

/**
 * Waits for document resources (fonts, images, idle) to finish loading.
 * All resource promises race against a timeout to prevent indefinite blocking.
 * @param doc The document to wait on
 * @param minDelay Minimum delay in ms regardless of resource readiness (default: 2000)
 * @param timeout Maximum time to wait for resources in ms (default: 8000)
 */
export async function waitForResources(doc: Document, minDelay = 2000, timeout = 8000): Promise<void> {
  const win = doc.defaultView;

  const resourcePromises: Promise<unknown>[] = [
    // Minimum buffer delay
    new Promise(resolve => setTimeout(resolve, minDelay)),
  ];

  // Wait for fonts
  if (doc.fonts?.ready) {
    resourcePromises.push(doc.fonts.ready.catch(() => {}));
  }

  // Wait for all <img> elements to load
  const images = doc.querySelectorAll('img');
  images.forEach((img) => {
    if (!img.complete) {
      resourcePromises.push(
        new Promise<void>(resolve => {
          img.addEventListener('load', () => resolve(), { once: true });
          img.addEventListener('error', () => resolve(), { once: true });
        })
      );
    }
  });

  // Wait for idle callback (indicates browser has finished layout/paint work)
  if (win) {
    resourcePromises.push(
      new Promise<void>(resolve => {
        if ('requestIdleCallback' in win) {
          (win as Window & { requestIdleCallback: (cb: () => void, opts?: { timeout: number }) => void })
            .requestIdleCallback(() => resolve(), { timeout: 500 });
        } else {
          setTimeout(resolve, 500);
        }
      })
    );
  }

  // Race all resource promises against timeout
  await Promise.race([
    Promise.all(resourcePromises),
    new Promise(resolve => setTimeout(resolve, timeout)),
  ]);
}

/**
 * Internal function to attempt screenshot capture
 */
async function attemptCapture(
  iframeDoc: Document,
  captureWidth: number,
  captureHeight: number,
  fullPage: boolean
): Promise<HTMLCanvasElement> {
  // Determine capture height based on mode
  let effectiveHeight: number;

  if (fullPage) {
    // Get actual document height for full-page capture
    effectiveHeight = Math.max(
      iframeDoc.body.scrollHeight,
      iframeDoc.body.offsetHeight,
      iframeDoc.documentElement.clientHeight,
      iframeDoc.documentElement.scrollHeight,
      iframeDoc.documentElement.offsetHeight
    );
    logger.debug('[Screenshot] Full-page mode: document height =', effectiveHeight);
  } else {
    // Use viewport height for initial view capture
    effectiveHeight = captureHeight;
    logger.debug('[Screenshot] Viewport-only mode: using height =', effectiveHeight);
  }

  logger.debug('[Screenshot] Capture dimensions:', captureWidth, 'x', effectiveHeight);

  return Promise.race([
    html2canvas(iframeDoc.body, {
        width: captureWidth,
        height: effectiveHeight,
        scale: 1,
        useCORS: true,
        allowTaint: true,
        logging: false,
        windowWidth: captureWidth,
        windowHeight: effectiveHeight,
        scrollX: 0,
        scrollY: 0,
        imageTimeout: 3000,
        backgroundColor: '#ffffff',
        removeContainer: true,
        // Clean up problematic elements in the cloned document
        onclone: (clonedDoc) => {
          // Remove external stylesheets that cause CORS errors
          const externalLinks = clonedDoc.querySelectorAll('link[rel="stylesheet"]');
          externalLinks.forEach((link) => {
            const href = link.getAttribute('href');
            if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
              link.remove();
            }
          });

          // Remove ALL gradient backgrounds (not just ones with "gradient" in class name)
          // Tailwind gradients can cause "non-finite" errors in html2canvas
          const allElements = clonedDoc.querySelectorAll('*');

          // CRITICAL: Use cloned document's window for getComputedStyle, not parent window
          const clonedWindow = clonedDoc.defaultView;
          if (!clonedWindow) {
            return;
          }

          allElements.forEach((el: Element) => {
            const htmlEl = el as HTMLElement;
            // Read styles from CLONED document's context
            const computedStyle = clonedWindow.getComputedStyle(htmlEl);
            const bg = computedStyle.backgroundImage;

            // Check if element has a gradient background
            if (bg && (bg.includes('gradient') || bg.includes('linear-gradient') || bg.includes('radial-gradient'))) {
              // Replace gradient with solid color from gradient's first color if possible
              // or use a neutral fallback
              const bgColor = computedStyle.backgroundColor;
              htmlEl.style.backgroundImage = 'none';
              if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
                htmlEl.style.backgroundColor = bgColor;
              } else {
                htmlEl.style.backgroundColor = '#64748b'; // slate-500 as neutral fallback
              }
            }
          });
        }
      }),
    new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error('html2canvas timeout after 4 seconds')), 4000)
    )
  ]);
}

export async function captureIframeScreenshot(
  iframe: HTMLIFrameElement,
  captureWidth: number = 1280,
  captureHeight: number = 720,
  outputWidth: number = 640,
  outputHeight: number = 360,
  quality: number = 0.8,
  fullPage: boolean = true,
  waitForContent: boolean = false,
  minWaitDelay: number = 1500
): Promise<string | null> {
  try {
    // Get the iframe's document
    const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;

    if (!iframeDoc || !iframeDoc.body) {
      logger.warn('Cannot access iframe document');
      return null;
    }

    // Wait for resources if requested
    if (waitForContent) {
      try {
        await waitForResources(iframeDoc, minWaitDelay);
      } catch {
        // Fall back to simple delay if resource waiting fails
        await new Promise(resolve => setTimeout(resolve, minWaitDelay));
      }
    }

    // Attempt capture with automatic retry on gradient errors
    let canvas: HTMLCanvasElement;
    try {
      canvas = await attemptCapture(iframeDoc, captureWidth, captureHeight, fullPage);
    } catch (firstError) {
      // Check if this is a gradient-related error
      const errorMsg = String(firstError);
      if (errorMsg.includes('non-finite') || errorMsg.includes('addColorStop') || errorMsg.includes('CanvasGradient')) {
        // Wait a bit for styles to stabilize further
        await new Promise(resolve => setTimeout(resolve, 500));
        canvas = await attemptCapture(iframeDoc, captureWidth, captureHeight, fullPage);
      } else {
        // Not a gradient error, rethrow
        throw firstError;
      }
    }

    // Scale down the captured image maintaining aspect ratio
    const aspectRatio = canvas.height / canvas.width;
    const scaledHeight = Math.round(outputWidth * aspectRatio);

    const scaledCanvas = document.createElement('canvas');
    scaledCanvas.width = outputWidth;
    scaledCanvas.height = scaledHeight;
    const ctx = scaledCanvas.getContext('2d');

    if (!ctx) {
      logger.error('Failed to get canvas context');
      return null;
    }

    // Draw the captured image scaled down maintaining aspect ratio
    ctx.drawImage(canvas, 0, 0, outputWidth, scaledHeight);

    // Convert scaled canvas to base64 JPEG
    const dataUrl = scaledCanvas.toDataURL('image/jpeg', quality);

    // Validate size (max 250KB)
    const sizeInBytes = Math.ceil((dataUrl.length * 3) / 4);
    const sizeInKB = sizeInBytes / 1024;

    if (sizeInKB > 250) {
      logger.warn(`Screenshot too large: ${sizeInKB.toFixed(0)}KB, trying with lower quality`);
      // Retry with lower quality using scaled canvas
      const retryDataUrl = scaledCanvas.toDataURL('image/jpeg', 0.6);
      const retrySizeInKB = Math.ceil((retryDataUrl.length * 3) / 4) / 1024;

      if (retrySizeInKB > 250) {
        logger.warn(`Screenshot still too large: ${retrySizeInKB.toFixed(0)}KB`);
        return retryDataUrl; // Return anyway, let VFS handle size limit
      }

      return retryDataUrl;
    }

    return dataUrl;

  } catch (error) {
    logger.error('Failed to capture screenshot:', error);
    return null;
  }
}