Media pool: this project and all assets

This project lists every symbol in the document and the video it uses or was
given this session, each with a frame from its proxy as a thumbnail. All
assets lists every upload on the server and every other saved project's
symbols, from a new /api/symbols that reads symbol leaf paths. Every row is a
drag source (symbol:, import:, footage:). Uploading no longer runs straight
into detection; it lands in the project's media.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:59:17 -04:00
parent c81f91c442
commit d5f044c6da
11 changed files with 270 additions and 105 deletions

View file

@ -38,6 +38,7 @@
;; ingested take — and having it before the first click is what lets the footage
;; picker be a picker rather than a path to type.
(rf/dispatch [::footage/refresh])
(rf/dispatch [::project/list-symbols])
(reset! root (rdc/create-root (js/document.getElementById "app")))
(mount)
(player/start!))

View file

@ -77,7 +77,12 @@
;; comes from the server — tier 3 is the backend's since step 9 — so there is
;; no path to type any more.
:footage {:id nil :label nil :loading? false :status nil
:available [] :chosen nil}
:available [] :chosen nil :uploaded #{}}
;; Every symbol in every saved project, for the pool's all-assets folder. Rows
;; from `/api/symbols`, nothing loaded: a symbol from elsewhere is fetched when
;; it is dropped.
:assets {:symbols [] :loading? false}
;; The document's own identity on the server. `:seq` is the monotonic project
;; version: a client that sees a delta with `seq > local + 1` refetches, which

View file

