真人/虚拟人像库app build

This commit is contained in:
2026-07-07 15:18:52 +08:00
parent 6026ec8670
commit 98e8fe145f
15 changed files with 648 additions and 615 deletions
+131 -28
View File
@@ -33,7 +33,7 @@ import {
import { useAppStore } from '../store/useAppStore';
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
import type { PrivatePortraitSelectableAsset } from '../types';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import {
PlusOutlined,
@@ -175,6 +175,7 @@ const AIChatPage: React.FC = () => {
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
const [privateAssetPickerLibraryType, setPrivateAssetPickerLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const [mediaStackHovered, setMediaStackHovered] = useState(false);
const mediaStackCloseTimerRef = useRef<number | null>(null);
const openMediaStackTray = useCallback(() => {
@@ -1374,17 +1375,29 @@ const AIChatPage: React.FC = () => {
}
}
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const pendingMedia: MediaReference = {
name: file.name,
type: mediaType,
url: '',
label: '',
...(isVideo && { duration: videoDuration }),
...(isAudio && { duration: audioDuration }),
};
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
if (!validateMediaReferencesBeforeAdd(latestMedia, [pendingMedia])) {
return false;
}
try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
const res = await uploadFn(file);
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
return {
name: file.name,
type: mediaType,
name: pendingMedia.name,
type: pendingMedia.type,
url: res.url,
label: '',
...(isVideo && { duration: videoDuration }),
...(isAudio && { duration: audioDuration }),
label: pendingMedia.label || '',
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
};
} catch (error) {
message.error('上传失败');
@@ -1419,29 +1432,118 @@ const AIChatPage: React.FC = () => {
}
};
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
if (mediaType !== 'video') {
message.warning('真人素材库第一版仅支持视频创作参考');
return;
const getMediaDurationTotal = (items: MediaReference[], type: 'video' | 'audio') => {
return items
.filter((m) => m.type === type)
.reduce((sum, m) => sum + (Number(m.duration) || 0), 0);
};
const validateMediaReferencesBeforeAdd = (baseMedia: MediaReference[], incoming: MediaReference[]) => {
if (!incoming.length) return false;
if (mediaType === 'image' && incoming.some((item) => item.type !== 'image')) {
message.error('图片模式仅支持添加图片素材');
return false;
}
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
const available = Math.max(0, maxImage - imageCount);
if (assets.length > available) {
message.warning(`当前引擎最多还能添加 ${available} 张图片参考`);
return;
if (incoming.some((item) => item.type === 'audio') && mediaType !== 'video') {
message.error('仅视频模式支持添加音频素材');
return false;
}
const added: MediaReference[] = assets.map((asset) => ({
name: asset.name || '真人素材',
type: 'image',
url: asset.previewUrl || '',
const imageCount = baseMedia.filter((m) => m.type === 'image').length;
const videoCount = baseMedia.filter((m) => m.type === 'video').length;
const audioCount = baseMedia.filter((m) => m.type === 'audio').length;
const incomingImageCount = incoming.filter((m) => m.type === 'image').length;
const incomingVideoCount = incoming.filter((m) => m.type === 'video').length;
const incomingAudioCount = incoming.filter((m) => m.type === 'audio').length;
if (imageCount + incomingImageCount > maxImage) {
message.error(`该引擎最多上传${maxImage}张图片,当前还能添加 ${Math.max(0, maxImage - imageCount)}`);
return false;
}
if (videoCount + incomingVideoCount > maxVideo) {
message.error(`该引擎最多上传${maxVideo}个视频,当前还能添加 ${Math.max(0, maxVideo - videoCount)}`);
return false;
}
if (audioCount + incomingAudioCount > maxAudio) {
message.error(`该引擎最多上传${maxAudio}个音频,当前还能添加 ${Math.max(0, maxAudio - audioCount)}`);
return false;
}
for (const item of incoming) {
if (item.type !== 'video') continue;
const duration = Number(item.duration);
if (!Number.isFinite(duration) || duration <= 0) {
message.error(`${item.name || '视频素材'}缺少视频秒数,不能用于 AI 创作`);
return false;
}
if (duration < 2) {
message.error(`${item.name || '视频素材'}最短不能少于 2 秒`);
return false;
}
if (duration > 15) {
message.error(`${item.name || '视频素材'}最长不能超过 15 秒`);
return false;
}
}
const totalVideoDuration = getMediaDurationTotal(baseMedia, 'video') + getMediaDurationTotal(incoming, 'video');
if (totalVideoDuration > 15) {
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${totalVideoDuration.toFixed(1)}`);
return false;
}
const totalAudioDuration = getMediaDurationTotal(baseMedia, 'audio') + getMediaDurationTotal(incoming, 'audio');
if (totalAudioDuration > 15) {
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${totalAudioDuration.toFixed(1)}`);
return false;
}
return true;
};
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
if (asset.assetType === 'Audio') {
message.error('音频私域素材暂不支持用于 AI 创作');
return null;
}
const refType: 'image' | 'video' = asset.assetType === 'Video' ? 'video' : 'image';
const fallbackName = privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟素材' : '真人素材';
const previewUrl = refType === 'video'
? (asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.providerUrl || '')
: (asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '');
return {
name: asset.name || fallbackName,
type: refType,
url: previewUrl,
source: 'private_portrait_asset',
private_asset_id: asset.id,
label: '',
}));
const newList = [...currentMedia, ...added];
...(refType === 'video' && { duration: Number(asset.videoDuration) || undefined }),
};
};
const openPrivatePortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
setPrivateAssetPickerLibraryType(libraryType);
setPrivateAssetPickerOpen(true);
};
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
const normalized = assets
.map(normalizePrivatePortraitAsset)
.filter(Boolean) as MediaReference[];
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
return;
}
const newList = [...latestMedia, ...normalized];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
message.success(`已添加 ${assets.length}真人素材参考`);
message.success(`已添加 ${normalized.length}${privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材参考`);
};
const buildPreviewUrl = (url: string) => {
@@ -2520,8 +2622,8 @@ const AIChatPage: React.FC = () => {
setCurrentMedia([...currentMedia, ...newMedia]);
message.success(`成功添加${items.length}个历史记录`);
}}
onPortraitSelect={(items) => {
handlePrivatePortraitAssetsSelected(items as any);
onPortraitLibrarySelect={(libraryType) => {
openPrivatePortraitPicker(libraryType);
}}
uploading={uploading}
tooltipTitle={mediaType === 'image'
@@ -2700,8 +2802,8 @@ const AIChatPage: React.FC = () => {
setCurrentMedia([...currentMedia, ...newMedia]);
message.success(`成功添加${items.length}个历史记录`);
}}
onPortraitSelect={(items) => {
handlePrivatePortraitAssetsSelected(items as any);
onPortraitLibrarySelect={(libraryType) => {
openPrivatePortraitPicker(libraryType);
}}
uploading={uploading}
tooltipTitle={mediaType === 'image'
@@ -4155,10 +4257,11 @@ const AIChatPage: React.FC = () => {
</Modal>
<PrivatePortraitAssetPicker
open={privateAssetPickerOpen}
libraryType={privateAssetPickerLibraryType}
onClose={() => setPrivateAssetPickerOpen(false)}
onSelect={handlePrivatePortraitAssetsSelected}
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
maxCount={maxImage}
maxCount={Math.max(1, maxImage + maxVideo)}
/>
</Layout>
@@ -50,6 +50,9 @@ const { Text, Title, Paragraph } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined;
const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15;
const statusConfig: Record<string, { label: string; color: string }> = {
creating: { label: '本地创建中', color: 'processing' },
Processing: { label: '火山处理中', color: 'processing' },
@@ -95,6 +98,9 @@ const buildPreviewUrl = (url?: string | null) => {
};
const getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
if (asset.assetType === 'Video') {
return buildPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.remoteUrl || asset.sourceUrl);
}
return buildPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.remoteUrl || asset.sourceUrl);
};
@@ -128,6 +134,22 @@ const getVideoDuration = (file: File): Promise<number | null> => {
});
};
const validatePrivateVideoDuration = (duration: number | null, showMessage: (content: string) => void): duration is number => {
if (duration == null || !Number.isFinite(duration) || duration <= 0) {
showMessage('无法读取视频秒数,请检查视频文件是否损坏');
return false;
}
if (duration < MIN_PRIVATE_VIDEO_DURATION) {
showMessage(`视频素材最短不能少于 ${MIN_PRIVATE_VIDEO_DURATION}`);
return false;
}
if (duration > MAX_PRIVATE_VIDEO_DURATION) {
showMessage(`视频素材最长不能超过 ${MAX_PRIVATE_VIDEO_DURATION}`);
return false;
}
return true;
};
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const value = status || '-';
const config = statusConfig[value];
@@ -265,10 +287,18 @@ const PrivatePortraitVirtualMaterialPage: React.FC = () => {
return;
}
const currentType = guessAssetType(file);
if (currentType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
return;
}
setUploading(true);
try {
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
return;
}
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
await createPrivatePortraitVirtualAsset(selectedProjectId, {
url: uploaded.url,
assetType: currentType,
@@ -578,7 +608,7 @@ const PrivatePortraitVirtualMaterialPage: React.FC = () => {
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
CreateAsset Active AI
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
</div>
</Space>
</Modal>