hls-transcoder
Version:
A tool to easily convert videos into HLS format
269 lines (268 loc) • 11.2 kB
JavaScript
"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;