'use client'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Carousel } from 'antd'; import type { CarouselRef } from 'antd/es/carousel'; import { Play, Pause, FeatherIcon, MessageCircleMore, Download, ArrowDownWideNarrow, RotateCcw, Navigation, X } from 'lucide-react'; import { TaskObject } from '@/api/DTO/movieEdit'; import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer'; import ScriptLoading from './script-loading'; import { GlassIconButton } from '@/components/ui/glass-icon-button'; import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools'; import { post } from '@/api/request'; import { Drawer } from 'antd'; import { useSearchParams } from 'next/navigation'; import error_image from '@/public/assets/error.webp'; import { showDownloadOptionsModal } from './download-options-modal'; import RenderLoading from './RenderLoading'; interface H5MediaViewerProps { /** 任务对象,包含各阶段数据 */ taskObject: TaskObject; /** 剧本数据(仅剧本阶段使用) */ scriptData: any; /** 当前索引(视频/分镜阶段用于定位) */ currentSketchIndex: number; /** 选中视图:final 或 video,用于覆盖阶段渲染 */ selectedView?: 'final' | 'video' | null; /** 渲染模式(仅剧本阶段透传) */ mode: string; /** 以下为剧本阶段透传必需项(与桌面版保持一致) */ setIsPauseWorkFlow: (isPause: boolean) => void; setAnyAttribute: any; isPauseWorkFlow: boolean; applyScript: any; /** Carousel 切换时回传新的索引 */ setCurrentSketchIndex: (index: number) => void; /** 打开智能对话 */ onOpenChat?: () => void; /** 设置聊天预览视频 */ setVideoPreview?: (url: string, id: string) => void; /** 智能对话是否打开(H5可忽略布局调整,仅占位) */ isSmartChatBoxOpen?: boolean; /** 失败重试生成视频 */ onRetryVideo?: (video_id: string) => void; /** 切换选择视图(final 或 video) */ onSelectView?: (view: 'final' | 'video') => void; /** 启用视频编辑功能 */ enableVideoEdit?: boolean; /** 视频编辑描述提交回调 */ onVideoEditDescriptionSubmit?: (editPoint: any, description: string) => void; /** 项目ID */ projectId?: string; /** 视频比例 */ aspectRatio?: string; } /** * 面向 H5 的媒体预览组件。 * - 除剧本阶段外,统一使用 antd Carousel 展示 图片/视频。 * - 视频仅保留中间的大号播放/暂停按钮。 */ export function H5MediaViewer({ taskObject, scriptData, currentSketchIndex, selectedView, mode, setIsPauseWorkFlow, setAnyAttribute, isPauseWorkFlow, applyScript, setCurrentSketchIndex, onOpenChat, setVideoPreview, isSmartChatBoxOpen, onRetryVideo, onSelectView, enableVideoEdit, onVideoEditDescriptionSubmit, projectId, aspectRatio }: H5MediaViewerProps) { const carouselRef = useRef(null); const videoRefs = useRef>([]); const rootRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isCatalogOpen, setIsCatalogOpen] = useState(false); const [isEdgeBrowser, setIsEdgeBrowser] = useState(false); const searchParams = useSearchParams(); const episodeId = searchParams.get('episodeId') || ''; /** 解析形如 "16:9" 的比例字符串 */ const parseAspect = (input?: string): { w: number; h: number } => { const parts = (typeof input === 'string' ? input.split(':') : []); const w = Number(parts[0]); const h = Number(parts[1]); return { w: Number.isFinite(w) && w > 0 ? w : 16, h: Number.isFinite(h) && h > 0 ? h : 9 }; }; /** 检测是否为 Edge 浏览器(客户端挂载后执行) */ useEffect(() => { const isEdge = navigator.userAgent.indexOf('Edg') !== -1; setIsEdgeBrowser(isEdge); }, []); /** 根据浏览器类型获取最大高度值 */ const maxHeight = useMemo(() => { return isEdgeBrowser ? 'calc(100vh - 10.5rem)' : 'calc(100vh - 15rem)'; }, [isEdgeBrowser]); /** 视频轮播容器高度:按 aspectRatio 计算(基于视口宽度) */ const videoWrapperHeight = useMemo(() => { const { w, h } = parseAspect(aspectRatio); return `min(calc(100vw * ${h} / ${w}), ${maxHeight})`; }, [aspectRatio, maxHeight]); /** 图片轮播容器高度:默认 16:9 */ const imageWrapperHeight = useMemo(() => { const { w, h } = parseAspect(aspectRatio); return `min(calc(100vw * ${h} / ${w}), ${maxHeight})`; }, [aspectRatio, maxHeight]); // 计算当前阶段类型 const stage = (selectedView === 'final' && taskObject.final?.url) ? 'final_video' : (!['init', 'script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage); // 生成各阶段对应的 slides 数据 const videoUrls = useMemo(() => { if (stage === 'final_video') { return taskObject.final?.url ? [taskObject.final.url] : []; } if (stage === 'video') { // 注意:不再过滤,保持与原始数组长度一致,避免索引错位 const list = (taskObject.videos?.data ?? []) as Array; return list.map(v => (Array.isArray(v?.urls) && v.urls.length > 0 ? v.urls[0] : '')) as string[]; } return []; }, [stage, taskObject.final?.url, taskObject.videos?.data]); const imageItems = useMemo(() => { if (stage === 'scene' || stage === 'character') { const roles = (taskObject.roles?.data ?? []) as Array; const scenes = (taskObject.scenes?.data ?? []) as Array; console.log('h5-media-viewer:stage', stage); console.log('h5-media-viewer:roles', roles); console.log('h5-media-viewer:scenes', scenes); return [...roles, ...scenes].map(item => ({ url: item?.url as string | undefined, status: item?.status as number | undefined, type: item?.type as string | undefined, })); } return [] as Array<{ url?: string; status?: number; type?: string }>; }, [stage, taskObject.roles?.data, taskObject.scenes?.data]); // 占位,避免未使用警告 useEffect(() => { void isSmartChatBoxOpen; }, [isSmartChatBoxOpen]); // 同步外部索引到 Carousel useEffect(() => { if (stage === 'video' || stage === 'scene' || stage === 'character') { const target = Math.max(0, currentSketchIndex); carouselRef.current?.goTo(target, false); setActiveIndex(target); setIsPlaying(false); // 切换时暂停全部视频 videoRefs.current.forEach(v => v?.pause()); } }, [currentSketchIndex, stage]); // 阶段变更时重置状态 useEffect(() => { setActiveIndex(0); setIsPlaying(false); videoRefs.current.forEach(v => v?.pause()); }, [stage]); const handleAfterChange = (index: number) => { setActiveIndex(index); setIsPlaying(false); videoRefs.current.forEach(v => v?.pause()); // 同步到父级索引 if (stage === 'video' || stage === 'scene' || stage === 'character') { setCurrentSketchIndex(index); } }; const togglePlay = () => { if (stage !== 'final_video' && stage !== 'video') return; const currentVideo = videoRefs.current[activeIndex] ?? null; if (!currentVideo) return; if (currentVideo.paused) { currentVideo.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false)); } else { currentVideo.pause(); setIsPlaying(false); } }; // 渲染视频 slide const renderVideoSlides = () => (
{videoUrls.map((url, idx) => { const hasUrl = typeof url === 'string' && url.length > 0; const raw = (taskObject.videos?.data ?? [])[idx] as any; const status = raw?.video_status; const videoId = raw?.video_id as string | undefined; return (
{hasUrl ? ( <>
); })}
); // 渲染图片 slide const renderImageSlides = () => (
{imageItems.map((item, idx) => { const status = item?.status; const url = item?.url; const showImage = status === 1 && typeof url === 'string' && url.length > 0; return (
{showImage ? ( scene ) : (
{(status === 0 || status === 2) && ( )} {/* {status === 2 && (
error Generate failed
)} */}
)}
); })}
); // 剧本阶段:不使用 Carousel,沿用 ScriptRenderer if (stage === 'script') { const navItems = Array.isArray(scriptData) ? (scriptData as Array).map(v => ({ id: v?.id, title: v?.title })) : []; const scrollToSection = (id?: string) => { if (!id) return; const el = document.getElementById(`section-${id}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; return (
{scriptData ? ( <> setIsCatalogOpen(false)} placement="right" width={'auto'} mask maskClosable={true} maskStyle={{ backgroundColor: 'rgba(0,0,0,0)' }} className="[&_.ant-drawer-content-wrapper]:w-auto [&_.ant-drawer-content-wrapper]:max-w-[80vw] backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl" rootClassName="outline-none" data-alt="catalog-drawer" closable={false} style={{ backgroundColor: 'transparent', borderBottomLeftRadius: 10, borderTopLeftRadius: 10, overflow: 'hidden', }} styles={{ body: { backgroundColor: 'transparent', padding: 0, }, }} >
navigation
{navItems.map(item => (
{ scrollToSection(item.id); setIsCatalogOpen(false); }} onKeyDown={(e) => { if (e.key === 'Enter') { scrollToSection(item.id); setIsCatalogOpen(false); } }} className="px-3 py-2 text-white/90 text-sm cursor-pointer transition-colors" data-alt="catalog-item" > {item.title}
))}
) : ( )}
); } // 其他阶段:使用 Carousel return (
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()} {stage === 'video' && videoUrls.length > 0 && renderVideoSlides()} {(stage === 'scene' || stage === 'character') && imageItems.length > 0 && renderImageSlides()} {/* 全局固定操作区 */} {(stage === 'video' || stage === 'final_video') && (
{stage === 'video' && ( <> { const current = (taskObject.videos?.data ?? [])[activeIndex] as any; if (current && Array.isArray(current.urls) && current.urls.length > 0 && setVideoPreview) { setVideoPreview(current.urls[0], current.video_id); onOpenChat && onOpenChat(); } }} /> { const current = (taskObject.videos?.data ?? [])[activeIndex] as any; const status = current?.video_status; const hasUrl = current && Array.isArray(current.urls) && current.urls.length > 0; const hasFinalVideo = taskObject.final?.url; const baseVideoCount = (taskObject.videos?.data ?? []).length; const totalVideos = hasFinalVideo ? baseVideoCount + 1 : baseVideoCount; const isCurrentVideoFailed = status === 2; showDownloadOptionsModal({ currentVideoIndex: hasFinalVideo ? activeIndex + 1 : activeIndex, totalVideos, isCurrentVideoFailed, projectId: episodeId, videoId: current?.video_id, onDownloadCurrent: async (withWatermark: boolean) => { if (!current?.video_id) return; const json: any = await post('/movie/download_video', { project_id: episodeId, video_id: current.video_id, watermark: withWatermark }); const url = json?.data?.download_url as string | undefined; if (url) await downloadVideo(url); }, onDownloadAll: ()=>{} }); }} /> {(() => { const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status; return status === 2 ? ( { const vid = (taskObject.videos?.data ?? [])[activeIndex]?.video_id; if (vid && onRetryVideo) onRetryVideo(vid); }} /> ) : null; })()} )} {stage === 'final_video' && ( { const totalVideos = (taskObject.videos?.data ?? []).length + 1; const finalUrl = videoUrls[0]; showDownloadOptionsModal({ currentVideoIndex: 0, totalVideos, isCurrentVideoFailed: false, isFinalStage: true, projectId: episodeId, onDownloadCurrent: async (withWatermark: boolean) => { const json: any = await post('/movie/download_video', { project_id: episodeId, watermark: withWatermark }); const url = json?.data?.download_url as string | undefined; if (url) await downloadVideo(url); }, onDownloadAll: ()=>{} }); }} /> )}
)}
); } export default H5MediaViewer;