76 KiB
Qwen-Image-Edit-2511-Multiple-Angles-LoRA 集成方案
📝 方案定位:使用 fal.ai 云端 API,无需本地部署,将多角度图像编辑功能集成到现有图片编辑器中。
⚙️ 配置方式:所有 API 配置通过管理后台系统设置界面配置,存储在数据库中。
📦 源码位置:
reference-sources/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/
📋 一、项目概述
1.1 项目信息
| 项目 | 详情 |
|---|---|
| 模型名称 | Qwen-Image-Edit-2511-Multiple-Angles-LoRA |
| 开发者 | fal.ai (Lovis Odin) |
| 基础模型 | Qwen-Image-Edit-2511 (阿里通义千问) |
| Hugging Face | https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA |
| fal.ai 模型页 | https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles |
| fal.ai 在线体验 | https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles |
| 许可证 | Apache 2.0 (可商用) |
1.2 源码文件说明
已下载到 reference-sources/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/:
| 文件 | 说明 |
|---|---|
README.md |
官方文档,包含完整的使用说明 |
qwen-image-edit-2511-multiple-angles-lora.safetensors |
LoRA 权重文件 (需从 HuggingFace 下载) |
comfyui-workflow-multiple-angles.json |
ComfyUI 工作流配置 |
all_animations_combined.gif |
效果展示动画 |
poses_96_animated.gif |
96 个相机位置动画 |
poses_96_distance_comparison.png |
距离对比图 |
1.3 功能特点
这是一个多角度相机控制模型,能够对同一图像生成不同视角的变体:
| 特性 | 说明 |
|---|---|
| 96 个相机姿态 | 4 个仰角 × 8 个方位角 × 3 个距离 |
| 精确角度控制 | 支持正面、侧面、俯视、仰视等视角 |
| 低角度支持 | 能正确处理 -30° 的低角度视角 |
| 高质量输出 | 基于 3000+ 高斯散射渲染数据训练 |
| 首创性 | 首个 Qwen-Image-Edit 多角度相机控制 LoRA |
1.3 应用场景
- 🚗 汽车展示:一张车图生成前3/4、后3/4、侧面等多角度展示图
- 🛋️ 产品展示:电商产品多角度图片生成
- 🎮 游戏角色:2D 角色生成不同视角立绘
- 📸 摄影后期:模拟不同拍摄角度的效果
🔧 二、技术架构
2.1 系统架构图
┌─────────────────────────────────────────────────────────────────────────┐
│ 用户浏览器 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 图片编辑器 (ImageAnnotator) │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ 🎯 角度编辑控制器 (新增组件) │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ [正前方] [前3/4] [侧面] [后3/4] [正后方] [俯视] │ │ │ │
│ │ │ │ ─────────────────────────────────────────────── │ │ │ │
│ │ │ │ 方位角滑块: ◀────────●────────▶ 45° │ │ │ │
│ │ │ │ 仰角滑块: ◀────────●────────▶ 0° │ │ │ │
│ │ │ │ 距离滑块: ◀────────●────────▶ 中景 │ │ │ │
│ │ │ │ ─────────────────────────────────────────────── │ │ │ │
│ │ │ │ [预览] [生成] │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────│─────────────────────────────────────┘
│
▼ POST /api/angle-edit
┌─────────────────────────────────────────────────────────────────────────┐
│ Next.js 后端 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ /api/angle-edit/route.ts │ │
│ │ 1. 验证用户登录 │ │
│ │ 2. 从数据库获取配置 (systemConfig 表) │ │
│ │ 3. 调用 fal.ai API │ │
│ │ 4. 上传结果到 S3 │ │
│ │ 5. 保存记录到数据库 │ │
│ └─────────────────────────────────────────│────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────│────────────────────────┐ │
│ │ 数据库 (PostgreSQL) │ │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ systemConfig 表 │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ key: "angleEditConfig" │ │ │ │
│ │ │ │ value: { │ │ │ │
│ │ │ │ enabled: true, │ │ │ │
│ │ │ │ falApiKey: "xxx", │ │ │ │
│ │ │ │ loraScale: 0.9, │ │ │ │
│ │ │ │ loraUrl: "https://...", │ │ │ │
│ │ │ │ inferenceSteps: 28, │ │ │ │
│ │ │ │ guidanceScale: 3.5 │ │ │ │
│ │ │ │ } │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────│────────────────────────────┘
│
▼ fal.ai API
┌─────────────────────────────────────────────────────────────────────────┐
│ fal.ai 云端服务 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Qwen-Image-Edit-2511 + Multiple-Angles-LoRA │ │
│ │ ─────────────────────────────────────────────────────────── │ │
│ │ 输入: 原图 + 角度参数 (azimuth, elevation, distance) │ │
│ │ 输出: 指定角度的图像 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
2.2 官方提示词格式
<sks> [azimuth] [elevation] [distance]
示例
<sks> front view eye-level shot medium shot
<sks> right side view high-angle shot close-up
<sks> back view low-angle shot wide shot
<sks> front-left quarter view elevated shot medium shot
2.3 角度参数说明
方位角 (Azimuth) - 水平旋转
0°
(front view)
│
315° │ 45°
(front-left) │ (front-right)
╲ │ ╱
╲ │ ╱
╲ │ ╱
270° ─────────────── ● ─────────────── 90°
(left side) OBJECT (right side)
╱ │ ╲
╱ │ ╲
╱ │ ╲
225° │ 135°
(back-left) │ (back-right)
│
180°
(back view)
| 角度 | 官方提示词 |
|---|---|
| 0° | front view |
| 45° | front-right quarter view |
| 90° | right side view |
| 135° | back-right quarter view |
| 180° | back view |
| 225° | back-left quarter view |
| 270° | left side view |
| 315° | front-left quarter view |
仰角 (Elevation) - 垂直角度
| 角度 | 官方提示词 | 说明 |
|---|---|---|
| -30° | low-angle shot |
相机在下方,向上看 |
| 0° | eye-level shot |
相机与物体平齐 |
| 30° | elevated shot |
相机略高于物体 |
| 60° | high-angle shot |
相机在高处,向下看 |
距离 (Distance) - 镜头远近
| 倍率 | 官方提示词 | 用途 |
|---|---|---|
| ×0.6 | close-up |
细节、纹理 |
| ×1.0 | medium shot |
标准平衡 |
| ×1.8 | wide shot |
展示环境 |
⚙️ 三、系统设置配置方案
3.1 数据库配置存储
配置数据存储在现有的 systemConfig 表中,使用 JSON 格式存储:
配置键名: angleEditConfig
配置结构:
interface AngleEditConfig {
// 基础配置
enabled: boolean; // 是否启用功能
falApiKey: string; // fal.ai API Key
// 模型参数
loraScale: number; // LoRA 强度 (0.5-1.0),默认 0.9
loraUrl: string; // LoRA 模型地址(可选,有默认值)
inferenceSteps: number; // 推理步数,默认 28
guidanceScale: number; // 引导系数,默认 3.5
// 高级配置
maxImageSize: number; // 最大图片尺寸(MB),默认 10
timeout: number; // 超时时间(秒),默认 120
}
数据库存储示例:
INSERT INTO system_config (key, value, created_at, updated_at) VALUES (
'angleEditConfig',
'{
"enabled": true,
"falApiKey": "fal_xxxxxxxxxxxxx",
"loraScale": 0.9,
"loraUrl": "https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors",
"inferenceSteps": 28,
"guidanceScale": 3.5,
"maxImageSize": 10,
"timeout": 120
}',
NOW(),
NOW()
);
3.2 管理后台配置界面
在系统设置页面 (/settings) 添加"角度编辑配置"卡片:
UI 设计:
┌─────────────────────────────────────────────────────────────────────────┐
│ 🎯 角度编辑配置 │
│ ───────────────────────────────────────────────────────────────────── │
│ 配置多角度图像编辑功能 (基于 fal.ai API) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 启用角度编辑功能 [✓ 开关] │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ fal.ai API Key * │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ •••••••••••••••••••••••••••••••••••• │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ 从 fal.ai Dashboard 获取: https://fal.ai/dashboard/keys │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ 默认变换强度 90% │
│ ◀────────────────────────────────●──────────────────────────────▶ │
│ 值越高角度变化越明显,值越低保留原图特征越多 │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ ▼ 高级设置 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ LoRA 模型地址 │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple...│ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ │ 留空使用默认地址 │ │
│ │ │ │
│ │ 推理步数 引导系数 最大图片(MB) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ 28 │ │ 3.5 │ │ 10 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ 超时时间(秒) │ │
│ │ ┌──────────┐ │ │
│ │ │ 120 │ │ │
│ │ └──────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ [ 保存配置 ] │
│ │
└─────────────────────────────────────────────────────────────────────────┘
3.3 配置界面代码实现
文件:app/(admin)/settings/admin-settings-client.tsx
在现有文件中添加以下代码:
// ==================== 角度编辑配置状态 ====================
const [angleEditEnabled, setAngleEditEnabled] = useState(false);
const [angleEditFalApiKey, setAngleEditFalApiKey] = useState("");
const [angleEditLoraScale, setAngleEditLoraScale] = useState(0.9);
const [angleEditLoraUrl, setAngleEditLoraUrl] = useState("");
const [angleEditInferenceSteps, setAngleEditInferenceSteps] = useState(28);
const [angleEditGuidanceScale, setAngleEditGuidanceScale] = useState(3.5);
const [angleEditMaxImageSize, setAngleEditMaxImageSize] = useState(10);
const [angleEditTimeout, setAngleEditTimeout] = useState(120);
const [angleEditShowAdvanced, setAngleEditShowAdvanced] = useState(false);
const [angleEditSaving, setAngleEditSaving] = useState(false);
// ==================== 加载角度编辑配置 ====================
// 在 useEffect 加载配置的地方添加:
useEffect(() => {
const loadConfigs = async () => {
try {
const response = await fetch("/api/admin/config");
const data = await response.json();
// ... 其他配置加载 ...
// 加载角度编辑配置
const angleEditConfig = data.configs?.find(
(c: any) => c.key === "angleEditConfig"
)?.value;
if (angleEditConfig) {
setAngleEditEnabled(angleEditConfig.enabled ?? false);
setAngleEditFalApiKey(angleEditConfig.falApiKey ?? "");
setAngleEditLoraScale(angleEditConfig.loraScale ?? 0.9);
setAngleEditLoraUrl(angleEditConfig.loraUrl ?? "");
setAngleEditInferenceSteps(angleEditConfig.inferenceSteps ?? 28);
setAngleEditGuidanceScale(angleEditConfig.guidanceScale ?? 3.5);
setAngleEditMaxImageSize(angleEditConfig.maxImageSize ?? 10);
setAngleEditTimeout(angleEditConfig.timeout ?? 120);
}
} catch (error) {
console.error("加载配置失败:", error);
}
};
loadConfigs();
}, []);
// ==================== 保存角度编辑配置 ====================
const handleSaveAngleEditConfig = async () => {
setAngleEditSaving(true);
try {
const response = await fetch("/api/admin/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "angleEditConfig",
value: {
enabled: angleEditEnabled,
falApiKey: angleEditFalApiKey,
loraScale: angleEditLoraScale,
loraUrl: angleEditLoraUrl || undefined,
inferenceSteps: angleEditInferenceSteps,
guidanceScale: angleEditGuidanceScale,
maxImageSize: angleEditMaxImageSize,
timeout: angleEditTimeout,
},
}),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("角度编辑配置已保存");
} catch (error) {
console.error("保存配置失败:", error);
toast.error("保存失败,请重试");
} finally {
setAngleEditSaving(false);
}
};
// ==================== 角度编辑配置 UI ====================
// 在 return 的 JSX 中添加配置卡片:
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Move3D className="h-5 w-5" />
角度编辑配置
</CardTitle>
<CardDescription>
配置多角度图像编辑功能 (基于 fal.ai API)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 启用开关 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>启用角度编辑功能</Label>
<p className="text-sm text-muted-foreground">
开启后用户可在图片编辑器中使用角度编辑功能
</p>
</div>
<Switch
checked={angleEditEnabled}
onCheckedChange={setAngleEditEnabled}
/>
</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={angleEditFalApiKey}
onChange={(e) => setAngleEditFalApiKey(e.target.value)}
placeholder="输入 fal.ai API Key"
disabled={!angleEditEnabled}
/>
<p 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
</p>
</div>
<Separator />
{/* LoRA 强度 */}
<div className="space-y-3">
<div className="flex justify-between">
<Label>默认变换强度</Label>
<span className="text-sm text-muted-foreground">
{(angleEditLoraScale * 100).toFixed(0)}%
</span>
</div>
<Slider
value={[angleEditLoraScale]}
onValueChange={([v]) => setAngleEditLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
disabled={!angleEditEnabled}
/>
<p className="text-xs text-muted-foreground">
值越高角度变化越明显,值越低保留原图特征越多(推荐 85%-95%)
</p>
</div>
<Separator />
{/* 高级设置折叠 */}
<Collapsible
open={angleEditShowAdvanced}
onOpenChange={setAngleEditShowAdvanced}
>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between">
<span className="flex items-center gap-2">
<Settings className="h-4 w-4" />
高级设置
</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
angleEditShowAdvanced && "rotate-180"
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
{/* LoRA 模型地址 */}
<div className="space-y-2">
<Label htmlFor="loraUrl">LoRA 模型地址</Label>
<Input
id="loraUrl"
value={angleEditLoraUrl}
onChange={(e) => setAngleEditLoraUrl(e.target.value)}
placeholder="留空使用默认地址"
disabled={!angleEditEnabled}
/>
<p className="text-xs text-muted-foreground">
默认: huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA
</p>
</div>
{/* 推理参数 */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="inferenceSteps">推理步数</Label>
<Input
id="inferenceSteps"
type="number"
value={angleEditInferenceSteps}
onChange={(e) => setAngleEditInferenceSteps(Number(e.target.value))}
min={10}
max={50}
disabled={!angleEditEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="guidanceScale">引导系数</Label>
<Input
id="guidanceScale"
type="number"
value={angleEditGuidanceScale}
onChange={(e) => setAngleEditGuidanceScale(Number(e.target.value))}
min={1}
max={10}
step={0.5}
disabled={!angleEditEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxImageSize">最大图片(MB)</Label>
<Input
id="maxImageSize"
type="number"
value={angleEditMaxImageSize}
onChange={(e) => setAngleEditMaxImageSize(Number(e.target.value))}
min={1}
max={50}
disabled={!angleEditEnabled}
/>
</div>
</div>
{/* 超时设置 */}
<div className="space-y-2">
<Label htmlFor="timeout">超时时间(秒)</Label>
<Input
id="timeout"
type="number"
value={angleEditTimeout}
onChange={(e) => setAngleEditTimeout(Number(e.target.value))}
min={30}
max={300}
disabled={!angleEditEnabled}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
API 调用超时时间,建议 60-180 秒
</p>
</div>
</CollapsibleContent>
</Collapsible>
{/* 保存按钮 */}
<Button
onClick={handleSaveAngleEditConfig}
disabled={angleEditSaving}
className="w-full"
>
{angleEditSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
保存中...
</>
) : (
"保存配置"
)}
</Button>
</CardContent>
</Card>
3.4 后端配置读取
文件:app/api/angle-edit/route.ts
从数据库读取配置:
// 获取配置
const [configRow] = await db
.select()
.from(systemConfig)
.where(eq(systemConfig.key, "angleEditConfig"));
const config = (configRow?.value as AngleEditConfig) || {};
// 验证配置
if (!config.enabled) {
return NextResponse.json(
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
{ status: 400 }
);
}
if (!config.falApiKey) {
return NextResponse.json(
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
{ status: 500 }
);
}
// 使用配置
const loraUrl = config.loraUrl ||
"https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors";
const result = await fal.subscribe("fal-ai/qwen-image-edit", {
input: {
image_url: imageInput,
prompt: prompt,
lora_url: loraUrl,
lora_scale: config.loraScale || 0.9,
num_inference_steps: config.inferenceSteps || 28,
guidance_scale: config.guidanceScale || 3.5,
},
// ...
});
📦 四、完整实现代码
4.1 安装依赖
# 安装 fal.ai SDK
npm install @fal-ai/serverless-client
# 安装 Three.js 和 React Three Fiber(用于 3D 控制器)
npm install @react-three/fiber @react-three/drei three
npm install -D @types/three
4.2 后端 API 路由
文件:app/api/angle-edit/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { systemConfig, userImages } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import * as fal from "@fal-ai/serverless-client";
// 配置类型定义
interface 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";
export async function POST(request: Request) {
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 [configRow] = await db
.select()
.from(systemConfig)
.where(eq(systemConfig.key, "angleEditConfig"));
const config = (configRow?.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);
// 配置 fal.ai
fal.config({
credentials: config.falApiKey,
});
// 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 API
console.log("[Angle-Edit] 调用 fal.ai API...");
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 any).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 { uploadToS3 } = await import("@/lib/s3");
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 8);
const fileName = `angle-edit/angle-${preset || "custom"}-${timestamp}-${randomStr}.png`;
const s3Url = await uploadToS3(imageBuffer, fileName, "image/png");
console.log("[Angle-Edit] 已上传到S3:", s3Url);
// 10. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000;
await db.insert(userImages).values({
userId: session.user.id,
originalUrl: image_url || "",
thumbnailUrl: s3Url,
originalImageUrl: s3Url,
processType: "angle-edit",
prompt: `角度编辑: ${preset || `${azimuth}-${elevation}-${distance}`}`,
processingTime: processingTime,
createdAt: new Date(),
});
console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒");
// 11. 返回结果
return NextResponse.json({
success: true,
image_url: s3Url,
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 }
);
}
}
4.3 安装 3D 相关依赖
# 安装 React Three Fiber 和相关库
npm install @react-three/fiber @react-three/drei three
npm install -D @types/three
4.4 3D 角度控制器组件
文件:components/angle-controller-3d.tsx
这是一个基于 Three.js 的交互式 3D 控制器,用户可以拖拽旋转来选择相机角度:
"use client";
import { useRef, useState, useCallback, useMemo, Suspense, useEffect } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { OrbitControls, PerspectiveCamera, Html } 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 direction = new THREE.Vector3(0, 0, 0).sub(position).normalize();
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="#1e293b" metalness={0.7} roughness={0.3} />
</mesh>
{/* 相机顶部(取景器凸起) */}
<mesh position={[0, 0.12, -0.02]} castShadow>
<boxGeometry args={[0.12, 0.06, 0.1]} />
<meshStandardMaterial color="#0f172a" metalness={0.6} roughness={0.4} />
</mesh>
{/* 镜头筒 */}
<mesh position={[0, 0, 0.14]} castShadow>
<cylinderGeometry args={[0.08, 0.1, 0.12, 24]} rotation={[Math.PI / 2, 0, 0]} />
<meshStandardMaterial color="#334155" metalness={0.8} roughness={0.2} />
</mesh>
{/* 镜头玻璃 */}
<mesh position={[0, 0, 0.21]}>
<circleGeometry args={[0.07, 24]} />
<meshStandardMaterial
color="#60a5fa"
metalness={0.9}
roughness={0.1}
transparent
opacity={0.8}
/>
</mesh>
{/* 镜头光圈环 */}
<mesh position={[0, 0, 0.2]}>
<ringGeometry args={[0.065, 0.08, 24]} />
<meshStandardMaterial color="#1e40af" metalness={0.9} roughness={0.1} />
</mesh>
{/* 相机手柄/握把 */}
<mesh position={[0.12, -0.05, 0]} castShadow>
<boxGeometry args={[0.06, 0.12, 0.14]} />
<meshStandardMaterial color="#1e293b" metalness={0.6} roughness={0.4} />
</mesh>
{/* 视野锥形指示线 */}
<line>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={5}
array={new Float32Array([
0, 0, 0.22, // 镜头中心
-0.3, -0.2, 1.2, // 左下
0.3, -0.2, 1.2, // 右下
0.3, 0.2, 1.2, // 右上
-0.3, 0.2, 1.2, // 左上
])}
itemSize={3}
/>
</bufferGeometry>
<lineBasicMaterial color="#3b82f6" opacity={0.4} transparent />
</line>
{/* 视野框 */}
<line>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={5}
array={new Float32Array([
-0.3, -0.2, 1.2, // 左下
0.3, -0.2, 1.2, // 右下
0.3, 0.2, 1.2, // 右上
-0.3, 0.2, 1.2, // 左上
-0.3, -0.2, 1.2, // 闭合到左下
])}
itemSize={3}
/>
</bufferGeometry>
<lineBasicMaterial color="#3b82f6" opacity={0.6} transparent />
</line>
</group>
);
}
// 方位角刻度圆环
function AzimuthRing() {
const points: THREE.Vector3[] = [];
for (let i = 0; i <= 360; i += 5) {
const rad = (i * Math.PI) / 180;
points.push(new THREE.Vector3(Math.sin(rad) * 2, 0, Math.cos(rad) * 2));
}
return (
<group>
{/* 圆环 */}
<line>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={points.length}
array={new Float32Array(points.flatMap((p) => [p.x, p.y, p.z]))}
itemSize={3}
/>
</bufferGeometry>
<lineBasicMaterial color="#64748b" opacity={0.3} transparent />
</line>
{/* 方位角标签 */}
{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-xs text-muted-foreground whitespace-nowrap select-none pointer-events-none">
{opt.label}
</div>
</Html>
);
})}
</group>
);
}
// 目标物体占位符
function TargetObject() {
return (
<group>
{/* 简化的汽车形状作为占位符 */}
<mesh position={[0, 0.15, 0]}>
<boxGeometry args={[0.8, 0.3, 0.4]} />
<meshStandardMaterial color="#6366f1" metalness={0.3} roughness={0.7} />
</mesh>
<mesh position={[0, 0.35, -0.05]}>
<boxGeometry args={[0.5, 0.2, 0.35]} />
<meshStandardMaterial color="#818cf8" metalness={0.3} roughness={0.7} />
</mesh>
{/* 地面参考 */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
<circleGeometry args={[1.5, 32]} />
<meshStandardMaterial color="#1e293b" opacity={0.3} transparent />
</mesh>
</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 { camera } = useThree();
// 计算相机位置
const cameraPosition = 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]);
// 监听控制器变化
useFrame(() => {
if (controlsRef.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 (controlsRef.current.enabled) {
onAngleChange(Math.round(newAzimuth), Math.round(newElevation));
}
}
});
return (
<>
<PerspectiveCamera makeDefault position={cameraPosition} fov={50} />
<OrbitControls
ref={controlsRef}
enablePan={false}
enableZoom={false}
minPolarAngle={Math.PI / 6} // 限制仰角范围
maxPolarAngle={(Math.PI * 5) / 6}
target={[0, 0, 0]}
/>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
<pointLight position={[-5, 5, -5]} intensity={0.5} />
{showObject && <TargetObject />}
<AzimuthRing />
<CameraIndicator position={cameraPosition} />
{/* 坐标轴辅助 */}
<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-900 to-slate-800 border border-slate-700">
<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 text-white/70 bg-black/30 backdrop-blur-sm rounded-lg px-3 py-2">
<span>方位: {currentAzimuth.label} ({azimuth}°)</span>
<span>仰角: {currentElevation.label} ({elevation}°)</span>
</div>
{/* 操作提示 */}
<div className="absolute top-3 left-3 text-xs text-white/50 bg-black/30 backdrop-blur-sm rounded px-2 py-1">
🖱️ 拖拽旋转视角
</div>
</div>
{/* 快速预设按钮 */}
<div className="space-y-3">
<div className="text-sm font-medium text-muted-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-3 py-2 text-sm rounded-lg border transition-colors",
currentAzimuth.angle === preset.azimuth &&
currentElevation.angle === preset.elevation
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 hover:bg-muted border-border"
)}
>
{preset.label}
</button>
))}
</div>
</div>
{/* 距离控制 */}
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">镜头距离</div>
<div className="flex gap-2">
{DISTANCE_OPTIONS.map((opt) => (
<button
type="button"
key={opt.value}
onClick={() => handleDistanceChange(opt.factor)}
className={cn(
"flex-1 px-3 py-2 text-sm rounded-lg border transition-colors",
currentDistance.value === opt.value
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 hover:bg-muted border-border"
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
);
}
export default AngleController3D;
4.5 角度编辑对话框组件
文件:components/angle-edit-dialog.tsx
"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 } 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-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
🎯 多角度编辑
</DialogTitle>
<DialogDescription>
拖拽 3D 控制器选择相机角度,AI 将生成对应视角的图像
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
{/* 左侧:3D 控制器 */}
<div className="space-y-4">
<AngleController3D
onChange={handleAngleChange}
initialAzimuth={currentAngles.azimuthDegree}
initialElevation={currentAngles.elevationDegree}
initialDistance={currentAngles.distanceFactor}
/>
{/* LoRA 强度控制 */}
<div className="space-y-2">
<div className="flex justify-between">
<Label>变换强度</Label>
<span className="text-sm text-muted-foreground">
{(loraScale * 100).toFixed(0)}%
</span>
</div>
<Slider
value={[loraScale]}
onValueChange={([v]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
/>
<p className="text-xs text-muted-foreground">
值越高角度变化越明显,值越低保留原图特征越多
</p>
</div>
</div>
{/* 右侧:图片预览 */}
<div className="space-y-4">
{/* 原图 */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">原图</Label>
<div className="aspect-video rounded-lg overflow-hidden bg-muted border">
<img
src={imageUrl}
alt="原图"
className="w-full h-full object-contain"
/>
</div>
</div>
{/* 结果图 */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
生成结果
{resultUrl && (
<span className="ml-2 text-green-500">✓ 已生成</span>
)}
</Label>
<div
className={cn(
"aspect-video rounded-lg overflow-hidden border flex items-center justify-center",
resultUrl ? "bg-muted" : "bg-muted/50 border-dashed"
)}
>
{isProcessing ? (
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="mt-2 text-sm text-muted-foreground">
正在生成新视角...
</p>
<p className="text-xs text-muted-foreground">
预计需要 10-30 秒
</p>
</div>
) : resultUrl ? (
<img
src={resultUrl}
alt="生成结果"
className="w-full h-full object-contain"
/>
) : (
<div className="text-center text-muted-foreground">
<p>选择角度后点击"生成新视角"</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-between items-center mt-6 pt-4 border-t">
<Button variant="outline" onClick={handleReset} disabled={isProcessing}>
<RotateCcw className="h-4 w-4 mr-2" />
重置
</Button>
<div className="flex gap-2">
{resultUrl && (
<>
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载图片
</Button>
<Button onClick={handleUseImage}>
<Check className="h-4 w-4 mr-2" />
使用此图
</Button>
</>
)}
{!resultUrl && (
<Button onClick={handleGenerate} disabled={isProcessing}>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
生成中...
</>
) : (
"🎯 生成新视角"
)}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default AngleEditDialog;
4.6 集成到图片编辑器
文件:components/image-annotator.tsx
在工具栏中添加角度编辑按钮:
// 在文件顶部导入
import { AngleEditDialog } from "./angle-edit-dialog";
import { Move3D } from "lucide-react";
// 在组件内添加状态
const [isAngleEditOpen, setIsAngleEditOpen] = useState(false);
// 在工具栏添加按钮
<Button
variant="ghost"
size="sm"
onClick={() => setIsAngleEditOpen(true)}
title="多角度编辑"
>
<Move3D className="h-4 w-4" />
</Button>
// 添加对话框组件
<AngleEditDialog
open={isAngleEditOpen}
onOpenChange={setIsAngleEditOpen}
imageUrl={currentImageUrl}
onComplete={(resultUrl) => {
// 使用生成的新视角图片
setCurrentImageUrl(resultUrl);
}}
/>
4.7 集成到聊天图片预览(与抠图按钮并列)
文件:components/image-preview.tsx
在聊天对话中的图片上添加"角度调整"按钮,与现有的"变清晰"、"抠图"、"图像拓展"按钮并列:
// 1. 在文件顶部添加导入
import { Move3D } from "lucide-react";
import { AngleEditDialog } from "./angle-edit-dialog";
// 2. 修改组件 Props 接口,添加角度编辑相关属性
export function ImagePreview({
src,
alt,
onImageEdit,
onAnnotate,
onDrawingComplete,
onImageEnhance,
onImageRmbg,
onImageExtend,
onImageAngleEdit, // 新增:角度编辑回调
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit, // 新增:角度编辑处理状态
imageIndex
}: {
// ... 其他 props ...
onImageAngleEdit?: (imageUrl: string) => void;
isProcessingAngleEdit?: boolean;
}) {
// 3. 添加角度编辑对话框状态
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
// 4. 在左上角按钮组中添加"角度调整"按钮(与抠图按钮并列)
// 位置:在 onImageExtend 按钮之后
{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>
)}
// 5. 在组件底部添加角度编辑对话框
{showAngleEditDialog && onImageAngleEdit && (
<AngleEditDialog
open={showAngleEditDialog}
onOpenChange={setShowAngleEditDialog}
imageUrl={original}
onComplete={(resultUrl) => {
onImageAngleEdit(resultUrl);
setShowAngleEditDialog(false);
toast.success("角度调整图片已生成");
}}
/>
)}
}
按钮布局效果:
┌─────────────────────────────────────────────────────────────┐
│ [变清晰] [抠图] [图像拓展] [角度调整] [尺寸调整] │ ← 左上角按钮组
│ ┌───────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 生成的图片 │ │
│ │ │ │
│ └───────────────────────────────────────────────────────┘ │
│ [画图] [编辑] [下载] │ ← 右下角悬停按钮
└─────────────────────────────────────────────────────────────┘
💰 五、成本估算
5.1 fal.ai API 定价
| 项目 | 费用 |
|---|---|
| 每张图片处理 | ~$0.02-0.05 |
| 首次充值赠送 | $10 免费额度 |
5.2 月度成本预估
| 月处理量 | 预估成本 |
|---|---|
| 500 张/月 | ¥70-175 |
| 1,000 张/月 | ¥140-350 |
| 5,000 张/月 | ¥700-1,750 |
| 10,000 张/月 | ¥1,400-3,500 |
⏱️ 六、实施时间表
| 阶段 | 任务 | 预计时间 |
|---|---|---|
| 阶段一 | 管理后台配置 | 0.5 天 |
| - 添加配置状态和 UI | 2 小时 | |
| - 实现配置保存/读取 | 2 小时 | |
| 阶段二 | 后端 API | 0.5 天 |
| - 创建 API 路由 | 2 小时 | |
| - 配置读取逻辑 | 1 小时 | |
| - fal.ai 集成 | 1 小时 | |
| 阶段三 | 前端组件 (含 3D 控制器) | 1.5 天 |
| - 3D 角度控制器组件 (Three.js) | 4 小时 | |
| - 角度编辑对话框组件 | 3 小时 | |
| - 集成到图片编辑器 | 2 小时 | |
| - 3D 交互优化与测试 | 3 小时 | |
| 阶段四 | 测试优化 | 0.5 天 |
| - 功能测试 | 2 小时 | |
| - UI/UX 优化 | 2 小时 | |
| 总计 | 3 天 |
🚀 七、快速开始
7.1 获取 fal.ai API Key
- 访问 https://fal.ai/dashboard/keys
- 注册/登录账户
- 创建新的 API Key
- 复制保存
7.2 安装依赖
# 安装 fal.ai SDK
npm install @fal-ai/serverless-client
# 安装 Three.js 和 React Three Fiber(3D 控制器)
npm install @react-three/fiber @react-three/drei three
npm install -D @types/three
7.3 创建文件
按顺序创建以下文件:
app/api/angle-edit/route.ts- 后端 APIcomponents/angle-controller-3d.tsx- 3D 角度控制器 (Three.js)components/angle-edit-dialog.tsx- 编辑对话框
7.4 添加管理后台配置
修改 app/(admin)/settings/admin-settings-client.tsx,添加角度编辑配置卡片。
7.5 集成到编辑器
修改 components/image-annotator.tsx,添加角度编辑入口按钮。
7.6 管理员配置
- 登录管理后台
- 进入系统设置页面
- 找到"角度编辑配置"卡片
- 填入 fal.ai API Key
- 开启功能
- 保存配置
⚠️ 八、注意事项
8.1 配置安全
- API Key 存储在数据库中,通过管理后台配置
- 前端无法直接访问 API Key
- 建议定期轮换 API Key
8.2 使用限制
- 图片格式:支持 JPG、PNG、WebP
- 图片大小:可在管理后台配置(默认 10MB)
- 处理时间:通常 10-30 秒
- 并发限制:取决于 fal.ai 账户等级
8.3 效果说明
- 适合主体清晰、背景简单的图片
- 汽车、产品、角色等物体效果最佳
- 复杂场景可能效果不理想
- 生成的是 AI 估算视角,非真实 3D 渲染
📎 九、参考资料
📖 附录:官方 96 个提示词完整列表
以下是来自官方 README 的完整 96 个提示词,可供参考使用:
CLOSE-UP (32 prompts)
点击展开
Low-angle (-30°)
<sks> front view low-angle shot close-up
<sks> front-right quarter view low-angle shot close-up
<sks> right side view low-angle shot close-up
<sks> back-right quarter view low-angle shot close-up
<sks> back view low-angle shot close-up
<sks> back-left quarter view low-angle shot close-up
<sks> left side view low-angle shot close-up
<sks> front-left quarter view low-angle shot close-up
Eye-level (0°)
<sks> front view eye-level shot close-up
<sks> front-right quarter view eye-level shot close-up
<sks> right side view eye-level shot close-up
<sks> back-right quarter view eye-level shot close-up
<sks> back view eye-level shot close-up
<sks> back-left quarter view eye-level shot close-up
<sks> left side view eye-level shot close-up
<sks> front-left quarter view eye-level shot close-up
Elevated (30°)
<sks> front view elevated shot close-up
<sks> front-right quarter view elevated shot close-up
<sks> right side view elevated shot close-up
<sks> back-right quarter view elevated shot close-up
<sks> back view elevated shot close-up
<sks> back-left quarter view elevated shot close-up
<sks> left side view elevated shot close-up
<sks> front-left quarter view elevated shot close-up
High-angle (60°)
<sks> front view high-angle shot close-up
<sks> front-right quarter view high-angle shot close-up
<sks> right side view high-angle shot close-up
<sks> back-right quarter view high-angle shot close-up
<sks> back view high-angle shot close-up
<sks> back-left quarter view high-angle shot close-up
<sks> left side view high-angle shot close-up
<sks> front-left quarter view high-angle shot close-up
MEDIUM SHOT (32 prompts)
点击展开
Low-angle (-30°)
<sks> front view low-angle shot medium shot
<sks> front-right quarter view low-angle shot medium shot
<sks> right side view low-angle shot medium shot
<sks> back-right quarter view low-angle shot medium shot
<sks> back view low-angle shot medium shot
<sks> back-left quarter view low-angle shot medium shot
<sks> left side view low-angle shot medium shot
<sks> front-left quarter view low-angle shot medium shot
Eye-level (0°) — 参考姿态: front view eye-level shot medium shot
<sks> front view eye-level shot medium shot
<sks> front-right quarter view eye-level shot medium shot
<sks> right side view eye-level shot medium shot
<sks> back-right quarter view eye-level shot medium shot
<sks> back view eye-level shot medium shot
<sks> back-left quarter view eye-level shot medium shot
<sks> left side view eye-level shot medium shot
<sks> front-left quarter view eye-level shot medium shot
Elevated (30°)
<sks> front view elevated shot medium shot
<sks> front-right quarter view elevated shot medium shot
<sks> right side view elevated shot medium shot
<sks> back-right quarter view elevated shot medium shot
<sks> back view elevated shot medium shot
<sks> back-left quarter view elevated shot medium shot
<sks> left side view elevated shot medium shot
<sks> front-left quarter view elevated shot medium shot
High-angle (60°)
<sks> front view high-angle shot medium shot
<sks> front-right quarter view high-angle shot medium shot
<sks> right side view high-angle shot medium shot
<sks> back-right quarter view high-angle shot medium shot
<sks> back view high-angle shot medium shot
<sks> back-left quarter view high-angle shot medium shot
<sks> left side view high-angle shot medium shot
<sks> front-left quarter view high-angle shot medium shot
WIDE SHOT (32 prompts)
点击展开
Low-angle (-30°)
<sks> front view low-angle shot wide shot
<sks> front-right quarter view low-angle shot wide shot
<sks> right side view low-angle shot wide shot
<sks> back-right quarter view low-angle shot wide shot
<sks> back view low-angle shot wide shot
<sks> back-left quarter view low-angle shot wide shot
<sks> left side view low-angle shot wide shot
<sks> front-left quarter view low-angle shot wide shot
Eye-level (0°)
<sks> front view eye-level shot wide shot
<sks> front-right quarter view eye-level shot wide shot
<sks> right side view eye-level shot wide shot
<sks> back-right quarter view eye-level shot wide shot
<sks> back view eye-level shot wide shot
<sks> back-left quarter view eye-level shot wide shot
<sks> left side view eye-level shot wide shot
<sks> front-left quarter view eye-level shot wide shot
Elevated (30°)
<sks> front view elevated shot wide shot
<sks> front-right quarter view elevated shot wide shot
<sks> right side view elevated shot wide shot
<sks> back-right quarter view elevated shot wide shot
<sks> back view elevated shot wide shot
<sks> back-left quarter view elevated shot wide shot
<sks> left side view elevated shot wide shot
<sks> front-left quarter view elevated shot wide shot
High-angle (60°)
<sks> front view high-angle shot wide shot
<sks> front-right quarter view high-angle shot wide shot
<sks> right side view high-angle shot wide shot
<sks> back-right quarter view high-angle shot wide shot
<sks> back view high-angle shot wide shot
<sks> back-left quarter view high-angle shot wide shot
<sks> left side view high-angle shot wide shot
<sks> front-left quarter view high-angle shot wide shot
文档更新时间:2026-01-10 版本:v3.0 - API 集成方案(数据库配置版 + 3D 控制器)