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) { >
{displayTitle} { + if (event.currentTarget.src !== data.src) { + event.currentTarget.src = data.src; + } + }} />
{`${displayTitle}-原图`} { + if (event.currentTarget.src !== compareSource) { + event.currentTarget.src = compareSource; + } + }} />
) : ( {displayTitle} { + 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()}`; +}