lovart3/components/canvas/ObjectPickerOverlay.tsx

277 lines
9.3 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, { useState, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Loader2, Crosshair } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCanvasStore, type PickedObject } from "@/store/canvas";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
/**
* 在图片上绘制标记点,返回带标记的图片 base64
* AI 通过看到这个红色标记来识别用户点击的位置
*/
async function drawMarkerOnImage(
imageUrl: string,
clickX: number,
clickY: number
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
// 创建 canvas
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法创建 canvas'));
return;
}
// 压缩图片尺寸(最大 800px),加快 AI 分析速度
const maxSize = 800;
let width = img.width;
let height = img.height;
if (width > maxSize || height > maxSize) {
if (width > height) {
height = (height / width) * maxSize;
width = maxSize;
} else {
width = (width / height) * maxSize;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
// 绘制图片
ctx.drawImage(img, 0, 0, width, height);
// 计算标记位置
const markerX = clickX * width;
const markerY = clickY * height;
const markerRadius = Math.max(12, Math.min(width, height) * 0.025); // 标记大小
// 绘制红色标记圆圈(带白色边框,更醒目)
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
ctx.beginPath();
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
ctx.fillStyle = '#FF0000';
ctx.fill();
// 绘制十字线(帮助 AI 定位)
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 2;
// 水平线
ctx.beginPath();
ctx.moveTo(markerX - markerRadius * 2, markerY);
ctx.lineTo(markerX - markerRadius - 2, markerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX + markerRadius + 2, markerY);
ctx.lineTo(markerX + markerRadius * 2, markerY);
ctx.stroke();
// 垂直线
ctx.beginPath();
ctx.moveTo(markerX, markerY - markerRadius * 2);
ctx.lineTo(markerX, markerY - markerRadius - 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(markerX, markerY + markerRadius + 2);
ctx.lineTo(markerX, markerY + markerRadius * 2);
ctx.stroke();
// 转为 base64
const markedImageBase64 = canvas.toDataURL('image/jpeg', 0.85);
resolve(markedImageBase64);
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
img.src = imageUrl;
});
}
export function ObjectPickerOverlay() {
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId);
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject);
const getNode = useCanvasStore((state) => state.getNode);
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
// 获取目标节点
const targetNode = pickerTargetNodeId ? getNode(pickerTargetNodeId) : null;
const imageUrl = targetNode?.type === 'image' ? (targetNode.data as any)?.src as string | undefined : undefined;
// 处理图片点击 - 识别成功后直接添加到对话框
const handleImageClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
if (isPickerLoading || !imageUrl || !pickerTargetNodeId) return;
const img = e.currentTarget;
const rect = img.getBoundingClientRect();
// 计算相对坐标 (0-1)
const clickX = (e.clientX - rect.left) / rect.width;
const clickY = (e.clientY - rect.top) / rect.height;
// 显示点击位置
setClickPosition({ x: clickX, y: clickY });
setPickerLoading(true);
try {
// 在图片上绘制红色标记点,让 AI 能看到用户点击的位置
const markedImageBase64 = await drawMarkerOnImage(imageUrl, clickX, clickY);
// 发送带标记的图片给 AI
const response = await fetch('/api/pick-object', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageData: markedImageBase64, // 带红色标记的图片
clickX,
clickY,
}),
});
if (!response.ok) {
throw new Error('识别失败');
}
const result = await response.json();
if (result.success && result.object) {
// 构建拾取结果(保存原始图片 URL,不是带标记的)
const picked: PickedObject = {
imageUrl, // 原始图片
imageNodeId: pickerTargetNodeId,
clickX,
clickY,
objectName: result.object.name,
objectPosition: result.object.position,
confidence: result.object.confidence,
boundingBox: result.object.boundingBox,
};
// 直接添加到对话框,不询问确认
addPendingPickedObject(picked);
toast.success(`已添加: ${result.object.name}`);
// 关闭拾取模式
setPickerMode(false);
setClickPosition(null);
} else {
toast.error(result.error || '未能识别物品');
setClickPosition(null);
}
} catch (error) {
console.error('物品拾取失败:', error);
toast.error('识别失败,请重试');
setClickPosition(null);
} finally {
setPickerLoading(false);
}
}, [imageUrl, isPickerLoading, pickerTargetNodeId, setPickerLoading, addPendingPickedObject, setPickerMode]);
// 关闭拾取模式
const handleClose = useCallback(() => {
setPickerMode(false);
setClickPosition(null);
}, [setPickerMode]);
if (!isPickerMode || !imageUrl) return null;
return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 z-[9998] bg-black/60 flex items-center justify-center"
onClick={(e) => {
// 点击背景关闭
if (e.target === overlayRef.current) {
handleClose();
}
}}
>
{/* 顶部提示栏 */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow-lg">
<Crosshair className="h-5 w-5 text-orange-500" />
<span className="text-sm font-medium">
{isPickerLoading ? '正在识别...' : '点击图片中的物品进行拾取'}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full"
onClick={handleClose}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 图片容器 */}
<div className="relative max-w-[80vw] max-h-[70vh]">
{/* 图片 - 原始图片,无任何标记 */}
<img
src={imageUrl}
alt="拾取目标"
className={cn(
"max-w-full max-h-[70vh] object-contain rounded-lg border-4 transition-all",
isPickerLoading ? "border-orange-400 opacity-80" : "border-orange-500",
!isPickerLoading && "cursor-crosshair"
)}
onClick={!isPickerLoading ? handleImageClick : undefined}
draggable={false}
/>
{/* 点击位置标记(脉冲动画)- 仅在前端显示,不影响发送给 AI 的图片 */}
{clickPosition && (
<div
className="absolute pointer-events-none"
style={{
left: `${clickPosition.x * 100}%`,
top: `${clickPosition.y * 100}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* 脉冲圆点 */}
<div className={cn(
"relative w-8 h-8 rounded-full border-2 border-orange-500 bg-orange-500/30",
isPickerLoading && "animate-pulse"
)}>
<div className="absolute inset-0 rounded-full bg-orange-500 animate-ping opacity-30" />
<div className="absolute inset-2 rounded-full bg-orange-500" />
</div>
</div>
)}
{/* 加载状态 */}
{isPickerLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-lg">
<div className="bg-white rounded-lg px-4 py-3 flex items-center gap-2 shadow-lg">
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
<span className="text-sm">正在识别...</span>
</div>
</div>
)}
</div>
</div>,
document.body
);
}