Browse Source
Renders low-resolution JPEG thumbnails (256px, q=0.6) in nodes when zoomed out (effective width < 300px), swapping to full resolution when zoomed in. Thumbnails are generated eagerly and cached in memory. - New utility: imageThumbnail.ts (offscreen canvas downscaling) - New cache: thumbnailCache.ts (ephemeral Map-based cache) - New hook: useAdaptiveImageSrc (zoom-aware src selector) - Integrated into all image-bearing nodes (GenerateImage, ImageInput, Output, Annotation, SplitGrid, VideoFrameGrab, OutputGallery, GLBViewer) - Lightbox/modal views always use full resolution https://claude.ai/code/session_01MvD1n4QeXutgwUpKJuDGHahandoff-20260429-1057
11 changed files with 209 additions and 12 deletions
@ -0,0 +1,95 @@ |
|||
import { useState, useEffect, useRef, useCallback } from "react"; |
|||
import { useStore, type ReactFlowState } from "@xyflow/react"; |
|||
import { generateThumbnail } from "@/utils/imageThumbnail"; |
|||
import { |
|||
getThumbnail, |
|||
setThumbnail, |
|||
getPending, |
|||
setPending, |
|||
removePending, |
|||
} from "@/store/thumbnailCache"; |
|||
|
|||
const EFFECTIVE_WIDTH_THRESHOLD = 300; |
|||
const DEFAULT_NODE_WIDTH = 350; |
|||
|
|||
/** |
|||
* Returns an adaptive image source that swaps between a small JPEG thumbnail |
|||
* and the full-resolution image based on how large the node appears on screen. |
|||
* |
|||
* - When `nodeWidth * zoom < 300px` → returns thumbnail (~256px JPEG) |
|||
* - Otherwise → returns full source |
|||
* - Thumbnails are generated eagerly when fullSrc changes and cached in memory |
|||
*/ |
|||
export function useAdaptiveImageSrc( |
|||
fullSrc: string | null | undefined, |
|||
nodeId: string |
|||
): string | null { |
|||
// Read zoom (quantized to 0.1 steps) and node width in a single selector
|
|||
// to minimize re-renders
|
|||
const shouldUseThumbnail = useStore( |
|||
useCallback( |
|||
(state: ReactFlowState) => { |
|||
const zoom = state.transform[2]; |
|||
const node = state.nodeLookup.get(nodeId); |
|||
const width = |
|||
node?.measured?.width ?? (node as Record<string, unknown>)?.width as number ?? DEFAULT_NODE_WIDTH; |
|||
return (width as number) * zoom < EFFECTIVE_WIDTH_THRESHOLD; |
|||
}, |
|||
[nodeId] |
|||
), |
|||
// Only re-render when the boolean result changes
|
|||
(a, b) => a === b |
|||
); |
|||
|
|||
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null); |
|||
const prevSrcRef = useRef<string | null>(null); |
|||
|
|||
// Eagerly generate thumbnail when fullSrc changes
|
|||
useEffect(() => { |
|||
if (!fullSrc) { |
|||
setThumbnailSrc(null); |
|||
prevSrcRef.current = null; |
|||
return; |
|||
} |
|||
|
|||
// Skip if same source
|
|||
if (fullSrc === prevSrcRef.current) return; |
|||
prevSrcRef.current = fullSrc; |
|||
|
|||
// Check cache first
|
|||
const cached = getThumbnail(fullSrc); |
|||
if (cached) { |
|||
setThumbnailSrc(cached); |
|||
return; |
|||
} |
|||
|
|||
// Check if generation is already in progress
|
|||
const existing = getPending(fullSrc); |
|||
if (existing) { |
|||
existing.then((thumb) => { |
|||
if (prevSrcRef.current === fullSrc) { |
|||
setThumbnailSrc(thumb); |
|||
} |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
// Generate thumbnail
|
|||
const promise = generateThumbnail(fullSrc).then((thumb) => { |
|||
setThumbnail(fullSrc, thumb); |
|||
removePending(fullSrc); |
|||
return thumb; |
|||
}); |
|||
setPending(fullSrc, promise); |
|||
|
|||
promise.then((thumb) => { |
|||
if (prevSrcRef.current === fullSrc) { |
|||
setThumbnailSrc(thumb); |
|||
} |
|||
}); |
|||
}, [fullSrc]); |
|||
|
|||
if (!fullSrc) return null; |
|||
|
|||
return shouldUseThumbnail && thumbnailSrc ? thumbnailSrc : fullSrc; |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
/** |
|||
* Simple in-memory cache for image thumbnails. |
|||
* Keys are derived from the first portion of the base64 source to avoid |
|||
* hashing multi-MB strings. Not persisted — thumbnails regenerate cheaply. |
|||
*/ |
|||
|
|||
const cache = new Map<string, string>(); |
|||
const pending = new Map<string, Promise<string>>(); |
|||
|
|||
function cacheKey(src: string): string { |
|||
// Use a slice of the data URL that's unique enough to identify the image.
|
|||
// Skip the "data:image/...;base64," prefix (~30 chars) and take the next 120 chars.
|
|||
return src.slice(0, 200); |
|||
} |
|||
|
|||
export function getThumbnail(src: string): string | undefined { |
|||
return cache.get(cacheKey(src)); |
|||
} |
|||
|
|||
export function setThumbnail(src: string, thumbnail: string): void { |
|||
cache.set(cacheKey(src), thumbnail); |
|||
} |
|||
|
|||
export function getPending(src: string): Promise<string> | undefined { |
|||
return pending.get(cacheKey(src)); |
|||
} |
|||
|
|||
export function setPending(src: string, promise: Promise<string>): void { |
|||
pending.set(cacheKey(src), promise); |
|||
} |
|||
|
|||
export function removePending(src: string): void { |
|||
pending.delete(cacheKey(src)); |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
/** |
|||
* Generates a lower-resolution JPEG thumbnail from a base64 image data URL. |
|||
* Used for adaptive image resolution — rendering smaller images when nodes |
|||
* are small in the viewport. |
|||
*/ |
|||
export async function generateThumbnail( |
|||
base64DataUrl: string, |
|||
maxDim: number = 256, |
|||
quality: number = 0.6 |
|||
): Promise<string> { |
|||
if (!base64DataUrl) return base64DataUrl; |
|||
|
|||
return new Promise((resolve) => { |
|||
const img = new Image(); |
|||
img.onload = () => { |
|||
const { naturalWidth: w, naturalHeight: h } = img; |
|||
|
|||
// Skip if already small enough
|
|||
if (w <= maxDim && h <= maxDim) { |
|||
resolve(base64DataUrl); |
|||
return; |
|||
} |
|||
|
|||
// Calculate scaled dimensions preserving aspect ratio
|
|||
const scale = Math.min(maxDim / w, maxDim / h); |
|||
const newW = Math.round(w * scale); |
|||
const newH = Math.round(h * scale); |
|||
|
|||
const canvas = document.createElement("canvas"); |
|||
canvas.width = newW; |
|||
canvas.height = newH; |
|||
|
|||
const ctx = canvas.getContext("2d"); |
|||
if (!ctx) { |
|||
resolve(base64DataUrl); |
|||
return; |
|||
} |
|||
|
|||
ctx.drawImage(img, 0, 0, newW, newH); |
|||
resolve(canvas.toDataURL("image/jpeg", quality)); |
|||
}; |
|||
img.onerror = () => resolve(base64DataUrl); |
|||
img.src = base64DataUrl; |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue