Commit graph

175 commits

Author SHA1 Message Date
Olive Vaughn
277c0c3b63 Keep the timeline's ruler and playhead dot in view while its rows scroll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:22:01 -04:00
Olive Vaughn
c139e4d747 Rename the project from the top bar; export from a dialog
The title is a button that becomes a field, saved on Enter or blur. Export's
target and scale move out of the bar into a dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:12:08 -04:00
Olive Vaughn
0f9ce826ef Set the project's stage and rate, and give a symbol its own stage
The inspector edits the project's width, height and fps, and a symbol's length
and, optionally, its own width and height. A symbol without them follows the
project's (`clip/stage`); the stage, export and placement all read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:12:08 -04:00
Olive Vaughn
1ac21fdcab Brought-in footage plays at the project's rate
The new symbol gets project-rate frames covering the same wall-clock length,
and its root and sound map them back onto the source's frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:11:32 -04:00
Olive Vaughn
4b8123d5e2 Delete a timeline row with everything in it
Delete or Backspace, or the × on the selected row, takes the node out of its
symbol along with every node hanging off it. One edit, so undo brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:11:16 -04:00
Olive Vaughn
7246534505 Key a node's transform and visibility from the inspector
Each of vis, anchor, pos, rot, scale and skew is a row: ◆ keys the channel on
the node's own frame, or takes the key there off, and a typed value is written
on blur or Enter — a key on a keyed channel, the one value on one that is not.
Rotation reads in degrees. Dense and other channels keep their readout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:11:16 -04:00
Olive Vaughn
38850a29ba Slide rows along time and restack them, at any depth
A node's bar drags along the timeline: one write to its :at, for every node
alike, carried down through the instances above it. The stage and rows show
the slide live through ::render/clip, and it lands as one edit on release.

A row dropped on another's top or bottom edge goes in front of it or behind:
one write to :z, between its new neighbours (symbol/z-between). Onto the edge
of a row in another symbol, it moves there first. Neither needs anything on
screen — nest/down walks a row path by structure, without a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:51:34 -04:00
Olive Vaughn
1b8bbc7372 Edit shapes nested in other symbols, from any symbol above them
`nest/inside` walked a row path only through instances. Every node has
the same two maps to its parent, so the walk now steps into any node:
inside an instance is the symbol it places, inside a shape is where its
points and keys are. The selected shape at any depth is `inside` over
its full path, which gives the stage editor its handles (through the
matrix, drags back through the inverse) and the inspector the shape's
own frame to key at, and the time map back for jumping to a key.

`inside` resolves only the node's lineage: where a node is depends on
its parents and nothing else, and resolving the whole symbol cost more
than a stage frame (7.9ms against 3ms on the swarm; now 0.09ms).
Checked equal to the whole-symbol resolve on every node and frame of
the swarm, two instances deep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:12:21 -04:00
Olive Vaughn
6a53adb5e0 Projects live at URLs, have owners, and are edited together live
A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.

Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.

