Add auto-key mode to main toolbar

This commit is contained in:
Your Name 2026-10-01 12:22:30 -04:00
parent 3879d76d57
commit 34f62ab007
11 changed files with 82 additions and 17 deletions

View file

@ -162,6 +162,7 @@
:time-view :timeline
:tone :skin-base
:tool nil
:auto-key? false
:draft []
:knobs {}
:trace {:faces #{} :opacity trace/opacity-default}

View file

@ -7,10 +7,10 @@
is in the placement's matrices, so a shape five symbols down moves under the
pointer like one on top.
THE KEYING RULE IS `node/set-channel`, the inspector's: a channel with keys
gets one on the node's own frame, and one without has its one value changed.
After Effects' stopwatch — there is no mode to be in, and nothing snaps back
on the next frame as an unkeyed change does in Blender."
The normal keying rule is `node/set-channel`, the inspector's: a channel with
keys gets one on the node's own frame, and one without has its one value
changed. Auto-key deliberately replaces that rule with `set-keyed-channel`,
so touching an otherwise static transform starts its animation at this frame."
(:require [arthur.domain.channel :as ch]
[arthur.domain.node :as node]))
@ -78,7 +78,10 @@
(defn apply-values
"Clip with channel values `vs`, `{path value}`, written into node `id` of
symbol `sid` on the node's own frame `f`, by the keying rule above."
[clip sid id f vs]
(update-in clip [:symbols sid :nodes id]
#(reduce-kv (fn [n path v] (node/set-channel n path f v)) % vs)))
symbol `sid` on the node's own frame `f`, by the keying rule above. The sixth
argument arms auto-key; the five-argument form retains the normal rule."
([clip sid id f vs] (apply-values clip sid id f vs false))
([clip sid id f vs auto-key?]
(let [put (if auto-key? node/set-keyed-channel node/set-channel)]
(update-in clip [:symbols sid :nodes id]
#(reduce-kv (fn [n path v] (put n path f v)) % vs)))))

View file

@ -104,6 +104,17 @@
(assoc-in n [:channels path]
(if (:keys c) (assoc-in c [:keys f] v) (ch/framed v)))))
(defn set-keyed-channel
"Write `v` as a key at `f`, starting an animated channel when needed. This is
the auto-key counterpart to `set-channel`; an existing channel keeps its
interpolation and segment choices."
[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/keyed {f v} (if (boolean? v) :hold :linear))))))
(defn toggle-key
"Key channel `path` on the node's own frame `f` with the value it has there, or
take the key there off. The first key starts the channel animating and taking

View file

@ -635,7 +635,8 @@
::set-channel
;; `frame` is the node's own, as for a drawing key.
(fn [db [_ sid id path frame value]]
(edit/edit db #(update-in % [:symbols sid :nodes id] node/set-channel path frame value))))
(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)))))
(rf/reg-event-db
::toggle-key

View file

@ -621,6 +621,11 @@
(fn [db [_ scope id knob value]]
(assoc-in db [:ui :knobs [scope id knob]] value)))
(rf/reg-event-db
::toggle-auto-key
(fn [db _]
(update-in db [:ui :auto-key?] not)))
;; ---------------------------------------------------------------------------
;; a new symbol
@ -754,14 +759,19 @@
;; A transform in the middle of a drag on the stage, `{:sid :id :frame
;; :values}`, drawn by `::render/clip` as `::sliding` is; nil when abandoned.
(fn [db [_ g]]
(if g (assoc-in db [:ui :gesture] g) (update db :ui dissoc :gesture))))
(if g
(assoc-in db [:ui :gesture]
(assoc g :auto-key? (boolean (get-in db [:ui :auto-key?]))))
(update db :ui dissoc :gesture))))
(rf/reg-event-db
::transform
;; The drag let go: one edit, so one undo step and one write to collaborators.
(fn [db [_ {:keys [sid id frame values]}]]
(cond-> (update db :ui dissoc :gesture)
(seq values) (edit/edit #(gesture/apply-values % sid id frame values)))))
(seq values) (edit/edit #(gesture/apply-values
% sid id frame values
(boolean (get-in db [:ui :auto-key?])))))))
(rf/reg-event-db
::delete-selected

View file

@ -46,8 +46,8 @@
(let [c (:clip (footage/entry id))]
(or (when-let [{:keys [path df]} sliding]
(:clip (nest/slide c open path df)))
(when-let [{:keys [sid id frame values]} gesture]
(when c (gesture/apply-values c sid id frame values)))
(when-let [{:keys [sid id frame values auto-key?]} gesture]
(when c (gesture/apply-values c sid id frame values auto-key?)))
c))))
(rf/reg-sub

View file

@ -17,6 +17,7 @@
(rf/reg-sub ::lane-retry (fn [db _] (get-in db [:ui :lane-retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool])))
(rf/reg-sub ::auto-key? (fn [db _] (boolean (get-in db [:ui :auto-key?]))))
(rf/reg-sub ::draft (fn [db _] (get-in db [:ui :draft])))
(rf/reg-sub ::convert (fn [db _] (get-in db [:ui :convert])))
(rf/reg-sub ::drop (fn [db _] (get-in db [:ui :drop])))

View file

@ -42,11 +42,19 @@
(defn bar []
(let [tone @(rf/subscribe [::sub/tone])
tool @(rf/subscribe [::sub/tool])
auto? @(rf/subscribe [::sub/auto-key?])
draft @(rf/subscribe [::sub/draft])]
[:div.palette-bar
[:div.swatches (doall (map #(swatch % tone) (range slots)))]
[:span.dim (name tone)]
[:span {:style {:flex 1}}]
[:button.auto-key {:class (when auto? "on")
:aria-pressed auto?
:title (if auto?
"auto-key is on — edited parameters get a key at this frame"
"auto-key edited parameters at this frame")
:on-click #(rf/dispatch [::ui/toggle-auto-key])}
"◆ auto key"]
(if (= :polygon tool)
[:<>
[:span.dim (str (quot (count draft) 2) " points")]

View file

@ -167,7 +167,7 @@
;; Rotation is shown in degrees. Between two keys, the gap after the one here
;; holds or tweens, as a drawing's does.
(defn- channel-control [sid id path ch frame]
(defn- channel-control [sid id path ch frame auto-key?]
(let [keyed? (some? (:keys ch))
;; No store: the call site below hands this only channels that are not
;; `:dense`, which are the only ones with anything in tier 2 to read.
@ -184,7 +184,7 @@
:value (if deg? (/ (js/Math.round (* x (/ 18000 js/Math.PI))) 100) x)
:parse js/parseFloat
:on-number #(on-number (if deg? (* % (/ js/Math.PI 180)) %))}])]
[:dd.channel
[:dd.channel {:class (when auto-key? "live")}
[:button.key {:class (cond (contains? (:keys ch) frame) "on" keyed? "keyed")
:disabled (nil? frame)
:title (if keyed? (str (count (:keys ch)) " keys") "key this here")
@ -199,7 +199,8 @@
(when gap? [segment-select sid id path ch left])]))
(defn- node-section [[sid id n]]
(let [[start end] (:span n)]
(let [[start end] (:span n)
auto-key? @(rf/subscribe [::sub/auto-key?])]
[section (str (name (:kind n)) " · in " (name sid))
[facts
"name" (or (:name n) (brief id))
@ -221,7 +222,7 @@
[:<>
[:dt (str/join " " (map name path))]
(if (and (contains? (node/defaults-of n) path) (not (:dense ch)))
[channel-control sid id path ch frame]
[channel-control sid id path ch frame auto-key?]
[:dd (channel-state ch)])]))])]))
;; ---------------------------------------------------------------------------

View file

@ -216,3 +216,13 @@
(let [d (node/toggle-key (node/set-segment-interp c [:xform :pos] 0 :hold) [:xform :pos] 0 nil)]
(is (not (contains? (get-in d [:channels [:xform :pos] :segments]) 0))
"taking a key off takes its gap's choice with it"))))
(deftest auto-keying-a-channel-from-a-parameter-edit
(let [n {:id :x :kind :group}
keyed (node/set-keyed-channel n [:xform :rot] 7 1.25)
moved (node/set-keyed-channel keyed [:xform :rot] 11 2.5)
visible (node/set-keyed-channel n [:vis] 7 false)]
(is (= {7 1.25, 11 2.5} (get-in moved [:channels [:xform :rot] :keys])))
(is (= :linear (get-in moved [:channels [:xform :rot] :interp])))
(is (= :hold (get-in visible [:channels [:vis] :interp]))
"boolean parameters do not tween")))

View file

@ -56,6 +56,10 @@
--aim: #8d4bd6;
--aim-stage: #c79bf2;
/* Auto-key is a recording state, distinct from selection and aiming. */
--live: #239447;
--live-bg: #e2f5e7;
/* A keyframe is a dot and a dot is ink. Flash draws them black and so does
this; the frames a node exists over are a pale tint behind them. */
--key: #1f1f1f;
@ -748,6 +752,13 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
border-bottom: 1px solid var(--line);
}
.palette-bar .auto-key.on {
color: #145c2b;
background: var(--live-bg);
border-color: var(--live);
box-shadow: 0 0 7px rgba(35, 148, 71, .72), inset 0 0 0 1px rgba(255, 255, 255, .7);
}
.swatches { display: flex; gap: 3px; }
/* Circles. A palette entry is one indivisible tone, not an area of coverage, and
@ -1283,6 +1294,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.facts dd.channel .key { padding: 0 3px; color: var(--dim); }
.facts dd.channel .key.keyed { color: var(--fg); }
.facts dd.channel .key.on { color: var(--sel); }
.facts dd.channel.live {
margin: -2px;
padding: 2px;
border-radius: 3px;
background: var(--live-bg);
box-shadow: 0 0 0 1px rgba(35, 148, 71, .45), 0 0 6px rgba(35, 148, 71, .28);
}
.facts dd.channel.live input { border-color: var(--live); }
/* The selected node's transform box, in stage pixels (the SVG's viewBox). */
.paint-overlay .handles .box { fill: none; stroke: #e6ca8b; stroke-width: 0.5; stroke-dasharray: 2 1; pointer-events: none; }