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

@ -401,6 +401,18 @@ class DocumentTests(TestCase):
"blocks": blocks if blocks is not None else [self.block]}], "blocks": blocks if blocks is not None else [self.block]}],
}) })
def test_every_saved_symbol_is_listed_across_projects(self):
leaves = self.leaves()
leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12]
leaves["clip/c1/symbol/sym~face/node/mark"] = ["^ ", "~:id", "~:mark", "~:z", "a1"]
self.assertEqual(200, self.save(leaves).status_code)
rows = self.client.get("/api/symbols").json()["symbols"]
self.assertEqual(
[("face", "sym~face", 12), ("main", "main", 48)],
[(r["name"], r["symbol"], r["frames"]) for r in rows])
self.assertEqual({str(self.project.id)}, {r["project"] for r in rows})
self.assertEqual({"c1"}, {r["cid"] for r in rows})
def test_a_document_comes_back_exactly(self): def test_a_document_comes_back_exactly(self):
response = self.save() response = self.save()
self.assertEqual(200, response.status_code, response.content) self.assertEqual(200, response.status_code, response.content)

View file

@ -23,6 +23,7 @@ urlpatterns = [
path("footage", views.footage_list), path("footage", views.footage_list),
path("footage/<uuid:footage_id>", views.footage_detail), path("footage/<uuid:footage_id>", views.footage_detail),
path("projects", views.projects), path("projects", views.projects),
path("symbols", views.symbols),
path("projects/<uuid:project_id>", views.project_detail), path("projects/<uuid:project_id>", views.project_detail),
path("projects/<uuid:project_id>/leaves/<path:leaf_path>", views.leaf_detail), path("projects/<uuid:project_id>/leaves/<path:leaf_path>", views.leaf_detail),
path("projects/<uuid:project_id>/revisions", views.revisions), path("projects/<uuid:project_id>/revisions", views.revisions),

View file

@ -273,6 +273,44 @@ def footage_list(request):
) )
_SYMBOL_LEAF = re.compile(r"^clip/([^/]+)/symbol/([^/]+)$")
def _transit_fields(value, *keys):
"""Top-level fields of a transit map leaf, by keyword name. A leaf's own
facts are a small flat map, so no key repeats and transit's cache never
stands in for one; anything else reads as absent."""
if not (isinstance(value, list) and value[:1] == ["^ "]):
return {}
pairs = dict(zip(value[1::2], value[2::2]))
return {k: pairs.get(f"~:{k}") for k in keys}
@require_http_methods(["GET"])
def symbols(request):
"""Every symbol in every saved project, for the pool's all-assets folder.
Read off the leaf PATHS rather than by loading documents: a symbol's own leaf
is `clip/<cid>/symbol/<sid>`, so listing them is one query and no decoding
beyond the name and length its value carries."""
rows = []
for leaf in Leaf.objects.filter(path__contains="/symbol/").select_related("project"):
m = _SYMBOL_LEAF.match(leaf.path)
if not m:
continue
fields = _transit_fields(leaf.value, "name", "frames")
rows.append({
"project": str(leaf.project_id),
"project_name": leaf.project.name,
"cid": m.group(1),
"symbol": m.group(2),
"name": fields.get("name") or m.group(2).replace("~", "/"),
"frames": fields.get("frames"),
})
rows.sort(key=lambda r: (r["project_name"], r["project"], r["name"]))
return JsonResponse({"symbols": rows})
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def footage_detail(request, footage_id): def footage_detail(request, footage_id):
try: try:

View file

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

View file

