Script pane, realtime peer sync, and logout button

- Script pane: PDF tab beside annotations; "Add script annotation" arms
  highlight mode on the draft, ✓/✕ to confirm/cancel, auto-scrolls to the
  active annotation's highlights during playback. Adds Project.script.
- Realtime: Django Channels websocket per project broadcasts attributed
  scene deltas after each save; peers merge them in (keeping local drafts).
- Logout link in the editor project-bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-29 10:52:46 -04:00
parent 595ac5ba72
commit d3c8dcbdf2
13 changed files with 307 additions and 11 deletions

22
scenes/consumers.py Normal file
View file

@ -0,0 +1,22 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class SceneConsumer(AsyncWebsocketConsumer):
"""One connection per open project. Joins the project's group and relays
scene deltas the server broadcasts (see scenes.views.scene). Read-only:
edits still go through the PUT endpoint, which is the broadcast source."""
async def connect(self):
self.pk = self.scope['url_route']['kwargs']['pk']
self.group = f'project_{self.pk}'
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
async def disconnect(self, code):
await self.channel_layer.group_discard(self.group, self.channel_name)
# broadcast handler: {type: "scene.delta", delta: {...}}
async def scene_delta(self, event):
await self.send(text_data=json.dumps(event['delta']))

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-29 08:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scenes', '0005_project_thumbnail_error_project_thumbnail_manifest_and_more'),
]
operations = [
migrations.AddField(
model_name='project',
name='script',
field=models.FileField(blank=True, null=True, upload_to='scripts/'),
),
]

View file

