diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 12e6909..46eb8e4 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -375,10 +375,9 @@ export function AdminSettingsClient() { const [angleEditConfig, setAngleEditConfig] = useState({ enabled: false, falApiKey: "", - loraScale: 0.9, - loraUrl: "", + loraScale: 1.0, // 官方默认值 inferenceSteps: 28, - guidanceScale: 3.5, + guidanceScale: 4.5, // 官方默认值 maxImageSize: 10, timeout: 120, }); @@ -449,7 +448,6 @@ export function AdminSettingsClient() { enabled?: boolean; falApiKey?: string; loraScale?: number; - loraUrl?: string; inferenceSteps?: number; guidanceScale?: number; maxImageSize?: number; @@ -458,10 +456,9 @@ export function AdminSettingsClient() { setAngleEditConfig({ enabled: config.enabled || false, falApiKey: config.falApiKey || "", - loraScale: config.loraScale ?? 0.9, - loraUrl: config.loraUrl || "", + loraScale: config.loraScale ?? 1.0, inferenceSteps: config.inferenceSteps ?? 28, - guidanceScale: config.guidanceScale ?? 3.5, + guidanceScale: config.guidanceScale ?? 4.5, maxImageSize: config.maxImageSize ?? 10, timeout: config.timeout ?? 120, }); @@ -885,7 +882,6 @@ export function AdminSettingsClient() { enabled: angleEditConfig.enabled, falApiKey: angleEditConfig.falApiKey.trim(), loraScale: angleEditConfig.loraScale, - loraUrl: angleEditConfig.loraUrl.trim() || undefined, inferenceSteps: angleEditConfig.inferenceSteps, guidanceScale: angleEditConfig.guidanceScale, maxImageSize: angleEditConfig.maxImageSize, @@ -3541,38 +3537,6 @@ export function AdminSettingsClient() {
-
- - setAngleEditConfig({ ...angleEditConfig, loraUrl: e.target.value })} - placeholder="留空使用默认地址" - disabled={!angleEditConfig.enabled} - /> -
-
- 默认地址(推荐使用,无需修改): -
-
- huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors -
-
- 如需自定义,请访问{" "} - e.stopPropagation()} - > - Hugging Face 模型页面 - - {" "},找到文件并复制下载链接 -
-
-
-
@@ -3592,7 +3556,7 @@ export function AdminSettingsClient() { id="guidanceScale" type="number" value={angleEditConfig.guidanceScale} - onChange={(e) => setAngleEditConfig({ ...angleEditConfig, guidanceScale: Number.parseFloat(e.target.value) || 3.5 })} + onChange={(e) => setAngleEditConfig({ ...angleEditConfig, guidanceScale: Number.parseFloat(e.target.value) || 4.5 })} min={1} max={10} step={0.5} diff --git a/app/api/angle-edit/route.ts b/app/api/angle-edit/route.ts index 4bfabeb..ec34afc 100644 --- a/app/api/angle-edit/route.ts +++ b/app/api/angle-edit/route.ts @@ -10,57 +10,12 @@ type AngleEditConfig = { enabled?: boolean; falApiKey?: string; loraScale?: number; - loraUrl?: string; inferenceSteps?: number; guidanceScale?: number; maxImageSize?: number; timeout?: number; }; -// 角度预设配置(使用官方提示词格式) -const ANGLE_PRESETS: Record = { - // 汽车常用角度 - "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 = { - "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 = { - "high": "high-angle shot", // 60° - "elevated": "elevated shot", // 30° - "eye-level": "eye-level shot", // 0° - "low": "low-angle shot", // -30° -}; - -// 距离映射(官方格式) -const DISTANCE_MAP: Record = { - "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; @@ -164,17 +119,17 @@ export async function POST(request: NextRequest) { ); } - console.log("[Angle-Edit] 配置加载成功,LoRA强度:", config.loraScale || 0.9); + console.log("[Angle-Edit] 配置加载成功,LoRA强度:", config.loraScale || 1); // 3. 解析请求 const body = await request.json(); const { image_url, image_base64, - preset, - azimuth, - elevation, - distance, + // 数值参数 (官方 API 格式) + horizontal_angle, // 0-360 度 + vertical_angle, // -30 到 90 度 + zoom, // 0-10 lora_scale, } = body; @@ -182,29 +137,19 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "缺少图片数据" }, { status: 400 }); } - // 使用请求中的 lora_scale 或配置中的默认值 - const finalLoraScale = lora_scale ?? config.loraScale ?? 0.9; + // 使用请求中的参数或配置中的默认值 + const finalLoraScale = lora_scale ?? config.loraScale ?? 1; + const finalHorizontalAngle = horizontal_angle ?? 0; + const finalVerticalAngle = vertical_angle ?? 0; + const finalZoom = zoom ?? 5; - // 4. 构建角度参数 - let angleParams: { azimuth: string; elevation: string; distance: string }; + console.log("[Angle-Edit] 角度参数:"); + console.log(` - 水平角度: ${finalHorizontalAngle}°`); + console.log(` - 垂直角度: ${finalVerticalAngle}°`); + console.log(` - 缩放: ${finalZoom}`); + console.log(` - LoRA强度: ${finalLoraScale}`); - 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 = ` ${angleParams.azimuth} ${angleParams.elevation} ${angleParams.distance}`; - console.log("[Angle-Edit] 提示词:", prompt); - - // 6. 准备图片输入 + // 4. 准备图片输入 let imageInput: string; if (image_url) { imageInput = image_url; @@ -213,8 +158,8 @@ export async function POST(request: NextRequest) { imageInput = `data:${mimeType};base64,${image_base64}`; } - // 7. 动态导入 fal.ai SDK 并调用 API - console.log("[Angle-Edit] 调用 fal.ai API..."); + // 5. 动态导入 fal.ai SDK 并调用官方多角度 API + console.log("[Angle-Edit] 调用 fal.ai API (qwen-image-edit-2511-multiple-angles)..."); const fal = await import("@fal-ai/serverless-client"); @@ -222,16 +167,19 @@ export async function POST(request: NextRequest) { credentials: config.falApiKey, }); - const loraUrl = config.loraUrl || DEFAULT_LORA_URL; - - const result = await fal.subscribe("fal-ai/qwen-image-edit", { + // 使用官方多角度 API 端点 + // 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles + const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", { input: { - image_url: imageInput, - prompt: prompt, - lora_url: loraUrl, - lora_scale: finalLoraScale, + image_urls: [imageInput], // 官方使用 image_urls (数组) + horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360 + vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90 + zoom: finalZoom, // 缩放 0-10 + lora_scale: finalLoraScale, // LoRA 强度 num_inference_steps: config.inferenceSteps || 28, - guidance_scale: config.guidanceScale || 3.5, + guidance_scale: config.guidanceScale || 4.5, // 官方默认 4.5 + enable_safety_checker: true, + output_format: "png", }, logs: true, onQueueUpdate: (update) => { @@ -241,16 +189,18 @@ export async function POST(request: NextRequest) { }, }); - // 8. 处理结果 - const outputImage = (result as { images?: Array<{ url?: string }> }).images?.[0]; + // 6. 处理结果 + const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string }; + const outputImage = apiResult.images?.[0]; if (!outputImage?.url) { throw new Error("API 未返回有效图片"); } const outputImageUrl = outputImage.url; console.log("[Angle-Edit] 输出图片URL:", outputImageUrl); + console.log("[Angle-Edit] API 构建的提示词:", apiResult.prompt); - // 9. 下载图片并上传到我们的 S3 + // 7. 下载图片并上传到我们的 S3 const imageResponse = await fetch(outputImageUrl); if (!imageResponse.ok) { throw new Error(`下载输出图片失败: ${imageResponse.status}`); @@ -261,33 +211,57 @@ export async function POST(request: NextRequest) { const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit"); console.log("[Angle-Edit] 已上传到S3:", s3Result.original); - // 10. 保存到数据库 + // 8. 保存到数据库 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", + prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`, + modelId: "qwen-image-edit-2511-multiple-angles", createdAt: new Date(), }); console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒"); - // 11. 返回结果 + // 9. 返回结果 return NextResponse.json({ success: true, image_url: s3Result.original, thumbnail_url: s3Result.thumbnail, image_base64: imageBase64Result, processing_time: processingTime, - preset: preset, - angles: angleParams, + angles: { + horizontal_angle: finalHorizontalAngle, + vertical_angle: finalVerticalAngle, + zoom: finalZoom, + }, + prompt: apiResult.prompt, }); } catch (error) { console.error("[Angle-Edit] ❌ 错误:", error); + + // 输出详细的错误信息 + if (error && typeof error === "object" && "status" in error) { + const apiError = error as { status?: number; body?: unknown; message?: string }; + console.error("[Angle-Edit] API 错误详情:"); + console.error(" - Status:", apiError.status); + console.error(" - Body:", JSON.stringify(apiError.body, null, 2)); + console.error(" - Message:", apiError.message); + + if (apiError.status === 403) { + return NextResponse.json( + { + error: "API 访问被拒绝 (403 Forbidden)。请检查 API Key 是否正确,以及是否有权限访问该模型。", + details: apiError.body, + }, + { status: 403 } + ); + } + } + return NextResponse.json( { error: error instanceof Error ? error.message : "角度编辑处理失败", diff --git a/components/angle-edit-dialog.tsx b/components/angle-edit-dialog.tsx index ff791a5..81505bf 100644 --- a/components/angle-edit-dialog.tsx +++ b/components/angle-edit-dialog.tsx @@ -23,40 +23,15 @@ interface AngleEditDialogProps { onComplete?: (resultUrl: string) => void; } -// 根据角度值生成 API 所需的字符串参数 -function getAzimuthString(azimuth: number): string { - const hAngle = azimuth % 360; - if (hAngle < 22.5 || hAngle >= 337.5) return "front"; - if (hAngle < 67.5) return "front-right"; - if (hAngle < 112.5) return "right"; - if (hAngle < 157.5) return "back-right"; - if (hAngle < 202.5) return "back"; - if (hAngle < 247.5) return "back-left"; - if (hAngle < 292.5) return "left"; - return "front-left"; -} - -function getElevationString(elevation: number): string { - if (elevation < -15) return "low"; - if (elevation < 15) return "eye-level"; - if (elevation < 45) return "elevated"; - return "high"; -} - -function getDistanceString(distance: number): string { - if (distance < 3) return "wide"; - if (distance < 7) return "medium"; - return "close"; -} - export function AngleEditDialog({ open, onOpenChange, imageUrl, + onComplete, }: AngleEditDialogProps) { const [isProcessing, setIsProcessing] = useState(false); const [resultUrl, setResultUrl] = useState(null); - const [loraScale, setLoraScale] = useState(0.9); + const [loraScale, setLoraScale] = useState(1.0); // 官方默认值是 1 const [cameraState, setCameraState] = useState({ azimuth: 45, elevation: 0, @@ -97,14 +72,15 @@ export function AngleEditDialog({ setActiveTab("result"); // 切换到结果 Tab 显示加载状态 try { + // 使用官方 API 的数值参数格式 const response = await fetch("/api/angle-edit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image_url: imageUrl, - azimuth: getAzimuthString(cameraState.azimuth), - elevation: getElevationString(cameraState.elevation), - distance: getDistanceString(cameraState.distance), + horizontal_angle: cameraState.azimuth, // 0-360 度 + vertical_angle: cameraState.elevation, // -30 到 90 度 + zoom: cameraState.distance, // 0-10 lora_scale: loraScale, }), }); @@ -116,7 +92,7 @@ export function AngleEditDialog({ const data = await response.json(); setResultUrl(data.image_url); - toast.success("视角生成成功!"); + toast.success("视角生成成功,已保存到相册!"); } catch (error) { console.error("角度编辑失败:", error); toast.error(error instanceof Error ? error.message : "生成失败,请重试"); @@ -150,11 +126,15 @@ export function AngleEditDialog({ // 使用此图片(直接发送到聊天) const handleUseImage = useCallback(() => { if (resultUrl) { + // 如果提供了 onComplete 回调,调用它 + if (onComplete) { + onComplete(resultUrl); + } // 关闭对话框,图片已经通过 API 保存到数据库 onOpenChange(false); toast.success("图片已添加到对话"); } - }, [resultUrl, onOpenChange]); + }, [resultUrl, onOpenChange, onComplete]); // 重置 const handleReset = useCallback(() => { @@ -179,72 +159,73 @@ export function AngleEditDialog({
3D 视角编辑器
-
-
- {/* 左侧:3D 控制区 (固定宽度 300px) */} -
- {/* 内容区域 */} -
+
+ {/* 左侧:3D 控制区 (固定宽度 320px) */} +
+ {/* 上部内容区域 - 可伸缩 */} +
{/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */} - {/* 变换强度控件 */} -
-
- - - {(loraScale * 100).toFixed(0)}% - + {/* 变换强度 - 调整样式使其更紧凑 */} +
+
+ +
+ setLoraScale(v)} + min={0.5} + max={1.0} + step={0.05} + className="flex-1 py-1" + /> + + {(loraScale * 100).toFixed(0)}% + +
- setLoraScale(v)} - min={0.5} - max={1.0} - step={0.05} - className="py-0.5" - />
+
+ + {/* 底部按钮区域 - 包含重置和生成 */} +
+ - {/* 操作按钮 */} -
- - -
+
@@ -300,11 +281,19 @@ export function AngleEditDialog({

正在渲染新视角...

) : resultUrl ? ( - 生成结果 +
+ 生成结果 +
+ +
+
) : (
diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index ffef1f9..91349ad 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -4,10 +4,11 @@ import React, { useRef, useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import Link from "next/link"; import { useTheme } from "next-themes"; -import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2 } from "lucide-react"; +import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D } from "lucide-react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { toast } from "sonner"; +import { AngleEditDialog } from "./angle-edit-dialog"; type Tool = "pen" | "arrow" | "circle" | "text"; @@ -65,6 +66,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod const [isRemovingBg, setIsRemovingBg] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); const [isExtending, setIsExtending] = useState(false); + const [showAngleEditDialog, setShowAngleEditDialog] = useState(false); const [originalImageUrl, setOriginalImageUrl] = useState(imageUrl); const [processedImageUrl, setProcessedImageUrl] = useState(null); const [processedImageBase64, setProcessedImageBase64] = useState(null); @@ -604,6 +606,20 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod setResizeImageScale(1); }; + // 角度调整处理 + const handleAngleEdit = () => { + if (!image) return; + setShowAngleEditDialog(true); + }; + + // 角度调整完成回调 + const handleAngleEditComplete = (resultUrl: string) => { + setProcessedImageUrl(resultUrl); + setProcessType("enhance"); // 使用现有的 processType 状态 + setShowAngleEditDialog(false); + toast.success("角度调整完成!图片已保存到相册。"); + }; + // 尺寸调整:处理图片拖拽 const handleResizeImageDragStart = (e: React.MouseEvent | React.TouchEvent) => { if (!isResizeMode || !resizeCanvasRef.current) return; @@ -1108,7 +1124,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod variant="ghost" size="sm" onClick={handleRemoveBackground} - disabled={isRemovingBg || isEnhancing || isExtending || isResizeMode || !image} + disabled={isRemovingBg || isEnhancing || isExtending || isResizeMode || showAngleEditDialog || !image} title="移除背景(抠图)" > @@ -1118,7 +1134,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod variant="ghost" size="sm" onClick={handleEnhance} - disabled={isEnhancing || isRemovingBg || isExtending || isResizeMode || !image} + disabled={isEnhancing || isRemovingBg || isExtending || isResizeMode || showAngleEditDialog || !image} title="图片变清晰" > @@ -1128,7 +1144,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod variant="ghost" size="sm" onClick={handleExtend} - disabled={isExtending || isRemovingBg || isEnhancing || isResizeMode || !image} + disabled={isExtending || isRemovingBg || isEnhancing || isResizeMode || showAngleEditDialog || !image} title="图像拓展" > @@ -1138,12 +1154,22 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod variant="ghost" size="sm" onClick={handleResize} - disabled={isResizeMode || isRemovingBg || isEnhancing || isExtending || !image} + disabled={isResizeMode || isRemovingBg || isEnhancing || isExtending || showAngleEditDialog || !image} title="尺寸调整" > 尺寸调整 +
)} + + {/* 角度调整对话框 */} + {showAngleEditDialog && ( + + )}
); diff --git a/components/qwen-camera-widget.tsx b/components/qwen-camera-widget.tsx index 680b182..e9873a5 100644 --- a/components/qwen-camera-widget.tsx +++ b/components/qwen-camera-widget.tsx @@ -179,8 +179,11 @@ export function QwenCameraWidget({ // 相机 const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); - camera.position.set(4.5, 4, 4.5); - camera.lookAt(0, 0.3, 0); + // 调整相机位置和注视点 + // position: 继续拉近距离,让主体显示得更大 (从 4.8 改为 3.8) + // lookAt Y: 稍微上调注视点,避免底部被切掉 + camera.position.set(3.8, 3.8, 3.8); + camera.lookAt(0, -1.2, 0); cameraRef.current = camera; // 渲染器 @@ -256,7 +259,20 @@ export function QwenCameraWidget({ scene.add(azimuthHandle); azimuthHandleRef.current = azimuthHandle; - // 仰角弧线 (青色) + // 仰角轨道背景 (灰色全圆环,表示垂直旋转的轨道) + const elTrackGeo = new THREE.TorusGeometry(ELEVATION_RADIUS, 0.02, 16, 100); + const elTrackMat = new THREE.MeshBasicMaterial({ + color: 0x444444, + transparent: true, + opacity: 0.3, + }); + const elevationTrack = new THREE.Mesh(elTrackGeo, elTrackMat); + elevationTrack.rotation.y = Math.PI / 2; // 垂直于 X 轴 + elevationTrack.position.x = ELEV_ARC_X; + elevationTrack.position.y = CENTER.y; + scene.add(elevationTrack); + + // 仰角弧线 (青色,表示有效范围) const arcPoints: THREE.Vector3[] = []; for (let i = 0; i <= 32; i++) { const angle = (-30 + (90 * i / 32)) * Math.PI / 180; @@ -561,9 +577,9 @@ export function QwenCameraWidget({ }; return ( -
- {/* 3D 画布 - 固定高度 */} -
+
+ {/* 3D 画布 - 占据剩余空间 */} +
- {/* 控制面板 */} -
-
+ {/* 控制面板 - 紧凑布局 */} +
+