forked from 77media/video-flow
Merge pull request 'dev' (#3) from dev into prod
Reviewed-on: 77media/video-flow#3
This commit is contained in:
commit
85cc31a44f
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -307,6 +307,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
textarea, input {
|
||||
.mobile-textarea, .mobile-input {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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!,
|
||||
|
||||
167
components/QueueBox/H5QueueNotication.tsx
Normal file
167
components/QueueBox/H5QueueNotication.tsx
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
293
components/pages/work-flow/H5ProgressBar.tsx
Normal file
293
components/pages/work-flow/H5ProgressBar.tsx
Normal 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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
BIN
public/assets/error.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user