* @license PHP License * @package WB * @subpackage vfs */ WBClass::load('WBVFS_Mime'); /** * Virtual File System: Mime * * This Mime handler requires ImageMagick * * @version 0.7.0 * @package WB * @subpackage vfs */ class WBVFS_Mime_Image extends WBVFS_Mime { /** * max image size for import */ const IMAGE_SIZE_MAX = 1000; /** * max image size to resize to */ const IMAGE_RESIZE_MAX = 3000; /** * max factor to resize images */ const IMAGE_RESIZE_MAX_FACTOR = 5; /** * Factor for panorama image aspect */ const IMAGE_PANORAMA_DETECT_FACTOR = 3; /** * Factor for panorama image aspect */ const IMAGE_PANORAMA_RESIZE_MAX_FACTOR = 10; /** * mogrify command * @link http://www.imagemagick.org */ protected static $magickMogrify = 'mogrify'; /** * convert command * @link http://www.imagemagick.org */ protected static $magickConvert = 'convert'; /** * rotate image accoring to exif header * @var string */ protected static $exifAutotran = 'exifautotran'; /** * too to convert PDFs to images */ protected static $pdftoppm = 'pdftoppm'; /** * ffmpeg command */ protected static $ffmpeg = 'ffmpeg'; /** * list of mime types that are convertable to image * * Can convert * - video * * @var array */ protected $convertable = array( 'video', 'text', 'audio', 'application' ); /** * major mime type * @var string */ protected $mimeMajor = 'image'; /** * 2nd constructor * * initialize tools */ protected function init() { self::$ffmpeg = WBParam::get('wb/vfs/mime/ffmpeg', self::$ffmpeg); self::$magickMogrify = WBParam::get('wb/vfs/mime/imagemagick/mogrify', self::$magickMogrify); self::$magickConvert = WBParam::get('wb/vfs/mime/imagemagick/convert', self::$magickConvert); self::$pdftoppm = WBParam::get('wb/vfs/mime/pdftoppm', self::$pdftoppm); self::$exifAutotran = WBParam::get('wb/vfs/mime/exif/autotran', self::$exifAutotran); } /** * inform when file has been set * * Resolve file name extension * @param string $name */ protected function onSetFile($name = '') { $this->mimeMajor = 'image'; $this->resolveExtension(); } /** * inform when virtual file was set * * */ protected function onSetVirtualFile() { switch ($this->mimeMajor) { case 'image': break; case 'video': $this->mimeMinor = 'jpeg'; break; default: $this->mimeMinor = 'png'; break; } $this->mimeMajor = 'image'; $this->resolveExtension(); } /** * get information about this file * * Extract from image: * - width * - height * - par (pixel aspect ration) * - exif header * - panorama mode * * @return array */ public function getInfo() { $size = getimagesize($this->file); $size[0] = max(1, $size[0]); $size[1] = max(1, $size[1]); $this->info = array( 'width' => $size[0], 'height' => $size[1], 'exif' => array(), 'panorama' => $this->detectPanoramaMode($size) ); switch ($this->mimeMinor) { case 'jpeg': case 'tiff': $exif = @exif_read_data($this->file); if (!is_array($exif)) { break; } // remove obvious exif headers $exifIgnore = array('filename', 'filedatetime', 'filesize', 'filetype', 'mimetype', 'computed'); foreach ($exif as $key => $value) { if (in_array(strtolower($key), $exifIgnore)) { continue; } if (is_scalar($value) && strlen($value) > 1000) { continue; } $this->info['exif'][$key] = $value; } break; } return $this->info; } /** * Detect Panorama Mode by Size * * @param array $size * @return string 'x', 'y' or '' */ private function detectPanoramaMode($size) { // detect panorama mode if ($size[0] > (self::IMAGE_PANORAMA_DETECT_FACTOR * $size[1])) { return 'x'; } if ($size[1] > (self::IMAGE_PANORAMA_DETECT_FACTOR * $size[0])) { return 'y'; } return ''; } /** * execute command * * Available commands: * - rotate * - flip/flop * - crop * - grayscale * - monochrome * * @todo implement commands * @param string $cmd * @param array $arg */ public function execute($cmd, $arg = array()) { $log = array( 'file' => $this->vfile->getId(), 'mime' => $this->mimeMajor, 'cmd' => $cmd, 'status' => 'done' ); $file = WBClass::create('WBFile'); /** @var $file WBFile */ $file->tempnam('img'); $file->copy($this->vfile->getPath() . '.org'); $cmd = trim(strtolower($cmd)); switch($cmd) { case 'rotate': $angle = intval($arg['angle']) % 360; $exe = sprintf( '%s -rotate %s %s', self::$magickMogrify, $angle, $file->realpath()); break; case 'grayscale': $exe = sprintf( '%s -colorspace Gray %s', self::$magickMogrify, $file->realpath()); break; case 'monochrome': case 'flip': case 'flop': $exe = sprintf( '%s -%s %s', self::$magickMogrify, $cmd, $file->realpath()); break; default: $log['status'] = 'commandunknown'; $this->log->err($log); return; break; } exec($exe, $out, $ret); if ($ret > 0) { $file->unlink(); return; } // copy attributes $save = array( 'dir' => $this->vfile->getDir(), 'description' => $this->vfile->getDescription() ); $name = $this->vfile->getName(); // what to do with the orginal? switch ($arg['original']) { case 'replace': copy($file->realpath(), $this->vfile->getPath() . '.org'); $this->vfile->reimport(); break; case 'remove': $this->vfile->delete(); // fall through to create a new VFS file default: case 'copy': $new = $this->vfile->import($file->realpath(), $name); if (!$new->isOK()) { $file->unlink(); return; } $new->save($save); $this->vfile = $new; break; } $this->log->notice($log); } /** * import file * * Check whether image extends max-size and resize it * Convert non-web-format images to JPEG */ public function import() { // scalable vector graphic - use the original if ('svg+xml' == $this->mimeMinor) { return; } // resize to max size $size = getimagesize($this->file); // build mogrify command $cmd = array( self::$magickConvert, $this->file, '-strip', '-colorspace sRGB' ); // resizing image during import increased speed of further image processing switch ($this->info['panorama']) { case 'x': if ($size[1] > self::IMAGE_SIZE_MAX) { $cmd[] = '-resize ' . (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_SIZE_MAX) . 'x' . self::IMAGE_SIZE_MAX; } break; case 'y': if ($size[0] > self::IMAGE_SIZE_MAX) { $cmd[] = '-resize ' . self::IMAGE_SIZE_MAX . 'x' . (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_SIZE_MAX); } break; default: case '': if (max($size[0], $size[1] ) > self::IMAGE_SIZE_MAX) { $cmd[] = '-resize ' . self::IMAGE_SIZE_MAX . 'x' . self::IMAGE_SIZE_MAX; } break; } // make image format usable for web $ext = 'jpeg'; switch ($this->mimeMinor) { case 'svg+xml': // this should never happen - see shortcut above return; break; case 'gif': case 'png': $ext = $this->mimeMinor; break; case 'jpeg': // rotate image according to orientation stored in exif header exec(self::$exifAutotran . ' ' . $this->file, $out, $ret); break; default: $cmd[] = '-format jpeg'; $cmd[] = '-fill white'; $cmd[] = '-opaque none'; break; } $cmd[] = $this->file . '.' . $ext; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'import', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); /* * some image formats like Adobe's Photoshop provide multiple * layers. Mogrify extracts all laysers and converts them * to individual JPEG images. Image #0 represents the merged * layers * * Therefore import #0 and trash all layer images */ if (file_exists($this->file . '-0.' . $ext)) { rename($this->file . '-0.' . $ext, $this->file); exec('rm ' . $this->file . '-*'); return; } // remove extension if (file_exists($this->file . '.' . $ext)) { rename($this->file . '.' . $ext, $this->file); } return; } /** * get file name of requested file * * @return string */ protected function doGetRequestedFile(&$redirect) { $this->incrementable = false; $this->file = $this->vfile->getPath(); $major = $this->vfile->getMime(WBVFS_File::MIME_MAJOR); $minor = $this->vfile->getMime(WBVFS_File::MIME_MINOR); switch ($major) { case 'image': $org = array($this->vfile->getInfo('width'), $this->vfile->getInfo('height')); $this->mimeMinor = $minor; // just deliver file, don't resize, because it is scalable anyway if ('svg+xml' == $minor) { return $this->file; } break; case 'video': $org = array($this->vfile->getInfo('width'), $this->vfile->getInfo('height')); $this->convertVideo($org); break; case 'application': if ($this->convertApplication($minor, $org)) { break; } default; $org = array(64, 64); $this->file = $this->getIcon($major, $this->vfile->getMime(WBVFS_File::MIME_MINOR)); break; } // increment view counter if ($this->resize($org, $redirect) && $major == 'image') { // only for real images $size = explode('x', $this->req->get('size', '')); if (!count($size) == 2 || !is_numeric($size[0]) || !is_numeric($size[1])) { $size = $org; } // must have resonable size, like 300x300 $res = array(300, 300); // for small images, half of the image's file is suffice if ($org[0] < $res[0]) { $res[0] = floor($org[0] / 2); } if ($org[1] < $res[1]) { $res[1] = floor($org[1] / 2); } if ($res[0] < $size[0] && $res[1] < $size[1]) { $this->incrementable = true; } } $this->resolveExtension(); return $this->file; } /** * tell file name extension * * Calculate suitable file name extension accotding * to minor mime type */ protected function resolveExtension() { // figure out file extension switch ($this->mimeMinor) { case 'svg+xml': $this->extension = 'svg'; break; case 'png': $this->extension = 'png'; break; case 'gif': $this->extension = 'gif'; break; default: case 'jpg': $this->extension = 'jpg'; break; } } /** * get path of icon * * try to find icon path for mime type * * @param string $major * @param string $minor * @return string */ protected function getIcon($major, $minor) { $this->mimeMinor = 'png'; $this->resolveExtension(); // application icons if ($major == 'application') { $icon = $major . '-' . $minor; $file = WBParam::get('wb/dir/base') . '/resource/mime/icon/' . $icon . '.png'; if (!file_exists($file)) { $file = WBParam::get('wb/dir/system') . '/resource/mime/icon/' . $icon . '.png'; } if (file_exists($file)) { return $file; } } // icons by major mime type $icon = $major; if (!in_array($major, array('text', 'audio', 'application'))) { $icon = 'unknown'; } // get icon for major mime type $file = WBParam::get('wb/dir/base') . '/resource/mime/icon/' . $icon . '.png'; if (!file_exists($file)) { $file = WBParam::get('wb/dir/system') . '/resource/mime/icon/' . $icon . '.png'; } return $file; } /** * Convert application/* to image * * Some application file are convertable to images. Support for the * the following sub-mime-types: * - pdf * @param string $minor * @param array $org * @return bool true if mime type is convertable */ private function convertApplication($minor, &$org) { if ('pdf' != $minor) { return false; } $org = array(); $cacheDir = $this->mkCacheDir(); // convert PDF to png if (!file_exists($cacheDir . '/image.jpg')) { $cmd = array(self::$pdftoppm); //$cmd[] = '-jpeg'; $cmd[] = '-f 1'; $cmd[] = '-l 1'; $cmd[] = $this->file; $cmd[] = $cacheDir . '/image'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); // depending on the amount of pages, zero or more "0" will be added to the filename for ($i = 0; $i < 5; ++$i) { $imageFilename = $cacheDir . '/image-'. str_repeat('0', $i) .'1.ppm'; if (file_exists($imageFilename)) { rename($imageFilename, $cacheDir . '/image.ppm'); break; } } $cmd = array(self::$magickMogrify); $cmd[] = '-format jpeg'; $cmd[] = $cacheDir . '/image.ppm'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); if (file_exists($cacheDir . '/image.jpeg')) { rename($cacheDir . '/image.jpeg', $cacheDir . '/image.jpg'); } unlink($cacheDir . '/image.ppm'); $size = getimagesize($cacheDir . '/image.jpg'); if (max(intval($size[0]), intval($size[1])) > self::IMAGE_SIZE_MAX) { $cmd = array(self::$magickMogrify); $cmd[] = '-resize ' . self::IMAGE_SIZE_MAX . 'x' . self::IMAGE_SIZE_MAX; $cmd[] = $cacheDir . '/image.jpg'; $cmd = implode(' ', $cmd); $log = array( 'mime' => 'image', 'action' => 'convert-pdf-resize', 'exec' => $cmd ); $this->log->info($log); exec($cmd, $out, $ret); } } $this->file = $cacheDir . '/image.jpg'; $size = getimagesize($this->file); $org = array($size[0], $size[1]); return true; } /** * convert video to browser compatible image * * Either convert to animation or to sinplge image * * @param array $org original size * @return bool true on success */ public function convertVideo($org) { $target = 'image'; $now = WBClock::now(); $log = array( 'mime' => 'image', 'action' => 'convert', 'source' => 'video', 'cache' => 'hit', 'elapsed' => '0ms' ); $cacheDir = $this->mkCacheDir(); if (file_exists($cacheDir . '/' . $target) && filesize($cacheDir . '/' . $target) > 10) { $this->file = $cacheDir . '/' . $target; $this->mimeMinor = 'jpeg'; if ($target == 'anim') { $this->mimeMinor = 'gif'; } $log['elapsed'] = WBClock::stop($now, 1000, 1) . 'ms'; $this->log->notice($log); return true; } // this sort of locks the file an prevents multi processings touch($cacheDir . '/' . $target, null); chmod($cacheDir . '/' . $target, 0666); // build preview if ($target == 'anim') { $image = $this->convertVideo2Anim($org); } else { $image = $this->convertVideo2Image($org); } $this->file = $cacheDir . '/' . $target; $this->mimeMinor = 'jpeg'; if ($target == 'anim') { $this->mimeMinor = 'gif'; } $this->resolveExtension(); rename($image, $this->file); chmod($this->file, 0666); $log['elapsed'] = WBClock::stop($now, 1000, 1) . 'ms'; $log['cache'] = 'miss'; $this->log->notice($log); } /** * extract simple image from video * * @param array $org original size * @return string image file name */ public function convertVideo2Image($org) { $tmpDir = WBParam::get('wb/dir/base') . '/var/tmp'; $image = tempnam($tmpDir, 'vfsVideoImage'); $seconds = array(100, 50, 20, 5, 1, 0); $cmd = '%s -i %s -ss %%d -an -vframes 1 -f image2 -y %s 2>&1'; $cmd = sprintf($cmd, self::$ffmpeg, $this->file, $image); foreach ($seconds as $sec) { if ($sec > $this->vfile->getInfo('seconds')) { continue; } $out = array(); $ret = null; exec(sprintf($cmd, $sec), $out, $ret); if(filesize($image)) { exec(self::$magickMogrify . ' -resize ' . implode('x', $org) . '! ' . $image); return $image; } clearstatcache(); } // shis should never happen! unlink($image); return $image; } /** * build an animated gif as preview for viedeo * * Extract frames after X second of the movie. Use every 25th * frame to build a preview animatrion. * * @param array $org original size * @return string image file name */ public function convertVideo2Anim($org) { $tmpDir = WBParam::get('wb/dir/base') . '/var/tmp'; $image = tempnam($tmpDir, 'vfsVideoAnim'); $seconds = array(100, 50, 20, 5, 1); foreach ($seconds as $sec) { if ($this->vfile->getInfo('seconds') > ($sec + 40)) { break; } } // extract some frames $cmd = '%s -i %s -ss %d -an -r 0.25 -vframes 10 -f image2 %s%%2d.jpg 2>&1'; $cmd = sprintf($cmd, self::$ffmpeg, $this->file, $sec, $image); exec($cmd, $out, $ret); // join frame to animated gif $cmd = '%s -resize %s! -adjoin -delay 100 %s*.jpg %s.gif'; $cmd = sprintf($cmd, self::$magickConvert, implode('x', $org), $image, $image); exec($cmd, $out, $ret); // remove temporary files $cmd = 'rm -f %s*.jpg'; $cmd = sprintf($cmd, $image); exec($cmd, $out, $ret); unlink($image); return $image . '.gif'; } /** * change image to requested size * * Return true in case the file will be delivered, false on redirect * * @param array $redirect parameter * @return bool */ protected function resize($org, &$redirect) { $size = explode('x', $this->req->get('size', '')); // shortcut if no size or invalid parameter if (count($size) != 2 || !is_numeric($size[0]) || !is_numeric($size[1])) { return true; } if (!$this->calcSize($size, $org)) { $redirect = array( 'get' => array( 'size' => implode('x', $size) ) ); return false; } $size = implode('x', $size); $cacheDir = $this->mkCacheDir(); $cacheFile = $cacheDir . '/' . $size; $now = WBClock::now(); if (!file_exists($cacheFile)) { // resize image and cache it copy($this->file, $cacheFile); exec(self::$magickMogrify . ' -resize ' . $size . ' ' . $cacheFile); $log = array( 'mime' => 'image', 'action' => 'resize', 'size' => $size, 'elapsed' => WBClock::stop($now, 1000, 1) . 'ms' ); $this->log->notice($log); } $this->file = $cacheFile; return true; } /** * caluclate new image size * * calculate proper image size and resize factor while * keeping aspect. * * The reckoned width and height are stored in size-array * * In case the requested size is too far away from the calculated size, * it will return false to indicate that you better redirect * * @param array $size requested image size * @param array $org * @return true on success */ protected function calcSize(&$size, $org) { $tmp = array(); $tmp[0] = min($size[0], self::IMAGE_RESIZE_MAX); $tmp[1] = min($size[1], self::IMAGE_RESIZE_MAX); $panorama = $this->detectPanoramaMode($org); switch ($panorama) { case 'x': $tmp[0] = min($size[0], (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_RESIZE_MAX)); break; case 'y': $tmp[1] = min($size[1], (self::IMAGE_PANORAMA_RESIZE_MAX_FACTOR * self::IMAGE_RESIZE_MAX)); break; default: break; } // get resize factor $divX = $tmp[0] / $org[0]; $divY = $tmp[1] / $org[1]; $factor = min($divX, $divY, self::IMAGE_RESIZE_MAX_FACTOR); // make sure there is at least one pixel in width and height $x = max(1, round($org[0] * $factor)); $y = max(1, round($org[1] * $factor)); // this is the excact result if ($x == $size[0] && $y == $size[1]) { return true; } // be somewhat tolerant to avoid too many redirects if (abs($x - $tmp[0]) < 3 && abs($y - $tmp[1]) < 3) { $size[0] = $x; $size[1] = $y; return true; } // this is the new image size $size[0] = $x; $size[1] = $y; return false; } }