lovart3/components/angle-edit-dialog.tsx
Your Name 87698afee8 feat: 替换为专业的 Qwen 3D 相机控制器
- 新增 qwen-camera-widget.tsx:基于 Three.js 的专业 3D 相机控制组件
- 彩色拖拽手柄:粉色(方位角)、青色(仰角)、金色(距离)
- 下拉快速选择预设角度
- 实时数值显示和提示词预览
- 暗色主题 UI,符合 ComfyUI-qwenmultiangle 设计风格
- 更新 angle-edit-dialog 使用新组件
2026-01-11 11:08:00 +08:00

331 lines
12 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 { useState, useCallback, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { QwenCameraWidget, type CameraState } from "./qwen-camera-widget";
import { cn } from "@/lib/utils";
interface AngleEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageUrl: string;
onComplete?: (resultUrl: string) => void;
}
// 根据角度值生成 API 所需的字符串参数
function getAzimuthString(azimuth: number): string {
const hAngle = azimuth % 360;
if (hAngle < 22.5 || hAngle >= 337.5) return "front";
if (hAngle < 67.5) return "front-right";
if (hAngle < 112.5) return "right";
if (hAngle < 157.5) return "back-right";
if (hAngle < 202.5) return "back";
if (hAngle < 247.5) return "back-left";
if (hAngle < 292.5) return "left";
return "front-left";
}
function getElevationString(elevation: number): string {
if (elevation < -15) return "low";
if (elevation < 15) return "eye-level";
if (elevation < 45) return "elevated";
return "high";
}
function getDistanceString(distance: number): string {
if (distance < 3) return "wide";
if (distance < 7) return "medium";
return "close";
}
export function AngleEditDialog({
open,
onOpenChange,
imageUrl,
}: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(0.9);
const [cameraState, setCameraState] = useState<CameraState>({
azimuth: 45,
elevation: 0,
distance: 5,
});
// 当对话框关闭时重置状态
useEffect(() => {
if (!open) {
setResultUrl(null);
setIsProcessing(false);
}
}, [open]);
// 处理相机状态变化
const handleCameraStateChange = useCallback((state: CameraState) => {
setCameraState(state);
// 角度变化时清除之前的结果
setResultUrl(null);
}, []);
// 生成新视角
const handleGenerate = useCallback(async () => {
setIsProcessing(true);
setResultUrl(null);
try {
const response = await fetch("/api/angle-edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_url: imageUrl,
azimuth: getAzimuthString(cameraState.azimuth),
elevation: getElevationString(cameraState.elevation),
distance: getDistanceString(cameraState.distance),
lora_scale: loraScale,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "生成失败");
}
const data = await response.json();
setResultUrl(data.image_url);
toast.success("视角生成成功!");
} catch (error) {
console.error("角度编辑失败:", error);
toast.error(error instanceof Error ? error.message : "生成失败,请重试");
} finally {
setIsProcessing(false);
}
}, [imageUrl, cameraState, loraScale]);
// 下载结果图片
const handleDownload = useCallback(async () => {
if (!resultUrl) return;
try {
const response = await fetch(resultUrl);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `angle-edit-${cameraState.azimuth}-${cameraState.elevation}-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("图片已下载");
} catch {
toast.error("下载失败");
}
}, [resultUrl, cameraState]);
// 使用此图片(直接发送到聊天)
const handleUseImage = useCallback(() => {
if (resultUrl) {
// 关闭对话框,图片已经通过 API 保存到数据库
onOpenChange(false);
toast.success("图片已添加到对话");
}
}, [resultUrl, onOpenChange]);
// 重置
const handleReset = useCallback(() => {
setResultUrl(null);
setCameraState({
azimuth: 45,
elevation: 0,
distance: 5,
});
setLoraScale(0.9);
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b bg-muted/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Move3D className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>多角度编辑</DialogTitle>
<DialogDescription className="mt-1">
拖拽 3D 控制器中的彩色手柄调整相机角度,AI 将生成对应视角的图像
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full">
{/* 左侧:3D 控制器 (占 5 列) */}
<div className="lg:col-span-5 space-y-4 flex flex-col">
<div className="flex-1 min-h-[400px]">
<QwenCameraWidget
initialState={cameraState}
imageUrl={imageUrl}
onStateChange={handleCameraStateChange}
className="h-full"
/>
</div>
{/* LoRA 强度控制 */}
<div className="rounded-lg border bg-card p-4 space-y-3 shadow-sm">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">变换强度</Label>
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">
{(loraScale * 100).toFixed(0)}%
</span>
</div>
<Slider
value={[loraScale]}
onValueChange={([v]: number[]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
className="py-2"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
强度越高角度变化越明显,越低则保留更多原图特征。建议 85-95%。
</p>
</div>
</div>
{/* 右侧:图片预览 (占 7 列) */}
<div className="lg:col-span-7 flex flex-col gap-4 min-h-[500px]">
<div className="grid grid-rows-2 gap-4 flex-1 h-full">
{/* 原图 */}
<div className="relative rounded-xl border bg-muted/30 overflow-hidden group">
<div className="absolute top-3 left-3 z-10 bg-background/80 backdrop-blur-sm px-2 py-1 rounded text-xs font-medium border shadow-sm">
原图
</div>
<img
src={imageUrl}
alt="原图"
className="w-full h-full object-contain p-4 transition-transform duration-300 group-hover:scale-[1.02]"
/>
</div>
{/* 结果图 */}
<div className={cn(
"relative rounded-xl border overflow-hidden transition-all duration-300",
resultUrl ? "bg-muted/30 border-primary/20 shadow-sm" : "bg-muted/10 border-dashed"
)}>
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
<div className={cn(
"px-2 py-1 rounded text-xs font-medium border shadow-sm backdrop-blur-sm",
resultUrl ? "bg-primary/10 text-primary border-primary/20" : "bg-background/80 text-muted-foreground"
)}>
生成结果
</div>
{resultUrl && (
<span className="flex items-center gap-1 text-[10px] text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full border border-green-100">
<Check className="h-3 w-3" /> 完成
</span>
)}
</div>
<div className="w-full h-full flex items-center justify-center">
{isProcessing ? (
<div className="text-center space-y-4">
<div className="relative">
<div className="h-12 w-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin mx-auto" />
<div className="absolute inset-0 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-primary/50 animate-pulse" />
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">正在生成新视角...</p>
<p className="text-xs text-muted-foreground">预计需要 10-30 秒</p>
</div>
</div>
) : resultUrl ? (
<img
src={resultUrl}
alt="生成结果"
className="w-full h-full object-contain p-4 animate-in fade-in zoom-in-95 duration-300"
/>
) : (
<div className="text-center text-muted-foreground p-8">
<div className="bg-muted rounded-full p-4 w-fit mx-auto mb-4">
<Move3D className="h-8 w-8 opacity-50" />
</div>
<p className="text-sm font-medium text-foreground">准备就绪</p>
<p className="text-xs mt-1">拖拽左侧彩色手柄调整角度后点击生成</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* 底部操作栏 */}
<div className="px-6 py-4 border-t bg-muted/30 flex justify-between items-center">
<Button
variant="ghost"
onClick={handleReset}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-4 w-4 mr-2" />
重置参数
</Button>
<div className="flex gap-3">
{resultUrl ? (
<>
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</Button>
<Button onClick={handleUseImage} className="min-w-[120px]">
<Check className="h-4 w-4 mr-2" />
使用此图
</Button>
</>
) : (
<Button
onClick={handleGenerate}
disabled={isProcessing}
className="min-w-[140px]"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
生成中...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
生成新视角
</>
)}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default AngleEditDialog;