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

@ -0,0 +1 @@
Subproject commit e369ad9462ed1816a39b1a0f5c79055a2c013f4f

View file

@ -52,10 +52,10 @@ overrides and kept-frame sets are all in clip-frame space, so a clip slides on
the timeline without a single stored number changing. Exposure and lead are the timeline without a single stored number changing. Exposure and lead are
transforms *within* clip space: transforms *within* clip space:
Detection retains every source frame. A chosen picture fps samples the frozen Detection retains every source frame. Symbols carry their native fps; project
roto in clip time; it changes neither source-frame count nor the audio clock. fps selects the output grid without changing source data or audio speed.
The set of source frames an artist uses as cel tracing references is another See [Time selection](time.md) for boundary sampling and frame units.
selection, independent of the picture fps. Tracing references remain an independent selection.
```clojure ```clojure
(defn pose-frame [clip cf] (defn pose-frame [clip cf]
@ -90,8 +90,8 @@ it a name is most of the work: `state` in `app.js` is a clip with its analysis
inlined and its palette global. inlined and its palette global.
Cel keys select where drawings begin and how long they hold. The source frames Cel keys select where drawings begin and how long they hold. The source frames
shown beneath a cel while tracing are chosen independently, and picture fps shown beneath a cel while tracing are chosen independently. Project fps controls
only controls which analyzed pose the finished roto displays at a given time. which native frames can appear on the output grid.
### Two things called "track" ### Two things called "track"

36
docs/time.md Normal file
View file

@ -0,0 +1,36 @@
# Time selection
Project `:fps` is the playback and export grid. Each symbol has its own native
`:fps` and `:frames`; keys, spans, trace choices and corrections stay in that
native space. A symbol without an explicit rate inherits the document rate;
changing project fps first records that rate so its existing timing stays put.
An output frame selects the latest native frame at or before its time:
`floor(output-frame * native-fps / output-fps)`. Thus 30fps content in a 12fps
project reads source frames 0, 2, 5, 7, 10… and retains its duration. A partial
last output frame is included. Changing back to 30 restores the original grid.
Nothing rewrites or discards the dense measurements.
The same boundary selection runs when entering a placed symbol. Placement and
artistic speed are applied before selection; the stored `:time :rate` and
`:playback :speed` never contain a frame-rate conversion. The derived maps used
by timeline rows, picking and editing account for the units of each symbol.
`clip/frames` is a native length; `clip/output-frames` is a transport/export
length. Resolver frame queries return native frames for edits.
There is one fps control. The old transient picture-fps control and node
sample-fps fields are gone. Existing exposure, trace choices and per-instance
pose tracks remain available: a pose track can hold a chosen closed-mouth frame
without deleting its neighboring measurements. Those choices stay in native
frames when output fps changes. Automatic content-aware frame selection is not
implemented. An event between output frames appears on the next output frame;
it cannot create an extra frame in a 12fps output.
Audio uses continuous time through the same derived placement maps, without
picture floors or holds. Frame-rate units cancel before Web Audio playbackRate
is set, so only deliberate speed changes affect pitch and duration. Export and
playback use the same output count and resolver.
Earlier imports with frame-rate conversion baked into stored retimes must be
re-imported. There is no second reader for that representation. Source video
presentation timestamps are still future work; this model assumes constant fps.

View file

@ -1,5 +1,7 @@
# Timing model # Timing model
[Time selection](time.md) defines the current frame-rate representation.
[The Lane Model](lane-model.md) defines the revised target for occurrence timing, [The Lane Model](lane-model.md) defines the revised target for occurrence timing,
source playback, sampling scope, and inverse editing. It supersedes conflicting source playback, sampling scope, and inverse editing. It supersedes conflicting
proposals here; the sections below describe earlier implementation decisions. proposals here; the sections below describe earlier implementation decisions.

View file

@ -113,7 +113,7 @@
(defn- render! [document sid sources store] (defn- render! [document sid sources store]
(let [fps (:fps document) (let [fps (:fps document)
frames (:frames (clip/symbol document sid)) frames (clip/output-frames document sid)
tracks (tracks-of document sid) tracks (tracks-of document sid)
output (js/OfflineAudioContext. output (js/OfflineAudioContext.
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)] 2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
@ -121,7 +121,7 @@
(let [[start end] (or (node/placed-span track) [0 frames]) (let [[start end] (or (node/placed-span track) [0 frames])
start (max 0 start) start (max 0 start)
end (min frames end) end (min frames end)
{:keys [buffer fps] :or {fps (:fps document)}} (get sources (:source track)) {:keys [buffer fps] :or {fps (:fps track)}} (get sources (:source track))
sound (.createBufferSource output) sound (.createBufferSource output)
gain (.createGain output) gain (.createGain output)
pan (.createStereoPanner output)] pan (.createStereoPanner output)]
@ -130,7 +130,7 @@
(set! (.-loop sound) (boolean (get-in track [:time :loop?]))) (set! (.-loop sound) (boolean (get-in track [:time :loop?])))
(automate! (.-playbackRate sound) (automate! (.-playbackRate sound)
(get-in track [:channels [:audio :rate]]) (get-in track [:channels [:audio :rate]])
start end (:fps document) (or (get-in track [:time :rate]) 1) 1 store) start end (:fps document) (* (or (get-in track [:time :rate]) 1) (/ (:fps document) fps)) 1 store)
(automate! (.-gain gain) (automate! (.-gain gain)
(get-in track [:channels [:audio :gain]]) (get-in track [:channels [:audio :gain]])
start end (:fps document) 1 1 store) start end (:fps document) 1 1 store)
@ -195,6 +195,6 @@
buffer (wav-url buffer) buffer (wav-url buffer)
(and fallback-url (= sid (clip/opens-on document))) fallback-url (and fallback-url (= sid (clip/opens-on document))) fallback-url
:else (let [rate 44100 :else (let [rate 44100
n (max 1 (js/Math.ceil (* rate (/ (clip/frames document sid) n (max 1 (js/Math.ceil (* rate (/ (clip/output-frames document sid)
(:fps document)))))] (:fps document)))))]
(wav-url (.createBuffer (js/OfflineAudioContext. 1 1 rate) 1 n rate)))))))) (wav-url (.createBuffer (js/OfflineAudioContext. 1 1 rate) 1 n rate))))))))

View file

@ -97,8 +97,3 @@
rather than only inside the scene." rather than only inside the scene."
[f expose] [f expose]
(node/expose f expose)) (node/expose f expose))
(defn picture-frame
"The source pose displayed at f after picture-rate sampling and exposure."
[f source-fps picture-fps expose]
(node/expose (node/sample-frame f source-fps picture-fps) expose))

View file

@ -30,8 +30,7 @@
;; serves by hash — and the synthetic take needs a sound of its own so ;; serves by hash — and the synthetic take needs a sound of its own so
;; that the clock has something to run against with no footage ingested. ;; that the clock has something to run against with no footage ingested.
:audio "/static/arthur/audio.wav" :audio "/static/arthur/audio.wav"
:cid (name label-key) :cid (name label-key)}
:display-fps (:fps clip)}
(select-keys clip [:fps :width :height]))) (select-keys clip [:fps :width :height])))
(def clips (def clips
@ -72,7 +71,7 @@
:clip (let [c (domain-clip/blank)] :clip (let [c (domain-clip/blank)]
{:fps (:fps c) {:fps (:fps c)
:width (:width c) :height (:height c) :width (:width c) :height (:height c)
:audio nil :display-fps (:fps c)}) :audio nil})
;; Which ingested footage to detect, and what the last load said. The list ;; Which ingested footage to detect, and what the last load said. The list
;; comes from the server — tier 3 is the backend's since step 9 — so there is ;; comes from the server — tier 3 is the backend's since step 9 — so there is

View file

@ -44,7 +44,7 @@
n)) n))
copy (fn [sid] copy (fn [sid]
(-> (clip/symbol other sid) (-> (clip/symbol other sid)
(assoc :id (ids sid)) (assoc :id (ids sid) :fps (or (:fps (clip/symbol other sid)) (:fps other)))
(update :nodes #(into {} (map (fn [[id n]] [id (repoint n ids)])) %))))] (update :nodes #(into {} (map (fn [[id n]] [id (repoint n ids)])) %))))]
{:clip (reduce (fn [c sid] (assoc-in c [:symbols (ids sid)] (copy sid))) clip reach) {:clip (reduce (fn [c sid] (assoc-in c [:symbols (ids sid)] (copy sid))) clip reach)
:ids ids})) :ids ids}))
@ -76,21 +76,6 @@
[clip frozen label footage-id range] [clip frozen label footage-id range]
(let [{c :clip ids :ids} (symbols clip frozen [:main] {:main (symbol-id label)}) (let [{c :clip ids :ids} (symbols clip frozen [:main] {:main (symbol-id label)})
sid (ids :main) sid (ids :main)
source-fps (:fps frozen)
project-fps (:fps clip)
;; The new symbol is played by the receiving project's clock. Keep its
;; wall-clock duration by giving it project-rate frames, while its root
;; maps those frames back onto the source timeline. A 30fps source in a
;; 12fps project therefore has 2.5 source frames per project frame.
source-rate (if (and (number? source-fps) (pos? source-fps)
(number? project-fps) (pos? project-fps))
(/ source-fps project-fps)
1)
output-frames (max 1 (js/Math.ceil (/ (clip/frames c sid) source-rate)))
c (-> c
(assoc-in [:symbols sid :frames] output-frames)
(update-in [:symbols sid :nodes :root :time]
#(assoc (or % {}) :mode :map :at 0 :rate source-rate)))
tracked? (and (nil? (:analysis clip)) tracked? (and (nil? (:analysis clip))
(every? #(= % (ids %)) (keys (:subjects frozen))))] (every? #(= % (ids %)) (keys (:subjects frozen))))]
{:sid sid {:sid sid
@ -103,8 +88,8 @@
;; Source frame `start` plays on the symbol's 0. ;; Source frame `start` plays on the symbol's 0.
:span range :span range
:time {:mode :map :time {:mode :map
:at (/ (- (first range)) source-rate) :at (- (first range))
:rate source-rate}})) :rate 1}}))
tracked? (-> (assoc :analysis (:analysis frozen)) tracked? (-> (assoc :analysis (:analysis frozen))
(update :subjects merge (:subjects frozen)) (update :subjects merge (:subjects frozen))
(update :features merge (:features frozen)) (update :features merge (:features frozen))
@ -118,4 +103,5 @@
when there is none. See `clip/place-symbol`." when there is none. See `clip/place-symbol`."
[entry brought store sid host frame uuid point] [entry brought store sid host frame uuid point]
(let [st (merge (:store entry) store)] (let [st (merge (:store entry) store)]
(assoc entry :store st :clip (clip/place-symbol brought st host sid frame uuid point)))) (assoc entry :store st :clip (clip/place-symbol brought st host sid
(* frame (:rate (clip/grid-time brought host))) uuid point))))

View file

@ -0,0 +1,15 @@
(ns arthur.domain.cadence
"Select integer content frames across frame grids. Stored frames never change.")
(defn ratio [grid native]
(if (and grid native (pos? grid) (pos? native)) (/ native grid) 1))
(defn frame
"Latest native frame at or before reader frame f. Never sample the future."
[f grid native]
(js/Math.floor (if (and grid native) (/ (* f native) grid) f)))
(defn frames
"Reader frames covering a native length, including a partial final frame."
[n grid native]
(when n (max 1 (js/Math.ceil (/ n (ratio grid native))))))

View file

@ -33,12 +33,11 @@
them. `unplaced` answers the question a reserved root used to: which symbols them. `unplaced` answers the question a reserved root used to: which symbols
nothing else places, and so which ones a person opening the document wants. nothing else places, and so which ones a person opening the document wants.
WHY :fps IS HERE AND :frames IS NOT. A rate is how fast the whole clip plays :fps is the output grid. A symbol's optional :fps names the native grid its
against its audio, and a nested symbol cannot have one of its own — retiming an frames were authored or measured on; absent means the document's grid."
instance is `:rate` on its `:time` map, which is a factor and not a rate. A
frame COUNT is a property of a frame space, so every symbol has its own."
(:refer-clojure :exclude [symbol]) (:refer-clojure :exclude [symbol])
(:require [arthur.domain.channel :as ch] (:require [arthur.domain.cadence :as cadence]
[arthur.domain.channel :as ch]
[arthur.domain.feature :as feature] [arthur.domain.feature :as feature]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
@ -69,6 +68,36 @@
[clip sid] [clip sid]
(:frames (symbol clip sid))) (:frames (symbol clip sid)))
(defn fps [clip sid] (or (:fps (symbol clip sid)) (:fps clip)))
(defn set-fps
"Change the output grid without rewriting any content's frames."
[clip rate]
(-> clip
(update :symbols #(into {} (map (fn [[sid sym]]
[sid (assoc sym :fps (fps clip sid))])) %))
(assoc :fps rate)))
(defn output-frames [clip sid]
(cadence/frames (frames clip sid) (:fps clip) (fps clip sid)))
(defn grid-time [clip sid]
{:at 0 :rate (cadence/ratio (:fps clip) (fps clip sid))})
(defn source-time
"Derived cel-to-content map. Frame-rate units never enter stored retimes."
[clip host n]
(when-let [t (node/source-time n)]
(let [r (cadence/ratio (fps clip host) (fps clip (node/source n)))]
(-> t (update :rate * r) (update :at / r)))))
(defn placed-frame [clip host n f]
(let [child (node/source n)
r (cadence/ratio (fps clip host) (fps clip child))]
(some-> (node/placed-frame (update-in n [:playback :speed] #(* (or % 1) r))
f (frames clip child))
(update :frame js/Math.floor))))
(defn stage (defn stage
"A symbol's stage as `[width height]`: its own, or the clip's where it has none. "A symbol's stage as `[width height]`: its own, or the clip's where it has none.
Absent rather than copied in at creation, so a symbol nobody has sized follows Absent rather than copied in at creation, so a symbol nobody has sized follows
@ -136,7 +165,7 @@
{:name "untitled" {:name "untitled"
:fps 30 :fps 30
:width 320 :height 200 :width 320 :height 200
:symbols {:main {:id :main :frames blank-frames :nodes {}}}}) :symbols {:main {:id :main :fps 30 :frames blank-frames :nodes {}}}})
(defn- transform-op (defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space. Its "Put a symbol's already resolved mark into its instance's parent space. Its
@ -162,23 +191,10 @@
op))) op)))
(defn resolver (defn resolver
"Resolve symbol `sid` of a clip, including every symbol its instances place. "Resolve an output frame, selecting native content at each symbol boundary.
Every instance owns its cursors and buffers. The IResolver queries return
Each instance owns its own symbol resolver, so two offsets never share a native node frames and world matrices for the last rendered output frame."
channel cursor or point buffer. The returned ops must be drawn before the next [clip sid store palette opts]
frame, as with symbol/resolver.
Any symbol can be resolved and none is the default: the frame space is the
resolved symbol's own `:frames`, and nested instances inside it still resolve,
because this is the function that knows how to do that.
It answers `symbol/world-of` and `symbol/frame-of` for a ROW PATH — the ids
from `sid` down through instances, as a timeline row names a node — as of the
frame it last resolved: the matrix into `sid`'s coordinates, and the node's
own frame. Nil for a node that was not on that frame. It is how something
drawn beside the picture, like a tracing photo, rides a node inside it without
resolving anything a second time."
[clip sid store palette {:keys [picture-fps] :as opts}]
(letfn [(build [sid chain pose-tracks] (letfn [(build [sid chain pose-tracks]
(when (some #{sid} chain) (when (some #{sid} chain)
(throw (ex-info "symbol cycle" {:chain (conj chain sid)}))) (throw (ex-info "symbol cycle" {:chain (conj chain sid)})))
@ -188,8 +204,7 @@
rank (symbol/draw-rank nodes (symbol/order nodes)) rank (symbol/draw-rank nodes (symbol/order nodes))
ids (sort-by rank (keys nodes)) ids (sort-by rank (keys nodes))
own (symbol/resolver sym store palette own (symbol/resolver sym store palette
(assoc opts :pose-tracks pose-tracks (assoc opts :pose-tracks pose-tracks))
:source-fps (:fps clip)))
;; Each cel owns its source resolver and mutable buffers. ;; Each cel owns its source resolver and mutable buffers.
children (into {} children (into {}
(for [[id n] nodes (for [[id n] nodes
@ -205,7 +220,7 @@
entered (volatile! {}) entered (volatile! {})
step (fn [f] step (fn [f]
(vreset! entered {}) (vreset! entered {})
(let [by-id (into {} (map (juxt :node identity)) (own f))] (let [by-id (into {} (map (juxt :node identity)) (own (js/Math.floor f)))]
(into [] (into []
(mapcat (mapcat
(fn [id] (fn [id]
@ -215,7 +230,7 @@
local (symbol/frame-of own id) local (symbol/frame-of own id)
length (frames clip (node/source n)) length (frames clip (node/source n))
shown (when (and m (number? local)) shown (when (and m (number? local))
(node/placed-frame n local length)) (placed-frame clip sid n local))
frame (:frame shown)] frame (:frame shown)]
(if (and frame (<= 0 frame) (< frame length)) (if (and frame (<= 0 frame) (< frame length))
(do (vswap! entered assoc id (:symbol shown)) (do (vswap! entered assoc id (:symbol shown))
@ -240,7 +255,13 @@
(when (contains? @entered id) (when (contains? @entered id)
(symbol/frame-of (get children [id (get @entered id)]) (vec more))) (symbol/frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/frame-of own id))))))] (symbol/frame-of own id))))))]
(build sid [] nil))) (let [r (build sid [] nil)]
(reify
IFn
(-invoke [_ f] (r (cadence/frame f (or (:grid-fps opts) (:fps clip)) (fps clip sid))))
symbol/IResolver
(world-of [_ path] (symbol/world-of r path))
(frame-of [_ path] (symbol/frame-of r path))))))
(defn center (defn center
"The middle of everything symbol `sid` draws, over all its frames, in its own "The middle of everything symbol `sid` draws, over all its frames, in its own
@ -254,7 +275,7 @@
Effects' anchor point are set once and left. A symbol that grows later keeps Effects' anchor point are set once and left. A symbol that grows later keeps
its instances' pivots where they were, so nothing on screen moves." its instances' pivots where they were, so nothing on screen moves."
[clip store sid] [clip store sid]
(let [resolve (resolver clip sid store pal/index-of nil) (let [resolve (resolver clip sid store pal/index-of {:grid-fps (fps clip sid)})
bounds (fn [[x0 y0 x1 y1 :as b] x y] bounds (fn [[x0 y0 x1 y1 :as b] x y]
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y])) (if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
[x0 y0 x1 y1] [x0 y0 x1 y1]
@ -310,7 +331,7 @@
;; Lexicographic draw order, as `domain/paint` does it: an instance made ;; Lexicographic draw order, as `domain/paint` does it: an instance made
;; later sits above one made earlier, and neither has to renumber. ;; later sits above one made earlier, and neither has to renumber.
:z (str "z" (js/Date.now) "-" (name sid)) :z (str "z" (js/Date.now) "-" (name sid))
:span [0 (:frames target)] :span [0 (cadence/frames (:frames target) (fps clip host) (fps clip sid))]
:time {:mode :map :at frame :rate 1} :time {:mode :map :at frame :rate 1}
:source {:symbol sid} :source {:symbol sid}
:playback {:in 0 :speed 1 :end :stop} :playback {:in 0 :speed 1 :end :stop}
@ -319,11 +340,8 @@
[:xform :anchor] {:animated? false :value middle}}}))))) [:xform :anchor] {:animated? false :value middle}}})))))
(defn place-sound (defn place-sound
"An audio node playing `source` — `{:sound id}`, an uploaded file, or "Place a sound at host frame `frame`. Its span uses `source`'s fps when
`{:footage id}`, a video's own sound — inside symbol `host` from `frame` of it. supplied, otherwise the host's. `rate` is a deliberate playback speed."
Timed as an instance is: `:at frame`, its span `length` of its OWN frames, and
`rate` of those to one of `host`'s, which is what keeps a 30fps video's sound
its own length in a 12fps project. Nothing else: a sound is not in the picture."
[clip host source label length rate frame uuid] [clip host source label length rate frame uuid]
(let [end (frames clip host)] (let [end (frames clip host)]
(if (or (nil? end) (nil? frame) (neg? frame) (>= frame end)) (if (or (nil? end) (nil? frame) (neg? frame) (>= frame end))
@ -354,7 +372,7 @@
(if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end)) (if (or (nil? end) (symbol clip sid) (nil? frame) (neg? frame) (>= frame end))
clip clip
(-> clip (-> clip
(assoc-in [:symbols sid] {:id sid :name (name sid) :frames (- end frame) :nodes {}}) (assoc-in [:symbols sid] {:id sid :name (name sid) :fps (fps clip host) :frames (- end frame) :nodes {}})
(place-symbol nil host sid frame uuid nil))))) (place-symbol nil host sid frame uuid nil)))))
(defn free-id (defn free-id

View file

@ -31,7 +31,7 @@
(reduce node/then-time {:at 0 :rate 1} (map node/time-of (reverse chain))) (reduce node/then-time {:at 0 :rate 1} (map node/time-of (reverse chain)))
(let [n (get nodes id) t (:time n)] (let [n (get nodes id) t (:time n)]
(when (and n (not (contains? seen id)) (when (and n (not (contains? seen id))
(not (:loop? t)) (not (:sample-fps t)) (not (:loop? t))
(<= (or (:expose t) 1) 1)) (<= (or (:expose t) 1) 1))
(recur (:parent n) (conj seen id) (conj chain n))))))) (recur (:parent n) (conj seen id) (conj chain n)))))))
@ -396,7 +396,7 @@
(when (clip/symbol clip drawing-id) "the new drawing ID is already used"))] (when (clip/symbol clip drawing-id) "the new drawing ID is already used"))]
{:refused why} {:refused why}
(place (assoc-in clip [:symbols drawing-id] (place (assoc-in clip [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) {:id drawing-id :name (name drawing-id) :fps (clip/fps clip sid) :frames 1 :nodes {}})
sid lane-id id drawing-id at extent true))) sid lane-id id drawing-id at extent true)))
(defn reuse-drawing (defn reuse-drawing
@ -475,7 +475,7 @@
(if (:refused cleared) (if (:refused cleared)
cleared cleared
(place (assoc-in (:clip cleared) [:symbols drawing-id] (place (assoc-in (:clip cleared) [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) {:id drawing-id :name (name drawing-id) :fps (clip/fps clip sid) :frames 1 :nodes {}})
sid lane-id id drawing-id at extent false)))))) sid lane-id id drawing-id at extent false))))))
(defn make-unique (defn make-unique

View file

@ -14,7 +14,7 @@
clip/<cid>/subject/<subj> a tracked subject and its params clip/<cid>/subject/<subj> a tracked subject and its params
clip/<cid>/feature/<fid> one feature: area, nodes, params clip/<cid>/feature/<fid> one feature: area, nodes, params
clip/<cid>/group/<gid> an eye pair and its shared params clip/<cid>/group/<gid> an eye pair and its shared params
clip/<cid>/symbol/<sid> frames, and a palette one day clip/<cid>/symbol/<sid> native frames and fps, optional palette
clip/<cid>/symbol/<sid>/node/<nid> kind, parent, stencil, z, time clip/<cid>/symbol/<sid>/node/<nid> kind, parent, stencil, z, time
clip/<cid>/symbol/<sid>/channel/<nid>/<prop> clip/<cid>/symbol/<sid>/channel/<nid>/<prop>
clip/<cid>/symbol/<sid>/measured/<nid> the channels a re-freeze owns clip/<cid>/symbol/<sid>/measured/<nid> the channels a re-freeze owns
@ -23,9 +23,8 @@
its own nodes, so the symbol id is a path segment. No symbol has a reserved its own nodes, so the symbol id is a path segment. No symbol has a reserved
segment: `main` in a path is an id like any other. segment: `main` in a path is an id like any other.
`:frames` MOVED OFF `timing` onto the symbol. A symbol is a frame space and a The timing leaf holds output fps. Each symbol leaf holds its native fps
clip is a rate, so `timing` holds `:fps` alone. Both used to be in one leaf, which and frame count, so changing the output grid leaves content untouched.
is how a nested symbol's length would have had nowhere to go.
WHY THESE BOUNDARIES. Last-writer-wins only clobbers when its unit is too big, WHY THESE BOUNDARIES. Last-writer-wins only clobbers when its unit is too big,
so the cut is chosen so that the things people do simultaneously land on so the cut is chosen so that the things people do simultaneously land on
@ -145,7 +144,7 @@
;; disagree with itself about; `clip` puts it back. ;; disagree with itself about; `clip` puts it back.
(for [[sid sym] (:symbols clip)] (for [[sid sym] (:symbols clip)]
{(at "symbol" (segment sid)) {(at "symbol" (segment sid))
(select-keys sym [:name :frames :width :height :palette])}) (select-keys sym [:name :frames :fps :width :height :palette])})
(for [[sid sym] (:symbols clip) (for [[sid sym] (:symbols clip)
[id n] (:nodes sym)] [id n] (:nodes sym)]
{(at "symbol" (segment sid) "node" (segment id)) {(at "symbol" (segment sid) "node" (segment id))

View file

@ -33,7 +33,7 @@
[clip store sid frame id] [clip store sid frame id]
(let [sym (clip/symbol clip sid) (let [sym (clip/symbol clip sid)
sym (update sym :nodes select-keys (symbol/lineage (:nodes sym) id)) sym (update sym :nodes select-keys (symbol/lineage (:nodes sym) id))
r (symbol/resolver sym store pal/index-of {:source-fps (:fps clip)})] r (symbol/resolver sym store pal/index-of nil)]
(r frame) (r frame)
r)) r))
@ -67,8 +67,7 @@
;; the hold pins, not on `local`, and inside a playing insert ;; the hold pins, not on `local`, and inside a playing insert
;; is its animation at its own in-point and speed. ;; is its animation at its own in-point and speed.
shown (when (number? local) shown (when (number? local)
(node/placed-frame (get nodes id) local (clip/placed-frame clip sid (get nodes id) local))
(clip/frames clip (node/source (get nodes id)))))
inner (:symbol shown) inner (:symbol shown)
lf (if shown (:frame shown) local)] lf (if shown (:frame shown) local)]
(if (and m (number? local) (if (and m (number? local)
@ -80,11 +79,12 @@
;; Forward sampling above works for holds too. :time is the ;; Forward sampling above works for holds too. :time is the
;; invertible edit map; source in-points and speeds belong in it. ;; invertible edit map; source in-points and speeds belong in it.
:time (when (and time (not-any? #(get-in % [:time :loop?]) chain) :time (when (and time (not-any? #(get-in % [:time :loop?]) chain)
(or (not inst?) (node/source-time (get nodes id)))) (or (not inst?) (clip/source-time clip sid (get nodes id))))
(cond-> (reduce node/then-time time (map node/time-of chain)) (cond-> (reduce node/then-time time (map node/time-of chain))
inst? (node/then-time (node/source-time (get nodes id)))))} inst? (node/then-time (clip/source-time clip sid (get nodes id)))))}
(reduced nil)))) (reduced nil))))
{:sid sid :frame f :matrix (node/mat) :time {:at 0 :rate 1}} {:sid sid :frame (js/Math.floor (* f (:rate (clip/grid-time clip sid))))
:matrix (node/mat) :time (clip/grid-time clip sid)}
path)) path))
(defn placement (defn placement
@ -154,7 +154,10 @@
(let [n (get nodes id) (let [n (get nodes id)
parent (if-let [pid (:parent n)] (get acc pid) parent (if-let [pid (:parent n)] (get acc pid)
{:time outer :bounds bounds}) {:time outer :bounds bounds})
m (node/then-time (:time parent) (node/time-of n))] m (node/then-time (:time parent) (node/time-of n))
m (if (= :audio (:kind n))
(update m :rate * (/ (or (get-in n [:source :fps]) (clip/fps clip sid))
(clip/fps clip sid))) m)]
(assoc acc id {:time m :bounds (window m (:span n) (:bounds parent))}))) (assoc acc id {:time m :bounds (window m (:span n) (:bounds parent))})))
{} (symbol/order nodes))] {} (symbol/order nodes))]
(mapcat (mapcat
@ -164,6 +167,7 @@
(case (:kind n) (case (:kind n)
:audio [(-> n :audio [(-> n
(assoc :parent nil :path (conj path id) :owner sid (assoc :parent nil :path (conj path id) :owner sid
:fps (or (get-in n [:source :fps]) (clip/fps clip sid))
:span [(to-local m lo) (to-local m hi)] :span [(to-local m lo) (to-local m hi)]
:time (merge (:time n) {:mode :map :at (:at m) :rate (:rate m) :offset 0}) :time (merge (:time n) {:mode :map :at (:at m) :rate (:rate m) :offset 0})
:channels (channels (:channels n) m)))] :channels (channels (:channels n) m)))]
@ -173,7 +177,10 @@
length (clip/frames clip child)] length (clip/frames clip child)]
;; A visual freeze does not emit a sustained audio sample. ;; A visual freeze does not emit a sustained audio sample.
(when (and child length (pos? speed)) (when (and child length (pos? speed))
(let [source (node/then-time m {:at (- (/ in speed)) :rate speed}) (let [source (node/then-time m (clip/source-time clip sid
(-> n
(assoc-in [:playback :end] :stop)
(update :time dissoc :loop?))))
loop? (or (= end :loop) (get-in n [:time :loop?])) loop? (or (= end :loop) (get-in n [:time :loop?]))
periods (if loop? periods (if loop?
(range (js/Math.floor (/ (to-local source lo) length)) (range (js/Math.floor (/ (to-local source lo) length))
@ -185,7 +192,7 @@
periods)))) periods))))
nil)))) nil))))
(sort-by (comp str key) nodes))))] (sort-by (comp str key) nodes))))]
(vec (walk sid {:at 0 :rate 1} [0 (clip/frames clip sid)] [] #{})))) (vec (walk sid (clip/grid-time clip sid) [0 (clip/output-frames clip sid)] [] #{}))))
(defn- retime (defn- retime
"Node `n` with its own time map replaced by `m`, and nothing else touched: its "Node `n` with its own time map replaced by `m`, and nothing else touched: its
@ -306,17 +313,18 @@
sids (reductions only sid path) sids (reductions only sid path)
;; Every node on the way, outermost first: each instance, after its ;; Every node on the way, outermost first: each instance, after its
;; parents in the symbol it is in. ;; parents in the symbol it is in.
chain (mapcat (fn [sid id] maps (mapcat (fn [sid id]
(let [nodes (:nodes (clip/symbol clip sid))] (let [nodes (:nodes (clip/symbol clip sid))
(map #(get nodes %) (rseq (symbol/lineage nodes id))))) chain (map #(get nodes %) (rseq (symbol/lineage nodes id)))]
sids path)] (mapcat (fn [n]
{:sid (last sids) (if (get-in n [:time :loop?]) [nil]
:time (when (not-any? #(or (get-in % [:time :loop?]) (cond-> [(node/time-of n)]
(and (= :instance (:kind %)) (nil? (node/source-time %)))) chain) (= :instance (:kind n)) (conj (clip/source-time clip sid n)))))
(reduce node/then-time {:at 0 :rate 1} chain)))
(mapcat (fn [n] (cond-> [(node/time-of n)] sids path)]
(= :instance (:kind n)) (conj (node/source-time n)))) {:sid (last sids)
chain)))})) :time (when (every? some? maps)
(reduce node/then-time (clip/grid-time clip sid) maps))}))
(defn slide (defn slide
"Move the node at row path `path` along its symbol's time by `df` frames of "Move the node at row path `path` along its symbol's time by `df` frames of
@ -402,7 +410,7 @@
start (js/Math.floor (max 0 (apply min (map first spans)))) start (js/Math.floor (max 0 (apply min (map first spans))))
end (min (second whole) (apply max (map second spans))) end (min (second whole) (apply max (map second spans)))
made (-> clip made (-> clip
(assoc-in [:symbols sid] {:id sid :name (name sid) (assoc-in [:symbols sid] {:id sid :name (name sid) :fps (clip/fps clip host)
:frames (max 1 (js/Math.ceil (- end start))) :frames (max 1 (js/Math.ceil (- end start)))
:nodes {}}) :nodes {}})
(clip/place-symbol store host sid start uuid nil)) (clip/place-symbol store host sid start uuid nil))

View file

@ -150,30 +150,18 @@
[f n] [f n]
(if (and n (> n 1)) (* (js/Math.floor (/ f n)) n) f)) (if (and n (> n 1)) (* (js/Math.floor (/ f n)) n) f))
(defn sample-frame
"Pick a source frame for a lower picture rate without changing clip time.
The input is already an integer source frame from the audio clock. Its time is
f/source-fps. Quantise that time to the picture grid, then read the latest
source frame at or before it. The result is always an integer and never from
the future, including when the rates do not divide (30 source → 24 picture)."
[f source-fps picture-fps]
(if (and source-fps picture-fps
(pos? source-fps) (pos? picture-fps)
(< picture-fps source-fps))
(min f (js/Math.floor
(* (js/Math.floor (/ (* f picture-fps) source-fps))
(/ source-fps picture-fps))))
f))
(defn time-of (defn time-of
"A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame "A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame
`p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A `p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A
node with no time map is `{:at 0 :rate 1}`, reading its parent's frames as its node with no time map is `{:at 0 :rate 1}`, reading its parent's frames as its
own; a mouth lead's `:offset` is folded into `:at`. Cel and picture own; a mouth lead's `:offset` is folded into `:at`. Exposure is a floor, not
sampling are floors, not part of the map, and are left out: this is the map a part of the map, and is left out: this is the map a move preserves and a
move preserves and a timeline row draws with, and `local-frame` is what reads timeline row draws with, and `local-frame` is what reads a frame, the floor and
a frame, floors and the lead in their load-bearing order." the lead in their load-bearing order.
`:rate` is a RETIME SOMEBODY CHOSE and nothing else — half speed on an insert.
Reconciling two frame rates is not a retime and does not belong here: it is a
selection, and it lives in `domain/cadence`."
[n] [n]
(let [{:keys [mode at rate offset] :or {mode :map at 0 rate 1 offset 0}} (:time n)] (let [{:keys [mode at rate offset] :or {mode :map at 0 rate 1 offset 0}} (:time n)]
(if (= mode :map) (if (= mode :map)
@ -258,31 +246,15 @@
(defn finite-number? [v] (and (number? v) (js/Number.isFinite v))) (defn finite-number? [v] (and (number? v) (js/Number.isFinite v)))
(defn local-frame (defn local-frame
"Apply a node's time map to the frame it was handed by its parent. "Apply an artistic time map within one frame space: expose, then offset.
Frame-rate selection happens at symbol boundaries in domain/clip."
ORDER IS LOAD-BEARING: expose first, then offset. Flooring onto a grid and
shifting against the clock do not commute — shift first and the floor discards
it on most frames, so the lead slider reads as doing nothing at cels above
1, which is indistinguishable from the slider being unwired.
Composed along the parent chain, outermost first, by symbol/eval-frame. Two
rules fall out and they are different rules: cel INHERITS STRICTLY,
because a head cutting on odd frames against a mouth cutting on even ones reads
as two performances; offset is PER-NODE by design, because mouth lead applies
to performance nodes and not to the plate, which is the entire point of it."
[n f] [n f]
(let [{:keys [mode source-fps sample-fps] ex :expose :or {mode :map}} (:time n)] (let [{:keys [mode at rate offset] ex :expose :or {mode :map at 0 rate 1}} (:time n)]
(if (= mode :inherit) (if (= mode :inherit)
f f
(do (cond-> (* rate (- f at))
(when (and sample-fps (not (and source-fps (pos? source-fps)))) ex (expose ex)
(throw (ex-info "picture sampling needs a positive source fps" offset (+ offset)))))
{:node (:id n) :time (:time n)})))
(let [{:keys [at rate offset] :or {at 0 rate 1}} (:time n)]
(cond-> (* rate (- f at))
sample-fps (sample-frame source-fps sample-fps)
ex (expose ex)
offset (+ offset)))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the transform ;; the transform

View file

@ -92,7 +92,7 @@
symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one
is a resolver per frame, which is what made `pivot` want a separate path for is a resolver per frame, which is what made `pivot` want a separate path for
instances rather than the one walk it is." instances rather than the one walk it is."
[document store n] [document store sid n]
(let [grow (fn [[x0 y0 x1 y1 :as b] x y] (let [grow (fn [[x0 y0 x1 y1 :as b] x y]
(if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y])) (if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y]))
at (fn [f p] (ch/value-at (get (node/channels n) p) f store))] at (fn [f p] (ch/value-at (get (node/channels n) p) f store))]
@ -105,10 +105,10 @@
(let [loop? (get-in n [:time :loop?]) (let [loop? (get-in n [:time :loop?])
resolvers (into {} (map (fn [child] resolvers (into {} (map (fn [child]
[child (clip/resolver document child store [child (clip/resolver document child store
pal/index-of nil)])) pal/index-of {:grid-fps (clip/fps document child)})]))
(node/sources n))] (node/sources n))]
(fn [f0] (fn [f0]
(let [shown (node/placed-frame n f0 (clip/frames document (node/source n))) (let [shown (clip/placed-frame document sid n f0)
frames (when shown (clip/frames document (:symbol shown))) frames (when shown (clip/frames document (:symbol shown)))
f (when (number? frames) f (when (number? frames)
(if loop? (mod (:frame shown) frames) (:frame shown))) (if loop? (mod (:frame shown) frames) (:frame shown)))
@ -151,7 +151,7 @@
ALL its frames, not the first, for the reason `clip/center` says: a mouth that ALL its frames, not the first, for the reason `clip/center` says: a mouth that
opens and travels still has its middle where the mouth is." opens and travels still has its middle where the mouth is."
[document store n fs] [document store sid n fs]
(let [bounds (bounds-of document store n)] (let [bounds (bounds-of document store sid n)]
(when-let [[x0 y0 x1 y1] (reduce #(union %1 (bounds %2)) nil fs)] (when-let [[x0 y0 x1 y1] (reduce #(union %1 (bounds %2)) nil fs)]
[(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]))) [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)])))

View file

@ -399,7 +399,7 @@
(defn- base-channel-frame (defn- base-channel-frame
"A trace selects the measured frames its node reads; marked channels read "A trace selects the measured frames its node reads; marked channels read
instance pose choices." instance pose choices."
[choices traces nodes source-fps picture-fps id c lf] [choices traces nodes id c lf]
(cond (cond
(contains? traces id) (contains? traces id)
(trace/held-frame (get traces id) lf) (trace/held-frame (get traces id) lf)
@ -410,9 +410,9 @@
[:node id] [:node id]
(or (:pose-group (get nodes id)) id)) (or (:pose-group (get nodes id)) id))
lf lf
(node/sample-frame lf source-fps picture-fps)) (js/Math.floor lf))
:else lf)) :else (js/Math.floor lf)))
(defn- prepared-traces [nodes] (defn- prepared-traces [nodes]
(into {} (into {}
@ -465,14 +465,14 @@
the only place the space changes. the only place the space changes.
This is the definition of what a frame means. `resolver` is what plays it." This is the definition of what a frame means. `resolver` is what plays it."
[sym f store palette {:keys [pose-tracks source-fps picture-fps]}] [sym f store palette {:keys [pose-tracks]}]
(let [nodes (nodes-of sym) (let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks) choices (pose/prepare pose-tracks)
traces (prepared-traces nodes) traces (prepared-traces nodes)
ord (order nodes)] ord (order nodes)]
(eval-into {:read (fn [id path c lf] (eval-into {:read (fn [id path c lf]
(ch/value-at c (base-channel-frame choices traces nodes (ch/value-at c (base-channel-frame choices traces nodes
source-fps picture-fps id c lf) id c lf)
lf store)) lf store))
:palette palette :palette palette
:mat-for (fn [_id] (node/mat)) :mat-for (fn [_id] (node/mat))
@ -531,7 +531,7 @@
because a fifth of them later is then a key rather than a nil at every one of because a fifth of them later is then a key rather than a nil at every one of
these call sites — which is what the arity ladder that used to be here was these call sites — which is what the arity ladder that used to be here was
standing in for." standing in for."
[sym store palette {:keys [pose-tracks source-fps picture-fps]}] [sym store palette {:keys [pose-tracks]}]
(let [nodes (nodes-of sym) (let [nodes (nodes-of sym)
choices (pose/prepare pose-tracks) choices (pose/prepare pose-tracks)
traces (prepared-traces nodes) traces (prepared-traces nodes)
@ -560,7 +560,7 @@
(when-let [cursor (get-in cursors [id path])] (when-let [cursor (get-in cursors [id path])]
(ch/sample! cursor (ch/sample! cursor
(base-channel-frame choices traces nodes (base-channel-frame choices traces nodes
source-fps picture-fps id c lf) id c lf)
lf))) lf)))
:palette palette :palette palette
:mat-for (fn [id] (get mats id)) :mat-for (fn [id] (get mats id))
@ -596,7 +596,7 @@
`:width` and `:height` are the symbol's own stage, and are absent until someone `:width` and `:height` are the symbol's own stage, and are absent until someone
sets them: a symbol without them uses the clip's — see `clip/stage`." sets them: a symbol without them uses the clip's — see `clip/stage`."
#{:id :name :frames :width :height :nodes :palette}) #{:id :name :frames :fps :width :height :nodes :palette})
(defn problems (defn problems
"Human-readable reasons this symbol will not evaluate. Empty means it will. "Human-readable reasons this symbol will not evaluate. Empty means it will.
@ -642,6 +642,9 @@
(into (when-not (or (nil? (:frames sym)) (and (integer? (:frames sym)) (pos? (:frames sym)))) (into (when-not (or (nil? (:frames sym)) (and (integer? (:frames sym)) (pos? (:frames sym))))
[(str ":frames is " (pr-str (:frames sym)) [(str ":frames is " (pr-str (:frames sym))
" — a symbol is a frame SPACE, so its length is a positive integer")])) " — a symbol is a frame SPACE, so its length is a positive integer")]))
(into (when (and (some? (:fps sym))
(not (and (node/finite-number? (:fps sym)) (pos? (:fps sym)))))
[":fps must be a positive finite native rate"]))
(into (for [k [:width :height] (into (for [k [:width :height]
:let [v (get sym k)] :let [v (get sym k)]
:when (and (some? v) (not (and (integer? v) (pos? v))))] :when (and (some? v) (not (and (integer? v) (pos? v))))]

View file

@ -59,7 +59,7 @@
[db clip] [db clip]
(cond-> (update db :clip merge (select-keys clip [:width :height])) (cond-> (update db :clip merge (select-keys clip [:width :height]))
(not= (:fps clip) (get-in db [:clip :fps])) (not= (:fps clip) (get-in db [:clip :fps]))
(update :clip merge {:fps (:fps clip) :display-fps (:fps clip)}))) (update :clip merge {:fps (:fps clip)})))
(defn history (defn history
"Apply `f` to the loaded entry's undo history, which is not an edit: nothing "Apply `f` to the loaded entry's undo history, which is not an edit: nothing

View file

@ -117,8 +117,7 @@
(let [{:keys [clip]} (store/entry (:clip/current db)) (let [{:keys [clip]} (store/entry (:clip/current db))
{sid :symbol isolate :isolate} (target db)] {sid :symbol isolate :isolate} (target db)]
(export/plan {:clip clip :symbol sid :zoom (get-in db [:export :zoom]) (export/plan {:clip clip :symbol sid :zoom (get-in db [:export :zoom])
:isolate isolate :isolate isolate}))))
:picture-fps (get-in db [:clip :display-fps])}))))
(rf/reg-event-db (rf/reg-event-db
::set-target ::set-target
@ -158,7 +157,6 @@
(:palette db) pal/index-of) (:palette db) pal/index-of)
:ramp (get {:arthur/default pal/rgb} (:palette db) pal/rgb) :ramp (get {:arthur/default pal/rgb} (:palette db) pal/rgb)
:zoom zoom :zoom zoom
:picture-fps (get-in db [:clip :display-fps])
:audio-url (:audio entry) :audio-url (:audio entry)
:name (stem (:label entry) :name (stem (:label entry)
(label-of (:clip entry) (get-in db [:ui :open]) (label-of (:clip entry) (get-in db [:ui :open])

View file

@ -152,7 +152,7 @@
source-blocks (source/pack-subjects (:id (:analysis built)) subjects) source-blocks (source/pack-subjects (:id (:analysis built)) subjects)
_ (mark! "build-clip: pack source blocks")] _ (mark! "build-clip: pack source blocks")]
(assoc (select-keys built [:fps :width :height]) (assoc (select-keys built [:fps :width :height])
:display-fps (:fps built)
:clip built :store (:store frozen) :clip built :store (:store frozen)
:source-blocks source-blocks :source-blocks source-blocks
:source-inputs (assoc source-inputs :subjects with-presence) :source-inputs (assoc source-inputs :subjects with-presence)
@ -501,7 +501,7 @@
{:keys [clip sid tracked?]} {:keys [clip sid tracked?]}
(bring/take (:clip (store/entry (:clip/current db))) (:clip built) (bring/take (:clip (store/entry (:clip/current db))) (:clip built)
name footage-id range) name footage-id range)
imported-frames (clip/frames clip sid) imported-frames (clip/output-frames clip sid)
source-fps (get-in built [:clip :fps]) source-fps (get-in built [:clip :fps])
db (edit/edit-entry db (edit/edit-entry
db db

View file

@ -24,7 +24,7 @@
the db beside it, because an edit can change it." the db beside it, because an edit can change it."
[db] [db]
(or (some-> (footage/entry (:clip/current db)) :clip (or (some-> (footage/entry (:clip/current db)) :clip
(clip/frames (get-in db [:ui :open]))) (clip/output-frames (get-in db [:ui :open])))
1)) 1))
(defn show (defn show
@ -36,7 +36,7 @@
sid (clip/opens-on (:clip entry))] sid (clip/opens-on (:clip entry))]
(-> db (-> db
(assoc :clip/current id (assoc :clip/current id
:clip (select-keys entry [:fps :width :height :audio :display-fps])) :clip (select-keys entry [:fps :width :height :audio]))
(update :ui merge (update :ui merge
{:open sid :tabs (if sid [sid] []) {:open sid :tabs (if sid [sid] [])
;; From scratch, not merged: the faces switched on were another ;; From scratch, not merged: the faces switched on were another
@ -95,13 +95,6 @@
{:db (assoc-in db [:playback :rate] r) {:db (assoc-in db [:playback :rate] r)
::rate! r})) ::rate! r}))
(rf/reg-event-db
::set-picture-fps
(fn [db [_ target]]
(if (and (number? target) (pos? target) (<= target (fps db)))
(assoc-in db [:clip :display-fps] target)
db)))
;; --- effects: every DOM touch on the audio element is one of these --- ;; --- effects: every DOM touch on the audio element is one of these ---
(rf/reg-fx ::play! (fn [_] (clock/play!))) (rf/reg-fx ::play! (fn [_] (clock/play!)))

View file

@ -120,7 +120,6 @@
;; differs from it, and a collaborator's ;; differs from it, and a collaborator's
;; write lands on what does not. ;; write lands on what does not.
:synced (project/tier1 (.-leaves clip-json)) :synced (project/tier1 (.-leaves clip-json))
:display-fps (:fps built)
:clip built :store (:store loaded) :clip built :store (:store loaded)
:footage-id footage-id :footage-id footage-id
:audio (if footage (.-audio footage) :audio (if footage (.-audio footage)
@ -512,7 +511,6 @@
;; A content id of its own from the start: `::save!` addresses the clip by ;; A content id of its own from the start: `::save!` addresses the clip by
;; it, and two untitled documents saved from two tabs are two documents. ;; it, and two untitled documents saved from two tabs are two documents.
:cid (str (random-uuid)) :cid (str (random-uuid))
:display-fps (:fps c)
:fps (:fps c) :width (:width c) :height (:height c)})) :fps (:fps c) :width (:width c) :height (:height c)}))
(rf/reg-event-fx (rf/reg-event-fx
@ -529,7 +527,7 @@
:busy? false :status "new document"})) :busy? false :status "new document"}))
;; The readout goes home with the document; so must the clock, or play ;; The readout goes home with the document; so must the clock, or play
;; picks up wherever the last document's audio had got to. ;; picks up wherever the last document's audio had got to.
::pb/seek! [(:fps entry) (clip/frames (:clip entry) (clip/opens-on (:clip entry))) 0] ::pb/seek! [(:fps entry) (clip/output-frames (:clip entry) (clip/opens-on (:clip entry))) 0]
::pb/pause! nil}))) ::pb/pause! nil})))
(rf/reg-event-fx (rf/reg-event-fx
@ -594,12 +592,15 @@
(if (or (not (#{:fps :width :height} key)) (if (or (not (#{:fps :width :height} key))
(not (and (integer? value) (pos? value)))) (not (and (integer? value) (pos? value))))
{} {}
(let [db' (-> (edit/edit db #(assoc % key value)) (let [db' (edit/edit db #(if (= key :fps) (clip/set-fps % value) (assoc % key value)))
(assoc-in [:clip key] value) db' (assoc-in db' [:clip key] value)
(cond-> (= key :fps) (assoc-in [:clip :display-fps] value))) frame (min (dec (pb/frames db'))
frame (get-in db [:playback :frame])] (js/Math.floor (* (get-in db [:playback :frame])
(/ value (get-in db [:clip :fps])))))]
(cond-> {:db db'} (cond-> {:db db'}
(= key :fps) (assoc ::pb/seek! [value (pb/frames db') frame])))))) (= key :fps) (assoc :db (assoc-in db' [:playback :frame] frame)
::pb/seek! [value (pb/frames db') frame]
:dispatch [::pb/refresh-clock]))))))
(rf/reg-event-db (rf/reg-event-db
::rename-symbol ::rename-symbol
@ -742,7 +743,7 @@
:status (str "opened " name " r" seq)})) :status (str "opened " name " r" seq)}))
::pb/pause! nil ::pb/pause! nil
::pb/seek! (let [{c :clip fps :fps} (store/entry clip-id)] ::pb/seek! (let [{c :clip fps :fps} (store/entry clip-id)]
[fps (clip/frames c (clip/opens-on c)) 0])})) [fps (clip/output-frames c (clip/opens-on c)) 0])}))
(rf/reg-event-fx (rf/reg-event-fx
::failed ::failed

View file

@ -130,7 +130,7 @@
[clip st open selection frame] [clip st open selection frame]
(let [[_ sid _ path] selection] (let [[_ sid _ path] selection]
(if (or (= sid open) (not (seq path))) (if (or (= sid open) (not (seq path)))
frame (:frame (nest/inside clip st open [] frame))
(:frame (nest/inside clip st open (pop path) frame))))) (:frame (nest/inside clip st open (pop path) frame)))))
(rf/reg-event-fx (rf/reg-event-fx
@ -472,7 +472,7 @@
(-> db (-> db
(update :ui dissoc :drop) (update :ui dissoc :drop)
(edit/edit-entry #(update % :clip clip/place-symbol (:store %) (edit/edit-entry #(update % :clip clip/place-symbol (:store %)
host sid frame uuid point)) host sid (* frame (:rate (clip/grid-time (:clip %) host))) uuid point))
(assoc-in [:ui :selection] [:node host uuid [uuid]]))))) (assoc-in [:ui :selection] [:node host uuid [uuid]])))))
(rf/reg-event-db (rf/reg-event-db
@ -482,7 +482,7 @@
host (get-in db [:ui :open])] host (get-in db [:ui :open])]
(-> db (-> db
(update :ui dissoc :drop) (update :ui dissoc :drop)
(edit/edit #(clip/place-sound % host source label length rate frame uuid)) (edit/edit #(clip/place-sound % host source label length rate (* frame (:rate (clip/grid-time % host))) uuid))
(assoc-in [:ui :selection] [:node host uuid [uuid]]))))) (assoc-in [:ui :selection] [:node host uuid [uuid]])))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------

