MediaWiki master
WebPHandler.php
Go to the documentation of this file.
1<?php
10namespace MediaWiki\Media;
11
17use Wikimedia\XMPReader\Reader as XMPReader;
18
28 private const BROKEN_FILE = '0';
32 private const MINIMUM_CHUNK_HEADER_LENGTH = 18;
36 private const MAX_METADATA_CHUNK_SIZE = 1024 * 1024 * 2;
40 private const _MW_WEBP_VERSION = 3;
41
42 private const VP8X_ICC = 32;
43 private const VP8X_ALPHA = 16;
44 private const VP8X_EXIF = 8;
45 private const VP8X_XMP = 4;
46 private const VP8X_ANIM = 2;
47
51 private const MIN_ANIM_CHUNK_SIZE = 6;
55 private const MIN_ANMF_CHUNK_SIZE = 16;
56
58 public function getSizeAndMetadata( $state, $filename ) {
59 $parsedWebPData = self::extractMetadata( $filename );
60 if ( !$parsedWebPData ) {
61 return [ 'metadata' => [ '_error' => self::BROKEN_FILE ] ];
62 }
63
64 $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION;
65 $info = [
66 'width' => $parsedWebPData['width'],
67 'height' => $parsedWebPData['height'],
68 'bits' => $parsedWebPData['bits'],
69 'metadata' => $parsedWebPData
70 ];
71 return $info;
72 }
73
75 public function getMetadataType( $image ) {
76 return 'parsed-webp';
77 }
78
80 public function isFileMetadataValid( $image ) {
81 $data = $image->getMetadataArray();
82 if ( $data === [ '_error' => self::BROKEN_FILE ] ) {
83 // Do not repetitively regenerate metadata on broken file.
85 }
86
87 if ( !$data || isset( $data['_error'] ) ) {
88 wfDebug( __METHOD__ . " invalid WebP metadata" );
89
90 return self::METADATA_BAD;
91 }
92
93 if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] )
94 || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION
95 ) {
96 wfDebug( __METHOD__ . " old but compatible WebP metadata" );
97
99 }
100 return self::METADATA_GOOD;
101 }
102
111 public static function extractMetadata( $filename ) {
112 wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename" );
113
114 $info = RiffExtractor::findChunksFromFile( $filename, 100 );
115 if ( $info === false ) {
116 wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file" );
117 return false;
118 }
119
120 if ( $info['fourCC'] !== 'WEBP' ) {
121 wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' .
122 bin2hex( $info['fourCC'] ) );
123 return false;
124 }
125 $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename );
126 if ( !$metadata ) {
127 wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found" );
128 return false;
129 }
130
131 return $metadata;
132 }
133
141 public static function extractMetadataFromChunks( $chunks, $filename ) {
142 $vp8Info = [];
143 $exifData = null;
144 $xmpData = null;
145 $frameCount = 0;
146 $duration = 0;
147 $loopCount = 0;
148
149 foreach ( $chunks as $chunk ) {
150 // Note, spec says it should be 'XMP ' but some real life files use "XMP\0"
151 if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X', 'ANIM', 'ANMF', 'EXIF', 'XMP ', "XMP\0" ] ) ) {
152 // Not a chunk containing interesting metadata
153 continue;
154 }
155
156 $chunkHeader = file_get_contents( $filename, false, null,
157 $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH );
158 wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}" );
159
160 switch ( $chunk['fourCC'] ) {
161 case 'VP8 ':
162 $vp8Info = array_merge( $vp8Info,
163 self::decodeLossyChunkHeader( $chunkHeader ) );
164 break;
165 case 'VP8L':
166 $vp8Info = array_merge( $vp8Info,
167 self::decodeLosslessChunkHeader( $chunkHeader ) );
168 break;
169 case 'VP8X':
170 $vp8Info = array_merge( $vp8Info,
171 self::decodeExtendedChunkHeader( $chunkHeader ) );
172 // Continue looking for other chunks to improve the metadata
173 break;
174 case 'ANIM':
175 $animChunk = self::extractChunk( $chunk, $filename );
176 if ( $animChunk && strlen( $animChunk ) >= self::MIN_ANIM_CHUNK_SIZE ) {
177 $loopCount = unpack( 'v', substr( $animChunk, 4, 2 ) )[1];
178 }
179 break;
180 case 'ANMF':
181 if ( $chunk['size'] >= self::MIN_ANMF_CHUNK_SIZE ) {
182 $anmfChunk = file_get_contents( $filename, false, null,
183 $chunk['start'] + 8, self::MIN_ANMF_CHUNK_SIZE );
184 if ( $anmfChunk !== false && strlen( $anmfChunk ) >= self::MIN_ANMF_CHUNK_SIZE ) {
185 $duration += unpack( 'V', substr( $anmfChunk, 12, 3 ) . "\x00" )[1];
186 $frameCount++;
187 }
188 }
189 break;
190 case 'EXIF':
191 // Spec says ignore all but first one
192 $exifData ??= self::extractChunk( $chunk, $filename );
193 break;
194 case 'XMP ':
195 case "XMP\0":
196 $xmpData ??= self::extractChunk( $chunk, $filename );
197 break;
198 }
199 }
200 if ( $frameCount > 1 ) {
201 $vp8Info['frameCount'] = $frameCount;
202 $vp8Info['duration'] = $duration / 1000.0;
203 $vp8Info['looped'] = $loopCount === 0 || $loopCount > 1;
204 $vp8Info['animated'] = true;
205 }
206 if ( $vp8Info ) {
207 $vp8Info['bits'] = 8;
208 }
209 $vp8Info = array_merge( $vp8Info,
210 self::decodeMediaMetadata( $exifData, $xmpData, $filename ) );
211 return $vp8Info;
212 }
213
222 private static function decodeMediaMetadata( $exifData, $xmpData, $filename ) {
223 if ( $exifData === null && $xmpData === null ) {
224 // Nothing to do
225 return [];
226 }
227 $bitmapMetadataHandler = new BitmapMetadataHandler;
228
229 if ( $xmpData && XMPReader::isSupported() ) {
230 $xmpReader = new XMPReader( LoggerFactory::getInstance( 'XMP' ), $filename );
231 $xmpReader->parse( $xmpData );
232 $res = $xmpReader->getResults();
233 foreach ( $res as $type => $array ) {
234 $bitmapMetadataHandler->addMetadata( $array, $type );
235 }
236 }
237
238 if ( $exifData ) {
239 // The Exif section of a webp file is basically a tiff file without an image.
240 // Some files start with an Exif\0\0. This is wrong according to standard and
241 // will prevent us from reading file, so remove for compatibility.
242 if ( str_starts_with( $exifData, "Exif\x00\x00" ) ) {
243 $exifData = substr( $exifData, 6 );
244 }
245 $tmpFile = MediaWikiServices::getInstance()->
246 getTempFSFileFactory()->
247 newTempFSFile( 'webp-exif_', 'tiff' );
248
249 $exifDataFile = $tmpFile->getPath();
250 file_put_contents( $exifDataFile, $exifData );
251 $byteOrder = BitmapMetadataHandler::getTiffByteOrder( $exifDataFile );
252 $bitmapMetadataHandler->getExif( $exifDataFile, $byteOrder );
253 }
254 return [ 'media-metadata' => $bitmapMetadataHandler->getMetadataArray() ];
255 }
256
262 private static function extractChunk( $chunk, $filename ) {
263 if ( $chunk['size'] > self::MAX_METADATA_CHUNK_SIZE || $chunk['size'] < 1 ) {
264 return null;
265 }
266
267 // Skip first 8 bytes as that is the fourCC header followed by size of chunk.
268 return file_get_contents( $filename, false, null, $chunk['start'] + 8, $chunk['size'] );
269 }
270
276 protected static function decodeLossyChunkHeader( $header ) {
277 // Bytes 0-3 are 'VP8 '
278 // Bytes 4-7 are the VP8 stream size
279 // Bytes 8-10 are the frame tag
280 // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code
281 $syncCode = substr( $header, 11, 3 );
282 if ( $syncCode !== "\x9D\x01\x2A" ) {
283 wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' .
284 bin2hex( $syncCode ) );
285 return [];
286 }
287 // Bytes 14-17 are image size
288 $imageSize = unpack( 'v2', substr( $header, 14, 4 ) );
289 // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here
290 return [
291 'compression' => 'lossy',
292 'width' => $imageSize[1] & 0x3FFF,
293 'height' => $imageSize[2] & 0x3FFF
294 ];
295 }
296
302 public static function decodeLosslessChunkHeader( $header ) {
303 // Bytes 0-3 are 'VP8L'
304 // Bytes 4-7 are chunk stream size
305 // Byte 8 is 0x2F called the signature
306 if ( $header[8] !== "\x2F" ) {
307 wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' .
308 bin2hex( $header[8] ) );
309 return [];
310 }
311 // Bytes 9-12 contain the image size
312 // Bits 0-13 are width-1; bits 14-27 are height-1
313 $imageSize = unpack( 'C4', substr( $header, 9, 4 ) );
314 return [
315 'compression' => 'lossless',
316 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1,
317 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) |
318 ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x0F ) << 10 ) ) + 1
319 ];
320 }
321
327 public static function decodeExtendedChunkHeader( $header ) {
328 // Bytes 0-3 are 'VP8X'
329 // Byte 4-7 are chunk length
330 // Byte 8-11 are a flag bytes
331 $flags = unpack( 'c', substr( $header, 8, 1 ) );
332
333 // Byte 12-17 are image size (24 bits)
334 $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" );
335 $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" );
336
337 return [
338 'compression' => 'unknown',
339 'animated' => ( $flags[1] & self::VP8X_ANIM ) === self::VP8X_ANIM,
340 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) === self::VP8X_ALPHA,
341 'width' => ( $width[1] & 0xFFFFFF ) + 1,
342 'height' => ( $height[1] & 0xFFFFFF ) + 1
343 ];
344 }
345
350 public function canRender( $file ) {
351 return true;
352 }
353
358 public function isAnimatedImage( $image ) {
359 $metadata = $image->getMetadataArray();
360 return !empty( $metadata['animated'] );
361 }
362
367 public function getImageArea( $image ) {
368 $metadata = $image->getMetadataArray();
369 if ( isset( $metadata['frameCount'] ) && $metadata['frameCount'] > 0 ) {
370 return $image->getWidth() * $image->getHeight() * $metadata['frameCount'];
371 }
372 return $image->getWidth() * $image->getHeight();
373 }
374
376 public function canAnimateThumbnail( $file ) {
377 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
378 [ $thumbExt ] = $mainConfig->get( MainConfigNames::WebPThumbnailType );
379 if ( $thumbExt !== 'webp' ) {
380 // Non-WebP thumbnail output can't be animated.
381 return false;
382 }
383
384 $maxAnimatedWebPArea = $mainConfig->get( MainConfigNames::MaxAnimatedWebPArea );
385
386 return $this->getImageArea( $file ) <= $maxAnimatedWebPArea;
387 }
388
390 public function getThumbType( $ext, $mime, $params = null ) {
391 return MediaWikiServices::getInstance()->getMainConfig()
393 }
394
396 protected function hasGDSupport() {
397 return function_exists( 'gd_info' ) && ( gd_info()['WebP Support'] ?? false );
398 }
399
401 public function getCommonMetaArray( File $image ) {
402 $meta = $image->getMetadataArray();
403 return $meta['media-metadata'] ?? [];
404 }
405
407 public function formatMetadata( $image, $context = false ) {
408 $meta = $this->getCommonMetaArray( $image );
409 if ( !$meta ) {
410 return false;
411 }
412
413 return $this->formatMetadataHelper( $meta, $context );
414 }
415
420 public function getLongDesc( $image ) {
421 $lang = $this->getLanguage();
422 $original = parent::getLongDesc( $image );
423
424 $metadata = $image->getMetadataArray();
425
426 if ( !$metadata || isset( $metadata['_error'] ) || empty( $metadata['frameCount'] ) ) {
427 return $original;
428 }
429
430 /* Preserve original image info string, but strip the last char ')' so we can add even more */
431 $info = [];
432 $info[] = $original;
433
434 if ( !empty( $metadata['looped'] ) ) {
435 $info[] = wfMessage( 'file-info-webp-looped' )->inLanguage( $lang )->parse();
436 }
437
438 if ( $metadata['frameCount'] > 1 ) {
439 $info[] = wfMessage( 'file-info-webp-frames' )->numParams( $metadata['frameCount'] )
440 ->inLanguage( $lang )->parse();
441 }
442
443 if ( !empty( $metadata['duration'] ) ) {
444 $info[] = htmlspecialchars( $lang->formatTimePeriod( $metadata['duration'] ), ENT_QUOTES );
445 }
446
447 return $lang->commaList( $info );
448 }
449
456 public function getLength( $file ) {
457 return (float)( $file->getMetadataArray()['duration'] ?? 0.0 );
458 }
459}
460
462class_alias( WebPHandler::class, 'WebPHandler' );
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
getMetadataArray()
Get the unserialized handler-specific metadata STUB.
Definition File.php:831
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const MaxAnimatedWebPArea
Name constant for the MaxAnimatedWebPArea setting, for use with Config::get()
const WebPThumbnailType
Name constant for the WebPThumbnailType setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Generic handler for bitmap images.
static getTiffByteOrder( $filename)
Read the first 2 bytes of a tiff file to figure out Little Endian or Big Endian.
getLanguage()
Get the language to be used by this media handler for text formatting.
formatMetadataHelper( $metadataArray, $context=false)
sorts the visible/invisible field.
Handler for Google's WebP format https://developers.google.com/speed/webp/
getLength( $file)
Return the duration of the WebP file.
isFileMetadataValid( $image)
Check if the metadata is valid for this handler.If it returns MediaHandler::METADATA_BAD (or false),...
getThumbType( $ext, $mime, $params=null)
Get the thumbnail extension and MIME type for a given source MIME type.to overridearray Thumbnail ext...
hasGDSupport()
Whether the php-gd extension supports this type of file.to override bool
formatMetadata( $image, $context=false)
Get an array structure that looks like this:[ 'visible' => [ 'Human-readable name' => 'Human readable...
getMetadataType( $image)
Get a string describing the type of metadata, for display purposes.to overrideThis method is currentl...
static extractMetadata( $filename)
Extracts the image size and WebP type from a file.
static decodeLossyChunkHeader( $header)
Decodes a lossy chunk header.
getCommonMetaArray(File $image)
Get an array of standard (FormatMetadata type) metadata values.The returned data is largely the same ...
static extractMetadataFromChunks( $chunks, $filename)
Extracts the image size and WebP type from a file based on the chunk list.
getSizeAndMetadata( $state, $filename)
Get image size information and metadata array.If this returns null, the caller will fall back to getI...
canAnimateThumbnail( $file)
If the material is animated, we can animate the thumbnail.1.20to overridebool If material is not anim...
static decodeExtendedChunkHeader( $header)
Decodes an extended chunk header.
static decodeLosslessChunkHeader( $header)
Decodes a lossless chunk header.
Extractor for the Resource Interchange File Format.
static findChunksFromFile( $filename, $maxChunks=-1)