feat: add canvas tool panels (multi-grid, upscale, inpaint) and conditional grid mode

Add modal-based tool panels for multi-grid, super-resolution, and inpaint in canvas image-to-image workflow. Grid mode selector only appears for models that support multi-image generation (wan2.7-image, gpt-image-2). Also fixes merge conflict markers in CSS and adds missing toast import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 01:48:13 +08:00
parent d7379af717
commit 93a7a6d5e6
7 changed files with 475 additions and 9 deletions
+1
View File
@@ -13,6 +13,7 @@ import {
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import { toast } from "./toast/toastStore";
import type { ServerConnectionHealth } from "../api/serverConnection"; import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets"; import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
+54 -5
View File
@@ -182,6 +182,7 @@ import {
} from "./canvasWorkflowDeserialize"; } from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
@@ -336,6 +337,7 @@ function CanvasPage({
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null); const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null); const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null); const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null); const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]); const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
const [stylePickerLoading, setStylePickerLoading] = useState(false); const [stylePickerLoading, setStylePickerLoading] = useState(false);
@@ -4264,7 +4266,7 @@ function CanvasPage({
setSelectedExistingCategory(""); setSelectedExistingCategory("");
setSaveAssetOpen(true); setSaveAssetOpen(true);
} }
if (key === "upscale") void handleGenerateImageNode(imageNode.id); if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
}} }}
moreActions={[ moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl }, { key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4570,16 +4572,42 @@ function CanvasPage({
)} )}
<button <button
type="button" type="button"
className={imageNodeFocusActive ? "is-active" : ""} title="多宫格生成"
title="框选聚焦区域" disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openImageFocusMode(imageNode); setCanvasToolModal({ tool: "multiGrid", imageNode });
}} }}
> >
<BarsOutlined /><span></span> <BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
</button> </button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button> <button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div> </div>
@@ -5729,6 +5757,27 @@ function CanvasPage({
</section> </section>
</div> </div>
{canvasToolModal && (
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
<header className="studio-canvas-tool-modal__header">
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
</header>
<div className="studio-canvas-tool-modal__body">
{canvasToolModal.tool === "multiGrid" && (
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "upscale" && (
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "inpaint" && (
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
</div>
</div>
</div>
)}
</WorkspacePageShell> </WorkspacePageShell>
); );
} }
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+4 -1
View File
@@ -109,6 +109,7 @@ import {
VIDEO_MODEL_OPTIONS, VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS, RATIO_OPTIONS,
GRID_MODE_OPTIONS, GRID_MODE_OPTIONS,
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS, VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS, VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY, MESSAGE_STORAGE_KEY,
@@ -2729,7 +2730,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"} isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")} onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus} onClose={closeToolbarMenus}
onChange={setImageModel} onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection} direction={dropdownDirection}
/> />
<CompoundSelectChip <CompoundSelectChip
@@ -2741,6 +2742,7 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")} onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection} direction={dropdownDirection}
/> />
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<SelectChip <SelectChip
chipId="image-grid-mode" chipId="image-grid-mode"
value={imageGridMode} value={imageGridMode}
@@ -2752,6 +2754,7 @@ function WorkbenchPage({
onChange={setImageGridMode} onChange={setImageGridMode}
direction={dropdownDirection} direction={dropdownDirection}
/> />
)}
</> </>
)} )}
{activeMode === "video" && ( {activeMode === "video" && (
@@ -236,6 +236,13 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
{ value: "grid-25", label: "25 宫格" }, { value: "grid-25", label: "25 宫格" },
]; ];
export const GRID_SUPPORTED_MODELS = new Set([
"wan2.7-image-pro",
"wan2.7-image",
"gpt-image-2",
"gpt-image-2-vip",
]);
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [ export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
{ value: "omni", label: "全能参考" }, { value: "omni", label: "全能参考" },
{ value: "start-end", label: "首尾帧" }, { value: "start-end", label: "首尾帧" },
+188
View File
@@ -722,3 +722,191 @@
right: -9999px; right: -9999px;
height: 1px; height: 1px;
} }
/* Tool Modal Overlay */
.studio-canvas-tool-modal-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
}
.studio-canvas-tool-modal {
position: relative;
width: 90vw;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
border-radius: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-heavy, 0 12px 40px rgba(0,0,0,0.4));
padding: 24px;
}
.studio-canvas-tool-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.studio-canvas-tool-modal__title {
font-size: 16px;
font-weight: 600;
color: var(--fg-default);
}
.studio-canvas-tool-modal__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: var(--bg-subtle);
color: var(--fg-muted);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.studio-canvas-tool-modal__close:hover {
background: var(--bg-hover);
}
/* Tool Panel Components */
.studio-canvas-tool-panel {
display: flex;
gap: 20px;
min-height: 280px;
}
.studio-canvas-tool-panel--inpaint {
flex-direction: column;
}
.studio-canvas-tool-panel__preview {
flex: 0 0 260px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
display: flex;
align-items: center;
justify-content: center;
}
.studio-canvas-tool-panel__preview img {
width: 100%;
height: 100%;
object-fit: contain;
}
.studio-canvas-tool-panel__controls {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.studio-canvas-tool-panel__label {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.studio-canvas-tool-panel__options {
display: flex;
gap: 8px;
}
.studio-canvas-tool-panel__options button {
padding: 6px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.studio-canvas-tool-panel__options button.is-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.studio-canvas-tool-panel__textarea {
width: 100%;
min-height: 60px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
resize: vertical;
}
.studio-canvas-tool-panel__submit {
margin-top: auto;
padding: 10px 20px;
border-radius: 8px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.studio-canvas-tool-panel__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.studio-canvas-tool-panel__actions {
display: flex;
gap: 10px;
margin-top: auto;
}
.studio-canvas-tool-panel__reset {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
cursor: pointer;
}
.studio-canvas-tool-panel__canvas-wrap {
position: relative;
width: 100%;
max-height: 320px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
}
.studio-canvas-tool-panel__canvas-bg {
width: 100%;
height: auto;
display: block;
}
.studio-canvas-tool-panel__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
-3
View File
@@ -3577,8 +3577,6 @@
font-size: 13px; font-size: 13px;
} }
} }
<<<<<<< HEAD
=======
/* Script review left panel overflow guard: keep actions available while history remains scrollable. */ /* Script review left panel overflow guard: keep actions available while history remains scrollable. */
.script-eval-v5-left { .script-eval-v5-left {
@@ -4087,4 +4085,3 @@
.script-eval-v5.is-ready .script-eval-v5-status-dot { .script-eval-v5.is-ready .script-eval-v5-status-dot {
box-shadow: none; box-shadow: none;
} }
>>>>>>> c1c4086383ddd7c1c8c152c2d5a97a4f432fa260