diff --git a/clips/migrations/0013_palette_track.py b/clips/migrations/0013_palette_track.py new file mode 100644 index 0000000..ab00fc7 --- /dev/null +++ b/clips/migrations/0013_palette_track.py @@ -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), + ] diff --git a/clips/models.py b/clips/models.py index 7b56a99..f304b71 100644 --- a/clips/models.py +++ b/clips/models.py @@ -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) diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index f01e47a..68f474f 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -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"]) diff --git a/docs/animation-model.md b/docs/animation-model.md index 9b16489..0514b97 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -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 diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index f313b7b..a74c2f7 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -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 diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 49f0f7b..714d12c 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -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)) diff --git a/frontend/src/arthur/domain/nest.cljs b/frontend/src/arthur/domain/nest.cljs index 210cca7..e8a6cdc 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -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" diff --git a/frontend/src/arthur/domain/palette.cljs b/frontend/src/arthur/domain/palette.cljs index d6cdbf4..3fe004c 100644 --- a/frontend/src/arthur/domain/palette.cljs +++ b/frontend/src/arthur/domain/palette.cljs @@ -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)))) diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index 0fc7f8b..5c5afc1 100644 --- a/frontend/src/arthur/domain/project.cljs +++ b/frontend/src/arthur/domain/project.cljs @@ -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." diff --git a/frontend/src/arthur/domain/span.cljs b/frontend/src/arthur/domain/span.cljs index a578c41..cd0a6fc 100644 --- a/frontend/src/arthur/domain/span.cljs +++ b/frontend/src/arthur/domain/span.cljs @@ -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)))) diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index bc52961..0ba6622 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -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))))) diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index bf63547..e2502c5 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -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 diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 80a8ac0..744725c 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -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) diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 94bf24a..1d91a6b 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -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] - (: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)))) + (or (when-let [{:keys [path df kind ripple? other owner]} sliding] + (let [root (or owner open)] + (:clip (case kind + :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 diff --git a/frontend/src/arthur/ui/drag.cljs b/frontend/src/arthur/ui/drag.cljs index 3161c0f..b6ee41a 100644 --- a/frontend/src/arthur/ui/drag.cljs +++ b/frontend/src/arthur/ui/drag.cljs @@ -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!)) diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index 3ca75a4..df51d77 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -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])]])) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index aeea14e..256daeb 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -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} diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index d87b9d6..587b58f 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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) - (set! (.. e -dataTransfer -dropEffect) - (if (drag/row) "move" "copy")) - (when (drag/accepts?) - (drag/hover! :timeline (frame-at e frames) nil select)))) + (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))))) :on-drop (fn [^js e] - (when (and lane? (or (drag/accepts?) (drag/row))) - (.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))))} + (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)))))} ;; 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)) diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index d8b0ce3..efd7f6c 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -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"))) diff --git a/frontend/test/arthur/domain/span_test.cljs b/frontend/test/arthur/domain/span_test.cljs index 625a285..cce8986 100644 --- a/frontend/test/arthur/domain/span_test.cljs +++ b/frontend/test/arthur/domain/span_test.cljs @@ -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")))