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:
parent
60fb4df795
commit
94475da936
9 changed files with 114 additions and 41 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)})")
|
||||||
|
|
|
||||||
18
scenes/migrations/0004_project_clip.py
Normal file
18
scenes/migrations/0004_project_clip.py
Normal 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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 request.method == "GET":
|
||||||
|
p = get_object_or_404(Project, pk=pk)
|
||||||
|
return JsonResponse({"fps": p.fps, "scene": p.scene})
|
||||||
|
if request.method == "PUT":
|
||||||
if (resp := _auth(request)):
|
if (resp := _auth(request)):
|
||||||
return resp
|
return resp
|
||||||
project = _owned(request, pk)
|
p = _editable(request, pk)
|
||||||
if request.method == "GET":
|
|
||||||
return JsonResponse({"fps": project.fps, "scene": project.scene})
|
|
||||||
if request.method == "PUT":
|
|
||||||
# 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")
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue