video-flow-b/components/pages/create-to-video2.tsx
2025-10-21 21:52:52 +08:00

494 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import type { MouseEvent } from 'react';
import { Loader2, Download, Send } from 'lucide-react';
import { useRouter } from 'next/navigation';
import './style/create-to-video2.css';
import { getScriptEpisodeListNew, MovieProject } from "@/api/script_episode";
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
import cover_image1 from '@/public/assets/cover_image3.jpg';
import cover_image2 from '@/public/assets/cover_image_shu.jpg';
import { motion } from 'framer-motion';
import { Tooltip, Button } from 'antd';
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
import { showDownloadOptionsModal } from '@/components/pages/work-flow/download-options-modal';
import { post, get } from '@/api/request';
import { baseUrl } from '@/lib/env';
import ShareModal from '@/components/common/ShareModal';
import Masonry from 'react-masonry-css';
import debounce from 'lodash/debounce';
// ideaText已迁移到ChatInputBox组件中
export default function CreateToVideo2() {
const router = useRouter();
const [episodeList, setEpisodeList] = useState<MovieProject[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [perPage] = useState(28);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isPreloading, setIsPreloading] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [userId, setUserId] = useState<number>(0);
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
const [isLoadingShareBtn, setIsLoadingShareBtn] = useState(false);
const [shareModalVisible, setShareModalVisible] = useState(false);
const [selectedProject, setSelectedProject] = useState<any>(null);
const masonryRef = useRef<any>(null);
interface PreloadedData {
page: number;
data: {
code: number;
data: {
movie_projects: MovieProject[];
total_pages: number;
};
};
}
const preloadedDataRef = useRef<PreloadedData | null>(null);
// 添加一个 ref 来跟踪当前正在加载的页码
const loadingPageRef = useRef<number | null>(null);
// 在客户端挂载后读取localStorage
// 预加载下一页数据
const preloadNextPage = async (userId: number, page: number) => {
if (isPreloading || !hasMore || page > totalPages) return;
setIsPreloading(true);
try {
const response = await fetchEpisodeData(userId, page);
if (response.code === 0) {
preloadedDataRef.current = {
page,
data: response
};
}
} catch (error) {
console.error('Failed to preload next page:', error);
} finally {
setIsPreloading(false);
}
};
// 监听滚动事件,实现无限加载和预加载
const handleScroll = useCallback(() => {
if (!scrollContainerRef.current || !hasMore || isLoadingMore || isLoading) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 在滚动到 30% 时预加载下一页
// if (scrollPercentage > 0.30 && !isPreloading && currentPage < totalPages) {
// preloadNextPage(userId, currentPage + 1);
// }
// 在滚动到 70% 时加载下一页
if (scrollPercentage > 0.7) {
const nextPage = currentPage + 1;
if (nextPage <= totalPages) {
getEpisodeList(userId, nextPage, true);
}
}
}, [hasMore, isLoadingMore, isLoading, totalPages, userId, currentPage, isPreloading]);
useEffect(() => {
if (typeof window !== 'undefined') {
const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{}');
console.log('currentUser', currentUser);
setUserId(currentUser.id);
getEpisodeList(currentUser.id, 1, false);
}
}, []);
// 添加滚动监听
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
// 获取剧集列表数据
const fetchEpisodeData = async (userId: number, page: number) => {
const params = {
user_id: String(userId),
page,
per_page: perPage
};
return await getScriptEpisodeListNew(params);
};
// 修改获取剧集列表函数
const getEpisodeList = async (userId: number, page: number = 1, loadMore: boolean = false) => {
// 检查是否正在加载该页
if (loadingPageRef.current === page) return;
if (isLoading || (isLoadingMore && !loadMore)) return;
// 设置当前正在加载的页码
loadingPageRef.current = page;
if (loadMore) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
}
try {
let episodeListResponse;
// 如果有预加载的数据且页码匹配,直接使用
if (preloadedDataRef.current && preloadedDataRef.current.page === page) {
episodeListResponse = preloadedDataRef.current.data;
preloadedDataRef.current = null;
} else {
episodeListResponse = await fetchEpisodeData(userId, page);
}
if (episodeListResponse.code === 0) {
const { movie_projects, total_pages } = episodeListResponse.data;
// 确保数据不重复
if (loadMore) {
setEpisodeList(prev => {
const newProjects = movie_projects.filter(
project => !prev.some(p => p.project_id === project.project_id)
);
return [...prev, ...newProjects];
});
} else {
setEpisodeList(movie_projects);
}
setTotalPages(total_pages);
setHasMore(page < total_pages);
setCurrentPage(page);
// 预加载下一页数据
// if (page < total_pages && !isPreloading) {
// preloadNextPage(userId, page + 1);
// }
}
} catch (error) {
console.error('Failed to fetch episode list:', error);
} finally {
setIsLoading(false);
setIsLoadingMore(false);
// 清除当前加载页码
loadingPageRef.current = null;
}
};
// 视频上传和创建功能已迁移到ChatInputBox组件中
// 所有视频工具相关的函数和配置已迁移到ChatInputBox组件中
const StatusBadge = (status: string) => {
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="flex items-center"
>
{/* 进行中 脉冲小圆点 */}
{status === 'pending' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-yellow-400 shadow-[0_0_8px_rgba(255,220,100,0.9)]"
animate={{ scale: [1, 1.4, 1] }}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
{/* 状态文字 */}
<span className="text-xs tracking-widest text-yellow-300 font-medium drop-shadow-[0_0_6px_rgba(255,220,100,0.6)]"></span>
</>
)}
{/* 失败 */}
{status === 'failed' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_8px_rgba(255,0,80,0.9)]"
/>
<span className="text-xs tracking-widest text-red-400 font-medium drop-shadow-[0_0_6px_rgba(255,0,80,0.6)]">
FAILED
</span>
</>
)}
</motion.div>
)
}
// 创建一个视频引用Map
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const handleMouseEnter = (projectId: string) => {
const videoElement = videoRefs.current.get(projectId);
if (videoElement) {
videoElement.play().catch(() => {
console.log('Video autoplay prevented');
});
}
};
const handleMouseLeave = (projectId: string) => {
const videoElement = videoRefs.current.get(projectId);
if (videoElement) {
videoElement.pause();
videoElement.currentTime = 0;
}
};
const setVideoRef = (projectId: string, element: HTMLVideoElement | null) => {
if (element) {
videoRefs.current.set(projectId, element);
} else {
videoRefs.current.delete(projectId);
}
};
// 监听窗口大小变化,触发 Masonry 重排
useEffect(() => {
const handleResize = debounce(() => {
if (masonryRef.current?.recomputeCellPositions) {
masonryRef.current.recomputeCellPositions();
}
}, 200);
window.addEventListener('resize', handleResize);
return () => {
handleResize.cancel();
window.removeEventListener('resize', handleResize);
};
}, []);
const renderProjectCard = (project: MovieProject): JSX.Element => {
const handleDownloadClick = async (e: MouseEvent, project: MovieProject) => {
console.log(project);
e.stopPropagation();
showDownloadOptionsModal({
currentVideoIndex: 0,
totalVideos: 1,
isCurrentVideoFailed: false,
isFinalStage: true,
projectId: project.project_id,
onDownloadCurrent: async (withWatermark: boolean) => {
setIsLoadingDownloadBtn(true);
try {
const json: any = await post('/movie/download_video', {
project_id: project.project_id,
watermark: withWatermark
});
const url = json?.data?.download_url as string | undefined;
if (url) {
await downloadVideo(url);
}
} finally {
setIsLoadingDownloadBtn(false);
}
},
onDownloadAll: ()=>{}
});
};
const handleShareClick = (e: MouseEvent, project: MovieProject) => {
e.stopPropagation();
setSelectedProject(project);
setShareModalVisible(true);
};
const getPosterUrl = (project: MovieProject): string => {
if (project.video_snapshot_url && project.video_snapshot_url.trim() !== '') {
return project.video_snapshot_url;
}
//使用getFirstFrame生成
const videoUrl = project.final_video_url || project.final_simple_video_url || project.video_urls || '';
if (videoUrl && videoUrl.trim() !== '') {
return getFirstFrame(videoUrl, 300);
}
return '';
};
// 根据 aspect_ratio 计算纵横比
const getAspectRatio = () => {
switch (project.aspect_ratio) {
case "VIDEO_ASPECT_RATIO_LANDSCAPE":
return 16 / 9; // 横屏 16:9
case "VIDEO_ASPECT_RATIO_PORTRAIT":
return 9 / 16; // 竖屏 9:16
default:
return 16 / 9; // 默认横屏
}
};
const aspectRatio = getAspectRatio();
return (
<div
key={project.project_id}
className="relative group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-white/5 transition-all duration-300"
onMouseEnter={() => handleMouseEnter(project.project_id)}
onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card"
>
{/* 视频/图片区域(使用 aspect_ratio 预设高度) */}
<div
className="relative w-full"
style={{
aspectRatio: aspectRatio
}}
onClick={() => router.push(`/movies/work-flow?episodeId=${project.project_id}`)}
>
{(project.final_video_url || project.final_simple_video_url || project.video_urls) ? (
<video
ref={(el) => setVideoRef(project.project_id, el)}
src={project.final_video_url || project.final_simple_video_url || project.video_urls || ''}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
muted
loop
playsInline
preload="auto"
poster={getPosterUrl(project)}
/>
) : (
<div
className="absolute inset-0 w-full h-full bg-cover bg-center bg-no-repeat group-hover:scale-105 transition-transform duration-500"
style={{
backgroundImage: `url(${project.aspect_ratio === 'VIDEO_ASPECT_RATIO_PORTRAIT' ? cover_image2.src : cover_image1.src})`,
}}
data-alt="cover-image"
/>
)}
{/* 转发和下载按钮 右上角 */}
{(project.final_video_url || project.final_simple_video_url) && (
<div className="absolute top-1 right-1 flex items-center gap-1">
{/* 转发按钮 */}
{/* <Tooltip placement="top" title="Share">
<Button
size="small"
type="text"
className="w-[2.5rem] h-[2.5rem] rounded-full items-center justify-center p-0 hidden group-hover:flex transition-all duration-300 hover:bg-white/15"
onClick={(e) => handleShareClick(e, project)}
>
<Send className="w-4 h-4 text-white" />
</Button>
</Tooltip> */}
{/* 下载按钮 */}
<Tooltip placement="top" title="Download">
<Button size="small" type="text" disabled={isLoadingDownloadBtn} className="w-[2.5rem] h-[2.5rem] rounded-full items-center justify-center p-0 hidden group-hover:flex transition-all duration-300 hover:bg-white/15" onClick={(e) => handleDownloadClick(e, project)}>
{isLoadingDownloadBtn ? <Loader2 className="w-4 h-4 animate-spin text-white" /> : <Download className="w-4 h-4 text-white" />}
</Button>
</Tooltip>
</div>
)}
{/* 状态标签 - 左上角 */}
<div className="absolute top-3 left-3">
{StatusBadge((project.status === 'COMPLETED' || project.final_simple_video_url) ? 'completed' : project.status === 'FAILED' ? 'failed' : 'pending')}
</div>
</div>
{/* 底部信息 */}
<div className="p-2 group absolute bottom-0 left-0 right-0 bg-black/10 backdrop-blur-lg rounded-b-lg hidden group-hover:block">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium text-white line-clamp-1">
{project.name || "Unnamed"}
</h2>
{/* TODO 编辑标题 */}
{/* <Tooltip title="Edit Title">
<Button size="small" type="text" className="w-[2.5rem] h-[2.5rem] rounded-full items-center justify-center p-0 hidden group-hover:flex transition-all duration-300 hover:bg-white/15" /></Button>
</Tooltip> */}
</div>
{/* TODO 删除 */}
{/* <Tooltip title="Delete">
<Button size="small" type="text" className="w-[2.5rem] h-[2.5rem] rounded-full items-center justify-center p-0 hidden group-hover:flex transition-all duration-300 hover:bg-white/15" /></Button>
</Tooltip> */}
</div>
</div>
</div>
);
};
return (
<div className="flex flex-col w-full h-full relative px-2">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto overflow-x-hidden custom-scrollbar"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(255,255,255,0.1) transparent'
}}
>
{episodeList.length > 0 && (
/* 优化的剧集网格 */
<div className="pb-8">
{(() => {
const masonryBreakpoints = { default: 5, 1024: 2, 640: 1 };
return (
<Masonry
ref={masonryRef}
breakpointCols={masonryBreakpoints}
className="flex -ml-2"
columnClassName="pl-2 space-y-2"
data-alt="masonry-grid"
>
{episodeList.map(renderProjectCard)}
</Masonry>
);
})()}
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex justify-center py-12">
<div className="flex items-center">
<Loader2 className="w-5 h-5 animate-spin text-purple-400" />
</div>
</div>
)}
</div>
)}
{episodeList.length === 0 && isLoading && (
<div className="flex justify-center py-12 h-full">
<div className="flex items-center">
<Loader2 className="w-8 h-8 animate-spin text-purple-400" />
</div>
</div>
)}
</div>
{/* 视频工具组件 - 使用独立组件 */}
{!isLoading &&
<ChatInputBox noData={episodeList.length === 0} />
}
{/* 分享弹框 */}
{selectedProject && (
<ShareModal
visible={shareModalVisible}
onClose={() => {
setShareModalVisible(false);
setSelectedProject(null);
}}
project={selectedProject}
/>
)}
</div>
);
}