真人/虚拟人像库app build
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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 (
|
from app.enums.private_portrait import (
|
||||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||||
@@ -13,6 +13,9 @@ from app.enums.private_portrait import (
|
|||||||
)
|
)
|
||||||
from app.schemas.common import NaiveDatetimeOptional
|
from app.schemas.common import NaiveDatetimeOptional
|
||||||
|
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitEnumItem(BaseModel):
|
class PrivatePortraitEnumItem(BaseModel):
|
||||||
value: str
|
value: str
|
||||||
@@ -160,6 +163,18 @@ class PrivatePortraitAssetCreate(BaseModel):
|
|||||||
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
|
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
|
||||||
return value
|
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):
|
class PrivatePortraitAssetOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ from app.utils.id_gen import generate_id
|
|||||||
|
|
||||||
DOMAIN = "private_portrait"
|
DOMAIN = "private_portrait"
|
||||||
|
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
def _json(data: Any) -> str | None:
|
def _json(data: Any) -> str | None:
|
||||||
if data is 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")
|
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:
|
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||||
return PrivatePortraitValidateSessionOut(
|
return PrivatePortraitValidateSessionOut(
|
||||||
id=session.id,
|
id=session.id,
|
||||||
@@ -402,6 +417,7 @@ async def create_asset(
|
|||||||
library_type: str | None = None,
|
library_type: str | None = None,
|
||||||
) -> PrivatePortraitAsset:
|
) -> PrivatePortraitAsset:
|
||||||
_assert_enabled_asset_type(payload.asset_type)
|
_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)
|
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
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)
|
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
|
||||||
if expected_ref_type:
|
if expected_ref_type:
|
||||||
_ref_set(ref, "type", 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:
|
if not _ref_get(ref, "name") and asset.name:
|
||||||
_ref_set(ref, "name", 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)
|
_ref_set(ref, "preview_url", display_url)
|
||||||
if expected_ref_type:
|
if expected_ref_type:
|
||||||
_ref_set(ref, "type", 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:
|
if not _ref_get(ref, "name") and asset.name:
|
||||||
_ref_set(ref, "name", 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>
|
||||||
<script type="module" crossorigin src="/assets/index-DjHXCPu7.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cap-_mpH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>␍
|
<div id="root"></div>␍
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Modal, Tooltip, Empty, Input, List, Spin, Tag, Typography, message } from 'antd';
|
import { Modal, Tooltip } from 'antd';
|
||||||
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, CheckOutlined, ReloadOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
|
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../api';
|
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||||
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../types';
|
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface UploadSelectorProps {
|
interface UploadSelectorProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
onLocalSelect?: (files: File[]) => void;
|
onLocalSelect?: (files: File[]) => void;
|
||||||
onHistorySelect?: (items: any[]) => void;
|
onHistorySelect?: (items: any[]) => void;
|
||||||
onPortraitSelect?: (items: any[]) => void;
|
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
||||||
|
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
||||||
|
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
|
||||||
|
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
tooltipTitle?: string;
|
tooltipTitle?: string;
|
||||||
}
|
}
|
||||||
@@ -22,10 +23,15 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
onLocalSelect,
|
onLocalSelect,
|
||||||
onHistorySelect,
|
onHistorySelect,
|
||||||
onPortraitSelect,
|
onPortraitSelect,
|
||||||
|
onPortraitLibrarySelect,
|
||||||
uploading,
|
uploading,
|
||||||
tooltipTitle,
|
tooltipTitle,
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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 = () => {
|
const handleLocalSelect = () => {
|
||||||
fileInputRef.current?.click();
|
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 = () => {
|
const handleClick = () => {
|
||||||
if (!uploading) {
|
if (!uploading) {
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockHistoryData = [];
|
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
|
||||||
|
setModalVisible(false);
|
||||||
const [selectedHistoryItems, setSelectedHistoryItems] = useState<number[]>([]);
|
if (onPortraitLibrarySelect) {
|
||||||
const [selectedPortraitItems, setSelectedPortraitItems] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
|
onPortraitLibrarySelect(libraryType);
|
||||||
const [historyActiveTab, setHistoryActiveTab] = useState<'asset' | 'history'>('asset');
|
return;
|
||||||
|
|
||||||
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 base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
setPortraitLibraryType(libraryType);
|
||||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
setPortraitPickerOpen(true);
|
||||||
};
|
|
||||||
|
|
||||||
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]
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmHistorySelection = () => {
|
const confirmHistorySelection = () => {
|
||||||
const items = mockHistoryData.filter(item => selectedHistoryItems.includes(item.id));
|
onHistorySelect?.([]);
|
||||||
onHistorySelect?.(items);
|
|
||||||
setHistoryModalVisible(false);
|
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);
|
setModalVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,14 +81,18 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'portrait',
|
key: 'real_person',
|
||||||
label: '人像',
|
label: '真人素材',
|
||||||
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
|
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
|
||||||
description: '从人像库中选择',
|
description: '从真人私域素材库中选择',
|
||||||
onClick: () => {
|
onClick: () => openPortraitPicker('real_person'),
|
||||||
setModalVisible(false);
|
},
|
||||||
setPortraitModalVisible(true);
|
{
|
||||||
},
|
key: 'aigc_virtual',
|
||||||
|
label: '虚拟素材',
|
||||||
|
icon: <TeamOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />,
|
||||||
|
description: '从虚拟私域素材库中选择',
|
||||||
|
onClick: () => openPortraitPicker('aigc_virtual'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'local',
|
key: 'local',
|
||||||
@@ -198,12 +118,12 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
/>
|
/>
|
||||||
{tooltipTitle ? (
|
{tooltipTitle ? (
|
||||||
<Tooltip title={tooltipTitle}>
|
<Tooltip title={tooltipTitle}>
|
||||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -275,149 +195,20 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
<Modal
|
<Modal
|
||||||
title="选择资产素材"
|
title="选择资产素材"
|
||||||
open={historyModalVisible}
|
open={historyModalVisible}
|
||||||
onCancel={() => {
|
onCancel={() => setHistoryModalVisible(false)}
|
||||||
setHistoryModalVisible(false);
|
|
||||||
setSelectedHistoryItems([]);
|
|
||||||
}}
|
|
||||||
footer={null}
|
footer={null}
|
||||||
width={"80%"}
|
width="80%"
|
||||||
height={"50%"}
|
|
||||||
centered
|
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={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
|
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
|
||||||
暂无资产图片
|
暂无资产图片
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
|
||||||
<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={[
|
|
||||||
<button
|
<button
|
||||||
key="cancel"
|
onClick={confirmHistorySelection}
|
||||||
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}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 24px',
|
padding: '8px 24px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -429,148 +220,23 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#7c3aed';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#8b5cf6';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
添加选中素材({selectedPortraitItems.size})
|
应用
|
||||||
</button>,
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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 React from 'react';
|
||||||
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
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';
|
import type { PrivatePortraitAsset } from '../../../types';
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
@@ -21,37 +21,70 @@ interface Props {
|
|||||||
|
|
||||||
const buildPreviewUrl = (url?: string | null) => {
|
const buildPreviewUrl = (url?: string | null) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return 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';
|
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
||||||
return `${base}${url}`;
|
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 }) => {
|
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
|
||||||
if (!items.length) return <Empty description="暂无真人素材" />;
|
if (!items.length) return <Empty description="暂无真人素材" />;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
|
const isVideo = item.assetType === 'Video';
|
||||||
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
const previewUrl = getAssetPreviewUrl(item);
|
||||||
{item.previewUrl || item.remoteUrl ? (
|
return (
|
||||||
<img src={buildPreviewUrl(item.previewUrl || item.remoteUrl)} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
|
||||||
) : <span style={{ color: '#94a3b8' }}>无预览</span>}
|
<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>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
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 { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
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 {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -11,24 +14,92 @@ interface Props {
|
|||||||
onSuccess: () => void;
|
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 PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setFileList([]);
|
||||||
|
setName('');
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const file = fileList[0]?.originFileObj as File | undefined;
|
const file = fileList[0]?.originFileObj as File | undefined;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
message.warning('请先选择图片素材');
|
message.warning('请先选择图片或视频素材');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const assetType = guessAssetType(file);
|
||||||
|
if (assetType === 'Image' && !file.type.startsWith('image/')) {
|
||||||
|
message.error('仅支持图片或视频素材');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadImage(file);
|
const videoDuration = assetType === 'Video' ? await getVideoDuration(file) : null;
|
||||||
await createPrivatePortraitAsset(projectId, { url: uploaded.url, assetType: 'Image', name: name || file.name });
|
if (assetType === 'Video' && !validatePrivateVideoDuration(videoDuration)) {
|
||||||
message.success('素材已提交入库,处理中');
|
return;
|
||||||
setFileList([]);
|
}
|
||||||
setName('');
|
|
||||||
|
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();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -39,21 +110,31 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="上传真人素材" open={open} onCancel={onClose} onOk={handleSubmit} confirmLoading={loading} okText="提交入库">
|
<Modal
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
title="上传真人素材"
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" />
|
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
|
<Upload
|
||||||
accept="image/*"
|
accept="image/*,video/*"
|
||||||
maxCount={1}
|
maxCount={1}
|
||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
beforeUpload={() => false}
|
beforeUpload={() => false}
|
||||||
onChange={({ fileList }) => setFileList(fileList)}
|
onChange={({ fileList }) => setFileList(fileList)}
|
||||||
listType="picture"
|
listType="picture"
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />}>选择图片</Button>
|
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
<div style={{ color: '#64748b', fontSize: 12 }}>建议上传真人正脸、全身或同人妆造图。入库后会经过火山真人一致性校验,Active 后才可用于 AI 创作。</div>
|
<div style={{ color: '#64748b', fontSize: 12 }}>
|
||||||
</div>
|
当前开放图片和视频,音频暂不接入。视频素材必须在 {MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} 秒内;入库后会经过火山真人一致性校验,Active 后才可用于 AI 创作。
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Empty, Input, List, Modal, Space, Spin, Tag, Typography, message } from 'antd';
|
import { Button, Empty, Input, List, Modal, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
||||||
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../../../api';
|
import {
|
||||||
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types';
|
getPrivatePortraitProjects,
|
||||||
|
getPrivatePortraitSelectableAssets,
|
||||||
|
getPrivatePortraitVirtualProjects,
|
||||||
|
getPrivatePortraitVirtualSelectableAssets,
|
||||||
|
} from '../../../api';
|
||||||
|
import type { PrivatePortraitLibraryType, PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
||||||
|
|
||||||
interface PrivatePortraitAssetPickerProps {
|
interface PrivatePortraitAssetPickerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
libraryType?: PrivatePortraitLibraryType;
|
||||||
selectedIds?: string[];
|
selectedIds?: string[];
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (assets: PrivatePortraitSelectableAsset[]) => 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) => {
|
const getPreviewUrl = (url?: string | null) => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
|
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}`;
|
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> = ({
|
const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
||||||
open,
|
open,
|
||||||
|
libraryType = 'real_person',
|
||||||
selectedIds = [],
|
selectedIds = [],
|
||||||
maxCount = 20,
|
maxCount = 20,
|
||||||
onClose,
|
onClose,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const meta = libraryMeta[libraryType];
|
||||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
const [projectId, setProjectId] = useState<string | undefined>();
|
const [projectId, setProjectId] = useState<string | undefined>();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
||||||
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
|
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 [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||||
|
|
||||||
const selectedMap = useMemo(() => {
|
const checked = useMemo(() => new Set(selectedAssets.keys()), [selectedAssets]);
|
||||||
const map = new Map<string, PrivatePortraitSelectableAsset>();
|
|
||||||
assets.forEach((item) => {
|
|
||||||
if (checked.has(item.id)) map.set(item.id, item);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [assets, checked]);
|
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
setLoadingProjects(true);
|
setLoadingProjects(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
|
const api = libraryType === 'aigc_virtual' ? getPrivatePortraitVirtualProjects : getPrivatePortraitProjects;
|
||||||
setProjects(res.items || []);
|
const res = await api({ page: 1, pageSize: 100, status: 'active' });
|
||||||
if (!projectId && res.items?.length) {
|
const next = res.items || [];
|
||||||
setProjectId(res.items[0].id);
|
setProjects(next);
|
||||||
}
|
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '加载真人素材项目失败');
|
message.error(err?.message || meta.projectError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingProjects(false);
|
setLoadingProjects(false);
|
||||||
}
|
}
|
||||||
@@ -64,10 +99,17 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
const loadAssets = async () => {
|
const loadAssets = async () => {
|
||||||
setLoadingAssets(true);
|
setLoadingAssets(true);
|
||||||
try {
|
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 || []);
|
setAssets(res.items || []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '加载真人素材失败');
|
message.error(err?.message || meta.assetError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAssets(false);
|
setLoadingAssets(false);
|
||||||
}
|
}
|
||||||
@@ -75,18 +117,26 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setChecked(new Set(selectedIds));
|
setSelectedAssets(new Map());
|
||||||
|
setKeyword('');
|
||||||
|
setAssetType(undefined);
|
||||||
|
setProjectId(undefined);
|
||||||
|
setAssets([]);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}, [open]);
|
}, [open, libraryType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
loadAssets();
|
loadAssets();
|
||||||
}, [open, projectId]);
|
}, [open, projectId, assetType]);
|
||||||
|
|
||||||
const toggle = (asset: PrivatePortraitSelectableAsset) => {
|
const toggle = (asset: PrivatePortraitSelectableAsset) => {
|
||||||
setChecked((prev) => {
|
if (selectedIds.includes(asset.id)) {
|
||||||
const next = new Set(prev);
|
message.info('该素材已添加');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAssets((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
if (next.has(asset.id)) {
|
if (next.has(asset.id)) {
|
||||||
next.delete(asset.id);
|
next.delete(asset.id);
|
||||||
return next;
|
return next;
|
||||||
@@ -95,15 +145,15 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
message.warning(`最多选择 ${maxCount} 个参考素材`);
|
message.warning(`最多选择 ${maxCount} 个参考素材`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
next.add(asset.id);
|
next.set(asset.id, asset);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirm = () => {
|
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) {
|
if (!selected.length) {
|
||||||
message.warning('请选择至少一个真人素材');
|
message.warning(`请选择至少一个${libraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelect(selected);
|
onSelect(selected);
|
||||||
@@ -112,14 +162,15 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="选择真人素材库"
|
title={meta.title}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={920}
|
width={920}
|
||||||
|
destroyOnHidden
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||||
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
|
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
|
||||||
添加选中素材({checked.size})
|
添加选中素材({selectedAssets.size})
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -147,7 +198,12 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
>
|
>
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
<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>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
@@ -165,35 +221,61 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
onPressEnter={loadAssets}
|
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>
|
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loadingAssets}>刷新</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Spin spinning={loadingAssets}>
|
<Spin spinning={loadingAssets}>
|
||||||
{assets.length === 0 ? (
|
{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 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
|
||||||
{assets.map((asset) => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
onClick={() => toggle(asset)}
|
onClick={() => toggle(asset)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
|
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)',
|
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',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||||
{asset.previewUrl ? (
|
{previewUrl ? (
|
||||||
<img src={getPreviewUrl(asset.previewUrl)} alt={asset.name || '真人素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
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' }} />
|
<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>
|
</div>
|
||||||
{active && (
|
{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' }}>
|
<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 }}>
|
<div style={{ padding: 10 }}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
|
<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="green">Active</Tag>
|
||||||
|
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
<Tag>{asset.projectName}</Tag>
|
<Tag>{asset.projectName}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
|
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
||||||
import type { PrivatePortraitSelectableAsset } from '../types';
|
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -175,6 +175,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
|
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
|
||||||
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
|
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
|
||||||
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
|
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
|
||||||
|
const [privateAssetPickerLibraryType, setPrivateAssetPickerLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
|
||||||
const [mediaStackHovered, setMediaStackHovered] = useState(false);
|
const [mediaStackHovered, setMediaStackHovered] = useState(false);
|
||||||
const mediaStackCloseTimerRef = useRef<number | null>(null);
|
const mediaStackCloseTimerRef = useRef<number | null>(null);
|
||||||
const openMediaStackTray = useCallback(() => {
|
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 {
|
try {
|
||||||
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
|
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
|
||||||
const res = await uploadFn(file);
|
const res = await uploadFn(file);
|
||||||
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
|
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
name: pendingMedia.name,
|
||||||
type: mediaType,
|
type: pendingMedia.type,
|
||||||
url: res.url,
|
url: res.url,
|
||||||
label: '',
|
label: pendingMedia.label || '',
|
||||||
...(isVideo && { duration: videoDuration }),
|
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
||||||
...(isAudio && { duration: audioDuration }),
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('上传失败');
|
message.error('上传失败');
|
||||||
@@ -1419,29 +1432,118 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
|
const getMediaDurationTotal = (items: MediaReference[], type: 'video' | 'audio') => {
|
||||||
if (mediaType !== 'video') {
|
return items
|
||||||
message.warning('真人素材库第一版仅支持视频创作参考');
|
.filter((m) => m.type === type)
|
||||||
return;
|
.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 (incoming.some((item) => item.type === 'audio') && mediaType !== 'video') {
|
||||||
if (assets.length > available) {
|
message.error('仅视频模式支持添加音频素材');
|
||||||
message.warning(`当前引擎最多还能添加 ${available} 张图片参考`);
|
return false;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const added: MediaReference[] = assets.map((asset) => ({
|
|
||||||
name: asset.name || '真人素材',
|
const imageCount = baseMedia.filter((m) => m.type === 'image').length;
|
||||||
type: 'image',
|
const videoCount = baseMedia.filter((m) => m.type === 'video').length;
|
||||||
url: asset.previewUrl || '',
|
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',
|
source: 'private_portrait_asset',
|
||||||
private_asset_id: asset.id,
|
private_asset_id: asset.id,
|
||||||
label: '',
|
label: '',
|
||||||
}));
|
...(refType === 'video' && { duration: Number(asset.videoDuration) || undefined }),
|
||||||
const newList = [...currentMedia, ...added];
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
message.success(`已添加 ${assets.length} 个真人素材参考`);
|
message.success(`已添加 ${normalized.length} 个${privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材参考`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPreviewUrl = (url: string) => {
|
const buildPreviewUrl = (url: string) => {
|
||||||
@@ -2520,8 +2622,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||||
message.success(`成功添加${items.length}个历史记录`);
|
message.success(`成功添加${items.length}个历史记录`);
|
||||||
}}
|
}}
|
||||||
onPortraitSelect={(items) => {
|
onPortraitLibrarySelect={(libraryType) => {
|
||||||
handlePrivatePortraitAssetsSelected(items as any);
|
openPrivatePortraitPicker(libraryType);
|
||||||
}}
|
}}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
tooltipTitle={mediaType === 'image'
|
tooltipTitle={mediaType === 'image'
|
||||||
@@ -2700,8 +2802,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||||
message.success(`成功添加${items.length}个历史记录`);
|
message.success(`成功添加${items.length}个历史记录`);
|
||||||
}}
|
}}
|
||||||
onPortraitSelect={(items) => {
|
onPortraitLibrarySelect={(libraryType) => {
|
||||||
handlePrivatePortraitAssetsSelected(items as any);
|
openPrivatePortraitPicker(libraryType);
|
||||||
}}
|
}}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
tooltipTitle={mediaType === 'image'
|
tooltipTitle={mediaType === 'image'
|
||||||
@@ -4155,10 +4257,11 @@ const AIChatPage: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
<PrivatePortraitAssetPicker
|
<PrivatePortraitAssetPicker
|
||||||
open={privateAssetPickerOpen}
|
open={privateAssetPickerOpen}
|
||||||
|
libraryType={privateAssetPickerLibraryType}
|
||||||
onClose={() => setPrivateAssetPickerOpen(false)}
|
onClose={() => setPrivateAssetPickerOpen(false)}
|
||||||
onSelect={handlePrivatePortraitAssetsSelected}
|
onSelect={handlePrivatePortraitAssetsSelected}
|
||||||
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
||||||
maxCount={maxImage}
|
maxCount={Math.max(1, maxImage + maxVideo)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ const { Text, Title, Paragraph } = Typography;
|
|||||||
|
|
||||||
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
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 }> = {
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||||
creating: { label: '本地创建中', color: 'processing' },
|
creating: { label: '本地创建中', color: 'processing' },
|
||||||
Processing: { label: '火山处理中', color: 'processing' },
|
Processing: { label: '火山处理中', color: 'processing' },
|
||||||
@@ -95,6 +98,9 @@ const buildPreviewUrl = (url?: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
|
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);
|
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 StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||||
const value = status || '-';
|
const value = status || '-';
|
||||||
const config = statusConfig[value];
|
const config = statusConfig[value];
|
||||||
@@ -265,10 +287,18 @@ const PrivatePortraitVirtualMaterialPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentType = guessAssetType(file);
|
const currentType = guessAssetType(file);
|
||||||
|
if (currentType === 'Image' && !file.type.startsWith('image/')) {
|
||||||
|
message.error('仅支持图片或视频素材');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
|
||||||
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
|
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, {
|
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
assetType: currentType,
|
assetType: currentType,
|
||||||
@@ -578,7 +608,7 @@ const PrivatePortraitVirtualMaterialPage: React.FC = () => {
|
|||||||
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
|
<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>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user