feat: add layer split import to realtime canvas
This commit is contained in:
parent
9a1c7a8f3a
commit
ade5344163
@ -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">
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user