cavepaintings/ext/transcode/main.php

517 lines
20 KiB
PHP

<?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
*/
class ImageTranscodeException extends SCoreException
{
}
class TranscodeImage extends Extension
{
/** @var TranscodeImageTheme */
protected ?Themelet $theme;
public const ACTION_BULK_TRANSCODE = "bulk_transcode";
public const INPUT_MIMES = [
"BMP" => MimeType::BMP,
"GIF" => MimeType::GIF,
"ICO" => MimeType::ICO,
"JPG" => MimeType::JPEG,
"PNG" => MimeType::PNG,
"PPM" => MimeType::PPM,
"PSD" => MimeType::PSD,
"TIFF" => MimeType::TIFF,
"WEBP" => MimeType::WEBP,
"TGA" => MimeType::TGA
];
public const OUTPUT_MIMES = [
"" => "",
"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)
{
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);
foreach (array_values(self::INPUT_MIMES) as $mime) {
$config->set_default_string(self::get_mapping_name($mime), "");
}
}
private static function get_mapping_name(string $mime): string
{
$mime = str_replace(".", "_", $mime);
$mime = str_replace("/", "_", $mime);
return TranscodeConfig::UPLOAD_PREFIX.$mime;
}
private static function get_mapping(String $mime): ?string
{
global $config;
return $config->get_string(self::get_mapping_name($mime));
}
private static function set_mapping(String $from_mime, ?String $to_mime): void
{
global $config;
$config->set_string(self::get_mapping_name($from_mime), $to_mime);
}
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)
{
if ($this->get_version(TranscodeConfig::VERSION) < 1) {
$old_extensions =[];
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)
{
global $user, $config;
if ($user->can(Permissions::EDIT_FILES) && $event->context != "report") {
$engine = $config->get_string(TranscodeConfig::ENGINE);
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)
{
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);
foreach (self::INPUT_MIMES as $display=> $mime) {
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);
}
}
$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)
{
global $config;
// 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) {
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);
if (empty($target_mime)) {
return;
}
try {
$new_image = $this->transcode_image($event->tmpname, $event->mime, $target_mime);
$event->set_tmpname($new_image, $target_mime);
} 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->can(Permissions::EDIT_FILES)) {
if ($event->count_args() >= 1) {
$image_id = int_escape($event->get_arg(0));
} elseif (isset($_POST['image_id'])) {
$image_id = int_escape($_POST['image_id']);
} else {
throw new ImageTranscodeException("Can not resize Image: No valid Post ID given.");
}
$image_obj = Image::by_id($image_id);
if (is_null($image_obj)) {
$this->theme->display_error(404, "Post not found", "No image in the database has the ID #$image_id");
} else {
if (isset($_POST['transcode_mime'])) {
try {
$this->transcode_and_replace_image($image_obj, $_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());
}
}
}
}
}
public function onImageDownloading(ImageDownloadingEvent $event)
{
global $config, $user;
if ($config->get_bool(TranscodeConfig::GET_ENABLED) &&
isset($_GET['transcode']) &&
$user->can(Permissions::EDIT_FILES) &&
$this->can_convert_mime($config->get_string(TranscodeConfig::ENGINE), $event->image->get_mime())) {
$target_mime = $_GET['transcode'];
if (!MimeType::is_mime($target_mime)) {
$target_mime = MimeType::get_for_extension($target_mime);
}
if (empty($target_mime)) {
throw new ImageTranscodeException("Unable to determine output MIME for ".$_GET['transcode']);
}
MediaEngine::is_output_supported($config->get_string(TranscodeConfig::ENGINE), $target_mime);
$source_mime = $event->image->get_mime();
if ($source_mime!=$target_mime) {
$tmp_filename = $this->transcode_image($event->path, $source_mime, $target_mime);
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)
{
global $user, $config;
$engine = $config->get_string(TranscodeConfig::ENGINE);
if ($user->can(Permissions::EDIT_FILES)) {
$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)
{
global $user, $database, $page;
switch ($event->action) {
case self::ACTION_BULK_TRANSCODE:
if (!isset($_POST['transcode_mime'])) {
return;
}
if ($user->can(Permissions::EDIT_FILES)) {
$mime = $_POST['transcode_mime'];
$total = 0;
$size_difference = 0;
foreach ($event->items as $image) {
try {
$database->begin_transaction();
$before_size = $image->filesize;
$new_image = $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
$database->commit();
$total++;
$size_difference += ($before_size - $new_image->filesize);
} catch (\Exception $e) {
log_error("transcode", "Error while bulk transcode on item {$image->id} to $mime: ".$e->getMessage());
try {
$database->rollback();
} catch (\Exception $e) {
// is this safe? o.o
}
}
}
if ($size_difference>0) {
$page->flash("Transcoded $total items, reduced size by ".human_filesize($size_difference));
} 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($engine, $mime): bool
{
return MediaEngine::is_input_supported($engine, $mime);
}
private function get_supported_output_mimes($engine, ?String $omit_mime = null): array
{
$output = [];
foreach (self::OUTPUT_MIMES as $key=> $value) {
if ($value=="") {
$output[$key] = $value;
continue;
}
if (MediaEngine::is_output_supported($engine, $value)
&&(empty($omit_mime)||$omit_mime!=$value)) {
$output[$key] = $value;
}
}
return $output;
}
private function transcode_and_replace_image(Image $image_obj, String $target_mime): Image
{
$original_file = warehouse_path(Image::IMAGE_DIR, $image_obj->hash);
$tmp_filename = $this->transcode_image($original_file, $image_obj->get_mime(), $target_mime);
$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;
/* 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));
return $new_image;
}
private function transcode_image(String $source_name, String $source_mime, string $target_mime): string
{
global $config;
if ($source_mime==$target_mime) {
throw new ImageTranscodeException("Source and target MIMEs are the same: ".$source_mime);
}
$engine = $config->get_string(TranscodeConfig::ENGINE);
if (!$this->can_convert_mime($engine, $source_mime)) {
throw new ImageTranscodeException("Engine $engine does not support input MIME $source_mime");
}
if (!MediaEngine::is_output_supported($engine, $target_mime)) {
throw new ImageTranscodeException("Engine $engine does not support output MIME $target_mime");
}
switch ($engine) {
case "gd":
return $this->transcode_image_gd($source_name, $source_mime, $target_mime);
case "convert":
return $this->transcode_image_convert($source_name, $source_mime, $target_mime);
default:
throw new ImageTranscodeException("No engine specified");
}
}
private function transcode_image_gd(String $source_name, String $source_mime, string $target_mime): string
{
global $config;
$q = $config->get_int(TranscodeConfig::QUALITY);
$tmp_name = tempnam(sys_get_temp_dir(), "shimmie_transcode");
$image = imagecreatefromstring(file_get_contents($source_name));
try {
$result = false;
switch ($target_mime) {
case MimeType::WEBP:
$result = imagewebp($image, $tmp_name, $q);
break;
case MimeType::PNG:
$result = imagepng($image, $tmp_name, 9);
break;
case MimeType::JPEG:
// 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 {
$background_color = Media::hex_color_allocate($new_image, $config->get_string(TranscodeConfig::ALPHA_COLOR));
if ($background_color===false) {
throw new ImageTranscodeException("Could not allocate background color");
}
if (imagefilledrectangle($new_image, 0, 0, $width, $height, $background_color)===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;
}
} finally {
imagedestroy($image);
}
if ($result===false) {
throw new ImageTranscodeException("Error while transcoding ".$source_name." to ".$target_mime);
}
return $tmp_name;
}
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");
}
$ext = Media::determine_ext($target_mime);
$args = " -background ";
if (Media::supports_alpha($target_mime)) {
$args .= "none ";
} else {
$args .= "\"".$config->get_string(TranscodeConfig::ALPHA_COLOR)."\" ";
}
$args .= " -flatten ";
switch ($target_mime) {
case MimeType::PNG:
$args .= ' -define png:compression-level=9';
break;
case MimeType::WEBP_LOSSLESS:
$args .= ' -define webp:lossless=true -quality 100 ';
break;
default:
$args .= ' -quality '.$q;
break;
}
$tmp_name = tempnam(sys_get_temp_dir(), "shimmie_transcode");
$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");
if ($ret!==0) {
throw new ImageTranscodeException("Transcoding failed with command ".$cmd.", returning ".implode("\r\n", $output));
}
return $tmp_name;
}
}