diff --git a/core/polyfills.php b/core/polyfills.php index e82dae7b..4b07aac7 100644 --- a/core/polyfills.php +++ b/core/polyfills.php @@ -265,7 +265,7 @@ const MIME_TYPE_MAP = [ 'avi' => 'video/x-msvideo', 'mpg' => 'video/mpeg', 'mpeg' => 'video/mpeg', 'mov' => 'video/quicktime', 'flv' => 'video/x-flv', 'php' => 'text/x-php', 'mp4' => 'video/mp4', 'ogv' => 'video/ogg', 'webm' => 'video/webm', - 'webp' => 'image/webp' + 'webp' => 'image/webp', 'bmp' =>'image/x-ms-bmp' ]; /** diff --git a/ext/transcode/main.php b/ext/transcode/main.php new file mode 100644 index 00000000..f71ce76a --- /dev/null +++ b/ext/transcode/main.php @@ -0,0 +1,459 @@ + + * Description: Allows admins to automatically and manually transcode images. + * License: MIT + * Version: 1.0 + * Documentation: + * Can transcode on-demand and automatically on upload. Config screen allows choosing an output format for each of the supported input formats. + * Supports GD and ImageMagick. Both support bmp, gif, jpg, png, and webp as inputs, and jpg, png, and lossy webp as outputs. + * ImageMagick additionally supports tiff and psd inputs, and webp lossless output. + * If and image is uanble to be transcoded for any reason, the upload will continue unaffected. + */ + + /* + * This is used by the image transcoding code when there is an error while transcoding + */ +class ImageTranscodeException extends SCoreException{ } + + +class TranscodeImage extends Extension +{ + const CONVERSION_ENGINES = [ + "GD" => "gd", + "ImageMagick" => "convert", + ]; + + const ENGINE_INPUT_SUPPORT = [ + "gd" => [ + "bmp", + "gif", + "jpg", + "png", + "webp", + ], + "convert" => [ + "bmp", + "gif", + "jpg", + "png", + "psd", + "tiff", + "webp", + ] + ]; + + const ENGINE_OUTPUT_SUPPORT = [ + "gd" => [ + "jpg", + "png", + "webp-lossy", + ], + "convert" => [ + "jpg", + "png", + "webp-lossy", + "webp-lossless", + ] + ]; + + const LOSSLESS_FORMATS = [ + "webp-lossless", + "png", + ]; + + const INPUT_FORMATS = [ + "BMP" => "bmp", + "GIF" => "gif", + "JPG" => "jpg", + "PNG" => "png", + "PSD" => "psd", + "TIFF" => "tiff", + "WEBP" => "webp", + ]; + + const FORMAT_ALIASES = [ + "tif" => "tiff", + "jpeg" => "jpg", + ]; + + const OUTPUT_FORMATS = [ + "" => "", + "JPEG (lossy)" => "jpg", + "PNG (lossless)" => "png", + "WEBP (lossy)" => "webp-lossy", + "WEBP (lossless)" => "webp-lossless", + ]; + + /** + * Need to be after upload, but before the processing extensions + */ + public function get_priority(): int + { + return 45; + } + + + public function onInitExt(InitExtEvent $event) + { + global $config; + $config->set_default_bool('transcode_enabled', true); + $config->set_default_bool('transcode_upload', false); + $config->set_default_string('transcode_engine', "gd"); + $config->set_default_int('transcode_quality', 80); + + foreach(array_values(self::INPUT_FORMATS) as $format) { + $config->set_default_string('transcode_upload_'.$format, ""); + } + } + + public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event) + { + global $user, $config; + + if ($user->is_admin() && $config->get_bool("resize_enabled")) { + $engine = $config->get_string("transcode_engine"); + if($this->can_convert_format($engine,$event->image->ext)) { + $options = $this->get_supported_output_formats($engine, $event->image->ext); + $event->add_part($this->theme->get_transcode_html($event->image, $options)); + } + } + } + + public function onSetupBuilding(SetupBuildingEvent $event) + { + global $config; + + $engine = $config->get_string("transcode_engine"); + + + $sb = new SetupBlock("Image Transcode"); + $sb->add_bool_option("transcode_enabled", "Allow transcoding images: "); + $sb->add_bool_option("transcode_upload", "
Transcode on upload: "); + $sb->add_choice_option('transcode_engine',self::CONVERSION_ENGINES,"
Transcode engine: "); + foreach(self::INPUT_FORMATS as $display=>$format) { + if(in_array($format, self::ENGINE_INPUT_SUPPORT[$engine])) { + $outputs = $this->get_supported_output_formats($engine, $format); + $sb->add_choice_option('transcode_upload_'.$format,$outputs,"
$display to: "); + } + } + $sb->add_int_option("transcode_quality", "
Lossy format quality: "); + $event->panel->add_block($sb); + } + + public function onDataUpload(DataUploadEvent $event) + { + global $config, $page; + + if ($config->get_bool("transcode_upload") == true) { + $ext = strtolower($event->type); + + $ext = $this->clean_format($ext); + + if($event->type=="gif"&&is_animated_gif($event->tmpname)) { + return; + } + + if(in_array($ext, array_values(self::INPUT_FORMATS))) { + $target_format = $config->get_string("transcode_upload_".$ext); + if(empty($target_format)) { + return; + } + try { + $new_image = $this->transcode_image($event->tmpname, $ext, $target_format); + $event->set_type($this->determine_ext($target_format)); + $event->set_tmpname($new_image); + } catch(Exception $e) { + 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 + } + } + } + } + + + + public function onPageRequest(PageRequestEvent $event) + { + global $page, $user; + + if ($event->page_matches("transcode") && $user->is_admin()) { + $image_id = int_escape($event->get_arg(0)); + if (empty($image_id)) { + $image_id = isset($_POST['image_id']) ? int_escape($_POST['image_id']) : null; + } + // Try to get the image ID + if (empty($image_id)) { + throw new ImageTranscodeException("Can not resize Image: No valid Image ID given."); + } + $image_obj = Image::by_id($image_id); + if (is_null($image_obj)) { + $this->theme->display_error(404, "Image not found", "No image in the database has the ID #$image_id"); + } else { + if (isset($_POST['transcode_format'])) { + + try { + $this->transcode_and_replace_image($image_obj, $_POST['transcode_format']); + $page->set_mode("redirect"); + $page->set_redirect(make_link("post/view/".$image_id)); + } catch (ImageTranscodeException $e) { + $this->theme->display_transcode_error($page, "Error Transcoding", $e->getMessage()); + } + } + } + } + } + + + public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event) + { + global $user, $config; + + $engine = $config->get_string("transcode_engine"); + + if ($user->is_admin()) { + $event->add_action("bulk_transcode","Transcode","",$this->theme->get_transcode_picker_html($this->get_supported_output_formats($engine))); + } + + } + + public function onBulkAction(BulkActionEvent $event) + { + global $user, $database; + + switch($event->action) { + case "bulk_transcode": + if (!isset($_POST['transcode_format'])) { + return; + } + if ($user->is_admin()) { + $format = $_POST['transcode_format']; + $total = 0; + foreach ($event->items as $id) { + try { + $database->beginTransaction(); + $image = Image::by_id($id); + if($image==null) { + continue; + } + + $this->transcode_and_replace_image($image, $format); + // If a subsequent transcode fails, the database need to have everything about the previous transcodes recorded already, + // otherwise the image entries will be stuck pointing to missing image files + $database->commit(); + $total++; + } catch(Exception $e) { + log_error("transcode", "Error while bulk transcode on item $id to $format: ".$e->getMessage()); + try { + $database->rollback(); + } catch (Exception $e) {} + } + } + flash_message("Transcoded $total items"); + + } + break; + } + } + + private function clean_format($format): ?string { + if(array_key_exists($format, self::FORMAT_ALIASES)) { + return self::FORMAT_ALIASES[$format]; + } + return $format; + } + + private function can_convert_format($engine, $format): bool + { + $format = $this->clean_format($format); + if(!in_array($format, self::ENGINE_INPUT_SUPPORT[$engine])) { + return false; + } + return true; + } + + private function get_supported_output_formats($engine, ?String $omit_format = null): array + { + $omit_format = $this->clean_format($omit_format); + $output = []; + foreach(self::OUTPUT_FORMATS as $key=>$value) { + if($value=="") { + $output[$key] = $value; + continue; + } + if(in_array($value, self::ENGINE_OUTPUT_SUPPORT[$engine]) + &&(empty($omit_format)||$omit_format!=$this->determine_ext($value))) { + $output[$key] = $value; + } + } + return $output; + } + + private function determine_ext(String $format): String + { + switch($format) { + case "webp-lossless": + case "webp-lossy": + return "webp"; + default: + return $format; + } + } + + private function transcode_and_replace_image(Image $image_obj, String $target_format) + { + $target_format = $this->clean_format($target_format); + $original_file = warehouse_path("images", $image_obj->hash); + + $tmp_filename = $this->transcode_image($original_file, $image_obj->ext, $target_format); + + $new_image = new Image(); + $new_image->hash = md5_file($tmp_filename); + $new_image->filesize = filesize($tmp_filename); + $new_image->filename = $image_obj->filename; + $new_image->width = $image_obj->width; + $new_image->height = $image_obj->height; + $new_image->ext = $this->determine_ext($target_format); + + /* Move the new image into the main storage location */ + $target = warehouse_path("images", $new_image->hash); + if (!@copy($tmp_filename, $target)) { + throw new ImageTranscodeException("Failed to copy new image file from temporary location ({$tmp_filename}) to archive ($target)"); + } + + /* Remove temporary file */ + @unlink($tmp_filename); + + send_event(new ImageReplaceEvent($image_obj->id, $new_image)); + + } + + + private function transcode_image(String $source_name, String $source_format, string $target_format): string + { + global $config; + + if($source_format==$this->determine_ext($target_format)) { + throw new ImageTranscodeException("Source and target formats are the same: ".$source_format); + } + + $engine = $config->get_string("transcode_engine"); + + + + if(!$this->can_convert_format($engine,$source_format)) { + throw new ImageTranscodeException("Engine $engine does not support input format $source_format"); + } + if(!in_array($target_format, self::ENGINE_OUTPUT_SUPPORT[$engine])) { + throw new ImageTranscodeException("Engine $engine does not support output format $target_format"); + } + + switch($engine) { + case "gd": + return $this->transcode_image_gd($source_name, $source_format, $target_format); + case "convert": + return $this->transcode_image_convert($source_name, $source_format, $target_format); + } + + } + + private function transcode_image_gd(String $source_name, String $source_format, string $target_format): string + { + global $config; + + $q = $config->get_int("transcode_quality"); + + $tmp_name = tempnam("/tmp", "shimmie_transcode"); + + $image = imagecreatefromstring(file_get_contents($source_name)); + try { + $result = false; + switch($target_format) { + case "webp-lossy": + $result = imagewebp($image, $tmp_name, $q); + break; + case "png": + $result = imagepng($image, $tmp_name, 9); + break; + case "jpg": + // In case of alpha channels + $width = imagesx($image); + $height = imagesy($image); + $new_image = imagecreatetruecolor($width, $height); + if($new_image===false) { + throw new ImageTranscodeException("Could not create image with dimensions $width x $height"); + } + try{ + $black = imagecolorallocate($new_image, 0, 0, 0); + if($black===false) { + throw new ImageTranscodeException("Could not allocate background color"); + } + if(imagefilledrectangle($new_image, 0, 0, $width, $height, $black)===false) { + throw new ImageTranscodeException("Could not fill background color"); + } + 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; + } + if($result===false) { + throw new ImageTranscodeException("Error while transcoding ".$source_name." to ".$target_format); + } + return $tmp_name; + } finally { + imagedestroy($image); + } + } + + private function transcode_image_convert(String $source_name, String $source_format, string $target_format): string + { + global $config; + + $q = $config->get_int("transcode_quality"); + $convert = $config->get_string("thumb_convert_path"); + + if($convert==null||$convert=="") + { + throw new ImageTranscodeException("ImageMagick path not configured"); + } + $ext = $this->determine_ext($target_format); + + $args = "-flatten"; + $bg = "none"; + switch($target_format) { + case "webp-lossless": + $args = '-define webp:lossless=true'; + break; + case "webp-lossy": + $args = ''; + break; + case "png": + $args = '-define png:compression-level=9'; + break; + default: + $bg = "white"; + break; + } + $tmp_name = tempnam("/tmp", "shimmie_transcode"); + + $format = '"%s" %s -quality %u -background %s "%s" %s:"%s"'; + $cmd = sprintf($format, $convert, $args, $q, $bg, $source_name, $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"); + + if($ret!==0) { + throw new ImageTranscodeException("Transcoding failed with command ".$cmd); + } + + return $tmp_name; + } + +} diff --git a/ext/transcode/theme.php b/ext/transcode/theme.php new file mode 100644 index 00000000..24ecc613 --- /dev/null +++ b/ext/transcode/theme.php @@ -0,0 +1,41 @@ +id}"), 'POST')." + + ".$this->get_transcode_picker_html($options)." +
+ + "; + + return $html; + } + + public function get_transcode_picker_html(array $options) { + $html = ""; + + } + + public function display_transcode_error(Page $page, string $title, string $message) + { + $page->set_title("Transcode Image"); + $page->set_heading("Transcode Image"); + $page->add_block(new NavBlock()); + $page->add_block(new Block($title, $message)); + } + +}