Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.68% covered (success)
98.68%
75 / 76
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Multivariant
98.68% covered (success)
98.68%
75 / 76
85.71% covered (warning)
85.71%
6 / 7
23
0.00% covered (danger)
0.00%
0 / 1
 isStreamingAudio
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isStreamingVideo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hlsCodec
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 quote
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 m3uLine
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 playlist
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
12
1<?php
2/**
3 * Multivariant playlist generator
4 *
5 * @file
6 * @ingroup HLS
7 */
8
9namespace MediaWiki\TimedMediaHandler\HLS;
10
11use MediaWiki\TimedMediaHandler\WebVideoTranscode\WebVideoTranscode;
12use RuntimeException;
13
14/**
15 * Generator for HLS multivariant playlists, which refer to
16 * various tracks of different types/features/languages/etc
17 *
18 * The client then selects from available video tracks, and
19 * picks matching audio.
20 *
21 * The HLS extended .m3u8 playlist format is described in
22 * informative RFC 8216.
23 */
24class Multivariant {
25    private string $filename;
26    private array $tracks;
27
28    private const CODEC_JPEG = 'jpeg';
29    private const CODEC_MPEG4 = 'mp4v.20.5';
30    private const CODEC_MP3  = 'mp4a.6b';
31    private const CODEC_OPUS = 'Opus';
32    private const MIME_MP3 = 'audio/mpeg';
33
34    public static function isStreamingAudio( array $options ): bool {
35        $streaming = $options['streaming'] ?? '';
36        $novideo = $options['novideo'] ?? null;
37        return $streaming === 'hls' && $novideo;
38    }
39
40    public static function isStreamingVideo( array $options ): bool {
41        $streaming = $options['streaming'] ?? '';
42        $noaudio = $options['noaudio'] ?? null;
43        return $streaming === 'hls' && $noaudio;
44    }
45
46    /**
47     * Internal map of codec string types we use to HLS-friendly
48     * MPEG-4 style codec name mappings.
49     * @var array
50     */
51    private static array $hlsCodecMap = [
52        'opus' => self::CODEC_OPUS,
53    ];
54
55    public static function hlsCodec( array $options ): string {
56        $type = $options['type'] ?? '';
57        $matches = [];
58        if ( preg_match( '/^\w+\/\w+;\s*codecs="(.*?)"/', $type, $matches ) ) {
59            // Warning: assumes a single track, single codec for streaming!
60            $codec = $matches[1];
61            return self::$hlsCodecMap[ $codec ] ?? $codec;
62        }
63        if ( $type === self::MIME_MP3 ) {
64            // MPEG-1 layer 3
65            return self::CODEC_MP3;
66        }
67        throw new RuntimeException( "Invalid streaming codec definition for type: $type" );
68    }
69
70    /**
71     * Validates and quotes a string for an extended m3u8
72     * attribute list.
73     *
74     * Per RFC 8216 4.2 Attribute Lists
75     * https://datatracker.ietf.org/doc/html/rfc8216#section-4.2
76     *
77     * quoted-string: a string of characters within a pair of double
78     * quotes (0x22).  The following characters MUST NOT appear in a
79     * quoted-string: line feed (0xA), carriage return (0xD), or double
80     * quote (0x22).  Quoted-string AttributeValues SHOULD be constructed
81     * so that byte-wise comparison is sufficient to test two quoted-
82     * string AttributeValues for equality.  Note that this implies case-
83     * sensitive comparison.
84     *
85     * Note there is no provision given for escaping the forbidden chars;
86     * rather than throwing an exception we'll simply strip them. The most
87     * likely place this is to come up is translations of language or
88     * format names.
89     */
90    public static function quote( string $val ): string {
91        $val = str_replace(
92            [ "\r\n", "\r", "\n", "\"" ],
93            [ " ", " ", " ", "'" ],
94            $val
95        );
96        return "\"$val\"";
97    }
98
99    private static function m3uLine( string $type, array $opts ): string {
100        $items = [];
101        foreach ( $opts as $key => $val ) {
102            $items[] = "$key=$val";
103        }
104        return "#$type:" . implode( ",", $items );
105    }
106
107    public function __construct( string $filename, array $tracks ) {
108        $this->filename = $filename;
109        $this->tracks = $tracks;
110    }
111
112    /**
113     * @return string m3u8 output
114     */
115    public function playlist(): string {
116        $out = [ '#EXTM3U' ];
117
118        $audio = [];
119        foreach ( $this->tracks as $key ) {
120            $options = WebVideoTranscode::$derivativeSettings[$key] ?? [];
121            if ( !self::isStreamingAudio( $options ) ) {
122                continue;
123            }
124            $codec = self::hlsCodec( $options );
125            $audio[$key] = $codec;
126            // max ?
127            $channels = (string)( $options['channels'] ?? 2 );
128            $audioFile = wfUrlencode( "$this->filename.$key.m3u8" );
129
130            $name = wfMessage( 'timedmedia-derivative-' . $key )->text();
131
132            $out[] = self::m3uLine( 'EXT-X-MEDIA', [
133                'TYPE' => 'AUDIO',
134                'GROUP-ID' => self::quote( $key ),
135                'NAME' => self::quote( $name ),
136                'AUTOSELECT' => 'YES',
137                'DEFAULT' => 'YES',
138                'CHANNELS' => self::quote( $channels ),
139                'URI' => self::quote( $audioFile ),
140            ] );
141        }
142
143        foreach ( $this->tracks as $key ) {
144            $options = WebVideoTranscode::$derivativeSettings[$key] ?? [];
145            if ( !self::isStreamingVideo( $options ) ) {
146                continue;
147            }
148            $codec = self::hlsCodec( $options );
149            $bandwidth = WebVideoTranscode::expandRate( $options['videoBitrate'] ?? '0' );
150            $resolution = $options['maxSize'] ?? (
151                ( $options['width'] ?? '0' ) .
152                'x' .
153                ( $options['height'] ?? '0' )
154            );
155            // max
156            $videoFile = wfUrlencode( "$this->filename.$key.m3u8" );
157
158            $base = [
159                'BANDWIDTH' => $bandwidth,
160                'RESOLUTION' => $resolution,
161            ];
162            if ( count( $audio ) ) {
163                foreach ( $audio as $audioKey => $audioCodec ) {
164                    $line = $base;
165                    if ( !( $codec === self::CODEC_JPEG || $codec === self::CODEC_MPEG4 )
166                        || ( $audioCodec !== self::CODEC_MP3 )
167                    ) {
168                        // Backwards-compatibility hack for iOS 10-15
169                        // Until iOS 16, the system HLS player was very picky
170                        // about what codecs you passed in for filtering even
171                        // if it would play several like jpeg, h263, and mp4v
172                        // that it didn't allow listing.
173                        // iOS 16 and later allow these and vp09 if they're
174                        // supported by the system.
175                        // Our higher-resolution better-bandwidth VP9 tracks
176                        // will always take precedence on newer, supporting
177                        // devices.
178                        $line['CODECS'] = self::quote( "$codec,$audioCodec" );
179                    }
180                    $line['AUDIO'] = self::quote( $audioKey );
181                    $out[] = self::m3uLine( 'EXT-X-STREAM-INF', $line );
182                    $out[] = $videoFile;
183                }
184            } else {
185                $line = $base;
186                if ( !( $codec === self::CODEC_JPEG || $codec === self::CODEC_MPEG4 ) ) {
187                    // Backwards-compatibility hack for iOS 10-15, see above.
188                    $line['CODECS'] = self::quote( $codec );
189                }
190                $out[] = self::m3uLine( 'EXT-X-STREAM-INF', $line );
191                $out[] = $videoFile;
192            }
193        }
194        return implode( "\n", $out );
195    }
196}