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 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 SIZE_TO_RATIO: Record = { "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; function shotScaleToZoom(shotScale: number): number { const map: Record = { 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(null); const inpaintFileInputRef = useRef(null); const cameraFileInputRef = useRef(null); const [activeTool, setActiveTool] = useState(initialTool); const [mode, setMode] = useState("single"); const [imageUrlInput, setImageUrlInput] = useState(""); const [referenceImages, setReferenceImages] = useState([]); const [inpaintImage, setInpaintImage] = useState(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(null); const [cameraImage, setCameraImage] = useState(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>(new Set()); const [prompt, setPrompt] = useState(""); const [outputSize, setOutputSize] = useState("1:1"); const [outputCount, setOutputCount] = useState(1); const [status, setStatus] = useState("上传参考图并输入提示词后开始"); const [generating, setGenerating] = useState(false); const [generationProgress, setGenerationProgress] = useState(0); const [resultImages, setResultImages] = useState([]); const [inpaintResultImages, setInpaintResultImages] = useState([]); const [cameraResultImages, setCameraResultImages] = useState([]); const [downloadingResultUrl, setDownloadingResultUrl] = useState(null); const [savingAssetResultUrl, setSavingAssetResultUrl] = useState(null); const [generationError, setGenerationError] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isCameraDragging, setIsCameraDragging] = useState(false); const abortRef = useRef(false); const taskIdRef = useRef(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, { 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) => { 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) => { 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) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); }; const handleInpaintDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); }; const handleInpaintDrop = (e: DragEvent) => { 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) => { 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 = cameraEffects.size > 0 ? Array.from(cameraEffects).map((key) => CAMERA_EFFECT_PRESETS.find((e) => e.key === key)?.prompt).filter(Boolean).join(",") : ""; 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 => { 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 => { return waitForTask(taskId, { abortRef, onProgress: (e) => setGenerationProgress(e.progress || 0), }); }, []); const persistResultUrl = useCallback(async (tempUrl: string | null): Promise => { 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) => (
); 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 (
{activeToolTitle}
{activeTool === "workbench" ? (
) : activeTool === "camera" ? (
{cameraPreset === "自定义" ? "中景 / 正面" : cameraPreset} {cameraZoom}mm
) : null}
{activeTool === "inpaint" ? (