Files
video-gen/video-gen-app/src/pages/RemoveInfo.tsx
T
2026-06-17 17:55:02 +08:00

516 lines
22 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Drawer, Input, Table, Tag, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker';
const { TextArea } = Input;
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const MIN_TRIM_SECONDS = 2;
const MAX_TRIM_SECONDS = 15;
function buildAssetUrl(url?: string): string {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
return `${API_BASE}${url}`;
}
function RemoveInfo() {
const { creatID } = useParams<{ creatID: string }>();
const navigate = useNavigate();
const [drawerVisible, setDrawerVisible] = useState(false);
const [trimModalVisible, setTrimModalVisible] = useState(false);
const [currentSegment, setCurrentSegment] = useState<string | null>(null);
const [productName, setProductName] = useState('');
const [productSellingPoint, setProductSellingPoint] = useState('');
const [productImage, setProductImage] = useState('');
const [taskDetail, setTaskDetail] = useState<any>(null);
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [splitLoading, setSplitLoading] = useState(false);
const [autoSplitLoading, setAutoSplitLoading] = useState(false);
const videoUrl = useMemo(() => buildAssetUrl(taskDetail?.videoUrl), [taskDetail?.videoUrl]);
const fetchTaskDetail = useCallback(async () => {
if (!creatID) return;
try {
const res = await getShotReplicationDetail(creatID);
setTaskDetail(res);
} catch {
message.error('获取任务详情失败');
}
}, [creatID]);
const fetchSegments = useCallback(async () => {
if (!creatID) return;
try {
const res = await Removelist(creatID);
setTableData(res.items || []);
} catch {
message.error('获取拆镜列表失败');
}
}, [creatID]);
const refreshPageData = useCallback(async () => {
await Promise.all([fetchTaskDetail(), fetchSegments()]);
}, [fetchTaskDetail, fetchSegments]);
useEffect(() => {
refreshPageData();
}, [refreshPageData]);
const handleGenerate = (segmentId: string) => {
setCurrentSegment(segmentId);
setDrawerVisible(true);
};
const handleCloseDrawer = () => {
setDrawerVisible(false);
setCurrentSegment(null);
setProductName('');
setProductSellingPoint('');
setProductImage('');
};
const handleProductImageChange: any = (info: any) => {
if (info.fileList.length === 0) {
setProductImage('');
}
};
const beforeUploadProductImage = async (file: File) => {
try {
const uploadResult = await uploadImage(file);
setProductImage(uploadResult.url);
message.success('图片上传成功');
} catch {
message.error('图片上传失败,请重试');
}
return false;
};
const handleManualGenerate = async () => {
if (!currentSegment) {
message.warning('请先选择拆镜片段');
return;
}
if (!productImage) {
message.warning('请上传产品图');
return;
}
if (!productName.trim()) {
message.warning('请输入产品名称');
return;
}
if (!productSellingPoint.trim()) {
message.warning('请输入产品卖点');
return;
}
setLoading(true);
try {
const params = {
target_project_name: productName.trim(),
core_content_point: productSellingPoint.trim(),
material_image_url: buildAssetUrl(productImage),
idempotency_key: `replication_${Date.now()}`,
};
await removeCreate(currentSegment, params);
message.success('视频生成任务创建成功');
handleCloseDrawer();
await fetchSegments();
} catch (err: any) {
message.error(err?.message || '创建失败,请重试');
} finally {
setLoading(false);
}
};
const handleAutoGenerate = async () => {
if (!creatID) return;
setAutoSplitLoading(true);
try {
await createRemoveLens(creatID, {
selected_indices: [],
replace_existing: false,
});
message.success('AI 拆镜任务已提交');
await refreshPageData();
} catch (error: any) {
message.error(error?.message || '拆镜失败');
} finally {
setAutoSplitLoading(false);
}
};
const handleOpenTrimModal = () => {
if (!videoUrl) {
message.warning('原视频地址不存在');
return;
}
setTrimModalVisible(true);
};
const handleCustomSplit = async (range: { startSecond: number; endSecond: number; durationSecond: number }) => {
if (!creatID) return;
if (range.durationSecond < MIN_TRIM_SECONDS) {
message.warning(`拆镜片段不能低于 ${MIN_TRIM_SECONDS} 秒`);
return;
}
if (range.durationSecond > MAX_TRIM_SECONDS) {
message.warning(`拆镜片段不能超过 ${MAX_TRIM_SECONDS} 秒`);
return;
}
setSplitLoading(true);
try {
await splitCustom(creatID, {
start_second: range.startSecond,
end_second: range.endSecond,
});
message.success('手动拆镜任务已提交');
setTrimModalVisible(false);
await refreshPageData();
} catch (error: any) {
message.error(error?.message || '手动拆镜失败');
} finally {
setSplitLoading(false);
}
};
const canCreateReplication = (record: any) => {
return record?.splitStatus === 'completed' && !!record?.segmentVideoUrl;
};
const columns = [
{
title: '片段',
width: 100,
align: 'center' as const,
render: (_: any, record: any) => (
<div>
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.segmentName || `片段${record.segmentIndex || ''}`}</div>
<div style={{ fontSize: 12, color: '#999' }}>{record.timeNode}</div>
{record.sourceMode === 'custom' && <Tag color="blue" style={{ marginTop: 6 }}>手动</Tag>}
</div>
),
},
{
title: '片段视频',
width: 150,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9' }}>
{record.segmentVideoUrl ? (
<video
controls
src={buildAssetUrl(record.segmentVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
{record.splitStatus === 'failed' ? '切割失败' : '切割中'}
</div>
)}
</div>
),
},
{
title: '视频内容',
width: 250,
align: 'left' as const,
render: (_: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
{record.segmentContent || record.lastError || '-'}
</div>
),
},
{
title: '视频类型',
width: 120,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
{record.segmentCategory || '-'}
</div>
),
},
{
title: '状态',
width: 120,
align: 'center' as const,
render: (_: any, record: any) => {
const statusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待切割', color: 'default' },
processing: { text: '切割中', color: 'processing' },
retry_waiting: { text: '等待重试', color: 'warning' },
completed: { text: '已完成', color: 'success' },
failed: { text: '失败', color: 'error' },
};
const item = statusMap[record.splitStatus] || { text: record.splitStatus || '-', color: 'default' };
return <Tag color={item.color}>{item.text}</Tag>;
},
},
{
title: '素材',
width: 140,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
{record.moduleProjectId ? (
<Button
type="text"
onClick={() => navigate(`/removelens/${record.moduleProjectId}/removefenbu`)}
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
详情任务
</Button>
) : (
<Button
type="text"
onClick={() => handleGenerate(String(record.id))}
disabled={!canCreateReplication(record)}
style={{ color: canCreateReplication(record) ? '#656efa' : '#94a3b8', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
</Button>
)}
</div>
),
},
];
return (
<>
<div
style={{
minHeight: 'calc(100vh - 90px)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ background: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
style={{ fontSize: 16, color: '#666' }}
/>
</div>
</div>
{taskDetail ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
<div style={{ flex: 0.4, background: '#fff', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
<video
controls
src={videoUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}>视频总结</h2>
<div style={{ textAlign: 'right' }}>
<span style={{ color: '#999', fontSize: 12 }}>上传时间: {taskDetail.createdAt}</span>
</div>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>产品名称:</span>
<span style={{ color: '#333', fontWeight: 500 }}>{taskDetail.title}</span>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>受众群体:</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{(taskDetail.originalVideoAudience?.split('、') || []).map((point: string, index: number) => (
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
{point}
</Tag>
))}
</div>
</div>
<div style={{ display: 'flex' }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>视频内容:</span>
<span style={{ color: '#333' }}>{taskDetail.originalVideoContent}</span>
</div>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 20, marginTop: 20 }}>
<Button
type="default"
onClick={handleOpenTrimModal}
disabled={!videoUrl || splitLoading}
style={{
flex: 1,
height: 48,
borderRadius: 8,
borderColor: '#6366f1',
color: '#6366f1',
fontWeight: 500,
}}
>
手动
</Button>
<Button
type="primary"
onClick={handleAutoGenerate}
loading={autoSplitLoading}
disabled={autoSplitLoading}
style={{
flex: 1,
height: 48,
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
fontWeight: 500,
border: 'none',
}}
>
自动
</Button>
</div>
<div style={{ flex: 0.6, background: '#fff', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
bordered={false}
rowKey="id"
scroll={{ y: '1005' }}
/>
</div>
</div>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 16, color: '#999' }}>加载中...</span>
</div>
)}
</div>
<VideoTrimPicker
open={trimModalVisible}
videoUrl={videoUrl}
title="手动视频切片"
loading={splitLoading}
minDuration={MIN_TRIM_SECONDS}
maxDuration={MAX_TRIM_SECONDS}
onCancel={() => setTrimModalVisible(false)}
onConfirm={handleCustomSplit}
/>
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>
<span style={{ color: '#6366f1' }}>智能视频复刻</span>
<span style={{ color: '#6366f1' }}>-片段{currentSegment}</span>
</span>
<Button
type="text"
icon={<XOutlined />}
onClick={handleCloseDrawer}
style={{ padding: 0 }}
/>
</div>
}
placement="right"
closable={false}
onClose={handleCloseDrawer}
open={drawerVisible}
size={480}
styles={{
body: { padding: '24px' },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<label style={{ fontWeight: 400, color: '#333', marginBottom: 12, display: 'block' }}>
产品白底图 <span style={{ color: '#ff4d4f' }}>*</span>
</label>
<div style={{ display: 'flex', gap: 16 }}>
<Upload
listType="picture-card"
onChange={handleProductImageChange}
beforeUpload={beforeUploadProductImage}
maxCount={1}
accept="image/*"
style={{ width: 140, height: 140 }}
>
{!productImage && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
<span style={{ fontSize: 12, color: '#999' }}>产品图 *</span>
</div>
)}
</Upload>
</div>
</div>
<div>
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
产品名称
</label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="请输入产品名称"
style={{ height: 48, borderRadius: 8 }}
maxLength={10}
showCount
/>
</div>
<div>
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
产品卖点
</label>
<TextArea
value={productSellingPoint}
onChange={(e) => setProductSellingPoint(e.target.value)}
placeholder="请输入产品卖点"
style={{ borderRadius: 8 }}
maxLength={100}
showCount
rows={3}
/>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 24 }}>
<Button
type="default"
onClick={handleManualGenerate}
loading={loading}
disabled={loading}
style={{
flex: 1,
height: 48,
borderRadius: 8,
borderColor: '#6366f1',
color: '#6366f1',
fontWeight: 500,
}}
>
{loading ? '生成中...' : '手动生成'}
</Button>
</div>
</div>
</Drawer>
</>
);
}
export default RemoveInfo;