Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimedMediaThumbnail
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 4
930
0.00% covered (danger)
0.00%
0 / 1
 get
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 tryFfmpegThumb
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
110
 resizeThumb
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
90
 getThumbTime
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\TimedMediaHandler;
4
5use File;
6use MediaTransformError;
7use MediaWiki\MainConfigNames;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
10use UnregisteredLocalFile;
11
12class TimedMediaThumbnail {
13
14    /**
15     * @param array $options
16     * @return bool|MediaTransformError
17     */
18    public static function get( $options ) {
19        if ( !is_dir( dirname( $options['dstPath'] ) ) ) {
20            wfMkdirParents( dirname( $options['dstPath'] ), null, __METHOD__ );
21        }
22
23        wfDebug( "Creating video thumbnail at " . $options['dstPath'] . "\n" );
24        if (
25            isset( $options['width'] ) && isset( $options['height'] ) &&
26            $options['width'] != $options['file']->getWidth() &&
27            $options['height'] != $options['file']->getHeight()
28        ) {
29            return self::resizeThumb( $options );
30        }
31        return self::tryFfmpegThumb( $options );
32    }
33
34    /**
35     * @param array $options
36     * @return bool|MediaTransformError
37     */
38    private static function tryFfmpegThumb( $options ) {
39        $config = MediaWikiServices::getInstance()->getMainConfig();
40        $fFmpegLocation = $config->get( 'FFmpegLocation' );
41        $maxShellMemory = $config->get( MainConfigNames::MaxShellMemory );
42
43        if ( !$fFmpegLocation || !is_file( $fFmpegLocation ) ) {
44            return false;
45        }
46
47        $cmd = wfEscapeShellArg( $fFmpegLocation ) . ' -nostdin -threads 1 ';
48
49        $file = $options['file'];
50        $handler = $file->getHandler();
51
52        $offset = (int)self::getThumbTime( $options );
53        /*
54        This is a workaround until ffmpegs ogg demuxer properly seeks to keyframes.
55        Seek N seconds before offset and seek in decoded stream after that.
56         -ss before input seeks without decode
57         -ss after input seeks in decoded stream
58
59         N depends on framerate of input, keyframe interval defaults
60         to 64 for most encoders, seeking a bit before that
61         */
62
63        $framerate = $handler->getFramerate( $file );
64        if ( $framerate > 0 ) {
65            $seekoffset = 1 + (int)( 64 / $framerate );
66        } else {
67            $seekoffset = 3;
68        }
69
70        if ( $offset > $seekoffset ) {
71            $cmd .= ' -ss ' . (float)( $offset - $seekoffset );
72            $offset = $seekoffset;
73        }
74
75        // try to get temporary local url to file
76        $backend = $file->getRepo()->getBackend();
77
78        $src = $backend->getFileHttpUrl( [
79            'src' => $file->getPath()
80        ] );
81        if ( $src === null ) {
82            $src = $file->getLocalRefPath();
83        }
84
85        $cmd .= ' -y -i ' . wfEscapeShellArg( $src );
86        $cmd .= ' -ss ' . $offset . ' ';
87
88        // Deinterlace MPEG-2 if necessary
89        if ( $handler->isInterlaced( $file ) ) {
90            // Send one frame only
91            $cmd .= ' -vf yadif=mode=0';
92        }
93
94        // Set the output size if set in options:
95        if ( isset( $options['width'] ) && isset( $options['height'] ) ) {
96            $cmd .= ' -s ' . (int)$options['width'] . 'x' . (int)$options['height'];
97        }
98
99        // MJPEG, that's the same as JPEG except it's supported by the windows build of ffmpeg
100        // No audio, one frame
101        $cmd .= ' -f mjpeg -an -vframes 1 ' .
102            wfEscapeShellArg( $options['dstPath'] ) . ' 2>&1';
103
104        $retval = 0;
105        $returnText = wfShellExec( $cmd, $retval );
106        // Check if it was successful
107        if ( !$options['file']->getHandler()->removeBadFile( $options['dstPath'], $retval ) ) {
108            return true;
109        }
110        $returnText = $cmd . "\nwgMaxShellMemory: $maxShellMemory\n" . $returnText;
111        // Return error box
112        return new MediaTransformError(
113            'thumbnail_error', $options['width'], $options['height'], $returnText
114        );
115    }
116
117    /**
118     * @param array $options
119     * @return bool|MediaTransformError
120     */
121    private static function resizeThumb( $options ) {
122        $file = $options['file'];
123        $params = [];
124        foreach ( [ 'start', 'thumbtime' ] as $key ) {
125            if ( isset( $options[ $key ] ) ) {
126                $params[ $key ] = $options[ $key ];
127            }
128        }
129        $params["width"] = $file->getWidth();
130        $params["height"] = $file->getHeight();
131
132        $poolKey = $file->getRepo()->getSharedCacheKey( 'file', md5( $file->getName() ) );
133        $posOptions = array_flip( [ 'start', 'thumbtime' ] );
134        $poolKey = wfAppendQuery( $poolKey, array_intersect_key( $options, $posOptions ) );
135
136        $work = new PoolCounterWorkViaCallback( 'TMHTransformFrame',
137            '_tmh:frame:' . $poolKey,
138            [ 'doWork' => static function () use ( $file, $params ) {
139                return $file->transform( $params, File::RENDER_NOW );
140            } ] );
141        $thumb = $work->execute();
142
143        if ( !$thumb || $thumb->isError() ) {
144            return $thumb;
145        }
146        $src = $thumb->getStoragePath();
147        if ( !$src ) {
148            return false;
149        }
150        $localRepo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
151        $thumbFile = new UnregisteredLocalFile( $file->getTitle(),
152            $localRepo, $src, false );
153        $thumbParams = [
154            "width" => $options['width'],
155            "height" => $options['height']
156        ];
157        $handler = $thumbFile->getHandler();
158        if ( !$handler ) {
159            return false;
160        }
161        $scaledThumb = $handler->doTransform(
162            $thumbFile,
163            $options['dstPath'],
164            $options['dstPath'],
165            $thumbParams
166        );
167
168        if ( !$scaledThumb || $scaledThumb->isError() ) {
169            // @phan-suppress-next-line PhanTypeMismatchReturnNullable
170            return $scaledThumb;
171        }
172        return true;
173    }
174
175    /**
176     * @param array $options
177     * @return bool|float|int
178     */
179    private static function getThumbTime( $options ) {
180        $length = $options['file']->getLength();
181
182        // If start time param isset use that for the thumb:
183        if ( isset( $options['start'] ) ) {
184            $thumbtime = TimedMediaHandler::parseTimeString( $options['start'], $length );
185            if ( $thumbtime !== false ) {
186                return $thumbtime;
187            }
188        }
189        // else use thumbtime
190        if ( isset( $options['thumbtime'] ) ) {
191            $thumbtime = TimedMediaHandler::parseTimeString( $options['thumbtime'], $length );
192            if ( $thumbtime !== false ) {
193                return $thumbtime;
194            }
195        }
196        // Seek to midpoint by default, it tends to be more interesting than the start
197        return $length / 2;
198    }
199}