253 lines
8.6 KiB
JavaScript
253 lines
8.6 KiB
JavaScript
import express from 'express';
|
|
import multer from 'multer';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { spawn } from 'child_process';
|
|
import cors from 'cors';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
|
const TEMP_DIR = path.join(DATA_DIR, 'temp');
|
|
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads');
|
|
|
|
// Ensure directories exist
|
|
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
|
|
// Cleanup on startup / rebuild
|
|
console.log('🧹 Cleaning up temporary directories on startup/rebuild...');
|
|
try {
|
|
const cleanFolder = (dir) => {
|
|
if (fs.existsSync(dir)) {
|
|
const files = fs.readdirSync(dir);
|
|
for (const file of files) {
|
|
const curPath = path.join(dir, file);
|
|
if (fs.lstatSync(curPath).isDirectory()) {
|
|
fs.rmSync(curPath, { recursive: true, force: true });
|
|
} else {
|
|
fs.unlinkSync(curPath);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
cleanFolder(TEMP_DIR);
|
|
cleanFolder(UPLOADS_DIR);
|
|
console.log('✅ Temporary directories cleaned successfully.');
|
|
} catch (err) {
|
|
console.error('⚠️ Warning during startup cleanup:', err.message);
|
|
}
|
|
|
|
// Multer Storage Configuration (200MB limit for high-res WAV/FLAC audio files)
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
cb(null, UPLOADS_DIR);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const ext = path.extname(file.originalname);
|
|
const safeName = `${Date.now()}_${Math.random().toString(36).substring(2, 8)}${ext}`;
|
|
cb(null, safeName);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: { fileSize: 200 * 1024 * 1024 } // 200 MB
|
|
});
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Healthcheck endpoint
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
service: 'viz',
|
|
port: PORT,
|
|
uptime: process.uptime(),
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
/**
|
|
* High-Quality Video Render Engine
|
|
* Generates DaVinci-compatible ProRes 4444 with Alpha (.mov), WebM with Alpha, or MP4
|
|
*/
|
|
app.post('/api/render', upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]), async (req, res) => {
|
|
const reqId = Date.now();
|
|
console.log(`[${reqId}] 🎬 New Render Request received`);
|
|
|
|
try {
|
|
if (!req.files || !req.files.audio || !req.files.audio[0]) {
|
|
return res.status(400).send('Audio-Datei fehlt.');
|
|
}
|
|
|
|
const audioFile = req.files.audio[0];
|
|
const coverFile = req.files.cover ? req.files.cover[0] : null;
|
|
const format = req.body.format || 'prores';
|
|
const width = parseInt(req.body.width, 10) || 1920;
|
|
const height = parseInt(req.body.height, 10) || 1080;
|
|
const fps = parseInt(req.body.fps, 10) || 60;
|
|
const title = req.body.title || 'Viz Visualizer';
|
|
const artist = req.body.artist || '';
|
|
|
|
let config = {};
|
|
try {
|
|
config = req.body.config ? JSON.parse(req.body.config) : {};
|
|
} catch (e) {
|
|
console.warn('Could not parse config JSON:', e);
|
|
}
|
|
|
|
const outputExt = format === 'prores' ? 'mov' : (format === 'webm' ? 'webm' : 'mp4');
|
|
const outputFilename = `render_${reqId}.${outputExt}`;
|
|
const outputPath = path.join(TEMP_DIR, outputFilename);
|
|
|
|
console.log(`[${reqId}] Rendering format: ${format.toUpperCase()}, ${width}x${height} @ ${fps}FPS -> ${outputPath}`);
|
|
|
|
// Build FFmpeg Command Arguments
|
|
const ffmpegArgs = [];
|
|
|
|
// Input 1: Audio
|
|
ffmpegArgs.push('-i', audioFile.path);
|
|
|
|
// If cover provided, input 2: Cover image
|
|
let hasCover = false;
|
|
if (coverFile && fs.existsSync(coverFile.path)) {
|
|
ffmpegArgs.push('-loop', '1', '-i', coverFile.path);
|
|
hasCover = true;
|
|
}
|
|
|
|
// Determine Audio-Visualizer filter complex for FFmpeg
|
|
// Generates circular / frequency visualizer with alpha channel
|
|
const barCount = config.barCount || 96;
|
|
const isAlpha = format === 'prores' || format === 'webm';
|
|
|
|
// Complex filter string
|
|
let filterComplex = '';
|
|
|
|
if (isAlpha) {
|
|
// Generate transparent background canvas with audio waves / spectrum
|
|
filterComplex = `
|
|
[0:a]showfreqs=s=${width}x${height}:mode=bar:ascale=log:fscale=log:colors=0x00f0ff|0xff007f:win_size=2048,
|
|
format=yuva444p,
|
|
colorchannelmixer=aa=1.0[v]
|
|
`.replace(/\s+/g, ' ').trim();
|
|
} else {
|
|
// Solid or ambient background
|
|
filterComplex = `
|
|
[0:a]showfreqs=s=${width}x${height}:mode=bar:ascale=log:fscale=log:colors=0x00f0ff|0xff007f:win_size=2048[v]
|
|
`.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
ffmpegArgs.push('-filter_complex', filterComplex);
|
|
ffmpegArgs.push('-map', '[v]', '-map', '0:a');
|
|
|
|
// Codec Configuration
|
|
if (format === 'prores') {
|
|
// Apple ProRes 4444 with Alpha (Lossless Studio Quality for DaVinci Resolve)
|
|
ffmpegArgs.push(
|
|
'-c:v', 'prores_ks',
|
|
'-profile:v', '4',
|
|
'-pix_fmt', 'yuva444p10le',
|
|
'-r', fps.toString(),
|
|
'-c:a', 'pcm_s16le',
|
|
'-shortest',
|
|
'-y',
|
|
outputPath
|
|
);
|
|
} else if (format === 'webm') {
|
|
// VP9 with Alpha channel
|
|
ffmpegArgs.push(
|
|
'-c:v', 'libvpx-vp9',
|
|
'-pix_fmt', 'yuva420p',
|
|
'-b:v', '4M',
|
|
'-r', fps.toString(),
|
|
'-c:a', 'libopus',
|
|
'-shortest',
|
|
'-y',
|
|
outputPath
|
|
);
|
|
} else {
|
|
// MP4 H.264 Video
|
|
ffmpegArgs.push(
|
|
'-c:v', 'libx264',
|
|
'-preset', 'fast',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-crf', '18',
|
|
'-r', fps.toString(),
|
|
'-c:a', 'aac',
|
|
'-b:a', '320k',
|
|
'-shortest',
|
|
'-y',
|
|
outputPath
|
|
);
|
|
}
|
|
|
|
// Execute FFmpeg
|
|
const ffmpegProc = spawn('ffmpeg', ffmpegArgs);
|
|
|
|
let stderrLog = '';
|
|
ffmpegProc.stderr.on('data', (data) => {
|
|
stderrLog += data.toString();
|
|
});
|
|
|
|
ffmpegProc.on('close', (code) => {
|
|
if (code === 0 && fs.existsSync(outputPath)) {
|
|
console.log(`[${reqId}] ✅ Render finished successfully! Serving ${outputPath}`);
|
|
|
|
// Set response headers for direct download
|
|
const mimeType = format === 'prores' ? 'video/quicktime' : (format === 'webm' ? 'video/webm' : 'video/mp4');
|
|
res.setHeader('Content-Type', mimeType);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${outputFilename}"`);
|
|
|
|
const fileStream = fs.createReadStream(outputPath);
|
|
fileStream.pipe(res);
|
|
|
|
fileStream.on('end', () => {
|
|
// Cleanup temporary files after sending
|
|
try {
|
|
if (fs.existsSync(audioFile.path)) fs.unlinkSync(audioFile.path);
|
|
if (coverFile && fs.existsSync(coverFile.path)) fs.unlinkSync(coverFile.path);
|
|
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
} catch (e) {
|
|
console.warn('Cleanup warning:', e.message);
|
|
}
|
|
});
|
|
} else {
|
|
console.error(`[${reqId}] ❌ FFmpeg failed with exit code ${code}`);
|
|
console.error(stderrLog.slice(-500));
|
|
res.status(500).send(`FFmpeg Render Error: ${stderrLog.slice(-300)}`);
|
|
}
|
|
});
|
|
|
|
ffmpegProc.on('error', (err) => {
|
|
console.error(`[${reqId}] ❌ Failed to start FFmpeg process:`, err);
|
|
res.status(500).send('FFmpeg Prozess konnte nicht gestartet werden: ' + err.message);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error(`[${reqId}] ❌ Server error:`, error);
|
|
res.status(500).send('Interner Serverfehler: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// Start Server
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`
|
|
╔═══════════════════════════════════════════════════════════════╗
|
|
║ VIZ AUDIO VISUALIZER ║
|
|
║ DaVinci Resolve Alpha Edition v1.0.0 ║
|
|
╠═══════════════════════════════════════════════════════════════╣
|
|
║ 🌐 Web Interface: http://localhost:${PORT} ║
|
|
║ 📁 Data Directory: ${DATA_DIR}
|
|
║ 🎬 Render Support: ProRes 4444 Alpha / WebM / MP4 ║
|
|
╚═══════════════════════════════════════════════════════════════╝
|
|
`);
|
|
});
|