Make the media pool a list you can actually read

Every row is one line now — a thumbnail at the stage's own 16:10, the name,
and the one number you need before dragging it somewhere — at the timeline's
own row height. Two always-open folders cost eight headings before the first
row in a pane 210px wide; they are two tabs, and a search counts its hits in
the scope you are not looking at so nothing can hide behind the one you did
not pick.

Symbols draw their own first frame, through the resolver and rasteriser the
stage uses. A PLACED symbol is drawn where it is placed, with the rest of its
host isolated away: rooting at a symbol renders its DRAWING, and a rotoscoped
face is head-local in units of one image height, so the source-to-stage scale
that makes it pixels lives on the :face group of whatever places it. Rendered
rooted at itself a face is correct and under a pixel across. Thumbnails are
smoothly downscaled for the same kind of reason the preview is not: at a tenth
of the stage's size nearest neighbour samples one pixel in a hundred, and the
silhouette is the whole of what makes a thumbnail recognisable.

Names are editable, and that is two operations behind one pencil. A symbol's
name is a field of this document, so it is an undoable edit and blank gives it
back its id. Footage and sounds live beside projects rather than inside one,
so theirs is a server write shared by every project using the row — PATCH on
the existing Footage.label, and a new Sound.label kept separate from the
filename, which is a fact about the upload and not somebody's name for it.

No TIMELINES section beside a SYMBOLS one: every symbol here IS a timeline, so
that pair named one thing twice. What is true is that exactly one of them is
where the work happens, and clip/opens-on already answers which — it leads the
pane as PROJECT, drawn at a size you can read a pose off, with the library
under it. Still not :main being special; rename it or place it inside
something else and the pool follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-01 00:56:39 -04:00
parent 815ce449ea
commit 7e34d0c704
11 changed files with 924 additions and 160 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-01 04:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clips', '0011_occurrence_schema'),
]
operations = [
migrations.AddField(
model_name='sound',
name='label',
field=models.CharField(blank=True, help_text='what a person called it; the filename when empty. Separate from `filename` because the name on disk is a fact about the upload and renaming must not rewrite it', max_length=200),
),
]

View file

