feat(canvas): add resolution label and bottom action rails
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-03 23:50:09 +08:00
parent 02f3ac4023
commit dba2460c43
4 changed files with 566 additions and 161 deletions

View File

@ -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",
},
});
}
}

View File

@ -683,139 +683,142 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
</div>
{/* 5. Floating Bottom Toolbar (Left) */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-20">
<div className="flex flex-col items-center gap-2 p-1.5 rounded-2xl border bg-background/80 backdrop-blur-md shadow-2xl">
{/* Tools: Select, Pan, Add Text, Add Image */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'select' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setTool('select')}
className={cn("rounded-xl h-9 w-9", tool === 'select' && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">选择 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'pan' ? 'secondary' : 'ghost'}
size="icon"
onClick={() => setTool('pan')}
className={cn("rounded-xl h-9 w-9", tool === 'pan' && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">平移 (H)</TooltipContent>
</Tooltip>
{/* 5. Floating Toolbar (Bottom) */}
<div className="absolute bottom-5 left-1/2 z-20 -translate-x-1/2">
<div className="flex items-center gap-1.5 rounded-[28px] border bg-background/88 px-3 py-2 shadow-2xl backdrop-blur-md">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === "select" ? "secondary" : "ghost"}
size="icon"
onClick={() => setTool("select")}
className={cn("h-10 w-10 rounded-2xl", tool === "select" && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">选择 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === "pan" ? "secondary" : "ghost"}
size="icon"
onClick={() => setTool("pan")}
className={cn("h-10 w-10 rounded-2xl", tool === "pan" && "bg-primary text-primary-foreground hover:bg-primary/90")}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">平移 (H)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAddImageNode}
className="h-10 w-10 rounded-2xl"
>
<ImagePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加图片</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAddTextNode}
className="h-10 w-10 rounded-2xl"
>
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加文本</TooltipContent>
</Tooltip>
{siteSettingsData?.realtimeCanvasEnabled === true &&
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/canvas?new=1')}
className="rounded-xl h-9 w-9"
onClick={() => router.push(`/canvas/${id}/realtime`)}
className="h-10 w-10 rounded-2xl"
>
<Plus className="h-4 w-4" />
<PenTool className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">新建画布</TooltipContent>
<TooltipContent side="top">实时画布</TooltipContent>
</Tooltip>
) : null}
<Separator orientation="horizontal" className="w-5 my-0.5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/model3d`)}
className="h-10 w-10 rounded-2xl"
>
<Box className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">3D生成</TooltipContent>
</Tooltip>
{/* 常用工具 */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleAddTextNode} className="rounded-xl h-9 w-9">
<Type className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">添加文本</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/canvas?new=1')}
className="h-10 w-10 rounded-2xl"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">新建画布</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleAddImageNode} className="rounded-xl h-9 w-9">
<ImagePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">添加图片</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-1 h-6" />
{siteSettingsData?.realtimeCanvasEnabled === true &&
siteSettingsData?.realtimeCanvasServiceEnabled !== false ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/realtime`)}
className="rounded-xl h-9 w-9"
>
<PenTool className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">实时画布</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={undo} disabled={!canUndo} className="h-10 w-10 rounded-2xl">
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/canvas/${id}/model3d`)}
className="rounded-xl h-9 w-9"
>
<Box className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">3D生成</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={redo} disabled={!canRedo} className="h-10 w-10 rounded-2xl">
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">重做</TooltipContent>
</Tooltip>
<Separator orientation="horizontal" className="w-5 my-0.5" />
<Separator orientation="vertical" className="mx-1 h-6" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={undo} disabled={!canUndo} className="rounded-xl h-9 w-9">
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">撤销</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={redo} disabled={!canRedo} className="rounded-xl h-9 w-9">
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">重做</TooltipContent>
</Tooltip>
<Separator orientation="horizontal" className="w-5 my-0.5" />
{/* Zoom Controls */}
<div className="flex flex-col items-center gap-1">
<Button variant="ghost" size="icon" onClick={zoomIn} className="h-8 w-8 rounded-lg">
<ZoomIn className="h-3.5 w-3.5" />
</Button>
<span className="text-[10px] w-10 text-center tabular-nums font-medium">
{Math.round(viewport.zoom * 100)}%
</span>
<Button variant="ghost" size="icon" onClick={zoomOut} className="h-8 w-8 rounded-lg">
<ZoomOut className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button variant="ghost" size="icon" onClick={zoomOut} className="h-10 w-10 rounded-2xl">
<ZoomOut className="h-4 w-4" />
</Button>
<span className="w-14 text-center text-sm tabular-nums font-medium text-muted-foreground">
{Math.round(viewport.zoom * 100)}%
</span>
<Button variant="ghost" size="icon" onClick={zoomIn} className="h-10 w-10 rounded-2xl">
<ZoomIn className="h-4 w-4" />
</Button>
</div>
</div>
{/* 6. 物品拾取模式覆盖层 */}

View File

@ -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 && (
<div className="absolute -top-7 left-0 right-0 flex items-center text-xs text-muted-foreground px-1 z-10">
<div className="flex items-center gap-1 min-w-0">
<div className="absolute -top-7 left-0 right-0 z-10 flex items-center justify-between px-1 text-xs text-[#2f7df6]">
<div className="flex min-w-0 items-center gap-1">
<ImagePlus className="h-3 w-3" />
{isEditingTitle ? (
<Input
@ -1585,7 +1650,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
/>
) : (
<span
className="truncate max-w-[150px] cursor-text"
className="max-w-[150px] cursor-text truncate"
title="双击重命名"
onDoubleClick={(e) => {
e.stopPropagation();
@ -1597,6 +1662,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</span>
)}
</div>
{resolutionLabel ? (
<span className="ml-2 shrink-0 tabular-nums tracking-wide">{resolutionLabel}</span>
) : null}
</div>
)}
@ -1636,10 +1704,17 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
>
<div className="absolute inset-0 bg-muted/30" />
<img
src={data.src}
src={displayImageSrc}
alt={displayTitle}
className="absolute inset-0 h-full w-full object-contain"
draggable={false}
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== data.src) {
event.currentTarget.src = data.src;
}
}}
/>
</div>
<div
@ -1650,10 +1725,17 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
>
<div className="absolute inset-0 bg-muted/30" />
<img
src={compareSource}
src={displayCompareSrc}
alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-contain"
draggable={false}
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = compareSource;
}
}}
/>
</div>
<div
@ -1676,13 +1758,20 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
</div>
) : (
<img
src={data.src}
src={displayImageSrc}
alt={displayTitle}
className={cn(
"w-full h-full object-contain bg-muted/30",
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
)}
draggable={false}
loading="lazy"
decoding="async"
onError={(event) => {
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(
<TooltipProvider delayDuration={200}>
<div
className="fixed z-[9999] flex items-center gap-1 rounded-[22px] border border-slate-300 bg-slate-50/95 px-3 py-2 text-gray-800 shadow-[0_8px_24px_rgba(15,23,42,0.14)] backdrop-blur-sm dark:border-slate-600 dark:bg-slate-900/95 dark:text-gray-100"
className="fixed z-[9999] flex items-center rounded-[24px] border border-slate-200 bg-white/95 px-2 py-2 text-gray-800 shadow-[0_14px_38px_rgba(15,23,42,0.16)] backdrop-blur-md dark:border-slate-700 dark:bg-slate-900/95 dark:text-gray-100"
style={{
top: getToolbarPosition().top,
left: getToolbarPosition().left,
@ -1976,82 +2065,93 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
disabled={isProcessing || isAddingToChat}
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={handleAddToChat}
>
{isAddingToChat ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<MessageSquarePlus className="mr-1.5 h-4 w-4 text-blue-600" />
)}
对话
</Button>
<div className="mr-1 inline-flex h-10 items-center gap-1.5 rounded-xl bg-slate-900 px-3 text-[13px] font-semibold text-white dark:bg-slate-100 dark:text-slate-900">
<Sparkles className="h-4 w-4" />
<span>快捷编辑</span>
<span className="text-white/55 dark:text-slate-500">Tab</span>
</div>
<div className="mx-1 h-5 w-px bg-slate-200 dark:bg-slate-700" />
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button
variant="ghost"
disabled={isProcessing}
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('enhance')}
>
<span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] leading-5 text-slate-600 dark:border-slate-500 dark:text-slate-100">HD</span>
增强
放大
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('rmbg')}
>
<Scissors className="mr-1.5 h-4 w-4" />
抠图
去背景
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('scene-fusion')}
>
<Sparkles className="mr-1.5 h-4 w-4" />
Mockup
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('watermark')}
>
<Eraser className="mr-1.5 h-4 w-4" />
去印
橡皮工具
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => {
setPickerMode(true, node.id);
toast.info('点击图片中的物品进行拾取');
}}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('resize')}
>
<Crosshair className="mr-1.5 h-4 w-4 text-orange-600" />
拾物
<Maximize2 className="mr-1.5 h-4 w-4" />
编辑元素
</Button>
<Button
variant="ghost"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
className="h-9 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('text-edit')}
>
<Type className="mr-1.5 h-4 w-4" />
改字
编辑文字
</Button>
<Button
variant="ghost"
disabled={isProcessing}
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={() => handleAction('angle')}
>
<Move3D className="mr-1.5 h-4 w-4" />
多角度
</Button>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-9 w-9 rounded-xl text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@ -2065,18 +2165,25 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
<RefreshCw className="mr-2 h-4 w-4" />
重绘
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('angle')}>
<Move3D className="mr-2 h-4 w-4" />
视角
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('resize')}>
<Maximize2 className="mr-2 h-4 w-4" />
调尺
<DropdownMenuItem onClick={() => {
setPickerMode(true, node.id);
toast.info('点击图片中的物品进行拾取');
}}>
<Crosshair className="mr-2 h-4 w-4" />
拾物
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('note')}>
<StickyNote className="mr-2 h-4 w-4" />
备注
</DropdownMenuItem>
<DropdownMenuItem disabled={isAddingToChat} onClick={handleAddToChat}>
{isAddingToChat ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MessageSquarePlus className="mr-2 h-4 w-4" />
)}
对话
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
@ -2092,7 +2199,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
variant="ghost"
size="icon"
disabled={isProcessing}
className="h-9 w-9 rounded-xl text-slate-700 hover:bg-white hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
onClick={handleDownload}
>
<Download className="h-4 w-4" />

56
lib/image-delivery.ts Normal file
View File

@ -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()}`;
}