grand unification of time

This commit is contained in:
Your Name 2026-10-01 01:47:08 -04:00
parent a4ce750be2
commit 05878ca48b
40 changed files with 334 additions and 292 deletions

View file

@ -0,0 +1,70 @@
(ns arthur.domain.cadence-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.bring :as bring]
[arthur.domain.cadence :as cadence]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.pick :as pick]
[arthur.domain.pose :as pose]
[arthur.ui.timeline :as timeline]))
(def footage
{:fps 30 :width 100 :height 100
:symbols {:main {:id :main :fps 30 :frames 60
:nodes {:mark {:id :mark :kind :rect :z "a" :pose-group :mouth
:channels {[:geom :size] {:animated? true :pose-sampled? true
:dense {:store "sizes" :offset 0 :stride 1 :frames 60}}
[:style :color] (ch/framed :brow)}}}}}})
(def store {"sizes" {:data (js/Float64Array. (clj->js (range 1 61)))}})
(deftest selection-is-integral-and-never-early
(is (= [0 2 5 7 10 12] (mapv #(cadence/frame % 12 30) (range 6))))
(doseq [grid [8 12 24 30 60] native [12 24 30 60] f (range 90)]
(let [selected (cadence/frame f grid native)]
(is (and (integer? selected) (<= selected (/ (* f native) grid))
(< (- (/ (* f native) grid) selected) 1))))))
(deftest changing-output-fps-preserves-dense-content-and-seconds
(let [doc (clip/set-fps footage 12)
draw (clip/resolver doc :main store pal/index-of nil)]
(is (= 24 (clip/output-frames doc :main)))
(is (= (:symbols footage) (:symbols doc)))
(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 #{})))))))
(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])
;; 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))
n (get-in doc [:symbols :main :nodes :insert])
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 (= [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 (= [-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)]
(is (= 0.5 (* (get-in track [:time :rate]) (/ (:fps slow) (:fps track)))))))))

View file

@ -98,7 +98,7 @@
[c st open path f]
(let [{:keys [sid id world frame]} (nest/placement c st open path f)
n (get-in c [:symbols sid :nodes id])
[x0 y0 x1 y1] ((pick/bounds-of c st n) frame)]
[x0 y0 x1 y1] ((pick/bounds-of c st sid n) frame)]
{:corners (mapv #(at world %) [[x0 y0] [x1 y0] [x1 y1] [x0 y1]])
:pivot (at world (:anchor (gesture/values n frame st)))}))
@ -135,7 +135,7 @@
[]
(let [c (two-down)]
(assoc-in c [:symbols :box :nodes :shape :channels [:xform :anchor]]
(ch/framed (pick/pivot c nil (get-in c [:symbols :box :nodes :shape]) [4])))))
(ch/framed (pick/pivot c nil :box (get-in c [:symbols :box :nodes :shape]) [4])))))
(deftest dragging-a-corner-away-from-the-middle-makes-it-bigger
(doseq [[what c path f]
@ -287,5 +287,5 @@
(deftest an-instances-box-is-what-its-symbol-draws
(let [c (two-down)
{:keys [frame]} (nest/placement c nil :main [u v] 16)]
(is (= [0 0 10 10] ((pick/bounds-of c nil (get-in c [:symbols :mid :nodes v])) frame)))
(is (= [0 0 10 10] ((pick/bounds-of c nil (get-in c [:symbols :box :nodes :shape])) 4)))))
(is (= [0 0 10 10] ((pick/bounds-of c nil :mid (get-in c [:symbols :mid :nodes v])) frame)))
(is (= [0 0 10 10] ((pick/bounds-of c nil :box (get-in c [:symbols :box :nodes :shape])) 4)))))

View file

@ -82,9 +82,7 @@
:sym/poses symbol}}
store {"sizes" {:data values}}
resolve (clip/resolver document :main store pal/index-of nil)
low-resolve (clip/resolver document :main store pal/index-of {:picture-fps 8})
at (fn [f] (into {} (map (fn [op] [(:node op) op])) (resolve f)))
low-at (fn [f] (into {} (map (fn [op] [(:node op) op])) (low-resolve f)))]
at (fn [f] (into {} (map (fn [op] [(:node op) op])) (resolve f)))]
(is (empty? (clip/problems document)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (= 102 (:size (get (at 7) [:first :mouth]))) "eight static frames, plus its correction")
@ -97,14 +95,8 @@
(is (nil? (get (at 9) [:first :mouth]))
"generated visibility is read from the same selected pose")
(is (some? (get (at 9) [:second :mouth])))
(is (= 105 (:size (get (low-at 7) [:first :brow])))
"picture rate samples the generated base and leaves correction time alone")
(is (= 22 (:size (get (low-at 8) [:first :mouth])))
"an explicit cut occurs at its exact local frame, even off the picture grid")
(is (= 8 (:cx (get (low-at 8) [:first :eye])))
"authored position ignores the picture grid")
(let [sym (get-in document [:symbols :sym/poses])
opts {:source-fps 30 :picture-fps 8 :pose-tracks {:mouth {0 0, 8 20}}}]
opts {:pose-tracks {:mouth {0 0, 8 20}}}]
(is (= (mapv #(select-keys % [:node :cx :size])
(symbol/eval-frame sym 8 store pal/index-of opts))
(mapv #(select-keys % [:node :cx :size])
@ -249,7 +241,7 @@
made (clip/new-symbol c :outer id 20 u)]
(is (= :symbol-1 id))
(is (= :symbol-2 (clip/fresh-id made)) "the next one does not collide")
(is (= {:id :symbol-1 :name "symbol-1" :frames 180 :nodes {}}
(is (= {:id :symbol-1 :name "symbol-1" :fps 30 :frames 180 :nodes {}}
(clip/symbol made :symbol-1))
"empty, and as long as the rest of what it was placed in")
(is (= {:span [0 180] :time {:mode :map :at 20 :rate 1}}

View file

@ -165,8 +165,8 @@
(is (= 0 (:frame (nest/inside doc nil :main [:a] 3))))
(is (nil? (:time (nest/inside doc nil :main [:a] 3))))
(is (nil? (nest/inside doc nil :main [:a] 4)))
(is (= ((pick/bounds-of doc nil n) 2)
((pick/bounds-of doc nil (assoc-in n [:playback :in] 5)) 0)))))
(is (= ((pick/bounds-of doc nil :main n) 2)
((pick/bounds-of doc nil :main (assoc-in n [:playback :in] 5)) 0)))))
(deftest seeking-and-source-reuse-do-not-share-cursors
(let [doc (assoc-in (document) [:symbols :main :nodes :b :source :symbol] :drawing-a)

View file

@ -49,8 +49,8 @@
;; 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"))
(is (= {:frames 229} (get ls "clip/c7/symbol/main"))
"a timeline's leaf is its frame space; :fps is the clip's")
(is (= {:frames 229 :fps 30} (get ls "clip/c7/symbol/main"))
"a symbol persists its native grid separately from the output grid")
;; Nodes are local to the face timeline; feature and group ids are clip-wide.
(is (contains? ls "clip/c7/symbol/face-1/node/mouth"))
(is (contains? ls "clip/c7/symbol/face-1/channel/mouth/geom.pts"))

View file

@ -1,19 +1,5 @@
(ns arthur.export-test
"The frame walk and the arithmetic above the sink.
THE SYNC RULE IS THE POINT OF THIS FILE. `arthur.export` states it twice — in
its own docstring and in `plan`'s comment — because it is the one failure the
export path exists to prevent: a lower picture rate must HOLD each pose across
several frames and never drop frames, so the emitted length always matches the
audio. Decimating instead gives a file that is silently short, whose sound
slides progressively out of sync, and which looks correct in every other
respect. Nothing downstream can detect that, so it is asserted here, at both
levels: `plan` reports poses separately from frames, and `run!` emits every
frame of the frame space whatever the picture rate is.
The sink is a recording fake. What the walk owes a sink is an ordering and a
count, and a fake is the only way to assert on those without also asserting on
PNG bytes — which `export.frames-test` already does."
"The output-grid frame walk and duration, using a recording sink."
(:require [cljs.test :refer [deftest is testing async]]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
@ -85,21 +71,12 @@
(is (= 1 (zoom-of nil)) "an absent zoom is 1:1")
(is (= 1 (zoom-of 1.9)))))
(deftest a-lower-picture-rate-changes-the-poses-and-not-the-length
;; THE SYNC RULE, in the arithmetic. 48 frames at 24fps is two seconds; at a
;; 12fps picture rate it is still 48 frames and two seconds, holding 24 poses.
;; If :frames ever tracks :poses here, every export at a reduced picture rate
;; comes out half length with the audio sliding off it.
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main
:picture-fps 12})]
(is (= 48 (:frames p)) "the frame count does not move")
(is (= 2 (:seconds p)) "and neither does the duration")
(is (= 24 (:poses p)) "but the picture holds half as many poses"))
(testing "a picture rate at or above the clip's rate changes nothing"
(doseq [fps [24 48 nil]]
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main
:picture-fps fps})]
(is (= 48 (:poses p)) (str "picture-fps " fps))))))
(deftest a-lower-output-rate-keeps-native-duration
(let [doc (assoc-in (a-clip {:frames 48 :fps 24}) [:symbols :main :fps] 24)
p (export/plan {:clip (clip/set-fps doc 12) :symbol :main})]
(is (= 24 (:frames p)))
(is (= 12 (:fps p)))
(is (= 2 (:seconds p)))))
(deftest plan-of-a-symbol-that-is-not-there-is-nothing
(is (nil? (export/plan {:clip (a-clip {}) :symbol :nope :zoom 1}))))
@ -115,17 +92,12 @@
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest a-lower-picture-rate-still-emits-every-frame
;; THE SYNC RULE, in the walk — the assertion that matters most in this file.
;; The poses repeat; the frames do not thin out.
(deftest a-lower-output-rate-emits-the-selected-grid
(async done
(-> (run!* (a-clip {:frames 12 :fps 24}) :picture-fps 8)
(-> (run!* (clip/set-fps (a-clip {:frames 12 :fps 24}) 8))
(.then (fn [{:keys [frames spec]}]
(is (= 12 (count frames))
"a 12-frame timeline exports 12 frames at any picture rate")
(is (= (range 12) (map :i frames)))
(is (= 24 (:fps spec))
"and the file's rate is the CLIP's, not the picture rate")
(is (= (range 4) (map :i frames)))
(is (= 8 (:fps spec)))
(done)))
(.catch (fn [e] (is false (str "threw: " e)) (done))))))

View file

@ -336,14 +336,14 @@
frames (range (get-in c [:symbols sid :frames]))
anchor (:value (get-in n [:channels [:xform :anchor]]))
usable (and (not (node/measured? n))
(some? (pick/pivot c @store n frames)))]
(some? (pick/pivot c @store sid n frames)))]
(is (= usable (some? anchor))
(str sid "/" id " has a pivot: " (some? anchor)
", but a hand can use one: " usable))
(is (= (nil? (gesture/refusal n)) (not (node/measured? n)))
(str sid "/" id ": `refusal` and `measured?` disagree"))
(when anchor
(let [bounds (pick/bounds-of c @store n)
(let [bounds (pick/bounds-of c @store sid n)
[x0 y0 x1 y1] (reduce #(let [k (bounds %2)]
(cond (nil? %1) k (nil? k) %1
:else (mapv (fn [op i] (op (nth %1 i) (nth k i)))