feat: 图片编辑器升级 - 入口优化 + 场景裂变功能
功能一:图片编辑器入口优化 - 支持先打开编辑器再上传图片 - 新增拖拽上传功能 - 新增粘贴上传功能 (Ctrl+V) 功能二:场景裂变 - 新增场景裂变弹窗组件 - 支持产品图批量融合到多个场景 - 每个场景独立配置补充关键词 - 并发处理提高效率 - 支持单个重新生成和全部下载 - 生成结果自动保存到相册 管理员配置: - 新增场景裂变配置面板 - 支持自定义默认提示词 - 支持配置最大场景数量 - 支持功能开关 新增文件: - app/api/scene-fusion/route.ts - app/api/scene-fusion/config/route.ts - components/scene-fusion-dialog.tsx - docs/图片编辑器功能升级规划.md
This commit is contained in:
parent
0d7584edf1
commit
688f67c25c
@ -396,6 +396,13 @@ export function AdminSettingsClient() {
|
|||||||
timeout: 120,
|
timeout: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 场景裂变配置状态
|
||||||
|
const [sceneFusionConfig, setSceneFusionConfig] = useState({
|
||||||
|
enabled: true,
|
||||||
|
defaultPrompt: "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
|
||||||
|
maxScenes: 10,
|
||||||
|
});
|
||||||
|
|
||||||
// 加载抠图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(() => {
|
||||||
@ -479,6 +486,23 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
}, [angleEditConfigData]);
|
}, [angleEditConfigData]);
|
||||||
|
|
||||||
|
// 加载场景裂变配置
|
||||||
|
const { data: sceneFusionConfigData } = useSWR("/api/admin/settings?key=scene_fusion_config", fetcher);
|
||||||
|
useEffect(() => {
|
||||||
|
if (sceneFusionConfigData?.setting?.value) {
|
||||||
|
const config = sceneFusionConfigData.setting.value as {
|
||||||
|
enabled?: boolean;
|
||||||
|
defaultPrompt?: string;
|
||||||
|
maxScenes?: number;
|
||||||
|
};
|
||||||
|
setSceneFusionConfig({
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
defaultPrompt: config.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。",
|
||||||
|
maxScenes: config.maxScenes ?? 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [sceneFusionConfigData]);
|
||||||
|
|
||||||
// 导航链接状态
|
// 导航链接状态
|
||||||
const [navLinkForm, setNavLinkForm] = useState({
|
const [navLinkForm, setNavLinkForm] = useState({
|
||||||
id: "",
|
id: "",
|
||||||
@ -922,6 +946,34 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 场景裂变配置逻辑 ---
|
||||||
|
const handleSaveSceneFusionConfig = async () => {
|
||||||
|
try {
|
||||||
|
if (!sceneFusionConfig.defaultPrompt.trim()) {
|
||||||
|
toast.error("默认提示词不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: "scene_fusion_config",
|
||||||
|
value: {
|
||||||
|
enabled: sceneFusionConfig.enabled,
|
||||||
|
defaultPrompt: sceneFusionConfig.defaultPrompt.trim(),
|
||||||
|
maxScenes: sceneFusionConfig.maxScenes,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("保存失败");
|
||||||
|
toast.success("场景裂变配置保存成功");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("保存场景裂变配置失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- 导航链接逻辑 ---
|
// --- 导航链接逻辑 ---
|
||||||
const resetNavLinkForm = () => {
|
const resetNavLinkForm = () => {
|
||||||
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
|
setNavLinkForm({ id: "", name: "", url: "", order: 0, isEnabled: true });
|
||||||
@ -3764,6 +3816,81 @@ export function AdminSettingsClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 场景裂变配置 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
场景裂变配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
配置场景裂变功能的默认提示词和参数。场景裂变可将产品图批量融合到多个场景中。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
{/* 功能开关 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-base">启用场景裂变功能</Label>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
开启后用户可在图片编辑器中使用场景裂变功能
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={sceneFusionConfig.enabled}
|
||||||
|
onCheckedChange={(checked) => setSceneFusionConfig({ ...sceneFusionConfig, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 默认提示词 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sceneFusionPrompt">默认提示词 *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="sceneFusionPrompt"
|
||||||
|
value={sceneFusionConfig.defaultPrompt}
|
||||||
|
onChange={(e) => setSceneFusionConfig({ ...sceneFusionConfig, defaultPrompt: e.target.value })}
|
||||||
|
placeholder="输入默认的融合提示词..."
|
||||||
|
rows={4}
|
||||||
|
disabled={!sceneFusionConfig.enabled}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
该提示词将作为场景裂变的基础指令,用户可以在此基础上补充关键词
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最大场景数量 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxScenes">最大场景数量</Label>
|
||||||
|
<Input
|
||||||
|
id="maxScenes"
|
||||||
|
type="number"
|
||||||
|
value={sceneFusionConfig.maxScenes}
|
||||||
|
onChange={(e) => setSceneFusionConfig({ ...sceneFusionConfig, maxScenes: Number.parseInt(e.target.value) || 10 })}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={!sceneFusionConfig.enabled}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
用户单次裂变可上传的最大场景图数量(1-20张)
|
||||||
|
</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">功能说明</div>
|
||||||
|
<div className="text-xs text-blue-800 space-y-1">
|
||||||
|
<p>• 用户上传1张产品图和多张场景参考图</p>
|
||||||
|
<p>• 系统自动将产品图融合到每个场景中</p>
|
||||||
|
<p>• 使用已配置的阿里生图模型进行处理</p>
|
||||||
|
<p>• 生成的图片会自动保存到用户相册</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSaveSceneFusionConfig}>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> 保存配置
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
55
app/api/scene-fusion/config/route.ts
Normal file
55
app/api/scene-fusion/config/route.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 场景裂变配置 API(公开端点)
|
||||||
|
*
|
||||||
|
* 允许普通用户获取场景裂变的配置信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { getSystemSetting } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
// 默认提示词
|
||||||
|
const FALLBACK_PROMPT = "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 需要登录但不需要 admin 权限
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
const setting = await getSystemSetting({ key: "scene_fusion_config" });
|
||||||
|
|
||||||
|
if (setting?.value) {
|
||||||
|
const config = setting.value as {
|
||||||
|
enabled?: boolean;
|
||||||
|
defaultPrompt?: string;
|
||||||
|
maxScenes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT,
|
||||||
|
maxScenes: config.maxScenes ?? 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回默认配置
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled: true,
|
||||||
|
defaultPrompt: FALLBACK_PROMPT,
|
||||||
|
maxScenes: 10,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SceneFusion Config] Error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "获取配置失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
406
app/api/scene-fusion/route.ts
Normal file
406
app/api/scene-fusion/route.ts
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* 场景裂变 API
|
||||||
|
*
|
||||||
|
* 将一张产品图批量融合到多个场景图中
|
||||||
|
* 使用阿里云千问图片编辑模型
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
|
||||||
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
// 请求体 Schema
|
||||||
|
const sceneFusionRequestSchema = z.object({
|
||||||
|
productImage: z.string().min(1, "产品图不能为空"),
|
||||||
|
scenes: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
image: z.string().min(1, "场景图不能为空"),
|
||||||
|
customPrompt: z.string().optional(),
|
||||||
|
})).min(1, "至少需要一个场景图").max(20, "最多支持20个场景图"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 默认提示词(当数据库未配置时使用)
|
||||||
|
const FALLBACK_PROMPT = "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取场景裂变配置
|
||||||
|
*/
|
||||||
|
async function getSceneFusionConfig() {
|
||||||
|
try {
|
||||||
|
const setting = await getSystemSetting({ key: "scene_fusion_config" });
|
||||||
|
if (setting?.value) {
|
||||||
|
const config = setting.value as {
|
||||||
|
enabled?: boolean;
|
||||||
|
defaultPrompt?: string;
|
||||||
|
maxScenes?: number;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
defaultPrompt: config.defaultPrompt || FALLBACK_PROMPT,
|
||||||
|
maxScenes: config.maxScenes ?? 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SceneFusion] Failed to load config:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
defaultPrompt: FALLBACK_PROMPT,
|
||||||
|
maxScenes: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建缩略图
|
||||||
|
*/
|
||||||
|
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const thumbnailBuffer = await sharp(buffer)
|
||||||
|
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toBuffer();
|
||||||
|
return thumbnailBuffer;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SceneFusion] Failed to create thumbnail:", error);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一文件名
|
||||||
|
*/
|
||||||
|
function generateFilename(prefix: string, extension: string): string {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `${prefix}-${timestamp}-${random}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Base64 图片上传到 S3 获取 URL
|
||||||
|
*/
|
||||||
|
async function uploadBase64ToS3(base64Data: string, prefix: string = "scene-fusion"): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 移除 data URL 前缀
|
||||||
|
const base64Clean = base64Data.replace(/^data:image\/\w+;base64,/, "");
|
||||||
|
const buffer = Buffer.from(base64Clean, "base64");
|
||||||
|
|
||||||
|
const filename = generateFilename(`${prefix}-ref`, "png");
|
||||||
|
const result = await uploadFile(filename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
||||||
|
console.log(`[SceneFusion] Uploaded reference image to: ${result.url}`);
|
||||||
|
return result.url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SceneFusion] Failed to upload reference image:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载图片并上传到 S3
|
||||||
|
*/
|
||||||
|
async function downloadAndUploadToS3(
|
||||||
|
imageUrl: string,
|
||||||
|
prefix: string = "scene-fusion"
|
||||||
|
): Promise<{ thumbnail: string; original: string }> {
|
||||||
|
try {
|
||||||
|
console.log("[SceneFusion] Downloading image from:", imageUrl.substring(0, 100) + "...");
|
||||||
|
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
console.log(`[SceneFusion] Downloaded image, size: ${buffer.length} bytes`);
|
||||||
|
|
||||||
|
// 创建缩略图
|
||||||
|
const thumbnailBuffer = await createThumbnail(buffer);
|
||||||
|
|
||||||
|
// 上传原图
|
||||||
|
const originalFilename = generateFilename(`${prefix}-orig`, "png");
|
||||||
|
const originalResult = await uploadFile(originalFilename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
||||||
|
console.log(`[SceneFusion] Uploaded original to: ${originalResult.url}`);
|
||||||
|
|
||||||
|
// 上传缩略图
|
||||||
|
const thumbnailFilename = generateFilename(`${prefix}-thumb`, "jpg");
|
||||||
|
const thumbnailArrayBuffer = new Uint8Array(thumbnailBuffer).buffer;
|
||||||
|
const thumbnailResult = await uploadFile(thumbnailFilename, thumbnailArrayBuffer);
|
||||||
|
console.log(`[SceneFusion] Uploaded thumbnail to: ${thumbnailResult.url}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: originalResult.url,
|
||||||
|
thumbnail: thumbnailResult.url,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SceneFusion] Failed to download/upload image:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用阿里生图 API
|
||||||
|
*/
|
||||||
|
async function callAlibabaImageAPI(
|
||||||
|
baseURL: string,
|
||||||
|
apiKey: string,
|
||||||
|
modelId: string,
|
||||||
|
productImageUrl: string,
|
||||||
|
sceneImageUrl: string,
|
||||||
|
prompt: string
|
||||||
|
): Promise<string> {
|
||||||
|
// 构建消息内容:产品图 + 场景图 + 提示词
|
||||||
|
const content = [
|
||||||
|
{ image: productImageUrl },
|
||||||
|
{ image: sceneImageUrl },
|
||||||
|
{ text: prompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model: modelId,
|
||||||
|
input: {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
n: 1,
|
||||||
|
prompt_extend: true,
|
||||||
|
watermark: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[SceneFusion] Sending request to Alibaba API...");
|
||||||
|
|
||||||
|
const response = await fetch(baseURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || data.code) {
|
||||||
|
console.error("[SceneFusion] API Error:", data);
|
||||||
|
throw new Error(data.message || `API request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取生成的图片 URL
|
||||||
|
const choices = data.output?.choices;
|
||||||
|
if (!choices || choices.length === 0) {
|
||||||
|
throw new Error("No images in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageContent = choices[0]?.message?.content;
|
||||||
|
if (!imageContent || imageContent.length === 0) {
|
||||||
|
throw new Error("No image content in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedImageUrl = imageContent[0]?.image;
|
||||||
|
if (!generatedImageUrl) {
|
||||||
|
throw new Error("No image URL in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[SceneFusion] Generated image URL:", generatedImageUrl.substring(0, 100) + "...");
|
||||||
|
return generatedImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个场景
|
||||||
|
*/
|
||||||
|
async function processScene(
|
||||||
|
productImageUrl: string,
|
||||||
|
sceneImageUrl: string,
|
||||||
|
customPrompt: string | undefined,
|
||||||
|
defaultPrompt: string,
|
||||||
|
baseURL: string,
|
||||||
|
apiKey: string,
|
||||||
|
modelId: string
|
||||||
|
): Promise<{ thumbnail: string; original: string }> {
|
||||||
|
// 组合提示词
|
||||||
|
const prompt = customPrompt
|
||||||
|
? `${defaultPrompt} ${customPrompt}`
|
||||||
|
: defaultPrompt;
|
||||||
|
|
||||||
|
// 调用阿里生图 API
|
||||||
|
const generatedImageUrl = await callAlibabaImageAPI(
|
||||||
|
baseURL,
|
||||||
|
apiKey,
|
||||||
|
modelId,
|
||||||
|
productImageUrl,
|
||||||
|
sceneImageUrl,
|
||||||
|
prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
// 下载并上传到 S3
|
||||||
|
const uploaded = await downloadAndUploadToS3(generatedImageUrl, "scene-fusion");
|
||||||
|
|
||||||
|
return uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// 1. 认证
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取场景裂变配置
|
||||||
|
const config = await getSceneFusionConfig();
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "场景裂变功能已被管理员禁用" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 解析请求体
|
||||||
|
const json = await request.json();
|
||||||
|
const parseResult = sceneFusionRequestSchema.safeParse(json);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parseResult.error.errors[0].message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productImage, scenes } = parseResult.data;
|
||||||
|
|
||||||
|
// 检查场景数量限制
|
||||||
|
if (scenes.length > config.maxScenes) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `最多只能添加 ${config.maxScenes} 个场景` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SceneFusion] Processing ${scenes.length} scenes for user ${session.user.id}`);
|
||||||
|
console.log(`[SceneFusion] Using prompt: ${config.defaultPrompt.substring(0, 50)}...`);
|
||||||
|
|
||||||
|
// 4. 获取阿里生图模型配置
|
||||||
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
|
const alibabaModel = allModels.find(
|
||||||
|
(m) => m.provider.type === "alibaba-image" && m.model.isEnabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alibabaModel) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "未配置阿里生图模型,请联系管理员" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { provider, model } = alibabaModel;
|
||||||
|
console.log(`[SceneFusion] Using model: ${model.displayName} (${model.modelId})`);
|
||||||
|
|
||||||
|
// 4. 预处理:将 Base64 图片上传到 S3
|
||||||
|
let productImageUrl = productImage;
|
||||||
|
if (productImage.startsWith("data:")) {
|
||||||
|
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedScenes = await Promise.all(
|
||||||
|
scenes.map(async (scene) => {
|
||||||
|
let sceneImageUrl = scene.image;
|
||||||
|
if (scene.image.startsWith("data:")) {
|
||||||
|
sceneImageUrl = await uploadBase64ToS3(scene.image, "scene-fusion-scene");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...scene,
|
||||||
|
imageUrl: sceneImageUrl,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 并发处理所有场景
|
||||||
|
const results = await Promise.all(
|
||||||
|
processedScenes.map(async (scene) => {
|
||||||
|
try {
|
||||||
|
const result = await processScene(
|
||||||
|
productImageUrl,
|
||||||
|
scene.imageUrl,
|
||||||
|
scene.customPrompt,
|
||||||
|
config.defaultPrompt,
|
||||||
|
provider.baseUrl,
|
||||||
|
provider.apiKey,
|
||||||
|
model.modelId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const messageId = `scene-fusion-${Date.now()}-${scene.id}`;
|
||||||
|
const chatId = `scene-fusion-batch-${Date.now()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(generatedImage).values({
|
||||||
|
userId: session.user.id,
|
||||||
|
chatId,
|
||||||
|
messageId,
|
||||||
|
thumbnailUrl: result.thumbnail,
|
||||||
|
originalUrl: result.original,
|
||||||
|
imageSize: "2K",
|
||||||
|
prompt: scene.customPrompt
|
||||||
|
? `${config.defaultPrompt} ${scene.customPrompt}`
|
||||||
|
: config.defaultPrompt,
|
||||||
|
modelId: model.modelId,
|
||||||
|
});
|
||||||
|
console.log(`[SceneFusion] Saved image to gallery: ${result.original.substring(0, 80)}...`);
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error("[SceneFusion] Failed to save to gallery:", dbError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: scene.id,
|
||||||
|
status: "completed" as const,
|
||||||
|
result: result.thumbnail, // 返回缩略图用于预览
|
||||||
|
originalUrl: result.original, // 原图用于下载
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SceneFusion] Failed to process scene ${scene.id}:`, error);
|
||||||
|
return {
|
||||||
|
id: scene.id,
|
||||||
|
status: "failed" as const,
|
||||||
|
error: error instanceof Error ? error.message : "处理失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 返回结果
|
||||||
|
const successCount = results.filter((r) => r.status === "completed").length;
|
||||||
|
const failCount = results.filter((r) => r.status === "failed").length;
|
||||||
|
|
||||||
|
console.log(`[SceneFusion] Completed: ${successCount} success, ${failCount} failed`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
summary: {
|
||||||
|
total: scenes.length,
|
||||||
|
success: successCount,
|
||||||
|
failed: failCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SceneFusion] Unexpected error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "处理失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,11 +4,12 @@ import React, { useRef, useState, useEffect, useCallback } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D } from "lucide-react";
|
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2, ZoomIn, ZoomOut, Loader2, Move3D, Layers, Upload as UploadIcon, Image as ImageIcon } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AngleEditDialog } from "./angle-edit-dialog";
|
import { AngleEditDialog } from "./angle-edit-dialog";
|
||||||
|
import { SceneFusionDialog } from "./scene-fusion-dialog";
|
||||||
|
|
||||||
type Tool = "pen" | "arrow" | "circle" | "text";
|
type Tool = "pen" | "arrow" | "circle" | "text";
|
||||||
|
|
||||||
@ -27,7 +28,7 @@ type DrawingAction = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ImageAnnotatorProps {
|
interface ImageAnnotatorProps {
|
||||||
imageUrl: string;
|
imageUrl?: string;
|
||||||
onConfirm: (annotatedImageUrl: string) => void;
|
onConfirm: (annotatedImageUrl: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
initialResizeMode?: boolean; // 是否直接进入尺寸调整模式
|
||||||
@ -62,17 +63,101 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
|
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(!!imageUrl); // 如果有 imageUrl 则加载中
|
||||||
const [isRemovingBg, setIsRemovingBg] = useState(false);
|
const [isRemovingBg, setIsRemovingBg] = useState(false);
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
const [isExtending, setIsExtending] = useState(false);
|
const [isExtending, setIsExtending] = useState(false);
|
||||||
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
|
const [showAngleEditDialog, setShowAngleEditDialog] = useState(false);
|
||||||
const [originalImageUrl, setOriginalImageUrl] = useState<string>(imageUrl);
|
const [showSceneFusion, setShowSceneFusion] = useState(false);
|
||||||
|
const [originalImageUrl, setOriginalImageUrl] = useState<string | undefined>(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" | "extend" | "resize" | null>(null);
|
const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | "resize" | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 处理拖拽上传
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error("请上传图片文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error("图片大小不能超过 10MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string;
|
||||||
|
setOriginalImageUrl(result);
|
||||||
|
setIsLoading(true);
|
||||||
|
setProcessedImageUrl(null);
|
||||||
|
setProcessedImageBase64(null);
|
||||||
|
setProcessType(null);
|
||||||
|
setActions([]);
|
||||||
|
setCurrentAction(null);
|
||||||
|
|
||||||
|
const img = new window.Image();
|
||||||
|
img.src = result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理粘贴上传
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted || originalImageUrl) return;
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error("图片大小不能超过 10MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string;
|
||||||
|
setOriginalImageUrl(result);
|
||||||
|
setIsLoading(true);
|
||||||
|
setProcessedImageUrl(null);
|
||||||
|
setProcessedImageBase64(null);
|
||||||
|
setProcessType(null);
|
||||||
|
setActions([]);
|
||||||
|
setCurrentAction(null);
|
||||||
|
|
||||||
|
const img = new window.Image();
|
||||||
|
img.src = result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
return () => document.removeEventListener('paste', handlePaste);
|
||||||
|
}, [mounted, originalImageUrl]);
|
||||||
|
|
||||||
// 尺寸调整相关状态
|
// 尺寸调整相关状态
|
||||||
const [isResizeMode, setIsResizeMode] = useState(false);
|
const [isResizeMode, setIsResizeMode] = useState(false);
|
||||||
const [resizeCanvasWidth, setResizeCanvasWidth] = useState<number>(1920);
|
const [resizeCanvasWidth, setResizeCanvasWidth] = useState<number>(1920);
|
||||||
@ -169,8 +254,45 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 处理本地图片上传
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error("图片大小不能超过 10MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string;
|
||||||
|
setOriginalImageUrl(result);
|
||||||
|
setIsLoading(true);
|
||||||
|
// 重置状态
|
||||||
|
setProcessedImageUrl(null);
|
||||||
|
setProcessedImageBase64(null);
|
||||||
|
setProcessType(null);
|
||||||
|
setActions([]);
|
||||||
|
setCurrentAction(null);
|
||||||
|
|
||||||
|
// 触发图片加载逻辑
|
||||||
|
const img = new window.Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
setImage(img);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
img.src = result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
// 重置 input
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
// 加载图片
|
// 加载图片
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!imageUrl) return; // 如果没有初始图片,不执行加载逻辑
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setOriginalImageUrl(imageUrl);
|
setOriginalImageUrl(imageUrl);
|
||||||
setProcessedImageUrl(null);
|
setProcessedImageUrl(null);
|
||||||
@ -459,7 +581,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
|
|
||||||
// 抠图功能
|
// 抠图功能
|
||||||
const handleRemoveBackground = async () => {
|
const handleRemoveBackground = async () => {
|
||||||
if (!image) return;
|
if (!image || !originalImageUrl) return;
|
||||||
|
|
||||||
setIsRemovingBg(true);
|
setIsRemovingBg(true);
|
||||||
try {
|
try {
|
||||||
@ -522,7 +644,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
|
|
||||||
// 变清晰处理
|
// 变清晰处理
|
||||||
const handleEnhance = async () => {
|
const handleEnhance = async () => {
|
||||||
if (!image) return;
|
if (!image || !originalImageUrl) return;
|
||||||
|
|
||||||
setIsEnhancing(true);
|
setIsEnhancing(true);
|
||||||
try {
|
try {
|
||||||
@ -1170,6 +1292,16 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
<Move3D className="mr-1 h-4 w-4" />
|
<Move3D className="mr-1 h-4 w-4" />
|
||||||
角度调整
|
角度调整
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSceneFusion(true)}
|
||||||
|
disabled={showSceneFusion || showAngleEditDialog || isRemovingBg || isEnhancing || isExtending || isResizeMode || !image}
|
||||||
|
title="场景裂变"
|
||||||
|
>
|
||||||
|
<Layers className="mr-1 h-4 w-4" />
|
||||||
|
场景裂变
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
|
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
|
||||||
<Undo className="mr-1 h-4 w-4" />
|
<Undo className="mr-1 h-4 w-4" />
|
||||||
撤销
|
撤销
|
||||||
@ -1574,7 +1706,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 角度调整对话框 */}
|
{/* 角度调整对话框 */}
|
||||||
{showAngleEditDialog && (
|
{showAngleEditDialog && originalImageUrl && (
|
||||||
<AngleEditDialog
|
<AngleEditDialog
|
||||||
open={showAngleEditDialog}
|
open={showAngleEditDialog}
|
||||||
onOpenChange={setShowAngleEditDialog}
|
onOpenChange={setShowAngleEditDialog}
|
||||||
@ -1582,6 +1714,12 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
onComplete={handleAngleEditComplete}
|
onComplete={handleAngleEditComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 场景裂变弹窗 */}
|
||||||
|
<SceneFusionDialog
|
||||||
|
open={showSceneFusion}
|
||||||
|
onOpenChange={setShowSceneFusion}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1590,5 +1728,55 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果没有图片,显示上传界面
|
||||||
|
if (!originalImageUrl) {
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||||
|
<div className="relative flex h-[80vh] w-[90vw] max-w-5xl flex-col overflow-hidden rounded-xl border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex h-14 items-center justify-between border-b px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold">图片编辑器</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Area */}
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center p-8 bg-muted/10">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md border-2 border-dashed border-muted-foreground/25 rounded-xl p-12 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-all group"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
>
|
||||||
|
<div className="bg-background rounded-full p-4 w-20 h-20 mx-auto mb-6 shadow-sm group-hover:scale-105 transition-transform flex items-center justify-center">
|
||||||
|
<UploadIcon className="w-10 h-10 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">上传图片以开始编辑</h3>
|
||||||
|
<p className="text-muted-foreground mb-2">拖拽图片到此处、点击选择或 Ctrl+V 粘贴</p>
|
||||||
|
<p className="text-muted-foreground/60 text-sm mb-6">支持 JPG, PNG, WebP 格式,最大 10MB</p>
|
||||||
|
<Button variant="default">选择图片</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return createPortal(modalContent, document.body);
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
568
components/scene-fusion-dialog.tsx
Normal file
568
components/scene-fusion-dialog.tsx
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
Layers,
|
||||||
|
Sparkles
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
|
|
||||||
|
// 场景项数据结构
|
||||||
|
export interface SceneItem {
|
||||||
|
id: string;
|
||||||
|
image: string; // 场景图 URL 或 Base64
|
||||||
|
customPrompt: string; // 该场景的补充关键词
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
result?: string; // 生成结果缩略图 URL
|
||||||
|
originalUrl?: string; // 生成结果原图 URL(用于下载)
|
||||||
|
error?: string; // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SceneFusionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps) {
|
||||||
|
// 获取场景裂变配置(使用公开 API 端点)
|
||||||
|
const { data: configData } = useSWR(
|
||||||
|
open ? "/api/scene-fusion/config" : null,
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = configData as {
|
||||||
|
enabled?: boolean;
|
||||||
|
defaultPrompt?: string;
|
||||||
|
maxScenes?: number;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
||||||
|
const maxScenes = config?.maxScenes || 10;
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const [productImage, setProductImage] = useState<string | null>(null);
|
||||||
|
const [scenes, setScenes] = useState<SceneItem[]>([]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
// 文件上传 Input 引用
|
||||||
|
const productInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sceneInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 处理产品图上传
|
||||||
|
const handleProductUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error("图片大小不能超过 10MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setProductImage(e.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
// 重置 input,允许重复选择同一文件
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理场景图上传
|
||||||
|
const handleSceneUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
if (scenes.length + files.length > maxScenes) {
|
||||||
|
toast.error(`最多只能添加 ${maxScenes} 张场景参考图`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error(`图片 ${file.name} 大小不能超过 10MB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const newScene: SceneItem = {
|
||||||
|
id: Math.random().toString(36).substring(7),
|
||||||
|
image: e.target?.result as string,
|
||||||
|
customPrompt: "",
|
||||||
|
status: 'pending'
|
||||||
|
};
|
||||||
|
setScenes(prev => [...prev, newScene]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置 input
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除场景
|
||||||
|
const removeScene = (id: string) => {
|
||||||
|
setScenes(prev => prev.filter(scene => scene.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新场景关键词
|
||||||
|
const updateScenePrompt = (id: string, prompt: string) => {
|
||||||
|
setScenes(prev => prev.map(scene =>
|
||||||
|
scene.id === id ? { ...scene, customPrompt: prompt } : scene
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始裂变
|
||||||
|
const startFusion = async () => {
|
||||||
|
if (config?.enabled === false) {
|
||||||
|
toast.error("场景裂变功能已被管理员禁用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!productImage) {
|
||||||
|
toast.error("请先上传产品图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scenes.length === 0) {
|
||||||
|
toast.error("请至少添加一张场景参考图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
// 将所有 pending 或 failed 的状态改为 processing
|
||||||
|
const scenesToProcess = scenes.filter(s => s.status === 'pending' || s.status === 'failed');
|
||||||
|
setScenes(prev => prev.map(s =>
|
||||||
|
(s.status === 'pending' || s.status === 'failed')
|
||||||
|
? { ...s, status: 'processing' }
|
||||||
|
: s
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用后端 API
|
||||||
|
const response = await fetch('/api/scene-fusion', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
productImage,
|
||||||
|
scenes: scenesToProcess.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
image: s.image,
|
||||||
|
customPrompt: s.customPrompt || undefined,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '处理失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新场景状态
|
||||||
|
setScenes(prev => prev.map(s => {
|
||||||
|
const result = data.results?.find((r: any) => r.id === s.id);
|
||||||
|
if (result) {
|
||||||
|
if (result.status === 'completed') {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
status: 'completed',
|
||||||
|
result: result.result,
|
||||||
|
originalUrl: result.originalUrl,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
status: 'failed',
|
||||||
|
error: result.error || '生成失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { summary } = data;
|
||||||
|
if (summary.failed > 0) {
|
||||||
|
toast.warning(`批量处理完成:${summary.success} 成功,${summary.failed} 失败`);
|
||||||
|
} else {
|
||||||
|
toast.success(`批量处理完成:${summary.success} 张图片已生成`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SceneFusion] Error:', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : '处理失败');
|
||||||
|
|
||||||
|
// 将 processing 状态改回 failed
|
||||||
|
setScenes(prev => prev.map(s =>
|
||||||
|
s.status === 'processing' ? { ...s, status: 'failed', error: '处理失败' } : s
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重新生成单个场景
|
||||||
|
const regenerateScene = async (id: string) => {
|
||||||
|
if (!productImage) return;
|
||||||
|
|
||||||
|
const scene = scenes.find(s => s.id === id);
|
||||||
|
if (!scene) return;
|
||||||
|
|
||||||
|
setScenes(prev => prev.map(s =>
|
||||||
|
s.id === id ? { ...s, status: 'processing', error: undefined } : s
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scene-fusion', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
productImage,
|
||||||
|
scenes: [{
|
||||||
|
id: scene.id,
|
||||||
|
image: scene.image,
|
||||||
|
customPrompt: scene.customPrompt || undefined,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '处理失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = data.results?.[0];
|
||||||
|
if (result?.status === 'completed') {
|
||||||
|
setScenes(prev => prev.map(s =>
|
||||||
|
s.id === id ? {
|
||||||
|
...s,
|
||||||
|
status: 'completed',
|
||||||
|
result: result.result,
|
||||||
|
originalUrl: result.originalUrl,
|
||||||
|
} : s
|
||||||
|
));
|
||||||
|
toast.success('重新生成成功');
|
||||||
|
} else {
|
||||||
|
throw new Error(result?.error || '生成失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SceneFusion] Regenerate error:', error);
|
||||||
|
setScenes(prev => prev.map(s =>
|
||||||
|
s.id === id ? { ...s, status: 'failed', error: error instanceof Error ? error.message : '处理失败' } : s
|
||||||
|
));
|
||||||
|
toast.error(error instanceof Error ? error.message : '重新生成失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下载图片
|
||||||
|
const downloadImage = (url: string, filename: string) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全部下载
|
||||||
|
const downloadAll = async () => {
|
||||||
|
const completedScenes = scenes.filter(s => s.status === 'completed' && (s.originalUrl || s.result));
|
||||||
|
if (completedScenes.length === 0) {
|
||||||
|
toast.error("没有可下载的图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.info(`正在下载 ${completedScenes.length} 张图片...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < completedScenes.length; i++) {
|
||||||
|
const scene = completedScenes[i];
|
||||||
|
const url = scene.originalUrl || scene.result!;
|
||||||
|
// 延迟下载避免浏览器阻止
|
||||||
|
setTimeout(() => {
|
||||||
|
downloadImage(url, `scene-fusion-${i + 1}.png`);
|
||||||
|
}, i * 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="w-5 h-5 text-primary" />
|
||||||
|
场景裂变
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col md:flex-row">
|
||||||
|
{/* 左侧:输入区域 */}
|
||||||
|
<div className="w-full md:w-[400px] border-r flex flex-col bg-muted/10 shrink-0">
|
||||||
|
<ScrollArea className="flex-1 p-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 1. 产品图上传 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-medium">产品图 A (必须)</Label>
|
||||||
|
{productImage && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setProductImage(null)} className="h-6 text-xs text-muted-foreground hover:text-destructive">
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
|
||||||
|
productImage ? "border-primary/50 bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={() => !productImage && productInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={productInputRef}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={handleProductUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{productImage ? (
|
||||||
|
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
|
||||||
|
<img src={productImage} alt="产品图" className="w-full h-full object-contain rounded-md" />
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-md">
|
||||||
|
<Button variant="secondary" size="sm" onClick={(e) => { e.stopPropagation(); productInputRef.current?.click(); }}>
|
||||||
|
更换图片
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Upload className="w-8 h-8 opacity-50" />
|
||||||
|
<span className="text-sm">点击上传产品图</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 场景图列表 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
场景参考图 ({scenes.length}/{maxScenes})
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => sceneInputRef.current?.click()}
|
||||||
|
disabled={scenes.length >= maxScenes || isProcessing}
|
||||||
|
className="h-7 text-xs gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" /> 添加场景
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={sceneInputRef}
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
onChange={handleSceneUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{scenes.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => sceneInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">点击添加场景参考图</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-1">支持批量上传,最多{maxScenes}张</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scenes.map((scene, index) => (
|
||||||
|
<div key={scene.id} className="border rounded-lg p-3 bg-background relative group">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{/* 缩略图 */}
|
||||||
|
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
|
||||||
|
<img src={scene.image} alt={`场景 ${index + 1}`} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关键词输入 */}
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">场景 {index + 1}</span>
|
||||||
|
{!isProcessing && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeScene(scene.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="补充关键词:如'放在木桌上'"
|
||||||
|
value={scene.customPrompt}
|
||||||
|
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 默认提示词提示 */}
|
||||||
|
<div className="px-4 py-2 border-t bg-muted/30">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">默认提示词:</span>
|
||||||
|
{defaultPrompt.length > 60 ? `${defaultPrompt.substring(0, 60)}...` : defaultPrompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
onClick={startFusion}
|
||||||
|
disabled={isProcessing || !productImage || scenes.length === 0}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
处理中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
开始裂变 ({scenes.length} 张)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:结果展示区域 */}
|
||||||
|
<div className="flex-1 flex flex-col bg-background/50 h-full">
|
||||||
|
<div className="p-4 border-b flex items-center justify-between bg-background">
|
||||||
|
<h3 className="font-medium">生成结果</h3>
|
||||||
|
{scenes.some(s => s.status === 'completed') && (
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadAll}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
全部下载
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 p-4">
|
||||||
|
{scenes.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50 min-h-[300px]">
|
||||||
|
<Layers className="w-16 h-16 mb-4" />
|
||||||
|
<p>添加场景图并开始裂变,结果将显示在这里</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{scenes.map((scene, index) => (
|
||||||
|
<div key={scene.id} className="border rounded-lg overflow-hidden bg-background shadow-sm flex flex-col">
|
||||||
|
<div className="p-2 border-b text-xs text-muted-foreground bg-muted/30 flex justify-between items-center">
|
||||||
|
<span>场景 {index + 1}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"capitalize px-1.5 py-0.5 rounded text-[10px]",
|
||||||
|
scene.status === 'completed' && "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
scene.status === 'failed' && "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||||
|
scene.status === 'processing' && "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
|
scene.status === 'pending' && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||||
|
)}>
|
||||||
|
{scene.status === 'completed' ? '完成' :
|
||||||
|
scene.status === 'failed' ? '失败' :
|
||||||
|
scene.status === 'processing' ? '生成中' : '等待'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-[4/3] bg-muted/20 relative group">
|
||||||
|
{scene.result ? (
|
||||||
|
<img src={scene.result} alt="生成结果" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center p-4">
|
||||||
|
{scene.status === 'processing' ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
<span className="text-xs text-muted-foreground">正在生成...</span>
|
||||||
|
</div>
|
||||||
|
) : scene.status === 'failed' ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-destructive mb-2">{scene.error || "生成失败"}</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => regenerateScene(scene.id)}>
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" /> 重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center w-full h-full opacity-30">
|
||||||
|
<img src={scene.image} className="w-full h-full object-cover blur-sm" />
|
||||||
|
<span className="absolute text-xs font-medium bg-black/50 text-white px-2 py-1 rounded">等待处理</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 悬停操作层 */}
|
||||||
|
{scene.status === 'completed' && (
|
||||||
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<Button size="icon" variant="secondary" onClick={() => downloadImage(scene.originalUrl || scene.result!, `scene-${index+1}.png`)}>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="secondary" onClick={() => regenerateScene(scene.id)}>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -49,7 +49,7 @@ export function TopNavbar({
|
|||||||
const [rankingOpen, setRankingOpen] = useState(false);
|
const [rankingOpen, setRankingOpen] = useState(false);
|
||||||
const [showImageEditor, setShowImageEditor] = useState(false);
|
const [showImageEditor, setShowImageEditor] = useState(false);
|
||||||
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
// 移除 fileInputRef,不再需要外部上传
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@ -90,45 +90,12 @@ export function TopNavbar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 决定显示哪个 LOGO
|
// 决定显示哪个 LOGO
|
||||||
// 导航栏背景固定为黑色,理论上应该始终显示适合深色背景的 LOGO
|
|
||||||
// 但如果有专门配置了 "Dark Mode Logo" (通常用于深色背景),我们优先使用它
|
|
||||||
// 如果没配置 Dark Mode Logo,则使用默认 Logo
|
|
||||||
// 注意:这里的背景是黑色的,所以通常需要一个浅色的 LOGO(即 Dark Mode 下使用的 LOGO)
|
|
||||||
// 如果用户配置逻辑是:siteLogo(Light Mode/白底用), siteLogoDark(Dark Mode/黑底用)
|
|
||||||
// 那么在黑色导航栏上,应该优先使用 siteLogoDark
|
|
||||||
const displayLogo = (siteLogoDark && siteLogoDark.trim() !== "") ? siteLogoDark : siteLogo;
|
const displayLogo = (siteLogoDark && siteLogoDark.trim() !== "") ? siteLogoDark : siteLogo;
|
||||||
|
|
||||||
// 处理图片上传
|
// 打开图片编辑器 - 直接打开,不触发上传
|
||||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// 检查文件类型
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为 data URL
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const result = reader.result as string;
|
|
||||||
setEditorImageUrl(result);
|
|
||||||
setShowImageEditor(true);
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
console.error("图片读取失败");
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
|
|
||||||
// 清空文件输入,以便可以再次选择同一文件
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开图片编辑器
|
|
||||||
const handleOpenImageEditor = () => {
|
const handleOpenImageEditor = () => {
|
||||||
fileInputRef.current?.click();
|
setEditorImageUrl(""); // 确保初始为空
|
||||||
|
setShowImageEditor(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理图片编辑完成
|
// 处理图片编辑完成
|
||||||
@ -287,15 +254,6 @@ export function TopNavbar({
|
|||||||
<span className="text-sm font-medium">图片编辑</span>
|
<span className="text-sm font-medium">图片编辑</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 隐藏的文件输入 */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 我的图册 */}
|
{/* 我的图册 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -330,7 +288,7 @@ export function TopNavbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 图片编辑器 */}
|
{/* 图片编辑器 */}
|
||||||
{showImageEditor && editorImageUrl && (
|
{showImageEditor && (
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
imageUrl={editorImageUrl}
|
imageUrl={editorImageUrl}
|
||||||
onConfirm={handleImageEditorConfirm}
|
onConfirm={handleImageEditorConfirm}
|
||||||
|
|||||||
303
docs/图片编辑器功能升级规划.md
Normal file
303
docs/图片编辑器功能升级规划.md
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# 图片编辑器功能升级规划文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档规划两个新功能:
|
||||||
|
1. **图片编辑器入口优化** - 允许用户先打开页面再上传图片
|
||||||
|
2. **场景裂变功能** - 批量将产品图替换到多个场景图中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能一:图片编辑器入口优化
|
||||||
|
|
||||||
|
### 当前问题
|
||||||
|
- 用户点击顶部导航栏的「图片编辑器」按钮时,必须先上传图片才能打开界面
|
||||||
|
- 用户体验不佳,无法预览编辑器界面
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
|
||||||
|
#### 方案 A:两步式交互(推荐)
|
||||||
|
1. 用户点击「图片编辑器」按钮
|
||||||
|
2. 打开图片编辑器页面/弹窗
|
||||||
|
3. 页面中显示上传区域,用户可以:
|
||||||
|
- 拖拽上传图片
|
||||||
|
- 点击选择图片
|
||||||
|
- 粘贴图片
|
||||||
|
4. 上传后进入编辑模式
|
||||||
|
|
||||||
|
#### UI 设计
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 图片编辑器 │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 📷 拖拽图片到此处或点击上传 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 支持 JPG、PNG、WebP 格式 │ │
|
||||||
|
│ │ 最大 10MB │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [选择图片] [从剪贴板粘贴] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 技术实现
|
||||||
|
- 修改 `ImageAnnotator` 组件,支持无图片时显示上传界面
|
||||||
|
- 修改导航栏点击事件,直接打开编辑器弹窗
|
||||||
|
- 添加拖拽上传、点击上传、粘贴上传功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能二:场景裂变
|
||||||
|
|
||||||
|
### 功能描述
|
||||||
|
用户上传一张产品图(图A),再上传 1-10 张场景参考图(B1-B10),系统自动将产品图批量融合到每个场景中。
|
||||||
|
|
||||||
|
### 用户流程
|
||||||
|
```
|
||||||
|
1. 用户上传产品图 A(必须)
|
||||||
|
2. 用户上传场景图 B1-B10(至少1张,最多10张)
|
||||||
|
3. 用户可选填补充关键词
|
||||||
|
4. 点击「开始裂变」
|
||||||
|
5. 系统依次调用阿里生图 API:
|
||||||
|
- 图A + 图B1 → 生成 AB1
|
||||||
|
- 图A + 图B2 → 生成 AB2
|
||||||
|
- ...
|
||||||
|
- 图A + 图B10 → 生成 AB10
|
||||||
|
6. 显示所有生成结果,支持下载
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI 设计
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 场景裂变 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 产品图 A (必须上传) │ │
|
||||||
|
│ │ ┌─────────┐ │ │
|
||||||
|
│ │ │ │ [上传产品图] [更换] │ │
|
||||||
|
│ │ │ 产品 │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ └─────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 场景参考图 (1-10张) [添加场景] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ 场景 1 [删除] │ │ │
|
||||||
|
│ │ │ ┌───────┐ 补充关键词: │ │ │
|
||||||
|
│ │ │ │ B1 │ ┌─────────────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ │ │ 例如:放在木质桌面上 │ │ │ │
|
||||||
|
│ │ │ └───────┘ └─────────────────────────────────┘ │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ 场景 2 [删除] │ │ │
|
||||||
|
│ │ │ ┌───────┐ 补充关键词: │ │ │
|
||||||
|
│ │ │ │ B2 │ ┌─────────────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ │ │ 例如:放在白色大理石台面上 │ │ │ │
|
||||||
|
│ │ │ └───────┘ └─────────────────────────────────┘ │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ... (更多场景) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 默认提示词:将图1的产品替换到图2的场景中,保持图1产品的外观、 │
|
||||||
|
│ 颜色和细节不变,自然融合到图2的环境中。 │
|
||||||
|
│ │
|
||||||
|
│ [开始裂变] (已添加 2 个场景) │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 生成结果: │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ 场景1: AB1 │ │ 场景2: AB2 │ │
|
||||||
|
│ │ ┌─────────┐ │ │ ┌─────────┐ │ │
|
||||||
|
│ │ │ │ │ │ │ │ │ │
|
||||||
|
│ │ │ ✓完成 │ │ │ │ 生成中..│ │ │
|
||||||
|
│ │ └─────────┘ │ │ └─────────┘ │ │
|
||||||
|
│ │ [下载] [重新生成]│ │ [下载] [重新生成]│ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [全部下载] [保存到相册] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术实现
|
||||||
|
|
||||||
|
#### 1. 数据结构
|
||||||
|
```typescript
|
||||||
|
// 场景项数据结构
|
||||||
|
interface SceneItem {
|
||||||
|
id: string; // 唯一标识
|
||||||
|
image: string; // 场景图 URL 或 Base64
|
||||||
|
customPrompt: string; // 该场景的补充关键词
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
result?: string; // 生成结果图片 URL
|
||||||
|
error?: string; // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景裂变状态
|
||||||
|
interface SceneFusionState {
|
||||||
|
productImage: string | null; // 产品图 A
|
||||||
|
scenes: SceneItem[]; // 场景列表 (1-10)
|
||||||
|
isProcessing: boolean; // 是否正在处理
|
||||||
|
currentIndex: number; // 当前处理的场景索引
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 新建组件
|
||||||
|
- `components/scene-fusion-dialog.tsx` - 场景裂变弹窗组件
|
||||||
|
|
||||||
|
#### 3. API 调用逻辑
|
||||||
|
```typescript
|
||||||
|
// 调用现有的阿里生图 API
|
||||||
|
// 每次请求:产品图A + 场景图Bn + 提示词
|
||||||
|
|
||||||
|
const defaultPrompt = "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
|
||||||
|
|
||||||
|
async function generateSceneFusion(
|
||||||
|
productImage: string, // 产品图 A
|
||||||
|
sceneImage: string, // 场景图 Bn
|
||||||
|
customPrompt?: string // 该场景的补充关键词(每个场景独立)
|
||||||
|
) {
|
||||||
|
// 组合提示词:默认提示词 + 该场景的补充关键词
|
||||||
|
const prompt = customPrompt
|
||||||
|
? `${defaultPrompt} ${customPrompt}`
|
||||||
|
: defaultPrompt;
|
||||||
|
|
||||||
|
// 调用阿里生图 API(使用现有的 alibaba-image provider)
|
||||||
|
// 输入:[productImage, sceneImage] + prompt
|
||||||
|
// 输出:融合后的图片
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量并发处理所有场景
|
||||||
|
async function processAllScenes(
|
||||||
|
productImage: string,
|
||||||
|
scenes: SceneItem[],
|
||||||
|
onComplete: (results: SceneItem[]) => void
|
||||||
|
) {
|
||||||
|
// 1. 显示动态占位卡片(使用现有的 ImageGenerationLoading 组件)
|
||||||
|
// 2. 并发发出所有请求
|
||||||
|
const promises = scenes.map(async (scene, index) => {
|
||||||
|
try {
|
||||||
|
const result = await generateSceneFusion(
|
||||||
|
productImage,
|
||||||
|
scene.image,
|
||||||
|
scene.customPrompt // 使用该场景独立的补充关键词
|
||||||
|
);
|
||||||
|
return { ...scene, status: 'completed' as const, result };
|
||||||
|
} catch (error) {
|
||||||
|
return { ...scene, status: 'failed' as const, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 等待所有请求完成
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
// 4. 同时显示所有结果
|
||||||
|
onComplete(results);
|
||||||
|
|
||||||
|
// 5. 保存到用户相册和生图记录
|
||||||
|
await saveToGalleryAndRecords(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存结果到相册和生图记录
|
||||||
|
async function saveToGalleryAndRecords(results: SceneItem[]) {
|
||||||
|
const successResults = results.filter(r => r.status === 'completed' && r.result);
|
||||||
|
|
||||||
|
for (const result of successResults) {
|
||||||
|
// 调用现有的保存图片 API
|
||||||
|
await fetch('/api/gallery', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl: result.result,
|
||||||
|
// ... 其他参数
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 批量处理策略
|
||||||
|
- **并发处理**:同时发出所有请求,提高处理速度
|
||||||
|
- **等待显示**:等待所有图片生成完成后,同时显示结果
|
||||||
|
- **加载状态**:使用现有的动态占位卡片显示等待状态
|
||||||
|
- **错误处理**:单张失败不影响其他图片,显示失败原因,支持重新生成
|
||||||
|
- **结果保存**:✅ 生成的图片保存到用户相册和生图记录
|
||||||
|
|
||||||
|
#### 4. 入口位置
|
||||||
|
- 图片编辑器页面顶部工具栏添加「场景裂变」按钮
|
||||||
|
- 点击打开场景裂变弹窗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件修改清单
|
||||||
|
|
||||||
|
### 功能一:图片编辑器入口优化
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `components/image-annotator.tsx` | 支持无图片时显示上传界面 |
|
||||||
|
| `components/multimodal-input.tsx` | 修改图片编辑器按钮点击逻辑 |
|
||||||
|
|
||||||
|
### 功能二:场景裂变
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `components/scene-fusion-dialog.tsx` | 新建场景裂变弹窗组件 |
|
||||||
|
| `components/image-annotator.tsx` | 添加「场景裂变」按钮入口 |
|
||||||
|
| `app/api/scene-fusion/route.ts` | 新建场景裂变 API(可选,也可直接前端调用现有 chat API) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 时间估算
|
||||||
|
|
||||||
|
| 功能 | 预计工时 |
|
||||||
|
|------|----------|
|
||||||
|
| 功能一:图片编辑器入口优化 | 1-2 小时 |
|
||||||
|
| 功能二:场景裂变 | 3-4 小时 |
|
||||||
|
| 测试和调试 | 1 小时 |
|
||||||
|
| **总计** | **5-7 小时** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
1. **场景裂变的默认提示词**:
|
||||||
|
- 当前设计:`将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。`
|
||||||
|
|
||||||
|
2. **每个场景的补充关键词**:
|
||||||
|
- ✅ 已确认:每个场景图独立配置补充关键词
|
||||||
|
- 用户可以为每个场景单独填写补充描述
|
||||||
|
|
||||||
|
3. **最大场景图数量**:
|
||||||
|
- ✅ 已确认:最多 10 张
|
||||||
|
|
||||||
|
4. **生成图片数量**:
|
||||||
|
- 当前设计:每个场景生成 1 张
|
||||||
|
|
||||||
|
5. **并发处理**:
|
||||||
|
- ✅ 已确认:并发处理(同时发出所有请求)
|
||||||
|
- 等待所有图片生成完成后,同时显示结果
|
||||||
|
- 等待过程中使用现有的动态占位卡片
|
||||||
|
|
||||||
|
6. **结果保存**:
|
||||||
|
- ✅ 已确认:保存到用户相册
|
||||||
|
- ✅ 已确认:保存到生图记录
|
||||||
|
|
||||||
|
7. **入口位置**:
|
||||||
|
- ✅ 已确认:场景裂变入口放在图片编辑器工具栏中
|
||||||
|
|
||||||
|
8. **重新生成**:
|
||||||
|
- ✅ 已确认:支持对单个失败/不满意的场景重新生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
请确认以上规划,确认后我将开始开发。
|
||||||
Loading…
x
Reference in New Issue
Block a user