View file

@ -158,29 +158,24 @@
Separate from `run!` so the UI can show the size and length it is about to Separate from `run!` so the UI can show the size and length it is about to
commit to, and so the arithmetic is assertable without a sink." commit to, and so the arithmetic is assertable without a sink."
[{:keys [clip zoom picture-fps] sid :symbol isolate-id :isolate}] [{:keys [clip zoom] sid :symbol isolate-id :isolate}]
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id)) (let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))
[width height] (clip/stage clip sid) [width height] (clip/stage clip sid)
zoom (max 1 (js/Math.floor (or zoom 1)))] zoom (max 1 (js/Math.floor (or zoom 1)))]
(when sym (when sym
{:frames (:frames sym) {:frames (clip/output-frames clip sid)
:fps (:fps clip) :fps (:fps clip)
:zoom zoom :zoom zoom
:width (* width zoom) :width (* width zoom)
:height (* height zoom) :height (* height zoom)
:seconds (/ (:frames sym) (:fps clip)) :seconds (/ (clip/output-frames clip sid) (:fps clip))})))
;; The unedited picture-grid count. A per-instance pose track can add or
;; remove changes, so this is only the grid's nominal count.
:poses (if (and picture-fps (< picture-fps (:fps clip)))
(js/Math.ceil (* (/ (:frames sym) (:fps clip)) picture-fps))
(:frames sym))})))
(defn run! (defn run!
"Render symbol `sid` into `exporter`. Promise of `{:filename :blob}`. "Render symbol `sid` into `exporter`. Promise of `{:filename :blob}`.
`on-progress` is called with `[done total]` as frames complete, and is where a `on-progress` is called with `[done total]` as frames complete, and is where a
UI hangs its readout." UI hangs its readout."
[{:keys [clip store palette ramp zoom picture-fps name audio-url] [{:keys [clip store palette ramp zoom name audio-url]
sid :symbol isolate-id :isolate} sid :symbol isolate-id :isolate}
exporter on-progress] exporter on-progress]
(let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))] (let [sym (some-> (clip/symbol clip sid) (isolate isolate-id))]
@ -195,7 +190,7 @@
;; inside it still resolve — clip/resolver is the function that knows ;; inside it still resolve — clip/resolver is the function that knows
;; how. ;; how.
doc (assoc-in clip [:symbols sid] sym) doc (assoc-in clip [:symbols sid] sym)
resolve-frame (clip/resolver doc sid store palette {:picture-fps picture-fps}) resolve-frame (clip/resolver doc sid store palette nil)
ras (raster/make width height) ras (raster/make width height)
bg (get palette :bg 0)] bg (get palette :bg 0)]
(-> (audio! doc sid store audio-url) (-> (audio! doc sid store audio-url)

View file

@ -728,7 +728,7 @@
at [:symbols sid :nodes id :channels [:xform :anchor]]] at [:symbols sid :nodes id :channels [:xform :anchor]]]
(if (or (get-in c at) (node/measured? n)) (if (or (get-in c at) (node/measured? n))
c c
(if-let [p (pick/pivot c store n (range (get-in c [:symbols sid :frames])))] (if-let [p (pick/pivot c store sid n (range (get-in c [:symbols sid :frames])))]
(assoc-in c at (ch/framed p)) (assoc-in c at (ch/framed p))
c)))) c))))
clip clip
@ -767,7 +767,7 @@
:features (merged :features) :groups (merged :groups) :features (merged :features) :groups (merged :groups)
:symbols :symbols
(into {:main (into {:main
{:id :main :frames nf {:id :main :fps fps :frames nf
:nodes (into {:root {:id :root :name "clip" :kind :group :z "a1" :nodes (into {:root {:id :root :name "clip" :kind :group :z "a1"
:time {:mode :map :expose expose}}} :time {:mode :map :expose expose}}}
(map-indexed (map-indexed
@ -777,7 +777,7 @@
:source {:symbol id}}])) :source {:symbol id}}]))
ordered)}} ordered)}}
(map (fn [[id part]] (map (fn [[id part]]
[id (place-in (:symbol part) placement)])) [id (assoc (place-in (:symbol part) placement) :fps fps)]))
parts)}] parts)}]
(doseq [[subject inputs] ordered (doseq [[subject inputs] ordered
[id track] (:presence inputs)] [id track] (:presence inputs)]

View file

@ -151,7 +151,7 @@
(throw (ex-info "regeneration needs retained source landmarks" (throw (ex-info "regeneration needs retained source landmarks"
{:subject subject}))) {:subject subject})))
base-params (merge take/knobs base-params (merge take/knobs
{:fps (:fps changed) {:fps (or (get-in changed [:symbols subject :fps]) (:fps changed))
:aspect (get-in changed [:analysis :aspect]) :aspect (get-in changed [:analysis :aspect])
:analysis (:analysis changed)}) :analysis (:analysis changed)})
;; One conditioned anchor for the whole edit, and it is the SUBJECT's. ;; One conditioned anchor for the whole edit, and it is the SUBJECT's.

