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; }
|
||||
h1 { font-size: 14px; font-weight: normal; opacity: .5; margin: 0 0 12px; }
|
||||
.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; }
|
||||
.transport { margin-top: 12px; width: 640px; }
|
||||
.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.
|
||||
A key may carry its own `:interp` to override the channel's, which is how Lottie
|
||||
and Blender both do per-key easing; nothing uses it yet and the door is cheap to
|
||||
leave open.
|
||||
An authored keyed channel may also carry `:segments {8 :linear}`: the key at 8
|
||||
tweens toward the next key, while other gaps use the channel default. The
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
(:require [arthur.db :as db]
|
||||
[arthur.events.footage :as footage]
|
||||
[arthur.events.playback]
|
||||
[arthur.events.paint]
|
||||
[arthur.events.project]
|
||||
[arthur.subs.playback]
|
||||
[arthur.subs.render]
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
(def default
|
||||
{;; --- the document ---
|
||||
:clip/current :take
|
||||
:paint/revision 0
|
||||
:palette :arthur/default ; a NAME; the ramp itself is project data
|
||||
|
||||
;; --- the clip ---
|
||||
|
|
|
|||
|
|
@ -169,9 +169,15 @@
|
|||
;; ---------------------------------------------------------------------------
|
||||
;; 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]
|
||||
(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)
|
||||
t (/ (- f left) (- right left))]
|
||||
(if (vector? a)
|
||||
|
|
@ -302,7 +308,9 @@
|
|||
(every? (fn [v] (and (vector? v)
|
||||
(= (count v) (count first-value))
|
||||
(every? number? v)))
|
||||
values)))]
|
||||
values)))
|
||||
linear? (or (= :linear (:interp ch))
|
||||
(some #{:linear} (vals (:segments ch))))]
|
||||
(cond-> []
|
||||
(not (map? ch))
|
||||
(conj "not a map")
|
||||
|
|
@ -326,7 +334,14 @@
|
|||
(and (map? ch) (:animated? ch) (not (#{:hold :linear nil} (:interp ch))))
|
||||
(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?)))
|
||||
(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))
|
||||
@loaded
|
||||
(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]))
|
||||
|
||||
(rf/reg-sub ::clip-id (fn [db _] (:clip/current db)))
|
||||
(rf/reg-sub ::paint-revision (fn [db _] (:paint/revision db)))
|
||||
|
||||
(rf/reg-sub
|
||||
::clip
|
||||
:<- [::clip-id]
|
||||
(fn [id _] (:clip (footage/entry id))))
|
||||
:<- [::paint-revision]
|
||||
(fn [[id _] _] (:clip (footage/entry id))))
|
||||
|
||||
(rf/reg-sub
|
||||
::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.render :as render]
|
||||
[arthur.ui.player :as player]
|
||||
[arthur.ui.paint :as paint]
|
||||
[re-frame.core :as rf]
|
||||
[reagent.core :as r]))
|
||||
|
||||
|
|
@ -299,15 +300,18 @@
|
|||
;; store, so this being a re-render costs nothing per frame.
|
||||
(let [w @(rf/subscribe [::sub/width])
|
||||
h @(rf/subscribe [::sub/height])]
|
||||
[:canvas.stage
|
||||
{:ref #(player/set-canvas! %)
|
||||
:width w :height h
|
||||
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]))
|
||||
[:div.stage-wrap
|
||||
[:canvas.stage
|
||||
{:ref #(player/set-canvas! %)
|
||||
:width w :height h
|
||||
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]
|
||||
[paint/overlay w h zoom]]))
|
||||
|
||||
(defn view []
|
||||
[:main
|
||||
[:h1 "arthur"]
|
||||
[stage]
|
||||
[paint/toolbar]
|
||||
[audio]
|
||||
[transport]
|
||||
[exporter]
|
||||
|
|
|
|||
|
|
@ -30,6 +30,16 @@
|
|||
(is (= (mapv #(ch/value-at c %) 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
|
||||
;; 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
|
||||
|
|
|
|||
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',
|
||||
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,
|
||||
// tune its shared symbol while its stage is playing.
|
||||
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue