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:
+88
-98
@@ -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
@@ -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 (0–4)</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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user