Add thumbnail renderer

And integrate the dashboard thumbnails to use that service
This commit is contained in:
Andrey Antukh 2023-06-21 17:14:50 +02:00
parent 64ddfa0c31
commit d11b007795
19 changed files with 579 additions and 183 deletions

View file

@ -38,6 +38,11 @@
;; --- FEATURES ;; --- FEATURES
(defn resolve-public-uri
[media-id]
(when media-id
(str (cf/get :public-uri) "/assets/by-id/" media-id)))
(def supported-features (def supported-features
#{"storage/objects-map" #{"storage/objects-map"
"storage/pointer-map" "storage/pointer-map"
@ -413,15 +418,23 @@
f.modified_at, f.modified_at,
f.name, f.name,
f.revn, f.revn,
f.is_shared f.is_shared,
ft.media_id
from file as f from file as f
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where f.project_id = ? where f.project_id = ?
and f.deleted_at is null and f.deleted_at is null
order by f.modified_at desc") order by f.modified_at desc")
(defn get-project-files (defn get-project-files
[conn project-id] [conn project-id]
(db/exec! conn [sql:project-files project-id])) (->> (db/exec! conn [sql:project-files project-id])
(mapv (fn [row]
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(sv/defmethod ::get-project-files (sv/defmethod ::get-project-files
"Get all files for the specified project." "Get all files for the specified project."
@ -668,9 +681,11 @@
f.modified_at, f.modified_at,
f.name, f.name,
f.is_shared, f.is_shared,
ft.media_id,
row_number() over w as row_num row_number() over w as row_num
from file as f from file as f
join project as p on (p.id = f.project_id) inner join project as p on (p.id = f.project_id)
left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn)
where p.team_id = ? where p.team_id = ?
and p.deleted_at is null and p.deleted_at is null
and f.deleted_at is null and f.deleted_at is null
@ -681,7 +696,13 @@
(defn get-team-recent-files (defn get-team-recent-files
[conn team-id] [conn team-id]
(db/exec! conn [sql:team-recent-files team-id])) (->> (db/exec! conn [sql:team-recent-files team-id])
(mapv (fn [row]
(if-let [media-id (:media-id row)]
(-> row
(dissoc :media-id)
(assoc :thumbnail-uri (resolve-public-uri media-id)))
(dissoc row :media-id))))))
(s/def ::get-team-recent-files (s/def ::get-team-recent-files
(s/keys :req [::rpc/profile-id] (s/keys :req [::rpc/profile-id]

View file

@ -14,7 +14,6 @@
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.types.shape-tree :as ctt] [app.common.types.shape-tree :as ctt]
[app.config :as cf]
[app.db :as db] [app.db :as db]
[app.db.sql :as sql] [app.db.sql :as sql]
[app.loggers.audit :as-alias audit] [app.loggers.audit :as-alias audit]
@ -39,10 +38,6 @@
;; --- COMMAND QUERY: get-file-object-thumbnails ;; --- COMMAND QUERY: get-file-object-thumbnails
(defn- get-public-uri
[media-id]
(str (cf/get :public-uri) "/assets/by-id/" media-id))
(defn- get-object-thumbnails (defn- get-object-thumbnails
([conn file-id] ([conn file-id]
(let [sql (str/concat (let [sql (str/concat
@ -52,7 +47,7 @@
res (db/exec! conn [sql file-id])] res (db/exec! conn [sql file-id])]
(->> res (->> res
(d/index-by :object-id (fn [row] (d/index-by :object-id (fn [row]
(or (some-> row :media-id get-public-uri) (or (some-> row :media-id files/resolve-public-uri)
(:data row)))) (:data row))))
(d/without-nils)))) (d/without-nils))))
@ -65,7 +60,7 @@
res (db/exec! conn [sql file-id ids])] res (db/exec! conn [sql file-id ids])]
(d/index-by :object-id (d/index-by :object-id
(fn [row] (fn [row]
(or (some-> row :media-id get-public-uri) (or (some-> row :media-id files/resolve-public-uri)
(:data row))) (:data row)))
res)))) res))))
@ -85,8 +80,6 @@
;; --- COMMAND QUERY: get-file-thumbnail ;; --- COMMAND QUERY: get-file-thumbnail
;; FIXME: refactor to support uploading data to storage
(defn get-file-thumbnail (defn get-file-thumbnail
[conn file-id revn] [conn file-id revn]
(let [sql (sql/select :file-thumbnail (let [sql (sql/select :file-thumbnail
@ -95,10 +88,15 @@
{:limit 1 {:limit 1
:order-by [[:revn :desc]]}) :order-by [[:revn :desc]]})
row (db/exec-one! conn sql)] row (db/exec-one! conn sql)]
(when-not row (when-not row
(ex/raise :type :not-found (ex/raise :type :not-found
:code :file-thumbnail-not-found)) :code :file-thumbnail-not-found))
(when-not (:data row)
(ex/raise :type :not-found
:code :file-thumbnail-not-found))
{:data (:data row) {:data (:data row)
:props (some-> (:props row) db/decode-transit-pgobject) :props (some-> (:props row) db/decode-transit-pgobject)
:revn (:revn row) :revn (:revn row)
@ -113,20 +111,16 @@
:opt-un [::revn])) :opt-un [::revn]))
(sv/defmethod ::get-file-thumbnail (sv/defmethod ::get-file-thumbnail
"Method used in frontend for obtain the file thumbnail (used in the {::doc/added "1.17"
dashboard)." ::doc/deprecated "1.19"}
{::doc/added "1.17"}
[{:keys [::db/pool]} {:keys [::rpc/profile-id file-id revn]}] [{:keys [::db/pool]} {:keys [::rpc/profile-id file-id revn]}]
(dm/with-open [conn (db/open pool)] (dm/with-open [conn (db/open pool)]
(files/check-read-permissions! conn profile-id file-id) (files/check-read-permissions! conn profile-id file-id)
(-> (get-file-thumbnail conn file-id revn) (-> (get-file-thumbnail conn file-id revn)
(rph/with-http-cache long-cache-duration)))) (rph/with-http-cache long-cache-duration))))
;; --- COMMAND QUERY: get-file-data-for-thumbnail ;; --- COMMAND QUERY: get-file-data-for-thumbnail
;; FIXME: performance issue, handle new media_id
;;
;; We need to improve how we set frame for thumbnail in order to avoid ;; We need to improve how we set frame for thumbnail in order to avoid
;; loading all pages into memory for find the frame set for thumbnail. ;; loading all pages into memory for find the frame set for thumbnail.
@ -427,24 +421,27 @@
:bucket "file-thumbnail"})] :bucket "file-thumbnail"})]
(db/exec-one! conn [sql:create-file-thumbnail file-id revn (db/exec-one! conn [sql:create-file-thumbnail file-id revn
(:id media) props (:id media) props
(:id media) props]))) (:id media) props])
media))
(s/def ::media ::media/upload)
(s/def ::create-file-thumbnail
(s/keys :req [::rpc/profile-id]
:req-un [::file-id ::revn ::props ::media]))
(sv/defmethod ::create-file-thumbnail (sv/defmethod ::create-file-thumbnail
"Creates or updates the file thumbnail. Mainly used for paint the "Creates or updates the file thumbnail. Mainly used for paint the
grid thumbnails." grid thumbnails."
{::doc/added "1.19" {::doc/added "1.19"
::audit/skip true} ::audit/skip true
::sm/params [:map {:title "create-file-thumbnail"}
[:file-id ::sm/uuid]
[:revn :int]
[:media ::media/upload]]
}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(files/check-edition-permissions! conn profile-id file-id) (files/check-edition-permissions! conn profile-id file-id)
(when-not (db/read-only? conn) (when-not (db/read-only? conn)
(-> cfg (let [media (-> cfg
(update ::sto/storage media/configure-assets-storage) (update ::sto/storage media/configure-assets-storage)
(assoc ::db/conn conn) (assoc ::db/conn conn)
(create-file-thumbnail! params)) (create-file-thumbnail! params))]
nil)))
{:uri (files/resolve-public-uri (:id media))}))))

