Add video upload, extraction progress, and reusable analysis sources

This commit is contained in:
Olive Vaughn 2026-09-28 09:38:49 -04:00
parent 690de21fa4
commit 686f897401
24 changed files with 927 additions and 137 deletions

View file

@ -27,15 +27,17 @@ import hashlib
import json
from functools import lru_cache
from pathlib import Path
from uuid import UUID
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from . import blobs
from .models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision
from . import blobs, extraction
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Source
KEY_LENGTH = 71 # "sha256:" + 64 hex
@ -155,8 +157,71 @@ def detector(request):
# THE MANIFEST NOW CARRIES URLS. It used to carry a directory and the loader built
# `frames/0001.png` itself, which quietly made the frame layout a shared secret
# between a shell script and a ClojureScript namespace. The server names every
# frame instead, so the frames can move into the blob store — or later be uploaded
# from the browser by wasm ffmpeg — without the client learning anything new.
# frame instead, so uploaded video and command-line bundles produce the same
# footage response without the client knowing where either stored its frames.
@require_http_methods(["GET", "POST"])
def sources(request):
if request.method == "GET":
return JsonResponse({"sources": [
{"id": str(row.id), "filename": row.filename, "probe": row.probe}
for row in Source.objects.order_by("-created")[:100]
]})
upload = request.FILES.get("file")
if upload is None:
return JsonResponse({"error": "upload a video as the file field"}, status=400)
try:
digest, size = blobs.write_stream(upload.chunks())
facts = extraction.probe(blobs.path_for(digest))
blob, _ = Blob.objects.get_or_create(
digest=digest, defaults={"size": size,
"media_type": upload.content_type or "video/mp4"})
row, created = Source.objects.get_or_create(
blob=blob, defaults={"filename": Path(upload.name).name[:255], "probe": facts})
return JsonResponse({"id": str(row.id), "digest": digest,
"filename": row.filename, "probe": row.probe,
"created": created}, status=201 if created else 200)
except (ValueError, OSError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
def _extraction_json(row):
return {"key": row.key, "source": str(row.source_id), "state": row.state,
"progress": row.progress, "error": row.error,
"footage": str(row.footage_id) if row.footage_id else None}
@require_http_methods(["POST"])
def extractions(request):
try:
data = _body(request)
source_id = data.get("source")
if not source_id:
raise Bad("an extraction needs a source id")
try:
source = Source.objects.get(id=UUID(str(source_id)))
except (ValueError, ValidationError, Source.DoesNotExist):
raise Bad("no such source", status=404)
settings = data.get("settings") or {}
if settings != {}:
raise Bad("extraction currently keeps the source frame rate; settings must be empty")
key = extraction.extraction_key(source, settings)
row, _ = Extraction.objects.get_or_create(
key=key, defaults={"source": source, "settings": settings})
if row.state != "done":
extraction.enqueue(key)
return JsonResponse(_extraction_json(row), status=202 if row.state != "done" else 200)
except Bad as exc:
return _error(exc)
@require_http_methods(["GET"])
def extraction_detail(request, key):
try:
return JsonResponse(_extraction_json(Extraction.objects.get(key=key)))
except Extraction.DoesNotExist:
return JsonResponse({"error": "no such extraction"}, status=404)
def _footage_json(footage: Footage, urls=True):
@ -254,6 +319,39 @@ def analyses(request):
return _error(exc)
@require_http_methods(["GET", "PUT"])
def analysis_detail(request, key):
try:
row = Analysis.objects.get(key=key)
except Analysis.DoesNotExist:
return JsonResponse({"error": "no such analysis"}, status=404)
if request.method == "GET":
return JsonResponse({
"key": row.key, "descriptor": row.descriptor,
"detector": row.detector, "version": row.version,
"footage": str(row.footage_id) if row.footage_id else None,
"source_blocks": sorted(row.source_blocks.values_list("key", flat=True)),
})
try:
keys = _body(request).get("source_blocks")
roles = {"source/dense", "source/detected", "source/crops"}
if not isinstance(keys, list) or len(keys) != len(roles) or len(set(keys)) != len(roles):
raise Bad("an analysis needs one block for each source role")
blocks = list(Block.objects.filter(key__in=keys))
if (len(blocks) != len(roles) or {b.role for b in blocks} != roles
or any(b.analysis_id != key for b in blocks)):
raise Bad("source blocks must have distinct source roles and name this analysis")
with transaction.atomic():
row = Analysis.objects.select_for_update().get(key=key)
current = set(row.source_blocks.values_list("key", flat=True))
if current and current != set(keys):
raise Bad("the source blocks of an analysis are immutable", status=409)
row.source_blocks.set(blocks)
return JsonResponse({"key": key, "source_blocks": sorted(keys)})
except Bad as exc:
return _error(exc)
@require_http_methods(["POST"])
def blocks_missing(request):
"""Which of these keys the server does not have.