| import html2canvas from 'html2canvas'; |
| import { logger } from '@/lib/utils'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function waitForResources(doc: Document, minDelay = 2000, timeout = 8000): Promise<void> { |
| const win = doc.defaultView; |
|
|
| const resourcePromises: Promise<unknown>[] = [ |
| |
| new Promise(resolve => setTimeout(resolve, minDelay)), |
| ]; |
|
|
| |
| if (doc.fonts?.ready) { |
| resourcePromises.push(doc.fonts.ready.catch(() => {})); |
| } |
|
|
| |
| 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 }); |
| }) |
| ); |
| } |
| }); |
|
|
| |
| 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); |
| } |
| }) |
| ); |
| } |
|
|
| |
| await Promise.race([ |
| Promise.all(resourcePromises), |
| new Promise(resolve => setTimeout(resolve, timeout)), |
| ]); |
| } |
|
|
| |
| |
| |
| async function attemptCapture( |
| iframeDoc: Document, |
| captureWidth: number, |
| captureHeight: number, |
| fullPage: boolean |
| ): Promise<HTMLCanvasElement> { |
| |
| let effectiveHeight: number; |
|
|
| if (fullPage) { |
| |
| 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 { |
| |
| 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, |
| |
| onclone: (clonedDoc) => { |
| |
| 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(); |
| } |
| }); |
|
|
| |
| |
| const allElements = clonedDoc.querySelectorAll('*'); |
|
|
| |
| const clonedWindow = clonedDoc.defaultView; |
| if (!clonedWindow) { |
| return; |
| } |
|
|
| allElements.forEach((el: Element) => { |
| const htmlEl = el as HTMLElement; |
| |
| const computedStyle = clonedWindow.getComputedStyle(htmlEl); |
| const bg = computedStyle.backgroundImage; |
|
|
| |
| if (bg && (bg.includes('gradient') || bg.includes('linear-gradient') || bg.includes('radial-gradient'))) { |
| |
| |
| 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'; |
| } |
| } |
| }); |
| } |
| }), |
| 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 { |
| |
| const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document; |
|
|
| if (!iframeDoc || !iframeDoc.body) { |
| logger.warn('Cannot access iframe document'); |
| return null; |
| } |
|
|
| |
| if (waitForContent) { |
| try { |
| await waitForResources(iframeDoc, minWaitDelay); |
| } catch { |
| |
| await new Promise(resolve => setTimeout(resolve, minWaitDelay)); |
| } |
| } |
|
|
| |
| let canvas: HTMLCanvasElement; |
| try { |
| canvas = await attemptCapture(iframeDoc, captureWidth, captureHeight, fullPage); |
| } catch (firstError) { |
| |
| const errorMsg = String(firstError); |
| if (errorMsg.includes('non-finite') || errorMsg.includes('addColorStop') || errorMsg.includes('CanvasGradient')) { |
| |
| await new Promise(resolve => setTimeout(resolve, 500)); |
| canvas = await attemptCapture(iframeDoc, captureWidth, captureHeight, fullPage); |
| } else { |
| |
| throw firstError; |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| ctx.drawImage(canvas, 0, 0, outputWidth, scaledHeight); |
|
|
| |
| const dataUrl = scaledCanvas.toDataURL('image/jpeg', quality); |
|
|
| |
| 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`); |
| |
| 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 retryDataUrl; |
| } |
|
|
| return dataUrl; |
|
|
| } catch (error) { |
| logger.error('Failed to capture screenshot:', error); |
| return null; |
| } |
| } |
|
|