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",
)
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)
palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True)

View file

@ -435,7 +435,7 @@ class DocumentTests(TestCase):
self.assertEqual(5, len(response.json()["written"]))
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"]))
clip = loaded["clips"][0]
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
`: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
{:frames 91
:palette {:animated? true :interp :hold :keys {0 :day, 48 :dusk, 72 :night}}
:nodes {...}}
{:id :shot :frames 91 :palette :day :palette-track :shot-palettes :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
timeline's content is read in that ramp, and it travels with the timeline** — a
symbol authored against `:night` stays night wherever it is placed. That is
lexical scope, and deliberately: a character with their own palette is a
character, not a decoration of whichever scene they were dropped into.
`:palette` is the symbol's authoring/preview palette. It seeds evaluation only
when that symbol is the viewed root; nested symbols do not replace the root's
choice merely because they were authored under another ramp. When absent, the
project default seeds evaluation.
Composition is the same walk as `:time` — down the instance chain, **innermost
set palette wins**. An enclosing timeline's palette therefore applies to
everything inside it that does not set its own, which is adjustment-layer
behaviour with no adjustment layer in it. It is just scope.
And because it is an ordinary channel, a project switches palette over time with
keys on the root timeline, a child timeline switches on its own, and neither
knows about the other.
Covered clips of the viewed root's palette track override that seed. An
uncovered lane interval is a genuine gap, restoring the authoring palette or
project default. Palette clips use the same trim, roll, slide, claim-time and
undo commands as visual clips; palette code does not duplicate those edits.
Thus palette-track coverage, authoring preview, and project fallback are
separate facts rather than three accidental meanings of one field. There is no
second keyed palette control on symbols or instances: time-varying palette
changes are authored only as clips in the palette lane.
### One index space, partitioned by palette

View file

@ -249,7 +249,40 @@
native node frames and world matrices for the last rendered output frame."
[clip sid store palette opts]
(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)
chosen (cond
(nil? selection) nil
@ -257,7 +290,7 @@
(ch/value-at selection frame store)
:else selection)]
(or chosen inherited (:default palette))))
(build [sid chain pose-tracks]
(build [sid chain pose-tracks root?]
(when (some #{sid} chain)
(throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
(let [sym (or (symbol clip sid)
@ -276,7 +309,7 @@
:when (= :instance (:kind n))
child (sort-by str (node/sources n))]
[[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
;; drawing each was showing — a row path is read back through
;; the child that was actually resolved, not the only one
@ -286,7 +319,10 @@
step (fn [f pre inherited forced]
(when context?
(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 {})
(let [by-id (into {} (map (juxt :node identity))
(own (js/Math.floor f) (js/Math.floor pre)))]
@ -349,7 +385,7 @@
(when (contains? @entered id)
(symbol/pre-frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/pre-frame-of own id))))))]
(let [r (build sid [] nil)
(let [r (build sid [] nil true)
grid (or (:grid-fps opts) (:fps clip))
native (fps clip sid)]
(reify

View file

@ -148,7 +148,8 @@
;; disagree with itself about; `clip` puts it back.
(for [[sid sym] (:symbols clip)]
{(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)
[id n] (:nodes sym)]
{(at "symbol" (segment sid) "node" (segment id))

View file

@ -319,6 +319,8 @@
(nil? n) "nothing to move"
(or (nil? here) (nil? there)) "both have to be on screen at this frame"
(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)))
"a held or looping clip has no clock to move through"
(nil? (some-> there :matrix node/invert)) "the target is scaled to nothing"

View file

@ -79,6 +79,10 @@
used (into #{(default-palette-id clip)}
(mapcat selection-values)
(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))
n (vals (:nodes sym))]
(:palette n))))

View file

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

View file

@ -501,6 +501,25 @@
nodes later)]
(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
"Place the already-materialized, direct child `n` into symbol `sid` at `at`.
@ -519,10 +538,12 @@
nodes (:nodes sym)
id (:id 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
(nil? sym) {:refused "there is no destination symbol"}
(nil? n) {:refused "there is no clip to place"}
incompatible {:refused incompatible}
(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"}
(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
blocks on one row — and is saved because two people editing one document must
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
"Human-readable reasons this symbol will not evaluate. Empty means it will.
@ -727,6 +728,11 @@
(if-not (map? nodes)
[":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
:when (not= id (: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
::symbol-palette
(fn [db [_ sid id frame]]
(fn [db [_ sid id]]
(edit/edit db #(if id
(let [old (get-in % [:symbols sid :palette])]
(assoc-in % [:symbols sid :palette]
(if (:keys old)
(assoc-in old [:keys frame] id)
(ch/framed id))))
(assoc-in % [:symbols sid :palette] id)
(update-in % [:symbols sid] dissoc :palette)))))
(rf/reg-event-db
::key-symbol-palette
(fn [db [_ sid frame id]]
(edit/edit db
(fn [c]
(let [old (get-in c [:symbols sid :palette])
base (or (when old (ch/value-at old frame nil))
(pal/default-palette-id c))
keyed (if (:keys old) old (ch/keyed {0 base} :hold))]
(assoc-in c [:symbols sid :palette]
(assoc-in keyed [:keys frame] id)))))))
(rf/reg-event-db
::instance-palette
(fn [db [_ sid node-id frame id]]
(edit/edit db
(fn [c]
(if-not id
(update-in c [:symbols sid :nodes node-id] dissoc :palette)
(let [old (get-in c [:symbols sid :nodes node-id :palette])]
(assoc-in c [:symbols sid :nodes node-id :palette]
(if (:keys old)
(assoc-in old [:keys frame] id)
(ch/framed id)))))))))
::drop-palette
(fn [db [_ root-sid frame palette-id]]
(let [{document :clip st :store} (store/entry (:clip/current db))
root (clip/symbol document root-sid)
old-track (:palette-track root)
track-id (if (= :palette-track (get-in document [:symbols old-track :type]))
old-track (clip/fresh-id document))
palette-sid (or (some (fn [[sid sym]]
(when (and (= :palette (:type sym))
(= palette-id (:palette-ref sym))) sid))
(:symbols document))
(clip/fresh-id (cond-> document
(not= track-id old-track)
(assoc-in [:symbols track-id] {}))))
document (cond-> (if (= track-id old-track)
document
(-> document
(assoc-in [:symbols root-sid :palette-track] track-id)
(assoc-in [:symbols track-id]
{:id track-id :name "palette" :type :palette-track
:display :lane :frames (:frames root)
:fps (clip/fps document root-sid) :nodes {}})))
(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
::set-channel

View file

@ -855,18 +855,18 @@
::sliding
;; A bar in the middle of a slide, drawn by `::render/clip`; nil path when the
;; drag is abandoned.
(fn [db [_ path df kind ripple? other]]
(fn [db [_ path df kind ripple? other owner]]
(if path
(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))))
(rf/reg-event-db
::slide
(fn [db [_ path df kind ripple? other]]
(fn [db [_ path df kind ripple? other owner]]
(let [db (update db :ui dissoc :sliding)
clip (:clip (store/entry (:clip/current db)))
open (get-in db [:ui :open])
open (or owner (get-in db [:ui :open]))
r (case kind
:out (nest/resize-out clip open path df ripple?)
: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
;; until then: one drag is one undo step and one write to collaborators.
(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
:out (nest/resize-out c open path df ripple?)
:in (nest/resize-in c open path df)
:roll (nest/roll c open other path df)
(nest/slide c open path df))))
:out (nest/resize-out c root path df ripple?)
:in (nest/resize-in c root path df)
:roll (nest/roll c root other path df)
(nest/slide c root path df)))))
(when-let [{:keys [sid id frame values edits]} gesture]
;; A held stage control owns the touched parameters completely. Make
;; them temporary static channels for the preview, so their existing

View file

@ -47,6 +47,13 @@
:center (clip/center 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?
"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."
@ -120,3 +127,10 @@
:sound (rf/dispatch [::ui/drop-sound c frame target])
nil))
(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))
"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 (= :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"]]
(let [{:keys [frame]} local]
[:dl.facts
@ -538,14 +528,12 @@
;; ---------------------------------------------------------------------------
;; a symbol
(defn- symbol-section [sid]
(defn- symbol-section [sid & [title]]
(let [clip @(rf/subscribe [::render/clip])
sym (get-in clip [:symbols sid])
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]))]
[section "symbol"
[section (or title "symbol")
[facts
"id" (str sid)
"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?]
[number-field "height" (:height sym)
#(rf/dispatch [::project/symbol-setting sid :height %]) "project default" busy?]
[:label.inspector-field "palette"
[:select {:value (str palette-value) :disabled busy?
[:label.inspector-field "authoring palette"
[:select {:value (str (or (:palette sym) "")) :disabled busy?
:on-change (fn [e]
(let [v (.. e -target -value)
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"]
(for [[id p] (sort-by (comp str :name val) (pal/palettes clip))]
^{: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))
[:div.row
[:button {:disabled busy? :on-click #(do
@ -587,6 +567,28 @@
(rf/dispatch [::project/symbol-setting sid :height nil]))}
"use project stage"]])]]))
(defn- symbol-parameters-section [sid frame title]
(let [clip @(rf/subscribe [::render/clip])
auto-key? @(rf/subscribe [::sub/auto-key?])
nodes (get-in clip [:symbols sid :nodes])]
(when (and sid (number? frame) (seq nodes))
(into
[section title]
(for [[id n] (sort-by (comp str key) nodes)
:let [editable (filter (fn [[path ch]]
(and (contains? (node/defaults-of n) path)
(not (:dense ch))))
(node/channels n))]
:when (seq editable)]
^{:key (str id)}
(into
[:div.inspector-form
[:div.row [:span.dim (or (:name n) (brief id))]]]
(for [[path ch] editable]
^{:key (str path)}
[:label.inspector-field (str/join " " (map name path))
[channel-control sid id path ch frame auto-key?]])))))))
;; ---------------------------------------------------------------------------
;; tracked objects
;;
@ -706,6 +708,11 @@
[:div {:style {:min-height 0}}
[clip-section]
(when node [node-section node])
(when placed [symbol-section placed "source symbol"])
(when placed
(let [{source-sid :sid source-frame :frame} @(rf/subscribe [::sub/selected-local])]
(when (= placed source-sid)
[symbol-parameters-section placed source-frame "source parameters"])))
(when node ^{:key (str (first node) "/" (second node))}
[correction-section node])
(when (seq here) [footage-section here])
@ -713,4 +720,9 @@
[tracing-section face faces path])
(when face ^{:key (str "perf/" face)} [performance-section face])
(when (= :symbol (first selection)) [symbol-section (second selection)])
(when (= :symbol (first selection))
(let [sid (second selection)
frame (min (max 0 @(rf/subscribe [::render/open-frame]))
(max 0 (dec (or (clip-domain/frames clip sid) 1))))]
[symbol-parameters-section sid frame "symbol parameters"]))
(when tracked? [tracking-section])]]))

View file

@ -272,9 +272,10 @@
(defn- palette-row [id p default-id chosen rename]
^{:key (str id)}
[row {:label (:name p)
[row (merge {:label (:name p)
: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"
:grid-template-columns "repeat(4,1fr)"}}
(for [[i s] (map-indexed vector (take 16 (:slots p)))]
@ -285,7 +286,8 @@
((:begin! rename) nil)
(rf/dispatch [::project/palette-name id value])))
: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]
^{:key id}

View file

@ -332,6 +332,18 @@
(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
"Audio rows use the same flattened intervals as the mixer, including source
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
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."
[{: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]}]
(let [active-row (:row @sliding)
slide (fn [^js e]
@ -784,7 +796,7 @@
(swap! sliding assoc :df df)
(rf/dispatch [::ui/sliding (:path @sliding) df
(:kind @sliding) (:ripple? @sliding)
(:other @sliding)]))))))))
(:other @sliding) owner]))))))))
done (fn [commit?]
(when (= path (:row @sliding))
(let [{:keys [path df kind ripple? other target-lane target-frame
@ -808,7 +820,7 @@
;; selected. The two structural cases above select what
;; they landed, so neither needs this.
(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])))))
begin! (fn [^js e actual-path gesture-kind actual-select other drag]
(let [track (.closest (.-currentTarget e) ".tl-track")]
@ -839,7 +851,7 @@
[:div.tl-track
;; 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.
{:class (when lane? "lane")
{:class (str (when lane? "lane") (when (= :palette kind) " palette"))
:on-pointer-move slide
:on-pointer-up (fn [e] (slide e) (done true))
:on-pointer-cancel (fn [_] (done false))
@ -854,28 +866,40 @@
(rf/dispatch [::pb/open-symbol source])))
:ref (when lane? (fn [el] (when el (aset el "arthurLane" select))))
:on-drag-enter (fn [^js e]
(when (and lane? (or (drag/accepts?) (drag/row)))
(.preventDefault e)
(.stopPropagation e)
(when (drag/accepts?)
(drag/hover! :timeline (frame-at e frames) nil select))))
(cond
(and (= :palette kind) (drag/palette?))
(do (.preventDefault e) (.stopPropagation e))
(and (not= :palette kind) lane?
(or (drag/accepts?) (drag/row)))
(do (.preventDefault e) (.stopPropagation e))))
:on-drag-over (fn [^js e]
(when (and lane? (or (drag/accepts?) (drag/row)))
(.preventDefault e)
(.stopPropagation e)
(cond
(and (= :palette kind) (drag/palette?))
(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)
(if (drag/row) "move" "copy"))
(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]
(when (and lane? (or (drag/accepts?) (drag/row)))
(.preventDefault e)
(cond
(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)
(if-let [from (drag/row-selection)]
(do (drag/done!)
(rf/dispatch [::ui/drop-clip from select
(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
;; 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))])]
@ -1052,13 +1076,19 @@
{:id ::drop :label (str "+ " (:label drop)) :ghost? true
:span drop-span})
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
(mapv (fn [row]
(if (= drop-lane (:select row))
(update row :cels (fnil conj []) lane-ghost)
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))
(sound-rows clip open expanded))
(and ghost (:sound? drop)) (cons ghost))

View file

@ -318,3 +318,40 @@
"the inheriting child follows the lightning cut; an instance override wins")
(is (= [17 34 51] (nth (:ramp context) 1)))
(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
(is (= 6 (span/host-frame (document) :main :b 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")))