@ -16,6 +16,7 @@ class Project(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
otio = models.FileField(upload_to="otio/", blank=True, null=True) otio = models.FileField(upload_to="otio/", blank=True, null=True)
clip = models.FileField(upload_to="clips/", blank=True, null=True) clip = models.FileField(upload_to="clips/", blank=True, null=True)
script = models.FileField(upload_to="scripts/", blank=True, null=True)
fps = models.FloatField(default=NTSC_24) fps = models.FloatField(default=NTSC_24)
thumbnail_status = models.CharField(max_length=20, default="missing") thumbnail_status = models.CharField(max_length=20, default="missing")
thumbnail_manifest = models.JSONField(default=dict, blank=True) thumbnail_manifest = models.JSONField(default=dict, blank=True)

7
scenes/routing.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from .consumers import SceneConsumer
websocket_urlpatterns = [
path('ws/projects/<int:pk>/', SceneConsumer.as_asgi()),
]

View file

@ -1,5 +1,7 @@
import json import json
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
@ -53,6 +55,7 @@ def _meta(request, project):
"owner": project.owner.get_username(), "owner": project.owner.get_username(),
"otio": request.build_absolute_uri(project.otio.url) if project.otio else None, "otio": request.build_absolute_uri(project.otio.url) if project.otio else None,
"clip": request.build_absolute_uri(project.clip.url) if project.clip else None, "clip": request.build_absolute_uri(project.clip.url) if project.clip else None,
"script": request.build_absolute_uri(project.script.url) if project.script else None,
"thumbnail_status": project.thumbnail_status, "thumbnail_status": project.thumbnail_status,
"thumbnail_error": project.thumbnail_error, "thumbnail_error": project.thumbnail_error,
"thumbnails": thumbnails or None, "thumbnails": thumbnails or None,
@ -150,6 +153,15 @@ def scene(request, pk):
if any(counts.values()): # don't log a no-op save if any(counts.values()): # don't log a no-op save
Revision.objects.create(project=p, user=request.user, summary=summary, Revision.objects.create(project=p, user=request.user, summary=summary,
annotations=annotation_layer(p.scene)) annotations=annotation_layer(p.scene))
# push the attributed delta to peers viewing this project
scene_groups = (p.scene or {}).get("groups", {})
async_to_sync(get_channel_layer().group_send)(
f"project_{p.pk}",
{"type": "scene.delta",
"delta": {"changed": {gid: scene_groups[gid]
for gid in changed if gid in scene_groups},
"deleted": list(deleted),
"by": request.user.get_username()}})
if "fps" in data: if "fps" in data:
p.fps = data["fps"] p.fps = data["fps"]
p.save(update_fields=["scene", "fps", "updated"]) p.save(update_fields=["scene", "fps", "updated"])

View file

@ -1,10 +1,8 @@
""" """
ASGI config for server project. ASGI config for server project.
It exposes the ASGI callable as a module-level variable named ``application``. HTTP is handled by Django as usual; websocket connections are routed to the
scene consumers so peers receive live scene deltas.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
""" """
import os import os
@ -13,4 +11,15 @@ from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
application = get_asgi_application() # Initialise Django before importing anything that touches the app registry.
django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import scenes.routing
application = ProtocolTypeRouter({
'http': django_asgi_app,
'websocket': AuthMiddlewareStack(URLRouter(scenes.routing.websocket_urlpatterns)),
})

View file

@ -31,6 +31,8 @@ ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'testserver']
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'daphne', # ASGI server; must precede staticfiles so runserver uses it
'channels',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@ -54,6 +56,11 @@ MIDDLEWARE = [
ROOT_URLCONF = 'server.urls' ROOT_URLCONF = 'server.urls'
# Realtime: push scene deltas to peers over websockets. In-memory layer is fine
# for a single process; swap to channels_redis when you run more than one worker.
ASGI_APPLICATION = 'server.asgi.application'
CHANNEL_LAYERS = {'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}}
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',

View file

@ -308,3 +308,28 @@ body { overflow: hidden; }
.project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px; .project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px;
background: #111; border-bottom: 1px solid #222; font-size: 12px; } background: #111; border-bottom: 1px solid #222; font-size: 12px; }
.project-bar .project-name { color: #cfe3f5; font-weight: 600; } .project-bar .project-name { color: #cfe3f5; font-weight: 600; }
.project-bar .logout-link { margin-left: auto; color: #8aa; text-decoration: none; }
.project-bar .logout-link:hover { color: #cfe3f5; text-decoration: underline; }
/* --- annotation / script pane tabs ------------------------------------- */
.pane-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.pane-tabs { display: flex; gap: 2px; background: #111; border-bottom: 1px solid #222; }
.pane-tabs button { flex: 1; background: none; color: #889; border: none; padding: 7px 10px;
cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; }
.pane-tabs button:hover { color: #cfe3f5; }
.pane-tabs button.active { color: #cfe3f5; border-bottom-color: #3a6ea5; background: #152333; }
.pane-body { flex: 1; min-height: 0; display: flex; }
/* --- script pane (pdf) ------------------------------------------------- */
.script-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: #111; }
.script-hint { padding: 6px 10px; font-size: 11px; color: #8aa; border-bottom: 1px solid #222; }
.script-pages { flex: 1; overflow: auto; padding: 12px; background: #1a1a1a;
display: flex; flex-direction: column; align-items: center; gap: 12px; }
.pdf-page { position: relative; box-shadow: 0 2px 10px rgba(0,0,0,.5); background: #fff; }
.pdf-page.armed { cursor: crosshair; }
.pdf-page canvas { display: block; }
.script-hl { position: absolute; border-radius: 2px; cursor: pointer; }
.script-hl.drafting { background: rgba(90,176,122,.25); border: 1.5px dashed #5ab07a; pointer-events: none; }
.script-empty { color: #888; font-size: 12px; padding: 20px; }
.script-badge { margin-left: 6px; color: #7aa6d6; font-size: 11px; }
.ann.selected { box-shadow: inset 3px 0 0 #5ab07a; }

View file

@ -14,6 +14,15 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="tl"> <meta name="apple-mobile-web-app-title" content="tl">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- pdf.js (script pane) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
if (window.pdfjsLib) {
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
}
</script>
</head> </head>
<body> <body>
<noscript> <noscript>

View file

@ -64,6 +64,23 @@
(seq changed) (assoc :changed changed) (seq changed) (assoc :changed changed)
(seq deleted) (assoc :deleted deleted))))}))) (seq deleted) (assoc :deleted deleted))))})))
;; --- realtime: live peer deltas over a websocket --------------------------
(defonce ^:private socket (atom nil))
(defn connect-scene!
"Open (or replace) the websocket that streams peer scene deltas for project
`id`. Incoming messages dispatch ::peer-delta, which merges them in."
[id]
(when-let [s @socket] (.close s))
(when id
(let [proto (if (= "https:" (.. js/window -location -protocol)) "wss:" "ws:")
url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/")
s (js/WebSocket. url)]
(reset! socket s)
(set! (.-onmessage s)
(fn [e] (rf/dispatch [:tl.events/peer-delta (->clj (js/JSON.parse (.-data e)))]))))))
(defn create-project! [name otio clip] (defn create-project! [name otio clip]
(let [fd (js/FormData.)] (let [fd (js/FormData.)]
(.append fd "name" name) (.append fd "name" name)

View file

@ -16,6 +16,7 @@
(rf/reg-fx :refresh-project-detail (fn [id] (api/fetch-project-detail! id))) (rf/reg-fx :refresh-project-detail (fn [id] (api/fetch-project-detail! id)))
(rf/reg-fx :put-scene (fn [{:keys [id changed deleted]}] (api/put-scene! id changed deleted))) (rf/reg-fx :put-scene (fn [{:keys [id changed deleted]}] (api/put-scene! id changed deleted)))
(rf/reg-fx :create-project (fn [{:keys [name otio clip]}] (api/create-project! name otio clip))) (rf/reg-fx :create-project (fn [{:keys [name otio clip]}] (api/create-project! name otio clip)))
(rf/reg-fx :connect-scene (fn [id] (api/connect-scene! id)))
(rf/reg-fx :goto (fn [hash] (set! (.. js/window -location -hash) hash))) (rf/reg-fx :goto (fn [hash] (set! (.. js/window -location -hash) hash)))
(rf/reg-fx :login (fn [{:keys [username password]}] (api/login! username password))) (rf/reg-fx :login (fn [{:keys [username password]}] (api/login! username password)))
(rf/reg-fx :logout (fn [_] (api/logout!))) (rf/reg-fx :logout (fn [_] (api/logout!)))
@ -61,9 +62,23 @@
(assoc :scene (update base :groups merge anns)) (assoc :scene (update base :groups merge anns))
(assoc :view (:view db/default-db)) (assoc :view (:view db/default-db))
(assoc-in [:load :status] :ready))] (assoc-in [:load :status] :ready))]
(cond-> {:db next-db} (cond-> {:db next-db :connect-scene (:id detail)}
(thumbnails-pending? detail) (assoc :poll-thumbnails (:id detail)))))) (thumbnails-pending? detail) (assoc :poll-thumbnails (:id detail))))))
;; A peer saved: merge their attributed groups in (and drop deletions). We keep
;; any group we're currently editing (flagged :draft) so a peer save can't yank
;; an in-progress edit out from under us — our own save is authoritative for it.
(rf/reg-event-db
::peer-delta
(fn [db [_ {:keys [changed deleted]}]]
(let [restored (scene/restore-annotations changed)
drafts (into #{} (keep (fn [[gid g]] (when (:draft g) gid))
(get-in db [:scene :groups])))]
(update-in db [:scene :groups]
(fn [groups]
(-> (apply dissoc groups (map keyword deleted))
(into (remove (fn [[gid _]] (contains? drafts gid)) restored))))))))
(rf/reg-event-fx (rf/reg-event-fx
::refresh-project-detail ::refresh-project-detail
(fn [{:keys [db]} _] (fn [{:keys [db]} _]
@ -92,6 +107,34 @@
;; --- view ----------------------------------------------------------------- ;; --- view -----------------------------------------------------------------
(rf/reg-event-db ::set-pane (fn [db [_ p]] (assoc-in db [:view :pane] p)))
;; Script highlighting is an edit-mode sub-task: "Add script annotation" arms it
;; for the annotation being edited (the draft) and jumps to the Script tab; the
;; rects land on the draft's :script and ride its save. ✓ keeps, ✕ restores the
;; snapshot taken on entry. (No card-click selection anymore.)
(rf/reg-event-db ::start-highlighting
(fn [db [_ gid]]
(-> db
(assoc-in [:view :highlighting]
{:gid gid :snapshot (get-in db [:scene :groups gid :script])})
(assoc-in [:view :pane] :script))))
(rf/reg-event-db ::finish-highlighting
(fn [db _] (-> db (assoc-in [:view :highlighting] nil)
(assoc-in [:view :pane] :annotations))))
(rf/reg-event-db ::cancel-highlighting
(fn [db _]
(let [{:keys [gid snapshot]} (get-in db [:view :highlighting])]
(-> db
(assoc-in [:scene :groups gid :script] snapshot)
(assoc-in [:view :highlighting] nil)
(assoc-in [:view :pane] :annotations)))))
;; rects update the draft locally; the draft's save (put-group) persists them.
(rf/reg-event-db ::add-script-rect
(fn [db [_ gid page rect]]
(update-in db [:scene :groups gid :script] (fnil conj []) {:page page :rect rect})))
(rf/reg-event-db ::set-playhead (rf/reg-event-db ::set-playhead
(fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf))) (fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf)))
(rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p))) (rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p)))

