This commit is contained in:
Your Name 2026-10-02 17:44:11 -04:00
parent edca82cd5d
commit 664252e0fc
17 changed files with 373 additions and 112 deletions

View file

@ -360,9 +360,15 @@
(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)
(cond
;; Palette-choice channels interpolate identities into a blend
;; descriptor. The renderer keeps indexed geometry in the left
;; palette's bank and blends that bank's ramp toward the right one.
(and (= :palette (:semantic ch)) (keyword? a) (keyword? b))
{:from a :to b :t t}
(vector? a)
(mapv (fn [x y] (+ x (* t (- y x)))) a b)
(+ a (* t (- b a)))))
:else (+ a (* t (- b a)))))
a)))
(defn- keyed-at
@ -514,6 +520,7 @@
(let [values (when (map? (:keys ch)) (vals (:keys ch)))
first-value (first values)
linear-values? (or (every? number? values)
(and (= :palette (:semantic ch)) (every? keyword? values))
(and (vector? first-value)
(pos? (count first-value))
(every? (fn [v] (and (vector? v)

View file

@ -280,9 +280,14 @@
(and a (<= a frame) (< frame b))))
(symbol/children (:nodes track-symbol))))
palette-symbol (some-> palette-clip node/source
(#(symbol clip %)))]
(when (= :palette (:type palette-symbol))
(:palette-ref palette-symbol))))
(#(symbol clip %)))
fallback (when (= :palette (:type palette-symbol))
(:palette-ref palette-symbol))
choice (get-in palette-clip [:channels [:palette]])
start (some-> palette-clip node/placed-span first)]
(or (when (and choice start)
(channel-value choice (- frame start)))
fallback)))
track-value
(channel-value (:palette owner) frame)
inherited

View file

@ -45,7 +45,10 @@
change to this spec silently change what gets drawn."
(let [base (into #{[:vis]} xform-paths)]
{:group base
:instance base
;; A palette-track placement is still an instance. Its palette choice is
;; a parameter channel on that instance, so the generic span commands can
;; move and trim it without knowing what kind of lane owns it.
:instance (conj base [:palette])
;; A sound is not in the picture: no transform, no visibility. After
;; Effects' audio-only layer has no Transform group for the same reason.
:audio #{[:audio :gain] [:audio :pan] [:audio :rate]}
@ -102,7 +105,9 @@
[n path f v]
(let [c (get (channels n) path)]
(assoc-in n [:channels path]
(if (:keys c) (assoc-in c [:keys f] v) (ch/framed v)))))
(if (:keys c)
(assoc-in c [:keys f] v)
(merge (select-keys c [:semantic]) (ch/framed v))))))
(defn set-keyed-channel
"Write `v` as a key at `f`, starting an animated channel when needed. This is
@ -113,7 +118,8 @@
(assoc-in n [:channels path]
(if (:keys c)
(assoc-in c [:keys f] v)
(ch/keyed {f v} (if (boolean? v) :hold :linear))))))
(merge (select-keys c [:semantic])
(ch/keyed {f v} (if (or (boolean? v) (keyword? v)) :hold :linear)))))))
(defn toggle-key
"Key channel `path` on the node's own frame `f` with the value it has there, or
@ -128,7 +134,8 @@
ks (dissoc (:keys c) f)]
(assoc-in n [:channels path]
(cond
(not (:keys c)) (ch/keyed {f v} (if (boolean? v) :hold :linear))
(not (:keys c)) (merge (select-keys c [:semantic])
(ch/keyed {f v} (if (or (boolean? v) (keyword? v)) :hold :linear)))
(not (contains? (:keys c) f)) (assoc-in c [:keys f] v)
(seq ks) (cond-> (assoc c :keys ks)
(:segments c) (update :segments dissoc f))

View file

@ -85,7 +85,10 @@
(vals (:symbols clip)))
(for [sym (vals (:symbols clip))
n (vals (:nodes sym))]
(:palette n))))
(:palette n))
(for [sym (vals (:symbols clip))
n (vals (:nodes sym))]
(get-in n [:channels [:palette]]))))
ps (select-keys (palettes clip) used)
ordered (sort-by (comp str key) ps)
offsets (loop [xs ordered at 0 out {}]
@ -105,7 +108,8 @@
(defn render-index
"A local slot (or a legacy tone keyword) in palette `id` -> raster index."
[{:keys [palettes offsets]} id value]
(let [p (get palettes id)
(let [id (if (map? id) (:from id) id)
p (get palettes id)
i (and p (slot-index p value))]
(if (some? i) (+ (get offsets id 0) i) 255)))
@ -119,6 +123,26 @@
(render-index palette active 0)
(get palette :bg 0)))
(defn effective-ramp
"The compiled ramp with the active palette blend applied. A hold returns the
original vector; a linear palette segment rewrites only the left palette's
bank, which is the bank the indexed raster was drawn into."
[{:keys [palettes offsets ramp]} active]
(if-let [{:keys [from to t]} (when (map? active) active)]
(let [a (get palettes from)
b (get palettes to)
at (get offsets from)
n (min (count (:slots a)) (count (:slots b)))]
(if (and a b (number? at) (number? t))
(reduce (fn [out i]
(let [x (hex->rgb (get-in a [:slots i :hex]))
y (hex->rgb (get-in b [:slots i :hex]))]
(assoc out (+ at i)
(mapv #(js/Math.round (+ %1 (* t (- %2 %1)))) x y))))
ramp (range n))
ramp))
ramp))
(defn valid-palette? [{:keys [id name slots]}]
(and id (string? name) (seq name) (vector? slots) (pos? (count slots))
(<= (count slots) 255)

View file

@ -751,7 +751,12 @@
:frames 1 :fps (clip/fps document root-sid) :nodes {}}))
id (random-uuid)
result (span/place-symbol document st track-id id palette-sid frame
{:extent :grow-symbol :remainder-id (random-uuid)})]
{:extent :grow-symbol :remainder-id (random-uuid)})
result (if-let [placed (:clip result)]
(assoc result :clip
(assoc-in placed [:symbols track-id :nodes id :channels [:palette]]
(assoc (ch/framed palette-id) :semantic :palette)))
result)]
(if-let [why (:refused result)]
(update db :project merge {:status why})
(-> db
@ -765,6 +770,34 @@
(let [put (if (get-in db [:ui :auto-key?]) node/set-keyed-channel node/set-channel)]
(edit/edit db #(update-in % [:symbols sid :nodes id] put path frame value)))))
(defn- seed-palette-choice [document sid id]
(if (get-in document [:symbols sid :nodes id :channels [:palette]])
document
(let [source (get-in document [:symbols sid :nodes id :source :symbol])
palette-id (get-in document [:symbols source :palette-ref])]
(assoc-in document [:symbols sid :nodes id :channels [:palette]]
(assoc (ch/framed palette-id) :semantic :palette)))))
(rf/reg-event-db
::set-palette-choice
(fn [db [_ sid id frame palette-id]]
(let [put (if (get-in db [:ui :auto-key?]) node/set-keyed-channel node/set-channel)]
(edit/edit db
(fn [document]
(update-in (seed-palette-choice document sid id)
[:symbols sid :nodes id]
put [:palette] frame palette-id))))))
(rf/reg-event-db
::toggle-palette-key
(fn [db [_ sid id frame]]
(let [st (:store (store/entry (:clip/current db)))]
(edit/edit db
(fn [document]
(update-in (seed-palette-choice document sid id)
[:symbols sid :nodes id]
node/toggle-key [:palette] frame st))))))
(rf/reg-event-db
::set-channels
;; One property assignment over a stage selection is one document edit.

View file

@ -63,26 +63,16 @@
(defn selected
"`db` with `selection` selected, and nothing aimed.
The rows above a selection are opened, so one made deep on the stage is seen
in the timeline. Not a sound's: its row is always in the audio section, and
opening the placement it is heard through would bury it.
AN EMPTY PATH IS A REAL SELECTION, not a missing one. The open symbol's own
lane row names itself with `[]`, and `(pop [])` THROWS — so clicking that row
aborted the whole event: nothing was selected, nothing was aimed, and the next
polygon went wherever the stale target still pointed, which is most of what
made aiming a lane feel random. There is nothing above a selection at the top
to open, so the guard is `seq` rather than `some?`."
Selection and row disclosure are independent. A click or trim can select a
nested item, but only the row's twist button opens or closes it."
[db selection]
(let [[kind sid id path] selection
sound? (= :audio (get-in (store/entry (:clip/current db))
[:clip :symbols sid :nodes id :kind]))]
(cond-> (-> db
(assoc-in [:ui :selection] selection)
(assoc-in [:ui :selections] (if selection [selection] []))
(update :ui dissoc :points :retry))
(and (= :node kind) (seq path) (not sound?))
(update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path)))))))
;; A captured trim gesture also selects on pointer-up. It must not acquire the
;; unrelated side effect of opening every row above the edited clip.
(cond-> (-> db
(assoc-in [:ui :selection] selection)
(assoc-in [:ui :selections] (if selection [selection] []))
(update :ui dissoc :points :retry))
(nil? selection) (assoc-in [:ui :target] nil)))
(defn target-of
"The target `selection` names: `{:sid :id :path}`, or nil for the open symbol
@ -487,8 +477,14 @@
question `symbol/lane?` answers."
[clip st db frame]
(let [open (get-in db [:ui :open])
down (where-new-goes clip db)]
(assoc (nest/inside clip st open down frame) :path down)))
down (where-new-goes clip db)
inside (nest/inside clip st open down frame)]
;; A stale or presently invisible target is no target. Creation must remain
;; a total operation, so it falls back to the document's main symbol.
(if (:sid inside)
(assoc inside :path down)
{:sid (if (get-in clip [:symbols :main]) :main open)
:path [] :frame frame})))
;; ---------------------------------------------------------------------------
;; clipboard and local duplication
@ -562,7 +558,7 @@
{:sid open :path [] :frame frame}
(nil? n)
{:refused "the paste target no longer exists"}
{:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}
(= :instance (:kind n))
(let [visible (nest/inside clip st open path frame)
@ -576,12 +572,12 @@
at (or (:frame visible) (:frame shown) mapped held)]
(if (number? at)
{:sid (node/source n) :path (vec path) :frame (js/Math.floor at)}
{:refused "the playhead has no single frame inside the paste target"}))
{:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}))
:else
(if (number? owner-frame)
{:sid sid :path (vec (butlast path)) :frame (js/Math.floor owner-frame)}
{:refused "the playhead has no single frame in the paste target"}))))
{:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}))))
(rf/reg-event-db
::paste

View file

@ -210,16 +210,19 @@
(fn [chain i]
(.then chain
(fn [_]
(let [ops (resolve-frame i)]
(let [ops (resolve-frame i)
active (clip/active-palette resolve-frame)]
(-> ras
(raster/clear! (pal/background-index palette
(clip/active-palette resolve-frame)))
(raster/draw-ops! ops)))
(-> (js/Promise.resolve (frame! exporter i ras))
(raster/clear! (pal/background-index palette active))
(raster/draw-ops! ops))
(-> (js/Promise.resolve
(frame! exporter i
(assoc ras :palette-ramp
(pal/effective-ramp palette active))))
(.then (fn [_]
(when on-progress
(on-progress (inc i) frames))
(yield!)))))))
(yield!))))))))
(js/Promise.resolve)
(range frames))))
(.then (fn [_] (finish! exporter)))))))

