File size: 8,110 Bytes
a3a7572
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
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}`));