真人/虚拟人像库app build
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB |
+116
-116
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>␍
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user