UNPKG

hls-transcoder

Version:

A tool to easily convert videos into HLS format

269 lines (268 loc) 11.2 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const child_process_1 = require("child_process"); const fs_1 = __importDefault(require("fs")); const events_1 = __importDefault(require("events")); const ffprobe_1 = __importDefault(require("ffprobe")); const command_exists_1 = __importDefault(require("command-exists")); const default_options_1 = __importDefault(require("./default-options")); const utils_1 = require("./utils"); class Transcoder extends events_1.default { constructor(inputPath, outputPath, options = {}) { super(); this._metadata = {}; this.inputPath = inputPath; this.outputPath = outputPath; this.options = options; this._options = this.setOptions(this.options); } transcode() { return __awaiter(this, void 0, void 0, function* () { yield this.validatePaths(this._options.ffmpegPath, this._options.ffprobePath); yield this.setMetadata(this._options); yield this.generateOutputDir(); this.generateRenditions(); let commands; try { commands = yield this.buildCommands(); } catch (err) { return err; } let masterPlaylist; try { masterPlaylist = yield this.writePlaylist(); } catch (err) { return err; } return new Promise((resolve, reject) => { const ffmpeg = this.options.ffmpegPath ? (0, child_process_1.spawn)(this.options.ffmpegPath, commands) : (0, child_process_1.spawn)('ffmpeg', commands); /** * stdout processing for progress */ ffmpeg.stdout.setEncoding('utf8'); ffmpeg.stdout.on('data', (data) => { const progressLine = (0, utils_1.parseProgressStdout)(data, this._metadata); if (progressLine) { this.emit('progress', progressLine); } }); /** * stderr processing for all other ffmpeg information */ ffmpeg.stderr.setEncoding('utf8'); ffmpeg.stderr.on('data', (data) => { this.emit('stderr', data); }); ffmpeg.stderr.on('error', (err) => { this.emit('error', err); }); ffmpeg.on('exit', (code) => { this.emit('end', `FFMPEG exited with code ${code}`); if (code === 0) return resolve(masterPlaylist); }); }); }); } buildCommands() { return new Promise((resolve) => { let commands = [ '-hide_banner', '-progress', `-`, '-loglevel', 'repeat+error', '-y', '-i', this.inputPath ]; let renditions; if (this._renditions) { renditions = this._renditions; } else { throw this.emit('error', new Error('Invalid renditions')); } for (let i = 0, len = renditions.length; i < len; i++) { const r = renditions[i]; commands = commands.concat([ '-vf', `scale=w=${r.width}:h=${r.height}:force_original_aspect_ratio=decrease`, '-c:a', 'aac', '-ar', '48000', '-c:v', 'h264', `-profile:v`, r.profile, '-crf', '20', '-sc_threshold', '0', '-g', '48', '-hls_time', r.hlsTime.toString(), '-hls_playlist_type', 'vod', '-b:v', r.bv, '-maxrate', r.maxrate, '-bufsize', r.bufsize, '-b:a', r.ba, '-hls_segment_filename', `${this.outputPath}/${r.ts_title}_%03d.ts`, `${this.outputPath}/${r.height}.m3u8` ]); } resolve(commands); }); } writePlaylist() { return new Promise((resolve) => { let m3u8Playlist = `#EXTM3U\n#EXT-X-VERSION:3\n`; const renditions = this._renditions; if (!renditions) { throw new Error('Invalid renditions'); } for (let i = 0, len = renditions.length; i < len; i++) { const r = renditions[i]; m3u8Playlist += `#EXT-X-STREAM-INF:BANDWIDTH=${r.bv.replace('k', '000')},RESOLUTION=${r.width}x${r.height}\n`; m3u8Playlist += `${r.height}.m3u8\n`; } const m3u8Path = `${this.outputPath}/index.m3u8`; fs_1.default.writeFileSync(m3u8Path, m3u8Playlist); resolve(m3u8Path); }); } /** * Check what (if any) options the user has supplied, otherwise fallback * to default values * @param options */ setOptions(options) { const _options = {}; _options.allowUpscaling = (options === null || options === void 0 ? void 0 : options.allowUpscaling) ? options.allowUpscaling : default_options_1.default.allowUpscaling; _options.ffmpegPath = (options === null || options === void 0 ? void 0 : options.ffmpegPath) ? options.ffmpegPath : default_options_1.default.ffmpegPath; _options.ffprobePath = (options === null || options === void 0 ? void 0 : options.ffprobePath) ? options.ffprobePath : default_options_1.default.ffprobePath; _options.renditions = (options === null || options === void 0 ? void 0 : options.renditions) ? options.renditions : default_options_1.default.renditions; return _options; } /** * Runs `ffprobe` on the input video file to gather video metadata * @param _options * @param inputPath */ setMetadata(_options) { return __awaiter(this, void 0, void 0, function* () { let ffprobeData; try { ffprobeData = yield (0, ffprobe_1.default)(this.inputPath, { path: _options.ffprobePath }); } catch (err) { return this.emit('error', new Error(err)); // throw new Error() } const _metadata = { codec_name: ffprobeData.streams[0].codec_name, duration: ffprobeData.streams[0].duration, height: ffprobeData.streams[0].height, width: ffprobeData.streams[0].width, sample_aspect_ratio: ffprobeData.streams[0].sample_aspect_ratio }; this._metadata = _metadata; return new Promise((resolve) => { resolve(); }); }); } /** * Validates that the supplied ffmpegPath and ffprobePaths exist * @param ffmpegPath * @param ffprobePath * @returns void */ validatePaths(ffmpegPath, ffprobePath) { return __awaiter(this, void 0, void 0, function* () { const ffmpegExists = yield (0, command_exists_1.default)(ffmpegPath).catch(() => { return; }); const ffprobeExists = yield (0, command_exists_1.default)(ffprobePath).catch(() => { return; }); return new Promise((resolve) => { if (!ffmpegExists && !ffprobeExists) { return this.emit('error', new Error('Invalid ffmpeg and ffprobe PATH')); } if (!ffmpegExists) { return this.emit('error', new Error('Invalid ffmpeg PATH')); } if (!ffprobeExists) { return this.emit('error', new Error('Invalid ffprobe PATH')); } resolve(); }); }); } /** * TODO - rewrite description, but this generates a renditions object based off the supplied renditions object * and the supplied options, aka upscaling etc. */ generateRenditions() { // User renditions will be stored in _options.renditions const _renditions = []; if (this._options.allowUpscaling) { this._renditions = this._options.renditions; return; } // Calculate number of pixels in video ie width*height if (!this._metadata.width || !this._metadata.height) { throw this.emit('error', new Error('Invalid metadata height or width')); } // Get SAR (Sample Aspect Ratio) to multiply videoResolution by if (!this._metadata.sample_aspect_ratio) { throw new Error('Metadata error'); } const sampleAspectRatio = parseInt(this._metadata.sample_aspect_ratio.split(':')[0]) / parseInt(this._metadata.sample_aspect_ratio.split(':')[1]); const videoResolution = this._metadata.width * this._metadata.height * sampleAspectRatio; for (let i = 0, len = this._options.renditions.length; i < len; i++) { const renditionResolution = (this._options.renditions[i].width * this._options.renditions[i].height * 0.90); if (renditionResolution <= videoResolution) { _renditions.push(this._options.renditions[i]); } } this._renditions = _renditions; return; } generateOutputDir() { return __awaiter(this, void 0, void 0, function* () { // TODO - loop check if this.outputPath exists // console.log({outputPath: this.outputPath}) return new Promise((resolve) => { if (!fs_1.default.existsSync(this.outputPath)) { fs_1.default.mkdirSync(this.outputPath, { recursive: true }); } resolve(); }); }); } } exports.default = Transcoder;