An empty symbol now survives the leaf round trip with `:nodes {}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:04:03 -04:00
Olive Vaughn
c17ee138f2 Split domain/clip along the data: clip, nest, bring
domain/clip is the document and what only needs the document: its symbol
table, placing, the resolver, problems. domain/nest is how nested symbols
relate — one walk down a row path gives the frame, the matrix and the time
map, which merges inside and time-down — and moving and grouping between
them, and nested sound. domain/bring is copying symbols in from another
clip, a take from footage, and one placed that merges the store and places
the instance, which conversion and import both now call instead of each
doing it in their own words. Tests follow the same split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:41 -04:00
Olive Vaughn
7bb80d315d Bring in symbols from other projects; new and open send the clock home
Dropping a symbol out of the pool's all-assets folder fetches that project's
clip and the blocks it names, copies the symbol and everything it places in
through clip/adopt — renamed where ids collide — and places it where it was
dropped. It comes in as drawing: its tracking stays with the analysis that
measured it. New and open now seek the audio clock to 0 with the readout,
where play used to pick up wherever the last document's audio had got to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:51:39 -04:00
Olive Vaughn
490460bf45 Drag timeline rows into symbols, into groups, and back out
A node's row can be dragged onto an instance's row, to go inside the symbol
it places; onto any other node's row, to be grouped with it into a new
symbol; or onto empty label space, to come back to the top of the open
symbol. All three keep the picture and the timing as they are, and a refused
move says why in the status line. Pool drop targets now only take things out
of the pool, and row drags set a move effect so the browser delivers the drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:50:34 -04:00
Olive Vaughn
eafbe6c4d2 One time map for every node; move and group nodes between symbols
Every node now has the same time map into its parent, local = rate·(parent
− at), with its span and keys in its own frames: what instances had, made the
rule. A shape without a time map reads as it always did, so no data changes.
The per-kind branches, the span-start term and the rate refusal are gone;
node/time-of, then-time and invert-time compose it like the matrix.

clip/move-node puts a node into another symbol without changing the picture
or the timing — its matrix becomes a :pinv, its time a new :at and :rate, and
its channels, keys and span are untouched — and clip/group makes a new symbol
around side-by-side nodes. Generated parts, split stencils, cycles and
looping instances are refused with the reason. Nested sounds use the same
map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:45:34 -04:00
Olive Vaughn
6bec121108 Draw into the selected symbol
With an instance selected, a finished polygon goes into the symbol it places,
its points and frame carried in through clip/inside — which resolves each
level, so the frame and matrix are the ones the stage draws with — so the
shape lands exactly where it was drawn however the instance is moved, turned,
scaled or retimed. Beside any other selected node, or at the top of the open
symbol with nothing selected. clip/inside also replaces frame-inside for new
symbols, so there is one account of 'which frame is it in there'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:35:06 -04:00
Olive Vaughn
1a42481575 An instance pivots about the middle of what its symbol draws
place-symbol sets a new instance's anchor to clip/center — the middle of the
bounds of everything the symbol draws over all its frames, or the stage's
middle for a symbol that draws nothing — and a stage drop puts that middle
under the pointer. The anchor is set once and never follows the symbol, as
Flash's transformation point and After Effects' anchor point do, so a symbol
that grows later moves nothing on screen. The drop preview marks the pivot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:32:52 -04:00
Olive Vaughn
41b4bdf110 Make a symbol from a dropped video
Dropping footage on the stage or the timeline asks which frames and what name:
a dialog plays the video with start and end handles that seek it. Detection
runs on that range only (decoding stops at the end, frames before the start
are skipped) and the range is part of the analysis address, so a partial
analysis is never served as a whole one. The frozen take comes in as one
named symbol, carrying its sound as an audio node, placed where it was
dropped; tracking and tuning come with it when the document has no analysis
of its own.

clip/adopt copies symbols between documents, renaming ids that collide, and
clip/audio-tracks carries sounds out of nested instances so a placed symbol
is heard where it is placed.

Fixes the stage going blank after a conversion: ::store recomputed only when
the clip id changed, so blocks merged into the loaded entry were invisible to
the resolver. And the paint loop now schedules its next frame before
painting and reports a frame it cannot draw instead of stopping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:21:08 -04:00
Olive Vaughn
270c5a4369 Version static URLs by mtime; flatter tabs
The stylesheet and bundle were served with Last-Modified only, so a browser
could pair new JavaScript with a stale app.css and render the tab strip and
thumbnails unstyled. Their URLs now carry the file's mtime. Tabs are flat on
the chrome with the open one underlined; the close button shows on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:08:38 -04:00
Olive Vaughn
f590ff19cf Drag symbols onto the stage or the timeline, with previews
A symbol dragged out of the pool shows where it would land: on the stage, a
dashed outline of its first frame centred on the pointer, and on the timeline
a preview row of its own length. A stage drop places it at the playhead under
the pointer; a timeline drop places it at the frame under the pointer, in its
own coordinates. A drop that would make a cycle is refused while hovering.
Pool thumbnails are capped at 40x30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:06:38 -04:00
Olive Vaughn
d5f044c6da Media pool: this project and all assets
This project lists every symbol in the document and the video it uses or was
given this session, each with a frame from its proxy as a thumbnail. All
assets lists every upload on the server and every other saved project's
symbols, from a new /api/symbols that reads symbol leaf paths. Every row is a
drag source (symbol:, import:, footage:). Uploading no longer runs straight
into detection; it lands in the project's media.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:59:17 -04:00
Olive Vaughn
c81f91c442 Tabs: open any symbol, and the clock follows it
Double-clicking a symbol in the pool, or an instance's row in the timeline,
opens it as a tab above the stage; the stage, the rows, the transport and new
shapes follow the open tab. Each tab gets a clock as long as it is: its own
mixed tracks, the document's audio for the symbol it opens on, or silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:54:58 -04:00
Olive Vaughn
6b41c6db94 An instance's span is in its own frames
A shape's :span stays in its parent's frames, but an instance's or a sound's
is now in its own: dropping a symbol at frame 97 gives it span 0 … length and
:at 97, so moving it along its parent is one write to :at. :time :in is gone
(it was the span's start written twice); node/placed-span maps an own-time
span out to the parent for playback, the mixer and the timeline rows, and
node/problems reports a stale :in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:53:21 -04:00
Olive Vaughn
2d2eb0fc9f New empty symbol, nested in the selected instance or the open symbol
'+ symbol' in the timeline makes an empty symbol and places it at the
playhead: inside the selected instance, beside any other selected node, or in
the open symbol. Timeline selections carry their row path so a symbol placed
twice nests into the row that was clicked. Symbols gain a :name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:49:46 -04:00
Olive Vaughn
5dff490162 Symbols, not timelines; no symbol is special
Everything that holds nodes is a symbol (domain/timeline -> domain/symbol,
:timelines -> :symbols) and a node that places one is :kind :instance. The
reserved :main root is gone: which symbol is on screen is editor state
([:ui :open]), every domain function that needs a symbol is told which, and
a document opens on the longest symbol nothing else places.

Saved projects move to schema 2 through migration 0007, which rewrites leaf
paths, instance kinds and the feature :symbol key; the client refuses a
schema it does not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:46:42 -04:00
Olive Vaughn
179770d7d4 Split the shell into topbar, pool, stage, timeline and params panes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:25:46 -04:00
Olive Vaughn
ddabfbeaa8 multi fce stuff 2026-09-29 02:34:53 -04:00
Olive Vaughn
49ece8dee6 Model head anchors and independent pose timing 2026-09-29 00:46:08 -04:00
Olive Vaughn
a45e89f4e4 Add polygon painting with per-gap drawing keys 2026-09-28 22:37:23 -04:00
Olive Vaughn
73ab153b02 Add project schema version 2026-09-28 21:03:18 -04:00
Olive Vaughn
e22ee600b9 Add PNG sequence export and uuid-keyed stage placements
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JBYKfeMPQTK1WNcgcw41o
2026-09-28 20:47:10 -04:00
Olive Vaughn
3058b9a5f2 Add scoped feature regeneration and retained pixel measurements 2026-09-28 16:31:27 -04:00
Olive Vaughn
39ee37db02 Animate seven stage symbols and bound playback memory 2026-09-28 15:29:31 -04:00
Olive Vaughn
ffb95543a3 Add instanced 8625 stage with independent audio controls 2026-09-28 15:05:04 -04:00
Olive Vaughn
a611b86c0d Stream block uploads and compress cached mouth crops 2026-09-28 14:37:09 -04:00
Olive Vaughn
65ad67c129 Decode uploaded footage in order with WebCodecs 2026-09-28 14:18:09 -04:00
Olive Vaughn
131b39bff0 Calibrate the video's frame origin instead of assuming it is zero
"the video never presented frame 1; it offered 2" was this function waiting
for a frame it had not asked for a second time. The walk discarded a wrong
frame and re-armed the callback WITHOUT seeking again, and nothing further is
ever presented to a paused element that has not been asked to move — so one
wrong answer starved until the timeout and reported it as the browser refusing.

Underneath that, the wrong answer was not wrong. A container can carry an edit
list, and `currentTime` then counts from the start of the edited presentation
while a frame's `mediaTime` counts from the start of the media. The two differ
by a constant, so the frame at currentTime 0 can honestly report a mediaTime
two frames in. Seeking cannot correct for it in the positive direction: source
frame 0 would have to be found before the start of the video, every attempt
clamps at zero, and the walk offers frame 2 forever.

So the constant is measured once and subtracted. `calibrate!` takes whatever
the browser calls the first frame it shows and makes that the origin, which is
the honest definition anyway — it is what a viewer sees at time zero, and the
audio clock this take plays against starts in the same place.

Calibration also leaves the element on frame 0, so the walk starts holding it.
`frame!` returns immediately for a frame already on screen rather than seeking
to where it already is, which presents nothing and would hang.

Residual error still re-seeks, corrected by exactly the measured miss, and
still fails loudly with every frame that was offered and where it was asked
from, so a next failure is diagnosable in one shot rather than four.

The proxy also drops B-frames now. That removes the edit list at the source
rather than only coping with it, and makes decode order presentation order
should this ever be fed to WebCodecs. 14% larger, and extraction refuses a
proxy whose timeline is shifted so it cannot come back silently. Honest note:
this was my first diagnosis and it did NOT reproduce the failure — both
proxies walk correctly here on hardware decode — so it is hardening, not the
fix.

Verified by forcing the fault: a harness that offsets the reported timeline by
-2, -1, 0, +1, +2 and +5 frames failed on every positive offset before and
recovers on all six now, first attempt. Real app in headed Chrome with Metal
hardware decode: 280/280. 41 backend and 234 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 12:50:11 -04:00
Olive Vaughn
44976cbb4b Stop refusing ordinary phone footage as variable-frame-rate
Uploading a clip shot straight from the iPhone camera app failed with
"variable-frame-rate video needs timestamp-aware playback". The file was not
variable: its container reports avg_frame_rate 8670/299 and nb_frames 289 over
a stream whose decoded timestamps are 280 frames exactly 1/30s apart. The
guard compared two pieces of container metadata and rejected CFR video on the
strength of a summary the container had got wrong about its own contents.

The guard was also obsolete. It dates from when the page measured the source's
own frames, where a wandering frame duration really does break
`frame = floor(t * fps)`. Nothing measures the source now — ffmpeg resamples it
onto a constant rate and the proxy is re-probed after it is written — so
variable input is a thing this converts rather than a thing it refuses.

So: probe picks a rate instead of validating one. It takes the nominal rate,
which is the rate every timestamp in the stream can be expressed at and so the
one that keeps every distinct source frame, and carries it as an exact fraction
because 30000/1001 is not a float and a rounded -r is how a long take drifts.
The disagreement is still recorded as `vfr`, just not fatal.

The frame-count cross-check went with it. It compared the proxy against the
source's nb_frames, which is the number this whole bug proves can lie, and a
resample to a constant rate legitimately changes the count. It now checks the
proxy's DURATION against the source's, because what must not drift is how long
the picture lasts against how long the audio lasts.

Verified on the reported file: 280 frames at 30fps, picture 9.3333s against
audio 9.3167s — half a frame — and 280/280 detected in the real app. 41 backend
tests green, including a genuinely variable fixture end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 12:37:21 -04:00
Olive Vaughn
83d106bbc5 Measure the video, not a PNG per frame
Detection now walks a browser-seekable H.264 proxy in MediaPipe's VIDEO
running mode. The PNG sequence it replaces was 112MB for 7.6 seconds at
1440x1920 and 1.1GB at the 900-frame limit; the proxy is 6MB, and landmarks
detected off decoded H.264 rather than off the PNGs moved at most 0.0033 of
frame width.

Three things had to be true for video mode to work, and each was measured
against the same footage decoded to PNGs:

/blob/<digest> answers byte ranges. Django's FileResponse does no Range
handling, and a media element handed 200 with no Accept-Ranges reports an
empty `seekable`, no-ops every currentTime write, and detects frame one
ninety times without raising.

A seek aims at the MIDDLE of its frame. Aiming at i/fps sits on a frame
boundary and landed one frame early 31 times in 91; (i + 0.5)/fps was exact
on all 91.

Timestamps are strictly increasing footage milliseconds. Video mode is a
tracker: a repeat leaves the graph in an error state every later call
re-throws, so the landmarker is discarded on failure, and passing the frame
index instead of i*1000/fps moved landmarks six times further from the
per-frame answer.

Frames are verified rather than trusted. requestVideoFrameCallback states
which frame it handed over, the walker discards any other and fails loudly
if the one it asked for never arrives — a stale presentation from the tail
of a previous seek is what produced "asked for frame 1 and it presented
frame 2" on a video whose seeks were in fact exact.

The proxy is re-encoded even when the upload is already H.264: HEVC is not
decodable everywhere, and footage identity is the proxy's digest. The JPEG
stills beside it are tracing references, outside the footage digest because
re-rendering them at another size is not different footage.

Verified end to end in a real browser against real footage: 228/228 frames
detected, a drawn roto face, 37 backend and 234 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 11:32:01 -04:00
Olive Vaughn
686f897401 Add video upload, extraction progress, and reusable analysis sources 2026-09-28 10:45:51 -04:00
Olive Vaughn
690de21fa4 Add Fly release image and local start command 2026-09-28 10:45:51 -04:00
Olive Vaughn
7249e73e7c Keep mouth crops independent of teeth settings 2026-09-28 09:06:30 -04:00
Olive Vaughn
7738c4e1c8 Align project paths with timeline model 2026-09-28 02:48:57 -04:00
Olive Vaughn
9778b9023b Split clips from timelines 2026-09-28 02:33:26 -04:00
Olive Vaughn
27bfe18bee Keep one invalidation table, and derive the inverse a UI wants
There were two answers in the tree to "which stored bytes stop being valid when
this knob moves", and only one of them was checked.

`flow/address/block-knobs` is per block and asserted by biconditional —
`address-test` re-freezes the take once per knob and requires that the bytes
changed if and only if the key did. `domain/params`'s `:affects` was per area,
had no caller but a test asserting it returned what it was written as, and was
already wrong in both directions on the one entry where the two granularities
disagree: `:aperture-cut` claimed `#{:mouth}`, where it reaches no block, and
omitted the teeth, whose contour bytes it genuinely moves by gating
`condition/interior`'s smoothing. `:blink-cut` claimed `#{:eye}` and reaches no
block either, because a blink is `[:vis]` keys in tier 1.

