| … | … | @@ -172,21 +172,49 @@ export class VideoProcessor { |
| 172 | 172 | const { originalHash, resolve, reject } = this._queue.shift() |
| 173 | 173 | |
| 174 | 174 | try { |
| 175 | | const result = await this.processBasic(originalHash) |
| 175 | // Write temp file |
| 176 | const info = await this.mediaStore.getInfo(originalHash) |
| 177 | const ext = info && info.mimeType === 'video/webm' ? '.webm' : '.mp4' |
| 178 | const tmpPath = await this._writeTempFile(originalHash, ext) |
| 176 | 179 | |
| 177 | | // Store video index |
| 178 | | await this.mediaStore._index.put('video:' + originalHash, { |
| 179 | | status: 'ready', |
| 180 | | poster: result.posterHash, |
| 181 | | width: result.probe.width, |
| 182 | | height: result.probe.height, |
| 183 | | duration: result.probe.duration, |
| 184 | | codec: result.probe.codec, |
| 185 | | fps: result.probe.fps, |
| 186 | | processedAt: Date.now() |
| 187 | | }) |
| 180 | try { |
| 181 | // Probe |
| 182 | const probeResult = await this.probe(tmpPath) |
| 183 | |
| 184 | // Poster |
| 185 | const posterTime = Math.min(2, probeResult.duration * 0.1) |
| 186 | const posterBuf = await this.extractPoster(tmpPath, posterTime) |
| 187 | const posterHash = await this.mediaStore.put(posterBuf, 'image/jpeg') |
| 188 | |
| 189 | // HLS transcode |
| 190 | const hls = await this.transcodeHLS(tmpPath, probeResult) |
| 188 | 191 | |
| 189 | | resolve(result) |
| 192 | // Write video index |
| 193 | const videoIndex = { |
| 194 | status: 'ready', |
| 195 | poster: posterHash, |
| 196 | manifest: hls.manifestHash, |
| 197 | width: probeResult.width, |
| 198 | height: probeResult.height, |
| 199 | duration: probeResult.duration, |
| 200 | codec: probeResult.codec, |
| 201 | fps: probeResult.fps, |
| 202 | levels: hls.levels.map(l => ({ |
| 203 | name: l.name, |
| 204 | height: l.height, |
| 205 | bitrate: l.bitrate, |
| 206 | m3u8: l.m3u8Hash, |
| 207 | segments: l.segmentHashes.map(s => s.hash) |
| 208 | })), |
| 209 | processedAt: Date.now() |
| 210 | } |
| 211 | |
| 212 | await this.mediaStore._index.put('video:' + originalHash, videoIndex) |
| 213 | |
| 214 | resolve({ probe: probeResult, posterHash, hls }) |
| 215 | } finally { |
| 216 | await fs.promises.unlink(tmpPath).catch(() => {}) |
| 217 | } |
| 190 | 218 | } catch (err) { |
| 191 | 219 | await this.mediaStore._index.put('video:' + originalHash, { |
| 192 | 220 | status: 'failed', |
| … | … | @@ -200,6 +228,120 @@ export class VideoProcessor { |
| 200 | 228 | } |
| 201 | 229 | } |
| 202 | 230 | |
| 231 | /** |
| 232 | * Transcode a video file to HLS at multiple quality levels. |
| 233 | * Only generates levels <= source resolution (never upscale). |
| 234 | * Returns { levels: [{ name, m3u8Hash, segmentHashes }], manifestHash } |
| 235 | */ |
| 236 | async transcodeHLS (filePath, probeInfo) { |
| 237 | const srcHeight = probeInfo.height |
| 238 | |
| 239 | // Filter levels to not exceed source resolution |
| 240 | let levels = HLS_LEVELS.filter(l => l.height <= srcHeight) |
| 241 | if (levels.length === 0) { |
| 242 | // Source is smaller than 480p — generate single native-res variant |
| 243 | levels = [{ |
| 244 | name: srcHeight + 'p', |
| 245 | height: srcHeight, |
| 246 | bitrate: '1000k', |
| 247 | maxrate: '1200k', |
| 248 | profile: 'baseline' |
| 249 | }] |
| 250 | } |
| 251 | |
| 252 | const hlsDir = path.join(this._tmpBase, 'hls-' + Date.now()) |
| 253 | await fs.promises.mkdir(hlsDir, { recursive: true }) |
| 254 | |
| 255 | const levelResults = [] |
| 256 | |
| 257 | try { |
| 258 | for (const level of levels) { |
| 259 | const levelDir = path.join(hlsDir, level.name) |
| 260 | await fs.promises.mkdir(levelDir, { recursive: true }) |
| 261 | |
| 262 | const playlistPath = path.join(levelDir, level.name + '.m3u8') |
| 263 | const segmentPattern = path.join(levelDir, level.name + '_%03d.ts') |
| 264 | const bufsize = parseInt(level.bitrate) * 2 + 'k' |
| 265 | |
| 266 | const result = await this._exec(this._ffmpegPath, [ |
| 267 | '-y', '-i', filePath, |
| 268 | '-vf', 'scale=-2:' + level.height, |
| 269 | '-c:v', 'libx264', '-profile:v', level.profile, |
| 270 | '-b:v', level.bitrate, '-maxrate', level.maxrate, '-bufsize', bufsize, |
| 271 | '-c:a', 'aac', '-b:a', '128k', '-ac', '2', |
| 272 | '-hls_time', '6', |
| 273 | '-hls_list_size', '0', |
| 274 | '-hls_segment_filename', segmentPattern, |
| 275 | '-f', 'hls', |
| 276 | playlistPath |
| 277 | ]) |
| 278 | |
| 279 | if (result.code !== 0) { |
| 280 | throw new Error('HLS transcode failed for ' + level.name + ': ' + result.stderr.slice(-500)) |
| 281 | } |
| 282 | |
| 283 | // Read and store all .ts segment files |
| 284 | const segmentHashes = [] |
| 285 | const files = await fs.promises.readdir(levelDir) |
| 286 | const tsFiles = files.filter(f => f.endsWith('.ts')).sort() |
| 287 | |
| 288 | for (const tsFile of tsFiles) { |
| 289 | const segBuf = await fs.promises.readFile(path.join(levelDir, tsFile)) |
| 290 | const segHash = await this.mediaStore.put(segBuf, 'video/mp2t') |
| 291 | segmentHashes.push({ file: tsFile, hash: segHash }) |
| 292 | } |
| 293 | |
| 294 | // Read the .m3u8 playlist |
| 295 | const m3u8Content = await fs.promises.readFile(playlistPath, 'utf-8') |
| 296 | const m3u8Buf = Buffer.from(m3u8Content, 'utf-8') |
| 297 | const m3u8Hash = await this.mediaStore.put(m3u8Buf, 'application/vnd.apple.mpegurl') |
| 298 | |
| 299 | levelResults.push({ |
| 300 | name: level.name, |
| 301 | height: level.height, |
| 302 | bitrate: level.bitrate, |
| 303 | m3u8Hash, |
| 304 | segmentHashes |
| 305 | }) |
| 306 | } |
| 307 | |
| 308 | // Build master.m3u8 |
| 309 | const masterLines = ['#EXTM3U'] |
| 310 | for (const level of levelResults) { |
| 311 | const bandwidth = parseInt(level.bitrate) * 1000 |
| 312 | masterLines.push('#EXT-X-STREAM-INF:BANDWIDTH=' + bandwidth + ',RESOLUTION=' + Math.round(level.height * probeInfo.width / probeInfo.height / 2) * 2 + 'x' + level.height) |
| 313 | // Reference the level playlist by its hash (will be rewritten at serve time) |
| 314 | masterLines.push(level.m3u8Hash) |
| 315 | } |
| 316 | const masterContent = masterLines.join('\n') + '\n' |
| 317 | const masterBuf = Buffer.from(masterContent, 'utf-8') |
| 318 | const manifestHash = await this.mediaStore.put(masterBuf, 'application/vnd.apple.mpegurl') |
| 319 | |
| 320 | return { levels: levelResults, manifestHash } |
| 321 | } finally { |
| 322 | // Clean up temp HLS directory |
| 323 | await this._rmrf(hlsDir) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | /** |
| 328 | * Recursively delete a temporary directory. |
| 329 | */ |
| 330 | async _rmrf (dir) { |
| 331 | try { |
| 332 | const entries = await fs.promises.readdir(dir, { withFileTypes: true }) |
| 333 | for (const entry of entries) { |
| 334 | const full = path.join(dir, entry.name) |
| 335 | if (entry.isDirectory()) { |
| 336 | await this._rmrf(full) |
| 337 | } else { |
| 338 | await fs.promises.unlink(full) |
| 339 | } |
| 340 | } |
| 341 | await fs.promises.rmdir(dir) |
| 342 | } catch {} |
| 343 | } |
| 344 | |
| 203 | 345 | /** |
| 204 | 346 | * Get video processing status. |
| 205 | 347 | */ |