feat: #1798 AI数字人前端页面 — 5列水平面板布局
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m4s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m25s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m51s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 11s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 26s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m6s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped

新增 AI 数字人口播短视频完整前端页面,5列水平面板 1:1 实现:
- 面板1 出镜视频:拖拽上传 MP4 + 视频预览播放器
- 面板2 声音克隆:音频上传 + 克隆状态 + 音色列表
- 面板3 文案&对口型:文案编辑/选择 + 对口型预览 + B-roll 插入
- 面板4 标题配置:复用 TitleStylePanel + 字幕设置
- 面板5 封面&生成:封面预览 + 分辨率/模式设置 + 生成按钮
- 弹窗:ScriptSelectModal(文案库选择)、BRollInsertModal(B-roll 配置)
- 路由 /ai-avatar + 侧边栏导航注册
- API 层封装(文案/对口型/渲染合成)
- 全部面板可折叠/展开,面板间无步骤依赖
- TypeScript 类型完整,无 any
- 构建/lint/prettier 全部通过
This commit is contained in:
xiaoxia
2026-09-08 18:25:31 +08:00
parent 800f90d8c6
commit 062e61dff9
14 changed files with 2500 additions and 0 deletions
+13
View File
@@ -18,6 +18,7 @@ import {
ControlOutlined,
CrownOutlined,
UnorderedListOutlined,
UserOutlined,
} from "@ant-design/icons"
/** 导航项类型 */
@@ -88,6 +89,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/generate",
icon: React.createElement(VideoCameraOutlined),
},
{
key: "ai-avatar",
label: "AI数字人",
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "history",
label: "任务历史",
@@ -131,6 +138,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/generate",
icon: React.createElement(VideoCameraOutlined),
},
{
key: "ai-avatar",
label: "AI数字人",
path: "/app/ai-avatar",
icon: React.createElement(UserOutlined),
},
{
key: "editing-planner",
label: "剪辑模板",
+607
View File
@@ -0,0 +1,607 @@
/**
* AI数字人页面 — 5 列水平面板布局 (#1798)
*/
/* ── 页面容器 ── */
.ai-avatar-page {
display: flex;
gap: 0;
height: 100%;
min-width: 1280px;
overflow-x: auto;
background: var(--bg-primary, #0f0f0f);
}
/* ── 面板通用 ── */
.ai-avatar-panel {
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color, #2a2a2a);
overflow: hidden;
transition: width 0.2s ease;
}
.ai-avatar-panel:last-child {
border-right: none;
}
.ai-avatar-panel.collapsed {
width: 48px !important;
min-width: 48px !important;
}
.ai-avatar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary, #1a1a1a);
border-bottom: 1px solid var(--border-color, #2a2a2a);
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.ai-avatar-panel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-avatar-panel-header .collapse-btn {
background: none;
border: none;
color: var(--text-secondary, #999);
cursor: pointer;
padding: 2px;
font-size: 12px;
transition: transform 0.2s;
}
.ai-avatar-panel.collapsed .collapse-btn {
transform: rotate(180deg);
}
.ai-avatar-panel-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.ai-avatar-panel.collapsed .ai-avatar-panel-body {
display: none;
}
.ai-avatar-panel.collapsed .ai-avatar-panel-header h3 {
display: none;
}
/* ── 面板宽度 ── */
.ai-avatar-panel.panel-avatar-video {
width: 20%;
}
.ai-avatar-panel.panel-voice-clone {
width: 20%;
}
.ai-avatar-panel.panel-script-lipsync {
width: 25%;
}
.ai-avatar-panel.panel-title-config {
width: 17.5%;
}
.ai-avatar-panel.panel-cover-generate {
width: 17.5%;
}
/* ── 上传拖拽区 ── */
.ai-avatar-upload-zone {
border: 2px dashed var(--border-color, #2a2a2a);
border-radius: 8px;
padding: 32px 16px;
text-align: center;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
color: var(--text-secondary, #999);
font-size: 13px;
}
.ai-avatar-upload-zone:hover {
border-color: var(--primary-color, #3b82f6);
background: rgba(59, 130, 246, 0.05);
}
.ai-avatar-upload-zone .upload-icon {
font-size: 32px;
margin-bottom: 8px;
display: block;
}
/* ── 视频/音频预览 ── */
.ai-avatar-media-preview {
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #000;
margin-top: 12px;
}
.ai-avatar-media-preview video,
.ai-avatar-media-preview audio {
width: 100%;
display: block;
}
.ai-avatar-media-info {
display: flex;
gap: 12px;
padding: 8px 0;
font-size: 12px;
color: var(--text-secondary, #999);
}
/* ── 文案输入 ── */
.ai-avatar-script-editor {
width: 100%;
min-height: 120px;
resize: vertical;
background: var(--bg-tertiary, #222);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 6px;
padding: 12px;
color: var(--text-primary, #fff);
font-size: 14px;
line-height: 1.6;
font-family: inherit;
}
.ai-avatar-script-editor:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-script-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.ai-avatar-script-tabs button {
padding: 6px 12px;
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #999);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.ai-avatar-script-tabs button.active {
background: var(--primary-color, #3b82f6);
color: #fff;
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-script-word-count {
text-align: right;
font-size: 12px;
color: var(--text-secondary, #999);
margin-top: 4px;
}
/* ── 对口型预览 ── */
.ai-avatar-lipsync-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-lipsync-section h4 {
font-size: 13px;
color: var(--text-secondary, #999);
margin: 0 0 12px;
font-weight: 500;
}
.ai-avatar-lipsync-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* ── 声音克隆状态 ── */
.ai-avatar-clone-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
margin-top: 12px;
}
.ai-avatar-clone-status.success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.ai-avatar-clone-status.cloning {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.ai-avatar-clone-status.failed {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* ── 音色列表 ── */
.ai-avatar-voice-list {
margin-top: 16px;
}
.ai-avatar-voice-list h4 {
font-size: 13px;
color: var(--text-secondary, #999);
margin: 0 0 8px;
font-weight: 500;
}
.ai-avatar-voice-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.ai-avatar-voice-item:hover {
background: var(--bg-tertiary, #222);
}
.ai-avatar-voice-item.selected {
background: rgba(59, 130, 246, 0.1);
border: 1px solid var(--primary-color, #3b82f6);
}
/* ── 字幕设置 ── */
.ai-avatar-subtitle-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-subtitle-section label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary, #999);
cursor: pointer;
}
/* ── 封面预览 ── */
.ai-avatar-cover-preview {
width: 100%;
aspect-ratio: 16/9;
border-radius: 8px;
overflow: hidden;
background: var(--bg-tertiary, #222);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #999);
font-size: 13px;
margin-bottom: 12px;
}
.ai-avatar-cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── 生成设置 ── */
.ai-avatar-generate-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-generate-section .field-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 13px;
color: var(--text-primary, #fff);
}
.ai-avatar-generate-section select {
background: var(--bg-tertiary, #222);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 4px;
color: var(--text-primary, #fff);
padding: 4px 8px;
font-size: 13px;
}
/* ── 生成按钮 ── */
.ai-avatar-generate-btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
margin-top: 16px;
}
.ai-avatar-generate-btn:hover {
opacity: 0.9;
}
.ai-avatar-generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── 弹窗通用 ── */
.ai-avatar-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.ai-avatar-modal {
background: var(--bg-secondary, #1a1a1a);
border-radius: 12px;
width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.ai-avatar-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #2a2a2a);
}
.ai-avatar-modal-header h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary, #fff);
}
.ai-avatar-modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.ai-avatar-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color, #2a2a2a);
}
/* ── 文案选择弹窗 ── */
.ai-avatar-script-search {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.ai-avatar-script-search input {
flex: 1;
background: var(--bg-tertiary, #222);
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary, #fff);
font-size: 14px;
}
.ai-avatar-script-search input:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-script-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #2a2a2a);
cursor: pointer;
transition: background 0.2s;
}
.ai-avatar-script-item:hover {
background: var(--bg-tertiary, #222);
}
.ai-avatar-script-item.selected {
background: rgba(59, 130, 246, 0.1);
}
.ai-avatar-script-item-info {
flex: 1;
}
.ai-avatar-script-item-info h4 {
margin: 0 0 4px;
font-size: 14px;
color: var(--text-primary, #fff);
font-weight: 500;
}
.ai-avatar-script-item-info span {
font-size: 12px;
color: var(--text-secondary, #999);
}
/* ── B-roll 弹窗 ── */
.ai-avatar-broll-layout {
display: flex;
gap: 16px;
}
.ai-avatar-broll-timeline {
flex: 1;
min-width: 0;
}
.ai-avatar-broll-settings {
width: 220px;
flex-shrink: 0;
}
.ai-avatar-broll-thumbnails {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.ai-avatar-broll-thumb {
aspect-ratio: 16/9;
border-radius: 4px;
overflow: hidden;
background: var(--bg-tertiary, #222);
cursor: grab;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.ai-avatar-broll-thumb:hover {
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-broll-thumb.selected {
border-color: var(--primary-color, #3b82f6);
}
.ai-avatar-pip-positions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
width: 120px;
}
.ai-avatar-pip-positions button {
padding: 4px 8px;
border: 1px solid var(--border-color, #2a2a2a);
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
font-size: 11px;
cursor: pointer;
}
.ai-avatar-pip-positions button.active {
background: var(--primary-color, #3b82f6);
color: #fff;
border-color: var(--primary-color, #3b82f6);
}
/* ── 状态指示器 ── */
.ai-avatar-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
}
.ai-avatar-status-badge.completed {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.ai-avatar-status-badge.processing {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.ai-avatar-status-badge.failed {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
/* ── 通用按钮 ── */
.aa-btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1px solid var(--border-color, #2a2a2a);
background: transparent;
color: var(--text-primary, #fff);
transition: all 0.2s;
}
.aa-btn:hover {
background: var(--bg-tertiary, #222);
}
.aa-btn-primary {
background: var(--primary-color, #3b82f6);
color: #fff;
border-color: var(--primary-color, #3b82f6);
}
.aa-btn-primary:hover {
opacity: 0.9;
background: var(--primary-color, #3b82f6);
}
.aa-btn-sm {
padding: 4px 10px;
font-size: 12px;
}
/* ── 单选组 ── */
.aa-radio-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.aa-radio-group label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary, #fff);
cursor: pointer;
}
/* ── 分割线 ── */
.aa-divider {
border: none;
border-top: 1px solid var(--border-color, #2a2a2a);
margin: 16px 0;
}
@@ -0,0 +1,102 @@
/**
* AI数字人 — 主页面(5列水平面板布局)(#1798)
*/
import React from "react"
import { useAiAvatarState } from "./hooks/useAiAvatarState"
import AvatarVideoPanel from "./components/AvatarVideoPanel"
import VoiceClonePanel from "./components/VoiceClonePanel"
import ScriptLipsyncPanel from "./components/ScriptLipsyncPanel"
import TitleConfigPanel from "./components/TitleConfigPanel"
import CoverGeneratePanel from "./components/CoverGeneratePanel"
import ScriptSelectModal from "./components/ScriptSelectModal"
import BRollInsertModal from "./components/BRollInsertModal"
import "./AiAvatar.css"
const AiAvatarPage: React.FC = () => {
const state = useAiAvatarState()
const handleSubmitGenerate = React.useCallback(() => {
// TODO: 调用 submitRender API
state.setIsGenerating(true)
}, [state])
return (
<div className="ai-avatar-page">
{/* 面板1:出镜视频 */}
<AvatarVideoPanel
video={state.avatarVideo}
onVideoChange={state.setAvatarVideo}
collapsed={!!state.collapsedPanels["avatar-video"]}
onToggleCollapse={() => state.togglePanel("avatar-video")}
/>
{/* 面板2:声音克隆 */}
<VoiceClonePanel
voiceClone={state.voiceClone}
setVoiceClone={state.setVoiceClone}
selectedVoiceId={state.selectedVoiceId}
setSelectedVoiceId={state.setSelectedVoiceId}
collapsed={!!state.collapsedPanels["voice-clone"]}
onToggleCollapse={() => state.togglePanel("voice-clone")}
/>
{/* 面板3:文案 & 对口型 */}
<ScriptLipsyncPanel
selectedScript={state.selectedScript}
setSelectedScript={state.setSelectedScript}
scriptContent={state.scriptContent}
setScriptContent={state.setScriptContent}
lipsyncJob={state.lipsyncJob}
setLipsyncJob={state.setLipsyncJob}
onOpenScriptModal={() => state.setScriptModalOpen(true)}
onOpenBRollModal={() => state.setBrollModalOpen(true)}
collapsed={!!state.collapsedPanels["script-lipsync"]}
onToggleCollapse={() => state.togglePanel("script-lipsync")}
/>
{/* 面板4:标题配置 */}
<TitleConfigPanel
titleConfig={state.titleConfig}
setTitleConfig={state.setTitleConfig}
collapsed={!!state.collapsedPanels["title-config"]}
onToggleCollapse={() => state.togglePanel("title-config")}
/>
{/* 面板5:封面 & 生成 */}
<CoverGeneratePanel
coverConfig={state.coverConfig}
setCoverConfig={state.setCoverConfig}
generateConfig={state.generateConfig}
setGenerateConfig={state.setGenerateConfig}
isGenerating={state.isGenerating}
onSubmitGenerate={handleSubmitGenerate}
collapsed={!!state.collapsedPanels["cover-generate"]}
onToggleCollapse={() => state.togglePanel("cover-generate")}
/>
{/* 弹窗:文案选择 */}
<ScriptSelectModal
open={state.scriptModalOpen}
onClose={() => state.setScriptModalOpen(false)}
onSelect={(script) => {
state.setSelectedScript(script)
state.setScriptContent(script.content)
state.setScriptModalOpen(false)
}}
/>
{/* 弹窗:B-roll 插入 */}
<BRollInsertModal
open={state.brollModalOpen}
onClose={() => state.setBrollModalOpen(false)}
onConfirm={(segment) => {
state.setBRollSegments((prev) => [...prev, segment])
state.setBrollModalOpen(false)
}}
videoDuration={state.lipsyncJob?.output_duration ?? 0}
/>
</div>
)
}
export default AiAvatarPage
@@ -0,0 +1,75 @@
/**
* AI数字人 API 调用封装 (#1798)
*/
import apiClient from "@/api/client"
import type {
Script,
LipsyncJob,
AiAvatarRenderRequest,
AiAvatarRenderJob,
} from "../types/aiAvatar"
/* ── 文案库 ── */
export async function getScripts(params?: { search?: string; offset?: number; limit?: number }) {
const { data } = await apiClient.get<{ items: Script[]; total: number }>("/scripts", { params })
return data
}
export async function getScript(id: string) {
const { data } = await apiClient.get<Script>(`/scripts/${id}`)
return data
}
export async function createScript(payload: { title: string; content: string; tags?: string[] }) {
const { data } = await apiClient.post<Script>("/scripts", payload)
return data
}
export async function deleteScript(id: string) {
await apiClient.delete(`/scripts/${id}`)
}
/* ── 对口型 ── */
export async function createLipsyncJob(payload: {
video_url: string
audio_url: string
enable_video_loop?: boolean
project_id?: string
}) {
const { data } = await apiClient.post<LipsyncJob>("/lipsync/jobs", payload)
return data
}
export async function getLipsyncJob(id: string) {
const { data } = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
return data
}
/* ── 渲染合成 ── */
export async function submitRender(payload: AiAvatarRenderRequest) {
const { data } = await apiClient.post<AiAvatarRenderJob>("/ai-avatar/render", payload)
return data
}
export async function getRenderJobs(params?: { project_id?: string; status?: string }) {
const { data } = await apiClient.get<AiAvatarRenderJob[]>("/ai-avatar/render/jobs", { params })
return data
}
export async function getRenderJob(jobId: string) {
const { data } = await apiClient.get<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}`)
return data
}
export async function cancelRenderJob(jobId: string) {
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/cancel`)
return data
}
export async function retryRenderJob(jobId: string) {
const { data } = await apiClient.post<AiAvatarRenderJob>(`/ai-avatar/render/${jobId}/retry`)
return data
}
/* ── 素材上传(复用已有 API) ── */
export { prepareDirectUpload, completeDirectUpload } from "@/api/assets"
@@ -0,0 +1,226 @@
import { useState, useRef, useCallback } from "react"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { AvatarVideo } from "../types/aiAvatar"
interface AvatarVideoPanelProps {
video: AvatarVideo | null
onVideoChange: (v: AvatarVideo | null) => void
collapsed: boolean
onToggleCollapse: () => void
}
const MAX_VIDEO_SIZE = 500 * 1024 * 1024 // 500MB
/** 格式化秒数为 mm:ss */
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 获取视频元信息 */
function getVideoMetadata(
file: File,
): Promise<{ duration: number; width: number; height: number }> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const video = document.createElement("video")
video.preload = "metadata"
video.onloadedmetadata = () => {
resolve({
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
})
URL.revokeObjectURL(url)
}
video.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error("无法读取视频信息"))
}
video.src = url
})
}
const AvatarVideoPanel: React.FC<AvatarVideoPanelProps> = ({
video,
onVideoChange,
collapsed,
onToggleCollapse,
}) => {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_VIDEO_SIZE) {
alert("视频文件大小不能超过 500MB")
return
}
if (!file.type.startsWith("video/")) {
alert("请上传 MP4 格式的视频文件")
return
}
try {
// 获取视频元信息
const metadata = await getVideoMetadata(file)
setUploading(true)
setUploadProgress(0)
// 获取或创建默认项目
const project = await getOrCreateDefaultProject()
// 获取或创建默认视频库
const library = await ensureDefaultLibrary({
project_id: project.id,
kind: "video",
})
// 上传文件到 OSS
await uploadAssetDirect({
file,
library_id: library.id,
onProgress: (p) => setUploadProgress(p),
})
// 构建视频对象(使用本地预览 URL)
const previewUrl = URL.createObjectURL(file)
onVideoChange({
url: previewUrl,
name: file.name,
duration: metadata.duration,
width: metadata.width,
height: metadata.height,
size: file.size,
})
} catch (err) {
const message = err instanceof Error ? err.message : "上传失败,请重试"
alert(message)
} finally {
setUploading(false)
setUploadProgress(0)
}
},
[onVideoChange],
)
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
// 清空 input 以支持重复选择同一文件
e.target.value = ""
},
[handleUpload],
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) {
handleUpload(file)
}
},
[handleUpload],
)
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(true)
}, [])
const handleDragLeave = useCallback(() => {
setDragOver(false)
}, [])
const handleRemove = useCallback(() => {
onVideoChange(null)
}, [onVideoChange])
return (
<div className={`ai-avatar-panel panel-avatar-video ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3></h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{video ? (
<div>
<div className="ai-avatar-media-preview">
<video src={video.url} controls />
</div>
<div className="ai-avatar-media-info">
<span>{formatDuration(video.duration)}</span>
<span>
{video.width}×{video.height}
</span>
</div>
<div className="ai-avatar-media-info">
<span>{video.name}</span>
</div>
<button className="aa-btn aa-btn-sm" onClick={handleRemove} style={{ marginTop: 8 }}>
</button>
</div>
) : (
<div
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<span className="upload-icon">🎬</span>
{uploading ? (
<div>
<div>... {uploadProgress}%</div>
<div
style={{
width: "100%",
height: 4,
background: "#2a2a2a",
borderRadius: 2,
marginTop: 8,
}}
>
<div
style={{
width: `${uploadProgress}%`,
height: "100%",
background: "#3b82f6",
borderRadius: 2,
transition: "width 0.2s",
}}
/>
</div>
</div>
) : (
<div>
<div></div>
<div style={{ marginTop: 4, fontSize: 12 }}>MP4 500MB</div>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="video/mp4,video/*"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
</div>
)}
</div>
</div>
)
}
export default AvatarVideoPanel
@@ -0,0 +1,272 @@
/**
* B-roll 画面插入弹窗
* 左右布局:左侧素材缩略图 + 右侧设置
*/
import React, { useCallback, useState } from "react"
import type { BRollSegment } from "../types/aiAvatar"
interface BRollInsertModalProps {
open: boolean
onClose: () => void
onConfirm: (segment: BRollSegment) => void
videoDuration: number
}
/** 画中画位置选项 */
const PIP_POSITIONS = [
{ value: "top-left", label: "左上" },
{ value: "top-right", label: "右上" },
{ value: "bottom-left", label: "左下" },
{ value: "bottom-right", label: "右下" },
]
/** 格式化秒数为 mm:ss */
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`
}
const BRollInsertModal: React.FC<BRollInsertModalProps> = ({
open,
onClose,
onConfirm,
videoDuration,
}) => {
/* 素材列表(示例数据,实际使用时通过 props 或 API 传入) */
const [assets, setAssets] = useState<{ id: string; url: string; name: string }[]>([])
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null)
/* 设置 */
const [insertMode, setInsertMode] = useState<"fullscreen" | "pip">("fullscreen")
const [pipPosition, setPipPosition] = useState("top-right")
const [startTime, setStartTime] = useState(0)
const [endTime, setEndTime] = useState(5)
const [scriptIndex, setScriptIndex] = useState(0)
const handleConfirm = useCallback(() => {
const asset = assets.find((a) => a.id === selectedAssetId)
if (!asset) return
const segment: BRollSegment = {
script_segment_index: scriptIndex,
asset_url: asset.url,
mode: insertMode,
start_time: startTime,
end_time: endTime,
pip_position: insertMode === "pip" ? pipPosition : undefined,
pip_scale: insertMode === "pip" ? 0.3 : undefined,
}
onConfirm(segment)
onClose()
}, [
assets,
selectedAssetId,
scriptIndex,
insertMode,
pipPosition,
startTime,
endTime,
onConfirm,
onClose,
])
/* 上传新素材占位 */
const handleUploadAsset = useCallback(() => {
/* 实际项目中触发文件上传逻辑 */
const newAsset = {
id: `asset-${Date.now()}`,
url: "",
name: "新素材",
}
setAssets((prev) => [...prev, newAsset])
setSelectedAssetId(newAsset.id)
}, [])
if (!open) return null
return (
<div className="ai-avatar-modal-overlay" onClick={onClose}>
<div className="ai-avatar-modal" style={{ width: 800 }} onClick={(e) => e.stopPropagation()}>
<div className="ai-avatar-modal-header">
<h3> B-roll </h3>
<button className="aa-btn aa-btn-sm" onClick={onClose}>
</button>
</div>
<div className="ai-avatar-modal-body">
<div className="ai-avatar-broll-layout">
{/* 左侧:素材缩略图 */}
<div className="ai-avatar-broll-timeline">
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
</h4>
<div className="ai-avatar-broll-thumbnails">
{assets.map((asset) => (
<div
key={asset.id}
className={`ai-avatar-broll-thumb ${selectedAssetId === asset.id ? "selected" : ""}`}
onClick={() => setSelectedAssetId(asset.id)}
title={asset.name}
>
{asset.url ? (
<img
src={asset.url}
alt={asset.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
fontSize: 11,
color: "#666",
}}
>
{asset.name}
</div>
)}
</div>
))}
</div>
<button className="aa-btn" onClick={handleUploadAsset}>
+
</button>
</div>
{/* 右侧:设置 */}
<div className="ai-avatar-broll-settings">
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
</h4>
{/* 插入位置 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
</label>
<input
type="number"
min={0}
value={scriptIndex}
onChange={(e) => setScriptIndex(Number(e.target.value))}
style={{
width: "100%",
background: "#222",
border: "1px solid #2a2a2a",
borderRadius: 4,
padding: "6px 8px",
color: "#fff",
fontSize: 13,
}}
/>
</div>
{/* 开始时间 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
{formatTime(startTime)}
</label>
<input
type="range"
min={0}
max={videoDuration}
step={0.1}
value={startTime}
onChange={(e) => setStartTime(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
{/* 持续时间 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
{formatTime(endTime)}
</label>
<input
type="range"
min={startTime}
max={videoDuration}
step={0.1}
value={endTime}
onChange={(e) => setEndTime(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
<hr className="aa-divider" />
{/* 插入模式 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
</label>
<div className="aa-radio-group">
<label>
<input
type="radio"
name="brollInsertMode"
value="fullscreen"
checked={insertMode === "fullscreen"}
onChange={() => setInsertMode("fullscreen")}
/>
</label>
<label>
<input
type="radio"
name="brollInsertMode"
value="pip"
checked={insertMode === "pip"}
onChange={() => setInsertMode("pip")}
/>
</label>
</div>
</div>
{/* 画中画位置选择 */}
{insertMode === "pip" && (
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 8 }}>
</label>
<div className="ai-avatar-pip-positions">
{PIP_POSITIONS.map((pos) => (
<button
key={pos.value}
className={pipPosition === pos.value ? "active" : ""}
onClick={() => setPipPosition(pos.value)}
>
{pos.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="ai-avatar-modal-footer">
<button className="aa-btn" onClick={onClose}>
</button>
<button
className="aa-btn aa-btn-primary"
disabled={!selectedAssetId}
onClick={handleConfirm}
>
</button>
</div>
</div>
</div>
)
}
export default BRollInsertModal
@@ -0,0 +1,131 @@
/**
* 面板5:封面 & 生成
* 封面预览 + 生成设置 + 生成按钮
*/
import React, { useCallback } from "react"
import type { AiAvatarCoverConfig, AiAvatarGenerateConfig } from "../types/aiAvatar"
interface CoverGeneratePanelProps {
coverConfig: AiAvatarCoverConfig
setCoverConfig: (c: AiAvatarCoverConfig) => void
generateConfig: AiAvatarGenerateConfig
setGenerateConfig: (c: AiAvatarGenerateConfig) => void
isGenerating: boolean
onSubmitGenerate: () => void
collapsed: boolean
onToggleCollapse: () => void
}
const CoverGeneratePanel: React.FC<CoverGeneratePanelProps> = ({
coverConfig,
setCoverConfig,
generateConfig,
setGenerateConfig,
isGenerating,
onSubmitGenerate,
collapsed,
onToggleCollapse,
}) => {
const handleFrameCapture = useCallback(() => {
setCoverConfig({ ...coverConfig, mode: "frame", enabled: true })
}, [coverConfig, setCoverConfig])
const handleCustomUpload = useCallback(() => {
setCoverConfig({ ...coverConfig, mode: "upload", enabled: true })
}, [coverConfig, setCoverConfig])
return (
<div className={`ai-avatar-panel panel-cover-generate ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3> &amp; </h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 封面预览区 */}
<div className="ai-avatar-cover-preview">
{coverConfig.thumbnail_url ? (
<img src={coverConfig.thumbnail_url} alt="封面预览" />
) : (
<span></span>
)}
</div>
{/* 封面操作按钮 */}
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<button className="aa-btn" style={{ flex: 1 }} onClick={handleFrameCapture}>
</button>
<button className="aa-btn" style={{ flex: 1 }} onClick={handleCustomUpload}>
</button>
</div>
{/* 分割线 + 生成设置 */}
<hr className="aa-divider" />
<div className="ai-avatar-generate-section">
<h4 style={{ fontSize: 13, color: "#999", margin: "0 0 12px", fontWeight: 500 }}>
</h4>
{/* 分辨率 */}
<div className="field-row">
<span></span>
<select
value={generateConfig.resolution}
onChange={(e) =>
setGenerateConfig({
...generateConfig,
resolution: e.target.value as "720p" | "1080p",
})
}
>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
</select>
</div>
{/* 画面插入模式 */}
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, color: "#fff", marginBottom: 8 }}>
</label>
<div className="aa-radio-group">
<label>
<input
type="radio"
name="bRollMode"
value="fullscreen"
checked={generateConfig.bRollMode === "fullscreen"}
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "fullscreen" })}
/>
</label>
<label>
<input
type="radio"
name="bRollMode"
value="pip"
checked={generateConfig.bRollMode === "pip"}
onChange={() => setGenerateConfig({ ...generateConfig, bRollMode: "pip" })}
/>
</label>
</div>
</div>
</div>
{/* 生成按钮 */}
<button
className="ai-avatar-generate-btn"
disabled={isGenerating}
onClick={onSubmitGenerate}
>
{isGenerating ? "生成中..." : "🚀 开始生成视频"}
</button>
</div>
</div>
)
}
export default CoverGeneratePanel
@@ -0,0 +1,189 @@
import { useState, useCallback } from "react"
import type { Script, LipsyncJob } from "../types/aiAvatar"
interface ScriptLipsyncPanelProps {
selectedScript: Script | null
setSelectedScript: (s: Script | null) => void
scriptContent: string
setScriptContent: (content: string) => void
lipsyncJob: LipsyncJob | null
setLipsyncJob: (job: LipsyncJob | null) => void
onOpenScriptModal: () => void
onOpenBRollModal: () => void
collapsed: boolean
onToggleCollapse: () => void
}
type ScriptTab = "library" | "manual"
/** 对口型状态标签 */
const LIPSYNC_STATUS_LABEL: Record<string, string> = {
pending: "等待中",
processing: "处理中",
completed: "已完成",
failed: "失败",
}
const LIPSYNC_STATUS_CLASS: Record<string, string> = {
pending: "processing",
processing: "processing",
completed: "completed",
failed: "failed",
}
const ScriptLipsyncPanel: React.FC<ScriptLipsyncPanelProps> = ({
selectedScript,
setSelectedScript,
scriptContent,
setScriptContent,
lipsyncJob,
setLipsyncJob,
onOpenScriptModal,
onOpenBRollModal,
collapsed,
onToggleCollapse,
}) => {
const [activeTab, setActiveTab] = useState<ScriptTab>("manual")
const handleTabChange = useCallback(
(tab: ScriptTab) => {
setActiveTab(tab)
if (tab === "library") {
onOpenScriptModal()
}
},
[onOpenScriptModal],
)
const handleScriptChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setScriptContent(e.target.value)
// 清除已选脚本(用户手动输入时)
if (selectedScript) {
setSelectedScript(null)
}
},
[setScriptContent, selectedScript, setSelectedScript],
)
const handleRegenerateLipsync = useCallback(() => {
// TODO: 调用实际的对口型 API
if (!scriptContent) {
alert("请先输入文案内容")
return
}
// 模拟创建对口型任务
const newJob: LipsyncJob = {
id: `lipsync-${Date.now()}`,
status: "pending",
video_url: "",
audio_url: "",
output_video_url: "",
output_duration: 0,
error_message: "",
submitted_at: new Date().toISOString(),
}
setLipsyncJob(newJob)
// 模拟处理流程
setTimeout(() => {
setLipsyncJob({ ...newJob, status: "processing" })
}, 1000)
setTimeout(() => {
setLipsyncJob({
...newJob,
status: "completed",
output_video_url: "",
output_duration: 30,
completed_at: new Date().toISOString(),
})
}, 5000)
}, [scriptContent, setLipsyncJob])
return (
<div className={`ai-avatar-panel panel-script-lipsync ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3> & </h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 上半区:文案编辑 */}
<div className="ai-avatar-script-tabs">
<button
className={activeTab === "library" ? "active" : ""}
onClick={() => handleTabChange("library")}
>
</button>
<button
className={activeTab === "manual" ? "active" : ""}
onClick={() => handleTabChange("manual")}
>
</button>
</div>
{selectedScript && activeTab === "library" && (
<div style={{ marginBottom: 8, fontSize: 13, color: "#999" }}>
{selectedScript.title}
</div>
)}
<textarea
className="ai-avatar-script-editor"
placeholder="请输入视频文案内容..."
value={scriptContent}
onChange={handleScriptChange}
disabled={activeTab === "library"}
/>
<div className="ai-avatar-script-word-count">{scriptContent.length} </div>
{/* 下半区:对口型预览 */}
<div className="ai-avatar-lipsync-section">
<h4></h4>
{/* 视频预览区域 */}
{lipsyncJob?.output_video_url && (
<div className="ai-avatar-media-preview">
<video src={lipsyncJob.output_video_url} controls />
</div>
)}
{/* 状态标签 */}
{lipsyncJob && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<span
className={`ai-avatar-status-badge ${LIPSYNC_STATUS_CLASS[lipsyncJob.status] || ""}`}
>
{LIPSYNC_STATUS_LABEL[lipsyncJob.status] || lipsyncJob.status}
</span>
{lipsyncJob.error_message && (
<span style={{ fontSize: 12, color: "#ef4444" }}>{lipsyncJob.error_message}</span>
)}
</div>
)}
{/* 操作按钮 */}
<div className="ai-avatar-lipsync-actions">
<button className="aa-btn aa-btn-sm" onClick={onOpenBRollModal}>
🎬
</button>
<button className="aa-btn aa-btn-sm aa-btn-primary" onClick={handleRegenerateLipsync}>
</button>
</div>
</div>
</div>
</div>
)
}
export default ScriptLipsyncPanel
@@ -0,0 +1,122 @@
/**
* 文案选择弹窗
* 搜索 + 文案列表 + 选择回调
*/
import React, { useCallback, useEffect, useState } from "react"
import type { Script } from "../types/aiAvatar"
import { getScripts } from "../api/aiAvatar"
interface ScriptSelectModalProps {
open: boolean
onClose: () => void
onSelect: (script: Script) => void
}
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({ open, onClose, onSelect }) => {
const [searchText, setSearchText] = useState("")
const [scripts, setScripts] = useState<Script[]>([])
const [loading, setLoading] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
/* 加载文案列表 */
const fetchScripts = useCallback(async () => {
setLoading(true)
try {
const result = await getScripts({ search: searchText || undefined })
setScripts(result.items)
} catch {
setScripts([])
} finally {
setLoading(false)
}
}, [searchText])
useEffect(() => {
if (open) {
setSearchText("")
setSelectedId(null)
fetchScripts()
}
}, [open, fetchScripts])
/* 搜索防抖 */
useEffect(() => {
if (!open) return
const timer = setTimeout(() => {
fetchScripts()
}, 300)
return () => clearTimeout(timer)
}, [searchText, open, fetchScripts])
const handleSelect = useCallback(
(script: Script) => {
setSelectedId(script.id)
onSelect(script)
onClose()
},
[onSelect, onClose],
)
/* 格式化时间 */
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}
if (!open) return null
return (
<div className="ai-avatar-modal-overlay" onClick={onClose}>
<div className="ai-avatar-modal" onClick={(e) => e.stopPropagation()}>
<div className="ai-avatar-modal-header">
<h3></h3>
<button className="aa-btn aa-btn-sm" onClick={onClose}>
</button>
</div>
<div className="ai-avatar-modal-body">
{/* 搜索栏 + 新建文案 */}
<div className="ai-avatar-script-search">
<input
type="text"
placeholder="搜索文案标题..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<button className="aa-btn aa-btn-primary"></button>
</div>
{/* 文案列表 */}
{loading ? (
<div style={{ textAlign: "center", padding: 32, color: "#999", fontSize: 13 }}>
...
</div>
) : scripts.length === 0 ? (
<div style={{ textAlign: "center", padding: 32, color: "#999", fontSize: 13 }}>
</div>
) : (
scripts.map((script) => (
<div
key={script.id}
className={`ai-avatar-script-item ${selectedId === script.id ? "selected" : ""}`}
onClick={() => handleSelect(script)}
>
<div className="ai-avatar-script-item-info">
<h4>{script.title}</h4>
<span>
{script.content.length} · {formatDate(script.created_at)}
</span>
</div>
<button className="aa-btn aa-btn-sm aa-btn-primary"></button>
</div>
))
)}
</div>
</div>
</div>
)
}
export default ScriptSelectModal
@@ -0,0 +1,250 @@
/**
* 面板4:标题配置
* 主标题输入 + 复用 TitleStylePanel + 字幕设置
*/
import React, { useCallback, useMemo, useState } from "react"
import type { AiAvatarTitleConfig } from "../types/aiAvatar"
import type { TitleSettings, TitlePreset } from "@/pages/generate/types"
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
interface TitleConfigPanelProps {
titleConfig: AiAvatarTitleConfig
setTitleConfig: (c: AiAvatarTitleConfig) => void
collapsed: boolean
onToggleCollapse: () => void
}
/* ── 位置选项 ── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "top-left", label: "左上" },
{ value: "top-right", label: "右上" },
{ value: "bottom-left", label: "左下" },
{ value: "bottom-right", label: "右下" },
{ value: "custom", label: "自由位置" },
]
/* ── 字体选项 ── */
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"阿里巴巴普惠体",
"站酷高端黑",
"站酷快乐体",
"方正兰亭黑",
"方正楷体",
"汉仪旗黑",
]
/* ── 标题预设 ── */
const titlePresets: TitlePreset[] = [
{
key: "default",
label: "默认",
style: { size: 36, color: "#ffffff", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontSize: 16, color: "#ffffff", fontWeight: 400 },
},
{
key: "bold-white",
label: "粗体白",
style: { size: 48, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: { fontSize: 18, color: "#ffffff", fontWeight: 700 },
},
{
key: "highlight-yellow",
label: "高亮黄",
style: { size: 44, color: "#FFD700", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: { fontSize: 17, color: "#FFD700", fontWeight: 700 },
},
{
key: "elegant-serif",
label: "优雅宋体",
style: { size: 40, color: "#f0f0f0", bold: false, italic: true, stroke: false, shadow: true },
previewStyle: { fontSize: 16, color: "#f0f0f0", fontStyle: "italic", fontFamily: "serif" },
},
{
key: "impact",
label: "冲击力",
style: { size: 56, color: "#ff4444", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: { fontSize: 20, color: "#ff4444", fontWeight: 900 },
},
]
/** 将 AiAvatarTitleConfig 适配为 TitleSettings */
function toTitleSettings(config: AiAvatarTitleConfig): TitleSettings {
return {
aiAutoSelect: false,
title: config.title,
position: config.position,
font: config.font,
size: config.size,
bold: config.bold,
italic: config.italic,
stroke: config.stroke,
shadow: config.shadow,
color: config.color,
posX: null,
posY: null,
}
}
/** 根据 preset key 找到对应的预设 */
function findPresetByKey(key: string): TitlePreset | undefined {
return titlePresets.find((p) => p.key === key)
}
const TitleConfigPanel: React.FC<TitleConfigPanelProps> = ({
titleConfig,
setTitleConfig,
collapsed,
onToggleCollapse,
}) => {
/* 字幕开关 */
const [subtitleEnabled, setSubtitleEnabled] = useState(false)
const [subtitleFont, setSubtitleFont] = useState("思源黑体")
const [subtitleSize, setSubtitleSize] = useState(24)
const titleSettings = useMemo(() => toTitleSettings(titleConfig), [titleConfig])
/* 当前激活的预设 */
const activePreset = useMemo(() => {
const match = titlePresets.find(
(p) =>
p.style.size === titleConfig.size &&
p.style.color === titleConfig.color &&
p.style.bold === titleConfig.bold &&
p.style.italic === titleConfig.italic &&
p.style.stroke === titleConfig.stroke &&
p.style.shadow === titleConfig.shadow,
)
return match ? match.key : null
}, [titleConfig])
/* 将 TitleStylePanel 的预设 key 映射回 titlePresets 项 */
const presetItems = useMemo(
() =>
titlePresets.map((p) => ({
key: p.key,
label: p.label,
previewStyle: p.previewStyle as React.CSSProperties,
})),
[],
)
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setTitleConfig({ ...titleConfig, title: e.target.value })
},
[titleConfig, setTitleConfig],
)
const handleApplyPreset = useCallback(
(presetKey: string) => {
const preset = findPresetByKey(presetKey)
if (!preset) return
setTitleConfig({
...titleConfig,
size: preset.style.size,
color: preset.style.color,
bold: preset.style.bold,
italic: preset.style.italic,
stroke: preset.style.stroke,
shadow: preset.style.shadow,
})
},
[titleConfig, setTitleConfig],
)
return (
<div className={`ai-avatar-panel panel-title-config ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3></h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 主标题输入 */}
<div style={{ marginBottom: 16 }}>
<label style={{ display: "block", fontSize: 13, color: "#999", marginBottom: 6 }}>
</label>
<input
className="ai-avatar-script-editor"
style={{ minHeight: "auto", padding: "8px 12px", fontSize: 14 }}
type="text"
placeholder="请输入视频标题"
value={titleConfig.title}
onChange={handleTitleChange}
/>
</div>
{/* 复用标题样式面板 */}
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={(position) => setTitleConfig({ ...titleConfig, position })}
onUpdateFont={(font) => setTitleConfig({ ...titleConfig, font })}
onUpdateSize={(size) => setTitleConfig({ ...titleConfig, size })}
onToggleBold={() => setTitleConfig({ ...titleConfig, bold: !titleConfig.bold })}
onToggleItalic={() => setTitleConfig({ ...titleConfig, italic: !titleConfig.italic })}
onToggleStroke={() => setTitleConfig({ ...titleConfig, stroke: !titleConfig.stroke })}
onToggleShadow={() => setTitleConfig({ ...titleConfig, shadow: !titleConfig.shadow })}
onApplyPreset={handleApplyPreset}
activePreset={activePreset}
titlePresets={presetItems}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
{/* 字幕区域 */}
<div className="ai-avatar-subtitle-section">
<label>
<input
type="checkbox"
checked={subtitleEnabled}
onChange={(e) => setSubtitleEnabled(e.target.checked)}
/>
</label>
{subtitleEnabled && (
<div style={{ marginTop: 12 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
</label>
<select
className="ai-avatar-script-editor"
style={{ minHeight: "auto", padding: "6px 10px", fontSize: 13 }}
value={subtitleFont}
onChange={(e) => setSubtitleFont(e.target.value)}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div>
<label style={{ display: "block", fontSize: 12, color: "#999", marginBottom: 4 }}>
{subtitleSize}px
</label>
<input
type="range"
min={12}
max={72}
value={subtitleSize}
onChange={(e) => setSubtitleSize(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default TitleConfigPanel
@@ -0,0 +1,256 @@
import { useState, useRef, useCallback } from "react"
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { VoiceCloneState, VoiceTone } from "../types/aiAvatar"
interface VoiceClonePanelProps {
voiceClone: VoiceCloneState
setVoiceClone: (v: VoiceCloneState) => void
selectedVoiceId: string
setSelectedVoiceId: (id: string) => void
collapsed: boolean
onToggleCollapse: () => void
}
const MAX_AUDIO_SIZE = 50 * 1024 * 1024 // 50MB
/** Mock 音色列表(后续接 API */
const MOCK_VOICES: VoiceTone[] = [
{
id: "voice-1",
name: "温柔女声",
description: "适合新闻播报和产品介绍",
gender: "女",
preview_url: "",
},
{
id: "voice-2",
name: "沉稳男声",
description: "适合企业宣传和培训视频",
gender: "男",
preview_url: "",
},
{
id: "voice-3",
name: "活泼女声",
description: "适合短视频和社交媒体内容",
gender: "女",
preview_url: "",
},
]
/** 状态文案映射 */
const STATUS_LABEL: Record<VoiceCloneState["status"], string> = {
idle: "",
uploading: "正在上传音频...",
cloning: "正在克隆声音...",
completed: "声音克隆完成",
failed: "克隆失败,请重试",
}
const STATUS_CLASS: Record<string, string> = {
completed: "success",
cloning: "cloning",
failed: "failed",
}
const VoiceClonePanel: React.FC<VoiceClonePanelProps> = ({
voiceClone,
setVoiceClone,
selectedVoiceId,
setSelectedVoiceId,
collapsed,
onToggleCollapse,
}) => {
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_AUDIO_SIZE) {
alert("音频文件大小不能超过 50MB")
return
}
const isAudio = file.type.startsWith("audio/")
if (!isAudio) {
alert("请上传 WAV 或 MP3 格式的音频文件")
return
}
try {
setVoiceClone({ ...voiceClone, status: "uploading", progress: 0 })
// 获取或创建默认项目和配音库
const project = await getOrCreateDefaultProject()
const library = await ensureDefaultLibrary({
project_id: project.id,
kind: "voice",
})
// 上传音频
const audioUrl = URL.createObjectURL(file)
setVoiceClone({
...voiceClone,
status: "uploading",
audioUrl,
audioName: file.name,
progress: 0,
})
await uploadAssetDirect({
file,
library_id: library.id,
onProgress: (p) => {
setVoiceClone({ ...voiceClone, audioUrl, audioName: file.name, progress: p })
},
})
// 开始克隆(模拟)
setVoiceClone({
...voiceClone,
status: "cloning",
audioUrl,
audioName: file.name,
progress: 100,
})
// TODO: 调用实际的声音克隆 API
// 模拟克隆完成
setTimeout(() => {
setVoiceClone({
...voiceClone,
status: "completed",
audioUrl,
audioName: file.name,
cloneJobId: `clone-${Date.now()}`,
voiceId: `voice-clone-${Date.now()}`,
progress: 100,
})
}, 3000)
} catch (err) {
const message = err instanceof Error ? err.message : "上传失败"
alert(message)
setVoiceClone({ ...voiceClone, status: "failed", progress: 0 })
}
},
[voiceClone, setVoiceClone],
)
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
e.target.value = ""
},
[handleUpload],
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file) {
handleUpload(file)
}
},
[handleUpload],
)
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(true)
}, [])
const handleDragLeave = useCallback(() => {
setDragOver(false)
}, [])
const handleVoiceSelect = useCallback(
(voiceId: string) => {
setSelectedVoiceId(voiceId)
},
[setSelectedVoiceId],
)
const showStatus = voiceClone.status !== "idle" && voiceClone.status !== "uploading"
return (
<div className={`ai-avatar-panel panel-voice-clone ${collapsed ? "collapsed" : ""}`}>
<div className="ai-avatar-panel-header" onClick={onToggleCollapse}>
<h3></h3>
<button className="collapse-btn"></button>
</div>
<div className="ai-avatar-panel-body">
{/* 上传区域 */}
{voiceClone.audioUrl ? (
<div>
<div className="ai-avatar-media-preview">
<audio src={voiceClone.audioUrl} controls />
</div>
<div className="ai-avatar-media-info">
<span>{voiceClone.audioName}</span>
</div>
</div>
) : (
<div
className={`ai-avatar-upload-zone ${dragOver ? "drag-over" : ""}`}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<span className="upload-icon">🎙</span>
<div></div>
<div style={{ marginTop: 4, fontSize: 12 }}>WAV / MP3 50MB</div>
<input
ref={fileInputRef}
type="file"
accept="audio/wav,audio/mp3,audio/*"
onChange={handleFileSelect}
style={{ display: "none" }}
/>
</div>
)}
{/* 克隆状态 */}
{showStatus && (
<div className={`ai-avatar-clone-status ${STATUS_CLASS[voiceClone.status] || ""}`}>
{voiceClone.status === "cloning" && "⏳ "}
{voiceClone.status === "completed" && "✅ "}
{voiceClone.status === "failed" && "❌ "}
{STATUS_LABEL[voiceClone.status]}
</div>
)}
{/* 上传中进度 */}
{voiceClone.status === "uploading" && (
<div style={{ marginTop: 12, fontSize: 13, color: "#999" }}>
... {voiceClone.progress}%
</div>
)}
{/* 已有音色列表 */}
<div className="ai-avatar-voice-list">
<h4></h4>
{MOCK_VOICES.map((voice) => (
<div
key={voice.id}
className={`ai-avatar-voice-item ${selectedVoiceId === voice.id ? "selected" : ""}`}
onClick={() => handleVoiceSelect(voice.id)}
>
<div>
<div style={{ fontSize: 13, color: "#fff" }}>{voice.name}</div>
<div style={{ fontSize: 12, color: "#999", marginTop: 2 }}>{voice.description}</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
export default VoiceClonePanel
@@ -0,0 +1,132 @@
/**
* AI数字人页面全局状态管理 (#1798)
*/
import { useState, useCallback } from "react"
import type {
AvatarVideo,
VoiceCloneState,
Script,
LipsyncJob,
BRollSegment,
AiAvatarTitleConfig,
AiAvatarCoverConfig,
AiAvatarGenerateConfig,
AiAvatarRenderJob,
} from "../types/aiAvatar"
const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
title: "",
font: "思源黑体",
size: 28,
color: "#ffffff",
position: "bottom",
bold: false,
italic: false,
stroke: false,
shadow: false,
}
const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
enabled: false,
mode: "auto",
frame_time: 0,
upload_url: "",
thumbnail_url: "",
}
const DEFAULT_VOICE_CLONE: VoiceCloneState = {
status: "idle",
audioUrl: "",
audioName: "",
cloneJobId: "",
voiceId: "",
progress: 0,
}
export function useAiAvatarState() {
/* ── 面板折叠状态 ── */
const [collapsedPanels, setCollapsedPanels] = useState<Record<string, boolean>>({})
const togglePanel = useCallback((key: string) => {
setCollapsedPanels((prev) => ({ ...prev, [key]: !prev[key] }))
}, [])
/* ── 面板1:出镜视频 ── */
const [avatarVideo, setAvatarVideo] = useState<AvatarVideo | null>(null)
/* ── 面板2:声音克隆 ── */
const [voiceClone, setVoiceClone] = useState<VoiceCloneState>(DEFAULT_VOICE_CLONE)
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
/* ── 面板3:文案 & 对口型 ── */
const [selectedScript, setSelectedScript] = useState<Script | null>(null)
const [scriptContent, setScriptContent] = useState("")
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
/* ── 面板4:标题配置 ── */
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
/* ── 面板5:封面 & 生成 ── */
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
const [generateConfig, setGenerateConfig] = useState<AiAvatarGenerateConfig>({
resolution: "1080p",
bRollMode: "pip",
})
/* ── 渲染任务 ── */
const [renderJob, setRenderJob] = useState<AiAvatarRenderJob | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
/* ── 弹窗状态 ── */
const [scriptModalOpen, setScriptModalOpen] = useState(false)
const [brollModalOpen, setBrollModalOpen] = useState(false)
return {
// 面板折叠
collapsedPanels,
togglePanel,
// 面板1
avatarVideo,
setAvatarVideo,
// 面板2
voiceClone,
setVoiceClone,
selectedVoiceId,
setSelectedVoiceId,
// 面板3
selectedScript,
setSelectedScript,
scriptContent,
setScriptContent,
lipsyncJob,
setLipsyncJob,
bRollSegments,
setBRollSegments,
// 面板4
titleConfig,
setTitleConfig,
// 面板5
coverConfig,
setCoverConfig,
generateConfig,
setGenerateConfig,
// 渲染
renderJob,
setRenderJob,
isGenerating,
setIsGenerating,
// 弹窗
scriptModalOpen,
setScriptModalOpen,
brollModalOpen,
setBrollModalOpen,
}
}
@@ -0,0 +1,121 @@
/**
* AI数字人页面 — 类型定义 (#1798)
*/
/* ── 出镜视频 ── */
export interface AvatarVideo {
url: string
name: string
duration: number // 秒
width: number
height: number
size: number // 字节
}
/* ── 声音克隆 ── */
export interface VoiceTone {
id: string
name: string
description: string
gender: string
preview_url?: string
}
export interface VoiceCloneState {
status: "idle" | "uploading" | "cloning" | "completed" | "failed"
audioUrl: string
audioName: string
cloneJobId: string
voiceId: string
progress: number
}
/* ── 文案 ── */
export interface ScriptSegment {
text: string
duration?: number
}
export interface Script {
id: string
title: string
content: string
segments: ScriptSegment[]
tags: string[]
created_at: string
updated_at: string
}
/* ── 对口型 ── */
export interface LipsyncJob {
id: string
status: "pending" | "processing" | "completed" | "failed"
video_url: string
audio_url: string
output_video_url: string
output_duration: number
error_message: string
submitted_at?: string
completed_at?: string
}
/* ── B-roll 插入 ── */
export interface BRollSegment {
script_segment_index: number
asset_url: string
mode: "fullscreen" | "pip"
start_time: number
end_time: number
pip_position?: string
pip_scale?: number
}
/* ── 渲染任务 ── */
export interface AiAvatarRenderRequest {
lipsync_job_id: string
script_id: string
b_roll_segments: BRollSegment[]
title_config: Record<string, unknown>
cover_config: Record<string, unknown>
project_id?: string
}
export interface AiAvatarRenderJob {
id: string
status: "pending" | "processing" | "completed" | "failed"
progress: number
output_video_url: string
output_cover_url: string
output_duration: number
error_message: string
created_at: string
updated_at: string
}
/* ── 封面配置 ── */
export interface AiAvatarCoverConfig {
enabled: boolean
mode: "auto" | "frame" | "upload"
frame_time: number
upload_url: string
thumbnail_url: string
}
/* ── 标题配置(复用 generate 的 TitleSettings 结构) ── */
export interface AiAvatarTitleConfig {
title: string
font: string
size: number
color: string
position: string
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
}
/* ── 生成设置 ── */
export interface AiAvatarGenerateConfig {
resolution: "720p" | "1080p"
bRollMode: "fullscreen" | "pip"
}
+4
View File
@@ -56,6 +56,10 @@ const appChildren: RouteObject[] = [
path: "editing-planner",
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
},
{
path: "ai-avatar",
lazy: lazyRoute(() => import("@/pages/ai-avatar/AiAvatarPage")),
},
{
path: "my-templates",
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),