Browse Source

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
handoff-20260429-1057
Claude 4 months ago
parent
commit
1f12e8e7a1
Failed to extract signature
  1. 4
      src/components/nodes/AnnotationNode.tsx
  2. 4
      src/components/nodes/GLBViewerNode.tsx
  3. 4
      src/components/nodes/GenerateImageNode.tsx
  4. 4
      src/components/nodes/ImageInputNode.tsx
  5. 18
      src/components/nodes/OutputGalleryNode.tsx
  6. 5
      src/components/nodes/OutputNode.tsx
  7. 4
      src/components/nodes/SplitGridNode.tsx
  8. 4
      src/components/nodes/VideoFrameGrabNode.tsx
  9. 95
      src/hooks/useAdaptiveImageSrc.ts
  10. 34
      src/store/thumbnailCache.ts
  11. 45
      src/utils/imageThumbnail.ts

4
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<AnnotationNodeData, "annotation">;
@ -89,6 +90,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
}, [id, updateNodeData]);
const displayImage = nodeData.outputImage || nodeData.sourceImage;
const adaptiveDisplayImage = useAdaptiveImageSrc(displayImage, id);
return (
<BaseNode
@ -124,7 +126,7 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
onClick={handleEdit}
>
<img
src={displayImage}
src={adaptiveDisplayImage ?? undefined}
alt="Annotated"
className="w-full h-full object-contain"
/>

4
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<GLBViewerNodeType>) {
const nodeData = data as GLBViewerNodeData;
const adaptiveCapturedImage = useAdaptiveImageSrc(nodeData.capturedImage, id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const fileInputRef = useRef<HTMLInputElement>(null);
const captureRef = useRef<(() => string | null) | null>(null);
@ -478,7 +480,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
</button>
</div>
<img
src={nodeData.capturedImage}
src={adaptiveCapturedImage ?? undefined}
alt="Captured 3D render"
className="w-full rounded border border-neutral-700 bg-neutral-900"
/>

4
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<NanoBananaNodeData, "nanoBanana">;
export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNodeType>) {
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<NanoBananaNo
{nodeData.outputImage ? (
<>
<img
src={nodeData.outputImage}
src={adaptiveOutputImage ?? undefined}
alt="Generated"
className="w-full h-full object-cover"
/>

4
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<ImageInputNodeData, "imageInput">;
export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeType>) {
const nodeData = data;
const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id);
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -109,7 +111,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
{nodeData.image ? (
<div className="relative group w-full h-full">
<img
src={nodeData.image}
src={adaptiveImage ?? undefined}
alt={nodeData.filename || "Uploaded image"}
className="w-full h-full object-cover rounded-lg"
/>

18
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 (
<img
src={adaptiveSrc ?? undefined}
alt={alt}
className="w-full h-full object-cover"
/>
);
}
type OutputGalleryNodeType = Node<OutputGalleryNodeData, "outputGallery">;
@ -136,11 +148,7 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
onClick={() => openLightbox(idx)}
className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors"
>
<img
src={img}
alt={`Image ${idx + 1}`}
className="w-full h-full object-cover"
/>
<AdaptiveGalleryThumbnail src={img} alt={`Image ${idx + 1}`} nodeId={id} />
</button>
))}
</div>

5
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<OutputNodeData, "output">;
@ -49,6 +50,8 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
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<OutputNodeType>) {
/>
) : (
<img
src={contentSrc}
src={adaptiveImage ?? contentSrc}
alt="Output"
className="w-full h-full object-cover"
/>

4
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<SplitGridNodeData, "splitGrid">;
export function SplitGridNode({ id, data, selected }: NodeProps<SplitGridNodeType>) {
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<SplitGridNodeTyp
{nodeData.sourceImage ? (
<div className="relative w-full h-full">
<img
src={nodeData.sourceImage}
src={adaptiveSourceImage ?? undefined}
alt="Source grid"
className="w-full h-full object-contain rounded-lg"
/>

4
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<VideoFrameGrabNodeData, "videoFrameGrab">;
export function VideoFrameGrabNode({ id, data, selected }: NodeProps<VideoFrameGrabNodeType>) {
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<VideoFrameG
{nodeData.outputImage ? (
<>
<img
src={nodeData.outputImage}
src={adaptiveOutputImage ?? undefined}
className="absolute inset-0 w-full h-full object-contain rounded"
alt="Extracted frame"
/>

95
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<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;
}

34
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<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));
}

45
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<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…
Cancel
Save