video-flow-b/scripts/batch-export.js
2025-09-29 15:13:36 +08:00

659 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 maxAttempts = 3; // 最多重试3次
const retryDelayMs = Number(this.config.retryDelay || 3000);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
this.log(`项目 ${projectId}: 第${attempt}次尝试获取剪辑计划...`);
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;
}
const errorMsg = response && (response.message || response.msg) ? (response.message || response.msg) : '未知错误';
throw new Error(`剪辑计划生成失败: ${errorMsg}`);
} catch (error) {
if (attempt < maxAttempts) {
this.log(`项目 ${projectId}: 获取剪辑计划失败(第${attempt}次)- ${error.message}${Math.round(retryDelayMs/1000)}秒后重试...`, 'warn');
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
} else {
// 第3次仍失败直接抛出终止该项目后续导出
throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${error.message}`);
}
}
}
// 理论上不会到达这里
throw new Error(`获取剪辑计划失败`);
}
/** 解析时间码为毫秒 */
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, attemptNumber = 1) {
const projectId = exportRequest.project_id;
this.log(`项目 ${projectId}: 开始调用导出流接口(第${attemptNumber}次尝试)...`);
// 使用fetch APINode.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') {
const errorMessage = `导出任务失败: ${(progress && progress.message) || '未知错误'}`;
throw new Error(errorMessage);
} else if (status === 'error') {
const errorMessage = `导出任务错误: ${(progress && progress.message) || '未知错误'}`;
throw new Error(errorMessage);
} 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');
// 对于明确的失败/错误状态,立即抛出,让上层进行重新导出重试
if (
typeof error?.message === 'string' &&
(error.message.includes('导出任务失败') || error.message.includes('导出任务错误'))
) {
throw error;
}
// 其他网络类错误,继续有限次数的轮询重试
attempts++;
if (attempts >= maxAttempts) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
throw new Error(`轮询超时,已尝试${maxAttempts}`);
}
/** 导出任务处理(包含重试逻辑) */
async processExportWithRetry(exportRequest) {
const projectId = exportRequest.project_id;
const maxExportRetries = 3; // 导出重试3次
for (let attempt = 1; attempt <= maxExportRetries; attempt++) {
try {
this.log(`项目 ${projectId}: 开始第${attempt}次导出尝试...`);
// 1. 调用导出流接口
let exportResult = await this.callExportStream(exportRequest, attempt);
// 2. 如果SSE没有返回完整结果使用轮询
let taskId = null;
if (!exportResult || !exportResult.video_url) {
taskId = (exportResult && exportResult.export_id) || (exportResult && exportResult.task_id);
if (!taskId) {
throw new Error('无法获取任务ID无法轮询进度');
}
try {
exportResult = await this.pollExportProgress(taskId, projectId);
} catch (pollError) {
// 如果轮询过程中发现任务失败,并且还有重试机会,则重新导出
if (pollError.message.includes('导出任务失败') || pollError.message.includes('导出任务错误')) {
this.log(`项目 ${projectId}: 第${attempt}次导出失败 - ${pollError.message}`, 'warn');
if (attempt < maxExportRetries) {
this.log(`项目 ${projectId}: 准备重新导出(剩余${maxExportRetries - attempt}次重试机会)...`);
await new Promise(resolve => setTimeout(resolve, 3000)); // 等待3秒后重试
continue;
} else {
throw new Error(`导出失败,已重试${maxExportRetries}次: ${pollError.message}`);
}
} else {
// 其他错误(如网络错误、超时等)直接抛出
throw pollError;
}
}
}
// 3. 导出成功,返回结果
this.log(`项目 ${projectId}: 第${attempt}次导出尝试成功!`);
return exportResult;
} catch (error) {
// 如果是导出接口调用失败(如网络错误、服务器错误等)
this.log(`项目 ${projectId}: 第${attempt}次导出尝试失败 - ${error.message}`, 'warn');
if (attempt < maxExportRetries) {
this.log(`项目 ${projectId}: 准备重新导出(剩余${maxExportRetries - attempt}次重试机会)...`);
await new Promise(resolve => setTimeout(resolve, 3000)); // 等待3秒后重试
} else {
throw new Error(`导出失败,已重试${maxExportRetries}次: ${error.message}`);
}
}
}
throw new Error(`导出失败,已达到最大重试次数${maxExportRetries}`);
}
/** 处理单个项目 */
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;
const exportResult = await this.processExportWithRetry(exportRequest);
// 4. 处理完成
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 };