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 }); // Active export sessions and History Persistence const HISTORY_FILE = path.join(DATA_DIR, 'history.json'); const activeSessions = new Map(); function saveHistory() { try { const historyData = {}; for (const [id, sess] of activeSessions.entries()) { const { ffmpegProc, ...sessData } = sess; historyData[id] = sessData; } fs.writeFileSync(HISTORY_FILE, JSON.stringify(historyData)); } catch (err) { console.error('⚠️ Failed to save history:', err.message); } } function loadHistory() { try { if (fs.existsSync(HISTORY_FILE)) { const data = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8')); for (const [id, sess] of Object.entries(data)) { if (!sess.done) { sess.error = 'Abgebrochen durch Server-Neustart'; sess.done = true; sess.currentStatus = 'Fehler'; } activeSessions.set(id, sess); } console.log(`✅ Loaded ${activeSessions.size} past sessions from history.json`); } } catch (err) { console.error('⚠️ Failed to load history:', err.message); } } function cleanupOldRenders() { console.log('🧹 Running 7-day retention cleanup...'); const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const now = Date.now(); let deletedCount = 0; for (const [id, sess] of activeSessions.entries()) { if (now - sess.createdAt > MAX_AGE_MS) { if (sess.formats) { for (const fmt of Object.values(sess.formats)) { if (fmt.path && fs.existsSync(fmt.path)) { try { fs.unlinkSync(fmt.path); } catch (e) {} } } } activeSessions.delete(id); deletedCount++; } } if (deletedCount > 0) { console.log(`🗑️ Deleted ${deletedCount} old sessions.`); saveHistory(); } // Cleanup orphan files in TEMP_DIR and UPLOADS_DIR older than 7 days [TEMP_DIR, UPLOADS_DIR].forEach(dir => { if (fs.existsSync(dir)) { const files = fs.readdirSync(dir); for (const file of files) { const curPath = path.join(dir, file); try { const stats = fs.statSync(curPath); if (now - stats.mtimeMs > MAX_AGE_MS) { if (stats.isDirectory()) { fs.rmSync(curPath, { recursive: true, force: true }); } else { fs.unlinkSync(curPath); } } } catch (e) {} } } }); } loadHistory(); cleanupOldRenders(); setInterval(cleanupOldRenders, 60 * 60 * 1000); // 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'))); // Presets Endpoint app.get('/api/presets', (req, res) => { const presetsDir = path.join(__dirname, 'public', 'presets'); try { const files = fs.readdirSync(presetsDir); const presets = {}; for (const file of files) { if (file.endsWith('.json')) { const key = file.replace('.json', ''); const data = fs.readFileSync(path.join(presetsDir, file), 'utf-8'); try { presets[key] = JSON.parse(data); } catch (e) { console.error(`Error parsing preset ${file}:`, e); } } } res.json(presets); } catch (err) { console.error('Error reading presets:', err); res.status(500).json({}); } }); // Healthcheck endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', service: 'viz', port: PORT, uptime: process.uptime(), timestamp: new Date().toISOString() }); }); // Removed duplicate map init 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 } } function deleteSessionCompletely(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) {} } // Delete output formats (videos) if (sess.formats) { for (const fmt of Object.values(sess.formats)) { if (fmt.path && fs.existsSync(fmt.path)) { try { fs.unlinkSync(fmt.path); } catch (e) {} } } } activeSessions.delete(id); saveHistory(); return true; } return false; } function enforceMaxHistory() { const MAX_HISTORY = 3; // Get all sessions sorted by date (oldest first) const sessions = Array.from(activeSessions.values()).sort((a, b) => a.createdAt - b.createdAt); if (sessions.length > MAX_HISTORY) { // Delete oldest sessions until we have exactly MAX_HISTORY const toDeleteCount = sessions.length - MAX_HISTORY; for (let i = 0; i < toDeleteCount; i++) { console.log(`🧹 Enforcing max history. Deleting oldest render: ${sessions[i].id}`); deleteSessionCompletely(sessions[i].id); } } } // GET history endpoint app.get('/api/export/history', (req, res) => { const history = []; for (const [id, sess] of activeSessions.entries()) { const formatsWithStatus = {}; if (sess.formats) { for (const [k, v] of Object.entries(sess.formats)) { formatsWithStatus[k] = { ...v, ready: !!v.ready }; } } history.push({ id: sess.id, title: sess.title || 'Visualizer', createdAt: sess.createdAt, done: sess.done, error: sess.error, currentStatus: sess.currentStatus, formats: formatsWithStatus, settings: sess.settings }); } // Neueste zuerst history.sort((a, b) => b.createdAt - a.createdAt); res.json(history); }); // DELETE history endpoint app.delete('/api/export/history/:id', (req, res) => { const id = req.params.id; const deleted = deleteSessionCompletely(id); if (deleted) { res.json({ success: true, message: 'Render wurde gelöscht.' }); } else { res.status(404).json({ error: 'Render nicht gefunden.' }); } }); /** * ⚡ 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 videoPathTemp = req.files.video[0].path; const originalName = req.files.video[0].originalname; const isWebmInput = originalName.toLowerCase().endsWith('.webm'); const videoPath = videoPathTemp + (isWebmInput ? '.webm' : '.mp4'); fs.renameSync(videoPathTemp, videoPath); 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'); const formats = { prores: { filename: `${title}_ProRes4444_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), formatId: 'prores' }, webm_alpha: { filename: `${title}_Alpha_DaVinci.webm`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Alpha_DaVinci.webm`), formatId: 'webm_alpha' }, qtrle: { filename: `${title}_Lossless_QTRLE_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Lossless_QTRLE_Alpha.mov`), formatId: 'qtrle' }, mp4: { filename: `${title}_Master.mp4`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), formatId: 'mp4' } }; console.log(`[${sessionId}] ⚡ High-Speed Transcoding starting (Sequential)`); let settings = null; if (req.body.settings) { try { settings = JSON.parse(req.body.settings); } catch (e) { console.warn('Failed to parse settings:', e.message); } } const session = { id: sessionId, title: title, createdAt: Date.now(), videoPath, audioPath, formats, ffmpegProc: null, // No single proc anymore errorLog: '', done: false, error: null, currentStatus: 'Startet...', settings: settings }; activeSessions.set(sessionId, session); saveHistory(); enforceMaxHistory(); const isTopBottom = (req.body.isTopBottom === 'true'); // Run sequentially in background runSequentialEncodes(session, videoPath, audioPath, ['prores', 'webm_alpha', 'mp4'], isTopBottom).catch(err => { console.error(`[${sessionId}] Queue Error:`, err); }); 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 = { prores: { filename: `${title}_ProRes4444_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_ProRes4444_Alpha.mov`), formatId: 'prores' }, webm_alpha: { filename: `${title}_Alpha_DaVinci.webm`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Alpha_DaVinci.webm`), formatId: 'webm_alpha' }, qtrle: { filename: `${title}_Lossless_QTRLE_Alpha.mov`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Lossless_QTRLE_Alpha.mov`), formatId: 'qtrle' }, mp4: { filename: `${title}_Master.mp4`, path: path.join(TEMP_DIR, `${sessionId}_${title}_Master.mp4`), formatId: 'mp4' } }; console.log(`[${sessionId}] 🚀 Starting Render: Live to ProRes ${width}x${height} @ ${fps}FPS`); const ffmpegArgs = [ '-y', '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${width}x${height}`, '-framerate', fps.toString(), '-i', '-', '-i', audioPath, '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'prores_ks', '-profile:v', '4444', '-alpha_bits', '16', '-qscale:v', '11', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', '-c:a', 'pcm_s16le', '-shortest', formats.prores.path ]; const ffmpegProc = spawn('ffmpeg', ffmpegArgs); ffmpegProc.stdin.on('error', (err) => { 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, currentStatus: 'Live Empfang & ProRes Render...' }; activeSessions.set(sessionId, session); saveHistory(); ffmpegProc.on('close', (code) => { if (code !== 0) { session.error = `FFmpeg Fehler beim Live-Render (Code ${code}): ${errorLog.slice(-400)}`; console.error(`[${sessionId}] ❌ Live Render failed:`, session.error); session.done = true; saveHistory(); } else { console.log(`[${sessionId}] ✅ Live Render (ProRes) complete! Starting background queue...`); session.formats.prores.ready = true; saveHistory(); // Start remaining encodes from the ProRes master file using the original audioPath runSequentialEncodes(session, formats.prores.path, audioPath, ['webm_alpha', 'mp4']).catch(err => { console.error(`[${sessionId}] Background Queue Error:`, err); }); } }); ffmpegProc.on('error', (err) => { session.done = true; session.error = err.message; saveHistory(); }); res.json({ sessionId, format }); } catch (err) { console.error('Error starting export:', err); res.status(500).json({ error: err.message }); } }); async function runSequentialEncodes(session, inputVideoPath, inputAudioPath, targetFormats, isTopBottom = false) { for (let i = 0; i < targetFormats.length; i++) { const formatId = targetFormats[i]; const formatInfo = session.formats[formatId]; if (!formatInfo) continue; session.currentStatus = `Rendere ${i+1}/${targetFormats.length}: ${formatId.toUpperCase()}...`; saveHistory(); const ffmpegArgs = ['-y']; if (inputVideoPath.toLowerCase().endsWith('.webm')) { ffmpegArgs.push('-c:v', 'libvpx-vp9'); } ffmpegArgs.push('-i', inputVideoPath); if (inputAudioPath) { ffmpegArgs.push('-i', inputAudioPath); } let mapArgs = []; let filterArgs = []; if (isTopBottom) { if (formatId === 'prores' || formatId === 'webm_alpha') { filterArgs = ['-filter_complex', '[0:v]crop=iw:ih/2:0:0[rgb];[0:v]crop=iw:ih/2:0:ih/2[alpha];[rgb][alpha]alphamerge[rgba]']; mapArgs = ['-map', '[rgba]']; } else if (formatId === 'mp4') { filterArgs = ['-filter_complex', '[0:v]crop=iw:ih/2:0:0[rgb]']; mapArgs = ['-map', '[rgb]']; } } else { mapArgs = ['-map', '0:v:0']; } if (inputAudioPath) { mapArgs.push('-map', '1:a:0'); } if (formatId === 'mp4') { ffmpegArgs.push( ...filterArgs, ...mapArgs, '-c:v', 'libx264', '-crf', '14', '-preset', 'fast', '-pix_fmt', 'yuv420p', '-threads', '0', ...(inputAudioPath ? ['-c:a', 'aac', '-b:a', '320k'] : []), '-shortest', formatInfo.path ); } else if (formatId === 'webm_alpha') { const isInputWebm = inputVideoPath.toLowerCase().endsWith('.webm'); if (isInputWebm && !isTopBottom) { ffmpegArgs.push( ...filterArgs, ...mapArgs, '-c:v', 'copy', ...(inputAudioPath ? ['-c:a', 'libopus', '-b:a', '320k'] : []), '-shortest', formatInfo.path ); } else { ffmpegArgs.push( ...filterArgs, ...mapArgs, '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', '35M', '-minrate', '20M', '-maxrate', '50M', '-quality', 'realtime', '-speed', '6', '-threads', '4', '-row-mt', '1', '-auto-alt-ref', '0', '-c:a', 'libopus', '-b:a', '320k', '-shortest', formatInfo.path ); } } else if (formatId === 'prores') { ffmpegArgs.push( ...filterArgs, ...mapArgs, '-c:v', 'prores_ks', '-profile:v', '4444', '-alpha_bits', '16', '-qscale:v', '18', '-pix_fmt', 'yuva444p10le', '-vendor', 'apl0', '-threads', '0', ...(inputAudioPath ? ['-c:a', 'pcm_s16le'] : []), '-shortest', formatInfo.path ); } else if (formatId === 'qtrle') { ffmpegArgs.push( ...mapArgs, '-c:v', 'qtrle', '-pix_fmt', 'argb', '-threads', '0', ...(inputAudioPath ? ['-c:a', 'pcm_s16le'] : []), '-shortest', formatInfo.path ); } console.log(`[${session.id}] ⏳ Starting sequential task: ${formatId}`); await new Promise((resolve, reject) => { const proc = spawn('ffmpeg', ffmpegArgs); session.ffmpegProc = proc; // Update current process so cleanupSession can kill it if needed let errorLog = ''; proc.stderr.on('data', d => { errorLog += d.toString(); if (errorLog.length > 5000) errorLog = errorLog.slice(-4000); }); proc.on('close', code => { if (code !== 0) { reject(new Error(`Code ${code} during ${formatId}: ${errorLog.slice(-300)}`)); } else { if (session.formats[formatId]) { session.formats[formatId].ready = true; saveHistory(); } resolve(); } }); proc.on('error', err => reject(err)); }); console.log(`[${session.id}] ✅ Sequential task finished: ${formatId}`); } // Clean up original files if they were temporary if (session.videoPath && fs.existsSync(session.videoPath)) { try { fs.unlinkSync(session.videoPath); } catch (e) {} } if (session.audioPath && fs.existsSync(session.audioPath)) { try { fs.unlinkSync(session.audioPath); } catch (e) {} } session.currentStatus = 'Fertig'; session.done = true; saveHistory(); } // 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 }); } res.json({ status: session.done ? 'ready' : 'finalizing', currentStatus: session.currentStatus || 'In Bearbeitung...' }); }); // 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+