So `:affects` and `affected-areas` are gone, and `address/knob-roles` is the
derived inverse of the table that is asserted — which is what a parameter panel
actually wants to ask. A knob absent from it invalidates no block, and that is
an answer rather than a gap.

Two new assertions keep the derivation from rotting at either edge: every role
in the table is reachable from some knob, and every knob a block declares is one
the registry defines. The second closes a real hole — `block-descriptor` checks
only that a knob was PASSED, and the freeze's `merge take/knobs` makes that true
of anything spelled like a keyword, so a typo in `block-knobs` would have named a
setting no slider can move.

228 CLJS tests, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:23:26 -04:00
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
Olive Vaughn
b6517f837a Pin every dense track to the feature whose presence it follows
The absence mapping was asserted for two of the ten dense tracks. Each block
hands `pack` an :absent predicate that derives a feature from a track INDEX, so
the predicate and the vector literal beside it agree only by hand, in four
places — and three of the four blocks had nothing checking them.

Give each feature a DIFFERENT gap window, so a track wired to the wrong feature
shows up as absence inside somebody else's window. A single shared window passes
under any permutation, which is the failure port-plan mechanical fact #2 warns
about: swap left for right and every part is still roughly where it belongs, so
it survives inspection.

Verified by mutation, since a test that cannot fail is worth nothing. Swapping
the brow block's two tracks, and swapping eye-block tracks 1 and 3 while leaving
0 and 2 correct, both now fail loudly; neither was caught before.

