feat: Integrate Qwen-Image-Edit 3D angle controller and update UI
This commit is contained in:
parent
00b167909f
commit
b12dd2d77a
3
.gitignore
vendored
3
.gitignore
vendored
@ -70,3 +70,6 @@ checkpoints/
|
||||
*.tflite
|
||||
*.mlmodel
|
||||
|
||||
|
||||
# 第三方参考源码(不提交到git)
|
||||
reference-sources/
|
||||
|
||||
@ -50,6 +50,7 @@ import {
|
||||
RefreshCw,
|
||||
Scissors,
|
||||
Expand,
|
||||
Move3D,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@ -370,6 +371,18 @@ export function AdminSettingsClient() {
|
||||
yScale: "1.5", // Y轴放大倍数
|
||||
});
|
||||
|
||||
// 角度编辑API配置状态
|
||||
const [angleEditConfig, setAngleEditConfig] = useState({
|
||||
enabled: false,
|
||||
falApiKey: "",
|
||||
loraScale: 0.9,
|
||||
loraUrl: "",
|
||||
inferenceSteps: 28,
|
||||
guidanceScale: 3.5,
|
||||
maxImageSize: 10,
|
||||
timeout: 120,
|
||||
});
|
||||
|
||||
// 加载抠图API配置
|
||||
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
|
||||
useEffect(() => {
|
||||
@ -428,6 +441,33 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}, [extendConfigData]);
|
||||
|
||||
// 加载角度编辑API配置
|
||||
const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher);
|
||||
useEffect(() => {
|
||||
if (angleEditConfigData?.setting?.value) {
|
||||
const config = angleEditConfigData.setting.value as {
|
||||
enabled?: boolean;
|
||||
falApiKey?: string;
|
||||
loraScale?: number;
|
||||
loraUrl?: string;
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
maxImageSize?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
setAngleEditConfig({
|
||||
enabled: config.enabled || false,
|
||||
falApiKey: config.falApiKey || "",
|
||||
loraScale: config.loraScale ?? 0.9,
|
||||
loraUrl: config.loraUrl || "",
|
||||
inferenceSteps: config.inferenceSteps ?? 28,
|
||||
guidanceScale: config.guidanceScale ?? 3.5,
|
||||
maxImageSize: config.maxImageSize ?? 10,
|
||||
timeout: config.timeout ?? 120,
|
||||
});
|
||||
}
|
||||
}, [angleEditConfigData]);
|
||||
|
||||
// 导航链接状态
|
||||
const [navLinkForm, setNavLinkForm] = useState({
|
||||
id: "",
|
||||
@ -828,6 +868,39 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- 角度编辑API配置逻辑 ---
|
||||
const handleSaveAngleEditConfig = async () => {
|
||||
try {
|
||||
if (angleEditConfig.enabled && !angleEditConfig.falApiKey.trim()) {
|
||||
toast.error("启用角度编辑功能需要配置 fal.ai API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: "angle_edit_config",
|
||||
value: {
|
||||
enabled: angleEditConfig.enabled,
|
||||
falApiKey: angleEditConfig.falApiKey.trim(),
|
||||
loraScale: angleEditConfig.loraScale,
|
||||
loraUrl: angleEditConfig.loraUrl.trim() || undefined,
|
||||
inferenceSteps: angleEditConfig.inferenceSteps,
|
||||
guidanceScale: angleEditConfig.guidanceScale,
|
||||
maxImageSize: angleEditConfig.maxImageSize,
|
||||
timeout: angleEditConfig.timeout,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("保存失败");
|
||||
toast.success("角度编辑API配置保存成功");
|
||||
} catch (error) {
|
||||
toast.error("保存角度编辑API配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
// --- 导航链接逻辑 ---
|
||||
const resetNavLinkForm = () => {
|
||||
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
|
||||
@ -3381,6 +3454,179 @@ export function AdminSettingsClient() {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 角度编辑API配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Move3D className="h-5 w-5" />
|
||||
角度编辑API配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
配置 fal.ai 多角度图像编辑 API,用于对同一图像生成不同视角的变体
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
{/* 启用开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用角度编辑功能</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
开启后用户可在图片编辑器中使用角度编辑功能
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={angleEditConfig.enabled}
|
||||
onCheckedChange={(checked) => setAngleEditConfig({ ...angleEditConfig, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="falApiKey">
|
||||
fal.ai API Key <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="falApiKey"
|
||||
type="password"
|
||||
value={angleEditConfig.falApiKey}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, falApiKey: e.target.value })}
|
||||
placeholder="fal_xxxxxxxxxxxxx"
|
||||
disabled={!angleEditConfig.enabled}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
从{" "}
|
||||
<a
|
||||
href="https://fal.ai/dashboard/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-primary"
|
||||
>
|
||||
fal.ai Dashboard
|
||||
</a>
|
||||
{" "}获取 API Key
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* LoRA 强度 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<Label>默认变换强度</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(angleEditConfig.loraScale * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
value={angleEditConfig.loraScale}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, loraScale: Number.parseFloat(e.target.value) })}
|
||||
min={0.5}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
disabled={!angleEditConfig.enabled}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
值越高角度变化越明显,值越低保留原图特征越多(推荐 85%-95%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 高级设置 */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">高级设置</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loraUrl">LoRA 模型地址(可选)</Label>
|
||||
<Input
|
||||
id="loraUrl"
|
||||
value={angleEditConfig.loraUrl}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, loraUrl: e.target.value })}
|
||||
placeholder="留空使用默认地址"
|
||||
disabled={!angleEditConfig.enabled}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
默认: huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inferenceSteps">推理步数</Label>
|
||||
<Input
|
||||
id="inferenceSteps"
|
||||
type="number"
|
||||
value={angleEditConfig.inferenceSteps}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, inferenceSteps: Number.parseInt(e.target.value) || 28 })}
|
||||
min={10}
|
||||
max={50}
|
||||
disabled={!angleEditConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guidanceScale">引导系数</Label>
|
||||
<Input
|
||||
id="guidanceScale"
|
||||
type="number"
|
||||
value={angleEditConfig.guidanceScale}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, guidanceScale: Number.parseFloat(e.target.value) || 3.5 })}
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
disabled={!angleEditConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxImageSize">最大图片(MB)</Label>
|
||||
<Input
|
||||
id="maxImageSize"
|
||||
type="number"
|
||||
value={angleEditConfig.maxImageSize}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, maxImageSize: Number.parseInt(e.target.value) || 10 })}
|
||||
min={1}
|
||||
max={50}
|
||||
disabled={!angleEditConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">超时时间(秒)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={angleEditConfig.timeout}
|
||||
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, timeout: Number.parseInt(e.target.value) || 120 })}
|
||||
min={30}
|
||||
max={300}
|
||||
disabled={!angleEditConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-blue-900">功能说明</div>
|
||||
<div className="text-xs text-blue-800 space-y-1">
|
||||
<p>• 使用 Qwen-Image-Edit-2511-Multiple-Angles-LoRA 模型</p>
|
||||
<p>• 支持 96 种相机姿态(4 仰角 × 8 方位角 × 3 距离)</p>
|
||||
<p>• 可生成正面、侧面、俯视、仰视等多角度图像</p>
|
||||
<p>• 处理时间:约 10-30 秒/张</p>
|
||||
<p>• 费用:约 $0.02-0.05/张(fal.ai 按量计费)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveAngleEditConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
299
app/api/angle-edit/route.ts
Normal file
299
app/api/angle-edit/route.ts
Normal file
@ -0,0 +1,299 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
|
||||
// 角度编辑API配置类型
|
||||
type AngleEditConfig = {
|
||||
enabled?: boolean;
|
||||
falApiKey?: string;
|
||||
loraScale?: number;
|
||||
loraUrl?: string;
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
maxImageSize?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
// 角度预设配置(使用官方提示词格式)
|
||||
const ANGLE_PRESETS: Record<string, { azimuth: string; elevation: string; distance: string }> = {
|
||||
// 汽车常用角度
|
||||
"front": { azimuth: "front view", elevation: "eye-level shot", distance: "medium shot" },
|
||||
"front-3/4": { azimuth: "front-right quarter view", elevation: "eye-level shot", distance: "medium shot" },
|
||||
"side": { azimuth: "right side view", elevation: "eye-level shot", distance: "medium shot" },
|
||||
"rear-3/4": { azimuth: "back-right quarter view", elevation: "eye-level shot", distance: "medium shot" },
|
||||
"rear": { azimuth: "back view", elevation: "eye-level shot", distance: "medium shot" },
|
||||
"overhead": { azimuth: "front view", elevation: "high-angle shot", distance: "wide shot" },
|
||||
"low-front": { azimuth: "front view", elevation: "low-angle shot", distance: "close-up" },
|
||||
"low-side": { azimuth: "right side view", elevation: "low-angle shot", distance: "medium shot" },
|
||||
};
|
||||
|
||||
// 方位角映射(官方格式)
|
||||
const AZIMUTH_MAP: Record<string, string> = {
|
||||
"front": "front view",
|
||||
"front-right": "front-right quarter view",
|
||||
"right": "right side view",
|
||||
"back-right": "back-right quarter view",
|
||||
"back": "back view",
|
||||
"back-left": "back-left quarter view",
|
||||
"left": "left side view",
|
||||
"front-left": "front-left quarter view",
|
||||
};
|
||||
|
||||
// 仰角映射(官方格式)
|
||||
const ELEVATION_MAP: Record<string, string> = {
|
||||
"high": "high-angle shot", // 60°
|
||||
"elevated": "elevated shot", // 30°
|
||||
"eye-level": "eye-level shot", // 0°
|
||||
"low": "low-angle shot", // -30°
|
||||
};
|
||||
|
||||
// 距离映射(官方格式)
|
||||
const DISTANCE_MAP: Record<string, string> = {
|
||||
"close": "close-up", // ×0.6
|
||||
"medium": "medium shot", // ×1.0
|
||||
"wide": "wide shot", // ×1.8
|
||||
};
|
||||
|
||||
// 默认 LoRA 地址
|
||||
const DEFAULT_LORA_URL =
|
||||
"https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors";
|
||||
|
||||
// 设置API路由最大执行时间为3分钟(180秒)
|
||||
export const maxDuration = 180;
|
||||
|
||||
/**
|
||||
* 压缩图片生成缩略图
|
||||
*/
|
||||
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const sharp = await import("sharp");
|
||||
|
||||
const thumbnailBuffer = await sharp.default(buffer)
|
||||
.resize(500, null, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
console.log(`[Angle-Edit] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
console.error("[Angle-Edit] Failed to create thumbnail:", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 图片上传到 S3
|
||||
*/
|
||||
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).slice(2, 8);
|
||||
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
|
||||
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
|
||||
|
||||
console.log(`[Angle-Edit] Original size: ${buffer.length} bytes`);
|
||||
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
|
||||
thumbnailBuffer.byteOffset,
|
||||
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
const originalArrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
|
||||
console.log(`[Angle-Edit] Uploading thumbnail and original to S3...`);
|
||||
const [thumbnailResult, originalResult] = await Promise.all([
|
||||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||||
uploadFile(originalFilename, originalArrayBuffer),
|
||||
]);
|
||||
|
||||
console.log(`[Angle-Edit] Uploaded to S3:`);
|
||||
console.log(` - Thumbnail: ${thumbnailResult.url}`);
|
||||
console.log(` - Original: ${originalResult.url}`);
|
||||
|
||||
return {
|
||||
thumbnail: thumbnailResult.url,
|
||||
original: originalResult.url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Angle-Edit] Failed to upload to S3:", error);
|
||||
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 验证用户登录
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
console.log("[Angle-Edit] ===== 收到请求 =====");
|
||||
console.log("[Angle-Edit] 用户ID:", session.user.id);
|
||||
|
||||
// 2. 从数据库获取配置
|
||||
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
|
||||
const config = (angleEditConfig?.value as AngleEditConfig) || {};
|
||||
|
||||
// 验证功能是否启用
|
||||
if (!config.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 API Key
|
||||
if (!config.falApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[Angle-Edit] 配置加载成功,LoRA强度:", config.loraScale || 0.9);
|
||||
|
||||
// 3. 解析请求
|
||||
const body = await request.json();
|
||||
const {
|
||||
image_url,
|
||||
image_base64,
|
||||
preset,
|
||||
azimuth,
|
||||
elevation,
|
||||
distance,
|
||||
lora_scale,
|
||||
} = body;
|
||||
|
||||
if (!image_url && !image_base64) {
|
||||
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 使用请求中的 lora_scale 或配置中的默认值
|
||||
const finalLoraScale = lora_scale ?? config.loraScale ?? 0.9;
|
||||
|
||||
// 4. 构建角度参数
|
||||
let angleParams: { azimuth: string; elevation: string; distance: string };
|
||||
|
||||
if (preset && ANGLE_PRESETS[preset]) {
|
||||
angleParams = ANGLE_PRESETS[preset];
|
||||
console.log("[Angle-Edit] 使用预设:", preset);
|
||||
} else {
|
||||
angleParams = {
|
||||
azimuth: AZIMUTH_MAP[azimuth] || azimuth || "front view",
|
||||
elevation: ELEVATION_MAP[elevation] || elevation || "eye-level shot",
|
||||
distance: DISTANCE_MAP[distance] || distance || "medium shot",
|
||||
};
|
||||
console.log("[Angle-Edit] 使用自定义参数:", angleParams);
|
||||
}
|
||||
|
||||
// 5. 构建提示词
|
||||
const prompt = `<sks> ${angleParams.azimuth} ${angleParams.elevation} ${angleParams.distance}`;
|
||||
console.log("[Angle-Edit] 提示词:", prompt);
|
||||
|
||||
// 6. 准备图片输入
|
||||
let imageInput: string;
|
||||
if (image_url) {
|
||||
imageInput = image_url;
|
||||
} else {
|
||||
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
|
||||
imageInput = `data:${mimeType};base64,${image_base64}`;
|
||||
}
|
||||
|
||||
// 7. 动态导入 fal.ai SDK 并调用 API
|
||||
console.log("[Angle-Edit] 调用 fal.ai API...");
|
||||
|
||||
const fal = await import("@fal-ai/serverless-client");
|
||||
|
||||
fal.config({
|
||||
credentials: config.falApiKey,
|
||||
});
|
||||
|
||||
const loraUrl = config.loraUrl || DEFAULT_LORA_URL;
|
||||
|
||||
const result = await fal.subscribe("fal-ai/qwen-image-edit", {
|
||||
input: {
|
||||
image_url: imageInput,
|
||||
prompt: prompt,
|
||||
lora_url: loraUrl,
|
||||
lora_scale: finalLoraScale,
|
||||
num_inference_steps: config.inferenceSteps || 28,
|
||||
guidance_scale: config.guidanceScale || 3.5,
|
||||
},
|
||||
logs: true,
|
||||
onQueueUpdate: (update) => {
|
||||
if (update.status === "IN_PROGRESS") {
|
||||
console.log("[Angle-Edit] 处理进度...");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 8. 处理结果
|
||||
const outputImage = (result as { images?: Array<{ url?: string }> }).images?.[0];
|
||||
if (!outputImage?.url) {
|
||||
throw new Error("API 未返回有效图片");
|
||||
}
|
||||
|
||||
const outputImageUrl = outputImage.url;
|
||||
console.log("[Angle-Edit] 输出图片URL:", outputImageUrl);
|
||||
|
||||
// 9. 下载图片并上传到我们的 S3
|
||||
const imageResponse = await fetch(outputImageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
|
||||
}
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const imageBase64Result = imageBuffer.toString("base64");
|
||||
|
||||
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
|
||||
console.log("[Angle-Edit] 已上传到S3:", s3Result.original);
|
||||
|
||||
// 10. 保存到数据库
|
||||
const processingTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
await db.insert(generatedImage).values({
|
||||
userId: session.user.id,
|
||||
thumbnailUrl: s3Result.thumbnail,
|
||||
originalUrl: s3Result.original,
|
||||
prompt: `角度编辑: ${preset || `${azimuth}-${elevation}-${distance}`}`,
|
||||
modelId: "qwen-image-edit-angle",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒");
|
||||
|
||||
// 11. 返回结果
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
image_url: s3Result.original,
|
||||
thumbnail_url: s3Result.thumbnail,
|
||||
image_base64: imageBase64Result,
|
||||
processing_time: processingTime,
|
||||
preset: preset,
|
||||
angles: angleParams,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Angle-Edit] ❌ 错误:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "角度编辑处理失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
499
components/angle-controller-3d.tsx
Normal file
499
components/angle-controller-3d.tsx
Normal file
@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useMemo, Suspense, useEffect } from "react";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, PerspectiveCamera, Html, useHelper } from "@react-three/drei";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
import * as THREE from "three";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 角度配置常量
|
||||
const AZIMUTH_OPTIONS = [
|
||||
{ angle: 0, label: "正前方", value: "front" },
|
||||
{ angle: 45, label: "前右", value: "front-right" },
|
||||
{ angle: 90, label: "右侧", value: "right" },
|
||||
{ angle: 135, label: "后右", value: "back-right" },
|
||||
{ angle: 180, label: "正后方", value: "back" },
|
||||
{ angle: 225, label: "后左", value: "back-left" },
|
||||
{ angle: 270, label: "左侧", value: "left" },
|
||||
{ angle: 315, label: "前左", value: "front-left" },
|
||||
];
|
||||
|
||||
const ELEVATION_OPTIONS = [
|
||||
{ angle: -30, label: "仰视", value: "low" },
|
||||
{ angle: 0, label: "平视", value: "eye-level" },
|
||||
{ angle: 30, label: "俯视", value: "elevated" },
|
||||
{ angle: 60, label: "高角度", value: "high" },
|
||||
];
|
||||
|
||||
const DISTANCE_OPTIONS = [
|
||||
{ factor: 0.6, label: "特写", value: "close" },
|
||||
{ factor: 1.0, label: "中景", value: "medium" },
|
||||
{ factor: 1.8, label: "远景", value: "wide" },
|
||||
];
|
||||
|
||||
interface AngleController3DProps {
|
||||
onChange?: (angles: {
|
||||
azimuth: string;
|
||||
elevation: string;
|
||||
distance: string;
|
||||
azimuthDegree: number;
|
||||
elevationDegree: number;
|
||||
distanceFactor: number;
|
||||
}) => void;
|
||||
initialAzimuth?: number;
|
||||
initialElevation?: number;
|
||||
initialDistance?: number;
|
||||
showObject?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 3D 场景中的相机指示器 - 简约现代风格
|
||||
function CameraIndicator({ position }: { position: THREE.Vector3 }) {
|
||||
// 计算相机朝向中心的旋转
|
||||
const lookAtCenter = useMemo(() => {
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const matrix = new THREE.Matrix4();
|
||||
matrix.lookAt(position, new THREE.Vector3(0, 0, 0), up);
|
||||
quaternion.setFromRotationMatrix(matrix);
|
||||
return quaternion;
|
||||
}, [position]);
|
||||
|
||||
return (
|
||||
<group position={position} quaternion={lookAtCenter}>
|
||||
{/* 相机主体 - 哑光黑/深灰 */}
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[0.28, 0.2, 0.18]} />
|
||||
<meshStandardMaterial color="#334155" metalness={0.2} roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* 相机顶部 */}
|
||||
<mesh position={[0, 0.12, -0.02]} castShadow>
|
||||
<boxGeometry args={[0.12, 0.06, 0.1]} />
|
||||
<meshStandardMaterial color="#1e293b" metalness={0.2} roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* 镜头筒 - 金属质感 */}
|
||||
<mesh position={[0, 0, 0.14]} rotation={[Math.PI / 2, 0, 0]} castShadow>
|
||||
<cylinderGeometry args={[0.08, 0.1, 0.12, 24]} />
|
||||
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
|
||||
</mesh>
|
||||
|
||||
{/* 镜头玻璃 - 蓝色反光 */}
|
||||
<mesh position={[0, 0, 0.21]}>
|
||||
<circleGeometry args={[0.07, 24]} />
|
||||
<meshStandardMaterial
|
||||
color="#3b82f6"
|
||||
metalness={0.9}
|
||||
roughness={0.1}
|
||||
transparent
|
||||
opacity={0.9}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 视野指示 - 极简线条 */}
|
||||
<group position={[0, 0, 0.2]}>
|
||||
<line>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={5}
|
||||
args={[
|
||||
new Float32Array([
|
||||
-0.3, -0.2, 1.0, // 左下
|
||||
0.3, -0.2, 1.0, // 右下
|
||||
0.3, 0.2, 1.0, // 右上
|
||||
-0.3, 0.2, 1.0, // 左上
|
||||
-0.3, -0.2, 1.0, // 闭合
|
||||
]),
|
||||
3
|
||||
]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color="#3b82f6" opacity={0.3} transparent />
|
||||
</line>
|
||||
|
||||
{/* 连接线 */}
|
||||
{[
|
||||
[-0.3, -0.2, 1.0], [0.3, -0.2, 1.0], [0.3, 0.2, 1.0], [-0.3, 0.2, 1.0]
|
||||
].map((point, i) => (
|
||||
<line key={i}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={2}
|
||||
args={[
|
||||
new Float32Array([0, 0, 0, ...point]),
|
||||
3
|
||||
]}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color="#3b82f6" opacity={0.15} transparent />
|
||||
</line>
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// 方位角刻度圆环 - 更加隐形
|
||||
function AzimuthRing() {
|
||||
return (
|
||||
<group>
|
||||
{/* 圆环 */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[1.98, 2.02, 64]} />
|
||||
<meshBasicMaterial color="#94a3b8" opacity={0.2} transparent side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
|
||||
{/* 方位角标签 */}
|
||||
{AZIMUTH_OPTIONS.map((opt) => {
|
||||
const rad = (opt.angle * Math.PI) / 180;
|
||||
const x = Math.sin(rad) * 2.3;
|
||||
const z = Math.cos(rad) * 2.3;
|
||||
return (
|
||||
<Html key={opt.value} position={[x, 0, z]} center>
|
||||
<div className="text-[10px] font-medium text-muted-foreground whitespace-nowrap select-none pointer-events-none bg-background/50 px-1.5 py-0.5 rounded-full border shadow-sm backdrop-blur-[2px]">
|
||||
{opt.label}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// 目标物体 - 极简风格
|
||||
function TargetObject() {
|
||||
return (
|
||||
<group>
|
||||
{/* 极简几何体代表目标 */}
|
||||
<mesh position={[0, 0.25, 0]}>
|
||||
<boxGeometry args={[0.5, 0.5, 0.5]} />
|
||||
<meshStandardMaterial color="#e2e8f0" metalness={0.1} roughness={0.8} />
|
||||
</mesh>
|
||||
|
||||
{/* 底部阴影 */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
|
||||
<circleGeometry args={[0.6, 32]} />
|
||||
<meshBasicMaterial color="#000000" opacity={0.1} transparent />
|
||||
</mesh>
|
||||
|
||||
{/* 网格地面 */}
|
||||
<gridHelper args={[4, 8, 0xe2e8f0, 0xf1f5f9]} position={[0, 0, 0]} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// 3D 控制场景
|
||||
function Scene({
|
||||
azimuth,
|
||||
elevation,
|
||||
distance,
|
||||
onAngleChange,
|
||||
showObject,
|
||||
}: {
|
||||
azimuth: number;
|
||||
elevation: number;
|
||||
distance: number;
|
||||
onAngleChange: (azimuth: number, elevation: number) => void;
|
||||
showObject: boolean;
|
||||
}) {
|
||||
const controlsRef = useRef<OrbitControlsImpl>(null);
|
||||
const ghostCameraRef = useRef<THREE.PerspectiveCamera>(null);
|
||||
const mainCameraRef = useRef<THREE.PerspectiveCamera>(null);
|
||||
|
||||
// 计算目标拍摄位置(CameraIndicator的位置)
|
||||
const targetPosition = useMemo(() => {
|
||||
const azimuthRad = (azimuth * Math.PI) / 180;
|
||||
const elevationRad = (elevation * Math.PI) / 180;
|
||||
const radius = 2 * distance;
|
||||
|
||||
const x = radius * Math.cos(elevationRad) * Math.sin(azimuthRad);
|
||||
const y = radius * Math.sin(elevationRad);
|
||||
const z = radius * Math.cos(elevationRad) * Math.cos(azimuthRad);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}, [azimuth, elevation, distance]);
|
||||
|
||||
// 同步 Ghost Camera 位置到 state
|
||||
// 当 state 改变时,更新 Ghost Camera 位置
|
||||
useEffect(() => {
|
||||
if (ghostCameraRef.current) {
|
||||
ghostCameraRef.current.position.copy(targetPosition);
|
||||
ghostCameraRef.current.lookAt(0, 0, 0);
|
||||
}
|
||||
}, [targetPosition]);
|
||||
|
||||
// 监听控制器变化 (用户拖拽)
|
||||
const handleControlsChange = useCallback(() => {
|
||||
if (ghostCameraRef.current) {
|
||||
const camera = ghostCameraRef.current;
|
||||
const spherical = new THREE.Spherical();
|
||||
spherical.setFromVector3(camera.position);
|
||||
|
||||
// 将球面坐标转换为我们的角度系统
|
||||
let newAzimuth = THREE.MathUtils.radToDeg(spherical.theta);
|
||||
if (newAzimuth < 0) newAzimuth += 360;
|
||||
|
||||
const newElevation = 90 - THREE.MathUtils.radToDeg(spherical.phi);
|
||||
|
||||
// 只有当角度变化超过阈值时才触发更新,避免浮点数抖动
|
||||
if (Math.abs(newAzimuth - azimuth) > 0.5 || Math.abs(newElevation - elevation) > 0.5) {
|
||||
onAngleChange(Math.round(newAzimuth), Math.round(newElevation));
|
||||
}
|
||||
}
|
||||
}, [onAngleChange, azimuth, elevation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 主相机:上帝视角,固定位置 */}
|
||||
<PerspectiveCamera
|
||||
makeDefault
|
||||
ref={mainCameraRef}
|
||||
position={[3.5, 2.5, 3.5]}
|
||||
fov={45}
|
||||
onUpdate={(c) => c.lookAt(0, 0, 0)}
|
||||
/>
|
||||
|
||||
{/* 幽灵相机:用于 OrbitControls 交互,不可见 */}
|
||||
<PerspectiveCamera ref={ghostCameraRef} position={targetPosition} />
|
||||
|
||||
{/* 控制器绑定到幽灵相机 */}
|
||||
<OrbitControls
|
||||
ref={controlsRef}
|
||||
camera={ghostCameraRef.current as any}
|
||||
enablePan={false}
|
||||
enableZoom={false}
|
||||
minPolarAngle={Math.PI / 6}
|
||||
maxPolarAngle={(Math.PI * 5) / 6}
|
||||
onChange={handleControlsChange}
|
||||
/>
|
||||
|
||||
{/* 灯光 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
|
||||
<pointLight position={[-5, 5, -5]} intensity={0.5} />
|
||||
|
||||
{/* 场景内容 */}
|
||||
{showObject && <TargetObject />}
|
||||
<AzimuthRing />
|
||||
|
||||
{/* 拍摄相机模型(在目标位置渲染) */}
|
||||
<CameraIndicator position={targetPosition} />
|
||||
|
||||
{/* 坐标轴辅助 */}
|
||||
<axesHelper args={[0.5]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 主组件
|
||||
export function AngleController3D({
|
||||
onChange,
|
||||
initialAzimuth = 45,
|
||||
initialElevation = 0,
|
||||
initialDistance = 1.0,
|
||||
showObject = true,
|
||||
className,
|
||||
}: AngleController3DProps) {
|
||||
const [azimuth, setAzimuth] = useState(initialAzimuth);
|
||||
const [elevation, setElevation] = useState(initialElevation);
|
||||
const [distance, setDistance] = useState(initialDistance);
|
||||
|
||||
// 将角度映射到最近的预设值
|
||||
const getClosestAzimuth = useCallback((deg: number) => {
|
||||
let minDiff = 360;
|
||||
let closest = AZIMUTH_OPTIONS[0];
|
||||
for (const opt of AZIMUTH_OPTIONS) {
|
||||
const diff = Math.min(Math.abs(deg - opt.angle), 360 - Math.abs(deg - opt.angle));
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = opt;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}, []);
|
||||
|
||||
const getClosestElevation = useCallback((deg: number) => {
|
||||
let minDiff = 180;
|
||||
let closest = ELEVATION_OPTIONS[0];
|
||||
for (const opt of ELEVATION_OPTIONS) {
|
||||
const diff = Math.abs(deg - opt.angle);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = opt;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}, []);
|
||||
|
||||
const getClosestDistance = useCallback((factor: number) => {
|
||||
let minDiff = 10;
|
||||
let closest = DISTANCE_OPTIONS[0];
|
||||
for (const opt of DISTANCE_OPTIONS) {
|
||||
const diff = Math.abs(factor - opt.factor);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = opt;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
}, []);
|
||||
|
||||
// 处理角度变化
|
||||
const handleAngleChange = useCallback(
|
||||
(newAzimuth: number, newElevation: number) => {
|
||||
setAzimuth(newAzimuth);
|
||||
setElevation(newElevation);
|
||||
|
||||
const closestAzimuth = getClosestAzimuth(newAzimuth);
|
||||
const closestElevation = getClosestElevation(newElevation);
|
||||
const closestDistance = getClosestDistance(distance);
|
||||
|
||||
onChange?.({
|
||||
azimuth: closestAzimuth.value,
|
||||
elevation: closestElevation.value,
|
||||
distance: closestDistance.value,
|
||||
azimuthDegree: newAzimuth,
|
||||
elevationDegree: newElevation,
|
||||
distanceFactor: distance,
|
||||
});
|
||||
},
|
||||
[distance, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
|
||||
);
|
||||
|
||||
// 处理距离变化
|
||||
const handleDistanceChange = useCallback(
|
||||
(newDistance: number) => {
|
||||
setDistance(newDistance);
|
||||
|
||||
const closestAzimuth = getClosestAzimuth(azimuth);
|
||||
const closestElevation = getClosestElevation(elevation);
|
||||
const closestDistance = getClosestDistance(newDistance);
|
||||
|
||||
onChange?.({
|
||||
azimuth: closestAzimuth.value,
|
||||
elevation: closestElevation.value,
|
||||
distance: closestDistance.value,
|
||||
azimuthDegree: azimuth,
|
||||
elevationDegree: elevation,
|
||||
distanceFactor: newDistance,
|
||||
});
|
||||
},
|
||||
[azimuth, elevation, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
|
||||
);
|
||||
|
||||
// 快速选择预设角度
|
||||
const handlePresetClick = useCallback(
|
||||
(presetAzimuth: number, presetElevation: number) => {
|
||||
setAzimuth(presetAzimuth);
|
||||
setElevation(presetElevation);
|
||||
|
||||
const closestAzimuth = getClosestAzimuth(presetAzimuth);
|
||||
const closestElevation = getClosestElevation(presetElevation);
|
||||
const closestDistance = getClosestDistance(distance);
|
||||
|
||||
onChange?.({
|
||||
azimuth: closestAzimuth.value,
|
||||
elevation: closestElevation.value,
|
||||
distance: closestDistance.value,
|
||||
azimuthDegree: presetAzimuth,
|
||||
elevationDegree: presetElevation,
|
||||
distanceFactor: distance,
|
||||
});
|
||||
},
|
||||
[distance, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
|
||||
);
|
||||
|
||||
const currentAzimuth = getClosestAzimuth(azimuth);
|
||||
const currentElevation = getClosestElevation(elevation);
|
||||
const currentDistance = getClosestDistance(distance);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
{/* 3D 视图 */}
|
||||
<div className="relative aspect-square w-full max-w-[400px] mx-auto rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-white border border-slate-200 shadow-sm dark:from-slate-900 dark:to-slate-950 dark:border-slate-800">
|
||||
<Canvas>
|
||||
<Suspense fallback={null}>
|
||||
<Scene
|
||||
azimuth={azimuth}
|
||||
elevation={elevation}
|
||||
distance={distance}
|
||||
onAngleChange={handleAngleChange}
|
||||
showObject={showObject}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
||||
{/* 当前角度显示 - 悬浮卡片风格 */}
|
||||
<div className="absolute bottom-3 left-3 right-3 flex justify-between text-xs font-medium text-foreground/80 bg-background/80 backdrop-blur-md rounded-lg px-3 py-2 border shadow-sm select-none pointer-events-none">
|
||||
<span>方位: {currentAzimuth.label} ({azimuth}°)</span>
|
||||
<span>仰角: {currentElevation.label} ({elevation}°)</span>
|
||||
</div>
|
||||
|
||||
{/* 操作提示 */}
|
||||
<div className="absolute top-3 left-3 text-[10px] font-medium text-muted-foreground bg-background/50 backdrop-blur-sm rounded-full px-2 py-1 border border-border/50 select-none pointer-events-none">
|
||||
🖱️ 拖拽旋转相机位置
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速预设按钮 */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-foreground">快速选择</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: "正前", azimuth: 0, elevation: 0 },
|
||||
{ label: "前3/4", azimuth: 45, elevation: 0 },
|
||||
{ label: "侧面", azimuth: 90, elevation: 0 },
|
||||
{ label: "后3/4", azimuth: 135, elevation: 0 },
|
||||
{ label: "正后", azimuth: 180, elevation: 0 },
|
||||
{ label: "俯视", azimuth: 0, elevation: 60 },
|
||||
{ label: "仰视", azimuth: 0, elevation: -30 },
|
||||
{ label: "鸟瞰", azimuth: 45, elevation: 60 },
|
||||
].map((preset) => (
|
||||
<button
|
||||
type="button"
|
||||
key={preset.label}
|
||||
onClick={() => handlePresetClick(preset.azimuth, preset.elevation)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-xs rounded-md border transition-all duration-200 font-medium",
|
||||
currentAzimuth.angle === preset.azimuth &&
|
||||
currentElevation.angle === preset.elevation
|
||||
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
||||
: "bg-background hover:bg-accent hover:text-accent-foreground border-border text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 距离控制 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">镜头距离</div>
|
||||
<div className="flex p-1 bg-muted rounded-lg">
|
||||
{DISTANCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
onClick={() => handleDistanceChange(opt.factor)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-1.5 text-xs rounded-md transition-all duration-200 font-medium",
|
||||
currentDistance.value === opt.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AngleController3D;
|
||||
312
components/angle-edit-dialog.tsx
Normal file
312
components/angle-edit-dialog.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
"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;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2 } from "lucide-react";
|
||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "./image-annotator";
|
||||
import { AngleEditDialog } from "./angle-edit-dialog";
|
||||
|
||||
export function ImagePreview({
|
||||
src,
|
||||
@ -14,9 +15,11 @@ export function ImagePreview({
|
||||
onImageEnhance,
|
||||
onImageRmbg,
|
||||
onImageExtend,
|
||||
onImageAngleEdit,
|
||||
isProcessingEnhance,
|
||||
isProcessingRmbg,
|
||||
isProcessingExtend,
|
||||
isProcessingAngleEdit,
|
||||
imageIndex
|
||||
}: {
|
||||
src: string;
|
||||
@ -31,9 +34,11 @@ export function ImagePreview({
|
||||
onImageEnhance?: (imageUrl: string) => void;
|
||||
onImageRmbg?: (imageUrl: string) => void;
|
||||
onImageExtend?: (imageUrl: string) => void;
|
||||
onImageAngleEdit?: (imageUrl: string) => void;
|
||||
isProcessingEnhance?: boolean;
|
||||
isProcessingRmbg?: boolean;
|
||||
isProcessingExtend?: boolean;
|
||||
isProcessingAngleEdit?: boolean;
|
||||
imageIndex?: number;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@ -41,6 +46,7 @@ export function ImagePreview({
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [showAnnotator, setShowAnnotator] = useState(false);
|
||||
const [showResizeEditor, setShowResizeEditor] = useState(false);
|
||||
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
|
||||
|
||||
// 解析 alt 文本,提取原图 URL 和宽度
|
||||
// 格式优先级:
|
||||
@ -272,11 +278,11 @@ export function ImagePreview({
|
||||
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
|
||||
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit) {
|
||||
onImageExtend(original);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend}
|
||||
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit}
|
||||
type="button"
|
||||
aria-label="图像拓展"
|
||||
title="图像拓展"
|
||||
@ -285,6 +291,24 @@ export function ImagePreview({
|
||||
<span className="text-xs font-medium">图像拓展</span>
|
||||
</button>
|
||||
)}
|
||||
{onImageAngleEdit && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit) {
|
||||
setShowAngleEditDialog(true);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit}
|
||||
type="button"
|
||||
aria-label="角度调整"
|
||||
title="角度调整"
|
||||
>
|
||||
<Move3D size={14} />
|
||||
<span className="text-xs font-medium">角度调整</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg"
|
||||
onClick={(e) => {
|
||||
@ -529,6 +553,20 @@ export function ImagePreview({
|
||||
onCancel={() => setShowResizeEditor(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 角度调整对话框 */}
|
||||
{showAngleEditDialog && onImageAngleEdit && (
|
||||
<AngleEditDialog
|
||||
open={showAngleEditDialog}
|
||||
onOpenChange={setShowAngleEditDialog}
|
||||
imageUrl={original}
|
||||
onComplete={(resultUrl) => {
|
||||
onImageAngleEdit(resultUrl);
|
||||
setShowAngleEditDialog(false);
|
||||
toast.success("角度调整图片已生成");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
29
components/ui/slider.tsx
Normal file
29
components/ui/slider.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
|
||||
2098
docs/Qwen-Image-Edit-Multiple-Angles-集成方案.md
Normal file
2098
docs/Qwen-Image-Edit-Multiple-Angles-集成方案.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -30,6 +30,7 @@
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.35.3",
|
||||
"@fal-ai/serverless-client": "^0.15.0",
|
||||
"@icons-pack/react-simple-icons": "^13.7.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
@ -40,6 +41,8 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/blob": "^0.24.1",
|
||||
@ -95,6 +98,8 @@
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.182.0",
|
||||
"three-stdlib": "^2.36.1",
|
||||
"tokenlens": "1.3.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
@ -113,6 +118,7 @@
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@types/three": "^0.182.0",
|
||||
"drizzle-kit": "^0.25.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.1.13",
|
||||
|
||||
589
pnpm-lock.yaml
generated
589
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -86,3 +86,5 @@ echo ""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -139,3 +139,6 @@ pmset -g
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user