@ -336,14 +336,14 @@
(rf/reg-event-fx
::uploaded
(fn [{:keys [db]} [_ footage-id]]
;; STRAIGHT INTO DETECTION. Dropping a video on the media pool is one
;; intention — get this footage into the tool — and stopping after extraction
;; to wait for a second click only means finding the button that does the
;; obvious next thing. `::load` reads `:chosen`, which this event has just
;; written, because `:dispatch` runs after the db update.
{:db (update db :footage merge {:loading? false :chosen footage-id
:status "video extracted"})
:dispatch-n [[::refresh] [::load]]}))
;; 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.
{:db (update db :footage #(-> %
(merge {:loading? false :chosen footage-id
:status "video extracted"})
(update :uploaded (fnil conj #{}) footage-id)))
:dispatch [::refresh]}))
(rf/reg-event-fx
::refresh

View file

@ -174,6 +174,32 @@
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
(rf/reg-fx
::list-symbols!
(fn [_]
(-> (http/GET "/api/symbols")
(.then (fn [^js listed]
(rf/dispatch [::symbols-listed
(mapv (fn [^js r]
{:project (.-project r) :project-name (.-project_name r)
:cid (.-cid r) :symbol (.-symbol r)
:name (.-name r) :frames (.-frames r)})
(array-seq (.-symbols listed)))])))
(.catch (fn [error]
(rf/dispatch [::failed (or (ex-message error) (str error))]))))))
(rf/reg-event-fx
::list-symbols
(fn [{:keys [db]} _]
{:db (assoc-in db [:assets :loading?] true)
::list-symbols! nil}))
(rf/reg-event-db
::symbols-listed
(fn [db [_ rows]] (assoc db :assets {:symbols rows :loading? false})))
(rf/reg-sub ::assets (fn [db _] (:assets db)))
(rf/reg-fx
::open!
(fn [id]
@ -453,14 +479,16 @@
{:db db
::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) 0]})))
(rf/reg-event-db
(rf/reg-event-fx
::saved
(fn [db [_ id cid label seq written uploaded]]
(update db :project merge
{:id id :cid cid :name label :seq seq :busy? false
:status (str "saved r" seq " · " written
(if (= 1 written) " leaf" " leaves")
" · " uploaded (if (= 1 uploaded) " block" " blocks"))})))
(fn [{:keys [db]} [_ id cid label seq written uploaded]]
{:db (update db :project merge
{:id id :cid cid :name label :seq seq :busy? false
:status (str "saved r" seq " · " written
(if (= 1 written) " leaf" " leaves")
" · " uploaded (if (= 1 uploaded) " block" " blocks"))})
;; The all-assets folder lists saved symbols, so a save can add rows to it.
:dispatch [::list-symbols]}))
(rf/reg-event-fx
::opened

View file

@ -4,7 +4,9 @@
Cheap by construction, like `subs/playback`: each reads a path and returns a
value, so clicking a swatch notifies the swatches and nothing else."
(:require [arthur.subs.render :as render]
(:require [arthur.footage.store :as store]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[re-frame.core :as rf]))
(rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection])))
@ -27,3 +29,22 @@
(let [[_ sid id] selection]
(when-let [n (get-in clip [:symbols sid :nodes id])]
[sid id n])))))
(rf/reg-sub
::project-footage
:<- [::render/clip-id]
:<- [::render/paint-revision]
:<- [::playback/footage]
(fn [[id _ {:keys [available uploaded]}] _]
;; THIS PROJECT's video: what the document was made from, what its sounds
;; play, and what was uploaded while it was open. Read off the document rather
;; than kept beside it, so a sound placed from footage makes that footage the
;; project's without anything else being told.
(let [entry (store/entry id)
used (into (set (keep identity [(:footage-id entry)])) uploaded)
used (into used (for [[_ sym] (get-in entry [:clip :symbols])
[_ n] (:nodes sym)
:let [f (get-in n [:source :footage])]
:when f]
f))]
(filterv #(contains? used (:id %)) available))))

View file

@ -1,97 +1,133 @@
(ns arthur.ui.pool
"The media pool: the open document's own contents.
"The media pool: what can be put into the open symbol, in two folders.
TWO GROUPS, AND NEITHER IS A DOCUMENT. Whole projects are `ui/openmenu`'s, and
the split is the point: opening a project REPLACES the stage, where everything
in here is a thing to put ON it. Listing documents beside the symbols inside
one of them made them look like two kinds of the same thing, which is exactly
the confusion this pane should be ending.
THIS PROJECT is the open document's own: every symbol in it — the open one
included, because none is special — and the video it uses or was given this
session. ALL ASSETS is everything the server holds: every upload, and every
symbol of every other saved project. Whole projects are `ui/openmenu`'s, and
the split is the point: opening a project REPLACES what is on screen, where
everything in here is a thing to put INTO it.
A SYMBOL is one timeline out of the open document's library — the thing a
`:kind :instance` node is an instance of. Dragging one onto the stage places an
instance. Open a different project and this is a different list, because a
library belongs to the document that holds it.
Every row is a drag source, and what it carries says what it is:
`symbol:<id>` for a symbol of this document, `import:<project>|<cid>|<id>` for
one of another's, and `footage:<id>` for video. The stage and the timeline are
where they are dropped.
FOOTAGE is source media on the server: uploads that have been probed,
transcoded and cut into frames. Running one through detection produces a
document whose roto IS a symbol — `flow/freeze/clip` gives each tracked subject
its own symbol and places one instance of each in `:main` — so
footage becomes a row in the group above it by way of being detected.
THE WHOLE PANE IS THE DROP TARGET. A video dropped anywhere in it uploads and
then goes straight into detection, because \"upload\" and \"roto\" are one
intention and splitting them into two clicks only means finding the second
button. The uploaded bytes, the extraction job and the decoded footage stay
separate records on the server, so dropping the same file twice does not decode
it twice."
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
where it should go. The uploaded bytes, the extraction job and the decoded
footage stay separate records on the server, so dropping the same file twice
does not decode it twice."
(:require [arthur.domain.clip :as clip]
[arthur.events.footage :as footage]
[arthur.events.playback :as pb]
[arthur.events.project :as project]
[arthur.events.ui :as ui]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[clojure.string :as str]
[re-frame.core :as rf]
[reagent.core :as r]))
(defn- item
"One row. `opts` is merged last so a caller can add `:draggable` and its
handlers without this function growing a parameter per affordance."
[{:keys [label sub on? disabled?] :as opts}]
[:button (merge {:class (str "pool-item" (when on? " on"))
"One row. `opts` is merged last so a caller can add its handlers without this
function growing a parameter per affordance."
[{:keys [label sub on? disabled? thumb] :as opts}]
[:button (merge {:class (str "pool-item" (when on? " on") (when thumb " media"))
:disabled (boolean disabled?)
:title label}
(dissoc opts :label :sub :on? :disabled?))
label
(when sub [:span.sub sub])])
(dissoc opts :label :sub :on? :disabled? :thumb))
thumb
[:span.text label (when sub [:span.sub sub])]])
(defn- group
"A titled group of rows. The title doubles as the group's class, so `.clips`
and `.footage` are addressable — `test/browser` needs to ask which CLIP is open
without the chosen footage row, which is also marked, answering instead."
[title & children]
(defn- carrying
"Drag-start handler for a row whose payload is `payload`."
[payload]
(fn [^js event]
(.setData (.-dataTransfer event) "text/plain" payload)
(set! (.. event -dataTransfer -effectAllowed) "copy")))
(defn- thumbnail
"One frame of a video, from its proxy. `#t=` seeks a paused, muted element to a
frame that is not the black leader most phone footage opens on; nothing is
played and nothing is decoded past it."
[{:keys [video]}]
(if video
[:video.thumb {:src (str video "#t=0.2") :muted true :preload "metadata"
:plays-inline true :tab-index -1}]
[:span.thumb]))
(defn- footage-row [{:keys [id label frames fps] :as f} chosen]
[item {:label label
:sub (str frames "f @ " fps)
:thumb [thumbnail f]
:on? (= id chosen)
:draggable true
:on-drag-start (carrying (str "footage:" id))
:on-click #(rf/dispatch [::footage/choose id])}])
(defn- folder [title & children]
(into [:details.pool-folder {:open true} [:summary title]] children))
(defn- group [title & children]
(into [:div.pool-group {:class title} [:h2 title]] children))
(defn- symbols []
(let [clip @(rf/subscribe [::render/clip])
(defn- this-project []
(let [document @(rf/subscribe [::render/clip])
selection @(rf/subscribe [::sub/selection])
library (sort-by str (keys (:symbols clip)))]
(group "symbols"
[:div.dim "in this document"]
(if (empty? library)
[:div.dim "none yet"]
(doall
(for [sid library
:let [sym (get-in clip [:symbols sid])]]
^{:key sid}
[item {:label (clip/symbol-name clip sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes")
:on? (= selection [:symbol sid])
:draggable true
:on-drag-start
(fn [^js event]
(.setData (.-dataTransfer event) "text/plain"
(str "symbol:" (subs (str sid) 1)))
(set! (.. event -dataTransfer -effectAllowed) "copy"))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])
:on-double-click #(rf/dispatch [::pb/open-symbol sid])}])))
(when (seq library)
[:div.dim "double-click to open · drag to place"]))))
open @(rf/subscribe [::render/open])
media @(rf/subscribe [::sub/project-footage])
{:keys [chosen]} @(rf/subscribe [::playback/footage])]
(folder "this project"
(group "symbols"
(doall
(for [sid (sort-by str (keys (:symbols document)))
:let [sym (clip/symbol document sid)]]
^{:key (str sid)}
[item {:label (clip/symbol-name document sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes"
(when (= sid open) " · open"))
:on? (= selection [:symbol sid])
:draggable true
:on-drag-start (carrying (str "symbol:" (subs (str sid) 1)))
:on-click #(rf/dispatch [::ui/select [:symbol sid]])
:on-double-click #(rf/dispatch [::pb/open-symbol sid])}]))
[:div.dim "double-click to open · drag to place"])
(group "media"
(if (empty? media)
[:div.dim "drop a video here"]
(doall (for [f media] ^{:key (:id f)} [footage-row f chosen])))))))
(defn- footage []
(let [{:keys [loading? available chosen]} @(rf/subscribe [::playback/footage])]
(group "footage"
[:div.dim "source media on the server"]
(if (empty? available)
[:div.dim "drop a video here"]
(doall
(for [{:keys [id label frames fps]} available]
^{:key id}
[item {:label label
:sub (str frames "f @ " fps)
:on? (= id chosen)
:disabled? loading?
:on-click #(rf/dispatch [::footage/choose id])}]))))))
(defn- all-assets []
(let [{:keys [available chosen]} @(rf/subscribe [::playback/footage])
{:keys [symbols]} @(rf/subscribe [::project/assets])
{:keys [id]} @(rf/subscribe [::playback/project])]
(folder "all assets"
(group "media"
(if (empty? available)
[:div.dim "nothing uploaded yet"]
(doall (for [f available] ^{:key (:id f)} [footage-row f chosen]))))
(group "symbols"
(let [others (remove #(= id (:project %)) symbols)]
(if (empty? others)
[:div.dim "no other saved projects"]
(doall
(for [[[pid pname] rows] (group-by (juxt :project :project-name) others)]
^{:key pid}
;; Closed: a server holds many projects, and a wall of
;; every symbol in every one buries the one you want.
[:details.pool-project
[:summary (str pname " · " (count rows)
(if (= 1 (count rows)) " symbol" " symbols"))]
(doall
(for [{:keys [cid symbol name frames]} rows]
^{:key (str cid symbol)}
[item {:label name
:sub (str frames "f")
:draggable true
:on-drag-start (carrying (str "import:" (str/join "|" [pid cid symbol])))}]))]))))))))
(defn view []
(r/with-let [;; Counted, not a boolean. `dragenter`/`dragleave` fire for every
@ -99,12 +135,15 @@
;; cleared on leave flickers off the moment the drag passes over a
;; row — the depth counter is what makes the outline steady.
depth (r/atom 0)]
(let [{:keys [loading? chosen status]} @(rf/subscribe [::playback/footage])]
(let [{:keys [loading? status]} @(rf/subscribe [::playback/footage])
files? (fn [^js e] (some #{"Files"} (array-seq (.. e -dataTransfer -types))))]
[:section.pane.pool
{:class (when (pos? @depth) "dropping")
:on-drag-enter (fn [^js e] (.preventDefault e) (swap! depth inc))
:on-drag-leave (fn [_] (swap! depth #(max 0 (dec %))))
:on-drag-over (fn [^js e] (.preventDefault e))
;; Only a FILE lights the pane up: a row dragged out of the pool is on
;; its way somewhere else.
:on-drag-enter (fn [^js e] (when (files? e) (.preventDefault e) (swap! depth inc)))
:on-drag-leave (fn [^js e] (when (files? e) (swap! depth #(max 0 (dec %)))))
:on-drag-over (fn [^js e] (when (files? e) (.preventDefault e)))
:on-drop (fn [^js e]
(.preventDefault e)
(reset! depth 0)
@ -113,10 +152,6 @@
[:div.pane-head
"media pool"
[:span.spacer]
[:button {:disabled (or loading? (nil? chosen))
:title "detect and freeze the chosen footage"
:on-click #(rf/dispatch [::footage/load])}
(if loading? "…" "roto")]
[:button {:title "add a video"
:disabled loading?
:on-click #(.click (js/document.getElementById "pool-file"))}
@ -128,6 +163,6 @@
(rf/dispatch [::footage/upload file])
(set! (.. event -target -value) "")))}]
[:div.pane-body
[symbols]
[footage]
[this-project]
[all-assets]
(when status [:div.dim status])]])))

View file

@ -737,16 +737,14 @@ async function main() {
nodeId: root.nodeId, selector: 'input[type=file]',
});
await page.send('DOM.setFileInputFiles', { files: [video], nodeId });
// Waited for in the MEDIA POOL rather than in the status line. Dropping or
// choosing a video now runs straight on into detection, so "video
// extracted" is a state the app passes through in one turn of the event
// loop and not one a poller can be relied on to catch. The row appearing in
// the pool is the durable evidence, and it is what a person would look at.
// Waited for in the MEDIA POOL rather than in the status line: the row
// appearing under this project's media is the durable evidence, and it is
// what a person would look at.
let extracted = null;
for (let i = 0; i < 160 && extracted === null; i++) {
await sleep(250);
if (await page.eval(`[...document.querySelectorAll('.pool-item')]
.some((b) => (b.firstChild?.textContent ?? '').trim() === 'browser-upload.mp4')`)) {
.some((b) => (b.querySelector('.text')?.firstChild?.textContent ?? '').trim() === 'browser-upload.mp4')`)) {
extracted = 'listed in the media pool';
}
}