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

1434 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeftOutlined,
CameraOutlined,
CheckCircleFilled,
ClearOutlined,
ColumnWidthOutlined,
CustomerServiceOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FontSizeOutlined,
HighlightOutlined,
LinkOutlined,
LoadingOutlined,
MinusOutlined,
PictureOutlined,
PlusOutlined,
InboxOutlined,
RightOutlined,
ScissorOutlined,
SwapOutlined,
TableOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { translateTaskError } from "../../utils/translateTaskError";
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;
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
const OUTPUT_COUNT_OPTIONS: OutputCount[] = [1, 2, 3, 4];
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;
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("");
}
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);
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, {
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("恢复任务失败");
}
},
});
}, []);
useEffect(() => {
return () => {
abortRef.current = true;
};
}, []);
const handleCancel = useCallback(() => {
abortRef.current = true;
if (taskIdRef.current) {
aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {});
taskIdRef.current = null;
}
clearToolTaskState("imagewb");
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);
});
};
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/"));
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("请先编辑页面,涂抹需要重绘的区域");
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 });
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);
};
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`;
const effectsDesc = getCameraEffectsPrompt(cameraEffects);
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 });
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, {
kind: "image",
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;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
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 });
}
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}
<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">
{OUTPUT_SIZE_OPTIONS.map((s) => (
<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" }} />
<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 />
</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 ? "重新编辑页面" : "编辑页面"}
</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" : ""}`}
onClick={() => inpaintFileInputRef.current?.click()}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inpaintFileInputRef.current?.click(); }}
>
<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>
)}
</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>}
<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>}
<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>
<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>
) : (
<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>
</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;