Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 107 additions & 52 deletions src/app/api/snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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(() => {});
}
}
}
}

Expand All @@ -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(() => {});
}
}
}
}
19 changes: 16 additions & 3 deletions src/components/features/ThemeSwitcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -36,22 +37,34 @@ 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) {
return <LoadingIcon />;
}

const initialThemeForIcon = originalTheme || (resolvedTheme as Theme);
const isLoading = isCapturing || isWarmingUp;

return (
<>
<ThemeToggleButtonIcon
onClick={toggleTheme}
onClick={isLoading ? () => {} : toggleTheme}
progress={wipeProgress}
initialTheme={initialThemeForIcon}
isLoading={isCapturing}
isLoading={isLoading}
/>

{createPortal(
Expand Down
25 changes: 17 additions & 8 deletions src/components/features/ThemeSwitcher/ui/WipeAnimationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<AnimatePresence>
{snapshots && (
<div
className="fixed inset-0 z-10000"
className="fixed inset-0 z-10000 pointer-events-none"
data-html2canvas-ignore="true"
>
{/* Static Background Layer (to prevent target theme flash before wipe starts) */}
<div
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
style={{
backgroundImage: `url(${snapshots.a})`,
}}
/>

{/* Status Text */}
{snapshots.method && (
<div className="absolute top-8 left-1/2 -translate-x-1/2 z-[10001] bg-black/50 text-white px-4 py-1 rounded-full text-sm font-medium backdrop-blur-sm border border-white/10">
Method: {snapshots.method}
</div>
)}

{/* Target Theme Snapshot (Bottom Layer - Revealed) */}
<div
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
style={{
backgroundImage: `url(${snapshots.b})`,
width: contentWidth,
}}
/>

Expand All @@ -43,18 +54,16 @@ export function WipeAnimationOverlay({
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
style={{
backgroundImage: `url(${snapshots.a})`,
width: contentWidth,
clipPath,
}}
/>

{/* Wipe Divider */}
<motion.div
key="theme-switcher-divider"
className="absolute left-0 h-1 bg-[#D9D24D]"
className="absolute left-0 w-full h-1 bg-[#D9D24D]"
style={{
top: dividerTop,
width: contentWidth,
translate: wipeDirection === "top-down" ? "0 -100%" : "0 0",
}}
/>
Expand Down
Loading