content(sim): rotating clip pools + progressive tracked labels & emotions

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

- Rotating pool (§11.1): each ring scale references a POOL of vetted clips
  (docs/content-candidate-pool.md); the player picks a random member on landing.
  player.ring.Scale gains `pool` + pure `pick_clip_id(scale, r)` (injected
  randomness); the pick happens at the API boundary (random.random()), a delta=0
  advance is the initial/re-roll pick. clips.py parses/exposes the pool; the
  client lands on move.target_clip_id. Back-compat: a scale with only clip_id is
  a pool of one.
- Rename ring scale forest -> coast (new land<->sea scale, orbit..reef); ring
  order cosmos -> orbit -> coast -> reef -> abyss -> wrap. Cleanup: dropped the
  leftover local media (cosmos_traverse, orbit_westcoast, old forest/reef, the
  Increment-1 orbit/abyss single bases); new coast-edge transition placeholders.
- Time-windowed tracked labels (§11.2): annotations gain appear/disappear
  (loop-normalized, wraps across the seam); a label shows only while on screen
  and its box tracks the subject. abyss + reef pools authored as test material.
- Progressive LEFT detail tiers (§11.3): per-object `salience` (first shows at
  Left 5-salience) + tiered strings (general -> specific -> scientific -> +fact)
  escalating with the Left knob; replaces flat min_level gating. Strings may be a
  tier list or a static string (back-compat). No engine change (client reads
  overlay.level).
- Progressive RIGHT emotion tiers (§11.4): affect vocabulary escalates basic ->
  compound with the Right knob (dream.strength); appearance still gated by
  min(left,right). No engine change.

