Serve the document from a Django backend, split into three tiers

Step 9. The tier split was the work; Django was the easy half.

Tier 1 — the authored scene — is the document, and it is addressed as
independently versioned leaves rather than saved whole, so one vertex drag
cannot clobber a collaborator's keying. `domain/leaf` is the document as
path -> value; `domain/wire` puts it on the wire as transit, because JSON
has neither integer map keys nor keywords and a save would quietly turn
`{0 v}` into `{"0" v}`.

Tier 2 — the dense channel blocks — is content-addressed by a hash over
every input, with the detector version inside every key through the
analysis the block descriptor names. `flow/address`'s `block-knobs` is the
invalidation table, and `address-test` does not trust it: it re-freezes the
take once per knob and asserts the biconditional, that a block's bytes
changed if and only if its key changed. That found `brow-pos` not depending
on `contour-avg` — the brow ring is smoothed, the raise is not.

Tier 3 — frames and audio — is served by the hash of its bytes out of the
same store. A manifest now names frames and carries a URL for each, so the
frame layout stopped being a shared secret between a shell script and a
ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's
name is the hash of its contents, so a stale copy is not a thing that can
happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an
asset the project owns, not an extraction that churns.

The server verifies rather than trusting a name it was handed: it
recomputes every key from the descriptor stored beside it, refuses an
analysis that declares no detector version, and refuses a document naming
blocks it does not hold. It hashes the descriptor TEXT, because JS prints
an integral double as `1` and Python as `1.0`, and a scheme where both ends
re-render the numbers disagrees on the first parameter that happens to be
whole.

Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each re-derived a feature from
an index — every track names the feature it follows, which deleted five
hand-maintained mappings. And `:dev-http` is gone: Django serves the page,
shadow-cljs only builds into the staticfiles tree.

227 CLJS tests, 31 Django tests, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-28 01:11:41 -04:00
parent b6517f837a
commit 9cd5243983
61 changed files with 4694 additions and 269 deletions

View file

@ -93,7 +93,18 @@ frames where that feature is occluded and later reappears:
:eye-l {:id :eye-l :subject :face-1 :area :eye
:nodes [:eye-l :eye-l-in :iris-l :pupil-l] :params {}}
:mouth {:id :mouth :subject :face-1 :area :mouth
:nodes [:mouth :mouth-in :teeth] :params {}}}
:nodes [:mouth :mouth-in] :params {}}
;; The teeth are their OWN feature and not three nodes of the mouth. A feature
;; carries the params of exactly one area, and the teeth have an `:area :teeth`
;; of their own — the otsu threshold, the tongue rejection, the radial contour's
;; vertex budget — which could not be reached if they were part of `:mouth`.
;; The coupling that made them look like the mouth's is real and is enforced
;; elsewhere: `:teeth` is STENCILLED by `:mouth-in`, and a node whose stencil drew
;; nothing is dropped, so an absent mouth takes the teeth with it without either
;; of them sharing an absence mask. An earlier draft of this block listed them
;; together; the code is right and this document was wrong.
:teeth {:id :teeth :subject :face-1 :area :teeth
:nodes [:teeth] :params {}}}
:groups
{:eyes-1 {:id :eyes-1 :kind :eye-pair :subject :face-1
:members [:eye-r :eye-l] :params {}}}

View file

@ -527,6 +527,102 @@ becomes addressable as a path; and two people keying different frames of one par
merge field-wise with no merge algorithm at all. `selectKeys` returns an array
today — change it before anything depends on the order.
### Serving tiers 2 and 3
Built at step 9. Above this point the tiers are a rule about what is allowed on
the wire; this is the shape that enforces it.
**One store for both, named by the sha256 of the bytes.** Once tier 3 is decoded
by the app rather than by a shell script it becomes the same kind of thing as tier
2 — a cache with a hash — so there is one place that writes bytes, one that reads
them, and one URL shape:
```
GET /blob/<sha256> raw bytes, Cache-Control: immutable
```
`immutable` is not optimism there, it is the definition: the name IS the hash of
the content, so a cached copy cannot be stale. That is what makes serving six
hundred frames out of it cheap enough to do on every load.
**Two kinds of hash, and they are not the same hash.** A blob is named by the hash
of its BYTES, which is what makes an identical frame in two extractions one file.
A derived thing — an analysis artifact, a dense block — is named by a hash over its
INPUTS, which is what lets a client ask for the block the current settings want
*before* anything has computed it, and what makes a stale bake unreachable rather
than wrong. So a `Block` row has both: `key` over the inputs, and a foreign key to
the blob whose digest is over the bytes. Conflating them would break the half of
addressing that answers questions about work not yet done.
```
POST /api/analyses {key, descriptor} idempotent
POST /api/blocks/missing {keys} -> {missing}
POST /api/blocks {key, descriptor, data, state}
GET /api/blocks/<key>
GET /api/footage/<id> the manifest, with a URL per frame
```
**The server verifies, rather than trusting a name it was handed.** It recomputes
`sha256(descriptor)` for every key and refuses a mismatch; it refuses an analysis
whose descriptor does not declare a detector and a version; it refuses a block
whose analysis it does not know; and it refuses a document naming blocks it does
not hold. The chain from a stored block to the model version that produced it
therefore cannot be broken by a client that skipped a step — which is what the
cache-key rule above actually requires, as opposed to recommends.
It hashes the descriptor TEXT rather than re-rendering it from parsed values, and
that is not a shortcut. JS prints an integral double as `1` and Python prints
`1.0`, so a scheme where both ends re-render the numbers disagrees on the first
parameter whose value happens to be whole — and the failure is an upload that 409s
with nothing wrong. The bytes are the contract; the schema on top of them is a
convention, and the two fields the server reads out of that schema are checked
separately.
**A manifest names frames; it does not locate them.** Until step 9 the client
fetched `manifest.json` and built `frames/0001.png` itself, which made the frame
layout a shared secret between a shell script and a ClojureScript namespace. The
manifest now carries a URL per frame, so the frames can live in the blob store —
or, when wasm-ffmpeg extraction arrives, be uploaded into the same store by the
app — and the client learns nothing new when that happens. The producer changes;
the shape does not.
**The document stores what a block IS, not what it holds.** A block's element type
is in its own descriptor, which is the only place it is written down: an
`Int16Array` and a `Float32Array` over the same bytes are both valid readings, and
only one of them is the block. That makes the descriptor load-bearing rather than
documentation, which is the right way round for the thing a key is the hash of.
### Leaf paths, as built
The list under **Make the merge unit small instead of clever** is the design; this
is what step 9 implemented, for the subset that exists:
```
clip/<cid>/name clip/<cid>/subject/<sid>
clip/<cid>/timing clip/<cid>/feature/<fid>
clip/<cid>/stage clip/<cid>/group/<gid>
clip/<cid>/source clip/<cid>/node/<nid>
clip/<cid>/measured/<nid> clip/<cid>/channel/<nid>/<prop>
```
Two departures from the design above, both because step 8 moved settings.
`params/:area` is **not** a leaf. That path came from a draft where params were one
blob per clip, and two people tuning teeth and eyes collided on every slider move.
Settings now live on the subject, the feature and the group, and a feature has
exactly one area — so the feature leaf already *is* the area-scoped leaf, and
splitting it again would separate a feature's params from its identity.
`measured/<nid>` is one leaf holding several channels, which contradicts "every
channel gets its own". `:head`'s measured channels are not authored: a freeze
writes them together and a re-freeze replaces them together, and `head-mode` reads
them to write `:channels`. A leaf per measured channel would offer a write nobody
can make.
A leaf path is "/"-delimited and an id is one segment of it, so a namespaced id —
`:eye-r/iris`, as drawn under **The node, decomposed** — is written `eye-r~iris`,
and `~` is then refused inside a name. That is the whole of the escaping.
## Collaboration
`../tl` already has the model, and it is the right one to copy:

View file

