diff --git a/requirements.txt b/requirements.txt index df5f001..9810cde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ asgiref==3.11.1 Django==6.0.6 +django-cors-headers==4.9.0 sqlparse==0.5.5 diff --git a/scenes/admin.py b/scenes/admin.py index 25aae7f..baa7f93 100644 --- a/scenes/admin.py +++ b/scenes/admin.py @@ -10,6 +10,8 @@ class ProjectAdmin(admin.ModelAdmin): search_fields = ("name",) filter_horizontal = ("collaborators",) readonly_fields = ("created", "updated") + fields = ("name", "owner", "collaborators", "otio", "clip", "fps", "scene", + "created", "updated") @admin.register(Revision) diff --git a/scenes/management/commands/seed_demo.py b/scenes/management/commands/seed_demo.py index e7774a2..49ccdb8 100644 --- a/scenes/management/commands/seed_demo.py +++ b/scenes/management/commands/seed_demo.py @@ -8,11 +8,13 @@ from django.core.management.base import BaseCommand from scenes.models import Project -OTIO = settings.BASE_DIR / "tl" / "resources" / "public" / "one_two_three.otio" +PUBLIC = settings.BASE_DIR / "tl" / "resources" / "public" +OTIO = PUBLIC / "one_two_three.otio" +CLIP = PUBLIC / "challengers_480p.mp4" class Command(BaseCommand): - help = "Seed a demo project from the bundled OTIO file." + help = "Seed the demo Challengers project from the bundled OTIO + clip." def handle(self, *args, **opts): User = get_user_model() @@ -22,10 +24,14 @@ class Command(BaseCommand): return project, created = Project.objects.get_or_create( - owner=owner, name="one two three", defaults={"scene": {}}) + owner=owner, name="Challengers", defaults={"scene": {}}) if OTIO.exists() and not project.otio: with OTIO.open("rb") as fh: - project.otio.save(Path(OTIO).name, File(fh), save=True) + project.otio.save(OTIO.name, File(fh), save=True) + if CLIP.exists() and not project.clip: + with CLIP.open("rb") as fh: + project.clip.save(CLIP.name, File(fh), save=True) verb = "created" if created else "exists" - self.stdout.write(f"project #{project.pk} {verb}: {project.name} (otio={bool(project.otio)})") + self.stdout.write(f"project #{project.pk} {verb}: {project.name} " + f"(otio={bool(project.otio)} clip={bool(project.clip)})") diff --git a/scenes/migrations/0004_project_clip.py b/scenes/migrations/0004_project_clip.py new file mode 100644 index 0000000..a02a15e --- /dev/null +++ b/scenes/migrations/0004_project_clip.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-29 05:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scenes', '0003_project_collaborators'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='clip', + field=models.FileField(blank=True, null=True, upload_to='clips/'), + ), + ] diff --git a/scenes/models.py b/scenes/models.py index 49f74de..b3a1c74 100644 --- a/scenes/models.py +++ b/scenes/models.py @@ -15,6 +15,7 @@ class Project(models.Model): related_name="shared_projects") 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) fps = models.FloatField(default=NTSC_24) scene = models.JSONField(default=dict, blank=True) created = models.DateTimeField(auto_now_add=True) diff --git a/scenes/urls.py b/scenes/urls.py index cf2b311..6b76988 100644 --- a/scenes/urls.py +++ b/scenes/urls.py @@ -3,8 +3,9 @@ from django.urls import path from . import views urlpatterns = [ + path("me/", views.me), path("projects/", views.projects), + path("projects//", views.project), path("projects//scene/", views.scene), - path("projects//otio/", views.otio), path("projects//revisions/", views.revisions), ] diff --git a/scenes/views.py b/scenes/views.py index 19360d1..f7aaed2 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -1,7 +1,7 @@ import json from django.db.models import Q -from django.http import FileResponse, HttpResponseNotAllowed, JsonResponse +from django.http import HttpResponseNotAllowed, JsonResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from django.views.decorators.csrf import csrf_exempt @@ -9,6 +9,8 @@ from django.views.decorators.csrf import csrf_exempt from .attribution import annotation_layer, apply_attribution from .models import Project, Revision +MAX_CLIP_BYTES = 100 * 1024 * 1024 + def _auth(request): """None if logged in, else a 401 JSON response (so fetch() doesn't get a @@ -24,27 +26,65 @@ def _visible(request): Q(owner=request.user) | Q(collaborators=request.user)).distinct() -def _owned(request, pk): +def _editable(request, pk): + """The project if the user may edit it (owner/collaborator), else 404.""" return get_object_or_404(_visible(request), pk=pk) +def _meta(request, project): + return { + "id": project.pk, + "name": project.name, + "fps": project.fps, + "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, + } + + +def me(request): + """GET /api/me/ — who the browser is logged in as (for the UI to gate edits).""" + u = request.user + return JsonResponse({"authenticated": u.is_authenticated, + "username": u.get_username() if u.is_authenticated else None}) + + +@csrf_exempt def projects(request): - """GET /api/projects/ — the projects the user owns or collaborates on.""" + """GET — the user's projects; POST — create one from an uploaded otio + clip.""" if (resp := _auth(request)): return resp - rows = _visible(request).values("id", "name", "fps", "updated", "owner__username") - return JsonResponse(list(rows), safe=False) + if request.method == "GET": + rows = list(_visible(request).values("id", "name", "fps", "updated", "owner__username")) + return JsonResponse(rows, safe=False) + if request.method == "POST": + name = (request.POST.get("name") or "").strip() + otio = request.FILES.get("otio") + clip = request.FILES.get("clip") + if not (name and otio and clip): + return JsonResponse({"detail": "name, otio and clip are required"}, status=400) + if clip.size > MAX_CLIP_BYTES: + return JsonResponse({"detail": "clip exceeds 100 MB"}, status=400) + project = Project.objects.create(owner=request.user, name=name, otio=otio, clip=clip) + return JsonResponse(_meta(request, project), status=201) + return HttpResponseNotAllowed(["GET", "POST"]) + + +def project(request, pk): + """GET /api/projects// — project metadata + media URLs (public to view).""" + return JsonResponse(_meta(request, get_object_or_404(Project, pk=pk))) @csrf_exempt # dev convenience: session-auth API without a CSRF token round-trip def scene(request, pk): - """GET/PUT /api/projects//scene/ — read or replace the timeline scene.""" - if (resp := _auth(request)): - return resp - project = _owned(request, pk) + """GET (public) the scene; PUT (owner/collaborator) merges a delta into it.""" if request.method == "GET": - return JsonResponse({"fps": project.fps, "scene": project.scene}) + p = get_object_or_404(Project, pk=pk) + return JsonResponse({"fps": p.fps, "scene": p.scene}) if request.method == "PUT": + if (resp := _auth(request)): + return resp + p = _editable(request, pk) # A delta, not the whole scene: {changed: {gid: annotation}, deleted: [gid]}. # Merging per-id means two users editing different annotations both land # (no clobber); same-annotation edits are last-write-wins by arrival. @@ -53,23 +93,21 @@ def scene(request, pk): deleted = data.get("deleted") or [] summary = None if changed or deleted: - groups = dict((project.scene or {}).get("groups", {})) + groups = dict((p.scene or {}).get("groups", {})) groups.update(changed) for gid in deleted: groups.pop(gid, None) - merged = dict(project.scene or {}, groups=groups) - project.scene, summary, counts = apply_attribution( - project.scene, merged, request.user.get_username(), - timezone.now().isoformat()) + merged = dict(p.scene or {}, groups=groups) + p.scene, summary, counts = apply_attribution( + p.scene, merged, request.user.get_username(), timezone.now().isoformat()) if any(counts.values()): # don't log a no-op save - Revision.objects.create(project=project, user=request.user, - summary=summary, - annotations=annotation_layer(project.scene)) + Revision.objects.create(project=p, user=request.user, summary=summary, + annotations=annotation_layer(p.scene)) if "fps" in data: - project.fps = data["fps"] - project.save(update_fields=["scene", "fps", "updated"]) - return JsonResponse({"ok": True, "updated": project.updated, "summary": summary, - "annotations": annotation_layer(project.scene)}) + p.fps = data["fps"] + p.save(update_fields=["scene", "fps", "updated"]) + return JsonResponse({"ok": True, "updated": p.updated, "summary": summary, + "annotations": annotation_layer(p.scene)}) return HttpResponseNotAllowed(["GET", "PUT"]) @@ -77,16 +115,6 @@ def revisions(request, pk): """GET /api/projects//revisions/ — the save history (who/when/what).""" if (resp := _auth(request)): return resp - project = _owned(request, pk) - rows = project.revisions.values("id", "user__username", "created", "summary") + p = _editable(request, pk) + rows = p.revisions.values("id", "user__username", "created", "summary") return JsonResponse(list(rows), safe=False) - - -def otio(request, pk): - """GET /api/projects//otio/ — serve the uploaded OTIO file.""" - if (resp := _auth(request)): - return resp - project = _owned(request, pk) - if not project.otio: - return JsonResponse({"detail": "no otio uploaded"}, status=404) - return FileResponse(project.otio.open("rb"), content_type="application/json") diff --git a/server/settings.py b/server/settings.py index ccd9ec6..33b9225 100644 --- a/server/settings.py +++ b/server/settings.py @@ -37,10 +37,12 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'corsheaders', 'scenes', ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -117,8 +119,16 @@ USE_TZ = True STATIC_URL = 'static/' -# Uploaded OTIO files +# Uploaded OTIO + clip files MEDIA_URL = 'media/' MEDIA_ROOT = BASE_DIR / 'media' +# Allow clip uploads up to ~100 MB (files stream to a temp file past 2.5 MB). +DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024 + +# The shadow-cljs dev frontend talks to this API cross-origin, with the session +# cookie, so allow its origin + credentials. +CORS_ALLOWED_ORIGINS = ['http://localhost:8280', 'http://127.0.0.1:8280'] +CORS_ALLOW_CREDENTIALS = True + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/server/urls.py b/server/urls.py index 69fb2d6..fcb15a1 100644 --- a/server/urls.py +++ b/server/urls.py @@ -1,4 +1,6 @@ """URL configuration for the scene_analysis backend.""" +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path @@ -6,3 +8,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("api/", include("scenes.urls")), ] + +# Serve uploaded OTIO/clip files in development (supports HTTP range → video seek). +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)