Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.68% |
75 / 76 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
Multivariant | |
98.68% |
75 / 76 |
|
85.71% |
6 / 7 |
23 | |
0.00% |
0 / 1 |
isStreamingAudio | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
isStreamingVideo | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
hlsCodec | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
quote | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
m3uLine | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
playlist | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
12 |
1 | <?php |
2 | /** |
3 | * Multivariant playlist generator |
4 | * |
5 | * @file |
6 | * @ingroup HLS |
7 | */ |
8 | |
9 | namespace MediaWiki\TimedMediaHandler\HLS; |
10 | |
11 | use MediaWiki\TimedMediaHandler\WebVideoTranscode\WebVideoTranscode; |
12 | use 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 | */ |
24 | class 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 | } |