Files
esun3d/app/code/Esun/BlogImport/Model/MediaNormalizer.php
T
2026-09-15 10:35:56 +08:00

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);
}
}
}