import { ArrowLeftOutlined, CameraOutlined, ColumnWidthOutlined, CustomerServiceOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, FontSizeOutlined, InboxOutlined, LoadingOutlined, PlayCircleOutlined, RightOutlined, ScissorOutlined, SwapOutlined, VideoCameraOutlined, } 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 StudioToolLayout from "../../components/StudioToolLayout"; import type { WebImageWorkbenchTool, WebViewKey } from "../../types"; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { uploadAssetWithProgress } from "../../api/uploadWithProgress"; import { waitForTask } from "../../api/taskSubscription"; import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive"; import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions"; import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons"; interface CharacterMixPageProps { isAuthenticated: boolean; onOpenMore?: () => void; onOpenImageTool?: (tool: WebImageWorkbenchTool) => void; onSelectView?: (view: WebViewKey) => void; } function CharacterMixPage({ isAuthenticated, onOpenMore, onOpenImageTool, onSelectView, }: CharacterMixPageProps) { const [characterFile, setCharacterFile] = useState(""); const [characterPreview, setCharacterPreview] = useState(""); const [characterDataUrl, setCharacterDataUrl] = useState(""); const [videoFile, setVideoFile] = useState(""); const [videoPreview, setVideoPreview] = useState(""); const [videoDataUrl, setVideoDataUrl] = useState(""); const [promptInput, setPromptInput] = useState(""); const [watermark, setWatermark] = useState(false); const [checkImage, setCheckImage] = useState(true); const [faceHint, setFaceHint] = useState(null); const faceDetectTimerRef = useRef | null>(null); const [notice, setNotice] = useState("等待上传角色图和参考视频"); const [isCreating, setIsCreating] = useState(false); const [isDownloadingResult, setIsDownloadingResult] = useState(false); const [isSavingResultAsset, setIsSavingResultAsset] = useState(false); const [progress, setProgress] = useState(0); const [resultUrl, setResultUrl] = useState(null); const abortRef = useRef(false); const taskIdRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isCanvasDragging, setIsCanvasDragging] = useState(false); const characterInputRef = useRef(null); const videoInputRef = useRef(null); useEffect(() => { return () => { if (characterPreview) URL.revokeObjectURL(characterPreview); }; }, [characterPreview]); useEffect(() => { return () => { if (videoPreview) URL.revokeObjectURL(videoPreview); }; }, [videoPreview]); useEffect(() => { if (faceDetectTimerRef.current) clearTimeout(faceDetectTimerRef.current); if (!checkImage || !characterPreview) { setFaceHint(null); return; } setFaceHint("analyzing"); faceDetectTimerRef.current = setTimeout(() => { setFaceHint("ready"); }, 800); return () => { if (faceDetectTimerRef.current) clearTimeout(faceDetectTimerRef.current); }; }, [checkImage, characterPreview]); const keepaliveRestoredRef = useRef(false); // Keep-alive: restore saved task on mount useEffect(() => { if (keepaliveRestoredRef.current) return; keepaliveRestoredRef.current = true; const saved = loadToolTaskState("charactermix"); if (!saved || saved.resultUrl) return; setIsCreating(true); abortRef.current = false; void pollTaskUntilDone(saved.taskId).then((result) => { setResultUrl(result); setNotice(result ? "角色迁移完成" : "已取消"); setIsCreating(false); setProgress(0); if (result) { saveToolTaskState("charactermix", { taskId: saved.taskId, resultUrl: result, status: "完成", progress: 100 }); } else { clearToolTaskState("charactermix"); } }); }, []); useEffect(() => { return () => { abortRef.current = true; }; }, []); const handleCancel = useCallback(() => { abortRef.current = true; if (taskIdRef.current) { aiGenerationClient.cancelTask(taskIdRef.current).catch(() => {}); taskIdRef.current = null; } clearToolTaskState("charactermix"); }, []); const pollTaskUntilDone = useCallback(async (taskId: string): Promise => { return waitForTask(taskId, { abortRef, onProgress: (e) => setProgress(e.progress || 0), }); }, []); const handleCreateTask = async () => { if (isCreating) return; if (!characterDataUrl) { setNotice("请先上传人物图"); return; } if (!videoDataUrl) { setNotice("请先上传参考视频"); return; } if (!isAuthenticated) { setNotice("请先登录后再创建角色迁移任务。"); return; } abortRef.current = false; setIsCreating(true); setProgress(0); setResultUrl(null); setNotice("正在上传素材..."); try { const [imageAsset, videoAsset] = await Promise.all([ uploadAssetWithProgress( { dataUrl: characterDataUrl, scope: "character-mix", mimeType: "image/png" }, { onProgress: (p) => setNotice(`上传人物图 ${p}%`) }, ), uploadAssetWithProgress( { dataUrl: videoDataUrl, scope: "character-mix", mimeType: "video/mp4" }, { onProgress: (p) => setNotice(`上传参考视频 ${p}%`) }, ), ]); const prompt = promptInput.trim() || "保持角色原有服装和面部特征,动作流畅自然"; setNotice("正在生成角色迁移视频..."); const { taskId } = await aiGenerationClient.createVideoTask({ model: "wan2.2-animate-mix", prompt, imageUrl: imageAsset.url, referenceUrls: [videoAsset.url], hasReferenceVideo: true, muted: !watermark, }); taskIdRef.current = taskId; saveToolTaskState("charactermix", { taskId, status: "running", progress: 0 }); const result = await pollTaskUntilDone(taskId); setResultUrl(result); setNotice(result ? "角色迁移完成" : "已取消"); if (result) { saveToolTaskState("charactermix", { taskId, resultUrl: result, status: "完成", progress: 100 }); } else { clearToolTaskState("charactermix"); } } catch (error) { setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。"); } finally { setIsCreating(false); setProgress(0); } }; const handleDownloadResult = async () => { if (!resultUrl || isDownloadingResult) return; setIsDownloadingResult(true); try { const status = await saveToolResultToLocal({ url: resultUrl, name: `character-mix-${Date.now()}`, type: "video", isVideo: true, taskId: taskIdRef.current || undefined, }); setNotice(status === "saved" ? "已保存到本地" : "已开始保存到本地"); } catch (error) { setNotice(error instanceof Error ? error.message : "保存本地失败"); } finally { setIsDownloadingResult(false); } }; const handleAddResultToAssets = async () => { if (!resultUrl || isSavingResultAsset) return; setIsSavingResultAsset(true); try { const status = await addToolResultToAssetLibrary({ url: resultUrl, name: `角色迁移-${Date.now()}`, type: "video", isVideo: true, taskId: taskIdRef.current || undefined, description: "从工具盒角色迁移生成的视频。", tags: ["工具盒", "角色迁移", "生成视频"], }); setNotice(status === "server" ? "已加入资产库" : "已加入本地资产库"); } catch (error) { setNotice(error instanceof Error ? error.message : "加入资产库失败"); } finally { setIsSavingResultAsset(false); } }; const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); }; const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); }; const handleDrop = (e: DragEvent) => { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer?.files?.[0]; if (!file) return; if (file.type.startsWith("image/")) { if (characterPreview) URL.revokeObjectURL(characterPreview); setCharacterFile(file.name); setCharacterPreview(URL.createObjectURL(file)); const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === "string") setCharacterDataUrl(reader.result); }; reader.readAsDataURL(file); setNotice(`已选择人物图 ${file.name}`); } else if (file.type.startsWith("video/")) { if (videoPreview) URL.revokeObjectURL(videoPreview); setVideoFile(file.name); setVideoPreview(URL.createObjectURL(file)); const reader2 = new FileReader(); reader2.onload = () => { if (typeof reader2.result === "string") setVideoDataUrl(reader2.result); }; reader2.readAsDataURL(file); setNotice(`已选择参考视频 ${file.name}`); } }; const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); }; const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); }; const handleCanvasDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsCanvasDragging(false); handleDrop(e); }; const handleCanvasClick = () => { if (!characterPreview) { characterInputRef.current?.click(); } else if (!videoPreview) { videoInputRef.current?.click(); } }; return (
角色迁移
{characterFile && videoFile ? "素材已就绪" : "迁移预览"} {characterFile || videoFile ? "人物 + 视频" : "待上传"}
{isDragging ? (
释放文件以上传
) : null}
人物图 {characterFile ? "已就绪" : "待上传"}
参考视频 {videoFile ? "已就绪" : "待上传"}
} canvas={ isCreating ? (
角色迁移中...
{progress}%
) : resultUrl ? (
) : videoPreview ? (
) : (
{ if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }} >
上传人物图与参考视频
点击或拖拽上传;支持人物图片 (PNG/JPG) 和参考视频 (MP4/MOV/AVI)
) } rightPanel={
迁移设置
驱动提示词