mirror of
https://github.com/penpot/penpot.git
synced 2026-09-22 11:56:15 +00:00
⚡ Composite render-wasm video instead of rastering it into tiles
This commit is contained in:
parent
8455fcf170
commit
c62cecced8
@ -57,6 +57,7 @@
|
|||||||
"text-editor/v2"
|
"text-editor/v2"
|
||||||
"text-editor-wasm/v1"
|
"text-editor-wasm/v1"
|
||||||
"render-wasm/v1"
|
"render-wasm/v1"
|
||||||
|
"video-overlay-wasm/v1"
|
||||||
"wasm-export/v1"
|
"wasm-export/v1"
|
||||||
"variants/v1"})
|
"variants/v1"})
|
||||||
|
|
||||||
@ -84,6 +85,7 @@
|
|||||||
"text-editor-wasm/v1"
|
"text-editor-wasm/v1"
|
||||||
"tokens/numeric-input"
|
"tokens/numeric-input"
|
||||||
"render-wasm/v1"
|
"render-wasm/v1"
|
||||||
|
"video-overlay-wasm/v1"
|
||||||
"wasm-export/v1"})
|
"wasm-export/v1"})
|
||||||
|
|
||||||
;; Features that are mainly backend only or there are a proper
|
;; Features that are mainly backend only or there are a proper
|
||||||
@ -134,6 +136,7 @@
|
|||||||
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
|
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
|
||||||
:feature-text-editor-wasm "text-editor-wasm/v1"
|
:feature-text-editor-wasm "text-editor-wasm/v1"
|
||||||
:feature-render-wasm "render-wasm/v1"
|
:feature-render-wasm "render-wasm/v1"
|
||||||
|
:feature-video-overlay-wasm "video-overlay-wasm/v1"
|
||||||
:feature-variants "variants/v1"
|
:feature-variants "variants/v1"
|
||||||
:feature-token-input "tokens/numeric-input"
|
:feature-token-input "tokens/numeric-input"
|
||||||
nil))
|
nil))
|
||||||
|
|||||||
@ -214,6 +214,7 @@
|
|||||||
:enable-inspect-styles
|
:enable-inspect-styles
|
||||||
:enable-feature-fdata-objects-map
|
:enable-feature-fdata-objects-map
|
||||||
:enable-feature-render-wasm
|
:enable-feature-render-wasm
|
||||||
|
:enable-feature-video-overlay-wasm
|
||||||
:enable-token-import-from-library
|
:enable-token-import-from-library
|
||||||
:enable-render-switch
|
:enable-render-switch
|
||||||
:enable-render-wasm-info
|
:enable-render-wasm-info
|
||||||
|
|||||||
@ -5,11 +5,16 @@
|
|||||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||||
|
|
||||||
(ns app.main.ui.workspace.sidebar.options.menus.video
|
(ns app.main.ui.workspace.sidebar.options.menus.video
|
||||||
"Proof of concept: plays a video into a shape's image fill.
|
"Plays a video into a shape's image fill.
|
||||||
|
|
||||||
Nothing uploads video, so the source is a path the browser can already
|
The source is an uploaded video asset, or a path the browser can already
|
||||||
reach — a file under `frontend/resources/public/images/` or a full URL. Only
|
reach — a file under `frontend/resources/public/images/` or a full URL. Only
|
||||||
the render-wasm renderer paints it; elsewhere the image fill still shows."
|
the render-wasm renderer paints it; elsewhere the image fill still shows.
|
||||||
|
|
||||||
|
A video is stamped when the frame is composed, which is a flat draw: it
|
||||||
|
cannot carry opacity, a blend mode, a blur, a shadow or a stroke. A shape
|
||||||
|
with one of those does not play, and this menu says which one is in the
|
||||||
|
way."
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
@ -33,14 +38,25 @@
|
|||||||
(dissoc shape :video)
|
(dissoc shape :video)
|
||||||
(assoc shape :video source)))))
|
(assoc shape :video source)))))
|
||||||
|
|
||||||
|
(def ^:private reason-labels
|
||||||
|
{:opacity "workspace.options.video.blocked.opacity"
|
||||||
|
:blend-mode "workspace.options.video.blocked.blend-mode"
|
||||||
|
:blur "workspace.options.video.blocked.blur"
|
||||||
|
:shadow "workspace.options.video.blocked.shadow"
|
||||||
|
:stroke "workspace.options.video.blocked.stroke"
|
||||||
|
:masked "workspace.options.video.blocked.masked"})
|
||||||
|
|
||||||
(mf/defc video-menu*
|
(mf/defc video-menu*
|
||||||
[{:keys [ids image-id source is-asset]}]
|
[{:keys [ids image-id source is-asset blocked-reason]}]
|
||||||
(let [;; `video/playing?` reads the element, which is outside app state, so
|
(let [;; `video/playing?` reads the element, which is outside app state, so
|
||||||
;; the button tracks it locally.
|
;; the button tracks it locally.
|
||||||
playing* (mf/use-state #(video/playing? image-id))
|
playing* (mf/use-state #(video/playing? image-id))
|
||||||
playing (deref playing*)
|
playing (deref playing*)
|
||||||
|
|
||||||
has-source (not (str/blank? source))
|
blocked-label (get reason-labels blocked-reason)
|
||||||
|
|
||||||
|
has-source (and (not (str/blank? source))
|
||||||
|
(nil? blocked-label))
|
||||||
|
|
||||||
on-change
|
on-change
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -75,6 +91,11 @@
|
|||||||
:selected playing
|
:selected playing
|
||||||
:tooltip-placement "top-left"
|
:tooltip-placement "top-left"
|
||||||
:icon i/play}])]]
|
:icon i/play}])]]
|
||||||
|
;; Playback was refused: say which property is in the way, so a video that
|
||||||
|
;; stops after a shadow is added does not look broken.
|
||||||
|
(when (some? blocked-label)
|
||||||
|
[:div {:class (stl/css :blocked)}
|
||||||
|
(tr blocked-label)])
|
||||||
;; An uploaded video has nothing to type: its source is the asset itself,
|
;; An uploaded video has nothing to type: its source is the asset itself,
|
||||||
;; and removing it means deleting the shape.
|
;; and removing it means deleting the shape.
|
||||||
(when-not is-asset
|
(when-not is-asset
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
// Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||||
|
|
||||||
|
@use "ds/typography.scss" as t;
|
||||||
@use "../../../sidebar/common/sidebar.scss" as sidebar;
|
@use "../../../sidebar/common/sidebar.scss" as sidebar;
|
||||||
|
|
||||||
.element-set {
|
.element-set {
|
||||||
@ -21,3 +22,10 @@
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blocked {
|
||||||
|
@include t.use-typography("body-small");
|
||||||
|
|
||||||
|
color: var(--input-foreground-color-disabled);
|
||||||
|
margin-bottom: var(--sp-s);
|
||||||
|
}
|
||||||
|
|||||||
@ -95,7 +95,12 @@
|
|||||||
|
|
||||||
;; An uploaded video asset needs no source field, only playback.
|
;; An uploaded video asset needs no source field, only playback.
|
||||||
video-asset?
|
video-asset?
|
||||||
(and render-wasm? (some? (video/video-fill shape)))]
|
(and render-wasm? (some? (video/video-fill shape)))
|
||||||
|
|
||||||
|
;; The composited path is a flat draw, so a shape carrying opacity, a
|
||||||
|
;; blend mode, a blur, a shadow or a stroke does not play at all.
|
||||||
|
video-blocked-reason
|
||||||
|
(when (some? video-image-id) (video/refuses-to-play shape))]
|
||||||
|
|
||||||
[:*
|
[:*
|
||||||
[:> layer-menu* {:ids ids
|
[:> layer-menu* {:ids ids
|
||||||
@ -146,7 +151,8 @@
|
|||||||
[:> video-menu* {:ids ids
|
[:> video-menu* {:ids ids
|
||||||
:image-id video-image-id
|
:image-id video-image-id
|
||||||
:source (video/shape-source shape)
|
:source (video/shape-source shape)
|
||||||
:is-asset video-asset?}])
|
:is-asset video-asset?
|
||||||
|
:blocked-reason video-blocked-reason}])
|
||||||
|
|
||||||
[:> stroke-menu* {:ids ids
|
[:> stroke-menu* {:ids ids
|
||||||
:type type
|
:type type
|
||||||
|
|||||||
@ -12,15 +12,19 @@
|
|||||||
the current frame is uploaded into that texture and WASM re-wraps it, so
|
the current frame is uploaded into that texture and WASM re-wraps it, so
|
||||||
every shape whose image fill points at `image-id` paints it.
|
every shape whose image fill points at `image-id` paints it.
|
||||||
|
|
||||||
The source is stored on the shape as `:video`, so it persists with the file
|
With `video-overlay-wasm/v1` on, an eligible shape has its frames stamped
|
||||||
and comes back on load. Drop a file into `frontend/resources/public/images/`,
|
when the frame is composed rather than rastered into the tiles, so playback
|
||||||
where it is served like any other static asset, and name it from the Video
|
costs one textured quad instead of a tile re-raster. A shape carrying
|
||||||
section of the design sidebar. `penpotAttachVideo(\"clip.mp4\")` does the same
|
opacity, a blend mode, a blur, a shadow or a stroke cannot be stamped that
|
||||||
from the console for the selected shape, without persisting it.
|
way and does not play at all; `ineligible-reason` says which one is in the
|
||||||
|
way so the sidebar can explain it.
|
||||||
|
|
||||||
There is no backend support: nothing uploads video, so the source is a path
|
The source is either an uploaded video asset or the shape's `:video`
|
||||||
the browser can reach on its own."
|
attribute — a file under `frontend/resources/public/images/` or a full URL.
|
||||||
|
`penpotAttachVideo(\"clip.mp4\")` does the same from the console for the
|
||||||
|
selected shape, without persisting it."
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.geom.rect :as grc]
|
||||||
[app.common.logging :as log]
|
[app.common.logging :as log]
|
||||||
[app.common.media :as cm]
|
[app.common.media :as cm]
|
||||||
[app.common.render-wasm.helpers :as h]
|
[app.common.render-wasm.helpers :as h]
|
||||||
@ -28,6 +32,7 @@
|
|||||||
[app.common.render-wasm.mem.heap32 :as mem.h32]
|
[app.common.render-wasm.mem.heap32 :as mem.h32]
|
||||||
[app.common.render-wasm.wasm :as wasm]
|
[app.common.render-wasm.wasm :as wasm]
|
||||||
[app.common.types.fills :as types.fills]
|
[app.common.types.fills :as types.fills]
|
||||||
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
@ -40,10 +45,26 @@
|
|||||||
;; Videos live next to the other static assets, so a bare file name is enough.
|
;; Videos live next to the other static assets, so a bare file name is enough.
|
||||||
(def ^:private assets-path "/images/")
|
(def ^:private assets-path "/images/")
|
||||||
|
|
||||||
;; image-id -> {:element :source :texture :texture-id :shape-id :image-id :last-time}
|
;; image-id -> {:element :source :texture :texture-id :shape-id :image-id
|
||||||
|
;; :tex-width :tex-height :pending? :frame-handle :last-time}
|
||||||
;; The texture keys arrive later, once the video has decoded its first frame.
|
;; The texture keys arrive later, once the video has decoded its first frame.
|
||||||
(defonce ^:private videos (atom {}))
|
(defonce ^:private videos (atom {}))
|
||||||
|
|
||||||
|
(defn- update-entry!
|
||||||
|
"Applies `f` to an attachment, and does nothing when it is already detached —
|
||||||
|
frame callbacks and promises resolve after a detach."
|
||||||
|
[image-id f & args]
|
||||||
|
(swap! videos (fn [videos]
|
||||||
|
(if (contains? videos image-id)
|
||||||
|
(apply update videos image-id f args)
|
||||||
|
videos))))
|
||||||
|
|
||||||
|
(defn- frame-callbacks?
|
||||||
|
"True when the element reports each presented frame on its own. Widely
|
||||||
|
available, but the rAF poll stays as the fallback."
|
||||||
|
[^js element]
|
||||||
|
(fn? (.-requestVideoFrameCallback element)))
|
||||||
|
|
||||||
;; `api.cljs` owns the render loop and requires this namespace, so it installs
|
;; `api.cljs` owns the render loop and requires this namespace, so it installs
|
||||||
;; its requester here rather than being required back.
|
;; its requester here rather than being required back.
|
||||||
(defonce ^:private render-requester (atom nil))
|
(defonce ^:private render-requester (atom nil))
|
||||||
@ -63,6 +84,52 @@
|
|||||||
[]
|
[]
|
||||||
(exists? js/document))
|
(exists? js/document))
|
||||||
|
|
||||||
|
(def ineligible-reasons
|
||||||
|
"Codes returned by `_get_video_eligibility`, matching `VideoIneligible` in
|
||||||
|
`render-wasm/src/render/video.rs`. `0` means the video can be composited."
|
||||||
|
{1 :no-video-fill
|
||||||
|
2 :opacity
|
||||||
|
3 :blend-mode
|
||||||
|
4 :blur
|
||||||
|
5 :shadow
|
||||||
|
6 :stroke
|
||||||
|
7 :masked})
|
||||||
|
|
||||||
|
(defn- call-with-uuid!
|
||||||
|
[export id]
|
||||||
|
(let [buffer (uuid/get-u32 id)]
|
||||||
|
(h/call wasm/internal-module export
|
||||||
|
(aget buffer 0)
|
||||||
|
(aget buffer 1)
|
||||||
|
(aget buffer 2)
|
||||||
|
(aget buffer 3))))
|
||||||
|
|
||||||
|
(defn- register-image!
|
||||||
|
"Tells the renderer this image is backed by a playing video, so its frames are
|
||||||
|
stamped when the frame is composed instead of rastered into the tiles."
|
||||||
|
[image-id]
|
||||||
|
(when (wasm/live?)
|
||||||
|
(call-with-uuid! "_register_video_image" image-id)))
|
||||||
|
|
||||||
|
(defn- unregister-image!
|
||||||
|
[image-id]
|
||||||
|
(when (wasm/live?)
|
||||||
|
(call-with-uuid! "_unregister_video_image" image-id)))
|
||||||
|
|
||||||
|
(defn set-overlay-enabled!
|
||||||
|
"Threads `video-overlay-wasm/v1` to the renderer. With it off, video frames
|
||||||
|
keep going through the tiles."
|
||||||
|
[enabled]
|
||||||
|
(when (wasm/live?)
|
||||||
|
(h/call wasm/internal-module "_set_video_overlay_enabled" (boolean enabled))))
|
||||||
|
|
||||||
|
(defn ineligible-reason
|
||||||
|
"Why `shape-id` cannot have its video composited, or nil when it can. The
|
||||||
|
renderer owns the rule, so the sidebar and the render path cannot disagree."
|
||||||
|
[shape-id]
|
||||||
|
(when (wasm/live?)
|
||||||
|
(get ineligible-reasons (call-with-uuid! "_get_video_eligibility" shape-id))))
|
||||||
|
|
||||||
(defn- send-frame!
|
(defn- send-frame!
|
||||||
"Hands the texture to WASM, which re-wraps it as a Skia image and invalidates
|
"Hands the texture to WASM, which re-wraps it as a Skia image and invalidates
|
||||||
the tiles of every shape painting it. Same layout as `_store_image_from_texture`."
|
the tiles of every shape painting it. Same layout as `_store_image_from_texture`."
|
||||||
@ -78,21 +145,51 @@
|
|||||||
(h/call wasm/internal-module "_update_image_from_texture")))
|
(h/call wasm/internal-module "_update_image_from_texture")))
|
||||||
|
|
||||||
(defn- decoded-frame?
|
(defn- decoded-frame?
|
||||||
[{:keys [^js element image-id texture-id]}]
|
[{:keys [^js element texture-id pending? last-time]}]
|
||||||
(and (some? texture-id)
|
(and (some? texture-id)
|
||||||
(>= (.-readyState element) HAVE-CURRENT-DATA)
|
(>= (.-readyState element) HAVE-CURRENT-DATA)
|
||||||
(pos? (.-videoWidth element))
|
(pos? (.-videoWidth element))
|
||||||
;; rAF runs faster than most videos decode; skip the upload when
|
(if (frame-callbacks? element)
|
||||||
;; playback has not advanced since the last one.
|
pending?
|
||||||
(not= (.-currentTime element) (get-in @videos [image-id :last-time]))))
|
;; rAF runs faster than most videos decode; without the frame callback
|
||||||
|
;; skip the upload when playback has not advanced since the last one.
|
||||||
|
(not= (.-currentTime element) last-time))))
|
||||||
|
|
||||||
|
(defn visible-in-viewport?
|
||||||
|
"True when `selrect` meets `vbox`. An unknown viewport or unknown bounds —
|
||||||
|
the viewer, a page still loading — count as visible, so the video keeps
|
||||||
|
painting rather than going blank."
|
||||||
|
[vbox selrect]
|
||||||
|
(or (nil? vbox)
|
||||||
|
(nil? selrect)
|
||||||
|
(grc/overlaps-rects? vbox selrect)))
|
||||||
|
|
||||||
|
(defn- on-screen?
|
||||||
|
"A video outside the viewport keeps playing — so it stays in step with the
|
||||||
|
others and resumes at the right moment — but stops paying for a texture
|
||||||
|
upload."
|
||||||
|
[shape-id]
|
||||||
|
(let [state (deref st/state)]
|
||||||
|
(visible-in-viewport? (get-in state [:workspace-local :vbox])
|
||||||
|
(:selrect (dsh/lookup-shape state shape-id)))))
|
||||||
|
|
||||||
(defn- upload-frame!
|
(defn- upload-frame!
|
||||||
[gl {:keys [^js element texture image-id] :as entry}]
|
[gl {:keys [^js element texture image-id shape-id tex-width tex-height] :as entry}]
|
||||||
(when (decoded-frame? entry)
|
(when (decoded-frame? entry)
|
||||||
(swap! videos assoc-in [image-id :last-time] (.-currentTime element))
|
(if-not (on-screen? shape-id)
|
||||||
(webgl/upload-texture-source! gl texture element)
|
(update-entry! image-id assoc :pending? false :last-time (.-currentTime element))
|
||||||
(send-frame! entry)
|
(let [width (.-videoWidth element)
|
||||||
true))
|
height (.-videoHeight element)]
|
||||||
|
;; An adaptive stream can switch resolution mid-playback, and the
|
||||||
|
;; texture storage has to be redefined when it does.
|
||||||
|
(if (and (= width tex-width) (= height tex-height))
|
||||||
|
(webgl/update-texture-source! gl texture element)
|
||||||
|
(do
|
||||||
|
(webgl/upload-texture-source! gl texture element)
|
||||||
|
(update-entry! image-id assoc :tex-width width :tex-height height)))
|
||||||
|
(update-entry! image-id assoc :pending? false :last-time (.-currentTime element))
|
||||||
|
(send-frame! entry)
|
||||||
|
true))))
|
||||||
|
|
||||||
(defn tick!
|
(defn tick!
|
||||||
"Uploads a frame for every attached video that advanced. Called from the
|
"Uploads a frame for every attached video that advanced. Called from the
|
||||||
@ -106,21 +203,49 @@
|
|||||||
(vals @videos)))))
|
(vals @videos)))))
|
||||||
|
|
||||||
(defn active?
|
(defn active?
|
||||||
"True while some attached video is playing, so the renderer keeps scheduling
|
"True while some attached video needs the renderer to keep scheduling frames
|
||||||
frames instead of settling."
|
instead of settling. A video reporting its own frames asks for a render when
|
||||||
|
it has one, so only the polling fallback keeps the loop awake."
|
||||||
[]
|
[]
|
||||||
(boolean (some (fn [{:keys [^js element]}] (not (.-paused element)))
|
(boolean (some (fn [{:keys [^js element]}]
|
||||||
|
(and (not (.-paused element))
|
||||||
|
(not (frame-callbacks? element))))
|
||||||
(vals @videos))))
|
(vals @videos))))
|
||||||
|
|
||||||
|
(defn- request-frame-callback!
|
||||||
|
"Asks the element to report its next presented frame, and re-arms itself from
|
||||||
|
the callback so the chain lasts as long as the attachment does."
|
||||||
|
[image-id ^js element]
|
||||||
|
(when (and (frame-callbacks? element)
|
||||||
|
(contains? @videos image-id)
|
||||||
|
(nil? (get-in @videos [image-id :frame-handle])))
|
||||||
|
(let [handle (.requestVideoFrameCallback
|
||||||
|
element
|
||||||
|
(fn [_now _metadata]
|
||||||
|
(when (contains? @videos image-id)
|
||||||
|
(update-entry! image-id assoc :pending? true :frame-handle nil)
|
||||||
|
(request-render!)
|
||||||
|
(request-frame-callback! image-id element))))]
|
||||||
|
(update-entry! image-id assoc :frame-handle handle))))
|
||||||
|
|
||||||
|
(defn- cancel-frame-callback!
|
||||||
|
[^js element handle]
|
||||||
|
(when (and (some? handle) (fn? (.-cancelVideoFrameCallback element)))
|
||||||
|
(.cancelVideoFrameCallback element handle)))
|
||||||
|
|
||||||
(defn detach!
|
(defn detach!
|
||||||
"Stops a video. The last frame stays on screen: the texture is deliberately
|
"Stops a video. The last frame stays on screen: the texture is deliberately
|
||||||
kept alive because WASM holds a Skia image borrowing it."
|
kept alive because WASM holds a Skia image borrowing it."
|
||||||
[image-id]
|
[image-id]
|
||||||
(when-let [{:keys [^js element]} (get @videos image-id)]
|
(when-let [{:keys [^js element frame-handle]} (get @videos image-id)]
|
||||||
|
;; Dropped from the registry first: the frame callback re-arms itself and
|
||||||
|
;; checks the registry to know when to stop.
|
||||||
|
(swap! videos dissoc image-id)
|
||||||
|
(unregister-image! image-id)
|
||||||
|
(cancel-frame-callback! element frame-handle)
|
||||||
(.pause element)
|
(.pause element)
|
||||||
(.removeAttribute element "src")
|
(.removeAttribute element "src")
|
||||||
(.load element)
|
(.load element)))
|
||||||
(swap! videos dissoc image-id)))
|
|
||||||
|
|
||||||
(defn detach-all!
|
(defn detach-all!
|
||||||
[]
|
[]
|
||||||
@ -131,9 +256,16 @@
|
|||||||
[image-id ^js element]
|
[image-id ^js element]
|
||||||
(if-let [gl (webgl/get-webgl-context)]
|
(if-let [gl (webgl/get-webgl-context)]
|
||||||
(let [texture (webgl/create-webgl-texture-from-image gl element)]
|
(let [texture (webgl/create-webgl-texture-from-image gl element)]
|
||||||
(swap! videos update image-id merge
|
(register-image! image-id)
|
||||||
{:texture texture
|
(update-entry! image-id merge
|
||||||
:texture-id (webgl/register-texture! texture)})
|
{:texture texture
|
||||||
|
:texture-id (webgl/register-texture! texture)
|
||||||
|
;; `create-webgl-texture-from-image` allocated the storage
|
||||||
|
;; at this size; later frames only overwrite its pixels.
|
||||||
|
:tex-width (.-videoWidth element)
|
||||||
|
:tex-height (.-videoHeight element)
|
||||||
|
:pending? true})
|
||||||
|
(request-frame-callback! image-id element)
|
||||||
(-> (.play element)
|
(-> (.play element)
|
||||||
;; Nothing else would ask for a frame: `tick!` only runs inside a
|
;; Nothing else would ask for a frame: `tick!` only runs inside a
|
||||||
;; render, and the loop only keeps itself alive once it has started.
|
;; render, and the loop only keeps itself alive once it has started.
|
||||||
@ -162,6 +294,10 @@
|
|||||||
:source source
|
:source source
|
||||||
:shape-id shape-id
|
:shape-id shape-id
|
||||||
:image-id image-id
|
:image-id image-id
|
||||||
|
:tex-width nil
|
||||||
|
:tex-height nil
|
||||||
|
:pending? false
|
||||||
|
:frame-handle nil
|
||||||
:last-time nil})
|
:last-time nil})
|
||||||
;; Same-origin assets need no CORS, but a remote video served without the
|
;; Same-origin assets need no CORS, but a remote video served without the
|
||||||
;; headers would taint the canvas and make `texImage2D` throw.
|
;; headers would taint the canvas and make `texImage2D` throw.
|
||||||
@ -221,16 +357,32 @@
|
|||||||
[image-id]
|
[image-id]
|
||||||
(when-let [^js element (get-in @videos [image-id :element])]
|
(when-let [^js element (get-in @videos [image-id :element])]
|
||||||
(if (.-paused element)
|
(if (.-paused element)
|
||||||
(-> (.play element)
|
(do
|
||||||
(.then (fn [_] (request-render!)))
|
(request-frame-callback! image-id element)
|
||||||
(.catch (fn [cause]
|
(-> (.play element)
|
||||||
(log/error :hint "Could not play video" :cause cause))))
|
(.then (fn [_] (request-render!)))
|
||||||
|
(.catch (fn [cause]
|
||||||
|
(log/error :hint "Could not play video" :cause cause)))))
|
||||||
(.pause element))
|
(.pause element))
|
||||||
(not (.-paused element))))
|
(not (.-paused element))))
|
||||||
|
|
||||||
|
(defn- overlay-enabled?
|
||||||
|
[]
|
||||||
|
(contains? (:features (deref st/state)) "video-overlay-wasm/v1"))
|
||||||
|
|
||||||
|
(defn refuses-to-play
|
||||||
|
"Why `shape` will not play its video, or nil when it will. Only the composited
|
||||||
|
path refuses: with the flag off, frames still go through the tiles and any
|
||||||
|
shape can carry them."
|
||||||
|
[shape]
|
||||||
|
(when (overlay-enabled?)
|
||||||
|
(ineligible-reason (:id shape))))
|
||||||
|
|
||||||
(defn sync-shape!
|
(defn sync-shape!
|
||||||
"Reconciles one shape with its `:video` attribute. Called whenever the
|
"Reconciles one shape with its `:video` attribute. Called whenever the
|
||||||
attribute changes, so attaching is a plain shape edit and undo comes free.
|
attribute changes, so attaching is a plain shape edit and undo comes free.
|
||||||
|
Also called when the shape's effects change, so adding a drop shadow to a
|
||||||
|
playing video stops it there and then.
|
||||||
|
|
||||||
Returns nil: `set-wasm-attr!` treats what it gets back as pending image
|
Returns nil: `set-wasm-attr!` treats what it gets back as pending image
|
||||||
loads, and `detach!` would otherwise hand it the video registry."
|
loads, and `detach!` would otherwise hand it the video registry."
|
||||||
@ -241,6 +393,11 @@
|
|||||||
(str/blank? source)
|
(str/blank? source)
|
||||||
(detach! image-id)
|
(detach! image-id)
|
||||||
|
|
||||||
|
;; A flat stamp cannot reproduce opacity, blending, blur, a shadow or a
|
||||||
|
;; stroke, so a shape carrying one shows its poster frame instead.
|
||||||
|
(some? (refuses-to-play shape))
|
||||||
|
(detach! image-id)
|
||||||
|
|
||||||
(not= source (get-in @videos [image-id :source]))
|
(not= source (get-in @videos [image-id :source]))
|
||||||
(attach! (:id shape) image-id source))))
|
(attach! (:id shape) image-id source))))
|
||||||
nil)
|
nil)
|
||||||
@ -250,6 +407,9 @@
|
|||||||
for and stops the ones no shape wants any more. Run on page load, so a video
|
for and stops the ones no shape wants any more. Run on page load, so a video
|
||||||
survives a reload; an unchanged attachment keeps playing."
|
survives a reload; an unchanged attachment keeps playing."
|
||||||
[objects]
|
[objects]
|
||||||
|
;; Cheap, and this is where the renderer learns the flag: it runs on every
|
||||||
|
;; page load, and the flag can change between them.
|
||||||
|
(set-overlay-enabled! (overlay-enabled?))
|
||||||
(let [wanted (into {}
|
(let [wanted (into {}
|
||||||
(keep (fn [[shape-id shape]]
|
(keep (fn [[shape-id shape]]
|
||||||
(let [source (shape-source shape)]
|
(let [source (shape-source shape)]
|
||||||
@ -262,7 +422,8 @@
|
|||||||
(detach! image-id)))
|
(detach! image-id)))
|
||||||
(doseq [[image-id {:keys [shape-id source]}] wanted]
|
(doseq [[image-id {:keys [shape-id source]}] wanted]
|
||||||
(when-not (contains? @videos image-id)
|
(when-not (contains? @videos image-id)
|
||||||
(attach! shape-id image-id source)))))
|
(when-not (and (overlay-enabled?) (some? (ineligible-reason shape-id)))
|
||||||
|
(attach! shape-id image-id source))))))
|
||||||
|
|
||||||
(defn attach-to-selected!
|
(defn attach-to-selected!
|
||||||
"Console entry point: attaches `source` to the selected image shape."
|
"Console entry point: attaches `source` to the selected image shape."
|
||||||
|
|||||||
@ -34,13 +34,25 @@
|
|||||||
new-id))
|
new-id))
|
||||||
|
|
||||||
(defn upload-texture-source!
|
(defn upload-texture-source!
|
||||||
"Uploads `source` — an HTMLImageElement, ImageBitmap or HTMLVideoElement — into
|
"Allocates the texture storage and fills it with `source` — an
|
||||||
an existing texture."
|
HTMLImageElement, ImageBitmap or HTMLVideoElement. Redefines the texture
|
||||||
|
level, so it is the call to make once per texture (or when the source
|
||||||
|
changes size), not the one to repeat per frame."
|
||||||
[gl texture source]
|
[gl texture source]
|
||||||
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
|
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
|
||||||
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) source)
|
(.texImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 (.-RGBA ^js gl) (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) source)
|
||||||
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil))
|
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil))
|
||||||
|
|
||||||
|
(defn update-texture-source!
|
||||||
|
"Overwrites the pixels of an already-allocated texture with `source`, which
|
||||||
|
must have the same size the texture was allocated with. Used for video
|
||||||
|
frames: unlike `upload-texture-source!` it does not redefine the level, so
|
||||||
|
the storage is not reallocated on every frame."
|
||||||
|
[gl texture source]
|
||||||
|
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) texture)
|
||||||
|
(.texSubImage2D ^js gl (.-TEXTURE_2D ^js gl) 0 0 0 (.-RGBA ^js gl) (.-UNSIGNED_BYTE ^js gl) source)
|
||||||
|
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil))
|
||||||
|
|
||||||
(defn create-webgl-texture-from-image
|
(defn create-webgl-texture-from-image
|
||||||
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
|
"Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object"
|
||||||
[gl image-element]
|
[gl image-element]
|
||||||
|
|||||||
@ -170,13 +170,17 @@
|
|||||||
(api/set-shape-fills id v false)
|
(api/set-shape-fills id v false)
|
||||||
|
|
||||||
:strokes
|
:strokes
|
||||||
(into [] (api/set-shape-strokes id v false))
|
(let [pending (into [] (api/set-shape-strokes id v false))]
|
||||||
|
(video/sync-shape! shape)
|
||||||
|
pending)
|
||||||
|
|
||||||
:blend-mode
|
:blend-mode
|
||||||
(api/set-shape-blend-mode v)
|
(do (api/set-shape-blend-mode v)
|
||||||
|
(video/sync-shape! shape))
|
||||||
|
|
||||||
:opacity
|
:opacity
|
||||||
(api/set-shape-opacity v)
|
(do (api/set-shape-opacity v)
|
||||||
|
(video/sync-shape! shape))
|
||||||
|
|
||||||
:hidden
|
:hidden
|
||||||
(api/set-shape-hidden v)
|
(api/set-shape-hidden v)
|
||||||
@ -185,13 +189,15 @@
|
|||||||
(api/set-shape-children v)
|
(api/set-shape-children v)
|
||||||
|
|
||||||
:blur
|
:blur
|
||||||
(api/set-shape-blur v)
|
(do (api/set-shape-blur v)
|
||||||
|
(video/sync-shape! shape))
|
||||||
|
|
||||||
:background-blur
|
:background-blur
|
||||||
(api/set-shape-background-blur v)
|
(api/set-shape-background-blur v)
|
||||||
|
|
||||||
:shadow
|
:shadow
|
||||||
(api/set-shape-shadows v)
|
(do (api/set-shape-shadows v)
|
||||||
|
(video/sync-shape! shape))
|
||||||
|
|
||||||
:constraints-h
|
:constraints-h
|
||||||
(api/set-constraints-h v)
|
(api/set-constraints-h v)
|
||||||
@ -314,6 +320,9 @@
|
|||||||
(ctl/flex-layout? shape)
|
(ctl/flex-layout? shape)
|
||||||
(api/set-flex-layout shape))
|
(api/set-flex-layout shape))
|
||||||
|
|
||||||
|
;; Also reached from the effect cases above: a flat video stamp cannot
|
||||||
|
;; carry opacity, blending, a blur, a shadow or a stroke, so gaining one
|
||||||
|
;; has to stop playback.
|
||||||
:video
|
:video
|
||||||
(video/sync-shape! shape)
|
(video/sync-shape! shape)
|
||||||
|
|
||||||
|
|||||||
40
frontend/test/frontend_tests/render_wasm/video_test.cljs
Normal file
40
frontend/test/frontend_tests/render_wasm/video_test.cljs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
;; 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 SUBSIDIARY SL
|
||||||
|
|
||||||
|
(ns frontend-tests.render-wasm.video-test
|
||||||
|
(:require
|
||||||
|
[app.common.geom.rect :as grc]
|
||||||
|
[app.render-wasm.api.video :as video]
|
||||||
|
[cljs.test :as t :include-macros true]))
|
||||||
|
|
||||||
|
(t/deftest video-inside-the-viewport-is-visible
|
||||||
|
(let [vbox (grc/make-rect 0 0 800 600)
|
||||||
|
selrect (grc/make-rect 100 100 200 200)]
|
||||||
|
(t/is (true? (boolean (video/visible-in-viewport? vbox selrect))))))
|
||||||
|
|
||||||
|
(t/deftest video-outside-the-viewport-is-not-visible
|
||||||
|
(let [vbox (grc/make-rect 0 0 800 600)
|
||||||
|
selrect (grc/make-rect 2000 2000 200 200)]
|
||||||
|
(t/is (false? (boolean (video/visible-in-viewport? vbox selrect))))))
|
||||||
|
|
||||||
|
(t/deftest video-partly-inside-the-viewport-is-visible
|
||||||
|
(let [vbox (grc/make-rect 0 0 800 600)
|
||||||
|
selrect (grc/make-rect 700 500 400 400)]
|
||||||
|
(t/is (true? (boolean (video/visible-in-viewport? vbox selrect))))))
|
||||||
|
|
||||||
|
(t/deftest an-unknown-viewport-counts-as-visible
|
||||||
|
(let [selrect (grc/make-rect 2000 2000 200 200)]
|
||||||
|
(t/is (true? (boolean (video/visible-in-viewport? nil selrect))))))
|
||||||
|
|
||||||
|
(t/deftest unknown-bounds-count-as-visible
|
||||||
|
(let [vbox (grc/make-rect 0 0 800 600)]
|
||||||
|
(t/is (true? (boolean (video/visible-in-viewport? vbox nil))))))
|
||||||
|
|
||||||
|
(t/deftest every-ineligible-code-maps-to-a-reason
|
||||||
|
;; The codes are the `VideoIneligible` discriminants in
|
||||||
|
;; render-wasm/src/render/video.rs; a gap would silently show no explanation.
|
||||||
|
(t/is (= (set (range 1 8))
|
||||||
|
(set (keys video/ineligible-reasons)))))
|
||||||
@ -70,6 +70,7 @@
|
|||||||
[frontend-tests.render-wasm.process-objects-test]
|
[frontend-tests.render-wasm.process-objects-test]
|
||||||
[frontend-tests.render-wasm.text-editor-apply-styles-test]
|
[frontend-tests.render-wasm.text-editor-apply-styles-test]
|
||||||
[frontend-tests.render-wasm.text-editor-caret-color-test]
|
[frontend-tests.render-wasm.text-editor-caret-color-test]
|
||||||
|
[frontend-tests.render-wasm.video-test]
|
||||||
[frontend-tests.svg-fills-test]
|
[frontend-tests.svg-fills-test]
|
||||||
[frontend-tests.text-editor-paste-guard-test]
|
[frontend-tests.text-editor-paste-guard-test]
|
||||||
[frontend-tests.tokens.copy-paste-props-test]
|
[frontend-tests.tokens.copy-paste-props-test]
|
||||||
@ -179,6 +180,7 @@
|
|||||||
'frontend-tests.render-wasm.process-objects-test
|
'frontend-tests.render-wasm.process-objects-test
|
||||||
'frontend-tests.render-wasm.text-editor-apply-styles-test
|
'frontend-tests.render-wasm.text-editor-apply-styles-test
|
||||||
'frontend-tests.render-wasm.text-editor-caret-color-test
|
'frontend-tests.render-wasm.text-editor-caret-color-test
|
||||||
|
'frontend-tests.render-wasm.video-test
|
||||||
'frontend-tests.svg-fills-test
|
'frontend-tests.svg-fills-test
|
||||||
'frontend-tests.tokens.copy-paste-props-test
|
'frontend-tests.tokens.copy-paste-props-test
|
||||||
'frontend-tests.tokens.import-export-test
|
'frontend-tests.tokens.import-export-test
|
||||||
|
|||||||
@ -8899,6 +8899,30 @@ msgstr "Use the play button at the header to run the prototype view."
|
|||||||
msgid "workspace.options.video"
|
msgid "workspace.options.video"
|
||||||
msgstr "Video"
|
msgstr "Video"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:44
|
||||||
|
msgid "workspace.options.video.blocked.blend-mode"
|
||||||
|
msgstr "Video does not play on a shape with a blend mode."
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:45
|
||||||
|
msgid "workspace.options.video.blocked.blur"
|
||||||
|
msgstr "Video does not play on a shape with a blur."
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:47
|
||||||
|
msgid "workspace.options.video.blocked.masked"
|
||||||
|
msgstr "Video does not play inside a mask."
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:43
|
||||||
|
msgid "workspace.options.video.blocked.opacity"
|
||||||
|
msgstr "Video does not play on a shape below full opacity."
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:46
|
||||||
|
msgid "workspace.options.video.blocked.shadow"
|
||||||
|
msgstr "Video does not play on a shape with a shadow."
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:48
|
||||||
|
msgid "workspace.options.video.blocked.stroke"
|
||||||
|
msgstr "Video does not play on a shape with a stroke."
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:73
|
#: src/app/main/ui/workspace/sidebar/options/menus/video.cljs:73
|
||||||
msgid "workspace.options.video.pause"
|
msgid "workspace.options.video.pause"
|
||||||
msgstr "Pause video"
|
msgstr "Pause video"
|
||||||
|
|||||||
@ -18,6 +18,7 @@ pub mod text;
|
|||||||
pub mod text_editor;
|
pub mod text_editor;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod vector;
|
mod vector;
|
||||||
|
pub mod video;
|
||||||
|
|
||||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
@ -42,10 +43,82 @@ use crate::{get_gpu_state, get_resources, performance};
|
|||||||
|
|
||||||
pub use fonts::*;
|
pub use fonts::*;
|
||||||
pub use images::*;
|
pub use images::*;
|
||||||
|
pub use video::{shape_overlay_blockers, video_overlay_eligibility, VideoIneligible, VideoRegistry};
|
||||||
pub(crate) use resources::RenderResources;
|
pub(crate) use resources::RenderResources;
|
||||||
|
|
||||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||||
|
|
||||||
|
/// A shape's own contribution to a clip stack: its bounds, its corner radii and
|
||||||
|
/// the transform they are expressed in. Shared by the tree walk and by
|
||||||
|
/// `ancestor_clip_stack`, so a caller drawing outside the walk clips exactly the
|
||||||
|
/// same way.
|
||||||
|
fn clip_entry(
|
||||||
|
element: &Shape,
|
||||||
|
offset: Option<(f32, f32)>,
|
||||||
|
clip_inset: Option<f32>,
|
||||||
|
) -> (Rect, Option<Corners>, Matrix) {
|
||||||
|
let mut bounds = element.selrect();
|
||||||
|
if let Some(offset) = offset {
|
||||||
|
let x = bounds.x() - offset.0;
|
||||||
|
let y = bounds.y() - offset.1;
|
||||||
|
let width = bounds.width();
|
||||||
|
let height = bounds.height();
|
||||||
|
bounds.set_xywh(x, y, width, height);
|
||||||
|
}
|
||||||
|
let mut transform = element.transform;
|
||||||
|
transform.post_translate(bounds.center());
|
||||||
|
transform.pre_translate(-bounds.center());
|
||||||
|
|
||||||
|
let corners = match &element.shape_type {
|
||||||
|
Type::Rect(data) => data.corners,
|
||||||
|
Type::Frame(data) => data.corners,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(clip_inset) = clip_inset.filter(|&e| e > 0.0) {
|
||||||
|
bounds.inset((clip_inset, clip_inset));
|
||||||
|
}
|
||||||
|
|
||||||
|
(bounds, corners, transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pool is not guaranteed acyclic while a file is loading, so ancestor
|
||||||
|
/// walks outside the render tree are bounded rather than trusting it.
|
||||||
|
const MAX_CLIP_ANCESTOR_DEPTH: u32 = 1024;
|
||||||
|
|
||||||
|
/// The clip stack a shape inherits from its ancestors, outermost first — the
|
||||||
|
/// same stack `get_children_clip_bounds` accumulates while descending the tree.
|
||||||
|
/// Needed by callers that draw a shape outside that walk, such as the video
|
||||||
|
/// overlay, which would otherwise paint straight through a clipping board.
|
||||||
|
pub(crate) fn ancestor_clip_stack(shapes: ShapesPoolRef, shape: &Shape) -> Option<ClipStack> {
|
||||||
|
let mut clips: ClipStack = Vec::new();
|
||||||
|
let mut current = shape.parent_id;
|
||||||
|
let mut depth = 0;
|
||||||
|
|
||||||
|
while let Some(parent_id) = current.filter(|id| !id.is_nil()) {
|
||||||
|
depth += 1;
|
||||||
|
if depth > MAX_CLIP_ANCESTOR_DEPTH {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(parent) = shapes.get(&parent_id) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if parent.clip() {
|
||||||
|
clips.push(clip_entry(parent, None, None));
|
||||||
|
}
|
||||||
|
current = parent.parent_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if clips.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collected innermost first while walking up; the walk produces them
|
||||||
|
// outermost first.
|
||||||
|
clips.reverse();
|
||||||
|
Some(clips)
|
||||||
|
}
|
||||||
|
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum FrameType {
|
pub enum FrameType {
|
||||||
None = 0,
|
None = 0,
|
||||||
@ -141,29 +214,10 @@ impl NodeRenderState {
|
|||||||
return self.clip_bounds.clone();
|
return self.clip_bounds.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bounds = element.selrect();
|
Self::append_clip(
|
||||||
if let Some(offset) = offset {
|
self.clip_bounds.clone(),
|
||||||
let x = bounds.x() - offset.0;
|
clip_entry(element, offset, clip_inset),
|
||||||
let y = bounds.y() - offset.1;
|
)
|
||||||
let width = bounds.width();
|
|
||||||
let height = bounds.height();
|
|
||||||
bounds.set_xywh(x, y, width, height);
|
|
||||||
}
|
|
||||||
let mut transform = element.transform;
|
|
||||||
transform.post_translate(bounds.center());
|
|
||||||
transform.pre_translate(-bounds.center());
|
|
||||||
|
|
||||||
let corners = match &element.shape_type {
|
|
||||||
Type::Rect(data) => data.corners,
|
|
||||||
Type::Frame(data) => data.corners,
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(clip_inset) = clip_inset.filter(|&e| e > 0.0) {
|
|
||||||
bounds.inset((clip_inset, clip_inset));
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::append_clip(self.clip_bounds.clone(), (bounds, corners, transform))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates the clip bounds for shadow rendering of a given shape.
|
/// Calculates the clip bounds for shadow rendering of a given shape.
|
||||||
@ -391,6 +445,17 @@ pub(crate) struct RenderState {
|
|||||||
/// Frame id passed as `base_object` for viewer renders; always traversed.
|
/// Frame id passed as `base_object` for viewer renders; always traversed.
|
||||||
pub viewer_render_root: Option<Uuid>,
|
pub viewer_render_root: Option<Uuid>,
|
||||||
pub touched_ids: HashSet<Uuid>,
|
pub touched_ids: HashSet<Uuid>,
|
||||||
|
/// Images currently backed by a playing video. Frames for these are
|
||||||
|
/// stamped during composition instead of rastered into the tiles.
|
||||||
|
pub videos: VideoRegistry,
|
||||||
|
/// Shape id -> video image id, for the shapes whose video is stamped at
|
||||||
|
/// compose time this frame. Their image fill is left out of the tiles so
|
||||||
|
/// the frame shows through. Refreshed on attach/detach and at the start of
|
||||||
|
/// every render loop.
|
||||||
|
pub composited_videos: HashMap<Uuid, Uuid>,
|
||||||
|
/// `video-overlay-wasm/v1`. With it off the renderer keeps painting video
|
||||||
|
/// frames through the tiles, exactly as before.
|
||||||
|
pub video_overlay_enabled: bool,
|
||||||
/// Pre-edit extrects for old∪new tile eviction (captured on first touch).
|
/// Pre-edit extrects for old∪new tile eviction (captured on first touch).
|
||||||
touched_prev_extrects: HashMap<Uuid, Rect>,
|
touched_prev_extrects: HashMap<Uuid, Rect>,
|
||||||
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.)
|
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.)
|
||||||
@ -613,6 +678,9 @@ impl RenderState {
|
|||||||
include_filter: None,
|
include_filter: None,
|
||||||
viewer_render_root: None,
|
viewer_render_root: None,
|
||||||
touched_ids: HashSet::default(),
|
touched_ids: HashSet::default(),
|
||||||
|
videos: VideoRegistry::new(),
|
||||||
|
composited_videos: HashMap::default(),
|
||||||
|
video_overlay_enabled: false,
|
||||||
touched_prev_extrects: HashMap::default(),
|
touched_prev_extrects: HashMap::default(),
|
||||||
ignore_nested_blurs: false,
|
ignore_nested_blurs: false,
|
||||||
preview_mode: false,
|
preview_mode: false,
|
||||||
@ -988,6 +1056,13 @@ impl RenderState {
|
|||||||
if self.viewer_masked_pass() {
|
if self.viewer_masked_pass() {
|
||||||
self.surfaces.clear_target(skia::Color::TRANSPARENT);
|
self.surfaces.clear_target(skia::Color::TRANSPARENT);
|
||||||
self.surfaces.copy_backbuffer_to_target_replace();
|
self.surfaces.copy_backbuffer_to_target_replace();
|
||||||
|
} else if self.has_composited_video() {
|
||||||
|
// Background, then the video frames, then the backbuffer over them.
|
||||||
|
// The backbuffer is transparent where an eligible video sits, so
|
||||||
|
// the frame shows through and anything stacked above it still wins.
|
||||||
|
self.surfaces.clear_target(self.background_color);
|
||||||
|
self.render_video_overlay(tree);
|
||||||
|
self.surfaces.draw_backbuffer_over_target();
|
||||||
} else {
|
} else {
|
||||||
self.surfaces
|
self.surfaces
|
||||||
.copy_backbuffer_to_target(self.background_color);
|
.copy_backbuffer_to_target(self.background_color);
|
||||||
@ -1003,6 +1078,144 @@ impl RenderState {
|
|||||||
debug::render_wasm_label(self);
|
debug::render_wasm_label(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the tile composite clears the backbuffer to. Transparent while a
|
||||||
|
/// video is stamped at compose time, so the hole left in the tiles is not
|
||||||
|
/// filled in with the page background before the frame is drawn under it.
|
||||||
|
/// `compose_frame` paints the background itself in that case.
|
||||||
|
pub fn backbuffer_clear_color(&self) -> skia::Color {
|
||||||
|
if self.has_composited_video() {
|
||||||
|
skia::Color::TRANSPARENT
|
||||||
|
} else {
|
||||||
|
self.background_color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes which shapes have their video stamped at compose time.
|
||||||
|
/// Cheap: one pass over the registered video images, and each of them is
|
||||||
|
/// carried by a handful of shapes at most.
|
||||||
|
pub fn refresh_composited_videos(&mut self, tree: ShapesPoolRef) {
|
||||||
|
self.composited_videos.clear();
|
||||||
|
if !self.video_overlay_enabled || self.videos.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for shape in tree.iter() {
|
||||||
|
if shape.id.is_nil() || shape.deleted() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(image_id) = video::video_overlay_eligibility(tree, shape, &self.videos) {
|
||||||
|
self.composited_videos.insert(shape.id, image_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `image_id` is the video this shape has stamped at compose
|
||||||
|
/// time, so the tiles must leave a transparent hole instead of painting the
|
||||||
|
/// poster frame.
|
||||||
|
pub fn is_composited_video(&self, shape_id: &Uuid, image_id: &Uuid) -> bool {
|
||||||
|
self.composited_videos.get(shape_id) == Some(image_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether anything is being stamped at compose time. While it is, the tile
|
||||||
|
/// composite leaves the backbuffer transparent so the stamp shows through.
|
||||||
|
pub fn has_composited_video(&self) -> bool {
|
||||||
|
!self.composited_videos.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamps each playing video onto Target, before the backbuffer is drawn
|
||||||
|
/// over it. The backbuffer carries a transparent hole where the video sits,
|
||||||
|
/// so shapes above the video still occlude it and shapes below stay hidden.
|
||||||
|
fn render_video_overlay(&mut self, tree: ShapesPoolRef) {
|
||||||
|
if self.composited_videos.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zoom = self.viewbox.zoom * self.options.dpr;
|
||||||
|
let pan = self.viewbox.area;
|
||||||
|
let entries: Vec<(Uuid, Uuid)> = self
|
||||||
|
.composited_videos
|
||||||
|
.iter()
|
||||||
|
.map(|(shape_id, image_id)| (*shape_id, *image_id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (shape_id, image_id) in entries {
|
||||||
|
let Some(shape) = tree.get(&shape_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(image) = get_resources().images.get(&image_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(image_fill) = shape.fills().find_map(|fill| match fill {
|
||||||
|
Fill::Image(image_fill) if image_fill.id() == image_id => Some(image_fill.clone()),
|
||||||
|
_ => None,
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let clips = ancestor_clip_stack(tree, shape);
|
||||||
|
let container = &shape.selrect;
|
||||||
|
let dest_rect = images::get_image_dest_rect(container, &image_fill);
|
||||||
|
let src_rect = images::get_source_rect(image.dimensions(), &dest_rect, &image_fill);
|
||||||
|
let corners = match &shape.shape_type {
|
||||||
|
Type::Rect(data) => data.corners,
|
||||||
|
Type::Frame(data) => data.corners,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut transform = shape.transform;
|
||||||
|
transform.post_translate(container.center());
|
||||||
|
transform.pre_translate(-container.center());
|
||||||
|
|
||||||
|
let sampling = get_resources().sampling_options;
|
||||||
|
let canvas = self.surfaces.canvas(SurfaceId::Target);
|
||||||
|
canvas.save();
|
||||||
|
canvas.scale((zoom, zoom));
|
||||||
|
canvas.translate((-pan.left, -pan.top));
|
||||||
|
|
||||||
|
if let Some(clips) = clips.as_ref() {
|
||||||
|
for (bounds, clip_corners, clip_transform) in clips.iter() {
|
||||||
|
canvas.concat(clip_transform);
|
||||||
|
match clip_corners {
|
||||||
|
Some(clip_corners) => {
|
||||||
|
canvas.clip_rrect(
|
||||||
|
RRect::new_rect_radii(*bounds, clip_corners),
|
||||||
|
skia::ClipOp::Intersect,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
canvas.clip_rect(*bounds, skia::ClipOp::Intersect, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.concat(&clip_transform.invert().unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.concat(&transform);
|
||||||
|
match corners {
|
||||||
|
Some(corners) => {
|
||||||
|
canvas.clip_rrect(
|
||||||
|
RRect::new_rect_radii(*container, &corners),
|
||||||
|
skia::ClipOp::Intersect,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
canvas.clip_rect(*container, skia::ClipOp::Intersect, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.draw_image_rect_with_sampling_options(
|
||||||
|
image,
|
||||||
|
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
|
||||||
|
dest_rect,
|
||||||
|
sampling,
|
||||||
|
&skia::Paint::default(),
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drawn on Target before the UI surface is composited, so rulers and guides
|
/// Drawn on Target before the UI surface is composited, so rulers and guides
|
||||||
/// stay above the selection band
|
/// stay above the selection band
|
||||||
fn render_text_editor_overlay(&mut self, tree: ShapesPoolRef) {
|
fn render_text_editor_overlay(&mut self, tree: ShapesPoolRef) {
|
||||||
@ -2323,11 +2536,9 @@ impl RenderState {
|
|||||||
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
|
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
|
||||||
let _start = performance::begin_timed_log!("render_from_cache");
|
let _start = performance::begin_timed_log!("render_from_cache");
|
||||||
performance::begin_measure!("render_from_cache");
|
performance::begin_measure!("render_from_cache");
|
||||||
self.surfaces.draw_combined_atlas_to_backbuffer(
|
let clear_color = self.backbuffer_clear_color();
|
||||||
&self.viewbox,
|
self.surfaces
|
||||||
&self.tile_viewbox,
|
.draw_combined_atlas_to_backbuffer(&self.viewbox, &self.tile_viewbox, clear_color);
|
||||||
self.background_color,
|
|
||||||
);
|
|
||||||
self.present_frame(shapes);
|
self.present_frame(shapes);
|
||||||
|
|
||||||
performance::end_measure!("render_from_cache");
|
performance::end_measure!("render_from_cache");
|
||||||
@ -2394,6 +2605,9 @@ impl RenderState {
|
|||||||
sync_render: bool,
|
sync_render: bool,
|
||||||
) -> Result<FrameType> {
|
) -> Result<FrameType> {
|
||||||
self.clear(tree);
|
self.clear(tree);
|
||||||
|
// Shape edits can make a playing video eligible or not, so the set is
|
||||||
|
// settled before any tile is rastered with (or without) its hole.
|
||||||
|
self.refresh_composited_videos(tree);
|
||||||
|
|
||||||
let _start = performance::begin_timed_log!("start_render_loop");
|
let _start = performance::begin_timed_log!("start_render_loop");
|
||||||
let scale = self.get_scale();
|
let scale = self.get_scale();
|
||||||
@ -2566,17 +2780,18 @@ impl RenderState {
|
|||||||
if should_compose {
|
if should_compose {
|
||||||
// Fast mode skips the tile atlas; use the same doc-atlas + scale
|
// Fast mode skips the tile atlas; use the same doc-atlas + scale
|
||||||
// overlays as render_from_cache instead of composing empty slots.
|
// overlays as render_from_cache instead of composing empty slots.
|
||||||
|
let clear_color = self.backbuffer_clear_color();
|
||||||
if self.options.is_fast_mode() {
|
if self.options.is_fast_mode() {
|
||||||
self.surfaces.draw_combined_atlas_to_backbuffer(
|
self.surfaces.draw_combined_atlas_to_backbuffer(
|
||||||
&self.viewbox,
|
&self.viewbox,
|
||||||
&self.tile_viewbox,
|
&self.tile_viewbox,
|
||||||
self.background_color,
|
clear_color,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
self.surfaces.draw_tile_atlas_to_backbuffer(
|
||||||
&self.viewbox,
|
&self.viewbox,
|
||||||
&self.tile_viewbox,
|
&self.tile_viewbox,
|
||||||
self.background_color,
|
clear_color,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2634,11 +2849,9 @@ impl RenderState {
|
|||||||
// Same composition as `continue_render_loop` for full frames: snapshot only the
|
// Same composition as `continue_render_loop` for full frames: snapshot only the
|
||||||
// drawable tile rect into the atlas (no blur-margin overlap), then blit once.
|
// drawable tile rect into the atlas (no blur-margin overlap), then blit once.
|
||||||
if !self.viewer_masked_pass() {
|
if !self.viewer_masked_pass() {
|
||||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
let clear_color = self.backbuffer_clear_color();
|
||||||
&self.viewbox,
|
self.surfaces
|
||||||
&self.tile_viewbox,
|
.draw_tile_atlas_to_backbuffer(&self.viewbox, &self.tile_viewbox, clear_color);
|
||||||
self.background_color,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let saved_preview_mode = self.preview_mode;
|
let saved_preview_mode = self.preview_mode;
|
||||||
@ -4587,3 +4800,79 @@ impl RenderState {
|
|||||||
get_gpu_state().context.free_gpu_resources();
|
get_gpu_state().context.free_gpu_resources();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod clip_stack_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::shapes::Type;
|
||||||
|
use crate::state::ShapesPool;
|
||||||
|
|
||||||
|
fn add_board(pool: &mut ShapesPool, clip: bool, x: f32, y: f32) -> Uuid {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let board = pool.add_shape(id);
|
||||||
|
board.set_shape_type(Type::Frame(Default::default()));
|
||||||
|
board.set_selrect(x, y, x + 100.0, y + 100.0);
|
||||||
|
board.set_clip(clip);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_child(pool: &mut ShapesPool, parent_id: Uuid) -> Uuid {
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let child = pool.add_shape(id);
|
||||||
|
child.set_shape_type(Type::Rect(Default::default()));
|
||||||
|
child.set_selrect(0.0, 0.0, 10.0, 10.0);
|
||||||
|
child.parent_id = Some(parent_id);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shape_without_clipping_ancestors_has_no_clip_stack() {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let board = add_board(&mut pool, false, 0.0, 0.0);
|
||||||
|
let child = add_child(&mut pool, board);
|
||||||
|
|
||||||
|
let shape = pool.get(&child).unwrap();
|
||||||
|
assert!(ancestor_clip_stack(&pool, shape).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clipping_board_contributes_its_bounds() {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let board = add_board(&mut pool, true, 5.0, 7.0);
|
||||||
|
let child = add_child(&mut pool, board);
|
||||||
|
|
||||||
|
let shape = pool.get(&child).unwrap();
|
||||||
|
let clips = ancestor_clip_stack(&pool, shape).expect("a clip stack");
|
||||||
|
assert_eq!(clips.len(), 1);
|
||||||
|
assert_eq!(clips[0].0, pool.get(&board).unwrap().selrect());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_boards_are_ordered_outermost_first() {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let outer = add_board(&mut pool, true, 0.0, 0.0);
|
||||||
|
let inner = add_board(&mut pool, true, 20.0, 20.0);
|
||||||
|
pool.get_mut(&inner).unwrap().parent_id = Some(outer);
|
||||||
|
let child = add_child(&mut pool, inner);
|
||||||
|
|
||||||
|
let shape = pool.get(&child).unwrap();
|
||||||
|
let clips = ancestor_clip_stack(&pool, shape).expect("a clip stack");
|
||||||
|
assert_eq!(clips.len(), 2);
|
||||||
|
assert_eq!(clips[0].0, pool.get(&outer).unwrap().selrect());
|
||||||
|
assert_eq!(clips[1].0, pool.get(&inner).unwrap().selrect());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_board_showing_overflow_contributes_nothing() {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let outer = add_board(&mut pool, true, 0.0, 0.0);
|
||||||
|
let inner = add_board(&mut pool, false, 20.0, 20.0);
|
||||||
|
pool.get_mut(&inner).unwrap().parent_id = Some(outer);
|
||||||
|
let child = add_child(&mut pool, inner);
|
||||||
|
|
||||||
|
let shape = pool.get(&child).unwrap();
|
||||||
|
let clips = ancestor_clip_stack(&pool, shape).expect("a clip stack");
|
||||||
|
assert_eq!(clips.len(), 1);
|
||||||
|
assert_eq!(clips[0].0, pool.get(&outer).unwrap().selrect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -73,6 +73,12 @@ fn draw_image_fill(
|
|||||||
antialias: bool,
|
antialias: bool,
|
||||||
surface_id: SurfaceId,
|
surface_id: SurfaceId,
|
||||||
) {
|
) {
|
||||||
|
// A video stamped at compose time leaves a transparent hole here, so the
|
||||||
|
// frame drawn underneath the backbuffer shows through.
|
||||||
|
if render_state.is_composited_video(&shape.id, &image_fill.id()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if draw_svg_image_fill(
|
if draw_svg_image_fill(
|
||||||
render_state,
|
render_state,
|
||||||
shape,
|
shape,
|
||||||
|
|||||||
@ -988,6 +988,19 @@ impl Surfaces {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Draw `Backbuffer` over whatever `Target` already holds, without clearing
|
||||||
|
/// it first. Used when something was painted underneath — a video frame
|
||||||
|
/// showing through the backbuffer's transparent hole.
|
||||||
|
pub fn draw_backbuffer_over_target(&mut self) {
|
||||||
|
let sampling_options = self.sampling_options;
|
||||||
|
self.backbuffer.draw(
|
||||||
|
self.target.canvas(),
|
||||||
|
(0.0, 0.0),
|
||||||
|
sampling_options,
|
||||||
|
Some(&skia::Paint::default()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace `Target` pixels with `Backbuffer` (Src blend).
|
/// Replace `Target` pixels with `Backbuffer` (Src blend).
|
||||||
///
|
///
|
||||||
/// Used for viewer masked passes: transparent backbuffer regions must not
|
/// Used for viewer masked passes: transparent backbuffer regions must not
|
||||||
|
|||||||
317
render-wasm/src/render/video.rs
Normal file
317
render-wasm/src/render/video.rs
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
//! Video painted as a composited overlay rather than as part of the tiles.
|
||||||
|
//!
|
||||||
|
//! A video frame changes the picture 30 to 60 times a second. Painting it
|
||||||
|
//! through the normal path means invalidating the shape's tiles that often,
|
||||||
|
//! and a tile re-raster redraws every shape intersecting it — so the cost of
|
||||||
|
//! playing a video grows with how crowded the page is around it.
|
||||||
|
//!
|
||||||
|
//! Instead the renderer keeps a transparent hole where an eligible video sits
|
||||||
|
//! and stamps the frame during composition. That only produces the right
|
||||||
|
//! picture when the shape composites trivially, so anything carrying opacity,
|
||||||
|
//! a blend mode, a blur, a shadow or a stroke is refused outright and keeps
|
||||||
|
//! showing its poster frame.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::shapes::{BlendMode, Blur, Fill, Shape, Type};
|
||||||
|
use crate::state::ShapesPoolRef;
|
||||||
|
use crate::uuid::Uuid;
|
||||||
|
|
||||||
|
/// The pool is not guaranteed acyclic while a file is loading, so the ancestor
|
||||||
|
/// walk is bounded rather than trusting it.
|
||||||
|
const MAX_ANCESTOR_DEPTH: u32 = 1024;
|
||||||
|
|
||||||
|
/// Why a shape cannot have its video stamped during composition. Reported back
|
||||||
|
/// to the bridge so the sidebar can name the property that is in the way.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VideoIneligible {
|
||||||
|
NoVideoFill = 1,
|
||||||
|
Opacity = 2,
|
||||||
|
BlendMode = 3,
|
||||||
|
Blur = 4,
|
||||||
|
Shadow = 5,
|
||||||
|
Stroke = 6,
|
||||||
|
Masked = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image ids currently backed by a playing video.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct VideoRegistry {
|
||||||
|
images: HashSet<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&mut self, image_id: Uuid) {
|
||||||
|
self.images.insert(image_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unregister(&mut self, image_id: Uuid) {
|
||||||
|
self.images.remove(&image_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.images.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, image_id: &Uuid) -> bool {
|
||||||
|
self.images.contains(image_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &Uuid> {
|
||||||
|
self.images.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The video image a shape paints, if it paints one.
|
||||||
|
pub fn video_fill_id(shape: &Shape, videos: &VideoRegistry) -> Option<Uuid> {
|
||||||
|
shape.fills().find_map(|fill| match fill {
|
||||||
|
Fill::Image(image) if videos.contains(&image.id()) => Some(image.id()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_masked_ancestor(shapes: ShapesPoolRef, shape: &Shape) -> bool {
|
||||||
|
let mut current = shape.parent_id;
|
||||||
|
let mut depth = 0;
|
||||||
|
while let Some(parent_id) = current.filter(|id| !id.is_nil()) {
|
||||||
|
depth += 1;
|
||||||
|
if depth > MAX_ANCESTOR_DEPTH {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(parent) = shapes.get_raw(&parent_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if matches!(&parent.shape_type, Type::Group(group) if group.masked) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = parent.parent_id;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `shape` can have its video stamped during composition instead of
|
||||||
|
/// rastered into the tiles.
|
||||||
|
///
|
||||||
|
/// The overlay is a flat stamp: it cannot blend, blur, cast a shadow, or sit
|
||||||
|
/// under an inner stroke. Rotation and corner radii are fine — the stamp
|
||||||
|
/// carries the shape's transform and clips to its rounded rect.
|
||||||
|
pub fn video_overlay_eligibility(
|
||||||
|
shapes: ShapesPoolRef,
|
||||||
|
shape: &Shape,
|
||||||
|
videos: &VideoRegistry,
|
||||||
|
) -> Result<Uuid, VideoIneligible> {
|
||||||
|
let image_id = video_fill_id(shape, videos).ok_or(VideoIneligible::NoVideoFill)?;
|
||||||
|
shape_overlay_blockers(shapes, shape)?;
|
||||||
|
Ok(image_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The properties that stop a shape being stamped, independent of whether a
|
||||||
|
/// video is attached to it yet. The bridge asks this before starting a video,
|
||||||
|
/// so the sidebar can refuse with a reason rather than starting and stopping.
|
||||||
|
pub fn shape_overlay_blockers(
|
||||||
|
shapes: ShapesPoolRef,
|
||||||
|
shape: &Shape,
|
||||||
|
) -> Result<(), VideoIneligible> {
|
||||||
|
if shape.opacity < 1.0 {
|
||||||
|
return Err(VideoIneligible::Opacity);
|
||||||
|
}
|
||||||
|
if shape.blend_mode != BlendMode::default() {
|
||||||
|
return Err(VideoIneligible::BlendMode);
|
||||||
|
}
|
||||||
|
// Hidden or zero effects paint nothing, so they are no reason to refuse.
|
||||||
|
let visible_blur = |blur: &Option<Blur>| {
|
||||||
|
blur.map(|blur| !blur.hidden && blur.value > 0.0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
if visible_blur(&shape.blur) || visible_blur(&shape.background_blur) {
|
||||||
|
return Err(VideoIneligible::Blur);
|
||||||
|
}
|
||||||
|
if shape.shadows.iter().any(|shadow| !shadow.hidden()) {
|
||||||
|
return Err(VideoIneligible::Shadow);
|
||||||
|
}
|
||||||
|
if shape.has_visible_strokes() {
|
||||||
|
return Err(VideoIneligible::Stroke);
|
||||||
|
}
|
||||||
|
if has_masked_ancestor(shapes, shape) {
|
||||||
|
return Err(VideoIneligible::Masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::shapes::{BlurType, Group, ImageFill, Shadow, ShadowStyle, Stroke, StrokeStyle};
|
||||||
|
use crate::state::ShapesPool;
|
||||||
|
|
||||||
|
fn registry(image_id: Uuid) -> VideoRegistry {
|
||||||
|
let mut videos = VideoRegistry::new();
|
||||||
|
videos.register(image_id);
|
||||||
|
videos
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_with_video(image_id: Uuid) -> (ShapesPool, Uuid) {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let shape_id = Uuid::new_v4();
|
||||||
|
let shape = pool.add_shape(shape_id);
|
||||||
|
shape.set_shape_type(Type::Rect(Default::default()));
|
||||||
|
shape.add_fill(Fill::Image(ImageFill::new(image_id, 255, 10, 10, false)));
|
||||||
|
(pool, shape_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eligibility(
|
||||||
|
pool: &ShapesPool,
|
||||||
|
shape_id: Uuid,
|
||||||
|
videos: &VideoRegistry,
|
||||||
|
) -> Result<Uuid, VideoIneligible> {
|
||||||
|
let shape = pool.get(&shape_id).unwrap();
|
||||||
|
video_overlay_eligibility(pool, shape, videos)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_video_shape_is_eligible() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (pool, shape_id) = pool_with_video(image_id);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Ok(image_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotation_and_corner_radii_stay_eligible() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
{
|
||||||
|
let shape = pool.get_mut(&shape_id).unwrap();
|
||||||
|
shape.rotation = 33.0;
|
||||||
|
shape.set_corners((8.0, 8.0, 8.0, 8.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Ok(image_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shape_without_a_registered_video_is_not_eligible() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (pool, shape_id) = pool_with_video(image_id);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, &VideoRegistry::new()),
|
||||||
|
Err(VideoIneligible::NoVideoFill)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opacity_below_one_is_refused() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
pool.get_mut(&shape_id).unwrap().opacity = 0.4;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Err(VideoIneligible::Opacity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blend_mode_is_refused() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
pool.get_mut(&shape_id).unwrap().blend_mode = BlendMode(skia_safe::BlendMode::Multiply);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Err(VideoIneligible::BlendMode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blur_is_refused() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
pool.get_mut(&shape_id).unwrap().blur =
|
||||||
|
Some(Blur::new(BlurType::LayerBlur, false, 4.0));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Err(VideoIneligible::Blur)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shadow_is_refused() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
pool.get_mut(&shape_id).unwrap().shadows.push(Shadow::new(
|
||||||
|
skia_safe::Color::BLACK,
|
||||||
|
4.0,
|
||||||
|
0.0,
|
||||||
|
(2.0, 2.0),
|
||||||
|
ShadowStyle::Drop,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Err(VideoIneligible::Shadow)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stroke_is_refused() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
pool.get_mut(&shape_id).unwrap().add_stroke(
|
||||||
|
Stroke::new_center_stroke(2.0, StrokeStyle::Solid, None, None, None, None),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Err(VideoIneligible::Stroke)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_masked_ancestor_is_refused() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
let group_id = Uuid::new_v4();
|
||||||
|
{
|
||||||
|
let group = pool.add_shape(group_id);
|
||||||
|
group.set_shape_type(Type::Group(Group { masked: true }));
|
||||||
|
}
|
||||||
|
pool.get_mut(&shape_id).unwrap().parent_id = Some(group_id);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Err(VideoIneligible::Masked)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unmasked_ancestor_stays_eligible() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_video(image_id);
|
||||||
|
let group_id = Uuid::new_v4();
|
||||||
|
{
|
||||||
|
let group = pool.add_shape(group_id);
|
||||||
|
group.set_shape_type(Type::Group(Group { masked: false }));
|
||||||
|
}
|
||||||
|
pool.get_mut(&shape_id).unwrap().parent_id = Some(group_id);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
eligibility(&pool, shape_id, ®istry(image_id)),
|
||||||
|
Ok(image_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -678,6 +678,16 @@ impl Shape {
|
|||||||
self.fills.iter()
|
self.fills.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every image this shape paints, from its fills and from its strokes.
|
||||||
|
pub fn image_ids(&self) -> impl Iterator<Item = Uuid> + '_ {
|
||||||
|
let fills = self.fills.iter();
|
||||||
|
let strokes = self.strokes.iter().map(|stroke| &stroke.fill);
|
||||||
|
fills.chain(strokes).filter_map(|fill| match fill {
|
||||||
|
Fill::Image(image) => Some(image.id()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_fills(&mut self, fills: Vec<Fill>) {
|
pub fn set_fills(&mut self, fills: Vec<Fill>) {
|
||||||
self.deferred_batch_fills = None;
|
self.deferred_batch_fills = None;
|
||||||
self.fills = fills;
|
self.fills = fills;
|
||||||
@ -2034,6 +2044,35 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn image_fill(id: Uuid) -> Fill {
|
||||||
|
Fill::Image(ImageFill::new(id, 255, 10, 10, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_ids_reports_fill_and_stroke_images() {
|
||||||
|
let fill_image = Uuid::new_v4();
|
||||||
|
let stroke_image = Uuid::new_v4();
|
||||||
|
|
||||||
|
let mut shape = any_shape();
|
||||||
|
shape.add_fill(Fill::Solid(SolidColor(Color::TRANSPARENT)));
|
||||||
|
shape.add_fill(image_fill(fill_image));
|
||||||
|
|
||||||
|
let mut stroke = Stroke::new_center_stroke(2.0, StrokeStyle::Solid, None, None, None, None);
|
||||||
|
stroke.fill = image_fill(stroke_image);
|
||||||
|
shape.add_stroke(stroke);
|
||||||
|
|
||||||
|
let ids: Vec<Uuid> = shape.image_ids().collect();
|
||||||
|
assert_eq!(ids, vec![fill_image, stroke_image]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_ids_is_empty_without_image_fills() {
|
||||||
|
let mut shape = any_shape();
|
||||||
|
shape.add_fill(Fill::Solid(SolidColor(Color::TRANSPARENT)));
|
||||||
|
|
||||||
|
assert_eq!(shape.image_ids().count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn layer_blur_and_background_blur_can_coexist() {
|
fn layer_blur_and_background_blur_can_coexist() {
|
||||||
let mut shape = any_shape();
|
let mut shape = any_shape();
|
||||||
|
|||||||
@ -59,6 +59,14 @@ pub struct ShapesPoolImpl {
|
|||||||
structure: HashMap<usize, Vec<StructureEntry>>,
|
structure: HashMap<usize, Vec<StructureEntry>>,
|
||||||
/// Scale content values, keyed by index
|
/// Scale content values, keyed by index
|
||||||
scale_content: HashMap<usize, f32>,
|
scale_content: HashMap<usize, f32>,
|
||||||
|
|
||||||
|
/// Bumped whenever a shape is added or handed out mutably, so derived
|
||||||
|
/// indexes can tell a cached answer from a stale one.
|
||||||
|
revision: u64,
|
||||||
|
/// Image id -> shapes painting it, with the revision it was built at.
|
||||||
|
/// Rebuilt on demand; while nothing mutates shapes (video playback, for
|
||||||
|
/// instance) the lookup stays O(1) instead of walking the whole pool.
|
||||||
|
image_index: Option<(u64, HashMap<Uuid, Vec<Uuid>>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type aliases - no longer need lifetimes!
|
// Type aliases - no longer need lifetimes!
|
||||||
@ -78,11 +86,19 @@ impl ShapesPoolImpl {
|
|||||||
modifier_uuids: Vec::new(),
|
modifier_uuids: Vec::new(),
|
||||||
structure: HashMap::default(),
|
structure: HashMap::default(),
|
||||||
scale_content: HashMap::default(),
|
scale_content: HashMap::default(),
|
||||||
|
revision: 0,
|
||||||
|
image_index: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn bump_revision(&mut self) {
|
||||||
|
self.revision = self.revision.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn initialize(&mut self, capacity: usize) {
|
pub fn initialize(&mut self, capacity: usize) {
|
||||||
performance::begin_measure!("shapes_pool_initialize");
|
performance::begin_measure!("shapes_pool_initialize");
|
||||||
|
self.bump_revision();
|
||||||
self.counter = 0;
|
self.counter = 0;
|
||||||
self.uuid_to_idx = HashMap::with_capacity(capacity);
|
self.uuid_to_idx = HashMap::with_capacity(capacity);
|
||||||
|
|
||||||
@ -102,6 +118,7 @@ impl ShapesPoolImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_shape(&mut self, id: Uuid) -> &mut Shape {
|
pub fn add_shape(&mut self, id: Uuid) -> &mut Shape {
|
||||||
|
self.bump_revision();
|
||||||
if self.counter >= self.shapes.len() {
|
if self.counter >= self.shapes.len() {
|
||||||
// We need more space
|
// We need more space
|
||||||
let current_capacity = self.shapes.capacity();
|
let current_capacity = self.shapes.capacity();
|
||||||
@ -144,9 +161,42 @@ impl ShapesPoolImpl {
|
|||||||
|
|
||||||
pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> {
|
pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> {
|
||||||
let idx = *self.uuid_to_idx.get(id)?;
|
let idx = *self.uuid_to_idx.get(id)?;
|
||||||
|
self.bump_revision();
|
||||||
Some(&mut self.shapes[idx])
|
Some(&mut self.shapes[idx])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shapes whose fills or strokes paint `image_id`. The index behind it is
|
||||||
|
/// rebuilt only after a shape changed, so a caller that runs every frame
|
||||||
|
/// (video frame uploads) pays for the walk once, not once per frame.
|
||||||
|
pub fn shapes_with_image(&mut self, image_id: Uuid) -> &[Uuid] {
|
||||||
|
let stale = !matches!(&self.image_index, Some((rev, _)) if *rev == self.revision);
|
||||||
|
if stale {
|
||||||
|
self.image_index = Some((self.revision, self.build_image_index()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.image_index
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(_, index)| index.get(&image_id))
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_image_index(&self) -> HashMap<Uuid, Vec<Uuid>> {
|
||||||
|
let mut index: HashMap<Uuid, Vec<Uuid>> = HashMap::default();
|
||||||
|
for shape in self.shapes.iter() {
|
||||||
|
if shape.id.is_nil() || shape.deleted() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for image_id in shape.image_ids() {
|
||||||
|
let shapes = index.entry(image_id).or_default();
|
||||||
|
if !shapes.contains(&shape.id) {
|
||||||
|
shapes.push(shape.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the current transform modifier matrix for the shape, if any.
|
/// Returns the current transform modifier matrix for the shape, if any.
|
||||||
pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> {
|
pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> {
|
||||||
let idx = *self.uuid_to_idx.get(id)?;
|
let idx = *self.uuid_to_idx.get(id)?;
|
||||||
@ -241,6 +291,7 @@ impl ShapesPoolImpl {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Shape> {
|
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Shape> {
|
||||||
|
self.bump_revision();
|
||||||
self.shapes.iter_mut()
|
self.shapes.iter_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -552,6 +603,98 @@ impl Clone for ShapesPoolImpl {
|
|||||||
modifier_uuids: self.modifier_uuids.clone(),
|
modifier_uuids: self.modifier_uuids.clone(),
|
||||||
structure: self.structure.clone(),
|
structure: self.structure.clone(),
|
||||||
scale_content: self.scale_content.clone(),
|
scale_content: self.scale_content.clone(),
|
||||||
|
revision: self.revision,
|
||||||
|
// Derived like modified_shape_cache: dropped on clone and rebuilt
|
||||||
|
// on demand.
|
||||||
|
image_index: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::shapes::{Fill, ImageFill};
|
||||||
|
|
||||||
|
fn image_fill(id: Uuid) -> Fill {
|
||||||
|
Fill::Image(ImageFill::new(id, 255, 10, 10, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_with_shape(image_id: Uuid) -> (ShapesPool, Uuid) {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let shape_id = Uuid::new_v4();
|
||||||
|
pool.add_shape(shape_id).add_fill(image_fill(image_id));
|
||||||
|
(pool, shape_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_the_shapes_painting_an_image() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_shape(image_id);
|
||||||
|
|
||||||
|
assert_eq!(pool.shapes_with_image(image_id), &[shape_id]);
|
||||||
|
assert!(pool.shapes_with_image(Uuid::new_v4()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_every_shape_painting_the_same_image() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, first) = pool_with_shape(image_id);
|
||||||
|
let second = Uuid::new_v4();
|
||||||
|
pool.add_shape(second).add_fill(image_fill(image_id));
|
||||||
|
|
||||||
|
let found = pool.shapes_with_image(image_id).to_vec();
|
||||||
|
assert_eq!(found.len(), 2);
|
||||||
|
assert!(found.contains(&first));
|
||||||
|
assert!(found.contains(&second));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_fill_is_picked_up() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let replacement = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_shape(image_id);
|
||||||
|
assert_eq!(pool.shapes_with_image(image_id), &[shape_id]);
|
||||||
|
|
||||||
|
pool.get_mut(&shape_id)
|
||||||
|
.unwrap()
|
||||||
|
.set_fills(vec![image_fill(replacement)]);
|
||||||
|
|
||||||
|
assert!(pool.shapes_with_image(image_id).is_empty());
|
||||||
|
assert_eq!(pool.shapes_with_image(replacement), &[shape_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cleared_fill_is_picked_up() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_shape(image_id);
|
||||||
|
assert_eq!(pool.shapes_with_image(image_id), &[shape_id]);
|
||||||
|
|
||||||
|
pool.get_mut(&shape_id).unwrap().clear_fills();
|
||||||
|
|
||||||
|
assert!(pool.shapes_with_image(image_id).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deleted_shape_drops_out() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let (mut pool, shape_id) = pool_with_shape(image_id);
|
||||||
|
assert_eq!(pool.shapes_with_image(image_id), &[shape_id]);
|
||||||
|
|
||||||
|
pool.get_mut(&shape_id).unwrap().set_deleted(true);
|
||||||
|
|
||||||
|
assert!(pool.shapes_with_image(image_id).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_shape_added_after_the_first_lookup_is_found() {
|
||||||
|
let image_id = Uuid::new_v4();
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
assert!(pool.shapes_with_image(image_id).is_empty());
|
||||||
|
|
||||||
|
let shape_id = Uuid::new_v4();
|
||||||
|
pool.add_shape(shape_id).add_fill(image_fill(image_id));
|
||||||
|
|
||||||
|
assert_eq!(pool.shapes_with_image(image_id), &[shape_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,3 +13,4 @@ pub mod text;
|
|||||||
pub mod text_editor;
|
pub mod text_editor;
|
||||||
pub mod transforms;
|
pub mod transforms;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod video;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::get_gpu_state;
|
use crate::get_gpu_state;
|
||||||
|
use crate::get_render_state;
|
||||||
use crate::get_resources;
|
use crate::get_resources;
|
||||||
use crate::mem;
|
use crate::mem;
|
||||||
use crate::shapes::Fill;
|
|
||||||
use crate::state::State;
|
use crate::state::State;
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
use crate::with_state;
|
use crate::with_state;
|
||||||
@ -10,26 +10,28 @@ use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet};
|
|||||||
use macros::wasm_error;
|
use macros::wasm_error;
|
||||||
|
|
||||||
fn touch_shapes_with_image(state: &mut State, image_id: Uuid) {
|
fn touch_shapes_with_image(state: &mut State, image_id: Uuid) {
|
||||||
let ids: Vec<Uuid> = state
|
let ids: Vec<Uuid> = state.shapes.shapes_with_image(image_id).to_vec();
|
||||||
.shapes
|
|
||||||
.iter()
|
|
||||||
.filter(|shape| {
|
|
||||||
shape
|
|
||||||
.fills()
|
|
||||||
.any(|f| matches!(f, Fill::Image(i) if i.id() == image_id))
|
|
||||||
|| shape
|
|
||||||
.strokes
|
|
||||||
.iter()
|
|
||||||
.any(|s| matches!(&s.fill, Fill::Image(i) if i.id() == image_id))
|
|
||||||
})
|
|
||||||
.map(|shape| shape.id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for id in ids {
|
for id in ids {
|
||||||
state.touch_shape(id);
|
state.touch_shape(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `touch_shapes_with_image`, but leaves out the shapes whose video is
|
||||||
|
/// stamped at compose time: their tiles hold a transparent hole, so a new frame
|
||||||
|
/// costs a recomposition and no tile work at all. A shape painting the same
|
||||||
|
/// video that cannot be composited — one carrying a shadow, say — still needs
|
||||||
|
/// its tiles back.
|
||||||
|
fn touch_shapes_not_compositing_image(state: &mut State, image_id: Uuid) {
|
||||||
|
let ids: Vec<Uuid> = state.shapes.shapes_with_image(image_id).to_vec();
|
||||||
|
|
||||||
|
for id in ids {
|
||||||
|
if !get_render_state().is_composited_video(&id, &image_id) {
|
||||||
|
state.touch_shape(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0;
|
const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0;
|
||||||
const FLAG_HAS_TRANSFORM: u8 = 1 << 1;
|
const FLAG_HAS_TRANSFORM: u8 = 1 << 1;
|
||||||
const IMAGE_IDS_SIZE: usize = 32;
|
const IMAGE_IDS_SIZE: usize = 32;
|
||||||
@ -290,7 +292,8 @@ pub extern "C" fn update_image_from_texture() -> Result<()> {
|
|||||||
) {
|
) {
|
||||||
eprintln!("update_image_from_texture error: {}", msg);
|
eprintln!("update_image_from_texture error: {}", msg);
|
||||||
}
|
}
|
||||||
touch_shapes_with_image(state, upload.ids.image_id);
|
|
||||||
|
touch_shapes_not_compositing_image(state, upload.ids.image_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
mem::free_bytes()?;
|
mem::free_bytes()?;
|
||||||
|
|||||||
67
render-wasm/src/wasm/video.rs
Normal file
67
render-wasm/src/wasm/video.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
use crate::get_render_state;
|
||||||
|
use crate::render::video::{shape_overlay_blockers, VideoIneligible};
|
||||||
|
use crate::utils::uuid_from_u32_quartet;
|
||||||
|
use crate::uuid::Uuid;
|
||||||
|
use crate::with_state;
|
||||||
|
|
||||||
|
/// Redraws the shapes painting `image_id` once, so the tiles pick up the switch
|
||||||
|
/// between the poster frame and the composited overlay.
|
||||||
|
fn resync_image(image_id: Uuid) {
|
||||||
|
with_state!(state, {
|
||||||
|
get_render_state().refresh_composited_videos(&state.shapes);
|
||||||
|
let ids = state.shapes.shapes_with_image(image_id).to_vec();
|
||||||
|
for id in ids {
|
||||||
|
state.touch_shape(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks an image as backed by a playing video, so its frames are stamped
|
||||||
|
/// during composition instead of rastered into the tiles. Called by
|
||||||
|
/// `app.render-wasm.api.video` when a video starts.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn register_video_image(a: u32, b: u32, c: u32, d: u32) {
|
||||||
|
let image_id = uuid_from_u32_quartet(a, b, c, d);
|
||||||
|
get_render_state().videos.register(image_id);
|
||||||
|
resync_image(image_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops treating an image as video. The shapes painting it go back to showing
|
||||||
|
/// the still image.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn unregister_video_image(a: u32, b: u32, c: u32, d: u32) {
|
||||||
|
let image_id = uuid_from_u32_quartet(a, b, c, d);
|
||||||
|
get_render_state().videos.unregister(image_id);
|
||||||
|
resync_image(image_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a shape could have a video stamped during composition, as a code the
|
||||||
|
/// bridge turns into the reason shown in the sidebar. `0` means it could; any
|
||||||
|
/// other value is a `VideoIneligible` discriminant. Answered from the shape's
|
||||||
|
/// own properties, so it can be asked before a video is attached.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn get_video_eligibility(a: u32, b: u32, c: u32, d: u32) -> u32 {
|
||||||
|
with_state!(state, {
|
||||||
|
let shape_id = uuid_from_u32_quartet(a, b, c, d);
|
||||||
|
|
||||||
|
let Some(shape) = state.shapes.get_raw(&shape_id) else {
|
||||||
|
return VideoIneligible::NoVideoFill as u32;
|
||||||
|
};
|
||||||
|
|
||||||
|
match shape_overlay_blockers(&state.shapes, shape) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(reason) => reason as u32,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the `video-overlay-wasm/v1` path on or off. With it off the renderer
|
||||||
|
/// keeps painting video frames through the tiles.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn set_video_overlay_enabled(enabled: bool) {
|
||||||
|
get_render_state().video_overlay_enabled = enabled;
|
||||||
|
let image_ids: Vec<Uuid> = get_render_state().videos.iter().copied().collect();
|
||||||
|
for image_id in image_ids {
|
||||||
|
resync_image(image_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user