fix canvas grid split selection workflow
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-26 17:55:43 +08:00
parent 1a74df664c
commit d622b18ddf
2 changed files with 440 additions and 59 deletions

View File

@ -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"

View File

@ -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 {