Files
omniai-web/src/features/character-mix/CharacterMixPage.tsx
T

614 lines
24 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
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";
2026-06-05 19:49:50 +08:00
import "../../styles/pages/more-tools.css";
2026-06-05 17:19:38 +08:00
import "../../styles/pages/image-workbench.css";
2026-06-02 12:38:01 +08:00
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";
2026-06-02 12:38:01 +08:00
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<string | null>(null);
const faceDetectTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string | null>(null);
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const characterInputRef = useRef<HTMLInputElement | null>(null);
const videoInputRef = useRef<HTMLInputElement | null>(null);
2026-06-02 12:38:01 +08:00
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");
}
});
}, []);
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;
}
clearToolTaskState("charactermix");
2026-06-02 12:38:01 +08:00
}, []);
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
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 });
2026-06-02 12:38:01 +08:00
const result = await pollTaskUntilDone(taskId);
setResultUrl(result);
setNotice(result ? "角色迁移完成" : "已取消");
if (result) {
saveToolTaskState("charactermix", { taskId, resultUrl: result, status: "完成", progress: 100 });
} else {
clearToolTaskState("charactermix");
}
2026-06-02 12:38:01 +08:00
} 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();
}
};
const clearCharacterAsset = () => {
if (characterPreview) URL.revokeObjectURL(characterPreview);
setCharacterFile("");
setCharacterPreview("");
setCharacterDataUrl("");
setFaceHint(null);
if (characterInputRef.current) characterInputRef.current.value = "";
setNotice("已移除人物图");
};
const clearReferenceVideo = () => {
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile("");
setVideoPreview("");
setVideoDataUrl("");
if (videoInputRef.current) videoInputRef.current.value = "";
setNotice("已移除参考视频");
};
const characterMixSettingsPanel = (
<div className="studio-panel__section character-mix-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="character-mix-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
2026-06-02 12:38:01 +08:00
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<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" onClick={() => onOpenImageTool?.("workbench")}>
<EditOutlined />
</button>
<button type="button" onClick={() => onOpenImageTool?.("inpaint")}>
<ScissorOutlined />
</button>
<button type="button" onClick={() => onOpenImageTool?.("camera")}>
<CameraOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("digitalHuman")}>
<CustomerServiceOutlined />
</button>
<button type="button" className="is-active">
<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">
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
<ArrowLeftOutlined />
</button>
<strong></strong>
<div className="image-workbench-camera-summary" aria-label="角色迁移状态">
<strong>{characterFile && videoFile ? "素材已就绪" : "迁移预览"}</strong>
<span>{characterFile || videoFile ? "人物 + 视频" : "待上传"}</span>
</div>
<button type="button" className="image-workbench-icon-btn" aria-label="下一项">
<RightOutlined />
</button>
</div>
<StudioToolLayout
noTop
noRight
2026-06-02 12:38:01 +08:00
leftPanel={
<div
className="character-mix-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{ position: "relative" }}
>
{isDragging ? (
<div style={{ position: "absolute", inset: 0, zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.55)", border: "2px dashed var(--primary, #4a9eff)", borderRadius: 12, pointerEvents: "none" }}>
<span style={{ fontSize: 18, color: "#fff", fontWeight: 600 }}></span>
</div>
) : null}
2026-06-02 12:38:01 +08:00
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
<span className={`studio-panel__section-chip studio-panel__section-chip--${characterFile ? "ready" : "waiting"}`}>
{characterFile ? "已就绪" : "待上传"}
</span>
</div>
<div className="studio-panel__section-body">
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input
ref={characterInputRef}
2026-06-02 12:38:01 +08:00
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
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}`);
}}
/>
{characterPreview ? (
<img src={characterPreview} alt="" className="studio-upload-slot--filled__thumb" />
) : (
<span className="studio-upload-slot--empty__icon">
<InboxOutlined />
</span>
)}
<span className="studio-upload-slot--filled__info">
<strong>{characterFile || "上传人物图"}</strong>
<small></small>
</span>
{characterPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除人物图"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearCharacterAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
2026-06-02 12:38:01 +08:00
</label>
</div>
</div>
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
<span className={`studio-panel__section-chip studio-panel__section-chip--${videoFile ? "ready" : "waiting"}`}>
{videoFile ? "已就绪" : "待上传"}
</span>
</div>
<div className="studio-panel__section-body">
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
<input
ref={videoInputRef}
2026-06-02 12:38:01 +08:00
type="file"
accept="video/*"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile(file.name);
setVideoPreview(URL.createObjectURL(file));
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") setVideoDataUrl(reader.result);
};
reader.readAsDataURL(file);
setNotice(`已选择参考视频 ${file.name}`);
}}
/>
<span className="studio-upload-slot--empty__icon">
<VideoCameraOutlined />
</span>
<span className="studio-upload-slot--filled__info">
<strong>{videoFile || "上传参考视频"}</strong>
<small>MP4 / MOV / AVI</small>
</span>
{videoPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考视频"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearReferenceVideo();
}}
>
<DeleteOutlined />
</button>
) : null}
2026-06-02 12:38:01 +08:00
</label>
</div>
</div>
{characterMixSettingsPanel}
</div>
2026-06-02 12:38:01 +08:00
}
canvas={
isCreating ? (
<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: `${progress}%` }} />
</div>
<span>{progress}%</span>
<button type="button" className="image-workbench-cancel" onClick={handleCancel}></button>
</div>
) : resultUrl ? (
<div className="studio-canvas-video">
<video src={resultUrl} controls playsInline style={{ maxHeight: "100%", maxWidth: "100%", borderRadius: 12 }} />
{characterPreview && (
<div className="studio-canvas-pip">
<img src={characterPreview} alt="人物图" />
</div>
)}
</div>
) : videoPreview ? (
<div className="studio-canvas-video">
<video src={videoPreview} controls muted playsInline />
{characterPreview ? (
<div className="studio-canvas-pip">
<img src={characterPreview} alt="人物图" />
</div>
) : null}
</div>
) : (
<div
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
onClick={handleCanvasClick}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
>
2026-06-02 12:38:01 +08:00
<div className="studio-canvas-ghost__icon">
<SwapOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> (PNG/JPG) (MP4/MOV/AVI)</div>
2026-06-02 12:38:01 +08:00
</div>
)
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--idle"></span>
<span className="studio-status-bar__text">{notice}</span>
</>
}
/>
</section>
);
}
export default CharacterMixPage;