feat: 在导航栏图片编辑器中添加角度调整功能按钮
This commit is contained in:
parent
1cd7289a36
commit
4024756ea6
@ -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() {
|
||||
<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="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="space-y-2">
|
||||
<Label htmlFor="inferenceSteps">推理步数</Label>
|
||||
@ -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}
|
||||
|
||||
@ -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<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;
|
||||
|
||||
@ -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 = `<sks> ${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 : "角度编辑处理失败",
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [loraScale, setLoraScale] = useState(0.9);
|
||||
const [loraScale, setLoraScale] = useState(1.0); // 官方默认值是 1
|
||||
const [cameraState, setCameraState] = useState<CameraState>({
|
||||
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,74 +159,75 @@ export function AngleEditDialog({
|
||||
</div>
|
||||
<span className="font-semibold text-sm">3D 视角编辑器</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onOpenChange(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[480px]">
|
||||
{/* 左侧:3D 控制区 (固定宽度 300px) */}
|
||||
<div className="w-[300px] flex flex-col border-r bg-muted/10">
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 flex flex-col p-3 gap-3">
|
||||
<div className="flex h-[500px]">
|
||||
{/* 左侧:3D 控制区 (固定宽度 320px) */}
|
||||
<div className="w-[320px] flex flex-col border-r bg-muted/10">
|
||||
{/* 上部内容区域 - 可伸缩 */}
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden">
|
||||
{/* QwenCameraWidget - 包含 Canvas 和 3个角度选择器 */}
|
||||
<QwenCameraWidget
|
||||
initialState={cameraState}
|
||||
imageUrl={imageUrl}
|
||||
onStateChange={handleCameraStateChange}
|
||||
className="flex-1 min-h-0 mb-3"
|
||||
/>
|
||||
|
||||
{/* 变换强度控件 */}
|
||||
<div className="space-y-2 pt-2 border-t border-dashed">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-medium text-muted-foreground">变换强度</Label>
|
||||
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{(loraScale * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* 变换强度 - 调整样式使其更紧凑 */}
|
||||
<div className="space-y-2 px-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground w-8">强度</label>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Slider
|
||||
value={[loraScale]}
|
||||
onValueChange={([v]: number[]) => setLoraScale(v)}
|
||||
min={0.5}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
className="py-0.5"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2 mt-auto">
|
||||
{/* 底部按钮区域 - 包含重置和生成 */}
|
||||
<div className="p-4 border-t bg-background flex-shrink-0 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="lg"
|
||||
onClick={handleReset}
|
||||
disabled={isProcessing}
|
||||
className="h-9 text-xs"
|
||||
className="h-10 w-10 p-0 flex-shrink-0"
|
||||
title="重置参数"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
重置
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 h-9 shadow-sm"
|
||||
size="sm"
|
||||
className="flex-1 shadow-md transition-all hover:scale-[1.02] h-10"
|
||||
size="lg"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
生成新视角
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:预览区 (自适应) */}
|
||||
<div className="flex-1 flex flex-col bg-muted/5 p-4">
|
||||
@ -300,11 +281,19 @@ export function AngleEditDialog({
|
||||
<p className="text-xs text-muted-foreground animate-pulse">正在渲染新视角...</p>
|
||||
</div>
|
||||
) : resultUrl ? (
|
||||
<div className="relative w-full h-full flex items-center justify-center group">
|
||||
<img
|
||||
src={resultUrl}
|
||||
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">
|
||||
<Move3D className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
|
||||
@ -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<string>(imageUrl);
|
||||
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
|
||||
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(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="移除背景(抠图)"
|
||||
>
|
||||
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
|
||||
@ -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="图片变清晰"
|
||||
>
|
||||
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
|
||||
@ -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="图像拓展"
|
||||
>
|
||||
<Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} />
|
||||
@ -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="尺寸调整"
|
||||
>
|
||||
<Maximize2 className="mr-1 h-4 w-4" />
|
||||
尺寸调整
|
||||
</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}>
|
||||
<Undo className="mr-1 h-4 w-4" />
|
||||
撤销
|
||||
@ -1546,6 +1572,16 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 角度调整对话框 */}
|
||||
{showAngleEditDialog && (
|
||||
<AngleEditDialog
|
||||
open={showAngleEditDialog}
|
||||
onOpenChange={setShowAngleEditDialog}
|
||||
imageUrl={originalImageUrl}
|
||||
onComplete={handleAngleEditComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className={cn("flex flex-col bg-transparent", className)} ref={containerRef}>
|
||||
{/* 3D 画布 - 固定高度 */}
|
||||
<div className="relative w-full h-[200px] bg-muted/20 rounded-lg overflow-hidden border">
|
||||
<div className={cn("flex flex-col bg-transparent h-full", className)} ref={containerRef}>
|
||||
{/* 3D 画布 - 占据剩余空间 */}
|
||||
<div className="relative flex-1 min-h-0 bg-muted/20 rounded-lg overflow-hidden border">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full block cursor-grab active:cursor-grabbing"
|
||||
@ -582,9 +598,9 @@ export function QwenCameraWidget({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 控制面板 - 紧凑布局 */}
|
||||
<div className="mt-2 space-y-2 flex-shrink-0">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground w-8">水平</label>
|
||||
<Select value={findClosestAzimuth(state.azimuth)} onValueChange={handleAzimuthChange}>
|
||||
|
||||
203
docs/fal-ai-qwen-image-edit-api文档.md
Normal file
203
docs/fal-ai-qwen-image-edit-api文档.md
Normal 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
|
||||
@ -143,3 +143,4 @@ pmset -g
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user