| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142 |
- 'use strict';
- /**
- * Transcode Worker
- * ─────────────────────────
- * Forked from the main Express process. Handles:
- * 1. Thumbnail extraction (1 JPEG frame)
- * 2. HLS transcoding (H.264/AAC, single 720p stream)
- *
- * Always re-encodes the input video to H.264 + AAC — this is required for
- * broad browser / HLS.js compatibility regardless of the source codec
- * (e.g. ProRes, VP9, AV1, H.265, etc.).
- *
- * Communicates progress back to the parent via process.send() (IPC).
- *
- * Usage: node transcode.js <assetId> <videoPath> <outputDir>
- */
- const ffmpeg = require('fluent-ffmpeg');
- const path = require('path');
- const fs = require('fs');
- const [, , assetId, videoPath, outputDir] = process.argv;
- function send(msg) {
- try { process.send(msg); } catch {}
- }
- /** ── 1. Extract thumbnail + probe metadata ─────────────────────────────────── */
- function probeAndThumbnail(videoPath, outputDir) {
- return new Promise((resolve) => {
- const videoFilename = path.basename(videoPath, path.extname(videoPath));
- const thumbFilename = videoFilename + '_thumb.jpg';
- fs.mkdirSync(outputDir, { recursive: true });
- ffmpeg.ffprobe(videoPath, (err, metadata) => {
- const duration = metadata?.format?.duration ?? null;
- let fps = 30;
- let codecName = 'unknown';
- const videoStream = metadata?.streams?.find(s => s.codec_type === 'video');
- if (videoStream) {
- codecName = videoStream.codec_name || 'unknown';
- if (videoStream.r_frame_rate) {
- const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
- fps = den ? Math.round(num / den) : num;
- }
- }
- send({ type: 'metadata', assetId, duration, fps, codec: codecName });
- ffmpeg(videoPath)
- .on('error', () => resolve({ thumbnailPath: null, duration, fps, codec: codecName }))
- .on('end', () => resolve({ thumbnailPath: thumbFilename, duration, fps, codec: codecName }))
- .screenshots({
- count: 1,
- folder: outputDir,
- filename: thumbFilename,
- size: '320x?',
- timemarks: ['1'],
- });
- });
- });
- }
- /** ── 2. Transcode to HLS (H.264 + AAC) ─────────────────────────────────────── */
- /**
- * We use a SINGLE output stream at 720p.
- * This is the simplest and most reliable approach:
- * • H.264 is supported by every modern browser natively via MSE.
- * • AAC is supported everywhere.
- * • FFmpeg creates the .m3u8 playlist + .ts segment files.
- * • A master playlist pointing to the single variant is written afterwards.
- *
- * Adding more quality levels later only requires additional .output() calls
- * with per-stream `-map` / `-b:v:N` options (fluent-ffmpeg supports this).
- */
- function transcodeToHLS(videoPath, outputDir, assetId, duration) {
- return new Promise((resolve, reject) => {
- const hlsDir = path.join(outputDir, 'hls', assetId);
- fs.mkdirSync(hlsDir, { recursive: true });
- const playlistPath = path.join(hlsDir, 'master.m3u8');
- const segmentPattern = path.join(hlsDir, 'segment_%03d.ts');
- // Estimate total segments for progress reporting
- const totalSegments = duration > 0 ? Math.ceil(duration / 6) : 100;
- let lastPct = 0;
- ffmpeg(videoPath)
- // ── Re-encode to H.264 + AAC ──────────────────────────────────────────
- .outputOptions([
- '-c:v libx264', // always re-encode → H.264 (universal support)
- '-c:a aac', // always re-encode → AAC (universal support)
- '-movflags +faststart', // moov atom at front → fast playback start
- '-preset fast',
- '-crf 23',
- // HLS output
- '-f hls',
- '-hls_time 6',
- '-hls_playlist_type vod',
- '-hls_segment_filename', segmentPattern,
- ])
- .output(playlistPath)
- .on('progress', ({ percent }) => {
- const pct = Math.round(Math.min(99, percent ?? lastPct));
- if (pct > lastPct) {
- lastPct = pct;
- send({ type: 'progress', assetId, phase: 'transcode', progress: pct });
- }
- })
- .on('error', (err) => reject(new Error('HLS_TRANSCODE_FAILED: ' + err.message)))
- .on('end', () => resolve('/hls/' + assetId + '/master.m3u8'))
- .run();
- });
- }
- /** ── Main ───────────────────────────────────────────────────────────────────── */
- async function run() {
- if (!assetId || !videoPath || !outputDir) {
- console.error('Usage: node transcode.js <assetId> <videoPath> <outputDir>');
- process.exit(1);
- }
- try {
- send({ type: 'start', assetId, phase: 'thumbnail', progress: 0 });
- const thumbResult = await probeAndThumbnail(videoPath, outputDir);
- send({ type: 'thumbnail_done', assetId, phase: 'thumbnail', progress: 100, ...thumbResult });
- send({ type: 'start', assetId, phase: 'transcode', progress: 0 });
- const hlsPath = await transcodeToHLS(videoPath, outputDir, assetId, thumbResult.duration);
- send({ type: 'done', assetId, phase: 'done', progress: 100, hlsPath, ...thumbResult });
- process.exit(0);
- } catch (err) {
- send({ type: 'error', assetId, phase: 'error', progress: 0, error: err.message });
- process.exit(1);
- }
- }
- run();
|