From ceb7605a1e9f1f66154273f35cd77e84d578147e Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Wed, 3 Jun 2026 16:05:35 +0800 Subject: [PATCH] feat(canvas): add image upscaling and super resolution features with UI enhancements --- docs/content/docs/progress/pending-test.mdx | 1 + .../(user)/canvas/[id]/canvas-client-page.tsx | 40 +++++- .../components/canvas-node-hover-toolbar.tsx | 12 +- .../components/canvas-node-upscale-dialog.tsx | 114 ++++++++++++++++++ .../(user)/canvas/utils/canvas-image-data.ts | 55 +++++++++ 5 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 web/src/app/(user)/canvas/components/canvas-node-upscale-dialog.tsx diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a1ac3bd..a641a72 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 图片节点悬浮工具栏新增“放大”和“超分”入口;“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。 - Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框、节点底部 prompt 面板和生成配置预览文本编辑框输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。 - 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。 - 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 37cd2b0..0745806 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -19,7 +19,7 @@ import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { UserStatusActions } from "@/components/layout/user-status-actions"; import { useAssetStore } from "@/stores/use-asset-store"; import { useThemeStore } from "@/stores/use-theme-store"; -import { cropDataUrl } from "../utils/canvas-image-data"; +import { cropDataUrl, upscaleDataUrl } from "../utils/canvas-image-data"; import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size"; import { App, Button, Dropdown, Modal } from "antd"; import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants"; @@ -29,6 +29,7 @@ import { CanvasAssistantPanel } from "../components/canvas-assistant-panel"; import { CanvasNodeContextMenu } from "../components/canvas-context-menu"; import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog"; import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog"; +import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "../components/canvas-node-upscale-dialog"; import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation"; import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar"; import { InfiniteCanvas } from "../components/infinite-canvas"; @@ -272,6 +273,8 @@ function InfiniteCanvasPage() { const [editRequestNonce, setEditRequestNonce] = useState(0); const [infoNodeId, setInfoNodeId] = useState(null); const [cropNodeId, setCropNodeId] = useState(null); + const [upscaleNodeId, setUpscaleNodeId] = useState(null); + const [superResolveNodeId, setSuperResolveNodeId] = useState(null); const [angleNodeId, setAngleNodeId] = useState(null); const [previewNodeId, setPreviewNodeId] = useState(null); const [assistantCollapsed, setAssistantCollapsed] = useState(true); @@ -573,6 +576,8 @@ function InfiniteCanvasPage() { const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null; const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null; const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null; + const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null; + const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null; const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null; const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null; const hasMultipleSelectedNodes = selectedNodeIds.size > 1; @@ -1459,6 +1464,31 @@ function InfiniteCanvasPage() { setCropNodeId(null); }, []); + const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => { + if (!node.metadata?.content) return; + setUpscaleNodeId(null); + const upscaled = await upscaleDataUrl(node.metadata.content, params); + const image = await uploadImage(upscaled); + const size = fitNodeSize(image.width, image.height); + const childId = nanoid(); + const child: CanvasNodeData = { + id: childId, + type: CanvasNodeType.Image, + title: "Upscaled Image", + position: { x: node.position.x + node.width + 96, y: node.position.y }, + width: size.width, + height: size.height, + metadata: { + ...imageMetadata(image), + prompt: node.metadata?.prompt, + }, + }; + setNodes((prev) => [...prev, child]); + setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]); + setSelectedNodeIds(new Set([childId])); + setDialogNodeId(childId); + }, []); + const generateAngleNode = useCallback( async (node: CanvasNodeData, params: CanvasImageAngleParams) => { if (!node.metadata?.content) return; @@ -2295,6 +2325,8 @@ function InfiniteCanvasPage() { onDownload={downloadNodeImage} onSaveAsset={(node) => void saveNodeAsset(node)} onCrop={(node) => setCropNodeId(node.id)} + onUpscale={(node) => setUpscaleNodeId(node.id)} + onSuperResolve={(node) => setSuperResolveNodeId(node.id)} onAngle={(node) => setAngleNodeId(node.id)} onViewImage={(node) => setPreviewNodeId(node.id)} onRetry={(node) => void handleRetryNode(node)} @@ -2361,6 +2393,12 @@ function InfiniteCanvasPage() { {cropNode?.metadata?.content ? setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null} + {upscaleNode?.metadata?.content ? setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null} + + setSuperResolveNodeId(null)}> +
暂未实现
+
+ {angleNode?.metadata?.content ? setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null} void; onSaveAsset: (node: CanvasNodeData) => void; onCrop: (node: CanvasNodeData) => void; + onUpscale: (node: CanvasNodeData) => void; + onSuperResolve: (node: CanvasNodeData) => void; onAngle: (node: CanvasNodeData) => void; onViewImage: (node: CanvasNodeData) => void; onRetry: (node: CanvasNodeData) => void; @@ -46,6 +48,8 @@ export function CanvasNodeHoverToolbar({ onDownload, onSaveAsset, onCrop, + onUpscale, + onSuperResolve, onAngle, onViewImage, onRetry, @@ -102,6 +106,8 @@ export function CanvasNodeHoverToolbar({ /> ) : null} {hasImage ? } onClick={() => onCrop(node)} /> : null} + {hasImage ? } onClick={() => onUpscale(node)} /> : null} + {hasImage ? } onClick={() => onSuperResolve(node)} /> : null} {hasImage ? } onClick={() => onAngle(node)} /> : null} {hasImage ? } onClick={() => onViewImage(node)} /> : null} @@ -180,7 +186,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) { return ( - + diff --git a/web/src/app/(user)/canvas/components/canvas-node-upscale-dialog.tsx b/web/src/app/(user)/canvas/components/canvas-node-upscale-dialog.tsx new file mode 100644 index 0000000..cc00a5e --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-node-upscale-dialog.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button, Modal, Segmented } from "antd"; +import { ImagePlus } from "lucide-react"; + +import { readImageMeta } from "@/lib/image-utils"; +import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "../utils/canvas-image-data"; + +export type CanvasImageUpscaleParams = ImageUpscaleParams; + +const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [ + { value: "high", title: "高清插值", description: "适合照片和细节图" }, + { value: "bilinear", title: "双线性", description: "平滑、速度快" }, + { value: "nearest", title: "最近邻", description: "适合像素风格" }, +]; + +const targetOptions = [ + { label: "1K", value: 1024 }, + { label: "2K", value: 2048 }, + { label: "4K", value: MAX_UPSCALE_LONG_EDGE }, +]; + +const defaultParams: CanvasImageUpscaleParams = { + targetLongEdge: 2048, + algorithm: "high", +}; + +export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) { + const [params, setParams] = useState(defaultParams); + const [image, setImage] = useState<{ width: number; height: number } | null>(null); + const sourceLongEdge = image ? Math.max(image.width, image.height) : 0; + const outputSize = useMemo(() => (image ? resolveUpscaleSize(image.width, image.height, params.targetLongEdge) : null), [image, params.targetLongEdge]); + const canUpscale = Boolean(image && sourceLongEdge < params.targetLongEdge && params.targetLongEdge <= MAX_UPSCALE_LONG_EDGE); + const reachedMax = Boolean(image && sourceLongEdge >= MAX_UPSCALE_LONG_EDGE); + + useEffect(() => { + if (!open) return; + setParams(defaultParams); + setImage(null); + }, [dataUrl, open]); + + useEffect(() => { + if (!open) return; + void readImageMeta(dataUrl).then(setImage); + }, [dataUrl, open]); + + useEffect(() => { + if (!image) return; + const nextTarget = targetOptions.find((option) => sourceLongEdge < option.value)?.value || MAX_UPSCALE_LONG_EDGE; + setParams((current) => ({ ...current, targetLongEdge: nextTarget })); + }, [image, sourceLongEdge]); + + return ( + +
+
+

图片放大

+
+
+
+
+ +
+
+ 源图 + {image ? `${image.width} x ${image.height} px` : "读取中"} +
+
+
+
+
目标像素
+ ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))} + onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))} + /> + {image && !canUpscale ?
{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}
: null} +
+
+
放大算法
+ ({ + value: item.value, + label: ( + + {item.title} + {item.description} + + ), + }))} + onChange={(value) => setParams((current) => ({ ...current, algorithm: value as ImageUpscaleAlgorithm }))} + /> +
+
+
+ 输出尺寸 + {outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"} +
+
+
+
+
+ +
+
+
+ ); +} diff --git a/web/src/app/(user)/canvas/utils/canvas-image-data.ts b/web/src/app/(user)/canvas/utils/canvas-image-data.ts index 409a7eb..e1bba3b 100644 --- a/web/src/app/(user)/canvas/utils/canvas-image-data.ts +++ b/web/src/app/(user)/canvas/utils/canvas-image-data.ts @@ -14,6 +14,15 @@ export type ImageAngleTransform = { wideAngle: boolean; }; +export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high"; + +export const MAX_UPSCALE_LONG_EDGE = 4096; + +export type ImageUpscaleParams = { + targetLongEdge: number; + algorithm: ImageUpscaleAlgorithm; +}; + export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) { const image = await loadImage(dataUrl); if (crop) { @@ -65,6 +74,19 @@ export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleT return canvas.toDataURL("image/png"); } +export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) { + const image = await loadImage(dataUrl); + const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge); + return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm); +} + +export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) { + const longEdge = Math.max(1, width, height); + const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge))); + const scale = target / longEdge; + return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }; +} + function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, sw); @@ -75,6 +97,39 @@ function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, s return canvas.toDataURL("image/png"); } +function drawStepUpscale(image: HTMLImageElement, width: number, height: number) { + let source: CanvasImageSource = image; + let sourceWidth = image.width; + let sourceHeight = image.height; + + while (sourceWidth * 2 < width && sourceHeight * 2 < height) { + const nextWidth = sourceWidth * 2; + const nextHeight = sourceHeight * 2; + const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high"); + source = next; + sourceWidth = nextWidth; + sourceHeight = nextHeight; + } + + return drawResize(source, sourceWidth, sourceHeight, width, height, "high"); +} + +function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) { + return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png"); +} + +function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return canvas; + context.imageSmoothingEnabled = algorithm !== "nearest"; + context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high"; + context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height); + return canvas; +} + function loadImage(dataUrl: string) { return new Promise((resolve) => { const image = new Image();