Also pin the teeth. They are their own feature so they can carry their own
:area :teeth parameters, which means an occluded mouth sets no bit on them; they
are dropped regardless because they stencil on :mouth-in and scene/finish drops a
node whose stencil drew nothing. Both frames come from an unannotated reference
clip, because the interior only draws on an open mouth and a frame the mouth was
shut on would have passed for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 23:38:26 -04:00
Olive Vaughn
35ef150b48 Give features stable identity, eye pairs and per-feature presence
Step 8's data model, ahead of its controls. Nothing here is a UI.

domain/params holds every knob's definition once — default, applicable area,
value constraints and the areas a change would force to regenerate. flow/take's
literal knob map becomes a view of it, so the take's defaults and the future
parameter panel cannot drift apart.

domain/feature adds subjects, features and groups as document data the renderer
never reads. A feature ID is stable for the whole clip, across occlusion: a run
of visible frames is not a new identity. An eye pair is an explicit group of one
or two eyes of the same subject, so a profile view with one identified eye needs
no invented partner. Settings resolve area -> subject -> group -> feature, and
dropping an eye from a pair materialises its effective values first so playback
does not jump. scene/problems now validates all of it.

Presence becomes per-feature rather than per-subject. freeze's :absent predicate
takes a track as well as a frame, so one occluded eye can be absent while its
partner still has a value; a full-face miss still marks everything absent. A
manifest may annotate known gaps as one-based inclusive intervals, which ingest
expands into observation tracks before measurement. An unobserved eye then gets
no vote in the iris pairing and cannot steer the shared gaze — gaze falls back to
whichever eye is visible. Temporal filters still see a sample on every frame,
held from the last observed one, because the numbers are a rectangular buffer;
the state mask, not the buffer, is what says the frame has no value.

