Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,590 @@
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 { 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;
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
};
}, [imagePreview]);
useEffect(() => {
return () => {
if (audioPreview) URL.revokeObjectURL(audioPreview);
};
}, [audioPreview]);
useEffect(() => {
return () => {
pollRunRef.current += 1;
cancelRef.current = true;
if (activeTaskIdRef.current) {
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
}
};
}, []);
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);
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}`);
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 && (
<div className="studio-result-actions studio-result-actions--with-clear">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
<button type="button" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</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;
@@ -0,0 +1,121 @@
export type AvatarEditorRatio = "16:9" | "9:16";
export type AvatarEditorLayerId = "solidBackground" | "background" | "avatar" | "sticker";
export interface AvatarEditorLayer {
id: AvatarEditorLayerId;
label: string;
tool: "avatar" | "content" | "subtitle" | "background" | "sticker";
x: number;
y: number;
width: number;
height: number;
zIndex: number;
locked?: boolean;
}
export interface AvatarEditorPoint {
x: number;
y: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeZIndexes(layers: AvatarEditorLayer[]): AvatarEditorLayer[] {
return [...layers]
.sort((a, b) => a.zIndex - b.zIndex)
.map((layer, index) => ({ ...layer, zIndex: index * 10 }));
}
function assignZIndexes(layers: AvatarEditorLayer[]): AvatarEditorLayer[] {
return layers.map((layer, index) => ({ ...layer, zIndex: index * 10 }));
}
export function createDefaultAvatarEditorLayers(ratio: AvatarEditorRatio): AvatarEditorLayer[] {
const isPortrait = ratio === "9:16";
return [
{
id: "solidBackground",
label: "纯色背景",
tool: "background",
x: 0,
y: 0,
width: 100,
height: 100,
zIndex: 0,
locked: true,
},
{
id: "background",
label: "背景",
tool: "background",
x: 0,
y: 0,
width: 100,
height: 100,
zIndex: 10,
locked: true,
},
{
id: "avatar",
label: "数字人",
tool: "avatar",
x: isPortrait ? 30 : 42,
y: isPortrait ? 24 : 12,
width: isPortrait ? 40 : 23,
height: isPortrait ? 70 : 88,
zIndex: 20,
},
{
id: "sticker",
label: "贴图",
tool: "sticker",
x: isPortrait ? 62 : 46,
y: isPortrait ? 42 : 12,
width: isPortrait ? 24 : 8,
height: isPortrait ? 40 : 12,
zIndex: 30,
},
];
}
export function moveAvatarEditorLayer(
layers: AvatarEditorLayer[],
layerId: AvatarEditorLayerId,
nextPosition: AvatarEditorPoint,
): AvatarEditorLayer[] {
return layers.map((layer) => {
if (layer.id !== layerId || layer.locked) return layer;
return {
...layer,
x: clamp(nextPosition.x, 0, 100 - layer.width),
y: clamp(nextPosition.y, 0, 100 - layer.height),
};
});
}
export function bringAvatarEditorLayerForward(
layers: AvatarEditorLayer[],
layerId: AvatarEditorLayerId,
): AvatarEditorLayer[] {
const ordered = normalizeZIndexes(layers);
const index = ordered.findIndex((layer) => layer.id === layerId);
if (index < 0 || index >= ordered.length - 1) return ordered;
const next = [...ordered];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
return assignZIndexes(next);
}
export function sendAvatarEditorLayerBackward(
layers: AvatarEditorLayer[],
layerId: AvatarEditorLayerId,
): AvatarEditorLayer[] {
const ordered = normalizeZIndexes(layers);
const index = ordered.findIndex((layer) => layer.id === layerId);
if (index <= 0) return ordered;
const next = [...ordered];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
return assignZIndexes(next);
}
@@ -0,0 +1,59 @@
export type AvatarTrainingScenarioKey = "video" | "chat" | "live";
export type AvatarTrainingStep = "scenario" | "material";
export interface AvatarTrainingScenario {
key: AvatarTrainingScenarioKey;
label: string;
description: string;
}
export interface AvatarTrainingDraft {
selectedScenario: AvatarTrainingScenarioKey | null;
step: AvatarTrainingStep;
canContinue: boolean;
}
export const avatarTrainingScenarios: AvatarTrainingScenario[] = [
{
key: "video",
label: "视频创作",
description: "支持创作有声播报形式的新闻视频、培训视频、广告营销视频等。",
},
{
key: "chat",
label: "对话互动",
description: "支持用户与数字人之间互动交流,可用于业务咨询、智能问答等场景。",
},
{
key: "live",
label: "直播",
description: "支持电商直播场景的数字人主播推流,实现7*24小时无人直播。",
},
];
export function createAvatarTrainingDraft(): AvatarTrainingDraft {
return {
selectedScenario: null,
step: "scenario",
canContinue: false,
};
}
export function selectAvatarTrainingScenario(
draft: AvatarTrainingDraft,
scenario: AvatarTrainingScenarioKey,
): AvatarTrainingDraft {
return {
...draft,
selectedScenario: scenario,
canContinue: true,
};
}
export function continueAvatarTraining(draft: AvatarTrainingDraft): AvatarTrainingDraft {
if (!draft.canContinue) return draft;
return {
...draft,
step: "material",
};
}
@@ -0,0 +1,34 @@
export type VoiceLibraryTabKey = "official" | "created";
export type VoiceCloneGender = "male" | "female";
export type VoiceCloneMode = "record" | "upload";
export interface VoiceLibraryTab {
key: VoiceLibraryTabKey;
label: string;
}
export interface VoiceCloneDraft {
gender: VoiceCloneGender;
cloneMode: VoiceCloneMode;
targetLanguage: string;
nameMaxLength: number;
}
export const voiceLibraryTabs: VoiceLibraryTab[] = [
{ key: "official", label: "官方音色" },
{ key: "created", label: "我创建的声音" },
];
export const voiceCloneLanguageOptions = ["不指定", "中文", "英文", "日语", "韩语", "法语", "德语", "俄语"];
export const VOICE_CLONE_SCRIPT =
"尊敬的患者您好,欢迎来到智能健康管理中心,接下来请您放松身心,我们将为您讲解日常养生的注意事项,包括合理膳食搭配、每日运动强度建议以及常见慢性病的预防方法,有需要定制个性化健康方案的可以随时告知。";
export function createVoiceCloneDraft(): VoiceCloneDraft {
return {
gender: "male",
cloneMode: "record",
targetLanguage: "不指定",
nameMaxLength: 20,
};
}