diff --git a/clips/templates/clips/index.html b/clips/templates/clips/index.html
index 1337f9f..e08393d 100644
--- a/clips/templates/clips/index.html
+++ b/clips/templates/clips/index.html
@@ -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; }
diff --git a/docs/animation-model.md b/docs/animation-model.md
index d8ff025..44d090c 100644
--- a/docs/animation-model.md
+++ b/docs/animation-model.md
@@ -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
diff --git a/frontend/README.md b/frontend/README.md
index b88ca31..62a7946 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -92,6 +92,19 @@ Then open ****. 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.
diff --git a/frontend/src/arthur/core.cljs b/frontend/src/arthur/core.cljs
index 15cf9c4..e359902 100644
--- a/frontend/src/arthur/core.cljs
+++ b/frontend/src/arthur/core.cljs
@@ -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]
diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs
index 8664d93..a116505 100644
--- a/frontend/src/arthur/db.cljs
+++ b/frontend/src/arthur/db.cljs
@@ -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 ---
diff --git a/frontend/src/arthur/domain/channel.cljs b/frontend/src/arthur/domain/channel.cljs
index 7131df3..9e4ef4f 100644
--- a/frontend/src/arthur/domain/channel.cljs
+++ b/frontend/src/arthur/domain/channel.cljs
@@ -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")
diff --git a/frontend/src/arthur/domain/paint.cljs b/frontend/src/arthur/domain/paint.cljs
new file mode 100644
index 0000000..6e943a2
--- /dev/null
+++ b/frontend/src/arthur/domain/paint.cljs
@@ -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)))
diff --git a/frontend/src/arthur/events/paint.cljs b/frontend/src/arthur/events/paint.cljs
new file mode 100644
index 0000000..4b92003
--- /dev/null
+++ b/frontend/src/arthur/events/paint.cljs
@@ -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))))
diff --git a/frontend/src/arthur/footage/store.cljs b/frontend/src/arthur/footage/store.cljs
index 8e60f26..aafc1a2 100644
--- a/frontend/src/arthur/footage/store.cljs
+++ b/frontend/src/arthur/footage/store.cljs
@@ -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")))))
diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs
index 1b9a6ed..254dd7c 100644
--- a/frontend/src/arthur/subs/render.cljs
+++ b/frontend/src/arthur/subs/render.cljs
@@ -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
diff --git a/frontend/src/arthur/ui/paint.cljs b/frontend/src/arthur/ui/paint.cljs
new file mode 100644
index 0000000..a2c2ebc
--- /dev/null
+++ b/frontend/src/arthur/ui/paint.cljs
@@ -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]))}])])]))
diff --git a/frontend/src/arthur/ui/shell.cljs b/frontend/src/arthur/ui/shell.cljs
index 1431f58..b34bb2f 100644
--- a/frontend/src/arthur/ui/shell.cljs
+++ b/frontend/src/arthur/ui/shell.cljs
@@ -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]
diff --git a/frontend/test/arthur/domain/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs
index af93f77..6ac5565 100644
--- a/frontend/test/arthur/domain/channel_test.cljs
+++ b/frontend/test/arthur/domain/channel_test.cljs
@@ -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
diff --git a/frontend/test/arthur/domain/paint_test.cljs b/frontend/test/arthur/domain/paint_test.cljs
new file mode 100644
index 0000000..d731aaf
--- /dev/null
+++ b/frontend/test/arthur/domain/paint_test.cljs
@@ -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))))))
diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs
index cba1db1..35e651a 100644
--- a/frontend/test/browser/take.mjs
+++ b/frontend/test/browser/take.mjs
@@ -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')