diff --git a/appinfo/routes.php b/appinfo/routes.php index 68496841..8598d0a6 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -69,6 +69,7 @@ return [ ['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'], ['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PATCH'], ['name' => 'Image#decodable', 'url' => '/api/image/decodable/{id}', 'verb' => 'GET'], + ['name' => 'Image#editImage', 'url' => '/api/image/edit/{id}', 'verb' => 'PUT'], ['name' => 'Video#transcode', 'url' => '/api/video/transcode/{client}/{fileid}/{profile}', 'verb' => 'GET'], ['name' => 'Video#livephoto', 'url' => '/api/video/livephoto/{fileid}', 'verb' => 'GET'], diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index dc6cecf0..bb25b915 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -26,6 +26,7 @@ namespace OCA\Memories\Controller; use OCA\Memories\AppInfo\Application; use OCA\Memories\Exceptions; use OCA\Memories\Exif; +use OCA\Memories\Service; use OCA\Memories\Util; use OCP\AppFramework\Http; use OCP\AppFramework\Http\FileDisplayResponse; @@ -285,6 +286,61 @@ class ImageController extends GenericApiController }); } + /** + * @NoAdminRequired + */ + public function editImage( + int $id, + string $name, + int $width, + int $height, + float $quality, + string $extension, + array $state + ): Http\Response { + return Util::guardEx(function () use ($id, $name, $quality, $extension, $state) { + // Get the file + $file = $this->fs->getUserFile($id); + + // Check if creating a copy + $copy = $name !== $file->getName(); + + // Check if user has permissions to do this + if (!$file->isUpdateable() || ($copy && !$file->getParent()->isCreatable())) { + throw Exceptions::ForbiddenFileUpdate($file->getName()); + } + + // Check if we have imagick + if (!class_exists('Imagick')) { + throw Exceptions::Forbidden('Imagick extension is not available'); + } + + // Read the image + $image = new \Imagick(); + $image->readImageBlob($file->getContent()); + + // Apply the edits + (new Service\FileRobotMagick($image, $state))->apply(); + + // Save the image + $image->setImageFormat($extension); + $image->setImageCompressionQuality((int) round(100 * $quality)); + $blob = $image->getImageBlob(); + + // Save the file + if ($copy) { + $file = $file->getParent()->newFile($name, $blob); + } else { + $file->putContent($blob); + } + + return new JSONResponse([ + 'fileid' => $file->getId(), + 'etag' => $file->getEtag(), + ], Http::STATUS_OK); + }); + } + /** * Given a blob of image data, return a JPEG blob. * diff --git a/lib/Service/FileRobotMagick.php b/lib/Service/FileRobotMagick.php new file mode 100644 index 00000000..63208c6c --- /dev/null +++ b/lib/Service/FileRobotMagick.php @@ -0,0 +1,653 @@ + + * @author Varun Patil + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace OCA\Memories\Service; + +/** + * Constructs a FileRobotImageState object from a JSON array state. + */ +class FileRobotImageState +{ + /** -1 to 1 */ + public ?float $brightness = null; + + /** -100 to 100 */ + public ?float $contrast = null; + + /** 0 to 259 */ + public ?float $hue = null; + + /** -2 to 10 */ + public ?float $saturation = null; + + /** -2 to 2 */ + public ?float $value = null; + + /** 0 to 100 */ + public ?float $blurRadius = null; + + /** 0 to 200 */ + public ?float $warmth = null; + + /** Order of filters */ + public array $finetuneOrder = []; + + /** Crop X coordinate */ + public ?float $cropX = null; + + /** Crop Y coordinate */ + public ?float $cropY = null; + + /** Crop width */ + public ?float $cropWidth = null; + + /** Crop height */ + public ?float $cropHeight = null; + + /** Rotation */ + public ?int $rotation = null; + + /** Flipped X */ + public bool $isFlippedX = false; + + /** Flipped Y */ + public bool $isFlippedY = false; + + /** Resize width */ + public ?int $resizeWidth = null; + + /** Resize height */ + public ?int $resizeHeight = null; + + /** Filter */ + public ?string $filter = null; + + public function __construct(array $json) + { + if ($order = $json['finetunes']) { + foreach ($order as $key) { + $this->finetuneOrder[] = $key; + } + } + + if ($props = $json['finetunesProps']) { + $this->_set($props, 'brightness'); + $this->_set($props, 'contrast'); + $this->_set($props, 'hue'); + $this->_set($props, 'saturation'); + $this->_set($props, 'value'); + $this->_set($props, 'blurRadius'); + $this->_set($props, 'warmth'); + } + + if ($props = $json['adjustments']) { + if ($crop = $props['crop']) { + $this->_set($crop, 'x', 'cropX'); + $this->_set($crop, 'y', 'cropY'); + $this->_set($crop, 'width', 'cropWidth'); + $this->_set($crop, 'height', 'cropHeight'); + } + $this->_set($props, 'rotation'); + $this->_set($props, 'isFlippedX'); + $this->_set($props, 'isFlippedY'); + } + + if ($filter = $json['filter']) { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/components/tools/Filters/Filters.constants.js#L8 + $this->filter = $filter; + } + + if ($resize = $json['resize']) { + $this->_set($resize, 'width', 'resizeWidth'); + $this->_set($resize, 'height', 'resizeHeight'); + } + } + + private function _set(array $parent, string $key, string $ckey = null) + { + $ckey ??= $key; + if (\array_key_exists($key, $parent)) { + $this->{$ckey} = $parent[$key]; + } + } +} + +/** + * Applies a FileRobotImageState to an Imagick object. + */ +class FileRobotMagick +{ + private \Imagick $image; + private FileRobotImageState $state; + + public function __construct(\Imagick $image, array $state) + { + $this->image = $image; + $this->state = new FileRobotImageState($state); + } + + public function apply() + { + $this->applyCrop(); + $this->applyFlipRotation(); + $this->applyResize(); + + foreach ($this->state->finetuneOrder as $key) { + $method = 'apply'.$key; + if (!method_exists($this, $method)) { + throw new \Exception('Unknown finetune: '.$key); + } + $this->{$method}(); + } + + if ($this->state->filter) { + $method = 'applyFilter'.$this->state->filter; + if (!method_exists($this, $method)) { + throw new \Exception('Unknown filter: '.$this->state->filter); + } + $this->{$method}(); + } + + return $this->image; + } + + protected function applyCrop() + { + if ($this->state->cropX || $this->state->cropY || $this->state->cropWidth || $this->state->cropHeight) { + $iw = $this->image->getImageWidth(); + $ih = $this->image->getImageHeight(); + $this->image->cropImage( + (int) (($this->state->cropWidth ?? 1) * $iw), + (int) (($this->state->cropHeight ?? 1) * $ih), + (int) (($this->state->cropX ?? 0) * $iw), + (int) (($this->state->cropY ?? 0) * $ih) + ); + } + } + + protected function applyFlipRotation() + { + if ($this->state->isFlippedX) { + $this->image->flopImage(); + } + if ($this->state->isFlippedY) { + $this->image->flipImage(); + } + if ($this->state->rotation) { + $this->image->rotateImage(new \ImagickPixel(), $this->state->rotation); + } + } + + protected function applyResize() + { + if ($this->state->resizeWidth || $this->state->resizeHeight) { + $this->image->resizeImage( + $this->state->resizeWidth ?? 0, + $this->state->resizeHeight ?? 0, + \Imagick::FILTER_LANCZOS, + 1 + ); + } + } + + protected function applyBrighten(?float $value = null) + { + $brightness = $value ?? $this->state->brightness ?? 0; + if (0 === $brightness) { + return; + } + + // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/Brighten.ts#L15-L29 + $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $brightness * 255 * 255, \Imagick::CHANNEL_ALL); + } + + protected function applyContrast(?float $value = null) + { + $contrast = $value ?? $this->state->contrast ?? 0; + if (0 === $contrast) { + return; + } + + // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/Contrast.ts#L15-L59 + // m = ((a + 100) / 100) ** 2 // slope + // y = (x - 0.5) * m + 0.5 + // y = mx + (0.5 * (1 - m)) // simplify + $m = (($contrast + 100) / 100) ** 2; + $c = 0.5 * (1 - $m); + + $this->image->functionImage(\Imagick::FUNCTION_POLYNOMIAL, [$m, $c], \Imagick::CHANNEL_ALL); + } + + protected function applyHSV(?float $hue = null, ?float $saturation = null, ?float $value = null) + { + $hue ??= $this->state->hue ?? 0; + $saturation ??= $this->state->saturation ?? 0; + $value ??= $this->state->value ?? 0; + + if (0 === $hue && 0 === $saturation && 0 === $value) { + return; + } + + $h = abs(($hue ?? 0) + 360) % 360; + $s = 2 ** ($saturation ?? 0); + $v = 2 ** ($value ?? 0); + + // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/HSV.ts#L17-L63 + $vsu = $v * $s * cos(($h * M_PI) / 180); + $vsw = $v * $s * sin(($h * M_PI) / 180); + + $rr = 0.299 * $v + 0.701 * $vsu + 0.167 * $vsw; + $rg = 0.587 * $v - 0.587 * $vsu + 0.33 * $vsw; + $rb = 0.114 * $v - 0.114 * $vsu - 0.497 * $vsw; + $gr = 0.299 * $v - 0.299 * $vsu - 0.328 * $vsw; + $gg = 0.587 * $v + 0.413 * $vsu + 0.035 * $vsw; + $gb = 0.114 * $v - 0.114 * $vsu + 0.293 * $vsw; + $br = 0.299 * $v - 0.3 * $vsu + 1.25 * $vsw; + $bg = 0.587 * $v - 0.586 * $vsu - 1.05 * $vsw; + $bb = 0.114 * $v + 0.886 * $vsu - 0.2 * $vsw; + + $colorMatrix = [ + $rr, $rg, $rb, 0, 0, + $gr, $gg, $gb, 0, 0, + $br, $bg, $bb, 0, 0, + 0, 0, 0, 1, 0, + 0, 0, 0, 0, 1, + ]; + + $this->image->colorMatrixImage($colorMatrix); + } + + protected function applyBlur() + { + if ($this->state->blurRadius <= 0) { + return; + } + + // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/Blur.ts#L834 + $sigma = min(round($this->state->blurRadius * 1.5), 100); + $this->image->blurImage(0, $sigma); + } + + protected function applyWarmth() + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/finetunes/Warmth.js#L17-L28 + $warmth = ($this->state->warmth ?? 0); + if ($warmth <= 0) { + return; + } + + // Add to red channel, subtract from blue channel + $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $warmth * 255, \Imagick::CHANNEL_RED); + $this->image->evaluateImage(\Imagick::EVALUATE_SUBTRACT, $warmth * 255, \Imagick::CHANNEL_BLUE); + } + + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/components/tools/Filters/Filters.constants.js#L8 + protected function applyFilterInvert() + { + $this->image->negateImage(false); + } + + protected function applyFilterBlackAndWhite() + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BlackAndWhite.js + $this->image->thresholdImage(100 * 255); + } + + protected function applyFilterSepia() + { + // https://github.com/konvajs/konva/blob/master/src/filters/Sepia.ts + $this->image->colorMatrixImage([ + 0.393, 0.769, 0.189, 0, 0, + 0.349, 0.686, 0.168, 0, 0, + 0.272, 0.534, 0.131, 0, 0, + 0, 0, 0, 1, 0, + 0, 0, 0, 0, 1, + ]); + } + + protected function applyFilterSolarize() + { + // https://github.com/konvajs/konva/blob/master/src/filters/Solarize.ts + $this->image->solarizeImage(128 * 255); + } + + protected function applyFilterClarendon() + { + $this->applyBaseFilterBrightness(0.1); + $this->applyBaseFilterContrast(0.1); + $this->applyBaseFilterSaturation(0.15); + } + + protected function applyFilterGingham() + { + $this->applyBaseFilterSepia(0.04); + $this->applyBaseFilterContrast(-0.15); + } + + protected function applyFilterMoon() + { + $this->applyBaseFilterGrayscale(); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterLark() + { + $this->applyBaseFilterBrightness(0.08); + $this->applyBaseFilterAdjustRGB(1, 1.03, 1.05); + $this->applyBaseFilterSaturation(0.12); + } + + protected function applyFilterReyes() + { + $this->applyBaseFilterSepia(0.4); + $this->applyBaseFilterBrightness(0.13); + $this->applyBaseFilterContrast(-0.05); + } + + protected function applyFilterJuno() + { + $this->applyBaseFilterAdjustRGB(1.01, 1.04, 1); + $this->applyBaseFilterSaturation(0.3); + } + + protected function applyFilterSlumber() + { + $this->applyBaseFilterBrightness(0.1); + $this->applyBaseFilterSaturation(-0.5); + } + + protected function applyFilterCrema() + { + $this->applyBaseFilterAdjustRGB(1.04, 1, 1.02); + $this->applyBaseFilterSaturation(-0.05); + } + + protected function applyFilterLudwig() + { + $this->applyBaseFilterBrightness(0.05); + $this->applyBaseFilterSaturation(-0.03); + } + + protected function applyFilterAden() + { + $this->applyBaseFilterColorFilter(228, 130, 225, 0.13); + $this->applyBaseFilterSaturation(-0.2); + } + + protected function applyFilterPerpetua() + { + $this->applyBaseFilterAdjustRGB(1.05, 1.1, 1); + } + + protected function applyFilterAmaro() + { + $this->applyBaseFilterSaturation(0.3); + $this->applyBaseFilterBrightness(0.15); + } + + protected function applyFilterMayfair() + { + $this->applyBaseFilterColorFilter(230, 115, 108, 0.05); + $this->applyBaseFilterSaturation(0.15); + } + + protected function applyFilterRise() + { + $this->applyBaseFilterColorFilter(255, 170, 0, 0.1); + $this->applyBaseFilterBrightness(0.09); + $this->applyBaseFilterSaturation(0.1); + } + + protected function applyFilterHudson() + { + $this->applyBaseFilterAdjustRGB(1, 1, 1.25); + $this->applyBaseFilterContrast(0.1); + $this->applyBaseFilterBrightness(0.15); + } + + protected function applyFilterValencia() + { + $this->applyBaseFilterColorFilter(255, 225, 80, 0.08); + $this->applyBaseFilterSaturation(0.1); + $this->applyBaseFilterContrast(0.05); + } + + protected function applyFilterXpro2() + { + $this->applyBaseFilterColorFilter(255, 255, 0, 0.07); + $this->applyBaseFilterSaturation(0.2); + $this->applyBaseFilterContrast(0.15); + } + + protected function applyFilterSierra() + { + $this->applyBaseFilterContrast(-0.15); + $this->applyBaseFilterSaturation(0.1); + } + + protected function applyFilterWillow() + { + $this->applyBaseFilterGrayscale(); + $this->applyBaseFilterColorFilter(100, 28, 210, 0.03); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterLoFi() + { + $this->applyBaseFilterContrast(0.15); + $this->applyBaseFilterSaturation(0.2); + } + + protected function applyFilterInkwell() + { + $this->applyBaseFilterGrayscale(); + } + + protected function applyFilterHefe() + { + $this->applyBaseFilterContrast(0.1); + $this->applyBaseFilterSaturation(0.15); + } + + protected function applyFilterNashville() + { + $this->applyBaseFilterColorFilter(220, 115, 188, 0.12); + $this->applyBaseFilterContrast(-0.05); + } + + protected function applyFilterStinson() + { + $this->applyBaseFilterBrightness(0.1); + $this->applyBaseFilterSepia(0.3); + } + + protected function applyFilterVesper() + { + $this->applyBaseFilterColorFilter(255, 225, 0, 0.05); + $this->applyBaseFilterBrightness(0.06); + $this->applyBaseFilterContrast(0.06); + } + + protected function applyFilterEarlybird() + { + $this->applyBaseFilterColorFilter(255, 165, 40, 0.2); + } + + protected function applyFilterBrannan() + { + $this->applyBaseFilterContrast(0.2); + $this->applyBaseFilterColorFilter(140, 10, 185, 0.1); + } + + protected function applyFilterSutro() + { + $this->applyBaseFilterBrightness(-0.1); + $this->applyBaseFilterContrast(-0.1); + } + + protected function applyFilterToaster() + { + $this->applyBaseFilterSepia(0.1); + $this->applyBaseFilterColorFilter(255, 145, 0, 0.2); + } + + protected function applyFilterWalden() + { + $this->applyBaseFilterBrightness(0.1); + $this->applyBaseFilterColorFilter(255, 255, 0, 0.2); + } + + protected function applyFilterNinteenSeventySeven() + { + $this->applyBaseFilterColorFilter(255, 25, 0, 0.15); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterKelvin() + { + $this->applyBaseFilterColorFilter(255, 140, 0, 0.1); + $this->applyBaseFilterAdjustRGB(1.15, 1.05, 1); + $this->applyBaseFilterSaturation(0.35); + } + + protected function applyFilterMaven() + { + $this->applyBaseFilterColorFilter(225, 240, 0, 0.1); + $this->applyBaseFilterSaturation(0.25); + $this->applyBaseFilterContrast(0.05); + } + + protected function applyFilterGinza() + { + $this->applyBaseFilterSepia(0.06); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterSkyline() + { + $this->applyBaseFilterSaturation(0.35); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterDogpatch() + { + $this->applyBaseFilterContrast(0.15); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterBrooklyn() + { + $this->applyBaseFilterColorFilter(25, 240, 252, 0.05); + $this->applyBaseFilterSepia(0.3); + } + + protected function applyFilterHelena() + { + $this->applyBaseFilterColorFilter(208, 208, 86, 0.2); + $this->applyBaseFilterContrast(0.15); + } + + protected function applyFilterAshby() + { + $this->applyBaseFilterColorFilter(255, 160, 25, 0.1); + $this->applyBaseFilterBrightness(0.1); + } + + protected function applyFilterCharmes() + { + $this->applyBaseFilterColorFilter(255, 50, 80, 0.12); + $this->applyBaseFilterContrast(0.05); + } + + protected function applyBaseFilterBrightness(float $value) + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L2 + $this->applyBrighten($value); + } + + protected function applyBaseFilterContrast(float $value) + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L14 + $value *= 255; + + // y = m * (x - 128) + 128 + // y = m * x + (128 * (1 - m)) + $m = (259 * ($value + 255)) / (255 * (259 - $value)); + $c = 0.5 * (1 - $m); + + $this->image->functionImage(\Imagick::FUNCTION_POLYNOMIAL, [$m, $c], \Imagick::CHANNEL_ALL); + } + + protected function applyBaseFilterSaturation(float $value) + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L24 + $this->applyHSV(0, $value, 0); // lazy + } + + protected function applyBaseFilterGrayscale() + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L38 + // y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + $this->image->colorMatrixImage([ + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, + 0, 0, 0, 0, 1, + ]); + } + + protected function applyBaseFilterSepia(float $value) + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L46 + $this->image->colorMatrixImage([ + 1 - 0.607 * $value, 0.769 * $value, 0.189 * $value, 0, 0, + 0.349 * $value, 1 - 0.314 * $value, 0.168 * $value, 0, 0, + 0.272 * $value, 0.534 * $value, 1 - 0.869 * $value, 0, 0, + 0, 0, 0, 1, 0, + 0, 0, 0, 0, 1, + ]); + } + + protected function applyBaseFilterAdjustRGB(float $r, float $g, float $b) + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L57 + $this->image->colorMatrixImage([ + $r, 0, 0, 0, 0, + 0, $g, 0, 0, 0, + 0, 0, $b, 0, 0, + 0, 0, 0, 1, 0, + 0, 0, 0, 0, 1, + ]); + } + + protected function applyBaseFilterColorFilter(float $r, float $g, float $b, float $v) + { + // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L63 + // y = x - (x - k) * v = (1 - v) * x + k * v + $this->image->evaluateImage(\Imagick::EVALUATE_MULTIPLY, 1 - $v, \Imagick::CHANNEL_ALL); + $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $v * $r * 255, \Imagick::CHANNEL_RED); + $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $v * $g * 255, \Imagick::CHANNEL_GREEN); + $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $v * $b * 255, \Imagick::CHANNEL_BLUE); + } +} diff --git a/package-lock.json b/package-lock.json index 5cbec422..f31f34c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@nextcloud/sharing": "^0.1.0", "@nextcloud/vue": "7.8.0", "camelcase": "^7.0.1", - "filerobot-image-editor": "^4.3.8", + "filerobot-image-editor": "^4.4.0", "fuse.js": "^6.6.2", "hammerjs": "^2.0.8", "justified-layout": "^4.1.0", @@ -2446,8 +2446,7 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "peer": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/qs": { "version": "6.9.7", @@ -2467,13 +2466,20 @@ "version": "18.0.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.28.tgz", "integrity": "sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.2.tgz", + "integrity": "sha512-8tu6lHzEgYPlfDf/J6GOQdIc+gs+S2yAqlby3zTsB3SP2svlqTYe5fwZNtZyfactP74ShooP2vvi1BOp9ZemWw==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -2493,8 +2499,7 @@ "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "peer": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "node_modules/@types/serve-index": { "version": "1.9.1", @@ -4796,19 +4801,65 @@ } }, "node_modules/filerobot-image-editor": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/filerobot-image-editor/-/filerobot-image-editor-4.3.8.tgz", - "integrity": "sha512-czOgrP/3cDBvDkiGFra/qu0hKesZcaTb9kUiyzCqbeUvZjMVlVcyHzumsETJZw4PDyYVzgJ120CcQYaYbgDhJw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/filerobot-image-editor/-/filerobot-image-editor-4.4.0.tgz", + "integrity": "sha512-B1lqUtPT0iEn+sguJ+XOrTLnjf5adBA60aNW6DPAwGCtHj4NjIHEGT7RMVeleCt9vsHwnTYEZVih0DvLcsVRcw==", "dependencies": { "@babel/runtime": "^7.17.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-konva": "18.2.5", "styled-components": "5.3.5" }, "peerDependencies": { "react-filerobot-image-editor": "^4.3.7" } }, + "node_modules/filerobot-image-editor/node_modules/react-konva": { + "version": "18.2.5", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.5.tgz", + "integrity": "sha512-lTqJStcHnpGSXB9RlV7p5at3MpRML/TujzbuUDZRIInsLocJ/I4Nhhg3w6yJm9UV05kcwr88OY6LO+2zRyzXog==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.0.6", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/filerobot-image-editor/node_modules/react-reconciler": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz", + "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/filerobot-image-editor/node_modules/styled-components": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", @@ -6111,6 +6162,17 @@ "node": ">=0.10.0" } }, + "node_modules/its-fine": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.1.0.tgz", + "integrity": "sha512-nEoEt5EYSed1mmvwCRv3l1+6T7pyu4ltyBihzPjUtaSWhFhUPU/c7xkPDIutTh8FeIv0F1F5wOFYI8a2s5rlBA==", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, "node_modules/jake": { "version": "10.8.5", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", @@ -13188,8 +13250,7 @@ "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "peer": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "@types/qs": { "version": "6.9.7", @@ -13209,13 +13270,20 @@ "version": "18.0.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.28.tgz", "integrity": "sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==", - "peer": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, + "@types/react-reconciler": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.2.tgz", + "integrity": "sha512-8tu6lHzEgYPlfDf/J6GOQdIc+gs+S2yAqlby3zTsB3SP2svlqTYe5fwZNtZyfactP74ShooP2vvi1BOp9ZemWw==", + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -13235,8 +13303,7 @@ "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "peer": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@types/serve-index": { "version": "1.9.1", @@ -15095,16 +15162,37 @@ } }, "filerobot-image-editor": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/filerobot-image-editor/-/filerobot-image-editor-4.3.8.tgz", - "integrity": "sha512-czOgrP/3cDBvDkiGFra/qu0hKesZcaTb9kUiyzCqbeUvZjMVlVcyHzumsETJZw4PDyYVzgJ120CcQYaYbgDhJw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/filerobot-image-editor/-/filerobot-image-editor-4.4.0.tgz", + "integrity": "sha512-B1lqUtPT0iEn+sguJ+XOrTLnjf5adBA60aNW6DPAwGCtHj4NjIHEGT7RMVeleCt9vsHwnTYEZVih0DvLcsVRcw==", "requires": { "@babel/runtime": "^7.17.2", "react": "18.2.0", "react-dom": "18.2.0", + "react-konva": "18.2.5", "styled-components": "5.3.5" }, "dependencies": { + "react-konva": { + "version": "18.2.5", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.5.tgz", + "integrity": "sha512-lTqJStcHnpGSXB9RlV7p5at3MpRML/TujzbuUDZRIInsLocJ/I4Nhhg3w6yJm9UV05kcwr88OY6LO+2zRyzXog==", + "requires": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.0.6", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + } + }, + "react-reconciler": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.0.tgz", + "integrity": "sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + } + }, "styled-components": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", @@ -16056,6 +16144,14 @@ "dev": true, "peer": true }, + "its-fine": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.1.0.tgz", + "integrity": "sha512-nEoEt5EYSed1mmvwCRv3l1+6T7pyu4ltyBihzPjUtaSWhFhUPU/c7xkPDIutTh8FeIv0F1F5wOFYI8a2s5rlBA==", + "requires": { + "@types/react-reconciler": "^0.28.0" + } + }, "jake": { "version": "10.8.5", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", diff --git a/package.json b/package.json index 0c901cbd..c9f3bbbc 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@nextcloud/sharing": "^0.1.0", "@nextcloud/vue": "7.8.0", "camelcase": "^7.0.1", - "filerobot-image-editor": "^4.3.8", + "filerobot-image-editor": "^4.4.0", "fuse.js": "^6.6.2", "hammerjs": "^2.0.8", "justified-layout": "^4.1.0", diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 13541e6e..641b8cb1 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -9,9 +9,7 @@ > @@ -186,7 +184,6 @@ import NcActions from "@nextcloud/vue/dist/Components/NcActions"; import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton"; import axios from "@nextcloud/axios"; import { subscribe, unsubscribe } from "@nextcloud/event-bus"; -import { getRootUrl } from "@nextcloud/router"; import { getDownloadLink } from "../../services/DavRequests"; import { API } from "../../services/API"; @@ -904,18 +901,7 @@ export default defineComponent({ return; } - // Get DAV path - const fileInfo = (await dav.getFiles([this.currentPhoto]))[0]; - if (!fileInfo) { - alert(this.t("memories", "Cannot edit this file")); - return; - } - - this.editorSrc = - window.location.origin + - getRootUrl() + - "/remote.php/dav" + - fileInfo.originalFilename; + // Open editor this.editorOpen = true; }, diff --git a/src/services/API.ts b/src/services/API.ts index 910f9216..35837638 100644 --- a/src/services/API.ts +++ b/src/services/API.ts @@ -142,6 +142,10 @@ export class API { return tok(API.Q(gen(`${BASE}/image/decodable/{id}`, { id }), { etag })); } + static IMAGE_EDIT(id: number) { + return gen(`${BASE}/image/edit/{id}`, { id }); + } + static VIDEO_TRANSCODE(fileid: number, file = "index.m3u8") { return tok( gen(`${BASE}/video/transcode/{videoClientId}/{fileid}/{file}`, { diff --git a/test.php b/test.php deleted file mode 100644 index d59a7b95..00000000 --- a/test.php +++ /dev/null @@ -1,312 +0,0 @@ -finetuneOrder[] = $key; - } - } - - if ($props = $json['finetuneProps']) { - $this->_set($props, 'brightness'); - $this->_set($props, 'contrast'); - $this->_set($props, 'hue'); - $this->_set($props, 'saturation'); - $this->_set($props, 'value'); - $this->_set($props, 'blurRadius'); - $this->_set($props, 'warmth'); - } - - if ($props = $json['adjustments']) { - if ($crop = $props['crop']) { - $this->_set($crop, 'x', 'cropX'); - $this->_set($crop, 'y', 'cropY'); - $this->_set($crop, 'width', 'cropWidth'); - $this->_set($crop, 'height', 'cropHeight'); - } - $this->_set($props, 'rotation'); - $this->_set($props, 'isFlippedX'); - $this->_set($props, 'isFlippedY'); - } - - if ($filter = $json['filter']) { - // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/components/tools/Filters/Filters.constants.js#L8 - $this->filter = $filter; - } - } - - private function _set(array $parent, string $key, string $ckey = null) { - $ckey ??= $key; - if (array_key_exists($key, $parent)) { - $this->$ckey = $parent[$key]; - } - } -} - -class FileRobotMagick { - private \Imagick $image; - private FileRobotImageState $state; - - public function __construct(\Imagick $image, FileRobotImageState $state) - { - $this->image = $image; - $this->state = $state; - } - - public function apply() { - $this->applyCrop(); - $this->applyFlipRotation(); - - foreach ($this->state->finetuneOrder as $key) { - $method = 'apply' . $key; - if (!method_exists($this, $method)) { - throw new \Exception('Unknown finetune: ' . $key); - } - $this->$method(); - } - - if ($this->state->filter) { - $method = 'applyFilter' . $this->state->filter; - if (!method_exists($this, $method)) { - throw new \Exception('Unknown filter: ' . $this->state->filter); - } - $this->$method(); - } - - return $this->image; - } - - protected function applyCrop() { - if ($this->state->cropX || $this->state->cropY || $this->state->cropWidth || $this->state->cropHeight) { - $iw = $this->image->getImageWidth(); - $ih = $this->image->getImageHeight(); - $this->image->cropImage( - (int) (($this->state->cropWidth ?? 1) * $iw), - (int) (($this->state->cropHeight ?? 1) * $ih), - (int) (($this->state->cropX ?? 0) * $iw), - (int) (($this->state->cropY ?? 0) * $ih) - ); - } - } - - protected function applyFlipRotation() { - if ($this->state->isFlippedX) { - $this->image->flopImage(); - } - if ($this->state->isFlippedY) { - $this->image->flipImage(); - } - if ($this->state->rotation) { - $this->image->rotateImage(new \ImagickPixel(), $this->state->rotation); - } - } - - protected function applyBrighten(?float $value = null) { - $brightness = $value ?? $this->state->brightness ?? 0; - if ($brightness === 0) { - return; - } - - // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/Brighten.ts#L15-L29 - $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $brightness * 255 * 255, \Imagick::CHANNEL_ALL); - } - - protected function applyContrast(?float $value = null) { - $contrast = $value ?? $this->state->contrast ?? 0; - if ($contrast === 0) { - return; - } - - // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/Contrast.ts#L15-L59 - // m = ((a + 100) / 100) ** 2 // slope - // y = (x - 0.5) * m + 0.5 - // y = mx + (0.5 * (1 - m)) // simplify - $m = (($contrast + 100) / 100) ** 2; - $c = 0.5 * (1 - $m); - - $this->image->functionImage(\Imagick::FUNCTION_POLYNOMIAL, [$m, $c], \Imagick::CHANNEL_ALL); - } - - protected function applyHSV(?float $hue = null, ?float $saturation = null, ?float $value = null) { - $hue ??= $this->state->hue ?? 0; - $saturation ??= $this->state->saturation ?? 0; - $value ??= $this->state->value ?? 0; - - if ($hue === 0 && $saturation === 0 && $value === 0) { - return; - } - - $h = abs(($hue ?? 0) + 360) % 360; - $s = 2 ** ($saturation ?? 0); - $v = 2 ** ($value ?? 0); - - // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/HSV.ts#L17-L63 - $vsu = $v * $s * cos(($h * pi()) / 180); - $vsw = $v * $s * sin(($h * pi()) / 180); - - $rr = 0.299 * $v + 0.701 * $vsu + 0.167 * $vsw; - $rg = 0.587 * $v - 0.587 * $vsu + 0.33 * $vsw; - $rb = 0.114 * $v - 0.114 * $vsu - 0.497 * $vsw; - $gr = 0.299 * $v - 0.299 * $vsu - 0.328 * $vsw; - $gg = 0.587 * $v + 0.413 * $vsu + 0.035 * $vsw; - $gb = 0.114 * $v - 0.114 * $vsu + 0.293 * $vsw; - $br = 0.299 * $v - 0.3 * $vsu + 1.25 * $vsw; - $bg = 0.587 * $v - 0.586 * $vsu - 1.05 * $vsw; - $bb = 0.114 * $v + 0.886 * $vsu - 0.2 * $vsw; - - $colorMatrix = [ - $rr, $rg, $rb, 0, 0, - $gr, $gg, $gb, 0, 0, - $br, $bg, $bb, 0, 0, - 0, 0, 0, 1, 0, - 0, 0, 0, 0, 1, - ]; - - $this->image->colorMatrixImage($colorMatrix); - } - - protected function applyBlur() { - if ($this->state->blurRadius <= 0) { - return; - } - - // https://github.com/konvajs/konva/blob/f0e18b09079175404a1026363689f8f89eae0749/src/filters/Blur.ts#L834 - $sigma = min(round($this->state->blurRadius * 1.5), 100); - $this->image->blurImage(0, $sigma); - } - - protected function applyWarmth() { - // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/finetunes/Warmth.js#L17-L28 - $warmth = ($this->state->warmth ?? 0); - if ($warmth <= 0) { - return; - } - - // Add to red channel, subtract from blue channel - $this->image->evaluateImage(\Imagick::EVALUATE_ADD, $warmth*255, \Imagick::CHANNEL_RED); - $this->image->evaluateImage(\Imagick::EVALUATE_SUBTRACT, $warmth*255, \Imagick::CHANNEL_BLUE); - } - - protected function applyFilterInvert() { - $this->image->negateImage(false); - } - - protected function applyFilterBlackAndWhite() { - // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BlackAndWhite.js - $this->image->thresholdImage(100 * 255); - } - - protected function applyFilterSepia() { - // https://github.com/konvajs/konva/blob/master/src/filters/Sepia.ts - $this->image->colorMatrixImage([ - 0.393, 0.769, 0.189, 0, 0, - 0.349, 0.686, 0.168, 0, 0, - 0.272, 0.534, 0.131, 0, 0, - 0, 0, 0, 1, 0, - 0, 0, 0, 0, 1, - ]); - } - - protected function applyFilterSolarize() { - // https://github.com/konvajs/konva/blob/master/src/filters/Solarize.ts - $this->image->solarizeImage(128 * 255); - } - - protected function applyFilterClarendon() { - // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/Clarendon.js - $this->applyBaseFilterBrightness(0.1); - $this->applyContrast(10); // TODO: this is wrong - $this->applyHSV(0, 0.15, 0); // TODO: this is wrong - } - - protected function applyFilterGingham() { - // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/Gingham.js - // ... - } - - protected function applyBaseFilterBrightness(float $value) { - // https://github.com/scaleflex/filerobot-image-editor/blob/7113bf4968d97f41381f4a2965a59defd44562c8/packages/react-filerobot-image-editor/src/custom/filters/BaseFilters.js#L2 - $this->applyBrighten($value); - } - - protected function applyFilterTest() { - $this->applyFilterClarendon(); - } -} - -// Create new ImageState object -$imageState = new FileRobotImageState([ - 'finetunes' => ['Blur', 'Warmth', 'HSV', 'Contrast', 'Brighten'], - 'finetuneProps' => [ - 'brightness' => 0, - 'contrast' => 0, - 'hue' => 0, - 'saturation' => 0, - 'value' => 0, - 'blurRadius' => 0, - 'warmth' => 0, - ], - 'filter' => 'Test', - 'adjustments' =>[ - // 'crop' => [ - // 'x' => 0.04811054824217651, - // 'y' => 0.30121176094862184, - // 'width' => 0.47661152675402463, - // 'height' => 0.47661153565936554, - // ], - // 'rotation' => 0, - // 'isFlippedX' => false, - // 'isFlippedY' => false, - ] -]); - -// Open test image file imagick -$image = new \Imagick('test.jpg'); -$image->setResourceLimit(\Imagick::RESOURCETYPE_THREAD, 4); - -// Apply image state -(new FileRobotMagick($image, $imageState))->apply(); - -//resize to max width -$image->resizeImage(800, 0, \Imagick::FILTER_LANCZOS, 1); - -// Write to out.jpg -$image->writeImage('out.jpg'); \ No newline at end of file