@ -60,6 +60,12 @@ class Sound(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
blob = models.ForeignKey(Blob, on_delete=models.PROTECT, related_name="sound_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. Separate "
"from `filename` because the name on disk is a fact about the "
"upload and renaming must not rewrite it",
)
duration = models.FloatField(help_text="seconds, as ffprobe reports it")
created = models.DateTimeField(auto_now_add=True)

View file

@ -657,6 +657,64 @@ class FootageTests(TestCase):
with self.assertRaisesMessage(CommandError, "refusing an inaccurate footage"):
call_command("ingest_bundle", str(root), stdout=StringIO())
def test_footage_can_be_renamed_and_falls_back_when_cleared(self):
"""A LABEL IS THE ONE FIELD A CLIENT MAY WRITE ON FOOTAGE. The rest is a
description of bytes that are content-addressed and immutable, so a
rename that could reach `frames` or `digest` would let the pool's name
for a clip contradict the clip."""
footage = self.ingest(self.bundle())
self.assertEqual("IMG_8608.MOV", self.client.get(
f"/api/footage/{footage.id}").json()["label"])
renamed = self.client.patch(f"/api/footage/{footage.id}",
json.dumps({"label": " the long take "}),
content_type="application/json")
self.assertEqual(200, renamed.status_code, renamed.content)
self.assertEqual("the long take", renamed.json()["label"])
# On the row, so every project listing this footage sees the new name.
footage.refresh_from_db()
self.assertEqual("the long take", footage.label)
self.assertEqual("the long take",
self.client.get("/api/footage").json()["footage"][0]["label"])
# Cleared gives back the name it was ingested under rather than nothing.
cleared = self.client.patch(f"/api/footage/{footage.id}",
json.dumps({"label": ""}),
content_type="application/json")
self.assertEqual("IMG_8608.MOV", cleared.json()["label"])
self.assertEqual(3, Footage.objects.get().frames)
def test_a_rename_that_names_no_label_is_refused(self):
footage = self.ingest(self.bundle())
refused = self.client.patch(f"/api/footage/{footage.id}",
json.dumps({"frames": 900}),
content_type="application/json")
self.assertEqual(400, refused.status_code)
self.assertEqual("a rename needs a label", refused.json()["error"])
self.assertEqual(3, Footage.objects.get().frames)
def test_a_sound_is_renamed_without_losing_the_name_it_arrived_as(self):
"""`filename` is a fact about the upload and `label` is what a person
called it, which is why renaming does not write over the first one."""
digest, size = blobs.write_stream([b"RIFF....WAVEfmt "])
blob = Blob.objects.create(digest=digest, size=size, media_type="audio/wav")
sound = Sound.objects.create(blob=blob, filename="rec0012.wav", duration=2.5)
renamed = self.client.patch(f"/api/sounds/{sound.id}",
json.dumps({"label": "arthur, line 4"}),
content_type="application/json")
self.assertEqual(200, renamed.status_code, renamed.content)
self.assertEqual("arthur, line 4", renamed.json()["label"])
self.assertEqual("rec0012.wav", renamed.json()["filename"])
sound.refresh_from_db()
self.assertEqual("rec0012.wav", sound.filename)
self.assertEqual("arthur, line 4", sound.label)
self.client.patch(f"/api/sounds/{sound.id}", json.dumps({"label": " "}),
content_type="application/json")
self.assertEqual("rec0012.wav",
self.client.get(f"/api/sounds/{sound.id}").json()["label"])
def test_the_footage_list_does_not_carry_every_url(self):
# A list of takes should not be a list of six hundred URLs each.
self.ingest(self.bundle())

View file

@ -226,10 +226,31 @@ def sources(request):
def _sound_json(row):
return {"id": str(row.id), "label": row.filename, "duration": row.duration,
return {"id": str(row.id), "label": row.label or row.filename,
"filename": row.filename, "duration": row.duration,
"audio": f"/blob/{row.blob_id}"}
def _relabel(request, row):
"""PATCH one asset's display name.
A LABEL IS THE ONLY FIELD EITHER ROW LETS A CLIENT WRITE, and the body is
read for that key alone. Footage is content-addressed and its frame count,
rate and digest are facts about the bytes; an endpoint that merged whatever
it was sent would let a rename quietly contradict them. Blank clears it,
which puts the row back to the name it was uploaded under rather than
leaving it nameless.
"""
data = _body(request)
if "label" not in data:
raise Bad("a rename needs a label")
label = str(data["label"] or "").strip()[:200]
if label != row.label:
row.label = label
row.save(update_fields=["label"])
return row
@require_http_methods(["GET", "POST"])
def sounds(request):
if request.method == "GET":
@ -252,12 +273,18 @@ def sounds(request):
return JsonResponse({"error": str(exc)}, status=400)
@require_http_methods(["GET"])
@require_http_methods(["GET", "PATCH"])
def sound_detail(request, sound_id):
try:
return JsonResponse(_sound_json(Sound.objects.get(id=sound_id)))
row = Sound.objects.get(id=sound_id)
except Sound.DoesNotExist:
return JsonResponse({"error": "no such sound"}, status=404)
try:
if request.method == "PATCH":
row = _relabel(request, row)
except Bad as exc:
return _error(exc)
return JsonResponse(_sound_json(row))
def _extraction_json(row):
@ -367,12 +394,17 @@ def symbols(request):
return JsonResponse({"symbols": rows})
@require_http_methods(["GET"])
@require_http_methods(["GET", "PATCH"])
def footage_detail(request, footage_id):
try:
footage = Footage.objects.select_related("audio", "video", "stream").get(id=footage_id)
except Footage.DoesNotExist:
return JsonResponse({"error": "no such footage"}, status=404)
try:
if request.method == "PATCH":
footage = _relabel(request, footage)
except Bad as exc:
return _error(exc)
return JsonResponse(_footage_json(footage))