feat: add canvas layer split workflow
This commit is contained in:
parent
14b6b3ec10
commit
f1ffeed9f2
@ -59,6 +59,7 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
Expand,
|
Expand,
|
||||||
Move3D,
|
Move3D,
|
||||||
|
Layers,
|
||||||
Upload,
|
Upload,
|
||||||
Video,
|
Video,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -959,6 +960,21 @@ export function AdminSettingsClient() {
|
|||||||
timeout: 120,
|
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配置状态
|
// 实时画布API配置状态
|
||||||
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -1134,6 +1150,40 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
}, [angleEditConfigData]);
|
}, [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配置
|
// 加载实时画布API配置
|
||||||
const { data: realtimeCanvasConfigData } = useSWR(
|
const { data: realtimeCanvasConfigData } = useSWR(
|
||||||
"/api/admin/settings?key=realtime_canvas_config",
|
"/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配置逻辑 ---
|
// --- 实时画布API配置逻辑 ---
|
||||||
const handleSaveRealtimeCanvasConfig = async () => {
|
const handleSaveRealtimeCanvasConfig = async () => {
|
||||||
try {
|
try {
|
||||||
@ -9141,6 +9236,220 @@ export function AdminSettingsClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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配置 */}
|
{/* 实时画布API配置 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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 React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -1932,6 +1932,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
'extend': '扩图',
|
'extend': '扩图',
|
||||||
'resize': '调尺',
|
'resize': '调尺',
|
||||||
'angle': '视角',
|
'angle': '视角',
|
||||||
|
'layer-split': '图层拆分',
|
||||||
'scene-fusion': '批量处理',
|
'scene-fusion': '批量处理',
|
||||||
'annotate': '标注',
|
'annotate': '标注',
|
||||||
'text-edit': '改字',
|
'text-edit': '改字',
|
||||||
@ -1966,6 +1967,34 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
setResizeAnnotatorOpen(true);
|
setResizeAnnotatorOpen(true);
|
||||||
return;
|
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") {
|
if (action === "box-cutout") {
|
||||||
handleEnterBoxCutoutMode();
|
handleEnterBoxCutoutMode();
|
||||||
return;
|
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]);
|
}, [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) => {
|
const handleAnnotateComplete = async (annotatedImageUrl: string) => {
|
||||||
setAnnotatorOpen(false);
|
setAnnotatorOpen(false);
|
||||||
@ -2760,6 +2841,10 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
<StickyNote className="h-4 w-4 mr-2" />
|
<StickyNote className="h-4 w-4 mr-2" />
|
||||||
备注
|
备注
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAction('layer-split')}>
|
||||||
|
<Layers className="h-4 w-4 mr-2" />
|
||||||
|
图层拆分
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleAction('resize')}>
|
<DropdownMenuItem onClick={() => handleAction('resize')}>
|
||||||
<Maximize2 className="h-4 w-4 mr-2" />
|
<Maximize2 className="h-4 w-4 mr-2" />
|
||||||
调尺
|
调尺
|
||||||
@ -2993,6 +3078,16 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
视角生成
|
视角生成
|
||||||
</Button>
|
</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" />
|
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||||
|
|
||||||
<DropdownMenu>
|
<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
|
// 尺寸调整 - 通过 URL 下载图片,调整大小后上传到 S3
|
||||||
async resizeImage(imageUrl: string, width: number, height: number): Promise<ApiResponse<{ url: string }>> {
|
async resizeImage(imageUrl: string, width: number, height: number): Promise<ApiResponse<{ url: string }>> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -175,7 +175,7 @@ export interface Attachment {
|
|||||||
|
|
||||||
// 图片编辑操作
|
// 图片编辑操作
|
||||||
export interface ImageEditAction {
|
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;
|
timestamp: Date;
|
||||||
params?: Record<string, any>;
|
params?: Record<string, any>;
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user