;; 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 Sucursal en España SL (ns app.wasm "Headless driver for the render-wasm module under Node: the GPU-free counterpart of `app.render-wasm.api`. Loads the emscripten artifact, boots it via `init_headless`, and exposes font provisioning + shape rendering. Serialization is reused from the portable render-wasm leaves, so this namespace owns only the Node runtime and the headless render calls. Requires render-wasm built with `-sENVIRONMENT=web,node`." (:require ["node:fs" :as fs] ["node:path" :as path] [app.common.data :as d] [app.common.logging :as l] [app.common.render-wasm.helpers :as h] [app.common.render-wasm.mem :as mem] [app.common.render-wasm.serializers :as sr] [app.common.render-wasm.wasm :as wasm] [app.common.uuid :as uuid] ;; Required for side effects: binds the generated enums. [app.wasm.enums] [promesa.core :as p] [shadow.esm :refer [dynamic-import]])) (def ^:private default-viewport-width 1920) (def ^:private default-viewport-height 1080) ;; render_shape_raster / render_shape_pixels result header: [len u32][w u32][h u32]. (def ^:private RASTER-HEADER-BYTES 12) ;; render_shape_pdf result header: [len u32] only. (def ^:private PDF-HEADER-BYTES 4) ;; get_fonts_for_shape entry: [uuid 16 bytes][weight u32][style u32]. (def ^:private FONT-ENTRY-BYTES 24) (def artifact-dir "Built render-wasm artifact, relative to the process working directory. Same path in devenv and inside the bundle, so it is a constant." "resources/wasm") (def image-cache-mb "Byte budget (MB) the image store is trimmed to between requests." 256) (defn- read-result-bytes "Reads `len` bytes from the WASM heap starting at `offset`, copying them out (via `.slice`) before the buffer is freed." [offset len] (.slice (mem/get-heap-u8) offset (+ offset len))) ;; --- MODULE LIFECYCLE (defn init! "Loads the render-wasm artifact under Node and boots it headless. Sets the shared `wasm/internal-module` so the portable serialization leaves work. Idempotent-ish: callers should hold the returned module." ([] (init! default-viewport-width default-viewport-height)) ([width height] (let [dir artifact-dir js-path (path/resolve dir "render-wasm.js") wasm-path (path/resolve dir "render-wasm.wasm") wasm-bytes (fs/readFileSync wasm-path)] (l/info :hint "loading render-wasm (headless)" :js js-path) ;; shadow-cljs :esm — use its dynamic-import helper (raw `js/import` ;; compiles to an undefined `import$`). (->> (dynamic-import (str "file://" js-path)) (p/mcat (fn [mod] (let [factory (unchecked-get mod "default")] (factory #js {;; Bypass the web fetch loader: instantiate from local bytes. :instantiateWasm (fn [imports success] (-> (js/WebAssembly.instantiate wasm-bytes imports) (.then (fn [result] (success (.-instance result))))) #js {}) :locateFile (fn [p] (path/resolve dir p)) :printErr (fn [s] (l/warn :wasm s))})))) (p/fmap (fn [module] (set! wasm/internal-module module) (h/call module "_init_headless" width height) (set! wasm/context-initialized? true) (l/info :hint "render-wasm headless module ready" :width width :height height) module)))))) ;; --- FONT PROVISIONING (on demand, mirrors the browser) (defn fonts-for-shape "Returns the distinct font families needed to render the subtree rooted at `shape-id` as a vector of {:id :weight :style}. Equivalent to the browser's `get-content-fonts`, but read from the loaded WASM tree." [shape-id] (let [module wasm/internal-module buf (uuid/get-u32 shape-id) ;; resolved from app.render-wasm leaves offset (h/call module "_get_fonts_for_shape" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)) heap32 (mem/get-heap-u32) n (aget heap32 (mem/->offset-32 offset)) ;; `vec` must stay eager: it reads the result buffer, and the ;; `mem/free` below invalidates these offsets. entries (vec (for [i (range n)] (let [base (+ offset 4 (* i FONT-ENTRY-BYTES)) u32 (fn [o] (aget heap32 (mem/->offset-32 (+ base o))))] {:id #js [(u32 0) (u32 4) (u32 8) (u32 12)] :weight (u32 16) :style (u32 20)})))] (mem/free) entries)) (defn- font-key "Value key for a family map. Its `:id` is a JS array, so the map itself can't be compared by value." [{:keys [id weight style]}] [(aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style]) (defn fonts-for-shapes "Distinct font families needed by every subtree in `shape-ids`. Objects in a partition overwhelmingly share families, so deduping here means one download and one `_store_font` per family rather than one per object." [shape-ids] (into [] (comp (mapcat fonts-for-shape) (d/distinct-xf font-key)) shape-ids)) (defn store-font! "Uploads one font's TTF bytes into the WASM font store, keyed by the family (uuid quartet + weight + style). `font-bytes` is a Uint8Array/Buffer. Does NOT call `mem/free` — `store_font` (and likewise `store_image` below) releases the global buffer itself on the Rust side. Freeing again here would drop a buffer a later writer already owns." [{:keys [id weight style emoji? fallback?]} font-bytes] (let [module wasm/internal-module size (.-byteLength font-bytes) ptr (h/call module "_alloc_bytes" size) heap (mem/get-heap-u8)] (.set heap (js/Uint8Array. font-bytes) ptr) (h/call module "_store_font" (aget id 0) (aget id 1) (aget id 2) (aget id 3) weight style (boolean emoji?) (boolean fallback?)))) (defn clear-fonts! "Resets the WASM font store. Must be called once per render request because the shared module would otherwise accumulate fonts across requests." [] (h/call wasm/internal-module "_clear_fonts")) (defn update-text-layout! "Recomputes a text shape's layout with the currently provisioned fonts. Text is laid out at serialize time using the fallback font (real fonts aren't uploaded yet), so this must run again after `provision-fonts!` or glyph metrics/line breaks are wrong." [shape-id] (let [buf (uuid/get-u32 shape-id)] (h/call wasm/internal-module "_update_shape_text_layout_for" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)))) (defn image-cached? "True when the module's image store already holds this image (full size). The store is NOT reset between requests, so previously provisioned images can be reused instead of refetched." [image-id] (let [buf (uuid/get-u32 image-id)] (not (zero? (h/call wasm/internal-module "_is_image_cached" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) false))))) (defn store-image! "Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into the WASM image store via `_store_image`. Buffer layout matches the Rust reader: [shape uuid 16][image uuid 16][is_thumbnail u32][encoded bytes]. Images are keyed by image uuid, so the shape uuid is left zero. `image-bytes` is an ArrayBuffer/Buffer/Uint8Array." [image-id image-bytes] (let [module wasm/internal-module img-u8 (js/Uint8Array. image-bytes) size (.-byteLength img-u8) total (+ 36 size) ptr (h/call module "_alloc_bytes" total) heap (mem/get-heap-u8) dview (js/DataView. (.-buffer heap)) quart (uuid/get-u32 image-id)] ;; shape uuid [0..16) = 0 (images are keyed by image uuid only) (.setUint32 dview (+ ptr 0) 0 true) (.setUint32 dview (+ ptr 4) 0 true) (.setUint32 dview (+ ptr 8) 0 true) (.setUint32 dview (+ ptr 12) 0 true) ;; image uuid [16..32) — 4 LE u32 (matches common `buffer/write-uuid`, which ;; the fill path uses, so it hashes to the same key the fill references) (.setUint32 dview (+ ptr 16) (aget quart 0) true) (.setUint32 dview (+ ptr 20) (aget quart 1) true) (.setUint32 dview (+ ptr 24) (aget quart 2) true) (.setUint32 dview (+ ptr 28) (aget quart 3) true) ;; is_thumbnail [32..36) = 0 (.setUint32 dview (+ ptr 32) 0 true) ;; encoded bytes [36..) (.set heap img-u8 (+ ptr 36)) (h/call module "_store_image"))) (defn evict-images! "Evicts least-recently-used images until the store retains at most `max-mb` megabytes. Returns the number evicted." [max-mb] (h/call wasm/internal-module "_evict_images_to_budget" max-mb)) (defn provision-fonts! "Resolves and uploads every font needed by `shape-ids`, each family fetched once. `resolve-font` is an injected fn of the family map -> promise of TTF bytes (or nil to skip). This keeps the font *source* (gfonts proxy / custom assets / backend) out of the driver." [shape-ids resolve-font] (->> (fonts-for-shapes shape-ids) (map (fn [family] (->> (resolve-font family) (p/fmap (fn [bytes] (when bytes (store-font! family bytes))))))) (p/all))) ;; --- RENDER (defn- read-render-result "Copies the encoded payload out of a `_render_shape_*` result buffer and frees it. `header-bytes` is the size of the header preceding the payload." [offset header-bytes] (let [heap32 (mem/get-heap-u32) len (aget heap32 (mem/->offset-32 offset)) bytes (read-result-bytes (+ offset header-bytes) len)] (mem/free) bytes)) (defn render-shape-raster "Renders the shape subtree to encoded image bytes (Uint8Array) on a CPU surface. `format` is :png, :jpeg or :webp; jpeg is flattened onto white on the Rust side, since it has no alpha channel." [shape-id scale format] (let [buf (uuid/get-u32 shape-id)] (-> (h/call wasm/internal-module "_render_shape_raster" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) scale (sr/translate-raster-format format)) (read-render-result RASTER-HEADER-BYTES)))) (defn render-shape-pdf "Renders the shape subtree to PDF bytes (Uint8Array)." [shape-id scale] (let [buf (uuid/get-u32 shape-id)] (-> (h/call wasm/internal-module "_render_shape_pdf" (aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3) scale) (read-render-result PDF-HEADER-BYTES))))