♻️ Refactor thumbnail generation.

This commit is contained in:
Andrey Antukh 2020-06-09 12:47:51 +02:00 committed by Andrés Moya
parent 60ec32f7cc
commit 3f554df687
5 changed files with 194 additions and 142 deletions

View file

@ -34,6 +34,8 @@
:media-uri "http://localhost:3449/media/" :media-uri "http://localhost:3449/media/"
:assets-uri "http://localhost:3449/static/" :assets-uri "http://localhost:3449/static/"
:image-process-max-threads 2
:sendmail-backend "console" :sendmail-backend "console"
:sendmail-reply-to "no-reply@example.com" :sendmail-reply-to "no-reply@example.com"
:sendmail-from "no-reply@example.com" :sendmail-from "no-reply@example.com"
@ -71,6 +73,7 @@
(s/def ::debug-humanize-transit ::us/boolean) (s/def ::debug-humanize-transit ::us/boolean)
(s/def ::public-uri ::us/string) (s/def ::public-uri ::us/string)
(s/def ::backend-uri ::us/string) (s/def ::backend-uri ::us/string)
(s/def ::image-process-max-threads ::us/integer)
(s/def ::google-client-id ::us/string) (s/def ::google-client-id ::us/string)
(s/def ::google-client-secret ::us/string) (s/def ::google-client-secret ::us/string)
@ -101,7 +104,8 @@
::smtp-ssl ::smtp-ssl
::debug-humanize-transit ::debug-humanize-transit
::allow-demo-users ::allow-demo-users
::registration-enabled])) ::registration-enabled
::image-process-max-threads]))
(defn env->config (defn env->config
[env] [env]

View file

@ -10,108 +10,155 @@
(ns uxbox.images (ns uxbox.images
"Image postprocessing." "Image postprocessing."
(:require (:require
[clojure.core.async :as a]
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[datoteka.core :as fs] [datoteka.core :as fs]
[uxbox.common.exceptions :as ex] [mount.core :refer [defstate]]
[uxbox.config :as cfg]
[uxbox.common.data :as d] [uxbox.common.data :as d]
[uxbox.common.exceptions :as ex]
[uxbox.common.spec :as us] [uxbox.common.spec :as us]
[uxbox.util.storage :as ust] [uxbox.media :as media]
[uxbox.media :as media]) [uxbox.util.storage :as ust])
(:import (:import
java.io.ByteArrayInputStream java.io.ByteArrayInputStream
java.io.InputStream java.io.InputStream
java.util.concurrent.Semaphore
org.im4java.core.ConvertCmd org.im4java.core.ConvertCmd
org.im4java.core.Info org.im4java.core.Info
org.im4java.core.IMOperation)) org.im4java.core.IMOperation))
;; --- Helpers (defstate semaphore
:start (Semaphore. (:image-process-max-threads cfg/config 1)))
(defn format->extension
[format]
(case format
"jpeg" ".jpg"
"webp" ".webp"))
(defn format->mtype
[format]
(case format
"jpeg" "image/jpeg"
"webp" "image/webp"))
;; --- Thumbnails Generation ;; --- Thumbnails Generation
(s/def ::cmd keyword?)
(s/def ::path (s/or :path fs/path?
:string string?
:file fs/file?))
(s/def ::mtype string?)
(s/def ::input
(s/keys :req-un [::path]
:opt-un [::mtype]))
(s/def ::width integer?) (s/def ::width integer?)
(s/def ::height integer?) (s/def ::height integer?)
(s/def ::format #{:jpeg :webp :png})
(s/def ::quality #(< 0 % 101)) (s/def ::quality #(< 0 % 101))
(s/def ::format #{"jpeg" "webp"})
(s/def ::thumbnail-opts (s/def ::thumbnail-params
(s/keys :opt-un [::format ::quality ::width ::height])) (s/keys :req-un [::cmd ::input ::format ::width ::height]))
;; Related info on how thumbnails generation ;; Related info on how thumbnails generation
;; http://www.imagemagick.org/Usage/thumbnails/ ;; http://www.imagemagick.org/Usage/thumbnails/
(defn generate-thumbnail (defn format->extension
([input] (generate-thumbnail input nil)) [format]
([input {:keys [quality format width height] (case format
:or {format "jpeg" :png ".png"
quality 92 :jpeg ".jpg"
width 200 :webp ".webp"))
height 200}
:as opts}]
(us/assert ::thumbnail-opts opts)
(us/assert fs/path? input)
(let [ext (format->extension format)
tmp (fs/create-tempfile :suffix ext)
opr (doto (IMOperation.)
(.addImage)
(.autoOrient)
(.strip)
(.thumbnail (int width) (int height) ">")
(.quality (double quality))
(.addImage))]
(doto (ConvertCmd.)
(.run opr (into-array (map str [input tmp]))))
(let [thumbnail-data (fs/slurp-bytes tmp)]
(fs/delete tmp)
(ByteArrayInputStream. thumbnail-data)))))
(defn generate-profile-thumbnail (defn format->mtype
([input] (generate-thumbnail input nil)) [format]
([input {:keys [quality format width height] (case format
:or {format "jpeg" :png "image/png"
quality 92 :jpeg "image/jpeg"
width 200 :webp "image/webp"))
height 200}
:as opts}]
(us/assert ::thumbnail-opts opts)
(us/assert fs/path? input)
(let [ext (format->extension format)
tmp (fs/create-tempfile :suffix ext)
opr (doto (IMOperation.)
(.addImage)
(.autoOrient)
(.strip)
(.thumbnail (int width) (int height) "^")
(.gravity "center")
(.extent (int width) (int height))
(.quality (double quality))
(.addImage))]
(doto (ConvertCmd.)
(.run opr (into-array (map str [input tmp]))))
(let [thumbnail-data (fs/slurp-bytes tmp)]
(fs/delete tmp)
(ByteArrayInputStream. thumbnail-data)))))
(defn info (defn mtype->format
[content-type path] [mtype]
(let [instance (Info. (str path))] (case mtype
(when-not (= content-type (.getProperty instance "Mime type")) "image/jpeg" :jpeg
"image/webp" :webp
"image/png" :png
nil))
(defn- generic-process
[{:keys [input format quality operation] :as params}]
(let [{:keys [path mtype]} input
format (or (mtype->format mtype) format)
ext (format->extension format)
tmp (fs/create-tempfile :suffix ext)]
(doto (ConvertCmd.)
(.run operation (into-array (map str [path tmp]))))
(let [thumbnail-data (fs/slurp-bytes tmp)]
(fs/delete tmp)
(assoc params
:format format
:mtype (format->mtype format)
:data (ByteArrayInputStream. thumbnail-data)))))
(defmulti process :cmd)
(defmethod process :generic-thumbnail
[{:keys [quality width height] :as params}]
(us/assert ::thumbnail-params params)
(let [op (doto (IMOperation.)
(.addImage)
(.autoOrient)
(.strip)
(.thumbnail (int width) (int height) ">")
(.quality (double quality))
(.addImage))]
(generic-process (assoc params :operation op))))
(defmethod process :profile-thumbnail
[{:keys [quality width height] :as params}]
(us/assert ::thumbnail-params params)
(let [op (doto (IMOperation.)
(.addImage)
(.autoOrient)
(.strip)
(.thumbnail (int width) (int height) "^")
(.gravity "center")
(.extent (int width) (int height))
(.quality (double quality))
(.addImage))]
(generic-process (assoc params :operation op))))
(defmethod process :info
[{:keys [input] :as params}]
(us/assert ::input input)
(let [{:keys [path mtype]} input
instance (Info. (str path))
mtype' (.getProperty instance "Mime type")]
(when (and (string? mtype)
(not= mtype mtype'))
(ex/raise :type :validation (ex/raise :type :validation
:code :image-type-mismatch :code :image-type-mismatch
:hint "Seems like you are uploading a file whose content does not match the extension.")) :hint "Seems like you are uploading a file whose content does not match the extension."))
{:width (.getImageWidth instance) {:width (.getImageWidth instance)
:height (.getImageHeight instance)})) :height (.getImageHeight instance)
:mtype mtype'}))
(defmethod process :default
[{:keys [cmd] :as params}]
(ex/raise :type :internal
:code :not-implemented
:hint (str "No impl found for process cmd:" cmd)))
(defn run
[params]
(try
(.acquire semaphore)
(let [res (a/<!! (a/thread
(try
(process params)
(catch Throwable e
e))))]
(if (instance? Throwable res)
(throw res)
res))
(finally
(.release semaphore))))
(defn resolve-urls (defn resolve-urls
[row src dst] [row src dst]

View file

@ -119,12 +119,6 @@
(mark-file-deleted conn params))) (mark-file-deleted conn params)))
(def ^:private sql:mark-file-deleted
"update file
set deleted_at = clock_timestamp()
where id = ?
and deleted_at is null")
(defn mark-file-deleted (defn mark-file-deleted
[conn {:keys [id] :as params}] [conn {:keys [id] :as params}]
(db/update! conn :file (db/update! conn :file
@ -150,14 +144,6 @@
(files/check-edition-permissions! conn profile-id file-id) (files/check-edition-permissions! conn profile-id file-id)
(create-file-image conn params))) (create-file-image conn params)))
(def ^:private sql:insert-file-image
"insert into file_image
(file_id, name, path, width, height, mtype,
thumb_path, thumb_width, thumb_height,
thumb_quality, thumb_mtype)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
returning *")
(defn- create-file-image (defn- create-file-image
[conn {:keys [content file-id name] :as params}] [conn {:keys [content file-id name] :as params}]
(when-not (imgs/valid-image-types? (:content-type content)) (when-not (imgs/valid-image-types? (:content-type content))
@ -165,22 +151,26 @@
:code :image-type-not-allowed :code :image-type-not-allowed
:hint "Seems like you are uploading an invalid image.")) :hint "Seems like you are uploading an invalid image."))
(let [image-opts (images/info (:content-type content) (:tempfile content)) (let [info (images/run {:cmd :info :input {:path (:tempfile content)
image-path (imgs/persist-image-on-fs content) :mtype (:content-type content)}})
thumb-opts imgs/thumbnail-options path (imgs/persist-image-on-fs content)
thumb-path (imgs/persist-image-thumbnail-on-fs thumb-opts image-path)] opts (assoc imgs/thumbnail-options
:input {:mtype (:mtype info)
:path path})
thumb (imgs/persist-image-thumbnail-on-fs opts)]
(-> (db/insert! conn :file-image (-> (db/insert! conn :file-image
{:file-id file-id {:file-id file-id
:name name :name name
:path (str image-path) :path (str path)
:width (:width image-opts) :width (:width info)
:height (:height image-opts) :height (:height info)
:mtype (:content-type content) :mtype (:mtype info)
:thumb-path (str thumb-path) :thumb-path (str (:path thumb))
:thumb-width (:width thumb-opts) :thumb-width (:width thumb)
:thumb-height (:height thumb-opts) :thumb-height (:height thumb)
:thumb-quality (:quality thumb-opts) :thumb-quality (:quality thumb)
:thumb-mtype (images/format->mtype (:format thumb-opts))}) :thumb-mtype (:mtype thumb)})
(images/resolve-urls :path :uri) (images/resolve-urls :path :uri)
(images/resolve-urls :thumb-path :thumb-uri)))) (images/resolve-urls :thumb-path :thumb-uri))))
@ -193,9 +183,6 @@
(s/def ::import-image-to-file (s/def ::import-image-to-file
(s/keys :req-un [::image-id ::file-id ::profile-id])) (s/keys :req-un [::image-id ::file-id ::profile-id]))
(def ^:private sql:select-image-by-id
"select img.* from image as img where id=$1")
(sm/defmutation ::import-image-to-file (sm/defmutation ::import-image-to-file
[{:keys [image-id file-id profile-id] :as params}] [{:keys [image-id file-id profile-id] :as params}]
(db/with-atomic [conn db/pool] (db/with-atomic [conn db/pool]

View file

@ -28,7 +28,7 @@
{:width 800 {:width 800
:height 800 :height 800
:quality 85 :quality 85
:format "jpeg"}) :format :jpeg})
(s/def ::id ::us/uuid) (s/def ::id ::us/uuid)
(s/def ::name ::us/string) (s/def ::name ::us/string)
@ -146,23 +146,27 @@
:code :image-type-not-allowed :code :image-type-not-allowed
:hint "Seems like you are uploading an invalid image.")) :hint "Seems like you are uploading an invalid image."))
(let [image-opts (images/info (:content-type content) (:tempfile content)) (let [info (images/run {:cmd :info :input {:path (:tempfile content)
image-path (persist-image-on-fs content) :mtype (:content-type content)}})
thumb-opts thumbnail-options path (persist-image-on-fs content)
thumb-path (persist-image-thumbnail-on-fs thumb-opts image-path)] opts (assoc thumbnail-options
:input {:mtype (:mtype info)
:path path})
thumb (persist-image-thumbnail-on-fs opts)]
(-> (db/insert! conn :image (-> (db/insert! conn :image
{:id (or id (uuid/next)) {:id (or id (uuid/next))
:library-id library-id :library-id library-id
:name name :name name
:path (str image-path) :path (str path)
:width (:width image-opts) :width (:width info)
:height (:height image-opts) :height (:height info)
:mtype (:content-type content) :mtype (:mtype info)
:thumb-path (str thumb-path) :thumb-path (str (:path thumb))
:thumb-width (:width thumb-opts) :thumb-width (:width thumb)
:thumb-height (:height thumb-opts) :thumb-height (:height thumb)
:thumb-quality (:quality thumb-opts) :thumb-quality (:quality thumb)
:thumb-mtype (images/format->mtype (:format thumb-opts))}) :thumb-mtype (:mtype thumb)})
(images/resolve-urls :path :uri) (images/resolve-urls :path :uri)
(images/resolve-urls :thumb-path :thumb-uri)))) (images/resolve-urls :thumb-path :thumb-uri))))
@ -172,14 +176,21 @@
(ust/save! media/media-storage filename tempfile))) (ust/save! media/media-storage filename tempfile)))
(defn persist-image-thumbnail-on-fs (defn persist-image-thumbnail-on-fs
[thumb-opts input-path] [{:keys [input] :as params}]
(let [input-path (ust/lookup media/media-storage input-path) (let [path (ust/lookup media/media-storage (:path input))
thumb-data (images/generate-thumbnail input-path thumb-opts) thumb (images/run
[filename _] (fs/split-ext (fs/name input-path)) (-> params
thumb-name (->> (images/format->extension (:format thumb-opts)) (assoc :cmd :generic-thumbnail)
(str "thumbnail-" filename))] (update :input assoc :path path)))
(ust/save! media/media-storage thumb-name thumb-data)))
name (str "thumbnail-"
(first (fs/split-ext (fs/name (:path input))))
(images/format->extension (:format thumb)))
path (ust/save! media/media-storage name (:data thumb))]
(-> thumb
(dissoc :data :input)
(assoc :path path))))
;; --- Mutation: Rename Image ;; --- Mutation: Rename Image

View file

@ -272,8 +272,15 @@
(sm/defmutation ::update-profile-photo (sm/defmutation ::update-profile-photo
[{:keys [profile-id file] :as params}] [{:keys [profile-id file] :as params}]
(when-not (imgs/valid-image-types? (:content-type file))
(ex/raise :type :validation
:code :image-type-not-allowed
:hint "Seems like you are uploading an invalid image."))
(db/with-atomic [conn db/pool] (db/with-atomic [conn db/pool]
(let [profile (profile/retrieve-profile conn profile-id) (let [profile (profile/retrieve-profile conn profile-id)
_ (images/run {:cmd :info :input {:path (:tempfile file)
:mtype (:content-type file)}})
photo (upload-photo conn params)] photo (upload-photo conn params)]
;; Schedule deletion of old photo ;; Schedule deletion of old photo
@ -286,21 +293,18 @@
(defn- upload-photo (defn- upload-photo
[conn {:keys [file profile-id]}] [conn {:keys [file profile-id]}]
(when-not (imgs/valid-image-types? (:content-type file)) (let [prefix (-> (sodi.prng/random-bytes 8)
(ex/raise :type :validation
:code :image-type-not-allowed
:hint "Seems like you are uploading an invalid image."))
(let [image-opts (images/info (:content-type file) (:tempfile file))
thumb-opts {:width 256
:height 256
:quality 75
:format "jpeg"}
prefix (-> (sodi.prng/random-bytes 8)
(sodi.util/bytes->b64s)) (sodi.util/bytes->b64s))
name (str prefix ".jpg") thumb (images/run
path (fs/path (:tempfile file)) {:cmd :profile-thumbnail
photo (images/generate-profile-thumbnail path thumb-opts)] :format :jpeg
(ust/save! media/media-storage name photo))) :quality 85
:width 256
:height 256
:input {:path (fs/path (:tempfile file))
:mtype (:content-type file)}})
name (str prefix (images/format->extension (:format thumb)))]
(ust/save! media/media-storage name (:data thumb))))
(defn- update-profile-photo (defn- update-profile-photo
[conn profile-id path] [conn profile-id path]
@ -309,7 +313,6 @@
{:id profile-id}) {:id profile-id})
nil) nil)
;; --- Mutation: Request Email Change ;; --- Mutation: Request Email Change
(declare select-profile-for-update) (declare select-profile-for-update)