| 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; |
|
|
| |
| 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 })); |
|
|
| |
| 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 } }); |
|
|
| |
| const jobs = {}; |
|
|
| app.use(express.json()); |
| app.use(express.static(path.join(__dirname, 'public'))); |
|
|
| |
| function sendEvent(res, data) { |
| res.write(`data: ${JSON.stringify(data)}\n\n`); |
| } |
|
|
| |
|
|
| |
| 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 }); |
|
|
| |
| processVideo(jobId, videoUrl, subPath, outputFile).catch(err => { |
| jobs[jobId].status = 'error'; |
| jobs[jobId].message = err.message; |
| }); |
| }); |
|
|
| |
| 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)); |
| }); |
|
|
| |
| async function processVideo(jobId, videoUrl, subPath, outputFile) { |
| const job = jobs[jobId]; |
|
|
| |
| 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); |
| job.message = `Downloading⦠${job.progress}%`; |
| }); |
|
|
| |
| job.status = 'burning'; |
| job.message = 'Burning subtitlesβ¦'; |
| job.progress = 30; |
|
|
| await burnSubtitles(jobId, videoFile, subPath, outputFile); |
|
|
| |
| job.status = 'uploading'; |
| job.message = 'Uploading to Pixeldrainβ¦'; |
| job.progress = 95; |
|
|
| const downloadUrl = await uploadToPixeldrain(outputFile, job.pixeldrainApiKey); |
|
|
| |
| [videoFile, subPath].forEach(f => { try { fs.unlinkSync(f); } catch {} }); |
|
|
| job.status = 'done'; |
| job.progress = 100; |
| job.message = 'Done!'; |
| job.downloadUrl = downloadUrl; |
| } |
|
|
| |
| 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); |
| }); |
| } |
|
|
| |
| 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'; |
|
|
| |
| |
| const options = { |
| input: videoFile, |
| output: outputFile, |
| encoder: 'x264', |
| quality: 22, |
| 'subtitle-lang-list': 'und', |
| 'all-subtitles': true, |
| 'subtitle-burned': 0, |
| 'sub-burn': 0, |
| }; |
|
|
| |
| if (isSrt) { |
| options['srt-file'] = subFile; |
| options['srt-burn'] = 0; |
| 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); |
| job.message = `Burning⦠${p.percentComplete.toFixed(1)}% | ETA: ${p.eta || '?'}`; |
| } |
| }) |
| .on('end', resolve); |
| }); |
| } |
|
|
| |
| 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; |
| |
| try { fs.unlinkSync(filePath); } catch {} |
| return `https://pixeldrain.com/u/${fileId}`; |
| } |
|
|
| |
| app.listen(PORT, () => console.log(`π¬ Subtitle Burner running on http://localhost:${PORT}`)); |
|
|