video-flow-b/utils/export-service.ts
2025-09-20 13:58:02 +08:00

879 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { notification } from 'antd';
import { downloadVideo } from './tools';
import { getGenerateEditPlan } from '@/api/video_flow';
/**
* 导出服务 - 封装视频导出相关功能
* 支持流式导出、进度轮询、失败重试机制
*/
// 导出请求接口
interface ExportRequest {
project_id: string;
ir: IRData;
options: ExportOptions;
}
// IR数据结构
interface IRData {
width: number;
height: number;
fps: number;
duration: number;
video: VideoElement[];
texts: TextElement[];
audio: any[];
transitions: TransitionElement[];
}
// 视频元素结构
interface VideoElement {
id: string;
src: string;
start: number;
end: number;
in: number;
out: number;
_source_type: 'remote_url' | 'local';
}
// 文本元素结构
interface TextElement {
id: string;
text: string;
start: number;
end: number;
style: {
fontFamily: string;
fontSize: number;
color: string;
backgroundColor: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
align: 'left' | 'center' | 'right';
shadow: boolean;
};
}
// 转场元素结构
interface TransitionElement {
id: string;
type: string;
duration: number;
start: number;
end: number;
}
// 导出选项
interface ExportOptions {
quality: 'preview' | 'standard' | 'professional';
codec: string;
subtitleMode: 'hard' | 'soft';
}
// 导出进度状态类型
type ExportStatus = 'processing' | 'completed' | 'failed';
// 导出进度回调接口
interface ExportProgressCallback {
onProgress?: (data: {
status: ExportStatus;
percentage: number;
message: string;
stage?: string;
taskId?: string;
}) => void;
}
// 导出服务配置
interface ExportServiceConfig {
maxRetries?: number;
pollInterval?: number;
apiBaseUrl?: string;
}
// 导出结果
interface ExportResult {
task_id: string;
status: string;
video_url?: string;
file_size?: number;
export_id?: string;
quality_mode?: string;
watermark_status?: string;
upload_time?: string;
}
/**
* 视频导出服务类
*/
export class VideoExportService {
private config: Required<ExportServiceConfig>;
private cachedExportRequest: ExportRequest | null = null;
constructor(config: ExportServiceConfig = {}) {
this.config = {
maxRetries: config.maxRetries || 3,
pollInterval: config.pollInterval || 5000, // 5秒轮询
apiBaseUrl: process.env.NEXT_PUBLIC_CUTAPI_URL || 'https://smartcut.api.movieflow.ai'
};
}
/**
* 辅助函数:将时间码转换为毫秒
*/
private parseTimecodeToMs(timecode: string): number {
// 处理两种时间码格式:
// 1. "00:00:08.000" (时:分:秒.毫秒) - 剪辑计划格式
// 2. "00:00:08:00" (时:分:秒:帧) - 传统时间码格式
if (timecode.includes('.')) {
// 格式: "00:00:08.000"
const [timePart, msPart] = timecode.split('.');
const [hours, minutes, seconds] = timePart.split(':').map(Number);
const milliseconds = parseInt(msPart.padEnd(3, '0').slice(0, 3));
return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;
} else {
// 格式: "00:00:08:00" (时:分:秒:帧)
const parts = timecode.split(':');
if (parts.length !== 4) return 0;
const hours = parseInt(parts[0]) || 0;
const minutes = parseInt(parts[1]) || 0;
const seconds = parseInt(parts[2]) || 0;
const frames = parseInt(parts[3]) || 0;
// 假设30fps
const totalSeconds = hours * 3600 + minutes * 60 + seconds + frames / 30;
return Math.round(totalSeconds * 1000);
}
}
/**
* 获取剪辑计划(带重试机制)
* 8秒重试一次最长重试10分钟直到成功
*/
private async getEditingPlanWithRetry(episodeId: string, progressCallback?: ExportProgressCallback['onProgress']): Promise<any> {
const maxRetryTime = 10 * 60 * 1000; // 10分钟
const retryInterval = 8 * 1000; // 8秒
const maxAttempts = Math.floor(maxRetryTime / retryInterval); // 75次
console.log('🎬 开始获取剪辑计划(带重试机制)...');
console.log(`⏰ 重试配置: ${retryInterval/1000}秒间隔,最多${maxAttempts}次,总时长${maxRetryTime/1000/60}分钟`);
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
try {
console.log(`🔄 第${attempts}次尝试获取剪辑计划...`);
// 触发进度回调
if (progressCallback) {
// 剪辑计划获取占总进度的80%,因为可能需要很长时间
const progressPercent = Math.min(Math.round((attempts / maxAttempts) * 80), 75);
progressCallback({
status: 'processing',
percentage: progressPercent,
message: `正在获取剪辑计划... (第${attempts}次尝试,预计${Math.ceil((maxAttempts - attempts) * retryInterval / 1000 / 60)}分钟)`,
stage: 'fetching_editing_plan'
});
}
const editPlanResponse = await getGenerateEditPlan({ project_id: episodeId });
if (editPlanResponse.successful && editPlanResponse.data.editing_plan) {
console.log(`✅ 第${attempts}次尝试成功获取剪辑计划`);
return editPlanResponse.data.editing_plan;
} else {
console.log(`⚠️ 第${attempts}次尝试失败: ${editPlanResponse.message || '剪辑计划未生成'}`);
if (attempts >= maxAttempts) {
throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${editPlanResponse.message}`);
}
console.log(`${retryInterval/1000}秒后进行第${attempts + 1}次重试...`);
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
} catch (error) {
console.log(`❌ 第${attempts}次尝试出现错误:`, error);
if (attempts >= maxAttempts) {
throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${error instanceof Error ? error.message : '未知错误'}`);
}
console.log(`${retryInterval/1000}秒后进行第${attempts + 1}次重试...`);
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
throw new Error(`获取剪辑计划超时,已重试${maxAttempts}`);
}
/**
* 基于剪辑计划生成导出数据
*/
private async generateExportDataFromEditingPlan(
episodeId: string,
taskObject: any,
progressCallback?: ExportProgressCallback['onProgress']
): Promise<{
exportRequest: ExportRequest;
editingPlan: any;
}> {
try {
// 1. 首先获取剪辑计划(带重试机制)
const editingPlan = await this.getEditingPlanWithRetry(episodeId, progressCallback);
console.log('📋 最终获取到剪辑计划:', editingPlan);
// 2. 检查是否有可用的视频数据
if (!taskObject.videos?.data || taskObject.videos.data.length === 0) {
throw new Error('没有可用的视频数据');
}
// 3. 过滤出已完成的视频
const completedVideos = taskObject.videos.data.filter((video: any) =>
video.video_status === 1 && video.urls && video.urls.length > 0
);
if (completedVideos.length === 0) {
throw new Error('没有已完成的视频片段');
}
console.log(`📊 找到 ${completedVideos.length} 个已完成的视频片段`);
// 4. 根据剪辑计划转换视频数据 - 符合API文档的VideoElement格式
const defaultClipDuration = 8000; // 默认8秒每个片段毫秒
let currentStartTime = 0; // 当前时间轴位置
// 构建视频元素数组 - 严格按照API文档的VideoElement结构
let videoElements: VideoElement[];
if (editingPlan.editing_sequence_plans && editingPlan.editing_sequence_plans.length > 0) {
// 使用剪辑计划中的时间线信息
const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips || [];
console.log('🎞️ 使用剪辑计划中的时间线信息:', timelineClips);
videoElements = timelineClips.map((clip: any, index: number) => {
// 查找对应的视频数据
const matchedVideo = completedVideos.find((video: any) =>
video.video_id === clip.source_clip_id ||
video.urls?.some((url: string) => url === clip.video_url)
);
// 优先使用剪辑计划中的video_url其次使用匹配视频的URL
const videoUrl = clip.video_url || matchedVideo?.urls?.[0];
// 解析剪辑计划中的精确时间码
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");
console.log(`🎬 处理片段 ${clip.sequence_clip_id}:`, {
video_url: videoUrl,
sequence_start: sequenceStartMs,
source_in: sourceInMs,
source_out: sourceOutMs,
duration: clipDurationMs
});
// 严格按照API文档的VideoElement结构
const element: VideoElement = {
id: clip.sequence_clip_id || matchedVideo?.video_id || `video_${index + 1}`,
src: videoUrl,
start: currentStartTime, // 在时间轴上的开始时间
end: currentStartTime + clipDurationMs, // 在时间轴上的结束时间
in: sourceInMs, // 视频内部开始时间
out: sourceOutMs, // 视频内部结束时间
_source_type: videoUrl?.startsWith('http') ? 'remote_url' : 'local'
};
currentStartTime += clipDurationMs;
return element;
});
} else {
// 如果没有具体的时间线信息,使用视频数据生成
console.log('📹 使用视频数据生成时间线');
videoElements = completedVideos.map((video: any, index: number) => {
const videoUrl = video.urls![0];
// 严格按照API文档的VideoElement结构
const element: VideoElement = {
id: video.video_id || `video_${index + 1}`,
src: videoUrl,
start: currentStartTime,
end: currentStartTime + defaultClipDuration, // 添加end字段
in: 0,
out: defaultClipDuration,
_source_type: videoUrl.startsWith('http') ? 'remote_url' : 'local'
};
currentStartTime += defaultClipDuration;
return element;
});
}
const totalDuration = currentStartTime;
// 处理转场效果
const transitions: TransitionElement[] = [];
if (editingPlan.editing_sequence_plans?.[0]?.timeline_clips) {
const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips;
for (let i = 0; i < timelineClips.length; i++) {
const clip = timelineClips[i];
if (clip.transition_from_previous && i > 0) {
const transition: TransitionElement = {
id: `transition_${i}`,
type: clip.transition_from_previous.transition_type || 'Cut',
duration: clip.transition_from_previous.transition_duration_ms || 0,
start: this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000") - (clip.transition_from_previous.transition_duration_ms || 0),
end: this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000")
};
transitions.push(transition);
}
}
}
// 处理字幕/对话轨道
const texts: TextElement[] = [];
if (editingPlan.finalized_dialogue_track?.final_dialogue_segments) {
editingPlan.finalized_dialogue_track.final_dialogue_segments.forEach((dialogue: any, index: number) => {
const textElement: TextElement = {
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
}
};
texts.push(textElement);
});
}
// 构建符合API文档的IR数据结构
const irData: IRData = {
width: 1920,
height: 1080,
fps: 30,
duration: totalDuration,
video: videoElements,
texts: texts, // 从剪辑计划中提取的字幕
audio: [], // 可选字段,空数组
transitions: transitions // 从剪辑计划中提取的转场
};
// 构建完整的导出请求数据 - 符合API文档的ExportRequest格式
const exportRequest: ExportRequest = {
project_id: episodeId,
ir: irData,
options: {
quality: 'standard',
codec: 'libx264',
subtitleMode: 'hard'
}
};
return {
exportRequest,
editingPlan: editingPlan // 保存剪辑计划信息用于调试
};
} catch (error) {
console.error('❌ 生成导出数据失败:', error);
throw error;
}
}
/**
* 调用导出流接口的核心函数
*/
private async callExportStreamAPI(exportRequest: ExportRequest, attemptNumber: number = 1): Promise<any> {
console.log(`🚀 第${attemptNumber}次调用流式导出接口...`);
console.log('📋 发送的完整导出请求数据:', JSON.stringify(exportRequest, null, 2));
const response = await fetch(`${this.config.apiBaseUrl}/api/export/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify(exportRequest)
});
console.log('📡 导出接口响应状态:', response.status, response.statusText);
console.log('📋 响应头信息:', Object.fromEntries(response.headers.entries()));
if (!response.ok) {
const errorText = await response.text();
console.error('❌ 导出接口错误响应:', errorText);
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
console.log('✅ 导出接口调用成功开始处理SSE流...');
// 处理SSE流式响应
console.log('📺 开始处理流式响应...');
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let eventCount = 0;
let finalResult = null;
let detectedTaskId = null; // 用于收集任务ID
if (reader) {
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));
eventCount++;
console.log(`📨 SSE事件 #${eventCount}:`, eventData);
// 尝试从任何事件中提取任务ID
if (eventData.export_id || eventData.task_id) {
detectedTaskId = eventData.export_id || eventData.task_id;
console.log('🔍 在SSE事件中发现任务ID:', detectedTaskId);
}
// 处理不同类型的事件按照API文档规范
switch (eventData.type) {
case 'start':
console.log('🚀 导出开始:', eventData.message);
// start事件中可能包含任务ID
if (eventData.export_id || eventData.task_id) {
detectedTaskId = eventData.export_id || eventData.task_id;
console.log('📋 从start事件获取任务ID:', detectedTaskId);
}
break;
case 'progress':
const progressPercent = Math.round((eventData.progress || 0) * 100);
console.log(`📊 导出进度: ${progressPercent}% - ${eventData.stage || 'processing'} - ${eventData.message}`);
break;
case 'complete':
console.log('🎉 导出完成!完整事件数据:', JSON.stringify(eventData, null, 2));
finalResult = eventData;
// 确保最终结果包含任务ID
if (detectedTaskId && !finalResult.export_id && !finalResult.task_id) {
finalResult.export_id = detectedTaskId;
console.log('📋 添加检测到的任务ID到完成结果:', detectedTaskId);
}
console.log('✅ 最终SSE结果:', JSON.stringify(finalResult, null, 2));
// 导出完成,退出循环
return finalResult;
case 'error':
throw new Error(`导出失败: ${eventData.message}`);
default:
console.log('📋 其他事件:', eventData);
}
} catch (parseError) {
console.warn('⚠️ 解析SSE事件失败:', line, parseError);
}
}
}
}
} finally {
reader.releaseLock();
}
}
// 如果有检测到的任务ID确保添加到最终结果中
if (detectedTaskId && finalResult && !finalResult.export_id && !finalResult.task_id) {
finalResult.export_id = detectedTaskId;
console.log('📋 将检测到的任务ID添加到最终结果:', detectedTaskId);
}
return finalResult;
}
/**
* 轮询导出进度的函数
* - status: 'completed' 时立即停止轮询并返回结果
* - status: 'failed' 时抛出 EXPORT_FAILED 错误,触发重新调用 api/export/stream
* - 其他状态继续轮询最多轮询10分钟5秒间隔
*/
private async pollExportProgress(taskId: string, progressCallback?: ExportProgressCallback['onProgress']): Promise<ExportResult> {
console.log('🔄 开始轮询导出进度任务ID:', taskId);
const maxAttempts = 120; // 最多轮询10分钟5秒间隔
let attempts = 0;
while (attempts < maxAttempts) {
try {
const progressUrl = `${this.config.apiBaseUrl}/api/export/task/${taskId}/progress`;
console.log(`📊 第${attempts + 1}次查询进度:`, progressUrl);
const response = await fetch(progressUrl);
if (!response.ok) {
throw new Error(`进度查询失败: ${response.status} ${response.statusText}`);
}
const progressData = await response.json();
console.log('📈 进度查询响应状态:', response.status, response.statusText);
console.log('📊 完整进度数据:', JSON.stringify(progressData, null, 2));
// 根据API返回的数据结构处理
const { status, progress } = progressData;
if (status === 'completed') {
console.log('🎉 导出任务完成progress数据:', JSON.stringify(progress, null, 2));
// 触发完成状态回调
if (progressCallback) {
progressCallback({
status: 'completed',
percentage: 100,
message: progress?.message || '导出完成',
stage: 'completed',
taskId
});
}
const completedResult = {
task_id: taskId,
status: status,
video_url: progress?.video_url,
file_size: progress?.file_size,
export_id: progress?.export_id,
quality_mode: progress?.quality_mode,
watermark_status: progress?.watermark_status,
upload_time: progress?.upload_time
};
console.log('✅ 轮询返回的完成结果:', JSON.stringify(completedResult, null, 2));
return completedResult;
} else if (status === 'failed') {
console.log('❌ 导出任务失败,需要重新调用 api/export/stream');
// 触发失败状态回调
if (progressCallback) {
progressCallback({
status: 'failed',
percentage: 0,
message: progress?.message || '导出任务失败',
stage: 'failed',
taskId
});
}
throw new Error(`EXPORT_FAILED: ${progress?.message || '导出任务失败'}`);
} else if (status === 'error') {
throw new Error(`导出任务错误: ${progress?.message || '未知错误'}`);
} else {
// 任务仍在进行中
const percentage = progress?.percentage || 0;
const message = progress?.message || '处理中...';
const stage = progress?.stage || 'processing';
console.log(`⏳ 导出进度: ${percentage}% - ${stage} - ${message}`);
// 触发处理中状态回调
if (progressCallback) {
progressCallback({
status: 'processing',
percentage,
message,
stage,
taskId
});
}
// 等待5秒后继续轮询
await new Promise(resolve => setTimeout(resolve, this.config.pollInterval));
attempts++;
}
} catch (error) {
console.error(`❌ 第${attempts + 1}次进度查询失败:`, error);
attempts++;
// 如果不是最后一次尝试等待5秒后重试
if (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, this.config.pollInterval));
}
}
}
throw new Error('导出进度查询超时,请稍后手动检查');
}
/**
* 主要的导出方法 - 支持重试机制
*/
public async exportVideo(episodeId: string, taskObject: any, progressCallback?: ExportProgressCallback['onProgress']): Promise<ExportResult> {
let currentAttempt = 1;
try {
// 重试循环
while (currentAttempt <= this.config.maxRetries) {
try {
// 第一步:获取剪辑计划(只在第一次尝试时获取,或缓存不存在时重新获取)
let exportRequest: ExportRequest;
if (currentAttempt === 1 || !this.cachedExportRequest) {
console.log('🎬 步骤1: 获取剪辑计划...');
try {
const { exportRequest: generatedExportRequest, editingPlan: generatedEditingPlan } = await this.generateExportDataFromEditingPlan(episodeId, taskObject, progressCallback);
exportRequest = generatedExportRequest;
console.log('📤 生成的导出请求数据:', exportRequest);
console.log(`📊 包含 ${exportRequest.ir.video.length} 个视频片段,总时长: ${exportRequest.ir.duration}ms`);
console.log('🎬 使用的剪辑计划:', generatedEditingPlan);
// 缓存exportRequest以便重试时使用
this.cachedExportRequest = exportRequest;
} catch (editPlanError) {
console.error('❌ 获取剪辑计划失败,无法继续导出:', editPlanError);
// 剪辑计划获取失败是致命错误,直接抛出,不进行导出重试
throw editPlanError;
}
} else {
// 重试时使用缓存的请求数据
exportRequest = this.cachedExportRequest;
console.log(`🔄 第${currentAttempt}次重试,使用缓存的导出请求数据`);
}
// 第二步:调用导出接口
console.log(`🚀 步骤2: 第${currentAttempt}次调用流式导出接口...`);
const result = await this.callExportStreamAPI(exportRequest, currentAttempt);
console.log('✅ 导出接口调用成功');
console.log('🔍 SSE最终结果详情:', JSON.stringify(result, null, 2));
// 尝试获取任务ID进行轮询
let taskId = null;
// 方法1: 从SSE结果中获取
if (result?.export_id || result?.task_id) {
taskId = result.export_id || result.task_id;
console.log('📋 从SSE结果中获取到任务ID:', taskId);
}
// 如果没有任务ID无法进行轮询
if (!taskId) {
console.log('⚠️ SSE结果中未找到任务ID无法进行进度轮询');
// 显示警告通知 - 已注释
/*
notification.warning({
message: `第${currentAttempt}次导出接口调用成功`,
description: 'SSE流中未找到任务ID无法进行进度轮询。请检查API返回数据结构。',
placement: 'topRight',
duration: 8
});
*/
// 如果SSE中直接有完整结果直接处理
if (result?.download_url || result?.video_url) {
const downloadUrl = result.download_url || result.video_url;
console.log('📥 直接从SSE结果下载视频:', downloadUrl);
await downloadVideo(downloadUrl);
// notification.success({
// message: '视频下载完成!',
// description: result?.file_size
// ? `文件大小: ${(result.file_size / 1024 / 1024).toFixed(2)}MB`
// : '视频已成功下载到本地',
// placement: 'topRight',
// duration: 8
// });
}
return result;
}
// 如果有任务ID开始轮询进度
console.log('🔄 开始轮询导出进度任务ID:', taskId);
try {
const finalExportResult = await this.pollExportProgress(taskId, progressCallback);
// 导出成功
console.log('🎉 导出成功完成!');
console.log('📋 轮询最终结果:', JSON.stringify(finalExportResult, null, 2));
// 显示最终成功通知 - 已注释
/*
notification.success({
message: `导出成功!(第${currentAttempt}次尝试)`,
description: `文件大小: ${(finalExportResult.file_size! / 1024 / 1024).toFixed(2)}MB正在下载到本地...`,
placement: 'topRight',
duration: 8
});
*/
// 自动下载视频
if (finalExportResult.video_url) {
console.log('📥 开始下载视频:', finalExportResult.video_url);
console.log('📋 视频文件信息:', {
url: finalExportResult.video_url,
file_size: finalExportResult.file_size,
quality_mode: finalExportResult.quality_mode
});
await downloadVideo(finalExportResult.video_url);
console.log('✅ 视频下载完成');
}
// 清除缓存的请求数据
this.cachedExportRequest = null;
return finalExportResult;
} catch (pollError) {
console.error(`❌ 第${currentAttempt}次轮询进度失败:`, pollError);
// 检查是否是导出失败错误(需要重新调用 api/export/stream
const isExportFailed = pollError instanceof Error && pollError.message.startsWith('EXPORT_FAILED:');
if (isExportFailed) {
console.log(`❌ 第${currentAttempt}次导出任务失败status: 'failed'),需要重新调用 api/export/stream`);
// 如果还有重试次数,继续重试
if (currentAttempt < this.config.maxRetries) {
console.log(`🔄 准备第${currentAttempt + 1}次重试(重新调用 api/export/stream...`);
// notification.warning({
// message: `第${currentAttempt}次导出失败`,
// description: `导出状态: failed。正在准备第${currentAttempt + 1}次重试...`,
// placement: 'topRight',
// duration: 5
// });
currentAttempt++;
continue; // 继续重试循环,重新调用 api/export/stream
} else {
// 已达到最大重试次数
throw new Error(`导出失败,已重试${this.config.maxRetries}次。最后状态: failed`);
}
} else {
// 其他轮询错误(网络错误等)
if (currentAttempt < this.config.maxRetries) {
console.log(`🔄 轮询失败,准备第${currentAttempt + 1}次重试...`);
// notification.warning({
// message: `第${currentAttempt}次轮询失败`,
// description: `${pollError instanceof Error ? pollError.message : '未知错误'}。正在准备第${currentAttempt + 1}次重试...`,
// placement: 'topRight',
// duration: 5
// });
currentAttempt++;
continue; // 继续重试循环
} else {
// 已达到最大重试次数回退到SSE结果
console.log('❌ 已达到最大重试次数回退到SSE结果');
// notification.error({
// message: '轮询重试失败',
// description: `已重试${this.config.maxRetries}次仍然失败。${pollError instanceof Error ? pollError.message : '未知错误'}`,
// placement: 'topRight',
// duration: 10
// });
// 回退到SSE结果
if (result?.download_url || result?.video_url) {
const downloadUrl = result.download_url || result.video_url;
console.log('📥 回退到SSE结果下载视频:', downloadUrl);
await downloadVideo(downloadUrl);
}
// 清除缓存的请求数据
this.cachedExportRequest = null;
throw pollError;
}
}
}
} catch (attemptError) {
console.error(`❌ 第${currentAttempt}次尝试失败:`, attemptError);
// 如果还有重试次数,继续重试
if (currentAttempt < this.config.maxRetries) {
console.log(`🔄 第${currentAttempt}次尝试失败,准备第${currentAttempt + 1}次重试...`);
// notification.warning({
// message: `第${currentAttempt}次导出尝试失败`,
// description: `${attemptError instanceof Error ? attemptError.message : '未知错误'}。正在准备第${currentAttempt + 1}次重试...`,
// placement: 'topRight',
// duration: 5
// });
currentAttempt++;
continue; // 继续重试循环
} else {
// 已达到最大重试次数
throw attemptError;
}
}
}
// 如果退出循环还没有成功,抛出错误
throw new Error(`导出失败,已重试${this.config.maxRetries}`);
} catch (error) {
console.error('❌ 视频导出最终失败:', error);
// 清除缓存的请求数据
this.cachedExportRequest = null;
// 显示最终错误通知 - 已注释
/*
notification.error({
message: '视频导出失败',
description: `经过${this.config.maxRetries}次尝试后仍然失败:${error instanceof Error ? error.message : '未知错误'}`,
placement: 'topRight',
duration: 10
});
*/
throw error;
}
}
}
// 创建默认的导出服务实例
export const videoExportService = new VideoExportService({
maxRetries: 3,
pollInterval: 5000, // 5秒轮询间隔
// apiBaseUrl 使用环境变量 NEXT_PUBLIC_CUT_URL在构造函数中处理
});
/**
* 便捷的导出函数
*/
export async function exportVideoWithRetry(
episodeId: string,
taskObject: any,
progressCallback?: ExportProgressCallback['onProgress']
): Promise<ExportResult> {
return videoExportService.exportVideo(episodeId, taskObject, progressCallback);
}
/**
* 测试轮询逻辑的函数(开发调试用)
*/
export async function testPollingLogic(taskId: string): Promise<ExportResult> {
console.log('🧪 测试轮询逻辑任务ID:', taskId);
return videoExportService['pollExportProgress'](taskId);
}