Files
omniai-web/src/features/digital-human/DigitalHumanPage.tsx
T
stringadmin 7c6129555b fix: 修复多个运行时崩溃和功能bug,优化画布连接线和剧本评分
- 修复 EcommercePage generateEcommerceImage 调用不存在变量导致运行时崩溃
- 修复 DigitalHumanPage/ImageWorkbenchPage 变量名错误导致页面不可用
- 修复 ecommerceVideoService token 读取用错 key 导致请求 401
- 修复画布连接线在弹窗出现后仍跟随鼠标的问题
- 剧本评分 .docx 文件改为服务端 mammoth 解析(新增 /api/files/extract-text)
- ErrorBoundary 加 key 支持切换页面时自动重置
- Vite proxy 改为指向公网域名 omniai.net.cn
- 新增视频生成历史记录面板和删除确认弹窗

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 01:12:51 +08:00

608 lines
25 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,
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 } from "react";
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);
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 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 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>
</>
}
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 ? "生成中..." : "提交 wan2.2-s2v"}
</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;