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:
+85
-15
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user