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}`));