lovart3/components/angle-edit-dialog.tsx

313 lines
11 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 } 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 { AngleController3D } from "./angle-controller-3d";
import { cn } from "@/lib/utils";
interface AngleEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageUrl: string;
onComplete?: (resultUrl: string) => void;
}
interface AngleParams {
azimuth: string;
elevation: string;
distance: string;
azimuthDegree: number;
elevationDegree: number;
distanceFactor: number;
}
export function AngleEditDialog({
open,
onOpenChange,
imageUrl,
onComplete,
}: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(0.9);
const [currentAngles, setCurrentAngles] = useState<AngleParams>({
azimuth: "front-right",
elevation: "eye-level",
distance: "medium",
azimuthDegree: 45,
elevationDegree: 0,
distanceFactor: 1.0,
});
// 处理角度变化
const handleAngleChange = useCallback((angles: AngleParams) => {
setCurrentAngles(angles);
// 清除之前的结果,因为角度变了
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: currentAngles.azimuth,
elevation: currentAngles.elevation,
distance: currentAngles.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, currentAngles, 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-${currentAngles.azimuth}-${currentAngles.elevation}-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("图片已下载");
} catch {
toast.error("下载失败");
}
}, [resultUrl, currentAngles]);
// 使用此图片
const handleUseImage = useCallback(() => {
if (resultUrl) {
onComplete?.(resultUrl);
onOpenChange(false);
}
}, [resultUrl, onComplete, onOpenChange]);
// 重置
const handleReset = useCallback(() => {
setResultUrl(null);
setCurrentAngles({
azimuth: "front-right",
elevation: "eye-level",
distance: "medium",
azimuthDegree: 45,
elevationDegree: 0,
distanceFactor: 1.0,
});
setLoraScale(0.9);
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl 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-8 h-full">
{/* 左侧:3D 控制器 (占 5 列) */}
<div className="lg:col-span-5 space-y-6 flex flex-col">
<div className="flex-1">
<AngleController3D
onChange={handleAngleChange}
initialAzimuth={currentAngles.azimuthDegree}
initialElevation={currentAngles.elevationDegree}
initialDistance={currentAngles.distanceFactor}
/>
</div>
{/* LoRA 强度控制 - 放入卡片 */}
<div className="rounded-lg border bg-card p-4 space-y-4 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;