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

Commit fb101c2

Browse files
author
Sjoerd Tieleman
committed
Merge pull request #34 from madebyhiro/pr/33
HLS jobs
2 parents 584ac52 + 100b50d commit fb101c2

4 files changed

Lines changed: 222 additions & 58 deletions

File tree

README.markdown

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ Parameters (HTTP POST data, should be valid JSON object):
134134
"size": "160x90",
135135
"format": "png"
136136
},
137+
"segments_options" : {
138+
"segment_time": 10
139+
},
137140
"callback_urls": ["http://example.com/notifications"]
138141
}
139142

@@ -143,6 +146,9 @@ Responses:
143146
* `400 Bad Request` - Invalid request (format)
144147
* `503 Service Unavailable` - Transcoder not accepting jobs at the moment (all encoding slots are in use)
145148

149+
150+
Required options are `source_file`, `destination_file` and `encoder_options`. Input and output files must be *absolute* paths.
151+
146152
The `callback_urls` array is optional and is a list (array) of HTTP endpoints that should be notified once encoding finishes (due to the job being complete or some error condition). The notification will sent using HTTP PUT to the specified endpoints with the job status. It will also include a custom HTTP header "X-Codem-Notify-Timestamp" that contains the timestamp (in milliseconds) at which the notification was generated and sent. It is best to observe this header to determine the order in which notifications are received at the other end due to network lag or other circumstances that may cause notifications to be received out of order.
147153

148154
The `thumbnail_options` object is optional and contains a set of thumbnails that should be encoded after the transcoding is complete. Thumbnails are captured from the source file for maximum quality. The options for thumbnails include:
@@ -159,7 +165,17 @@ The `thumbnail_options` object is optional and contains a set of thumbnails that
159165

160166
If you specify thumbnails but an error occurs during generation, your job will be marked as failed. If you don't specify a valid `seconds` or `percentages` option thumbnail generation will be skipped but the job can still be completed successfully.
161167

