Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 165574f826 | |||
| a7e6145bb1 | |||
| 6ee99a652c | |||
| bf7c8b8a08 | |||
| e4ac398397 | |||
| a0ca13d463 |
@@ -5,7 +5,7 @@
|
||||
.xx-app-shell 全屏 flex 容器
|
||||
├── header (xx-top-nav) 顶部导航(Header.tsx 管理)
|
||||
└── .xx-app-body 水平 flex 行
|
||||
├── .xx-app-sidebar 左侧侧边栏(240px / 64px 折叠)
|
||||
├── .xx-app-sidebar 左侧侧边栏(128px / 64px 折叠)
|
||||
└── .xx-app-content 主内容区(自适应)
|
||||
|
||||
所有尺寸/颜色均使用 global.css 设计系统变量
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
/* ── 侧边栏 ───────────────────────────────────────────────── */
|
||||
.xx-app-sidebar {
|
||||
width: 240px;
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -103,6 +103,12 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 展开态(侧边栏 128px)水平 padding 收窄,为菜单文字留出完整一行空间 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-content {
|
||||
padding-left: var(--space-xs);
|
||||
padding-right: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ── 主内容区 ─────────────────────────────────────────────── */
|
||||
.xx-app-content {
|
||||
flex: 1;
|
||||
@@ -136,7 +142,7 @@
|
||||
|
||||
/* 展开态恢复完整宽度 */
|
||||
.xx-app-sidebar:not(.xx-collapsed) {
|
||||
width: 240px;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle {
|
||||
@@ -159,7 +165,7 @@
|
||||
top: 56px; /* 移动端 Header 高度 */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
width: 128px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-slow);
|
||||
box-shadow: none;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* MainLayout - 主布局组件(Task 1.2)
|
||||
*
|
||||
* 三栏布局:左侧侧边栏 + 顶部导航栏 + 主内容区
|
||||
* - 侧边栏:240px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 侧边栏:128px 固定宽度,可折叠至 64px 图标栏
|
||||
* - 顶部导航:复用 Header 组件(68px 固定高度)
|
||||
* - 主内容区:自适应填充剩余空间
|
||||
* - 响应式:移动端(<768px)隐藏侧边栏
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
/* 分组标题 */
|
||||
.xx-sidebar-group-title {
|
||||
padding: var(--space-sm) var(--space-md) var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
@@ -56,9 +56,9 @@
|
||||
.xx-sidebar-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: 0 var(--space-xs);
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-xs);
|
||||
margin: 0 var(--space-xxs);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
@@ -97,12 +97,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: 0.15s ease;
|
||||
}
|
||||
@@ -119,7 +119,9 @@
|
||||
/* ── 菜单项文字 ───────────────────────────────────────────── */
|
||||
.xx-sidebar-menu-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -133,8 +135,16 @@
|
||||
/* 折叠时菜单项居中,仅图标 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-sm);
|
||||
margin: 0 var(--space-xxs);
|
||||
padding: var(--space-xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 折叠态图标恢复更大尺寸居中 */
|
||||
.xx-sidebar-nav--collapsed .xx-sidebar-menu-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 折叠时隐藏分组标题 */
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
.admin-coming-soon-page {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +1,341 @@
|
||||
/**
|
||||
* AI数字人 — 主页面(5列水平面板布局)(#1798)
|
||||
* AI数字人 — 主页面(v3)
|
||||
* 5列水平面板布局
|
||||
*/
|
||||
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 React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import "./AiAvatar.css"
|
||||
import { useAiAvatar } from "./hooks/useAiAvatar"
|
||||
import { PanelVideoSelector } from "./components/PanelVideoSelector"
|
||||
import PanelVoiceSelector from "./components/PanelVoiceSelector"
|
||||
import PanelScriptAndLipsync from "./components/PanelScriptAndLipsync"
|
||||
import PanelTitleConfig from "./components/PanelTitleConfig"
|
||||
import PanelCoverAndGenerate from "./components/PanelCoverAndGenerate"
|
||||
import { ModalAssetPicker } from "./components/ModalAssetPicker"
|
||||
import ModalBRollEditor from "./components/ModalBRollEditor"
|
||||
import { getScripts, createLipsyncJob, getLipsyncJob, submitRender } from "./api/aiAvatar"
|
||||
|
||||
/** 面板折叠状态 */
|
||||
type PanelKey = "video" | "voice" | "script" | "title" | "cover"
|
||||
|
||||
const AiAvatarPage: React.FC = () => {
|
||||
const state = useAiAvatarState()
|
||||
const state = useAiAvatar()
|
||||
const [collapsed, setCollapsed] = useState<Record<PanelKey, boolean>>({
|
||||
video: false,
|
||||
voice: false,
|
||||
script: false,
|
||||
title: false,
|
||||
cover: false,
|
||||
})
|
||||
|
||||
const handleSubmitGenerate = React.useCallback(() => {
|
||||
// TODO: 调用 submitRender API
|
||||
/* ── 对口型轮询 ── */
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const togglePanel = useCallback((key: PanelKey) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}, [])
|
||||
|
||||
/* ── 对口型 ── */
|
||||
const handleGenerateLipsync = useCallback(async () => {
|
||||
// ② 缺项明确提示(#1809):不再静默 return
|
||||
const video = state.selectedVideo
|
||||
const voice = state.selectedVoice
|
||||
const text = state.scriptText.trim()
|
||||
const missing: string[] = []
|
||||
if (!video) missing.push("出镜视频")
|
||||
if (!voice) missing.push("音色")
|
||||
if (!text) missing.push("文案")
|
||||
if (missing.length > 0 || !video || !voice) {
|
||||
message.warning(`请先选择${missing.join("、")}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const job = await createLipsyncJob({
|
||||
voice_id: voice.voice_id,
|
||||
script_text: state.scriptText,
|
||||
video_asset_id: video.id,
|
||||
})
|
||||
state.setLipsyncJob(job)
|
||||
message.success("对口型任务已提交,生成中…")
|
||||
// 开始轮询
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
if (updated.status === "completed" || updated.status === "failed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
if (updated.status === "completed") {
|
||||
message.success("对口型视频生成完成")
|
||||
} else {
|
||||
message.error(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误(轮询期间不打扰用户)
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
// ② 接口失败弹错误提示,不只 console
|
||||
console.error("对口型任务创建失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "对口型任务提交失败,请重试")
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedVideo, state.selectedVoice, state.scriptText])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const handleGenerate = useCallback(async () => {
|
||||
// ② 前置条件提示(#1809)
|
||||
if (!state.lipsyncJob || state.lipsyncJob.status !== "completed") {
|
||||
message.warning("请先生成对口型视频,待对口型完成后再提交渲染")
|
||||
return
|
||||
}
|
||||
state.setIsGenerating(true)
|
||||
}, [state])
|
||||
try {
|
||||
await submitRender({
|
||||
lipsync_job_id: state.lipsyncJob.id,
|
||||
script_id: state.script?.id,
|
||||
b_roll_segments: state.bRollSegments as never,
|
||||
title_config: state.titleConfig as unknown as Record<string, unknown>,
|
||||
cover_config: state.coverConfig as unknown as Record<string, unknown>,
|
||||
resolution: state.resolution,
|
||||
})
|
||||
message.success("渲染任务已提交,可在视频管理中查看进度")
|
||||
} catch (err) {
|
||||
console.error("渲染任务提交失败:", err)
|
||||
message.error(err instanceof Error ? err.message : "渲染任务提交失败,请重试")
|
||||
} finally {
|
||||
state.setIsGenerating(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
state.lipsyncJob,
|
||||
state.script,
|
||||
state.bRollSegments,
|
||||
state.titleConfig,
|
||||
state.coverConfig,
|
||||
state.resolution,
|
||||
])
|
||||
|
||||
/* ── 配置汇总 ── */
|
||||
const summary = {
|
||||
videoName: state.selectedVideo?.name || null,
|
||||
voiceName: state.selectedVoice?.name || null,
|
||||
scriptLength: state.scriptText.length,
|
||||
lipsyncStatus: state.lipsyncJob?.status || null,
|
||||
brollCount: state.bRollSegments.length,
|
||||
hasTitle: state.titleConfig.title.length > 0,
|
||||
hasCover: state.coverConfig.enabled,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-avatar-page">
|
||||
{/* 面板1:出镜视频 */}
|
||||
<AvatarVideoPanel
|
||||
video={state.avatarVideo}
|
||||
onVideoChange={state.setAvatarVideo}
|
||||
collapsed={!!state.collapsedPanels["avatar-video"]}
|
||||
onToggleCollapse={() => state.togglePanel("avatar-video")}
|
||||
/>
|
||||
<div className="aa-page">
|
||||
<div className="aa-page-header">
|
||||
<h1>AI数字人</h1>
|
||||
</div>
|
||||
<div className="aa-page-body">
|
||||
{/* 面板1:出镜视频 */}
|
||||
<div className={`aa-panel aa-panel--p1${collapsed.video ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("video")}>
|
||||
<span className="aa-panel__title">出镜视频</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelVideoSelector
|
||||
selectedVideo={state.selectedVideo}
|
||||
onSelectVideo={() => state.setShowAssetPicker(true)}
|
||||
onRemoveVideo={state.removeVideo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板2:声音克隆 */}
|
||||
<VoiceClonePanel
|
||||
voiceClone={state.voiceClone}
|
||||
setVoiceClone={state.setVoiceClone}
|
||||
selectedVoiceId={state.selectedVoiceId}
|
||||
setSelectedVoiceId={state.setSelectedVoiceId}
|
||||
collapsed={!!state.collapsedPanels["voice-clone"]}
|
||||
onToggleCollapse={() => state.togglePanel("voice-clone")}
|
||||
/>
|
||||
{/* 面板2:配音库 */}
|
||||
<div className={`aa-panel aa-panel--p2${collapsed.voice ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("voice")}>
|
||||
<span className="aa-panel__title">配音库</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelVoiceSelector
|
||||
voiceSource={state.voiceSource}
|
||||
onVoiceSourceChange={state.setVoiceSource}
|
||||
selectedVoice={state.selectedVoice}
|
||||
onSelectVoice={state.setSelectedVoice}
|
||||
emotion={state.emotion}
|
||||
onEmotionChange={state.setEmotion}
|
||||
speed={state.speed}
|
||||
onSpeedChange={state.setSpeed}
|
||||
language={state.language}
|
||||
onLanguageChange={state.setLanguage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板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")}
|
||||
/>
|
||||
{/* 面板3:文案 & 对口型 */}
|
||||
<div className={`aa-panel aa-panel--p3${collapsed.script ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("script")}>
|
||||
<span className="aa-panel__title">文案 & 对口型</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelScriptAndLipsync
|
||||
scriptText={state.scriptText}
|
||||
onScriptTextChange={state.setScriptText}
|
||||
onOpenScriptModal={() => state.setShowScriptModal(true)}
|
||||
lipsyncJob={state.lipsyncJob}
|
||||
onGenerateLipsync={handleGenerateLipsync}
|
||||
bRollSegments={state.bRollSegments}
|
||||
onOpenBRollModal={() => state.setShowBRollModal(true)}
|
||||
onRemoveBRoll={state.removeBRollSegment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板4:标题配置 */}
|
||||
<TitleConfigPanel
|
||||
titleConfig={state.titleConfig}
|
||||
setTitleConfig={state.setTitleConfig}
|
||||
collapsed={!!state.collapsedPanels["title-config"]}
|
||||
onToggleCollapse={() => state.togglePanel("title-config")}
|
||||
/>
|
||||
{/* 面板4:标题配置 */}
|
||||
<div className={`aa-panel aa-panel--p4${collapsed.title ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("title")}>
|
||||
<span className="aa-panel__title">标题配置</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelTitleConfig titleConfig={state.titleConfig} onUpdate={state.updateTitleConfig} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板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")}
|
||||
/>
|
||||
{/* 面板5:封面 & 生成 */}
|
||||
<div className={`aa-panel aa-panel--p5${collapsed.cover ? " collapsed" : ""}`}>
|
||||
<div className="aa-panel__header" onClick={() => togglePanel("cover")}>
|
||||
<span className="aa-panel__title">封面 & 生成</span>
|
||||
<span className="aa-panel__toggle">▼</span>
|
||||
</div>
|
||||
<div className="aa-panel__body">
|
||||
<PanelCoverAndGenerate
|
||||
coverConfig={state.coverConfig}
|
||||
onCoverConfigChange={(partial) =>
|
||||
state.setCoverConfig((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
resolution={state.resolution}
|
||||
onResolutionChange={state.setResolution}
|
||||
isGenerating={state.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
summary={summary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗:文案选择 */}
|
||||
<ScriptSelectModal
|
||||
open={state.scriptModalOpen}
|
||||
onClose={() => state.setScriptModalOpen(false)}
|
||||
onSelect={(script) => {
|
||||
state.setSelectedScript(script)
|
||||
state.setScriptContent(script.content)
|
||||
state.setScriptModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
{/* 素材库弹窗 */}
|
||||
{state.showAssetPicker && (
|
||||
<ModalAssetPicker
|
||||
open={state.showAssetPicker}
|
||||
onClose={() => state.setShowAssetPicker(false)}
|
||||
onSelect={state.selectVideo}
|
||||
selectedId={state.selectedVideo?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 弹窗: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}
|
||||
/>
|
||||
{/* 文案选择弹窗 */}
|
||||
{state.showScriptModal && (
|
||||
<ScriptSelectModalLazy
|
||||
open={state.showScriptModal}
|
||||
onClose={() => state.setShowScriptModal(false)}
|
||||
onSelect={state.selectScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* B-roll 编辑器弹窗 */}
|
||||
{state.showBRollModal && (
|
||||
<ModalBRollEditor
|
||||
open={state.showBRollModal}
|
||||
onClose={() => state.setShowBRollModal(false)}
|
||||
existingSegments={state.bRollSegments}
|
||||
scriptText={state.scriptText}
|
||||
outputDuration={state.lipsyncJob?.output_duration ?? 0}
|
||||
onConfirm={state.addBRollSegment}
|
||||
onRemove={state.removeBRollSegment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文案选择弹窗(内联实现,轻量版) */
|
||||
const ScriptSelectModalLazy: React.FC<{
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (script: import("./types").Script) => void
|
||||
}> = ({ open, onClose, onSelect }) => {
|
||||
const [scripts, setScripts] = useState<import("./types").Script[]>([])
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getScripts()
|
||||
.then((items) => setScripts(Array.isArray(items) ? items : []))
|
||||
.catch(() => setScripts([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const filtered = scripts.filter(
|
||||
(s) => !search || s.title.includes(search) || s.content.includes(search),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择文案</span>
|
||||
<button className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-script-list-header">
|
||||
<input
|
||||
className="aa-input"
|
||||
placeholder="搜索文案..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="aa-empty">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📝</div>
|
||||
暂无文案,请手动输入或新建
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-script-list">
|
||||
{filtered.map((s) => (
|
||||
<div key={s.id} className="aa-script-item" onClick={() => onSelect(s)}>
|
||||
<span className="aa-script-item__icon">📄</span>
|
||||
<div className="aa-script-item__info">
|
||||
<div className="aa-script-item__title">{s.title}</div>
|
||||
<div className="aa-script-item__meta">
|
||||
{s.char_count}字 · {new Date(s.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-modal__footer">
|
||||
<button className="aa-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,75 +1,67 @@
|
||||
/**
|
||||
* AI数字人 API 调用封装 (#1798)
|
||||
* AI数字人 — API 封装
|
||||
*/
|
||||
import apiClient from "@/api/client"
|
||||
import type {
|
||||
Script,
|
||||
LipsyncJob,
|
||||
AiAvatarRenderRequest,
|
||||
AiAvatarRenderJob,
|
||||
} from "../types/aiAvatar"
|
||||
import type { Script, LipsyncJob, RenderJob, BRollSegment } from "../types"
|
||||
|
||||
/* ── 文案库 ── */
|
||||
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 const getScripts = async (): Promise<Script[]> => {
|
||||
const response = await apiClient.get<{ items?: Script[] } | Script[]>("/scripts")
|
||||
// 后端列表返回 { items, total } 分页对象,做兼容解包 + 数组防御(#1809 白屏修复)
|
||||
const data = response.data as unknown
|
||||
if (Array.isArray(data)) return data
|
||||
const items = (data as { items?: Script[] })?.items
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
export async function getScript(id: string) {
|
||||
const { data } = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return data
|
||||
export const getScriptById = async (id: string): Promise<Script> => {
|
||||
const response = await apiClient.get<Script>(`/scripts/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createScript(payload: { title: string; content: string; tags?: string[] }) {
|
||||
const { data } = await apiClient.post<Script>("/scripts", payload)
|
||||
return data
|
||||
export const createScript = async (data: { title: string; content: string }): Promise<Script> => {
|
||||
const response = await apiClient.post<Script>("/scripts", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteScript(id: string) {
|
||||
export const deleteScript = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/scripts/${id}`)
|
||||
}
|
||||
|
||||
/* ── 对口型 ── */
|
||||
export async function createLipsyncJob(payload: {
|
||||
video_url: string
|
||||
audio_url: string
|
||||
enable_video_loop?: boolean
|
||||
export const createLipsyncJob = async (data: {
|
||||
voice_id: string
|
||||
script_text: string
|
||||
video_asset_id: string
|
||||
}): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.post<LipsyncJob>("/lipsync/jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
export const submitRender = async (data: {
|
||||
lipsync_job_id: string
|
||||
script_id?: string
|
||||
b_roll_segments?: BRollSegment[]
|
||||
title_config?: Record<string, unknown>
|
||||
cover_config?: Record<string, unknown>
|
||||
project_id?: string
|
||||
}) {
|
||||
const { data } = await apiClient.post<LipsyncJob>("/lipsync/jobs", payload)
|
||||
return data
|
||||
resolution?: string
|
||||
}): Promise<RenderJob> => {
|
||||
const response = await apiClient.post<RenderJob>("/ai-avatar/render", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function getLipsyncJob(id: string) {
|
||||
const { data } = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`)
|
||||
return data
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 渲染合成 ── */
|
||||
export async function submitRender(payload: AiAvatarRenderRequest) {
|
||||
const { data } = await apiClient.post<AiAvatarRenderJob>("/ai-avatar/render", payload)
|
||||
return data
|
||||
export const cancelRenderJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.post(`/ai-avatar/render/${jobId}/cancel`)
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 面板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,209 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择弹窗(#1809 ③)
|
||||
* 交互对齐智能剪辑 Step2:先选素材库(video 库)→ 再选该库内视频。
|
||||
* 搜索框 + 素材库下拉 + 竖屏 9:16 视频缩略图网格 + 底部确认选择。
|
||||
*/
|
||||
import { useEffect, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
|
||||
export interface ModalAssetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelect: (asset: AssetItem) => void
|
||||
/** 已选中的素材 ID(用于高亮) */
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
export function ModalAssetPicker({ open, onClose, onSelect, selectedId }: ModalAssetPickerProps) {
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [pickedId, setPickedId] = useState<string | null>(null)
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
/* 弹窗打开:重置状态 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setKeyword("")
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAssets([])
|
||||
setError("")
|
||||
setPickedId(selectedId ?? null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* 第一步:加载视频素材库列表(仅 kind=video,对齐智能剪辑 #1777) */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setLoadingLibs(true)
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
// 默认选中第一个视频库
|
||||
if (list.length > 0) setLibraryId((prev) => prev || list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 第二步:选中库后拉取该库视频素材(关键字防抖) */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
const load = async () => {
|
||||
try {
|
||||
// getAssets 返回 { items, total };拉满一页(数字人口播视频库通常不大)
|
||||
const { items } = await getAssets(libraryId, { page_size: 100 })
|
||||
if (cancelled) return
|
||||
let list = Array.isArray(items) ? items : []
|
||||
// 仅保留视频素材(出镜视频要求)
|
||||
list = list.filter((a) => a.mime_type?.includes("video"))
|
||||
const kw = keyword.trim()
|
||||
if (kw) list = list.filter((a) => a.name?.includes(kw))
|
||||
setAssets(list)
|
||||
setError("")
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("素材加载失败,请重试")
|
||||
setAssets([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
}
|
||||
}
|
||||
const timer = window.setTimeout(load, 300)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [open, libraryId, keyword])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pickedId) return
|
||||
const asset = assets.find((a) => a.id === pickedId)
|
||||
if (asset) onSelect(asset)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">选择出镜视频</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:素材库选择 + 搜索 + 网格 */}
|
||||
<div className="aa-modal__body">
|
||||
{/* 第一步:选素材库 */}
|
||||
<div className="aa-asset-search">
|
||||
<select
|
||||
className="aa-select"
|
||||
style={{ width: 160, flex: "0 0 auto" }}
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
className="aa-input"
|
||||
type="text"
|
||||
placeholder="搜索素材名称…"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{libraries.length === 0 && !loadingLibs ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">📁</div>
|
||||
暂无视频素材库,请先在「素材库」中创建视频库并上传视频
|
||||
</div>
|
||||
) : loadingAssets ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
{error}
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
该素材库暂无视频素材
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-asset-grid">
|
||||
{assets.map((asset) => {
|
||||
const isActive = asset.id === pickedId
|
||||
const thumb = asset.thumbnail_url || asset.file_url
|
||||
const isVideo = asset.mime_type?.includes("video")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`aa-asset-card${isActive ? " selected" : ""}`}
|
||||
onClick={() => setPickedId(asset.id)}
|
||||
>
|
||||
{isVideo && !asset.thumbnail_url ? (
|
||||
<video src={asset.file_url} muted preload="metadata" />
|
||||
) : (
|
||||
<img src={thumb} alt={asset.name} />
|
||||
)}
|
||||
{isActive && <div className="aa-asset-card__check">✓</div>}
|
||||
<div className="aa-asset-card__name">{asset.name}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:取消 + 确认选择 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn aa-btn--ghost" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!pickedId}
|
||||
>
|
||||
确认选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* AI数字人 — B-roll 画面插入编辑器弹窗(#1809 ④⑤⑥)
|
||||
*
|
||||
* 布局:
|
||||
* - 左侧:先选素材库(video 库)→ 再选该库视频素材(已被其他 segment 使用的素材
|
||||
* 标灰 + "已选择" 遮罩,pointer-events:none 防重复选择)
|
||||
* - 右侧:文案句子列表(点选对应段落,替代原数字索引框)/ 全屏 or 画中画 / 四角位置+大小
|
||||
* (开始/结束时间已删除,按句子字数占比 × 口播总时长自动估算)
|
||||
* - 底部:已配置的画面插入列表(可删除)
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { getAssets, getAssetLibraries, type AssetItem, type AssetLibraryItem } from "@/api/assets"
|
||||
import type { BRollSegment, BRollInsertMode, PipPosition } from "../types"
|
||||
import { splitScriptIntoSentences, type ScriptSentence } from "../utils/sentences"
|
||||
|
||||
interface ModalBRollEditorProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 当前已有的 B-roll segments(用于标灰已选素材) */
|
||||
existingSegments: BRollSegment[]
|
||||
/** 当前文案全文(用于分句) */
|
||||
scriptText: string
|
||||
/** 对口型成片总时长(秒),用于时间自动估算 */
|
||||
outputDuration: number
|
||||
onConfirm: (segment: BRollSegment) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const PIP_POSITION_OPTIONS: { value: PipPosition; label: string }[] = [
|
||||
{ value: "top-left", label: "左上" },
|
||||
{ value: "top-right", label: "右上" },
|
||||
{ value: "bottom-left", label: "左下" },
|
||||
{ value: "bottom-right", label: "右下" },
|
||||
]
|
||||
|
||||
const MODE_LABEL: Record<BRollInsertMode, string> = {
|
||||
fullscreen: "全屏切换",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
const ModalBRollEditor: React.FC<ModalBRollEditorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
existingSegments,
|
||||
scriptText,
|
||||
outputDuration,
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}) => {
|
||||
/* ── 素材库(④ 先选库再选素材) ── */
|
||||
const [libraries, setLibraries] = useState<AssetLibraryItem[]>([])
|
||||
const [libraryId, setLibraryId] = useState<string>("")
|
||||
const [availableAssets, setAvailableAssets] = useState<AssetItem[]>([])
|
||||
const [loadingLibs, setLoadingLibs] = useState(false)
|
||||
const [loadingAssets, setLoadingAssets] = useState(false)
|
||||
const [assetError, setAssetError] = useState("")
|
||||
|
||||
/* ── 右侧设置本地状态 ── */
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetItem | null>(null)
|
||||
const [selectedSentence, setSelectedSentence] = useState<ScriptSentence | null>(null)
|
||||
const [mode, setMode] = useState<BRollInsertMode>("fullscreen")
|
||||
const [pipPosition, setPipPosition] = useState<PipPosition>("top-right")
|
||||
const [pipScale, setPipScale] = useState(0.3)
|
||||
|
||||
/** 文案分句(⑤) */
|
||||
const sentences = useMemo(
|
||||
() => splitScriptIntoSentences(scriptText, outputDuration),
|
||||
[scriptText, outputDuration],
|
||||
)
|
||||
|
||||
/** 已被现有 segments 占用的素材 id 集合(标灰、禁止重复选择) */
|
||||
const usedAssetIds = useMemo(
|
||||
() => new Set(existingSegments.map((seg) => seg.asset.id)),
|
||||
[existingSegments],
|
||||
)
|
||||
|
||||
/* 弹窗打开:重置选择 + 加载视频库列表 */
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
setMode("fullscreen")
|
||||
setPipPosition("top-right")
|
||||
setPipScale(0.3)
|
||||
setLibraries([])
|
||||
setLibraryId("")
|
||||
setAvailableAssets([])
|
||||
setAssetError("")
|
||||
setLoadingLibs(true)
|
||||
let cancelled = false
|
||||
getAssetLibraries("video")
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(libs) ? libs : []
|
||||
setLibraries(list)
|
||||
if (list.length > 0) setLibraryId(list[0].id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssetError("素材库加载失败,请重试")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingLibs(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
/* 选中库后拉取该库视频素材 */
|
||||
useEffect(() => {
|
||||
if (!open || !libraryId) return
|
||||
let cancelled = false
|
||||
setLoadingAssets(true)
|
||||
getAssets(libraryId, { page_size: 100 })
|
||||
.then(({ items }) => {
|
||||
if (cancelled) return
|
||||
const list = (Array.isArray(items) ? items : []).filter((a) =>
|
||||
a.mime_type?.includes("video"),
|
||||
)
|
||||
setAvailableAssets(list)
|
||||
setAssetError("")
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAssetError("素材加载失败,请重试")
|
||||
setAvailableAssets([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingAssets(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, libraryId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
/** 选择素材(已选素材因 pointer-events:none 不会触发) */
|
||||
const handleSelectAsset = (asset: AssetItem) => {
|
||||
if (usedAssetIds.has(asset.id)) return
|
||||
setSelectedAsset(asset)
|
||||
}
|
||||
|
||||
/** 确认添加一段 B-roll(⑥ 时间取所选句子的估算起止) */
|
||||
const handleConfirm = () => {
|
||||
if (!selectedAsset || !selectedSentence) return
|
||||
const startTime = selectedSentence.startTime
|
||||
const endTime = Math.max(selectedSentence.endTime, startTime + 0.5)
|
||||
const segment: BRollSegment = {
|
||||
id: crypto.randomUUID(),
|
||||
asset: selectedAsset,
|
||||
script_segment_index: selectedSentence.index,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
mode,
|
||||
pip_position: pipPosition,
|
||||
pip_scale: mode === "pip" ? pipScale : 0.3,
|
||||
}
|
||||
onConfirm(segment)
|
||||
// 重置素材/句子选择,保留模式设置便于连续添加
|
||||
setSelectedAsset(null)
|
||||
setSelectedSentence(null)
|
||||
}
|
||||
|
||||
const canConfirm = selectedAsset !== null && selectedSentence !== null
|
||||
|
||||
return (
|
||||
<div className="aa-modal-overlay" onClick={onClose}>
|
||||
<div className="aa-modal aa-modal--wide" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 头部 */}
|
||||
<div className="aa-modal__header">
|
||||
<span className="aa-modal__title">🎞️ 画面插入(B-roll)</span>
|
||||
<button type="button" className="aa-modal__close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主体:左素材 + 右设置 */}
|
||||
<div className="aa-modal__body">
|
||||
<div className="aa-broll-modal-body">
|
||||
{/* 左侧:选库 + 素材网格 */}
|
||||
<div className="aa-broll-left">
|
||||
<div className="aa-broll-lib-row">
|
||||
<select
|
||||
className="aa-select"
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
<option value="">{loadingLibs ? "素材库加载中…" : "暂无视频素材库"}</option>
|
||||
) : (
|
||||
libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
📁 {lib.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-broll-asset-grid">
|
||||
{availableAssets.map((asset) => {
|
||||
const alreadySelected = usedAssetIds.has(asset.id)
|
||||
const isCurrent = selectedAsset?.id === asset.id
|
||||
const classNames = [
|
||||
"aa-broll-asset-thumb",
|
||||
isCurrent ? "selected" : "",
|
||||
alreadySelected ? "already-selected" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={classNames}
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
title={asset.name}
|
||||
>
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="aa-broll-asset-placeholder">🎬</div>
|
||||
)}
|
||||
<span className="aa-asset-card__name">{asset.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{loadingAssets ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
素材加载中…
|
||||
</div>
|
||||
) : availableAssets.length === 0 ? (
|
||||
<div className="aa-empty" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="aa-empty__icon">🎬</div>
|
||||
{assetError || "该素材库暂无视频素材"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:插入设置 */}
|
||||
<div className="aa-broll-right">
|
||||
<div className="aa-broll-settings">
|
||||
{/* ⑤ 文案句子列表(替代段落索引数字框) */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">对应文案句子(点选)</label>
|
||||
{sentences.length === 0 ? (
|
||||
<div className="aa-sentence-empty">
|
||||
请先在「文案 & 对口型」面板填写或选择文案
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-sentence-list">
|
||||
{sentences.map((sent) => {
|
||||
const active = selectedSentence?.index === sent.index
|
||||
return (
|
||||
<button
|
||||
key={sent.index}
|
||||
type="button"
|
||||
className={`aa-sentence-item${active ? " active" : ""}`}
|
||||
onClick={() => setSelectedSentence(sent)}
|
||||
title={sent.text}
|
||||
>
|
||||
<span className="aa-sentence-item__idx">{sent.index + 1}</span>
|
||||
<span className="aa-sentence-item__text">{sent.text}</span>
|
||||
{outputDuration > 0 && (
|
||||
<span className="aa-sentence-item__time">
|
||||
{sent.startTime.toFixed(1)}-{sent.endTime.toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 插入模式 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">插入模式</label>
|
||||
<div className="aa-broll-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "fullscreen" ? " active" : ""}`}
|
||||
onClick={() => setMode("fullscreen")}
|
||||
>
|
||||
全屏切换
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-broll-mode-btn${mode === "pip" ? " active" : ""}`}
|
||||
onClick={() => setMode("pip")}
|
||||
>
|
||||
画中画
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画中画:四角位置 + 大小 */}
|
||||
{mode === "pip" && (
|
||||
<>
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">画中画位置</label>
|
||||
<div className="aa-pip-positions">
|
||||
{PIP_POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`aa-pip-pos-btn${
|
||||
pipPosition === opt.value ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setPipPosition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-form-field">
|
||||
<div className="aa-field-label-row">
|
||||
<label className="aa-label">画中画大小</label>
|
||||
<span style={{ fontSize: 12, color: "#8c8ca1" }}>
|
||||
{Math.round(pipScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={0.6}
|
||||
step={0.05}
|
||||
value={pipScale}
|
||||
onChange={(e) => setPipScale(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 当前选择提示(⑥ 自动估算时间在这里展示) */}
|
||||
<div className="aa-broll-hint">
|
||||
{selectedAsset && selectedSentence ? (
|
||||
<>
|
||||
<div>已选素材:{selectedAsset.name}</div>
|
||||
<div>
|
||||
对应第 {selectedSentence.index + 1} 句 · 时间段{" "}
|
||||
{selectedSentence.startTime.toFixed(1)}s -{" "}
|
||||
{Math.max(
|
||||
selectedSentence.endTime,
|
||||
selectedSentence.startTime + 0.5,
|
||||
).toFixed(1)}
|
||||
s (按字数自动估算)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#8c8ca1" }}>
|
||||
{!selectedAsset ? "请从左侧选择一段素材" : "请在上方点选对应的文案句子"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:已配置的画面插入列表 */}
|
||||
<div className="aa-broll-list">
|
||||
<div className="aa-broll-list__title">已配置画面插入({existingSegments.length})</div>
|
||||
{existingSegments.length === 0 ? (
|
||||
<div className="aa-empty" style={{ padding: 12 }}>
|
||||
尚未配置画面插入
|
||||
</div>
|
||||
) : (
|
||||
existingSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url ? (
|
||||
<img className="aa-broll-item__thumb" src={seg.asset.thumbnail_url} alt="" />
|
||||
) : (
|
||||
<div className="aa-broll-item__thumb" />
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div style={{ fontWeight: 500, color: "#1a1a2e" }}>{seg.asset.name}</div>
|
||||
<div style={{ color: "#8c8ca1", fontSize: 11 }}>
|
||||
第 {seg.script_segment_index + 1} 句 · {MODE_LABEL[seg.mode]}
|
||||
{seg.mode === "pip" ? ` · ${seg.pip_position}` : ""} ·{" "}
|
||||
{seg.start_time.toFixed(1)}s - {seg.end_time.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemove(seg.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="aa-modal__footer">
|
||||
<button type="button" className="aa-btn" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary"
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
✅ 添加画面插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalBRollEditor
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI数字人 — 面板5:封面 & 生成
|
||||
* - 竖屏 9:16 封面预览(从视频截取 / 自定义上传)
|
||||
* - 分辨率选择(720p / 1080p / 4K)
|
||||
* - 配置汇总卡片(出镜视频/音色/文案/对口型/B-roll/标题/封面)
|
||||
* - 渐变紫色生成按钮
|
||||
*
|
||||
* 注意:v3 已删除"画面插入模式",本面板不包含该选项。
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import type { AiAvatarCoverConfig } from "../types"
|
||||
|
||||
interface PanelCoverAndGenerateProps {
|
||||
coverConfig: AiAvatarCoverConfig
|
||||
onCoverConfigChange: (partial: Partial<AiAvatarCoverConfig>) => void
|
||||
resolution: string
|
||||
onResolutionChange: (r: string) => void
|
||||
isGenerating: boolean
|
||||
onGenerate: () => void
|
||||
/** 配置汇总信息 */
|
||||
summary: {
|
||||
videoName: string | null
|
||||
voiceName: string | null
|
||||
scriptLength: number
|
||||
lipsyncStatus: string | null
|
||||
brollCount: number
|
||||
hasTitle: boolean
|
||||
hasCover: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const RESOLUTION_OPTIONS = [
|
||||
{ value: "720p", label: "720p(高清)" },
|
||||
{ value: "1080p", label: "1080p(全高清)" },
|
||||
{ value: "4k", label: "4K(超清)" },
|
||||
]
|
||||
|
||||
const LIPSYNC_STATUS_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
idle: { text: "未开始", cls: "aa-status-badge--idle" },
|
||||
pending: { text: "排队中", cls: "aa-status-badge--pending" },
|
||||
processing: { text: "生成中", cls: "aa-status-badge--processing" },
|
||||
completed: { text: "已完成", cls: "aa-status-badge--completed" },
|
||||
failed: { text: "失败", cls: "aa-status-badge--failed" },
|
||||
}
|
||||
|
||||
const PanelCoverAndGenerate: React.FC<PanelCoverAndGenerateProps> = ({
|
||||
coverConfig,
|
||||
onCoverConfigChange,
|
||||
resolution,
|
||||
onResolutionChange,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
summary,
|
||||
}) => {
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** 自定义上传封面 */
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
// 本地预览:生成 object URL(实际上传由父级/后端链路处理)
|
||||
const url = URL.createObjectURL(file)
|
||||
onCoverConfigChange({ mode: "upload", upload_url: url, thumbnail_url: url })
|
||||
// 允许重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 从视频截取(使用配置的帧时间,默认首帧) */
|
||||
const handleCaptureFromVideo = () => {
|
||||
onCoverConfigChange({ mode: "auto_frame" })
|
||||
}
|
||||
|
||||
const lipsync = summary.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null
|
||||
|
||||
const canGenerate = summary.lipsyncStatus === "completed" && !isGenerating
|
||||
|
||||
return (
|
||||
<div className="aa-cover-generate">
|
||||
{/* 封面预览(竖屏 9:16) */}
|
||||
<div className="aa-cover-preview">
|
||||
{coverConfig.thumbnail_url ? (
|
||||
<img src={coverConfig.thumbnail_url} alt="封面预览" />
|
||||
) : (
|
||||
<span className="aa-cover-preview__placeholder">暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-cover-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "auto_frame" ? " active" : ""}`}
|
||||
onClick={handleCaptureFromVideo}
|
||||
>
|
||||
🎬 从视频截取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-btn aa-btn--ghost${coverConfig.mode === "upload" ? " active" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
📷 自定义上传
|
||||
</button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分辨率选择 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">分辨率</label>
|
||||
<select
|
||||
className="aa-select"
|
||||
value={resolution}
|
||||
onChange={(e) => onResolutionChange(e.target.value)}
|
||||
>
|
||||
{RESOLUTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 配置汇总 */}
|
||||
<div className="aa-generate-section">
|
||||
<div className="aa-config-summary">
|
||||
<div className="aa-config-summary__row">
|
||||
<span>出镜视频</span>
|
||||
{summary.videoName ? (
|
||||
<span className="aa-config-summary__value">{summary.videoName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>音色</span>
|
||||
{summary.voiceName ? (
|
||||
<span className="aa-config-summary__value">{summary.voiceName}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>文案</span>
|
||||
{summary.scriptLength > 0 ? (
|
||||
<span className="aa-config-summary__value">{summary.scriptLength} 字</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>对口型</span>
|
||||
{lipsync ? (
|
||||
<span className={`aa-status-badge ${lipsync.cls}`}>{lipsync.text}</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>B-roll 画面插入</span>
|
||||
<span className="aa-config-summary__value">
|
||||
{summary.brollCount > 0 ? `${summary.brollCount} 段` : "无"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>标题</span>
|
||||
{summary.hasTitle ? (
|
||||
<span className="aa-config-summary__value">已设置</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-config-summary__row">
|
||||
<span>封面</span>
|
||||
{summary.hasCover ? (
|
||||
<span className="aa-config-summary__value">已开启</span>
|
||||
) : (
|
||||
<span className="aa-config-summary__empty">未配置</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--generate aa-btn--full"
|
||||
disabled={!canGenerate}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? "⏳ 生成中..." : "🚀 开始生成视频"}
|
||||
</button>
|
||||
{summary.lipsyncStatus !== "completed" && !isGenerating && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: "#8c8ca1", textAlign: "center" }}>
|
||||
请先完成对口型生成
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelCoverAndGenerate
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* AI数字人 — 文案 & 对口型面板(面板4)
|
||||
* 上半区:文案(文案库选择 / 手动输入);下半区:对口型视频预览(9:16)+ B-roll 画面
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import type { LipsyncJob, BRollSegment } from "../types"
|
||||
|
||||
interface PanelScriptAndLipsyncProps {
|
||||
scriptText: string
|
||||
onScriptTextChange: (text: string) => void
|
||||
onOpenScriptModal: () => void
|
||||
lipsyncJob: LipsyncJob | null
|
||||
onGenerateLipsync: () => void
|
||||
bRollSegments: BRollSegment[]
|
||||
onOpenBRollModal: () => void
|
||||
onRemoveBRoll: (id: string) => void
|
||||
}
|
||||
|
||||
type ScriptTab = "library" | "manual"
|
||||
|
||||
const BROLL_MODE_LABEL: Record<BRollSegment["mode"], string> = {
|
||||
fullscreen: "全屏",
|
||||
pip: "画中画",
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelScriptAndLipsync({
|
||||
scriptText,
|
||||
onScriptTextChange,
|
||||
onOpenScriptModal,
|
||||
lipsyncJob,
|
||||
onGenerateLipsync,
|
||||
bRollSegments,
|
||||
onOpenBRollModal,
|
||||
onRemoveBRoll,
|
||||
}: PanelScriptAndLipsyncProps) {
|
||||
const [scriptTab, setScriptTab] = useState<ScriptTab>("library")
|
||||
|
||||
/* 对口型状态判断 */
|
||||
const isGenerating = lipsyncJob?.status === "pending" || lipsyncJob?.status === "processing"
|
||||
const isDone = lipsyncJob?.status === "completed"
|
||||
const isFailed = lipsyncJob?.status === "failed"
|
||||
|
||||
const statusText =
|
||||
lipsyncJob?.status === "processing"
|
||||
? "对口型生成中…"
|
||||
: lipsyncJob?.status === "pending"
|
||||
? "排队中…"
|
||||
: "对口型生成中…"
|
||||
|
||||
return (
|
||||
<div className="aa-script-lipsync">
|
||||
{/* ── 上半区:文案 ── */}
|
||||
<div className="aa-script-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-script-tab${scriptTab === "library" ? " active" : ""}`}
|
||||
onClick={() => setScriptTab("library")}
|
||||
>
|
||||
从文案库选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-script-tab${scriptTab === "manual" ? " active" : ""}`}
|
||||
onClick={() => setScriptTab("manual")}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scriptTab === "library" && (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--ghost aa-btn--full"
|
||||
style={{ marginBottom: 8 }}
|
||||
onClick={onOpenScriptModal}
|
||||
>
|
||||
📚 从文案库选择文案
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="aa-textarea"
|
||||
value={scriptText}
|
||||
readOnly={scriptTab === "library"}
|
||||
placeholder={
|
||||
scriptTab === "library" ? "点击上方按钮,从文案库选择文案…" : "请输入数字人口播文案…"
|
||||
}
|
||||
onChange={(e) => onScriptTextChange(e.target.value)}
|
||||
/>
|
||||
<div className="aa-char-count">{scriptText.length} 字</div>
|
||||
|
||||
{/* ── B-roll 画面 ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">
|
||||
<span style={{ marginRight: 8 }}>🎞️ 插入画面</span>
|
||||
{bRollSegments.length > 0 && (
|
||||
<span className="aa-broll-badge">🎬 {bRollSegments.length} 个画面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="aa-lipsync-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onOpenBRollModal}
|
||||
>
|
||||
🎬 插入画面
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bRollSegments.length > 0 && (
|
||||
<div className="aa-broll-list">
|
||||
{bRollSegments.map((seg) => (
|
||||
<div key={seg.id} className="aa-broll-item">
|
||||
{seg.asset.thumbnail_url || seg.asset.file_url ? (
|
||||
<img
|
||||
className="aa-broll-item__thumb"
|
||||
src={seg.asset.thumbnail_url || seg.asset.file_url}
|
||||
alt={seg.asset.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="aa-broll-item__thumb" style={{ padding: "6px 4px" }}>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
<div className="aa-broll-item__info">
|
||||
<div
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{seg.asset.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#8c8ca1", marginTop: 2 }}>
|
||||
{BROLL_MODE_LABEL[seg.mode]} · {formatTime(seg.start_time)}-
|
||||
{formatTime(seg.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-broll-item__remove"
|
||||
title="删除"
|
||||
onClick={() => onRemoveBRoll(seg.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 下半区:对口型预览(竖屏 9:16) ── */}
|
||||
<div className="aa-lipsync-section">
|
||||
<div className="aa-lipsync-section__title">对口型预览</div>
|
||||
|
||||
<div className="aa-lipsync-preview">
|
||||
{isDone && lipsyncJob?.output_video_url ? (
|
||||
<video src={lipsyncJob.output_video_url} controls />
|
||||
) : isGenerating ? (
|
||||
<div style={{ width: "80%", textAlign: "center", color: "#fff" }}>
|
||||
<div style={{ fontSize: 13, marginBottom: 8 }}>
|
||||
{statusText} {Math.round(lipsyncJob?.progress ?? 0)}%
|
||||
</div>
|
||||
<div className="aa-progress">
|
||||
<div
|
||||
className="aa-progress__bar"
|
||||
style={{ width: `${lipsyncJob?.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">
|
||||
{isFailed ? (
|
||||
<>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>❌</div>
|
||||
<div>对口型生成失败</div>
|
||||
{lipsyncJob?.error_message && (
|
||||
<div style={{ fontSize: 11, marginTop: 4, color: "#fca5a5" }}>
|
||||
{lipsyncJob.error_message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"生成对口型视频后在此预览"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aa-lipsync-actions">
|
||||
{isDone ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" onClick={onGenerateLipsync}>
|
||||
🔄 重新生成对口型
|
||||
</button>
|
||||
) : isGenerating ? (
|
||||
<button type="button" className="aa-btn aa-btn--full" disabled>
|
||||
对口型生成中…
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--primary aa-btn--full"
|
||||
onClick={onGenerateLipsync}
|
||||
>
|
||||
🎬 生成对口型视频
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelScriptAndLipsync
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* AI数字人 — 面板4:标题配置
|
||||
*
|
||||
* 关键:直接复用智能剪辑(generate)模块的 TitleStylePanel 标题样式面板,
|
||||
* 不重新开发标题预设/字体/位置等样式能力。本组件只负责:
|
||||
* - 主标题文字输入
|
||||
* - AiAvatarTitleConfig ↔ TitleSettings 的双向适配
|
||||
* - 自动生成字幕开关
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import TitleStylePanel from "@/pages/generate/components/title/TitleStylePanel"
|
||||
import type { TitleSettings } from "@/pages/generate/types"
|
||||
import {
|
||||
POSITION_OPTIONS,
|
||||
FONT_OPTIONS,
|
||||
TITLE_PRESETS,
|
||||
getFontFamily,
|
||||
} from "@/pages/generate/constants"
|
||||
import type { AiAvatarTitleConfig } from "../types"
|
||||
|
||||
interface PanelTitleConfigProps {
|
||||
titleConfig: AiAvatarTitleConfig
|
||||
onUpdate: (partial: Partial<AiAvatarTitleConfig>) => void
|
||||
}
|
||||
|
||||
const PanelTitleConfig: React.FC<PanelTitleConfigProps> = ({ titleConfig, onUpdate }) => {
|
||||
/** TitleStylePanel 内部高亮的预设 key(面板本地状态) */
|
||||
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||
|
||||
/** AiAvatarTitleConfig → TitleSettings(补齐 aiAutoSelect / 自由坐标字段) */
|
||||
const titleSettings: TitleSettings = useMemo(
|
||||
() => ({
|
||||
aiAutoSelect: false,
|
||||
title: titleConfig.title,
|
||||
position: titleConfig.position,
|
||||
font: titleConfig.font,
|
||||
size: titleConfig.size,
|
||||
bold: titleConfig.bold,
|
||||
italic: titleConfig.italic,
|
||||
stroke: titleConfig.stroke,
|
||||
shadow: titleConfig.shadow,
|
||||
color: titleConfig.color,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}),
|
||||
[titleConfig],
|
||||
)
|
||||
|
||||
/** 应用预设:与智能剪辑一致,只覆盖 color/bold/italic/stroke/shadow,不改变字号 */
|
||||
const handleApplyPreset = (presetKey: string) => {
|
||||
const preset = TITLE_PRESETS.find((p) => p.key === presetKey)
|
||||
if (!preset) return
|
||||
setActivePreset(presetKey)
|
||||
onUpdate({
|
||||
color: preset.style.color,
|
||||
bold: preset.style.bold,
|
||||
italic: preset.style.italic,
|
||||
stroke: preset.style.stroke,
|
||||
shadow: preset.style.shadow,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-title-config">
|
||||
{/* 主标题输入 */}
|
||||
<div className="aa-form-field">
|
||||
<label className="aa-label">主标题</label>
|
||||
<input
|
||||
className="aa-input aa-title-input"
|
||||
type="text"
|
||||
placeholder="输入视频标题(留空则不显示标题)"
|
||||
value={titleConfig.title}
|
||||
maxLength={30}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
/>
|
||||
{titleConfig.title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
padding: "6px 8px",
|
||||
background: "#f8f8fc",
|
||||
borderRadius: 6,
|
||||
fontFamily: getFontFamily(titleConfig.font),
|
||||
fontWeight: titleConfig.bold ? 700 : 400,
|
||||
fontStyle: titleConfig.italic ? "italic" : "normal",
|
||||
color: titleConfig.color,
|
||||
textShadow: titleConfig.shadow ? "1px 1px 3px rgba(0,0,0,0.6)" : undefined,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{titleConfig.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题样式:直接复用智能剪辑 TitleStylePanel(位置/字体/字号/样式/预设) */}
|
||||
<TitleStylePanel
|
||||
settings={titleSettings}
|
||||
onUpdatePosition={(position) => onUpdate({ position })}
|
||||
onUpdateFont={(font) => onUpdate({ font })}
|
||||
onUpdateSize={(size) => onUpdate({ size: Math.min(128, Math.max(16, size)) })}
|
||||
onToggleBold={() => onUpdate({ bold: !titleConfig.bold })}
|
||||
onToggleItalic={() => onUpdate({ italic: !titleConfig.italic })}
|
||||
onToggleStroke={() => onUpdate({ stroke: !titleConfig.stroke })}
|
||||
onToggleShadow={() => onUpdate({ shadow: !titleConfig.shadow })}
|
||||
onApplyPreset={handleApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={TITLE_PRESETS}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
|
||||
{/* 自动生成字幕 */}
|
||||
<div className="aa-subtitle-toggle">
|
||||
<label className="aa-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={titleConfig.auto_subtitle}
|
||||
onChange={(e) => onUpdate({ auto_subtitle: e.target.checked })}
|
||||
/>
|
||||
自动生成字幕
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelTitleConfig
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* AI数字人 — 出镜视频选择面板
|
||||
* - 未选视频:虚线上传区,点击打开素材库弹窗
|
||||
* - 已选视频:竖屏 9:16 预览播放器 + 视频信息卡片 + 移除按钮
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export interface PanelVideoSelectorProps {
|
||||
selectedVideo: AssetItem | null
|
||||
/** 触发打开素材库弹窗 */
|
||||
onSelectVideo: () => void
|
||||
onRemoveVideo: () => void
|
||||
}
|
||||
|
||||
/** 格式化时长(秒 → mm:ss) */
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
|
||||
return "00:00"
|
||||
}
|
||||
return `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function PanelVideoSelector({
|
||||
selectedVideo,
|
||||
onSelectVideo,
|
||||
onRemoveVideo,
|
||||
}: PanelVideoSelectorProps) {
|
||||
/* 未选视频:虚线上传区,点击打开素材库弹窗 */
|
||||
if (!selectedVideo) {
|
||||
return (
|
||||
<div
|
||||
className="aa-upload-zone"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelectVideo}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onSelectVideo()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="aa-upload-zone__icon">🎬</div>
|
||||
<div className="aa-upload-zone__text">从素材库选择视频</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const width = selectedVideo.metadata?.width
|
||||
const height = selectedVideo.metadata?.height
|
||||
const duration = selectedVideo.duration ?? selectedVideo.metadata?.duration
|
||||
const fileUrl = selectedVideo.file_url ?? ""
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 竖屏 9:16 视频预览播放器 */}
|
||||
<div className="aa-video-preview">
|
||||
{fileUrl ? (
|
||||
<video src={fileUrl} poster={selectedVideo.thumbnail_url} controls playsInline />
|
||||
) : (
|
||||
<div className="aa-video-preview__placeholder">视频暂不可预览</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频信息卡片:文件名 / 时长 / 分辨率 */}
|
||||
<div className="aa-video-info">
|
||||
<div className="aa-video-info__row">
|
||||
<span>文件名</span>
|
||||
<span title={selectedVideo.name}>{selectedVideo.name}</span>
|
||||
</div>
|
||||
<div className="aa-video-info__row">
|
||||
<span>时长</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
<div className="aa-video-info__row">
|
||||
<span>分辨率</span>
|
||||
<span>{width && height ? `${width}×${height}` : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="aa-btn aa-btn--danger aa-btn--full"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={onRemoveVideo}
|
||||
>
|
||||
移除视频
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* AI数字人 — 配音库面板(面板3)
|
||||
* 音色来源切换(系统预设 / 我的音色)、音色选择与试听、情绪/语速/语言参数
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { fetchVoices } from "@/api/voices/voices"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
VOICE_EMOTION_OPTIONS,
|
||||
VOICE_LANGUAGE_OPTIONS,
|
||||
} from "../types"
|
||||
|
||||
interface PanelVoiceSelectorProps {
|
||||
voiceSource: VoiceSource
|
||||
onVoiceSourceChange: (source: VoiceSource) => void
|
||||
selectedVoice: UnifiedVoiceItem | null
|
||||
onSelectVoice: (voice: UnifiedVoiceItem) => void
|
||||
emotion: VoiceEmotion
|
||||
onEmotionChange: (e: VoiceEmotion) => void
|
||||
speed: number
|
||||
onSpeedChange: (s: number) => void
|
||||
language: VoiceLanguage
|
||||
onLanguageChange: (l: VoiceLanguage) => void
|
||||
}
|
||||
|
||||
export function PanelVoiceSelector({
|
||||
voiceSource,
|
||||
onVoiceSourceChange,
|
||||
selectedVoice,
|
||||
onSelectVoice,
|
||||
emotion,
|
||||
onEmotionChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
language,
|
||||
onLanguageChange,
|
||||
}: PanelVoiceSelectorProps) {
|
||||
const [voices, setVoices] = useState<UnifiedVoiceItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* 切换来源时重新获取音色列表 */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const loadVoices = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchVoices({ type: voiceSource })
|
||||
if (!cancelled) setVoices(Array.isArray(res?.items) ? res.items : [])
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "音色加载失败")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
loadVoices()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [voiceSource])
|
||||
|
||||
/* 卸载时停止试听 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPreview = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPreviewingId(null)
|
||||
}
|
||||
|
||||
const handlePreview = (voice: UnifiedVoiceItem) => {
|
||||
const url = voice.preview_url || voice.audio_url
|
||||
if (!url) return
|
||||
/* 再次点击当前试听音色 → 停止 */
|
||||
if (previewingId === voice.id) {
|
||||
stopPreview()
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
setPreviewingId(voice.id)
|
||||
audio.onended = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
}
|
||||
}
|
||||
audio.onerror = () => {
|
||||
if (audioRef.current === audio) {
|
||||
audioRef.current = null
|
||||
setPreviewingId(null)
|
||||
setError("试听音频加载失败")
|
||||
}
|
||||
}
|
||||
void audio.play().catch(() => {
|
||||
setPreviewingId(null)
|
||||
setError("试听播放失败")
|
||||
})
|
||||
}
|
||||
|
||||
const handleSpeedChange = (value: string) => {
|
||||
const parsed = parseFloat(value)
|
||||
if (Number.isNaN(parsed)) return
|
||||
const clamped = Math.min(2.0, Math.max(0.5, parsed))
|
||||
onSpeedChange(clamped)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="aa-voice-selector">
|
||||
{/* 音色来源切换 */}
|
||||
<div className="aa-voice-source-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "preset" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("preset")}
|
||||
>
|
||||
系统预设
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`aa-voice-source-btn${voiceSource === "clone" ? " active" : ""}`}
|
||||
onClick={() => onVoiceSourceChange("clone")}
|
||||
>
|
||||
我的音色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 音色列表 */}
|
||||
{loading ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⏳</div>
|
||||
<div>音色加载中…</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">⚠️</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="aa-empty">
|
||||
<div className="aa-empty__icon">🎙️</div>
|
||||
<div>{voiceSource === "clone" ? "还没有克隆音色" : "暂无预置音色"}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aa-voice-list">
|
||||
{voices.map((voice) => {
|
||||
const selected = selectedVoice?.id === voice.id
|
||||
const previewUrl = voice.preview_url || voice.audio_url
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`aa-voice-card${selected ? " selected" : ""}`}
|
||||
onClick={() => onSelectVoice(voice)}
|
||||
>
|
||||
<span className="aa-voice-card__radio" />
|
||||
<div className="aa-voice-card__info">
|
||||
<div className="aa-voice-card__name">{voice.name}</div>
|
||||
{voice.description && (
|
||||
<div className="aa-voice-card__desc">{voice.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="aa-voice-card__preview"
|
||||
title={previewingId === voice.id ? "停止试听" : "试听"}
|
||||
disabled={!previewUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePreview(voice)
|
||||
}}
|
||||
>
|
||||
{previewingId === voice.id ? "⏸" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 我的音色:克隆入口 */}
|
||||
{voiceSource === "clone" && (
|
||||
<div className="aa-clone-entry">
|
||||
<a href="/app/voice-clone">+ 克隆新音色</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配音参数 */}
|
||||
<div className="aa-voice-params">
|
||||
<div className="aa-voice-params__row">
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-emotion">
|
||||
情绪
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-emotion"
|
||||
className="aa-select"
|
||||
value={emotion}
|
||||
onChange={(e) => onEmotionChange(e.target.value as VoiceEmotion)}
|
||||
>
|
||||
{VOICE_EMOTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-language">
|
||||
语言
|
||||
</label>
|
||||
<select
|
||||
id="aa-voice-language"
|
||||
className="aa-select"
|
||||
value={language}
|
||||
onChange={(e) => onLanguageChange(e.target.value as VoiceLanguage)}
|
||||
>
|
||||
{VOICE_LANGUAGE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aa-voice-params__field">
|
||||
<label className="aa-label" htmlFor="aa-voice-speed">
|
||||
语速({speed.toFixed(1)}x)
|
||||
</label>
|
||||
<input
|
||||
id="aa-voice-speed"
|
||||
type="number"
|
||||
className="aa-input"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => handleSpeedChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PanelVoiceSelector
|
||||
@@ -1,189 +0,0 @@
|
||||
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
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* 文案选择弹窗
|
||||
* 搜索 + 文案列表 + 选择回调
|
||||
*/
|
||||
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
|
||||
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* 面板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
|
||||
@@ -1,256 +0,0 @@
|
||||
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,141 @@
|
||||
/**
|
||||
* AI数字人 — 页面全局状态管理 hook(v3)
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { UnifiedVoiceItem } from "@/api/voices/types"
|
||||
import {
|
||||
type VoiceSource,
|
||||
type VoiceEmotion,
|
||||
type VoiceLanguage,
|
||||
type Script,
|
||||
type LipsyncJob,
|
||||
type BRollSegment,
|
||||
type AiAvatarTitleConfig,
|
||||
type AiAvatarCoverConfig,
|
||||
DEFAULT_TITLE_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "../types"
|
||||
|
||||
export function useAiAvatar() {
|
||||
/* ── 面板1:出镜视频 ── */
|
||||
const [selectedVideo, setSelectedVideo] = useState<AssetItem | null>(null)
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||
|
||||
/* ── 面板2:配音库 ── */
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>("preset")
|
||||
const [selectedVoice, setSelectedVoice] = useState<UnifiedVoiceItem | null>(null)
|
||||
const [emotion, setEmotion] = useState<VoiceEmotion>("natural")
|
||||
const [speed, setSpeed] = useState(1.0)
|
||||
const [language, setLanguage] = useState<VoiceLanguage>("mandarin")
|
||||
|
||||
/* ── 面板3:文案 & 对口型 ── */
|
||||
const [script, setScript] = useState<Script | null>(null)
|
||||
const [scriptText, setScriptText] = useState("")
|
||||
const [lipsyncJob, setLipsyncJob] = useState<LipsyncJob | null>(null)
|
||||
const [showScriptModal, setShowScriptModal] = useState(false)
|
||||
const [showBRollModal, setShowBRollModal] = useState(false)
|
||||
|
||||
/* ── 面板3.5:B-roll ── */
|
||||
const [bRollSegments, setBRollSegments] = useState<BRollSegment[]>([])
|
||||
|
||||
/* ── 面板4:标题配置 ── */
|
||||
const [titleConfig, setTitleConfig] = useState<AiAvatarTitleConfig>(DEFAULT_TITLE_CONFIG)
|
||||
|
||||
/* ── 面板5:封面 & 生成 ── */
|
||||
const [coverConfig, setCoverConfig] = useState<AiAvatarCoverConfig>(DEFAULT_COVER_CONFIG)
|
||||
const [resolution, setResolution] = useState("1080p")
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
/* ── Actions ── */
|
||||
const selectVideo = useCallback((asset: AssetItem) => {
|
||||
setSelectedVideo(asset)
|
||||
setShowAssetPicker(false)
|
||||
}, [])
|
||||
|
||||
const removeVideo = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
}, [])
|
||||
|
||||
const selectScript = useCallback((s: Script) => {
|
||||
setScript(s)
|
||||
setScriptText(s.content)
|
||||
setShowScriptModal(false)
|
||||
}, [])
|
||||
|
||||
const addBRollSegment = useCallback((segment: BRollSegment) => {
|
||||
setBRollSegments((prev) => [...prev, segment])
|
||||
}, [])
|
||||
|
||||
const removeBRollSegment = useCallback((id: string) => {
|
||||
setBRollSegments((prev) => prev.filter((s) => s.id !== id))
|
||||
}, [])
|
||||
|
||||
const updateTitleConfig = useCallback((partial: Partial<AiAvatarTitleConfig>) => {
|
||||
setTitleConfig((prev) => ({ ...prev, ...partial }))
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectedVideo(null)
|
||||
setSelectedVoice(null)
|
||||
setScript(null)
|
||||
setScriptText("")
|
||||
setLipsyncJob(null)
|
||||
setBRollSegments([])
|
||||
setTitleConfig(DEFAULT_TITLE_CONFIG)
|
||||
setCoverConfig(DEFAULT_COVER_CONFIG)
|
||||
setResolution("1080p")
|
||||
setIsGenerating(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 面板1
|
||||
selectedVideo,
|
||||
showAssetPicker,
|
||||
setShowAssetPicker,
|
||||
selectVideo,
|
||||
removeVideo,
|
||||
// 面板2
|
||||
voiceSource,
|
||||
setVoiceSource,
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
emotion,
|
||||
setEmotion,
|
||||
speed,
|
||||
setSpeed,
|
||||
language,
|
||||
setLanguage,
|
||||
// 面板3
|
||||
script,
|
||||
setScript,
|
||||
scriptText,
|
||||
setScriptText,
|
||||
lipsyncJob,
|
||||
setLipsyncJob,
|
||||
showScriptModal,
|
||||
setShowScriptModal,
|
||||
showBRollModal,
|
||||
setShowBRollModal,
|
||||
selectScript,
|
||||
// B-roll
|
||||
bRollSegments,
|
||||
addBRollSegment,
|
||||
removeBRollSegment,
|
||||
// 面板4
|
||||
titleConfig,
|
||||
updateTitleConfig,
|
||||
setTitleConfig,
|
||||
// 面板5
|
||||
coverConfig,
|
||||
setCoverConfig,
|
||||
resolution,
|
||||
setResolution,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
// 全局
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseAiAvatarReturn = ReturnType<typeof useAiAvatar>
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 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,123 @@
|
||||
/**
|
||||
* AI数字人 — TypeScript 类型定义(v3)
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/* ── 音色来源切换 ── */
|
||||
export type VoiceSource = "preset" | "clone"
|
||||
|
||||
/* ── 情绪 ── */
|
||||
export type VoiceEmotion = "natural" | "excited" | "calm" | "friendly"
|
||||
|
||||
export const VOICE_EMOTION_OPTIONS: { value: VoiceEmotion; label: string }[] = [
|
||||
{ value: "natural", label: "自然" },
|
||||
{ value: "excited", label: "兴奋" },
|
||||
{ value: "calm", label: "沉稳" },
|
||||
{ value: "friendly", label: "亲切" },
|
||||
]
|
||||
|
||||
/* ── 语言 ── */
|
||||
export type VoiceLanguage = "mandarin" | "english" | "cantonese"
|
||||
|
||||
export const VOICE_LANGUAGE_OPTIONS: { value: VoiceLanguage; label: string }[] = [
|
||||
{ value: "mandarin", label: "普通话" },
|
||||
{ value: "english", label: "English" },
|
||||
{ value: "cantonese", label: "粤语" },
|
||||
]
|
||||
|
||||
/* ── 对口型任务状态 ── */
|
||||
export type LipsyncStatus = "idle" | "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/* ── 文案 ── */
|
||||
export interface Script {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
char_count: number
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ── 对口型任务 ── */
|
||||
export interface LipsyncJob {
|
||||
id: string
|
||||
status: LipsyncStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
/** 对口型成片总时长(秒),后端返回;用于 B-roll 时间自动估算(#1809 ⑥) */
|
||||
output_duration?: number
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── B-roll 画面插入 ── */
|
||||
export type BRollInsertMode = "fullscreen" | "pip"
|
||||
export type PipPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
|
||||
export interface BRollSegment {
|
||||
id: string
|
||||
asset: AssetItem
|
||||
script_segment_index: number
|
||||
start_time: number
|
||||
end_time: number
|
||||
mode: BRollInsertMode
|
||||
pip_position: PipPosition
|
||||
pip_scale: number
|
||||
}
|
||||
|
||||
/* ── 标题配置 ── */
|
||||
export interface AiAvatarTitleConfig {
|
||||
title: string
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
auto_subtitle: boolean
|
||||
}
|
||||
|
||||
/* ── 封面配置 ── */
|
||||
export interface AiAvatarCoverConfig {
|
||||
enabled: boolean
|
||||
mode: "auto_frame" | "upload"
|
||||
frame_time: number
|
||||
upload_url: string | null
|
||||
thumbnail_url: string | null
|
||||
}
|
||||
|
||||
/* ── 渲染任务 ── */
|
||||
export type RenderStatus = "pending" | "processing" | "completed" | "failed" | "cancelled"
|
||||
|
||||
export interface RenderJob {
|
||||
id: string
|
||||
status: RenderStatus
|
||||
progress: number
|
||||
output_video_url: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/* ── 默认值 ── */
|
||||
export const DEFAULT_TITLE_CONFIG: AiAvatarTitleConfig = {
|
||||
title: "",
|
||||
position: "top",
|
||||
font: "思源黑体",
|
||||
size: 28,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
auto_subtitle: true,
|
||||
}
|
||||
|
||||
export const DEFAULT_COVER_CONFIG: AiAvatarCoverConfig = {
|
||||
enabled: true,
|
||||
mode: "auto_frame",
|
||||
frame_time: 0,
|
||||
upload_url: null,
|
||||
thumbnail_url: null,
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* AI数字人 — 文案分句 & B-roll 时间自动估算(#1809 ⑤⑥)
|
||||
*/
|
||||
|
||||
export interface ScriptSentence {
|
||||
/** 句子序号(从 0 开始,对应提交给后端的 script_segment_index) */
|
||||
index: number
|
||||
/** 句子文本(去掉首尾空白) */
|
||||
text: string
|
||||
/** 句子字数(按中文/字符计,去除空白) */
|
||||
charCount: number
|
||||
/** 累计起始字数(用于时间估算) */
|
||||
startChar: number
|
||||
/** 估算的对口型视频内起始时间(秒) */
|
||||
startTime: number
|
||||
/** 估算的对口型视频内结束时间(秒) */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句号/问号/感叹号/分号/换行分句(兼容中英文标点)。
|
||||
* 空文案返回空数组。时间按「该句字数 ÷ 全文总字数 × 口播总时长」线性估算。
|
||||
*/
|
||||
export function splitScriptIntoSentences(
|
||||
scriptText: string,
|
||||
outputDuration: number,
|
||||
): ScriptSentence[] {
|
||||
const text = (scriptText || "").trim()
|
||||
if (!text) return []
|
||||
|
||||
const rawParts = text
|
||||
.split(/[。!?!?;;\n\r]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
const totalChars = rawParts.reduce((sum, part) => sum + part.replace(/\s/g, "").length, 0)
|
||||
const duration = outputDuration > 0 ? outputDuration : 0
|
||||
|
||||
const sentences: ScriptSentence[] = []
|
||||
let accChar = 0
|
||||
rawParts.forEach((part, i) => {
|
||||
const charCount = part.replace(/\s/g, "").length
|
||||
const startTime = duration > 0 && totalChars > 0 ? (accChar / totalChars) * duration : 0
|
||||
const endTime =
|
||||
duration > 0 && totalChars > 0 ? ((accChar + charCount) / totalChars) * duration : 0
|
||||
sentences.push({
|
||||
index: i,
|
||||
text: part,
|
||||
charCount,
|
||||
startChar: accChar,
|
||||
startTime: round1(startTime),
|
||||
endTime: round1(endTime),
|
||||
})
|
||||
accChar += charCount
|
||||
})
|
||||
|
||||
return sentences
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
============================================================ */
|
||||
.dup-page {
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
max-width: 1100px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/* ============================================================
|
||||
TitleStylePanel 标题样式面板 — 独立共用样式(#1809 ⑦)
|
||||
|
||||
从 generate.css 抽取的标题样式区块,供「智能剪辑」与「AI数字人」
|
||||
两个页面共用。AI数字人页面不引入 generate.css,直接由
|
||||
TitleStylePanel.tsx import 本文件,保证 24 个 T 预设格子的网格布局、
|
||||
配色描边、选中态与智能剪辑页面完全一致。
|
||||
|
||||
注意:本文件规则与 generate.css 中同名规则一一对应、取值相同;
|
||||
智能剪辑页面两处同时存在时同优先级同值,不改变其原有呈现。
|
||||
============================================================ */
|
||||
|
||||
/* ── 区块容器 ── */
|
||||
.xx-title-style-section {
|
||||
margin-top: 22px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.xx-section-subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.xx-title-style-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-half-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-field-label-row label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-field-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ── 共用表单字段(位置/字体下拉) ── */
|
||||
.xx-title-style-section .xx-form-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field select,
|
||||
.xx-title-style-section .xx-form-field input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
outline: 0;
|
||||
transition: 0.15s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-title-style-section .xx-form-field select:focus,
|
||||
.xx-title-style-section .xx-form-field input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* ── 字号滑块 ── */
|
||||
.xx-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.xx-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
/* ── 标题预设卡片网格(24 个 T 格子) ── */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 52px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
background: #404040;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: #666;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: #409eff;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── 样式按钮组(加粗/斜体/描边/阴影) ── */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-style-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-style-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-style-btn.active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
import React from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import TitlePresetsGrid from "./TitlePresetsGrid"
|
||||
// 标题样式面板共用样式(#1809 ⑦):智能剪辑与 AI数字人复用同一组件,
|
||||
// 由组件自带样式,避免 AI数字人页面重复引入整个 generate.css
|
||||
import "./TitleStylePanel.css"
|
||||
|
||||
interface PositionOption {
|
||||
value: string
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.xx-generate-page {
|
||||
min-height: 100%;
|
||||
padding: var(--space-xl);
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.mt-page {
|
||||
padding: var(--space-xl);
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
.task-center {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1400px;
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ services:
|
||||
# 健康检查配置
|
||||
# 注:celery inspect ping 依赖 broker 连接,在容器内不可靠,改用进程检查
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'celery.*worker' | head -n1 >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1"]
|
||||
test: ["CMD-SHELL", "for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -155,7 +155,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -116,7 +116,7 @@ docker run -d \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "sh -c \"pgrep -f 'celery.*worker' >/dev/null 2>&1 || grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -209,7 +209,7 @@ rollback() {
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
@@ -408,7 +408,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -310,7 +310,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -192,7 +192,7 @@ rollback() {
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
@@ -501,7 +501,7 @@ docker run -d \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"\$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -305,7 +305,7 @@ docker run -d \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
@@ -201,7 +201,7 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
|
||||
Reference in New Issue
Block a user