643 lines
27 KiB
TypeScript
643 lines
27 KiB
TypeScript
import {
|
||
ArrowLeftOutlined,
|
||
AudioOutlined,
|
||
CameraOutlined,
|
||
CloseCircleOutlined,
|
||
ColumnWidthOutlined,
|
||
CustomerServiceOutlined,
|
||
DeleteOutlined,
|
||
DownloadOutlined,
|
||
EditOutlined,
|
||
FontSizeOutlined,
|
||
InboxOutlined,
|
||
PlayCircleOutlined,
|
||
RightOutlined,
|
||
ScissorOutlined,
|
||
SwapOutlined,
|
||
ThunderboltOutlined,
|
||
UserOutlined,
|
||
} 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 { aiGenerationClient } from "../../api/aiGenerationClient";
|
||
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
|
||
import { waitForTask } from "../../api/taskSubscription";
|
||
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
||
import { getServerBaseUrl } from "../../api/serverConnection";
|
||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||
import StudioToolLayout from "../../components/StudioToolLayout";
|
||
import type { WebGenerationPreviewTask, WebImageWorkbenchTool, WebViewKey } from "../../types";
|
||
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
|
||
|
||
interface DigitalHumanPageProps {
|
||
isAuthenticated: boolean;
|
||
isAdmin?: boolean;
|
||
onCreateTask: (input: CreatePreviewTaskInput) => Promise<WebGenerationPreviewTask>;
|
||
onRequireLogin: (input: {
|
||
title: string;
|
||
type: WebGenerationPreviewTask["type"];
|
||
prompt: string;
|
||
}) => void;
|
||
onOpenMore?: () => void;
|
||
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
|
||
onSelectView?: (view: WebViewKey) => void;
|
||
}
|
||
|
||
function formatFileSize(bytes: number): string {
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 KB";
|
||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||
}
|
||
|
||
function summarizeUrl(value: string): string {
|
||
try {
|
||
const url = new URL(value);
|
||
const lastSegment = url.pathname.split("/").filter(Boolean).pop();
|
||
return lastSegment ? `${url.host}/.../${lastSegment}` : url.host;
|
||
} catch {
|
||
return value.length > 72 ? `${value.slice(0, 34)}...${value.slice(-28)}` : value;
|
||
}
|
||
}
|
||
|
||
function getGenerationAssetUrl(asset: { url: string; signedUrl?: string }): string {
|
||
return asset.signedUrl || asset.url;
|
||
}
|
||
|
||
function getCurrentApiBaseLabel(): string {
|
||
return getServerBaseUrl() || "/api";
|
||
}
|
||
|
||
function DigitalHumanPage({
|
||
isAuthenticated,
|
||
onCreateTask,
|
||
onRequireLogin,
|
||
onOpenMore,
|
||
onOpenImageTool,
|
||
onSelectView,
|
||
}: DigitalHumanPageProps) {
|
||
const [imageName, setImageName] = useState("");
|
||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||
const [imagePreview, setImagePreview] = useState("");
|
||
const [audioName, setAudioName] = useState("");
|
||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||
const [audioPreview, setAudioPreview] = useState("");
|
||
const [promptInput, setPromptInput] = useState("");
|
||
const [watermark, setWatermark] = useState(false);
|
||
const [keepOriginalAudio, setKeepOriginalAudio] = useState(true);
|
||
const [notice, setNotice] = useState("等待上传参考图和音频");
|
||
const [isCreating, setIsCreating] = useState(false);
|
||
const [isDownloadingResult, setIsDownloadingResult] = useState(false);
|
||
const [isSavingResultAsset, setIsSavingResultAsset] = useState(false);
|
||
const [resultVideoUrl, setResultVideoUrl] = useState("");
|
||
const [activeTaskId, setActiveTaskId] = useState("");
|
||
const [taskProgress, setTaskProgress] = useState(0);
|
||
const pollRunRef = useRef(0);
|
||
const cancelRef = useRef(false);
|
||
const activeTaskIdRef = useRef(activeTaskId);
|
||
activeTaskIdRef.current = activeTaskId;
|
||
const keepaliveRestoredRef = useRef(false);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||
};
|
||
}, [imagePreview]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (audioPreview) URL.revokeObjectURL(audioPreview);
|
||
};
|
||
}, [audioPreview]);
|
||
|
||
// Keep-alive: restore saved task on mount
|
||
useEffect(() => {
|
||
if (keepaliveRestoredRef.current) return;
|
||
keepaliveRestoredRef.current = true;
|
||
const saved = loadToolTaskState("digital-human");
|
||
if (!saved || saved.resultUrl) return;
|
||
setIsCreating(true);
|
||
cancelRef.current = false;
|
||
pollRunRef.current += 1;
|
||
setActiveTaskId(saved.taskId);
|
||
void waitForTaskResult(saved.taskId).catch(() => {});
|
||
setNotice("正在恢复数字人任务...");
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
pollRunRef.current += 1;
|
||
cancelRef.current = true;
|
||
};
|
||
}, []);
|
||
|
||
const fileToDataUrl = (file: File) =>
|
||
new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ""));
|
||
reader.onerror = () => reject(new Error("读取素材失败"));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
const handleCancel = useCallback(() => {
|
||
cancelRef.current = true;
|
||
if (activeTaskId) {
|
||
aiGenerationClient.cancelTask(activeTaskId).catch(() => {});
|
||
}
|
||
setIsCreating(false);
|
||
setNotice("已取消");
|
||
}, [activeTaskId]);
|
||
|
||
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 (imagePreview) URL.revokeObjectURL(imagePreview);
|
||
setImageName(file.name);
|
||
setImageFile(file);
|
||
setImagePreview(URL.createObjectURL(file));
|
||
setNotice(`已拖放参考图 ${file.name}`);
|
||
} else if (file.type.startsWith("audio/")) {
|
||
if (audioPreview) URL.revokeObjectURL(audioPreview);
|
||
setAudioName(file.name);
|
||
setAudioFile(file);
|
||
setAudioPreview(URL.createObjectURL(file));
|
||
setNotice(`已拖放音频 ${file.name}`);
|
||
}
|
||
};
|
||
|
||
const handleDownloadResult = async () => {
|
||
if (!resultVideoUrl || isDownloadingResult) return;
|
||
setIsDownloadingResult(true);
|
||
try {
|
||
const status = await saveToolResultToLocal({
|
||
url: resultVideoUrl,
|
||
name: `digital-human-${Date.now()}`,
|
||
type: "video",
|
||
isVideo: true,
|
||
taskId: activeTaskId || undefined,
|
||
});
|
||
setNotice(status === "saved" ? "已保存到本地" : "已开始保存到本地");
|
||
} catch (error) {
|
||
setNotice(error instanceof Error ? error.message : "保存本地失败");
|
||
} finally {
|
||
setIsDownloadingResult(false);
|
||
}
|
||
};
|
||
|
||
const handleAddResultToAssets = async () => {
|
||
if (!resultVideoUrl || isSavingResultAsset) return;
|
||
setIsSavingResultAsset(true);
|
||
try {
|
||
const status = await addToolResultToAssetLibrary({
|
||
url: resultVideoUrl,
|
||
name: `数字人-${Date.now()}`,
|
||
type: "video",
|
||
isVideo: true,
|
||
taskId: activeTaskId || undefined,
|
||
description: "从工具盒数字人生成功能加入的视频。",
|
||
tags: ["工具盒", "数字人", "生成视频"],
|
||
});
|
||
setNotice(status === "server" ? "已加入资产库" : "已加入本地资产库");
|
||
} catch (error) {
|
||
setNotice(error instanceof Error ? error.message : "加入资产库失败");
|
||
} finally {
|
||
setIsSavingResultAsset(false);
|
||
}
|
||
};
|
||
|
||
const pushDebugEntry = (
|
||
label: string,
|
||
detail: string,
|
||
level: "info" | "success" | "error" = "info",
|
||
data?: Record<string, unknown>,
|
||
) => {
|
||
console.debug("[DigitalHuman]", { stage: label, detail, level, ...(data ? { data } : {}) });
|
||
};
|
||
|
||
const waitForTaskResult = async (taskId: string): Promise<string> => {
|
||
const runId = ++pollRunRef.current;
|
||
setActiveTaskId(taskId);
|
||
setTaskProgress(0);
|
||
saveToolTaskState("digital-human", { taskId, status: "running", progress: 0 });
|
||
pushDebugEntry("开始订阅", `开始接收任务 ${taskId} 的生成结果。`);
|
||
|
||
const resultUrl = await waitForTask(taskId, {
|
||
abortRef: cancelRef,
|
||
onProgress: (e) => {
|
||
if (pollRunRef.current !== runId) return;
|
||
const progress = Math.max(0, Math.min(100, Math.trunc(e.progress || 0)));
|
||
setTaskProgress(progress);
|
||
setNotice(`任务 ${taskId} ${e.status},进度 ${progress}%`);
|
||
pushDebugEntry(
|
||
"状态更新",
|
||
`${e.status} / ${progress}%${e.resultUrl ? ` / ${summarizeUrl(e.resultUrl)}` : ""}`,
|
||
e.status === "failed" ? "error" : e.status === "completed" ? "success" : "info",
|
||
{ taskId, status: e },
|
||
);
|
||
if (e.status === "completed" && e.resultUrl) {
|
||
setResultVideoUrl(e.resultUrl);
|
||
setNotice(`任务完成,结果已接收:${taskId}`);
|
||
clearToolTaskState("digital-human");
|
||
pushDebugEntry("结果已接收", summarizeUrl(e.resultUrl), "success", { taskId, resultUrl: e.resultUrl });
|
||
}
|
||
},
|
||
});
|
||
|
||
if (cancelRef.current) throw new Error("已取消");
|
||
if (pollRunRef.current !== runId) throw new Error("当前轮询已被新的任务替换。");
|
||
if (!resultUrl) throw new Error("任务已完成,但服务端没有返回 resultUrl。");
|
||
return resultUrl;
|
||
};
|
||
|
||
const handleCreateTask = async () => {
|
||
if (isCreating) {
|
||
pushDebugEntry("重复提交", "当前已有提交流程正在进行,已忽略本次点击。");
|
||
return;
|
||
}
|
||
const taskInput = {
|
||
title: "数字人口播预览",
|
||
type: "digital-human" as const,
|
||
prompt: promptInput.trim() || `参考图: ${imageName || "未上传"}; 音频: ${audioName || "未上传"}; 生成数字人预览。`,
|
||
};
|
||
|
||
if (!isAuthenticated) {
|
||
onRequireLogin(taskInput);
|
||
pushDebugEntry("登录检查", "当前没有有效登录态,已打开登录拦截。", "error");
|
||
setNotice("请先登录后再创建数字人预览任务。");
|
||
return;
|
||
}
|
||
if (!imageFile || !audioFile) {
|
||
pushDebugEntry("素材检查", "缺少参考人像或音频源,无法提交。", "error", {
|
||
hasImage: Boolean(imageFile),
|
||
hasAudio: Boolean(audioFile),
|
||
});
|
||
setNotice("请先上传参考人像和音频。");
|
||
return;
|
||
}
|
||
|
||
setIsCreating(true);
|
||
setResultVideoUrl("");
|
||
setTaskProgress(0);
|
||
cancelRef.current = false;
|
||
pushDebugEntry("开始提交", `图片 ${imageFile.name},音频 ${audioFile.name}`, "info", {
|
||
image: { name: imageFile.name, type: imageFile.type, size: imageFile.size },
|
||
audio: { name: audioFile.name, type: audioFile.type, size: audioFile.size },
|
||
});
|
||
setNotice("正在上传素材...");
|
||
try {
|
||
pushDebugEntry(
|
||
"读取素材",
|
||
`${formatFileSize(imageFile.size)} 图片 / ${formatFileSize(audioFile.size)} 音频`,
|
||
);
|
||
const [imageDataUrl, audioDataUrl] = await Promise.all([
|
||
fileToDataUrl(imageFile),
|
||
fileToDataUrl(audioFile),
|
||
]);
|
||
pushDebugEntry("读取完成", `DataURL 已生成:图片 ${formatFileSize(imageDataUrl.length)},音频 ${formatFileSize(audioDataUrl.length)}`);
|
||
pushDebugEntry("上传素材", "正在上传参考图和音频到 OSS...");
|
||
const [imageAsset, audioAsset] = await Promise.all([
|
||
uploadAssetWithProgress(
|
||
{ dataUrl: imageDataUrl, name: imageFile.name, mimeType: imageFile.type || "image/png" },
|
||
{ onProgress: (p) => setNotice(`上传参考图 ${p}%`) },
|
||
),
|
||
uploadAssetWithProgress(
|
||
{ dataUrl: audioDataUrl, name: audioFile.name, mimeType: audioFile.type || "audio/mpeg" },
|
||
{ onProgress: (p) => setNotice(`上传音频 ${p}%`) },
|
||
),
|
||
]);
|
||
const missingSignedAssets = [
|
||
imageAsset.signedUrl ? "" : "图片",
|
||
audioAsset.signedUrl ? "" : "音频",
|
||
].filter(Boolean);
|
||
if (missingSignedAssets.length) {
|
||
const apiBase = getCurrentApiBaseLabel();
|
||
const message = `服务端上传接口未返回 signedUrl,${missingSignedAssets.join("、")}素材不能直接交给 DashScope。请先更新并重启 key server:${apiBase}`;
|
||
pushDebugEntry("服务端未更新", message, "error", {
|
||
apiBase,
|
||
imageUrl: imageAsset.url,
|
||
audioUrl: audioAsset.url,
|
||
hasImageSignedUrl: Boolean(imageAsset.signedUrl),
|
||
hasAudioSignedUrl: Boolean(audioAsset.signedUrl),
|
||
});
|
||
throw new Error(message);
|
||
}
|
||
|
||
setNotice("素材已上传,正在提交 wan2.2-s2v 任务...");
|
||
pushDebugEntry(
|
||
"上传完成",
|
||
`图片 ${summarizeUrl(getGenerationAssetUrl(imageAsset))};音频 ${summarizeUrl(getGenerationAssetUrl(audioAsset))}`,
|
||
"success",
|
||
{
|
||
imageUrl: imageAsset.url,
|
||
imageSignedUrl: imageAsset.signedUrl ? summarizeUrl(imageAsset.signedUrl) : "",
|
||
audioUrl: audioAsset.url,
|
||
audioSignedUrl: audioAsset.signedUrl ? summarizeUrl(audioAsset.signedUrl) : "",
|
||
},
|
||
);
|
||
const imageGenerationUrl = getGenerationAssetUrl(imageAsset);
|
||
const audioGenerationUrl = getGenerationAssetUrl(audioAsset);
|
||
const videoTaskPayload = {
|
||
model: "wan2.2-s2v",
|
||
prompt: taskInput.prompt,
|
||
imageUrl: imageGenerationUrl,
|
||
audioUrl: audioGenerationUrl,
|
||
quality: "720p",
|
||
style: "speech",
|
||
referenceUrls: [imageGenerationUrl],
|
||
};
|
||
pushDebugEntry("提交任务", "正在请求 /api/ai/video,模型 wan2.2-s2v。", "info", {
|
||
...videoTaskPayload,
|
||
imageUrl: summarizeUrl(imageGenerationUrl),
|
||
audioUrl: summarizeUrl(audioGenerationUrl),
|
||
referenceUrls: [summarizeUrl(imageGenerationUrl)],
|
||
});
|
||
const { taskId } = await aiGenerationClient.createVideoTask(videoTaskPayload);
|
||
pushDebugEntry("任务已创建", `DashScope 服务端任务 ID:${taskId}`, "success", { taskId });
|
||
await onCreateTask({
|
||
...taskInput,
|
||
params: {
|
||
existingTaskId: taskId,
|
||
model: "wan2.2-s2v",
|
||
quality: "720p",
|
||
referenceUrls: [imageAsset.url],
|
||
},
|
||
title: "wan2.2-s2v 数字人口播",
|
||
prompt: `${taskInput.prompt} 任务ID: ${taskId}`,
|
||
});
|
||
setNotice(`任务已提交,服务端将使用 DashScope 并发池生成。任务ID:${taskId}`);
|
||
pushDebugEntry("队列已同步", "页面任务队列已记录这次数字人任务。", "success", { taskId });
|
||
await waitForTaskResult(taskId);
|
||
} catch (error) {
|
||
const debugErrorMessage = error instanceof Error ? error.message : String(error || "任务创建失败");
|
||
pushDebugEntry("提交失败", debugErrorMessage, "error", { message: debugErrorMessage });
|
||
console.error("[DigitalHuman] submit failed", error);
|
||
setNotice(error instanceof Error ? error.message : "任务创建失败,请稍后重试。");
|
||
} finally {
|
||
setIsCreating(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="image-workbench-page digital-human-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" className="is-active">
|
||
<CustomerServiceOutlined />
|
||
数字人
|
||
</button>
|
||
<button type="button" onClick={() => onSelectView?.("characterMix")}>
|
||
<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>{imageName && audioName ? "素材已就绪" : "口播预览"}</strong>
|
||
<span>{imageName || audioName ? "人像 + 音频" : "待上传"}</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--${imageName ? "ready" : "waiting"}`}>
|
||
{imageName ? "已就绪" : "待上传"}
|
||
</span>
|
||
</div>
|
||
<div className="studio-panel__section-body">
|
||
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||
setImageName(file.name);
|
||
setImageFile(file);
|
||
setImagePreview(URL.createObjectURL(file));
|
||
pushDebugEntry("选择图片", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
|
||
setNotice(`已选择参考图 ${file.name}`);
|
||
}}
|
||
/>
|
||
{imagePreview ? (
|
||
<img src={imagePreview} alt="" className="studio-upload-slot--filled__thumb" />
|
||
) : (
|
||
<span className="studio-upload-slot--empty__icon">
|
||
<UserOutlined />
|
||
</span>
|
||
)}
|
||
<span className="studio-upload-slot--filled__info">
|
||
<strong>{imageName || "上传参考图"}</strong>
|
||
<small>PNG / JPG / WEBP</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--${audioName ? "ready" : "waiting"}`}>
|
||
{audioName ? "已就绪" : "待上传"}
|
||
</span>
|
||
</div>
|
||
<div className="studio-panel__section-body">
|
||
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||
<input
|
||
type="file"
|
||
accept="audio/*"
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
if (audioPreview) URL.revokeObjectURL(audioPreview);
|
||
setAudioName(file.name);
|
||
setAudioFile(file);
|
||
setAudioPreview(URL.createObjectURL(file));
|
||
pushDebugEntry("选择音频", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
|
||
setNotice(`已选择音频 ${file.name}`);
|
||
}}
|
||
/>
|
||
<span className="studio-upload-slot--empty__icon">
|
||
<AudioOutlined />
|
||
</span>
|
||
<span className="studio-upload-slot--filled__info">
|
||
<strong>{audioName || "上传音频"}</strong>
|
||
<small>MP3 / WAV / M4A,建议 5 分钟内</small>
|
||
</span>
|
||
</label>
|
||
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
canvas={
|
||
resultVideoUrl ? (
|
||
<div className="studio-canvas-video">
|
||
<video src={resultVideoUrl} controls playsInline />
|
||
<div className="studio-result-caption">
|
||
<strong>结果已接收</strong>
|
||
<span>{activeTaskId ? `任务 ${activeTaskId}` : summarizeUrl(resultVideoUrl)}</span>
|
||
</div>
|
||
</div>
|
||
) : imagePreview ? (
|
||
<div className="studio-canvas-image">
|
||
<img src={imagePreview} alt="参考人像" />
|
||
</div>
|
||
) : (
|
||
<div className="studio-canvas-ghost">
|
||
<div className="studio-canvas-ghost__icon">
|
||
<CustomerServiceOutlined />
|
||
</div>
|
||
<div className="studio-canvas-ghost__title">上传参考人像与音频</div>
|
||
<div className="studio-canvas-ghost__hint">网页端首版只做本地预览,正式生成仍会继续走服务端队列。</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: 8 }}>
|
||
<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={2500}
|
||
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${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
|
||
<span className="studio-toggle__thumb" />
|
||
</button>
|
||
</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${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
|
||
<span className="studio-toggle__thumb" />
|
||
</button>
|
||
</div>
|
||
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
|
||
<PlayCircleOutlined />
|
||
{isCreating ? "生成中..." : "开始生成"}
|
||
</button>
|
||
{isCreating && (
|
||
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
|
||
<CloseCircleOutlined />
|
||
取消生成
|
||
</button>
|
||
)}
|
||
{resultVideoUrl && (
|
||
<button type="button" className="studio-generate-btn" onClick={() => {
|
||
setResultVideoUrl("");
|
||
setActiveTaskId("");
|
||
setTaskProgress(0);
|
||
setNotice("已清空工作区");
|
||
}}>
|
||
清空
|
||
</button>
|
||
)}
|
||
{resultVideoUrl && (
|
||
<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--running">预览</span>
|
||
<span className="studio-status-bar__text">{notice}</span>
|
||
{activeTaskId ? <span className="studio-status-bar__meta">#{activeTaskId} · {taskProgress}%</span> : null}
|
||
</>
|
||
}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export default DigitalHumanPage;
|