2025-09-20 16:16:27 +08:00

313 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;