* Description: Provides common functions and settings used for media operations. * License: MIT * Version: 1.0 */ /* * This is used by the media code when there is an error */ abstract class MediaConfig { const FFMPEG_PATH = "media_ffmpeg_path"; const FFPROBE_PATH = "media_ffprobe_path"; const CONVERT_PATH = "media_convert_path"; const VERSION = "ext_media_version"; const MEM_LIMIT = 'media_mem_limit'; } abstract class MediaEngine { public const GD = "gd"; public const IMAGICK = "convert"; public const FFMPEG = "ffmpeg"; public const ALL = [ MediaEngine::GD, MediaEngine::FFMPEG, MediaEngine::IMAGICK ]; public const OUTPUT_SUPPORT = [ MediaEngine::GD => [ "gif", "jpg", "png", "webp", Media::WEBP_LOSSY, ], MediaEngine::IMAGICK => [ "gif", "jpg", "png", "webp", Media::WEBP_LOSSY, Media::WEBP_LOSSLESS, ], MediaEngine::FFMPEG => [ ] ]; public const INPUT_SUPPORT = [ MediaEngine::GD => [ "bmp", "gif", "jpg", "png", "webp", ], MediaEngine::IMAGICK => [ "bmp", "gif", "jpg", "png", "psd", "tiff", "webp", "ico", ], MediaEngine::FFMPEG => [ "avi", "mkv", "webm", "mp4", "mov", "flv" ] ]; } class MediaException extends SCoreException { } class MediaResizeEvent extends Event { public $engine; public $input_path; public $input_type; public $output_path; public $target_format; public $target_width; public $target_height; public $target_quality; public $minimize; public $ignore_aspect_ratio; public $allow_upscale; public function __construct(String $engine, string $input_path, string $input_type, string $output_path, int $target_width, int $target_height, bool $ignore_aspect_ratio = false, string $target_format = null, int $target_quality = 80, bool $minimize = false, bool $allow_upscale = true) { assert(in_array($engine, MediaEngine::ALL)); $this->engine = $engine; $this->input_path = $input_path; $this->input_type = $input_type; $this->output_path = $output_path; $this->target_height = $target_height; $this->target_width = $target_width; $this->target_format = $target_format; $this->target_quality = $target_quality; $this->minimize = $minimize; $this->ignore_aspect_ratio = $ignore_aspect_ratio; $this->allow_upscale = $allow_upscale; } } class MediaCheckLosslessEvent extends Event { public $file_name; public $ext; public $result = false; public function __construct(string $file_name, string $ext) { $this->file_name = $file_name; $this->ext = $ext; } } class Media extends Extension { const WEBP_LOSSY = "webp-lossy"; const WEBP_LOSSLESS = "webp-lossless"; const IMAGE_MEDIA_ENGINES = [ "GD" => MediaEngine::GD, "ImageMagick" => MediaEngine::IMAGICK, ]; const LOSSLESS_FORMATS = [ self::WEBP_LOSSLESS, "png", ]; const ALPHA_FORMATS = [ self::WEBP_LOSSLESS, self::WEBP_LOSSY, "png", ]; const FORMAT_ALIASES = [ "tif" => "tiff", "jpeg" => "jpg", ]; //RIFF####WEBPVP8?..............ANIM private const WEBP_ANIMATION_HEADER = [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 0x41, 0x4E, 0x49, 0x4D]; //RIFF####WEBPVP8L private const WEBP_LOSSLESS_HEADER = [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4C]; static function imagick_available(): bool { return extension_loaded("imagick"); } /** * High priority just so that it can be early in the settings */ public function get_priority(): int { return 30; } public function onInitExt(InitExtEvent $event) { global $config; $config->set_default_string(MediaConfig::FFPROBE_PATH, 'ffprobe'); $config->set_default_int(MediaConfig::MEM_LIMIT, parse_shorthand_int('8MB')); $config->set_default_string(MediaConfig::FFMPEG_PATH, ''); $config->set_default_string(MediaConfig::CONVERT_PATH, 'convert'); if ($config->get_int(MediaConfig::VERSION) < 1) { $current_value = $config->get_string("thumb_ffmpeg_path"); if (!empty($current_value)) { $config->set_string(MediaConfig::FFMPEG_PATH, $current_value); } elseif ($ffmpeg = shell_exec((PHP_OS == 'WINNT' ? 'where' : 'which') . ' ffmpeg')) { //ffmpeg exists in PATH, check if it's executable, and if so, default to it instead of static if (is_executable(strtok($ffmpeg, PHP_EOL))) { $config->set_default_string(MediaConfig::FFMPEG_PATH, 'ffmpeg'); } } $current_value = $config->get_string("thumb_convert_path"); if (!empty($current_value)) { $config->set_string(MediaConfig::CONVERT_PATH, $current_value); } elseif ($convert = shell_exec((PHP_OS == 'WINNT' ? 'where' : 'which') . ' convert')) { //ffmpeg exists in PATH, check if it's executable, and if so, default to it instead of static if (is_executable(strtok($convert, PHP_EOL))) { $config->set_default_string(MediaConfig::CONVERT_PATH, 'convert'); } } $current_value = $config->get_int("thumb_mem_limit"); if (!empty($current_value)) { $config->set_int(MediaConfig::MEM_LIMIT, $current_value); } $config->set_int(MediaConfig::VERSION, 1); log_info("media", "extension installed"); } } public function onSetupBuilding(SetupBuildingEvent $event) { $sb = new SetupBlock("Media Engines"); // if (self::imagick_available()) { // try { // $image = new Imagick(realpath('tests/favicon.png')); // $image->clear(); // $sb->add_label("ImageMagick detected"); // } catch (ImagickException $e) { // $sb->add_label("ImageMagick not detected"); // } // } else { $sb->add_text_option(MediaConfig::CONVERT_PATH, "convert command: "); // } $sb->add_text_option(MediaConfig::FFMPEG_PATH, "
ffmpeg command: "); $sb->add_shorthand_int_option(MediaConfig::MEM_LIMIT, "
Max memory use: "); $event->panel->add_block($sb); } /** * @param MediaResizeEvent $event * @throws MediaException * @throws InsufficientMemoryException */ public function onMediaResize(MediaResizeEvent $event) { switch ($event->engine) { case MediaEngine::GD: $info = getimagesize($event->input_path); if ($info === false) { throw new MediaException("getimagesize failed for " . $event->input_path); } self::image_resize_gd( $event->input_path, $info, $event->target_width, $event->target_height, $event->output_path, $event->target_format, $event->ignore_aspect_ratio, $event->target_quality, $event->allow_upscale); break; case MediaEngine::IMAGICK: // if (self::imagick_available()) { // } else { self::image_resize_convert( $event->input_path, $event->input_type, $event->target_width, $event->target_height, $event->output_path, $event->target_format, $event->ignore_aspect_ratio, $event->target_quality, $event->minimize, $event->allow_upscale); //} break; default: throw new MediaException("Engine not supported for resize: " . $event->engine); } // TODO: Get output optimization tools working better // if ($config->get_bool("thumb_optim", false)) { // exec("jpegoptim $outname", $output, $ret); // } } public function onMediaCheckLossless(MediaCheckLosslessEvent $event) { switch ($event->ext) { case "png": case "psd": case "bmp": case "gif": case "ico": $event->result = true; break; case "webp": $event->result = Media::is_lossless_webp($event->file_name); break; } } /** * Check Memory usage limits * * Old check: $memory_use = (filesize($image_filename)*2) + ($width*$height*4) + (4*1024*1024); * New check: $memory_use = $width * $height * ($bits_per_channel) * channels * 2.5 * * It didn't make sense to compute the memory usage based on the NEW size for the image. ($width*$height*4) * We need to consider the size that we are GOING TO instead. * * The factor of 2.5 is simply a rough guideline. * http://stackoverflow.com/questions/527532/reasonable-php-memory-limit-for-image-resize * * @param array $info The output of getimagesize() for the source file in question. * @return int The number of bytes an image resize operation is estimated to use. */ public static function calc_memory_use(array $info): int { if (isset($info['bits']) && isset($info['channels'])) { $memory_use = ($info[0] * $info[1] * ($info['bits'] / 8) * $info['channels'] * 2.5) / 1024; } else { // If we don't have bits and channel info from the image then assume default values // of 8 bits per color and 4 channels (R,G,B,A) -- ie: regular 24-bit color $memory_use = ($info[0] * $info[1] * 1 * 4 * 2.5) / 1024; } return (int)$memory_use; } /** * Creates a thumbnail using ffmpeg. * * @param $hash * @return bool true if successful, false if not. * @throws MediaException */ public static function create_thumbnail_ffmpeg($hash): bool { global $config; $ffmpeg = $config->get_string(MediaConfig::FFMPEG_PATH); if ($ffmpeg == null || $ffmpeg == "") { throw new MediaException("ffmpeg command configured"); } $inname = warehouse_path(Image::IMAGE_DIR, $hash); $outname = warehouse_path(Image::THUMBNAIL_DIR, $hash); $orig_size = self::video_size($inname); $scaled_size = get_thumbnail_size($orig_size[0], $orig_size[1], true); $codec = "mjpeg"; $quality = $config->get_int(ImageConfig::THUMB_QUALITY); if ($config->get_string(ImageConfig::THUMB_TYPE) == "webp") { $codec = "libwebp"; } else { // mjpeg quality ranges from 2-31, with 2 being the best quality. $quality = floor(31 - (31 * ($quality / 100))); if ($quality < 2) { $quality = 2; } } $args = [ escapeshellarg($ffmpeg), "-y", "-i", escapeshellarg($inname), "-vf", "thumbnail,scale={$scaled_size[0]}:{$scaled_size[1]}", "-f", "image2", "-vframes", "1", "-c:v", $codec, "-q:v", $quality, escapeshellarg($outname), ]; $cmd = escapeshellcmd(implode(" ", $args)); exec($cmd, $output, $ret); if ((int)$ret == (int)0) { log_debug('Media', "Generating thumbnail with command `$cmd`, returns $ret"); return true; } else { log_error('Media', "Generating thumbnail with command `$cmd`, returns $ret"); return false; } } public static function determine_ext(String $format): String { $format = self::normalize_format($format); switch ($format) { case self::WEBP_LOSSLESS: case self::WEBP_LOSSY: return "webp"; default: return $format; } } // private static function image_save_imagick(Imagick $image, string $path, string $format, int $output_quality = 80, bool $minimize) // { // switch ($format) { // case "png": // $result = $image->setOption('png:compression-level', 9); // if ($result !== true) { // throw new GraphicsException("Could not set png compression option"); // } // break; // case Graphics::WEBP_LOSSLESS: // $result = $image->setOption('webp:lossless', true); // if ($result !== true) { // throw new GraphicsException("Could not set lossless webp option"); // } // break; // default: // $result = $image->setImageCompressionQuality($output_quality); // if ($result !== true) { // throw new GraphicsException("Could not set compression quality for $path to $output_quality"); // } // break; // } // // if (self::supports_alpha($format)) { // $result = $image->setImageBackgroundColor(new \ImagickPixel('transparent')); // } else { // $result = $image->setImageBackgroundColor(new \ImagickPixel('black')); // } // if ($result !== true) { // throw new GraphicsException("Could not set background color"); // } // // // if ($minimize) { // $profiles = $image->getImageProfiles("icc", true); // $result = $image->stripImage(); // if ($result !== true) { // throw new GraphicsException("Could not strip information from image"); // } // if (!empty($profiles)) { // $image->profileImage("icc", $profiles['icc']); // } // } // // $ext = self::determine_ext($format); // // $result = $image->writeImage($ext . ":" . $path); // if ($result !== true) { // throw new GraphicsException("Could not write image to $path"); // } // } // public static function image_resize_imagick( // String $input_path, // String $input_type, // int $new_width, // int $new_height, // string $output_filename, // string $output_type = null, // bool $ignore_aspect_ratio = false, // int $output_quality = 80, // bool $minimize = false, // bool $allow_upscale = true // ): void // { // global $config; // // if (!empty($input_type)) { // $input_type = self::determine_ext($input_type); // } // // try { // $image = new Imagick($input_type . ":" . $input_path); // try { // $result = $image->flattenImages(); // if ($result !== true) { // throw new GraphicsException("Could not flatten image $input_path"); // } // // $height = $image->getImageHeight(); // $width = $image->getImageWidth(); // if (!$allow_upscale && // ($new_width > $width || $new_height > $height)) { // $new_height = $height; // $new_width = $width; // } // // $result = $image->resizeImage($new_width, $new_width, Imagick::FILTER_LANCZOS, 0, !$ignore_aspect_ratio); // if ($result !== true) { // throw new GraphicsException("Could not perform image resize on $input_path"); // } // // // if (empty($output_type)) { // $output_type = $input_type; // } // // self::image_save_imagick($image, $output_filename, $output_type, $output_quality); // // } finally { // $image->destroy(); // } // } catch (ImagickException $e) { // throw new GraphicsException("Error while resizing with Imagick: " . $e->getMessage(), $e->getCode(), $e); // } // } public static function image_resize_convert( String $input_path, String $input_type, int $new_width, int $new_height, string $output_filename, string $output_type = null, bool $ignore_aspect_ratio = false, int $output_quality = 80, bool $minimize = false, bool $allow_upscale = true ): void { global $config; $convert = $config->get_string(MediaConfig::CONVERT_PATH); if (empty($convert)) { throw new MediaException("convert command not configured"); } if (empty($output_type)) { $output_type = $input_type; } $bg = "black"; if (self::supports_alpha($output_type)) { $bg = "none"; } if (!empty($input_type)) { $input_type = $input_type . ":"; } $args = ""; if ($minimize) { $args = " -strip -thumbnail"; } $resize_args = ""; if (!$allow_upscale) { $resize_args .= "\>"; } if ($ignore_aspect_ratio) { $resize_args .= "\!"; } $format = '"%s" -flatten %s %ux%u%s -quality %u -background %s %s"%s[0]" %s:"%s" 2>&1'; $cmd = sprintf($format, $convert, $args, $new_width, $new_height, $resize_args, $output_quality, $bg, $input_type, $input_path, $output_type, $output_filename); $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); if ($ret != 0) { throw new MediaException("Resizing image with command `$cmd`, returns $ret, outputting " . implode("\r\n", $output)); } else { log_debug('Media', "Generating thumbnail with command `$cmd`, returns $ret"); } } /** * Performs a resize operation on an image file using GD. * * @param String $image_filename The source file to be resized. * @param array $info The output of getimagesize() for the source file. * @param int $new_width * @param int $new_height * @param string $output_filename * @param string|null $output_type If set to null, the output file type will be automatically determined via the $info parameter. Otherwise an exception will be thrown. * @param int $output_quality Defaults to 80. * @throws MediaException * @throws InsufficientMemoryException if the estimated memory usage exceeds the memory limit. */ public static function image_resize_gd( String $image_filename, array $info, int $new_width, int $new_height, string $output_filename, string $output_type = null, bool $ignore_aspect_ratio = false, int $output_quality = 80, bool $allow_upscale = true ) { $width = $info[0]; $height = $info[1]; if ($output_type == null) { /* If not specified, output to the same format as the original image */ switch ($info[2]) { case IMAGETYPE_GIF: $output_type = "gif"; break; case IMAGETYPE_JPEG: $output_type = "jpeg"; break; case IMAGETYPE_PNG: $output_type = "png"; break; case IMAGETYPE_WEBP: $output_type = "webp"; break; case IMAGETYPE_BMP: $output_type = "bmp"; break; default: throw new MediaException("Failed to save the new image - Unsupported image type."); } } $memory_use = self::calc_memory_use($info); $memory_limit = get_memory_limit(); if ($memory_use > $memory_limit) { throw new InsufficientMemoryException("The image is too large to resize given the memory limits. ($memory_use > $memory_limit)"); } if (!$ignore_aspect_ratio) { list($new_width, $new_height) = get_scaled_by_aspect_ratio($width, $height, $new_width, $new_height); } if (!$allow_upscale && ($new_width > $width || $new_height > $height)) { $new_height = $height; $new_width = $width; } $image = imagecreatefromstring(file_get_contents($image_filename)); $image_resized = imagecreatetruecolor($new_width, $new_height); try { if ($image === false) { throw new MediaException("Could not load image: " . $image_filename); } if ($image_resized === false) { throw new MediaException("Could not create output image with dimensions $new_width c $new_height "); } // Handle transparent images switch ($info[2]) { case IMAGETYPE_GIF: $transparency = imagecolortransparent($image); $pallet_size = imagecolorstotal($image); // If we have a specific transparent color if ($transparency >= 0 && $transparency < $pallet_size) { // Get the original image's transparent color's RGB values $transparent_color = imagecolorsforindex($image, $transparency); // Allocate the same color in the new image resource $transparency = imagecolorallocate($image_resized, $transparent_color['red'], $transparent_color['green'], $transparent_color['blue']); if ($transparency === false) { throw new MediaException("Unable to allocate transparent color"); } // Completely fill the background of the new image with allocated color. if (imagefill($image_resized, 0, 0, $transparency) === false) { throw new MediaException("Unable to fill new image with transparent color"); } // Set the background color for new image to transparent imagecolortransparent($image_resized, $transparency); } break; case IMAGETYPE_PNG: case IMAGETYPE_WEBP: // // More info here: http://stackoverflow.com/questions/279236/how-do-i-resize-pngs-with-transparency-in-php // if (imagealphablending($image_resized, false) === false) { throw new MediaException("Unable to disable image alpha blending"); } if (imagesavealpha($image_resized, true) === false) { throw new MediaException("Unable to enable image save alpha"); } $transparent_color = imagecolorallocatealpha($image_resized, 255, 255, 255, 127); if ($transparent_color === false) { throw new MediaException("Unable to allocate transparent color"); } if (imagefilledrectangle($image_resized, 0, 0, $new_width, $new_height, $transparent_color) === false) { throw new MediaException("Unable to fill new image with transparent color"); } break; } // Actually resize the image. if (imagecopyresampled( $image_resized, $image, 0, 0, 0, 0, $new_width, $new_height, $width, $height ) === false) { throw new MediaException("Unable to copy resized image data to new image"); } switch ($output_type) { case "bmp": $result = imagebmp($image_resized, $output_filename, true); break; case "webp": case Media::WEBP_LOSSY: $result = imagewebp($image_resized, $output_filename, $output_quality); break; case "jpg": case "jpeg": $result = imagejpeg($image_resized, $output_filename, $output_quality); break; case "png": $result = imagepng($image_resized, $output_filename, 9); break; case "gif": $result = imagegif($image_resized, $output_filename); break; default: throw new MediaException("Failed to save the new image - Unsupported image type: $output_type"); } if ($result === false) { throw new MediaException("Failed to save the new image, function returned false when saving type: $output_type"); } } finally { @imagedestroy($image); @imagedestroy($image_resized); } } /** * Determines if a file is an animated gif. * * @param String $image_filename The path of the file to check. * @return bool true if the file is an animated gif, false if it is not. */ public static function is_animated_gif(String $image_filename): bool { $is_anim_gif = 0; if (($fh = @fopen($image_filename, 'rb'))) { try { //check if gif is animated (via http://www.php.net/manual/en/function.imagecreatefromgif.php#104473) while (!feof($fh) && $is_anim_gif < 2) { $chunk = fread($fh, 1024 * 100); $is_anim_gif += preg_match_all('#\x00\x21\xF9\x04.{4}\x00(\x2C|\x21)#s', $chunk, $matches); } } finally { @fclose($fh); } } return ($is_anim_gif == 0); } private static function compare_file_bytes(String $file_name, array $comparison): bool { $size= filesize($file_name); if ($size < count($comparison)) { // Can't match because it's too small return false; } if (($fh = @fopen($file_name, 'rb'))) { try { $chunk = unpack("C*",fread($fh, count($comparison))); for ($i = 0; $i < count($comparison); $i++) { $byte = $comparison[$i]; if($byte==null) { continue; } else { $fileByte = $chunk[$i+1]; if($fileByte!=$byte) { return false; } } } return true; } finally { @fclose($fh); } } else { throw new MediaException("Unable to open file for byte check: $file_name"); } } public static function is_animated_webp(String $image_filename): bool { return self::compare_file_bytes($image_filename, self::WEBP_ANIMATION_HEADER); } public static function is_lossless_webp(String $image_filename): bool { return self::compare_file_bytes($image_filename, self::WEBP_LOSSLESS_HEADER); } public static function supports_alpha(string $format) { return in_array(self::normalize_format($format), self::ALPHA_FORMATS); } public static function is_input_supported($engine, $format): bool { $format = self::normalize_format($format); if (!in_array($format, MediaEngine::INPUT_SUPPORT[$engine])) { return false; } return true; } public static function is_output_supported($engine, $format): bool { $format = self::normalize_format($format); if (!in_array($format, MediaEngine::OUTPUT_SUPPORT[$engine])) { return false; } return true; } /** * Checks if a format (normally a file extension) is a variant name of another format (ie, jpg and jpeg). * If one is found, then the maine name that the Media extension will recognize is returned, * otherwise the incoming format is returned. * * @param $format * @return string|null The format name that the media extension will recognize. */ static public function normalize_format($format): ?string { if (array_key_exists($format, Media::FORMAT_ALIASES)) { return self::FORMAT_ALIASES[$format]; } return $format; } /** * Determines the dimensions of a video file using ffmpeg. * * @param string $filename * @return array [width, height] */ static public function video_size(string $filename): array { global $config; $ffmpeg = $config->get_string(MediaConfig::FFMPEG_PATH); $cmd = escapeshellcmd(implode(" ", [ escapeshellarg($ffmpeg), "-y", "-i", escapeshellarg($filename), "-vstats" ])); $output = shell_exec($cmd . " 2>&1"); // error_log("Getting size with `$cmd`"); $regex_sizes = "/Video: .* ([0-9]{1,4})x([0-9]{1,4})/"; if (preg_match($regex_sizes, $output, $regs)) { if (preg_match("/displaymatrix: rotation of (90|270).00 degrees/", $output)) { $size = [$regs[2], $regs[1]]; } else { $size = [$regs[1], $regs[2]]; } } else { $size = [1, 1]; } log_debug('Media', "Getting video size with `$cmd`, returns $output -- $size[0], $size[1]"); return $size; } }