lovart3/components/image-annotator.tsx
Your Name e9ceedb3bf feat: 添加图片变清晰功能
- 创建 /api/enhance 路由,实现图片变清晰API集成
- 在图片标注组件中添加变清晰按钮(Sparkles图标)
- 在相册页面添加变清晰按钮
- 系统设置:将抠图设置改名为本地AI设置,添加变清晰API配置
- 支持同步和异步两种模式
- 自动上传到S3存储桶并保存到数据库
- 在相册和出图记录中显示处理后的图片
2025-12-29 00:33:12 +08:00

657 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles } from "lucide-react";
import { Button } from "./ui/button";
import { toast } from "sonner";
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;
onCancel: () => void;
}
const COLORS = [
"#FF0000", // 红色
"#FF6B00", // 橙色
"#FFCC00", // 黄色
"#00CC00", // 绿色
"#0099FF", // 蓝色
"#9933FF", // 紫色
"#FF0099", // 粉色
"#FFFFFF", // 白色
"#000000", // 黑色
];
const LINE_WIDTHS = [2, 4, 6, 8];
export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotatorProps) {
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, setTool] = useState<Tool>("pen");
const [color, setColor] = useState("#FF0000");
const [lineWidth, setLineWidth] = useState(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(true);
const [isRemovingBg, setIsRemovingBg] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
// 加载图片
useEffect(() => {
setIsLoading(true);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
};
img.src = imageUrl;
}, [imageUrl]);
// 初始化 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;
// 弹窗内画布区域大小(减去工具栏和边距)
const containerWidth = 800; // 850 - padding
const containerHeight = 350; // 550 - 工具栏高度
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([]);
};
// 抠图功能
const handleRemoveBackground = async () => {
if (!image || !canvasRef.current) return;
setIsRemovingBg(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用抠图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) {
// 将返回的Base64图片加载为新图片
const processedImage = new window.Image();
processedImage.crossOrigin = "anonymous";
processedImage.onload = () => {
// 更新图片和画布
setImage(processedImage);
// 清空所有标注(因为背景已移除)
setActions([]);
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
};
processedImage.onerror = () => {
throw new Error("加载处理后的图片失败");
};
// 优先使用S3 URL(如果可用),否则使用Base64
if (result.original_url) {
processedImage.src = result.original_url;
} else {
processedImage.src = `data:image/png;base64,${result.image_base64}`;
}
} else {
throw new Error(result.error || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
} finally {
setIsRemovingBg(false);
}
};
// 变清晰处理
const handleEnhance = async () => {
if (!image || !canvasRef.current) return;
setIsEnhancing(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用变清晰API
const response = await fetch("/api/enhance", {
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) {
// 将返回的Base64图片加载为新图片
const processedImage = new window.Image();
processedImage.crossOrigin = "anonymous";
processedImage.onload = () => {
// 更新图片和画布
setImage(processedImage);
// 清空所有标注(因为图片已更新)
setActions([]);
toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
};
processedImage.onerror = () => {
throw new Error("加载处理后的图片失败");
};
// 优先使用S3 URL(如果可用),否则使用Base64
if (result.original_url) {
processedImage.src = result.original_url;
} else {
processedImage.src = `data:image/jpeg;base64,${result.image_base64}`;
}
} else {
throw new Error(result.error || "变清晰处理失败");
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
} finally {
setIsEnhancing(false);
}
};
// 确认
const handleConfirm = () => {
const canvas = canvasRef.current;
if (!canvas || !image) return;
// 创建一个新的 canvas 用于导出原始尺寸
const exportCanvas = document.createElement("canvas");
exportCanvas.width = image.width;
exportCanvas.height = image.height;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) return;
// 绘制原始图片
exportCtx.drawImage(image, 0, 0);
// 绘制所有动作(使用原始尺寸)
for (const action of actions) {
drawAction(exportCtx, action, 1);
}
// 导出为 data URL
const dataUrl = exportCanvas.toDataURL("image/png");
onConfirm(dataUrl);
};
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={onCancel}>
{/* 弹窗容器 */}
<div
className="flex flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
style={{ width: "850px", height: "550px" }}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 */}
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-base font-medium">图片标注</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleRemoveBackground}
disabled={isRemovingBg || isEnhancing || !image}
title="移除背景(抠图)"
>
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
{isRemovingBg ? "处理中..." : "抠图"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleEnhance}
disabled={isEnhancing || isRemovingBg || !image}
title="图片变清晰"
>
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
{isEnhancing ? "处理中..." : "变清晰"}
</Button>
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
<Undo className="mr-1 h-4 w-4" />
撤销
</Button>
<Button variant="ghost" size="sm" onClick={handleClear} disabled={actions.length === 0}>
<Trash2 className="mr-1 h-4 w-4" />
清空
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
<X className="mr-1 h-4 w-4" />
取消
</Button>
<Button size="sm" onClick={handleConfirm} className="bg-primary text-primary-foreground hover:bg-primary/90">
<Check className="mr-1 h-4 w-4" />
确定
</Button>
</div>
</div>
{/* 工具选择栏 */}
<div className="flex flex-wrap items-center justify-center gap-3 border-b px-4 py-2">
{/* 工具按钮 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "pen" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("pen")}
title="画笔"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "arrow" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("arrow")}
title="箭头"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "circle" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("circle")}
title="圆圈"
>
<Circle className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "text" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("text")}
title="文字"
>
<Type className="h-4 w-4" />
</button>
</div>
{/* 颜色选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${color === c ? "scale-110 border-primary" : "border-transparent hover:scale-105"}`}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
title={c}
/>
))}
</div>
{/* 线宽选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{LINE_WIDTHS.map((w) => (
<button
key={w}
type="button"
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${lineWidth === w ? "bg-primary/20" : "hover:bg-muted-foreground/10"}`}
onClick={() => setLineWidth(w)}
title={`线宽 ${w}px`}
>
<div className="rounded-full bg-foreground" style={{ width: w + 2, height: w + 2 }} />
</button>
))}
</div>
</div>
{/* 画布区域 */}
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-4">
{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>
) : (
<canvas
ref={canvasRef}
className="cursor-crosshair rounded-lg shadow-lg"
onMouseDown={handleStart}
onMouseMove={handleMove}
onMouseUp={handleEnd}
onMouseLeave={handleEnd}
onTouchStart={handleStart}
onTouchMove={handleMove}
onTouchEnd={handleEnd}
/>
)}
</div>
</div>
{/* 文字输入弹窗 */}
{textPosition && (
<div className="fixed inset-0 z-[310] flex items-center justify-center bg-black/50" 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>
)}
</div>
);
}