真人素材库本地完成

This commit is contained in:
2026-07-06 15:13:41 +08:00
parent 75d1f7a428
commit ab67bdf19e
52 changed files with 4131 additions and 137 deletions
+91 -5
View File
@@ -30,6 +30,8 @@ import {
} from '../api';
import { useAppStore } from '../store/useAppStore';
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
import type { PrivatePortraitSelectableAsset } from '../types';
import {
PlusOutlined,
@@ -71,6 +73,9 @@ interface MediaReference {
duration?: number;
role?: string;
label?: string;
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
}
interface Message {
@@ -167,6 +172,7 @@ const AIChatPage: React.FC = () => {
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
const [mediaStackHovered, setMediaStackHovered] = useState(false);
const mediaStackCloseTimerRef = useRef<number | null>(null);
const openMediaStackTray = useCallback(() => {
@@ -1298,6 +1304,41 @@ const AIChatPage: React.FC = () => {
};
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
if (mediaType !== 'video') {
message.warning('真人素材库第一版仅支持视频创作参考');
return;
}
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
const available = Math.max(0, maxImage - imageCount);
if (assets.length > available) {
message.warning(`当前引擎最多还能添加 ${available} 张图片参考`);
return;
}
const added: MediaReference[] = assets.map((asset) => ({
name: asset.name || '真人素材',
type: 'image',
url: asset.previewUrl || '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
label: '',
}));
const newList = [...currentMedia, ...added];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
message.success(`已添加 ${assets.length} 个真人素材参考`);
};
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
return url;
}
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const handleRemoveMedia = (index: number) => {
const newList = currentMedia.filter((_, i) => i !== index);
const labels = generateMediaLabels(newList);
@@ -2407,6 +2448,15 @@ const AIChatPage: React.FC = () => {
</Tooltip>
</Upload>
)}
{mediaType === 'video' && currentMedia.length === 0 && (
<Button
size="small"
onClick={() => setPrivateAssetPickerOpen(true)}
style={{ marginTop: 8, borderRadius: 8, color: '#8b5cf6', borderColor: '#ddd6fe', background: '#fff' }}
>
</Button>
)}
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
{currentMedia.length > 0 && (
@@ -2432,7 +2482,7 @@ const AIChatPage: React.FC = () => {
<div style={{ position: 'relative', width: 52, height: 60 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
src={buildPreviewUrl(media.url)}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
@@ -2444,7 +2494,7 @@ const AIChatPage: React.FC = () => {
/>
) : media.type === 'video' ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
src={buildPreviewUrl(media.url)}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
@@ -2462,7 +2512,7 @@ const AIChatPage: React.FC = () => {
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? (
{playingAudioUrl === (buildPreviewUrl(media.url)) ? (
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
@@ -2553,6 +2603,34 @@ const AIChatPage: React.FC = () => {
</Tooltip>
</Upload>
)}
{mediaType === 'video' && currentMedia.length > 0 && (
<Tooltip title="选择真人素材库">
<div
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
style={{
position: 'absolute',
right: -32,
bottom: 0,
width: 28,
height: 28,
borderRadius: 50,
background: '#fff',
border: '1px solid #ddd6fe',
color: '#8b5cf6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
fontWeight: 800,
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
zIndex: 100,
}}
>
</div>
</Tooltip>
)}
</div>
)}
</>
@@ -3906,20 +3984,28 @@ const AIChatPage: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{attachmentPreviewType === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
src={buildPreviewUrl(attachmentPreviewUrl)}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={attachmentPreviewVideoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
src={buildPreviewUrl(attachmentPreviewUrl)}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
<PrivatePortraitAssetPicker
open={privateAssetPickerOpen}
onClose={() => setPrivateAssetPickerOpen(false)}
onSelect={handlePrivatePortraitAssetsSelected}
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
maxCount={maxImage}
/>
</Layout>
);
};