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

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>
<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"> <link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head> </head>
<body> <body>
@@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button, Input, Modal, Space, Upload, message } from 'antd'; import { Button, Input, Modal, Space, Upload, Spin, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined, DeleteOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface'; import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api'; import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api';
import { useLibrary } from './LibraryContext';
const MIN_PRIVATE_VIDEO_DURATION = 2; const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15; 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 PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
const { refreshConfig } = useLibrary();
const [fileList, setFileList] = useState<UploadFile[]>([]); 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 [loading, setLoading] = useState(false);
const reset = () => { const reset = () => {
setFileList([]); setFileList([]);
setName(''); setAssetNames({});
setUploadStatus({});
setUploadResults({});
setImportStatus({});
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
const file = fileList[0]?.originFileObj as File | undefined; const uploadedFiles = fileList.filter((f) => uploadStatus[f.uid] === 'uploaded');
if (!file) { if (uploadedFiles.length === 0) {
message.warning('请先选择图片或视频素材'); message.warning('请等待文件上传完成后再提交入库');
return;
}
const assetType = guessAssetType(file);
if (assetType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const videoDuration = assetType === 'Video' ? await getVideoDuration(file) : null; for (const fileItem of uploadedFiles) {
if (assetType === 'Video' && !validatePrivateVideoDuration(videoDuration)) { const file = fileItem.originFileObj as File | undefined;
return; if (!file) continue;
} const uploadResult = uploadResults[fileItem.uid];
if (!uploadResult) continue;
const uploaded = assetType === 'Video' ? await uploadPrivatePortraitVideo(file, videoDuration || undefined) : await uploadPrivatePortraitImage(file); setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'importing' }));
await createPrivatePortraitAsset(projectId, {
url: uploaded.url, const currentType = file.type.startsWith('image/') ? 'Image' : 'Video';
assetType, await createPrivatePortraitAsset(projectId, {
name: name.trim() || file.name, url: uploadResult.url,
videoDuration: uploaded.duration_seconds ?? videoDuration, assetType: currentType,
fileSize: uploaded.file_size_bytes ?? file.size, name: assetNames[fileItem.uid]?.trim() || file.name,
mimeType: file.type || null, videoDuration: uploadResult.duration_seconds,
uploadResourceId: uploaded.resource_id || null, fileSize: uploadResult.file_size_bytes ?? file.size,
}); mimeType: file.type || null,
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中'); uploadResourceId: uploadResult.resource_id || null,
reset(); });
onSuccess();
onClose(); setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'imported' }));
}
message.success(`已成功提交 ${uploadedFiles.length} 个素材入库,处理中`);
void refreshConfig();
setTimeout(() => {
reset();
onSuccess();
onClose();
}, 1500);
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '上传素材失败'); message.error(e?.message || '提交入库失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -114,25 +126,170 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
<Modal <Modal
title="上传真人素材" title="上传真人素材"
open={open} open={open}
onCancel={onClose} onCancel={() => {
reset();
onClose();
}}
onOk={handleSubmit} onOk={handleSubmit}
confirmLoading={loading} confirmLoading={loading}
okText="提交入库" okText="提交入库"
destroyOnHidden destroyOnHidden
width={650}
okButtonProps={{
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
> >
<Space direction="vertical" style={{ width: '100%' }} size={12}> <Space direction="vertical" style={{ width: '100%' }} size={14}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Upload <Upload
accept="image/*,video/*" accept="image/*,video/*"
maxCount={1}
fileList={fileList} fileList={fileList}
beforeUpload={() => false} multiple
onChange={({ fileList }) => setFileList(fileList)} 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" 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> <Button icon={<UploadOutlined />}></Button>
</Upload> </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 {MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} Active AI
</div> </div>
</Space> </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 type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import RealPersonLibraryPanel from './RealPersonLibraryPanel'; import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel'; import VirtualMaterialPanel from './VirtualMaterialPanel';
import { LibraryProvider } from './LibraryContext';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@@ -21,6 +22,20 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>(); 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(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
@@ -84,48 +99,50 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
}; };
return ( return (
<div> <LibraryProvider refreshConfig={loadConfig}>
{/* <div style={{ marginBottom: 16 }}> <div>
<Title level={4} style={{ margin: 0 }}>私域素材库</Title> {/* <div style={{ marginBottom: 16 }}>
<Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text> <Title level={4} style={{ margin: 0 }}>私域素材库</Title>
</div> */} <Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text>
{/* <Row gutter={[16, 16]} style={{ marginBottom: 16 }}> </div> */}
<Col xs={24} md={8}> {/* <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}> <Col xs={24} md={8}>
<Text type="secondary">素材总额度</Text> <Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div> <Text type="secondary">素材总额度</Text>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph> <div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
</Card> <Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
</Col> </Card>
<Col xs={24} md={8}> </Col>
<Card style={{ borderRadius: 16 }}> <Col xs={24} md={8}>
<Text type="secondary">项目组</Text> <Card style={{ borderRadius: 16 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div> <Text type="secondary">项目组</Text>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> <div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
{activeKey === 'aigc_virtual' <Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
? '虚拟素材如需使用需先上传' {activeKey === 'aigc_virtual'
: '真人项目组需完成人脸认证后才可上传素材。'} ? '虚拟素材如需使用需先上传'
</Paragraph> : '真人项目组需完成人脸认证后才可上传素材。'}
</Card> </Paragraph>
</Col> </Card>
<Col xs={24} md={8}> </Col>
<Card style={{ borderRadius: 16 }}> <Col xs={24} md={8}>
<Text type="secondary">当前项目素材</Text> <Card style={{ borderRadius: 16 }}>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div> <Text type="secondary">当前项目素材</Text>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 入库成功 状态素材可在 AI 创作中引用。</Paragraph> <div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
</Card> <Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 入库成功 状态素材可在 AI 创作中引用。</Paragraph>
</Col> </Card>
</Row> */} </Col>
<Tabs </Row> */}
activeKey={activeKey} <Tabs
onChange={handleTabChange} activeKey={activeKey}
items={items} onChange={handleTabChange}
destroyOnHidden={false} items={items}
tabBarExtraContent={ destroyOnHidden={false}
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div> tabBarExtraContent={
} <div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
/> }
</div> />
</div>
</LibraryProvider>
); );
}; };
@@ -5,6 +5,7 @@ import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../type
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api'; import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid'; import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload'; import PrivatePortraitAssetUpload from './AssetUpload';
import { useLibrary } from './LibraryContext';
interface Props { interface Props {
project: PrivatePortraitProject; project: PrivatePortraitProject;
@@ -13,6 +14,7 @@ interface Props {
} }
const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onChanged }) => { const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onChanged }) => {
const { refreshConfig } = useLibrary();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
@@ -51,6 +53,7 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
await deletePrivatePortraitAsset(assetId); await deletePrivatePortraitAsset(assetId);
await loadAssets(assetPage, assetPageSize); await loadAssets(assetPage, assetPageSize);
onChanged(); onChanged();
void refreshConfig();
message.success('素材已删除'); message.success('素材已删除');
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '删除素材失败'); message.error(e?.message || '删除素材失败');
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLibrary } from './LibraryContext';
import { import {
App, App,
Button, Button,
@@ -131,6 +132,7 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const VirtualMaterialPanel: React.FC = () => { const VirtualMaterialPanel: React.FC = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const { refreshConfig } = useLibrary();
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>(); const [selectedProjectId, setSelectedProjectId] = useState<string>();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -147,7 +149,10 @@ const VirtualMaterialPanel: React.FC = () => {
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]); 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 [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState(''); const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image'); const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
@@ -259,40 +264,52 @@ const VirtualMaterialPanel: React.FC = () => {
message.warning('请先创建或选择项目组'); message.warning('请先创建或选择项目组');
return; return;
} }
const file = fileList[0]?.originFileObj as File | undefined; if (fileList.length === 0) {
if (!file) {
message.warning('请先选择图片或视频素材'); message.warning('请先选择图片或视频素材');
return; return;
} }
const currentType = guessAssetType(file);
if (currentType === 'Image' && !file.type.startsWith('image/')) { const uploadedFiles = fileList.filter((f) => uploadStatus[f.uid] === 'uploaded');
message.error('仅支持图片或视频素材'); if (uploadedFiles.length === 0) {
message.warning('请等待文件上传完成后再提交入库');
return; return;
} }
setUploading(true); setUploading(true);
try { try {
const duration = currentType === 'Video' ? await getVideoDuration(file) : null; for (const fileItem of uploadedFiles) {
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) { const file = fileItem.originFileObj as File | undefined;
return; 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); message.success(`已成功提交 ${uploadedFiles.length} 个素材入库,处理中`);
await createPrivatePortraitVirtualAsset(selectedProjectId, { setTimeout(() => {
url: uploaded.url, setUploadOpen(false);
assetType: currentType, setFileList([]);
name: assetName.trim() || file.name, setAssetNames({});
videoDuration: uploaded.duration_seconds ?? duration, setUploadStatus({});
fileSize: uploaded.file_size_bytes ?? file.size, setUploadResults({});
mimeType: file.type || null, setImportStatus({});
uploadResourceId: uploaded.resource_id || null, }, 1500);
}); await Promise.all([loadProjects(), loadAssets(1, assetPageSize), refreshConfig()]);
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
setUploadOpen(false);
setFileList([]);
setAssetName('');
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '上传素材失败'); message.error(err?.message || '提交入库失败');
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -302,7 +319,7 @@ const VirtualMaterialPanel: React.FC = () => {
try { try {
await deletePrivatePortraitVirtualAsset(assetId); await deletePrivatePortraitVirtualAsset(assetId);
message.success('素材已删除,远端删除将异步执行'); message.success('素材已删除,远端删除将异步执行');
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]); await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize), refreshConfig()]);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '删除素材失败'); message.error(err?.message || '删除素材失败');
} }
@@ -571,21 +588,163 @@ const VirtualMaterialPanel: React.FC = () => {
onOk={handleUpload} onOk={handleUpload}
confirmLoading={uploading} confirmLoading={uploading}
okText="提交入库" okText="提交入库"
width={650}
okButtonProps={{
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
> >
<Space direction="vertical" style={{ width: '100%' }} size={14}> <Space direction="vertical" style={{ width: '100%' }} size={14}>
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Upload <Upload
accept="image/*,video/*" accept="image/*,video/*"
maxCount={1}
fileList={fileList} fileList={fileList}
beforeUpload={() => false} multiple
onChange={({ fileList: next }) => setFileList(next)} 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" 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> <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 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI {MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
</div> </div>
</Space> </Space>
</Modal> </Modal>
+20 -16
View File
@@ -2786,7 +2786,7 @@ const AIChatPage: React.FC = () => {
{firstFrame ? ( {firstFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}> <div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`} src={buildPreviewUrl(firstFrame.url)}
alt={firstFrame.name} alt={firstFrame.name}
onClick={() => { onClick={() => {
setAttachmentPreviewUrl(firstFrame.url); setAttachmentPreviewUrl(firstFrame.url);
@@ -2836,6 +2836,8 @@ const AIChatPage: React.FC = () => {
url: previewUrl, url: previewUrl,
role: 'first_frame', role: 'first_frame',
label: '', label: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
}; };
setFirstFrame(mediaRef); setFirstFrame(mediaRef);
antdMessage.success('成功添加首帧'); antdMessage.success('成功添加首帧');
@@ -2875,7 +2877,7 @@ const AIChatPage: React.FC = () => {
{lastFrame ? ( {lastFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}> <div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`} src={buildPreviewUrl(lastFrame.url)}
alt={lastFrame.name} alt={lastFrame.name}
onClick={() => { onClick={() => {
setAttachmentPreviewUrl(lastFrame.url); setAttachmentPreviewUrl(lastFrame.url);
@@ -2917,20 +2919,22 @@ const AIChatPage: React.FC = () => {
} }
}} }}
onPortraitSelect={(assets) => { onPortraitSelect={(assets) => {
if (assets.length > 0) { if (assets.length > 0) {
const asset = assets[0]; const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || ''; const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = { const mediaRef: MediaReference = {
name: asset.name || '尾帧图片', name: asset.name || '尾帧图片',
type: 'image', type: 'image',
url: previewUrl, url: previewUrl,
role: 'last_frame', role: 'last_frame',
label: '', label: '',
}; source: 'private_portrait_asset',
setLastFrame(mediaRef); private_asset_id: asset.id,
antdMessage.success('成功添加尾帧'); };
} setLastFrame(mediaRef);
}} antdMessage.success('成功添加尾帧');
}
}}
uploading={uploading} uploading={uploading}
> >
<div <div
+1 -1
View File
@@ -349,7 +349,7 @@ const HomePage: React.FC = () => {
}, },
{ {
icon: <RobotOutlined style={{ fontSize: 24 }} />, icon: <RobotOutlined style={{ fontSize: 24 }} />,
title: 'AI成片', title: 'AI创作',
description: '输入想法、剧本或上传参考,智能生成视频/图片', description: '输入想法、剧本或上传参考,智能生成视频/图片',
action: '立即生成', action: '立即生成',
path: '/conversation', path: '/conversation',