./kaisetsu-app/src/app/api/export/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, unlink, readFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface NarrationBlock {
id: string;
startTime: number;
audioData: string; // Base64 encoded audio
}
interface ExportRequest {
videoData: string; // Base64 encoded video
narrations: NarrationBlock[];
format: 'video' | 'audio-only';
includeOriginalAudio: boolean;
}
async function checkFFmpeg(): Promise<boolean> {
try {
await execAsync('which ffmpeg');
return true;
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
const tempDir = join(tmpdir(), `export-${Date.now()}`);
const tempFiles: string[] = [];
try {
// Check FFmpeg availability
const hasFFmpeg = await checkFFmpeg();
if (!hasFFmpeg) {
return NextResponse.json(
{ error: 'FFmpeg is not installed on the server' },
{ status: 500 }
);
}
const body: ExportRequest = await request.json();
const { videoData, narrations, format, includeOriginalAudio } = body;
if (!videoData) {
return NextResponse.json({ error: 'No video data provided' }, { status: 400 });
}
// Create temp directory
await mkdir(tempDir, { recursive: true });
// Save original video
const videoBuffer = Buffer.from(videoData, 'base64');
const videoPath = join(tempDir, 'input.mp4');
await writeFile(videoPath, videoBuffer);
tempFiles.push(videoPath);
// Save narration audio files
const narrationFiles: { path: string; startTime: number }[] = [];
for (let i = 0; i < narrations.length; i++) {
const narration = narrations[i];
const audioBuffer = Buffer.from(narration.audioData, 'base64');
const audioPath = join(tempDir, `narration-${i}.mp3`);
await writeFile(audioPath, audioBuffer);
tempFiles.push(audioPath);
narrationFiles.push({ path: audioPath, startTime: narration.startTime });
}
const outputPath = join(tempDir, format === 'video' ? 'output.mp4' : 'output.mp3');
tempFiles.push(outputPath);
if (narrationFiles.length === 0) {
// No narrations, just return original
if (format === 'video') {
const outputBuffer = await readFile(videoPath);
return new NextResponse(outputBuffer, {
headers: {
'Content-Type': 'video/mp4',
'Content-Disposition': 'attachment; filename="output.mp4"',
},
});
} else {
// Extract audio only
await execAsync(`ffmpeg -i "${videoPath}" -vn -acodec libmp3lame -q:a 2 "${outputPath}"`);
const outputBuffer = await readFile(outputPath);
return new NextResponse(outputBuffer, {
headers: {
'Content-Type': 'audio/mpeg',
'Content-Disposition': 'attachment; filename="output.mp3"',
},
});
}
}
// Build FFmpeg filter complex for mixing narrations
let filterComplex = '';
let inputs = `-i "${videoPath}"`;
// Add narration inputs
for (let i = 0; i < narrationFiles.length; i++) {
inputs += ` -i "${narrationFiles[i].path}"`;
}
// Build filter complex
// First, handle original audio
if (includeOriginalAudio) {
filterComplex += '[0:a]volume=1.0[original];';
}
// Add delay to each narration
for (let i = 0; i < narrationFiles.length; i++) {
const delayMs = Math.round(narrationFiles[i].startTime * 1000);
filterComplex += `[${i + 1}:a]adelay=${delayMs}|${delayMs}[nar${i}];`;
}
// Mix all audio streams
let mixInputs = includeOriginalAudio ? '[original]' : '';
for (let i = 0; i < narrationFiles.length; i++) {
mixInputs += `[nar${i}]`;
}
const streamCount = narrationFiles.length + (includeOriginalAudio ? 1 : 0);
filterComplex += `${mixInputs}amix=inputs=${streamCount}:duration=longest:dropout_transition=0[mixed]`;
if (format === 'video') {
// Output video with mixed audio
const cmd = `ffmpeg -y ${inputs} -filter_complex "${filterComplex}" -map 0:v -map "[mixed]" -c:v copy -c:a aac -b:a 192k "${outputPath}"`;
await execAsync(cmd);
const outputBuffer = await readFile(outputPath);
return new NextResponse(outputBuffer, {
headers: {
'Content-Type': 'video/mp4',
'Content-Disposition': 'attachment; filename="output.mp4"',
},
});
} else {
// Output audio only
const cmd = `ffmpeg -y ${inputs} -filter_complex "${filterComplex}" -map "[mixed]" -c:a libmp3lame -q:a 2 "${outputPath}"`;
await execAsync(cmd);
const outputBuffer = await readFile(outputPath);
return new NextResponse(outputBuffer, {
headers: {
'Content-Type': 'audio/mpeg',
'Content-Disposition': 'attachment; filename="output.mp3"',
},
});
}
} catch (error) {
console.error('Export error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Export failed' },
{ status: 500 }
);
} finally {
// Cleanup temp files
for (const file of tempFiles) {
try {
await unlink(file);
} catch {
// Ignore cleanup errors
}
}
try {
await execAsync(`rm -rf "${tempDir}"`);
} catch {
// Ignore cleanup errors
}
}
}