237 lines
7.9 KiB
PHP
237 lines
7.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\BlogImport\Model;
|
|
|
|
use Magento\Framework\Exception\LocalizedException;
|
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class MediaNormalizer
|
|
{
|
|
/** @var array<string, int> */
|
|
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);
|
|
}
|
|
}
|
|
}
|