import { ArrowLeftOutlined, CameraOutlined, ColumnWidthOutlined, CustomerServiceOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, FontSizeOutlined, InboxOutlined, LoadingOutlined, PlayCircleOutlined, RightOutlined, ScissorOutlined, SwapOutlined, ThunderboltOutlined, VideoCameraOutlined, } from "@ant-design/icons"; import { useCallback, useEffect, useRef, useState } from "react"; 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); 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); } }; return (
角色迁移
{characterFile && videoFile ? "素材已就绪" : "迁移预览"} {characterFile || videoFile ? "人物 + 视频" : "待上传"}
人物图 {characterFile ? "已就绪" : "待上传"}
参考视频 {videoFile ? "已就绪" : "待上传"}
} canvas={ isCreating ? (
角色迁移中...
{progress}%
) : resultUrl ? (
) : videoPreview ? (
) : (
上传人物图与参考视频
将静态角色迁移到参考视频的动作与表情中。
) } rightPanel={
迁移设置
驱动提示词