From 9286d4e63a69ebd32be068c84fd88676378f6175 Mon Sep 17 00:00:00 2001 From: Arne Brasseur Date: Sun, 15 Sep 2024 12:00:48 +0200 Subject: [PATCH] Resize session images to 500x500 --- deps.edn | 3 +- ops/nginx.conf | 1 + src/co/gaiwan/compass/db/schema.clj | 1 + src/co/gaiwan/compass/html/profiles.clj | 1 - src/co/gaiwan/compass/images.clj | 98 +++++++++++++++++++++++ src/co/gaiwan/compass/model/assets.clj | 56 ++++++++++++- src/co/gaiwan/compass/model/session.clj | 3 +- src/co/gaiwan/compass/repl.clj | 4 + src/co/gaiwan/compass/routes/profiles.clj | 6 +- src/co/gaiwan/compass/routes/sessions.clj | 3 +- 10 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 src/co/gaiwan/compass/images.clj diff --git a/deps.edn b/deps.edn index 7ad4d3e..1da640e 100644 --- a/deps.edn +++ b/deps.edn @@ -35,7 +35,8 @@ potemkin/potemkin {:mvn/version "0.4.7"} ; def-map-type ;; Markdown - markdown-to-hiccup/markdown-to-hiccup {:mvn/version "0.6.2"}} + markdown-to-hiccup/markdown-to-hiccup {:mvn/version "0.6.2"} + com.drewnoakes/metadata-extractor {:mvn/version "2.19.0"}} :aliases {:dev diff --git a/ops/nginx.conf b/ops/nginx.conf index 4ccf50b..95180b9 100644 --- a/ops/nginx.conf +++ b/ops/nginx.conf @@ -2,6 +2,7 @@ server { listen 8000; listen [::]:8000; server_name compass.heartofclojure.eu; + client_max_body_size 20M; location /uploads/ { alias /home/compass/uploads/; diff --git a/src/co/gaiwan/compass/db/schema.clj b/src/co/gaiwan/compass/db/schema.clj index a882474..fdee7a9 100644 --- a/src/co/gaiwan/compass/db/schema.clj +++ b/src/co/gaiwan/compass/db/schema.clj @@ -53,6 +53,7 @@ [:session/duration :string "Duration of the session in ISO interval notation"] [:session/location :ref "Where does the session take place"] [:session/image :string "Image URL, either absolute, or relative to compass root"] + [:session/thumbnail :string "Downsized image thumbnail"] [:session/capacity :long "Number of people that are able to join this session"] [:session/ticket-required? :boolean "If this session requires a ticket"] [:session/published? :boolean "If this session is published/visible?"] diff --git a/src/co/gaiwan/compass/html/profiles.clj b/src/co/gaiwan/compass/html/profiles.clj index 757da03..0739e74 100644 --- a/src/co/gaiwan/compass/html/profiles.clj +++ b/src/co/gaiwan/compass/html/profiles.clj @@ -117,7 +117,6 @@ :border-radius t/--size-2} [#{:textarea "input[type='text']"} {:background-color t/--surface-3}]] ([user] - (def user user) [:<> [:h2 "Edit Profile"] [:form {:method "POST" :action "/profile/save" :enctype "multipart/form-data"} diff --git a/src/co/gaiwan/compass/images.clj b/src/co/gaiwan/compass/images.clj new file mode 100644 index 0000000..8ea46c2 --- /dev/null +++ b/src/co/gaiwan/compass/images.clj @@ -0,0 +1,98 @@ +(ns co.gaiwan.compass.images + (:require + [clojure.java.io :as io]) + (:import + (java.awt.geom AffineTransform) + (java.awt.image AffineTransformOp BufferedImage DataBufferByte Raster) + (java.io ByteArrayInputStream ByteArrayOutputStream) + (java.lang ArrayIndexOutOfBoundsException) + (javax.imageio ImageIO))) + +(set! *warn-on-reflection* true) + +(def ^"[F" float4 (make-array Float/TYPE 4)) + +(defn image-raster ^Raster [^BufferedImage img] + (.. img getRaster)) + +(defn raster-data [img] + (.getData ^DataBufferByte (.getDataBuffer (image-raster img)))) + +(defn read-img [path] + (ImageIO/read (io/file path))) + +(defn bytes->img [bytes] + (ImageIO/read (ByteArrayInputStream. bytes))) + +(defn write-png [path img] + (ImageIO/write + ^java.awt.image.RenderedImage img + "png" + (io/file path))) + +(defn slice [img [x1 y1 x2 y2]] + (let [src-raster (image-raster img) + width (int (Math/ceil (inc (- x2 x1)))) + height (int (Math/ceil (inc (- y2 y1)))) + dest (BufferedImage. width height BufferedImage/TYPE_INT_ARGB) + ^java.awt.image.WritableRaster dest-raster (image-raster dest)] + (doseq [x (range width) + y (range height) + px (.getPixel src-raster (int (+ x1 x)) (int (+ y1 y)) float4)] + (try + (.setPixel dest-raster (int x) (int y) float4) + (catch ArrayIndexOutOfBoundsException e + (println "out of bounds:" [x1 y1 x2 y2] [x y] :width width :height height) + (throw e)))) + dest)) + +(defn width [^BufferedImage img] + (.getWidth img)) + +(defn height [^BufferedImage img] + (.getHeight img)) + +(defn new-img ^BufferedImage [w h] + (BufferedImage. w h BufferedImage/TYPE_INT_ARGB)) + +(defn apply-transform [^BufferedImage in ^BufferedImage out ^AffineTransform at] + (let [^AffineTransformOp at-op (AffineTransformOp. at AffineTransformOp/TYPE_BILINEAR)] + (.filter at-op in out))) + +(defn scale-img [^BufferedImage img scale] + (apply-transform + img + (new-img (* (width img) scale) + (* (height img) scale)) + (AffineTransform/getScaleInstance scale scale))) + +(defn rotate-180 [^BufferedImage img] + (apply-transform + img + (new-img (width img) (height img)) + (AffineTransform/getRotateInstance Math/PI (/ (width img) 2) (/ (height img) 2)))) + +(defn rotate-clockwise [^BufferedImage img] + (let [h (height img) w (width img)] + (apply-transform + img + (new-img h w) + (doto (AffineTransform.) + (.translate (/ h 2) (/ w 2)) + (.rotate (/ Math/PI 2)) + (.translate (- (/ w 2)) (- (/ h 2))))))) + +(defn rotate-counterclockwise [^BufferedImage img] + (let [h (height img) w (width img)] + (apply-transform + img + (new-img h w) + (doto (AffineTransform.) + (.translate (/ h 2) (/ w 2)) + (.rotate (* 3/2 Math/PI)) + (.translate (- (/ w 2)) (- (/ h 2))))))) + +(defn image-png-bytes [^BufferedImage img] + (let [baos (ByteArrayOutputStream.)] + (ImageIO/write img "png" baos) + (.toByteArray baos))) diff --git a/src/co/gaiwan/compass/model/assets.clj b/src/co/gaiwan/compass/model/assets.clj index cbcd2ec..c17d5dd 100644 --- a/src/co/gaiwan/compass/model/assets.clj +++ b/src/co/gaiwan/compass/model/assets.clj @@ -3,10 +3,14 @@ [clojure.java.io :as io] [clojure.string :as str] [co.gaiwan.compass.config :as config] + [co.gaiwan.compass.images :as images] [hato.client :as hato]) (:import (java.security MessageDigest) - (java.util Formatter))) + (java.util Formatter) + (javax.imageio ImageIO))) + +(set! *warn-on-reflection* true) (defn file-extension [mime] (let [[mime] (str/split mime #"\s*;\s*")] @@ -16,6 +20,14 @@ "image/webp" "webp" "image/svg+xml" "svg"} mime))) +(defn ext->mime [path] + (let [ext (str/lower-case (last (str/split (str path) #"\.")))] + ({"png" "image/png" + "jpeg" "image/jpeg" + "gif" "image/gif" + "webp" "image/webp" + "svg" "image/svg+xml"} ext))) + (defn sha256 [bytes] (.digest (MessageDigest/getInstance "SHA-256") bytes)) @@ -49,7 +61,9 @@ (bytes? source) source (instance? java.io.File source) - (java.nio.file.Files/readAllBytes (.toPath source)) + (java.nio.file.Files/readAllBytes (.toPath ^java.io.File source)) + (instance? java.awt.image.BufferedImage source) + (images/image-png-bytes source) :else (throw (ex-info {:source (class source)} "Unsupported data source"))) filename (str (sha256-hex bytes) "." ext) @@ -64,7 +78,45 @@ (let [{:keys [^bytes body headers]} (hato/get url {:as :byte-array})] (add-to-content-addressed-storage (get headers "content-type") body))) +(defn jpeg-orientation [f] + ;; 1: Normal (0° rotation) + ;; 3: Upside-down (180° rotation) + ;; 6: Rotated 90° counterclockwise (270° clockwise) + ;; 8: Rotated 90° clockwise (270° counterclockwise) + (case + (.getInt + (.getFirstDirectoryOfType + (com.drew.imaging.ImageMetadataReader/readMetadata (io/file f)) + com.drew.metadata.exif.ExifIFD0Directory) + com.drew.metadata.exif.ExifIFD0Directory/TAG_ORIENTATION) + 3 :upside-down + 6 :counterclockwise + 8 :clockwise)) + +(defn resize-image + ([path max-width max-height] + (resize-image (ext->mime path) path max-width max-height)) + ([mime-type path max-width max-height] + (let [source (images/read-img path) + width (images/width source) + height (images/height source) + aspect (/ width height) + scale (min (/ max-width width) + (/ max-height height)) + rotation (when (= "image/jpeg" mime-type) + (jpeg-orientation (io/file path))) + img (images/scale-img source scale)] + (case rotation + nil img + :upside-down (images/rotate-180 img) + :counterclockwise (images/rotate-clockwise img) + :clockwise (images/rotate-counterclockwise img))))) + (comment + (def path "/home/arne/Downloads/IMG_20240914_212508.jpg") + (images/write-png "/tmp/resized.png" + (resize-image path 500 500)) + (add-to-content-addressed-storage "image/png" (resize-image path 500 500)) (def r (hato/get "https://cdn.discordapp.com/avatars/758588684177768469/8b32119c1ae262544e2952ea60aaf9a7.png" {:as :byte-array})) (download-image "https://cdn.discordapp.com/avatars/758588684177768469/8b32119c1ae262544e2952ea60aaf9a7.png")) diff --git a/src/co/gaiwan/compass/model/session.clj b/src/co/gaiwan/compass/model/session.clj index 78b983a..b36eb01 100644 --- a/src/co/gaiwan/compass/model/session.clj +++ b/src/co/gaiwan/compass/model/session.clj @@ -115,7 +115,8 @@ (merge default-filters filters))) (defn session-image-css-value [session] - (str "url(" (assets/image-url (:session/image session)) ")")) + (str "url(" (assets/image-url (or (:session/thumbnail session) + (:session/image session))) ")")) (defn subtitle "Returns the subtitle if there is one, or 'organized by ' otherwise." diff --git a/src/co/gaiwan/compass/repl.clj b/src/co/gaiwan/compass/repl.clj index 7fe9a12..42bc8f8 100644 --- a/src/co/gaiwan/compass/repl.clj +++ b/src/co/gaiwan/compass/repl.clj @@ -23,6 +23,10 @@ (db/db) name-or-email))) +(:db/id + (user "Arne")) + +{:user/uuid #uuid "ee944d53-0c49-486c-9b4e-a178491673ba", :public-profile/name "Arne", :public-profile/avatar-url "66c1ebd5cfd87056a7fd591c773efe4cfe022304554e3f49988fb7a240010c19.png", :discord/id "758588684177768469", :discord/access-token "2cYNs1YwseOCwjmlkid2r7QiiVIl01", :discord/expires-at #time/zdt "2024-09-21T09:37:58.756+02:00[Europe/Brussels]", :discord/refresh-token "YauvlnohxgWk7XREbfRD02cXsV61xj", :discord/email "arne.brasseur@gmail.com"} (defn sessions [] (map db/entity (db/q '[:find [?e ...] :where diff --git a/src/co/gaiwan/compass/routes/profiles.clj b/src/co/gaiwan/compass/routes/profiles.clj index 8df7399..4cdc2d9 100644 --- a/src/co/gaiwan/compass/routes/profiles.clj +++ b/src/co/gaiwan/compass/routes/profiles.clj @@ -17,8 +17,8 @@ (defn GET-profile [{:keys [params] :as req}] {:html/body [h/profile-detail - (if-let [profile-id (:profile-id params)] - (db/entity profile-id) + (if-let [user-uuid (:user-uuid params)] + (db/entity [:user/uuid user-uuid]) (:identity req))]}) (defn GET-profile-form [req] @@ -191,7 +191,7 @@ ["/uploads/:filename" {:middleware [[response/wrap-requires-auth]] :get {:handler file-handler}}] - ["/user/:profile-id" + ["/user/:user-uuid" {:name :profile/show :middleware [[response/wrap-requires-auth]] :get {:handler GET-profile}}] diff --git a/src/co/gaiwan/compass/routes/sessions.clj b/src/co/gaiwan/compass/routes/sessions.clj index 5aea752..303c7e7 100644 --- a/src/co/gaiwan/compass/routes/sessions.clj +++ b/src/co/gaiwan/compass/routes/sessions.clj @@ -77,7 +77,8 @@ (= published? "on") (assoc :session/published? true) image - (assoc :session/image (assets/add-to-content-addressed-storage (:content-type image) (:tempfile image)))))) + (assoc :session/image (assets/add-to-content-addressed-storage (:content-type image) (:tempfile image)) + :session/thumbnail (assets/add-to-content-addressed-storage "image/png" (assets/resize-image (:content-type image) (:tempfile image) 500 500)))))) (defn POST-create-session "Create new session, save to Datomic