Model palette tracks as typed symbol lanes

This commit is contained in:
Your Name 2026-10-02 10:06:05 -04:00
parent dff23d7994
commit 26fab9392e
20 changed files with 371 additions and 123 deletions

View file

@ -0,0 +1,45 @@
"""Schema 4: split animated symbol palettes from the authoring palette.
Before schema 4 a symbol's ``:palette`` leaf value was always a channel. It now
names the static palette used when that symbol is the viewed root, while the
old channel is retained as ``:palette-channel`` compatibility data. New edits
use a real lane symbol referenced by ``:palette-track``.
"""
from django.db import migrations, models
def forwards(apps, schema_editor):
Leaf = apps.get_model("clips", "Leaf")
Project = apps.get_model("clips", "Project")
for leaf in Leaf.objects.filter(path__contains="/symbol/"):
parts = leaf.path.split("/")
if len(parts) != 4 or parts[2] != "symbol":
continue
value = leaf.value
if not (isinstance(value, list) and value[:1] == ["^ "]):
continue
out = list(value)
changed = False
for i in range(1, len(out) - 1, 2):
if out[i] == "~:palette" and isinstance(out[i + 1], list):
out[i] = "~:palette-channel"
changed = True
if changed:
leaf.value = out
leaf.version += 1
leaf.save(update_fields=["value", "version"])
Project.objects.update(schema_version=4)
class Migration(migrations.Migration):
dependencies = [("clips", "0012_sound_label")]
operations = [
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=4),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -235,7 +235,7 @@ class Project(models.Model):
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects", settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
) )
name = models.CharField(max_length=200, default="untitled") name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=3) schema_version = models.PositiveIntegerField(default=4)
seq = models.PositiveBigIntegerField(default=0) seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default") palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)

View file

@ -435,7 +435,7 @@ class DocumentTests(TestCase):
self.assertEqual(5, len(response.json()["written"])) self.assertEqual(5, len(response.json()["written"]))
loaded = self.client.get(f"/api/projects/{self.project.id}").json() loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(3, loaded["schema_version"]) self.assertEqual(4, loaded["schema_version"])
self.assertEqual(1, len(loaded["clips"])) self.assertEqual(1, len(loaded["clips"]))
clip = loaded["clips"][0] clip = loaded["clips"][0]
self.assertEqual("c1", clip["cid"]) self.assertEqual("c1", clip["cid"])

View file

@ -718,28 +718,33 @@ scope does not define resolves to the loud magenta, like any other missing index
### The scope rule ### The scope rule
`:palette` on a timeline is a channel like any other: `:palette-track` points to an ordinary lane symbol. Its clips are instances of
restricted palette symbols: a palette symbol owns no nodes and points at exactly
one project palette.
```clojure ```clojure
{:frames 91 {:id :shot :frames 91 :palette :day :palette-track :shot-palettes :nodes {...}}
:palette {:animated? true :interp :hold :keys {0 :day, 48 :dusk, 72 :night}}
:nodes {...}} {:id :shot-palettes :type :palette-track :display :lane :frames 91
:nodes {:day-clip {:kind :instance :source {:symbol :day-palette} ...}
:dusk-clip {:kind :instance :source {:symbol :dusk-palette} ...}}}
{:id :day-palette :type :palette :palette-ref :day :frames 1 :nodes {}}
``` ```
**Absent means inherit** from the instancing context. **Present means this `:palette` is the symbol's authoring/preview palette. It seeds evaluation only
timeline's content is read in that ramp, and it travels with the timeline** — a when that symbol is the viewed root; nested symbols do not replace the root's
symbol authored against `:night` stays night wherever it is placed. That is choice merely because they were authored under another ramp. When absent, the
lexical scope, and deliberately: a character with their own palette is a project default seeds evaluation.
character, not a decoration of whichever scene they were dropped into.
Composition is the same walk as `:time` — down the instance chain, **innermost Covered clips of the viewed root's palette track override that seed. An
set palette wins**. An enclosing timeline's palette therefore applies to uncovered lane interval is a genuine gap, restoring the authoring palette or
everything inside it that does not set its own, which is adjustment-layer project default. Palette clips use the same trim, roll, slide, claim-time and
behaviour with no adjustment layer in it. It is just scope. undo commands as visual clips; palette code does not duplicate those edits.
Thus palette-track coverage, authoring preview, and project fallback are
And because it is an ordinary channel, a project switches palette over time with separate facts rather than three accidental meanings of one field. There is no
keys on the root timeline, a child timeline switches on its own, and neither second keyed palette control on symbols or instances: time-varying palette
knows about the other. changes are authored only as clips in the palette lane.
### One index space, partitioned by palette ### One index space, partitioned by palette

View file

