Merge branch 'dev' into prod

This commit is contained in:
moux1024 2025-09-30 14:34:53 +08:00
commit 0ea56ac410
32 changed files with 1499 additions and 1625 deletions

3
.gitignore vendored
View File

@ -46,3 +46,6 @@ dist
build_and_copy.log build_and_copy.log
*.log *.log
*.lock *.lock
# git
.github

View File

@ -4,7 +4,7 @@
### 目录结构与职责 ### 目录结构与职责
- `constants.ts`:基础配置(`BASE_URL``NEXT_PUBLIC_BASE_URL` 注入) - 基础配置从 `@/lib/env` 导入 `baseUrl`
- `request.ts`Axios 实例与拦截器、通用 `get/post/put/del``stream`SSE风格下载进度`downloadStream``streamJsonPost` - `request.ts`Axios 实例与拦截器、通用 `get/post/put/del``stream`SSE风格下载进度`downloadStream``streamJsonPost`
- `errorHandle.ts`:错误码映射与统一提示、特殊码处理(如 401 跳转登录、402 不弹提示) - `errorHandle.ts`:错误码映射与统一提示、特殊码处理(如 401 跳转登录、402 不弹提示)
- `common.ts`:通用类型与与上传相关的工具(获取七牛 Token、上传 - `common.ts`:通用类型与与上传相关的工具(获取七牛 Token、上传
@ -16,7 +16,7 @@
1. 使用 `request.ts` 提供的 `get/post/put/del` 包装函数发起请求,返回后端响应体(已通过响应拦截器做业务码检查)。 1. 使用 `request.ts` 提供的 `get/post/put/del` 包装函数发起请求,返回后端响应体(已通过响应拦截器做业务码检查)。
2. 业务成功码:`code === 0``code === 202`(长任务/排队等需要前端自行处理状态)。若非成功码,拦截器会调用 `errorHandle``Promise.reject` 2. 业务成功码:`code === 0``code === 202`(长任务/排队等需要前端自行处理状态)。若非成功码,拦截器会调用 `errorHandle``Promise.reject`
3. 认证:前端从 `localStorage.token` 注入 `Authorization: Bearer <token>`,请确保登录流程写入 `token` 3. 认证:前端从 `localStorage.token` 注入 `Authorization: Bearer <token>`,请确保登录流程写入 `token`
4. 基础地址:通过环境变量 `NEXT_PUBLIC_BASE_URL` 注入,构建前需设置 4. 基础地址:`@/lib/env``baseUrl` 获取,统一管理环境变量
### 错误处理约定 ### 错误处理约定
@ -90,7 +90,7 @@ await downloadStream('/download/file', 'result.mp4');
#### 浏览器前端React/Next.js CSR #### 浏览器前端React/Next.js CSR
- 直接使用 `get/post/put/del`;确保登录后将 `token` 写入 `localStorage` - 直接使用 `get/post/put/del`;确保登录后将 `token` 写入 `localStorage`
- 环境变量:在 `.env.local` 配置 `NEXT_PUBLIC_BASE_URL` - 环境变量:在 `.env.local` 配置 `NEXT_PUBLIC_BASE_URL`,通过 `@/lib/env` 统一管理
- 错误提示:由 `errorHandle` 统一处理402 会展示积分不足通知 - 错误提示:由 `errorHandle` 统一处理402 会展示积分不足通知
#### Next.js Route Handler服务端 API #### Next.js Route Handler服务端 API
@ -101,7 +101,7 @@ await downloadStream('/download/file', 'result.mp4');
#### Next.js Server Components/SSR #### Next.js Server Components/SSR
- 服务端不具备 `localStorage`,如需鉴权请改为从 Cookie/Headers 传递 token并在转发时设置 `Authorization` - 服务端不具备 `localStorage`,如需鉴权请改为从 Cookie/Headers 传递 token并在转发时设置 `Authorization`
- 服务器端可直接使用 `fetch(BASE_URL + path, { headers })` - 服务器端可直接使用 `fetch(baseUrl + path, { headers })`
#### Node/ServerlessVercel/Cloudflare #### Node/ServerlessVercel/Cloudflare

View File

@ -1,5 +1,5 @@
// Common API 相关接口 // Common API 相关接口
import { BASE_URL } from './constants' import { baseUrl } from '@/lib/env';
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
code: number code: number
@ -58,7 +58,7 @@ export const getUploadToken = async (timeoutMs: number = 10000): Promise<{ token
}, timeoutMs) }, timeoutMs)
try { try {
const response = await fetch(`${BASE_URL}/common/get-upload-token`, { const response = await fetch(`${baseUrl}/common/get-upload-token`, {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: "application/json",

View File

@ -1,4 +0,0 @@
export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
// export const BASE_URL = 'https://77.smartvideo.py.qikongjian.com'
// export const BASE_URL ='http://192.168.120.5:8000'
//

View File

@ -1,5 +1,5 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios';
import { BASE_URL } from './constants' import { baseUrl } from '@/lib/env';
import { errorHandle } from './errorHandle'; import { errorHandle } from './errorHandle';
/** /**
@ -20,7 +20,7 @@ const handleRequestError = (error: any, defaultMessage: string = '请求失败')
}; };
// 创建 axios 实例 // 创建 axios 实例
const request: AxiosInstance = axios.create({ const request: AxiosInstance = axios.create({
baseURL: BASE_URL, // 设置基础URL baseURL: baseUrl, // 设置基础URL
timeout: 300000, // 请求超时时间 timeout: 300000, // 请求超时时间
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -102,7 +102,7 @@ export async function streamJsonPost<T = any>(
) { ) {
try { try {
const token = localStorage?.getItem('token') || ''; const token = localStorage?.getItem('token') || '';
const response = await fetch(`${BASE_URL}${url}`, { const response = await fetch(`${baseUrl}${url}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -1,4 +1,4 @@
import { BASE_URL } from "./constants"; import { baseUrl } from '@/lib/env';
import { post } from './request'; import { post } from './request';
// 获取路演配置数据 // 获取路演配置数据
@ -8,7 +8,7 @@ export const fetchRoadshowConfigs = async () => {
try { try {
console.log('开始请求接口数据...'); console.log('开始请求接口数据...');
const response = await fetch(BASE_URL + '/serversetting/roadshow-configs', { const response = await fetch(baseUrl + '/serversetting/roadshow-configs', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -1,7 +1,6 @@
import { post, streamJsonPost } from "./request"; import { post, streamJsonPost } from "./request";
import { ProjectTypeEnum } from "@/app/model/enums"; import { ProjectTypeEnum } from "@/app/model/enums";
import { ApiResponse } from "@/api/common"; import { ApiResponse } from "@/api/common";
import { BASE_URL } from "./constants";
import { import {
AITextEntity, AITextEntity,
RoleEntity, RoleEntity,

View File

@ -5,6 +5,7 @@ import { Providers } from '@/components/providers';
import { ConfigProvider, theme } from 'antd'; import { ConfigProvider, theme } from 'antd';
import CallbackModal from '@/components/common/CallbackModal'; import CallbackModal from '@/components/common/CallbackModal';
import { useAppStartupAnalytics } from '@/hooks/useAppStartupAnalytics'; import { useAppStartupAnalytics } from '@/hooks/useAppStartupAnalytics';
import { gaEnabled, gaMeasurementId } from '@/lib/env';
// 创建上下文来传递弹窗控制方法 // 创建上下文来传递弹窗控制方法
const CallbackModalContext = createContext<{ const CallbackModalContext = createContext<{
@ -53,16 +54,16 @@ export default function RootLayout({
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" /> <link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" /> <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico?v=1" /> <link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico?v=1" />
{process.env.NEXT_PUBLIC_GA_ENABLED === 'true' && ( {gaEnabled && (
<> <>
<script async src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}`}></script> <script async src={`https://www.googletagmanager.com/gtag/js?id=${gaMeasurementId}`}></script>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
function gtag(){window.dataLayer.push(arguments);} function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date()); gtag('js', new Date());
gtag('config', '${process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID}', { gtag('config', '${gaMeasurementId}', {
page_title: document.title, page_title: document.title,
page_location: window.location.href, page_location: window.location.href,
send_page_view: true send_page_view: true

View File

@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { CheckCircle, XCircle, Loader2, AlertTriangle } from "lucide-react"; import { CheckCircle, XCircle, Loader2, AlertTriangle } from "lucide-react";
import type { OAuthCallbackParams } from "@/app/types/google-oauth"; import type { OAuthCallbackParams } from "@/app/types/google-oauth";
import { baseUrl } from '@/lib/env';
// 根据后端实际返回格式定义响应类型 // 根据后端实际返回格式定义响应类型
interface GoogleOAuthResponse { interface GoogleOAuthResponse {
@ -98,8 +99,7 @@ export default function OAuthCallback() {
console.log('最终使用的邀请码:', finalInviteCode); console.log('最终使用的邀请码:', finalInviteCode);
// 根据 jiekou.md 文档调用统一的 Python OAuth 接口 // 根据 jiekou.md 文档调用统一的 Python OAuth 接口
// 使用 NEXT_PUBLIC_BASE_URL 配置,默认为 https://77.smartvideo.py.qikongjian.com // 使用统一配置中的 baseUrl
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://77.smartvideo.py.qikongjian.com';
console.log('🔧 调用 Python OAuth 接口:', baseUrl); console.log('🔧 调用 Python OAuth 接口:', baseUrl);
const response = await fetch(`${baseUrl}/api/oauth/google`, { const response = await fetch(`${baseUrl}/api/oauth/google`, {

View File

@ -38,12 +38,13 @@ if [ "$current_branch" = "$BRANCH_NAME" ]; then
echo "On dev branch, building project..." | tee -a $LOGFILE echo "On dev branch, building project..." | tee -a $LOGFILE
PROFILE_ENV=$BRANCH_NAME PROFILE_ENV=$BRANCH_NAME
# 安装依赖并构建 # 安装依赖并构建(以生产模式构建,但注入开发环境变量)
yarn install yarn install
yarn build NODE_ENV=production npx --yes env-cmd -f .env.development yarn build
# 准备dist目录 # 准备dist目录
mkdir -p dist mkdir -p dist
rm -rf .next/cache
cp -r .next dist/ cp -r .next dist/
cp -r public dist/ cp -r public dist/
cp package.json dist/ cp package.json dist/

View File

@ -38,9 +38,9 @@ if [ "$current_branch" = "$BRANCH_NAME" ]; then
echo "On prod branch, building project..." | tee -a $LOGFILE echo "On prod branch, building project..." | tee -a $LOGFILE
PROFILE_ENV=$BRANCH_NAME PROFILE_ENV=$BRANCH_NAME
# 安装依赖并构建 # 安装依赖并构建(以生产模式构建并注入生产环境变量)
yarn install yarn install
yarn build NODE_ENV=production npx --yes env-cmd -f .env.production yarn build
# 准备dist目录 # 准备dist目录
mkdir -p dist mkdir -p dist

View File

@ -167,7 +167,7 @@ export default function CallbackModal({
return return
} }
const userId = JSON.parse(localStorage.getItem('currentUser') || '{}').id const userId = typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('currentUser') || '{}').id : 0
try { try {
if (paymentType === 'subscription') { if (paymentType === 'subscription') {

View File

@ -7,7 +7,8 @@ import { Drawer } from 'antd';
import { fetchTabsByCode, HomeTabItem } from '@/api/serversetting'; import { fetchTabsByCode, HomeTabItem } from '@/api/serversetting';
import { getSigninStatus } from '@/api/signin'; import { getSigninStatus } from '@/api/signin';
import { getCurrentUser, isAuthenticated, logoutUser } from '@/lib/auth'; import { getCurrentUser, isAuthenticated, logoutUser } from '@/lib/auth';
import { getUserSubscriptionInfo, createPortalSession, redirectToPortal } from '@/lib/stripe'; import { getUserSubscriptionInfo } from '@/lib/stripe';
import { trackEvent } from '@/utils/analytics';
import { GradientText } from '@/components/ui/gradient-text'; import { GradientText } from '@/components/ui/gradient-text';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import SigninBox from './signin-box'; import SigninBox from './signin-box';
@ -184,13 +185,42 @@ export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
if (!user?.id) return; if (!user?.id) return;
setIsManagingSubscription(true); setIsManagingSubscription(true);
try { try {
const response = await createPortalSession({ // 获取用户当前订阅信息
user_id: String(user.id), const response = await getUserSubscriptionInfo(String(user.id));
return_url: window.location.origin + '/dashboard', if (!response?.successful || !response?.data) {
}); throw new Error('Failed to get subscription info');
if (response.successful && response.data?.portal_url) {
redirectToPortal(response.data.portal_url);
} }
const currentPlan = response.data.plan_name;
const billingType = 'month'; // 默认使用月付用户可以在pricing页面切换
// 跟踪订阅管理按钮点击事件
trackEvent('subscription_manage_click', {
event_category: 'subscription',
event_label: 'manage_subscription',
custom_parameters: {
current_plan: currentPlan,
billing_type: billingType,
},
});
// 复用pricing页面的跳转方案构建pay-redirect URL
const url = `/pay-redirect?type=subscription&plan=${encodeURIComponent(currentPlan)}&billing=${encodeURIComponent(billingType)}`;
const win = window.open(url, '_blank');
// 通知当前窗口等待支付显示loading模态框
window.postMessage({
type: 'waiting-payment',
paymentType: 'subscription',
}, '*');
if (!win) {
throw new Error('Unable to open redirect window, please check popup settings');
}
} catch (error) {
console.error('Failed to manage subscription:', error);
// 如果出错回退到pricing页面
router.push('/pricing');
} finally { } finally {
setIsManagingSubscription(false); setIsManagingSubscription(false);
} }
@ -278,10 +308,8 @@ export default function H5TopBar({ onSelectHomeTab }: H5TopBarProps) {
title={null} title={null}
closable closable
height={undefined} height={undefined}
bodyStyle={{ padding: 0 }}
maskClosable maskClosable
// 64px 顶栏高度 + 8px 安全间距 // 64px 顶栏高度 + 8px 安全间距
maskStyle={{ position: 'absolute', top: '3.5rem', height: 'calc(100dvh - 3.5rem)', backgroundColor: 'transparent' }}
styles={{ styles={{
content: { position: 'absolute', top: '3.5rem', height: isHome ? 'auto' : 'calc(100dvh - 3.5rem)' }, content: { position: 'absolute', top: '3.5rem', height: isHome ? 'auto' : 'calc(100dvh - 3.5rem)' },
body: { padding: 0 }, body: { padding: 0 },

View File

@ -836,7 +836,7 @@ function HomeModule3() {
> >
{/* 上方阴影遮罩 */} {/* 上方阴影遮罩 */}
<div <div
className="absolute -top-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none" className="absolute -top-[1rem] -left-0 w-full h-[20rem] z-10 pointer-events-none"
style={{ style={{
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)",
@ -849,7 +849,7 @@ function HomeModule3() {
{/* 下方阴影遮罩 */} {/* 下方阴影遮罩 */}
<div <div
className="absolute -bottom-[1rem] -left-0 w-full h-[20rem] z-20 pointer-events-none" className="absolute -bottom-[1rem] -left-0 w-full h-[20rem] z-10 pointer-events-none"
style={{ style={{
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)",
@ -861,7 +861,7 @@ function HomeModule3() {
></div> ></div>
{pcVideoList.map((column, columnIndex) => ( {pcVideoList.map((column, columnIndex) => (
<div key={columnIndex} className="w-full h-[64rem] relative z-10"> <div key={columnIndex} className="w-full h-[64rem] relative">
<Swiper <Swiper
modules={[Autoplay]} modules={[Autoplay]}
direction="vertical" direction="vertical"

View File

@ -8,6 +8,7 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { cutUrl } from '@/lib/env';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
Zap, Zap,
@ -95,7 +96,6 @@ export const AIEditingIframe = React.forwardRef<AIEditingIframeHandle, AIEditing
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null); const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const cutUrl = process.env.NEXT_PUBLIC_CUT_URL || 'https://cut.movieflow.ai';
console.log('cutUrl', cutUrl); console.log('cutUrl', cutUrl);
// 构建智能剪辑URL // 构建智能剪辑URL

View File

@ -2,7 +2,7 @@
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react'; import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert, PenTool } from 'lucide-react'; import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, PictureInPicture2, PenTool } from 'lucide-react';
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal'; import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
import { GlassIconButton } from '@/components/ui/glass-icon-button'; import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer'; import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
@ -69,6 +69,8 @@ export const MediaViewer = React.memo(function MediaViewer({
// 音量控制状态 // 音量控制状态
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(0.8); const [volume, setVolume] = useState(0.8);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
// 最终视频控制状态 // 最终视频控制状态
const [isFinalVideoPlaying, setIsFinalVideoPlaying] = useState(true); const [isFinalVideoPlaying, setIsFinalVideoPlaying] = useState(true);
@ -175,6 +177,9 @@ export const MediaViewer = React.memo(function MediaViewer({
if (finalVideoRef.current) { if (finalVideoRef.current) {
setFinalVideoReady(true); setFinalVideoReady(true);
applyVolumeSettings(finalVideoRef.current); applyVolumeSettings(finalVideoRef.current);
try {
setDuration(Number.isFinite(finalVideoRef.current.duration) ? finalVideoRef.current.duration : 0);
} catch {}
// 如果当前状态是应该播放的,尝试播放 // 如果当前状态是应该播放的,尝试播放
if (isFinalVideoPlaying) { if (isFinalVideoPlaying) {
@ -235,16 +240,15 @@ export const MediaViewer = React.memo(function MediaViewer({
// 全屏控制 // 全屏控制
const toggleFullscreen = () => { const toggleFullscreen = () => {
setUserHasInteracted(true); setUserHasInteracted(true);
const target = activeVideoRef().current;
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
// 进入全屏 if (target) {
if (finalVideoRef.current) { target.requestFullscreen?.() ||
finalVideoRef.current.requestFullscreen?.() || (target as any).webkitRequestFullscreen?.() ||
(finalVideoRef.current as any).webkitRequestFullscreen?.() || (target as any).msRequestFullscreen?.();
(finalVideoRef.current as any).msRequestFullscreen?.();
setIsFullscreen(true); setIsFullscreen(true);
} }
} else { } else {
// 退出全屏
document.exitFullscreen?.() || document.exitFullscreen?.() ||
(document as any).webkitExitFullscreen?.() || (document as any).webkitExitFullscreen?.() ||
(document as any).msExitFullscreen?.(); (document as any).msExitFullscreen?.();
@ -263,6 +267,9 @@ export const MediaViewer = React.memo(function MediaViewer({
} else { } else {
mainVideoRef.current.pause(); mainVideoRef.current.pause();
} }
try {
setDuration(Number.isFinite(mainVideoRef.current.duration) ? mainVideoRef.current.duration : 0);
} catch {}
} }
}, [isVideoPlaying]); }, [isVideoPlaying]);
@ -289,6 +296,71 @@ export const MediaViewer = React.memo(function MediaViewer({
} }
}, [volume, isMuted]); }, [volume, isMuted]);
// 绑定时间更新(只监听当前活跃的 video
useEffect(() => {
const activeRef = activeVideoRef();
const activeVideo = activeRef.current;
if (!activeVideo) return;
const onTimeUpdate = (e: Event) => {
const el = e.currentTarget as HTMLVideoElement;
// 只有当事件来源是当前活跃视频时才更新状态
if (el === activeVideo) {
setCurrentTime(el.currentTime || 0);
if (Number.isFinite(el.duration)) setDuration(el.duration);
}
};
activeVideo.addEventListener('timeupdate', onTimeUpdate);
activeVideo.addEventListener('loadedmetadata', onTimeUpdate);
return () => {
activeVideo.removeEventListener('timeupdate', onTimeUpdate);
activeVideo.removeEventListener('loadedmetadata', onTimeUpdate);
};
}, [selectedView, taskObject.currentStage]); // 依赖项包含影响activeVideoRef的状态
// 当切换视频源时重置进度状态
useEffect(() => {
setCurrentTime(0);
setDuration(0);
}, [selectedView, currentSketchIndex, taskObject.currentStage]);
const activeVideoRef = () => {
// 根据当前阶段选择活跃的 video 引用
const effectiveStage = (selectedView === 'final' && taskObject.final?.url)
? 'final_video'
: (!['script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
return effectiveStage === 'final_video' ? finalVideoRef : mainVideoRef;
};
const progressPercent = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
const formatRemaining = (dur: number, cur: number) => {
const remain = Math.max(0, Math.round(dur - cur));
const m = Math.floor(remain / 60);
const s = remain % 60;
return `-${m}:${s.toString().padStart(2, '0')}`;
};
const seekTo = (pct: number) => {
const ref = activeVideoRef().current;
if (!ref || !Number.isFinite(ref.duration)) return;
const t = (pct / 100) * ref.duration;
ref.currentTime = t;
setCurrentTime(t);
};
const requestPip = async () => {
try {
const ref = activeVideoRef().current as any;
if (ref && typeof ref.requestPictureInPicture === 'function') {
await ref.requestPictureInPicture();
}
} catch {}
};
// 监听全屏状态变化 // 监听全屏状态变化
useEffect(() => { useEffect(() => {
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
@ -317,41 +389,54 @@ export const MediaViewer = React.memo(function MediaViewer({
}; };
}, []); }, []);
// 渲染音量控制组件 // 渲染底部通栏控制条与图2一致
const renderVolumeControls = () => ( const renderBottomControls = (
<div className="flex items-center gap-2"> isFinal: boolean,
{/* 静音按钮 */} onPlayToggle: () => void,
<GlassIconButton playing: boolean
icon={isMuted ? VolumeX : Volume2} ) => (
onClick={toggleMute} <div
size="sm" className="absolute left-0 right-0 bottom-2 z-[21] px-6"
/> data-alt={isFinal ? 'final-controls' : 'video-controls'}
>
<div className="flex items-center gap-3">
{/* 播放/暂停 */}
<GlassIconButton icon={playing ? Pause : Play} onClick={onPlayToggle} size="sm" />
{/* 音量滑块 - 一直显示 */} {/* 静音,仅图标 */}
<div className="flex items-center gap-2"> <GlassIconButton icon={isMuted ? VolumeX : Volume2} onClick={toggleMute} size="sm" />
<div className="relative">
{/* 进度条 */}
<div className="flex-1 flex items-center">
<input <input
type="range" type="range"
min="0" min="0"
max="1" max="100"
step="0.1" step="0.1"
value={volume} value={progressPercent}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))} onChange={(e) => seekTo(parseFloat(e.target.value))}
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer 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]: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]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:rounded-full [&::-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]:bg-white [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:border-none"
[&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:shadow-lg"
style={{ style={{
background: `linear-gradient(to right, white 0%, white ${volume * 100}%, rgba(255,255,255,0.2) ${volume * 100}%, rgba(255,255,255,0.2) 100%)` 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>
<span className="text-xs text-white/70 w-8 text-center">
{Math.round(volume * 100)}% {/* 剩余时间 */}
</span> <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" />
</div> </div>
</div> </div>
); );
@ -432,35 +517,9 @@ export const MediaViewer = React.memo(function MediaViewer({
)} )}
</div> </div>
{/* 底部控制区域 */} {/* 底部通栏控制区域 */}
<motion.div <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }}>
className="absolute bottom-16 left-4 z-10 flex items-center gap-3" {renderBottomControls(true, toggleFinalVideoPlay, isFinalVideoPlaying)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 1, duration: 0.6 }}
>
{/* 播放/暂停按钮 */}
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="relative"
>
<GlassIconButton
icon={isFinalVideoPlaying ? Pause : Play}
onClick={toggleFinalVideoPlay}
size="sm"
/>
</motion.div>
{/* 音量控制 */}
{renderVolumeControls()}
{/* 全屏按钮 */}
<GlassIconButton
icon={isFullscreen ? Minimize : Maximize}
onClick={toggleFullscreen}
size="sm"
/>
</motion.div> </motion.div>
</div> </div>
</div> </div>
@ -625,31 +684,11 @@ export const MediaViewer = React.memo(function MediaViewer({
</motion.div> </motion.div>
</AnimatePresence> */} </AnimatePresence> */}
{/* 底部控制区域 */} {/* 底部通栏控制区域(仅生成成功时显示) */}
{ taskObject.videos.data[currentSketchIndex].video_status === 1 && ( { taskObject.videos.data[currentSketchIndex].video_status === 1 && (
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
className="absolute bottom-4 left-4 z-[21] flex items-center gap-3" {renderBottomControls(false, onToggleVideoPlay, isVideoPlaying)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
{/* 播放按钮 */}
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="relative"
>
<GlassIconButton
icon={isVideoPlaying ? Pause : Play}
onClick={onToggleVideoPlay}
size="sm"
/>
</motion.div>
{/* 音量控制 */}
{renderVolumeControls()}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
)} )}

View File

@ -8,6 +8,7 @@ import { useUpdateEffect } from '@/app/hooks/useUpdateEffect';
import { LOADING_TEXT_MAP, TaskObject, Status, Stage } from '@/api/DTO/movieEdit'; import { LOADING_TEXT_MAP, TaskObject, Status, Stage } from '@/api/DTO/movieEdit';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector'; import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
import { useDeviceType } from '@/hooks/useDeviceType'; import { useDeviceType } from '@/hooks/useDeviceType';
import { cutUrlTo, errorConfig } from '@/lib/env';
interface UseWorkflowDataProps { interface UseWorkflowDataProps {
onEditPlanGenerated?: () => void; onEditPlanGenerated?: () => void;
@ -40,7 +41,7 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
const { isMobile, isTablet, isDesktop } = useDeviceType(); const { isMobile, isTablet, isDesktop } = useDeviceType();
const cutUrl = process.env.NEXT_PUBLIC_CUT_URL_TO || 'https://smartcut.api.movieflow.ai'; const cutUrl = cutUrlTo;
console.log('cutUrl', cutUrl); console.log('cutUrl', cutUrl);
useEffect(() => { useEffect(() => {
@ -77,7 +78,6 @@ export function useWorkflowData({ onEditPlanGenerated, editingStatus, onExportFa
} }
}); });
let loadingText: any = useRef(LOADING_TEXT_MAP.getInfo); let loadingText: any = useRef(LOADING_TEXT_MAP.getInfo);
const errorConfig = Number(process.env.NEXT_PUBLIC_ERROR_CONFIG);
// 更新 taskObject 的类型 // 更新 taskObject 的类型

View File

@ -2,6 +2,8 @@
* *
*/ */
import { getVideoEditApiConfig as getEnvVideoEditConfig, isDevelopment } from '@/lib/env';
export interface VideoEditApiConfig { export interface VideoEditApiConfig {
/** 是否使用Mock API */ /** 是否使用Mock API */
useMockApi: boolean; useMockApi: boolean;
@ -26,32 +28,23 @@ export const defaultVideoEditApiConfig: VideoEditApiConfig = {
remoteApiBase: '/video-edit', remoteApiBase: '/video-edit',
localApiBase: '/api/video-edit', localApiBase: '/api/video-edit',
timeout: 10000, timeout: 10000,
enableDebugLog: process.env.NODE_ENV === 'development' enableDebugLog: isDevelopment
}; };
/** /**
* API配置 * API配置
*/ */
export function getVideoEditApiConfig(): VideoEditApiConfig { export function getVideoEditApiConfig(): VideoEditApiConfig {
// 可以从环境变量或其他配置源读 // 从统一环境配置获
const config = { ...defaultVideoEditApiConfig }; const envConfig = getEnvVideoEditConfig();
// 环境变量覆盖 return {
if (process.env.NEXT_PUBLIC_VIDEO_EDIT_USE_MOCK === 'true') { ...defaultVideoEditApiConfig,
config.useMockApi = true; useMockApi: envConfig.useMockApi,
config.useLocalApi = false; useLocalApi: !envConfig.useRemoteApi,
} remoteApiBase: envConfig.remoteApiBase,
enableDebugLog: envConfig.enableDebugLog,
if (process.env.NEXT_PUBLIC_VIDEO_EDIT_USE_REMOTE === 'true') { };
config.useLocalApi = false;
config.useMockApi = false;
}
if (process.env.NEXT_PUBLIC_VIDEO_EDIT_REMOTE_BASE) {
config.remoteApiBase = process.env.NEXT_PUBLIC_VIDEO_EDIT_REMOTE_BASE;
}
return config;
} }
/** /**
@ -155,7 +148,7 @@ export const errorHandlingConfig = {
*/ */
export const performanceConfig = { export const performanceConfig = {
/** 是否启用性能监控 */ /** 是否启用性能监控 */
enabled: process.env.NODE_ENV === 'development', enabled: isDevelopment,
/** 慢请求阈值(毫秒) */ /** 慢请求阈值(毫秒) */
slowRequestThreshold: 2000, slowRequestThreshold: 2000,
/** 是否记录所有请求 */ /** 是否记录所有请求 */
@ -193,7 +186,7 @@ export const uiConfig = {
/** 描述最大长度 */ /** 描述最大长度 */
maxDescriptionLength: 500, maxDescriptionLength: 500,
/** 是否显示调试信息 */ /** 是否显示调试信息 */
showDebugInfo: process.env.NODE_ENV === 'development', showDebugInfo: isDevelopment,
/** 动画配置 */ /** 动画配置 */
animations: { animations: {
enabled: true, enabled: true,

View File

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-[999]', 'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-[1000]',
className className
)} )}
{...props} {...props}
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg z-[999]', 'fixed left-[50%] top-[50%] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg z-[1000]',
className className
)} )}
{...props} {...props}

View File

@ -1,4 +1,5 @@
// src/components/VantaHaloBackground.jsx // src/components/VantaHaloBackground.jsx
// 未使用
'use client'; 'use client';
import React, { useRef, useEffect, memo } from 'react' import React, { useRef, useEffect, memo } from 'react'

View File

@ -8,11 +8,9 @@ import type {
OAuthState OAuthState
} from '@/app/types/google-oauth'; } from '@/app/types/google-oauth';
import { setUserProperties } from '@/utils/analytics'; import { setUserProperties } from '@/utils/analytics';
import { javaUrl, baseUrl, googleClientId, getGoogleRedirectUri } from '@/lib/env';
// API配置 // API配置 - 直接使用导入的配置变量
//const JAVA_BASE_URL = 'http://192.168.120.36:8080';
const JAVA_BASE_URL = process.env.NEXT_PUBLIC_JAVA_URL || 'https://77.app.java.auth.qikongjian.com';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
// Token存储键 // Token存储键
const TOKEN_KEY = 'token'; const TOKEN_KEY = 'token';
@ -43,7 +41,7 @@ type RegisterUserResponse = {
*/ */
export const loginUser = async (email: string, password: string) => { export const loginUser = async (email: string, password: string) => {
try { try {
const response = await fetch(`${JAVA_BASE_URL}/api/user/login`, { const response = await fetch(`${javaUrl}/api/user/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -205,9 +203,6 @@ export const authFetch = async (url: string, options: RequestInit = {}) => {
// Google OAuth相关函数 // Google OAuth相关函数
// Google Client ID - 从环境变量获取
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com';
/** /**
* Google GSI SDK * Google GSI SDK
*/ */
@ -268,10 +263,8 @@ export const signInWithGoogle = async (inviteCode?: string): Promise<void> => {
} }
} }
// 从环境变量获取配置 // 从统一配置获取配置
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com'; const redirectUri = getGoogleRedirectUri();
const javaBaseUrl = process.env.NEXT_PUBLIC_JAVA_URL || 'https://auth.test.movieflow.ai';
const redirectUri = process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI || `${javaBaseUrl}/api/auth/google/callback`;
// 生成随机nonce用于安全验证 // 生成随机nonce用于安全验证
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32))) const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)))
@ -286,12 +279,9 @@ export const signInWithGoogle = async (inviteCode?: string): Promise<void> => {
}; };
console.log('使用的配置:', { console.log('使用的配置:', {
clientId, clientId: googleClientId,
javaBaseUrl, javaBaseUrl: javaUrl,
redirectUri, redirectUri
envClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
envJavaUrl: process.env.NEXT_PUBLIC_JAVA_URL,
envRedirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI
}); });
// 详细的调试日志 // 详细的调试日志
@ -300,14 +290,14 @@ export const signInWithGoogle = async (inviteCode?: string): Promise<void> => {
console.log(' - 当前协议:', window.location.protocol); console.log(' - 当前协议:', window.location.protocol);
console.log(' - 当前端口:', window.location.port); console.log(' - 当前端口:', window.location.port);
console.log(' - 完整 origin:', window.location.origin); console.log(' - 完整 origin:', window.location.origin);
console.log(' - 环境变量 NEXT_PUBLIC_GOOGLE_REDIRECT_URI:', process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI); console.log(' - 环境变量 NEXT_PUBLIC_GOOGLE_REDIRECT_URI:', redirectUri);
console.log(' - 最终使用的 redirect_uri:', redirectUri); console.log(' - 最终使用的 redirect_uri:', redirectUri);
console.log(' - Google Client ID:', GOOGLE_CLIENT_ID); console.log(' - Google Client ID:', googleClientId);
// 构建Google OAuth2授权URL // 构建Google OAuth2授权URL
const authParams = new URLSearchParams({ const authParams = new URLSearchParams({
access_type: 'online', access_type: 'online',
client_id: clientId, client_id: googleClientId,
nonce: nonce, nonce: nonce,
redirect_uri: redirectUri, redirect_uri: redirectUri,
response_type: 'code', // 使用授权码模式 response_type: 'code', // 使用授权码模式
@ -362,7 +352,7 @@ export const loginWithGoogleToken = async (idToken: string, action: 'login' | 'r
inviteCode inviteCode
}; };
const response = await fetch(`${JAVA_BASE_URL}/api/auth/google/login`, { const response = await fetch(`${javaUrl}/api/auth/google/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -417,7 +407,7 @@ export const bindGoogleAccount = async (bindToken: string, idToken?: string) =>
confirm: true confirm: true
}; };
const response = await fetch(`${JAVA_BASE_URL}/api/auth/google/bind`, { const response = await fetch(`${javaUrl}/api/auth/google/bind`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -453,7 +443,7 @@ export const getGoogleBindStatus = async () => {
throw new Error('User not authenticated'); throw new Error('User not authenticated');
} }
const response = await fetch(`${JAVA_BASE_URL}/api/auth/google/status`, { const response = await fetch(`${javaUrl}/api/auth/google/status`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -529,7 +519,7 @@ export const getUserProfile = async (): Promise<any> => {
throw new Error('No token available'); throw new Error('No token available');
} }
const response = await fetch(`${BASE_URL}/auth/profile`, { const response = await fetch(`${baseUrl}/auth/profile`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -638,7 +628,7 @@ export const registerUser = async ({
inviteCode?: string; inviteCode?: string;
}): Promise<any> => { }): Promise<any> => {
try { try {
const response = await fetch(`${BASE_URL}/api/user/register`, { const response = await fetch(`${baseUrl}/api/user/register`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -676,7 +666,7 @@ export const registerUserWithInvite = async ({
invite_code?: string; invite_code?: string;
}): Promise<RegisterUserResponse> => { }): Promise<RegisterUserResponse> => {
try { try {
const response = await fetch(`${BASE_URL}/api/user_fission/register_with_invite`, { const response = await fetch(`${baseUrl}/api/user_fission/register_with_invite`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -707,7 +697,7 @@ export const registerUserWithInvite = async ({
*/ */
export const sendVerificationLink = async (email: string) => { export const sendVerificationLink = async (email: string) => {
try { try {
const response = await fetch(`${JAVA_BASE_URL}/api/user/sendVerificationLink?email=${email}`); const response = await fetch(`${javaUrl}/api/user/sendVerificationLink?email=${email}`);
const data = await response.json(); const data = await response.json();
if(!data.success){ if(!data.success){
throw new Error(data.message||data.msg) throw new Error(data.message||data.msg)

202
lib/env.ts Normal file
View File

@ -0,0 +1,202 @@
/**
*
*
*/
/**
*
*/
export interface EnvConfig {
// 基础配置
nodeEnv: string;
isDevelopment: boolean;
isProduction: boolean;
// API 基础 URL 配置
baseUrl: string;
javaUrl: string;
cutUrl: string;
cutUrlTo: string;
// Google OAuth 配置
googleClientId: string;
googleRedirectUri: string;
// Google Analytics 配置
gaEnabled: boolean;
gaMeasurementId: string;
// 视频编辑配置
videoEditUseMock: boolean;
videoEditUseRemote: boolean;
videoEditRemoteBase: string;
// 其他配置
errorConfig: number;
}
/**
*
*/
export const getEnvConfig = (): EnvConfig => {
const nodeEnv = process.env.NODE_ENV || 'development';
return {
// 基础配置
nodeEnv,
isDevelopment: nodeEnv === 'development',
isProduction: nodeEnv === 'production',
// API 基础 URL 配置
baseUrl: process.env.NEXT_PUBLIC_BASE_URL || 'https://77.smartvideo.py.qikongjian.com',
javaUrl: process.env.NEXT_PUBLIC_JAVA_URL || 'https://77.app.java.auth.qikongjian.com',
cutUrl: process.env.NEXT_PUBLIC_CUT_URL || 'https://smartcut.api.movieflow.ai',
cutUrlTo: process.env.NEXT_PUBLIC_CUT_URL_TO || 'https://smartcut.api.movieflow.ai',
// Google OAuth 配置
googleClientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '847079918888-o1nne8d3ij80dn20qurivo987pv07225.apps.googleusercontent.com',
googleRedirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI || '',
// Google Analytics 配置
gaEnabled: process.env.NEXT_PUBLIC_GA_ENABLED === 'true',
gaMeasurementId: process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || 'G-4BDXV6TWF4',
// 视频编辑配置
videoEditUseMock: process.env.NEXT_PUBLIC_VIDEO_EDIT_USE_MOCK === 'true',
videoEditUseRemote: process.env.NEXT_PUBLIC_VIDEO_EDIT_USE_REMOTE === 'true',
videoEditRemoteBase: process.env.NEXT_PUBLIC_VIDEO_EDIT_REMOTE_BASE || '/video-edit',
// 其他配置
errorConfig: Number(process.env.NEXT_PUBLIC_ERROR_CONFIG) || 0,
};
};
/**
*
*/
export const env = getEnvConfig();
/**
*
*/
export const {
// 基础配置
nodeEnv,
isDevelopment,
isProduction,
// API 基础 URL 配置
baseUrl,
javaUrl,
cutUrl,
cutUrlTo,
// Google OAuth 配置
googleClientId,
googleRedirectUri,
// Google Analytics 配置
gaEnabled,
gaMeasurementId,
// 视频编辑配置
videoEditUseMock,
videoEditUseRemote,
videoEditRemoteBase,
// 其他配置
errorConfig,
} = env;
/**
* Google OAuth URI
*/
export const getGoogleRedirectUri = (): string => {
if (googleRedirectUri) {
return googleRedirectUri;
}
return `${javaUrl}/api/auth/google/callback`;
};
/**
* Google Analytics
*/
export const isGAAvailable = (): boolean => {
return typeof window !== 'undefined' &&
typeof window.gtag === 'function' &&
gaEnabled;
};
/**
* API
*/
export const getVideoEditApiConfig = () => {
return {
useMockApi: videoEditUseMock,
useRemoteApi: videoEditUseRemote,
remoteApiBase: videoEditRemoteBase,
localApiBase: '/api/video-edit',
enableDebugLog: isDevelopment,
};
};
/**
*
*/
export const validateEnvConfig = (): { isValid: boolean; errors: string[] } => {
const errors: string[] = [];
// 验证必需的配置
if (!baseUrl) {
errors.push('NEXT_PUBLIC_BASE_URL is required');
}
if (!javaUrl) {
errors.push('NEXT_PUBLIC_JAVA_URL is required');
}
if (!googleClientId) {
errors.push('NEXT_PUBLIC_GOOGLE_CLIENT_ID is required');
}
// 验证 URL 格式
try {
new URL(baseUrl);
} catch {
errors.push('NEXT_PUBLIC_BASE_URL must be a valid URL');
}
try {
new URL(javaUrl);
} catch {
errors.push('NEXT_PUBLIC_JAVA_URL must be a valid URL');
}
return {
isValid: errors.length === 0,
errors,
};
};
/**
*
*/
export const logEnvConfig = (): void => {
if (isDevelopment) {
console.log('🔧 环境变量配置:', {
nodeEnv,
baseUrl,
javaUrl,
cutUrl,
cutUrlTo,
googleClientId,
googleRedirectUri: getGoogleRedirectUri(),
gaEnabled,
gaMeasurementId,
videoEditUseMock,
videoEditUseRemote,
videoEditRemoteBase,
errorConfig,
});
}
};

View File

@ -2,6 +2,8 @@
* *
*/ */
import { baseUrl } from '@/lib/env';
// 注意:这里不使用 @/api/request 中的 post 函数,因为它会将请求发送到远程服务器 // 注意:这里不使用 @/api/request 中的 post 函数,因为它会将请求发送到远程服务器
// 我们需要直接调用本地的 Next.js API 路由 // 我们需要直接调用本地的 Next.js API 路由
@ -10,8 +12,7 @@
*/ */
const localPost = async <T>(url: string, data: any): Promise<T> => { const localPost = async <T>(url: string, data: any): Promise<T> => {
try { try {
// 使用环境变量中的 BASE_URL生产要求使用 NEXT_PUBLIC_BASE_URL // 使用统一配置中的 BASE_URL
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || '';
const isAbsolute = /^https?:\/\//i.test(url); const isAbsolute = /^https?:\/\//i.test(url);
const normalizedBase = baseUrl.replace(/\/$/, ''); const normalizedBase = baseUrl.replace(/\/$/, '');
const normalizedPath = url.startsWith('/') ? url : `/${url}`; const normalizedPath = url.startsWith('/') ? url : `/${url}`;

1428
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,11 +14,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.15",
"@formatjs/intl-localematcher": "^0.6.1",
"@hookform/resolvers": "^3.9.0",
"@mdx-js/mdx": "^3.1.0", "@mdx-js/mdx": "^3.1.0",
"@next/swc-wasm-nodejs": "14.2.10",
"@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.0",
@ -33,7 +29,6 @@
"@radix-ui/react-menubar": "^1.1.1", "@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
@ -42,13 +37,10 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
"@tiptap/core": "^3.0.7", "@tiptap/core": "^3.0.7",
"@tiptap/extension-placeholder": "^3.0.9", "@tiptap/extension-placeholder": "^3.0.9",
"@tiptap/react": "^3.0.7", "@tiptap/react": "^3.0.7",
@ -56,15 +48,12 @@
"@types/gsap": "^1.20.2", "@types/gsap": "^1.20.2",
"@types/node": "20.6.2", "@types/node": "20.6.2",
"@types/react": "18.2.25", "@types/react": "18.2.25",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "18.2.15", "@types/react-dom": "18.2.15",
"@types/styled-components": "^5.1.34", "@types/styled-components": "^5.1.34",
"@types/three": "^0.177.0",
"@types/wavesurfer.js": "^6.0.12", "@types/wavesurfer.js": "^6.0.12",
"antd": "^5.26.2", "antd": "^5.26.2",
"autoprefixer": "10.4.15", "autoprefixer": "10.4.15",
"axios": "^1.10.0", "axios": "^1.10.0",
"babel-loader": "^10.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
@ -80,47 +69,32 @@
"mdast-util-from-markdown": "^2.0.2", "mdast-util-from-markdown": "^2.0.2",
"mdast-util-mdx": "^3.0.0", "mdast-util-mdx": "^3.0.0",
"micromark-extension-mdxjs": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0",
"motion": "^12.18.1",
"negotiator": "^1.0.0",
"next": "14.2.10", "next": "14.2.10",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"postcss": "8.4.31",
"react": "18.2.0", "react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-contenteditable": "^3.3.7", "react-contenteditable": "^3.3.7",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-grid-layout": "^1.5.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-intersection-observer": "^9.16.0",
"react-joyride": "^2.9.3",
"react-lazyload": "^3.2.1", "react-lazyload": "^3.2.1",
"react-markdown": "^10.1.0",
"react-masonry-css": "^1.0.16", "react-masonry-css": "^1.0.16",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.3", "react-resizable-panels": "^2.1.3",
"react-rough-notation": "^1.0.5",
"react-textarea-autosize": "^8.5.9",
"react-wavesurfer.js": "^0.0.8",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^1.5.0", "sonner": "^1.5.0",
"styled-components": "^6.1.19", "styled-components": "^6.1.19",
"swiper": "^11.2.10", "swiper": "^11.2.10",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss": "3.3.3", "tailwindcss": "3.3.3",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"three": "^0.177.0",
"typescript": "5.2.2", "typescript": "5.2.2",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"wavesurfer.js": "^7.10.1", "wavesurfer.js": "^7.10.1"
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/lodash": "^4.17.19", "@types/lodash": "^4.17.19",
"@types/react-grid-layout": "^1.3.5", "env-cmd": "^10.1.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"ts-jest": "^29.1.2" "ts-jest": "^29.1.2"
} }

309
scripts/README.md Normal file
View File

@ -0,0 +1,309 @@
# 批量视频导出脚本
这个脚本用于批量处理项目ID生成剪辑计划并调用导出接口实现自动化的视频处理流程。
## 功能特性
- ✅ 批量处理多个项目ID
- ✅ 自动生成剪辑计划(调用 `/edit-plan/generate-by-project` 接口)
- ✅ 自动调用导出接口(调用 `/api/export/stream` 接口)
- ✅ 支持并发处理,提高效率
- ✅ 完整的错误处理和重试机制
- ✅ 实时进度跟踪和日志记录
- ✅ 生成详细的处理报告
- ✅ 支持从命令行或文件读取项目ID
## 文件说明
- `batch-export.js` - JavaScript版本的主脚本推荐使用
- `batch-video-export.ts` - TypeScript版本的主脚本
- `batch-config.example.env` - 配置文件示例
- `projects.example.txt` - 项目ID列表文件示例
## 快速开始
### 1. 配置环境变量
复制配置文件并填入实际值:
```bash
cp scripts/batch-config.example.env scripts/batch-config.env
```
编辑 `batch-config.env` 文件:
```env
# API配置
API_BASE_URL=https://your-api-domain.com
AUTH_TOKEN=your-actual-auth-token
USER_ID=your-actual-user-id
# 处理配置
CONCURRENCY=3 # 并发处理数量
MAX_RETRIES=3 # 最大重试次数
RETRY_DELAY=5000 # 重试间隔(毫秒)
# 导出配置
EXPORT_QUALITY=standard # 导出质量: standard | high | ultra
# 输出配置
OUTPUT_DIR=./batch-export-output # 输出目录
```
### 2. 准备项目ID列表
**方法一:命令行参数**
```bash
node scripts/batch-export.js --projects "project-001,project-002,project-003"
```
**方法二:文件列表**
创建项目ID文件
```bash
cp scripts/projects.example.txt scripts/projects.txt
```
编辑 `projects.txt` 文件:
```text
project-001
project-002
project-003
project-004
# project-005 # 注释行会被忽略
```
然后运行:
```bash
node scripts/batch-export.js --file scripts/projects.txt
```
### 3. 运行脚本
加载环境变量并运行:
```bash
# 加载配置文件
source scripts/batch-config.env
# 运行脚本
node scripts/batch-export.js --projects "project-001,project-002"
```
或者一次性运行:
```bash
API_BASE_URL=https://your-api.com AUTH_TOKEN=your-token node scripts/batch-export.js --projects "project-001,project-002"
```
## 使用方法
### 命令行选项
```bash
# 使用项目ID参数
node scripts/batch-export.js --projects "id1,id2,id3"
# 使用文件列表
node scripts/batch-export.js --file projects.txt
```
### 环境变量配置
| 变量名 | 说明 | 默认值 | 必填 |
|--------|------|--------|------|
| `API_BASE_URL` | API基础URL | - | ✅ |
| `AUTH_TOKEN` | 认证Token | - | ✅ |
| `USER_ID` | 用户ID | - | ✅ |
| `CONCURRENCY` | 并发处理数量 | 3 | ❌ |
| `MAX_RETRIES` | 最大重试次数 | 3 | ❌ |
| `RETRY_DELAY` | 重试间隔(毫秒) | 5000 | ❌ |
| `EXPORT_QUALITY` | 导出质量 | standard | ❌ |
| `OUTPUT_DIR` | 输出目录 | ./batch-export-output | ❌ |
## 工作流程
脚本会按以下步骤处理每个项目:
1. **生成剪辑计划**
- 调用 `/edit-plan/generate-by-project` 接口
- 支持自动重试最多10分钟8秒间隔
- 等待剪辑计划生成完成
2. **构建导出请求**
- 解析剪辑计划中的时间线信息
- 构建符合API规范的导出请求数据
- 包含视频片段、字幕、转场等信息
3. **调用导出接口**
- 调用 `/api/export/stream` 流式导出接口
- 实时处理SSE事件流
- 监控导出进度
4. **轮询导出状态**
- 如果SSE未返回完整结果自动轮询进度
- 调用 `/api/export/task/{taskId}/progress` 接口
- 等待导出完成并获取视频URL
## 输出文件
脚本运行后会在输出目录生成以下文件:
- `batch-export-{timestamp}.log` - 详细日志文件
- `batch-report-{timestamp}.json` - 处理结果报告
### 报告格式示例
```json
{
"timestamp": "2023-12-07T10:30:00.000Z",
"config": {
"concurrency": 3,
"maxRetries": 3,
"exportQuality": "standard"
},
"results": {
"total": 5,
"completed": 4,
"failed": 1,
"errors": [
{
"projectId": "project-005",
"error": "剪辑计划生成失败"
}
]
},
"projects": [
{
"projectId": "project-001",
"status": "completed",
"videoUrl": "https://example.com/video1.mp4",
"duration": 125.5
}
]
}
```
## 错误处理
脚本包含完善的错误处理机制:
### 自动重试
- 剪辑计划生成失败最多重试10分钟
- 导出接口调用失败:根据配置重试
- 网络错误:自动重试
### 错误类型
- **剪辑计划生成失败**API返回错误或超时
- **导出接口错误**:请求格式错误或服务器错误
- **网络连接错误**:网络不稳定或服务不可用
- **认证错误**Token无效或过期
### 故障恢复
- 单个项目失败不影响其他项目处理
- 详细错误日志帮助定位问题
- 支持断点续传(可以只处理失败的项目)
## 性能优化
### 并发控制
- 默认并发数为3可根据服务器性能调整
- 避免同时处理太多项目导致服务器压力
### 内存管理
- 流式处理SSE响应避免内存积累
- 及时释放不需要的数据
### 网络优化
- 合理的重试间隔,避免频繁请求
- 长连接处理SSE流
## 故障排除
### 常见问题
1. **认证失败**
```
错误HTTP 401: Unauthorized
解决:检查 AUTH_TOKEN 是否正确
```
2. **API地址错误**
```
错误ENOTFOUND your-api-domain.com
解决:检查 API_BASE_URL 是否正确
```
3. **剪辑计划生成超时**
```
错误获取剪辑计划超时已重试75次
解决:检查项目状态,可能需要更长等待时间
```
4. **Node.js版本问题**
```
错误fetch is not defined
解决:升级到 Node.js 18+ 或安装 node-fetch
```
### 调试技巧
1. **查看详细日志**
```bash
tail -f batch-export-output/batch-export-*.log
```
2. **测试单个项目**
```bash
node scripts/batch-export.js --projects "single-project-id"
```
3. **检查API连通性**
```bash
curl -H "Authorization: Bearer $AUTH_TOKEN" $API_BASE_URL/health
```
## 高级用法
### 自定义配置
可以通过修改脚本中的配置对象来自定义更多选项:
```javascript
const config = {
apiBaseUrl: process.env.API_BASE_URL,
token: process.env.AUTH_TOKEN,
// 自定义超时时间
requestTimeout: 30000,
// 自定义User-Agent
userAgent: 'BatchVideoExporter/1.0',
// 其他配置...
};
```
### 集成到CI/CD
可以将脚本集成到自动化流程中:
```yaml
# GitHub Actions 示例
- name: Batch Export Videos
env:
API_BASE_URL: ${{ secrets.API_BASE_URL }}
AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }}
USER_ID: ${{ secrets.USER_ID }}
run: |
node scripts/batch-export.js --file projects.txt
```
## 注意事项
1. **资源使用**:批量处理会消耗较多服务器资源,建议在低峰期运行
2. **网络稳定性**:确保网络连接稳定,避免长时间处理中断
3. **存储空间**:确保有足够的存储空间保存日志和报告文件
4. **API限制**注意API的调用频率限制避免被限流
5. **数据备份**:重要项目建议先备份,避免处理过程中数据丢失
## 许可证
MIT License

658
scripts/batch-export.js Normal file
View File

@ -0,0 +1,658 @@
#!/usr/bin/env node
/**
* 批量视频导出脚本 - JavaScript版本
* 用于批量处理项目ID生成剪辑计划并调用导出接口
*
* 使用方法:
* node scripts/batch-export.js --projects "project1,project2,project3"
* 或者
* node scripts/batch-export.js --file projects.txt
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
/**
* 批量视频导出处理器
*/
class BatchVideoExporter {
constructor(config) {
this.config = config;
this.projectStatuses = new Map();
this.logFile = path.join(config.outputDir, `batch-export-${Date.now()}.log`);
// 确保输出目录存在
if (!fs.existsSync(config.outputDir)) {
fs.mkdirSync(config.outputDir, { recursive: true });
}
}
/** 记录日志 */
log(message, level = 'info') {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
console.log(logMessage);
fs.appendFileSync(this.logFile, logMessage + '\n');
}
/** HTTP请求封装 */
async makeRequest(endpoint, data = null, method = 'POST') {
return new Promise((resolve, reject) => {
const url = new URL(`${this.config.apiBaseUrl}${endpoint}`);
const isHttps = url.protocol === 'https:';
const httpModule = isHttps ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname + url.search,
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.token}`,
'X-EASE-ADMIN-TOKEN': this.config.token,
}
};
const req = httpModule.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(responseData));
} catch (e) {
resolve(responseData);
}
} else {
reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
}
});
});
req.on('error', (error) => {
reject(error);
});
if (data && method === 'POST') {
req.write(JSON.stringify(data));
}
req.end();
});
}
/** 生成剪辑计划 */
async generateEditPlan(projectId) {
this.log(`开始为项目 ${projectId} 生成剪辑计划...`);
const maxAttempts = 3; // 最多重试3次
const retryDelayMs = Number(this.config.retryDelay || 3000);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
this.log(`项目 ${projectId}: 第${attempt}次尝试获取剪辑计划...`);
const response = await this.makeRequest('/edit-plan/generate-by-project', {
project_id: projectId
});
if (response.code === 0 && response.data && response.data.success && response.data.editing_plan) {
this.log(`项目 ${projectId}: 剪辑计划生成成功`);
return response.data.editing_plan;
}
const errorMsg = response && (response.message || response.msg) ? (response.message || response.msg) : '未知错误';
throw new Error(`剪辑计划生成失败: ${errorMsg}`);
} catch (error) {
if (attempt < maxAttempts) {
this.log(`项目 ${projectId}: 获取剪辑计划失败(第${attempt}次)- ${error.message}${Math.round(retryDelayMs/1000)}秒后重试...`, 'warn');
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
} else {
// 第3次仍失败直接抛出终止该项目后续导出
throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${error.message}`);
}
}
}
// 理论上不会到达这里
throw new Error(`获取剪辑计划失败`);
}
/** 解析时间码为毫秒 */
parseTimecodeToMs(timecode) {
const parts = timecode.split(':');
if (parts.length !== 3) return 0;
const hours = parseInt(parts[0]) || 0;
const minutes = parseInt(parts[1]) || 0;
const secondsParts = parts[2].split('.');
const seconds = parseInt(secondsParts[0]) || 0;
const milliseconds = parseInt((secondsParts[1] || '').padEnd(3, '0').slice(0, 3)) || 0;
return Math.round((hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds);
}
/** 构建导出请求数据 */
buildExportRequest(projectId, editingPlan) {
this.log(`项目 ${projectId}: 构建导出请求数据...`);
const defaultClipDuration = 8000; // 8秒
let currentStartTime = 0;
const videoElements = [];
// 处理剪辑计划中的时间线信息
if (editingPlan.editing_sequence_plans && editingPlan.editing_sequence_plans.length > 0) {
const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips || [];
this.log(`项目 ${projectId}: 使用剪辑计划中的 ${timelineClips.length} 个时间线片段`);
timelineClips.forEach((clip, index) => {
const sequenceStartMs = this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000");
const sourceInMs = this.parseTimecodeToMs(clip.source_in_timecode || "00:00:00.000");
const sourceOutMs = this.parseTimecodeToMs(clip.source_out_timecode || "00:00:08.000");
const clipDurationMs = this.parseTimecodeToMs(clip.clip_duration_in_sequence || "00:00:08.000");
const element = {
id: clip.sequence_clip_id || `video_${index + 1}`,
src: clip.video_url,
start: currentStartTime,
end: currentStartTime + clipDurationMs,
in: sourceInMs,
out: sourceOutMs,
_source_type: (clip.video_url && clip.video_url.startsWith('http')) ? 'remote_url' : 'local'
};
videoElements.push(element);
currentStartTime += clipDurationMs;
});
}
const totalDuration = currentStartTime || defaultClipDuration;
// 处理字幕
const texts = [];
if (editingPlan.finalized_dialogue_track && editingPlan.finalized_dialogue_track.final_dialogue_segments) {
editingPlan.finalized_dialogue_track.final_dialogue_segments.forEach((dialogue, index) => {
texts.push({
id: dialogue.sequence_clip_id || `text_${index + 1}`,
text: dialogue.transcript,
start: this.parseTimecodeToMs(dialogue.start_timecode || "00:00:00.000"),
end: this.parseTimecodeToMs(dialogue.end_timecode || "00:00:02.000"),
style: {
fontFamily: 'Arial',
fontSize: 40,
color: '#FFFFFF',
backgroundColor: 'transparent',
fontWeight: 'normal',
fontStyle: 'normal',
align: 'center',
shadow: true
}
});
});
}
// 构建导出请求
const exportRequest = {
project_id: projectId,
ir: {
width: 1920,
height: 1080,
fps: 30,
duration: totalDuration,
video: videoElements,
texts: texts,
audio: [],
transitions: []
},
options: {
quality: this.config.exportQuality,
codec: 'h264',
subtitleMode: 'hard'
}
};
this.log(`项目 ${projectId}: 导出请求数据构建完成,视频片段: ${videoElements.length}, 字幕片段: ${texts.length}`);
return exportRequest;
}
/** 调用导出流接口 */
async callExportStream(exportRequest, attemptNumber = 1) {
const projectId = exportRequest.project_id;
this.log(`项目 ${projectId}: 开始调用导出流接口(第${attemptNumber}次尝试)...`);
// 使用fetch APINode.js 18+支持)
let fetch;
try {
fetch = globalThis.fetch;
} catch {
// 如果没有fetch使用node-fetch或提示升级Node.js
throw new Error('需要Node.js 18+或安装node-fetch包');
}
const response = await fetch(`${this.config.exportApiBaseUrl}/api/export/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': `Bearer ${this.config.token}`,
'X-EASE-ADMIN-TOKEN': this.config.token,
},
body: JSON.stringify(exportRequest)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`导出接口错误: ${response.status} ${errorText}`);
}
this.log(`项目 ${projectId}: 导出接口调用成功开始处理SSE流...`);
// 处理SSE流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let finalResult = null;
let detectedTaskId = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
// 提取任务ID
if (eventData.export_id || eventData.task_id) {
detectedTaskId = eventData.export_id || eventData.task_id;
}
// 处理不同类型的事件
switch (eventData.type) {
case 'start':
this.log(`项目 ${projectId}: 导出开始 - ${eventData.message}`);
if (eventData.export_id || eventData.task_id) {
detectedTaskId = eventData.export_id || eventData.task_id;
}
break;
case 'progress':
const progressPercent = Math.round((eventData.progress || 0) * 100);
this.log(`项目 ${projectId}: 导出进度 ${progressPercent}% - ${eventData.stage || 'processing'} - ${eventData.message}`);
break;
case 'complete':
this.log(`项目 ${projectId}: 导出完成!`);
finalResult = eventData;
if (detectedTaskId && !finalResult.export_id && !finalResult.task_id) {
finalResult.export_id = detectedTaskId;
}
return finalResult;
case 'error':
throw new Error(`导出失败: ${eventData.message}`);
}
} catch (parseError) {
this.log(`项目 ${projectId}: 解析SSE事件失败: ${line}`, 'warn');
}
}
}
}
} finally {
reader.releaseLock();
}
return finalResult;
}
/** 轮询导出进度 */
async pollExportProgress(taskId, projectId) {
this.log(`项目 ${projectId}: 开始轮询导出进度任务ID: ${taskId}`);
const maxAttempts = 120; // 最多轮询10分钟
let attempts = 0;
while (attempts < maxAttempts) {
try {
// 使用导出API基础URL进行轮询
const progressUrl = `${this.config.exportApiBaseUrl}/api/export/task/${taskId}/progress`;
const progressResponse = await fetch(progressUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.token}`,
'X-EASE-ADMIN-TOKEN': this.config.token,
}
});
if (!progressResponse.ok) {
throw new Error(`进度查询失败: ${progressResponse.status} ${progressResponse.statusText}`);
}
const response = await progressResponse.json();
const { status, progress } = response;
if (status === 'completed') {
this.log(`项目 ${projectId}: 导出任务完成视频URL: ${progress && progress.video_url}`);
return {
task_id: taskId,
status: status,
video_url: progress && progress.video_url,
file_size: progress && progress.file_size,
export_id: progress && progress.export_id
};
} else if (status === 'failed') {
const errorMessage = `导出任务失败: ${(progress && progress.message) || '未知错误'}`;
throw new Error(errorMessage);
} else if (status === 'error') {
const errorMessage = `导出任务错误: ${(progress && progress.message) || '未知错误'}`;
throw new Error(errorMessage);
} else {
const percentage = (progress && progress.percentage) || 0;
const message = (progress && progress.message) || '处理中...';
this.log(`项目 ${projectId}: 导出进度 ${percentage}% - ${message}`);
}
attempts++;
await new Promise(resolve => setTimeout(resolve, 5000)); // 5秒间隔
} catch (error) {
this.log(`项目 ${projectId}: 轮询进度出错: ${error.message}`, 'error');
// 对于明确的失败/错误状态,立即抛出,让上层进行重新导出重试
if (
typeof error?.message === 'string' &&
(error.message.includes('导出任务失败') || error.message.includes('导出任务错误'))
) {
throw error;
}
// 其他网络类错误,继续有限次数的轮询重试
attempts++;
if (attempts >= maxAttempts) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
throw new Error(`轮询超时,已尝试${maxAttempts}`);
}
/** 导出任务处理(包含重试逻辑) */
async processExportWithRetry(exportRequest) {
const projectId = exportRequest.project_id;
const maxExportRetries = 3; // 导出重试3次
for (let attempt = 1; attempt <= maxExportRetries; attempt++) {
try {
this.log(`项目 ${projectId}: 开始第${attempt}次导出尝试...`);
// 1. 调用导出流接口
let exportResult = await this.callExportStream(exportRequest, attempt);
// 2. 如果SSE没有返回完整结果使用轮询
let taskId = null;
if (!exportResult || !exportResult.video_url) {
taskId = (exportResult && exportResult.export_id) || (exportResult && exportResult.task_id);
if (!taskId) {
throw new Error('无法获取任务ID无法轮询进度');
}
try {
exportResult = await this.pollExportProgress(taskId, projectId);
} catch (pollError) {
// 如果轮询过程中发现任务失败,并且还有重试机会,则重新导出
if (pollError.message.includes('导出任务失败') || pollError.message.includes('导出任务错误')) {
this.log(`项目 ${projectId}: 第${attempt}次导出失败 - ${pollError.message}`, 'warn');
if (attempt < maxExportRetries) {
this.log(`项目 ${projectId}: 准备重新导出(剩余${maxExportRetries - attempt}次重试机会)...`);
await new Promise(resolve => setTimeout(resolve, 3000)); // 等待3秒后重试
continue;
} else {
throw new Error(`导出失败,已重试${maxExportRetries}次: ${pollError.message}`);
}
} else {
// 其他错误(如网络错误、超时等)直接抛出
throw pollError;
}
}
}
// 3. 导出成功,返回结果
this.log(`项目 ${projectId}: 第${attempt}次导出尝试成功!`);
return exportResult;
} catch (error) {
// 如果是导出接口调用失败(如网络错误、服务器错误等)
this.log(`项目 ${projectId}: 第${attempt}次导出尝试失败 - ${error.message}`, 'warn');
if (attempt < maxExportRetries) {
this.log(`项目 ${projectId}: 准备重新导出(剩余${maxExportRetries - attempt}次重试机会)...`);
await new Promise(resolve => setTimeout(resolve, 3000)); // 等待3秒后重试
} else {
throw new Error(`导出失败,已重试${maxExportRetries}次: ${error.message}`);
}
}
}
throw new Error(`导出失败,已达到最大重试次数${maxExportRetries}`);
}
/** 处理单个项目 */
async processProject(projectId) {
const status = {
projectId,
status: 'pending',
editPlanGenerated: false,
exportStarted: false,
exportCompleted: false,
attempts: 0,
startTime: Date.now()
};
this.projectStatuses.set(projectId, status);
try {
// 1. 生成剪辑计划
status.status = 'generating_plan';
this.log(`项目 ${projectId}: 开始处理...`);
const editingPlan = await this.generateEditPlan(projectId);
status.editPlanGenerated = true;
this.log(`项目 ${projectId}: 剪辑计划生成完成`);
// 2. 构建导出请求
const exportRequest = this.buildExportRequest(projectId, editingPlan);
// 3. 调用导出接口(包含重试逻辑)
status.status = 'exporting';
status.exportStarted = true;
const exportResult = await this.processExportWithRetry(exportRequest);
// 4. 处理完成
status.status = 'completed';
status.exportCompleted = true;
status.videoUrl = exportResult.video_url;
status.endTime = Date.now();
this.log(`项目 ${projectId}: 处理完成视频URL: ${exportResult.video_url}`);
this.log(`项目 ${projectId}: 总耗时: ${((status.endTime - status.startTime) / 1000).toFixed(2)}`);
} catch (error) {
status.status = 'failed';
status.error = error.message;
status.endTime = Date.now();
this.log(`项目 ${projectId}: 处理失败: ${status.error}`, 'error');
throw error;
}
}
/** 批量处理项目 */
async processProjects(projectIds) {
this.log(`开始批量处理 ${projectIds.length} 个项目...`);
this.log(`配置: 并发数=${this.config.concurrency}, 最大重试=${this.config.maxRetries}`);
const results = {
total: projectIds.length,
completed: 0,
failed: 0,
errors: []
};
// 分批并发处理
for (let i = 0; i < projectIds.length; i += this.config.concurrency) {
const batch = projectIds.slice(i, i + this.config.concurrency);
this.log(`处理第 ${Math.floor(i/this.config.concurrency) + 1} 批,项目: ${batch.join(', ')}`);
const promises = batch.map(async (projectId) => {
let attempts = 0;
while (attempts < this.config.maxRetries) {
try {
await this.processProject(projectId);
results.completed++;
break;
} catch (error) {
attempts++;
const errorMsg = error.message;
if (attempts >= this.config.maxRetries) {
this.log(`项目 ${projectId}: 达到最大重试次数,放弃处理`, 'error');
results.failed++;
results.errors.push({ projectId, error: errorMsg });
} else {
this.log(`项目 ${projectId}: 第${attempts}次尝试失败,${this.config.retryDelay/1000}秒后重试...`, 'warn');
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay));
}
}
}
});
await Promise.all(promises);
}
// 生成处理报告
this.generateReport(results);
}
/** 生成处理报告 */
generateReport(results) {
const reportPath = path.join(this.config.outputDir, `batch-report-${Date.now()}.json`);
const report = {
timestamp: new Date().toISOString(),
config: {
concurrency: this.config.concurrency,
maxRetries: this.config.maxRetries,
exportQuality: this.config.exportQuality
},
results: results,
projects: Array.from(this.projectStatuses.values()).map(status => ({
projectId: status.projectId,
status: status.status,
videoUrl: status.videoUrl,
error: status.error,
duration: status.endTime && status.startTime ?
((status.endTime - status.startTime) / 1000) : null
}))
};
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
this.log(`\n=== 批量处理完成 ===`);
this.log(`总项目数: ${results.total}`);
this.log(`成功: ${results.completed}`);
this.log(`失败: ${results.failed}`);
this.log(`处理报告: ${reportPath}`);
this.log(`详细日志: ${this.logFile}`);
if (results.errors.length > 0) {
this.log(`\n失败项目:`);
results.errors.forEach((error) => {
this.log(` - ${error.projectId}: ${error.error}`, 'error');
});
}
}
}
/** 从命令行参数解析项目ID列表 */
function parseProjectIds() {
const args = process.argv.slice(2);
const projectIds = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--projects' && i + 1 < args.length) {
// 从命令行参数解析
projectIds.push(...args[i + 1].split(',').map(id => id.trim()).filter(Boolean));
} else if (args[i] === '--file' && i + 1 < args.length) {
// 从文件读取
const filePath = args[i + 1];
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
projectIds.push(...content.split('\n').map(id => id.trim()).filter(id => id && !id.startsWith('#')));
} else {
console.error(`文件不存在: ${filePath}`);
process.exit(1);
}
}
}
if (projectIds.length === 0) {
console.error('请提供项目ID列表:');
console.error(' 使用 --projects "id1,id2,id3"');
console.error(' 或者 --file projects.txt');
process.exit(1);
}
return projectIds;
}
/** 主函数 */
async function main() {
try {
// 解析项目ID列表
const projectIds = parseProjectIds();
// 配置参数(可以通过环境变量或配置文件自定义)
const config = {
apiBaseUrl: process.env.API_BASE_URL || 'https://api.video.movieflow.ai',
exportApiBaseUrl: process.env.EXPORT_API_BASE_URL || 'https://smartcut.api.movieflow.ai',
token: process.env.AUTH_TOKEN || 'your-auth-token',
userId: process.env.USER_ID || 'your-user-id',
concurrency: parseInt(process.env.CONCURRENCY || '3'),
maxRetries: parseInt(process.env.MAX_RETRIES || '3'),
retryDelay: parseInt(process.env.RETRY_DELAY || '5000'),
exportQuality: process.env.EXPORT_QUALITY || 'standard',
outputDir: process.env.OUTPUT_DIR || './batch-export-output'
};
console.log(`开始批量处理 ${projectIds.length} 个项目...`);
console.log(`项目列表: ${projectIds.join(', ')}`);
// 创建处理器并执行
const exporter = new BatchVideoExporter(config);
await exporter.processProjects(projectIds);
} catch (error) {
console.error('批量处理失败:', error);
process.exit(1);
}
}
// 运行主函数
if (require.main === module) {
main();
}
module.exports = { BatchVideoExporter };

View File

@ -0,0 +1,44 @@
# 项目ID列表示例文件
# 每行一个项目ID以#开头的行为注释
35abfaba-d5b8-4caf-bbb7-89feaa007415
8013eeb8-6938-43e1-b5e1-91f68d860eb8
30c8582f-5231-4a31-af08-5bba4b6171f3
0cece1ba-ea3b-43cb-a7f2-10ce0de96936
9a0e2fc9-73c0-43b5-b3d2-d6c1491cf9e5
8659867f-0887-4a59-a5e4-22c1db88fff1
f24bc59e-acbe-40a4-9383-c2d6f238475f
530500f8-8320-4bef-a7d8-7f1114c69a16
8d926de6-4089-49a8-a58b-c71c9c0b9e87
c7a08757-8c78-437c-9a75-10298fbd58e3
af559e8b-2c36-4d21-a60d-3f6e8c9ce9a1
2971aaa9-2d9f-46cb-b09b-e3d8ad0ba9de
63a03433-f1df-4e0f-99f3-58933ee7fe8e
92e0944d-183a-4e42-aad1-c54f2a70a29b
160493c9-235b-4d75-ba59-d97cd41d7bff
3ffeffe3-0191-47a8-8112-bda7bac5c983
1b433f0d-bf02-449d-bb51-0b51ee4ffee9
a563afec-afe3-4eca-b347-861bc6e00a82
3d66b6ff-80ec-439b-a0f8-ffcd31663166
1e5d52d3-c3b4-46c1-b555-8d507cd4b81f
ecae91fc-bd4a-4f3c-a086-a2f8970e2fc0
5c6ca83f-3a32-45ff-baad-0a2036bf2d35
8d725266-f62f-4e1e-984a-17414f8ca937
4e200654-5af5-448a-bac2-9f421cde1272
8574e0a4-10b9-494b-ab7f-6544197480d6
4c1182a2-13cb-4422-a551-b89dc2cc1f0c
f42ad2b3-1f29-45b1-9b25-ba3eed23b03c
e923af63-0df2-4609-b3fa-2a19232f26ae
4e468c8b-1ba3-4fa7-bfc9-2d96aff78d32
57a82669-5fcc-4289-be8a-9179cf535aa1
49915888-c999-4d0c-9504-98146ae2fea1
001c33b6-fefb-4807-b0ef-2c332bd881ca
d963c23c-a5b6-4b43-a6f1-7d801ea7bf34
8e879443-1a98-4a1f-811a-4c98cb1d6e60
d291dc06-15de-49d2-a140-6eef8da8de22
2f7b5b56-e20e-4b29-9e09-6ca9b4dcee1b
5ad180ae-c4a6-435a-8f94-2ae0e081c91f
475f90f4-2a02-4e0b-aaa2-eae68ee4c6ac
9d609d66-51d0-4392-9023-96172eaa94ca
3c46b89d-44b1-47fd-ac2a-61c0b439bc27
35be5718-1036-44e3-89a5-d8431bcb3b50

View File

@ -0,0 +1,27 @@
{
"timestamp": "2025-09-28T15:12:31.624Z",
"config": {
"concurrency": 1,
"maxRetries": 3,
"exportQuality": "standard"
},
"results": {
"total": 1,
"completed": 0,
"failed": 1,
"errors": [
{
"projectId": "107c5fcc-8348-4c3b-b9f3-f7474e24295d",
"error": "无法获取任务ID无法轮询进度"
}
]
},
"projects": [
{
"projectId": "107c5fcc-8348-4c3b-b9f3-f7474e24295d",
"status": "failed",
"error": "无法获取任务ID无法轮询进度",
"duration": 6.201
}
]
}

View File

@ -3,6 +3,8 @@
* 访 * 访
*/ */
import { isGAAvailable as checkGAAvailable, gaMeasurementId } from '@/lib/env';
// 扩展全局Window接口 // 扩展全局Window接口
declare global { declare global {
interface Window { interface Window {
@ -74,16 +76,14 @@ const normalizeEventParams = (
* GA是否可用 * GA是否可用
*/ */
export const isGAAvailable = (): boolean => { export const isGAAvailable = (): boolean => {
return typeof window !== 'undefined' && return checkGAAvailable();
typeof window.gtag === 'function' &&
process.env.NEXT_PUBLIC_GA_ENABLED === 'true';
}; };
/** /**
* GA测量ID * GA测量ID
*/ */
export const getGAMeasurementId = (): string => { export const getGAMeasurementId = (): string => {
return process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || 'G-4BDXV6TWF4'; return gaMeasurementId;
}; };
/** /**
@ -113,7 +113,7 @@ export const trackEvent = (
window.gtag('event', eventName, eventParams); window.gtag('event', eventName, eventParams);
// 开发环境下打印日志 // 开发环境下打印日志
if (process.env.NODE_ENV === 'development') { if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.log('GA Event:', eventName, eventParams); console.log('GA Event:', eventName, eventParams);
} }
} catch (error) { } catch (error) {
@ -150,7 +150,7 @@ export const trackPageView = (
window.gtag('config', getGAMeasurementId(), pageParams); window.gtag('config', getGAMeasurementId(), pageParams);
// 开发环境下打印日志 // 开发环境下打印日志
if (process.env.NODE_ENV === 'development') { if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
console.log('GA Page View:', pagePath, pageParams); console.log('GA Page View:', pagePath, pageParams);
} }
} catch (error) { } catch (error) {

View File

@ -3,10 +3,7 @@
* Next.js * Next.js
*/ */
/** import { isDevelopment } from '@/lib/env';
*
*/
export const isDevelopment = process.env.NODE_ENV === 'development';
/** /**
* *

View File

@ -1,6 +1,7 @@
import { notification } from 'antd'; import { notification } from 'antd';
import { downloadVideo } from './tools'; import { downloadVideo } from './tools';
import { getGenerateEditPlan } from '@/api/video_flow'; import { getGenerateEditPlan } from '@/api/video_flow';
import { cutUrl } from '@/lib/env';
/** /**
* - * -
@ -115,7 +116,7 @@ export class VideoExportService {
this.config = { this.config = {
maxRetries: config.maxRetries || 3, maxRetries: config.maxRetries || 3,
pollInterval: config.pollInterval || 5000, // 5秒轮询 pollInterval: config.pollInterval || 5000, // 5秒轮询
apiBaseUrl: process.env.NEXT_PUBLIC_CUT_URL || 'https://smartcut.api.movieflow.ai' apiBaseUrl: cutUrl
}; };
} }
@ -857,7 +858,7 @@ export class VideoExportService {
export const videoExportService = new VideoExportService({ export const videoExportService = new VideoExportService({
maxRetries: 3, maxRetries: 3,
pollInterval: 5000, // 5秒轮询间隔 pollInterval: 5000, // 5秒轮询间隔
// apiBaseUrl 使用环境变量 NEXT_PUBLIC_CUT_URL,在构造函数中处理 // apiBaseUrl 使用统一配置中的 cutUrl,在构造函数中处理
}); });
/** /**