From 1f12e8e7a1c0537b73f91121b29f6f890eaa6e6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 03:13:47 +0000 Subject: [PATCH] Add adaptive image resolution scaling for viewport performance 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_01MvD1n4QeXutgwUpKJuDGHa --- src/components/nodes/AnnotationNode.tsx | 4 +- src/components/nodes/GLBViewerNode.tsx | 4 +- src/components/nodes/GenerateImageNode.tsx | 4 +- src/components/nodes/ImageInputNode.tsx | 4 +- src/components/nodes/OutputGalleryNode.tsx | 18 ++-- src/components/nodes/OutputNode.tsx | 5 +- src/components/nodes/SplitGridNode.tsx | 4 +- src/components/nodes/VideoFrameGrabNode.tsx | 4 +- src/hooks/useAdaptiveImageSrc.ts | 95 +++++++++++++++++++++ src/store/thumbnailCache.ts | 34 ++++++++ src/utils/imageThumbnail.ts | 45 ++++++++++ 11 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useAdaptiveImageSrc.ts create mode 100644 src/store/thumbnailCache.ts create mode 100644 src/utils/imageThumbnail.ts diff --git a/src/components/nodes/AnnotationNode.tsx b/src/components/nodes/AnnotationNode.tsx index a2e16228..021271e9 100644 --- a/src/components/nodes/AnnotationNode.tsx +++ b/src/components/nodes/AnnotationNode.tsx @@ -6,6 +6,7 @@ import { BaseNode } from "./BaseNode"; import { useAnnotationStore } from "@/store/annotationStore"; import { useWorkflowStore } from "@/store/workflowStore"; import { AnnotationNodeData } from "@/types"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; type AnnotationNodeType = Node; @@ -89,6 +90,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps Annotated diff --git a/src/components/nodes/GLBViewerNode.tsx b/src/components/nodes/GLBViewerNode.tsx index 27a81507..ae6603b1 100644 --- a/src/components/nodes/GLBViewerNode.tsx +++ b/src/components/nodes/GLBViewerNode.tsx @@ -8,6 +8,7 @@ import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { useToast } from "@/components/Toast"; import { GLBViewerNodeData } from "@/types"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import * as THREE from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; @@ -201,6 +202,7 @@ function LoadingIndicator() { export function GLBViewerNode({ id, data, selected }: NodeProps) { const nodeData = data as GLBViewerNodeData; + const adaptiveCapturedImage = useAdaptiveImageSrc(nodeData.capturedImage, id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const fileInputRef = useRef(null); const captureRef = useRef<(() => string | null) | null>(null); @@ -478,7 +480,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps Captured 3D render diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index bb52d5c1..91a11495 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -15,6 +15,7 @@ import { ProviderBadge } from "./ProviderBadge"; import { useInlineParameters } from "@/hooks/useInlineParameters"; import { InlineParameterPanel } from "./InlineParameterPanel"; import { browseRegistry } from "@/utils/browseRegistry"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; /** Reorder items so they read column-first in a row-based CSS grid. * e.g. [1,2,3,4,5,6,7,8] with 2 cols → [1,5,2,6,3,7,4,8] */ @@ -54,6 +55,7 @@ type NanoBananaNodeType = Node; export function GenerateImageNode({ id, data, selected }: NodeProps) { const nodeData = data; + const adaptiveOutputImage = useAdaptiveImageSrc(data.outputImage, id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const generationsPath = useWorkflowStore((state) => state.generationsPath); // Use stable selector for API keys to prevent unnecessary re-fetches @@ -684,7 +686,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps Generated diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index efc6faf1..00a27203 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -6,11 +6,13 @@ import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useWorkflowStore } from "@/store/workflowStore"; import { ImageInputNodeData } from "@/types"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; type ImageInputNodeType = Node; export function ImageInputNode({ id, data, selected }: NodeProps) { const nodeData = data; + const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id); const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const fileInputRef = useRef(null); @@ -109,7 +111,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps {nodeData.filename diff --git a/src/components/nodes/OutputGalleryNode.tsx b/src/components/nodes/OutputGalleryNode.tsx index b4664487..ab2e9e89 100644 --- a/src/components/nodes/OutputGalleryNode.tsx +++ b/src/components/nodes/OutputGalleryNode.tsx @@ -6,6 +6,18 @@ import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { OutputGalleryNodeData } from "@/types"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; + +function AdaptiveGalleryThumbnail({ src, alt, nodeId }: { src: string; alt: string; nodeId: string }) { + const adaptiveSrc = useAdaptiveImageSrc(src, nodeId); + return ( + {alt} + ); +} type OutputGalleryNodeType = Node; @@ -136,11 +148,7 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps openLightbox(idx)} className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors" > - {`Image + ))} diff --git a/src/components/nodes/OutputNode.tsx b/src/components/nodes/OutputNode.tsx index fdf1ca99..2a55dc0b 100644 --- a/src/components/nodes/OutputNode.tsx +++ b/src/components/nodes/OutputNode.tsx @@ -8,6 +8,7 @@ import { useWorkflowStore } from "@/store/workflowStore"; import { OutputNodeData } from "@/types"; import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl"; import { useVideoAutoplay } from "@/hooks/useVideoAutoplay"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; type OutputNodeType = Node; @@ -49,6 +50,8 @@ export function OutputNode({ id, data, selected }: NodeProps) { return nodeData.image; }, [nodeData.audio, nodeData.video, nodeData.image]); + const imageSrc = !isAudio && !isVideo ? contentSrc : null; + const adaptiveImage = useAdaptiveImageSrc(imageSrc, id); const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null); // Auto-trigger execution when a new connection is made @@ -162,7 +165,7 @@ export function OutputNode({ id, data, selected }: NodeProps) { /> ) : ( Output diff --git a/src/components/nodes/SplitGridNode.tsx b/src/components/nodes/SplitGridNode.tsx index 96fda3a9..346b6af5 100644 --- a/src/components/nodes/SplitGridNode.tsx +++ b/src/components/nodes/SplitGridNode.tsx @@ -6,11 +6,13 @@ import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { SplitGridNodeData } from "@/types"; import { SplitGridSettingsModal } from "../SplitGridSettingsModal"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; type SplitGridNodeType = Node; export function SplitGridNode({ id, data, selected }: NodeProps) { const nodeData = data; + const adaptiveSourceImage = useAdaptiveImageSrc(nodeData.sourceImage, id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); @@ -67,7 +69,7 @@ export function SplitGridNode({ id, data, selected }: NodeProps Source grid diff --git a/src/components/nodes/VideoFrameGrabNode.tsx b/src/components/nodes/VideoFrameGrabNode.tsx index b691a068..e637db40 100644 --- a/src/components/nodes/VideoFrameGrabNode.tsx +++ b/src/components/nodes/VideoFrameGrabNode.tsx @@ -5,11 +5,13 @@ import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { VideoFrameGrabNodeData } from "@/types"; +import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; type VideoFrameGrabNodeType = Node; export function VideoFrameGrabNode({ id, data, selected }: NodeProps) { const nodeData = data; + const adaptiveOutputImage = useAdaptiveImageSrc(nodeData.outputImage, id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const isRunning = useWorkflowStore((state) => state.isRunning); @@ -83,7 +85,7 @@ export function VideoFrameGrabNode({ id, data, selected }: NodeProps Extracted frame diff --git a/src/hooks/useAdaptiveImageSrc.ts b/src/hooks/useAdaptiveImageSrc.ts new file mode 100644 index 00000000..17519d65 --- /dev/null +++ b/src/hooks/useAdaptiveImageSrc.ts @@ -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)?.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(null); + const prevSrcRef = useRef(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; +} diff --git a/src/store/thumbnailCache.ts b/src/store/thumbnailCache.ts new file mode 100644 index 00000000..b2bd19ec --- /dev/null +++ b/src/store/thumbnailCache.ts @@ -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(); +const pending = new Map>(); + +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 | undefined { + return pending.get(cacheKey(src)); +} + +export function setPending(src: string, promise: Promise): void { + pending.set(cacheKey(src), promise); +} + +export function removePending(src: string): void { + pending.delete(cacheKey(src)); +} diff --git a/src/utils/imageThumbnail.ts b/src/utils/imageThumbnail.ts new file mode 100644 index 00000000..ba258776 --- /dev/null +++ b/src/utils/imageThumbnail.ts @@ -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 { + 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; + }); +}