真人/虚拟人像库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>