diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
index ce00c06d60..3283050d23 100644
--- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs
@@ -88,7 +88,7 @@
(p/resolved nil)
;; Blur with Skia, then capture the already-blurred frame.
(do (wasm.api/render-blurred-snapshot!)
- (wasm.api/capture-canvas-snapshot-url)))
+ (wasm.api/capture-canvas-snapshot)))
(p/finally
(fn []
(wasm.api/apply-canvas-blur)
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index 2972b58ead..9f23d33485 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -117,6 +117,44 @@
(uuid? outlined-frame-id) (conj outlined-frame-id)
(uuid? edition) (disj edition))))
+(mf/defc transition-overlay*
+ "Frozen-frame overlay shown while switching pages or recovering from WebGL
+ context loss. `image` is either an `ImageBitmap` snapshot of the canvas or
+ a data-url string placeholder (initial load)."
+ [{:keys [image clip-path]}]
+ (let [canvas-ref (mf/use-ref nil)
+ bitmap? (not (string? image))]
+ (mf/with-effect [image]
+ (when bitmap?
+ (when-let [canvas (mf/ref-val canvas-ref)]
+ ;; A closed ImageBitmap reports zero size; skip instead of throwing.
+ (when (pos? (.-width ^js image))
+ (set! (.-width ^js canvas) (.-width ^js image))
+ (set! (.-height ^js canvas) (.-height ^js image))
+ (let [ctx (.getContext ^js canvas "2d")]
+ (.drawImage ^js ctx image 0 0))))))
+ (if bitmap?
+ ;; Full-bleed so the snapshot overlays the canvas 1:1.
+ [:canvas {:data-testid "canvas-wasm-transition"
+ :ref canvas-ref
+ :style {:position "absolute"
+ :inset 0
+ :width "100%"
+ :height "100%"
+ :object-fit "cover"
+ :pointer-events "none"
+ :clip-path clip-path}}]
+ [:img {:data-testid "canvas-wasm-transition"
+ :src image
+ :draggable false
+ :style {:position "absolute"
+ :inset 0
+ :width "100%"
+ :height "100%"
+ :object-fit "cover"
+ :pointer-events "none"
+ :clip-path clip-path}}])))
+
(mf/defc viewport*
[{:keys [selected wglobal layout file page palete-size]}]
(let [;; When adding data from workspace-local revisit `app.main.ui.workspace` to check
@@ -375,7 +413,7 @@
preview-blend (-> refs/workspace-preview-blend
(mf/deref))
shapes-loading? (mf/deref wasm.api/shapes-loading?)
- transition-image-url (mf/deref wasm.api/transition-image-url*)]
+ transition-image (mf/deref wasm.api/transition-image*)]
;; NOTE: We need this page-id dependency to react to it and reset the
;; canvas, even though we are not using `page-id` inside the hook.
@@ -593,25 +631,16 @@
;; Show the transition image when switching pages or recovering from WebGL context loss.
(when (and (or page-transition? context-loss-overlay?)
- (some? transition-image-url))
- (let [src transition-image-url]
- [:img {:data-testid "canvas-wasm-transition"
- :src src
- :draggable false
- ;; Full-bleed so the snapshot overlays the canvas 1:1.
- :style {:position "absolute"
- :inset 0
- :width "100%"
- :height "100%"
- :object-fit "cover"
- :pointer-events "none"
- ;; Initial load: clip to the live canvas frame (rounded
- ;; corner + ruler strips when present) so it shows
- ;; through. No frame in hide-UI mode -> no clip.
- :clip-path (when (and transition-reveal-rulers? frame-visible?)
- (let [strip (if show-rulers? rulers/ruler-area-size 0)]
- (dm/str "inset(" strip "px 0 0 " strip "px round "
- rulers/canvas-border-radius "px)")))}}]))
+ (some? transition-image))
+ [:> transition-overlay*
+ {:image transition-image
+ ;; Initial load: clip to the live canvas frame (rounded
+ ;; corner + ruler strips when present) so it shows
+ ;; through. No frame in hide-UI mode -> no clip.
+ :clip-path (when (and transition-reveal-rulers? frame-visible?)
+ (let [strip (if show-rulers? rulers/ruler-area-size 0)]
+ (dm/str "inset(" strip "px 0 0 " strip "px round "
+ rulers/canvas-border-radius "px)")))}])
[:svg.viewport-controls
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index a1a0a2f6bf..cefdb08dc1 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -70,9 +70,9 @@
;; `penpot:wasm:tiles-complete`.
;;
;; - `page-transition?`: true while the overlay should be considered active.
-;; - `transition-image-url*`: URL used by the UI overlay (usually `blob:` from the
-;; current WebGL canvas snapshot; on initial load it may be a tiny SVG data-url
-;; derived from the page background color).
+;; - `transition-image*`: image shown by the UI overlay (usually an `ImageBitmap`
+;; snapshot of the WebGL canvas; on initial load it may be a tiny SVG data-url
+;; string derived from the page background color).
;; - `transition-epoch*`: monotonic counter used to ignore stale async work/events
;; when the user clicks pages rapidly (A -> B -> C).
;; - `transition-tiles-handler*`: the currently installed DOM event handler for
@@ -83,7 +83,7 @@
;; rulers show through. False (page switch / context loss) keeps the snapshot's
;; baked-in rulers full-bleed to avoid a blank-strip flicker on canvas remount.
(defonce transition-reveal-rulers? (atom false))
-(defonce transition-image-url* (atom nil))
+(defonce transition-image* (atom nil))
(defonce transition-epoch* (atom 0))
(defonce transition-tiles-handler* (atom nil))
(defonce snapshot-tiles-handler* (atom nil))
@@ -100,13 +100,13 @@
(defn set-transition-image-from-background!
- "Sets `transition-image-url*` to a data URL representing a solid background color."
+ "Sets `transition-image*` to a data URL representing a solid background color."
[background]
(when (string? background)
(let [svg (str "")]
- (reset! transition-image-url*
+ (reset! transition-image*
(str "data:image/svg+xml;charset=utf-8," (js/encodeURIComponent svg))))))
(defn begin-page-transition!
@@ -120,7 +120,7 @@
(when-let [prev @transition-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! transition-tiles-handler* nil)
- (reset! transition-image-url* nil))
+ (reset! transition-image* nil))
(defn- set-transition-tiles-complete-handler!
"Installs a tiles-complete handler bound to the current transition epoch.
@@ -146,7 +146,7 @@
(reset! transition-reveal-rulers? true) ; reveal the live rulers
;; If something already toggled `page-transition?` (e.g. legacy init code paths),
;; ensure we still have a deterministic placeholder on initial load.
- (when (or (not @page-transition?) (nil? @transition-image-url*))
+ (when (or (not @page-transition?) (nil? @transition-image*))
(set-transition-image-from-background! background))
(when-not @page-transition?
;; Start transition + bind the tiles-complete handler to this epoch.
@@ -161,7 +161,7 @@
[]
(reset! context-loss-overlay? false)
(when-not @page-transition?
- (reset! transition-image-url* nil)))
+ (reset! transition-image* nil)))
(defn listen-tiles-render-complete-once!
"Registers a one-shot listener for `penpot:wasm:tiles-complete`, dispatched from WASM
@@ -173,12 +173,28 @@
(f))
#js {:once true}))
+(defn capture-canvas-snapshot
+ "Captures the viewport canvas into `wasm/canvas-snapshot` (an `ImageBitmap`)
+ and closes the replaced snapshot unless the transition overlay is still
+ showing it (a replaced snapshot can never become displayed again, so closing
+ it is safe). Returns a promise resolving to the bitmap (or nil)."
+ []
+ (let [^js prev wasm/canvas-snapshot]
+ (-> (webgl/capture-canvas-snapshot)
+ (p/then (fn [^js bitmap]
+ (when (and (some? prev)
+ (some? bitmap)
+ (not (identical? prev bitmap))
+ (not (identical? prev @transition-image*)))
+ (.close prev))
+ bitmap)))))
+
(defonce ^:private schedule-canvas-snapshot-capture!
(fns/debounce
(fn []
(when (and (initialized?)
(some? wasm/canvas))
- (-> (webgl/capture-canvas-snapshot-url)
+ (-> (capture-canvas-snapshot)
(p/catch (fn [_] nil)))))
snapshot-capture-debounce-ms))
@@ -239,7 +255,6 @@
(def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4)
;; Re-export public WebGL functions
-(def capture-canvas-snapshot-url webgl/capture-canvas-snapshot-url)
(def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas)
;; Re-export public text editor functions
@@ -432,7 +447,7 @@
(defn render-blurred-snapshot!
"Blurs the current page into the canvas so a following
- `capture-canvas-snapshot-url` grabs an already-blurred transition frame."
+ `capture-canvas-snapshot` grabs an already-blurred transition frame."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_blurred_snapshot" TRANSITION_BLUR_RADIUS)))
@@ -1994,9 +2009,8 @@
;; Keep the last rendered pixels visible while context is lost/recovering.
(reset! transition-reveal-rulers? false) ; snapshot has rulers baked in
(start-context-loss-overlay!)
- (when-let [url wasm/canvas-snapshot-url]
- (when (string? url)
- (reset! transition-image-url* url)))
+ (when-let [snapshot wasm/canvas-snapshot]
+ (reset! transition-image* snapshot))
(reset! wasm/context-lost? true)
(st/async-emit!
(ntf/show {:content (tr "webgl.webgl-context-lost.toast")
@@ -2415,12 +2429,11 @@
;; Lock the snapshot for the whole transition: if the user clicks to another page
;; while the transition is active, keep showing the original page snapshot until
;; the final target page finishes rendering. The caller (sitemap on-click) is
- ;; responsible for ensuring `wasm/canvas-snapshot-url` was freshly captured
+ ;; responsible for ensuring `wasm/canvas-snapshot` was freshly captured
;; before invoking us.
(when-not already?
- (when-let [url wasm/canvas-snapshot-url]
- (when (string? url)
- (reset! transition-image-url* url))))))
+ (when-let [snapshot wasm/canvas-snapshot]
+ (reset! transition-image* snapshot)))))
(defn render-shape-pixels
[shape-id scale]
diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs
index 853370335e..23b8d57810 100644
--- a/frontend/src/app/render_wasm/api/webgl.cljs
+++ b/frontend/src/app/render_wasm/api/webgl.cljs
@@ -134,28 +134,20 @@ void main() {
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
(.deleteTexture ^js gl texture))))
-(defn capture-canvas-snapshot-url
- "Captures the current viewport canvas as a PNG `blob:` URL and stores it in
- `wasm/canvas-snapshot-url`.
+(defn capture-canvas-snapshot
+ "Captures the current viewport canvas as an `ImageBitmap` and stores it in
+ `wasm/canvas-snapshot`. Unlike `canvas.toBlob` (which does a synchronous GPU
+ readback plus PNG encoding on the main thread), `createImageBitmap` resolves
+ asynchronously and stays on the GPU in accelerated browsers.
- Returns a promise resolving to the URL string (or nil)."
+ Returns a promise resolving to the ImageBitmap (or nil)."
[]
(if-let [^js canvas wasm/canvas]
- (p/create
- (fn [resolve _reject]
- ;; Revoke previous snapshot to avoid leaking blob URLs.
- (when-let [prev wasm/canvas-snapshot-url]
- (when (and (string? prev) (.startsWith ^js prev "blob:"))
- (js/URL.revokeObjectURL prev)))
- (set! wasm/canvas-snapshot-url nil)
- (.toBlob canvas
- (fn [^js blob]
- (if blob
- (let [url (js/URL.createObjectURL blob)]
- (set! wasm/canvas-snapshot-url url)
- (resolve url))
- (resolve nil)))
- "image/png")))
+ (-> (js/createImageBitmap canvas)
+ (p/then (fn [^js bitmap]
+ (set! wasm/canvas-snapshot bitmap)
+ bitmap))
+ (p/catch (fn [_] nil)))
(p/resolved nil)))
(defn draw-thumbnail-to-canvas
diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs
index caf3d3a15a..22403cc823 100644
--- a/frontend/src/app/render_wasm/wasm.cljs
+++ b/frontend/src/app/render_wasm/wasm.cljs
@@ -13,9 +13,10 @@
;; Reference to the HTML canvas element.
(defonce canvas nil)
-;; Snapshot of the current canvas suitable for `
` overlays.
-;; This is typically a `blob:` URL created via `canvas.toBlob`.
-(defonce canvas-snapshot-url nil)
+;; Snapshot of the current canvas as an `ImageBitmap`, suitable for painting
+;; into an overlay canvas. Created via `createImageBitmap` so capturing never
+;; encodes pixels on the main thread.
+(defonce canvas-snapshot nil)
;; Reference to the Emscripten GL context wrapper.
(defonce gl-context-handle nil)
@@ -38,7 +39,7 @@
[]
(set! internal-frame-id nil)
(set! canvas nil)
- (set! canvas-snapshot-url nil)
+ (set! canvas-snapshot nil)
(set! gl-context-handle nil)
(set! gl-context nil)
(set! context-initialized? false)