- 新增 qwen-camera-widget.tsx:基于 Three.js 的专业 3D 相机控制组件 - 彩色拖拽手柄:粉色(方位角)、青色(仰角)、金色(距离) - 下拉快速选择预设角度 - 实时数值显示和提示词预览 - 暗色主题 UI,符合 ComfyUI-qwenmultiangle 设计风格 - 更新 angle-edit-dialog 使用新组件
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
"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;
|