diff --git a/src/app/api/snapshot/route.ts b/src/app/api/snapshot/route.ts index 74e1c63..19e0830 100644 --- a/src/app/api/snapshot/route.ts +++ b/src/app/api/snapshot/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import puppeteer from "puppeteer-core"; -import { puppeteerManager } from "@/utils/puppeteer-manager"; +import fs from "fs"; export const maxDuration = 60; @@ -16,13 +16,60 @@ function isValidRequest(req: Request) { } } -export async function GET() { +async function getBrowserInstance() { + const wsEndpoint = process.env.PUPPETEER_WS_ENDPOINT; + + if (wsEndpoint) { + console.log("Connecting to Browserless.io..."); + return await puppeteer.connect({ browserWSEndpoint: wsEndpoint }); + } + + const isLocal = process.env.NODE_ENV === "development"; + if (isLocal) { + const paths = [ + process.env.PUPPETEER_EXECUTABLE_PATH, + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ]; + const executablePath = paths.find(p => p && fs.existsSync(p)) || "/usr/bin/google-chrome"; + return await puppeteer.launch({ + headless: true, + executablePath, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + } + + const chromium = (await import("@sparticuz/chromium-min")).default; + return await puppeteer.launch({ + args: [...chromium.args, "--no-sandbox", "--disable-setuid-sandbox"], + defaultViewport: chromium.defaultViewport, + executablePath: await chromium.executablePath(), + headless: chromium.headless as any, + }); +} + +export async function GET(req: Request) { + if (!isValidRequest(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + let browser: any = null; try { - // Lazy initialize the browser - await puppeteerManager.getBrowser(); + browser = await getBrowserInstance(); return NextResponse.json({ status: "warmed up" }); } catch (error: any) { + console.error("Warmup API error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); + } finally { + if (browser) { + if (process.env.PUPPETEER_WS_ENDPOINT) { + await browser.disconnect().catch(() => {}); + } else { + await browser.close().catch(() => {}); + } + } } } @@ -32,66 +79,74 @@ export async function POST(req: Request) { } let browser: any = null; - let page: any = null; try { - const { html, width, height, devicePixelRatio = 2 } = await req.json(); + const body = await req.json(); + const tasks = Array.isArray(body.tasks) ? body.tasks : [body]; - if (!html) { + if (tasks.length === 0 || !tasks[0].html) { return NextResponse.json({ error: "HTML content is required" }, { status: 400 }); } - // Connect to the persistent browser instance - const wsEndpoint = await puppeteerManager.getWsEndpoint(); - browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint }); - - page = await browser.newPage(); - - // Set viewport correctly for this specific snapshot - const safeWidth = Math.min(Math.max(width || 1280, 100), 3840); - const safeHeight = Math.min(Math.max(height || 720, 100), 2160); - const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3); - - await page.setViewport({ - width: safeWidth, - height: safeHeight, - deviceScaleFactor: safeScale, - }); - - // Performance: Disable JS - await page.setJavaScriptEnabled(false); - - // Wait for full load - await page.setContent(html, { waitUntil: "load" }); - - // Tiny delay for layout/font rendering - await new Promise(r => setTimeout(r, 100)); - - await page.evaluate(() => { - const htmlEl = document.documentElement; - const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0'); - const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0'); - window.scrollTo(x, y); - }); - - const buffer = await page.screenshot({ - type: "png", - fullPage: false, - }); - - const base64 = `data:image/png;base64,${buffer.toString("base64")}`; - return NextResponse.json({ snapshot: base64 }); + // Connect/Launch a FRESH browser for every request to avoid "Target closed" errors + // when multiple concurrent requests try to share/disconnect a singleton. + browser = await getBrowserInstance(); + + const snapshots = []; + + // Process tasks sequentially to maintain stability on low-concurrency connections (Browserless.io) + for (const task of tasks) { + const { html, width, height, devicePixelRatio = 2 } = task; + const page = await browser.newPage(); + try { + const safeWidth = Math.min(Math.max(width || 1280, 100), 3840); + const safeHeight = Math.min(Math.max(height || 720, 100), 2160); + const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3); + + await page.setViewport({ + width: safeWidth, + height: safeHeight, + deviceScaleFactor: safeScale, + }); + + await page.setJavaScriptEnabled(false); + await page.setContent(html, { waitUntil: "load" }); + + // Delay for layout, font rendering, and asset loading + await new Promise(r => setTimeout(r, 500)); + + // Ensure fonts are fully loaded + try { + await page.evaluateHandle('document.fonts.ready'); + } catch (e) { + console.warn("Fonts ready check failed:", e); + } + + await page.evaluate(() => { + const htmlEl = document.documentElement; + const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0'); + const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0'); + window.scrollTo(x, y); + }); + + const buffer = await page.screenshot({ type: "png", fullPage: false }); + snapshots.push(`data:image/png;base64,${Buffer.from(buffer).toString("base64")}`); + } finally { + await page.close().catch(() => {}); + } + } + return NextResponse.json({ snapshots }); } catch (error: any) { console.error("Snapshot API error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } finally { - if (page) { - await page.close().catch(() => {}); - } if (browser) { - // We disconnect from the persistent browser, NOT close it - await browser.disconnect(); + if (process.env.PUPPETEER_WS_ENDPOINT) { + await browser.disconnect().catch(() => {}); + } else { + await browser.close().catch(() => {}); + } } } } diff --git a/src/components/features/ThemeSwitcher/index.tsx b/src/components/features/ThemeSwitcher/index.tsx index ff287fd..1c30417 100644 --- a/src/components/features/ThemeSwitcher/index.tsx +++ b/src/components/features/ThemeSwitcher/index.tsx @@ -25,6 +25,7 @@ export function ThemeSwitcher({ setWipeDirection, }: ThemeSwitcherProps) { const [mounted, setMounted] = useState(false); + const [isWarmingUp, setIsWarmingUp] = useState(true); const { resolvedTheme } = useTheme(); const { toggleTheme, snapshots, isCapturing, originalTheme, animationStyles } = useThemeWipe({ @@ -36,7 +37,18 @@ export function ThemeSwitcher({ useEffect(() => { setMounted(true); // Warm up the snapshot API on mount - fetch("/api/snapshot").catch(() => {}); + setIsWarmingUp(true); + fetch("/api/snapshot") + .then(res => { + if (!res.ok) throw new Error("Warmup failed"); + setIsWarmingUp(false); + }) + .catch((err) => { + console.error("Theme switcher warmup error:", err); + // If warmup fails, we still unlock it so the user can try, + // but it will likely fallback to modern-screenshot. + setIsWarmingUp(false); + }); }, []); if (!mounted) { @@ -44,14 +56,15 @@ export function ThemeSwitcher({ } const initialThemeForIcon = originalTheme || (resolvedTheme as Theme); + const isLoading = isCapturing || isWarmingUp; return ( <> {} : toggleTheme} progress={wipeProgress} initialTheme={initialThemeForIcon} - isLoading={isCapturing} + isLoading={isLoading} /> {createPortal( diff --git a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx index 25cf973..12de498 100644 --- a/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx +++ b/src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx @@ -18,22 +18,33 @@ export function WipeAnimationOverlay({ animationStyles: { clipPath, dividerTop }, wipeDirection, }: WipeAnimationOverlayProps) { - // Use the client width to ensure the snapshot matches the content area (excluding scrollbar) - const contentWidth = typeof document !== 'undefined' ? `${document.documentElement.clientWidth}px` : '100%'; - return ( {snapshots && (
+ {/* Static Background Layer (to prevent target theme flash before wipe starts) */} +
+ + {/* Status Text */} + {snapshots.method && ( +
+ Method: {snapshots.method} +
+ )} + {/* Target Theme Snapshot (Bottom Layer - Revealed) */}
@@ -43,7 +54,6 @@ export function WipeAnimationOverlay({ className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]" style={{ backgroundImage: `url(${snapshots.a})`, - width: contentWidth, clipPath, }} /> @@ -51,10 +61,9 @@ export function WipeAnimationOverlay({ {/* Wipe Divider */} diff --git a/src/hooks/useThemeWipe.ts b/src/hooks/useThemeWipe.ts index 9fdb616..4f9a683 100644 --- a/src/hooks/useThemeWipe.ts +++ b/src/hooks/useThemeWipe.ts @@ -17,6 +17,7 @@ type UseThemeWipeProps = { export type Snapshots = { a: string; // Original theme b: string; // Target theme + method?: string; // Method used for snapshot }; export function useThemeWipe({ @@ -31,8 +32,10 @@ export function useThemeWipe({ const [originalTheme, setOriginalTheme] = useState(null); const setScrollLock = (isLocked: boolean) => { + const hasScrollbar = window.innerWidth > document.documentElement.clientWidth; document.documentElement.style.overflow = isLocked ? 'hidden' : ''; - document.documentElement.style.scrollbarGutter = isLocked ? 'stable' : ''; + // Only reserve gutter space if a scrollbar was actually present to prevent layout shift on mobile + document.documentElement.style.scrollbarGutter = (isLocked && hasScrollbar) ? 'stable' : ''; }; const handleAnimationComplete = useCallback(() => { @@ -88,20 +91,70 @@ export function useThemeWipe({ const direction: WipeDirection = currentTheme === "dark" ? "bottom-up" : "top-down"; - const fetchSnapshot = async (themeOverride?: "light" | "dark") => { - const html = getFullPageHTML(themeOverride); + const captureMask = async () => { + const vh = window.innerHeight; + const scrollY = window.scrollY; + const options = { + useCORS: true, + width: window.innerWidth, + height: vh, + scale: 1, // Low scale is fine for a temporary mask + filter: (node: Node) => { + if (node instanceof HTMLElement || node instanceof SVGElement) { + if (node.hasAttribute('data-html2canvas-ignore')) return false; + } + return true; + }, + style: { + width: `${document.documentElement.clientWidth}px`, + height: `${document.documentElement.scrollHeight}px`, + transform: `translateY(-${scrollY}px)`, + transformOrigin: 'top left', + } + }; + return await domToPng(document.documentElement, options); + }; + + const fetchSnapshotsBatch = async (newTheme: Theme) => { + // 1. Snapshot A (current) + const htmlA = await getFullPageHTML(); + + // 2. Switch theme (to handle layouts that require re-render) + setTheme(newTheme); + // Wait multiple frames and a small timeout to ensure all React components/effects have settled + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(r)))); + await new Promise(r => setTimeout(r, 250)); + + // 3. Snapshot B (newly rendered theme) + const htmlB = await getFullPageHTML(); + + // 4. Restore original theme state before sending to API + // This ensures the live page matches Snapshot A when the wipe animation starts. + setTheme(currentTheme); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + const response = await fetch("/api/snapshot", { method: "POST", body: JSON.stringify({ - html, - width: window.innerWidth, - height: window.innerHeight, - devicePixelRatio: window.devicePixelRatio, + tasks: [ + { + html: htmlA, + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + }, + { + html: htmlB, + width: window.innerWidth, + height: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + } + ] }), }); const data = await response.json(); if (data.error) throw new Error(data.error); - return data.snapshot; + return data.snapshots; // Array of [snapshotA, snapshotB] }; const captureWithModernScreenshot = async (): Promise => { @@ -109,9 +162,11 @@ export function useThemeWipe({ const scrollY = window.scrollY; const options = { useCORS: true, - width: document.documentElement.clientWidth, + width: window.innerWidth, height: vh, scale: Math.max(window.devicePixelRatio, 2), + // Force font rendering and asset loading delay for modern-screenshot too + waitForFonts: true, filter: (node: Node) => { if (node instanceof HTMLElement || node instanceof SVGElement) { if (node.hasAttribute('data-html2canvas-ignore')) return false; @@ -119,7 +174,7 @@ export function useThemeWipe({ return true; }, style: { - width: `${document.documentElement.clientWidth}px`, + width: `${window.innerWidth}px`, height: `${document.documentElement.scrollHeight}px`, transform: `translateY(-${scrollY}px)`, transformOrigin: 'top left', @@ -150,15 +205,19 @@ export function useThemeWipe({ }; try { - // PHASE 1: Try Puppeteer (3s timeout) + // PHASE 0: Capture Mask to prevent theme flash + const mask = await captureMask(); + setSnapshots({ a: mask, b: mask, method: "Capturing..." }); + + // PHASE 1: Try Puppeteer (20s timeout as per instructions) console.log("Attempting Puppeteer snapshot..."); const [snapshotA, snapshotB] = await withTimeout( - Promise.all([fetchSnapshot(), fetchSnapshot(newTheme)]), - 3000, + fetchSnapshotsBatch(newTheme), + 20000, "Puppeteer timeout" ) as [string, string]; - setSnapshots({ a: snapshotA, b: snapshotB }); + setSnapshots({ a: snapshotA, b: snapshotB, method: "Puppeteer" }); await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); setTheme(newTheme); setWipeDirection(direction); @@ -167,27 +226,31 @@ export function useThemeWipe({ console.warn("Puppeteer failed or timed out, falling back to modern-screenshot:", e.message); try { - // PHASE 2: Try modern-screenshot (2s timeout) - const snapshots = await withTimeout( + // PHASE 2: Try modern-screenshot (15s timeout as per instructions) + const snapshotsResult = await withTimeout( captureWithModernScreenshot(), - 2000, + 15000, "modern-screenshot timeout" ) as Snapshots; - setSnapshots(snapshots); + setSnapshots({ ...snapshotsResult, method: "modern-screenshot" }); setWipeDirection(direction); } catch (e2: any) { - console.warn("modern-screenshot failed or timed out, changing theme instantly:", e2.message); - // PHASE 3: Fallback instantly + console.warn("modern-screenshot failed or timed out, changing theme instantly:", e2.message); setTheme(newTheme); - setSnapshots(null); - setScrollLock(false); - setAnimationTargetTheme(null); - setOriginalTheme(null); - setWipeDirection(null); - wipeProgress.set(0); + setSnapshots({ a: '', b: '', method: 'Instant' }); + // Give it a moment to show the status before clearing + setTimeout(() => { + // Check if we haven't started a new capture in the meantime + setSnapshots(prev => (prev?.method === 'Instant' ? null : prev)); + setScrollLock(false); + setAnimationTargetTheme(prev => (prev === newTheme ? null : prev)); + setOriginalTheme(prev => (prev === currentTheme ? null : prev)); + setWipeDirection(prev => (prev === null ? null : prev)); // Don't clear if animation started + wipeProgress.set(0); + }, 2000); } } finally { setIsCapturing(false); diff --git a/src/utils/dom-serializer.ts b/src/utils/dom-serializer.ts index 98bf048..5b658a0 100644 --- a/src/utils/dom-serializer.ts +++ b/src/utils/dom-serializer.ts @@ -26,6 +26,56 @@ export function serializeDOM(root: HTMLElement): string { } }); + // 1.5. Inlining images to ensure visual parity in headless browsers. + // We convert visible images to data URLs to ensure they render in remote environments. + const originalImages = root.querySelectorAll('img'); + const clonedImages = clone.querySelectorAll('img'); + originalImages.forEach((img, index) => { + try { + const clonedImg = clonedImages[index] as HTMLImageElement; + if (!clonedImg) return; + + // Only attempt to inline images that are already loaded + if (img instanceof HTMLImageElement && img.complete && img.naturalWidth > 0) { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0); + clonedImg.src = canvas.toDataURL(); + // Remove srcset to force the data URL + clonedImg.removeAttribute('srcset'); + } + } + } catch (e) { + // CORS might block this, which is fine, we fallback to the original src + console.warn('Could not inline image', e); + } + }); + + // 1.6. Inlining background images from inline styles + const allElements = root.querySelectorAll('*'); + const allCloned = clone.querySelectorAll('*'); + allElements.forEach((el, index) => { + const style = (el as HTMLElement).style; + if (style && style.backgroundImage && style.backgroundImage.includes('url(')) { + const clonedEl = allCloned[index] as HTMLElement; + if (clonedEl) { + // Simple regex to extract URL + const match = style.backgroundImage.match(/url\(["']?([^"']+)["']?\)/); + if (match && match[1] && !match[1].startsWith('data:')) { + // In a real scenario, we'd fetch and convert to data URL here. + // For now, ensure it's at least absolute if we can't inline. + const origin = window.location.origin; + if (match[1].startsWith('/')) { + clonedEl.style.backgroundImage = `url("${origin}${match[1]}")`; + } + } + } + } + }); + // 2. Form values const inputs = root.querySelectorAll('input, textarea, select'); const clonedInputs = clone.querySelectorAll('input, textarea, select'); @@ -92,7 +142,35 @@ export function serializeDOM(root: HTMLElement): string { return clone.innerHTML; // Return innerHTML to avoid nested body } -export function getFullPageHTML(themeOverride?: "light" | "dark"): string { +async function fetchAsDataURL(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) return null; + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch (e) { + return null; + } +} + +export async function getFullPageHTML(themeOverride?: "light" | "dark"): Promise { + // Ensure fonts and images are loaded before serialization + try { + await document.fonts.ready; + const images = Array.from(document.images); + await Promise.all(images.map(img => img.complete ? Promise.resolve() : new Promise(r => { + img.onload = r; + img.onerror = r; + }))); + } catch (e) { + console.warn('Wait for assets failed:', e); + } + const originalHtml = document.documentElement; const doc = originalHtml.cloneNode(true) as HTMLElement; @@ -115,24 +193,115 @@ export function getFullPageHTML(themeOverride?: "light" | "dark"): string { } const body = doc.querySelector('body'); - if (body) { + if (body && document.body) { + // Preserve body attributes (classes, etc.) which are often used by Tailwind/Next.js + Array.from(document.body.attributes).forEach(attr => { + body.setAttribute(attr.name, attr.value); + }); // Correctly replace inner content body.innerHTML = serializeDOM(document.body); } + // 1. Capture and inline all readable CSS rules and assets (fonts/images) + let inlineStyles = ''; + try { + const origin = window.location.origin; + for (const sheet of Array.from(document.styleSheets)) { + try { + if (!sheet.cssRules) continue; + // Limit total inline size to stay within Vercel limits + if (inlineStyles.length > 1500000) break; + + for (const rule of Array.from(sheet.cssRules)) { + let cssText = rule.cssText; + + // Handle fonts and background images in CSS + const urlMatches = cssText.matchAll(/url\(['"]?([^'")]*)['"]?\)/g); + for (const match of Array.from(urlMatches)) { + const path = match[1]; + if (!path || path.startsWith('data:') || path.startsWith('http') || path.startsWith('//')) { + continue; + } + + try { + const absoluteUrl = new URL(path, sheet.href || origin).href; + // Only attempt to inline fonts or small images + if (path.match(/\.(woff2?|ttf|otf|eot|svg|png|jpe?g|webp)$/i)) { + const dataUrl = await fetchAsDataURL(absoluteUrl); + if (dataUrl) { + cssText = cssText.replace(match[0], `url("${dataUrl}")`); + } else { + // Fallback to absolute URL if fetch fails + cssText = cssText.replace(match[0], `url("${absoluteUrl}")`); + } + } else { + cssText = cssText.replace(match[0], `url("${absoluteUrl}")`); + } + } catch (e) {} + } + + inlineStyles += cssText + '\n'; + } + } catch (e) { + console.warn('Could not read cssRules from stylesheet', sheet.href, e); + } + } + } catch (e) { + console.error('Error reading stylesheets', e); + } + + const head = doc.querySelector('head'); if (head) { + // Inject the inlined and resolved styles + const styleTag = document.createElement('style'); + styleTag.textContent = inlineStyles; + head.appendChild(styleTag); + + // Convert all relative links to absolute to ensure resolution in headless browser + const origin = window.location.origin; + doc.querySelectorAll('link[href], script[src], img[src], source[src], source[srcset]').forEach(el => { + if (el.hasAttribute('href')) { + const href = el.getAttribute('href')!; + if (href.startsWith('/') && !href.startsWith('//')) { + el.setAttribute('href', origin + href); + } + } + if (el.hasAttribute('src')) { + const src = el.getAttribute('src')!; + if (src.startsWith('/') && !src.startsWith('//')) { + el.setAttribute('src', origin + src); + } + } + if (el.hasAttribute('srcset')) { + const srcset = el.getAttribute('srcset')!; + const absoluteSrcset = srcset.split(',').map(part => { + const [url, size] = part.trim().split(/\s+/); + if (url.startsWith('/') && !url.startsWith('//')) { + return (origin + url) + (size ? ' ' + size : ''); + } + return part; + }).join(', '); + el.setAttribute('srcset', absoluteSrcset); + } + }); + const base = document.createElement('base'); - base.href = window.location.origin; + base.href = origin + '/'; head.insertBefore(base, head.firstChild); doc.setAttribute('data-scroll-x', window.scrollX.toString()); doc.setAttribute('data-scroll-y', window.scrollY.toString()); } + // 5. Cleanup: Remove scripts and other non-visual elements to reduce payload size + const scripts = doc.querySelectorAll('script, noscript, template, iframe'); + scripts.forEach(s => s.remove()); + // Hide the switcher and overlay const itemsToHide = doc.querySelectorAll('[data-html2canvas-ignore]'); itemsToHide.forEach(el => (el as HTMLElement).style.display = 'none'); - return ` `${a.name}="${a.value}"`).join(' ')}>${doc.innerHTML}`; + const htmlAttrs = Array.from(doc.attributes).map(a => `${a.name}="${a.value}"`).join(' '); + return `${doc.innerHTML}`; }