forked from 77media/video-flow
607 lines
20 KiB
JavaScript
607 lines
20 KiB
JavaScript
#!/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 };
|