feat(simulator): alteration preview UI (grade + variant crossfade + Left overlay)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 22:57:13 -07:00
parent c3f9262a73
commit d2d63c0184
3 changed files with 153 additions and 175 deletions
+88 -98
View File
@@ -1,111 +1,101 @@
const DIALS = ["left", "right", "dark", "light"];
const MODEL = ["brain_weight", "mood_weight", "pool_size"];
// Thin renderer: post controls+calibration -> RenderPlan; render grade, Right
// variant crossfade, and the live Left overlay. All math stays in Python.
const $ = (id) => document.getElementById(id);
const vid = $("vid"), overlay = $("overlay"), black = $("black"), readout = $("readout");
function buildGrid(el) {
el.innerHTML = "";
// rows = first axis 0..4 top->bottom, cols = second axis 0..4 left->right
for (let a = 0; a < 5; a++) {
for (let b = 0; b < 5; b++) {
const cell = document.createElement("div");
cell.className = "cell";
cell.dataset.a = a;
cell.dataset.b = b;
el.appendChild(cell);
}
let clip = null; // active clip manifest entry
let currentVariant = -1; // last loaded Right strength
async function loadClips() {
const data = await (await fetch("/api/clips")).json();
clip = data.clips[0] || null;
}
function mediaUrl(file) { return "/media/" + file; }
function variantFile(strength) {
const v = clip.right_variants[String(strength)];
return v ? v.file : clip.base_file;
}
function applyGrade(tone) {
// Light: warm + brighten; Dark: cool + darken; 0: raw.
const warm = tone > 0 ? tone : 0, cool = tone < 0 ? -tone : 0;
const bright = 1 + 0.25 * tone, sat = 1 + 0.15 * Math.abs(tone);
vid.style.filter =
`brightness(${bright}) saturate(${sat}) ` +
`sepia(${(warm * 0.5).toFixed(3)}) hue-rotate(${(-cool * 200).toFixed(0)}deg)`;
}
function loadVariant(strength) {
if (strength === currentVariant) return;
currentVariant = strength;
vid.style.opacity = "0";
setTimeout(() => {
vid.src = mediaUrl(variantFile(strength));
vid.play().catch(() => {});
vid.style.opacity = "1";
}, 150);
}
function renderOverlay(level, intensity) {
overlay.innerHTML = "";
if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
overlay.style.opacity = String(intensity);
const strings = (clip.strings && clip.strings.en) || {};
for (const a of clip.annotations) {
if (a.min_level > level) continue;
const [x, y, w, h] = a.box.map((n) => n * 100);
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x); rect.setAttribute("y", y);
rect.setAttribute("width", w); rect.setAttribute("height", h);
rect.setAttribute("class", "anno-box");
overlay.appendChild(rect);
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", x + 0.5); text.setAttribute("y", Math.max(y - 0.5, 2));
text.setAttribute("class", "anno-label");
text.textContent = strings[a.key] || a.key;
overlay.appendChild(text);
}
}
function paintGrid(el, axisA, axisB, point, pool) {
// clear
el.querySelectorAll(".cell").forEach((c) => {
c.className = "cell";
c.innerHTML = "";
});
const counts = {};
pool.forEach((c) => {
const r = c.record;
const key = `${r[axisA]},${r[axisB]}`;
counts[key] = (counts[key] || 0) + 1;
});
el.querySelectorAll(".cell").forEach((c) => {
const a = +c.dataset.a, b = +c.dataset.b;
const key = `${a},${b}`;
if (counts[key]) {
c.classList.add("cand");
const n = document.createElement("span");
n.className = "n";
n.textContent = counts[key];
c.appendChild(n);
}
if (a === point[axisA] && b === point[axisB]) c.classList.add("point");
});
function controls() {
return {
content: $("content").value,
left: +$("left").value, right: +$("right").value,
dark: +$("dark").value, light: +$("light").value,
volume: 2, brightness: 2,
};
}
function readState() {
const s = { mode: document.getElementById("mode").value, approved_only: document.getElementById("approved_only").checked };
DIALS.forEach((d) => (s[d] = +document.getElementById(d).value));
s.brain_weight = +document.getElementById("brain_weight").value;
s.mood_weight = +document.getElementById("mood_weight").value;
s.pool_size = +document.getElementById("pool_size").value;
return s;
function calibration() {
return { mood_gain: +$("mood_gain").value, overlay_gain: +$("overlay_gain").value,
right_variant_map: [0, 1, 2, 3, 4] };
}
function syncOutputs() {
[...DIALS, ...MODEL].forEach((id) => {
const out = document.getElementById(`${id}-out`);
if (out) out.textContent = document.getElementById(id).value;
});
}
async function refresh() {
syncOutputs();
const state = readState();
const resp = await fetch("/api/select", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state),
let timer = null;
async function update() {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), calibration: calibration() }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
readout.textContent = JSON.stringify(data, null, 2);
if (!data.content.video) { black.classList.remove("hidden"); return; }
black.classList.add("hidden");
applyGrade(data.plan.grade.tone);
loadVariant(data.plan.restyle.variant);
renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
}
const pickEl = document.getElementById("pick");
if (!data.pick) {
pickEl.innerHTML = '<div class="void">∅ Void / rest — walls dark, audio silent.</div>';
} else {
const p = data.pick;
pickEl.innerHTML =
`<div class="title">${p.title}</div>` +
`<div>mode ${p.mode} · coord (${p.left},${p.right},${p.dark},${p.light})</div>` +
`<div>${p.rationale || ""}</div>`;
function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
async function main() {
await loadClips();
for (const id of ["content", "left", "right", "dark", "light", "mood_gain", "overlay_gain"]) {
$(id).addEventListener("input", debounced);
}
const poolEl = document.getElementById("pool");
poolEl.innerHTML = "";
data.pool.forEach((c, i) => {
const li = document.createElement("li");
if (i === 0) li.className = "winner";
li.innerHTML = `${c.record.title} <span class="dist">d=${c.distance.toFixed(2)}</span>`;
poolEl.appendChild(li);
});
const point = { left: state.left, right: state.right, dark: state.dark, light: state.light };
paintGrid(document.getElementById("brain-grid"), "left", "right", point, data.pool);
paintGrid(document.getElementById("mood-grid"), "dark", "light", point, data.pool);
update();
}
async function loadMeta() {
const data = await (await fetch("/api/catalog/meta")).json();
const byMode = Object.entries(data.by_mode).map(([k, v]) => `${k}:${v}`).join(" ");
document.getElementById("meta").textContent = `${data.total} records · ${byMode}`;
}
function init() {
buildGrid(document.getElementById("brain-grid"));
buildGrid(document.getElementById("mood-grid"));
document.querySelectorAll("input, select").forEach((el) =>
el.addEventListener("input", refresh)
);
loadMeta();
refresh();
}
document.addEventListener("DOMContentLoaded", init);
main();
+44 -52
View File
@@ -1,64 +1,56 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HEF — Curator's X-ray</title>
<link rel="stylesheet" href="/style.css">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header>
<h1>Experience Filter — Curator's X-ray</h1>
<div id="meta" class="meta"></div>
</header>
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
<main>
<section class="controls">
<h2>Dials</h2>
<label>Mode
<select id="mode">
<option value="none">None</option>
<option value="audio">Audio</option>
<option value="video">Video</option>
<option value="av" selected>A+V</option>
</select>
</label>
<label>Left (analytical) <output id="left-out">0</output>
<input type="range" id="left" min="0" max="4" step="1" value="0"></label>
<label>Right (artistic) <output id="right-out">0</output>
<input type="range" id="right" min="0" max="4" step="1" value="0"></label>
<label>Dark (somber) <output id="dark-out">0</output>
<input type="range" id="dark" min="0" max="4" step="1" value="0"></label>
<label>Light (uplifting) <output id="light-out">0</output>
<input type="range" id="light" min="0" max="4" step="1" value="0"></label>
<h2>Model knobs</h2>
<label>Brain weight <output id="brain_weight-out">1</output>
<input type="range" id="brain_weight" min="0" max="4" step="0.5" value="1"></label>
<label>Mood weight <output id="mood_weight-out">1</output>
<input type="range" id="mood_weight" min="0" max="4" step="0.5" value="1"></label>
<label>Pool size <output id="pool_size-out">4</output>
<input type="range" id="pool_size" min="1" max="10" step="1" value="4"></label>
<label class="check"><input type="checkbox" id="approved_only"> Approved only</label>
<section class="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<div id="black" class="black hidden"></div>
</div>
</section>
<section class="xray">
<div class="pick">
<h2>Picked</h2>
<div id="pick"></div>
</div>
<div class="pool">
<h2>Pool (nearest first)</h2>
<ol id="pool"></ol>
</div>
<div class="maps">
<h2>Coordinate maps</h2>
<div class="map"><div class="label">Brain — Left × Right</div><div id="brain-grid" class="grid5"></div></div>
<div class="map"><div class="label">Mood — Dark × Light</div><div id="mood-grid" class="grid5"></div></div>
</div>
<section class="panel">
<fieldset>
<legend>Content dial</legend>
<select id="content">
<option value="video">video</option>
<option value="audio_video">audio + video</option>
<option value="music_video">music + video</option>
<option value="off">off (black)</option>
<option value="white_noise">white noise (no video)</option>
<option value="music">music (no video)</option>
<option value="audio_track">audio track (no video)</option>
</select>
</fieldset>
<fieldset>
<legend>Experience knobs (04)</legend>
<label>Left (analytical) <input type="range" id="left" min="0" max="4" value="0" /></label>
<label>Right (dreamlike) <input type="range" id="right" min="0" max="4" value="0" /></label>
<label>Dark <input type="range" id="dark" min="0" max="4" value="0" /></label>
<label>Light <input type="range" id="light" min="0" max="4" value="0" /></label>
</fieldset>
<fieldset>
<legend>Calibration</legend>
<label>mood gain <input type="range" id="mood_gain" min="0" max="2" step="0.05" value="1" /></label>
<label>overlay gain <input type="range" id="overlay_gain" min="0" max="2" step="0.05" value="1" /></label>
</fieldset>
<fieldset>
<legend>RenderPlan readout</legend>
<pre id="readout"></pre>
</fieldset>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
+21 -25
View File
@@ -1,26 +1,22 @@
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, system-ui, sans-serif; background: #0e0e16; color: #e6e6ee; }
header { padding: 12px 20px; border-bottom: 1px solid #2a2a3a; display: flex; justify-content: space-between; align-items: baseline; }
header h1 { font-size: 18px; margin: 0; }
.meta { font-size: 12px; color: #9a9ab0; }
main { display: grid; grid-template-columns: 280px 1fr; gap: 20px; padding: 20px; }
.controls label { display: block; margin: 8px 0; font-size: 13px; }
.controls input[type=range] { width: 100%; }
.controls .check { display: flex; gap: 6px; align-items: center; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .5px; color: #9a9ab0; }
.xray { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.pick #pick { background: #1a1a2e; border: 1px solid #33334a; border-radius: 8px; padding: 14px; min-height: 80px; }
.pick .title { font-size: 16px; font-weight: 600; }
.pick .void { color: #7777aa; font-style: italic; }
.pool ol { margin: 0; padding-left: 18px; font-size: 13px; }
.pool li { margin: 4px 0; }
.pool li.winner { color: #7fffd4; font-weight: 600; }
.pool .dist { color: #9a9ab0; }
.maps { grid-column: 1 / -1; display: flex; gap: 40px; }
.grid5 { display: grid; grid-template-columns: repeat(5, 28px); grid-template-rows: repeat(5, 28px); gap: 3px; }
.grid5 .cell { background: #1c1c2c; border: 1px solid #2a2a3a; border-radius: 3px; position: relative; }
.grid5 .cell.cand { background: #3a3a66; }
.grid5 .cell.point { outline: 2px solid #7fffd4; }
.grid5 .cell .n { position: absolute; right: 2px; bottom: 1px; font-size: 9px; color: #aab; }
.label { font-size: 11px; color: #9a9ab0; margin-bottom: 4px; }
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
header { padding: 0.6rem 1rem; background: #000; }
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; }
.stage { flex: 1 1 640px; }
.screen { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000;
border-radius: 6px; overflow: hidden; }
#vid { width: 100%; height: 100%; object-fit: cover; transition: opacity 0.15s ease; }
#overlay { position: absolute; inset: 0; width: 100%; height: 100%;
pointer-events: none; transition: opacity 0.2s ease; }
.anno-box { fill: none; stroke: #6cf; stroke-width: 0.4; vector-effect: non-scaling-stroke; }
.anno-label { fill: #6cf; font-size: 3px; font-family: monospace; }
.black { position: absolute; inset: 0; background: #000; }
.hidden { display: none; }
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; }
fieldset { border: 1px solid #333; border-radius: 6px; }
legend { color: #9af; padding: 0 0.4rem; }
label { display: block; margin: 0.4rem 0; }
input[type=range], select { width: 100%; }
#readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px;
white-space: pre-wrap; max-height: 240px; overflow: auto; }