Skip to content
This repository was archived by the owner on Mar 25, 2019. It is now read-only.

Commit c2cafa0

Browse files
committed
Segmenting videos (HLS support)
Added `segments_options` to job input options, which only can contain a `segment_time` time parameter so far. Also added `playlist` and `segments_options` columns to the Job model and API ouput. This feature adds as a "post-processing" action and requires `encoder_options` to be set. They also should prepare file to be "segmentable". Not all files can be segmented without re-encoding. Segmenting applies on ready `destination_file`. It generates playlist named "destination_file_basename.m3u8" and a set of segments in a mpegts format named "destination_file_basename-%06d.ts" Also method `Job#finalize` refactored a bit. Moved few guard clauses to the top instead of 3-levels if-else conditions. Related to #31
1 parent 584ac52 commit c2cafa0

3 files changed

Lines changed: 144 additions & 56 deletions

File tree

lib/job-handler.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,15 @@ function validateJobRequest(postData, callback) {
9898
// destination_file: the output file
9999
// encoder_options: the flags for the encoder
100100
// thumbnail_options: options to generate thumbnails (optional)
101+
// segments_options: options to generate segmented video (optional)
101102
// callback_urls: array of callbacks to notify of events (optional)
102103
var missingFields = [];
103104
var opts = {};
104105

105106
try {
106107
var obj = JSON.parse(postData);
107108
var requiredFields = ['source_file', 'destination_file', 'encoder_options'];
108-
var acceptedFields = ['source_file', 'destination_file', 'encoder_options', 'thumbnail_options', 'callback_urls'];
109+
var acceptedFields = ['source_file', 'destination_file', 'encoder_options', 'thumbnail_options', 'segments_options', 'callback_urls'];
109110

110111
for (var field in requiredFields) {
111112
if (typeof(obj[requiredFields[field]]) == "undefined") {
@@ -157,4 +158,4 @@ function hasFreeSlots() {
157158
function removeItemFromSlot(item) {
158159
var idx = slots.indexOf(item);
159160
if(idx!=-1) slots.splice(idx, 1);
160-
}
161+
}

lib/job.js

Lines changed: 129 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ var Job = JobUtils.getDatabase().define('Job', {
144144
opts: { type: Sequelize.TEXT, defaultValue: null },
145145
thumbnails: { type: Sequelize.TEXT, defaultValue: null },
146146
message: { type: Sequelize.TEXT, defaultValue: null },
147+
playlist: { type: Sequelize.STRING, defaultValue: null },
148+
segments: { type: Sequelize.TEXT, defaultValue: null },
147149
createdAt: Sequelize.DATE,
148150
updatedAt: Sequelize.DATE
149151
}, {
@@ -274,35 +276,103 @@ var Job = JobUtils.getDatabase().define('Job', {
274276
}
275277
},
276278
didFinish: function(code) {
277-
if (code == 0 && this.parsedOpts()['thumbnail_options']) {
278-
this.processThumbnails();
279-
} else {
279+
if (code != 0) {
280280
this.finalize(code);
281+
return;
281282
}
283+
284+
this.processThumbnails({
285+
error: function(job) { job.finalize(1); },
286+
success: function(job) {
287+
job.processSegments({
288+
error: function(job) { job.finalize(1); },
289+
success: function(job) { job.finalize(0); }
290+
})
291+
}
292+
})
282293
},
283-
processThumbnails: function() {
294+
295+
processSegments: function(callbacks){
296+
if (!this.parsedOpts()['segments_options']) {
297+
callbacks.success(this);
298+
return;
299+
}
300+
301+
logger.log("Processing segments for job " + this.internalId + ".");
302+
303+
var job = this;
304+
var args = [];
305+
var segmentsOpts = this.parsedOpts()['segments_options'];
306+
var segmentTime = segmentsOpts['segment_time'];
307+
var destinationFile = this.parsedOpts()['destination_file'];
308+
var playlistName = path.basename(destinationFile, path.extname(destinationFile));
309+
var playlistDir = path.dirname(destinationFile);
310+
var playlistPath = [playlistDir, playlistName].join(path.sep) + '.m3u8';
311+
var segmentsFormat = [playlistDir, playlistName].join(path.sep) + '-%06d.ts'
312+
313+
args.push('-i', job.tmpFile,
314+
'-codec', 'copy', '-map', '0', '-f', 'segment',
315+
'-vbsf', 'h264_mp4toannexb', '-flags', '-global_header',
316+
'-segment_format', 'mpegts', '-segment_list', playlistPath,
317+
'-segment_time', segmentTime, segmentsFormat);
318+
319+
child_process.execFile(config['encoder'], args, function(error, stdout, stderr) {
320+
if (error) {
321+
job.lastMessage = 'Error while generating segments: ' + error.message;
322+
callbacks.error(job);
323+
return;
324+
}
325+
326+
fs.readdir(playlistDir, function(error, files) {
327+
if (error) {
328+
job.lastMessage = 'Error while generating segments: ' + error.message;
329+
callbacks.error(job);
330+
return;
331+
}
332+
333+
job.segments = JSON.stringify(files.filter(
334+
function(file){ return file.match(new RegExp(playlistName + "-\\d+\\.ts")) }
335+
).map(
336+
function(file){ return path.join(playlistDir, file) }
337+
));
338+
339+
job.playlist = playlistPath;
340+
341+
callbacks.success(job);
342+
});
343+
});
344+
},
345+
346+
processThumbnails: function(callbacks) {
347+
if (!this.parsedOpts()['thumbnail_options']) {
348+
callbacks.success(this);
349+
return;
350+
}
351+
284352
logger.log("Processing thumbnails for job " + this.internalId + ".");
285353
var thumbOpts = this.parsedOpts()['thumbnail_options'];
286354
var range = JobUtils.generateRangeFromThumbOpts(thumbOpts, this.duration);
287-
288-
if (range) {
289-
var job = this;
290-
async.parallel(
291-
range.map(job.execThumbJob.bind(job)),
292-
function(err, results) {
293-
if (err) {
294-
job.lastMessage = err.message;
295-
job.finalize(1);
296-
} else {
297-
job.finalize(0, results);
298-
}
299-
}
300-
);
301-
} else {
355+
356+
if (!range) {
302357
// no valid range
303358
logger.log("No valid thumbnails to process for job " + this.internalId + ". Skipping...");
304-
this.finalize(0);
359+
callbacks.success(this);
360+
return;
305361
}
362+
363+
var job = this;
364+
async.parallel(
365+
range.map(job.execThumbJob.bind(job)),
366+
function(err, results) {
367+
if (err) {
368+
job.lastMessage = err.message;
369+
callbacks.error(job);
370+
} else {
371+
job.thumbnails = JSON.stringify(results);
372+
callbacks.success(job);
373+
}
374+
}
375+
);
306376
},
307377
execThumbJob: function(offset) {
308378
var job = this;
@@ -331,43 +401,44 @@ var Job = JobUtils.getDatabase().define('Job', {
331401
});
332402
}
333403
},
334-
finalize: function(code, thumbnails) {
404+
finalize: function(code) {
335405
var job = this;
336-
if (thumbnails) job.thumbnails = JSON.stringify(thumbnails);
337-
338-
if (code == 0) {
339-
if (job.tmpFile) {
340-
fs.rename(job.tmpFile, job.parsedOpts()['destination_file'], function (err) {
341-
if (err) {
342-
if ( (err.message).match(/EXDEV/) ) {
343-
/*
344-
EXDEV fix, since util.pump is deprecated, using stream.pipe
345-
example from http://stackoverflow.com/questions/11293857/fastest-way-to-copy-file-in-node-js
346-
*/
347-
try {
348-
logger.log('ffmpeg finished successfully, trying to copy across partitions');
349-
fs.createReadStream(job.tmpFile).pipe(fs.createWriteStream(job.parsedOpts()['destination_file']));
350-
job.exitHandler(code, 'ffmpeg finished succesfully.');
351-
} catch (err) {
352-
logger.log(err);
353-
job.exitHandler(-1, 'ffmpeg finished succesfully, but unable to move file to different partition (' + job.parsedOpts()['destination_file'] + ').');
354-
}
355406

356-
} else {
357-
logger.log(err);
358-
job.exitHandler(-1, 'ffmpeg finished succesfully, but unable to move file to destination (' + job.parsedOpts()['destination_file'] + ').');
359-
}
360-
} else {
407+
if (code != 0) {
408+
job.exitHandler(code, "ffmpeg finished with an error: '" + job.lastMessage + "' (" + code + ").");
409+
return;
410+
}
411+
412+
if (!job.tmpFile) {
413+
// No tmpFile, hence no transcoding, only thumbnails
414+
job.exitHandler(code, 'finished thumbnail job.');
415+
return;
416+
}
417+
418+
fs.rename(job.tmpFile, job.parsedOpts()['destination_file'], function (err) {
419+
if (err) {
420+
if ( (err.message).match(/EXDEV/) ) {
421+
/*
422+
EXDEV fix, since util.pump is deprecated, using stream.pipe
423+
example from http://stackoverflow.com/questions/11293857/fastest-way-to-copy-file-in-node-js
424+
*/
425+
try {
426+
logger.log('ffmpeg finished successfully, trying to copy across partitions');
427+
fs.createReadStream(job.tmpFile).pipe(fs.createWriteStream(job.parsedOpts()['destination_file']));
361428
job.exitHandler(code, 'ffmpeg finished succesfully.');
429+
} catch (err) {
430+
logger.log(err);
431+
job.exitHandler(-1, 'ffmpeg finished succesfully, but unable to move file to different partition (' + job.parsedOpts()['destination_file'] + ').');
362432
}
363-
});
433+
434+
} else {
435+
logger.log(err);
436+
job.exitHandler(-1, 'ffmpeg finished succesfully, but unable to move file to destination (' + job.parsedOpts()['destination_file'] + ').');
437+
}
364438
} else {
365-
// No tmpFile, hence no transcoding, only thumbnails
366-
job.exitHandler(code, 'finished thumbnail job.');
439+
job.exitHandler(code, 'ffmpeg finished succesfully.');
367440
}
368-
} else {
369-
job.exitHandler(code, "ffmpeg finished with an error: '" + job.lastMessage + "' (" + code + ").")
370-
}
441+
});
371442
},
372443
toJSON: function() {
373444
var obj = {
@@ -377,13 +448,17 @@ var Job = JobUtils.getDatabase().define('Job', {
377448
'duration': this.duration,
378449
'filesize': this.filesize,
379450
'message': this.message,
380-
381451
};
382452

383453
if (this.thumbnails) {
384454
obj['thumbnails'] = JSON.parse(this.thumbnails);
385455
}
386-
456+
457+
if (this.playlist) { obj['playlist'] = this.playlist; }
458+
if (this.segments) {
459+
obj['segments'] = JSON.parse(this.segments);
460+
}
461+
387462
return obj;
388463
},
389464
progressHandler: function(data) {
@@ -448,4 +523,4 @@ var Job = JobUtils.getDatabase().define('Job', {
448523
}
449524
});
450525

451-
module.exports = Job;
526+
module.exports = Job;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module.exports = {
2+
up: function(migration, DataTypes, done) {
3+
// add altering commands here
4+
migration.addColumn('Jobs', 'playlist', { type: DataTypes.STRING, defaultValue: null }).complete(done);
5+
migration.addColumn('Jobs', 'segments', { type: DataTypes.TEXT, defaultValue: null }).complete(done);
6+
},
7+
down: function(migration, DataTypes, done) {
8+
// add reverting commands here
9+
migration.removeColumn('Jobs', 'playlist').complete(done);
10+
migration.removeColumn('Jobs', 'segments').complete(done);
11+
}
12+
}

0 commit comments

Comments
 (0)