313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"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;
|
||
|