“真人人像/虚拟素材批量上传”
This commit is contained in:
+506
File diff suppressed because one or more lines are too long
-506
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -349,7 +349,7 @@ const HomePage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
icon: <RobotOutlined style={{ fontSize: 24 }} />,
|
||||
title: 'AI成片',
|
||||
title: 'AI创作',
|
||||
description: '输入想法、剧本或上传参考,智能生成视频/图片',
|
||||
action: '立即生成',
|
||||
path: '/conversation',
|
||||
|
||||
Reference in New Issue
Block a user