爆款开头复刻
This commit is contained in:
Binary file not shown.
@@ -342,3 +342,35 @@ export async function getAuthorizationList(params: OAuthAppParam): Promise<OAuth
|
||||
|
||||
return api.get<OAuthAppList>(`/admin/user-oauth-apps/list?${query.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
// 爆款开头复刻
|
||||
export async function generateReplication(params: any): Promise<any> {
|
||||
return api.post('/hot-opening-replications/tasks', params);
|
||||
}
|
||||
// 获取爆款开头复刻任务列表
|
||||
export async function getReplicationList(page: number,page_size: number): Promise<any[]> {
|
||||
return api.get(`/hot-opening-replications/tasks?page=${page}&page_size=${page_size}`);
|
||||
}
|
||||
// 获取爆款开头复刻任务详情
|
||||
export async function getReplicationDetail(id: string): Promise<any> {
|
||||
return api.get(`/hot-opening-replications/tasks/${id}`);
|
||||
}
|
||||
// 第一步,生成提示词
|
||||
export async function getone(projectId: string, stepId: string): Promise<any> {
|
||||
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image-prompt`);
|
||||
}
|
||||
|
||||
// 第二步,生成图片
|
||||
export async function gettwo(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image`, params);
|
||||
}
|
||||
|
||||
// 第三步,生成视频提示词
|
||||
export async function getthree(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video-prompt`, params);
|
||||
}
|
||||
// 第四步,生成视频
|
||||
export async function getfour(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-video`, params);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ const AIChatPage: React.FC = () => {
|
||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// 提示词展开状态
|
||||
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
|
||||
|
||||
// 从URL中提取exp时间戳(支持相对路径和完整URL)
|
||||
const extractExpTimestamp = (url: string): number | null => {
|
||||
if (!url) return null;
|
||||
@@ -249,7 +252,6 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
console.log(config);
|
||||
|
||||
|
||||
// 根据配置计算积分
|
||||
@@ -456,7 +458,7 @@ const AIChatPage: React.FC = () => {
|
||||
setTotalnumber(data.total);
|
||||
}).catch((error) => {
|
||||
});
|
||||
}, 5000);
|
||||
}, 10000);
|
||||
|
||||
// 清理定时器
|
||||
return () => {
|
||||
@@ -807,7 +809,7 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setPreviewVisible(false);
|
||||
setPreviewUrl('');
|
||||
setPreviewUrl('');
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
@@ -818,7 +820,7 @@ const AIChatPage: React.FC = () => {
|
||||
e.stopPropagation();
|
||||
if (!previewUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`;
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
|
||||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -1088,7 +1090,7 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{/* 删除按钮 - 右上角 */}
|
||||
<div style={{ position: 'absolute', top: 8, right: 8 }}>
|
||||
<div style={{ position: 'absolute', top: 8, right: 8 ,zIndex: 100}}>
|
||||
<Popconfirm
|
||||
title="确定要删除吗?"
|
||||
onConfirm={async () => {
|
||||
@@ -1099,7 +1101,6 @@ const AIChatPage: React.FC = () => {
|
||||
setGen_list(prev => prev.filter(item => item.id !== msg.id));
|
||||
setTotalnumber(prev => prev - 1);
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
|
||||
const errorMsg = error?.response?.data?.message || error?.message || '删除失败';
|
||||
msgApi.error(errorMsg);
|
||||
@@ -1115,8 +1116,8 @@ const AIChatPage: React.FC = () => {
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: 'rgba(0,0,0,0.05)',
|
||||
color: '#999',
|
||||
background: 'rgba(146, 144, 144, 1)',
|
||||
color: '#ffffffff',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -1140,29 +1141,56 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<Tooltip
|
||||
title={msg.originalPrompt}
|
||||
placement="top"
|
||||
style={{ maxWidth: '400px' }}
|
||||
>
|
||||
<p style={{
|
||||
margin: '8px 0',
|
||||
fontSize: 14,
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
margin: '8px 0',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: 13,
|
||||
color: '#475569',
|
||||
lineHeight: 1.6,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.05)',
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setExpandedPrompts(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(msg.id);
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setExpandedPrompts(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(msg.id);
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* 默认显示:一行省略 */}
|
||||
<div style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f1f5f9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
{msg.originalPrompt}
|
||||
</p>
|
||||
</Tooltip>
|
||||
display: expandedPrompts.has(msg.id) ? 'none' : 'block',
|
||||
}}>
|
||||
{msg.originalPrompt}
|
||||
</div>
|
||||
|
||||
{/* 鼠标移入显示:完整内容 */}
|
||||
<div style={{
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
display: expandedPrompts.has(msg.id) ? 'block' : 'none',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{msg.originalPrompt}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 根据 status 显示不同内容 */}
|
||||
{/* 生成中 - 显示加载动画 */}
|
||||
@@ -2354,7 +2382,7 @@ const AIChatPage: React.FC = () => {
|
||||
<Button key="download" type="primary" onClick={() => {
|
||||
if (!attachmentPreviewUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`;
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}&download=1`;
|
||||
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -4323,7 +4323,7 @@ const GeneratePage: React.FC = () => {
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${type === "video" ? record.videoUrl : record.imageUrl}`;
|
||||
a.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${type === "video" ? record.videoUrl : record.imageUrl}&download=1`;
|
||||
a.download = `${record.projectName}${type === "video" ? ".mp4" : ".png"}`;
|
||||
a.click();
|
||||
}}
|
||||
|
||||
@@ -1048,7 +1048,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}`;
|
||||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}&download=1`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
style={{ flex: 1, borderRadius: 8 }}
|
||||
|
||||
+1026
-110
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -401,7 +401,7 @@ const RecordsPage: React.FC = () => {
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
|
||||
<Tooltip title={`下载${type === 'video' ? '视频' : '图片'}`}>
|
||||
<Button size="small" icon={<DownloadOutlined />}
|
||||
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${type === 'video' ? record.videoUrl : record.imageUrl}`; a.download = `${record.projectName}${type === 'video' ? '.mp4' : '.png'}`; a.click(); }}
|
||||
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${type === 'video' ? record.videoUrl : record.imageUrl}&download=1`; a.download = `${record.projectName}${type === 'video' ? '.mp4' : '.png'}`; a.click(); }}
|
||||
style={{ background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', backdropFilter: 'blur(4px)', borderRadius: 8 }}>
|
||||
下载
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Button, Modal, Input, Table, Upload, Popconfirm } from 'antd';
|
||||
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -9,40 +9,12 @@ export default function VideoFrameExtractor() {
|
||||
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 [segmentStart, setSegmentStart] = useState<number>(0);
|
||||
const [segmentEnd, setSegmentEnd] = useState<number>(0);
|
||||
const [isSegmentSelected, setIsSegmentSelected] = useState<boolean>(false);
|
||||
|
||||
const [dragging, setDragging] = useState<'start' | 'end' | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const [framePreviews, setFramePreviews] = useState<string[]>([]);
|
||||
|
||||
const [tableData] = useState<any[]>([
|
||||
{
|
||||
id: 1,
|
||||
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square',
|
||||
originalName: '进圈',
|
||||
productName: '他趣',
|
||||
status: '视频成功',
|
||||
createTime: '2026-05-14 17:49:20',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square',
|
||||
originalName: '香水',
|
||||
productName: '面霜',
|
||||
status: '视频提示词成功',
|
||||
createTime: '2026-05-08 08:57:46',
|
||||
},
|
||||
]);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const hiddenVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const cleanupResources = useCallback(() => {
|
||||
if (videoUrl) {
|
||||
@@ -51,12 +23,7 @@ export default function VideoFrameExtractor() {
|
||||
setVideoUrl('');
|
||||
setError('');
|
||||
setVideoDuration(0);
|
||||
setSegmentStart(0);
|
||||
setSegmentEnd(0);
|
||||
setIsSegmentSelected(false);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setFramePreviews([]);
|
||||
setProductName('');
|
||||
}, [videoUrl]);
|
||||
|
||||
const handleFileChange = (file: File) => {
|
||||
@@ -78,213 +45,104 @@ export default function VideoFrameExtractor() {
|
||||
return false;
|
||||
};
|
||||
|
||||
const generateFramePreviews = useCallback(async () => {
|
||||
if (!canvasRef.current || videoDuration <= 0 || !videoUrl) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 创建隐藏的视频元素用于生成帧预览
|
||||
if (!hiddenVideoRef.current) {
|
||||
hiddenVideoRef.current = document.createElement('video');
|
||||
hiddenVideoRef.current.style.display = 'none';
|
||||
hiddenVideoRef.current.crossOrigin = 'anonymous';
|
||||
document.body.appendChild(hiddenVideoRef.current);
|
||||
}
|
||||
|
||||
const hiddenVideo = hiddenVideoRef.current;
|
||||
hiddenVideo.src = videoUrl;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
hiddenVideo.addEventListener('loadedmetadata', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
// 按秒数生成帧,每秒 1 帧,最多 15 帧
|
||||
const numFrames = Math.min(Math.ceil(videoDuration), 15);
|
||||
const previews: string[] = [];
|
||||
|
||||
for (let i = 0; i < numFrames; i++) {
|
||||
const time = ((i + 0.5) / numFrames) * videoDuration;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handleSeeked = () => {
|
||||
hiddenVideo.removeEventListener('seeked', handleSeeked);
|
||||
setTimeout(resolve, 80);
|
||||
};
|
||||
hiddenVideo.addEventListener('seeked', handleSeeked);
|
||||
hiddenVideo.currentTime = time;
|
||||
});
|
||||
|
||||
canvas.width = 120;
|
||||
canvas.height = 80;
|
||||
ctx.drawImage(hiddenVideo, 0, 0, canvas.width, canvas.height);
|
||||
previews.push(canvas.toDataURL('image/jpeg', 0.8));
|
||||
}
|
||||
|
||||
setFramePreviews(previews);
|
||||
}, [videoDuration, videoUrl]);
|
||||
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
const duration = videoRef.current.duration;
|
||||
setVideoDuration(duration);
|
||||
setSegmentStart(0);
|
||||
setSegmentEnd(Math.min(15, duration));
|
||||
setIsSegmentSelected(true);
|
||||
setVideoDuration(videoRef.current.duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoDuration > 0) {
|
||||
generateFramePreviews();
|
||||
}
|
||||
}, [videoDuration, generateFramePreviews]);
|
||||
|
||||
const handleVideoTimeUpdate = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const cs = Math.floor((seconds % 1) * 100);
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}:${String(cs).padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
const formatTimeShort = useCallback((seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
const getTimeFromEvent = useCallback((clientX: number): number | null => {
|
||||
if (!timelineRef.current || videoDuration === 0) return null;
|
||||
const rect = timelineRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(rect.width, clientX - rect.left));
|
||||
const percentage = x / rect.width;
|
||||
return percentage * videoDuration;
|
||||
}, [videoDuration]);
|
||||
|
||||
const handleTimelineMouseDown = useCallback((
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
handle: 'start' | 'end'
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragging(handle);
|
||||
}, []);
|
||||
|
||||
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
const time = getTimeFromEvent(e.clientX);
|
||||
if (time !== null && videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
}
|
||||
}, [getTimeFromEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const time = getTimeFromEvent(e.clientX);
|
||||
if (time === null) return;
|
||||
|
||||
if (dragging === 'start') {
|
||||
setSegmentStart(Math.max(0, Math.min(time, segmentEnd - 0.3)));
|
||||
} else {
|
||||
setSegmentEnd(Math.max(segmentStart + 0.3, Math.min(time, videoDuration)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, segmentStart, segmentEnd, videoDuration, getTimeFromEvent]);
|
||||
|
||||
const segmentDuration = useMemo(() => segmentEnd - segmentStart, [segmentEnd, segmentStart]);
|
||||
const segmentValid = useMemo(() => segmentDuration >= 4 && segmentDuration <= 15, [segmentDuration]);
|
||||
|
||||
const startPercent = useMemo(() => videoDuration > 0 ? (segmentStart / videoDuration) * 100 : 0, [segmentStart, videoDuration]);
|
||||
const endPercent = useMemo(() => videoDuration > 0 ? (segmentEnd / videoDuration) * 100 : 100, [segmentEnd, videoDuration]);
|
||||
const rangeWidth = useMemo(() => endPercent - startPercent, [endPercent, startPercent]);
|
||||
const tableData = [
|
||||
{
|
||||
id: 1,
|
||||
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square',
|
||||
originalName: '进圈',
|
||||
productName: '他趣',
|
||||
status: '视频成功',
|
||||
createTime: '2026-05-14 17:49:20',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square',
|
||||
originalName: '香水',
|
||||
productName: '面霜',
|
||||
status: '视频提示词成功',
|
||||
createTime: '2026-05-08 08:57:46',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '94vh',
|
||||
background: '#ffffff',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
padding: '20px',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<div style={{ textAlign: 'right', marginBottom: 20 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileTextOutlined />}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: '#656efa'
|
||||
}}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
创作记录
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ minHeight: '100vh', 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>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 'calc(100vh - 100px)'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 40 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
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: 36,
|
||||
height: 36,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
|
||||
borderRadius: 10,
|
||||
width: 56,
|
||||
height: 56,
|
||||
background: 'linear-gradient(135deg, #e0e7ff 0%, #fce7f3 100%)',
|
||||
borderRadius: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: 20
|
||||
margin: '0 auto 16px'
|
||||
}}>
|
||||
✨
|
||||
<span style={{ fontSize: 28 }}>🎬</span>
|
||||
</div>
|
||||
<h1 style={{
|
||||
fontSize: 26,
|
||||
<h2 style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
|
||||
@@ -292,15 +150,15 @@ export default function VideoFrameExtractor() {
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
拆镜复刻
|
||||
</h1>
|
||||
上传视频开始创作
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: '#888', margin: '8px 0 0 0' }}>
|
||||
支持 MP4、MOV 格式视频,最大支持 100MB
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, color: '#888', margin: 0 }}>
|
||||
一键拆解画面分镜,助力仿拍或创作
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 720 }}>
|
||||
<div style={{ width: '100%', maxWidth: 720, margin: '0 auto' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#fff5f5',
|
||||
@@ -325,7 +183,7 @@ export default function VideoFrameExtractor() {
|
||||
background: '#faf5ff',
|
||||
border: '2px dashed #c4b5fd',
|
||||
borderRadius: 24,
|
||||
padding: '40px 20px'
|
||||
padding: '60px 20px'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
@@ -344,7 +202,7 @@ export default function VideoFrameExtractor() {
|
||||
点击或拖拽到此上传视频文件
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: '#999', margin: 0 }}>
|
||||
支持 MP4、MOV 格式视频,最大支持 100MB 的文件,支持上传 3 分钟内的视频,可处理 4-15 秒片段
|
||||
支持 MP4、MOV 格式视频,最大支持 100MB 的文件,支持上传 3 分钟内的视频
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
)}
|
||||
@@ -397,322 +255,149 @@ export default function VideoFrameExtractor() {
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
controls
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
onTimeUpdate={handleVideoTimeUpdate}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 320,
|
||||
borderRadius: 12,
|
||||
background: '#000',
|
||||
cursor: 'pointer'
|
||||
background: '#000'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSegmentSelected && (
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 16,
|
||||
padding: '20px'
|
||||
{/* 产品名称输入框 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: '#374151',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
产品名称
|
||||
</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
|
||||
disabled={!videoUrl || !productName.trim()}
|
||||
style={{
|
||||
padding: '14px 56px',
|
||||
background: videoUrl && productName.trim()
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
|
||||
: '#e2e8f0',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: videoUrl && productName.trim() ? 'pointer' : 'not-allowed',
|
||||
boxShadow: videoUrl && productName.trim() ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
marginBottom: 16
|
||||
gap: 10
|
||||
}}
|
||||
>
|
||||
<span>创建</span>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
fontWeight: 400
|
||||
}}>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
color: 'white',
|
||||
borderRadius: 8
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{formatTime(currentTime)} / {formatTime(videoDuration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={timelineRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 70,
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
cursor: dragging ? 'grabbing' : 'pointer',
|
||||
// overflow: 'visible',
|
||||
marginBottom: 12,
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
// overflow: 'auto',
|
||||
}}
|
||||
onClick={handleTimelineClick}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: `calc(${startPercent}% + 0px)`,
|
||||
width: `calc(${rangeWidth}% - 0px)`,
|
||||
background: 'linear-gradient(90deg, rgba(99, 102, 241, 0.25) 0%, rgba(236, 72, 153, 0.25) 100%)',
|
||||
borderRadius: 8,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
// justifyContent: 'space-around',
|
||||
pointerEvents: 'none',
|
||||
// overflow: 'auto',
|
||||
}}>
|
||||
{framePreviews.map((frame, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `calc((100% - 16px) / ${framePreviews.length})`,
|
||||
// width: 200,
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
background: '#f1f5f9',
|
||||
overflow: 'hidden',
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
{frame && (
|
||||
<img
|
||||
src={frame}
|
||||
alt={`frame ${index}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseDown={(e) => handleTimelineMouseDown(e, 'start')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `calc(${startPercent}% - 10px)`,
|
||||
width: 20,
|
||||
background: 'linear-gradient(180deg, #6366f1 0%, #4f46e5 100%)',
|
||||
borderRadius: 6,
|
||||
cursor: 'ew-resize',
|
||||
boxShadow: dragging === 'start'
|
||||
? '0 4px 16px rgba(99, 102, 241, 0.4)'
|
||||
: '0 2px 8px rgba(99, 102, 241, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}>
|
||||
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
|
||||
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
|
||||
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseDown={(e) => handleTimelineMouseDown(e, 'end')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `calc(${endPercent}% - 10px)`,
|
||||
width: 20,
|
||||
background: 'linear-gradient(180deg, #ec4899 0%, #db2777 100%)',
|
||||
borderRadius: 6,
|
||||
cursor: 'ew-resize',
|
||||
boxShadow: dragging === 'end'
|
||||
? '0 4px 16px rgba(236, 72, 153, 0.4)'
|
||||
: '0 2px 8px rgba(236, 72, 153, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2
|
||||
}}>
|
||||
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
|
||||
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
|
||||
<div style={{ width: 8, height: 1, background: 'white', borderRadius: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: '#666'
|
||||
}}>
|
||||
已选取 {formatTimeShort(segmentDuration)}
|
||||
{!segmentValid && (
|
||||
<span style={{ color: '#dc2626', marginLeft: 8 }}>
|
||||
(建议 4-15 秒)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
预计消耗 10 个积分
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
||||
<button
|
||||
disabled={!videoUrl || !segmentValid}
|
||||
style={{
|
||||
padding: '14px 56px',
|
||||
background: videoUrl && segmentValid
|
||||
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
|
||||
: '#e2e8f0',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: videoUrl && segmentValid ? 'pointer' : 'not-allowed',
|
||||
boxShadow: videoUrl && segmentValid ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
}}
|
||||
>
|
||||
<span>一键拆解</span>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
fontWeight: 400
|
||||
}}>
|
||||
预计消耗 10 个积分
|
||||
</span>
|
||||
</button>
|
||||
</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: 'originalName',
|
||||
key: 'originalName',
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
dataIndex: 'productName',
|
||||
key: 'productName',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
},
|
||||
{
|
||||
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={false}
|
||||
/>
|
||||
</Modal>
|
||||
</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: 'originalName',
|
||||
key: 'originalName',
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
dataIndex: 'productName',
|
||||
key: 'productName',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
},
|
||||
{
|
||||
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={false}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.product_img{
|
||||
margin-left: 20px;
|
||||
|
||||
|
||||
/* 加载旋转动画 */
|
||||
@keyframes spinSlow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user