feat: 在导航栏图片编辑器中添加角度调整功能按钮
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-12 11:27:16 +08:00
parent 1cd7289a36
commit 4024756ea6
7 changed files with 419 additions and 236 deletions

View File

@ -375,10 +375,9 @@ export function AdminSettingsClient() {
const [angleEditConfig, setAngleEditConfig] = useState({ const [angleEditConfig, setAngleEditConfig] = useState({
enabled: false, enabled: false,
falApiKey: "", falApiKey: "",
loraScale: 0.9, loraScale: 1.0, // 官方默认值
loraUrl: "",
inferenceSteps: 28, inferenceSteps: 28,
guidanceScale: 3.5, guidanceScale: 4.5, // 官方默认值
maxImageSize: 10, maxImageSize: 10,
timeout: 120, timeout: 120,
}); });
@ -449,7 +448,6 @@ export function AdminSettingsClient() {
enabled?: boolean; enabled?: boolean;
falApiKey?: string; falApiKey?: string;
loraScale?: number; loraScale?: number;
loraUrl?: string;
inferenceSteps?: number; inferenceSteps?: number;
guidanceScale?: number; guidanceScale?: number;
maxImageSize?: number; maxImageSize?: number;
@ -458,10 +456,9 @@ export function AdminSettingsClient() {
setAngleEditConfig({ setAngleEditConfig({
enabled: config.enabled || false, enabled: config.enabled || false,
falApiKey: config.falApiKey || "", falApiKey: config.falApiKey || "",
loraScale: config.loraScale ?? 0.9, loraScale: config.loraScale ?? 1.0,
loraUrl: config.loraUrl || "",
inferenceSteps: config.inferenceSteps ?? 28, inferenceSteps: config.inferenceSteps ?? 28,
guidanceScale: config.guidanceScale ?? 3.5, guidanceScale: config.guidanceScale ?? 4.5,
maxImageSize: config.maxImageSize ?? 10, maxImageSize: config.maxImageSize ?? 10,
timeout: config.timeout ?? 120, timeout: config.timeout ?? 120,
}); });
@ -885,7 +882,6 @@ export function AdminSettingsClient() {
enabled: angleEditConfig.enabled, enabled: angleEditConfig.enabled,
falApiKey: angleEditConfig.falApiKey.trim(), falApiKey: angleEditConfig.falApiKey.trim(),
loraScale: angleEditConfig.loraScale, loraScale: angleEditConfig.loraScale,
loraUrl: angleEditConfig.loraUrl.trim() || undefined,
inferenceSteps: angleEditConfig.inferenceSteps, inferenceSteps: angleEditConfig.inferenceSteps,
guidanceScale: angleEditConfig.guidanceScale, guidanceScale: angleEditConfig.guidanceScale,
maxImageSize: angleEditConfig.maxImageSize, maxImageSize: angleEditConfig.maxImageSize,
@ -3541,38 +3537,6 @@ export function AdminSettingsClient() {
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium">高级设置</Label> <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="space-y-1 text-xs text-muted-foreground">
<div>
默认地址(推荐使用,无需修改):
</div>
<div className="font-mono break-all">
huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA/resolve/main/qwen-image-edit-2511-multiple-angles-lora.safetensors
</div>
<div className="mt-1 text-muted-foreground/80">
如需自定义,请访问{" "}
<a
href="https://huggingface.co/fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:no-underline"
onClick={(e) => e.stopPropagation()}
>
Hugging Face 模型页面
</a>
{" "},找到文件并复制下载链接
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="inferenceSteps">推理步数</Label> <Label htmlFor="inferenceSteps">推理步数</Label>
@ -3592,7 +3556,7 @@ export function AdminSettingsClient() {
id="guidanceScale" id="guidanceScale"
type="number" type="number"
value={angleEditConfig.guidanceScale} 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} min={1}
max={10} max={10}
step={0.5} step={0.5}

View File

@ -10,57 +10,12 @@ type AngleEditConfig = {
enabled?: boolean; enabled?: boolean;
falApiKey?: string; falApiKey?: string;
loraScale?: number; loraScale?: number;
loraUrl?: string;
inferenceSteps?: number; inferenceSteps?: number;
guidanceScale?: number; guidanceScale?: number;
maxImageSize?: number; maxImageSize?: number;
timeout?: 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秒) // 设置API路由最大执行时间为3分钟(180秒)
export const maxDuration = 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. 解析请求 // 3. 解析请求
const body = await request.json(); const body = await request.json();
const { const {
image_url, image_url,
image_base64, image_base64,
preset, // 数值参数 (官方 API 格式)
azimuth, horizontal_angle, // 0-360 度
elevation, vertical_angle, // -30 到 90 度
distance, zoom, // 0-10
lora_scale, lora_scale,
} = body; } = body;
@ -182,29 +137,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "缺少图片数据" }, { status: 400 }); 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. 构建角度参数 console.log("[Angle-Edit] 角度参数:");
let angleParams: { azimuth: string; elevation: string; distance: string }; console.log(` - 水平角度: ${finalHorizontalAngle}°`);
console.log(` - 垂直角度: ${finalVerticalAngle}°`);
console.log(` - 缩放: ${finalZoom}`);
console.log(` - LoRA强度: ${finalLoraScale}`);
if (preset && ANGLE_PRESETS[preset]) { // 4. 准备图片输入
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; let imageInput: string;
if (image_url) { if (image_url) {
imageInput = image_url; imageInput = image_url;
@ -213,8 +158,8 @@ export async function POST(request: NextRequest) {
imageInput = `data:${mimeType};base64,${image_base64}`; imageInput = `data:${mimeType};base64,${image_base64}`;
} }
// 7. 动态导入 fal.ai SDK 并调用 API // 5. 动态导入 fal.ai SDK 并调用官方多角度 API
console.log("[Angle-Edit] 调用 fal.ai API..."); console.log("[Angle-Edit] 调用 fal.ai API (qwen-image-edit-2511-multiple-angles)...");
const fal = await import("@fal-ai/serverless-client"); const fal = await import("@fal-ai/serverless-client");
@ -222,16 +167,19 @@ export async function POST(request: NextRequest) {
credentials: config.falApiKey, credentials: config.falApiKey,
}); });
const loraUrl = config.loraUrl || DEFAULT_LORA_URL; // 使用官方多角度 API 端点
// 文档: https://fal.ai/models/fal-ai/qwen-image-edit-2511-multiple-angles
const result = await fal.subscribe("fal-ai/qwen-image-edit", { const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
input: { input: {
image_url: imageInput, image_urls: [imageInput], // 官方使用 image_urls (数组)
prompt: prompt, horizontal_angle: finalHorizontalAngle, // 水平旋转角度 0-360
lora_url: loraUrl, vertical_angle: finalVerticalAngle, // 垂直角度 -30 到 90
lora_scale: finalLoraScale, zoom: finalZoom, // 缩放 0-10
lora_scale: finalLoraScale, // LoRA 强度
num_inference_steps: config.inferenceSteps || 28, 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, logs: true,
onQueueUpdate: (update) => { onQueueUpdate: (update) => {
@ -241,16 +189,18 @@ export async function POST(request: NextRequest) {
}, },
}); });
// 8. 处理结果 // 6. 处理结果
const outputImage = (result as { images?: Array<{ url?: string }> }).images?.[0]; const apiResult = result as { images?: Array<{ url?: string }>; prompt?: string };
const outputImage = apiResult.images?.[0];
if (!outputImage?.url) { if (!outputImage?.url) {
throw new Error("API 未返回有效图片"); throw new Error("API 未返回有效图片");
} }
const outputImageUrl = outputImage.url; const outputImageUrl = outputImage.url;
console.log("[Angle-Edit] 输出图片URL:", outputImageUrl); console.log("[Angle-Edit] 输出图片URL:", outputImageUrl);
console.log("[Angle-Edit] API 构建的提示词:", apiResult.prompt);
// 9. 下载图片并上传到我们的 S3 // 7. 下载图片并上传到我们的 S3
const imageResponse = await fetch(outputImageUrl); const imageResponse = await fetch(outputImageUrl);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new Error(`下载输出图片失败: ${imageResponse.status}`); throw new Error(`下载输出图片失败: ${imageResponse.status}`);
@ -261,33 +211,57 @@ export async function POST(request: NextRequest) {
const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit"); const s3Result = await uploadBase64ToS3(imageBase64Result, "angle-edit");
console.log("[Angle-Edit] 已上传到S3:", s3Result.original); console.log("[Angle-Edit] 已上传到S3:", s3Result.original);
// 10. 保存到数据库 // 8. 保存到数据库
const processingTime = (Date.now() - startTime) / 1000; const processingTime = (Date.now() - startTime) / 1000;
await db.insert(generatedImage).values({ await db.insert(generatedImage).values({
userId: session.user.id, userId: session.user.id,
thumbnailUrl: s3Result.thumbnail, thumbnailUrl: s3Result.thumbnail,
originalUrl: s3Result.original, originalUrl: s3Result.original,
prompt: `角度编辑: ${preset || `${azimuth}-${elevation}-${distance}`}`, prompt: `角度编辑: H${finalHorizontalAngle}° V${finalVerticalAngle}° Z${finalZoom}`,
modelId: "qwen-image-edit-angle", modelId: "qwen-image-edit-2511-multiple-angles",
createdAt: new Date(), createdAt: new Date(),
}); });
console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒"); console.log("[Angle-Edit] ✅ 处理完成,耗时:", processingTime.toFixed(2), "秒");
// 11. 返回结果 // 9. 返回结果
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
image_url: s3Result.original, image_url: s3Result.original,
thumbnail_url: s3Result.thumbnail, thumbnail_url: s3Result.thumbnail,
image_base64: imageBase64Result, image_base64: imageBase64Result,
processing_time: processingTime, processing_time: processingTime,
preset: preset, angles: {
angles: angleParams, horizontal_angle: finalHorizontalAngle,
vertical_angle: finalVerticalAngle,
zoom: finalZoom,
},
prompt: apiResult.prompt,
}); });
} catch (error) { } catch (error) {
console.error("[Angle-Edit] ❌ 错误:", 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( return NextResponse.json(
{ {
error: error instanceof Error ? error.message : "角度编辑处理失败", error: error instanceof Error ? error.message : "角度编辑处理失败",

View File

@ -23,40 +23,15 @@ interface AngleEditDialogProps {
onComplete?: (resultUrl: string) => void; 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({ export function AngleEditDialog({
open, open,
onOpenChange, onOpenChange,
imageUrl, imageUrl,
onComplete,
}: AngleEditDialogProps) { }: AngleEditDialogProps) {
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [resultUrl, setResultUrl] = useState<string | null>(null); const [resultUrl, setResultUrl] = useState<string | null>(null);
const [loraScale, setLoraScale] = useState(0.9); const [loraScale, setLoraScale] = useState(1.0); // 官方默认值是 1
const [cameraState, setCameraState] = useState<CameraState>({ const [cameraState, setCameraState] = useState<CameraState>({
azimuth: 45, azimuth: 45,
elevation: 0, elevation: 0,
@ -97,14 +72,15 @@ export function AngleEditDialog({
setActiveTab("result"); // 切换到结果 Tab 显示加载状态 setActiveTab("result"); // 切换到结果 Tab 显示加载状态
try { try {
// 使用官方 API 的数值参数格式
const response = await fetch("/api/angle-edit", { const response = await fetch("/api/angle-edit", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
image_url: imageUrl, image_url: imageUrl,
azimuth: getAzimuthString(cameraState.azimuth), horizontal_angle: cameraState.azimuth, // 0-360 度
elevation: getElevationString(cameraState.elevation), vertical_angle: cameraState.elevation, // -30 到 90 度
distance: getDistanceString(cameraState.distance), zoom: cameraState.distance, // 0-10
lora_scale: loraScale, lora_scale: loraScale,
}), }),
}); });
@ -116,7 +92,7 @@ export function AngleEditDialog({
const data = await response.json(); const data = await response.json();
setResultUrl(data.image_url); setResultUrl(data.image_url);
toast.success("视角生成成功!"); toast.success("视角生成成功,已保存到相册!");
} catch (error) { } catch (error) {
console.error("角度编辑失败:", error); console.error("角度编辑失败:", error);
toast.error(error instanceof Error ? error.message : "生成失败,请重试"); toast.error(error instanceof Error ? error.message : "生成失败,请重试");
@ -150,11 +126,15 @@ export function AngleEditDialog({
// 使用此图片(直接发送到聊天) // 使用此图片(直接发送到聊天)
const handleUseImage = useCallback(() => { const handleUseImage = useCallback(() => {
if (resultUrl) { if (resultUrl) {
// 如果提供了 onComplete 回调,调用它
if (onComplete) {
onComplete(resultUrl);
}
// 关闭对话框,图片已经通过 API 保存到数据库 // 关闭对话框,图片已经通过 API 保存到数据库
onOpenChange(false); onOpenChange(false);
toast.success("图片已添加到对话"); toast.success("图片已添加到对话");
} }
}, [resultUrl, onOpenChange]); }, [resultUrl, onOpenChange, onComplete]);
// 重置 // 重置
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
@ -179,72 +159,73 @@ export function AngleEditDialog({
</div> </div>
<span className="font-semibold text-sm">3D 视角编辑器</span> <span className="font-semibold text-sm">3D 视角编辑器</span>
</div> </div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4" />
</Button>
</div> </div>
<div className="flex h-[480px]"> <div className="flex h-[500px]">
{/* 左侧:3D 控制区 (固定宽度 300px) */} {/* 左侧:3D 控制区 (固定宽度 320px) */}
<div className="w-[300px] flex flex-col border-r bg-muted/10"> <div className="w-[320px] flex flex-col border-r bg-muted/10">
{/* 内容区域 */} {/* 上部内容区域 - 可伸缩 */}
<div className="flex-1 flex flex-col p-3 gap-3"> <div className="flex-1 flex flex-col p-4 overflow-hidden">
{/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */} {/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */}
<QwenCameraWidget <QwenCameraWidget
initialState={cameraState} initialState={cameraState}
imageUrl={imageUrl} imageUrl={imageUrl}
onStateChange={handleCameraStateChange} onStateChange={handleCameraStateChange}
className="flex-1 min-h-0 mb-3"
/> />
{/* 变换强度控件 */} {/* 变换强度 - 调整样式使其更紧凑 */}
<div className="space-y-2 pt-2 border-t border-dashed"> <div className="space-y-2 px-1 flex-shrink-0">
<div className="flex justify-between items-center"> <div className="flex items-center gap-2">
<Label className="text-xs font-medium text-muted-foreground">变换强度</Label> <label className="text-[10px] font-semibold text-muted-foreground w-8">强度</label>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground"> <div className="flex-1 flex items-center gap-2">
{(loraScale * 100).toFixed(0)}% <Slider
</span> value={[loraScale]}
onValueChange={([v]: number[]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
className="flex-1 py-1"
/>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground min-w-[36px] text-center">
{(loraScale * 100).toFixed(0)}%
</span>
</div>
</div> </div>
<Slider
value={[loraScale]}
onValueChange={([v]: number[]) => setLoraScale(v)}
min={0.5}
max={1.0}
step={0.05}
className="py-0.5"
/>
</div> </div>
</div>
{/* 底部按钮区域 - 包含重置和生成 */}
<div className="p-4 border-t bg-background flex-shrink-0 flex gap-2">
<Button
variant="outline"
size="lg"
onClick={handleReset}
disabled={isProcessing}
className="h-10 w-10 p-0 flex-shrink-0"
title="重置参数"
>
<RotateCcw className="h-4 w-4" />
</Button>
{/* 操作按钮 */} <Button
<div className="flex gap-2 mt-auto"> onClick={handleGenerate}
<Button disabled={isProcessing}
variant="outline" className="flex-1 shadow-md transition-all hover:scale-[1.02] h-10"
size="sm" size="lg"
onClick={handleReset} >
disabled={isProcessing} {isProcessing ? (
className="h-9 text-xs" <>
> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
<RotateCcw className="h-3 w-3 mr-1" /> 生成中...
重置 </>
</Button> ) : (
<Button <>
onClick={handleGenerate} <Sparkles className="h-4 w-4 mr-2" />
disabled={isProcessing} 生成新视角
className="flex-1 h-9 shadow-sm" </>
size="sm" )}
> </Button>
{isProcessing ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
生成中...
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
生成新视角
</>
)}
</Button>
</div>
</div> </div>
</div> </div>
@ -300,11 +281,19 @@ export function AngleEditDialog({
<p className="text-xs text-muted-foreground animate-pulse">正在渲染新视角...</p> <p className="text-xs text-muted-foreground animate-pulse">正在渲染新视角...</p>
</div> </div>
) : resultUrl ? ( ) : resultUrl ? (
<img <div className="relative w-full h-full flex items-center justify-center group">
src={resultUrl} <img
alt="生成结果" src={resultUrl}
className="max-w-full max-h-full object-contain animate-in fade-in zoom-in-95 duration-300" alt="生成结果"
/> className="max-w-full max-h-full object-contain animate-in fade-in zoom-in-95 duration-300"
/>
<div className="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button onClick={handleDownload} size="sm" variant="secondary" className="shadow-lg backdrop-blur-md bg-background/80 hover:bg-background/90">
<Download className="h-4 w-4 mr-2" />
下载图片
</Button>
</div>
</div>
) : ( ) : (
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<Move3D className="h-8 w-8 mx-auto mb-2 opacity-20" /> <Move3D className="h-8 w-8 mx-auto mb-2 opacity-20" />

View File

@ -4,10 +4,11 @@ import React, { useRef, useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Link from "next/link"; import Link from "next/link";
import { useTheme } from "next-themes"; 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 { Button } from "./ui/button";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
import { AngleEditDialog } from "./angle-edit-dialog";
type Tool = "pen" | "arrow" | "circle" | "text"; type Tool = "pen" | "arrow" | "circle" | "text";
@ -65,6 +66,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
const [isRemovingBg, setIsRemovingBg] = useState(false); const [isRemovingBg, setIsRemovingBg] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false);
const [isExtending, setIsExtending] = useState(false); const [isExtending, setIsExtending] = useState(false);
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
const [originalImageUrl, setOriginalImageUrl] = useState<string>(imageUrl); const [originalImageUrl, setOriginalImageUrl] = useState<string>(imageUrl);
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null); const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null); const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null);
@ -604,6 +606,20 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
setResizeImageScale(1); 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) => { const handleResizeImageDragStart = (e: React.MouseEvent | React.TouchEvent) => {
if (!isResizeMode || !resizeCanvasRef.current) return; if (!isResizeMode || !resizeCanvasRef.current) return;
@ -1108,7 +1124,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleRemoveBackground} onClick={handleRemoveBackground}
disabled={isRemovingBg || isEnhancing || isExtending || isResizeMode || !image} disabled={isRemovingBg || isEnhancing || isExtending || isResizeMode || showAngleEditDialog || !image}
title="移除背景(抠图)" title="移除背景(抠图)"
> >
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} /> <Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
@ -1118,7 +1134,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleEnhance} onClick={handleEnhance}
disabled={isEnhancing || isRemovingBg || isExtending || isResizeMode || !image} disabled={isEnhancing || isRemovingBg || isExtending || isResizeMode || showAngleEditDialog || !image}
title="图片变清晰" title="图片变清晰"
> >
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} /> <Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
@ -1128,7 +1144,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleExtend} onClick={handleExtend}
disabled={isExtending || isRemovingBg || isEnhancing || isResizeMode || !image} disabled={isExtending || isRemovingBg || isEnhancing || isResizeMode || showAngleEditDialog || !image}
title="图像拓展" title="图像拓展"
> >
<Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} /> <Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} />
@ -1138,12 +1154,22 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleResize} onClick={handleResize}
disabled={isResizeMode || isRemovingBg || isEnhancing || isExtending || !image} disabled={isResizeMode || isRemovingBg || isEnhancing || isExtending || showAngleEditDialog || !image}
title="尺寸调整" title="尺寸调整"
> >
<Maximize2 className="mr-1 h-4 w-4" /> <Maximize2 className="mr-1 h-4 w-4" />
尺寸调整 尺寸调整
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={handleAngleEdit}
disabled={showAngleEditDialog || isRemovingBg || isEnhancing || isExtending || isResizeMode || !image}
title="角度调整"
>
<Move3D className="mr-1 h-4 w-4" />
角度调整
</Button>
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}> <Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
<Undo className="mr-1 h-4 w-4" /> <Undo className="mr-1 h-4 w-4" />
撤销 撤销
@ -1546,6 +1572,16 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
</div> </div>
</div> </div>
)} )}
{/* 角度调整对话框 */}
{showAngleEditDialog && (
<AngleEditDialog
open={showAngleEditDialog}
onOpenChange={setShowAngleEditDialog}
imageUrl={originalImageUrl}
onComplete={handleAngleEditComplete}
/>
)}
</div> </div>
); );

View File

@ -179,8 +179,11 @@ export function QwenCameraWidget({
// 相机 // 相机
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); 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; cameraRef.current = camera;
// 渲染器 // 渲染器
@ -256,7 +259,20 @@ export function QwenCameraWidget({
scene.add(azimuthHandle); scene.add(azimuthHandle);
azimuthHandleRef.current = 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[] = []; const arcPoints: THREE.Vector3[] = [];
for (let i = 0; i <= 32; i++) { for (let i = 0; i <= 32; i++) {
const angle = (-30 + (90 * i / 32)) * Math.PI / 180; const angle = (-30 + (90 * i / 32)) * Math.PI / 180;
@ -561,9 +577,9 @@ export function QwenCameraWidget({
}; };
return ( return (
<div className={cn("flex flex-col bg-transparent", className)} ref={containerRef}> <div className={cn("flex flex-col bg-transparent h-full", className)} ref={containerRef}>
{/* 3D 画布 - 固定高度 */} {/* 3D 画布 - 占据剩余空间 */}
<div className="relative w-full h-[200px] bg-muted/20 rounded-lg overflow-hidden border"> <div className="relative flex-1 min-h-0 bg-muted/20 rounded-lg overflow-hidden border">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full block cursor-grab active:cursor-grabbing" className="w-full h-full block cursor-grab active:cursor-grabbing"
@ -582,9 +598,9 @@ export function QwenCameraWidget({
</div> </div>
</div> </div>
{/* 控制面板 */} {/* 控制面板 - 紧凑布局 */}
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-2 flex-shrink-0">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-[10px] font-semibold text-muted-foreground w-8">水平</label> <label className="text-[10px] font-semibold text-muted-foreground w-8">水平</label>
<Select value={findClosestAzimuth(state.azimuth)} onValueChange={handleAzimuthChange}> <Select value={findClosestAzimuth(state.azimuth)} onValueChange={handleAzimuthChange}>

View File

@ -0,0 +1,203 @@
fal-ai/qwen-image-edit-2511-多角度
使用Qwen图像编辑2511和Lora多角度从不同角度(方位/海拔)生成相同的场景关于
使用<sks>触发词系统生成不同的相机角度。
从水平角度、垂直角度和缩放滑块自动构建提示。格式:<sks> [angle_description] [elevation_description] [distance_description]
1.调用API
#
安装客户端
#
客户端提供了与模型API交互的便捷方式npm install --save @fal-ai/client设置您的API密钥
#
在运行时将FAL_KEY设置为环境变量。
export FAL_KEY="YOUR_API_KEY"
提交请求
#
客户端API处理API提交协议。它将处理请求状态更新,并在请求完成后返回结果。
import { fal } from "@fal-ai/client";
const result = await fal.subscribe("fal-ai/qwen-image-edit-2511-multiple-angles", {
input: {
image_urls: ["https://v3b.fal.media/files/b/0a8973cb/qUbVwDCcMlvX4drBGYB1H.png"]
},
logs: true,
onQueueUpdate: (update) => {
if (update.status === "IN_PROGRESS") {
update.logs.map((log) => log.message).forEach(console.log);
}
},
});
console.log(result.data);
console.log(result.requestId);
2.认证
#
API使用API密钥进行身份验证。建议尽可能在运行时设置FAL_KEY环境变量。
API密钥
#
如果您的应用程序在无法设置环境变量的环境中运行,您可以手动将API密钥设置为客户端配置。
import { fal } from "@fal-ai/client";
fal.config({
credentials: "YOUR_FAL_KEY"
});3.排队
#
长期运行的请求
对于长期运行的请求,例如培训作业或推理时间较慢的模型,建议检查队列状态并依赖Webhooks,而不是在等待结果时阻止。
提交请求
#
客户端API提供了向模型提交请求的便捷方式。
import { fal } from "@fal-ai/client";
const { request_id } = await fal.queue.submit("fal-ai/qwen-image-edit-2511-multiple-angles", {
input: {
image_urls: ["https://v3b.fal.media/files/b/0a8973cb/qUbVwDCcMlvX4drBGYB1H.png"]
},
webhookUrl: "https://optional.webhook.url/for/results",
});
获取请求状态
#
您可以获取请求的状态,以检查它是已完成还是仍在进行中。
import { fal } from "@fal-ai/client";
const status = await fal.queue.status("fal-ai/qwen-image-edit-2511-multiple-angles", {
requestId: "764cabcf-b745-4b3e-ae38-1200304cf45b",
logs: true,
});
获得结果
#
请求完成后,您可以获取结果。请参阅输出模式,了解预期结果格式。
import { fal } from "@fal-ai/client";
const result = await fal.queue.result("fal-ai/qwen-image-edit-2511-multiple-angles", {
requestId: "764cabcf-b745-4b3e-ae38-1200304cf45b"
});
console.log(result.data);
console.log(result.requestId);
4.文件
#
API中的一些属性接受文件URL作为输入。每当这种情况时,您都可以传递您自己的URL或Base64数据URI。
数据URI(base64)
#
您可以将Base64数据URI作为文件输入传递。API将为您处理文件解码。请记住,对于大文件,这种替代方案虽然方便,但会影响请求性能。
托管文件(URL)
#
您也可以传递您自己的URL,只要它们可以公开访问。请注意,一些主机可能会阻止跨站点请求、速率限制或将请求视为机器人。
上传文件
#
我们提供方便的文件存储,允许您上传文件并在请求中使用它们。您可以使用客户端API上传文件,并在请求中使用返回的URL。
import { fal } from "@fal-ai/client";
const file = new File(["Hello, World!"], "hello.txt", { type: "text/plain" });
const url = await fal.storage.upload(file);
自动上传
如果您传递二进制对象,客户端将为您自动上传文件(例如File,Data)。
在我们的文件上传指南中阅读更多有关文件处理的信息。
5.模式
#
输入
#
image_urls list<string>
调整相机角度的图像URL。
horizontal_angle float
以度为单位的物体周围的水平旋转角度。0°=正面视图,90°=右侧,180°=后视图,270°=左侧,360°=正面视图。
vertical_angle float
垂直相机角度(以度为单位)。-30°=低角度拍摄(向上看),0°=眼睛水平,30°=高,60°=高角度,90°=鸟瞰图(向下看)。
zoom float
相机变焦/距离。0=广角镜头(远距离),5=中镜头(正常),10=特写(非常近)。默认值:5
lora_scale float
LoRA模型的比例因子。控制相机控制效果的强度。默认值:1
image_size ImageSize | Enum
生成的图像的大小。如果没有提供,将使用输入图像的大小。
可能的枚数值: square_hd, square, portrait_4_3, portrait_16_9, landscape_4_3, landscape_16_9
注意:对于自定义图像尺寸,您可以将width和height作为对象传递:
"image_size": {
"width": 1280,
"height": 720
}
guidance_scale float
CFG(分类器免费指导)量表。默认值:4.5
num_inference_steps integer
要执行的推理步骤的数量。默认值:28
acceleration AccelerationEnum
图像生成的加速水平。默认值:"regular"
可能的枚数值: none, regular
negative_prompt string
生成默认值的负提示:""
seed integer
可重复性的随机种子。
sync_mode boolean
如果True,媒体将作为数据URI返回。
enable_safety_checker boolean
是否启用安全检查器。默认值:true
output_format OutputFormatEnum
输出图像的格式默认值:"png"
可能的枚数值: png, jpeg, webp
num_images integer
要生成的图像数量默认值:1
{
"image_urls": [
"https://v3b.fal.media/files/b/0a8973cb/qUbVwDCcMlvX4drBGYB1H.png"
],
"zoom": 5,
"lora_scale": 1,
"guidance_scale": 4.5,
"num_inference_steps": 28,
"acceleration": "regular",
"enable_safety_checker": true,
"output_format": "png",
"num_images": 1
}
输出
#
images list<Image>
生成/编辑的图像
seed integer
用于生成的种子
prompt string
用于生成的构造提示
{
"images": [
{
"url": "https://v3b.fal.media/files/b/0a8973d9/8Z0xxKdGnoJAWc2tKJ68f.png"
}
],
"prompt": ""
}
其他类型
#
图像
#
url string
可以下载文件的URL。
content_type string
文件的Mime类型。
file_name string
文件的名称。如果没有提供,它将自动生成。
file_size integer
文件的大小,以字节为本。
file_data string
文件数据
width integer
图像的宽度,以像素为单位。
height integer
以像素为单位的图像高度。
图像大小
#
width integer
生成图像的宽度。默认值:512
height integer
生成的图像的高度。默认值:512

View File

@ -143,3 +143,4 @@ pmset -g