'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 */ 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 '); 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();