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:
parent
b6517f837a
commit
9cd5243983
61 changed files with 4694 additions and 269 deletions
|
|
@ -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 {}}}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue