diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 6d67bd5..2b3c137 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -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) diff --git a/clips/urls.py b/clips/urls.py index 4b4042f..422aa49 100644 --- a/clips/urls.py +++ b/clips/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path("footage", views.footage_list), path("footage/", views.footage_detail), path("projects", views.projects), + path("symbols", views.symbols), path("projects/", views.project_detail), path("projects//leaves/", views.leaf_detail), path("projects//revisions", views.revisions), diff --git a/clips/views.py b/clips/views.py index ce8ce79..d54da4d 100644 --- a/clips/views.py +++ b/clips/views.py @@ -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//symbol/`, 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: diff --git a/frontend/src/arthur/core.cljs b/frontend/src/arthur/core.cljs index 90848b4..007e40e 100644 --- a/frontend/src/arthur/core.cljs +++ b/frontend/src/arthur/core.cljs @@ -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!)) diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index 740faca..f4c0f8e 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -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 diff --git a/frontend/src/arthur/events/footage.cljs b/frontend/src/arthur/events/footage.cljs index 9df1c2d..ca54b58 100644 --- a/frontend/src/arthur/events/footage.cljs +++ b/frontend/src/arthur/events/footage.cljs @@ -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 diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index 4bf8e87..68f6fe0 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -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 diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index dded3b1..a611e69 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -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)))) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index b86b06b..05190e3 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -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:` for a symbol of this document, `import:||` for + one of another's, and `footage:` 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])]]))) diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index 6e5d40f..283a8ae 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -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'; } } diff --git a/static/arthur/app.css b/static/arthur/app.css index a61cd02..7e76fe4 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -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