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
|
*.tflite
|
||||||
*.mlmodel
|
*.mlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# 第三方参考源码(不提交到git)
|
||||||
|
reference-sources/
|
||||||
|
|||||||
@ -50,6 +50,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Scissors,
|
Scissors,
|
||||||
Expand,
|
Expand,
|
||||||
|
Move3D,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -370,6 +371,18 @@ export function AdminSettingsClient() {
|
|||||||
yScale: "1.5", // Y轴放大倍数
|
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配置
|
// 加载抠图API配置
|
||||||
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
|
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -428,6 +441,33 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
}, [extendConfigData]);
|
}, [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({
|
const [navLinkForm, setNavLinkForm] = useState({
|
||||||
id: "",
|
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 = () => {
|
const resetNavLinkForm = () => {
|
||||||
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
|
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
|
||||||
@ -3381,6 +3454,179 @@ export function AdminSettingsClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
</Tabs>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
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 { toast } from "sonner";
|
||||||
import { ImageAnnotator } from "./image-annotator";
|
import { ImageAnnotator } from "./image-annotator";
|
||||||
|
import { AngleEditDialog } from "./angle-edit-dialog";
|
||||||
|
|
||||||
export function ImagePreview({
|
export function ImagePreview({
|
||||||
src,
|
src,
|
||||||
@ -14,9 +15,11 @@ export function ImagePreview({
|
|||||||
onImageEnhance,
|
onImageEnhance,
|
||||||
onImageRmbg,
|
onImageRmbg,
|
||||||
onImageExtend,
|
onImageExtend,
|
||||||
|
onImageAngleEdit,
|
||||||
isProcessingEnhance,
|
isProcessingEnhance,
|
||||||
isProcessingRmbg,
|
isProcessingRmbg,
|
||||||
isProcessingExtend,
|
isProcessingExtend,
|
||||||
|
isProcessingAngleEdit,
|
||||||
imageIndex
|
imageIndex
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
@ -31,9 +34,11 @@ export function ImagePreview({
|
|||||||
onImageEnhance?: (imageUrl: string) => void;
|
onImageEnhance?: (imageUrl: string) => void;
|
||||||
onImageRmbg?: (imageUrl: string) => void;
|
onImageRmbg?: (imageUrl: string) => void;
|
||||||
onImageExtend?: (imageUrl: string) => void;
|
onImageExtend?: (imageUrl: string) => void;
|
||||||
|
onImageAngleEdit?: (imageUrl: string) => void;
|
||||||
isProcessingEnhance?: boolean;
|
isProcessingEnhance?: boolean;
|
||||||
isProcessingRmbg?: boolean;
|
isProcessingRmbg?: boolean;
|
||||||
isProcessingExtend?: boolean;
|
isProcessingExtend?: boolean;
|
||||||
|
isProcessingAngleEdit?: boolean;
|
||||||
imageIndex?: number;
|
imageIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@ -41,6 +46,7 @@ export function ImagePreview({
|
|||||||
const [isDetecting, setIsDetecting] = useState(false);
|
const [isDetecting, setIsDetecting] = useState(false);
|
||||||
const [showAnnotator, setShowAnnotator] = useState(false);
|
const [showAnnotator, setShowAnnotator] = useState(false);
|
||||||
const [showResizeEditor, setShowResizeEditor] = useState(false);
|
const [showResizeEditor, setShowResizeEditor] = useState(false);
|
||||||
|
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
|
||||||
|
|
||||||
// 解析 alt 文本,提取原图 URL 和宽度
|
// 解析 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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
|
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit) {
|
||||||
onImageExtend(original);
|
onImageExtend(original);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend}
|
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="图像拓展"
|
aria-label="图像拓展"
|
||||||
title="图像拓展"
|
title="图像拓展"
|
||||||
@ -285,6 +291,24 @@ export function ImagePreview({
|
|||||||
<span className="text-xs font-medium">图像拓展</span>
|
<span className="text-xs font-medium">图像拓展</span>
|
||||||
</button>
|
</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
|
<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"
|
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) => {
|
onClick={(e) => {
|
||||||
@ -529,6 +553,20 @@ export function ImagePreview({
|
|||||||
onCancel={() => setShowResizeEditor(false)}
|
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/state": "^6.5.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@codemirror/view": "^6.35.3",
|
"@codemirror/view": "^6.35.3",
|
||||||
|
"@fal-ai/serverless-client": "^0.15.0",
|
||||||
"@icons-pack/react-simple-icons": "^13.7.0",
|
"@icons-pack/react-simple-icons": "^13.7.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/api-logs": "^0.200.0",
|
"@opentelemetry/api-logs": "^0.200.0",
|
||||||
@ -40,6 +41,8 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
"@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",
|
"@types/form-data": "^2.5.2",
|
||||||
"@vercel/analytics": "^1.3.1",
|
"@vercel/analytics": "^1.3.1",
|
||||||
"@vercel/blob": "^0.24.1",
|
"@vercel/blob": "^0.24.1",
|
||||||
@ -95,6 +98,8 @@
|
|||||||
"swr": "^2.2.5",
|
"swr": "^2.2.5",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"three": "^0.182.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
"tokenlens": "1.3.0",
|
"tokenlens": "1.3.0",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
@ -113,6 +118,7 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/sharp": "^0.32.0",
|
"@types/sharp": "^0.32.0",
|
||||||
|
"@types/three": "^0.182.0",
|
||||||
"drizzle-kit": "^0.25.0",
|
"drizzle-kit": "^0.25.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4.1.13",
|
"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