Represent tracing media as symbols and add pool thumbnails

This commit is contained in:
Olive Vaughn 2026-10-04 00:02:12 -04:00
parent 551d572347
commit 17e1b4f403
49 changed files with 1777 additions and 864 deletions

View file

@ -161,6 +161,17 @@ def _extract_stills(job, proxy_path, frames_dir, frames, root):
MAX_RATE = 120 # a capture rate; past this the container is describing something else
def probe_image(path):
"""An uploaded still's pixel size as (width, height), refusing anything that
is not one picture."""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",
"-of", "json", str(path)]))
video = [s for s in data.get("streams", []) if s.get("codec_type") == "video"]
if len(video) != 1 or not (video[0].get("width") and video[0].get("height")):
raise ValueError("the uploaded file is not an image")
return int(video[0]["width"]), int(video[0]["height"])
def probe_audio(path):
"""The length of an uploaded sound in seconds, refusing a file with no audio."""
data = json.loads(_command(["ffprobe", "-v", "error", "-show_streams",

View file

@ -0,0 +1,48 @@
"""Schema 6: tracing is a symbol.
A face's `:head :trace` is gone: its footage is a placement of a `:type :trace`
symbol under the head, the trace keys are that placement's `:time :holds`, and the
head follows them with `:reads`. Nothing is converted. Every project is marked 6,
and one that still carries a `:trace` is refused when it is opened, by name and
with what to do about it; the rest open as they did.
Images are stills to trace over, stored like sounds.
"""
import uuid
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):
apps.get_model("clips", "Project").objects.update(schema_version=6)
class Migration(migrations.Migration):
dependencies = [("clips", "0014_multiple_analyses")]
operations = [
migrations.CreateModel(
name="Image",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False,
primary_key=True, serialize=False)),
("filename", models.CharField(max_length=255)),
("label", models.CharField(
blank=True, max_length=200,
help_text="what a person called it; the filename when empty")),
("width", models.PositiveIntegerField(help_text="pixels, as ffprobe reports them")),
("height", models.PositiveIntegerField()),
("created", models.DateTimeField(auto_now_add=True)),
("blob", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT,
related_name="image_for", to="clips.blob")),
],
),
migrations.AlterField(
model_name="project",
name="schema_version",
field=models.PositiveIntegerField(default=6),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
]

View file

