forked from 77media/video-flow
H5适配中
This commit is contained in:
parent
5efe68b63b
commit
8a0e0c6d2a
@ -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<HTMLDivElement>(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<string>(`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<NodeJS.Timeout | null>(null);
|
||||
const editingProgressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const editingProgressStartRef = useRef<number>(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<string>(`editing-${Date.now()}`);
|
||||
const [isHandleEdit, setIsHandleEdit] = React.useState(false);
|
||||
|
||||
// 使用 ref 存储 handleTestExport 避免循环依赖
|
||||
const handleTestExportRef = useRef<(() => Promise<any>) | 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 (
|
||||
<H5ProgressToastProvider>
|
||||
<H5ToastBridge />
|
||||
<div className={`w-full overflow-hidden h-full ${isDesktop ? 'px-[1rem] pb-[1rem]' : ''}`}>
|
||||
<div className="w-full h-full">
|
||||
<div className="splashContainer-otuV_A">
|
||||
@ -392,6 +409,7 @@ Please process this video editing request.`;
|
||||
title={taskObject.title}
|
||||
current={currentSketchIndex + 1}
|
||||
taskObject={taskObject}
|
||||
currentLoadingText={currentLoadingText}
|
||||
/>
|
||||
) : (
|
||||
<TaskInfo
|
||||
@ -406,9 +424,9 @@ Please process this video editing request.`;
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="media-Ocdu1O rounded-lg">
|
||||
<div className={`media-Ocdu1O rounded-lg ${!isDesktop ? '!flex' : ''}`}>
|
||||
<div
|
||||
className={`videoContainer-qteKNi ${!isDesktop ? '!w-full' : ''}`}
|
||||
className={`videoContainer-qteKNi ${!isDesktop ? '!w-full flex-1 items-center' : ''}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
{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.`;
|
||||
)}
|
||||
</div>
|
||||
{taskObject.currentStage !== 'script' && (
|
||||
<div className="h-[123px] w-[calc((100vh-6rem-200px)/9*16)]">
|
||||
<div className={`h-[123px] ${!isDesktop ? '!w-full' : 'w-[calc((100vh-6rem-200px)/9*16)]'}`}>
|
||||
<ThumbnailGrid
|
||||
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
|
||||
taskObject={taskObject}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
onSketchSelect={setCurrentSketchIndex}
|
||||
onRetryVideo={handleRetryVideo}
|
||||
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[40%]')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -610,7 +630,33 @@ Please process this video editing request.`;
|
||||
originalText={originalText}
|
||||
/>
|
||||
</div>
|
||||
</H5ProgressToastProvider>
|
||||
)
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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<HTMLDivElement | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false);
|
||||
|
||||
// 计算当前阶段类型
|
||||
const stage = taskObject.currentStage;
|
||||
@ -84,6 +90,9 @@ export function H5MediaViewer({
|
||||
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 => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
@ -216,7 +230,7 @@ export function H5MediaViewer({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full aspect-video min-h-[200px] flex items-center justify-center bg-black/10" data-alt="video-status">
|
||||
<div className="w-full aspect-video min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status">
|
||||
{status === 0 && (
|
||||
<span className="text-blue-500 text-base">Generating...</span>
|
||||
)}
|
||||
@ -241,6 +255,7 @@ export function H5MediaViewer({
|
||||
{status !== 0 && status !== 2 && (
|
||||
<span className="text-white/70 text-base">Pending</span>
|
||||
)}
|
||||
{/* 失败重试按钮改为全局固定渲染,移出 slide */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -275,20 +290,94 @@ export function H5MediaViewer({
|
||||
|
||||
// 剧本阶段:不使用 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 h-full">
|
||||
<div data-alt="script-content" className="w-full h-full pt-[4rem]">
|
||||
{scriptData ? (
|
||||
<ScriptRenderer
|
||||
data={scriptData}
|
||||
setIsPauseWorkFlow={setIsPauseWorkFlow}
|
||||
setAnyAttribute={setAnyAttribute}
|
||||
isPauseWorkFlow={isPauseWorkFlow}
|
||||
applyScript={applyScript}
|
||||
mode={mode}
|
||||
/>
|
||||
<>
|
||||
<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-4 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>
|
||||
);
|
||||
}
|
||||
@ -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') && (
|
||||
<div data-alt="global-video-actions" className="absolute top-2 right-6 z-[60] flex items-center gap-2">
|
||||
{stage === 'video' && (
|
||||
<>
|
||||
<GlassIconButton
|
||||
data-alt="edit-with-chat-button"
|
||||
className="w-8 h-8 bg-gradient-to-br from-blue-500/80 to-blue-600/80 backdrop-blur-xl border border-blue-400/30 rounded-full flex items-center justify-center hover:from-blue-400/80 hover:to-blue-500/80 transition-all"
|
||||
icon={MessageCircleMore}
|
||||
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 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-current-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-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]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
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: block; }
|
||||
.slick-list { width: 100%;height: 100%; }
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { TaskObject } from '@/api/DTO/movieEdit'
|
||||
import { GlassIconButton } from '@/components/ui/glass-icon-button'
|
||||
import { Pencil, RotateCcw, Download, ArrowDownWideNarrow, Scissors, Maximize, Minimize, MessageCircleMore } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Heart, Camera, Film, Scissors, type LucideIcon } from 'lucide-react'
|
||||
|
||||
interface H5TaskInfoProps {
|
||||
/** 标题文案 */
|
||||
@ -12,32 +12,41 @@ interface H5TaskInfoProps {
|
||||
current: number
|
||||
/** 任务对象(用于读取总数等信息) */
|
||||
taskObject: TaskObject
|
||||
/** video:聊天编辑 */
|
||||
onEditWithChat?: () => void
|
||||
/** 下载:全部分镜 */
|
||||
onDownloadAll?: () => void
|
||||
/** 下载:最终成片 */
|
||||
onDownloadFinal?: () => void
|
||||
/** 下载:当前分镜视频 */
|
||||
onDownloadCurrent?: () => void
|
||||
/** video:失败时显示重试 */
|
||||
showRetry?: boolean
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
currentLoadingText: string
|
||||
}
|
||||
|
||||
const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
||||
title,
|
||||
current,
|
||||
taskObject,
|
||||
onEditWithChat,
|
||||
onDownloadAll,
|
||||
onDownloadFinal,
|
||||
onDownloadCurrent,
|
||||
showRetry,
|
||||
onRetry,
|
||||
className
|
||||
className,
|
||||
currentLoadingText
|
||||
}) => {
|
||||
type StageIndex = 0 | 1 | 2 | 3
|
||||
|
||||
const [currentStage, setCurrentStage] = useState<StageIndex>(0)
|
||||
|
||||
const stageIconMap: Record<StageIndex, { icon: LucideIcon; color: string }> = {
|
||||
0: { icon: Heart, color: '#8b5cf6' },
|
||||
1: { icon: Camera, color: '#06b6d4' },
|
||||
2: { icon: Film, color: '#10b981' },
|
||||
3: { icon: Scissors, color: '#f59e0b' }
|
||||
}
|
||||
|
||||
const computeStage = (text: string): StageIndex => {
|
||||
if (text.includes('initializing...') || text.includes('script') || text.includes('character')) return 0
|
||||
if (text.includes('sketch') && !text.includes('shot sketch')) return 1
|
||||
if (!text.includes('Post-production') && (text.includes('shot sketch') || text.includes('video'))) return 2
|
||||
if (text.includes('Post-production')) return 3
|
||||
return 0
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStage(computeStage(currentLoadingText))
|
||||
}, [currentLoadingText])
|
||||
|
||||
const stageColor = useMemo(() => stageIconMap[currentStage].color, [currentStage])
|
||||
const total = useMemo(() => {
|
||||
if (taskObject.currentStage === 'video' || taskObject.currentStage === 'final_video') {
|
||||
return taskObject.videos?.total_count || taskObject.videos?.data?.length || 0
|
||||
@ -66,13 +75,13 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
||||
return (
|
||||
<div
|
||||
data-alt="h5-header"
|
||||
className={`absolute top-0 left-0 right-0 z-[50] pr-2 ${className || ''}`}
|
||||
className={`absolute top-0 left-0 right-0 z-[50] pr-1 ${className || ''}`}
|
||||
>
|
||||
<div
|
||||
data-alt="h5-header-bar"
|
||||
className="flex items-start justify-between"
|
||||
>
|
||||
<div data-alt="title-area" className="flex flex-col min-w-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg p-4">
|
||||
<div data-alt="title-area" className="flex flex-col min-w-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4">
|
||||
<h1
|
||||
data-alt="title"
|
||||
className="text-white text-lg font-bold"
|
||||
@ -82,71 +91,10 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
|
||||
</h1>
|
||||
{shouldShowCount && (
|
||||
<span data-alt="shot-count" className="flex items-center gap-4 text-sm text-slate-300">
|
||||
分镜 {displayCurrent}/{Math.max(total, 1)}
|
||||
{taskObject.currentStage === 'video' ? 'Shots' : 'Roles & Scenes '} {displayCurrent}/{Math.max(total, 1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div data-alt="actions" className="flex flex-col items-center gap-2">
|
||||
{taskObject.currentStage === 'final_video' && (
|
||||
<div data-alt="final-video-actions" className="flex flex-col items-center gap-2">
|
||||
<GlassIconButton
|
||||
data-alt="download-all-button"
|
||||
className="w-10 h-10 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={onDownloadAll}
|
||||
/>
|
||||
<GlassIconButton
|
||||
data-alt="download-final-button"
|
||||
className="w-10 h-10 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={onDownloadFinal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskObject.currentStage === 'video' && (
|
||||
<div data-alt="video-actions" className="flex flex-col items-center gap-2">
|
||||
<GlassIconButton
|
||||
data-alt="edit-with-chat-button"
|
||||
className="w-10 h-10 bg-gradient-to-br from-blue-500/80 to-blue-600/80 backdrop-blur-xl border border-blue-400/30 rounded-full flex items-center justify-center hover:from-blue-400/80 hover:to-blue-500/80 transition-all"
|
||||
icon={MessageCircleMore}
|
||||
size="sm"
|
||||
aria-label="edit-with-chat"
|
||||
onClick={onEditWithChat}
|
||||
/>
|
||||
<GlassIconButton
|
||||
data-alt="download-all-button"
|
||||
className="w-10 h-10 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={onDownloadAll}
|
||||
/>
|
||||
<GlassIconButton
|
||||
data-alt="download-current-button"
|
||||
className="w-10 h-10 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-current"
|
||||
onClick={onDownloadCurrent}
|
||||
/>
|
||||
{showRetry && (
|
||||
<GlassIconButton
|
||||
data-alt="retry-button"
|
||||
className="w-10 h-10 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={onRetry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,6 +14,7 @@ interface ThumbnailGridProps {
|
||||
currentSketchIndex: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
onRetryVideo: (video_id: string) => void;
|
||||
className: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -24,7 +25,8 @@ export function ThumbnailGrid({
|
||||
taskObject,
|
||||
currentSketchIndex,
|
||||
onSketchSelect,
|
||||
onRetryVideo
|
||||
onRetryVideo,
|
||||
className
|
||||
}: ThumbnailGridProps) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
@ -198,9 +200,7 @@ export function ThumbnailGrid({
|
||||
)}
|
||||
{taskObject.videos.data[index].video_status === 2 && (
|
||||
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center z-20">
|
||||
<div className="text-[#813b9dcc] text-xl font-bold flex items-center gap-2">
|
||||
<RotateCcw className="w-10 h-10" />
|
||||
</div>
|
||||
<div className="text-2xl mb-4">⚠️</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -334,7 +334,7 @@ export function ThumbnailGrid({
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
tabIndex={0}
|
||||
className="w-full h-full grid grid-flow-col auto-cols-[20%] gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none"
|
||||
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none ${className}`}
|
||||
autoFocus
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { notification } from 'antd';
|
||||
import { showEditingNotification } from '@/components/pages/work-flow/editing-notification';
|
||||
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, pauseMovieProjectPlan, resumeMovieProjectPlan, getGenerateEditPlan, regenerateVideoNew } from '@/api/video_flow';
|
||||
import { useScriptService } from "@/app/service/Interaction/ScriptService";
|
||||
import { useUpdateEffect } from '@/app/hooks/useUpdateEffect';
|
||||
@ -21,7 +19,22 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
const from = searchParams.get('from') || '';
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const useid = JSON.parse(localStorage.getItem("currentUser") || '{}').id || NaN;
|
||||
const notificationKey = useMemo(() => `video-workflow-${episodeId}`, [episodeId]);
|
||||
// H5进度提示事件桥接
|
||||
const emitToastShow = (params: { title?: string; progress?: number }) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('h5Toast:show', { detail: params }));
|
||||
}
|
||||
};
|
||||
const emitToastUpdate = (params: { title?: string; progress?: number }) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('h5Toast:update', { detail: params }));
|
||||
}
|
||||
};
|
||||
const emitToastHide = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('h5Toast:hide'));
|
||||
}
|
||||
};
|
||||
|
||||
const cutUrl = process.env.NEXT_PUBLIC_CUT_URL || 'https://cut.movieflow.ai';
|
||||
console.log('cutUrl', cutUrl);
|
||||
@ -30,14 +43,10 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
console.log("init-useWorkflowData");
|
||||
return () => {
|
||||
console.log("unmount-useWorkflowData");
|
||||
// 组件卸载时销毁通知
|
||||
notification.destroy(notificationKey);
|
||||
// 清理window上的重置函数
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (window as any)[`resetProgress_${notificationKey}`];
|
||||
}
|
||||
// 组件卸载时隐藏H5进度提示
|
||||
emitToastHide();
|
||||
};
|
||||
}, [notificationKey]);
|
||||
}, []);
|
||||
// 查看缓存中 是否已经 加载过 这个项目的 剪辑计划
|
||||
let isLoadedRef = useRef<string | null>(localStorage.getItem(`isLoaded_plan_${episodeId}`));
|
||||
|
||||
@ -94,6 +103,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
} = useScriptService();
|
||||
// 监听剧本加载完毕
|
||||
const scriptData = useMemo(() => {
|
||||
console.log('scriptData', scriptBlocksMemo);
|
||||
return scriptBlocksMemo.length > 0 ? scriptBlocksMemo : null;
|
||||
}, [scriptBlocksMemo]);
|
||||
// 监听继续 请求更新数据
|
||||
@ -145,19 +155,19 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
if (isLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
// 调用重置方法
|
||||
const resetFunc = (window as any)[`resetProgress_${notificationKey}`];
|
||||
if (resetFunc) {
|
||||
resetFunc();
|
||||
}
|
||||
// 更新通知内容
|
||||
showEditingNotification({
|
||||
key: notificationKey,
|
||||
description: `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`,
|
||||
successDescription: 'Editing plan generated successfully.',
|
||||
timeoutDescription: 'Editing plan generation failed. Please refresh and try again.',
|
||||
timeout: 8 * 60 * 1000
|
||||
});
|
||||
// 显示生成剪辑计划进度提示
|
||||
emitToastShow({ title: `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`, progress: 0 });
|
||||
// 平滑推进到 80%,后续阶段接管
|
||||
const start = Date.now();
|
||||
const duration = 3 * 60 * 1000; // 3分钟推进到 80%
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
const stop = () => { if (interval) { clearInterval(interval); interval = null; } };
|
||||
interval = setInterval(() => {
|
||||
const elapsed = Date.now() - start;
|
||||
const pct = Math.min(80, Math.floor((elapsed / duration) * 80));
|
||||
emitToastUpdate({ progress: pct });
|
||||
if (pct >= 80) stop();
|
||||
}, 300);
|
||||
// 先停止轮询
|
||||
await new Promise(resolve => {
|
||||
setNeedStreamData(false);
|
||||
@ -175,47 +185,41 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
isLoadedRef.current = 'true';
|
||||
setNeedStreamData(true);
|
||||
|
||||
// 销毁生成计划的通知
|
||||
notification.destroy(notificationKey);
|
||||
|
||||
// 触发回调,通知父组件计划生成完成
|
||||
console.log('📞 calling onEditPlanGenerated callback');
|
||||
onEditPlanGenerated?.();
|
||||
setIsLoadingGenerateEditPlan(false);
|
||||
stop();
|
||||
} catch (error) {
|
||||
console.error('生成剪辑计划失败:', error);
|
||||
setNeedStreamData(true);
|
||||
setIsGenerateEditPlan(false);
|
||||
|
||||
// 显示失败通知3秒
|
||||
showEditingNotification({
|
||||
key: notificationKey,
|
||||
description: `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`,
|
||||
timeoutDescription: 'Editing plan generation failed. Retrying later.',
|
||||
timeout: 3000
|
||||
});
|
||||
// 显示失败提示,并在稍后隐藏
|
||||
emitToastShow({ title: 'Editing plan generation failed. Retrying later.', progress: 0 });
|
||||
setTimeout(() => {
|
||||
notification.destroy(notificationKey);
|
||||
emitToastHide();
|
||||
setIsLoadingGenerateEditPlan(false);
|
||||
}, 8000);
|
||||
stop();
|
||||
}
|
||||
}, [episodeId, onEditPlanGenerated, notificationKey]);
|
||||
}, [episodeId, onEditPlanGenerated]);
|
||||
|
||||
const openEditPlan = useCallback(async () => {
|
||||
window.open(`${cutUrl}/ai-editor/${episodeId}?token=${token}&user_id=${useid}`, '_target');
|
||||
}, [episodeId]);
|
||||
}, [episodeId, cutUrl, token, useid]);
|
||||
|
||||
useEffect(() => {
|
||||
// 主动触发剪辑
|
||||
if (canGoToCut && taskObject.currentStage === 'video' && !isShowError) {
|
||||
generateEditPlan(retryCount - 1);
|
||||
}
|
||||
}, [canGoToCut, taskObject.currentStage, retryCount, isShowError]);
|
||||
}, [canGoToCut, taskObject.currentStage, isShowError, generateEditPlan, retryCount]);
|
||||
|
||||
useEffect(() => {
|
||||
// 加载剪辑计划结束 并且 失败了 重试
|
||||
if (!isLoadingGenerateEditPlan && !isGenerateEditPlan) {
|
||||
setRetryCount(retryCount + 1);
|
||||
setRetryCount((r) => r + 1);
|
||||
}
|
||||
}, [isLoadingGenerateEditPlan, isGenerateEditPlan]);
|
||||
|
||||
@ -225,12 +229,14 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
setCurrentLoadingText(LOADING_TEXT_MAP.toManyFailed);
|
||||
// 停止轮询
|
||||
setNeedStreamData(false);
|
||||
emitToastHide();
|
||||
}
|
||||
if (editingStatus === 'error') {
|
||||
window.msg.error('Editing failed, Please click the scissors button to go to the intelligent editing platform.', 8000);
|
||||
setCurrentLoadingText(LOADING_TEXT_MAP.editingError);
|
||||
// 停止轮询
|
||||
setNeedStreamData(false);
|
||||
emitToastHide();
|
||||
}
|
||||
}, [isShowError, editingStatus]);
|
||||
|
||||
@ -366,7 +372,6 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
}
|
||||
}
|
||||
|
||||
// debugger;
|
||||
|
||||
|
||||
if (task.task_name === 'generate_videos' && task.task_result && task.task_result.data) {
|
||||
@ -411,17 +416,8 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
// 只在第一次检测到视频分析任务时显示通知
|
||||
if (analyze_video_total_count > 0 && !isAnalyzing && analyze_video_completed_count !== analyze_video_total_count) {
|
||||
setIsAnalyzing(true);
|
||||
// 如果是第一次显示通知,才调用showEditingNotification
|
||||
const resetFunc = (window as any)[`resetProgress_${notificationKey}`];
|
||||
if (!resetFunc) {
|
||||
showEditingNotification({
|
||||
key: notificationKey,
|
||||
description: 'Preparing intelligent editing plan...',
|
||||
successDescription: 'Preparing successful',
|
||||
timeoutDescription: 'Preparing failed, please try again',
|
||||
timeout: 3 * 60 * 1000
|
||||
});
|
||||
}
|
||||
// 显示准备剪辑计划的提示
|
||||
emitToastShow({ title: 'Preparing intelligent editing plan...', progress: 0 });
|
||||
}
|
||||
|
||||
if (analyze_video_total_count && analyze_video_completed_count === analyze_video_total_count) {
|
||||
@ -430,10 +426,11 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
setCanGoToCut(true);
|
||||
// 重置进度条,显示生成剪辑计划进度
|
||||
setIsAnalyzing(false);
|
||||
// 不主动隐藏,交由后续阶段覆盖标题与进度
|
||||
} else {
|
||||
setIsShowError(true);
|
||||
notification.destroy(notificationKey);
|
||||
setIsAnalyzing(false);
|
||||
emitToastHide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -504,7 +501,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
}
|
||||
}, [episodeId, needStreamData, notificationKey, onExportFailed]);
|
||||
}, [episodeId, needStreamData, onExportFailed, errorConfig, isAnalyzing]);
|
||||
|
||||
// 轮询获取流式数据
|
||||
useUpdateEffect(() => {
|
||||
@ -523,7 +520,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
}, [needStreamData, fetchStreamData], {mode: 'none'});
|
||||
|
||||
// 初始化数据
|
||||
const initializeWorkflow = async () => {
|
||||
const initializeWorkflow = useCallback(async () => {
|
||||
if (!episodeId) {
|
||||
setDataLoadError('缺少必要的参数');
|
||||
return;
|
||||
@ -565,7 +562,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
}
|
||||
|
||||
if (status === 'COMPLETED') {
|
||||
loadingText = LOADING_TEXT_MAP.complete;
|
||||
loadingText.current = LOADING_TEXT_MAP.complete;
|
||||
taskCurrent.currentStage = 'final_video';
|
||||
setCanGoToCut(true);
|
||||
}
|
||||
@ -705,7 +702,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
isLoading: false
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [episodeId, initializeFromProject]);
|
||||
|
||||
// 重试生成视频
|
||||
const handleRetryVideo = async (video_id: string) => {
|
||||
@ -759,7 +756,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
initializeWorkflow();
|
||||
}, [episodeId]);
|
||||
}, [initializeWorkflow]);
|
||||
|
||||
return {
|
||||
taskObject,
|
||||
|
||||
@ -254,8 +254,9 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
||||
return (
|
||||
<motion.div
|
||||
key={block.id}
|
||||
className={`relative p-4 mb-1 rounded-lg shadow-md transition-colors duration-300
|
||||
className={`relative p-2 mb-1 rounded-lg shadow-md transition-colors duration-300
|
||||
${isActive ? 'bg-slate-700/50' : ''} hover:bg-slate-700/30`}
|
||||
id={`section-${block.id}`}
|
||||
ref={(el) => (contentRefs.current[block.id] = el)}
|
||||
onMouseEnter={() => setHoveredBlockId(block.id)}
|
||||
onMouseLeave={() => setHoveredBlockId(null)}
|
||||
@ -263,7 +264,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-1 text-blue-500">{block.title}</h2>
|
||||
<h2 className="text-lg font-semibold mb-1 text-blue-500">{block.title}</h2>
|
||||
{
|
||||
renderTypeBlock(block, isHovered, isActive, isEditing)
|
||||
}
|
||||
@ -272,10 +273,11 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden pt-2">
|
||||
<div className="flex-1 overflow-y-auto pr-4">
|
||||
<div className={`flex h-full overflow-hidden pt-2 ${from === 'h5' ? '!p-0' : ''}`} data-alt={from === 'h5' ? 'script-h5-container' : 'script-container'}>
|
||||
<div className={`flex-1 overflow-y-auto ${from === 'h5' ? 'pr-0' : 'pr-4'}`}>
|
||||
{data.map(renderBlock)}
|
||||
</div>
|
||||
{from !== 'h5' && (
|
||||
<div className="flex-shrink-0 flex flex-col overflow-y-auto relative">
|
||||
{/* 翻译功能 待开发 */}
|
||||
{/* <div className="p-2 rounded-lg">
|
||||
@ -317,6 +319,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
116
components/ui/h5-progress-toast.tsx
Normal file
116
components/ui/h5-progress-toast.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
interface ProgressState {
|
||||
open: boolean
|
||||
title: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
interface ProgressToastContextValue {
|
||||
/** 显示或更新进度提示 */
|
||||
show: (params: { title?: string; progress?: number }) => void
|
||||
/** 仅更新进度或标题 */
|
||||
update: (params: { title?: string; progress?: number }) => void
|
||||
/** 隐藏提示 */
|
||||
hide: () => void
|
||||
/** 当前状态(只读) */
|
||||
state: ProgressState
|
||||
}
|
||||
|
||||
const ProgressToastContext = createContext<ProgressToastContextValue | null>(null)
|
||||
|
||||
export const H5ProgressToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<ProgressState>({ open: false, title: 'AI生成中…', progress: 0 })
|
||||
|
||||
const hide = useCallback(() => setState(prev => ({ ...prev, open: false })), [])
|
||||
|
||||
const show = useCallback((params: { title?: string; progress?: number }) => {
|
||||
setState(prev => ({
|
||||
open: true,
|
||||
title: params.title ?? prev.title,
|
||||
progress: typeof params.progress === 'number' ? params.progress : prev.progress,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const update = useCallback((params: { title?: string; progress?: number }) => {
|
||||
setState(prev => ({
|
||||
open: prev.open || true,
|
||||
title: params.title ?? prev.title,
|
||||
progress: typeof params.progress === 'number' ? params.progress : prev.progress,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// 进度到100自动隐藏
|
||||
useEffect(() => {
|
||||
if (!state.open) return
|
||||
if (state.progress >= 100) {
|
||||
const timer = setTimeout(() => hide(), 600)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [state.open, state.progress, hide])
|
||||
|
||||
const value = useMemo<ProgressToastContextValue>(() => ({ show, update, hide, state }), [show, update, hide, state])
|
||||
|
||||
return (
|
||||
<ProgressToastContext.Provider value={value}>
|
||||
{children}
|
||||
<H5ProgressToastUI open={state.open} title={state.title} progress={state.progress} />
|
||||
</ProgressToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useH5ProgressToast() {
|
||||
const ctx = useContext(ProgressToastContext)
|
||||
if (!ctx) throw new Error('useH5ProgressToast must be used within H5ProgressToastProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
interface UIProps {
|
||||
open: boolean
|
||||
title: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
/**
|
||||
* H5样式的顶部居中进度提示,贴合截图风格。
|
||||
* 无遮罩;进度为0-100。
|
||||
*/
|
||||
const H5ProgressToastUI: React.FC<UIProps> = ({ open, title, progress }) => {
|
||||
if (!open) return null
|
||||
const pct = Math.max(0, Math.min(100, Math.round(progress)))
|
||||
return (
|
||||
<div
|
||||
data-alt="progress-toast"
|
||||
className="fixed right-4 top-16 sm:top-20 z-[100]"
|
||||
>
|
||||
<div
|
||||
data-alt="toast-card"
|
||||
className="px-4 py-3 rounded-2xl bg-[#1f1b2e]/95 shadow-2xl border border-white/10 min-w-[240px] max-w-[86vw]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-3 h-3 rounded-full bg-purple-500 shadow-[0_0_12px_rgba(168,85,247,0.8)]" data-alt="dot" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-white text-sm font-semibold truncate" data-alt="title-text">{title}</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-purple-500/30 overflow-hidden" data-alt="progress-bar">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-400 to-purple-600 rounded-full transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
data-alt="progress-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-white/70">
|
||||
<span data-alt="percent">{pct}%</span>
|
||||
<span data-alt="hint">{pct >= 100 ? '即将完成' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default H5ProgressToastProvider
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/90 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user