From 7f796a5bfd8c58c4d45e757ba5e3fc6098129159 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 18:48:54 +0800 Subject: [PATCH] feat: add split-tile upscale toolbar --- app/api/enhance/route.ts | 20 +- .../canvas/LiblibVideoCanvasReplica.tsx | 427 ++++++++++++++++++ services/canvas/index.ts | 12 +- 3 files changed, 453 insertions(+), 6 deletions(-) diff --git a/app/api/enhance/route.ts b/app/api/enhance/route.ts index 28406f04f..104cf60fe 100644 --- a/app/api/enhance/route.ts +++ b/app/api/enhance/route.ts @@ -684,6 +684,7 @@ export async function POST(request: NextRequest) { let chatId: string | null = null; // 聊天ID(可选) let messageId: string | null = null; // 消息ID(可选) let sourceImageUrl: string | null = null; + let requestedScaleFactor: number | undefined; if (contentType.includes("application/json")) { console.log("[Enhance API] 解析JSON请求体..."); @@ -691,6 +692,11 @@ export async function POST(request: NextRequest) { prompt = body.prompt || "图片变清晰处理"; chatId = body.chatId || null; messageId = body.messageId || null; + const parsedScaleFactor = Number.parseInt(String(body.scaleFactor || ""), 10); + requestedScaleFactor = + Number.isFinite(parsedScaleFactor) && [1, 2, 4, 8].includes(parsedScaleFactor) + ? parsedScaleFactor + : undefined; // 优先使用 imageUrl,其次使用 image_base64 if (body.imageUrl) { @@ -767,7 +773,7 @@ export async function POST(request: NextRequest) { const result = await processWithRealESRGAN(sourceImageUrl, { backupApiUrl: config.backupApiUrl!, backupApiKey: config.backupApiKey!, - backupScale: config.backupScale || "4", + backupScale: String(requestedScaleFactor || Number.parseInt(config.backupScale || "4", 10) || 4), backupModel: config.backupModel || "RealESRGAN_x4plus", }); @@ -806,7 +812,7 @@ export async function POST(request: NextRequest) { messageId: messageId || null, thumbnailUrl, originalUrl, - imageSize: `${config.backupScale}x放大`, + imageSize: `${requestedScaleFactor || Number.parseInt(config.backupScale || "4", 10) || 4}x放大`, prompt: prompt || "Real-ESRGAN图片变清晰处理", modelId: `realesrgan-${config.backupModel}`, }).returning()); @@ -851,7 +857,7 @@ export async function POST(request: NextRequest) { sourceRowId: savedImage.id, primaryParentAssetId: sourceAssets[0]?.id ?? null, metadata: { - imageSize: `${config.backupScale}x放大`, + imageSize: `${requestedScaleFactor || Number.parseInt(config.backupScale || "4", 10) || 4}x放大`, apiType: "realesrgan-url", }, }); @@ -926,7 +932,13 @@ export async function POST(request: NextRequest) { // 使用异步模式(sync=0),根据官方示例 formData.append("sync", "0"); formData.append("type", config.type || "clean"); // 默认通用清晰化 - const scaleFactorValue = typeof config.scaleFactor === "number" ? config.scaleFactor : (config.scaleFactor === "auto" ? undefined : Number.parseInt(String(config.scaleFactor))); + const scaleFactorValue = + requestedScaleFactor || + (typeof config.scaleFactor === "number" + ? config.scaleFactor + : config.scaleFactor === "auto" + ? undefined + : Number.parseInt(String(config.scaleFactor))); if (scaleFactorValue) { formData.append("scale_factor", String(scaleFactorValue)); } diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 5b0a64fc5..0296f7b5e 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -1226,6 +1226,12 @@ function getNodeGridSplitSourceUrl(node: CloneNode | null | undefined) { return getNodeMediaUrl(node) || getNodeSourceMediaUrl(node); } +function getGridSplitCellLabel(index: number, split: Pick) { + const row = Math.floor(index / split.columns) + 1; + const column = (index % split.columns) + 1; + return `${row}-${column}`; +} + function getOfficialImagePresetFunctionKey(value?: unknown) { const text = String(value || "").trim(); if (!text) return null; @@ -3555,6 +3561,282 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [] ); + const createGridSplitImageNodes = useCallback( + async (nodeId: string) => { + const anchor = nodes.find((node) => node.id === nodeId); + const split = getNodeGridSplitState(anchor); + const sourceUrl = getNodeGridSplitSourceUrl(anchor); + if (!anchor || !split || split.selectedIndices.length === 0 || !sourceUrl) { + toast.error("请先选择需要处理的宫格"); + return; + } + + const selectedIndices = split.selectedIndices.slice(); + const selectedAssets = await Promise.all( + selectedIndices.map(async (index) => { + const rect = split.rects[index]; + if (!rect) return null; + const url = await createGridSplitReferenceAsset({ + index, + nodeId: anchor.id, + rect, + sourceUrl, + }).catch(() => ""); + if (!url) return null; + return { index, rect, url }; + }) + ); + const validAssets = selectedAssets.filter((item): item is NonNullable => Boolean(item)); + if (validAssets.length === 0) { + toast.error("所选宫格暂时无法切出独立图片"); + return; + } + + const startX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); + const columns = validAssets.length >= 4 ? 2 : 1; + const columnGap = 52; + const rowGap = 34; + const tileWidth = 428; + const tileHeight = 240; + const totalHeight = + Math.ceil(validAssets.length / columns) * tileHeight + + Math.max(0, Math.ceil(validAssets.length / columns) - 1) * rowGap; + const startY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2); + + const createdNodes = validAssets.map((asset, order) => { + const column = order % columns; + const row = Math.floor(order / columns); + const title = `镜头 ${getGridSplitCellLabel(asset.index, split)}`; + return createMediaResourceNode({ + action: "IMAGE_RESOURCE", + height: tileHeight, + mediaType: "image", + mediaUrl: asset.url, + title, + width: tileWidth, + x: startX + column * (tileWidth + columnGap), + y: startY + row * (tileHeight + rowGap), + }); + }).map((node, order) => { + const asset = validAssets[order]; + return { + ...node, + subtitle: "宫格镜头", + draftValues: { + ...(node.draftValues || {}), + cropTile: { + columns: split.columns, + detectedDivider: split.detectedDivider, + index: asset.index, + rect: asset.rect, + rows: split.rows, + sourceCompositeNodeId: anchor.id, + sourceMode: split.sourceMode, + }, + mediaUrl: asset.url, + outputSizeLabel: undefined, + params: { + ...(node.draftValues?.params as Record | undefined), + cropRect: asset.rect, + sourceNodeId: anchor.id, + sourceUrl: asset.url, + }, + sourceCompositeNodeId: anchor.id, + sourceNodeId: anchor.id, + sourceUrl: asset.url, + url: [asset.url], + }, + } satisfies CloneNode; + }); + + setNodes((current) => [...current, ...createdNodes]); + setEdges((current) => [ + ...current, + ...createdNodes.map((node) => ({ + id: makeId("edge"), + source: anchor.id, + target: node.id, + })), + ]); + setSelectedNodeId(createdNodes[0]?.id || anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success(`已创建 ${createdNodes.length} 个宫格图片节点`); + }, + [nodes] + ); + + const upscaleSelectedGridSplit = useCallback( + async (nodeId: string, scaleFactor: number) => { + const anchor = nodes.find((node) => node.id === nodeId); + const split = getNodeGridSplitState(anchor); + const sourceUrl = getNodeGridSplitSourceUrl(anchor); + if (!anchor || !split || split.selectedIndices.length === 0 || !sourceUrl) { + toast.error("请先选择需要高清放大的宫格"); + return; + } + + const definition = getConfiguredFunction(videoCanvasFunctionConfig, "hd-upscale"); + const selectedIndices = split.selectedIndices.slice(); + const selectedAssets = await Promise.all( + selectedIndices.map(async (index) => { + const rect = split.rects[index]; + if (!rect) return null; + const url = await createGridSplitReferenceAsset({ + index, + nodeId: anchor.id, + rect, + sourceUrl, + }).catch(() => ""); + if (!url) return null; + return { index, rect, url }; + }) + ); + const validAssets = selectedAssets.filter((item): item is NonNullable => Boolean(item)); + if (validAssets.length === 0) { + toast.error("所选宫格暂时无法执行高清放大"); + return; + } + + const startX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); + const columns = validAssets.length >= 4 ? 2 : 1; + const columnGap = 52; + const rowGap = 34; + const tileWidth = 560; + const tileHeight = 315; + const totalHeight = + Math.ceil(validAssets.length / columns) * tileHeight + + Math.max(0, Math.ceil(validAssets.length / columns) - 1) * rowGap; + const startY = Math.round(anchor.y + anchor.height / 2 - totalHeight / 2); + + const pendingNodes = validAssets.map((asset, order) => { + const column = order % columns; + const row = Math.floor(order / columns); + const cellLabel = getGridSplitCellLabel(asset.index, split); + const pending = createMediaResourceNode({ + action: "IMAGE_RESOURCE", + height: tileHeight, + mediaType: "image", + mediaUrl: asset.url, + prompt: definition.prompt, + title: `宫格高清 ${cellLabel}`, + width: tileWidth, + x: startX + column * (tileWidth + columnGap), + y: startY + row * (tileHeight + rowGap), + }); + return { + ...pending, + subtitle: "生成中", + draftValues: { + ...(pending.draftValues || {}), + action: "IMAGE_RESOURCE", + functionKey: definition.key, + generated: false, + generationProgress: 2, + generationStatus: "pending", + mediaType: "image", + mediaUrl: asset.url, + nodeType: "image", + outputSizeLabel: `${scaleFactor}x 高清`, + params: { + functionKey: definition.key, + mode: "grid-split-upscale", + prompt: definition.prompt, + scaleFactor, + sourceNodeId: anchor.id, + sourceTileIndex: asset.index, + sourceUrl: asset.url, + }, + pendingLabel: "生成中", + prompt: definition.prompt, + sourceCompositeNodeId: anchor.id, + sourceNodeId: anchor.id, + sourceUrl: asset.url, + toolbarOption: `x${scaleFactor}`, + url: [asset.url], + }, + } satisfies CloneNode; + }); + + setNodes((current) => [...current, ...pendingNodes]); + setEdges((current) => [ + ...current, + ...pendingNodes.map((node) => ({ + id: makeId("edge"), + source: anchor.id, + target: node.id, + })), + ]); + setSelectedNodeId(anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + + for (const [order, asset] of validAssets.entries()) { + const pendingNode = pendingNodes[order]; + const response = await imageGenerationService.enhanceImage(asset.url, { scaleFactor }); + const generatedUrl = response.success ? response.data?.url || "" : ""; + if (generatedUrl) { + setNodes((current) => + current.map((node) => + node.id === pendingNode.id + ? { + ...node, + subtitle: "生成结果", + draftValues: { + ...clearGridSplitDraftValues(node.draftValues), + functionKey: definition.key, + generated: true, + generationProgress: 100, + generationStatus: "complete", + mediaType: "image", + mediaUrl: generatedUrl, + nodeType: "image", + outputSizeLabel: `${scaleFactor}x 高清`, + params: { + ...getNodeParams(node), + functionKey: definition.key, + prompt: definition.prompt, + scaleFactor, + sourceNodeId: anchor.id, + sourceTileIndex: asset.index, + sourceUrl: asset.url, + }, + sourceCompositeNodeId: anchor.id, + sourceNodeId: anchor.id, + sourceUrl: asset.url, + url: [generatedUrl], + }, + } + : node + ) + ); + } else { + setNodes((current) => + current.map((node) => + node.id === pendingNode.id + ? { + ...node, + draftValues: { + ...clearGridSplitDraftValues(node.draftValues), + generationStatus: "failed", + generationError: response.error || "宫格高清放大失败", + }, + } + : node + ) + ); + } + } + + toast.success(`已提交 ${validAssets.length} 张宫格图的 ${scaleFactor}x 高清放大`); + }, + [nodes, videoCanvasFunctionConfig] + ); + const submitSelectedImagePrompt = useCallback( async (nodeId: string, payload: ComposerSubmitPayload) => { const anchor = nodes.find((node) => node.id === nodeId); @@ -4480,6 +4762,9 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib functionConfig={videoCanvasFunctionConfig} modelOptions={canvasModelOptions} nodes={nodes} + onClearGridSplitSelection={() => clearNodeGridSplitSelection(selectedPresetControlNode.id)} + onGridSplitCreateNodes={() => createGridSplitImageNodes(selectedPresetControlNode.id)} + onGridSplitUpscale={(scaleFactor) => upscaleSelectedGridSplit(selectedPresetControlNode.id, scaleFactor)} onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)} onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)} onPresetSubmit={(prompt, settings) => @@ -5225,12 +5510,124 @@ function MagnetGlyph() { ); } +function GridSplitActionToolbar({ + anchor, + creditCost, + onClearSelection, + onCreateNodes, + onUpscale, + selectedCount, +}: { + anchor?: PresetControlAnchor; + creditCost: number; + onClearSelection: () => void; + onCreateNodes: () => void; + onUpscale: (scaleFactor: number) => void; + selectedCount: number; +}) { + const [scaleFactor, setScaleFactor] = useState(2); + const [scaleMenuOpen, setScaleMenuOpen] = useState(false); + + return ( + <> +
+ +
+ + + 已选 {selectedCount} 个宫格 + +
+ +
+ +
+ + {scaleMenuOpen ? ( +
+ {[2, 4].map((option) => ( + + ))} +
+ ) : null} +
+
+ + ); +} + function PresetWorkflowControls({ anchor, functionConfig, mode, modelOptions, nodes, + onClearGridSplitSelection, + onGridSplitCreateNodes, + onGridSplitUpscale, onComposerSubmit, onImageToolAction, onPresetSubmit, @@ -5241,6 +5638,9 @@ function PresetWorkflowControls({ mode: Exclude; modelOptions: CanvasModelOption[]; nodes: CloneNode[]; + onClearGridSplitSelection: () => void; + onGridSplitCreateNodes: () => void; + onGridSplitUpscale: (scaleFactor: number) => void; onComposerSubmit: (payload: ComposerSubmitPayload) => void; onImageToolAction: (action: ImageToolbarAction, option?: string) => void; onPresetSubmit: (prompt: string, settings?: ComposerSettings) => void; @@ -5248,6 +5648,21 @@ function PresetWorkflowControls({ }) { if (isOfficialImagePresetFunctionKey(mode)) { const definition = getConfiguredFunction(functionConfig, mode); + const gridSplit = getNodeGridSplitState(selectedNode); + if (gridSplit && gridSplit.selectedIndices.length > 0) { + return ( +
+ +
+ ); + } return (
@@ -8180,6 +8595,7 @@ function PresetMediaCanvasNode({ @@ -8242,11 +8658,13 @@ function PresetMediaCanvasNode({ function GenerationPendingPreview({ errorMessage, onCancel, + previewUrl, progress, status, }: { errorMessage?: string; onCancel: () => void; + previewUrl?: string; progress: number; status: string; }) { @@ -8255,6 +8673,15 @@ function GenerationPendingPreview({ return (
+ {previewUrl ? ( + + ) : null}