Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 508
0.00% covered (danger)
0.00%
0 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebVideoTranscodeJob
0.00% covered (danger)
0.00%
0 / 508
0.00% covered (danger)
0.00%
0 / 29
22350
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getTargetEncodePath
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTargetPlaylistPath
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 fileTarget
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 purgeTargetEncodeFile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getSourceFilePath
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 setTranscodeError
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 183
0.00% covered (danger)
0.00%
0 / 1
1406
 getCommand
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 useScript
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 ffmpegEncode
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 1
1482
 scaleRate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 expandRate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 effectiveFrameRate
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 fractionToFloat
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 frameRate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 ffmpegAddH264VideoOptions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 ffmpegAddMPEG4VideoOptions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 ffmpegAddGenericVideoOptions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 ffmpegAddVideoSizeOptions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 ffmpegAddWebmVideoOptions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 isInterlaced
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 shouldFrameDouble
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 ffmpegAddDeinterlaceOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 ffmpegAddAudioOptions
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 midiToAudioEncode
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Job for transcode jobs
4 *
5 * @file
6 * @ingroup JobQueue
7 */
8
9namespace MediaWiki\TimedMediaHandler\WebVideoTranscode;
10
11use Exception;
12use File;
13use FSFile;
14use InvalidArgumentException;
15use Job;
16use LogicException;
17use MediaWiki\Config\Config;
18use MediaWiki\Deferred\CdnCacheUpdate;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MainConfigNames;
21use MediaWiki\Shell\CommandFactory;
22use MediaWiki\Shell\Shell;
23use MediaWiki\TimedMediaHandler\HLS\Segmenter;
24use MediaWiki\TimedMediaHandler\TimedMediaHandler;
25use MediaWiki\Title\Title;
26use RepoGroup;
27use Shellbox\Command\BoxedCommand;
28use TempFSFile;
29use Wikimedia\Rdbms\ILBFactory;
30
31/**
32 * Job for web video transcode
33 *
34 * Support two modes
35 * 1) non-free media transcode ( delays the media file being inserted,
36 *    adds note to talk page once ready)
37 * 2) derivatives for video ( makes new sources for the asset )
38 *
39 * @ingroup JobQueue
40 */
41
42class WebVideoTranscodeJob extends Job {
43
44    /** @var TempFSFile|null */
45    public $targetEncodeFile;
46
47    /** @var TempFSFile|null */
48    public $targetPlaylistFile;
49
50    /** @var string|null|false */
51    public $sourceFilePath;
52
53    /** @var File */
54    public $file;
55
56    /** @var FSFile|null */
57    public $source;
58
59    /** @var FSFile|null */
60    public $remuxSource;
61
62    /** @var CommandFactory */
63    private $commandFactory;
64
65    /** @var Config */
66    private $config;
67
68    /** @var ILBFactory */
69    private $lbFactory;
70
71    /** @var RepoGroup */
72    private $repoGroup;
73
74    /**
75     * @param Title $title
76     * @param array $params
77     * @param CommandFactory $commandFactory
78     * @param Config $config
79     * @param ILBFactory $lbFactory
80     * @param RepoGroup $repoGroup
81     */
82    public function __construct( $title, $params,
83        CommandFactory $commandFactory,
84        Config $config,
85        ILBFactory $lbFactory,
86        RepoGroup $repoGroup
87    ) {
88        if ( isset( $params['prioritized'] ) && $params['prioritized'] ) {
89            $command = 'webVideoTranscodePrioritized';
90        } else {
91            $command = 'webVideoTranscode';
92        }
93        parent::__construct( $command, $title, $params );
94        $this->removeDuplicates = true;
95        $this->commandFactory = $commandFactory;
96        $this->config = $config;
97        $this->lbFactory = $lbFactory;
98        $this->repoGroup = $repoGroup;
99    }
100
101    /**
102     * Accessor for MainConfig
103     * @return Config
104     */
105    protected function getConfig(): Config {
106        return $this->config;
107    }
108
109    /**
110     * Wrapper around debug logger
111     * @param string $msg
112     */
113    private function output( $msg ) {
114        LoggerFactory::getInstance( 'WebVideoTranscodeJob' )->debug( $msg );
115    }
116
117    /**
118     * @return File
119     */
120    private function getFile() {
121        if ( !$this->file ) {
122            $this->file = $this->repoGroup->getLocalRepo()
123                ->findFile( $this->title, [ 'latest' => true ] );
124        }
125        return $this->file;
126    }
127
128    /**
129     * @return string
130     */
131    private function getTargetEncodePath() {
132        if ( !$this->targetEncodeFile ) {
133            $this->targetEncodeFile = $this->fileTarget();
134        }
135        return $this->targetEncodeFile->getPath();
136    }
137
138    /**
139     * @return string
140     */
141    private function getTargetPlaylistPath() {
142        if ( !$this->targetPlaylistFile ) {
143            $this->targetPlaylistFile = $this->fileTarget( '.m3u8' );
144        }
145        return $this->targetPlaylistFile->getPath();
146    }
147
148    /**
149     * @param string $suffix
150     * @return TempFSFile
151     */
152    private function fileTarget( $suffix = '' ) {
153        $base = $this->getFile();
154        $transcodeKey = $this->params[ 'transcodeKey' ];
155        $file = WebVideoTranscode::getTargetEncodeFile( $base, $transcodeKey, $suffix );
156        if ( !$file ) {
157            throw new LogicException( 'Internal state error' );
158        }
159        $file->bind( $this );
160        return $file;
161    }
162
163    /**
164     * purge temporary encode target
165     */
166    private function purgeTargetEncodeFile() {
167        if ( $this->targetEncodeFile ) {
168            $this->targetEncodeFile->purge();
169            $this->targetEncodeFile = null;
170        }
171        if ( $this->targetPlaylistFile ) {
172            $this->targetPlaylistFile->purge();
173            $this->targetPlaylistFile = null;
174        }
175    }
176
177    /**
178     * @return string|false
179     */
180    private function getSourceFilePath() {
181        if ( !$this->sourceFilePath ) {
182            $file = $this->getFile();
183            $this->source = $file->repo->getLocalReference( $file->getPath() );
184            if ( !$this->source ) {
185                $this->sourceFilePath = false;
186            } else {
187                $this->sourceFilePath = $this->source->getPath();
188            }
189        }
190        return $this->sourceFilePath;
191    }
192
193    /**
194     * Update the transcode table with failure time and error
195     * @param string $transcodeKey
196     * @param string $error
197     *
198     */
199    private function setTranscodeError( $transcodeKey, $error ) {
200        $dbw = $this->lbFactory->getPrimaryDatabase();
201        $dbw->newUpdateQueryBuilder()
202            ->update( 'transcode' )
203            ->set( [
204                'transcode_time_error' => $dbw->timestamp(),
205                'transcode_error' => $error
206            ] )
207            ->where( [
208                    'transcode_image_name' => $this->getFile()->getName(),
209                    'transcode_key' => $transcodeKey
210            ] )
211            ->caller( __METHOD__ )
212            ->execute();
213        $this->setLastError( $error );
214    }
215
216    /**
217     * Run the transcode request
218     * @return bool success
219     */
220    public function run() {
221        $transcodeKey = $this->params['transcodeKey'];
222
223        try {
224            // get a local pointer to the file
225            $file = $this->getFile();
226
227            // Validate the file exists:
228            if ( !$file ) {
229                $error = $this->title . ': File not found';
230                $this->output( $error );
231                $this->setTranscodeError( $transcodeKey, $error );
232                return false;
233            }
234
235            // Validate the transcode key param:
236            if ( !isset( WebVideoTranscode::$derivativeSettings[ $transcodeKey ] ) ) {
237                $error = "Transcode key $transcodeKey not found, skipping";
238                $this->output( $error );
239                $this->setTranscodeError( $transcodeKey, $error );
240                return false;
241            }
242
243            // Validate the source exists:
244            if ( !$this->getSourceFilePath() || !is_file( $this->getSourceFilePath() ) ) {
245                $status = $this->title . ': Source not found ' . $this->getSourceFilePath();
246                $this->output( $status );
247                $this->setTranscodeError( $transcodeKey, $status );
248                return false;
249            }
250
251            $options = WebVideoTranscode::$derivativeSettings[ $transcodeKey ];
252
253            if ( isset( $options[ 'novideo' ] ) ) {
254                if ( !isset( $options['audioCodec'] ) ) {
255                    throw new LogicException( 'Invalid audio track options' );
256                }
257                $this->output( "Encoding to audio codec: " . $options['audioCodec'] );
258            } else {
259                if ( !isset( $options['videoCodec'] ) ) {
260                    throw new LogicException( 'Invalid video track options' );
261                }
262                $this->output( "Encoding to codec: " . $options['videoCodec'] );
263            }
264            $dbw = $this->lbFactory->getPrimaryDatabase();
265
266            // Check if we have "already started" the transcode ( possible error )
267            $dbStartTime = $dbw->newSelectQueryBuilder()
268                ->select( 'transcode_time_startwork' )
269                ->from( 'transcode' )
270                ->where( [
271                    'transcode_image_name' => $this->getFile()->getName(),
272                    'transcode_key' => $transcodeKey
273                ] )
274                ->caller( __METHOD__ )
275                ->fetchField();
276            if ( $dbStartTime !== null ) {
277                $error = 'Error, running transcode job, for job that has already started';
278                $this->output( $error );
279                return true;
280            }
281
282            // Update the transcode table letting it know we have "started work":
283            $jobStartTimeCache = wfTimestamp( TS_UNIX );
284            $dbw->newUpdateQueryBuilder()
285                ->update( 'transcode' )
286                ->set( [ 'transcode_time_startwork' => $dbw->timestamp( $jobStartTimeCache ) ] )
287                ->where( [
288                    'transcode_image_name' => $this->getFile()->getName(),
289                    'transcode_key' => $transcodeKey
290                ] )
291                ->caller( __METHOD__ )
292                ->execute();
293
294            // Avoid contention and "server has gone away" errors as
295            // the transcode will take a very long time in some cases
296            $this->lbFactory->commitPrimaryChanges( __METHOD__ );
297            $this->lbFactory->flushPrimarySessions( __METHOD__ );
298            $this->lbFactory->flushReplicaSnapshots( __METHOD__ );
299            // We can't just leave the connection open either or it will
300            // eat up resources and block new connections, so make sure
301            // everything is dead and gone.
302            $this->lbFactory->closeAll();
303
304            // Check the codec see which encode method to call;
305            $streaming = $options['streaming'] ?? false;
306            $videoCodec = $options['videoCodec'] ?? '';
307            $codecs = [ 'vp8', 'vp9', 'h264', 'h263', 'mpeg4', 'mjpeg' ];
308            $twopass = isset( $options['twopass'] );
309
310            // Was the _job_ enqueued with the remux option variant?
311            $remux = $this->params['remux'] ?? false;
312            // Does the _transcode config_ have a list of remux sources?
313            $remuxFrom = $options['remuxFrom'] ?? false;
314            if ( $remux && $remuxFrom ) {
315                foreach ( $remuxFrom as $altKey ) {
316                    $altSource = WebVideoTranscode::getDerivativeFilePath( $file, $altKey );
317                    $repo = $this->file->repo;
318                    if ( $repo->fileExists( $altSource ) ) {
319                        $remuxSource = $repo->getLocalReference( $altSource );
320                        if ( $remuxSource ) {
321                            $this->remuxSource = $remuxSource;
322                            $twopass = false;
323                            break;
324                        }
325                    }
326                }
327            }
328            if ( isset( $options[ 'novideo' ] ) ) {
329                if ( $file->getMimeType() === 'audio/midi' ) {
330                    $status = $this->midiToAudioEncode( $options );
331                } else {
332                    $status = $this->ffmpegEncode( $options );
333                }
334            } elseif ( in_array( $videoCodec, $codecs ) ) {
335                // Check for twopass:
336                if ( $twopass ) {
337                    // ffmpeg requires manual two pass
338                    $status = $this->ffmpegEncode( $options, 2 );
339                } else {
340                    $status = $this->ffmpegEncode( $options );
341                }
342            } else {
343                wfDebug( 'Error unknown codec:' . $videoCodec );
344                $status = 'Error unknown target encode codec:' . $videoCodec;
345            }
346
347            // Reconnect to the database...
348            $dbw = $this->lbFactory->getPrimaryDatabase();
349
350            // Do a quick check to confirm the job was not restarted or removed while we were transcoding
351            // Confirm that the in memory $jobStartTimeCache matches db start time
352            $dbStartTime = $dbw->newSelectQueryBuilder()
353                ->select( 'transcode_time_startwork' )
354                ->from( 'transcode' )
355                ->where( [
356                    'transcode_image_name' => $this->getFile()->getName(),
357                    'transcode_key' => $transcodeKey
358                ] )
359                ->caller( __METHOD__ )
360                ->fetchField();
361
362            // Check for ( hopefully rare ) issue of or job restarted while transcode in progress
363            if ( $dbStartTime === null || $jobStartTimeCache !== wfTimestamp( TS_UNIX, $dbStartTime ) ) {
364                $this->output(
365                    'Possible Error,
366                        transcode task restarted, removed, or completed while transcode was in progress'
367                );
368                // if an error; just error out,
369                // we can't remove temp files or update states, because the new job may be doing stuff.
370                if ( $status !== true ) {
371                    $this->setTranscodeError( $transcodeKey, $status );
372                    return false;
373                }
374                // else just continue with db updates,
375                // and when the new job comes around it won't start because it will see
376                // that the job has already been started.
377            }
378
379            // If status is ok and target does not exist, reset status
380            if ( $status === true && !is_file( $this->getTargetEncodePath() ) ) {
381                $status = 'Target does not exist: ' . $this->getTargetEncodePath();
382            }
383
384            // If status is ok and target is larger than 0 bytes
385            if ( $status === true && filesize( $this->getTargetEncodePath() ) > 0 ) {
386
387                $file = $this->getFile();
388                $mediaFilename = WebVideoTranscode::getTranscodeFileBaseName( $file, $transcodeKey );
389                $mediaPath = WebVideoTranscode::getDerivativeFilePath( $file, $transcodeKey );
390                $storeOptions = null;
391                $playlistStoreOptions = null;
392
393                if ( $streaming === 'hls' ) {
394                    $playlistKey = $transcodeKey . '.m3u8';
395                    $playlistFilename = WebVideoTranscode::getTranscodeFileBaseName( $file, $playlistKey );
396                    $playlistPath = WebVideoTranscode::getDerivativeFilePath( $file, $playlistKey );
397                    $playlistTemp = $this->getTargetPlaylistPath();
398
399                    $segmenter = Segmenter::segment( $this->getTargetEncodePath() );
400                    // @fixme put the 10-second segment target in a constant somewhere
401                    $segmenter->consolidate( 10 );
402                    $segmenter->rewrite();
403                    $playlist = $segmenter->playlist( 10, $mediaFilename );
404
405                    file_put_contents( $playlistTemp, $playlist );
406                    $playlistStoreOptions = [];
407                    $playlistStoreOptions['headers']['Content-Type'] = 'application/vnd.apple.mpegurl; charset=utf-8';
408                } else {
409                    $playlistTemp = null;
410                    $playlistPath = null;
411                }
412
413                if (
414                    strpos( $options['type'], '/ogg' ) !== false &&
415                    $file->getLength()
416                ) {
417                    $storeOptions = [];
418                    // Ogg files need a duration header for firefox
419                    $storeOptions['headers']['X-Content-Duration'] = (float)$file->getLength();
420                }
421
422                // Avoid "server has gone away" errors as copying can be slow
423                $this->lbFactory->commitPrimaryChanges( __METHOD__ );
424                $this->lbFactory->flushPrimarySessions( __METHOD__ );
425                $this->lbFactory->flushReplicaSnapshots( __METHOD__ );
426                $this->lbFactory->closeAll();
427
428                // Copy derivative from the FS into storage at $finalDerivativeFilePath
429                $result = $file->getRepo()->quickImport(
430                    // temp file
431                    $this->getTargetEncodePath(),
432                    // storage
433                    $mediaPath,
434                    $storeOptions
435                );
436                if ( $result->isOK() && $streaming === 'hls' && $playlistTemp && $playlistPath ) {
437                    $result = $file->getRepo()->quickImport(
438                        // temp file
439                        $playlistTemp,
440                        // storage
441                        $playlistPath,
442                        $playlistStoreOptions
443                    );
444                    if ( $result->isOK() ) {
445                        WebVideoTranscode::updateStreamingManifests( $file );
446                    }
447                }
448
449                if ( !$result->isOK() ) {
450                    // no need to invalidate all pages with video.
451                    // Because all pages remain valid ( no $transcodeKey derivative )
452                    // just clear the file page ( so that the transcode table shows the error )
453                    $this->title->invalidateCache();
454                    $this->setTranscodeError( $transcodeKey, $result->getWikiText() );
455                    $status = false;
456                } else {
457                    $bitrate = round(
458                        (int)( filesize( $this->getTargetEncodePath() ) / $file->getLength() ) * 8
459                    );
460                    // Wikimedia\restoreWarnings();
461                    // Reconnect to the database...
462                    $dbw = $this->lbFactory->getPrimaryDatabase();
463                    // Update the transcode table with success time:
464                    $dbw->newUpdateQueryBuilder()
465                        ->update( 'transcode' )
466                        ->set( [
467                            'transcode_error' => '',
468                            'transcode_time_error' => null,
469                            'transcode_time_success' => $dbw->timestamp(),
470                            'transcode_final_bitrate' => $bitrate
471                        ] )
472                        ->where( [
473                            'transcode_image_name' => $this->getFile()->getName(),
474                            'transcode_key' => $transcodeKey,
475                        ] )
476                        ->caller( __METHOD__ )
477                        ->execute();
478                    // Commit to reduce contention
479                    $dbw->commit( __METHOD__, 'flush' );
480                    WebVideoTranscode::invalidatePagesWithFile( $this->title );
481                }
482            } else {
483                // Update the transcode table with failure time and error
484                $this->setTranscodeError( $transcodeKey, $status );
485                // no need to invalidate all pages with video.
486                // Because all pages remain valid ( no $transcodeKey derivative )
487                // just clear the file page ( so that the transcode table shows the error )
488                $this->title->invalidateCache();
489            }
490            // done with encoding target, clean up
491            $this->purgeTargetEncodeFile();
492
493            // Clear the webVideoTranscode cache ( so we don't keep out dated table cache around )
494            WebVideoTranscode::clearTranscodeCache( $this->title->getDBkey() );
495
496            $url = WebVideoTranscode::getTranscodedUrlForFile( $file, $transcodeKey );
497            $urls = [ $url ];
498            if ( $streaming === 'hls' ) {
499                $urls[] = "$url.m3u8";
500            }
501            $update = new CdnCacheUpdate( $urls );
502            $update->doUpdate();
503
504            if ( $status !== true ) {
505                $this->setLastError( $status );
506            }
507            return $status === true;
508        } catch ( Exception $e ) {
509            $error = "Exception: " . $e->getMessage();
510            $trace = $e->getTraceAsString();
511            $this->output( "$error\n$trace\n" );
512            $this->setTranscodeError( $transcodeKey, $error );
513            return false;
514        }
515    }
516
517    /**
518     * Gets a boxedCommand executor
519     * @param string $name The route name for the BoxedCommand
520     * @return BoxedCommand
521     */
522    private function getCommand( string $name ) {
523        $fullName = 'tmh-' . strtolower( $name );
524        return $this->commandFactory
525            ->createBoxed( 'timedmediahandler' )
526            ->disableNetwork()
527            ->firejailDefaultSeccomp()
528            ->routeName( $fullName );
529    }
530
531    /**
532     * Adds an input file from the scripts directory, sets the command to execute it
533     * @param BoxedCommand $command
534     * @param string $script
535     *
536     */
537    private function useScript( BoxedCommand $command, string $script ) {
538        $file = __DIR__ . "/../../scripts/$script";
539        if ( !is_file( $file ) ) {
540            throw new InvalidArgumentException( "File '$file' not found" );
541        }
542        $command->inputFileFromFile( "scripts/$script", $file )
543            ->params( $this->config->get( MainConfigNames::ShellboxShell ), 'scripts/' . $script );
544    }
545
546    /**
547     * Utility helper for ffmpeg mapping
548     * @param array $options
549     * @param int $passes the number of encoding passes to perform
550     * @return true|string
551     */
552    private function ffmpegEncode( $options, $passes = 0 ) {
553        if ( !is_file( $this->getSourceFilePath() ) ) {
554            return "source file is missing, " . $this->getSourceFilePath() . ". Encoding failed.";
555        }
556        // Environment variables for shellbox
557        $optsEnv = [];
558        if ( $this->remuxSource ) {
559            $sourcePath = $this->remuxSource->getPath();
560        } else {
561            $sourcePath = $this->getSourceFilePath();
562        }
563
564        $interval = 10;
565        $fps = 0;
566        // Set up all the video-related options
567        if ( isset( $options['novideo'] ) ) {
568            $optsEnv['TMH_OPTS_VIDEO'] = '-vn';
569        } else {
570            $optsEnv['TMH_OPTS_VIDEO'] = "";
571            $fps = $this->effectiveFrameRate( $options );
572            if ( isset( $options['framerate'] ) ) {
573                // $options['framerate'] is a float
574                $optsEnv['TMH_OPTS_VIDEO'] .= '-r ' . strval( $options['framerate'] );
575            } else {
576                // Note -fpsmax is not available on Wikimedia's Debian as of 2023-02-02
577                //
578                //   $cmd .= " -fpsmax " . wfEscapeShellArg( $options['fpsmax'] );
579                //   $cmd .= " -fpsmax " . self::MAX_FPS;
580                //
581                // Instead, manually check the detected framerate.
582                // Note some files report incorrectly via GetID3, and may
583                // end up actually increasing in frame rate because of this!
584                $orig = $this->frameRate();
585                if ( $this->isInterlaced() ) {
586                    $orig *= 2;
587                }
588                if ( $orig > $fps ) {
589                    $optsEnv['TMH_OPTS_VIDEO'] .= '-r ' . strval( $fps );
590                }
591            }
592
593            if ( $this->remuxSource ) {
594                $optsEnv['TMH_OPTS_VIDEO'] .= ' -vcodec copy';
595                $optsEnv['TMH_REMUX'] = "yes";
596            } else {
597                $optsEnv['TMH_REMUX'] = "no";
598                $optsEnv['TMH_OPT_VIDEOCODEC'] = $options['videoCodec'];
599                switch ( $options['videoCodec'] ) {
600                    case 'vp8':
601                    case 'vp9':
602                        $optsEnv['TMH_OPTS_VIDEO'] .= $this->ffmpegAddWebmVideoOptions( $options );
603                        if ( isset( $options['speed'] ) ) {
604                            $optsEnv['TMH_OPT_SPEED'] = (string)intval( $options['speed'] );
605                        }
606                        break;
607                    case 'h264':
608                        $optsEnv['TMH_OPTS_VIDEO'] .= $this->ffmpegAddH264VideoOptions( $options );
609                        break;
610                    case 'mpeg4':
611                        $optsEnv['TMH_OPTS_VIDEO'] .= $this->ffmpegAddMPEG4VideoOptions( $options );
612                        break;
613                    default:
614                        $optsEnv['TMH_OPTS_VIDEO'] .= $this->ffmpegAddGenericVideoOptions( $options );
615                }
616            }
617
618            // needed for 2-pass & streaming to override file type detection
619            if ( $options['videoCodec'] === 'h264' ||
620                $options['videoCodec'] === 'mpeg4' ||
621                isset( $options['streaming'] ) ) {
622                $optsEnv['TMH_OPTS_VIDEO'] .= ' -f mp4';
623            } elseif ( $options['videoCodec'] === 'vp8' ||
624                $options['videoCodec'] === 'vp9' ) {
625                $optsEnv['TMH_OPTS_VIDEO'] .= ' -f webm';
626            }
627
628            // Check for keyframeInterval
629            $keyframeInterval = round( $fps * $interval );
630            $optsEnv['TMH_OPTS_VIDEO'] .= ' -g ' . strval( $keyframeInterval );
631
632            if ( isset( $options['videoBitrate'] ) ) {
633                $base = $this->expandRate( $options['videoBitrate'] );
634                $bitrate = $this->scaleRate( $options, $base );
635                $optsEnv['TMH_OPTS_VIDEO'] .= " -b:v $bitrate";
636
637                // Estimate the output file size in KiB and bail out early
638                // if it's potentially very large. Could be a denial of
639                // service, or just a large file that probably is poorly
640                // compressed.
641                $duration = (float)$this->file->getLength();
642                $estimatedSize = round( ( $bitrate / 8 ) * $duration / 1024 );
643                $backgroundSizeLimit = $this->config->get( 'TranscodeBackgroundSizeLimit' );
644                if ( $backgroundSizeLimit > 0 && $estimatedSize > $backgroundSizeLimit ) {
645                    // This hard limit cannot be overridden by admins, except by raising the limit in config.
646                    // @todo return an error code that can be localized later
647                    return "estimated file size $estimatedSize KiB over hard limit $backgroundSizeLimit KiB";
648                }
649
650                $transcodeSoftSizeLimit = $this->config->get( 'TranscodeSoftSizeLimit' );
651                if ( $transcodeSoftSizeLimit > 0 && $estimatedSize > $transcodeSoftSizeLimit ) {
652                    // This soft limit can be overridden when a transcode is reset by hand via the web UI
653                    // or API, or requeueTranscodes.php with --manual-override option.
654                    $manualOverride = $this->params['manualOverride'] ?? false;
655                    if ( !$manualOverride ) {
656                        // @todo return an error code that can be localized later
657                        return "estimated file size $estimatedSize KiB over soft limit $transcodeSoftSizeLimit KiB";
658                    }
659                }
660
661                if ( isset( $options['minrate'] ) ) {
662                    $minrate = $this->scaleRate( $options, $options['minrate'] );
663                    $optsEnv['TMH_OPTS_VIDEO'] .= " -minrate $minrate";
664                }
665                if ( isset( $options['maxrate'] ) ) {
666                    $maxrate = $this->scaleRate( $options, $options['maxrate'] );
667                    $optsEnv['TMH_OPTS_VIDEO'] .= " -maxrate $maxrate";
668                }
669            }
670
671            if ( !$this->remuxSource ) {
672                // If necessary, add deinterlacing options
673                $optsEnv['TMH_OPTS_VIDEO'] .= $this->ffmpegAddDeinterlaceOptions( $options );
674                // Add size options:
675                $optsEnv['TMH_OPTS_VIDEO'] .= $this->ffmpegAddVideoSizeOptions( $options );
676            }
677        }
678
679        if ( !$this->config->get( 'UseFFmpeg2' ) ) {
680            // Work around https://trac.ffmpeg.org/ticket/6375 in ffmpeg 3.4/4.0
681            // Sometimes caused transcode failures saying things like:
682            // "1 frames left in the queue on closing"
683            $optsEnv['TMH_OPTS_FFMPEG2'] = '-max_muxing_queue_size 1024';
684        } else {
685            $optsEnv['TMH_OPTS_FFMPEG2'] = '';
686        }
687
688        // Audio options
689        $optsEnv['TMH_OPT_NOAUDIO'] = isset( $options['noaudio'] ) ? "yes" : "no";
690        $optsEnv['TMH_OPTS_AUDIO'] = $this->ffmpegAddAudioOptions( $options );
691
692        $streaming = $options['streaming'] ?? false;
693        $transcodeKey = $this->params[ 'transcodeKey' ];
694        $extension = substr( $transcodeKey, strrpos( $transcodeKey, '.' ) + 1 );
695
696        if ( WebVideoTranscode::isBaseMediaFormat( $extension ) ) {
697            $optsEnv['TMH_MOVFLAGS'] = '-movflags +faststart';
698        }
699
700        if ( $streaming === 'hls' ) {
701            if ( WebVideoTranscode::isBaseMediaFormat( $extension ) ) {
702                if ( !isset( $optsEnv['TMH_MOVFLAGS'] ) ) {
703                    $optsEnv['TMH_MOVFLAGS'] = '';
704                }
705                // Don't use the HLS muxer, as it'll want to manage
706                // filenames and we have to rewrite everything anyway.
707                // We'll generate an .m3u8 from the file structure after.
708
709                if ( isset( $options['novideo'] ) || isset( $options['intraframe'] ) ) {
710                    // Audio-only tracks should be fragmented around the standard interval.
711                    // Intraframe-only codecs like Motion-JPEG should also be treated this way.
712                    $optsEnv['TMH_MOVFLAGS'] .= " -movflags +empty_moov+default_base_moof";
713                    $optsEnv['TMH_MOVFLAGS'] .= " -frag_duration {$interval}000000";
714                } else {
715                    // Video keyframe interval is set to approximate the desired interval, but
716                    // they may occur whenever the encoder thinks they would be desirable such
717                    // as a visible scene change.
718                    $optsEnv['TMH_MOVFLAGS'] .= " -movflags +frag_keyframe+empty_moov+default_base_moof";
719                }
720
721                // This is needed for opus on debian bullseye
722                $optsEnv['TMH_MOVFLAGS'] .= " -strict experimental";
723            } elseif ( $extension === 'mp3' ) {
724                // No additional options needed at present.
725            } else {
726                return "Invalid HLS track media type, expected .mp4, .m4v, .m4a, .mov, .3gp, or .mp3";
727            }
728        }
729
730        $cmd = $this->getCommand( 'ffmpegencode' );
731        $this->useScript( $cmd, 'ffmpeg-encode.sh' );
732        // set up options that don't need mangling
733
734        $backgroundMemoryLimit = $this->config->get( 'TranscodeBackgroundMemoryLimit' ) * 1024;
735        $wallTimeLimit = (int)$this->config->get( 'TranscodeBackgroundTimeLimit' );
736        $cpuTimeLimit = (int)$this->config->get( 'FFmpegThreads' ) * $wallTimeLimit;
737        // cast to string to make phan happy
738        $ffmpegLocation = (string)$this->config->get( 'FFmpegLocation' );
739        // Create an output file name with the correct extension
740        $target = $this->getTargetEncodePath();
741        $outputFile = 'transcoded.' . pathinfo( $target, PATHINFO_EXTENSION );
742        // Execute the conversion
743        $cmd->outputFileToFile( $outputFile, $this->getTargetEncodePath() )
744            ->inputFileFromFile( 'original.video', $sourcePath )
745            ->includeStderr()
746            ->environment( [
747                'TMH_OUTPUT_FILE'      => $outputFile,
748                'TMH_FFMPEG_PASSES'    => strval( $passes ),
749                'TMH_FFMPEG_PATH'      => $ffmpegLocation,
750            ] + $optsEnv );
751        $result = $cmd->memoryLimit( $backgroundMemoryLimit )
752            ->wallTimeLimit( $wallTimeLimit )
753            ->cpuTimeLimit( $cpuTimeLimit )
754            ->execute();
755
756        // and pass it to this->output()
757        if ( $result->getExitCode() != 0 ) {
758            $host = wfHostname();
759            return 'ffmpeg-encode.sh' .
760                "\n\nExitcode: " . $result->getExitCode() .
761                "\nMemory: $backgroundMemoryLimit" .
762                "\nHost: $host\n\n"
763                . $result->getStdout();
764        }
765
766        return true;
767    }
768
769    // Bitrates and keyframe distances are specified for this
770    // common frame rate (30), and scaled accordingly to accomodate
771    // higher frame rates.
772    private const DEFAULT_FPS = 30;
773    private const MAX_FPS = 60;
774    private const MIN_FPS = 24;
775
776    /**
777     * Scale a bitrate or frame count according to the frame rate
778     * of the file versus the default frame rate. This is not a
779     * straight linear multiplication; it's biased to reduce impact
780     * beyond 30 fps, to 1.5x base at 60 fps.
781     *
782     * @param array $options
783     * @param string|int $rate
784     * @return int
785     */
786    private function scaleRate( $options, $rate ) {
787        $fps = $this->effectiveFrameRate( $options );
788        $base = $this->expandRate( $rate );
789
790        $lofps = min( $fps, self::DEFAULT_FPS );
791        $hifps = $fps - $lofps;
792        $scaled = $base * $lofps / self::DEFAULT_FPS +
793            0.5 * $base * $hifps / self::DEFAULT_FPS;
794        return (int)$scaled;
795    }
796
797    /**
798     * Expand a bitrate that may have a k/m/g suffix
799     *
800     * @param string|int $rate
801     * @return int
802     */
803    private function expandRate( $rate ) {
804        return WebVideoTranscode::expandRate( $rate );
805    }
806
807    /**
808     * Grab the frame rate from the file, bounded by
809     * format-specific or generic limitations.
810     * Suitable for scaling linear parameters like the
811     * target bit rate.
812     *
813     * @param array $options
814     * @return float
815     */
816    private function effectiveFrameRate( $options ) {
817        if ( isset( $options['framerate'] ) ) {
818            // fixed framerate
819            $fps = $this->fractionToFloat( $options['framerate'] );
820        } else {
821            // @todo getid3 gets this wrong on some WebM input files
822            // consider reading from ffmpeg or ffprobe...
823            // We cap it, but this can cause a 29.97fps file to use
824            // the 60fps bitrate. Worst case it's a bloated file.
825            $fps = $this->frameRate();
826        }
827        if ( $this->shouldFrameDouble( $options ) ) {
828            $fps *= 2;
829        }
830
831        if ( $fps < self::MIN_FPS ) {
832            return self::MIN_FPS;
833        }
834        if ( isset( $options['fpsmax'] ) ) {
835            $max = $this->fractionToFloat( $options['fpsmax'] );
836        } else {
837            $max = self::MAX_FPS;
838        }
839        if ( $fps > $max ) {
840            return $max;
841        }
842        return $fps;
843    }
844
845    /**
846     * @param string $str
847     * @return float
848     */
849    private function fractionToFloat( $str ) {
850        $fraction = explode( '/', $str, 2 );
851        if ( count( $fraction ) > 1 ) {
852            return (float)$fraction[0] / (float)$fraction[1];
853        }
854        return (float)$str;
855    }
856
857    /**
858     * Return the actual frame rate of the file, or the default
859     * if can't retrieve it.
860     *
861     * @return float
862     */
863    private function frameRate() {
864        $file = $this->getFile();
865        $handler = $file->getHandler();
866        if ( $handler instanceof TimedMediaHandler ) {
867            $fps = $handler->getFrameRate( $file );
868            if ( $fps ) {
869                return $fps;
870            }
871        }
872        return self::DEFAULT_FPS;
873    }
874
875    /**
876     * Adds ffmpeg shell options for h264
877     *
878     * @param array $options
879     * @return string
880     */
881    public function ffmpegAddH264VideoOptions( $options ) {
882        // Set the codec:
883        $cmd = " -threads " . (int)$this->config->get( 'FFmpegThreads' ) . " -vcodec libx264";
884        $cmd .= ' -pix_fmt yuv420p';
885        $cmd .= ' -rc-lookahead 16';
886
887        return $cmd;
888    }
889
890    /**
891     * Adds ffmpeg shell options for h264
892     *
893     * @param array $options
894     * @return string
895     */
896    public function ffmpegAddMPEG4VideoOptions( $options ) {
897        $cmd = " -vcodec mpeg4";
898
899        // Force to 4:2:0 chroma subsampling.
900        $cmd .= ' -pix_fmt yuv420p';
901
902        return $cmd;
903    }
904
905    /**
906     * @param array $options
907     * @return string
908     */
909    private function ffmpegAddGenericVideoOptions( $options ) {
910        $cmd = ' -vcodec ' . $options['videoCodec'];
911
912        // Force to 4:2:0 chroma subsampling.
913        $cmd .= ' -pix_fmt yuv420p';
914
915        return $cmd;
916    }
917
918    /**
919     * @param array $options
920     *
921     * @return string
922     */
923    private function ffmpegAddVideoSizeOptions( $options ) {
924        $cmd = '';
925        // Get a local pointer to the file object
926        $file = $this->getFile();
927
928        // Check for aspect ratio
929        $aspectRatio = $options['aspect'] ?? $file->getWidth() . ':' . $file->getHeight();
930        if ( ( isset( $options['width'] ) && $options['width'] > 0 )
931            &&
932            ( isset( $options['height'] ) && $options['height'] > 0 )
933        ) {
934            $cmd .= ' -s ' . (int)$options['width'] . 'x' . (int)$options['height'];
935            $cmd .= ' -aspect ' . $aspectRatio;
936        } elseif ( isset( $options['maxSize'] ) ) {
937            // Get size transform ( if maxSize is > file, file size is used:
938
939            [ $width, $height ] = WebVideoTranscode::getMaxSizeTransform( $file, $options['maxSize'] );
940            $cmd .= ' -s ' . (int)$width . 'x' . (int)$height;
941        }
942        return $cmd;
943    }
944
945    /**
946     * Adds ffmpeg shell options for webm
947     *
948     * @param array $options
949     * @return string
950     */
951    private function ffmpegAddWebmVideoOptions( $options ) {
952        $cmd = ' -threads ' . (int)$this->config->get( 'FFmpegThreads' );
953        if ( $this->config->get( 'FFmpegVP9RowMT' ) && $options['videoCodec'] === 'vp9' ) {
954            // Macroblock row multithreading allows using more CPU cores
955            // for VP9 encoding. This is not yet the default, and the option
956            // will fail on a version of ffmpeg that is too old or is built
957            // against a libvpx that is too old, so we have to enable it
958            // conditionally for now.
959            //
960            // Requires libvpx 1.7 and ffmpeg 3.3.
961            $cmd .= ' -row-mt 1';
962        }
963
964        // Force to 4:2:0 chroma subsampling. Others are supported in Theora
965        // and in VP9 profile 1, but Chrome and Edge don't grok them.
966        $cmd .= ' -pix_fmt yuv420p';
967
968        // libvpx-specific constant quality or constrained quality
969        // note the range is different between VP8 and VP9
970        // Also an integer.
971        if ( isset( $options['crf'] ) ) {
972            $cmd .= " -crf " . (string)intval( $options['crf'] );
973        }
974
975        // Set the codec:
976        if ( $options['videoCodec'] === 'vp9' ) {
977            $cmd .= " -vcodec libvpx-vp9";
978            if ( isset( $options['tileColumns'] ) ) {
979                $cmd .= ' -tile-columns ' . (string)intval( $options['tileColumns'] );
980            }
981        } else {
982            $cmd .= " -vcodec libvpx";
983            if ( isset( $options['slices'] ) ) {
984                $cmd .= ' -slices ' . (string)intval( $options['slices'] );
985            }
986        }
987
988        $cmd .= ' -quality good';
989        return $cmd;
990    }
991
992    /**
993     * @return bool
994     */
995    private function isInterlaced() {
996        $handler = $this->file->getHandler();
997        return ( $handler instanceof TimedMediaHandler && $handler->isInterlaced( $this->file ) );
998    }
999
1000    /**
1001     * Whether to produce one frame per field when deinterlacing.
1002     * This will double the output frame rate.
1003     *
1004     * @param array $options
1005     * @return bool
1006     */
1007    private function shouldFrameDouble( $options ) {
1008        if ( $this->isInterlaced() ) {
1009            if ( isset( $options['framerate'] ) ) {
1010                // Fixed framerate, don't mess with it.
1011                return false;
1012            }
1013            if ( isset( $options['fpsmax'] ) && $this->fractionToFloat( $options['fpsmax'] ) < 60 ) {
1014                return false;
1015            }
1016            return true;
1017        }
1018        return false;
1019    }
1020
1021    /**
1022     * @param array $options
1023     * @return string
1024     */
1025    private function ffmpegAddDeinterlaceOptions( $options ) {
1026        if ( $this->isInterlaced() ) {
1027            if ( $this->shouldFrameDouble( $options ) ) {
1028                // Send one frame per field for full motion smoothness.
1029                return ' -vf yadif=1';
1030            }
1031            // Send one frame per field
1032            return ' -vf yadif=0';
1033        }
1034        return '';
1035    }
1036
1037    /**
1038     * @param array $options
1039     * @return string
1040     */
1041    private function ffmpegAddAudioOptions( $options ) {
1042        $cmd = '';
1043        if ( isset( $options['audioQuality'] ) ) {
1044            $cmd .= " -aq " . (string)intval( $options['audioQuality'] );
1045        }
1046        if ( isset( $options['audioBitrate'] ) ) {
1047            $cmd .= " -ab " . $this->expandRate( $options['audioBitrate'] );
1048        }
1049        if ( isset( $options['samplerate'] ) ) {
1050            $cmd .= " -ar " . (string)intval( $options['samplerate'] );
1051        }
1052        if ( isset( $options['channels'] ) ) {
1053            $cmd .= " -ac " . (string)intval( $options['channels'] );
1054        }
1055
1056        if ( isset( $options['audioCodec'] ) ) {
1057            $encoders = [
1058                'vorbis'    => 'libvorbis',
1059                'opus'        => 'libopus',
1060                'mp3'        => 'libmp3lame',
1061            ];
1062            $codec = $encoders[$options['audioCodec']] ?? $options['audioCodec'];
1063            $cmd .= " -acodec " . $codec;
1064            if ( $codec === 'aac' ) {
1065                // the aac encoder is currently "experimental" in libav 9? :P
1066                $cmd .= ' -strict experimental';
1067            }
1068        } else {
1069            // if no audio codec set use vorbis :
1070            $cmd .= " -acodec libvorbis ";
1071        }
1072        return $cmd;
1073    }
1074
1075    /**
1076     * Utility helper for midi to an audio format conversion
1077     * @param array $options
1078     * @return true|string
1079     */
1080    private function midiToAudioEncode( $options ) {
1081        if ( !is_file( $this->getSourceFilePath() ) ) {
1082            return "source file is missing, " . $this->getSourceFilePath() . ". Encoding failed.";
1083        }
1084        $cmd = $this->getCommand( 'miditoaudio' );
1085        $this->useScript( $cmd, 'midi-encode.sh' );
1086        // set up options
1087        $optsEnv = [];
1088        $optToEnv = [
1089            'audioQuality' => 'QUALITY',
1090            'audioBitrate' => 'BITRATE',
1091            'samplerate' => 'SAMPLERATE',
1092            'channels' => 'CHANNELS'
1093        ];
1094        foreach ( $optToEnv as $opt => $label ) {
1095            # Here we're passing the argument directly to the shell, so we want it escaped
1096            if ( isset( $options[$opt] ) ) {
1097                $optsEnv['TMH_OPT_' . $label] = Shell::escape( $options[$opt] );
1098            }
1099        }
1100        $outputFile = 'output_audio.' . pathinfo( $this->getTargetEncodePath(), PATHINFO_EXTENSION );
1101        // Execute the conversion
1102        $backgroundMemoryLimit = $this->config->get( 'TranscodeBackgroundMemoryLimit' ) * 1024;
1103        $wallTimeLimit = (int)$this->config->get( 'TranscodeBackgroundTimeLimit' );
1104        $cpuTimeLimit = (int)$this->config->get( 'FFmpegThreads' ) * $wallTimeLimit;
1105        $cmd->outputFileToFile( $outputFile, $this->getTargetEncodePath() )
1106            ->inputFileFromFile( 'input.mid', $this->getSourceFilePath() )
1107            ->includeStderr()
1108            ->environment( [
1109                'TMH_FLUIDSYNTH_PATH' => $this->config->get( 'TmhFluidsynthLocation' ),
1110                'TMH_FFMPEG_PATH'     => $this->config->get( 'FFmpegLocation' ),
1111                'TMH_SOUNDFONT_PATH'  => $this->config->get( 'TmhSoundfontLocation' ),
1112                'TMH_AUDIO_CODEC'     => $options['audioCodec'],
1113                'TMH_OUTPUT_FILE'     => $outputFile,
1114            ] + $optToEnv );
1115        $result = $cmd->memoryLimit( $backgroundMemoryLimit )
1116                    ->wallTimeLimit( $wallTimeLimit )
1117                    ->cpuTimeLimit( $cpuTimeLimit )
1118                    ->execute();
1119
1120        if ( $result->getExitCode() != 0 ) {
1121            return 'midi-encode.sh' .
1122                "\n\nExitcode: " . $result->getExitCode() . "\nMemory: $backgroundMemoryLimit\n\n"
1123                . $result->getStdout();
1124        }
1125        return true;
1126    }
1127}