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:
parent
c81f91c442
commit
d5f044c6da
11 changed files with 270 additions and 105 deletions
|
|
@ -401,6 +401,18 @@ class DocumentTests(TestCase):
|
|||
"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):
|
||||
response = self.save()
|
||||
self.assertEqual(200, response.status_code, response.content)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ urlpatterns = [
|
|||
path("footage", views.footage_list),
|
||||
path("footage/<uuid:footage_id>", views.footage_detail),
|
||||
path("projects", views.projects),
|
||||
path("symbols", views.symbols),
|
||||
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>/revisions", views.revisions),
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
def footage_detail(request, footage_id):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -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!))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;; 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"})
|
||||
:dispatch-n [[::refresh] [::load]]}))
|
||||
(update :uploaded (fnil conj #{}) footage-id)))
|
||||
:dispatch [::refresh]}))
|
||||
|
||||
(rf/reg-event-fx
|
||||
::refresh
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(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"))})))
|
||||
" · " 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
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
(into [:div.pool-group {:class title} [:h2 title]] children))
|
||||
|
||||
(defn- symbols []
|
||||
(let [clip @(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
|
||||
(defn- carrying
|
||||
"Drag-start handler for a row whose payload is `payload`."
|
||||
[payload]
|
||||
(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"]))))
|
||||
(.setData (.-dataTransfer event) "text/plain" payload)
|
||||
(set! (.. event -dataTransfer -effectAllowed) "copy")))
|
||||
|
||||
(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}
|
||||
(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)
|
||||
:disabled? loading?
|
||||
:on-click #(rf/dispatch [::footage/choose id])}]))))))
|
||||
: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- this-project []
|
||||
(let [document @(rf/subscribe [::render/clip])
|
||||
selection @(rf/subscribe [::sub/selection])
|
||||
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- 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])]])))
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,19 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
|
|||
/* --------------------------------------------------------------------------
|
||||
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; }
|
||||
|
||||
/* 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.on { background: var(--sel-bg); border-color: var(--sel); }
|
||||
.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
|
||||
zone inside it — a drop that lands two pixels outside a dashed rectangle and
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue