2025-03-25 15:01:14 +01:00
|
|
|
<?php
|
|
|
|
namespace Vichan\Service\Media;
|
|
|
|
|
|
|
|
use Vichan\Data\ThumbGenerationResult;
|
|
|
|
use Vichan\Functions\{Fs, Mime};
|
|
|
|
|
|
|
|
|
|
|
|
class LibMagickMediaHandler implements MediaHandler {
|
|
|
|
// getImageAlphaChannel requires Imagick >= 2.3.0 and ImageMagick >= 6.4.0
|
|
|
|
private const MIN_IMAGICK_VERSION = '2.3.0';
|
|
|
|
private const MIN_IMAGEMAGICK_VERSION = '6.4.0';
|
|
|
|
|
|
|
|
public const THUMB_KEEP_FRAMES_NO = 0;
|
|
|
|
public const THUMB_KEEP_FRAMES_ALL = -1;
|
|
|
|
|
|
|
|
|
|
|
|
private bool $strip_metadata;
|
|
|
|
private bool $frames_for_gif_thumbs;
|
|
|
|
|
|
|
|
|
|
|
|
private static function degreesFromOrientation(int $orientation): int {
|
|
|
|
switch ($orientation) {
|
|
|
|
case \Imagick::ORIENTATION_UNDEFINED:
|
|
|
|
case \Imagick::ORIENTATION_TOPLEFT:
|
|
|
|
case \Imagick::ORIENTATION_TOPRIGHT:
|
|
|
|
default:
|
|
|
|
return 0;
|
|
|
|
case \Imagick::ORIENTATION_BOTTOMRIGHT:
|
|
|
|
case \Imagick::ORIENTATION_BOTTOMLEFT:
|
|
|
|
return 180;
|
|
|
|
case \Imagick::ORIENTATION_LEFTTOP:
|
|
|
|
case \Imagick::ORIENTATION_RIGHTTOP:
|
|
|
|
return 90;
|
|
|
|
case \Imagick::ORIENTATION_RIGHTBOTTOM:
|
|
|
|
case \Imagick::ORIENTATION_LEFTBOTTOM:
|
|
|
|
return 270;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public static function isFlippedFromOrientation(int $orientation): bool {
|
|
|
|
switch ($orientation) {
|
|
|
|
case \Imagick::ORIENTATION_UNDEFINED:
|
|
|
|
case \Imagick::ORIENTATION_TOPLEFT:
|
|
|
|
default:
|
|
|
|
return false;
|
|
|
|
case \Imagick::ORIENTATION_TOPRIGHT:
|
|
|
|
return true;
|
|
|
|
case \Imagick::ORIENTATION_BOTTOMRIGHT:
|
|
|
|
return false;
|
|
|
|
case \Imagick::ORIENTATION_BOTTOMLEFT:
|
|
|
|
return true;
|
|
|
|
case \Imagick::ORIENTATION_LEFTTOP:
|
|
|
|
return false;
|
|
|
|
case \Imagick::ORIENTATION_RIGHTTOP:
|
|
|
|
return true;
|
|
|
|
case \Imagick::ORIENTATION_RIGHTBOTTOM:
|
|
|
|
return false;
|
|
|
|
case \Imagick::ORIENTATION_LEFTBOTTOM:
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @return bool Returns if width and height were swapped.
|
|
|
|
*/
|
|
|
|
private static function adjustOrientation(\Imagick $imagick) {
|
|
|
|
$orientation = $imagick->getImageOrientation();
|
|
|
|
$degrees = self::degreesFromOrientation($orientation);
|
|
|
|
$flipped = self::isFlippedFromOrientation($orientation);
|
|
|
|
|
|
|
|
if ($degrees !== 0) {
|
|
|
|
// Used to return an int, make it always a bool.
|
|
|
|
$has_alpha = !!$imagick->getImageAlphaChannel();
|
|
|
|
$background = $has_alpha ? '#0000' : '#000';
|
|
|
|
$imagick->rotateImage($background, $degrees);
|
|
|
|
}
|
|
|
|
if ($flipped) {
|
|
|
|
$imagick->flopImage();
|
|
|
|
}
|
|
|
|
return $degrees == 0 || $degrees == 180;
|
|
|
|
}
|
|
|
|
|
2025-03-25 15:15:09 +01:00
|
|
|
private function generateThumbImpl(
|
|
|
|
\Imagick $imagick,
|
|
|
|
string $source_file_mime,
|
|
|
|
string $preferred_out_file_dir,
|
|
|
|
string $preferred_out_file_name,
|
|
|
|
string $preferred_out_mime,
|
|
|
|
int $width,
|
|
|
|
int $height,
|
|
|
|
int $max_width,
|
|
|
|
int $max_height
|
|
|
|
) {
|
2025-03-25 21:22:05 +01:00
|
|
|
// Special handling for gifs with multiple frames.
|
2025-03-25 15:15:09 +01:00
|
|
|
if (
|
|
|
|
$source_file_mime === 'image/gif'
|
|
|
|
&& $this->frames_for_gif_thumbs !== self::THUMB_KEEP_FRAMES_NO
|
|
|
|
&& $imagick->getNumberImages() > 1
|
|
|
|
) {
|
|
|
|
$out_path = $preferred_out_file_dir . \DIRECTORY_SEPARATOR . $preferred_out_file_name . '.gif';
|
|
|
|
|
|
|
|
if ($width > $max_width || $height > $max_height) {
|
|
|
|
$thumb_width = $max_width;
|
|
|
|
$thumb_height = $max_height;
|
|
|
|
} else {
|
|
|
|
$thumb_width = $width;
|
|
|
|
$thumb_height = $height;
|
|
|
|
}
|
|
|
|
|
2025-03-25 21:22:05 +01:00
|
|
|
// By now $this->frames_for_gif_thumbs !== 0.
|
|
|
|
$step = \floor($imagick->getNumberImages() / $this->frames_for_gif_thumbs);
|
|
|
|
|
|
|
|
if ($this->frames_for_gif_thumbs !== self::THUMB_KEEP_FRAMES_ALL && $step > 1) {
|
|
|
|
// Reduce the number of frames.
|
|
|
|
|
2025-03-25 15:15:09 +01:00
|
|
|
$other = new \Imagick();
|
|
|
|
try {
|
|
|
|
$other->setFormat('gif');
|
|
|
|
|
|
|
|
for ($i = 0, $j = 0; $i < $imagick->getNumberImages(); $i += $step, $j++) {
|
|
|
|
$imagick->setIteratorIndex($i);
|
|
|
|
$delay = $imagick->getImageDelay();
|
|
|
|
|
|
|
|
$imagick->sampleImage($thumb_width, $thumb_height);
|
|
|
|
$imagick->setImagePage($thumb_width, $thumb_height, 0, 0);
|
|
|
|
$imagick->setImageDelay($delay);
|
|
|
|
|
|
|
|
$other->addImage($imagick->getImage());
|
|
|
|
}
|
|
|
|
|
|
|
|
$other->optimizeImageLayers();
|
|
|
|
|
|
|
|
$other->setImageCompressionQuality(70);
|
|
|
|
$other->writeImage("gif:$out_path");
|
|
|
|
} finally {
|
|
|
|
$other->clear();
|
|
|
|
}
|
|
|
|
} else {
|
2025-03-25 21:22:05 +01:00
|
|
|
// Just try to optimize it a little.
|
2025-03-25 15:15:09 +01:00
|
|
|
$imagick->stripImage();
|
|
|
|
$imagick->optimizeImageLayers();
|
|
|
|
|
|
|
|
$imagick->setImageCompressionQuality(70);
|
|
|
|
$imagick->writeImage("gif:$out_path");
|
|
|
|
}
|
|
|
|
|
|
|
|
return new ThumbGenerationResult(
|
|
|
|
$out_path,
|
|
|
|
'image/gif',
|
|
|
|
false,
|
|
|
|
$thumb_width,
|
|
|
|
$thumb_height
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
if ($width > $max_width || $height > $max_height) {
|
|
|
|
$thumb_width = $max_width;
|
|
|
|
$thumb_height = $max_height;
|
|
|
|
|
2025-03-25 21:22:05 +01:00
|
|
|
// Unreliable behavior on some versions if the target width/height are under the limit?
|
2025-03-25 15:15:09 +01:00
|
|
|
$imagick->thumbnailImage($max_width, $max_height, true);
|
|
|
|
} else {
|
|
|
|
$thumb_width = $width;
|
|
|
|
$thumb_height = $height;
|
|
|
|
}
|
|
|
|
|
|
|
|
$out_ext = Mime\mime_to_ext($preferred_out_mime);
|
|
|
|
$out_path = $preferred_out_file_dir . \DIRECTORY_SEPARATOR . $preferred_out_file_name . '.' . $out_ext;
|
|
|
|
|
|
|
|
$imagick->stripImage();
|
|
|
|
$imagick->setImageCompressionQuality(70);
|
|
|
|
$imagick->writeImage("$out_ext:$out_path");
|
|
|
|
|
|
|
|
return new ThumbGenerationResult(
|
|
|
|
$out_path,
|
|
|
|
$preferred_out_mime,
|
|
|
|
false,
|
|
|
|
$thumb_width,
|
|
|
|
$thumb_height
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-03-25 21:22:05 +01:00
|
|
|
/**
|
|
|
|
* Checks if the Imagick and ImageMagick are recent enough to support the required features.
|
|
|
|
* @return bool
|
|
|
|
*/
|
2025-03-25 15:01:14 +01:00
|
|
|
public static function checkImagickVersion(): bool {
|
|
|
|
$imagick_ver = \phpversion('imagick');
|
|
|
|
if ($imagick_ver !== false && \version_compare($imagick_ver, self::MIN_IMAGICK_VERSION, '>=')) {
|
|
|
|
$str = \Imagick::getVersion()['versionString'];
|
|
|
|
if (\preg_match('/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $str, $matches)) {
|
|
|
|
return \version_compare($matches[1], self::MIN_IMAGEMAGICK_VERSION, '>=');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
public function __construct(bool $strip_metadata, int $frames_for_gif_thumbs) {
|
|
|
|
$this->strip_metadata = $strip_metadata;
|
|
|
|
$this->frames_for_gif_thumbs = $frames_for_gif_thumbs;
|
|
|
|
}
|
|
|
|
|
|
|
|
public function supportsMime(string $mime): bool {
|
|
|
|
$ext = Mime\mime_to_ext($mime);
|
|
|
|
if ($ext === null) {
|
|
|
|
return false;
|
|
|
|
}
|
2025-03-25 15:17:32 +01:00
|
|
|
$ext = \strtoupper($ext);
|
2025-03-25 15:01:14 +01:00
|
|
|
|
2025-03-25 15:17:32 +01:00
|
|
|
return !empty(\Imagick::queryFormats("$ext*"));
|
2025-03-25 15:01:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public function openHandle(string $file_path, string $file_mime, int $file_kind): mixed {
|
|
|
|
$ext = Mime\mime_to_ext($file_mime);
|
|
|
|
$path = \realpath($file_path);
|
|
|
|
// Open it as the supplied mime type.
|
|
|
|
$imagick = new \Imagick("$ext:$path");
|
|
|
|
|
|
|
|
return [ $imagick, $file_path, $file_mime, $file_kind ];
|
|
|
|
}
|
|
|
|
|
|
|
|
public function closeHandle(mixed $handle) {
|
|
|
|
$handle[0]->clear();
|
|
|
|
}
|
|
|
|
|
|
|
|
public function generateThumb(
|
|
|
|
mixed $handle,
|
|
|
|
string $preferred_out_file_dir,
|
|
|
|
string $preferred_out_file_name,
|
|
|
|
string $preferred_out_mime,
|
|
|
|
int $max_width,
|
|
|
|
int $max_height
|
|
|
|
): ThumbGenerationResult {
|
|
|
|
list($imagick, $source_file_path, $source_file_mime, $source_file_kind) = $handle;
|
|
|
|
|
|
|
|
$width = $imagick->getImageWidth();
|
|
|
|
$height = $imagick->getImageHeight();
|
|
|
|
|
|
|
|
if (!$this->strip_metadata && $width <= $max_width && $height <= $max_height) {
|
|
|
|
$out_path = $preferred_out_file_dir . \DIRECTORY_SEPARATOR . $preferred_out_file_name . '.' . Mime\mime_to_ext($source_file_mime);
|
|
|
|
|
|
|
|
if ($source_file_kind === self::FILE_KIND_UPLOADED) {
|
|
|
|
if (!Fs\move_or_copy_uploaded($source_file_path, $out_path)) {
|
|
|
|
throw new \RuntimeException("Could not move or copy uploaded file '$source_file_path' to '$out_path'");
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (!Fs\link_or_copy($source_file_path, $out_path)) {
|
|
|
|
throw new \RuntimeException("Could not link or copy '$source_file_path' to '$out_path'");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return new ThumbGenerationResult(
|
|
|
|
$out_path,
|
|
|
|
$source_file_mime,
|
|
|
|
false,
|
|
|
|
$max_width,
|
|
|
|
$max_height
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
$swap = self::adjustOrientation($imagick);
|
|
|
|
if ($swap) {
|
|
|
|
$tmp = $width;
|
|
|
|
$width = $height;
|
|
|
|
$height = $tmp;
|
|
|
|
}
|
|
|
|
|
2025-03-25 15:15:09 +01:00
|
|
|
return self::generateThumbImpl(
|
|
|
|
$imagick,
|
|
|
|
$source_file_mime,
|
|
|
|
$preferred_out_file_dir,
|
|
|
|
$preferred_out_file_name,
|
|
|
|
$preferred_out_mime,
|
|
|
|
$width,
|
|
|
|
$height,
|
|
|
|
$max_width,
|
|
|
|
$max_height
|
|
|
|
);
|
2025-03-25 15:01:14 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public function installMediaAndGenerateThumb(
|
|
|
|
mixed $handle,
|
|
|
|
string $media_preferred_out_file_dir,
|
|
|
|
string $media_preferred_out_file_name,
|
|
|
|
string $thumb_preferred_out_file_dir,
|
|
|
|
string $thumb_preferred_out_file_name,
|
|
|
|
string $thumb_preferred_out_mime,
|
|
|
|
int $thumb_max_width,
|
|
|
|
int $thumb_max_height
|
|
|
|
): ThumbGenerationResult {
|
2025-03-25 21:22:05 +01:00
|
|
|
list($imagick, $media_file_path, $media_file_mime, $media_file_kind) = $handle;
|
|
|
|
|
|
|
|
$width = $imagick->getImageWidth();
|
|
|
|
$height = $imagick->getImageHeight();
|
|
|
|
|
|
|
|
if (!$this->strip_metadata) {
|
|
|
|
$media_out_path = $media_preferred_out_file_dir . \DIRECTORY_SEPARATOR . $media_preferred_out_file_name . '.' . Mime\mime_to_ext($media_file_mime);
|
|
|
|
|
|
|
|
if ($media_file_kind === self::FILE_KIND_UPLOADED) {
|
|
|
|
if (!Fs\move_or_copy_uploaded($media_file_path, $media_out_path)) {
|
|
|
|
throw new \RuntimeException("Could not move or copy uploaded file '$media_file_path' to '$media_out_path'");
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (!Fs\link_or_copy($media_file_path, $media_out_path)) {
|
|
|
|
throw new \RuntimeException("Could not link or copy '$media_file_path' to '$media_out_path'");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if ($width <= $thumb_max_width && $height >= $thumb_max_height) {
|
|
|
|
$thumb_out_path = $thumb_preferred_out_file_dir . \DIRECTORY_SEPARATOR . '.' . Mime\mime_to_ext($media_file_mime);
|
|
|
|
if (!Fs\link_or_copy($media_out_path, $thumb_out_path)) {
|
|
|
|
throw new \RuntimeException("Could not link or copy '$media_out_path' to '$thumb_out_path'");
|
|
|
|
}
|
|
|
|
|
|
|
|
return new ThumbGenerationResult($thumb_out_path, $media_file_mime, false, $width, $height);
|
|
|
|
} else {
|
|
|
|
$swap = self::adjustOrientation($imagick);
|
|
|
|
if ($swap) {
|
|
|
|
$tmp = $width;
|
|
|
|
$width = $height;
|
|
|
|
$height = $tmp;
|
|
|
|
}
|
|
|
|
|
|
|
|
return self::generateThumbImpl(
|
|
|
|
$imagick,
|
|
|
|
$media_file_mime,
|
|
|
|
$thumb_preferred_out_file_dir,
|
|
|
|
$thumb_preferred_out_file_name,
|
|
|
|
$thumb_preferred_out_mime,
|
|
|
|
$width,
|
|
|
|
$height,
|
|
|
|
$thumb_max_width,
|
|
|
|
$thumb_max_height
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
$swap = self::adjustOrientation($imagick);
|
|
|
|
if ($swap) {
|
|
|
|
$tmp = $width;
|
|
|
|
$width = $height;
|
|
|
|
$height = $tmp;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Backup the color profile, then re-apply it.
|
|
|
|
$profiles = $imagick->getImageProfiles('icc', true);
|
|
|
|
$imagick->stripImage();
|
|
|
|
if (!empty($profiles)) {
|
|
|
|
$imagick->profileImage('icc', $profiles['icc']);
|
|
|
|
}
|
|
|
|
|
|
|
|
$out_ext = Mime\mime_to_ext($media_file_mime);
|
|
|
|
$out_path = $media_preferred_out_file_dir . \DIRECTORY_SEPARATOR . $media_preferred_out_file_name . '.' . $out_ext;
|
|
|
|
$imagick->writeImage("$out_ext:$out_path");
|
|
|
|
|
|
|
|
return self::generateThumbImpl(
|
|
|
|
$imagick,
|
|
|
|
$media_file_mime,
|
|
|
|
$thumb_preferred_out_file_dir,
|
|
|
|
$thumb_preferred_out_file_name,
|
|
|
|
$thumb_preferred_out_mime,
|
|
|
|
$width,
|
|
|
|
$height,
|
|
|
|
$thumb_max_width,
|
|
|
|
$thumb_max_height
|
|
|
|
);
|
|
|
|
}
|
2025-03-25 15:01:14 +01:00
|
|
|
}
|
|
|
|
}
|