diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts
new file mode 100644
index 000000000..19e041faf
--- /dev/null
+++ b/app/api/image-proxy/route.ts
@@ -0,0 +1,239 @@
+import crypto from "node:crypto";
+import { NextRequest, NextResponse } from "next/server";
+
+export const runtime = "nodejs";
+
+const DEFAULT_QUALITY = 82;
+const MAX_WIDTH = 4096;
+const MIN_WIDTH = 64;
+const MIN_QUALITY = 40;
+const MAX_QUALITY = 100;
+const FETCH_TIMEOUT_MS = 15000;
+
+type OutputFormat = "webp" | "avif" | "jpeg" | "png";
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+function parsePositiveInt(value: string | null): number | null {
+ if (!value) return null;
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+}
+
+function isPrivateIpv4(hostname: string): boolean {
+ const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
+ return false;
+ }
+
+ if (parts[0] === 10) return true;
+ if (parts[0] === 127) return true;
+ if (parts[0] === 169 && parts[1] === 254) return true;
+ if (parts[0] === 192 && parts[1] === 168) return true;
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
+ return false;
+}
+
+function isBlockedHost(hostname: string): boolean {
+ const normalized = hostname.trim().toLowerCase();
+ if (!normalized) return true;
+
+ if (
+ normalized === "localhost" ||
+ normalized === "::1" ||
+ normalized.endsWith(".local") ||
+ normalized.endsWith(".internal")
+ ) {
+ return true;
+ }
+
+ if (isPrivateIpv4(normalized)) {
+ return true;
+ }
+
+ if (normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80")) {
+ return true;
+ }
+
+ return false;
+}
+
+function inferSourceFormat(contentType: string): OutputFormat {
+ const lower = contentType.toLowerCase();
+ if (lower.includes("avif")) return "avif";
+ if (lower.includes("webp")) return "webp";
+ if (lower.includes("png")) return "png";
+ return "jpeg";
+}
+
+function resolveOutputFormat(
+ requested: string | null,
+ sourceContentType: string,
+ hasAlpha: boolean | undefined
+): OutputFormat {
+ if (requested === "avif" || requested === "webp" || requested === "jpeg" || requested === "png") {
+ if (requested === "jpeg" && hasAlpha) {
+ return "webp";
+ }
+ return requested;
+ }
+
+ if (hasAlpha) {
+ return "webp";
+ }
+ return inferSourceFormat(sourceContentType);
+}
+
+function buildEtag(input: string): string {
+ return `"${crypto.createHash("sha1").update(input).digest("hex")}"`;
+}
+
+export async function GET(request: NextRequest) {
+ const { searchParams } = request.nextUrl;
+ const rawUrl = (searchParams.get("url") || "").trim();
+ if (!rawUrl) {
+ return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
+ }
+
+ let target: URL;
+ try {
+ target = new URL(rawUrl);
+ } catch {
+ return NextResponse.json({ error: "Invalid image url" }, { status: 400 });
+ }
+
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
+ return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
+ }
+
+ if (isBlockedHost(target.hostname)) {
+ return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
+ }
+
+ const requestedWidth = parsePositiveInt(searchParams.get("w"));
+ const requestedQuality = parsePositiveInt(searchParams.get("q"));
+ const width = requestedWidth ? clamp(requestedWidth, MIN_WIDTH, MAX_WIDTH) : null;
+ const quality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : DEFAULT_QUALITY;
+ const requestedFormat = searchParams.get("f");
+
+ const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
+ const etag = buildEtag(cacheKey);
+ const ifNoneMatch = request.headers.get("if-none-match");
+ if (ifNoneMatch && ifNoneMatch === etag) {
+ return new NextResponse(null, {
+ status: 304,
+ headers: {
+ ETag: etag,
+ "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
+ },
+ });
+ }
+
+ let upstreamResponse: Response;
+ try {
+ upstreamResponse = await fetch(target.toString(), {
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+ headers: {
+ Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
+ },
+ });
+ } catch (error) {
+ console.error("[ImageProxy] Upstream fetch failed:", error);
+ return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 });
+ }
+
+ if (!upstreamResponse.ok) {
+ return NextResponse.json(
+ { error: `Upstream responded with ${upstreamResponse.status}` },
+ { status: 502 }
+ );
+ }
+
+ const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
+ if (!contentType.toLowerCase().startsWith("image/")) {
+ return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
+ }
+
+ const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
+ const isSvg = contentType.toLowerCase().includes("image/svg");
+ const isGif = contentType.toLowerCase().includes("image/gif");
+
+ if ((isSvg || isGif) && !width && !requestedFormat) {
+ return new NextResponse(originalBuffer, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ "Content-Length": String(originalBuffer.length),
+ ETag: etag,
+ "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
+ },
+ });
+ }
+
+ if (isSvg || isGif) {
+ return new NextResponse(originalBuffer, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ "Content-Length": String(originalBuffer.length),
+ ETag: etag,
+ "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
+ },
+ });
+ }
+
+ try {
+ const sharp = await import("sharp");
+ const image = sharp.default(originalBuffer, { failOn: "none" }).rotate();
+ const metadata = await image.metadata();
+ const outputFormat = resolveOutputFormat(requestedFormat, contentType, metadata.hasAlpha);
+ const transformed = width
+ ? image.resize({
+ width,
+ fit: "inside",
+ withoutEnlargement: true,
+ })
+ : image;
+
+ let outputBuffer: Buffer;
+ let outputContentType = contentType;
+
+ if (outputFormat === "avif") {
+ outputBuffer = await transformed.avif({ quality }).toBuffer();
+ outputContentType = "image/avif";
+ } else if (outputFormat === "webp") {
+ outputBuffer = await transformed.webp({ quality }).toBuffer();
+ outputContentType = "image/webp";
+ } else if (outputFormat === "png") {
+ outputBuffer = await transformed.png({ compressionLevel: 9 }).toBuffer();
+ outputContentType = "image/png";
+ } else {
+ outputBuffer = await transformed.jpeg({ quality, mozjpeg: true }).toBuffer();
+ outputContentType = "image/jpeg";
+ }
+
+ return new NextResponse(outputBuffer, {
+ status: 200,
+ headers: {
+ "Content-Type": outputContentType,
+ "Content-Length": String(outputBuffer.length),
+ ETag: etag,
+ Vary: "Accept",
+ "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
+ },
+ });
+ } catch (error) {
+ console.error("[ImageProxy] Transform failed:", error);
+ return new NextResponse(originalBuffer, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ "Content-Length": String(originalBuffer.length),
+ ETag: etag,
+ "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
+ },
+ });
+ }
+}
diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx
index 1209a1f2e..ec9fd4c45 100644
--- a/app/canvas/[id]/page.tsx
+++ b/app/canvas/[id]/page.tsx
@@ -683,139 +683,142 @@ export default function CanvasEditorPage({ params }: PageProps) {
- {/* 5. Floating Bottom Toolbar (Left) */}
-
-
- {/* Tools: Select, Pan, Add Text, Add Image */}
-
-
-
-
- 选择 (V)
-
-
-
-
-
-
- 平移 (H)
-
+ {/* 5. Floating Toolbar (Bottom) */}
+
+
+
+
+
+
+ 选择 (V)
+
+
+
+
+
+ 平移 (H)
+
+
+
+
+
+
+ 添加图片
+
+
+
+
+
+
+ 添加文本
+
+
+ {siteSettingsData?.realtimeCanvasEnabled === true &&
+ siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
- 新建画布
+ 实时画布
+ ) : null}
-
+
+
+
+
+ 3D生成
+
- {/* 常用工具 */}
-
-
-
-
- 添加文本
-
+
+
+
+
+ 新建画布
+
-
-
-
-
- 添加图片
-
+
- {siteSettingsData?.realtimeCanvasEnabled === true &&
- siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
-
-
-
-
- 实时画布
-
- ) : null}
+
+
+
+
+ 撤销
+
-
-
-
-
- 3D生成
-
+
+
+
+
+ 重做
+
-
+
-
-
-
-
- 撤销
-
-
-
-
-
-
- 重做
-
-
-
-
- {/* Zoom Controls */}
-
-
-
- {Math.round(viewport.zoom * 100)}%
-
-
-
-
+
+
+ {Math.round(viewport.zoom * 100)}%
+
+
+
{/* 6. 物品拾取模式覆盖层 */}
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx
index 5891a1e45..4421fcd43 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -29,6 +29,7 @@ import type { CanvasTextEditPayloadItem } from "@/services/canvas";
import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { inferOriginalImageUrl } from "@/lib/image-url";
+import { buildImageProxyUrl } from "@/lib/image-delivery";
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ImageAnnotator } from "@/components/image-annotator";
@@ -373,6 +374,44 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
() => Math.max(6, Math.min(10, Math.round(compareButtonVisualSize * 0.22))),
[compareButtonVisualSize]
);
+ const displayImageWidth = useMemo(
+ () => Math.max(640, Math.min(2200, Math.round((node.size.width || 320) * 2))),
+ [node.size.width]
+ );
+ const displayImageSrc = useMemo(
+ () =>
+ buildImageProxyUrl(data.src, {
+ width: displayImageWidth,
+ quality: 86,
+ format: "webp",
+ }),
+ [data.src, displayImageWidth]
+ );
+ const displayCompareSrc = useMemo(
+ () =>
+ buildImageProxyUrl(compareSource, {
+ width: displayImageWidth,
+ quality: 86,
+ format: "webp",
+ }),
+ [compareSource, displayImageWidth]
+ );
+ const [loadedResolution, setLoadedResolution] = useState<{ width: number; height: number } | null>(null);
+ const metadataResolution = useMemo(() => {
+ const rawWidth = Number((data.metadata as any)?.originalWidth ?? 0);
+ const rawHeight = Number((data.metadata as any)?.originalHeight ?? 0);
+ const width = Number.isFinite(rawWidth) ? Math.round(rawWidth) : 0;
+ const height = Number.isFinite(rawHeight) ? Math.round(rawHeight) : 0;
+ if (width > 0 && height > 0) {
+ return { width, height };
+ }
+ return null;
+ }, [data.metadata]);
+ const resolutionLabel = useMemo(() => {
+ const source = metadataResolution || loadedResolution;
+ if (!source) return "";
+ return `${source.width} x ${source.height}`;
+ }, [loadedResolution, metadataResolution]);
// 退出拾取模式时清空状态
useEffect(() => {
@@ -403,6 +442,32 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
}
}, [hasCompareSource, isThisNodePicking]);
+ useEffect(() => {
+ setLoadedResolution(null);
+ if (!isSelected || !data.src || metadataResolution) return;
+
+ let canceled = false;
+ const img = new Image();
+ img.onload = () => {
+ if (canceled) return;
+ const width = Math.round(img.naturalWidth || 0);
+ const height = Math.round(img.naturalHeight || 0);
+ if (width > 0 && height > 0) {
+ setLoadedResolution({ width, height });
+ }
+ };
+ img.onerror = () => {
+ if (!canceled) {
+ setLoadedResolution(null);
+ }
+ };
+ img.src = data.src;
+
+ return () => {
+ canceled = true;
+ };
+ }, [data.src, isSelected, metadataResolution]);
+
const updateCompareSplitByClientX = useCallback((clientX: number) => {
const frame = compareFrameRef.current;
if (!frame) return;
@@ -1558,8 +1623,8 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<>
{/* 选中时在图片上方显示标题 */}
{isSelected && !isMultiSelected && data.src && (
-
-
+
+
{isEditingTitle ? (
) : (
{
e.stopPropagation();
@@ -1597,6 +1662,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
)}
+ {resolutionLabel ? (
+
{resolutionLabel}
+ ) : null}
)}
@@ -1636,10 +1704,17 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
>

{
+ if (event.currentTarget.src !== data.src) {
+ event.currentTarget.src = data.src;
+ }
+ }}
/>

{
+ if (event.currentTarget.src !== compareSource) {
+ event.currentTarget.src = compareSource;
+ }
+ }}
/>
) : (

{
+ if (event.currentTarget.src !== data.src) {
+ event.currentTarget.src = data.src;
+ }
+ }}
onClick={isThisNodePicking ? handlePickerClick : undefined}
onDoubleClick={(e) => {
if (!isThisNodePicking) {
@@ -1965,7 +2054,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
{isSelected && !isMultiSelected && data.src && !isEditingTitle && typeof document !== 'undefined' && createPortal(
e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
-
+
+
+ 快捷编辑
+ Tab
+
-
+
+
+
+
+
+
+
@@ -2065,18 +2165,25 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
重绘
- handleAction('angle')}>
-
- 视角
-
- handleAction('resize')}>
-
- 调尺
+ {
+ setPickerMode(true, node.id);
+ toast.info('点击图片中的物品进行拾取');
+ }}>
+
+ 拾物
handleAction('note')}>
备注
+
+ {isAddingToChat ? (
+
+ ) : (
+
+ )}
+ 对话
+
diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts
new file mode 100644
index 000000000..65dc5916f
--- /dev/null
+++ b/lib/image-delivery.ts
@@ -0,0 +1,56 @@
+const IMAGE_PROXY_PATH = "/api/image-proxy";
+
+type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png";
+
+export type ImageProxyOptions = {
+ width?: number;
+ quality?: number;
+ format?: ImageProxyFormat;
+};
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+function normalizeWidth(width?: number): number | undefined {
+ if (!Number.isFinite(width) || !width) return undefined;
+ return clamp(Math.round(width), 64, 4096);
+}
+
+function normalizeQuality(quality?: number): number | undefined {
+ if (!Number.isFinite(quality) || !quality) return undefined;
+ return clamp(Math.round(quality), 40, 100);
+}
+
+function isAbsoluteHttpUrl(url: string): boolean {
+ return /^https?:\/\//i.test(url);
+}
+
+function isBypassUrl(url: string): boolean {
+ return (
+ !url ||
+ url.startsWith("data:") ||
+ url.startsWith("blob:") ||
+ url.startsWith(IMAGE_PROXY_PATH) ||
+ !isAbsoluteHttpUrl(url)
+ );
+}
+
+export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
+ const source = String(url || "").trim();
+ if (isBypassUrl(source)) {
+ return source;
+ }
+
+ const width = normalizeWidth(options.width);
+ const quality = normalizeQuality(options.quality);
+ const format = options.format;
+
+ const params = new URLSearchParams();
+ params.set("url", source);
+ if (width) params.set("w", String(width));
+ if (quality) params.set("q", String(quality));
+ if (format) params.set("f", format);
+
+ return `${IMAGE_PROXY_PATH}?${params.toString()}`;
+}