#!/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 maxRetryTime = 10 * 60 * 1000; // 10分钟 const retryInterval = 8 * 1000; // 8秒 const maxAttempts = Math.floor(maxRetryTime / retryInterval); let attempts = 0; while (attempts < maxAttempts) { attempts++; try { this.log(`项目 ${projectId}: 第${attempts}次尝试获取剪辑计划...`); 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; } if (attempts >= maxAttempts) { throw new Error(`剪辑计划生成失败: ${response.message || '未知错误'}`); } this.log(`项目 ${projectId}: 第${attempts}次尝试失败,${retryInterval/1000}秒后重试...`); await new Promise(resolve => setTimeout(resolve, retryInterval)); } catch (error) { if (attempts >= maxAttempts) { throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${error.message}`); } this.log(`项目 ${projectId}: 第${attempts}次尝试出现错误: ${error.message}`); await new Promise(resolve => setTimeout(resolve, retryInterval)); } } throw new Error(`获取剪辑计划超时,已重试${maxAttempts}次`); } /** 解析时间码为毫秒 */ 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) { const projectId = exportRequest.project_id; this.log(`项目 ${projectId}: 开始调用导出流接口...`); // 使用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') { throw new Error(`导出任务失败: ${(progress && progress.message) || '未知错误'}`); } else if (status === 'error') { throw new Error(`导出任务错误: ${(progress && progress.message) || '未知错误'}`); } 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'); attempts++; if (attempts >= maxAttempts) { throw error; } await new Promise(resolve => setTimeout(resolve, 5000)); } } throw new Error(`轮询超时,已尝试${maxAttempts}次`); } /** 处理单个项目 */ 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; let exportResult = await this.callExportStream(exportRequest); // 4. 如果SSE没有返回完整结果,使用轮询 if (!exportResult || !exportResult.video_url) { const taskId = (exportResult && exportResult.export_id) || (exportResult && exportResult.task_id); if (taskId) { exportResult = await this.pollExportProgress(taskId, projectId); } else { throw new Error('无法获取任务ID,无法轮询进度'); } } // 5. 处理完成 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 };