feat: add canvas layer split workflow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-24 02:07:45 +08:00
parent 14b6b3ec10
commit f1ffeed9f2
6 changed files with 1162 additions and 2 deletions

View File

@ -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>

View 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 }
);
}
}

View File

@ -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>

View 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,
};
}

View File

@ -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 {

View File

@ -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;