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

feat: add digital human recent tasks component and related hooks

- Implemented DigitalHumanRecentTasks component for displaying recent tasks with modal support. - Updated DigitalHumanUploadField to enhance upload functionality and UI. - Created DigitalHumanVoiceSettings for managing voice settings with audio playback. - Added digital human preview utilities for handling video sizes and parameters. - Introduced digital human UI constants for task status and styles. - Refactored styles in digital-human.less for improved layout and consistency. - Developed hooks for advanced and basic video composition, managing common fields, and handling digital human insertion.
上级 6ffe2961
......@@ -142,7 +142,6 @@ export function buildDigitalHumanRequest(type, options) {
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
......@@ -153,23 +152,11 @@ export function buildDigitalHumanRequest(type, options) {
: 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,
})
}
......
......@@ -64,7 +64,6 @@ export default function useDigitalHuman() {
const [maleVoices, setMaleVoices] = useState([])
const [loadingFigures, setLoadingFigures] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [generatedVideo, setGeneratedVideo] = useState(null)
const [error, setError] = useState(null)
const [tasks, setTasks] = useState(readDigitalHumanTasks)
const [activeTask, setActiveTask] = useState(null)
......@@ -107,14 +106,13 @@ export default function useDigitalHuman() {
if (status === 'SUCCESS' && videoUrl) {
stopPolling()
const completedTask = persistTask({
persistTask({
...task,
...result,
status: 'SUCCESS',
url: videoUrl,
updatedAt: Date.now(),
})
setGeneratedVideo(completedTask)
setIsGenerating(false)
message.success('数字人视频生成成功')
} else if (status === 'FAILED') {
......@@ -218,7 +216,6 @@ export default function useDigitalHuman() {
const type = options.type || DIGITAL_HUMAN_TYPES.BASIC
setIsGenerating(true)
setGeneratedVideo(null)
setError(null)
try {
......@@ -270,14 +267,12 @@ export default function useDigitalHuman() {
const selectTask = useCallback(
(task) => {
if (task.status === 'PROCESSING') {
setGeneratedVideo(null)
startPolling(task)
return
}
stopPolling()
setActiveTask(task)
setError(task.error || null)
setGeneratedVideo(task.status === 'SUCCESS' && task.url ? task : null)
setIsGenerating(false)
},
[startPolling, stopPolling],
......@@ -286,7 +281,6 @@ export default function useDigitalHuman() {
const resumeTask = useCallback(
(task = activeTask) => {
if (!task?.taskId) return
setGeneratedVideo(null)
startPolling({ ...task, status: 'PROCESSING', error: null })
},
[activeTask, startPolling],
......@@ -355,7 +349,6 @@ export default function useDigitalHuman() {
maleVoices,
loadingFigures,
isGenerating,
generatedVideo,
error,
tasks,
activeTask,
......@@ -364,6 +357,5 @@ export default function useDigitalHuman() {
resumeTask,
retryTask,
refreshFigures: fetchFigures,
resetGeneratedVideo: () => setGeneratedVideo(null),
}
}
import { describe, expect, it } from 'vitest'
import {
buildPreviewDhParams,
DIGITAL_HUMAN_VIDEO_SIZES,
getFigureLayerSize,
} from '../features/ai-media/digital-human-preview'
const portraitFigure = { resolutionWidth: 1080, resolutionHeight: 1920 }
describe('digital human preview layout', () => {
it('preserves the figure aspect ratio inside the canvas', () => {
expect(getFigureLayerSize(DIGITAL_HUMAN_VIDEO_SIZES.landscape, portraitFigure)).toEqual({
width: 608,
height: 1080,
})
})
it('uses pixel positions and allows the figure outside the canvas', () => {
expect(
buildPreviewDhParams(
DIGITAL_HUMAN_VIDEO_SIZES.landscape,
{ x: 0, y: 0, scale: 1 },
portraitFigure,
),
).toEqual({
positionV2: {
location: { top: -540, left: -304, width: 608, height: 1080 },
},
})
})
})
......@@ -35,7 +35,7 @@ describe('digital human request builder', () => {
})
})
it('builds the advanced video media and fission contract', () => {
it('builds the supported advanced video contract', () => {
const result = buildDigitalHumanRequest(DIGITAL_HUMAN_TYPES.ADVANCED, {
...common,
figureId: '1081',
......@@ -48,15 +48,8 @@ describe('digital human request builder', () => {
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({
......@@ -68,28 +61,12 @@ describe('digital human request builder', () => {
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('riskTip')
expect(result).not.toHaveProperty('mashupMaterials')
expect(result).not.toHaveProperty('fissionParams')
})
......
......@@ -18,13 +18,13 @@ 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)
saveDigitalHumanTask({ taskId: 'task-1', type: 'basic', status: 'PROCESSING' }, storage)
saveDigitalHumanTask({ taskId: 'task-2', type: 'advanced', status: 'SUCCESS' }, storage)
saveDigitalHumanTask({ taskId: 'task-1', type: 'basic', status: 'FAILED' }, storage)
expect(readDigitalHumanTasks(storage)).toEqual([
{ taskId: 'task-1', status: 'FAILED' },
{ taskId: 'task-2', status: 'SUCCESS' },
{ taskId: 'task-1', type: 'basic', status: 'FAILED' },
{ taskId: 'task-2', type: 'advanced', status: 'SUCCESS' },
])
})
......
import { Collapse } from 'antd'
import { DIGITAL_HUMAN_TYPES } from '@/components/editor/hooks/digitalHumanRequest'
import DigitalHumanAdvancedSettings from './DigitalHumanAdvancedSettings'
import DigitalHumanFigureField from './DigitalHumanFigureField'
import DigitalHumanOutputSizeField from './DigitalHumanOutputSizeField'
import DigitalHumanPreview from './DigitalHumanPreview'
import DigitalHumanVoiceSettings from './DigitalHumanVoiceSettings'
import { digitalHumanCollapseStyles } from './digital-human-ui'
import useAdvancedVideoComposition from './hooks/useAdvancedVideoComposition'
import useDigitalHumanInsertion from './hooks/useDigitalHumanInsertion'
const ignoreLayoutChange = () => {}
export default function DigitalHumanAdvancedPanel({
editor,
digitalHuman,
selectedTaskId,
onTaskClear,
onInserted,
}) {
const composition = useAdvancedVideoComposition({
digitalHuman,
selectedTaskId,
onTaskClear,
})
const insertion = useDigitalHumanInsertion({
editor,
video: composition.previewVideo,
onInserted,
})
const outputSettings = [
{
key: 'voice',
label: '声音输出设置',
children: (
<DigitalHumanVoiceSettings
type={DIGITAL_HUMAN_TYPES.ADVANCED}
femaleVoices={digitalHuman.femaleVoices}
maleVoices={digitalHuman.maleVoices}
value={composition.voice}
onChange={composition.setVoice}
language='auto'
onLanguageChange={ignoreLayoutChange}
speed={composition.speed}
onSpeedChange={composition.setSpeed}
volume={composition.volume}
onVolumeChange={composition.setVolume}
pitch={composition.pitch}
onPitchChange={composition.setPitch}
text={composition.text}
onTextChange={composition.setText}
maxTextLength={2000}
/>
),
},
{
key: 'base',
label: '基础输出设置',
children: (
<DigitalHumanOutputSizeField
value={composition.videoSize}
onChange={composition.setVideoSize}
/>
),
},
{
key: 'advanced',
label: '高级输出设置',
children: (
<div className='output-settings'>
<DigitalHumanAdvancedSettings
templateId={composition.templateId}
onTemplateChange={composition.setTemplate}
title={composition.title}
onTitleChange={composition.setTitle}
logoUrl={composition.logoUrl}
onLogoUrlChange={composition.setLogoUrl}
bgmUrl={composition.bgmUrl}
onBgmUrlChange={composition.setBgmUrl}
materialUrl={composition.materialUrl}
onMaterialUrlChange={composition.setMaterialUrl}
extendBackgroundUrl={composition.extendBackgroundUrl}
onExtendBackgroundUrlChange={composition.setExtendBackgroundUrl}
openingMaterial={composition.openingMaterial}
onOpeningMaterialChange={composition.setOpeningMaterial}
endingMaterial={composition.endingMaterial}
onEndingMaterialChange={composition.setEndingMaterial}
/>
</div>
),
},
]
return (
<div className='component-layout'>
<div className='config-column'>
<DigitalHumanFigureField
figures={digitalHuman.figures}
loading={digitalHuman.loadingFigures}
value={composition.figure}
onChange={composition.setFigure}
/>
<Collapse
ghost
expandIconPlacement='end'
styles={digitalHumanCollapseStyles}
defaultActiveKey={['voice', 'base']}
className='config-collapse output-collapse'
items={outputSettings}
/>
</div>
<DigitalHumanPreview
type={DIGITAL_HUMAN_TYPES.ADVANCED}
videoSize={composition.videoSize}
figure={composition.figure}
templatePreview={composition.selectedTemplate?.preview}
generatedVideo={composition.previewVideo}
revealKey={composition.visibleTaskId}
activeTask={composition.activeVideoTask}
error={digitalHuman.error}
isGenerating={digitalHuman.isGenerating && !!composition.activeVideoTask}
isInserting={insertion.isInserting}
onLayoutChange={ignoreLayoutChange}
onReset={composition.clearResult}
onResume={composition.resume}
onRetry={composition.retry}
onInsert={insertion.insert}
onGenerate={composition.generate}
/>
</div>
)
}
import { Input } from 'antd'
import { CheckOutlined } from '@ant-design/icons'
import { ADVANCED_TEMPLATES } from '@/components/editor/hooks/digitalHumanRequest'
import DigitalHumanBaiduUploadField from './DigitalHumanBaiduUploadField'
import DigitalHumanUploadField from './DigitalHumanUploadField'
export default function DigitalHumanAdvancedSettings({
templateId,
onTemplateChange,
title,
onTitleChange,
logoUrl,
onLogoUrlChange,
bgmUrl,
onBgmUrlChange,
materialUrl,
onMaterialUrlChange,
extendBackgroundUrl,
onExtendBackgroundUrlChange,
openingMaterial,
onOpeningMaterialChange,
endingMaterial,
onEndingMaterialChange,
}) {
return (
<>
<div className='field-label'>视频样式</div>
<div className='template-grid'>
{ADVANCED_TEMPLATES.map((template) => (
<button
type='button'
key={template.value}
className={`template-card ${templateId === template.value ? 'active' : ''}`}
onClick={() => onTemplateChange(template)}
>
<span className={`template-preview ${template.ratio}`}>
<img src={template.preview} alt={`${template.label}效果预览`} />
</span>
<span className='template-meta'>
<strong>{template.label}</strong>
<small>{template.ratio === 'landscape' ? '横屏' : '竖屏'}</small>
</span>
{templateId === template.value ? <CheckOutlined className='template-selected' /> : null}
</button>
))}
</div>
<div className='advanced-field'>
<div className='field-label'>视频标题</div>
<Input
maxLength={30}
showCount
value={title}
onChange={(event) => onTitleChange(event.target.value)}
placeholder='可选,最多 30 个字符'
/>
</div>
<DigitalHumanUploadField
label='Logo(可选)'
value={logoUrl}
onChange={onLogoUrlChange}
accept='.png,.jpg,.jpeg,.bmp'
maxSizeMB={3}
filenamePrefix='digital-human-logo'
previewType='image'
uploadHint='PNG、JPG、JPEG、BMP,不超过 3MB'
/>
<DigitalHumanUploadField
label='背景音乐(可选)'
value={bgmUrl}
onChange={onBgmUrlChange}
accept='.wav,.mp3,.wma,.m4a'
maxSizeMB={120}
filenamePrefix='digital-human-bgm'
hint='支持 WAV、MP3、WMA、M4A,不超过 120MB。'
/>
<DigitalHumanUploadField
label='模板背景素材(可选)'
value={materialUrl}
onChange={onMaterialUrlChange}
accept='.png,.jpg,.jpeg,.bmp,.mp4'
maxImageSizeMB={3}
maxVideoSizeMB={500}
filenamePrefix='digital-human-material'
hint='图片不超过 3MB,MP4 视频不超过 500MB。'
/>
<DigitalHumanUploadField
label='扩展自定义背景(可选)'
value={extendBackgroundUrl}
onChange={onExtendBackgroundUrlChange}
accept='.png,.jpg,.jpeg,.bmp,.mp4'
maxImageSizeMB={3}
maxVideoSizeMB={500}
filenamePrefix='digital-human-extend-background'
hint='横屏输出使用横屏扩展背景、竖屏输出使用竖屏扩展背景;模板和模板背景素材比例与输出相反。'
/>
<DigitalHumanBaiduUploadField
label='片头视频'
value={openingMaterial}
onChange={onOpeningMaterialChange}
accept='.mp4,video/mp4'
hint='可选,仅支持 MP4,不超过 500MB。'
/>
<DigitalHumanBaiduUploadField
label='片尾视频'
value={endingMaterial}
onChange={onEndingMaterialChange}
accept='.mp4,video/mp4'
hint='可选,仅支持 MP4,不超过 500MB。'
/>
</>
)
}
import { useState } from 'react'
import { App, Button, Upload } from 'antd'
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { CheckCircleFilled, DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { uploadDigitalHumanFile } from '@/components/editor/api/xiling'
const PROVIDER_TYPE = 'OPEN_VIDEO_DURATION_VIDEO'
......@@ -30,20 +30,9 @@ function getFileLimit(file) {
return getMediaType(file) === 'IMAGE' ? 3 : 500
}
export default function DigitalHumanBaiduUploadField({
label,
value,
onChange,
accept,
multiple = false,
maxCount = 1,
hint,
}) {
export default function DigitalHumanBaiduUploadField({ label, value, onChange, accept, 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 [uploading, setUploading] = useState(false)
const upload = async ({ file, onSuccess, onError }) => {
if (!matchesAccept(file, accept)) {
......@@ -63,7 +52,7 @@ export default function DigitalHumanBaiduUploadField({
return
}
setPendingUploads((current) => current + 1)
setUploading(true)
try {
const result = await uploadDigitalHumanFile(file, PROVIDER_TYPE)
if (!result?.fileId) throw new Error('上传结果缺少文件编号')
......@@ -72,58 +61,48 @@ export default function DigitalHumanBaiduUploadField({
fileName: result.fileName || file.name,
mediaType: getMediaType(file),
}
onChange(multiple ? (current) => [...(current || []), nextFile].slice(0, maxCount) : nextFile)
onChange(nextFile)
onSuccess(result)
message.success(`${label}上传成功`)
} catch (error) {
message.error(`${label}上传失败`)
onError(error)
} finally {
setPendingUploads((current) => Math.max(0, current - 1))
setUploading(false)
}
}
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 className={`image-upload-card ${value ? 'has-value' : ''}`}>
<Upload
className='image-upload-trigger'
accept={accept}
showUploadList={false}
customRequest={upload}
disabled={uploading}
>
<button type='button' className='image-upload-button' disabled={uploading}>
<span className='image-upload-icon'>
{value ? <CheckCircleFilled /> : <UploadOutlined />}
</span>
<span className='image-upload-copy'>
<strong>{uploading ? '正在上传…' : value ? `已选择${label}` : `上传${label}`}</strong>
<small>{value ? '点击更换视频' : hint}</small>
</span>
</button>
</Upload>
{value ? (
<Button
type='text'
icon={<DeleteOutlined />}
className='image-upload-remove'
aria-label={`清除${label}`}
onClick={() => onChange(null)}
/>
) : null}
</div>
</div>
)
}
import { Collapse } from 'antd'
import { DIGITAL_HUMAN_TYPES } from '@/components/editor/hooks/digitalHumanRequest'
import DigitalHumanBasicSettings from './DigitalHumanBasicSettings'
import DigitalHumanFigureField from './DigitalHumanFigureField'
import DigitalHumanOutputSizeField from './DigitalHumanOutputSizeField'
import DigitalHumanPreview from './DigitalHumanPreview'
import DigitalHumanVoiceSettings from './DigitalHumanVoiceSettings'
import { digitalHumanCollapseStyles } from './digital-human-ui'
import useBasicVideoComposition from './hooks/useBasicVideoComposition'
import useDigitalHumanInsertion from './hooks/useDigitalHumanInsertion'
export default function DigitalHumanBasicPanel({
editor,
digitalHuman,
selectedTaskId,
onTaskClear,
onInserted,
}) {
const composition = useBasicVideoComposition({
digitalHuman,
selectedTaskId,
onTaskClear,
})
const insertion = useDigitalHumanInsertion({
editor,
video: composition.previewVideo,
onInserted,
})
const outputSettings = [
{
key: 'voice',
label: '声音输出设置',
children: (
<DigitalHumanVoiceSettings
type={DIGITAL_HUMAN_TYPES.BASIC}
femaleVoices={digitalHuman.femaleVoices}
maleVoices={digitalHuman.maleVoices}
value={composition.voice}
onChange={composition.setVoice}
language={composition.language}
onLanguageChange={composition.setLanguage}
speed={composition.speed}
onSpeedChange={composition.setSpeed}
volume={composition.volume}
onVolumeChange={composition.setVolume}
pitch={composition.pitch}
onPitchChange={composition.setPitch}
text={composition.text}
onTextChange={composition.setText}
maxTextLength={20000}
/>
),
},
{
key: 'base',
label: '基础输出设置',
children: (
<DigitalHumanOutputSizeField
value={composition.videoSize}
onChange={composition.setVideoSize}
/>
),
},
{
key: 'advanced',
label: '高级输出设置',
children: (
<div className='output-settings'>
<DigitalHumanBasicSettings
backgroundImageUrl={composition.backgroundImageUrl}
onBackgroundImageUrlChange={composition.setBackgroundImageUrl}
/>
</div>
),
},
]
return (
<div className='component-layout'>
<div className='config-column'>
<DigitalHumanFigureField
figures={digitalHuman.figures}
loading={digitalHuman.loadingFigures}
value={composition.figure}
onChange={composition.setFigure}
/>
<Collapse
ghost
expandIconPlacement='end'
styles={digitalHumanCollapseStyles}
defaultActiveKey={['voice', 'base']}
className='config-collapse output-collapse'
items={outputSettings}
/>
</div>
<DigitalHumanPreview
type={DIGITAL_HUMAN_TYPES.BASIC}
videoSize={composition.videoSize}
figure={composition.figure}
backgroundImageUrl={composition.backgroundImageUrl}
generatedVideo={composition.previewVideo}
revealKey={composition.visibleTaskId}
activeTask={composition.activeVideoTask}
error={digitalHuman.error}
isGenerating={digitalHuman.isGenerating && !!composition.activeVideoTask}
isInserting={insertion.isInserting}
onLayoutChange={composition.setPreviewDhParams}
onReset={composition.clearResult}
onResume={composition.resume}
onRetry={composition.retry}
onInsert={insertion.insert}
onGenerate={composition.generate}
/>
</div>
)
}
import DigitalHumanUploadField from './DigitalHumanUploadField'
export default function DigitalHumanBasicSettings({
backgroundImageUrl,
onBackgroundImageUrlChange,
}) {
return (
<DigitalHumanUploadField
label='背景图片(可选)'
value={backgroundImageUrl}
onChange={onBackgroundImageUrlChange}
accept='image/*'
maxSizeMB={3}
filenamePrefix='digital-human-background'
previewType='image'
hint='不上传也可以直接生成视频。'
/>
)
}
import { useMemo, useState } from 'react'
import { Empty, Modal, Select, Space, Spin } from 'antd'
import { CheckOutlined, CopyOutlined, SwapOutlined, UserOutlined } from '@ant-design/icons'
export default function DigitalHumanFigureField({ figures, loading, value, onChange }) {
const [open, setOpen] = useState(false)
const [filters, setFilters] = useState({
gender: 'all',
style: 'all',
pose: 'all',
background: 'all',
})
const filterOptions = useMemo(() => {
const styles = new Set()
const poses = new Set()
const backgrounds = new Set()
figures.forEach((figure) => {
const tags = figure.tags || []
tags.forEach((tag) => {
if (!['视频', '2D'].includes(tag)) styles.add(tag)
})
if (figure.pose) poses.add(figure.pose)
if (figure.background) backgrounds.add(figure.background)
})
return { styles: [...styles], poses: [...poses], backgrounds: [...backgrounds] }
}, [figures])
const filteredFigures = useMemo(
() =>
figures.filter((figure) => {
const gender = String(figure.gender || '').toLowerCase()
const matchesGender =
filters.gender === 'all' ||
gender === filters.gender ||
(filters.gender === 'female' && gender === 'f') ||
(filters.gender === 'male' && gender === 'm')
const matchesStyle = filters.style === 'all' || (figure.tags || []).includes(filters.style)
const matchesPose = filters.pose === 'all' || figure.pose === filters.pose
const matchesBackground =
filters.background === 'all' || figure.background === filters.background
return matchesGender && matchesStyle && matchesPose && matchesBackground
}),
[figures, filters],
)
return (
<>
<section className='config-section figure-section'>
<h2>形象</h2>
<button
type='button'
className='selected-media-card'
onClick={() => setOpen(true)}
disabled={loading && figures.length === 0}
>
<span className='selected-media-thumb'>
{value?.avatar ? <img src={value.avatar} alt={value.name} /> : <UserOutlined />}
</span>
<span className='selected-media-copy'>
<strong>{value?.name || '请选择形象'}</strong>
<span>
ID:{value?.id || '--'} <CopyOutlined />
</span>
</span>
<SwapOutlined className='selected-media-action' />
</button>
</section>
<Modal
open={open}
title='选择形象'
footer={null}
onCancel={() => setOpen(false)}
width={920}
className='digital-human-picker-modal figure-picker-modal'
>
{loading && figures.length === 0 ? (
<div className='picker-loading'>
<Spin description='同步云端形象中...' />
</div>
) : figures.length > 0 ? (
<>
<Space wrap size='middle'>
<Select
value={filters.gender}
popupMatchSelectWidth={false}
options={[
{ label: '全部性别', value: 'all' },
{ label: '女性', value: 'female' },
{ label: '男性', value: 'male' },
]}
onChange={(gender) => setFilters((current) => ({ ...current, gender }))}
/>
<Select
value={filters.style}
popupMatchSelectWidth={false}
options={[
{ label: '全部风格', value: 'all' },
...filterOptions.styles.map((style) => ({ label: style, value: style })),
]}
onChange={(style) => setFilters((current) => ({ ...current, style }))}
/>
<Select
value={filters.pose}
popupMatchSelectWidth={false}
options={[
{ label: '全部姿态&机位', value: 'all' },
...filterOptions.poses.map((pose) => ({ label: pose, value: pose })),
]}
onChange={(pose) => setFilters((current) => ({ ...current, pose }))}
/>
<Select
value={filters.background}
popupMatchSelectWidth={false}
options={[
{ label: '全部背景', value: 'all' },
...filterOptions.backgrounds.map((background) => ({
label: background,
value: background,
})),
]}
onChange={(background) => setFilters((current) => ({ ...current, background }))}
/>
</Space>
<div className='figure-picker-scroll'>
{filteredFigures.length > 0 ? (
<div className='figure-picker-grid'>
{filteredFigures.map((figure) => (
<button
type='button'
key={figure.id}
className={`picker-figure-card ${value?.id === figure.id ? 'active' : ''}`}
onClick={() => {
onChange(figure)
setOpen(false)
}}
>
<span className='picker-avatar'>
{figure.avatar ? (
<img src={figure.avatar} alt={figure.name} />
) : (
<UserOutlined />
)}
</span>
<strong>{figure.name}</strong>
{value?.id === figure.id ? (
<span className='picker-selected-mark'>
<CheckOutlined />
</span>
) : null}
</button>
))}
</div>
) : (
<Empty description='没有符合条件的形象' />
)}
</div>
</>
) : (
<Empty description='暂无可用形象' />
)}
</Modal>
</>
)
}
import { DIGITAL_HUMAN_VIDEO_SIZES } from './digital-human-preview'
const sizes = ['landscape', 'portrait']
export default function DigitalHumanOutputSizeField({ value, onChange }) {
return (
<div className='output-settings'>
<div className='field-label'>输出画面</div>
<div className='choice-grid'>
{sizes.map((size) => (
<button
type='button'
key={size}
className={value === size ? 'active' : ''}
onClick={() => onChange(size)}
>
<span className={`ratio-icon ${size}`} />
<span>{size === 'landscape' ? '16:9' : '9:16'}</span>
<small>
{DIGITAL_HUMAN_VIDEO_SIZES[size].width}*{DIGITAL_HUMAN_VIDEO_SIZES[size].height}
</small>
</button>
))}
</div>
</div>
)
}
import { useState } from 'react'
import { Button, Empty, Modal, Tag } from 'antd'
import { ClockCircleOutlined } from '@ant-design/icons'
import { DIGITAL_HUMAN_TYPE_LABELS } from '@/components/editor/hooks/digitalHumanRequest'
import { digitalHumanTaskStatus } from './digital-human-ui'
export default function DigitalHumanRecentTasks({ tasks, activeTask, onSelect }) {
const [open, setOpen] = useState(false)
return (
<>
<Button icon={<ClockCircleOutlined />} onClick={() => setOpen(true)}>
最近生成{tasks.length ? `(${tasks.length})` : ''}
</Button>
<Modal
open={open}
title='最近生成'
footer={null}
onCancel={() => setOpen(false)}
width={720}
className='digital-human-history-modal'
>
{tasks.length ? (
<div className='task-list'>
{tasks.map((task) => (
<button
type='button'
key={task.taskId}
className={`task-item ${activeTask?.taskId === task.taskId ? 'active' : ''}`}
onClick={() => {
onSelect(task)
setOpen(false)
}}
>
<span className='task-name'>
{DIGITAL_HUMAN_TYPE_LABELS[task.type] || '基础接口'} · {task.figureName || '视频'}
</span>
<span className='task-time'>{new Date(task.createdAt).toLocaleString()}</span>
<Tag color={digitalHumanTaskStatus[task.status]?.color}>
{digitalHumanTaskStatus[task.status]?.text || task.status}
</Tag>
</button>
))}
</div>
) : (
<Empty description='暂无生成记录' />
)}
</Modal>
</>
)
}
import { useState } from 'react'
import { Button, message, Upload } from 'antd'
import { App, Button, Upload } from 'antd'
import { CheckCircleFilled, DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { uploadFile } from '@/utils/oss'
......@@ -43,6 +43,7 @@ export default function DigitalHumanUploadField({
previewType,
filenamePrefix = 'digital-human',
}) {
const { message } = App.useApp()
const [uploading, setUploading] = useState(false)
const displayLabel = label.replace('(可选)', '')
......@@ -79,78 +80,44 @@ export default function DigitalHumanUploadField({
}
}
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>
<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 && previewType === 'image' ? (
<img src={value} alt={`${label}预览`} />
) : (
<span className='image-upload-icon'>
{value ? <CheckCircleFilled /> : <UploadOutlined />}
</span>
)}
<span className='image-upload-copy'>
<strong>
{uploading ? '正在上传…' : value ? `已选择${displayLabel}` : `上传${displayLabel}`}
</strong>
<small>
{value
? `点击更换${previewType === 'image' ? '图片' : '文件'}`
: uploadHint || `文件不能超过 ${maxSizeMB || maxVideoSizeMB}MB`}
</small>
</span>
</button>
</Upload>
{value ? (
<span className='upload-ready'>
<CheckCircleFilled /> 已准备好
</span>
) : null}
{value ? (
<Button
type='text'
size='small'
icon={<DeleteOutlined />}
className='image-upload-remove'
onClick={() => onChange('')}
aria-label={`清除${label}`}
>
清除
</Button>
/>
) : null}
</div>
{value && previewType === 'video' ? (
......
import { useEffect, useRef, useState } from 'react'
import { Button, Input, message, Modal, Select, Slider } from 'antd'
import {
CopyOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
SwapOutlined,
UserOutlined,
} from '@ant-design/icons'
import { DIGITAL_HUMAN_TYPES } from '@/components/editor/hooks/digitalHumanRequest'
const sliderTooltip = { placement: 'top' }
export default function DigitalHumanVoiceSettings({
type,
femaleVoices,
maleVoices,
value,
onChange,
language,
onLanguageChange,
speed,
onSpeedChange,
volume,
onVolumeChange,
pitch,
onPitchChange,
text,
onTextChange,
maxTextLength,
}) {
const audioRef = useRef(null)
const [open, setOpen] = useState(false)
const [gender, setGender] = useState('female')
const [playingVoiceId, setPlayingVoiceId] = useState(null)
useEffect(
() => () => {
audioRef.current?.pause()
},
[],
)
const playVoice = (voice) => {
if (!voice?.previewUrl || !audioRef.current) return
const audio = audioRef.current
if (playingVoiceId === voice.id) {
audio.pause()
setPlayingVoiceId(null)
return
}
audio.src = voice.previewUrl
audio.play().catch(() => {
setPlayingVoiceId(null)
message.error('音色试听失败')
})
setPlayingVoiceId(voice.id)
}
const selectVoice = (voice) => {
onChange(voice)
setOpen(false)
}
const renderSlider = (label, currentValue, handleChange, low, high) => (
<div className='range-setting'>
<div className='range-label'>{label}</div>
<div className='range-control'>
<span className='range-edge'>{low}</span>
<Slider
min={0}
max={type === DIGITAL_HUMAN_TYPES.ADVANCED ? 15 : 10}
value={currentValue}
onChange={handleChange}
tooltip={sliderTooltip}
/>
<span className='range-edge'>{high}</span>
</div>
</div>
)
const voices = gender === 'female' ? femaleVoices : maleVoices
return (
<>
<audio ref={audioRef} onEnded={() => setPlayingVoiceId(null)} />
<div className='voice-output-settings'>
<div className='field-label'>声音</div>
<button
type='button'
className='selected-media-card voice-card'
onClick={() => setOpen(true)}
>
<span className='selected-media-thumb voice-avatar'>
<UserOutlined />
</span>
<span className='selected-media-copy'>
<strong>{value?.name || '请选择声音'}</strong>
<span>
ID:{value?.id || '--'} <CopyOutlined />
</span>
<span className='voice-tags'>
<em>{value?.gender || '女声'}</em>
<em>中文、方言</em>
{value?.style ? <em>{value.style}</em> : null}
</span>
</span>
<SwapOutlined className='selected-media-action' />
</button>
<div className='field-label'>语言</div>
<Select
className='full-control'
value={type === DIGITAL_HUMAN_TYPES.ADVANCED ? 'auto' : language}
onChange={onLanguageChange}
disabled={type === DIGITAL_HUMAN_TYPES.ADVANCED}
options={[
{ label: '自动', value: 'auto' },
{ label: '中文', value: 'Chinese' },
{ label: '方言', value: 'Chinese,Yue' },
]}
/>
{type === DIGITAL_HUMAN_TYPES.ADVANCED ? (
<div className='field-hint'>高级视频模板暂不支持多语言。</div>
) : null}
{renderSlider('语速', speed, onSpeedChange, '慢', '快')}
{renderSlider('音量', volume, onVolumeChange, '小', '大')}
{renderSlider('语调', pitch, onPitchChange, '低', '高')}
<section className='script-section'>
<div className='field-label'>播报文案</div>
<div className='script-box'>
<Input.TextArea
variant='borderless'
placeholder='请输入数字人要播报的内容'
rows={7}
maxLength={maxTextLength}
showCount
value={text}
onChange={(event) => onTextChange(event.target.value)}
/>
</div>
</section>
</div>
<Modal
open={open}
title='选择声音'
footer={null}
onCancel={() => setOpen(false)}
width={620}
className='digital-human-picker-modal voice-picker-modal'
>
<div className='picker-tabs' role='tablist' aria-label='声音性别'>
<button
type='button'
className={gender === 'female' ? 'active' : ''}
onClick={() => setGender('female')}
>
女声
</button>
<button
type='button'
className={gender === 'male' ? 'active' : ''}
onClick={() => setGender('male')}
>
男声
</button>
</div>
<div className='voice-picker-grid'>
<div className='voice-grid'>
{voices.map((voice) => (
<div
role='button'
tabIndex={0}
key={voice.id}
className={`voice-item ${value?.id === voice.id ? 'active' : ''}`}
onClick={() => selectVoice(voice)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectVoice(voice)
}
}}
>
<span className='voice-picker-avatar'>
<UserOutlined />
</span>
<span className='v-info'>
<strong>{voice.name}</strong>
<small>{voice.style}</small>
</span>
<Button
type='text'
shape='circle'
disabled={!voice.previewUrl}
aria-label={voice.previewUrl ? `试听${voice.name}` : `${voice.name}暂无试听`}
icon={
playingVoiceId === voice.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />
}
onClick={(event) => {
event.stopPropagation()
playVoice(voice)
}}
/>
</div>
))}
</div>
</div>
</Modal>
</>
)
}
export const DIGITAL_HUMAN_VIDEO_SIZES = {
portrait: { width: 1080, height: 1920 },
landscape: { width: 1920, height: 1080 },
}
export function getFigureLayerSize(canvasSize, figure, imageAspect = null) {
const figureWidth = Number(figure?.resolutionWidth)
const figureHeight = Number(figure?.resolutionHeight)
const figureRatio =
figureWidth && figureHeight ? figureWidth / figureHeight : imageAspect ? 1 / imageAspect : null
if (!figureRatio) return canvasSize
const canvasRatio = canvasSize.width / canvasSize.height
if (figureRatio < canvasRatio) {
return {
width: Math.max(2, Math.round(canvasSize.height * figureRatio)),
height: canvasSize.height,
}
}
return {
width: canvasSize.width,
height: Math.max(2, Math.round(canvasSize.width / figureRatio)),
}
}
export function buildPreviewDhParams(size, layout, figure, imageAspect = null) {
const layerSize = getFigureLayerSize(size, figure, imageAspect)
const width = Math.max(2, Math.round((layerSize.width * layout.scale) / 2) * 2)
const height = Math.max(2, Math.round((layerSize.height * layout.scale) / 2) * 2)
return {
positionV2: {
location: {
top: Math.round((size.height * layout.y) / 100 - height / 2),
left: Math.round((size.width * layout.x) / 100 - width / 2),
width,
height,
},
},
}
}
export const digitalHumanTaskStatus = {
PROCESSING: { color: 'processing', text: '生成中' },
SUCCESS: { color: 'success', text: '已完成' },
FAILED: { color: 'error', text: '生成失败' },
PAUSED: { color: 'warning', text: '查询已暂停' },
}
export const digitalHumanCollapseStyles = {
header: { padding: '0 0 12px' },
body: { padding: 0 },
}
......@@ -42,7 +42,6 @@
.ant-input:focus,
.ant-input-focused,
.ant-input-number-focused,
.ant-select-focused .ant-select-selector {
border-color: var(--dh-accent) !important;
box-shadow: 0 0 0 2px var(--dh-focus) !important;
......@@ -263,17 +262,16 @@
.config-collapse {
margin: 0 0 24px;
> .ant-collapse-item:not(:last-child) {
margin-bottom: 24px;
}
> .ant-collapse-item > .ant-collapse-header {
align-items: center;
padding: 0 0 12px;
color: var(--dh-ink);
font-size: 16px;
font-weight: 600;
}
> .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box {
padding: 0;
}
}
.field-label {
......@@ -307,9 +305,10 @@
.range-control {
display: grid;
grid-template-columns: 18px minmax(60px, 1fr) 18px 56px;
grid-template-columns: 18px minmax(60px, 1fr) 18px;
align-items: center;
gap: 8px;
padding-top: 14px;
}
.range-edge {
......@@ -321,10 +320,6 @@
.ant-slider {
margin: 4px 6px;
}
.ant-input-number {
width: 56px;
}
}
.script-section {
......@@ -370,10 +365,6 @@
margin-top: 8px;
}
.teacher-upload {
margin-top: 6px;
}
> .advanced-field,
> .media-field {
margin-top: 12px;
......@@ -525,24 +516,6 @@
font-size: 14px;
}
.teacher-upload {
display: flex;
min-height: 40px;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
border: 1px dashed #ddd4d8;
border-radius: 10px;
background: #fff;
&.has-value {
border-style: solid;
border-color: #b7d9c4;
background: #f3faf5;
}
}
.field-hint {
margin-top: 6px;
color: #8f858d;
......@@ -565,6 +538,11 @@
&.has-value {
border-style: solid;
.image-upload-icon {
background: #f6ffed;
color: #52c41a;
}
}
}
......@@ -669,31 +647,6 @@
}
}
.baidu-upload-list {
display: flex;
margin-top: 8px;
flex-direction: column;
gap: 4px;
}
.baidu-upload-item {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 6px 4px 10px;
border-radius: 6px;
background: var(--dh-canvas);
span {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.generation-result {
margin-top: 0;
}
......@@ -898,57 +851,6 @@
font-weight: 600;
}
.task-history {
margin-top: 24px;
margin-bottom: 0;
padding: 16px;
border: 1px solid var(--dh-line);
border-radius: 14px;
}
.task-history .section-title {
margin-bottom: 12px;
font-size: 17px;
font-weight: 650;
}
.task-list {
display: grid;
gap: 8px;
}
.task-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid var(--dh-line);
border-radius: 8px;
background: #fff;
color: inherit;
cursor: pointer;
text-align: left;
&:hover,
&.active {
border-color: var(--dh-accent);
background: var(--dh-accent-soft);
}
}
.task-name {
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time {
color: #8f858d;
font-size: 12px;
}
.customize-layout {
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
......@@ -1046,6 +948,45 @@
}
}
.digital-human-history-modal {
.task-list {
display: grid;
gap: 8px;
}
.task-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--dh-line);
border-radius: 8px;
background: #fff;
color: inherit;
cursor: pointer;
text-align: left;
&:hover,
&.active {
border-color: var(--dh-accent);
background: var(--dh-accent-soft);
}
}
.task-name {
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time {
color: #8f858d;
font-size: 12px;
}
}
.digital-human-picker-modal {
.ant-modal-content {
border-radius: 14px;
......@@ -1290,13 +1231,9 @@
.range-setting {
.range-control {
grid-template-columns: 18px minmax(60px, 1fr) 18px 64px;
grid-template-columns: 18px minmax(60px, 1fr) 18px;
gap: 6px;
}
.ant-input-number {
width: 64px;
}
}
.script-preview-bar {
......@@ -1318,13 +1255,13 @@
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.task-item {
grid-template-columns: minmax(0, 1fr) auto;
.digital-human-history-modal .task-item {
grid-template-columns: minmax(0, 1fr) auto;
.task-time {
display: none;
}
.task-time {
display: none;
}
}
}
......
import { useCallback, useState } from 'react'
import { message } from 'antd'
import {
ADVANCED_TEMPLATES,
DIGITAL_HUMAN_TYPES,
} from '@/components/editor/hooks/digitalHumanRequest'
import { DIGITAL_HUMAN_VIDEO_SIZES } from '../digital-human-preview'
import useDigitalHumanCommonFields from './useDigitalHumanCommonFields'
const noop = () => {}
export default function useAdvancedVideoComposition({
digitalHuman,
selectedTaskId,
onTaskClear = noop,
}) {
const [submittedTaskId, setSubmittedTaskId] = useState(null)
const clearResult = useCallback(() => {
setSubmittedTaskId(null)
onTaskClear()
}, [onTaskClear])
const fields = useDigitalHumanCommonFields({ digitalHuman, onChange: clearResult })
const [templateId, setTemplateId] = useState(ADVANCED_TEMPLATES[0].value)
const [videoSize, setVideoSizeState] = useState(ADVANCED_TEMPLATES[0].ratio)
const [title, setTitleState] = useState('')
const [logoUrl, setLogoUrlState] = useState('')
const [bgmUrl, setBgmUrlState] = useState('')
const [materialUrl, setMaterialUrlState] = useState('')
const [extendBackgroundUrl, setExtendBackgroundUrlState] = useState('')
const [openingMaterial, setOpeningMaterialState] = useState(null)
const [endingMaterial, setEndingMaterialState] = useState(null)
const selectedTemplate = ADVANCED_TEMPLATES.find((item) => item.value === templateId)
const previewSize = DIGITAL_HUMAN_VIDEO_SIZES[videoSize]
const { activeTask, tasks } = digitalHuman
const activeVideoTask = activeTask?.type === DIGITAL_HUMAN_TYPES.ADVANCED ? activeTask : null
const visibleTaskId = selectedTaskId || submittedTaskId
const previewVideo =
tasks.find(
(task) =>
task.type === DIGITAL_HUMAN_TYPES.ADVANCED &&
task.taskId === visibleTaskId &&
task.status === 'SUCCESS' &&
task.url,
) || null
const canGenerate = !!fields.figure && !!fields.voice && !!fields.text.trim() && !!templateId
const update = (setter) => (value) => {
clearResult()
setter(value)
}
const generate = async () => {
if (!canGenerate) {
if (!fields.figure) return message.warning('请先完成:选择 2D 小样本数字人')
if (!templateId) return message.warning('请先完成:选择视频样式')
if (!fields.text.trim()) return message.warning('请先完成:填写讲课内容')
return message.warning('请先完成:确认配音')
}
try {
clearResult()
const taskId = await digitalHuman.generateVideo({
type: DIGITAL_HUMAN_TYPES.ADVANCED,
figureId: fields.figure.id,
figureName: fields.figure.name,
driveType: 'TEXT',
text: fields.text.trim(),
voiceId: fields.voice.id,
speed: fields.speed,
volume: fields.volume,
pitch: fields.pitch,
width: previewSize.width,
height: previewSize.height,
transparent: false,
enableSubtitle: false,
templateId,
title,
logoUrl,
bgmUrl,
materialUrl,
extendBackgroundUrl,
openingFileId: openingMaterial?.fileId,
endingFileId: endingMaterial?.fileId,
})
setSubmittedTaskId(taskId)
} catch {}
}
const retry = async () => {
try {
clearResult()
setSubmittedTaskId(await digitalHuman.retryTask())
} catch (error) {
message.error(error.message || '重新提交失败')
}
}
const resume = () => {
if (!activeVideoTask?.taskId) return
onTaskClear()
setSubmittedTaskId(activeVideoTask.taskId)
digitalHuman.resumeTask(activeVideoTask)
}
return {
...fields,
templateId,
setTemplate: (template) => {
clearResult()
setTemplateId(template.value)
setVideoSizeState(template.ratio)
},
selectedTemplate,
videoSize,
setVideoSize: update(setVideoSizeState),
title,
setTitle: update(setTitleState),
logoUrl,
setLogoUrl: update(setLogoUrlState),
bgmUrl,
setBgmUrl: update(setBgmUrlState),
materialUrl,
setMaterialUrl: update(setMaterialUrlState),
extendBackgroundUrl,
setExtendBackgroundUrl: update(setExtendBackgroundUrlState),
openingMaterial,
setOpeningMaterial: update(setOpeningMaterialState),
endingMaterial,
setEndingMaterial: update(setEndingMaterialState),
visibleTaskId,
previewVideo,
activeVideoTask,
clearResult,
generate,
retry,
resume,
}
}
import { useCallback, useState } from 'react'
import { message } from 'antd'
import { DIGITAL_HUMAN_TYPES } from '@/components/editor/hooks/digitalHumanRequest'
import { DIGITAL_HUMAN_VIDEO_SIZES } from '../digital-human-preview'
import useDigitalHumanCommonFields from './useDigitalHumanCommonFields'
const noop = () => {}
function getFigureVideoSize(figure) {
const width = Number(figure?.resolutionWidth)
const height = Number(figure?.resolutionHeight)
if (!width || !height) return null
return width >= height ? 'landscape' : 'portrait'
}
export default function useBasicVideoComposition({
digitalHuman,
selectedTaskId,
onTaskClear = noop,
}) {
const [submittedTaskId, setSubmittedTaskId] = useState(null)
const clearResult = useCallback(() => {
setSubmittedTaskId(null)
onTaskClear()
}, [onTaskClear])
const fields = useDigitalHumanCommonFields({ digitalHuman, onChange: clearResult })
const [videoSizeOverride, setVideoSizeOverride] = useState(null)
const [language, setLanguageState] = useState('auto')
const [backgroundImageUrl, setBackgroundImageUrlState] = useState('')
const [previewDhParams, setPreviewDhParamsState] = useState(null)
const videoSize = videoSizeOverride || getFigureVideoSize(fields.figure) || 'landscape'
const previewSize = DIGITAL_HUMAN_VIDEO_SIZES[videoSize]
const { activeTask, tasks } = digitalHuman
const activeVideoTask = activeTask?.type === DIGITAL_HUMAN_TYPES.BASIC ? activeTask : null
const visibleTaskId = selectedTaskId || submittedTaskId
const previewVideo =
tasks.find(
(task) =>
task.type === DIGITAL_HUMAN_TYPES.BASIC &&
task.taskId === visibleTaskId &&
task.status === 'SUCCESS' &&
task.url,
) || null
const canGenerate = !!fields.figure && !!fields.voice && !!fields.text.trim()
const update = (setter) => (value) => {
clearResult()
setter(value)
}
const setPreviewDhParams = useCallback(
(value) => {
clearResult()
setPreviewDhParamsState(value)
},
[clearResult],
)
const generate = async () => {
if (!canGenerate) {
if (!fields.figure) return message.warning('请先完成:选择 2D 小样本数字人')
if (!fields.text.trim()) return message.warning('请先完成:填写讲课内容')
return message.warning('请先完成:确认配音')
}
try {
clearResult()
const taskId = await digitalHuman.generateVideo({
type: DIGITAL_HUMAN_TYPES.BASIC,
figureId: fields.figure.id,
figureName: fields.figure.name,
driveType: 'TEXT',
text: fields.text.trim(),
voiceId: fields.voice.id,
speed: fields.speed,
volume: fields.volume,
pitch: fields.pitch,
language: language === 'auto' ? undefined : language,
width: previewSize.width,
height: previewSize.height,
transparent: false,
dhParams: previewDhParams,
enableSubtitle: false,
backgroundImageUrl,
})
setSubmittedTaskId(taskId)
} catch {}
}
const retry = async () => {
try {
clearResult()
setSubmittedTaskId(await digitalHuman.retryTask())
} catch (error) {
message.error(error.message || '重新提交失败')
}
}
const resume = () => {
if (!activeVideoTask?.taskId) return
onTaskClear()
setSubmittedTaskId(activeVideoTask.taskId)
digitalHuman.resumeTask(activeVideoTask)
}
return {
...fields,
setFigure: (figure) => {
setVideoSizeOverride(null)
fields.setFigure(figure)
},
videoSize,
setVideoSize: update(setVideoSizeOverride),
language,
setLanguage: update(setLanguageState),
backgroundImageUrl,
setBackgroundImageUrl: update(setBackgroundImageUrlState),
setPreviewDhParams,
visibleTaskId,
previewVideo,
activeVideoTask,
clearResult,
generate,
retry,
resume,
}
}
import { useState } from 'react'
const noop = () => {}
export default function useDigitalHumanCommonFields({ digitalHuman, onChange = noop }) {
const [figureId, setFigureId] = useState(null)
const [voiceId, setVoiceId] = useState(null)
const [text, setTextState] = useState('')
const [speed, setSpeedState] = useState(5)
const [volume, setVolumeState] = useState(5)
const [pitch, setPitchState] = useState(5)
const { figures, femaleVoices, maleVoices } = digitalHuman
const voices = [...femaleVoices, ...maleVoices]
const figure = figures.find((item) => String(item.id) === String(figureId)) || figures[0] || null
const voice =
voices.find((item) => String(item.id) === String(voiceId)) || femaleVoices[0] || null
const update = (setter) => (value) => {
onChange()
setter(value)
}
return {
figure,
setFigure: (value) => {
onChange()
setFigureId(value?.id ?? null)
},
voice,
setVoice: (value) => {
onChange()
setVoiceId(value?.id ?? null)
},
text,
setText: update(setTextState),
speed,
setSpeed: update(setSpeedState),
volume,
setVolume: update(setVolumeState),
pitch,
setPitch: update(setPitchState),
}
}
import { useState } from 'react'
import { message } from 'antd'
import { SlateTransforms } from '@wangeditor/editor'
import { uploadFileByUrl } from '@/utils/oss'
import {
insertBlockNodesAndFocus,
removeSelectedEmptyParagraph,
} from '../../../shared/editor-operations'
export default function useDigitalHumanInsertion({ editor, video, onInserted }) {
const [isInserting, setIsInserting] = useState(false)
const insert = async () => {
if (!video) return
setIsInserting(true)
try {
message.loading({ content: '处理中...', key: 'inserting' })
const ossUrl = await uploadFileByUrl(video.url)
if (editor) {
editor.restoreSelection()
removeSelectedEmptyParagraph(editor, SlateTransforms)
insertBlockNodesAndFocus(editor, SlateTransforms, {
type: 'chapterVideo',
videoUrl: ossUrl,
title: `${video.figureName} - AI播报`,
children: [{ text: '' }],
})
message.success({ content: '已插入文章', key: 'inserting' })
onInserted?.()
}
} catch {
message.error({ content: '插入失败', key: 'inserting' })
} finally {
setIsInserting(false)
}
}
return { isInserting, insert }
}
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论