forked from 77media/video-flow
Merge branch 'dev' into prod
This commit is contained in:
commit
0ea56ac410
3
.gitignore
vendored
3
.gitignore
vendored
@ -46,3 +46,6 @@ dist
|
|||||||
build_and_copy.log
|
build_and_copy.log
|
||||||
*.log
|
*.log
|
||||||
*.lock
|
*.lock
|
||||||
|
|
||||||
|
# git
|
||||||
|
.github
|
||||||
|
|||||||
@ -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/Serverless(Vercel/Cloudflare)
|
#### Node/Serverless(Vercel/Cloudflare)
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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'
|
|
||||||
//
|
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`, {
|
||||||
|
|||||||
@ -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/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 的类型
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
46
lib/auth.ts
46
lib/auth.ts
@ -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
202
lib/env.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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
1428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -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
309
scripts/README.md
Normal 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
658
scripts/batch-export.js
Normal 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 API(Node.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 };
|
||||||
44
scripts/projects.example.txt
Normal file
44
scripts/projects.example.txt
Normal 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
|
||||||
|
|
||||||
27
test-output/batch-report-1759072351624.json
Normal file
27
test-output/batch-report-1759072351624.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -3,10 +3,7 @@
|
|||||||
* 用于处理 Next.js 开发环境中的常见问题
|
* 用于处理 Next.js 开发环境中的常见问题
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
import { isDevelopment } from '@/lib/env';
|
||||||
* 检测是否为开发环境
|
|
||||||
*/
|
|
||||||
export const isDevelopment = process.env.NODE_ENV === 'development';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安全的组件重渲染函数
|
* 安全的组件重渲染函数
|
||||||
|
|||||||
@ -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,在构造函数中处理
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user