feat: add layer split import to realtime canvas

This commit is contained in:
Your Name 2026-03-29 14:13:37 +08:00
parent 9a1c7a8f3a
commit ade5344163
2 changed files with 207 additions and 28 deletions

View File

@ -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<T extends LayerSplitItem>(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<HTMLDivElement>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const splitUploadInputRef = useRef<HTMLInputElement | null>(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
@ -767,6 +814,7 @@ export function RealtimeCanvasDialog({
canvasBackgroundRef.current
);
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
const [isLayerSplitImporting, setIsLayerSplitImporting] = useState(false);
const [selectedLayerId, setSelectedLayerId] = useState<string>(DRAW_LAYER_ID);
const [brushSize, setBrushSize] = useState(6);
const [savedProjects, setSavedProjects] = useState<SavedRealtimeProject[]>(
@ -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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
if (!Array.from(event.dataTransfer?.types || []).includes("Files"))
@ -3110,15 +3256,32 @@ export function RealtimeCanvasDialog({
))
)}
</div>
<Button
className="mt-2 h-10 rounded-xl bg-background px-0 hover:bg-muted"
onClick={() => uploadInputRef.current?.click()}
title="添加图片图层"
type="button"
variant="outline"
>
<Upload className="h-4 w-4" />
</Button>
<div className="mt-2 flex flex-col gap-2">
<Button
className="h-10 rounded-xl bg-background px-0 hover:bg-muted"
disabled={isLayerSplitImporting}
onClick={() => splitUploadInputRef.current?.click()}
title="图层拆分导入"
type="button"
variant="outline"
>
{isLayerSplitImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Layers3 className="h-4 w-4" />
)}
</Button>
<Button
className="h-10 rounded-xl bg-background px-0 hover:bg-muted"
disabled={isLayerSplitImporting}
onClick={() => uploadInputRef.current?.click()}
title="添加图片图层"
type="button"
variant="outline"
>
<Upload className="h-4 w-4" />
</Button>
</div>
</div>
</div>
@ -3169,6 +3332,14 @@ export function RealtimeCanvasDialog({
ref={uploadInputRef}
type="file"
/>
<input
accept="image/*"
className="hidden"
multiple
onChange={handleLayerSplitUpload}
ref={splitUploadInputRef}
type="file"
/>
<div className="-translate-x-1/2 pointer-events-none absolute bottom-4 left-1/2 z-30 flex w-[min(600px,calc(100vw-2rem))] flex-col items-center gap-2.5">
<TooltipProvider delayDuration={120}>
<div className="pointer-events-auto flex flex-wrap items-center justify-center gap-1 rounded-[24px] border border-border/40 bg-background/80 p-1.5 shadow-xl backdrop-blur-md">

View File

@ -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<ExecuteLayerSplitJobResult> {
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<Ex
const totalLayers = layerBuffers.length;
const sourceAssets = await getMediaAssetsByUserAndUrls({
userId: params.userId,
urls: [params.imageUrl],
urls:
normalizedImageUrl === params.imageUrl
? [params.imageUrl]
: [params.imageUrl, normalizedImageUrl],
}).catch(() => []);
const results: LayerSplitResultItem[] = [];
@ -677,7 +685,7 @@ export async function executeLayerSplitJob(params: LayerSplitParams): Promise<Ex
totalLayers,
provider: "replicate",
model: config.replicateModel,
sourceImageUrl: params.imageUrl,
sourceImageUrl: normalizedImageUrl,
sourceFilename: layer.filename,
bounds: visibleBounds,
},