From 88fa136ad7853ec123e502f9c5f28e2777f56ab8 Mon Sep 17 00:00:00 2001 From: qikongjian Date: Mon, 29 Sep 2025 10:48:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=A4=84=E7=90=86=E6=9C=AA?= =?UTF-8?q?=E5=89=AA=E8=BE=91=E7=9A=84=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/README.md | 309 ++++++++++ scripts/batch-export.js | 606 ++++++++++++++++++++ scripts/projects.example.txt | 74 +++ test-output/batch-report-1759072351624.json | 27 + 4 files changed, 1016 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/batch-export.js create mode 100644 scripts/projects.example.txt create mode 100644 test-output/batch-report-1759072351624.json diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..507c230 --- /dev/null +++ b/scripts/README.md @@ -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 diff --git a/scripts/batch-export.js b/scripts/batch-export.js new file mode 100644 index 0000000..1bbf8dc --- /dev/null +++ b/scripts/batch-export.js @@ -0,0 +1,606 @@ +#!/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 }; diff --git a/scripts/projects.example.txt b/scripts/projects.example.txt new file mode 100644 index 0000000..5b7ba92 --- /dev/null +++ b/scripts/projects.example.txt @@ -0,0 +1,74 @@ +# 项目ID列表示例文件 +# 每行一个项目ID,以#开头的行为注释 + +107c5fcc-8348-4c3b-b9f3-f7474e24295d +6243a491-d8cd-46cf-8d11-933300f27569 +2b296c0f-dcc3-4141-8977-b9f3579bdb04 +2238ca87-1c6a-484a-9405-9e25d0999072 +481e672d-3a00-454f-bee0-71797d40ea17 +de805e6c-2374-4a5d-9f8a-f0b6baa3fad5 +64f137e0-06e5-4d30-86e7-28c6b8644de1 +8a132503-456a-462f-83e3-367636cd0286 +8d81241b-ae7c-431a-b953-85cfd6af77f3 +98b53d49-a02d-4fed-aa4e-62b6cc45fae8 +f23d558c-6f18-43f3-a373-8e3d1e8d278a +12daaba2-79c0-4fe8-bdc8-5b746b6bee3b +6b7ca8b9-5760-4908-aac6-b7388402652d +19db2b3e-9fb7-408c-90d5-35f9969b3d01 +89c20909-4ebf-4ccc-a5e7-401ef91ef242 +4de9ef61-cefd-4027-b07e-a2520a62f218 +d2e23eaf-e806-44c9-a241-7fc1af53dab4 +64941470-7689-4ea1-872c-672f2b5e4628 +469c5792-aa1a-4489-bfea-779d3782218c +7c55bd8e-8f16-4caa-a001-1a34497a9711 +86dcc7cf-f116-429f-b298-fe0878aa25ef +ded3ed74-b6fa-48fd-ad7e-fbfa717d54f5 +96da7e21-7284-4056-900e-59835e99b651 +892083d2-a7e7-4436-aac9-91cfdf24feae +52543696-fbe0-4fd9-bbe2-ef31e642fb8e +675ae612-d6ac-4017-98a6-ac9c4fd4eac9 +e430dfe1-3cb8-40bd-a6a9-f6b223052ff7 +3b12c5d3-7899-4ef6-97c4-8ff07ee5bc8e +35abfaba-d5b8-4caf-bbb7-89feaa007415 +71d52c66-b83b-4d51-91a2-be26f88f9eba +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 + diff --git a/test-output/batch-report-1759072351624.json b/test-output/batch-report-1759072351624.json new file mode 100644 index 0000000..62f0058 --- /dev/null +++ b/test-output/batch-report-1759072351624.json @@ -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 + } + ] +} \ No newline at end of file