video-flow-b/api/export-adapter.ts
2025-10-27 15:48:42 +08:00

437 lines
13 KiB
TypeScript
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.

/**
* 导出API适配器 - 将OpenCut的导出API适配到video-flow-b项目
* 文件路径: video-flow-b/api/export-adapter.ts
*/
import { NextRequest, NextResponse } from "next/server";
import { tmpdir } from "os";
import { join } from "path";
import { promises as fs } from "fs";
/**
* AI剪辑片段数据接口
*/
interface AIClipData {
sequence_clip_id: string;
source_clip_id: string;
video_url: string;
source_in_timecode: string;
source_out_timecode: string;
sequence_start_timecode: string;
clip_duration_in_sequence: string;
corresponding_script_scene_id?: string;
}
/**
* AI导出请求接口
*/
interface AIExportRequest {
clips: AIClipData[];
subtitles?: string;
totalDuration: number;
options: {
quality: 'low' | 'standard' | 'high';
format: 'mp4' | 'webm' | 'mov';
fps?: number;
};
}
/**
* video-flow-b AI剪辑导出API
* 复用OpenCut项目的 /api/export/ai-clips 逻辑
*/
export async function POST(req: NextRequest) {
const encoder = new TextEncoder();
let workDir: string | null = null;
const stream = new ReadableStream({
async start(controller) {
try {
console.log('🚀 video-flow-b AI剪辑导出API启动');
const requestData: AIExportRequest = await req.json();
const { clips, subtitles, totalDuration, options } = requestData;
console.log('=== video-flow-b AI剪辑导出开始 ===');
console.log('Clips count:', clips.length);
console.log('Total duration:', totalDuration, 'seconds');
console.log('Quality:', options.quality);
console.log('First clip URL:', clips[0]?.video_url);
// 发送开始事件
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'starting',
message: '开始video-flow-b AI剪辑导出...',
progress: 0,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
// 创建工作目录
workDir = await fs.mkdtemp(join(tmpdir(), 'video-flow-b-ai-export-'));
console.log('Work directory:', workDir);
// 执行AI剪辑导出
await executeVideoFlowAIExport(clips, subtitles || "", totalDuration, options, workDir, controller, encoder);
// 发送完成事件
const outputPath = join(workDir, 'output.mp4');
const stats = await fs.stat(outputPath);
// 提取导出ID
const exportId = workDir.split('/').pop()?.replace('video-flow-b-ai-export-', '') || '';
console.log('🎉 video-flow-b AI剪辑导出完成:', {
outputPath,
fileSize: stats.size,
exportId,
workDir,
downloadUrl: `/api/video-flow-b/export/download/${exportId}`,
});
// 🚀 新增上传到七牛云复用OpenCut逻辑
let qiniuUrl = null;
try {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'uploading',
message: '正在上传到云存储...',
progress: 0.9,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
// 这里可以调用七牛云上传逻辑
// const qiniuResult = await uploadToQiniu(outputPath, exportId);
// 暂时使用本地URL
qiniuUrl = `/api/video-flow-b/export/download/${exportId}`;
console.log('✅ 视频上传完成:', qiniuUrl);
} catch (error) {
console.warn('⚠️ 视频上传失败:', error);
}
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'completed',
downloadUrl: qiniuUrl || `/api/video-flow-b/export/download/${exportId}`,
fileSize: stats.size,
exportId: exportId,
message: 'video-flow-b AI剪辑导出完成!'
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送完成通知:', controllerError);
}
} catch (error) {
console.error('video-flow-b AI剪辑导出失败:', error);
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: error instanceof Error ? error.message : '未知错误'
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送错误通知:', controllerError);
}
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
/**
* 执行video-flow-b AI剪辑导出
* 复用OpenCut的核心导出逻辑
*/
async function executeVideoFlowAIExport(
clips: AIClipData[],
subtitles: string,
totalDuration: number,
options: any,
workDir: string,
controller: ReadableStreamDefaultController,
encoder: TextEncoder
): Promise<void> {
// 生成字幕文件
if (subtitles) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'preparing',
message: '生成字幕文件...',
progress: 0.1,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
const assPath = join(workDir, 'subtitles.ass');
await fs.writeFile(assPath, subtitles, 'utf8');
console.log('Generated subtitles file:', assPath);
}
// 下载和处理视频片段
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'downloading',
message: '下载video-flow-b视频片段...',
progress: 0.2,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
const processedClips = await downloadAndProcessVideoFlowClips(clips, workDir, controller, encoder);
// 构建FFmpeg命令
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'preparing',
message: '准备视频合成...',
progress: 0.6,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
const ffmpegArgs = await buildVideoFlowFFmpegCommand(processedClips, subtitles, totalDuration, options, workDir);
console.log('=== video-flow-b AI剪辑导出调试信息 ===');
console.log('Processed clips:', processedClips.length);
console.log('Total duration:', totalDuration);
console.log('FFmpeg合成命令: ffmpeg', ffmpegArgs.join(' '));
console.log('========================');
// 执行FFmpeg
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'encoding',
message: '开始视频编码...',
progress: 0.7,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
await executeFFmpegWithProgress(ffmpegArgs, workDir, totalDuration, controller, encoder);
}
/**
* 下载和处理video-flow-b视频片段
*/
async function downloadAndProcessVideoFlowClips(
clips: AIClipData[],
workDir: string,
controller: ReadableStreamDefaultController,
encoder: TextEncoder
): Promise<Array<{
clipPath: string;
duration: number;
startTime: number;
}>> {
const processedClips = [];
for (let i = 0; i < clips.length; i++) {
const clip = clips[i];
const progress = 0.2 + (i / clips.length) * 0.3; // 20-50%
// 检查控制器是否仍然可用
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'downloading',
message: `处理视频片段 ${i + 1}/${clips.length}...`,
progress: progress,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
// 继续处理,但不发送进度更新
}
try {
// 下载视频文件
const response = await fetch(clip.video_url);
if (!response.ok) {
throw new Error(`下载失败: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const clipPath = join(workDir, `clip_${i}.mp4`);
await fs.writeFile(clipPath, Buffer.from(buffer));
// 估算时长实际项目中应该使用ffprobe获取真实时长
const duration = parseTimecode(clip.clip_duration_in_sequence);
const startTime = parseTimecode(clip.sequence_start_timecode);
processedClips.push({
clipPath,
duration,
startTime
});
console.log(`✅ 视频片段 ${i + 1} 处理完成:`, clipPath);
} catch (error) {
console.error(`❌ 视频片段 ${i + 1} 处理失败:`, error);
// 继续处理其他片段
}
}
return processedClips;
}
/**
* 构建video-flow-b FFmpeg命令
*/
async function buildVideoFlowFFmpegCommand(
processedClips: Array<{ clipPath: string; duration: number; startTime: number }>,
subtitles: string,
totalDuration: number,
options: any,
workDir: string
): Promise<string[]> {
const args = ['-y']; // 覆盖输出文件
// 添加输入文件
processedClips.forEach(clip => {
args.push('-i', clip.clipPath);
});
// 构建filter_complex
let filterComplex = '';
// 视频拼接
if (processedClips.length > 1) {
const concatInputs = processedClips.map((_, i) => `[${i}:v][${i}:a]`).join('');
filterComplex += `${concatInputs}concat=n=${processedClips.length}:v=1:a=1[outv][outa]`;
} else {
filterComplex += '[0:v]copy[outv];[0:a]copy[outa]';
}
args.push('-filter_complex', filterComplex);
args.push('-map', '[outv]', '-map', '[outa]');
// 编码设置
args.push('-c:v', 'h264');
args.push('-c:a', 'aac');
args.push('-preset', 'medium');
// 质量设置
switch (options.quality) {
case 'high':
args.push('-crf', '18');
break;
case 'low':
args.push('-crf', '28');
break;
default:
args.push('-crf', '23');
}
// 输出文件
args.push(join(workDir, 'output.mp4'));
return args;
}
/**
* 执行FFmpeg并显示进度
*/
async function executeFFmpegWithProgress(
args: string[],
workDir: string,
totalDuration: number,
controller: ReadableStreamDefaultController,
encoder: TextEncoder
): Promise<void> {
const { spawn } = require('child_process');
return new Promise((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', args, { cwd: workDir });
let stderr = '';
ffmpeg.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
// 解析FFmpeg进度
const timeMatch = stderr.match(/time=(\d+):(\d+):(\d+\.\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseFloat(timeMatch[3]);
const currentTime = hours * 3600 + minutes * 60 + seconds;
const progress = Math.min(0.7 + (currentTime / totalDuration) * 0.25, 0.95);
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'encoding',
message: `视频编码中... ${Math.round(progress * 100)}%`,
progress: progress,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
}
});
ffmpeg.on('close', (code: number) => {
if (code === 0) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
stage: 'encoding',
message: '视频编码完成',
progress: 0.95,
})}\n\n`));
} catch (controllerError) {
console.warn('⚠️ 控制器已关闭,无法发送进度更新:', controllerError);
}
resolve();
} else {
reject(new Error(`FFmpeg failed with code ${code}`));
}
});
ffmpeg.on('error', (error: Error) => {
reject(error);
});
});
}
/**
* 解析时间码为秒数
*/
function parseTimecode(timecode: string): number {
if (!timecode) return 0;
const parts = timecode.split(':');
if (parts.length === 4) {
const hours = parseInt(parts[0]);
const minutes = parseInt(parts[1]);
const seconds = parseInt(parts[2]);
const frames = parseInt(parts[3]);
return hours * 3600 + minutes * 60 + seconds + frames / 30;
}
return 0;
}