H5进度条

This commit is contained in:
北枳 2025-10-06 17:49:37 +08:00
parent d92a8f285a
commit 444e8bc58e
6 changed files with 340 additions and 94 deletions

View File

@ -5,6 +5,7 @@ import "./style/work-flow.css";
import { EditModal } from "@/components/ui/edit-modal";
import { TaskInfo } from "./work-flow/task-info";
import H5TaskInfo from "./work-flow/H5TaskInfo";
import H5ProgressBar from "./work-flow/H5ProgressBar";
import H5MediaViewer from "./work-flow/H5MediaViewer";
import { MediaViewer } from "./work-flow/media-viewer";
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
@ -16,12 +17,8 @@ import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
import { useSearchParams } from "next/navigation";
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
import { Drawer, Tooltip, notification } from 'antd';
import { showEditingNotification } from "@/components/pages/work-flow/editing-notification";
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { exportVideoWithRetry } from '@/utils/export-service';
import { getFirstFrame } from '@/utils/tools';
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';
@ -141,16 +138,16 @@ const WorkFlow = React.memo(function WorkFlow() {
const title = isMobile ? 'editing...' : 'Performing intelligent editing...';
// 显示进度提示并启动超时定时器
emitToastShow({ title: title, progress: 0 });
// emitToastShow({ title: title, 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);
// 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...');
@ -159,9 +156,9 @@ const WorkFlow = React.memo(function WorkFlow() {
clearInterval(editingProgressIntervalRef.current);
editingProgressIntervalRef.current = null;
}
emitToastHide();
// emitToastHide();
setTimeout(() => {
emitToastShow({ title: 'Retry intelligent editing...', progress: 0 });
// emitToastShow({ title: 'Retry intelligent editing...', progress: 0 });
// 重试阶段自动推进5分钟到 90%
if (editingProgressIntervalRef.current) clearInterval(editingProgressIntervalRef.current);
editingProgressStartRef.current = Date.now();
@ -169,7 +166,7 @@ const WorkFlow = React.memo(function WorkFlow() {
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 });
// emitToastUpdate({ progress: pct });
}, 250);
if (editingTimeoutRef.current) clearTimeout(editingTimeoutRef.current);
editingTimeoutRef.current = setTimeout(() => {
@ -183,7 +180,7 @@ const WorkFlow = React.memo(function WorkFlow() {
clearInterval(editingProgressIntervalRef.current);
editingProgressIntervalRef.current = null;
}
emitToastHide();
// emitToastHide();
}, 5000);
}, 5 * 60 * 1000);
}, 200);
@ -204,7 +201,7 @@ const WorkFlow = React.memo(function WorkFlow() {
clearInterval(editingProgressIntervalRef.current);
editingProgressIntervalRef.current = null;
}
emitToastHide();
// emitToastHide();
}, [emitToastHide]);
// 使用自定义 hooks 管理状态
@ -272,15 +269,15 @@ const WorkFlow = React.memo(function WorkFlow() {
clearInterval(editingProgressIntervalRef.current);
editingProgressIntervalRef.current = null;
}
emitToastUpdate({ title: 'Editing successful', progress: 100 });
// 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);
// setTimeout(() => {
// emitToastHide();
// }, 3000);
}
}, [taskObject.final, isHandleEdit, episodeId, emitToastHide, emitToastUpdate]);
@ -422,6 +419,7 @@ Please process this video editing request.`;
<div className="content-vPGYx8">
<div className="info-UUGkPJ">
{isMobile || isTablet ? (
<>
<H5TaskInfo
title={taskObject.title}
current={currentSketchIndex + 1}
@ -429,6 +427,14 @@ Please process this video editing request.`;
selectedView={selectedView}
currentLoadingText={currentLoadingText}
/>
{taskObject.currentStage !== 'init' && (
<H5ProgressBar
taskObject={taskObject}
scriptData={scriptData}
currentLoadingText={currentLoadingText}
/>
)}
</>
) : (
<TaskInfo
taskObject={taskObject}

View File

@ -102,14 +102,14 @@ export function H5MediaViewer({
/** 视频轮播容器高度:按 aspectRatio 计算(基于视口宽度) */
const videoWrapperHeight = useMemo(() => {
const { w, h } = parseAspect(aspectRatio);
return `min(calc(100vw * ${h} / ${w}), calc(100vh - 8.5rem))`;
return `min(calc(100vw * ${h} / ${w}), calc(100vh - 10.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))`;
return `min(calc(100vw * ${h} / ${w}), calc(100vh - 10.5rem))`;
}, [aspectRatio]);
// 计算当前阶段类型
@ -354,7 +354,7 @@ export function H5MediaViewer({
<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"
className="fixed bottom-[5rem] 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)}
>

View File

@ -0,0 +1,293 @@
'use client'
import React, { useMemo, useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Heart, Camera, Film, Scissors, type LucideIcon } from 'lucide-react'
import { TaskObject } from '@/api/DTO/movieEdit'
interface H5ProgressBarProps {
taskObject: TaskObject
scriptData: any
/** Loading text for stage detection */
currentLoadingText: string
className?: string
}
/** Stage configuration map */
const stageIconMap: Record<number, { icon: LucideIcon; color: string; label: string }> = {
0: { icon: Heart, color: '#6bf5f9', label: 'Script' },
1: { icon: Camera, color: '#88bafb', label: 'Roles & Scenes' },
2: { icon: Film, color: '#a285fd', label: 'Shots' },
3: { icon: Scissors, color: '#c73dff', label: 'Final' }
}
const H5ProgressBar: React.FC<H5ProgressBarProps> = ({
taskObject,
scriptData,
currentLoadingText,
className
}) => {
/** Calculate current stage based on taskObject state */
const currentStage = useMemo(() => {
/** Check if roles & scenes are completed */
const rolesCompleted =
taskObject.roles?.total_count > 0 &&
taskObject.roles?.data?.filter(v => v.status !== 0).length === taskObject.roles?.total_count
const scenesCompleted =
taskObject.scenes?.total_count > 0 &&
taskObject.scenes?.data?.filter(v => v.status !== 0).length === taskObject.scenes?.total_count
const rolesAndScenesCompleted = rolesCompleted && scenesCompleted
/** Check if videos are completed or nearly completed */
const videosCount = taskObject.videos?.data?.filter(v => v.video_status !== 0).length || 0
const videosTotal = taskObject.videos?.total_count || 0
const videosCompleted = videosTotal > 0 && videosCount === videosTotal
const videosNearlyComplete = videosTotal > 0 && videosCount >= videosTotal * 0.9 /** 90% complete */
/** Check if final video exists */
const finalVideoExists = !!taskObject.final?.url
/** Determine stage based on conditions */
if (finalVideoExists) {
return 4 /** Completed - all done */
}
/** Enter final video stage when videos are completed or nearly complete, or stage is explicitly set */
if (videosCompleted || videosNearlyComplete || taskObject.currentStage === 'final_video') {
return 3 /** Final video stage */
}
if (rolesAndScenesCompleted || taskObject.currentStage === 'video') {
return 2 /** Shots/video stage */
}
if (scriptData || taskObject.currentStage === 'character' || taskObject.currentStage === 'scene') {
return 1 /** Roles & scenes stage */
}
if (taskObject.currentStage === 'script') {
return 0 /** Script stage */
}
return 0 /** Default to script stage */
}, [
taskObject.currentStage,
taskObject.roles?.data,
taskObject.roles?.total_count,
taskObject.scenes?.data,
taskObject.scenes?.total_count,
taskObject.videos?.data,
taskObject.videos?.total_count,
taskObject.final?.url,
scriptData
])
/** Generate progress segments */
const segments = useMemo(() => {
/** Calculate progress for each stage */
const calculateStageProgress = (stage: number): number => {
const isCompleted = stage < currentStage
const isCurrent = stage === currentStage
const isNext = stage === currentStage + 1
/** Completed stages are always 100% */
if (isCompleted) {
return 100
}
/** Non-current and non-next stages are 0% */
if (!isCurrent && !isNext) {
return 0
}
/** Calculate current stage progress */
switch (stage) {
case 0: /** Script stage */
/** If scriptData exists or moved to next stage, show 100% */
if (scriptData || currentStage > 0) {
return 100
}
return 40
case 1: /** Roles & Scenes stage */
const rolesCount = taskObject.roles?.data?.filter(v => v.status !== 0).length || 0
const rolesTotal = taskObject.roles?.total_count || 0
const scenesCount = taskObject.scenes?.data?.filter(v => v.status !== 0).length || 0
const scenesTotal = taskObject.scenes?.total_count || 0
const totalItems = rolesTotal + scenesTotal
if (totalItems === 0) {
return 40
}
const completedItems = rolesCount + scenesCount
return Math.min(Math.round((completedItems / totalItems) * 100), 95)
case 2: /** Shots/Video stage */
const videosCount = taskObject.videos?.data?.filter(v => v.video_status !== 0).length || 0
const videosTotal = taskObject.videos?.total_count || 0
if (videosTotal === 0) {
return 40
}
return Math.min(Math.round((videosCount / videosTotal) * 100), 95)
case 3: /** Final video stage */
/** If final.url exists, show 100% */
if (taskObject.final?.url) {
return 100
}
/** If this is the next stage (not current), show initial progress */
if (isNext) {
return 0
}
/** Current stage in progress */
return 60
default:
return 0
}
}
return [0, 1, 2, 3].map((stage) => {
const config = stageIconMap[stage]
const isCompleted = stage < currentStage
const isCurrent = stage === currentStage
const segmentProgress = calculateStageProgress(stage)
return {
stage,
config,
isCompleted,
isCurrent,
segmentProgress
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentStage,
scriptData,
taskObject.roles?.data,
taskObject.roles?.total_count,
taskObject.scenes?.data,
taskObject.scenes?.total_count,
taskObject.videos?.data,
taskObject.videos?.total_count,
taskObject.final?.url
])
return (
<div
data-alt="h5-progress-bar-container"
className={`w-full py-2 ${className || ''}`}
>
<div data-alt="progress-segments" className="flex items-center gap-1 relative">
{segments.map(({ stage, config, isCompleted, isCurrent, segmentProgress }) => {
const Icon = config.icon
return (
<div
key={stage}
data-alt={`progress-segment-${stage}`}
className="flex-1 relative h-[0.35rem] bg-slate-700/50 rounded-full overflow-visible"
>
{/* Progress fill */}
<motion.div
data-alt="progress-fill"
className="absolute inset-0 rounded-full z-0 backdrop-blur-md"
style={{
background: `${config.color}80`
}}
initial={{ width: '0%' }}
animate={{ width: `${segmentProgress}%` }}
transition={{
duration: 0.6,
ease: 'easeInOut'
}}
/>
{/* Animated icon for current stage only */}
<AnimatePresence>
{isCurrent && !currentLoadingText.includes('Task completed') && segmentProgress < 100 && (
<motion.div
data-alt="stage-icon-moving"
className="absolute -top-1/2 -translate-y-1/2 z-20"
initial={{ left: '0%', x: '-50%', opacity: 0, scale: 0.5 }}
animate={{
left: `${segmentProgress}%`,
x: '-50%',
opacity: 1,
scale: 1,
rotate: [0, 360],
transition: {
left: { duration: 0.6, ease: 'easeInOut' },
x: { duration: 0 },
opacity: { duration: 0.3 },
scale: { duration: 0.3 },
rotate: { duration: 2, repeat: Infinity, ease: 'linear' }
}
}}
exit={{
opacity: 0,
scale: 0.5,
transition: { duration: 0.3 }
}}
>
<div
data-alt="icon-wrapper"
className="w-3 h-3 rounded-full bg-white/90 backdrop-blur-sm flex items-center justify-center shadow-lg"
style={{
boxShadow: `0 0 8px ${config.color}80`,
background: `${config.color}`
}}
>
{/* <Icon className="w-2 h-2" style={{ color: config.color }} /> */}
<Icon className="w-2 h-2" style={{ color: '#fff', fontWeight: 'bold' }} />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Glow effect for current stage */}
{isCurrent && segmentProgress < 100 && (
<motion.div
data-alt="glow-effect"
className="absolute inset-0 rounded-full z-10"
style={{
background: `linear-gradient(to right, transparent, ${config.color}40, transparent)`
}}
animate={{
x: ['-100%', '100%'],
transition: {
duration: 1.5,
repeat: Infinity,
ease: 'linear'
}
}}
/>
)}
</div>
)
})}
</div>
{/* Stage labels (optional) */}
{/* <div data-alt="stage-labels" className="flex items-center justify-between mt-1 px-1">
{segments.map(({ stage, config, isCurrent }) => (
<span
key={stage}
data-alt={`stage-label-${stage}`}
className={`text-[10px] ${isCurrent ? 'text-white font-medium' : 'text-slate-400'}`}
style={isCurrent ? { color: config.color } : {}}
>
{config.label}
</span>
))}
</div> */}
</div>
)
}
export default H5ProgressBar

View File

@ -147,57 +147,6 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
)}
</div>
{/* 右侧状态区域 */}
<div data-alt="status-area" className="flex-shrink-0 bg-gradient-to-b from-slate-900/80 via-slate-900/40 to-transparent backdrop-blur-sm rounded-lg py-4 px-4 max-w-[200px]">
<AnimatePresence mode="popLayout">
{currentLoadingText && currentLoadingText !== 'Task completed' && (
<motion.div
key={currentLoadingText}
data-alt="status-line"
className="flex flex-col gap-2"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.25 }}
>
<div className="flex items-center justify-center">
{StageIcon}
</div>
<div className="relative text-center">
{/* 背景流光 */}
<motion.div
className="absolute inset-0 text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-cyan-400 to-purple-400 blur-[1px]"
animate={{
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
transition: { duration: 2, repeat: Infinity, ease: 'linear' }
}}
style={{ backgroundSize: '200% 200%' }}
>
<span className="text-xs leading-tight break-words">{currentLoadingText}</span>
</motion.div>
{/* 主文字轻微律动 */}
<motion.div
className="relative z-10"
animate={{ scale: [1, 1.02, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
>
</motion.div>
{/* 底部装饰线 */}
<motion.div
className="absolute -bottom-0.5 left-1/2 transform -translate-x-1/2 h-0.5 w-8"
style={{
background: `linear-gradient(to right, ${stageColor}, rgb(34 211 238), rgb(168 85 247))`
}}
animate={{ width: ['0%', '100%', '0%'] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)

View File

@ -29,19 +29,19 @@ interface TaskInfoProps {
const stageIconMap = {
0: {
icon: Heart,
color: '#8b5cf6'
color: '#6bf5f9'
},
1: {
icon: Camera,
color: '#06b6d4'
color: '#88bafb'
},
2: {
icon: Film,
color: '#10b981'
color: '#a285fd'
},
3: {
icon: Scissors,
color: '#f59e0b'
color: '#c73dff'
}
}

View File

@ -49,7 +49,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
return () => {
console.log("unmount-useWorkflowData");
// 组件卸载时隐藏H5进度提示
emitToastHide();
// emitToastHide();
};
}, []);
// 查看缓存中 是否已经 加载过 这个项目的 剪辑计划
@ -161,7 +161,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
return;
}
// 显示生成剪辑计划进度提示
emitToastShow({ title: isMobile ? 'Preparing for editing...' : `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`, progress: 0 });
// !emitToastShow({ title: isMobile ? 'Preparing for editing...' : `Generating intelligent editing plan... ${retryCount ? 'Retry Time: ' + retryCount : ''}`, progress: 0 });
// 平滑推进到 80%,后续阶段接管
const start = Date.now();
const duration = 3 * 60 * 1000; // 3分钟推进到 80%
@ -170,7 +170,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
interval = setInterval(() => {
const elapsed = Date.now() - start;
const pct = Math.min(80, Math.floor((elapsed / duration) * 80));
emitToastUpdate({ progress: pct });
// emitToastUpdate({ progress: pct });
if (pct >= 80) stop();
}, 300);
// 先停止轮询
@ -200,10 +200,8 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
setNeedStreamData(true);
setIsGenerateEditPlan(false);
// 显示失败提示,并在稍后隐藏
// emitToastShow({ title: isMobile ? 'Editing plan generation failed. Retrying later.' : 'Editing plan generation failed. Retrying later.', progress: 0 });
setTimeout(() => {
emitToastHide();
// emitToastHide();
setIsLoadingGenerateEditPlan(false);
}, 8000);
stop();
@ -234,14 +232,14 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
setCurrentLoadingText(LOADING_TEXT_MAP.toManyFailed);
// 停止轮询
setNeedStreamData(false);
emitToastHide();
// 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();
// emitToastHide();
}
}, [isShowError, editingStatus]);
@ -422,7 +420,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
if (analyze_video_total_count > 0 && !isAnalyzing && analyze_video_completed_count !== analyze_video_total_count) {
setIsAnalyzing(true);
// 显示准备剪辑计划的提示
emitToastShow({ title: isMobile ? 'Preparing for editing...' : 'Preparing intelligent editing plan...', progress: 0 });
// emitToastShow({ title: isMobile ? 'Preparing for editing...' : 'Preparing intelligent editing plan...', progress: 0 });
}
if (analyze_video_total_count && analyze_video_completed_count === analyze_video_total_count) {
@ -435,7 +433,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
} else {
setIsShowError(true);
setIsAnalyzing(false);
emitToastHide();
// emitToastHide();
}
}
}