View file

@ -11,6 +11,9 @@
(rf/reg-sub ::projects (fn [db] (:projects db))) (rf/reg-sub ::projects (fn [db] (:projects db)))
(rf/reg-sub ::project (fn [db] (:project db))) (rf/reg-sub ::project (fn [db] (:project db)))
(rf/reg-sub ::clip-url (fn [db] (get-in db [:project :clip]))) (rf/reg-sub ::clip-url (fn [db] (get-in db [:project :clip])))
(rf/reg-sub ::script-url (fn [db] (get-in db [:project :script])))
(rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations)))
(rf/reg-sub ::highlighting (fn [db] (get-in db [:view :highlighting])))
(rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails]))) (rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails])))
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status]))) (rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
(rf/reg-sub ::create-error (fn [db] (:create-error db))) (rf/reg-sub ::create-error (fn [db] (:create-error db)))
@ -81,6 +84,7 @@
:content (:content g) :children (count (:marks g)) :content (:content g) :children (count (:marks g))
:nested (get nested gid 0) :nested (get nested gid 0)
:draft (boolean (:draft g)) :draft (boolean (:draft g))
:script (:script g) ; pdf highlights linked to this group
:broken (boolean reason) :reason reason :broken (boolean reason) :reason reason
:start (or (ffirst bars) 0) :bars bars})))) :start (or (ffirst bars) 0) :bars bars}))))
(sort-by (juxt :broken :start)) ; broken annotations sink to the bottom (sort-by (juxt :broken :start)) ; broken annotations sink to the bottom

