真人/虚拟人像库app build
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user