video-flow-b/scripts/batch-export.js
2025-09-29 10:48:25 +08:00

607 lines
20 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 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 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') {
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 };