@openveo/publish
Version:
OpenVeo video publication plugin
672 lines (575 loc) • 20.1 kB
JavaScript
;
/**
* @module publish/providers/VideoPackage
*/
var util = require('util');
var path = require('path');
var fs = require('fs');
var async = require('async');
var ffmpeg = require('fluent-ffmpeg');
var mp4Box = require('mp4box');
var openVeoApi = require('@openveo/api');
var Package = process.requirePublish('app/server/packages/Package.js');
var ERRORS = process.requirePublish('app/server/packages/errors.js');
var STATES = process.requirePublish('app/server/packages/states.js');
var VideoPackageError = process.requirePublish('app/server/packages/VideoPackageError.js');
var ResourceFilter = openVeoApi.storages.ResourceFilter;
var fileSystem = openVeoApi.fileSystem;
// Accepted images files extensions in the package
var acceptedImagesExtensions = [
fileSystem.FILE_TYPES.JPG,
fileSystem.FILE_TYPES.GIF
];
/**
* Defines a VideoPackage to manage publication of a video file.
*
* @class VideoPackage
* @extends module:publish/packages/Package~Package
* @constructor
* @param {Object} mediaPackage Information about the video
* @param {module:publish/providers/VideoProvider~VideoProvider} videoProvider A video provider
* @param {module:publish/providers/PoiProvider~PoiProvider} poiProvider Points of interest provider
*/
function VideoPackage(mediaPackage, videoProvider, poiProvider) {
VideoPackage.super_.call(this, mediaPackage, videoProvider, poiProvider);
}
module.exports = VideoPackage;
util.inherits(VideoPackage, Package);
/**
* Process states for video packages.
*
* @const
* @type {Object}
*/
VideoPackage.STATES = {
MP4_DEFRAGMENTED: 'mp4Defragmented',
THUMB_GENERATED: 'thumbGenerated',
COPIED_IMAGES: 'copiedImages',
METADATA_RETRIEVED: 'metadataRetrieved'
};
Object.freeze(VideoPackage.STATES);
/**
* Video package process transitions (from one state to another).
*
* @const
* @type {Object}
*/
VideoPackage.TRANSITIONS = {
DEFRAGMENT_MP4: 'defragmentMp4',
GENERATE_THUMB: 'generateThumb',
COPY_IMAGES: 'copyImages',
GET_METADATA: 'getMetadata'
};
Object.freeze(VideoPackage.TRANSITIONS);
/**
* Define the order in which transitions will be executed for a video Package.
*
* @const
* @type {Object}
*/
VideoPackage.stateTransitions = [
Package.TRANSITIONS.INIT,
Package.TRANSITIONS.COPY_PACKAGE,
VideoPackage.TRANSITIONS.DEFRAGMENT_MP4,
VideoPackage.TRANSITIONS.GENERATE_THUMB,
VideoPackage.TRANSITIONS.GET_METADATA,
Package.TRANSITIONS.REMOVE_ORIGINAL_PACKAGE,
Package.TRANSITIONS.UPLOAD_MEDIA,
Package.TRANSITIONS.SYNCHRONIZE_MEDIA,
VideoPackage.TRANSITIONS.COPY_IMAGES,
Package.TRANSITIONS.CLEAN_DIRECTORY,
Package.TRANSITIONS.INIT_MERGE,
Package.TRANSITIONS.MERGE,
Package.TRANSITIONS.FINALIZE_MERGE,
Package.TRANSITIONS.REMOVE_PACKAGE
];
Object.freeze(VideoPackage.stateTransitions);
/**
* Define machine state authorized transitions depending on previous and next states.
*
* @const
* @type {Object}
*/
VideoPackage.stateMachine = Package.stateMachine.concat([
{
name: VideoPackage.TRANSITIONS.DEFRAGMENT_MP4,
from: Package.STATES.PACKAGE_COPIED,
to: VideoPackage.STATES.MP4_DEFRAGMENTED
},
{
name: VideoPackage.TRANSITIONS.GENERATE_THUMB,
from: VideoPackage.STATES.MP4_DEFRAGMENTED,
to: VideoPackage.STATES.THUMB_GENERATED
},
{
name: VideoPackage.TRANSITIONS.GET_METADATA,
from: VideoPackage.STATES.THUMB_GENERATED,
to: VideoPackage.STATES.METADATA_RETRIEVED
},
{
name: Package.TRANSITIONS.REMOVE_ORIGINAL_PACKAGE,
from: VideoPackage.STATES.METADATA_RETRIEVED,
to: Package.STATES.ORIGINAL_PACKAGE_REMOVED
},
{
name: VideoPackage.TRANSITIONS.COPY_IMAGES,
from: Package.STATES.MEDIA_SYNCHRONIZED,
to: VideoPackage.STATES.COPIED_IMAGES
},
{
name: Package.TRANSITIONS.CLEAN_DIRECTORY,
from: VideoPackage.STATES.COPIED_IMAGES,
to: Package.STATES.DIRECTORY_CLEANED
}
]);
Object.freeze(VideoPackage.stateMachine);
/**
* Defragments given MP4 file.
*
* If the input file is fragmented, FFMPEG will be used to defragment the MP4.
* The fragmentation detection of the file is based on isFragmented property returned by MP4Box after analyzing the
* moov box.
*
* If file is not fragmented, it does nothing.
*
* @param {String} mp4FilePath The path of the MP4 file to defragment
* @return {callback} Function to call when its done
*/
VideoPackage.prototype.defragment = function(mp4FilePath, callback) {
var defragmentationRequired = false;
var mp4FilePathElements = path.parse(mp4FilePath);
var defragmentedFilePath = path.join(
mp4FilePathElements.dir,
mp4FilePathElements.name + '-defrag' + mp4FilePathElements.ext
);
var self = this;
async.series([
// Detect if file needs defragmentation using MP4Box
function(callback) {
var mp4FileBoxes = mp4Box.createFile();
var mp4FileReader = fs.createReadStream(mp4FilePath);
var mp4FilePosition = 0;
var stopParse = false;
mp4FileBoxes.onError = callback;
mp4FileBoxes.onReady = function(info) {
stopParse = true;
defragmentationRequired = info.isFragmented;
mp4FileReader.destroy();
callback();
};
mp4FileReader.on('error', callback);
mp4FileReader.on('readable', function() {
if (!stopParse) {
var chunk = mp4FileReader.read();
if (chunk) {
var chunkBuffer = chunk.buffer;
chunkBuffer.fileStart = mp4FilePosition;
mp4FilePosition += chunkBuffer.byteLength;
mp4FileBoxes.appendBuffer(chunkBuffer);
} else {
mp4FileBoxes.flush();
}
}
});
},
// Defragment media file
function(callback) {
if (!defragmentationRequired) {
self.log('No defragmentation is needed for file ' + mp4FilePath, 'verbose');
return callback();
}
// MP4 defragmentation
ffmpeg(mp4FilePath)
.audioCodec('copy')
.videoCodec('copy')
.outputOptions('-movflags faststart')
.on('start', function() {
self.log(
'Starting defragmentation of ' + mp4FilePath + ' to ' + defragmentedFilePath,
'verbose'
);
})
.on('error', function(error) {
callback(error);
})
.on('end', function() {
self.log('Defragmentation complete for file ' + mp4FilePath, 'verbose');
callback();
})
.save(defragmentedFilePath);
},
// Remove original media file
function(callback) {
if (!defragmentationRequired) return callback();
// Replace original file
self.log('Removing original fragmented file ' + mp4FilePath, 'verbose');
fs.unlink(mp4FilePath, function(error) {
if (error) return callback(error);
callback();
});
},
// Rename fragmented media file
function(callback) {
if (!defragmentationRequired) return callback();
self.log('Renaming file ' + defragmentedFilePath + ' into ' + mp4FilePath, 'verbose');
fs.rename(defragmentedFilePath, mp4FilePath, function(error) {
if (error) return callback(error);
self.log(defragmentedFilePath + ' renamed into ' + mp4FilePath, 'verbose');
callback();
});
}
], callback);
};
/**
* Defragments the MP4.
*
* This is a transition.
*
* @async
* @return {Promise} Promise resolving when transition is done
*/
VideoPackage.prototype.defragmentMp4 = function() {
var self = this;
var mediaFilePath;
return new Promise(function(resolve, reject) {
async.series([
// Update package state
function(callback) {
self.updateState(self.mediaPackage.id, STATES.DEFRAGMENTING_MP4, callback);
},
// Get media file name
function(callback) {
self.getMediaFilePath(function(error, filePath) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.DEFRAGMENT_MP4_GET_MEDIA_FILE_PATH));
mediaFilePath = filePath;
callback();
});
},
// Defragment media file
function(callback) {
self.defragment(mediaFilePath, function(error) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.DEFRAGMENTATION));
callback();
});
}
], function(error) {
if (error) reject(error);
else resolve();
});
});
};
/**
* Generates a thumbnail for the video.
*
* If no thumbnail has been provided by the user form, ffmpeg will be
* used to extract an image from the video to generate a thumbnail.
*
* This is a transition.
*
* @async
* @return {Promise} Promise resolving when transition is done
*/
VideoPackage.prototype.generateThumb = function() {
var self = this;
var mediaFilePath;
return new Promise(function(resolve, reject) {
async.series([
// Update package state
function(callback) {
self.updateState(self.mediaPackage.id, STATES.GENERATING_THUMB, callback);
},
// Get media file name
function(callback) {
if (self.mediaPackage.mediaId && self.mediaPackage.mediaId.length) return callback();
self.getMediaFilePath(function(error, filePath) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GENERATE_THUMB_GET_MEDIA_FILE_PATH));
mediaFilePath = filePath;
callback();
});
},
// Copy thumbnail if it already exists for this package
function(callback) {
if (!self.mediaPackage.originalThumbnailPath) return callback();
self.log('Copy thumbnail in ' + self.packageTemporaryDirectory);
openVeoApi.fileSystem.copy(
self.mediaPackage.originalThumbnailPath,
path.join(self.packageTemporaryDirectory, 'thumbnail.jpg'),
function(error) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GENERATE_THUMB_COPY_ORIGINAL));
self.mediaPackage.thumbnail = '/publish/' + self.mediaPackage.id + '/thumbnail.jpg';
callback();
}
);
},
// Remove original thumbnail
function(callback) {
if (!self.mediaPackage.originalThumbnailPath) return callback();
self.log('Remove original thumbnail ' + self.mediaPackage.originalThumbnailPath);
fs.unlink(self.mediaPackage.originalThumbnailPath, function(error) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GENERATE_THUMB_REMOVE_ORIGINAL));
callback();
});
},
// Generate thumb
function(callback) {
if (self.mediaPackage.originalThumbnailPath) return callback();
self.log('Generate thumbnail in ' + self.packageTemporaryDirectory);
ffmpeg(mediaFilePath).screenshots({
timestamps: ['10%'],
filename: 'thumbnail.jpg',
folder: self.packageTemporaryDirectory
}).on('error', function(error) {
callback(new VideoPackageError(error.message, ERRORS.GENERATE_THUMB));
}).on('end', function() {
self.mediaPackage.thumbnail = '/publish/' + self.mediaPackage.id + '/thumbnail.jpg';
callback();
});
},
// Update package
function(callback) {
self.videoProvider.updateThumbnail(self.mediaPackage.id, self.mediaPackage.thumbnail, function(error) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GENERATE_THUMB_UPDATE_PACKAGE));
callback();
});
}
], function(error) {
if (error) reject(error);
else resolve();
});
});
};
/**
* Retrieves information about the video.
*
* This is a transition.
*
* @async
* @return {Promise} Promise resolving when transition is done
*/
VideoPackage.prototype.getMetadata = function() {
var self = this;
var mediaFilePath;
return new Promise(function(resolve, reject) {
async.series([
// Update package state
function(callback) {
self.updateState(self.mediaPackage.id, STATES.GETTING_METADATA, callback);
},
// Get media file name
function(callback) {
if (self.mediaPackage.mediasHeights && self.mediaPackage.mediasHeights.length) return callback();
self.getMediaFilePath(function(error, filePath) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GET_METADATA_GET_MEDIA_FILE_PATH));
mediaFilePath = filePath;
callback();
});
},
// Get video height
function(callback) {
if (self.mediaPackage.mediasHeights && self.mediaPackage.mediasHeights.length) return callback();
self.getVideoHeight(mediaFilePath, function(error, videoHeight) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GET_METADATA_GET_MEDIAS_HEIGHTS));
self.mediaPackage.mediasHeights = [videoHeight];
callback();
});
},
// Update package
function(callback) {
self.videoProvider.updateMediasHeights(self.mediaPackage.id, self.mediaPackage.mediasHeights, function(error) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.GET_METADATA_UPDATE_PACKAGE));
callback();
});
}
], function(error) {
if (error) reject(error);
else resolve();
});
});
};
/**
* Copies presentation images from temporary directory to the public directory.
*
* This is a transition.
*
* @async
* @return {Promise} Promise resolving when transition is done
*/
VideoPackage.prototype.copyImages = function() {
var self = this;
return new Promise(function(resolve, reject) {
var extractDirectory = self.packageTemporaryDirectory;
var videoFinalDir = path.join(self.mediasPublicPath, self.mediaPackage.id);
var resources = [];
var filesToCopy = [];
self.log('Copy images to ' + videoFinalDir);
async.series([
// Change state
function(callback) {
self.updateState(self.mediaPackage.id, STATES.COPYING_IMAGES, callback);
},
// Read directory
function(callback) {
self.log('Scan directory ' + extractDirectory + ' for images', 'verbose');
fs.readdir(extractDirectory, function(error, files) {
if (error)
callback(new VideoPackageError(error.message, ERRORS.SCAN_FOR_IMAGES));
else {
resources = files;
callback();
}
});
},
// Validate files in the directory to keep only accepted types
function(callback) {
var filesToValidate = {};
var filesValidationDescriptor = {};
resources.forEach(function(resource) {
filesToValidate[resource] = path.join(extractDirectory, resource);
filesValidationDescriptor[resource] = {in: acceptedImagesExtensions};
});
openVeoApi.util.validateFiles(filesToValidate, filesValidationDescriptor, function(error, files) {
if (error)
self.log(error.message, 'warn');
for (var filePath in files) {
if (files[filePath].isValid)
filesToCopy.push(filePath);
}
callback();
});
},
// Copy images
function(callback) {
var filesLeftToCopy = filesToCopy.length;
if (!filesToCopy.length) return callback();
filesToCopy.forEach(function(file) {
self.log(
'Copy image ' + path.join(extractDirectory, file) + ' to ' + path.join(videoFinalDir, file),
'verbose'
);
openVeoApi.fileSystem.copy(
path.join(extractDirectory, file),
path.join(videoFinalDir, file),
function(error) {
if (error)
self.log(error.message, 'warn');
filesLeftToCopy--;
if (filesLeftToCopy === 0)
callback();
}
);
});
}
], function(error) {
if (error) reject(error);
else resolve();
});
});
};
/**
* Gets the height of the given video.
*
* @param {String} videoFilePath Path of the video file
* @return {module:publish/packages/VideoPackage~VideoPackage~getVideoHeightCallback} Function to call when its done
*/
VideoPackage.prototype.getVideoHeight = function(videoFilePath, callback) {
ffmpeg.ffprobe(videoFilePath, function(error, metadata) {
if (!error && !metadata.streams) error = new Error('No stream found in media file');
if (error) return callback(error);
// Find video stream
var videoStream;
for (var i = 0; i < metadata.streams.length; i++) {
if (metadata.streams[i]['codec_type'] === 'video')
videoStream = metadata.streams[i];
}
if (videoStream) {
// Got video stream associated to the video file
callback(null, videoStream.height);
} else callback(new Error('No video stream found'));
});
};
/**
* Merges package media and same package name media.
*
* Merging consists of merging the two medias into one in OpenVeo. It means that both medias still exist on the media
* platform but only one reference exists in OpenVeo with multi remote medias.
* The incoming media is merged into the existing one.
*
* This is a transition.
*
* @async
* @return {Promise} Promise resolving when transition is done
*/
VideoPackage.prototype.merge = function() {
var self = this;
return new Promise(function(resolve, reject) {
var lockedPackage;
async.series([
// Update package state
function(callback) {
VideoPackage.super_.prototype.merge.call(self).then(function() {
callback();
}).catch(function(error) {
reject(error);
});
},
// Find package locked in WAITING_FOR_MERGE state
function(callback) {
self.videoProvider.getOne(
new ResourceFilter().and([
new ResourceFilter().equal('state', STATES.WAITING_FOR_MERGE),
new ResourceFilter().equal('originalFileName', self.mediaPackage.originalFileName),
new ResourceFilter().equal('lockedByPackage', self.mediaPackage.id)
]),
null,
function(error, foundPackage) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.MERGE_GET_PACKAGE_WITH_SAME_NAME));
lockedPackage = foundPackage;
callback();
}
);
},
// Merge package medias with locked package medias
function(callback) {
self.log('Merge medias with medias of package ' + lockedPackage.id);
self.videoProvider.updateOne(
new ResourceFilter().equal('id', lockedPackage.id),
{
mediaId: openVeoApi.util.joinArray(lockedPackage.mediaId, self.mediaPackage.mediaId),
mediasHeights: lockedPackage.mediasHeights.concat(self.mediaPackage.mediasHeights)
},
function(error) {
if (error) return callback(new VideoPackageError(error.message, ERRORS.MERGE_UPDATE_MEDIAS));
callback();
}
);
}
], function(error) {
if (error) return reject(error);
resolve();
});
});
};
/**
* Gets the stack of transitions corresponding to the package.
*
* Each package has its own way to be published, thus transitions stack
* is different by package.
*
* @return {Array} The stack of transitions
*/
VideoPackage.prototype.getTransitions = function() {
return VideoPackage.stateTransitions;
};
/**
* Gets the list of transitions states corresponding to the package.
*
* @return {Array} The list of states/transitions
*/
VideoPackage.prototype.getStateMachine = function() {
return VideoPackage.stateMachine;
};
/**
* @callback module:publish/packages/VideoPackage~VideoPackage~getVideoHeightCallback
* @param {(Error|undefined)} error The error if an error occurred
* @param {Number} height The video height in pixels
*/