arthur/server/settings.py
Olive Vaughn 9cd5243983 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>
2026-09-28 01:11:41 -04:00

122 lines
4.6 KiB
Python

"""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