feat: 添加图像拓展功能
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

- 新增 /api/extend 路由,集成阿里云Dashscope图像拓展API
- 在系统设置'本地AI设置'中添加图像拓展API配置(API密钥、X/Y轴放大倍数)
- 在图片标注组件中添加'图像拓展'按钮(橙色,Expand图标)
- 在聊天对话图片上添加'图像拓展'按钮,支持动态占位卡片
- 更新ImageProcessingLoading组件支持extend类型(橙色主题)
- 处理后的图片上传到S3,保存到数据库(modelId: extend-1.0)
- 同步到我的相册和出图记录
- 添加完整的API调用文档
This commit is contained in:
Your Name 2025-12-29 10:54:32 +08:00
parent d8f4a43a0a
commit 7c253e04a0
10 changed files with 1357 additions and 59 deletions

View File

@ -48,6 +48,7 @@ import {
Sparkles, Sparkles,
RefreshCw, RefreshCw,
Scissors, Scissors,
Expand,
} from "lucide-react"; } from "lucide-react";
import { import {
Dialog, Dialog,
@ -355,6 +356,13 @@ export function AdminSettingsClient() {
scaleFactor: "auto", // "auto" | "1" | "2" | "4" scaleFactor: "auto", // "auto" | "1" | "2" | "4"
}); });
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
apiKey: "",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
});
// 加载抠图API配置 // 加载抠图API配置
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher); const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
useEffect(() => { useEffect(() => {
@ -386,6 +394,23 @@ export function AdminSettingsClient() {
} }
}, [enhanceConfigData]); }, [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({ const [navLinkForm, setNavLinkForm] = useState({
id: "", 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 = () => { const resetNavLinkForm = () => {
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true }); setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
@ -3100,6 +3166,79 @@ export function AdminSettingsClient() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* 图像拓展API配置 */}
<Card>
<CardHeader>
<CardTitle>图像拓展API配置</CardTitle>
<CardDescription>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
id="extendApiKey"
type="password"
value={extendConfig.apiKey}
onChange={(e) => setExtendConfig({ ...extendConfig, apiKey: e.target.value })}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
<div className="text-xs text-muted-foreground">
请输入阿里云Dashscope API密钥,例如: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
<Input
id="extendXScale"
type="number"
step="0.1"
min="1.0"
max="2.0"
value={extendConfig.xScale}
onChange={(e) => setExtendConfig({ ...extendConfig, xScale: e.target.value })}
placeholder="1.5"
/>
<div className="text-xs text-muted-foreground">
X轴放大倍数(1.0-2.0),推荐1.5
</div>
</div>
<div className="space-y-2">
<Label htmlFor="extendYScale">Y轴放大倍数 *</Label>
<Input
id="extendYScale"
type="number"
step="0.1"
min="1.0"
max="2.0"
value={extendConfig.yScale}
onChange={(e) => setExtendConfig({ ...extendConfig, yScale: e.target.value })}
placeholder="1.5"
/>
<div className="text-xs text-muted-foreground">
Y轴放大倍数(1.0-2.0),推荐1.5
</div>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-2">
<div className="text-sm font-medium text-blue-900">API文档说明</div>
<div className="text-xs text-blue-800 space-y-1">
<p>• 使用阿里云Dashscope图像拓展API(image-out-painting模型)</p>
<p>• API端点: https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting</p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p>
<p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div>
</div>
<Button onClick={handleSaveExtendConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

352
app/api/extend/route.ts Normal file
View File

@ -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<Buffer> {
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<string> {
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 }
);
}
}

View File

@ -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 = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息(前端显示用)
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({ useAutoResume({
autoResume, autoResume,
initialMessages, initialMessages,
@ -694,6 +805,7 @@ export function Chat({
onDrawingComplete={handleDrawingComplete} onDrawingComplete={handleDrawingComplete}
onImageEnhance={handleImageEnhance} onImageEnhance={handleImageEnhance}
onImageRmbg={handleImageRmbg} onImageRmbg={handleImageRmbg}
onImageExtend={handleImageExtend}
pendingGeneration={pendingGeneration} pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1} imageCount={imageParams?.imageCount || 1}
/> />

View File

@ -11,10 +11,11 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
onDrawingComplete?: (annotatedImageUrl: string) => void; onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
}; };
export const Response = memo( export const Response = memo(
({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, ...props }: ResponseProps) => ( ({ className, onImageEdit, onDrawingComplete, onImageEnhance, onImageRmbg, onImageExtend, ...props }: ResponseProps) => (
<Streamdown <Streamdown
className={cn( className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto", "size-full [&>*: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} onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance} onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg} onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
/>; />;
}, },
video: ({ src, alt }: { src?: string; alt?: string }) => { video: ({ src, alt }: { src?: string; alt?: string }) => {
@ -49,7 +51,8 @@ export const Response = memo(
prevProps.onImageEdit === nextProps.onImageEdit && prevProps.onImageEdit === nextProps.onImageEdit &&
prevProps.onDrawingComplete === nextProps.onDrawingComplete && prevProps.onDrawingComplete === nextProps.onDrawingComplete &&
prevProps.onImageEnhance === nextProps.onImageEnhance && prevProps.onImageEnhance === nextProps.onImageEnhance &&
prevProps.onImageRmbg === nextProps.onImageRmbg prevProps.onImageRmbg === nextProps.onImageRmbg &&
prevProps.onImageExtend === nextProps.onImageExtend
); );
Response.displayName = "Response"; Response.displayName = "Response";

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useRef, useState, useEffect, useCallback } from "react"; 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 { Button } from "./ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
@ -59,10 +59,11 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
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 [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);
const [processType, setProcessType] = useState<"rmbg" | "enhance" | null>(null); const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | null>(null);
// 加载图片 // 加载图片
useEffect(() => { useEffect(() => {
@ -422,32 +423,98 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
} catch (error) { } catch (error) {
console.error("[变清晰] 错误:", error); console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); 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 { } finally {
setIsEnhancing(false); setIsExtending(false);
} }
}; };
// 下载处理后的图片 // 下载处理后的图片(必须使用原图URL)
const handleDownloadProcessed = () => { const handleDownloadProcessed = async () => {
if (!processedImageBase64) return; if (!processedImageUrl) {
toast.error("原图地址不可用,无法下载");
return;
}
try { try {
// 确定文件类型和MIME类型 // 确定文件类型和MIME类型
const isPng = processType === "rmbg"; const isPng = processType === "rmbg";
const mimeType = isPng ? "image/png" : "image/jpeg";
const fileExtension = isPng ? "png" : "jpg"; 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}`;
// 创建下载链接 // 从原图URL下载(原图已上传到S3)
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`; 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"); const link = document.createElement("a");
link.href = dataUrl; link.href = url;
link.download = fileName; link.download = fileName;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("图片下载成功"); toast.success("原图下载成功");
} catch (error) { } catch (error) {
console.error("[下载] 错误:", error); console.error("[下载] 错误:", error);
toast.error("下载失败,请稍后重试"); toast.error("下载失败,请稍后重试");
@ -495,7 +562,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleRemoveBackground} onClick={handleRemoveBackground}
disabled={isRemovingBg || isEnhancing || !image} disabled={isRemovingBg || isEnhancing || isExtending || !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" : ""}`} />
@ -505,12 +572,22 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleEnhance} onClick={handleEnhance}
disabled={isEnhancing || isRemovingBg || !image} disabled={isEnhancing || isRemovingBg || isExtending || !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" : ""}`} />
{isEnhancing ? "处理中..." : "变清晰"} {isEnhancing ? "处理中..." : "变清晰"}
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={handleExtend}
disabled={isExtending || isRemovingBg || isEnhancing || !image}
title="图像拓展"
>
<Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} />
{isExtending ? "处理中..." : "图像拓展"}
</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" />
撤销 撤销
@ -641,9 +718,9 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
{processedImageBase64 && ( {processedImageBase64 && (
<div className="flex flex-1 flex-col items-center justify-center"> <div className="flex flex-1 flex-col items-center justify-center">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{processType === "rmbg" ? "抠图结果" : "清晰结果"} {processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -655,12 +732,21 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
</Button> </Button>
</div> </div>
<div className="relative rounded-lg border-2 border-primary/50 bg-background p-2 shadow-lg"> <div className="relative rounded-lg border-2 border-primary/50 bg-background p-2 shadow-lg">
<img {processedImageUrl ? (
src={processedImageUrl || `data:image/${processType === "rmbg" ? "png" : "jpeg"};base64,${processedImageBase64}`} <img
alt={processType === "rmbg" ? "抠图结果" : "清晰结果"} src={processedImageUrl}
className="max-h-[350px] max-w-full rounded-lg" alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
style={{ maxWidth: canvasRef.current?.width || "400px" }} className="max-h-[350px] max-w-full rounded-lg"
/> style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : processedImageBase64 ? (
<img
src={`data:image/${processType === "rmbg" ? "png" : "jpeg"};base64,${processedImageBase64}`}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : null}
</div> </div>
</div> </div>
)} )}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState } from "react"; 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 { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator"; import { ImageAnnotator } from "./image-annotator";
@ -13,6 +13,7 @@ export function ImagePreview({
onDrawingComplete, onDrawingComplete,
onImageEnhance, onImageEnhance,
onImageRmbg, onImageRmbg,
onImageExtend,
imageIndex imageIndex
}: { }: {
src: string; src: string;
@ -26,6 +27,7 @@ export function ImagePreview({
onDrawingComplete?: (annotatedImageUrl: string) => void; onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
imageIndex?: number; imageIndex?: number;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -219,8 +221,8 @@ export function ImagePreview({
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
/> />
{/* 左上角:变清晰和抠图按钮 - 始终显示 */} {/* 左上角:变清晰、抠图和图像拓展按钮 - 始终显示 */}
{(onImageEnhance || onImageRmbg) && ( {(onImageEnhance || onImageRmbg || onImageExtend) && (
<div className="absolute top-2 left-2 flex gap-1.5 z-10"> <div className="absolute top-2 left-2 flex gap-1.5 z-10">
{onImageEnhance && ( {onImageEnhance && (
<button <button
@ -252,6 +254,21 @@ export function ImagePreview({
<span className="text-xs font-medium">抠图</span> <span className="text-xs font-medium">抠图</span>
</button> </button>
)} )}
{onImageExtend && (
<button
className="flex items-center gap-1.5 rounded-full bg-orange-600/90 hover:bg-orange-700 px-2.5 py-1.5 text-white transition-colors shadow-lg"
onClick={(e) => {
e.stopPropagation();
onImageExtend(original);
}}
type="button"
aria-label="图像拓展"
title="图像拓展"
>
<Expand size={14} />
<span className="text-xs font-medium">图像拓展</span>
</button>
)}
</div> </div>
)} )}

View File

@ -2,10 +2,10 @@
import { memo } from "react"; import { memo } from "react";
import { SparklesIcon } from "./icons"; import { SparklesIcon } from "./icons";
import { Scissors } from "lucide-react"; import { Scissors, Expand } from "lucide-react";
interface ImageProcessingLoadingProps { interface ImageProcessingLoadingProps {
type: "enhance" | "rmbg"; // 处理类型:变清晰或抠图 type: "enhance" | "rmbg" | "extend"; // 处理类型:变清晰、抠图或图像拓展
} }
/** /**
@ -14,34 +14,56 @@ interface ImageProcessingLoadingProps {
*/ */
export const ImageProcessingLoading = memo(({ type }: ImageProcessingLoadingProps) => { export const ImageProcessingLoading = memo(({ type }: ImageProcessingLoadingProps) => {
const isEnhance = type === "enhance"; const isEnhance = type === "enhance";
const color = isEnhance ? "blue" : "green"; const isRmbg = type === "rmbg";
const text = isEnhance ? "正在变清晰" : "正在抠图"; const isExtend = type === "extend";
const icon = isEnhance ? SparklesIcon : Scissors;
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 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 ( return (
<div <div
className="group/message fade-in w-full animate-in duration-300" className="group/message fade-in w-full animate-in duration-300"

View File

@ -150,6 +150,7 @@ const PurePreviewMessage = ({
onDrawingComplete, onDrawingComplete,
onImageEnhance, onImageEnhance,
onImageRmbg, onImageRmbg,
onImageExtend,
}: { }: {
chatId: string; chatId: string;
message: ChatMessage; message: ChatMessage;
@ -164,6 +165,7 @@ const PurePreviewMessage = ({
onDrawingComplete?: (annotatedImageUrl: string) => void; onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
}) => { }) => {
const [mode, setMode] = useState<"view" | "edit">("view"); const [mode, setMode] = useState<"view" | "edit">("view");
@ -241,12 +243,19 @@ const PurePreviewMessage = ({
if (type === "text") { if (type === "text") {
if (mode === "view") { if (mode === "view") {
// 检查是否是图片处理的加载消息 // 检查是否是图片处理的加载消息
if (part.text === "正在变清晰..." || part.text === "正在抠图...") { if (part.text === "正在变清晰..." || part.text === "正在抠图..." || part.text === "正在图像拓展...") {
const isEnhance = part.text === "正在变清晰..."; let type: "enhance" | "rmbg" | "extend";
if (part.text === "正在变清晰...") {
type = "enhance";
} else if (part.text === "正在抠图...") {
type = "rmbg";
} else {
type = "extend";
}
return ( return (
<ImageProcessingLoading <ImageProcessingLoading
key={key} key={key}
type={isEnhance ? "enhance" : "rmbg"} type={type}
/> />
); );
} }
@ -289,6 +298,7 @@ const PurePreviewMessage = ({
onDrawingComplete={onDrawingComplete} onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance} onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg} onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
> >
{sanitizeText(part.content)} {sanitizeText(part.content)}
</Response> </Response>

View File

@ -39,6 +39,7 @@ type MessagesProps = {
onDrawingComplete?: (annotatedImageUrl: string) => void; onDrawingComplete?: (annotatedImageUrl: string) => void;
onImageEnhance?: (imageUrl: string) => void; onImageEnhance?: (imageUrl: string) => void;
onImageRmbg?: (imageUrl: string) => void; onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态 pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量 imageCount?: number; // 🆕 用户选择的图片数量
}; };
@ -57,6 +58,7 @@ function PureMessages({
onDrawingComplete, onDrawingComplete,
onImageEnhance, onImageEnhance,
onImageRmbg, onImageRmbg,
onImageExtend,
pendingGeneration, pendingGeneration,
imageCount = 1, imageCount = 1,
}: MessagesProps) { }: MessagesProps) {
@ -226,6 +228,7 @@ function PureMessages({
onDrawingComplete={onDrawingComplete} onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance} onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg} onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
/> />
))} ))}

View File

@ -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<string> {
// 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<string> {
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秒/张