diff --git a/components/pages/work-flow.tsx b/components/pages/work-flow.tsx index f417b2f..fe4d26a 100644 --- a/components/pages/work-flow.tsx +++ b/components/pages/work-flow.tsx @@ -23,19 +23,27 @@ import { exportVideoWithRetry } from '@/utils/export-service'; // import { EditPoint as EditPointType } from './work-flow/video-edit/types'; import { AIEditingIframeButton } from './work-flow/ai-editing-iframe'; import { useDeviceType } from '@/hooks/useDeviceType'; +import { H5ProgressToastProvider, useH5ProgressToast } from '@/components/ui/h5-progress-toast'; const WorkFlow = React.memo(function WorkFlow() { const { isMobile, isTablet, isDesktop } = useDeviceType(); + // 通过全局事件桥接 H5ProgressToast(Provider 在本组件 JSX 中,逻辑层无法直接使用 hook) + const emitToastShow = useCallback((params: { title?: string; progress?: number }) => { + window.dispatchEvent(new CustomEvent('h5Toast:show', { detail: params })); + }, []); + const emitToastUpdate = useCallback((params: { title?: string; progress?: number }) => { + window.dispatchEvent(new CustomEvent('h5Toast:update', { detail: params })); + }, []); + const emitToastHide = useCallback(() => { + window.dispatchEvent(new CustomEvent('h5Toast:hide')); + }, []); useEffect(() => { console.log("init-WorkFlow"); return () => { console.log("unmount-WorkFlow"); - // 销毁编辑通知 - if (editingNotificationKey.current) { - notification.destroy(editingNotificationKey.current); - } + // 不在卸载时强制隐藏,避免严格模式下二次卸载导致刚显示就被关闭 }; - }, []); + }, [emitToastHide]); const containerRef = useRef(null); const [isEditModalOpen, setIsEditModalOpen] = React.useState(false); const [activeEditTab, setActiveEditTab] = React.useState('1'); @@ -50,7 +58,6 @@ const WorkFlow = React.memo(function WorkFlow() { // const [iframeAiEditingKey, setIframeAiEditingKey] = React.useState(`iframe-ai-editing-${Date.now()}`); const [isEditingInProgress, setIsEditingInProgress] = React.useState(false); const isEditingInProgressRef = useRef(false); - // 导出进度状态 const [exportProgress, setExportProgress] = React.useState<{ status: 'processing' | 'completed' | 'failed'; @@ -59,6 +66,9 @@ const WorkFlow = React.memo(function WorkFlow() { stage?: string; taskId?: string; } | null>(null); + const editingTimeoutRef = useRef(null); + const editingProgressIntervalRef = useRef(null); + const editingProgressStartRef = useRef(0); const searchParams = useSearchParams(); const episodeId = searchParams.get('episodeId') || ''; @@ -66,9 +76,7 @@ const WorkFlow = React.memo(function WorkFlow() { const userId = JSON.parse(localStorage.getItem("currentUser") || '{}').id || NaN; SaveEditUseCase.setProjectId(episodeId); - let editingNotificationKey = useRef(`editing-${Date.now()}`); const [isHandleEdit, setIsHandleEdit] = React.useState(false); - // 使用 ref 存储 handleTestExport 避免循环依赖 const handleTestExportRef = useRef<(() => Promise) | null>(null); @@ -126,54 +134,56 @@ const WorkFlow = React.memo(function WorkFlow() { setTimeout(() => { handleTestExportRef.current?.(); }, 0); - editingNotificationKey.current = `editing-${Date.now()}`; - showEditingNotification({ - description: 'Performing intelligent editing...', - successDescription: 'Editing successful', - timeoutDescription: 'Editing failed. Please click the scissors button to go to the intelligent editing platform.', - timeout: 8 * 60 * 1000, - key: editingNotificationKey.current, - onFail: () => { - console.log('❌ onFail callback triggered - Editing failed, retrying...'); - // 清缓存 生成计划 视频重新分析 - localStorage.removeItem(`isLoaded_plan_${episodeId}`); - - // 先销毁当前通知 - if (editingNotificationKey.current) { - notification.destroy(editingNotificationKey.current); - } - - // 重新生成 iframeAiEditingKey 触发重新渲染 - // setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`); - - // 延时200ms后显示重试通知,确保之前的通知已销毁 - setTimeout(() => { - editingNotificationKey.current = `editing-${Date.now()}`; - showEditingNotification({ - description: 'Retry intelligent editing...', - successDescription: 'Editing successful', - timeoutDescription: 'Editing failed. Please click the scissors button to go to the intelligent editing platform.', - timeout: 5 * 60 * 1000, // 5分钟超时 - key: editingNotificationKey.current, - onFail: () => { - console.log('Editing retry failed'); - // 清缓存 - localStorage.removeItem(`isLoaded_plan_${episodeId}`); - // 5秒后关闭通知并设置错误状态 - setTimeout(() => { - setEditingStatus('error'); - setIsEditingInProgress(false); // 重置编辑状态 - isEditingInProgressRef.current = false; // 重置 ref - if (editingNotificationKey.current) { - notification.destroy(editingNotificationKey.current); - } - }, 5000); - } - }); - }, 200); + + // 显示进度提示并启动超时定时器 + emitToastShow({ title: 'Performing intelligent editing...', progress: 0 }); + // 启动自动推进到 90% 的进度(8分钟) + if (editingProgressIntervalRef.current) clearInterval(editingProgressIntervalRef.current); + editingProgressStartRef.current = Date.now(); + const totalMs = 8 * 60 * 1000; + editingProgressIntervalRef.current = setInterval(() => { + const elapsed = Date.now() - editingProgressStartRef.current; + const pct = Math.min(90, Math.max(0, Math.floor((elapsed / totalMs) * 90))); + emitToastUpdate({ progress: pct }); + }, 250); + if (editingTimeoutRef.current) clearTimeout(editingTimeoutRef.current); + editingTimeoutRef.current = setTimeout(() => { + console.log('❌ Editing timeout - retrying...'); + localStorage.removeItem(`isLoaded_plan_${episodeId}`); + if (editingProgressIntervalRef.current) { + clearInterval(editingProgressIntervalRef.current); + editingProgressIntervalRef.current = null; } - }); - }, [episodeId]); // handleTestExport 在内部调用,无需作为依赖 + emitToastHide(); + setTimeout(() => { + emitToastShow({ title: 'Retry intelligent editing...', progress: 0 }); + // 重试阶段自动推进(5分钟到 90%) + if (editingProgressIntervalRef.current) clearInterval(editingProgressIntervalRef.current); + editingProgressStartRef.current = Date.now(); + const retryTotalMs = 5 * 60 * 1000; + editingProgressIntervalRef.current = setInterval(() => { + const elapsed = Date.now() - editingProgressStartRef.current; + const pct = Math.min(90, Math.max(0, Math.floor((elapsed / retryTotalMs) * 90))); + emitToastUpdate({ progress: pct }); + }, 250); + if (editingTimeoutRef.current) clearTimeout(editingTimeoutRef.current); + editingTimeoutRef.current = setTimeout(() => { + console.log('Editing retry failed'); + localStorage.removeItem(`isLoaded_plan_${episodeId}`); + setTimeout(() => { + setEditingStatus('error'); + setIsEditingInProgress(false); + isEditingInProgressRef.current = false; + if (editingProgressIntervalRef.current) { + clearInterval(editingProgressIntervalRef.current); + editingProgressIntervalRef.current = null; + } + emitToastHide(); + }, 5000); + }, 5 * 60 * 1000); + }, 200); + }, 8 * 60 * 1000); + }, [episodeId, emitToastHide, emitToastShow, emitToastUpdate]); // 移除 isEditingInProgress 依赖 /** 处理导出失败 */ const handleExportFailed = useCallback(() => { @@ -181,12 +191,16 @@ const WorkFlow = React.memo(function WorkFlow() { setEditingStatus('error'); // setIsEditingInProgress(false); // 已移除该状态变量 isEditingInProgressRef.current = false; - - // 销毁当前编辑通知 - if (editingNotificationKey.current) { - notification.destroy(editingNotificationKey.current); + if (editingTimeoutRef.current) { + clearTimeout(editingTimeoutRef.current); + editingTimeoutRef.current = null; } - }, []); + if (editingProgressIntervalRef.current) { + clearInterval(editingProgressIntervalRef.current); + editingProgressIntervalRef.current = null; + } + emitToastHide(); + }, [emitToastHide]); // 使用自定义 hooks 管理状态 const { @@ -226,36 +240,31 @@ const WorkFlow = React.memo(function WorkFlow() { useEffect(() => { console.log('🎬 final video useEffect triggered:', { finalUrl: taskObject.final.url, - notificationKey: editingNotificationKey.current, isHandleEdit }); - if (taskObject.final.url && editingNotificationKey.current && isHandleEdit) { + if (taskObject.final.url && isHandleEdit) { console.log('🎉 显示编辑完成通知'); - // 更新通知状态为完成 - showEditingNotification({ - isCompleted: true, - description: 'Performing intelligent editing...', - successDescription: 'Editing successful', - timeoutDescription: 'Editing failed, please try again', - timeout: 5 * 60 * 1000, - key: editingNotificationKey.current, - onComplete: () => { - console.log('Editing successful'); - localStorage.setItem(`isLoaded_plan_${episodeId}`, 'true'); - setEditingStatus('success'); - setIsEditingInProgress(false); // 重置编辑状态 - isEditingInProgressRef.current = false; // 重置 ref - // 3秒后关闭通知 - setTimeout(() => { - if (editingNotificationKey.current) { - notification.destroy(editingNotificationKey.current); - } - }, 3000); - }, - }); + // 完成:推进到 100 并清理超时计时器 + if (editingTimeoutRef.current) { + clearTimeout(editingTimeoutRef.current); + editingTimeoutRef.current = null; + } + if (editingProgressIntervalRef.current) { + clearInterval(editingProgressIntervalRef.current); + editingProgressIntervalRef.current = null; + } + emitToastUpdate({ title: 'Editing successful', progress: 100 }); + console.log('Editing successful'); + localStorage.setItem(`isLoaded_plan_${episodeId}`, 'true'); + setEditingStatus('success'); + setIsEditingInProgress(false); + isEditingInProgressRef.current = false; + setTimeout(() => { + emitToastHide(); + }, 3000); } - }, [taskObject.final, isHandleEdit, episodeId]); + }, [taskObject.final, isHandleEdit, episodeId, emitToastHide, emitToastUpdate]); const handleEditModalOpen = useCallback((tab: string) => { setActiveEditTab(tab); @@ -377,11 +386,19 @@ Please process this video editing request.`; /* const handleIframeAIEditingProgress = useCallback((progress: number, message: string) => { console.log(`📊 AI剪辑进度: ${progress}% - ${message}`); - // setAiEditingInProgress(true); // 已移除该状态变量 - }, []); + setAiEditingInProgress(true); + // 收到显式进度时停止自动推进,防止倒退 + if (editingProgressIntervalRef.current) { + clearInterval(editingProgressIntervalRef.current); + editingProgressIntervalRef.current = null; + } + emitToastUpdate({ title: message, progress }); + }, [emitToastUpdate]); */ return ( + +
@@ -392,6 +409,7 @@ Please process this video editing request.`; title={taskObject.title} current={currentSketchIndex + 1} taskObject={taskObject} + currentLoadingText={currentLoadingText} /> ) : (
-
+
{isDesktop ? ( @@ -446,6 +464,7 @@ Please process this video editing request.`; setAnyAttribute={setAnyAttribute} isPauseWorkFlow={isPauseWorkFlow} applyScript={applyScript} + setCurrentSketchIndex={setCurrentSketchIndex} onOpenChat={() => setIsSmartChatBoxOpen(true)} setVideoPreview={(url, id) => { setPreviewVideoUrl(url); @@ -460,13 +479,14 @@ Please process this video editing request.`; )}
{taskObject.currentStage !== 'script' && ( -
+
)} @@ -610,7 +630,33 @@ Please process this video editing request.`; originalText={originalText} />
+ ) }); export default WorkFlow; + +// 桥接组件:监听全局事件并驱动 H5ProgressToast +const H5ToastBridge: React.FC = () => { + const { show, update, hide } = useH5ProgressToast(); + useEffect(() => { + const onShow = (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + show(detail); + }; + const onUpdate = (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + update(detail); + }; + const onHide = () => hide(); + window.addEventListener('h5Toast:show', onShow as EventListener); + window.addEventListener('h5Toast:update', onUpdate as EventListener); + window.addEventListener('h5Toast:hide', onHide as EventListener); + return () => { + window.removeEventListener('h5Toast:show', onShow as EventListener); + window.removeEventListener('h5Toast:update', onUpdate as EventListener); + window.removeEventListener('h5Toast:hide', onHide as EventListener); + }; + }, [show, update, hide]); + return null; +}; diff --git a/components/pages/work-flow/H5MediaViewer.tsx b/components/pages/work-flow/H5MediaViewer.tsx index e96be7c..53331f0 100644 --- a/components/pages/work-flow/H5MediaViewer.tsx +++ b/components/pages/work-flow/H5MediaViewer.tsx @@ -3,11 +3,13 @@ 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 { Play, Pause, Scissors, 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 { getFirstFrame } from '@/utils/tools'; +import { GlassIconButton } from '@/components/ui/glass-icon-button'; +import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools'; +import { Drawer } from 'antd'; interface H5MediaViewerProps { /** 任务对象,包含各阶段数据 */ @@ -23,6 +25,8 @@ interface H5MediaViewerProps { setAnyAttribute: any; isPauseWorkFlow: boolean; applyScript: any; + /** Carousel 切换时回传新的索引 */ + setCurrentSketchIndex: (index: number) => void; /** 打开智能对话 */ onOpenChat?: () => void; /** 设置聊天预览视频 */ @@ -51,6 +55,7 @@ export function H5MediaViewer({ setAnyAttribute, isPauseWorkFlow, applyScript, + setCurrentSketchIndex, onOpenChat, setVideoPreview, showGotoCutButton, @@ -63,6 +68,7 @@ export function H5MediaViewer({ const rootRef = useRef(null); const [activeIndex, setActiveIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(false); + const [isCatalogOpen, setIsCatalogOpen] = useState(false); // 计算当前阶段类型 const stage = taskObject.currentStage; @@ -84,6 +90,9 @@ export function H5MediaViewer({ 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 => item?.url).filter(Boolean) as string[]; } return []; @@ -117,6 +126,10 @@ export function H5MediaViewer({ setActiveIndex(index); setIsPlaying(false); videoRefs.current.forEach(v => v?.pause()); + // 同步到父级索引 + if (stage === 'video' || stage === 'scene' || stage === 'character') { + setCurrentSketchIndex(index); + } }; const togglePlay = () => { @@ -174,6 +187,7 @@ export function H5MediaViewer({ onCanPlay={() => {}} onError={() => {}} /> + {/* 顶部功能按钮改为全局固定渲染,移出 slide */} {activeIndex === idx && !isPlaying && ( + + 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} +
+ ))} +
+
+ + ) : ( )} + +
); } @@ -299,6 +388,96 @@ export function H5MediaViewer({ {stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()} {stage === 'video' && videoUrls.length > 0 && renderVideoSlides()} {(stage === 'scene' || stage === 'character') && imageUrls.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 all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []); + await downloadAllVideos(all); + }} + /> + { + 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]); + } + }} + /> + {(() => { + 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 all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []); + await downloadAllVideos(all); + }} + /> + { + const url = videoUrls[0]; + if (url) await downloadVideo(url); + }} + /> + + )} +
+ )}