From a45e89f4e4aec88008d4de61f26588e0ded0e963 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Mon, 28 Sep 2026 22:37:23 -0400 Subject: [PATCH] Add polygon painting with per-gap drawing keys --- clips/templates/clips/index.html | 7 + docs/animation-model.md | 7 +- frontend/README.md | 13 ++ frontend/src/arthur/core.cljs | 1 + frontend/src/arthur/db.cljs | 1 + frontend/src/arthur/domain/channel.cljs | 21 ++- frontend/src/arthur/domain/paint.cljs | 57 +++++++ frontend/src/arthur/events/paint.cljs | 33 ++++ frontend/src/arthur/footage/store.cljs | 10 ++ frontend/src/arthur/subs/render.cljs | 4 +- frontend/src/arthur/ui/paint.cljs | 149 ++++++++++++++++++ frontend/src/arthur/ui/shell.cljs | 12 +- frontend/test/arthur/domain/channel_test.cljs | 10 ++ frontend/test/arthur/domain/paint_test.cljs | 32 ++++ frontend/test/browser/take.mjs | 89 +++++++++++ 15 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 frontend/src/arthur/domain/paint.cljs create mode 100644 frontend/src/arthur/events/paint.cljs create mode 100644 frontend/src/arthur/ui/paint.cljs create mode 100644 frontend/test/arthur/domain/paint_test.cljs 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')