ai-chatbot/docs/Qwen-Image-Edit-Multiple-Angles-集成方案.md
Your Name b12dd2d77a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: Integrate Qwen-Image-Edit 3D angle controller and update UI
2026-01-10 23:07:14 +08:00

76 KiB
Raw Blame History

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

  1. 访问 https://fal.ai/dashboard/keys
  2. 注册/登录账户
  3. 创建新的 API Key
  4. 复制保存

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 创建文件

按顺序创建以下文件:

  1. app/api/angle-edit/route.ts - 后端 API
  2. components/angle-controller-3d.tsx - 3D 角度控制器 (Three.js)
  3. components/angle-edit-dialog.tsx - 编辑对话框

7.4 添加管理后台配置

修改 app/(admin)/settings/admin-settings-client.tsx,添加角度编辑配置卡片。

7.5 集成到编辑器

修改 components/image-annotator.tsx,添加角度编辑入口按钮。

7.6 管理员配置

  1. 登录管理后台
  2. 进入系统设置页面
  3. 找到"角度编辑配置"卡片
  4. 填入 fal.ai API Key
  5. 开启功能
  6. 保存配置

⚠️ 八、注意事项

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 控制器)