352 lines
11 KiB
JavaScript
352 lines
11 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()
|
|
});
|
|
});
|
|
|
|
// Active frame-by-frame 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) {}
|
|
}
|
|
activeSessions.delete(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
let formatSuffix = 'Visualizer';
|
|
if (format === 'prores') formatSuffix = 'ProRes4444_Alpha';
|
|
else if (format === 'qtrle') formatSuffix = 'Lossless_QTRLE_Alpha';
|
|
else if (format === 'webm_alpha') formatSuffix = 'Alpha_DaVinci';
|
|
|
|
const outputFilename = `${title}_${formatSuffix}.${ext}`;
|
|
const outputPath = path.join(TEMP_DIR, `${sessionId}_${outputFilename}`);
|
|
|
|
console.log(`[${sessionId}] 🚀 Starting Render: ${format.toUpperCase()} ${width}x${height} @ ${fps}FPS -> ${outputFilename}`);
|
|
|
|
const ffmpegArgs = [
|
|
'-y',
|
|
'-f', 'rawvideo',
|
|
'-pix_fmt', 'rgba',
|
|
'-s', `${width}x${height}`,
|
|
'-framerate', fps.toString(),
|
|
'-i', '-',
|
|
'-i', audioPath
|
|
];
|
|
|
|
if (format === 'prores') {
|
|
// Apple ProRes 4444 with True 10-bit Alpha for DaVinci Resolve / Premiere
|
|
ffmpegArgs.push(
|
|
'-c:v', 'prores_ks',
|
|
'-profile:v', '4',
|
|
'-pix_fmt', 'yuva444p10le',
|
|
'-vendor', 'apl0',
|
|
'-threads', '0',
|
|
'-c:a', 'pcm_s16le',
|
|
'-shortest',
|
|
outputPath
|
|
);
|
|
} else if (format === 'qtrle') {
|
|
// QuickTime Animation RLE - 100% Lossless RGBA (Direct DaVinci Resolve native)
|
|
ffmpegArgs.push(
|
|
'-c:v', 'qtrle',
|
|
'-pix_fmt', 'argb',
|
|
'-threads', '0',
|
|
'-c:a', 'pcm_s16le',
|
|
'-shortest',
|
|
outputPath
|
|
);
|
|
} else if (format === 'webm_alpha') {
|
|
// WebM VP9 with True Alpha (Lossless/High-bitrate studio quality)
|
|
ffmpegArgs.push(
|
|
'-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',
|
|
outputPath
|
|
);
|
|
} else {
|
|
// MP4 H.264 (Broadcast quality CRF 14, high profile, yuv420p)
|
|
ffmpegArgs.push(
|
|
'-c:v', 'libx264',
|
|
'-crf', '14',
|
|
'-preset', 'fast',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-threads', '0',
|
|
'-c:a', 'aac',
|
|
'-b:a', '320k',
|
|
'-shortest',
|
|
outputPath
|
|
);
|
|
}
|
|
|
|
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,
|
|
audioPath,
|
|
outputPath,
|
|
outputFilename,
|
|
format,
|
|
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 {
|
|
const sizeMb = fs.existsSync(outputPath) ? (fs.statSync(outputPath).size / 1024 / 1024).toFixed(1) : '0';
|
|
console.log(`[${sessionId}] ✅ Render complete! Output size: ${sizeMb} MB`);
|
|
}
|
|
});
|
|
|
|
ffmpegProc.on('error', (err) => {
|
|
session.done = true;
|
|
session.error = err.message;
|
|
});
|
|
|
|
activeSessions.set(sessionId, session);
|
|
res.json({ sessionId, outputFilename });
|
|
} 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 });
|
|
}
|
|
|
|
let sizeBytes = 0;
|
|
try {
|
|
if (fs.existsSync(session.outputPath)) {
|
|
sizeBytes = fs.statSync(session.outputPath).size;
|
|
}
|
|
} catch (e) {}
|
|
|
|
if (session.done) {
|
|
if (!fs.existsSync(session.outputPath)) {
|
|
return res.status(500).json({ status: 'error', error: 'Ausgabedatei wurde nicht gefunden.' });
|
|
}
|
|
return res.json({
|
|
status: 'ready',
|
|
downloadUrl: `/api/export/download/${session.id}`,
|
|
filename: session.outputFilename,
|
|
sizeBytes: sizeBytes
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
status: 'finalizing',
|
|
sizeBytes: sizeBytes
|
|
});
|
|
});
|
|
|
|
// Download exported video file
|
|
app.get('/api/export/download/:id', (req, res) => {
|
|
const session = activeSessions.get(req.params.id);
|
|
if (!session || !fs.existsSync(session.outputPath)) {
|
|
return res.status(404).send('Datei nicht gefunden oder abgelaufen.');
|
|
}
|
|
|
|
const stat = fs.statSync(session.outputPath);
|
|
const mime = session.format.startsWith('prores') || session.format === 'qtrle'
|
|
? 'video/quicktime'
|
|
: (session.format === 'webm_alpha' ? 'video/webm' : 'video/mp4');
|
|
|
|
res.setHeader('Content-Type', mime);
|
|
res.setHeader('Content-Length', stat.size);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${session.outputFilename}"`);
|
|
|
|
const stream = fs.createReadStream(session.outputPath);
|
|
stream.pipe(res);
|
|
|
|
stream.on('end', () => {
|
|
setTimeout(() => {
|
|
try {
|
|
if (fs.existsSync(session.outputPath)) fs.unlinkSync(session.outputPath);
|
|
cleanupSession(session.id);
|
|
} catch (e) {}
|
|
}, 300000);
|
|
});
|
|
});
|
|
|
|
// Cancel export session
|
|
app.post('/api/export/cancel/:id', (req, res) => {
|
|
cleanupSession(req.params.id);
|
|
res.json({ status: 'cancelled' });
|
|
});
|
|
|
|
// Start Server
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`
|
|
╔═══════════════════════════════════════════════════════════════╗
|
|
║ ORFEL VIZ ║
|
|
╠═══════════════════════════════════════════════════════════════╣
|
|
║ 🌐 Web Interface: http://localhost:${PORT} ║
|
|
║ 📁 Data Directory: ${DATA_DIR}
|
|
╚═══════════════════════════════════════════════════════════════╝
|
|
`);
|
|
});
|