'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 { Drawer } from 'antd'; import error_image from '@/public/assets/error.webp'; import { createRoot, Root } from 'react-dom/client'; 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; /** 显示跳转至剪辑平台按钮 */ showGotoCutButton?: boolean; /** 跳转至剪辑平台 */ onGotoCut?: () => 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; } interface DownloadOptionsModalProps { onDownloadCurrent: () => void; onDownloadAll: () => void; onClose: () => void; currentVideoIndex: number; totalVideos: number; /** 当前视频是否生成失败 */ isCurrentVideoFailed: boolean; /** 是否为最终视频阶段 */ isFinalStage?: boolean; } function DownloadOptionsModal(props: DownloadOptionsModalProps) { const { onDownloadCurrent, onDownloadAll, onClose, currentVideoIndex, totalVideos, isCurrentVideoFailed, isFinalStage = false } = props; const containerRef = useRef(null); useEffect(() => { const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalOverflow; }; }, []); return (
e.stopPropagation()} >

Download Options

Choose your download preference

{!isCurrentVideoFailed && (
Current video
{currentVideoIndex + 1} / {totalVideos}
)}
{!isCurrentVideoFailed && ( )}
); } /** * Opens a download options modal with glass morphism style. * @param {DownloadOptionsModalProps} options - download options and callbacks. */ function showDownloadOptionsModal(options: Omit): void { if (typeof window === 'undefined' || typeof document === 'undefined') { return; } const mount = document.createElement('div'); mount.setAttribute('data-alt', 'download-options-modal-root'); document.body.appendChild(mount); let root: Root | null = null; try { root = createRoot(mount); } catch { if (mount.parentNode) { mount.parentNode.removeChild(mount); } return; } const close = () => { try { root?.unmount(); } finally { if (mount.parentNode) { mount.parentNode.removeChild(mount); } } }; root.render( ); } /** * 面向 H5 的媒体预览组件。 * - 除剧本阶段外,统一使用 antd Carousel 展示 图片/视频。 * - 视频仅保留中间的大号播放/暂停按钮。 */ export function H5MediaViewer({ taskObject, scriptData, currentSketchIndex, selectedView, mode, setIsPauseWorkFlow, setAnyAttribute, isPauseWorkFlow, applyScript, setCurrentSketchIndex, onOpenChat, setVideoPreview, showGotoCutButton, onGotoCut, 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); /** 解析形如 "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, })); } return [] as Array<{ url?: string; status?: number }>; }, [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 && ( Generating... )} {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, onDownloadCurrent: async () => { if (hasUrl) { await downloadVideo(current.urls[0]); } }, onDownloadAll: async () => { const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []); if (hasFinalVideo) { all.push(taskObject.final.url); } await downloadAllVideos(all); }, }); }} /> {(() => { 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, onDownloadCurrent: async () => { if (finalUrl) { await downloadVideo(finalUrl); } }, onDownloadAll: async () => { const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []); if (finalUrl) { all.push(finalUrl); } await downloadAllVideos(all); }, }); }} /> )}
)}
); } export default H5MediaViewer;