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.
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 (1–4; 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 (1–4) <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>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user