tools(pipeline): hybrid track.py + simulator label author mode

Content-pipeline Increment 2, part 2 (the tooling). Implements design §11.5
(docs/superpowers/specs/2026-06-24-content-pipeline-design.md):

- tools/pipeline/track.py — the stage-5 geometry pass. Classical path: a
  hand-seeded normalized box propagated by OpenCV Lucas-Kanade optical flow
  (CSRT used instead when a contrib build provides it), sampled to a SPARSE
  loop-normalized keyframed track + an appear/disappear window. Optional ML
  detect+track path lazy-imports ultralytics (clear ImportError if absent;
  base install needs only cv2). Pure helpers (normalize/denormalize, loop_t,
  infer_window, sample_track, track_to_annotation) are unit-tested; the cv2
  propagation is an opt-in integration test on real abyss_wow footage.
  Semantics are never produced here — geometry only.

- Author mode — /author.html + author.js/.css reuse the preview stage: pick a
  pool clip, scrub, drag a seed box, Run tracker (or Add as static box), author
  the LEFT detail tiers (general -> scientific+fact) + salience, shift-click to
  place affect anchors with RIGHT emotion tiers, and Save to manifest. Backend:
  POST /api/author/track (runs track_seed on the clip's base) + POST
  /api/author/clip (idempotent upsert via tools.pipeline.manifest — keeps media
  + provenance, replaces only authored content, reloads in place). The tracker
  propagates box geometry; all strings/scientific names/facts are hand-typed.

- Repointed the pipeline integration test off the retired forest base to the
  cosmos pool primary. USER_GUIDE simulator section brought current (pools,
  coast, 3-knob Mood, real-time dream, progressive tiers, author mode).

267 passed / 2 skipped (+ track pure + opt-in real-footage + author endpoint
tests). Author UI by-eye review deferred to the operator (no Chrome on this box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-24 18:14:32 -07:00
parent 12408e505e
commit 70fd367c70
10 changed files with 1023 additions and 48 deletions
+75
View File
@@ -8,6 +8,7 @@ endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
from __future__ import annotations
import hashlib
import json
import os
import random
import time
@@ -84,6 +85,29 @@ class RingAdvanceRequest(BaseModel):
delta: int
class AuthorTrackRequest(BaseModel):
"""Author mode: propagate a hand-seeded box on a clip into a keyframed track
(content-pipeline §11.5). `seed_box` + `seed_t` are normalized."""
clip_id: str
key: str
seed_box: list[float] = Field(min_length=4, max_length=4)
seed_t: float = Field(default=0.0, ge=0.0, le=1.0)
salience: int = Field(default=4, ge=1, le=4)
n_keyframes: int = Field(default=5, ge=2, le=20)
max_frames: Optional[int] = None
class AuthorClipRequest(BaseModel):
"""Author mode: persist the authored labels/affect/strings for an existing
pool clip into the manifest (geometry from the tracker, semantics by hand)."""
clip_id: str
annotations: list = []
affect: list = []
strings: dict = {}
def _load_clips(manifest_path: Optional[Path]):
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
if path.exists():
@@ -100,6 +124,7 @@ def _load_ring(manifest_path: Optional[Path]):
def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
app = FastAPI(title="HEF Alteration Simulator")
app.state.manifest_path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
app.state.clips = _load_clips(manifest_path)
app.state.ring = _load_ring(manifest_path)
@@ -158,6 +183,56 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
chosen = pick_clip_id(landed, random.random())
return ring_move_to_dict(move, app.state.ring, chosen)
@app.post("/api/author/track")
def api_author_track(req: AuthorTrackRequest):
# Author mode (§11.5): run the classical optical-flow tracker on a clip's
# base footage, returning the keyframed track geometry for the author to
# accept/correct. Semantics (key/strings/tiers) stay the author's.
clip = next((c for c in app.state.clips if c.id == req.clip_id), None)
if clip is None:
raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
base = MEDIA_DIR / clip.base_file
if not base.exists():
raise HTTPException(status_code=404, detail=f"media absent: {clip.base_file}")
try:
import cv2
except ImportError:
raise HTTPException(status_code=503, detail="opencv-python not installed")
from tools.pipeline.track import track_seed
cap = cv2.VideoCapture(str(base))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
cap.release()
seed_frame = max(0, min(total - 1, round(req.seed_t * total))) if total else 0
return track_seed(
base, req.key, tuple(req.seed_box), seed_frame=seed_frame,
salience=req.salience, n_keyframes=req.n_keyframes, max_frames=req.max_frames,
)
@app.post("/api/author/clip")
def api_author_clip(req: AuthorClipRequest):
# Author mode (§11.5 / stage 6): persist authored labels/affect/strings for
# an EXISTING pool clip — idempotent upsert that keeps the clip's media +
# provenance and replaces only the authored content. Reloads in place so
# the preview reflects the edit without a restart.
from tools.pipeline.manifest import upsert_clip
path = app.state.manifest_path
data = json.loads(path.read_text())
existing = next((c for c in data.get("clips", []) if c["id"] == req.clip_id), None)
if existing is None:
raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
merged = dict(existing)
merged["annotations"] = req.annotations
merged["affect"] = req.affect
if req.strings:
merged["strings"] = req.strings
data = upsert_clip(data, merged)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
app.state.clips = load_manifest(path)
app.state.ring = load_ring(path)
return {"ok": True, "clip": merged}
@app.middleware("http")
async def _no_cache(request, call_next):
# Dev preview server: never let a browser serve a stale app.js/style.css.
+64
View File
@@ -0,0 +1,64 @@
/* Author-mode-only styling (content-pipeline §11.5). Reuses style.css for the
shared stage/panel chrome; this adds the box-draw layer + editor widgets. */
.author #draw {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
.author #draw .seed-box {
fill: rgba(80, 200, 255, 0.12);
stroke: #50c8ff;
stroke-width: 0.4;
vector-effect: non-scaling-stroke;
}
.author #draw .track-box {
fill: rgba(120, 255, 160, 0.10);
stroke: #78ffa0;
stroke-width: 0.4;
stroke-dasharray: 1.5 1;
vector-effect: non-scaling-stroke;
}
.author #draw .affect-dot {
fill: rgba(190, 150, 255, 0.85);
stroke: #fff;
stroke-width: 0.25;
vector-effect: non-scaling-stroke;
}
.author .scrub {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.author .scrub input[type="range"] { flex: 1; }
.author .mono { font-family: ui-monospace, monospace; font-size: 12px; opacity: 0.85; }
.author .row { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
.author fieldset label { display: block; margin: 4px 0; font-size: 13px; }
.author fieldset input[type="text"],
.author fieldset input[type="number"] { width: 100%; box-sizing: border-box; }
.author fieldset input[type="number"] { width: 5em; }
.author ul.authored { list-style: none; padding: 0; margin: 6px 0; }
.author ul.authored li {
font-size: 12px;
padding: 3px 6px;
margin: 2px 0;
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
display: flex;
justify-content: space-between;
gap: 6px;
}
.author ul.authored .rm {
background: none;
border: none;
color: #f88;
cursor: pointer;
font-size: 12px;
}
.author header .hint { font-size: 12px; opacity: 0.8; max-width: 70ch; }
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Label Author Mode</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/author.css" />
</head>
<body class="author">
<header><h1>HEF — Label Author Mode</h1>
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
</header>
<main>
<section class="stage" id="stage">
<div class="screen">
<video id="vid" muted playsinline></video>
<svg id="draw" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
</div>
<div class="scrub">
<button type="button" id="play">▶/⏸</button>
<input type="range" id="seek" min="0" max="1000" value="0" />
<span id="time" class="mono">0.00</span>
</div>
</section>
<section class="panel">
<fieldset>
<legend>Clip</legend>
<select id="clip"></select>
<p class="hint" id="clip-meta"></p>
</fieldset>
<fieldset>
<legend>Seed box (drag on the video)</legend>
<p class="mono" id="seed-readout">no box — drag on the stage</p>
<label>label key <input type="text" id="ann-key" placeholder="detected.jelly" /></label>
<label>salience (14; 4 = shows first/at low Left)
<input type="number" id="ann-salience" min="1" max="4" value="4" /></label>
<label>tier 1 — general <input type="text" id="t1" placeholder="jelly" /></label>
<label>tier 2 — specific <input type="text" id="t2" placeholder="comb jelly" /></label>
<label>tier 3 — scientific <input type="text" id="t3" placeholder="Ctenophora" /></label>
<label>tier 4 — +fact <input type="text" id="t4" placeholder="Ctenophora · beats rows of cilia" /></label>
<label>keyframes <input type="number" id="ann-keyframes" min="2" max="20" value="5" /></label>
<div class="row">
<button type="button" id="run-track">Run tracker → track</button>
<button type="button" id="add-static">Add as static box</button>
</div>
<p class="mono" id="track-readout"></p>
<button type="button" id="add-ann" disabled>+ Add label</button>
</fieldset>
<fieldset>
<legend>Affect anchor (click the stage to place)</legend>
<p class="mono" id="affect-readout">no point — shift-click the stage</p>
<label>feel key <input type="text" id="aff-key" placeholder="feel.unease" /></label>
<label>min strength (14) <input type="number" id="aff-min" min="1" max="4" value="1" /></label>
<label>tier 1 — basic <input type="text" id="a1" placeholder="uh" /></label>
<label>tier 2 <input type="text" id="a2" placeholder="unease" /></label>
<label>tier 3 <input type="text" id="a3" placeholder="disquiet" /></label>
<label>tier 4 — compound <input type="text" id="a4" placeholder="a creeping disquiet" /></label>
<button type="button" id="add-aff" disabled>+ Add emotion</button>
</fieldset>
<fieldset>
<legend>Authored for this clip</legend>
<ul id="ann-list" class="authored"></ul>
<ul id="aff-list" class="authored"></ul>
<div class="row">
<button type="button" id="load-existing">Load existing</button>
<button type="button" id="save">Save to manifest</button>
</div>
<p class="mono" id="save-readout"></p>
</fieldset>
</section>
</main>
<script src="/author.js"></script>
</body>
</html>
+254
View File
@@ -0,0 +1,254 @@
// Label author mode (content-pipeline §11.5): scrub a pool clip, drag a seed box
// on a subject, run the classical tracker to propagate a keyframed track, author
// the LEFT detail tiers + RIGHT emotion tiers, and save the entry to the manifest.
// Geometry comes from the tracker; SEMANTICS (keys, strings, tiers) are hand-typed.
const $ = (id) => document.getElementById(id);
const vid = $("vid"), draw = $("draw");
const SVGNS = "http://www.w3.org/2000/svg";
let clips = [];
let currentClip = null;
let seedBox = null; // [x,y,w,h] normalized, from a drag
let pendingTrack = null; // {track, appear, disappear} from the tracker (or static)
let affectPoint = null; // [x,y] normalized, from a shift-click
let annotations = []; // authored annotation dicts (carry _tiers)
let affect = []; // authored affect dicts (carry _tiers)
function svg(tag, attrs, parent) {
const el = document.createElementNS(SVGNS, tag);
for (const k in attrs) el.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(el);
return el;
}
const mono = (n) => Number(n).toFixed(3);
async function loadClips() {
clips = (await (await fetch("/api/clips")).json()).clips || [];
const sel = $("clip");
sel.innerHTML = "";
for (const c of clips) {
const o = document.createElement("option");
o.value = c.id; o.textContent = c.id;
sel.appendChild(o);
}
if (clips.length) selectClip(clips[0].id);
}
function selectClip(id) {
currentClip = clips.find((c) => c.id === id) || null;
if (!currentClip) return;
$("clip-meta").textContent = `${currentClip.title}${currentClip.license}`;
vid.src = "/media/" + currentClip.base_file;
vid.currentTime = 0;
annotations = []; affect = []; seedBox = null; pendingTrack = null; affectPoint = null;
renderLists(); renderDrawLayer();
$("seed-readout").textContent = "no box — drag on the stage";
$("track-readout").textContent = "";
$("affect-readout").textContent = "no point — shift-click the stage";
}
// --- scrub ---
function seekT() { return vid.duration ? vid.currentTime / vid.duration : 0; }
$("play").addEventListener("click", () => { vid.paused ? vid.play() : vid.pause(); });
$("seek").addEventListener("input", () => {
if (vid.duration) vid.currentTime = (+$("seek").value / 1000) * vid.duration;
});
vid.addEventListener("timeupdate", () => {
if (!vid.duration) return;
$("seek").value = String(Math.round(seekT() * 1000));
$("time").textContent = seekT().toFixed(3);
});
// --- draw a seed box / place an affect point on the stage ---
function evtNorm(e) {
const r = draw.getBoundingClientRect();
return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height];
}
let dragStart = null;
draw.addEventListener("mousedown", (e) => {
if (e.shiftKey) { // shift-click = affect anchor
affectPoint = evtNorm(e).map((v) => Math.min(Math.max(v, 0), 1));
$("affect-readout").textContent = `affect @ ${affectPoint.map(mono).join(", ")}`;
$("add-aff").disabled = false;
renderDrawLayer();
return;
}
dragStart = evtNorm(e);
});
draw.addEventListener("mousemove", (e) => {
if (!dragStart) return;
seedBox = boxFrom(dragStart, evtNorm(e));
renderDrawLayer();
});
window.addEventListener("mouseup", (e) => {
if (!dragStart) return;
seedBox = boxFrom(dragStart, evtNorm(e));
dragStart = null;
pendingTrack = null;
$("seed-readout").textContent = `seed [${seedBox.map(mono).join(", ")}] @ t=${seekT().toFixed(3)}`;
$("add-ann").disabled = false;
renderDrawLayer();
});
function boxFrom(a, b) {
const x = Math.min(a[0], b[0]), y = Math.min(a[1], b[1]);
const w = Math.abs(b[0] - a[0]), h = Math.abs(b[1] - a[1]);
const cl = (v) => Math.min(Math.max(v, 0), 1);
return [cl(x), cl(y), Math.min(w, 1 - cl(x)), Math.min(h, 1 - cl(y))];
}
function renderDrawLayer() {
draw.innerHTML = "";
if (seedBox) {
const [x, y, w, h] = seedBox.map((n) => n * 100);
svg("rect", { x, y, width: w, height: h, class: "seed-box" }, draw);
}
if (pendingTrack && pendingTrack.track && vid.duration) {
const b = boxAt(pendingTrack.track, seekT());
if (b) {
const [x, y, w, h] = b.map((n) => n * 100);
svg("rect", { x, y, width: w, height: h, class: "track-box" }, draw);
}
}
if (affectPoint) {
const [x, y] = affectPoint.map((n) => n * 100);
svg("circle", { cx: x, cy: y, r: 1.2, class: "affect-dot" }, draw);
}
}
// Interpolate a track box at loop-normalized t (mirrors the preview renderer).
function boxAt(track, t) {
if (!track || !track.length) return null;
if (t <= track[0].t) return track[0].box;
if (t >= track[track.length - 1].t) return track[track.length - 1].box;
for (let i = 1; i < track.length; i++) {
if (t <= track[i].t) {
const a = track[i - 1], b = track[i], f = (t - a.t) / (b.t - a.t);
return a.box.map((v, j) => v + (b.box[j] - v) * f);
}
}
return track[track.length - 1].box;
}
// Animate the pending track preview while playing.
function previewLoop() { renderDrawLayer(); requestAnimationFrame(previewLoop); }
// --- run the tracker on the seed box ---
$("run-track").addEventListener("click", async () => {
if (!seedBox || !currentClip) { $("track-readout").textContent = "draw a seed box first"; return; }
const key = $("ann-key").value.trim() || "detected.object";
$("track-readout").textContent = "tracking…";
const resp = await fetch("/api/author/track", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({
clip_id: currentClip.id, key, seed_box: seedBox, seed_t: seekT(),
salience: +$("ann-salience").value, n_keyframes: +$("ann-keyframes").value,
}),
});
if (!resp.ok) { $("track-readout").textContent = "tracker error " + resp.status; return; }
const ann = await resp.json();
pendingTrack = { track: ann.track, appear: ann.appear, disappear: ann.disappear };
$("track-readout").textContent =
`track: ${ann.track.length} keyframes · window ${mono(ann.appear)}${mono(ann.disappear)}`;
$("add-ann").disabled = false;
});
// Add the current seed as a STATIC (untracked) box at the scrub position.
$("add-static").addEventListener("click", () => {
if (!seedBox) return;
pendingTrack = { static: true, box: seedBox.slice() };
$("track-readout").textContent = "static box (no track) at the drawn position";
$("add-ann").disabled = false;
});
function tiers(ids) {
const vals = ids.map((id) => $(id).value.trim());
while (vals.length && !vals[vals.length - 1]) vals.pop(); // drop empty trailing tiers
return vals.length ? vals : null;
}
$("add-ann").addEventListener("click", () => {
const key = $("ann-key").value.trim();
if (!key) { $("track-readout").textContent = "a label key is required"; return; }
const t = tiers(["t1", "t2", "t3", "t4"]);
const ann = { key, salience: +$("ann-salience").value, _tiers: t || key };
if (pendingTrack && pendingTrack.track) {
ann.track = pendingTrack.track;
ann.appear = pendingTrack.appear; ann.disappear = pendingTrack.disappear;
} else if (pendingTrack && pendingTrack.static) {
ann.box = pendingTrack.box;
} else if (seedBox) {
ann.box = seedBox.slice();
} else { $("track-readout").textContent = "draw + (optionally) track a box first"; return; }
annotations.push(ann);
seedBox = null; pendingTrack = null; $("add-ann").disabled = true;
renderLists(); renderDrawLayer();
});
$("add-aff").addEventListener("click", () => {
const key = $("aff-key").value.trim();
if (!key || !affectPoint) return;
const t = tiers(["a1", "a2", "a3", "a4"]);
affect.push({ key, at: affectPoint.slice(), min_level: +$("aff-min").value, _tiers: t || key });
affectPoint = null; $("add-aff").disabled = true;
renderLists(); renderDrawLayer();
});
function renderLists() {
const al = $("ann-list"); al.innerHTML = "";
annotations.forEach((a, i) => {
const li = document.createElement("li");
const kind = a.track ? `track ${a.track.length}kf ${mono(a.appear)}${mono(a.disappear)}` : "static";
const tip = Array.isArray(a._tiers) ? a._tiers.join(" → ") : a._tiers;
li.textContent = `${a.key} (s${a.salience}, ${kind}) — ${tip}`;
li.appendChild(rm(() => { annotations.splice(i, 1); renderLists(); }));
al.appendChild(li);
});
const fl = $("aff-list"); fl.innerHTML = "";
affect.forEach((f, i) => {
const li = document.createElement("li");
const tip = Array.isArray(f._tiers) ? f._tiers.join(" → ") : f._tiers;
li.textContent = `${f.key} (min ${f.min_level}) — ${tip}`;
li.appendChild(rm(() => { affect.splice(i, 1); renderLists(); }));
fl.appendChild(li);
});
}
function rm(fn) {
const b = document.createElement("button");
b.textContent = "✕"; b.className = "rm"; b.onclick = fn;
return b;
}
// Load whatever is already authored for this clip into the editor.
$("load-existing").addEventListener("click", () => {
if (!currentClip) return;
const strings = (currentClip.strings && currentClip.strings.en) || {};
annotations = (currentClip.annotations || []).map((a) => ({ ...a, _tiers: strings[a.key] || a.key }));
affect = (currentClip.affect || []).map((f) => ({ ...f, _tiers: strings[f.key] || f.key }));
renderLists();
});
// Assemble strings.en from each entry's _tiers and POST to the manifest.
$("save").addEventListener("click", async () => {
if (!currentClip) return;
const en = {};
const strip = (x) => { const { _tiers, ...rest } = x; return rest; };
const anns = annotations.map((a) => { en[a.key] = a._tiers; return strip(a); });
const affs = affect.map((f) => { en[f.key] = f._tiers; return strip(f); });
const resp = await fetch("/api/author/clip", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ clip_id: currentClip.id, annotations: anns, affect: affs, strings: { en } }),
});
$("save-readout").textContent = resp.ok
? `saved ${anns.length} labels + ${affs.length} emotions to the manifest ✓`
: "save error " + resp.status;
if (resp.ok) { // refresh the in-memory clip copy
clips = (await (await fetch("/api/clips")).json()).clips || [];
currentClip = clips.find((c) => c.id === currentClip.id) || currentClip;
}
});
$("clip").addEventListener("change", (e) => selectClip(e.target.value));
async function main() {
await loadClips();
requestAnimationFrame(previewLoop);
}
main();