Merge pull request 'dev' (#3) from dev into prod

Reviewed-on: 77media/video-flow#3
This commit is contained in:
wangrumeng 2025-10-10 16:19:01 +08:00
commit 85cc31a44f
23 changed files with 1369 additions and 671 deletions

View File

@ -1,16 +1,16 @@
# 测试
# NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
# NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
# NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.net/api/auth/google/callback
# NEXT_PUBLIC_CUT_URL_TO = https://smartcut.huiying.video
NEXT_PUBLIC_JAVA_URL = https://auth.test.movieflow.ai
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_CUT_URL = https://77.smartcut.py.qikongjian.com
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.net/api/auth/google/callback
NEXT_PUBLIC_CUT_URL_TO = https://smartcut.huiying.video
# 生产
NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
NEXT_PUBLIC_CUT_URL = https://smartcut.api.movieflow.ai
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.ai/api/auth/google/callback
NEXT_PUBLIC_CUT_URL_TO = https://smartcut.movieflow.ai
# NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
# NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
# NEXT_PUBLIC_CUT_URL = https://smartcut.api.movieflow.ai
# NEXT_PUBLIC_GOOGLE_REDIRECT_URI=https://www.movieflow.ai/api/auth/google/callback
# NEXT_PUBLIC_CUT_URL_TO = https://smartcut.movieflow.ai
# 通用
# 当前域名配置
NEXT_PUBLIC_FRONTEND_URL = https://www.movieflow.ai

View File

@ -1,5 +1,5 @@
import { ApiResponse } from './common';
import { showQueueNotification } from '../components/QueueBox/QueueNotification2';
import { showH5QueueNotification } from '../components/QueueBox/H5QueueNotication';
import { notification } from 'antd';
/** 队列状态枚举 */
@ -66,6 +66,7 @@ export async function withQueuePolling<T>(
let attempts = 0;
let lastNotificationPosition: number | undefined;
let isCancelled = false;
let closeModal: (() => void) | null = null;
// 生成唯一的轮询ID
const pollId = Math.random().toString(36).substring(7);
@ -73,7 +74,8 @@ export async function withQueuePolling<T>(
// 创建取消函数
const cancel = () => {
isCancelled = true;
notification.destroy(); // 关闭通知
try { closeModal?.(); } catch {}
notification.destroy(); // 兼容旧弹层
onCancel?.();
cancelTokens.delete(pollId);
};
@ -100,7 +102,19 @@ export async function withQueuePolling<T>(
// 如果位置发生变化,更新通知
if ((position !== lastNotificationPosition || status === QueueStatus.PROCESS) &&
position !== undefined && waiting !== undefined) {
showQueueNotification(position, waiting, status, cancel);
// 打开或更新 H5 弹窗(仅允许 Cancel 关闭Refresh 触发刷新)
try { closeModal?.(); } catch {}
closeModal = showH5QueueNotification(
position,
waiting,
status,
cancel,
async () => {
// 触发一次立刻刷新:重置 attempts 的等待,直接递归调用 poll()
// 不关闭弹窗,由 showH5QueueNotification 保持打开
attempts = Math.max(0, attempts - 1);
}
);
lastNotificationPosition = position;
}
@ -120,15 +134,18 @@ export async function withQueuePolling<T>(
// 如果状态为ready结束轮询
if (response.code !== 202 && response.data) {
notification.destroy(); // 关闭通知
try { closeModal?.(); } catch {}
notification.destroy(); // 兼容旧弹层
onSuccess?.(response.data);
return response;
}
notification.destroy(); // 关闭通知
try { closeModal?.(); } catch {}
notification.destroy(); // 兼容旧弹层
return response;
} catch (error) {
notification.destroy(); // 关闭通知
try { closeModal?.(); } catch {}
notification.destroy(); // 兼容旧弹层
if (error instanceof Error) {
onError?.(error);
}

View File

@ -307,6 +307,6 @@ body {
}
}
textarea, input {
.mobile-textarea, .mobile-input {
font-size: 16px !important;
}

View File

@ -9,6 +9,7 @@ import { GoogleLoginButton } from "@/components/ui/google-login-button";
import { Eye, EyeOff, Mail, PartyPopper } from "lucide-react";
import { isGoogleLoginEnabled } from "@/lib/server-config";
import { fetchSettingByCode } from "@/api/serversetting";
import { useDeviceType } from "@/hooks/useDeviceType";
export default function SignupPage() {
const [name, setName] = useState("");
@ -31,7 +32,7 @@ export default function SignupPage() {
const [showGoogleLogin, setShowGoogleLogin] = useState(false);
const [showRedirectModal, setShowRedirectModal] = useState(false);
const router = useRouter();
const { isMobile } = useDeviceType();
// Handle scroll indicator for small screens and load SSO config
React.useEffect(() => {
try {
@ -448,7 +449,7 @@ export default function SignupPage() {
onFocus={() => setEmailFocused(true)}
onBlur={() => setEmailFocused(false)}
required
className="w-full px-4 py-3 rounded-lg bg-black/30 border border-white/20 text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:border-custom-blue/80"
className={`w-full px-4 py-3 rounded-lg bg-black/30 border border-white/20 text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:border-custom-blue/80 ${isMobile ? 'mobile-input' : ''}`}
/>
</div>
@ -473,7 +474,7 @@ export default function SignupPage() {
required
className={`w-full px-4 py-3 pr-12 rounded-lg bg-black/30 border border-white/20 text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:border-custom-blue/80 ${
passwordError ? "border-red-500/50" : "border-white/20"
}`}
} ${isMobile ? 'mobile-input' : ''}`}
/>
<button
type="button"

View File

@ -356,7 +356,7 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="Describe the story you want to make..."
className={`w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none overflow-y-auto transition-all duration-300 ease-in-out ${isMobile ? '' : 'max-h-[120px]'}`}
className={`w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none overflow-y-auto transition-all duration-300 ease-in-out ${isMobile ? 'mobile-textarea' : 'max-h-[120px]'}`}
style={{
minHeight: noData ? "128px" : (isMobile ? (isInputFocused ? "96px" : "48px") : "unset"),
maxHeight: isMobile ? (isInputFocused ? "200px" : (persistedMobileMaxHeight ? `${persistedMobileMaxHeight}px` : "120px")) : undefined,

View File

@ -305,7 +305,7 @@ export const H5PhotoStoryDrawer = ({
updateCharacterName(avatar.name, newName);
}
}}
className="w-full max-w-[72px] md:max-w-[80px] text-center text-xs md:text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5"
className="w-full max-w-[72px] md:max-w-[80px] text-center text-xs md:text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 mobile-input"
/>
</div>
))}

View File

@ -276,7 +276,7 @@ export const H5TemplateDrawer = ({
setSelectedTemplate(updatedTemplate);
}}
placeholder={role.user_tips}
className="w-[20rem] px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
className="w-[20rem] px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm mobile-input"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<ActionButton
@ -404,7 +404,7 @@ export const H5TemplateDrawer = ({
setSelectedTemplate(updatedTemplate);
}}
placeholder="Enter description for AI image generation..."
className="w-[20rem] px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
className="w-[20rem] px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm mobile-input"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<ActionButton
@ -576,7 +576,7 @@ export const H5TemplateDrawer = ({
data-alt="h5-template-free-input-top"
value={selectedTemplate?.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm mobile-textarea"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
@ -601,7 +601,7 @@ export const H5TemplateDrawer = ({
data-alt="h5-template-free-input-bottom"
value={selectedTemplate.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate.freeInput[0].user_tips}
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm mobile-input"
onChange={(e) => {
const updatedTemplate = {
...selectedTemplate!,

View File

@ -0,0 +1,167 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { createRoot, Root } from 'react-dom/client';
type QueueStatus = 'waiting' | 'process' | string;
interface H5QueueNotificationProps {
position: number;
estimatedMinutes: number;
status: QueueStatus;
onCancel?: () => void;
onRefresh?: () => void;
onClose?: () => void;
}
function H5QueueNotificationModal(props: H5QueueNotificationProps) {
const { position, estimatedMinutes, status, onCancel, onClose } = props;
const containerRef = useRef<HTMLDivElement | null>(null);
// 禁用 ESC 关闭,保留空 effect 结构便于未来拓展
useEffect(() => {
return () => {};
}, []);
useEffect(() => {
// Prevent background scroll on mobile while modal is open
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
const message = status === 'process'
? 'Your work is being produced. Please wait until it is completed before creating a new work.'
: `Your work is waiting for production at the ${position} position`;
return (
<div
ref={containerRef}
data-alt="h5-queue-overlay"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
>
<div
data-alt="h5-queue-modal"
className="relative w-11/12 max-w-sm rounded-2xl bg-white/5 border border-white/10 backdrop-blur-xl shadow-2xl p-6 text-white"
>
{/* 去除右上角关闭按钮,避免除取消以外的关闭路径 */}
<div data-alt="modal-header" className="flex flex-col items-center text-center gap-2">
<div data-alt="modal-icon" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-lg">
🎬
</div>
<h3 data-alt="modal-title" className="text-base font-semibold">Queue Reminder</h3>
</div>
<div data-alt="modal-body" className="mt-4 space-y-4">
<p data-alt="queue-description" className="text-sm text-white/80">
{message}
</p>
{status !== 'process' && (
<div data-alt="stats-grid" className="grid grid-cols-2 gap-3">
<div data-alt="stat-position" className="rounded-lg bg-white/5 border border-white/10 p-3">
<div className="text-[11px] text-white/60">Position</div>
<div className="text-sm font-medium">#{position}</div>
</div>
<div data-alt="stat-eta" className="rounded-lg bg-white/5 border border-white/10 p-3">
<div className="text-[11px] text-white/60">ETA</div>
<div className="text-sm font-medium">~{estimatedMinutes} min</div>
</div>
</div>
)}
</div>
<div data-alt="modal-actions" className="mt-6 space-y-3">
<button
data-alt="refresh-button"
className="w-full px-4 py-2 rounded-lg bg-[#9144b0] text-white font-medium transition-colors"
onClick={() => {
// 刷新仅触发回调,不关闭弹窗
if (status !== 'process') {
props.onRefresh?.();
} else {
onCancel?.();
onClose?.();
}
}}
>
{status !== 'process' ? 'Refresh' : 'OK'}
</button>
{status !== 'process' && (
<button
data-alt="cancel-queue-button"
className="w-full text-sm text-[#ab50d0] underline underline-offset-2 decoration-[#9144b0]/60"
onClick={() => {
onCancel?.();
onClose?.();
}}
>
Cancel queue
</button>
)}
</div>
</div>
</div>
);
}
/**
* Opens a lightweight H5-styled dark modal queue notification.
* @param {number} position - Current queue position.
* @param {number} estimatedMinutes - Estimated waiting minutes.
* @param {QueueStatus} status - Queue status: 'waiting' | 'process' | string.
* @param {() => void} onCancel - Callback when user confirms cancel.
* @returns {() => void} - Close function to dismiss the modal programmatically.
*/
export function showH5QueueNotification(
position: number,
estimatedMinutes: number,
status: QueueStatus,
onCancel?: () => void,
onRefresh?: () => void
): () => void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return () => {};
}
const mount = document.createElement('div');
mount.setAttribute('data-alt', 'h5-queue-root');
document.body.appendChild(mount);
let root: Root | null = null;
try {
root = createRoot(mount);
} catch {
// Fallback cleanup if root creation fails
document.body.removeChild(mount);
return () => {};
}
const close = () => {
try {
root?.unmount();
} finally {
if (mount.parentNode) {
mount.parentNode.removeChild(mount);
}
}
};
root.render(
<H5QueueNotificationModal
position={position}
estimatedMinutes={estimatedMinutes}
status={status}
onCancel={onCancel}
onRefresh={onRefresh}
onClose={close}
/>
);
return close;
}

View File

@ -286,7 +286,7 @@ const { isMobile, isTablet, isDesktop } = useDeviceType();
<textarea
ref={textareaRef}
placeholder="Describe your idea..."
className="w-full pl-2 pr-2 py-4 rounded-2 leading-4 text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
className={`w-full pl-2 pr-2 py-4 rounded-2 leading-4 text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto ${isMobile ? 'mobile-textarea' : ''}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}

View File

@ -172,7 +172,10 @@ export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
const handleCustomAmountBuy = async () => {
const amount = parseInt(customAmount);
if (isNaN(amount) || amount <= 0) return;
if (isNaN(amount) || amount < 50) {
window.msg.warning("Minimum purchase is 50 credits.");
return;
}
await handleBuyTokens(amount);
setCustomAmount("");
};
@ -385,7 +388,7 @@ export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="Custom amount"
className="w-[120px] h-9 px-2 text-sm bg-white/10 text-black dark:text-white placeholder-black/40 dark:placeholder-white/60 border border-black/20 dark:border-white/20 rounded focus:outline-none"
className="w-[120px] h-9 px-2 text-sm bg-white/10 text-black dark:text-white placeholder-black/40 dark:placeholder-white/60 border border-black/20 dark:border-white/20 rounded focus:outline-none mobile-input"
min={1}
/>
<button

View File

@ -113,11 +113,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
const handleCustomAmountBuy = async () => {
const amount = parseInt(customAmount);
if (isNaN(amount) || amount < 50) {
showInsufficientPointsNotification({
current_balance: credits,
required_tokens: 50,
message: "Minimum purchase is 50 credits.",
});
window.msg.warning("Minimum purchase is 50 credits.");
return;
}
await handleBuyTokens(amount);

View File

@ -11,6 +11,7 @@ import { GoogleLoginButton } from "@/components/ui/google-login-button";
import { Eye, EyeOff } from "lucide-react";
import { isGoogleLoginEnabled } from "@/lib/server-config";
import Footer from "@/components/common/Footer";
import { useDeviceType } from "@/hooks/useDeviceType";
export default function Login() {
const [email, setEmail] = useState("");
@ -26,7 +27,7 @@ export default function Login() {
const [showGoogleLogin, setShowGoogleLogin] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const { isMobile } = useDeviceType();
/** 密码验证函数 */
/**
* Password validation function with English prompts
@ -213,7 +214,7 @@ export default function Login() {
<input
placeholder="Email"
required
className="form-control"
className={`form-control ${isMobile ? 'mobile-input' : ''}`}
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
@ -236,8 +237,8 @@ export default function Login() {
placeholder="Password (8-18 characters, letters, numbers and !@#$%^*&.)"
required
className={`form-control pr-10 ${
passwordError ? "border-red-500/50" : ""
}`}
passwordError ? "border-red-500/50" : ""
} ${isMobile ? 'mobile-input' : ''}`}
type={showPassword ? "text" : "password"}
value={password}
onChange={handlePasswordChange}

View File

@ -165,7 +165,7 @@
display: grid;
grid-auto-rows: auto;
justify-content: center;
align-items: center;
align-items: self-start;
}
.videoContainer-qteKNi {
flex: 1;
@ -181,7 +181,7 @@
object-position: center;
background-color: #0003;
border-radius: 8px;
height: 100%;
/* height: 100%; */
}
.container-kIPoeH {
box-sizing: border-box;

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';
@ -54,7 +51,6 @@ const WorkFlow = React.memo(function WorkFlow() {
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
const [selectedView, setSelectedView] = React.useState<'final' | 'video' | null>(null);
const [isFinalBarOpen, setIsFinalBarOpen] = React.useState(true);
const [aiEditingResult, setAiEditingResult] = React.useState<any>(null);
// const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
@ -142,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...');
@ -160,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();
@ -170,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(() => {
@ -184,7 +180,7 @@ const WorkFlow = React.memo(function WorkFlow() {
clearInterval(editingProgressIntervalRef.current);
editingProgressIntervalRef.current = null;
}
emitToastHide();
// emitToastHide();
}, 5000);
}, 5 * 60 * 1000);
}, 200);
@ -205,7 +201,7 @@ const WorkFlow = React.memo(function WorkFlow() {
clearInterval(editingProgressIntervalRef.current);
editingProgressIntervalRef.current = null;
}
emitToastHide();
// emitToastHide();
}, [emitToastHide]);
// 使用自定义 hooks 管理状态
@ -248,12 +244,12 @@ const WorkFlow = React.memo(function WorkFlow() {
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
}, [currentSketchIndex, taskObject]);
// 当最终视频出现时,默认选中最终视频
// 当最终视频出现时,强制切换到最终视频
useEffect(() => {
if (taskObject?.final?.url && selectedView === null) {
if (taskObject?.final?.url) {
setSelectedView('final');
}
}, [taskObject?.final?.url, selectedView]);
}, [taskObject?.final?.url]);
// 监听粗剪是否完成
useEffect(() => {
@ -273,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]);
@ -423,13 +419,22 @@ Please process this video editing request.`;
<div className="content-vPGYx8">
<div className="info-UUGkPJ">
{isMobile || isTablet ? (
<H5TaskInfo
title={taskObject.title}
current={currentSketchIndex + 1}
taskObject={taskObject}
selectedView={selectedView}
currentLoadingText={currentLoadingText}
/>
<>
<H5TaskInfo
title={taskObject.title}
current={currentSketchIndex + 1}
taskObject={taskObject}
selectedView={selectedView}
currentLoadingText={currentLoadingText}
/>
{taskObject.currentStage !== 'init' && (
<H5ProgressBar
taskObject={taskObject}
scriptData={scriptData}
currentLoadingText={currentLoadingText}
/>
)}
</>
) : (
<TaskInfo
taskObject={taskObject}
@ -449,39 +454,13 @@ Please process this video editing request.`;
ref={containerRef}
>
{isDesktop ? (
<div className={`relative heroVideo-FIzuK1 ${['script'].includes(taskObject.currentStage) ? 'h-[calc(100vh-6rem)] w-[calc((100vh-6rem)/9*16)]' : 'h-[calc(100vh-6rem-200px)] w-[calc((100vh-6rem-200px)/9*16)]'}`} style={{ aspectRatio: "16 / 9" }} key={taskObject.currentStage+'_'+currentSketchIndex}>
{/* 左侧最终视频缩略图栏(仅桌面) */}
{taskObject?.final?.url && (
<div
className="absolute left-0 top-0 z-[50]"
data-alt="final-sidebar"
>
<div className={`flex items-start`}>
{isFinalBarOpen && (
<div
className="w-28 max-h-[60vh] overflow-y-auto rounded-lg backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-2 mr-2"
data-alt="final-thumbnails"
>
{/* 预留历史列表,目前仅展示当前最终视频 */}
<button
type="button"
onClick={() => setSelectedView('final')}
className={`block w-full overflow-hidden rounded-md border ${selectedView === 'final' ? 'border-blue-500' : 'border-white/20'}`}
data-alt="final-thumb-item"
aria-label="Select final video"
>
<img
src={getFirstFrame(taskObject.final.url)}
alt="final"
className="w-full h-auto object-cover"
/>
</button>
</div>
)}
</div>
</div>
)}
<div className={`relative heroVideo-FIzuK1`}
style={{
height: taskObject.final.url ? 'calc(100vh - 8rem)' : 'calc(100vh - 12rem)'
}}
>
<MediaViewer
key={taskObject.currentStage+'_'+currentSketchIndex}
taskObject={taskObject}
scriptData={scriptData}
currentSketchIndex={currentSketchIndex}
@ -503,55 +482,104 @@ Please process this video editing request.`;
onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
aspectRatio={aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '16:9' : '9:16'}
placeholderWidth={aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `calc((100vh - ${taskObject.final.url ? '8rem' : '12rem'}) / 9 * 16)` : `calc((100vh - ${taskObject.final.url ? '8rem' : '12rem'}) / 16 * 9)`}
/>
{['scene', 'character', 'video', 'final_video'].includes(taskObject.currentStage) && (
<div className={`h-14 absolute bottom-[0.5rem] left-[50%] translate-x-[-50%] z-[21]`}
style={{
maxWidth: 'calc(100% - 6rem)'
}}>
<ThumbnailGrid
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject}
currentSketchIndex={currentSketchIndex}
onSketchSelect={(index) => {
if (index === -1 && taskObject.final.url) {
// 点击最终视频
setSelectedView('final');
setCurrentSketchIndex(0);
} else {
// 点击普通视频
taskObject.final.url && setSelectedView('video');
setCurrentSketchIndex(index);
}
}}
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
selectedView={selectedView}
aspectRatio={aspectRatio}
isMobile={isMobile}
/>
</div>
)}
</div>
) : (
<H5MediaViewer
taskObject={taskObject}
scriptData={scriptData}
currentSketchIndex={currentSketchIndex}
selectedView={selectedView}
mode={mode}
setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute}
isPauseWorkFlow={isPauseWorkFlow}
applyScript={applyScript}
setCurrentSketchIndex={setCurrentSketchIndex}
onOpenChat={() => setIsSmartChatBoxOpen(true)}
setVideoPreview={(url, id) => {
setPreviewVideoUrl(url);
setPreviewVideoId(id);
}}
showGotoCutButton={showGotoCutButton || editingStatus !== 'idle'}
onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
enableVideoEdit={true}
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
projectId={episodeId}
/>
<div className="relative w-full">
<H5MediaViewer
taskObject={taskObject}
scriptData={scriptData}
currentSketchIndex={currentSketchIndex}
selectedView={selectedView}
mode={mode}
setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute}
isPauseWorkFlow={isPauseWorkFlow}
applyScript={applyScript}
setCurrentSketchIndex={setCurrentSketchIndex}
onOpenChat={() => setIsSmartChatBoxOpen(true)}
setVideoPreview={(url, id) => {
setPreviewVideoUrl(url);
setPreviewVideoId(id);
}}
showGotoCutButton={showGotoCutButton || editingStatus !== 'idle'}
onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
enableVideoEdit={true}
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
projectId={episodeId}
aspectRatio={aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '16:9' : '9:16'}
/>
{['scene', 'character', 'video', 'final_video'].includes(taskObject.currentStage) && (
<div className={`h-14 absolute left-[50%] translate-x-[-50%] z-[21] ${isMobile ? '' : 'bottom-[180px]'}`}
style={{
maxWidth: 'calc(100vw - 5.5rem)',
bottom: (aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE') ? '-2.5rem' : '0.5rem'
}}>
<ThumbnailGrid
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject}
currentSketchIndex={currentSketchIndex}
onSketchSelect={(index) => {
if (index === -1) {
// 点击最终视频
setSelectedView('final');
setCurrentSketchIndex(0);
} else {
// 点击普通视频
setSelectedView('video');
setCurrentSketchIndex(index);
}
}}
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
selectedView={selectedView}
aspectRatio={aspectRatio}
isMobile={isMobile}
/>
</div>
)}
</div>
)}
</div>
{taskObject.currentStage !== 'script' && (
<div className={`h-[123px] ${!isDesktop ? '!w-full' : 'w-[calc((100vh-6rem-200px)/9*16)]'}`}>
<ThumbnailGrid
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject}
currentSketchIndex={currentSketchIndex}
onSketchSelect={(index) => {
setSelectedView('video');
setCurrentSketchIndex(index);
}}
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
selectedView={selectedView}
aspectRatio={aspectRatio}
isMobile={isMobile}
/>
</div>
)}
</div>
</div>
@ -621,7 +649,7 @@ Please process this video editing request.`;
{/* 智能对话按钮 */}
<div
className={`fixed right-[1rem] z-[49] ${isMobile ? 'bottom-[2rem]' : 'bottom-[10rem]'}`}
className={`fixed right-[1rem] z-[49] ${isMobile ? 'bottom-[9rem]' : 'bottom-[10rem]'}`}
>
{isMobile ? (
<div className="relative">

View File

@ -3,13 +3,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Carousel } from 'antd';
import type { CarouselRef } from 'antd/es/carousel';
import { Play, Pause, FeatherIcon, MessageCircleMore, Download, ArrowDownWideNarrow, RotateCcw, Navigation } from 'lucide-react';
import { Play, Pause, FeatherIcon, MessageCircleMore, Download, ArrowDownWideNarrow, RotateCcw, Navigation, X } from 'lucide-react';
import { TaskObject } from '@/api/DTO/movieEdit';
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
import ScriptLoading from './script-loading';
import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
import { Drawer } from 'antd';
import error_image from '@/public/assets/error.webp';
import { createRoot, Root } from 'react-dom/client';
interface H5MediaViewerProps {
/** 任务对象,包含各阶段数据 */
@ -49,6 +51,145 @@ interface H5MediaViewerProps {
onVideoEditDescriptionSubmit?: (editPoint: any, description: string) => void;
/** 项目ID */
projectId?: string;
/** 视频比例 */
aspectRatio?: string;
}
interface DownloadOptionsModalProps {
onDownloadCurrent: () => void;
onDownloadAll: () => void;
onClose: () => void;
currentVideoIndex: number;
totalVideos: number;
/** 当前视频是否生成失败 */
isCurrentVideoFailed: boolean;
/** 是否为最终视频阶段 */
isFinalStage?: boolean;
}
function DownloadOptionsModal(props: DownloadOptionsModalProps) {
const { onDownloadCurrent, onDownloadAll, onClose, currentVideoIndex, totalVideos, isCurrentVideoFailed, isFinalStage = false } = props;
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
return (
<div
ref={containerRef}
data-alt="download-options-overlay"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
>
<div
data-alt="download-options-modal"
className="relative w-11/12 max-w-sm rounded-2xl bg-white/5 border border-white/10 backdrop-blur-xl shadow-2xl p-6 text-white"
role="dialog"
aria-modal="true"
aria-labelledby="download-options-title"
onClick={(e) => e.stopPropagation()}
>
<button
data-alt="close-button"
className="absolute top-4 right-4 w-6 h-6 rounded-full bg-white/10 hover:bg-white/20 border border-white/10 flex items-center justify-center text-white transition-all active:scale-95"
onClick={onClose}
aria-label="Close"
>
<X size={14} />
</button>
<div data-alt="modal-header" className="flex flex-col items-center text-center gap-2">
<div data-alt="modal-icon" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-lg text-purple-400">
<Download />
</div>
<h3 id="download-options-title" data-alt="modal-title" className="text-base font-semibold">
Download Options
</h3>
</div>
<div data-alt="modal-body" className="mt-4">
<p data-alt="modal-description" className="text-sm text-white/80 text-center">
Choose your download preference
</p>
{!isCurrentVideoFailed && (
<div data-alt="stats-info" className="mt-3 rounded-lg bg-white/5 border border-white/10 p-3 text-center">
<div className="text-xs text-white/60">Current video</div>
<div className="text-sm font-medium">{currentVideoIndex + 1} / {totalVideos}</div>
</div>
)}
</div>
<div data-alt="modal-actions" className="mt-6 space-y-3">
{!isCurrentVideoFailed && (
<button
data-alt="download-current-button"
className="w-full px-4 py-2.5 rounded-lg bg-gradient-to-br from-purple-600/80 to-purple-700/80 hover:from-purple-500/80 hover:to-purple-600/80 text-white font-medium transition-all flex items-center justify-center gap-2"
onClick={() => {
onDownloadCurrent();
onClose();
}}
>
<Download size={16} />
{isFinalStage ? 'Download Final Video' : 'Download Current Video'}
</button>
)}
<button
data-alt="download-all-button"
className="w-full px-4 py-2.5 rounded-lg bg-gradient-to-br from-purple-500/60 to-purple-600/60 hover:from-purple-500/80 hover:to-purple-600/80 text-white font-medium transition-all flex items-center justify-center gap-2 border border-purple-400/30"
onClick={() => {
onDownloadAll();
onClose();
}}
>
<ArrowDownWideNarrow size={16} />
Download All Videos ({totalVideos})
</button>
</div>
</div>
</div>
);
}
/**
* Opens a download options modal with glass morphism style.
* @param {DownloadOptionsModalProps} options - download options and callbacks.
*/
function showDownloadOptionsModal(options: Omit<DownloadOptionsModalProps, 'onClose'>): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const mount = document.createElement('div');
mount.setAttribute('data-alt', 'download-options-modal-root');
document.body.appendChild(mount);
let root: Root | null = null;
try {
root = createRoot(mount);
} catch {
if (mount.parentNode) {
mount.parentNode.removeChild(mount);
}
return;
}
const close = () => {
try {
root?.unmount();
} finally {
if (mount.parentNode) {
mount.parentNode.removeChild(mount);
}
}
};
root.render(
<DownloadOptionsModal {...options} onClose={close} />
);
}
/**
@ -76,7 +217,8 @@ export function H5MediaViewer({
onSelectView,
enableVideoEdit,
onVideoEditDescriptionSubmit,
projectId
projectId,
aspectRatio
}: H5MediaViewerProps) {
const carouselRef = useRef<CarouselRef>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
@ -84,12 +226,46 @@ export function H5MediaViewer({
const [activeIndex, setActiveIndex] = useState<number>(0);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false);
const [isFinalBarOpen, setIsFinalBarOpen] = useState<boolean>(true);
const [isEdgeBrowser, setIsEdgeBrowser] = useState<boolean>(false);
/** 解析形如 "16:9" 的比例字符串 */
const parseAspect = (input?: string): { w: number; h: number } => {
const parts = (typeof input === 'string' ? input.split(':') : []);
const w = Number(parts[0]);
const h = Number(parts[1]);
return {
w: Number.isFinite(w) && w > 0 ? w : 16,
h: Number.isFinite(h) && h > 0 ? h : 9
};
};
/** 检测是否为 Edge 浏览器(客户端挂载后执行) */
useEffect(() => {
const isEdge = navigator.userAgent.indexOf('Edg') !== -1;
setIsEdgeBrowser(isEdge);
}, []);
/** 根据浏览器类型获取最大高度值 */
const maxHeight = useMemo(() => {
return isEdgeBrowser ? 'calc(100vh - 10.5rem)' : 'calc(100vh - 15rem)';
}, [isEdgeBrowser]);
/** 视频轮播容器高度:按 aspectRatio 计算(基于视口宽度) */
const videoWrapperHeight = useMemo(() => {
const { w, h } = parseAspect(aspectRatio);
return `min(calc(100vw * ${h} / ${w}), ${maxHeight})`;
}, [aspectRatio, maxHeight]);
/** 图片轮播容器高度:默认 16:9 */
const imageWrapperHeight = useMemo(() => {
const { w, h } = parseAspect(aspectRatio);
return `min(calc(100vw * ${h} / ${w}), ${maxHeight})`;
}, [aspectRatio, maxHeight]);
// 计算当前阶段类型
const stage = (selectedView === 'final' && taskObject.final?.url)
? 'final_video'
: (!['script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
: (!['init', 'script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
// 生成各阶段对应的 slides 数据
const videoUrls = useMemo(() => {
@ -104,16 +280,19 @@ export function H5MediaViewer({
return [];
}, [stage, taskObject.final?.url, taskObject.videos?.data]);
const imageUrls = useMemo(() => {
const imageItems = useMemo(() => {
if (stage === 'scene' || stage === 'character') {
const roles = (taskObject.roles?.data ?? []) as Array<any>;
const scenes = (taskObject.scenes?.data ?? []) as Array<any>;
console.log('h5-media-viewer:stage', stage);
console.log('h5-media-viewer:roles', roles);
console.log('h5-media-viewer:scenes', scenes);
return [...roles, ...scenes].map(item => item?.url).filter(Boolean) as string[];
return [...roles, ...scenes].map(item => ({
url: item?.url as string | undefined,
status: item?.status as number | undefined,
}));
}
return [];
return [] as Array<{ url?: string; status?: number }>;
}, [stage, taskObject.roles?.data, taskObject.scenes?.data]);
// 占位,避免未使用警告
@ -165,7 +344,7 @@ export function H5MediaViewer({
// 渲染视频 slide
const renderVideoSlides = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg" style={{
height: 'calc(100vh - 20rem)',
height: videoWrapperHeight,
}}>
<Carousel
ref={carouselRef}
@ -189,17 +368,20 @@ export function H5MediaViewer({
<>
<video
ref={(el) => (videoRefs.current[idx] = el)}
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black cursor-pointer"
style={{
maxHeight: 'calc(100vh - 20rem)',
maxHeight: '100%',
}}
src={url}
preload="metadata"
playsInline
controls={isPlaying && activeIndex === idx}
loop
poster={getFirstFrame(url)}
crossOrigin="anonymous"
onClick={(e) => {
e.stopPropagation();
if (activeIndex === idx) togglePlay();
}}
onLoadedMetadata={() => {}}
onPlay={() => {
if (activeIndex === idx) setIsPlaying(true);
@ -225,15 +407,15 @@ export function H5MediaViewer({
</>
) : (
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status" style={{
height: 'calc(100vh - 20rem)',
height: videoWrapperHeight,
}}>
{status === 0 && (
<span className="text-blue-500 text-base">Generating...</span>
)}
{status === 2 && (
<div className="flex flex-col items-center justify-center gap-3">
<div className="text-4xl"></div>
<span className="text-red-500 text-base">Generate failed</span>
<div className="flex flex-col items-center justify-center gap-1">
<img src={error_image.src} alt="error" className="w-12 h-12" />
<span className="text-[#fa485f] text-base">Generate failed</span>
</div>
)}
{status !== 0 && status !== 2 && (
@ -251,11 +433,11 @@ export function H5MediaViewer({
// 渲染图片 slide
const renderImageSlides = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg" style={{
height: 'calc(100vh - 20rem)',
height: imageWrapperHeight,
}}>
<Carousel
ref={carouselRef}
key={`h5-carousel-image-${stage}-${imageUrls.length}`}
key={`h5-carousel-image-${stage}-${imageItems.length}`}
arrows
dots={false}
infinite={false}
@ -264,13 +446,34 @@ export function H5MediaViewer({
slidesToShow={1}
adaptiveHeight
>
{imageUrls.map((url, idx) => (
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full flex justify-center">
<img src={url} alt="scene" className="w-full h-full object-contain" style={{
maxHeight: 'calc(100vh - 20rem)',
}} />
</div>
))}
{imageItems.map((item, idx) => {
const status = item?.status;
const url = item?.url;
const showImage = status === 1 && typeof url === 'string' && url.length > 0;
return (
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full flex justify-center">
{showImage ? (
<img src={url} alt="scene" className="w-full h-full object-contain" style={{
maxHeight: '100%',
}} />
) : (
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="image-status" style={{
height: imageWrapperHeight,
}}>
{status === 0 && (
<span className="text-blue-500 text-base">Generating...</span>
)}
{status === 2 && (
<div className="flex flex-col items-center justify-center gap-1">
<img src={error_image.src} alt="error" className="w-12 h-12" />
<span className="text-[#fa485f] text-base">Generate failed</span>
</div>
)}
</div>
)}
</div>
);
})}
</Carousel>
</div>
);
@ -286,7 +489,10 @@ export function H5MediaViewer({
}
};
return (
<div data-alt="script-content" className="w-full h-full">
<div data-alt="script-content" className="w-full overflow-auto"
style={{
height: 'calc(100vh - 10rem)'
}}>
{scriptData ? (
<>
<ScriptRenderer
@ -301,7 +507,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)}
>
@ -371,33 +577,16 @@ export function H5MediaViewer({
// 其他阶段:使用 Carousel
return (
<div ref={rootRef} data-alt="h5-media-viewer" className={`w-[100vw] relative ${stage === 'final_video' ? '' : 'px-4'}`}>
{/* 左侧最终视频缩略图栏H5 视频暂停时展示 */}
{taskObject?.final?.url && !isPlaying && (
<div className={`absolute left-0 top-4 z-[10] ${stage === 'final_video' ? 'left-0' : 'left-4'}`} data-alt="final-sidebar-h5">
<div className="flex items-start">
{isFinalBarOpen && (
<div className="w-[3rem] max-h-[50vh] overflow-y-auto rounded-md backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-1" data-alt="final-thumbnails">
<button
type="button"
onClick={() => onSelectView && onSelectView('final')}
className={`block w-full overflow-hidden rounded border ${selectedView === 'final' ? 'border-blue-500' : 'border-white/20'}`}
data-alt="final-thumb-item"
aria-label="Select final video"
>
<img src={getFirstFrame(taskObject.final.url)} alt="final" className="w-full h-auto object-contain" />
</button>
</div>
)}
</div>
</div>
)}
<div ref={rootRef} data-alt="h5-media-viewer" className={`relative`}
style={{
width: 'calc(100vw - 2rem)'
}}>
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
{/* 全局固定操作区(右下角)视频暂停时展示 */}
{(stage === 'video' || stage === 'final_video') && !isPlaying && (
<div data-alt="global-video-actions" className="absolute bottom-0 right-4 z-[60] flex flex-col items-center gap-2">
{(stage === 'scene' || stage === 'character') && imageItems.length > 0 && renderImageSlides()}
{/* 全局固定操作区 */}
{(stage === 'video' || stage === 'final_video') && (
<div data-alt="global-video-actions" className="absolute top-0 right-4 z-[60] flex flex-col items-center gap-2">
{stage === 'video' && (
<>
<GlassIconButton
@ -415,35 +604,40 @@ export function H5MediaViewer({
}}
/>
<GlassIconButton
data-alt="download-all-button"
data-alt="download-button"
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
icon={ArrowDownWideNarrow}
icon={Download}
size="sm"
aria-label="download-all"
onClick={async () => {
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
await downloadAllVideos(all);
aria-label="download"
onClick={() => {
const current = (taskObject.videos?.data ?? [])[activeIndex] as any;
const status = current?.video_status;
const hasUrl = current && Array.isArray(current.urls) && current.urls.length > 0;
const hasFinalVideo = taskObject.final?.url;
const baseVideoCount = (taskObject.videos?.data ?? []).length;
const totalVideos = hasFinalVideo ? baseVideoCount + 1 : baseVideoCount;
const isCurrentVideoFailed = status === 2;
showDownloadOptionsModal({
currentVideoIndex: hasFinalVideo ? activeIndex + 1 : activeIndex,
totalVideos,
isCurrentVideoFailed,
onDownloadCurrent: async () => {
if (hasUrl) {
await downloadVideo(current.urls[0]);
}
},
onDownloadAll: async () => {
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
if (hasFinalVideo) {
all.push(taskObject.final.url);
}
console.log('h5-media-viewer:all', all);
await downloadAllVideos(all);
},
});
}}
/>
{(() => {
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
return status === 1 ? (
<GlassIconButton
data-alt="download-current-button"
className="w-8 h-8 bg-gradient-to-br from-purple-600/80 to-purple-700/80 backdrop-blur-xl rounded-full flex items-center justify-center transition-all"
icon={Download}
size="sm"
aria-label="download-current"
onClick={async () => {
const current = (taskObject.videos?.data ?? [])[activeIndex] as any;
const hasUrl = current && Array.isArray(current.urls) && current.urls.length > 0;
if (hasUrl) {
await downloadVideo(current.urls[0]);
}
}}
/>
) : null;
})()}
{(() => {
const status = (taskObject.videos?.data ?? [])[activeIndex]?.video_status;
return status === 2 ? (
@ -463,39 +657,48 @@ export function H5MediaViewer({
</>
)}
{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);
}}
/>
</>
<GlassIconButton
data-alt="download-button"
className="w-8 h-8 bg-gradient-to-br from-purple-500/80 to-purple-600/80 backdrop-blur-xl rounded-full flex items-center justify-center hover:from-purple-400/80 hover:to-purple-500/80 transition-all"
icon={Download}
size="sm"
aria-label="download"
onClick={() => {
const totalVideos = (taskObject.videos?.data ?? []).length + 1;
const finalUrl = videoUrls[0];
showDownloadOptionsModal({
currentVideoIndex: 0,
totalVideos,
isCurrentVideoFailed: false,
isFinalStage: true,
onDownloadCurrent: async () => {
if (finalUrl) {
await downloadVideo(finalUrl);
}
},
onDownloadAll: async () => {
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
if (finalUrl) {
all.push(finalUrl);
}
await downloadAllVideos(all);
},
});
}}
/>
)}
</div>
)}
<style jsx global>{`
[data-alt='carousel-wrapper'] .slick-slide { display: flex !important;justify-content: center; }
.slick-slider { height: 100% !important;display: flex !important; }
.slick-slider { height: 100% !important; }
.ant-carousel { height: 100% !important; }
.slick-list { width: 100%;height: 100% !important;max-height: calc(100vh - 20rem); }
.slick-list { width: 100%;height: 100% !important;max-height: 100%; }
.slick-track { display: flex !important; align-items: center;height: 100% !important; }
[data-alt='carousel-wrapper'] .slick-arrow { z-index: 70 !important; }
[data-alt='carousel-wrapper'] .slick-prev { left: 8px; }
[data-alt='carousel-wrapper'] .slick-next { right: 8px; }
`}</style>
</div>
);

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

@ -15,6 +15,8 @@ import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
import { EditPoint as EditPointType } from './video-edit/types';
import { isVideoModificationEnabled } from '@/lib/server-config';
import error_image from '@/public/assets/error.webp';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
interface MediaViewerProps {
taskObject: TaskObject;
@ -38,6 +40,8 @@ interface MediaViewerProps {
enableVideoEdit?: boolean;
onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
projectId?: string;
aspectRatio: string;
placeholderWidth: string;
}
export const MediaViewer = React.memo(function MediaViewer({
@ -61,7 +65,9 @@ export const MediaViewer = React.memo(function MediaViewer({
onRetryVideo,
enableVideoEdit = true,
onVideoEditDescriptionSubmit,
projectId
projectId,
aspectRatio,
placeholderWidth
}: MediaViewerProps) {
const mainVideoRef = useRef<HTMLVideoElement>(null);
const finalVideoRef = useRef<HTMLVideoElement>(null);
@ -212,6 +218,9 @@ export const MediaViewer = React.memo(function MediaViewer({
<video
ref={finalVideoRef}
className="w-full h-full object-contain rounded-lg"
style={{
aspectRatio: aspectRatio
}}
src={taskObject.final.url}
autoPlay={isFinalVideoPlaying}
loop
@ -389,6 +398,37 @@ export const MediaViewer = React.memo(function MediaViewer({
};
}, []);
// 加载中
const renderLoading = () => {
return (
<div data-alt="generating-overlay" className="absolute inset-0 bg-black/40 flex items-center justify-center">
<div data-alt="generating-content" className="relative flex flex-col items-center gap-4">
{/* 渐变进度环 */}
<div className="relative w-24 h-24">
{/* 外层旋转渐变边框 */}
<div
className="absolute inset-0 rounded-full opacity-80 animate-spin"
style={{
background: 'conic-gradient(from 0deg, transparent 0%, #3b82f6 25%, #60a5fa 50%, #93c5fd 75%, transparent 100%)',
animation: 'spin 2s linear infinite'
}}
/>
{/* 内层遮罩(形成边框效果) */}
<div className="absolute inset-[2px] rounded-full bg-black/60 backdrop-blur-sm" />
</div>
{/* 文案 */}
<div className="text-white text-center">
<div className="text-sm font-medium" role="status" aria-live="polite" aria-busy="true">
Generating...
</div>
<div className="text-xs text-white/60 mt-1">Processing your request</div>
</div>
</div>
</div>
)
}
// 渲染底部通栏控制条与图2一致
const renderBottomControls = (
isFinal: boolean,
@ -396,47 +436,14 @@ export const MediaViewer = React.memo(function MediaViewer({
playing: boolean
) => (
<div
className="absolute left-0 right-0 bottom-2 z-[21] px-6"
className="absolute left-0 right-0 bottom-4 z-[21] px-2"
data-alt={isFinal ? 'final-controls' : 'video-controls'}
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-between">
{/* 播放/暂停 */}
<GlassIconButton icon={playing ? Pause : Play} onClick={onPlayToggle} size="sm" />
{/* 静音,仅图标 */}
<GlassIconButton icon={isMuted ? VolumeX : Volume2} onClick={toggleMute} size="sm" />
{/* 进度条 */}
<div className="flex-1 flex items-center">
<input
type="range"
min="0"
max="100"
step="0.1"
value={progressPercent}
onChange={(e) => seekTo(parseFloat(e.target.value))}
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:border-none"
style={{
background: `linear-gradient(to right, white 0%, white ${progressPercent}%, rgba(255,255,255,0.2) ${progressPercent}%, rgba(255,255,255,0.2) 100%)`
}}
/>
</div>
{/* 剩余时间 */}
<div className="text-white/80 text-sm w-14 text-right select-none" data-alt="time-remaining">
{formatRemaining(duration, currentTime)}
</div>
{/* 画中画 */}
<GlassIconButton icon={PictureInPicture2} onClick={requestPip} size="sm" />
{/* 全屏 */}
<GlassIconButton icon={isFullscreen ? Minimize : Maximize} onClick={toggleFullscreen} size="sm" />
<GlassIconButton className="group-hover:block hidden animate-in duration-300" icon={isFullscreen ? Minimize : Maximize} onClick={toggleFullscreen} size="sm" />
</div>
</div>
);
@ -456,11 +463,14 @@ export const MediaViewer = React.memo(function MediaViewer({
<motion.div
className="absolute inset-0 overflow-hidden"
initial={{ filter: "blur(0px)", scale: 1, opacity: 1 }}
animate={{ filter: "blur(20px)", scale: 1.1, opacity: 0.5 }}
animate={{ filter: "blur(20px)", scale: 1, opacity: 0.5 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
<video
className="w-full h-full rounded-lg object-contain object-center"
style={{
aspectRatio: aspectRatio
}}
src={taskObject.final.url}
loop
playsInline
@ -484,8 +494,9 @@ export const MediaViewer = React.memo(function MediaViewer({
</motion.div>
{/* 编辑和剪辑按钮 */}
<div className="absolute top-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: toosBtnRight
<div className="absolute top-2 right-2 z-[21] flex items-center gap-2 transition-right duration-100"
style={{
right: aspectRatio === '16:9' ? toosBtnRight : ''
}}>
<Tooltip placement="top" title='Edit'>
<GlassIconButton
@ -535,31 +546,27 @@ export const MediaViewer = React.memo(function MediaViewer({
className="relative w-full h-full rounded-lg group"
key={`render-video-${urls}`}
ref={videoContentRef}
style={{
width: taskObject.videos.data[currentSketchIndex].video_status !== 1 ? placeholderWidth : '100%'
}}
>
{/* 背景模糊的图片 */}
{taskObject.videos.data[currentSketchIndex].video_status !== 1 && (
<div className="absolute inset-0 overflow-hidden z-20">
{/* 生成中 */}
{taskObject.videos.data[currentSketchIndex].video_status === 0 && (
<div className="absolute inset-0 bg-black/10 flex items-center justify-center">
<div className="text-blue-500 text-2xl font-bold flex items-center gap-2">
<Loader2 className="w-10 h-10 animate-spin" />
<span>Generating...</span>
</div>
</div>
renderLoading()
)}
{/* 生成失败 */}
{taskObject.videos.data[currentSketchIndex].video_status === 2 && (
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center">
<div
className="text-[#813b9dcc] text-2xl font-bold flex items-center gap-2"
>
<RotateCcw className="w-10 h-10 cursor-pointer" onClick={() => {
const video = taskObject.videos.data[currentSketchIndex];
if (onRetryVideo && video?.video_id) {
onRetryVideo(video.video_id);
}
}} />
<div className="absolute inset-0 bg-[#fcb0ba1a] flex flex-col items-center justify-center">
<img src={error_image.src} alt="error" className="w-12 h-12" />
{/* 文案 */}
<div className="text-white text-center">
<div className="text-sm font-medium" role="status" aria-live="polite" aria-busy="true">
Failed
</div>
<div className="text-xs text-white/60 mt-1">Violation of security policy. Please modify your prompt and regenerate.</div>
</div>
</div>
)}
@ -580,6 +587,9 @@ export const MediaViewer = React.memo(function MediaViewer({
ref={mainVideoRef}
key={taskObject.videos.data[currentSketchIndex].urls[0]}
className="w-full h-full rounded-lg object-contain object-center relative z-10"
style={{
aspectRatio: aspectRatio
}}
src={taskObject.videos.data[currentSketchIndex].urls[0]}
poster={getFirstFrame(taskObject.videos.data[currentSketchIndex].urls[0])}
preload="none"
@ -611,23 +621,55 @@ export const MediaViewer = React.memo(function MediaViewer({
)}
</motion.div>
{/* 跳转剪辑按钮 */}
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: toosBtnRight
</>
)}
{/* 操作按钮组 */}
{
taskObject.videos.data[currentSketchIndex].video_status !== 0 && (
<div className="absolute top-2 right-2 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: aspectRatio === '16:9' ? toosBtnRight : ''
}}>
{/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
{enableVideoEdit && showVideoModification && (
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
<GlassIconButton
icon={PenTool}
size='sm'
onClick={() => {
console.log('🖊️ 钢笔图标被点击,切换编辑模式:', !isVideoEditMode);
setIsVideoEditMode(!isVideoEditMode);
}}
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
/>
</Tooltip>
{taskObject.videos.data[currentSketchIndex].video_status === 1 ? (
<>
{/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
{enableVideoEdit && showVideoModification && (
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
<GlassIconButton
icon={PenTool}
size='sm'
onClick={() => {
console.log('🖊️ 钢笔图标被点击,切换编辑模式:', !isVideoEditMode);
setIsVideoEditMode(!isVideoEditMode);
}}
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
/>
</Tooltip>
)}
<Tooltip placement="top" title="Download video">
<GlassIconButton icon={Download} loading={isLoadingDownloadBtn} size='sm' onClick={async () => {
const currentVideo = taskObject.videos.data[currentSketchIndex];
if (currentVideo && currentVideo.urls && currentVideo.urls.length > 0) {
setIsLoadingDownloadBtn(true);
await downloadVideo(currentVideo.urls[0]);
setIsLoadingDownloadBtn(false);
}
}} />
</Tooltip>
</>
) : (
<>
<Tooltip placement="top" title="Regenerate video">
<GlassIconButton icon={RotateCcw} size='sm' onClick={() => {
const video = taskObject.videos.data[currentSketchIndex];
if (onRetryVideo && video?.video_id) {
onRetryVideo(video.video_id);
}
}} />
</Tooltip>
</>
)}
{/* 添加到chat去编辑 按钮 */}
<Tooltip placement="top" title="Edit video with chat">
@ -647,17 +689,6 @@ export const MediaViewer = React.memo(function MediaViewer({
setIsLoadingDownloadAllVideosBtn(false);
}} />
</Tooltip>
{/* 下载按钮 */}
<Tooltip placement="top" title="Download video">
<GlassIconButton icon={Download} loading={isLoadingDownloadBtn} size='sm' onClick={async () => {
const currentVideo = taskObject.videos.data[currentSketchIndex];
if (currentVideo && currentVideo.urls && currentVideo.urls.length > 0) {
setIsLoadingDownloadBtn(true);
await downloadVideo(currentVideo.urls[0]);
setIsLoadingDownloadBtn(false);
}
}} />
</Tooltip>
{/* 跳转剪辑按钮 */}
{showGotoCutButton && (
<Tooltip placement="top" title='Go to AI-powered editing platform'>
@ -665,8 +696,9 @@ export const MediaViewer = React.memo(function MediaViewer({
</Tooltip>
)}
</div>
</>
)}
)
}
{/* 操作按钮组 */}
{/* <AnimatePresence>
@ -702,21 +734,19 @@ export const MediaViewer = React.memo(function MediaViewer({
return (
<div
className="relative w-full h-full rounded-lg group"
className="relative w-full h-full rounded-lg group overflow-hidden"
key={`render-sketch-${currentSketch.url}`}
style={{
width: currentSketch.status === 1 ? '100%' : placeholderWidth
}}
>
{/* 状态 */}
{currentSketch.status === 0 && (
<div className="absolute inset-0 bg-black/10 flex items-center justify-center">
<div className="text-blue-500 text-2xl font-bold flex items-center gap-2">
<Loader2 className="w-10 h-10 animate-spin" />
<span>Generating...</span>
</div>
</div>
renderLoading()
)}
{currentSketch.status === 2 && (
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center">
<div className="text-2xl mb-4"></div>
<div className="absolute inset-0 bg-[#fcb0ba1a] flex items-center justify-center">
<img src={error_image.src} alt="error" className="w-12 h-12" />
</div>
)}
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
@ -785,7 +815,10 @@ export const MediaViewer = React.memo(function MediaViewer({
// 渲染剧本
const renderScriptContent = () => {
return (
<div className="relative w-full h-full rounded-lg overflow-hidden p-2">
<div className="relative h-full rounded-lg overflow-hidden p-2"
style={{
width: 'calc(100vw - 8rem)'
}}>
{scriptData ? (
<ScriptRenderer
data={scriptData}

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'
}
}
@ -220,23 +220,6 @@ export function TaskInfo({
) : currentLoadingText}
</div>
{/* 主题 彩色标签tags */}
<div className="flex items-center justify-center gap-2">
{taskObject?.tags?.map((tag: string) => (
<div
key={tag}
data-alt="tag-item"
className="flex items-center gap-2 text-sm text-[#ececec] rounded-full px-3 py-1.5 bg-white/10 backdrop-blur-sm shadow-[0_4px_12px_rgba(0,0,0,0.2)]"
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: tagColors[tag] }}
/>
{tag}
</div>
))}
</div>
<ScriptModal
isOpen={isScriptModalOpen}
onClose={() => {

View File

@ -1,13 +1,11 @@
'use client';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import { Skeleton } from '@/components/ui/skeleton';
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
import { Loader2, X, SquareUserRound, MapPinHouse, Clapperboard, Video, RotateCcw, CircleAlert } from 'lucide-react';
import { CircleAlert, Film } from 'lucide-react';
import { TaskObject } from '@/api/DTO/movieEdit';
import { getFirstFrame } from '@/utils/tools';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
import error_image from '@/public/assets/error.webp';
interface ThumbnailGridProps {
isDisabledFocus: boolean;
@ -93,37 +91,43 @@ export function ThumbnailGrid({
return [];
}, [taskObject.currentStage, taskObject.videos.data, taskObject.roles.data, taskObject.scenes.data]);
// 使用 useRef 存储前一次的数据,避免触发重渲染
const prevDataRef = useRef<any[]>([]);
/** Store previous status snapshot for change detection */
const prevStatusRef = useRef<Array<number | undefined>>([]);
useEffect(() => {
const currentData = getCurrentData();
if (currentData && currentData.length > 0) {
const currentDataStr = JSON.stringify(currentData);
const prevDataStr = JSON.stringify(prevDataRef.current);
if (!currentData || currentData.length === 0) return;
// 只有当数据真正发生变化时才进行处理
if (currentDataStr !== prevDataStr) {
// 找到最新更新的数据项的索引
const changedIndex = currentData.findIndex((item, index) => {
// 检查是否是新增的数据
if (index >= prevDataRef.current.length) return true;
// 检查数据是否发生变化(包括状态变化)
return JSON.stringify(item) !== JSON.stringify(prevDataRef.current[index]);
});
// Extract status fields only to detect meaningful changes
const currentStatuses: Array<number | undefined> = currentData.map((item: any) => (
taskObject.currentStage === 'video' ? item?.video_status : item?.status
));
console.log('changedIndex_thumbnail-grid', changedIndex, 'currentData:', currentData, 'prevData:', prevDataRef.current);
const prevStatuses = prevStatusRef.current;
// 如果找到变化的项,自动选择该项
if (changedIndex !== -1) {
onSketchSelect(changedIndex);
}
// 更新前一次的数据快照
prevDataRef.current = JSON.parse(JSON.stringify(currentData));
// Find first changed or newly added index
let changedIndex = -1;
for (let i = 0; i < currentStatuses.length; i += 1) {
if (i >= prevStatuses.length) {
changedIndex = i; // new item
break;
}
if (currentStatuses[i] !== prevStatuses[i]) {
changedIndex = i; // status changed
break;
}
}
}, [taskObject, getCurrentData, onSketchSelect]);
console.log('changedIndex_thumbnail-grid', changedIndex);
if (changedIndex !== -1) {
onSketchSelect(changedIndex);
}
// Update snapshot
prevStatusRef.current = currentStatuses.slice();
}, [taskObject, getCurrentData]);
// 处理键盘左右键事件
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@ -187,31 +191,83 @@ export function ThumbnailGrid({
// 渲染视频阶段的缩略图
const renderVideoThumbnails = (disabled: boolean = false) => (
taskObject.videos.data.map((video, index) => {
const urls: string = video.urls ? video.urls.join(',') : '';
return (
<>
{/* 最终视频缩略图(排在第一位) */}
{taskObject?.final?.url && (
<div
key={`video-${urls}-${index}`}
className={`relative aspect-auto rounded-lg overflow-hidden
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
key="video-final"
data-alt="final-thumbnail"
className={`relative w-8 h-8 shrink-0 hover:brightness-50 rounded-full duration-300 transition-all ring-offset-surface-tertiary ring-white size-8
${selectedView === 'final' ? '!w-16 !h-10 ring-2 ring-white ring-offset-2 ring-offset-neutral-900 shadow-[0_0_0_3px_rgba(255,255,255,0.6)] z-10' : 'ring-1 ring-white/20 hover:ring-2 hover:ring-white/60'}
`}
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
onClick={() => !isDragging && !disabled && onSketchSelect(-1)}
>
<div className="rounded-full overflow-hidden w-full h-full">
{/* 视频层 */}
<div className="relative w-full h-full transform hover:scale-105 transition-transform duration-300">
<div
className="w-full h-full relative"
onMouseEnter={() => handleMouseEnter(-1)}
onMouseLeave={() => handleMouseLeave(-1)}
>
<img
className="w-full h-full object-cover"
src={getFirstFrame(taskObject.final.url)}
draggable="false"
alt="final video thumbnail"
/>
{hoveredIndex === -1 && (
<video
className="absolute inset-0 w-full h-full object-cover"
src={taskObject.final.url}
autoPlay
muted
playsInline
loop
poster={getFirstFrame(taskObject.final.url)}
preload="none"
/>
)}
</div>
</div>
</div>
{/* 最终视频徽标 */}
<div className="absolute -top-1 -left-1 z-20">
<div className="w-4 h-4 rounded-full bg-amber-500/60 flex items-center justify-center">
<Film className="w-2.5 h-2.5 text-white" />
</div>
</div>
</div>
)}
{/* 普通视频缩略图 */}
{taskObject.videos.data.map((video, index) => {
const urls: string = video.urls ? video.urls.join(',') : '';
return (
<div
key={`video-${urls}-${index}`}
data-alt={`video-thumbnail-${index+1}`}
className={`relative overflow-hidden w-8 h-8 shrink-0 rounded-full duration-300 transition-all ring-offset-surface-tertiary ring-white size-8
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? '!w-16 !h-10 ring-2 ring-white ring-offset-2 ring-offset-neutral-900 shadow-[0_0_0_3px_rgba(255,255,255,0.6)] z-10' : 'ring-1 ring-white/20 hover:ring-2 hover:ring-white/60'}
`}
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
>
{/* 视频层 */}
<div className="relative w-full h-full transform hover:scale-105 transition-transform duration-500">
<div className="relative w-full h-full transform hover:scale-105 transition-transform duration-300">
{taskObject.videos.data[index].video_status === 0 && (
<div className="absolute inset-0 bg-black/10 flex items-center justify-center z-20">
<div className="text-blue-500 text-xl font-bold flex items-center gap-2">
<Loader2 className="w-10 h-10 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="relative size-12">
<span className="absolute inset-0 rounded-full border-2 border-white/40 animate-ping" />
<span className="absolute inset-2 rounded-full border-2 border-white/20 animate-ping [animation-delay:150ms]" />
<span className="absolute inset-0 rounded-full bg-white/5" />
</div>
</div>
)}
{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-2xl mb-4"></div>
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center z-20">
<img src={error_image.src} alt="error" className="w-6 h-6" />
</div>
)}
@ -229,14 +285,14 @@ export function ThumbnailGrid({
onMouseLeave={() => handleMouseLeave(index)}
>
<img
className="w-full h-full object-contain"
className="w-full h-full object-cover"
src={getFirstFrame(taskObject.videos.data[index].urls[0])}
draggable="false"
alt="video thumbnail"
/>
{hoveredIndex === index && (
<video
className="absolute inset-0 w-full h-full object-contain"
className="absolute inset-0 w-full h-full object-cover"
src={taskObject.videos.data[index].urls[0]}
autoPlay
muted
@ -248,30 +304,14 @@ export function ThumbnailGrid({
)}
</div>
) : (
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
</div>
<div className="w-full h-full" />
)}
</div>
{!isMobile && (
<div className='absolute bottom-0 left-0 right-0 p-2'>
<div className="inline-flex items-center px-2 py-1 rounded-full bg-green-500/20 backdrop-blur-sm">
<Video className="w-3 h-3 text-green-400 mr-1" />
<span className="text-xs text-green-400">{index + 1}</span>
</div>
</div>
)}
{/* <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
<span className="text-xs text-white/90">Scene {index + 1}</span>
</div> */}
</div>
);
})
})}
</>
);
// 渲染分镜草图阶段的缩略图
@ -282,63 +322,40 @@ export function ThumbnailGrid({
return (
<div
key={sketch.uniqueId || `sketch-${sketch.url}-${index}`}
className={`relative aspect-auto rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
data-alt={`sketch-thumbnail-${index+1}`}
className={`relative overflow-hidden w-8 h-8 shrink-0 hover:brightness-50 rounded-full duration-300 transition-all ring-offset-surface-tertiary ring-white size-8
${currentSketchIndex === index ? '!w-16 !h-10 ring-2 ring-white ring-offset-2 ring-offset-neutral-900 shadow-[0_0_0_3px_rgba(255,255,255,0.6)] z-10' : 'ring-1 ring-white/20 hover:ring-2 hover:ring-white/60'}
`}
onClick={() => !isDragging && onSketchSelect(index)}
>
{/* 状态 */}
{sketch.status === 0 && (
<div className="absolute inset-0 bg-black/10 flex items-center justify-center">
<div className="text-blue-500 text-xl font-bold flex items-center gap-2">
<Loader2 className="w-10 h-10 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative size-12">
<span className="absolute inset-0 rounded-full border-2 border-white/40 animate-ping" />
<span className="absolute inset-2 rounded-full border-2 border-white/20 animate-ping [animation-delay:150ms]" />
<span className="absolute inset-0 rounded-full bg-white/5" />
</div>
</div>
)}
{sketch.status === 2 && (
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center z-20">
<div className="text-2xl mb-4"></div>
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center z-20">
<img src={error_image.src} alt="error" className="w-6 h-6" />
</div>
)}
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
{(sketch.status === 1) && (
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<div className="w-full h-full transform hover:scale-105 transition-transform duration-300">
<img
className="w-full h-full object-contain select-none"
className="w-full h-full object-cover select-none"
src={sketch.url}
draggable="false"
alt={sketch.type ? String(sketch.type) : 'sketch'}
/>
</div>
)}
{!isMobile && (
<div className='absolute bottom-0 left-0 right-0 p-2'>
{/* 角色类型 */}
{sketch.type === 'role' && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
<SquareUserRound className="w-3 h-3 text-purple-400 mr-1" />
<span className="text-xs text-purple-400">Role</span>
</div>
)}
{/* 场景类型 */}
{sketch.type === 'scene' && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
<MapPinHouse className="w-3 h-3 text-purple-400 mr-1" />
<span className="text-xs text-purple-400">Scene</span>
</div>
)}
{/* 分镜类型 */}
{(!sketch.type || sketch.type === 'shot_sketch') && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-cyan-500/20 backdrop-blur-sm">
<Clapperboard className="w-3 h-3 text-cyan-400 mr-1" />
<span className="text-xs text-cyan-400">{index + 1}</span>
</div>
)}
</div>
)}
{/* 极简圆形预览,不显示类型徽标 */}
{/* <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
@ -354,7 +371,8 @@ export function ThumbnailGrid({
<div
ref={thumbnailsRef}
tabIndex={0}
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 auto-cols-[${cols}] ${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '' : '!auto-cols-min'}`}
data-alt="thumbnail-strip"
className={`w-full h-full grid grid-flow-col items-center gap-2 px-3 overflow-x-auto hide-scrollbar cursor-grab active:cursor-grabbing focus:outline-none select-none rounded-full ring-1 ring-white/10 shadow-inner backdrop-blur-md bg-white/10 auto-cols-[${cols}] !auto-cols-min border border-white/20`}
autoFocus
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}

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();
@ -214,12 +212,12 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
window.open(`${cutUrl}/ai-editor/${episodeId}?token=${token}&user_id=${useid}`, '_target');
}, [episodeId, cutUrl, token, useid]);
useEffect(() => {
// 主动触发剪辑
if (canGoToCut && taskObject.currentStage === 'video' && !isShowError) {
generateEditPlan(retryCount - 1);
}
}, [canGoToCut, taskObject.currentStage, isShowError, generateEditPlan, retryCount]);
// useEffect(() => {
// // 主动触发剪辑
// if (canGoToCut && taskObject.currentStage === 'video' && !isShowError) {
// generateEditPlan(retryCount - 1);
// }
// }, [canGoToCut, taskObject.currentStage, isShowError, generateEditPlan, retryCount]);
useEffect(() => {
// 加载剪辑计划结束 并且 失败了 重试
@ -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,41 +433,43 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
} else {
setIsShowError(true);
setIsAnalyzing(false);
emitToastHide();
// emitToastHide();
}
}
}
}
// 粗剪
if (task.task_name === 'generate_final_simple_video') {
if (task.task_result && task.task_result.video) {
taskCurrent.currentStage = 'final_video';
taskCurrent.final.url = task.task_result.video;
taskCurrent.final.note = 'simple';
taskCurrent.status = 'COMPLETED';
}
}
// generate_export_video
if (task.task_name === 'generate_export_video') {
// 合成视频
if (task.task_name === 'combiner_videos') {
if (task.task_status === 'COMPLETED') {
taskCurrent.currentStage = 'final_video';
taskCurrent.final.url = task.task_result.video;
taskCurrent.final.note = 'export';
taskCurrent.final.url = task.task_result.video_url;
taskCurrent.final.note = 'combiner';
taskCurrent.status = 'COMPLETED';
// 停止轮询
setNeedStreamData(false);
}
if (task.task_status === 'FAILED' || task.task_status === 'ERROR') {
console.log('----------视频导出失败');
taskCurrent.status = 'FAILED';
// 触发导出失败回调
if (onExportFailed) {
onExportFailed();
}
// 停止轮询
setNeedStreamData(false);
}
}
// 水印视频
if (task.task_name === 'watermark_videos') {
if (task.task_status === 'COMPLETED') {
taskCurrent.currentStage = 'final_video';
taskCurrent.final.url = task.task_result.video_url;
taskCurrent.final.note = 'watermark';
taskCurrent.status = 'COMPLETED';
// 停止轮询
setNeedStreamData(false);
}
if (task.task_status === 'FAILED' || task.task_status === 'ERROR') {
// 停止轮询
setNeedStreamData(false);
}

BIN
public/assets/error.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,131 +1,137 @@
import { notification } from 'antd';
import { useRouter } from 'next/router';
"use client";
type NotificationType = 'success' | 'info' | 'warning' | 'error';
import React, { useEffect, useRef } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { BadgeCent, X } from 'lucide-react';
const darkGlassStyle = {
background: 'rgba(30, 32, 40, 0.95)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
padding: '12px 16px',
};
const messageStyle = {
fontSize: '13px',
fontWeight: 500,
color: '#ffffff',
marginBottom: '6px',
display: 'flex',
alignItems: 'center',
gap: '6px',
};
const iconStyle = {
color: '#F6B266', // 警告图标颜色
background: 'rgba(246, 178, 102, 0.15)',
padding: '4px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const descriptionStyle = {
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.65)',
marginBottom: '12px',
lineHeight: '1.5',
};
const btnStyle = {
color: 'rgb(250 173 20 / 90%)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
fontSize: '12px',
fontWeight: 500,
textDecoration: 'underline',
textUnderlineOffset: '2px',
textDecorationColor: 'rgb(250 173 20 / 60%)',
transition: 'all 0.2s ease',
};
/**
*
* @description
*/
const INSUFFICIENT_POINTS_KEY = 'insufficient-points-notification';
export const showInsufficientPointsNotification = (detail?: {
interface InsufficientDetail {
current_balance?: number;
required_tokens?: number;
message?: string;
}) => {
// 先关闭现有的通知
notification.destroy(INSUFFICIENT_POINTS_KEY);
// 每次都生成新的 key
const uniqueKey = `${INSUFFICIENT_POINTS_KEY}-${Date.now()}`;
}
notification.warning({
key: uniqueKey,
message: null,
description: (
<div data-alt="insufficient-points-notification" style={{ minWidth: '280px' }}>
<h3 style={messageStyle}>
Insufficient credits reminder
</h3>
<p style={descriptionStyle}>
{detail?.message || 'Your credits are insufficient, please upgrade to continue.'}
{detail?.current_balance !== undefined && detail?.required_tokens !== undefined && (
<>
<br />
<span style={{ color: 'rgba(255, 255, 255, 0.85)' }}>
Current balance: {detail.current_balance} / Required: {detail.required_tokens}
</span>
</>
)}
</p>
<button
onClick={() => window.location.href = '/pricing'}
style={btnStyle}
data-alt="recharge-button"
>
Upgrade to continue
</button>
</div>
),
duration: 0,
placement: 'topRight',
style: darkGlassStyle,
className: 'dark-glass-notification',
closeIcon: (
<button
className="hover:text-white"
style={{
background: 'transparent',
border: 'none',
padding: '2px',
cursor: 'pointer',
color: 'rgba(255, 255, 255, 0.45)',
transition: 'color 0.2s ease',
}}
interface InsufficientModalProps {
detail?: InsufficientDetail;
onClose?: () => void;
}
function InsufficientPointsModal(props: InsufficientModalProps) {
const { detail, onClose } = props;
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, []);
const titleText = 'Insufficient credits reminder';
const descriptionText =
detail?.message || 'Your credits are insufficient, please upgrade to continue.';
return (
<div
ref={containerRef}
data-alt="insufficient-overlay"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
>
<div
data-alt="insufficient-modal"
className="relative w-11/12 max-w-sm rounded-2xl bg-white/5 border border-white/10 backdrop-blur-xl shadow-2xl p-6 text-white"
role="dialog"
aria-modal="true"
aria-labelledby="insufficient-title"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
),
});
};
<button
data-alt="close-button"
className="absolute top-4 right-4 w-6 h-6 rounded-full bg-white/10 hover:bg-white/20 border border-white/10 flex items-center justify-center text-white transition-all active:scale-95"
onClick={onClose}
aria-label="Close"
>
<X size={14} />
</button>
<div data-alt="modal-header" className="flex flex-col items-center text-center gap-2">
<div data-alt="modal-icon" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-lg text-[#ab50d0]">
<BadgeCent />
</div>
<h3 id="insufficient-title" data-alt="modal-title" className="text-base font-semibold">
{titleText}
</h3>
</div>
<div data-alt="modal-body" className="mt-4 space-y-4">
<p data-alt="modal-description" className="text-sm text-white/80">
{descriptionText}
</p>
{(typeof detail?.current_balance !== 'undefined') && (typeof detail?.required_tokens !== 'undefined') && (
<div data-alt="stats-grid" className="grid grid-cols-2 gap-3">
<div data-alt="stat-balance" className="rounded-lg bg-white/5 border border-white/10 p-3">
<div className="text-[11px] text-white/60">Current balance</div>
<div className="text-sm font-medium">{detail?.current_balance}</div>
</div>
<div data-alt="stat-required" className="rounded-lg bg-white/5 border border-white/10 p-3">
<div className="text-[11px] text-white/60">Required</div>
<div className="text-sm font-medium">{detail?.required_tokens}</div>
</div>
</div>
)}
</div>
<div data-alt="modal-actions" className="mt-6 space-y-3">
<button
data-alt="upgrade-button"
className="w-full px-4 py-2 rounded-lg bg-[#9144b0] text-white font-medium transition-colors"
onClick={() => {
window.location.href = '/pricing';
onClose?.();
}}
>
Upgrade to continue
</button>
</div>
</div>
</div>
);
}
/**
*
* Opens an H5-styled modal to notify insufficient credits.
* @param {InsufficientDetail} detail - optional detail including balances and message.
*/
notification.config({
maxCount: 3, // 最多同时显示3个通知
});
export function showInsufficientPointsNotification(detail?: InsufficientDetail): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const mount = document.createElement('div');
mount.setAttribute('data-alt', 'insufficient-modal-root');
document.body.appendChild(mount);
let root: Root | null = null;
try {
root = createRoot(mount);
} catch {
if (mount.parentNode) {
mount.parentNode.removeChild(mount);
}
return;
}
const close = () => {
try {
root?.unmount();
} finally {
if (mount.parentNode) {
mount.parentNode.removeChild(mount);
}
}
};
root.render(
<InsufficientPointsModal detail={detail} onClose={close} />
);
}