feat: #1798 AI数字人前端页面 — 5列水平面板布局 #1803
@@ -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: "剪辑模板",
|
||||
|
||||
@@ -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>封面 & 生成</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"
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user