“真人人像/虚拟素材批量上传”
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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user