forked from 77media/video-flow
313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Carousel } from 'antd';
|
||
import type { CarouselRef } from 'antd/es/carousel';
|
||
import { Play, Scissors, MessageCircleMore, RotateCcw } from 'lucide-react';
|
||
import { TaskObject } from '@/api/DTO/movieEdit';
|
||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||
import ScriptLoading from './script-loading';
|
||
import { getFirstFrame } from '@/utils/tools';
|
||
|
||
interface H5MediaViewerProps {
|
||
/** 任务对象,包含各阶段数据 */
|
||
taskObject: TaskObject;
|
||
/** 剧本数据(仅剧本阶段使用) */
|
||
scriptData: any;
|
||
/** 当前索引(视频/分镜阶段用于定位) */
|
||
currentSketchIndex: number;
|
||
/** 渲染模式(仅剧本阶段透传) */
|
||
mode: string;
|
||
/** 以下为剧本阶段透传必需项(与桌面版保持一致) */
|
||
setIsPauseWorkFlow: (isPause: boolean) => void;
|
||
setAnyAttribute: any;
|
||
isPauseWorkFlow: boolean;
|
||
applyScript: any;
|
||
/** 打开智能对话 */
|
||
onOpenChat?: () => void;
|
||
/** 设置聊天预览视频 */
|
||
setVideoPreview?: (url: string, id: string) => void;
|
||
/** 显示跳转至剪辑平台按钮 */
|
||
showGotoCutButton?: boolean;
|
||
/** 跳转至剪辑平台 */
|
||
onGotoCut?: () => void;
|
||
/** 智能对话是否打开(H5可忽略布局调整,仅占位) */
|
||
isSmartChatBoxOpen?: boolean;
|
||
/** 失败重试生成视频 */
|
||
onRetryVideo?: (video_id: string) => void;
|
||
}
|
||
|
||
/**
|
||
* 面向 H5 的媒体预览组件。
|
||
* - 除剧本阶段外,统一使用 antd Carousel 展示 图片/视频。
|
||
* - 视频仅保留中间的大号播放/暂停按钮。
|
||
*/
|
||
export function H5MediaViewer({
|
||
taskObject,
|
||
scriptData,
|
||
currentSketchIndex,
|
||
mode,
|
||
setIsPauseWorkFlow,
|
||
setAnyAttribute,
|
||
isPauseWorkFlow,
|
||
applyScript,
|
||
onOpenChat,
|
||
setVideoPreview,
|
||
showGotoCutButton,
|
||
onGotoCut,
|
||
isSmartChatBoxOpen,
|
||
onRetryVideo
|
||
}: H5MediaViewerProps) {
|
||
const carouselRef = useRef<CarouselRef>(null);
|
||
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
|
||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||
|
||
// 计算当前阶段类型
|
||
const stage = 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<any>;
|
||
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 imageUrls = useMemo(() => {
|
||
if (stage === 'scene' || stage === 'character') {
|
||
const roles = (taskObject.roles?.data ?? []) as Array<any>;
|
||
const scenes = (taskObject.scenes?.data ?? []) as Array<any>;
|
||
return [...roles, ...scenes].map(item => item?.url).filter(Boolean) as string[];
|
||
}
|
||
return [];
|
||
}, [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());
|
||
};
|
||
|
||
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 = () => (
|
||
<div data-alt="carousel-wrapper" className="relative w-full aspect-video min-h-[200px] overflow-hidden rounded-lg">
|
||
<Carousel
|
||
ref={carouselRef}
|
||
key={`h5-carousel-video-${stage}-${videoUrls.length}`}
|
||
arrows
|
||
dots={false}
|
||
infinite={false}
|
||
afterChange={handleAfterChange}
|
||
className="absolute inset-0"
|
||
slidesToShow={1}
|
||
adaptiveHeight
|
||
>
|
||
{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 (
|
||
<div key={`h5-video-${idx}`} data-alt="video-slide" className="relative w-full h-full">
|
||
{hasUrl ? (
|
||
<>
|
||
<video
|
||
ref={(el) => (videoRefs.current[idx] = el)}
|
||
className="w-full h-full object-cover [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
|
||
src={url}
|
||
preload="metadata"
|
||
playsInline
|
||
controls={isPlaying && activeIndex === idx}
|
||
loop
|
||
poster={getFirstFrame(url)}
|
||
crossOrigin="anonymous"
|
||
onLoadedMetadata={() => {}}
|
||
onPlay={() => {
|
||
if (activeIndex === idx) setIsPlaying(true);
|
||
}}
|
||
onPause={() => {
|
||
if (activeIndex === idx) setIsPlaying(false);
|
||
}}
|
||
onCanPlay={() => {}}
|
||
onError={() => {}}
|
||
/>
|
||
{activeIndex === idx && !isPlaying && (
|
||
<button
|
||
type="button"
|
||
onClick={togglePlay}
|
||
data-alt="play-toggle"
|
||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-16 h-16 rounded-full bg-black/60 text-white flex items-center justify-center active:scale-95"
|
||
aria-label={'Play'}
|
||
>
|
||
<Play className="w-8 h-8" />
|
||
</button>
|
||
)}
|
||
|
||
{/* 顶部操作按钮 */}
|
||
<div data-alt="video-actions" className="absolute top-2 right-2 z-10 flex items-center gap-2">
|
||
{showGotoCutButton && (
|
||
<button
|
||
type="button"
|
||
onClick={onGotoCut}
|
||
data-alt="goto-cut-button"
|
||
className="w-9 h-9 rounded-full bg-black/50 text-white flex items-center justify-center active:scale-95"
|
||
aria-label={'Go to editing platform'}
|
||
>
|
||
<Scissors className="w-5 h-5" />
|
||
</button>
|
||
)}
|
||
{!!setVideoPreview && !!onOpenChat && hasUrl && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setVideoPreview(url, videoId || '');
|
||
onOpenChat();
|
||
}}
|
||
data-alt="open-chat-button"
|
||
className="w-9 h-9 rounded-full bg-black/50 text-white flex items-center justify-center active:scale-95"
|
||
aria-label={'Open chat for editing'}
|
||
>
|
||
<MessageCircleMore className="w-5 h-5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="w-full aspect-video min-h-[200px] flex items-center justify-center bg-black/10" data-alt="video-status">
|
||
{status === 0 && (
|
||
<span className="text-blue-500 text-base">Generating...</span>
|
||
)}
|
||
{status === 2 && (
|
||
<div className="flex flex-col items-center justify-center gap-3">
|
||
<div className="text-4xl">⚠️</div>
|
||
<span className="text-red-500 text-base">Generate failed</span>
|
||
{onRetryVideo && videoId && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRetryVideo(videoId)}
|
||
data-alt="retry-button"
|
||
className="px-3 py-1.5 rounded-full bg-black/60 text-white text-sm inline-flex items-center gap-1 active:scale-95"
|
||
aria-label={'Retry'}
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
Retry
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{status !== 0 && status !== 2 && (
|
||
<span className="text-white/70 text-base">Pending</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</Carousel>
|
||
</div>
|
||
);
|
||
|
||
// 渲染图片 slide
|
||
const renderImageSlides = () => (
|
||
<div data-alt="carousel-wrapper" className="relative w-full aspect-video min-h-[200px] overflow-hidden rounded-lg">
|
||
<Carousel
|
||
ref={carouselRef}
|
||
key={`h5-carousel-image-${stage}-${imageUrls.length}`}
|
||
arrows
|
||
dots={false}
|
||
infinite={false}
|
||
afterChange={handleAfterChange}
|
||
className="absolute inset-0"
|
||
slidesToShow={1}
|
||
adaptiveHeight
|
||
>
|
||
{imageUrls.map((url, idx) => (
|
||
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full">
|
||
<img src={url} alt="scene" className="w-full h-full object-cover" />
|
||
</div>
|
||
))}
|
||
</Carousel>
|
||
</div>
|
||
);
|
||
|
||
// 剧本阶段:不使用 Carousel,沿用 ScriptRenderer
|
||
if (stage === 'script') {
|
||
return (
|
||
<div data-alt="script-content" className="w-full h-full">
|
||
{scriptData ? (
|
||
<ScriptRenderer
|
||
data={scriptData}
|
||
setIsPauseWorkFlow={setIsPauseWorkFlow}
|
||
setAnyAttribute={setAnyAttribute}
|
||
isPauseWorkFlow={isPauseWorkFlow}
|
||
applyScript={applyScript}
|
||
mode={mode}
|
||
/>
|
||
) : (
|
||
<ScriptLoading isCompleted={!!scriptData} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 其他阶段:使用 Carousel
|
||
return (
|
||
<div ref={rootRef} data-alt="h5-media-viewer" className="w-[100vw] relative px-4">
|
||
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
|
||
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
|
||
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
|
||
<style jsx global>{`
|
||
[data-alt='carousel-wrapper'] .slick-slide { display: block; }
|
||
.slick-list { width: 100%;height: 100%; }
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default H5MediaViewer;
|
||
|
||
|