@ -77,7 +77,12 @@
;; comes from the server — tier 3 is the backend's since step 9 — so there is ;; comes from the server — tier 3 is the backend's since step 9 — so there is
;; no path to type any more. ;; no path to type any more.
:footage {:id nil :label nil :loading? false :status nil :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 ;; 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 ;; version: a client that sees a delta with `seq > local + 1` refetches, which

View file

@ -336,14 +336,14 @@
(rf/reg-event-fx (rf/reg-event-fx
::uploaded ::uploaded
(fn [{:keys [db]} [_ footage-id]] (fn [{:keys [db]} [_ footage-id]]
;; STRAIGHT INTO DETECTION. Dropping a video on the media pool is one ;; Into the pool, and no further. An upload is media for this project; turning
;; intention — get this footage into the tool — and stopping after extraction ;; it into a symbol is a separate decision — which frames, what name — made by
;; to wait for a second click only means finding the button that does the ;; dropping it where it should go.
;; obvious next thing. `::load` reads `:chosen`, which this event has just {:db (update db :footage #(-> %
;; written, because `:dispatch` runs after the db update. (merge {:loading? false :chosen footage-id
{:db (update db :footage merge {:loading? false :chosen footage-id :status "video extracted"})
:status "video extracted"}) (update :uploaded (fnil conj #{}) footage-id)))
:dispatch-n [[::refresh] [::load]]})) :dispatch [::refresh]}))
(rf/reg-event-fx (rf/reg-event-fx
::refresh ::refresh

View file

@ -174,6 +174,32 @@
(.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
::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 (rf/reg-fx
::open! ::open!
(fn [id] (fn [id]
@ -453,14 +479,16 @@
{:db db {:db db
::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) 0]}))) ::pb/seek! [(get-in db [:clip :fps]) (pb/frames db) 0]})))
(rf/reg-event-db (rf/reg-event-fx
::saved ::saved
(fn [db [_ id cid label seq written uploaded]] (fn [{:keys [db]} [_ id cid label seq written uploaded]]
(update db :project merge {:db (update db :project merge
{:id id :cid cid :name label :seq seq :busy? false {:id id :cid cid :name label :seq seq :busy? false
:status (str "saved r" seq " · " written :status (str "saved r" seq " · " written
(if (= 1 written) " leaf" " leaves") (if (= 1 written) " leaf" " leaves")
" · " uploaded (if (= 1 uploaded) " block" " blocks"))}))) " · " 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 (rf/reg-event-fx
::opened ::opened

View file

@ -4,7 +4,9 @@
Cheap by construction, like `subs/playback`: each reads a path and returns a Cheap by construction, like `subs/playback`: each reads a path and returns a
value, so clicking a swatch notifies the swatches and nothing else." 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])) [re-frame.core :as rf]))
(rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection]))) (rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection])))
@ -27,3 +29,22 @@
(let [[_ sid id] selection] (let [[_ sid id] selection]
(when-let [n (get-in clip [:symbols sid :nodes id])] (when-let [n (get-in clip [:symbols sid :nodes id])]
[sid id n]))))) [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 (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 THIS PROJECT is the open document's own: every symbol in it — the open one
the split is the point: opening a project REPLACES the stage, where everything included, because none is special — and the video it uses or was given this
in here is a thing to put ON it. Listing documents beside the symbols inside session. ALL ASSETS is everything the server holds: every upload, and every
one of them made them look like two kinds of the same thing, which is exactly symbol of every other saved project. Whole projects are `ui/openmenu`'s, and
the confusion this pane should be ending. 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 Every row is a drag source, and what it carries says what it is:
`:kind :instance` node is an instance of. Dragging one onto the stage places an `symbol:<id>` for a symbol of this document, `import:<project>|<cid>|<id>` for
instance. Open a different project and this is a different list, because a one of another's, and `footage:<id>` for video. The stage and the timeline are
library belongs to the document that holds it. where they are dropped.
FOOTAGE is source media on the server: uploads that have been probed, THE WHOLE PANE IS THE DROP TARGET for a file. A video dropped anywhere in it
transcoded and cut into frames. Running one through detection produces a uploads and lands in THIS PROJECT's media, and goes no further: which frames of
document whose roto IS a symbol — `flow/freeze/clip` gives each tracked subject it become a symbol, and what that symbol is called, is asked when it is dropped
its own symbol and places one instance of each in `:main` — so where it should go. The uploaded bytes, the extraction job and the decoded
footage becomes a row in the group above it by way of being detected. 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. 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."
(:require [arthur.domain.clip :as clip] (:require [arthur.domain.clip :as clip]
[arthur.events.footage :as footage] [arthur.events.footage :as footage]
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
[arthur.events.project :as project]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
[clojure.string :as str]
[re-frame.core :as rf] [re-frame.core :as rf]
[reagent.core :as r])) [reagent.core :as r]))
(defn- item (defn- item
"One row. `opts` is merged last so a caller can add `:draggable` and its "One row. `opts` is merged last so a caller can add its handlers without this
handlers without this function growing a parameter per affordance." function growing a parameter per affordance."
[{:keys [label sub on? disabled?] :as opts}] [{:keys [label sub on? disabled? thumb] :as opts}]
[:button (merge {:class (str "pool-item" (when on? " on")) [:button (merge {:class (str "pool-item" (when on? " on") (when thumb " media"))
:disabled (boolean disabled?) :disabled (boolean disabled?)
:title label} :title label}
(dissoc opts :label :sub :on? :disabled?)) (dissoc opts :label :sub :on? :disabled? :thumb))
label thumb
(when sub [:span.sub sub])]) [:span.text label (when sub [:span.sub sub])]])
(defn- group (defn- carrying
"A titled group of rows. The title doubles as the group's class, so `.clips` "Drag-start handler for a row whose payload is `payload`."
and `.footage` are addressable — `test/browser` needs to ask which CLIP is open [payload]
without the chosen footage row, which is also marked, answering instead." (fn [^js event]
[title & children] (.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)) (into [:div.pool-group {:class title} [:h2 title]] children))
(defn- symbols [] (defn- this-project []
(let [clip @(rf/subscribe [::render/clip]) (let [document @(rf/subscribe [::render/clip])
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
library (sort-by str (keys (:symbols clip)))] open @(rf/subscribe [::render/open])
(group "symbols" media @(rf/subscribe [::sub/project-footage])
[:div.dim "in this document"] {:keys [chosen]} @(rf/subscribe [::playback/footage])]
(if (empty? library) (folder "this project"
[:div.dim "none yet"] (group "symbols"
(doall (doall
(for [sid library (for [sid (sort-by str (keys (:symbols document)))
:let [sym (get-in clip [:symbols sid])]] :let [sym (clip/symbol document sid)]]
^{:key sid} ^{:key (str sid)}
[item {:label (clip/symbol-name clip sid) [item {:label (clip/symbol-name document sid)
:sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes") :sub (str (:frames sym) "f · " (count (:nodes sym)) " nodes"
:on? (= selection [:symbol sid]) (when (= sid open) " · open"))
:draggable true :on? (= selection [:symbol sid])
:on-drag-start :draggable true
(fn [^js event] :on-drag-start (carrying (str "symbol:" (subs (str sid) 1)))
(.setData (.-dataTransfer event) "text/plain" :on-click #(rf/dispatch [::ui/select [:symbol sid]])
(str "symbol:" (subs (str sid) 1))) :on-double-click #(rf/dispatch [::pb/open-symbol sid])}]))
(set! (.. event -dataTransfer -effectAllowed) "copy")) [:div.dim "double-click to open · drag to place"])
:on-click #(rf/dispatch [::ui/select [:symbol sid]]) (group "media"
:on-double-click #(rf/dispatch [::pb/open-symbol sid])}]))) (if (empty? media)
(when (seq library) [:div.dim "drop a video here"]
[:div.dim "double-click to open · drag to place"])))) (doall (for [f media] ^{:key (:id f)} [footage-row f chosen])))))))
(defn- footage [] (defn- all-assets []
(let [{:keys [loading? available chosen]} @(rf/subscribe [::playback/footage])] (let [{:keys [available chosen]} @(rf/subscribe [::playback/footage])
(group "footage" {:keys [symbols]} @(rf/subscribe [::project/assets])
[:div.dim "source media on the server"] {:keys [id]} @(rf/subscribe [::playback/project])]
(if (empty? available) (folder "all assets"
[:div.dim "drop a video here"] (group "media"
(doall (if (empty? available)
(for [{:keys [id label frames fps]} available] [:div.dim "nothing uploaded yet"]
^{:key id} (doall (for [f available] ^{:key (:id f)} [footage-row f chosen]))))
[item {:label label (group "symbols"
:sub (str frames "f @ " fps) (let [others (remove #(= id (:project %)) symbols)]
:on? (= id chosen) (if (empty? others)
:disabled? loading? [:div.dim "no other saved projects"]
:on-click #(rf/dispatch [::footage/choose id])}])))))) (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 [] (defn view []
(r/with-let [;; Counted, not a boolean. `dragenter`/`dragleave` fire for every (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 ;; cleared on leave flickers off the moment the drag passes over a
;; row — the depth counter is what makes the outline steady. ;; row — the depth counter is what makes the outline steady.
depth (r/atom 0)] 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 [:section.pane.pool
{:class (when (pos? @depth) "dropping") {:class (when (pos? @depth) "dropping")
:on-drag-enter (fn [^js e] (.preventDefault e) (swap! depth inc)) ;; Only a FILE lights the pane up: a row dragged out of the pool is on
:on-drag-leave (fn [_] (swap! depth #(max 0 (dec %)))) ;; its way somewhere else.
:on-drag-over (fn [^js e] (.preventDefault e)) :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] :on-drop (fn [^js e]
(.preventDefault e) (.preventDefault e)
(reset! depth 0) (reset! depth 0)
@ -113,10 +152,6 @@
[:div.pane-head [:div.pane-head
"media pool" "media pool"
[:span.spacer] [: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" [:button {:title "add a video"
:disabled loading? :disabled loading?
:on-click #(.click (js/document.getElementById "pool-file"))} :on-click #(.click (js/document.getElementById "pool-file"))}
@ -128,6 +163,6 @@
(rf/dispatch [::footage/upload file]) (rf/dispatch [::footage/upload file])
(set! (.. event -target -value) "")))}] (set! (.. event -target -value) "")))}]
[:div.pane-body [:div.pane-body
[symbols] [this-project]
[footage] [all-assets]
(when status [:div.dim status])]]))) (when status [:div.dim status])]])))

View file

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

View file

@ -255,6 +255,19 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
media pool */ media pool */
/* Two folders, THIS PROJECT and ALL ASSETS, each holding groups. */
.pool-folder { margin-bottom: 8px; }
.pool-folder > summary {
cursor: pointer;
padding: 2px 0 4px;
font-weight: 600;
color: var(--fg);
}
.pool-folder > .pool-group { padding-left: 8px; }
.pool-project { margin-bottom: 2px; }
.pool-project > summary { cursor: pointer; color: var(--dim); padding: 1px 0; }
.pool-project > .pool-item { margin-left: 10px; width: calc(100% - 10px); }
.pool-group { margin-bottom: 9px; } .pool-group { margin-bottom: 9px; }
/* The one-line gloss under a group heading. Small and quiet: it answers "what /* The one-line gloss under a group heading. Small and quiet: it answers "what
@ -287,6 +300,19 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
.pool-item:hover:not(:disabled) { background: #fff; } .pool-item:hover:not(:disabled) { background: #fff; }
.pool-item.on { background: var(--sel-bg); border-color: var(--sel); } .pool-item.on { background: var(--sel-bg); border-color: var(--sel); }
.pool-item .sub { display: block; color: var(--dim); } .pool-item .sub { display: block; color: var(--dim); }
.pool-item .text { display: block; overflow: hidden; text-overflow: ellipsis; }
/* A media row leads with one frame of the video. */
.pool-item.media { display: flex; align-items: center; gap: 6px; }
.pool-item .thumb {
flex: 0 0 40px;
width: 40px;
height: 30px;
object-fit: cover;
background: var(--stage);
border-radius: 2px;
pointer-events: none;
}
/* The whole pane is the drop target, so the cue has to be the pane and not a /* The whole pane is the drop target, so the cue has to be the pane and not a
zone inside it — a drop that lands two pixels outside a dashed rectangle and zone inside it — a drop that lands two pixels outside a dashed rectangle and