@ -2,16 +2,20 @@
Self-contained. You should not need any prior conversation to execute this.
**Implementation status (2026-09-27):** steps 0–7 are in the CLJS frontend.
Step 6 reads extracted footage from the manifest, detects landmarks with local
MediaPipe assets at full source cadence, and runs the same freeze path as the
synthetic take. The scene time map can sample the frozen roto at a lower picture
fps without changing source analysis, duration or audio. Step 7 adds dense
eyelids, shared gaze, brows and pixel-derived teeth. Step 8's data model now
has stable feature identity, feature-level presence, explicit eye pairs and
shared parameter definitions. A manifest can now supply known feature absence
intervals through measurement and freeze. Automatic per-feature detection,
parameter editing and scoped regeneration remain.
**Implementation status (2026-09-28):** steps 0–9 are in. Step 6 reads extracted
footage, detects landmarks with local MediaPipe assets at full source cadence, and
runs the same freeze path as the synthetic take. The scene time map can sample the
frozen roto at a lower picture fps without changing source analysis, duration or
audio. Step 7 adds dense eyelids, shared gaze, brows and pixel-derived teeth.
Step 8's data model has stable feature identity, feature-level presence, explicit
eye pairs and shared parameter definitions; a manifest can supply known feature
absence intervals through measurement and freeze. Step 9 adds the Django backend,
the three-tier split, content-addressed tier 2 with the detector version inside
every key, leaf addressing for tier 1, and project load/save that round-trips.
**Still open.** Step 8's parameter UI and scoped regeneration, and automatic
per-feature detection. Everything under "Out, and do not build it" below, which
step 9 did not touch.
## What arthur is
@ -59,17 +63,40 @@ arthur/
src/arthur/** namespace root stays arthur.* whatever the dir is called
test/arthur/**
static/arthur/js/ shadow-cljs output, collected by Django staticfiles
static/arthur/audio.wav the synthetic take's clock. NOT extract.sh's output —
that is tier 3 and lives in the blob store
var/blobs/ the content-addressed blob store: tiers 2 and 3. Gitignored
docs/
js/ index.html serve.py extract.sh the old tool — see "the oracle"
```
The namespaces step 9 added, since the list under **Namespaces** in
`docs/architecture.md` predates them:
```
domain/sha256.cljs SHA-256, synchronous and pure, byte-compatible with hashlib
domain/canon.cljs the one canonical text for a descriptor, so hashing it means
something
domain/leaf.cljs leaf addressing: the document as path -> value
domain/wire.cljs transit for tier 1, base64 for tier 2
domain/project.cljs clip <-> the document and blocks that travel
flow/address.cljs tier-2 keys, and the invalidation table they are built from
fx/http.cljs the only namespace that talks to the server
events/project.cljs save and open
```
`clips` is a naming call, not a constraint — it is the Django app holding
Project, Clip, Footage, Analysis, Leaf and Revision. Rename in one line if
something fits better.
Dev runs two processes: Django serves the page, `shadow-cljs watch app` rebuilds
into `static/arthur/js`. Set `:output-dir "../static/arthur/js"` in
`shadow-cljs.edn`.
Dev runs two processes and they do not talk to each other: Django serves the page,
`shadow-cljs watch app` rebuilds into `static/arthur/js`, which is already
`:output-dir` in `shadow-cljs.edn`. `:dev-http` is gone.
```sh
mise exec -- python manage.py runserver 8778 # from the repo root
cd frontend && mise exec -- npx shadow-cljs watch app
```
## Toolchain
@ -352,10 +379,50 @@ group. Retain source measurements so a setting change can regenerate affected
channels without re-detecting footage. Time-varying parameter values and all
parameter controls are deferred to the UI pass.
### 9 — backend
Django project, the `clips` app, models for Project/Clip/Footage/Analysis/Leaf,
and project load/save. Round-tripping a project through the server is the proof
the model serialises.
### 9 — backend — DONE
Django project, the `clips` app, models for
Project/Clip/Footage/FootageFrame/Analysis/Block/Leaf/Revision/Blob, and project
load/save. Round-tripping a project through the server is the proof the model
serialises.
Django was the easy half; the tier split was the work. What it came to:
**Tier 2 keys are content addresses over every input**, and the detector version
is in every one of them, through the analysis id that each block descriptor names.
`flow/address`'s `block-knobs` is the invalidation table, and it is not trusted:
`address-test` re-freezes the take once per knob and asserts the biconditional —
a block's bytes changed if and only if its key changed. That test found two
things reading the code would not have. `brow-pos` does not depend on
`contour-avg`, because the brow RING is smoothed and the raise is not. And the
first version of the test was itself wrong: a 3% perturbation of `gaze-gain`
moves every sample inside the grid cell `quantize-snap` had already rounded it
into, so the bytes came out identical and the knob looked like an input the block
did not have.
**The server verifies what it is handed.** It recomputes every key from the
descriptor stored beside it and refuses a mismatch, refuses an analysis that does
not declare a detector version, and refuses a document naming blocks it does not
hold. It hashes the descriptor TEXT rather than re-rendering it from parsed
values, because JS prints an integral double as `1` and Python as `1.0` — a
scheme where both sides re-render breaks on the first parameter whose value
happens to be whole.
**Tier 3 is served by hash.** `extract.sh` still decodes; `manage.py
ingest_bundle` hashes the result into the blob store, by hard link. The manifest
the client receives now carries a URL per frame, so the frame layout stopped being
a shared secret between a shell script and a ClojureScript namespace. The
cache-busting `?v=` on every frame URL went with it: a blob's name is the hash of
its bytes, so a stale copy is not a thing that can happen.
**Leaf addressing exists**, with conditional writes and a monotonic project
version, so the sync design has nothing to retrofit. The socket, presence and
broadcasts are still out of scope.
Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each derived a feature from an index —
every track names the feature it follows, which deleted five hand-maintained
mappings and handed `flow/address` the same list for its observation digest. And
the `presence-check` binding in a `let` nobody read is now an ordinary `doseq`.
**Output is not in this plan.** The `.take` writer in `js/take.js` was for
driving an Animator Pro render script and it is not where this is going: the