├── .gitignore ├── .jshintignore ├── .jshintrc ├── README.md ├── examples ├── file.js └── s3.js ├── index.js ├── lib ├── encoder.js ├── ffmpeg.js └── output │ ├── file.js │ └── s3.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "indent": 2, 4 | "curly": true, 5 | "latedef": true, 6 | "quotmark": true, 7 | "undef": true, 8 | "unused": true, 9 | "trailing": true 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hapi-video-encoder 2 | ================== 3 | 4 | Hapi plugin which generates an endpoint for accepting video which is streamed to ffmpeg for video manipulation/transcoding purposes. The resulting video can either be stored permenantly to disk or streamed to an s3 container. 5 | 6 | ## Requirements 7 | 8 | * hapi - [Hapi](https://github.com/spumko/hapi) will be used as the server which will accept incoming POST requests with the media to be transcoded. 9 | * ffmpeg - This module uses the [https://github.com/fluent-ffmpeg/node-fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) module for interfacing with ffmpeg. The ffmpeg binaries must be installed on the same system as the hapi server. 10 | 11 | ## Installation 12 | * ffmpeg - Installation of ffmpeg can be a beast if it's not included how you want it configured in your package manager. Some helpful guides for manual compilation: 13 | * [Ubuntu/Debian/Mint](https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu) 14 | * [MacOSX](https://trac.ffmpeg.org/wiki/CompilationGuide/MacOSX) 15 | * [CentOS/RedHat/Fedora](https://trac.ffmpeg.org/wiki/CompilationGuide/Centos) 16 | * hapi-video-encoder - Installed via npm: 17 | ``` 18 | $ npm install hapi-video-encoder 19 | ``` 20 | 21 | ## Basic Usage 22 | 23 | After creating a Hapi server object register the hapi-video-encoder plugin: 24 | 25 | ``` 26 | var server = new Hapi.server(...); 27 | server.pack.regiser({ 28 | plugin: require('hapi-video-encoder'), 29 | options: { // options described below } 30 | }); 31 | ``` 32 | 33 | This will add a POST route (by default) /media which will handle incoming media requests. It also sets up a basic ffmpeg command to encode videos to mp4 for use in HTML5 video tags. For a quick test cd into the examples folder: 34 | 35 | ``` 36 | $ cd examples 37 | $ node file.js 38 | ``` 39 | 40 | This has started a minimal server running on port 8000 which will accept a media file at the /media route and transcode it an mp4 file named boom.mp4 in the project directory. To test POST a file to that endpoint: 41 | 42 | ``` 43 | $ curl -X POST -F file=@video1.avi http://localhost:8000/media 44 | ``` 45 | 46 | The result will be a job id which could be used to lookup information about the job if the user implements a tracking system. 47 | 48 | ## Options 49 | 50 | Option that can be sent to plugin are as shown. The values filled in are defaults. Note that s3 and file options are only required when using those outputs respectively. 51 | 52 | ``` 53 | server.pack.register({ 54 | plugin: require('hapi-video-encoder'), 55 | options: { 56 | output: 'file', // Can be 'file' or 's3' and describe where the finalized transcoded media is sent. 57 | route: { 58 | path: '/media', // The endpoint for which the hapi server will listen for incoming POSTed media 59 | fileParam: 'file', // Which payload parameter contains the media stream ie request.payload.file 60 | maxBytes: 5368709120 // The maximum file size of the media stream (default 5 GB) 61 | }, 62 | ffmpeg: { 63 | videoCodec: 'libx264', // Which video codec to use for video compression and transcoding 64 | audioCodec: 'libfaac', // Which audio coded use for audio compression and transcoding 65 | nolog: true, // Quiets the output from node-fluent-ffmpeg 66 | options: [ // Extra arguments that are sent to the ffmpeg process (described below) ] 67 | }, 68 | file: { 69 | outputPath: '' // Where the transcoded file should be placed. If not set will be stored in a temporary location 70 | }, 71 | s3: { 72 | acl: 'bucket-owner-full-control', // ACL rule to use for uploaded media 73 | accessKey: '', // AWS Access Key 74 | secretKey: '', // AWS Secret Key 75 | region: '', // S3 region 76 | endpoint: 'https://s3.amazonaws.com', // S3 Endpoint 77 | bucket: '' // S3 bucket to place transcoded media. 78 | } 79 | } 80 | }); 81 | ``` 82 | 83 | ## Output Modes 84 | * File - The default output mode for transcoded media is to store it on the server. The location depends on the `file.outputPath` parameter. If it is missing the file will be stored in a temporary generic file. If `file.outputPath` is set in the plugin options media will be saved there. Lastly, the file location can be overwritten if the upload specifies a path: ie: 85 | 86 | ``` 87 | $ curl -X POST -F file=@video1.avi http://localhost:8000/media;outputPath=/some/cool/path/file.m3v 88 | ``` 89 | * S3 - If desired, transcoded media can be streamed to s3. Requirements for this are to specify `bucket` and `key`. If `bucket` is set in plugin options as described above then that bucket will be used for all uploads. The bucket can be overwritten for a single upload if it is specified in the payload such as: 90 | 91 | ``` 92 | $ curl -X POST -F file=@video1.avi http://localhost:8000/media;bucket=uploads 93 | ``` 94 | 95 | The S3 key by default will be the filename that comes along with the form post metadata. This can be overwritten by specifying `key` in the payload: 96 | 97 | ``` 98 | $ curl -X POST -F file=@video1.avi http://localhost:8000/media;key=/super/cool/key.mov 99 | ``` 100 | 101 | ## Events 102 | An event-emitter is exposed through the plugin interface which will report transcoding information. It can be accessed through the server object: 103 | 104 | ``` 105 | var emitter = server.plugins['hapi-video-encoder'].emitter; 106 | emitter.on('transcode-start', function (info) { 107 | console.log(info); 108 | }); 109 | ``` 110 | 111 | ### Event Names 112 | * transcode-start - When transcoding has begun. Will return an object with job id and details about the file 113 | * transcode-progress - FFmpeg progress details for job. 114 | * transcode-error - Emitted when receiving an error event from ffmpeg 115 | * transcode-end - The stream has been completely transcoded 116 | * s3-start - Starting to upload stream to S3 117 | * s3-progress - Progress event for S3 uploading 118 | * s3-end - Object successfully uploaded to S3 119 | * s3-error - Return an s3 error 120 | 121 | ## Default FFmpeg Settings 122 | The current default settings of ffmpeg are geared towards generating html5 compatible mp4 files. Those settings and explanations are: 123 | 124 | * videoCodec: 'libx264' - Pretty standard codec for handling the h.264 video compression format 125 | * audioCodec: 'libfaac' - Again a standard codec for handling most audio formats 126 | * options: [ 127 | * -pix_fmt yuv420p - Pixel format that is widely compatible with different players and devices. 128 | * -profile:v baseline - Allows compatability with a wide range of devices and platforms 129 | * -preset fast - Tradeoff for faster transcoding but less compression 130 | * -crf 23 - Specifies overall quality rating. This is a middle of the road setting, 131 | * -movflags +faststart - Lets the video play almost immediately for the user providing a better experience 132 | * vf scale=trunc(in_w/2)*2:trunc(in_h/2)*2 - Make sure the video doesn't have an odd width or height 133 | * -f mp4 - Specifies the result container to be mp4 134 | ] 135 | 136 | ## TODO 137 | * Allow a stream interface to pipe streaming transcodings to. This is useful for streaming media formats 138 | * Handle screenshot generation. FFmpeg allows for multiple screenshots while transcoding video. Want to expose this functionality. 139 | * Generate mulitple formats from same input stream. Right now you can only create 1 format from 1 input. 140 | 141 | ## Considerations 142 | * Storage space - While most transcodings can be streamed into the transcoder there are some formats that have to be written to disk after transcoding (such as mp4). And some media formats (such as quicktime) must be read completely from disk before transcoding even begins. For this reason you should ensure that you have enough disk space to handle the expected amount of transcoding jobs. 143 | * CPU - Transcoding is CPU intensive and the more quality and compression that is desired, the more stress on the CPU so ensure that your hardware is 144 | * ffmpeg settings - With the multitude of formats/options/codecs there are simply too many options to specify one "setting" as being correct for every situation. Playing with quality and compression levels until you strike a satisfactory balance between size, transcode time, and quality will most likely be required. 145 | 146 | 147 | ## References 148 | * [ffmpeg main site](https://www.ffmpeg.org/) - The official ffmpeg website containing documentation and information on every single thing in the ffmpeg world. 149 | * [h.264 encoding guide](https://trac.ffmpeg.org/wiki/Encode/H.264) - Some useful information about settings relevant to encoding with the libx264 library and useful for applications using web containers (mp4, ogg, etc). 150 | * [ffmpeg streaming guide](https://trac.ffmpeg.org/wiki/StreamingGuide) - Relevant information for streaming transcoded media from ffmpeg. 151 | * [html5 video encoding guide](https://blog.mediacru.sh/2013/12/23/The-right-way-to-encode-HTML5-video.html) - Very helpful article for setting ffmpeg options when transcoding html5 compatible media. 152 | -------------------------------------------------------------------------------- /examples/file.js: -------------------------------------------------------------------------------- 1 | var Hapi = require('hapi'); 2 | 3 | var server = new Hapi.Server(8000, 'localhost', { cors: true }); 4 | server.pack.register({ 5 | plugin: require('../index'), 6 | options: { 7 | output: 'file', 8 | file: { 9 | outputPath: __dirname + '/../boom.mp4' 10 | } 11 | } 12 | }, function (err) { 13 | if (err) { 14 | console.log(err); 15 | } 16 | }); 17 | 18 | var encoderEvents = server.plugins['hapi-video-encoder'].emitter; 19 | 20 | encoderEvents.on('transcode-start', function (data) { 21 | console.log('started', data); 22 | }); 23 | 24 | encoderEvents.on('transcode-progress', function (data) { 25 | console.log('progress', data); 26 | }); 27 | 28 | encoderEvents.on('transcode-stop', function (data) { 29 | console.log('stop', data); 30 | }); 31 | 32 | encoderEvents.on('transcode-error', function (error) { 33 | console.error('error', error); 34 | }); 35 | 36 | server.start(function () { 37 | console.log('Example file server listening'); 38 | }); 39 | -------------------------------------------------------------------------------- /examples/s3.js: -------------------------------------------------------------------------------- 1 | var Hapi = require('hapi'); 2 | 3 | var server = new Hapi.Server(8000, 'localhost', { cors: true }); 4 | server.pack.register({ 5 | plugin: require('../index'), 6 | options: { 7 | output: 's3', 8 | s3: { 9 | accessKey: 'AWS_KEY', 10 | secretKey: 'AWS_SECRET', 11 | region: 'us-west-2' 12 | } 13 | } 14 | }, function (err) { 15 | if (err) { 16 | console.log(err); 17 | } 18 | }); 19 | 20 | server.start(function () { 21 | console.log('Example file server listening'); 22 | }); 23 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Hoek = require('hoek'); 2 | var encoder = require('./lib/encoder'); 3 | 4 | var defaultOptions = { 5 | output: 'file', 6 | file: {}, 7 | tempDir: '/tmp', 8 | s3: { 9 | acl: 'bucket-owner-full-control', 10 | endpoint: 'https://s3.amazonaws.com' 11 | }, 12 | route: { 13 | path: '/media', 14 | fileParam: 'file', 15 | maxBytes: 5368709120 // 5 GB 16 | }, 17 | ffmpeg: { 18 | videoCodec: 'libx264', 19 | audioCodec: 'libfaac', 20 | nolog: true, 21 | options: [ 22 | '-pix_fmt yuv420p', 23 | '-profile:v baseline', 24 | '-preset fast', 25 | '-crf 23', 26 | '-movflags', 27 | '+faststart', 28 | '-vf scale=trunc(in_w/2)*2:trunc(in_h/2)*2', 29 | '-f mp4' 30 | ] 31 | } 32 | }; 33 | 34 | exports.register = function (plugin, options, next) { 35 | options = Hoek.applyToDefaults(defaultOptions, options); 36 | encoder.register(plugin, options); 37 | next(); 38 | }; 39 | 40 | exports.register.attributes = { 41 | pkg: require('./package.json') 42 | }; 43 | -------------------------------------------------------------------------------- /lib/encoder.js: -------------------------------------------------------------------------------- 1 | var events = require('events'); 2 | 3 | var fileOutput = require('./output/file'); 4 | var s3Output = require('./output/s3'); 5 | 6 | exports.register = function (plugin, options) { 7 | // Get output handler 8 | options.emitter = new events.EventEmitter(); 9 | var handler = null; 10 | 11 | switch (options.output) { 12 | case 'file': 13 | handler = fileOutput(options); 14 | break; 15 | case 's3': 16 | handler = s3Output(options); 17 | break; 18 | } 19 | 20 | // Build route 21 | plugin.route({ 22 | method: 'POST', 23 | path: options.route.path, 24 | handler: handler, 25 | config: { 26 | payload: { 27 | output: 'stream', 28 | maxBytes: options.route.maxBytes 29 | } 30 | } 31 | }); 32 | 33 | plugin.expose('emitter', options.emitter); 34 | }; 35 | -------------------------------------------------------------------------------- /lib/ffmpeg.js: -------------------------------------------------------------------------------- 1 | var FFMpeg = require('fluent-ffmpeg'); 2 | var fs = require('fs'); 3 | var temp = require('temp'); 4 | 5 | function saveToTemp(stream, cb) { 6 | var tempStream = temp.createWriteStream(); 7 | 8 | stream.pipe(tempStream, { end: true }); 9 | stream.on('end', function () { 10 | tempStream.end(); 11 | cb(null, tempStream.path); 12 | }); 13 | } 14 | 15 | function doEncode(options) { 16 | var tempFile = options.file.filePath; 17 | new FFMpeg({ source: options.source, nolog: options.ffmpeg.nolog }) 18 | .withVideoCodec(options.ffmpeg.videoCodec) 19 | .withAudioCodec(options.ffmpeg.audioCodec) 20 | .addOptions(options.ffmpeg.options) 21 | .on('start', function (commandLine) { 22 | options.emitter.emit('transcode-start', { jobId: options.jobId, commandLink: commandLine }); 23 | options.emitter.emit('transcode-start-' + options.jobId, commandLine); 24 | }) 25 | .on('progress', function (info) { 26 | options.emitter.emit('transcode-progress', { jobId: options.jobId, info: info }); 27 | options.emitter.emit('transcode-progress-' + options.jobId, info); 28 | }) 29 | .on('error', function (err, stdout, stderr) { 30 | options.emitter.emit('transcode-error', { jobId: options.jobId, err: err, stdout: stdout, stderr: stderr }); 31 | options.emitter.emit('transcode-error-' + options.jobId); 32 | }) 33 | .on('end', function () { 34 | options.emitter.emit('transcode-end', { jobId: options.jobId }); 35 | options.emitter.emit('transcode-end-' + options.jobId); 36 | if (typeof(options.source) === 'string') { 37 | fs.unlink(options.source); 38 | } 39 | }) 40 | .saveToFile(tempFile); 41 | } 42 | 43 | exports.encode = function (options, cb) { 44 | if (options.mediaType.indexOf('quicktime') !== -1) { 45 | saveToTemp(options.source, function (err, path) { 46 | options.source = path; 47 | doEncode(options, cb); 48 | }); 49 | } else { 50 | doEncode(options, cb); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /lib/output/file.js: -------------------------------------------------------------------------------- 1 | var ffmpeg = require('../ffmpeg'); 2 | var uuid = require('node-uuid'); 3 | 4 | module.exports = function (options) { 5 | return function (request, reply) { 6 | options.jobId = uuid.v4(); 7 | options.mediaType = request.payload[options.route.fileParam].hapi.headers['content-type']; 8 | options.source = request.payload[options.route.fileParam]; 9 | options.file.filePath = request.payload.outputPath || options.file.outputPath || options.tempDir + '/' + options.jobId + '.mp4'; 10 | ffmpeg.encode(options); 11 | reply({ jobId: options.jobId }); 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/output/s3.js: -------------------------------------------------------------------------------- 1 | var AWS = require('s3-upload-stream/node_modules/aws-sdk'); 2 | var ffmpeg = require('../ffmpeg'); 3 | var fs = require('fs'); 4 | var Uploader = require('s3-upload-stream').Uploader; 5 | var uuid = require('node-uuid'); 6 | 7 | var s3 = null; 8 | 9 | function uploadToS3(options) { 10 | options.emitter.emit('s3-start', { jobId: options.jobId }); 11 | new Uploader({ 12 | s3Client: s3 13 | }, { 14 | Bucket: options.s3.bucket, 15 | Key: options.s3.key, 16 | ACL: options.s3.acl, 17 | ContentType: 'video/mp4' 18 | }, function (err, uploadStream) { 19 | if (err) { 20 | options.emitter.emit('s3-error', { jobId: options.jobId, err: err.message }); 21 | } else { 22 | uploadStream.on('chunk', function (data) { 23 | options.emitter.emit('s3-progress', { jobId: options.jobId, data: data }); 24 | }); 25 | 26 | // Emitted when all parts have been flushed to S3 and the multipart 27 | // upload has been finalized. 28 | uploadStream.on('uploaded', function (data) { 29 | fs.unlink(options.file.filePath); 30 | options.emitter.emit('s3-end', { jobId: options.jobId, data: data }); 31 | }); 32 | 33 | uploadStream.on('error', function (err) { 34 | options.emitter.emit('s3-error', { jobId: options.jobId, err: err.message }); 35 | fs.unlink(options.file.filePath); 36 | }); 37 | 38 | fs.createReadStream(options.file.filePath).pipe(uploadStream); 39 | } 40 | }); 41 | } 42 | 43 | module.exports = function (options) { 44 | AWS.config.update({ 45 | accessKeyId: options.s3.accessKey, 46 | secretAccessKey: options.s3.secretKey, 47 | region: options.s3.region 48 | }); 49 | 50 | s3 = new AWS.S3({ 51 | endpoint: options.s3.endpoint 52 | }); 53 | 54 | return function (request, reply) { 55 | options.jobId = uuid.v4(); 56 | options.mediaType = request.payload[options.route.fileParam].hapi.headers['content-type']; 57 | options.source = request.payload[options.route.fileParam]; 58 | options.s3.bucket = request.payload.bucket || options.s3.bucket; 59 | options.s3.key = request.payload.key || request.payload[options.route.fileParam].hapi.filename; 60 | options.file.filePath = options.tempDir + '/' + options.jobId + '.mp4'; 61 | 62 | options.emitter.once('transcode-end-' + options.jobId, function () { 63 | uploadToS3(options); 64 | }); 65 | 66 | ffmpeg.encode(options); 67 | reply({ jobId: options.jobId }); 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-video-encoder", 3 | "version": "0.10.0", 4 | "description": "Plugin for streaming uploaded video media to ffmpeg", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "./node_modules/lab/bin/lab test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/petreboy14/hapi-video-encoder.git" 12 | }, 13 | "keywords": [ 14 | "hapi", 15 | "ffmpeg", 16 | "video", 17 | "stream", 18 | "upload", 19 | "encode", 20 | "decode" 21 | ], 22 | "author": "Peter Henning", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/petreboy14/hapi-video-encoder/issues" 26 | }, 27 | "homepage": "https://github.com/petreboy14/hapi-video-encoder", 28 | "devDependencies": { 29 | "hapi": "^6.0.2", 30 | "jshint": "^2.5.1", 31 | "lab": "^3.2.1", 32 | "precommit-hook": "^1.0.2" 33 | }, 34 | "dependencies": { 35 | "fluent-ffmpeg": "^1.7.2", 36 | "hoek": "^2.3.0", 37 | "joi": "^4.6.1", 38 | "node-uuid": "^1.4.1", 39 | "s3-upload-stream": "^0.4.0", 40 | "temp": "^0.8.0", 41 | "through2": "^1.0.0" 42 | } 43 | } 44 | --------------------------------------------------------------------------------