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)) (if (and (= :linear (segment-interp ch left)) right (>= f left) (> right left))
(let [b (get (:keys ch) right) (let [b (get (:keys ch) right)
t (/ (- f left) (- right left))] 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) (mapv (fn [x y] (+ x (* t (- y x)))) a b)
(+ a (* t (- b a))))) :else (+ a (* t (- b a)))))
a))) a)))
(defn- keyed-at (defn- keyed-at
@ -514,6 +520,7 @@
(let [values (when (map? (:keys ch)) (vals (:keys ch))) (let [values (when (map? (:keys ch)) (vals (:keys ch)))
first-value (first values) first-value (first values)
linear-values? (or (every? number? values) linear-values? (or (every? number? values)
(and (= :palette (:semantic ch)) (every? keyword? values))
(and (vector? first-value) (and (vector? first-value)
(pos? (count first-value)) (pos? (count first-value))
(every? (fn [v] (and (vector? v) (every? (fn [v] (and (vector? v)

View file

@ -280,9 +280,14 @@
(and a (<= a frame) (< frame b)))) (and a (<= a frame) (< frame b))))
(symbol/children (:nodes track-symbol)))) (symbol/children (:nodes track-symbol))))
palette-symbol (some-> palette-clip node/source palette-symbol (some-> palette-clip node/source
(#(symbol clip %)))] (#(symbol clip %)))
(when (= :palette (:type palette-symbol)) fallback (when (= :palette (:type palette-symbol))
(:palette-ref 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 track-value
(channel-value (:palette owner) frame) (channel-value (:palette owner) frame)
inherited inherited

View file

@ -45,7 +45,10 @@
change to this spec silently change what gets drawn." change to this spec silently change what gets drawn."
(let [base (into #{[:vis]} xform-paths)] (let [base (into #{[:vis]} xform-paths)]
{:group base {: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 ;; A sound is not in the picture: no transform, no visibility. After
;; Effects' audio-only layer has no Transform group for the same reason. ;; Effects' audio-only layer has no Transform group for the same reason.
:audio #{[:audio :gain] [:audio :pan] [:audio :rate]} :audio #{[:audio :gain] [:audio :pan] [:audio :rate]}
@ -102,7 +105,9 @@
[n path f v] [n path f v]
(let [c (get (channels n) path)] (let [c (get (channels n) path)]
(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)
(merge (select-keys c [:semantic]) (ch/framed v))))))
(defn set-keyed-channel (defn set-keyed-channel
"Write `v` as a key at `f`, starting an animated channel when needed. This is "Write `v` as a key at `f`, starting an animated channel when needed. This is
@ -113,7 +118,8 @@
(assoc-in n [:channels path] (assoc-in n [:channels path]
(if (:keys c) (if (:keys c)
(assoc-in c [:keys f] v) (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 (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
@ -128,7 +134,8 @@
ks (dissoc (:keys c) f)] ks (dissoc (:keys c) f)]
(assoc-in n [:channels path] (assoc-in n [:channels path]
(cond (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) (not (contains? (:keys c) f)) (assoc-in c [:keys f] v)
(seq ks) (cond-> (assoc c :keys ks) (seq ks) (cond-> (assoc c :keys ks)
(:segments c) (update :segments dissoc f)) (:segments c) (update :segments dissoc f))

View file

@ -85,7 +85,10 @@
(vals (:symbols clip))) (vals (:symbols clip)))
(for [sym (vals (:symbols clip)) (for [sym (vals (:symbols clip))
n (vals (:nodes sym))] 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) ps (select-keys (palettes clip) used)
ordered (sort-by (comp str key) ps) ordered (sort-by (comp str key) ps)
offsets (loop [xs ordered at 0 out {}] offsets (loop [xs ordered at 0 out {}]
@ -105,7 +108,8 @@
(defn render-index (defn render-index
"A local slot (or a legacy tone keyword) in palette `id` -> raster index." "A local slot (or a legacy tone keyword) in palette `id` -> raster index."
[{:keys [palettes offsets]} id value] [{: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))] i (and p (slot-index p value))]
(if (some? i) (+ (get offsets id 0) i) 255))) (if (some? i) (+ (get offsets id 0) i) 255)))
@ -119,6 +123,26 @@
(render-index palette active 0) (render-index palette active 0)
(get palette :bg 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]}] (defn valid-palette? [{:keys [id name slots]}]
(and id (string? name) (seq name) (vector? slots) (pos? (count slots)) (and id (string? name) (seq name) (vector? slots) (pos? (count slots))
(<= (count slots) 255) (<= (count slots) 255)

View file

@ -751,7 +751,12 @@
:frames 1 :fps (clip/fps document root-sid) :nodes {}})) :frames 1 :fps (clip/fps document root-sid) :nodes {}}))
id (random-uuid) id (random-uuid)
result (span/place-symbol document st track-id id palette-sid frame 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)] (if-let [why (:refused result)]
(update db :project merge {:status why}) (update db :project merge {:status why})
(-> db (-> db
@ -765,6 +770,34 @@
(let [put (if (get-in db [:ui :auto-key?]) node/set-keyed-channel node/set-channel)] (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))))) (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 (rf/reg-event-db
::set-channels ::set-channels
;; One property assignment over a stage selection is one document edit. ;; One property assignment over a stage selection is one document edit.

View file

@ -63,26 +63,16 @@
(defn selected (defn selected
"`db` with `selection` selected, and nothing aimed. "`db` with `selection` selected, and nothing aimed.
The rows above a selection are opened, so one made deep on the stage is seen Selection and row disclosure are independent. A click or trim can select a
in the timeline. Not a sound's: its row is always in the audio section, and nested item, but only the row's twist button opens or closes it."
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?`."
[db selection] [db selection]
(let [[kind sid id path] selection ;; A captured trim gesture also selects on pointer-up. It must not acquire the
sound? (= :audio (get-in (store/entry (:clip/current db)) ;; unrelated side effect of opening every row above the edited clip.
[:clip :symbols sid :nodes id :kind]))] (cond-> (-> db
(cond-> (-> db (assoc-in [:ui :selection] selection)
(assoc-in [:ui :selection] selection) (assoc-in [:ui :selections] (if selection [selection] []))
(assoc-in [:ui :selections] (if selection [selection] [])) (update :ui dissoc :points :retry))
(update :ui dissoc :points :retry)) (nil? selection) (assoc-in [:ui :target] nil)))
(and (= :node kind) (seq path) (not sound?))
(update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path)))))))
(defn target-of (defn target-of
"The target `selection` names: `{:sid :id :path}`, or nil for the open symbol "The target `selection` names: `{:sid :id :path}`, or nil for the open symbol
@ -487,8 +477,14 @@
question `symbol/lane?` answers." question `symbol/lane?` answers."
[clip st db frame] [clip st db frame]
(let [open (get-in db [:ui :open]) (let [open (get-in db [:ui :open])
down (where-new-goes clip db)] down (where-new-goes clip db)
(assoc (nest/inside clip st open down frame) :path down))) 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 ;; clipboard and local duplication
@ -562,7 +558,7 @@
{:sid open :path [] :frame frame} {:sid open :path [] :frame frame}
(nil? n) (nil? n)
{:refused "the paste target no longer exists"} {:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}
(= :instance (:kind n)) (= :instance (:kind n))
(let [visible (nest/inside clip st open path frame) (let [visible (nest/inside clip st open path frame)
@ -576,12 +572,12 @@
at (or (:frame visible) (:frame shown) mapped held)] at (or (:frame visible) (:frame shown) mapped held)]
(if (number? at) (if (number? at)
{:sid (node/source n) :path (vec path) :frame (js/Math.floor 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 :else
(if (number? owner-frame) (if (number? owner-frame)
{:sid sid :path (vec (butlast path)) :frame (js/Math.floor 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 (rf/reg-event-db
::paste ::paste

View file

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

View file

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

View file

@ -77,6 +77,13 @@
:parse #(if (seq %) (js/parseInt % 10) nil) :parse #(if (seq %) (js/parseInt % 10) nil)
:on-number on-change}]]) :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 ;; the clip
@ -89,12 +96,13 @@
busy? (:busy? project)] busy? (:busy? project)]
[section "project" [section "project"
[facts [facts
"name" (or (:name project) (:name clip))
"stage" (str (:width clip) "×" (:height clip)) "stage" (str (:width clip) "×" (:height clip))
"open" (some-> open name) "open" (some-> open name)
"length" (str frames " frames") "length" (str frames " frames")
"rate" (str fps " fps")] "rate" (str fps " fps")]
[:div.inspector-form [:div.inspector-form
[name-field "name" (or (:name project) (:name clip) "")
#(rf/dispatch [::project/rename %])]
[number-field "width" (:width clip) [number-field "width" (:width clip)
#(rf/dispatch [::project/project-setting :width %]) nil busy?] #(rf/dispatch [::project/project-setting :width %]) nil busy?]
[number-field "height" (:height clip) [number-field "height" (:height clip)
@ -208,7 +216,6 @@
frame (:frame local)] frame (:frame local)]
[section (str (name (:kind n)) " · in " (name sid)) [section (str (name (:kind n)) " · in " (name sid))
[facts [facts
"name" (or (:name n) (brief id))
"id" (brief id) "id" (brief id)
;; Which symbol a cel places. The one fact that makes an instance ;; Which symbol a cel places. The one fact that makes an instance
;; legible as an instance rather than as a node. ;; legible as an instance rather than as a node.
@ -217,6 +224,9 @@
;; in this symbol. See `node/placed-span`. ;; in this symbol. See `node/placed-span`.
"span" (when start (str start " … " end)) "span" (when start (str start " … " end))
"at" (when (node/mapped-time? n) (str (get-in n [:time :at] 0)))] "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])]) (when (:paint? n) [drawing-keys sid id n @(rf/subscribe [::sub/selected-local])])
[:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]] [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]]
(let [{:keys [frame]} local] (let [{:keys [frame]} local]
@ -230,6 +240,58 @@
[channel-control sid id path ch frame auto-key?] [channel-control sid id path ch frame auto-key?]
[:dd (channel-state ch)])]))])])) [: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 ;; corrections
@ -540,6 +602,8 @@
"stage" (let [[w h] (clip-domain/stage clip sid)] (str w "×" h)) "stage" (let [[w h] (clip-domain/stage clip sid)] (str w "×" h))
"nodes" (str (count (:nodes sym)))] "nodes" (str (count (:nodes sym)))]
[:div.inspector-form [:div.inspector-form
[name-field "name" (clip-domain/symbol-name clip sid)
#(rf/dispatch [::project/rename-symbol sid %])]
[number-field "length (frames)" (:frames sym) [number-field "length (frames)" (:frames sym)
#(rf/dispatch [::project/symbol-setting sid :frames %]) nil busy?] #(rf/dispatch [::project/symbol-setting sid :frames %]) nil busy?]
[number-field "fps" (clip-domain/fps clip sid) [number-field "fps" (clip-domain/fps clip sid)
@ -567,28 +631,6 @@
(rf/dispatch [::project/symbol-setting sid :height nil]))} (rf/dispatch [::project/symbol-setting sid :height nil]))}
"use project stage"]])]])) "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 ;; tracked objects
;; ;;
@ -692,6 +734,10 @@
;; lane traces is not a question with one answer, and naming the drawing ;; lane traces is not a question with one answer, and naming the drawing
;; showing now would move the section under the playhead. ;; showing now would move the section under the playhead.
placed (node/source (peek node)) 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)) face (or placed (when (trace/traceable? clip open) open))
faces (when face (trace/faces clip face)) faces (when face (trace/faces clip face))
;; Every face the open symbol has, which is what the footage switch is ;; Every face the open symbol has, which is what the footage switch is
@ -706,23 +752,19 @@
[:section.pane.params [:section.pane.params
[:div.pane-head "inspector"] [:div.pane-head "inspector"]
[:div {:style {:min-height 0}} [:div {:style {:min-height 0}}
[clip-section] (if palette-placement?
(when node [node-section node]) [palette-placement-section node]
(when placed [symbol-section placed "source symbol"]) [clip-section])
(when placed (when (and node (not palette-placement?)) [node-section node])
(let [{source-sid :sid source-frame :frame} @(rf/subscribe [::sub/selected-local])] (when (and placed (not palette-placement?)) [symbol-section placed "source symbol"])
(when (= placed source-sid) (when (and node (not palette-placement?)) ^{:key (str (first node) "/" (second node))}
[symbol-parameters-section placed source-frame "source parameters"])))
(when node ^{:key (str (first node) "/" (second node))}
[correction-section 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))) (when (and face (or (trace/traceable? clip face) (seq faces)))
[tracing-section face faces path]) [tracing-section face faces path])
(when face ^{:key (str "perf/" face)} [performance-section face]) (when face ^{:key (str "perf/" face)} [performance-section face])
(when (= :symbol (first selection)) [symbol-section (second selection)]) (when palette-symbol? [palette-symbol-section (second selection)])
(when (= :symbol (first selection)) (when (and (= :symbol (first selection)) (not palette-symbol?))
(let [sid (second selection) [symbol-section (second selection)])
frame (min (max 0 @(rf/subscribe [::render/open-frame])) (when (and tracked? (not palette-placement?) (not palette-symbol?))
(max 0 (dec (or (clip-domain/frames clip sid) 1))))] [tracking-section])]]))
[symbol-parameters-section sid frame "symbol parameters"]))
(when tracked? [tracking-section])]]))

View file

@ -172,15 +172,16 @@
;; THE STAGE IS THE CLIP'S, not a constant. Project dimensions are ;; 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 ;; independent of the footage, so the size the frame is rasterised at comes
;; out of the document like everything else. ;; out of the document like everything else.
(let [ras (raster-for width height)] (let [ras (raster-for width height)
(let [ops (resolver f) ops (resolver f)
bg (pal/background-index palette (clip/active-palette resolver))] active (clip/active-palette resolver)
(swap! state assoc :ops ops) bg (pal/background-index palette active)]
(-> ras (swap! state assoc :ops ops)
(raster/clear! bg) (-> ras
(raster/draw-ops! ops))) (raster/clear! bg)
(raster/draw-ops! ops))
(js/performance.mark "arthur/blit:start") (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?) (underlay/paint! (assoc underlay :width width :playing? playing?)
resolver repaint!)) resolver repaint!))
(js/performance.measure "arthur/resolve+draw" "arthur/paint:start" "arthur/blit:start") (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])} :on-double-click #(rf/dispatch [::project/default-palette id])}
(carrying (str "palette:" id) #(drag/palette! id (:name p))))]) (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] (defn- footage-row [{:keys [id label frames fps video] :as f} chosen rename]
^{:key id} ^{:key id}
[row (merge {:label label [row (merge {:label label
@ -403,23 +419,32 @@
Still not `:main` being special. The document says which symbol that is by its 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." 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 %)) (let [named? #(hit? query (clip/symbol-name document %))
top (when (and main (named? main)) main) top (when (and main (named? main)) main)
rest (filterv #(and (named? %) (not= main %)) symbol-ids (sort-by str (keys (:symbols document)))
(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) media (filterv #(hit? query (:label %)) media)
sounds (filterv #(hit? query (:label %)) sounds) sounds (filterv #(hit? query (:label %)) sounds)
palettes (filterv #(hit? query (:name (val %))) palettes (filterv #(hit? query (:name (val %)))
(sort-by (comp str :name val) (pal/palettes document)))] (sort-by (comp str :name val) (pal/palettes document)))]
[sections searching? [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? [{:title "project" :searching? searching?
:blank "nothing to open yet" :blank "nothing to open yet"
:rows (when top [(symbol-row document top ctx)])} :rows (when top [(symbol-row document top ctx)])}
{:title "symbols" :searching? searching? {:title "symbols" :searching? searching?
:blank "nothing else in the library" :blank "nothing else in the library"
:rows (mapv #(symbol-row document % ctx) rest)} :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? {:title "palettes" :searching? searching?
:blank "no palettes" :blank "no palettes"
:rows (mapv (fn [[id p]] (palette-row id p (:default-palette document) :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 THE CANVAS IS THE RASTER'S OWN SIZE, scaled by CSS. See `ui/canvas` for why
that is load-bearing rather than convenient." that is load-bearing rather than convenient."
(:require [arthur.domain.channel :as channel] (:require [arthur.domain.channel :as channel]
[arthur.domain.clip :as clip]
[arthur.domain.gesture :as gesture] [arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
@ -473,6 +474,15 @@
;; A drop lands at the playhead IN THE OPEN SYMBOL's frames, the same ;; A drop lands at the playhead IN THE OPEN SYMBOL's frames, the same
;; unit a drop on the timeline's ruler lands in. ;; unit a drop on the timeline's ruler lands in.
frame @(rf/subscribe [::render/open-frame]) 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])] zoom @(rf/subscribe [::layout/zoom :stage])]
[:div.stage-area [:div.stage-area
[:div.stage-wrap [:div.stage-wrap
@ -498,4 +508,6 @@
:height (str (* zoom h) "px")}}] :height (str (* zoom h) "px")}}]
[:canvas.underlay {:ref #(underlay/set-canvas! %) [:canvas.underlay {:ref #(underlay/set-canvas! %)
:width (* zoom w) :height (* zoom h)}] :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)) (-> (first (rows clip track-id expanded chosen))
(assoc :path [::palette-track] :owner track-id (assoc :path [::palette-track] :owner track-id
:label "palette" :kind :palette :label "palette" :kind :palette
:expandable? false :expanded? false
:select [::palette-track sid])) :select [::palette-track sid]))
{:path [::palette-track] :depth 0 :label "palette" :kind :palette {:path [::palette-track] :depth 0 :label "palette" :kind :palette
:owner nil :select [::palette-track sid] :owner nil :select [::palette-track sid]
@ -730,7 +731,11 @@
active-row (:row @sliding) active-row (:row @sliding)
slide (fn [^js e] slide (fn [^js e]
(let [{from :row pressed :from} @sliding] (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 ;; HOW MANY FRAMES THE POINTER HAS MOVED, as the difference
;; of two frames under it -- not a pixel distance scaled and ;; of two frames under it -- not a pixel distance scaled and
;; rounded by itself. The ghost below and the command on ;; rounded by itself. The ghost below and the command on
@ -779,7 +784,14 @@
target-sid (when target target-sid (when target
(:sid (ui/drop-destination-at (:sid (ui/drop-destination-at
clip store open target-at target))) 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 landing? (and target-sid
(not (identical? target-el (.-currentTarget e)))
(not= target-sid (nth (:selection drag) 1))) (not= target-sid (nth (:selection drag) 1)))
target-frame (when landing? target-frame (when landing?
(max 0 (- target-at (:grab drag))))] (max 0 (- target-at (:grab drag))))]
@ -814,7 +826,7 @@
(:kind @sliding) (:ripple? @sliding) (:kind @sliding) (:ripple? @sliding)
(:other @sliding) owner])))))))) (:other @sliding) owner]))))))))
done (fn [commit?] done (fn [commit?]
(when (= path (:row @sliding)) (when @sliding
(let [{:keys [path df kind ripple? other target-lane target-frame (let [{:keys [path df kind ripple? other target-lane target-frame
drag nest on-click more?]} @sliding] drag nest on-click more?]} @sliding]
(reset! sliding nil) (reset! sliding nil)
@ -880,11 +892,18 @@
;; one, else this row's own instance. It opens as a tab, which is what ;; one, else this row's own instance. It opens as a tab, which is what
;; double-clicking the same symbol in the pool does. ;; double-clicking the same symbol in the pool does.
:on-double-click (fn [^js e] :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) (.stopPropagation e)
(rf/dispatch [::pb/open-symbol source]))) (rf/dispatch [::pb/open-symbol source])))
:ref (when lane? (fn [el] (when el (aset el "arthurLane" select)))) :ref (when lane? (fn [el] (when el (aset el "arthurLane" select))))
:on-drag-enter (fn [^js e] :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 (cond
(and (= :palette kind) (drag/palette?)) (and (= :palette kind) (drag/palette?))
(do (.preventDefault e) (.stopPropagation e)) (do (.preventDefault e) (.stopPropagation e))
@ -947,6 +966,9 @@
:on-pointer-down :on-pointer-down
(when (and select (not unmapped?)) (when (and select (not unmapped?))
#(begin! % (or slides path) :slide select nil nil))} #(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?)) (when (and select (not unmapped?))
[:span.tl-edge.out {:title "Drag endpoint · Shift-drag ripples later clips" [:span.tl-edge.out {:title "Drag endpoint · Shift-drag ripples later clips"
:on-pointer-down #(begin! % path :out select nil nil)}])])) :on-pointer-down #(begin! % path :out select nil nil)}])]))
@ -966,8 +988,10 @@
:when (< in out)] :when (< in out)]
^{:key (str id)} ^{:key (str id)}
[:button.tl-cel [:button.tl-cel
{:title (str label " · select clip; double-click to edit its symbol" {:title (if (= :palette kind)
" · shift-drag another clip onto it to nest that clip inside") (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 ;; WHICH CLIP IS SELECTED IS THE QUESTION THIS ROW ANSWERS. An
;; expanded lane opens exactly the selected clip, so a block that ;; expanded lane opens exactly the selected clip, so a block that
;; cannot say whether it is the one leaves the portal below it ;; cannot say whether it is the one leaves the portal below it
@ -1114,19 +1138,19 @@
{:id ::drop :label (str "+ " (:label drop)) :ghost? true {:id ::drop :label (str "+ " (:label drop)) :ghost? true
:span drop-span}) :span drop-span})
chosen (when (= :node (first selection)) (nth selection 3)) chosen (when (= :node (first selection)) (nth selection 3))
picture (cond->> (cond->> (into [(palette-track-row clip open frames expanded chosen)] picture (cond-> (cond->> (into [(palette-track-row clip open frames expanded chosen)]
(rows clip open expanded chosen)) (rows clip open expanded chosen))
lane-ghost lane-ghost
(mapv (fn [row] (mapv (fn [row]
(if (= drop-lane (:select row)) (if (= drop-lane (:select row))
(update row :cels (fnil conj []) lane-ghost) (update row :cels (fnil conj []) lane-ghost)
row)))) row))))
;; An untargeted visual drop becomes a new ordinary row ;; An untargeted visual drop becomes a new ordinary row
;; at the bottom. Inserting it above the lanes shifts the ;; at the bottom. Inserting it above the lanes shifts the
;; lane under the pointer and makes the browser alternate ;; lane under the pointer and makes the browser alternate
;; between this ghost and that lane's drop target. ;; between this ghost and that lane's drop target.
(and ghost (not (:sound? drop))) (and ghost (not (:sound? drop)))
(fn [rows] (conj (vec rows) ghost))) (conj ghost))
sounds (cond->> (into (vec (filter :sound? picture)) sounds (cond->> (into (vec (filter :sound? picture))
(sound-rows clip open expanded)) (sound-rows clip open expanded))
(and ghost (:sound? drop)) (cons ghost)) (and ghost (:sound? drop)) (cons ghost))

View file

@ -384,6 +384,14 @@
(is (seq (ch/problems (ch/keyed {0 :a, 10 :b} :linear))) (is (seq (ch/problems (ch/keyed {0 :a, 10 :b} :linear)))
"a cut or tone cannot interpolate")) "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 (deftest numeric-channels-can-ramp-between-keys
(let [c (ch/keyed {0 0.0, 10 1.0} :linear) (let [c (ch/keyed {0 0.0, 10 1.0} :linear)
cursor (ch/cursor c nil)] cursor (ch/cursor c nil)]

View file

@ -4,6 +4,7 @@
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf] [arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.domain.pose :as pose] [arthur.domain.pose :as pose]
@ -362,3 +363,46 @@
(is (= :night (clip/active-palette main))) (is (= :night (clip/active-palette main)))
(is (= (pal/render-index context :night 0) (is (= (pal/render-index context :night 0)
(pal/background-index context (clip/active-palette main)))))) (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 :selection])))
(is (nil? (get-in after [:ui :target]))))) (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 (deftest an-explicit-lane-is-one-row-of-clips
(let [doc (fixture/document) (let [doc (fixture/document)
rows (timeline/rows doc :main #{}) rows (timeline/rows doc :main #{})
@ -206,6 +213,14 @@
(is (= address (get-in after [:ui :selection]))) (is (= address (get-in after [:ui :selection])))
(is (= [address] (get-in after [:ui :selections]))))) (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 (deftest finishing-a-polygon-opens-no-rows
;; Expansion is the twist triangle's business. Finishing a shape used to open ;; 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 ;; 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 { .stage-area {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; display: flex;
/* `safe`, because the stage zooms: a centred grid item larger than its scroll 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 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 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. */ 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; overflow: auto;
padding: 14px; 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 /* 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 tool of this shape: the picture has edges and they are part of what you are
judging. */ judging. */