From ac476f32d189b18a9db10c541f6aeed1b76aaa97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Jan 2026 15:37:44 +0000 Subject: [PATCH 1/2] feat: Improve image loading and Safari compatibility Co-authored-by: avatarneil --- src/lib/image-generator.ts | 79 ++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/src/lib/image-generator.ts b/src/lib/image-generator.ts index 15e9bed..17b603a 100644 --- a/src/lib/image-generator.ts +++ b/src/lib/image-generator.ts @@ -30,6 +30,38 @@ async function imageToDataUri(url: string): Promise { } } +/** + * Wait for an image element to fully load + */ +function waitForImageLoad(img: HTMLImageElement): Promise { + return new Promise((resolve) => { + // If already loaded (complete and has dimensions), resolve immediately + if (img.complete && img.naturalWidth > 0) { + resolve(); + return; + } + + const onLoad = () => { + img.removeEventListener("load", onLoad); + img.removeEventListener("error", onError); + resolve(); + }; + + const onError = () => { + img.removeEventListener("load", onLoad); + img.removeEventListener("error", onError); + // Resolve anyway to not block the capture + resolve(); + }; + + img.addEventListener("load", onLoad); + img.addEventListener("error", onError); + + // Timeout fallback for Safari - if image doesn't load within 2s, continue anyway + setTimeout(resolve, 2000); + }); +} + /** * Replace all external image sources with data URIs to avoid CORS issues during capture * Returns a cleanup function to restore original sources @@ -49,7 +81,7 @@ async function replaceImagesWithDataUris( for (const img of images) { const src = img.src; // Only process external ESPN CDN images - if (src && src.includes("espncdn.com") && !urlToDataUri.has(src)) { + if (src?.includes("espncdn.com") && !urlToDataUri.has(src)) { urlsToFetch.push(src); } } @@ -60,7 +92,9 @@ async function replaceImagesWithDataUris( urlToDataUri.set(url, dataUris[index]); }); - // Replace all image sources + // Replace all image sources and collect promises for load events + const loadPromises: Promise[] = []; + for (const img of images) { const originalSrc = img.src; const dataUri = urlToDataUri.get(originalSrc); @@ -68,10 +102,16 @@ async function replaceImagesWithDataUris( // Also store the original srcset if any const originalSrcset = img.srcset; - img.src = dataUri; - img.srcset = ""; // Clear srcset to prevent Next.js Image from overriding + // Clear srcset first to prevent Next.js Image from interfering + img.srcset = ""; img.removeAttribute("data-nimg"); // Remove Next.js image marker + // Set the new source and wait for it to load + img.src = dataUri; + + // Wait for the new image to load (important for Safari) + loadPromises.push(waitForImageLoad(img)); + restoreFunctions.push(() => { img.src = originalSrc; if (originalSrcset) { @@ -81,6 +121,9 @@ async function replaceImagesWithDataUris( } } + // Wait for all images to fully load with their new data URI sources + await Promise.all(loadPromises); + return () => { for (let i = restoreFunctions.length - 1; i >= 0; i--) { restoreFunctions[i](); @@ -320,8 +363,13 @@ export async function generateBracketImage( const originalOverflow = element.style.overflow; element.style.overflow = "visible"; - // Small delay to ensure layout reflow is complete - await new Promise((resolve) => setTimeout(resolve, 150)); + // Delay to ensure layout reflow is complete + // Use a longer delay on Safari/iOS which has slower rendering for dynamically changed images + const isSafari = + typeof navigator !== "undefined" && + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + const delay = isSafari ? 500 : 200; + await new Promise((resolve) => setTimeout(resolve, delay)); // Get the full scroll dimensions (content may be wider than visible area) const fullWidth = Math.max(element.scrollWidth, element.offsetWidth); @@ -330,16 +378,33 @@ export async function generateBracketImage( let canvas: HTMLCanvasElement; try { // Use html-to-image which has better CSS support + // Safari-specific: use skipAutoScale and lower pixelRatio to avoid memory issues + const isSafariDevice = + typeof navigator !== "undefined" && + /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + canvas = await htmlToImage.toCanvas(element, { backgroundColor: "#000000", // Pure black for OLED - pixelRatio: 2, // Higher resolution + pixelRatio: isSafariDevice ? 1.5 : 2, // Lower resolution on Safari to avoid memory issues cacheBust: true, // Avoid cache issues width: fullWidth, height: fullHeight, + skipAutoScale: isSafariDevice, // Skip auto-scaling on Safari which can cause issues + includeQueryParams: true, // Include query params in image URLs (for cache busting) fetchRequestInit: { mode: "cors", credentials: "omit", }, + // Filter to ensure we only include fully loaded images + filter: (node: HTMLElement | Node) => { + // Don't filter out any nodes - we want everything + // But this function being present helps html-to-image process nodes correctly + if (node instanceof HTMLImageElement) { + // Only include images that are loaded + return node.complete && node.naturalWidth > 0; + } + return true; + }, }); } catch (captureError) { // Restore everything before throwing From 6cf809650021a51c833d9e40d261de6d4bb91252 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Jan 2026 15:42:40 +0000 Subject: [PATCH 2/2] Refactor image handling and add ESPN CDN support This commit introduces several improvements to image handling: - **ESPN CDN Support:** The code now correctly identifies and processes images served from the ESPN CDN, including those optimized by Next.js. - **Next.js Image Optimization Handling:** It extracts the original image URL from Next.js optimized image URLs, allowing for proper fetching and conversion to data URIs. - **Image Pre-loading:** Images are now pre-loaded after conversion to data URIs to ensure they are ready for rendering, improving performance, especially on Safari. - **CORS Proxy Usage:** The image proxy is used to fetch images, bypassing CORS restrictions. - **Next.js Image Styling Fixes:** Adjustments are made to handle Next.js Image's absolute positioning and styling to ensure images are displayed correctly when replaced with data URIs. - **Restoration of Attributes:** Original image attributes like `srcset`, `style`, `loading`, and `decoding` are now restored after processing. - **Removed Unnecessary Filter:** The `filter` option in `html-to-image` has been removed as it was not needed and potentially problematic. Co-authored-by: avatarneil --- src/lib/image-generator.ts | 103 +++++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/src/lib/image-generator.ts b/src/lib/image-generator.ts index 17b603a..7e360ae 100644 --- a/src/lib/image-generator.ts +++ b/src/lib/image-generator.ts @@ -5,24 +5,84 @@ export interface GenerateImageOptions { bracketName: string; } +/** + * Extract the original image URL from a Next.js optimized image URL + * Next.js Image serves images from /_next/image?url=&w=...&q=... + */ +function extractOriginalUrl(url: string): string | null { + try { + const urlObj = new URL(url, window.location.origin); + if (urlObj.pathname === "/_next/image") { + const originalUrl = urlObj.searchParams.get("url"); + if (originalUrl) { + return originalUrl; + } + } + } catch { + // Not a valid URL + } + return null; +} + +/** + * Check if an image URL is from ESPN CDN (either directly or via Next.js optimization) + */ +function isEspnImage(url: string): boolean { + if (!url) return false; + + // Direct ESPN CDN URL + if (url.includes("espncdn.com")) return true; + + // Next.js optimized ESPN image + const originalUrl = extractOriginalUrl(url); + if (originalUrl?.includes("espncdn.com")) return true; + + return false; +} + +/** + * Get the URL to use for fetching (extract original from Next.js if needed) + */ +function getImageFetchUrl(url: string): string { + const originalUrl = extractOriginalUrl(url); + return originalUrl || url; +} + /** * Convert an image URL to a data URI by fetching through our proxy + * Also pre-loads the data URI to ensure it's ready for rendering */ async function imageToDataUri(url: string): Promise { try { + // Get the actual URL to fetch (extract from Next.js optimization if needed) + const fetchUrl = getImageFetchUrl(url); + // Use our proxy to avoid CORS issues - const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(url)}`; + const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(fetchUrl)}`; const response = await fetch(proxyUrl); if (!response.ok) { throw new Error(`Failed to fetch: ${response.status}`); } const blob = await response.blob(); - return new Promise((resolve, reject) => { + const dataUri = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); + + // Pre-load the data URI to ensure it's cached and ready for rendering + // This is especially important for Safari which can be slow to decode data URIs + await new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(); + img.onerror = () => resolve(); // Continue even on error + img.src = dataUri; + // Timeout fallback + setTimeout(resolve, 1000); + }); + + return dataUri; } catch (error) { console.error("Failed to convert image to data URI:", url, error); // Return a transparent 1x1 pixel as fallback @@ -80,8 +140,8 @@ async function replaceImagesWithDataUris( for (const img of images) { const src = img.src; - // Only process external ESPN CDN images - if (src?.includes("espncdn.com") && !urlToDataUri.has(src)) { + // Process ESPN CDN images (both direct and via Next.js optimization) + if (isEspnImage(src) && !urlToDataUri.has(src)) { urlsToFetch.push(src); } } @@ -99,12 +159,28 @@ async function replaceImagesWithDataUris( const originalSrc = img.src; const dataUri = urlToDataUri.get(originalSrc); if (dataUri) { - // Also store the original srcset if any + // Store original attributes for restoration const originalSrcset = img.srcset; + const originalStyle = img.getAttribute("style") || ""; + const originalLoading = img.getAttribute("loading"); + const originalDecoding = img.getAttribute("decoding"); // Clear srcset first to prevent Next.js Image from interfering img.srcset = ""; img.removeAttribute("data-nimg"); // Remove Next.js image marker + img.removeAttribute("loading"); // Remove lazy loading + img.removeAttribute("decoding"); // Remove async decoding + + // Fix Next.js Image styling - ensure image is visible and properly sized + // Next.js Image uses position:absolute with object-fit, which can cause issues + const computedStyle = window.getComputedStyle(img); + if (computedStyle.position === "absolute") { + // Keep position absolute but ensure dimensions are correct + img.style.width = "100%"; + img.style.height = "100%"; + img.style.inset = "0"; + img.style.objectFit = "contain"; + } // Set the new source and wait for it to load img.src = dataUri; @@ -114,9 +190,16 @@ async function replaceImagesWithDataUris( restoreFunctions.push(() => { img.src = originalSrc; + img.setAttribute("style", originalStyle); if (originalSrcset) { img.srcset = originalSrcset; } + if (originalLoading) { + img.setAttribute("loading", originalLoading); + } + if (originalDecoding) { + img.setAttribute("decoding", originalDecoding); + } }); } } @@ -395,16 +478,6 @@ export async function generateBracketImage( mode: "cors", credentials: "omit", }, - // Filter to ensure we only include fully loaded images - filter: (node: HTMLElement | Node) => { - // Don't filter out any nodes - we want everything - // But this function being present helps html-to-image process nodes correctly - if (node instanceof HTMLImageElement) { - // Only include images that are loaded - return node.complete && node.naturalWidth > 0; - } - return true; - }, }); } catch (captureError) { // Restore everything before throwing