This commit is contained in:
sjy
2026-07-09 18:31:01 +08:00
71 changed files with 3736 additions and 504 deletions
+41 -2
View File
@@ -165,6 +165,43 @@ export async function uploadVideo(file: File, durationSeconds?: number): Promise
return await res.json();
}
function privatePortraitUploadEndpoint(path: string, durationSeconds?: number): string {
const base = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api${path}`;
const query = typeof durationSeconds === 'number' && durationSeconds > 0
? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}`
: '';
return `${base}${query}`;
}
async function uploadPrivatePortraitFile(path: string, file: File, durationSeconds?: number, errorMessage = '素材上传失败'): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(privatePortraitUploadEndpoint(path, durationSeconds), {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error(errorMessage);
return await res.json();
}
export async function uploadPrivatePortraitImage(file: File): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/uploads/image', file, undefined, '真人图片素材上传失败');
}
export async function uploadPrivatePortraitVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/uploads/video', file, durationSeconds, '真人视频素材上传失败');
}
export async function uploadPrivatePortraitVirtualImage(file: File): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/image', file, undefined, '虚拟图片素材上传失败');
}
export async function uploadPrivatePortraitVirtualVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/video', file, durationSeconds, '虚拟视频素材上传失败');
}
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
const form = new FormData();
form.append('file', file);
@@ -962,7 +999,7 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`);
}
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null; uploadResourceId?: string | null }): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
url: payload.url,
asset_type: payload.assetType || 'Image',
@@ -971,6 +1008,7 @@ export async function createPrivatePortraitAsset(projectId: string, payload: { u
video_cover_url: payload.videoCoverUrl || null,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType || null,
upload_resource_id: payload.uploadResourceId || null,
});
}
@@ -1032,7 +1070,7 @@ export async function deletePrivatePortraitVirtualProject(projectId: string): Pr
await api.delete(`/private-portrait/virtual-projects/${projectId}`);
}
export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null; uploadResourceId?: string | null }): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
url: payload.url,
asset_type: payload.assetType || 'Image',
@@ -1041,6 +1079,7 @@ export async function createPrivatePortraitVirtualAsset(projectId: string, paylo
video_cover_url: payload.videoCoverUrl || null,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType || null,
upload_resource_id: payload.uploadResourceId || null,
});
}
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Button, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadImage, uploadVideo } from '../../../api';
import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api';
const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15;
@@ -89,14 +89,15 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
return;
}
const uploaded = assetType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
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,
fileSize: file.size,
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();
@@ -63,12 +63,12 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
const items = useMemo(() => [
{
key: 'real_person',
label: '真人素材',
label: '真人素材',
children: <RealPersonLibraryPanel />,
},
{
key: 'aigc_virtual',
label: '虚拟素材',
label: '虚拟素材',
children: <VirtualMaterialPanel />,
},
], []);
@@ -84,12 +84,12 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
};
return (
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<div>
{/* <div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
<Text type="secondary"> AIGC Asset Group</Text>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<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>
@@ -103,7 +103,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
{activeKey === 'aigc_virtual'
? '虚拟人像项目会同步创建火山 AIGC Asset Group。'
? '虚拟素材如需使用需先上传'
: '真人项目组需完成人脸认证后才可上传素材。'}
</Paragraph>
</Card>
@@ -112,15 +112,18 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
<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' }}> Active AI </Paragraph>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 入库成功 状态素材可在 AI 创作中引用。</Paragraph>
</Card>
</Col>
</Row>
</Row> */}
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
items={items}
destroyInactiveTabPane={false}
tabBarExtraContent={
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
}
/>
</div>
);
@@ -117,32 +117,29 @@ const RealPersonLibraryPanel: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</div>
<Row gutter={16}>
<Col xs={24} lg={7}>
<Col xs={24} lg={5}>
<Card
title={<span></span>}
extra={(
<Space size={8}>
{/* <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading} size="small">刷新</Button> */}
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small"></Button>
</Space>
)}
// title="真人素材项目组"
style={{ borderRadius: 16, minHeight: 520 }}
>
<Spin spinning={loading}>
{projects.length === 0 ? (
<Empty description="暂无项目组" />
<Empty description="暂无真人项目组" />
) : (
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
)}
</Spin>
</Card>
</Col>
<Col xs={24} lg={17}>
<Col xs={24} lg={19}>
{selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : (
@@ -35,8 +35,8 @@ import {
deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualProjects,
uploadImage,
uploadVideo,
uploadPrivatePortraitVirtualImage,
uploadPrivatePortraitVirtualVideo,
} from '../../../api';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
@@ -276,14 +276,15 @@ const VirtualMaterialPanel: React.FC = () => {
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
return;
}
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
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: duration,
fileSize: file.size,
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);
@@ -385,13 +386,19 @@ const VirtualMaterialPanel: React.FC = () => {
};
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={24} lg={7}>
<div >
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">使</Typography.Text>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</div>
<Row gutter={[10, 10]}>
<Col xs={24} lg={5} >
<Card
title="虚拟人像项目组"
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>}
style={{ borderRadius: 16, minHeight: 520 }}
// title="虚拟人像项目组"
style={{ borderRadius: 16, minHeight: 520, margin: '0 auto', maxWidth: 1200}}
>
<Spin spinning={projectLoading}>
{projects.length === 0 ? (
@@ -434,7 +441,7 @@ const VirtualMaterialPanel: React.FC = () => {
</Card>
</Col>
<Col xs={24} lg={17}>
<Col xs={24} lg={19}>
<Card
title={selectedProject ? selectedProject.name : '素材资产'}
extra={(
@@ -518,7 +525,7 @@ const VirtualMaterialPanel: React.FC = () => {
onCancel={() => setCreateOpen(false)}
onOk={handleCreateProject}
confirmLoading={creatingProject}
okText="创建并同步火山 Asset Group"
okText="创建项目组"
>
<Form form={createForm} layout="vertical">
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
+38 -13
View File
@@ -384,30 +384,38 @@ const AIChatPage: React.FC = () => {
config = {
perSecondCredits: 2,
baseCredits: 60,
ratio: 1,
inputVideoRatio: 1,
ratio: 1.3,
inputVideoRatio: 1.3,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 0.5,
inputVideoPerSecondCredits: 15,
inputImageRatio: 1,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.0,
};
if (videoResolution === '1080p') {
config.ratio = 2;
config.ratio = 1.3;
} else if (videoResolution === '720p') {
config.ratio = 1.5;
config.ratio = 1.3;
} else if (videoResolution === '480p') {
config.ratio = 1;
config.ratio = 1.3;
}
}
else {
// 如果没有找到对应的配置,则使用默认配置
config = {
perSecondCredits: 0.1,
baseCredits: 4,
ratio: 1,
baseCredits: 2,
ratio: 3,
inputImageRatio: 1,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.0,
};
if (selectedResolution === '2K') {
config.ratio = 1;
config.ratio = 3;
} else if (selectedResolution === '4K') {
config.ratio = 2;
config.ratio = 3;
} else if (selectedResolution === '1K') {
config.ratio = 3;
}
}
}
@@ -424,10 +432,27 @@ const AIChatPage: React.FC = () => {
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
total += inputVideoCost;
}
// 传入图片积分
const inputImageCount = currentMedia
.filter((m) => m.type === 'image')
.length;
if (inputImageCount > 0) {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
} else {
// 图片:baseCredits × ratio
return Number((config.baseCredits * config.ratio).toFixed(2));
let total = config.baseCredits * config.ratio;
// 传入图片积分
const inputImageCount = currentMedia
.filter((m) => m.type === 'image')
.length;
if (inputImageCount > 0) {
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
total += inputImageCost;
}
return Number(total.toFixed(2));
}
};
@@ -1849,9 +1874,9 @@ const AIChatPage: React.FC = () => {
// ==================== 渲染 ====================
const isFirstLastFrameComposer = mediaType === 'video' && referenceMode === 'first_last_frame';
const composerCanSend = isFirstLastFrameComposer
const composerCanSend = !uploading && (isFirstLastFrameComposer
? Boolean(inputValue.trim() || firstFrame)
: Boolean(inputValue.trim() || currentMedia.length > 0);
: Boolean(inputValue.trim() || currentMedia.length > 0));
const composerModeLabel = mediaType === 'image'
? '图片生成'
: isFirstLastFrameComposer
+44 -34
View File
@@ -68,6 +68,7 @@ const GeneratedRecord: React.FC = () => {
const [previewItem, setPreviewItem] = useState<any>(null);
const videoRef = React.createRef<HTMLVideoElement>();
const [selectedDate, setSelectedDate] = useState<string>('');
const [searchKeyword, setSearchKeyword] = useState<string>('');
const [uploading, setUploading] = useState(false);
@@ -175,11 +176,23 @@ const GeneratedRecord: React.FC = () => {
document.body.removeChild(link);
};
// 预览文件
const handlePreview = (item: any) => {
const handlePreview = async (item: any) => {
setPreviewItem(item);
setPreviewVisible(true);
// 触发事件通知布局组件关闭浮动按钮
window.dispatchEvent(new Event('previewOpen'));
// 如果是视频且没有尺寸信息,异步获取视频尺寸
if (item.videoUrl && item.videoUrl.trim() && !item.imagePx) {
try {
const dimensions = await getVideoDimensions(item.videoUrl);
if (dimensions) {
setPreviewItem((prev: any) => prev ? { ...prev, imagePx: dimensions } : prev);
}
} catch (e) {
// 忽略获取尺寸失败
}
}
};
// 关闭预览并暂停视频
const handleClosePreview = () => {
@@ -778,11 +791,17 @@ const GeneratedRecord: React.FC = () => {
if (historySource) {
parameters = `?gen_type=${filterMedia}&history_source=${historySource}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
}
if (searchKeyword && searchKeyword.trim()) {
parameters += `&keyword=${encodeURIComponent(searchKeyword.trim())}`;
}
if (selectedDate) {
let parameters = `${selectedDate}?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
if (historySource) {
parameters = `${selectedDate}?gen_type=${filterMedia}&history_source=${historySource}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
}
if (searchKeyword && searchKeyword.trim()) {
parameters += `&keyword=${encodeURIComponent(searchKeyword.trim())}`;
}
gethistoryItems(parameters).then((res: any) => {
const data = Array.isArray(res) ? res : (res?.items || []);
let recordList = [{
@@ -796,21 +815,6 @@ const GeneratedRecord: React.FC = () => {
} else {
setRecordList([]);
}
if (isPageLoaded) {
data.forEach(async (item: any) => {
if (item.videoUrl && item.videoUrl.trim()) {
const dimensions = await getVideoDimensions(item.videoUrl);
if (dimensions) {
setRecordList(prev => prev.map(group => ({
...group,
items: group.items.map((i: any) =>
i.id === item.id ? { ...i, imagePx: dimensions } : i
),
})));
}
}
});
}
}).catch((err) => {
}).finally(() => {
setLoading(false);
@@ -831,23 +835,6 @@ const GeneratedRecord: React.FC = () => {
setRecordList(prev => [...prev, ...data]);
}
setTotalnumber(res?.totalDays || 0);
if (isPageLoaded) {
data.forEach(group => {
group.items.forEach(async (item: any) => {
if (item.videoUrl && item.videoUrl.trim()) {
const dimensions = await getVideoDimensions(item.videoUrl);
if (dimensions) {
setRecordList(prev => prev.map(g => ({
...g,
items: g.items.map((i: any) =>
i.id === item.id ? { ...i, imagePx: dimensions } : i
),
})));
}
}
});
});
}
}).catch((err) => {
if (Pagebreak.page === 1) {
setRecordList([]);
@@ -885,6 +872,9 @@ const GeneratedRecord: React.FC = () => {
if (historySource) {
parameters = `${time}?gen_type=${filterMedia}&history_source=${historySource}&page=${addpage}&page_size=${Pagebreak.pageSize}`;
}
if (searchKeyword && searchKeyword.trim()) {
parameters += `&keyword=${encodeURIComponent(searchKeyword.trim())}`;
}
try {
const res: any = await gethistoryItems(parameters);
const newItems: any[] = res.items || [];
@@ -909,12 +899,13 @@ const GeneratedRecord: React.FC = () => {
});
}
};
// 当筛选条件改变时,重置页码
// 当筛选条件改变时,重置页码和搜索关键词
useEffect(() => {
setPagebreak(prev => ({
...prev,
page: 1
}));
setSearchKeyword('');
}, [filterType, filterMedia]);
return (
<div className="content_box" >
@@ -1218,6 +1209,24 @@ const GeneratedRecord: React.FC = () => {
</Button>
)}
<Input.Search
placeholder="搜索提示词"
allowClear
value={searchKeyword}
onChange={(e) => {
const val = e.target.value;
setSearchKeyword(val);
if (!val) {
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
}
}}
onSearch={() => {
setPagebreak(prev => ({ ...prev, page: 1 }));
loadRecordList();
}}
style={{ width: 240, borderRadius: 8 }}
/>
</Space>
</div>
<Button
@@ -1325,6 +1334,7 @@ const GeneratedRecord: React.FC = () => {
<img
src={displayUrl}
alt="预览"
loading="lazy"
style={{
width: '100%',
height: '100%',