forked from 77media/video-flow
批量处理未剪辑的视频
This commit is contained in:
parent
967fe95ccc
commit
88fa136ad7
309
scripts/README.md
Normal file
309
scripts/README.md
Normal file
@ -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
|
||||||
606
scripts/batch-export.js
Normal file
606
scripts/batch-export.js
Normal file
@ -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 };
|
||||||
74
scripts/projects.example.txt
Normal file
74
scripts/projects.example.txt
Normal file
@ -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
|
||||||
|
|
||||||
27
test-output/batch-report-1759072351624.json
Normal file
27
test-output/batch-report-1759072351624.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user