316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback, useEffect } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} 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, X } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { QwenCameraWidget, type CameraState } from "./qwen-camera-widget";
|
||
import { cn } from "@/lib/utils";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
|
||
interface AngleEditDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
imageUrl: string;
|
||
onComplete?: (resultUrl: string) => void;
|
||
}
|
||
|
||
export function AngleEditDialog({
|
||
open,
|
||
onOpenChange,
|
||
imageUrl,
|
||
onComplete,
|
||
}: AngleEditDialogProps) {
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||
const [loraScale, setLoraScale] = useState(1.0); // 官方默认值是 1
|
||
const [cameraState, setCameraState] = useState<CameraState>({
|
||
azimuth: 45,
|
||
elevation: 0,
|
||
distance: 5,
|
||
});
|
||
// 预览 Tab 状态:如果生成了结果,默认显示结果;否则显示原图
|
||
const [activeTab, setActiveTab] = useState<"original" | "result">("original");
|
||
|
||
// 当对话框关闭时重置状态
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setResultUrl(null);
|
||
setIsProcessing(false);
|
||
setActiveTab("original");
|
||
}
|
||
}, [open]);
|
||
|
||
// 当生成结果时,自动切换到结果 Tab
|
||
useEffect(() => {
|
||
if (resultUrl) {
|
||
setActiveTab("result");
|
||
}
|
||
}, [resultUrl]);
|
||
|
||
// 处理相机状态变化
|
||
const handleCameraStateChange = useCallback((state: CameraState) => {
|
||
setCameraState(state);
|
||
// 角度变化时清除之前的结果
|
||
if (!isProcessing) {
|
||
setResultUrl(null);
|
||
}
|
||
}, [isProcessing]);
|
||
|
||
// 生成新视角
|
||
const handleGenerate = useCallback(async () => {
|
||
setIsProcessing(true);
|
||
setResultUrl(null);
|
||
setActiveTab("result"); // 切换到结果 Tab 显示加载状态
|
||
|
||
try {
|
||
// 使用官方 API 的数值参数格式
|
||
const response = await fetch("/api/angle-edit", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
image_url: imageUrl,
|
||
horizontal_angle: cameraState.azimuth, // 0-360 度
|
||
vertical_angle: cameraState.elevation, // -30 到 90 度
|
||
zoom: cameraState.distance, // 0-10
|
||
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 : "生成失败,请重试");
|
||
setActiveTab("original"); // 失败后切回原图
|
||
} 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) {
|
||
// 如果提供了 onComplete 回调,调用它
|
||
if (onComplete) {
|
||
onComplete(resultUrl);
|
||
}
|
||
// 关闭对话框,图片已经通过 API 保存到数据库
|
||
onOpenChange(false);
|
||
toast.success("图片已添加到对话");
|
||
}
|
||
}, [resultUrl, onOpenChange, onComplete]);
|
||
|
||
// 重置
|
||
const handleReset = useCallback(() => {
|
||
setResultUrl(null);
|
||
setCameraState({
|
||
azimuth: 45,
|
||
elevation: 0,
|
||
distance: 5,
|
||
});
|
||
setLoraScale(0.9);
|
||
setActiveTab("original");
|
||
}, []);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-none shadow-2xl bg-background/95 backdrop-blur-xl">
|
||
{/* 紧凑标题栏 */}
|
||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/20">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-1.5 bg-primary/10 rounded-md">
|
||
<Move3D className="h-4 w-4 text-primary" />
|
||
</div>
|
||
<span className="font-semibold text-sm">3D 视角编辑器</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex h-[500px]">
|
||
{/* 左侧:3D 控制区 (固定宽度 320px) */}
|
||
<div className="w-[320px] flex flex-col border-r bg-muted/10">
|
||
{/* 上部内容区域 - 可伸缩 */}
|
||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||
{/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */}
|
||
<QwenCameraWidget
|
||
initialState={cameraState}
|
||
imageUrl={imageUrl}
|
||
onStateChange={handleCameraStateChange}
|
||
className="flex-1 min-h-0 mb-3"
|
||
/>
|
||
|
||
{/* 变换强度 - 调整样式使其更紧凑 */}
|
||
<div className="space-y-2 px-1 flex-shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-[10px] font-semibold text-muted-foreground w-8">强度</label>
|
||
<div className="flex-1 flex items-center gap-2">
|
||
<Slider
|
||
value={[loraScale]}
|
||
onValueChange={([v]: number[]) => setLoraScale(v)}
|
||
min={0.5}
|
||
max={1.0}
|
||
step={0.05}
|
||
className="flex-1 py-1"
|
||
/>
|
||
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground min-w-[36px] text-center">
|
||
{(loraScale * 100).toFixed(0)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部按钮区域 - 包含重置和生成 */}
|
||
<div className="p-4 border-t bg-background flex-shrink-0 flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
onClick={handleReset}
|
||
disabled={isProcessing}
|
||
className="h-10 w-10 p-0 flex-shrink-0"
|
||
title="重置参数"
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={handleGenerate}
|
||
disabled={isProcessing}
|
||
className="flex-1 shadow-md transition-all hover:scale-[1.02] h-10"
|
||
size="lg"
|
||
>
|
||
{isProcessing ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
生成中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Sparkles className="h-4 w-4 mr-2" />
|
||
生成新视角
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:预览区 (自适应) */}
|
||
<div className="flex-1 flex flex-col bg-muted/5 p-4">
|
||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<TabsList className="h-8">
|
||
<TabsTrigger value="original" className="text-xs h-7 px-3">原图</TabsTrigger>
|
||
<TabsTrigger value="result" className="text-xs h-7 px-3" disabled={!resultUrl && !isProcessing}>
|
||
生成结果
|
||
{resultUrl && <span className="ml-1.5 w-1.5 h-1.5 rounded-full bg-green-500" />}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{resultUrl && (
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" size="sm" onClick={handleDownload} className="h-8 text-xs">
|
||
<Download className="h-3 w-3 mr-1.5" />
|
||
下载
|
||
</Button>
|
||
<Button size="sm" onClick={handleUseImage} className="h-8 text-xs">
|
||
<Check className="h-3 w-3 mr-1.5" />
|
||
使用
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 relative rounded-lg border bg-background overflow-hidden shadow-inner">
|
||
{/* 原图 Tab */}
|
||
<TabsContent value="original" className="absolute inset-0 m-0 h-full w-full p-0">
|
||
<div className="w-full h-full flex items-center justify-center p-4">
|
||
<img
|
||
src={imageUrl}
|
||
alt="原图"
|
||
className="max-w-full max-h-full object-contain"
|
||
/>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* 结果 Tab */}
|
||
<TabsContent value="result" className="absolute inset-0 m-0 h-full w-full p-0">
|
||
<div className="w-full h-full flex items-center justify-center bg-checkered p-4">
|
||
{isProcessing ? (
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="relative">
|
||
<div className="h-10 w-10 rounded-full border-2 border-primary/20 border-t-primary animate-spin" />
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<Sparkles className="h-4 w-4 text-primary/50 animate-pulse" />
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground animate-pulse">正在渲染新视角...</p>
|
||
</div>
|
||
) : resultUrl ? (
|
||
<div className="relative w-full h-full flex items-center justify-center group">
|
||
<img
|
||
src={resultUrl}
|
||
alt="生成结果"
|
||
className="max-w-full max-h-full object-contain animate-in fade-in zoom-in-95 duration-300"
|
||
/>
|
||
<div className="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<Button onClick={handleDownload} size="sm" variant="secondary" className="shadow-lg backdrop-blur-md bg-background/80 hover:bg-background/90">
|
||
<Download className="h-4 w-4 mr-2" />
|
||
下载图片
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-muted-foreground">
|
||
<Move3D className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||
<p className="text-xs">暂无结果</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
</div>
|
||
</Tabs>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export default AngleEditDialog;
|
||
|