feat(canvas): add resolution label and bottom action rails
This commit is contained in:
parent
02f3ac4023
commit
dba2460c43
239
app/api/image-proxy/route.ts
Normal file
239
app/api/image-proxy/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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. 物品拾取模式覆盖层 */}
|
||||
|
||||
@ -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
56
lib/image-delivery.ts
Normal 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()}`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user