UNPKG

hls-transcoder

Version:

A tool to easily convert videos into HLS format

287 lines (247 loc) 8.57 kB
import { HLSTranscoderOptions, _HLSTranscoderOptions, VideoMetadata, RenditionOptions } from './types' import { spawn } from 'child_process' import fs from 'fs' import EventEmitter from 'events' import ffprobe from 'ffprobe' import commandExists from 'command-exists' import DefaultRenditions from './default-renditions' import DefaultOptions from './default-options' import { parseProgressStdout } from './utils' export default class Transcoder extends EventEmitter { inputPath: string outputPath: string options: HLSTranscoderOptions private _metadata: VideoMetadata = {} private _options: _HLSTranscoderOptions private _renditions?: Array<RenditionOptions> constructor(inputPath: string, outputPath: string, options: HLSTranscoderOptions = {}) { super() this.inputPath = inputPath this.outputPath = outputPath this.options = options this._options = this.setOptions(this.options) } public async transcode() { await this.validatePaths(this._options.ffmpegPath, this._options.ffprobePath) await this.setMetadata(this._options) await this.generateOutputDir() this.generateRenditions() let commands: Array<string> try { commands = await this.buildCommands() } catch (err) { return err } let masterPlaylist: string try { masterPlaylist = await this.writePlaylist() } catch (err) { return err } return new Promise((resolve, reject) => { const ffmpeg = this.options.ffmpegPath ? spawn(this.options.ffmpegPath, commands) : spawn('ffmpeg', commands) /** * stdout processing for progress */ ffmpeg.stdout.setEncoding('utf8') ffmpeg.stdout.on('data', (data: any) => { const progressLine = 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: any) => { this.emit('stderr', data) }) ffmpeg.stderr.on('error', (err: any) => { this.emit('error', err) }) ffmpeg.on('exit', (code: any) => { this.emit('end', `FFMPEG exited with code ${code}`) if (code === 0) return resolve(masterPlaylist) }) }) } private buildCommands(): Promise<string[]> { return new Promise((resolve) => { let commands: Array<string> = [ '-hide_banner', '-progress', // TODO make progress optional? `-`, '-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) }) } private writePlaylist(): Promise<string> { 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.writeFileSync(m3u8Path, m3u8Playlist) resolve(m3u8Path) }) } /** * Check what (if any) options the user has supplied, otherwise fallback * to default values * @param options */ private setOptions(options?: HLSTranscoderOptions): _HLSTranscoderOptions { const _options: any = {} _options.allowUpscaling = options?.allowUpscaling ? options.allowUpscaling : DefaultOptions.allowUpscaling _options.ffmpegPath = options?.ffmpegPath ? options.ffmpegPath : DefaultOptions.ffmpegPath _options.ffprobePath = options?.ffprobePath ? options.ffprobePath : DefaultOptions.ffprobePath _options.renditions = options?.renditions ? options.renditions : DefaultOptions.renditions return _options } /** * Runs `ffprobe` on the input video file to gather video metadata * @param _options * @param inputPath */ private async setMetadata(this: any, _options: _HLSTranscoderOptions): Promise<void> { let ffprobeData: ffprobe.FFProbeResult try { ffprobeData = await ffprobe(this.inputPath, { path: _options.ffprobePath}) } catch (err: any) { return this.emit('error', new Error(err)) // throw new Error() } const _metadata: VideoMetadata = { 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 */ private async validatePaths(ffmpegPath: string, ffprobePath: string): Promise<void> { const ffmpegExists = await commandExists(ffmpegPath).catch(() => { return }) const ffprobeExists = await commandExists(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. */ private generateRenditions(): void { // User renditions will be stored in _options.renditions const _renditions: Array<RenditionOptions> = [] 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 } private async generateOutputDir(): Promise<void> { // TODO - loop check if this.outputPath exists // console.log({outputPath: this.outputPath}) return new Promise((resolve) => { if (!fs.existsSync(this.outputPath)) { fs.mkdirSync(this.outputPath, { recursive: true }); } resolve() }) } }