162-
All other options are required (`source_file`, `destination_file` and `encoder_options`). Input and output files must be *absolute* paths.
168+
The `segments_options` object is optional and contains segment time (duration) in seconds. Segmented videos are used in [HLS](https://en.wikipedia.org/wiki/HTTP_Live_Streaming). These options are applied to the encoded video, thus `encoder_options` are required. Moreover `encoder_options` should prepare video for segmenting, because bitstream
169+
filter [h264_mp4toannexb](https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb) will be applied to the video. Therefore it is recommended to transcode to an MP4 file before segmenting.
170+
171+
The segmenting command looks like:
172+
173+
ffmpeg -i /tmp/46ee0a404a4b75d85c09d98a7c6b403579ee9f99.mp4 -codec copy -map 0 \
174+
-f segment -vbsf h264_mp4toannexb -flags -global_header -segment_format mpegts \
175+
-segment_list /path/to/dest_file_dir/dest_file_name.m3u8 -segment_time 10 \
176+
/path/to/dest_file_dir/dest_file_name-%06d.ts
177+
178+
`46ee0a404a4b75d85c09d98a7c6b403579ee9f99.mp4` is a temporary encoded file (generated by Codem). After transcoding and segmenting you end up with the transcoded file, as well as the segments/playlist.
163179

164180
### Thumbnail-only job
165181

@@ -259,6 +275,61 @@ Thumbnail-only job (160x90 in PNG format every 10% of the video).
259275

260276
Output: {"message":"The transcoder accepted your job.","job_id":"d4b1dfebe6860839b2c21b70f35938d870011682"}
261277

278+
279+
Segmenting job.
280+
281+
# curl -d '{"source_file": "/tmp/video.mp4", "destination_file": "/tmp/output/test.mp4", "encoder_options": "-vb 2000k -minrate 2000k -maxrate 2000k -bufsize 2000k -s 1280x720 -acodec aac -strict -2 -ab 192000 -ar 44100 -ac 2 -vcodec libx264 -movflags faststart", "segments_options": {"segment_time": 10} }' http://localhost:8080/jobs
282+
283+
Output: {"message":"The transcoder accepted your job.","job_id":"7dc3c268783d7f3c737f3a134ccf1d4f15bb8442"}
284+
285+
Status of finished job:
286+
287+
# curl http://localhost:8080/jobs/7dc3c268783d7f3c737f3a134ccf1d4f15bb8442
288+
289+
Output:
290+
{
291+
"id": "7dc3c268783d7f3c737f3a134ccf1d4f15bb8442",
292+
"status": "success",
293+
"progress": 1,
294+
"duration": 1,
295+
"filesize": 783373,
296+
"message": "ffmpeg finished succesfully.",
297+
"playlist": "/tmp/output/test.m3u8",
298+
"segments": [
299+
"/tmp/output/test-000000.ts",
300+
"/tmp/output/test-000001.ts",
301+
"/tmp/output/test-000002.ts",
302+
"/tmp/output/test-000003.ts",
303+
"/tmp/output/test-000004.ts",
304+
"/tmp/output/test-000005.ts"
305+
]
306+
}
307+
308+
Segmenting-only job (you are expected to have a valid MP4 file suitable for segmenting as the input).
309+
310+
# curl -d '{"source_file": "/tmp/video.mp4","destination_file":"/tmp/segments/video.mp4","encoder_options": "", "segments_options": {"segment_time": 10} }' http://localhost:8080/jobs
311+
312+
Output: {"message":"The transcoder accepted your job.","job_id":"c7599790527c0bb173cc7a0c44411aaca5c1550a"}
313+
314+
Status of finished job:
315+
316+
# curl http://localhost:8080/jobs/c7599790527c0bb173cc7a0c44411aaca5c1550a
317+
318+
Output:
319+
{
320+
"id":"c7599790527c0bb173cc7a0c44411aaca5c1550a",
321+
"status":"success",
322+
"progress":1,
323+
"duration":26,
324+
"filesize":6734045,
325+
"message":"finished segmenting job.",
326+
"playlist":"/tmp/segments/video.m3u8",
327+
"segments":[
328+
"/tmp/segments/video-000000.ts",
329+
"/tmp/segments/video-000001.ts",
330+
"/tmp/segments/video-000002.ts"
331+
]
332+
}
262333
## Issues and support
263334

264335
If you run into any issues while using codem-transcode please use the Github issue tracker to see if it is a known problem
@@ -269,4 +340,4 @@ commercial support or are already receiving commercial support, feel free to con
269340

270341
## License
271342

272-
Codem-transcode is released under the MIT license, see `LICENSE.txt`.
343+
Codem-transcode is released under the MIT license, see `LICENSE.txt`.

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: 134 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,104 @@ 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;
282+
}
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+
})
293+
},
294+
295+
processSegments: function(callbacks){
296+
if (!this.parsedOpts()['segments_options']) {
297+
callbacks.success(this);
298+
return;
281299
}
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+
var inputFile = (this.parsedOpts()['encoder_options'].length > 0) ? job.tmpFile : this.parsedOpts()['source_file']
313+
314+
args.push('-i', inputFile,
315+
'-codec', 'copy', '-map', '0', '-f', 'segment',
316+
'-vbsf', 'h264_mp4toannexb', '-flags', '-global_header',
317+
'-segment_format', 'mpegts', '-segment_list', playlistPath,
318+
'-segment_time', segmentTime, segmentsFormat);
319+
320+
child_process.execFile(config['encoder'], args, function(error, stdout, stderr) {
321+
if (error) {
322+
job.lastMessage = 'Error while generating segments: ' + error.message;
323+
callbacks.error(job);
324+
return;
325+
}
326+
327+
fs.readdir(playlistDir, function(error, files) {
328+
if (error) {
329+
job.lastMessage = 'Error while generating segments: ' + error.message;
330+
callbacks.error(job);
331+
return;
332+
}
333+
334+
job.segments = JSON.stringify(files.filter(
335+
function(file){ return file.match(new RegExp(playlistName + "-\\d+\\.ts")) }
336+
).map(
337+
function(file){ return path.join(playlistDir, file) }
338+
));
339+
340+
job.playlist = playlistPath;
341+
342+
callbacks.success(job);
343+
});
344+
});
282345
},
283-
processThumbnails: function() {
346+
347+
processThumbnails: function(callbacks) {
348+
if (!this.parsedOpts()['thumbnail_options']) {
349+
callbacks.success(this);
350+
return;
351+
}
352+
284353
logger.log("Processing thumbnails for job " + this.internalId + ".");
285354
var thumbOpts = this.parsedOpts()['thumbnail_options'];
286355
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 {
356+
357+
if (!range) {
302358
// no valid range
303359
logger.log("No valid thumbnails to process for job " + this.internalId + ". Skipping...");
304-
this.finalize(0);
360+
callbacks.success(this);
361+
return;
305362
}
363+
364+
var job = this;
365+
async.parallel(
366+
range.map(job.execThumbJob.bind(job)),
367+
function(err, results) {
368+
if (err) {
369+
job.lastMessage = err.message;
370+
callbacks.error(job);
371+
} else {
372+
job.thumbnails = JSON.stringify(results);
373+
callbacks.success(job);
374+
}
375+
}
376+
);
306377
},
307378
execThumbJob: function(offset) {
308379
var job = this;
@@ -331,43 +402,48 @@ var Job = JobUtils.getDatabase().define('Job', {
331402
});
332403
}
333404
},
334-
finalize: function(code, thumbnails) {
405+
finalize: function(code) {
335406
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-
}
355407

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 {
408+
if (code != 0) {
409+
job.exitHandler(code, "ffmpeg finished with an error: '" + job.lastMessage + "' (" + code + ").");
410+
return;
411+
}
412+
413+
if (!job.tmpFile) {
414+
// No tmpFile, hence no transcoding, only thumbnails or segmenting
415+
if (job.parsedOpts()['thumbnail_options']) {
416+
job.exitHandler(code, 'finished thumbnail job.');
417+
} else {
418+
job.exitHandler(code, 'finished segmenting job.');
419+
}
420+
return;
421+
}
422+
423+
fs.rename(job.tmpFile, job.parsedOpts()['destination_file'], function (err) {
424+
if (err) {
425+
if ( (err.message).match(/EXDEV/) ) {
426+
/*
427+
EXDEV fix, since util.pump is deprecated, using stream.pipe
428+
example from http://stackoverflow.com/questions/11293857/fastest-way-to-copy-file-in-node-js
429+
*/
430+
try {
431+
logger.log('ffmpeg finished successfully, trying to copy across partitions');
432+
fs.createReadStream(job.tmpFile).pipe(fs.createWriteStream(job.parsedOpts()['destination_file']));
361433
job.exitHandler(code, 'ffmpeg finished succesfully.');
434+
} catch (err) {
435+
logger.log(err);
436+
job.exitHandler(-1, 'ffmpeg finished succesfully, but unable to move file to different partition (' + job.parsedOpts()['destination_file'] + ').');
362437
}
363-
});
438+
439+
} else {
440+
logger.log(err);
441+
job.exitHandler(-1, 'ffmpeg finished succesfully, but unable to move file to destination (' + job.parsedOpts()['destination_file'] + ').');
442+
}
364443
} else {
365-
// No tmpFile, hence no transcoding, only thumbnails
366-
job.exitHandler(code, 'finished thumbnail job.');
444+
job.exitHandler(code, 'ffmpeg finished succesfully.');
367445
}
368-
} else {
369-
job.exitHandler(code, "ffmpeg finished with an error: '" + job.lastMessage + "' (" + code + ").")
370-
}
446+
});
371447
},
372448
toJSON: function() {
373449
var obj = {
@@ -377,13 +453,17 @@ var Job = JobUtils.getDatabase().define('Job', {
377453
'duration': this.duration,
378454
'filesize': this.filesize,
379455
'message': this.message,
380-
381456
};
382457

383458
if (this.thumbnails) {
384459
obj['thumbnails'] = JSON.parse(this.thumbnails);
385460
}
386-
461+
462+
if (this.playlist) { obj['playlist'] = this.playlist; }
463+
if (this.segments) {
464+
obj['segments'] = JSON.parse(this.segments);
465+
}
466+
387467
return obj;
388468
},
389469
progressHandler: function(data) {
@@ -448,4 +528,4 @@ var Job = JobUtils.getDatabase().define('Job', {
448528
}
449529
});
450530

451-
module.exports = Job;
531+
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)