transcode.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. 'use strict';
  2. /**
  3. * Transcode Worker
  4. * ─────────────────────────
  5. * Forked from the main Express process. Handles:
  6. * 1. Thumbnail extraction (1 JPEG frame)
  7. * 2. HLS transcoding (H.264/AAC, single 720p stream)
  8. *
  9. * Always re-encodes the input video to H.264 + AAC — this is required for
  10. * broad browser / HLS.js compatibility regardless of the source codec
  11. * (e.g. ProRes, VP9, AV1, H.265, etc.).
  12. *
  13. * Communicates progress back to the parent via process.send() (IPC).
  14. *
  15. * Usage: node transcode.js <assetId> <videoPath> <outputDir>
  16. */
  17. const ffmpeg = require('fluent-ffmpeg');
  18. const path = require('path');
  19. const fs = require('fs');
  20. const [, , assetId, videoPath, outputDir] = process.argv;
  21. function send(msg) {
  22. try { process.send(msg); } catch {}
  23. }
  24. /** ── 1. Extract thumbnail + probe metadata ─────────────────────────────────── */
  25. function probeAndThumbnail(videoPath, outputDir) {
  26. return new Promise((resolve) => {
  27. const videoFilename = path.basename(videoPath, path.extname(videoPath));
  28. const thumbFilename = videoFilename + '_thumb.jpg';
  29. fs.mkdirSync(outputDir, { recursive: true });
  30. ffmpeg.ffprobe(videoPath, (err, metadata) => {
  31. const duration = metadata?.format?.duration ?? null;
  32. let fps = 30;
  33. let codecName = 'unknown';
  34. const videoStream = metadata?.streams?.find(s => s.codec_type === 'video');
  35. if (videoStream) {
  36. codecName = videoStream.codec_name || 'unknown';
  37. if (videoStream.r_frame_rate) {
  38. const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
  39. fps = den ? Math.round(num / den) : num;
  40. }
  41. }
  42. send({ type: 'metadata', assetId, duration, fps, codec: codecName });
  43. ffmpeg(videoPath)
  44. .on('error', () => resolve({ thumbnailPath: null, duration, fps, codec: codecName }))
  45. .on('end', () => resolve({ thumbnailPath: thumbFilename, duration, fps, codec: codecName }))
  46. .screenshots({
  47. count: 1,
  48. folder: outputDir,
  49. filename: thumbFilename,
  50. size: '320x?',
  51. timemarks: ['1'],
  52. });
  53. });
  54. });
  55. }
  56. /** ── 2. Transcode to HLS (H.264 + AAC) ─────────────────────────────────────── */
  57. /**
  58. * We use a SINGLE output stream at 720p.
  59. * This is the simplest and most reliable approach:
  60. * • H.264 is supported by every modern browser natively via MSE.
  61. * • AAC is supported everywhere.
  62. * • FFmpeg creates the .m3u8 playlist + .ts segment files.
  63. * • A master playlist pointing to the single variant is written afterwards.
  64. *
  65. * Adding more quality levels later only requires additional .output() calls
  66. * with per-stream `-map` / `-b:v:N` options (fluent-ffmpeg supports this).
  67. */
  68. function transcodeToHLS(videoPath, outputDir, assetId, duration) {
  69. return new Promise((resolve, reject) => {
  70. const hlsDir = path.join(outputDir, 'hls', assetId);
  71. fs.mkdirSync(hlsDir, { recursive: true });
  72. const playlistPath = path.join(hlsDir, 'master.m3u8');
  73. const segmentPattern = path.join(hlsDir, 'segment_%03d.ts');
  74. // Estimate total segments for progress reporting
  75. const totalSegments = duration > 0 ? Math.ceil(duration / 6) : 100;
  76. let lastPct = 0;
  77. ffmpeg(videoPath)
  78. // ── Re-encode to H.264 + AAC ──────────────────────────────────────────
  79. .outputOptions([
  80. '-c:v libx264', // always re-encode → H.264 (universal support)
  81. '-c:a aac', // always re-encode → AAC (universal support)
  82. '-movflags +faststart', // moov atom at front → fast playback start
  83. '-preset fast',
  84. '-crf 23',
  85. // HLS output
  86. '-f hls',
  87. '-hls_time 6',
  88. '-hls_playlist_type vod',
  89. '-hls_segment_filename', segmentPattern,
  90. ])
  91. .output(playlistPath)
  92. .on('progress', ({ percent }) => {
  93. const pct = Math.round(Math.min(99, percent ?? lastPct));
  94. if (pct > lastPct) {
  95. lastPct = pct;
  96. send({ type: 'progress', assetId, phase: 'transcode', progress: pct });
  97. }
  98. })
  99. .on('error', (err) => reject(new Error('HLS_TRANSCODE_FAILED: ' + err.message)))
  100. .on('end', () => resolve('/hls/' + assetId + '/master.m3u8'))
  101. .run();
  102. });
  103. }
  104. /** ── Main ───────────────────────────────────────────────────────────────────── */
  105. async function run() {
  106. if (!assetId || !videoPath || !outputDir) {
  107. console.error('Usage: node transcode.js <assetId> <videoPath> <outputDir>');
  108. process.exit(1);
  109. }
  110. try {
  111. send({ type: 'start', assetId, phase: 'thumbnail', progress: 0 });
  112. const thumbResult = await probeAndThumbnail(videoPath, outputDir);
  113. send({ type: 'thumbnail_done', assetId, phase: 'thumbnail', progress: 100, ...thumbResult });
  114. send({ type: 'start', assetId, phase: 'transcode', progress: 0 });
  115. const hlsPath = await transcodeToHLS(videoPath, outputDir, assetId, thumbResult.duration);
  116. send({ type: 'done', assetId, phase: 'done', progress: 100, hlsPath, ...thumbResult });
  117. process.exit(0);
  118. } catch (err) {
  119. send({ type: 'error', assetId, phase: 'error', progress: 0, error: err.message });
  120. process.exit(1);
  121. }
  122. }
  123. run();