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 โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• `); });