From aa1f85f28e6df5c7d350d381eff732b814dd40be Mon Sep 17 00:00:00 2001 From: Renan Alves de Oliveira Date: Mon, 8 Sep 2025 18:07:13 -0300 Subject: [PATCH] feat: implement blob caching for cutout images - add blob caching to avoid re-fetching images - add utility function to revoke blob URLs - clean up blob URLs when components unmount or data changes - fallback to direct URL if fetch fails - bypass zrok interstitial by adding headers --- dashboard/src/api.ts | 65 ++++++++++++++++++++++++- dashboard/src/components/CutoutGrid.tsx | 22 +++++++-- dashboard/src/components/Gallery.tsx | 20 +++++++- 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 1a18429..3eaff49 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -111,13 +111,74 @@ export const buildCutoutUrl = (objectKey: string): string => { return url; }; +// Cache for blob URLs to avoid re-fetching images +const blobCache = new Map(); + // Simple async wrapper (keeps existing calling pattern with react-query) export const getCutoutObject = async (objectKey: string): Promise => { const url = buildCutoutUrl(objectKey); + // In dev with proxy we just return the proxied path; browser will request via Vite server if ((import.meta as any).env?.DEV) { return url; } - // In production (no proxy) just return direct URL (CORS must be handled server-side) - return url; + + // Check if we already have a blob URL for this image + if (blobCache.has(url)) { + const cachedUrl = blobCache.get(url)!; + if ((import.meta as { env?: Record }).env?.VITE_DEBUG_CUTOUTS) { + console.debug('[cutout-blob-cache-hit]', { objectKey, url, cachedUrl }); + } + return cachedUrl; + } + + try { + if ((import.meta as { env?: Record }).env?.VITE_DEBUG_CUTOUTS) { + console.debug('[cutout-fetch-start]', { objectKey, url }); + } + + // In production, fetch the image with proper headers to bypass zrok interstitial + const response = await fetch(url, { + headers: { + 'skip_zrok_interstitial': 'true', + 'Accept': 'image/*' + }, + cache: 'force-cache' + }); + + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + } + + // Create blob URL from the response + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + // Cache the blob URL + blobCache.set(url, blobUrl); + + if ((import.meta as { env?: Record }).env?.VITE_DEBUG_CUTOUTS) { + console.debug('[cutout-blob-created]', { objectKey, url, blobUrl, blobSize: blob.size }); + } + + return blobUrl; + } catch (error) { + console.error('Failed to fetch cutout image:', { objectKey, url, error }); + // Fallback to direct URL if fetch fails + return url; + } +}; + +// Utility function to clean up blob URLs (can be called from components) +export const revokeBlobUrl = (url: string) => { + if (url && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + // Remove from cache + for (const [key, value] of blobCache.entries()) { + if (value === url) { + blobCache.delete(key); + break; + } + } + } }; diff --git a/dashboard/src/components/CutoutGrid.tsx b/dashboard/src/components/CutoutGrid.tsx index 1c2eeb3..aece26a 100644 --- a/dashboard/src/components/CutoutGrid.tsx +++ b/dashboard/src/components/CutoutGrid.tsx @@ -1,7 +1,7 @@ -import React, { memo } from 'react'; +import React, { memo, useEffect } from 'react'; import { Box, Grid, Typography, Paper, Skeleton } from '@mui/material'; import { useQuery } from '@tanstack/react-query'; -import { getCutoutObject } from '../api'; +import { getCutoutObject, revokeBlobUrl } from '../api'; import type { CutoutRecord } from '../types'; interface Props { @@ -34,6 +34,15 @@ const CutoutCard: React.FC<{ record: CutoutRecord }> = memo(({ record }) => { retry: 1, // Only retry once for ngrok issues }); + // Clean up blob URLs when component unmounts or data changes + useEffect(() => { + return () => { + if (data) { + revokeBlobUrl(data); + } + }; + }, [data]); + return ( {record.band} @@ -51,8 +60,13 @@ const CutoutCard: React.FC<{ record: CutoutRecord }> = memo(({ record }) => { if(img.dataset.fallbackTried) return; img.dataset.fallbackTried = '1'; - // For ngrok URLs, try adding the bypass parameters as query string - if (img.src.includes('ngrok')) { + // For blob URLs, we can't retry with different parameters + if (img.src.startsWith('blob:')) { + return; + } + + // For ngrok/zrok URLs, try adding the bypass parameters as query string + if (img.src.includes('ngrok') || img.src.includes('zrok')) { const url = new URL(img.src); url.searchParams.set('skip_zrok_interstitial', 'true'); img.src = url.toString(); diff --git a/dashboard/src/components/Gallery.tsx b/dashboard/src/components/Gallery.tsx index 4500ac4..cc564ee 100644 --- a/dashboard/src/components/Gallery.tsx +++ b/dashboard/src/components/Gallery.tsx @@ -5,7 +5,7 @@ import CloseIcon from '@mui/icons-material/Close'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import type { CutoutRecord } from '../types'; -import { getCutoutObject } from '../api'; +import { getCutoutObject, revokeBlobUrl } from '../api'; interface GalleryProps { cutouts: CutoutRecord[]; @@ -34,6 +34,14 @@ export const Gallery: React.FC = ({ cutouts }) => { setImages([]); setLoadedCount(0); if(filtered.length === 0) return; + + // Clean up previous blob URLs + images.forEach(img => { + if (img.url) { + revokeBlobUrl(img.url); + } + }); + (async () => { const entries: ImgEntry[] = []; for(const c of filtered){ @@ -50,7 +58,15 @@ export const Gallery: React.FC = ({ cutouts }) => { setImages(e=> [...e, ...entries.filter(ne=> !e.find(prev => prev.key === ne.key))]); } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + // Clean up blob URLs when component unmounts + images.forEach(img => { + if (img.url) { + revokeBlobUrl(img.url); + } + }); + }; }, [filtered]); const progress = filtered.length === 0 ? 0 : Math.min(100, Math.round( (loadedCount / filtered.length) * 100));