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'; } // 导出服务配置 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; private cachedExportRequest: ExportRequest | null = null; constructor(config: ExportServiceConfig = {}) { this.config = { maxRetries: config.maxRetries || 3, pollInterval: config.pollInterval || 5000, // 5秒轮询 apiBaseUrl: config.apiBaseUrl || '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); } } /** * 基于剪辑计划生成导出数据 */ private async generateExportDataFromEditingPlan(episodeId: string, taskObject: any): Promise<{ exportRequest: ExportRequest; editingPlan: any; }> { console.log('🎬 开始获取剪辑计划...'); try { // 1. 首先获取剪辑计划 const editPlanResponse = await getGenerateEditPlan({ project_id: episodeId }); if (!editPlanResponse.successful || !editPlanResponse.data.editing_plan) { throw new Error('获取剪辑计划失败: ' + editPlanResponse.message); } const editingPlan = editPlanResponse.data.editing_plan; 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 { 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); if (!response.ok) { const errorText = await response.text(); console.error('❌ 导出接口错误响应:', errorText); throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); } // 处理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('🎉 导出完成!', eventData); finalResult = eventData; // 确保最终结果包含任务ID if (detectedTaskId && !finalResult.export_id && !finalResult.task_id) { finalResult.export_id = detectedTaskId; } // 导出完成,退出循环 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): Promise { 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('📈 进度数据:', progressData); // 根据API返回的数据结构处理 const { status, progress } = progressData; if (status === 'completed') { console.log('🎉 导出任务完成!', progress); return { 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 }; } else if (status === 'failed') { console.log('❌ 导出任务失败,需要重新调用 api/export/stream'); 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}`); // 等待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): Promise { let currentAttempt = 1; try { // 重试循环 while (currentAttempt <= this.config.maxRetries) { try { // 第一步:获取剪辑计划(只在第一次尝试时获取) let exportRequest: ExportRequest; if (currentAttempt === 1) { console.log('🎬 步骤1: 获取剪辑计划...'); const { exportRequest: generatedExportRequest, editingPlan: generatedEditingPlan } = await this.generateExportDataFromEditingPlan(episodeId, taskObject); exportRequest = generatedExportRequest; console.log('📤 生成的导出请求数据:', exportRequest); console.log(`📊 包含 ${exportRequest.ir.video.length} 个视频片段,总时长: ${exportRequest.ir.duration}ms`); console.log('🎬 使用的剪辑计划:', generatedEditingPlan); // 缓存exportRequest以便重试时使用 this.cachedExportRequest = exportRequest; } 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); // 导出成功 console.log('🎉 导出成功完成!'); // 显示最终成功通知 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); 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: 'https://smartcut.api.movieflow.ai' }); /** * 便捷的导出函数 */ export async function exportVideoWithRetry(episodeId: string, taskObject: any): Promise { return videoExportService.exportVideo(episodeId, taskObject); } /** * 测试轮询逻辑的函数(开发调试用) */ export async function testPollingLogic(taskId: string): Promise { console.log('🧪 测试轮询逻辑,任务ID:', taskId); return videoExportService['pollExportProgress'](taskId); }