This repository has been archived on 2024-09-05. You can view files and clone it, but cannot push or open issues or pull requests.
shimmie2/ext/transcode/main.php

473 lines
18 KiB
PHP
Raw Normal View History

2021-12-14 18:32:47 +00:00
<?php
declare(strict_types=1);
namespace Shimmie2;
require_once "config.php";
/*
* This is used by the image transcoding code when there is an error while transcoding
*/
2019-06-14 12:47:50 +00:00
class ImageTranscodeException extends SCoreException
{
}
class TranscodeImage extends Extension
{
2020-02-04 00:46:36 +00:00
/** @var TranscodeImageTheme */
2023-06-27 14:56:49 +00:00
protected Themelet $theme;
2020-02-04 00:46:36 +00:00
2021-12-14 18:32:47 +00:00
public const ACTION_BULK_TRANSCODE = "bulk_transcode";
2019-06-27 17:26:09 +00:00
2021-12-14 18:32:47 +00:00
public const INPUT_MIMES = [
2020-06-14 16:05:55 +00:00
"BMP" => MimeType::BMP,
"GIF" => MimeType::GIF,
"ICO" => MimeType::ICO,
"JPG" => MimeType::JPEG,
"PNG" => MimeType::PNG,
"PPM" => MimeType::PPM,
2020-06-14 16:05:55 +00:00
"PSD" => MimeType::PSD,
"TIFF" => MimeType::TIFF,
2021-01-16 20:18:11 +00:00
"WEBP" => MimeType::WEBP,
"TGA" => MimeType::TGA
];
2021-12-14 18:32:47 +00:00
public const OUTPUT_MIMES = [
"" => "",
2020-06-14 16:05:55 +00:00
"JPEG (lossy)" => MimeType::JPEG,
"PNG (lossless)" => MimeType::PNG,
"WEBP (lossy)" => MimeType::WEBP,
"WEBP (lossless)" => MimeType::WEBP_LOSSLESS,
];
/**
* Needs to be after upload, but before the processing extensions
*/
public function get_priority(): int
{
return 45;
}
public function onInitExt(InitExtEvent $event): void
{
global $config;
$config->set_default_bool(TranscodeConfig::ENABLED, true);
$config->set_default_bool(TranscodeConfig::GET_ENABLED, false);
$config->set_default_bool(TranscodeConfig::UPLOAD, false);
$config->set_default_string(TranscodeConfig::ENGINE, MediaEngine::GD);
$config->set_default_int(TranscodeConfig::QUALITY, 80);
$config->set_default_string(TranscodeConfig::ALPHA_COLOR, Media::DEFAULT_ALPHA_CONVERSION_COLOR);
2020-06-14 16:05:55 +00:00
foreach (array_values(self::INPUT_MIMES) as $mime) {
$config->set_default_string(self::get_mapping_name($mime), "");
}
}
2020-06-14 16:05:55 +00:00
private static function get_mapping_name(string $mime): string
{
$mime = str_replace(".", "_", $mime);
$mime = str_replace("/", "_", $mime);
return TranscodeConfig::UPLOAD_PREFIX.$mime;
}
2023-08-17 17:12:36 +00:00
private static function get_mapping(string $mime): ?string
2020-06-14 16:05:55 +00:00
{
global $config;
return $config->get_string(self::get_mapping_name($mime));
}
2023-08-17 17:12:36 +00:00
private static function set_mapping(string $from_mime, ?string $to_mime): void
2020-06-14 16:05:55 +00:00
{
global $config;
$config->set_string(self::get_mapping_name($from_mime), $to_mime);
}
/**
* @return string[]
*/
2020-06-14 16:05:55 +00:00
public static function get_enabled_mimes(): array
{
$output = [];
foreach (array_values(self::INPUT_MIMES) as $mime) {
$value = self::get_mapping($mime);
if (!empty($value)) {
$output[] = $mime;
}
}
return $output;
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event): void
2020-06-14 16:05:55 +00:00
{
if ($this->get_version(TranscodeConfig::VERSION) < 1) {
2023-11-11 21:49:12 +00:00
$old_extensions = [];
2020-06-14 16:05:55 +00:00
foreach (array_values(self::INPUT_MIMES) as $mime) {
$old_extensions = array_merge($old_extensions, FileExtension::get_all_for_mime($mime));
}
foreach ($old_extensions as $old_extension) {
$oldValue = $this->get_mapping($old_extension);
if (!empty($oldValue)) {
$from_mime = MimeType::get_for_extension($old_extension);
if (empty($from_mime)) {
continue;
}
$to_mime = MimeType::get_for_extension($oldValue);
if (empty($to_mime)) {
continue;
}
$this->set_mapping($from_mime, $to_mime);
$this->set_mapping($old_extension, null);
}
}
$this->set_version(TranscodeConfig::VERSION, 1);
}
}
public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event): void
{
global $user, $config;
2021-04-25 10:50:27 +00:00
if ($user->can(Permissions::EDIT_FILES) && $event->context != "report") {
$engine = $config->get_string(TranscodeConfig::ENGINE);
2020-06-14 16:05:55 +00:00
if ($this->can_convert_mime($engine, $event->image->get_mime())) {
$options = $this->get_supported_output_mimes($engine, $event->image->get_mime());
$event->add_part($this->theme->get_transcode_html($event->image, $options));
}
}
}
public function onSetupBuilding(SetupBuildingEvent $event): void
{
global $config;
$engine = $config->get_string(TranscodeConfig::ENGINE);
$sb = $event->panel->create_new_block("Image Transcode");
$sb->start_table();
$sb->add_bool_option(TranscodeConfig::ENABLED, "Allow transcoding images", true);
$sb->add_bool_option(TranscodeConfig::GET_ENABLED, "Enable GET args", true);
$sb->add_bool_option(TranscodeConfig::UPLOAD, "Transcode on upload", true);
$sb->add_choice_option(TranscodeConfig::ENGINE, MediaEngine::IMAGE_ENGINES, "Engine", true);
2023-11-11 21:49:12 +00:00
foreach (self::INPUT_MIMES as $display => $mime) {
2020-06-14 16:05:55 +00:00
if (MediaEngine::is_input_supported($engine, $mime)) {
$outputs = $this->get_supported_output_mimes($engine, $mime);
$sb->add_choice_option(self::get_mapping_name($mime), $outputs, "$display", true);
2019-06-14 12:47:50 +00:00
}
}
$sb->add_int_option(TranscodeConfig::QUALITY, "Lossy Format Quality", true);
$sb->add_color_option(TranscodeConfig::ALPHA_COLOR, "Alpha Conversion Color", true);
$sb->end_table();
}
public function onDataUpload(DataUploadEvent $event): void
{
global $config;
2023-03-22 20:08:23 +00:00
// this onDataUpload happens earlier (or could happen earlier) than handle_pixel.onDataUpload
// it mutates the image such that the incorrect mime type is not checked (checking against
// the post-transcode mime type instead). This is to give user feedback on what the mime type
// was before potential transcoding (the original) at the time of upload, and that it failed if not allowed.
// does it break bulk image importing? ZIP? SVG? there are a few flows that are untested!
if ($config->get_bool(UploadConfig::MIME_CHECK_ENABLED) == true) {
$allowed_mimes = $config->get_array(UploadConfig::ALLOWED_MIME_STRINGS);
if (!MimeType::matches_array($event->mime, $allowed_mimes)) {
throw new UploadException("MIME type not supported: " . $event->mime);
}
}
if ($config->get_bool(TranscodeConfig::UPLOAD) == true) {
2023-11-11 21:49:12 +00:00
if ($event->mime === MimeType::GIF && MimeType::is_animated_gif($event->tmpname)) {
return;
}
if (in_array($event->mime, array_values(self::INPUT_MIMES))) {
$target_mime = self::get_mapping($event->mime);
2020-06-14 16:05:55 +00:00
if (empty($target_mime)) {
return;
}
try {
$new_image = $this->transcode_image($event->tmpname, $event->mime, $target_mime);
$event->set_tmpname($new_image, $target_mime);
2023-01-11 11:15:26 +00:00
} catch (\Exception $e) {
2019-06-14 12:47:50 +00:00
log_error("transcode", "Error while performing upload transcode: ".$e->getMessage());
// We don't want to interfere with the upload process,
// so if something goes wrong the untranscoded image jsut continues
}
}
2019-06-14 12:47:50 +00:00
}
}
public function onPageRequest(PageRequestEvent $event): void
{
global $page, $user;
2024-02-11 11:34:09 +00:00
if ($event->page_matches("transcode/{image_id}", method: "POST", permission: Permissions::EDIT_FILES)) {
$image_id = $event->get_iarg('image_id');
$image_obj = Image::by_id_ex($image_id);
try {
$this->transcode_and_replace_image($image_obj, $event->req_POST('transcode_mime'));
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("post/view/".$image_id));
} catch (ImageTranscodeException $e) {
$this->theme->display_transcode_error($page, "Error Transcoding", $e->getMessage());
2019-06-14 12:47:50 +00:00
}
}
}
public function onImageDownloading(ImageDownloadingEvent $event): void
{
global $config, $user;
if ($config->get_bool(TranscodeConfig::GET_ENABLED) &&
2024-02-09 16:36:57 +00:00
isset($event->params['transcode']) &&
$user->can(Permissions::EDIT_FILES) &&
$this->can_convert_mime($config->get_string(TranscodeConfig::ENGINE), $event->image->get_mime())) {
2024-02-09 16:36:57 +00:00
$target_mime = $event->params['transcode'];
if (!MimeType::is_mime($target_mime)) {
$target_mime = MimeType::get_for_extension($target_mime);
}
if (empty($target_mime)) {
2024-02-09 16:36:57 +00:00
throw new ImageTranscodeException("Unable to determine output MIME for ".$event->params['transcode']);
}
MediaEngine::is_output_supported($config->get_string(TranscodeConfig::ENGINE), $target_mime);
$source_mime = $event->image->get_mime();
2023-11-11 21:49:12 +00:00
if ($source_mime != $target_mime) {
$tmp_filename = $this->transcode_image($event->path, $source_mime, $target_mime);
2023-11-11 21:49:12 +00:00
if ($event->file_modified === true && $event->path != $event->image->get_image_filename()) {
// This means that we're dealing with a temp file that will need cleaned up
unlink($event->path);
}
$event->path = $tmp_filename;
$event->mime = $target_mime;
$event->file_modified = true;
}
}
}
public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event): void
{
global $user, $config;
2019-09-29 18:00:51 +00:00
if ($user->can(Permissions::EDIT_FILES)) {
$engine = $config->get_string(TranscodeConfig::ENGINE);
$event->add_action(self::ACTION_BULK_TRANSCODE, "Transcode Image", null, "", $this->theme->get_transcode_picker_html($this->get_supported_output_mimes($engine)));
}
}
public function onBulkAction(BulkActionEvent $event): void
{
global $user, $database, $page;
2019-06-14 12:47:50 +00:00
switch ($event->action) {
case self::ACTION_BULK_TRANSCODE:
if (!isset($event->params['transcode_mime'])) {
return;
}
2019-09-29 18:00:51 +00:00
if ($user->can(Permissions::EDIT_FILES)) {
$mime = $event->params['transcode_mime'];
$total = 0;
$size_difference = 0;
foreach ($event->items as $image) {
try {
2024-01-09 21:59:24 +00:00
$before_size = $image->filesize;
$database->with_savepoint(function () use ($image, $mime) {
$this->transcode_and_replace_image($image, $mime);
});
// If a subsequent transcode fails, the database needs to have everything about the previous
// transcodes recorded already, otherwise the image entries will be stuck pointing to
// missing image files
$total++;
$size_difference += ($before_size - $image->filesize);
2023-01-11 11:15:26 +00:00
} catch (\Exception $e) {
2020-06-14 16:05:55 +00:00
log_error("transcode", "Error while bulk transcode on item {$image->id} to $mime: ".$e->getMessage());
}
}
2023-11-11 21:49:12 +00:00
if ($size_difference > 0) {
2020-02-24 15:30:23 +00:00
$page->flash("Transcoded $total items, reduced size by ".human_filesize($size_difference));
2023-11-11 21:49:12 +00:00
} elseif ($size_difference < 0) {
$page->flash("Transcoded $total items, increased size by ".human_filesize(-1 * $size_difference));
} else {
$page->flash("Transcoded $total items, no size difference");
}
}
break;
}
}
private function can_convert_mime(string $engine, string $mime): bool
{
2020-06-14 16:05:55 +00:00
return MediaEngine::is_input_supported($engine, $mime);
}
/**
* @return array<string, string>
*/
private function get_supported_output_mimes(string $engine, ?string $omit_mime = null): array
{
$output = [];
2023-11-11 21:49:12 +00:00
foreach (self::OUTPUT_MIMES as $key => $value) {
if ($value == "") {
$output[$key] = $value;
continue;
}
2020-06-14 16:05:55 +00:00
if (MediaEngine::is_output_supported($engine, $value)
2023-11-11 21:49:12 +00:00
&& (empty($omit_mime) || $omit_mime != $value)) {
$output[$key] = $value;
2019-06-14 12:47:50 +00:00
}
}
return $output;
}
private function transcode_and_replace_image(Image $image, string $target_mime): void
{
$original_file = warehouse_path(Image::IMAGE_DIR, $image->hash);
$tmp_filename = $this->transcode_image($original_file, $image->get_mime(), $target_mime);
send_event(new ImageReplaceEvent($image, $tmp_filename));
2019-06-14 12:47:50 +00:00
}
2023-08-17 17:12:36 +00:00
private function transcode_image(string $source_name, string $source_mime, string $target_mime): string
{
global $config;
2023-11-11 21:49:12 +00:00
if ($source_mime == $target_mime) {
2020-06-14 16:05:55 +00:00
throw new ImageTranscodeException("Source and target MIMEs are the same: ".$source_mime);
}
$engine = $config->get_string(TranscodeConfig::ENGINE);
2020-06-14 16:05:55 +00:00
if (!$this->can_convert_mime($engine, $source_mime)) {
throw new ImageTranscodeException("Engine $engine does not support input MIME $source_mime");
}
2020-06-14 16:05:55 +00:00
if (!MediaEngine::is_output_supported($engine, $target_mime)) {
throw new ImageTranscodeException("Engine $engine does not support output MIME $target_mime");
}
2019-06-14 12:47:50 +00:00
switch ($engine) {
case "gd":
2020-06-14 16:05:55 +00:00
return $this->transcode_image_gd($source_name, $source_mime, $target_mime);
case "convert":
2020-06-14 16:05:55 +00:00
return $this->transcode_image_convert($source_name, $source_mime, $target_mime);
default:
throw new ImageTranscodeException("No engine specified");
}
}
2023-08-17 17:12:36 +00:00
private function transcode_image_gd(string $source_name, string $source_mime, string $target_mime): string
{
global $config;
$q = $config->get_int(TranscodeConfig::QUALITY);
2024-01-20 20:48:47 +00:00
$tmp_name = shm_tempnam("transcode");
$image = false_throws(imagecreatefromstring(\Safe\file_get_contents($source_name)));
try {
$result = false;
2020-06-14 16:05:55 +00:00
switch ($target_mime) {
case MimeType::WEBP:
$result = imagewebp($image, $tmp_name, $q);
break;
2020-06-14 16:05:55 +00:00
case MimeType::PNG:
$result = imagepng($image, $tmp_name, 9);
break;
2020-06-14 16:05:55 +00:00
case MimeType::JPEG:
// In case of alpha channels
$width = imagesx($image);
$height = imagesy($image);
$new_image = imagecreatetruecolor($width, $height);
2023-11-11 21:49:12 +00:00
if ($new_image === false) {
throw new ImageTranscodeException("Could not create image with dimensions $width x $height");
}
2019-06-14 12:47:50 +00:00
try {
$background_color = Media::hex_color_allocate($new_image, $config->get_string(TranscodeConfig::ALPHA_COLOR));
2023-11-11 21:49:12 +00:00
if (imagefilledrectangle($new_image, 0, 0, $width, $height, $background_color) === false) {
throw new ImageTranscodeException("Could not fill background color");
}
2023-11-11 21:49:12 +00:00
if (imagecopy($new_image, $image, 0, 0, 0, 0, $width, $height) === false) {
throw new ImageTranscodeException("Could not copy source image to new image");
}
$result = imagejpeg($new_image, $tmp_name, $q);
} finally {
imagedestroy($new_image);
}
break;
}
} finally {
imagedestroy($image);
}
2023-11-11 21:49:12 +00:00
if ($result === false) {
2020-06-14 16:05:55 +00:00
throw new ImageTranscodeException("Error while transcoding ".$source_name." to ".$target_mime);
2019-11-04 01:04:08 +00:00
}
return $tmp_name;
}
2023-08-17 17:12:36 +00:00
private function transcode_image_convert(string $source_name, string $source_mime, string $target_mime): string
{
global $config;
$q = $config->get_int(TranscodeConfig::QUALITY);
$convert = $config->get_string(MediaConfig::CONVERT_PATH);
if (empty($convert)) {
throw new ImageTranscodeException("ImageMagick path not configured");
}
2020-06-14 16:05:55 +00:00
$ext = Media::determine_ext($target_mime);
$args = " -background ";
2020-06-14 16:05:55 +00:00
if (Media::supports_alpha($target_mime)) {
$args .= "none ";
} else {
$args .= "\"".$config->get_string(TranscodeConfig::ALPHA_COLOR)."\" ";
2020-06-14 16:05:55 +00:00
}
$args .= " -flatten ";
2020-06-14 16:05:55 +00:00
switch ($target_mime) {
case MimeType::PNG:
$args .= ' -define png:compression-level=9';
break;
2020-06-14 16:05:55 +00:00
case MimeType::WEBP_LOSSLESS:
$args .= ' -define webp:lossless=true -quality 100 ';
break;
default:
2020-06-14 16:05:55 +00:00
$args .= ' -quality '.$q;
break;
}
2024-01-20 20:48:47 +00:00
$tmp_name = shm_tempnam("transcode");
2020-06-14 16:05:55 +00:00
$source_type = FileExtension::get_for_mime($source_mime);
$format = '"%s" %s:"%s" %s %s:"%s" 2>&1';
$cmd = sprintf($format, $convert, $source_type, $source_name, $args, $ext, $tmp_name);
$cmd = str_replace("\"convert\"", "convert", $cmd); // quotes are only needed if the path to convert contains a space; some other times, quotes break things, see github bug #27
exec($cmd, $output, $ret);
log_debug('transcode', "Transcoding with command `$cmd`, returns $ret");
2023-11-11 21:49:12 +00:00
if ($ret !== 0) {
throw new ImageTranscodeException("Transcoding failed with command ".$cmd.", returning ".implode("\r\n", $output));
}
return $tmp_name;
}
}