提交 3c16b961 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 添加数字人视频生成功能和相关组件

- 新增 AIDigitalHumanModal 组件用于生成数字人视频 - 引入数字人和配音数据 - 实现视频生成和插入功能 - 更新 API 代理路径为 /api/volcano_file - 优化相关样式和逻辑
上级 e793befe
import axios from 'axios'
import { message } from 'antd'
const appId = 'i-ri1jxut3edfmz'
const appKey = 'z3d69f164i698e3nph68'
/**
* 使用 Web Crypto API 实现 HMAC-SHA256
* 由于 HmacSHA256 通常是同步的,但 Web Crypto 是异步的,
* 我们需要调整拦截器以支持异步签名生成。
*/
const hmacSHA256 = async (data, key) => {
const encoder = new TextEncoder()
const keyData = encoder.encode(key)
const msgData = encoder.encode(data)
const cryptoKey = await window.crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await window.crypto.subtle.sign('HMAC', cryptoKey, msgData)
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
const buildSign = async () => {
// 生成当前时间 + 1小时后的时间戳
const time = new Date(new Date().getTime() + 60 * 60 * 3000).toISOString()
// 使用 Web Crypto API 生成签名
const signature = await hmacSHA256(appId + time, appKey)
// 返回Authorization头格式: appId/signature/time
return `${appId}/${signature}/${time}`
}
const request = axios.create({ baseURL: '/api/xiling' })
request.interceptors.request.use(async (config) => {
config.headers.Authorization = await buildSign()
return config
})
request.interceptors.response.use((response) => {
if (response.data.code == 0) {
return response.data
} else {
const errorMsg = response.data.message?.global || '请求失败'
message.error(errorMsg)
return Promise.reject(response)
}
}, (error) => {
message.error('网络请求错误')
return Promise.reject(error)
})
/**
* 文件上传
* https://cloud.baidu.com/doc/AI_DH/s/5m11r7clu
*/
export const upload = async (data, options = {}) => {
const response = await request.post('/api/digitalhuman/open/v1/file/upload', data, {
headers: { 'Content-Type': 'multipart/form-data' },
...options,
})
return response.result
}
// 获取数字人列表
export const getDigitalHumanList = async (params, options) => {
const { systemFigure, item = 'VIDEO-DH_2D', pageSize = 1000 } = params
const response = await request.get('/api/digitalhuman/open/v1/figure/query', {
params: { systemFigure, item, pageSize },
...options,
})
return response.result?.result || []
}
/**
* 提交生成式数字人形象定制任务
*/
export const submitGenerativeFigure = async (params, options = {}) => {
return request.post('/api/digitalhuman/open/v1/figure/generative/train', params, options)
}
/**
* 查询生成式数字人形象定制状态
*/
export const queryGenerativeFigure = async (params, options = {}) => {
return request.get('/api/digitalhuman/open/v1/figure/lite2d/query', { params, ...options })
}
/**
* 提交123数字人视频任务
*/
export const submitVideoFast = async (params, options = {}) => {
return request.post('/api/digitalhuman/open/v1/video/submit/fast', params, options)
}
/**
* 提交基础视频合成任务
*/
export const submitVideo = async (params, options = {}) => {
return request.post('/api/digitalhuman/open/v1/video/submit', params, options)
}
/**
* 查询视频任务状态
*/
export const getVideoTask = async (params, options = {}) => {
return request.get('/api/digitalhuman/open/v1/video/task', { params, ...options })
}
/**
* 提交高级视频合成任务
*/
export const submitVideoAdvanced = async (params, options = {}) => {
return request.post('/api/digitalhuman/open/v1/video/advanced/submit', params, options)
}
/**
* 查询高级视频任务状态
*/
export const getVideoAdvancedTask = async (params, options = {}) => {
return request.get('/api/digitalhuman/open/v1/video/advanced/task', { params, ...options })
}
......@@ -88,8 +88,6 @@
overflow: hidden;
text-overflow: ellipsis;
}
.in {
}
}
.no {
padding-left: 20px;
......@@ -191,6 +189,17 @@
}
}
// 插入图标(img + 文本节点)需要整体按行居中
p:has(> img[alt='icon-inline']) {
display: flex;
align-items: center;
gap: 6px;
}
p:has(> img[alt='icon-inline']) > img[alt='icon-inline'] {
flex: 0 0 auto;
}
// 预览题库
.chapter-practice {
margin: 0 10px;
......@@ -374,8 +383,6 @@
&.one {
width: 100%;
}
p {
}
img {
height: auto;
width: 100%;
......
......@@ -70,6 +70,29 @@
}
}
.w-e-text-container [data-slate-editor] img[alt='icon-inline'] {
vertical-align: middle;
display: inline-block;
}
// 插入图标后(图片节点 + 文本节点)强制整行居中
.w-e-text-container [data-slate-editor] p:has(.w-e-image-container img[alt='icon-inline']) {
display: flex;
align-items: center;
gap: 6px;
}
.w-e-text-container [data-slate-editor] p:has(.w-e-image-container img[alt='icon-inline'])
.w-e-image-container {
flex: 0 0 auto;
}
.w-e-text-container [data-slate-editor] p:has(.w-e-image-container img[alt='icon-inline'])
.w-e-image-container
+ span {
padding-top: 0;
}
p {
span[data-slate-zero-width] {
display: inline-block;
......
// Extend menu
class AIDigitalHuman {
import BaseModalMenu from './common/BaseModalMenu'
import AIDigitalHumanModal from './common/AIDigitalHumanModal'
class AIDigitalHuman extends BaseModalMenu {
constructor() {
super()
this.title = '数字人'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 28 28"><path fill="currentColor" d="M12 5.5a2 2 0 0 0 1.491 1.935c.337.053.68.053 1.018 0A2 2 0 1 0 12 5.5m-1.337 1.058a3.5 3.5 0 1 1 6.675 0l4.419-1.436a2.477 2.477 0 1 1 1.53 4.712L18 11.552v3.822c0 .16.03.32.091.468l2.728 6.752a2.477 2.477 0 0 1-4.594 1.856l-2.243-5.553l-2.232 5.56a2.46 2.46 0 0 1-3.21 1.362a2.477 2.477 0 0 1-1.364-3.215l2.734-6.812q.09-.224.09-.466v-3.774L4.712 9.834a2.477 2.477 0 0 1 1.531-4.712zm2.518 2.346a5 5 0 0 1-.649-.162L5.78 6.548a.977.977 0 0 0-.604 1.859l5.46 1.774c.515.168.864.648.864 1.189v3.957c0 .35-.067.698-.198 1.024l-2.734 6.811a.977.977 0 0 0 .538 1.267a.96.96 0 0 0 1.252-.531l2.463-6.136c.42-1.045 1.897-1.047 2.319-.003l2.476 6.129a.977.977 0 1 0 1.812-.732L16.7 16.404a2.8 2.8 0 0 1-.2-1.03V11.37c0-.541.349-1.021.864-1.189l5.46-1.774a.977.977 0 1 0-.604-1.859l-6.752 2.194q-.32.104-.649.162a3.5 3.5 0 0 1-1.639 0"/></svg>`
this.tag = 'button'
}
getValue() {
return 'hello, 音频'
this.iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 28 28"><path fill="currentColor" d="M12 5.5a2 2 0 0 0 1.491 1.935c.337.053.68.053 1.018 0A2 2 0 1 0 12 5.5m-1.337 1.058a3.5 3.5 0 1 1 6.675 0l4.419-1.436a2.477 2.477 0 1 1 1.53 4.712L18 11.552v3.822c0 .16.03.32.091.468l2.728 6.752a2.477 2.477 0 0 1-4.594 1.856l-2.243-5.553l-2.232 5.56a2.46 2.46 0 0 1-3.21 1.362a2.477 2.477 0 0 1-1.364-3.215l2.734-6.812q.09-.224.09-.466v-3.774L4.712 9.834a2.477 2.477 0 0 1 1.531-4.712zm2.518 2.346a5 5 0 0 1-.649-.162L5.78 6.548a.977.977 0 0 0-.604 1.859l5.46 1.774c.515.168.864.648.864 1.189v3.957c0 .35-.067.698-.198 1.024l-2.734 6.811a.977.977 0 0 0 .538 1.267a.96.96 0 0 0 1.252-.531l2.463-6.136c.42-1.045 1.897-1.047 2.319-.003l2.476 6.129a.977.977 0 1 0 1.812-.732L16.7 16.404a2.8 2.8 0 0 1-.2-1.03V11.37c0-.541.349-1.021.864-1.189l5.46-1.774a.977.977 0 1 0-.604-1.859l-6.752 2.194q-.32.104-.649.162a3.5 3.5 0 0 1-1.639 0"/></svg>`
}
isActive() {
return false
}
isDisabled() {
return true
}
exec() {
return
getValue(editor) {
return <AIDigitalHumanModal key={Date.now()} editor={editor}></AIDigitalHumanModal>
}
}
......@@ -23,5 +17,5 @@ export default {
key: 'AIDigitalHuman',
factory() {
return new AIDigitalHuman()
}
},
}
import { useState, useRef, useEffect } from 'react'
import { Modal, Input, Button, message, Spin, Row, Col, Space, Divider, Checkbox, Tabs, Empty } from 'antd'
import {
VideoCameraOutlined,
CheckOutlined,
DownloadOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
UserOutlined,
} from '@ant-design/icons'
import { SlateTransforms } from '@wangeditor/editor'
import { saveAs } from 'file-saver'
import { uploadFileByUrl } from '@/utils/oss'
import useDigitalHuman from '@/hooks/useDigitalHuman'
import './AIDigitalHumanModal.less'
const { TextArea } = Input
export default function AIDigitalHumanModal(props) {
const { editor } = props
const [isModalOpen, setIsModalOpen] = useState(true)
// 核心选择状态
const [selectedFigure, setSelectedFigure] = useState(null)
const [selectedVoice, setSelectedVoice] = useState(null)
// 已移除字幕、透明背景和背景图片 URL 相关配置
const [textValue, setTextValue] = useState('')
const [playingVoiceId, setPlayingVoiceId] = useState(null)
const { figures, femaleVoices, maleVoices, loadingFigures, isGenerating, generatedVideo, generateVideo } =
useDigitalHuman()
// 初始化默认选择
useEffect(() => {
if (!selectedFigure && figures.length > 0) {
setSelectedFigure(figures[0])
}
if (!selectedVoice && femaleVoices.length > 0) {
setSelectedVoice(femaleVoices[0])
}
}, [figures, femaleVoices, selectedFigure, selectedVoice])
const handleVoicePlay = (voice) => {
if (playingVoiceId === voice.id) {
audioRef.current.pause()
setPlayingVoiceId(null)
} else {
audioRef.current.src = voice.previewUrl
audioRef.current.play()
setPlayingVoiceId(voice.id)
audioRef.current.onended = () => setPlayingVoiceId(null)
}
}
const handleGenerate = async () => {
if (!selectedFigure) return message.warning('请选择数字人形象')
if (!textValue.trim()) return message.warning('请输入播报文本')
if (!selectedVoice) return message.warning('请选择音色')
try {
await generateVideo({
figureId: selectedFigure.id,
figureName: selectedFigure.name,
driveType: 'TEXT',
text: textValue.trim(),
voiceId: selectedVoice?.id,
width: 720,
height: 1280,
})
} catch (error) {}
}
const handleInsert = async () => {
if (!generatedVideo) return
try {
message.loading({ content: '处理中...', key: 'inserting' })
const ossUrl = await uploadFileByUrl(generatedVideo.url)
if (editor) {
editor.restoreSelection()
const nodes = [
{ type: 'video', src: ossUrl, children: [{ text: '' }] },
{
type: 'paragraph',
textAlign: 'center',
fontSize: '14px',
children: [{ text: `${generatedVideo.figureName} - AI播报` }],
},
]
SlateTransforms.insertNodes(editor, nodes)
message.success({ content: '已插入文章', key: 'inserting' })
setIsModalOpen(false)
}
} catch (error) {
message.error({ content: '插入失败', key: 'inserting' })
}
}
return (
<Modal
title="数字人 AI 视频生成"
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
width={1000}
centered
destroyOnClose>
<div className="ai-digital-human">
<Row gutter={[24, 16]}>
<Col span={24}>
{/* 形象选择 */}
<div className="section-box">
<div className="section-title">1. 选择数字人形象</div>
{loadingFigures && figures.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="同步云端形象中..." />
</div>
) : figures.length > 0 ? (
<div className="figure-list">
{figures.map((fig) => (
<div
key={fig.id}
className={`figure-item ${selectedFigure?.id === fig.id ? 'active' : ''}`}
onClick={() => setSelectedFigure(fig)}>
<div className="avatar-wrapper">
{fig.avatar ? (
<img
src={fig.avatar}
className="figure-avatar"
onError={(e) => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
) : null}
<div className="avatar-fallback" style={{ display: fig.avatar ? 'none' : 'flex' }}>
<UserOutlined />
</div>
</div>
<div className="figure-name">{fig.name}</div>
</div>
))}
</div>
) : (
<Empty description="暂无可用形象" />
)}
</div>
{/* 驱动配置 */}
<div className="section-box">
<div className="section-title">2. 配置播报内容与音色</div>
<div className="drive-content">
<TextArea
placeholder="请输入要播报的文本内容..."
rows={4}
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
style={{ marginBottom: 12 }}
/>
<div className="voice-selection">
<div className="sub-title">精选配音:</div>
<Tabs
size="small"
items={[
{
key: 'female',
label: '女声',
children: (
<div className="voice-grid">
{femaleVoices.map((v) => (
<div
key={v.id}
className={`voice-item ${selectedVoice?.id === v.id ? 'active' : ''}`}
onClick={() => setSelectedVoice(v)}>
<div className="v-info">
<span className="v-name">{v.name}</span>
<span className="v-style">{v.style}</span>
</div>
<Button
type="text"
shape="circle"
icon={playingVoiceId === v.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={(e) => {
e.stopPropagation()
handleVoicePlay(v)
}}
/>
</div>
))}
</div>
),
},
{
key: 'male',
label: '男声',
children: (
<div className="voice-grid">
{maleVoices.map((v) => (
<div
key={v.id}
className={`voice-item ${selectedVoice?.id === v.id ? 'active' : ''}`}
onClick={() => setSelectedVoice(v)}>
<div className="v-info">
<span className="v-name">{v.name}</span>
<span className="v-style">{v.style}</span>
</div>
<Button
type="text"
shape="circle"
icon={playingVoiceId === v.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={(e) => {
e.stopPropagation()
handleVoicePlay(v)
}}
/>
</div>
))}
</div>
),
},
]}
/>
</div>
</div>
{/* 已彻底移除背景图片 URL 相关输入 */}
</div>
</Col>
<Col span={24}>
<div className="preview-panel">
<div className="section-title">生成状态</div>
<div className="results-container">
{isGenerating ? (
<div className="generating-box">
<Spin size="large" />
<div className="gen-text">视频正在录制中...</div>
<div className="gen-hint">预计 1 分钟左右完成</div>
</div>
) : generatedVideo ? (
<div className="final-video-box">
<video src={generatedVideo.url} controls autoPlay />
<div className="video-actions">
<Button
block
size="large"
icon={<DownloadOutlined />}
onClick={() => saveAs(generatedVideo.url, 'ai_video.mp4')}>
下载视频
</Button>
<Button block type="primary" size="large" icon={<CheckOutlined />} onClick={handleInsert}>
插入编辑器
</Button>
</div>
</div>
) : (
<div className="empty-preview">
<VideoCameraOutlined style={{ fontSize: 64, opacity: 0.1 }} />
<p>等待配置完成后点击开始</p>
</div>
)}
</div>
<Divider style={{ margin: '12px 0' }} />
<Button
type="primary"
size="large"
block
icon={<VideoCameraOutlined />}
onClick={handleGenerate}
loading={isGenerating}
className="submit-btn">
开始生成 AI 视频
</Button>
</div>
</Col>
</Row>
</div>
</Modal>
)
}
.ai-digital-human {
.section-box {
margin-bottom: 14px;
padding: 12px;
background: #fff;
border-radius: 8px;
border: 1px solid #f0f0f0;
.section-title {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
border-left: 4px solid #ab1941;
padding-left: 10px;
}
}
.figure-list {
display: flex;
overflow-x: auto;
gap: 12px;
padding: 6px 0;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #e8e8e8;
border-radius: 3px;
}
.figure-item {
flex: 0 0 92px;
cursor: pointer;
text-align: center;
padding: 8px;
border: 2px solid transparent;
border-radius: 12px;
transition: all 0.3s;
&.active {
border-color: #ab1941;
background: rgba(171, 25, 65, 0.08);
}
.avatar-wrapper {
width: 72px;
height: 72px;
margin: 0 auto 6px;
position: relative;
.figure-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #f0f0f0;
}
.avatar-fallback {
width: 72px;
height: 72px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #bfbfbf;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.figure-name {
font-size: 13px;
color: #555;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.voice-selection {
.sub-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: #666;
}
.voice-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
max-height: 176px;
overflow-y: auto;
padding-right: 4px;
}
.voice-item {
display: flex;
align-items: center;
padding: 6px 10px;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&.active {
border-color: #ab1941;
background: rgba(171, 25, 65, 0.08);
}
&:hover {
background: #fafafa;
}
.v-info {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.v-name {
font-size: 13px;
font-weight: 500;
}
.v-style {
font-size: 11px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.upload-status {
margin-top: 10px;
color: #52c41a;
font-size: 13px;
}
.preview-panel {
.results-container {
min-height: 320px;
background: #f7f8fa;
border-radius: 12px;
border: 2px dashed #e1e4e8;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
.empty-preview {
text-align: center;
color: #abb2bb;
p {
font-size: 14px;
margin-top: 16px;
}
}
.generating-box {
text-align: center;
.gen-text {
font-weight: 600;
margin: 16px 0 8px;
color: #333;
}
.gen-hint {
font-size: 13px;
color: #999;
}
}
.final-video-box {
width: 100%;
video {
width: 100%;
border-radius: 8px;
background: #000;
max-height: 300px;
}
.video-actions {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
}
}
.submit-btn {
height: 44px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
margin-top: 16px;
}
}
}
......@@ -80,8 +80,8 @@ export default function IconModal(props) {
const iconNode = {
type: 'image',
src: selectedIcon.url,
alt: 'icon',
style: { width: '32px', height: '32px' },
alt: 'icon-inline',
style: { width: '32px', height: '32px', verticalAlign: 'middle' },
width: '32', // 双重保障,部分渲染器读这个
height: '32',
children: [{ text: '' }],
......
export const digitalHumans = [
{
id: 'A2A_V2-xinxin',
name: '梓欣',
gender: 'female',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2089_8dc1165.png',
},
{
id: 'A2A_V2-xixi',
name: '筱萱',
gender: 'female',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2090_2cae36d.png',
},
{
id: 'A2A_V2-xiaomeng2',
name: '乔雅',
gender: 'female',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2091_70a3d4d.png',
},
{
id: 'A2A_V2-aning',
name: '嘉睿',
gender: 'male',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/%E5%98%89%E7%9D%BF-2_34e59dc.png',
},
{
id: 'A2A_V2-aning_red',
name: '嘉霖',
gender: 'male',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2094_10f09cc.png',
},
{
id: 'A2A_V2-gaoming',
name: '纪楚',
gender: 'male',
avatar: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%2095_ed96bc7.png',
},
]
export const femaleVoices = [
{
id: 'CAP_4146',
name: '度禧禧',
gender: '女声',
style: '温柔甜美',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/1e9d042c-f9d7-417f-88d3-4209f5516338/4146.wav',
},
{
id: 'CAP_6567',
name: '度小柔',
gender: '女声',
style: '知性大方',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/d00df619-70d5-458b-98e2-4d0e14595ace/6567.wav',
},
{
id: 'CAP_4189',
name: '度涵竹',
gender: '女声',
style: '自然生动',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/c3414454-fe66-4980-b653-b806da9616ed/4189.wav',
},
{
id: 'CAP_4194',
name: '度嫣然',
gender: '女声',
style: '温柔可爱',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/503224a8-98f3-4703-b953-795725546686/4194.wav',
},
{
id: 'CAP_4196',
name: '度清影',
gender: '女声',
style: '甜美可爱',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/d5e13abb-7a7e-4264-896c-4f9022fb6b78/4196.wav',
},
{
id: 'CAP_4197',
name: '度沁遥',
gender: '女声',
style: '温柔知性',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/e7e7274e-02df-4caa-9741-7d73b25d8ddd/4197.wav',
},
{
id: '5132',
name: '度小夏',
gender: '女声',
style: '知性大方',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/075ca6ce61d49629f62c520734b9e70e.wav',
},
{
id: '4100',
name: '度小雯',
gender: '女声',
style: '元气活力',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/b444cd975c8e5458ab0ab49644514a1a.wav',
},
{
id: '5116',
name: '度小希',
gender: '女声',
style: '元气活力',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/7015792b10a16e60753918c06869da2b.wav',
},
{
id: '5147',
name: '度常盈',
gender: '女声',
style: '亲和力强',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/1e6368f45761d7d65724bfc109cc5d4d.wav',
},
]
export const maleVoices = [
{
id: 'CAP_4193',
name: '度泽言-开朗',
gender: '男声',
style: '温柔青年',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/a545f018-54a1-4a89-a279-2c56a901bd5b/4193.wav',
},
{
id: 'CAP_4195',
name: '度怀安',
gender: '男声',
style: '磁性深情',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/029dd3eb-1bd9-455b-a5fe-3cc3d32f85c3/4195.wav',
},
{
id: 'CAP_4179',
name: '度泽言-温暖',
gender: '男声',
style: '温柔青年',
previewUrl: 'https://meta-human-editor-prd.cdn.bcebos.com/1a71e60c-bbe0-482b-81fb-4889524acbc3/ed2cc48e-db88-4a4d-91ba-e7f86935df03/4179.wav',
},
{
id: '4140',
name: '度小新',
gender: '男声',
style: '元气活力',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/a6bf500f7156b7e762ee2760288de98c.wav',
},
{
id: '5135',
name: '度星河',
gender: '男声',
style: '沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/337482d4a3299ac70a4d01283ce9de9f.wav',
},
{
id: '4123',
name: '度小凯',
gender: '男声',
style: '激情饱满',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/17c68fd8042a35b667cd6a92ceef4dbf.wav',
},
{
id: '4003',
name: '度逍遥',
gender: '男声',
style: '权威靠谱/专业娴熟',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/f26b3d0c97543381f7a2ec3508eb96e8.wav',
},
{
id: '4129',
name: '度小彦',
gender: '男声',
style: '元气活力',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/f7e067b169ff8262f91ff69aa64a6be3.wav',
},
{
id: '4115',
name: '度小贤',
gender: '男声',
style: '权威靠谱/沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/31aebec53b10a474019ba63e687c21f8.wav',
},
{
id: '4106',
name: '度博文',
gender: '男声',
style: '沉稳冷静',
previewUrl: 'https://digital-human-pipeline-output.cdn.bcebos.com/e192515285df0423911b14ef916cafbb.wav',
},
]
import { useState, useEffect, useRef, useCallback } from 'react'
import { message } from 'antd'
import { getDigitalHumanList, submitVideo, getVideoTask } from '@/api/xiling'
import { digitalHumans as staticFigures, femaleVoices, maleVoices } from '@/common/wangeditor-customer/menu/common/digital-human-data'
/**
* 数字人功能 Hook (简化版:仅文本驱动,移除底图)
*/
export default function useDigitalHuman() {
const [figures, setFigures] = useState(staticFigures)
const [loadingFigures, setLoadingFigures] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [generatedVideo, setGeneratedVideo] = useState(null)
const [error, setError] = useState(null)
const pollTimerRef = useRef(null)
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
}, [])
const startPolling = useCallback((taskId, figureName) => {
stopPolling()
pollTimerRef.current = setInterval(async () => {
try {
const response = await getVideoTask({ taskId })
const { status, videoUrl } = response.result
if (status === 'SUCCESS' && videoUrl) {
stopPolling()
setGeneratedVideo({
url: videoUrl,
taskId: taskId,
figureName: figureName,
})
setIsGenerating(false)
message.success('数字人视频生成成功!')
} else if (status === 'FAILED') {
stopPolling()
setIsGenerating(false)
setError(response.result.failedMessage || '生成失败')
message.error(`视频生成失败: ${response.result.failedMessage || '未知错误'}`)
}
} catch (err) {
console.error('Polling Error:', err)
}
}, 5000)
}, [stopPolling])
// 处理获取列表
const fetchFigures = useCallback(async () => {
setLoadingFigures(true)
try {
const list = await getDigitalHumanList({ systemFigure: true })
if (list && list.length > 0) {
setFigures(prev => {
// 创建新数组,保留静态配置,并用 API 的数据补充或更新关键信息(如 avatar)
const combined = [...prev]
list.forEach(item => {
const existingIndex = combined.findIndex(c => String(c.id) === String(item.figureId))
const avatarUrl = item.figureImageUrl || item.figureVideoThumbnailUrl
if (existingIndex > -1) {
// 如果静态配置已存在,且 API 返回了有效的预览图,则更新它
if (avatarUrl) {
combined[existingIndex] = {
...combined[existingIndex],
avatar: avatarUrl
}
}
} else {
// 如果是全新的数字人,则添加
combined.push({
id: item.figureId,
name: item.name,
avatar: avatarUrl,
})
}
})
return combined
})
}
} catch (err) {
console.error('Fetch Figures Error:', err)
} finally {
setLoadingFigures(false)
}
}, [])
const generateVideo = useCallback(async (options) => {
const {
figureId,
figureName,
text,
voiceId,
width = 720,
height = 1280,
transparent = true,
enableSubtitle = false,
backgroundImageUrl
} = options
setIsGenerating(true)
setGeneratedVideo(null)
setError(null)
try {
const params = {
figureId,
driveType: 'TEXT',
text,
...(backgroundImageUrl ? { backgroundImageUrl } : {}),
ttsParams: {
person: voiceId || '5116',
speed: '5',
pitch: '5',
volume: '5'
},
videoParams: {
width: parseInt(width),
height: parseInt(height),
transparent: !!transparent
},
subtitleParams: {
enabled: !!enableSubtitle,
subtitlePolicy: 'SRT'
}
}
const response = await submitVideo(params)
if (response.result && response.result.taskId) {
startPolling(response.result.taskId, figureName)
return response.result.taskId
} else {
throw new Error('TaskId missing')
}
} catch (err) {
setIsGenerating(false)
const msg = err.response?.data?.message?.global || '提交失败'
setError(msg)
message.error(msg)
throw err
}
}, [startPolling])
useEffect(() => {
fetchFigures()
return () => stopPolling()
}, [fetchFigures, stopPolling])
return {
figures,
femaleVoices,
maleVoices,
loadingFigures,
isGenerating,
generatedVideo,
error,
generateVideo,
resetGeneratedVideo: () => setGeneratedVideo(null)
}
}
......@@ -77,7 +77,7 @@ export async function uploadFile(file) {
// 上传通过URL获取的文件
export async function uploadFileByUrl(url) {
try {
url = url.replace('https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com', '/api/ai_file')
url = url.replace('https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com', '/api/volcano_file')
const res = await axios.get(url, { responseType: 'blob' })
return await uploadFile(res.data)
} catch (error) {
......
......@@ -52,10 +52,10 @@ export default defineConfig(() => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/xiling/, ''),
},
'/api/ai_file': {
'/api/volcano_file': {
target: 'https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/ai_file/, ''),
rewrite: (path) => path.replace(/^\/api\/volcano_file/, ''),
},
'/api': {
target: 'https://zijingebook.ezijing.com',
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论