diff --git a/clips/admin.py b/clips/admin.py index 9e73270..8b7add7 100644 --- a/clips/admin.py +++ b/clips/admin.py @@ -18,7 +18,7 @@ class ProjectAdmin(admin.ModelAdmin): @admin.register(Clip) class ClipAdmin(admin.ModelAdmin): - list_display = ("cid", "project", "name", "footage", "analysis") + list_display = ("cid", "project", "name") list_filter = ("project",) diff --git a/clips/migrations/0014_multiple_analyses.py b/clips/migrations/0014_multiple_analyses.py new file mode 100644 index 0000000..97fe72b --- /dev/null +++ b/clips/migrations/0014_multiple_analyses.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("clips", "0013_palette_track")] + + operations = [ + migrations.RemoveField(model_name="clip", name="analysis"), + migrations.RemoveField(model_name="clip", name="footage"), + migrations.AlterField( + model_name="project", + name="schema_version", + field=models.PositiveIntegerField(default=5), + ), + ] diff --git a/clips/models.py b/clips/models.py index f304b71..9eeb4b3 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=4) + schema_version = models.PositiveIntegerField(default=5) seq = models.PositiveBigIntegerField(default=0) palette = models.CharField(max_length=64, default="arthur/default") created = models.DateTimeField(auto_now_add=True) @@ -273,12 +273,6 @@ class Clip(models.Model): cid = models.SlugField(max_length=64) name = models.CharField(max_length=200, blank=True) order = models.IntegerField(default=0) - footage = models.ForeignKey( - Footage, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips" - ) - analysis = models.ForeignKey( - Analysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips" - ) blocks = models.ManyToManyField( Block, blank=True, related_name="clips", help_text="the tier-2 blocks this clip's channels name", diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 68f474f..26e8e73 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -396,14 +396,25 @@ class DocumentTests(TestCase): ], } - def save(self, leaves=None, blocks=None): + def save(self, leaves=None, blocks=None, analyses=None): return self.put(f"/api/projects/{self.project.id}", { "name": "a project", - "clips": [{"cid": "c1", "name": "take", "analysis": self.analysis, + "clips": [{"cid": "c1", "name": "take", + "analyses": [self.analysis] if analyses is None else analyses, "leaves": leaves if leaves is not None else self.leaves(), "blocks": blocks if blocks is not None else [self.block]}], }) + def test_a_clip_declares_the_registered_analyses_its_blocks_name(self): + undeclared = self.save(analyses=[]) + self.assertEqual(409, undeclared.status_code) + self.assertIn("every block", undeclared.json()["error"]) + + unknown = "sha256:" + "f" * 64 + missing = self.save(analyses=[self.analysis, unknown]) + self.assertEqual(409, missing.status_code) + self.assertEqual([unknown], missing.json()["missing"]) + def test_every_saved_symbol_is_listed_across_projects(self): leaves = self.leaves() leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12] @@ -435,12 +446,12 @@ class DocumentTests(TestCase): self.assertEqual(5, len(response.json()["written"])) loaded = self.client.get(f"/api/projects/{self.project.id}").json() - self.assertEqual(4, loaded["schema_version"]) + self.assertEqual(5, loaded["schema_version"]) self.assertEqual(1, len(loaded["clips"])) clip = loaded["clips"][0] self.assertEqual("c1", clip["cid"]) self.assertEqual([self.block], clip["blocks"]) - self.assertEqual(self.analysis, clip["analysis"]) + self.assertNotIn("analysis", clip) # The whole point: byte-identical values, including the integer frame keys # transit writes as "~i0". A JSON round trip that stringified them would # come back "0" and the part would hold its first pose forever. @@ -542,7 +553,7 @@ class DocumentTests(TestCase): def patch(self, base, leaves, removed=()): return self.put(f"/api/projects/{self.project.id}", { "base": base, - "clips": [{"cid": "c1", "analysis": self.analysis, "leaves": leaves, + "clips": [{"cid": "c1", "analyses": [self.analysis], "leaves": leaves, "removed": list(removed), "blocks": [self.block]}], }) diff --git a/clips/views.py b/clips/views.py index 4092a0e..e88dff8 100644 --- a/clips/views.py +++ b/clips/views.py @@ -718,8 +718,6 @@ def _project_json(project: Project, user): { "cid": clip.cid, "name": clip.name, - "footage": str(clip.footage_id) if clip.footage_id else None, - "analysis": clip.analysis_id, "blocks": sorted(clip.blocks.values_list("key", flat=True)), "leaves": {leaf.path: leaf.value for leaf in leaves if leaf.path.startswith(prefix)}, } @@ -909,7 +907,19 @@ def _save(project: Project, data, user): ) keys = spec.get("blocks") or [] - have = set(Block.objects.filter(key__in=keys).values_list("key", flat=True)) + analyses = spec.get("analyses") or [] + if (not isinstance(analyses, list) + or not all(isinstance(key, str) for key in analyses) + or len(analyses) != len(set(analyses))): + raise Bad("a clip's analyses must be a list of distinct analysis ids") + registered = set(Analysis.objects.filter(key__in=analyses) + .values_list("key", flat=True)) + if unknown := [key for key in analyses if key not in registered]: + raise Bad("this clip names analyses the server does not know; register them first", + status=409, missing=unknown) + + block_rows = list(Block.objects.filter(key__in=keys)) + have = {block.key for block in block_rows} if missing := [k for k in keys if k not in have]: # Referential integrity across the tiers, enforced where it can be: # a document that names blocks the server does not hold would load @@ -919,6 +929,9 @@ def _save(project: Project, data, user): "before saving the document that points at them", status=409, missing=missing, ) + if undeclared := sorted({block.analysis_id for block in block_rows} - registered): + raise Bad("every block in a clip must name one of that clip's analyses", + status=409, missing=undeclared) existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)} if base is not None: @@ -930,16 +943,12 @@ def _save(project: Project, data, user): if conflicts: continue - analysis = Analysis.objects.filter(key=spec.get("analysis")).first() - footage = None - if spec.get("footage"): - footage = Footage.objects.filter(id=spec["footage"]).first() clip, _ = Clip.objects.update_or_create( project=project, cid=cid, - defaults={"name": spec.get("name") or "", "analysis": analysis, "footage": footage}, + defaults={"name": spec.get("name") or ""}, ) - blocks = Block.objects.filter(key__in=keys) + blocks = block_rows if base is None: clip.blocks.set(blocks) gone = [path for path in existing if path not in leaves] diff --git a/docs/creating-in.md b/docs/creating-in.md index ad03dca..8d5a8dd 100644 --- a/docs/creating-in.md +++ b/docs/creating-in.md @@ -72,6 +72,12 @@ Once resolved, every creation command follows the destination kind. - Selecting an existing cel changes the destination from the lane to the symbol placed by that cel; subsequent symbols and shapes become children there. +Double-clicking a lane creates an empty cel at the playhead; beginning a drawing +creates a drawing cel there. These are the same lane-creation operation with +different payloads. The pointer chooses the lane, never a second creation time. +The resulting cel is selected, so it immediately becomes the preferred target: +drawing again enters that cel's symbol instead of replacing it. + Thus no separate "new cel" versus "add inside" mode is needed. Selecting the lane header says new cel; selecting a cel says add inside. diff --git a/frontend/src/arthur/domain/bring.cljs b/frontend/src/arthur/domain/bring.cljs index 166ca51..97a18b5 100644 --- a/frontend/src/arthur/domain/bring.cljs +++ b/frontend/src/arthur/domain/bring.cljs @@ -11,6 +11,7 @@ so the events that fetch them are only fetching." (:refer-clojure :exclude [take]) (:require [arthur.domain.clip :as clip] + [arthur.domain.feature :as feature] [arthur.domain.node :as node] [clojure.string :as string])) @@ -59,8 +60,8 @@ (keyword (if (seq slug) slug "symbol")))) (defn take - "Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns - `{:clip :sid :tracked?}`. + "Put `frozen`, a take, into `clip` as ONE symbol called `label`. Returns the + changed clip, the imported symbol id and the old-to-scoped subject ids. `frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per tracked face. `:main` becomes the named symbol — it is what holds the faces in @@ -75,18 +76,22 @@ symbols. `nest/audio-tracks` collapses simultaneous copies carrying the same `:media-link`; placing those faces at different times still schedules each one. - The tracking identities, and the analysis they were measured by, come along - only when `clip` has no analysis of its own and no face had to be renamed. A - document holds one analysis, and regeneration finds a face's symbol by its - subject id, so either condition failing means the take comes in as drawings - that play but cannot be re-tuned — `:tracked? false` says so." + The unique imported symbol id scopes every subject id. Thus two takes may both + arrive with detector subject `:face-1` without colliding in the document." [clip frozen label footage-id range] - (let [{c :clip ids :ids} (symbols clip frozen [:main] {:main (symbol-id label)}) + (let [scope (fn [root subject] (keyword (str (name root) "." (name subject)))) + root (clip/free-id + (fn [candidate] + (or (contains? (:symbols clip) candidate) + (some #(contains? (:symbols clip) (scope candidate %)) + (keys (:subjects frozen))))) + (symbol-id label)) + scoped (partial scope root) + wanted (into {:main root} (map (fn [subject] [subject (scoped subject)])) + (keys (:subjects frozen))) + {c :clip ids :ids} (symbols clip frozen [:main] wanted) sid (ids :main) faces (mapv ids (sort-by str (keys (:subjects frozen)))) - ;; Older/plain imported clips have no subject table. They are still a - ;; take, so their wrapper remains the only honest owner of the sound. - sound-hosts (if (seq faces) faces [sid]) sound {:id :sound :name "sound" :kind :audio :parent nil :z "z-sound" :source {:footage footage-id} ;; All automatic copies name one recording. The audio walk @@ -96,18 +101,42 @@ ;; Source frame `start` plays on the symbol's 0. :span range :time {:mode :map :at (- (first range)) :rate 1}} - tracked? (and (nil? (:analysis clip)) - (every? #(= % (ids %)) (keys (:subjects frozen))))] + feature-ids (into {} + (map (fn [[id f]] + [id (feature/owned (ids (:subject f)) (keyword (name id)))])) + (:features frozen)) + subjects (into {} + (map (fn [[id subject]] + (let [new-id (ids id)] + [new-id (assoc subject :id new-id + :footage footage-id)]))) + (:subjects frozen)) + features (into {} + (map (fn [[id f]] + (let [new-id (feature-ids id)] + [new-id (-> f + (assoc :id new-id + :subject (ids (:subject f)) + :symbol (ids (:symbol f))))]))) + (:features frozen)) + groups (into {} + (map (fn [[id g]] + (let [new-subject (ids (:subject g)) + new-id (feature/owned new-subject (keyword (name id)))] + [new-id (-> g + (assoc :id new-id :subject new-subject) + (update :members #(mapv feature-ids %)))]))) + (:groups frozen))] {:sid sid - :tracked? tracked? - :clip (cond-> (-> (reduce (fn [document face] - (assoc-in document [:symbols face :nodes :sound] sound)) - c sound-hosts) - (assoc-in [:symbols sid :name] (str label))) - tracked? (-> (assoc :analysis (:analysis frozen)) - (update :subjects merge (:subjects frozen)) - (update :features merge (:features frozen)) - (update :groups merge (:groups frozen))))})) + :subject-ids (select-keys ids (keys (:subjects frozen))) + :clip (-> (reduce (fn [document face] + (assoc-in document [:symbols face :nodes :sound] sound)) + c faces) + (assoc-in [:symbols sid :name] (str label)) + (update :analyses merge (:analyses frozen)) + (update :subjects merge subjects) + (update :features merge features) + (update :groups merge groups))})) (defn placed diff --git a/frontend/src/arthur/domain/channel.cljs b/frontend/src/arthur/domain/channel.cljs index 9864b41..6ce70f8 100644 --- a/frontend/src/arthur/domain/channel.cljs +++ b/frontend/src/arthur/domain/channel.cljs @@ -364,7 +364,11 @@ ;; Palette-choice channels interpolate identities into a blend ;; descriptor. The renderer keeps indexed geometry in the left ;; palette's bank and blends that bank's ramp toward the right one. - (and (= :palette (:semantic ch)) (keyword? a) (keyword? b)) + ;; Palette ids are opaque identities. Built-ins happen to use + ;; keywords, while palettes made in the editor use UUIDs; treating + ;; the latter as numbers produces NaN and therefore the renderer's + ;; pink bad-data sentinel. + (and (= :palette (:semantic ch)) (some? a) (some? b)) {:from a :to b :t t} (vector? a) (mapv (fn [x y] (+ x (* t (- y x)))) a b) @@ -520,7 +524,7 @@ (let [values (when (map? (:keys ch)) (vals (:keys ch))) first-value (first values) linear-values? (or (every? number? values) - (and (= :palette (:semantic ch)) (every? keyword? values)) + (and (= :palette (:semantic ch)) (every? some? values)) (and (vector? first-value) (pos? (count first-value)) (every? (fn [v] (and (vector? v) diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 6dd8cfe..39d15a2 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -4,7 +4,7 @@ {:name \"take\" :fps 30 :width 320 :height 200 - :analysis {...} + :analyses {analysis-id {...}} :subjects {...} :features {...} :groups {...} :symbols {:main {:id :main :frames 229 :nodes {...}}}} @@ -51,7 +51,7 @@ that loses something on every round trip, which is the one bug a persistence layer must not be able to have. Add the field here and to `leaf/leaves` and `leaf/clip` in the same commit." - #{:name :fps :analysis :subjects :features :groups :width :height :symbols + #{:name :fps :analyses :subjects :features :groups :width :height :symbols :palettes :default-palette}) (defn symbol @@ -264,7 +264,16 @@ ;; 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) + (let [fallback (or (channel-value (:palette owner) frame) + inherited (:default palette)) + materialize (fn [choice] + (cond + (= pal/inherit choice) fallback + (map? choice) (-> choice + (update :from #(if (= pal/inherit %) fallback %)) + (update :to #(if (= pal/inherit %) fallback %))) + :else choice)) + track (:palette-channel owner) track-value (if-let [ks (:keys track)] (some->> (keys ks) (filter #(<= % frame)) @@ -286,12 +295,10 @@ choice (get-in palette-clip [:channels [:palette]]) start (some-> palette-clip node/placed-span first)] (or (when (and choice start) - (channel-value choice (- frame start))) + (materialize (channel-value choice (- frame start)))) fallback))) track-value - (channel-value (:palette owner) frame) - inherited - (:default palette)))) + fallback))) (selection-at [owner frame inherited] (let [selection (:palette owner) chosen (cond @@ -574,6 +581,18 @@ (str "clip has a field with no leaf to save it in: " (pr-str k))) (when-not (map? (:symbols clip)) [":symbols must be a map of id -> symbol"]) + (when (and (contains? clip :analyses) (not (map? (:analyses clip)))) + [":analyses must be a map of analysis id -> analysis"]) + (for [[id analysis] (:analyses clip) + :when (not= id (:id analysis))] + (str "analysis under key " (pr-str id) " has :id " (pr-str (:id analysis)))) + (for [[id subject] (:subjects clip) + :when (not (contains? (:analyses clip) (:analysis subject)))] + (str "subject " (pr-str id) " names missing analysis " + (pr-str (:analysis subject)))) + (for [[id subject] (:subjects clip) + :when (not (keyword? (:source-subject subject)))] + (str "subject " (pr-str id) " has no source subject")) (when (and (contains? clip :palettes) (not (map? (:palettes clip)))) [":palettes must be a map of id -> palette"]) (when (and (contains? clip :default-palette) (map? (:palettes clip)) diff --git a/frontend/src/arthur/domain/creation.cljs b/frontend/src/arthur/domain/creation.cljs index ec1f3ca..49660b0 100644 --- a/frontend/src/arthur/domain/creation.cljs +++ b/frontend/src/arthur/domain/creation.cljs @@ -6,20 +6,39 @@ is actually available. See `docs/creating-in.md`." (:require [arthur.domain.clip :as clip] [arthur.domain.nest :as nest] + [arthur.domain.node :as node] [arthur.domain.symbol :as symbol])) +(defn- path-node + "The node at the end of an occurrence `path`, walked from `open`. + + Selection also carries an owner sid and node id for commands that edit the + node directly. Those are deliberately not used here: the path is the address + of the row as seen from the open symbol, and is the only part that describes + every enclosing occurrence at arbitrary depth." + [document open path] + (loop [sid open [id & more] (seq path) found nil] + (if-not id + found + (when-let [n (get-in document [:symbols sid :nodes id])] + (if (seq more) + (when-let [inner (and (= :instance (:kind n)) (node/source n))] + (recur inner more n)) + n))))) + (defn preferred-path "The container path structurally implied by `selection`. Selecting an instance means inside it. Selecting any other node means its containing symbol. An empty or non-node selection means the open symbol." - [document selection] - (let [[kind sid id selected-path] selection + [document open selection] + (let [[kind _sid id selected-path] selection path (when (= :node kind) - (vec (or (seq selected-path) (when id [id]))))] + (vec (or (seq selected-path) (when id [id])))) + selected (path-node document open path)] (cond (empty? path) [] - (= :instance (get-in document [:symbols sid :nodes id :kind])) path + (= :instance (:kind selected)) path :else (vec (butlast path))))) (defn target @@ -33,7 +52,7 @@ Returns `{:kind :lane|:symbol :sid :path :frame :matrix :time}`." [document store open selection frame] - (let [preferred (preferred-path document selection)] + (let [preferred (preferred-path document open selection)] (some (fn [path] (when-let [inside (nest/inside document store open path frame)] (when-let [sid (:sid inside)] diff --git a/frontend/src/arthur/domain/leaf.cljs b/frontend/src/arthur/domain/leaf.cljs index 714d12c..e47accf 100644 --- a/frontend/src/arthur/domain/leaf.cljs +++ b/frontend/src/arthur/domain/leaf.cljs @@ -137,7 +137,7 @@ (some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "palette-default") (select-keys clip [:default-palette])) - (some-leaf (at "source") (:analysis clip)) + (some-leaf (at "analyses") (:analyses clip)) (concat (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) (for [[id v] (:features clip)] {(at "feature" (segment id)) v}) @@ -194,7 +194,7 @@ "name" (merge acc v) "timing" (merge acc v) "stage" (merge acc v) - "source" (assoc acc :analysis v) + "analyses" (assoc acc :analyses v) "palette-default" (merge acc v) "palette" (assoc-in acc [:palettes (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v) @@ -237,7 +237,7 @@ false) (case (count p) ;; The clip's own facts carry no id. - 3 (#{"name" "timing" "stage" "source" "palette-default"} (nth p 2)) + 3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2)) false))))] (vec diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index 0f2a2fb..34d4775 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -99,15 +99,30 @@ (or (:dense c) (:generated c))) [:pos :rot :scale]))) +(defn hold-only? + "Channels whose values are choices, not quantities. Their keys may change at + a frame boundary but there is no meaningful value between two keys." + [path value] + (or (= path [:style :color]) + (boolean? value) + (and (keyword? value) (not= path [:palette])))) + +(defn- enforce-hold [path c] + (if (and (= path [:style :color]) (:keys c)) + (-> c (assoc :interp :hold) (dissoc :segments)) + c)) + (defn set-channel "Write `v` into channel `path`: a key on the node's own frame `f` when the channel is keyed, its one value when it is not." [n path f v] (let [c (get (channels n) path)] (assoc-in n [:channels path] - (if (:keys c) - (assoc-in c [:keys f] v) - (merge (select-keys c [:semantic]) (ch/framed v)))))) + (enforce-hold + path + (if (:keys c) + (assoc-in c [:keys f] v) + (merge (select-keys c [:semantic]) (ch/framed v))))))) (defn set-keyed-channel "Write `v` as a key at `f`, starting an animated channel when needed. This is @@ -116,10 +131,12 @@ [n path f v] (let [c (get (channels n) path)] (assoc-in n [:channels path] - (if (:keys c) - (assoc-in c [:keys f] v) - (merge (select-keys c [:semantic]) - (ch/keyed {f v} (if (or (boolean? v) (keyword? v)) :hold :linear))))))) + (enforce-hold + path + (if (:keys c) + (assoc-in c [:keys f] v) + (merge (select-keys c [:semantic]) + (ch/keyed {f v} (if (hold-only? path v) :hold :linear)))))))) (defn toggle-key "Key channel `path` on the node's own frame `f` with the value it has there, or @@ -127,19 +144,22 @@ the last one off leaves it that one value. A boolean holds; anything else tweens. `store` because the value it keys is read out of the channel, and a measured - channel's values live in tier 2." + channel's values live in tier 2. Colour is always held even though current + documents store palette choices as numeric slot indices." [n path f store] (let [c (get (channels n) path) v (ch/value-at c f store) ks (dissoc (:keys c) f)] (assoc-in n [:channels path] - (cond - (not (:keys c)) (merge (select-keys c [:semantic]) - (ch/keyed {f v} (if (or (boolean? v) (keyword? v)) :hold :linear))) - (not (contains? (:keys c) f)) (assoc-in c [:keys f] v) - (seq ks) (cond-> (assoc c :keys ks) - (:segments c) (update :segments dissoc f)) - :else (ch/framed v))))) + (enforce-hold + path + (cond + (not (:keys c)) (merge (select-keys c [:semantic]) + (ch/keyed {f v} (if (hold-only? path v) :hold :linear))) + (not (contains? (:keys c) f)) (assoc-in c [:keys f] v) + (seq ks) (cond-> (assoc c :keys ks) + (:segments c) (update :segments dissoc f)) + :else (ch/framed v)))))) (defn set-segment-interp "Choose how channel `path`'s key at `left` leads to the next one: `:hold` cuts @@ -147,7 +167,9 @@ only a gap that exists, between a key and a later one, can be chosen." [n path left interp] (let [ks (:keys (get (channels n) path))] - (if (and (contains? ks left) (some #(< left %) (keys ks)) (#{:hold :linear} interp)) + (if (and (contains? ks left) (some #(< left %) (keys ks)) + (#{:hold :linear} interp) + (or (= :hold interp) (not (hold-only? path nil)))) (assoc-in n [:channels path :segments left] interp) n))) diff --git a/frontend/src/arthur/domain/palette.cljs b/frontend/src/arthur/domain/palette.cljs index 2669fdb..ca9e6b8 100644 --- a/frontend/src/arthur/domain/palette.cljs +++ b/frontend/src/arthur/domain/palette.cljs @@ -7,6 +7,7 @@ are derived, never persisted: adding a palette never rewrites drawing data.") (def default-id :arthur/default) +(def inherit :arthur.palette/inherit) (def entries [{:name :bg :hex "#12141c"} diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index 5c5afc1..38920a7 100644 --- a/frontend/src/arthur/domain/project.cljs +++ b/frontend/src/arthur/domain/project.cljs @@ -29,9 +29,7 @@ (:require [arthur.domain.leaf :as leaf] [arthur.domain.wire :as wire])) -(def schema-version - "4 separates a symbol's static authoring palette from its palette track." - 4) +(def schema-version 5) (defn block-keys "Every tier-2 key a leaf map names, in a stable order." diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 0ba6622..c407451 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -730,9 +730,7 @@ (-> [] (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")) + (conj "a palette symbol cannot contain nodes")) (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/footage.cljs b/frontend/src/arthur/events/footage.cljs index bc2e80b..3a7670c 100644 --- a/frontend/src/arthur/events/footage.cljs +++ b/frontend/src/arthur/events/footage.cljs @@ -151,7 +151,8 @@ :detector detector}) _ (mark! "build-clip: freeze") built (:clip frozen) - source-blocks (source/pack-subjects (:id (:analysis built)) subjects) + analysis-id (-> built :analyses keys first) + source-blocks (source/pack-subjects analysis-id subjects) _ (mark! "build-clip: pack source blocks")] (assoc (select-keys built [:fps :width :height]) @@ -394,7 +395,10 @@ (rf/reg-event-db ::choose - (fn [db [_ id]] (assoc-in db [:footage :chosen] id))) + (fn [db [_ id]] + (-> db + (assoc-in [:footage :chosen] id) + (ui/selected [:footage id])))) ;; --------------------------------------------------------------------------- ;; renaming an asset @@ -504,7 +508,7 @@ (let [entry (store/entry (:clip/current db)) uuid (random-uuid) fps (get-in db [:clip :fps]) - {:keys [clip sid tracked?]} + {:keys [clip sid subject-ids]} (bring/take (:clip entry) (:clip built) name footage-id range) st (merge (:store entry) (:store built)) imported-frames (clip/output-frames clip sid) @@ -525,9 +529,22 @@ (update :footage merge {:loading? false :status why}))} (let [db (edit/edit-entry db - #(cond-> (assoc % :clip (:clip result) :store st) - tracked? (merge (select-keys built [:footage-id :source-blocks - :source-inputs]))))] + #(let [analysis-id (-> built :clip :analyses keys first) + remap-subjects + (fn [inputs] + (update inputs :subjects + (fn [subjects] + (into {} (map (fn [[old new]] [new (get subjects old)])) + subject-ids))))] + (-> (assoc % :clip (:clip result) :store st) + (update-in [:sources analysis-id] + (fn [source] + {:footage-id (:footage-id built) + :source-blocks (:source-blocks built) + :source-inputs + (update (remap-subjects (:source-inputs built)) + :subjects merge + (get-in source [:source-inputs :subjects]))})))))] {:db (-> db (update :ui dissoc :convert) (ui/selected @@ -536,7 +553,5 @@ {:loading? false :status (str "made " name " · " imported-frames " frames at " fps " fps" (when (not= fps source-fps) - (str " · sampled from " source-fps " fps")) - (when-not tracked? - " · as drawings: this project already tracks other footage"))})) + (str " · sampled from " source-fps " fps")))})) :dispatch [::pb/refresh-clock]}))))) diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index 295ccfe..6838371 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -102,15 +102,10 @@ (.then (fn [^js created] (.-id created)))))) (defn- opened-entry! [^js clip-json] - (let [footage-id (.-footage clip-json)] - (-> (js/Promise.all - #js [(js/Promise.all - (into-array (map #(http/GET (str "/api/blocks/" %)) - (array-seq (.-blocks clip-json))))) - (if footage-id - (http/GET (str "/api/footage/" footage-id)) - (js/Promise.resolve nil))]) - (.then (fn [[blocks ^js footage]] + (-> (js/Promise.all + (into-array (map #(http/GET (str "/api/blocks/" %)) + (array-seq (.-blocks clip-json))))) + (.then (fn [blocks] (let [cid (.-cid clip-json) loaded (project/load cid #js {:leaves (.-leaves clip-json) @@ -129,11 +124,9 @@ ;; write lands on what does not. :synced (project/tier1 (.-leaves clip-json)) :clip built :store (:store loaded) - :footage-id footage-id - :audio (if footage (.-audio footage) - "/static/arthur/audio.wav")})] + :audio "/static/arthur/audio.wav"})] (-> (mix/mix! built (clip/opens-on built) (:audio entry) (:store entry)) - (.then (fn [audio] (assoc entry :audio audio))))))))))) + (.then (fn [audio] (assoc entry :audio audio)))))))))) (defn- saved-clip! "Promise of clip `cid` of saved project `pid`, as `{:clip :store}`: its @@ -240,46 +233,60 @@ :removed (into-array (remove #(contains? local %) (keys synced)))}) #js {:leaves (.-leaves doc)})) -(defonce ^:private on-server - ;; Analyses and block keys this page has already put on the server. Content - ;; addressed, so once there they are there: a save of a moved vertex asks for - ;; none of it again, and is one request. +(defonce ^:private uploaded-blocks + ;; Block keys this page has already put on the server. Content addressed, so + ;; once there they are there: a save of a moved vertex asks for none again. (atom #{})) +(defonce ^:private linked-analyses (atom #{})) + (defn- upload-new! [^js doc] (let [keys (array-seq (block-keys doc))] - (if (every? @on-server keys) + (if (every? @uploaded-blocks keys) (js/Promise.resolve 0) - (.then (upload-missing! doc) (fn [n] (swap! on-server into keys) n))))) + (.then (upload-missing! doc) (fn [n] (swap! uploaded-blocks into keys) n))))) + +(defn- analyses-of [entry] + (vals (get-in entry [:clip :analyses]))) + +(defn- upload-sources! [sources] + (reduce + (fn [chain [analysis-id {:keys [source-blocks]}]] + (.then chain + (fn [_] + (when (and (seq source-blocks) + (not (@linked-analyses analysis-id))) + (-> (upload-missing! #js {:blocks (source/upload-blocks source-blocks)}) + (.then #(http/PUT + (str "/api/analyses/" analysis-id) + #js {:source_blocks + (into-array (source/block-keys source-blocks))})) + (.then (fn [answer] + (swap! linked-analyses conj analysis-id) + answer))))))) + (js/Promise.resolve nil) + sources)) (rf/reg-fx ::save! (fn [{:keys [id cid label clip base]}] - (let [analysis (:analysis (:clip clip)) - doc (project/save cid clip) - local (leaf/leaves cid (:clip clip)) - base (when (and id (:synced clip)) base) - source-blocks (:source-blocks clip)] + (let [entry clip + analyses (analyses-of entry) + doc (project/save cid entry) + local (leaf/leaves cid (:clip entry)) + base (when (and id (:synced entry)) base) + sources (:sources entry)] (-> (ensure-project! id label) (.then (fn [pid] - (-> (if (and analysis (not (@on-server (:id analysis)))) - (http/POST "/api/analyses" (analysis-payload analysis)) - (js/Promise.resolve nil)) + (-> (reduce (fn [chain one] + (.then chain + #(http/POST "/api/analyses" + (analysis-payload one)))) + (js/Promise.resolve nil) + analyses) (.then (fn [_] - (when (and (seq source-blocks) (not (@on-server (:id analysis)))) - (-> (upload-missing! - #js {:blocks (source/upload-blocks source-blocks)}) - (.then (fn [_] - (http/PUT - (str "/api/analyses/" (:id analysis)) - ;; One set per tracked subject, in - ;; the order `source/unpack` does - ;; not depend on. - #js {:source_blocks - (into-array - (source/block-keys source-blocks))}))))))) + (upload-sources! sources))) (.then (fn [_] - (when analysis (swap! on-server conj (:id analysis))) (upload-new! doc))) (.then (fn [uploaded] (-> (http/PUT (str "/api/projects/" pid) @@ -288,11 +295,11 @@ :clips #js [(js/Object.assign #js {:cid cid :name label - :analysis (:id analysis) - :footage (:footage-id clip) + :analyses (into-array + (keys (get-in entry [:clip :analyses]))) :blocks (block-keys doc)} (clip-payload doc base local - (:synced clip)))]}) + (:synced entry)))]}) (.then (fn [^js saved] (rf/dispatch [::saved pid cid label (.-seq saved) @@ -439,39 +446,38 @@ (assoc measured :interior-key block-key)))))))) (throw error))))))) -(defn- source-for! [entry] - (if-let [inputs (:source-inputs entry)] - (js/Promise.resolve inputs) - (let [analysis (get-in entry [:clip :analysis])] - (if (= (:id analysis) (:id @retained-source)) +(defn- source-for! [entry edit] + (let [subject (:subject (regenerate/plan (:clip entry) edit)) + subject-record (get-in entry [:clip :subjects subject]) + analysis-id (:analysis subject-record) + analysis (get-in entry [:clip :analyses analysis-id]) + source-subject (:source-subject subject-record) + local (get-in entry [:sources analysis-id :source-inputs :subjects subject])] + (if local + (js/Promise.resolve {:subjects {subject local}}) + (if (= [analysis-id subject] (:key @retained-source)) (:promise @retained-source) (let [promise (if (= "synth" (:detector analysis)) - ;; The synthetic take tracks one face and regenerating it reads - ;; that face's landmarks, so it arrives in the same shape real - ;; footage does rather than in a flat one only this branch uses. (js/Promise.resolve {:subjects - {:face-1 {:dense (synth/synth-dense (:frames analysis) - {:seed (:seed analysis)})}}}) - (-> (ingest/manifest! (:footage-id entry)) - (.then (fn [manifest] - (-> (footage/saved-source! (:id analysis) - [(:width manifest) - (:height manifest)]) - (.then (fn [inputs] - (when-not inputs - (throw (ex-info "saved analysis has no source blocks" {}))) - (update inputs :subjects - (fn [subjects] - (into {} - (map (fn [[id one]] - [id (assoc one :presence - (footage/presence-for - manifest id))])) - subjects))))))))))] + {subject {:dense (synth/synth-dense (:frames analysis) + {:seed (:seed analysis)})}}}) + (.then (ingest/manifest! (:footage subject-record)) + (fn [manifest] + (.then (footage/saved-source! + analysis-id [(:width manifest) (:height manifest)]) + (fn [inputs] + (when-not inputs + (throw (ex-info "saved analysis has no source blocks" {}))) + {:subjects + {subject + (assoc (get-in inputs [:subjects source-subject]) + :presence + (footage/presence-for manifest + source-subject))}})))))] (do - (reset! retained-source {:id (:id analysis) :promise promise}) + (reset! retained-source {:key [analysis-id subject] :promise promise}) promise)))))) (defn- inputs-for-edit! @@ -484,18 +490,19 @@ one (get-in inputs [:subjects subject])] (if (and teeth (:crops one)) (let [settings (merge take/knobs (feature/effective-params clip teeth)) - analysis (get-in clip [:analysis :id]) + analysis (get-in clip [:subjects subject :analysis]) + source-subject (get-in clip [:subjects subject :source-subject]) frames (count (:crops one)) - key (source/interior-key analysis subject settings frames) + key (source/interior-key analysis source-subject settings frames) done (fn [measured] (assoc-in inputs [:subjects subject] measured))] (if (and (:interior one) (or (= (:interior-key one) key) (and (nil? (:interior-key one)) - (= key (source/interior-key analysis subject take/knobs frames))))) + (= key (source/interior-key analysis source-subject take/knobs frames))))) (js/Promise.resolve inputs) (.then (if (= key (:key @retained-interior)) (:promise @retained-interior) - (let [promise (retained-interior! analysis subject settings one)] + (let [promise (retained-interior! analysis source-subject settings one)] (reset! retained-interior {:key key :promise promise}) promise)) done))) @@ -504,7 +511,7 @@ (rf/reg-fx ::preview-settings! (fn [{:keys [id entry edit request]}] - (-> (source-for! entry) + (-> (source-for! entry edit) (.then (fn [inputs] (inputs-for-edit! entry edit inputs))) (.then (fn [inputs] (regenerate/change (assoc entry :source-inputs inputs) edit))) @@ -519,7 +526,7 @@ (fn [{:keys [db]} [_ edit]] (let [id (:clip/current db) entry (store/entry id)] - (if (or (get-in db [:project :busy?]) (nil? (:analysis (:clip entry)))) + (if (or (get-in db [:project :busy?]) (empty? (:analyses (:clip entry)))) {} (let [plan (regenerate/plan (:clip entry) edit) report (select-keys plan [:features :roles]) @@ -722,50 +729,6 @@ (assoc-in % [:symbols sid :palette] id) (update-in % [:symbols sid] dissoc :palette))))) -(rf/reg-event-db - ::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)}) - result (if-let [placed (:clip result)] - (assoc result :clip - (assoc-in placed [:symbols track-id :nodes id :channels [:palette]] - (assoc (ch/framed palette-id) :semantic :palette))) - result)] - (if-let [why (:refused result)] - (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 ;; `frame` is the node's own, as for a drawing key. @@ -777,7 +740,7 @@ (if (get-in document [:symbols sid :nodes id :channels [:palette]]) document (let [source (get-in document [:symbols sid :nodes id :source :symbol]) - palette-id (get-in document [:symbols source :palette-ref])] + palette-id (or (get-in document [:symbols source :palette-ref]) pal/inherit)] (assoc-in document [:symbols sid :nodes id :channels [:palette]] (assoc (ch/framed palette-id) :semantic :palette))))) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 724c56b..b61f2d2 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -17,12 +17,14 @@ app to change and the most expensive to have two copies of." (:require [clojure.string :as str] [arthur.domain.clip :as clip] + [arthur.domain.channel :as ch] [arthur.domain.clipboard :as clipboard] [arthur.domain.correction :as correction] [arthur.domain.creation :as creation] [arthur.domain.gesture :as gesture] [arthur.domain.nest :as nest] [arthur.domain.node :as node] + [arthur.domain.palette :as pal] [arthur.domain.span :as span] [arthur.events.edit :as edit] [arthur.domain.paint :as paint] @@ -343,9 +345,16 @@ (rf/reg-event-db - ::toggle-row - (fn [db [_ path]] - (update-in db [:ui :expanded] #(if (contains? % path) (disj % path) (conj % path))))) + ::set-row-expanded + (fn [db [_ path expanded?]] + ;; The disclosure control says what the next state is instead of asking us + ;; to invert whatever happens to be in app-db by the time its event runs. + ;; A structural drop may reveal this path between pointer-down and click; + ;; blindly toggling then immediately closed the row whose triangle was still + ;; painted as closed. + (update-in db [:ui :expanded] + (fn [paths] + ((if expanded? conj disj) (set paths) path))))) (rf/reg-event-db ::solo @@ -793,6 +802,55 @@ (edit/transaction (constantly (:clip result))) (selected [:node sid uuid (conj (vec path) uuid)])))) +(rf/reg-event-db + ::new-symbol-at + ;; One gesture and one creation path for every lane. The destination decides + ;; the kind: an ordinary lane gets a blank symbol; the synthetic palette row + ;; gets a blank palette symbol whose placement starts by inheriting. + (fn [db [_ _pointer-frame target]] + (let [{document :clip st :store} (store/entry (:clip/current db)) + ;; Creation always happens at the playhead. The double-click only + ;; names the lane; it is not a second, pointer-based time cursor. + frame (editing-frame db document) + palette? (= :arthur.ui.timeline/palette-track (first target)) + root-sid (second target) + root (when palette? (clip/symbol document root-sid)) + old-track (:palette-track root) + track-id (when palette? + (if (= :palette-track (get-in document [:symbols old-track :type])) + old-track (clip/fresh-id document))) + document (if (and palette? (not= track-id old-track)) + (-> 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 {}})) + document) + where (if palette? + {:clip document :sid track-id :at frame :path []} + (drop-destination db document st frame target)) + sid (clip/fresh-id document) + uuid (random-uuid)] + (if (:refused where) + (update db :project merge {:status (:refused where)}) + (let [seeded (assoc-in document [:symbols sid] + (cond-> {:id sid + :name (if palette? "palette transition" (name sid)) + :fps (clip/fps document (:sid where)) + :frames 1 :nodes {}} + palette? (assoc :type :palette))) + result (span/place-symbol seeded st (:sid where) uuid sid (:at where) + {:extent :grow-symbol + :remainder-id (random-uuid)}) + result (if-let [placed (and palette? (:clip result))] + (assoc result :clip + (assoc-in placed [:symbols (:sid where) :nodes uuid + :channels [:palette]] + (assoc (ch/framed pal/inherit) :semantic :palette))) + result)] + (landed db where uuid result)))))) + (rf/reg-event-db ::drop-symbol ;; A symbol dropped on a row becomes a naturally playing clip in the symbol that diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index c83efa5..91d057a 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -762,11 +762,15 @@ {:frames (vec lengths)}))) nf (first lengths) merged (fn [k] (into {} (mapcat (comp k second)) parts)) - built {:name name :fps fps :analysis (:analysis params) + analysis (:analysis params) + built {:name name :fps fps :analyses {(:id analysis) analysis} :width (first stage) :height (second stage) :palettes {pal/default-id pal/default-palette} :default-palette pal/default-id - :subjects (into {} (map (fn [[id _]] [id {:id id :params {}}])) ordered) + :subjects (into {} (map (fn [[id _]] + [id {:id id :params {} + :analysis (:id analysis) + :source-subject id}])) ordered) :features (merged :features) :groups (merged :groups) :symbols (into {:main diff --git a/frontend/src/arthur/flow/regenerate.cljs b/frontend/src/arthur/flow/regenerate.cljs index e228d3a..7148df0 100644 --- a/frontend/src/arthur/flow/regenerate.cljs +++ b/frontend/src/arthur/flow/regenerate.cljs @@ -142,6 +142,11 @@ teeth are dirty, retained pixel measurements. No IO or app-db here." [{:keys [clip source-inputs] :as entry} edit] (let [{:keys [changed subject features]} (plan clip edit) + analysis-id (get-in changed [:subjects subject :analysis]) + analysis (get-in changed [:analyses analysis-id]) + _ (when-not analysis + (throw (ex-info "subject names a missing analysis" + {:subject subject :analysis analysis-id}))) ;; THE EDITED SUBJECT'S OWN LANDMARKS. Retained source is per subject — ;; one video, one dense track per tracked face — so re-measuring the ;; second face through the first one's anchor is the mistake this lookup @@ -152,8 +157,8 @@ {:subject subject}))) base-params (merge take/knobs {:fps (or (get-in changed [:symbols subject :fps]) (:fps changed)) - :aspect (get-in changed [:analysis :aspect]) - :analysis (:analysis changed)}) + :aspect (:aspect analysis) + :analysis analysis}) ;; One conditioned anchor for the whole edit, and it is the SUBJECT's. ;; `:anchor-avg` is a subject setting, so the shared upstream measurement ;; is not read off whichever dirty feature happened to sort first — and diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index d225b09..8b09c31 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -212,7 +212,6 @@ ;; row is not on stage, so neither is its footage. (let [solo (filter #(placed? document open %) solo)] {:document document :store store - :footage-id (:footage-id (footage/entry id)) :opacity (or opacity trace/opacity-default) :traces (cond->> (when document (trace/shown document open faces)) (seq solo) (filterv (fn [{:keys [path]}] diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index 3ffc7b7..763b876 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -156,7 +156,8 @@ ;; than kept beside it, so a sound placed from footage makes that footage the ;; project's without anything else being told. (let [entry (store/entry id) - used (into (set (keep identity [(:footage-id entry)])) uploaded) + used (into (set uploaded) + (keep :footage (vals (get-in entry [:clip :subjects])))) used (into used (for [[_ sym] (get-in entry [:clip :symbols]) [_ n] (:nodes sym) :let [f (get-in n [:source :footage])] diff --git a/frontend/src/arthur/ui/drag.cljs b/frontend/src/arthur/ui/drag.cljs index 530c4eb..1b50f0f 100644 --- a/frontend/src/arthur/ui/drag.cljs +++ b/frontend/src/arthur/ui/drag.cljs @@ -47,13 +47,6 @@ :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." @@ -127,10 +120,3 @@ :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 a1770d7..60f5140 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -2,10 +2,9 @@ "The right pane: what the selection is, and what can be changed about it. Sections rather than a mode switch. The clip's facts are always true, so the - clip section is always there; the node and symbol sections appear when - something of that kind is selected; the tracking section appears when the clip - has analysis in it. Nothing here computes — every control dispatches an intent - and every readout comes off a subscription." + clip section is always there; node, footage, face and tracking sections follow + the thing actually selected. Nothing here computes — every control dispatches + an intent and every readout comes off a subscription." (:require [clojure.string :as str] [arthur.domain.clip :as clip-domain] [arthur.domain.channel :as channel] @@ -208,12 +207,42 @@ v))) (when gap? [segment-select sid id path ch left])])) +(defn- color-control [sid id ch frame auto-key? palette] + (let [keyed? (some? (:keys ch)) + value (channel/value-at ch (or frame 0) nil) + value (if (integer? value) + value + (or (first (keep-indexed #(when (= value (:name %2)) %1) + (:slots palette))) 0)) + off? (and keyed? (nil? frame))] + [:dd.channel {:class (when auto-key? "live")} + [:button.key {:class (cond (contains? (:keys ch) frame) "on" keyed? "keyed") + :disabled (nil? frame) + :title (if keyed? (str (count (:keys ch)) " held keys") "key color here") + :on-click #(rf/dispatch [::project/toggle-key sid id [:style :color] frame])} + "◆"] + [:div.channel-swatches + (doall + (for [[i {:keys [name hex]}] (map-indexed vector (:slots palette))] + ^{:key i} + [:button.swatch + {:class (when (= i value) "on") + :style {:background hex} + :disabled off? + :aria-label (str "color " i (when name (str " " (clojure.core/name name)))) + :aria-pressed (= i value) + :title (str i (when name (str " · " (clojure.core/name name))) " · " hex) + :on-click #(rf/dispatch [::project/set-channel sid id [:style :color] frame i])}]))] + (when keyed? [:span.dim "hold"])])) + (defn- node-section [[sid id n]] (let [[start end] (:span n) auto-key? @(rf/subscribe [::sub/auto-key?]) clip @(rf/subscribe [::render/clip]) local @(rf/subscribe [::sub/selected-local]) - frame (:frame local)] + frame (:frame local) + palette-id (or (get-in clip [:symbols sid :palette]) (pal/default-palette-id clip)) + palette (get (pal/palettes clip) palette-id)] [section (str (name (:kind n)) " · in " (name sid)) [facts "id" (brief id) @@ -232,13 +261,24 @@ (let [{:keys [frame]} local] [:dl.facts (doall - (for [[path ch] (sort-by (comp str key) (node/channels n))] + (for [[path ch] (sort-by (fn [[path]] + [(cond + (= :xform (first path)) 0 + (= path [:style :color]) 1 + :else 2) + (str path)]) + (node/channels n))] ^{:key (str path)} [:<> [:dt (str/join " " (map name path))] - (if (and (contains? (node/defaults-of n) path) (not (:dense ch))) + (cond + (and (= path [:style :color]) (not (:dense ch))) + [color-control sid id ch frame auto-key? palette] + + (and (contains? (node/defaults-of n) path) (not (:dense ch))) [channel-control sid id path ch frame auto-key?] - [:dd (channel-state ch)])]))])])) + + :else [:dd (channel-state ch)])]))])])) (defn- palette-placement-section [[sid id n]] (let [clip @(rf/subscribe [::render/clip]) @@ -268,12 +308,15 @@ :title (if (:keys ch) (str (count (:keys ch)) " keys") "key this here") :on-click #(rf/dispatch [::project/toggle-palette-key sid id frame])} "◆"] - [:select {:value (str chosen) + [:select {:value (if (= pal/inherit chosen) "" (str chosen)) :on-change (fn [e] (let [v (.. e -target -value) - palette-id (first (filter #(= v (str %)) (keys (pal/palettes clip))))] + palette-id (or (first (filter #(= v (str %)) + (keys (pal/palettes clip)))) + pal/inherit)] (rf/dispatch [::project/set-palette-choice sid id frame palette-id])))} + [:option {:value ""} "inherit"] (for [[palette-id p] palettes] ^{:key (str palette-id)} [:option {:value (str palette-id)} (:name p)])]]] @@ -428,19 +471,16 @@ ;; the footage showing under the picture (defn- footage-section - "The footage under the faces the open symbol has, on or off and how strongly. - `here` is those faces. + "The footage under the explicitly selected face or footage asset, on or off + and how strongly. `here` is exactly those faces. ONE SWITCH FOR THE FACES THAT ARE HERE. A face's footage is the face's, not a placement's, so there is nothing to inherit and nothing to set twice; with several faces in a take the box says how many are showing and switches the rest on, and one face alone is switched from its own timeline row. - THE OPEN SYMBOL'S FACES AND NOT THE SELECTION'S, which is what lets this live in - the inspector at all: a viewing aid that appeared only once the right row had - been found would make the way to see the footage you are tracing depend on what - you had clicked. So the section is there whenever the picture on the stage has - any footage behind it, wherever the selection happens to be." + This is editor state, but the inspector still obeys selection scope: selecting + an unrelated shape must neither expose nor mutate a face's viewing aid." [here] (let [{:keys [faces opacity]} @(rf/subscribe [::render/tracing]) on (filterv (set faces) here)] @@ -654,13 +694,45 @@ (max 1 (* 2 default))) :step (cond even? 2 (= type :integer) 1 :else 0.01)}) -(defn- tracking-section [] +(defn- subject-of-owner [clip [scope id]] + (case scope + :subject id + :feature (get-in clip [:features id :subject]) + :group (get-in clip [:groups id :subject]) + nil)) + +(defn- face-selection + "The one face explicitly named by a face symbol, its placement, or a tracking + owner. A shape merely living inside an open face is deliberately not one." + [clip selection selected-node] + (let [[kind id] selection + placed (node/source (peek selected-node)) + candidate (cond + (= :symbol kind) id + (#{:subject :feature :group} kind) (subject-of-owner clip selection) + (= :node kind) placed)] + (when (and candidate (trace/traceable? clip candidate)) candidate))) + +(defn- footage-faces [clip selection selected-node] + (if (= :footage (first selection)) + (let [footage-id (second selection)] + (into [] (comp (filter #(= footage-id (:footage (val %)))) (map key)) + (:subjects clip))) + (some-> (face-selection clip selection selected-node) vector))) + +(defn- tracking-owners [clip selection selected-node] + (let [faces (if (= :footage (first selection)) + (set (footage-faces clip selection selected-node)) + (some-> (face-selection clip selection selected-node) hash-set))] + (when (seq faces) + (filterv #(contains? faces (subject-of-owner clip %)) (owners clip))))) + +(defn- tracking-section [all] (let [clip @(rf/subscribe [::render/clip]) selection @(rf/subscribe [::sub/selection]) knobs @(rf/subscribe [::sub/knobs]) busy? (:busy? @(rf/subscribe [::playback/project])) report @(rf/subscribe [::project/regeneration]) - all (owners clip) [scope id :as owner] (if (some #{selection} all) selection (first all)) area (case scope :subject :subject @@ -724,7 +796,6 @@ open @(rf/subscribe [::render/open]) selection @(rf/subscribe [::sub/selection]) node @(rf/subscribe [::sub/selected-node]) - tracked? (seq (owners clip)) ;; The face the tracing section is about: the SELECTED PLACEMENT's symbol, ;; or, when the selection is not an instance or there is none, the OPEN ;; symbol — which is the face itself when a face is open to be drawn over. @@ -734,16 +805,15 @@ ;; lane traces is not a question with one answer, and naming the drawing ;; showing now would move the section under the playhead. placed (node/source (peek node)) + footage-faces (footage-faces clip selection node) + tracking-owners (tracking-owners clip selection node) palette-placement? (and node - (= :palette (get-in clip [:symbols placed :type]))) + (= :palette-track + (get-in clip [:symbols (first node) :type]))) palette-symbol? (and (= :symbol (first selection)) (= :palette (get-in clip [:symbols (second selection) :type]))) face (or placed (when (trace/traceable? clip open) open)) faces (when face (trace/faces clip face)) - ;; Every face the open symbol has, which is what the footage switch is - ;; about: the stage either has footage behind it or it has none, and that - ;; does not depend on what is selected. - here (trace/traceable-faces clip open) ;; Where that face sits, as a row path from the open symbol, so the faces ;; inside it can be selected by their own rows. A selection made on the ;; stage has no path and names a node directly in the open symbol; the @@ -759,12 +829,13 @@ (when (and placed (not palette-placement?)) [symbol-section placed "source symbol"]) (when (and node (not palette-placement?)) ^{:key (str (first node) "/" (second node))} [correction-section node]) - (when (and (not palette-placement?) (seq here)) [footage-section here]) + (when (and (not palette-placement?) (seq footage-faces)) + [footage-section footage-faces]) (when (and face (or (trace/traceable? clip face) (seq faces))) [tracing-section face faces path]) (when face ^{:key (str "perf/" face)} [performance-section face]) (when palette-symbol? [palette-symbol-section (second selection)]) (when (and (= :symbol (first selection)) (not palette-symbol?)) [symbol-section (second selection)]) - (when (and tracked? (not palette-placement?) (not palette-symbol?)) - [tracking-section])]])) + (when (and (seq tracking-owners) (not palette-placement?) (not palette-symbol?)) + [tracking-section tracking-owners])]])) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index 16b047e..5c93ba0 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -271,25 +271,6 @@ (carrying (str "symbol:" (subs (str sid) 1)) #(drag/symbol! clip-id sid open)))])) -(defn- palette-row [id p default-id chosen rename] - ^{:key (str id)} - [row (merge {:label (:name p) - :sub (str (count (:slots p)) " 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)))] - ^{:key i} [:i {:style {:background (:hex s)}}])] - :on? (= id chosen) - :rename (assoc rename :key [:palette id] :value (:name p) - :commit! (fn [value] - ((: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])} - (carrying (str "palette:" id) #(drag/palette! id (:name p))))]) - (defn- palette-transition-row [document sid selection rename] (let [sym (clip/symbol document sid) p (get (pal/palettes document) (:palette-ref sym))] @@ -423,7 +404,12 @@ (let [named? #(hit? query (clip/symbol-name document %)) top (when (and main (named? main)) main) symbol-ids (sort-by str (keys (:symbols document))) - transition? #(= :palette (:type (clip/symbol document %))) + transition-ids (into #{} (mapcat (fn [sym] + (when (= :palette-track (:type sym)) + (keep (comp :symbol :source val) (:nodes sym))))) + (vals (:symbols document))) + transition? #(or (= :palette (:type (clip/symbol document %))) + (contains? transition-ids %)) transitions (filterv #(and (named? %) (transition? %)) symbol-ids) rest (filterv #(and (named? %) (not= main %) (not (#{:palette :palette-track} @@ -431,11 +417,10 @@ symbol-ids) media (filterv #(hit? query (:label %)) media) sounds (filterv #(hit? query (:label %)) sounds) - palettes (filterv #(hit? query (:name (val %))) - (sort-by (comp str :name val) (pal/palettes document)))] + ] [sections searching? (+ (if top 1 0) (count rest) (count transitions) - (count media) (count sounds) (count palettes)) + (count media) (count sounds)) [{:title "project" :searching? searching? :blank "nothing to open yet" :rows (when top [(symbol-row document top ctx)])} @@ -445,11 +430,6 @@ {:title "palette transitions" :searching? searching? :blank "no palette transitions" :rows (mapv #(palette-transition-row document % selection rename) transitions)} - {:title "palettes" :searching? searching? - :blank "no palettes" - :rows (mapv (fn [[id p]] (palette-row id p (:default-palette document) - (or palette-choice (pal/default-palette-id document)) - rename)) palettes)} {:title "media" :searching? searching? :blank "drop a video here" :rows (mapv #(footage-row % chosen rename) media)} diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 5a4a22d..3772771 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -656,11 +656,14 @@ :else [::ui/group [from path]])))))})) [:button.tl-twist {:disabled (not expandable?) + ;; This button lives inside a draggable row label. Do not let a tiny + ;; pointer movement turn disclosure into a native row drag (whose click + ;; is then suppressed by the browser). + :draggable false + :on-pointer-down (fn [^js e] (.stopPropagation e)) :on-click (fn [^js e] - (println "heyyy") (.stopPropagation e) - (rf/dispatch [::ui/toggle-row path])) - :class (println expandable?)} + (rf/dispatch [::ui/set-row-expanded path (not expanded?)]))} (when expandable? (if expanded? "▾" "▸"))] ;; A LANE IS NOT AN INSTANCE WEARING A DIFFERENT HAT, to read. Its one row ;; holds blocks that follow one another in time, where every other row @@ -887,30 +890,25 @@ ;; one, else this row's own instance. It opens as a tab, which is what ;; double-clicking the same symbol in the pool does. :on-double-click (fn [^js e] - (when-let [source (and (not= :palette kind) - (or (:source (clip-under e)) of))] - (.stopPropagation e) - (rf/dispatch [::pb/open-symbol source]))) + (let [under (clip-under e)] + (cond + (and lane? (nil? under)) + (do (.stopPropagation e) + (rf/dispatch [::ui/new-symbol-at + (frame-at e frames) select])) + + :else + (when-let [source (or (:source under) of)] + (.stopPropagation e) + (rf/dispatch [::pb/open-symbol source]))))) :ref (when lane? (fn [el] (when el (aset el "arthurLane" select)))) :on-drag-enter (fn [^js e] - ;; A palette hover belongs only to the palette row. If - ;; the pointer leaves it for an ordinary row, clear its - ;; last accepted preview even though this row correctly - ;; declines the native drop. - (when (and (drag/palette?) (not= :palette kind)) - (rf/dispatch [::ui/drop-clear])) (cond - (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] (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) @@ -920,10 +918,6 @@ (drag/hover! :timeline (frame-at e frames) nil select))))) :on-drop (fn [^js 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) diff --git a/frontend/src/arthur/ui/underlay.cljs b/frontend/src/arthur/ui/underlay.cljs index 73c6525..7daf387 100644 --- a/frontend/src/arthur/ui/underlay.cljs +++ b/frontend/src/arthur/ui/underlay.cljs @@ -129,7 +129,7 @@ says where each face's head went and which of its frames it was on, so this reads the frame rather than resolving it again. `on-ready` is called when a still or a manifest that was missing arrives, to paint again." - [{:keys [document store footage-id traces opacity width playing?]} resolver on-ready] + [{:keys [document store traces opacity width playing?]} resolver on-ready] (when-let [^js canvas (:canvas @state)] (let [ctx (.getContext canvas "2d") zoom (/ (.-width canvas) width)] @@ -138,17 +138,20 @@ ;; What each face is showing, forgotten for the faces switched off. Before ;; the early exits, so switching them all off forgets all of them. (swap! state update :last select-keys (map :path traces)) - (when-let [us (and (seq traces) footage-id (urls footage-id on-ready))] - (let [start (first (get-in document [:analysis :range] [0])) - ;; Read once: each face writes only its own entry below, and what it - ;; was showing is what it falls back to. A face that has been off - ;; stage for a few frames still has the still it went away with. - was (:last @state)] + (when (seq traces) + (let [;; Read once: each face writes only its own entry below, and what it + ;; was showing is what it falls back to. + was (:last @state)] ;; ONE OPACITY for all of them, set once: how strongly the reference ;; draws is a property of looking at the stage, not of a face. (set! (.-globalAlpha ctx) opacity) - (doseq [{:keys [path face]} traces] - (let [at (conj path :head) + (doseq [{:keys [path face]} traces + :let [subject (get-in document [:subjects face]) + analysis (get-in document [:analyses (:analysis subject)]) + us (urls (:footage subject) on-ready)] + :when us] + (let [start (first (get analysis :range [0])) + at (conj path :head) world (symbol/world-of resolver at) frame (symbol/frame-of resolver at)] (when (and world (number? frame)) diff --git a/frontend/test/arthur/domain/bring_test.cljs b/frontend/test/arthur/domain/bring_test.cljs index 50cb809..468d754 100644 --- a/frontend/test/arthur/domain/bring_test.cljs +++ b/frontend/test/arthur/domain/bring_test.cljs @@ -26,3 +26,34 @@ (vals (get-in clip [:symbols :take :nodes])))))) "and the copy's instance follows its renamed symbol") (is (empty? (clip/problems clip))))) + +(defn- tracked-take [analysis-id] + {:analyses {analysis-id {:id analysis-id :detector "test" :version "1"}} + :subjects {:face-1 {:id :face-1 :analysis analysis-id + :source-subject :face-1 :params {}}} + :features {:face-1/mouth {:id :face-1/mouth :subject :face-1 + :symbol :face-1 :area :mouth :nodes [:mouth] :params {}}} + :groups {} + :symbols {:main {:id :main :fps 30 :frames 10 + :nodes {:face-1 {:id :face-1 :kind :instance :z "a1" + :source {:symbol :face-1}}}} + :face-1 {:id :face-1 :fps 30 :frames 10 + :nodes {:head {:id :head :kind :group :z "a1" + :measured {[:xform :pos] + {:animated? false :value [0 0]}}} + :mouth {:id :mouth :kind :poly :z "a2"}}}}}) + +(deftest every-import-is-tracked-under-its-unique-symbol-scope + (let [first (bring/take (clip/blank) (tracked-take "analysis-a") + "close up" "footage-a" [0 10]) + second (bring/take (:clip first) (tracked-take "analysis-b") + "close up" "footage-b" [0 10]) + c (:clip second)] + (is (= #{:close-up.face-1 :close-up-2.face-1} (set (keys (:subjects c))))) + (is (= #{"analysis-a" "analysis-b"} (set (keys (:analyses c))))) + (is (= "analysis-a" (get-in c [:subjects :close-up.face-1 :analysis]))) + (is (= "analysis-b" (get-in c [:subjects :close-up-2.face-1 :analysis]))) + (is (= :face-1 (get-in c [:subjects :close-up-2.face-1 :source-subject]))) + (is (= #{:close-up.face-1/mouth :close-up-2.face-1/mouth} + (set (keys (:features c))))) + (is (empty? (clip/problems c))))) diff --git a/frontend/test/arthur/domain/cadence_test.cljs b/frontend/test/arthur/domain/cadence_test.cljs index c32002b..9e8c30a 100644 --- a/frontend/test/arthur/domain/cadence_test.cljs +++ b/frontend/test/arthur/domain/cadence_test.cljs @@ -90,8 +90,17 @@ (is (or (zero? f) (< (clip/shown-frame doc :main (dec f)) n)))))))) (deftest imported-footage-uses-selection-for-picture-and-real-speed-for-audio - (let [{doc :clip sid :sid} (bring/take (clip/set-fps (clip/blank) 12) - footage "take" "video" [15 75]) + (let [tracked (-> footage + (assoc :analyses {"a" {:id "a"}} + :subjects {:face-1 {:id :face-1 :analysis "a" + :source-subject :face-1}} + :features {} :groups {} + :symbols {:main {:id :main :fps 30 :frames 60 + :nodes {:face-1 {:id :face-1 :kind :instance + :z "a" :source {:symbol :face-1}}}} + :face-1 (get-in footage [:symbols :main])})) + {doc :clip sid :sid} (bring/take (clip/set-fps (clip/blank) 12) + tracked "take" "video" [15 75]) ;; A host authored at 12, with a 30fps source starting half a second in. doc (-> doc (assoc-in [:symbols :main :fps] 12) (clip/place-symbol store :main sid 6 :insert nil)) @@ -99,21 +108,16 @@ draw (clip/resolver doc :main store pal/index-of nil) [sound] (nest/audio-tracks doc :main)] (is (= 60 (clip/frames doc sid))) - (is (= 1 (get-in doc [:symbols sid :nodes :sound :time :rate]))) + (is (= 1 (get-in doc [:symbols :take.face-1 :nodes :sound :time :rate]))) (is (= [0 24] (:span n))) (is (empty? (draw 5))) (is (= 8 (:size (first (draw 9))))) - (is (= 7 (:frame (nest/inside doc store :main [:insert :mark] 9)))) + (is (= 7 (:frame (nest/inside doc store :main [:insert :face-1 :mark] 9)))) (is (= [-4 -4 4 4] ((pick/bounds-of doc store :main n) 3))) (is (= [6 30] (node/placed-span sound))) (is (= 15 (node/local-frame sound 6))) (is (= 1 (* (get-in sound [:time :rate]) (/ (:fps doc) (:fps sound))))) (is (= [6 30] (:span (first (timeline/rows doc :main #{[:insert]}))))) - (testing "an off-grid closed mouth can win without modifying the dense source" - (let [pinned (pose/put-cut doc :main :insert :mouth 7 6) - r (clip/resolver pinned :main store pal/index-of nil)] - (is (= 7 (:size (first (r 9))))) - (is (= (get-in doc [:symbols sid]) (get-in pinned [:symbols sid]))))) (testing "a deliberate half speed still retimes audio" (let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5) [track] (nest/audio-tracks slow :main)] @@ -121,7 +125,9 @@ (deftest generated-faces-own-their-footage-sound (let [face (fn [id] {:id id :fps 30 :frames 20 :nodes {}}) - frozen {:fps 30 :subjects {:face-1 {} :face-2 {}} + frozen {:fps 30 :analyses {"a" {:id "a"}} + :subjects {:face-1 {:id :face-1 :analysis "a" :source-subject :face-1} + :face-2 {:id :face-2 :analysis "a" :source-subject :face-2}} :symbols {:main {:id :main :fps 30 :frames 20 :nodes {:face-1 {:id :face-1 :kind :instance :z "a1" :source {:symbol :face-1}} @@ -130,11 +136,11 @@ :face-1 (face :face-1) :face-2 (face :face-2)}} {doc :clip take :sid} (bring/take (clip/blank) frozen "take" "video" [3 13]) - alone (clip/place-symbol doc nil :main :face-1 4 :placed-face nil)] + alone (clip/place-symbol doc nil :main :take.face-1 4 :placed-face nil)] (is (nil? (get-in doc [:symbols take :nodes :sound])) "the wrapper does not own a sound the face would lose") (is (= {:footage "video"} - (get-in doc [:symbols :face-1 :nodes :sound :source]))) + (get-in doc [:symbols :take.face-1 :nodes :sound :source]))) (is (= 1 (count (nest/audio-tracks doc take))) "the same take recording is not mixed once per detected face") (is (= 1 (count (nest/audio-tracks alone :main))) diff --git a/frontend/test/arthur/domain/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs index be640bb..fd3fc9d 100644 --- a/frontend/test/arthur/domain/channel_test.cljs +++ b/frontend/test/arthur/domain/channel_test.cljs @@ -4,7 +4,8 @@ in the renderer reads. These assert the parts of that claim that could silently stop being true." (:require [cljs.test :refer [deftest is testing]] - [arthur.domain.channel :as ch])) + [arthur.domain.channel :as ch] + [arthur.domain.palette :as pal])) ;; ---- the three shapes read the same way ---- @@ -392,6 +393,21 @@ (is (= {:from :day :to :night :t 0.5} (ch/value-at blend 5 nil))))) +(deftest palette-choice-channels-blend-editor-uuid-identities + (let [day (random-uuid) + night (random-uuid) + blend (assoc (ch/keyed {0 day 10 night} :linear) :semantic :palette)] + (is (empty? (ch/problems blend))) + (is (= {:from day :to night :t 0.5} + (ch/value-at blend 5 nil))))) + +(deftest palette-choice-channels-can-blend-from-inherit + (let [night (random-uuid) + blend (assoc (ch/keyed {0 pal/inherit 10 night} :linear) :semantic :palette)] + (is (empty? (ch/problems blend))) + (is (= {:from pal/inherit :to night :t 0.5} + (ch/value-at blend 5 nil))))) + (deftest numeric-channels-can-ramp-between-keys (let [c (ch/keyed {0 0.0, 10 1.0} :linear) cursor (ch/cursor c nil)] diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index aec3c64..7323f9e 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -406,3 +406,32 @@ (is (nil? (:refused extended)) (:refused extended)) (is (= [0 6] (node/placed-span (get-in extended [:clip :symbols :palettes :nodes :change])))))) + +(deftest a-blank-palette-symbol-can-tween-from-inherit-to-an-editor-palette + (let [day (random-uuid) + night (random-uuid) + palette (fn [id color] + {:id id :name (str id) :slots [{:hex color} {:hex color}]}) + choice (assoc (ch/keyed {0 pal/inherit 4 night} :linear) :semantic :palette) + document {:fps 30 :width 20 :height 20 + :palettes {day (palette day "#000000") + night (palette night "#ffffff")} + :default-palette day + :symbols + {:main {:id :main :frames 5 :palette day :palette-track :palettes + :nodes {}} + :palettes {:id :palettes :type :palette-track :display :lane :frames 5 + :nodes {:change {:id :change :kind :instance :z "a1" + :source {:symbol :transition} + :span [0 5] :time {:at 0} + :channels {[:palette] choice}}}} + :transition {:id :transition :name "palette transition" + :type :palette :frames 1 :nodes {}}}} + context (pal/compile document) + resolve (clip/resolver document :main nil context nil)] + (is (empty? (clip/problems document))) + (resolve 2) + (is (= {:from day :to night :t 0.5} (clip/active-palette resolve))) + (is (= [128 128 128] + (nth (pal/effective-ramp context (clip/active-palette resolve)) + (pal/render-index context day 1)))))) diff --git a/frontend/test/arthur/domain/leaf_test.cljs b/frontend/test/arthur/domain/leaf_test.cljs index cae7576..0978d65 100644 --- a/frontend/test/arthur/domain/leaf_test.cljs +++ b/frontend/test/arthur/domain/leaf_test.cljs @@ -45,7 +45,7 @@ (let [ls (leaf/leaves :c7 @take/clip)] (is (contains? ls "clip/c7/timing")) (is (contains? ls "clip/c7/stage")) - (is (contains? ls "clip/c7/source")) + (is (contains? ls "clip/c7/analyses")) ;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be ;; addressed by the same path shape as the clip's own. `main` is the root. (is (contains? ls "clip/c7/symbol/main")) diff --git a/frontend/test/arthur/domain/node_test.cljs b/frontend/test/arthur/domain/node_test.cljs index 5c19862..73d96dc 100644 --- a/frontend/test/arthur/domain/node_test.cljs +++ b/frontend/test/arthur/domain/node_test.cljs @@ -226,3 +226,20 @@ (is (= :linear (get-in moved [:channels [:xform :rot] :interp]))) (is (= :hold (get-in visible [:channels [:vis] :interp])) "boolean parameters do not tween"))) + +(deftest colour-keys-are-discrete + (let [n {:id :x :kind :poly + :channels {[:style :color] (ch/framed 2)}} + keyed (node/set-keyed-channel n [:style :color] 3 4) + two (node/set-keyed-channel keyed [:style :color] 8 7)] + (is (= :hold (get-in two [:channels [:style :color] :interp])) + "numeric palette slots still hold") + (is (= two (node/set-segment-interp two [:style :color] 3 :linear)) + "a caller cannot introduce a colour tween") + (is (= :hold (get-in (node/set-channel + (assoc-in two [:channels [:style :color] :interp] :linear) + [:style :color] 8 6) + [:channels [:style :color] :interp])) + "editing repairs an older tweened colour channel") + (is (= :hold (get-in (node/toggle-key n [:style :color] 3 nil) + [:channels [:style :color] :interp]))))) diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index 42b576d..b4b3783 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -5,6 +5,7 @@ [arthur.domain.history :as history] [arthur.domain.leaf :as leaf] [arthur.domain.node :as node] + [arthur.domain.palette :as pal] [arthur.domain.sequence-test :as fixture] [arthur.domain.span :as span] [arthur.events.ui :as ui] @@ -71,6 +72,44 @@ (vals (get-in saved [:symbols :main :nodes])))) "claiming the frame leaves no overlapping cel")))) +(deftest double-click-creation-uses-the-lane-and-playhead + (let [doc (fixture/document) + id (store/install! {:clip doc :store {}} "double-click-new-symbol")] + (reset! rf-db/app-db {:clip/current id :paint/revision 0 + :ui {:open :main} :playback {:frame 5}}) + (rf/dispatch-sync [::ui/new-symbol-at 5 [:node :main nil []]]) + (let [saved (:clip (store/entry id)) + [_ sid instance-id] (get-in @rf-db/app-db [:ui :selection]) + selection (get-in @rf-db/app-db [:ui :selection]) + instance (get-in saved [:symbols sid :nodes instance-id])] + (is (= :main sid)) + (is (= [5 6] (node/placed-span instance))) + (is (= 1 (clip/frames saved (node/source instance)))) + (is (= (node/source instance) + (:sid (creation/target saved {} :main selection 5))) + "the new cel can immediately be selected as the creation target") + (is (= 5 (get-in @rf-db/app-db [:playback :frame])) + "the playhead chooses the new cel's time")))) + +(deftest the-same-double-click-command-creates-a-palette-symbol-on-the-palette-row + (let [doc (clip/blank) + id (store/install! {:clip doc :store {}} "double-click-palette-symbol")] + (reset! rf-db/app-db {:clip/current id :paint/revision 0 + :ui {:open :main} :playback {:frame 5}}) + (rf/dispatch-sync [::ui/new-symbol-at 5 + [:arthur.ui.timeline/palette-track :main]]) + (let [saved (:clip (store/entry id)) + track-id (get-in saved [:symbols :main :palette-track]) + [_ sid instance-id] (get-in @rf-db/app-db [:ui :selection]) + instance (get-in saved [:symbols sid :nodes instance-id]) + source (node/source instance)] + (is (= track-id sid)) + (is (= :palette-track (get-in saved [:symbols track-id :type]))) + (is (= :palette (get-in saved [:symbols source :type]))) + (is (= [5 6] (node/placed-span instance))) + (is (= pal/inherit + (get-in instance [:channels [:palette] :value])))))) + (deftest a-new-lane-uses-the-symbol-selected-at-the-playhead (let [doc (clip/blank) id (store/install! {:clip doc :store {}} "nested-new-lane")] @@ -178,6 +217,36 @@ (at 25)) "a lane behind an inactive parent occurrence is not valid"))) +(deftest creation-uses-the-occurrence-path-at-arbitrary-depth + (let [instance (fn [id source span] + {:id id :kind :instance :z "a" :span span + :time {:mode :map :at 0 :rate 1} + :source {:symbol source} + :playback {:in 0 :speed 1 :end :stop}}) + doc {:fps 24 :width 20 :height 20 + :symbols + {:main {:id :main :frames 20 + :nodes {:outer (instance :outer :ordinary [0 20])}} + :ordinary {:id :ordinary :frames 20 + :nodes {:lane (instance :lane :symbol-7 [0 20])}} + :symbol-7 {:id :symbol-7 :name "symbol-7" :display :lane :frames 20 + :nodes {:cel (instance :cel :symbol-8 [4 10])}} + :symbol-8 {:id :symbol-8 :name "symbol-8" :frames 6 :nodes {}}}} + lane-selection [:node :ordinary :lane [:outer :lane]] + ;; The path, rather than these redundant owner fields, is authoritative + ;; for creation. A row address may name the occurrence from an outer + ;; view; resolution must still enter the terminal cel. + cel-selection [:node :ordinary :lane [:outer :lane :cel]] + pick #(select-keys (creation/target doc nil :main % 5) + [:kind :sid :path :frame])] + (is (= {:kind :lane :sid :symbol-7 :path [:outer :lane] :frame 5} + (pick lane-selection)) + "the nested lane header remains the lane insertion surface") + (is (= {:kind :symbol :sid :symbol-8 + :path [:outer :lane :cel] :frame 5} + (pick cel-selection)) + "the cel enters its symbol no matter how deeply the lane is nested"))) + (deftest creation-walks-out-of-an-instance-past-its-window (let [doc {:fps 24 :width 20 :height 20 :symbols @@ -325,6 +394,16 @@ (is (= #{[:already-open]} (get-in after [:ui :expanded])) "disclosure is changed only by the twist control"))) +(deftest disclosure-sets-the-state-painted-by-the-button + ;; A move can reveal a destination after the old, closed button has already + ;; received pointer-down. Its eventual click must keep the row open instead + ;; of toggling the newer state closed again. + (reset! rf-db/app-db {:ui {:expanded #{[:destination]}}}) + (rf/dispatch-sync [::ui/set-row-expanded [:destination] true]) + (is (= #{[:destination]} (get-in @rf-db/app-db [:ui :expanded]))) + (rf/dispatch-sync [::ui/set-row-expanded [:destination] false]) + (is (empty? (get-in @rf-db/app-db [:ui :expanded])))) + (deftest finishing-a-polygon-opens-no-rows ;; Expansion is the twist triangle's business. Finishing a shape used to open ;; every row down to it, which inside a lane meant tearing its one row into a diff --git a/frontend/test/arthur/flow/regenerate_test.cljs b/frontend/test/arthur/flow/regenerate_test.cljs index 232e7a6..12b9ab0 100644 --- a/frontend/test/arthur/flow/regenerate_test.cljs +++ b/frontend/test/arthur/flow/regenerate_test.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :refer [deftest is testing]] [clojure.walk :as walk] [arthur.demo.stage :as stage] + [arthur.domain.bring :as bring] [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] [arthur.domain.params :as params] @@ -29,13 +30,41 @@ (merge take/knobs {:name "regen" :fps 30 :aspect 1 :stage [320 200] :expose 1 :head :free - :analysis (get-in @initial [:clip :analysis])} + :analysis (-> @initial :clip :analyses vals first)} change) {:face-1 @inputs})) (defn- channel [entry node path] (get-in entry [:clip :symbols :face-1 :nodes node :channels path])) +(deftest a-second-import-regenerates-through-its-own-analysis + (let [built (fn [seed] + (let [input {:dense (synth/synth-dense frames {:seed seed})} + analysis (address/analysis + {:detector "synth" :version "mulberry32" + :seed seed :frames frames :fps 30 :aspect 1})] + [(take/build (merge take/knobs + {:name "take" :fps 30 :aspect 1 :stage [320 200] + :expose 1 :head :free :analysis analysis}) + {:face-1 input}) + input])) + [a input-a] (built 3) + [b input-b] (built 7) + first (bring/take (clip/blank) (:clip a) "shot" "footage-a" [0 frames]) + second (bring/take (:clip first) (:clip b) "shot" "footage-b" [0 frames]) + entry {:clip (:clip second) :store (merge (:store a) (:store b)) + :source-inputs {:subjects {:shot.face-1 input-a + :shot-2.face-1 input-b}}} + fid :shot-2.face-1/mouth + changed (regenerate/change entry + {:scope :feature :id fid :knob :verts :value 10}) + analysis-id (get-in changed [:clip :subjects :shot-2.face-1 :analysis]) + generated (get-in changed [:clip :symbols :shot-2.face-1 :nodes :mouth + :channels [:geom :pts] :generated])] + (is (= (get-in b [:clip :subjects :face-1 :analysis]) analysis-id)) + (is (= analysis-id (:analysis generated))) + (is (= 10 (get-in generated [:params :verts]))))) + (deftest eye-rebuild-is-confined-to-the-edited-feature (let [before @initial after (regenerate/change before @@ -170,7 +199,7 @@ (defn- part-at [area overrides] (let [p (merge take/knobs - {:fps 30 :aspect 1 :analysis (get-in @initial [:clip :analysis])} + {:fps 30 :aspect 1 :analysis (-> @initial :clip :analyses vals first)} overrides) inputs (assoc @inputs :interior @interior-track)] (freeze/part :face-1 area p (take/measure-part area p inputs (take/anchor-base p inputs))))) @@ -212,8 +241,8 @@ ;; document that cannot be previewed fails in the suite rather than as a slider ;; that silently does nothing in the browser. (let [entry (staged)] - (is (some? (:analysis (:clip entry))) - "the composed stage keeps the analysis the edit needs") + (is (seq (:analyses (:clip entry))) + "the composed stage keeps the analyses its edits need") (is (some? (:source-inputs entry))) (testing "and the features still say which timeline they live in" (is (every? #(= :face-1 (:symbol %)) diff --git a/static/arthur/app.css b/static/arthur/app.css index 5951bc7..c3c82c2 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -1384,6 +1384,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .facts dd.channel .key { padding: 0 3px; color: var(--dim); } .facts dd.channel .key.keyed { color: var(--fg); } .facts dd.channel .key.on { color: var(--sel); } +.facts dd.channel .channel-swatches { + display: flex; + flex: 1; + flex-wrap: wrap; + gap: 3px; + min-width: 0; +} +.facts dd.channel .channel-swatches .swatch { flex: 0 0 15px; } .facts dd.channel.live { margin: -2px; padding: 2px;