OpenVeo Publish server

API Docs for: 8.0.0
Show:

File: app/server/packages/VideoPackage.js

'use strict';

/**
 * @module packages
 */

var util = require('util');
var path = require('path');
var fs = require('fs');
var async = require('async');
var ffmpeg = require('fluent-ffmpeg');
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 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 Package
 * @constructor
 * @param {Object} mediaPackage Information about the video
 * @param {VideoProvider} videoProvider A video provider
 */
function VideoPackage(mediaPackage, videoProvider) {
  VideoPackage.super_.call(this, mediaPackage, videoProvider);
}

module.exports = VideoPackage;
util.inherits(VideoPackage, Package);

/**
 * Process states for video packages.
 *
 * @property STATES
 * @type Object
 * @static
 * @final
 */
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).
 *
 * @property TRANSITIONS
 * @type Object
 * @static
 * @final
 */
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.
 *
 * @property stateTransitions
 * @type Array
 * @static
 * @final
 */
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
];
Object.freeze(VideoPackage.stateTransitions);

/**
 * Define machine state authorized transitions depending on previous and next states.
 *
 * @property stateMachine
 * @type Array
 * @static
 * @final
 */
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);

/**
 * Defragment the MP4
 *
 * If the input file is fragmented, ffmpeg will be used to defragment
 * the MP4. The fragmentation detection of the file is based on an un-
 * known "nb_frames" property in ffprobe output metadata.
 *
 * @method defragmentMp4
 */
VideoPackage.prototype.defragmentMp4 = function() {
  var self = this;
  var filePath = this.getMediaFilePath();

  this.updateState(this.mediaPackage.id, STATES.DEFRAGMENT_MP4, function() {
    // Detect if file need defragmentation (unknown "nb_frames")
    ffmpeg.ffprobe(filePath, function(error, metadata) {
      if (Array.isArray(metadata.streams)) {
        var fragmentedStreams = metadata.streams.filter(function(stream) {
          if (stream.codec_type !== 'video')
            return false;

          return stream.nb_frames === 'N/A';
        });

        if (fragmentedStreams.length === 0) {
          process.logger.debug('No defragmentation is needed (' + self.mediaPackage.id + ')');

          return self.fsm.transition();
        }

        var destinationPath = path.join(self.publishConf.videoTmpDir, String(self.mediaPackage.id));
        var defragmentedFile = path.join(destinationPath, 'video_defrag.mp4');

        // MP4 defragmentation
        ffmpeg(filePath)
          .audioCodec('copy')
          .videoCodec('copy')
          .outputOptions('-movflags faststart')
          .on('start', function() {
            process.logger.debug('Starting defragmentation (' + self.mediaPackage.id + ') ' +
                                 'of ' + filePath + ' to ' + defragmentedFile);
          })
          .on('error', function(error) {
            self.setError(new VideoPackageError(error.message, ERRORS.DEFRAGMENTATION));
          })
          .on('end', function() {
            process.logger.debug('Defragmentation complete (' + self.mediaPackage.id + ')');

            // Replace original file
            process.logger.debug('Removing fragmented file ' + filePath);
            fs.unlink(filePath, function(error) {
              if (error)
                self.setError(new VideoPackageError(error.message, ERRORS.UNLINK_FRAGMENTED));

              process.logger.debug('Replacing original file (' + self.mediaPackage.id + ') with ' + defragmentedFile);
              fs.rename(defragmentedFile, filePath, function(error) {
                if (error)
                  self.setError(new VideoPackageError(error.message, ERRORS.REPLACE_FRAGMENTED));

                process.logger.debug('Original file replaced (' + self.mediaPackage.id + ')');

                return self.fsm.transition();
              });
            });
          })
          .save(defragmentedFile);
      } else
        return self.fsm.transition();
    });
  });
};

/**
 * 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.
 *
 * @method generateThumb
 */
VideoPackage.prototype.generateThumb = function() {
  var self = this;
  var filePath = this.getMediaFilePath();

  this.updateState(this.mediaPackage.id, STATES.GENERATE_THUMB, function() {
    var destinationPath = path.join(self.publishConf.videoTmpDir, String(self.mediaPackage.id));

    if (self.mediaPackage.originalThumbnailPath !== undefined) {
    // Copy user's thumbnail
      process.logger.debug('Copy thumbnail (' + self.mediaPackage.id + ') in ' + destinationPath);
      openVeoApi.fileSystem.copy(
        self.mediaPackage.originalThumbnailPath,
        path.join(destinationPath, 'thumbnail.jpg'),
        function(error) {
          if (error) {
            self.setError(new VideoPackageError(error.message, ERRORS.COPY_THUMB));
          }

          self.videoProvider.updateThumbnail(
            self.mediaPackage.id,
            '/publish/' + self.mediaPackage.id + '/thumbnail.jpg',
            function() {
              self.fsm.transition();
            }
          );
        }
      );
    } else {
    // Generate thumb
      process.logger.debug('Generate thumbnail (' + self.mediaPackage.id + ') in ' + destinationPath);
      ffmpeg(filePath).screenshots({
        timestamps: ['10%'],
        filename: 'thumbnail.jpg',
        folder: destinationPath
      }).on('error', function(error) {
        self.setError(new VideoPackageError(error.message, ERRORS.GENERATE_THUMB));
      }).on('end', function() {
        self.videoProvider.updateThumbnail(
          self.mediaPackage.id,
          '/publish/' + self.mediaPackage.id + '/thumbnail.jpg',
          function() {
            self.fsm.transition();
          }
        );
      });
    }
  });
};

