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";
|
} from "@/components/ui/tooltip";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
|
|
||||||
type RealtimeConfig = {
|
type RealtimeConfig = {
|
||||||
transport: {
|
transport: {
|
||||||
@ -119,6 +120,24 @@ type SavedRealtimeProject = {
|
|||||||
previewUrl?: string;
|
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 = {
|
type RealtimeCanvasDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@ -138,6 +157,33 @@ function cloneImageLayers(layers: ImageLayer[]) {
|
|||||||
return layers.map((layer) => ({ ...layer }));
|
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) {
|
function getResizeCursor(handle: LayerResizeHandle) {
|
||||||
return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
|
return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
|
||||||
}
|
}
|
||||||
@ -753,6 +799,7 @@ export function RealtimeCanvasDialog({
|
|||||||
const historyIndexRef = useRef(-1);
|
const historyIndexRef = useRef(-1);
|
||||||
const cursorRef = useRef<HTMLDivElement>(null);
|
const cursorRef = useRef<HTMLDivElement>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const splitUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [canUndo, setCanUndo] = useState(false);
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
const [canRedo, setCanRedo] = useState(false);
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
|
||||||
@ -767,6 +814,7 @@ export function RealtimeCanvasDialog({
|
|||||||
canvasBackgroundRef.current
|
canvasBackgroundRef.current
|
||||||
);
|
);
|
||||||
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
|
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
|
||||||
|
const [isLayerSplitImporting, setIsLayerSplitImporting] = useState(false);
|
||||||
const [selectedLayerId, setSelectedLayerId] = useState<string>(DRAW_LAYER_ID);
|
const [selectedLayerId, setSelectedLayerId] = useState<string>(DRAW_LAYER_ID);
|
||||||
const [brushSize, setBrushSize] = useState(6);
|
const [brushSize, setBrushSize] = useState(6);
|
||||||
const [savedProjects, setSavedProjects] = useState<SavedRealtimeProject[]>(
|
const [savedProjects, setSavedProjects] = useState<SavedRealtimeProject[]>(
|
||||||
@ -987,13 +1035,36 @@ export function RealtimeCanvasDialog({
|
|||||||
}, [drawImageLayersToContext]);
|
}, [drawImageLayersToContext]);
|
||||||
|
|
||||||
const createPlacedLayer = useCallback(
|
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 image = await loadRenderableImage(params.src);
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
throw new Error("画布未初始化");
|
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 rect = canvas.getBoundingClientRect();
|
||||||
const canvasWidth = Math.max(1, rect.width);
|
const canvasWidth = Math.max(1, rect.width);
|
||||||
const canvasHeight = Math.max(1, rect.height);
|
const canvasHeight = Math.max(1, rect.height);
|
||||||
@ -1575,6 +1646,25 @@ export function RealtimeCanvasDialog({
|
|||||||
[markCanvasDirty, saveHistory, scheduleFlush]
|
[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(
|
const importFilesAsLayers = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
if (files.length === 0) return 0;
|
if (files.length === 0) return 0;
|
||||||
@ -1592,28 +1682,61 @@ export function RealtimeCanvasDialog({
|
|||||||
});
|
});
|
||||||
nextLayerBatch.push(layer);
|
nextLayerBatch.push(layer);
|
||||||
}
|
}
|
||||||
|
return appendLayerBatch(nextLayerBatch);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
appendLayerBatch,
|
||||||
createPlacedLayer,
|
createPlacedLayer,
|
||||||
fileToDataUrl,
|
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(() => {
|
const triggerGenerate = useCallback(() => {
|
||||||
if (!hasRenderableInput(true)) {
|
if (!hasRenderableInput(true)) {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
@ -2568,6 +2691,29 @@ export function RealtimeCanvasDialog({
|
|||||||
[importFilesAsLayers]
|
[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(
|
const handleDragEnter = useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>) => {
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
if (!Array.from(event.dataTransfer?.types || []).includes("Files"))
|
if (!Array.from(event.dataTransfer?.types || []).includes("Files"))
|
||||||
@ -3110,15 +3256,32 @@ export function RealtimeCanvasDialog({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
className="mt-2 h-10 rounded-xl bg-background px-0 hover:bg-muted"
|
<Button
|
||||||
onClick={() => uploadInputRef.current?.click()}
|
className="h-10 rounded-xl bg-background px-0 hover:bg-muted"
|
||||||
title="添加图片图层"
|
disabled={isLayerSplitImporting}
|
||||||
type="button"
|
onClick={() => splitUploadInputRef.current?.click()}
|
||||||
variant="outline"
|
title="图层拆分导入"
|
||||||
>
|
type="button"
|
||||||
<Upload className="h-4 w-4" />
|
variant="outline"
|
||||||
</Button>
|
>
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -3169,6 +3332,14 @@ export function RealtimeCanvasDialog({
|
|||||||
ref={uploadInputRef}
|
ref={uploadInputRef}
|
||||||
type="file"
|
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">
|
<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}>
|
<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">
|
<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 { createMediaAssetRelation, getMediaAssetsByUserAndUrls, getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
|
import { ensurePublicImageUrl } from "@/lib/realtime/image-input";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
const LAYER_SPLIT_CONFIG_KEY = "layer_split_config";
|
const LAYER_SPLIT_CONFIG_KEY = "layer_split_config";
|
||||||
@ -614,11 +615,15 @@ async function runReplicateLayerSplit(params: {
|
|||||||
export async function executeLayerSplitJob(params: LayerSplitParams): Promise<ExecuteLayerSplitJobResult> {
|
export async function executeLayerSplitJob(params: LayerSplitParams): Promise<ExecuteLayerSplitJobResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const config = await getValidatedLayerSplitConfig();
|
const config = await getValidatedLayerSplitConfig();
|
||||||
|
const normalizedImageUrl = await ensurePublicImageUrl({
|
||||||
|
imageUrl: params.imageUrl,
|
||||||
|
prefix: "layer-split-input",
|
||||||
|
});
|
||||||
|
|
||||||
const payload = await runReplicateLayerSplit({
|
const payload = await runReplicateLayerSplit({
|
||||||
apiKey: config.replicateApiToken,
|
apiKey: config.replicateApiToken,
|
||||||
modelId: config.replicateModel,
|
modelId: config.replicateModel,
|
||||||
imageUrl: params.imageUrl,
|
imageUrl: normalizedImageUrl,
|
||||||
config,
|
config,
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
});
|
});
|
||||||
@ -627,7 +632,10 @@ export async function executeLayerSplitJob(params: LayerSplitParams): Promise<Ex
|
|||||||
const totalLayers = layerBuffers.length;
|
const totalLayers = layerBuffers.length;
|
||||||
const sourceAssets = await getMediaAssetsByUserAndUrls({
|
const sourceAssets = await getMediaAssetsByUserAndUrls({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
urls: [params.imageUrl],
|
urls:
|
||||||
|
normalizedImageUrl === params.imageUrl
|
||||||
|
? [params.imageUrl]
|
||||||
|
: [params.imageUrl, normalizedImageUrl],
|
||||||
}).catch(() => []);
|
}).catch(() => []);
|
||||||
|
|
||||||
const results: LayerSplitResultItem[] = [];
|
const results: LayerSplitResultItem[] = [];
|
||||||
@ -677,7 +685,7 @@ export async function executeLayerSplitJob(params: LayerSplitParams): Promise<Ex
|
|||||||
totalLayers,
|
totalLayers,
|
||||||
provider: "replicate",
|
provider: "replicate",
|
||||||
model: config.replicateModel,
|
model: config.replicateModel,
|
||||||
sourceImageUrl: params.imageUrl,
|
sourceImageUrl: normalizedImageUrl,
|
||||||
sourceFilename: layer.filename,
|
sourceFilename: layer.filename,
|
||||||
bounds: visibleBounds,
|
bounds: visibleBounds,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user