@ -249,7 +249,40 @@
native node frames and world matrices for the last rendered output frame." native node frames and world matrices for the last rendered output frame."
[clip sid store palette opts] [clip sid store palette opts]
(let [context? (and (map? palette) (:palettes palette) (:offsets palette))] (let [context? (and (map? palette) (:palettes palette) (:offsets palette))]
(letfn [(selection-at [owner frame inherited] (letfn [(channel-value [selection frame]
(cond
(nil? selection) nil
(and (map? selection) (contains? selection :animated?))
(ch/value-at selection frame store)
:else selection))
(root-selection-at [owner frame inherited]
;; A palette track has meaningful uncovered time. Ordinary held
;; channels clamp to their first key before it, but doing that
;; here would erase the gap before the first palette segment.
(let [track (:palette-channel owner)
track-value (if-let [ks (:keys track)]
(some->> (keys ks)
(filter #(<= % frame))
sort last
(get ks))
(channel-value track frame))]
(or (when-let [track-sid (and (keyword? (:palette-track owner))
(:palette-track owner))]
(let [track-symbol (symbol clip track-sid)
palette-clip (first
(filter (fn [n]
(let [[a b] (node/placed-span n)]
(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))))
track-value
(channel-value (:palette owner) frame)
inherited
(:default palette))))
(selection-at [owner frame inherited]
(let [selection (:palette owner) (let [selection (:palette owner)
chosen (cond chosen (cond
(nil? selection) nil (nil? selection) nil
@ -257,7 +290,7 @@
(ch/value-at selection frame store) (ch/value-at selection frame store)
:else selection)] :else selection)]
(or chosen inherited (:default palette)))) (or chosen inherited (:default palette))))
(build [sid chain pose-tracks] (build [sid chain pose-tracks root?]
(when (some #{sid} chain) (when (some #{sid} chain)
(throw (ex-info "symbol cycle" {:chain (conj chain sid)}))) (throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
(let [sym (or (symbol clip sid) (let [sym (or (symbol clip sid)
@ -276,7 +309,7 @@
:when (= :instance (:kind n)) :when (= :instance (:kind n))
child (sort-by str (node/sources n))] child (sort-by str (node/sources n))]
[[id child] (build child (conj chain sid) [[id child] (build child (conj chain sid)
(get-in n [:playback :tracks]))])) (get-in n [:playback :tracks]) false)]))
;; The instances that were on the last frame, and WHICH ;; The instances that were on the last frame, and WHICH
;; drawing each was showing — a row path is read back through ;; drawing each was showing — a row path is read back through
;; the child that was actually resolved, not the only one ;; the child that was actually resolved, not the only one
@ -286,7 +319,10 @@
step (fn [f pre inherited forced] step (fn [f pre inherited forced]
(when context? (when context?
(vreset! active (or forced (vreset! active (or forced
(selection-at sym (js/Math.floor f) inherited)))) (if root?
(root-selection-at sym (js/Math.floor f) inherited)
inherited)
(:default palette))))
(vreset! entered {}) (vreset! entered {})
(let [by-id (into {} (map (juxt :node identity)) (let [by-id (into {} (map (juxt :node identity))
(own (js/Math.floor f) (js/Math.floor pre)))] (own (js/Math.floor f) (js/Math.floor pre)))]
@ -349,7 +385,7 @@
(when (contains? @entered id) (when (contains? @entered id)
(symbol/pre-frame-of (get children [id (get @entered id)]) (vec more))) (symbol/pre-frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/pre-frame-of own id))))))] (symbol/pre-frame-of own id))))))]
(let [r (build sid [] nil) (let [r (build sid [] nil true)
grid (or (:grid-fps opts) (:fps clip)) grid (or (:grid-fps opts) (:fps clip))
native (fps clip sid)] native (fps clip sid)]
(reify (reify

View file

@ -148,7 +148,8 @@
;; disagree with itself about; `clip` puts it back. ;; disagree with itself about; `clip` puts it back.
(for [[sid sym] (:symbols clip)] (for [[sid sym] (:symbols clip)]
{(at "symbol" (segment sid)) {(at "symbol" (segment sid))
(select-keys sym [:name :frames :fps :width :height :palette :display])}) (select-keys sym [:name :frames :fps :width :height :palette
:palette-track :palette-channel :type :palette-ref :display])})
(for [[sid sym] (:symbols clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes sym)] [id n] (:nodes sym)]
{(at "symbol" (segment sid) "node" (segment id)) {(at "symbol" (segment sid) "node" (segment id))

View file

@ -319,6 +319,8 @@
(nil? n) "nothing to move" (nil? n) "nothing to move"
(or (nil? here) (nil? there)) "both have to be on screen at this frame" (or (nil? here) (nil? there)) "both have to be on screen at this frame"
(nil? (:sid there)) "only a symbol can take it" (nil? (:sid there)) "only a symbol can take it"
(span/placement-refusal clip (:sid there) n)
(span/placement-refusal clip (:sid there) n)
(not (and (:time here) (:time there))) (not (and (:time here) (:time there)))
"a held or looping clip has no clock to move through" "a held or looping clip has no clock to move through"
(nil? (some-> there :matrix node/invert)) "the target is scaled to nothing" (nil? (some-> there :matrix node/invert)) "the target is scaled to nothing"

View file

@ -79,6 +79,10 @@
used (into #{(default-palette-id clip)} used (into #{(default-palette-id clip)}
(mapcat selection-values) (mapcat selection-values)
(concat (map :palette (vals (:symbols clip))) (concat (map :palette (vals (:symbols clip)))
(map :palette-channel (vals (:symbols clip)))
(map (fn [sym]
(when (= :palette (:type sym)) (:palette-ref sym)))
(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))))

View file

@ -30,9 +30,8 @@
[arthur.domain.wire :as wire])) [arthur.domain.wire :as wire]))
(def schema-version (def schema-version
"3 stores cel source references and explicit playback clocks. Older "4 separates a symbol's static authoring palette from its palette track."
source-channel documents are unsupported; there is no compatibility conversion." 4)
3)
(defn block-keys (defn block-keys
"Every tier-2 key a leaf map names, in a stable order." "Every tier-2 key a leaf map names, in a stable order."

View file

@ -501,6 +501,25 @@
nodes later)] nodes later)]
(finish clip sid nodes id extent))))) (finish clip sid nodes id extent)))))
(defn placement-refusal
"Why direct child `n` may not be placed in symbol `sid`, or nil.
This is the type-containment boundary for every placement path. Specialized
symbols are confined here rather than by drag affordances, so pool drops,
transfers, structural nesting and future commands cannot disagree."
[clip sid n]
(let [destination (clip/symbol clip sid)
source (when (= :instance (:kind n)) (clip/symbol clip (node/source n)))
destination-type (:type destination)
source-type (:type source)]
(cond
(= :palette destination-type) "a palette symbol cannot contain nodes"
(and (= :palette-track destination-type) (not= :palette source-type))
"a palette track accepts only palette symbols"
(and (= :palette source-type) (not= :palette-track destination-type))
"a palette symbol can be placed only in a palette track"
:else nil)))
(defn place-node (defn place-node
"Place the already-materialized, direct child `n` into symbol `sid` at `at`. "Place the already-materialized, direct child `n` into symbol `sid` at `at`.
@ -519,10 +538,12 @@
nodes (:nodes sym) nodes (:nodes sym)
id (:id n) id (:id n)
[lo hi] (when n (node/placed-span n)) [lo hi] (when n (node/placed-span n))
duration (when (and lo hi) (- hi lo))] duration (when (and lo hi) (- hi lo))
incompatible (when (and sym n) (placement-refusal clip sid n))]
(cond (cond
(nil? sym) {:refused "there is no destination symbol"} (nil? sym) {:refused "there is no destination symbol"}
(nil? n) {:refused "there is no clip to place"} (nil? n) {:refused "there is no clip to place"}
incompatible {:refused incompatible}
(contains? nodes id) {:refused "the destination already uses that clip ID"} (contains? nodes id) {:refused "the destination already uses that clip ID"}
(some? (:parent n)) {:refused "only a direct child can be placed in a symbol"} (some? (:parent n)) {:refused "only a direct child can be placed in a symbol"}
(not (and (integer? at) (not (neg? at)))) (not (and (integer? at) (not (neg? at))))

View file

@ -709,7 +709,8 @@
`:display` is how the TIMELINE draws the symbol — `:lane` for its clips as `:display` is how the TIMELINE draws the symbol — `:lane` for its clips as
blocks on one row — and is saved because two people editing one document must blocks on one row — and is saved because two people editing one document must
play by the same editing rules. See `lane?`." play by the same editing rules. See `lane?`."
#{:id :name :frames :fps :width :height :nodes :palette :display}) #{:id :name :frames :fps :width :height :nodes :palette :palette-track
:palette-channel :type :palette-ref :display})
(defn problems (defn problems
"Human-readable reasons this symbol will not evaluate. Empty means it will. "Human-readable reasons this symbol will not evaluate. Empty means it will.
@ -727,6 +728,11 @@
(if-not (map? nodes) (if-not (map? nodes)
[":nodes must be a map of id -> node"] [":nodes must be a map of id -> node"]
(-> [] (-> []
(cond->
(and (= :palette (:type sym)) (seq nodes))
(conj "a palette symbol cannot contain nodes")
(and (= :palette (:type sym)) (nil? (:palette-ref sym)))
(conj "a palette symbol must point at a palette"))
(into (for [[id n] nodes (into (for [[id n] nodes
:when (not= id (:id n))] :when (not= id (:id n))]
(str "node under key " (pr-str id) " has :id " (pr-str (:id n))))) (str "node under key " (pr-str id) " has :id " (pr-str (:id n)))))

View file

@ -714,39 +714,49 @@
(rf/reg-event-db (rf/reg-event-db
::symbol-palette ::symbol-palette
(fn [db [_ sid id frame]] (fn [db [_ sid id]]
(edit/edit db #(if id (edit/edit db #(if id
(let [old (get-in % [:symbols sid :palette])] (assoc-in % [:symbols sid :palette] id)
(assoc-in % [:symbols sid :palette]
(if (:keys old)
(assoc-in old [:keys frame] id)
(ch/framed id))))
(update-in % [:symbols sid] dissoc :palette))))) (update-in % [:symbols sid] dissoc :palette)))))
(rf/reg-event-db (rf/reg-event-db
::key-symbol-palette ::drop-palette
(fn [db [_ sid frame id]] (fn [db [_ root-sid frame palette-id]]
(edit/edit db (let [{document :clip st :store} (store/entry (:clip/current db))
(fn [c] root (clip/symbol document root-sid)
(let [old (get-in c [:symbols sid :palette]) old-track (:palette-track root)
base (or (when old (ch/value-at old frame nil)) track-id (if (= :palette-track (get-in document [:symbols old-track :type]))
(pal/default-palette-id c)) old-track (clip/fresh-id document))
keyed (if (:keys old) old (ch/keyed {0 base} :hold))] palette-sid (or (some (fn [[sid sym]]
(assoc-in c [:symbols sid :palette] (when (and (= :palette (:type sym))
(assoc-in keyed [:keys frame] id))))))) (= palette-id (:palette-ref sym))) sid))
(:symbols document))
(rf/reg-event-db (clip/fresh-id (cond-> document
::instance-palette (not= track-id old-track)
(fn [db [_ sid node-id frame id]] (assoc-in [:symbols track-id] {}))))
(edit/edit db document (cond-> (if (= track-id old-track)
(fn [c] document
(if-not id (-> document
(update-in c [:symbols sid :nodes node-id] dissoc :palette) (assoc-in [:symbols root-sid :palette-track] track-id)
(let [old (get-in c [:symbols sid :nodes node-id :palette])] (assoc-in [:symbols track-id]
(assoc-in c [:symbols sid :nodes node-id :palette] {:id track-id :name "palette" :type :palette-track
(if (:keys old) :display :lane :frames (:frames root)
(assoc-in old [:keys frame] id) :fps (clip/fps document root-sid) :nodes {}})))
(ch/framed id))))))))) (nil? (clip/symbol document palette-sid))
(assoc-in [:symbols palette-sid]
{:id palette-sid
:name (or (get-in document [:palettes palette-id :name])
(name palette-id))
:type :palette :palette-ref palette-id
: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)})]
(if-let [why (:refused result)]
(update db :project merge {:status why})
(-> db
(edit/edit (constantly (:clip result)))
(assoc-in [:ui :selection] [:node track-id id [id]]))))))
(rf/reg-event-db (rf/reg-event-db
::set-channel ::set-channel

View file

@ -855,18 +855,18 @@
::sliding ::sliding
;; A bar in the middle of a slide, drawn by `::render/clip`; nil path when the ;; A bar in the middle of a slide, drawn by `::render/clip`; nil path when the
;; drag is abandoned. ;; drag is abandoned.
(fn [db [_ path df kind ripple? other]] (fn [db [_ path df kind ripple? other owner]]
(if path (if path
(assoc-in db [:ui :sliding] {:path path :df df :kind (or kind :slide) (assoc-in db [:ui :sliding] {:path path :df df :kind (or kind :slide)
:ripple? (boolean ripple?) :other other}) :ripple? (boolean ripple?) :other other :owner owner})
(update db :ui dissoc :sliding)))) (update db :ui dissoc :sliding))))
(rf/reg-event-db (rf/reg-event-db
::slide ::slide
(fn [db [_ path df kind ripple? other]] (fn [db [_ path df kind ripple? other owner]]
(let [db (update db :ui dissoc :sliding) (let [db (update db :ui dissoc :sliding)
clip (:clip (store/entry (:clip/current db))) clip (:clip (store/entry (:clip/current db)))
open (get-in db [:ui :open]) open (or owner (get-in db [:ui :open]))
r (case kind r (case kind
:out (nest/resize-out clip open path df ripple?) :out (nest/resize-out clip open path df ripple?)
:in (nest/resize-in clip open path df) :in (nest/resize-in clip open path df)

View file

@ -44,12 +44,13 @@
;; lets go, so the stage and the rows follow the pointer. Nothing is written ;; lets go, so the stage and the rows follow the pointer. Nothing is written
;; until then: one drag is one undo step and one write to collaborators. ;; until then: one drag is one undo step and one write to collaborators.
(let [{c :clip st :store} (footage/entry id)] (let [{c :clip st :store} (footage/entry id)]
(or (when-let [{:keys [path df kind ripple? other]} sliding] (or (when-let [{:keys [path df kind ripple? other owner]} sliding]
(let [root (or owner open)]
(:clip (case kind (:clip (case kind
:out (nest/resize-out c open path df ripple?) :out (nest/resize-out c root path df ripple?)
:in (nest/resize-in c open path df) :in (nest/resize-in c root path df)
:roll (nest/roll c open other path df) :roll (nest/roll c root other path df)
(nest/slide c open path df)))) (nest/slide c root path df)))))
(when-let [{:keys [sid id frame values edits]} gesture] (when-let [{:keys [sid id frame values edits]} gesture]
;; A held stage control owns the touched parameters completely. Make ;; A held stage control owns the touched parameters completely. Make
;; them temporary static channels for the preview, so their existing ;; them temporary static channels for the preview, so their existing

View file

@ -47,6 +47,13 @@
:center (clip/center document st sid) :center (clip/center document st sid)
:shapes (outline document st sid)})))) :shapes (outline document st sid)}))))
(defn palette!
"Start carrying a project palette toward the open symbol's palette track."
[id label]
(reset! carrying {:kind :palette :palette id :label label :frames 1}))
(defn palette? [] (= :palette (:kind @carrying)))
(defn accepts? (defn accepts?
"Whether the stage or the tracks should accept what is being carried: things "Whether the stage or the tracks should accept what is being carried: things
out of the pool, and not one that would make a cycle." out of the pool, and not one that would make a cycle."
@ -120,3 +127,10 @@
:sound (rf/dispatch [::ui/drop-sound c frame target]) :sound (rf/dispatch [::ui/drop-sound c frame target])
nil)) nil))
(done!))) (done!)))
(defn land-palette!
"Put the carried palette on `sid`'s palette track at `frame`."
[sid frame]
(when-let [{:keys [palette]} (when (palette?) @carrying)]
(rf/dispatch [::project/drop-palette sid frame palette]))
(done!))

View file

@ -218,16 +218,6 @@
"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)))]
(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])])
(when (= :instance (:kind n))
[:label.inspector-field "palette override"
[:select {:value (str (or (some-> (:palette n) (channel/value-at frame nil)) ""))
:on-change (fn [e]
(let [v (.. e -target -value)
pid (first (filter #(= v (str %)) (keys (pal/palettes clip))))]
(rf/dispatch [::project/instance-palette sid id frame pid])))}
[:option {:value ""} "inherit"]
(for [[pid p] (sort-by (comp str :name val) (pal/palettes clip))]
^{:key (str pid)} [:option {:value (str pid)} (:name p)])]])
[: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]
[:dl.facts [:dl.facts
@ -538,14 +528,12 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; a symbol ;; a symbol
(defn- symbol-section [sid] (defn- symbol-section [sid & [title]]
(let [clip @(rf/subscribe [::render/clip]) (let [clip @(rf/subscribe [::render/clip])
sym (get-in clip [:symbols sid]) sym (get-in clip [:symbols sid])
frame @(rf/subscribe [::render/open-frame]) frame @(rf/subscribe [::render/open-frame])
palette-value (or (some-> (:palette sym) (channel/value-at frame nil))
(pal/default-palette-id clip))
busy? (:busy? @(rf/subscribe [::playback/project]))] busy? (:busy? @(rf/subscribe [::playback/project]))]
[section "symbol" [section (or title "symbol")
[facts [facts
"id" (str sid) "id" (str sid)
"length" (str (:frames sym) " frames at " (clip-domain/fps clip sid) " fps") "length" (str (:frames sym) " frames at " (clip-domain/fps clip sid) " fps")
@ -563,23 +551,15 @@
#(rf/dispatch [::project/symbol-setting sid :width %]) "project default" busy?] #(rf/dispatch [::project/symbol-setting sid :width %]) "project default" busy?]
[number-field "height" (:height sym) [number-field "height" (:height sym)
#(rf/dispatch [::project/symbol-setting sid :height %]) "project default" busy?] #(rf/dispatch [::project/symbol-setting sid :height %]) "project default" busy?]
[:label.inspector-field "palette" [:label.inspector-field "authoring palette"
[:select {:value (str palette-value) :disabled busy? [:select {:value (str (or (:palette sym) "")) :disabled busy?
:on-change (fn [e] :on-change (fn [e]
(let [v (.. e -target -value) (let [v (.. e -target -value)
id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] id (first (filter #(= v (str %)) (keys (pal/palettes clip))))]
(rf/dispatch [::project/symbol-palette sid id frame])))} (rf/dispatch [::project/symbol-palette sid id])))}
[:option {:value ""} "inherit"] [:option {:value ""} "inherit"]
(for [[id p] (sort-by (comp str :name val) (pal/palettes clip))] (for [[id p] (sort-by (comp str :name val) (pal/palettes clip))]
^{:key (str id)} [:option {:value (str id)} (:name p)])]] ^{:key (str id)} [:option {:value (str id)} (:name p)])]]
[:div.row
[:button {:disabled busy?
:title "hold this palette from this frame"
:on-click #(rf/dispatch [::project/key-symbol-palette sid frame palette-value])}
"◆ key palette"]
[:button {:disabled (or busy? (nil? (:palette sym)))
:on-click #(rf/dispatch [::project/symbol-palette sid nil])}
"inherit"]]
(when (or (:width sym) (:height sym)) (when (or (:width sym) (:height sym))
[:div.row [:div.row
[:button {:disabled busy? :on-click #(do [:button {:disabled busy? :on-click #(do
@ -587,6 +567,28 @@
(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
;; ;;
@ -706,6 +708,11 @@
[:div {:style {:min-height 0}} [:div {:style {:min-height 0}}
[clip-section] [clip-section]
(when node [node-section node]) (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))} (when node ^{:key (str (first node) "/" (second node))}
[correction-section node]) [correction-section node])
(when (seq here) [footage-section here]) (when (seq here) [footage-section here])
@ -713,4 +720,9 @@
[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 (= :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 tracked? [tracking-section])]]))

View file

@ -272,9 +272,10 @@
(defn- palette-row [id p default-id chosen rename] (defn- palette-row [id p default-id chosen rename]
^{:key (str id)} ^{:key (str id)}
[row {:label (:name p) [row (merge {:label (:name p)
:sub (str (count (:slots p)) " colors") :sub (str (count (:slots p)) " colors")
:title (str (:name p) " · " (count (:slots p)) " indexed colors") :title (str (:name p) " · " (count (:slots p))
" indexed colors — drag onto the palette track")
:thumb [:span.thumb {:style {:display "grid" :thumb [:span.thumb {:style {:display "grid"
:grid-template-columns "repeat(4,1fr)"}} :grid-template-columns "repeat(4,1fr)"}}
(for [[i s] (map-indexed vector (take 16 (:slots p)))] (for [[i s] (map-indexed vector (take 16 (:slots p)))]
@ -285,7 +286,8 @@
((:begin! rename) nil) ((:begin! rename) nil)
(rf/dispatch [::project/palette-name id value]))) (rf/dispatch [::project/palette-name id value])))
:on-click #(rf/dispatch [:arthur.ui.palette/select id]) :on-click #(rf/dispatch [:arthur.ui.palette/select id])
: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))))])
(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}

View file

@ -332,6 +332,18 @@
(walk sid [] 0 identity) (walk sid [] 0 identity)
[]))))) [])))))
(defn- palette-track-row [clip sid frames expanded chosen]
(let [track-id (get-in clip [:symbols sid :palette-track])
track (get-in clip [:symbols track-id])]
(if (= :palette-track (:type track))
(-> (first (rows clip track-id expanded chosen))
(assoc :path [::palette-track] :owner track-id
:label "palette" :kind :palette
:select [::palette-track sid]))
{:path [::palette-track] :depth 0 :label "palette" :kind :palette
:owner nil :select [::palette-track sid]
:span [0 frames] :keys [] :cels []})))
(defn sound-rows (defn sound-rows
"Audio rows use the same flattened intervals as the mixer, including source "Audio rows use the same flattened intervals as the mixer, including source
in-points, cel speeds, parent timing, and silence beneath visual holds. in-points, cel speeds, parent timing, and silence beneath visual holds.
@ -705,7 +717,7 @@
`:from` is the frame the press was on and `:df` how many frames the pointer has `:from` is the frame the press was on and `:df` how many frames the pointer has
moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip
every row and the stage are drawn from already has in it." every row and the stage are drawn from already has in it."
[{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped?]} [{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped? owner]}
frames sliding hint {:keys [clip store open frame selection]}] frames sliding hint {:keys [clip store open frame selection]}]
(let [active-row (:row @sliding) (let [active-row (:row @sliding)
slide (fn [^js e] slide (fn [^js e]
@ -784,7 +796,7 @@
(swap! sliding assoc :df df) (swap! sliding assoc :df df)
(rf/dispatch [::ui/sliding (:path @sliding) df (rf/dispatch [::ui/sliding (:path @sliding) df
(:kind @sliding) (:ripple? @sliding) (:kind @sliding) (:ripple? @sliding)
(:other @sliding)])))))))) (:other @sliding) owner]))))))))
done (fn [commit?] done (fn [commit?]
(when (= path (:row @sliding)) (when (= path (:row @sliding))
(let [{:keys [path df kind ripple? other target-lane target-frame (let [{:keys [path df kind ripple? other target-lane target-frame
@ -808,7 +820,7 @@
;; selected. The two structural cases above select what ;; selected. The two structural cases above select what
;; they landed, so neither needs this. ;; they landed, so neither needs this.
(do (when on-click (rf/dispatch [::ui/select on-click])) (do (when on-click (rf/dispatch [::ui/select on-click]))
(rf/dispatch [::ui/slide path df kind ripple? other])) (rf/dispatch [::ui/slide path df kind ripple? other owner]))
:else (rf/dispatch [::ui/sliding nil]))))) :else (rf/dispatch [::ui/sliding nil])))))
begin! (fn [^js e actual-path gesture-kind actual-select other drag] begin! (fn [^js e actual-path gesture-kind actual-select other drag]
(let [track (.closest (.-currentTarget e) ".tl-track")] (let [track (.closest (.-currentTarget e) ".tl-track")]
@ -839,7 +851,7 @@
[:div.tl-track [:div.tl-track
;; The track, not the bar, holds the pointer while a bar slides, so the drag ;; The track, not the bar, holds the pointer while a bar slides, so the drag
;; goes on when the bar has slid off the ruler and is no longer drawn. ;; goes on when the bar has slid off the ruler and is no longer drawn.
{:class (when lane? "lane") {:class (str (when lane? "lane") (when (= :palette kind) " palette"))
:on-pointer-move slide :on-pointer-move slide
:on-pointer-up (fn [e] (slide e) (done true)) :on-pointer-up (fn [e] (slide e) (done true))
:on-pointer-cancel (fn [_] (done false)) :on-pointer-cancel (fn [_] (done false))
@ -854,28 +866,40 @@
(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]
(when (and lane? (or (drag/accepts?) (drag/row))) (cond
(.preventDefault e) (and (= :palette kind) (drag/palette?))
(.stopPropagation e) (do (.preventDefault e) (.stopPropagation e))
(when (drag/accepts?) (and (not= :palette kind) lane?
(drag/hover! :timeline (frame-at e frames) nil select)))) (or (drag/accepts?) (drag/row)))
(do (.preventDefault e) (.stopPropagation e))))
:on-drag-over (fn [^js e] :on-drag-over (fn [^js e]
(when (and lane? (or (drag/accepts?) (drag/row))) (cond
(.preventDefault e) (and (= :palette kind) (drag/palette?))
(.stopPropagation e) (do (.preventDefault e) (.stopPropagation e)
(set! (.. e -dataTransfer -dropEffect) "copy")
(drag/hover! :timeline (frame-at e frames) nil select))
(and (not= :palette kind) lane?
(or (drag/accepts?) (drag/row)))
(do (.preventDefault e) (.stopPropagation e)
(set! (.. e -dataTransfer -dropEffect) (set! (.. e -dataTransfer -dropEffect)
(if (drag/row) "move" "copy")) (if (drag/row) "move" "copy"))
(when (drag/accepts?) (when (drag/accepts?)
(drag/hover! :timeline (frame-at e frames) nil select)))) (drag/hover! :timeline (frame-at e frames) nil select)))))
:on-drop (fn [^js e] :on-drop (fn [^js e]
(when (and lane? (or (drag/accepts?) (drag/row))) (cond
(.preventDefault e) (and (= :palette kind) (drag/palette?))
(let [at (frame-at e frames)]
(.preventDefault e) (.stopPropagation e)
(drag/land-palette! open at))
(and (not= :palette kind) lane?
(or (drag/accepts?) (drag/row)))
(do (.preventDefault e)
(.stopPropagation e) (.stopPropagation e)
(if-let [from (drag/row-selection)] (if-let [from (drag/row-selection)]
(do (drag/done!) (do (drag/done!)
(rf/dispatch [::ui/drop-clip from select (rf/dispatch [::ui/drop-clip from select
(frame-at e frames)])) (frame-at e frames)]))
(drag/land! (frame-at e frames) nil select))))} (drag/land! (frame-at e frames) nil select)))))}
;; Clipped to the ruler: an instance longer than the room left in its ;; Clipped to the ruler: an instance longer than the room left in its
;; symbol still plays its own frames from 0, it is just cut off at the end. ;; symbol still plays its own frames from 0, it is just cut off at the end.
(when-let [[in out] (when (and span (nil? cels)) [(max 0 (first span)) (min frames (second span))])] (when-let [[in out] (when (and span (nil? cels)) [(max 0 (first span)) (min frames (second span))])]
@ -1052,13 +1076,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->> (rows clip open expanded chosen) picture (cond->> (cond->> (into [(palette-track-row clip open frames 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))))
(and ghost (not (:sound? drop))) (cons ghost)) ;; The palette track is a permanent master row. An
;; untargeted visual drop becomes a new ordinary row and
;; therefore previews immediately BELOW it, where the
;; committed row will appear—not above the master row.
(and ghost (not (:sound? drop)))
((fn [rows] (into [(first rows) ghost] (rest rows)))))
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

@ -318,3 +318,40 @@
"the inheriting child follows the lightning cut; an instance override wins") "the inheriting child follows the lightning cut; an instance override wins")
(is (= [17 34 51] (nth (:ramp context) 1))) (is (= [17 34 51] (nth (:ramp context) 1)))
(is (= [170 187 204] (nth (:ramp context) 3))))) (is (= [170 187 204] (nth (:ramp context) 3)))))
(deftest symbol-authoring-palettes-seed-only-the-view-root
(let [palette (fn [id name color]
{:id id :name name :slots [{:hex color} {:hex color}]})
mark {:id :mark :kind :rect :z "a1"
:channels {[:geom :size] (ch/framed 2)
[:style :color] (ch/framed 1)}}
document {:fps 30 :width 20 :height 20
:palettes {:day (palette :day "Day" "#112233")
:night (palette :night "Night" "#aabbcc")}
:default-palette :day
:symbols
{:main {:id :main :frames 4 :palette :day :palette-track :palette-lane
:nodes {:child {:id :child :kind :instance :z "a1"
:source {:symbol :drawing}}}}
:palette-lane {:id :palette-lane :name "palette"
:type :palette-track :display :lane :frames 4
:nodes {:night {:id :night :kind :instance :z "a1"
:source {:symbol :night-palette}
:span [0 1] :time {:at 1}}}}
:night-palette {:id :night-palette :name "Night"
:type :palette :palette-ref :night
:frames 1 :nodes {}}
:drawing {:id :drawing :frames 4 :palette :night
:nodes {:mark mark}}}}
context (pal/compile document)
main (clip/resolver document :main nil context nil)
drawing (clip/resolver document :drawing nil context nil)
color #(-> (%1 %2) first :color)]
(is (= (pal/render-index context :day 1) (color main 0))
"a nested symbol's authoring palette does not override the viewed root")
(is (= (pal/render-index context :night 1) (color main 1))
"a covered palette-track interval overrides the root fallback")
(is (= (pal/render-index context :day 1) (color main 2))
"a nil track interval is a real gap and restores the authoring palette")
(is (= (pal/render-index context :night 1) (color drawing 0))
"the same nested symbol uses its authoring palette when viewed directly")))

View file

@ -90,3 +90,26 @@
(deftest host-frame-of-a-parentless-clip-is-the-symbol-frame (deftest host-frame-of-a-parentless-clip-is-the-symbol-frame
(is (= 6 (span/host-frame (document) :main :b 6))) (is (= 6 (span/host-frame (document) :main :b 6)))
(is (= 6 (span/host-frame (ordinary-document) :main :badge 6)))) (is (= 6 (span/host-frame (ordinary-document) :main :badge 6))))
(deftest typed-symbol-containment-applies-to-every-transfer
(let [palette-node {:id :p :kind :instance :z "a1"
:source {:symbol :night-palette}
:span [0 1] :time {:at 0}}
visual-node {:id :v :kind :instance :z "a1"
:source {:symbol :drawing}
:span [0 1] :time {:at 0}}
doc {:fps 30 :width 20 :height 20
:symbols
{:root {:id :root :frames 8 :nodes {}}
:ordinary-lane {:id :ordinary-lane :frames 8 :display :lane :nodes {}}
:palette-lane {:id :palette-lane :frames 8 :display :lane
:type :palette-track :nodes {:p palette-node}}
:night-palette {:id :night-palette :frames 1 :type :palette
:palette-ref :night :nodes {}}
:drawing {:id :drawing :frames 1 :nodes {}}}}]
(is (:refused (span/transfer doc :palette-lane :p :root 0 {}))
"a palette clip cannot escape to a composition root")
(is (:refused (span/transfer doc :palette-lane :p :ordinary-lane 0 {}))
"a palette clip cannot escape to an ordinary lane")
(is (:refused (span/place-node doc :palette-lane visual-node 0 {}))
"a palette lane refuses ordinary visual clips")))