*/ private const EXPECTED_IMAGE_TYPES = [ 'gif' => IMAGETYPE_GIF, 'jpeg' => IMAGETYPE_JPEG, 'jpg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, ]; public function needsNormalization(string $path): bool { $expectedType = $this->getExpectedType($path); if ($expectedType === null) { return false; } return $this->getActualType($path) !== $expectedType; } /** * Convert image data in place so that its real format matches its filename extension. * * @throws LocalizedException */ public function normalize(string $path): bool { $expectedType = $this->getExpectedType($path); if ($expectedType === null) { return false; } $actualType = $this->getActualType($path); if ($actualType === $expectedType) { return false; } $createCallback = match ($actualType) { IMAGETYPE_GIF => 'imagecreatefromgif', IMAGETYPE_JPEG => 'imagecreatefromjpeg', IMAGETYPE_PNG => 'imagecreatefrompng', IMAGETYPE_WEBP => 'imagecreatefromwebp', default => null, }; if ($createCallback === null || !is_callable($createCallback)) { throw new LocalizedException( __('Cannot convert unsupported image data in %1.', $path) ); } $sourceImage = @$createCallback($path); $temporaryPath = tempnam(dirname($path), '.esun-media-'); if ($temporaryPath === false) { if ($sourceImage instanceof \GdImage) { imagedestroy($sourceImage); } throw new LocalizedException(__('Unable to create a temporary image beside %1.', $path)); } try { $written = $sourceImage instanceof \GdImage ? $this->writeImage($sourceImage, $temporaryPath, $expectedType) : $this->writeAnimatedWebp($path, $temporaryPath, $actualType, $expectedType); if (!$written) { throw new LocalizedException(__('Unable to encode image %1.', $path)); } $permissions = fileperms($path); if ($permissions !== false) { @chmod($temporaryPath, $permissions & 0777); } if (!@rename($temporaryPath, $path)) { throw new LocalizedException(__('Unable to replace image %1 after conversion.', $path)); } } finally { if ($sourceImage instanceof \GdImage) { imagedestroy($sourceImage); } if (is_file($temporaryPath)) { @unlink($temporaryPath); } } return true; } private function getExpectedType(string $path): ?int { $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); return self::EXPECTED_IMAGE_TYPES[$extension] ?? null; } /** @throws LocalizedException */ private function getActualType(string $path): int { $imageInfo = @getimagesize($path); if ($imageInfo === false || !isset($imageInfo[2])) { throw new LocalizedException(__('Unable to determine the real image format of %1.', $path)); } return (int)$imageInfo[2]; } private function writeImage(\GdImage $sourceImage, string $path, int $expectedType): bool { if ($expectedType === IMAGETYPE_JPEG) { $flattened = imagecreatetruecolor(imagesx($sourceImage), imagesy($sourceImage)); if (!$flattened instanceof \GdImage) { return false; } $white = imagecolorallocate($flattened, 255, 255, 255); imagefill($flattened, 0, 0, $white); imagecopy( $flattened, $sourceImage, 0, 0, 0, 0, imagesx($sourceImage), imagesy($sourceImage) ); $written = imagejpeg($flattened, $path, 90); imagedestroy($flattened); return $written; } if ($expectedType === IMAGETYPE_PNG) { imagealphablending($sourceImage, false); imagesavealpha($sourceImage, true); return imagepng($sourceImage, $path, 6); } return imagegif($sourceImage, $path); } /** * GD cannot decode animated WebP. Use ffmpeg as a fallback and keep animation when the target is GIF. * * @throws LocalizedException */ private function writeAnimatedWebp( string $sourcePath, string $destinationPath, int $actualType, int $expectedType ): bool { if ($actualType !== IMAGETYPE_WEBP) { throw new LocalizedException(__('Unable to decode image %1.', $sourcePath)); } $outputArguments = match ($expectedType) { IMAGETYPE_GIF => ['-f', 'gif'], IMAGETYPE_JPEG => ['-frames:v', '1', '-c:v', 'mjpeg', '-f', 'image2'], IMAGETYPE_PNG => ['-frames:v', '1', '-c:v', 'png', '-f', 'image2'], default => [], }; $command = array_merge( ['ffmpeg', '-v', 'error', '-y', '-i', $sourcePath], $outputArguments, [$destinationPath] ); $process = new Process($command); $process->setTimeout(60); try { $process->mustRun(); } catch (ProcessFailedException $ffmpegException) { @unlink($destinationPath); try { return $this->writeFirstWebpFrame($sourcePath, $destinationPath, $expectedType); } catch (\Throwable $fallbackException) { throw new LocalizedException( __( 'Unable to convert animated WebP image %1. Install ffmpeg or the WebP tools (webpmux and dwebp). %2', $sourcePath, trim($fallbackException->getMessage()) ), $ffmpegException ); } } return is_file($destinationPath) && filesize($destinationPath) > 0; } /** * Extract a static first frame when ffmpeg cannot decode the animated WebP stream. */ private function writeFirstWebpFrame(string $sourcePath, string $destinationPath, int $expectedType): bool { $frameWebp = tempnam(dirname($sourcePath), '.esun-webp-frame-'); $framePng = tempnam(dirname($sourcePath), '.esun-png-frame-'); if ($frameWebp === false || $framePng === false) { if (is_string($frameWebp)) { @unlink($frameWebp); } if (is_string($framePng)) { @unlink($framePng); } throw new LocalizedException(__('Unable to create temporary WebP frame files.')); } $frameImage = null; try { (new Process(['webpmux', '-get', 'frame', '1', $sourcePath, '-o', $frameWebp])) ->setTimeout(60) ->mustRun(); (new Process(['dwebp', $frameWebp, '-o', $framePng])) ->setTimeout(60) ->mustRun(); $frameImage = @imagecreatefrompng($framePng); if (!$frameImage instanceof \GdImage) { throw new LocalizedException(__('Unable to decode the extracted WebP frame.')); } return $this->writeImage($frameImage, $destinationPath, $expectedType); } finally { if ($frameImage instanceof \GdImage) { imagedestroy($frameImage); } @unlink($frameWebp); @unlink($framePng); } } }