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