* 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. */ class TranscodeConfig { const ENGINE = "transcode_engine"; const ENABLED = "transcode_enabled"; const UPLOAD = "transcode_upload"; const UPLOAD_PREFIX = "transcode_upload_"; const QUALITY = "transcode_quality"; } /* * This is used by the image transcoding code when there is an error while transcoding */ class ImageTranscodeException extends SCoreException { } class TranscodeImage extends Extension { const ACTION_BULK_TRANSCODE = "bulk_transcode"; 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", "ico", ] ]; 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", "ICO" => "ico", "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", ]; /** * Needs 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->start_table(); $sb->add_bool_option(TranscodeConfig::ENABLED, "Allow transcoding images: ", true); $sb->add_bool_option(TranscodeConfig::UPLOAD, "Transcode on upload: ", true); $sb->add_choice_option(TranscodeConfig::ENGINE, self::CONVERSION_ENGINES, "Engine", true); 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(TranscodeConfig::UPLOAD_PREFIX.$format, $outputs, "$display", true); } } $sb->add_int_option(TranscodeConfig::QUALITY, "Lossy format quality: "); $sb->end_table(); $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(PageMode::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(self::ACTION_BULK_TRANSCODE, "Transcode", null,"", $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(Image::IMAGE_DIR, $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(Image::IMAGE_DIR, $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 = "black"; break; } $tmp_name = tempnam("/tmp", "shimmie_transcode"); $source_type = ""; switch ($source_format) { case "ico": $source_type = "ico:"; } $format = '"%s" %s -quality %u -background %s %s"%s" %s:"%s" 2>&1'; $cmd = sprintf($format, $convert, $args, $q, $bg, $source_type, $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.", returning ".implode("\r\n", $output)); } return $tmp_name; } }