Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.94% covered (danger)
17.94%
40 / 223
20.00% covered (danger)
20.00%
4 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimedMediaTransformOutput
17.94% covered (danger)
17.94%
40 / 223
20.00% covered (danger)
20.00%
4 / 20
5189.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 getTextHandler
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getUrl
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 getPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPlayerHeight
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPlayerWidth
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 getTagName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 toHtml
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 useImagePopUp
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 htmlTagSet
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getPopupPlayerSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPopupPlayerWidth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sortMediaByBandwidth
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
16.41
 getHtmlMediaTagOutput
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
182
 getPoster
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getMediaAttr
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
306
 getMediaSources
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getTemporalUrlHash
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 resetSerialForTest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAPIData
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\TimedMediaHandler;
4
5use LogicException;
6use MediaTransformOutput;
7use MediaWiki\Html\Html;
8use MediaWiki\MainConfigNames;
9use MediaWiki\MediaWikiServices;
10use MediaWiki\TimedMediaHandler\Handlers\TextHandler\TextHandler;
11use MediaWiki\TimedMediaHandler\WebVideoTranscode\WebVideoTranscode;
12
13class TimedMediaTransformOutput extends MediaTransformOutput {
14    /** @var int */
15    protected static $serial = 0;
16
17    // Video file sources object lazy init in getSources()
18    // TODO these vars should probably be private
19    /** @var array[]|false|null */
20    public $sources;
21
22    /** @var string|false|null */
23    public $hashTime;
24
25    /** @var TextHandler|null */
26    public $textHandler;
27
28    /** @var string|false|null */
29    public $disablecontrols;
30
31    /** @var mixed */
32    public $dstPath;
33
34    /** @var string|false */
35    public $thumbUrl;
36
37    /** @var string|false */
38    public $start;
39
40    /** @var string|false */
41    public $end;
42
43    /** @var float|false */
44    public $length;
45
46    /** @var float|false */
47    public $offset;
48
49    /** @var bool */
50    public $isVideo;
51
52    /** @var bool */
53    public $fillwindow;
54
55    /** @var string|false */
56    protected $playerClass;
57
58    /** @var bool */
59    protected $inline;
60
61    /** @var bool */
62    protected $muted;
63
64    /** @var bool */
65    protected $loop;
66
67    // The prefix for player ids
68    private const PLAYER_ID_PREFIX = 'mwe_player_';
69
70    /**
71     * @param array $conf
72     */
73    public function __construct( $conf ) {
74        $this->file = $conf['file'] ?? false;
75        $this->dstPath = $conf['dstPath'] ?? false;
76        $this->sources = $conf['sources'] ?? false;
77        $this->thumbUrl = $conf['thumbUrl'] ?? false;
78        $this->start = $conf['start'] ?? false;
79        $this->end = $conf['end'] ?? false;
80        $this->width = $conf['width'] ?? 0;
81        $this->height = $conf['height'] ?? 0;
82        $this->length = $conf['length'] ?? false;
83        $this->offset = $conf['offset'] ?? false;
84        $this->isVideo = $conf['isVideo'] ?? false;
85        $this->path = $conf['path'] ?? false;
86        $this->fillwindow = $conf['fillwindow'] ?? false;
87        $this->disablecontrols = $conf['disablecontrols'] ?? false;
88        $this->playerClass = $conf['playerClass'] ?? false;
89        $this->inline = $conf['inline'] ?? false;
90        $this->muted = $conf['muted'] ?? false;
91        $this->loop = $conf['loop'] ?? false;
92    }
93
94    /**
95     * @return TextHandler
96     */
97    private function getTextHandler() {
98        if ( !$this->textHandler ) {
99            // Init an associated textHandler
100            $this->textHandler = new TextHandler( $this->file, [ TimedTextPage::VTT_SUBTITLE_FORMAT ] );
101        }
102        return $this->textHandler;
103    }
104
105    /**
106     * Get the media transform thumbnail
107     * @param false|array $sizeOverride
108     * @return string
109     */
110    public function getUrl( $sizeOverride = false ) {
111        $config = MediaWikiServices::getInstance()->getMainConfig();
112        $resourceBasePath = $config->get( MainConfigNames::ResourceBasePath );
113        $url = "$resourceBasePath/resources/assets/file-type-icons/fileicon-ogg.png";
114
115        if ( $this->isVideo ) {
116            if ( $this->thumbUrl ) {
117                $url = $this->thumbUrl;
118            }
119
120            // Update the $posterUrl to $sizeOverride ( if not an old file )
121            if ( !$this->file->isOld() && $sizeOverride &&
122                $sizeOverride[0] && (int)$sizeOverride[0] !== (int)$this->width ) {
123                $apiUrl = $this->getPoster( $sizeOverride[0] );
124                if ( $apiUrl ) {
125                    $url = $apiUrl;
126                }
127            }
128        }
129        return $url;
130    }
131
132    /**
133     * TODO get the local path
134     * @return mixed
135     */
136    public function getPath() {
137        return $this->dstPath;
138    }
139
140    /**
141     * @return int
142     */
143    public function getPlayerHeight() {
144        // Check if "video" tag output:
145        if ( $this->isVideo ) {
146            return (int)$this->height;
147        }
148        // Give sound files a height of 23px
149        return 23;
150    }
151
152    /**
153     * @return int
154     */
155    public function getPlayerWidth() {
156        // Check if "video" tag output:
157        if ( $this->isVideo ) {
158            return (int)$this->width;
159        }
160
161        // Give sound files a width of 300px ( if unsized )
162        if ( !$this->width ) {
163            return 300;
164        }
165        // else give the target size, but at least 35px
166        return max( 35, (int)$this->width );
167    }
168
169    /**
170     * @return string
171     */
172    public function getTagName() {
173        return ( $this->isVideo ) ? 'video' : 'audio';
174    }
175
176    /**
177     * @param array $options
178     * @return string
179     */
180    public function toHtml( $options = [] ) {
181        $classes = $options['img-class'] ?? '';
182
183        $oldHeight = $this->height;
184        $oldWidth = $this->width;
185        if ( isset( $options['override-height'] ) ) {
186            $this->height = $options['override-height'];
187        }
188        if ( isset( $options['override-width'] ) ) {
189            $this->width = $options['override-width'];
190        }
191
192        $mediaAttr = $this->getMediaAttr( false, false, $classes );
193
194        // XXX: This might be redundant with data-mwtitle
195        $services = MediaWikiServices::getInstance();
196        $enableLegacyMediaDOM = $services->getMainConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM );
197        if ( !$enableLegacyMediaDOM && isset( $options['magnify-resource'] ) ) {
198            $mediaAttr['resource'] = $options['magnify-resource'];
199        }
200
201        $res = $this->getHtmlMediaTagOutput( $mediaAttr );
202        $this->width = $oldWidth;
203        $this->height = $oldHeight;
204        return $this->linkWrap( [], $res );
205    }
206
207    /**
208     * Helper to determine if to use pop up dialog for videos
209     *
210     * @return bool
211     */
212    private function useImagePopUp() {
213        $config = MediaWikiServices::getInstance()->getMainConfig();
214        // Check if the video is too small to play inline ( instead do a pop-up dialog )
215        // If we're filling the window (e.g. during an iframe embed) one probably doesn't want the pop-up.
216        // Also, the pop-up is broken in that case.
217        return $this->isVideo
218            && !$this->fillwindow
219            && $this->getPlayerWidth() < $config->get( 'MinimumVideoPlayerSize' )
220            // Do not do pop-up if it's going to be the same size as inline player anyways
221            && $this->getPlayerWidth() < $this->getPopupPlayerWidth();
222    }
223
224    /**
225     * XXX migrate this to the mediawiki Html class as 'tagSet' helper function
226     * @param string $tagName
227     * @param array $tagSet
228     * @return string
229     */
230    private static function htmlTagSet( $tagName, $tagSet ) {
231        if ( !$tagSet ) {
232            return '';
233        }
234        $s = '';
235        foreach ( $tagSet as $attr ) {
236            $s .= Html::element( $tagName, $attr );
237        }
238        return $s;
239    }
240
241    /**
242     * Get target popup player size
243     * @return int[]
244     */
245    private function getPopupPlayerSize() {
246        // Get the max width from the enabled transcode settings:
247        $maxImageSize = WebVideoTranscode::getMaxSizeWebStream();
248        return WebVideoTranscode::getMaxSizeTransform( $this->file, (string)$maxImageSize );
249    }
250
251    /**
252     * Helper function to get pop up width
253     *
254     * Silly function because array index operations aren't allowed
255     * on function calls before php 5.4
256     * @return int
257     */
258    private function getPopupPlayerWidth() {
259        [ $popUpWidth ] = $this->getPopupPlayerSize();
260        return $popUpWidth;
261    }
262
263    /**
264     * Sort media by bandwidth, but with things not wide enough at end
265     *
266     * The list should be in preferred source order, so we want the file
267     * with the lowest bitrate (to save bandwidth) first, but we also want
268     * appropriate resolution files before the 160p transcodes.
269     * @param array $a
270     * @param array $b
271     * @return int
272     */
273    private function sortMediaByBandwidth( $a, $b ) {
274        $width = $this->getPlayerWidth();
275        $maxWidth = $this->getPopupPlayerWidth();
276        if ( $this->useImagePopUp() || $width > $maxWidth ) {
277            // If it's a pop-up player than we should use the pop-up player size.
278            // If it's a normal player, but has a bigger width than the pop-up
279            // player, then we use the pop-up players width as the target width
280            // as that is equivalent to the max transcode size. Otherwise, this
281            // will suggest the original file as the best source, which seems like
282            // a potentially bad idea, as it could be anything size wise.
283            $width = $maxWidth;
284        }
285
286        if ( $a['width'] < $width && $b['width'] >= $width ) {
287            // $a is not wide enough but $b is, so we
288            // consider $a > $b as we want $b before $a
289            return 1;
290        }
291        if ( $a['width'] >= $width && $b['width'] < $width ) {
292            // $b not wide enough, so $a must be preferred.
293            return -1;
294        }
295        if ( $a['width'] < $width && $b['width'] < $width && $a['width'] != $b['width'] ) {
296            // both are too small. Go with the one closer to the target width
297            return ( $a['width'] < $b['width'] ) ? -1 : 1;
298        }
299        // Both are big enough, or both equally too small. Go with the one
300        // that has a lower bit-rate (as it will be faster to download).
301        if ( isset( $a['bandwidth'] ) && isset( $b['bandwidth'] ) ) {
302            return ( $a['bandwidth'] < $b['bandwidth'] ) ? -1 : 1;
303        }
304
305        // We have no firm basis for a comparison, so consider them equal.
306        return 0;
307    }
308
309    /**
310     * Call mediaWiki xml helper class to build media tag output from
311     * supplied arrays.
312     *
313     * This function is also called by the Score extension, in which case
314     * there is no connection to a file object.
315     *
316     * @param array $mediaAttr The result of calling getMediaAttr()
317     * @return string HTML
318     */
319    private function getHtmlMediaTagOutput( array $mediaAttr ) {
320        // Try to get the first source src attribute ( usually this should be the source file )
321        $mediaSources = $this->getMediaSources();
322        // do not rely on auto-resetting of arrays under HHVM
323        reset( $mediaSources );
324        $firstSource = current( $mediaSources );
325
326        if ( $firstSource === false || !$firstSource['src'] ) {
327            // XXX media handlers don't seem to work with exceptions..
328            return 'Error missing media source';
329        }
330
331        // Sort sources by bandwidth least to greatest (so that the default selection on resource
332        // constrained browsers (without js?) go with minimal source.)
333        usort( $mediaSources, [ $this, 'sortMediaByBandwidth' ] );
334
335        // We prefix some source attributes with data- to pass along to the javascript player
336        $prefixedSourceAttr = [
337            'width',
338            'height',
339            'transcodekey',
340        ];
341        $removeSourceAttr = [
342            'bandwidth',
343            'framerate',
344            'disablecontrols',
345            'title',
346            'shorttitle',
347            'label',
348            'res',
349        ];
350        foreach ( $mediaSources as &$source ) {
351            foreach ( $source as $attr => $val ) {
352                if ( in_array( $attr, $removeSourceAttr, true ) ) {
353                    unset( $source[ $attr ] );
354                }
355                if ( in_array( $attr, $prefixedSourceAttr, true ) ) {
356                    $source[ 'data-' . $attr ] = $val;
357                    unset( $source[ $attr ] );
358                }
359            }
360        }
361        unset( $source );
362        $mediaTracks = $this->file ? $this->getTextHandler()->getTracks() : [];
363        foreach ( $mediaTracks as &$track ) {
364            foreach ( $track as $attr => $val ) {
365                if ( $attr === 'title' || $attr === 'provider' ) {
366                    $track[ 'data-mw' . $attr ] = $val;
367                    unset( $track[ $attr ] );
368                } elseif ( $attr === 'dir' ) {
369                    $track[ 'data-' . $attr ] = $val;
370                    unset( $track[ $attr ] );
371                }
372            }
373        }
374        unset( $track );
375
376        // Build the video tag output:
377        return Html::rawElement( $this->getTagName(), $mediaAttr,
378            // The set of media sources:
379            self::htmlTagSet( 'source', $mediaSources ) .
380
381            // Timed text:
382            self::htmlTagSet( 'track', $mediaTracks )
383        );
384    }
385
386    /**
387     * Get poster.
388     * @param int $width width of poster. Should not equal $this->width.
389     * @return string|false url for poster or false
390     */
391    private function getPoster( $width ) {
392        if ( (int)$width === (int)$this->width ) {
393            // Prevent potential loop
394            throw new LogicException( "Asked for poster in current size. Potential loop." );
395        }
396        $params = [ "width" => (int)$width ];
397        $mto = $this->file->transform( $params );
398        if ( $mto ) {
399            return $mto->getUrl();
400        }
401
402        return false;
403    }
404
405    /**
406     * Get the media attributes
407     * @param array|false $sizeOverride Array of width and height
408     * @param bool $autoPlay
409     * @param string $classes
410     * @return array
411     */
412    private function getMediaAttr(
413        $sizeOverride = false, $autoPlay = false, string $classes = ''
414    ): array {
415        // Make sure we have pure floats values and round them up to whole seconds
416        $length = ceil( (float)$this->length );
417
418        $width = $sizeOverride ? $sizeOverride[0] : $this->getPlayerWidth();
419        $height = $sizeOverride ? $sizeOverride[1] : $this->getPlayerHeight();
420
421        $id = self::$serial;
422        self::$serial++;
423        $mediaAttr = [
424            'id' => self::PLAYER_ID_PREFIX . $id,
425            // Get the correct size:
426            'poster' => $this->getUrl( $sizeOverride ),
427
428            // Note we set controls to true ( for no-js players )
429            // When ext.tmh.player.element.js runs it replaces the native player controls
430            'controls' => 'true',
431
432            // Since we will reload the item with javascript,
433            // tell browser to not load the video before
434            'preload' => 'none',
435        ];
436
437        if ( $autoPlay === true ) {
438            $mediaAttr['autoplay'] = 'true';
439        }
440
441        if ( !$this->isVideo ) {
442            // audio element doesn't have poster attribute
443            unset( $mediaAttr[ 'poster' ] );
444        }
445
446        if ( $this->muted ) {
447            $mediaAttr['muted'] = 'true';
448        }
449
450        if ( $this->loop ) {
451            $mediaAttr['loop'] = 'true';
452        }
453
454        // Note: do not add 'video-js' class before the runtime transform!
455        $mediaAttr['class'] = '';
456        $mediaAttr['width'] = (int)$width;
457        if ( $this->isVideo ) {
458            $mediaAttr['height'] = (int)$height;
459        } else {
460            $mediaAttr['style'] = "width:{$width}px;";
461            unset( $mediaAttr['height'] );
462        }
463        if ( $this->fillwindow ) {
464            $mediaAttr[ 'data-player' ] = 'fillwindow';
465        }
466        if ( $this->inline ) {
467            $mediaAttr['class'] .= ' mw-tmh-inline';
468            $mediaAttr['playsinline'] = '';
469            $mediaAttr['preload'] = 'auto';
470        }
471
472        // Used by Score extension and to disable specific controls from wikicode
473        if ( $this->disablecontrols ) {
474            $mediaAttr[ 'data-disablecontrols' ] = $this->disablecontrols;
475        }
476
477        // Additional class-name provided by Transform caller
478        if ( $this->playerClass ) {
479            $mediaAttr[ 'class' ] .= ' ' . $this->playerClass;
480        }
481
482        if ( $classes !== '' ) {
483            $mediaAttr[ 'class' ] .= ' ' . $classes;
484        }
485
486        if ( $length ) {
487            $mediaAttr[ 'data-durationhint' ] = $length;
488        }
489
490        if ( $this->file ) {
491            // Add api provider:
492            if ( $this->file->isLocal() ) {
493                $apiProviderName = 'local';
494            } else {
495                // Set the api provider name to "wikimediacommons" for shared ( instant commons convention )
496                // (provider names should have identified the provider instead of the provider type "shared")
497                $apiProviderName = $this->file->getRepoName();
498                if ( $apiProviderName === 'shared' ) {
499                    $apiProviderName = 'wikimediacommons';
500                }
501            }
502            // Custom data-attributes
503            $mediaAttr += [
504                'data-mwtitle' => $this->file->getTitle()->getDBkey(),
505                // XXX Note: will probably migrate mwprovider to an escaped api url.
506                'data-mwprovider' => $apiProviderName,
507            ];
508        }
509
510        return $mediaAttr;
511    }
512
513    /**
514     * @return array
515     */
516    private function getMediaSources() {
517        if ( !$this->sources ) {
518            // Generate transcode jobs ( and get sources that are already transcoded)
519            // At a minimum this should return the source video file.
520            $this->sources = WebVideoTranscode::getSources( $this->file );
521            // Check if we have "start or end" times and append the temporal url fragment hash
522            foreach ( $this->sources as &$source ) {
523                $source['src'] .= $this->getTemporalUrlHash();
524            }
525        }
526        return $this->sources;
527    }
528
529    /**
530     * @return string
531     */
532    private function getTemporalUrlHash() {
533        if ( $this->hashTime ) {
534            return $this->hashTime;
535        }
536        $hash = '';
537        if ( $this->start ) {
538            $startSec = TimedMediaHandler::parseTimeString( $this->start );
539            if ( $startSec !== false ) {
540                $hash .= '#t=' . TimedMediaHandler::seconds2npt( $startSec );
541            }
542        }
543        if ( $this->end ) {
544            if ( $hash === '' ) {
545                $hash .= '#t=0';
546            }
547            $endSec = TimedMediaHandler::parseTimeString( $this->end );
548            if ( $endSec !== false ) {
549                $hash .= ',' . TimedMediaHandler::seconds2npt( $endSec );
550            }
551        }
552        $this->hashTime = $hash;
553        return $this->hashTime;
554    }
555
556    public static function resetSerialForTest() {
557        self::$serial = 1;
558    }
559
560    /**
561     * @param array|null $options An optional array of strings to tweak
562     *   the values returned.  Currently valid keys are `"fullurl"`, which
563     *   calls `wfExpandUrl(..., PROTO_CURRENT)` on all URLs returned, and
564     *   `"withhash"`, which ensures that returned URLs have the temporal
565     *   url hash appended (as `getMediaSources()` does).
566     * @return array
567     */
568    public function getAPIData( ?array $options = null ) {
569        $options ??= [ 'fullurl' ];
570
571        $timedtext = $this->getTextHandler()->getTracks();
572        if ( in_array( 'fullurl', $options, true ) ) {
573            foreach ( $timedtext as &$track ) {
574                $track['src'] = wfExpandUrl( $track['src'], PROTO_CURRENT );
575            }
576            unset( $track );
577        }
578
579        $derivatives = WebVideoTranscode::getSources( $this->file, $options );
580        if ( in_array( 'withhash', $options, true ) ) {
581            // Check if we have "start or end" times and append the temporal url fragment hash
582            foreach ( $derivatives as &$source ) {
583                $source['src'] .= $this->getTemporalUrlHash();
584            }
585            unset( $source );
586        }
587
588        return [
589            'derivatives' => $derivatives,
590            'timedtext' => $timedtext,
591        ];
592    }
593}