提交 72492e6c authored 作者: 王鹏飞's avatar 王鹏飞

refactor: 重构数字人模块

上级 5452e724
......@@ -28,20 +28,20 @@
"axios": "^1.18.1",
"dayjs": "^1.11.11",
"easy-formula-editor": "^0.0.2-alpha.1",
"echarts": "^5.4.3",
"echarts": "^6.1.0",
"file-saver": "^2.0.5",
"highlight.js": "^11.11.1",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.8",
"js-md5": "^0.8.3",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",
"lodash-es": "^4.18.1",
"markdown-it": "^14.3.0",
"mathjax": "3.2.2",
"nanoid": "^3.3.8",
"nanoid": "^3.3.18",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-router": "^7.18.1",
"react-router": "^7.18.2",
"react-use": "^17.6.0",
"snabbdom": "^3.6.2",
"zustand": "^4.5.5"
......@@ -52,14 +52,14 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.7",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.1.1",
"less": "^4.2.0",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vite-plugin-mkcert": "^1.17.10",
"vite": "^8.2.1",
"vite-plugin-mkcert": "^2.1.0",
"vitest": "^4.1.10"
},
"browserslist": [
......
......@@ -72,9 +72,20 @@ export const upload = async (data, options = {}) => {
return response.result
}
export const uploadDigitalHumanFile = async (file, providerType, options = {}) => {
const data = new FormData()
data.append('file', file)
const response = await request.post('/api/digitalhuman/open/v1/file/upload', data, {
params: { providerType, sourceFileName: file.name },
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 { systemFigure, item = 'VIDEO-DH_2D_LITE', pageSize = 1000 } = params
const response = await request.get('/api/digitalhuman/open/v1/figure/query', {
params: { systemFigure, item, pageSize },
...options,
......@@ -82,6 +93,15 @@ export const getDigitalHumanList = async (params, options) => {
return response.result?.result || []
}
// 获取系统音色列表
export const getDigitalHumanVoiceList = async (params = {}, options) => {
const response = await request.get('/api/digitalhuman/open/v1/tts/persons', {
params: { isSystem: true, ...params },
...options,
})
return response.result?.result || response.result || []
}
/**
* 提交生成式数字人形象定制任务
*/
......@@ -130,3 +150,24 @@ export const submitVideoAdvanced = async (params, options = {}) => {
export const getVideoAdvancedTask = async (params, options = {}) => {
return request.get('/api/digitalhuman/open/v1/video/advanced/task', { params, ...options })
}
/**
* 校验照片数字人人像图片
*/
export const verifyImageVideo = async (params, options = {}) => {
return request.post('/api/digitalhuman/open/v1/video/image/verify', params, options)
}
/**
* 提交照片数字人视频任务
*/
export const submitImageVideo = async (params, options = {}) => {
return request.post('/api/digitalhuman/open/v1/video/image/submit', params, options)
}
/**
* 查询照片数字人视频任务
*/
export const getImageVideoTask = async (params, options = {}) => {
return request.get('/api/digitalhuman/open/v1/video/image/task', { params, ...options })
}
export const DIGITAL_HUMAN_TYPES = {
BASIC: 'basic',
ADVANCED: 'advanced',
FAST: 'fast',
PHOTO: 'photo',
}
export const DIGITAL_HUMAN_TYPE_LABELS = {
[DIGITAL_HUMAN_TYPES.BASIC]: '基础接口',
[DIGITAL_HUMAN_TYPES.ADVANCED]: '高级接口',
[DIGITAL_HUMAN_TYPES.FAST]: '已有真人视频',
[DIGITAL_HUMAN_TYPES.PHOTO]: '照片数字人',
}
// 视频组件只在这里切换接口,页面不再把接口能力暴露成四个并列选项。
export const DIGITAL_HUMAN_VIDEO_CONFIG = {
interfaceType: DIGITAL_HUMAN_TYPES.BASIC,
figureItem: 'VIDEO-DH_2D_LITE',
}
export function getConfiguredDigitalHumanType() {
return DIGITAL_HUMAN_VIDEO_CONFIG.interfaceType === DIGITAL_HUMAN_TYPES.ADVANCED
? DIGITAL_HUMAN_TYPES.ADVANCED
: DIGITAL_HUMAN_TYPES.BASIC
}
export const ADVANCED_TEMPLATES = [
{
value: 't-pf4kqasspwzwyexyte121',
label: '视频样式 1',
ratio: 'portrait',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/1_8c5a2e7.png',
},
{
value: 't-af4keqsspfzwyexyte123',
label: '视频样式 2',
ratio: 'portrait',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/2_755cd08.png',
},
{
value: 't-ad4eeqsspfzwyqxyte125',
label: '视频样式 3',
ratio: 'portrait',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/3_c730e57.png',
},
{
value: 't-cd4eeqsspfzwyqxyte127',
label: '视频样式 4',
ratio: 'portrait',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/4_d542bdf.png',
},
{
value: 't-hb4eeqsspfzwyqxyte129',
label: '视频样式 5',
ratio: 'landscape',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/5_5708421.png',
},
{
value: 't-cd4eeqsspfzwyqxyteditu3',
label: '视频样式 6',
ratio: 'portrait',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%20%284%29_a9ef435.png',
},
{
value: 't-cd4eeqsspfzwyqxyteditu4',
label: '视频样式 7',
ratio: 'portrait',
preview: 'https://bce.bdstatic.com/doc/bce-doc/AI_DH/image%20%285%29_4f18cab.png',
},
{
value: 't-cd4eeqsspfzwyqxyteditu5',
label: '视频样式 8',
ratio: 'portrait',
withoutFigure: true,
preview:
'https://bce.bdstatic.com/doc/bce-doc/AI_DH/%E6%97%A0%E6%95%B0%E5%AD%97%E4%BA%BA%E6%A8%A1%E6%9D%BF_97739d1.png',
},
]
function compact(value) {
if (Array.isArray(value)) return value.filter(Boolean)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value)
.filter(([, item]) => item !== '' && item !== undefined && item !== null)
.map(([key, item]) => [key, compact(item)]),
)
}
function buildDriveParams(options, includeLanguage = true) {
if (options.driveType === 'VOICE') {
return {
driveType: 'VOICE',
inputAudioUrl: options.inputAudioUrl,
}
}
return {
driveType: 'TEXT',
text: options.text,
ttsParams: {
person: options.voiceId,
lan: includeLanguage ? options.language || undefined : undefined,
speed: String(options.speed ?? 5),
volume: String(options.volume ?? 5),
pitch: String(options.pitch ?? 5),
},
}
}
export function buildDigitalHumanRequest(type, options) {
const driveParams = buildDriveParams(options, type !== DIGITAL_HUMAN_TYPES.ADVANCED)
if (type === DIGITAL_HUMAN_TYPES.FAST) {
return compact({
templateVideoId: options.templateVideoId,
...driveParams,
})
}
if (type === DIGITAL_HUMAN_TYPES.PHOTO) {
return compact({
inputImageUrl: options.inputImageUrl,
model: options.model,
...driveParams,
subtitleParams: {
enabled: options.driveType === 'TEXT' && !!options.enableSubtitle,
subtitlePolicy: 'SRT',
},
})
}
if (type === DIGITAL_HUMAN_TYPES.ADVANCED) {
return compact({
figureId: options.figureId,
templateId: options.templateId,
model: options.model,
...driveParams,
title: options.title,
logoParams: options.logoUrl ? { logoUrl: options.logoUrl } : undefined,
bgmParams: options.bgmUrl ? { bgmUrl: options.bgmUrl } : undefined,
materialUrl: options.materialUrl,
extendBackgroundUrl: options.extendBackgroundUrl,
riskTip: options.riskTip,
openingMaterial: options.openingFileId
? { fileId: options.openingFileId, mediaType: 'VIDEO' }
: options.openingUrl
? { fileUrl: options.openingUrl, mediaType: 'VIDEO' }
: undefined,
endingMaterial: options.endingFileId
? { fileId: options.endingFileId, mediaType: 'VIDEO' }
: options.endingUrl
? { fileUrl: options.endingUrl, mediaType: 'VIDEO' }
: undefined,
mashupMaterials: options.mashupMaterials?.map(({ fileId, fileUrl, mediaType }) => ({
fileId,
fileUrl,
mediaType,
})),
videoParams: {
width: Number(options.width),
height: Number(options.height),
},
dhParams: options.dhParams,
fissionParams:
options.driveType === 'TEXT' && options.fissionFigureIds?.length
? {
figureIds: options.fissionFigureIds,
ttsPersons: options.fissionVoiceIds,
}
: undefined,
})
}
return compact({
figureId: options.figureId,
...driveParams,
backgroundImageUrl: options.transparent ? undefined : options.backgroundImageUrl,
playControlParams: options.enableSegmentLoop
? {
controlType: 'SEGMENT_LOOP',
startTimestamp: Number(options.startTimestamp || 0),
endTimestamp: Number(options.endTimestamp),
}
: undefined,
videoParams: {
width: Number(options.width),
height: Number(options.height),
transparent: !!options.transparent,
},
dhParams:
options.dhParams ||
(options.positionMode === 'camera'
? { cameraId: Number(options.cameraId) }
: options.positionMode === 'pixel'
? {
positionV2: {
crop: {
top: Number(options.cropTop),
left: Number(options.cropLeft),
width: Number(options.cropWidth),
height: Number(options.cropHeight),
},
location: {
top: Number(options.locationTop),
left: Number(options.locationLeft),
width: Number(options.locationWidth),
height: Number(options.locationHeight),
},
},
}
: options.positionX !== undefined
? {
position: {
x: Number(options.positionX),
y: Number(options.positionY),
z: Number(options.positionZ),
},
}
: undefined),
autoAnimoji: options.driveType === 'TEXT' && !!options.autoAnimoji,
subtitleParams: {
enabled: options.driveType === 'TEXT' && !!options.enableSubtitle,
subtitlePolicy: 'SRT',
},
})
}
const STORAGE_KEY = 'center-book:digital-human-tasks'
const MAX_TASKS = 10
function getStorage(storage) {
if (storage) return storage
if (typeof window === 'undefined') return null
return window.localStorage
}
export function readDigitalHumanTasks(storage) {
try {
const value = getStorage(storage)?.getItem(STORAGE_KEY)
const tasks = value ? JSON.parse(value) : []
return Array.isArray(tasks) ? tasks : []
} catch {
return []
}
}
export function saveDigitalHumanTask(task, storage) {
const target = getStorage(storage)
if (!target || !task?.taskId) return []
const tasks = readDigitalHumanTasks(target)
const nextTasks = [task, ...tasks.filter((item) => item.taskId !== task.taskId)].slice(
0,
MAX_TASKS,
)
try {
target.setItem(STORAGE_KEY, JSON.stringify(nextTasks))
} catch {
return tasks
}
return nextTasks
}
export function clearDigitalHumanTasks(storage) {
getStorage(storage)?.removeItem(STORAGE_KEY)
}
import { describe, expect, it } from 'vitest'
import { buildDigitalHumanRequest, DIGITAL_HUMAN_TYPES } from '../../hooks/digitalHumanRequest'
const common = {
driveType: 'TEXT',
text: '数字人测试。',
voiceId: '5116',
language: 'Chinese',
speed: 5,
volume: 6,
pitch: 4,
}
describe('digital human request builder', () => {
it('builds the basic video contract', () => {
expect(
buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.BASIC, {
...common,
figureId: '1081',
width: 720,
height: 1280,
transparent: false,
enableSubtitle: true,
positionX: 0,
positionY: 0.2,
positionZ: 1,
}),
).toMatchObject({
figureId: '1081',
driveType: 'TEXT',
ttsParams: { person: '5116', speed: '5', volume: '6', pitch: '4' },
videoParams: { width: 720, height: 1280, transparent: false },
dhParams: { position: { x: 0, y: 0.2, z: 1 } },
subtitleParams: { enabled: true, subtitlePolicy: 'SRT' },
})
})
it('builds the advanced video media and fission contract', () => {
const result = buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.ADVANCED, {
...common,
figureId: '1081',
templateId: 'template-1',
width: 1080,
height: 1920,
model: 'turbo_v2',
title: '标题',
logoUrl: 'https://example.com/logo.png',
bgmUrl: 'https://example.com/bgm.mp3',
materialUrl: 'https://example.com/material.mp4',
extendBackgroundUrl: 'https://example.com/extend.png',
riskTip: '示例水印',
openingFileId: 'opening-file-id',
endingFileId: 'ending-file-id',
mashupMaterials: [
{ fileId: 'image-file-id', fileName: '背景.jpg', mediaType: 'IMAGE' },
{ fileId: 'video-file-id', fileName: '背景.mp4', mediaType: 'VIDEO' },
],
fissionFigureIds: ['1081', '1112'],
fissionVoiceIds: ['5116'],
})
expect(result).toMatchObject({
figureId: '1081',
templateId: 'template-1',
model: 'turbo_v2',
title: '标题',
logoParams: { logoUrl: 'https://example.com/logo.png' },
bgmParams: { bgmUrl: 'https://example.com/bgm.mp3' },
materialUrl: 'https://example.com/material.mp4',
extendBackgroundUrl: 'https://example.com/extend.png',
riskTip: '示例水印',
openingMaterial: { fileId: 'opening-file-id', mediaType: 'VIDEO' },
endingMaterial: { fileId: 'ending-file-id', mediaType: 'VIDEO' },
mashupMaterials: [
{ fileId: 'image-file-id', mediaType: 'IMAGE' },
{ fileId: 'video-file-id', mediaType: 'VIDEO' },
],
fissionParams: { figureIds: ['1081', '1112'], ttsPersons: ['5116'] },
})
expect(result).not.toHaveProperty('ttsParams.lan')
})
it('does not enable fission without figure IDs', () => {
const result = buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.ADVANCED, {
...common,
figureId: '1081',
templateId: 'template-1',
width: 1080,
height: 1920,
fissionVoiceIds: ['5116'],
})
expect(result).not.toHaveProperty('fissionParams')
})
it('supports the basic camera and pixel positioning contracts', () => {
expect(
buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.BASIC, {
...common,
figureId: '1081',
width: 1080,
height: 1920,
positionMode: 'camera',
cameraId: 3,
}),
).toMatchObject({ dhParams: { cameraId: 3 } })
expect(
buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.BASIC, {
...common,
figureId: '1081',
width: 1080,
height: 1920,
positionMode: 'pixel',
cropTop: 10,
cropLeft: 20,
cropWidth: 900,
cropHeight: 1600,
locationTop: 30,
locationLeft: 40,
locationWidth: 720,
locationHeight: 1280,
}),
).toMatchObject({
dhParams: {
positionV2: {
crop: { top: 10, left: 20, width: 900, height: 1600 },
location: { top: 30, left: 40, width: 720, height: 1280 },
},
},
})
})
it('uses the visual preview location for 2D small-sample figures', () => {
expect(
buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.BASIC, {
...common,
figureId: '1081',
width: 1920,
height: 1080,
dhParams: {
positionV2: {
location: { top: -39, left: 525, width: 1920, height: 1080 },
},
},
}),
).toMatchObject({
dhParams: {
positionV2: {
location: { top: -39, left: 525, width: 1920, height: 1080 },
},
},
})
})
it('builds 123 and photo video contracts with their own inputs', () => {
expect(
buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.FAST, {
...common,
templateVideoId: 'https://example.com/123.mp4',
}),
).toMatchObject({ templateVideoId: 'https://example.com/123.mp4', driveType: 'TEXT' })
expect(
buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.PHOTO, {
...common,
inputImageUrl: 'https://example.com/photo.jpg',
model: 'turbo_v2',
enableSubtitle: true,
}),
).toMatchObject({
inputImageUrl: 'https://example.com/photo.jpg',
model: 'turbo_v2',
subtitleParams: { enabled: true },
})
})
it('uses input audio instead of TTS parameters for voice drive', () => {
const result = buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.PHOTO, {
driveType: 'VOICE',
inputAudioUrl: 'https://example.com/voice.mp3',
inputImageUrl: 'https://example.com/photo.jpg',
})
expect(result).toMatchObject({
driveType: 'VOICE',
inputAudioUrl: expect.any(String),
subtitleParams: { enabled: false },
})
expect(result).not.toHaveProperty('ttsParams')
})
it('omits options that do not take effect in the selected mode', () => {
const result = buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.BASIC, {
driveType: 'VOICE',
figureId: '1081',
inputAudioUrl: 'https://example.com/voice.mp3',
width: 1080,
height: 1920,
transparent: true,
backgroundImageUrl: 'https://example.com/background.jpg',
autoAnimoji: true,
enableSubtitle: true,
})
expect(result).not.toHaveProperty('backgroundImageUrl')
expect(result).toMatchObject({
autoAnimoji: false,
subtitleParams: { enabled: false },
})
})
})
import { describe, expect, it } from 'vitest'
import {
clearDigitalHumanTasks,
readDigitalHumanTasks,
saveDigitalHumanTask,
} from '../../hooks/digitalHumanTaskStore'
function createStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
describe('digital human task storage', () => {
it('keeps the newest version of a task first', () => {
const storage = createStorage()
saveDigitalHumanTask({ taskId: 'task-1', status: 'PROCESSING' }, storage)
saveDigitalHumanTask({ taskId: 'task-2', status: 'SUCCESS' }, storage)
saveDigitalHumanTask({ taskId: 'task-1', status: 'FAILED' }, storage)
expect(readDigitalHumanTasks(storage)).toEqual([
{ taskId: 'task-1', status: 'FAILED' },
{ taskId: 'task-2', status: 'SUCCESS' },
])
})
it('keeps at most ten recent tasks and can clear them', () => {
const storage = createStorage()
for (let index = 0; index < 12; index += 1) {
saveDigitalHumanTask({ taskId: `task-${index}`, status: 'SUCCESS' }, storage)
}
expect(readDigitalHumanTasks(storage)).toHaveLength(10)
expect(readDigitalHumanTasks(storage)[0].taskId).toBe('task-11')
clearDigitalHumanTasks(storage)
expect(readDigitalHumanTasks(storage)).toEqual([])
})
it('ignores invalid stored data', () => {
const storage = createStorage()
storage.setItem('center-book:digital-human-tasks', '{invalid')
expect(readDigitalHumanTasks(storage)).toEqual([])
})
it('does not break generation when browser storage is unavailable', () => {
const storage = createStorage()
storage.setItem = () => {
throw new Error('storage unavailable')
}
expect(saveDigitalHumanTask({ taskId: 'task-1', status: 'PROCESSING' }, storage)).toEqual([])
})
})
import { useState } from 'react'
import { App, Button, Upload } from 'antd'
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { uploadDigitalHumanFile } from '@/components/editor/api/xiling'
const PROVIDER_TYPE = 'OPEN_VIDEO_DURATION_VIDEO'
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp']
function getExtension(file) {
return file.name.split('.').pop()?.toLowerCase() || ''
}
function getMediaType(file) {
return file.type.startsWith('image/') || IMAGE_EXTENSIONS.includes(getExtension(file))
? 'IMAGE'
: 'VIDEO'
}
function matchesAccept(file, accept) {
const extension = getExtension(file)
return accept.split(',').some((item) => {
const pattern = item.trim().toLowerCase()
if (pattern.startsWith('.')) return pattern === `.${extension}`
if (pattern.endsWith('/*')) return file.type.startsWith(pattern.slice(0, -1))
return file.type.toLowerCase() === pattern
})
}
function getFileLimit(file) {
return getMediaType(file) === 'IMAGE' ? 3 : 500
}
export default function DigitalHumanBaiduUploadField({
label,
value,
onChange,
accept,
multiple = false,
maxCount = 1,
hint,
}) {
const { message } = App.useApp()
const [pendingUploads, setPendingUploads] = useState(0)
const uploading = pendingUploads > 0
const files = multiple ? value || [] : value ? [value] : []
const uploadDisabled = uploading || (multiple && files.length >= maxCount)
const upload = async ({ file, onSuccess, onError }) => {
if (!matchesAccept(file, accept)) {
const error = new Error('文件格式不符合要求')
message.error(error.message)
onError(error)
return
}
const limit = getFileLimit(file)
if (file.size > limit * 1024 * 1024) {
const error = new Error(
`${getMediaType(file) === 'IMAGE' ? '图片' : '视频'}不能超过 ${limit}MB`,
)
message.error(error.message)
onError(error)
return
}
setPendingUploads((current) => current + 1)
try {
const result = await uploadDigitalHumanFile(file, PROVIDER_TYPE)
if (!result?.fileId) throw new Error('上传结果缺少文件编号')
const nextFile = {
fileId: result.fileId,
fileName: result.fileName || file.name,
mediaType: getMediaType(file),
}
onChange(multiple ? (current) => [...(current || []), nextFile].slice(0, maxCount) : nextFile)
onSuccess(result)
message.success(`${label}上传成功`)
} catch (error) {
message.error(`${label}上传失败`)
onError(error)
} finally {
setPendingUploads((current) => Math.max(0, current - 1))
}
}
const remove = (index) => {
if (multiple) onChange(files.filter((_, itemIndex) => itemIndex !== index))
else onChange(null)
}
return (
<div className='media-field baidu-upload-field'>
<div className='field-label'>{label}</div>
<Upload
accept={accept}
multiple={multiple}
maxCount={maxCount}
showUploadList={false}
customRequest={upload}
disabled={uploadDisabled}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={uploadDisabled}>
{multiple
? `添加${label}(${files.length}/${maxCount})`
: files.length
? `更换${label}`
: `上传${label}`}
</Button>
</Upload>
{files.length ? (
<div className='baidu-upload-list'>
{files.map((file, index) => (
<div className='baidu-upload-item' key={file.fileId}>
<span>{file.fileName}</span>
<Button
type='text'
size='small'
icon={<DeleteOutlined />}
aria-label={`删除${file.fileName}`}
onClick={() => remove(index)}
/>
</div>
))}
</div>
) : null}
{hint ? <div className='field-hint'>{hint}</div> : null}
</div>
)
}
import { useState } from 'react'
import { Alert, App, Button, Form, Input, Radio, Tag } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { queryGenerativeFigure, submitGenerativeFigure } from '@/components/editor/api/xiling'
import DigitalHumanUploadField from './DigitalHumanUploadField'
const customizeStatus = {
LINE_UP: { color: 'default', text: '排队中' },
ACTIONING: { color: 'processing', text: '动作生成中' },
GENERATING: { color: 'processing', text: '训练中' },
SUCCESS: { color: 'success', text: '已完成' },
FAILED: { color: 'error', text: '定制失败' },
}
function findFigure(response, figureId) {
const payload = response?.result
const list = Array.isArray(payload?.result)
? payload.result
: Array.isArray(payload)
? payload
: payload
? [payload]
: []
return list.find((item) => String(item.figureId) === String(figureId)) || list[0]
}
export default function DigitalHumanCustomizePanel({ onSuccess }) {
const { message } = App.useApp()
const [imageUrl, setImageUrl] = useState('')
const [task, setTask] = useState(null)
const [submitting, setSubmitting] = useState(false)
const [querying, setQuerying] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (values) => {
if (!imageUrl) return message.warning('请先上传参考图片')
setSubmitting(true)
setError('')
try {
const response = await submitGenerativeFigure({
name: values.name.trim(),
customizeType: 'GENERATE_NO_ACTION',
gender: values.gender,
figureImageUrl: imageUrl,
})
const figureId = response.result?.figureId
if (!figureId) throw new Error('未获取到形象 ID')
setTask({ figureId, name: values.name.trim(), status: 'LINE_UP' })
message.success('形象定制任务已提交')
} catch (submitError) {
setError(submitError.response?.data?.message?.global || submitError.message || '提交失败')
} finally {
setSubmitting(false)
}
}
const handleQuery = async () => {
if (!task?.figureId) return
setQuerying(true)
setError('')
try {
const response = await queryGenerativeFigure({ figureId: task.figureId })
const figure = findFigure(response, task.figureId)
if (!figure) throw new Error('暂未查询到形象任务')
const nextTask = { ...task, ...figure }
setTask(nextTask)
if (nextTask.status === 'SUCCESS') onSuccess?.()
} catch (queryError) {
setError(queryError.response?.data?.message?.global || queryError.message || '查询失败')
} finally {
setQuerying(false)
}
}
return (
<div className='customize-layout'>
<section className='customize-form-card'>
<h2>生成式数字人形象定制</h2>
<p>上传一张人物图片,生成可用于视频合成的数字人形象。</p>
<Form
layout='vertical'
initialValues={{ gender: 'MALE' }}
requiredMark={false}
onFinish={handleSubmit}
>
<Form.Item
label='形象名称'
name='name'
rules={[
{ required: true, whitespace: true, message: '请输入形象名称' },
{ max: 20, message: '形象名称不能超过20个字符' },
]}
>
<Input maxLength={20} placeholder='请输入形象名称' />
</Form.Item>
<Form.Item label='性别' name='gender'>
<Radio.Group
optionType='button'
buttonStyle='solid'
options={[
{ label: '男', value: 'MALE' },
{ label: '女', value: 'FEMALE' },
]}
/>
</Form.Item>
<DigitalHumanUploadField
label='参考图片'
value={imageUrl}
onChange={setImageUrl}
accept='image/jpeg,image/png'
maxSizeMB={10}
filenamePrefix='digital-human-figure'
previewType='image'
hint='支持 JPG、JPEG、PNG,建议使用 1080×1920 或 1920×1080 图片。'
/>
<Button type='primary' htmlType='submit' loading={submitting} block>
开始定制
</Button>
</Form>
</section>
<section className='customize-result-card'>
<div className='customize-result-heading'>
<h2>定制进度</h2>
{task ? (
<Button icon={<ReloadOutlined />} onClick={handleQuery} loading={querying}>
查询进度
</Button>
) : null}
</div>
{error ? <Alert type='error' showIcon title={error} /> : null}
{task ? (
<div className='customize-task'>
{task.templateImg || task.pictureUrl || imageUrl ? (
<img
src={task.templateImg || task.pictureUrl || imageUrl}
alt={`${task.name || '数字人'}形象预览`}
/>
) : null}
<div>
<strong>{task.name || '数字人形象'}</strong>
<span>ID:{task.figureId}</span>
<Tag color={customizeStatus[task.status]?.color}>
{customizeStatus[task.status]?.text || task.status}
</Tag>
{task.failedMessage ? <small>{task.failedMessage}</small> : null}
</div>
</div>
) : (
<div className='customize-empty'>提交任务后,可在这里查询训练进度。</div>
)}
</section>
</div>
)
}
import { useState } from 'react'
import { Button, message, Upload } from 'antd'
import { CheckCircleFilled, DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { uploadFile } from '@/utils/oss'
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'bmp']
const VIDEO_EXTENSIONS = ['mp4']
const AUDIO_EXTENSIONS = ['wav', 'mp3', 'wma', 'm4a']
function getExtension(file) {
return file.name.split('.').pop()?.toLowerCase() || ''
}
function getFileCategory(file) {
const extension = getExtension(file)
if (file.type.startsWith('image/') || IMAGE_EXTENSIONS.includes(extension)) return 'image'
if (file.type.startsWith('video/') || VIDEO_EXTENSIONS.includes(extension)) return 'video'
if (file.type.startsWith('audio/') || AUDIO_EXTENSIONS.includes(extension)) return 'audio'
return ''
}
function matchesAccept(file, accept) {
const extension = getExtension(file)
const category = getFileCategory(file)
return accept.split(',').some((item) => {
const pattern = item.trim().toLowerCase()
if (pattern.startsWith('.')) return pattern === `.${extension}`
if (pattern.endsWith('/*')) return category === pattern.slice(0, -2)
return file.type.toLowerCase() === pattern
})
}
export default function DigitalHumanUploadField({
label,
value,
onChange,
accept,
maxSizeMB,
maxImageSizeMB,
maxVideoSizeMB,
hint,
uploadHint,
previewType,
filenamePrefix = 'digital-human',
}) {
const [uploading, setUploading] = useState(false)
const displayLabel = label.replace('(可选)', '')
const upload = async ({ file, onSuccess, onError }) => {
if (!matchesAccept(file, accept)) {
const error = new Error('文件格式不符合要求')
message.error(error.message)
onError(error)
return
}
const fileLimit =
getFileCategory(file) === 'image'
? (maxImageSizeMB ?? maxSizeMB)
: (maxVideoSizeMB ?? maxSizeMB)
if (file.size > fileLimit * 1024 * 1024) {
const error = new Error(`文件不能超过 ${fileLimit}MB`)
message.error(error.message)
onError(error)
return
}
setUploading(true)
try {
const url = await uploadFile(file, { filenamePrefix })
onChange(url)
onSuccess({ url })
message.success(`${label}上传成功`)
} catch (error) {
message.error(`${label}上传失败`)
onError(error)
} finally {
setUploading(false)
}
}
if (previewType === 'image') {
return (
<div className='media-field image-media-field'>
<div className='field-label'>{label}</div>
<div className={`image-upload-card ${value ? 'has-value' : ''}`}>
<Upload
className='image-upload-trigger'
accept={accept}
showUploadList={false}
customRequest={upload}
>
<button type='button' className='image-upload-button' disabled={uploading}>
{value ? (
<img src={value} alt={`${label}预览`} />
) : (
<span className='image-upload-icon'>
<UploadOutlined />
</span>
)}
<span className='image-upload-copy'>
<strong>
{uploading
? '正在上传…'
: value
? `已选择${displayLabel}`
: `上传${displayLabel}`}
</strong>
<small>
{value ? '点击更换图片' : uploadHint || `文件不能超过 ${maxSizeMB}MB`}
</small>
</span>
</button>
</Upload>
{value ? (
<Button
type='text'
icon={<DeleteOutlined />}
className='image-upload-remove'
onClick={() => onChange('')}
aria-label={`清除${label}`}
/>
) : null}
</div>
{hint ? <div className='field-hint'>{hint}</div> : null}
</div>
)
}
return (
<div className='media-field'>
<div className='field-label'>{label}</div>
<div className={`teacher-upload ${value ? 'has-value' : ''}`}>
<Upload accept={accept} showUploadList={false} customRequest={upload}>
<Button icon={<UploadOutlined />} loading={uploading}>
{value ? `重新上传${label}` : `上传${label}`}
</Button>
</Upload>
{value ? (
<span className='upload-ready'>
<CheckCircleFilled /> 已准备好
</span>
) : null}
{value ? (
<Button
type='text'
size='small'
icon={<DeleteOutlined />}
onClick={() => onChange('')}
aria-label={`清除${label}`}
>
清除
</Button>
) : null}
</div>
{value && previewType === 'video' ? (
<div className='source-preview video-preview'>
<video src={value} controls preload='metadata' aria-label={`${label}预览`} />
<span>请播放检查画面、时长和声音是否正确</span>
</div>
) : null}
{hint ? <div className='field-hint'>{hint}</div> : null}
</div>
)
}
......@@ -21,7 +21,7 @@
"nuqs": "^2.9.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^7.4.0",
"react-router": "^7.18.2",
"zustand": "^5.0.3"
},
"devDependencies": {
......@@ -31,13 +31,13 @@
"@types/node": "^22.13.9",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.1.1",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vite-plugin-mkcert": "^1.17.10",
"vite": "^8.2.1",
"vite-plugin-mkcert": "^2.1.0",
"vitest": "^4.1.10"
}
}
差异被折叠。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论