a6626beb32
Web Quality / verify (pull_request) Has been cancelled
本次更新对多个功能页面进行了系统性的 UI/UX 打磨,统一了交互模式并补充了缺失的状态反馈。 ## 新增功能 - WorkbenchPage: 图片提示词案例区域新增加载骨架屏、错误回退、空数据三种状态展示 - CharacterMixPage: 新增左侧设置面板(驱动提示词、图像检测开关、水印开关),支持清除已上传的人物图/参考视频 - DigitalHumanPage: 新增左侧设置面板(提示词输入、去水印/保留原声开关),支持清除已上传的人像/音频,增加取消生成按钮 - ImageWorkbenchPage / ResolutionUpscalePage: 新增参数设置面板和资产清除交互 - MorePage: 新增页面入口 ## UI 优化 - 统一 Toggle 开关组件: 所有设置页面采用一致的 .studio-toggle 交互模式 - 资产清除: 各上传区域新增清除按钮,含二次确认和提示反馈 - 生成按钮: 统一为带图标的 .studio-generate-btn,增加 disabled/loading 状态 - ConversationSidebar / ProjectSidebar: 侧边栏交互细节优化 ## 样式升级 - image-workbench.css: 大幅扩展样式 (+1900 行),覆盖设置面板、上传区、结果展示等 - workbench.css: 新增 666 行样式,含骨架屏动画、案例卡片网格、状态占位等 - subtitle-removal.css: 补充设置面板样式
614 lines
24 KiB
TypeScript
614 lines
24 KiB
TypeScript
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();
|
||
}
|
||
};
|
||
|
||
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>
|
||
);
|
||
|
||
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
|
||
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}
|
||
<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>
|
||
{characterPreview ? (
|
||
<button
|
||
type="button"
|
||
className="studio-upload-slot__remove"
|
||
aria-label="移除人物图"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
clearCharacterAsset();
|
||
}}
|
||
>
|
||
<DeleteOutlined />
|
||
</button>
|
||
) : null}
|
||
</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>
|
||
{videoPreview ? (
|
||
<button
|
||
type="button"
|
||
className="studio-upload-slot__remove"
|
||
aria-label="移除参考视频"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
clearReferenceVideo();
|
||
}}
|
||
>
|
||
<DeleteOutlined />
|
||
</button>
|
||
) : null}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
{characterMixSettingsPanel}
|
||
</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>
|
||
)
|
||
}
|
||
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;
|