burner / server.js
mrpoddaa's picture
Upload 4 files
a3a7572 verified
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const axios = require('axios');
const FormData = require('form-data');
const app = express();
const PORT = process.env.PORT || 7860;
// ─── Directories ───────────────────────────────────────────────────────────
const UPLOADS_DIR = path.join(__dirname, 'uploads');
const OUTPUT_DIR = path.join(__dirname, 'output');
const FONTS_DIR = path.join(__dirname, 'public', 'fonts');
[UPLOADS_DIR, OUTPUT_DIR, FONTS_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
// ─── Multer ────────────────────────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOADS_DIR),
filename: (req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`),
});
const upload = multer({ storage, limits: { fileSize: 50 * 1024 * 1024 } }); // 50 MB sub limit
// ─── In-memory job store ───────────────────────────────────────────────────
const jobs = {};
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// ─── SSE helper ────────────────────────────────────────────────────────────
function sendEvent(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// ─── Routes ────────────────────────────────────────────────────────────────
// POST /burn – starts the job
app.post('/burn', upload.single('subtitle'), async (req, res) => {
const { videoUrl, pixeldrainApiKey } = req.body;
if (!videoUrl) return res.status(400).json({ error: 'videoUrl is required' });
if (!req.file) return res.status(400).json({ error: 'Subtitle file is required' });
const jobId = uuidv4();
const subPath = req.file.path;
const outputFile = path.join(OUTPUT_DIR, `${jobId}.mp4`);
jobs[jobId] = { status: 'queued', progress: 0, message: 'Starting…', outputFile, pixeldrainApiKey };
res.json({ jobId });
// Run in background
processVideo(jobId, videoUrl, subPath, outputFile).catch(err => {
jobs[jobId].status = 'error';
jobs[jobId].message = err.message;
});
});
// GET /status/:jobId – SSE progress stream
app.get('/status/:jobId', (req, res) => {
const { jobId } = req.params;
if (!jobs[jobId]) return res.status(404).json({ error: 'Job not found' });
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const interval = setInterval(() => {
const job = jobs[jobId];
sendEvent(res, job);
if (job.status === 'done' || job.status === 'error') {
clearInterval(interval);
res.end();
}
}, 800);
req.on('close', () => clearInterval(interval));
});
// ─── Core processing ────────────────────────────────────────────────────────
async function processVideo(jobId, videoUrl, subPath, outputFile) {
const job = jobs[jobId];
// Step 1 – Download video
job.status = 'downloading';
job.message = 'Downloading video…';
job.progress = 2;
const videoFile = path.join(UPLOADS_DIR, `${jobId}.mp4`);
await downloadFile(videoUrl, videoFile, (pct) => {
job.progress = Math.round(pct * 0.3); // 0–30%
job.message = `Downloading… ${job.progress}%`;
});
// Step 2 – Burn subtitles with HandBrake
job.status = 'burning';
job.message = 'Burning subtitles…';
job.progress = 30;
await burnSubtitles(jobId, videoFile, subPath, outputFile);
// Step 3 – Upload to Pixeldrain
job.status = 'uploading';
job.message = 'Uploading to Pixeldrain…';
job.progress = 95;
const downloadUrl = await uploadToPixeldrain(outputFile, job.pixeldrainApiKey);
// Cleanup
[videoFile, subPath].forEach(f => { try { fs.unlinkSync(f); } catch {} });
job.status = 'done';
job.progress = 100;
job.message = 'Done!';
job.downloadUrl = downloadUrl;
}
// ─── Download with progress ────────────────────────────────────────────────
async function downloadFile(url, dest, onProgress) {
const response = await axios({ method: 'GET', url, responseType: 'stream' });
const total = parseInt(response.headers['content-length'] || '0', 10);
let received = 0;
return new Promise((resolve, reject) => {
const writer = fs.createWriteStream(dest);
response.data.on('data', chunk => {
received += chunk.length;
if (total) onProgress(received / total);
});
response.data.pipe(writer);
writer.on('finish', resolve);
writer.on('error', reject);
response.data.on('error', reject);
});
}
// ─── HandBrake subtitle burn ───────────────────────────────────────────────
function burnSubtitles(jobId, videoFile, subFile, outputFile) {
const hbjs = require('handbrake-js');
const job = jobs[jobId];
const subExt = path.extname(subFile).toLowerCase();
const isSrt = subExt === '.srt' || subExt === '.ass' || subExt === '.ssa';
// ASS override style – NotoSans Sinhala for Sinhala chars, Roboto for Latin
// We embed the subtitle track and burn it in
const options = {
input: videoFile,
output: outputFile,
encoder: 'x264',
quality: 22,
'subtitle-lang-list': 'und',
'all-subtitles': true,
'subtitle-burned': 0,
'sub-burn': 0,
};
// Use SRT import for text subtitle files
if (isSrt) {
options['srt-file'] = subFile;
options['srt-burn'] = 0; // burn the imported track (index 0-based)
options['srt-codeset'] = 'UTF-8';
}
return new Promise((resolve, reject) => {
hbjs.spawn(options)
.on('error', reject)
.on('progress', p => {
if (p.percentComplete !== undefined) {
job.progress = 30 + Math.round(p.percentComplete * 0.64); // 30–94%
job.message = `Burning… ${p.percentComplete.toFixed(1)}% | ETA: ${p.eta || '?'}`;
}
})
.on('end', resolve);
});
}
// ─── Pixeldrain upload ────────────────────────────────────────────────────
async function uploadToPixeldrain(filePath, apiKey) {
const form = new FormData();
const fileName = path.basename(filePath);
form.append('file', fs.createReadStream(filePath), fileName);
const headers = { ...form.getHeaders() };
if (apiKey) {
const encoded = Buffer.from(`:${apiKey}`).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
}
const resp = await axios.post('https://pixeldrain.com/api/file', form, { headers, maxContentLength: Infinity, maxBodyLength: Infinity });
const fileId = resp.data.id;
// Cleanup output after upload
try { fs.unlinkSync(filePath); } catch {}
return `https://pixeldrain.com/u/${fileId}`;
}
// ─── Start ────────────────────────────────────────────────────────────────
app.listen(PORT, () => console.log(`🎬 Subtitle Burner running on http://localhost:${PORT}`));