From ade5344163efd5b3602beba19d4d0d6dfbef5095 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 29 Mar 2026 14:13:37 +0800 Subject: [PATCH] feat: add layer split import to realtime canvas --- components/canvas/RealtimeCanvasDialog.tsx | 221 ++++++++++++++++++--- lib/ai/layer-split/service.ts | 14 +- 2 files changed, 207 insertions(+), 28 deletions(-) diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index cda3014a6..a83cb1f45 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -53,6 +53,7 @@ import { } from "@/components/ui/tooltip"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; +import { imageGenerationService } from "@/services/canvas"; type RealtimeConfig = { transport: { @@ -119,6 +120,24 @@ type SavedRealtimeProject = { previewUrl?: string; }; +type LayerSplitBounds = { + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + coverage: number; +}; + +type LayerSplitItem = { + originalUrl: string; + title?: string; + layerIndex: number; + totalLayers: number; + bounds?: LayerSplitBounds | null; +}; + type RealtimeCanvasDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -138,6 +157,33 @@ function cloneImageLayers(layers: ImageLayer[]) { return layers.map((layer) => ({ ...layer })); } +function stripFileExtension(filename: string) { + const trimmed = String(filename || "").trim(); + return trimmed.replace(/\.[^.]+$/, "") || trimmed || "图层"; +} + +function isFullFrameSplitLayer(bounds?: LayerSplitBounds | null) { + if (!bounds) return false; + return ( + bounds.coverage >= 0.92 && + bounds.width >= bounds.originalWidth - 2 && + bounds.height >= bounds.originalHeight - 2 + ); +} + +function orderLayerSplitItems(layers: T[]) { + const ordered = [...layers].sort( + (left, right) => (left.layerIndex || 0) - (right.layerIndex || 0) + ); + const backgroundLayers = ordered.filter((layer) => + isFullFrameSplitLayer(layer.bounds) + ); + const subjectLayers = ordered.filter( + (layer) => !isFullFrameSplitLayer(layer.bounds) + ); + return [...backgroundLayers, ...subjectLayers]; +} + function getResizeCursor(handle: LayerResizeHandle) { return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize"; } @@ -753,6 +799,7 @@ export function RealtimeCanvasDialog({ const historyIndexRef = useRef(-1); const cursorRef = useRef(null); const uploadInputRef = useRef(null); + const splitUploadInputRef = useRef(null); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); @@ -767,6 +814,7 @@ export function RealtimeCanvasDialog({ canvasBackgroundRef.current ); const [imageLayers, setImageLayers] = useState([]); + const [isLayerSplitImporting, setIsLayerSplitImporting] = useState(false); const [selectedLayerId, setSelectedLayerId] = useState(DRAW_LAYER_ID); const [brushSize, setBrushSize] = useState(6); const [savedProjects, setSavedProjects] = useState( @@ -987,13 +1035,36 @@ export function RealtimeCanvasDialog({ }, [drawImageLayersToContext]); const createPlacedLayer = useCallback( - async (params: { src: string; name: string }) => { + async (params: { + src: string; + name: string; + placement?: { + x: number; + y: number; + width: number; + height: number; + }; + }) => { const image = await loadRenderableImage(params.src); const canvas = canvasRef.current; if (!canvas) { throw new Error("画布未初始化"); } + if (params.placement) { + return { + id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: params.name, + src: params.src, + x: params.placement.x, + y: params.placement.y, + width: params.placement.width, + height: params.placement.height, + baseWidth: params.placement.width, + baseHeight: params.placement.height, + } satisfies ImageLayer; + } + const rect = canvas.getBoundingClientRect(); const canvasWidth = Math.max(1, rect.width); const canvasHeight = Math.max(1, rect.height); @@ -1575,6 +1646,25 @@ export function RealtimeCanvasDialog({ [markCanvasDirty, saveHistory, scheduleFlush] ); + const appendLayerBatch = useCallback( + (nextLayerBatch: ImageLayer[]) => { + if (nextLayerBatch.length === 0) return 0; + + const nextLayers = [...imageLayersRef.current, ...nextLayerBatch]; + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + const newestLayer = nextLayerBatch[nextLayerBatch.length - 1]; + if (newestLayer) { + setSelectedLayerId(newestLayer.id); + } + saveHistory(nextLayers); + markCanvasDirty(null); + scheduleFlush(true); + return nextLayerBatch.length; + }, + [markCanvasDirty, saveHistory, scheduleFlush] + ); + const importFilesAsLayers = useCallback( async (files: File[]) => { if (files.length === 0) return 0; @@ -1592,28 +1682,61 @@ export function RealtimeCanvasDialog({ }); nextLayerBatch.push(layer); } - - const nextLayers = [...imageLayersRef.current, ...nextLayerBatch]; - imageLayersRef.current = nextLayers; - setImageLayers(nextLayers); - const newestLayer = nextLayerBatch[nextLayerBatch.length - 1]; - if (newestLayer) { - setSelectedLayerId(newestLayer.id); - } - saveHistory(nextLayers); - markCanvasDirty(null); - scheduleFlush(true); - return nextLayerBatch.length; + return appendLayerBatch(nextLayerBatch); }, [ + appendLayerBatch, createPlacedLayer, fileToDataUrl, - markCanvasDirty, - saveHistory, - scheduleFlush, ] ); + const importFilesAsSplitLayers = useCallback( + async (files: File[]) => { + if (files.length === 0) return 0; + const invalidFile = files.find((file) => !file.type.startsWith("image/")); + if (invalidFile) { + throw new Error("请上传图片文件"); + } + + const nextLayerBatch: ImageLayer[] = []; + + for (const file of files) { + const dataUrl = await fileToDataUrl(file); + const referenceLayer = await createPlacedLayer({ + src: dataUrl, + name: file.name || "图层拆分参考图", + }); + const splitResult = await imageGenerationService.splitImageLayers(dataUrl); + const splitLayers = splitResult.data?.layers || []; + + if (!splitResult.success || splitLayers.length === 0) { + throw new Error(splitResult.error || `${file.name || "图片"} 图层拆分失败`); + } + + const orderedLayers = orderLayerSplitItems(splitLayers); + const baseName = stripFileExtension(file.name || "图层拆分"); + + for (const layer of orderedLayers) { + const placedLayer = await createPlacedLayer({ + src: layer.originalUrl, + name: `${baseName}-${layer.title || `图层${layer.layerIndex}`}`.slice(0, 48), + placement: { + x: referenceLayer.x, + y: referenceLayer.y, + width: referenceLayer.width, + height: referenceLayer.height, + }, + }); + nextLayerBatch.push(placedLayer); + } + } + + return appendLayerBatch(nextLayerBatch); + }, + [appendLayerBatch, createPlacedLayer, fileToDataUrl] + ); + const triggerGenerate = useCallback(() => { if (!hasRenderableInput(true)) { setStatus("idle"); @@ -2568,6 +2691,29 @@ export function RealtimeCanvasDialog({ [importFilesAsLayers] ); + const handleLayerSplitUpload = useCallback( + async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + if (files.length === 0) return; + + setIsLayerSplitImporting(true); + try { + const importedCount = await importFilesAsSplitLayers(files); + toast.success( + importedCount === 1 + ? "图层拆分导入完成" + : `图层拆分完成,已导入 ${importedCount} 个图层` + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "图层拆分导入失败"); + } finally { + setIsLayerSplitImporting(false); + event.currentTarget.value = ""; + } + }, + [importFilesAsSplitLayers] + ); + const handleDragEnter = useCallback( (event: React.DragEvent) => { if (!Array.from(event.dataTransfer?.types || []).includes("Files")) @@ -3110,15 +3256,32 @@ export function RealtimeCanvasDialog({ )) )} - +
+ + +
@@ -3169,6 +3332,14 @@ export function RealtimeCanvasDialog({ ref={uploadInputRef} type="file" /> +
diff --git a/lib/ai/layer-split/service.ts b/lib/ai/layer-split/service.ts index 8060b7244..b5b7c7fb8 100644 --- a/lib/ai/layer-split/service.ts +++ b/lib/ai/layer-split/service.ts @@ -4,6 +4,7 @@ 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 { ensurePublicImageUrl } from "@/lib/realtime/image-input"; import { uploadFile } from "@/lib/storage/service"; const LAYER_SPLIT_CONFIG_KEY = "layer_split_config"; @@ -614,11 +615,15 @@ async function runReplicateLayerSplit(params: { export async function executeLayerSplitJob(params: LayerSplitParams): Promise { const startedAt = Date.now(); const config = await getValidatedLayerSplitConfig(); + const normalizedImageUrl = await ensurePublicImageUrl({ + imageUrl: params.imageUrl, + prefix: "layer-split-input", + }); const payload = await runReplicateLayerSplit({ apiKey: config.replicateApiToken, modelId: config.replicateModel, - imageUrl: params.imageUrl, + imageUrl: normalizedImageUrl, config, prompt: params.prompt, }); @@ -627,7 +632,10 @@ export async function executeLayerSplitJob(params: LayerSplitParams): Promise []); const results: LayerSplitResultItem[] = []; @@ -677,7 +685,7 @@ export async function executeLayerSplitJob(params: LayerSplitParams): Promise