@ -70,6 +70,21 @@ class Sound(models.Model):
created = models.DateTimeField(auto_now_add=True)
class Image(models.Model):
"""An uploaded still — a drawing, a photo, a model sheet — kept as uploaded, to
be traced over. Never part of the picture: a document names its blob as a
tracing symbol's `:media`, and the page draws it over the stage."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="image_for")
filename = models.CharField(max_length=255)
label = models.CharField(max_length=200, blank=True,
help_text="what a person called it; the filename when empty")
width = models.PositiveIntegerField(help_text="pixels, as ffprobe reports them")
height = models.PositiveIntegerField()
created = models.DateTimeField(auto_now_add=True)
class Extraction(models.Model):
"""One requested decode of a source into immutable footage."""
@ -235,7 +250,7 @@ class Project(models.Model):
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
)
name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=5)
schema_version = models.PositiveIntegerField(default=6)
seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True)

View file

@ -34,7 +34,7 @@ from django.core.management import call_command
from django.test import TestCase, override_settings
from clips import blobs, extraction
from clips.models import Analysis, Block, Blob, Clip, Footage, Leaf, Project, Revision, Sound, Source
from clips.models import Analysis, Block, Blob, Clip, Footage, Image, Leaf, Project, Revision, Sound, Source
BLOB_DIR = tempfile.mkdtemp(prefix="arthur-test-blobs-")
@ -446,7 +446,7 @@ class DocumentTests(TestCase):
self.assertEqual(5, len(response.json()["written"]))
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(5, loaded["schema_version"])
self.assertEqual(6, loaded["schema_version"])
self.assertEqual(1, len(loaded["clips"]))
clip = loaded["clips"][0]
self.assertEqual("c1", clip["cid"])
@ -828,6 +828,32 @@ class UploadTests(TestCase):
self.assertEqual(200, again.status_code)
self.assertEqual(1, Sound.objects.count())
def test_an_uploaded_still_is_an_image_named_by_its_bytes(self):
payload = png(17, 5)
uploaded = self.client.post("/api/images", {
"file": SimpleUploadedFile("sheet.png", payload, content_type="image/png")})
self.assertEqual(201, uploaded.status_code, uploaded.content)
image = uploaded.json()
self.assertEqual(("sheet.png", 17, 5), (image["label"], image["width"], image["height"]))
self.assertEqual(f"/blob/{image['digest']}", image["url"])
self.assertEqual(payload, b"".join(self.client.get(image["url"]).streaming_content))
self.assertEqual([image], self.client.get("/api/images").json()["images"])
again = self.client.post("/api/images", {
"file": SimpleUploadedFile("again.png", payload, content_type="image/png")})
self.assertEqual(200, again.status_code)
self.assertEqual(1, Image.objects.count())
renamed = self.client.patch(f"/api/images/{image['id']}",
json.dumps({"label": "model sheet"}),
content_type="application/json")
self.assertEqual("model sheet", renamed.json()["label"])
def test_a_file_that_is_not_a_picture_is_not_an_image(self):
refused = self.client.post("/api/images", {
"file": SimpleUploadedFile("notes.txt", b"not a picture", content_type="text/plain")})
self.assertEqual(400, refused.status_code)
def test_a_file_without_audio_is_not_a_sound(self):
refused = self.client.post("/api/sounds", {
"file": SimpleUploadedFile("notes.txt", b"not audio", content_type="text/plain")})

View file

@ -24,6 +24,8 @@ urlpatterns = [
path("sources", views.sources),
path("sounds", views.sounds),
path("sounds/<uuid:sound_id>", views.sound_detail),
path("images", views.images),
path("images/<uuid:image_id>", views.image_detail),
path("extractions", views.extractions),
path("extractions/<str:key>", views.extraction_detail),
path("footage", views.footage_list),

View file

@ -43,7 +43,7 @@ from django.views.decorators.http import require_http_methods
from . import blobs, extraction
from .consumers import broadcast
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Leaf, Project, Revision, Sound, Source
from .models import Analysis, Block, Blob, Clip, Extraction, Footage, Image, Leaf, Project, Revision, Sound, Source
KEY_LENGTH = 71 # "sha256:" + 64 hex
@ -287,6 +287,50 @@ def sound_detail(request, sound_id):
return JsonResponse(_sound_json(row))
def _image_json(row):
return {"id": str(row.id), "label": row.label or row.filename,
"filename": row.filename, "width": row.width, "height": row.height,
"digest": row.blob_id, "url": f"/blob/{row.blob_id}"}
@require_http_methods(["GET", "POST"])
def images(request):
"""Stills to trace over. The document names one by its blob digest, so an
image is the same picture in every project that uses it."""
if request.method == "GET":
return JsonResponse({"images": [_image_json(row)
for row in Image.objects.order_by("-created")]})
upload = request.FILES.get("file")
if upload is None:
return JsonResponse({"error": "upload an image as the file field"}, status=400)
try:
digest, size = blobs.write_stream(upload.chunks())
width, height = extraction.probe_image(blobs.path_for(digest))
blob, _ = Blob.objects.get_or_create(
digest=digest, defaults={"size": size,
"media_type": upload.content_type or "image/png"})
row, created = Image.objects.get_or_create(
blob=blob, defaults={"filename": Path(upload.name).name[:255],
"width": width, "height": height})
return JsonResponse(_image_json(row), status=201 if created else 200)
except (ValueError, OSError) as exc:
return JsonResponse({"error": str(exc)}, status=400)
@require_http_methods(["GET", "PATCH"])
def image_detail(request, image_id):
try:
row = Image.objects.get(id=image_id)
except Image.DoesNotExist:
return JsonResponse({"error": "no such image"}, status=404)
try:
if request.method == "PATCH":
row = _relabel(request, row)
except Bad as exc:
return _error(exc)
return JsonResponse(_image_json(row))
def _extraction_json(row):
return {"key": row.key, "source": str(row.source_id), "state": row.state,
"progress": row.progress, "error": row.error,