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

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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CEhV1cDD.js"></script>
<script type="module" crossorigin src="/assets/index-BoVQkhEX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head>
<body>
@@ -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',