Add polygon painting with per-gap drawing keys
This commit is contained in:
parent
73ab153b02
commit
a45e89f4e4
15 changed files with 435 additions and 11 deletions
|
|
@ -27,6 +27,13 @@ and PUTs with ordinary CSRF protection — no endpoint in this app is exempt.
|
||||||
canvas { image-rendering: pixelated; }
|
canvas { image-rendering: pixelated; }
|
||||||
h1 { font-size: 14px; font-weight: normal; opacity: .5; margin: 0 0 12px; }
|
h1 { font-size: 14px; font-weight: normal; opacity: .5; margin: 0 0 12px; }
|
||||||
.stage { display: block; background: #12141c; }
|
.stage { display: block; background: #12141c; }
|
||||||
|
.stage-wrap { position: relative; width: fit-content; }
|
||||||
|
.paint-overlay { position: absolute; inset: 0; touch-action: none; }
|
||||||
|
.paint-overlay circle { cursor: grab; }
|
||||||
|
.paint-tools { width: 640px; margin-top: 9px; font-size: 12px; }
|
||||||
|
.paint-tools .row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 4px 0; }
|
||||||
|
.paint-tools select { color: var(--fg); background: #1c1f2b; border: 1px solid #2b3040; font: inherit; }
|
||||||
|
.paint-tools .hint { color: #d0ba86; opacity: .8; }
|
||||||
audio { display: none; }
|
audio { display: none; }
|
||||||
.transport { margin-top: 12px; width: 640px; }
|
.transport { margin-top: 12px; width: 640px; }
|
||||||
.transport .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
.transport .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,10 @@ Three channel shapes, and the uniformity across them is the point:
|
||||||
```
|
```
|
||||||
|
|
||||||
`:interp` defaults to `:hold`, which `docs/design.md` requires of every cut part.
|
`:interp` defaults to `:hold`, which `docs/design.md` requires of every cut part.
|
||||||
A key may carry its own `:interp` to override the channel's, which is how Lottie
|
An authored keyed channel may also carry `:segments {8 :linear}`: the key at 8
|
||||||
and Blender both do per-key easing; nothing uses it yet and the door is cheap to
|
tweens toward the next key, while other gaps use the channel default. The
|
||||||
leave open.
|
transition belongs to the gap starting at a key, so a shape can cut into one
|
||||||
|
drawing and tween out of it. Per-key easing beyond hold and linear is deferred.
|
||||||
|
|
||||||
### Keys are a map by frame, not a list
|
### Keys are a map by frame, not a list
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,19 @@ Then open **<http://localhost:8778/>**. Django serves the page from
|
||||||
`static/arthur/js`, where `shadow-cljs` already writes it — so nothing copies files
|
`static/arthur/js`, where `shadow-cljs` already writes it — so nothing copies files
|
||||||
between the two.
|
between the two.
|
||||||
|
|
||||||
|
### Paint sketch
|
||||||
|
|
||||||
|
Click **new polygon**, place at least three vertices on the stage, then click
|
||||||
|
**finish shape**. Select a shape to drag its vertices. Scrub to another frame and
|
||||||
|
click **new drawing key** to copy the visible outline there; the previous drawing
|
||||||
|
holds until that key. The numbered drawing-key buttons jump to editable keys.
|
||||||
|
The transition control between two drawing keys can switch that gap between a
|
||||||
|
hold and linear vertex tweening. Other gaps keep their own timing. Tweening works
|
||||||
|
best when the same vertex
|
||||||
|
keeps the same meaning in every drawing. Paint shapes use the timeline clock
|
||||||
|
directly, so the roto exposure grid does not delay a drawing key or step its
|
||||||
|
tween. Use the project **save** button to persist the drawings.
|
||||||
|
|
||||||
`/index.html` still works, and that is deliberate: it is the URL the browser suite
|
`/index.html` still works, and that is deliberate: it is the URL the browser suite
|
||||||
has used since step 5, when shadow-cljs's `:dev-http` did no directory-index
|
has used since step 5, when shadow-cljs's `:dev-http` did no directory-index
|
||||||
resolution and the suite learned to ask for the file.
|
resolution and the suite learned to ask for the file.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
(:require [arthur.db :as db]
|
(:require [arthur.db :as db]
|
||||||
[arthur.events.footage :as footage]
|
[arthur.events.footage :as footage]
|
||||||
[arthur.events.playback]
|
[arthur.events.playback]
|
||||||
|
[arthur.events.paint]
|
||||||
[arthur.events.project]
|
[arthur.events.project]
|
||||||
[arthur.subs.playback]
|
[arthur.subs.playback]
|
||||||
[arthur.subs.render]
|
[arthur.subs.render]
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
(def default
|
(def default
|
||||||
{;; --- the document ---
|
{;; --- the document ---
|
||||||
:clip/current :take
|
:clip/current :take
|
||||||
|
:paint/revision 0
|
||||||
:palette :arthur/default ; a NAME; the ramp itself is project data
|
:palette :arthur/default ; a NAME; the ramp itself is project data
|
||||||
|
|
||||||
;; --- the clip ---
|
;; --- the clip ---
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,15 @@
|
||||||
;; ---------------------------------------------------------------------------
|
;; ---------------------------------------------------------------------------
|
||||||
;; the specification
|
;; the specification
|
||||||
|
|
||||||
|
(defn segment-interp
|
||||||
|
"How the key at `left` leads to the next key. A channel default remains useful
|
||||||
|
for uniform tracks; :segments overrides only the gaps an artist chose."
|
||||||
|
[ch left]
|
||||||
|
(get (:segments ch) left (:interp ch)))
|
||||||
|
|
||||||
(defn- interpolate [ch f left right]
|
(defn- interpolate [ch f left right]
|
||||||
(let [a (get (:keys ch) left)]
|
(let [a (get (:keys ch) left)]
|
||||||
(if (and (= :linear (:interp ch)) right (>= f left) (> right left))
|
(if (and (= :linear (segment-interp ch left)) right (>= f left) (> right left))
|
||||||
(let [b (get (:keys ch) right)
|
(let [b (get (:keys ch) right)
|
||||||
t (/ (- f left) (- right left))]
|
t (/ (- f left) (- right left))]
|
||||||
(if (vector? a)
|
(if (vector? a)
|
||||||
|
|
@ -302,7 +308,9 @@
|
||||||
(every? (fn [v] (and (vector? v)
|
(every? (fn [v] (and (vector? v)
|
||||||
(= (count v) (count first-value))
|
(= (count v) (count first-value))
|
||||||
(every? number? v)))
|
(every? number? v)))
|
||||||
values)))]
|
values)))
|
||||||
|
linear? (or (= :linear (:interp ch))
|
||||||
|
(some #{:linear} (vals (:segments ch))))]
|
||||||
(cond-> []
|
(cond-> []
|
||||||
(not (map? ch))
|
(not (map? ch))
|
||||||
(conj "not a map")
|
(conj "not a map")
|
||||||
|
|
@ -326,7 +334,14 @@
|
||||||
(and (map? ch) (:animated? ch) (not (#{:hold :linear nil} (:interp ch))))
|
(and (map? ch) (:animated? ch) (not (#{:hold :linear nil} (:interp ch))))
|
||||||
(conj (str ":interp " (:interp ch) " is not implemented"))
|
(conj (str ":interp " (:interp ch) " is not implemented"))
|
||||||
|
|
||||||
(and (map? ch) (= :linear (:interp ch))
|
(and (map? ch) (contains? ch :segments)
|
||||||
|
(or (not (map? (:segments ch)))
|
||||||
|
(not (:keys ch))
|
||||||
|
(not (every? (set (keys (:keys ch))) (keys (:segments ch))))
|
||||||
|
(not (every? #{:hold :linear} (vals (:segments ch))))))
|
||||||
|
(conj ":segments must map existing key frames to :hold or :linear")
|
||||||
|
|
||||||
|
(and (map? ch) linear?
|
||||||
(or (:dense ch) (not linear-values?)))
|
(or (:dense ch) (not linear-values?)))
|
||||||
(conj ":linear interpolation needs numeric keys of one shape")
|
(conj ":linear interpolation needs numeric keys of one shape")
|
||||||
|
|
||||||
|
|
|
||||||
57
frontend/src/arthur/domain/paint.cljs
Normal file
57
frontend/src/arthur/domain/paint.cljs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
(ns arthur.domain.paint
|
||||||
|
"Small authored polygon operations. Paint nodes read timeline frames directly;
|
||||||
|
the roto root's exposure and picture sampling must not quantise a hand edit."
|
||||||
|
(:require [arthur.domain.channel :as channel]))
|
||||||
|
|
||||||
|
(def geometry [:geom :pts])
|
||||||
|
|
||||||
|
(defn shapes [clip]
|
||||||
|
(->> (get-in clip [:timelines :main :nodes])
|
||||||
|
(filter (fn [[_ node]] (:paint? node)))
|
||||||
|
(sort-by (comp :z val))
|
||||||
|
vec))
|
||||||
|
|
||||||
|
(defn active-frame [ch frame]
|
||||||
|
(let [frames (sort (keys (:keys ch)))]
|
||||||
|
(or (last (take-while #(<= % frame) frames)) (first frames))))
|
||||||
|
|
||||||
|
(defn new-shape [clip id frame points color]
|
||||||
|
(let [end (get-in clip [:timelines :main :frames])
|
||||||
|
z (str "z" (js/Date.now) "-" (name id))]
|
||||||
|
(if (and (<= 0 frame) (< frame end) (>= (count points) 6)
|
||||||
|
(even? (count points)))
|
||||||
|
(assoc-in clip [:timelines :main :nodes id]
|
||||||
|
{:id id :name (str "shape " (inc (count (shapes clip))))
|
||||||
|
:kind :poly :paint? true :parent nil :z z
|
||||||
|
:span [frame end]
|
||||||
|
:channels {geometry (channel/keyed {frame points})
|
||||||
|
[:style :color] (channel/framed color)}})
|
||||||
|
clip)))
|
||||||
|
|
||||||
|
(defn add-key [clip id frame]
|
||||||
|
(let [path [:timelines :main :nodes id]
|
||||||
|
node (get-in clip path)
|
||||||
|
ch (get-in node [:channels geometry])
|
||||||
|
[start end] (:span node)]
|
||||||
|
(if (and (:paint? node) (<= start frame) (< frame end) ch)
|
||||||
|
(assoc-in clip (into path [:channels geometry :keys frame])
|
||||||
|
(vec (channel/value-at ch frame)))
|
||||||
|
clip)))
|
||||||
|
|
||||||
|
(defn set-vertex [clip id key-frame vertex [x y]]
|
||||||
|
(let [path [:timelines :main :nodes id :channels geometry :keys key-frame]
|
||||||
|
points (get-in clip path)
|
||||||
|
i (* 2 vertex)]
|
||||||
|
(if (and points (< (inc i) (count points)))
|
||||||
|
(assoc-in clip path (-> points (assoc i x) (assoc (inc i) y)))
|
||||||
|
clip)))
|
||||||
|
|
||||||
|
(defn set-segment-interp [clip id key-frame interp]
|
||||||
|
(let [node (get-in clip [:timelines :main :nodes id])
|
||||||
|
keys (get-in node [:channels geometry :keys])]
|
||||||
|
(if (and (:paint? node) (contains? keys key-frame)
|
||||||
|
(some #(< key-frame %) (clojure.core/keys keys))
|
||||||
|
(#{:hold :linear} interp))
|
||||||
|
(assoc-in clip [:timelines :main :nodes id :channels geometry
|
||||||
|
:segments key-frame] interp)
|
||||||
|
clip)))
|
||||||
33
frontend/src/arthur/events/paint.cljs
Normal file
33
frontend/src/arthur/events/paint.cljs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
(ns arthur.events.paint
|
||||||
|
(:require [arthur.domain.paint :as paint]
|
||||||
|
[arthur.footage.store :as store]
|
||||||
|
[re-frame.core :as rf]))
|
||||||
|
|
||||||
|
(defn- edit [db f]
|
||||||
|
(let [id (store/edit-clip! (:clip/current db) f)]
|
||||||
|
(if id
|
||||||
|
(-> db
|
||||||
|
(assoc :clip/current id)
|
||||||
|
(update :paint/revision (fnil inc 0))
|
||||||
|
(update :project merge {:status "paint edited · unsaved"}))
|
||||||
|
db)))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::new-shape
|
||||||
|
(fn [db [_ id points color]]
|
||||||
|
(edit db #(paint/new-shape % id (get-in db [:playback :frame]) points color))))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::add-key
|
||||||
|
(fn [db [_ id]]
|
||||||
|
(edit db #(paint/add-key % id (get-in db [:playback :frame])))))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::set-vertex
|
||||||
|
(fn [db [_ id key-frame vertex point]]
|
||||||
|
(edit db #(paint/set-vertex % id key-frame vertex point))))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::set-segment-interp
|
||||||
|
(fn [db [_ id key-frame interp]]
|
||||||
|
(edit db #(paint/set-segment-interp % id key-frame interp))))
|
||||||
|
|
@ -31,3 +31,13 @@
|
||||||
(if (= id (:id @loaded))
|
(if (= id (:id @loaded))
|
||||||
@loaded
|
@loaded
|
||||||
(db/clip-entry id)))
|
(db/clip-entry id)))
|
||||||
|
|
||||||
|
(defn edit-clip!
|
||||||
|
"Edit the loaded document. Built-in clips are copied into the runtime store
|
||||||
|
on first edit, so their delayed source values remain reusable."
|
||||||
|
[id f]
|
||||||
|
(if (= id (:id @loaded))
|
||||||
|
(do (swap! loaded update :clip f) id)
|
||||||
|
(let [entry (entry id)]
|
||||||
|
(when entry
|
||||||
|
(install! (update entry :clip f) "paint")))))
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@
|
||||||
[re-frame.core :as rf]))
|
[re-frame.core :as rf]))
|
||||||
|
|
||||||
(rf/reg-sub ::clip-id (fn [db _] (:clip/current db)))
|
(rf/reg-sub ::clip-id (fn [db _] (:clip/current db)))
|
||||||
|
(rf/reg-sub ::paint-revision (fn [db _] (:paint/revision db)))
|
||||||
|
|
||||||
(rf/reg-sub
|
(rf/reg-sub
|
||||||
::clip
|
::clip
|
||||||
:<- [::clip-id]
|
:<- [::clip-id]
|
||||||
(fn [id _] (:clip (footage/entry id))))
|
:<- [::paint-revision]
|
||||||
|
(fn [[id _] _] (:clip (footage/entry id))))
|
||||||
|
|
||||||
(rf/reg-sub
|
(rf/reg-sub
|
||||||
::timeline
|
::timeline
|
||||||
|
|
|
||||||
149
frontend/src/arthur/ui/paint.cljs
Normal file
149
frontend/src/arthur/ui/paint.cljs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
(ns arthur.ui.paint
|
||||||
|
"Polygon authoring overlay. The raster canvas remains the playback sink; SVG
|
||||||
|
supplies only editor handles and an unfinished outline."
|
||||||
|
(:require [arthur.domain.channel :as channel]
|
||||||
|
[arthur.domain.paint :as paint]
|
||||||
|
[arthur.domain.palette :as palette]
|
||||||
|
[arthur.events.paint :as events]
|
||||||
|
[arthur.events.playback :as pb]
|
||||||
|
[arthur.subs.playback :as playback]
|
||||||
|
[arthur.subs.render :as render]
|
||||||
|
[re-frame.core :as rf]
|
||||||
|
[reagent.core :as r]))
|
||||||
|
|
||||||
|
(defonce ^:private selected (r/atom nil))
|
||||||
|
(defonce ^:private drawing? (r/atom false))
|
||||||
|
(defonce ^:private draft (r/atom []))
|
||||||
|
(defonce ^:private tone (r/atom :skin-base))
|
||||||
|
(defonce ^:private dragging (atom nil))
|
||||||
|
|
||||||
|
(defn- point [event w h]
|
||||||
|
(let [box (.getBoundingClientRect (.-currentTarget event))]
|
||||||
|
[(-> (/ (* (- (.-clientX event) (.-left box)) w) (.-width box))
|
||||||
|
js/Math.round (max 0) (min (dec w)))
|
||||||
|
(-> (/ (* (- (.-clientY event) (.-top box)) h) (.-height box))
|
||||||
|
js/Math.round (max 0) (min (dec h)))]))
|
||||||
|
|
||||||
|
(defn- pairs [pts]
|
||||||
|
(mapv vec (partition 2 pts)))
|
||||||
|
|
||||||
|
(defn- points-text [pts]
|
||||||
|
(apply str (interpose " " (map (fn [[x y]] (str x "," y)) (pairs pts)))))
|
||||||
|
|
||||||
|
(defn- begin! []
|
||||||
|
(reset! selected nil)
|
||||||
|
(reset! draft [])
|
||||||
|
(reset! drawing? true))
|
||||||
|
|
||||||
|
(defn- finish! []
|
||||||
|
(when (>= (count @draft) 6)
|
||||||
|
(let [id (keyword (str "paint-" (random-uuid)))]
|
||||||
|
(rf/dispatch [::events/new-shape id @draft @tone])
|
||||||
|
(reset! selected id)
|
||||||
|
(reset! draft [])
|
||||||
|
(reset! drawing? false))))
|
||||||
|
|
||||||
|
(defn toolbar []
|
||||||
|
(let [clip @(rf/subscribe [::render/clip])
|
||||||
|
frame @(rf/subscribe [::playback/frame])
|
||||||
|
shapes (paint/shapes clip)
|
||||||
|
ids (set (map first shapes))
|
||||||
|
id (when (contains? ids @selected) @selected)
|
||||||
|
shape (get-in clip [:timelines :main :nodes id])
|
||||||
|
geom (get-in shape [:channels paint/geometry])
|
||||||
|
active (when geom (paint/active-frame geom frame))
|
||||||
|
keys (when geom (sort (keys (:keys geom))))
|
||||||
|
next-key (first (filter #(> % active) keys))
|
||||||
|
tween? (= :linear (channel/segment-interp geom active))]
|
||||||
|
[:div.paint-tools
|
||||||
|
[:div.row
|
||||||
|
[:strong "paint"]
|
||||||
|
[:button {:class (when @drawing? "on") :on-click begin!} "new polygon"]
|
||||||
|
(when @drawing?
|
||||||
|
[:button {:disabled (< (count @draft) 6) :on-click finish!} "finish shape"])
|
||||||
|
(when @drawing?
|
||||||
|
[:button {:on-click #(do (reset! drawing? false) (reset! draft []))} "cancel"])
|
||||||
|
[:label "colour "
|
||||||
|
[:select {:value (name @tone)
|
||||||
|
:on-change #(reset! tone (keyword (.. % -target -value)))}
|
||||||
|
(for [{:keys [name hex]} (rest palette/entries)]
|
||||||
|
^{:key name} [:option {:value (clojure.core/name name)}
|
||||||
|
(str (clojure.core/name name) " " hex)])]]]
|
||||||
|
[:div.row
|
||||||
|
[:label "shape "
|
||||||
|
[:select {:value (if id (name id) "")
|
||||||
|
:on-change #(reset! selected (when-not (= "" (.. % -target -value))
|
||||||
|
(keyword (.. % -target -value))))}
|
||||||
|
[:option {:value ""} "select"]
|
||||||
|
(for [[shape-id node] shapes]
|
||||||
|
^{:key shape-id} [:option {:value (name shape-id)} (:name node)])]]
|
||||||
|
(when id
|
||||||
|
[:button {:disabled (or (< frame (first (:span shape)))
|
||||||
|
(contains? (:keys geom) frame))
|
||||||
|
:on-click #(rf/dispatch [::events/add-key id])}
|
||||||
|
"new drawing key"])
|
||||||
|
(when (and id next-key)
|
||||||
|
[:label (str "key " active " → " next-key " ")
|
||||||
|
[:select {:value (name (or (channel/segment-interp geom active) :hold))
|
||||||
|
:on-change #(rf/dispatch [::events/set-segment-interp id active
|
||||||
|
(keyword (.. % -target -value))])}
|
||||||
|
[:option {:value "hold"} "hold"]
|
||||||
|
[:option {:value "linear"} "tween shape"]]])]
|
||||||
|
(when (seq keys)
|
||||||
|
[:div.row
|
||||||
|
[:span "drawing keys "]
|
||||||
|
(for [f keys]
|
||||||
|
^{:key f}
|
||||||
|
[:button {:class (when (= frame f) "on")
|
||||||
|
:on-click #(rf/dispatch [::pb/seek f])}
|
||||||
|
(str f)])])
|
||||||
|
[:div.hint
|
||||||
|
(cond
|
||||||
|
@drawing? (str "Click vertices on the stage, then Finish shape. "
|
||||||
|
(quot (count @draft) 2) " points")
|
||||||
|
(and id tween? (not (contains? (:keys geom) frame)))
|
||||||
|
"Tweening between drawings. Add a drawing key here, or jump to a key to edit its vertices."
|
||||||
|
id (str "Drag vertices to edit drawing key " active
|
||||||
|
". New drawing key copies the visible shape at this frame. The transition control changes only the selected gap.")
|
||||||
|
:else "Create a polygon, or select one to edit its drawing keys.")]]))
|
||||||
|
|
||||||
|
(defn overlay [w h zoom]
|
||||||
|
(let [clip @(rf/subscribe [::render/clip])
|
||||||
|
frame @(rf/subscribe [::playback/frame])
|
||||||
|
shape (get-in clip [:timelines :main :nodes @selected])
|
||||||
|
geom (get-in shape [:channels paint/geometry])
|
||||||
|
active (when geom (paint/active-frame geom frame))
|
||||||
|
visible? (and shape (let [[start end] (:span shape)] (<= start frame) (< frame end)))
|
||||||
|
pts (when visible? (channel/value-at geom frame))
|
||||||
|
editable? (or (not= :linear (channel/segment-interp geom active))
|
||||||
|
(contains? (:keys geom) frame))]
|
||||||
|
[:svg.paint-overlay
|
||||||
|
{:width (* zoom w) :height (* zoom h)
|
||||||
|
:view-box (str "0 0 " w " " h)
|
||||||
|
:on-pointer-down (fn [event]
|
||||||
|
(when @drawing?
|
||||||
|
(let [[x y] (point event w h)]
|
||||||
|
(swap! draft into [x y]))))
|
||||||
|
:on-pointer-move (fn [event]
|
||||||
|
(when-let [[id key-frame vertex] @dragging]
|
||||||
|
(rf/dispatch [::events/set-vertex id key-frame vertex
|
||||||
|
(point event w h)])))
|
||||||
|
:on-pointer-up (fn [_] (reset! dragging nil))
|
||||||
|
:on-pointer-cancel (fn [_] (reset! dragging nil))}
|
||||||
|
(when (seq @draft)
|
||||||
|
[:polyline {:points (points-text @draft) :fill "none"
|
||||||
|
:stroke "#d0ba86" :stroke-width 1}])
|
||||||
|
(when (and visible? (not @drawing?) pts)
|
||||||
|
[:g
|
||||||
|
[:polygon {:points (points-text pts) :fill "none"
|
||||||
|
:stroke "#e6ca8b" :stroke-width 1}]
|
||||||
|
(for [[i [x y]] (map-indexed vector (pairs (when editable? pts)))]
|
||||||
|
^{:key i}
|
||||||
|
[:circle {:cx x :cy y :r 2.6 :fill "#fff1be"
|
||||||
|
:stroke "#161820" :stroke-width 0.7
|
||||||
|
:on-pointer-down (fn [event]
|
||||||
|
(.stopPropagation event)
|
||||||
|
(.preventDefault event)
|
||||||
|
(.setPointerCapture (.-currentTarget event)
|
||||||
|
(.-pointerId event))
|
||||||
|
(reset! dragging [@selected active i]))}])])]))
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
[arthur.subs.playback :as sub]
|
[arthur.subs.playback :as sub]
|
||||||
[arthur.subs.render :as render]
|
[arthur.subs.render :as render]
|
||||||
[arthur.ui.player :as player]
|
[arthur.ui.player :as player]
|
||||||
|
[arthur.ui.paint :as paint]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[reagent.core :as r]))
|
[reagent.core :as r]))
|
||||||
|
|
||||||
|
|
@ -299,15 +300,18 @@
|
||||||
;; store, so this being a re-render costs nothing per frame.
|
;; store, so this being a re-render costs nothing per frame.
|
||||||
(let [w @(rf/subscribe [::sub/width])
|
(let [w @(rf/subscribe [::sub/width])
|
||||||
h @(rf/subscribe [::sub/height])]
|
h @(rf/subscribe [::sub/height])]
|
||||||
|
[:div.stage-wrap
|
||||||
[:canvas.stage
|
[:canvas.stage
|
||||||
{:ref #(player/set-canvas! %)
|
{:ref #(player/set-canvas! %)
|
||||||
:width w :height h
|
:width w :height h
|
||||||
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]))
|
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]
|
||||||
|
[paint/overlay w h zoom]]))
|
||||||
|
|
||||||
(defn view []
|
(defn view []
|
||||||
[:main
|
[:main
|
||||||
[:h1 "arthur"]
|
[:h1 "arthur"]
|
||||||
[stage]
|
[stage]
|
||||||
|
[paint/toolbar]
|
||||||
[audio]
|
[audio]
|
||||||
[transport]
|
[transport]
|
||||||
[exporter]
|
[exporter]
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,16 @@
|
||||||
(is (= (mapv #(ch/value-at c %) frames)
|
(is (= (mapv #(ch/value-at c %) frames)
|
||||||
(mapv #(ch/sample! cursor %) frames)))))
|
(mapv #(ch/sample! cursor %) frames)))))
|
||||||
|
|
||||||
|
(deftest one-channel-can-cut-then-tween
|
||||||
|
(let [c (assoc (ch/keyed {0 [0 0], 4 [4 0], 8 [8 0]})
|
||||||
|
:segments {4 :linear})
|
||||||
|
cursor (ch/cursor c)]
|
||||||
|
(is (= [0 0] (ch/value-at c 2)))
|
||||||
|
(is (= [4 0] (ch/value-at c 4)))
|
||||||
|
(is (= [6 0] (ch/value-at c 6)))
|
||||||
|
(is (= (mapv #(ch/value-at c %) [0 2 4 6 8 3 7])
|
||||||
|
(mapv #(ch/sample! cursor %) [0 2 4 6 8 3 7])))))
|
||||||
|
|
||||||
(deftest a-frame-before-the-first-key-reads-the-first-key
|
(deftest a-frame-before-the-first-key-reads-the-first-key
|
||||||
;; The JS activeKey clamps low, and that is kept: a channel's first key is the
|
;; The JS activeKey clamps low, and that is kept: a channel's first key is the
|
||||||
;; pose the part starts in. Having NO value is a different question — it is a
|
;; pose the part starts in. Having NO value is a different question — it is a
|
||||||
|
|
|
||||||
32
frontend/test/arthur/domain/paint_test.cljs
Normal file
32
frontend/test/arthur/domain/paint_test.cljs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
(ns arthur.domain.paint-test
|
||||||
|
(:require [cljs.test :refer [deftest is]]
|
||||||
|
[arthur.demo :as demo]
|
||||||
|
[arthur.domain.channel :as channel]
|
||||||
|
[arthur.domain.leaf :as leaf]
|
||||||
|
[arthur.domain.paint :as paint]
|
||||||
|
[arthur.domain.timeline :as timeline]))
|
||||||
|
|
||||||
|
(defn- geometry [clip]
|
||||||
|
(get-in clip [:timelines :main :nodes :paint-test :channels paint/geometry]))
|
||||||
|
|
||||||
|
(deftest drawing-keys-hold-and-tween-on-the-timeline-clock
|
||||||
|
(let [a [10 10 30 10 20 30]
|
||||||
|
c0 (paint/new-shape demo/clip :paint-test 3 a :brow)
|
||||||
|
c1 (paint/add-key c0 :paint-test 9)
|
||||||
|
c2 (paint/set-vertex c1 :paint-test 9 0 [22 10])
|
||||||
|
c3 (paint/add-key c2 :paint-test 15)
|
||||||
|
c4 (paint/set-vertex c3 :paint-test 15 0 [34 10])
|
||||||
|
held (geometry c4)
|
||||||
|
mixed-clip (paint/set-segment-interp c4 :paint-test 9 :linear)
|
||||||
|
mixed (geometry mixed-clip)]
|
||||||
|
(is (= [3 229] (get-in c2 [:timelines :main :nodes :paint-test :span])))
|
||||||
|
(is (= a (channel/value-at held 8)))
|
||||||
|
(is (= 10 (first (channel/value-at held 8))))
|
||||||
|
(is (= 22 (first (channel/value-at held 9))))
|
||||||
|
(is (= 10 (first (channel/value-at mixed 6))) "the first gap cuts")
|
||||||
|
(is (= 28 (first (channel/value-at mixed 12))) "the second gap tweens")
|
||||||
|
(is (empty? (channel/problems mixed)))
|
||||||
|
;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3.
|
||||||
|
(is (some #(= :paint-test (:node %))
|
||||||
|
(timeline/eval-frame (get-in c2 [:timelines :main]) 3)))
|
||||||
|
(is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip))))))
|
||||||
|
|
@ -394,6 +394,95 @@ async function main() {
|
||||||
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
|
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
|
||||||
JSON.stringify(back[0].selectedClip));
|
JSON.stringify(back[0].selectedClip));
|
||||||
|
|
||||||
|
// Paint through the visible controls. This exercises the SVG pointer path,
|
||||||
|
// the authored node, the raster preview and the document round trip.
|
||||||
|
await page.eval(SEEK(0));
|
||||||
|
await sleep(150);
|
||||||
|
const beforePaint = await page.eval(`(() => {
|
||||||
|
const c = document.querySelector('canvas.stage');
|
||||||
|
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
|
||||||
|
return [...d];
|
||||||
|
})()`);
|
||||||
|
const painted = await page.eval(`(() => {
|
||||||
|
const button = [...document.querySelectorAll('.paint-tools button')]
|
||||||
|
.find(b => b.textContent === 'new polygon');
|
||||||
|
button.click();
|
||||||
|
const svg = document.querySelector('.paint-overlay');
|
||||||
|
const box = svg.getBoundingClientRect();
|
||||||
|
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
|
||||||
|
svg.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
|
bubbles: true, clientX: box.left + x * box.width / 320,
|
||||||
|
clientY: box.top + y * box.height / 200,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(100);
|
||||||
|
const finished = await page.eval(`(() => {
|
||||||
|
const button = [...document.querySelectorAll('.paint-tools button')]
|
||||||
|
.find(b => b.textContent === 'finish shape');
|
||||||
|
if (!button || button.disabled) return false;
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(250);
|
||||||
|
const afterPaint = await page.eval(`(() => {
|
||||||
|
const c = document.querySelector('canvas.stage');
|
||||||
|
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
|
||||||
|
return [...d];
|
||||||
|
})()`);
|
||||||
|
check(painted && finished && beforePaint.join(',') !== afterPaint.join(','),
|
||||||
|
'a polygon drawn with the paint controls appears on the canvas');
|
||||||
|
for (const f of [8, 16]) {
|
||||||
|
await page.eval(SEEK(f));
|
||||||
|
await sleep(100);
|
||||||
|
check(await page.eval(`(() => {
|
||||||
|
const button = [...document.querySelectorAll('.paint-tools button')]
|
||||||
|
.find(b => b.textContent === 'new drawing key');
|
||||||
|
if (!button || button.disabled) return false;
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
|
})()`), `a drawing key can be added at frame ${f}`);
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
await page.eval(SEEK(8));
|
||||||
|
await sleep(100);
|
||||||
|
const tweenSelected = await page.eval(`(() => {
|
||||||
|
const label = [...document.querySelectorAll('.paint-tools label')]
|
||||||
|
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||||
|
const select = label?.querySelector('select');
|
||||||
|
if (!select) return false;
|
||||||
|
select.value = 'linear';
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`);
|
||||||
|
await sleep(100);
|
||||||
|
check(tweenSelected && await page.eval(`(() => {
|
||||||
|
const label = [...document.querySelectorAll('.paint-tools label')]
|
||||||
|
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||||
|
return label?.querySelector('select')?.value === 'linear';
|
||||||
|
})()`), 'the second drawing gap can be set to tween');
|
||||||
|
check(await page.eval(CLICK('save')), 'the painted document can be saved');
|
||||||
|
check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null,
|
||||||
|
'the painted shape is saved', await page.eval(STATUS));
|
||||||
|
check(await page.eval(CLICK('open')), 'the painted document can be reopened');
|
||||||
|
check((await statusMatching(/opened /)) !== null,
|
||||||
|
'the painted shape is reopened', await page.eval(STATUS));
|
||||||
|
await sleep(150);
|
||||||
|
const reopenedPaint = await page.eval(`(() => {
|
||||||
|
const c = document.querySelector('canvas.stage');
|
||||||
|
return [...c.getContext('2d').getImageData(25, 20, 1, 1).data];
|
||||||
|
})()`);
|
||||||
|
check(afterPaint.join(',') === reopenedPaint.join(','),
|
||||||
|
'the painted polygon survives the project round trip');
|
||||||
|
await page.eval(SEEK(8));
|
||||||
|
await sleep(100);
|
||||||
|
check(await page.eval(`(() => {
|
||||||
|
const label = [...document.querySelectorAll('.paint-tools label')]
|
||||||
|
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||||
|
return label?.querySelector('select')?.value === 'linear';
|
||||||
|
})()`), 'the per-gap tween setting survives the project round trip');
|
||||||
|
|
||||||
// The local 8625 study is optional in a fresh test database. When present,
|
// The local 8625 study is optional in a fresh test database. When present,
|
||||||
// tune its shared symbol while its stage is playing.
|
// tune its shared symbol while its stage is playing.
|
||||||
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
|
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue