Files
omniai-web/src/features/character-mix/CharacterMixPage.tsx
T
stringadmin 4a298d205b
Web Quality / verify (push) Has been cancelled
chore: reduce frontend lint warnings
2026-06-09 12:02:30 +08:00

569 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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);
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<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 });
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 (
<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
leftPanel={
<div
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}
<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}
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>
</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}
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>
</label>
</div>
</div>
</div>
}
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(); }}
>
<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>
</div>
)
}
rightPanel={
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 10 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</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>
}
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;