From a5f917bbc55e295896b8084f6657eb8b6abaf8a8 Mon Sep 17 00:00:00 2001 From: André Fabian Silva Delgado Date: Fri, 15 Jul 2016 15:33:36 -0300 Subject: Add TimedMediaHandler extension that allows display audio and video files in wiki pages, using the same syntax as for image files --- .../WebVideoTranscode/WebVideoTranscode.php | 1192 ++++++++++++++++++++ .../WebVideoTranscode/WebVideoTranscodeJob.php | 965 ++++++++++++++++ 2 files changed, 2157 insertions(+) create mode 100644 extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscode.php create mode 100644 extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscodeJob.php (limited to 'extensions/TimedMediaHandler/WebVideoTranscode') diff --git a/extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscode.php b/extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscode.php new file mode 100644 index 00000000..e4d02556 --- /dev/null +++ b/extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscode.php @@ -0,0 +1,1192 @@ + 'Ogg 200'; + */ + + // Ogg Profiles + const ENC_OGV_160P = '160p.ogv'; + const ENC_OGV_240P = '240p.ogv'; + const ENC_OGV_360P = '360p.ogv'; + const ENC_OGV_480P = '480p.ogv'; + const ENC_OGV_720P = '720p.ogv'; + const ENC_OGV_1080P = '1080p.ogv'; + + // WebM VP8/Vorbis profiles: + const ENC_WEBM_160P = '160p.webm'; + const ENC_WEBM_360P = '360p.webm'; + const ENC_WEBM_480P = '480p.webm'; + const ENC_WEBM_720P = '720p.webm'; + const ENC_WEBM_1080P = '1080p.webm'; + const ENC_WEBM_2160P = '2160p.webm'; + + // WebM VP9/Opus profiles: + const ENC_VP9_360P = '360p.vp9.webm'; + const ENC_VP9_480P = '480p.vp9.webm'; + const ENC_VP9_720P = '720p.vp9.webm'; + const ENC_VP9_1080P = '1080p.vp9.webm'; + const ENC_VP9_2160P = '2160p.vp9.webm'; + + // mp4 profiles: + const ENC_H264_320P = '320p.mp4'; + const ENC_H264_480P = '480p.mp4'; + const ENC_H264_720P = '720p.mp4'; + const ENC_H264_1080P = '1080p.mp4'; + const ENC_H264_2160P = '2160p.mp4'; + + const ENC_OGG_VORBIS = 'ogg'; + const ENC_OGG_OPUS = 'opus'; + const ENC_MP3 = 'mp3'; + const ENC_AAC = 'm4a'; + + // Static cache of transcode state per instantiation + public static $transcodeState = array() ; + + /** + * Encoding parameters are set via firefogg encode api + * + * For clarity and compatibility with passing down + * client side encode settings at point of upload + * + * http://firefogg.org/dev/index.html + */ + public static $derivativeSettings = array( + WebVideoTranscode::ENC_OGV_160P => + array( + 'maxSize' => '288x160', + 'videoBitrate' => '160', + 'framerate' => '15', + 'audioQuality' => '-1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + //'twopass' => 'true', // temporarily disabled for broken ffmpeg2theora + 'optimize' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'theora', + 'type' => 'video/ogg; codecs="theora, vorbis"', + ), + WebVideoTranscode::ENC_OGV_240P => + array( + 'maxSize' => '426x240', + 'videoBitrate' => '512', + 'audioQuality' => '0', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + //'twopass' => 'true', // temporarily disabled for broken ffmpeg2theora + 'optimize' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'theora', + 'type' => 'video/ogg; codecs="theora, vorbis"', + ), + WebVideoTranscode::ENC_OGV_360P => + array( + 'maxSize' => '640x360', + 'videoBitrate' => '1024', + 'audioQuality' => '1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + //'twopass' => 'true', // temporarily disabled for broken ffmpeg2theora + 'optimize' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'theora', + 'type' => 'video/ogg; codecs="theora, vorbis"', + ), + WebVideoTranscode::ENC_OGV_480P => + array( + 'maxSize' => '854x480', + 'videoBitrate' => '2048', + 'audioQuality' => '2', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + //'twopass' => 'true', // temporarily disabled for broken ffmpeg2theora + 'optimize' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'theora', + 'type' => 'video/ogg; codecs="theora, vorbis"', + ), + + WebVideoTranscode::ENC_OGV_720P => + array( + 'maxSize' => '1280x720', + 'videoQuality' => 6, + 'audioQuality' => 3, + 'noUpscaling' => 'true', + //'twopass' => 'true', // temporarily disabled for broken ffmpeg2theora + 'optimize' => 'true', + 'keyframeInterval' => '128', + 'videoCodec' => 'theora', + 'type' => 'video/ogg; codecs="theora, vorbis"', + ), + + WebVideoTranscode::ENC_OGV_1080P => + array( + 'maxSize' => '1920x1080', + 'videoQuality' => 6, + 'audioQuality' => 3, + 'noUpscaling' => 'true', + //'twopass' => 'true', // temporarily disabled for broken ffmpeg2theora + 'optimize' => 'true', + 'keyframeInterval' => '128', + 'videoCodec' => 'theora', + 'type' => 'video/ogg; codecs="theora, vorbis"', + ), + + // WebM transcode: + WebVideoTranscode::ENC_WEBM_160P => + array( + 'maxSize' => '288x160', + 'videoBitrate' => '256', + 'audioQuality' => '-1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp8', + 'type' => 'video/webm; codecs="vp8, vorbis"', + ), + WebVideoTranscode::ENC_WEBM_360P => + array( + 'maxSize' => '640x360', + 'videoBitrate' => '512', + 'audioQuality' => '1', + 'samplerate' => '44100', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp8', + 'type' => 'video/webm; codecs="vp8, vorbis"', + ), + WebVideoTranscode::ENC_WEBM_480P => + array( + 'maxSize' => '854x480', + 'videoBitrate' => '1024', + 'audioQuality' => '2', + 'samplerate' => '44100', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp8', + 'type' => 'video/webm; codecs="vp8, vorbis"', + ), + WebVideoTranscode::ENC_WEBM_720P => + array( + 'maxSize' => '1280x720', + 'videoQuality' => 7, + 'audioQuality' => 3, + 'noUpscaling' => 'true', + 'videoCodec' => 'vp8', + 'type' => 'video/webm; codecs="vp8, vorbis"', + ), + WebVideoTranscode::ENC_WEBM_1080P => + array( + 'maxSize' => '1920x1080', + 'videoQuality' => 7, + 'audioQuality' => 3, + 'noUpscaling' => 'true', + 'videoCodec' => 'vp8', + 'type' => 'video/webm; codecs="vp8, vorbis"', + ), + WebVideoTranscode::ENC_WEBM_2160P => + array( + 'maxSize' => '4096x2160', + 'videoQuality' => 7, + 'audioQuality' => 3, + 'noUpscaling' => 'true', + 'videoCodec' => 'vp8', + 'type' => 'video/webm; codecs="vp8, vorbis"', + ), + + // WebM VP9 transcode: + WebVideoTranscode::ENC_VP9_360P => + array( + 'maxSize' => '640x360', + 'videoBitrate' => '256', + 'samplerate' => '48000', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp9', + 'audioCodec' => 'opus', + 'type' => 'video/webm; codecs="vp9, opus"', + ), + WebVideoTranscode::ENC_VP9_480P => + array( + 'maxSize' => '854x480', + 'videoBitrate' => '512', + 'samplerate' => '48000', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp9', + 'audioCodec' => 'opus', + 'type' => 'video/webm; codecs="vp9, opus"', + ), + WebVideoTranscode::ENC_VP9_720P => + array( + 'maxSize' => '1280x720', + 'videoBitrate' => '1024', + 'samplerate' => '48000', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp9', + 'audioCodec' => 'opus', + 'tileColumns' => '2', + 'type' => 'video/webm; codecs="vp9, opus"', + ), + WebVideoTranscode::ENC_VP9_1080P => + array( + 'maxSize' => '1920x1080', + 'videoBitrate' => '2048', + 'samplerate' => '48000', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp9', + 'audioCodec' => 'opus', + 'tileColumns' => '4', + 'type' => 'video/webm; codecs="vp9, opus"', + ), + WebVideoTranscode::ENC_VP9_2160P => + array( + 'maxSize' => '4096x2160', + 'videoBitrate' => '8192', + 'samplerate' => '48000', + 'noUpscaling' => 'true', + 'twopass' => 'true', + 'keyframeInterval' => '128', + 'bufDelay' => '256', + 'videoCodec' => 'vp9', + 'audioCodec' => 'opus', + 'tileColumns' => '4', + 'type' => 'video/webm; codecs="vp9, opus"', + ), + + // Losly defined per PCF guide to mp4 profiles: + // https://develop.participatoryculture.org/index.php/ConversionMatrix + // and apple HLS profile guide: + // https://developer.apple.com/library/ios/#documentation/networkinginternet/conceptual/streamingmediaguide/UsingHTTPLiveStreaming/UsingHTTPLiveStreaming.html#//apple_ref/doc/uid/TP40008332-CH102-DontLinkElementID_24 + + WebVideoTranscode::ENC_H264_320P => + array( + 'maxSize' => '480x320', + 'videoCodec' => 'h264', + 'preset' => 'ipod320', + 'videoBitrate' => '400k', + 'audioCodec' => 'aac', + 'channels' => '2', + 'audioBitrate' => '40k', + 'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + ), + + WebVideoTranscode::ENC_H264_480P => + array( + 'maxSize' => '640x480', + 'videoCodec' => 'h264', + 'preset' => 'ipod640', + 'videoBitrate' => '1200k', + 'audioCodec' => 'aac', + 'channels' => '2', + 'audioBitrate' => '64k', + 'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + ), + + WebVideoTranscode::ENC_H264_720P => + array( + 'maxSize' => '1280x720', + 'videoCodec' => 'h264', + 'preset' => '720p', + 'videoBitrate' => '2500k', + 'audioCodec' => 'aac', + 'channels' => '2', + 'audioBitrate' => '128k', + 'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + ), + + WebVideoTranscode::ENC_H264_1080P => + array( + 'maxSize' => '1920x1080', + 'videoCodec' => 'h264', + 'videoBitrate' => '5000k', + 'audioCodec' => 'aac', + 'channels' => '2', + 'audioBitrate' => '128k', + 'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + ), + WebVideoTranscode::ENC_H264_2160P => + array( + 'maxSize' => '4096x2160', + 'videoCodec' => 'h264', + 'videoBitrate' => '16384k', + 'audioCodec' => 'aac', + 'channels' => '2', + 'audioBitrate' => '128k', + 'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + ), + + //Audio profiles + WebVideoTranscode::ENC_OGG_VORBIS => + array( + 'audioCodec' => 'vorbis', + 'audioQuality' => '1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + 'novideo' => 'true', + 'type' => 'audio/ogg; codecs="vorbis"', + ), + WebVideoTranscode::ENC_OGG_OPUS => + array( + 'audioCodec' => 'opus', + 'audioQuality' => '1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + 'novideo' => 'true', + 'type' => 'audio/ogg; codecs="opus"', + ), + WebVideoTranscode::ENC_MP3 => + array( + 'audioCodec' => 'mp3', + 'audioQuality' => '1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + 'novideo' => 'true', + 'type' => 'audio/mpeg', + ), + WebVideoTranscode::ENC_AAC => + array( + 'audioCodec' => 'aac', + 'audioQuality' => '1', + 'samplerate' => '44100', + 'channels' => '2', + 'noUpscaling' => 'true', + 'novideo' => 'true', + 'type' => 'audio/mp4; codecs="mp4a.40.5"', + ), + ); + + /** + * @param $file File + * @param $transcodeKey string + * @return string + */ + static public function getDerivativeFilePath( $file, $transcodeKey ) { + return $file->getTranscodedPath( self::getTranscodeFileBaseName( $file, $transcodeKey ) ); + } + + /** + * Get the name to use as the base name for the transcode. + * + * Swift has problems where the url-encoded version of + * the path (ie 'filename.ogv/filename.ogv.720p.webm' ) + * is greater that > 1024 bytes, so shorten in that case. + * + * Future versions might respect FileRepo::$abbrvThreshold. + * + * @param File $file + * @param String $suffix Optional suffix (e.g. transcode key). + * @return String File name, or the string transcode. + */ + static public function getTranscodeFileBaseName( $file, $suffix = '' ) { + $name = $file->getName(); + if ( strlen( urlencode( $name ) ) * 2 + 12 > 1024 ) { + return 'transcode' . '.' . $suffix; + } else { + return $name . '.' . $suffix; + } + } + + /** + * Get url for a transcode. + * + * @param $file File + * @param $suffix string Transcode key + * @return string + */ + static public function getTranscodedUrlForFile( $file, $suffix = '' ) { + return $file->getTranscodedUrl( self::getTranscodeFileBaseName( $file, $suffix ) ); + } + + /** + * Get temp file at target path for video encode + * + * @param $file File + * @param $transcodeKey String + * + * @return TempFSFile at target encode path + */ + static public function getTargetEncodeFile( &$file, $transcodeKey ){ + $filePath = self::getDerivativeFilePath( $file, $transcodeKey ); + $ext = strtolower( pathinfo( "$filePath", PATHINFO_EXTENSION ) ); + + // Create a temp FS file with the same extension + $tmpFile = TempFSFile::factory( 'transcode_' . $transcodeKey, $ext); + if ( !$tmpFile ) { + return False; + } + return $tmpFile; + } + + /** + * Get the max size of the web stream ( constant bitrate ) + * @return int + */ + static public function getMaxSizeWebStream(){ + global $wgEnabledTranscodeSet; + $maxSize = 0; + foreach( $wgEnabledTranscodeSet as $transcodeKey ){ + if( isset( self::$derivativeSettings[$transcodeKey]['videoBitrate'] ) ){ + $currentSize = self::$derivativeSettings[$transcodeKey]['maxSize']; + if( $currentSize > $maxSize ){ + $maxSize = $currentSize; + } + } + } + return $maxSize; + } + + /** + * Give a rough estimate on file size + * Note this is not always accurate.. especially with variable bitrate codecs ;) + * @param $file File + * @param $transcodeKey string + * @return number + */ + static public function getProjectedFileSize( $file, $transcodeKey ){ + $settings = self::$derivativeSettings[$transcodeKey]; + if( $settings[ 'videoBitrate' ] && $settings['audioBitrate'] ){ + return $file->getLength() * 8 * ( + self::$derivativeSettings[$transcodeKey]['videoBitrate'] + + + self::$derivativeSettings[$transcodeKey]['audioBitrate'] + ); + } + // Else just return the size of the source video ( we have no idea how large the actual derivative size will be ) + return $file->getLength() * $file->getHandler()->getBitrate( $file ) * 8; + } + + /** + * Static function to get the set of video assets + * Checks if the file is local or remote and grabs respective sources + * @param $file File + * @param $options array + * @return array|mixed + */ + static public function getSources( &$file , $options = array() ){ + if( $file->isLocal() || $file->repo instanceof ForeignDBViaLBRepo ){ + return self::getLocalSources( $file , $options ); + } else { + return self::getRemoteSources( $file , $options ); + } + } + + /** + * Grabs sources from the remote repo via ApiQueryVideoInfo.php entry point. + * + * TODO: This method could use some rethinking. See comments on PS1 of + * + * + * Because this works with commons regardless of whether TimedMediaHandler is installed or not + * @param $file File + * @param $options array + * @return array|mixed + */ + static public function getRemoteSources(&$file , $options = array() ){ + global $wgMemc; + // Setup source attribute options + $dataPrefix = in_array( 'nodata', $options )? '': 'data-'; + + // Use descriptionCacheExpiry as our expire for timed text tracks info + if ( $file->repo->descriptionCacheExpiry > 0 ) { + wfDebug("Attempting to get sources from cache..."); + $key = $file->repo->getLocalCacheKey( 'WebVideoSources', 'url', $file->getName() ); + $sources = $wgMemc->get($key); + if ( $sources ) { + wfDebug("Success found sources in local cache\n"); + return $sources; + } + wfDebug("source cache miss\n"); + } + + wfDebug("Get Video sources from remote api for " . $file->getName() . "\n"); + $query = array( + 'action' => 'query', + 'prop' => 'videoinfo', + 'viprop' => 'derivatives', + 'titles' => MWNamespace::getCanonicalName( NS_FILE ) .':'. $file->getTitle()->mTextform + ); + + $data = $file->repo->fetchImageQuery( $query ); + + if( isset( $data['warnings'] ) && isset( $data['warnings']['query'] ) + && $data['warnings']['query']['*'] == "Unrecognized value for parameter 'prop': videoinfo" ) + { + // Commons does not yet have TimedMediaHandler. + // Use the normal file repo system single source: + return array( self::getPrimarySourceAttributes( $file, array( $dataPrefix ) ) ); + } + $sources = array(); + // Generate the source list from the data response: + if( isset( $data['query'] ) && $data['query']['pages'] ){ + $vidResult = array_shift( $data['query']['pages'] ); + if( isset( $vidResult['videoinfo'] ) ) { + $derResult = array_shift( $vidResult['videoinfo'] ); + $derivatives = $derResult['derivatives']; + foreach( $derivatives as $derivativeSource ){ + $sources[] = $derivativeSource; + } + } + } + + // Update the cache: + if ( $sources && $file->repo->descriptionCacheExpiry > 0 ) { + $wgMemc->set( $key, $sources, $file->repo->descriptionCacheExpiry ); + } + + return $sources; + + } + + /** + * Based on the $wgEnabledTranscodeSet set of enabled derivatives we + * return sources that are ready. + * + * This will not automatically update or queue anything! + * + * @param $file File object + * @param $options array Options, a set of options: + * 'nodata' Strips the data- attribute, useful when your output is not html + * @return array an associative array of sources suitable for tag output + */ + static public function getLocalSources( &$file , $options=array() ){ + global $wgEnabledTranscodeSet, $wgEnabledAudioTranscodeSet, $wgEnableTranscode; + $sources = array(); + + // Add the original file: + $sources[] = self::getPrimarySourceAttributes( $file, $options ); + + // If $wgEnableTranscode is false don't look for or add other local sources: + if( $wgEnableTranscode === false && + !($file->repo instanceof ForeignDBViaLBRepo) ){ + return $sources; + } + + // If an "oldFile" don't look for other sources: + if( $file->isOld() ){ + return $sources; + } + + // Now Check for derivatives + if( $file->getHandler()->isAudio( $file ) ){ + $transcodeSet = $wgEnabledAudioTranscodeSet; + } else { + $transcodeSet = $wgEnabledTranscodeSet; + } + foreach( $transcodeSet as $transcodeKey ){ + if ( self::isTranscodeEnabled( $file, $transcodeKey ) ) { + // Try and add the source + self::addSourceIfReady( $file, $sources, $transcodeKey, $options ); + } + } + + return $sources; + } + + /** + * Get the transcode state for a given filename and transcodeKey + * + * @param $fileName string + * @param $transcodeKey string + * @return bool + */ + public static function isTranscodeReady( $file, $transcodeKey ){ + + // Check if we need to populate the transcodeState cache: + $transcodeState = self::getTranscodeState( $file ); + + // If no state is found the cache for this file is false: + if( !isset( $transcodeState[ $transcodeKey ] ) ) { + return false; + } + // Else return boolean ready state ( if not null, then ready ): + return !is_null( $transcodeState[ $transcodeKey ]['time_success'] ); + } + + /** + * Clear the transcode state cache: + * @param String $fileName Optional fileName to clear transcode cache for + */ + public static function clearTranscodeCache( $fileName = null){ + if( $fileName ){ + unset( self::$transcodeState[ $fileName ] ); + } else { + self::$transcodeState = array(); + } + } + + /** + * Populates the transcode table with the current DB state of transcodes + * if transcodes are not found in the database their state is set to "false" + * + * @param {Object} File object + */ + public static function getTranscodeState( $file, $db = false ){ + global $wgTranscodeBackgroundTimeLimit; + $fileName = $file->getName(); + if( ! isset( self::$transcodeState[$fileName] ) ){ + if ( $db === false ) { + $db = $file->repo->getSlaveDB(); + } + // initialize the transcode state array + self::$transcodeState[ $fileName ] = array(); + $res = $db->select( 'transcode', + '*', + array( 'transcode_image_name' => $fileName ), + __METHOD__, + array( 'LIMIT' => 100 ) + ); + $overTimeout = array(); + $over = $db->timestamp(time() - (2 * $wgTranscodeBackgroundTimeLimit)); + // Populate the per transcode state cache + foreach ( $res as $row ) { + // strip the out the "transcode_" from keys + $trascodeState = array(); + foreach( $row as $k => $v ){ + $trascodeState[ str_replace( 'transcode_', '', $k ) ] = $v; + } + self::$transcodeState[ $fileName ][ $row->transcode_key ] = $trascodeState; + if ( $row->transcode_time_startwork != NULL + && $row->transcode_time_startwork < $over + && $row->transcode_time_success == NULL + && $row->transcode_time_error == NULL ) { + $overTimeout[] = $row->transcode_key; + } + } + if ( $overTimeout ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'transcode', + array( + 'transcode_time_error' => $dbw->timestamp(), + 'transcode_error' => 'timeout' + ), + array( + 'transcode_image_name' => $fileName, + 'transcode_key' => $overTimeout + ), + __METHOD__, + array( 'LIMIT' => count( $overTimeout ) ) + ); + } + } + $sorted = self::$transcodeState[ $fileName ]; + uksort( $sorted, 'strnatcmp' ); + return $sorted; + } + + /** + * Remove any transcode files and db states associated with a given $file + * Note that if you want to see them again, you must re-queue them by calling + * startJobQueue() or updateJobQueue(). + * + * also remove the transcode files: + * @param $file File Object + * @param $transcodeKey String Optional transcode key to remove only this key + */ + public static function removeTranscodes( &$file, $transcodeKey = false ){ + + // if transcode key is non-false, non-null: + if( $transcodeKey ){ + // only remove the requested $transcodeKey + $removeKeys = array( $transcodeKey ); + } else { + // Remove any existing files ( regardless of their state ) + $res = $file->repo->getMasterDB()->select( 'transcode', + array( 'transcode_key' ), + array( 'transcode_image_name' => $file->getName() ) + ); + $removeKeys = array(); + foreach( $res as $transcodeRow ){ + $removeKeys[] = $transcodeRow->transcode_key; + } + } + + // Remove files by key: + $urlsToPurge = array(); + foreach ( $removeKeys as $tKey ) { + $urlsToPurge[] = self::getTranscodedUrlForFile( $file, $tKey ); + $filePath = self::getDerivativeFilePath( $file, $tKey ); + if( $file->repo->fileExists( $filePath ) ){ + wfSuppressWarnings(); + $res = $file->repo->quickPurge( $filePath ); + wfRestoreWarnings(); + if( !$res ){ + wfDebug( "Could not delete file $filePath\n" ); + } + } + } + + SquidUpdate::purge( $urlsToPurge ); + + // Build the sql query: + $dbw = wfGetDB( DB_MASTER ); + $deleteWhere = array( 'transcode_image_name' => $file->getName() ); + // Check if we are removing a specific transcode key + if( $transcodeKey !== false ){ + $deleteWhere['transcode_key'] = $transcodeKey; + } + // Remove the db entries + $dbw->delete( 'transcode', $deleteWhere, __METHOD__ ); + + // Purge the cache for pages that include this video: + $titleObj = $file->getTitle(); + self::invalidatePagesWithFile( $titleObj ); + + // Remove from local WebVideoTranscode cache: + self::clearTranscodeCache( $titleObj->getDBkey() ); + } + + /** + * @param $titleObj Title + */ + public static function invalidatePagesWithFile( &$titleObj ){ + wfDebug("WebVideoTranscode:: Invalidate pages that include: " . $titleObj->getDBkey() . "\n" ); + // Purge the main image page: + $titleObj->invalidateCache(); + + // TODO if the video is used in over 500 pages add to 'job queue' + // TODO interwiki invalidation ? + $limit = 500; + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( + array( 'imagelinks', 'page' ), + array( 'page_namespace', 'page_title' ), + array( 'il_to' => $titleObj->getDBkey(), 'il_from = page_id' ), + __METHOD__, + array( 'LIMIT' => $limit + 1 ) + ); + foreach ( $res as $page ) { + $title = Title::makeTitle( $page->page_namespace, $page->page_title ); + $title->invalidateCache(); + } + } + + /** + * Add a source to the sources list if the transcode job is ready + * + * If the source is not found, it will not be used yet... + * Missing transcodes should be added by write tasks, not read tasks! + */ + public static function addSourceIfReady( &$file, &$sources, $transcodeKey, $dataPrefix = '' ){ + // Check if the transcode is ready: + if( self::isTranscodeReady( $file, $transcodeKey ) ){ + $sources[] = self::getDerivativeSourceAttributes( $file, $transcodeKey, $dataPrefix ); + } + } + + /** + * Get the primary "source" asset used for other derivatives + * @param $file File + * @param $options array + * @return array + */ + static public function getPrimarySourceAttributes( $file, $options = array() ){ + global $wgLang; + $src = in_array( 'fullurl', $options)? wfExpandUrl( $file->getUrl() ) : $file->getUrl(); + + $bitrate = $file->getHandler()->getBitrate( $file ); + $metadataType = $file->getHandler()->getMetadataType( $file ); + + // Give grep a chance to find the usages: timedmedia-ogg, timedmedia-webm, + // timedmedia-mp4, timedmedia-flac, timedmedia-wav + if( $file->getHandler()->isAudio( $file ) ){ + $title = wfMessage( 'timedmedia-source-audio-file-desc', + wfMessage( 'timedmedia-' . $metadataType )->text() ) + ->params( $wgLang->formatBitrate( $bitrate ) )->text(); + } else { + $title = wfMessage( 'timedmedia-source-file-desc', + wfMessage( 'timedmedia-' . $metadataType )->text() ) + ->numParams( $file->getWidth(), $file->getHeight() ) + ->params( $wgLang->formatBitrate( $bitrate ) )->text(); + } + + // Give grep a chance to find the usages: timedmedia-ogg, timedmedia-webm, + // timedmedia-mp4, timedmedia-flac, timedmedia-wav + $source = array( + 'src' => $src, + 'type' => $file->getHandler()->getWebType( $file ), + 'title' => $title, + "shorttitle" => wfMessage( + 'timedmedia-source-file', + wfMessage( 'timedmedia-' . $metadataType )->text() + )->text(), + "width" => intval( $file->getWidth() ), + "height" => intval( $file->getHeight() ), + ); + + if( $bitrate ){ + $source["bandwidth"] = round ( $bitrate ); + } + + // For video include framerate: + if( !$file->getHandler()->isAudio( $file ) ){ + $framerate = $file->getHandler()->getFramerate( $file ); + if( $framerate ){ + $source[ "framerate" ] = floatval( $framerate ); + } + } + return $source; + } + + /** + * Get derivative "source" attributes + * @param $file File + * @param $transcodeKey string + * @param $options array + * @return array + */ + static public function getDerivativeSourceAttributes($file, $transcodeKey, $options = array() ){ + $fileName = $file->getTitle()->getDbKey(); + + $src = self::getTranscodedUrlForFile( $file, $transcodeKey ); + + if( $file->getHandler()->isAudio( $file ) ){ + $width = $height = 0; + } else { + list( $width, $height ) = WebVideoTranscode::getMaxSizeTransform( + $file, + self::$derivativeSettings[$transcodeKey]['maxSize'] + ); + } + + $framerate = ( isset( self::$derivativeSettings[$transcodeKey]['framerate'] ) )? + self::$derivativeSettings[$transcodeKey]['framerate'] : + $file->getHandler()->getFramerate( $file ); + // Setup the url src: + $src = in_array( 'fullurl', $options) ? wfExpandUrl( $src ) : $src; + $fields = array( + 'src' => $src, + 'title' => wfMessage( 'timedmedia-derivative-desc-' . $transcodeKey )->text(), + 'type' => self::$derivativeSettings[ $transcodeKey ][ 'type' ], + "shorttitle" => wfMessage( 'timedmedia-derivative-' . $transcodeKey )->text(), + "transcodekey" => $transcodeKey, + + // Add data attributes per emerging DASH / webTV adaptive streaming attributes + // eventually we will define a manifest xml entry point. + "width" => intval( $width ), + "height" => intval( $height ), + ); + + // a "ready" transcode should have a bitrate: + if ( isset( self::$transcodeState[$fileName] ) ) { + $fields["bandwidth"] = intval( + self::$transcodeState[$fileName][ $transcodeKey ]['final_bitrate'] + ); + } + + if ( !$file->getHandler()->isAudio( $file ) ) { + $fields += array( "framerate" => floatval( $framerate ) ); + } + return $fields; + } + + /** + * Queue up all enabled transcodes if missing. + * @param $file File object + */ + public static function startJobQueue( File $file ) { + global $wgEnabledTranscodeSet, $wgEnabledAudioTranscodeSet; + $keys = array_merge( $wgEnabledTranscodeSet, $wgEnabledAudioTranscodeSet ); + + // 'Natural sort' puts the transcodes in ascending order by resolution, + // which roughly gives us fastest-to-slowest order. + natsort($keys); + + foreach ( $keys as $tKey ) { + // Note the job queue will de-duplicate and handle various errors, so we + // can just blast out the full list here. + self::updateJobQueue( $file, $tKey ); + } + } + + /** + * Make sure all relevant transcodes for the given file are tracked in the + * transcodes table; add entries for any missing ones. + * + * @param $file File object + */ + public static function cleanupTranscodes( File $file ) { + global $wgEnabledTranscodeSet, $wgEnabledAudioTranscodeSet; + + $fileName = $file->getTitle()->getDbKey(); + $db = $file->repo->getMasterDB(); + + $transcodeState = self::getTranscodeState( $file, $db ); + + $keys = array_merge( $wgEnabledTranscodeSet, $wgEnabledAudioTranscodeSet ); + foreach ( $keys as $transcodeKey ) { + if ( !self::isTranscodeEnabled( $file, $transcodeKey ) ) { + // This transcode is no longer enabled or erroneously included... + // Leave it in place, allowing it to be removed manually; + // it won't be used in playback and should be doing no harm. + continue; + } + if ( !isset( $transcodeState[ $transcodeKey ] ) ) { + $db->insert( + 'transcode', + array( + 'transcode_image_name' => $fileName, + 'transcode_key' => $transcodeKey, + 'transcode_time_addjob' => null, + 'transcode_error' => "", + 'transcode_final_bitrate' => 0 + ), + __METHOD__, + array( 'IGNORE' ) + ); + } + } + } + + /** + * Check if the given transcode key is appropriate for the file. + * + * @param $file File object + * @param $transcodeKey String transcode key + * @return boolean + */ + public static function isTranscodeEnabled( File $file, $transcodeKey ) { + global $wgEnabledTranscodeSet, $wgEnabledAudioTranscodeSet; + + $audio = $file->getHandler()->isAudio( $file ); + if ( $audio ) { + $keys = $wgEnabledAudioTranscodeSet; + } else { + $keys = $wgEnabledTranscodeSet; + } + + if ( in_array( $transcodeKey, $keys ) ) { + $settings = self::$derivativeSettings[$transcodeKey]; + if ( $audio ) { + $sourceCodecs = $file->getHandler()->getStreamTypes( $file ); + $sourceCodec = $sourceCodecs ? strtolower( $sourceCodecs[0] ) : ''; + return ( $sourceCodec !== $settings['audioCodec'] ); + } else if ( self::isTargetLargerThanFile( $file, $settings['maxSize'] ) ) { + // Are we the smallest enabled transcode for this type? + // Then go ahead and make a wee little transcode for compat. + return self::isSmallestTranscodeForCodec( $transcodeKey ); + } else { + return true; + } + } else { + // Transcode key is invalid or has been disabled. + return false; + } + } + + /** + * Update the job queue if the file is not already in the job queue: + * @param $file File object + * @param $transcodeKey String transcode key + */ + public static function updateJobQueue( &$file, $transcodeKey ){ + $fileName = $file->getTitle()->getDbKey(); + $db = $file->repo->getMasterDB(); + + $transcodeState = self::getTranscodeState( $file, $db ); + + if ( !self::isTranscodeEnabled( $file, $transcodeKey ) ) { + return; + } + + // If the job hasn't been added yet, attempt to do so + if ( !isset( $transcodeState[ $transcodeKey ] ) ) { + $db->insert( + 'transcode', + array( + 'transcode_image_name' => $fileName, + 'transcode_key' => $transcodeKey, + 'transcode_time_addjob' => $db->timestamp(), + 'transcode_error' => "", + 'transcode_final_bitrate' => 0 + ), + __METHOD__, + array( 'IGNORE' ) + ); + + if ( !$db->affectedRows() ) { + // There is already a row for that job added by another request, no need to continue + return; + } + + $job = new WebVideoTranscodeJob( $file->getTitle(), array( + 'transcodeMode' => 'derivative', + 'transcodeKey' => $transcodeKey, + ) ); + + if ( $job->insert() ) { + // Clear the state cache ( now that we have updated the page ) + self::clearTranscodeCache( $fileName ); + } else { + // Adding job failed, update transcode row + $db->update( + 'transcode', + array( + 'transcode_time_error' => $db->timestamp(), + 'transcode_error' => "Failed to insert Job." + ), + array( + 'transcode_image_name' => $fileName, + 'transcode_key' => $transcodeKey, + ), + __METHOD__, + array( 'LIMIT' => 1 ) + ); + } + } + } + + /** + * Transforms the size per a given "maxSize" + * if maxSize is > file, file size is used + * @param $file File + * @param $targetMaxSize int + * @return array + */ + public static function getMaxSizeTransform( &$file, $targetMaxSize ){ + $maxSize = self::getMaxSize( $targetMaxSize ); + $sourceWidth = intval( $file->getWidth() ); + $sourceHeight = intval( $file->getHeight() ); + if ( $sourceHeight === 0 ) { + // Audio file + return array( 0, 0 ); + } + $sourceAspect = $sourceWidth / $sourceHeight; + $targetWidth = $sourceWidth; + $targetHeight = $sourceHeight; + if ( $sourceAspect <= $maxSize['aspect'] ) { + if ( $sourceHeight > $maxSize['height'] ) { + $targetHeight = $maxSize['height']; + $targetWidth = intval( $targetHeight * $sourceAspect ); + } + } else { + if ( $sourceWidth > $maxSize['width'] ) { + $targetWidth = $maxSize['width']; + $targetHeight = intval( $targetWidth / $sourceAspect ); + //some players do not like uneven frame sizes + } + } + //some players do not like uneven frame sizes + $targetWidth += $targetWidth%2; + $targetHeight += $targetHeight%2; + return array( $targetWidth, $targetHeight ); + } + + /** + * Test if a given transcode target is larger than the source file + * + * @param $file File object + * @param $targetMaxSize string + * @return bool + */ + public static function isTargetLargerThanFile( &$file, $targetMaxSize ){ + $maxSize = self::getMaxSize( $targetMaxSize ); + $sourceWidth = $file->getWidth(); + $sourceHeight = $file->getHeight(); + $sourceAspect = intval( $sourceWidth ) / intval( $sourceHeight ); + if ( $sourceAspect <= $maxSize['aspect'] ) { + return ( $maxSize['height'] > $sourceHeight ); + } else { + return ( $maxSize['width'] > $sourceWidth ); + } + } + + /** + * Is the given transcode key the smallest configured transcode for + * its video codec? + */ + public static function isSmallestTranscodeForCodec( $transcodeKey ) { + global $wgEnabledTranscodeSet; + + $settings = self::$derivativeSettings[$transcodeKey]; + $vcodec = $settings['videoCodec']; + $maxSize = self::getMaxSize( $settings['maxSize'] ); + + foreach ( $wgEnabledTranscodeSet as $tKey ) { + $tsettings = self::$derivativeSettings[$tKey]; + if ( $tsettings['videoCodec'] === $vcodec ) { + $tmaxSize = self::getMaxSize( $tsettings['maxSize'] ); + if ( $tmaxSize['width'] < $maxSize['width'] ) { + return false; + } + if ( $tmaxSize['height'] < $maxSize['height'] ) { + return false; + } + } + } + + return true; + } + + /** + * Return maxSize array for given maxSize setting + * + * @param $targetMaxSize string + * @return array + */ + public static function getMaxSize( $targetMaxSize ){ + $maxSize = array(); + $targetMaxSize = explode( 'x', $targetMaxSize ); + $maxSize['width'] = intval( $targetMaxSize[0] ); + if ( count( $targetMaxSize ) == 1 ) { + $maxSize['height'] = intval( $targetMaxSize[0] ); + } else { + $maxSize['height'] = intval( $targetMaxSize[1] ); + } + // check for zero size ( audio ) + if( $maxSize['width'] === 0 || $maxSize['height'] == 0 ){ + return 0; + } + $maxSize['aspect'] = $maxSize['width'] / $maxSize['height']; + return $maxSize; + } +} diff --git a/extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscodeJob.php b/extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscodeJob.php new file mode 100644 index 00000000..d437d9c7 --- /dev/null +++ b/extensions/TimedMediaHandler/WebVideoTranscode/WebVideoTranscodeJob.php @@ -0,0 +1,965 @@ +removeDuplicates = true; + } + + /** + * Local function to debug output ( jobs don't have access to the maintenance output class ) + * @param $msg string + */ + private function output( $msg ){ + print $msg . "\n"; + } + + /** + * @return File + */ + private function getFile() { + if( !$this->file ){ + $this->file = wfLocalFile( $this->title ); + } + return $this->file; + } + + /** + * @return string + */ + private function getTargetEncodePath(){ + if( !$this->targetEncodeFile ){ + $file = $this->getFile(); + $transcodeKey = $this->params[ 'transcodeKey' ]; + $this->targetEncodeFile = WebVideoTranscode::getTargetEncodeFile( $file, $transcodeKey ); + $this->targetEncodeFile->bind( $this ); + } + return $this->targetEncodeFile->getPath(); + } + /** + * purge temporary encode target + */ + private function purgeTargetEncodeFile () { + if ( $this->targetEncodeFile ) { + $this->targetEncodeFile->purge(); + $this->targetEncodeFile = null; + } + } + + /** + * @return string|bool + */ + private function getSourceFilePath(){ + if( !$this->sourceFilePath ){ + $file = $this->getFile(); + $this->source = $file->repo->getLocalReference( $file->getPath() ); + if ( !$this->source ) { + $this->sourceFilePath = false; + } else { + $this->sourceFilePath = $this->source->getPath(); + } + } + return $this->sourceFilePath; + } + + /** + * Update the transcode table with failure time and error + * @param $transcodeKey string + * @param $error string + * + */ + private function setTranscodeError( $transcodeKey, $error ){ + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'transcode', + array( + 'transcode_time_error' => $dbw->timestamp(), + 'transcode_error' => $error + ), + array( + 'transcode_image_name' => $this->getFile()->getName(), + 'transcode_key' => $transcodeKey + ), + __METHOD__ + ); + $this->setLastError( $error ); + } + + /** + * Run the transcode request + * @return boolean success + */ + public function run() { + global $wgVersion, $wgFFmpeg2theoraLocation; + // get a local pointer to the file + $file = $this->getFile(); + + // Validate the file exists: + if( !$file ){ + $this->output( $this->title . ': File not found ' ); + return false; + } + + // Validate the transcode key param: + $transcodeKey = $this->params['transcodeKey']; + // Build the destination target + if( ! isset( WebVideoTranscode::$derivativeSettings[ $transcodeKey ] )){ + $error = "Transcode key $transcodeKey not found, skipping"; + $this->output( $error ); + $this->setLastError( $error ); + return false; + } + + // Validate the source exists: + if( !$this->getSourceFilePath() || !is_file( $this->getSourceFilePath() ) ){ + $status = $this->title . ': Source not found ' . $this->getSourceFilePath(); + $this->output( $status ); + $this->setTranscodeError( $transcodeKey, $status ); + return false; + } + + $options = WebVideoTranscode::$derivativeSettings[ $transcodeKey ]; + + if ( isset( $options[ 'novideo' ] ) ) { + $this->output( "Encoding to audio codec: " . $options['audioCodec'] ); + } else { + $this->output( "Encoding to codec: " . $options['videoCodec'] ); + } + + $dbw = wfGetDB( DB_MASTER ); + + // Check if we have "already started" the transcode ( possible error ) + $dbStartTime = $dbw->selectField( 'transcode', 'transcode_time_startwork', + array( + 'transcode_image_name' => $this->getFile()->getName(), + 'transcode_key' => $transcodeKey + ), + __METHOD__ + ); + if( ! is_null( $dbStartTime ) ){ + $error = 'Error, running transcode job, for job that has already started'; + $this->output( $error ); + return true; + } + + // Update the transcode table letting it know we have "started work": + $jobStartTimeCache = $dbw->timestamp(); + $dbw->update( + 'transcode', + array( 'transcode_time_startwork' => $jobStartTimeCache ), + array( + 'transcode_image_name' => $this->getFile()->getName(), + 'transcode_key' => $transcodeKey + ), + __METHOD__ + ); + // Avoid contention and "server has gone away" errors as + // the transcode will take a very long time in some cases + $dbw->commit( __METHOD__, 'flush' ); + + // Check the codec see which encode method to call; + if ( isset( $options[ 'novideo' ] ) ) { + $status = $this->ffmpegEncode( $options ); + } elseif( $options['videoCodec'] == 'theora' && $wgFFmpeg2theoraLocation !== false ){ + $status = $this->ffmpeg2TheoraEncode( $options ); + } elseif( $options['videoCodec'] == 'vp8' || $options['videoCodec'] == 'vp9' || $options['videoCodec'] == 'h264' || ( $options['videoCodec'] == 'theora' && $wgFFmpeg2theoraLocation === false ) ){ + // Check for twopass: + if( isset( $options['twopass'] ) ){ + // ffmpeg requires manual two pass + $status = $this->ffmpegEncode( $options, 1 ); + if( $status && !is_string($status) ){ + $status = $this->ffmpegEncode( $options, 2 ); + } + } else { + $status = $this->ffmpegEncode( $options ); + } + } else { + wfDebug( 'Error unknown codec:' . $options['videoCodec'] ); + $status = 'Error unknown target encode codec:' . $options['videoCodec']; + } + + // Remove any log files all useful info should be in status and or we are done with 2 passs encoding + $this->removeFffmpgeLogFiles(); + + // Do a quick check to confirm the job was not restarted or removed while we were transcoding + // Confirm the in memory $jobStartTimeCache matches db start time + $dbStartTime = $dbw->selectField( 'transcode', 'transcode_time_startwork', + array( + 'transcode_image_name' => $this->getFile()->getName(), + 'transcode_key' => $transcodeKey + ) + ); + + // Check for ( hopefully rare ) issue of or job restarted while transcode in progress + if( $dbw->timestamp( $jobStartTimeCache ) != $dbw->timestamp( $dbStartTime ) ){ + $this->output('Possible Error, transcode task restarted, removed, or completed while transcode was in progress'); + // if an error; just error out, we can't remove temp files or update states, because the new job may be doing stuff. + if( $status !== true ){ + $this->setTranscodeError( $transcodeKey, $status ); + return false; + } + // else just continue with db updates, and when the new job comes around it won't start because it will see + // that the job has already been started. + } + + // If status is oky and target does not exist, reset status + if( $status === true && !is_file( $this->getTargetEncodePath() ) ) { + $status = 'Target does not exist: ' . $this->getTargetEncodePath(); + } + // If status is ok and target is larger than 0 bytes + if( $status === true && filesize( $this->getTargetEncodePath() ) > 0 ){ + $file = $this->getFile(); + $storeOptions = null; + if ( version_compare( $wgVersion, '1.23c', '>' ) && + strpos( $options['type'], '/ogg' ) !== false && + $file->getLength() + ) { + // Ogg files need a duration header for firefox + $storeOptions['headers']['X-Content-Duration'] = floatval( $file->getLength() ); + } + + // Copy derivative from the FS into storage at $finalDerivativeFilePath + $result = $file->getRepo()->quickImport( + $this->getTargetEncodePath(), // temp file + WebVideoTranscode::getDerivativeFilePath( $file, $transcodeKey ), // storage + $storeOptions + ); + if ( !$result->isOK() ) { + // no need to invalidate all pages with video. Because all pages remain valid ( no $transcodeKey derivative ) + // just clear the file page ( so that the transcode table shows the error ) + $this->title->invalidateCache(); + $this->setTranscodeError( $transcodeKey, $result->getWikiText() ); + $status = false; + } else { + $bitrate = round( intval( filesize( $this->getTargetEncodePath() ) / $file->getLength() ) * 8 ); + //wfRestoreWarnings(); + // Update the transcode table with success time: + $dbw->update( + 'transcode', + array( + 'transcode_error' => '', + 'transcode_time_success' => $dbw->timestamp(), + 'transcode_final_bitrate' => $bitrate + ), + array( + 'transcode_image_name' => $this->getFile()->getName(), + 'transcode_key' => $transcodeKey, + ), + __METHOD__ + ); + $dbw->commit( __METHOD__, 'flush' ); + WebVideoTranscode::invalidatePagesWithFile( $this->title ); + } + } else { + // Update the transcode table with failure time and error + $this->setTranscodeError( $transcodeKey, $status ); + // no need to invalidate all pages with video. Because all pages remain valid ( no $transcodeKey derivative ) + // just clear the file page ( so that the transcode table shows the error ) + $this->title->invalidateCache(); + } + //done with encoding target, clean up + $this->purgeTargetEncodeFile(); + + // Clear the webVideoTranscode cache ( so we don't keep out dated table cache around ) + WebVideoTranscode::clearTranscodeCache( $this->title->getDBkey() ); + + $url = WebVideoTranscode::getTranscodedUrlForFile( $file, $transcodeKey ); + SquidUpdate::purge( array( $url ) ); + + if ($status !== true) { + $this->setLastError( $status ); + } + return $status === true; + } + + function removeFffmpgeLogFiles(){ + $path = $this->getTargetEncodePath(); + $dir = dirname( $path ); + if ( is_dir( $dir ) ) { + $dh = opendir( $dir ); + if ( $dh ) { + while ( ($file = readdir($dh)) !== false ) { + $log_path = "$dir/$file"; + $ext = strtolower( pathinfo( $log_path, PATHINFO_EXTENSION ) ); + if( $ext == 'log' && substr( $log_path, 0 , strlen($path) ) == $path ){ + wfSuppressWarnings(); + unlink( $log_path ); + wfRestoreWarnings(); + } + } + closedir( $dh ); + } + } + } + + /** + * Utility helper for ffmpeg and ffmpeg2theora mapping + * @param $options array + * @param $pass int + * @return bool|string + */ + function ffmpegEncode( $options, $pass=0 ){ + global $wgFFmpegLocation, $wgTranscodeBackgroundMemoryLimit; + + if( !is_file( $this->getSourceFilePath() ) ) { + return "source file is missing, " . $this->getSourceFilePath() . ". Encoding failed."; + } + + // Set up the base command + $cmd = wfEscapeShellArg( $wgFFmpegLocation ) . ' -y -i ' . wfEscapeShellArg( $this->getSourceFilePath() ); + + + if( isset( $options['vpre'] ) ){ + $cmd.= ' -vpre ' . wfEscapeShellArg( $options['vpre'] ); + } + + if ( isset( $options['novideo'] ) ) { + $cmd.= " -vn "; + } elseif( $options['videoCodec'] == 'vp8' || $options['videoCodec'] == 'vp9' ){ + $cmd.= $this->ffmpegAddWebmVideoOptions( $options, $pass ); + } elseif( $options['videoCodec'] == 'h264'){ + $cmd.= $this->ffmpegAddH264VideoOptions( $options, $pass ); + } elseif( $options['videoCodec'] == 'theora'){ + $cmd.= $this->ffmpegAddTheoraVideoOptions( $options, $pass ); + } + // Add size options: + $cmd .= $this->ffmpegAddVideoSizeOptions( $options ) ; + + // Check for start time + if( isset( $options['starttime'] ) ){ + $cmd.= ' -ss ' . wfEscapeShellArg( $options['starttime'] ); + } else { + $options['starttime'] = 0; + } + // Check for end time: + if( isset( $options['endtime'] ) ){ + $cmd.= ' -t ' . intval( $options['endtime'] ) - intval($options['starttime'] ) ; + } + + if ( $pass == 1 || isset( $options['noaudio'] ) ) { + $cmd.= ' -an'; + } else { + $cmd.= $this->ffmpegAddAudioOptions( $options, $pass ); + } + + if ( $pass != 0 ) { + $cmd.=" -pass " .wfEscapeShellArg( $pass ) ; + $cmd.=" -passlogfile " . wfEscapeShellArg( $this->getTargetEncodePath() .'.log' ); + } + // And the output target: + if ($pass == 1) { + $cmd.= ' /dev/null'; + } else{ + $cmd.= " " . $this->getTargetEncodePath(); + } + + $this->output( "Running cmd: \n\n" .$cmd . "\n" ); + + // Right before we output remove the old file + $retval = 0; + $shellOutput = $this->runShellExec( $cmd, $retval ); + + if( $retval != 0 ){ + return $cmd . + "\n\nExitcode: $retval\nMemory: $wgTranscodeBackgroundMemoryLimit\n\n" . + $shellOutput; + } + return true; + } + + /** + * Adds ffmpeg shell options for h264 + * + * @param $options + * @param $pass + * @return string + */ + function ffmpegAddH264VideoOptions( $options, $pass ){ + global $wgFFmpegThreads; + // Set the codec: + $cmd= " -threads " . intval( $wgFFmpegThreads ) . " -vcodec libx264"; + // Check for presets: + if( isset( $options['preset'] ) ){ + // Add the two vpre types: + switch( $options['preset'] ){ + case 'ipod320': + $cmd .= " -profile:v baseline -preset slow -coder 0 -bf 0 -weightb 1 -level 13 -maxrate 768k -bufsize 3M"; + break; + case '720p': + case 'ipod640': + $cmd .= " -profile:v baseline -preset slow -coder 0 -bf 0 -refs 1 -weightb 1 -level 31 -maxrate 10M -bufsize 10M"; + break; + default: + // in the default case just pass along the preset to ffmpeg + $cmd.= " -vpre " . wfEscapeShellArg( $options['preset'] ); + break; + } + } + if( isset( $options['videoBitrate'] ) ){ + $cmd.= " -b " . wfEscapeShellArg ( $options['videoBitrate'] ); + } + // Output mp4 + $cmd.=" -f mp4"; + return $cmd; + } + + function ffmpegAddVideoSizeOptions( $options ){ + $cmd = ''; + // Get a local pointer to the file object + $file = $this->getFile(); + + // Check for aspect ratio ( we don't do anything with this right now) + if ( isset( $options['aspect'] ) ) { + $aspectRatio = $options['aspect']; + } else { + $aspectRatio = $file->getWidth() . ':' . $file->getHeight(); + } + if (isset( $options['maxSize'] )) { + // Get size transform ( if maxSize is > file, file size is used: + + list( $width, $height ) = WebVideoTranscode::getMaxSizeTransform( $file, $options['maxSize'] ); + $cmd.= ' -s ' . intval( $width ) . 'x' . intval( $height ); + } elseif ( + (isset( $options['width'] ) && $options['width'] > 0 ) + && + (isset( $options['height'] ) && $options['height'] > 0 ) + ){ + $cmd.= ' -s ' . intval( $options['width'] ) . 'x' . intval( $options['height'] ); + } + + // Handle crop: + $optionMap = array( + 'cropTop' => '-croptop', + 'cropBottom' => '-cropbottom', + 'cropLeft' => '-cropleft', + 'cropRight' => '-cropright' + ); + foreach( $optionMap as $name => $cmdArg ){ + if( isset($options[$name]) ){ + $cmd.= " $cmdArg " . wfEscapeShellArg( $options[$name] ); + } + } + return $cmd; + } + /** + * Adds ffmpeg shell options for webm + * + * @param $options + * @param $pass + * @return string + */ + function ffmpegAddWebmVideoOptions( $options, $pass ){ + global $wgFFmpegThreads; + + // Get a local pointer to the file object + $file = $this->getFile(); + + $cmd =' -threads ' . intval( $wgFFmpegThreads ); + + // check for presets: + if( isset($options['preset']) ){ + if ($options['preset'] == "360p") { + $cmd.= " -vpre libvpx-360p"; + } elseif ( $options['preset'] == "720p" ) { + $cmd.= " -vpre libvpx-720p"; + } elseif ( $options['preset'] == "1080p" ) { + $cmd.= " -vpre libvpx-1080p"; + } + } + + // Add the boiler plate vp8 ffmpeg command: + $cmd.=" -skip_threshold 0 -bufsize 6000k -rc_init_occupancy 4000"; + + // Check for video quality: + if ( isset( $options['videoQuality'] ) && $options['videoQuality'] >= 0 ) { + // Map 0-10 to 63-0, higher values worse quality + $quality = 63 - intval( intval( $options['videoQuality'] )/10 * 63 ); + $cmd .= " -qmin " . wfEscapeShellArg( $quality ); + $cmd .= " -qmax " . wfEscapeShellArg( $quality ); + } + + // Check for video bitrate: + if ( isset( $options['videoBitrate'] ) ) { + $cmd.= " -qmin 1 -qmax 51"; + $cmd.= " -vb " . wfEscapeShellArg( $options['videoBitrate'] * 1000 ); + } + // Set the codec: + if ( $options['videoCodec'] === 'vp9' ) { + $cmd.= " -vcodec libvpx-vp9"; + if ( isset( $options['tileColumns'] ) ) { + $cmd.= ' -tile-columns ' . wfEscapeShellArg( $options['tileColumns'] ); + } + } else { + $cmd.= " -vcodec libvpx"; + } + + // Check for keyframeInterval + if( isset( $options['keyframeInterval'] ) ){ + $cmd.= ' -g ' . wfEscapeShellArg( $options['keyframeInterval'] ); + $cmd.= ' -keyint_min ' . wfEscapeShellArg( $options['keyframeInterval'] ); + } + if( isset( $options['deinterlace'] ) ){ + $cmd.= ' -deinterlace'; + } + + // Output WebM + $cmd.=" -f webm"; + + return $cmd; + } + + /** + * Adds ffmpeg/avconv shell options for ogg + * + * Used only when $wgFFmpeg2theoraLocation set to false. + * Warning: does not create Ogg skeleton metadata track. + * + * @param $options + * @param $pass + * @return string + */ + function ffmpegAddTheoraVideoOptions( $options, $pass ){ + global $wgFFmpegThreads; + + // Get a local pointer to the file object + $file = $this->getFile(); + + $cmd =' -threads ' . intval( $wgFFmpegThreads ); + + // Check for video quality: + if ( isset( $options['videoQuality'] ) && $options['videoQuality'] >= 0 ) { + // Map 0-10 to 63-0, higher values worse quality + $quality = 63 - intval( intval( $options['videoQuality'] )/10 * 63 ); + $cmd .= " -qmin " . wfEscapeShellArg( $quality ); + $cmd .= " -qmax " . wfEscapeShellArg( $quality ); + } + + // Check for video bitrate: + if ( isset( $options['videoBitrate'] ) ) { + $cmd.= " -qmin 1 -qmax 51"; + $cmd.= " -vb " . wfEscapeShellArg( $options['videoBitrate'] * 1000 ); + } + // Set the codec: + $cmd.= " -vcodec theora"; + + // Check for keyframeInterval + if( isset( $options['keyframeInterval'] ) ){ + $cmd.= ' -g ' . wfEscapeShellArg( $options['keyframeInterval'] ); + $cmd.= ' -keyint_min ' . wfEscapeShellArg( $options['keyframeInterval'] ); + } + if( isset( $options['deinterlace'] ) ){ + $cmd.= ' -deinterlace'; + } + if( isset( $options['framerate'] ) ) { + $cmd.= ' -r ' . wfEscapeShellArg( $options['framerate'] ); + } + + // Output Ogg + $cmd.=" -f ogg"; + + return $cmd; + } + + /** + * @param $options array + * @param $pass + * @return string + */ + function ffmpegAddAudioOptions( $options, $pass ){ + $cmd =''; + if( isset( $options['audioQuality'] ) ){ + $cmd.= " -aq " . wfEscapeShellArg( $options['audioQuality'] ); + } + if( isset( $options['audioBitrate'] )){ + $cmd.= ' -ab ' . intval( $options['audioBitrate'] ) * 1000; + } + if( isset( $options['samplerate'] ) ){ + $cmd.= " -ar " . wfEscapeShellArg( $options['samplerate'] ); + } + if( isset( $options['channels'] ) ){ + $cmd.= " -ac " . wfEscapeShellArg( $options['channels'] ); + } + + if( isset( $options['audioCodec'] ) ){ + $encoders = array( + 'vorbis' => 'libvorbis', + 'opus' => 'libopus', + 'mp3' => 'libmp3lame', + ); + if ( isset( $encoders[ $options['audioCodec'] ] ) ) { + $codec = $encoders[ $options['audioCodec'] ]; + } else { + $codec = $options['audioCodec']; + } + $cmd.= " -acodec " . wfEscapeShellArg( $codec ); + if ( $codec === 'aac' ) { + // the aac encoder is currently "experimental" in libav 9? :P + $cmd .= ' -strict experimental'; + } + } else { + // if no audio codec set use vorbis : + $cmd.= " -acodec libvorbis "; + } + return $cmd; + } + + /** + * ffmpeg2Theora mapping is much simpler since it is the basis of the the firefogg API + * @param $options array + * @return bool|string + */ + function ffmpeg2TheoraEncode( $options ){ + global $wgFFmpeg2theoraLocation, $wgTranscodeBackgroundMemoryLimit; + + if( !is_file( $this->getSourceFilePath() ) ) { + return "source file is missing, " . $this->getSourceFilePath() . ". Encoding failed."; + } + + // Set up the base command + $cmd = wfEscapeShellArg( $wgFFmpeg2theoraLocation ) . ' ' . wfEscapeShellArg( $this->getSourceFilePath() ); + + $file = $this->getFile(); + + if( isset( $options['maxSize'] ) ){ + list( $width, $height ) = WebVideoTranscode::getMaxSizeTransform( $file, $options['maxSize'] ); + $options['width'] = $width; + $options['height'] = $height; + $options['aspect'] = $width . ':' . $height; + unset( $options['maxSize'] ); + } + + // Add in the encode settings + foreach( $options as $key => $val ){ + if( isset( self::$foggMap[$key] ) ){ + if( is_array( self::$foggMap[$key] ) ){ + $cmd.= ' '. implode(' ', self::$foggMap[$key] ); + } elseif ($val == 'true' || $val === true){ + $cmd.= ' '. self::$foggMap[$key]; + } elseif ($val == 'false' || $val === false){ + //ignore "false" flags + } else { + //normal get/set value + $cmd.= ' '. self::$foggMap[$key] . ' ' . wfEscapeShellArg( $val ); + } + } + } + + // Add the output target: + $outputFile = $this->getTargetEncodePath(); + $cmd.= ' -o ' . wfEscapeShellArg ( $outputFile ); + + $this->output( "Running cmd: \n\n" .$cmd . "\n" ); + + $retval = 0; + $shellOutput = $this->runShellExec( $cmd, $retval ); + + // ffmpeg2theora returns 0 status on some errors, so also check for file + if( $retval != 0 || !is_file( $outputFile ) || filesize( $outputFile ) === 0 ){ + return $cmd . + "\n\nExitcode: $retval\nMemory: $wgTranscodeBackgroundMemoryLimit\n\n" . + $shellOutput; + } + return true; + } + + /** + * Runs the shell exec command. + * if $wgEnableBackgroundTranscodeJobs is enabled will mannage a background transcode task + * else it just directly passes off to wfShellExec + * + * @param $cmd String Command to be run + * @param $retval String, refrence variable to return the exit code + * @return string + */ + public function runShellExec( $cmd, &$retval ){ + global $wgTranscodeBackgroundTimeLimit, + $wgTranscodeBackgroundMemoryLimit, + $wgTranscodeBackgroundSizeLimit, + $wgEnableNiceBackgroundTranscodeJobs; + + // For profiling + $caller = wfGetCaller(); + + // Check if background tasks are enabled + if( $wgEnableNiceBackgroundTranscodeJobs === false ){ + // Directly execute the shell command: + $limits = array( + "filesize" => $wgTranscodeBackgroundSizeLimit, + "memory" => $wgTranscodeBackgroundMemoryLimit, + "time" => $wgTranscodeBackgroundTimeLimit + ); + return wfShellExec( $cmd . ' 2>&1', $retval , array(), $limits, + array( 'profileMethod' => $caller ) ); + } + + $encodingLog = $this->getTargetEncodePath() . '.stdout.log'; + $retvalLog = $this->getTargetEncodePath() . '.retval.log'; + // Check that we can actually write to these files + //( no point in running the encode if we can't write ) + wfSuppressWarnings(); + if( ! touch( $encodingLog) || ! touch( $retvalLog ) ){ + wfRestoreWarnings(); + $retval = 1; + return "Error could not write to target location"; + } + wfRestoreWarnings(); + + // Fork out a process for running the transcode + $pid = pcntl_fork(); + if ($pid == -1) { + $errorMsg = '$wgEnableNiceBackgroundTranscodeJobs enabled but failed pcntl_fork'; + $retval = 1; + $this->output( $errorMsg); + return $errorMsg; + } elseif ( $pid == 0) { + // we are the child + $this->runChildCmd( $cmd, $retval, $encodingLog, $retvalLog, $caller ); + // dont remove any temp files in the child process, this is done + // once the parent is finished + $this->targetEncodeFile->preserve(); + if ( $this->source instanceof TempFSFile ) { + $this->source->preserve(); + } + // exit with the same code as the transcode: + exit( $retval ); + } else { + // we are the parent monitor and return status + return $this->monitorTranscode($pid, $retval, $encodingLog, $retvalLog); + } + } + + /** + * @param $cmd + * @param $retval + * @param $encodingLog + * @param $retvalLog + * @param string $caller The calling method + */ + public function runChildCmd( $cmd, &$retval, $encodingLog, $retvalLog, $caller ){ + global $wgTranscodeBackgroundTimeLimit, + $wgTranscodeBackgroundMemoryLimit, + $wgTranscodeBackgroundSizeLimit; + // In theory we should use pcntl_exec but not sure how to get the stdout, ensure + // we don't max php memory with the same protections provided by wfShellExec. + + // pcntl_exec requires a direct path to the exe and arguments as an array: + //$cmd = explode(' ', $cmd ); + //$baseCmd = array_shift( $cmd ); + //print "run:" . $baseCmd . " args: " . print_r( $cmd, true ); + //$status = pcntl_exec($baseCmd , $cmd ); + + // Directly execute the shell command: + //global $wgTranscodeBackgroundPriority; + //$status = wfShellExec( 'nice -n ' . $wgTranscodeBackgroundPriority . ' '. $cmd . ' 2>&1', $retval ); + $limits = array( + "filesize" => $wgTranscodeBackgroundSizeLimit, + "memory" => $wgTranscodeBackgroundMemoryLimit, + "time" => $wgTranscodeBackgroundTimeLimit + ); + $status = wfShellExec( $cmd . ' 2>&1', $retval , array(), $limits, + array( 'profileMethod' => $caller ) ); + + // Output the status: + wfSuppressWarnings(); + file_put_contents( $encodingLog, $status ); + // Output the retVal to the $retvalLog + file_put_contents( $retvalLog, $retval ); + wfRestoreWarnings(); + } + + /** + * @param $pid + * @param $retval + * @param $encodingLog + * @param $retvalLog + * @return string + */ + public function monitorTranscode( $pid, &$retval, $encodingLog, $retvalLog ){ + global $wgTranscodeBackgroundTimeLimit, $wgLang; + $errorMsg = ''; + $loopCount = 0; + $oldFileSize = 0; + $startTime = time(); + $fileIsNotGrowing = false; + + $this->output( "Encoding with pid: $pid \npcntl_waitpid: " . pcntl_waitpid( $pid, $status, WNOHANG OR WUNTRACED) . + "\nisProcessRunning: " . self::isProcessRunningKillZombie( $pid ) . "\n" ); + + // Check that the child process is still running ( note this does not work well with pcntl_waitpid + // for some reason :( + while( self::isProcessRunningKillZombie( $pid ) ) { + //$this->output( "$pid is running" ); + + // Check that the target file is growing ( every 5 seconds ) + if( $loopCount == 10 ){ + // only run check if we are outputing to target file + // ( two pass encoding does not output to target on first pass ) + clearstatcache(); + $newFileSize = is_file( $this->getTargetEncodePath() ) ? filesize( $this->getTargetEncodePath() ) : 0; + // Don't start checking for file growth until we have an initial positive file size: + if( $newFileSize > 0 ){ + $this->output( $wgLang->formatSize( $newFileSize ). ' Total size, encoding ' . + $wgLang->formatSize( ( $newFileSize - $oldFileSize ) / 5 ) . ' per second' ); + if( $newFileSize == $oldFileSize ){ + if( $fileIsNotGrowing ){ + $errorMsg = "Target File is not increasing in size, kill process."; + $this->output( $errorMsg ); + // file is not growing in size, kill proccess + $retval = 1; + + //posix_kill( $pid, 9); + self::killProcess( $pid ); + break; + } + // Wait an additional 5 seconds of the file not growing to confirm + // the transcode is frozen. + $fileIsNotGrowing = true; + } else { + $fileIsNotGrowing = false; + } + $oldFileSize = $newFileSize; + } + // reset the loop counter + $loopCount = 0; + } + + // Check if we have global job run-time has been exceeded: + if ( $wgTranscodeBackgroundTimeLimit && time() - $startTime > $wgTranscodeBackgroundTimeLimit ){ + $errorMsg = "Encoding exceeded max job run time ( " + . TimedMediaHandler::seconds2npt( $wgTranscodeBackgroundTimeLimit ) . " ), kill process."; + $this->output( $errorMsg ); + // File is not growing in size, kill proccess + $retval = 1; + //posix_kill( $pid, 9); + self::killProcess( $pid ); + break; + } + + // Sleep for one second before repeating loop + $loopCount++; + sleep( 1 ); + } + + $returnPcntl = pcntl_wexitstatus( $status ); + // check status + wfSuppressWarnings(); + $returnCodeFile = file_get_contents( $retvalLog ); + wfRestoreWarnings(); + //$this->output( "TranscodeJob:: Child pcntl return:". $returnPcntl . ' Log file exit code:' . $returnCodeFile . "\n" ); + + // File based exit code seems more reliable than pcntl_wexitstatus + $retval = $returnCodeFile; + + // return the encoding log contents ( will be inserted into error table if an error ) + // ( will be ignored and removed if success ) + if( $errorMsg!= '' ){ + $errorMsg.="\n\n"; + } + return $errorMsg . file_get_contents( $encodingLog ); + } + + /** + * check if proccess is running and not a zombie + * @param $pid int + * @return bool + */ + public static function isProcessRunningKillZombie( $pid ){ + exec( "ps $pid", $processState ); + if( !isset( $processState[1] ) ){ + return false; + } + if( strpos( $processState[1], '' ) !== false ){ + // posix kill does not seem to work + //posix_kill( $pid, 9); + self::killProcess( $pid ); + return false; + } + return true; + } + + /** + * Kill Application PID + * + * @param $pid int + * @return bool + */ + public static function killProcess( $pid ){ + exec( "kill -9 $pid" ); + exec( "ps $pid", $processState ); + if( isset( $processState[1] ) ){ + return false; + } + return true; + } + + /** + * Mapping between firefogg api and ffmpeg2theora command line + * + * This lets us share a common api between firefogg and WebVideoTranscode + * also see: http://firefogg.org/dev/index.html + */ + public static $foggMap = array( + // video + 'width' => "--width", + 'height' => "--height", + 'maxSize' => "--max_size", + 'noUpscaling' => "--no-upscaling", + 'videoQuality'=> "-v", + 'videoBitrate' => "-V", + 'twopass' => "--two-pass", + 'optimize' => "--optimize", + 'framerate' => "-F", + 'aspect' => "--aspect", + 'starttime' => "--starttime", + 'endtime' => "--endtime", + 'cropTop' => "--croptop", + 'cropBottom' => "--cropbottom", + 'cropLeft' => "--cropleft", + 'cropRight' => "--cropright", + 'keyframeInterval'=> "--keyint", + 'denoise' => array("--pp", "de"), + 'deinterlace' => "--deinterlace", + 'novideo' => array("--novideo", "--no-skeleton"), + 'bufDelay' => "--buf-delay", + // audio + 'audioQuality' => "-a", + 'audioBitrate' => "-A", + 'samplerate' => "-H", + 'channels' => "-c", + 'noaudio' => "--noaudio", + // metadata + 'artist' => "--artist", + 'title' => "--title", + 'date' => "--date", + 'location' => "--location", + 'organization' => "--organization", + 'copyright' => "--copyright", + 'license' => "--license", + 'contact' => "--contact" + ); + +} -- cgit v1.2.3-54-g00ecf