Support multiple regeneratable footage analyses
This commit is contained in:
parent
5bcf22e458
commit
15deaea19e
20 changed files with 333 additions and 168 deletions
|
|
@ -18,7 +18,7 @@ class ProjectAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(Clip)
|
||||
class ClipAdmin(admin.ModelAdmin):
|
||||
list_display = ("cid", "project", "name", "footage", "analysis")
|
||||
list_display = ("cid", "project", "name")
|
||||
list_filter = ("project",)
|
||||
|
||||
|
||||
|
|
|
|||
15
clips/migrations/0014_multiple_analyses.py
Normal file
15
clips/migrations/0014_multiple_analyses.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("clips", "0013_palette_track")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="clip", name="analysis"),
|
||||
migrations.RemoveField(model_name="clip", name="footage"),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="schema_version",
|
||||
field=models.PositiveIntegerField(default=5),
|
||||
),
|
||||
]
|
||||
|
|
@ -235,7 +235,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=4)
|
||||
schema_version = models.PositiveIntegerField(default=5)
|
||||
seq = models.PositiveBigIntegerField(default=0)
|
||||
palette = models.CharField(max_length=64, default="arthur/default")
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
|
@ -273,12 +273,6 @@ class Clip(models.Model):
|
|||
cid = models.SlugField(max_length=64)
|
||||
name = models.CharField(max_length=200, blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
footage = models.ForeignKey(
|
||||
Footage, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips"
|
||||
)
|
||||
analysis = models.ForeignKey(
|
||||
Analysis, null=True, blank=True, on_delete=models.SET_NULL, related_name="clips"
|
||||
)
|
||||
blocks = models.ManyToManyField(
|
||||
Block, blank=True, related_name="clips",
|
||||
help_text="the tier-2 blocks this clip's channels name",
|
||||
|
|
|
|||
|
|
@ -396,14 +396,25 @@ class DocumentTests(TestCase):
|
|||
],
|
||||
}
|
||||
|
||||
def save(self, leaves=None, blocks=None):
|
||||
def save(self, leaves=None, blocks=None, analyses=None):
|
||||
return self.put(f"/api/projects/{self.project.id}", {
|
||||
"name": "a project",
|
||||
"clips": [{"cid": "c1", "name": "take", "analysis": self.analysis,
|
||||
"clips": [{"cid": "c1", "name": "take",
|
||||
"analyses": [self.analysis] if analyses is None else analyses,
|
||||
"leaves": leaves if leaves is not None else self.leaves(),
|
||||
"blocks": blocks if blocks is not None else [self.block]}],
|
||||
})
|
||||
|
||||
def test_a_clip_declares_the_registered_analyses_its_blocks_name(self):
|
||||
undeclared = self.save(analyses=[])
|
||||
self.assertEqual(409, undeclared.status_code)
|
||||
self.assertIn("every block", undeclared.json()["error"])
|
||||
|
||||
unknown = "sha256:" + "f" * 64
|
||||
missing = self.save(analyses=[self.analysis, unknown])
|
||||
self.assertEqual(409, missing.status_code)
|
||||
self.assertEqual([unknown], missing.json()["missing"])
|
||||
|
||||
def test_every_saved_symbol_is_listed_across_projects(self):
|
||||
leaves = self.leaves()
|
||||
leaves["clip/c1/symbol/sym~face"] = ["^ ", "~:name", "face", "~:frames", 12]
|
||||
|
|
@ -435,12 +446,12 @@ class DocumentTests(TestCase):
|
|||
self.assertEqual(5, len(response.json()["written"]))
|
||||
|
||||
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
|
||||
self.assertEqual(4, loaded["schema_version"])
|
||||
self.assertEqual(5, loaded["schema_version"])
|
||||
self.assertEqual(1, len(loaded["clips"]))
|
||||
clip = loaded["clips"][0]
|
||||
self.assertEqual("c1", clip["cid"])
|
||||
self.assertEqual([self.block], clip["blocks"])
|
||||
self.assertEqual(self.analysis, clip["analysis"])
|
||||
self.assertNotIn("analysis", clip)
|
||||
# The whole point: byte-identical values, including the integer frame keys
|
||||
# transit writes as "~i0". A JSON round trip that stringified them would
|
||||
# come back "0" and the part would hold its first pose forever.
|
||||
|
|
@ -542,7 +553,7 @@ class DocumentTests(TestCase):
|
|||
def patch(self, base, leaves, removed=()):
|
||||
return self.put(f"/api/projects/{self.project.id}", {
|
||||
"base": base,
|
||||
"clips": [{"cid": "c1", "analysis": self.analysis, "leaves": leaves,
|
||||
"clips": [{"cid": "c1", "analyses": [self.analysis], "leaves": leaves,
|
||||
"removed": list(removed), "blocks": [self.block]}],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -718,8 +718,6 @@ def _project_json(project: Project, user):
|
|||
{
|
||||
"cid": clip.cid,
|
||||
"name": clip.name,
|
||||
"footage": str(clip.footage_id) if clip.footage_id else None,
|
||||
"analysis": clip.analysis_id,
|
||||
"blocks": sorted(clip.blocks.values_list("key", flat=True)),
|
||||
"leaves": {leaf.path: leaf.value for leaf in leaves if leaf.path.startswith(prefix)},
|
||||
}
|
||||
|
|
@ -909,7 +907,19 @@ def _save(project: Project, data, user):
|
|||
)
|
||||
|
||||
keys = spec.get("blocks") or []
|
||||
have = set(Block.objects.filter(key__in=keys).values_list("key", flat=True))
|
||||
analyses = spec.get("analyses") or []
|
||||
if (not isinstance(analyses, list)
|
||||
or not all(isinstance(key, str) for key in analyses)
|
||||
or len(analyses) != len(set(analyses))):
|
||||
raise Bad("a clip's analyses must be a list of distinct analysis ids")
|
||||
registered = set(Analysis.objects.filter(key__in=analyses)
|
||||
.values_list("key", flat=True))
|
||||
if unknown := [key for key in analyses if key not in registered]:
|
||||
raise Bad("this clip names analyses the server does not know; register them first",
|
||||
status=409, missing=unknown)
|
||||
|
||||
block_rows = list(Block.objects.filter(key__in=keys))
|
||||
have = {block.key for block in block_rows}
|
||||
if missing := [k for k in keys if k not in have]:
|
||||
# Referential integrity across the tiers, enforced where it can be:
|
||||
# a document that names blocks the server does not hold would load
|
||||
|
|
@ -919,6 +929,9 @@ def _save(project: Project, data, user):
|
|||
"before saving the document that points at them",
|
||||
status=409, missing=missing,
|
||||
)
|
||||
if undeclared := sorted({block.analysis_id for block in block_rows} - registered):
|
||||
raise Bad("every block in a clip must name one of that clip's analyses",
|
||||
status=409, missing=undeclared)
|
||||
|
||||
existing = {leaf.path: leaf for leaf in project.leaves.filter(path__startswith=prefix)}
|
||||
if base is not None:
|
||||
|
|
@ -930,16 +943,12 @@ def _save(project: Project, data, user):
|
|||
if conflicts:
|
||||
continue
|
||||
|
||||
analysis = Analysis.objects.filter(key=spec.get("analysis")).first()
|
||||
footage = None
|
||||
if spec.get("footage"):
|
||||
footage = Footage.objects.filter(id=spec["footage"]).first()
|
||||
clip, _ = Clip.objects.update_or_create(
|
||||
project=project,
|
||||
cid=cid,
|
||||
defaults={"name": spec.get("name") or "", "analysis": analysis, "footage": footage},
|
||||
defaults={"name": spec.get("name") or ""},
|
||||
)
|
||||
blocks = Block.objects.filter(key__in=keys)
|
||||
blocks = block_rows
|
||||
if base is None:
|
||||
clip.blocks.set(blocks)
|
||||
gone = [path for path in existing if path not in leaves]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue