sound stuff

This commit is contained in:
Olive Vaughn 2026-09-30 03:31:19 -04:00
parent 309c47e0a7
commit ae03b61dca
22 changed files with 487 additions and 105 deletions

View file

@ -55,26 +55,36 @@
(js/URL.createObjectURL
(js/Blob. #js [(wav-bytes buffer)] #js {:type "audio/wav"})))
(defn- source! [footage-id]
(-> (js/fetch (str "/api/footage/" footage-id))
(defn- fetch-ok! [url what]
(-> (js/fetch url)
(.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)}])))))))))))
(throw (ex-info (str "audio track's " what " is missing")
{:url url :status (.-status response)})))
response))))
(defn- decode-bytes! [bytes]
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes))
(defn- source!
"Promise of `[source {:buffer :fps}]` for an audio node's `:source`. Footage
counts its frames at its own rate; a sound file has no frames of its own, so
its `:fps` is nil and it counts in the document's."
[{:keys [footage sound] :as source}]
(if sound
(-> (fetch-ok! (str "/api/sounds/" sound) "sound")
(.then #(.json %))
(.then #(fetch-ok! (.-audio %) "blob"))
(.then #(.arrayBuffer %))
(.then decode-bytes!)
(.then (fn [buffer] [source {:buffer buffer}])))
(-> (fetch-ok! (str "/api/footage/" footage) "footage")
(.then #(.json %))
(.then (fn [^js manifest]
(-> (fetch-ok! (.-audio manifest) "blob")
(.then #(.arrayBuffer %))
(.then decode-bytes!)
(.then (fn [buffer] [source {:buffer buffer :fps (.-fps manifest)}]))))))))
(defn- automate! [^js param channel start end fps factor default store]
(let [channel (or channel (ch/framed default))]
@ -107,7 +117,7 @@
(let [[start end] (or (node/placed-span track) [0 frames])
start (max 0 start)
end (min frames end)
{:keys [buffer fps]} (get sources (get-in track [:source :footage]))
{:keys [buffer fps] :or {fps (:fps document)}} (get sources (:source track))
sound (.createBufferSource output)
gain (.createGain output)
pan (.createStereoPanner output)]
@ -143,7 +153,7 @@
(if (empty? tracks)
(js/Promise.resolve nil)
(-> (js/Promise.all
(into-array (map source! (distinct (map #(get-in % [:source :footage]) tracks)))))
(into-array (map source! (distinct (map :source tracks)))))
(.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store))))))))
(defn decode!
@ -156,8 +166,7 @@
(throw (ex-info "the clip's audio did not load"
{:url url :status (.-status response)})))
(.arrayBuffer response)))
(.then (fn [bytes]
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes)))))
(.then decode-bytes!)))
(defn mix!
"Promise of a mixed WAV URL for symbol `sid`, or the original URL when it has

View file

@ -307,6 +307,27 @@
:value (if point (mapv - point middle) [0 0])}
[:xform :anchor] {:animated? false :value middle}}})))))
(defn place-sound
"An audio node playing `source` — `{:sound id}`, an uploaded file, or
`{:footage id}`, a video's own sound — inside symbol `host` from `frame` of it.
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]
(let [end (frames clip host)]
(if (or (nil? end) (nil? frame) (neg? frame) (>= frame end))
clip
(update-symbol
clip host assoc-in [:nodes uuid]
{:id uuid
:name label
:kind :audio
:parent nil
:z (str "z" (js/Date.now) "-sound")
:source source
:span [0 (max 1 length)]
:time {:mode :map :at frame :rate rate}}))))
(defn fresh-id
"The first `:symbol-N` the clip does not already hold. Readable because an id
shows up in saved leaf paths, and deterministic because this namespace is pure."

View file

@ -46,7 +46,9 @@
(let [base (into #{[:vis]} xform-paths)]
{:group base
:instance base
:audio (into base [[:audio :gain] [:audio :pan] [:audio :rate]])
;; A sound is not in the picture: no transform, no visibility. After
;; Effects' audio-only layer has no Transform group for the same reason.
:audio #{[: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.
@ -65,10 +67,19 @@
[:xform :anchor] (ch/framed [0.0 0.0])
[:vis] (ch/framed true)})
(def audio-defaults
"Unity gain, centred, at its own speed."
{[:audio :gain] (ch/framed 1.0)
[:audio :pan] (ch/framed 0.0)
[:audio :rate] (ch/framed 1.0)})
(defn defaults-of [n]
(if (= :audio (:kind n)) audio-defaults defaults))
(defn channels
"The node's channels with the transform defaults filled in."
"The node's channels with its kind's defaults filled in."
[n]
(merge defaults (:channels n)))
(merge (defaults-of n) (:channels n)))
(defn set-channel
"Write `v` into channel `path`: a key on the node's own frame `f` when the
@ -342,8 +353,8 @@
(conj (str ":kind " k " is in the vocabulary but not implemented"))
(and (= k :instance) (nil? (:of n))) (conj "an instance needs :of")
(and (= k :audio) (nil? (get-in n [:source :footage])))
(conj "an audio instance needs :source :footage")
(and (= k :audio) (not (some (:source n) [:footage :sound])))
(conj "an audio node needs a :source :footage or :sound")
(and (some? (get-in n [:time :rate]))
(not (pos? (get-in n [:time :rate]))))
(conj ":time :rate must be positive")

View file

@ -302,7 +302,6 @@
(case (:kind n)
:group nil
:instance nil
:audio nil
:poly
(let [pts (rd [:geom :pts])]
@ -342,14 +341,17 @@
not an error, it is `(:nodes clip)` being nil and a frame resolving to no ops at
all. That reads as a black stage, or, in a benchmark, as \"0 nodes\" and a
flattering number. It happened once while the split was being made, which is why
this is a guard and not a comment."
this is a guard and not a comment.
Sounds are left out: they are heard, not drawn, and have no transform to
evaluate. `audio/mix` is what plays them."
[sym]
(let [nodes (:nodes sym)]
(when-not (map? nodes)
(throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes)
" — a clip is not a symbol, its `:symbols` hold them")
{:keys (vec (sort-by str (keys sym)))})))
nodes))
(into {} (remove #(= :audio (:kind (val %)))) nodes)))
(defn- channel-frame
"A trace selects the measured frames its node reads; marked channels read

View file

@ -310,8 +310,10 @@
(rf/reg-fx
::list!
(fn [_]
(-> (ingest/available!)
(.then (fn [footage] (rf/dispatch [::listed footage])))
(-> (js/Promise.all #js [(ingest/available!)
(.then (http/GET "/api/sounds")
#(:sounds (js->clj % :keywordize-keys true)))])
(.then (fn [[footage sounds]] (rf/dispatch [::listed footage sounds])))
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
@ -341,24 +343,38 @@
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
(rf/reg-fx
::upload-sound!
(fn [file]
(let [form (js/FormData.)]
(.append form "file" file)
(-> (http/POST-form "/api/sounds" form)
(.then (fn [^js sound] (rf/dispatch [::uploaded (.-id sound) "sound imported"])))
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))])))))))
(rf/reg-event-fx
::upload
(fn [{:keys [db]} [_ file]]
(if (or (nil? file) (get-in db [:footage :loading?]))
{}
{:db (update db :footage merge {:loading? true :status "uploading video…"})
::upload! file})))
(fn [{:keys [db]} [_ ^js file]]
;; By type, and by name for a browser that leaves the type empty.
(let [sound? (and file (or (string/starts-with? (.-type file) "audio/")
(re-find #"(?i)\.(mp3|wav|aiff?|flac|ogg|m4a|aac)$" (.-name file))))]
(if (or (nil? file) (get-in db [:footage :loading?]))
{}
{:db (update db :footage merge {:loading? true
:status (if sound? "uploading sound…" "uploading video…")})
(if sound? ::upload-sound! ::upload!) file}))))
(rf/reg-event-fx
::uploaded
(fn [{:keys [db]} [_ footage-id]]
(fn [{:keys [db]} [_ id status]]
;; Into the pool, and no further. An upload is media for this project; turning
;; it into a symbol is a separate decision — which frames, what name — made by
;; dropping it where it should go.
;; it into a symbol or a sound is a separate decision — which frames, what
;; name, where — made by dropping it where it should go.
{:db (update db :footage #(-> %
(merge {:loading? false :chosen footage-id
:status "video extracted"})
(update :uploaded (fnil conj #{}) footage-id)))
(merge {:loading? false :status (or status "video extracted")})
(cond-> (not status) (assoc :chosen id))
(update :uploaded (fnil conj #{}) id)))
:dispatch [::refresh]}))
(rf/reg-event-fx
@ -367,9 +383,10 @@
(rf/reg-event-db
::listed
(fn [db [_ footage]]
(fn [db [_ footage sounds]]
(update db :footage merge
(cond-> {:available (vec footage)
:sounds (vec sounds)
:chosen (or (:chosen (:footage db)) (:id (first footage)))}
(empty? footage) (assoc :status "upload a video to begin")))))

View file

@ -16,13 +16,16 @@
(rf/reg-event-db
::select
;; The rows above a selection are opened, so one made deep on the stage is
;; seen in the timeline.
;; seen in the timeline. Not a sound's: its row is always in the audio section,
;; and opening the placement it is heard through would bury it.
(fn [db [_ selection]]
(let [[kind _ _ path] selection]
(let [[kind sid id path] selection
sound? (= :audio (get-in (store/entry (:clip/current db))
[:clip :symbols sid :nodes id :kind]))]
(cond-> (-> db
(assoc-in [:ui :selection] selection)
(update :ui dissoc :points))
(and (= :node kind) path)
(and (= :node kind) path (not sound?))
(update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path))))))))
(rf/reg-event-db
@ -174,6 +177,16 @@
host sid frame uuid point))
(assoc-in [:ui :selection] [:node host uuid [uuid]])))))
(rf/reg-event-db
::drop-sound
(fn [db [_ {:keys [source label length rate]} frame]]
(let [uuid (random-uuid)
host (get-in db [:ui :open])]
(-> db
(update :ui dissoc :drop)
(edit/edit #(clip/place-sound % host source label length rate frame uuid))
(assoc-in [:ui :selection] [:node host uuid [uuid]])))))
;; ---------------------------------------------------------------------------
;; moving rows between symbols
;;
@ -195,7 +208,8 @@
(-> db
(edit/edit (constantly (:clip r)))
(assoc-in [:ui :selection] [:node (:sid r) (:id r) (conj to (:id r))])
(update-in [:ui :expanded] into (rest (reductions conj [] to))))))))
(cond-> (not= :audio (get-in r [:clip :symbols (:sid r) :nodes (:id r) :kind]))
(update-in [:ui :expanded] into (rest (reductions conj [] to)))))))))
(rf/reg-event-db
::sliding

View file

@ -43,6 +43,17 @@
(when c (gesture/apply-values c sid id frame values)))
c))))
(rf/reg-sub
::sounds
:<- [::clip-id]
:<- [::paint-revision]
:<- [::open]
(fn [[id _ sid] _]
;; What the open symbol plays, off the document as saved rather than `::clip`,
;; so a bar being slid does not re-mix on every frame of the drag.
(let [c (:clip (footage/entry id))]
[sid (when (clip/symbol c sid) (nest/audio-tracks c sid))])))
(rf/reg-sub
::symbol
:<- [::clip]

View file

@ -86,3 +86,19 @@
:when f]
f))]
(filterv #(contains? used (:id %)) available))))
(rf/reg-sub
::project-sounds
:<- [::render/clip-id]
:<- [::render/paint-revision]
:<- [::playback/footage]
(fn [[id _ {:keys [sounds uploaded]}] _]
;; As `::project-footage`: the sounds this document plays, and those uploaded
;; while it was open.
(let [used (into (set uploaded)
(for [[_ sym] (get-in (store/entry id) [:clip :symbols])
[_ n] (:nodes sym)
:let [s (get-in n [:source :sound])]
:when s]
s))]
(filterv #(contains? used (:id %)) sounds))))

View file

@ -52,19 +52,24 @@
out of the pool, and not one that would make a cycle."
[]
(let [c @carrying]
(and c (#{:symbol :footage :import} (:kind c)) (not (:refused? c)))))
(and c (#{:symbol :footage :import :sound} (:kind c)) (not (:refused? c)))))
(defn row!
"Start carrying the timeline row at `path` — a node, to be moved into another
symbol or grouped with another node."
[path]
(reset! carrying {:kind :row :path path}))
"Start carrying the timeline row at `path` — a node of kind `node-kind`, to be
moved into another symbol or grouped with another node."
[path node-kind]
(reset! carrying {:kind :row :path path :node-kind node-kind}))
(defn row
"The path of the row being carried, or nil when it is not a row."
[]
(let [c @carrying] (when (= :row (:kind c)) (:path c))))
(defn row-kind
"The node kind of the row being carried."
[]
(:node-kind @carrying))
(defn other!
"Start carrying something that is not yet in the document: `:kind` says what,
and the rest is what a preview can show of it before it is fetched."
@ -81,9 +86,10 @@
"Say where the drag would land, for the previews. `point` is nil over the
timeline."
[where frame point]
(when-let [{:keys [label frames]} @carrying]
(when-let [{:keys [kind label frames]} @carrying]
(rf/dispatch [::ui/drop-hover {:where where :frame frame :point point
:label label :frames frames}])))
:label label :frames frames
:sound? (= :sound kind)}])))
(defn pos-for
"Where the preview goes so the symbol's middle is under `point` — what
@ -102,5 +108,7 @@
;; the symbol they become is called.
:footage (rf/dispatch [::footage/ask-convert c frame point])
:import (rf/dispatch [::project/import c frame point])
;; Where it is dropped in time; a sound has no place in space.
:sound (rf/dispatch [::ui/drop-sound c frame])
nil))
(done!))

View file

@ -229,7 +229,7 @@
^{:key (str path)}
[:<>
[:dt (str/join " " (map name path))]
(if (and (contains? node/defaults path) (not (:dense ch)))
(if (and (contains? (node/defaults-of n) path) (not (:dense ch)))
[channel-control sid id path ch frame]
[:dd (channel-state ch)])]))])]))

