2025-10-04 19:34:16 +08:00

546 lines
26 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, Pause, FeatherIcon, MessageCircleMore, Download, ArrowDownWideNarrow, RotateCcw, Navigation } 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';
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;
}
/**
* 面向 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<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 [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(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
};
};
/** 视频轮播容器高度:按 aspectRatio 计算(基于视口宽度) */
const videoWrapperHeight = useMemo(() => {
const { w, h } = parseAspect(aspectRatio);
return `min(calc(100vw * ${h} / ${w}), calc(100vh - 8.5rem))`;
}, [aspectRatio]);
/** 图片轮播容器高度:默认 16:9 */
const imageWrapperHeight = useMemo(() => {
// return 'calc(100vw * 9 / 16)';
const { w, h } = parseAspect(aspectRatio);
return `min(calc(100vw * ${h} / ${w}), calc(100vh - 8.5rem))`;
}, [aspectRatio]);
// 计算当前阶段类型
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<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 imageItems = useMemo(() => {
if (stage === 'scene' || stage === 'character') {
const roles = (taskObject.roles?.data ?? []) as Array<any>;
const scenes = (taskObject.scenes?.data ?? []) as Array<any>;
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 = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg" style={{
height: videoWrapperHeight,
}}>
<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 flex justify-center">
{hasUrl ? (
<>
<video
ref={(el) => (videoRefs.current[idx] = el)}
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
style={{
maxHeight: '100%',
}}
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={() => {}}
/>
{/* 顶部功能按钮改为全局固定渲染,移出 slide */}
{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 className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status" style={{
height: videoWrapperHeight,
}}>
{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>
</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-auto min-h-[200px] overflow-hidden rounded-lg" style={{
height: imageWrapperHeight,
}}>
<Carousel
ref={carouselRef}
key={`h5-carousel-image-${stage}-${imageItems.length}`}
arrows
dots={false}
infinite={false}
afterChange={handleAfterChange}
className="absolute inset-0"
slidesToShow={1}
adaptiveHeight
>
{imageItems.map((item, idx) => {
const status = item?.status;
const url = item?.url;
const showImage = status === 1 && typeof url === 'string' && url.length > 0;
return (
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full flex justify-center">
{showImage ? (
<img src={url} alt="scene" className="w-full h-full object-contain" style={{
maxHeight: '100%',
}} />
) : (
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="image-status" style={{
height: imageWrapperHeight,
}}>
{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>
</div>
)}
</div>
)}
</div>
);
})}
</Carousel>
</div>
);
// 剧本阶段:不使用 Carousel沿用 ScriptRenderer
if (stage === 'script') {
const navItems = Array.isArray(scriptData) ? (scriptData as Array<any>).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 (
<div data-alt="script-content" className="w-full overflow-auto"
style={{
height: 'calc(100vh - 10rem)'
}}>
{scriptData ? (
<>
<ScriptRenderer
data={scriptData}
setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute}
isPauseWorkFlow={isPauseWorkFlow}
applyScript={applyScript}
mode={mode}
from="h5"
/>
<button
type="button"
data-alt="open-catalog-button"
className="fixed bottom-[6rem] right-4 z-[60] w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg flex items-center justify-center active:scale-95"
aria-label="open-catalog"
onClick={() => setIsCatalogOpen(true)}
>
<Navigation className="w-6 h-6" />
</button>
<Drawer
open={isCatalogOpen}
onClose={() => 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,
},
}}
>
<div className="mt-1" data-alt="catalog-list">
<div className="px-3 py-2 text-blue-500 text-xl font-bold">navigation</div>
{navItems.map(item => (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
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}
</div>
))}
</div>
</Drawer>
</>
) : (
<ScriptLoading isCompleted={!!scriptData} />
)}
</div>
);
}
// 其他阶段:使用 Carousel
return (
<div ref={rootRef} data-alt="h5-media-viewer" className={`relative`}
style={{
width: 'calc(100vw - 2rem)'
}}>
{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') && !isPlaying && (
<div data-alt="global-video-actions" className="absolute top-0 right-4 z-[60] flex flex-col items-center gap-2">
{stage === 'video' && (
<>
<GlassIconButton
data-alt="edit-with-chat-button"
className="w-8 h-8 bg-custom-purple backdrop-blur-xl rounded-full flex items-center justify-center transition-all"
icon={FeatherIcon}
size="sm"
aria-label="edit-with-chat"
onClick={() => {
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();
}
}}
/>
<GlassIconButton
data-alt="download-all-button"
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
icon={ArrowDownWideNarrow}
size="sm"
aria-label="download-all"
onClick={async () => {
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
await downloadAllVideos(all);
}}
/>
{(() => {
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
return status === 1 ? (
<GlassIconButton
data-alt="download-current-button"
className="w-8 h-8 bg-gradient-to-br from-purple-600/80 to-purple-700/80 backdrop-blur-xl rounded-full flex items-center justify-center transition-all"
icon={Download}
size="sm"
aria-label="download-current"
onClick={async () => {
const current = (taskObject.videos?.data ?? [])[activeIndex] as any;
const hasUrl = current && Array.isArray(current.urls) && current.urls.length > 0;
if (hasUrl) {
await downloadVideo(current.urls[0]);
}
}}
/>
) : null;
})()}
{(() => {
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
return status === 2 ? (
<GlassIconButton
data-alt="retry-button"
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl border border-purple-400/30 rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
icon={RotateCcw}
size="sm"
aria-label="retry"
onClick={() => {
const vid = (taskObject.videos?.data ?? [])[activeIndex]?.video_id;
if (vid && onRetryVideo) onRetryVideo(vid);
}}
/>
) : null;
})()}
</>
)}
{stage === 'final_video' && (
<>
<GlassIconButton
data-alt="download-all-button"
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl border border-purple-400/30 rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
icon={ArrowDownWideNarrow}
size="sm"
aria-label="download-all"
onClick={async () => {
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
await downloadAllVideos(all);
}}
/>
<GlassIconButton
data-alt="download-final-button"
className="w-8 h-8 bg-gradient-to-br from-amber-500/80 to-yellow-600/80 backdrop-blur-xl border border-amber-400/30 rounded-full flex items-center justify-center hover:from-amber-400/80 hover:to-yellow-500/80 transition-all"
icon={Download}
size="sm"
aria-label="download-final"
onClick={async () => {
const url = videoUrls[0];
if (url) await downloadVideo(url);
}}
/>
</>
)}
</div>
)}
<style jsx global>{`
[data-alt='carousel-wrapper'] .slick-slide { display: flex !important;justify-content: center; }
.slick-slider { height: 100% !important; }
.ant-carousel { height: 100% !important; }
.slick-list { width: 100%;height: 100% !important;max-height: 100%; }
.slick-track { display: flex !important; align-items: center;height: 100% !important; }
[data-alt='carousel-wrapper'] .slick-arrow { z-index: 70 !important; }
[data-alt='carousel-wrapper'] .slick-prev { left: 8px; }
[data-alt='carousel-wrapper'] .slick-next { right: 8px; }
`}</style>
</div>
);
}
export default H5MediaViewer;