466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
|
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
|
|
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
|
|
|
|
export default function VideoFrameExtractor() {
|
|
const navigate = useNavigate();
|
|
|
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
|
const [error, setError] = useState<string>('');
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [productName, setProductName] = useState<string>('');
|
|
const [videoDuration, setVideoDuration] = useState<number>(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [tableData, setTableData] = useState<any[]>([]);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(10);
|
|
const [total, setTotal] = useState(0);
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
const cleanupResources = useCallback(() => {
|
|
if (videoUrl) {
|
|
URL.revokeObjectURL(videoUrl);
|
|
}
|
|
setVideoUrl('');
|
|
setError('');
|
|
setVideoDuration(0);
|
|
setProductName('');
|
|
}, [videoUrl]);
|
|
|
|
const handleFileChange = async (file: File) => {
|
|
if (!file.type.startsWith('video/')) {
|
|
setError('请选择视频文件');
|
|
return false;
|
|
}
|
|
|
|
if (file.size > 100 * 1024 * 1024) {
|
|
setError('视频文件大小不能超过 100MB');
|
|
return false;
|
|
}
|
|
|
|
cleanupResources();
|
|
setLoading(true);
|
|
|
|
try {
|
|
const uploadResult = await uploadVideo(file);
|
|
setVideoUrl(uploadResult.url);
|
|
setError('');
|
|
message.success('视频上传成功');
|
|
} catch (err) {
|
|
setError('视频上传失败,请重试');
|
|
message.error('视频上传失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const handleVideoLoaded = useCallback(() => {
|
|
if (videoRef.current) {
|
|
setVideoDuration(videoRef.current.duration);
|
|
}
|
|
}, []);
|
|
|
|
const handleCreate = async () => {
|
|
if (!videoUrl || !productName.trim()) {
|
|
message.warning('请先上传视频并输入产品名称');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const params = {
|
|
video_url: videoUrl,
|
|
video_duration_seconds: videoDuration,
|
|
title: productName.trim(),
|
|
idempotency_key: `shot_${Date.now()}`,
|
|
};
|
|
|
|
await createShotReplication(params);
|
|
message.success('任务创建成功');
|
|
// 清空上传内容和输入框
|
|
cleanupResources();
|
|
navigate('/');
|
|
} catch (err) {
|
|
message.error('任务创建失败,请重试');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchList = async (page: number, size: number) => {
|
|
try {
|
|
const res = await getShotReplicationList(page, size);
|
|
setTableData(res.items || []);
|
|
setTotal(res.total || 0);
|
|
setCurrentPage(page);
|
|
setPageSize(size);
|
|
} catch (err) {
|
|
message.error('获取列表失败');
|
|
}
|
|
};
|
|
|
|
const handlePageChange = (page: number, size: number) => {
|
|
fetchList(page, size);
|
|
};
|
|
|
|
// 打开弹窗时获取列表数据
|
|
const handleOpenModal = () => {
|
|
setIsModalOpen(true);
|
|
fetchList(1, 10);
|
|
};
|
|
|
|
return (
|
|
<div style={{ minHeight: 'calc(100vh - 90px)', overflow: 'auto', background: 'linear-gradient(135deg, #f5f3ff 0%, #fdf2f8 100%)', padding: '20px' }}>
|
|
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<div>
|
|
<div style={{
|
|
width: 36,
|
|
height: 36,
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
|
|
borderRadius: 10,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'white',
|
|
fontSize: 18,
|
|
fontWeight: 600
|
|
}}>
|
|
✨
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h1 style={{
|
|
fontSize: 22,
|
|
fontWeight: 600,
|
|
margin: 0,
|
|
color: '#1e293b'
|
|
}}>
|
|
拆镜复刻
|
|
</h1>
|
|
<p style={{ fontSize: 13, color: '#94a3b8', margin: 4 }}>
|
|
一键拆解画面分镜,助力仿拍或创作
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleOpenModal}
|
|
style={{
|
|
padding: '10px 20px',
|
|
background: 'white',
|
|
border: '1px solid #e2e8f0',
|
|
borderRadius: 8,
|
|
cursor: 'pointer',
|
|
fontSize: 14,
|
|
color: '#6366f1',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
|
}}
|
|
>
|
|
<FileTextOutlined />
|
|
<span>创作记录</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 32 }}>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<div style={{
|
|
width: 56,
|
|
height: 56,
|
|
background: 'linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%)',
|
|
borderRadius: 16,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
margin: '0 auto 16px'
|
|
}}>
|
|
<span style={{ fontSize: 28 }}>🎬</span>
|
|
</div>
|
|
<h2 style={{
|
|
fontSize: 22,
|
|
fontWeight: 600,
|
|
margin: 0,
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
|
|
WebkitBackgroundClip: 'text',
|
|
WebkitTextFillColor: 'transparent',
|
|
backgroundClip: 'text'
|
|
}}>
|
|
上传视频开始创作
|
|
</h2>
|
|
<p style={{ fontSize: 14, color: '#888', margin: '8px 0 0 0' }}>
|
|
支持 MP4、MOV 格式视频,最大支持 100MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ width: '100%', maxWidth: 720, margin: '0 auto' }}>
|
|
{error && (
|
|
<div style={{
|
|
backgroundColor: '#fff5f5',
|
|
border: '1px solid #ffccc7',
|
|
borderRadius: 8,
|
|
padding: '12px 16px',
|
|
marginBottom: 20,
|
|
color: '#d93026',
|
|
fontSize: 14,
|
|
textAlign: 'center'
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!videoUrl && (
|
|
<Upload.Dragger
|
|
accept="video/*"
|
|
beforeUpload={handleFileChange}
|
|
showUploadList={false}
|
|
style={{
|
|
background: '#faf5ff',
|
|
border: '2px dashed #c4b5fd',
|
|
borderRadius: 24,
|
|
padding: '60px 20px'
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: 100,
|
|
height: 100,
|
|
margin: '0 auto 20px',
|
|
background: 'linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%)',
|
|
borderRadius: 24,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
}}>
|
|
<CloudUploadOutlined style={{ fontSize: 40, color: '#818cf8' }} />
|
|
</div>
|
|
<p style={{ fontSize: 16, color: '#333', margin: '0 0 8px 0', fontWeight: 500 }}>
|
|
点击或拖拽到此上传视频文件
|
|
</p>
|
|
<p style={{ fontSize: 13, color: '#999', margin: 0 }}>
|
|
支持 MP4、MOV 格式视频,最大支持 100MB 的文件,支持上传 3 分钟内的视频
|
|
</p>
|
|
</Upload.Dragger>
|
|
)}
|
|
|
|
{videoUrl && (
|
|
<div style={{
|
|
position: 'relative',
|
|
borderRadius: 24,
|
|
padding: 4,
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
|
|
}}>
|
|
<div style={{
|
|
borderRadius: 22,
|
|
background: '#ffffff',
|
|
padding: '24px'
|
|
}}>
|
|
<Popconfirm
|
|
title="确定要删除吗?"
|
|
okText="确定"
|
|
cancelText="取消"
|
|
onConfirm={cleanupResources}
|
|
>
|
|
<button
|
|
style={{
|
|
position: 'absolute',
|
|
top: 20,
|
|
right: 20,
|
|
width: 36,
|
|
height: 36,
|
|
border: '1px solid #e5e7eb',
|
|
background: '#fff',
|
|
borderRadius: 10,
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: 16,
|
|
color: '#666',
|
|
zIndex: 10
|
|
}}
|
|
>
|
|
🗑️
|
|
</button>
|
|
</Popconfirm>
|
|
|
|
<div style={{
|
|
textAlign: 'center',
|
|
marginBottom: 20
|
|
}}>
|
|
<video
|
|
ref={videoRef}
|
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${videoUrl}`}
|
|
controls
|
|
onLoadedMetadata={handleVideoLoaded}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: 320,
|
|
borderRadius: 12,
|
|
background: '#000'
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 产品名称输入框 */}
|
|
<div style={{ marginBottom: 24 }}>
|
|
<label style={{
|
|
display: 'block',
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
color: '#374151',
|
|
marginBottom: 8
|
|
}}>
|
|
产品名称
|
|
</label>
|
|
<Input
|
|
placeholder="请输入产品名称"
|
|
value={productName}
|
|
onChange={(e) => setProductName(e.target.value)}
|
|
style={{
|
|
width: '100%',
|
|
height: 40,
|
|
borderRadius: 8,
|
|
borderColor: '#e5e7eb'
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={!videoUrl || !productName.trim() || loading}
|
|
style={{
|
|
padding: '14px 56px',
|
|
background: videoUrl && productName.trim() && !loading
|
|
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
|
|
: '#e2e8f0',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: 14,
|
|
fontSize: 15,
|
|
fontWeight: 600,
|
|
cursor: videoUrl && productName.trim() && !loading ? 'pointer' : 'not-allowed',
|
|
boxShadow: videoUrl && productName.trim() && !loading ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 10
|
|
}}
|
|
>
|
|
<span>{loading ? '创建中...' : '创建'}</span>
|
|
<span style={{
|
|
fontSize: 12,
|
|
opacity: 0.85,
|
|
fontWeight: 400
|
|
}}>
|
|
预计消耗 10 个积分
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
|
|
|
<Modal
|
|
title="创作记录"
|
|
open={isModalOpen}
|
|
onCancel={() => setIsModalOpen(false)}
|
|
width={800}
|
|
footer={null}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
|
<Input
|
|
placeholder="搜索产品名称"
|
|
style={{ width: 200, borderRadius: 6, marginRight: 8 }}
|
|
/>
|
|
<Button type="primary" style={{ borderRadius: 6 }}>
|
|
搜索
|
|
</Button>
|
|
</div>
|
|
|
|
<Table
|
|
columns={[
|
|
// {
|
|
// title: '产品图片',
|
|
// dataIndex: 'image',
|
|
// key: 'image',
|
|
// width: 90,
|
|
// render: (image: string) => (
|
|
// <img
|
|
// src={image}
|
|
// alt="产品图片"
|
|
// style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }}
|
|
// />
|
|
// ),
|
|
// },
|
|
{
|
|
title: '产品名称',
|
|
dataIndex: 'title',
|
|
key: 'title',
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
},
|
|
{
|
|
title: '创建时间',
|
|
dataIndex: 'createdAt',
|
|
key: 'createdAt',
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'action',
|
|
render: (record) => (
|
|
<button
|
|
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
|
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 13, border: 'none', background: 'none', cursor: 'pointer' }}
|
|
>
|
|
查看详情
|
|
</button>
|
|
),
|
|
},
|
|
]}
|
|
dataSource={tableData}
|
|
rowKey="id"
|
|
pagination={{
|
|
current: currentPage,
|
|
pageSize: pageSize,
|
|
total: total,
|
|
onChange: handlePageChange,
|
|
showSizeChanger: false,
|
|
showQuickJumper: false,
|
|
showTotal: (total) => `共 ${total} 条`,
|
|
placement: ['bottomCenter'],
|
|
itemRender: (_, type, originalElement) => {
|
|
if (type === 'page') {
|
|
return <span style={{ borderRadius: 4, margin: '0 4px' }}>{originalElement}</span>;
|
|
}
|
|
if (type === 'prev') {
|
|
return <span style={{ borderRadius: 4, margin: '0 4px' }}>上一页</span>;
|
|
}
|
|
if (type === 'next') {
|
|
return <span style={{ borderRadius: 4, margin: '0 4px' }}>下一页</span>;
|
|
}
|
|
return originalElement;
|
|
},
|
|
}}
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|