View file

@ -119,8 +119,8 @@
(defn footage (defn footage
"A real manifest and its detected landmarks through the same measurement and "A real manifest and its detected landmarks through the same measurement and
freeze path as the synthetic take. The source cadence stays in :fps; picture freeze path as the synthetic take. Symbols keep the source fps; the reader
sampling is a root time map applied only after this artifact exists." selects native frames on the project grid."
[manifest {:keys [dimensions detector subjects]}] [manifest {:keys [dimensions detector subjects]}]
(let [[w h] dimensions (let [[w h] dimensions
params (merge knobs params (merge knobs

View file

@ -12,7 +12,6 @@
(rf/reg-sub ::loop? (fn [db _] (get-in db [:playback :loop?]))) (rf/reg-sub ::loop? (fn [db _] (get-in db [:playback :loop?])))
(rf/reg-sub ::muted? (fn [db _] (get-in db [:playback :muted?]))) (rf/reg-sub ::muted? (fn [db _] (get-in db [:playback :muted?])))
(rf/reg-sub ::fps (fn [db _] (get-in db [:clip :fps]))) (rf/reg-sub ::fps (fn [db _] (get-in db [:clip :fps])))
(rf/reg-sub ::display-fps (fn [db _] (get-in db [:clip :display-fps])))
;; The open symbol can have a local stage size; otherwise it follows the project. ;; The open symbol can have a local stage size; otherwise it follows the project.
(rf/reg-sub ::stage-size (rf/reg-sub ::stage-size
:<- [:arthur.subs.playback/clip-id] :<- [:arthur.subs.playback/clip-id]

View file

@ -64,11 +64,9 @@
(rf/reg-sub (rf/reg-sub
::frames ::frames
:<- [::symbol] :<- [::clip]
(fn [sym _] :<- [::open]
;; The open symbol's length, read off it rather than copied into the db, so a (fn [[document sid] _] (clip/output-frames document sid)))
;; drop that lengthens it lengthens the transport with it.
(:frames sym)))
(rf/reg-sub (rf/reg-sub
::exposure ::exposure
@ -129,10 +127,9 @@
:<- [::open] :<- [::open]
:<- [::store] :<- [::store]
:<- [::palette] :<- [::palette]
:<- [::playback/display-fps] (fn [[document sid store palette] _]
(fn [[document sid store palette picture-fps] _]
(when (and document (clip/symbol document sid)) (when (and document (clip/symbol document sid))
(clip/resolver document sid store palette {:picture-fps picture-fps})))) (clip/resolver document sid store palette nil))))
(rf/reg-sub (rf/reg-sub
::shown ::shown

View file

@ -68,7 +68,7 @@
(when n (when n
(let [st (:store (store/entry clip-id))] (let [st (:store (store/entry clip-id))]
(when-let [pl (nest/placement clip st open (or path [id]) f)] (when-let [pl (nest/placement clip st open (or path [id]) f)]
(assoc pl :node n :bounds ((pick/bounds-of clip st n) (:frame pl)))))))) (assoc pl :node n :bounds ((pick/bounds-of clip st (:sid pl) n) (:frame pl))))))))
(rf/reg-sub (rf/reg-sub
::settled-clip ::settled-clip

View file

@ -37,7 +37,7 @@
(let [{document :clip st :store} (store/entry clip-id)] (let [{document :clip st :store} (store/entry clip-id)]
(reset! carrying (merge {:kind :symbol :sid sid (reset! carrying (merge {:kind :symbol :sid sid
:label (clip/symbol-name document sid) :label (clip/symbol-name document sid)
:frames (clip/frames document sid) :frames (clip/output-frames document sid)
;; A symbol cannot go inside itself or inside ;; A symbol cannot go inside itself or inside
;; anything it places. Refused by not ACCEPTING the ;; anything it places. Refused by not ACCEPTING the
;; drop, so the pointer says so while it hovers. ;; drop, so the pointer says so while it hovers.

View file

@ -80,7 +80,6 @@
(defn- clip-section [] (defn- clip-section []
(let [clip @(rf/subscribe [::render/clip]) (let [clip @(rf/subscribe [::render/clip])
fps @(rf/subscribe [::playback/fps]) fps @(rf/subscribe [::playback/fps])
picture @(rf/subscribe [::playback/display-fps])
open @(rf/subscribe [::render/open]) open @(rf/subscribe [::render/open])
frames @(rf/subscribe [::render/frames]) frames @(rf/subscribe [::render/frames])
project @(rf/subscribe [::playback/project]) project @(rf/subscribe [::playback/project])
@ -98,18 +97,7 @@
[number-field "height" (:height clip) [number-field "height" (:height clip)
#(rf/dispatch [::project/project-setting :height %]) nil busy?] #(rf/dispatch [::project/project-setting :height %]) nil busy?]
[number-field "project fps" fps [number-field "project fps" fps
#(rf/dispatch [::project/project-setting :fps %]) nil busy?]] #(rf/dispatch [::project/project-setting :fps %]) nil busy?]]]))
;; Sampling the frozen roto at a lower rate. The source track, the duration
;; and the audio clock are untouched — a drawing is HELD, the file is never
;; short — which is why this is a picture rate and not a playback rate.
[:div.row {:style {:margin-top "5px"}}
[:span.dim "picture"]
(doall
(for [r (distinct (filter #(<= % fps) [8 12 15 24 fps]))]
^{:key r}
[:button {:class (when (= r picture) "on")
:on-click #(rf/dispatch [::pb/set-picture-fps r])}
(if (= r fps) "source" (str r))]))]]))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; a node ;; a node
@ -456,7 +444,7 @@
[section "symbol" [section "symbol"
[facts [facts
"id" (str sid) "id" (str sid)
"length" (str (:frames sym) " frames") "length" (str (:frames sym) " frames at " (clip-domain/fps clip sid) " fps")
"stage" (let [[w h] (clip-domain/stage clip sid)] (str w "×" h)) "stage" (let [[w h] (clip-domain/stage clip sid)] (str w "×" h))
"nodes" (str (count (:nodes sym)))] "nodes" (str (count (:nodes sym)))]
[:div.inspector-form [:div.inspector-form

View file

@ -294,8 +294,8 @@
[{:keys [id label duration footage? fps]} project-fps rename] [{:keys [id label duration footage? fps]} project-fps rename]
(let [frames (js/Math.ceil (* duration project-fps)) (let [frames (js/Math.ceil (* duration project-fps))
[source length rate] (if footage? [source length rate] (if footage?
[{:footage id} (js/Math.round (* duration fps)) (/ fps project-fps)] [{:footage id :fps fps} (js/Math.round (* duration fps)) 1]
[{:sound id} frames 1])] [{:sound id :fps project-fps} frames 1])]
^{:key (str (when footage? "f") id)} ^{:key (str (when footage? "f") id)}
[row (merge {:label label [row (merge {:label label
:sub (str (.toFixed duration 1) "s") :sub (str (.toFixed duration 1) "s")

View file

@ -22,6 +22,7 @@
happen and not where they happen again." happen and not where they happen again."
(:require [clojure.string :as str] (:require [clojure.string :as str]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.clip :as clip]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.lane :as lane] [arthur.domain.lane :as lane]
[arthur.domain.symbol :as symbol] [arthur.domain.symbol :as symbol]
@ -65,8 +66,10 @@
(defn- source-frames (defn- source-frames
"How long the thing this node places is, for a placement carrying no span of "How long the thing this node places is, for a placement carrying no span of
its own. 0 for a node that places nothing, or a reference with nothing there." its own. 0 for a node that places nothing, or a reference with nothing there."
[clip n] [clip sid n]
(or (get-in clip [:symbols (node/source n) :frames]) 0)) (if-let [{:keys [at rate]} (clip/source-time clip sid n)]
(+ at (/ (or (clip/frames clip (node/source n)) 0) rate))
(clip/frames clip sid)))
(defn- node-label (defn- node-label
"What to call a node in the label column. "What to call a node in the label column.
@ -126,7 +129,7 @@
(or (node/placed-span (or (node/placed-span
(cond-> n (cond-> n
(and (= :instance (:kind n)) (nil? (:span n))) (and (= :instance (:kind n)) (nil? (:span n)))
(assoc :span [0 (source-frames clip n)]))) (assoc :span [0 (source-frames clip sid n)])))
[0 (:frames sym)])) [0 (:frames sym)]))
row {:path rpath row {:path rpath
:depth depth :depth depth
@ -167,13 +170,13 @@
(-> [row] (-> [row]
(into (channel-rows n rpath (inc depth) self span)) (into (channel-rows n rpath (inc depth) self span))
(into (when-let [{:keys [at rate]} (and (= :instance (:kind n)) (into (when-let [{:keys [at rate]} (and (= :instance (:kind n))
(node/source-time n))] (clip/source-time clip sid n))]
(when-let [child (node/source n)] (when-let [child (node/source n)]
(walk child rpath (inc depth) (walk child rpath (inc depth)
(comp self #(+ at (/ % rate))))))))))))) (comp self #(+ at (/ % rate)))))))))))))
ordered))))] ordered))))]
(if (get-in clip [:symbols sid]) (if (get-in clip [:symbols sid])
(walk sid [] 0 identity) (walk sid [] 0 #(/ % (:rate (clip/grid-time clip sid))))
[]))) [])))
(defn sound-rows (defn sound-rows

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

View file

@ -82,9 +82,7 @@
:sym/poses symbol}} :sym/poses symbol}}
store {"sizes" {:data values}} store {"sizes" {:data values}}
resolve (clip/resolver document :main store pal/index-of nil) 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)))]
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)))]
(is (empty? (clip/problems document))) (is (empty? (clip/problems document)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document)))) (is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (= 102 (:size (get (at 7) [:first :mouth]))) "eight static frames, plus its correction") (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])) (is (nil? (get (at 9) [:first :mouth]))
"generated visibility is read from the same selected pose") "generated visibility is read from the same selected pose")
(is (some? (get (at 9) [:second :mouth]))) (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]) (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]) (is (= (mapv #(select-keys % [:node :cx :size])
(symbol/eval-frame sym 8 store pal/index-of opts)) (symbol/eval-frame sym 8 store pal/index-of opts))
(mapv #(select-keys % [:node :cx :size]) (mapv #(select-keys % [:node :cx :size])
@ -249,7 +241,7 @@
made (clip/new-symbol c :outer id 20 u)] made (clip/new-symbol c :outer id 20 u)]
(is (= :symbol-1 id)) (is (= :symbol-1 id))
(is (= :symbol-2 (clip/fresh-id made)) "the next one does not collide") (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)) (clip/symbol made :symbol-1))
"empty, and as long as the rest of what it was placed in") "empty, and as long as the rest of what it was placed in")
(is (= {:span [0 180] :time {:mode :map :at 20 :rate 1}} (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 (= 0 (:frame (nest/inside doc nil :main [:a] 3))))
(is (nil? (:time (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 (nil? (nest/inside doc nil :main [:a] 4)))
(is (= ((pick/bounds-of doc nil n) 2) (is (= ((pick/bounds-of doc nil :main n) 2)
((pick/bounds-of doc nil (assoc-in n [:playback :in] 5)) 0))))) ((pick/bounds-of doc nil :main (assoc-in n [:playback :in] 5)) 0)))))
(deftest seeking-and-source-reuse-do-not-share-cursors (deftest seeking-and-source-reuse-do-not-share-cursors
(let [doc (assoc-in (document) [:symbols :main :nodes :b :source :symbol] :drawing-a) (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 ;; 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. ;; addressed by the same path shape as the clip's own. `main` is the root.
(is (contains? ls "clip/c7/symbol/main")) (is (contains? ls "clip/c7/symbol/main"))
(is (= {:frames 229} (get ls "clip/c7/symbol/main")) (is (= {:frames 229 :fps 30} (get ls "clip/c7/symbol/main"))
"a timeline's leaf is its frame space; :fps is the clip's") "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. ;; 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/node/mouth"))
(is (contains? ls "clip/c7/symbol/face-1/channel/mouth/geom.pts")) (is (contains? ls "clip/c7/symbol/face-1/channel/mouth/geom.pts"))

View file

@ -1,19 +1,5 @@
(ns arthur.export-test (ns arthur.export-test
"The frame walk and the arithmetic above the sink. "The output-grid frame walk and duration, using a recording 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."
(:require [cljs.test :refer [deftest is testing async]] (:require [cljs.test :refer [deftest is testing async]]
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
@ -85,21 +71,12 @@
(is (= 1 (zoom-of nil)) "an absent zoom is 1:1") (is (= 1 (zoom-of nil)) "an absent zoom is 1:1")
(is (= 1 (zoom-of 1.9))))) (is (= 1 (zoom-of 1.9)))))
(deftest a-lower-picture-rate-changes-the-poses-and-not-the-length (deftest a-lower-output-rate-keeps-native-duration
;; THE SYNC RULE, in the arithmetic. 48 frames at 24fps is two seconds; at a (let [doc (assoc-in (a-clip {:frames 48 :fps 24}) [:symbols :main :fps] 24)
;; 12fps picture rate it is still 48 frames and two seconds, holding 24 poses. p (export/plan {:clip (clip/set-fps doc 12) :symbol :main})]
;; If :frames ever tracks :poses here, every export at a reduced picture rate (is (= 24 (:frames p)))
;; comes out half length with the audio sliding off it. (is (= 12 (:fps p)))
(let [p (export/plan {:clip (a-clip {:frames 48 :fps 24}) :symbol :main (is (= 2 (:seconds p)))))
: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 plan-of-a-symbol-that-is-not-there-is-nothing (deftest plan-of-a-symbol-that-is-not-there-is-nothing
(is (nil? (export/plan {:clip (a-clip {}) :symbol :nope :zoom 1})))) (is (nil? (export/plan {:clip (a-clip {}) :symbol :nope :zoom 1}))))
@ -115,17 +92,12 @@
(done))) (done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))) (.catch (fn [e] (is false (str "threw: " e)) (done))))))
(deftest a-lower-picture-rate-still-emits-every-frame (deftest a-lower-output-rate-emits-the-selected-grid
;; THE SYNC RULE, in the walk — the assertion that matters most in this file.
;; The poses repeat; the frames do not thin out.
(async done (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]}] (.then (fn [{:keys [frames spec]}]
(is (= 12 (count frames)) (is (= (range 4) (map :i frames)))
"a 12-frame timeline exports 12 frames at any picture rate") (is (= 8 (:fps spec)))
(is (= (range 12) (map :i frames)))
(is (= 24 (:fps spec))
"and the file's rate is the CLIP's, not the picture rate")
(done))) (done)))
(.catch (fn [e] (is false (str "threw: " e)) (done)))))) (.catch (fn [e] (is false (str "threw: " e)) (done))))))

View file

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