feat: Integrate Qwen-Image-Edit 3D angle controller and update UI
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-01-10 23:07:14 +08:00
parent 00b167909f
commit b12dd2d77a
12 changed files with 4127 additions and 3 deletions

3
.gitignore vendored
View File

@ -70,3 +70,6 @@ checkpoints/
*.tflite *.tflite
*.mlmodel *.mlmodel
# 第三方参考源码(不提交到git)
reference-sources/

View File

@ -50,6 +50,7 @@ import {
RefreshCw, RefreshCw,
Scissors, Scissors,
Expand, Expand,
Move3D,
} from "lucide-react"; } from "lucide-react";
import { import {
Dialog, Dialog,
@ -370,6 +371,18 @@ export function AdminSettingsClient() {
yScale: "1.5", // Y轴放大倍数 yScale: "1.5", // Y轴放大倍数
}); });
// 角度编辑API配置状态
const [angleEditConfig, setAngleEditConfig] = useState({
enabled: false,
falApiKey: "",
loraScale: 0.9,
loraUrl: "",
inferenceSteps: 28,
guidanceScale: 3.5,
maxImageSize: 10,
timeout: 120,
});
// 加载抠图API配置 // 加载抠图API配置
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
useEffect(() => { useEffect(() => {
@ -428,6 +441,33 @@ export function AdminSettingsClient() {
} }
}, [extendConfigData]); }, [extendConfigData]);
// 加载角度编辑API配置
const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher);
useEffect(() => {
if (angleEditConfigData?.setting?.value) {
const config = angleEditConfigData.setting.value as {
enabled?: boolean;
falApiKey?: string;
loraScale?: number;
loraUrl?: string;
inferenceSteps?: number;
guidanceScale?: number;
maxImageSize?: number;
timeout?: number;
};
setAngleEditConfig({
enabled: config.enabled || false,
falApiKey: config.falApiKey || "",
loraScale: config.loraScale ?? 0.9,
loraUrl: config.loraUrl || "",
inferenceSteps: config.inferenceSteps ?? 28,
guidanceScale: config.guidanceScale ?? 3.5,
maxImageSize: config.maxImageSize ?? 10,
timeout: config.timeout ?? 120,
});
}
}, [angleEditConfigData]);
// 导航链接状态 // 导航链接状态
const [navLinkForm, setNavLinkForm] = useState({ const [navLinkForm, setNavLinkForm] = useState({
id: "", id: "",
@ -828,6 +868,39 @@ export function AdminSettingsClient() {
} }
}; };
// --- 角度编辑API配置逻辑 ---
const handleSaveAngleEditConfig = async () => {
try {
if (angleEditConfig.enabled && !angleEditConfig.falApiKey.trim()) {
toast.error("启用角度编辑功能需要配置 fal.ai API Key");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "angle_edit_config",
value: {
enabled: angleEditConfig.enabled,
falApiKey: angleEditConfig.falApiKey.trim(),
loraScale: angleEditConfig.loraScale,
loraUrl: angleEditConfig.loraUrl.trim() || undefined,
inferenceSteps: angleEditConfig.inferenceSteps,
guidanceScale: angleEditConfig.guidanceScale,
maxImageSize: angleEditConfig.maxImageSize,
timeout: angleEditConfig.timeout,
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("角度编辑API配置保存成功");
} catch (error) {
toast.error("保存角度编辑API配置失败");
}
};
// --- 导航链接逻辑 --- // --- 导航链接逻辑 ---
const resetNavLinkForm = () => { const resetNavLinkForm = () => {
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true }); setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
@ -3381,6 +3454,179 @@ export function AdminSettingsClient() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* 角度编辑API配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Move3D className="h-5 w-5" />
角度编辑API配置
</CardTitle>
<CardDescription>
配置 fal.ai 多角度图像编辑 API,用于对同一图像生成不同视角的变体
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* 启用开关 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>启用角度编辑功能</Label>
<div className="text-xs text-muted-foreground">
开启后用户可在图片编辑器中使用角度编辑功能
</div>
</div>
<Switch
checked={angleEditConfig.enabled}
onCheckedChange={(checked) => setAngleEditConfig({ ...angleEditConfig, enabled: checked })}
/>
</div>
<Separator />
{/* API Key */}
<div className="space-y-2">
<Label htmlFor="falApiKey">
fal.ai API Key <span className="text-destructive">*</span>
</Label>
<Input
id="falApiKey"
type="password"
value={angleEditConfig.falApiKey}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, falApiKey: e.target.value })}
placeholder="fal_xxxxxxxxxxxxx"
disabled={!angleEditConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
从{" "}
<a
href="https://fal.ai/dashboard/keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-primary"
>
fal.ai Dashboard
</a>
{" "}获取 API Key
</div>
</div>
<Separator />
{/* LoRA 强度 */}
<div className="space-y-3">
<div className="flex justify-between">
<Label>默认变换强度</Label>
<span className="text-sm text-muted-foreground">
{(angleEditConfig.loraScale * 100).toFixed(0)}%
</span>
</div>
<Input
type="range"
value={angleEditConfig.loraScale}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, loraScale: Number.parseFloat(e.target.value) })}
min={0.5}
max={1.0}
step={0.05}
disabled={!angleEditConfig.enabled}
className="w-full"
/>
<div className="text-xs text-muted-foreground">
值越高角度变化越明显,值越低保留原图特征越多(推荐 85%-95%)
</div>
</div>
<Separator />
{/* 高级设置 */}
<div className="space-y-4">
<Label className="text-base font-medium">高级设置</Label>
<div className="space-y-2">
<Label htmlFor="loraUrl">LoRA 模型地址(可选)</Label>
<Input
id="loraUrl"
value={angleEditConfig.loraUrl}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, loraUrl: e.target.value })}
placeholder="留空使用默认地址"
disabled={!angleEditConfig.enabled}
/>
<div className="text-xs text-muted-foreground">
默认: huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inferenceSteps">推理步数</Label>
<Input
id="inferenceSteps"
type="number"
value={angleEditConfig.inferenceSteps}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, inferenceSteps: Number.parseInt(e.target.value) || 28 })}
min={10}
max={50}
disabled={!angleEditConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="guidanceScale">引导系数</Label>
<Input
id="guidanceScale"
type="number"
value={angleEditConfig.guidanceScale}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, guidanceScale: Number.parseFloat(e.target.value) || 3.5 })}
min={1}
max={10}
step={0.5}
disabled={!angleEditConfig.enabled}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxImageSize">最大图片(MB)</Label>
<Input
id="maxImageSize"
type="number"
value={angleEditConfig.maxImageSize}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, maxImageSize: Number.parseInt(e.target.value) || 10 })}
min={1}
max={50}
disabled={!angleEditConfig.enabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="timeout">超时时间(秒)</Label>
<Input
id="timeout"
type="number"
value={angleEditConfig.timeout}
onChange={(e) => setAngleEditConfig({ ...angleEditConfig, timeout: Number.parseInt(e.target.value) || 120 })}
min={30}
max={300}
disabled={!angleEditConfig.enabled}
/>
</div>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">功能说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 使用 Qwen-Image-Edit-2511-Multiple-Angles-LoRA 模型</p>
<p>• 支持 96 种相机姿态(4 仰角 × 8 方位角 × 3 距离)</p>
<p>• 可生成正面、侧面、俯视、仰视等多角度图像</p>
<p>• 处理时间:约 10-30 秒/张</p>
<p>• 费用:约 $0.02-0.05/张(fal.ai 按量计费)</p>
</div>
</div>
<Button onClick={handleSaveAngleEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

299
app/api/angle-edit/route.ts Normal file
View File

@ -0,0 +1,299 @@
import { NextRequest, NextResponse } from "next/server";
import { getSystemSetting } from "@/lib/db/queries";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
// 角度编辑API配置类型
type AngleEditConfig = {
enabled?: boolean;
falApiKey?: string;
loraScale?: number;
loraUrl?: string;
inferenceSteps?: number;
guidanceScale?: number;
maxImageSize?: number;
timeout?: number;
};
// 角度预设配置(使用官方提示词格式)
const ANGLE_PRESETS: Record<string, { azimuth: string; elevation: string; distance: string }> = {
// 汽车常用角度
"front": { azimuth: "front view", elevation: "eye-level shot", distance: "medium shot" },
"front-3/4": { azimuth: "front-right quarter view", elevation: "eye-level shot", distance: "medium shot" },
"side": { azimuth: "right side view", elevation: "eye-level shot", distance: "medium shot" },
"rear-3/4": { azimuth: "back-right quarter view", elevation: "eye-level shot", distance: "medium shot" },
"rear": { azimuth: "back view", elevation: "eye-level shot", distance: "medium shot" },
"overhead": { azimuth: "front view", elevation: "high-angle shot", distance: "wide shot" },
"low-front": { azimuth: "front view", elevation: "low-angle shot", distance: "close-up" },
"low-side": { azimuth: "right side view", elevation: "low-angle shot", distance: "medium shot" },
};
// 方位角映射(官方格式)
const AZIMUTH_MAP: Record<string, string> = {
"front": "front view",
"front-right": "front-right quarter view",
"right": "right side view",
"back-right": "back-right quarter view",
"back": "back view",
"back-left": "back-left quarter view",
"left": "left side view",
"front-left": "front-left quarter view",
};
// 仰角映射(官方格式)
const ELEVATION_MAP: Record<string, string> = {
"high": "high-angle shot", // 60°
"elevated": "elevated shot", // 30°
"eye-level": "eye-level shot", // 0°
"low": "low-angle shot", // -30°
};
// 距离映射(官方格式)
const DISTANCE_MAP: Record<string, string> = {
"close": "close-up", // ×0.6
"medium": "medium shot", // ×1.0
"wide": "wide shot", // ×1.8
};
// 默认 LoRA 地址
const DEFAULT_LORA_URL =
"https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors";
// 设置API路由最大执行时间为3分钟(180秒)
export const maxDuration = 180;
/**
* 压缩图片生成缩略图
*/
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const sharp = await import("sharp");
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 80 })
.toBuffer();
console.log(`[Angle-Edit] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
return thumbnailBuffer;
} catch (error) {
console.error("[Angle-Edit] Failed to create thumbnail:", error);
return buffer;
}
}
/**
* 将 base64 图片上传到 S3
*/
async function uploadBase64ToS3(base64Data: string, prefix: string = "angle-edit"): Promise<{ thumbnail: string; original: string }> {
try {
const buffer = Buffer.from(base64Data, "base64");
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `${prefix}-thumb-${timestamp}-${randomStr}.png`;
const originalFilename = `${prefix}-orig-${timestamp}-${randomStr}.png`;
console.log(`[Angle-Edit] Original size: ${buffer.length} bytes`);
const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
console.log(`[Angle-Edit] Uploading thumbnail and original to S3...`);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
console.log(`[Angle-Edit] Uploaded to S3:`);
console.log(` - Thumbnail: ${thumbnailResult.url}`);
console.log(` - Original: ${originalResult.url}`);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[Angle-Edit] Failed to upload to S3:", error);
throw new Error(`图片上传失败: ${error instanceof Error ? error.message : "未知错误"}`);
}
}
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
// 1. 验证用户登录
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
console.log("[Angle-Edit] ===== 收到请求 =====");
console.log("[Angle-Edit] 用户ID:", session.user.id);
// 2. 从数据库获取配置
const angleEditConfig = await getSystemSetting({ key: "angle_edit_config" });
const config = (angleEditConfig?.value as AngleEditConfig) || {};
// 验证功能是否启用
if (!config.enabled) {
return NextResponse.json(
{ error: "角度编辑功能未启用,请联系管理员在系统设置中开启" },
{ status: 400 }
);
}
// 验证 API Key
if (!config.falApiKey) {
return NextResponse.json(
{ error: "fal.ai API Key 未配置,请联系管理员配置" },
{ status: 500 }
);
}
console.log("[Angle-Edit] 配置加载成功,LoRA强度:", config.loraScale || 0.9);
// 3. 解析请求
const body = await request.json();
const {
image_url,
image_base64,
preset,
azimuth,
elevation,
distance,
lora_scale,
} = body;
if (!image_url && !image_base64) {
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 });
}
// 使用请求中的 lora_scale 或配置中的默认值
const finalLoraScale = lora_scale ?? config.loraScale ?? 0.9;
// 4. 构建角度参数
let angleParams: { azimuth: string; elevation: string; distance: string };
if (preset && ANGLE_PRESETS[preset]) {
angleParams = ANGLE_PRESETS[preset];
console.log("[Angle-Edit] 使用预设:", preset);
} else {
angleParams = {
azimuth: AZIMUTH_MAP[azimuth] || azimuth || "front view",
elevation: ELEVATION_MAP[elevation] || elevation || "eye-level shot",
distance: DISTANCE_MAP[distance] || distance || "medium shot",
};
console.log("[Angle-Edit] 使用自定义参数:", angleParams);
}
// 5. 构建提示词
const prompt = `<sks> ${angleParams.azimuth} ${angleParams.elevation} ${angleParams.distance}`;
console.log("[Angle-Edit] 提示词:", prompt);
// 6. 准备图片输入
let imageInput: string;
if (image_url) {
imageInput = image_url;
} else {
const mimeType = image_base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
imageInput = `data:${mimeType};base64,${image_base64}`;
}
// 7. 动态导入 fal.ai SDK 并调用 API
console.log("[Angle-Edit] 调用 fal.ai API...");
const fal = await import("@fal-ai/serverless-client");
fal.config({
credentials: config.falApiKey,
});
const loraUrl = config.loraUrl || DEFAULT_LORA_URL;
const result = await fal.subscribe("fal-ai/qwen-image-edit", {
input: {
image_url: imageInput,
prompt: prompt,
lora_url: loraUrl,
lora_scale: finalLoraScale,
num_inference_steps: config.inferenceSteps || 28,
guidance_scale: config.guidanceScale || 3.5,
},
logs: true,
onQueueUpdate: (update) => {
if (update.status === "IN_PROGRESS") {
console.log("[Angle-Edit] 处理进度...");
}
},
});
// 8. 处理结果
const outputImage = (result as { images?: Array<{ url?: string }> }).images?.[0];
if (!outputImage?.url) {
throw new Error("API 未返回有效图片");
}
const outputImageUrl = outputImage.url;
console.log("[Angle-Edit] 输出图片URL:", outputImageUrl);
// 9. 下载图片并上传到我们的 S3
const imageResponse = await fetch(outputImageUrl);
if (!imageResponse.ok) {
throw new Error(`下载输出图片失败: ${imageResponse.status}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = imageBuffer.toString("base64");
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
console.log("[Angle-Edit] 已上传到S3:", s3Result.original);
// 10. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000;
await db.insert(generatedImage).values({
userId: session.user.id,
thumbnailUrl: s3Result.thumbnail,
originalUrl: s3Result.original,
prompt: `角度编辑: ${preset || `${azimuth}-${elevation}-${distance}`}`,
modelId: "qwen-image-edit-angle",
createdAt: new Date(),
});
console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒");
// 11. 返回结果
return NextResponse.json({
success: true,
image_url: s3Result.original,
thumbnail_url: s3Result.thumbnail,
image_base64: imageBase64Result,
processing_time: processingTime,
preset: preset,
angles: angleParams,
});
} catch (error) {
console.error("[Angle-Edit] ❌ 错误:", error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "角度编辑处理失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,499 @@
"use client";
import { useRef, useState, useCallback, useMemo, Suspense, useEffect } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { OrbitControls, PerspectiveCamera, Html, useHelper } from "@react-three/drei";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import * as THREE from "three";
import { cn } from "@/lib/utils";
// 角度配置常量
const AZIMUTH_OPTIONS = [
{ angle: 0, label: "正前方", value: "front" },
{ angle: 45, label: "前右", value: "front-right" },
{ angle: 90, label: "右侧", value: "right" },
{ angle: 135, label: "后右", value: "back-right" },
{ angle: 180, label: "正后方", value: "back" },
{ angle: 225, label: "后左", value: "back-left" },
{ angle: 270, label: "左侧", value: "left" },
{ angle: 315, label: "前左", value: "front-left" },
];
const ELEVATION_OPTIONS = [
{ angle: -30, label: "仰视", value: "low" },
{ angle: 0, label: "平视", value: "eye-level" },
{ angle: 30, label: "俯视", value: "elevated" },
{ angle: 60, label: "高角度", value: "high" },
];
const DISTANCE_OPTIONS = [
{ factor: 0.6, label: "特写", value: "close" },
{ factor: 1.0, label: "中景", value: "medium" },
{ factor: 1.8, label: "远景", value: "wide" },
];
interface AngleController3DProps {
onChange?: (angles: {
azimuth: string;
elevation: string;
distance: string;
azimuthDegree: number;
elevationDegree: number;
distanceFactor: number;
}) => void;
initialAzimuth?: number;
initialElevation?: number;
initialDistance?: number;
showObject?: boolean;
className?: string;
}
// 3D 场景中的相机指示器 - 简约现代风格
function CameraIndicator({ position }: { position: THREE.Vector3 }) {
// 计算相机朝向中心的旋转
const lookAtCenter = useMemo(() => {
const quaternion = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
const matrix = new THREE.Matrix4();
matrix.lookAt(position, new THREE.Vector3(0, 0, 0), up);
quaternion.setFromRotationMatrix(matrix);
return quaternion;
}, [position]);
return (
<group position={position} quaternion={lookAtCenter}>
{/* 相机主体 - 哑光黑/深灰 */}
<mesh castShadow>
<boxGeometry args={[0.28, 0.2, 0.18]} />
<meshStandardMaterial color="#334155" metalness={0.2} roughness={0.8} />
</mesh>
{/* 相机顶部 */}
<mesh position={[0, 0.12, -0.02]} castShadow>
<boxGeometry args={[0.12, 0.06, 0.1]} />
<meshStandardMaterial color="#1e293b" metalness={0.2} roughness={0.8} />
</mesh>
{/* 镜头筒 - 金属质感 */}
<mesh position={[0, 0, 0.14]} rotation={[Math.PI / 2, 0, 0]} castShadow>
<cylinderGeometry args={[0.08, 0.1, 0.12, 24]} />
<meshStandardMaterial color="#475569" metalness={0.6} roughness={0.3} />
</mesh>
{/* 镜头玻璃 - 蓝色反光 */}
<mesh position={[0, 0, 0.21]}>
<circleGeometry args={[0.07, 24]} />
<meshStandardMaterial
color="#3b82f6"
metalness={0.9}
roughness={0.1}
transparent
opacity={0.9}
/>
</mesh>
{/* 视野指示 - 极简线条 */}
<group position={[0, 0, 0.2]}>
<line>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={5}
args={[
new Float32Array([
-0.3, -0.2, 1.0, // 左下
0.3, -0.2, 1.0, // 右下
0.3, 0.2, 1.0, // 右上
-0.3, 0.2, 1.0, // 左上
-0.3, -0.2, 1.0, // 闭合
]),
3
]}
/>
</bufferGeometry>
<lineBasicMaterial color="#3b82f6" opacity={0.3} transparent />
</line>
{/* 连接线 */}
{[
[-0.3, -0.2, 1.0], [0.3, -0.2, 1.0], [0.3, 0.2, 1.0], [-0.3, 0.2, 1.0]
].map((point, i) => (
<line key={i}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={2}
args={[
new Float32Array([0, 0, 0, ...point]),
3
]}
/>
</bufferGeometry>
<lineBasicMaterial color="#3b82f6" opacity={0.15} transparent />
</line>
))}
</group>
</group>
);
}
// 方位角刻度圆环 - 更加隐形
function AzimuthRing() {
return (
<group>
{/* 圆环 */}
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.98, 2.02, 64]} />
<meshBasicMaterial color="#94a3b8" opacity={0.2} transparent side={THREE.DoubleSide} />
</mesh>
{/* 方位角标签 */}
{AZIMUTH_OPTIONS.map((opt) => {
const rad = (opt.angle * Math.PI) / 180;
const x = Math.sin(rad) * 2.3;
const z = Math.cos(rad) * 2.3;
return (
<Html key={opt.value} position={[x, 0, z]} center>
<div className="text-[10px] font-medium text-muted-foreground whitespace-nowrap select-none pointer-events-none bg-background/50 px-1.5 py-0.5 rounded-full border shadow-sm backdrop-blur-[2px]">
{opt.label}
</div>
</Html>
);
})}
</group>
);
}
// 目标物体 - 极简风格
function TargetObject() {
return (
<group>
{/* 极简几何体代表目标 */}
<mesh position={[0, 0.25, 0]}>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#e2e8f0" metalness={0.1} roughness={0.8} />
</mesh>
{/* 底部阴影 */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
<circleGeometry args={[0.6, 32]} />
<meshBasicMaterial color="#000000" opacity={0.1} transparent />
</mesh>
{/* 网格地面 */}
<gridHelper args={[4, 8, 0xe2e8f0, 0xf1f5f9]} position={[0, 0, 0]} />
</group>
);
}
// 3D 控制场景
function Scene({
azimuth,
elevation,
distance,
onAngleChange,
showObject,
}: {
azimuth: number;
elevation: number;
distance: number;
onAngleChange: (azimuth: number, elevation: number) => void;
showObject: boolean;
}) {
const controlsRef = useRef<OrbitControlsImpl>(null);
const ghostCameraRef = useRef<THREE.PerspectiveCamera>(null);
const mainCameraRef = useRef<THREE.PerspectiveCamera>(null);
// 计算目标拍摄位置(CameraIndicator的位置)
const targetPosition = useMemo(() => {
const azimuthRad = (azimuth * Math.PI) / 180;
const elevationRad = (elevation * Math.PI) / 180;
const radius = 2 * distance;
const x = radius * Math.cos(elevationRad) * Math.sin(azimuthRad);
const y = radius * Math.sin(elevationRad);
const z = radius * Math.cos(elevationRad) * Math.cos(azimuthRad);
return new THREE.Vector3(x, y, z);
}, [azimuth, elevation, distance]);
// 同步 Ghost Camera 位置到 state
// 当 state 改变时,更新 Ghost Camera 位置
useEffect(() => {
if (ghostCameraRef.current) {
ghostCameraRef.current.position.copy(targetPosition);
ghostCameraRef.current.lookAt(0, 0, 0);
}
}, [targetPosition]);
// 监听控制器变化 (用户拖拽)
const handleControlsChange = useCallback(() => {
if (ghostCameraRef.current) {
const camera = ghostCameraRef.current;
const spherical = new THREE.Spherical();
spherical.setFromVector3(camera.position);
// 将球面坐标转换为我们的角度系统
let newAzimuth = THREE.MathUtils.radToDeg(spherical.theta);
if (newAzimuth < 0) newAzimuth += 360;
const newElevation = 90 - THREE.MathUtils.radToDeg(spherical.phi);
// 只有当角度变化超过阈值时才触发更新,避免浮点数抖动
if (Math.abs(newAzimuth - azimuth) > 0.5 || Math.abs(newElevation - elevation) > 0.5) {
onAngleChange(Math.round(newAzimuth), Math.round(newElevation));
}
}
}, [onAngleChange, azimuth, elevation]);
return (
<>
{/* 主相机:上帝视角,固定位置 */}
<PerspectiveCamera
makeDefault
ref={mainCameraRef}
position={[3.5, 2.5, 3.5]}
fov={45}
onUpdate={(c) => c.lookAt(0, 0, 0)}
/>
{/* 幽灵相机:用于 OrbitControls 交互,不可见 */}
<PerspectiveCamera ref={ghostCameraRef} position={targetPosition} />
{/* 控制器绑定到幽灵相机 */}
<OrbitControls
ref={controlsRef}
camera={ghostCameraRef.current as any}
enablePan={false}
enableZoom={false}
minPolarAngle={Math.PI / 6}
maxPolarAngle={(Math.PI * 5) / 6}
onChange={handleControlsChange}
/>
{/* 灯光 */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} castShadow />
<pointLight position={[-5, 5, -5]} intensity={0.5} />
{/* 场景内容 */}
{showObject && <TargetObject />}
<AzimuthRing />
{/* 拍摄相机模型(在目标位置渲染) */}
<CameraIndicator position={targetPosition} />
{/* 坐标轴辅助 */}
<axesHelper args={[0.5]} />
</>
);
}
// 主组件
export function AngleController3D({
onChange,
initialAzimuth = 45,
initialElevation = 0,
initialDistance = 1.0,
showObject = true,
className,
}: AngleController3DProps) {
const [azimuth, setAzimuth] = useState(initialAzimuth);
const [elevation, setElevation] = useState(initialElevation);
const [distance, setDistance] = useState(initialDistance);
// 将角度映射到最近的预设值
const getClosestAzimuth = useCallback((deg: number) => {
let minDiff = 360;
let closest = AZIMUTH_OPTIONS[0];
for (const opt of AZIMUTH_OPTIONS) {
const diff = Math.min(Math.abs(deg - opt.angle), 360 - Math.abs(deg - opt.angle));
if (diff < minDiff) {
minDiff = diff;
closest = opt;
}
}
return closest;
}, []);
const getClosestElevation = useCallback((deg: number) => {
let minDiff = 180;
let closest = ELEVATION_OPTIONS[0];
for (const opt of ELEVATION_OPTIONS) {
const diff = Math.abs(deg - opt.angle);
if (diff < minDiff) {
minDiff = diff;
closest = opt;
}
}
return closest;
}, []);
const getClosestDistance = useCallback((factor: number) => {
let minDiff = 10;
let closest = DISTANCE_OPTIONS[0];
for (const opt of DISTANCE_OPTIONS) {
const diff = Math.abs(factor - opt.factor);
if (diff < minDiff) {
minDiff = diff;
closest = opt;
}
}
return closest;
}, []);
// 处理角度变化
const handleAngleChange = useCallback(
(newAzimuth: number, newElevation: number) => {
setAzimuth(newAzimuth);
setElevation(newElevation);
const closestAzimuth = getClosestAzimuth(newAzimuth);
const closestElevation = getClosestElevation(newElevation);
const closestDistance = getClosestDistance(distance);
onChange?.({
azimuth: closestAzimuth.value,
elevation: closestElevation.value,
distance: closestDistance.value,
azimuthDegree: newAzimuth,
elevationDegree: newElevation,
distanceFactor: distance,
});
},
[distance, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
);
// 处理距离变化
const handleDistanceChange = useCallback(
(newDistance: number) => {
setDistance(newDistance);
const closestAzimuth = getClosestAzimuth(azimuth);
const closestElevation = getClosestElevation(elevation);
const closestDistance = getClosestDistance(newDistance);
onChange?.({
azimuth: closestAzimuth.value,
elevation: closestElevation.value,
distance: closestDistance.value,
azimuthDegree: azimuth,
elevationDegree: elevation,
distanceFactor: newDistance,
});
},
[azimuth, elevation, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
);
// 快速选择预设角度
const handlePresetClick = useCallback(
(presetAzimuth: number, presetElevation: number) => {
setAzimuth(presetAzimuth);
setElevation(presetElevation);
const closestAzimuth = getClosestAzimuth(presetAzimuth);
const closestElevation = getClosestElevation(presetElevation);
const closestDistance = getClosestDistance(distance);
onChange?.({
azimuth: closestAzimuth.value,
elevation: closestElevation.value,
distance: closestDistance.value,
azimuthDegree: presetAzimuth,
elevationDegree: presetElevation,
distanceFactor: distance,
});
},
[distance, getClosestAzimuth, getClosestElevation, getClosestDistance, onChange]
);
const currentAzimuth = getClosestAzimuth(azimuth);
const currentElevation = getClosestElevation(elevation);
const currentDistance = getClosestDistance(distance);
return (
<div className={cn("flex flex-col gap-4", className)}>
{/* 3D 视图 */}
<div className="relative aspect-square w-full max-w-[400px] mx-auto rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-white border border-slate-200 shadow-sm dark:from-slate-900 dark:to-slate-950 dark:border-slate-800">
<Canvas>
<Suspense fallback={null}>
<Scene
azimuth={azimuth}
elevation={elevation}
distance={distance}
onAngleChange={handleAngleChange}
showObject={showObject}
/>
</Suspense>
</Canvas>
{/* 当前角度显示 - 悬浮卡片风格 */}
<div className="absolute bottom-3 left-3 right-3 flex justify-between text-xs font-medium text-foreground/80 bg-background/80 backdrop-blur-md rounded-lg px-3 py-2 border shadow-sm select-none pointer-events-none">
<span>方位: {currentAzimuth.label} ({azimuth}°)</span>
<span>仰角: {currentElevation.label} ({elevation}°)</span>
</div>
{/* 操作提示 */}
<div className="absolute top-3 left-3 text-[10px] font-medium text-muted-foreground bg-background/50 backdrop-blur-sm rounded-full px-2 py-1 border border-border/50 select-none pointer-events-none">
🖱️ 拖拽旋转相机位置
</div>
</div>
{/* 快速预设按钮 */}
<div className="space-y-3">
<div className="text-sm font-medium text-foreground">快速选择</div>
<div className="grid grid-cols-4 gap-2">
{[
{ label: "正前", azimuth: 0, elevation: 0 },
{ label: "前3/4", azimuth: 45, elevation: 0 },
{ label: "侧面", azimuth: 90, elevation: 0 },
{ label: "后3/4", azimuth: 135, elevation: 0 },
{ label: "正后", azimuth: 180, elevation: 0 },
{ label: "俯视", azimuth: 0, elevation: 60 },
{ label: "仰视", azimuth: 0, elevation: -30 },
{ label: "鸟瞰", azimuth: 45, elevation: 60 },
].map((preset) => (
<button
type="button"
key={preset.label}
onClick={() => handlePresetClick(preset.azimuth, preset.elevation)}
className={cn(
"px-2 py-1.5 text-xs rounded-md border transition-all duration-200 font-medium",
currentAzimuth.angle === preset.azimuth &&
currentElevation.angle === preset.elevation
? "bg-primary text-primary-foreground border-primary shadow-sm"
: "bg-background hover:bg-accent hover:text-accent-foreground border-border text-muted-foreground"
)}
>
{preset.label}
</button>
))}
</div>
</div>
{/* 距离控制 */}
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">镜头距离</div>
<div className="flex p-1 bg-muted rounded-lg">
{DISTANCE_OPTIONS.map((opt) => (
<button
type="button"
key={opt.value}
onClick={() => handleDistanceChange(opt.factor)}
className={cn(
"flex-1 px-3 py-1.5 text-xs rounded-md transition-all duration-200 font-medium",
currentDistance.value === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
);
}
export default AngleController3D;

View File

@ -0,0 +1,312 @@
"use client";
import { useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Loader2, Download, Check, RotateCcw, Move3D, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { AngleController3D } from "./angle-controller-3d";
import { cn } from "@/lib/utils";
interface AngleEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageUrl: string;
onComplete?: (resultUrl: string) => void;
}
interface AngleParams {
azimuth: string;
elevation: string;
distance: string;
azimuthDegree: number;
elevationDegree: number;
distanceFactor: number;
}
export function AngleEditDialog({
open,
onOpenChange,
imageUrl,
onComplete,
}: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(0.9);
const [currentAngles, setCurrentAngles] = useState<AngleParams>({
azimuth: "front-right",
elevation: "eye-level",
distance: "medium",
azimuthDegree: 45,
elevationDegree: 0,
distanceFactor: 1.0,
});
// 处理角度变化
const handleAngleChange = useCallback((angles: AngleParams) => {
setCurrentAngles(angles);
// 清除之前的结果,因为角度变了
setResultUrl(null);
}, []);
// 生成新视角
const handleGenerate = useCallback(async () => {
setIsProcessing(true);
setResultUrl(null);
try {
const response = await fetch("/api/angle-edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_url: imageUrl,
azimuth: currentAngles.azimuth,
elevation: currentAngles.elevation,
distance: currentAngles.distance,
lora_scale: loraScale,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "生成失败");
}
const data = await response.json();
setResultUrl(data.image_url);
toast.success("视角生成成功!");
} catch (error) {
console.error("角度编辑失败:", error);
toast.error(error instanceof Error ? error.message : "生成失败,请重试");
} finally {
setIsProcessing(false);
}
}, [imageUrl, currentAngles, loraScale]);
// 下载结果图片
const handleDownload = useCallback(async () => {
if (!resultUrl) return;
try {
const response = await fetch(resultUrl);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `angle-edit-${currentAngles.azimuth}-${currentAngles.elevation}-${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("图片已下载");
} catch {
toast.error("下载失败");
}
}, [resultUrl, currentAngles]);
// 使用此图片
const handleUseImage = useCallback(() => {
if (resultUrl) {
onComplete?.(resultUrl);
onOpenChange(false);
}
}, [resultUrl, onComplete, onOpenChange]);
// 重置
const handleReset = useCallback(() => {
setResultUrl(null);
setCurrentAngles({
azimuth: "front-right",
elevation: "eye-level",
distance: "medium",
azimuthDegree: 45,
elevationDegree: 0,
distanceFactor: 1.0,
});
setLoraScale(0.9);
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b bg-muted/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Move3D className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>多角度编辑</DialogTitle>
<DialogDescription className="mt-1">
拖拽 3D 控制器选择相机角度,AI 将生成对应视角的图像
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-full">
{/* 左侧:3D 控制器 (占 5 列) */}
<div className="lg:col-span-5 space-y-6 flex flex-col">
<div className="flex-1">
<AngleController3D
onChange={handleAngleChange}
initialAzimuth={currentAngles.azimuthDegree}
initialElevation={currentAngles.elevationDegree}
initialDistance={currentAngles.distanceFactor}
/>
</div>
{/* LoRA 强度控制 - 放入卡片 */}
<div className="rounded-lg border bg-card p-4 space-y-4 shadow-sm">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">变换强度</Label>
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">
{(loraScale * 100).toFixed(0)}%
</span>
</div>
<Slider
value={[loraScale]}
onValueChange={([v]: number[]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
className="py-2"
/>
<p className="text-xs text-muted-foreground leading-relaxed">
强度越高角度变化越明显,越低则保留更多原图特征。建议 85-95%。
</p>
</div>
</div>
{/* 右侧:图片预览 (占 7 列) */}
<div className="lg:col-span-7 flex flex-col gap-4 min-h-[500px]">
<div className="grid grid-rows-2 gap-4 flex-1 h-full">
{/* 原图 */}
<div className="relative rounded-xl border bg-muted/30 overflow-hidden group">
<div className="absolute top-3 left-3 z-10 bg-background/80 backdrop-blur-sm px-2 py-1 rounded text-xs font-medium border shadow-sm">
原图
</div>
<img
src={imageUrl}
alt="原图"
className="w-full h-full object-contain p-4 transition-transform duration-300 group-hover:scale-[1.02]"
/>
</div>
{/* 结果图 */}
<div className={cn(
"relative rounded-xl border overflow-hidden transition-all duration-300",
resultUrl ? "bg-muted/30 border-primary/20 shadow-sm" : "bg-muted/10 border-dashed"
)}>
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
<div className={cn(
"px-2 py-1 rounded text-xs font-medium border shadow-sm backdrop-blur-sm",
resultUrl ? "bg-primary/10 text-primary border-primary/20" : "bg-background/80 text-muted-foreground"
)}>
生成结果
</div>
{resultUrl && (
<span className="flex items-center gap-1 text-[10px] text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full border border-green-100">
<Check className="h-3 w-3" /> 完成
</span>
)}
</div>
<div className="w-full h-full flex items-center justify-center">
{isProcessing ? (
<div className="text-center space-y-4">
<div className="relative">
<div className="h-12 w-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin mx-auto" />
<div className="absolute inset-0 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-primary/50 animate-pulse" />
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">正在生成新视角...</p>
<p className="text-xs text-muted-foreground">预计需要 10-30 秒</p>
</div>
</div>
) : resultUrl ? (
<img
src={resultUrl}
alt="生成结果"
className="w-full h-full object-contain p-4 animate-in fade-in zoom-in-95 duration-300"
/>
) : (
<div className="text-center text-muted-foreground p-8">
<div className="bg-muted rounded-full p-4 w-fit mx-auto mb-4">
<Move3D className="h-8 w-8 opacity-50" />
</div>
<p className="text-sm font-medium text-foreground">准备就绪</p>
<p className="text-xs mt-1">调整左侧角度后点击生成</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
{/* 底部操作栏 */}
<div className="px-6 py-4 border-t bg-muted/30 flex justify-between items-center">
<Button
variant="ghost"
onClick={handleReset}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-4 w-4 mr-2" />
重置参数
</Button>
<div className="flex gap-3">
{resultUrl ? (
<>
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
</Button>
<Button onClick={handleUseImage} className="min-w-[120px]">
<Check className="h-4 w-4 mr-2" />
使用此图
</Button>
</>
) : (
<Button
onClick={handleGenerate}
disabled={isProcessing}
className="min-w-[140px]"
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
生成中...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
生成新视角
</>
)}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default AngleEditDialog;

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2 } from "lucide-react"; import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator"; import { ImageAnnotator } from "./image-annotator";
import { AngleEditDialog } from "./angle-edit-dialog";
export function ImagePreview({ export function ImagePreview({
src, src,
@ -14,9 +15,11 @@ export function ImagePreview({
onImageEnhance, onImageEnhance,
onImageRmbg, onImageRmbg,
onImageExtend, onImageExtend,
onImageAngleEdit,
isProcessingEnhance, isProcessingEnhance,
isProcessingRmbg, isProcessingRmbg,
isProcessingExtend, isProcessingExtend,
isProcessingAngleEdit,
imageIndex imageIndex
}: { }: {
src: string; src: string;
@ -31,9 +34,11 @@ export function ImagePreview({
onImageEnhance?: (imageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void; onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
isProcessingEnhance?: boolean; isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean; isProcessingRmbg?: boolean;
isProcessingExtend?: boolean; isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
imageIndex?: number; imageIndex?: number;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -41,6 +46,7 @@ export function ImagePreview({
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [showAnnotator, setShowAnnotator] = useState(false); const [showAnnotator, setShowAnnotator] = useState(false);
const [showResizeEditor, setShowResizeEditor] = useState(false); const [showResizeEditor, setShowResizeEditor] = useState(false);
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
// 解析 alt 文本,提取原图 URL 和宽度 // 解析 alt 文本,提取原图 URL 和宽度
// 格式优先级: // 格式优先级:
@ -272,11 +278,11 @@ export function ImagePreview({
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) { if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit) {
onImageExtend(original); onImageExtend(original);
} }
}} }}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend} disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit}
type="button" type="button"
aria-label="图像拓展" aria-label="图像拓展"
title="图像拓展" title="图像拓展"
@ -285,6 +291,24 @@ export function ImagePreview({
<span className="text-xs font-medium">图像拓展</span> <span className="text-xs font-medium">图像拓展</span>
</button> </button>
)} )}
{onImageAngleEdit && (
<button
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit) {
setShowAngleEditDialog(true);
}
}}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit}
type="button"
aria-label="角度调整"
title="角度调整"
>
<Move3D size={14} />
<span className="text-xs font-medium">角度调整</span>
</button>
)}
<button <button
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg" className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg"
onClick={(e) => { onClick={(e) => {
@ -529,6 +553,20 @@ export function ImagePreview({
onCancel={() => setShowResizeEditor(false)} onCancel={() => setShowResizeEditor(false)}
/> />
)} )}
{/* 角度调整对话框 */}
{showAngleEditDialog && onImageAngleEdit && (
<AngleEditDialog
open={showAngleEditDialog}
onOpenChange={setShowAngleEditDialog}
imageUrl={original}
onComplete={(resultUrl) => {
onImageAngleEdit(resultUrl);
setShowAngleEditDialog(false);
toast.success("角度调整图片已生成");
}}
/>
)}
</> </>
); );
} }

29
components/ui/slider.tsx Normal file
View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.35.3", "@codemirror/view": "^6.35.3",
"@fal-ai/serverless-client": "^0.15.0",
"@icons-pack/react-simple-icons": "^13.7.0", "@icons-pack/react-simple-icons": "^13.7.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/api-logs": "^0.200.0",
@ -40,6 +41,8 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@radix-ui/react-visually-hidden": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.1.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@types/form-data": "^2.5.2", "@types/form-data": "^2.5.2",
"@vercel/analytics": "^1.3.1", "@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1", "@vercel/blob": "^0.24.1",
@ -95,6 +98,8 @@
"swr": "^2.2.5", "swr": "^2.2.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"three": "^0.182.0",
"three-stdlib": "^2.36.1",
"tokenlens": "1.3.0", "tokenlens": "1.3.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
@ -113,6 +118,7 @@
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/sharp": "^0.32.0", "@types/sharp": "^0.32.0",
"@types/three": "^0.182.0",
"drizzle-kit": "^0.25.0", "drizzle-kit": "^0.25.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",

589
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -86,3 +86,5 @@ echo ""

View File

@ -139,3 +139,6 @@ pmset -g