“真人人像/虚拟素材批量上传”

This commit is contained in:
sjy
2026-07-13 17:54:01 +08:00
parent dc2e527977
commit 5b937f652b
10 changed files with 995 additions and 635 deletions
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { Button, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Space, Upload, Spin, message } from 'antd';
import { UploadOutlined, DeleteOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api';
import { useLibrary } from './LibraryContext';
const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15;
@@ -61,50 +62,61 @@ const validatePrivateVideoDuration = (duration: number | null): duration is numb
};
const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
const { refreshConfig } = useLibrary();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [name, setName] = useState('');
const [assetNames, setAssetNames] = useState<Record<string, string>>({});
const [uploadStatus, setUploadStatus] = useState<Record<string, 'uploading' | 'uploaded' | 'error'>>({});
const [uploadResults, setUploadResults] = useState<Record<string, { url: string; duration_seconds?: number; file_size_bytes?: number; resource_id?: string }>>({});
const [importStatus, setImportStatus] = useState<Record<string, 'importing' | 'imported' | 'error'>>({});
const [loading, setLoading] = useState(false);
const reset = () => {
setFileList([]);
setName('');
setAssetNames({});
setUploadStatus({});
setUploadResults({});
setImportStatus({});
};
const handleSubmit = async () => {
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
message.warning('请先选择图片或视频素材');
return;
}
const assetType = guessAssetType(file);
if (assetType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
const uploadedFiles = fileList.filter((f) => uploadStatus[f.uid] === 'uploaded');
if (uploadedFiles.length === 0) {
message.warning('请等待文件上传完成后再提交入库');
return;
}
setLoading(true);
try {
const videoDuration = assetType === 'Video' ? await getVideoDuration(file) : null;
if (assetType === 'Video' && !validatePrivateVideoDuration(videoDuration)) {
return;
}
for (const fileItem of uploadedFiles) {
const file = fileItem.originFileObj as File | undefined;
if (!file) continue;
const uploadResult = uploadResults[fileItem.uid];
if (!uploadResult) continue;
const uploaded = assetType === 'Video' ? await uploadPrivatePortraitVideo(file, videoDuration || undefined) : await uploadPrivatePortraitImage(file);
await createPrivatePortraitAsset(projectId, {
url: uploaded.url,
assetType,
name: name.trim() || file.name,
videoDuration: uploaded.duration_seconds ?? videoDuration,
fileSize: uploaded.file_size_bytes ?? file.size,
mimeType: file.type || null,
uploadResourceId: uploaded.resource_id || null,
});
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
reset();
onSuccess();
onClose();
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'importing' }));
const currentType = file.type.startsWith('image/') ? 'Image' : 'Video';
await createPrivatePortraitAsset(projectId, {
url: uploadResult.url,
assetType: currentType,
name: assetNames[fileItem.uid]?.trim() || file.name,
videoDuration: uploadResult.duration_seconds,
fileSize: uploadResult.file_size_bytes ?? file.size,
mimeType: file.type || null,
uploadResourceId: uploadResult.resource_id || null,
});
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'imported' }));
}
message.success(`已成功提交 ${uploadedFiles.length} 个素材入库,处理中`);
void refreshConfig();
setTimeout(() => {
reset();
onSuccess();
onClose();
}, 1500);
} catch (e: any) {
message.error(e?.message || '上传素材失败');
message.error(e?.message || '提交入库失败');
} finally {
setLoading(false);
}
@@ -114,25 +126,170 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
<Modal
title="上传真人素材"
open={open}
onCancel={onClose}
onCancel={() => {
reset();
onClose();
}}
onOk={handleSubmit}
confirmLoading={loading}
okText="提交入库"
destroyOnHidden
width={650}
okButtonProps={{
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Upload
accept="image/*,video/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
multiple
beforeUpload={async (file) => {
const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const isImage = file.type.startsWith('image/');
const previewUrl = URL.createObjectURL(file);
const newFile: UploadFile = {
uid,
name: file.name,
status: 'uploading',
originFileObj: file,
thumbUrl: previewUrl,
type: file.type,
};
setFileList((prev) => [...prev, newFile]);
setAssetNames((prev) => ({ ...prev, [uid]: '' }));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploading' }));
try {
const currentType = isImage ? 'Image' : 'Video';
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration)) {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
return false;
}
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVideo(file, duration || undefined) : await uploadPrivatePortraitImage(file);
setUploadResults((prev) => ({
...prev,
[uid]: {
url: uploaded.url,
duration_seconds: uploaded.duration_seconds,
file_size_bytes: uploaded.file_size_bytes,
resource_id: uploaded.resource_id,
},
}));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploaded' }));
} catch {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
message.error(`文件 ${file.name} 上传失败`);
}
return false;
}}
listType="picture"
itemRender={(_, file, actions) => {
const isVideo = file.type?.startsWith('video/') || file.name.match(/\.(mp4|mov|avi|webm)$/i);
const uploadState = uploadStatus[file.uid];
const importState = importStatus[file.uid];
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12, border: '1px solid #e5e7eb', borderRadius: 8, marginBottom: 8 }}>
<div style={{ width: 64, height: 64, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
{file.thumbUrl || file.url ? (
isVideo ? (
<video src={file.thumbUrl || file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={file.thumbUrl || file.url} alt={file.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#f1f5f9' }}>
{isVideo ? (
<VideoCameraOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
) : (
<PictureOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
)}
</div>
)}
</div>
<span style={{ width: '20%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', fontSize: 13, color: '#64748b', flexShrink: 0 }}>{file.name}</span>
<Input
style={{ width: 200, flexShrink: 0 }}
value={assetNames[file.uid] || ''}
onChange={(e) => setAssetNames({ ...assetNames, [file.uid]: e.target.value })}
placeholder="素材名称,默认使用文件名"
maxLength={256}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{uploadState === 'uploading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{uploadState === 'uploaded' && !importState && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{uploadState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
{importState === 'importing' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{importState === 'imported' && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{importState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
setAssetNames((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadResults((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setImportStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
}}
style={{
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: '#f1f5f9',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<DeleteOutlined style={{ color: '#64748b', fontSize: 14 }} />
</button>
</div>
);
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ color: '#64748b', fontSize: 12 }}>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} Active AI
</div>
</Space>
@@ -0,0 +1,20 @@
import React, { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
interface LibraryContextType {
refreshConfig: () => void;
}
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
export const LibraryProvider: React.FC<{ children: ReactNode; refreshConfig: () => void }> = ({ children, refreshConfig }) => {
return <LibraryContext.Provider value={{ refreshConfig }}>{children}</LibraryContext.Provider>;
};
export const useLibrary = () => {
const context = useContext(LibraryContext);
if (!context) {
throw new Error('useLibrary must be used within a LibraryProvider');
}
return context;
};
@@ -5,6 +5,7 @@ import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortrai
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel';
import { LibraryProvider } from './LibraryContext';
const { Title, Text, Paragraph } = Typography;
@@ -21,6 +22,20 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
const loadConfig = async () => {
try {
if (activeKey === 'aigc_virtual') {
const configRes = await getPrivatePortraitVirtualConfig();
setConfig(configRes);
} else {
const configRes = await getPrivatePortraitConfig();
setConfig(configRes);
}
} catch (err: any) {
message.error(err?.message || '加载配置失败');
}
};
useEffect(() => {
const loadData = async () => {
try {
@@ -84,48 +99,50 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
};
return (
<div>
{/* <div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
<Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text>
</div> */}
{/* <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
<Text type="secondary">素材总额度</Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary">项目组</Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
{activeKey === 'aigc_virtual'
? '虚拟素材如需使用需先上传'
: '真人项目组需完成人脸认证后才可上传素材。'}
</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary">当前项目素材</Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 入库成功 状态素材可在 AI 创作中引用。</Paragraph>
</Card>
</Col>
</Row> */}
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
items={items}
destroyOnHidden={false}
tabBarExtraContent={
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
}
/>
</div>
<LibraryProvider refreshConfig={loadConfig}>
<div>
{/* <div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
<Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text>
</div> */}
{/* <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
<Text type="secondary">素材总额度</Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary">项目组</Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
{activeKey === 'aigc_virtual'
? '虚拟素材如需使用需先上传'
: '真人项目组需完成人脸认证后才可上传素材。'}
</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary">当前项目素材</Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 入库成功 状态素材可在 AI 创作中引用。</Paragraph>
</Card>
</Col>
</Row> */}
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
items={items}
destroyOnHidden={false}
tabBarExtraContent={
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
}
/>
</div>
</LibraryProvider>
);
};
@@ -5,6 +5,7 @@ import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../type
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload';
import { useLibrary } from './LibraryContext';
interface Props {
project: PrivatePortraitProject;
@@ -13,6 +14,7 @@ interface Props {
}
const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onChanged }) => {
const { refreshConfig } = useLibrary();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
@@ -51,6 +53,7 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
await deletePrivatePortraitAsset(assetId);
await loadAssets(assetPage, assetPageSize);
onChanged();
void refreshConfig();
message.success('素材已删除');
} catch (e: any) {
message.error(e?.message || '删除素材失败');
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLibrary } from './LibraryContext';
import {
App,
Button,
@@ -131,6 +132,7 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const VirtualMaterialPanel: React.FC = () => {
const { message } = App.useApp();
const { refreshConfig } = useLibrary();
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -147,7 +149,10 @@ const VirtualMaterialPanel: React.FC = () => {
const [uploadOpen, setUploadOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [assetName, setAssetName] = useState('');
const [assetNames, setAssetNames] = useState<Record<string, string>>({});
const [uploadStatus, setUploadStatus] = useState<Record<string, 'uploading' | 'uploaded' | 'error'>>({});
const [uploadResults, setUploadResults] = useState<Record<string, { url: string; duration_seconds?: number; file_size_bytes?: number; resource_id?: string }>>({});
const [importStatus, setImportStatus] = useState<Record<string, 'importing' | 'imported' | 'error'>>({});
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
@@ -259,40 +264,52 @@ const VirtualMaterialPanel: React.FC = () => {
message.warning('请先创建或选择项目组');
return;
}
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
if (fileList.length === 0) {
message.warning('请先选择图片或视频素材');
return;
}
const currentType = guessAssetType(file);
if (currentType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
const uploadedFiles = fileList.filter((f) => uploadStatus[f.uid] === 'uploaded');
if (uploadedFiles.length === 0) {
message.warning('请等待文件上传完成后再提交入库');
return;
}
setUploading(true);
try {
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
return;
for (const fileItem of uploadedFiles) {
const file = fileItem.originFileObj as File | undefined;
if (!file) continue;
const uploadResult = uploadResults[fileItem.uid];
if (!uploadResult) continue;
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'importing' }));
const currentType = file.type.startsWith('image/') ? 'Image' : 'Video';
await createPrivatePortraitVirtualAsset(selectedProjectId, {
url: uploadResult.url,
assetType: currentType,
name: assetNames[fileItem.uid]?.trim() || file.name,
videoDuration: uploadResult.duration_seconds,
fileSize: uploadResult.file_size_bytes ?? file.size,
mimeType: file.type || null,
uploadResourceId: uploadResult.resource_id || null,
});
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'imported' }));
}
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVirtualVideo(file, duration || undefined) : await uploadPrivatePortraitVirtualImage(file);
await createPrivatePortraitVirtualAsset(selectedProjectId, {
url: uploaded.url,
assetType: currentType,
name: assetName.trim() || file.name,
videoDuration: uploaded.duration_seconds ?? duration,
fileSize: uploaded.file_size_bytes ?? file.size,
mimeType: file.type || null,
uploadResourceId: uploaded.resource_id || null,
});
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
setUploadOpen(false);
setFileList([]);
setAssetName('');
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
message.success(`已成功提交 ${uploadedFiles.length} 个素材入库,处理中`);
setTimeout(() => {
setUploadOpen(false);
setFileList([]);
setAssetNames({});
setUploadStatus({});
setUploadResults({});
setImportStatus({});
}, 1500);
await Promise.all([loadProjects(), loadAssets(1, assetPageSize), refreshConfig()]);
} catch (err: any) {
message.error(err?.message || '上传素材失败');
message.error(err?.message || '提交入库失败');
} finally {
setUploading(false);
}
@@ -302,7 +319,7 @@ const VirtualMaterialPanel: React.FC = () => {
try {
await deletePrivatePortraitVirtualAsset(assetId);
message.success('素材已删除,远端删除将异步执行');
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize), refreshConfig()]);
} catch (err: any) {
message.error(err?.message || '删除素材失败');
}
@@ -571,21 +588,163 @@ const VirtualMaterialPanel: React.FC = () => {
onOk={handleUpload}
confirmLoading={uploading}
okText="提交入库"
width={650}
okButtonProps={{
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Upload
accept="image/*,video/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList: next }) => setFileList(next)}
multiple
beforeUpload={async (file) => {
const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const isImage = file.type.startsWith('image/');
const previewUrl = URL.createObjectURL(file);
const newFile: UploadFile = {
uid,
name: file.name,
status: 'uploading',
originFileObj: file,
thumbUrl: previewUrl,
type: file.type,
};
setFileList((prev) => [...prev, newFile]);
setAssetNames((prev) => ({ ...prev, [uid]: '' }));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploading' }));
try {
const currentType = isImage ? 'Image' : 'Video';
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
return false;
}
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVirtualVideo(file, duration || undefined) : await uploadPrivatePortraitVirtualImage(file);
setUploadResults((prev) => ({
...prev,
[uid]: {
url: uploaded.url,
duration_seconds: uploaded.duration_seconds,
file_size_bytes: uploaded.file_size_bytes,
resource_id: uploaded.resource_id,
},
}));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploaded' }));
} catch {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
message.error(`文件 ${file.name} 上传失败`);
}
return false;
}}
listType="picture"
itemRender={(_, file, actions) => {
const isVideo = file.type?.startsWith('video/') || file.name.match(/\.(mp4|mov|avi|webm)$/i);
const uploadState = uploadStatus[file.uid];
const importState = importStatus[file.uid];
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12, border: '1px solid #e5e7eb', borderRadius: 8, marginBottom: 8 }}>
<div style={{ width: 64, height: 64, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
{file.thumbUrl || file.url ? (
isVideo ? (
<video src={file.thumbUrl || file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={file.thumbUrl || file.url} alt={file.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#f1f5f9' }}>
{isVideo ? (
<VideoCameraOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
) : (
<PictureOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
)}
</div>
)}
</div>
<span style={{ width: '20%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', fontSize: 13, color: '#64748b', flexShrink: 0 }}>{file.name}</span>
<Input
style={{ width: 200, flexShrink: 0 }}
value={assetNames[file.uid] || ''}
onChange={(e) => setAssetNames({ ...assetNames, [file.uid]: e.target.value })}
placeholder="素材名称,默认使用文件名"
maxLength={256}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{uploadState === 'uploading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{uploadState === 'uploaded' && !importState && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{uploadState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
{importState === 'importing' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{importState === 'imported' && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{importState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
setAssetNames((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadResults((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setImportStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
}}
style={{
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: '#f1f5f9',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<DeleteOutlined style={{ color: '#64748b', fontSize: 14 }} />
</button>
</div>
);
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
</div>
</Space>
</Modal>
+20 -16
View File
@@ -2786,7 +2786,7 @@ const AIChatPage: React.FC = () => {
{firstFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
src={buildPreviewUrl(firstFrame.url)}
alt={firstFrame.name}
onClick={() => {
setAttachmentPreviewUrl(firstFrame.url);
@@ -2836,6 +2836,8 @@ const AIChatPage: React.FC = () => {
url: previewUrl,
role: 'first_frame',
label: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
};
setFirstFrame(mediaRef);
antdMessage.success('成功添加首帧');
@@ -2875,7 +2877,7 @@ const AIChatPage: React.FC = () => {
{lastFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
src={buildPreviewUrl(lastFrame.url)}
alt={lastFrame.name}
onClick={() => {
setAttachmentPreviewUrl(lastFrame.url);
@@ -2917,20 +2919,22 @@ const AIChatPage: React.FC = () => {
}
}}
onPortraitSelect={(assets) => {
if (assets.length > 0) {
const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = {
name: asset.name || '尾帧图片',
type: 'image',
url: previewUrl,
role: 'last_frame',
label: '',
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
}
}}
if (assets.length > 0) {
const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = {
name: asset.name || '尾帧图片',
type: 'image',
url: previewUrl,
role: 'last_frame',
label: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
}
}}
uploading={uploading}
>
<div
+1 -1
View File
@@ -349,7 +349,7 @@ const HomePage: React.FC = () => {
},
{
icon: <RobotOutlined style={{ fontSize: 24 }} />,
title: 'AI成片',
title: 'AI创作',
description: '输入想法、剧本或上传参考,智能生成视频/图片',
action: '立即生成',
path: '/conversation',