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

View file

@ -104,6 +104,17 @@
(assoc-in n [:channels 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) (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 (defn toggle-key
"Key channel `path` on the node's own frame `f` with the value it has there, or "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 take the key there off. The first key starts the channel animating and taking

View file

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

View file

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

View file

@ -46,8 +46,8 @@
(let [c (:clip (footage/entry id))] (let [c (:clip (footage/entry id))]
(or (when-let [{:keys [path df]} sliding] (or (when-let [{:keys [path df]} sliding]
(:clip (nest/slide c open path df))) (:clip (nest/slide c open path df)))
(when-let [{:keys [sid id frame values]} gesture] (when-let [{:keys [sid id frame values auto-key?]} gesture]
(when c (gesture/apply-values c sid id frame values))) (when c (gesture/apply-values c sid id frame values auto-key?)))
c)))) c))))
(rf/reg-sub (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 ::lane-retry (fn [db _] (get-in db [:ui :lane-retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone]))) (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 ::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 ::draft (fn [db _] (get-in db [:ui :draft])))
(rf/reg-sub ::convert (fn [db _] (get-in db [:ui :convert]))) (rf/reg-sub ::convert (fn [db _] (get-in db [:ui :convert])))
(rf/reg-sub ::drop (fn [db _] (get-in db [:ui :drop]))) (rf/reg-sub ::drop (fn [db _] (get-in db [:ui :drop])))

View file

@ -42,11 +42,19 @@
(defn bar [] (defn bar []
(let [tone @(rf/subscribe [::sub/tone]) (let [tone @(rf/subscribe [::sub/tone])
tool @(rf/subscribe [::sub/tool]) tool @(rf/subscribe [::sub/tool])
auto? @(rf/subscribe [::sub/auto-key?])
draft @(rf/subscribe [::sub/draft])] draft @(rf/subscribe [::sub/draft])]
[:div.palette-bar [:div.palette-bar
[:div.swatches (doall (map #(swatch % tone) (range slots)))] [:div.swatches (doall (map #(swatch % tone) (range slots)))]
[:span.dim (name tone)] [:span.dim (name tone)]
[:span {:style {:flex 1}}] [: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) (if (= :polygon tool)
[:<> [:<>
[:span.dim (str (quot (count draft) 2) " points")] [: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 ;; Rotation is shown in degrees. Between two keys, the gap after the one here
;; holds or tweens, as a drawing's does. ;; 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)) (let [keyed? (some? (:keys ch))
;; No store: the call site below hands this only channels that are not ;; 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. ;; `: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) :value (if deg? (/ (js/Math.round (* x (/ 18000 js/Math.PI))) 100) x)
:parse js/parseFloat :parse js/parseFloat
:on-number #(on-number (if deg? (* % (/ js/Math.PI 180)) %))}])] :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") [:button.key {:class (cond (contains? (:keys ch) frame) "on" keyed? "keyed")
:disabled (nil? frame) :disabled (nil? frame)
:title (if keyed? (str (count (:keys ch)) " keys") "key this here") :title (if keyed? (str (count (:keys ch)) " keys") "key this here")
@ -199,7 +199,8 @@
(when gap? [segment-select sid id path ch left])])) (when gap? [segment-select sid id path ch left])]))
(defn- node-section [[sid id n]] (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)) [section (str (name (:kind n)) " · in " (name sid))
[facts [facts
"name" (or (:name n) (brief id)) "name" (or (:name n) (brief id))
@ -221,7 +222,7 @@
[:<> [:<>
[:dt (str/join " " (map name path))] [:dt (str/join " " (map name path))]
(if (and (contains? (node/defaults-of n) path) (not (:dense ch))) (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)])]))])])) [: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)] (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)) (is (not (contains? (get-in d [:channels [:xform :pos] :segments]) 0))
"taking a key off takes its gap's choice with it")))) "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: #8d4bd6;
--aim-stage: #c79bf2; --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 /* 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. */ this; the frames a node exists over are a pale tint behind them. */
--key: #1f1f1f; --key: #1f1f1f;
@ -748,6 +752,13 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
border-bottom: 1px solid var(--line); 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; } .swatches { display: flex; gap: 3px; }
/* Circles. A palette entry is one indivisible tone, not an area of coverage, and /* 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 { padding: 0 3px; color: var(--dim); }
.facts dd.channel .key.keyed { color: var(--fg); } .facts dd.channel .key.keyed { color: var(--fg); }
.facts dd.channel .key.on { color: var(--sel); } .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). */ /* 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; } .paint-overlay .handles .box { fill: none; stroke: #e6ca8b; stroke-width: 0.5; stroke-dasharray: 2 1; pointer-events: none; }