diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 86617c5e5..18d77aeda 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -48,6 +48,7 @@ import { Sparkles, RefreshCw, Scissors, + Expand, } from "lucide-react"; import { Dialog, @@ -355,6 +356,13 @@ export function AdminSettingsClient() { scaleFactor: "auto", // "auto" | "1" | "2" | "4" }); + // 图像拓展API配置状态 + const [extendConfig, setExtendConfig] = useState({ + apiKey: "", + xScale: "1.5", // X轴放大倍数 + yScale: "1.5", // Y轴放大倍数 + }); + // 加载抠图API配置 const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); useEffect(() => { @@ -386,6 +394,23 @@ export function AdminSettingsClient() { } }, [enhanceConfigData]); + // 加载图像拓展API配置 + const { data: extendConfigData } = useSWR("/api/admin/settings?key=extend_api_config", fetcher); + useEffect(() => { + if (extendConfigData?.setting?.value) { + const config = extendConfigData.setting.value as { + apiKey?: string; + xScale?: number; + yScale?: number; + }; + setExtendConfig({ + apiKey: config.apiKey || "", + xScale: config.xScale ? String(config.xScale) : "1.5", + yScale: config.yScale ? String(config.yScale) : "1.5", + }); + } + }, [extendConfigData]); + // 导航链接状态 const [navLinkForm, setNavLinkForm] = useState({ id: "", @@ -727,6 +752,47 @@ export function AdminSettingsClient() { } }; + // --- 图像拓展API配置逻辑 --- + const handleSaveExtendConfig = async () => { + try { + if (!extendConfig.apiKey.trim()) { + toast.error("请输入API密钥"); + return; + } + + const xScale = Number.parseFloat(extendConfig.xScale); + const yScale = Number.parseFloat(extendConfig.yScale); + + if (isNaN(xScale) || xScale < 1.0 || xScale > 2.0) { + toast.error("X轴放大倍数必须在1.0-2.0之间"); + return; + } + + if (isNaN(yScale) || yScale < 1.0 || yScale > 2.0) { + toast.error("Y轴放大倍数必须在1.0-2.0之间"); + return; + } + + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: "extend_api_config", + value: { + apiKey: extendConfig.apiKey.trim(), + xScale: xScale, + yScale: yScale, + }, + }), + }); + + if (!response.ok) throw new Error("保存失败"); + toast.success("图像拓展API配置保存成功"); + } catch (error) { + toast.error("保存图像拓展API配置失败"); + } + }; + // --- 导航链接逻辑 --- const resetNavLinkForm = () => { setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true }); @@ -3100,6 +3166,79 @@ export function AdminSettingsClient() { + + {/* 图像拓展API配置 */} + + + 图像拓展API配置 + + 配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理 + + + + + API密钥 * + setExtendConfig({ ...extendConfig, apiKey: e.target.value })} + placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + /> + + 请输入阿里云Dashscope API密钥,例如: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + + + + X轴放大倍数 * + setExtendConfig({ ...extendConfig, xScale: e.target.value })} + placeholder="1.5" + /> + + X轴放大倍数(1.0-2.0),推荐1.5 + + + + Y轴放大倍数 * + setExtendConfig({ ...extendConfig, yScale: e.target.value })} + placeholder="1.5" + /> + + Y轴放大倍数(1.0-2.0),推荐1.5 + + + + + API文档说明 + + • 使用阿里云Dashscope图像拓展API(image-out-painting模型) + • API端点: https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting + • 支持的图片格式:JPG, PNG, WebP等常见格式 + • 最大图片大小:4MB + • 处理时间:约5-15秒/张 + • 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展) + + + + 保存配置 + + + diff --git a/app/api/extend/route.ts b/app/api/extend/route.ts new file mode 100644 index 000000000..f5a6fc0e5 --- /dev/null +++ b/app/api/extend/route.ts @@ -0,0 +1,352 @@ +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"; + +/** + * 压缩图片生成缩略图(使用 sharp) + */ +async function createThumbnail(buffer: Buffer, mimeType: string): Promise { + try { + const sharp = await import("sharp"); + + // 创建缩略图(宽度 500px,高度按比例,质量 80%) + const thumbnailBuffer = await sharp.default(buffer) + .resize(500, null, { + fit: "inside", + withoutEnlargement: true, + }) + .jpeg({ quality: 80 }) // JPEG格式 + .toBuffer(); + + console.log(`[Extend] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`); + return thumbnailBuffer; + } catch (error) { + console.error("[Extend] Failed to create thumbnail:", error); + // 如果失败,返回原图 + return buffer; + } +} + +/** + * 将 base64 图片上传到 S3,返回缩略图和原图的 URL + */ +async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> { + try { + // 解析 base64 数据 + const buffer = Buffer.from(base64Data, "base64"); + + // 生成文件名 + const ext = mimeType.split("/")[1] || "jpg"; + const timestamp = Date.now(); + const randomStr = Math.random().toString(36).slice(2, 8); + const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.${ext}`; + const originalFilename = `extend-orig-${timestamp}-${randomStr}.${ext}`; + + console.log(`[Extend] Original size: ${buffer.length} bytes`); + + // 生成缩略图 + const thumbnailBuffer = await createThumbnail(buffer, mimeType); + + // 转换为 ArrayBuffer + 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; + + // 并行上传缩略图和原图到 S3 + console.log(`[Extend] Uploading thumbnail and original to S3...`); + const [thumbnailResult, originalResult] = await Promise.all([ + uploadFile(thumbnailFilename, thumbnailArrayBuffer), + uploadFile(originalFilename, originalArrayBuffer), + ]); + + console.log(`[Extend] Uploaded to S3:`); + console.log(` - Thumbnail: ${thumbnailResult.url}`); + console.log(` - Original: ${originalResult.url}`); + + return { + thumbnail: thumbnailResult.url, + original: originalResult.url, + }; + } catch (error) { + console.error("[Extend] Failed to upload to S3:", error); + throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`); + } +} + +// 设置API路由最大执行时间为3分钟(180秒) +export const maxDuration = 180; + +/** + * 图像拓展核心实现 + * @param imageBase64 - Base64编码的图片数据 + * @param apiKey - Dashscope API Key + * @param xScale - X轴放大倍数(默认1.5) + * @param yScale - Y轴放大倍数(默认1.5) + * @returns 拓展后的图片URL + */ +async function extendImage( + imageBase64: string, + apiKey: string, + xScale: number = 1.5, + yScale: number = 1.5 +): Promise { + console.log("[Extend API] 开始图像拓展..."); + + // 1. 提交拓展任务 + const requestBody = { + model: "image-out-painting", + input: { + image_base64: imageBase64, + }, + parameters: { + x_scale: xScale, + y_scale: yScale, + best_quality: false, + limit_image_size: true, + add_watermark: false, + }, + }; + + console.log("[Extend API] 提交拓展任务..."); + const response = await fetch( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-DashScope-Async": "enable", + }, + body: JSON.stringify(requestBody), + } + ); + + const initialResult = await response.json(); + + if (initialResult.code) { + console.error("[Extend API] API调用失败:", initialResult); + throw new Error( + `图像拓展API错误: ${initialResult.message || JSON.stringify(initialResult)}` + ); + } + + // 2. 获取任务ID + const taskId = initialResult.output.task_id; + const taskStatusUrl = `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`; + console.log(`[Extend API] 任务已提交,任务ID: ${taskId}`); + + // 3. 等待3秒后开始轮询 + console.log("[Extend API] 等待API初始化..."); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // 4. 轮询获取结果 + let attempts = 0; + const maxAttempts = 60; // 最多轮询60次 + const pollInterval = 2000; // 每2秒轮询一次 + + console.log("[Extend API] 开始轮询任务状态..."); + while (attempts < maxAttempts) { + const statusResponse = await fetch(taskStatusUrl, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + const result = await statusResponse.json(); + + if (attempts === 0 || attempts % 5 === 0) { + const elapsedTime = attempts * 2 + 3; + console.log(`[Extend API] [${elapsedTime}秒] 任务状态: ${result.output?.task_status || "UNKNOWN"}`); + } + + if (result.output?.task_status === "SUCCEEDED") { + const elapsedTime = attempts * 2 + 3; + console.log(`[Extend API] ✅ 任务完成(总用时 ${elapsedTime}秒)`); + return result.output.output_image_url; + } else if (result.output?.task_status === "FAILED") { + console.error("[Extend API] 任务失败:", result.output); + throw new Error( + `图像拓展任务失败: ${result.output.message || result.output.code || "未知错误"}` + ); + } + + attempts++; + if (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + } + + throw new Error(`图像拓展任务超时(等待${maxAttempts * 2 + 3}秒)`); +} + +/** + * 图像拓展API路由 + * 从数据库读取图像拓展API配置,调用Dashscope API进行图像拓展处理 + * 将处理后的图片上传到S3并保存到数据库 + */ +export async function POST(request: NextRequest) { + console.log("[Extend API] ===== 收到请求 ====="); + try { + // 0. 获取用户会话 + console.log("[Extend API] 开始获取用户会话..."); + const session = await auth(); + console.log("[Extend API] 用户会话获取完成:", session?.user?.id ? "已登录" : "未登录"); + if (!session?.user?.id) { + return NextResponse.json( + { error: "未授权,请先登录" }, + { status: 401 } + ); + } + + const userId = session.user.id; + console.log("[Extend API] 用户ID:", userId); + + // 1. 从数据库获取图像拓展API配置 + console.log("[Extend API] 开始获取配置..."); + const extendConfig = await getSystemSetting({ key: "extend_api_config" }); + console.log("[Extend API] 配置获取完成:", extendConfig ? "已找到" : "未找到"); + + if (!extendConfig || !extendConfig.value) { + return NextResponse.json( + { error: "图像拓展API配置未设置,请在系统设置中配置" }, + { status: 400 } + ); + } + + const config = extendConfig.value as { + apiKey?: string; + xScale?: number; + yScale?: number; + }; + + if (!config.apiKey) { + return NextResponse.json( + { error: "图像拓展API密钥未配置" }, + { status: 400 } + ); + } + + // 2. 获取请求体(Base64图片或FormData) + console.log("[Extend API] 开始获取请求体..."); + const contentType = request.headers.get("content-type") || ""; + console.log("[Extend API] Content-Type:", contentType); + + let imageBase64: string; + let prompt = ""; // 用户提示词(可选) + let chatId: string | null = null; // 聊天ID(可选) + let messageId: string | null = null; + + if (contentType.includes("application/json")) { + // JSON格式:Base64图片 + const body = await request.json(); + imageBase64 = body.image_base64; + prompt = body.prompt || "图像拓展处理"; + chatId = body.chatId || null; + messageId = body.messageId || null; + + if (!imageBase64) { + return NextResponse.json( + { error: "缺少image_base64参数" }, + { status: 400 } + ); + } + } else if (contentType.includes("multipart/form-data")) { + // FormData格式:文件上传 + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json( + { error: "缺少file参数" }, + { status: 400 } + ); + } + + // 将文件转换为Base64 + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + imageBase64 = buffer.toString("base64"); + prompt = (formData.get("prompt") as string) || "图像拓展处理"; + chatId = (formData.get("chatId") as string) || null; + messageId = (formData.get("messageId") as string) || null; + } else { + return NextResponse.json( + { error: "不支持的Content-Type,请使用application/json或multipart/form-data" }, + { status: 400 } + ); + } + + console.log(`[Extend API] 图片Base64长度: ${imageBase64.length} 字符`); + + // 3. 调用图像拓展API + const startTime = Date.now(); + const xScale = config.xScale || 1.5; + const yScale = config.yScale || 1.5; + + console.log(`[Extend API] 拓展参数: X=${xScale}, Y=${yScale}`); + + const extendedImageUrl = await extendImage(imageBase64, config.apiKey, xScale, yScale); + console.log("[Extend API] 拓展后图片URL:", extendedImageUrl); + + // 4. 下载拓展后的图片 + console.log("[Extend API] 下载拓展后的图片..."); + const imageResponse = await fetch(extendedImageUrl); + if (!imageResponse.ok) { + throw new Error(`下载拓展图片失败: ${imageResponse.status} ${imageResponse.statusText}`); + } + + const arrayBuffer = await imageResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const extendedImageBase64 = buffer.toString("base64"); + + // 5. 上传到S3 + console.log("[Extend API] 上传到S3..."); + const { thumbnail, original } = await uploadBase64ToS3(extendedImageBase64, "image/jpeg"); + + // 6. 保存到数据库 + console.log("[Extend API] 保存到数据库..."); + const [savedImage] = await db + .insert(generatedImage) + .values({ + userId, + chatId, + messageId, + thumbnailUrl: thumbnail, + originalUrl: original, + prompt, + modelId: "extend-1.0", + imageSize: "2K", + }) + .returning(); + + const processingTime = ((Date.now() - startTime) / 1000).toFixed(2); + console.log(`[Extend API] ✅ 图像拓展完成,处理时间: ${processingTime}秒`); + + // 7. 返回结果 + return NextResponse.json({ + success: true, + original_url: original, + thumbnail_url: thumbnail, + base64: extendedImageBase64, + processing_time: parseFloat(processingTime), + }); + } catch (error) { + console.error("[Extend API] 错误:", error); + return NextResponse.json( + { + error: "图像拓展失败", + detail: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} + diff --git a/components/chat.tsx b/components/chat.tsx index 845f42e1f..ab586f52c 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -663,6 +663,117 @@ export function Chat({ } }; + // 处理图像拓展 + const handleImageExtend = async (imageUrl: string) => { + const messageId = generateUUID(); + + // 创建临时加载消息 + const loadingMessage: ChatMessage = { + id: messageId, + role: "assistant", + parts: [{ type: "text", text: "正在图像拓展..." }], + metadata: { + createdAt: new Date().toISOString(), + }, + } as ChatMessage; + + setMessages((prev) => [...prev, loadingMessage]); + + try { + // 获取图片并转换为Base64 + const response = await fetch(imageUrl); + const blob = await response.blob(); + const reader = new FileReader(); + + reader.onloadend = async () => { + try { + const base64Data = (reader.result as string).split(",")[1]; + + // 调用图像拓展API + const extendResponse = await fetch("/api/extend", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + image_base64: base64Data, + prompt: "图片图像拓展处理", + }), + }); + + if (!extendResponse.ok) { + const error = await extendResponse.json(); + throw new Error(error.error || "图像拓展处理失败"); + } + + const result = await extendResponse.json(); + + if (result.success && result.original_url) { + // 构造图片Markdown(使用与Gemini生成相同的格式) + const imageMarkdown = ``; + + // 创建最终消息(前端显示用) + const finalMessage: ChatMessage = { + id: messageId, + role: "assistant", + parts: [{ type: "text", text: imageMarkdown }], + metadata: { + createdAt: new Date().toISOString(), + }, + } as ChatMessage; + + // 更新消息 + setMessages((prev) => + prev.map((msg) => (msg.id === messageId ? finalMessage : msg)) + ); + + // 保存消息到数据库 + try { + await fetch("/api/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chatId: id, + message: { + id: messageId, + role: "assistant", + parts: [{ type: "text", text: imageMarkdown }], + attachments: [], + createdAt: new Date().toISOString(), + }, + }), + }); + } catch (saveError) { + console.error("[Chat] Failed to save message:", saveError); + } + + toast.success(`图像拓展完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); + } else { + throw new Error(result.error || "图像拓展处理失败"); + } + } catch (error) { + console.error("[图像拓展] 错误:", error); + toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); + // 移除加载消息 + setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); + } + }; + + reader.onerror = () => { + toast.error("图片读取失败"); + setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); + }; + + reader.readAsDataURL(blob); + } catch (error) { + console.error("[图像拓展] 错误:", error); + toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); + setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); + } + }; + useAutoResume({ autoResume, initialMessages, @@ -694,6 +805,7 @@ export function Chat({ onDrawingComplete={handleDrawingComplete} onImageEnhance={handleImageEnhance} onImageRmbg={handleImageRmbg} + onImageExtend={handleImageExtend} pendingGeneration={pendingGeneration} imageCount={imageParams?.imageCount || 1} /> diff --git a/components/elements/response.tsx b/components/elements/response.tsx index b60f698af..f2b2cd5fd 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -11,10 +11,11 @@ type ResponseProps = ComponentProps & { onDrawingComplete?: (annotatedImageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void; + onImageExtend?: (imageUrl: string) => void; }; export const Response = memo( - ({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, ...props }: ResponseProps) => ( + ({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, onImageExtend, ...props }: ResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto", @@ -32,6 +33,7 @@ export const Response = memo( onDrawingComplete={onDrawingComplete} onImageEnhance={onImageEnhance} onImageRmbg={onImageRmbg} + onImageExtend={onImageExtend} />; }, video: ({ src, alt }: { src?: string; alt?: string }) => { @@ -49,7 +51,8 @@ export const Response = memo( prevProps.onImageEdit === nextProps.onImageEdit && prevProps.onDrawingComplete === nextProps.onDrawingComplete && prevProps.onImageEnhance === nextProps.onImageEnhance && - prevProps.onImageRmbg === nextProps.onImageRmbg + prevProps.onImageRmbg === nextProps.onImageRmbg && + prevProps.onImageExtend === nextProps.onImageExtend ); Response.displayName = "Response"; diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 384fa8c05..db711a7c1 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useRef, useState, useEffect, useCallback } from "react"; -import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download } from "lucide-react"; +import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand } from "lucide-react"; import { Button } from "./ui/button"; import { toast } from "sonner"; @@ -59,10 +59,11 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator const [isLoading, setIsLoading] = useState(true); const [isRemovingBg, setIsRemovingBg] = useState(false); const [isEnhancing, setIsEnhancing] = useState(false); + const [isExtending, setIsExtending] = useState(false); const [originalImageUrl, setOriginalImageUrl] = useState(imageUrl); const [processedImageUrl, setProcessedImageUrl] = useState(null); const [processedImageBase64, setProcessedImageBase64] = useState(null); - const [processType, setProcessType] = useState<"rmbg" | "enhance" | null>(null); + const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | null>(null); // 加载图片 useEffect(() => { @@ -422,32 +423,98 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator } catch (error) { console.error("[变清晰] 错误:", error); toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); + } finally { + setIsEnhancing(false); + } + }; + + // 图像拓展处理 + const handleExtend = async () => { + if (!image || !canvasRef.current) return; + + setIsExtending(true); + try { + // 获取当前画布的图片(包含标注) + const canvas = canvasRef.current; + const currentImageData = canvas.toDataURL("image/png"); + + // 提取Base64数据(去掉data:image/png;base64,前缀) + const base64Data = currentImageData.split(",")[1]; + + // 调用图像拓展API + const response = await fetch("/api/extend", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + image_base64: base64Data, + prompt: "画图标注-图像拓展处理", // 可选:添加提示词 + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "图像拓展处理失败"); + } + + const result = await response.json(); + + if (result.success && result.base64) { + // 保存处理后的图片URL和Base64 + if (result.original_url) { + setProcessedImageUrl(result.original_url); + } else { + setProcessedImageUrl(null); + } + setProcessedImageBase64(result.base64); + setProcessType("extend"); + + toast.success(`图像拓展完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); + } else { + throw new Error(result.error || "图像拓展处理失败"); + } + } catch (error) { + console.error("[图像拓展] 错误:", error); + toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); } finally { - setIsEnhancing(false); + setIsExtending(false); } }; - // 下载处理后的图片 - const handleDownloadProcessed = () => { - if (!processedImageBase64) return; + // 下载处理后的图片(必须使用原图URL) + const handleDownloadProcessed = async () => { + if (!processedImageUrl) { + toast.error("原图地址不可用,无法下载"); + return; + } try { // 确定文件类型和MIME类型 const isPng = processType === "rmbg"; - const mimeType = isPng ? "image/png" : "image/jpeg"; const fileExtension = isPng ? "png" : "jpg"; - const fileName = processType === "rmbg" ? `抠图-${Date.now()}.${fileExtension}` : `变清晰-${Date.now()}.${fileExtension}`; + const fileName = processType === "rmbg" + ? `抠图-${Date.now()}.${fileExtension}` + : processType === "enhance" + ? `变清晰-${Date.now()}.${fileExtension}` + : `图像拓展-${Date.now()}.${fileExtension}`; - // 创建下载链接 - const dataUrl = `data:${mimeType};base64,${processedImageBase64}`; + // 从原图URL下载(原图已上传到S3) + const response = await fetch(processedImageUrl); + if (!response.ok) { + throw new Error("下载原图失败"); + } + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); - link.href = dataUrl; + link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); + window.URL.revokeObjectURL(url); - toast.success("图片下载成功"); + toast.success("原图下载成功"); } catch (error) { console.error("[下载] 错误:", error); toast.error("下载失败,请稍后重试"); @@ -495,7 +562,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator variant="ghost" size="sm" onClick={handleRemoveBackground} - disabled={isRemovingBg || isEnhancing || !image} + disabled={isRemovingBg || isEnhancing || isExtending || !image} title="移除背景(抠图)" > @@ -505,12 +572,22 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator variant="ghost" size="sm" onClick={handleEnhance} - disabled={isEnhancing || isRemovingBg || !image} + disabled={isEnhancing || isRemovingBg || isExtending || !image} title="图片变清晰" > {isEnhancing ? "处理中..." : "变清晰"} + + + {isExtending ? "处理中..." : "图像拓展"} + 撤销 @@ -641,9 +718,9 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator {processedImageBase64 && ( - - {processType === "rmbg" ? "抠图结果" : "清晰结果"} - + + {processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"} + - + {processedImageUrl ? ( + + ) : processedImageBase64 ? ( + + ) : null} )} diff --git a/components/image-preview.tsx b/components/image-preview.tsx index d75251406..b1d7cee31 100644 --- a/components/image-preview.tsx +++ b/components/image-preview.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; -import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors } from "lucide-react"; +import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand } from "lucide-react"; import { toast } from "sonner"; import { ImageAnnotator } from "./image-annotator"; @@ -13,6 +13,7 @@ export function ImagePreview({ onDrawingComplete, onImageEnhance, onImageRmbg, + onImageExtend, imageIndex }: { src: string; @@ -26,6 +27,7 @@ export function ImagePreview({ onDrawingComplete?: (annotatedImageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void; + onImageExtend?: (imageUrl: string) => void; imageIndex?: number; }) { const [isOpen, setIsOpen] = useState(false); @@ -219,8 +221,8 @@ export function ImagePreview({ onClick={() => setIsOpen(true)} /> - {/* 左上角:变清晰和抠图按钮 - 始终显示 */} - {(onImageEnhance || onImageRmbg) && ( + {/* 左上角:变清晰、抠图和图像拓展按钮 - 始终显示 */} + {(onImageEnhance || onImageRmbg || onImageExtend) && ( {onImageEnhance && ( 抠图 )} + {onImageExtend && ( + { + e.stopPropagation(); + onImageExtend(original); + }} + type="button" + aria-label="图像拓展" + title="图像拓展" + > + + 图像拓展 + + )} )} diff --git a/components/image-processing-loading.tsx b/components/image-processing-loading.tsx index 678ecbc91..99ff7c1f2 100644 --- a/components/image-processing-loading.tsx +++ b/components/image-processing-loading.tsx @@ -2,10 +2,10 @@ import { memo } from "react"; import { SparklesIcon } from "./icons"; -import { Scissors } from "lucide-react"; +import { Scissors, Expand } from "lucide-react"; interface ImageProcessingLoadingProps { - type: "enhance" | "rmbg"; // 处理类型:变清晰或抠图 + type: "enhance" | "rmbg" | "extend"; // 处理类型:变清晰、抠图或图像拓展 } /** @@ -14,34 +14,56 @@ interface ImageProcessingLoadingProps { */ export const ImageProcessingLoading = memo(({ type }: ImageProcessingLoadingProps) => { const isEnhance = type === "enhance"; - const color = isEnhance ? "blue" : "green"; - const text = isEnhance ? "正在变清晰" : "正在抠图"; - const icon = isEnhance ? SparklesIcon : Scissors; + const isRmbg = type === "rmbg"; + const isExtend = type === "extend"; + + let text: string; + let icon: any; + let colorClasses: any; + + if (isEnhance) { + text = "正在变清晰"; + icon = SparklesIcon; + colorClasses = { + ping: "bg-blue-500/20", + pulse: "bg-blue-500/10", + ring: "ring-blue-500/30", + text: "text-blue-500", + dot: "bg-blue-500", + gradientFrom: "from-blue-400", + gradientFromOverlay: "from-blue-500/40", + gradientToOverlay: "to-blue-500/30", + }; + } else if (isRmbg) { + text = "正在抠图"; + icon = Scissors; + colorClasses = { + ping: "bg-green-500/20", + pulse: "bg-green-500/10", + ring: "ring-green-500/30", + text: "text-green-500", + dot: "bg-green-500", + gradientFrom: "from-green-400", + gradientFromOverlay: "from-green-500/40", + gradientToOverlay: "to-green-500/30", + }; + } else { + text = "正在图像拓展"; + icon = Expand; + colorClasses = { + ping: "bg-orange-500/20", + pulse: "bg-orange-500/10", + ring: "ring-orange-500/30", + text: "text-orange-500", + dot: "bg-orange-500", + gradientFrom: "from-orange-400", + gradientFromOverlay: "from-orange-500/40", + gradientToOverlay: "to-orange-500/30", + }; + } + const Icon = icon; - // 根据类型设置颜色类名 - const colorClasses = isEnhance - ? { - ping: "bg-blue-500/20", - pulse: "bg-blue-500/10", - ring: "ring-blue-500/30", - text: "text-blue-500", - dot: "bg-blue-500", - gradientFrom: "from-blue-400", - gradientFromOverlay: "from-blue-500/40", - gradientToOverlay: "to-blue-500/30", - } - : { - ping: "bg-green-500/20", - pulse: "bg-green-500/10", - ring: "ring-green-500/30", - text: "text-green-500", - dot: "bg-green-500", - gradientFrom: "from-green-400", - gradientFromOverlay: "from-green-500/40", - gradientToOverlay: "to-green-500/30", - }; - return ( void; onImageEnhance?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void; + onImageExtend?: (imageUrl: string) => void; }) => { const [mode, setMode] = useState<"view" | "edit">("view"); @@ -241,12 +243,19 @@ const PurePreviewMessage = ({ if (type === "text") { if (mode === "view") { // 检查是否是图片处理的加载消息 - if (part.text === "正在变清晰..." || part.text === "正在抠图...") { - const isEnhance = part.text === "正在变清晰..."; + if (part.text === "正在变清晰..." || part.text === "正在抠图..." || part.text === "正在图像拓展...") { + let type: "enhance" | "rmbg" | "extend"; + if (part.text === "正在变清晰...") { + type = "enhance"; + } else if (part.text === "正在抠图...") { + type = "rmbg"; + } else { + type = "extend"; + } return ( ); } @@ -289,6 +298,7 @@ const PurePreviewMessage = ({ onDrawingComplete={onDrawingComplete} onImageEnhance={onImageEnhance} onImageRmbg={onImageRmbg} + onImageExtend={onImageExtend} > {sanitizeText(part.content)} diff --git a/components/messages.tsx b/components/messages.tsx index 04a139ad3..981cddc39 100644 --- a/components/messages.tsx +++ b/components/messages.tsx @@ -39,6 +39,7 @@ type MessagesProps = { onDrawingComplete?: (annotatedImageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void; + onImageExtend?: (imageUrl: string) => void; pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态 imageCount?: number; // 🆕 用户选择的图片数量 }; @@ -57,6 +58,7 @@ function PureMessages({ onDrawingComplete, onImageEnhance, onImageRmbg, + onImageExtend, pendingGeneration, imageCount = 1, }: MessagesProps) { @@ -226,6 +228,7 @@ function PureMessages({ onDrawingComplete={onDrawingComplete} onImageEnhance={onImageEnhance} onImageRmbg={onImageRmbg} + onImageExtend={onImageExtend} /> ))} diff --git a/图像拓展功能-API调用文档.md b/图像拓展功能-API调用文档.md new file mode 100644 index 000000000..4d54c5b37 --- /dev/null +++ b/图像拓展功能-API调用文档.md @@ -0,0 +1,554 @@ +# 🎨 图像拓展功能 - API调用文档 + +## 📋 功能概述 + +图像拓展(Image Out-painting)功能使用阿里云 Dashscope API,可以将图片智能扩展,自动填充边缘区域,实现图片尺寸的放大。 + +--- + +## 🔧 API配置 + +### 环境变量 + +```bash +# 阿里云 Dashscope API Key(用于图像拓展) +GENERATE_BG_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +**说明:** 此配置与背景生成功能共用同一个 API Key。 + +--- + +## 🌐 API端点 + +### 1. 提交拓展任务 + +**端点:** `https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting` + +**方法:** `POST` + +**请求头:** +```http +Content-Type: application/json +Authorization: Bearer {GENERATE_BG_API_KEY} +X-DashScope-Async: enable +``` + +**说明:** `X-DashScope-Async: enable` 是必需的,用于启用异步模式。 + +--- + +### 2. 查询任务状态 + +**端点:** `https://dashscope.aliyuncs.com/api/v1/tasks/{taskId}` + +**方法:** `GET` + +**请求头:** +```http +Authorization: Bearer {GENERATE_BG_API_KEY} +``` + +--- + +## 📤 请求参数 + +### 请求体结构 + +```json +{ + "model": "image-out-painting", + "input": { + "image_url": "https://example.com/image.jpg" // 方式1:公网URL + // 或 + "image_base64": "iVBORw0KGgoAAAANSUhEUgAA..." // 方式2:Base64编码(推荐) + }, + "parameters": { + "x_scale": 1.5, // X轴放大倍数(1.0-2.0) + "y_scale": 1.5, // Y轴放大倍数(1.0-2.0) + "best_quality": false, // 是否使用最佳质量(true/false) + "limit_image_size": true, // 是否限制图片大小 + "add_watermark": false // 是否添加水印(建议false) + } +} +``` + +### 参数说明 + +| 参数 | 类型 | 必填 | 说明 | 默认值 | +|------|------|------|------|--------| +| `model` | string | 是 | 固定值 `"image-out-painting"` | - | +| `input.image_url` | string | 否* | 图片的公网URL(需可访问) | - | +| `input.image_base64` | string | 否* | 图片的Base64编码 | - | +| `parameters.x_scale` | number | 否 | X轴放大倍数(1.0-2.0) | 1.5 | +| `parameters.y_scale` | number | 否 | Y轴放大倍数(1.0-2.0) | 1.5 | +| `parameters.best_quality` | boolean | 否 | 是否使用最佳质量 | false | +| `parameters.limit_image_size` | boolean | 否 | 是否限制图片大小 | true | +| `parameters.add_watermark` | boolean | 否 | 是否添加水印 | false | + +**注意:** `input.image_url` 和 `input.image_base64` 二选一,推荐使用 `image_base64`。 + +--- + +## 📥 响应格式 + +### 提交任务响应 + +**成功响应:** +```json +{ + "output": { + "task_id": "task-abc123def456", + "task_status": "PENDING" + } +} +``` + +**失败响应:** +```json +{ + "code": "InvalidParameter", + "message": "image_base64 is required" +} +``` + +### 查询任务状态响应 + +**处理中:** +```json +{ + "output": { + "task_id": "task-abc123def456", + "task_status": "RUNNING" + } +} +``` + +**成功:** +```json +{ + "output": { + "task_id": "task-abc123def456", + "task_status": "SUCCEEDED", + "output_image_url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/..." + } +} +``` + +**失败:** +```json +{ + "output": { + "task_id": "task-abc123def456", + "task_status": "FAILED", + "message": "任务处理失败", + "code": "InternalError" + } +} +``` + +--- + +## 🔄 任务状态 + +| 状态 | 说明 | 处理方式 | +|------|------|---------| +| `PENDING` | 等待处理 | 继续轮询 | +| `RUNNING` | 处理中 | 继续轮询 | +| `SUCCEEDED` | 成功 | 获取结果URL | +| `FAILED` | 失败 | 抛出错误 | + +--- + +## 💻 代码实现示例 + +### TypeScript/Node.js 实现 + +```typescript +import { NextRequest, NextResponse } from "next/server"; +import sharp from "sharp"; + +/** + * 图像拓展核心实现 + * @param imageBase64 - Base64编码的图片数据 + * @param apiKey - Dashscope API Key + * @returns 拓展后的图片URL + */ +async function extendImage(imageBase64: string, apiKey: string): Promise { + // 1. 提交拓展任务 + const requestBody = { + model: "image-out-painting", + input: { + image_base64: imageBase64, + }, + parameters: { + x_scale: 1.5, + y_scale: 1.5, + best_quality: false, + limit_image_size: true, + add_watermark: false, + }, + }; + + const response = await fetch( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-DashScope-Async": "enable", + }, + body: JSON.stringify(requestBody), + } + ); + + const initialResult = await response.json(); + + if (initialResult.code) { + throw new Error( + `图像拓展API错误: ${initialResult.message || JSON.stringify(initialResult)}` + ); + } + + // 2. 获取任务ID + const taskId = initialResult.output.task_id; + const taskStatusUrl = `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`; + + // 3. 等待3秒后开始轮询 + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // 4. 轮询获取结果 + let attempts = 0; + const maxAttempts = 60; // 最多轮询60次 + const pollInterval = 2000; // 每2秒轮询一次 + + while (attempts < maxAttempts) { + const statusResponse = await fetch(taskStatusUrl, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + const result = await statusResponse.json(); + + if (result.output?.task_status === "SUCCEEDED") { + return result.output.output_image_url; + } else if (result.output?.task_status === "FAILED") { + throw new Error( + `图像拓展任务失败: ${result.output.message || result.output.code || "未知错误"}` + ); + } + + attempts++; + if (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + } + + throw new Error(`图像拓展任务超时(等待${maxAttempts * 2 + 3}秒)`); +} + +/** + * 完整的图像拓展流程(包含图片上传到S3) + */ +export async function POST(request: NextRequest) { + try { + // 1. 获取用户会话 + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "未授权" }, { status: 401 }); + } + + // 2. 获取API配置 + const apiKey = process.env.GENERATE_BG_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "图像拓展API配置未设置" }, + { status: 400 } + ); + } + + // 3. 获取请求体(Base64图片) + const contentType = request.headers.get("content-type") || ""; + let imageBase64: string; + + if (contentType.includes("application/json")) { + const body = await request.json(); + imageBase64 = body.image_base64; + } else if (contentType.includes("multipart/form-data")) { + const formData = await request.formData(); + const file = formData.get("file") as File; + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + imageBase64 = buffer.toString("base64"); + } else { + return NextResponse.json( + { error: "不支持的Content-Type" }, + { status: 400 } + ); + } + + if (!imageBase64) { + return NextResponse.json( + { error: "缺少图片数据" }, + { status: 400 } + ); + } + + // 4. 调用图像拓展API + const extendedImageUrl = await extendImage(imageBase64, apiKey); + + // 5. 下载拓展后的图片 + const imageResponse = await fetch(extendedImageUrl); + if (!imageResponse.ok) { + throw new Error(`下载拓展图片失败: ${imageResponse.status}`); + } + + const arrayBuffer = await imageResponse.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 6. 上传到S3(可选) + // const { uploadFile } = await import("@/lib/storage/service"); + // const thumbnailBuffer = await createThumbnail(buffer, "image/jpeg"); + // const [thumbnailResult, originalResult] = await Promise.all([ + // uploadFile(`extend-thumb-${Date.now()}.jpg`, thumbnailBuffer), + // uploadFile(`extend-orig-${Date.now()}.jpg`, buffer), + // ]); + + // 7. 保存到数据库(可选) + // await db.insert(generatedImage).values({ + // userId: session.user.id, + // thumbnailUrl: thumbnailResult.url, + // originalUrl: originalResult.url, + // prompt: "图像拓展处理", + // modelId: "extend-1.0", + // }); + + // 8. 返回结果 + return NextResponse.json({ + success: true, + original_url: extendedImageUrl, // 或 originalResult.url + thumbnail_url: extendedImageUrl, // 或 thumbnailResult.url + // base64: buffer.toString("base64"), // 可选:返回Base64 + }); + } catch (error) { + console.error("[Extend API] 错误:", error); + return NextResponse.json( + { + error: "图像拓展失败", + detail: error instanceof Error ? error.message : String(error), + }, + { status: 500 } + ); + } +} +``` + +--- + +## 🔄 使用公网URL方式(备选方案) + +如果不想使用Base64,可以先将图片上传到S3/MinIO,然后使用公网URL: + +```typescript +// 1. 上传图片到S3/MinIO获取公网URL +const imageUrl = await uploadToS3(imageBuffer); + +// 2. 使用URL调用API +const requestBody = { + model: "image-out-painting", + input: { + image_url: imageUrl, // 使用URL而不是Base64 + }, + parameters: { + x_scale: 1.5, + y_scale: 1.5, + best_quality: false, + limit_image_size: true, + add_watermark: false, + }, +}; +``` + +**注意:** 公网URL必须可访问,且不能是localhost或内网地址。 + +--- + +## ⚙️ 配置参数说明 + +### 放大倍数 + +- **x_scale / y_scale**: 1.0 - 2.0 + - `1.0`: 不放大 + - `1.5`: 放大1.5倍(推荐) + - `2.0`: 放大2倍(最大) + +### 质量设置 + +- **best_quality**: + - `true`: 最佳质量,处理时间更长 + - `false`: 标准质量,处理时间较短(推荐) + +### 水印设置 + +- **add_watermark**: + - `true`: 添加水印(不推荐) + - `false`: 不添加水印(推荐) + +--- + +## ⏱️ 性能指标 + +| 指标 | 值 | 说明 | +|------|-----|------| +| 单张处理时间 | 5-15秒 | 取决于图片大小和质量设置 | +| 最大图片大小 | 4MB | 原始图片大小限制 | +| 支持格式 | JPG, PNG, WebP | 常见图片格式 | +| 并发限制 | 5个/秒 | 根据账户类型 | +| 轮询间隔 | 2秒 | 建议值 | +| 最大轮询次数 | 60次 | 总计约120秒超时 | + +--- + +## 🛡️ 错误处理 + +### 常见错误 + +| 错误码 | 说明 | 解决方案 | +|--------|------|---------| +| `InvalidParameter` | 参数错误 | 检查图片格式和参数值 | +| `Arrearage` | 配额不足 | 充值或升级账户 | +| `Throttling.RateQuota` | 请求过快 | 降低请求频率 | +| `InternalError` | 内部错误 | 稍后重试 | +| `submit algo service error` | API服务问题 | 稍后重试 | + +### 重试机制 + +建议实现3次重试,每次重试间隔递增: + +```typescript +async function extendImageWithRetry( + imageBase64: string, + apiKey: string, + maxRetries = 3 +): Promise { + let lastError: Error; + + for (let retry = 0; retry < maxRetries; retry++) { + try { + return await extendImage(imageBase64, apiKey); + } catch (error) { + lastError = error as Error; + console.error(`第${retry + 1}次拓展尝试失败:`, error); + + if (retry < maxRetries - 1) { + const waitTime = (retry + 1) * 5; // 5秒、10秒 + await new Promise((resolve) => setTimeout(resolve, waitTime * 1000)); + } + } + } + + throw new Error(`图像拓展失败(已重试${maxRetries}次): ${lastError.message}`); +} +``` + +--- + +## 📊 完整流程示例 + +``` +用户上传图片 + ↓ +转换为Base64(或上传到S3获取URL) + ↓ +提交拓展任务到Dashscope API + ↓ +获取task_id + ↓ +等待3秒(API初始化) + ↓ +轮询任务状态(每2秒一次,最多60次) + ↓ +任务完成,获取output_image_url + ↓ +下载拓展后的图片 + ↓ +上传到S3(可选) + ↓ +保存到数据库(可选) + ↓ +返回结果给前端 +``` + +--- + +## 🔍 测试方法 + +### 1. 使用curl测试 + +```bash +# 准备Base64图片 +IMAGE_BASE64=$(base64 -i test.jpg) + +# 提交任务 +curl -X POST \ + https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $GENERATE_BG_API_KEY" \ + -H "X-DashScope-Async: enable" \ + -d "{ + \"model\": \"image-out-painting\", + \"input\": { + \"image_base64\": \"$IMAGE_BASE64\" + }, + \"parameters\": { + \"x_scale\": 1.5, + \"y_scale\": 1.5, + \"best_quality\": false, + \"limit_image_size\": true, + \"add_watermark\": false + } + }" + +# 查询任务状态(替换task_id) +curl -X GET \ + https://dashscope.aliyuncs.com/api/v1/tasks/{task_id} \ + -H "Authorization: Bearer $GENERATE_BG_API_KEY" +``` + +### 2. 使用Node.js测试脚本 + +参考 `第三方源码参考/test-image-extend-final.js` + +--- + +## 📝 注意事项 + +1. **API Key安全**: 不要在客户端代码中暴露API Key,应在服务端调用 +2. **图片大小**: 建议先压缩图片到4MB以下再调用API +3. **异步处理**: 必须使用异步模式(`X-DashScope-Async: enable`) +4. **轮询超时**: 建议设置合理的超时时间(120秒) +5. **错误处理**: 实现重试机制以提高成功率 +6. **配额限制**: 注意API调用配额,避免超出限制 + +--- + +## 🔗 相关资源 + +- [阿里云Dashscope官方文档](https://help.aliyun.com/zh/model-studio/) +- [图像拓展API文档](https://help.aliyun.com/zh/model-studio/developer-reference/api-details-9) + +--- + +## ✅ 总结 + +图像拓展功能使用阿里云Dashscope API,支持Base64和URL两种输入方式,采用异步模式处理,需要轮询获取结果。建议使用Base64方式,简化流程,提高可靠性。 + +**关键点:** +- ✅ 使用 `GENERATE_BG_API_KEY` 环境变量 +- ✅ 必须设置 `X-DashScope-Async: enable` +- ✅ 推荐使用Base64输入方式 +- ✅ 实现轮询和重试机制 +- ✅ 处理时间约5-15秒/张 +
• 使用阿里云Dashscope图像拓展API(image-out-painting模型)
• API端点: https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
• 支持的图片格式:JPG, PNG, WebP等常见格式
• 最大图片大小:4MB
• 处理时间:约5-15秒/张
• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)