/**
 * Prepares public directory where the media associated files will be deployed.
 *
 * This is a transition.
 *
 * @method preparePublicDirectory
 */
VideoPackage.prototype.preparePublicDirectory = function() {
  var self = this;
  var publicDirectory = path.normalize(process.rootPublish + '/assets/player/videos/' + this.mediaPackage.id);

  this.updateState(this.mediaPackage.id, STATES.PREPARING, function() {
    process.logger.debug('Prepare package public directory ' + publicDirectory);

    openVeoApi.fileSystem.mkdir(publicDirectory,
      function(error) {
        if (error && error.code !== 'EEXIST')
          self.setError(new VideoPackageError(error.message, ERRORS.CREATE_VIDEO_PUBLIC_DIR));
        else
          self.fsm.transition();
      });
  });
};

/**
 * Retrieves video height from video metadatas.
 *
 * This is a transition.
 *
 * @method getMetadata
 */
VideoPackage.prototype.getMetadata = function() {
  var self = this;
  var filePath = this.getMediaFilePath();

  this.updateState(this.mediaPackage.id, STATES.GET_METADATA, function() {
    if (!self.mediaPackage.metadata) self.mediaPackage.metadata = {};
    self.mediaPackage.metadata['profile-settings'] = self.mediaPackage.metadata['profile-settings'] || {};

    if (self.mediaPackage.metadata['profile-settings']['video-height'])
      self.fsm.transition();
    else {
      ffmpeg.ffprobe(filePath, function(error, metadata) {
        if (error || !metadata.streams)
          self.setError(new VideoPackageError(error.message, ERRORS.GET_METADATA));
        else {

          // 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];
          }

          // Got video stream associated to the video file
          if (videoStream) {
            self.mediaPackage.metadata['profile-settings']['video-height'] = videoStream.height;
            self.videoProvider.updateMetadata(self.mediaPackage.id, self.mediaPackage.metadata, function() {
              self.fsm.transition();
            });
          } else
            self.setError(new VideoPackageError('No video stream found', ERRORS.GET_METADATA));
        }
      });
    }
  });
};

/**
 * Copies presentation images from temporary directory to the public directory.
 *
 * This is a transition.
 *
 * @method copyImages
 */
VideoPackage.prototype.copyImages = function() {
  var self = this;
  var extractDirectory = path.join(this.publishConf.videoTmpDir, String(this.mediaPackage.id));
  var videoFinalDir = path.normalize(process.rootPublish + '/assets/player/videos/' + this.mediaPackage.id);
  var resources = [];
  var filesToCopy = [];

  process.logger.debug('Copy images to ' + videoFinalDir);
  async.series([

    // Read directory
    function(callback) {
      process.logger.verbose('Scan directory ' + extractDirectory + ' for images');
      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)
          process.logger.warn(error.message, {action: 'copyImages', mediaId: self.mediaPackage.id});

        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) {
        process.logger.verbose('Copy image ' + path.join(extractDirectory, file) +
                               ' to ' + path.join(videoFinalDir, file));
        openVeoApi.fileSystem.copy(path.join(extractDirectory, file), path.join(videoFinalDir, file), function(error) {

          if (error)
            process.logger.warn(error.message, {action: 'copyImages', mediaId: self.mediaPackage.id});

          filesLeftToCopy--;

          if (filesLeftToCopy === 0)
            callback();
        });
      });

    }
  ], function(error) {
    if (error)
      self.setError(error);
    else
      self.fsm.transition();
  });

};

/**
 * 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.
 *
 * @method getTransitions
 * @return {Array} The stack of transitions
 */
VideoPackage.prototype.getTransitions = function() {
  return VideoPackage.stateTransitions;
};

/**
 * Gets the list of transitions states corresponding to the package.
 *
 * @method getStateMachine
 * @return {Array} The list of states/transitions
 */
VideoPackage.prototype.getStateMachine = function() {
  return VideoPackage.stateMachine;
};