View file

@ -63,7 +63,7 @@
;; raster: keeping a reference to it and encoding later would encode
;; the last frame N times, and every frame would be a valid PNG of the
;; wrong picture.
(-> (encode raster ramp)
(-> (encode raster (or (:palette-ramp raster) ramp))
(.then (fn [bytes]
(swap! state update :entries conj
{:name (str name "/" (pad (inc i) digits) ".png")

View file

@ -77,6 +77,13 @@
:parse #(if (seq %) (js/parseInt % 10) nil)
:on-number on-change}]])
(defn- name-field [label value commit!]
[:label.inspector-field label
[:input {:type "text" :default-value value
:key value
:on-key-down #(when (= "Enter" (.-key %)) (.. % -target blur))
:on-blur #(commit! (.. % -target -value))}]])
;; ---------------------------------------------------------------------------
;; the clip
@ -89,12 +96,13 @@
busy? (:busy? project)]
[section "project"
[facts
"name" (or (:name project) (:name clip))
"stage" (str (:width clip) "×" (:height clip))
"open" (some-> open name)
"length" (str frames " frames")
"rate" (str fps " fps")]
[:div.inspector-form
[name-field "name" (or (:name project) (:name clip) "")
#(rf/dispatch [::project/rename %])]
[number-field "width" (:width clip)
#(rf/dispatch [::project/project-setting :width %]) nil busy?]
[number-field "height" (:height clip)
@ -208,7 +216,6 @@
frame (:frame local)]
[section (str (name (:kind n)) " · in " (name sid))
[facts
"name" (or (:name n) (brief id))
"id" (brief id)
;; Which symbol a cel places. The one fact that makes an instance
;; legible as an instance rather than as a node.
@ -217,6 +224,9 @@
;; in this symbol. See `node/placed-span`.
"span" (when start (str start " … " end))
"at" (when (node/mapped-time? n) (str (get-in n [:time :at] 0)))]
[:div.inspector-form
[name-field "name" (or (:name n) "")
#(rf/dispatch [::ui/rename-node sid id %])]]
(when (:paint? n) [drawing-keys sid id n @(rf/subscribe [::sub/selected-local])])
[:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]]
(let [{:keys [frame]} local]
@ -230,6 +240,58 @@
[channel-control sid id path ch frame auto-key?]
[:dd (channel-state ch)])]))])]))
(defn- palette-placement-section [[sid id n]]
(let [clip @(rf/subscribe [::render/clip])
transition (node/source n)
open-frame @(rf/subscribe [::render/open-frame])
[start _] (node/placed-span n)
frame (max 0 (- open-frame (or start 0)))
ch (or (get-in n [:channels [:palette]])
(assoc (channel/framed
(get-in clip [:symbols (node/source n) :palette-ref]))
:semantic :palette))
value (channel/value-at ch frame nil)
chosen (if (map? value) (:from value) value)
keyframes (sort (keys (:keys ch)))
left (when (seq keyframes)
(or (last (filter #(<= % frame) keyframes)) (first keyframes)))
right (when left (first (filter #(< left %) keyframes)))
palettes (sort-by (comp str :name val) (pal/palettes clip))]
[section "palette transition"
[:div.inspector-form
[name-field "name" (clip-domain/symbol-name clip transition)
#(rf/dispatch [::project/rename-symbol transition %])]]
[:div.inspector-form
[:label.inspector-field "palette"
[:span.row
[:button.key {:class (cond (contains? (:keys ch) frame) "on" (:keys ch) "keyed")
:title (if (:keys ch) (str (count (:keys ch)) " keys") "key this here")
:on-click #(rf/dispatch [::project/toggle-palette-key sid id frame])}
"◆"]
[:select {:value (str chosen)
:on-change
(fn [e]
(let [v (.. e -target -value)
palette-id (first (filter #(= v (str %)) (keys (pal/palettes clip))))]
(rf/dispatch [::project/set-palette-choice sid id frame palette-id])))}
(for [[palette-id p] palettes]
^{:key (str palette-id)}
[:option {:value (str palette-id)} (:name p)])]]]
(when right
[:label.inspector-field (str "key " left " → " right)
[segment-select sid id [:palette] ch left]])]]))
(defn- palette-symbol-section [sid]
(let [clip @(rf/subscribe [::render/clip])
sym (get-in clip [:symbols sid])]
[section "palette transition"
[:div.inspector-form
[name-field "name" (clip-domain/symbol-name clip sid)
#(rf/dispatch [::project/rename-symbol sid %])]]
[facts "palette" (or (get-in clip [:palettes (:palette-ref sym) :name])
(:palette-ref sym))]
[:div.dim "Keyframes and cut/tween interpolation are edited on a placement in the palette lane."]]))
;; ---------------------------------------------------------------------------
;; corrections
@ -540,6 +602,8 @@
"stage" (let [[w h] (clip-domain/stage clip sid)] (str w "×" h))
"nodes" (str (count (:nodes sym)))]
[:div.inspector-form
[name-field "name" (clip-domain/symbol-name clip sid)
#(rf/dispatch [::project/rename-symbol sid %])]
[number-field "length (frames)" (:frames sym)
#(rf/dispatch [::project/symbol-setting sid :frames %]) nil busy?]
[number-field "fps" (clip-domain/fps clip sid)
@ -567,28 +631,6 @@
(rf/dispatch [::project/symbol-setting sid :height nil]))}
"use project stage"]])]]))
(defn- symbol-parameters-section [sid frame title]
(let [clip @(rf/subscribe [::render/clip])
auto-key? @(rf/subscribe [::sub/auto-key?])
nodes (get-in clip [:symbols sid :nodes])]
(when (and sid (number? frame) (seq nodes))
(into
[section title]
(for [[id n] (sort-by (comp str key) nodes)
:let [editable (filter (fn [[path ch]]
(and (contains? (node/defaults-of n) path)
(not (:dense ch))))
(node/channels n))]
:when (seq editable)]
^{:key (str id)}
(into
[:div.inspector-form
[:div.row [:span.dim (or (:name n) (brief id))]]]
(for [[path ch] editable]
^{:key (str path)}
[:label.inspector-field (str/join " " (map name path))
[channel-control sid id path ch frame auto-key?]])))))))
;; ---------------------------------------------------------------------------
;; tracked objects
;;
@ -692,6 +734,10 @@
;; lane traces is not a question with one answer, and naming the drawing
;; showing now would move the section under the playhead.
placed (node/source (peek node))
palette-placement? (and node
(= :palette (get-in clip [:symbols placed :type])))
palette-symbol? (and (= :symbol (first selection))
(= :palette (get-in clip [:symbols (second selection) :type])))
face (or placed (when (trace/traceable? clip open) open))
faces (when face (trace/faces clip face))
;; Every face the open symbol has, which is what the footage switch is
@ -706,23 +752,19 @@
[:section.pane.params
[:div.pane-head "inspector"]
[:div {:style {:min-height 0}}
[clip-section]
(when node [node-section node])
(when placed [symbol-section placed "source symbol"])
(when placed
(let [{source-sid :sid source-frame :frame} @(rf/subscribe [::sub/selected-local])]
(when (= placed source-sid)
[symbol-parameters-section placed source-frame "source parameters"])))
(when node ^{:key (str (first node) "/" (second node))}
(if palette-placement?
[palette-placement-section node]
[clip-section])
(when (and node (not palette-placement?)) [node-section node])
(when (and placed (not palette-placement?)) [symbol-section placed "source symbol"])
(when (and node (not palette-placement?)) ^{:key (str (first node) "/" (second node))}
[correction-section node])
(when (seq here) [footage-section here])
(when (and (not palette-placement?) (seq here)) [footage-section here])
(when (and face (or (trace/traceable? clip face) (seq faces)))
[tracing-section face faces path])
(when face ^{:key (str "perf/" face)} [performance-section face])
(when (= :symbol (first selection)) [symbol-section (second selection)])
(when (= :symbol (first selection))
(let [sid (second selection)
frame (min (max 0 @(rf/subscribe [::render/open-frame]))
(max 0 (dec (or (clip-domain/frames clip sid) 1))))]
[symbol-parameters-section sid frame "symbol parameters"]))
(when tracked? [tracking-section])]]))
(when palette-symbol? [palette-symbol-section (second selection)])
(when (and (= :symbol (first selection)) (not palette-symbol?))
[symbol-section (second selection)])
(when (and tracked? (not palette-placement?) (not palette-symbol?))
[tracking-section])]]))

View file

@ -172,15 +172,16 @@
;; THE STAGE IS THE CLIP'S, not a constant. Project dimensions are
;; independent of the footage, so the size the frame is rasterised at comes
;; out of the document like everything else.
(let [ras (raster-for width height)]
(let [ops (resolver f)
bg (pal/background-index palette (clip/active-palette resolver))]
(swap! state assoc :ops ops)
(-> ras
(raster/clear! bg)
(raster/draw-ops! ops)))
(let [ras (raster-for width height)
ops (resolver f)
active (clip/active-palette resolver)
bg (pal/background-index palette active)]
(swap! state assoc :ops ops)
(-> ras
(raster/clear! bg)
(raster/draw-ops! ops))
(js/performance.mark "arthur/blit:start")
(canvas/blit! canvas ras ramp)
(canvas/blit! canvas ras (pal/effective-ramp palette active))
(underlay/paint! (assoc underlay :width width :playing? playing?)
resolver repaint!))
(js/performance.measure "arthur/resolve+draw" "arthur/paint:start" "arthur/blit:start")

View file

@ -290,6 +290,22 @@
:on-double-click #(rf/dispatch [::project/default-palette id])}
(carrying (str "palette:" id) #(drag/palette! id (:name p))))])
(defn- palette-transition-row [document sid selection rename]
(let [sym (clip/symbol document sid)
p (get (pal/palettes document) (:palette-ref sym))]
^{:key (str sid)}
[row {:label (clip/symbol-name document sid)
:sub (str "uses " (or (:name p) (:palette-ref sym)))
:title "Palette transition definition"
:on? (= selection [:symbol sid])
:rename (assoc rename
:key [:symbol sid]
:value (clip/symbol-name document sid)
:commit! (fn [value]
((:begin! rename) nil)
(rf/dispatch [::project/rename-symbol sid value])))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])}]))
(defn- footage-row [{:keys [id label frames fps video] :as f} chosen rename]
^{:key id}
[row (merge {:label label
@ -403,23 +419,32 @@
Still not `:main` being special. The document says which symbol that is by its
structure; rename it, place it inside something else, and the pool follows."
[{:keys [document query searching? media sounds chosen rename main palette-choice] :as ctx}]
[{:keys [document query searching? media sounds chosen rename main palette-choice selection] :as ctx}]
(let [named? #(hit? query (clip/symbol-name document %))
top (when (and main (named? main)) main)
rest (filterv #(and (named? %) (not= main %))
(sort-by str (keys (:symbols document))))
symbol-ids (sort-by str (keys (:symbols document)))
transition? #(= :palette (:type (clip/symbol document %)))
transitions (filterv #(and (named? %) (transition? %)) symbol-ids)
rest (filterv #(and (named? %) (not= main %)
(not (#{:palette :palette-track}
(:type (clip/symbol document %)))))
symbol-ids)
media (filterv #(hit? query (:label %)) media)
sounds (filterv #(hit? query (:label %)) sounds)
palettes (filterv #(hit? query (:name (val %)))
(sort-by (comp str :name val) (pal/palettes document)))]
[sections searching?
(+ (if top 1 0) (count rest) (count media) (count sounds) (count palettes))
(+ (if top 1 0) (count rest) (count transitions)
(count media) (count sounds) (count palettes))
[{:title "project" :searching? searching?
:blank "nothing to open yet"
:rows (when top [(symbol-row document top ctx)])}
{:title "symbols" :searching? searching?
:blank "nothing else in the library"
:rows (mapv #(symbol-row document % ctx) rest)}
{:title "palette transitions" :searching? searching?
:blank "no palette transitions"
:rows (mapv #(palette-transition-row document % selection rename) transitions)}
{:title "palettes" :searching? searching?
:blank "no palettes"
:rows (mapv (fn [[id p]] (palette-row id p (:default-palette document)

View file

@ -10,6 +10,7 @@
THE CANVAS IS THE RASTER'S OWN SIZE, scaled by CSS. See `ui/canvas` for why
that is load-bearing rather than convenient."
(:require [arthur.domain.channel :as channel]
[arthur.domain.clip :as clip]
[arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
@ -473,6 +474,15 @@
;; A drop lands at the playhead IN THE OPEN SYMBOL's frames, the same
;; unit a drop on the timeline's ruler lands in.
frame @(rf/subscribe [::render/open-frame])
clip @(rf/subscribe [::render/clip])
clip-id @(rf/subscribe [::render/clip-id])
db-target @(rf/subscribe [::sub/target])
open @(rf/subscribe [::render/open])
destination (when clip
(ui/aimed-symbol clip (:store (store/entry clip-id))
{:ui {:open open :target db-target}} frame))
destination-name (when-let [sid (:sid destination)]
(clip/symbol-name clip sid))
zoom @(rf/subscribe [::layout/zoom :stage])]
[:div.stage-area
[:div.stage-wrap
@ -498,4 +508,6 @@
:height (str (* zoom h) "px")}}]
[:canvas.underlay {:ref #(underlay/set-canvas! %)
:width (* zoom w) :height (* zoom h)}]
[overlay w h zoom]]]))
[overlay w h zoom]]
(when destination-name
[:div.stage-target "creating in " [:strong destination-name]])]))

View file

@ -339,6 +339,7 @@
(-> (first (rows clip track-id expanded chosen))
(assoc :path [::palette-track] :owner track-id
:label "palette" :kind :palette
:expandable? false :expanded? false
:select [::palette-track sid]))
{:path [::palette-track] :depth 0 :label "palette" :kind :palette
:owner nil :select [::palette-track sid]
@ -730,7 +731,11 @@
active-row (:row @sliding)
slide (fn [^js e]
(let [{from :row pressed :from} @sliding]
(when (= path from)
;; Pointer capture already makes this the one track receiving
;; the gesture. Do not compare its presentation path: the
;; palette lane is a view of another symbol and its synthetic
;; row address is not the authored clip path.
(when from
;; HOW MANY FRAMES THE POINTER HAS MOVED, as the difference
;; of two frames under it -- not a pixel distance scaled and
;; rounded by itself. The ghost below and the command on
@ -779,7 +784,14 @@
target-sid (when target
(:sid (ui/drop-destination-at
clip store open target-at target)))
;; Row identity answers before document addressing:
;; remaining on the DOM track that captured the
;; pointer is a slide. This is true for every lane
;; and avoids asking special rows (such as the
;; palette track) to counterfeit an ordinary target
;; address merely to recognize themselves.
landing? (and target-sid
(not (identical? target-el (.-currentTarget e)))
(not= target-sid (nth (:selection drag) 1)))
target-frame (when landing?
(max 0 (- target-at (:grab drag))))]
@ -814,7 +826,7 @@
(:kind @sliding) (:ripple? @sliding)
(:other @sliding) owner]))))))))
done (fn [commit?]
(when (= path (:row @sliding))
(when @sliding
(let [{:keys [path df kind ripple? other target-lane target-frame
drag nest on-click more?]} @sliding]
(reset! sliding nil)
@ -880,11 +892,18 @@
;; one, else this row's own instance. It opens as a tab, which is what
;; double-clicking the same symbol in the pool does.
:on-double-click (fn [^js e]
(when-let [source (or (:source (clip-under e)) of)]
(when-let [source (and (not= :palette kind)
(or (:source (clip-under e)) of))]
(.stopPropagation e)
(rf/dispatch [::pb/open-symbol source])))
:ref (when lane? (fn [el] (when el (aset el "arthurLane" select))))
:on-drag-enter (fn [^js e]
;; A palette hover belongs only to the palette row. If
;; the pointer leaves it for an ordinary row, clear its
;; last accepted preview even though this row correctly
;; declines the native drop.
(when (and (drag/palette?) (not= :palette kind))
(rf/dispatch [::ui/drop-clear]))
(cond
(and (= :palette kind) (drag/palette?))
(do (.preventDefault e) (.stopPropagation e))
@ -947,6 +966,9 @@
:on-pointer-down
(when (and select (not unmapped?))
#(begin! % (or slides path) :slide select nil nil))}
(when (and select (not unmapped?))
[:span.tl-edge.in {:title "Drag start"
:on-pointer-down #(begin! % path :in select nil nil)}])
(when (and select (not unmapped?))
[:span.tl-edge.out {:title "Drag endpoint · Shift-drag ripples later clips"
:on-pointer-down #(begin! % path :out select nil nil)}])]))
@ -966,8 +988,10 @@
:when (< in out)]
^{:key (str id)}
[:button.tl-cel
{:title (str label " · select clip; double-click to edit its symbol"
" · shift-drag another clip onto it to nest that clip inside")
{:title (if (= :palette kind)
(str label " · select palette transition")
(str label " · select clip; double-click to edit its symbol"
" · shift-drag another clip onto it to nest that clip inside"))
;; WHICH CLIP IS SELECTED IS THE QUESTION THIS ROW ANSWERS. An
;; expanded lane opens exactly the selected clip, so a block that
;; cannot say whether it is the one leaves the portal below it
@ -1114,19 +1138,19 @@
{:id ::drop :label (str "+ " (:label drop)) :ghost? true
:span drop-span})
chosen (when (= :node (first selection)) (nth selection 3))
picture (cond->> (cond->> (into [(palette-track-row clip open frames expanded chosen)]
(rows clip open expanded chosen))
lane-ghost
(mapv (fn [row]
(if (= drop-lane (:select row))
(update row :cels (fnil conj []) lane-ghost)
row))))
picture (cond-> (cond->> (into [(palette-track-row clip open frames expanded chosen)]
(rows clip open expanded chosen))
lane-ghost
(mapv (fn [row]
(if (= drop-lane (:select row))
(update row :cels (fnil conj []) lane-ghost)
row))))
;; An untargeted visual drop becomes a new ordinary row
;; at the bottom. Inserting it above the lanes shifts the
;; lane under the pointer and makes the browser alternate
;; between this ghost and that lane's drop target.
(and ghost (not (:sound? drop)))
(fn [rows] (conj (vec rows) ghost)))
(conj ghost))
sounds (cond->> (into (vec (filter :sound? picture))
(sound-rows clip open expanded))
(and ghost (:sound? drop)) (cons ghost))

View file

@ -384,6 +384,14 @@
(is (seq (ch/problems (ch/keyed {0 :a, 10 :b} :linear)))
"a cut or tone cannot interpolate"))
(deftest palette-choice-channels-can-cut-or-blend
(let [held (assoc (ch/keyed {0 :day 10 :night} :hold) :semantic :palette)
blend (assoc held :interp :linear)]
(is (empty? (ch/problems blend)))
(is (= :day (ch/value-at held 5 nil)))
(is (= {:from :day :to :night :t 0.5}
(ch/value-at blend 5 nil)))))
(deftest numeric-channels-can-ramp-between-keys
(let [c (ch/keyed {0 0.0, 10 1.0} :linear)
cursor (ch/cursor c nil)]

View file

@ -4,6 +4,7 @@
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.paint :as paint]
[arthur.domain.pose :as pose]
@ -362,3 +363,46 @@
(is (= :night (clip/active-palette main)))
(is (= (pal/render-index context :night 0)
(pal/background-index context (clip/active-palette main))))))
(deftest a-palette-lane-can-blend-between-keyed-palettes
(let [palette (fn [id color]
{:id id :name (name id) :slots [{:hex color} {:hex color}]})
mark {:id :mark :kind :rect :z "a1"
:channels {[:geom :size] (ch/framed 2)
[:style :color] (ch/framed 1)}}
choice (assoc (ch/keyed {0 :day 4 :night} :linear) :semantic :palette)
document {:fps 30 :width 20 :height 20
:palettes {:day (palette :day "#000000")
:night (palette :night "#ffffff")}
:default-palette :day
:symbols
{:main {:id :main :frames 5 :palette-track :palettes
:nodes {:drawing {:id :drawing :kind :instance :z "a1"
:source {:symbol :drawing}}}}
:palettes {:id :palettes :type :palette-track :display :lane :frames 5
:nodes {:change {:id :change :kind :instance :z "a1"
:source {:symbol :day-palette}
:span [0 5] :time {:at 0}
:channels {[:palette] choice}}}}
:day-palette {:id :day-palette :type :palette :palette-ref :day
:frames 1 :nodes {}}
:drawing {:id :drawing :frames 5 :nodes {:mark mark}}}}
context (pal/compile document)
resolve (clip/resolver document :main nil context nil)
slid (nest/slide document :palettes [:change] 1)
trimmed (nest/resize-in document :palettes [:change] 1)
extended (nest/resize-out document :palettes [:change] 1 false)]
(resolve 2)
(is (= {:from :day :to :night :t 0.5} (clip/active-palette resolve)))
(is (= [128 128 128]
(nth (pal/effective-ramp context (clip/active-palette resolve))
(pal/render-index context :day 1))))
(is (nil? (:refused slid)) (:refused slid))
(is (= [1 6] (node/placed-span
(get-in slid [:clip :symbols :palettes :nodes :change]))))
(is (nil? (:refused trimmed)) (:refused trimmed))
(is (= [1 5] (node/placed-span
(get-in trimmed [:clip :symbols :palettes :nodes :change]))))
(is (nil? (:refused extended)) (:refused extended))
(is (= [0 6] (node/placed-span
(get-in extended [:clip :symbols :palettes :nodes :change]))))))

View file

@ -52,6 +52,13 @@
(is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])))))
(deftest clearing-selection-also-clears-the-creation-target
(let [db {:ui {:selection [:symbol :drawing]
:target {:sid :main :id :lane :path [:lane]}}}
after (ui/selected db nil)]
(is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])))))
(deftest an-explicit-lane-is-one-row-of-clips
(let [doc (fixture/document)
rows (timeline/rows doc :main #{})
@ -206,6 +213,14 @@
(is (= address (get-in after [:ui :selection])))
(is (= [address] (get-in after [:ui :selections])))))
(deftest selecting-or-trimming-a-nested-lane-item-does-not-open-its-row
(let [selection [:node :inner :clip [:lane :clip]]
before {:ui {:expanded #{[:already-open]}}}
after (ui/selected before selection)]
(is (= selection (get-in after [:ui :selection])))
(is (= #{[:already-open]} (get-in after [:ui :expanded]))
"disclosure is changed only by the twist control")))
(deftest finishing-a-polygon-opens-no-rows
;; Expansion is the twist triangle's business. Finishing a shape used to open
;; every row down to it, which inside a lane meant tearing its one row into a

View file

@ -841,16 +841,31 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.stage-area {
flex: 1;
min-height: 0;
display: grid;
/* `safe`, because the stage zooms: a centred grid item larger than its scroll
display: flex;
flex-direction: column;
/* `safe`, because the stage zooms: a centred item larger than its scroll
container puts its top-left edge out of reach on the start side, which at
8x is most of the picture. Safe alignment falls back to start when it does
not fit, and centring is only ever about where a SMALL stage sits. */
place-items: safe center;
align-items: safe center;
justify-content: safe center;
overflow: auto;
padding: 14px;
}
.stage-target {
flex: 0 0 auto;
margin-top: -1px;
padding: 3px 9px 4px;
border: 1px solid var(--line);
border-top: 0;
border-radius: 0 0 3px 3px;
background: var(--pane);
color: var(--dim);
}
.stage-target strong { color: var(--fg); font-weight: 600; }
/* The stage sits ON the work area rather than filling it, as it does in every
tool of this shape: the picture has edges and they are part of what you are
judging. */