H5适配中

This commit is contained in:
北枳 2025-09-20 16:05:06 +08:00
parent 5efe68b63b
commit 8a0e0c6d2a
8 changed files with 543 additions and 254 deletions

View File

@ -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();
// 通过全局事件桥接 H5ProgressToastProvider 在本组件 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;
};

View File

@ -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%; }

View File

@ -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>

View File

@ -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}

View File

@ -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,

View File

@ -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>
);
};

View 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

View File

@ -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}