View file

@ -184,7 +184,7 @@
(when (seq res) (when (seq res)
(doseq [media-id (into #{} (keep :media-id) res)] (doseq [media-id (into #{} (keep :media-id) res)]
;; Mark as deleted the storage object related with the ;; Mark as deleted the storage object related with the
;; photo-id field. ;; media-id field.
(l/trace :hint "mark storage object as deleted" :id media-id) (l/trace :hint "mark storage object as deleted" :id media-id)
(sto/del-object! storage media-id)) (sto/del-object! storage media-id))

View file

@ -141,7 +141,7 @@
))) )))
(t/deftest upsert-file-thumbnail (t/deftest create-file-thumbnail
(let [storage (::sto/storage th/*system*) (let [storage (::sto/storage th/*system*)
profile (th/create-profile* 1) profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile) file (th/create-file* 1 {:profile-id (:id profile)
@ -159,7 +159,6 @@
data2 {::th/type :create-file-thumbnail data2 {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile) ::rpc/profile-id (:id profile)
:file-id (:id file) :file-id (:id file)
:props {}
:revn 2 :revn 2
:media {:filename "sample.jpg" :media {:filename "sample.jpg"
:size 7923 :size 7923
@ -169,7 +168,6 @@
data3 {::th/type :create-file-thumbnail data3 {::th/type :create-file-thumbnail
::rpc/profile-id (:id profile) ::rpc/profile-id (:id profile)
:file-id (:id file) :file-id (:id file)
:props {}
:revn 3 :revn 3
:media {:filename "sample.jpg" :media {:filename "sample.jpg"
:size 312043 :size 312043
@ -183,11 +181,11 @@
(let [out (th/command! data2)] (let [out (th/command! data2)]
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(t/is (nil? (:result out)))) (t/is (contains? (:result out) :uri)))
(let [out (th/command! data3)] (let [out (th/command! data3)]
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(t/is (nil? (:result out)))) (t/is (contains? (:result out) :uri)))
(let [[row1 row2 row3 :as rows] (th/db-query :file-thumbnail (let [[row1 row2 row3 :as rows] (th/db-query :file-thumbnail
{:file-id (:id file)} {:file-id (:id file)}

View file

@ -131,7 +131,8 @@ function readManifest() {
"polyfills": "js/polyfills.js", "polyfills": "js/polyfills.js",
"main": "js/main.js", "main": "js/main.js",
"shared": "js/shared.js", "shared": "js/shared.js",
"worker": "js/worker.js" "worker": "js/worker.js",
"thumbnail-renderer": "js/thumbnail-renderer.js"
}; };
} }
} }
@ -242,7 +243,17 @@ gulp.task("template:render", templatePipeline({
output: paths.output output: paths.output
})); }));
gulp.task("templates", gulp.series("svg:sprite:icons", "svg:sprite:cursors", "template:main", "template:render")); gulp.task("template:thumbnail-renderer", templatePipeline({
name: "thumbnail-renderer.html",
input: paths.resources + "templates/thumbnail-renderer.mustache",
output: paths.output
}));
gulp.task("templates", gulp.series("svg:sprite:icons",
"svg:sprite:cursors",
"template:main",
"template:render",
"template:thumbnail-renderer"));
gulp.task("polyfills", function() { gulp.task("polyfills", function() {
return gulp.src(paths.resources + "polyfills/*.js") return gulp.src(paths.resources + "polyfills/*.js")

View file

@ -51,6 +51,10 @@
border-radius: $br3; border-radius: $br3;
border: 2px solid lighten($color-gray-20, 15%); border: 2px solid lighten($color-gray-20, 15%);
text-align: initial; text-align: initial;
img {
object-fit: contain;
}
} }
&.dragged { &.dragged {

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Penpot - Thumbnail Renderer</title>
<link rel="icon" href="images/favicon.png" />
<script>
window.penpotVersion = "%version%";
window.penpotBuildDate = "%buildDate%";
</script>
{{# manifest}}
<script>window.penpotWorkerURI="{{& worker}}"</script>
<script src="{{& config}}"></script>
<script src="{{& polyfills}}"></script>
{{/manifest}}
</head>
<body>
{{# manifest}}
<script src="{{& shared}}"></script>
<script src="{{& thumbnail-renderer}}"></script>
{{/manifest}}
</body>
</html>

View file

@ -16,17 +16,25 @@
:modules :modules
{:shared {:entries []} {:shared {:entries []}
:main {:entries [app.main] :main
:depends-on #{:shared} {:entries [app.main]
:init-fn app.main/init} :depends-on #{:shared}
:init-fn app.main/init}
:render {:entries [app.render] :render
:depends-on #{:shared} {:entries [app.render]
:init-fn app.render/init} :depends-on #{:shared}
:init-fn app.render/init}
:worker {:entries [app.worker] :worker
:web-worker true {:entries [app.worker]
:depends-on #{:shared}}} :web-worker true
:depends-on #{:shared}}
:thumbnail-renderer
{:entries [app.thumbnail-renderer]
:depends-on #{:shared}
:init-fn app.thumbnail-renderer/init}}
:compiler-options :compiler-options
{:output-feature-set :es2020 {:output-feature-set :es2020

View file

@ -84,7 +84,6 @@
(def default-theme "default") (def default-theme "default")
(def default-language "en") (def default-language "en")
(def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js"))
(def translations (obj/get global "penpotTranslations")) (def translations (obj/get global "penpotTranslations"))
(def themes (obj/get global "penpotThemes")) (def themes (obj/get global "penpotThemes"))
@ -110,7 +109,14 @@
(def public-uri (def public-uri
(atom (atom
(normalize-uri (or (obj/get global "penpotPublicURI") (normalize-uri (or (obj/get global "penpotPublicURI")
(.-origin ^js location))))) (obj/get location "origin")))))
(def thumbnail-renderer-uri
(or (some-> (obj/get global "penpotThumbnailRendererURI") normalize-uri)
(deref public-uri)))
(def worker-uri
(obj/get global "penpotWorkerURI" "/js/worker.js"))
;; --- Helper Functions ;; --- Helper Functions

View file

@ -15,6 +15,7 @@
[app.main.errors] [app.main.errors]
[app.main.features :as feat] [app.main.features :as feat]
[app.main.store :as st] [app.main.store :as st]
[app.main.thumbnail-renderer :as tr]
[app.main.ui :as ui] [app.main.ui :as ui]
[app.main.ui.alert] [app.main.ui.alert]
[app.main.ui.confirm] [app.main.ui.confirm]
@ -80,6 +81,7 @@
(i18n/init! cf/translations) (i18n/init! cf/translations)
(theme/init! cf/themes) (theme/init! cf/themes)
(cur/init-styles) (cur/init-styles)
(tr/init!)
(init-ui) (init-ui)
(st/emit! (initialize))) (st/emit! (initialize)))

View file

@ -782,6 +782,15 @@
(->> (rp/cmd! :set-file-shared params) (->> (rp/cmd! :set-file-shared params)
(rx/ignore)))))) (rx/ignore))))))
(defn set-file-thumbnail
[file-id thumbnail-uri]
(ptk/reify ::set-file-thumbnail
ptk/UpdateEvent
(update [_ state]
(-> state
(d/update-in-when [:dashboard-files file-id] assoc :thumbnail-uri thumbnail-uri)
(d/update-in-when [:dashboard-recent-files file-id] assoc :thumbnail-uri thumbnail-uri)))))
;; --- EVENT: create-file ;; --- EVENT: create-file
(declare file-created) (declare file-created)

View file

@ -9,6 +9,7 @@
(:require-macros [app.main.fonts :refer [preload-gfonts]]) (:require-macros [app.main.fonts :refer [preload-gfonts]])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.text :as txt] [app.common.text :as txt]
[app.config :as cf] [app.config :as cf]
@ -148,15 +149,13 @@
;; --- LOADER: CUSTOM ;; --- LOADER: CUSTOM
(def font-css-template (def font-face-template
"@font-face { "@font-face {
font-family: '%(family)s'; font-family: '%(family)s';
font-style: %(style)s; font-style: %(style)s;
font-weight: %(weight)s; font-weight: %(weight)s;
font-display: block; font-display: block;
src: url(%(woff1-uri)s) format('woff'), src: url(%(uri)s) format('woff');
url(%(ttf-uri)s) format('ttf'),
url(%(otf-uri)s) format('otf');
}") }")
(defn- asset-id->uri (defn- asset-id->uri
@ -165,14 +164,11 @@
(defn generate-custom-font-variant-css (defn generate-custom-font-variant-css
[family variant] [family variant]
(str/fmt font-css-template (str/fmt font-face-template
{:family family {:family family
:style (:style variant) :style (:style variant)
:weight (:weight variant) :weight (:weight variant)
:woff2-uri (asset-id->uri (::woff2-file-id variant)) :uri (asset-id->uri (::woff1-file-id variant))}))
:woff1-uri (asset-id->uri (::woff1-file-id variant))
:ttf-uri (asset-id->uri (::ttf-file-id variant))
:otf-uri (asset-id->uri (::otf-file-id variant))}))
(defn- generate-custom-font-css (defn- generate-custom-font-css
[{:keys [family variants] :as font}] [{:keys [family variants] :as font}]
@ -237,26 +233,19 @@
(-> (obj/get-in js/document ["fonts" "ready"]) (-> (obj/get-in js/document ["fonts" "ready"])
(p/then cb))) (p/then cb)))
(defn get-default-variant [{:keys [variants]}] (defn get-default-variant
(or [{:keys [variants]}]
(d/seek #(or (= (:id %) "regular") (= (:name %) "regular")) variants) (or (d/seek #(or (= (:id %) "regular")
(first variants))) (= (:name %) "regular")) variants)
(first variants)))
(defn get-variant
[{:keys [variants] :as font} font-variant-id]
(or (d/seek #(= (:id %) font-variant-id) variants)
(get-default-variant font)))
;; Font embedding functions ;; Font embedding functions
;; Template for a CSS font face
(def font-face-template "
/* latin */
@font-face {
font-family: '%(family)s';
font-style: %(style)s;
font-weight: %(weight)s;
font-display: block;
src: url(%(baseurl)sfonts/%(family)s-%(suffix)s.woff) format('woff');
}
")
(defn get-content-fonts (defn get-content-fonts
"Extracts the fonts used by the content of a text shape" "Extracts the fonts used by the content of a text shape"
[{font-id :font-id children :children :as content}] [{font-id :font-id children :children :as content}]
@ -267,38 +256,52 @@
children-font (->> children (mapv get-content-fonts))] children-font (->> children (mapv get-content-fonts))]
(reduce set/union (conj children-font current-font)))) (reduce set/union (conj children-font current-font))))
(defn fetch-font-css (defn fetch-font-css
"Given a font and the variant-id, retrieves the fontface CSS" "Given a font and the variant-id, retrieves the fontface CSS"
[{:keys [font-id font-variant-id] [{:keys [font-id font-variant-id]
:or {font-variant-id "regular"}}] :or {font-variant-id "regular"}}]
(let [{:keys [backend family variants]} (get @fontsdb font-id)] (let [{:keys [backend family] :as font} (get @fontsdb font-id)]
(cond (cond
(nil? font)
(rx/empty)
(= :google backend) (= :google backend)
(let [variant (d/seek #(= (:id %) font-variant-id) variants)] (let [variant (get-variant font font-variant-id)]
(-> (generate-gfonts-url (-> (generate-gfonts-url
{:family family {:family family
:variants [variant]}) :variants [variant]})
(http/fetch-text))) (http/fetch-text)))
(= :custom backend) (= :custom backend)
(let [variant (d/seek #(= (:id %) font-variant-id) variants) (let [variant (get-variant font font-variant-id)
result (generate-custom-font-variant-css family variant)] result (generate-custom-font-variant-css family variant)]
(p/resolved result)) (rx/of result))
:else :else
(let [{:keys [weight style suffix] :as variant} (let [{:keys [weight style suffix]} (get-variant font font-variant-id)
(d/seek #(= (:id %) font-variant-id) variants) suffix (or suffix font-variant-id)
font-data {:baseurl (str @cf/public-uri) params {:uri (dm/str @cf/public-uri "fonts/" family "-" suffix ".woff")
:family family :family family
:style style :style style
:suffix (or suffix font-variant-id) :weight weight}]
:weight weight}] (rx/of (str/fmt font-face-template params))))))
(rx/of (str/fmt font-face-template font-data))))))
(defn extract-fontface-urls (defn extract-fontface-urls
"Parses the CSS and retrieves the font urls" "Parses the CSS and retrieves the font urls"
[^string css] [^string css]
(->> (re-seq #"url\(([^)]+)\)" css) (->> (re-seq #"url\(([^)]+)\)" css)
(mapv second))) (mapv second)))
(defn render-font-styles
[ids]
(->> (rx/from ids)
(rx/mapcat (fn [font-id]
(let [font (get @fontsdb font-id)]
(->> (:variants font [])
(map :id)
(map (fn [variant-id]
{:font-id font-id
:font-variant-id variant-id}))))))
(rx/mapcat fetch-font-css)
(rx/reduce (fn [acc css] (dm/str acc "\n" css)) "")))

View file

@ -50,6 +50,11 @@
:upsert-file-object-thumbnail {:query-params [:file-id :object-id]} :upsert-file-object-thumbnail {:query-params [:file-id :object-id]}
:create-file-object-thumbnail {:query-params [:file-id :object-id] :create-file-object-thumbnail {:query-params [:file-id :object-id]
:form-data? true} :form-data? true}
:create-file-thumbnail
{:query-params [:file-id :revn]
:form-data? true}
:export-binfile {:response-type :blob} :export-binfile {:response-type :blob}
:import-binfile {:form-data? true} :import-binfile {:form-data? true}
:retrieve-list-of-builtin-templates {:query-params :all} :retrieve-list-of-builtin-templates {:query-params :all}

View file

@ -0,0 +1,93 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.thumbnail-renderer
"A main entry point for the thumbnail renderer API interface.
This ns is responsible to provide an API for create thumbnail
renderer iframes and interact with them using asyncrhonous
messages."
(:require
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.dom :as dom]
[beicon.core :as rx]
[cuerdas.core :as str]))
(defonce ready? false)
(defonce queue #js [])
(defonce instance nil)
(defonce msgbus (rx/subject))
(defonce origin
(dm/str (assoc cf/thumbnail-renderer-uri :path "/thumbnail-renderer.html")))
(declare send-message!)
(defn- process-queued-messages!
[]
(loop [message (.shift ^js queue)]
(when (some? message)
(send-message! message)
(recur (.shift ^js queue)))))
(defn- on-message
"Handles a message from the thumbnail renderer."
[event]
(let [evorigin (unchecked-get event "origin")
evdata (unchecked-get event "data")]
(when (and (object? evdata) (str/starts-with? origin evorigin))
(let [scope (unchecked-get evdata "scope")
type (unchecked-get evdata "type")]
(when (= "penpot/thumbnail-renderer" scope)
(when (= type "ready")
(set! ready? true)
(process-queued-messages!))
(rx/push! msgbus evdata))))))
(defn- send-message!
"Sends a message to the thumbnail renderer."
[message]
(let [window (.-contentWindow ^js instance)]
(.postMessage ^js window message origin)))
(defn- queue-message!
"Queues a message to be sent to the thumbnail renderer when it's ready."
[message]
(.push ^js queue message))
(defn render
"Renders a thumbnail."
[{:keys [data styles] :as params}]
(let [id (dm/str (uuid/next))
payload #js {:data data :styles styles}
message #js {:id id
:scope "penpot/thumbnail-renderer"
:payload payload}]
(if ^boolean ready?
(send-message! message)
(queue-message! message))
(->> msgbus
(rx/filter #(= id (unchecked-get % "id")))
(rx/mapcat (fn [msg]
(case (unchecked-get msg "type")
"success" (rx/of (unchecked-get msg "payload"))
"failure" (rx/throw (unchecked-get msg "payload")))))
(rx/take 1))))
(defn init!
"Initializes the thumbnail renderer."
[]
(let [iframe (dom/create-element "iframe")]
(dom/set-attribute! iframe "src" origin)
(dom/set-attribute! iframe "hidden" true)
(dom/append-child! js/document.body iframe)
(set! instance iframe)
(.addEventListener js/window "message" on-message)))

View file

@ -16,7 +16,9 @@
[app.main.fonts :as fonts] [app.main.fonts :as fonts]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.render :refer [component-svg]] [app.main.render :refer [component-svg]]
[app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.main.thumbnail-renderer :as thr]
[app.main.ui.components.color-bullet :as bc] [app.main.ui.components.color-bullet :as bc]
[app.main.ui.dashboard.file-menu :refer [file-menu]] [app.main.ui.dashboard.file-menu :refer [file-menu]]
[app.main.ui.dashboard.import :refer [use-import-file]] [app.main.ui.dashboard.import :refer [use-import-file]]
@ -30,7 +32,6 @@
[app.util.dom.dnd :as dnd] [app.util.dom.dnd :as dnd]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.perf :as perf]
[app.util.time :as dt] [app.util.time :as dt]
[app.util.timers :as ts] [app.util.timers :as ts]
[beicon.core :as rx] [beicon.core :as rx]
@ -41,44 +42,49 @@
;; --- Grid Item Thumbnail ;; --- Grid Item Thumbnail
(defn ask-for-thumbnail (defn- persist-thumbnail
[file-id revn blob]
(let [params {:file-id file-id :revn revn :media blob}]
(->> (rp/cmd! :create-file-thumbnail params)
(rx/map :uri))))
(defn- ask-for-thumbnail
"Creates some hooks to handle the files thumbnails cache" "Creates some hooks to handle the files thumbnails cache"
[file] [file-id revn]
(let [features (cond-> ffeat/enabled (let [features (cond-> ffeat/enabled
(features/active-feature? :components-v2) (features/active-feature? :components-v2)
(conj "components/v2"))] (conj "components/v2"))]
(wrk/ask! {:cmd :thumbnails/generate-for-file (->> (wrk/ask! {:cmd :thumbnails/generate-for-file
:revn (:revn file) :revn revn
:file-id (:id file) :file-id file-id
:file-name (:name file) :features features})
:features features}))) (rx/mapcat (fn [{:keys [fonts] :as result}]
(->> (fonts/render-font-styles fonts)
(rx/map (fn [styles]
(assoc result :styles styles))))))
(rx/mapcat thr/render)
(rx/mapcat (partial persist-thumbnail file-id revn)))))
(mf/defc grid-item-thumbnail (mf/defc grid-item-thumbnail
{::mf/wrap [mf/memo]} {::mf/wrap-props false}
[{:keys [file] :as props}] [{:keys [file-id revn thumbnail-uri background-color]}]
(let [container (mf/use-ref) (let [container (mf/use-ref)
bgcolor (dm/get-in file [:data :options :background])
visible? (h/use-visible container :once? true)] visible? (h/use-visible container :once? true)]
(mf/with-effect [file visible?] (mf/with-effect [file-id revn visible? thumbnail-uri]
(when visible? (when (and visible? (not thumbnail-uri))
(let [tp (perf/tpoint)] (->> (ask-for-thumbnail file-id revn)
(->> (ask-for-thumbnail file) (rx/subs (fn [url]
(rx/subscribe-on :af) (st/emit! (dd/set-file-thumbnail file-id url)))))))
(rx/subs (fn [{:keys [data fonts] :as params}]
(run! fonts/ensure-loaded! fonts)
(log/debug :hint "loaded thumbnail"
:file-id (dm/str (:id file))
:file-name (:name file)
:elapsed (str/ffmt "%ms" (tp)))
(when-let [node (mf/ref-val container)]
(dom/set-html! node data))))))))
[:div.grid-item-th [:div.grid-item-th
{:style {:background-color bgcolor} {:style {:background-color background-color}
:ref container} :ref container}
i/loader-pencil])) (when visible?
(if thumbnail-uri
[:img.grid-item-thumbnail-image {:src thumbnail-uri}]
i/loader-pencil))]))
;; --- Grid Item Library ;; --- Grid Item Library
@ -312,7 +318,12 @@
[:div.overlay] [:div.overlay]
(if library-view? (if library-view?
[:& grid-item-library {:file file}] [:& grid-item-library {:file file}]
[:& grid-item-thumbnail {:file file}]) [:& grid-item-thumbnail
{:file-id (:id file)
:revn (:revn file)
:thumbnail-uri (:thumbnail-uri file)
:background-color (dm/get-in file [:data :options :background])}])
(when (and (:is-shared file) (not library-view?)) (when (and (:is-shared file) (not library-view?))
[:div.item-badge i/library]) [:div.item-badge i/library])
[:div.info-wrapper [:div.info-wrapper

View file

@ -0,0 +1,245 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.thumbnail-renderer
"A main entry point for the thumbnail renderer process that is
executed on a separated iframe."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.config :as cf]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.object :as obj]
[app.util.webapi :as wapi]
[beicon.core :as rx]
[cuerdas.core :as str]))
(log/set-level! :trace)
(declare send-success!)
(declare send-failure!)
(defonce parent-origin
(dm/str @cf/public-uri))
(defn- get-document-element
[^js svg]
(.-documentElement svg))
(defn- create-image
[uri]
(rx/create
(fn [subs]
(let [image (js/Image.)]
(obj/set! image "onload" #(do
(rx/push! subs image)
(rx/end! subs)))
(obj/set! image "crossOrigin" "anonymous")
(obj/set! image "onerror" #(rx/error! subs %))
(obj/set! image "onabort" #(rx/error! subs (ex/error :type :internal
:code :abort
:hint "operation aborted")))
(obj/set! image "src" uri)
(fn []
(obj/set! image "src" "")
(obj/set! image "onload" nil)
(obj/set! image "onerror" nil)
(obj/set! image "onabort" nil))))))
(defn- svg-get-size
[svg max]
(let [doc (get-document-element svg)
vbox (dom/get-attribute doc "viewBox")]
(when (string? vbox)
(let [[_ _ width height] (str/split vbox #"\s+")
width (d/parse-integer width 0)
height (d/parse-integer height 0)
ratio (/ width height)]
(if (> width height)
[max (* max (/ 1 ratio))]
[(* max ratio) max])))))
(defn- svg-has-intrinsic-size?
"Returns true if the SVG has an intrinsic size."
[svg]
(let [doc (get-document-element svg)
width (dom/get-attribute doc "width")
height (dom/get-attribute doc "height")]
(d/num? width height)))
(defn- svg-set-intrinsic-size!
"Sets the intrinsic size of an SVG to the given max size."
[^js svg max]
(when-not (svg-has-intrinsic-size? svg)
(let [doc (get-document-element svg)
[w h] (svg-get-size svg max)]
(dom/set-attribute! doc "width" (dm/str w))
(dom/set-attribute! doc "height" (dm/str h))))
svg)
(defn- fetch-as-data-uri
"Fetches a URL as a Data URI."
[uri]
(->> (http/send! {:uri uri
:response-type :blob
:method :get
:mode :cors
:omit-default-headers true})
(rx/map :body)
(rx/mapcat wapi/read-file-as-data-url)))
(defn- svg-update-image!
"Updates an image in an SVG to a Data URI."
[image]
(when-let [href (dom/get-attribute image "href")]
(->> (fetch-as-data-uri href)
(rx/map (fn [url]
(dom/set-attribute! image "href" url)
image)))))
(defn- svg-resolve-images!
"Resolves all images in an SVG to Data URIs."
[svg]
(->> (rx/from (dom/query-all svg "image"))
(rx/mapcat svg-update-image!)
(rx/ignore)))
(defn- svg-add-style!
"Adds a <style> node to an SVG."
[svg styles]
(let [doc (get-document-element svg)
style (dom/create-element svg "http://www.w3.org/2000/svg" "style")]
(dom/append-child! style (dom/create-text svg styles))
(dom/append-child! doc style)))
(defn- svg-resolve-styles!
"Resolves all fonts in an SVG to Data URIs."
[svg styles]
(->> (rx/from (re-seq #"url\((https?://[^)]+)\)" styles))
(rx/map second)
(rx/mapcat (fn [url]
(->> (fetch-as-data-uri url)
(rx/map (fn [uri] [url uri])))))
(rx/reduce (fn [styles [url uri]]
(str/replace styles url uri))
styles)
(rx/tap (partial svg-add-style! svg))
(rx/ignore)))
(defn- svg-resolve-all!
"Resolves all images and fonts in an SVG to Data URIs."
[svg styles]
(rx/concat
(svg-resolve-images! svg)
(svg-resolve-styles! svg styles)
(rx/of svg)))
(defn- svg-parse
"Parses an SVG string into an SVG DOM."
[data]
(let [parser (js/DOMParser.)]
(.parseFromString ^js parser data "image/svg+xml")))
(defn- svg-stringify
"Converts an SVG to a string."
[svg]
(let [doc (get-document-element svg)
serializer (js/XMLSerializer.)]
(.serializeToString ^js serializer doc)))
(defn- svg-prepare
"Prepares an SVG for rendering (resolves images to Data URIs and adds intrinsic size)."
[data styles]
(let [svg (svg-parse data)]
(->> (svg-resolve-all! svg styles)
(rx/map #(svg-set-intrinsic-size! % 300))
(rx/map svg-stringify))))
(defn- bitmap->blob
"Converts an ImageBitmap to a Blob."
[bitmap]
(rx/create
(fn [subs]
(let [canvas (dom/create-element "canvas")]
(set! (.-width ^js canvas) (.-width ^js bitmap))
(set! (.-height ^js canvas) (.-height ^js bitmap))
(let [context (.getContext ^js canvas "bitmaprenderer")]
(.transferFromImageBitmap ^js context bitmap)
(.toBlob canvas #(do (rx/push! subs %)
(rx/end! subs))))
(constantly nil)))))
(defn- render
"Renders a thumbnail using it's SVG and returns an ArrayBuffer of the image."
[payload]
(let [data (unchecked-get payload "data")
styles (unchecked-get payload "styles")]
(->> (svg-prepare data styles)
(rx/map #(wapi/create-blob % "image/svg+xml"))
(rx/map wapi/create-uri)
(rx/mapcat (fn [uri]
(->> (create-image uri)
(rx/mapcat wapi/create-image-bitmap)
(rx/tap #(wapi/revoke-uri uri)))))
(rx/mapcat bitmap->blob))))
(defn- on-message
"Handles messages from the main thread."
[event]
(let [evdata (unchecked-get event "data")
evorigin (unchecked-get event "origin")]
(when (str/starts-with? parent-origin evorigin)
(let [id (unchecked-get evdata "id")
payload (unchecked-get evdata "payload")
scope (unchecked-get evdata "scope")]
(when (and (some? payload)
(= scope "penpot/thumbnail-renderer"))
(->> (render payload)
(rx/subs (partial send-success! id)
(partial send-failure! id))))))))
(defn- listen
"Initializes the listener for messages from the main thread."
[]
(.addEventListener js/window "message" on-message))
(defn- send-answer!
"Sends an answer message."
[id type payload]
(let [message #js {:id id
:type type
:scope "penpot/thumbnail-renderer"
:payload payload}]
(when-not (identical? js/window js/parent)
(.postMessage js/parent message parent-origin))))
(defn- send-success!
"Sends a success message."
[id payload]
(send-answer! id "success" payload))
(defn- send-failure!
"Sends a failure message."
[id payload]
(send-answer! id "failure" payload))
(defn- send-ready!
"Sends a ready message."
[]
(send-answer! nil "ready" nil))
;; Initializes worker
(defn ^:export init
[]
(listen)
(send-ready!)
(log/info :hint "initialized" :public-uri @cf/public-uri))

View file

@ -254,7 +254,15 @@
([tag] ([tag]
(.createElement globals/document tag)) (.createElement globals/document tag))
([ns tag] ([ns tag]
(.createElementNS globals/document ns tag))) (.createElementNS globals/document ns tag))
([document ns tag]
(.createElementNS document ns tag)))
(defn create-text
([^js text]
(create-text globals/document text))
([document ^js text]
(.createTextNode document text)))
(defn set-html! (defn set-html!
[^js el html] [^js el html]

View file

@ -130,6 +130,10 @@
(map #(.item file-list %)) (map #(.item file-list %))
(filter #(str/starts-with? (.-type %) "image/")))))) (filter #(str/starts-with? (.-type %) "image/"))))))
(defn create-image-bitmap
[image]
(js/createImageBitmap image))
(defn request-fullscreen (defn request-fullscreen
[el] [el]
(cond (cond

View file

@ -18,7 +18,6 @@
[app.util.webapi :as wapi] [app.util.webapi :as wapi]
[app.worker.impl :as impl] [app.worker.impl :as impl]
[beicon.core :as rx] [beicon.core :as rx]
[debug :refer [debug?]]
[promesa.core :as p] [promesa.core :as p]
[rumext.v2 :as mf])) [rumext.v2 :as mf]))
@ -45,15 +44,6 @@
:http-status status :http-status status
:http-body body}))) :http-body body})))
(defn- not-found?
[{:keys [type]}]
(= :not-found type))
(defn- body-too-large?
[{:keys [type code]}]
(and (= :validation type)
(= :request-body-too-large code)))
(defn- request-data-for-thumbnail (defn- request-data-for-thumbnail
[file-id revn features] [file-id revn features]
(let [path "api/rpc/command/get-file-data-for-thumbnail" (let [path "api/rpc/command/get-file-data-for-thumbnail"
@ -69,70 +59,25 @@
(rx/map http/conditional-decode-transit) (rx/map http/conditional-decode-transit)
(rx/mapcat handle-response)))) (rx/mapcat handle-response))))
(defn- request-thumbnail
[file-id revn]
(let [path "api/rpc/command/get-file-thumbnail"
params {:file-id file-id
:revn revn}
request {:method :get
:uri (u/join @cf/public-uri path)
:credentials "include"
:query params}]
(->> (http/send! request)
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response))))
(defn- render-thumbnail (defn- render-thumbnail
[{:keys [page file-id revn] :as params}] [{:keys [page file-id revn] :as params}]
(let [objects (:objects page) (let [objects (:objects page)
frame (some->> page :thumbnail-frame-id (get objects)) frame (some->> page :thumbnail-frame-id (get objects))
element (if frame element (if frame
(mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true}) (mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true})
(mf/element render/page-svg #js {:data page :thumbnails? true})) (mf/element render/page-svg #js {:data page :thumbnails? true :render-embed? true}))
data (rds/renderToStaticMarkup element)] data (rds/renderToStaticMarkup element)
font-ids (into @fonts/loaded (map first) @fonts/loading)]
{:data data {:data data
:fonts (into @fonts/loaded (map first) @fonts/loading) :fonts font-ids
:file-id file-id :file-id file-id
:revn revn})) :revn revn}))
(defn- persist-thumbnail
[{:keys [file-id data revn fonts]}]
(let [path "api/rpc/command/upsert-file-thumbnail"
params {:file-id file-id
:revn revn
:props {:fonts fonts}
:data data}
request {:method :post
:uri (u/join @cf/public-uri path)
:credentials "include"
:body (http/transit-data params)}]
(->> (http/send! request)
(rx/map http/conditional-decode-transit)
(rx/mapcat handle-response)
(rx/catch body-too-large? (constantly (rx/of nil)))
(rx/map (constantly params)))))
(defmethod impl/handler :thumbnails/generate-for-file (defmethod impl/handler :thumbnails/generate-for-file
[{:keys [file-id revn features] :as message} _] [{:keys [file-id revn features] :as message} _]
(letfn [(on-result [{:keys [data props]}] (->> (request-data-for-thumbnail file-id revn features)
{:data data (rx/map render-thumbnail)))
:fonts (:fonts props)})
(on-cache-miss [_]
(log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "miss")
(->> (request-data-for-thumbnail file-id revn features)
(rx/map render-thumbnail)
(rx/mapcat persist-thumbnail)))]
(if (debug? :disable-thumbnail-cache)
(->> (request-data-for-thumbnail file-id revn features)
(rx/map render-thumbnail))
(->> (request-thumbnail file-id revn)
(rx/tap (fn [_]
(log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "hit")))
(rx/catch not-found? on-cache-miss)
(rx/map on-result)))))
(defmethod impl/handler :thumbnails/render-offscreen-canvas (defmethod impl/handler :thumbnails/render-offscreen-canvas
[_ ibpm] [_ ibpm]