View file

@ -290,9 +290,11 @@
(doall (doall
(for [a anns] (for [a anns]
^{:key (:id a)} ^{:key (:id a)}
[:div.ann {:id (str "ann-" (name (:id a))) :class (when (= (:id a) active) "active")} [:div.ann {:id (str "ann-" (name (:id a)))
:class (when (= (:id a) active) "active")}
[:div.ann-head [:div.ann-head
[:div.ann-title (when (:broken a) [:span {:title (:reason a)} "⚠ "]) (:name a)] [:div.ann-title (when (:broken a) [:span {:title (:reason a)} "⚠ "]) (:name a)
(when (seq (:script a)) [:span.script-badge {:title "linked to script"} "¶"])]
[:div.ann-actions [:div.ann-actions
[jump-control open a] [jump-control open a]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
@ -356,6 +358,10 @@
(when (= pt :new) (when (= pt :new)
[:div.mark-row [:div.pt-input.active [:div.pt-text "click a clip…"]]]) [:div.mark-row [:div.pt-input.active [:div.pt-text "click a clip…"]]])
[:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."] [:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."]
[:div.form-marks-label "Script"]
[:button.add-mark {:on-click #(rf/dispatch [::events/start-highlighting gid])}
(str "+ Add script annotation"
(when-let [n (seq (:script d))] (str " (" (count n) ")")))]
[:div.form-actions [:div.form-actions
[:button.save {:disabled (or (str/blank? (:name d)) (empty? (:marks d))) [:button.save {:disabled (or (str/blank? (:name d)) (empty? (:marks d)))
:on-click #(put (dissoc d :draft))} "Save"] :on-click #(put (dissoc d :draft))} "Save"]
@ -399,15 +405,127 @@
(.addEventListener js/document "pointermove" move) (.addEventListener js/document "pointermove" move)
(.addEventListener js/document "pointerup" up))) (.addEventListener js/document "pointerup" up)))
;; --- script pane (PDF) ----------------------------------------------------
;; A highlight is a normalized rect on a page, stored on an annotation group's
;; :script — so the group's timeline range and its script passage are linked by
;; living in the same mark-group. Drag on a page to add one to the selected
;; annotation; click a highlight to jump the timeline to that annotation.
(def ^:private pdf-scale 1.4)
(defonce ^:private pdf-drag (r/atom nil)) ; {:page :x0 :y0 :x1 :y1} (normalized) while dragging
(defn- render-page! [^js pg ^js canvas]
(when (and pg canvas)
(let [^js vp (.getViewport pg #js {:scale pdf-scale})]
(set! (.-width canvas) (.-width vp))
(set! (.-height canvas) (.-height vp))
(.render pg #js {:canvasContext (.getContext canvas "2d") :viewport vp}))))
(defn- begin-drag! [pnum selected ev]
(when selected
(.preventDefault ev)
(let [box (.getBoundingClientRect (.-currentTarget ev))
nx #(/ (- % (.-left box)) (.-width box))
ny #(/ (- % (.-top box)) (.-height box))
x0 (nx (.-clientX ev)) y0 (ny (.-clientY ev))]
(reset! pdf-drag {:page pnum :x0 x0 :y0 y0 :x1 x0 :y1 y0})
(letfn [(move [e] (swap! pdf-drag assoc :x1 (nx (.-clientX e)) :y1 (ny (.-clientY e))))
(up [_] (.removeEventListener js/document "mousemove" move)
(.removeEventListener js/document "mouseup" up)
(let [{:keys [x0 y0 x1 y1]} @pdf-drag
rect [(min x0 x1) (min y0 y1) (max x0 x1) (max y0 y1)]]
(reset! pdf-drag nil)
(when (> (* (- (rect 2) (rect 0)) (- (rect 3) (rect 1))) 0.0004)
(rf/dispatch [::events/add-script-rect selected pnum rect]))))]
(.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up)))))
(defn- pdf-page [pnum pg w h anns armed]
[:div.pdf-page {:style {:width w :height h}
:class (when armed "armed")
:on-mouse-down #(begin-drag! pnum armed %)}
[:canvas {:ref (fn [c] (when c (render-page! pg c)))}]
(for [a anns
[i {:keys [page rect]}] (map-indexed vector (:script a))
:when (= page pnum)]
^{:key (str (:id a) "-" i)}
(let [[x0 y0 x1 y1] rect]
[:div.script-hl {:title (:name a) :data-ann (name (:id a))
:on-mouse-down #(.stopPropagation %)
:on-click #(goto! (:start a)) ; jump the timeline to this annotation
:style {:left (* x0 w) :top (* y0 h)
:width (* (- x1 x0) w) :height (* (- y1 y0) h)
:background (str (:color a) "44") :border (str "1.5px solid " (:color a))}}]))
(when-let [{:keys [page x0 y0 x1 y1]} @pdf-drag]
(when (= page pnum)
[:div.script-hl.drafting {:style {:left (* (min x0 x1) w) :top (* (min y0 y1) h)
:width (* (js/Math.abs (- x1 x0)) w)
:height (* (js/Math.abs (- y1 y0)) h)}}]))])
(defn script-pane []
(let [pages (r/atom (sorted-map)) loaded (atom nil) el (atom nil) scrolled (atom nil)]
(fn []
(let [url @(rf/subscribe [::subs/script-url])
anns @(rf/subscribe [::subs/annotations])
hl @(rf/subscribe [::subs/highlighting])
active @(rf/subscribe [::subs/active-annotation])
armed (:gid hl)
hl-name (some #(when (= (:id %) armed) (:name %)) anns)]
(when (and url (exists? js/pdfjsLib) (not= url @loaded))
(reset! loaded url) (reset! pages (sorted-map))
(-> (.-promise ^js (.getDocument js/pdfjsLib url))
(.then (fn [^js pdf]
(doseq [n (range 1 (inc (.-numPages pdf)))]
(-> (.getPage pdf n)
(.then (fn [^js pg]
(let [^js vp (.getViewport pg #js {:scale pdf-scale})]
(swap! pages assoc n {:pg pg :w (.-width vp) :h (.-height vp)}))))))))
(.catch (fn [e] (js/console.error "pdf load failed" e)))))
;; follow the playhead: scroll to the active annotation's first highlight
(r/after-render
(fn [] (when (and @el active (not= active @scrolled))
(when-let [node (.querySelector @el (str "[data-ann='" (name active) "']"))]
(reset! scrolled active)
(.scrollIntoView node #js {:block "center" :behavior "smooth"})))))
[:div.script-pane
(if armed
[:div.script-hint.arming
[:span "Highlighting for “" hl-name "” — drag on a page to add."]
[:span.hl-actions
[:button.hl-ok {:title "Done" :on-click #(rf/dispatch [::events/finish-highlighting])} "✓"]
[:button.hl-cancel {:title "Cancel" :on-click #(rf/dispatch [::events/cancel-highlighting])} "✕"]]]
[:div.script-hint
(if url "Playing follows the script to each note. Edit an annotation → “Add script annotation” to link a passage."
"No script uploaded for this project.")])
[:div.script-pages {:ref #(reset! el %)}
(if (seq @pages)
(doall (for [[n {:keys [pg w h]}] @pages]
^{:key n} [pdf-page n pg w h anns armed]))
(when url [:div.script-empty "Loading script…"]))]]))))
(defn annot-pane []
(let [pane @(rf/subscribe [::subs/pane])
draft? (some? @(rf/subscribe [::subs/draft-group]))]
[:div.pane-wrap
[:div.pane-tabs
[:button {:class (when (= pane :annotations) "active")
:on-click #(rf/dispatch [::events/set-pane :annotations])} "Annotations"]
[:button {:class (when (= pane :script) "active")
:on-click #(rf/dispatch [::events/set-pane :script])} "Script"]]
[:div.pane-body
(if (= pane :script)
[script-pane]
(if draft? [annotation-form] [commentary]))]]))
(defn editor [] (defn editor []
(let [status @(rf/subscribe [::subs/status]) (let [status @(rf/subscribe [::subs/status])
draft? (some? @(rf/subscribe [::subs/draft-group]))
proj @(rf/subscribe [::subs/project]) proj @(rf/subscribe [::subs/project])
authed? @(rf/subscribe [::subs/authed?])
thumb-status @(rf/subscribe [::subs/thumbnail-status])] thumb-status @(rf/subscribe [::subs/thumbnail-status])]
[:div.app [:div.app
[:div.top [:div.top
[:div.video-pane [video-monitor] [frame-readout]] [:div.video-pane [video-monitor] [frame-readout]]
[:div.annot-pane (if draft? [annotation-form] [commentary])]] [:div.annot-pane [annot-pane]]]
[:div.divider-h {:on-pointer-down drag-top!}] [:div.divider-h {:on-pointer-down drag-top!}]
[:div.timeline-pane [:div.timeline-pane
[:div.project-bar [:div.project-bar
@ -416,7 +534,11 @@
(when (#{"pending" "running"} thumb-status) (when (#{"pending" "running"} thumb-status)
[:span.muted "thumbnails processing"]) [:span.muted "thumbnails processing"])
(when (= "failed" thumb-status) (when (= "failed" thumb-status)
[:span.err "thumbnail generation failed"])] [:span.err "thumbnail generation failed"])
(when authed?
[:a.logout-link {:href "#" :on-click #(do (.preventDefault %)
(rf/dispatch [::events/logout]))}
"log out"])]
[breadcrumbs] [breadcrumbs]
[toolbar] [toolbar]
[:div.timeline-scroll [:div.timeline-scroll