mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 07:46:16 +00:00
✨ Make export follow the active renderer only (#11910)
Drop the separate :wasm-export flag and wasm-export/v1 feature. Single export, clipboard PNG, plugins, and batch :is-wasm now key off render-wasm/v1 alone. The exporter trusts :is-wasm for headless WASM and always keeps a worker pool ready.
This commit is contained in:
parent
63baf86152
commit
ec5a1edbed
@ -57,7 +57,6 @@
|
||||
"text-editor/v2"
|
||||
"text-editor-wasm/v1"
|
||||
"render-wasm/v1"
|
||||
"wasm-export/v1"
|
||||
"variants/v1"})
|
||||
|
||||
;; A set of features enabled by default
|
||||
@ -83,8 +82,7 @@
|
||||
"text-editor/v2"
|
||||
"text-editor-wasm/v1"
|
||||
"tokens/numeric-input"
|
||||
"render-wasm/v1"
|
||||
"wasm-export/v1"})
|
||||
"render-wasm/v1"})
|
||||
|
||||
;; Features that are mainly backend only or there are a proper
|
||||
;; fallback when frontend reports no support for it
|
||||
|
||||
@ -184,9 +184,6 @@
|
||||
:stroke-path
|
||||
:stroke-per-side
|
||||
|
||||
;; Exporter only: uses render-wasm for export instead of browser
|
||||
;; renderer.
|
||||
:wasm-export
|
||||
:custom-shortcuts
|
||||
:remote-media-processing
|
||||
|
||||
|
||||
@ -34,11 +34,10 @@
|
||||
:public-uri (str (cf/get :public-uri))
|
||||
:internal-uri (str (cf/get-internal-uri))
|
||||
:version (:full cf/version))
|
||||
(when (contains? cf/flags :wasm-export)
|
||||
(l/info :msg "headless wasm export enabled (experimental)"
|
||||
:wasm-dir wasm/artifact-dir
|
||||
:workers (cf/get :wasm-worker-pool-max)
|
||||
:image-cache-size (cf/get :wasm-worker-image-cache-size)))
|
||||
(l/info :msg "headless wasm export available"
|
||||
:wasm-dir wasm/artifact-dir
|
||||
:workers (cf/get :wasm-worker-pool-max)
|
||||
:image-cache-size (cf/get :wasm-worker-image-cache-size))
|
||||
(p/do
|
||||
(bwr/init)
|
||||
(redis/init)
|
||||
|
||||
@ -71,11 +71,8 @@
|
||||
(let [{:keys [resource total headless run]} (prepare cmd auth-token params)]
|
||||
(->> (jobs/create! {:profile-id profile-id
|
||||
:cmd cmd
|
||||
;; What the renderer will actually do, not what the
|
||||
;; client asked for: `is-wasm` alone still renders in
|
||||
;; the browser without the `wasm-export` flag, or for
|
||||
;; svg, and the backend decides both the admission cap
|
||||
;; and whether the client offers to cancel.
|
||||
;; Backend used for admission and cancel UI: headless
|
||||
;; WASM when `:is-wasm` is set, otherwise browser.
|
||||
:backend (if headless "wasm" "browser")
|
||||
:total total
|
||||
:name (:name resource)
|
||||
|
||||
@ -164,9 +164,8 @@
|
||||
(defn prepare-exports
|
||||
[exports token is-wasm]
|
||||
(letfn [(process-group [[part1 :as group]]
|
||||
;; The browser renders a partition as a single DOM page, so it is
|
||||
;; chunked to bound that page. A wasm export is headless, so
|
||||
;; it does not need to be chunked, and can be rendered as a single partition.
|
||||
;; Browser path renders one DOM page per partition; headless WASM
|
||||
;; takes the whole group in one render.
|
||||
(if (rd/headless? {:is-wasm is-wasm :type (:type part1)})
|
||||
[(build-render group)]
|
||||
(sequence (comp (partition-all default-partition-size)
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.renderer.bitmap :as rb]
|
||||
[app.renderer.pdf :as rp]
|
||||
[app.renderer.svg :as rs]
|
||||
@ -42,7 +41,7 @@
|
||||
(defn headless?
|
||||
"Whether `params` renders with render-wasm rather than a browser."
|
||||
[{:keys [is-wasm]}]
|
||||
(and is-wasm (contains? cf/flags :wasm-export)))
|
||||
(boolean is-wasm))
|
||||
|
||||
(defn render
|
||||
[{:keys [type is-wasm] :as params} on-object]
|
||||
@ -52,7 +51,6 @@
|
||||
(when is-wasm
|
||||
(l/info :hint "render"
|
||||
:type type
|
||||
:wasm-export (contains? cf/flags :wasm-export)
|
||||
:backend (if headless? "wasm" "browser")))
|
||||
(if headless?
|
||||
(rw/render params on-object)
|
||||
|
||||
@ -16,9 +16,8 @@
|
||||
the idle watchdog guarantees a wedged worker gives its slot back.
|
||||
|
||||
Workers run the same bundle as the main thread; `app.core/start` branches on
|
||||
`isMainThread`. Without the `wasm-export` flag no worker is spawned at all;
|
||||
with it there is always at least one, since a headless render has nowhere
|
||||
else to go."
|
||||
`isMainThread`. There is always at least one worker, since a headless render
|
||||
has nowhere else to go."
|
||||
(:require
|
||||
["generic-pool" :as gp]
|
||||
["node:path" :as path]
|
||||
@ -104,32 +103,27 @@
|
||||
|
||||
(defn capacity
|
||||
"How many renders can run at once, and so how many headless jobs the
|
||||
scheduler may admit. Zero exactly when headless export is off, which is also
|
||||
when no job is headless, so a headless job always has a worker to wait for."
|
||||
scheduler may admit."
|
||||
[]
|
||||
(if (contains? cf/flags :wasm-export)
|
||||
;; Clamped rather than rejected: a bad value should not stop the exporter
|
||||
;; from booting, and a headless render has no other backend to fall back to.
|
||||
(max 1 (cf/get :wasm-worker-pool-max 2))
|
||||
0))
|
||||
;; Clamped rather than rejected: a bad value should not stop the exporter
|
||||
;; from booting, and a headless render has no other backend to fall back to.
|
||||
(max 1 (cf/get :wasm-worker-pool-max 2)))
|
||||
|
||||
(defn init
|
||||
[]
|
||||
(let [configured (cf/get :wasm-worker-pool-max 2)
|
||||
max-workers (capacity)]
|
||||
(when (and (pos? max-workers) (not= configured max-workers))
|
||||
max-workers (capacity)
|
||||
opts #js {:max max-workers
|
||||
:min (min max-workers (cf/get :wasm-worker-pool-min 1))
|
||||
:testOnBorrow true
|
||||
:evictionRunIntervalMillis 30000
|
||||
:numTestsPerEvictionRun 2
|
||||
:idleTimeoutMillis 300000}]
|
||||
(when (not= configured max-workers)
|
||||
(l/warn :hint "wasm-worker-pool-max raised to the minimum of one"
|
||||
:configured configured))
|
||||
(if (pos? max-workers)
|
||||
(let [opts #js {:max max-workers
|
||||
:min (min max-workers (cf/get :wasm-worker-pool-min 1))
|
||||
:testOnBorrow true
|
||||
:evictionRunIntervalMillis 30000
|
||||
:numTestsPerEvictionRun 2
|
||||
:idleTimeoutMillis 300000}]
|
||||
(l/info :hint "initializing render worker pool" :opts opts)
|
||||
(reset! pool (gp/createPool worker-pool-factory opts)))
|
||||
(l/info :hint "render worker pool disabled, wasm export is off"))
|
||||
(l/info :hint "initializing render worker pool" :opts opts)
|
||||
(reset! pool (gp/createPool worker-pool-factory opts))
|
||||
(p/resolved nil)))
|
||||
|
||||
(defn stop
|
||||
|
||||
@ -511,44 +511,26 @@ test("BUG 12384 - Export crashing when exporting a board", async ({ page }) => {
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockRPC(/get\-file\?/, "design/get-file-12384.json");
|
||||
|
||||
let hasExportRequestBeenIntercepted = false;
|
||||
// Single export with render-wasm runs in the browser; it must not hit the
|
||||
// exporter API. Fail the test if that path is taken by mistake.
|
||||
await workspace.page.route("**/api/export", (route) => {
|
||||
if (hasExportRequestBeenIntercepted) {
|
||||
route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
hasExportRequestBeenIntercepted = true;
|
||||
const payload = route.request().postData();
|
||||
const parsedPayload = JSON.parse(payload);
|
||||
|
||||
expect(parsedPayload["~:exports"]).toHaveLength(1);
|
||||
expect(parsedPayload["~:exports"][0]["~:file-id"]).toBe(
|
||||
"~ufa6ce865-34dd-80ac-8006-fe0dab5539a7",
|
||||
);
|
||||
expect(parsedPayload["~:exports"][0]["~:page-id"]).toBe(
|
||||
"~ufa6ce865-34dd-80ac-8006-fe0dab5539a8",
|
||||
);
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
response: {},
|
||||
});
|
||||
throw new Error("unexpected /api/export request under render-wasm");
|
||||
});
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
fileId: "fa6ce865-34dd-80ac-8006-fe0dab5539a7",
|
||||
pageId: "fa6ce865-34dd-80ac-8006-fe0dab5539a8",
|
||||
});
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
await workspace.clickLeafLayer("Board");
|
||||
|
||||
let exportRequest = workspace.page.waitForRequest("**/api/export");
|
||||
const [download] = await Promise.all([
|
||||
workspace.page.waitForEvent("download"),
|
||||
workspace.rightSidebar
|
||||
.getByRole("button", { name: "Export 1 element" })
|
||||
.click(),
|
||||
]);
|
||||
|
||||
await workspace.rightSidebar
|
||||
.getByRole("button", { name: "Export 1 element" })
|
||||
.click();
|
||||
|
||||
await exportRequest;
|
||||
expect(download.suggestedFilename()).toBe("Board.png");
|
||||
});
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.exports.wasm :as wasm.exports]
|
||||
[app.main.data.helpers :as dsh]
|
||||
@ -255,9 +254,9 @@
|
||||
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg})
|
||||
|
||||
(defn- wasm-export-enabled?
|
||||
"True when the active renderer is render-wasm."
|
||||
[state]
|
||||
(and (contains? cf/flags :wasm-export)
|
||||
(features/active-feature? state "render-wasm/v1")))
|
||||
(features/active-feature? state "render-wasm/v1"))
|
||||
|
||||
(defn- use-wasm-export?
|
||||
"Whether to take the client-side WASM export path for `export`."
|
||||
|
||||
@ -1170,8 +1170,7 @@
|
||||
(rx/of (ntf/info (tr "workspace.clipboard.copying")))
|
||||
|
||||
;; Call exporter to get image URI, then fetch blob and resolve the deferred.
|
||||
(->> (if (and (features/active-feature? state "render-wasm/v1")
|
||||
(contains? cf/flags :wasm-export))
|
||||
(->> (if (features/active-feature? state "render-wasm/v1")
|
||||
(rx/of {:uri (wasm.exports/export-image-uri export)})
|
||||
(rp/cmd! :export
|
||||
{:exports [export]
|
||||
|
||||
@ -33,7 +33,6 @@
|
||||
[app.common.types.shape.shadow :as ctss]
|
||||
[app.common.types.text :as txt]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.main.data.exports.assets :as de]
|
||||
[app.main.data.exports.wasm :as wasm.exports]
|
||||
[app.main.data.persistence :as dwp]
|
||||
@ -49,6 +48,7 @@
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.data.workspace.tokens.application :as dwta]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.features :as features]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.plugins.exports :as exports]
|
||||
@ -1603,65 +1603,67 @@
|
||||
(u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission")
|
||||
|
||||
:else
|
||||
(if (and (contains? cf/flags :wasm-export)
|
||||
(contains? #{:jpeg :webp :png} (:type value :png)))
|
||||
;; New export with wasm
|
||||
(let [uri (wasm.exports/export-image-uri
|
||||
{:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id id
|
||||
:type (:type value :png)
|
||||
:scale (:scale value 1)})]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (http/send!
|
||||
{:method :get
|
||||
:uri uri
|
||||
:response-type :blob
|
||||
:omit-default-headers true})
|
||||
(rx/map :body)
|
||||
(rx/mapcat #(.arrayBuffer %))
|
||||
(rx/map #(js/Uint8Array. %))
|
||||
(rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm")))
|
||||
(rx/subs! resolve reject)))))
|
||||
|
||||
;; Old export through exporter
|
||||
(let [shape (u/locate-shape file-id page-id id)
|
||||
payload
|
||||
{:cmd :export-shapes
|
||||
:profile-id (:profile-id @st/state)
|
||||
:wait true
|
||||
:is-wasm false
|
||||
:exports [(de/normalize-export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id id
|
||||
:name (:name shape)
|
||||
:type (:type value :png)
|
||||
:suffix (:suffix value "")
|
||||
:scale (:scale value 1)})]}]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
;; The exporter renders the file from its persisted
|
||||
;; state, so flush pending local changes and wait until
|
||||
;; they are saved before invoking it. Otherwise it may
|
||||
;; export a stale/empty shape. (The wasm export above
|
||||
;; renders locally and does not need this.)
|
||||
(st/emit! ::dwp/force-persist)
|
||||
(->> (rx/concat
|
||||
(->> (dwp/wait-persisted 5000)
|
||||
(rx/ignore))
|
||||
(rp/cmd! :export payload))
|
||||
(rx/mapcat (fn [{:keys [uri]}]
|
||||
(->> (http/send! {:method :get
|
||||
:uri uri
|
||||
:response-type :blob
|
||||
:omit-default-headers true})
|
||||
(rx/map :body))))
|
||||
(rx/mapcat #(.arrayBuffer %))
|
||||
(rx/map #(js/Uint8Array. %))
|
||||
(rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "exporter")))
|
||||
(rx/subs! resolve reject)))))))))
|
||||
(let [export-type (:type value :png)
|
||||
wasm-enabled? (features/active-feature? @st/state "render-wasm/v1")]
|
||||
(if (and wasm-enabled?
|
||||
(contains? #{:jpeg :webp :png :pdf :svg} export-type))
|
||||
;; Render in the browser with render-wasm.
|
||||
(let [export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id id
|
||||
:type export-type
|
||||
:scale (:scale value 1)}
|
||||
uri (case export-type
|
||||
:pdf (wasm.exports/export-pdf-uri export)
|
||||
:svg (wasm.exports/export-svg-uri export)
|
||||
(wasm.exports/export-image-uri export))]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
(->> (http/send!
|
||||
{:method :get
|
||||
:uri uri
|
||||
:response-type :blob
|
||||
:omit-default-headers true})
|
||||
(rx/map :body)
|
||||
(rx/mapcat #(.arrayBuffer %))
|
||||
(rx/map #(js/Uint8Array. %))
|
||||
(rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "wasm")))
|
||||
(rx/subs! resolve reject)))))
|
||||
|
||||
;; Render via the exporter service.
|
||||
(let [shape (u/locate-shape file-id page-id id)
|
||||
payload
|
||||
{:cmd :export-shapes
|
||||
:profile-id (:profile-id @st/state)
|
||||
:wait true
|
||||
:is-wasm wasm-enabled?
|
||||
:exports [(de/normalize-export {:file-id file-id
|
||||
:page-id page-id
|
||||
:object-id id
|
||||
:name (:name shape)
|
||||
:type export-type
|
||||
:suffix (:suffix value "")
|
||||
:scale (:scale value 1)})]}]
|
||||
(js/Promise.
|
||||
(fn [resolve reject]
|
||||
;; Exporter reads persisted file state; flush first
|
||||
;; so the export is not stale. Browser WASM above
|
||||
;; renders from local state and needs no flush.
|
||||
(st/emit! ::dwp/force-persist)
|
||||
(->> (rx/concat
|
||||
(->> (dwp/wait-persisted 5000)
|
||||
(rx/ignore))
|
||||
(rp/cmd! :export payload))
|
||||
(rx/mapcat (fn [{:keys [uri]}]
|
||||
(->> (http/send! {:method :get
|
||||
:uri uri
|
||||
:response-type :blob
|
||||
:omit-default-headers true})
|
||||
(rx/map :body))))
|
||||
(rx/mapcat #(.arrayBuffer %))
|
||||
(rx/map #(js/Uint8Array. %))
|
||||
(rx/tap #(st/emit! (se/event plugin-id "export-shapes" :method "exporter")))
|
||||
(rx/subs! resolve reject))))))))))
|
||||
|
||||
;; Interactions
|
||||
:addInteraction
|
||||
|
||||
@ -78,11 +78,11 @@ flowchart TB
|
||||
## Export wiring (single vs multiple)
|
||||
|
||||
The client-side WASM export — rendering in the browser through the vector path
|
||||
(`render_shape_pdf` / `render_shape_pixels`) — is wired **only for single
|
||||
exports** (`request-simple-export` in `frontend/.../exports/assets.cljs`), and
|
||||
only when render-wasm is active and the `enable-wasm-export` flag is set. The
|
||||
exporter service reads the same flag to decide whether it can serve the
|
||||
headless WASM path.
|
||||
(`render_shape_pdf` / `render_shape_pixels` / `render_shape_svg`) — is wired
|
||||
**only for single exports** (`request-simple-export` in
|
||||
`frontend/.../exports/assets.cljs`), and only when the active renderer is
|
||||
render-wasm (`render-wasm/v1`). The exporter service takes the headless WASM
|
||||
path when the client sends `:is-wasm true` (same condition).
|
||||
|
||||
**Multiple/batch export** (`request-multiple-export`) always runs **server-side**
|
||||
via the `:export-shapes` command; it merely passes an `:is-wasm` hint so the
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user