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

@ -161,6 +161,18 @@ def _extract_stills(job, proxy_path, frames_dir, frames, root):
MAX_RATE = 120 # a capture rate; past this the container is describing something else MAX_RATE = 120 # a capture rate; past this the container is describing something else
def probe_audio(path):
"""The length of an uploaded sound in seconds, refusing a file with no audio."""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
"-show_format", "-of", "json", str(path)]))
if not any(s.get("codec_type") == "audio" for s in data.get("streams", [])):
raise ValueError("the uploaded file has no audio stream")
duration = float(data.get("format", {}).get("duration") or 0)
if duration <= 0:
raise ValueError("the sound's length is unknown")
return duration
def probe(path): def probe(path):
"""What the upload is, as far as choosing a proxy rate goes. """What the upload is, as far as choosing a proxy rate goes.

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2.17 on 2026-09-30 07:14
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0009_revision_blocks'),
]
operations = [
migrations.CreateModel(
name='Sound',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('filename', models.CharField(max_length=255)),
('duration', models.FloatField(help_text='seconds, as ffprobe reports it')),
('created', models.DateTimeField(auto_now_add=True)),
('blob', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sound_for', to='clips.blob')),
],
),
]

View file

@ -52,6 +52,18 @@ class Source(models.Model):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
class Sound(models.Model):
"""An uploaded sound file — mp3, wav, whatever the browser can decode — kept
as uploaded. Not footage: it has no frames and nothing measures it, so it
skips extraction and an audio node plays its bytes directly."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_for")
filename = models.CharField(max_length=255)
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
created = models.DateTimeField(auto_now_add=True)
class Extraction(models.Model): class Extraction(models.Model):
"""One requested decode of a source into immutable footage.""" """One requested decode of a source into immutable footage."""

View file

@ -34,7 +34,7 @@ from django.core.management import call_command
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from clips import blobs, extraction from clips import blobs, extraction
from clips.models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision, Source from clips.models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision, Sound, Source
BLOB_DIR = tempfile.mkdtemp(prefix="arthur-test-blobs-") BLOB_DIR = tempfile.mkdtemp(prefix="arthur-test-blobs-")
@ -721,6 +721,36 @@ class UploadTests(TestCase):
self.assertEqual(27, job.progress) self.assertEqual(27, job.progress)
job.save.assert_called_once_with(update_fields=["progress", "updated"]) job.save.assert_called_once_with(update_fields=["progress", "updated"])
def test_an_uploaded_mp3_is_a_sound_and_not_footage(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "tone.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1.5", str(path),
], check=True, capture_output=True)
payload = path.read_bytes()
uploaded = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("tone.mp3", payload, content_type="audio/mpeg")})
self.assertEqual(201, uploaded.status_code, uploaded.content)
sound = uploaded.json()
self.assertEqual("tone.mp3", sound["label"])
self.assertAlmostEqual(1.5, sound["duration"], delta=0.1)
self.assertEqual(payload, b"".join(self.client.get(sound["audio"]).streaming_content))
self.assertEqual(sound, self.client.get(f"/api/sounds/{sound['id']}").json())
self.assertEqual([sound], self.client.get("/api/sounds").json()["sounds"])
self.assertEqual(0, Source.objects.count())
again = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("again.mp3", payload, content_type="audio/mpeg")})
self.assertEqual(200, again.status_code)
self.assertEqual(1, Sound.objects.count())
def test_a_file_without_audio_is_not_a_sound(self):
refused = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("notes.txt", b"not audio", content_type="text/plain")})
self.assertEqual(400, refused.status_code)
def test_uploaded_video_extracts_to_reopenable_footage(self): def test_uploaded_video_extracts_to_reopenable_footage(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "four-frames.mp4" path = Path(directory) / "four-frames.mp4"

View file

@ -22,6 +22,8 @@ urlpatterns = [
path("logout", views.logout), path("logout", views.logout),
path("detector", views.detector), path("detector", views.detector),
path("sources", views.sources), path("sources", views.sources),
path("sounds", views.sounds),
path("sounds/<uuid:sound_id>", views.sound_detail),
path("extractions", views.extractions), path("extractions", views.extractions),
path("extractions/<str:key>", views.extraction_detail), path("extractions/<str:key>", views.extraction_detail),
path("footage", views.footage_list), path("footage", views.footage_list),

View file

@ -43,7 +43,7 @@ from django.views.decorators.http import require_http_methods
from . import blobs, extraction from . import blobs, extraction
from .consumers import broadcast from .consumers import broadcast
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Source from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Sound, Source
KEY_LENGTH = 71 # "sha256:" + 64 hex KEY_LENGTH = 71 # "sha256:" + 64 hex
@ -225,6 +225,41 @@ def sources(request):
return JsonResponse({"error": str(exc)}, status=400) return JsonResponse({"error": str(exc)}, status=400)
def _sound_json(row):
return {"id": str(row.id), "label": row.filename, "duration": row.duration,
"audio": f"/blob/{row.blob_id}"}
@require_http_methods(["GET", "POST"])
def sounds(request):
if request.method == "GET":
return JsonResponse({"sounds": [_sound_json(row)
for row in Sound.objects.order_by("-created")]})
upload = request.FILES.get("file")
if upload is None:
return JsonResponse({"error": "upload a sound as the file field"}, status=400)
try:
digest, size = blobs.write_stream(upload.chunks())
duration = extraction.probe_audio(blobs.path_for(digest))
blob, _ = Blob.objects.get_or_create(
digest=digest, defaults={"size": size,
"media_type": upload.content_type or "audio/mpeg"})
row, created = Sound.objects.get_or_create(
blob=blob, defaults={"filename": Path(upload.name).name[:255],
"duration": duration})
return JsonResponse(_sound_json(row), status=201 if created else 200)
except (ValueError, OSError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
@require_http_methods(["GET"])
def sound_detail(request, sound_id):
try:
return JsonResponse(_sound_json(Sound.objects.get(id=sound_id)))
except Sound.DoesNotExist:
return JsonResponse({"error": "no such sound"}, status=404)
def _extraction_json(row): def _extraction_json(row):
return {"key": row.key, "source": str(row.source_id), "state": row.state, return {"key": row.key, "source": str(row.source_id), "state": row.state,
"progress": row.progress, "error": row.error, "progress": row.progress, "error": row.error,

View file

@ -55,26 +55,36 @@
(js/URL.createObjectURL (js/URL.createObjectURL
(js/Blob. #js [(wav-bytes buffer)] #js {:type "audio/wav"}))) (js/Blob. #js [(wav-bytes buffer)] #js {:type "audio/wav"})))
(defn- source! [footage-id] (defn- fetch-ok! [url what]
(-> (js/fetch (str "/api/footage/" footage-id)) (-> (js/fetch url)
(.then (fn [response] (.then (fn [response]
(when-not (.-ok response) (when-not (.-ok response)
(throw (ex-info "audio track's footage is missing" (throw (ex-info (str "audio track's " what " is missing")
{:footage footage-id :status (.-status response)}))) {:url url :status (.-status response)})))
(.json 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] (.then (fn [^js manifest]
(-> (js/fetch (.-audio manifest)) (-> (fetch-ok! (.-audio manifest) "blob")
(.then (fn [response] (.then #(.arrayBuffer %))
(when-not (.-ok response) (.then decode-bytes!)
(throw (ex-info "audio track's blob is missing" (.then (fn [buffer] [source {:buffer buffer :fps (.-fps manifest)}]))))))))
{: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] (defn- automate! [^js param channel start end fps factor default store]
(let [channel (or channel (ch/framed default))] (let [channel (or channel (ch/framed default))]
@ -107,7 +117,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]} (get sources (get-in track [:source :footage])) {:keys [buffer fps] :or {fps (:fps document)}} (get sources (:source track))
sound (.createBufferSource output) sound (.createBufferSource output)
gain (.createGain output) gain (.createGain output)
pan (.createStereoPanner output)] pan (.createStereoPanner output)]
@ -143,7 +153,7 @@
(if (empty? tracks) (if (empty? tracks)
(js/Promise.resolve nil) (js/Promise.resolve nil)
(-> (js/Promise.all (-> (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)))))))) (.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store))))))))
(defn decode! (defn decode!
@ -156,8 +166,7 @@
(throw (ex-info "the clip's audio did not load" (throw (ex-info "the clip's audio did not load"
{:url url :status (.-status response)}))) {:url url :status (.-status response)})))
(.arrayBuffer response))) (.arrayBuffer response)))
(.then (fn [bytes] (.then decode-bytes!)))
(.decodeAudioData (js/OfflineAudioContext. 1 1 44100) bytes)))))
(defn mix! (defn mix!
"Promise of a mixed WAV URL for symbol `sid`, or the original URL when it has "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])} :value (if point (mapv - point middle) [0 0])}
[:xform :anchor] {:animated? false :value middle}}}))))) [: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 (defn fresh-id
"The first `:symbol-N` the clip does not already hold. Readable because an 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." 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)] (let [base (into #{[:vis]} xform-paths)]
{:group base {:group base
:instance 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]]) :poly (into base [[:geom :pts] [:style :color]])
;; A disc's radius is framed in practice — iris size is a knob, not a ;; 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. ;; 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]) [:xform :anchor] (ch/framed [0.0 0.0])
[:vis] (ch/framed true)}) [: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 (defn channels
"The node's channels with the transform defaults filled in." "The node's channels with its kind's defaults filled in."
[n] [n]
(merge defaults (:channels n))) (merge (defaults-of n) (:channels n)))
(defn set-channel (defn set-channel
"Write `v` into channel `path`: a key on the node's own frame `f` when the "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")) (conj (str ":kind " k " is in the vocabulary but not implemented"))
(and (= k :instance) (nil? (:of n))) (conj "an instance needs :of") (and (= k :instance) (nil? (:of n))) (conj "an instance needs :of")
(and (= k :audio) (nil? (get-in n [:source :footage]))) (and (= k :audio) (not (some (:source n) [:footage :sound])))
(conj "an audio instance needs :source :footage") (conj "an audio node needs a :source :footage or :sound")
(and (some? (get-in n [:time :rate])) (and (some? (get-in n [:time :rate]))
(not (pos? (get-in n [:time :rate])))) (not (pos? (get-in n [:time :rate]))))
(conj ":time :rate must be positive") (conj ":time :rate must be positive")

View file

@ -302,7 +302,6 @@
(case (:kind n) (case (:kind n)
:group nil :group nil
:instance nil :instance nil
:audio nil
:poly :poly
(let [pts (rd [:geom :pts])] (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 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 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 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] [sym]
(let [nodes (:nodes sym)] (let [nodes (:nodes sym)]
(when-not (map? nodes) (when-not (map? nodes)
(throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes) (throw (ex-info (str "not a symbol: :nodes is " (pr-str nodes)
" — a clip is not a symbol, its `:symbols` hold them") " — a clip is not a symbol, its `:symbols` hold them")
{:keys (vec (sort-by str (keys sym)))}))) {:keys (vec (sort-by str (keys sym)))})))
nodes)) (into {} (remove #(= :audio (:kind (val %)))) nodes)))
(defn- channel-frame (defn- 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

View file

@ -310,8 +310,10 @@
(rf/reg-fx (rf/reg-fx
::list! ::list!
(fn [_] (fn [_]
(-> (ingest/available!) (-> (js/Promise.all #js [(ingest/available!)
(.then (fn [footage] (rf/dispatch [::listed footage]))) (.then (http/GET "/api/sounds")
#(:sounds (js->clj % :keywordize-keys true)))])
(.then (fn [[footage sounds]] (rf/dispatch [::listed footage sounds])))
(.catch (fn [error] (.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))])))))) (rf/dispatch [::failed (or (ex-message error) (str error))]))))))
@ -341,24 +343,38 @@
(.catch (fn [error] (.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str 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 (rf/reg-event-fx
::upload ::upload
(fn [{:keys [db]} [_ 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?])) (if (or (nil? file) (get-in db [:footage :loading?]))
{} {}
{:db (update db :footage merge {:loading? true :status "uploading video…"}) {:db (update db :footage merge {:loading? true
::upload! file}))) :status (if sound? "uploading sound…" "uploading video…")})
(if sound? ::upload-sound! ::upload!) file}))))
(rf/reg-event-fx (rf/reg-event-fx
::uploaded ::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 ;; 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 ;; it into a symbol or a sound is a separate decision — which frames, what
;; dropping it where it should go. ;; name, where — made by dropping it where it should go.
{:db (update db :footage #(-> % {:db (update db :footage #(-> %
(merge {:loading? false :chosen footage-id (merge {:loading? false :status (or status "video extracted")})
:status "video extracted"}) (cond-> (not status) (assoc :chosen id))
(update :uploaded (fnil conj #{}) footage-id))) (update :uploaded (fnil conj #{}) id)))
:dispatch [::refresh]})) :dispatch [::refresh]}))
(rf/reg-event-fx (rf/reg-event-fx
@ -367,9 +383,10 @@
(rf/reg-event-db (rf/reg-event-db
::listed ::listed
(fn [db [_ footage]] (fn [db [_ footage sounds]]
(update db :footage merge (update db :footage merge
(cond-> {:available (vec footage) (cond-> {:available (vec footage)
:sounds (vec sounds)
:chosen (or (:chosen (:footage db)) (:id (first footage)))} :chosen (or (:chosen (:footage db)) (:id (first footage)))}
(empty? footage) (assoc :status "upload a video to begin"))))) (empty? footage) (assoc :status "upload a video to begin")))))

View file

@ -16,13 +16,16 @@
(rf/reg-event-db (rf/reg-event-db
::select ::select
;; The rows above a selection are opened, so one made deep on the stage is ;; 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]] (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 (cond-> (-> db
(assoc-in [:ui :selection] selection) (assoc-in [:ui :selection] selection)
(update :ui dissoc :points)) (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)))))))) (update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path))))))))
(rf/reg-event-db (rf/reg-event-db
@ -174,6 +177,16 @@
host sid frame uuid point)) host sid frame uuid point))
(assoc-in [:ui :selection] [:node host uuid [uuid]]))))) (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 ;; moving rows between symbols
;; ;;
@ -195,7 +208,8 @@
(-> db (-> db
(edit/edit (constantly (:clip r))) (edit/edit (constantly (:clip r)))
(assoc-in [:ui :selection] [:node (:sid r) (:id r) (conj to (:id 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 (rf/reg-event-db
::sliding ::sliding

View file

@ -43,6 +43,17 @@
(when c (gesture/apply-values c sid id frame values))) (when c (gesture/apply-values c sid id frame values)))
c)))) 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 (rf/reg-sub
::symbol ::symbol
:<- [::clip] :<- [::clip]

View file

@ -86,3 +86,19 @@
:when f] :when f]
f))] f))]
(filterv #(contains? used (:id %)) available)))) (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." out of the pool, and not one that would make a cycle."
[] []
(let [c @carrying] (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! (defn row!
"Start carrying the timeline row at `path` — a node, to be moved into another "Start carrying the timeline row at `path` — a node of kind `node-kind`, to be
symbol or grouped with another node." moved into another symbol or grouped with another node."
[path] [path node-kind]
(reset! carrying {:kind :row :path path})) (reset! carrying {:kind :row :path path :node-kind node-kind}))
(defn row (defn row
"The path of the row being carried, or nil when it is not a 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)))) (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! (defn other!
"Start carrying something that is not yet in the document: `:kind` says what, "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." 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 "Say where the drag would land, for the previews. `point` is nil over the
timeline." timeline."
[where frame point] [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 (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 (defn pos-for
"Where the preview goes so the symbol's middle is under `point` — what "Where the preview goes so the symbol's middle is under `point` — what
@ -102,5 +108,7 @@
;; the symbol they become is called. ;; the symbol they become is called.
:footage (rf/dispatch [::footage/ask-convert c frame point]) :footage (rf/dispatch [::footage/ask-convert c frame point])
:import (rf/dispatch [::project/import 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)) nil))
(done!)) (done!))

View file

@ -229,7 +229,7 @@
^{:key (str path)} ^{:key (str path)}
[:<> [:<>
[:dt (str/join " " (map name 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] [channel-control sid id path ch frame]
[:dd (channel-state ch)])]))])])) [: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 one of another's, and `footage:<id>` for video. The stage and the timeline are
where they are dropped. 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 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 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 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 #(drag/other! {:kind :footage :id id :label label
:frames frames :fps fps :video video})))]) :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] (defn- folder [title & children]
(into [:details.pool-folder {:open true} [:summary title]] children)) (into [:details.pool-folder {:open true} [:summary title]] children))
@ -90,6 +108,12 @@
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
open @(rf/subscribe [::render/open]) open @(rf/subscribe [::render/open])
media @(rf/subscribe [::sub/project-footage]) 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])] {:keys [chosen]} @(rf/subscribe [::playback/footage])]
(folder "this project" (folder "this project"
(group "symbols" (group "symbols"
@ -109,10 +133,15 @@
(group "media" (group "media"
(if (empty? media) (if (empty? media)
[:div.dim "drop a video here"] [: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 [] (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 [symbols]} @(rf/subscribe [::project/assets])
{:keys [id]} @(rf/subscribe [::playback/project])] {:keys [id]} @(rf/subscribe [::playback/project])]
(folder "all assets" (folder "all assets"
@ -120,6 +149,10 @@
(if (empty? available) (if (empty? available)
[:div.dim "nothing uploaded yet"] [:div.dim "nothing uploaded yet"]
(doall (for [f available] ^{:key (:id f)} [footage-row f chosen])))) (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" (group "symbols"
(let [others (remove #(= id (:project %)) symbols)] (let [others (remove #(= id (:project %)) symbols)]
(if (empty? others) (if (empty? others)
@ -165,11 +198,11 @@
[:div.pane-head [:div.pane-head
"media pool" "media pool"
[:span.spacer] [:span.spacer]
[:button {:title "add a video" [:button {:title "add a video or a sound"
:disabled loading? :disabled loading?
:on-click #(.click (js/document.getElementById "pool-file"))} :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"} :style {:display "none"}
:on-change (fn [^js event] :on-change (fn [^js event]
(when-let [file (aget (.. event -target -files) 0)] (when-let [file (aget (.. event -target -files) 0)]

View file

@ -9,6 +9,7 @@
[arthur.ui.convert :as convert] [arthur.ui.convert :as convert]
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.ui.palette :as palette] [arthur.ui.palette :as palette]
[arthur.ui.params :as params] [arthur.ui.params :as params]
[arthur.ui.pool :as pool] [arthur.ui.pool :as pool]
@ -16,9 +17,20 @@
[arthur.ui.tabs :as tabs] [arthur.ui.tabs :as tabs]
[arthur.ui.timeline :as timeline] [arthur.ui.timeline :as timeline]
[arthur.ui.topbar :as topbar] [arthur.ui.topbar :as topbar]
[re-frame.core :as rf])) [re-frame.core :as rf]
[reagent.core :as r]))
(defn- audio [] (defn- audio []
;; 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 [:audio
{:ref #(when % (clock/attach! %)) {:ref #(when % (clock/attach! %))
:src @(rf/subscribe [::playback/audio]) :src @(rf/subscribe [::playback/audio])
@ -28,7 +40,8 @@
;; OS media keys, a browser autoplay block — has to be able to correct the ;; OS media keys, a browser autoplay block — has to be able to correct the
;; document rather than be contradicted by it. ;; document rather than be contradicted by it.
:on-play #(rf/dispatch [::pb/play]) :on-play #(rf/dispatch [::pb/play])
:on-pause #(rf/dispatch [::pb/pause])}]) :on-pause #(rf/dispatch [::pb/pause])}]
(finally (r/dispose! remix))))
(defn view [] (defn view []
[:div.app [:div.app

View file

@ -1,5 +1,6 @@
(ns arthur.ui.timeline (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 `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 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; ;; everywhere. `:z` is the lexicographic draw key;
;; the id breaks ties so the order is stable. ;; the id breaks ties so the order is stable.
(sort-by (fn [[id n]] [(or (:z n) "") (str id)])) (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 [] (into []
(mapcat (mapcat
(fn [[id n]] (fn [[id n]]
@ -133,6 +137,50 @@
(walk sid [] 0 identity) (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 ;; geometry
;; ;;
@ -189,19 +237,26 @@
(when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]])) (when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]]))
(defn- takes? (defn- takes?
"Whether the row at `target` can take the row being carried: not itself, and "Whether the row at `target`, a `target-kind` node, can take the row being
not anything inside it." carried: not itself, and not anything inside it. A sound goes into a
[target] placement or beside another sound, and only a sound goes beside a sound."
[target target-kind]
(when-let [from (drag/row)] (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 (defn- zone
"Which part of a row the pointer is over: its top edge, to go in front of it; "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." its bottom edge, to go behind; its middle, to go into it or be grouped with it.
[^js e] Nothing goes into a sound, so its middle is behind it."
[^js e node-kind]
(let [box (.getBoundingClientRect (.-currentTarget e)) (let [box (.getBoundingClientRect (.-currentTarget e))
y (/ (- (.-clientY e) (.-top box)) (max 1 (.-height box)))] 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 (def ^:private reveal
"A ref that scrolls the selected row into view, ONE per selection: React calls "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." on every render after, which would fight a person scrolling away."
(memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"})))))) (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] selection over solo]
(let [node? (= :node kind) (let [node? (= :node kind)
[over-path where] @over] [over-path where] @over]
@ -237,21 +292,21 @@
(.stopPropagation e) (.stopPropagation e)
(.setData (.-dataTransfer e) "text/plain" "row") (.setData (.-dataTransfer e) "text/plain" "row")
(set! (.. e -dataTransfer -effectAllowed) "move") (set! (.. e -dataTransfer -effectAllowed) "move")
(drag/row! path)) (drag/row! path node-kind))
:on-drag-end (fn [_] (reset! over nil) (drag/done!)) :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] :on-drag-over (fn [^js e]
(when (takes? path) (when (takes? path node-kind)
(.preventDefault e) (.preventDefault e)
(.stopPropagation e) (.stopPropagation e)
(set! (.. e -dataTransfer -dropEffect) "move") (set! (.. e -dataTransfer -dropEffect) "move")
(let [o [path (zone e)]] (let [o [path (zone e node-kind)]]
(when (not= o @over) (reset! over o))))) (when (not= o @over) (reset! over o)))))
:on-drop (fn [^js e] :on-drop (fn [^js e]
(.preventDefault e) (.preventDefault e)
(.stopPropagation e) (.stopPropagation e)
(let [from (when (takes? path) (drag/row)) (let [from (when (takes? path node-kind) (drag/row))
where (zone e)] where (zone e node-kind)]
(reset! over nil) (reset! over nil)
(drag/done!) (drag/done!)
(when from (when from
@ -267,7 +322,7 @@
(rf/dispatch [::ui/toggle-row path]))} (rf/dispatch [::ui/toggle-row path]))}
(when expandable? (if expanded? "▾" "▸"))] (when expandable? (if expanded? "▾" "▸"))]
[:span.name label] [: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) (when (= :instance node-kind)
[:button {:class (str "tl-solo" (when (contains? solo path) " on")) [:button {:class (str "tl-solo" (when (contains? solo path) " on"))
:title "show only this on the stage (⇧ for more than one)" :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 "`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 :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." 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 (let [{from :path x0 :x width :width} @sliding
slide (fn [^js e] slide (fn [^js e]
(when (= path from) (when (= path from)
(let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width)))] (let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width)))]
(when (not= df (:df @sliding)) (when (not= df (:df @sliding))
(swap! sliding assoc :df df) (swap! sliding assoc :df df)
(rf/dispatch [::ui/sliding path df]))))) (rf/dispatch [::ui/sliding (or slides path) df])))))
done (fn [commit?] done (fn [commit?]
(when (= path from) (when (= path from)
(let [df (:df @sliding)] (let [df (:df @sliding)]
(reset! sliding nil) (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 [:div.tl-track
;; The track, not the bar, holds the pointer while a bar slides, so the drag ;; 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. ;; 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-let [[in out] (when span [(max 0 (first span)) (min frames (second span))])]
(when (< in out) (when (< in out)
[:div {:class (str "tl-span" (when dense? " dense") (when (= :ghost kind) " ghost") [:div {:class (str "tl-span" (when dense? " dense") (when (= :ghost kind) " ghost")
(when (= :audio node-kind) " sound")
(when select " movable") (when (= path from) " sliding")) (when select " movable") (when (= path from) " sliding"))
:style {:left (edge% in frames) :style {:left (edge% in frames)
:width (str (* 100 (/ (- out in) (max 1 frames))) "%")} :width (str (* 100 (/ (- out in) (max 1 frames))) "%")}
@ -345,14 +401,24 @@
expanded @(rf/subscribe [::sub/expanded]) expanded @(rf/subscribe [::sub/expanded])
drop @(rf/subscribe [::sub/drop]) drop @(rf/subscribe [::sub/drop])
solo (set @(rf/subscribe [::render/solo])) 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 ;; 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 ;; top of its section: its own length, starting on the frame it would
;; stage's drop shows it too, at the playhead. ;; start on. The stage's drop shows it too, at the playhead.
visible (cond->> (rows clip @(rf/subscribe [::render/open]) expanded) ghost (when drop
drop (cons {:path [::drop] :depth 0 :kind :ghost {:path [::drop] :depth 0 :kind :ghost
:label (str "+ " (:label drop)) :label (str "+ " (:label drop))
:span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))] :span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))]
:keys []})) :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. ;; Roughly ten labels, on a round number of frames.
step (* 10 (js/Math.ceil (/ frames 100)))] step (* 10 (js/Math.ceil (/ frames 100)))]
[:section.pane.time [:section.pane.time
@ -373,7 +439,10 @@
(rf/dispatch [::ui/move-node from []]))))} (rf/dispatch [::ui/move-node from []]))))}
[:div.tl-corner] [:div.tl-corner]
(doall (for [row visible] (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 [:div.tl-tracks
{:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) {:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e)))
:on-drag-over (fn [^js e] :on-drag-over (fn [^js e]
@ -419,6 +488,9 @@
[:div.tl-knob {:style {:left (at% frame frames)}}]] [:div.tl-knob {:style {:left (at% frame frames)}}]]
(if (seq visible) (if (seq visible)
(doall (for [row 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-empty "nothing in this symbol"])
[:div.tl-playhead {:style {:left (at% frame frames)}}]]]]))) [:div.tl-playhead {:style {:left (at% frame frames)}}]]]])))

View file

@ -238,3 +238,26 @@
"and nothing else is renumbered") "and nothing else is renumbered")
(is (:refused (nest/restack c :main [:a] [a-uuid :inner] true)) (is (:refused (nest/restack c :main [:a] [a-uuid :inner] true))
"only among the things it is beside"))) "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 (deftest skew-and-anchor-are-in-the-shape-although-nothing-drives-them
;; A decomposition is not extensible after the fact: adding a component later ;; A decomposition is not extensible after the fact: adding a component later
;; means migrating every stored transform. So both are present from the start, ;; means migrating every stored transform. So both are present from the start,
;; on every kind. ;; on every kind that is in the picture.
(doseq [k node/implemented-kinds] (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 :skew]) (str k))
(is (contains? (get node/valid-paths k) [:xform :anchor]) (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? (:disc node/valid-paths) [:geom :radius]))
(is (contains? (:rect node/valid-paths) [:geom :size])) (is (contains? (:rect node/valid-paths) [:geom :size]))
(is (not (contains? (:group node/valid-paths) [:geom :pts])) (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 (deftest problems-names-the-ways-a-node-is-malformed
(is (empty? (node/problems {:id :x :kind :group :z "a1"}))) (is (empty? (node/problems {:id :x :kind :group :z "a1"})))

View file

@ -684,6 +684,16 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
line-height: var(--ruler); line-height: var(--ruler);
} }
/* The heading between the picture's rows and the sounds', as an editor puts
its audio tracks under its video tracks. */
.tl-label.tl-section,
.tl-track.tl-section { background: var(--chrome); border-top: 1px solid var(--line); }
.tl-label.tl-section { padding-left: 6px; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
/* A sound's bar, told apart from the picture's at a glance. */
.tl-span.sound { background: #e3efdf; border-color: #a6c49b; }
.pool-item .thumb.sound { display: flex; align-items: center; justify-content: center; color: #d9d9d9; }
/* The span the node exists over. Drawn under the keys so a dot on the first /* The span the node exists over. Drawn under the keys so a dot on the first
frame of a span is not half-hidden by its own bar. */ frame of a span is not half-hidden by its own bar. */
.tl-span { .tl-span {