feat: add canvas layer split workflow
This commit is contained in:
parent
14b6b3ec10
commit
f1ffeed9f2
@ -59,6 +59,7 @@ import {
|
||||
Scissors,
|
||||
Expand,
|
||||
Move3D,
|
||||
Layers,
|
||||
Upload,
|
||||
Video,
|
||||
Loader2,
|
||||
@ -959,6 +960,21 @@ export function AdminSettingsClient() {
|
||||
timeout: 120,
|
||||
});
|
||||
|
||||
const [layerSplitConfig, setLayerSplitConfig] = useState({
|
||||
enabled: false,
|
||||
provider: "replicate" as "replicate",
|
||||
replicateApiToken: "",
|
||||
replicateModel: "qwen/qwen-image-layered",
|
||||
layers: 4,
|
||||
resolution: 640,
|
||||
trueCfgScale: 4,
|
||||
numInferenceSteps: 30,
|
||||
cfgNormalize: true,
|
||||
useEnPrompt: true,
|
||||
negativePrompt: "",
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
|
||||
// 实时画布API配置状态
|
||||
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
||||
enabled: true,
|
||||
@ -1134,6 +1150,40 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}, [angleEditConfigData]);
|
||||
|
||||
const { data: layerSplitConfigData } = useSWR("/api/admin/settings?key=layer_split_config", fetcher);
|
||||
useEffect(() => {
|
||||
if (layerSplitConfigData?.setting?.value) {
|
||||
const config = layerSplitConfigData.setting.value as {
|
||||
enabled?: boolean;
|
||||
provider?: "replicate";
|
||||
replicateApiToken?: string;
|
||||
replicateModel?: string;
|
||||
layers?: number;
|
||||
resolution?: number;
|
||||
trueCfgScale?: number;
|
||||
numInferenceSteps?: number;
|
||||
cfgNormalize?: boolean;
|
||||
useEnPrompt?: boolean;
|
||||
negativePrompt?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
setLayerSplitConfig({
|
||||
enabled: config.enabled === true,
|
||||
provider: "replicate",
|
||||
replicateApiToken: config.replicateApiToken || "",
|
||||
replicateModel: config.replicateModel || "qwen/qwen-image-layered",
|
||||
layers: Math.max(2, Math.min(12, Number(config.layers) || 4)),
|
||||
resolution: Math.max(256, Math.min(1536, Number(config.resolution) || 640)),
|
||||
trueCfgScale: Math.max(1, Math.min(12, Number(config.trueCfgScale) || 4)),
|
||||
numInferenceSteps: Math.max(1, Math.min(80, Number(config.numInferenceSteps) || 30)),
|
||||
cfgNormalize: config.cfgNormalize !== false,
|
||||
useEnPrompt: config.useEnPrompt !== false,
|
||||
negativePrompt: config.negativePrompt || "",
|
||||
timeoutMs: Math.max(30000, Math.min(900000, Number(config.timeoutMs) || 300000)),
|
||||
});
|
||||
}
|
||||
}, [layerSplitConfigData]);
|
||||
|
||||
// 加载实时画布API配置
|
||||
const { data: realtimeCanvasConfigData } = useSWR(
|
||||
"/api/admin/settings?key=realtime_canvas_config",
|
||||
@ -2242,6 +2292,51 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveLayerSplitConfig = async () => {
|
||||
try {
|
||||
if (layerSplitConfig.enabled) {
|
||||
if (!layerSplitConfig.replicateApiToken.trim()) {
|
||||
toast.error("启用图层拆分需要配置 Replicate API Token");
|
||||
return;
|
||||
}
|
||||
if (!layerSplitConfig.replicateModel.trim()) {
|
||||
toast.error("请填写 Replicate 模型");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: "layer_split_config",
|
||||
value: {
|
||||
enabled: layerSplitConfig.enabled,
|
||||
provider: "replicate",
|
||||
replicateApiToken: layerSplitConfig.replicateApiToken.trim(),
|
||||
replicateModel: layerSplitConfig.replicateModel.trim() || "qwen/qwen-image-layered",
|
||||
layers: Math.max(2, Math.min(12, Number(layerSplitConfig.layers) || 4)),
|
||||
resolution: Math.max(256, Math.min(1536, Number(layerSplitConfig.resolution) || 640)),
|
||||
trueCfgScale: Math.max(1, Math.min(12, Number(layerSplitConfig.trueCfgScale) || 4)),
|
||||
numInferenceSteps: Math.max(
|
||||
1,
|
||||
Math.min(80, Number(layerSplitConfig.numInferenceSteps) || 30)
|
||||
),
|
||||
cfgNormalize: layerSplitConfig.cfgNormalize,
|
||||
useEnPrompt: layerSplitConfig.useEnPrompt,
|
||||
negativePrompt: layerSplitConfig.negativePrompt.trim(),
|
||||
timeoutMs: Math.max(30000, Math.min(900000, Number(layerSplitConfig.timeoutMs) || 300000)),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("保存失败");
|
||||
toast.success("图层拆分API配置保存成功");
|
||||
} catch (error) {
|
||||
toast.error("保存图层拆分API配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
// --- 实时画布API配置逻辑 ---
|
||||
const handleSaveRealtimeCanvasConfig = async () => {
|
||||
try {
|
||||
@ -9141,6 +9236,220 @@ export function AdminSettingsClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Layers className="h-5 w-5" />
|
||||
图层拆分API配置
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
配置 Replicate 上的 Qwen-Image-Layered 模型,用于将单张图片拆分为多个可编辑图层并回写到无限画布。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>启用图层拆分功能</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
开启后,用户在无限画布中选中图片可使用“图层拆分”工具。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={layerSplitConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLayerSplitConfig({ ...layerSplitConfig, enabled: checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
|
||||
{layerSplitConfig.enabled ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitReplicateApiToken">
|
||||
Replicate API Token <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="layerSplitReplicateApiToken"
|
||||
type="password"
|
||||
value={layerSplitConfig.replicateApiToken}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({ ...layerSplitConfig, replicateApiToken: e.target.value })
|
||||
}
|
||||
placeholder="r8_xxxxxxxxxxxxx"
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitReplicateModel">Replicate 模型</Label>
|
||||
<Input
|
||||
id="layerSplitReplicateModel"
|
||||
value={layerSplitConfig.replicateModel}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({ ...layerSplitConfig, replicateModel: e.target.value })
|
||||
}
|
||||
placeholder="qwen/qwen-image-layered"
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitLayers">默认层数</Label>
|
||||
<Input
|
||||
id="layerSplitLayers"
|
||||
type="number"
|
||||
min={2}
|
||||
max={12}
|
||||
value={layerSplitConfig.layers}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({
|
||||
...layerSplitConfig,
|
||||
layers: Math.max(2, Math.min(12, Number(e.target.value) || 4)),
|
||||
})
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitResolution">分辨率</Label>
|
||||
<Input
|
||||
id="layerSplitResolution"
|
||||
type="number"
|
||||
min={256}
|
||||
max={1536}
|
||||
step={64}
|
||||
value={layerSplitConfig.resolution}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({
|
||||
...layerSplitConfig,
|
||||
resolution: Math.max(256, Math.min(1536, Number(e.target.value) || 640)),
|
||||
})
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitCfg">true_cfg_scale</Label>
|
||||
<Input
|
||||
id="layerSplitCfg"
|
||||
type="number"
|
||||
min={1}
|
||||
max={12}
|
||||
step={0.5}
|
||||
value={layerSplitConfig.trueCfgScale}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({
|
||||
...layerSplitConfig,
|
||||
trueCfgScale: Math.max(1, Math.min(12, Number(e.target.value) || 4)),
|
||||
})
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitSteps">推理步数</Label>
|
||||
<Input
|
||||
id="layerSplitSteps"
|
||||
type="number"
|
||||
min={1}
|
||||
max={80}
|
||||
value={layerSplitConfig.numInferenceSteps}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({
|
||||
...layerSplitConfig,
|
||||
numInferenceSteps: Math.max(1, Math.min(80, Number(e.target.value) || 30)),
|
||||
})
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">cfg_normalize</Label>
|
||||
<div className="text-xs text-muted-foreground">保持前景/背景透明边缘更稳定</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={layerSplitConfig.cfgNormalize}
|
||||
onCheckedChange={(checked) =>
|
||||
setLayerSplitConfig({ ...layerSplitConfig, cfgNormalize: checked })
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">use_en_prompt</Label>
|
||||
<div className="text-xs text-muted-foreground">使用英文提示增强兼容性</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={layerSplitConfig.useEnPrompt}
|
||||
onCheckedChange={(checked) =>
|
||||
setLayerSplitConfig({ ...layerSplitConfig, useEnPrompt: checked })
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitTimeout">超时时间(毫秒)</Label>
|
||||
<Input
|
||||
id="layerSplitTimeout"
|
||||
type="number"
|
||||
min={30000}
|
||||
max={900000}
|
||||
step={1000}
|
||||
value={layerSplitConfig.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({
|
||||
...layerSplitConfig,
|
||||
timeoutMs: Math.max(30000, Math.min(900000, Number(e.target.value) || 300000)),
|
||||
})
|
||||
}
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layerSplitNegativePrompt">负向提示词(可选)</Label>
|
||||
<Textarea
|
||||
id="layerSplitNegativePrompt"
|
||||
rows={3}
|
||||
value={layerSplitConfig.negativePrompt}
|
||||
onChange={(e) =>
|
||||
setLayerSplitConfig({ ...layerSplitConfig, negativePrompt: e.target.value })
|
||||
}
|
||||
placeholder="例如:text, watermark, logo, artifacts"
|
||||
disabled={!layerSplitConfig.enabled}
|
||||
/>
|
||||
</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">Qwen-Image-Layered 说明</div>
|
||||
<div className="text-xs text-blue-800 space-y-1">
|
||||
<p>• 用途:将单张图片拆成多个独立图层(RGBA layer)</p>
|
||||
<p>• 平台:Replicate 托管推理,无需自建 GPU 服务器</p>
|
||||
<p>• 默认模型:qwen/qwen-image-layered</p>
|
||||
<p>• 使用后会将每个图层转存到你的存储桶,并在无限画布中新增图片节点</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveLayerSplitConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存图层拆分配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 实时画布API配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
60
app/api/layer-split/route.ts
Normal file
60
app/api/layer-split/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { executeLayerSplitJob, LayerSplitServiceError } from "@/lib/ai/layer-split/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return NextResponse.json(
|
||||
{ error: "不支持的 Content-Type,请使用 application/json" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const imageUrl = String(body.imageUrl || body.image_url || "").trim();
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: "缺少 imageUrl 参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await executeLayerSplitJob({
|
||||
userId: session.user.id,
|
||||
imageUrl,
|
||||
prompt: String(body.prompt || "").trim() || undefined,
|
||||
chatId: String(body.chatId || "").trim() || null,
|
||||
messageId: String(body.messageId || "").trim() || null,
|
||||
sourceNodeId: String(body.sourceNodeId || "").trim() || null,
|
||||
skipGallerySave: body.skipGallerySave === true,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LayerSplitServiceError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
detail: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[LayerSplit API] 处理错误:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote, Scan } from "lucide-react";
|
||||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, MoreHorizontal, Scissors, Eraser, Sparkles, Expand, Maximize2, Box, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X, Type, StickyNote, Scan, Layers } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -1932,6 +1932,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
'extend': '扩图',
|
||||
'resize': '调尺',
|
||||
'angle': '视角',
|
||||
'layer-split': '图层拆分',
|
||||
'scene-fusion': '批量处理',
|
||||
'annotate': '标注',
|
||||
'text-edit': '改字',
|
||||
@ -1966,6 +1967,34 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
setResizeAnnotatorOpen(true);
|
||||
return;
|
||||
}
|
||||
if (action === "layer-split") {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await imageGenerationService.splitImageLayers(data.src);
|
||||
const layers = result.data?.layers || [];
|
||||
|
||||
if (!result.success || layers.length === 0) {
|
||||
toast.error(result.error || "图层拆分失败");
|
||||
return;
|
||||
}
|
||||
|
||||
handleLayerSplitComplete(
|
||||
layers.map((layer) => ({
|
||||
originalUrl: layer.originalUrl,
|
||||
title: layer.title,
|
||||
layerIndex: layer.layerIndex,
|
||||
totalLayers: layer.totalLayers,
|
||||
}))
|
||||
);
|
||||
toast.success(`图层拆分完成,已新增 ${layers.length} 个图层结果`);
|
||||
} catch (error) {
|
||||
console.error("图层拆分失败:", error);
|
||||
toast.error("图层拆分失败");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "box-cutout") {
|
||||
handleEnterBoxCutoutMode();
|
||||
return;
|
||||
@ -2152,6 +2181,58 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
});
|
||||
}, [addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width]);
|
||||
|
||||
const handleLayerSplitComplete = useCallback(
|
||||
(
|
||||
layers: Array<{
|
||||
originalUrl: string;
|
||||
title?: string;
|
||||
layerIndex: number;
|
||||
totalLayers: number;
|
||||
}>
|
||||
) => {
|
||||
const validLayers = layers.filter(
|
||||
(item) => typeof item?.originalUrl === "string" && item.originalUrl.trim().length > 0
|
||||
);
|
||||
|
||||
if (validLayers.length === 0) {
|
||||
throw new Error("没有可添加到画布的图层结果");
|
||||
}
|
||||
|
||||
const maxColumns = 3;
|
||||
const gap = 20;
|
||||
const baseX = node.position.x + node.size.width + gap;
|
||||
const baseY = node.position.y;
|
||||
|
||||
validLayers.forEach((layer, index) => {
|
||||
const column = index % maxColumns;
|
||||
const row = Math.floor(index / maxColumns);
|
||||
const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`;
|
||||
|
||||
addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: baseX + column * (node.size.width + gap),
|
||||
y: baseY + row * (node.size.height + gap),
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: layer.originalUrl,
|
||||
status: "completed",
|
||||
prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`,
|
||||
title: layerTitle,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
layerIndex: layer.layerIndex,
|
||||
totalLayers: layer.totalLayers,
|
||||
},
|
||||
},
|
||||
connections: [node.id],
|
||||
});
|
||||
});
|
||||
},
|
||||
[addNode, data.src, displayTitle, node.id, node.position.x, node.position.y, node.size.height, node.size.width]
|
||||
);
|
||||
|
||||
// 图片标注完成回调
|
||||
const handleAnnotateComplete = async (annotatedImageUrl: string) => {
|
||||
setAnnotatorOpen(false);
|
||||
@ -2760,6 +2841,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
备注
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('layer-split')}>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
图层拆分
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('resize')}>
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
调尺
|
||||
@ -2993,6 +3078,16 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
视角生成
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isProcessing}
|
||||
className="h-10 rounded-xl px-3 text-[13px] font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-white/10"
|
||||
onClick={() => handleAction('layer-split')}
|
||||
>
|
||||
<Layers className="mr-1.5 h-4 w-4" />
|
||||
图层拆分
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
659
lib/ai/layer-split/service.ts
Normal file
659
lib/ai/layer-split/service.ts
Normal file
@ -0,0 +1,659 @@
|
||||
import "server-only";
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { createMediaAssetRelation, getMediaAssetsByUserAndUrls, getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
const LAYER_SPLIT_CONFIG_KEY = "layer_split_config";
|
||||
const DEFAULT_REPLICATE_BASE_URL = "https://api.replicate.com/v1";
|
||||
const DEFAULT_LAYER_SPLIT_MODEL = "qwen/qwen-image-layered";
|
||||
const DEFAULT_LAYERS = 4;
|
||||
const DEFAULT_RESOLUTION = 640;
|
||||
const DEFAULT_TRUE_CFG_SCALE = 4;
|
||||
const DEFAULT_INFERENCE_STEPS = 30;
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
type LayerSplitConfig = {
|
||||
enabled?: boolean;
|
||||
provider?: "replicate";
|
||||
replicateApiToken?: string;
|
||||
replicateModel?: string;
|
||||
layers?: number;
|
||||
resolution?: number;
|
||||
trueCfgScale?: number;
|
||||
numInferenceSteps?: number;
|
||||
cfgNormalize?: boolean;
|
||||
useEnPrompt?: boolean;
|
||||
negativePrompt?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LayerSplitParams = {
|
||||
userId: string;
|
||||
imageUrl: string;
|
||||
prompt?: string;
|
||||
chatId?: string | null;
|
||||
messageId?: string | null;
|
||||
sourceNodeId?: string | null;
|
||||
skipGallerySave?: boolean;
|
||||
};
|
||||
|
||||
type ReplicatePredictionResponse = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
message?: string;
|
||||
urls?: {
|
||||
get?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LayerSplitResultItem = {
|
||||
originalUrl: string;
|
||||
thumbnailUrl: string;
|
||||
generatedImageId?: string | null;
|
||||
mediaAssetId?: string | null;
|
||||
title: string;
|
||||
layerIndex: number;
|
||||
totalLayers: number;
|
||||
};
|
||||
|
||||
export type ExecuteLayerSplitJobResult = {
|
||||
layers: LayerSplitResultItem[];
|
||||
provider: "replicate";
|
||||
model: string;
|
||||
processingTime: number;
|
||||
};
|
||||
|
||||
export class LayerSplitServiceError extends Error {
|
||||
status: number;
|
||||
detail?: string;
|
||||
|
||||
constructor(message: string, status = 500, detail?: string) {
|
||||
super(message);
|
||||
this.name = "LayerSplitServiceError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
function trimString(value: unknown) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function splitReplicateModelIdentifier(modelId: string) {
|
||||
const [owner, ...rest] = modelId.split("/").filter(Boolean);
|
||||
const name = rest.join("/");
|
||||
if (!owner || !name) {
|
||||
throw new LayerSplitServiceError(`无效的 Replicate 模型标识:${modelId}`, 400);
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
function normalizeLayerSplitConfig(value: unknown): Required<LayerSplitConfig> {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
provider: "replicate",
|
||||
replicateApiToken: trimString(raw.replicateApiToken),
|
||||
replicateModel: trimString(raw.replicateModel) || DEFAULT_LAYER_SPLIT_MODEL,
|
||||
layers: Math.max(2, Math.min(12, Math.round(Number(raw.layers) || DEFAULT_LAYERS))),
|
||||
resolution: Math.max(256, Math.min(1536, Math.round(Number(raw.resolution) || DEFAULT_RESOLUTION))),
|
||||
trueCfgScale: Math.max(1, Math.min(12, Number(raw.trueCfgScale) || DEFAULT_TRUE_CFG_SCALE)),
|
||||
numInferenceSteps: Math.max(
|
||||
1,
|
||||
Math.min(80, Math.round(Number(raw.numInferenceSteps) || DEFAULT_INFERENCE_STEPS))
|
||||
),
|
||||
cfgNormalize: raw.cfgNormalize !== false,
|
||||
useEnPrompt: raw.useEnPrompt !== false,
|
||||
negativePrompt: trimString(raw.negativePrompt),
|
||||
timeoutMs: Math.max(30_000, Math.min(15 * 60_000, Math.round(Number(raw.timeoutMs) || DEFAULT_TIMEOUT_MS))),
|
||||
};
|
||||
}
|
||||
|
||||
async function getValidatedLayerSplitConfig() {
|
||||
const setting = await getSystemSetting({ key: LAYER_SPLIT_CONFIG_KEY });
|
||||
const config = normalizeLayerSplitConfig(setting?.value);
|
||||
|
||||
if (!config.enabled) {
|
||||
throw new LayerSplitServiceError("图层拆分功能未启用,请先在本地AI设置中开启", 400);
|
||||
}
|
||||
|
||||
if (!config.replicateApiToken) {
|
||||
throw new LayerSplitServiceError("未配置 Replicate API Token,请先在本地AI设置中保存", 400);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function toReplicateErrorMessage(payload: any, fallback: string) {
|
||||
const message = trimString(payload?.detail || payload?.error || payload?.message);
|
||||
return message || fallback;
|
||||
}
|
||||
|
||||
function shouldRetryWithAlternativeInput(errorMessage: string) {
|
||||
const normalized = errorMessage.toLowerCase();
|
||||
return (
|
||||
normalized.includes("validation") ||
|
||||
normalized.includes("schema") ||
|
||||
normalized.includes("image") ||
|
||||
normalized.includes("input") ||
|
||||
normalized.includes("field")
|
||||
);
|
||||
}
|
||||
|
||||
async function createPrediction(params: {
|
||||
apiKey: string;
|
||||
modelId: string;
|
||||
input: Record<string, unknown>;
|
||||
}) {
|
||||
const { owner, name } = splitReplicateModelIdentifier(params.modelId);
|
||||
const endpoint = `${DEFAULT_REPLICATE_BASE_URL}/models/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/predictions`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "wait=5",
|
||||
},
|
||||
body: JSON.stringify({ input: params.input }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as ReplicatePredictionResponse | null;
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
async function pollPrediction(params: {
|
||||
apiKey: string;
|
||||
taskId?: string;
|
||||
pollingUrl?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<ReplicatePredictionResponse> {
|
||||
const deadline = Date.now() + params.timeoutMs;
|
||||
let lastMessage = "";
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const taskId = trimString(params.taskId);
|
||||
const url =
|
||||
trimString(params.pollingUrl) ||
|
||||
`${DEFAULT_REPLICATE_BASE_URL}/predictions/${encodeURIComponent(taskId)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as ReplicatePredictionResponse | null;
|
||||
const status = trimString(payload?.status).toLowerCase();
|
||||
|
||||
if (status) {
|
||||
lastMessage = status;
|
||||
}
|
||||
|
||||
if (response.ok && status === "succeeded") {
|
||||
return payload || {};
|
||||
}
|
||||
|
||||
if (status === "failed" || status === "canceled") {
|
||||
throw new LayerSplitServiceError(
|
||||
"图层拆分任务失败",
|
||||
502,
|
||||
toReplicateErrorMessage(payload, "Replicate 任务执行失败")
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new LayerSplitServiceError(
|
||||
`图层拆分轮询失败(${response.status})`,
|
||||
502,
|
||||
toReplicateErrorMessage(payload, `Replicate 轮询失败(${response.status})`)
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
}
|
||||
|
||||
throw new LayerSplitServiceError(
|
||||
"图层拆分任务超时",
|
||||
504,
|
||||
`Replicate 轮询超时${lastMessage ? `(${lastMessage})` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
function collectArtifactStrings(value: unknown, bucket: string[] = []): string[] {
|
||||
if (typeof value === "string") {
|
||||
const next = trimString(value);
|
||||
if (next) bucket.push(next);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectArtifactStrings(item, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (
|
||||
key === "url" ||
|
||||
key === "href" ||
|
||||
key === "download" ||
|
||||
key === "file" ||
|
||||
key === "image" ||
|
||||
key === "get" ||
|
||||
key === "output" ||
|
||||
key === "outputs" ||
|
||||
key === "layers" ||
|
||||
key === "files" ||
|
||||
key === "result"
|
||||
) {
|
||||
collectArtifactStrings(item, bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[]) {
|
||||
return Array.from(new Set(values.map((value) => trimString(value)).filter(Boolean)));
|
||||
}
|
||||
|
||||
function extFromFilename(filename: string): string {
|
||||
const clean = trimString(filename).split("?")[0];
|
||||
const ext = clean.split(".").pop()?.toLowerCase() || "";
|
||||
return ext;
|
||||
}
|
||||
|
||||
function deriveFilenameFromUrl(url: string, fallback: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const candidate = parsed.pathname.split("/").pop() || "";
|
||||
return candidate || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function detectMimeType(buffer: Buffer, fallback?: string): string {
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
return "image/png";
|
||||
}
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
|
||||
return "image/webp";
|
||||
}
|
||||
if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "PK\u0003\u0004") {
|
||||
return "application/zip";
|
||||
}
|
||||
if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "8BPS") {
|
||||
return "image/vnd.adobe.photoshop";
|
||||
}
|
||||
|
||||
const normalizedFallback = trimString(fallback).toLowerCase();
|
||||
if (normalizedFallback.startsWith("image/")) return normalizedFallback;
|
||||
if (normalizedFallback === "application/zip") return normalizedFallback;
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function extFromMimeType(mimeType: string) {
|
||||
if (mimeType === "image/png") return "png";
|
||||
if (mimeType === "image/jpeg") return "jpg";
|
||||
if (mimeType === "image/webp") return "webp";
|
||||
if (mimeType === "application/zip") return "zip";
|
||||
if (mimeType === "image/vnd.adobe.photoshop") return "psd";
|
||||
return "bin";
|
||||
}
|
||||
|
||||
async function fetchArtifact(url: string): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "AI-Chatbot-LayerSplit/1.0",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new LayerSplitServiceError(
|
||||
`下载图层拆分结果失败(${response.status})`,
|
||||
502,
|
||||
`${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = trimString(response.headers.get("content-type"));
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const filename = deriveFilenameFromUrl(url, `artifact.${extFromMimeType(contentType || "application/octet-stream")}`);
|
||||
const mimeType = detectMimeType(buffer, contentType);
|
||||
return { buffer, mimeType, filename };
|
||||
}
|
||||
|
||||
async function extractLayerBuffersFromZip(zipBuffer: Buffer): Promise<Array<{ buffer: Buffer; filename: string; mimeType: string }>> {
|
||||
const zip = await JSZip.loadAsync(zipBuffer);
|
||||
const entries: Array<{ buffer: Buffer; filename: string; mimeType: string }> = [];
|
||||
|
||||
for (const [name, file] of Object.entries(zip.files)) {
|
||||
if (file.dir) continue;
|
||||
const ext = extFromFilename(name);
|
||||
if (!["png", "jpg", "jpeg", "webp"].includes(ext)) continue;
|
||||
const fileBuffer = await file.async("nodebuffer");
|
||||
const mimeType = detectMimeType(fileBuffer, ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg");
|
||||
if (!mimeType.startsWith("image/")) continue;
|
||||
entries.push({
|
||||
buffer: fileBuffer,
|
||||
filename: name.split("/").pop() || `layer-${entries.length + 1}.${ext || "png"}`,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveLayerArtifacts(payload: ReplicatePredictionResponse): Promise<Array<{ buffer: Buffer; filename: string; mimeType: string }>> {
|
||||
const artifacts = dedupeStrings(collectArtifactStrings(payload?.output ?? payload));
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
throw new LayerSplitServiceError("图层拆分未返回任何可用结果", 502);
|
||||
}
|
||||
|
||||
const imageEntries: Array<{ buffer: Buffer; filename: string; mimeType: string }> = [];
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
if (artifact.startsWith("data:")) {
|
||||
const match = artifact.match(/^data:([^;,]+)?;base64,(.+)$/i);
|
||||
if (!match) continue;
|
||||
const mimeType = detectMimeType(Buffer.from(match[2], "base64"), match[1] || "image/png");
|
||||
const buffer = Buffer.from(match[2], "base64");
|
||||
if (mimeType.startsWith("image/")) {
|
||||
imageEntries.push({
|
||||
buffer,
|
||||
mimeType,
|
||||
filename: `layer-${imageEntries.length + 1}.${extFromMimeType(mimeType)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (mimeType === "application/zip") {
|
||||
const zipped = await extractLayerBuffersFromZip(buffer);
|
||||
imageEntries.push(...zipped);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(artifact)) continue;
|
||||
|
||||
const downloaded = await fetchArtifact(artifact);
|
||||
if (downloaded.mimeType.startsWith("image/")) {
|
||||
imageEntries.push(downloaded);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (downloaded.mimeType === "application/zip") {
|
||||
const zipped = await extractLayerBuffersFromZip(downloaded.buffer);
|
||||
imageEntries.push(...zipped);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
throw new LayerSplitServiceError("图层拆分返回了结果文件,但未找到可回画布的图片图层", 502);
|
||||
}
|
||||
|
||||
return imageEntries;
|
||||
}
|
||||
|
||||
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
|
||||
try {
|
||||
const sharp = await import("sharp");
|
||||
const pipeline = sharp.default(buffer).resize(500, null, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
|
||||
if (mimeType === "image/jpeg") {
|
||||
return await pipeline.jpeg({ quality: 82 }).toBuffer();
|
||||
}
|
||||
if (mimeType === "image/webp") {
|
||||
return await pipeline.webp({ quality: 82 }).toBuffer();
|
||||
}
|
||||
return await pipeline.png({ compressionLevel: 8 }).toBuffer();
|
||||
} catch (error) {
|
||||
console.error("[LayerSplit] Failed to create thumbnail:", error);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBufferPair(buffer: Buffer, mimeType: string, prefix: string) {
|
||||
const ext = extFromMimeType(mimeType);
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
const thumbFilename = `${prefix}-thumb-${timestamp}-${random}.${ext}`;
|
||||
const origFilename = `${prefix}-orig-${timestamp}-${random}.${ext}`;
|
||||
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
|
||||
|
||||
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;
|
||||
|
||||
const [thumbnailResult, originalResult] = await Promise.all([
|
||||
uploadFile(thumbFilename, thumbnailArrayBuffer),
|
||||
uploadFile(origFilename, originalArrayBuffer),
|
||||
]);
|
||||
|
||||
return {
|
||||
thumbnail: thumbnailResult.url,
|
||||
original: originalResult.url,
|
||||
};
|
||||
}
|
||||
|
||||
async function runReplicateLayerSplit(params: {
|
||||
apiKey: string;
|
||||
modelId: string;
|
||||
imageUrl: string;
|
||||
config: Required<LayerSplitConfig>;
|
||||
prompt?: string;
|
||||
}): Promise<ReplicatePredictionResponse> {
|
||||
const baseInput: Record<string, unknown> = {
|
||||
max_num_layers: params.config.layers,
|
||||
resolution: params.config.resolution,
|
||||
true_cfg_scale: params.config.trueCfgScale,
|
||||
num_inference_steps: params.config.numInferenceSteps,
|
||||
cfg_normalize: params.config.cfgNormalize,
|
||||
use_en_prompt: params.config.useEnPrompt,
|
||||
};
|
||||
|
||||
const negativePrompt = trimString(params.config.negativePrompt);
|
||||
if (negativePrompt) {
|
||||
baseInput.negative_prompt = negativePrompt;
|
||||
}
|
||||
|
||||
const prompt = trimString(params.prompt);
|
||||
if (prompt) {
|
||||
baseInput.prompt = prompt;
|
||||
}
|
||||
|
||||
const candidateInputs = [
|
||||
{ ...baseInput, image: params.imageUrl },
|
||||
{ ...baseInput, input_image: params.imageUrl },
|
||||
{ ...baseInput, images: [params.imageUrl] },
|
||||
];
|
||||
|
||||
let lastPayload: ReplicatePredictionResponse | null = null;
|
||||
let lastError = "";
|
||||
|
||||
for (let index = 0; index < candidateInputs.length; index += 1) {
|
||||
const { response, payload } = await createPrediction({
|
||||
apiKey: params.apiKey,
|
||||
modelId: params.modelId,
|
||||
input: candidateInputs[index],
|
||||
});
|
||||
|
||||
lastPayload = payload;
|
||||
|
||||
if (
|
||||
response.ok &&
|
||||
(trimString(payload?.status) === "succeeded" || trimString(payload?.id) || trimString(payload?.urls?.get))
|
||||
) {
|
||||
if (trimString(payload?.status).toLowerCase() === "succeeded") {
|
||||
return payload || {};
|
||||
}
|
||||
return await pollPrediction({
|
||||
apiKey: params.apiKey,
|
||||
taskId: trimString(payload?.id),
|
||||
pollingUrl: trimString(payload?.urls?.get),
|
||||
timeoutMs: params.config.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMessage = toReplicateErrorMessage(payload, `Replicate 请求失败(${response.status})`);
|
||||
lastError = errorMessage;
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new LayerSplitServiceError("Replicate API Token 无效或无权限", 401, errorMessage);
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new LayerSplitServiceError(`Replicate 模型不存在:${params.modelId}`, 400, errorMessage);
|
||||
}
|
||||
|
||||
if (index < candidateInputs.length - 1 && shouldRetryWithAlternativeInput(errorMessage)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new LayerSplitServiceError(
|
||||
"图层拆分请求失败",
|
||||
502,
|
||||
lastError || toReplicateErrorMessage(lastPayload || {}, "Replicate 请求失败")
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeLayerSplitJob(params: LayerSplitParams): Promise<ExecuteLayerSplitJobResult> {
|
||||
const startedAt = Date.now();
|
||||
const config = await getValidatedLayerSplitConfig();
|
||||
|
||||
const payload = await runReplicateLayerSplit({
|
||||
apiKey: config.replicateApiToken,
|
||||
modelId: config.replicateModel,
|
||||
imageUrl: params.imageUrl,
|
||||
config,
|
||||
prompt: params.prompt,
|
||||
});
|
||||
|
||||
const layerBuffers = await resolveLayerArtifacts(payload);
|
||||
const totalLayers = layerBuffers.length;
|
||||
const sourceAssets = await getMediaAssetsByUserAndUrls({
|
||||
userId: params.userId,
|
||||
urls: [params.imageUrl],
|
||||
}).catch(() => []);
|
||||
|
||||
const results: LayerSplitResultItem[] = [];
|
||||
|
||||
for (let index = 0; index < layerBuffers.length; index += 1) {
|
||||
const layer = layerBuffers[index];
|
||||
const uploadResult = await uploadBufferPair(layer.buffer, layer.mimeType, `layer-split-${index + 1}`);
|
||||
let generatedImageId: string | null = null;
|
||||
let mediaAssetId: string | null = null;
|
||||
|
||||
if (!params.skipGallerySave) {
|
||||
const [savedImage] = await db
|
||||
.insert(generatedImage)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId || null,
|
||||
messageId: params.messageId || null,
|
||||
thumbnailUrl: uploadResult.thumbnail,
|
||||
originalUrl: uploadResult.original,
|
||||
prompt: params.prompt || `图层拆分 ${index + 1}/${totalLayers}`,
|
||||
modelId: config.replicateModel,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning({ id: generatedImage.id });
|
||||
generatedImageId = savedImage?.id || null;
|
||||
|
||||
const media = await upsertMediaAsset({
|
||||
userId: params.userId,
|
||||
chatId: params.chatId || null,
|
||||
mediaType: "image",
|
||||
sourceType: "edited",
|
||||
status: "ready",
|
||||
originalUrl: uploadResult.original,
|
||||
previewUrl: uploadResult.original,
|
||||
thumbnailUrl: uploadResult.thumbnail,
|
||||
displayName: `图层拆分 ${index + 1}/${totalLayers}`.slice(0, 80),
|
||||
prompt: params.prompt || `图层拆分 ${index + 1}/${totalLayers}`,
|
||||
modelId: config.replicateModel,
|
||||
sourceMessageId: params.messageId || null,
|
||||
sourceTable: "GeneratedImage",
|
||||
sourceRowId: generatedImageId,
|
||||
sourceNodeId: params.sourceNodeId || null,
|
||||
primaryParentAssetId: sourceAssets[0]?.id ?? null,
|
||||
metadata: {
|
||||
layerIndex: index + 1,
|
||||
totalLayers,
|
||||
provider: "replicate",
|
||||
model: config.replicateModel,
|
||||
sourceImageUrl: params.imageUrl,
|
||||
sourceFilename: layer.filename,
|
||||
},
|
||||
});
|
||||
mediaAssetId = media?.id || null;
|
||||
|
||||
if (media && sourceAssets.length > 0) {
|
||||
for (const sourceAsset of sourceAssets) {
|
||||
await createMediaAssetRelation({
|
||||
userId: params.userId,
|
||||
fromAssetId: sourceAsset.id,
|
||||
toAssetId: media.id,
|
||||
relationType: "edited_from",
|
||||
chatId: params.chatId || null,
|
||||
sourceMessageId: params.messageId || null,
|
||||
sourceNodeId: params.sourceNodeId || null,
|
||||
metadata: {
|
||||
tool: "layer_split",
|
||||
layerIndex: index + 1,
|
||||
totalLayers,
|
||||
},
|
||||
}).catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
originalUrl: uploadResult.original,
|
||||
thumbnailUrl: uploadResult.thumbnail,
|
||||
generatedImageId,
|
||||
mediaAssetId,
|
||||
title: `图层${index + 1}`,
|
||||
layerIndex: index + 1,
|
||||
totalLayers,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
layers: results,
|
||||
provider: "replicate",
|
||||
model: config.replicateModel,
|
||||
processingTime: (Date.now() - startedAt) / 1000,
|
||||
};
|
||||
}
|
||||
@ -308,6 +308,43 @@ export const imageGenerationService = {
|
||||
}
|
||||
},
|
||||
|
||||
async splitImageLayers(imageUrl: string): Promise<
|
||||
ApiResponse<{
|
||||
layers: Array<{
|
||||
originalUrl: string;
|
||||
thumbnailUrl: string;
|
||||
title: string;
|
||||
layerIndex: number;
|
||||
totalLayers: number;
|
||||
}>;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const response = await fetch('/api/layer-split', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.layers)) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
layers: data.layers,
|
||||
model: data.model,
|
||||
provider: data.provider,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: false, error: data.error || '图层拆分失败' };
|
||||
} catch (error) {
|
||||
console.error('[Canvas] 图层拆分失败:', error);
|
||||
return { success: false, error: '图层拆分失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 尺寸调整 - 通过 URL 下载图片,调整大小后上传到 S3
|
||||
async resizeImage(imageUrl: string, width: number, height: number): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
|
||||
@ -175,7 +175,7 @@ export interface Attachment {
|
||||
|
||||
// 图片编辑操作
|
||||
export interface ImageEditAction {
|
||||
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit' | 'box-cutout';
|
||||
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate' | 'text-edit' | 'box-cutout' | 'layer-split';
|
||||
timestamp: Date;
|
||||
params?: Record<string, any>;
|
||||
resultUrl?: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user