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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user