js/app.js gets the same occlusion lesson: leading nulls from a face that starts
occluded used to throw away the whole take, and the neutral frame could be chosen
from a held duplicate pose.

Parameter editing, scoped regeneration and a feature-level detector remain. Until
one exists, footage without annotations falls back to the full-face mask rather
than claiming occlusions it cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 22:44:36 -04:00
Olive Vaughn
ccca93e233 Check the mouth interior by its own tone, and stop teardown failing the run
Two bugs in the browser suite, both dating from step 7 adding eyes, brows and
teeth to the stage.

The shut-mouth check asked for exactly one tone on the whole canvas. That was
true when the stage held nothing but a mouth; since step 7 every frame carries
five or six tones whatever the mouth is doing, so the check failed on a correct
picture. Ask instead whether :mouth-dark is on the stage at all: nothing else
carries that tone, so it answers "is the interior drawn" without needing to know
where the mouth is. freeze_test already got this same rescoping; its browser twin
did not.

The failure went unnoticed because close() raced Chrome's own profile writes and
threw ENOTEMPTY out of main's `finally`, past the summary line and the
process.exit that reports the failure count. The suite therefore exited 2 and
printed no verdict whether it passed or failed. Teardown is now allowed to fail
out loud without taking the exit code with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 22:44:16 -04:00
Olive Vaughn
663b7c367a Add eyes brows and pixel-derived mouth interior to CLJS take 2026-09-27 19:48:42 -04:00
Olive Vaughn
06cf02db83 Preserve source cadence and sample picture fps after analysis 2026-09-27 19:31:39 -04:00
Olive Vaughn
32683efccf Port step 6: detect real footage with local MediaPipe 2026-09-27 19:13:20 -04:00