Add instanced 8625 stage with independent audio controls

This commit is contained in:
Olive Vaughn 2026-09-28 15:05:04 -04:00
parent a611b86c0d
commit ffb95543a3
15 changed files with 507 additions and 48 deletions

View file

@ -0,0 +1,119 @@
(ns arthur.audio.mix
"Render independently placed audio tracks into one stage audio clock.
The mix is derived from saved audio track leaves and immutable footage blobs.
The transport still has one audio element, so seeking, rate changes and looping
stay tied to the same clock the picture reads."
(:require [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.node :as node]))
(defn- wav [^js buffer]
(let [channels (.-numberOfChannels buffer)
frames (.-length buffer)
rate (.-sampleRate buffer)
bytes (js/ArrayBuffer. (+ 44 (* frames channels 2)))
view (js/DataView. bytes)
samples (mapv #(.getChannelData buffer %) (range channels))
peak (reduce max 0
(for [channel samples i (range frames)]
(js/Math.abs (aget channel i))))
level (if (> peak 0.98) (/ 0.98 peak) 1)]
(doseq [[offset word] [[0 "RIFF"] [8 "WAVE"] [12 "fmt "] [36 "data"]]]
(dotimes [i 4] (.setUint8 view (+ offset i) (.charCodeAt word i))))
(.setUint32 view 4 (- (.-byteLength bytes) 8) true)
(.setUint32 view 16 16 true)
(.setUint16 view 20 1 true)
(.setUint16 view 22 channels true)
(.setUint32 view 24 rate true)
(.setUint32 view 28 (* rate channels 2) true)
(.setUint16 view 32 (* channels 2) true)
(.setUint16 view 34 16 true)
(.setUint32 view 40 (* frames channels 2) true)
(dotimes [i frames]
(dotimes [c channels]
(let [sample (* level (aget (get samples c) i))]
(.setInt16 view (+ 44 (* (+ (* i channels) c) 2))
(js/Math.round (* 32767 (max -1 (min 1 sample)))) true))))
(js/URL.createObjectURL (js/Blob. #js [bytes] #js {:type "audio/wav"}))))
(defn- source! [footage-id]
(-> (js/fetch (str "/api/footage/" footage-id))
(.then (fn [response]
(when-not (.-ok response)
(throw (ex-info "audio track's footage is missing"
{:footage footage-id :status (.-status response)})))
(.json response)))
(.then (fn [^js manifest]
(-> (js/fetch (.-audio manifest))
(.then (fn [response]
(when-not (.-ok response)
(throw (ex-info "audio track's blob is missing"
{:footage footage-id :status (.-status response)})))
(.arrayBuffer response)))
(.then (fn [bytes]
(let [decoder (js/OfflineAudioContext. 1 1 44100)]
(-> (.decodeAudioData decoder bytes)
(.then (fn [buffer]
[footage-id {:buffer buffer
:fps (.-fps manifest)}])))))))))))
(defn- automate! [^js param channel start end fps factor default store]
(let [channel (or channel (ch/framed default))]
(.setValueAtTime param (* factor (ch/value-at channel start store)) (/ start fps))
(cond
(:dense channel)
(doseq [f (range (inc start) end)]
(.setValueAtTime param (* factor (ch/value-at channel f store)) (/ f fps)))
(:animated? channel)
(doseq [[f v] (sort-by key (:keys channel))
:when (and (> f start) (< f end))]
(if (= :linear (:interp channel))
(.linearRampToValueAtTime param (* factor v) (/ f fps))
(.setValueAtTime param (* factor v) (/ f fps)))))))
(defn- render! [document sources store]
(let [fps (:fps document)
frames (clip/frames document)
tracks (filter #(= :audio (:kind %)) (vals (clip/nodes document)))
output (js/OfflineAudioContext.
2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)]
(doseq [track tracks]
(let [[start end] (or (:span track) [0 frames])
start (max 0 start)
end (min frames end)
{:keys [buffer fps]} (get sources (get-in track [:source :footage]))
sound (.createBufferSource output)
gain (.createGain output)
pan (.createStereoPanner output)]
(when (< start end)
(set! (.-buffer sound) buffer)
(set! (.-loop sound) (boolean (get-in track [:time :loop?])))
(automate! (.-playbackRate sound)
(get-in track [:channels [:audio :rate]])
start end (:fps document) (or (get-in track [:time :rate]) 1) 1 store)
(automate! (.-gain gain)
(get-in track [:channels [:audio :gain]])
start end (:fps document) 1 1 store)
(automate! (.-pan pan)
(get-in track [:channels [:audio :pan]])
start end (:fps document) 1 0 store)
(.connect sound gain)
(.connect gain pan)
(.connect pan (.-destination output))
(.start sound (/ start (:fps document)) (/ (node/local-frame track start) fps))
(.stop sound (/ end (:fps document))))))
(-> (.startRendering output) (.then wav))))
(defn mix!
"Promise of a mixed WAV URL, or the original URL for a clip without audio
tracks. Each track can be trimmed and faded independently of its linked picture."
([document fallback-url] (mix! document fallback-url nil))
([document fallback-url store]
(let [tracks (filter #(= :audio (:kind %)) (vals (clip/nodes document)))]
(if (empty? tracks)
(js/Promise.resolve fallback-url)
(-> (js/Promise.all
(into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks)))))
(.then (fn [pairs] (render! document (into {} (array-seq pairs)) store))))))))

View file

@ -0,0 +1,32 @@
(ns arthur.demo.stage
"A saved take placed twice on a stage. The EDN is the authored layout."
(:require [cljs.reader :as reader]
[shadow.resource :as rc]))
(def layout (reader/read-string (rc/inline "arthur/demo/stage_8625.edn")))
(defn compose [source]
(let [{:keys [name width height frames symbol instances audio]} layout
original (get-in source [:timelines :main])
nodes (into
{:root {:id :root :name "stage" :kind :group :z "a1"}}
(map (fn [{:keys [id name z span at in pos scale]}]
[id {:id id :name name :kind :symbol :of symbol
:parent :root :z z :span span
:time {:mode :map :at at :in in :rate 1}
:channels {[:xform :pos] {:animated? false :value pos}
[:xform :scale] {:animated? false :value scale}}}])
instances))
nodes (into nodes
(map (fn [{:keys [id linked-to z source span at in gain pan]}]
[id {:id id :kind :audio :parent :root :z z
:linked-to linked-to :source source :span span
:time {:mode :map :at at :in in :rate 1}
:channels (cond-> {[:audio :gain] gain}
pan (assoc [:audio :pan] pan))}])
audio))]
(-> source
(assoc :name name :width width :height height
:timelines {:main {:id :main :frames frames :nodes nodes}
symbol (assoc original :id symbol)})
(dissoc :subjects :features :groups))))

View file

@ -0,0 +1,32 @@
;; A local stage sketch. The source is the saved, post-processed IMG_8625.MOV
;; clip in the project store; its dense channel blocks remain shared by both
;; instances. Positions and timing are authored here in stage pixels and frames.
{:source-project "4379f900-bdd2-409b-acf6-32081f8ce01f"
:source-cid "f8cace9e-4ad3-4796-973c-c62eeebe3d01"
:symbol :sym/face-8625
:name "8625 stage study"
:width 320 :height 200 :frames 280
;; Audio placements are ordinary timeline nodes with channel parameters.
;; :linked-to is an editorial link; their spans and time maps are independent.
:audio
[{:id :voice-left :linked-to :left :z "a3"
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
:span [0 280] :at 0 :in 0
:gain {:animated? false :value 1.0}}
{:id :voice-right :linked-to :right :z "a4"
:source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"}
:span [48 260] :at 48 :in 0
:gain {:animated? true :interp :linear
:keys {48 0.0, 60 1.0, 90 0.35, 115 0.9, 145 0.45,
170 1.0, 195 0.4, 220 0.85, 245 1.0, 259 0.0}
:over []}
:pan {:animated? true :interp :linear
:keys {48 -0.8, 90 -0.8, 130 0.7, 175 0.7, 220 -0.65, 259 0.65}
:over []}}]
:instances
[{:id :left :name "8625 left" :z "a1"
:span [0 280] :at 0 :in 0
:pos [12 38] :scale [0.62 0.62]}
{:id :right :name "8625 right" :z "a2"
:span [48 280] :at 48 :in 0
:pos [108 38] :scale [0.62 0.62]}]}

View file

@ -92,12 +92,9 @@
(vec (sort (keys ks)))))
(defn- check-unimplemented!
"An override layer or a retimed symbol instance must fail LOUDLY rather than
be ignored.
"An override layer must fail LOUDLY rather than be ignored.
Both are specified in docs/animation-model.md and neither is built yet
(port-plan scope: \"leave the :over field present and empty; leave :symbol out
entirely\"). Silently dropping an :over layer would present as a hand
Silently dropping an :over layer would present as a hand
correction that did not take — a correction the user made once, watched fail,
and has no reason to trust again. Nothing can produce one yet, so this can
only fire on a data shape that has run ahead of the code."
@ -172,6 +169,13 @@
;; ---------------------------------------------------------------------------
;; the specification
(defn- interpolate [ch f left right]
(let [a (get (:keys ch) left)]
(if (and (= :linear (:interp ch)) right (>= f left) (> right left))
(let [b (get (:keys ch) right)]
(+ a (* (/ (- f left) (- right left)) (- b a))))
a)))
(defn- keyed-at
"The most recent key at or before f, CLAMPED to the first key below it.
@ -180,11 +184,11 @@
frame before it reads that pose rather than having no value. This is not the
same question as presence — a part with no value at all is `absent`, which is
a state bit, not an empty key map."
[ks f]
(let [fr (sort (keys ks))]
(if-let [hit (last (take-while #(<= % f) fr))]
(get ks hit)
(get ks (first fr)))))
[ch f]
(let [fr (sort (keys (:keys ch)))
left (or (last (take-while #(<= % f) fr)) (first fr))
right (first (drop-while #(<= % f) fr))]
(interpolate ch f left right)))
(defn value-at
"Sample a channel at frame f. THE SPECIFICATION — correct, allocating, and
@ -196,7 +200,7 @@
(not (:animated? ch)) (:value ch)
(:dense ch) (dense-at (:dense ch) f store)
(:keys ch) (let [ks (:keys ch)]
(if (empty? ks) absent (keyed-at ks f)))
(if (empty? ks) absent (keyed-at ch f)))
:else
(throw (ex-info "animated channel has neither :keys nor :dense" {:channel ch})))))
@ -272,7 +276,7 @@
:else (bsearch ks f))]
(set! (.-i cur) i')
(get (:keys ch) (nth ks i'))))))
(interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i'))))))))
;; ---------------------------------------------------------------------------
@ -307,9 +311,12 @@
(and (map? ch) (:keys ch) (map? (:keys ch)) (not (every? number? (keys (:keys ch)))))
(conj ":keys has a non-numeric frame")
(and (map? ch) (:animated? ch) (not (#{:hold nil} (:interp ch))))
(conj (str ":interp " (:interp ch) " — only :hold is implemented; docs/design.md"
" requires hold of every cut part and tweening reads as puppet software"))
(and (map? ch) (:animated? ch) (not (#{:hold :linear nil} (:interp ch))))
(conj (str ":interp " (:interp ch) " is not implemented"))
(and (map? ch) (= :linear (:interp ch))
(or (:dense ch) (not (every? number? (vals (:keys ch))))))
(conj ":linear interpolation needs numeric keyed values")
(and (map? ch) (seq (:over ch)))
(conj ":over layers are not implemented (port-plan step 2 scope)")
@ -322,4 +329,3 @@
(conj (str ":dense :scale is " (pr-str (:scale (:dense ch)))
" — a fixed-point scale is a positive number the stored integers"
" were multiplied by"))))

View file

@ -19,9 +19,8 @@
structure with the same `:nodes` key that every walk had to be taught about.
Now there is one node-holding type — `arthur.domain.timeline` — and a clip holds
a MAP of them. `:kind :symbol` is still unimplemented and this is the shape it
was waiting for: an instance names a timeline in `:timelines`, and the resolver
recurses into a type it already knows how to evaluate.
a MAP of them. A `:kind :symbol` instance names a timeline in `:timelines`,
and the clip resolver gives each placement its own reading heads.
THE ROOT TIMELINE HAS A RESERVED ID, `:main`, rather than the clip carrying a
pointer to it. A pointer is a field that can be wrong — it can name a timeline
@ -35,6 +34,8 @@
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 timeline has its own."
(:require [arthur.domain.feature :as feature]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.timeline :as timeline]))
(def ^:const root-id
@ -81,6 +82,69 @@
[clip]
(:nodes (root clip)))
(defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space."
[op m path]
(let [at (fn [x y] [(+ (* (aget m 0) x) (* (aget m 2) y) (aget m 4))
(+ (* (aget m 1) x) (* (aget m 3) y) (aget m 5))])
scale (node/mean-scale m)
op (assoc op :node (conj path (:node op)))]
(case (:kind op)
:poly (let [out (js/Float64Array. (.-length (:pts op)))]
(dotimes [i (:n op)]
(let [[x y] (at (aget (:pts op) (* 2 i))
(aget (:pts op) (inc (* 2 i))))]
(aset out (* 2 i) x)
(aset out (inc (* 2 i)) y)))
(assoc op :pts out))
:disc (let [[x y] (at (:cx op) (:cy op))]
(assoc op :cx x :cy y :r (* scale (:r op))))
:rect (let [[x y] (at (:cx op) (:cy op))]
(assoc op :cx x :cy y :size (js/Math.round (* scale (:size op)))))
op)))
(defn resolver
"Resolve a clip, including each library timeline placed by a symbol instance.
Each instance owns its own timeline resolver, so two offsets never share a
channel cursor or point buffer. The returned ops must be drawn before the next
frame, as with timeline/resolver."
([clip store] (resolver clip store pal/index-of))
([clip store palette]
(letfn [(build [tid chain]
(when (some #{tid} chain)
(throw (ex-info "symbol timeline cycle" {:chain (conj chain tid)})))
(let [tl (or (timeline clip tid)
(throw (ex-info "symbol names a missing timeline" {:timeline tid})))
nodes (:nodes tl)
rank (timeline/draw-rank nodes (timeline/order nodes))
ids (sort-by rank (keys nodes))
own (timeline/resolver tl store palette)
children (into {}
(for [[id n] nodes :when (= :symbol (:kind n))]
[id (build (:of n) (conj chain tid))]))]
(fn [f]
(let [by-id (into {} (map (juxt :node identity)) (own f))]
(into []
(mapcat
(fn [id]
(let [n (get nodes id)]
(if (= :symbol (:kind n))
(let [m (timeline/world-of own id)
local (timeline/frame-of own id)
target (timeline clip (:of n))
length (:frames target)
frame (when (and m (number? local))
(if (get-in n [:time :loop?])
(mod local length)
local))]
(if (and frame (<= 0 frame) (< frame length))
(map #(transform-op % m [id]) ((get children id) frame))
[]))
(when-let [op (get by-id id)] [op]))))
ids))))))]
(build root-id []))))
(defn problems
"Human-readable reasons this clip will not evaluate or will not save. Empty
means it will.
@ -106,5 +170,17 @@
(for [[id tl] (:timelines clip)
p (timeline/problems tl)]
(str "timeline " (pr-str id) ": " p))
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
:when (and (= :symbol (:kind n))
(not (contains? (:timelines clip) (:of n))))]
(str "timeline " (pr-str tid) " symbol " (pr-str id)
" names missing timeline " (pr-str (:of n))))
(for [[tid tl] (:timelines clip)
[id n] (:nodes tl)
:when (and (= :audio (:kind n)) (:linked-to n)
(not (contains? (:nodes tl) (:linked-to n))))]
(str "timeline " (pr-str tid) " audio " (pr-str id)
" links to missing node " (pr-str (:linked-to n))))
(when (map? (root clip))
(feature/problems clip (nodes clip))))))

View file

@ -18,12 +18,12 @@
(:require [arthur.domain.channel :as ch]))
(def kinds
"`:symbol` and `:bitmap` are in the vocabulary and not implemented; they are
"`:bitmap` is in the vocabulary and not implemented; it is
here so that a scene that names one fails as \"not implemented\" rather than as
\"not a kind\"."
#{:poly :disc :rect :group :bitmap :symbol})
#{:poly :disc :rect :group :bitmap :symbol :audio})
(def implemented-kinds #{:poly :disc :rect :group})
(def implemented-kinds #{:poly :disc :rect :group :symbol :audio})
(def xform-paths
"In composition order, which is also the order they have to be sampled in.
@ -45,6 +45,8 @@
change to this spec silently change what gets drawn."
(let [base (into #{[:vis]} xform-paths)]
{:group base
:symbol base
:audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]])
:poly (into base [[:geom :pts] [:style :color]])
;; A disc's radius is framed in practice — iris size is a knob, not a
;; performance — but it is a channel like any other so it can be keyed.
@ -115,22 +117,20 @@
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]
(let [{:keys [mode offset rate source-fps sample-fps]
(let [{:keys [mode offset rate at in source-fps sample-fps]
ex :expose :or {mode :inherit}} (:time n)]
(if (= mode :inherit)
f
(do
;; A symbol instance's (f - at)·rate + in is the third face of this
;; mechanism and symbols are out of scope. Loud rather than ignored: a
;; silently dropped rate is a retimed blink playing at the wrong speed,
;; which looks like a bad blink and not like a missing feature.
(when (and rate (not= rate 1.0) (not= rate 1))
(throw (ex-info "time map :rate is symbol timing and symbols are not built (port-plan step 2 scope)"
(when (and (not (#{:symbol :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1))
(throw (ex-info "time map :rate belongs to a symbol or audio instance"
{:node (:id n) :time (:time n)})))
(when (and sample-fps (not (and source-fps (pos? source-fps))))
(throw (ex-info "picture sampling needs a positive source fps"
{:node (:id n) :time (:time n)})))
(cond-> f
(cond-> (if (#{:symbol :audio} (:kind n))
(+ (or in 0) (* (or rate 1) (- f (or at 0))))
f)
sample-fps (sample-frame source-fps sample-fps)
ex (expose ex)
offset (+ offset))))))
@ -265,6 +265,12 @@
(not (contains? implemented-kinds k)))
(conj (str ":kind " k " is in the vocabulary but not implemented"))
(and (= k :symbol) (nil? (:of n))) (conj "a symbol instance needs :of")
(and (= k :audio) (nil? (get-in n [:source :footage])))
(conj "an audio instance needs :source :footage")
(and (#{:symbol :audio} k) (some? (get-in n [:time :rate]))
(not (pos? (get-in n [:time :rate]))))
(conj "an instance's :rate must be positive")
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree")
(and (:span n) (not= 2 (count (:span n))))
(conj ":span must be [in out]"))

View file

@ -282,6 +282,8 @@
(let [colour #(colour-index palette (rd [:style :color]))]
(case (:kind n)
:group nil
:symbol nil
:audio nil
:poly
(let [pts (rd [:geom :pts])]
@ -356,7 +358,8 @@
(if (and pid (nil? parent))
acc
(if-let [p (place ctx n parent f)]
(let [op (emit ctx n p {:node id :stencil (:stencil n)})]
(let [_ (when-let [on-place (:on-place ctx)] (on-place id p))
op (emit ctx n p {:node id :stencil (:stencil n)})]
(cond-> (update acc :placed assoc id p)
op (update :ops conj op)))
acc))))
@ -418,7 +421,8 @@
A registered photo underlay has to ride the same transform the vectors went
through or it is merely decorative, and it paints immediately after the frame
it belongs to, so \"as of the last frame\" is the only answer that can be
correct."))
correct.")
(frame-of [this id] "The placed node's local frame on the last resolve."))
(defn resolver
"(fn [f] -> ops). Holds everything that does not change per frame.
@ -455,21 +459,23 @@
;; it is how the resolver learns which nodes exist this frame without
;; eval-into having to report it — and it covers groups, which are
;; placed but emit no op, and which are exactly what an underlay rides.
placed (volatile! #{})
placed (volatile! {})
ctx {:read (fn [id path _c lf] (ch/sample! (get-in cursors [id path]) lf))
:palette palette
:mat-for (fn [id] (vswap! placed conj id) (get mats id))
:mat-for (fn [id] (get mats id))
:on-place (fn [id p] (vswap! placed assoc id p))
:pinv-for (fn [id] (get pinvs id))
:buf-for (fn [id _n] (get bufs id))
:scratch scratch}
step (fn [f]
(vreset! placed #{})
(vreset! placed {})
(eval-into ctx nodes ord rank f))]
(reify
IFn
(-invoke [_ f] (step f))
IResolver
(world-of [_ id] (when (contains? @placed id) (get mats id)))))))
(world-of [_ id] (:m (get @placed id)))
(frame-of [_ id] (:f (get @placed id)))))))
;; ---------------------------------------------------------------------------

View file

@ -22,6 +22,8 @@
Nothing here touches app-db except through events. The promise chain lives in an
fx, which is the only thing in this namespace that is not pure."
(:require [arthur.domain.clip :as clip]
[arthur.audio.mix :as mix]
[arthur.demo.stage :as stage]
[arthur.domain.project :as project]
[arthur.domain.wire :as wire]
[arthur.events.playback :as pb]
@ -98,14 +100,16 @@
cid #js {:leaves (.-leaves clip-json)
:blocks blocks})
built (:clip loaded)]
(merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :frames (clip/frames built)
:display-fps (:fps built)
:clip built :store (:store loaded)
:footage-id footage-id
:audio (if footage (.-audio footage)
"/static/arthur/audio.wav")})))))))
(let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :frames (clip/frames built)
:display-fps (:fps built)
:clip built :store (:store loaded)
:footage-id footage-id
:audio (if footage (.-audio footage)
"/static/arthur/audio.wav")})]
(-> (mix/mix! built (:audio entry) (:store entry))
(.then (fn [audio] (assoc entry :audio audio)))))))))))
(rf/reg-fx
::save!
@ -173,6 +177,29 @@
(js/console.error error)
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
(rf/reg-fx
::stage!
(fn [_]
(-> (http/GET (str "/api/projects/" (:source-project stage/layout)))
(.then (fn [^js saved]
(or (first (filter #(= (:source-cid stage/layout) (.-cid ^js %))
(array-seq (.-clips saved))))
(throw (ex-info "the saved 8625 clip is missing" {})))))
(.then opened-entry!)
(.then (fn [entry]
(let [built (stage/compose (:clip entry))
entry (assoc entry :clip built :label (:name built)
:cid "stage-8625" :frames (clip/frames built)
:width (:width built) :height (:height built))]
(-> (mix/mix! built (:audio entry) (:store entry))
(.then (fn [audio]
(rf/dispatch
[::stage-opened
(store/install! (assoc entry :audio audio) "stage")])))))))
(.catch (fn [error]
(js/console.error error)
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
;; ---------------------------------------------------------------------------
;; events
@ -198,6 +225,28 @@
::pb/pause! nil
::open! (:id (:project db))})))
(rf/reg-event-fx
::load-stage
(fn [{:keys [db]} _]
(if (:busy? (:project db))
{}
{:db (update db :project merge {:busy? true :status "loading 8625 stage…"})
::pb/pause! nil
::stage! nil})))
(rf/reg-event-fx
::stage-opened
(fn [{:keys [db]} [_ clip-id]]
(let [entry (store/entry clip-id)]
{:db (-> db
(assoc :clip/current clip-id
:clip (select-keys entry [:fps :frames :width :height :audio :display-fps]))
(assoc :project {:id nil :cid nil :name nil :seq nil
:busy? false :status "loaded 8625 stage study"})
(assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false))
::pb/seek! [(:fps entry) (:frames entry) 0]})))
(rf/reg-event-db
::saved
(fn [db [_ id cid label seq written uploaded]]

View file

@ -21,6 +21,8 @@
([entry] (install! entry "footage"))
([entry kind]
(let [id (keyword kind (str (swap! serial inc)))]
(when-let [old (:audio @loaded)]
(when (.startsWith old "blob:") (js/URL.revokeObjectURL old)))
(reset! loaded (assoc entry :id id))
id)))

View file

@ -79,8 +79,10 @@
(rf/reg-sub
::resolver
:<- [::clip]
:<- [::timeline]
:<- [::store]
:<- [::palette]
(fn [[tl store palette] _]
(when tl (timeline/resolver tl store palette))))
(fn [[document tl store palette] _]
(when tl (clip/resolver (assoc-in document [:timelines :main] tl)
store palette))))

View file

@ -72,6 +72,8 @@
;; the model serialises and a proof nobody can run is not one.
[:button {:disabled busy? :on-click #(rf/dispatch [::project/save])} "save"]
[:button {:disabled busy? :on-click #(rf/dispatch [::project/open])} "open"]
[:button {:disabled busy? :on-click #(rf/dispatch [::project/load-stage])}
"stage 8625"]
[:span.gap]
(doall
(for [r db/rates]