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,
|
|
|
|
|
|
ThunderboltOutlined,
|
|
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
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";
|
2026-06-03 01:39:06 +08:00
|
|
|
|
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-05 19:17:35 +08:00
|
|
|
|
const OUTPUT_SIZE_OPTIONS: OutputSize[] = ["9:16", "16:9", "4:3", "3:4", "1:1"];
|
|
|
|
|
|
const OUTPUT_COUNT_OPTIONS: OutputCount[] = [1, 2, 3, 4];
|
|
|
|
|
|
|
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 abortRef = useRef(false);
|
|
|
|
|
|
const taskIdRef = useRef<string | null>(null);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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;
|
2026-06-04 01:12:51 +08:00
|
|
|
|
setGenerating(true);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
abortRef.current = false;
|
|
|
|
|
|
taskIdRef.current = saved.taskId;
|
|
|
|
|
|
void waitForTask(saved.taskId, {
|
2026-06-05 16:43:02 +08:00
|
|
|
|
kind: "image",
|
2026-06-03 01:39:06 +08:00
|
|
|
|
onProgress: (e) => {
|
|
|
|
|
|
setStatus(`${e.status} / ${e.progress}%`);
|
|
|
|
|
|
if (e.status === "completed" && e.resultUrl) {
|
|
|
|
|
|
setResultImages([e.resultUrl]);
|
|
|
|
|
|
clearToolTaskState("imagewb");
|
2026-06-04 01:12:51 +08:00
|
|
|
|
setGenerating(false);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
setStatus("恢复任务完成");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.status === "failed") {
|
|
|
|
|
|
clearToolTaskState("imagewb");
|
2026-06-04 01:12:51 +08:00
|
|
|
|
setGenerating(false);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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 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 handleInpaintDrop = (event: React.DragEvent) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
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;
|
|
|
|
|
|
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;
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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 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;
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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">
|
|
|
|
|
|
<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">
|
2026-06-03 20:36:07 +08:00
|
|
|
|
<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 /> 重新编辑遮罩
|
|
|
|
|
|
</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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="image-workbench-empty image-workbench-empty--button"
|
|
|
|
|
|
onClick={() => inpaintFileInputRef.current?.click()}
|
|
|
|
|
|
onDragOver={(e) => e.preventDefault()}
|
|
|
|
|
|
onDrop={handleInpaintDrop}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FileImageOutlined />
|
|
|
|
|
|
<strong>拖拽或选择图片</strong>
|
|
|
|
|
|
<span>支持 PNG / JPG / WebP</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<aside className="image-workbench-panel image-workbench-panel--right">
|
|
|
|
|
|
<section className="image-workbench-right-note">
|
|
|
|
|
|
<div className="image-workbench-section-title">
|
|
|
|
|
|
<h3>遮罩</h3>
|
|
|
|
|
|
<span>{isMaskEditing ? (inpaintTool === "eraser" ? "橡皮中" : "画笔中") : hasMask ? "已保存" : "待编辑"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span>{inpaintImage ? (hasMask ? "遮罩区域已标记,可开始重绘。" : "点击画布上的「编辑遮罩」开始涂抹。") : "上传原图后可编辑遮罩"}</span>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="image-workbench-right-note">
|
|
|
|
|
|
<div className="image-workbench-section-title">
|
|
|
|
|
|
<h3>结果</h3>
|
|
|
|
|
|
<span>{inpaintResultImages.length > 0 ? `${inpaintResultImages.length} 张` : "待生成"}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{inpaintResultImages.length > 0 ? (
|
|
|
|
|
|
<div className="image-workbench-result-grid">
|
|
|
|
|
|
{inpaintResultImages.map((url, i) => (
|
|
|
|
|
|
<div key={url} className="image-workbench-result-card">
|
|
|
|
|
|
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-thumb">
|
|
|
|
|
|
<img src={url} alt={`重绘结果 ${i + 1}`} />
|
|
|
|
|
|
</a>
|
|
|
|
|
|
{renderResultActions(url, i)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span>重绘结果会显示在这里</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</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">
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<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 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 ? (
|
2026-06-03 19:15:45 +08:00
|
|
|
|
<div className="studio-canvas-image">
|
|
|
|
|
|
<img src={referenceImage} alt="参考图预览" />
|
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
) : (
|
2026-06-03 19:15:45 +08:00
|
|
|
|
<div className="studio-canvas-ghost">
|
|
|
|
|
|
<div className="studio-canvas-ghost__icon">
|
|
|
|
|
|
<PictureOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-ghost__title">上传参考图后在此预览</div>
|
|
|
|
|
|
<div className="studio-canvas-ghost__hint">生成结果也会显示在这里</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<aside className="image-workbench-panel image-workbench-panel--right">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<div className="image-workbench-count">
|
|
|
|
|
|
<span>数量</span>
|
|
|
|
|
|
<div>
|
2026-06-05 19:17:35 +08:00
|
|
|
|
{OUTPUT_COUNT_OPTIONS.map((count) => (
|
2026-06-02 12:38:01 +08:00
|
|
|
|
<button
|
|
|
|
|
|
key={count}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={outputCount === count ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setOutputCount(count)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{count} 张
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</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;
|