真人/虚拟人像库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
+16 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
@@ -13,6 +13,9 @@ from app.enums.private_portrait import (
)
from app.schemas.common import NaiveDatetimeOptional
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
class PrivatePortraitEnumItem(BaseModel):
value: str
@@ -160,6 +163,18 @@ class PrivatePortraitAssetCreate(BaseModel):
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
return value
@model_validator(mode="after")
def validate_video_duration(self) -> "PrivatePortraitAssetCreate":
if self.asset_type == PrivatePortraitAssetType.VIDEO.value:
duration = self.video_duration
if duration is None:
raise ValueError("Video 素材必须提供 video_duration")
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}")
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
raise ValueError(f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS}")
return self
class PrivatePortraitAssetOut(BaseModel):
id: str
@@ -46,6 +46,9 @@ from app.utils.id_gen import generate_id
DOMAIN = "private_portrait"
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
def _json(data: Any) -> str | None:
if data is None:
@@ -118,6 +121,18 @@ def _assert_enabled_asset_type(asset_type: str) -> None:
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video")
def _assert_private_asset_video_duration(payload: PrivatePortraitAssetCreate) -> None:
if payload.asset_type != PrivatePortraitAssetType.VIDEO.value:
return
duration = payload.video_duration
if duration is None:
raise HTTPException(status_code=400, detail="Video 素材必须提供 video_duration")
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
raise HTTPException(status_code=400, detail=f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS}")
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
raise HTTPException(status_code=400, detail=f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS}")
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
return PrivatePortraitValidateSessionOut(
id=session.id,
@@ -402,6 +417,7 @@ async def create_asset(
library_type: str | None = None,
) -> PrivatePortraitAsset:
_assert_enabled_asset_type(payload.asset_type)
_assert_private_asset_video_duration(payload)
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
@@ -90,6 +90,8 @@ def _fill_private_portrait_reference_display_fields(ref: Any, asset: PrivatePort
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
if expected_ref_type:
_ref_set(ref, "type", expected_ref_type)
if asset.asset_type == PrivatePortraitAssetType.VIDEO.value and asset.video_duration is not None:
_ref_set(ref, "duration", asset.video_duration)
if not _ref_get(ref, "name") and asset.name:
_ref_set(ref, "name", asset.name)
@@ -293,6 +295,10 @@ async def resolve_private_portrait_references(
_ref_set(ref, "preview_url", display_url)
if expected_ref_type:
_ref_set(ref, "type", expected_ref_type)
if asset.asset_type == PrivatePortraitAssetType.VIDEO.value:
if asset.video_duration is None:
raise HTTPException(status_code=400, detail="私域视频素材缺少 video_duration,不能用于生成")
_ref_set(ref, "duration", asset.video_duration)
if not _ref_get(ref, "name") and asset.name:
_ref_set(ref, "name", asset.name)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DjHXCPu7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
<script type="module" crossorigin src="/assets/index-Cap-_mpH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head>
<body>
<div id="root"></div>
+53 -387
View File
@@ -1,17 +1,18 @@
import React, { useRef, useState, useEffect } from 'react';
import { Modal, Tooltip, Empty, Input, List, Spin, Tag, Typography, message } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, CheckOutlined, ReloadOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../api';
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../types';
const { Text } = Typography;
import React, { useRef, useState } from 'react';
import { Modal, Tooltip } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
interface UploadSelectorProps {
children: React.ReactNode;
accept?: string;
onLocalSelect?: (files: File[]) => void;
onHistorySelect?: (items: any[]) => void;
onPortraitSelect?: (items: any[]) => void;
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
uploading?: boolean;
tooltipTitle?: string;
}
@@ -22,10 +23,15 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
onLocalSelect,
onHistorySelect,
onPortraitSelect,
onPortraitLibrarySelect,
uploading,
tooltipTitle,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [modalVisible, setModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const handleLocalSelect = () => {
fileInputRef.current?.click();
@@ -41,115 +47,25 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
}
};
const [modalVisible, setModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [portraitModalVisible, setPortraitModalVisible] = useState(false);
const handleClick = () => {
if (!uploading) {
setModalVisible(true);
}
};
const mockHistoryData = [];
const [selectedHistoryItems, setSelectedHistoryItems] = useState<number[]>([]);
const [selectedPortraitItems, setSelectedPortraitItems] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
const [historyActiveTab, setHistoryActiveTab] = useState<'asset' | 'history'>('asset');
const [portraitProjects, setPortraitProjects] = useState<PrivatePortraitProject[]>([]);
const [portraitProjectId, setPortraitProjectId] = useState<string | undefined>();
const [portraitKeyword, setPortraitKeyword] = useState('');
const [portraitAssets, setPortraitAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [loadingPortraitProjects, setLoadingPortraitProjects] = useState(false);
const [loadingPortraitAssets, setLoadingPortraitAssets] = useState(false);
const getPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
return url;
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
setModalVisible(false);
if (onPortraitLibrarySelect) {
onPortraitLibrarySelect(libraryType);
return;
}
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const loadPortraitProjects = async () => {
setLoadingPortraitProjects(true);
try {
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
setPortraitProjects(res.items || []);
if (!portraitProjectId && res.items?.length) {
setPortraitProjectId(res.items[0].id);
}
} catch (err: any) {
message.error(err?.message || '加载真人素材项目失败');
} finally {
setLoadingPortraitProjects(false);
}
};
const loadPortraitAssets = async () => {
setLoadingPortraitAssets(true);
try {
const res = await getPrivatePortraitSelectableAssets({ projectId: portraitProjectId, keyword: portraitKeyword.trim() || undefined, page: 1, pageSize: 100 });
setPortraitAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载真人素材失败');
} finally {
setLoadingPortraitAssets(false);
}
};
useEffect(() => {
if (!portraitModalVisible) return;
setSelectedPortraitItems(new Map());
loadPortraitProjects();
}, [portraitModalVisible]);
useEffect(() => {
if (!portraitModalVisible) return;
loadPortraitAssets();
}, [portraitModalVisible, portraitProjectId]);
const togglePortraitAsset = (asset: PrivatePortraitSelectableAsset) => {
setSelectedPortraitItems((prev) => {
const next = new Map(prev);
if (next.has(asset.id)) {
next.delete(asset.id);
} else {
next.set(asset.id, asset);
}
return next;
});
};
const toggleHistoryItem = (id: number) => {
setSelectedHistoryItems(prev =>
prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
);
setPortraitLibraryType(libraryType);
setPortraitPickerOpen(true);
};
const confirmHistorySelection = () => {
const items = mockHistoryData.filter(item => selectedHistoryItems.includes(item.id));
onHistorySelect?.(items);
onHistorySelect?.([]);
setHistoryModalVisible(false);
setSelectedHistoryItems([]);
setModalVisible(false);
};
const confirmPortraitSelection = () => {
const selected = Array.from(selectedPortraitItems.values());
if (!selected.length) {
message.warning('请选择至少一个真人素材');
return;
}
const transformedItems = selected.map(item => ({
...item,
avatar: getPreviewUrl(item.previewUrl),
}));
onPortraitSelect?.(transformedItems);
setPortraitModalVisible(false);
setSelectedPortraitItems(new Map());
setModalVisible(false);
};
@@ -165,14 +81,18 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
},
},
{
key: 'portrait',
label: '人像',
key: 'real_person',
label: '真人素材',
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
description: '从人像库中选择',
onClick: () => {
setModalVisible(false);
setPortraitModalVisible(true);
},
description: '从真人私域素材库中选择',
onClick: () => openPortraitPicker('real_person'),
},
{
key: 'aigc_virtual',
label: '虚拟素材',
icon: <TeamOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />,
description: '从虚拟私域素材库中选择',
onClick: () => openPortraitPicker('aigc_virtual'),
},
{
key: 'local',
@@ -198,12 +118,12 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
/>
{tooltipTitle ? (
<Tooltip title={tooltipTitle}>
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
</Tooltip>
) : (
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
)}
@@ -275,149 +195,20 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
<Modal
title="选择资产素材"
open={historyModalVisible}
onCancel={() => {
setHistoryModalVisible(false);
setSelectedHistoryItems([]);
}}
onCancel={() => setHistoryModalVisible(false)}
footer={null}
width={"80%"}
height={"50%"}
width="80%"
centered
destroyOnHidden
>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div
onClick={() => setHistoryActiveTab('asset')}
style={{
padding: '6px 16px',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
fontWeight: historyActiveTab === 'asset' ? 600 : 500,
color: historyActiveTab === 'asset' ? '#fff' : '#64748b',
background: historyActiveTab === 'asset' ? '#6366f1' : '#f1f5f9',
transition: 'all 0.2s ease',
}}
>
</div>
<div
onClick={() => setHistoryActiveTab('history')}
style={{
padding: '6px 16px',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
fontWeight: historyActiveTab === 'history' ? 600 : 500,
color: historyActiveTab === 'history' ? '#fff' : '#64748b',
background: historyActiveTab === 'history' ? '#6366f1' : '#f1f5f9',
transition: 'all 0.2s ease',
}}
>
</div>
</div>
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
<div style={{ fontSize: 14, color: '#64748b' }}>
<span style={{ color: '#ef4444', fontWeight: 600 }}>{selectedHistoryItems.length}</span>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={() => {
setHistoryModalVisible(false);
setSelectedHistoryItems([]);
}}
style={{
padding: '8px 24px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 14,
color: '#64748b',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#cbd5e1';
e.currentTarget.style.background = '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.background = '#fff';
}}
>
</button>
<button
onClick={confirmHistorySelection}
style={{
padding: '8px 24px',
borderRadius: 8,
border: 'none',
background: '#ef4444',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ef4444';
}}
>
</button>
</div>
</div>
</Modal>
<Modal
title="选择真人素材库"
open={portraitModalVisible}
onCancel={() => {
setPortraitModalVisible(false);
setSelectedPortraitItems(new Map());
}}
width={920}
centered
footer={[
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
<button
key="cancel"
onClick={() => {
setPortraitModalVisible(false);
setSelectedPortraitItems(new Map());
}}
style={{
padding: '8px 24px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 14,
color: '#64748b',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#cbd5e1';
e.currentTarget.style.background = '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.background = '#fff';
}}
></button>,
<button
key="ok"
onClick={confirmPortraitSelection}
onClick={confirmHistorySelection}
style={{
padding: '8px 24px',
borderRadius: 8,
@@ -429,148 +220,23 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
fontWeight: 600,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#7c3aed';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#8b5cf6';
}}
>
{selectedPortraitItems.size}
</button>,
]}
>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12, alignItems: 'center' }}>
<Text strong></Text>
<button
onClick={loadPortraitProjects}
disabled={loadingPortraitProjects}
style={{
padding: '4px 8px',
borderRadius: 6,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 12,
color: '#64748b',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ReloadOutlined style={{ fontSize: 12 }} />
</button>
</div>
<Spin spinning={loadingPortraitProjects}>
<List
dataSource={portraitProjects}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
renderItem={(item) => (
<List.Item
onClick={() => setPortraitProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: portraitProjectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: portraitProjectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Active {item.activeAssetCount || 0}</Text>
</div>
</List.Item>
)}
/>
</Spin>
</div>
<div>
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索素材名称"
value={portraitKeyword}
onChange={(e) => setPortraitKeyword(e.target.value)}
onPressEnter={loadPortraitAssets}
style={{ flex: 1 }}
/>
<button
onClick={loadPortraitAssets}
disabled={loadingPortraitAssets}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 14,
color: '#64748b',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ReloadOutlined />
</button>
</div>
<Spin spinning={loadingPortraitAssets}>
{portraitAssets.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可选 Active 真人素材" style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
{portraitAssets.map((asset) => {
const active = selectedPortraitItems.has(asset.id);
return (
<div
key={asset.id}
onClick={() => togglePortraitAsset(asset)}
style={{
cursor: 'pointer',
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
borderRadius: 12,
overflow: 'hidden',
background: '#fff',
boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)',
position: 'relative',
}}
>
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{asset.previewUrl ? (
<img src={getPreviewUrl(asset.previewUrl)} alt={asset.name || '真人素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
</div>
{active && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
<div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
<Tag color="green">Active</Tag>
<Tag>{asset.projectName}</Tag>
</div>
</div>
</div>
);
})}
</div>
)}
</Spin>
</div>
</button>
</div>
</Modal>
<PrivatePortraitAssetPicker
open={portraitPickerOpen}
libraryType={portraitLibraryType}
onClose={() => setPortraitPickerOpen(false)}
onSelect={(assets) => {
onPortraitSelect?.(assets);
setPortraitPickerOpen(false);
}}
/>
</>
);
};
export default UploadSelector;
export default UploadSelector;
@@ -1,6 +1,6 @@
import React from 'react';
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { DeleteOutlined, PictureOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset } from '../../../types';
const statusColor: Record<string, string> = {
@@ -21,37 +21,70 @@ interface Props {
const buildPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return `${base}${url}`;
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) return url;
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const getAssetPreviewUrl = (item: PrivatePortraitAsset) => {
if (item.assetType === 'Video') {
return buildPreviewUrl(item.videoCoverUrl || item.previewUrl || item.displayUrl || item.remoteUrl || item.sourceUrl);
}
return buildPreviewUrl(item.previewUrl || item.displayUrl || item.videoCoverUrl || item.remoteUrl || item.sourceUrl);
};
const formatDuration = (value?: number | null) => {
const duration = Number(value || 0);
if (!Number.isFinite(duration) || duration <= 0) return '-';
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
};
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
if (!items.length) return <Empty description="暂无真人素材" />;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
{items.map((item) => (
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.previewUrl || item.remoteUrl ? (
<img src={buildPreviewUrl(item.previewUrl || item.remoteUrl)} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : <span style={{ color: '#94a3b8' }}></span>}
{items.map((item) => {
const isVideo = item.assetType === 'Video';
const previewUrl = getAssetPreviewUrl(item);
return (
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{previewUrl ? (
isVideo ? (
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 8, bottom: 8, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(item.videoDuration)}
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Tooltip title={item.name || item.remoteAssetId}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div>
</Tooltip>
<Space wrap size={4} style={{ marginTop: 8 }}>
<Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</Space>
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
<Space style={{ marginTop: 10 }} size={6}>
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}></Button>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
<div style={{ padding: 10 }}>
<Tooltip title={item.name || item.remoteAssetId}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div>
</Tooltip>
<div style={{ marginTop: 8 }}><Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag></div>
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
<Space style={{ marginTop: 10 }} size={6}>
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}></Button>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
))}
);
})}
</div>
);
};
@@ -1,8 +1,11 @@
import React, { useState } from 'react';
import { Button, Input, Modal, Upload, message } from 'antd';
import { Button, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadImage } from '../../../api';
import { createPrivatePortraitAsset, uploadImage, uploadVideo } from '../../../api';
const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15;
interface Props {
projectId: string;
@@ -11,24 +14,92 @@ interface Props {
onSuccess: () => void;
}
const guessAssetType = (file?: File | null): 'Image' | 'Video' => {
if (!file) return 'Image';
if (file.type.startsWith('video/')) return 'Video';
const name = file.name.toLowerCase();
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(name)) return 'Video';
return 'Image';
};
const getVideoDuration = (file: File): Promise<number | null> => {
return new Promise((resolve) => {
if (!file.type.startsWith('video/')) {
resolve(null);
return;
}
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
const duration = Number.isFinite(video.duration) ? video.duration : null;
URL.revokeObjectURL(url);
resolve(duration);
};
video.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
video.src = url;
});
};
const validatePrivateVideoDuration = (duration: number | null): duration is number => {
if (duration == null || !Number.isFinite(duration) || duration <= 0) {
message.error('无法读取视频秒数,请检查视频文件是否损坏');
return false;
}
if (duration < MIN_PRIVATE_VIDEO_DURATION) {
message.error(`视频素材最短不能少于 ${MIN_PRIVATE_VIDEO_DURATION}`);
return false;
}
if (duration > MAX_PRIVATE_VIDEO_DURATION) {
message.error(`视频素材最长不能超过 ${MAX_PRIVATE_VIDEO_DURATION}`);
return false;
}
return true;
};
const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const reset = () => {
setFileList([]);
setName('');
};
const handleSubmit = async () => {
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
message.warning('请先选择图片素材');
message.warning('请先选择图片或视频素材');
return;
}
const assetType = guessAssetType(file);
if (assetType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
return;
}
setLoading(true);
try {
const uploaded = await uploadImage(file);
await createPrivatePortraitAsset(projectId, { url: uploaded.url, assetType: 'Image', name: name || file.name });
message.success('素材已提交入库,处理中');
setFileList([]);
setName('');
const videoDuration = assetType === 'Video' ? await getVideoDuration(file) : null;
if (assetType === 'Video' && !validatePrivateVideoDuration(videoDuration)) {
return;
}
const uploaded = assetType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
await createPrivatePortraitAsset(projectId, {
url: uploaded.url,
assetType,
name: name.trim() || file.name,
videoDuration,
fileSize: file.size,
mimeType: file.type || null,
});
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
reset();
onSuccess();
onClose();
} catch (e: any) {
@@ -39,21 +110,31 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
};
return (
<Modal title="上传真人素材" open={open} onCancel={onClose} onOk={handleSubmit} confirmLoading={loading} okText="提交入库">
<div style={{ display: 'grid', gap: 12 }}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" />
<Modal
title="上传真人素材"
open={open}
onCancel={onClose}
onOk={handleSubmit}
confirmLoading={loading}
okText="提交入库"
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Upload
accept="image/*"
accept="image/*,video/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
listType="picture"
>
<Button icon={<UploadOutlined />}></Button>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ color: '#64748b', fontSize: 12 }}>Active AI </div>
</div>
<div style={{ color: '#64748b', fontSize: 12 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} Active AI
</div>
</Space>
</Modal>
);
};
@@ -1,19 +1,44 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Tag, Typography, message } from 'antd';
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../../../api';
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types';
import { Button, Empty, Input, List, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
import {
getPrivatePortraitProjects,
getPrivatePortraitSelectableAssets,
getPrivatePortraitVirtualProjects,
getPrivatePortraitVirtualSelectableAssets,
} from '../../../api';
import type { PrivatePortraitLibraryType, PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types';
const { Text } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined;
interface PrivatePortraitAssetPickerProps {
open: boolean;
libraryType?: PrivatePortraitLibraryType;
selectedIds?: string[];
maxCount?: number;
onClose: () => void;
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
}
const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = {
real_person: {
title: '选择真人素材库',
empty: '暂无可选 Active 真人素材',
projectError: '加载真人素材项目失败',
assetError: '加载真人素材失败',
fallbackName: '真人素材',
},
aigc_virtual: {
title: '选择虚拟素材库',
empty: '暂无可选 Active 虚拟素材',
projectError: '加载虚拟素材项目失败',
assetError: '加载虚拟素材失败',
fallbackName: '虚拟素材',
},
};
const getPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
@@ -23,39 +48,49 @@ const getPreviewUrl = (url?: string | null) => {
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const getAssetPreviewUrl = (asset: PrivatePortraitSelectableAsset) => {
if (asset.assetType === 'Video') {
return getPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.providerUrl);
}
return getPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl);
};
const formatDuration = (value?: number | null) => {
const duration = Number(value || 0);
if (!Number.isFinite(duration) || duration <= 0) return '-';
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
};
const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
open,
libraryType = 'real_person',
selectedIds = [],
maxCount = 20,
onClose,
onSelect,
}) => {
const meta = libraryMeta[libraryType];
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [projectId, setProjectId] = useState<string | undefined>();
const [keyword, setKeyword] = useState('');
const [assetType, setAssetType] = useState<AssetTypeFilter>();
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set(selectedIds));
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false);
const selectedMap = useMemo(() => {
const map = new Map<string, PrivatePortraitSelectableAsset>();
assets.forEach((item) => {
if (checked.has(item.id)) map.set(item.id, item);
});
return map;
}, [assets, checked]);
const checked = useMemo(() => new Set(selectedAssets.keys()), [selectedAssets]);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
setProjects(res.items || []);
if (!projectId && res.items?.length) {
setProjectId(res.items[0].id);
}
const api = libraryType === 'aigc_virtual' ? getPrivatePortraitVirtualProjects : getPrivatePortraitProjects;
const res = await api({ page: 1, pageSize: 100, status: 'active' });
const next = res.items || [];
setProjects(next);
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
} catch (err: any) {
message.error(err?.message || '加载真人素材项目失败');
message.error(err?.message || meta.projectError);
} finally {
setLoadingProjects(false);
}
@@ -64,10 +99,17 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
const loadAssets = async () => {
setLoadingAssets(true);
try {
const res = await getPrivatePortraitSelectableAssets({ projectId, keyword: keyword.trim() || undefined, page: 1, pageSize: 100 });
const api = libraryType === 'aigc_virtual' ? getPrivatePortraitVirtualSelectableAssets : getPrivatePortraitSelectableAssets;
const res = await api({
projectId,
keyword: keyword.trim() || undefined,
assetType,
page: 1,
pageSize: 100,
});
setAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载真人素材失败');
message.error(err?.message || meta.assetError);
} finally {
setLoadingAssets(false);
}
@@ -75,18 +117,26 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
useEffect(() => {
if (!open) return;
setChecked(new Set(selectedIds));
setSelectedAssets(new Map());
setKeyword('');
setAssetType(undefined);
setProjectId(undefined);
setAssets([]);
loadProjects();
}, [open]);
}, [open, libraryType]);
useEffect(() => {
if (!open) return;
loadAssets();
}, [open, projectId]);
}, [open, projectId, assetType]);
const toggle = (asset: PrivatePortraitSelectableAsset) => {
setChecked((prev) => {
const next = new Set(prev);
if (selectedIds.includes(asset.id)) {
message.info('该素材已添加');
return;
}
setSelectedAssets((prev) => {
const next = new Map(prev);
if (next.has(asset.id)) {
next.delete(asset.id);
return next;
@@ -95,15 +145,15 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
message.warning(`最多选择 ${maxCount} 个参考素材`);
return prev;
}
next.add(asset.id);
next.set(asset.id, asset);
return next;
});
};
const confirm = () => {
const selected = assets.filter((item) => checked.has(item.id));
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
if (!selected.length) {
message.warning('请选择至少一个真人素材');
message.warning(`请选择至少一个${libraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材`);
return;
}
onSelect(selected);
@@ -112,14 +162,15 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
return (
<Modal
title="选择真人素材库"
title={meta.title}
open={open}
onCancel={onClose}
width={920}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
{checked.size}
{selectedAssets.size}
</Button>,
]}
>
@@ -147,7 +198,12 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Active {item.activeAssetCount || 0}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
Active {item.activeAssetCount || 0}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? ` · 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
</div>
</List.Item>
)}
@@ -165,35 +221,61 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={loadAssets}
/>
<Select
allowClear
placeholder="素材类型"
value={assetType}
onChange={setAssetType}
style={{ width: 116 }}
options={[
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loadingAssets}></Button>
</Space>
<Spin spinning={loadingAssets}>
{assets.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可选 Active 真人素材" style={{ marginTop: 120 }} />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
{assets.map((asset) => {
const active = checked.has(asset.id);
const active = checked.has(asset.id) || selectedIds.includes(asset.id);
const disabled = selectedIds.includes(asset.id);
const previewUrl = getAssetPreviewUrl(asset);
const isVideo = asset.assetType === 'Video';
return (
<div
key={asset.id}
onClick={() => toggle(asset)}
style={{
cursor: 'pointer',
cursor: disabled ? 'not-allowed' : 'pointer',
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
borderRadius: 12,
overflow: 'hidden',
background: '#fff',
boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)',
opacity: disabled ? 0.62 : 1,
position: 'relative',
}}
>
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{asset.previewUrl ? (
<img src={getPreviewUrl(asset.previewUrl)} alt={asset.name || '真人素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{previewUrl ? (
isVideo ? (
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={previewUrl} alt={asset.name || meta.fallbackName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 8, bottom: 8, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(asset.videoDuration)}
</div>
)}
</div>
{active && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -202,8 +284,9 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
)}
<div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<Space style={{ marginTop: 6 }}>
<Space wrap size={4} style={{ marginTop: 6 }}>
<Tag color="green">Active</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
<Tag>{asset.projectName}</Tag>
</Space>
</div>
+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>