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
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
12
server/asgi.py
Normal file
12
server/asgi.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""ASGI entry point.
|
||||
|
||||
ASGI and not only WSGI because the collaboration design in docs/architecture.md
|
||||
puts presence and document deltas on a websocket. Those consumers are out of step
|
||||
9's scope; this is the half of their setup that costs nothing now.
|
||||
"""
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
|
||||
application = get_asgi_application()
|
||||
122
server/settings.py
Normal file
122
server/settings.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Settings for arthur's backend.
|
||||
|
||||
It serves three things and they are three different kinds of thing, which is most
|
||||
of what there is to know about this file:
|
||||
|
||||
THE PAGE. One template, which replaced `frontend/public/index.html` at step 9.
|
||||
It pulls the shadow-cljs bundle out of staticfiles, so `manage.py runserver` and
|
||||
`shadow-cljs watch app` are the whole dev loop with nothing copying files
|
||||
between them.
|
||||
|
||||
THE DOCUMENT (tier 1). Small, authored, in the database.
|
||||
|
||||
THE BLOBS (tiers 2 and 3). Large, immutable, content-addressed, on disk under
|
||||
`var/blobs`. Never in the database, and never in a migration.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Dev default. The deployment that needs a real one will set it, and until there
|
||||
# is a deployment, a checked-in placeholder that says so beats a checked-in secret
|
||||
# that does not.
|
||||
SECRET_KEY = "dev-only-not-a-secret-arthur"
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"clips",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "server.urls"
|
||||
WSGI_APPLICATION = "server.wsgi.application"
|
||||
ASGI_APPLICATION = "server.asgi.application"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# WAL and a real busy timeout, because a save is a BURST of writes: one analysis,
|
||||
# then a block per dense channel, then the leaves. Under the default rollback
|
||||
# journal and a 5-second timeout, the block uploads of one save fail with
|
||||
# "database is locked" — which surfaces in the page as a 500 with nothing wrong,
|
||||
# and in the browser suite as a document that will not save. WAL lets readers and
|
||||
# one writer proceed at once, and the timeout makes the writers queue instead of
|
||||
# giving up. The client serialises its uploads as well (see events/project), so
|
||||
# this is the belt to that braces.
|
||||
#
|
||||
# The ceiling this has is the one docs/architecture.md already names for tl's
|
||||
# process-local ROOMS: one writer. It is a single-worker arrangement, and moving
|
||||
# off it is a Postgres URL rather than a change to anything above this line.
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
"OPTIONS": {
|
||||
"timeout": 20,
|
||||
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
LANGUAGE_CODE = "en-gb"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# --- static -----------------------------------------------------------------
|
||||
#
|
||||
# Two roots, and the second is the interesting one. `static/` is where
|
||||
# shadow-cljs writes the bundle (`:output-dir "../static/arthur/js"`), so the
|
||||
# build lands straight in the staticfiles tree. The vendored MediaPipe is served
|
||||
# under the prefix `mediapipe/` from where it already lives in `frontend/public/`
|
||||
# — 26MB of wasm and model that has no business being copied into a second place
|
||||
# in the tree, and that `collectstatic` picks up from there.
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "static",
|
||||
("mediapipe", BASE_DIR / "frontend" / "public" / "mediapipe"),
|
||||
]
|
||||
STATIC_ROOT = BASE_DIR / "var" / "static"
|
||||
|
||||
# --- blobs ------------------------------------------------------------------
|
||||
#
|
||||
# Tiers 2 and 3 live here, named by the sha256 of their own bytes, fanned out two
|
||||
# levels so no directory holds a hundred thousand entries. Deliberately NOT under
|
||||
# `static/`: a blob is served by a view that can set immutable cache headers and
|
||||
# refuse a path that is not a hash, and `collectstatic` has no business walking
|
||||
# gigabytes of frames.
|
||||
BLOB_ROOT = BASE_DIR / "var" / "blobs"
|
||||
|
||||
# The port the browser suite expects by default, and not 8777, which is still the
|
||||
# old JS tool's under `serve.py`. Both are meant to run side by side.
|
||||
DEV_PORT = 8778
|
||||
16
server/urls.py
Normal file
16
server/urls.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
from clips import views
|
||||
|
||||
urlpatterns = [
|
||||
# `/index.html` as well as `/`, because that is the URL the browser suite has
|
||||
# used since step 5 — shadow-cljs's dev server did no directory-index
|
||||
# resolution, so the suite learned to ask for the file. Keeping both means
|
||||
# ARTHUR_URL can stay pointed at the same place and only the port moves.
|
||||
path("", views.page, name="page"),
|
||||
path("index.html", views.page),
|
||||
path("api/", include("clips.urls")),
|
||||
path("blob/<str:digest>", views.blob, name="blob"),
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
6
server/wsgi.py
Normal file
6
server/wsgi.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
|
||||
application = get_wsgi_application()
|
||||
Loading…
Add table
Add a link
Reference in a new issue