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:
parent
595ac5ba72
commit
d3c8dcbdf2
13 changed files with 307 additions and 11 deletions
22
scenes/consumers.py
Normal file
22
scenes/consumers.py
Normal 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']))
|
||||||
18
scenes/migrations/0006_project_script.py
Normal file
18
scenes/migrations/0006_project_script.py
Normal 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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
7
scenes/routing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .consumers import SceneConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path('ws/projects/<int:pk>/', SceneConsumer.as_asgi()),
|
||||||
|
]
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue