706 lines
23 KiB
JavaScript
706 lines
23 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 });
|
|
|
|
// 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.replace(/^\uFEFF/, ''));
|
|
} 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+
|