Project create/list API, clip uploads, CORS, public read

- Project gains a clip FileField; POST /api/projects/ creates one from an
  uploaded otio + clip (<=100 MB), owned by the requester
- add /api/me/ and project-detail; scene GET is public (view without login),
  PUT stays owner/collaborator-only
- serve media in DEBUG (range requests for video seek); CORS for the cljs dev
  origin with credentials
- seed_demo now builds the Challengers project from the bundled otio + 480p clip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-29 01:39:54 -04:00
parent 60fb4df795
commit 94475da936
9 changed files with 114 additions and 41 deletions

View file

@ -1,3 +1,4 @@
asgiref==3.11.1 asgiref==3.11.1
Django==6.0.6 Django==6.0.6
django-cors-headers==4.9.0
sqlparse==0.5.5 sqlparse==0.5.5

View file

@ -10,6 +10,8 @@ class ProjectAdmin(admin.ModelAdmin):
search_fields = ("name",) search_fields = ("name",)
filter_horizontal = ("collaborators",) filter_horizontal = ("collaborators",)
readonly_fields = ("created", "updated") readonly_fields = ("created", "updated")
fields = ("name", "owner", "collaborators", "otio", "clip", "fps", "scene",
"created", "updated")
@admin.register(Revision) @admin.register(Revision)

View file

@ -8,11 +8,13 @@ from django.core.management.base import BaseCommand
from scenes.models import Project 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): 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): def handle(self, *args, **opts):
User = get_user_model() User = get_user_model()
@ -22,10 +24,14 @@ class Command(BaseCommand):
return return
project, created = Project.objects.get_or_create( 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: if OTIO.exists() and not project.otio:
with OTIO.open("rb") as fh: 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" 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)})")

View file

@ -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/'),
),
]

View file

@ -15,6 +15,7 @@ class Project(models.Model):
related_name="shared_projects") related_name="shared_projects")
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)
fps = models.FloatField(default=NTSC_24) fps = models.FloatField(default=NTSC_24)
scene = models.JSONField(default=dict, blank=True) scene = models.JSONField(default=dict, blank=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)

View file

@ -3,8 +3,9 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path("me/", views.me),
path("projects/", views.projects), path("projects/", views.projects),
path("projects/<int:pk>/", views.project),
path("projects/<int:pk>/scene/", views.scene), path("projects/<int:pk>/scene/", views.scene),
path("projects/<int:pk>/otio/", views.otio),
path("projects/<int:pk>/revisions/", views.revisions), path("projects/<int:pk>/revisions/", views.revisions),
] ]

View file

@ -1,7 +1,7 @@
import json import json
from django.db.models import Q 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.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt 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 .attribution import annotation_layer, apply_attribution
from .models import Project, Revision from .models import Project, Revision
MAX_CLIP_BYTES = 100 * 1024 * 1024
def _auth(request): def _auth(request):
"""None if logged in, else a 401 JSON response (so fetch() doesn't get a """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() 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) 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): 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)): if (resp := _auth(request)):
return resp return resp
rows = _visible(request).values("id", "name", "fps", "updated", "owner__username") if request.method == "GET":
return JsonResponse(list(rows), safe=False) 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/<pk>/ — 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 @csrf_exempt # dev convenience: session-auth API without a CSRF token round-trip
def scene(request, pk): def scene(request, pk):
"""GET/PUT /api/projects/<pk>/scene/ — read or replace the timeline scene.""" """GET (public) the scene; PUT (owner/collaborator) merges a delta into it."""
if (resp := _auth(request)):
return resp
project = _owned(request, pk)
if request.method == "GET": 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 request.method == "PUT":
if (resp := _auth(request)):
return resp
p = _editable(request, pk)
# A delta, not the whole scene: {changed: {gid: annotation}, deleted: [gid]}. # A delta, not the whole scene: {changed: {gid: annotation}, deleted: [gid]}.
# Merging per-id means two users editing different annotations both land # Merging per-id means two users editing different annotations both land
# (no clobber); same-annotation edits are last-write-wins by arrival. # (no clobber); same-annotation edits are last-write-wins by arrival.
@ -53,23 +93,21 @@ def scene(request, pk):
deleted = data.get("deleted") or [] deleted = data.get("deleted") or []
summary = None summary = None
if changed or deleted: if changed or deleted:
groups = dict((project.scene or {}).get("groups", {})) groups = dict((p.scene or {}).get("groups", {}))
groups.update(changed) groups.update(changed)
for gid in deleted: for gid in deleted:
groups.pop(gid, None) groups.pop(gid, None)
merged = dict(project.scene or {}, groups=groups) merged = dict(p.scene or {}, groups=groups)
project.scene, summary, counts = apply_attribution( p.scene, summary, counts = apply_attribution(
project.scene, merged, request.user.get_username(), p.scene, merged, request.user.get_username(), timezone.now().isoformat())
timezone.now().isoformat())
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=project, user=request.user, Revision.objects.create(project=p, user=request.user, summary=summary,
summary=summary, annotations=annotation_layer(p.scene))
annotations=annotation_layer(project.scene))
if "fps" in data: if "fps" in data:
project.fps = data["fps"] p.fps = data["fps"]
project.save(update_fields=["scene", "fps", "updated"]) p.save(update_fields=["scene", "fps", "updated"])
return JsonResponse({"ok": True, "updated": project.updated, "summary": summary, return JsonResponse({"ok": True, "updated": p.updated, "summary": summary,
"annotations": annotation_layer(project.scene)}) "annotations": annotation_layer(p.scene)})
return HttpResponseNotAllowed(["GET", "PUT"]) return HttpResponseNotAllowed(["GET", "PUT"])
@ -77,16 +115,6 @@ def revisions(request, pk):
"""GET /api/projects/<pk>/revisions/ — the save history (who/when/what).""" """GET /api/projects/<pk>/revisions/ — the save history (who/when/what)."""
if (resp := _auth(request)): if (resp := _auth(request)):
return resp return resp
project = _owned(request, pk) p = _editable(request, pk)
rows = project.revisions.values("id", "user__username", "created", "summary") rows = p.revisions.values("id", "user__username", "created", "summary")
return JsonResponse(list(rows), safe=False) return JsonResponse(list(rows), safe=False)
def otio(request, pk):
"""GET /api/projects/<pk>/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")

View file

@ -37,10 +37,12 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'corsheaders',
'scenes', 'scenes',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
@ -117,8 +119,16 @@ USE_TZ = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
# Uploaded OTIO files # Uploaded OTIO + clip files
MEDIA_URL = 'media/' MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / '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' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View file

@ -1,4 +1,6 @@
"""URL configuration for the scene_analysis backend.""" """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.contrib import admin
from django.urls import include, path from django.urls import include, path
@ -6,3 +8,7 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/", include("scenes.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)