New simulator/build_pool_manifest.py holds the hand-authored content and emits
the 19-clip pool manifest (the reproducible baseline). setup_scales_media.py
marked superseded. 251 passed / 4 skipped (+ ring pool + API pool/window tests).
By-eye visual 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:05:13 -07:00
parent 4270c265e3
commit edca8a9615
12 changed files with 2783 additions and 213 deletions
+85 -15
View File
@@ -31,8 +31,10 @@ window.addEventListener("error", (e) =>
showError(`${e.message} @ ${(e.filename || "").split("/").pop()}:${e.lineno}`));
let clipsById = {}; // id -> clip manifest entry
let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} or null
let ring = null; // {scales:[{id,clip_id,title,pool:[...]}], transitions:[...]} or null
let serverRing = false; // true when /api/ring served a real ring (vs the synthesized fallback)
let ringIndex = 0; // current scale position on the ring
let activeClipId = null; // the pool member chosen for the current scale landing
let currentClipId = null; // base media currently loaded (reset to force a reload)
let busy = false; // true while a ring transition is playing
let lastOverlay = null; // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation
@@ -41,17 +43,36 @@ async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
const r = await fetch("/api/ring");
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
if (!ring && clips.length) {
// No ring: single-clip mode — synthesize a 1-scale ring over clip 0.
ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title }], transitions: [] };
// No ring: single-clip mode — synthesize a 1-scale ring (pool of one) over clip 0.
ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title, pool: [{ clip_id: clips[0].id, title: clips[0].title }] }], transitions: [] };
}
ringIndex = 0;
}
// Land on the current scale: pick a random pool member (content-pipeline §11.1).
// A real server ring picks via a delta=0 advance (Python-canonical pick); the
// synthesized fallback ring has a pool of one, so it just takes that member.
async function landScale() {
const scale = ring && ring.scales[ringIndex];
if (!scale) { activeClipId = null; return; }
if (serverRing) {
try {
const resp = await fetch("/api/ring/advance", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ from_index: ringIndex, delta: 0 }),
});
if (resp.ok) { activeClipId = (await resp.json()).target_clip_id; currentClipId = null; return; }
} catch (_) { /* fall through to the primary member */ }
}
activeClipId = scale.clip_id;
currentClipId = null;
}
function activeClip() {
if (!ring) return null;
return clipsById[ring.scales[ringIndex].clip_id] || null;
return clipsById[activeClipId] || null;
}
function mediaUrl(file) { return "/media/" + file; }
@@ -183,10 +204,12 @@ function paintLoop() {
gl.drawArrays(gl.TRIANGLES, 0, 3);
paint.style.filter = busy ? "none" : gradeFilter;
}
// Animate keyframed annotation tracks: re-place boxes against playback time.
// Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
// labels (a label enters/leaves frame as the loop plays) against playback time.
const c = activeClip();
if (lastOverlay && lastOverlay.level > 0 && c &&
c.annotations.some((a) => a.track && a.track.length)) {
c.annotations.some((a) => (a.track && a.track.length) ||
typeof a.appear === "number" || typeof a.disappear === "number")) {
renderOverlay(lastOverlay.level, lastOverlay.intensity);
}
requestAnimationFrame(paintLoop);
@@ -261,17 +284,50 @@ function loopT() {
return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0;
}
// --- Progressive labels & emotions (content-pipeline §11.3 / §11.4) ---
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
// The Left level at which a label first appears. From `salience` (1..4): high
// salience appears early (first_level = 5 - salience), so raising Left brings in
// lower-salience objects. Falls back to a legacy flat `min_level`, else 1.
function firstLevel(a) {
if (typeof a.salience === "number") return clamp(5 - a.salience, 1, 4);
if (typeof a.min_level === "number") return a.min_level;
return 1;
}
// A tiered string escalates with a knob: a LIST is indexed by tier-1 (clamped to
// the available tiers); a plain string is static (tier ignored — back-compat).
function pickTier(value, tier) {
if (Array.isArray(value)) return value[clamp(tier - 1, 0, value.length - 1)];
return value;
}
function tierCount(value) { return Array.isArray(value) ? value.length : 1; }
// Time-windowed presence (§11.2): a label with `appear`/`disappear` (loop-norm t)
// shows only while on screen. A `disappear < appear` window wraps across the loop
// seam (visible when t >= appear OR t <= disappear). No window => always present.
function inWindow(a, t) {
const hasA = typeof a.appear === "number", hasD = typeof a.disappear === "number";
if (!hasA && !hasD) return true;
const ap = hasA ? a.appear : 0, dis = hasD ? a.disappear : 1;
return ap <= dis ? (t >= ap && t <= dis) : (t >= ap || t <= dis);
}
function renderOverlay(level, intensity) {
const clip = activeClip();
overlay.innerHTML = "";
if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
overlay.style.opacity = String(intensity);
const strings = (clip.strings && clip.strings.en) || {};
const t = loopT();
let shown = 0;
for (const a of clip.annotations) {
if (a.min_level > level) continue;
const first = firstLevel(a);
if (level < first) continue; // salience gating: low Left = fewer/general labels
if (!inWindow(a, t)) continue; // time-windowed: only while the object is on screen
shown++;
const [x, y, w, h] = boxAt(a, loopT()).map((n) => n * 100);
const [x, y, w, h] = boxAt(a, t).map((n) => n * 100);
const measure = a.key.startsWith("measure.");
const kind = measure ? "measure" : "detect";
reticle(x, y, w, h, "hud-reticle " + kind, overlay);
@@ -281,7 +337,12 @@ function renderOverlay(level, intensity) {
svg("line", { x1: cx - 1, y1: cyc, x2: cx + 1, y2: cyc, class: "hud-reticle measure" }, overlay);
svg("line", { x1: cx, y1: cyc - 1, x2: cx, y2: cyc + 1, class: "hud-reticle measure" }, overlay);
}
chip(x, y, strings[a.key] || a.key, measure ? null : confidence(a.key), kind, overlay);
// LEFT detail tier: a detection escalates general -> scientific+fact as Left
// rises above where it first appeared; measurements stay single-tier.
const raw = strings[a.key];
const tier = measure ? 1 : clamp(level - first + 1, 1, tierCount(raw));
const label = pickTier(raw !== undefined ? raw : a.key, tier);
chip(x, y, label, measure ? null : confidence(a.key), kind, overlay);
}
// Global status tag — the machine announcing it is analyzing, escalating with Left.
// Right-anchored at the frame edge; the plate is sized from the text's real bbox
@@ -302,7 +363,7 @@ function renderOverlay(level, intensity) {
// up. `strength` = min(left, right); `intensity` is the layer opacity. Words are
// placed at authored scene points (no boxes — feelings are scene-level) and read
// softer than the clinical reticles — the dream leaking into the machine's read.
function renderAffect(strength, intensity) {
function renderAffect(strength, intensity, right) {
const clip = activeClip();
if (!affectLayer) return; // tolerate a stale page missing the affect layer
affectLayer.innerHTML = "";
@@ -313,14 +374,21 @@ function renderAffect(strength, intensity) {
if (f.min_level > strength) continue;
const [x, y] = f.at.map((n) => n * 100);
const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
t.textContent = strings[f.key] || f.key;
// RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
// knob rises (appearance still gated by min(left,right) above).
const raw = strings[f.key];
t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
}
}
function renderScaleReadout() {
if (!ring) return;
const s = ring.scales[ringIndex];
$("scale-name").textContent = `${s.title} (${ringIndex + 1}/${ring.scales.length})`;
const poolN = (s.pool && s.pool.length) || 1;
const member = (activeClip() && activeClip().title) || s.title;
// scale id · the chosen pool member · position on the ring (pool size if >1)
const poolTag = poolN > 1 ? ` · pool ${poolN}` : "";
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
}
function controls() {
@@ -352,7 +420,7 @@ async function update() {
ensureClipMedia();
applyVideoLook(data.plan.grade.tone, data.plan.dream.intensity);
renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
renderAffect(data.plan.affect.strength, data.plan.affect.intensity);
renderAffect(data.plan.affect.strength, data.plan.affect.intensity, data.plan.dream.strength);
const b = document.getElementById("err-banner");
if (b) b.remove(); // render succeeded — clear any prior error
} catch (err) {
@@ -411,7 +479,8 @@ async function advance(delta) {
await playTransition(step.file, step.blended);
}
ringIndex = move.to_index;
currentClipId = null; // force the target scale's base media to (re)load
activeClipId = move.target_clip_id; // the randomly-picked pool member to load
currentClipId = null; // force the target scale's base media to (re)load
renderScaleReadout();
} finally {
busy = false;
@@ -450,6 +519,7 @@ async function main() {
devLiveReload();
try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
await loadData();
await landScale(); // pick the initial scale's pool member before first render
renderScaleReadout();
for (const id of ["content", "left", "right", "mood"]) {
$(id).addEventListener("input", debounced);