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: 1024 * 1024 * 1024 } // 1 GB for mezzanine video & audio }); app.use(cors()); // Disable request timeouts for large media uploads and FFmpeg transcoding app.use((req, res, next) => { req.setTimeout(0); res.setTimeout(0); next(); }); 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() }); }); // Active export sessions const activeSessions = new Map(); function cleanupSession(id) { const sess = activeSessions.get(id); if (sess) { if (sess.ffmpegProc) { try { sess.ffmpegProc.kill('SIGKILL'); } catch (e) {} } if (sess.audioPath && fs.existsSync(sess.audioPath)) { try { fs.unlinkSync(sess.audioPath); } catch (e) {} } if (sess.videoPath && fs.existsSync(sess.videoPath)) { try { fs.unlinkSync(sess.videoPath); } catch (e) {} } // We intentionally DO NOT delete the output files here, // so they remain available in the history until server restart. // activeSessions.delete(id); // wir behalten die Session im Speicher fΓΌr die Historie } } // GET history endpoint app.get('/api/export/history', (req, res) => { const history = []; for (const [id, sess] of activeSessions.entries()) { history.push({ id: sess.id, title: sess.title || 'Visualizer', createdAt: sess.createdAt, done: sess.done, error: sess.error, formats: sess.formats }); } // Neueste zuerst history.sort((a, b) => b.createdAt - a.createdAt); res.json(history); }); /** * ⚑ Ultra-Fast Mezzanine Transcoding Route (ProRes 4444 / QTRLE / MP4) * Receives compact GPU-rendered video (WebM VP9 Alpha / MP4) and original audio, * and transcodes it on the server with FFmpeg at 60-120+ FPS in seconds! */ app.post('/api/export/transcode', upload.fields([ { name: 'video', maxCount: 1 }, { name: 'audio', maxCount: 1 } ]), async (req, res) => { const sessionId = `transcode_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; try { if (!req.files || !req.files.video || !req.files.video[0]) { return res.status(400).json({ error: 'Video-Datei fehlt fΓΌr Server-Transcoding.' }); } const videoPath = req.files.video[0].path; const audioPath = (req.files.audio && req.files.audio[0]) ? req.files.audio[0].path : null; const format = req.body.format || 'prores'; const title = (req.body.title || 'Visualizer').replace(/[^a-zA-Z0-9_-]/g, '_'); const ext = (format === 'prores' || format === 'qtrle') ? 'mov' : (format === 'webm_alpha' ? 'webm' : 'mp4'); // We will render ALL formats simultaneously const formats = { mp4: { filename: `${title}_Master.mp4`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), formatId: 'mp4' }, webm_alpha: { filename: `${title}_Alpha_DaVinci.webm`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Alpha_DaVinci.webm`), formatId: 'webm_alpha' }, prores: { filename: `${title}_ProRes4444_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), formatId: 'prores' }, qtrle: { filename: `${title}_Lossless_QTRLE_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Lossless_QTRLE_Alpha.mov`), formatId: 'qtrle' } }; console.log(`[${sessionId}] ⚑ High-Speed Transcoding to ALL FORMATS`); const ffmpegArgs = ['-y', '-i', videoPath]; if (audioPath) { ffmpegArgs.push('-i', audioPath); } // Map video and audio to all outputs const mapArgs = audioPath ? ['-map', '0:v:0', '-map', '1:a:0'] : ['-map', '0:v:0']; // 1. MP4 ffmpegArgs.push( ...mapArgs, '-c:v', 'libx264', '-crf', '14', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-threads', '0', ...(audioPath ? ['-c:a', 'aac', '-b:a', '320k'] : []), '-shortest', formats.mp4.path ); // 2. WebM VP9 Alpha ffmpegArgs.push( ...mapArgs, '-c:v', 'copy', ...(audioPath ? ['-c:a', 'libopus', '-b:a', '320k'] : []), '-shortest', formats.webm_alpha.path ); // 3. ProRes 4444 ffmpegArgs.push( ...mapArgs, '-c:v', 'prores_ks', '-profile:v', '4', '-qscale:v', '11', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', ...(audioPath ? ['-c:a', 'pcm_s16le'] : []), '-shortest', formats.prores.path ); // 4. QTRLE ffmpegArgs.push( ...mapArgs, '-c:v', 'qtrle', '-pix_fmt', 'argb', '-threads', '0', ...(audioPath ? ['-c:a', 'pcm_s16le'] : []), '-shortest', formats.qtrle.path ); const ffmpegProc = spawn('ffmpeg', ffmpegArgs); let errorLog = ''; ffmpegProc.stderr.on('data', (d) => { errorLog += d.toString(); if (errorLog.length > 10000) { errorLog = errorLog.slice(-8000); } }); const session = { id: sessionId, title: title, createdAt: Date.now(), videoPath, audioPath, formats, ffmpegProc, errorLog: '', done: false, error: null }; ffmpegProc.on('close', (code, signal) => { session.done = true; if (videoPath && fs.existsSync(videoPath)) { try { fs.unlinkSync(videoPath); } catch (e) {} } if (audioPath && fs.existsSync(audioPath)) { try { fs.unlinkSync(audioPath); } catch (e) {} } if (code !== 0 || signal) { session.error = signal ? `FFmpeg Transcode Prozess wurde durch Signal ${signal} abgebrochen: ${errorLog.slice(-400)}` : `FFmpeg Transcode Fehler (Code ${code}): ${errorLog.slice(-400)}`; console.error(`[${sessionId}] ❌ Transcode failed:`, session.error); } else { console.log(`[${sessionId}] βœ… Transcode complete! (All Formats)`); } }); ffmpegProc.on('error', (err) => { session.done = true; session.error = err.message; }); activeSessions.set(sessionId, session); res.json({ sessionId, format }); } catch (err) { console.error('Error starting transcode:', err); res.status(500).json({ error: err.message }); } }); /** * Lossless Frame-Streaming Video Export Engine * Generates broadcast-grade ProRes 4444 (.mov with alpha), WebM VP9 Alpha, or MP4 */ app.post('/api/export/start', upload.single('audio'), async (req, res) => { const sessionId = `export_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`; try { if (!req.file) { return res.status(400).json({ error: 'Audiodatei fehlt.' }); } const audioPath = req.file.path; const format = req.body.format || 'prores'; // 'prores' | 'webm_alpha' | 'mp4' 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 || 'Visualizer').replace(/[^a-zA-Z0-9_-]/g, '_'); const ext = (format === 'prores' || format === 'qtrle') ? 'mov' : (format === 'webm_alpha' ? 'webm' : 'mp4'); const formats = { mp4: { filename: `${title}_Master.mp4`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), formatId: 'mp4' }, webm_alpha: { filename: `${title}_Alpha_DaVinci.webm`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Alpha_DaVinci.webm`), formatId: 'webm_alpha' }, prores: { filename: `${title}_ProRes4444_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), formatId: 'prores' }, qtrle: { filename: `${title}_Lossless_QTRLE_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Lossless_QTRLE_Alpha.mov`), formatId: 'qtrle' } }; console.log(`[${sessionId}] πŸš€ Starting Render: ALL FORMATS ${width}x${height} @ ${fps}FPS`); const ffmpegArgs = [ '-y', '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${width}x${height}`, '-framerate', fps.toString(), '-i', '-', '-i', audioPath ]; const mapArgs = ['-map', '0:v:0', '-map', '1:a:0']; // 1. MP4 (Hardware accelerated preset if possible, fallback to CPU) ffmpegArgs.push( ...mapArgs, '-c:v', 'libx264', '-crf', '14', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-threads', '0', '-c:a', 'aac', '-b:a', '320k', '-shortest', formats.mp4.path ); // 2. WebM VP9 Alpha (CPU intensive) ffmpegArgs.push( ...mapArgs, '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', '35M', '-minrate', '20M', '-maxrate', '50M', '-speed', '2', '-threads', '8', '-row-mt', '1', '-auto-alt-ref', '0', '-c:a', 'libopus', '-b:a', '320k', '-shortest', formats.webm_alpha.path ); // 3. ProRes 4444 ffmpegArgs.push( ...mapArgs, '-c:v', 'prores_ks', '-profile:v', '4', '-qscale:v', '11', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', '-c:a', 'pcm_s16le', '-shortest', formats.prores.path ); // 4. QTRLE ffmpegArgs.push( ...mapArgs, '-c:v', 'qtrle', '-pix_fmt', 'argb', '-threads', '0', '-c:a', 'pcm_s16le', '-shortest', formats.qtrle.path ); const ffmpegProc = spawn('ffmpeg', ffmpegArgs); ffmpegProc.stdin.on('error', (err) => { // Prevent unhandled EPIPE if FFmpeg closes before stdin is flushed console.warn(`[${sessionId}] Warning on stdin pipe:`, err.message); }); let errorLog = ''; ffmpegProc.stderr.on('data', (d) => { errorLog += d.toString(); }); const session = { id: sessionId, title: title, createdAt: Date.now(), audioPath, formats, ffmpegProc, errorLog: '', done: false, error: null }; ffmpegProc.on('close', (code) => { session.done = true; if (code !== 0) { session.error = `FFmpeg Fehler (Code ${code}): ${errorLog.slice(-400)}`; console.error(`[${sessionId}] ❌ Render failed:`, session.error); } else { console.log(`[${sessionId}] βœ… Render complete! (All Formats)`); } }); ffmpegProc.on('error', (err) => { session.done = true; session.error = err.message; }); activeSessions.set(sessionId, session); res.json({ sessionId, format }); } catch (err) { console.error('Error starting export:', err); res.status(500).json({ error: err.message }); } }); // Stream raw RGBA frame or multi-frame batch app.post(['/api/export/frame/:id', '/api/export/frame-batch/:id'], express.raw({ type: 'application/octet-stream', limit: '500mb' }), (req, res) => { const session = activeSessions.get(req.params.id); if (!session || !session.ffmpegProc || session.done) { return res.status(404).json({ error: 'Session nicht gefunden oder bereits beendet.' }); } try { const ok = session.ffmpegProc.stdin.write(req.body); if (!ok) { session.ffmpegProc.stdin.once('drain', () => { res.json({ status: 'ok' }); }); } else { res.json({ status: 'ok' }); } } catch (err) { res.status(500).json({ error: err.message }); } }); // Finalize export (close stdin stream) app.post('/api/export/finish/:id', (req, res) => { const session = activeSessions.get(req.params.id); if (!session) { return res.status(404).json({ error: 'Session nicht gefunden.' }); } try { if (session.ffmpegProc && !session.ffmpegProc.stdin.destroyed) { session.ffmpegProc.stdin.end(); } res.json({ status: 'finalizing' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Status Polling for Export Finalization app.get('/api/export/status/:id', (req, res) => { const session = activeSessions.get(req.params.id); if (!session) { return res.status(404).json({ error: 'Session nicht gefunden.' }); } if (session.error) { return res.status(500).json({ status: 'error', error: session.error }); } // We can track the size of the mp4 format as a proxy for progress let sizeBytes = 0; try { if (session.formats && session.formats.mp4 && fs.existsSync(session.formats.mp4.path)) { sizeBytes = fs.statSync(session.formats.mp4.path).size; } } catch (e) {} if (session.done) { return res.json({ status: 'ready', sizeBytes: sizeBytes }); } res.json({ status: 'finalizing', sizeBytes: sizeBytes }); }); // Download exported video file for a specific format app.get('/api/export/download/:id/:formatId', (req, res) => { const session = activeSessions.get(req.params.id); const formatId = req.params.formatId; if (!session || !session.formats || !session.formats[formatId] || !fs.existsSync(session.formats[formatId].path)) { return res.status(404).send('Datei nicht gefunden oder abgelaufen.'); } const formatObj = session.formats[formatId]; const stat = fs.statSync(formatObj.path); const mime = formatId === 'prores' || formatId === 'qtrle' ? 'video/quicktime' : (formatId === 'webm_alpha' ? 'video/webm' : 'video/mp4'); res.setHeader('Content-Type', mime); res.setHeader('Content-Length', stat.size); res.setHeader('Content-Disposition', `attachment; filename="${formatObj.filename}"`); const stream = fs.createReadStream(formatObj.path); stream.pipe(res); // Notice: We don't delete the file anymore so it's available in history }); // Cancel export session app.post('/api/export/cancel/:id', (req, res) => { cleanupSession(req.params.id); res.json({ status: 'cancelled' }); }); // Start Server with infinite timeouts for large file uploads & transcoding const server = app.listen(PORT, '0.0.0.0', () => { console.log(` ╔═══════════════════════════════════════════════════════════════╗ β•‘ ORFEL VIZ β•‘ ╠═══════════════════════════════════════════════════════════════╣ β•‘ 🌐 Web Interface: http://localhost:${PORT} β•‘ β•‘ πŸ“ Data Directory: ${DATA_DIR} β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• `); }); // Configure Node.js HTTP Server timeouts (prevents HTTP 408 Request Timeout during uploads) server.timeout = 0; // Disable socket inactivity timeout server.keepAliveTimeout = 1800000; // 30 minutes server.headersTimeout = 1860000; // 31 minutes (must exceed keepAliveTimeout) server.requestTimeout = 0; // 0 disables request timeout in Node.js 18+