Represent tracing media as symbols and add pool thumbnails
This commit is contained in:
parent
551d572347
commit
17e1b4f403
49 changed files with 1777 additions and 864 deletions
|
|
@ -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",
|
||||
|
|
|
|||
48
clips/migrations/0015_tracing_images.py
Normal file
48
clips/migrations/0015_tracing_images.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue