Files
omniai-web/src/features/image-workbench/ImageWorkbenchPage.tsx
T

1431 lines
60 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
ArrowLeftOutlined,
CameraOutlined,
CheckCircleFilled,
ClearOutlined,
ColumnWidthOutlined,
CustomerServiceOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FontSizeOutlined,
HighlightOutlined,
LinkOutlined,
LoadingOutlined,
MinusOutlined,
PictureOutlined,
PlusOutlined,
InboxOutlined,
RightOutlined,
ScissorOutlined,
SwapOutlined,
TableOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
2026-06-05 19:49:50 +08:00
import "../../styles/pages/more-tools.css";
2026-06-05 17:19:38 +08:00
import "../../styles/pages/image-workbench.css";
2026-06-02 12:38:01 +08:00
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
2026-06-02 12:38:01 +08:00
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import { useCanvasDrawing } from "./useCanvasDrawing";
import CameraViewport3D from "./CameraViewport3D";
type WorkMode = "single" | "blend";
type OutputSize = "9:16" | "16:9" | "4:3" | "3:4" | "1:1";
type OutputCount = 1 | 2 | 3 | 4;
2026-06-05 19:17:35 +08:00
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
2026-06-02 12:38:01 +08:00
const SIZE_TO_RATIO: Record<OutputSize, string> = {
"9:16": "9:16",
"16:9": "16:9",
"4:3": "4:3",
"3:4": "3:4",
"1:1": "1:1",
};
const cameraPresets = [
{ name: "自定义", rotationX: 0, rotationY: 0, rotationZ: 0, shotScale: 5 },
{ name: "正面平视", rotationX: 0, rotationY: 0, rotationZ: 0, shotScale: 5 },
{ name: "正面俯拍", rotationX: -35, rotationY: 0, rotationZ: 0, shotScale: 4 },
{ name: "正面仰拍", rotationX: 28, rotationY: 0, rotationZ: 0, shotScale: 6 },
{ name: "左侧平视", rotationX: 0, rotationY: 90, rotationZ: 0, shotScale: 5 },
{ name: "右侧平视", rotationX: 0, rotationY: -90, rotationZ: 0, shotScale: 5 },
{ name: "正背面", rotationX: 0, rotationY: 180, rotationZ: 0, shotScale: 5 },
{ name: "左肩越肩", rotationX: -5, rotationY: 35, rotationZ: 0, shotScale: 7 },
{ name: "右肩越肩", rotationX: -5, rotationY: -35, rotationZ: 0, shotScale: 7 },
{ name: "背身反打", rotationX: 2, rotationY: 180, rotationZ: 0, shotScale: 6 },
{ name: "俯拍全景", rotationX: -55, rotationY: 20, rotationZ: 0, shotScale: 1 },
{ name: "虫视角", rotationX: 65, rotationY: 0, rotationZ: 0, shotScale: 8 },
{ name: "鸟瞰", rotationX: -85, rotationY: 0, rotationZ: 0, shotScale: 2 },
{ name: "低斜近拍", rotationX: 4, rotationY: 78, rotationZ: 18, shotScale: 6 },
{ name: "鱼眼贴脸", rotationX: 8, rotationY: 42, rotationZ: 0, shotScale: 2 },
{ name: "英雄仰角", rotationX: 40, rotationY: -15, rotationZ: -8, shotScale: 7 },
{ name: "戏剧肩角", rotationX: -50, rotationY: 45, rotationZ: 12, shotScale: 3 },
];
const CAMERA_EFFECT_PRESETS = [
{ key: "dof", label: "浅景深", prompt: "浅景深,背景虚化,主体清晰突出" },
{ key: "wide-angle", label: "广角畸变", prompt: "广角镜头畸变效果,边缘拉伸,空间感强" },
{ key: "telephoto", label: "长焦压缩", prompt: "长焦镜头压缩感,前后景距离感缩小,画面扁平" },
{ key: "motion-blur", label: "运动模糊", prompt: "运动模糊效果,速度感,动态拖影" },
{ key: "handheld", label: "手持抖动", prompt: "手持摄影轻微晃动感,纪实风格,真实感" },
{ key: "fisheye", label: "鱼眼", prompt: "鱼眼镜头效果,180度超广角,强烈桶形畸变" },
{ key: "low-angle", label: "低机位", prompt: "低机位拍摄,从地面向上取景,增强高度感和力量感" },
{ key: "high-angle", label: "高机位", prompt: "高机位俯拍,从上方取景,展示全貌和渺小感" },
{ key: "aerial", label: "航拍感", prompt: "航拍视角,无人机空中俯瞰,大场景纵深感" },
{ key: "cinematic", label: "电影感", prompt: "电影感色调,2.39:1宽银幕构图感觉,戏剧性光影" },
{ key: "vintage", label: "复古胶片", prompt: "复古胶片质感,颗粒感,暖色调,怀旧氛围" },
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
] as const;
2026-06-05 18:27:08 +08:00
const CAMERA_EFFECT_PROMPT_BY_KEY = new Map<string, string>(
CAMERA_EFFECT_PRESETS.map((effect) => [effect.key, effect.prompt]),
);
function getCameraEffectsPrompt(effectKeys: Set<string>): string {
if (effectKeys.size === 0) return "";
const prompts: string[] = [];
for (const key of effectKeys) {
const prompt = CAMERA_EFFECT_PROMPT_BY_KEY.get(key);
if (prompt) prompts.push(prompt);
}
return prompts.join("");
}
2026-06-02 12:38:01 +08:00
function shotScaleToZoom(shotScale: number): number {
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
}
const cameraDirections = [
{ key: "up", label: "↑" },
{ key: "left", label: "←" },
{ key: "center", label: "⌂" },
{ key: "right", label: "→" },
{ key: "down", label: "↓" },
] as const;
interface ImageWorkbenchPageProps {
initialTool?: WebImageWorkbenchTool;
onOpenMore?: () => void;
onSelectView?: (view: WebViewKey) => void;
}
function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectView }: ImageWorkbenchPageProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const inpaintFileInputRef = useRef<HTMLInputElement>(null);
const cameraFileInputRef = useRef<HTMLInputElement>(null);
const [activeTool, setActiveTool] = useState<WebImageWorkbenchTool>(initialTool);
const [mode, setMode] = useState<WorkMode>("single");
const [imageUrlInput, setImageUrlInput] = useState("");
const [referenceImages, setReferenceImages] = useState<string[]>([]);
const [inpaintImage, setInpaintImage] = useState<string | null>(null);
const [inpaintUrlInput, setInpaintUrlInput] = useState("");
const [brushSize, setBrushSize] = useState(40);
const [isMaskEditing, setIsMaskEditing] = useState(false);
const [inpaintTool, setInpaintTool] = useState<"brush" | "eraser">("brush");
const [inpaintZoom, setInpaintZoom] = useState(1);
const [canvasInitCounter, setCanvasInitCounter] = useState(0);
const inpaintCanvasRef = useRef<HTMLCanvasElement>(null);
const [cameraImage, setCameraImage] = useState<string | null>(null);
const [cameraUrlInput, setCameraUrlInput] = useState("");
const [cameraPreset, setCameraPreset] = useState("自定义");
const [cameraDirection, setCameraDirection] = useState("center");
const [cameraHorizontal, setCameraHorizontal] = useState(0);
const [cameraVertical, setCameraVertical] = useState(0);
const [cameraRoll, setCameraRoll] = useState(0);
const [cameraZoom, setCameraZoom] = useState(40);
const [cameraPromptEnabled, setCameraPromptEnabled] = useState(true);
const [cameraPrompt, setCameraPrompt] = useState("");
const [cameraEffects, setCameraEffects] = useState<Set<string>>(new Set());
const [prompt, setPrompt] = useState("");
const [outputSize, setOutputSize] = useState<OutputSize>("1:1");
const [outputCount, setOutputCount] = useState<OutputCount>(1);
const [status, setStatus] = useState("上传参考图并输入提示词后开始");
const [generating, setGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState(0);
const [resultImages, setResultImages] = useState<string[]>([]);
const [inpaintResultImages, setInpaintResultImages] = useState<string[]>([]);
const [cameraResultImages, setCameraResultImages] = useState<string[]>([]);
const [downloadingResultUrl, setDownloadingResultUrl] = useState<string | null>(null);
const [savingAssetResultUrl, setSavingAssetResultUrl] = useState<string | null>(null);
const [generationError, setGenerationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCameraDragging, setIsCameraDragging] = useState(false);
2026-06-02 12:38:01 +08:00
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const keepaliveRestoredRef = useRef(false);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("imagewb");
if (!saved || saved.resultUrl) return;
setGenerating(true);
abortRef.current = false;
taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, {
2026-06-05 16:43:02 +08:00
kind: "image",
onProgress: (e) => {
setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) {
setResultImages([e.resultUrl]);
clearToolTaskState("imagewb");
setGenerating(false);
setStatus("恢复任务完成");
}
if (e.status === "failed") {
clearToolTaskState("imagewb");
setGenerating(false);
setStatus("恢复任务失败");
}
},
});
}, []);
2026-06-02 12:38:01 +08:00
useEffect(() => {
return () => {
abortRef.current = true;
};
}, []);
const handleCancel = useCallback(() => {
abortRef.current = true;
if (taskIdRef.current) {
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
taskIdRef.current = null;
}
clearToolTaskState("imagewb");
2026-06-02 12:38:01 +08:00
setGenerating(false);
setGenerationProgress(0);
setStatus("已取消");
}, []);
const referenceImage = referenceImages[0] ?? null;
const {
imageSize: inpaintCanvasSize,
hasMask,
initCanvas: initInpaintCanvas,
startDrawing: maskStartDrawing,
draw: maskDraw,
stopDrawing: maskStopDrawing,
handleTouchStart: maskTouchStart,
handleTouchMove: maskTouchMove,
handleTouchEnd: maskTouchEnd,
clearMask,
exportMaskDataUrl,
} = useCanvasDrawing({ baseImage: inpaintImage, brushSize, activeTool: inpaintTool });
useEffect(() => {
if (!inpaintImage) return;
const timer = window.setTimeout(() => initInpaintCanvas(inpaintCanvasRef.current), 0);
return () => window.clearTimeout(timer);
}, [inpaintImage, initInpaintCanvas, canvasInitCounter]);
useEffect(() => {
setActiveTool(initialTool);
}, [initialTool]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (!files.length) return;
const selectedFiles = mode === "blend" ? files : files.slice(0, 1);
selectedFiles.forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") return;
setReferenceImages((current) => (mode === "blend" ? [...current, reader.result as string] : [reader.result as string]));
setStatus(mode === "blend" ? `已追加 ${file.name}` : `已导入 ${file.name}`);
};
reader.readAsDataURL(file);
});
event.target.value = "";
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (!files.length) return;
const selectedFiles = mode === 'blend' ? files : files.slice(0, 1);
selectedFiles.forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') return;
setReferenceImages((current) => (mode === 'blend' ? [...current, reader.result as string] : [reader.result as string]));
setStatus(mode === 'blend' ? `已追加 ${file.name}` : `已导入 ${file.name}`);
};
reader.readAsDataURL(file);
});
};
2026-06-02 12:38:01 +08:00
const handleAddUrl = () => {
const nextUrl = imageUrlInput.trim();
if (!nextUrl) return;
setReferenceImages((current) => (mode === "blend" ? [...current, nextUrl] : [nextUrl]));
setImageUrlInput("");
setStatus("已添加 URL 参考图");
};
const handleRemoveReferenceImage = (index = 0) => {
setReferenceImages((current) => current.filter((_, imageIndex) => imageIndex !== index));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setStatus("已删除参考图");
};
const handleInpaintFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") return;
setInpaintImage(reader.result);
setInpaintResultImages([]);
setIsMaskEditing(false);
setStatus(`已导入局部重绘素材 ${file.name}`);
};
reader.readAsDataURL(file);
event.target.value = "";
};
const [isInpaintDragging, setIsInpaintDragging] = useState(false);
const handleInpaintDragOver = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
const handleInpaintDragLeave = (e: DragEvent<HTMLElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
const handleInpaintDrop = (e: DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setIsInpaintDragging(false);
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
2026-06-02 12:38:01 +08:00
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") return;
setInpaintImage(reader.result);
setInpaintResultImages([]);
setIsMaskEditing(false);
setStatus(`已导入局部重绘素材 ${file.name}`);
};
reader.readAsDataURL(file);
};
const handleAddInpaintUrl = () => {
const nextUrl = inpaintUrlInput.trim();
if (!nextUrl) return;
setInpaintImage(nextUrl);
setInpaintResultImages([]);
setIsMaskEditing(false);
setInpaintUrlInput("");
setStatus("已添加局部重绘素材 URL");
};
const handleRemoveInpaintImage = () => {
setInpaintImage(null);
setIsMaskEditing(false);
clearMask(inpaintCanvasRef.current);
if (inpaintFileInputRef.current) {
inpaintFileInputRef.current.value = "";
}
setStatus("已删除局部重绘素材");
};
const handleStartInpaint = async () => {
if (!inpaintImage) {
setStatus("请先上传原图再开始局部重绘");
return;
}
if (!hasMask) {
setStatus("请先编辑页面,涂抹需要重绘的区域");
2026-06-02 12:38:01 +08:00
return;
}
if (generating) return;
if (isMaskEditing) setIsMaskEditing(false);
abortRef.current = false;
setGenerating(true);
setGenerationProgress(0);
setGenerationError(null);
setResultImages([]);
setInpaintResultImages([]);
setCameraResultImages([]);
setStatus("正在上传素材...");
try {
const maskDataUrl = exportMaskDataUrl();
const imagesToUpload = maskDataUrl ? [inpaintImage, maskDataUrl] : [inpaintImage];
const refUrls = await uploadReferenceImages(imagesToUpload);
const ratio = SIZE_TO_RATIO[outputSize];
const model = "wan2.7-image";
setStatus("正在生成局部重绘...");
const { taskId } = await aiGenerationClient.createImageTask({
model,
prompt: prompt || "图1是原图,图2的绿色标记是需要重绘的区域,在图1的标记区域内生成与周围画面风格一致的新内容,光影融合自然,无拼接痕迹",
ratio,
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
2026-06-02 12:38:01 +08:00
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
const durableUrl = await persistResultUrl(tempUrl);
setInpaintResultImages([durableUrl || tempUrl]);
}
setStatus(tempUrl ? "局部重绘完成" : "已取消");
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
setGenerationError(msg);
setStatus(`局部重绘失败: ${msg}`);
} finally {
setGenerating(false);
setGenerationProgress(0);
}
};
const handleCameraFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") return;
setCameraImage(reader.result);
setStatus(`已导入镜头参考图 ${file.name}`);
};
reader.readAsDataURL(file);
event.target.value = "";
};
const handleCameraDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(true);
};
const handleCameraDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(false);
};
const handleCameraDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(false);
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith('image/'));
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') return;
setCameraImage(reader.result);
setStatus(`已导入镜头参考图 ${file.name}`);
};
reader.readAsDataURL(file);
};
2026-06-02 12:38:01 +08:00
const handleAddCameraUrl = () => {
const nextUrl = cameraUrlInput.trim();
if (!nextUrl) return;
setCameraImage(nextUrl);
setCameraUrlInput("");
setStatus("已添加镜头参考图 URL");
};
const handleRemoveCameraImage = () => {
setCameraImage(null);
if (cameraFileInputRef.current) {
cameraFileInputRef.current.value = "";
}
setStatus("已删除镜头参考图");
};
const handleStartCamera = async () => {
if (!cameraImage) {
setStatus("请先导入参考图再构建镜头");
return;
}
if (generating) return;
abortRef.current = false;
setGenerating(true);
setGenerationProgress(0);
setGenerationError(null);
setCameraResultImages([]);
setStatus("正在上传镜头参考图...");
try {
const refUrls = await uploadReferenceImages([cameraImage]);
const model = "wan2.7-image-pro";
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
2026-06-05 18:27:08 +08:00
const effectsDesc = getCameraEffectsPrompt(cameraEffects);
2026-06-02 12:38:01 +08:00
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}${cameraPrompt}`
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
setStatus("正在生成镜头画面...");
const { taskId } = await aiGenerationClient.createImageTask({
model,
prompt: fullPrompt,
ratio: "16:9",
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
2026-06-02 12:38:01 +08:00
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
const durableUrl = await persistResultUrl(tempUrl);
setCameraResultImages([durableUrl || tempUrl]);
}
setStatus(tempUrl ? `镜头生成完成 · ${cameraPreset} · ${cameraZoom}mm` : "已取消");
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
setGenerationError(msg);
setStatus(`镜头生成失败: ${msg}`);
} finally {
setGenerating(false);
setGenerationProgress(0);
}
};
const uploadReferenceImages = useCallback(async (images: string[]): Promise<string[]> => {
const urls: string[] = [];
for (const img of images) {
if (img.startsWith("data:")) {
const result = await aiGenerationClient.uploadAsset({ dataUrl: img, scope: "workbench-ref" });
urls.push(result.url);
} else {
urls.push(img);
}
}
return urls;
}, []);
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
return waitForTask(taskId, {
2026-06-05 16:43:02 +08:00
kind: "image",
2026-06-02 12:38:01 +08:00
abortRef,
onProgress: (e) => setGenerationProgress(e.progress || 0),
});
}, []);
const persistResultUrl = useCallback(async (tempUrl: string | null): Promise<string | null> => {
if (!tempUrl) return null;
try {
const uploaded = await aiGenerationClient.uploadAssetByUrl({
sourceUrl: tempUrl,
name: `workbench-result-${Date.now()}`,
mimeType: "image/png",
scope: "workbench-result",
});
return uploaded.url || tempUrl;
} catch {
return tempUrl;
}
}, []);
const getResultBaseName = (index = 0) => {
const toolName = activeTool === "camera" ? "camera" : activeTool === "inpaint" ? "inpaint" : "image-workbench";
return `${toolName}-result-${index + 1}-${Date.now()}`;
};
const getResultTags = () => {
const toolName = activeTool === "camera" ? "镜头实验室" : activeTool === "inpaint" ? "局部重绘" : "图片工作台";
return ["工具盒", toolName, "生成图片"];
};
const handleDownloadResult = async (url: string, index = 0) => {
if (downloadingResultUrl) return;
setDownloadingResultUrl(url);
try {
const status = await saveToolResultToLocal({
url,
name: getResultBaseName(index),
type: "image",
isVideo: false,
});
setStatus(status === "saved" ? "已保存到本地" : "已开始保存到本地");
} catch (error) {
setStatus(error instanceof Error ? error.message : "保存本地失败");
} finally {
setDownloadingResultUrl(null);
}
};
const handleAddResultToAssets = async (url: string, index = 0) => {
if (savingAssetResultUrl) return;
setSavingAssetResultUrl(url);
try {
const status = await addToolResultToAssetLibrary({
url,
name: getResultBaseName(index),
type: "image",
isVideo: false,
description: "从工具盒图片生成结果加入的素材。",
tags: getResultTags(),
});
setStatus(status === "server" ? "已加入资产库" : "已加入本地资产库");
} catch (error) {
setStatus(error instanceof Error ? error.message : "加入资产库失败");
} finally {
setSavingAssetResultUrl(null);
}
};
const renderResultActions = (url: string, index = 0) => (
<div className="image-workbench-result-actions">
<button type="button" onClick={() => void handleDownloadResult(url, index)} disabled={downloadingResultUrl === url}>
<DownloadOutlined />
{downloadingResultUrl === url ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets(url, index)} disabled={savingAssetResultUrl === url}>
<InboxOutlined />
{savingAssetResultUrl === url ? "加入中" : "加入资产库"}
</button>
</div>
);
const handleGenerate = async () => {
if (!referenceImages.length && !prompt.trim()) {
setStatus("请先上传参考图或输入提示词");
return;
}
if (generating) return;
abortRef.current = false;
setGenerating(true);
setGenerationProgress(0);
setGenerationError(null);
setResultImages([]);
setStatus("正在上传参考图...");
try {
const refUrls = referenceImages.length ? await uploadReferenceImages(referenceImages) : undefined;
const ratio = SIZE_TO_RATIO[outputSize];
const model = "wan2.7-image";
const results: string[] = [];
for (let i = 0; i < outputCount; i++) {
if (abortRef.current) break;
setStatus(`正在生成第 ${i + 1}/${outputCount} 张...`);
setGenerationProgress(0);
const { taskId } = await aiGenerationClient.createImageTask({
model,
prompt: prompt || "按照参考图风格生成",
ratio,
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
2026-06-05 16:43:02 +08:00
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
2026-06-02 12:38:01 +08:00
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
results.push(tempUrl);
setResultImages([...results]);
persistResultUrl(tempUrl).then((durableUrl) => {
if (durableUrl && durableUrl !== tempUrl) {
setResultImages((prev) => prev.map((u) => u === tempUrl ? durableUrl : u));
}
});
}
}
setResultImages(results);
clearToolTaskState("imagewb");
if (results.length) {
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: results[0], status: "完成", progress: 100 });
}
2026-06-02 12:38:01 +08:00
setStatus(results.length ? `生成完成,共 ${results.length}` : "生成已取消");
} catch (err) {
const msg = err instanceof Error ? err.message : "生成失败";
setGenerationError(msg);
setStatus(`生成失败: ${msg}`);
} finally {
setGenerating(false);
setGenerationProgress(0);
}
};
const handleClear = () => {
handleCancel();
setReferenceImages([]);
setPrompt("");
setImageUrlInput("");
setOutputSize("1:1");
setOutputCount(1);
setMode("single");
setGenerating(false);
setGenerationProgress(0);
setResultImages([]);
setInpaintResultImages([]);
setCameraResultImages([]);
setGenerationError(null);
setStatus("上传参考图并输入提示词后开始");
};
const activeToolTitle = activeTool === "inpaint" ? "局部重绘" : activeTool === "camera" ? "镜头实验室" : "图片工作台";
return (
<section className="image-workbench-page image-workbench-page--image-tool">
<header className="image-workbench-topbar">
<button type="button" className="image-workbench-back-to-more" onClick={onOpenMore}>
</button>
<div className="image-workbench-tool-strip" aria-label="图片工具">
<button type="button" className={activeTool === "workbench" ? "is-active" : ""} onClick={() => setActiveTool("workbench")}>
<EditOutlined />
</button>
<button type="button" className={activeTool === "inpaint" ? "is-active" : ""} onClick={() => setActiveTool("inpaint")}>
<ScissorOutlined />
</button>
<button type="button" className={activeTool === "camera" ? "is-active" : ""} onClick={() => setActiveTool("camera")}>
<CameraOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("digitalHuman")}>
<CustomerServiceOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("characterMix")}>
<SwapOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("resolutionUpscale")}>
<ColumnWidthOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("watermarkRemoval")}>
<DeleteOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("subtitleRemoval")}>
<FontSizeOutlined />
</button>
</div>
</header>
<div className="image-workbench-subbar">
<div className="image-workbench-subbar__heading">
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
<ArrowLeftOutlined />
</button>
<strong className="image-workbench-subbar__title">{activeToolTitle}</strong>
</div>
{activeTool === "workbench" ? (
<div className="image-workbench-mode-tabs" role="tablist" aria-label="图片工作模式">
<button type="button" className={mode === "single" ? "is-active" : ""} onClick={() => setMode("single")}>
<PictureOutlined />
</button>
<button type="button" className={mode === "blend" ? "is-active" : ""} onClick={() => setMode("blend")}>
<TableOutlined />
</button>
</div>
) : activeTool === "camera" ? (
<div className="image-workbench-camera-summary" aria-label="镜头参数">
<strong>{cameraPreset === "自定义" ? "中景 / 正面" : cameraPreset}</strong>
<span>{cameraZoom}mm</span>
</div>
) : null}
<button
type="button"
className={`image-workbench-icon-btn image-workbench-subbar__next${activeTool === "inpaint" ? " image-workbench-subbar__next--solo" : ""}`}
aria-label="下一项"
>
<RightOutlined />
</button>
</div>
{activeTool === "inpaint" ? (
<main className="image-workbench-layout image-workbench-layout--inpaint">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card">
<div className="image-workbench-section-title">
<h3></h3>
<span>{inpaintImage ? "待编辑" : "待上传"}</span>
</div>
<input
ref={inpaintFileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleInpaintFileChange}
/>
<div
className={`image-workbench-upload-shell${isInpaintDragging ? " is-dragging" : ""}`}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop}
>
{isInpaintDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
2026-06-02 12:38:01 +08:00
<button type="button" className="image-workbench-upload" onClick={() => inpaintFileInputRef.current?.click()}>
{inpaintImage ? <img src={inpaintImage} alt="" /> : <FileImageOutlined />}
<strong>{inpaintImage ? "更换原图" : "选择图片"}</strong>
<span>PNG / JPG / WebP</span>
</button>
{inpaintImage ? (
<button
type="button"
className="image-workbench-upload-remove"
aria-label="删除局部重绘素材"
onClick={handleRemoveInpaintImage}
>
×
</button>
) : null}
</div>
<div className="image-workbench-url-row">
<label>
<LinkOutlined />
<input
value={inpaintUrlInput}
placeholder="粘贴图片 URL"
onChange={(event) => setInpaintUrlInput(event.target.value)}
/>
</label>
<button type="button" onClick={handleAddInpaintUrl}></button>
</div>
</section>
<section className="image-workbench-control-card image-workbench-brush-card">
<h3></h3>
<label className="image-workbench-range">
<span></span>
<input
type="range"
min="8"
max="96"
step="2"
value={brushSize}
onChange={(event) => setBrushSize(Number(event.target.value))}
/>
<em>{brushSize}px</em>
</label>
</section>
<section className="image-workbench-control-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
2026-06-05 19:17:35 +08:00
{OUTPUT_SIZE_OPTIONS.map((s) => (
2026-06-02 12:38:01 +08:00
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s}
</button>
))}
</div>
</section>
<section className="image-workbench-control-card">
<h3></h3>
<textarea
className="image-workbench-prompt"
placeholder="描述需要重绘的内容,例如:将背景替换为森林"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={3}
/>
</section>
<div className="image-workbench-actions">
<button type="button" className="image-workbench-primary" onClick={handleStartInpaint} disabled={generating}>
{generating && activeTool === "inpaint" ? <LoadingOutlined /> : <ScissorOutlined />}
{generating && activeTool === "inpaint" ? "重绘中..." : "开始重绘"}
</button>
</div>
</aside>
<section className="image-workbench-canvas image-workbench-canvas--inpaint" aria-label="局部重绘画布">
{generating && activeTool === "inpaint" ? (
<div className="image-workbench-generating">
<LoadingOutlined style={{ fontSize: 32 }} />
<strong>...</strong>
<div className="image-workbench-progress-bar">
<div className="image-workbench-progress-fill" style={{ width: `${generationProgress}%` }} />
</div>
<span>{generationProgress}%</span>
<button type="button" className="image-workbench-cancel" onClick={handleCancel}></button>
</div>
) : inpaintResultImages.length && activeTool === "inpaint" ? (
<div className="image-workbench-inpaint-stage">
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "95%", maxHeight: "95%", borderRadius: 8, objectFit: "contain" }} />
2026-06-02 12:38:01 +08:00
<div className="image-workbench-inpaint-bottom-bar">
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintResultImages([]); setIsMaskEditing(true); setInpaintTool("brush"); setCanvasInitCounter((c) => c + 1); }}>
<HighlightOutlined />
2026-06-02 12:38:01 +08:00
</button>
{renderResultActions(inpaintResultImages[0], 0)}
{inpaintResultImages.length > 1 && (
<span className="image-workbench-inpaint-zoom-controls">
{inpaintResultImages.map((url, i) => (
<a key={url} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, color: "var(--accent)" }}>
{i + 1}
</a>
))}
</span>
)}
</div>
</div>
) : inpaintImage ? (
<div
className="image-workbench-inpaint-stage"
onWheel={(e) => {
setInpaintZoom((z) => Math.min(3, Math.max(0.5, z + (e.deltaY < 0 ? 0.1 : -0.1))));
}}
>
{isMaskEditing && (
<div className="image-workbench-inpaint-toolbar">
<button type="button" className={`image-workbench-inpaint-tool${inpaintTool === "brush" ? " is-active" : ""}`} onClick={() => setInpaintTool("brush")}>
<HighlightOutlined />
</button>
<button type="button" className={`image-workbench-inpaint-tool${inpaintTool === "eraser" ? " is-active" : ""}`} onClick={() => setInpaintTool("eraser")}>
<EditOutlined />
</button>
<button type="button" className="image-workbench-inpaint-tool" onClick={() => clearMask(inpaintCanvasRef.current)}>
<ClearOutlined />
</button>
<button type="button" className="image-workbench-inpaint-tool image-workbench-inpaint-tool--done" onClick={() => setIsMaskEditing(false)}>
<CheckCircleFilled />
</button>
</div>
)}
<canvas
ref={inpaintCanvasRef}
className="image-workbench-inpaint-canvas"
style={{
width: (inpaintCanvasSize.width || 400) * inpaintZoom,
height: (inpaintCanvasSize.height || 300) * inpaintZoom,
cursor: isMaskEditing ? (inpaintTool === "eraser" ? "cell" : "crosshair") : "default",
}}
onMouseDown={isMaskEditing ? maskStartDrawing : undefined}
onMouseMove={isMaskEditing ? maskDraw : undefined}
onMouseUp={isMaskEditing ? maskStopDrawing : undefined}
onMouseLeave={isMaskEditing ? maskStopDrawing : undefined}
onTouchStart={isMaskEditing ? maskTouchStart : undefined}
onTouchMove={isMaskEditing ? maskTouchMove : undefined}
onTouchEnd={isMaskEditing ? maskTouchEnd : undefined}
/>
<div className="image-workbench-inpaint-bottom-bar">
{!isMaskEditing && (
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintTool("brush"); setIsMaskEditing(true); }}>
<HighlightOutlined /> {hasMask ? "重新编辑页面" : "编辑页面"}
2026-06-02 12:38:01 +08:00
</button>
)}
<span className="image-workbench-inpaint-zoom-controls">
<button type="button" onClick={() => setInpaintZoom((z) => Math.max(0.5, z - 0.2))}><MinusOutlined /></button>
<span>{Math.round(inpaintZoom * 100)}%</span>
<button type="button" onClick={() => setInpaintZoom((z) => Math.min(3, z + 0.2))}><PlusOutlined /></button>
</span>
</div>
</div>
) : (
<div
className={`studio-canvas-ghost${isInpaintDragging ? " is-dragging" : ""}`}
2026-06-02 12:38:01 +08:00
onClick={() => inpaintFileInputRef.current?.click()}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
2026-06-02 12:38:01 +08:00
onDrop={handleInpaintDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inpaintFileInputRef.current?.click(); }}
2026-06-02 12:38:01 +08:00
>
<div className="studio-canvas-ghost__icon">
<FileImageOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> PNG / JPG / WebP使</div>
</div>
2026-06-02 12:38:01 +08:00
)}
</section>
</main>
) : activeTool === "camera" ? (
<main className="image-workbench-layout image-workbench-layout--camera">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card">
<div className="image-workbench-section-title">
<h3></h3>
<span>{cameraImage ? "已导入" : "待上传"}</span>
</div>
<input
ref={cameraFileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleCameraFileChange}
/>
<div
className={`image-workbench-upload-shell${isCameraDragging ? " is-dragging" : ""}`}
onDragOver={handleCameraDragOver}
onDragLeave={handleCameraDragLeave}
onDrop={handleCameraDrop}
>
{isCameraDragging && <div className="image-workbench-upload-overlay"></div>}
2026-06-02 12:38:01 +08:00
<button type="button" className="image-workbench-upload" onClick={() => cameraFileInputRef.current?.click()}>
{cameraImage ? <img src={cameraImage} alt="" /> : <FileImageOutlined />}
<strong>{cameraImage ? "更换参考图" : "导入参考图"}</strong>
<span>PNG / JPG / WebP</span>
</button>
{cameraImage ? (
<button
type="button"
className="image-workbench-upload-remove"
aria-label="删除镜头参考图"
onClick={handleRemoveCameraImage}
>
×
</button>
) : null}
</div>
<div className="image-workbench-url-row">
<label>
<LinkOutlined />
<input
value={cameraUrlInput}
placeholder="粘贴图片 URL"
onChange={(event) => setCameraUrlInput(event.target.value)}
/>
</label>
<button type="button" onClick={handleAddCameraUrl}></button>
</div>
</section>
<section className="image-workbench-control-card image-workbench-camera-presets">
<h3></h3>
<div className="image-workbench-chip-grid">
{cameraPresets.map((preset) => (
<button
key={preset.name}
type="button"
className={cameraPreset === preset.name ? "is-active" : ""}
onClick={() => {
setCameraPreset(preset.name);
if (preset.name !== "自定义") {
setCameraVertical(preset.rotationX);
setCameraHorizontal(preset.rotationY);
setCameraRoll(preset.rotationZ);
setCameraZoom(shotScaleToZoom(preset.shotScale));
}
}}
>
{preset.name}
</button>
))}
</div>
</section>
<section className="image-workbench-control-card image-workbench-camera-direction">
<h3></h3>
<div className="image-workbench-direction-pad">
{cameraDirections.map((direction) => (
<button
key={direction.key}
type="button"
className={cameraDirection === direction.key ? "is-active" : ""}
onClick={() => {
setCameraDirection(direction.key);
setCameraPreset("自定义");
const step = 15;
if (direction.key === "up") setCameraVertical((v) => Math.min(45, v + step));
else if (direction.key === "down") setCameraVertical((v) => Math.max(-45, v - step));
else if (direction.key === "left") setCameraHorizontal((v) => Math.min(180, v + step));
else if (direction.key === "right") setCameraHorizontal((v) => Math.max(-180, v - step));
else { setCameraVertical(0); setCameraHorizontal(0); setCameraRoll(0); setCameraZoom(40); }
}}
>
{direction.label}
</button>
))}
</div>
</section>
<section className="image-workbench-control-card image-workbench-camera-fine">
<h3></h3>
<label className="image-workbench-range">
<span></span>
<input
type="range"
min="-180"
max="180"
value={cameraHorizontal}
onChange={(event) => { setCameraHorizontal(Number(event.target.value)); setCameraPreset("自定义"); }}
/>
<em>{cameraHorizontal}°</em>
</label>
<label className="image-workbench-range">
<span></span>
<input
type="range"
min="-85"
max="65"
value={cameraVertical}
onChange={(event) => { setCameraVertical(Number(event.target.value)); setCameraPreset("自定义"); }}
/>
<em>{cameraVertical}°</em>
</label>
<label className="image-workbench-range">
<span></span>
<input
type="range"
min="-30"
max="30"
value={cameraRoll}
onChange={(event) => { setCameraRoll(Number(event.target.value)); setCameraPreset("自定义"); }}
/>
<em>{cameraRoll}°</em>
</label>
<label className="image-workbench-range">
<span></span>
<input
type="range"
min="24"
max="100"
value={cameraZoom}
onChange={(event) => { setCameraZoom(Number(event.target.value)); setCameraPreset("自定义"); }}
/>
<em>{cameraZoom < 40 ? "广角" : cameraZoom > 70 ? "长焦" : "中景"}</em>
</label>
</section>
<section className="image-workbench-control-card image-workbench-camera-effects">
<div className="image-workbench-section-title">
<h3></h3>
<span style={{ fontSize: 11, color: "var(--fg-muted, #888)" }}></span>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{CAMERA_EFFECT_PRESETS.map((effect) => {
const isActive = cameraEffects.has(effect.key);
return (
<button
key={effect.key}
type="button"
className={`image-workbench-chip${isActive ? " is-active" : ""}`}
style={{
padding: "3px 8px",
borderRadius: 999,
fontSize: 11,
border: `1px solid ${isActive ? "var(--accent, #2dd4bf)" : "var(--border-subtle, #333)"}`,
background: isActive ? "rgba(45, 212, 191, 0.12)" : "transparent",
color: isActive ? "var(--accent, #2dd4bf)" : "var(--fg-body, #eee)",
cursor: "pointer",
transition: "all 0.15s",
}}
onClick={() => {
setCameraEffects((prev) => {
const next = new Set(prev);
if (next.has(effect.key)) next.delete(effect.key);
else next.add(effect.key);
return next;
});
setCameraPreset("自定义");
}}
>
{effect.label}
</button>
);
})}
</div>
</section>
<section className="image-workbench-control-card image-workbench-camera-prompt">
<div className="image-workbench-section-title">
<h3></h3>
<button
type="button"
className={`image-workbench-toggle${cameraPromptEnabled ? " is-active" : ""}`}
aria-label="启用提示词"
onClick={() => setCameraPromptEnabled((current) => !current)}
/>
</div>
<textarea
value={cameraPrompt}
disabled={!cameraPromptEnabled}
placeholder="可选:补充额外的镜头要求,如光线、氛围、运动感等"
onChange={(event) => setCameraPrompt(event.target.value)}
/>
</section>
<div className="image-workbench-actions">
<button type="button" className="image-workbench-primary" onClick={handleStartCamera} disabled={generating}>
{generating && activeTool === "camera" ? <LoadingOutlined /> : <CameraOutlined />}
{generating && activeTool === "camera" ? "生成中..." : "生成"}
</button>
</div>
</aside>
<section className="image-workbench-canvas image-workbench-canvas--camera" aria-label="镜头实验室画布">
{generating && activeTool === "camera" ? (
<div className="image-workbench-generating">
<LoadingOutlined style={{ fontSize: 32 }} />
<strong>...</strong>
<div className="image-workbench-progress-bar">
<div className="image-workbench-progress-fill" style={{ width: `${generationProgress}%` }} />
</div>
<span>{generationProgress}%</span>
<button type="button" className="image-workbench-cancel" onClick={handleCancel}></button>
</div>
) : cameraResultImages.length && activeTool === "camera" ? (
<div className="image-workbench-camera-stage">
<img src={cameraResultImages[0]} alt="镜头结果" />
<span>{cameraPreset} · {cameraZoom}mm</span>
{renderResultActions(cameraResultImages[0], 0)}
</div>
) : (
<CameraViewport3D
horizontal={cameraHorizontal}
vertical={cameraVertical}
roll={cameraRoll}
zoom={cameraZoom}
onOrbit={(h, v) => { setCameraHorizontal(h); setCameraVertical(v); setCameraPreset("自定义"); }}
onZoom={(z) => { setCameraZoom(z); setCameraPreset("自定义"); }}
onReset={() => { setCameraHorizontal(0); setCameraVertical(0); setCameraRoll(0); setCameraZoom(40); setCameraPreset("正面平视"); }}
referenceImage={cameraImage}
/>
)}
</section>
</main>
) : (
<main className="image-workbench-layout">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card image-workbench-mode-card">
<h3></h3>
<div className="image-workbench-segmented">
<button type="button" className={mode === "single" ? "is-active" : ""} onClick={() => setMode("single")}>
<PictureOutlined />
</button>
<button type="button" className={mode === "blend" ? "is-active" : ""} onClick={() => setMode("blend")}>
<TableOutlined />
</button>
</div>
</section>
<section className="image-workbench-control-card">
<div className="image-workbench-section-title">
<h3></h3>
<span>{mode === "blend" ? `${referenceImages.length || 0}` : referenceImage ? "已导入" : "需1张"}</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple={mode === "blend"}
onChange={handleFileChange}
/>
{mode === "blend" ? (
<div className="image-workbench-reference-grid">
{referenceImages.map((image, index) => (
<div className="image-workbench-reference-thumb" key={`${image}-${index}`}>
<img src={image} alt={`参考图 ${index + 1}`} />
<button
type="button"
className="image-workbench-upload-remove"
aria-label={`删除参考图 ${index + 1}`}
onClick={() => handleRemoveReferenceImage(index)}
>
×
</button>
</div>
))}
<button
type="button"
className="image-workbench-upload image-workbench-upload--tile"
onClick={() => fileInputRef.current?.click()}
>
<FileImageOutlined />
<strong>{referenceImages.length ? "继续上传" : "导入参考图"}</strong>
<span>PNG / JPG / WebP</span>
</button>
</div>
) : (
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && <div className="image-workbench-upload-overlay"></div>}
2026-06-02 12:38:01 +08:00
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
{referenceImage ? <img src={referenceImage} alt="" /> : <FileImageOutlined />}
<strong>{referenceImage ? "更换参考图" : "导入参考图"}</strong>
<span>PNG / JPG / WebP</span>
</button>
{referenceImage ? (
<button
type="button"
className="image-workbench-upload-remove"
aria-label="删除参考图"
onClick={() => handleRemoveReferenceImage(0)}
>
×
</button>
) : null}
</div>
)}
<div className="image-workbench-url-row">
<label>
<LinkOutlined />
<input
value={imageUrlInput}
placeholder="粘贴图片 URL"
onChange={(event) => setImageUrlInput(event.target.value)}
/>
</label>
<button type="button" onClick={handleAddUrl}></button>
</div>
</section>
<section className="image-workbench-control-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
{(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s}
</button>
))}
</div>
<div className="image-workbench-count">
<span></span>
<div>
{([1, 2, 3, 4] as OutputCount[]).map((count) => (
<button
key={count}
type="button"
className={outputCount === count ? "is-active" : ""}
onClick={() => setOutputCount(count)}
>
{count}
</button>
))}
</div>
</div>
</section>
2026-06-02 12:38:01 +08:00
<section className="image-workbench-control-card image-workbench-prompt">
<h3></h3>
<textarea
value={prompt}
placeholder="例如:改成米白色大衣,保留构图与人物特征。"
onChange={(event) => setPrompt(event.target.value)}
/>
</section>
<div className="image-workbench-actions">
<button type="button" className="image-workbench-primary" onClick={handleGenerate} disabled={generating}>
{generating ? <LoadingOutlined /> : <ScissorOutlined />}
{generating ? "生成中..." : "开始生成"}
</button>
<button type="button" onClick={handleClear}>
<ClearOutlined />
</button>
</div>
</aside>
<section className="image-workbench-canvas" aria-label="图片预览区">
{generating ? (
<div className="image-workbench-generating">
<LoadingOutlined style={{ fontSize: 32 }} />
<strong>...</strong>
<div className="image-workbench-progress-bar">
<div className="image-workbench-progress-fill" style={{ width: `${generationProgress}%` }} />
</div>
<span>{generationProgress}%</span>
<button type="button" className="image-workbench-cancel" onClick={handleCancel}>
</button>
</div>
) : resultImages.length ? (
<div className="image-workbench-result-grid">
{resultImages.map((url, i) => (
<div key={url} className="image-workbench-result-card">
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-item">
<img src={url} alt={`生成结果 ${i + 1}`} />
</a>
{renderResultActions(url, i)}
</div>
))}
</div>
) : generationError ? (
<div className="image-workbench-empty image-workbench-empty--error">
<PictureOutlined />
<strong></strong>
<span>{generationError}</span>
</div>
) : mode === "blend" && referenceImages.length ? (
<div className="image-workbench-canvas-grid">
{referenceImages.map((image, index) => (
<img src={image} alt={`参考图预览 ${index + 1}`} key={`${image}-preview-${index}`} />
))}
</div>
) : referenceImage ? (
<div className="studio-canvas-image">
<img src={referenceImage} alt="参考图预览" />
</div>
2026-06-02 12:38:01 +08:00
) : (
<div
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
>
<div className="studio-canvas-ghost__icon">
<PictureOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> (PNG / JPG / WebP)</div>
2026-06-02 12:38:01 +08:00
</div>
)}
</section>
</main>
)}
<footer className="image-workbench-status">
<span></span>
<p>{status}</p>
<em>
<ColumnWidthOutlined />
{activeTool === "inpaint"
? "局部重绘"
: activeTool === "camera"
? `AZ ${cameraHorizontal} PT ${cameraVertical} ${cameraZoom}mm${cameraEffects.size ? ` + ${cameraEffects.size} 效果` : ""}`
: `${mode === "single" ? "单图" : "融合"} · ${outputSize}`}
</em>
</footer>
</section>
);
}
export default ImageWorkbenchPage;