From 784ad8ab7585d62af51ec16650796d83ca1f70a0 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Fri, 27 Mar 2026 09:29:38 +0100 Subject: [PATCH] :wrench: Use wasm render for components thumbnail --- .../app/main/data/workspace/libraries.cljs | 80 +++++++++--- .../app/main/data/workspace/thumbnails.cljs | 2 +- .../main/data/workspace/thumbnails_wasm.cljs | 118 ++++++++++++++++++ .../ui/workspace/sidebar/assets/common.cljs | 4 +- 4 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/thumbnails_wasm.cljs diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index dc9e5843bb..ef7bfd2f93 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -44,6 +44,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.specialized-panel :as dwsp] [app.main.data.workspace.thumbnails :as dwt] + [app.main.data.workspace.thumbnails-wasm :as dwt.wasm] [app.main.data.workspace.transforms :as dwtr] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] @@ -945,7 +946,12 @@ component (ctkl/get-component data component-id) page-id (:main-instance-page component) root-id (:main-instance-id component)] - (dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync"))) + (if (and (= tag "component") + (features/active-feature? state "render-wasm/v1")) + ;; WASM: render immediately, UI only — server persist happens + ;; on the debounced path (update-component-thumbnail) + (dwt.wasm/render-thumbnail file-id page-id root-id) + (dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync")))) (defn update-component-sync ([shape-id file-id] (update-component-sync shape-id file-id nil)) @@ -964,9 +970,12 @@ (dwu/start-undo-transaction undo-id) (update-component shape-id undo-group) - ;; These two calls are necessary for properly sync thumbnails - ;; when a main component does not live in the same page - (update-component-thumbnail-sync state component-id file-id "frame") + ;; These calls are necessary for properly sync thumbnails + ;; when a main component does not live in the same page. + ;; When WASM is active, skip the "frame" tag (SVG-based) since + ;; component previews are rendered locally via WASM. + (when-not (features/active-feature? state "render-wasm/v1") + (update-component-thumbnail-sync state component-id file-id "frame")) (update-component-thumbnail-sync state component-id file-id "component") (sync-file current-file-id file-id :components component-id undo-group) @@ -991,13 +1000,21 @@ (dwu/commit-undo-transaction undo-id))))))) (defn update-component-thumbnail - "Update the thumbnail of the component with the given id, in the - current file and in the imported libraries." + "Persist the thumbnail of the component to the server. + For WASM, the UI is already up-to-date from the immediate render in + update-component-thumbnail-sync, so this only persists. + For SVG, this does the full render + persist." [component-id file-id] (ptk/reify ::update-component-thumbnail ptk/WatchEvent (watch [_ state _] - (rx/of (update-component-thumbnail-sync state component-id file-id "component"))))) + (if (features/active-feature? state "render-wasm/v1") + (let [data (dsh/lookup-file-data state file-id) + component (ctkl/get-component data component-id) + page-id (:main-instance-page component) + root-id (:main-instance-id component)] + (rx/of (dwt.wasm/persist-thumbnail file-id page-id root-id))) + (rx/of (update-component-thumbnail-sync state component-id file-id "component")))))) (defn- find-shape-index [objects id shape-id] @@ -1373,9 +1390,18 @@ (->> (rx/from changed-components) (rx/map #(component-changed % (:id old-data) undo-group)))) ;; even if save-undo? is false, we need to update the :modified-date of the component - ;; (for example, for undos) - (->> (rx/from changed-components) - (rx/map touch-component))) + ;; (for example, for undos). When WASM is active, also re-render the thumbnail + ;; so undo/redo visually updates component previews. + (->> (mapcat (fn [component-id] + (if (features/active-feature? @st/state "render-wasm/v1") + (let [component (ctkl/get-component old-data component-id)] + [(touch-component component-id) + (dwt.wasm/render-thumbnail (:id old-data) + (:main-instance-page component) + (:main-instance-id component))]) + [(touch-component component-id)])) + changed-components) + (rx/from))) (rx/empty))))) @@ -1390,18 +1416,32 @@ (rx/debounce 5000) (rx/tap #(log/trc :hint "buffer initialized")))] - (when (contains? cf/flags :component-thumbnails) - (->> (rx/merge - changes-s + (when (or (contains? cf/flags :component-thumbnails) + (features/active-feature? @st/state "render-wasm/v1")) + (let [wasm? (features/active-feature? @st/state "render-wasm/v1")] + (->> (rx/merge + changes-s - (->> changes-s - (rx/map deref) - (rx/buffer-until notifier-s) - (rx/mapcat #(into #{} %)) - (rx/map (fn [[component-id file-id]] - (update-component-thumbnail component-id file-id))))) + ;; WASM: render thumbnails immediately for instant UI feedback + (if wasm? + (->> changes-s + (rx/filter (ptk/type? ::component-changed)) + (rx/map deref) + (rx/map (fn [[component-id file-id]] + (update-component-thumbnail-sync @st/state component-id file-id "component")))) + (rx/empty)) - (rx/take-until stopper-s))))))) + ;; Persist thumbnails to the server in batches after user + ;; becomes inactive for 5 seconds. + (->> changes-s + (rx/filter (ptk/type? ::component-changed)) + (rx/map deref) + (rx/buffer-until notifier-s) + (rx/mapcat #(into #{} %)) + (rx/map (fn [[component-id file-id]] + (update-component-thumbnail component-id file-id))))) + + (rx/take-until stopper-s)))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Backend interactions diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index 5edab10c27..b2854917af 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -118,7 +118,7 @@ (rx/ignore)))) (rx/empty))))))) -(defn- assoc-thumbnail +(defn assoc-thumbnail [object-id uri] (let [prev-uri* (volatile! nil)] (ptk/reify ::assoc-thumbnail diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs new file mode 100644 index 0000000000..44459e9df8 --- /dev/null +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -0,0 +1,118 @@ +;; 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.data.workspace.thumbnails-wasm + "WASM-based component thumbnail rendering. + Renders component previews using the existing workspace WASM context + via render-shape-pixels (SurfaceId::Export), avoiding a separate + WASM module in the worker. + + Two-phase design: + - render-thumbnail: immediate WASM render → UI update (no server) + - persist-thumbnail: pushes current data-uri to the server (debounced)" + (:require + [app.common.data.macros :as dm] + [app.common.logging :as l] + [app.common.thumbnails :as thc] + [app.common.time :as ct] + [app.main.data.workspace.thumbnails :as dwt] + [app.main.repo :as rp] + [app.render-wasm.api :as wasm.api] + [app.util.webapi :as wapi] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [potok.v2.core :as ptk])) + +(l/set-level! :warn) + +(defn- png-bytes->data-uri + "Converts a Uint8Array of PNG bytes to a data:image/png;base64 URI." + [png-bytes] + (let [blob (wapi/create-blob png-bytes "image/png") + reader (js/FileReader.)] + (js/Promise. + (fn [resolve reject] + (set! (.-onload reader) + (fn [] (resolve (.-result reader)))) + (set! (.-onerror reader) + (fn [e] (reject e))) + (.readAsDataURL reader blob))))) + +(defn- render-component-pixels + "Renders a component frame using the workspace WASM context. + Returns an observable that emits a data-uri string. + Deferred by one animation frame so that process-shape-changes! + has time to sync all child shapes to WASM memory first." + [frame-id] + (rx/create + (fn [subs] + (js/requestAnimationFrame + (fn [_] + (try + (let [png-bytes (wasm.api/render-shape-pixels frame-id 1)] + (if (or (nil? png-bytes) (zero? (.-length png-bytes))) + (do (js/console.error "[thumbnails] render-shape-pixels returned empty for" (str frame-id)) + (rx/end! subs)) + (.then (png-bytes->data-uri png-bytes) + (fn [data-uri] + (rx/push! subs data-uri) + (rx/end! subs)) + (fn [err] + (rx/error! subs err))))) + (catch :default err + (rx/error! subs err))))) + nil))) + +(defn render-thumbnail + "Renders a component thumbnail via WASM and updates the UI immediately. + Does NOT persist to the server — persistence is handled separately + by `persist-thumbnail` on a debounced schedule." + [file-id page-id frame-id] + (let [object-id (thc/fmt-object-id file-id page-id frame-id "component")] + (ptk/reify ::render-thumbnail + cljs.core/IDeref + (-deref [_] object-id) + + ptk/WatchEvent + (watch [_ _ stream] + (let [tp (ct/tpoint-ms)] + (->> (render-component-pixels frame-id) + (rx/map + (fn [data-uri] + (l/dbg :hint "component thumbnail rendered (wasm)" + :elapsed (dm/str (tp) "ms")) + (dwt/assoc-thumbnail object-id data-uri))) + + (rx/catch (fn [err] + (js/console.error "[thumbnails] error rendering component thumbnail" err) + (rx/empty))) + + (rx/take-until + (->> stream + (rx/filter (ptk/type? ::dwt/clear-thumbnail)) + (rx/filter #(= (deref %) object-id)))))))))) + +(defn persist-thumbnail + "Persists the current component thumbnail data-uri to the server. + Expects that `render-thumbnail` has already been called so the + data-uri is present in app state. If not, this is a no-op." + [file-id page-id frame-id] + (let [object-id (thc/fmt-object-id file-id page-id frame-id "component")] + (ptk/reify ::persist-thumbnail + ptk/WatchEvent + (watch [_ state _] + (let [data-uri (dm/get-in state [:thumbnails object-id])] + (if (and (some? data-uri) + (str/starts-with? data-uri "data:")) + (let [blob (wapi/data-uri->blob data-uri)] + (->> (rp/cmd! :create-file-object-thumbnail + {:file-id file-id + :object-id object-id + :media blob + :tag "component"}) + (rx/catch rx/empty) + (rx/ignore))) + (rx/empty))))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 9f7762b861..df0fe8c14c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -24,6 +24,7 @@ [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] + [app.main.features :as features] [app.main.refs :as refs] [app.main.render :refer [component-svg component-svg-thumbnail]] [app.main.store :as st] @@ -299,7 +300,8 @@ (inc retry))))] (if (and (some? thumbnail-uri) - (contains? cf/flags :component-thumbnails)) + (or (contains? cf/flags :component-thumbnails) + (features/active-feature? @st/state "render-wasm/v1"))) [:& component-svg-thumbnail {:thumbnail-uri thumbnail-uri :class class