2111 lines
82 KiB
TypeScript
2111 lines
82 KiB
TypeScript
"use client";
|
||
|
||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useTheme } from "next-themes";
|
||
import { X, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D, Layers, Upload as UploadIcon, Image as ImageIcon, Droplet, Redo, MousePointer2, ChevronDown } from "lucide-react";
|
||
import { Button } from "./ui/button";
|
||
import { Input } from "./ui/input";
|
||
import { Slider } from "./ui/slider";
|
||
import { toast } from "sonner";
|
||
import { AngleEditDialog } from "./angle-edit-dialog";
|
||
import { SceneFusionView } from "./scene-fusion-view";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
} from "@/components/ui/dialog";
|
||
import { cn } from "@/lib/utils";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import { getCanvasLaunchStorageKey, type CanvasLaunchAttachment } from "@/lib/canvas-launch";
|
||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||
|
||
type Tool = "pen" | "arrow" | "circle" | "text";
|
||
|
||
type DrawingAction = {
|
||
type: Tool;
|
||
color: string;
|
||
lineWidth: number;
|
||
points?: { x: number; y: number }[];
|
||
startX?: number;
|
||
startY?: number;
|
||
endX?: number;
|
||
endY?: number;
|
||
radius?: number;
|
||
text?: string;
|
||
fontSize?: number;
|
||
};
|
||
|
||
interface ImageAnnotatorProps {
|
||
imageUrl?: string;
|
||
onConfirm: (annotatedImageUrl: string) => void | Promise<void>;
|
||
onCancel: () => void;
|
||
onHomeClick?: () => void;
|
||
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
||
confirmButtonText?: string;
|
||
submitMode?: "confirm" | "publish-new-canvas";
|
||
appearance?: "theme" | "dark";
|
||
open?: boolean; // 兼容 Dialog 模式
|
||
onOpenChange?: (open: boolean) => void;
|
||
}
|
||
|
||
export function ImageAnnotator({
|
||
imageUrl,
|
||
onConfirm,
|
||
onCancel,
|
||
onHomeClick,
|
||
initialResizeMode = false,
|
||
confirmButtonText,
|
||
submitMode = "confirm",
|
||
appearance = "theme",
|
||
open = true,
|
||
onOpenChange,
|
||
}: ImageAnnotatorProps) {
|
||
const router = useRouter();
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
|
||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||
const [isDrawing, setIsDrawing] = useState(false);
|
||
const tool: Tool = "pen";
|
||
const color = "#FF0000";
|
||
const lineWidth = 4;
|
||
const [actions, setActions] = useState<DrawingAction[]>([]);
|
||
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(null);
|
||
const [textInput, setTextInput] = useState("");
|
||
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
|
||
const [scale, setScale] = useState(1);
|
||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||
const [isLoading, setIsLoading] = useState(!!imageUrl); // 如果有 imageUrl 则加载中
|
||
const [isRemovingBg, setIsRemovingBg] = useState(false);
|
||
const [isEnhancing, setIsEnhancing] = useState(false);
|
||
const [isExtending, setIsExtending] = useState(false);
|
||
const [isRemovingWatermark, setIsRemovingWatermark] = useState(false);
|
||
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
|
||
const [showSceneFusion, setShowSceneFusion] = useState(false);
|
||
const [originalImageUrl, setOriginalImageUrl] = useState<string | undefined>(imageUrl);
|
||
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
|
||
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null);
|
||
const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | "resize" | "watermark" | null>(null);
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const objectUrlRef = useRef<string | null>(null);
|
||
const resolvedConfirmButtonText =
|
||
confirmButtonText ||
|
||
(submitMode === "publish-new-canvas" ? "发布到新的画布" : "发送到新对话");
|
||
const forceDarkAppearance = appearance === "dark";
|
||
const toCanvasLoadUrl = useCallback((url: string) => {
|
||
return buildImageProxyUrl(url, { delivery: "proxy" });
|
||
}, []);
|
||
|
||
const revokeObjectUrl = useCallback(() => {
|
||
if (objectUrlRef.current) {
|
||
URL.revokeObjectURL(objectUrlRef.current);
|
||
objectUrlRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
const createImageElement = useCallback((src: string, useCrossOrigin = true) => {
|
||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const img = new window.Image();
|
||
if (useCrossOrigin) {
|
||
img.crossOrigin = "anonymous";
|
||
}
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => reject(new Error(`图片加载失败: ${src}`));
|
||
img.src = src;
|
||
});
|
||
}, []);
|
||
|
||
const loadImageFromSource = useCallback(async (sourceUrl: string) => {
|
||
const normalizedSource = String(sourceUrl || "").trim();
|
||
if (!normalizedSource) {
|
||
throw new Error("图片地址为空");
|
||
}
|
||
|
||
const directUrl = normalizedSource;
|
||
const proxiedUrl = toCanvasLoadUrl(normalizedSource);
|
||
const candidateUrls = Array.from(
|
||
new Set([proxiedUrl, directUrl].filter((item): item is string => Boolean(item)))
|
||
);
|
||
|
||
for (const candidateUrl of candidateUrls) {
|
||
try {
|
||
const shouldUseCrossOrigin = !candidateUrl.startsWith("data:") && !candidateUrl.startsWith("blob:");
|
||
const imageElement = await createImageElement(candidateUrl, shouldUseCrossOrigin);
|
||
revokeObjectUrl();
|
||
return imageElement;
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
for (const candidateUrl of candidateUrls) {
|
||
try {
|
||
const response = await fetch(candidateUrl, { cache: "force-cache" });
|
||
if (!response.ok) {
|
||
continue;
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
const imageElement = await createImageElement(objectUrl, false);
|
||
revokeObjectUrl();
|
||
objectUrlRef.current = objectUrl;
|
||
return imageElement;
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
throw new Error("图片加载失败");
|
||
}, [createImageElement, revokeObjectUrl, toCanvasLoadUrl]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
revokeObjectUrl();
|
||
};
|
||
}, [revokeObjectUrl]);
|
||
|
||
// Handle open change
|
||
const handleOpenChange = (newOpen: boolean) => {
|
||
onOpenChange?.(newOpen);
|
||
if (!newOpen) {
|
||
onCancel();
|
||
}
|
||
};
|
||
|
||
const readUploadErrorMessage = useCallback(async (response: Response) => {
|
||
try {
|
||
const payload = await response.clone().json();
|
||
return String(payload?.details || payload?.error || "").trim() || "上传失败,请重试";
|
||
} catch {
|
||
return `上传失败(${response.status})`;
|
||
}
|
||
}, []);
|
||
|
||
const resetEditorStateForNewImage = useCallback(() => {
|
||
setProcessedImageUrl(null);
|
||
setProcessedImageBase64(null);
|
||
setProcessType(null);
|
||
setActions([]);
|
||
setCurrentAction(null);
|
||
setTextInput("");
|
||
setTextPosition(null);
|
||
setIsResizeMode(false);
|
||
}, []);
|
||
|
||
const loadImageFromFile = useCallback(
|
||
async (file: File) => {
|
||
revokeObjectUrl();
|
||
const objectUrl = URL.createObjectURL(file);
|
||
objectUrlRef.current = objectUrl;
|
||
return await createImageElement(objectUrl, false);
|
||
},
|
||
[createImageElement, revokeObjectUrl]
|
||
);
|
||
|
||
// 上传图片到服务器获取 URL 的辅助函数
|
||
const uploadImageFile = async (file: File): Promise<{ url: string; assetId?: string | null }> => {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
const response = await fetchUploadWithRetry(formData);
|
||
if (!response.ok) {
|
||
throw new Error(await readUploadErrorMessage(response));
|
||
}
|
||
const result = await response.json();
|
||
if (!result?.url) {
|
||
throw new Error("上传成功,但未返回可用图片地址");
|
||
}
|
||
return result;
|
||
};
|
||
|
||
const applyUploadedFile = useCallback(
|
||
async (file: File) => {
|
||
if (!file.type.startsWith("image/")) {
|
||
throw new Error("请上传图片文件");
|
||
}
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
throw new Error("图片大小不能超过 50MB");
|
||
}
|
||
|
||
setIsLoading(true);
|
||
toast.info("正在上传图片...");
|
||
|
||
try {
|
||
const localPreviewPromise = loadImageFromFile(file);
|
||
const uploadResult = await uploadImageFile(file);
|
||
|
||
resetEditorStateForNewImage();
|
||
setOriginalImageUrl(uploadResult.url);
|
||
|
||
try {
|
||
const localImage = await localPreviewPromise;
|
||
setImage(localImage);
|
||
} catch (previewError) {
|
||
console.warn("[ImageAnnotator] Local preview failed, fallback to uploaded url:", previewError);
|
||
const uploadedImage = await loadImageFromSource(uploadResult.url);
|
||
setImage(uploadedImage);
|
||
}
|
||
|
||
toast.success("图片上传成功");
|
||
} catch (error) {
|
||
console.error("[ImageAnnotator] 上传图片失败:", error);
|
||
const message = error instanceof Error ? error.message : "上传失败,请重试";
|
||
toast.error(message);
|
||
throw error;
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
},
|
||
[loadImageFromFile, loadImageFromSource, resetEditorStateForNewImage]
|
||
);
|
||
|
||
// 处理拖拽上传 - 先上传到服务器获取URL
|
||
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const files = e.dataTransfer.files;
|
||
if (files.length > 0) {
|
||
try {
|
||
await applyUploadedFile(files[0]);
|
||
} catch {}
|
||
}
|
||
}, [applyUploadedFile]);
|
||
|
||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, []);
|
||
|
||
// 处理粘贴上传 - 先上传到服务器获取URL
|
||
useEffect(() => {
|
||
if (!mounted || originalImageUrl) return;
|
||
|
||
const handlePaste = async (e: ClipboardEvent) => {
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
|
||
for (const item of items) {
|
||
if (item.type.startsWith('image/')) {
|
||
const file = item.getAsFile();
|
||
if (!file) continue;
|
||
try {
|
||
await applyUploadedFile(file);
|
||
} catch {}
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
document.addEventListener('paste', handlePaste);
|
||
return () => document.removeEventListener('paste', handlePaste);
|
||
}, [applyUploadedFile, mounted, originalImageUrl]);
|
||
|
||
// 尺寸调整相关状态
|
||
const [isResizeMode, setIsResizeMode] = useState(initialResizeMode);
|
||
const [resizeCanvasWidth, setResizeCanvasWidth] = useState<number>(1920);
|
||
const [resizeCanvasHeight, setResizeCanvasHeight] = useState<number>(1080);
|
||
const [resizeBackgroundColor, setResizeBackgroundColor] = useState<string>("#FFFFFF");
|
||
const [resizeBackgroundTransparent, setResizeBackgroundTransparent] = useState<boolean>(false);
|
||
const [resizeImageScale, setResizeImageScale] = useState<number>(1);
|
||
const [resizeImagePosition, setResizeImagePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||
const [isDraggingResize, setIsDraggingResize] = useState(false);
|
||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
|
||
const [selectedAspectRatio, setSelectedAspectRatio] = useState<string | null>(null); // 选中的比例,null表示自定义
|
||
const [isResizing, setIsResizing] = useState(false); // 尺寸调整处理中状态
|
||
const [resizeRatioPickerOpen, setResizeRatioPickerOpen] = useState(false);
|
||
const resizeCanvasRef = useRef<HTMLCanvasElement>(null);
|
||
const resizeContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 网站设置相关状态
|
||
const [siteName, setSiteName] = useState<string>("AI Chatbot");
|
||
const [siteLogo, setSiteLogo] = useState<string | null>(null);
|
||
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
|
||
const { resolvedTheme } = useTheme();
|
||
|
||
// 比例选项定义
|
||
const aspectRatios = [
|
||
{ label: "智能", value: "smart", ratio: null }, // 智能:根据原图比例
|
||
{ label: "1:1", value: "1:1", ratio: 1 / 1 },
|
||
{ label: "16:9", value: "16:9", ratio: 16 / 9 },
|
||
{ label: "9:16", value: "9:16", ratio: 9 / 16 },
|
||
{ label: "4:3", value: "4:3", ratio: 4 / 3 },
|
||
{ label: "3:4", value: "3:4", ratio: 3 / 4 },
|
||
{ label: "3:2", value: "3:2", ratio: 3 / 2 },
|
||
{ label: "2:3", value: "2:3", ratio: 2 / 3 },
|
||
{ label: "5:4", value: "5:4", ratio: 5 / 4 },
|
||
{ label: "4:5", value: "4:5", ratio: 4 / 5 },
|
||
{ label: "21:9", value: "21:9", ratio: 21 / 9 },
|
||
];
|
||
const resizeQuickPresets = [
|
||
{ label: "主图 1440×1440", width: 1440, height: 1440, aspectRatioValue: "1:1" },
|
||
{ label: "主图 1440×1920", width: 1440, height: 1920, aspectRatioValue: "3:4" },
|
||
] as const;
|
||
|
||
// 根据比例和宽度计算高度
|
||
const calculateHeightFromWidth = (width: number, ratio: number | null) => {
|
||
if (ratio === null) {
|
||
// 智能模式:使用原图比例
|
||
if (image) {
|
||
return Math.round(width * (image.height / image.width));
|
||
}
|
||
return resizeCanvasHeight; // 如果没有原图,保持当前高度
|
||
}
|
||
return Math.round(width / ratio);
|
||
};
|
||
|
||
// 处理宽度变化
|
||
const handleWidthChange = (newWidth: number) => {
|
||
setResizeCanvasWidth(newWidth);
|
||
if (selectedAspectRatio) {
|
||
const ratioConfig = aspectRatios.find((r) => r.value === selectedAspectRatio);
|
||
if (ratioConfig) {
|
||
const newHeight = calculateHeightFromWidth(newWidth, ratioConfig.ratio);
|
||
setResizeCanvasHeight(newHeight);
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理高度变化(如果用户手动修改高度,取消比例锁定)
|
||
const handleHeightChange = (newHeight: number) => {
|
||
setResizeCanvasHeight(newHeight);
|
||
// 如果用户手动修改高度,取消比例锁定
|
||
if (selectedAspectRatio) {
|
||
setSelectedAspectRatio(null);
|
||
}
|
||
};
|
||
|
||
// 处理比例选择
|
||
const handleAspectRatioSelect = (value: string | null) => {
|
||
setSelectedAspectRatio(value);
|
||
if (value) {
|
||
const ratioConfig = aspectRatios.find((r) => r.value === value);
|
||
if (ratioConfig) {
|
||
const newHeight = calculateHeightFromWidth(resizeCanvasWidth, ratioConfig.ratio);
|
||
setResizeCanvasHeight(newHeight);
|
||
}
|
||
}
|
||
};
|
||
|
||
const currentAspectRatioLabel =
|
||
aspectRatios.find((ratio) => ratio.value === selectedAspectRatio)?.label ||
|
||
(selectedAspectRatio ? selectedAspectRatio : "自定义");
|
||
|
||
const handleResizeQuickPresetSelect = (
|
||
preset: (typeof resizeQuickPresets)[number]
|
||
) => {
|
||
setResizeCanvasWidth(preset.width);
|
||
setResizeCanvasHeight(preset.height);
|
||
setSelectedAspectRatio(preset.aspectRatioValue);
|
||
};
|
||
|
||
// 确保在客户端渲染
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
|
||
// 获取网站设置
|
||
fetch(`/api/site-settings?scope=branding&t=${Date.now()}`)
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
if (data.siteName) setSiteName(data.siteName);
|
||
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
|
||
})
|
||
.catch((err) => {
|
||
console.error("[ImageAnnotator] Failed to fetch site settings:", err);
|
||
});
|
||
}, []);
|
||
|
||
// 处理本地图片上传 - 先上传到服务器获取URL
|
||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
try {
|
||
await applyUploadedFile(file);
|
||
} catch {}
|
||
}
|
||
// 重置 input
|
||
e.target.value = "";
|
||
};
|
||
|
||
// 加载图片
|
||
useEffect(() => {
|
||
if (!imageUrl) return; // 如果没有初始图片,不执行加载逻辑
|
||
|
||
let cancelled = false;
|
||
|
||
const initializeImage = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
setIsResizeMode(initialResizeMode);
|
||
setOriginalImageUrl(imageUrl);
|
||
setProcessedImageUrl(null);
|
||
setProcessedImageBase64(null);
|
||
setProcessType(null);
|
||
|
||
const img = await loadImageFromSource(imageUrl);
|
||
if (cancelled) return;
|
||
|
||
setImage(img);
|
||
setIsLoading(false);
|
||
setIsResizeMode(initialResizeMode);
|
||
setResizeCanvasWidth(img.width);
|
||
setResizeCanvasHeight(img.height);
|
||
setSelectedAspectRatio(null);
|
||
} catch (error) {
|
||
if (cancelled) return;
|
||
console.error("初始化图片加载失败:", error);
|
||
setImage(null);
|
||
setIsLoading(false);
|
||
toast.error("当前图片加载失败,请重试");
|
||
}
|
||
};
|
||
|
||
initializeImage();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [imageUrl, initialResizeMode, loadImageFromSource]);
|
||
|
||
// 初始化 canvas - 适配弹窗大小,固定编辑器大小,图片缩小显示
|
||
useEffect(() => {
|
||
if (!canvasRef.current || !image || !containerRef.current) return;
|
||
|
||
const canvas = canvasRef.current;
|
||
const container = containerRef.current;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return;
|
||
|
||
// 固定弹窗内画布区域大小(减去工具栏和边距,考虑并排显示)
|
||
// 弹窗固定为 1200px x 550px,画布区域约为 1100px x 350px(单图)或 550px x 350px(双图)
|
||
const containerWidth = processedImageBase64 ? 550 : 1100; // 如果有处理后图片,每个区域550px;否则1100px
|
||
const containerHeight = 350; // 550 - 工具栏高度(约200px)
|
||
const scaleX = containerWidth / image.width;
|
||
const scaleY = containerHeight / image.height;
|
||
const newScale = Math.min(scaleX, scaleY, 1); // 不放大,只缩小以适应固定容器
|
||
|
||
setScale(newScale);
|
||
|
||
canvas.width = image.width * newScale;
|
||
canvas.height = image.height * newScale;
|
||
|
||
// 计算居中偏移
|
||
const offsetX = (containerWidth - canvas.width) / 2;
|
||
const offsetY = (containerHeight - canvas.height) / 2;
|
||
setOffset({ x: Math.max(0, offsetX), y: Math.max(0, offsetY) });
|
||
|
||
setCtx(context);
|
||
redrawCanvas(context, image, newScale, []);
|
||
}, [image]);
|
||
|
||
// 重绘画布
|
||
const redrawCanvas = useCallback(
|
||
(context: CanvasRenderingContext2D, img: HTMLImageElement, currentScale: number, drawActions: DrawingAction[]) => {
|
||
const canvas = context.canvas;
|
||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// 绘制图片
|
||
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
|
||
// 绘制所有动作
|
||
for (const action of drawActions) {
|
||
drawAction(context, action, currentScale);
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
// 当 actions 变化时重绘
|
||
useEffect(() => {
|
||
if (ctx && image) {
|
||
redrawCanvas(ctx, image, scale, actions);
|
||
}
|
||
}, [actions, ctx, image, scale, redrawCanvas]);
|
||
|
||
// 绘制单个动作
|
||
const drawAction = (context: CanvasRenderingContext2D, action: DrawingAction, currentScale: number) => {
|
||
context.strokeStyle = action.color;
|
||
context.fillStyle = action.color;
|
||
context.lineWidth = action.lineWidth;
|
||
context.lineCap = "round";
|
||
context.lineJoin = "round";
|
||
|
||
switch (action.type) {
|
||
case "pen":
|
||
if (action.points && action.points.length > 0) {
|
||
context.beginPath();
|
||
context.moveTo(action.points[0].x * currentScale, action.points[0].y * currentScale);
|
||
for (let i = 1; i < action.points.length; i++) {
|
||
context.lineTo(action.points[i].x * currentScale, action.points[i].y * currentScale);
|
||
}
|
||
context.stroke();
|
||
}
|
||
break;
|
||
|
||
case "arrow":
|
||
if (action.startX !== undefined && action.startY !== undefined && action.endX !== undefined && action.endY !== undefined) {
|
||
const sx = action.startX * currentScale;
|
||
const sy = action.startY * currentScale;
|
||
const ex = action.endX * currentScale;
|
||
const ey = action.endY * currentScale;
|
||
|
||
// 绘制线条
|
||
context.beginPath();
|
||
context.moveTo(sx, sy);
|
||
context.lineTo(ex, ey);
|
||
context.stroke();
|
||
|
||
// 绘制箭头
|
||
const angle = Math.atan2(ey - sy, ex - sx);
|
||
const arrowLength = 15 + action.lineWidth * 2;
|
||
context.beginPath();
|
||
context.moveTo(ex, ey);
|
||
context.lineTo(ex - arrowLength * Math.cos(angle - Math.PI / 6), ey - arrowLength * Math.sin(angle - Math.PI / 6));
|
||
context.lineTo(ex - arrowLength * Math.cos(angle + Math.PI / 6), ey - arrowLength * Math.sin(angle + Math.PI / 6));
|
||
context.closePath();
|
||
context.fill();
|
||
}
|
||
break;
|
||
|
||
case "circle":
|
||
if (action.startX !== undefined && action.startY !== undefined && action.radius !== undefined) {
|
||
context.beginPath();
|
||
context.arc(action.startX * currentScale, action.startY * currentScale, action.radius * currentScale, 0, 2 * Math.PI);
|
||
context.stroke();
|
||
}
|
||
break;
|
||
|
||
case "text":
|
||
if (action.startX !== undefined && action.startY !== undefined && action.text) {
|
||
const fontSize = (action.fontSize || 20) * currentScale;
|
||
context.font = `bold ${fontSize}px sans-serif`;
|
||
context.fillText(action.text, action.startX * currentScale, action.startY * currentScale);
|
||
}
|
||
break;
|
||
}
|
||
};
|
||
|
||
// 获取鼠标/触摸位置(转换为原始图片坐标)
|
||
const getPosition = (e: React.MouseEvent | React.TouchEvent) => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return { x: 0, y: 0 };
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
let clientX: number;
|
||
let clientY: number;
|
||
|
||
if ("touches" in e) {
|
||
clientX = e.touches[0].clientX;
|
||
clientY = e.touches[0].clientY;
|
||
} else {
|
||
clientX = e.clientX;
|
||
clientY = e.clientY;
|
||
}
|
||
|
||
// 转换为原始图片坐标
|
||
return {
|
||
x: (clientX - rect.left) / scale,
|
||
y: (clientY - rect.top) / scale,
|
||
};
|
||
};
|
||
|
||
// 开始绘制
|
||
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||
if (tool === "text") {
|
||
const pos = getPosition(e);
|
||
setTextPosition(pos);
|
||
return;
|
||
}
|
||
|
||
setIsDrawing(true);
|
||
const pos = getPosition(e);
|
||
|
||
const newAction: DrawingAction = {
|
||
type: tool,
|
||
color,
|
||
lineWidth,
|
||
};
|
||
|
||
if (tool === "pen") {
|
||
newAction.points = [pos];
|
||
} else {
|
||
newAction.startX = pos.x;
|
||
newAction.startY = pos.y;
|
||
newAction.endX = pos.x;
|
||
newAction.endY = pos.y;
|
||
if (tool === "circle") {
|
||
newAction.radius = 0;
|
||
}
|
||
}
|
||
|
||
setCurrentAction(newAction);
|
||
};
|
||
|
||
// 绘制中
|
||
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||
if (!isDrawing || !currentAction || !ctx || !image) return;
|
||
|
||
const pos = getPosition(e);
|
||
|
||
const updatedAction = { ...currentAction };
|
||
|
||
if (tool === "pen" && updatedAction.points) {
|
||
updatedAction.points = [...updatedAction.points, pos];
|
||
} else if (tool === "arrow") {
|
||
updatedAction.endX = pos.x;
|
||
updatedAction.endY = pos.y;
|
||
} else if (tool === "circle" && updatedAction.startX !== undefined && updatedAction.startY !== undefined) {
|
||
const dx = pos.x - updatedAction.startX;
|
||
const dy = pos.y - updatedAction.startY;
|
||
updatedAction.radius = Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
|
||
setCurrentAction(updatedAction);
|
||
|
||
// 实时重绘
|
||
redrawCanvas(ctx, image, scale, [...actions, updatedAction]);
|
||
};
|
||
|
||
// 结束绘制
|
||
const handleEnd = () => {
|
||
if (!isDrawing || !currentAction) return;
|
||
|
||
setIsDrawing(false);
|
||
setActions((prev) => [...prev, currentAction]);
|
||
setCurrentAction(null);
|
||
};
|
||
|
||
// 添加文字
|
||
const handleAddText = () => {
|
||
if (!textInput.trim() || !textPosition) return;
|
||
|
||
const textAction: DrawingAction = {
|
||
type: "text",
|
||
color,
|
||
lineWidth,
|
||
startX: textPosition.x,
|
||
startY: textPosition.y,
|
||
text: textInput,
|
||
fontSize: 20 + lineWidth * 2,
|
||
};
|
||
|
||
setActions((prev) => [...prev, textAction]);
|
||
setTextInput("");
|
||
setTextPosition(null);
|
||
};
|
||
|
||
// 撤销
|
||
const handleUndo = () => {
|
||
setActions((prev) => prev.slice(0, -1));
|
||
};
|
||
|
||
// 清空
|
||
const handleClear = () => {
|
||
setActions([]);
|
||
};
|
||
|
||
// 将图片URL转换为Base64(获取原图数据)
|
||
const imageUrlToBase64 = async (url: string): Promise<string> => {
|
||
// 如果已经是data URL,直接返回
|
||
if (url.startsWith("data:")) {
|
||
return url.split(",")[1];
|
||
}
|
||
|
||
// 如果是HTTP URL,需要先fetch获取
|
||
const response = await fetch(url);
|
||
if (!response.ok) {
|
||
throw new Error(`获取原图失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
const blob = await response.blob();
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
const base64String = (reader.result as string).split(",")[1];
|
||
resolve(base64String);
|
||
};
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
};
|
||
|
||
// 抠图功能
|
||
const handleRemoveBackground = async () => {
|
||
if (!image || !originalImageUrl) return;
|
||
|
||
setIsRemovingBg(true);
|
||
try {
|
||
// 使用原图URL获取原图数据,而不是canvas的预览图
|
||
console.log("[抠图] 使用原图URL获取数据:", originalImageUrl);
|
||
const base64Data = await imageUrlToBase64(originalImageUrl);
|
||
console.log("[抠图] 原图Base64长度:", base64Data.length, "字符");
|
||
|
||
// 调用抠图API
|
||
const response = await fetch("/api/rmbg", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
image_base64: base64Data,
|
||
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "抠图失败");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.image_base64) {
|
||
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
|
||
console.log("[抠图] API返回结果:", {
|
||
hasOriginalUrl: !!result.original_url,
|
||
hasThumbnailUrl: !!result.thumbnail_url,
|
||
originalUrl: result.original_url?.substring(0, 100),
|
||
thumbnailUrl: result.thumbnail_url?.substring(0, 100)
|
||
});
|
||
if (result.original_url) {
|
||
console.log("[抠图] ✅ 使用原图URL:", result.original_url);
|
||
setProcessedImageUrl(result.original_url);
|
||
} else if (result.thumbnail_url) {
|
||
console.warn("[抠图] ⚠️ 未找到original_url,使用thumbnail_url作为备选:", result.thumbnail_url);
|
||
setProcessedImageUrl(result.thumbnail_url);
|
||
} else {
|
||
console.warn("[抠图] ⚠️ 未找到图片URL");
|
||
setProcessedImageUrl(null);
|
||
}
|
||
setProcessedImageBase64(result.image_base64);
|
||
setProcessType("rmbg");
|
||
|
||
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} else {
|
||
throw new Error(result.error || result.detail || "抠图处理失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("[抠图] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
|
||
} finally {
|
||
setIsRemovingBg(false);
|
||
}
|
||
};
|
||
|
||
// 去水印功能
|
||
const handleWatermarkRemove = async () => {
|
||
if (!image || !originalImageUrl) return;
|
||
|
||
setIsRemovingWatermark(true);
|
||
try {
|
||
console.log("[去水印] 使用原图URL:", originalImageUrl);
|
||
|
||
const result = await requestLongTaskResult({
|
||
endpoint: "/api/watermark-remove",
|
||
payload: {
|
||
image_url: originalImageUrl,
|
||
},
|
||
});
|
||
|
||
if (result.success && result.original_url) {
|
||
console.log("[去水印] API返回结果:", {
|
||
originalUrl: result.original_url?.substring(0, 100),
|
||
thumbnailUrl: result.thumbnail_url?.substring(0, 100)
|
||
});
|
||
|
||
setProcessedImageUrl(result.original_url);
|
||
// 下载图片获取base64用于显示
|
||
try {
|
||
const imgResponse = await fetch(result.original_url);
|
||
const blob = await imgResponse.blob();
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
const base64 = (reader.result as string).split(',')[1];
|
||
setProcessedImageBase64(base64);
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
} catch {
|
||
console.warn("[去水印] 无法获取Base64,仅使用URL");
|
||
}
|
||
setProcessType("watermark");
|
||
|
||
toast.success(`去水印完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} else {
|
||
throw new Error(result.error || "去水印处理失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("[去水印] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "去水印失败,请稍后重试");
|
||
} finally {
|
||
setIsRemovingWatermark(false);
|
||
}
|
||
};
|
||
|
||
// 变清晰处理
|
||
const handleEnhance = async () => {
|
||
if (!image || !originalImageUrl) return;
|
||
|
||
setIsEnhancing(true);
|
||
try {
|
||
// 使用原图URL获取原图数据,而不是canvas的预览图
|
||
console.log("[变清晰] 使用原图URL获取数据:", originalImageUrl);
|
||
const base64Data = await imageUrlToBase64(originalImageUrl);
|
||
console.log("[变清晰] 原图Base64长度:", base64Data.length, "字符");
|
||
|
||
const result = await requestLongTaskResult({
|
||
endpoint: "/api/enhance",
|
||
payload: {
|
||
image_base64: base64Data,
|
||
prompt: "画图标注-变清晰处理",
|
||
},
|
||
pollingParams: {
|
||
prompt: "画图标注-变清晰处理",
|
||
},
|
||
});
|
||
|
||
if (result.success && result.image_base64) {
|
||
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是处理后的原图)
|
||
console.log("[变清晰] API返回结果:", {
|
||
hasOriginalUrl: !!result.original_url,
|
||
hasThumbnailUrl: !!result.thumbnail_url,
|
||
originalUrl: result.original_url,
|
||
thumbnailUrl: result.thumbnail_url,
|
||
base64Length: result.image_base64?.length || 0
|
||
});
|
||
|
||
// ✅ 验证:检查返回的URL是否是处理后的图片
|
||
if (result.original_url) {
|
||
console.log("[变清晰] ✅ 使用处理后的原图URL:", result.original_url);
|
||
console.log("[变清晰] ✅ URL应该包含 'enhance-orig' 标识,确认是处理后的图片");
|
||
|
||
// 验证URL格式(应该包含enhance-orig)
|
||
if (!result.original_url.includes("enhance-orig") && !result.original_url.includes("enhance-orig")) {
|
||
console.warn("[变清晰] ⚠️ 警告:URL不包含 'enhance-orig' 标识,可能不是处理后的图片!");
|
||
}
|
||
|
||
setProcessedImageUrl(result.original_url);
|
||
} else if (result.thumbnail_url) {
|
||
console.warn("[变清晰] ⚠️ 未找到original_url,使用thumbnail_url作为备选:", result.thumbnail_url);
|
||
setProcessedImageUrl(result.thumbnail_url);
|
||
} else {
|
||
console.warn("[变清晰] ⚠️ 未找到图片URL");
|
||
setProcessedImageUrl(null);
|
||
}
|
||
setProcessedImageBase64(result.image_base64);
|
||
setProcessType("enhance");
|
||
|
||
toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} else {
|
||
throw new Error(result.error || result.detail || "变清晰处理失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("[变清晰] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
|
||
} finally {
|
||
setIsEnhancing(false);
|
||
}
|
||
};
|
||
|
||
// 尺寸调整功能
|
||
const handleResize = () => {
|
||
if (!image) return;
|
||
setIsResizeMode(true);
|
||
// 初始化尺寸为原图尺寸
|
||
setResizeCanvasWidth(image.width);
|
||
setResizeCanvasHeight(image.height);
|
||
setSelectedAspectRatio(null); // 重置比例选择
|
||
// 初始化图片位置为画布中心
|
||
setResizeImagePosition({ x: 0, y: 0 });
|
||
setResizeImageScale(1);
|
||
};
|
||
|
||
// 角度调整处理
|
||
const handleAngleEdit = () => {
|
||
if (!image) return;
|
||
setShowAngleEditDialog(true);
|
||
};
|
||
|
||
// 角度调整完成回调
|
||
const handleAngleEditComplete = (resultUrl: string) => {
|
||
setProcessedImageUrl(resultUrl);
|
||
setProcessType("enhance"); // 使用现有的 processType 状态
|
||
setShowAngleEditDialog(false);
|
||
toast.success("角度调整完成!图片已保存到相册。");
|
||
};
|
||
|
||
// 尺寸调整:处理图片拖拽
|
||
const handleResizeImageDragStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||
if (!isResizeMode || !resizeCanvasRef.current) return;
|
||
e.stopPropagation();
|
||
setIsDraggingResize(true);
|
||
const canvas = resizeCanvasRef.current;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
|
||
|
||
// 计算相对于画布的坐标
|
||
const canvasX = ((clientX - rect.left) / rect.width) * canvas.width;
|
||
const canvasY = ((clientY - rect.top) / rect.height) * canvas.height;
|
||
|
||
// 计算画布中心到图片中心的偏移(考虑显示缩放)
|
||
const containerWidth = resizeContainerRef.current?.clientWidth || canvas.width;
|
||
const containerHeight = resizeContainerRef.current?.clientHeight || canvas.height;
|
||
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
|
||
const containerAspect = containerWidth / containerHeight;
|
||
const displayScale = canvasAspect > containerAspect
|
||
? (containerWidth / resizeCanvasWidth)
|
||
: (containerHeight / resizeCanvasHeight);
|
||
|
||
// 保存拖拽起始点(相对于画布中心)
|
||
const centerX = canvas.width / 2;
|
||
const centerY = canvas.height / 2;
|
||
const offsetX = (canvasX - centerX) / displayScale - resizeImagePosition.x;
|
||
const offsetY = (canvasY - centerY) / displayScale - resizeImagePosition.y;
|
||
setDragStart({ x: offsetX, y: offsetY });
|
||
};
|
||
|
||
const handleResizeImageDrag = (e: React.MouseEvent | React.TouchEvent) => {
|
||
if (!isDraggingResize || !dragStart || !resizeCanvasRef.current) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const canvas = resizeCanvasRef.current;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
|
||
|
||
// 计算相对于画布的坐标
|
||
const canvasX = ((clientX - rect.left) / rect.width) * canvas.width;
|
||
const canvasY = ((clientY - rect.top) / rect.height) * canvas.height;
|
||
|
||
// 计算显示缩放
|
||
const containerWidth = resizeContainerRef.current?.clientWidth || canvas.width;
|
||
const containerHeight = resizeContainerRef.current?.clientHeight || canvas.height;
|
||
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
|
||
const containerAspect = containerWidth / containerHeight;
|
||
const displayScale = canvasAspect > containerAspect
|
||
? (containerWidth / resizeCanvasWidth)
|
||
: (containerHeight / resizeCanvasHeight);
|
||
|
||
// 计算新的图片位置(相对于画布中心)
|
||
const centerX = canvas.width / 2;
|
||
const centerY = canvas.height / 2;
|
||
const newX = ((canvasX - centerX) / displayScale) - dragStart.x;
|
||
const newY = ((canvasY - centerY) / displayScale) - dragStart.y;
|
||
|
||
setResizeImagePosition({ x: newX, y: newY });
|
||
};
|
||
|
||
const handleResizeImageDragEnd = () => {
|
||
setIsDraggingResize(false);
|
||
setDragStart(null);
|
||
};
|
||
|
||
// 尺寸调整:放大图片(每次2%)
|
||
const handleResizeZoomIn = () => {
|
||
if (!isResizeMode) return;
|
||
setResizeImageScale((prev) => Math.min(prev * 1.02, 5)); // 最大5倍,每次放大2%
|
||
};
|
||
|
||
// 尺寸调整:缩小图片(每次2%)
|
||
const handleResizeZoomOut = () => {
|
||
if (!isResizeMode) return;
|
||
setResizeImageScale((prev) => Math.max(prev / 1.02, 0.1)); // 最小0.1倍,每次缩小2%
|
||
};
|
||
|
||
// 尺寸调整:处理图片缩放(鼠标滚轮,每次2%)
|
||
const handleResizeImageWheel = (e: React.WheelEvent) => {
|
||
if (!isResizeMode || !image) return;
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? -0.02 : 0.02; // 滚轮每次2%
|
||
const newScale = Math.max(0.1, Math.min(5, resizeImageScale + delta));
|
||
setResizeImageScale(newScale);
|
||
};
|
||
|
||
const handleResizeScaleSliderChange = (value: number[]) => {
|
||
const nextScale = value[0];
|
||
if (!isResizeMode || typeof nextScale !== "number" || Number.isNaN(nextScale)) {
|
||
return;
|
||
}
|
||
setResizeImageScale(Math.max(0.1, Math.min(5, nextScale)));
|
||
};
|
||
|
||
// 尺寸调整:渲染预览
|
||
useEffect(() => {
|
||
if (!isResizeMode || !resizeCanvasRef.current || !image || !resizeContainerRef.current) return;
|
||
|
||
const canvas = resizeCanvasRef.current;
|
||
const container = resizeContainerRef.current;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
// 计算画布显示尺寸(适应容器)
|
||
const containerWidth = container.clientWidth;
|
||
const containerHeight = container.clientHeight;
|
||
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
|
||
const containerAspect = containerWidth / containerHeight;
|
||
|
||
let displayWidth: number;
|
||
let displayHeight: number;
|
||
let displayScale: number;
|
||
|
||
if (canvasAspect > containerAspect) {
|
||
displayWidth = containerWidth;
|
||
displayHeight = containerWidth / canvasAspect;
|
||
displayScale = displayWidth / resizeCanvasWidth;
|
||
} else {
|
||
displayHeight = containerHeight;
|
||
displayWidth = containerHeight * canvasAspect;
|
||
displayScale = displayHeight / resizeCanvasHeight;
|
||
}
|
||
|
||
canvas.width = displayWidth;
|
||
canvas.height = displayHeight;
|
||
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// 绘制背景(如果设置了背景颜色且不是透明)
|
||
if (!resizeBackgroundTransparent) {
|
||
ctx.fillStyle = resizeBackgroundColor;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
|
||
// 绘制图片(考虑缩放和位置)
|
||
const imageDisplayWidth = image.width * resizeImageScale * displayScale;
|
||
const imageDisplayHeight = image.height * resizeImageScale * displayScale;
|
||
const imageX = (canvas.width / 2) + (resizeImagePosition.x * displayScale);
|
||
const imageY = (canvas.height / 2) + (resizeImagePosition.y * displayScale);
|
||
|
||
// 绘制图片(不设置透明度,保持原图清晰度)
|
||
ctx.drawImage(
|
||
image,
|
||
imageX - imageDisplayWidth / 2,
|
||
imageY - imageDisplayHeight / 2,
|
||
imageDisplayWidth,
|
||
imageDisplayHeight
|
||
);
|
||
}, [isResizeMode, resizeCanvasWidth, resizeCanvasHeight, resizeBackgroundColor, resizeBackgroundTransparent, resizeImageScale, resizeImagePosition, image]);
|
||
|
||
// 尺寸调整:导出为指定尺寸的图片并上传到存储桶
|
||
const handleResizeConfirm = async () => {
|
||
if (!image || !isResizeMode || isResizing) return;
|
||
|
||
setIsResizing(true);
|
||
try {
|
||
// 创建指定尺寸的画布
|
||
const exportCanvas = document.createElement("canvas");
|
||
exportCanvas.width = resizeCanvasWidth;
|
||
exportCanvas.height = resizeCanvasHeight;
|
||
const exportCtx = exportCanvas.getContext("2d");
|
||
if (!exportCtx) return;
|
||
|
||
// 绘制背景
|
||
if (!resizeBackgroundTransparent) {
|
||
exportCtx.fillStyle = resizeBackgroundColor;
|
||
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||
}
|
||
|
||
// 计算图片在画布上的实际尺寸和位置
|
||
const imageWidth = image.width * resizeImageScale;
|
||
const imageHeight = image.height * resizeImageScale;
|
||
const imageX = (exportCanvas.width / 2) + resizeImagePosition.x;
|
||
const imageY = (exportCanvas.height / 2) + resizeImagePosition.y;
|
||
|
||
// 绘制图片
|
||
exportCtx.drawImage(
|
||
image,
|
||
imageX - imageWidth / 2,
|
||
imageY - imageHeight / 2,
|
||
imageWidth,
|
||
imageHeight
|
||
);
|
||
|
||
// 导出为图片
|
||
const mimeType = resizeBackgroundTransparent ? "image/png" : "image/jpeg";
|
||
const dataUrl = exportCanvas.toDataURL(mimeType, 0.95);
|
||
const base64Data = dataUrl.split(",")[1];
|
||
|
||
// 调用API上传到存储桶并保存到数据库
|
||
const response = await fetch("/api/resize", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
image_base64: base64Data,
|
||
prompt: `尺寸调整处理 - ${resizeCanvasWidth}x${resizeCanvasHeight}`,
|
||
canvasWidth: resizeCanvasWidth,
|
||
canvasHeight: resizeCanvasHeight,
|
||
mimeType: mimeType,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "上传失败");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const finalResultUrl = result.original_url || dataUrl;
|
||
|
||
if (result.original_url) {
|
||
console.log("[尺寸调整] 原图URL:", result.original_url);
|
||
setProcessedImageUrl(result.original_url);
|
||
} else {
|
||
setProcessedImageUrl(null);
|
||
}
|
||
setProcessedImageBase64(base64Data);
|
||
setProcessType("resize");
|
||
|
||
await Promise.resolve(onConfirm(finalResultUrl));
|
||
} else {
|
||
throw new Error(result.error || "尺寸调整处理失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("[尺寸调整] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "尺寸调整失败,请稍后重试");
|
||
} finally {
|
||
setIsResizing(false);
|
||
}
|
||
};
|
||
|
||
// 图像拓展处理
|
||
const handleExtend = async () => {
|
||
if (!image) return;
|
||
|
||
setIsExtending(true);
|
||
try {
|
||
// 直接使用原图URL,而不是转换为base64
|
||
console.log("[图像拓展] 使用原图URL:", originalImageUrl);
|
||
|
||
// 调用图像拓展API(直接发送URL)
|
||
const response = await fetch("/api/extend", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
image_url: originalImageUrl, // 直接发送图片URL
|
||
prompt: "画图标注-图像拓展处理", // 可选:添加提示词
|
||
includeBase64: false,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "图像拓展处理失败");
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
|
||
if (result.original_url) {
|
||
console.log("[图像拓展] 原图URL:", result.original_url);
|
||
setProcessedImageUrl(result.original_url);
|
||
} else if (result.thumbnail_url) {
|
||
console.warn("[图像拓展] ⚠️ 未找到original_url,使用thumbnail_url作为备选");
|
||
setProcessedImageUrl(result.thumbnail_url);
|
||
} else {
|
||
console.warn("[图像拓展] ⚠️ 未找到图片URL");
|
||
setProcessedImageUrl(null);
|
||
}
|
||
// 优先使用返回的base64,如果没有则使用thumbnail_url或original_url
|
||
setProcessedImageBase64(result.base64 || null);
|
||
setProcessType("extend");
|
||
|
||
toast.success(`图像拓展完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} else {
|
||
throw new Error(result.error || result.detail || "图像拓展处理失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("[图像拓展] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
|
||
} finally {
|
||
setIsExtending(false);
|
||
}
|
||
};
|
||
|
||
// 下载处理后的图片(必须使用原图URL或Base64)
|
||
const handleDownloadProcessed = async () => {
|
||
try {
|
||
// 确定文件类型和MIME类型
|
||
const isPng = processType === "rmbg" || processType === "resize";
|
||
const fileExtension = isPng ? "png" : "jpg";
|
||
const mimeType = isPng ? "image/png" : "image/jpeg";
|
||
const fileName = processType === "rmbg"
|
||
? `抠图-${Date.now()}.${fileExtension}`
|
||
: processType === "enhance"
|
||
? `变清晰-${Date.now()}.${fileExtension}`
|
||
: processType === "resize"
|
||
? `尺寸调整-${resizeCanvasWidth}x${resizeCanvasHeight}-${Date.now()}.${fileExtension}`
|
||
: processType === "watermark"
|
||
? `去水印-${Date.now()}.${fileExtension}`
|
||
: `图像拓展-${Date.now()}.${fileExtension}`;
|
||
|
||
// 如果尺寸调整使用Base64,直接下载(优先使用URL,如果没有URL则使用Base64)
|
||
if (processType === "resize") {
|
||
if (processedImageUrl) {
|
||
// 如果有URL,使用URL下载
|
||
const response = await fetch(processedImageUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
toast.success("图片下载成功");
|
||
return;
|
||
} else if (processedImageBase64) {
|
||
// 如果没有URL,使用Base64下载
|
||
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
|
||
const link = document.createElement("a");
|
||
link.href = dataUrl;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
toast.success("图片下载成功");
|
||
return;
|
||
} else {
|
||
toast.error("图片数据不可用,无法下载");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 其他情况使用URL下载
|
||
if (!processedImageUrl) {
|
||
toast.error("原图地址不可用,无法下载");
|
||
return;
|
||
}
|
||
|
||
// ✅ 验证:确认下载的是处理后的图片URL
|
||
console.log("[下载] 📥 开始下载,processType:", processType);
|
||
console.log("[下载] 📥 processedImageUrl:", processedImageUrl);
|
||
console.log("[下载] 📥 这个URL应该是处理后的图片,不是原图!");
|
||
|
||
// 确保使用的是处理后的原图URL(检查URL中是否包含thumb标识)
|
||
let downloadUrl = processedImageUrl;
|
||
|
||
// 更完善的缩略图URL转换逻辑
|
||
if (downloadUrl.includes("-thumb") || downloadUrl.includes("/thumb/") || downloadUrl.includes("thumbnail")) {
|
||
// 如果URL包含thumb标识,尝试转换为原图URL
|
||
downloadUrl = downloadUrl
|
||
.replace(/-thumb-/g, "-orig-")
|
||
.replace(/-thumb\./g, "-orig.")
|
||
.replace(/\/thumb\//g, "/orig/")
|
||
.replace(/thumbnail/g, "original");
|
||
console.warn("[下载] ⚠️ 检测到缩略图URL,已转换为原图URL:", downloadUrl);
|
||
}
|
||
|
||
// 如果转换后的URL仍然包含thumb标识,尝试更激进的转换
|
||
if (downloadUrl.includes("thumb")) {
|
||
try {
|
||
// 尝试直接替换文件名中的thumb为orig
|
||
const urlObj = new URL(downloadUrl);
|
||
const pathname = urlObj.pathname;
|
||
if (pathname.includes("thumb")) {
|
||
const newPathname = pathname.replace(/thumb/g, "orig");
|
||
downloadUrl = downloadUrl.replace(pathname, newPathname);
|
||
console.warn("[下载] ⚠️ 二次转换原图URL:", downloadUrl);
|
||
}
|
||
} catch (urlError) {
|
||
// 如果URL解析失败,尝试简单的字符串替换
|
||
downloadUrl = downloadUrl.replace(/thumb/g, "orig");
|
||
console.warn("[下载] ⚠️ 使用简单替换转换原图URL:", downloadUrl);
|
||
}
|
||
}
|
||
|
||
// ✅ 验证:确认下载URL是正确的
|
||
console.log("[下载] ✅ 最终下载URL:", downloadUrl);
|
||
console.log("[下载] ✅ 这个URL应该指向处理后的图片(不是原图)");
|
||
|
||
// 如果是变清晰,验证URL应该包含enhance相关标识
|
||
if (processType === "enhance") {
|
||
if (!downloadUrl.includes("enhance") && !downloadUrl.includes("1767358346590")) {
|
||
console.warn("[下载] ⚠️ 警告:变清晰下载URL不包含 'enhance' 标识,可能下载了错误的图片!");
|
||
}
|
||
}
|
||
|
||
// 从原图URL下载(原图已上传到S3)
|
||
const response = await fetch(downloadUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`下载原图失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
const blob = await response.blob();
|
||
console.log("[下载] 下载的图片大小:", blob.size, "bytes");
|
||
const url = window.URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(url);
|
||
|
||
toast.success("原图下载成功");
|
||
} catch (error) {
|
||
console.error("[下载] 错误:", error);
|
||
toast.error(`下载失败: ${error instanceof Error ? error.message : "请稍后重试"}`);
|
||
}
|
||
};
|
||
|
||
// 上传图片到服务器获取URL的辅助函数
|
||
const uploadImageToServer = async (dataUrl: string, filename: string): Promise<string> => {
|
||
try {
|
||
// 将 data URL 转换为 Blob
|
||
const response = await fetch(dataUrl);
|
||
const blob = await response.blob();
|
||
|
||
// 创建 FormData 上传
|
||
const formData = new FormData();
|
||
formData.append("file", blob, filename);
|
||
|
||
const uploadResponse = await fetchUploadWithRetry(formData);
|
||
|
||
if (!uploadResponse.ok) {
|
||
throw new Error(await readUploadErrorMessage(uploadResponse));
|
||
}
|
||
|
||
const uploadResult = await uploadResponse.json();
|
||
if (!uploadResult?.url) {
|
||
throw new Error("上传成功,但未返回可用图片地址");
|
||
}
|
||
return uploadResult.url;
|
||
} catch (error) {
|
||
console.error("上传图片失败:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const inferImageContentType = useCallback((url: string) => {
|
||
const normalized = String(url || "").split("?")[0]?.toLowerCase() || "";
|
||
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
|
||
if (normalized.endsWith(".webp")) return "image/webp";
|
||
if (normalized.endsWith(".gif")) return "image/gif";
|
||
return "image/png";
|
||
}, []);
|
||
|
||
const resolveConfirmedImageUrl = useCallback(async (): Promise<string | null> => {
|
||
if (processedImageUrl) {
|
||
return processedImageUrl;
|
||
}
|
||
|
||
if (processedImageBase64) {
|
||
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
|
||
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
|
||
const ext = processType === "rmbg" || processType === "resize" ? "png" : "jpg";
|
||
const filename = `processed-${Date.now()}.${ext}`;
|
||
toast.info("正在上传图片...");
|
||
return await uploadImageToServer(dataUrl, filename);
|
||
}
|
||
|
||
const canvas = canvasRef.current;
|
||
if (!canvas || !image) return null;
|
||
|
||
const exportCanvas = document.createElement("canvas");
|
||
exportCanvas.width = image.width;
|
||
exportCanvas.height = image.height;
|
||
const exportCtx = exportCanvas.getContext("2d");
|
||
if (!exportCtx) return null;
|
||
|
||
exportCtx.drawImage(image, 0, 0);
|
||
|
||
for (const action of actions) {
|
||
drawAction(exportCtx, action, 1);
|
||
}
|
||
|
||
const dataUrl = exportCanvas.toDataURL("image/png");
|
||
const filename = `annotated-${Date.now()}.png`;
|
||
toast.info("正在上传图片...");
|
||
return await uploadImageToServer(dataUrl, filename);
|
||
}, [actions, image, processType, processedImageBase64, processedImageUrl]);
|
||
|
||
const publishToNewCanvas = useCallback(async (resultUrl: string) => {
|
||
const normalizedOriginalUrl = String(originalImageUrl || "").trim();
|
||
const normalizedResultUrl = String(resultUrl || "").trim();
|
||
const resultLabelMap: Record<NonNullable<typeof processType> | "annotate", string> = {
|
||
annotate: "编辑结果",
|
||
rmbg: "抠图结果",
|
||
enhance: "变清晰结果",
|
||
extend: "拓展结果",
|
||
resize: "尺寸调整结果",
|
||
watermark: "去水印结果",
|
||
};
|
||
const resultLabel =
|
||
resultLabelMap[processType || "annotate"] || "编辑结果";
|
||
|
||
const attachments: CanvasLaunchAttachment[] = [];
|
||
if (normalizedOriginalUrl) {
|
||
attachments.push({
|
||
url: normalizedOriginalUrl,
|
||
name: "原图",
|
||
contentType: inferImageContentType(normalizedOriginalUrl),
|
||
});
|
||
}
|
||
if (normalizedResultUrl) {
|
||
const hasSameAsOriginal =
|
||
normalizedOriginalUrl && normalizedOriginalUrl === normalizedResultUrl;
|
||
if (!hasSameAsOriginal || attachments.length === 0) {
|
||
attachments.push({
|
||
url: normalizedResultUrl,
|
||
name: resultLabel,
|
||
contentType: inferImageContentType(normalizedResultUrl),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (attachments.length === 0) {
|
||
throw new Error("没有可发布到画布的图片");
|
||
}
|
||
|
||
const titleLabel = processType ? resultLabel.replace("结果", "") : "图片编辑";
|
||
const createResponse = await fetch("/api/canvas", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
title: `${titleLabel}-${new Date().toLocaleDateString("zh-CN")}`,
|
||
}),
|
||
});
|
||
|
||
const createResult = await createResponse.json();
|
||
if (!createResponse.ok || !createResult?.data?.id) {
|
||
throw new Error(createResult?.error || "创建新画布失败");
|
||
}
|
||
|
||
const canvasId = String(createResult.data.id);
|
||
sessionStorage.setItem(
|
||
getCanvasLaunchStorageKey(canvasId),
|
||
JSON.stringify({
|
||
prompt: "",
|
||
attachments,
|
||
autoSend: false,
|
||
createdAt: Date.now(),
|
||
}),
|
||
);
|
||
|
||
toast.success("已发布到新的画布");
|
||
router.push(`/canvas/${canvasId}`);
|
||
}, [inferImageContentType, originalImageUrl, processType, router]);
|
||
|
||
// 确认
|
||
const handleConfirm = async () => {
|
||
try {
|
||
const finalUrl = await resolveConfirmedImageUrl();
|
||
if (!finalUrl) {
|
||
toast.error("没有可发布的图片");
|
||
return;
|
||
}
|
||
|
||
if (submitMode === "publish-new-canvas") {
|
||
await publishToNewCanvas(finalUrl);
|
||
return;
|
||
}
|
||
|
||
await onConfirm(finalUrl);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "处理失败,请重试";
|
||
toast.error(message);
|
||
}
|
||
};
|
||
|
||
if (!mounted) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||
<DialogContent
|
||
className={cn(
|
||
"h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]",
|
||
forceDarkAppearance ? "bg-[#09090b] text-zinc-100" : "bg-background"
|
||
)}
|
||
>
|
||
<DialogHeader className="sr-only">
|
||
<DialogTitle>图片编辑器</DialogTitle>
|
||
<DialogDescription>编辑、标注或处理图片。</DialogDescription>
|
||
</DialogHeader>
|
||
<div className={cn("h-full w-full", forceDarkAppearance && "dark")}>
|
||
|
||
{/* Top Header - Minimal */}
|
||
{!showSceneFusion && (
|
||
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
|
||
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/95 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
|
||
<span className="font-semibold text-sm">图片编辑</span>
|
||
<div className="h-4 w-[1px] bg-border" />
|
||
<span className="text-xs text-muted-foreground">
|
||
{isResizeMode ? "调整尺寸" : "图片标注与处理"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content Area */}
|
||
<div className="relative flex h-full w-full bg-[#f4f4f5] dark:bg-[#09090b]">
|
||
{showSceneFusion ? (
|
||
<div className="flex-1 overflow-hidden relative">
|
||
<SceneFusionView
|
||
onBack={() => setShowSceneFusion(false)}
|
||
initialProductImage={processedImageUrl ?? originalImageUrl ?? null}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div ref={containerRef} className="flex flex-1 items-center justify-center gap-2 overflow-hidden p-0 relative">
|
||
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
|
||
|
||
{/* Main Canvas / Image Area */}
|
||
<div className={cn(
|
||
"relative z-10 flex items-center justify-center w-full h-full p-8",
|
||
isResizeMode ? "pb-36" : "pb-32"
|
||
)}>
|
||
{isLoading ? (
|
||
<div className="flex flex-col items-center justify-center gap-3">
|
||
<div className="flex items-center gap-1">
|
||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce" style={{ animationDelay: "0ms" }} />
|
||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce" style={{ animationDelay: "150ms" }} />
|
||
<span className="h-2 w-2 rounded-full bg-primary animate-bounce" style={{ animationDelay: "300ms" }} />
|
||
</div>
|
||
<span className="text-sm text-muted-foreground">正在加载图片...</span>
|
||
</div>
|
||
) : !image ? (
|
||
<div className="flex flex-col items-center justify-center p-8">
|
||
<div
|
||
className="w-full max-w-lg border-2 border-dashed border-muted-foreground/25 rounded-xl p-12 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-all group"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
>
|
||
<div className="bg-background rounded-full p-4 w-20 h-20 mx-auto mb-6 shadow-sm group-hover:scale-105 transition-transform flex items-center justify-center">
|
||
<UploadIcon className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors" />
|
||
</div>
|
||
<h3 className="text-xl font-medium mb-2">上传图片以开始编辑</h3>
|
||
<p className="text-muted-foreground mb-2">拖拽图片到此处、点击选择或 Ctrl+V 粘贴</p>
|
||
<p className="text-muted-foreground/60 text-sm mb-6">支持 JPG, PNG, WebP 格式,最大 50MB</p>
|
||
<Button variant="default">选择图片</Button>
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
className="hidden"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={handleImageUpload}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : isResizeMode ? (
|
||
/* Resize Mode View */
|
||
<div className="flex h-full w-full items-center justify-center gap-5">
|
||
{/* Source Image */}
|
||
<div className="flex flex-col border border-border/40 rounded-xl bg-white/50 dark:bg-zinc-900/50 p-2 overflow-hidden shadow-sm h-full max-h-[80vh] flex-1">
|
||
<div className="mb-2 text-xs font-medium text-muted-foreground text-center bg-black/5 dark:bg-white/5 py-1 rounded">原图</div>
|
||
<div className="flex-1 flex items-center justify-center overflow-auto min-h-0">
|
||
{image && (
|
||
<img
|
||
src={image.src}
|
||
alt="原图"
|
||
className="rounded-lg shadow-sm"
|
||
style={{ objectFit: "contain", maxHeight: "100%", maxWidth: "100%" }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Preview Canvas */}
|
||
<div className="flex flex-col border border-primary/20 rounded-xl bg-white/50 dark:bg-zinc-900/50 p-2 overflow-hidden shadow-sm h-full max-h-[80vh] flex-1">
|
||
<div className="mb-2 flex items-center justify-between bg-black/5 dark:bg-white/5 py-1 px-2 rounded">
|
||
<span className="text-xs font-medium text-muted-foreground">调整预览</span>
|
||
{(processedImageUrl || processedImageBase64) && processType === "resize" && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleDownloadProcessed}
|
||
className="h-6 px-2 text-xs hover:bg-background"
|
||
>
|
||
<Download className="h-3 w-3 mr-1" />
|
||
下载
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div
|
||
ref={resizeContainerRef}
|
||
className="flex-1 flex items-center justify-center overflow-hidden relative"
|
||
onWheel={handleResizeImageWheel}
|
||
>
|
||
<div className="relative inline-block">
|
||
<canvas
|
||
ref={resizeCanvasRef}
|
||
className="cursor-move rounded-lg shadow-lg"
|
||
onMouseDown={handleResizeImageDragStart}
|
||
onMouseMove={handleResizeImageDrag}
|
||
onMouseUp={handleResizeImageDragEnd}
|
||
onMouseLeave={handleResizeImageDragEnd}
|
||
onTouchStart={handleResizeImageDragStart}
|
||
onTouchMove={handleResizeImageDrag}
|
||
onTouchEnd={handleResizeImageDragEnd}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* Normal Mode View */
|
||
<div className="flex gap-4 h-full w-full items-center justify-center">
|
||
<div className="relative flex items-center justify-center max-h-full max-w-full">
|
||
<canvas
|
||
ref={canvasRef}
|
||
className={cn(
|
||
"touch-none shadow-2xl rounded-lg block bg-white dark:bg-[#18181b]",
|
||
(tool === "pen" || tool === "arrow" || tool === "circle" || tool === "text") ? "cursor-crosshair" : "cursor-default"
|
||
)}
|
||
onMouseDown={handleStart}
|
||
onMouseMove={handleMove}
|
||
onMouseUp={handleEnd}
|
||
onMouseLeave={handleEnd}
|
||
onTouchStart={handleStart}
|
||
onTouchMove={handleMove}
|
||
onTouchEnd={handleEnd}
|
||
/>
|
||
|
||
{/* Processed Result Overlay/Side-by-side could go here if we want splitscreen, but ImageAnnotator usually replaces or shows result in place?
|
||
Currently it shows result in a separate box if processed.
|
||
Let's keep the logic: if processedImage exists, show it side-by-side or replace?
|
||
Original code showed side-by-side.
|
||
*/}
|
||
|
||
</div>
|
||
|
||
{/* Processed Result (Side by Side) */}
|
||
{(processedImageUrl || processedImageBase64) && (
|
||
<div className="relative flex flex-col items-center justify-center max-h-full max-w-full ml-4 border-l border-border/20 pl-4">
|
||
<div className="mb-2 text-xs font-medium text-muted-foreground bg-black/5 dark:bg-white/5 px-2 py-1 rounded">
|
||
{processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : processType === "watermark" ? "去水印结果" : "拓展结果"}
|
||
</div>
|
||
<div className="relative rounded-lg shadow-2xl overflow-hidden bg-white dark:bg-[#18181b]">
|
||
{processedImageUrl ? (
|
||
<img
|
||
src={processedImageUrl}
|
||
alt="Result"
|
||
className="max-h-[70vh] max-w-[40vw] object-contain"
|
||
/>
|
||
) : processedImageBase64 ? (
|
||
<img
|
||
src={`data:image/${processType === "rmbg" || processType === "resize" ? "png" : "jpeg"};base64,${processedImageBase64}`}
|
||
alt="Result"
|
||
className="max-h-[70vh] max-w-[40vw] object-contain"
|
||
/>
|
||
) : null}
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="secondary"
|
||
className="absolute bottom-4 right-4 shadow-lg"
|
||
onClick={handleDownloadProcessed}
|
||
>
|
||
<Download className="h-5 w-5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!showSceneFusion && isResizeMode && (
|
||
<div className="absolute bottom-8 left-1/2 z-20 w-[min(92vw,1120px)] -translate-x-1/2 pointer-events-none">
|
||
<div className="pointer-events-auto flex items-center justify-between gap-3 overflow-x-auto rounded-[28px] border border-border/50 bg-background/95 px-3 py-2 shadow-2xl backdrop-blur-xl scrollbar-hide">
|
||
<div className="flex min-w-max items-center gap-2">
|
||
<Popover open={resizeRatioPickerOpen} onOpenChange={setResizeRatioPickerOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
className="h-10 shrink-0 rounded-[20px] bg-muted/35 px-4 text-xs font-medium text-foreground hover:bg-muted/45"
|
||
>
|
||
比例
|
||
<span className="ml-2 text-foreground/80">{currentAspectRatioLabel}</span>
|
||
<ChevronDown className="ml-2 h-4 w-4 text-muted-foreground" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
side="top"
|
||
className="z-[10020] w-[320px] rounded-[22px] border-border/60 bg-background/96 p-3 backdrop-blur-xl"
|
||
>
|
||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||
选择画布比例
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{aspectRatios.map((ratio) => (
|
||
<Button
|
||
key={ratio.value}
|
||
variant={selectedAspectRatio === ratio.value ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => {
|
||
handleAspectRatioSelect(
|
||
ratio.value === selectedAspectRatio ? null : ratio.value
|
||
);
|
||
setResizeRatioPickerOpen(false);
|
||
}}
|
||
className={cn(
|
||
"h-9 rounded-[16px] px-3 text-xs font-medium",
|
||
selectedAspectRatio === ratio.value && "shadow-sm"
|
||
)}
|
||
>
|
||
{ratio.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||
<div className="flex items-center gap-2 rounded-[20px] bg-muted/30 px-2 py-1">
|
||
<Input
|
||
type="number"
|
||
value={resizeCanvasWidth}
|
||
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value) || 1)}
|
||
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-sm shadow-none focus-visible:ring-0"
|
||
/>
|
||
<span className="text-sm text-muted-foreground">×</span>
|
||
<Input
|
||
type="number"
|
||
value={resizeCanvasHeight}
|
||
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value) || 1)}
|
||
className="h-8 w-24 border-0 bg-transparent px-2 text-center text-sm shadow-none focus-visible:ring-0"
|
||
/>
|
||
</div>
|
||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||
<div className="flex items-center gap-1 rounded-[22px] bg-muted/35 px-1 py-1">
|
||
{resizeQuickPresets.map((preset) => {
|
||
const isActive =
|
||
resizeCanvasWidth === preset.width && resizeCanvasHeight === preset.height;
|
||
return (
|
||
<Button
|
||
key={`${preset.width}x${preset.height}`}
|
||
variant={isActive ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => handleResizeQuickPresetSelect(preset)}
|
||
className={cn(
|
||
"h-8 rounded-[18px] px-3 text-xs font-medium whitespace-nowrap",
|
||
isActive && "shadow-sm"
|
||
)}
|
||
>
|
||
{preset.label}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||
<div className="flex min-w-[280px] items-center gap-3 rounded-[22px] bg-muted/35 px-3 py-2">
|
||
<span className="shrink-0 text-xs font-medium text-muted-foreground">
|
||
缩放
|
||
</span>
|
||
<Slider
|
||
value={[resizeImageScale]}
|
||
onValueChange={handleResizeScaleSliderChange}
|
||
min={0.1}
|
||
max={5}
|
||
step={0.01}
|
||
className="w-[160px] shrink-0"
|
||
trackClassName="h-1.5 bg-white/12 dark:bg-white/12"
|
||
rangeClassName="bg-emerald-400 dark:bg-emerald-400"
|
||
thumbClassName="h-4 w-4 border border-emerald-300 bg-white shadow-[0_0_0_4px_rgba(16,185,129,0.18)] dark:bg-emerald-50"
|
||
/>
|
||
<span className="min-w-[48px] shrink-0 text-right text-xs font-medium text-foreground">
|
||
{(resizeImageScale * 100).toFixed(0)}%
|
||
</span>
|
||
</div>
|
||
<div className="h-7 w-px shrink-0 bg-border/60" />
|
||
<TooltipProvider delayDuration={0}>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-9 w-9 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleResizeZoomOut}
|
||
>
|
||
<ZoomOut className="h-4.5 w-4.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>缩小</TooltipContent>
|
||
</Tooltip>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-9 w-9 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleResizeZoomIn}
|
||
>
|
||
<ZoomIn className="h-4.5 w-4.5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>放大</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={onCancel}
|
||
className="rounded-[18px] px-4 text-muted-foreground hover:text-foreground"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleResizeConfirm}
|
||
disabled={isResizing}
|
||
className="rounded-[18px] px-5 bg-primary text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
{isResizing ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
处理中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Check className="mr-2 h-4 w-4" />
|
||
{resolvedConfirmButtonText}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!showSceneFusion && !isResizeMode && (
|
||
<div className="absolute bottom-8 left-1/2 z-20 w-[min(94vw,1120px)] -translate-x-1/2 pointer-events-none">
|
||
<div className="pointer-events-auto flex items-center justify-between gap-3 overflow-x-auto rounded-[28px] border border-border/60 bg-background px-3 py-2 shadow-2xl scrollbar-hide">
|
||
<TooltipProvider delayDuration={0}>
|
||
<div className="flex min-w-max items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleRemoveBackground}
|
||
disabled={isRemovingBg}
|
||
>
|
||
<Scissors className={cn("h-5 w-5", isRemovingBg && "animate-spin")} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>移除背景</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleEnhance}
|
||
disabled={isEnhancing}
|
||
>
|
||
<Sparkles className={cn("h-5 w-5", isEnhancing && "animate-spin")} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>变清晰</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleExtend}
|
||
disabled={isExtending}
|
||
>
|
||
<Expand className={cn("h-5 w-5", isExtending && "animate-spin")} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>图像拓展</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleWatermarkRemove}
|
||
disabled={isRemovingWatermark}
|
||
>
|
||
<Droplet className={cn("h-5 w-5", isRemovingWatermark && "animate-spin")} />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>去水印</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="mx-1 h-7 w-px shrink-0 bg-border/60" />
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleResize}
|
||
>
|
||
<Maximize2 className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>尺寸调整</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleAngleEdit}
|
||
>
|
||
<Move3D className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>角度调整</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={() => setShowSceneFusion(true)}
|
||
>
|
||
<Layers className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>批量处理</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<div className="mx-1 h-7 w-px shrink-0 bg-border/60" />
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleUndo}
|
||
disabled={actions.length === 0}
|
||
>
|
||
<Undo className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>撤销</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
onClick={handleClear}
|
||
disabled={actions.length === 0}
|
||
>
|
||
<Trash2 className="h-5 w-5" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>清空</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TooltipProvider>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={onCancel}
|
||
className="rounded-[18px] px-4 text-muted-foreground hover:text-foreground"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleConfirm}
|
||
className="rounded-[18px] px-6 bg-primary text-primary-foreground hover:bg-primary/90"
|
||
>
|
||
<Check className="mr-2 h-4 w-4" />
|
||
{resolvedConfirmButtonText}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Text Input Modal */}
|
||
{textPosition && (
|
||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setTextPosition(null)}>
|
||
<div className="rounded-lg border bg-background p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||
<div className="mb-3 text-sm font-medium">输入标注文字</div>
|
||
<input
|
||
type="text"
|
||
value={textInput}
|
||
onChange={(e) => setTextInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") handleAddText();
|
||
}}
|
||
className="mb-3 w-64 rounded-md border bg-muted px-3 py-2 text-foreground outline-none focus:border-primary"
|
||
placeholder="输入文字..."
|
||
autoFocus
|
||
/>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="ghost" size="sm" onClick={() => setTextPosition(null)}>
|
||
取消
|
||
</Button>
|
||
<Button size="sm" onClick={handleAddText} disabled={!textInput.trim()}>
|
||
添加
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Angle Edit Dialog */}
|
||
{showAngleEditDialog && originalImageUrl && (
|
||
<div onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||
<AngleEditDialog
|
||
open={showAngleEditDialog}
|
||
onOpenChange={setShowAngleEditDialog}
|
||
imageUrl={originalImageUrl}
|
||
onComplete={handleAngleEditComplete}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|