fix canvas grid split selection workflow
This commit is contained in:
parent
1a74df664c
commit
d622b18ddf
@ -307,6 +307,15 @@ type GridSplitSpec = {
|
||||
rows: number;
|
||||
};
|
||||
|
||||
type GridSplitState = {
|
||||
columns: number;
|
||||
detectedDivider: boolean;
|
||||
rects: GridCropRect[];
|
||||
rows: number;
|
||||
selectedIndices: number[];
|
||||
sourceMode: string;
|
||||
};
|
||||
|
||||
type AngleEditorSettings = {
|
||||
horizontalAngle: number;
|
||||
promptEnabled: boolean;
|
||||
@ -1213,6 +1222,10 @@ function getNodeReferenceMediaUrl(node: CloneNode | null | undefined) {
|
||||
return getNodeMediaUrl(node) || getDraftString(node, "sourceUrl");
|
||||
}
|
||||
|
||||
function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) {
|
||||
return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node);
|
||||
}
|
||||
|
||||
function getOfficialImagePresetFunctionKey(value?: unknown) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return null;
|
||||
@ -1474,6 +1487,56 @@ function createEqualGridRects(rows: number, columns: number): GridCropRect[] {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGridRect(value: unknown, fallback: GridCropRect): GridCropRect {
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
height: Math.min(1, Math.max(0.02, Number(record.height ?? fallback.height))),
|
||||
width: Math.min(1, Math.max(0.02, Number(record.width ?? fallback.width))),
|
||||
x: Math.min(0.98, Math.max(0, Number(record.x ?? fallback.x))),
|
||||
y: Math.min(0.98, Math.max(0, Number(record.y ?? fallback.y))),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGridSplitState(value: unknown): GridSplitState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const rows = Math.max(1, Math.min(5, Math.round(Number(record.rows || 1))));
|
||||
const columns = Math.max(1, Math.min(5, Math.round(Number(record.columns || 1))));
|
||||
const cellCount = rows * columns;
|
||||
const fallbackRects = createEqualGridRects(rows, columns);
|
||||
const rawRects = Array.isArray(record.rects) ? record.rects.slice(0, cellCount) : [];
|
||||
const rects = Array.from({ length: cellCount }).map((_, index) =>
|
||||
normalizeGridRect(rawRects[index], fallbackRects[index] || fallbackRects[0])
|
||||
);
|
||||
const selectedIndices = Array.from(
|
||||
new Set(
|
||||
(Array.isArray(record.selectedIndices) ? record.selectedIndices : [])
|
||||
.map((item) => Math.round(Number(item)))
|
||||
.filter((index) => Number.isFinite(index) && index >= 0 && index < cellCount)
|
||||
)
|
||||
).sort((left, right) => left - right);
|
||||
|
||||
return {
|
||||
columns,
|
||||
detectedDivider: Boolean(record.detectedDivider),
|
||||
rects,
|
||||
rows,
|
||||
selectedIndices,
|
||||
sourceMode: String(record.sourceMode || "detected-lines"),
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeGridSplitState(node: CloneNode | null | undefined) {
|
||||
return normalizeGridSplitState(node?.draftValues?.gridSplit);
|
||||
}
|
||||
|
||||
function clearGridSplitDraftValues(draftValues: Record<string, unknown> | undefined) {
|
||||
const nextDraftValues = { ...(draftValues || {}) };
|
||||
delete nextDraftValues.gridSplit;
|
||||
return nextDraftValues;
|
||||
}
|
||||
|
||||
function buildGridRectsFromLines({
|
||||
columns,
|
||||
height,
|
||||
@ -1630,6 +1693,109 @@ async function detectGridSplitFromImage(url: string, rows: number, columns: numb
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCanvasImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
if (!url.startsWith("blob:") && !url.startsWith("data:")) {
|
||||
image.crossOrigin = "anonymous";
|
||||
image.referrerPolicy = "no-referrer";
|
||||
}
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("image-load-failed"));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function cropRectToBlobUrlSource(url: string, rect: GridCropRect): Promise<Blob | null> {
|
||||
if (typeof window === "undefined" || !url) return null;
|
||||
const image = await loadCanvasImage(url);
|
||||
const naturalWidth = image.naturalWidth || image.width;
|
||||
const naturalHeight = image.naturalHeight || image.height;
|
||||
if (!naturalWidth || !naturalHeight) return null;
|
||||
|
||||
const cropX = Math.max(0, Math.floor(rect.x * naturalWidth));
|
||||
const cropY = Math.max(0, Math.floor(rect.y * naturalHeight));
|
||||
const cropWidth = Math.max(1, Math.round(rect.width * naturalWidth));
|
||||
const cropHeight = Math.max(1, Math.round(rect.height * naturalHeight));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cropWidth;
|
||||
canvas.height = cropHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return null;
|
||||
|
||||
context.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
|
||||
|
||||
return await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), "image/png", 1);
|
||||
});
|
||||
}
|
||||
|
||||
async function createGridSplitReferenceAsset({
|
||||
index,
|
||||
nodeId,
|
||||
rect,
|
||||
sourceUrl,
|
||||
}: {
|
||||
index: number;
|
||||
nodeId: string;
|
||||
rect: GridCropRect;
|
||||
sourceUrl: string;
|
||||
}) {
|
||||
if (/^https?:\/\//i.test(sourceUrl)) {
|
||||
const response = await imageGenerationService.boxCutout({
|
||||
cropRect: rect,
|
||||
imageUrl: sourceUrl,
|
||||
paddingRatio: 0,
|
||||
sourceNodeId: nodeId,
|
||||
});
|
||||
if (response.success && response.data?.url) return response.data.url;
|
||||
}
|
||||
|
||||
const blob = await cropRectToBlobUrlSource(sourceUrl, rect);
|
||||
if (!blob) return "";
|
||||
const file = new File([blob], `video-canvas-grid-${nodeId}-${index + 1}.png`, {
|
||||
type: blob.type || "image/png",
|
||||
});
|
||||
const upload = await imageGenerationService.uploadImage(file);
|
||||
return upload.success ? upload.data?.url || "" : "";
|
||||
}
|
||||
|
||||
async function resolveNodeReferenceImages(node: CloneNode | null | undefined, limit = 12): Promise<string[]> {
|
||||
const fallbackUrl = getNodeReferenceMediaUrl(node);
|
||||
const split = getNodeGridSplitState(node);
|
||||
const sourceUrl = getNodeGridSplitSourceUrl(node);
|
||||
if (!split || split.selectedIndices.length === 0 || !sourceUrl) {
|
||||
return fallbackUrl ? [fallbackUrl] : [];
|
||||
}
|
||||
|
||||
const selectedIndices = split.selectedIndices.slice(0, limit);
|
||||
const resolved = await Promise.all(
|
||||
selectedIndices.map(async (index) => {
|
||||
const rect = split.rects[index];
|
||||
if (!rect) return "";
|
||||
try {
|
||||
return await createGridSplitReferenceAsset({
|
||||
index,
|
||||
nodeId: node?.id || "grid",
|
||||
rect,
|
||||
sourceUrl,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
resolved
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
return urls.length > 0 ? urls : fallbackUrl ? [fallbackUrl] : [];
|
||||
}
|
||||
|
||||
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
|
||||
const value = getDraftString(node, "targetPreset");
|
||||
if (
|
||||
@ -2743,15 +2909,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
async (nodeId: string, action: ImageToolbarAction, option?: string) => {
|
||||
const anchor = nodes.find((node) => node.id === nodeId);
|
||||
if (!anchor) return;
|
||||
const sourceUrl = getNodeReferenceMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
const anchorSourceUrl = getNodeReferenceMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
|
||||
if (action === "download") {
|
||||
if (!sourceUrl) {
|
||||
if (!anchorSourceUrl) {
|
||||
toast.error("当前素材没有可下载地址");
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("a");
|
||||
link.href = sourceUrl;
|
||||
link.href = anchorSourceUrl;
|
||||
link.download = `${anchor.title || "libtv-image"}.png`;
|
||||
link.rel = "noreferrer";
|
||||
document.body.appendChild(link);
|
||||
@ -2818,16 +2984,31 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
|
||||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||||
const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
|
||||
const nextNodes: CloneNode[] = [];
|
||||
const nextEdges: CloneEdge[] = [];
|
||||
|
||||
if (action === "crop") {
|
||||
const { columns, rows, tileCount } = normalizeGridOption(option);
|
||||
const tileSize = tileCount === 25 ? 142 : tileCount === 16 ? 156 : 180;
|
||||
const gap = 26;
|
||||
const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2);
|
||||
const anchorMediaUrl = getNodeMediaUrl(anchor);
|
||||
const currentSplit = getNodeGridSplitState(anchor);
|
||||
const isSyntheticGrid = Boolean(anchor.draftValues?.nineGridComposite) && !anchorMediaUrl;
|
||||
if (currentSplit && currentSplit.columns === columns && currentSplit.rows === rows) {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === anchor.id
|
||||
? {
|
||||
...node,
|
||||
draftValues: clearGridSplitDraftValues(node.draftValues),
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
setSelectedNodeId(anchor.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
toast.success("已取消宫格切分");
|
||||
return;
|
||||
}
|
||||
const detectedSplit =
|
||||
!isSyntheticGrid && anchorMediaUrl
|
||||
? await detectGridSplitFromImage(anchorMediaUrl, rows, columns)
|
||||
@ -2836,59 +3017,43 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
detectedSplit?.rects && detectedSplit.rects.length >= tileCount
|
||||
? detectedSplit.rects
|
||||
: createEqualGridRects(rows, columns);
|
||||
const sourceVisuals = Array.isArray(anchor.draftValues?.nineGridCells)
|
||||
? (anchor.draftValues?.nineGridCells as GridCellVisual[])
|
||||
: [];
|
||||
|
||||
Array.from({ length: tileCount }).forEach((_, index) => {
|
||||
const tile = createMediaResourceNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: tileSize,
|
||||
mediaType: "image",
|
||||
mediaUrl: isSyntheticGrid ? sourceUrl : anchorMediaUrl || sourceUrl,
|
||||
prompt: option || "宫格切分",
|
||||
title: `镜头 ${String(index + 1).padStart(2, "0")}`,
|
||||
width: tileSize,
|
||||
x: rightX + (index % columns) * (tileSize + gap),
|
||||
y: startY + Math.floor(index / columns) * (tileSize + gap),
|
||||
});
|
||||
nextNodes.push({
|
||||
...tile,
|
||||
subtitle: "宫格切片",
|
||||
draftValues: {
|
||||
...(tile.draftValues || {}),
|
||||
cropTile: {
|
||||
columns,
|
||||
detectedDivider: Boolean(detectedSplit?.detected),
|
||||
index,
|
||||
rect: rects[index],
|
||||
rows,
|
||||
sourceCompositeNodeId: anchor.id,
|
||||
sourceMode: isSyntheticGrid ? "synthetic-grid" : "detected-lines",
|
||||
},
|
||||
gridCellVisual: isSyntheticGrid ? sourceVisuals[index] || getGridCellVisual(index) : undefined,
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
});
|
||||
nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id });
|
||||
});
|
||||
|
||||
setNodes((current) => [...current, ...nextNodes]);
|
||||
setEdges((current) => [...current, ...nextEdges]);
|
||||
setSelectedNodeId(nextNodes[0]?.id || anchor.id);
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === anchor.id
|
||||
? {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
gridSplit: {
|
||||
columns,
|
||||
detectedDivider: Boolean(detectedSplit?.detected),
|
||||
rects,
|
||||
rows,
|
||||
selectedIndices: [],
|
||||
sourceMode: isSyntheticGrid ? "synthetic-grid" : "detected-lines",
|
||||
},
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
setSelectedNodeId(anchor.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
setActivePanel(null);
|
||||
toast.success(
|
||||
detectedSplit?.detected
|
||||
? `已识别黑色分割线并切分为 ${tileCount} 张图片`
|
||||
: `已按 ${columns}x${rows} 宫格切分为 ${tileCount} 张图片`
|
||||
? `已在当前图上识别黑色分割线,进入 ${tileCount} 宫格选择模式`
|
||||
: `已在当前图上按 ${columns}x${rows} 进入宫格选择模式`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedReferenceImages = await resolveNodeReferenceImages(anchor);
|
||||
const sourceUrl = resolvedReferenceImages[0] || anchorSourceUrl;
|
||||
const extraReferenceImages = resolvedReferenceImages.slice(1);
|
||||
|
||||
const labels: Record<Exclude<ImageToolbarAction, "annotate" | "rotate" | "download" | "preview" | "crop" | "orbit">, string> = {
|
||||
grid: "九宫格",
|
||||
hd: "高清",
|
||||
@ -2936,6 +3101,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
mode: "grid",
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
@ -2965,6 +3131,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImage: sourceUrl,
|
||||
referenceImages: extraReferenceImages,
|
||||
});
|
||||
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (generatedUrl) {
|
||||
@ -2987,6 +3154,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
mode: "grid",
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
},
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
@ -3051,6 +3219,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
mode: functionKey === CHARACTER_THREE_VIEW_FUNCTION_KEY ? "character-three-view" : "image",
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
@ -3080,6 +3249,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImage: sourceUrl,
|
||||
referenceImages: extraReferenceImages,
|
||||
});
|
||||
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (generatedUrl) {
|
||||
@ -3101,6 +3271,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
functionKey,
|
||||
option: resultTitle,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
},
|
||||
prompt,
|
||||
sourceNodeId: anchor.id,
|
||||
@ -3174,6 +3345,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
params: {
|
||||
mode: action,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
@ -3214,6 +3386,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImage: sourceUrl,
|
||||
referenceImages: extraReferenceImages,
|
||||
});
|
||||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (!generatedUrl && !response.success) toast.error(response.error || `${definition.label}处理失败`);
|
||||
@ -3234,6 +3407,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
params: {
|
||||
mode: action,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
@ -3277,6 +3451,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
params: {
|
||||
mode: action,
|
||||
prompt,
|
||||
referenceImages: [sourceUrl, ...extraReferenceImages],
|
||||
sourceNodeId: anchor.id,
|
||||
sourceUrl,
|
||||
},
|
||||
@ -3314,11 +3489,92 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dismissNodeGridSplit = useCallback((nodeId: string) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
draftValues: clearGridSplitDraftValues(node.draftValues),
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearNodeGridSplitSelection = useCallback((nodeId: string) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
const split = getNodeGridSplitState(node);
|
||||
if (!split) return node;
|
||||
return {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
gridSplit: {
|
||||
...split,
|
||||
selectedIndices: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleNodeGridSplitSelection = useCallback(
|
||||
(nodeId: string, index: number, behavior: "replace" | "toggle" = "replace") => {
|
||||
setNodes((current) =>
|
||||
current.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
const split = getNodeGridSplitState(node);
|
||||
if (!split || !split.rects[index]) return node;
|
||||
const alreadySelected = split.selectedIndices.includes(index);
|
||||
const nextSelected =
|
||||
behavior === "toggle"
|
||||
? alreadySelected
|
||||
? split.selectedIndices.filter((item) => item !== index)
|
||||
: [...split.selectedIndices, index].sort((left, right) => left - right)
|
||||
: alreadySelected && split.selectedIndices.length === 1
|
||||
? []
|
||||
: [index];
|
||||
|
||||
return {
|
||||
...node,
|
||||
draftValues: {
|
||||
...(node.draftValues || {}),
|
||||
gridSplit: {
|
||||
...split,
|
||||
selectedIndices: nextSelected,
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const submitSelectedImagePrompt = useCallback(
|
||||
async (nodeId: string, payload: ComposerSubmitPayload) => {
|
||||
const anchor = nodes.find((node) => node.id === nodeId);
|
||||
if (!anchor) return;
|
||||
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
const anchorSplit = getNodeGridSplitState(anchor);
|
||||
const anchorReferenceImages = await resolveNodeReferenceImages(anchor);
|
||||
const referencedNodes = payload.references
|
||||
.filter((referenceId) => referenceId !== anchor.id)
|
||||
.map((referenceId) => nodes.find((node) => node.id === referenceId))
|
||||
.filter((node): node is CloneNode => Boolean(node));
|
||||
const referencedImageLists = await Promise.all(referencedNodes.map((node) => resolveNodeReferenceImages(node)));
|
||||
const mergedReferenceImages = Array.from(
|
||||
new Set(
|
||||
[...anchorReferenceImages, ...referencedImageLists.flat()]
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 12);
|
||||
const sourceUrl = mergedReferenceImages[0] || getNodeReferenceMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||||
const extraReferenceImages = mergedReferenceImages.slice(1);
|
||||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||||
const starterMode = getPresetMode(anchor);
|
||||
const slashScene = payload.slashScene || "";
|
||||
@ -3356,6 +3612,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
prompt: finalPrompt,
|
||||
promptExtend: !isWideGeneration,
|
||||
referenceImage: sourceUrl,
|
||||
referenceImages: extraReferenceImages,
|
||||
});
|
||||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (!generatedUrl && !response.success) {
|
||||
@ -3424,6 +3681,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
: "image",
|
||||
prompt: finalPrompt,
|
||||
references: payload.references,
|
||||
referenceImages: mergedReferenceImages,
|
||||
selectedGridCellCount: anchorSplit?.selectedIndices.length || 0,
|
||||
settings: payload.settings,
|
||||
slashScene,
|
||||
sourceNodeId: anchor.id,
|
||||
@ -3627,10 +3886,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
.filter((edge) => edge.target === nodeId)
|
||||
.map((edge) => nodes.find((item) => item.id === edge.source))
|
||||
.filter((item): item is CloneNode => Boolean(item));
|
||||
const imageUrls = incomingNodes
|
||||
.filter((item) => item.inputType === "image")
|
||||
.map((item) => getNodeReferenceMediaUrl(item))
|
||||
.filter(Boolean);
|
||||
const imageReferenceLists = await Promise.all(
|
||||
incomingNodes
|
||||
.filter((item) => item.inputType === "image")
|
||||
.map((item) => resolveNodeReferenceImages(item))
|
||||
);
|
||||
const imageUrls = Array.from(
|
||||
new Set(
|
||||
imageReferenceLists
|
||||
.flat()
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 12);
|
||||
const videoUrls = incomingNodes
|
||||
.filter((item) => item.inputType === "video")
|
||||
.map((item) => getNodeReferenceMediaUrl(item))
|
||||
@ -3665,6 +3933,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
: "image-repaint";
|
||||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
|
||||
const sourceUrl = imageUrls[0] || getNodeReferenceMediaUrl(node);
|
||||
const extraReferenceImages = imageUrls.slice(1);
|
||||
const prompt =
|
||||
functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY
|
||||
? composeMultiViewNineGridPrompt(definition, String(params.option || node.title))
|
||||
@ -3687,6 +3956,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
prompt,
|
||||
promptExtend: !isWideImageGenerationFunction(functionKey),
|
||||
referenceImage: sourceUrl || undefined,
|
||||
referenceImages: extraReferenceImages,
|
||||
});
|
||||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||||
if (!generatedUrl && !response.success) throw new Error(response.error || "图片生成失败");
|
||||
@ -3701,7 +3971,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
kind: "media",
|
||||
subtitle: "生成结果",
|
||||
draftValues: {
|
||||
...(item.draftValues || {}),
|
||||
...clearGridSplitDraftValues(item.draftValues),
|
||||
functionKey,
|
||||
generated: true,
|
||||
generationProgress: 100,
|
||||
@ -3710,7 +3980,9 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
gridPrompt: gridLayout ? prompt : item.draftValues?.gridPrompt,
|
||||
gridRows: gridLayout?.rows || item.draftValues?.gridRows,
|
||||
nineGridCells: gridLayout
|
||||
? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) => getGridCellVisual(index))
|
||||
? Array.from({ length: gridLayout.rows * gridLayout.columns }).map((_, index) =>
|
||||
getGridCellVisual(index)
|
||||
)
|
||||
: item.draftValues?.nineGridCells,
|
||||
nineGridComposite: gridLayout ? true : item.draftValues?.nineGridComposite,
|
||||
mediaType: "image",
|
||||
@ -3723,6 +3995,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
...getNodeParams(item),
|
||||
functionKey,
|
||||
prompt,
|
||||
referenceImages: sourceUrl ? [sourceUrl, ...extraReferenceImages] : extraReferenceImages,
|
||||
},
|
||||
prompt,
|
||||
sourceUrl,
|
||||
@ -4111,9 +4384,12 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
selected={node.id === selectedNodeId}
|
||||
onContextMenu={(event) => openNodeContext(event, node)}
|
||||
onCancelGeneration={() => cancelGenerationNode(node.id)}
|
||||
onClearGridSplit={() => dismissNodeGridSplit(node.id)}
|
||||
onClearGridSplitSelection={() => clearNodeGridSplitSelection(node.id)}
|
||||
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
|
||||
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
|
||||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||||
onToggleGridSplitSelection={(index, behavior) => toggleNodeGridSplitSelection(node.id, index, behavior)}
|
||||
onTextResourceChange={(content) => updateTextResourceContent(node.id, content)}
|
||||
/>
|
||||
))}
|
||||
@ -7235,21 +7511,27 @@ function EdgeFlowSegments({
|
||||
}
|
||||
|
||||
function CloneCanvasNode({
|
||||
onClearGridSplit,
|
||||
onClearGridSplitSelection,
|
||||
node,
|
||||
onCancelGeneration,
|
||||
onContextMenu,
|
||||
onModuleAction,
|
||||
onOpenConnectionMenu,
|
||||
onPointerDown,
|
||||
onToggleGridSplitSelection,
|
||||
onTextResourceChange,
|
||||
selected,
|
||||
}: {
|
||||
onClearGridSplit: () => void;
|
||||
onClearGridSplitSelection: () => void;
|
||||
node: CloneNode;
|
||||
onCancelGeneration: () => void;
|
||||
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
|
||||
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onToggleGridSplitSelection: (index: number, behavior: "replace" | "toggle") => void;
|
||||
onTextResourceChange: (content: string) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
@ -7273,7 +7555,14 @@ function CloneCanvasNode({
|
||||
if (node.kind === "media") {
|
||||
return (
|
||||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||||
<PresetMediaCanvasNode node={node} onCancelGeneration={onCancelGeneration} selected={selected} />
|
||||
<PresetMediaCanvasNode
|
||||
node={node}
|
||||
onCancelGeneration={onCancelGeneration}
|
||||
onClearGridSplit={onClearGridSplit}
|
||||
onClearGridSplitSelection={onClearGridSplitSelection}
|
||||
onToggleGridSplitSelection={onToggleGridSplitSelection}
|
||||
selected={selected}
|
||||
/>
|
||||
</CanvasNodeShell>
|
||||
);
|
||||
}
|
||||
@ -7609,6 +7898,82 @@ function CroppedGridTileImage({
|
||||
);
|
||||
}
|
||||
|
||||
function GridSplitSelectionOverlay({
|
||||
onClearSelection,
|
||||
onClose,
|
||||
onToggleCell,
|
||||
split,
|
||||
}: {
|
||||
onClearSelection: () => void;
|
||||
onClose: () => void;
|
||||
onToggleCell: (index: number, behavior: "replace" | "toggle") => void;
|
||||
split: GridSplitState;
|
||||
}) {
|
||||
const selectedCount = split.selectedIndices.length;
|
||||
const selectedSet = new Set(split.selectedIndices);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden rounded-[10px]">
|
||||
<div className="pointer-events-auto absolute left-3 top-3 z-30 flex items-center gap-2">
|
||||
<span className="rounded-full border border-white/14 bg-black/56 px-3 py-1 text-[12px] font-medium text-white/82 backdrop-blur-sm">
|
||||
{selectedCount > 0 ? `已选 ${selectedCount}` : `${split.columns} × ${split.rows} 切分`}
|
||||
</span>
|
||||
{selectedCount > 0 ? (
|
||||
<button
|
||||
className="rounded-full border border-white/12 bg-black/48 px-3 py-1 text-[12px] text-white/72 transition hover:bg-black/60 hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClearSelection();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
清空选择
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="rounded-full border border-white/12 bg-black/48 px-3 py-1 text-[12px] text-white/72 transition hover:bg-black/60 hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
取消切分
|
||||
</button>
|
||||
</div>
|
||||
{split.rects.map((rect, index) => {
|
||||
const selected = selectedSet.has(index);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"pointer-events-auto absolute cursor-pointer rounded-[3px] border transition-colors duration-150",
|
||||
selected
|
||||
? "border-[#69abff] bg-[#2f73ff]/26 shadow-[inset_0_0_0_1px_rgba(105,171,255,0.55)]"
|
||||
: "border-white/12 bg-transparent hover:border-white/42 hover:bg-white/6"
|
||||
)}
|
||||
key={`grid-split-${index}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleCell(index, event.metaKey || event.ctrlKey || event.shiftKey ? "toggle" : "replace");
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
height: `${rect.height * 100}%`,
|
||||
left: `${rect.x * 100}%`,
|
||||
top: `${rect.y * 100}%`,
|
||||
width: `${rect.width * 100}%`,
|
||||
}}
|
||||
title={`镜头 ${String(index + 1).padStart(2, "0")}`}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OfficialAudioCard({ mediaUrl, selected }: { mediaUrl: string; selected: boolean }) {
|
||||
return (
|
||||
<div
|
||||
@ -7732,12 +8097,18 @@ function CanvasNodeShell({
|
||||
}
|
||||
|
||||
function PresetMediaCanvasNode({
|
||||
onClearGridSplit,
|
||||
onClearGridSplitSelection,
|
||||
node,
|
||||
onCancelGeneration,
|
||||
onToggleGridSplitSelection,
|
||||
selected,
|
||||
}: {
|
||||
onClearGridSplit: () => void;
|
||||
onClearGridSplitSelection: () => void;
|
||||
node: CloneNode;
|
||||
onCancelGeneration: () => void;
|
||||
onToggleGridSplitSelection: (index: number, behavior: "replace" | "toggle") => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||||
@ -7752,6 +8123,7 @@ function PresetMediaCanvasNode({
|
||||
const outputSizeLabel = getDraftString(node, "outputSizeLabel");
|
||||
const isNineGridComposite = Boolean(node.draftValues?.nineGridComposite);
|
||||
const cropTile = normalizeGridCropTile(node.draftValues?.cropTile);
|
||||
const gridSplit = getNodeGridSplitState(node);
|
||||
const gridCellVisual = node.draftValues?.gridCellVisual
|
||||
? normalizeGridCellVisual(node.draftValues?.gridCellVisual)
|
||||
: null;
|
||||
@ -7844,6 +8216,14 @@ function PresetMediaCanvasNode({
|
||||
<span className="absolute left-[38%] top-[calc(32%-26px)] rounded-[7px] bg-black/68 px-2 py-1 text-[11px] text-white">主体</span>
|
||||
</div>
|
||||
) : null}
|
||||
{gridSplit ? (
|
||||
<GridSplitSelectionOverlay
|
||||
onClearSelection={onClearGridSplitSelection}
|
||||
onClose={onClearGridSplit}
|
||||
onToggleCell={onToggleGridSplitSelection}
|
||||
split={gridSplit}
|
||||
/>
|
||||
) : null}
|
||||
{!generated && !isGenerating && !isGenerationFailed ? (
|
||||
<button
|
||||
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90"
|
||||
|
||||
@ -264,6 +264,7 @@ export const imageGenerationService = {
|
||||
imageCount?: number;
|
||||
promptExtend?: boolean;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
billingFeature?: 'multi-view' | 'repaint';
|
||||
}): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; model?: string; prompt?: string }>> {
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user