Unify timeline placement and gesture geometry

This commit is contained in:
Your Name 2026-10-01 23:35:22 -04:00
parent 4e5e02c856
commit 0cb9d0ebf6
16 changed files with 702 additions and 121 deletions

View file

@ -36,8 +36,28 @@
(is (= footage (clip/set-fps doc 30)))
(is (= doc (leaf/clip "test" (leaf/leaves "test" doc))))
(is (= [1 3 6 8 11 13] (mapv #(:size (first (draw %))) (range 6))))
(is (= 7 (:frame (nest/inside doc store :main [:mark] 3))))
(is (= [0 24] (:span (first (timeline/rows doc :main #{})))))))
;; `nest/inside` and the timeline's rows are in the SYMBOL's own frames --
;; see `docs/one-grid-plan.md`. Frame 7 of :main is frame 7 of :main; the
;; output frame that shows it is 3, and `shown-frame` is the one function
;; that crosses between the two.
(is (= 7 (:frame (nest/inside doc store :main [:mark] 7))))
(is (= 7 (clip/shown-frame doc :main 3)))
(is (= 3 (clip/first-output-frame doc :main 7)))
(is (= [0 60] (:span (first (timeline/rows doc :main #{})))))))
(deftest crossing-to-the-output-grid-and-back-lands-on-the-frame-it-names
;; `first-output-frame` is the inverse of `shown-frame` as far as a floor has
;; one: seeking to the output frame it names puts the playhead on a frame at or
;; after the mark, never before it, and exactly on it whenever an output frame
;; shows it at all.
(doseq [project [8 12 24 30 60] native [12 24 30 60]]
(let [doc (-> footage (assoc :fps project) (assoc-in [:symbols :main :fps] native))]
(doseq [n (range 40)]
(let [f (clip/first-output-frame doc :main n)]
(is (integer? f))
(is (>= (clip/shown-frame doc :main f) n)
(str n " at " project "/" native))
(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)

View file

@ -281,3 +281,78 @@
(get-in [:symbols :outer :nodes u]))]
(is (empty? (node/problems v)))
(is (= [0 36] (node/placed-span v)) "90 frames at 30 are 36 at 12")))))
;; ---------------------------------------------------------------------------
;; the ruler IS the symbol's frames
(defn- on-twos
"A 12fps project holding a 30fps lane, which is what changing project fps to
12 leaves behind: `docs/time.md` records each symbol's own rate so its timing
stays put. Two one-frame held clips in it, at 5 and at 19."
[]
(let [held (fn [id at] {:id id :kind :instance :z (str "a-" id) :span [0 1]
;; As `span/held` writes it: no `:mode`, because
;; `node/time-of` reads an absent one as `:map`.
:time {:at at :rate 1}
:source {:symbol :draw} :playback {:in 0 :speed 0 :end :stop}})]
(-> (clip/blank)
(assoc :fps 12)
(assoc-in [:symbols :draw] {:id :draw :fps 30 :frames 1 :nodes {}})
(assoc-in [:symbols :lane] {:id :lane :fps 30 :frames 60 :display :lane
:nodes {:a (held :a 5) :b (held :b 19)}}))))
(deftest a-drag-in-the-open-symbol-is-frame-for-frame
;; `df` counts the OPEN SYMBOL's frames, because that is what the ruler is
;; drawn in -- see `docs/one-grid-plan.md`. So one frame of the gesture is one
;; frame of the document, the project's output rate is not in the arithmetic at
;; all, and nothing can land between two frames.
(doseq [project [8 12 24 30 60]]
(let [c (assoc (on-twos) :fps project)
at #(get-in % [:symbols :lane :nodes :b :time :at])
span #(node/placed-span (get-in % [:symbols :lane :nodes :b]))]
(is (= 20 (at (:clip (nest/slide c :lane [:b] 1)))) (str "at " project "fps"))
(is (= 18 (at (:clip (nest/slide c :lane [:b] -1)))))
(is (= 24 (at (:clip (nest/slide c :lane [:b] 5)))))
(is (= [20 21] (span (:clip (nest/slide c :lane [:b] 1)))))
(testing "an edge drag too"
(let [grown (nest/resize-out c :lane [:b] 1 false)]
(is (nil? (:refused grown)) (:refused grown))
(is (= [19 21] (node/placed-span (get-in (:clip grown) [:symbols :lane :nodes :b]))))
(is (= [18 20] (node/placed-span
(get-in (:clip (nest/resize-in c :lane [:b] -1))
[:symbols :lane :nodes :b])))))))))
(deftest a-drag-through-a-retimed-instance-still-lands-on-a-whole-frame
;; The one case where a ruler frame is not a frame of the space being edited:
;; a row reached THROUGH an instance somebody deliberately retimed. At double
;; speed one frame of the ruler is two inside, and at half speed it is half of
;; one -- which has to round, because a frame number cannot fall between two
;; frames. That rounding is `nest/dragged` and it is the whole of its job.
(let [inner (fn [rate]
(-> (on-twos)
(assoc-in [:symbols :outer]
{:id :outer :fps 30 :frames 60
:nodes {:ins {:id :ins :kind :instance :z "a" :span [0 60]
:time {:mode :map :at 0 :rate rate}
:source {:symbol :lane}
:playback {:in 0 :speed 1 :end :stop}}}})))
at (fn [rate df]
(get-in (:clip (nest/slide (inner rate) :outer [:ins :b] df))
[:symbols :lane :nodes :b :time :at]))]
(is (= 21 (at 2 1)) "double speed inside: one frame of the ruler is two")
(is (= 23 (at 2 2)))
(is (= 20 (at 0.5 2)) "half speed: two frames of the ruler are one inside")
(is (= 20 (at 0.5 1)) "and one lands on the nearer whole frame, half upwards")
(is (every? integer? (map #(at 0.5 %) (range -4 5))))))
(deftest sliding-a-held-clip-moves-it-rather-than-forgetting-where-it-was
;; `span/held` writes `{:at f :rate 1}` with no `:mode`, and the slide used to
;; read that as "no time map" and replace the whole thing — so the first drag
;; of a freshly drawn clip threw its `:at` away and jumped it to the head of
;; the lane. Same grid both sides here, so the only question is the mode.
(let [c (assoc (on-twos) :fps 30)
slid (:clip (nest/slide c :lane [:b] 3))]
(is (= 22 (get-in slid [:symbols :lane :nodes :b :time :at])))
(is (= [22 23] (node/placed-span (get-in slid [:symbols :lane :nodes :b]))))
(is (= 1 (:rate (get-in slid [:symbols :lane :nodes :b :time])))
"and the rest of its map is still there")))

View file

@ -115,3 +115,73 @@
lane (first (filter :lane? (timeline/rows doc :main #{} nil)))]
(is (= [:vo] (mapv :id (:cels lane))))
(is (empty? (timeline/sound-rows doc :main #{})))))
(deftest aiming-the-open-lanes-own-row-is-a-selection-and-not-a-crash
;; `rows` names the open symbol's own lane row `[:node sid nil []]`, and
;; `selected` used to open the rows above a selection with `(pop path)` —
;; which THROWS on `[]`, aborting the whole event. Nothing was selected,
;; nothing was aimed, and the next polygon went wherever the stale target
;; still pointed, which is most of what made aiming a lane feel random.
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "aim-the-open-lane")
db {:clip/current id :paint/revision 0
:ui {:open :main :target {:sid :main :id :a :path [:a]}}
:playback {:frame 0}}
lane (first (filter :lane? (timeline/rows doc :main #{})))
after (ui/aimed db (:select lane))]
(is (= [:node :main nil []] (:select lane)))
(is (= [:node :main nil []] (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target]))
"the open symbol IS the place, so aiming its own row clears the target")
(is (= [] (ui/where-new-goes doc after)))
(is (empty? (get-in after [:ui :expanded]))
"and there are no rows above the top to open")))
(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
;; portal, that clip's channels and every shape already in the drawing.
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "finish-opens-nothing")
db {:clip/current id :paint/revision 0
:ui {:open :main :tool :polygon
:target {:sid :main :id :a :path [:a]}
:draft [10 10 40 10 40 40]}
:playback {:frame 1}}]
(reset! rf-db/app-db db)
(rf/dispatch-sync [::ui/finish-polygon])
(let [after @rf-db/app-db
[kind sid shape-id path] (get-in after [:ui :selection])]
(is (= :node kind))
(is (= :drawing-a sid) "the shape went into the drawing the aimed clip places")
(is (= [:a shape-id] path))
(is (empty? (get-in after [:ui :expanded])))
(is (nil? (get-in after [:ui :tool]))))))
(deftest a-clip-made-for-a-drawing-is-selected-in-the-symbol-it-lives-in
;; The clip `beginning-polygon` materializes lives in the symbol the LANE
;; draws. Naming the open one instead left a selection that looked up to
;; nothing, so the inspector, the breadcrumb and every span command went blank
;; on a drawing that had just been created.
(let [doc (-> (fixture/document)
;; The last of the lane's three clips taken out, so frames 8
;; to 12 are a gap and drawing there makes the held clip that
;; was missing rather than landing in one that is there.
(update-in [:symbols :main :nodes] dissoc :insert)
(assoc-in [:symbols :shot]
{:id :shot :fps 24 :frames 12
:nodes {:girl {:id :girl :kind :instance :z "b" :span [0 12]
:time {:mode :map :at 0 :rate 1}
:source {:symbol :main}
:playback {:in 0 :speed 1 :end :stop}}}}))
id (store/install! {:clip doc :store {}} "clip-selected-where-it-lives")
db {:clip/current id :paint/revision 0
:ui {:open :shot :target {:sid :shot :id :girl :path [:girl]}}
:playback {:frame 9}}
after (ui/beginning-polygon db)
[_ sid clip-id path] (get-in after [:ui :selection])
saved (:clip (store/entry id))]
(is (= :main sid) "the lane's symbol, not the open one")
(is (= [:girl clip-id] path))
(is (some? (get-in saved [:symbols :main :nodes clip-id]))
"and that is where the node actually is")))