forked from 77media/video-flow
880 lines
33 KiB
TypeScript
880 lines
33 KiB
TypeScript
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: 'https://smartcut.api.movieflow.ai',
|
||
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);
|
||
}
|