View file

@ -13,6 +13,9 @@
one of another's, and `footage:<id>` for video. The stage and the timeline are
where they are dropped.
A SOUND — mp3, wav — is `sound:<id>`, and is dropped on the timeline, where it
becomes an audio node in the open symbol from the frame it lands on.
THE WHOLE PANE IS THE DROP TARGET for a file. A video dropped anywhere in it
uploads and lands in THIS PROJECT's media, and goes no further: which frames of
it become a symbol, and what that symbol is called, is asked when it is dropped
@ -78,6 +81,21 @@
#(drag/other! {:kind :footage :id id :label label
:frames frames :fps fps :video video})))])
(defn- sound-row
"An uploaded sound, or with `:footage?` a video's own — which is how a take's
sound goes back into its symbol after it was deleted there."
[{:keys [id label duration footage? fps]} project-fps]
(let [frames (js/Math.ceil (* duration project-fps))
[source length rate] (if footage?
[{:footage id} (js/Math.round (* duration fps)) (/ fps project-fps)]
[{:sound id} frames 1])]
[item (merge {:label label
:sub (str (.toFixed duration 1) "s · " frames "f")
:thumb [:span.thumb.sound "♪"]}
(carrying (str "sound:" id)
#(drag/other! {:kind :sound :source source :label label
:length length :rate rate :frames frames})))]))
(defn- folder [title & children]
(into [:details.pool-folder {:open true} [:summary title]] children))
@ -90,6 +108,12 @@
selection @(rf/subscribe [::sub/selection])
open @(rf/subscribe [::render/open])
media @(rf/subscribe [::sub/project-footage])
;; The project's videos' sounds first, then its uploaded ones.
sounds (into (mapv (fn [{:keys [id label frames fps]}]
{:id id :label label :footage? true :fps fps
:duration (/ frames fps)})
media)
@(rf/subscribe [::sub/project-sounds]))
{:keys [chosen]} @(rf/subscribe [::playback/footage])]
(folder "this project"
(group "symbols"
@ -109,10 +133,15 @@
(group "media"
(if (empty? media)
[:div.dim "drop a video here"]
(doall (for [f media] ^{:key (:id f)} [footage-row f chosen])))))))
(doall (for [f media] ^{:key (:id f)} [footage-row f chosen]))))
(group "sounds"
(if (empty? sounds)
[:div.dim "drop an mp3 or wav here"]
(doall (for [s sounds] ^{:key (:id s)} [sound-row s (:fps document)])))))))
(defn- all-assets []
(let [{:keys [available chosen]} @(rf/subscribe [::playback/footage])
(let [{:keys [available chosen sounds]} @(rf/subscribe [::playback/footage])
fps (:fps @(rf/subscribe [::render/clip]))
{:keys [symbols]} @(rf/subscribe [::project/assets])
{:keys [id]} @(rf/subscribe [::playback/project])]
(folder "all assets"
@ -120,6 +149,10 @@
(if (empty? available)
[:div.dim "nothing uploaded yet"]
(doall (for [f available] ^{:key (:id f)} [footage-row f chosen]))))
(group "sounds"
(if (empty? sounds)
[:div.dim "no sounds uploaded yet"]
(doall (for [s sounds] ^{:key (:id s)} [sound-row s fps]))))
(group "symbols"
(let [others (remove #(= id (:project %)) symbols)]
(if (empty? others)
@ -165,11 +198,11 @@
[:div.pane-head
"media pool"
[:span.spacer]
[:button {:title "add a video"
[:button {:title "add a video or a sound"
:disabled loading?
:on-click #(.click (js/document.getElementById "pool-file"))}
"+"]]
[:input {:id "pool-file" :type "file" :accept "video/*"
[:input {:id "pool-file" :type "file" :accept "video/*,audio/*"
:style {:display "none"}
:on-change (fn [^js event]
(when-let [file (aget (.. event -target -files) 0)]

View file

@ -9,6 +9,7 @@
[arthur.ui.convert :as convert]
[arthur.events.playback :as pb]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.ui.palette :as palette]
[arthur.ui.params :as params]
[arthur.ui.pool :as pool]
@ -16,19 +17,31 @@
[arthur.ui.tabs :as tabs]
[arthur.ui.timeline :as timeline]
[arthur.ui.topbar :as topbar]
[re-frame.core :as rf]))
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- audio []
[:audio
{:ref #(when % (clock/attach! %))
:src @(rf/subscribe [::playback/audio])
:preload "auto"
;; Transport state follows the ELEMENT, not the other way round: the audio is
;; the clock, so anything that can change its state — the end of the file, the
;; OS media keys, a browser autoplay block — has to be able to correct the
;; document rather than be contradicted by it.
:on-play #(rf/dispatch [::pb/play])
:on-pause #(rf/dispatch [::pb/pause])}])
;; An edit to what the open symbol plays — a sound dropped, moved, turned
;; down, undone — re-mixes the clock. Opening a symbol mixes on its own, so
;; only a change under the same one counts.
(r/with-let [heard (atom nil)
remix (r/track! (fn []
(let [[sid :as now] @(rf/subscribe [::render/sounds])
[was :as before] @heard]
(reset! heard now)
(when (and before (= sid was) (not= now before))
(rf/dispatch [::pb/refresh-clock])))))]
[:audio
{:ref #(when % (clock/attach! %))
:src @(rf/subscribe [::playback/audio])
:preload "auto"
;; Transport state follows the ELEMENT, not the other way round: the audio is
;; the clock, so anything that can change its state — the end of the file, the
;; OS media keys, a browser autoplay block — has to be able to correct the
;; document rather than be contradicted by it.
:on-play #(rf/dispatch [::pb/play])
:on-pause #(rf/dispatch [::pb/pause])}]
(finally (r/dispose! remix))))
(defn view []
[:div.app

View file

@ -1,5 +1,6 @@
(ns arthur.ui.timeline
"The bottom pane: the transport, a ruler, and a row per node of the open symbol.
"The bottom pane: the transport, a ruler, and a row per node of the open symbol
— the picture's, then, under an `audio` heading, every sound it plays.
`rows` is the whole of the interesting part and it is a PURE function of the
clip, the open symbol and the set of open paths. It flattens the document's two
@ -87,7 +88,10 @@
;; everywhere. `:z` is the lexicographic draw key;
;; the id breaks ties so the order is stable.
(sort-by (fn [[id n]] [(or (:z n) "") (str id)]))
reverse)]
reverse
;; Sounds are listed below the picture, by
;; `sound-rows`, wherever they are.
(remove #(= :audio (:kind (val %)))))]
(into []
(mapcat
(fn [[id n]]
@ -133,6 +137,50 @@
(walk sid [] 0 identity)
[])))
(defn sound-rows
"Every sound symbol `sid` plays, one row each, below the picture as an
editor's audio tracks are: its own, and those inside what it places at any
depth — each TIED to the placement it is heard through, `:via`, and drawn
where it is heard, cut to that placement's span as the mix cuts it."
[clip sid expanded]
(letfn [(walk [sid path ->open [lo hi] via]
(mapcat
(fn [[id n]]
(let [rpath (conj path id)
self (comp ->open (local->parent n))
[a b] (mapv ->open (or (node/placed-span
(cond-> n
(and (= :instance (:kind n)) (nil? (:span n)))
(assoc :span [0 (get-in clip [:symbols (:of n) :frames])])))
[0 (get-in clip [:symbols sid :frames])]))
span [(max lo a) (min hi b)]
open? (contains? expanded rpath)]
(case (:kind n)
:audio (cons {:path rpath
:depth 0
:label (node-label id n)
:kind :node
:node-kind :audio
:via via
;; A sound heard through a placement is
;; that placement's picture's sound: its bar
;; moves the placement, so the two stay in
;; sync. Moving the placement moves it too.
:slides (if via (subvec rpath 0 1) rpath)
:select [:node sid id rpath]
:expandable? true
:expanded? open?
:span span
:keys (into [] (comp (mapcat keyed-frames) (map self) (distinct))
(vals (node/channels n)))}
(when open? (channel-rows n rpath 1 self span)))
:instance (walk (:of n) rpath self span (or via (node-label id n)))
nil)))
(sort-by (fn [[id n]] [(or (:z n) "") (str id)]) (get-in clip [:symbols sid :nodes]))))]
(if (get-in clip [:symbols sid])
(vec (walk sid [] identity [##-Inf ##Inf] nil))
[])))
;; ---------------------------------------------------------------------------
;; geometry
;;
@ -189,19 +237,26 @@
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]]))
(defn- takes?
"Whether the row at `target` can take the row being carried: not itself, and
not anything inside it."
[target]
"Whether the row at `target`, a `target-kind` node, can take the row being
carried: not itself, and not anything inside it. A sound goes into a
placement or beside another sound, and only a sound goes beside a sound."
[target target-kind]
(when-let [from (drag/row)]
(not= from (subvec target 0 (min (count from) (count target))))))
(and (not= from (subvec target 0 (min (count from) (count target))))
(if (= :audio (drag/row-kind))
(#{:audio :instance} target-kind)
(not= :audio target-kind)))))
(defn- zone
"Which part of a row the pointer is over: its top edge, to go in front of it;
its bottom edge, to go behind; its middle, to go into it or be grouped with it."
[^js e]
its bottom edge, to go behind; its middle, to go into it or be grouped with it.
Nothing goes into a sound, so its middle is behind it."
[^js e node-kind]
(let [box (.getBoundingClientRect (.-currentTarget e))
y (/ (- (.-clientY e) (.-top box)) (max 1 (.-height box)))]
(cond (< y 0.3) :front (> y 0.7) :back :else :into)))
(cond (< y 0.3) :front
(or (> y 0.7) (= :audio node-kind)) :back
:else :into)))
(def ^:private reveal
"A ref that scrolls the selected row into view, ONE per selection: React calls
@ -210,7 +265,7 @@
on every render after, which would fight a person scrolling away."
(memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"}))))))
(defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded? of]}
(defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded? of via]}
selection over solo]
(let [node? (= :node kind)
[over-path where] @over]
@ -237,21 +292,21 @@
(.stopPropagation e)
(.setData (.-dataTransfer e) "text/plain" "row")
(set! (.. e -dataTransfer -effectAllowed) "move")
(drag/row! path))
(drag/row! path node-kind))
:on-drag-end (fn [_] (reset! over nil) (drag/done!))
:on-drag-enter (fn [^js e] (when (takes? path) (.preventDefault e)))
:on-drag-enter (fn [^js e] (when (takes? path node-kind) (.preventDefault e)))
:on-drag-over (fn [^js e]
(when (takes? path)
(when (takes? path node-kind)
(.preventDefault e)
(.stopPropagation e)
(set! (.. e -dataTransfer -dropEffect) "move")
(let [o [path (zone e)]]
(let [o [path (zone e node-kind)]]
(when (not= o @over) (reset! over o)))))
:on-drop (fn [^js e]
(.preventDefault e)
(.stopPropagation e)
(let [from (when (takes? path) (drag/row))
where (zone e)]
(let [from (when (takes? path node-kind) (drag/row))
where (zone e node-kind)]
(reset! over nil)
(drag/done!)
(when from
@ -267,7 +322,7 @@
(rf/dispatch [::ui/toggle-row path]))}
(when expandable? (if expanded? "▾" "▸"))]
[:span.name label]
(when node? [:span.kind (str "·" (name node-kind))])
(when node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))])
(when (= :instance node-kind)
[:button {:class (str "tl-solo" (when (contains? solo path) " on"))
:title "show only this on the stage (⇧ for more than one)"
@ -286,19 +341,19 @@
"`sliding` is the pointer's side of a bar being dragged, `{:path :x :width
:df}`. What it looks like mid-drag is `[:ui :sliding]`, which the clip every
row and the stage are drawn from already has in it."
[{:keys [path span keys dense? kind select]} frames sliding]
[{:keys [path span keys dense? kind node-kind select slides]} frames sliding]
(let [{from :path x0 :x width :width} @sliding
slide (fn [^js e]
(when (= path from)
(let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width)))]
(when (not= df (:df @sliding))
(swap! sliding assoc :df df)
(rf/dispatch [::ui/sliding path df])))))
(rf/dispatch [::ui/sliding (or slides path) df])))))
done (fn [commit?]
(when (= path from)
(let [df (:df @sliding)]
(reset! sliding nil)
(rf/dispatch (if commit? [::ui/slide path df] [::ui/sliding nil])))))]
(rf/dispatch (if commit? [::ui/slide (or slides path) df] [::ui/sliding nil])))))]
[:div.tl-track
;; The track, not the bar, holds the pointer while a bar slides, so the drag
;; goes on when the bar has slid off the ruler and is no longer drawn.
@ -310,6 +365,7 @@
(when-let [[in out] (when span [(max 0 (first span)) (min frames (second span))])]
(when (< in out)
[:div {:class (str "tl-span" (when dense? " dense") (when (= :ghost kind) " ghost")
(when (= :audio node-kind) " sound")
(when select " movable") (when (= path from) " sliding"))
:style {:left (edge% in frames)
:width (str (* 100 (/ (- out in) (max 1 frames))) "%")}
@ -345,14 +401,24 @@
expanded @(rf/subscribe [::sub/expanded])
drop @(rf/subscribe [::sub/drop])
solo (set @(rf/subscribe [::render/solo]))
open @(rf/subscribe [::render/open])
;; Where a drag out of the pool would land, as a row of its own at the
;; top: its own length, starting on the frame it would start on. The
;; stage's drop shows it too, at the playhead.
visible (cond->> (rows clip @(rf/subscribe [::render/open]) expanded)
drop (cons {:path [::drop] :depth 0 :kind :ghost
:label (str "+ " (:label drop))
:span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))]
:keys []}))
;; top of its section: its own length, starting on the frame it would
;; start on. The stage's drop shows it too, at the playhead.
ghost (when drop
{:path [::drop] :depth 0 :kind :ghost
:label (str "+ " (:label drop))
:span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))]
:keys []})
picture (cond->> (rows clip open expanded)
(and ghost (not (:sound? drop))) (cons ghost))
sounds (cond->> (sound-rows clip open expanded)
(and ghost (:sound? drop)) (cons ghost))
;; The audio section's heading is a row like the others, so the two
;; columns stay aligned without measuring anything.
visible (cond-> (vec picture)
(seq sounds) (-> (conj {:path [::sounds] :kind :section :label "audio"})
(into sounds)))
;; Roughly ten labels, on a round number of frames.
step (* 10 (js/Math.ceil (/ frames 100)))]
[:section.pane.time
@ -373,7 +439,10 @@
(rf/dispatch [::ui/move-node from []]))))}
[:div.tl-corner]
(doall (for [row visible]
^{:key (str (:path row))} [label-cell row selection over solo]))]
(with-meta (if (= :section (:kind row))
[:div.tl-label.tl-section (:label row)]
[label-cell row selection over solo])
{:key (str (:path row))})))]
[:div.tl-tracks
{:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e)))
:on-drag-over (fn [^js e]
@ -419,6 +488,9 @@
[:div.tl-knob {:style {:left (at% frame frames)}}]]
(if (seq visible)
(doall (for [row visible]
^{:key (str (:path row))} [track-cell row frames sliding]))
(with-meta (if (= :section (:kind row))
[:div.tl-track.tl-section]
[track-cell row frames sliding])
{:key (str (:path row))})))
[:div.tl-empty "nothing in this symbol"])
[:div.tl-playhead {:style {:left (at% frame frames)}}]]]])))

View file

@ -238,3 +238,26 @@
"and nothing else is renumbered")
(is (:refused (nest/restack c :main [:a] [a-uuid :inner] true))
"only among the things it is beside")))
(deftest a-sound-dropped-in-a-placed-symbol-is-heard-where-the-placement-puts-it
(let [u #uuid "00000000-0000-4000-8000-0000000000a1"
c (-> (nested)
(clip/place-sound :inner {:sound "tone"} "tone.mp3" 40 1 2 u))
sound (get-in c [:symbols :inner :nodes u])
[heard] (nest/audio-tracks c :outer)]
(is (empty? (node/problems sound)) "a sound needs no transform")
(is (= {:sound "tone"} (:source sound)))
(is (= #{[:audio :gain] [:audio :pan] [:audio :rate]} (set (keys (node/channels sound)))))
(is (= [0 8] (:span heard))
"own frames 0-8: it starts on inner's 2 and inner ends on 10")
(is (= [7 15] (node/placed-span heard)) "inner starts on 5 of outer")
(is (empty? ((clip/resolver c nil pal/index-of :inner) 3))
"and it draws nothing")
(is (= c (clip/place-sound c :inner {:sound "tone"} "tone.mp3" 40 1 10 (random-uuid)))
"nor lands past the end of its symbol")
(testing "a video's own sound keeps its length at another rate"
(let [v (-> (nested)
(clip/place-sound :outer {:footage "f"} "take" 90 2.5 0 u)
(get-in [:symbols :outer :nodes u]))]
(is (empty? (node/problems v)))
(is (= [0 36] (node/placed-span v)) "90 frames at 30 are 36 at 12")))))

View file

@ -157,8 +157,8 @@
(deftest skew-and-anchor-are-in-the-shape-although-nothing-drives-them
;; A decomposition is not extensible after the fact: adding a component later
;; means migrating every stored transform. So both are present from the start,
;; on every kind.
(doseq [k node/implemented-kinds]
;; on every kind that is in the picture.
(doseq [k (disj node/implemented-kinds :audio)]
(is (contains? (get node/valid-paths k) [:xform :skew]) (str k))
(is (contains? (get node/valid-paths k) [:xform :anchor]) (str k))))
@ -168,7 +168,13 @@
(is (contains? (:disc node/valid-paths) [:geom :radius]))
(is (contains? (:rect node/valid-paths) [:geom :size]))
(is (not (contains? (:group node/valid-paths) [:geom :pts]))
"a group is a pure transform node"))
"a group is a pure transform node")
(is (= #{[:audio :gain] [:audio :pan] [:audio :rate]} (:audio node/valid-paths))
"a sound has no transform"))
(deftest a-sound-defaults-to-its-own-channels
(is (= (set (keys node/audio-defaults))
(set (keys (node/channels {:id :s :kind :audio}))))))
(deftest problems-names-the-ways-a-node-is-malformed
(is (empty? (node/problems {:id :x :kind :group :z "a1"})))