Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.02% covered (danger)
3.02%
14 / 464
8.11% covered (danger)
8.11%
3 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebVideoTranscode
3.02% covered (danger)
3.02%
14 / 464
8.11% covered (danger)
8.11%
3 / 37
16759.60
0.00% covered (danger)
0.00%
0 / 1
 getDerivativeFilePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTranscodeFileBaseName
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getTranscodedUrlForFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTargetEncodeFile
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getMaxSizeWebStream
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getProjectedFileSize
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getSources
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getRemoteSources
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 getLocalSources
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
132
 isTranscodeKeyPlayable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isTranscodeReady
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 clearTranscodeCache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTranscodeState
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 removeTranscodes
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
156
 invalidatePagesWithFile
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 addSourceIfReady
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getPrimarySourceAttributes
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getDerivativeSourceAttributes
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 startJobQueue
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 updateStreamingManifests
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 cleanupTranscodes
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 isTranscodeEnabled
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 updateJobQueue
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
 isTranscodePrioritized
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getQueueSize
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getMaxSizeTransform
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 isTargetLargerThanFile
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 isSmallestTranscodeForCodec
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getMaxSize
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 filterAndSort
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 enabledTranscodes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 enabledVideoTranscodes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 enabledAudioTranscodes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 validateTranscodeConfiguration
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isBaseMediaFormat
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 expandRate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 cleanupOrphanedTranscodes
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * WebVideoTranscode provides:
4 *  encode keys
5 *  encode settings
6 *
7 *     extends api to return all the streams
8 *  extends video tag output to provide all the available sources
9 */
10
11namespace MediaWiki\TimedMediaHandler\WebVideoTranscode;
12
13use Exception;
14use File;
15use HTMLCacheUpdateJob;
16use IForeignRepoWithDB;
17use IForeignRepoWithMWApi;
18use JobSpecification;
19use LogicException;
20use MediaWiki\Config\ConfigException;
21use MediaWiki\Deferred\CdnCacheUpdate;
22use MediaWiki\Deferred\DeferredUpdates;
23use MediaWiki\FileBackend\FSFile\TempFSFileFactory;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Status\Status;
26use MediaWiki\TimedMediaHandler\Handlers\FLACHandler\FLACHandler;
27use MediaWiki\TimedMediaHandler\Handlers\ID3Handler\ID3Handler;
28use MediaWiki\TimedMediaHandler\Handlers\MIDIHandler\MIDIHandler;
29use MediaWiki\TimedMediaHandler\Handlers\MP3Handler\MP3Handler;
30use MediaWiki\TimedMediaHandler\Handlers\MP4Handler\MP4Handler;
31use MediaWiki\TimedMediaHandler\Handlers\OggHandler\OggHandler;
32use MediaWiki\TimedMediaHandler\Handlers\WAVHandler\WAVHandler;
33use MediaWiki\TimedMediaHandler\HLS\Multivariant;
34use MediaWiki\Title\Title;
35use TempFSFile;
36use Wikimedia\Rdbms\IReadableDatabase;
37
38/**
39 * Main WebVideoTranscode Class hold some constants and config values
40 */
41class WebVideoTranscode {
42    /** @var array[] Static cache of transcode state per instantiation */
43    public static $transcodeState = [];
44
45    /**
46     * Encoding parameters are set via firefogg encode api
47     *
48     * For clarity and compatibility with passing down
49     * client side encode settings at point of upload
50     *
51     * http://firefogg.org/dev/index.html
52     * @var string[][]
53     */
54    public static $derivativeSettings = [
55
56        // WebM VP8/Vorbis transcodes
57        //
58        // Two-pass encoding is a bit slower, but *massively* improves bitrate control.
59        // Trading off speed using the '-speed 3' parameter on the second pass.
60        //
61        // The current defaults do not include VP8 output, but it may be helpful
62        // at a limited resolution range for certain back-compatibility scenarios.
63        '160p.webm' => [
64            'maxSize' => '288x160',
65            'videoBitrate' => '193k',
66            'minrate' => '96k',
67            'maxrate' => '280k',
68            'crf' => '37',
69            'speed' => '3',
70            'twopass' => 'true',
71            'videoCodec' => 'vp8',
72            'audioCodec' => 'vorbis',
73            'samplerate' => '48000',
74            'channels' => '2',
75            'audioBitrate' => '128k',
76            'type' => 'video/webm; codecs="vp8, vorbis"',
77        ],
78        '240p.webm' => [
79            'maxSize' => '426x240',
80            'videoBitrate' => '385k',
81            'minrate' => '193k',
82            'maxrate' => '558k',
83            'crf' => '37',
84            'speed' => '3',
85            'twopass' => 'true',
86            'videoCodec' => 'vp8',
87            'audioCodec' => 'vorbis',
88            'samplerate' => '48000',
89            'channels' => '2',
90            'audioBitrate' => '128k',
91            'type' => 'video/webm; codecs="vp8, vorbis"',
92        ],
93        '360p.webm' => [
94            'maxSize' => '640x360',
95            'videoBitrate' => '767k',
96            'minrate' => '383k',
97            'maxrate' => '1112k',
98            'crf' => '36',
99            'speed' => '3',
100            'slices' => '2',
101            'twopass' => 'true',
102            'videoCodec' => 'vp8',
103            'audioCodec' => 'vorbis',
104            'samplerate' => '48000',
105            'channels' => '2',
106            'audioBitrate' => '128k',
107            'type' => 'video/webm; codecs="vp8, vorbis"',
108        ],
109        '480p.webm' => [
110            'maxSize' => '854x480',
111            'videoBitrate' => '1250k',
112            'minrate' => '625k',
113            'maxrate' => '1813k',
114            'crf' => '33',
115            'speed' => '3',
116            'slices' => '2',
117            'twopass' => 'true',
118            'videoCodec' => 'vp8',
119            'audioCodec' => 'vorbis',
120            'samplerate' => '48000',
121            'channels' => '2',
122            'audioBitrate' => '128k',
123            'type' => 'video/webm; codecs="vp8, vorbis"',
124        ],
125        '720p.webm' => [
126            'maxSize' => '1280x720',
127            'videoBitrate' => '2491k',
128            'minrate' => '1246k',
129            'maxrate' => '3612k',
130            'crf' => '32',
131            'speed' => '3',
132            'slices' => '4',
133            'twopass' => 'true',
134            'videoCodec' => 'vp8',
135            'audioCodec' => 'vorbis',
136            'samplerate' => '48000',
137            'channels' => '2',
138            'audioBitrate' => '128k',
139            'type' => 'video/webm; codecs="vp8, vorbis"',
140        ],
141        '1080p.webm' => [
142            'maxSize' => '1920x1080',
143            'videoBitrate' => '4963k',
144            'minrate' => '2482k',
145            'maxrate' => '7197k',
146            'crf' => '31',
147            'speed' => '3',
148            'slices' => '4',
149            'twopass' => 'true',
150            'videoCodec' => 'vp8',
151            'audioCodec' => 'vorbis',
152            'samplerate' => '48000',
153            'channels' => '2',
154            'audioBitrate' => '128k',
155            'type' => 'video/webm; codecs="vp8, vorbis"',
156        ],
157        '1440p.webm' => [
158            'maxSize' => '2560x1440',
159            'videoBitrate' => '8094k',
160            'minrate' => '4047k',
161            'maxrate' => '11736k',
162            'crf' => '24',
163            'speed' => '2',
164            'slices' => '8',
165            'twopass' => 'true',
166            'videoCodec' => 'vp8',
167            'audioCodec' => 'vorbis',
168            'samplerate' => '48000',
169            'channels' => '2',
170            'audioBitrate' => '128k',
171            'type' => 'video/webm; codecs="vp8, vorbis"',
172        ],
173        '2160p.webm' => [
174            'maxSize' => '3840x2160',
175            'videoBitrate' => '16126k',
176            'minrate' => '8063k',
177            'maxrate' => '23382k',
178            'crf' => '15',
179            'speed' => '2',
180            'slices' => '8',
181            'twopass' => 'true',
182            'videoCodec' => 'vp8',
183            'audioCodec' => 'vorbis',
184            'samplerate' => '48000',
185            'channels' => '2',
186            'audioBitrate' => '128k',
187            'type' => 'video/webm; codecs="vp8, vorbis"',
188        ],
189
190        // WebM VP9 transcode:
191        //
192        // Two-pass encoding is a bit slower, but *massively* improves bitrate control.
193        // Trading off speed using the '-speed 3' parameter on the second pass.
194        //
195        // Encoding speed is greatly affected by threading settings; HD videos can use up to
196        // 8 threads with a suitable ffmpeg/libvpx and $wgFFmpegVP9RowMT enabled ("row-mt").
197        // Ultra-HD can use up to 16 threads. Be sure to set $wgFFmpegThreads to a suitable
198        // maximum values!
199        //
200        '120p.vp9.webm' => [
201            'maxSize' => '213x120',
202            'videoBitrate' => '95k',
203            'minrate' => '47k',
204            'maxrate' => '137k',
205            'crf' => '37',
206            'speed' => '3',
207            'videoCodec' => 'vp9',
208            'twopass' => 'true',
209            'audioCodec' => 'opus',
210            'audioBitrate' => '96k',
211            'type' => 'video/webm; codecs="vp9, opus"',
212        ],
213        '180p.vp9.webm' => [
214            'maxSize' => '320x180',
215            'videoBitrate' => '189k',
216            'minrate' => '94k',
217            'maxrate' => '274k',
218            'crf' => '37',
219            'speed' => '3',
220            'twopass' => 'true',
221            'videoCodec' => 'vp9',
222            'audioCodec' => 'opus',
223            'audioBitrate' => '96k',
224            'type' => 'video/webm; codecs="vp9, opus"',
225        ],
226        '240p.vp9.webm' => [
227            'maxSize' => '426x240',
228            'videoBitrate' => '308k',
229            'minrate' => '154k',
230            'maxrate' => '447k',
231            'crf' => '37',
232            'speed' => '3',
233            'twopass' => 'true',
234            'videoCodec' => 'vp9',
235            'audioCodec' => 'opus',
236            'audioBitrate' => '96k',
237            'type' => 'video/webm; codecs="vp9, opus"',
238        ],
239        '360p.vp9.webm' => [
240            'maxSize' => '640x360',
241            'videoBitrate' => '613k',
242            'minrate' => '307k',
243            'maxrate' => '889k',
244            'crf' => '36',
245            'speed' => '3',
246            'tileColumns' => '1',
247            'twopass' => 'true',
248            'videoCodec' => 'vp9',
249            'audioCodec' => 'opus',
250            'audioBitrate' => '96k',
251            'type' => 'video/webm; codecs="vp9, opus"',
252        ],
253        '480p.vp9.webm' => [
254            'maxSize' => '854x480',
255            'videoBitrate' => '1000k',
256            'minrate' => '500k',
257            'maxrate' => '1450k',
258            'crf' => '33',
259            'speed' => '3',
260            'tileColumns' => '1',
261            'twopass' => 'true',
262            'videoCodec' => 'vp9',
263            'audioCodec' => 'opus',
264            'audioBitrate' => '96k',
265            'type' => 'video/webm; codecs="vp9, opus"',
266        ],
267        '720p.vp9.webm' => [
268            'maxSize' => '1280x720',
269            'videoBitrate' => '1993k',
270            'minrate' => '996k',
271            'maxrate' => '2890k',
272            'crf' => '32',
273            'speed' => '3',
274            'tileColumns' => '2',
275            'twopass' => 'true',
276            'videoCodec' => 'vp9',
277            'audioCodec' => 'opus',
278            'audioBitrate' => '96k',
279            'type' => 'video/webm; codecs="vp9, opus"',
280        ],
281        '1080p.vp9.webm' => [
282            'maxSize' => '1920x1080',
283            'videoBitrate' => '3971k',
284            'minrate' => '1985k',
285            'maxrate' => '5757k',
286            'crf' => '31',
287            'speed' => '3',
288            'tileColumns' => '2',
289            'twopass' => 'true',
290            'videoCodec' => 'vp9',
291            'audioCodec' => 'opus',
292            'audioBitrate' => '96k',
293            'type' => 'video/webm; codecs="vp9, opus"',
294        ],
295        '1440p.vp9.webm' => [
296            'maxSize' => '2560x1440',
297            'videoBitrate' => '6475k',
298            'minrate' => '3238k',
299            'maxrate' => '9389k',
300            'crf' => '24',
301            'speed' => '3',
302            'tileColumns' => '3',
303            'twopass' => 'true',
304            'videoCodec' => 'vp9',
305            'audioCodec' => 'opus',
306            'audioBitrate' => '96k',
307            'type' => 'video/webm; codecs="vp9, opus"',
308        ],
309        '2160p.vp9.webm' => [
310            'maxSize' => '3840x2160',
311            'videoBitrate' => '12900k',
312            'minrate' => '6450k',
313            'maxrate' => '18706k',
314            'crf' => '15',
315            'speed' => '3',
316            'tileColumns' => '3',
317            'twopass' => 'true',
318            'videoCodec' => 'vp9',
319            'audioCodec' => 'opus',
320            'audioBitrate' => '96k',
321            'type' => 'video/webm; codecs="vp9, opus"',
322        ],
323
324        // Adaptive streaming transcodes:
325        // * stereo.audio.mp3 audio (for Safari 16 and below)
326        // * stereo.audio.opus.mp4 audio (for Chromium, Firefox, Safari 17)
327        // * surround.audio.opus.mp4 audio (reserved for future expansion)
328        // * 144p.video.mjpeg.mov fallback video for old iOS (optional)
329        // * 180p .. 480p.video.mpeg4.mp4 fallback video for old iOS (optional)
330        // * 240p .. 2160p.video.vp9.mp4 video
331        // * .m3u8 playlists
332        //
333        'stereo.audio.mp3' => [
334            'novideo' => 'true',
335            'audioCodec' => 'mp3',
336            'samplerate' => '48000',
337            'channels' => '2',
338            'audioBitrate' => '128k',
339            'type' => 'audio/mpeg',
340            'streaming' => 'hls',
341        ],
342        'stereo.audio.opus.mp4' => [
343            'novideo' => 'true',
344            'audioCodec' => 'opus',
345            'samplerate' => '48000',
346            'channels' => '2',
347            'audioBitrate' => '96k',
348            'type' => 'audio/mp4; codecs="opus"',
349            'streaming' => 'hls',
350        ],
351        /*
352        // @todo implement surround support for input
353        // with >2 channels. note safari doesn't grok
354        // opus surround.
355        'surround.audio.opus.mp4' => [
356            'novideo' => true,
357            'audioCodec' => 'opus',
358            'samplerate' => '48000',
359            'minChannels' => 3,
360            'audioBitrate' => '256k',
361            'type' => 'audio/mp4; codecs="opus"',
362            'streaming' => 'hls',
363        ],
364        */
365
366        // Optional back-compat
367        // Streaming Motion-JPEG track
368        //
369        // These are video-only, in fragmented .mov that allows adaptive streaming
370        // with chunks split at fragment boundaries listed in an associated .m3u8
371        // streaming playlist. MJPEG works with iOS on hardware that doesn't support
372        // the VP9 codec, but is poorly compressed for the low resolution.
373        '144p.video.mjpeg.mov' => [
374            'width' => '176',
375            'height' => '144',
376            'fpsmax' => '30',
377            'videoBitrate' => '1000k',
378            'videoCodec' => 'mjpeg',
379            'noaudio' => 'true',
380            'type' => 'video/quicktime; codecs="jpeg"',
381            'streaming' => 'hls',
382            'intraframe' => true,
383        ],
384        '180p.video.mpeg4.mp4' => [
385            'maxSize' => '320x180',
386            'videoBitrate' => '380k',
387            'twopass' => 'true',
388            'videoCodec' => 'mpeg4',
389            'noaudio' => 'true',
390            'type' => 'video/mp4; codecs="mp4v.20.5"',
391            'streaming' => 'hls',
392        ],
393        '240p.video.mpeg4.mp4' => [
394            'maxSize' => '426x240',
395            'videoBitrate' => '720k',
396            'twopass' => 'true',
397            'videoCodec' => 'mpeg4',
398            'noaudio' => 'true',
399            'type' => 'video/mp4; codecs="mp4v.20.5"',
400            'streaming' => 'hls',
401        ],
402        '360p.video.mpeg4.mp4' => [
403            'maxSize' => '640x360',
404            'videoBitrate' => '1280k',
405            'twopass' => 'true',
406            'videoCodec' => 'mpeg4',
407            'noaudio' => 'true',
408            'type' => 'video/mp4; codecs="mp4v.20.5"',
409            'streaming' => 'hls',
410        ],
411        '480p.video.mpeg4.mp4' => [
412            'maxSize' => '854x480',
413            'videoBitrate' => '2500k',
414            'twopass' => 'true',
415            'videoCodec' => 'mpeg4',
416            'noaudio' => 'true',
417            'type' => 'video/mp4; codecs="mp4v.20.5"',
418            'streaming' => 'hls',
419        ],
420
421        // VP9 streaming tracks
422        //
423        // These are video-only, in fragmented .mp4 that allows adaptive streaming
424        // with chunks split at fragment boundaries listed in an associated .m3u8
425        // streaming playlist.
426        //
427        // The 'remuxFrom' key specifies that if a WebM tracks was previously made,
428        // it can be used as a data source via remuxing packets instead of doing
429        // a fresh encoding when doing bulk conversions with requeueTranscodes.php
430        // with the '--remux' option.
431        '240p.video.vp9.mp4' => [
432            'maxSize' => '426x240',
433            'videoBitrate' => '308k',
434            'crf' => '37',
435            'speed' => '3',
436            'twopass' => 'true',
437            'videoCodec' => 'vp9',
438            'noaudio' => 'true',
439            'type' => 'video/mp4; codecs="vp09.00.51.08"',
440            'streaming' => 'hls',
441            'remuxFrom' => [ '240p.vp9.webm' ],
442        ],
443        '360p.video.vp9.mp4' => [
444            'maxSize' => '640x360',
445            'videoBitrate' => '613k',
446            'crf' => '36',
447            'speed' => '3',
448            'tileColumns' => '1',
449            'twopass' => 'true',
450            'videoCodec' => 'vp9',
451            'noaudio' => 'true',
452            'type' => 'video/mp4; codecs="vp09.00.51.08"',
453            'streaming' => 'hls',
454            'remuxFrom' => [ '360p.vp9.webm' ],
455        ],
456        '480p.video.vp9.mp4' => [
457            'maxSize' => '854x480',
458            'videoBitrate' => '1000k',
459            'crf' => '33',
460            'speed' => '3',
461            'tileColumns' => '1',
462            'twopass' => 'true',
463            'videoCodec' => 'vp9',
464            'noaudio' => 'true',
465            'type' => 'video/mp4; codecs="vp09.00.51.08"',
466            'streaming' => 'hls',
467            'remuxFrom' => [ '480p.vp9.webm' ],
468        ],
469        '720p.video.vp9.mp4' => [
470            'maxSize' => '1280x720',
471            'videoBitrate' => '1993k',
472            'crf' => '32',
473            'speed' => '3',
474            'tileColumns' => '2',
475            'twopass' => 'true',
476            'videoCodec' => 'vp9',
477            'noaudio' => 'true',
478            'type' => 'video/mp4; codecs="vp09.00.51.08"',
479            'streaming' => 'hls',
480            'remuxFrom' => [ '720p.vp9.webm' ],
481        ],
482        '1080p.video.vp9.mp4' => [
483            'maxSize' => '1920x1080',
484            'videoBitrate' => '3971k',
485            'crf' => '31',
486            'speed' => '3',
487            'tileColumns' => '2',
488            'twopass' => 'true',
489            'videoCodec' => 'vp9',
490            'noaudio' => 'true',
491            'type' => 'video/mp4; codecs="vp09.00.51.08"',
492            'streaming' => 'hls',
493            'remuxFrom' => [ '1080p.vp9.webm' ],
494        ],
495        '1440p.video.vp9.mp4' => [
496            'maxSize' => '2560x1440',
497            'videoBitrate' => '6475k',
498            'crf' => '24',
499            'speed' => '3',
500            'tileColumns' => '3',
501            'twopass' => 'true',
502            'videoCodec' => 'vp9',
503            'noaudio' => 'true',
504            'type' => 'video/mp4; codecs="vp09.00.51.08"',
505            'streaming' => 'hls',
506            'remuxFrom' => [ '1440p.vp9.webm' ],
507        ],
508        '2160p.video.vp9.mp4' => [
509            'maxSize' => '3840x2160',
510            'videoBitrate' => '12900k',
511            'crf' => '15',
512            'speed' => '3',
513            'tileColumns' => '3',
514            'twopass' => 'true',
515            'videoCodec' => 'vp9',
516            'noaudio' => 'true',
517            'type' => 'video/mp4; codecs="vp09.00.51.08"',
518            'streaming' => 'hls',
519            'remuxFrom' => [ '2160p.vp9.webm' ],
520        ],
521
522        // Loosely defined per PCF guide to mp4 profiles:
523        // https://develop.participatoryculture.org/index.php/ConversionMatrix
524        // and apple HLS profile guide:
525        // https://developer.apple.com/library/ios/#documentation/networkinginternet/conceptual/streamingmediaguide/UsingHTTPLiveStreaming/UsingHTTPLiveStreaming.html#//apple_ref/doc/uid/TP40008332-CH102-DontLinkElementID_24
526
527        // high profile
528        // level 2 needed for 160p60
529        // level 2.1 needed for 240p60
530        // level 3 needed for 360p60, 480p60
531        // level 4 needed for 720p60, 1080p30
532        // level 4.1 needed for 1080p60
533        // level 5 needed for 1440p60, 2160p30
534        // level 5.1 needed for 2160p60
535
536        // deprecated
537        '160p.mp4' => [
538            'maxSize' => '288x160',
539            'videoCodec' => 'h264',
540            'videoBitrate' => '154k',
541            'audioCodec' => 'aac',
542            'audioBitrate' => '112k',
543            'type' => 'video/mp4; codecs="avc1.640014, mp4a.40.2"',
544        ],
545
546        '240p.mp4' => [
547            'maxSize' => '426x240',
548            'videoCodec' => 'h264',
549            'videoBitrate' => '308k',
550            'audioCodec' => 'aac',
551            'audioBitrate' => '112k',
552            'type' => 'video/mp4; codecs="avc1.42E015, mp4a.40.2"',
553        ],
554
555        // deprecated
556        '320p.mp4' => [
557            'maxSize' => '480x320',
558            'videoCodec' => 'h264',
559            'videoBitrate' => '460k',
560            'audioCodec' => 'aac',
561            'audioBitrate' => '112k',
562            'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
563        ],
564
565        '360p.mp4' => [
566            'maxSize' => '640x360',
567            'videoCodec' => 'h264',
568            'videoBitrate' => '613k',
569            'audioCodec' => 'aac',
570            'audioBitrate' => '112k',
571            'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
572        ],
573        '480p.mp4' => [
574            'maxSize' => '854x480',
575            'videoCodec' => 'h264',
576            'videoBitrate' => '1000k',
577            'audioCodec' => 'aac',
578            'audioBitrate' => '112k',
579            'type' => 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
580        ],
581        '720p.mp4' => [
582            'maxSize' => '1280x720',
583            'videoCodec' => 'h264',
584            'videoBitrate' => '1993k',
585            'audioCodec' => 'aac',
586            'audioBitrate' => '112k',
587            'type' => 'video/mp4; codecs="avc1.42E028, mp4a.40.2"',
588        ],
589        '1080p.mp4' => [
590            'maxSize' => '1920x1080',
591            'videoCodec' => 'h264',
592            'videoBitrate' => '3971k',
593            'audioCodec' => 'aac',
594            'audioBitrate' => '128k',
595            'type' => 'video/mp4; codecs="avc1.640029, mp4a.40.2"',
596        ],
597        // Recommend against due to size
598        '1440p.mp4' => [
599            'maxSize' => '2560x1440',
600            'videoCodec' => 'h264',
601            'videoBitrate' => '6475k',
602            'audioCodec' => 'aac',
603            'audioBitrate' => '112k',
604            'type' => 'video/mp4; codecs="avc1.42E032, mp4a.40.2"',
605        ],
606        // Recommend against due to size
607        '2160p.mp4' => [
608            'maxSize' => '4096x2160',
609            'videoCodec' => 'h264',
610            'videoBitrate' => '12900k',
611            'audioCodec' => 'aac',
612            'audioBitrate' => '112k',
613            'type' => 'video/mp4; codecs="avc1.42E033, mp4a.40.2"',
614        ],
615
616        // Audio profiles
617        'ogg' => [
618            'audioCodec' => 'vorbis',
619            'audioQuality' => '3',
620            'samplerate' => '44100',
621            'noUpscaling' => 'true',
622            'novideo' => 'true',
623            'type' => 'audio/ogg; codecs="vorbis"',
624        ],
625        'opus' => [
626            'audioCodec' => 'opus',
627            'audioQuality' => '1',
628            'samplerate' => '48000',
629            'noUpscaling' => 'true',
630            'novideo' => 'true',
631            'type' => 'audio/ogg; codecs="opus"',
632        ],
633        'mp3' => [
634            'audioCodec' => 'mp3',
635            'audioQuality' => '1',
636            'samplerate' => '44100',
637            'channels' => '2',
638            'noUpscaling' => 'true',
639            'novideo' => 'true',
640            'type' => 'audio/mpeg',
641        ],
642        'm4a' => [
643            'audioCodec' => 'aac',
644            'audioQuality' => '1',
645            'samplerate' => '44100',
646            'noUpscaling' => 'true',
647            'novideo' => 'true',
648            'type' => 'audio/mp4; codecs="mp4a.40.5"',
649        ],
650    ];
651
652    /**
653     * @param File $file
654     * @param string $transcodeKey
655     * @return string
656     */
657    public static function getDerivativeFilePath( $file, $transcodeKey ) {
658        return $file->getTranscodedPath( static::getTranscodeFileBaseName( $file, $transcodeKey ) );
659    }
660
661    /**
662     * Get the name to use as the base name for the transcode.
663     *
664     * Swift has problems where the url-encoded version of
665     * the path (ie '0/00/filename.ogv/filename.ogv.720p.webm' )
666     * is greater than > 1024 bytes, so shorten in that case.
667     *
668     * Future versions might respect FileRepo::$abbrvThreshold.
669     *
670     * @param File $file
671     * @param string $suffix Optional suffix (e.g. transcode key).
672     * @return string File name, or the string transcode.
673     */
674    public static function getTranscodeFileBaseName( $file, $suffix = '' ) {
675        $name = $file->getName();
676        $length = strlen( urlencode( '0/00/' . $name . '/' . $name . '.' . $suffix ) );
677        if ( $length > 1024 ) {
678            return 'transcode' . '.' . $suffix;
679        }
680        return $name . '.' . $suffix;
681    }
682
683    /**
684     * Get url for a transcode.
685     *
686     * @param File $file
687     * @param string $suffix Transcode key
688     * @return string
689     */
690    public static function getTranscodedUrlForFile( $file, $suffix = '' ) {
691        return $file->getTranscodedUrl( static::getTranscodeFileBaseName( $file, $suffix ) );
692    }
693
694    /**
695     * Get temp file at target path for video encode
696     *
697     * @param File $file
698     * @param string $transcodeKey
699     * @param string $suffix
700     *
701     * @return TempFSFile|false at target encode path
702     */
703    public static function getTargetEncodeFile( $file, $transcodeKey, $suffix = '' ) {
704        $filePath = static::getDerivativeFilePath( $file, $transcodeKey ) . $suffix;
705        $ext = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) );
706
707        // Create a temp FS file with the same extension
708        $tmpFileFactory = new TempFSFileFactory();
709        $tmpFile = $tmpFileFactory->newTempFSFile( 'transcode_' . $transcodeKey, $ext );
710        if ( !$tmpFile ) {
711            return false;
712        }
713        return $tmpFile;
714    }
715
716    /**
717     * Get the max size of the web stream ( constant bitrate )
718     * @return int
719     */
720    public static function getMaxSizeWebStream() {
721        $maxSize = 0;
722        foreach ( static::enabledVideoTranscodes() as $transcodeKey ) {
723            if ( isset( static::$derivativeSettings[$transcodeKey]['videoBitrate'] ) ) {
724                $currentSize = static::$derivativeSettings[$transcodeKey]['maxSize'] ?? null;
725                if ( $currentSize > $maxSize ) {
726                    $maxSize = $currentSize;
727                }
728            }
729        }
730        return $maxSize;
731    }
732
733    /**
734     * Give a rough estimate on file size
735     * Note this is not always accurate.. especially with variable bitrate codecs ;)
736     * @param File $file
737     * @param string $transcodeKey
738     * @suppress PhanTypePossiblyInvalidDimOffset
739     * @return int
740     */
741    public static function getProjectedFileSize( $file, $transcodeKey ) {
742        $settings = static::$derivativeSettings[$transcodeKey];
743        // FIXME broken, as bitrate settings can contain units (64k)
744        if ( $settings[ 'videoBitrate' ] && $settings['audioBitrate'] ) {
745            return $file->getLength() * 8 * (
746                (int)$settings['videoBitrate']
747                +
748                (int)$settings['audioBitrate']
749            );
750        }
751        // Else just return the size of the source video
752        // ( we have no idea how large the actual derivative size will be )
753
754        /** @var ID3Handler $handler */
755        $handler = $file->getHandler();
756        '@phan-var ID3Handler $handler';
757        return $file->getLength() * $handler->getBitrate( $file ) * 8;
758    }
759
760    /**
761     * Static function to get the set of video assets
762     * Checks if the file is local or remote and grabs respective sources
763     * @param File &$file
764     * @param array $options
765     * @return array|mixed
766     */
767    public static function getSources( &$file, $options = [] ) {
768        if ( $file->isLocal() || $file->repo instanceof IForeignRepoWithDB ) {
769            return static::getLocalSources( $file, $options );
770        }
771
772        if ( $file->getRepo() instanceof IForeignRepoWithMWApi ) {
773            return static::getRemoteSources( $file, $options );
774        }
775
776        return [];
777    }
778
779    /**
780     * Grabs sources from the remote repo via ApiQueryVideoInfo.php entry point.
781     *
782     * TODO: This method could use some rethinking. See comments on PS1 of
783     *      <https://gerrit.wikimedia.org/r/#/c/117916/>
784     *
785     * Because this works with commons regardless of whether TimedMediaHandler is installed or not
786     * @param File $file The File must belong to a repo that is an instance of IForeignRepoWithMWApi
787     * @param array $options
788     * @return array|mixed
789     */
790    public static function getRemoteSources( $file, $options = [] ) {
791        $regenerator = static function () use ( $file, $options ) {
792            // Setup source attribute options
793            $dataPrefix = in_array( 'nodata', $options, true ) ? '' : 'data-';
794
795            wfDebug( "Get Video sources from remote api for " . $file->getName() . "\n" );
796            $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
797            $query = [
798                'action' => 'query',
799                'prop' => 'videoinfo',
800                'viprop' => 'derivatives',
801                'titles' => $namespaceInfo->getCanonicalName( NS_FILE ) . ':' . $file->getTitle()->getText()
802            ];
803
804            /** @var IForeignRepoWithMWApi $repo */
805            $repo = $file->getRepo();
806            '@phan-var IForeignRepoWithMWApi $repo';
807            $data = $repo->fetchImageQuery( $query );
808
809            if ( isset( $data['warnings']['query'] ) &&
810                $data['warnings']['query']['*'] === "Unrecognized value for parameter 'prop': videoinfo"
811            ) {
812                // The target wiki doesn't have TimedMediaHandler.
813                // Use the normal file repo system single source:
814                return [ static::getPrimarySourceAttributes( $file, [ $dataPrefix ] ) ];
815            }
816
817            $sources = [];
818            // Generate the source list from the data response:
819            if ( isset( $data['query']['pages'] ) ) {
820                $vidResult = array_shift( $data['query']['pages'] );
821                if ( isset( $vidResult['videoinfo'] ) ) {
822                    $derResult = array_shift( $vidResult['videoinfo'] );
823                    $derivatives = $derResult['derivatives'];
824                    foreach ( $derivatives as $derivativeSource ) {
825                        $sources[] = $derivativeSource;
826                    }
827                }
828            }
829
830            return $sources;
831        };
832
833        $repoInfo = $file->getRepo()->getInfo();
834        $cacheTTL = $repoInfo['descriptionCacheExpiry'] ?? 0;
835
836        if ( $cacheTTL > 0 ) {
837            $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
838            $sources = $cache->getWithSetCallback(
839                $cache->makeKey( 'WebVideoSources-url', $file->getRepoName(), $file->getName() ),
840                $cacheTTL,
841                $regenerator
842            );
843        } else {
844            $sources = $regenerator();
845        }
846
847        return $sources;
848    }
849
850    /**
851     * Based on the $wgEnabledTranscodeSet set of enabled derivatives we
852     * return sources that are ready.
853     *
854     * This will not automatically update or queue anything!
855     *
856     * @param File &$file File object
857     * @param array $options Options, a set of options:
858     *         'nodata' Strips the data- attribute, useful when your output is not html
859     * @return array an associative array of sources suitable for <source> tag output
860     */
861    public static function getLocalSources( &$file, $options = [] ) {
862        $sources = [];
863
864        // Add the original file:
865        $sources[] = static::getPrimarySourceAttributes( $file, $options );
866
867        // If $wgEnableTranscode is false don't look for or add other local sources:
868        if ( MediaWikiServices::getInstance()->getMainConfig()->get( 'EnableTranscode' ) === false &&
869            !( $file->repo instanceof IForeignRepoWithDB ) ) {
870            return $sources;
871        }
872
873        // If an "oldFile" don't look for other sources:
874        if ( $file->isOld() ) {
875            return $sources;
876        }
877
878        /** @var ID3Handler $handler */
879        $handler = $file->getHandler();
880        '@phan-var ID3Handler $handler';
881        // Now Check for derivatives
882        if ( $handler->isAudio( $file ) ) {
883            $transcodeSet = static::enabledAudioTranscodes();
884        } else {
885            $transcodeSet = static::enabledVideoTranscodes();
886        }
887
888        $lastHLS = null;
889        foreach ( $transcodeSet as $transcodeKey ) {
890            if ( static::isTranscodeKeyPlayable( $transcodeKey ) &&
891                 static::isTranscodeEnabled( $file, $transcodeKey )
892            ) {
893                // Try and add the source
894                static::addSourceIfReady( $file, $sources, $transcodeKey, $options );
895            }
896            $streaming = static::$derivativeSettings[$transcodeKey]['streaming'] ?? '';
897            if ( $streaming === 'hls' && static::isTranscodeReady( $file, $transcodeKey ) ) {
898                $lastHLS = $transcodeKey;
899            }
900        }
901        if ( $lastHLS ) {
902            $src = static::getTranscodedUrlForFile( $file, 'm3u8' );
903            $settings =& static::$derivativeSettings[$lastHLS];
904            [ $width, $height ] = static::getMaxSizeTransform(
905                $file,
906                $settings['maxSize'] ?? (
907                    implode( 'x', [
908                        $settings['width'] ?? '0',
909                        $settings['height'] ?? '0',
910                    ] )
911                )
912            );
913            $sources[] = [
914                'src' => $src,
915                'title' => wfMessage( 'timedmedia-derivative-desc-m3u8' )->text(),
916                'type' => 'application/vnd.apple.mpegurl',
917                'shorttitle' => wfMessage( 'timedmedia-derivative-desc-m3u8' )->text(),
918                'transcodekey' => 'm3u8',
919                'width' => $width,
920                'height' => $height,
921            ];
922        }
923
924        return $sources;
925    }
926
927    /**
928     * Does this transcode key represent a directly-playable type?
929     * If not it's a backing track for adaptive streaming, and should
930     * not be exposed directly as a downloadable/playable derivative.
931     *
932     * @param string $transcodeKey
933     * @return bool
934     */
935    public static function isTranscodeKeyPlayable( $transcodeKey ) {
936        $settings = static::$derivativeSettings[$transcodeKey] ?? null;
937        if ( !$settings ) {
938            return false;
939        }
940        $streaming = $settings['streaming'] ?? false;
941        return !$streaming;
942    }
943
944    /**
945     * Get the transcode state for a given filename and transcodeKey
946     *
947     * @param File $file
948     * @param string $transcodeKey
949     * @return bool
950     */
951    public static function isTranscodeReady( $file, $transcodeKey ) {
952        // Check if we need to populate the transcodeState cache:
953        $transcodeState = static::getTranscodeState( $file );
954
955        // If no state is found the cache for this file is false:
956        if ( !isset( $transcodeState[ $transcodeKey ] ) ) {
957            return false;
958        }
959        // Else return boolean ready state ( if not null, then ready ):
960        return ( $transcodeState[ $transcodeKey ]['time_success'] ) !== null;
961    }
962
963    /**
964     * Clear the transcode state cache:
965     * @param string|null $fileName Optional fileName to clear transcode cache for
966     */
967    public static function clearTranscodeCache( $fileName = null ) {
968        if ( $fileName ) {
969            unset( static::$transcodeState[ $fileName ] );
970        } else {
971            static::$transcodeState = [];
972        }
973    }
974
975    /**
976     * Populates the transcode table with the current DB state of transcodes
977     * if transcodes are not found in the database their state is set to "false"
978     *
979     * @param File $file File object
980     * @param IReadableDatabase|false $db
981     * @return array[]
982     */
983    public static function getTranscodeState( $file, $db = false ) {
984        $fileName = $file->getName();
985        if ( $db || !isset( static::$transcodeState[$fileName] ) ) {
986            if ( $db === false ) {
987                $db = $file->repo->getReplicaDB();
988            }
989            // initialize the transcode state array
990            static::$transcodeState[ $fileName ] = [];
991            $res = $db->newSelectQueryBuilder()
992                ->select( '*' )
993                ->from( 'transcode' )
994                ->where( [ 'transcode_image_name' => $fileName ] )
995                ->limit( 100 )
996                ->caller( __METHOD__ )
997                ->fetchResultSet();
998
999            // Populate the per transcode state cache
1000            foreach ( $res as $row ) {
1001                // strip the out the "transcode_" from keys
1002                $transcodeState = [];
1003                foreach ( $row as $k => $v ) {
1004                    $transcodeState[ str_replace( 'transcode_', '', $k ) ] = $v;
1005                }
1006                static::$transcodeState[ $fileName ][ $row->transcode_key ] = $transcodeState;
1007            }
1008        }
1009        $sorted = static::$transcodeState[ $fileName ];
1010        uksort( $sorted, 'strnatcmp' );
1011        return $sorted;
1012    }
1013
1014    /**
1015     * Remove any transcode files and db states associated with a given $file
1016     * Note that if you want to see them again, you must re-queue them by calling
1017     * startJobQueue() or updateJobQueue().
1018     *
1019     * also remove the transcode files:
1020     * @param File $file File Object
1021     * @param string|false $transcodeKey Optional transcode key to remove only this key
1022     */
1023    public static function removeTranscodes( $file, $transcodeKey = false ) {
1024        // if transcode key is non-false, non-null:
1025        if ( $transcodeKey ) {
1026            // only remove the requested $transcodeKey
1027            $removeKeys = [ $transcodeKey ];
1028        } else {
1029            // Remove any existing files ( regardless of their state )
1030            $res = $file->repo->getPrimaryDB()->newSelectQueryBuilder()
1031                ->select( 'transcode_key' )
1032                ->from( 'transcode' )
1033                ->where( [ 'transcode_image_name' => $file->getName() ] )
1034                ->caller( __METHOD__ )
1035                ->fetchResultSet();
1036
1037            $removeKeys = [];
1038            foreach ( $res as $transcodeRow ) {
1039                $removeKeys[] = $transcodeRow->transcode_key;
1040            }
1041        }
1042
1043        // Remove files by key:
1044        $urlsToPurge = [];
1045        $filesToPurge = [];
1046        $hasHLS = false;
1047        foreach ( $removeKeys as $tKey ) {
1048            $urlPath = static::getTranscodedUrlForFile( $file, $tKey );
1049            $filePath = static::getDerivativeFilePath( $file, $tKey );
1050            $urlsToPurge[] = $urlPath;
1051            $filesToPurge[] = $filePath;
1052
1053            $options = static::$derivativeSettings[$tKey] ?? [];
1054            $streaming = $options['streaming'] ?? null;
1055            if ( $streaming === 'hls' ) {
1056                $urlsToPurge[] = $urlPath . '.m3u8';
1057                $filesToPurge[] = $filePath . '.m3u8';
1058                $hasHLS = true;
1059            }
1060        }
1061        if ( $hasHLS && $transcodeKey === false ) {
1062            // Delete all derivatives including the main hls manifest
1063            $urlsToPurge[] = static::getTranscodedUrlForFile( $file, 'm3u8' );
1064            $filesToPurge[] = static::getDerivativeFilePath( $file, 'm3u8' );
1065        }
1066        foreach ( $filesToPurge as $filePath ) {
1067            if ( $file->repo->fileExists( $filePath ) ) {
1068                $res = $file->repo->quickPurge( $filePath );
1069                if ( !$res ) {
1070                    wfDebug( "Could not delete file $filePath\n" );
1071                }
1072            }
1073        }
1074
1075        $update = new CdnCacheUpdate( $urlsToPurge );
1076        DeferredUpdates::addUpdate( $update );
1077
1078        // Build the sql query:
1079        $queryBuilder = $file->repo->getPrimaryDB()->newDeleteQueryBuilder()
1080            ->deleteFrom( 'transcode' )
1081            ->where( [ 'transcode_image_name' => $file->getName() ] );
1082        // Check if we are removing a specific transcode key
1083        if ( $transcodeKey !== false ) {
1084            $queryBuilder->andWhere( [ 'transcode_key' => $transcodeKey ] );
1085        }
1086        // Remove the db entries
1087        $queryBuilder->caller( __METHOD__ )->execute();
1088
1089        // Purge the cache for pages that include this video:
1090        $titleObj = $file->getTitle();
1091        static::invalidatePagesWithFile( $titleObj );
1092
1093        // Remove from local WebVideoTranscode cache:
1094        static::clearTranscodeCache( $titleObj->getDBkey() );
1095        if ( $transcodeKey !== false ) {
1096            // We only removed a single transcode, so we need to update the manifests
1097            static::updateStreamingManifests( $file );
1098        }
1099    }
1100
1101    /**
1102     * @param Title $titleObj
1103     */
1104    public static function invalidatePagesWithFile( $titleObj ) {
1105        wfDebug( "WebVideoTranscode:: Invalidate pages that include: " . $titleObj->getDBkey() . "\n" );
1106        // Purge the main image page:
1107        $titleObj->invalidateCache();
1108
1109        // Invalidate cache for all pages using this file
1110        $cacheUpdateJob = HTMLCacheUpdateJob::newForBacklinks(
1111            $titleObj,
1112            'imagelinks',
1113            // TODO add 'causeAgent' => $user->getName()
1114            // and more accurate action
1115            [ 'causeAction' => 'tmh-transcode-update' ]
1116        );
1117        MediaWikiServices::getInstance()->getJobQueueGroup()->lazyPush( $cacheUpdateJob );
1118
1119        // TODO GlobalUsage
1120    }
1121
1122    /**
1123     * Add a source to the sources list if the transcode job is ready
1124     *
1125     * If the source is not found, it will not be used yet...
1126     * Missing transcodes should be added by write tasks, not read tasks!
1127     * @param File $file
1128     * @param array &$sources
1129     * @param string $transcodeKey
1130     * @param array $dataPrefix
1131     */
1132    public static function addSourceIfReady( $file, &$sources, $transcodeKey, $dataPrefix ) {
1133        // Check if the transcode is ready:
1134        if ( static::isTranscodeReady( $file, $transcodeKey ) ) {
1135            $sources[] = static::getDerivativeSourceAttributes( $file, $transcodeKey, $dataPrefix );
1136        }
1137    }
1138
1139    /**
1140     * Get the primary "source" asset used for other derivatives
1141     * @param File $file
1142     * @param array $options
1143     * @return array
1144     */
1145    public static function getPrimarySourceAttributes( $file, $options = [] ) {
1146        $src = in_array( 'fullurl', $options, true ) ? wfExpandUrl( $file->getUrl() ) : $file->getUrl();
1147
1148        /** @var FLACHandler|MIDIHandler|MP3Handler|MP4Handler|OggHandler|WAVHandler $handler */
1149        $handler = $file->getHandler();
1150        '@phan-var FLACHandler|MIDIHandler|MP3Handler|MP4Handler|OggHandler|WAVHandler $handler';
1151        $bitrate = $handler->getBitrate( $file );
1152
1153        $source = [
1154            'src' => $src,
1155            'type' => $handler->getWebType( $file ),
1156            'width' => (int)$file->getWidth(),
1157            'height' => (int)$file->getHeight(),
1158        ];
1159
1160        if ( $bitrate ) {
1161            $source["bandwidth"] = round( $bitrate );
1162        }
1163        return $source;
1164    }
1165
1166    /**
1167     * Get derivative "source" attributes
1168     * @param File $file
1169     * @param string $transcodeKey
1170     * @param array $options
1171     * @return array
1172     * @suppress PhanTypePossiblyInvalidDimOffset
1173     */
1174    public static function getDerivativeSourceAttributes( $file, $transcodeKey, $options = [] ) {
1175        $fileName = $file->getTitle()->getDBkey();
1176
1177        $src = static::getTranscodedUrlForFile( $file, $transcodeKey );
1178
1179        /** @var ID3Handler $handler */
1180        $handler = $file->getHandler();
1181        '@phan-var ID3Handler $handler';
1182        if ( $handler->isAudio( $file ) ) {
1183            $width = $height = 0;
1184        } else {
1185            [ $width, $height ] = static::getMaxSizeTransform(
1186                $file,
1187                static::$derivativeSettings[$transcodeKey]['maxSize']
1188            );
1189        }
1190
1191        // Setup the url src:
1192        $src = in_array( 'fullurl', $options, true ) ? wfExpandUrl( $src ) : $src;
1193        $fields = [
1194            'src' => $src,
1195            'type' => static::$derivativeSettings[ $transcodeKey ][ 'type' ],
1196            'transcodekey' => $transcodeKey,
1197
1198            // Add data attributes per emerging DASH / webTV adaptive streaming attributes
1199            // eventually we will define a manifest xml entry point.
1200            "width" => (int)$width,
1201            "height" => (int)$height,
1202        ];
1203
1204        // a "ready" transcode should have a bitrate:
1205        if ( isset( static::$transcodeState[$fileName] ) ) {
1206            $fields["bandwidth"] = (int)static::$transcodeState[$fileName][$transcodeKey]['final_bitrate'];
1207        }
1208        return $fields;
1209    }
1210
1211    /**
1212     * Queue up all enabled transcodes if missing.
1213     * @param File $file File object
1214     */
1215    public static function startJobQueue( File $file ) {
1216        $keys = static::enabledTranscodes();
1217
1218        // 'Natural sort' puts the transcodes in ascending order by resolution,
1219        // which roughly gives us fastest-to-slowest order.
1220        natsort( $keys );
1221
1222        foreach ( $keys as $tKey ) {
1223            // Note the job queue will de-duplicate and handle various errors, so we
1224            // can just blast out the full list here.
1225            static::updateJobQueue( $file, $tKey );
1226        }
1227    }
1228
1229    /**
1230     * Regenerate the streaming manifests, currently the HLS multivariant playlist,
1231     * to refer to available completed transcodes. If there are no available
1232     * compatible transcodes the playlist will be written out empty.
1233     *
1234     * Simultaneous attempts to overwrite will result in whichever commits to
1235     * the filesystem or other backend last "winning". Locks in the database
1236     * have been known to cause production problems, and a more thorough queueing
1237     * system might be wise to look into later.
1238     *
1239     * @param File $file base file to check for transcodes on
1240     */
1241    public static function updateStreamingManifests( File $file ): Status {
1242        $fileName = $file->getTitle()->getDBkey();
1243        $repo = $file->getRepo();
1244        if ( !is_a( $repo, 'LocalRepo' ) ) {
1245            return Status::newGood();
1246        }
1247        $dbw = $repo->getPrimaryDB();
1248
1249        // Note that trying to use a database lock here plays hell with many
1250        // many scenarios in production, it seems, especially when deleting
1251        // files.
1252        //
1253        // See [T348689](https://phabricator.wikimedia.org/T348689) etc.
1254        //
1255        // To in future: serialize these updates through the job queue
1256        // or something else *clever* and non-destructive in terms of wait
1257        // states.
1258
1259        static::clearTranscodeCache( $fileName );
1260
1261        // Currently only HLS streaming is output.
1262        $m3u8 = "$fileName.m3u8";
1263        $keys = [];
1264        foreach ( static::$derivativeSettings as $key => $settings ) {
1265            $streaming = $settings['streaming'] ?? '';
1266            if ( $streaming === 'hls' && static::isTranscodeReady( $file, $key ) ) {
1267                $keys[] = $key;
1268            }
1269        }
1270        // @todo look up the frame rate and final bitrates and use those
1271        $multivariant = new Multivariant( $fileName, $keys );
1272        $playlist = $multivariant->playlist();
1273
1274        $tmpFileFactory = new TempFSFileFactory();
1275        $tmpFile = $tmpFileFactory->newTempFSFile( $m3u8, 'm3u8' );
1276        if ( !$tmpFile ) {
1277            return Status::newFatal( 'm3u8-error-create-temp', $m3u8 );
1278        }
1279        $result = file_put_contents( $tmpFile->getPath(), $playlist );
1280        if ( $result === false ) {
1281            return Status::newFatal( 'm3u8-error-write-temp', $m3u8 );
1282        }
1283
1284        $result = $repo->quickImport(
1285            $tmpFile,
1286            $file->getTranscodedPath( $m3u8 )
1287        );
1288        return $result;
1289    }
1290
1291    /**
1292     * Make sure all relevant transcodes for the given file are tracked in the
1293     * transcodes table; add entries for any missing ones.
1294     *
1295     * @param File $file File object
1296     */
1297    public static function cleanupTranscodes( File $file ) {
1298        $fileName = $file->getTitle()->getDBkey();
1299        $dbw = $file->repo->getPrimaryDB();
1300
1301        $transcodeState = static::getTranscodeState( $file, $dbw );
1302
1303        $keys = static::enabledTranscodes();
1304        foreach ( $keys as $transcodeKey ) {
1305            if ( !static::isTranscodeEnabled( $file, $transcodeKey ) ) {
1306                // This transcode is no longer enabled or erroneously included...
1307                // Leave it in place, allowing it to be removed manually;
1308                // it won't be used in playback and should be doing no harm.
1309                continue;
1310            }
1311            if ( !isset( $transcodeState[ $transcodeKey ] ) ) {
1312                $dbw->newInsertQueryBuilder()
1313                    ->insertInto( 'transcode' )
1314                    ->ignore()
1315                    ->row( [
1316                        'transcode_image_name' => $fileName,
1317                        'transcode_key' => $transcodeKey,
1318                        // Do not start transcode jobs automatically, as purging is too common.
1319                        'transcode_time_addjob' => null,
1320                        'transcode_error' => '',
1321                        'transcode_final_bitrate' => 0,
1322                    ] )
1323                    ->caller( __METHOD__ )->execute();
1324            }
1325        }
1326
1327        // Remove from local WebVideoTranscode cache:
1328        static::clearTranscodeCache( $fileName );
1329    }
1330
1331    /**
1332     * Check if the given transcode key is appropriate for the file.
1333     *
1334     * @param File $file File object
1335     * @param string $transcodeKey transcode key
1336     * @return bool
1337     * @suppress PhanTypePossiblyInvalidDimOffset
1338     */
1339    public static function isTranscodeEnabled( File $file, $transcodeKey ) {
1340        /** @var FLACHandler|MIDIHandler|MP3Handler|MP4Handler|OggHandler|WAVHandler $handler */
1341        $handler = $file->getHandler();
1342        '@phan-var FLACHandler|MIDIHandler|MP3Handler|MP4Handler|OggHandler|WAVHandler $handler';
1343        $audio = $handler->isAudio( $file );
1344        if ( $audio ) {
1345            $keys = static::enabledAudioTranscodes();
1346        } else {
1347            $keys = static::enabledVideoTranscodes();
1348        }
1349
1350        if ( in_array( $transcodeKey, $keys, true ) ) {
1351            $settings = static::$derivativeSettings[$transcodeKey];
1352            if ( $audio ) {
1353                $sourceCodecs = $handler->getStreamTypes( $file );
1354                $sourceCodec = $sourceCodecs ? strtolower( $sourceCodecs[0] ) : '';
1355                return ( $sourceCodec !== $settings['audioCodec'] );
1356            }
1357            $streaming = $settings['streaming'] ?? false;
1358            $novideo = $settings['novideo'] ?? false;
1359            if ( $streaming && $novideo ) {
1360                // Streaming audio should be generated for all formats
1361                // if audio is present on the file, and for none if not.
1362                return $handler->hasAudio( $file );
1363            }
1364            if ( static::isTargetLargerThanFile( $file, $settings['maxSize'] ?? '' ) ) {
1365                // Are we the smallest enabled transcode for this type?
1366                // Then go ahead and make a wee little transcode for compat.
1367                return static::isSmallestTranscodeForCodec( $transcodeKey );
1368            }
1369            return true;
1370        }
1371        // Transcode key is invalid or has been disabled.
1372        return false;
1373    }
1374
1375    /**
1376     * Update the job queue if the file is not already in the job queue:
1377     * @param File &$file File object
1378     * @param string $transcodeKey transcode key
1379     * @param array $options array with 'manualOverride' or 'remux' boolean options
1380     */
1381    public static function updateJobQueue( &$file, $transcodeKey, $options = [] ) {
1382        $fileName = $file->getTitle()->getDBkey();
1383        $dbw = $file->repo->getPrimaryDB();
1384
1385        $transcodeState = static::getTranscodeState( $file, $dbw );
1386
1387        if ( !static::isTranscodeEnabled( $file, $transcodeKey ) ) {
1388            return;
1389        }
1390
1391        // If the job hasn't been added yet, attempt to do so
1392        if ( !isset( $transcodeState[ $transcodeKey ] ) ) {
1393            $dbw->newInsertQueryBuilder()
1394                ->insertInto( 'transcode' )
1395                ->ignore()
1396                ->row( [
1397                    'transcode_image_name' => $fileName,
1398                    'transcode_key' => $transcodeKey,
1399                    'transcode_time_addjob' => $dbw->timestamp(),
1400                    'transcode_error' => '',
1401                    'transcode_final_bitrate' => 0,
1402                ] )
1403                ->caller( __METHOD__ )->execute();
1404
1405            if ( !$dbw->affectedRows() ) {
1406                // There is already a row for that job added by another request, no need to continue
1407                return;
1408            }
1409
1410            // Set the priority
1411            $prioritized = static::isTranscodePrioritized( $file, $transcodeKey );
1412
1413            $job = new JobSpecification( $prioritized ? 'webVideoTranscodePrioritized' : 'webVideoTranscode', [
1414                'transcodeMode' => 'derivative',
1415                'transcodeKey' => $transcodeKey,
1416                'prioritized' => $prioritized,
1417                'manualOverride' => $options['manualOverride'] ?? false,
1418                'remux' => $options['remux'] ?? false,
1419            ], [], $file->getTitle() );
1420
1421            try {
1422                MediaWikiServices::getInstance()->getJobQueueGroupFactory()->makeJobQueueGroup()->push( $job );
1423                // Clear the state cache ( now that we have updated the page )
1424                static::clearTranscodeCache( $fileName );
1425            } catch ( Exception $ex ) {
1426                // Adding job failed, update transcode row
1427                $dbw->newUpdateQueryBuilder()
1428                    ->update( 'transcode' )
1429                    ->set( [
1430                        'transcode_time_error' => $dbw->timestamp(),
1431                        'transcode_error' => "Failed to insert Job."
1432                    ] )
1433                    ->where( [
1434                        'transcode_image_name' => $fileName,
1435                        'transcode_key' => $transcodeKey,
1436                    ] )
1437                    ->caller( __METHOD__ )
1438                    ->execute();
1439            }
1440        }
1441    }
1442
1443    /**
1444     * Check if this transcode belongs to the high-priority queue.
1445     * @param File $file
1446     * @param string $transcodeKey
1447     * @return bool
1448     */
1449    public static function isTranscodePrioritized( File $file, $transcodeKey ) {
1450        $transcodeHeight = 0;
1451        $matches = [];
1452        if ( preg_match( '/^(\d+)p/', $transcodeKey, $matches ) ) {
1453            $transcodeHeight = (int)$matches[0];
1454        }
1455        $config = MediaWikiServices::getInstance()->getMainConfig();
1456        return ( $transcodeHeight <= $config->get( 'TmhPriorityResolutionThreshold' ) )
1457            && ( $file->getLength() <= $config->get( 'TmhPriorityLengthThreshold' ) );
1458    }
1459
1460    /**
1461     * Return job queue length for the queue that will run this transcode.
1462     * @param File $file
1463     * @param string $transcodeKey
1464     * @return int
1465     */
1466    public static function getQueueSize( File $file, $transcodeKey ) {
1467        // Warning: this won't treat the prioritized queue separately.
1468        $db = $file->repo->getPrimaryDB();
1469        return $db->newSelectQueryBuilder()
1470            ->from( 'transcode' )
1471            ->where( [
1472                'transcode_time_addjob IS NOT NULL',
1473                'transcode_time_startwork IS NULL',
1474                'transcode_time_success IS NULL',
1475                'transcode_time_error IS NULL',
1476            ] )
1477            ->caller( __METHOD__ )
1478            ->fetchRowCount();
1479    }
1480
1481    /**
1482     * Transforms the size per a given "maxSize"
1483     *  if maxSize is > file, file size is used
1484     * @param File $file
1485     * @param string $targetMaxSize
1486     * @return int[]
1487     */
1488    public static function getMaxSizeTransform( $file, $targetMaxSize ) {
1489        $maxSize = static::getMaxSize( $targetMaxSize );
1490        $sourceWidth = (int)$file->getWidth();
1491        $sourceHeight = (int)$file->getHeight();
1492        if ( $sourceHeight === 0 ) {
1493            // Audio file
1494            return [ 0, 0 ];
1495        }
1496        $sourceAspect = $sourceWidth / $sourceHeight;
1497        $targetWidth = $sourceWidth;
1498        $targetHeight = $sourceHeight;
1499        if ( $sourceAspect <= $maxSize['aspect'] ) {
1500            if ( $sourceHeight > $maxSize['height'] ) {
1501                $targetHeight = $maxSize['height'];
1502                $targetWidth = (int)( $targetHeight * $sourceAspect );
1503            }
1504        } else {
1505            if ( $sourceWidth > $maxSize['width'] ) {
1506                $targetWidth = $maxSize['width'];
1507                $targetHeight = (int)( $targetWidth / $sourceAspect );
1508                // some players do not like uneven frame sizes
1509            }
1510        }
1511        // some players do not like uneven frame sizes
1512        $targetWidth += $targetWidth % 2;
1513        $targetHeight += $targetHeight % 2;
1514        return [ $targetWidth, $targetHeight ];
1515    }
1516
1517    /**
1518     * Test if a given transcode target is larger than the source file
1519     *
1520     * @param File &$file File object
1521     * @param string $targetMaxSize
1522     * @return bool
1523     */
1524    public static function isTargetLargerThanFile( &$file, $targetMaxSize ) {
1525        $maxSize = static::getMaxSize( $targetMaxSize );
1526        $sourceWidth = $file->getWidth();
1527        $sourceHeight = $file->getHeight();
1528        $sourceAspect = (int)$sourceWidth / (int)$sourceHeight;
1529        if ( $sourceAspect <= $maxSize['aspect'] ) {
1530            return ( $maxSize['height'] > $sourceHeight );
1531        }
1532        return ( $maxSize['width'] > $sourceWidth );
1533    }
1534
1535    /**
1536     * Is the given transcode key the smallest configured transcode for
1537     * its video codec?
1538     * @param string $transcodeKey
1539     * @return bool
1540     * @suppress PhanTypePossiblyInvalidDimOffset
1541     */
1542    public static function isSmallestTranscodeForCodec( $transcodeKey ) {
1543        $settings = static::$derivativeSettings[$transcodeKey];
1544        $vcodec = $settings['videoCodec'];
1545        $maxSize = static::getMaxSize( $settings['maxSize'] );
1546
1547        foreach ( static::enabledVideoTranscodes() as $tKey ) {
1548            $tsettings = static::$derivativeSettings[$tKey];
1549            if ( isset( $tsettings['novideo'] ) ) {
1550                // This is an audio track for a video streaming set.
1551                // Always generate it.
1552                return true;
1553            }
1554            if ( $tsettings['videoCodec'] === $vcodec ) {
1555                $tmaxSize = static::getMaxSize( $tsettings['maxSize'] );
1556                if ( $tmaxSize['width'] < $maxSize['width'] ) {
1557                    return false;
1558                }
1559                if ( $tmaxSize['height'] < $maxSize['height'] ) {
1560                    return false;
1561                }
1562            }
1563        }
1564
1565        return true;
1566    }
1567
1568    /**
1569     * Return maxSize array for given maxSize setting
1570     *
1571     * @param string $targetMaxSize
1572     * @return array
1573     */
1574    public static function getMaxSize( $targetMaxSize ) {
1575        $maxSize = [];
1576        $targetMaxSize = explode( 'x', $targetMaxSize, 2 );
1577        $maxSize['width'] = (int)$targetMaxSize[0];
1578        if ( count( $targetMaxSize ) === 1 ) {
1579            $maxSize['height'] = (int)$targetMaxSize[0];
1580        } else {
1581            $maxSize['height'] = (int)$targetMaxSize[1];
1582        }
1583        // check for zero size ( audio )
1584        if ( $maxSize['width'] === 0 || $maxSize['height'] === 0 ) {
1585            $maxSize['aspect'] = 0;
1586        } else {
1587            $maxSize['aspect'] = $maxSize['width'] / $maxSize['height'];
1588        }
1589        return $maxSize;
1590    }
1591
1592    /**
1593     * @param array $set
1594     *
1595     * @return array
1596     */
1597    private static function filterAndSort( array $set ) {
1598        $keys = array_keys( array_filter( $set ) );
1599        natsort( $keys );
1600        return $keys;
1601    }
1602
1603    public static function enabledTranscodes() {
1604        $config = MediaWikiServices::getInstance()->getMainConfig();
1605        return static::filterAndSort( array_merge(
1606            $config->get( 'EnabledTranscodeSet' ),
1607            $config->get( 'EnabledAudioTranscodeSet' )
1608        ) );
1609    }
1610
1611    public static function enabledVideoTranscodes() {
1612        $config = MediaWikiServices::getInstance()->getMainConfig();
1613        return static::filterAndSort( $config->get( 'EnabledTranscodeSet' ) );
1614    }
1615
1616    public static function enabledAudioTranscodes() {
1617        $config = MediaWikiServices::getInstance()->getMainConfig();
1618        return static::filterAndSort( $config->get( 'EnabledAudioTranscodeSet' ) );
1619    }
1620
1621    public static function validateTranscodeConfiguration() {
1622        foreach ( static::enabledTranscodes() as $transcodeKey ) {
1623            if ( !isset( static::$derivativeSettings[ $transcodeKey ] ) ) {
1624                throw new ConfigException(
1625                    __METHOD__ . ": Invalid key '$transcodeKey' specified in"
1626                        . " wgEnabledTranscodeSet or wgEnabledAudioTranscodeSet."
1627                );
1628            }
1629        }
1630    }
1631
1632    public static function isBaseMediaFormat( string $extension ): bool {
1633        $isos = [ 'mp4', 'm4v', 'm4a', 'mov', '3gp' ];
1634        return in_array( $extension, $isos );
1635    }
1636
1637    /**
1638     * Expand a bitrate that may have a k/m/g suffix
1639     *
1640     * @param string|int $rate
1641     * @return int
1642     */
1643    public static function expandRate( $rate ) {
1644        if ( is_int( $rate ) ) {
1645            return $rate;
1646        }
1647        $matches = [];
1648        if ( preg_match( '/^(\d+)([kmg])$/', strtolower( $rate ), $matches ) ) {
1649            $n = (int)$matches[1];
1650            switch ( $matches[2] ) {
1651                case 'g':
1652                    $n *= 1000;
1653                    // fall through
1654                case 'm':
1655                    $n *= 1000;
1656                    // fall through
1657                case 'k':
1658                    $n *= 1000;
1659                    break;
1660                default:
1661                    throw new LogicException( "Unexpected size suffix: " . $matches[2] );
1662            }
1663            return $n;
1664        } else {
1665            return (int)$rate;
1666        }
1667    }
1668
1669    /**
1670     * Remove stray transcode table entries that no longer refer to a living
1671     * file. Note this does not remove the backing files, if any.
1672     *
1673     * @param int $batchSize max number of rows to clean in this batch
1674     * @return int number of rows deleted
1675     */
1676    public static function cleanupOrphanedTranscodes( int $batchSize ): int {
1677        $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
1678        $dbw = $lbFactory->getPrimaryDatabase();
1679        $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
1680        $ids = $dbw->newSelectQueryBuilder()
1681            ->select( 'transcode_id' )
1682            ->from( 'transcode' )
1683            ->leftJoin( 'image', null, [ 'img_name = transcode_image_name' ] )
1684            ->where( [ 'img_name' => null ] )
1685            ->limit( $batchSize )
1686            ->caller( __METHOD__ )
1687            ->fetchFieldValues();
1688
1689        if ( count( $ids ) > 0 ) {
1690            $dbw->newDeleteQueryBuilder()
1691                ->delete( 'transcode' )
1692                ->where( [ 'transcode_id' => $ids ] )
1693                ->caller( __METHOD__ )
1694                ->execute();
1695        }
1696        $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
1697        return count( $ids );
1698    }
1699}