Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
22.83% covered (danger)
22.83%
42 / 184
8.70% covered (danger)
8.70%
2 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimedMediaHandler
22.83% covered (danger)
22.83%
42 / 184
8.70% covered (danger)
8.70%
2 / 23
3729.76
0.00% covered (danger)
0.00%
0 / 1
 getImageSize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamMap
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 validateParam
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
110
 makeParamString
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 parseParamString
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 normaliseParams
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
462
 parserTransformHook
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 parseTimeString
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 seconds2npt
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 unpackMetadata
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isMetadataValid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getThumbType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAudio
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 hasVideo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasAudio
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAudioChannels
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doTransform
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
132
 mustRender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOffset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLength
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDimensionsString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFramerate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isInterlaced
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\TimedMediaHandler;
4
5use File;
6use MediaHandler;
7use MediaTransformError;
8use MediaTransformOutput;
9use Parser;
10use TransformParameterError;
11
12class TimedMediaHandler extends MediaHandler {
13
14    /**
15     * Get an image size array like that returned by getimagesize(), or false if it
16     * can't be determined.
17     * @param File $file
18     * @param string $path
19     * @param string|false $metadata
20     * @return array|false
21     */
22    public function getImageSize( $file, $path, $metadata = false ) {
23        /* override by handler */
24        return false;
25    }
26
27    /**
28     * Get the list of supported wikitext embed params
29     * @return array
30     */
31    public function getParamMap() {
32        return [
33            'img_width' => 'width',
34            'timedmedia_thumbtime' => 'thumbtime',
35            'timedmedia_starttime' => 'start',
36            'timedmedia_endtime' => 'end',
37            'timedmedia_disablecontrols' => 'disablecontrols',
38            'timedmedia_loop' => 'loop',
39            'timedmedia_muted' => 'muted',
40        ];
41    }
42
43    /**
44     * Validate a embed file parameters
45     *
46     * @param string $name Name of the param
47     * @param mixed $value Value to validated
48     * @return bool
49     */
50    public function validateParam( $name, $value ) {
51        if ( $name === 'thumbtime' || $name === 'start' || $name === 'end' ) {
52            if ( self::parseTimeString( $value ) === false ) {
53                return false;
54            }
55        } elseif ( $name === 'disablecontrols' ) {
56            $values = explode( ',', $value );
57            foreach ( $values as $v ) {
58                if ( !in_array( $v, [ 'options', 'timedText', 'fullscreen' ] ) ) {
59                    return false;
60                }
61            }
62        } elseif ( $name === 'width' || $name === 'height' ) {
63            return $value > 0;
64        }
65        return true;
66    }
67
68    /**
69     * TODO we should really have "$file" available here to validate the param string
70     * @param array $params
71     * @return string
72     */
73    public function makeParamString( $params ) {
74        // Add the width param string ( same as images {width}px )
75        $paramString = ( isset( $params['width'] ) ) ? $params['width'] . 'px' : '';
76        $paramString .= ( $paramString !== '' ) ? '-' : '';
77
78        // Get the raw thumbTime from thumbtime or start param
79        if ( isset( $params['thumbtime'] ) ) {
80            $thumbTime = $params['thumbtime'];
81        } elseif ( isset( $params['start'] ) ) {
82            $thumbTime = $params['start'];
83        } else {
84            $thumbTime = false;
85        }
86
87        if ( $thumbTime !== false ) {
88            $time = self::parseTimeString( $thumbTime );
89            if ( $time !== false ) {
90                return $paramString . 'seek=' . $time;
91            }
92        }
93
94        if ( !$paramString ) {
95            $paramString = 'mid';
96        }
97        return $paramString;
98    }
99
100    /**
101     * Used by thumb.php to find url parameters
102     *
103     * @param string $str
104     * @return array|false Array of thumbnail parameters, or false if string cannot be parsed
105     */
106    public function parseParamString( $str ) {
107        $params = [];
108        if ( preg_match( '/^(mid|(\d*)px-)*(seek=([\d.]+))*$/', $str, $matches ) ) {
109            $size = $thumbtime = null;
110            if ( isset( $matches[2] ) ) {
111                $size = $matches[2];
112            }
113            if ( isset( $matches[4] ) ) {
114                $thumbtime = $matches[4];
115            }
116
117            if ( $size !== null && $size !== '' ) {
118                $params['width'] = (int)$size;
119            }
120            if ( $thumbtime !== null ) {
121                $params['thumbtime'] = (float)$thumbtime;
122            }
123            // valid thumbnail URL
124            return $params;
125        }
126        // invalid parameter string
127        return false;
128    }
129
130    /**
131     * @param File $image
132     * @param array &$params
133     * @return bool
134     */
135    public function normaliseParams( $image, &$params ) {
136        $timeParam = [ 'thumbtime', 'start', 'end' ];
137        // Parse time values if endtime or thumbtime can't be more than length -1
138        foreach ( $timeParam as $pn ) {
139            if ( isset( $params[$pn] ) && $params[$pn] !== false ) {
140                $length = $this->getLength( $image );
141                $time = self::parseTimeString( $params[$pn] );
142                if ( $time === false ) {
143                    return false;
144                }
145
146                if ( $time > $length - 1 ) {
147                    $params[$pn] = $length - 1;
148                } elseif ( $time <= 0 ) {
149                    $params[$pn] = 0;
150                }
151            }
152        }
153
154        if ( $this->isAudio( $image ) ) {
155            // Assume a default for audio files
156            $size = [
157                'width' => 220,
158                'height' => 23,
159            ];
160        } else {
161            $size = [
162                'width' => $image->getWidth(),
163                'height' => $image->getHeight(),
164            ];
165        }
166        // Make sure we don't try and up-scale the asset:
167        if ( !$this->isAudio( $image ) && isset( $params['width'] )
168            && (int)$params['width'] > $size['width']
169        ) {
170            $params['width'] = $size['width'];
171        }
172
173        if ( isset( $params['height'] ) && $params['height'] !== -1 ) {
174            if ( $params['width'] * $size['height'] > $params['height'] * $size['width'] ) {
175                $params['width'] = self::fitBoxWidth( $size['width'], $size['height'], $params['height'] );
176            }
177        }
178        if ( isset( $params['width'] ) ) {
179            $params['height'] = File::scaleHeight( $size['width'], $size['height'], $params['width'] );
180        }
181
182        // Make sure start time is not > than end time
183        if (
184            isset( $params['start'] ) && isset( $params['end'] ) &&
185            $params['start'] !== false &&
186            $params['end'] !== false &&
187            ( self::parseTimeString( $params['start'] ) > self::parseTimeString( $params['end'] ) )
188        ) {
189            return false;
190        }
191
192        foreach ( [ 'loop', 'muted' ] as $flag ) {
193            $params[ $flag ] = isset( $params[ $flag ] );
194        }
195        return true;
196    }
197
198    /**
199     * Parser output hook only adds the required modules
200     *
201     * The core embedPlayer module lazy loaded by the loader modules
202     *
203     * @param Parser $parser
204     * @param ?File $file
205     */
206    public function parserTransformHook( $parser, $file ) {
207        $parserOutput = $parser->getOutput();
208        if ( $parserOutput->getExtensionData( 'mw_ext_TMH_hasTimedMediaTransform' ) ) {
209            return;
210        }
211
212        $parserOutput->addModuleStyles( [ 'ext.tmh.player.styles' ] );
213        $parserOutput->addModules( [ 'ext.tmh.player' ] );
214
215        $parserOutput->setExtensionData( 'mw_ext_TMH_hasTimedMediaTransform', true );
216    }
217
218    /**
219     * Utility functions
220     * @param string $timeString
221     * @param false|int $length
222     * @return false|int
223     */
224    public static function parseTimeString( $timeString, $length = false ) {
225        $parts = explode( ':', $timeString );
226        $time = 0;
227        $partsCount = count( $parts );
228        // Check for extra :s
229        if ( $partsCount > 3 ) {
230            return false;
231        }
232        foreach ( $parts as $i => $iValue ) {
233            if ( !is_numeric( $iValue ) ) {
234                return false;
235            }
236            $time += (float)$iValue * pow( 60, $partsCount - $i - 1 );
237        }
238
239        if ( $time < 0 ) {
240            wfDebug( __METHOD__ . ": specified negative time, using zero\n" );
241            return 0;
242        }
243        // We don't need more than millisecond precisions
244        // And for duration (length) seconds precision is ok
245        $time = $length ? ceil( $time ) : round( $time, 3 );
246        if ( $length !== false && $time > $length - 1 ) {
247            wfDebug( __METHOD__ .
248                ": specified near-end or past-the-end time {$time}s, using end minus 1s\n" );
249            $time = $length - 1;
250        }
251        return $time;
252    }
253
254    /**
255     * Converts seconds to Normal play time (NPT) time format:
256     * consist of hh:mm:ss.ms
257     * also see: http://www.ietf.org/rfc/rfc2326.txt section 3.6
258     *
259     * @param int $time Seconds to be converted to npt time format
260     * @return false|string
261     */
262    public static function seconds2npt( $time ) {
263        if ( !is_numeric( $time ) ) {
264            wfDebug( __METHOD__ . ": trying to get npt time on NaN: " . $time );
265            return false;
266        }
267        if ( $time < 0 ) {
268            wfDebug( __METHOD__ . ": trying to time on negative value: " . $time );
269            return false;
270        }
271        $hours = floor( $time / 3600 );
272        $min = floor( $time / 60 ) % 60;
273        $sec = floor( $time ) % 60;
274        $ms = floor( $time * 1000 ) % 1000;
275        $ms = ( $ms != 0 ) ? sprintf( '.%03d', $ms ) : '';
276
277        return sprintf( '%02d:%02d:%02d%s', $hours, $min, $sec, $ms );
278    }
279
280    /**
281     * @param string $metadata
282     * @return false|mixed
283     */
284    public function unpackMetadata( $metadata ) {
285        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
286        $unser = @unserialize( $metadata );
287        if ( isset( $unser['version'] ) ) {
288            return $unser;
289        }
290
291        return false;
292    }
293
294    /**
295     * @param File $image
296     * @param string $metadata
297     * @return bool
298     */
299    public function isMetadataValid( $image, $metadata ) {
300        return $this->unpackMetadata( $metadata ) !== false;
301    }
302
303    /**
304     * @param string $ext
305     * @param string $mime
306     * @param null $params
307     * @return array
308     */
309    public function getThumbType( $ext, $mime, $params = null ) {
310        return [ 'jpg', 'image/jpeg' ];
311    }
312
313    /**
314     * checks if a given file is an audio file
315     * @param File $file
316     * @return bool
317     */
318    public function isAudio( $file ) {
319        return ( !$file->getWidth() && !$file->getHeight() );
320    }
321
322    /**
323     * @param File $file
324     * @return bool
325     */
326    public function hasVideo( $file ) {
327        return false;
328    }
329
330    /**
331     * @param File $file
332     * @return bool
333     */
334    public function hasAudio( $file ) {
335        return false;
336    }
337
338    /**
339     * Audio channel count, or 0 if no audio.
340     * Fractional subwoofer channels are counted as a whole, so
341     * eg "5.1 surround" is 6 channels.
342     *
343     * @param File $file
344     * @return int
345     */
346    public function getAudioChannels( $file ) {
347        return 0;
348    }
349
350    /**
351     * @param File $file
352     * @param string $dstPath
353     * @param string $dstUrl
354     * @param array $params
355     * @param int $flags
356     * @return bool|MediaTransformError|MediaTransformOutput|TimedMediaTransformOutput
357     */
358    public function doTransform( $file, $dstPath, $dstUrl, $params, $flags = 0 ) {
359        # Important or height handling is wrong.
360        if ( !$this->normaliseParams( $file, $params ) ) {
361            return new TransformParameterError( $params );
362        }
363
364        $options = [
365            'file' => $file,
366            'length' => $this->getLength( $file ),
367            'offset' => $this->getOffset( $file ),
368            // Default thumbnail width and height for audio files is hardcoded to match the dimensions of
369            // the filetype icon, see TimedMediaTransformOutput::getUrl(). Overridden for video below.
370            'width' => $params['width'] ?? 120,
371            // Height is ignored for audio files anyway, and $params['height'] might be set to 0
372            'height' => $params['width'] ?? 120,
373            'isVideo' => !$this->isAudio( $file ),
374            'thumbtime' => $params['thumbtime'] ?? (int)( $file->getLength() / 2 ),
375            'start' => $params['start'] ?? false,
376            'end' => $params['end'] ?? false,
377            'fillwindow' => $params['fillwindow'] ?? false,
378            'disablecontrols' => $params['disablecontrols'] ?? false,
379            'loop' => $params['loop'] ?? false,
380            'muted' => $params['muted'] ?? false,
381            'inline' => $params['inline'] ?? false,
382        ];
383
384        // Allow start and end query string params on image pages (T203994)
385        if ( isset( $params['imagePageParams'] ) ) {
386            $requestParams = $params['imagePageParams'];
387            if ( !$options['start'] ) {
388                $options['start'] = $requestParams[ 'start' ] ?? false;
389            }
390            if ( !$options['end'] ) {
391                $options['end'] = $requestParams[ 'end' ] ?? false;
392            }
393        }
394
395        // No thumbs for audio
396        if ( !$options['isVideo'] ) {
397            return new TimedMediaTransformOutput( $options );
398        }
399
400        // We're dealing with a video file now, set width and height
401        $srcWidth = $file->getWidth();
402        $srcHeight = $file->getHeight();
403
404        $params['width'] ??= $srcWidth;
405
406        // if height overtakes width use height as max:
407        $targetWidth = $params['width'];
408        $targetHeight = $srcWidth ? round( $params['width'] * $srcHeight / $srcWidth ) : $srcHeight;
409        if ( isset( $params['height'] ) && $targetHeight > $params['height'] ) {
410            $targetHeight = $params['height'];
411            $targetWidth = round( $params['height'] * $srcWidth / $srcHeight );
412        }
413
414        $options[ 'width' ] = $targetWidth;
415        $options[ 'height' ] = $targetHeight;
416
417        // Setup pointer to thumb arguments
418        $options[ 'thumbUrl' ] = $dstUrl;
419        $options[ 'dstPath' ] = $dstPath;
420        $options[ 'path' ] = $dstPath;
421
422        // Check if transform is deferred:
423        if ( $flags & self::TRANSFORM_LATER ) {
424            return new TimedMediaTransformOutput( $options );
425        }
426
427        // Generate thumb:
428        $thumbStatus = TimedMediaThumbnail::get( $options );
429        if ( $thumbStatus !== true ) {
430            return $thumbStatus;
431        }
432
433        return new TimedMediaTransformOutput( $options );
434    }
435
436    /**
437     * @param File $file
438     * @return bool
439     */
440    public function mustRender( $file ) {
441        return true;
442    }
443
444    /**
445     * Get a stream offset time
446     * @param File $file
447     * @return float
448     */
449    public function getOffset( $file ) {
450        return 0.0;
451    }
452
453    /**
454     * Get length of a file
455     * @param File $file
456     * @return float
457     */
458    public function getLength( $file ) {
459        return $file->getLength();
460    }
461
462    /**
463     * @param File $file
464     * @return string
465     */
466    public function getDimensionsString( $file ) {
467        global $wgLang;
468
469        if ( $file->getWidth() ) {
470            return wfMessage( 'video-dims', $wgLang->formatTimePeriod( $this->getLength( $file ) ) )
471                ->numParams( $file->getWidth(), $file->getHeight() )->text();
472        }
473
474        return $wgLang->formatTimePeriod( $this->getLength( $file ) );
475    }
476
477    /**
478     * Return frame rate, if applicable, or 0 if no valid data.
479     * Subclasses will implement relevant metadata extraction.
480     *
481     * Note that values returned as floating point are not exact for
482     * NTSC/ATSC video with 30000/1001, 60000/1001, or 24000/1001
483     * frame rates!
484     *
485     * Note interlacing should be checked separately if relevant.
486     *
487     * @param File $file
488     * @return float
489     */
490    public function getFramerate( $file ) {
491        return 0.0;
492    }
493
494    /**
495     * Returns true if the file contains an interlaced video track.
496     * @param File $file
497     * @return bool
498     */
499    public function isInterlaced( $file ) {
500        return false;
501    }
502}