From d3c8dcbdf2aa5a4a0cc48a06ba7ecdce59694ed0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 10:52:46 -0400 Subject: [PATCH] Script pane, realtime peer sync, and logout button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scenes/consumers.py | 22 ++++ scenes/migrations/0006_project_script.py | 18 ++++ scenes/models.py | 1 + scenes/routing.py | 7 ++ scenes/views.py | 12 +++ server/asgi.py | 19 +++- server/settings.py | 7 ++ tl/resources/public/css/app.css | 25 +++++ tl/resources/public/index.html | 9 ++ tl/src/tl/api.cljs | 17 +++ tl/src/tl/events.cljs | 45 +++++++- tl/src/tl/subs.cljs | 4 + tl/src/tl/views.cljs | 132 ++++++++++++++++++++++- 13 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 scenes/consumers.py create mode 100644 scenes/migrations/0006_project_script.py create mode 100644 scenes/routing.py diff --git a/scenes/consumers.py b/scenes/consumers.py new file mode 100644 index 0000000..ad17413 --- /dev/null +++ b/scenes/consumers.py @@ -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'])) diff --git a/scenes/migrations/0006_project_script.py b/scenes/migrations/0006_project_script.py new file mode 100644 index 0000000..ef18ef8 --- /dev/null +++ b/scenes/migrations/0006_project_script.py @@ -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/'), + ), + ] diff --git a/scenes/models.py b/scenes/models.py index 219b714..6f2d23a 100644 --- a/scenes/models.py +++ b/scenes/models.py @@ -16,6 +16,7 @@ class Project(models.Model): name = models.CharField(max_length=200) otio = models.FileField(upload_to="otio/", 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) thumbnail_status = models.CharField(max_length=20, default="missing") thumbnail_manifest = models.JSONField(default=dict, blank=True) diff --git a/scenes/routing.py b/scenes/routing.py new file mode 100644 index 0000000..837e2f0 --- /dev/null +++ b/scenes/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .consumers import SceneConsumer + +websocket_urlpatterns = [ + path('ws/projects//', SceneConsumer.as_asgi()), +] diff --git a/scenes/views.py b/scenes/views.py index 9907760..def00a1 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -1,5 +1,7 @@ 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.db import transaction from django.db.models import Q @@ -53,6 +55,7 @@ def _meta(request, project): "owner": project.owner.get_username(), "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, + "script": request.build_absolute_uri(project.script.url) if project.script else None, "thumbnail_status": project.thumbnail_status, "thumbnail_error": project.thumbnail_error, "thumbnails": thumbnails or None, @@ -150,6 +153,15 @@ def scene(request, pk): if any(counts.values()): # don't log a no-op save Revision.objects.create(project=p, user=request.user, summary=summary, 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: p.fps = data["fps"] p.save(update_fields=["scene", "fps", "updated"]) diff --git a/server/asgi.py b/server/asgi.py index 2c8b7fc..c45fb1e 100644 --- a/server/asgi.py +++ b/server/asgi.py @@ -1,10 +1,8 @@ """ ASGI config for server project. -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +HTTP is handled by Django as usual; websocket connections are routed to the +scene consumers so peers receive live scene deltas. """ import os @@ -13,4 +11,15 @@ from django.core.asgi import get_asgi_application 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)), +}) diff --git a/server/settings.py b/server/settings.py index 33b9225..4ca24b7 100644 --- a/server/settings.py +++ b/server/settings.py @@ -31,6 +31,8 @@ ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'testserver'] # Application definition INSTALLED_APPS = [ + 'daphne', # ASGI server; must precede staticfiles so runserver uses it + 'channels', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -54,6 +56,11 @@ MIDDLEWARE = [ 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 = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index ef15297..cb4011e 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -308,3 +308,28 @@ body { overflow: hidden; } .project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px; background: #111; border-bottom: 1px solid #222; font-size: 12px; } .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; } diff --git a/tl/resources/public/index.html b/tl/resources/public/index.html index 5b0f759..f13a2b5 100644 --- a/tl/resources/public/index.html +++ b/tl/resources/public/index.html @@ -14,6 +14,15 @@ + + + +