{% extends "base.html" %}
{% block title %}Studio — CoderAI{% endblock %}
{% block wrapper_class %}{% endblock %}

{% block head %}
<style>
:root {
  --text-1:#DDE1F0; --surface-0:#08090D; --surface-1:#111218;
  --surface-2:#161820; --surface-3:#252836; --accent-dim:rgba(99,102,241,.15);
}
/* ── Layout ─────────────────────────────────────────────────────── */
.studio { display:flex; height:calc(100vh - 56px); overflow:hidden; }

/* Sidebar */
.sidebar {
  width:220px; min-width:180px; background:var(--surface-1);
  border-right:1px solid var(--border); display:flex; flex-direction:column;
  overflow:hidden; flex-shrink:0; transition:width .15s, min-width .15s;
}
.sidebar.hidden { width:0; min-width:0; border-right:none; overflow:hidden; }
.sidebar-hd { padding:.6rem 1rem .15rem; font-size:10px; font-weight:700;
  color:var(--text-3); letter-spacing:.07em; text-transform:uppercase; }
.model-list { flex:1; overflow-y:auto; padding:.2rem .4rem .5rem; }
.model-item {
  display:flex; align-items:center; gap:.4rem; padding:.4rem .55rem;
  border-radius:6px; cursor:pointer; font-size:12px; color:var(--text-2);
  transition:background .1s;
}
.model-item:hover { background:var(--surface-2); }
.model-item.active { background:var(--accent-dim,#1d3354); color:var(--accent,#4e9cf5); font-weight:500; }
.mbadge {
  font-size:9px; font-weight:700; padding:1px 5px; border-radius:20px;
  letter-spacing:.03em; text-transform:uppercase; flex-shrink:0;
}
.mb-text    { background:#1d3250; color:#7aaef7; }
.mb-vision  { background:#1a2e3a; color:#5ed3f5; }
.mb-image   { background:#1d3520; color:#6ecf7e; }
.mb-video   { background:#301a40; color:#c07af5; }
.mb-audio   { background:#3a2510; color:#f0a844; }
.mb-tts     { background:#2d2010; color:#f0c060; }
.mb-audiogen{ background:#1a2535; color:#70b8f5; }
.mb-embed   { background:#1e2e1e; color:#88c888; }

/* Main */
.studio-main { flex:1; display:flex; flex-direction:column; overflow:hidden; }

/* Two-level tab bar */
.tabbar1 {
  display:flex; gap:.2rem; padding:.45rem .6rem .3rem;
  border-bottom:1px solid var(--border); background:var(--surface-0); flex-shrink:0;
  overflow-x:auto;
}
.tabbar2 {
  display:none; gap:.15rem; padding:.3rem .6rem;
  border-bottom:1px solid var(--border); background:var(--surface-1); flex-shrink:0;
  overflow-x:auto;
}
.tabbar2.visible { display:flex; }
.t1btn, .t2btn {
  padding:.28rem .65rem; border-radius:5px; font-size:12px; font-weight:500;
  cursor:pointer; border:1px solid transparent; color:var(--text-3);
  background:transparent; transition:all .1s; white-space:nowrap; flex-shrink:0;
  display:inline-flex; align-items:center; gap:.35rem;
}
.t1btn:hover, .t2btn:hover { background:var(--surface-2); color:var(--text-1); }
.t1btn.active { background:var(--accent,#4e9cf5); color:#fff; }
.t2btn.active { background:var(--surface-3,#333); color:var(--text-1); border-color:var(--border); }
.t1btn.hidden, .t2btn.hidden { display:none; }
.t1btn.state-partial, .t2btn.state-partial { color:var(--text-2); }
.t1btn.state-unavailable, .t2btn.state-unavailable { color:var(--text-3); opacity:.55; }
.t1btn.state-unavailable:hover, .t2btn.state-unavailable:hover { color:var(--text-2); }
.tab-status {
  display:inline-flex; align-items:center; padding:1px 5px; border-radius:999px;
  font-size:9px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
  background:var(--surface-3,#333); color:var(--text-2);
}
.t1btn.state-ready .tab-status, .t2btn.state-ready .tab-status { background:#0d2e18; color:#4ade80; }
.t1btn.state-partial .tab-status, .t2btn.state-partial .tab-status { background:#3a2510; color:#f0c060; }
.t1btn.state-unavailable .tab-status, .t2btn.state-unavailable .tab-status { background:var(--surface-2); color:var(--text-3); }
.t1btn.active .tab-status { background:rgba(255,255,255,.18); color:#fff; }
.t2btn.active .tab-status { background:rgba(255,255,255,.08); color:var(--text-1); }
.state-hidden { display:none !important; }

/* Panels */
.panel { flex:1; display:none; flex-direction:column; overflow:hidden; }
.panel.active { display:flex; }

/* ── Chat ─────────────────────────────────────────────────────── */
.chat-msgs { flex:1; overflow-y:auto; padding:1rem 1.25rem; display:flex; flex-direction:column; gap:.75rem; }
.chat-empty { margin:auto; text-align:center; color:var(--text-3); }
.chat-empty h3 { font-size:1rem; margin-bottom:.25rem; }
.msg { display:flex; gap:.75rem; }
.msg.user { flex-direction:row-reverse; }
.av { width:28px; height:28px; border-radius:50%; display:flex; align-items:center;
  justify-content:center; font-size:9px; font-weight:700; flex-shrink:0; }
.av.user { background:var(--accent,#4e9cf5); color:#fff; }
.av.ai   { background:var(--surface-3,#2a2a2a); color:var(--text-2); }
.msg-body { max-width:70%; }
.msg.user .msg-body { text-align:right; }
.msg-meta { font-size:10px; color:var(--text-3); margin-bottom:.2rem; }
.msg-text { background:var(--surface-2); padding:.5rem .75rem; border-radius:8px;
  font-size:13px; line-height:1.55; white-space:pre-wrap; word-break:break-word; }
.msg.user .msg-text { background:var(--accent-dim,#1d3354); }
.msg-img { max-width:280px; border-radius:8px; margin-top:.3rem; cursor:pointer; }
.chat-foot { flex-shrink:0; padding:.5rem .75rem .75rem; border-top:1px solid var(--border); }
.attach-bar { display:flex; align-items:center; gap:.5rem; margin-bottom:.3rem; }
.attach-thumb { width:36px; height:36px; border-radius:4px; object-fit:cover; }
.chat-row { display:flex; gap:.5rem; align-items:flex-end; }
.chat-ta { flex:1; resize:none; border-radius:6px; border:1px solid var(--border);
  background:var(--surface-2); color:var(--text-1); padding:.45rem .7rem;
  font-size:13px; font-family:inherit; outline:none; min-height:36px; max-height:140px; }
.chat-hint { font-size:10px; color:var(--text-3); margin-top:.2rem; text-align:right; }

/* ── Shared panel helpers ─────────────────────────────────────── */
.gen-wrap { flex:1; display:flex; overflow:hidden; }
.gen-ctrl { width:min(380px,36vw); min-width:340px; max-width:420px; padding:.9rem 1rem; overflow-y:auto; border-right:1px solid var(--border);
  background:var(--surface-1); display:flex; flex-direction:column; gap:.65rem; flex-shrink:0; }
/* spacer so Chrome doesn't clip the last child in overflow-y:auto */
.gen-ctrl::after { content:''; display:block; min-height:.75rem; }
.gen-out { flex:1; display:flex; align-items:center; justify-content:center;
  overflow:auto; padding:1.2rem; background:var(--surface-0); }
.gen-out-inner { display:flex; flex-direction:column; align-items:center; gap:.6rem; width:100%; max-width:960px; }
.gen-empty { color:var(--text-3); text-align:center; font-size:13px; }
.out-img { max-width:100%; max-height:calc(100vh - 200px); border-radius:8px; cursor:pointer; }
.out-video { max-width:100%; max-height:calc(100vh - 200px); border-radius:8px; }
.out-audio { width:100%; }
.fl { font-size:11px; font-weight:600; color:var(--text-2); margin-bottom:.15rem; display:block; }
.progress { min-height:18px; }
.progress:not(:empty) {
  display:block; font-size:13px; font-weight:600; letter-spacing:.03em;
  color:#c7caf5;
  border-left:3px solid #6366f1;
  padding:.3rem .55rem;
  background:rgba(99,102,241,.1);
  border-radius:0 5px 5px 0;
  margin-top:.4rem;
  overflow:visible;
  animation:prog-pulse 1.6s ease-in-out infinite;
}
@keyframes prog-pulse { 0%,100%{opacity:1;border-color:#6366f1} 50%{opacity:.7;border-color:#a5b4fc} }
.gen-progress-wrap { display:none; margin-top:.4rem; }
.gen-progress-wrap.active { display:block; }
.gen-progress-bar-bg {
  height:6px; border-radius:3px; background:var(--surface-3);
  overflow:hidden; margin-top:.3rem;
}
.gen-progress-bar-fill {
  height:100%; border-radius:3px;
  background:linear-gradient(90deg,#6366f1,#a5b4fc);
  transition:width .4s ease;
  width:0%;
}
.gen-progress-label {
  font-size:11px; color:#a0a8cc; margin-top:.15rem; text-align:right;
}
.fi, .fs, .fselect { background:var(--surface-2); border:1px solid var(--border); border-radius:5px;
  color:var(--text-1); padding:.38rem .6rem; font-size:13px; font-family:inherit;
  outline:none; width:100%; box-sizing:border-box; color-scheme:dark; }
.fs { resize:vertical; min-height:70px; }
.fselect { appearance:auto; }
.fselect option, .fselect optgroup { background:var(--surface-2); color:var(--text-1); }
.g2 { display:grid; grid-template-columns:1fr 1fr; gap:.5rem; }
.g3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:.5rem; }
.frow { display:flex; flex-direction:column; gap:.15rem; }
.char-refs { display:flex; flex-wrap:wrap; gap:.3rem; margin-top:.25rem; }
.char-thumb { width:44px; height:44px; object-fit:cover; border-radius:4px; cursor:pointer; }
.char-thumb:hover { opacity:.7; }
.char-slot { border:1px solid var(--border); border-radius:6px; padding:.5rem .6rem; display:flex; flex-direction:column; gap:.35rem; background:var(--surface-1); }
.char-slot-header { display:flex; gap:.4rem; align-items:center; }
.char-slot-actions { display:flex; gap:.3rem; align-items:center; flex-wrap:wrap; }
.media-input-group { display:flex; gap:.4rem; align-items:center; flex:1; }
.media-preview img, .media-preview video, .media-preview audio { max-width:100%; max-height:120px; border-radius:6px; margin-top:.3rem; }
.webcam-box { margin-top:.4rem; display:flex; flex-direction:column; gap:.4rem; }
.webcam-box video { width:100%; max-height:200px; border-radius:6px; background:#000; }
.webcam-box .webcam-controls { display:flex; gap:.4rem; flex-wrap:wrap; }
/* download link styled as button */
a.dl { display:inline-block; margin-top:.4rem; }

@media (max-width: 900px) {
  .gen-ctrl { width:min(360px,44vw); min-width:280px; }
}

@media (max-width: 720px) {
  .gen-wrap { flex-direction:column; }
  .gen-ctrl { width:100%; min-width:0; max-width:none; border-right:none; border-bottom:1px solid var(--border); max-height:55vh; }
}

/* ── Capability cards ─────────────────────────────────────────── */
.cap-card {
  border:1px solid var(--border); background:var(--surface-1); border-radius:8px;
  padding:.75rem .9rem; margin-bottom:.75rem; display:flex; flex-direction:column; gap:.55rem;
}
.cap-card.state-partial {
  border-color:rgba(245,158,11,.4); background:rgba(58,37,16,.7);
}
.cap-card.state-unavailable {
  border-color:rgba(248,113,113,.4); background:rgba(80,28,28,.7);
}
.cap-card-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; }
.cap-card-title { font-size:13px; font-weight:600; color:var(--text-1); }
.cap-card-summary { font-size:12px; line-height:1.45; color:var(--text-2); }
.cap-meta { display:flex; flex-wrap:wrap; gap:.35rem; }
.cap-chip {
  font-size:10px; border-radius:999px; padding:.16rem .45rem;
  background:var(--surface-2); color:var(--text-2); border:1px solid var(--border);
}
.cap-chip.ok { background:#0d2e18; color:#4ade80; border-color:transparent; }
.cap-chip.warn { background:#3a2510; color:#f0c060; border-color:transparent; }
.cap-chip.dim { opacity:.72; }
.cap-missing, .cap-note { font-size:12px; color:var(--text-2); }
.cap-note strong, .cap-missing strong { color:var(--text-1); }
.cap-find-link { display:inline-flex; align-items:center; gap:2px; text-decoration:none; border-radius:4px; transition:opacity .15s; }
.cap-find-link:hover { opacity:.75; }
.cap-find-icon { font-size:10px; color:var(--text-3); }
.cap-output-note {
  width:100%; max-width:960px; border:1px solid rgba(245,158,11,.35); background:rgba(58,37,16,.85);
  color:#f6d08a; border-radius:8px; padding:.85rem 1rem; display:flex; flex-direction:column; gap:.4rem;
  font-size:13px; margin-bottom:.75rem;
}
.cap-output-note.unavailable {
  border-color:rgba(248,113,113,.35); background:rgba(80,28,28,.85); color:#f3b2b2;
}
.cap-output-note ul { margin:0; padding-left:1.1rem; }
.cap-toggle { font-size:12px; display:flex; align-items:center; gap:.4rem; cursor:pointer; color:var(--text-2); }
.cap-toggle input { margin:0; }
.cap-preserve-note {
  font-size:12px; line-height:1.45; color:var(--text-2); background:var(--surface-2);
  border:1px solid var(--border); border-radius:6px; padding:.55rem .65rem;
}
.req-preview {
  border:1px solid var(--border); border-radius:8px; background:var(--surface-2);
  padding:.7rem; display:flex; flex-direction:column; gap:.55rem;
}
.req-preview-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.6rem; }
.req-preview-title { font-size:12px; font-weight:600; color:var(--text-1); }
.req-preview-sub { font-size:11px; color:var(--text-3); margin-top:.15rem; }
.req-preview-endpoint { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-all; }
.req-preview-grid { display:grid; grid-template-columns:1fr 1fr; gap:.45rem; }
.req-preview-field { border:1px solid var(--border); border-radius:6px; background:var(--surface-1); padding:.45rem .5rem; min-width:0; }
.req-preview-label { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-3); margin-bottom:.15rem; }
.req-preview-value { font-size:12px; color:var(--text-1); word-break:break-word; }
.req-preview-code { width:100%; box-sizing:border-box; min-height:180px; resize:vertical; font-family:var(--mono); font-size:11px; }
.req-preview-actions { display:flex; gap:.4rem; flex-wrap:wrap; align-items:center; }
.req-preview-status { font-size:11px; color:var(--text-3); min-height:14px; }

/* ── Model pick block (Studio per-panel selectors) ───────────── */
.model-pick-block {
  background:var(--surface-2); border:1px solid var(--border); border-radius:8px;
  padding:.6rem .75rem; display:flex; flex-direction:column; gap:.4rem; margin-bottom:.6rem;
}
.model-pick-title {
  font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
  color:var(--text-3); margin-bottom:.1rem;
}
.model-pick-row { display:flex; align-items:center; gap:.5rem; }
.model-pick-role {
  font-size:11px; color:var(--text-2); min-width:7.5rem; flex-shrink:0; line-height:1.3;
}
.model-pick-sel {
  flex:1; padding:.35rem .5rem; border:1px solid var(--border); border-radius:6px;
  background:var(--surface-1); color:var(--text-1); font-size:12px; cursor:pointer;
  min-width:0;
}
.model-pick-sel:focus { outline:2px solid var(--accent); outline-offset:1px; }
.model-pick-hint {
  font-size:10px; color:var(--text-3); display:flex; align-items:center; gap:.55rem; flex-wrap:wrap;
}
.mp-ok { color:#4ade80; }
.mp-warn { color:#f0c060; }
/* VAE / LoRA optional section */
.mp-extra { border-top:1px solid var(--border); margin-top:.3rem; padding-top:.4rem; }
.mp-extra summary {
  font-size:11px; color:var(--text-2); cursor:pointer; user-select:none;
  list-style:none; display:flex; align-items:center; gap:.3rem;
}
.mp-extra summary::-webkit-details-marker { display:none; }
.mp-extra summary::before { content:'▶'; font-size:9px; transition:transform .15s; }
.mp-extra[open] summary::before { transform:rotate(90deg); }
.mp-extra-body { display:flex; flex-direction:column; gap:.4rem; margin-top:.45rem; }
.lora-entry { display:flex; align-items:center; gap:.35rem; }
.lora-weight {
  width:4.5rem; flex-shrink:0; padding:.3rem .4rem; border:1px solid var(--border);
  border-radius:5px; background:var(--surface-1); color:var(--text-1); font-size:12px;
}
.lora-remove {
  width:1.5rem; height:1.5rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;
  border:none; background:transparent; color:var(--text-3); cursor:pointer; font-size:13px;
  line-height:1; padding:0; border-radius:4px;
}
.lora-remove:hover { background:var(--surface-3); color:var(--text-1); }

/* ── Diagnostics / history ────────────────────────────────────── */
.diag-card, .hist-card {
  border:1px solid var(--border); background:var(--surface-1); border-radius:8px;
  padding:.8rem .9rem; display:flex; flex-direction:column; gap:.6rem; margin-bottom:.6rem;
}
.diag-title, .hist-title { font-size:13px; font-weight:600; color:var(--text-1); }
.diag-sub, .hist-empty, .hist-meta, .hist-summary { font-size:12px; color:var(--text-2); line-height:1.45; }
.diag-groups, .hist-list { display:flex; flex-direction:column; gap:.5rem; }
.diag-group { display:flex; flex-direction:column; gap:.3rem; }
.diag-group-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; }
.diag-group-label { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-3); }
.diag-items, .hist-links, .hist-chips { display:flex; flex-wrap:wrap; gap:.35rem; }
.diag-empty { font-size:11px; color:var(--text-3); }
.hist-item { border:1px solid var(--border); border-radius:7px; background:var(--surface-2); padding:.65rem .7rem; display:flex; flex-direction:column; gap:.35rem; }
.hist-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.6rem; }
.hist-name { font-size:12px; font-weight:600; color:var(--text-1); }
.hist-link { font-size:11px; }

/* ── Pipeline ─────────────────────────────────────────────────── */
.pipe-panel { flex:1; overflow-y:auto; padding:1.2rem; display:block; }
.pipe-card { background:var(--surface-1); border:1px solid var(--border); border-radius:8px; overflow:hidden; margin-bottom:.6rem; }
.pipe-card summary {
  list-style:none; padding:.8rem 1rem; cursor:pointer; display:flex; align-items:flex-start;
  justify-content:space-between; gap:.75rem; font-weight:600; font-size:13px; user-select:none; color:var(--text-1);
}
.pipe-card summary::-webkit-details-marker { display:none; }
.pipe-card summary::after { content:'›'; font-size:16px; color:var(--text-2); transition:transform .2s; margin-top:.1rem; }
.pipe-card[open] summary::after { transform:rotate(90deg); }
.pipe-card summary:hover { background:rgba(255,255,255,.03); }
.pipe-card-body { padding:.75rem 1rem 1rem; border-top:1px solid var(--border); display:flex; flex-direction:column; gap:.5rem; }
.pipe-head { display:flex; flex-direction:column; gap:.45rem; min-width:0; flex:1; }
.pipe-title { font-weight:600; font-size:13px; color:var(--text-1); }
.pipe-summary { font-size:12px; color:var(--text-2); line-height:1.45; }
.pipe-tags { display:flex; flex-wrap:wrap; gap:.35rem; }
.pipe-tag { font-size:10px; padding:.16rem .45rem; border-radius:999px; background:var(--surface-2); color:var(--text-2); border:1px solid var(--border); }
.pipe-badge { font-size:10px; padding:.15rem .45rem; border-radius:999px; font-weight:600; align-self:flex-start; }
.pipe-badge.ready { background:#0f2a0f; color:#4caf50; border:1px solid #2a5a2a; }
.pipe-badge.partial { background:#2a1f0a; color:#f0a020; border:1px solid #5a3a10; }
.pipe-badge.unavailable { background:var(--surface-2); color:var(--text-3); border:1px solid var(--border); }
.pipe-steps { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; font-size:11px; color:var(--text-2); margin-bottom:.1rem; }
.pipe-step { background:var(--surface-2); border-radius:5px; padding:.2rem .5rem; color:var(--text-2); }
.pipe-arrow { color:var(--text-3); }
.pb-step { background:var(--surface-2); border:1px solid var(--border); border-radius:6px; padding:.5rem .6rem; display:flex; flex-direction:column; gap:.3rem; }
.pb-step-header { display:flex; align-items:center; gap:.4rem; font-size:12px; font-weight:600; }
.pb-step-params { display:flex; flex-direction:column; gap:.25rem; padding-top:.25rem; }
.pb-step-param { display:flex; align-items:center; gap:.4rem; font-size:12px; }
/* ── Pipeline capability chips ────────────────────────────────── */
.pipe-caps { display:flex; flex-wrap:wrap; align-items:center; gap:.3rem; padding-bottom:.6rem; margin-bottom:.25rem; border-bottom:1px solid var(--border); }
.pipe-caps-label { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.05em; margin-right:.1rem; }
.pipe-cap-chip { font-size:10px; padding:.1rem .4rem; border-radius:4px; border:1px solid transparent; }
.pipe-cap-chip.ok { background:#0d2e18; color:#4ade80; border-color:#1a4a1a; }
.pipe-cap-chip.missing { background:#2e0d0d; color:#f07070; border-color:#5a1a1a; }
.pipe-cap-chip.optional { background:var(--surface-2); color:var(--text-3); }
.pipe-cap-chip.optional.ok { background:#1a1f0a; color:#c0d060; border-color:#3a4a10; }
/* ── Sub-tab model picker ─────────────────────────────────────── */
.cap-model-picker { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; padding-top:.4rem; border-top:1px solid var(--border); margin-top:.1rem; }
.cap-model-picker.multi { flex-direction:column; align-items:flex-start; gap:.4rem; }
.cap-model-picker-label { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.05em; white-space:nowrap; }
.cap-model-chips { display:flex; flex-wrap:wrap; gap:.3rem; }
.cap-assign-rows { display:flex; flex-direction:column; gap:.3rem; width:100%; }
.cap-assign-row  { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
.cap-assign-label { font-size:10px; color:var(--text-2); min-width:7rem; flex-shrink:0; text-align:right; }
.cap-assign-row.opt .cap-assign-label { color:var(--text-3); }
.cap-assign-sep  { font-size:10px; color:var(--text-3); text-transform:uppercase; letter-spacing:.05em; padding-top:.2rem; border-top:1px solid var(--border); width:100%; }
.cap-model-chip { font-size:11px; padding:.18rem .55rem; border-radius:999px; border:1px solid; cursor:pointer; background:transparent; font-family:inherit; transition:background .15s; }
.cap-model-chip.ok  { color:#4ade80; border-color:#2a5a2a; background:#0f2a0f; }
.cap-model-chip.ok:hover  { background:#1a3f1a; }
.cap-model-chip.warn { color:#f0c060; border-color:#5a3a10; background:#2a1f0a; }
.cap-model-chip.warn:hover { background:#3a2f0a; }
.cap-model-chip.active { font-weight:700; outline:1px solid currentColor; outline-offset:1px; }
/* ── Sidebar capability highlights ───────────────────────────── */
.model-item.cap-ok      { border-left:3px solid #4caf50; padding-left:calc(.55rem - 3px); background:rgba(76,175,80,.13); }
.model-item.cap-ok:hover { background:rgba(76,175,80,.22); }
.model-item.cap-partial { border-left:3px solid #f0a020; padding-left:calc(.55rem - 3px); background:rgba(240,160,32,.11); }
.model-item.cap-partial:hover { background:rgba(240,160,32,.2); }
.pb-step-param label { min-width:110px; color:var(--text-2); flex-shrink:0; }
.pb-step-param input, .pb-step-param select, .pb-step-param textarea { flex:1; font-size:12px; }
.pb-step-param textarea { rows:2; resize:vertical; min-height:40px; }
/* ── Archive panel ────────────────────────────────────────────── */
.archive-panel { flex:1; overflow-y:auto; padding:1.2rem; display:flex; flex-direction:column; gap:.9rem; }
.archive-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem; }
.archive-filters { display:flex; gap:.35rem; }
.arch-filter { background:var(--surface-2); border:1px solid var(--border); border-radius:5px; color:var(--text-2); padding:.28rem .7rem; font-size:12px; cursor:pointer; font-family:inherit; transition:background .15s; }
.arch-filter.active { background:var(--accent,#4e9cf5); color:#fff; border-color:var(--accent,#4e9cf5); }
.archive-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:.7rem; }
.arch-card { background:var(--surface-1); border:1px solid var(--border); border-radius:8px; overflow:hidden; display:flex; flex-direction:column; }
.arch-thumb { width:100%; aspect-ratio:1; object-fit:cover; background:var(--surface-2); display:block; cursor:pointer; }
.arch-thumb-ph { width:100%; aspect-ratio:1; background:var(--surface-2); display:flex; align-items:center; justify-content:center; font-size:2.2rem; color:var(--text-3); }
.arch-info { padding:.35rem .5rem; display:flex; flex-direction:column; gap:.15rem; }
.arch-name { font-size:11px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.arch-meta { font-size:10px; color:var(--text-3); }
.arch-actions { display:flex; gap:.25rem; padding:.3rem .4rem; border-top:1px solid var(--border); flex-wrap:wrap; }
.arch-btn { flex:1; font-size:11px; padding:.22rem .35rem; border:1px solid var(--border); border-radius:4px; background:transparent; color:var(--text-2); cursor:pointer; font-family:inherit; text-decoration:none; text-align:center; white-space:nowrap; }
.arch-btn:hover { background:var(--surface-2); color:var(--text-1); }
.arch-btn.del:hover { background:#3a1010; color:#f07070; border-color:#5a2020; }
.arch-empty { color:var(--text-3); text-align:center; padding:2rem 1rem; grid-column:1/-1; font-size:13px; }
/* ── Profile panels ───────────────────────────────────────────── */
.prof-gen-out { align-items:flex-start; justify-content:flex-start; flex-direction:column; padding:0; }
.prof-out-hd { display:flex; align-items:center; justify-content:space-between; padding:.6rem 1rem .55rem; flex-shrink:0; border-bottom:1px solid var(--border); width:100%; box-sizing:border-box; }
.prof-out-hd-title { font-size:13px; font-weight:600; color:var(--text-1); }
.prof-out-body { flex:1; overflow-y:auto; padding:.8rem; width:100%; box-sizing:border-box; }
.prof-card-info { padding:.6rem .75rem .75rem; display:flex; flex-direction:column; gap:.15rem; flex:1; }
.prof-card-name { font-weight:600; font-size:13px; color:var(--text-1); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.prof-card-desc { font-size:11.5px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.prof-card-meta { font-size:11px; color:var(--text-3); }
.prof-card-actions { display:flex; gap:.4rem; margin-top:.45rem; }
.prof-voice-list { display:flex; flex-direction:column; gap:.55rem; }
.prof-voice-card { display:flex; align-items:flex-start; gap:.85rem; background:var(--surface-1); border:1px solid var(--border); border-radius:8px; padding:.75rem .9rem; }
.prof-voice-icon { width:40px; height:40px; border-radius:8px; background:var(--surface-3); display:flex; align-items:center; justify-content:center; font-size:1.3rem; flex-shrink:0; }
.prof-voice-info { flex:1; min-width:0; }
.prof-voice-name { font-weight:600; font-size:13px; color:var(--text-1); }
.prof-voice-meta { font-size:11px; color:var(--text-3); margin-top:.15rem; }
.prof-voice-quote { font-style:italic; color:var(--text-3); font-size:11.5px; margin-top:.15rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.prof-voice-actions { display:flex; gap:.4rem; margin-top:.5rem; }
/* ── Role-picker popup ────────────────────────────────────────── */
.role-picker-popup { position:fixed; z-index:9999; background:var(--surface-1); border:1px solid var(--border); border-radius:8px; padding:.7rem; box-shadow:0 8px 24px rgba(0,0,0,.5); min-width:200px; max-width:280px; }
/* ── Profile viewer modal ─────────────────────────────────────── */
.prof-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:10000; display:flex; align-items:center; justify-content:center; }
.prof-modal { background:var(--surface-1); border:1px solid var(--border); border-radius:10px; box-shadow:0 12px 40px rgba(0,0,0,.6); width:min(700px,95vw); max-height:85vh; display:flex; flex-direction:column; overflow:hidden; }
.prof-modal-hd { display:flex; align-items:center; gap:.6rem; padding:.8rem 1rem; border-bottom:1px solid var(--border); flex-shrink:0; }
.prof-modal-hd h3 { margin:0; font-size:15px; flex:1; color:var(--text-1); }
.prof-modal-body { padding:1rem; overflow-y:auto; flex:1; }
.prof-modal-desc { font-size:13px; color:var(--text-2); margin-bottom:.8rem; }
.prof-modal-imgs { display:flex; flex-wrap:wrap; gap:.5rem; }
.prof-modal-imgs img { height:140px; width:140px; object-fit:cover; border-radius:6px; cursor:pointer; border:2px solid transparent; transition:border-color .15s; }
.prof-modal-imgs img:hover { border-color:var(--accent,#4f8ef7); }
.prof-modal-empty { color:var(--text-3); font-size:13px; font-style:italic; }
.prof-lightbox { position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:11000; display:flex; align-items:center; justify-content:center; cursor:zoom-out; }
.prof-lightbox img { max-width:90vw; max-height:90vh; object-fit:contain; border-radius:6px; box-shadow:0 8px 40px rgba(0,0,0,.8); }
.role-picker-header { font-size:12px; color:var(--text-2); margin-bottom:.5rem; }
.role-picker-caps { display:flex; flex-direction:column; gap:.3rem; }
.role-pick-btn { background:var(--surface-2); border:1px solid var(--border); border-radius:5px; color:var(--text-1); padding:.35rem .6rem; font-size:12px; cursor:pointer; font-family:inherit; text-align:left; display:flex; align-items:center; justify-content:space-between; gap:.4rem; }
.role-pick-btn:hover { background:var(--surface-3); }
.role-pick-btn.active { border-color:#6366f1; background:rgba(99,102,241,.1); }
.role-pick-cap { text-transform:capitalize; }
.role-pick-badge { font-size:10px; color:#6366f1; }
.role-picker-close { width:100%; margin-top:.45rem; background:transparent; border:1px solid var(--border); border-radius:5px; color:var(--text-3); padding:.28rem; font-size:11px; cursor:pointer; font-family:inherit; }
.role-picker-close:hover { color:var(--text-1); background:var(--surface-2); }
</style>
{% endblock %}

{% block content %}
<div class="studio">

  <!-- Sidebar -->
  <aside class="sidebar">
    <div class="sidebar-hd">Models</div>
    <div class="model-list" id="model-list"><div class="muted small" style="padding:.5rem .6rem">Loading…</div></div>
  </aside>

  <div class="studio-main">

    <!-- Level-1 tabs (category) -->
    <div class="tabbar1" id="tabbar1">
      <button class="t1btn active" data-cat="chat" onclick="selectCat('chat')">Chat <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="image" onclick="selectCat('image')">Image <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="video" onclick="selectCat('video')">Video <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="audio" onclick="selectCat('audio')">Audio <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="embed" onclick="selectCat('embed')">Embed <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="3d" onclick="selectCat('3d')">3D <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="profiles" onclick="selectCat('profiles')">Profiles <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="pipe" onclick="selectCat('pipe')">Pipelines <span class="tab-status" hidden></span></button>
    </div>

    <!-- Level-2 tabs (sub-mode) -->
    <div class="tabbar2" id="tabbar2">
      <!-- Image sub-tabs -->
      <button class="t2btn" data-sub="img-gen" onclick="selectSub('img-gen')">Generate <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-edit" onclick="selectSub('img-edit')">Edit (i2i) <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-inpaint" onclick="selectSub('img-inpaint')">Inpaint <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-upscale" onclick="selectSub('img-upscale')">Upscale <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-depth" onclick="selectSub('img-depth')">Depth <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-seg" onclick="selectSub('img-seg')">Segment <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-faceswap" onclick="selectSub('img-faceswap')">Face Swap <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-deblur" onclick="selectSub('img-deblur')">Deblur <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-unpix" onclick="selectSub('img-unpix')">Unpixelate <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-outfit" onclick="selectSub('img-outfit')">Outfit Change <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-to3d" onclick="selectSub('img-to3d')">2D→3D <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="img-from3d" onclick="selectSub('img-from3d')">3D→2D <span class="tab-status" hidden></span></button>
      <!-- Video sub-tabs -->
      <button class="t2btn" data-sub="vid-t2v" onclick="selectSub('vid-t2v')">Text→Video <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-i2v" onclick="selectSub('vid-i2v')">Image→Video <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-v2v" onclick="selectSub('vid-v2v')">Vid→Vid <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-ti2v" onclick="selectSub('vid-ti2v')">Ti2V <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-interp" onclick="selectSub('vid-interp')">Interpolate <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-sub" onclick="selectSub('vid-sub')">Subtitles <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-dub" onclick="selectSub('vid-dub')">Dub <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-up" onclick="selectSub('vid-up')">Upscale <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-faceswap" onclick="selectSub('vid-faceswap')">Face Swap <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-outfit" onclick="selectSub('vid-outfit')">Outfit Change <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-to3d" onclick="selectSub('vid-to3d')">2D→3D <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="vid-from3d" onclick="selectSub('vid-from3d')">3D→Video <span class="tab-status" hidden></span></button>
      <!-- 3D sub-tabs -->
      <button class="t2btn" data-sub="3d-generate" onclick="selectSub('3d-generate')">Generate Model <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="3d-img-to3d" onclick="selectSub('3d-img-to3d')">Image→3D <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="3d-vid-to3d" onclick="selectSub('3d-vid-to3d')">Video→3D <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="3d-from3d" onclick="selectSub('3d-from3d')">3D→Render <span class="tab-status" hidden></span></button>
      <!-- Audio sub-tabs -->
      <button class="t2btn" data-sub="aud-gen" onclick="selectSub('aud-gen')">Generate Music/SFX <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-music-dub" onclick="selectSub('aud-music-dub')">Music Dub <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-tts" onclick="selectSub('aud-tts')">TTS <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-clone" onclick="selectSub('aud-clone')">Voice Clone <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-convert" onclick="selectSub('aud-convert')">Voice Convert <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-stt" onclick="selectSub('aud-stt')">Transcribe <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-understand" onclick="selectSub('aud-understand')">Understand <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-stems" onclick="selectSub('aud-stems')">Stems <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="aud-clean" onclick="selectSub('aud-clean')">Clean <span class="tab-status" hidden></span></button>
      <!-- Profiles sub-tabs -->
      <button class="t2btn" data-sub="prof-char" onclick="selectSub('prof-char')">Characters <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="prof-env" onclick="selectSub('prof-env')">Environments <span class="tab-status" hidden></span></button>
      <button class="t2btn" data-sub="prof-voice" onclick="selectSub('prof-voice')">Voices <span class="tab-status" hidden></span></button>
    </div>

    <!-- ═══════════════ CHAT ═══════════════ -->
    <div class="panel active" id="panel-chat">
      <div class="chat-msgs" id="chat-msgs">
        <div class="chat-empty"><h3>CoderAI Studio</h3><p>Select a model from the sidebar</p></div>
      </div>
      <div class="chat-foot">
        <div class="attach-bar" id="attach-bar" style="display:none">
          <img id="attach-thumb" class="attach-thumb" src="" alt="">
          <span style="font-size:11px;color:var(--text-3)" id="attach-name"></span>
          <button class="btn btn-ghost btn-sm" onclick="clearAttach()" style="margin-left:auto">✕</button>
        </div>
        <div id="typing" class="progress" style="margin-bottom:.2rem;font-family:var(--mono)"></div>
        <div class="chat-row">
          <label id="attach-btn" style="display:none;cursor:pointer;align-self:flex-end;padding:.35rem" title="Attach image">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" style="width:15px;height:15px"><path d="M2 10l4-4 3 3 2-2 3 3"/><rect x="1" y="1" width="14" height="14" rx="2"/></svg>
            <input type="file" accept="image/*" id="vision-input" style="display:none" onchange="attachImg(this)">
          </label>
          <textarea id="chat-in" class="chat-ta" placeholder="Send a message…" rows="1"></textarea>
          <button class="btn btn-primary" id="send-btn" onclick="sendChat()" style="padding:.45rem .7rem;align-self:flex-end">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><line x1="14" y1="2" x2="7" y2="9"/><polygon points="14 2 10 14 7 9 2 6 14 2"/></svg>
          </button>
        </div>
        <div class="chat-hint">Enter to send · Shift+Enter for newline</div>
      </div>
    </div>

    <!-- ═══════════════ IMAGE ═══════════════ -->

    <!-- Generate -->
    <div class="panel" id="panel-img-gen">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-gen"></div>
          <div class="frow"><label class="fl">Prompt</label><textarea id="ig-prompt" class="fs" placeholder="Describe the image…" rows="4"></textarea></div>
          <div class="frow"><label class="fl">Negative prompt</label><textarea id="ig-neg" class="fs" placeholder="Things to avoid…" rows="2"></textarea></div>
          <div class="g2">
            <div class="frow"><label class="fl">Width</label><input type="number" id="ig-w" class="fi" value="1024" step="64"></div>
            <div class="frow"><label class="fl">Height</label><input type="number" id="ig-h" class="fi" value="1024" step="64"></div>
          </div>
          <div class="g2">
            <div class="frow"><label class="fl">Steps</label><input type="number" id="ig-steps" class="fi" value="30" min="1"></div>
            <div class="frow"><label class="fl">CFG</label><input type="number" id="ig-cfg" class="fi" value="7.5" step="0.5"></div>
          </div>
          <div class="frow"><label class="fl">Seed</label><input type="number" id="ig-seed" class="fi" placeholder="random"></div>
          <div class="frow"><label class="fl">Count (n)</label><input type="number" id="ig-n" class="fi" value="1" min="1" max="8"></div>
          <div class="frow"><label class="fl" title="Removes the model's built-in safety checker. Only use with uncensored/NSFW fine-tunes.">Disable safety filter</label><input type="checkbox" id="ig-nosafe"></div>
          <div class="frow" style="align-items:flex-start">
            <label class="fl" style="padding-top:.3rem">Characters</label>
            <div style="flex:1;display:flex;flex-direction:column;gap:.3rem">
              <div id="ig-char-profile-slots"></div>
              <button class="btn btn-ghost btn-sm" id="ig-add-char-btn" onclick="addCharProfileSlot('ig')" style="align-self:flex-start;font-size:11px">+ Add character</button>
            </div>
          </div>
          <div class="frow"><label class="fl">Character strength <span id="ig-char-sv">0.6</span></label>
            <input type="range" id="ig-char-str" min="0" max="1" step="0.05" value="0.6" oninput="$('ig-char-sv').textContent=this.value" style="width:100%">
          </div>
          <div class="frow" style="align-items:flex-start">
            <label class="fl" style="padding-top:.3rem">Environments</label>
            <div style="flex:1;display:flex;flex-direction:column;gap:.3rem">
              <div id="ig-env-profile-slots"></div>
              <button class="btn btn-ghost btn-sm" id="ig-add-env-btn" onclick="addEnvProfileSlot('ig')" style="align-self:flex-start;font-size:11px">+ Add environment</button>
            </div>
          </div>
          <div class="frow"><label class="fl">Environment strength <span id="ig-env-sv">0.6</span></label>
            <input type="range" id="ig-env-str" min="0" max="1" step="0.05" value="0.6" oninput="$('ig-env-sv').textContent=this.value" style="width:100%">
          </div>
          <div class="req-preview" id="ig-preview"></div>
          <button class="btn btn-primary" onclick="genImage()">Generate</button>
          <div class="progress" id="ig-prog"></div>
          <div class="gen-progress-wrap" id="ig-pbar-wrap">
            <div class="gen-progress-bar-bg"><div class="gen-progress-bar-fill" id="ig-pbar-fill"></div></div>
            <div class="gen-progress-label" id="ig-pbar-label"></div>
          </div>
        </div>
        <div class="gen-out" id="ig-out"><div class="gen-empty">Image will appear here</div></div>
      </div>
    </div>

    <!-- Edit (i2i) -->
    <div class="panel" id="panel-img-edit">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-edit"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="ie-src" accept="image/*" class="fi"></div>
          <div class="frow"><label class="fl">Prompt</label><textarea id="ie-prompt" class="fs" rows="4" placeholder="Describe the desired change…"></textarea></div>
          <div class="frow"><label class="fl">Strength <span id="ie-sv">0.75</span></label>
            <input type="range" id="ie-str" min="0" max="1" step="0.05" value="0.75" oninput="$('ie-sv').textContent=this.value" style="width:100%"></div>
          <div class="g2">
            <div class="frow"><label class="fl">Steps</label><input type="number" id="ie-steps" class="fi" value="30"></div>
            <div class="frow"><label class="fl">CFG</label><input type="number" id="ie-cfg" class="fi" value="7.5" step="0.5"></div>
          </div>
          <div class="frow"><label class="fl">Seed</label><input type="number" id="ie-seed" class="fi" placeholder="random"></div>
          <button class="btn btn-primary" onclick="genEdit()">Edit Image</button>
          <div class="progress" id="ie-prog"></div>
        </div>
        <div class="gen-out" id="ie-out"><div class="gen-empty">Edited image will appear here</div></div>
      </div>
    </div>

    <!-- Inpaint -->
    <div class="panel" id="panel-img-inpaint">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-inpaint"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="ip-src" accept="image/*" class="fi"></div>
          <div class="frow"><label class="fl">Mask image <span style="font-weight:400">(white = fill area)</span></label><input type="file" id="ip-mask" accept="image/*" class="fi"></div>
          <div class="frow"><label class="fl">Prompt</label><textarea id="ip-prompt" class="fs" rows="4" placeholder="What to fill the masked area with…"></textarea></div>
          <div class="g2">
            <div class="frow"><label class="fl">Steps</label><input type="number" id="ip-steps" class="fi" value="30"></div>
            <div class="frow"><label class="fl">CFG</label><input type="number" id="ip-cfg" class="fi" value="7.5" step="0.5"></div>
          </div>
          <div class="frow"><label class="fl">Strength <span id="ip-sv">0.99</span></label>
            <input type="range" id="ip-str" min="0" max="1" step="0.05" value="0.99" oninput="$('ip-sv').textContent=this.value" style="width:100%"></div>
          <div class="frow"><label class="fl">Seed</label><input type="number" id="ip-seed" class="fi" placeholder="random"></div>
          <button class="btn btn-primary" onclick="genInpaint()">Inpaint</button>
          <div class="progress" id="ip-prog"></div>
        </div>
        <div class="gen-out" id="ip-out"><div class="gen-empty">Result will appear here</div></div>
      </div>
    </div>

    <!-- Upscale image -->
    <div class="panel" id="panel-img-upscale">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-upscale"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="iu-src" accept="image/*" class="fi"></div>
          <div class="frow"><label class="fl">Scale factor</label>
            <select id="iu-scale" class="fselect"><option value="2">2×</option><option value="4" selected>4×</option><option value="8">8×</option></select>
          </div>
          <button class="btn btn-primary" onclick="genImgUpscale()">Upscale</button>
          <div class="progress" id="iu-prog"></div>
        </div>
        <div class="gen-out" id="iu-out"><div class="gen-empty">Upscaled image will appear here</div></div>
      </div>
    </div>

    <!-- Depth -->
    <div class="panel" id="panel-img-depth">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-depth"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="id-src" accept="image/*" class="fi"></div>
          <button class="btn btn-primary" onclick="genDepth()">Estimate Depth</button>
          <div class="progress" id="id-prog"></div>
        </div>
        <div class="gen-out" id="id-out"><div class="gen-empty">Depth map will appear here</div></div>
      </div>
    </div>

    <!-- Segment -->
    <div class="panel" id="panel-img-seg">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-seg"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="is-src" accept="image/*" class="fi"></div>
          <div class="frow"><label class="fl">Point prompts (optional)</label>
            <input type="text" id="is-pts" class="fi" placeholder='[[x,y], [x,y]]'></div>
          <div class="frow"><label class="fl">Box prompts (optional)</label>
            <input type="text" id="is-boxes" class="fi" placeholder='[[x1,y1,x2,y2]]'></div>
          <button class="btn btn-primary" onclick="genSegment()">Segment</button>
          <div class="progress" id="is-prog"></div>
        </div>
        <div class="gen-out" id="is-out"><div class="gen-empty">Segmented image will appear here</div></div>
      </div>
    </div>

    <!-- Face Swap -->
    <div class="panel" id="panel-img-faceswap">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-faceswap"></div>
          <div class="cap-card" id="cap-vid-faceswap" style="display:none"></div>
          <div class="frow"><label class="fl">Source face</label>
            <div class="media-input-group">
              <input type="file" id="fs-src" accept="image/*" class="fi" onchange="previewMediaInput('fs-src','fs-src-preview')">
              <button class="btn btn-ghost btn-sm" onclick="openWebcam('fs-src-cam','fs-src','fs-src-preview','photo')">📷 Webcam</button>
            </div>
          </div>
          <div id="fs-src-preview" class="media-preview"></div>
          <div id="fs-src-cam" class="webcam-box" style="display:none"></div>

          <div class="frow"><label class="fl">Target type</label>
            <select id="fs-type" class="fselect" onchange="fsFaceSwapTypeChange()">
              <option value="image">Image</option>
              <option value="video">Video</option>
            </select>
          </div>

          <div class="frow" id="fs-tgt-wrap"><label class="fl">Target image</label>
            <div class="media-input-group">
              <input type="file" id="fs-tgt" accept="image/*,video/*" class="fi" onchange="previewMediaInput('fs-tgt','fs-tgt-preview')">
              <button class="btn btn-ghost btn-sm" id="fs-tgt-cam-btn" onclick="openWebcam('fs-tgt-cam','fs-tgt','fs-tgt-preview','photo')">📷 Webcam</button>
            </div>
          </div>
          <div id="fs-tgt-preview" class="media-preview"></div>
          <div id="fs-tgt-cam" class="webcam-box" style="display:none"></div>

          <button class="btn btn-primary" onclick="genFaceSwap()">Swap Face</button>
          <div class="progress" id="fs-prog"></div>
        </div>
        <div class="gen-out" id="fs-out"><div class="gen-empty">Result will appear here</div></div>
      </div>
    </div>

    <!-- Deblur -->
    <div class="panel" id="panel-img-deblur">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-deblur"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="db-src" accept="image/*" class="fi" onchange="previewMediaInput('db-src','db-src-preview')"></div>
          <div id="db-src-preview" class="media-preview"></div>
          <div class="frow"><label class="fl">Strength <span id="db-sv">0.5</span></label>
            <input type="range" id="db-str" min="0.1" max="1" step="0.05" value="0.5" oninput="$('db-sv').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
          <button class="btn btn-primary" onclick="genDeblur()">Deblur</button>
          <div class="progress" id="db-prog"></div>
        </div>
        <div class="gen-out" id="db-out"><div class="gen-empty">Deblurred image will appear here</div></div>
      </div>
    </div>

    <!-- Unpixelate -->
    <div class="panel" id="panel-img-unpix">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-unpix"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="up-src" accept="image/*" class="fi" onchange="previewMediaInput('up-src','up-src-preview')"></div>
          <div id="up-src-preview" class="media-preview"></div>
          <div class="frow"><label class="fl">Scale</label>
            <select id="up-scale" class="fselect"><option value="2">2×</option><option value="4" selected>4×</option><option value="8">8×</option></select>
          </div>
          <button class="btn btn-primary" onclick="genUnpixelate()">Unpixelate</button>
          <div class="progress" id="up-prog"></div>
        </div>
        <div class="gen-out" id="up-out"><div class="gen-empty">Restored image will appear here</div></div>
      </div>
    </div>

    <!-- Outfit Change -->
    <div class="panel" id="panel-img-outfit">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-outfit"></div>
          <div class="cap-card" id="cap-vid-outfit" style="display:none"></div>
          <div class="frow"><label class="fl">Target type</label>
            <select id="ot-type" class="fselect" onchange="otOutfitTypeChange()">
              <option value="image">Image</option>
              <option value="video">Video</option>
            </select>
          </div>
          <div class="frow" id="ot-img-wrap"><label class="fl">Source image</label><input type="file" id="ot-src" accept="image/*" class="fi" onchange="previewMediaInput('ot-src','ot-src-preview')"></div>
          <div class="frow" id="ot-vid-wrap" style="display:none"><label class="fl">Source video</label><input type="file" id="ot-vid" accept="video/*" class="fi"></div>
          <div id="ot-src-preview" class="media-preview"></div>
          <div class="frow"><label class="fl">New outfit prompt</label><textarea id="ot-prompt" class="fs" rows="3" placeholder="e.g. red evening dress, formal suit, casual jeans and t-shirt…"></textarea></div>
          <div class="frow"><label class="fl">Negative prompt</label><input id="ot-neg" class="fi" placeholder="ugly, deformed…"></div>
          <div class="frow"><label class="fl">Mask (optional — auto-generated if blank)</label><input type="file" id="ot-mask" accept="image/*" class="fi"></div>
          <div class="g2">
            <div class="frow"><label class="fl">Steps</label><input type="number" id="ot-steps" class="fi" value="30"></div>
            <div class="frow"><label class="fl">CFG</label><input type="number" id="ot-cfg" class="fi" value="7.5" step="0.5"></div>
          </div>
          <div class="frow"><label class="fl">Seed</label><input type="number" id="ot-seed" class="fi" placeholder="random"></div>
          <button class="btn btn-primary" onclick="genOutfit()">Change Outfit</button>
          <div class="progress" id="ot-prog"></div>
        </div>
        <div class="gen-out" id="ot-out"><div class="gen-empty">Result will appear here</div></div>
      </div>
    </div>

    <!-- Image 2D → 3D -->
    <div class="panel" id="panel-img-to3d">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-to3d"></div>
          <div class="frow"><label class="fl">Source image</label><input type="file" id="i3-src" accept="image/*" class="fi" onchange="previewMediaInput('i3-src','i3-src-preview')"></div>
          <div id="i3-src-preview" class="media-preview"></div>
          <div class="frow"><label class="fl">Output method</label>
            <select id="i3-method" class="fselect">
              <option value="stereo">Stereo (side-by-side)</option>
              <option value="anaglyph">Anaglyph (red-cyan)</option>
              <option value="depth">Depth map</option>
              <option value="mesh">3D mesh (GLB)</option>
            </select>
          </div>
          <div class="frow" id="i3-shift-row"><label class="fl">Parallax shift <span id="i3-sv">20</span>px</label>
            <input type="range" id="i3-shift" min="5" max="50" step="1" value="20" oninput="$('i3-sv').textContent=this.value" style="width:100%">
          </div>
          <button class="btn btn-primary" onclick="genImageTo3D()">Convert to 3D</button>
          <div class="progress" id="i3-prog"></div>
        </div>
        <div class="gen-out" id="i3-out"><div class="gen-empty">3D output will appear here</div></div>
      </div>
    </div>

    <!-- Image 3D → 2D -->
    <div class="panel" id="panel-img-from3d">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-img-from3d"></div>
          <div class="frow"><label class="fl">3D model file (GLB/OBJ)</label><input type="file" id="f3-src" accept=".glb,.obj,.gltf" class="fi"></div>
          <div class="frow"><label class="fl">Format</label>
            <select id="f3-fmt" class="fselect">
              <option value="glb">GLB</option>
              <option value="obj">OBJ</option>
            </select>
          </div>
          <div class="g3">
            <div class="frow"><label class="fl">Distance</label><input type="number" id="f3-dist" class="fi" value="2.0" step="0.5"></div>
            <div class="frow"><label class="fl">Elevation°</label><input type="number" id="f3-elev" class="fi" value="30" step="5"></div>
            <div class="frow"><label class="fl">Azimuth°</label><input type="number" id="f3-azim" class="fi" value="45" step="5"></div>
          </div>
          <div class="g2">
            <div class="frow"><label class="fl">Width</label><input type="number" id="f3-w" class="fi" value="512" step="64"></div>
            <div class="frow"><label class="fl">Height</label><input type="number" id="f3-h" class="fi" value="512" step="64"></div>
          </div>
          <button class="btn btn-primary" onclick="genImageFrom3D()">Render to Image</button>
          <div class="progress" id="f3-prog"></div>
        </div>
        <div class="gen-out" id="f3-out"><div class="gen-empty">Rendered image will appear here</div></div>
      </div>
    </div>

    <!-- ═══════════════ VIDEO ═══════════════ -->

    <!-- T2V -->
    <div class="panel" id="panel-vid-t2v"><div class="gen-wrap">
      <div class="gen-ctrl" id="vid-shared-ctrl-t2v">
        {# vid_ctrl and vid_postproc are injected by JS below #}
        <button class="btn btn-primary" onclick="genVideo('t2v')">Generate Video</button>
        <div class="progress" id="vt-prog"></div>
      </div>
      <div class="gen-out" id="vt-out"><div class="gen-empty">Video will appear here</div></div>
    </div></div>

    <!-- I2V -->
    <div class="panel" id="panel-vid-i2v"><div class="gen-wrap">
      <div class="gen-ctrl">
          <div class="cap-card" id="cap-vid-i2v"></div>
          <div class="cap-card" id="cap-vid-t2v"></div>
        <div class="frow"><label class="fl">Source image</label><input type="file" id="vi-src" accept="image/*" class="fi"></div>
        <div class="frow"><label class="fl">Motion prompt (optional)</label><textarea id="vi-prompt" class="fs" rows="2" placeholder="How should the image animate?"></textarea></div>
        <div class="g2">
          <div class="frow"><label class="fl">Frames</label><input type="number" id="vi-frames" class="fi" value="16"></div>
          <div class="frow"><label class="fl">FPS</label><input type="number" id="vi-fps" class="fi" value="8"></div>
        </div>
        <div class="g2">
          <div class="frow"><label class="fl">Steps</label><input type="number" id="vi-steps" class="fi" value="25"></div>
          <div class="frow"><label class="fl">Guidance</label><input type="number" id="vi-cfg" class="fi" value="7.5" step="0.5"></div>
        </div>
        <div class="frow"><label class="fl">Seed</label><input type="number" id="vi-seed" class="fi" placeholder="random"></div>
        <div class="frow"><label class="fl">Camera motion</label>
          <select id="vi-cam" class="fselect">
            <option value="">None</option>
            <option value="zoom-in">Zoom in</option><option value="zoom-out">Zoom out</option>
            <option value="pan-left">Pan left</option><option value="pan-right">Pan right</option>
            <option value="tilt-up">Tilt up</option><option value="tilt-down">Tilt down</option>
            <option value="rotate">Rotate</option>
          </select>
        </div>
        <!-- Character consistency -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Characters &amp; Environment</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.6rem">
            <div id="vi-char-profile-slots"></div>
            <button class="btn btn-ghost btn-sm" id="vi-add-char-btn" onclick="addCharProfileSlot('vi')" style="align-self:flex-start;font-size:11px">+ Add saved character</button>
            <div id="vi-char-slots"></div>
            <button class="btn btn-ghost btn-sm" onclick="addCharSlot('vi')" style="align-self:flex-start;font-size:11px">+ Add character images</button>
            <div class="frow"><label class="fl">Character strength <span id="vi-cs">0.80</span></label>
              <input type="range" id="vi-cstr" min="0" max="1" step="0.05" value="0.8" oninput="$('vi-cs').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
            <div id="vi-env-profile-slots"></div>
            <button class="btn btn-ghost btn-sm" id="vi-add-env-btn" onclick="addEnvProfileSlot('vi')" style="align-self:flex-start;font-size:11px">+ Add environment</button>
            <div class="frow"><label class="fl">Environment strength <span id="vi-es">0.60</span></label>
              <input type="range" id="vi-estr" min="0" max="1" step="0.05" value="0.6" oninput="$('vi-es').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
          </div>
        </details>
        <div id="vi-dialog-section"></div>
        <button class="btn btn-primary" onclick="genVideo('i2v')">Animate</button>
        <div class="progress" id="vi-prog"></div>
        <div class="gen-progress-wrap" id="vi-pbar-wrap">
          <div class="gen-progress-bar-bg"><div class="gen-progress-bar-fill" id="vi-pbar-fill"></div></div>
          <div class="gen-progress-label" id="vi-pbar-label"></div>
        </div>
      </div>
      <div class="gen-out" id="vi-out"><div class="gen-empty">Animated video will appear here</div></div>
    </div></div>

    <!-- V2V -->
    <div class="panel" id="panel-vid-v2v"><div class="gen-wrap">
      <div class="gen-ctrl">
          <div class="cap-card" id="cap-vid-v2v"></div>
        <div class="frow"><label class="fl">Source video</label><input type="file" id="vv-src" accept="video/*" class="fi"></div>
        <div class="frow"><label class="fl">Prompt</label><textarea id="vv-prompt" class="fs" rows="3" placeholder="Describe the transformation…"></textarea></div>
        <div class="frow"><label class="fl">Strength <span id="vv-sv">0.70</span></label>
          <input type="range" id="vv-str" min="0" max="1" step="0.05" value="0.70" oninput="$('vv-sv').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
        <div class="g2">
          <div class="frow"><label class="fl">Steps</label><input type="number" id="vv-steps" class="fi" value="25"></div>
          <div class="frow"><label class="fl">Guidance</label><input type="number" id="vv-cfg" class="fi" value="7.5" step="0.5"></div>
        </div>
        <div class="frow"><label class="fl">Seed</label><input type="number" id="vv-seed" class="fi" placeholder="random"></div>
        <div class="frow"><label class="fl">Camera motion</label>
          <select id="vv-cam" class="fselect">
            <option value="">None</option>
            <option value="zoom-in">Zoom in</option><option value="zoom-out">Zoom out</option>
            <option value="pan-left">Pan left</option><option value="pan-right">Pan right</option>
            <option value="tilt-up">Tilt up</option><option value="tilt-down">Tilt down</option>
            <option value="rotate">Rotate</option>
          </select>
        </div>
        <!-- Characters & Environment -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Characters &amp; Environment</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.6rem">
            <div id="vv-char-profile-slots"></div>
            <button class="btn btn-ghost btn-sm" id="vv-add-char-btn" onclick="addCharProfileSlot('vv')" style="align-self:flex-start;font-size:11px">+ Add saved character</button>
            <div id="vv-char-slots"></div>
            <button class="btn btn-ghost btn-sm" onclick="addCharSlot('vv')" style="align-self:flex-start;font-size:11px">+ Add character images</button>
            <div class="frow"><label class="fl">Character strength <span id="vv-cs">0.80</span></label>
              <input type="range" id="vv-cstr" min="0" max="1" step="0.05" value="0.8" oninput="$('vv-cs').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
            <div id="vv-env-profile-slots"></div>
            <button class="btn btn-ghost btn-sm" id="vv-add-env-btn" onclick="addEnvProfileSlot('vv')" style="align-self:flex-start;font-size:11px">+ Add environment</button>
            <div class="frow"><label class="fl">Environment strength <span id="vv-es">0.60</span></label>
              <input type="range" id="vv-estr" min="0" max="1" step="0.05" value="0.6" oninput="$('vv-es').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
          </div>
        </details>
        <div id="vv-dialog-section"></div>
        <button class="btn btn-primary" onclick="genVideo('v2v')">Transform</button>
        <div class="progress" id="vv-prog"></div>
        <div class="gen-progress-wrap" id="vv-pbar-wrap">
          <div class="gen-progress-bar-bg"><div class="gen-progress-bar-fill" id="vv-pbar-fill"></div></div>
          <div class="gen-progress-label" id="vv-pbar-label"></div>
        </div>
      </div>
      <div class="gen-out" id="vv-out"><div class="gen-empty">Transformed video will appear here</div></div>
    </div></div>

    <!-- Ti2V: Text + Image → Video -->
    <div class="panel" id="panel-vid-ti2v"><div class="gen-wrap">
      <div class="gen-ctrl">
          <div class="cap-card" id="cap-vid-ti2v"></div>
        <div class="frow"><label class="fl">Initial image</label><input type="file" id="ti-init" accept="image/*" class="fi"></div>
        <div class="frow"><label class="fl">End image (optional — for frame interpolation)</label><input type="file" id="ti-end" accept="image/*" class="fi"></div>
        <div class="frow"><label class="fl">Source video (optional — enables V2V mode, overrides image)</label><input type="file" id="ti-vid" accept="video/*" class="fi"></div>
        <div class="frow"><label class="fl">Strength (V2V denoising) <span id="ti-vs">0.70</span></label>
          <input type="range" id="ti-vstr" min="0" max="1" step="0.05" value="0.70" oninput="$('ti-vs').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
        <div class="frow"><label class="fl">Prompt</label><textarea id="ti-prompt" class="fs" rows="3" placeholder="Describe the video motion / story…"></textarea></div>
        <div class="frow"><label class="fl">Negative prompt</label><textarea id="ti-neg" class="fs" rows="2" placeholder="Things to avoid…"></textarea></div>
        <div class="g2">
          <div class="frow"><label class="fl">Width</label><input type="number" id="ti-w" class="fi" value="512" step="64"></div>
          <div class="frow"><label class="fl">Height</label><input type="number" id="ti-h" class="fi" value="512" step="64"></div>
        </div>
        <div class="g2">
          <div class="frow"><label class="fl">Frames</label><input type="number" id="ti-frames" class="fi" value="16"></div>
          <div class="frow"><label class="fl">FPS</label><input type="number" id="ti-fps" class="fi" value="8"></div>
        </div>
        <div class="g2">
          <div class="frow"><label class="fl">Steps</label><input type="number" id="ti-steps" class="fi" value="25"></div>
          <div class="frow"><label class="fl">Guidance</label><input type="number" id="ti-cfg" class="fi" value="7.5" step="0.5"></div>
        </div>
        <div class="frow"><label class="fl">Camera motion</label>
          <select id="ti-cam" class="fselect">
            <option value="">None</option>
            <option value="zoom-in">Zoom in</option><option value="zoom-out">Zoom out</option>
            <option value="pan-left">Pan left</option><option value="pan-right">Pan right</option>
            <option value="tilt-up">Tilt up</option><option value="tilt-down">Tilt down</option>
            <option value="rotate">Rotate</option>
          </select>
        </div>
        <div class="frow"><label class="fl">Seed</label><input type="number" id="ti-seed" class="fi" placeholder="random"></div>
        <div class="frow"><label class="fl" title="Removes the model's built-in safety checker. Only use with uncensored/NSFW fine-tunes.">Disable safety filter</label><input type="checkbox" id="ti-nosafe"></div>
        <!-- Characters & Environment -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Characters &amp; Environment</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.6rem">
            <div id="ti-char-profile-slots"></div>
            <button class="btn btn-ghost btn-sm" id="ti-add-char-btn" onclick="addCharProfileSlot('ti')" style="align-self:flex-start;font-size:11px">+ Add saved character</button>
            <div id="ti-char-slots"></div>
            <button class="btn btn-ghost btn-sm" onclick="addCharSlot('ti')" style="align-self:flex-start;font-size:11px">+ Add character images</button>
            <div class="frow"><label class="fl">Character strength <span id="ti-cs">0.80</span></label>
              <input type="range" id="ti-cstr" min="0" max="1" step="0.05" value="0.8" oninput="$('ti-cs').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
            <div id="ti-env-profile-slots"></div>
            <button class="btn btn-ghost btn-sm" id="ti-add-env-btn" onclick="addEnvProfileSlot('ti')" style="align-self:flex-start;font-size:11px">+ Add environment</button>
            <div class="frow"><label class="fl">Environment strength <span id="ti-es">0.60</span></label>
              <input type="range" id="ti-estr" min="0" max="1" step="0.05" value="0.6" oninput="$('ti-es').textContent=(+this.value).toFixed(2)" style="width:100%"></div>
          </div>
        </details>
        <div id="ti-dialog-section"></div>
        <!-- Audio -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Add audio</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.4rem">
            <div class="frow"><label class="fl">Audio type</label>
              <select id="ti-atype" class="fselect">
                <option value="">None</option>
                <option value="music">Music</option><option value="sfx">SFX</option>
                <option value="speech">Speech / TTS</option><option value="ambient">Ambient</option>
              </select>
            </div>
            <div class="frow"><label class="fl">Audio prompt</label><textarea id="ti-aprompt" class="fs" rows="2" placeholder="e.g. epic orchestral soundtrack"></textarea></div>
            <div class="frow"><label class="fl">TTS text (for speech)</label><textarea id="ti-ttstext" class="fs" rows="2"></textarea></div>
            <div class="g2">
              <div class="frow"><label class="fl">TTS voice</label><input id="ti-voice" class="fi" placeholder="af_sky"></div>
              <div class="frow"><label class="fl">Speed</label><input type="number" id="ti-speed" class="fi" value="1.0" step="0.1" min="0.5"></div>
            </div>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer">
              <input type="checkbox" id="ti-lipsync"> Lip-sync to speech
            </label>
          </div>
        </details>
        <!-- Subtitles -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Subtitles</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.4rem">
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="ti-gensub"> Generate subtitles</label>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="ti-burnsub"> Burn subtitles into video</label>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="ti-transsub"> Translate subtitles</label>
            <div class="frow"><label class="fl">Target language</label><input id="ti-sublang" class="fi" placeholder="es, fr, de, it…"></div>
          </div>
        </details>
        <!-- Post-processing -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Post-processing</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.4rem">
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="ti-upscale"> Upscale output</label>
            <div class="frow"><label class="fl">Upscale factor</label>
              <select id="ti-upfact" class="fselect"><option value="2">2×</option><option value="4">4×</option></select>
            </div>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="ti-interp"> Frame interpolation</label>
            <div class="frow"><label class="fl">FPS multiplier</label>
              <select id="ti-fmult" class="fselect"><option value="2">2×</option><option value="4">4×</option></select>
            </div>
          </div>
        </details>
        <button class="btn btn-primary" onclick="genTi2V()" style="margin-top:.25rem">Generate</button>
        <div class="progress" id="ti-prog"></div>
        <div class="gen-progress-wrap" id="ti-pbar-wrap">
          <div class="gen-progress-bar-bg"><div class="gen-progress-bar-fill" id="ti-pbar-fill"></div></div>
          <div class="gen-progress-label" id="ti-pbar-label"></div>
        </div>
      </div>
      <div class="gen-out" id="ti-out"><div class="gen-empty">Video will appear here</div></div>
    </div></div>

    <!-- Interpolate -->
    <div class="panel" id="panel-vid-interp"><div class="gen-wrap">
      <div class="gen-ctrl">
          <div class="cap-card" id="cap-vid-interp"></div>
        <div class="frow"><label class="fl">Source video (or use frame images below)</label><input type="file" id="vc-src" accept="video/*" class="fi"></div>
        <div class="frow"><label class="fl">— OR — First frame</label><input type="file" id="vc-init" accept="image/*" class="fi"></div>
        <div class="frow"><label class="fl">Last frame</label><input type="file" id="vc-end" accept="image/*" class="fi"></div>
        <div class="frow"><label class="fl">FPS multiplier</label>
          <select id="vc-mult" class="fselect"><option value="2">2×</option><option value="4">4×</option><option value="8">8×</option></select>
        </div>
        <button class="btn btn-primary" onclick="genInterp()">Interpolate</button>
        <div class="progress" id="vc-prog"></div>
      </div>
      <div class="gen-out" id="vc-out"><div class="gen-empty">Interpolated video will appear here</div></div>
    </div></div>

    <!-- Subtitles -->
    <div class="panel" id="panel-vid-sub"><div class="gen-wrap">
      <div class="gen-ctrl">
          <div class="cap-card" id="cap-vid-sub"></div>
        <div class="frow"><label class="fl">Source video</label><input type="file" id="vs-src" accept="video/*" class="fi"></div>
        <div class="frow"><label class="fl">Language hint</label><input id="vs-lang" class="fi" placeholder="en, fr, de… (auto-detect if blank)"></div>
        <div class="frow"><label class="fl">Output format</label>
          <select id="vs-fmt" class="fselect"><option value="srt">SRT text</option><option value="burned_video">Burn into video</option></select>
        </div>
        <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="vs-translate"> Translate subtitles</label>
        <div class="frow"><label class="fl">Target language</label><input id="vs-tlang" class="fi" placeholder="es, fr, de…"></div>
        <button class="btn btn-primary" onclick="genSubtitles()">Generate Subtitles</button>
        <div class="progress" id="vs-prog"></div>
      </div>
      <div class="gen-out" id="vs-out"><div class="gen-empty">Result will appear here</div></div>
    </div></div>

    <!-- Dub -->
    <div class="panel" id="panel-vid-dub"><div class="gen-wrap">
      <div class="gen-ctrl">
        <div class="cap-card" id="cap-vid-dub"></div>
        <div class="cap-preserve-note" id="vd-preserve-note">Voice identity, timing, emotion, and singing preservation depend on model support and may fall back to a simpler dubbed track.</div>
        <div class="frow"><label class="fl">Source video</label><input type="file" id="vd-src" accept="video/*" class="fi"></div>
        <div class="g2">
          <div class="frow"><label class="fl">Source lang</label><input id="vd-slang" class="fi" placeholder="en"></div>
          <div class="frow"><label class="fl">Target lang</label><input id="vd-tlang" class="fi" placeholder="es, fr, de…"></div>
        </div>
        <div class="g2">
          <label class="cap-toggle"><input type="checkbox" id="vd-preserve-voice" checked> Preserve speaker identity</label>
          <label class="cap-toggle"><input type="checkbox" id="vd-preserve-tone" checked> Preserve emotional tone</label>
        </div>
        <div class="g2">
          <label class="cap-toggle"><input type="checkbox" id="vd-preserve-timing" checked> Preserve pacing / timing</label>
          <label class="cap-toggle"><input type="checkbox" id="vd-preserve-singing"> Preserve singing style</label>
        </div>
        <label class="cap-toggle"><input type="checkbox" id="vd-lyrics"> Translate detected lyrics</label>
        <div class="frow"><label class="fl">Fallback mode</label>
          <select id="vd-track-mode" class="fselect">
            <option value="speech-only">Replace spoken track only</option>
            <option value="localized-mix">Rebuild localized track</option>
          </select>
        </div>
        <label class="cap-toggle"><input type="checkbox" id="vd-burn"> Burn subtitles into result</label>
        <div class="req-preview" id="vd-preview"></div>
        <button class="btn btn-primary" onclick="genDub()">Dub Video</button>
        <div class="progress" id="vd-prog"></div>
      </div>
      <div class="gen-out" id="vd-out"><div class="gen-empty">Dubbed video will appear here</div></div>
    </div></div>

    <!-- Video upscale -->
    <div class="panel" id="panel-vid-up"><div class="gen-wrap">
      <div class="gen-ctrl">
          <div class="cap-card" id="cap-vid-up"></div>
        <div class="frow"><label class="fl">Source video</label><input type="file" id="vu-src" accept="video/*" class="fi"></div>
        <div class="frow"><label class="fl">Scale factor</label>
          <select id="vu-scale" class="fselect"><option value="2">2×</option><option value="4">4×</option></select>
        </div>
        <button class="btn btn-primary" onclick="genVidUpscale()">Upscale</button>
        <div class="progress" id="vu-prog"></div>
      </div>
      <div class="gen-out" id="vu-out"><div class="gen-empty">Upscaled video will appear here</div></div>
    </div></div>

    <!-- Video 2D → 3D -->
    <div class="panel" id="panel-vid-to3d"><div class="gen-wrap">
      <div class="gen-ctrl">
        <div class="cap-card" id="cap-vid-to3d"></div>
        <div class="frow"><label class="fl">Source video</label><input type="file" id="v3-src" accept="video/*" class="fi"></div>
        <div class="frow"><label class="fl">Output method</label>
          <select id="v3-method" class="fselect">
            <option value="anaglyph">Anaglyph (red-cyan)</option>
            <option value="stereo">Stereo (side-by-side)</option>
            <option value="depth">Depth video</option>
          </select>
        </div>
        <div class="frow"><label class="fl">Parallax shift <span id="v3-sv">15</span>px</label>
          <input type="range" id="v3-shift" min="5" max="40" step="1" value="15" oninput="$('v3-sv').textContent=this.value" style="width:100%">
        </div>
        <button class="btn btn-primary" onclick="genVideoTo3D()">Convert to 3D</button>
        <div class="progress" id="v3-prog"></div>
      </div>
      <div class="gen-out" id="v3-out"><div class="gen-empty">3D video will appear here</div></div>
    </div></div>

    <!-- Video from 3D model (turntable) -->
    <div class="panel" id="panel-vid-from3d"><div class="gen-wrap">
      <div class="gen-ctrl">
        <div class="cap-card" id="cap-vid-from3d"></div>
        <div class="frow"><label class="fl">3D model file (GLB/OBJ)</label><input type="file" id="vf3-src" accept=".glb,.obj,.gltf" class="fi"></div>
        <div class="frow"><label class="fl">Format</label>
          <select id="vf3-fmt" class="fselect">
            <option value="glb">GLB</option>
            <option value="obj">OBJ</option>
          </select>
        </div>
        <div class="g2">
          <div class="frow"><label class="fl">Frames</label><input type="number" id="vf3-frames" class="fi" value="36"></div>
          <div class="frow"><label class="fl">FPS</label><input type="number" id="vf3-fps" class="fi" value="12"></div>
        </div>
        <div class="g2">
          <div class="frow"><label class="fl">Elevation°</label><input type="number" id="vf3-elev" class="fi" value="20" step="5"></div>
          <div class="frow"><label class="fl">Distance</label><input type="number" id="vf3-dist" class="fi" value="2.5" step="0.5"></div>
        </div>
        <div class="g2">
          <div class="frow"><label class="fl">Width</label><input type="number" id="vf3-w" class="fi" value="512" step="64"></div>
          <div class="frow"><label class="fl">Height</label><input type="number" id="vf3-h" class="fi" value="512" step="64"></div>
        </div>
        <button class="btn btn-primary" onclick="genVideoFrom3D()">Render Turntable</button>
        <div class="progress" id="vf3-prog"></div>
      </div>
      <div class="gen-out" id="vf3-out"><div class="gen-empty">Turntable video will appear here</div></div>
    </div></div>

    <!-- ═══════════════ AUDIO ═══════════════ -->

    <!-- Music/SFX generation -->
    <div class="panel" id="panel-aud-gen">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-gen"></div>
          <div class="frow"><label class="fl">Prompt</label><textarea id="ag-prompt" class="fs" rows="4" placeholder="e.g. upbeat electronic dance music with synth bass…"></textarea></div>
          <div class="g2">
            <div class="frow"><label class="fl">Duration (sec)</label><input type="number" id="ag-dur" class="fi" value="10" min="1" max="300"></div>
            <div class="frow"><label class="fl">Temperature</label><input type="number" id="ag-temp" class="fi" value="1.0" step="0.1" min="0.1"></div>
          </div>
          <div class="g2">
            <div class="frow"><label class="fl">Top-k</label><input type="number" id="ag-topk" class="fi" value="250" min="1"></div>
            <div class="frow"><label class="fl">CFG coef</label><input type="number" id="ag-cfg" class="fi" value="3.0" step="0.5" min="0"></div>
          </div>
          <div class="frow"><label class="fl">Melody reference (optional)</label><input type="file" id="ag-melody" accept="audio/*" class="fi"></div>
          <div class="frow"><label class="fl">Seed</label><input type="number" id="ag-seed" class="fi" placeholder="random"></div>
          <div class="req-preview" id="ag-preview"></div>
          <button class="btn btn-primary" onclick="genAudio()">Generate</button>
          <div class="progress" id="ag-prog"></div>
          <div class="gen-progress-wrap" id="ag-pbar-wrap">
            <div class="gen-progress-bar-bg"><div class="gen-progress-bar-fill" id="ag-pbar-fill"></div></div>
            <div class="gen-progress-label" id="ag-pbar-label"></div>
          </div>
        </div>
        <div class="gen-out" id="ag-out"><div class="gen-empty">Generated audio will appear here</div></div>
      </div>
    </div>

    <!-- TTS -->
    <div class="panel" id="panel-aud-tts">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-tts"></div>
          <div class="frow"><label class="fl">Text to speak</label><textarea id="at-text" class="fs" rows="6" placeholder="Enter text…"></textarea></div>
          <div class="g2">
            <div class="frow"><label class="fl">Voice ID</label><input id="at-voice" class="fi" placeholder="af_sky, en-US-Jenny…"></div>
            <div class="frow"><label class="fl">Speed</label><input type="number" id="at-speed" class="fi" value="1.0" step="0.1"></div>
          </div>
          <div class="frow"><label class="fl">Voice profile <span class="small muted">(overrides Voice ID, uses cloning)</span></label>
            <select id="tts-voice-profile" class="fi" data-placeholder="— None (use Voice ID above) —">
              <option value="">— None (use Voice ID above) —</option>
            </select>
          </div>
          <button class="btn btn-primary" onclick="genTTS()">Synthesize</button>
          <div class="progress" id="at-prog"></div>
          <div class="req-preview" id="at-preview"></div>
        </div>
        <div class="gen-out" id="at-out"><div class="gen-empty">Audio will appear here</div></div>
      </div>
    </div>

    <!-- Voice Clone -->
    <div class="panel" id="panel-aud-clone">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-clone"></div>
          <div class="frow"><label class="fl">Text to synthesize</label><textarea id="vc-text" class="fs" rows="4" placeholder="Enter text to speak in the cloned voice…"></textarea></div>
          <div class="frow"><label class="fl">Voice profile (saved)</label>
            <select id="vc-voice" class="fselect">
              <option value="">— Or upload reference audio below —</option>
            </select>
          </div>
          <div class="frow"><label class="fl">Reference audio (upload or record)</label>
            <div class="media-input-group">
              <input type="file" id="vc-ref" accept="audio/*" class="fi">
              <button class="btn btn-ghost btn-sm" onclick="openWebcam('vc-ref-cam','vc-ref',null,'audio')">🎤 Record</button>
            </div>
          </div>
          <div id="vc-ref-cam" class="webcam-box" style="display:none"></div>
          <div class="frow"><label class="fl">Transcript of reference audio</label><input id="vc-reftxt" class="fi" placeholder="What the reference audio says…"></div>          <div class="g2">
            <div class="frow"><label class="fl">Speed</label><input type="number" id="vc-speed" class="fi" value="1.0" step="0.1" min="0.5"></div>
            <div class="frow"><label class="fl">Seed</label><input type="number" id="vc-seed" class="fi" placeholder="random"></div>
          </div>
          <button class="btn btn-primary" onclick="genVoiceClone()">Clone & Synthesize</button>
          <div class="progress" id="vc-prog"></div>
          <details style="margin-top:.75rem">
            <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Save voice profile</summary>
            <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.4rem">
              <div class="frow"><label class="fl">Profile name</label><input id="vc-savename" class="fi" placeholder="my-voice"></div>
              <div class="frow"><label class="fl">Description</label><input id="vc-savedesc" class="fi" placeholder="Optional description"></div>
              <div class="frow"><label class="fl">Reference audio (upload or record)</label>
                <div class="media-input-group">
                  <input type="file" id="vc-saveref" accept="audio/*" class="fi">
                  <button class="btn btn-ghost btn-sm" onclick="openWebcam('vc-saveref-cam','vc-saveref',null,'audio')">🎤 Record</button>
                </div>
              </div>
              <div id="vc-saveref-cam" class="webcam-box" style="display:none"></div>              <div class="frow"><label class="fl">Transcript</label><input id="vc-savetxt" class="fi" placeholder="What the audio says"></div>
              <button class="btn btn-ghost btn-sm" onclick="saveVoiceProfile()">Save Profile</button>
              <div class="progress" id="vc-saveprog"></div>
            </div>
          </details>
        </div>
        <div class="gen-out" id="vc-out"><div class="gen-empty">Cloned voice audio will appear here</div></div>
      </div>
    </div>

    <!-- Voice Convert (SVC) -->
    <div class="panel" id="panel-aud-convert">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-convert"></div>
          <p style="font-size:12px;color:var(--text-3);margin:0 0 .5rem">
            Converts only the <strong>timbre</strong> of the source audio to match the target voice,
            preserving pitch, melody, rhythm and expression.
            Use <strong>Singing mode</strong> for music and songs.
          </p>
          <div class="frow"><label class="fl">Source audio (to convert)</label>
            <div class="media-input-group">
              <input type="file" id="cv-src" accept="audio/*" class="fi">
              <button class="btn btn-ghost btn-sm" onclick="openWebcam('cv-src-cam','cv-src',null,'audio')">🎤 Record</button>
            </div>
          </div>
          <div id="cv-src-cam" class="webcam-box" style="display:none"></div>
          <div class="frow"><label class="fl">Target voice profile</label>
            <select id="cv-voice" class="fselect">
              <option value="">— Or upload reference audio below —</option>
            </select>
          </div>
          <div class="frow"><label class="fl">Target voice (upload)</label>
            <div class="media-input-group">
              <input type="file" id="cv-ref" accept="audio/*" class="fi">
              <button class="btn btn-ghost btn-sm" onclick="openWebcam('cv-ref-cam','cv-ref',null,'audio')">🎤 Record</button>
            </div>
          </div>
          <div id="cv-ref-cam" class="webcam-box" style="display:none"></div>
          <div class="frow"><label class="fl">Mode</label>
            <select id="cv-mode" class="fselect">
              <option value="speech">Speech (fast)</option>
              <option value="singing">Singing / Music (pitch-accurate)</option>
            </select>
          </div>
          <div class="g2">
            <div class="frow"><label class="fl">Pitch shift (semitones)</label><input type="number" id="cv-pitch" class="fi" value="0" min="-12" max="12"></div>
            <div class="frow"><label class="fl">Diffusion steps</label><input type="number" id="cv-steps" class="fi" value="10" min="1" max="50"></div>
          </div>
          <button class="btn btn-primary" onclick="genVoiceConvert()">Convert Voice</button>
          <div class="progress" id="cv-prog"></div>
        </div>
        <div class="gen-out" id="cv-out"><div class="gen-empty">Converted audio will appear here</div></div>
      </div>
    </div>

    <!-- STT -->
    <div class="panel" id="panel-aud-stt">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-stt"></div>
          <div class="frow"><label class="fl">Audio / video file</label><input type="file" id="as-file" accept="audio/*,video/*" class="fi"></div>
          <div class="g2">
            <div class="frow"><label class="fl">Language</label><input id="as-lang" class="fi" placeholder="en, fr…"></div>
            <div class="frow"><label class="fl">Hint</label><input id="as-prompt" class="fi" placeholder="context hint…"></div>
          </div>
          <button class="btn btn-primary" onclick="genSTT()">Transcribe</button>
          <div class="progress" id="as-prog"></div>
          <div class="req-preview" id="as-preview"></div>
        </div>
        <div class="gen-out" id="as-out">
          <div class="gen-out-inner"><div class="gen-empty">Transcript will appear here</div></div>
        </div>
      </div>
    </div>

    <!-- Music dubbing -->
    <div class="panel" id="panel-aud-music-dub">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-music-dub"></div>
          <div class="frow"><label class="fl">Source song / vocal mix</label><input type="file" id="amd-file" accept="audio/*,video/*" class="fi"></div>
          <div class="g2">
            <div class="frow"><label class="fl">Source language</label><input id="amd-slang" class="fi" placeholder="auto, en, ja…"></div>
            <div class="frow"><label class="fl">Target language</label><input id="amd-tlang" class="fi" placeholder="en, es, fr…"></div>
          </div>
          <div class="frow"><label class="fl">Notes</label><textarea id="amd-notes" class="fs" rows="3" placeholder="Explain whether the priority is lyric translation, singability, or preserving the original arrangement."></textarea></div>
          <div class="cap-preserve-note">
            <strong>Current studio behavior:</strong> this panel can now run transcript-first intake through existing speech-to-text primitives, then return a truthful partial workflow plan for lyric localization.
          </div>
          <div class="cap-note"><strong>Fallback path:</strong> transcribe lyrics, translate/adapt them, then resynthesize guide vocals or use Voice Convert on isolated vocals before recombining outside this panel. Rhythm-safe lyric adaptation and automatic mix replacement are still not exposed here.</div>
          <button class="btn btn-primary" onclick="genMusicDubPlan()">Analyze Music Dub Path</button>
          <div class="progress" id="amd-prog"></div>
          <div class="req-preview" id="amd-preview"></div>
        </div>
        <div class="gen-out" id="amd-out">
          <div class="gen-out-inner"><div class="gen-empty">Transcript, limitations, and next-step guidance will appear here.</div></div>
        </div>
      </div>
    </div>

    <!-- Audio understanding -->
    <div class="panel" id="panel-aud-understand">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-understand"></div>
          <div class="frow"><label class="fl">Source audio / video</label><input type="file" id="au-file" accept="audio/*,video/*" class="fi"></div>
          <div class="frow"><label class="fl">Analysis goal</label><textarea id="au-goal" class="fs" rows="4" placeholder="Examples: summarize the meeting, extract speakers and topics, describe the soundscape, identify notable events."></textarea></div>
          <div class="cap-preserve-note">
            <strong>Current studio behavior:</strong> understanding runs as a transcript-first composed request. If a text/chat model is also selected, Studio can ask it to summarize or reason over the transcript.
          </div>
          <div class="cap-note"><strong>Fallback path:</strong> if no text/chat model is selected, this panel still returns the transcript so you can continue reasoning manually in Chat.</div>
          <div class="frow"><label class="fl">Reasoning / summary model <span style="font-weight:400">(optional)</span></label><input id="au-text-model" class="fi" placeholder="model id for chat summarization"></div>
          <button class="btn btn-primary" onclick="genAudioUnderstand()">Analyze Audio</button>
          <div class="progress" id="au-prog"></div>
          <div class="req-preview" id="au-preview"></div>
        </div>
        <div class="gen-out" id="au-out">
          <div class="gen-out-inner"><div class="gen-empty">Transcript-backed understanding will appear here.</div></div>
        </div>
      </div>
    </div>

    <!-- Stem separation -->
    <div class="panel" id="panel-aud-stems">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-stems"></div>
          <div class="frow"><label class="fl">Source mix</label><input type="file" id="ast-file" accept="audio/*,video/*" class="fi"></div>
          <div class="frow"><label class="fl">Requested split</label>
            <select id="ast-mode" class="fselect">
              <option value="vocals-instrumental">Vocals / instrumental</option>
              <option value="4-stem">4-stem band split</option>
              <option value="drums-bass-other">Drums / bass / other</option>
            </select>
          </div>
          <div class="cap-preserve-note">
            <strong>Native backend:</strong> Studio now sends a real <code>/v1/audio/stems</code> request. The current backend uses ffmpeg-only heuristics, so outputs are best-effort estimates rather than production-grade ML demixing.
          </div>
          <div class="cap-note"><strong>Quality note:</strong> vocals/instrumental works best when vocals are center-panned. The 4-stem and drums/bass/other modes are broad frequency-group splits, not clean source-isolated stems.</div>
          <button class="btn btn-primary" onclick="genStems()">Split Stems</button>
          <div class="progress" id="ast-prog"></div>
          <div class="req-preview" id="ast-preview"></div>
        </div>
        <div class="gen-out" id="ast-out">
          <div class="gen-out-inner"><div class="gen-empty">Separated stem artifacts will appear here.</div></div>
        </div>
      </div>
    </div>

    <!-- Audio cleanup -->
    <div class="panel" id="panel-aud-clean">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-aud-clean"></div>
          <div class="frow"><label class="fl">Source recording</label><input type="file" id="ac-file" accept="audio/*,video/*" class="fi"></div>
          <div class="g2">
            <label class="cap-toggle"><input type="checkbox" id="ac-noise" checked> Reduce background noise</label>
            <label class="cap-toggle"><input type="checkbox" id="ac-level"> Normalize levels</label>
          </div>
          <div class="g2">
            <label class="cap-toggle"><input type="checkbox" id="ac-hum"> Remove hum</label>
            <label class="cap-toggle"><input type="checkbox" id="ac-click"> Repair clicks</label>
          </div>
          <div class="cap-preserve-note">
            <strong>Native backend:</strong> Studio now sends a real <code>/v1/audio/cleanup</code> request. The current backend chains standard ffmpeg filters, so it can improve many recordings but is not a substitute for deep restoration models.
          </div>
          <div class="cap-note"><strong>Fallback path:</strong> if the result is still not intelligible, use Transcribe to assess what survived and switch to dedicated external restoration before dubbing or conversion.</div>
          <button class="btn btn-primary" onclick="genAudioCleanup()">Clean Audio</button>
          <div class="progress" id="ac-prog"></div>
          <div class="req-preview" id="ac-preview"></div>
        </div>
        <div class="gen-out" id="ac-out">
          <div class="gen-out-inner"><div class="gen-empty">Cleaned audio and backend limitations will appear here.</div></div>
        </div>
      </div>
    </div>

    <!-- ═══════════════ PIPELINES ═══════════════ -->
    <div class="panel" id="panel-pipe">
      <div class="pipe-panel">
        <div class="diag-card" id="studio-diagnostics">
          <div class="diag-title">Diagnostics</div>
          <div class="diag-sub">Evaluated from the current model type, declared capabilities, and existing Studio fallback rules.</div>
          <div class="diag-groups" id="diag-groups"></div>
        </div>
        <div class="hist-card" id="studio-history">
          <div class="hist-title">Session artifact history</div>
          <div class="hist-list" id="artifact-history-list"></div>
        </div>

        <details class="pipe-card" data-requires='["image_generation","image_to_video"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Image → Video Pipeline</div>
              <div class="pipe-summary">Turn a text prompt into a generated still, animate it into motion, and optionally add soundtrack cues.</div>
              <div class="pipe-steps">
                <span class="pipe-step">Generate image</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Animate</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Audio (opt)</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">image</span><span class="pipe-tag">video</span><span class="pipe-tag">soundtrack</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <div class="frow"><label class="fl">Text prompt</label><textarea id="pp1-prompt" class="fs" rows="3" placeholder="Describe the scene…"></textarea></div>
            <div class="frow"><label class="fl">Negative prompt</label><textarea id="pp1-neg" class="fs" rows="2" placeholder="Things to avoid…"></textarea></div>
            <div class="g2">
              <div class="frow"><label class="fl">Image model</label><input id="pp1-imodel" class="fi" placeholder="model id"></div>
              <div class="frow"><label class="fl">Video model</label><input id="pp1-vmodel" class="fi" placeholder="model id"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Image size</label><input id="pp1-isize" class="fi" value="1024x1024"></div>
              <div class="frow"><label class="fl">Image steps</label><input type="number" id="pp1-isteps" class="fi" placeholder="default"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Image CFG</label><input type="number" id="pp1-icfg" class="fi" placeholder="default" step="0.5"></div>
              <div class="frow"><label class="fl">Image seed</label><input type="number" id="pp1-iseed" class="fi" placeholder="random"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Frames</label><input type="number" id="pp1-frames" class="fi" value="16"></div>
              <div class="frow"><label class="fl">FPS</label><input type="number" id="pp1-fps" class="fi" value="8"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Video steps</label><input type="number" id="pp1-vsteps" class="fi" value="25"></div>
              <div class="frow"><label class="fl">Video CFG</label><input type="number" id="pp1-vcfg" class="fi" value="7.5" step="0.5"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Camera motion</label>
                <select id="pp1-cam" class="fselect">
                  <option value="">None</option>
                  <option value="zoom-in">Zoom in</option><option value="zoom-out">Zoom out</option>
                  <option value="pan-left">Pan left</option><option value="pan-right">Pan right</option>
                  <option value="tilt-up">Tilt up</option><option value="tilt-down">Tilt down</option>
                  <option value="rotate">Rotate</option>
                </select>
              </div>
              <div class="frow"><label class="fl">Video seed</label><input type="number" id="pp1-vseed" class="fi" placeholder="random"></div>
            </div>
            <details style="margin-top:.25rem">
              <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Add audio to video</summary>
              <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.4rem">
                <div class="frow"><label class="fl">Audio type</label>
                  <select id="pp1-atype" class="fselect">
                    <option value="">None</option>
                    <option value="music">Music</option><option value="sfx">SFX</option>
                    <option value="ambient">Ambient</option>
                  </select>
                </div>
                <div class="frow"><label class="fl">Audio prompt</label><input id="pp1-aprompt" class="fi" placeholder="e.g. epic orchestral soundtrack"></div>
              </div>
            </details>
            <button class="btn btn-primary" onclick="runPipeline1()">Run Pipeline</button>
            <div class="progress" id="pp1-prog"></div>
            <div id="pp1-out"></div>
          </div>
        </details>

        <details class="pipe-card" data-requires='["speech_to_text","text_to_speech"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Video → Dub + Subtitle</div>
              <div class="pipe-summary">Transcribe spoken dialogue, translate it, synthesize replacement speech, and optionally burn subtitles into the localized video.</div>
              <div class="pipe-steps">
                <span class="pipe-step">Transcribe</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Translate</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">TTS dub</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Burn subs</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">video</span><span class="pipe-tag">localization</span><span class="pipe-tag">subtitles</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <div class="frow"><label class="fl">Source video</label><input type="file" id="pp2-src" accept="video/*" class="fi"></div>
            <div class="g2">
              <div class="frow"><label class="fl">Source language</label><input id="pp2-slang" class="fi" placeholder="en"></div>
              <div class="frow"><label class="fl">Target language</label><input id="pp2-tlang" class="fi" placeholder="es, fr, de…"></div>
            </div>
            <div class="frow"><label class="fl">Model (audio/video)</label><input id="pp2-model" class="fi" placeholder="model id"></div>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="pp2-clone"> Clone original voice</label>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="pp2-burn" checked> Burn subtitles into video</label>
            <button class="btn btn-primary" onclick="runPipeline2()">Run Pipeline</button>
            <div class="progress" id="pp2-prog"></div>
            <div id="pp2-out"></div>
          </div>
        </details>

        <details class="pipe-card" data-requires='["text_generation","image_generation","video_generation"]' data-optional='["text_to_speech"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Full Story</div>
              <div class="pipe-summary">Draft a narrated multi-scene story, generate matching visuals, animate them, and add spoken narration in one pass.</div>
              <div class="pipe-steps">
                <span class="pipe-step">LLM script</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Images</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Video</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">TTS</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">storytelling</span><span class="pipe-tag">image</span><span class="pipe-tag">video</span><span class="pipe-tag">tts</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <div class="frow"><label class="fl">Story idea</label><textarea id="pp3-story" class="fs" rows="3" placeholder="A knight fights a dragon at sunset…"></textarea></div>
            <div class="g2">
              <div class="frow"><label class="fl">Scenes</label><input type="number" id="pp3-scenes" class="fi" value="3" min="1" max="6"></div>
              <div class="frow"><label class="fl">Frames/scene</label><input type="number" id="pp3-frames" class="fi" value="16"></div>
            </div>
            <div class="g3">
              <div class="frow"><label class="fl">Text model</label><input id="pp3-tmodel" class="fi" placeholder="llm id"></div>
              <div class="frow"><label class="fl">Image model</label><input id="pp3-imodel" class="fi" placeholder="sd id"></div>
              <div class="frow"><label class="fl">Video model</label><input id="pp3-vmodel" class="fi" placeholder="video id"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">TTS model (optional)</label><input id="pp3-amodel" class="fi" placeholder="tts id"></div>
              <div class="frow"><label class="fl">TTS voice</label><input id="pp3-voice" class="fi" placeholder="af_sarah"></div>
            </div>
            <button class="btn btn-primary" onclick="runPipeline3()">Run Full Pipeline</button>
            <div class="progress" id="pp3-prog"></div>
            <div id="pp3-out"></div>
          </div>
        </details>

        <details class="pipe-card" data-requires='["speech_to_text","text_to_speech"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Audio/Video Dub with Voice Clone</div>
              <div class="pipe-summary">Create a dubbed replacement track while trying to preserve the original speaker identity through reference-based voice cloning.</div>
              <div class="pipe-steps">
                <span class="pipe-step">Transcribe</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Clone voice</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Replace audio</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">voice clone</span><span class="pipe-tag">dub</span><span class="pipe-tag">video</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <div class="frow"><label class="fl">Source video or audio</label><input type="file" id="pp4-src" accept="audio/*,video/*" class="fi"></div>
            <div class="frow"><label class="fl">Voice profile (saved)</label>
              <select id="pp4-voice" class="fselect">
                <option value="">— Or upload reference audio below —</option>
              </select>
            </div>
            <div class="frow"><label class="fl">Reference audio (upload or record)</label>
              <div class="media-input-group">
                <input type="file" id="pp4-ref" accept="audio/*" class="fi">
                <button class="btn btn-ghost btn-sm" onclick="openWebcam('pp4-ref-cam','pp4-ref',null,'audio')">🎤 Record</button>
              </div>
            </div>
            <div id="pp4-ref-cam" class="webcam-box" style="display:none"></div>
            <div class="frow"><label class="fl">Transcript of reference audio</label><input id="pp4-reftxt" class="fi" placeholder="What the reference audio says…"></div>
            <div class="g2">
              <div class="frow"><label class="fl">Source language</label><input id="pp4-slang" class="fi" placeholder="en (auto-detect)"></div>
              <div class="frow"><label class="fl">Translate to</label><input id="pp4-tlang" class="fi" placeholder="es, fr… (optional)"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Speed</label><input type="number" id="pp4-speed" class="fi" value="1.0" step="0.1"></div>
              <div class="frow"><label class="fl">Whisper model</label><input id="pp4-whisper" class="fi" placeholder="base"></div>
            </div>
            <label style="font-size:12px;display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="pp4-burn"> Burn subtitles into video</label>
            <button class="btn btn-primary" onclick="runPipeline4()">Run Pipeline</button>
            <div class="progress" id="pp4-prog"></div>
            <div id="pp4-out"></div>
          </div>
        </details>

        <details class="pipe-card" data-requires='["image_to_image"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Face Swap</div>
              <div class="pipe-summary">Transfer a source face into a target image or video result while keeping the rest of the scene intact.</div>
              <div class="pipe-steps">
                <span class="pipe-step">Source face</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Target</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Result</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">image</span><span class="pipe-tag">video</span><span class="pipe-tag">identity</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <div class="frow"><label class="fl">Source face (upload or webcam)</label>
              <div class="media-input-group">
                <input type="file" id="pp5-src" accept="image/*" class="fi" onchange="previewMediaInput('pp5-src','pp5-src-preview')">
                <button class="btn btn-ghost btn-sm" onclick="openWebcam('pp5-src-cam','pp5-src','pp5-src-preview','photo')">📷 Webcam</button>
              </div>
            </div>
            <div id="pp5-src-preview" class="media-preview"></div>
            <div id="pp5-src-cam" class="webcam-box" style="display:none"></div>
            <div class="frow"><label class="fl">Target (upload or webcam)</label>
              <div class="media-input-group">
                <input type="file" id="pp5-tgt" accept="image/*,video/*" class="fi" onchange="previewMediaInput('pp5-tgt','pp5-tgt-preview')">
                <button class="btn btn-ghost btn-sm" onclick="openWebcam('pp5-tgt-cam','pp5-tgt','pp5-tgt-preview','photo')">📷 Webcam</button>
              </div>
            </div>
            <div id="pp5-tgt-preview" class="media-preview"></div>
            <div id="pp5-tgt-cam" class="webcam-box" style="display:none"></div>
            <div class="frow"><label class="fl">Target type</label>
              <select id="pp5-type" class="fselect"><option value="image">Image</option><option value="video">Video</option></select>
            </div>
            <button class="btn btn-primary" onclick="runPipeline5()">Run Face Swap</button>
            <div class="progress" id="pp5-prog"></div>
            <div id="pp5-out"></div>
          </div>
        </details>

        <details class="pipe-card" data-requires='["text_to_speech"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Voice Clone (TTS)</div>
              <div class="pipe-summary">Synthesize fresh speech from text using a saved or uploaded voice reference as the target speaker style.</div>
              <div class="pipe-steps">
                <span class="pipe-step">Reference audio</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Text</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Cloned speech</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">tts</span><span class="pipe-tag">voice clone</span><span class="pipe-tag">speech</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <p style="font-size:12px;color:var(--text-2);margin:0 0 .4rem">Synthesize new speech in a cloned voice. Good for dubbing scripts.</p>
            <div class="frow"><label class="fl">Text to synthesize</label><textarea id="pvc-text" class="fs" rows="3" placeholder="Enter text…"></textarea></div>
            <div class="frow"><label class="fl">Voice profile</label>
              <select id="pvc-voice" class="fselect"><option value="">— Or upload reference below —</option></select>
            </div>
            <div class="frow"><label class="fl">Reference audio (upload or record)</label>
              <div class="media-input-group">
                <input type="file" id="pvc-ref" accept="audio/*" class="fi">
                <button class="btn btn-ghost btn-sm" onclick="openWebcam('pvc-ref-cam','pvc-ref',null,'audio')">🎤 Record</button>
              </div>
            </div>
            <div id="pvc-ref-cam" class="webcam-box" style="display:none"></div>
            <div class="frow"><label class="fl">Transcript of reference</label><input id="pvc-reftxt" class="fi" placeholder="What the reference audio says…"></div>
            <div class="g2">
              <div class="frow"><label class="fl">Speed</label><input type="number" id="pvc-speed" class="fi" value="1.0" step="0.1"></div>
              <div class="frow"><label class="fl">Seed</label><input type="number" id="pvc-seed" class="fi" placeholder="random"></div>
            </div>
            <button class="btn btn-primary" onclick="runPipelineVC()">Clone & Synthesize</button>
            <div class="progress" id="pvc-prog"></div>
            <div id="pvc-out"></div>
          </div>
        </details>

        <details class="pipe-card" data-requires='["audio_to_audio"]' data-optional='["text_to_speech"]'>
          <summary>
            <div class="pipe-head">
              <div class="pipe-title">Voice Convert (SVC)</div>
              <div class="pipe-summary">Convert the source timbre to a new speaker while preserving pitch, rhythm, and musical phrasing for speech or singing.</div>
              <div class="pipe-steps">
                <span class="pipe-step">Source audio</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Target timbre</span><span class="pipe-arrow">→</span>
                <span class="pipe-step">Converted audio</span>
              </div>
              <div class="pipe-tags"><span class="pipe-tag">audio</span><span class="pipe-tag">conversion</span><span class="pipe-tag">singing</span></div>
            </div>
          </summary>
          <div class="pipe-card-body">
            <p style="font-size:12px;color:var(--text-2);margin:0 0 .4rem">Convert timbre while preserving pitch, melody and expression. Use <strong>Singing mode</strong> for music.</p>
            <div class="frow"><label class="fl">Source audio (to convert)</label>
              <div class="media-input-group">
                <input type="file" id="psvc-src" accept="audio/*" class="fi">
                <button class="btn btn-ghost btn-sm" onclick="openWebcam('psvc-src-cam','psvc-src',null,'audio')">🎤 Record</button>
              </div>
            </div>
            <div id="psvc-src-cam" class="webcam-box" style="display:none"></div>
            <div class="frow"><label class="fl">Target voice profile</label>
              <select id="psvc-voice" class="fselect"><option value="">— Or upload reference below —</option></select>
            </div>
            <div class="frow"><label class="fl">Target voice (upload or record)</label>
              <div class="media-input-group">
                <input type="file" id="psvc-ref" accept="audio/*" class="fi">
                <button class="btn btn-ghost btn-sm" onclick="openWebcam('psvc-ref-cam','psvc-ref',null,'audio')">🎤 Record</button>
              </div>
            </div>
            <div id="psvc-ref-cam" class="webcam-box" style="display:none"></div>
            <div class="frow"><label class="fl">Mode</label>
              <select id="psvc-mode" class="fselect">
                <option value="speech">Speech (fast)</option>
                <option value="singing">Singing / Music (pitch-accurate)</option>
              </select>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Pitch shift (semitones)</label><input type="number" id="psvc-pitch" class="fi" value="0" min="-12" max="12"></div>
              <div class="frow"><label class="fl">Diffusion steps</label><input type="number" id="psvc-steps" class="fi" value="10" min="1" max="50"></div>
            </div>
            <button class="btn btn-primary" onclick="runPipelineSVC()">Convert Voice</button>
            <div class="progress" id="psvc-prog"></div>
            <div id="psvc-out"></div>
          </div>
        </details>

    <details class="pipe-card" id="pipe-builder-card" open>
      <summary>＋ Build New Pipeline
        <span class="pipe-steps" style="margin:0;font-size:11px"><span class="pipe-step">Add steps</span><span class="pipe-arrow">→</span><span class="pipe-step">Configure</span><span class="pipe-arrow">→</span><span class="pipe-step">Save &amp; Run</span></span>
      </summary>
      <div class="pipe-card-body">
        <div class="g2">
          <div class="frow"><label class="fl">Pipeline name</label><input id="pb-name" class="fi" placeholder="My Pipeline"></div>
          <div class="frow"><label class="fl">Description</label><input id="pb-desc" class="fi" placeholder="Optional"></div>
        </div>
        <div id="pb-steps" style="display:flex;flex-direction:column;gap:.4rem;margin:.25rem 0"></div>
        <div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.25rem">
          <select id="pb-add-type" class="fselect" style="flex:1"></select>
          <button class="btn btn-ghost btn-sm" onclick="pbAddStep()">＋ Add Step</button>
        </div>
        <div style="display:flex;gap:.4rem;margin-top:.5rem;flex-wrap:wrap">
          <div class="frow" style="flex:1"><label class="fl">Pipeline input</label><input id="pb-input" class="fi" placeholder="Initial text/prompt passed as {{ '{{' }}input{{ '}}' }}"></div>
        </div>
        <div style="display:flex;gap:.4rem;margin-top:.5rem">
          <button class="btn btn-primary" onclick="pbSaveAndRun()">Save &amp; Run</button>
          <button class="btn btn-ghost btn-sm" onclick="pbSave()">Save only</button>
          <button class="btn btn-ghost btn-sm" onclick="pbRun()">Run without saving</button>
        </div>
        <div class="progress" id="pb-prog"></div>
        <div id="pb-out"></div>
      </div>
    </details>
    <div id="custom-pipe-cards"></div>

    </div><!-- pipe-panel -->
    </div><!-- panel-pipe -->

    <div class="panel" id="panel-embed">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-embed"></div>
          <div class="frow"><label class="fl">Text(s) — one per line</label><textarea id="em-text" class="fs" rows="6" placeholder="Enter text to embed…"></textarea></div>
          <div class="frow"><label class="fl">Encoding</label>
            <select id="em-enc" class="fselect"><option value="float">Float array</option><option value="base64">Base64</option></select>
          </div>
          <div class="frow"><label class="fl">Dimensions (truncate, optional)</label><input type="number" id="em-dims" class="fi" placeholder="all"></div>
          <button class="btn btn-primary" onclick="genEmbeddings()">Embed</button>
          <div class="progress" id="em-prog"></div>
          <div class="req-preview" id="em-preview"></div>
        </div>
        <div class="gen-out" id="em-out"><div class="gen-empty">Embedding vectors will appear here</div></div>
      </div>
    </div>

    <div class="panel" id="panel-archive">
      <div class="archive-panel">
        <div class="archive-toolbar">
          <div class="archive-filters">
            <button class="arch-filter active" data-type="all" onclick="archiveSetFilter('all')">All</button>
            <button class="arch-filter" data-type="image" onclick="archiveSetFilter('image')">Images</button>
            <button class="arch-filter" data-type="video" onclick="archiveSetFilter('video')">Videos</button>
            <button class="arch-filter" data-type="audio" onclick="archiveSetFilter('audio')">Audio</button>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="loadArchive()">Refresh</button>
        </div>
        <div class="archive-grid" id="archive-grid">
          <div class="arch-empty">Switch to this tab to load generated files.</div>
        </div>
      </div>
    </div>

    <!-- ═══════════════ PROFILES — CHARACTER ═══════════════ -->
    <div class="panel" id="panel-prof-char">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="gen-hd">New Character</div>
          <input type="hidden" id="pc-edit-name">
          <div class="frow"><label class="fl">Name*</label><input class="fi" id="pc-name" placeholder="alice"></div>
          <div class="frow"><label class="fl">Description</label><input class="fi" id="pc-desc" placeholder="optional"></div>

          <div class="frow" style="align-items:center;gap:1rem;margin-bottom:.2rem">
            <label class="fl">Mode</label>
            <label style="display:flex;align-items:center;gap:.3rem;cursor:pointer;font-size:12px">
              <input type="radio" name="pc-mode" value="extract" checked onchange="pcModeChange()"> Extract
            </label>
            <label style="display:flex;align-items:center;gap:.3rem;cursor:pointer;font-size:12px">
              <input type="radio" name="pc-mode" value="generate" onchange="pcModeChange()"> Generate
            </label>
          </div>

          <div id="pc-extract-fields">
            <div class="frow"><label class="fl">Source images</label>
              <div style="flex:1">
                <input type="file" id="pc-img-files" multiple accept="image/*" onchange="pcImgFilesChanged()" style="width:100%">
                <div id="pc-img-previews" style="display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.4rem"></div>
              </div>
            </div>
            <div class="frow"><label class="fl">Source videos</label>
              <input type="file" id="pc-vid-files" multiple accept="video/*" style="flex:1">
            </div>
            <div class="frow"><label class="fl">Max ref images</label>
              <input type="number" class="fi" id="pc-max" value="5" min="1" max="20" style="width:100px">
            </div>
          </div>

          <div id="pc-generate-fields" style="display:none">
            <div class="frow" style="align-items:flex-start"><label class="fl" style="padding-top:.3rem">Prompt*</label>
              <textarea class="fi" id="pc-gen-prompt" rows="3" placeholder="e.g. a tall woman with red hair and blue eyes, realistic, studio lighting" style="flex:1;resize:vertical"></textarea>
            </div>
            <div class="frow"><label class="fl">Image model</label>
              <select class="fi" id="pc-gen-model" style="flex:1"><option value="">Default image model</option></select>
            </div>
            <div class="frow"><label class="fl">Ref images (n)</label>
              <input type="number" class="fi" id="pc-gen-n" value="3" min="1" max="8" style="width:100px">
            </div>
            <details style="margin-top:.3rem">
              <summary style="font-size:12px;cursor:pointer;color:var(--text-2)">Advanced</summary>
              <div style="padding:.4rem 0">
                <div class="frow"><label class="fl">Steps</label>
                  <input type="number" class="fi" id="pc-gen-steps" placeholder="model default" min="1" max="150" style="width:120px">
                </div>
                <div class="frow"><label class="fl">Width</label>
                  <input type="number" class="fi" id="pc-gen-w" value="512" min="64" max="2048" step="64" style="width:120px">
                </div>
                <div class="frow"><label class="fl">Height</label>
                  <input type="number" class="fi" id="pc-gen-h" value="512" min="64" max="2048" step="64" style="width:120px">
                </div>
              </div>
            </details>
          </div>

          <div style="display:flex;gap:.5rem;margin-top:.7rem">
            <button class="btn btn-primary btn-sm" id="pc-submit-btn" onclick="profCharSubmit()">Extract &amp; Save</button>
            <button class="btn btn-ghost btn-sm" onclick="profCharShowCreate()">Reset</button>
          </div>
          <div id="pc-form-status" class="small muted" style="margin-top:.4rem"></div>
        </div>

        <div class="gen-out prof-gen-out">
          <div class="prof-out-hd">
            <span class="prof-out-hd-title">Character Profiles</span>
            <button class="btn btn-ghost btn-sm" onclick="profCharLoad()">Refresh</button>
          </div>
          <div class="prof-out-body">
            <div id="prof-char-list" class="archive-grid">
              <div class="arch-empty">Loading…</div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- ═══════════════ PROFILES — ENVIRONMENT ═══════════════ -->
    <div class="panel" id="panel-prof-env">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="gen-hd">New Environment</div>
          <div class="frow"><label class="fl">Name*</label><input class="fi" id="pe-name" placeholder="living-room"></div>
          <div class="frow"><label class="fl">Description</label><input class="fi" id="pe-desc" placeholder="optional"></div>

          <div class="frow" style="align-items:center;gap:1rem;margin-bottom:.2rem">
            <label class="fl">Mode</label>
            <label style="display:flex;align-items:center;gap:.3rem;cursor:pointer;font-size:12px">
              <input type="radio" name="pe-mode" value="extract" checked onchange="peModeChange()"> Extract
            </label>
            <label style="display:flex;align-items:center;gap:.3rem;cursor:pointer;font-size:12px">
              <input type="radio" name="pe-mode" value="generate" onchange="peModeChange()"> Generate
            </label>
          </div>

          <div id="pe-extract-fields">
            <div class="frow"><label class="fl">Source images</label>
              <div style="flex:1">
                <input type="file" id="pe-img-files" multiple accept="image/*" onchange="peImgFilesChanged()" style="width:100%">
                <div id="pe-img-previews" style="display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.4rem"></div>
              </div>
            </div>
            <div class="frow"><label class="fl">Source videos</label>
              <input type="file" id="pe-vid-files" multiple accept="video/*" style="flex:1">
            </div>
            <div class="frow"><label class="fl">Max ref images</label>
              <input type="number" class="fi" id="pe-max" value="5" min="1" max="20" style="width:100px">
            </div>
          </div>

          <div id="pe-generate-fields" style="display:none">
            <div class="frow" style="align-items:flex-start"><label class="fl" style="padding-top:.3rem">Prompt*</label>
              <textarea class="fi" id="pe-gen-prompt" rows="3" placeholder="e.g. a cozy living room with wooden floors, warm lighting, bookshelves" style="flex:1;resize:vertical"></textarea>
            </div>
            <div class="frow"><label class="fl">Image model</label>
              <select class="fi" id="pe-gen-model" style="flex:1"><option value="">Default image model</option></select>
            </div>
            <div class="frow"><label class="fl">Ref images (n)</label>
              <input type="number" class="fi" id="pe-gen-n" value="3" min="1" max="8" style="width:100px">
            </div>
            <details style="margin-top:.3rem">
              <summary style="font-size:12px;cursor:pointer;color:var(--text-2)">Advanced</summary>
              <div style="padding:.4rem 0">
                <div class="frow"><label class="fl">Steps</label>
                  <input type="number" class="fi" id="pe-gen-steps" placeholder="model default" min="1" max="150" style="width:120px">
                </div>
                <div class="frow"><label class="fl">Width</label>
                  <input type="number" class="fi" id="pe-gen-w" value="512" min="64" max="2048" step="64" style="width:120px">
                </div>
                <div class="frow"><label class="fl">Height</label>
                  <input type="number" class="fi" id="pe-gen-h" value="512" min="64" max="2048" step="64" style="width:120px">
                </div>
              </div>
            </details>
          </div>

          <div style="display:flex;gap:.5rem;margin-top:.7rem">
            <button class="btn btn-primary btn-sm" id="pe-submit-btn" onclick="profEnvSubmit()">Extract &amp; Save</button>
            <button class="btn btn-ghost btn-sm" onclick="profEnvShowCreate()">Reset</button>
          </div>
          <div id="pe-form-status" class="small muted" style="margin-top:.4rem"></div>
        </div>

        <div class="gen-out prof-gen-out">
          <div class="prof-out-hd">
            <span class="prof-out-hd-title">Environment Profiles</span>
            <button class="btn btn-ghost btn-sm" onclick="profEnvLoad()">Refresh</button>
          </div>
          <div class="prof-out-body">
            <div id="prof-env-list" class="archive-grid">
              <div class="arch-empty">Loading…</div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- ═══════════════ PROFILES — VOICE ═══════════════ -->
    <div class="panel" id="panel-prof-voice">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="gen-hd">Extract Voice</div>
          <div class="frow"><label class="fl">Name*</label><input class="fi" id="pv-name" placeholder="john-doe"></div>
          <div class="frow"><label class="fl">Description</label><input class="fi" id="pv-desc" placeholder="optional"></div>
          <div class="frow"><label class="fl">Audio or Video*</label>
            <input type="file" id="pv-file" accept="audio/*,video/*" style="flex:1">
          </div>
          <div class="frow" style="align-items:flex-start"><label class="fl" style="padding-top:.3rem">Transcript</label>
            <textarea class="fi fs" id="pv-transcript" rows="3" placeholder="Leave empty to auto-transcribe with Whisper" style="resize:vertical"></textarea>
          </div>
          <div style="display:flex;gap:.5rem;margin-top:.7rem">
            <button class="btn btn-primary btn-sm" onclick="profVoiceSave()">Extract &amp; Save</button>
            <button class="btn btn-ghost btn-sm" onclick="profVoiceShowCreate()">Reset</button>
          </div>
          <div id="pv-form-status" class="small muted" style="margin-top:.4rem"></div>
        </div>

        <div class="gen-out prof-gen-out">
          <div class="prof-out-hd">
            <span class="prof-out-hd-title">Voice Profiles</span>
            <button class="btn btn-ghost btn-sm" onclick="profVoiceLoad()">Refresh</button>
          </div>
          <div class="prof-out-body">
            <div id="prof-voice-list" class="prof-voice-list">
              <div class="arch-empty">Loading…</div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- ═══════════════ 3D ═══════════════ -->

    <!-- 3D: Generate model from text / image -->
    <div class="panel" id="panel-3d-generate">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-3d-generate"></div>
          <div class="frow"><label class="fl">Prompt (text-to-3D)</label>
            <textarea id="g3-prompt" class="fs" rows="3" placeholder="A wooden chair, a sci-fi helmet, a cute dog…"></textarea>
          </div>
          <div class="frow"><label class="fl">Reference image (image-to-3D, optional)</label>
            <input type="file" id="g3-img" accept="image/*" class="fi" onchange="previewMediaInput('g3-img','g3-img-preview')">
          </div>
          <div id="g3-img-preview" class="media-preview"></div>
          <div class="g2">
            <div class="frow"><label class="fl">Steps</label><input type="number" id="g3-steps" class="fi" value="64" min="8"></div>
            <div class="frow"><label class="fl">Seed</label><input type="number" id="g3-seed" class="fi" placeholder="random"></div>
          </div>
          <div style="font-size:11px;color:var(--text-3);margin-top:.2rem">
            Requires <strong>TripoSR</strong> (image) or <strong>Shap-E</strong> (text/image). Falls back to a depth-based mesh when neither is installed.
          </div>
          <button class="btn btn-primary" onclick="gen3DModel()">Generate 3D Model</button>
          <div class="progress" id="g3-prog"></div>
          <div id="g3-dl" style="display:none;margin-top:.5rem"></div>
        </div>
        <div class="gen-out" id="g3-out"><div class="gen-empty">GLB model will be ready for download</div></div>
      </div>
    </div>

    <!-- 3D: Image → 3D (alias panel, same as img-to3d but in 3D category) -->
    <div class="panel" id="panel-3d-img-to3d">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-3d-img-to3d"></div>
          <div class="frow"><label class="fl">Source image</label>
            <input type="file" id="t3i-src" accept="image/*" class="fi" onchange="previewMediaInput('t3i-src','t3i-src-preview')">
          </div>
          <div id="t3i-src-preview" class="media-preview"></div>
          <div class="frow"><label class="fl">Output method</label>
            <select id="t3i-method" class="fselect">
              <option value="stereo">Stereo (side-by-side)</option>
              <option value="anaglyph">Anaglyph (red-cyan)</option>
              <option value="depth">Depth map</option>
              <option value="mesh">3D mesh (GLB)</option>
            </select>
          </div>
          <div class="frow"><label class="fl">Parallax shift <span id="t3i-sv">20</span>px</label>
            <input type="range" id="t3i-shift" min="5" max="50" step="1" value="20" oninput="$('t3i-sv').textContent=this.value" style="width:100%">
          </div>
          <button class="btn btn-primary" onclick="gen3DImgTo3D()">Convert to 3D</button>
          <div class="progress" id="t3i-prog"></div>
        </div>
        <div class="gen-out" id="t3i-out"><div class="gen-empty">3D output will appear here</div></div>
      </div>
    </div>

    <!-- 3D: Video → 3D (alias panel) -->
    <div class="panel" id="panel-3d-vid-to3d">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-3d-vid-to3d"></div>
          <div class="frow"><label class="fl">Source video</label>
            <input type="file" id="t3v-src" accept="video/*" class="fi">
          </div>
          <div class="frow"><label class="fl">Output method</label>
            <select id="t3v-method" class="fselect">
              <option value="anaglyph">Anaglyph (red-cyan)</option>
              <option value="stereo">Stereo (side-by-side)</option>
              <option value="depth">Depth video</option>
            </select>
          </div>
          <div class="frow"><label class="fl">Parallax shift <span id="t3v-sv">15</span>px</label>
            <input type="range" id="t3v-shift" min="5" max="40" step="1" value="15" oninput="$('t3v-sv').textContent=this.value" style="width:100%">
          </div>
          <button class="btn btn-primary" onclick="gen3DVidTo3D()">Convert to 3D</button>
          <div class="progress" id="t3v-prog"></div>
        </div>
        <div class="gen-out" id="t3v-out"><div class="gen-empty">3D video will appear here</div></div>
      </div>
    </div>

    <!-- 3D: 3D model → rendered image/video -->
    <div class="panel" id="panel-3d-from3d">
      <div class="gen-wrap">
        <div class="gen-ctrl">
          <div class="cap-card" id="cap-3d-from3d"></div>
          <div class="frow"><label class="fl">3D model file (GLB/OBJ)</label>
            <input type="file" id="r3-src" accept=".glb,.obj,.gltf" class="fi">
          </div>
          <div class="frow"><label class="fl">Format</label>
            <select id="r3-fmt" class="fselect">
              <option value="glb">GLB</option>
              <option value="obj">OBJ</option>
            </select>
          </div>
          <div class="frow"><label class="fl">Output type</label>
            <select id="r3-type" class="fselect" onchange="r3TypeChange()">
              <option value="image">Single image</option>
              <option value="video">Turntable video</option>
            </select>
          </div>
          <div id="r3-img-opts">
            <div class="g3">
              <div class="frow"><label class="fl">Distance</label><input type="number" id="r3-dist" class="fi" value="2.0" step="0.5"></div>
              <div class="frow"><label class="fl">Elevation°</label><input type="number" id="r3-elev" class="fi" value="30" step="5"></div>
              <div class="frow"><label class="fl">Azimuth°</label><input type="number" id="r3-azim" class="fi" value="45" step="5"></div>
            </div>
          </div>
          <div id="r3-vid-opts" style="display:none">
            <div class="g2">
              <div class="frow"><label class="fl">Frames</label><input type="number" id="r3-frames" class="fi" value="36"></div>
              <div class="frow"><label class="fl">FPS</label><input type="number" id="r3-fps" class="fi" value="12"></div>
            </div>
            <div class="g2">
              <div class="frow"><label class="fl">Elevation°</label><input type="number" id="r3-velev" class="fi" value="20" step="5"></div>
              <div class="frow"><label class="fl">Distance</label><input type="number" id="r3-vdist" class="fi" value="2.5" step="0.5"></div>
            </div>
          </div>
          <div class="g2">
            <div class="frow"><label class="fl">Width</label><input type="number" id="r3-w" class="fi" value="512" step="64"></div>
            <div class="frow"><label class="fl">Height</label><input type="number" id="r3-h" class="fi" value="512" step="64"></div>
          </div>
          <button class="btn btn-primary" onclick="gen3DRender()">Render</button>
          <div class="progress" id="r3-prog"></div>
        </div>
        <div class="gen-out" id="r3-out"><div class="gen-empty">Render will appear here</div></div>
      </div>
    </div>

  </div><!-- studio-main -->
</div><!-- studio -->
{% endblock %}

{% block scripts %}
<script>
// ─────────────────────────────────────────────────────────────────
//  State
// ─────────────────────────────────────────────────────────────────
let models = [], activeModel = null, chatHistory = [], chatBusy = false, attachedImage = null;
let _localCapSet = new Set();  // capabilities available from locally downloaded (not necessarily configured) models
let _imgPollTimer = null;
let _vidPollTimer = null;
let _audPollTimer = null;

function _startVidPoll(prefix) {
  if (_vidPollTimer) { clearInterval(_vidPollTimer); _vidPollTimer = null; }
  const wrap = $(prefix+'-pbar-wrap'), fill = $(prefix+'-pbar-fill'), lbl = $(prefix+'-pbar-label');
  if (!wrap) return;
  wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent='';
  _vidPollTimer = setInterval(async () => {
    try {
      const p = await (await fetch(ROOT_PATH + '/v1/video/progress')).json();
      if (p.total > 0) {
        fill.style.width = p.pct + '%';
        const spd = p.it_per_s > 0 ? ` · ${p.it_per_s} it/s` : (p.elapsed > 0 ? ` · ${p.elapsed}s` : '');
        lbl.textContent = `${p.current} / ${p.total} steps${spd}`;
      } else if (p.elapsed > 0) {
        lbl.textContent = `${p.elapsed}s`;
      }
      if (!p.active) { clearInterval(_vidPollTimer); _vidPollTimer = null; }
    } catch(_) {}
  }, 500);
}

function _stopVidPoll(prefix, done) {
  if (_vidPollTimer) { clearInterval(_vidPollTimer); _vidPollTimer = null; }
  const wrap = $(prefix+'-pbar-wrap'), fill = $(prefix+'-pbar-fill'), lbl = $(prefix+'-pbar-label');
  if (!wrap) return;
  if (done) { fill.style.width='100%'; lbl.textContent='Done'; setTimeout(() => wrap.classList.remove('active'), 2000); }
  else { wrap.classList.remove('active'); }
}

function _startAudPoll(prefix) {
  if (_audPollTimer) { clearInterval(_audPollTimer); _audPollTimer = null; }
  const wrap = $(prefix+'-pbar-wrap'), fill = $(prefix+'-pbar-fill'), lbl = $(prefix+'-pbar-label');
  if (!wrap) return;
  wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent='';
  _audPollTimer = setInterval(async () => {
    try {
      const p = await (await fetch(ROOT_PATH + '/v1/audio/progress')).json();
      if (p.total > 0) {
        fill.style.width = p.pct + '%';
        const unit = p.unit || 'it';
        const spd = p.it_per_s > 0 ? ` · ${p.it_per_s} ${unit}/s` : (p.elapsed > 0 ? ` · ${p.elapsed}s` : '');
        lbl.textContent = `${p.current} / ${p.total} steps${spd}`;
      } else if (p.elapsed > 0) {
        lbl.textContent = `Elapsed: ${p.elapsed}s`;
      }
      if (!p.active) { clearInterval(_audPollTimer); _audPollTimer = null; }
    } catch(_) {}
  }, 500);
}

function _stopAudPoll(prefix, done) {
  if (_audPollTimer) { clearInterval(_audPollTimer); _audPollTimer = null; }
  const wrap = $(prefix+'-pbar-wrap'), fill = $(prefix+'-pbar-fill'), lbl = $(prefix+'-pbar-label');
  if (!wrap) return;
  if (done) { fill.style.width='100%'; lbl.textContent='Done'; setTimeout(() => wrap.classList.remove('active'), 2000); }
  else { wrap.classList.remove('active'); }
}
let apiToken = null;
let charSlots = {};        // prefix → [{name:'', images:[b64...]}]
let _charProfiles = [];    // cached list from /v1/characters
let artifactHistory = [];
const ARTIFACT_HISTORY_LIMIT = 12;

const $ = id => document.getElementById(id);
const val = id => ($(id) ? $(id).value : '');
const ival = id => parseInt(val(id)) || 0;
const fval = id => parseFloat(val(id)) || 0;
const chk = id => ($(id) ? $(id).checked : false);

// Capability → which level-1 tab
const CAP_CAT = {
  text_generation:'chat', image_to_text:'chat',
  image_generation:'image', image_to_image:'image', inpainting:'image',
  image_upscaling:'image', depth_estimation:'image', image_segmentation:'image',
  video_generation:'video', image_to_video:'video', video_to_video:'video',
  video_interpolation:'video', video_upscaling:'video',
  text_to_speech:'audio', speech_to_text:'audio', subtitle_generation:'video',
  audio_generation:'audio',
  embeddings:'embed',
};
// Capability → which level-2 sub-tab
const CAP_SUB = {
  image_generation:'img-gen', image_to_image:'img-edit', inpainting:'img-inpaint',
  image_upscaling:'img-upscale', depth_estimation:'img-depth', image_segmentation:'img-seg',
  video_generation:'vid-t2v', image_to_video:'vid-i2v', video_to_video:'vid-v2v',
  video_interpolation:'vid-interp', video_upscaling:'vid-up',
  subtitle_generation:'vid-sub',
  text_to_speech:'aud-tts', speech_to_text:'aud-stt',
  audio_generation:'aud-gen',
  model_3d_generation:'3d-generate', image_to_3d:'3d-img-to3d',
  video_to_3d:'3d-vid-to3d', model_3d_to_image:'3d-from3d',
};
// Sub-tab → parent category
const SUB_CAT = {
  'img-gen':'image','img-edit':'image','img-inpaint':'image','img-upscale':'image','img-depth':'image','img-seg':'image','img-faceswap':'image','img-deblur':'image','img-unpix':'image','img-outfit':'image','img-outfit':'image',
  'img-to3d':'image','img-from3d':'image',
  'vid-t2v':'video','vid-i2v':'video','vid-v2v':'video','vid-ti2v':'video','vid-interp':'video',
  'vid-sub':'video','vid-dub':'video','vid-up':'video','vid-faceswap':'video','vid-outfit':'video',
  'vid-to3d':'video','vid-from3d':'video',
  'aud-gen':'audio','aud-music-dub':'audio','aud-tts':'audio','aud-stt':'audio','aud-clone':'audio','aud-convert':'audio','aud-understand':'audio','aud-stems':'audio','aud-clean':'audio',
  '3d-generate':'3d','3d-img-to3d':'3d','3d-vid-to3d':'3d','3d-from3d':'3d',
  'prof-char':'profiles','prof-env':'profiles','prof-voice':'profiles',
};
// Sub-tabs that share a panel with an image sub-tab (vid-X → panel-img-Y)
const SUB_PANEL_ALIAS = {
  'vid-faceswap': 'panel-img-faceswap',
  'vid-outfit':   'panel-img-outfit',
};
// Video models also enable all video sub-tabs
const VIDEO_EXTRA_SUBS = ['vid-ti2v', 'vid-dub', 'vid-v2v', 'vid-sub', 'vid-interp', 'vid-up', 'vid-faceswap', 'vid-outfit'];
const TAB_STATE = {
  available: { label:'Ready', className:'state-ready' },
  partial: { label:'Partial', className:'state-partial' },
  unavailable: { label:'Unavailable', className:'state-unavailable' },
  none: {},
};
// Subs whose features are served by any available dedicated model, not only activeModel.
const CROSS_MODEL_SUBS = new Set(['aud-stt', 'aud-tts', 'aud-gen', 'embed']);
const STUDIO_CAPABILITIES = {
  'vid-dub': {
    category:'video',
    label:'Video dubbing',
    summary:'Translate spoken dialogue in a source video into another language and optionally burn subtitles into the result.',
    requires:['speech_to_text','text_to_speech'],
    optional:['subtitle_generation','video_to_video'],
    notes:[
      'Speaker matching and final lip-sync depend on the selected backend path.',
      'Subtitle burn-in is strongest when subtitle generation is available.',
      'Singing preservation remains limited unless the backend exposes a music-aware conversion path.'
    ],
    backendPath:'mixed',
    io:'Input: source video. Output: dubbed video with optional subtitle burn-in.'
  },
  'aud-gen': {
    category:'audio',
    label:'Music / SFX generation',
    summary:'Generate music beds, ambience, or sound effects directly from a text prompt, with optional melody guidance.',
    requires:['audio_generation'],
    optional:['speech_to_text'],
    notes:[
      'Melody reference improves control when the backend supports conditioning.',
      'Use pipelines when you need composition plus later narration or localization steps.'
    ],
    backendPath:'direct',
    io:'Input: prompt and optional melody reference. Output: generated audio clip.'
  },
  'aud-music-dub': {
    category:'audio',
    label:'Music dubbing',
    summary:'Assess song and lyric-localization workflows without claiming a one-click music-dubbing backend path.',
    requires:['speech_to_text','text_to_speech'],
    optional:['audio_generation'],
    notes:[
      'Automatic lyric-to-lyric rewriting, singable translation, and final mix replacement are not wired in this template.',
      'If vocals can be isolated externally, Voice Convert may still help preserve phrasing after translation.'
    ],
    backendPath:'guided fallback',
    io:'Input: source song plus language goals. Output: workflow guidance only unless a future backend route is added.'
  },
  'aud-understand': {
    category:'audio',
    label:'Audio understanding',
    summary:'Surface semantic audio analysis as a transcript-first workflow rather than a speculative new API call.',
    requires:['speech_to_text'],
    optional:['text_generation'],
    notes:[
      'The supported path is transcript extraction first, then reasoning in Chat.',
      'Environmental sound labeling or event reasoning remains limited if the active model only exposes plain speech recognition.'
    ],
    backendPath:'transcribe → chat',
    io:'Input: source audio or video. Output: transcript-backed understanding workflow.'
  },
  'aud-stems': {
    category:'audio',
    label:'Stem separation',
    summary:'Run native ffmpeg-backed stem estimation while clearly signaling that the current backend is heuristic and quality-limited.',
    requires:[],
    optional:['audio_generation','speech_to_text'],
    notes:[
      'This endpoint is runnable, but it performs best-effort estimation rather than ML source separation.',
      'Center-panned vocals separate more cleanly than dense or heavily stereo-processed mixes.'
    ],
    backendPath: ROOT_PATH + '/v1/audio/stems',
    io:'Input: mixed audio. Output: downloadable stem estimate artifacts.'
  },
  'aud-clean': {
    category:'audio',
    label:'Audio cleanup',
    summary:'Run native ffmpeg filter-chain cleanup for denoise and basic restoration while keeping quality and backend limits explicit.',
    requires:[],
    optional:['speech_to_text'],
    notes:[
      'This endpoint is runnable, but it uses standard filter processing rather than deep restoration models.',
      'Transcription remains the fastest fallback for judging intelligibility when cleanup cannot fully recover the source.'
    ],
    backendPath: ROOT_PATH + '/v1/audio/cleanup',
    io:'Input: noisy audio. Output: cleaned audio artifact plus applied-operation metadata.'
  },
  'embed': {
    category:'embed',
    label:'Embeddings',
    summary:'Generate dense text embedding vectors for semantic search, similarity scoring, and RAG pipelines.',
    requires:['embeddings'],
    optional:[],
    notes:[
      'Requires a model with <b>embedding</b> capability configured in the Models page.',
      'GGUF text models with embedding support work, as do dedicated sentence-transformer models.',
    ],
    backendPath: ROOT_PATH + '/v1/embeddings',
    io:'Input: one or more text strings. Output: floating-point embedding vectors.'
  },
  'img-faceswap': {
    category:'image',
    label:'Face Swap',
    summary:'Replace faces in images or video frames using the InsightFace/InSwapper backend.',
    requires:[],
    optional:[],
    notes:[
      'Requires <code>insightface</code> and <code>onnxruntime</code>: <code>pip install insightface onnxruntime</code>.',
      'The <b>inswapper_128.onnx</b> model is <b>auto-downloaded</b> from HuggingFace on first use (<a href="' + (window.ROOT_PATH||'') + '/admin/models?tab=search&q=inswapper&pipeline=&gguf=no-gguf" class="cap-find-link">deepinsight/inswapper<span class="cap-find-icon">↗</span></a>).',
      'No AI model selection needed — this feature uses its own dedicated backend.',
    ],
    backendPath: ROOT_PATH + '/v1/images/faceswap',
    io:'Input: source face image + target image or video. Output: face-swapped result.'
  },
  'vid-faceswap': {
    category:'video',
    label:'Face Swap (Video)',
    summary:'Replace faces in video frames using the InsightFace/InSwapper backend.',
    requires:[],
    optional:[],
    notes:[
      'Requires <code>insightface</code> and <code>onnxruntime</code>: <code>pip install insightface onnxruntime</code>.',
      'The <b>inswapper_128.onnx</b> model is <b>auto-downloaded</b> from HuggingFace on first use (<a href="' + (window.ROOT_PATH||'') + '/admin/models?tab=search&q=inswapper&pipeline=&gguf=no-gguf" class="cap-find-link">deepinsight/inswapper<span class="cap-find-icon">↗</span></a>).',
      'No AI model selection needed — this feature uses its own dedicated backend.',
    ],
    backendPath: ROOT_PATH + '/v1/images/faceswap',
    io:'Input: source face image + target video. Output: face-swapped video.'
  },
  'img-deblur': {
    category:'image',
    label:'Deblur',
    summary:'Remove motion blur and sharpen images using built-in signal processing.',
    requires:[],
    optional:[],
    notes:[
      'Always available — uses OpenCV Wiener deconvolution, no additional model download required.',
      'Works best on images with motion or out-of-focus blur; severe lens distortion may not fully recover.',
    ],
    backendPath: ROOT_PATH + '/v1/images/deblur',
    io:'Input: blurred image. Output: sharpened image.'
  },
  'img-unpix': {
    category:'image',
    label:'Unpixelate / Upscale',
    summary:'Restore pixelated or low-resolution images using Real-ESRGAN super-resolution.',
    requires:[],
    optional:[],
    notes:[
      'The Real-ESRGAN model is <b>auto-downloaded</b> from HuggingFace on first use — no manual setup needed.',
      'GPU (CUDA) is recommended for fast processing; CPU fallback is available but significantly slower.',
    ],
    backendPath: ROOT_PATH + '/v1/images/unpixelate',
    io:'Input: low-resolution or pixelated image. Output: upscaled image.'
  }
};
// Maps a capability token to the best HuggingFace search parameters
// NOTE: HF API `search=` is AND — keep q to one or two co-occurring terms.
// The pipeline filter already narrows by category; q just refines within it.
const CAP_TO_HF_SEARCH = {
  // Text / LLM
  'text_generation':     { pipeline:'text-generation',             q:'instruct',                gguf:'gguf'    },
  'embeddings':          { pipeline:'feature-extraction',          q:'embedding',               gguf:'all'     },
  'image_to_text':       { pipeline:'image-to-text',               q:'vision',                  gguf:'gguf'    },
  // Image generation & editing
  'image_generation':    { pipeline:'text-to-image',               q:'diffusion',               gguf:'no-gguf' },
  'image_to_image':      { pipeline:'image-to-image',              q:'diffusion',               gguf:'no-gguf' },
  'inpainting':          { pipeline:'image-to-image',              q:'inpainting',              gguf:'no-gguf' },
  'image_upscaling':     { pipeline:'image-to-image',              q:'upscaler',                gguf:'no-gguf' },
  'face_restoration':    { pipeline:'image-to-image',              q:'face restoration',        gguf:'no-gguf' },
  'style_transfer':      { pipeline:'image-to-image',              q:'style transfer',          gguf:'no-gguf' },
  'controlnet':          { pipeline:'',                            q:'controlnet',              gguf:'no-gguf' },
  // Image analysis
  'depth_estimation':    { pipeline:'depth-estimation',            q:'depth',                   gguf:'no-gguf' },
  'image_segmentation':  { pipeline:'image-segmentation',          q:'segmentation',            gguf:'no-gguf' },
  'object_detection':    { pipeline:'object-detection',            q:'detection',               gguf:'no-gguf' },
  // Image 3D
  'image_to_3d':         { pipeline:'',                            q:'image-to-3d',             gguf:'no-gguf' },
  'model_3d_generation': { pipeline:'',                            q:'3d generation',           gguf:'no-gguf' },
  'model_3d_to_image':   { pipeline:'',                            q:'3d rendering',            gguf:'no-gguf' },
  // Video
  'video_generation':    { pipeline:'text-to-video',               q:'video',                   gguf:'no-gguf' },
  'image_to_video':      { pipeline:'image-to-video',              q:'image-to-video',          gguf:'no-gguf' },
  'video_to_video':      { pipeline:'',                            q:'video diffusion',         gguf:'no-gguf' },
  'video_interpolation': { pipeline:'',                            q:'frame interpolation',     gguf:'no-gguf' },
  'video_upscaling':     { pipeline:'',                            q:'video upscaling',         gguf:'no-gguf' },
  'video_to_3d':         { pipeline:'',                            q:'video depth',             gguf:'no-gguf' },
  // Audio
  'speech_to_text':      { pipeline:'automatic-speech-recognition',q:'whisper',                gguf:'gguf'    },
  'subtitle_generation': { pipeline:'automatic-speech-recognition',q:'whisper',                gguf:'gguf'    },
  'text_to_speech':      { pipeline:'text-to-speech',              q:'tts',                     gguf:'no-gguf' },
  'audio_generation':    { pipeline:'text-to-audio',               q:'music',                   gguf:'no-gguf' },
  'audio_to_audio':      { pipeline:'audio-to-audio',              q:'voice conversion',        gguf:'no-gguf' },
};
function capSearchUrl(cap) {
  const s = CAP_TO_HF_SEARCH[cap];
  if (!s) return null;
  const p = new URLSearchParams({ tab:'search', q: s.q, pipeline: s.pipeline, gguf: s.gguf });
  return (window.ROOT_PATH || '') + '/admin/models?' + p.toString();
}
function capMissingHtml(caps, label) {
  if (!caps.length) return '';
  const links = caps.map(cap => {
    const chip = `<span class="cap-chip dim">${cap.replace(/_/g,' ')}</span>`;
    if (_localCapSet.has(cap)) {
      const url = `${window.ROOT_PATH || ''}/admin/models?local_cap=${encodeURIComponent(cap)}`;
      return `<a href="${url}" class="cap-find-link" title="You have a local model with ${cap.replace(/_/g,' ')} — click to configure it">${chip}<span class="cap-find-icon" style="color:#6ecf7e">↑ configure</span></a>`;
    }
    const url = capSearchUrl(cap);
    return url
      ? `<a href="${url}" target="_blank" class="cap-find-link" title="Find ${cap.replace(/_/g,' ')} model on HuggingFace">${chip}<span class="cap-find-icon">↗ HuggingFace</span></a>`
      : chip;
  }).join(' ');
  return `<div class="cap-missing"><strong>${label}:</strong> ${links}</div>`;
}

const SUB_CAPABILITY_RULES = {
  'img-gen': { category:'image', requiresAny:['image_generation'] },
  'img-edit': { category:'image', requiresAny:['image_to_image'] },
  'img-inpaint': { category:'image', requiresAny:['inpainting'] },
  'img-upscale': { category:'image', requiresAny:['image_upscaling'] },
  'img-depth': { category:'image', requiresAny:['depth_estimation'] },
  'img-seg': { category:'image', requiresAny:['image_segmentation'] },
  'img-faceswap': { category:'image' },
  'img-deblur': { category:'image' },
  'img-unpix': { category:'image' },
  'img-outfit': { category:'image', requiresAny:['inpainting','image_to_image'] },
  'vid-t2v': { category:'video', requiresAny:['video_generation'] },
  'vid-i2v': { category:'video', requiresAny:['image_to_video'] },
  'vid-v2v': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] },
  'vid-ti2v': { category:'video', optional:['video_generation','image_to_video'], fallbackTypes:['video'] },
  'vid-interp': { category:'video', optional:['video_interpolation'], fallbackTypes:['video'] },
  'vid-sub': { category:'video', optional:['subtitle_generation'], fallbackTypes:['video'] },
  'vid-dub': { category:'video', optional:['subtitle_generation','speech_to_text','text_to_speech'], fallbackTypes:['video'] },
  'vid-up': { category:'video', requiresAny:['video_upscaling'], fallbackTypes:['video'] },
  'vid-faceswap': { category:'video' },
  'vid-outfit': { category:'video', requiresAny:['inpainting','image_to_image'], fallbackTypes:['video'] },
  'img-to3d': { category:'image', optional:['depth_estimation','image_to_3d'], fallbackTypes:['image'] },
  'img-from3d': { category:'image', optional:['model_3d_to_image'], fallbackTypes:['image'] },
  'vid-to3d': { category:'video', optional:['depth_estimation','video_to_3d'], fallbackTypes:['video'] },
  'vid-from3d': { category:'video', optional:['model_3d_to_image'], fallbackTypes:['video'] },
  '3d-generate': { category:'3d', optional:['model_3d_generation','image_to_3d'], fallbackTypes:['image'] },
  '3d-img-to3d': { category:'3d', optional:['depth_estimation','image_to_3d'], fallbackTypes:['image'] },
  '3d-vid-to3d': { category:'3d', optional:['depth_estimation','video_to_3d'], fallbackTypes:['video'] },
  '3d-from3d': { category:'3d', optional:['model_3d_to_image'], fallbackTypes:['image'] },
  'aud-gen': { category:'audio', requiresAny:['audio_generation'] },
  'aud-music-dub': { category:'audio', optional:['speech_to_text','text_to_speech','audio_generation'] },
  'aud-tts': { category:'audio', requiresAny:['text_to_speech'] },
  'aud-clone': { category:'audio', optional:['text_to_speech','audio_generation'] },
  'aud-convert': { category:'audio', optional:['speech_to_text','text_to_speech','audio_generation'] },
  'aud-stt': { category:'audio', requiresAny:['speech_to_text'] },
  'aud-understand': { category:'audio', optional:['speech_to_text','text_generation'], fallbackTypes:['audio','text','vision'] },
  'aud-stems': { category:'audio', optional:['audio_generation'], fallbackTypes:['audio'] },
  'aud-clean': { category:'audio', optional:['speech_to_text'], fallbackTypes:['audio'] },
  'embed': { requiresAny:['embeddings'] },
};
const CATEGORY_TABS = ['chat', 'image', 'video', 'audio', '3d', 'profiles', 'pipe', 'embed', 'archive'];
const CATEGORY_SUBS = {
  image: Object.keys(SUB_CAPABILITY_RULES).filter(sub => SUB_CAT[sub] === 'image'),
  video: Object.keys(SUB_CAPABILITY_RULES).filter(sub => SUB_CAT[sub] === 'video'),
  audio: Object.keys(SUB_CAPABILITY_RULES).filter(sub => SUB_CAT[sub] === 'audio'),
  '3d': Object.keys(SUB_CAPABILITY_RULES).filter(sub => SUB_CAT[sub] === '3d'),
};
let currentTabState = { categories:{}, subs:{} };
let audioBackendHealth = {
  separation: { available:false, engine:null, model:null },
  restoration: { available:false, engine:null, model:null },
  musicDub: { available:false, stages:[] },
};

function refreshAudioBackendHealth() {
  audioBackendHealth = {
    separation: {
      available: !!(activeModel?.capabilities || []).includes('audio_to_audio'),
      engine: !!(activeModel?.capabilities || []).includes('audio_to_audio') ? 'ml-provider' : null,
      model: activeModel?.id || null,
    },
    restoration: {
      available: !!(activeModel?.capabilities || []).includes('audio_to_audio'),
      engine: !!(activeModel?.capabilities || []).includes('audio_to_audio') ? 'ml-provider' : null,
      model: activeModel?.id || null,
    },
    musicDub: {
      available: !!(activeModel?.capabilities || []).includes('speech_to_text') && !!(activeModel?.capabilities || []).includes('audio_to_audio'),
      stages: ['stems', 'stt', 'translate', 'voice_convert', 'remix'],
    },
  };
}

function capabilitySetForModel(m) {
  return new Set((m?.capabilities || []).filter(Boolean));
}

function crossModelCaps() {
  const s = new Set();
  models.forEach(m => (m.capabilities || []).forEach(c => s.add(c)));
  return s;
}

function bestModelForCap(cap) {
  return models.find(m => (m.capabilities || []).includes(cap)) || null;
}

function updatePipelineBadges() {
  const allCaps = crossModelCaps();
  document.querySelectorAll('.pipe-card[data-requires]').forEach(card => {
    let reqs, opts;
    try { reqs = JSON.parse(card.dataset.requires || '[]'); } catch(e) { reqs = []; }
    try { opts = JSON.parse(card.dataset.optional || '[]'); } catch(e) { opts = []; }
    const hasAll = reqs.every(c => allCaps.has(c));
    const hasSome = reqs.some(c => allCaps.has(c));
    const state = hasAll ? 'ready' : (hasSome || (!reqs.length && opts.some(c => allCaps.has(c)))) ? 'partial' : 'unavailable';
    const label = state === 'ready' ? 'Ready' : state === 'partial' ? 'Partial' : 'Unavailable';
    const head = card.querySelector('.pipe-head');
    if (!head) return;
    let badge = head.querySelector('.pipe-badge');
    if (!badge) {
      badge = document.createElement('span');
      badge.className = 'pipe-badge';
      const title = head.querySelector('.pipe-title');
      if (title) title.insertAdjacentElement('afterend', badge);
      else head.insertBefore(badge, head.firstChild);
    }
    badge.className = `pipe-badge ${state}`;
    badge.textContent = label;
    // Capability chips in the card body
    const body = card.querySelector('.pipe-card-body');
    if (body) {
      let capsDiv = body.querySelector('.pipe-caps');
      if (!capsDiv) {
        capsDiv = document.createElement('div');
        capsDiv.className = 'pipe-caps';
        body.insertBefore(capsDiv, body.firstChild);
      }
      const fmt = c => c.replace(/_/g, ' ');
      const reqChips = reqs.map(c =>
        `<span class="pipe-cap-chip${allCaps.has(c) ? ' ok' : ' missing'}" title="${c}">${fmt(c)}</span>`
      ).join('');
      const optChips = opts.map(c =>
        `<span class="pipe-cap-chip optional${allCaps.has(c) ? ' ok' : ''}" title="${c}">${fmt(c)}</span>`
      ).join('');
      const parts = [];
      if (reqChips) parts.push(`<span class="pipe-caps-label">Requires</span>${reqChips}`);
      if (optChips) parts.push(`<span class="pipe-caps-label">Optional</span>${optChips}`);
      capsDiv.innerHTML = parts.join('');
    }
  });
  autoPopulatePipelineInputs();
}

function hasTypeFallback(rule, typeOrTypes) {
  if (!Array.isArray(rule.fallbackTypes)) return false;
  if (typeOrTypes instanceof Set) return rule.fallbackTypes.some(t => typeOrTypes.has(t));
  return rule.fallbackTypes.includes(typeOrTypes);
}

function evaluateSubCapability(rule, caps, typeOrTypes) {
  const required = rule.requiresAny || [];
  const optional = rule.optional || [];
  const hasRequired = required.some(cap => caps.has(cap));
  const hasOptional = optional.some(cap => caps.has(cap));
  const fallback = hasTypeFallback(rule, typeOrTypes);

  if (hasRequired) return 'available';
  if (!required.length && (hasOptional || fallback)) return 'partial';
  if (required.length && (hasOptional || fallback)) return 'partial';
  if (!required.length) return 'partial';
  return 'unavailable';
}

function evaluateCategoryState(cat, subStates, caps, type) {
  if (cat === 'chat') {
    return (caps.has('text_generation') || caps.has('image_to_text') || type === 'text' || type === 'vision')
      ? 'available'
      : 'partial';
  }
  if (cat === 'pipe') return 'none';
  if (cat === 'archive') return 'none';
  if (cat === 'profiles') return 'none';
  if (cat === 'embed') return caps.has('embeddings') || type === 'embedding' ? 'available' : 'unavailable';
  if (cat === '3d') return 'none';  // always show 3D tab (no required capability)
  const states = (CATEGORY_SUBS[cat] || []).map(sub => subStates[sub]).filter(Boolean);
  if (states.includes('available')) return 'available';
  if (states.includes('partial')) return 'partial';
  return 'unavailable';
}

function setTabVisualState(btn, state) {
  if (!btn) return;
  btn.classList.remove('state-ready', 'state-partial', 'state-unavailable');
  const def = TAB_STATE[state] || TAB_STATE.unavailable;
  if (def.className) btn.classList.add(def.className);
  const badge = btn.querySelector('.tab-status');
  if (!badge) return;
  if (def.label) {
    badge.textContent = def.label;
    badge.hidden = false;
  } else {
    badge.textContent = '';
    badge.hidden = true;
  }
}

function isSubVisibleForCategory(sub, cat) {
  return SUB_CAT[sub] === cat;
}

function getFirstVisibleSub(cat) {
  return document.querySelector(`.t2btn[data-sub][data-cat-visible="${cat}"]:not(.state-hidden)`);
}

function getSubtabState(sub) {
  return currentTabState.subs[sub] || 'unavailable';
}

function getCapabilityDetails(sub) {
  const def = STUDIO_CAPABILITIES[sub];
  if (!def) return null;
  const caps = crossModelCaps();
  const required = def.requires || [];
  const optional = def.optional || [];
  const missingRequired = required.filter(cap => !caps.has(cap));
  const missingOptional = optional.filter(cap => !caps.has(cap));
  const availability = getSubtabState(sub);
  return {
    ...def,
    availability,
    missingRequired,
    missingOptional,
  };
}

// ── Model pick block state ───────────────────────────────────────────────
let _mpVae = {};    // { sub: string }  — VAE override per sub
let _mpLoras = {};  // { sub: [{model, weight, name}] }

const _MP_ROLE_LABELS = {
  image_generation:'Image generation', image_to_image:'Image editing',
  inpainting:'Inpainting', image_upscaling:'Upscaler', depth_estimation:'Depth estimator',
  image_segmentation:'Segmentation', video_generation:'Video generation',
  image_to_video:'Image → video', video_to_video:'Video editing',
  video_interpolation:'Frame interpolation', video_upscaling:'Video upscaler',
  subtitle_generation:'Subtitles', speech_to_text:'Transcription',
  text_to_speech:'Voice synthesis', audio_generation:'Music / SFX',
  audio_to_audio:'Voice conversion', embeddings:'Embedding model',
  text_generation:'Language model', image_to_text:'Vision model',
};

const _IMAGE_SUBS = new Set(['img-gen','img-edit','img-inpaint','img-upscale','img-depth',
  'img-seg','img-outfit','img-faceswap','img-deblur','img-unpix','img-to3d','img-from3d']);
const _VIDEO_SUBS_VL = new Set(['vid-t2v','vid-i2v','vid-v2v','vid-ti2v']);

function _mpBuildOpts(sub, cap) {
  const assigned = capModelAssignments[sub]?.[cap] || activeModel?.id || '';
  const capable = modelsForCap(cap);
  if (!capable.length) {
    return `<option value="">— no compatible model configured —</option>`;
  }
  let opts = `<option value="">— select —</option>`;
  capable.forEach(m => {
    const lbl = m.id.split('/').pop() + (m.load_mode === 'load' ? ' ●' : '');
    opts += `<option value="${escapeHtml(m.id)}"${m.id === assigned ? ' selected' : ''}>${escapeHtml(lbl)}</option>`;
  });
  return opts;
}

function _mpBuildComponentOpts(selected, pattern) {
  const list = models.filter(m => pattern.test(m.id));
  if (!list.length) return null;
  let opts = `<option value="">— none —</option>`;
  list.forEach(m => {
    const lbl = m.id.split('/').pop() + (m.load_mode === 'load' ? ' ●' : '');
    opts += `<option value="${escapeHtml(m.id)}"${m.id === selected ? ' selected' : ''}>${escapeHtml(lbl)}</option>`;
  });
  return opts;
}

function _mpLoraEntry(sub, i, lora) {
  const opts = _mpBuildComponentOpts(lora.model || '', /lora/i);
  const sel = opts
    ? `<select class="model-pick-sel" onchange="_mpLoraChange('${sub}',${i},'model',this.value)">${opts}</select>`
    : `<span class="model-pick-sel" style="color:var(--text-3);font-size:11px;display:flex;align-items:center">No LoRA models configured</span>`;
  return `<div class="lora-entry" id="mp-lora-${sub}-${i}">
    ${sel}
    ${opts ? `<input type="number" class="lora-weight" value="${lora.weight??1}" step="0.05" min="0" max="2"
      title="Weight" onchange="_mpLoraChange('${sub}',${i},'weight',parseFloat(this.value))">` : ''}
    <button class="lora-remove" onclick="_mpLoraRemove('${sub}',${i})" title="Remove">✕</button>
  </div>`;
}

function renderModelPickBlock(sub) {
  const studioRule = STUDIO_CAPABILITIES[sub];
  const subRule    = SUB_CAPABILITY_RULES[sub];
  const isMulti    = studioRule && (studioRule.requires || []).length > 1;
  const showVaeLora = _IMAGE_SUBS.has(sub) || _VIDEO_SUBS_VL.has(sub);

  // Build the list of caps to show selectors for
  let caps = [];
  if (studioRule) {
    const req = (studioRule.requires || []).filter(c => modelsForCap(c).length > 0);
    const opt = (studioRule.optional || []).filter(c => modelsForCap(c).length > 0);
    caps = req.map(c=>({cap:c,required:true})).concat(opt.map(c=>({cap:c,required:false})));
  } else {
    const primary = SUB_API_CAP[sub] || (subRule?.requiresAny||subRule?.optional||[])[0];
    if (primary) caps = [{cap:primary, required:true}];
  }

  let rows = '';
  if (caps.length === 0) {
    rows = `<div class="model-pick-row" style="font-size:12px;color:var(--text-3)">Auto — no explicit capability required.</div>`;
  } else {
    rows = caps.map(({cap, required}) => {
      const roleLabel = _MP_ROLE_LABELS[cap] || cap.replace(/_/g,' ');
      const opts = _mpBuildOpts(sub, cap);
      const selId = `mpsel-${sub.replace(/-/g,'_')}-${cap}`;
      const optLabel = !required && isMulti ? ` <em style="font-weight:400;opacity:.65">(opt)</em>` : '';
      return `<div class="model-pick-row">
        ${isMulti || caps.length > 1 ? `<span class="model-pick-role">${escapeHtml(roleLabel)}${optLabel}</span>` : ''}
        <select class="model-pick-sel" id="${selId}" onchange="_mpSelChange('${sub}','${cap}',this.value)">
          ${opts}
        </select>
      </div>`;
    }).join('');
  }

  // VAE / LoRA section
  let vaeLoraSec = '';
  if (showVaeLora) {
    const vaeOpts = _mpBuildComponentOpts(_mpVae[sub] || '', /vae/i);
    const hasLora = models.some(m => /lora/i.test(m.id));
    const loraListHtml = (_mpLoras[sub] || []).map((l,i) => _mpLoraEntry(sub, i, l)).join('');
    const hasAnyComponent = vaeOpts || hasLora;
    if (hasAnyComponent) {
      const vaeRow = vaeOpts
        ? `<div class="model-pick-row">
            <span class="model-pick-role">VAE</span>
            <select class="model-pick-sel" id="mpvae-${sub.replace(/-/g,'_')}" onchange="_mpVaeChange('${sub}',this.value)">
              ${vaeOpts}
            </select>
          </div>`
        : '';
      const loraSection = hasLora
        ? `<div class="model-pick-role" style="min-width:0;font-size:10px;color:var(--text-3);margin-top:.15rem">LoRA</div>
           <div id="mp-loras-${sub}">${loraListHtml}</div>
           <button class="btn btn-ghost btn-sm" onclick="_mpLoraAdd('${sub}')" style="align-self:flex-start;font-size:11px">+ Add LoRA</button>`
        : '';
      vaeLoraSec = `<div class="mp-extra"><details>
        <summary>VAE / LoRA <em style="font-weight:400;opacity:.65">(optional overrides)</em></summary>
        <div class="mp-extra-body">
          ${vaeRow}
          ${loraSection}
        </div>
      </details></div>`;
    }
  }

  return `<div class="model-pick-block">
    <div class="model-pick-title">${isMulti || caps.length > 1 ? 'Models' : 'Model'}</div>
    ${rows}
    ${caps.length > 0 ? `<div class="model-pick-hint"><span style="opacity:.6">● loaded in VRAM</span></div>` : ''}
    ${vaeLoraSec}
  </div>`;
}

function _mpSelChange(sub, cap, modelId) {
  if (!modelId) return;
  const m = models.find(m => m.id === modelId);
  if (!m) return;
  if (cap) assignModelToCap(sub, cap, m);
  else selectSubModel(sub, m);
}

function _mpVaeChange(sub, value) { _mpVae[sub] = value || null; }

function _mpLoraChange(sub, i, field, value) {
  if (!_mpLoras[sub]) _mpLoras[sub] = [];
  if (!_mpLoras[sub][i]) _mpLoras[sub][i] = {model:'', weight:1.0};
  _mpLoras[sub][i][field] = value;
}

function _mpLoraAdd(sub) {
  if (!_mpLoras[sub]) _mpLoras[sub] = [];
  _mpLoras[sub].push({model:'', weight:1.0});
  const el = document.getElementById(`mp-loras-${sub}`);
  if (el) el.innerHTML = _mpLoras[sub].map((l,i) => _mpLoraEntry(sub, i, l)).join('');
}

function _mpLoraRemove(sub, idx) {
  if (_mpLoras[sub]) {
    _mpLoras[sub].splice(idx, 1);
    const el = document.getElementById(`mp-loras-${sub}`);
    if (el) el.innerHTML = _mpLoras[sub].map((l,i) => _mpLoraEntry(sub, i, l)).join('');
  }
}

function _mpSyncSelect(sub, cap, modelId) {
  const selId = `mpsel-${sub.replace(/-/g,'_')}-${cap}`;
  const el = document.getElementById(selId);
  if (el && modelId) el.value = modelId;
}

function getVaeForSub(sub) { return _mpVae[sub] || null; }
function getLorasForSub(sub) {
  return (_mpLoras[sub] || [])
    .filter(l => l.model)
    .map((l, i) => ({
      model: l.model,
      weight: l.weight ?? 1.0,
      name: l.name || l.model.split('/').pop().replace(/[^a-zA-Z0-9_-]/g,'_'),
    }));
}

// ── end model pick block ─────────────────────────────────────────────────

function renderCapabilityCard(sub) {
  const shell = $(`cap-${sub}`);
  if (!shell) return;
  const details = getCapabilityDetails(sub);
  if (!details) {
    shell.style.display = 'none';
    shell.innerHTML = '';
    return;
  }
  shell.style.display = '';
  shell.classList.remove('state-partial', 'state-unavailable');
  if (details.availability === 'partial') shell.classList.add('state-partial');
  if (details.availability === 'unavailable') shell.classList.add('state-unavailable');
  const availabilityLabel = details.availability === 'available' ? 'Ready' : details.availability === 'partial' ? 'Partial' : 'Unavailable';
  const availabilityClass = details.availability === 'available' ? ' ok' : details.availability === 'partial' ? ' warn' : ' dim';
  const missingBits = [];
  if (details.missingRequired.length) missingBits.push(capMissingHtml(details.missingRequired, 'Missing required'));
  if (details.missingOptional.length) missingBits.push(capMissingHtml(details.missingOptional, 'Limited without'));
  const notes = (details.notes || []).map(note => `<div class="cap-note">${note}</div>`).join('');
  shell.innerHTML = `
    <div class="cap-card-top">
      <div>
        <div class="cap-card-title">${details.label}</div>
        <div class="cap-card-summary">${details.summary}</div>
      </div>
      <span class="cap-chip${availabilityClass}">${availabilityLabel}</span>
    </div>
    <div class="cap-meta">
      <span class="cap-chip">${details.category}</span>
      <span class="cap-chip">${details.backendPath}</span>
      <span class="cap-chip">${details.io}</span>
    </div>
    ${renderModelPickBlock(sub)}
    ${missingBits.join('')}
    ${notes}
  `;
}

function renderCapabilityCards() {
  Object.keys(STUDIO_CAPABILITIES).forEach(renderCapabilityCard);
  // Render availability cards for all subs using SUB_CAPABILITY_RULES
  Object.keys(SUB_CAPABILITY_RULES).forEach(sub => {
    if (STUDIO_CAPABILITIES[sub]) return; // already handled above
    const shell = $(`cap-${sub}`);
    if (!shell) return;
    const state = currentTabState.subs[sub] || 'unavailable';
    const picker = renderModelPickBlock(sub);
    if (state === 'available') {
      shell.style.display = '';
      shell.classList.remove('state-partial', 'state-unavailable');
      shell.innerHTML = picker;
      return;
    }
    shell.style.display = '';
    shell.classList.remove('state-partial', 'state-unavailable');
    shell.classList.add(state === 'partial' ? 'state-partial' : 'state-unavailable');
    const rule = SUB_CAPABILITY_RULES[sub];
    const allCaps = crossModelCaps();
    const missingRequired = (rule.requiresAny || []).filter(c => !allCaps.has(c));
    const missingOptional = (rule.optional || []).filter(c => !allCaps.has(c));
    const label = document.querySelector(`.t2btn[data-sub="${sub}"]`)?.childNodes[0]?.textContent?.trim() || sub;
    const missingBits = [];
    if (missingRequired.length) missingBits.push(capMissingHtml(missingRequired, 'Missing required'));
    if (missingOptional.length) missingBits.push(capMissingHtml(missingOptional, 'Limited without'));
    const availabilityLabel = state === 'partial' ? 'Partial' : 'Unavailable';
    const availabilityClass = state === 'partial' ? ' warn' : ' dim';
    shell.innerHTML = `
      <div class="cap-card-top">
        <div class="cap-card-title">${escapeHtml(label)}</div>
        <span class="cap-chip${availabilityClass}">${availabilityLabel}</span>
      </div>
      ${picker}
      ${missingBits.join('')}
    `;
  });
  renderAudioBackendHealth();
}

function renderCapabilityOutputNote(sub) {
  const details = getCapabilityDetails(sub);
  const noteId = `cap-output-note-${sub}`;
  const panelId = SUB_PANEL_ALIAS[sub] || (`panel-${sub}`);
  const panel = $(panelId);
  if (!panel) return;
  panel.querySelector(`#${noteId}`)?.remove();
  if (!details || details.availability === 'available') return;
  const missingItems = [];
  details.missingRequired.forEach(cap => {
    const url = capSearchUrl(cap);
    const label = cap.replace(/_/g,' ');
    missingItems.push(url
      ? `<strong>Required:</strong> <a href="${url}" target="_blank" class="cap-find-link">${label}<span class="cap-find-icon">↗</span></a>`
      : `<strong>Required:</strong> ${label}`);
  });
  details.missingOptional.forEach(cap => {
    const url = capSearchUrl(cap);
    const label = cap.replace(/_/g,' ');
    missingItems.push(url
      ? `<strong>Optional:</strong> <a href="${url}" target="_blank" class="cap-find-link">${label}<span class="cap-find-icon">↗</span></a>`
      : `<strong>Optional:</strong> ${label}`);
  });
  if (!missingItems.length && Array.isArray(details.notes)) missingItems.push(...details.notes);
  const title = details.availability === 'unavailable' ? '⚠ Feature unavailable' : '⚠ Feature partially available';
  const detailHtml = missingItems.length
    ? `<ul>${missingItems.map(item => `<li>${item}</li>`).join('')}</ul>`
    : '<div>Some required pieces are missing for this surface.</div>';
  const target = panel.querySelector('.gen-out') || panel.querySelector('.gen-wrap') || panel;
  target.insertAdjacentHTML('afterbegin', `
    <div class="cap-output-note ${details.availability === 'unavailable' ? 'unavailable' : ''}" id="${noteId}">
      <strong>${title}</strong>
      ${detailHtml}
    </div>
  `);
}

function renderOutputCapabilityNotes() {
  Object.keys(STUDIO_CAPABILITIES).forEach(sub => renderCapabilityOutputNote(sub));
}

function summarizeDiagnostics() {
  const subStates = currentTabState.subs || {};
  const details = Object.entries(SUB_CAPABILITY_RULES).map(([sub, rule]) => {
    const def = STUDIO_CAPABILITIES[sub];
    const state = subStates[sub] || 'unavailable';
    const label = def?.label || document.querySelector(`.t2btn[data-sub="${sub}"]`)?.childNodes[0]?.textContent?.trim() || sub;
    const fallbackOnly = state !== 'available' && !((rule.requiresAny || []).some(cap => capabilitySetForModel(activeModel).has(cap))) && hasTypeFallback(rule, activeModel?.type || 'text');
    return { sub, state, label, fallbackOnly };
  });
  return {
    available: details.filter(item => item.state === 'available'),
    limited: details.filter(item => item.state === 'partial' && !item.fallbackOnly),
    unavailable: details.filter(item => item.state === 'unavailable'),
    fallbackOnly: details.filter(item => item.fallbackOnly),
  };
}

function renderAudioBackendHealth() {
  const targets = {
    'aud-music-dub': audioBackendHealth.musicDub,
    'aud-stems': audioBackendHealth.separation,
    'aud-clean': audioBackendHealth.restoration,
  };
  Object.entries(targets).forEach(([sub, info]) => {
    const shell = $(`cap-${sub}`);
    if (!shell) return;
    const extra = [];
    if (info?.engine) extra.push(`<div class="cap-note"><strong>Runtime engine:</strong> ${escapeHtml(info.engine)}</div>`);
    if (info?.model) extra.push(`<div class="cap-note"><strong>Runtime model:</strong> ${escapeHtml(info.model)}</div>`);
    if (Array.isArray(info?.stages) && info.stages.length) extra.push(`<div class="cap-note"><strong>Stages:</strong> ${info.stages.map(escapeHtml).join(' → ')}</div>`);
    if (extra.length) shell.insertAdjacentHTML('beforeend', extra.join(''));
  });
}

function renderDiagnostics() {
  const shell = $('diag-groups');
  if (!shell) return;
  if (!activeModel) {
    shell.innerHTML = '<div class="diag-empty">Select a model to inspect Studio surfaces.</div>';
    return;
  }
  const groups = summarizeDiagnostics();
  const defs = [
    { key:'available', label:'Available surfaces' },
    { key:'limited', label:'Limited surfaces' },
    { key:'unavailable', label:'Unavailable surfaces' },
    { key:'fallbackOnly', label:'Fallback / pipeline-only' },
  ];
  shell.innerHTML = defs.map(def => {
    const items = groups[def.key] || [];
    return `
      <div class="diag-group">
        <div class="diag-group-head">
          <div class="diag-group-label">${escapeHtml(def.label)}</div>
          <span class="cap-chip">${items.length}</span>
        </div>
        ${items.length ? `<div class="diag-items">${items.map(item => `<span class="cap-chip">${escapeHtml(item.label)}</span>`).join('')}</div>` : '<div class="diag-empty">None</div>'}
      </div>
    `;
  }).join('');
}

function compactSummary(value, max=140) {
  const text = previewText(value, '').replace(/\s+/g, ' ').trim();
  if (!text) return '—';
  return text.length > max ? text.slice(0, max - 1) + '…' : text;
}

function pushArtifactHistory(entry) {
  artifactHistory.unshift({
    timestamp: new Date(),
    ...entry,
  });
  artifactHistory = artifactHistory.slice(0, ARTIFACT_HISTORY_LIMIT);
  renderArtifactHistory();
}

function renderArtifactHistory() {
  const shell = $('artifact-history-list');
  if (!shell) return;
  if (!artifactHistory.length) {
    shell.innerHTML = '<div class="hist-empty">Successful outputs from this session will appear here.</div>';
    return;
  }
  shell.innerHTML = artifactHistory.map(item => {
    const links = (item.links || []).filter(link => link?.href).map(link => `<a class="btn btn-ghost btn-sm hist-link" href="${escapeHtml(link.href)}" target="_blank" rel="noopener">${escapeHtml(link.label || 'Open')}</a>`).join('');
    const chips = [item.family, item.model].filter(Boolean).map(value => `<span class="cap-chip">${escapeHtml(value)}</span>`).join('');
    return `
      <div class="hist-item">
        <div class="hist-top">
          <div>
            <div class="hist-name">${escapeHtml(item.task)}</div>
            <div class="hist-meta">${escapeHtml(item.timestamp.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}))}</div>
          </div>
          <div class="hist-links">${links}</div>
        </div>
        <div class="hist-chips">${chips}</div>
        <div class="hist-summary">${escapeHtml(compactSummary(item.summary))}</div>
      </div>
    `;
  }).join('');
}

function buildDubHistorySummary(preview) {
  const source = preview?.source_lang || 'auto';
  const target = preview?.target_lang || 'unspecified';
  return `Dub ${source} → ${target}${preview?.burn_subtitles ? ' with burned subtitles' : ''}`;
}

function buildImageHistorySummary(urls) {
  return `${urls.length} image${urls.length === 1 ? '' : 's'} generated`;
}

function buildAudioHistorySummary(prompt, duration) {
  const promptText = compactSummary(prompt, 90);
  return `${duration || '—'}s clip · ${promptText}`;
}

function buildTTSHistorySummary(input, voice) {
  return `${voice || 'default voice'} · ${compactSummary(input, 100)}`;
}

function buildSTTHistorySummary(text, fileName) {
  return `${fileName || 'uploaded file'} · ${compactSummary(text, 100)}`;
}

function buildStemHistorySummary(mode, count, backend) {
  const engine = backend?.engine || 'unknown';
  const model = backend?.model ? ` · ${backend.model}` : '';
  return `${mode} · ${count} artifact${count === 1 ? '' : 's'} · ${engine}${model}`;
}

function buildCleanupHistorySummary(applied, fileName, backend) {
  const engine = backend?.engine || 'unknown';
  const model = backend?.model ? ` · ${backend.model}` : '';
  return `${fileName || 'uploaded file'} · ${(applied || []).join(', ') || 'no ops'} · ${engine}${model}`;
}

function buildMusicDubHistorySummary(transcript, translatedLyrics, backend) {
  const engine = backend?.engine || 'pipeline';
  const model = backend?.model ? ` · ${backend.model}` : '';
  return `${compactSummary(transcript, 50)} · ${compactSummary(translatedLyrics, 50)} · ${engine}${model}`;
}

function buildDubFamilyLabel() {
  return val('vd-track-mode') === 'localized-mix' ? 'video dub / localized mix' : 'video dub / speech-only';
}

function buildDubLinks(item) {
  const src = vidSrc(item);
  return src ? [{ label:'Open', href:src }] : [];
}

function buildAudioLinks(item) {
  const src = audSrc(item);
  return src ? [{ label:'Open', href:src }] : [];
}

function buildImageLinks(urls) {
  return (urls || []).filter(Boolean).slice(0, 2).map((href, index) => ({ label:index === 0 ? 'Open' : `Open ${index + 1}`, href }));
}

function buildDubPreferencePreview() {
  return {
    source_lang: val('vd-slang') || undefined,
    target_lang: val('vd-tlang') || undefined,
    burn_subtitles: chk('vd-burn'),
    preserve_voice: chk('vd-preserve-voice'),
    preserve_tone: chk('vd-preserve-tone'),
    preserve_timing: chk('vd-preserve-timing'),
    preserve_singing: chk('vd-preserve-singing'),
    translate_lyrics: chk('vd-lyrics'),
    track_mode: val('vd-track-mode') || 'speech-only',
  };
}

function previewText(value, fallback='—') {
  if (value === undefined || value === null || value === '') return fallback;
  if (typeof value === 'boolean') return value ? 'true' : 'false';
  return String(value);
}

function previewFileName(id) {
  const file = fileOrNull(id);
  return file ? file.name : 'none';
}

function escapeHtml(value) {
  return String(value)
    .replace(/&/g,'&amp;')
    .replace(/</g,'&lt;')
    .replace(/>/g,'&gt;')
    .replace(/"/g,'&quot;')
    .replace(/'/g,'&#39;');
}

function previewExportBody(endpoint, body) {
  const safe = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
  return {
    endpoint,
    body: safe,
    json: JSON.stringify(safe, null, 2),
  };
}

function buildAudioPreviewData() {
  return previewExportBody(ROOT_PATH + '/v1/audio/generate', {
    model: modelForSub('aud-gen') || '',
    prompt: val('ag-prompt'),
    duration: fval('ag-dur') || 10,
    temperature: fval('ag-temp') || 1.0,
    top_k: ival('ag-topk') || 250,
    cfg_coef: fval('ag-cfg') || 3.0,
    seed: val('ag-seed') ? ival('ag-seed') : undefined,
    melody: fileOrNull('ag-melody') ? '<audio file data>' : undefined,
    response_format: 'url',
  });
}

function buildTTSPreviewData() {
  return previewExportBody(ROOT_PATH + '/v1/audio/speech', {
    model: modelForSub('aud-tts') || '',
    input: val('at-text'),
    voice: val('at-voice') || undefined,
    speed: fval('at-speed') || 1.0,
    response_format: 'mp3',
  });
}

function buildSTTPreviewData() {
  const sttModel = bestModelForCap('speech_to_text') || activeModel;
  return previewExportBody(ROOT_PATH + '/v1/audio/transcriptions', {
    model: sttModel?.id || '',
    file: fileOrNull('as-file') ? '<multipart audio/video file>' : undefined,
    language: val('as-lang') || undefined,
    prompt: val('as-prompt') || undefined,
    response_format: 'json',
  });
}

function buildImageGenPreviewData() {
  const loras = getLorasForSub('img-gen');
  const vae = getVaeForSub('img-gen');
  return previewExportBody(ROOT_PATH + '/v1/images/generations', {
    model: modelForSub('img-gen') || '',
    prompt: val('ig-prompt'),
    negative_prompt: val('ig-neg') || undefined,
    size: `${ival('ig-w') || 1024}x${ival('ig-h') || 1024}`,
    steps: ival('ig-steps') || 30,
    guidance_scale: fval('ig-cfg') || 7.5,
    seed: val('ig-seed') ? ival('ig-seed') : undefined,
    n: ival('ig-n') || 1,
    response_format: 'url',
    safety_checker: chk('ig-nosafe') ? false : undefined,
    ...(vae ? {vae_model: vae} : {}),
    ...(loras.length ? {loras} : {}),
  });
}

function buildEmbeddingsPreviewData() {
  const lines = val('em-text').split('\n').filter(l => l.trim());
  const input = lines.length <= 1 ? (lines[0] || '') : lines;
  return previewExportBody(ROOT_PATH + '/v1/embeddings', {
    model: modelForSub('embed') || '',
    input,
    encoding_format: val('em-enc') || 'float',
    dimensions: val('em-dims') ? ival('em-dims') : undefined,
  });
}

function buildAudioUnderstandPreviewData() {
  return previewExportBody(ROOT_PATH + '/v1/pipelines/audio-understand', {
    audio: fileOrNull('au-file') ? '<audio/video file data>' : undefined,
    audio_model: activeModel?.id || '',
    text_model: val('au-text-model') || undefined,
    input: val('au-goal') || undefined,
    language: val('as-lang') || undefined,
  });
}

function buildMusicDubPreviewData() {
  return previewExportBody(ROOT_PATH + '/v1/pipelines/audio-music-dub', {
    audio: fileOrNull('amd-file') ? '<audio/video file data>' : undefined,
    stt_model: getAssignedModelId('aud-music-dub', 'speech_to_text'),
    tts_model: getAssignedModelId('aud-music-dub', 'text_to_speech'),
    source_lang: val('amd-slang') || undefined,
    target_lang: val('amd-tlang') || undefined,
    notes: val('amd-notes') || undefined,
  });
}

function buildStemPreviewData() {
  return previewExportBody(ROOT_PATH + '/v1/audio/stems', {
    audio: fileOrNull('ast-file') ? '<audio/video file data>' : undefined,
    stem_mode: val('ast-mode') || 'vocals-instrumental',
    response_format: 'url',
  });
}

function buildCleanupPreviewData() {
  return previewExportBody(ROOT_PATH + '/v1/audio/cleanup', {
    audio: fileOrNull('ac-file') ? '<audio/video file data>' : undefined,
    noise_reduction: chk('ac-noise'),
    normalize: chk('ac-level'),
    remove_hum: chk('ac-hum'),
    repair_clicks: chk('ac-click'),
    response_format: 'url',
  });
}

function buildDubPreviewData() {
  const preview = buildDubPreferencePreview();
  return previewExportBody(ROOT_PATH + '/v1/video/dub', {
    stt_model: getAssignedModelId('vid-dub', 'speech_to_text'),
    tts_model: getAssignedModelId('vid-dub', 'text_to_speech'),
    video: fileOrNull('vd-src') ? '<video file data>' : undefined,
    source_lang: preview.source_lang,
    target_lang: preview.target_lang,
    burn_subtitles: preview.burn_subtitles,
  });
}

function buildCodeSnippet(kind, preview) {
  const origin = window.location.hostname === '0.0.0.0'
    ? window.location.origin.replace('0.0.0.0', '127.0.0.1')
    : window.location.origin;
  const token = apiToken || 'YOUR_API_KEY';
  if (kind === 'curl') {
    return `curl -X POST ${origin}${preview.endpoint} \\
  -H "Content-Type: application/json" \\
  -H "Authorization: Bearer ${token}" \\
  -d '${preview.json.replace(/'/g, "'\\''")}'`;
  }
  if (kind === 'python') {
    return `import requests\n\npayload = ${preview.json}\nresponse = requests.post(\n    "${origin}${preview.endpoint}",\n    headers={"Authorization": "Bearer ${token}"},\n    json=payload,\n    timeout=300,\n)\nprint(response.json())`;
  }
  return `const payload = ${preview.json};\nconst response = await fetch("${origin}${preview.endpoint}", {\n  method: "POST",\n  headers: { "Content-Type": "application/json", "Authorization": "Bearer ${token}" },\n  body: JSON.stringify(payload),\n});\nconst data = await response.json();\nconsole.log(data);`;
}

function renderRequestPreview(panel, config) {
  const shell = $(config.containerId);
  if (!shell) return;
  const preview = config.build();
  const fields = config.fields.map(field => `
    <div class="req-preview-field">
      <div class="req-preview-label">${escapeHtml(field.label)}</div>
      <div class="req-preview-value">${escapeHtml(previewText(field.value(preview)))}</div>
    </div>
  `).join('');
  const snippet = buildCodeSnippet(config.snippet || 'curl', preview);
  shell.innerHTML = `
    <div class="req-preview-top">
      <div>
        <div class="req-preview-title">Request preview</div>
        <div class="req-preview-sub">Read-only export derived from current form values.</div>
      </div>
      <div class="req-preview-endpoint">POST ${escapeHtml(preview.endpoint)}</div>
    </div>
    <div class="req-preview-grid">${fields}</div>
    <div class="req-preview-actions">
      <button type="button" class="btn btn-ghost btn-sm" data-preview-kind="curl">cURL</button>
      <button type="button" class="btn btn-ghost btn-sm" data-preview-kind="python">Python</button>
      <button type="button" class="btn btn-ghost btn-sm" data-preview-kind="javascript">JavaScript</button>
      <button type="button" class="btn btn-ghost btn-sm" data-preview-copy>Copy</button>
    </div>
    <textarea class="fi req-preview-code" readonly>${escapeHtml(snippet)}</textarea>
    <div class="req-preview-status"></div>
  `;
  const codeEl = shell.querySelector('.req-preview-code');
  const statusEl = shell.querySelector('.req-preview-status');
  shell.querySelectorAll('[data-preview-kind]').forEach(btn => {
    btn.classList.toggle('btn-primary', btn.dataset.previewKind === (config.snippet || 'curl'));
    btn.classList.toggle('btn-ghost', btn.dataset.previewKind !== (config.snippet || 'curl'));
    btn.addEventListener('click', () => {
      config.snippet = btn.dataset.previewKind;
      renderRequestPreview(panel, config);
    });
  });
  shell.querySelector('[data-preview-copy]')?.addEventListener('click', async () => {
    try {
      await navigator.clipboard.writeText(codeEl.value);
      statusEl.textContent = 'Copied ✓';
    } catch (e) {
      statusEl.textContent = 'Copy failed';
    }
  });
}

const REQUEST_PREVIEW_CONFIG = {
  'aud-gen': {
    containerId:'ag-preview',
    snippet:'curl',
    build:buildAudioPreviewData,
    fields:[
      { label:'Model', value:preview => preview.body.model || 'none selected' },
      { label:'Prompt', value:preview => preview.body.prompt || 'empty' },
      { label:'Duration', value:preview => preview.body.duration },
      { label:'Temperature', value:preview => preview.body.temperature },
      { label:'Melody ref', value:() => previewFileName('ag-melody') },
      { label:'Seed', value:preview => preview.body.seed ?? 'random' },
    ],
  },
  'aud-tts': {
    containerId:'at-preview',
    snippet:'curl',
    build:buildTTSPreviewData,
    fields:[
      { label:'Model', value:preview => preview.body.model || 'none selected' },
      { label:'Voice', value:preview => preview.body.voice || 'default' },
      { label:'Speed', value:preview => preview.body.speed },
      { label:'Text', value:preview => preview.body.input || 'empty' },
    ],
  },
  'aud-stt': {
    containerId:'as-preview',
    snippet:'curl',
    build:buildSTTPreviewData,
    fields:[
      { label:'Model', value:preview => preview.body.model || 'none selected' },
      { label:'File', value:() => previewFileName('as-file') },
      { label:'Language', value:preview => preview.body.language || 'auto' },
      { label:'Hint', value:preview => preview.body.prompt || 'none' },
    ],
  },
  'img-gen': {
    containerId:'ig-preview',
    snippet:'curl',
    build:buildImageGenPreviewData,
    fields:[
      { label:'Model', value:preview => preview.body.model || 'none selected' },
      { label:'Prompt', value:preview => preview.body.prompt || 'empty' },
      { label:'Size', value:preview => preview.body.size },
      { label:'Steps', value:preview => preview.body.steps },
      { label:'CFG', value:preview => preview.body.guidance_scale },
      { label:'Count', value:preview => preview.body.n },
      { label:'VAE', value:preview => preview.body.vae_model || '—' },
      { label:'LoRA', value:preview => preview.body.loras?.length ? preview.body.loras.map(l=>l.model.split('/').pop()).join(', ') : '—' },
    ],
  },
  'embed': {
    containerId:'em-preview',
    snippet:'curl',
    build:buildEmbeddingsPreviewData,
    fields:[
      { label:'Model', value:preview => preview.body.model || 'none selected' },
      { label:'Items', value:preview => Array.isArray(preview.body.input) ? preview.body.input.length : (preview.body.input ? 1 : 0) },
      { label:'Encoding', value:preview => preview.body.encoding_format },
      { label:'Dimensions', value:preview => preview.body.dimensions ?? 'full' },
    ],
  },
  'aud-understand': {
    containerId:'au-preview',
    snippet:'curl',
    build:buildAudioUnderstandPreviewData,
    fields:[
      { label:'Audio model', value:preview => preview.body.audio_model || 'none selected' },
      { label:'Reasoning model', value:preview => preview.body.text_model || 'transcript only' },
      { label:'File', value:() => previewFileName('au-file') },
      { label:'Goal', value:preview => preview.body.input || 'general understanding' },
    ],
  },
  'aud-music-dub': {
    containerId:'amd-preview',
    snippet:'curl',
    build:buildMusicDubPreviewData,
    fields:[
      { label:'STT model', value:preview => preview.body.stt_model || 'none selected' },
      { label:'TTS model', value:preview => preview.body.tts_model || 'none selected' },
      { label:'File', value:() => previewFileName('amd-file') },
      { label:'Source lang', value:preview => preview.body.source_lang || 'auto' },
      { label:'Target lang', value:preview => preview.body.target_lang || 'none' },
    ],
  },
  'vid-dub': {
    containerId:'vd-preview',
    snippet:'curl',
    build:buildDubPreviewData,
    fields:[
      { label:'STT model', value:preview => preview.body.stt_model || 'none selected' },
      { label:'TTS model', value:preview => preview.body.tts_model || 'none selected' },
      { label:'Video file', value:() => previewFileName('vd-src') },
      { label:'Source lang', value:preview => preview.body.source_lang || 'auto' },
      { label:'Target lang', value:preview => preview.body.target_lang || 'required at run time' },
      { label:'Burn subtitles', value:preview => preview.body.burn_subtitles },
      { label:'Track mode', value:() => val('vd-track-mode') || 'speech-only' },
    ],
  },
};

function updateRequestPreview(panel) {
  const config = REQUEST_PREVIEW_CONFIG[panel];
  if (!config) return;
  renderRequestPreview(panel, config);
}

function initRequestPreviews() {
  ['ag-prompt','ag-dur','ag-temp','ag-topk','ag-cfg','ag-melody','ag-seed','at-text','at-voice','at-speed','as-file','as-lang','as-prompt','ig-prompt','ig-neg','ig-w','ig-h','ig-steps','ig-cfg','ig-seed','ig-n','ig-nosafe','em-text','em-enc','em-dims','au-file','au-goal','au-text-model','amd-file','amd-slang','amd-tlang','amd-notes','vd-src','vd-slang','vd-tlang','vd-track-mode','vd-burn','vd-preserve-voice','vd-preserve-tone','vd-preserve-timing','vd-preserve-singing','vd-lyrics']
    .forEach(id => {
      const el = $(id);
      if (!el) return;
      el.addEventListener('input', () => {
        if (id.startsWith('ag-')) updateRequestPreview('aud-gen');
        if (id.startsWith('at-')) updateRequestPreview('aud-tts');
        if (id.startsWith('as-')) updateRequestPreview('aud-stt');
        if (id.startsWith('ig-')) updateRequestPreview('img-gen');
        if (id.startsWith('em-')) updateRequestPreview('embed');
        if (id.startsWith('au-')) updateRequestPreview('aud-understand');
        if (id.startsWith('amd-')) updateRequestPreview('aud-music-dub');
        if (id.startsWith('ast-')) updateRequestPreview('aud-stems');
        if (id.startsWith('ac-')) updateRequestPreview('aud-clean');
        if (id.startsWith('vd-')) updateRequestPreview('vid-dub');
      });
      el.addEventListener('change', () => {
        if (id.startsWith('ag-')) updateRequestPreview('aud-gen');
        if (id.startsWith('at-')) updateRequestPreview('aud-tts');
        if (id.startsWith('as-')) updateRequestPreview('aud-stt');
        if (id.startsWith('ig-')) updateRequestPreview('img-gen');
        if (id.startsWith('em-')) updateRequestPreview('embed');
        if (id.startsWith('au-')) updateRequestPreview('aud-understand');
        if (id.startsWith('amd-')) updateRequestPreview('aud-music-dub');
        if (id.startsWith('ast-')) updateRequestPreview('aud-stems');
        if (id.startsWith('ac-')) updateRequestPreview('aud-clean');
        if (id.startsWith('vd-')) updateRequestPreview('vid-dub');
      });
    });
  updateRequestPreview('aud-gen');
  updateRequestPreview('aud-tts');
  updateRequestPreview('aud-stt');
  updateRequestPreview('img-gen');
  updateRequestPreview('embed');
  updateRequestPreview('aud-understand');
  updateRequestPreview('aud-music-dub');
  updateRequestPreview('aud-stems');
  updateRequestPreview('aud-clean');
  updateRequestPreview('vid-dub');
  renderDiagnostics();
  renderArtifactHistory();
}

// ─────────────────────────────────────────────────────────────────
//  Boot
// ─────────────────────────────────────────────────────────────────
function deduplicateModels(raw) {
  const TYPE_PREFIXES = /^(audio|tts|image|vision|video|audio_gen|embedding):/;

  // Group entries sharing the same alias → keep only the entry whose id === alias
  const aliasGroups = {};
  const noAlias = [];
  for (const m of raw) {
    if (m.alias) (aliasGroups[m.alias] = aliasGroups[m.alias] || []).push(m);
    else noAlias.push(m);
  }
  const result = [];
  for (const alias in aliasGroups) {
    const g = aliasGroups[alias];
    result.push(g.find(m => m.id === alias) || g[0]);
  }
  // For alias-less entries, skip bare basenames that are short forms of a full path
  const fullIds = new Set(noAlias.map(m => m.id));
  for (const m of noAlias) {
    if (m.id.includes('/')) {
      result.push(m);
    } else if (![...fullIds].some(id => id.endsWith('/' + m.id))) {
      result.push(m);
    }
  }
  // Drop "type:modelname" entries when "modelname" already appears in the result
  const resultIds = new Set(result.map(m => m.id));
  return result.filter(m => {
    const match = m.id.match(TYPE_PREFIXES);
    return !match || !resultIds.has(m.id.slice(match[0].length));
  });
}

async function loadModels() {
  try {
    const d = await fetch(ROOT_PATH + '/v1/models').then(r => r.json());
    models = deduplicateModels(d.data || []);
    renderSidebar();
    if (models.length) selectModel(models[0]);
    pcPopulateModelSelect(); pePopulateModelSelect();
  } catch(e) {
    $('model-list').innerHTML = '<div class="muted small" style="padding:.5rem .6rem">Failed to load models</div>';
  }
}

async function loadLocalCapabilities() {
  try {
    const r = await fetch(ROOT_PATH + '/admin/api/cached-models');
    if (!r.ok) return;
    const d = await r.json();
    _localCapSet.clear();
    [...(d.hf||[]), ...(d.gguf||[])].forEach(m => {
      (m.capabilities||[]).forEach(cap => _localCapSet.add(cap));
    });
    // Re-render capability cards now that local data is available
    renderCapabilityCards();
  } catch(e) {}
}

const BADGE = {text:'mb-text',vision:'mb-vision',image:'mb-image',video:'mb-video',
               audio:'mb-audio',tts:'mb-tts',audio_gen:'mb-audiogen',embedding:'mb-embed',
               spatial:'mb-image'};
const BLABEL = {text:'LLM',vision:'VLM',image:'IMG',video:'VID',audio:'STT',
                tts:'TTS',audio_gen:'MUS',embedding:'EMB',spatial:'SPA'};

function renderSidebar() {
  const el = $('model-list');
  if (!models.length) { el.innerHTML='<div class="muted small" style="padding:.5rem .6rem">No models</div>'; return; }
  el.innerHTML = models.map(m => {
    const t = m.type || 'text';
    const caps = m.capabilities || [];
    const safe = JSON.stringify(m).replace(/"/g,'&quot;');
    
    // Show multimodal badge if model has multiple capabilities
    const capLabels = {
      text_generation:'T',image_generation:'I',image_to_text:'V',
      video_generation:'Vid',audio_generation:'A',speech_to_text:'STT',
      text_to_speech:'TTS',embeddings:'E'
    };
    const mainCaps = caps.filter(c=>capLabels[c]).slice(0,3);
    const capBadges = mainCaps.length > 1 
      ? `<span style="font-size:9px;color:var(--text-3);margin-left:.25rem">${mainCaps.map(c=>capLabels[c]).join('+')}</span>`
      : '';
    
    return `<div class="model-item" data-id="${m.id}" onclick="selectModel(${safe})">
      <span class="mbadge ${BADGE[t]||'mb-text'}">${BLABEL[t]||t}</span>
      <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px" title="${m.id}">${m.id.split('/').pop()}${capBadges}</span>
    </div>`;
  }).join('');
}

function selectModel(m) {
  activeModel = m;
  document.querySelectorAll('.model-item').forEach(el =>
    el.classList.toggle('active', el.dataset.id === m.id));
  chatHistory = []; attachedImage = null; updateAttachBar();
  $('chat-msgs').innerHTML = `<div class="chat-empty"><h3>${m.id.split('/').pop()}</h3><p>Start below</p></div>`;
  // Sync to currently active sub-tab's cap assignment
  const _activeSub = document.querySelector('.t2btn.active')?.dataset.sub;
  if (_activeSub) {
    if (subNeedsMultiModel(_activeSub)) {
      // Multi-cap sub: let the user choose which role to assign this model to
      showRolePicker(_activeSub, m);
    } else {
      const _cap = SUB_API_CAP[_activeSub];
      if (_cap) {
        if (!capModelAssignments[_activeSub]) capModelAssignments[_activeSub] = {};
        capModelAssignments[_activeSub][_cap] = m.id;
      }
    }
  }
  updateRequestPreview('aud-gen');
  updateRequestPreview('aud-tts');
  updateRequestPreview('aud-stt');
  updateRequestPreview('img-gen');
  updateRequestPreview('embed');
  updateRequestPreview('aud-understand');
  updateRequestPreview('aud-music-dub');
  updateRequestPreview('aud-stems');
  updateRequestPreview('aud-clean');
  updateRequestPreview('vid-dub');
  // Update chat input placeholder to hint at routing for non-text models
  const PLACEHOLDER = {
    image: 'Describe an image to generate…',
    tts: 'Type text to speak…',
    audio: 'Use Audio → Transcribe to upload audio',
    video: 'Describe a video to generate…',
    audio_gen: 'Describe music or audio to generate…',
    embedding: 'Use the Embeddings tab',
  };
  $('chat-in').placeholder = PLACEHOLDER[m.type] || 'Send a message…';
  updateTabs(m);
}

function updateTabs(m) {
  const allCaps = crossModelCaps();
  const allTypes = new Set(models.map(mdl => mdl.type || 'text'));
  const type = m.type || 'text';
  refreshAudioBackendHealth();
  const subStates = {};
  Object.entries(SUB_CAPABILITY_RULES).forEach(([sub, rule]) => {
    if (VIDEO_EXTRA_SUBS.includes(sub) && allTypes.has('video') && !rule.fallbackTypes) {
      rule = Object.assign({}, rule, { fallbackTypes:['video'] });
    }
    subStates[sub] = evaluateSubCapability(rule, allCaps, allTypes);
  });
  // Profile subs and self-contained backends require no model capability — always available
  ['prof-char', 'prof-env', 'prof-voice', 'img-faceswap', 'vid-faceswap', 'img-deblur', 'img-unpix'].forEach(sub => { subStates[sub] = 'available'; });
  updatePipelineBadges();
  const categoryStates = {};
  CATEGORY_TABS.forEach(cat => {
    categoryStates[cat] = evaluateCategoryState(cat, subStates, allCaps, type);
  });
  currentTabState = { categories:categoryStates, subs:subStates };

  document.querySelectorAll('.t1btn').forEach(btn => {
    setTabVisualState(btn, categoryStates[btn.dataset.cat] || 'unavailable');
  });
  document.querySelectorAll('.t2btn').forEach(btn => {
    setTabVisualState(btn, subStates[btn.dataset.sub] || 'unavailable');
  });
  $('attach-btn').style.display = capabilitySetForModel(m).has('image_to_text') ? '' : 'none';
  renderCapabilityCards();
  renderDiagnostics();
  renderOutputCapabilityNotes();
  const activeCatBtn = document.querySelector('.t1btn.active');
  const nextCat = activeCatBtn?.dataset.cat || 'chat';
  selectCat(nextCat);
}

function selectCat(cat) {
  document.querySelectorAll('.t1btn').forEach(b => b.classList.toggle('active', b.dataset.cat === cat));
  const hasL2 = ['image','video','audio','3d','profiles'].includes(cat);
  $('tabbar2').classList.toggle('visible', hasL2);
  const isChatLike = cat === 'chat' || cat === 'embed';
  document.querySelector('.sidebar')?.classList.toggle('hidden', !isChatLike);
  if (!hasL2) {
    clearSidebarHighlights();
    document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
    const panel = $('panel-' + cat);
    if (panel) panel.classList.add('active');
    if (cat === 'archive') loadArchive();
    return;
  }
  document.querySelectorAll('.t2btn').forEach(btn => {
    const belongsHere = SUB_CAT[btn.dataset.sub] === cat;
    btn.dataset.catVisible = belongsHere ? cat : '';
    btn.classList.toggle('state-hidden', !belongsHere);
  });
  if (cat === 'profiles') { profCharLoad(); profEnvLoad(); profVoiceLoad(); pcPopulateModelSelect(); pePopulateModelSelect(); }
  const activeSub = document.querySelector('.t2btn.active');
  const activeSubFits = activeSub && isSubVisibleForCategory(activeSub.dataset.sub, cat);
  const nextSub = activeSubFits ? activeSub.dataset.sub : getFirstVisibleSub(cat)?.dataset.sub;
  if (nextSub) selectSub(nextSub);
}

// Stores per-capability model assignments for multi-model subs: {sub: {cap: modelId}}
const capModelAssignments = {};

function modelsForCap(cap) {
  return models.filter(m => capabilitySetForModel(m).has(cap));
}

function modelsForSub(sub) {
  const rule = SUB_CAPABILITY_RULES[sub];
  if (!rule) return [];
  return models.map(m => {
    const caps = capabilitySetForModel(m);
    let state = evaluateSubCapability(rule, caps, m.type || 'text');
    if (state === 'unavailable' && rule.category === (m.type || 'text')) state = 'partial';
    return { model: m, state };
  }).filter(item => item.state !== 'unavailable');
}

function subNeedsMultiModel(sub) {
  const studioRule = STUDIO_CAPABILITIES[sub];
  if (!studioRule) return false;
  return (studioRule.requires || []).length > 1;
}

function renderCapRow(sub, cap, isOptional) {
  const capable = modelsForCap(cap);
  if (!capable.length) {
    if (isOptional) return '';
    return `<div class="cap-assign-row">
      <span class="cap-assign-label">${cap.replace(/_/g,' ')}</span>
      <span class="cap-chip dim">No model available</span>
    </div>`;
  }
  const assigned = capModelAssignments[sub]?.[cap];
  const chips = capable.map(m => {
    const isSelected = m.id === assigned;
    const label = escapeHtml(m.id.split('/').pop());
    const safe = JSON.stringify(m).replace(/"/g, '&quot;');
    return `<button class="cap-model-chip ok${isSelected ? ' active' : ''}" onclick="assignModelToCap('${sub}','${cap}',${safe})" title="${m.id}">${label}</button>`;
  }).join('');
  return `<div class="cap-assign-row${isOptional ? ' opt' : ''}">
    <span class="cap-assign-label">${cap.replace(/_/g,' ')}</span>
    <div class="cap-model-chips">${chips}</div>
  </div>`;
}

function renderMultiCapPicker(sub) {
  const studioRule = STUDIO_CAPABILITIES[sub];
  const required = studioRule?.requires || [];
  const optional  = studioRule?.optional  || [];
  const reqRows = required.map(c => renderCapRow(sub, c, false)).join('');
  const optRows = optional.map(c => renderCapRow(sub, c, true)).join('');
  const optSection = optRows
    ? `<div class="cap-assign-sep">Optional</div>${optRows}`
    : '';
  return `<div class="cap-model-picker multi">
    <span class="cap-model-picker-label">Models per role</span>
    <div class="cap-assign-rows">${reqRows}${optSection}</div>
  </div>`;
}

function renderSubModelPicker(sub) {
  if (subNeedsMultiModel(sub)) return renderMultiCapPicker(sub);
  const compatible = modelsForSub(sub);
  if (!compatible.length) return '';
  const cap = SUB_API_CAP[sub]
    || (STUDIO_CAPABILITIES[sub]?.requires || SUB_CAPABILITY_RULES[sub]?.requiresAny || [])[0];
  const selectedId = (cap ? capModelAssignments[sub]?.[cap] : null) || activeModel?.id;
  const chips = compatible.map(({ model, state }) => {
    const cls = state === 'available' ? 'ok' : 'warn';
    const isActive = model.id === selectedId;
    const label = escapeHtml(model.id.split('/').pop());
    const safe = JSON.stringify(model).replace(/"/g, '&quot;');
    return `<button class="cap-model-chip ${cls}${isActive ? ' active' : ''}" onclick="selectSubModel('${sub}',${safe})" title="${model.id}">${label}</button>`;
  }).join('');
  return `<div class="cap-model-picker"><span class="cap-model-picker-label">Models</span><div class="cap-model-chips">${chips}</div></div>`;
}

function assignModelToCap(sub, cap, model) {
  if (!capModelAssignments[sub]) capModelAssignments[sub] = {};
  capModelAssignments[sub][cap] = model.id;
  // Sync left-panel selection only for single-cap subs; multi-cap subs have
  // independent per-role assignments that shouldn't overwrite the global model.
  if (!subNeedsMultiModel(sub)) {
    activeModel = model;
    document.querySelectorAll('.model-item').forEach(el =>
      el.classList.toggle('active', el.dataset.id === model.id));
  }
  // Sync the select element in the panel (if rendered, avoids full re-render).
  if (cap) _mpSyncSelect(sub, cap, model.id);
  renderCapabilityCards();
  if (SUB_CAT[sub]) highlightSidebarForSub(sub);
}

// Returns the user-assigned model ID for a capability, or falls back to activeModel.
function getAssignedModelId(sub, cap) {
  return capModelAssignments[sub]?.[cap] || activeModel?.id || '';
}

// Returns the ModelInfo object for the best model to use for a single capability.
// Prefers models with load_mode:'load' (already resident), then smallest used_vram_gb.
function bestModelForCap(cap) {
  return modelsForCap(cap).slice().sort((a, b) => {
    const al = a.load_mode === 'load' ? 0 : 1;
    const bl = b.load_mode === 'load' ? 0 : 1;
    if (al !== bl) return al - bl;
    return (a.used_vram_gb || 99) - (b.used_vram_gb || 99);
  })[0] || null;
}

// Primary capability per sub-tab — used for single-cap model assignment and API calls.
const SUB_API_CAP = {
  'img-gen':      'image_generation',
  'img-edit':     'image_to_image',
  'img-inpaint':  'inpainting',
  'img-upscale':  'image_upscaling',
  'img-depth':    'depth_estimation',
  'img-seg':      'image_segmentation',
  'img-outfit':   'image_to_image',
  'vid-t2v':      'video_generation',
  'vid-i2v':      'image_to_video',
  'vid-v2v':      'video_to_video',
  'vid-ti2v':     'video_generation',
  'vid-interp':   'video_interpolation',
  'vid-sub':      'subtitle_generation',
  'vid-up':       'video_upscaling',
  'aud-gen':      'audio_generation',
  'aud-tts':      'text_to_speech',
  'aud-clone':    'text_to_speech',
  'aud-convert':  'speech_to_text',
  'aud-stt':      'speech_to_text',
  'aud-understand':'speech_to_text',
  'aud-music-dub':'speech_to_text',
  'embed':        'embeddings',
};

// Returns the model ID to use for a sub-tab operation.
// Prefers the user-assigned model, falls back to activeModel.
function modelForSub(sub) {
  const cap = SUB_API_CAP[sub];
  if (cap && capModelAssignments[sub]?.[cap]) return capModelAssignments[sub][cap];
  return activeModel?.id || '';
}

// Assigns a model to a single-cap sub without changing the global activeModel.
function selectSubModel(sub, model) {
  const cap = SUB_API_CAP[sub]
    || (STUDIO_CAPABILITIES[sub]?.requires || SUB_CAPABILITY_RULES[sub]?.requiresAny || [])[0]
    || '__default__';
  assignModelToCap(sub, cap, model);
}

// Auto-assigns the best VRAM-fitting model to each capability for any sub.
// Existing manual assignments are never overwritten.
function autoAssignModels(sub) {
  const studioRule = STUDIO_CAPABILITIES[sub];
  if (subNeedsMultiModel(sub)) {
    const caps = [...(studioRule?.requires || []), ...(studioRule?.optional || [])];
    caps.forEach(cap => {
      if (capModelAssignments[sub]?.[cap]) return;
      const best = bestModelForCap(cap);
      if (best) { if (!capModelAssignments[sub]) capModelAssignments[sub] = {}; capModelAssignments[sub][cap] = best.id; }
    });
  } else {
    const cap = SUB_API_CAP[sub];
    if (cap && !capModelAssignments[sub]?.[cap]) {
      const best = bestModelForCap(cap);
      if (best) { if (!capModelAssignments[sub]) capModelAssignments[sub] = {}; capModelAssignments[sub][cap] = best.id; }
    }
  }
}

// Auto-populates empty pipeline form inputs with the best available model for each role.
const PIPELINE_INPUT_CAP = {
  'pp1-imodel': 'image_generation',
  'pp1-vmodel': 'image_to_video',
  'pp2-model':  'speech_to_text',
  'pp3-amodel': 'text_to_speech',
};
function autoPopulatePipelineInputs() {
  Object.entries(PIPELINE_INPUT_CAP).forEach(([inputId, cap]) => {
    const el = $(inputId);
    if (!el || el.value) return; // don't overwrite user input
    const best = bestModelForCap(cap);
    if (best) el.value = best.id;
  });
}

function highlightSidebarForSub(sub) {
  if (subNeedsMultiModel(sub)) {
    // Highlight every model that covers any required capability
    const required = (STUDIO_CAPABILITIES[sub]?.requires || []);
    const capable = new Set();
    required.forEach(cap => modelsForCap(cap).forEach(m => capable.add(m.id)));
    document.querySelectorAll('.model-item').forEach(el => {
      el.classList.remove('cap-ok', 'cap-partial');
      if (capable.has(el.dataset.id)) el.classList.add('cap-ok');
    });
    return;
  }
  const compatible = new Map(modelsForSub(sub).map(({ model, state }) => [model.id, state]));
  document.querySelectorAll('.model-item').forEach(el => {
    el.classList.remove('cap-ok', 'cap-partial');
    const state = compatible.get(el.dataset.id);
    if (state === 'available') el.classList.add('cap-ok');
    else if (state === 'partial') el.classList.add('cap-partial');
  });
}

function clearSidebarHighlights() {
  document.querySelectorAll('.model-item').forEach(el => el.classList.remove('cap-ok', 'cap-partial'));
}

function selectSub(sub) {
  if (SUB_CAT[sub] && currentTabState.subs[sub] === undefined) return;
  if (SUB_CAT[sub]) {
    const parentCat = SUB_CAT[sub];
    const belongsToActiveCat = document.querySelector(`.t1btn.active`)?.dataset.cat === parentCat;
    const btn = document.querySelector(`.t2btn[data-sub="${sub}"]`);
    if (!belongsToActiveCat || !btn || btn.classList.contains('state-hidden')) return;
  }
  document.querySelectorAll('.t2btn').forEach(b => b.classList.toggle('active', b.dataset.sub === sub));
  document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
  const panelId = SUB_PANEL_ALIAS[sub] || ('panel-' + sub);
  const panel = $(panelId);
  if (panel) panel.classList.add('active');
  renderCapabilityOutputNote(sub);
  // When switching to vid-faceswap, pre-select video mode
  if (sub === 'vid-faceswap') { const t = $('fs-type'); if (t) { t.value='video'; fsFaceSwapTypeChange(); } }
  if (sub === 'vid-outfit')   { const t = $('ot-type'); if (t) { t.value='video'; otOutfitTypeChange(); } }
  // Toggle shared cap-cards on faceswap and outfit panels
  if (sub === 'vid-faceswap' || sub === 'img-faceswap') {
    const isVid = sub === 'vid-faceswap';
    const ic = $('cap-img-faceswap'), vc = $('cap-vid-faceswap');
    if (ic) ic.style.display = isVid ? 'none' : '';
    if (vc) vc.style.display = isVid ? '' : 'none';
  }
  if (sub === 'vid-outfit' || sub === 'img-outfit') {
    const isVid = sub === 'vid-outfit';
    const ic = $('cap-img-outfit'), vc = $('cap-vid-outfit');
    if (ic) ic.style.display = isVid ? 'none' : '';
    if (vc) vc.style.display = isVid ? '' : 'none';
  }
  if (SUB_CAT[sub]) { autoAssignModels(sub); highlightSidebarForSub(sub); }
}

// ─────────────────────────────────────────────────────────────────
//  Chat
// ─────────────────────────────────────────────────────────────────
function addMsg(role, text, imgSrc) {
  const wrap = $('chat-msgs');
  wrap.querySelector('.chat-empty')?.remove();
  const t = new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
  const d = document.createElement('div');
  d.className = 'msg ' + role;
  const name = role === 'user' ? 'You' : (activeModel?.id?.split('/').pop() || 'AI');
  d.innerHTML = `<div class="av ${role==='user'?'user':'ai'}">${role==='user'?'YOU':'AI'}</div>
    <div class="msg-body">
      <div class="msg-meta">${name} · ${t}</div>
      ${imgSrc ? `<img src="${imgSrc}" class="msg-img" onclick="window.open(this.src)">` : ''}
      <div class="msg-text">${String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>')}</div>
    </div>`;
  wrap.appendChild(d);
  wrap.scrollTop = wrap.scrollHeight;
  return d;
}

function attachImg(input) {
  const f = input.files[0]; if (!f) return;
  const r = new FileReader();
  r.onload = e => {
    attachedImage = e.target.result;
    $('attach-thumb').src = attachedImage;
    $('attach-name').textContent = f.name;
    updateAttachBar();
  };
  r.readAsDataURL(f);
}
function clearAttach() { attachedImage = null; updateAttachBar(); }
function updateAttachBar() { $('attach-bar').style.display = attachedImage ? 'flex' : 'none'; }

async function sendChat() {
  if (chatBusy || !activeModel) return;
  const input = $('chat-in');
  const text = input.value.trim(); if (!text) return;

  // Route non-text models to the correct endpoint instead of /v1/chat/completions
  const mtype = activeModel.type || 'text';
  if (mtype === 'image') {
    input.value = '';
    // Switch to image-gen tab and pre-fill prompt
    $('ig-prompt').value = text;
    document.querySelector('.t1btn[data-cat="image"]')?.click();
    document.querySelector('.t2btn[data-sub="img-gen"]')?.click();
    await genImage();
    return;
  }
  if (mtype === 'tts') {
    input.value = '';
    $('at-text').value = text;
    document.querySelector('.t1btn[data-cat="audio"]')?.click();
    document.querySelector('.t2btn[data-sub="aud-tts"]')?.click();
    await genTTS();
    return;
  }
  if (mtype === 'audio') {
    addMsg('assistant', 'This is a speech-to-text model. Use the Audio → Transcribe tab to upload audio.');
    return;
  }
  if (mtype === 'video') {
    input.value = '';
    $('vt-prompt').value = text;
    document.querySelector('.t1btn[data-cat="video"]')?.click();
    document.querySelector('.t2btn[data-sub="vid-t2v"]')?.click();
    await genVideoT2V();
    return;
  }
  if (mtype === 'audio_gen') {
    input.value = '';
    $('ag-prompt').value = text;
    document.querySelector('.t1btn[data-cat="audio"]')?.click();
    document.querySelector('.t2btn[data-sub="aud-gen"]')?.click();
    await genAudio();
    return;
  }
  if (mtype === 'embedding') {
    addMsg('assistant', 'This is an embedding model. Use the Embeddings tab.');
    return;
  }

  addMsg('user', text, attachedImage);
  let content = text;
  if (attachedImage && (activeModel.capabilities||[]).includes('image_to_text')) {
    content = [{type:'image_url',image_url:{url:attachedImage}},{type:'text',text}];
  }
  chatHistory.push({role:'user',content});
  input.value = ''; input.style.height = 'auto';
  attachedImage = null; updateAttachBar();
  chatBusy = true; $('send-btn').disabled = true;
  $('typing').textContent = 'Thinking…';
  const _chatT0 = Date.now();
  try {
    const r = await fetch(ROOT_PATH + '/v1/chat/completions',{
      method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({model:activeModel.id,messages:chatHistory,stream:false})
    });
    if (!r.ok) throw new Error('HTTP '+r.status+': '+await r.text());
    const d = await r.json();
    const reply = d.choices[0].message.content;
    const elapsed = (Date.now() - _chatT0) / 1000;
    const toks = d.usage?.completion_tokens;
    if (toks && elapsed > 0) {
      $('typing').textContent = `${toks} tok · ${(toks/elapsed).toFixed(1)} tok/s`;
    }
    addMsg('assistant',reply);
    chatHistory.push({role:'assistant',content:reply});
  } catch(e) { addMsg('assistant','Error: '+e.message); }
  finally { chatBusy=false; $('send-btn').disabled=false; setTimeout(()=>{ $('typing').textContent=''; }, 3000); }
}

$('chat-in').addEventListener('keydown', e => { if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();} });
$('chat-in').addEventListener('input', function(){ this.style.height='auto'; this.style.height=Math.min(this.scrollHeight,140)+'px'; });

// ─────────────────────────────────────────────────────────────────
//  Utilities
// ─────────────────────────────────────────────────────────────────
function fileToB64(file) {
  return new Promise((res,rej) => {
    const r = new FileReader();
    r.onload = e => res(e.target.result);
    r.onerror = rej;
    r.readAsDataURL(file);
  });
}
function fileOrNull(id) { const f=$(id); return f&&f.files[0] ? f.files[0] : null; }
async function b64OrNull(id) { const f=fileOrNull(id); return f ? await fileToB64(f) : null; }

function showImg(outId, src, prog) {
  $(outId).innerHTML = `<div class="gen-out-inner">
    <img class="out-img" src="${src}" onclick="window.open(this.src)">
    <a href="${src}" download class="btn btn-ghost btn-sm dl">Download</a>
  </div>`;
  if(prog) $(prog).textContent='Done ✓';
}
function showVideo(outId, src, prog) {
  $(outId).innerHTML = `<div class="gen-out-inner">
    <video class="out-video" controls src="${src}"></video>
    <a href="${src}" download class="btn btn-ghost btn-sm dl">Download</a>
  </div>`;
  if(prog) $(prog).textContent='Done ✓';
}
function showAudio(outId, src, prog, ext) {
  $(outId).innerHTML = `<div class="gen-out-inner" style="width:100%">
    <audio class="out-audio" controls src="${src}"></audio>
    <a href="${src}" download="audio.${ext||'wav'}" class="btn btn-ghost btn-sm dl">Download</a>
  </div>`;
  if(prog) $(prog).textContent='Done ✓';
}
function imgSrc(d) { return d.url || (d.b64_json ? 'data:image/png;base64,'+d.b64_json : null); }
function vidSrc(d) { return d.url || (d.b64_mp4 ? 'data:video/mp4;base64,'+d.b64_mp4 : null); }
function audSrc(d) {
  if (d.url) return d.url;
  for (const k of Object.keys(d)) { if(k.startsWith('b64_')) return 'data:audio/'+k.slice(4)+';base64,'+d[k]; }
  return null;
}

async function post(path, body) {
  const r = await fetch(ROOT_PATH + path, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
  if (!r.ok) throw new Error(await r.text());
  return r.json();
}
async function postForm(path, fd) {
  const r = await fetch(ROOT_PATH + path, {method:'POST', body:fd});
  if (!r.ok) throw new Error(await r.text());
  return r.json();
}

// Character reference helpers
// ─────────────────────────────────────────────────────────────────
//  Character consistency — named multi-slot system
// ─────────────────────────────────────────────────────────────────
async function loadCharProfileList() {
  try {
    const d = await fetch(ROOT_PATH + '/v1/characters').then(r => r.json());
    _charProfiles = d.characters || [];
  } catch(e) { _charProfiles = []; }
}

function _profileOptions(selectedName) {
  const base = `<option value="">Load profile…</option>`;
  return base + _charProfiles.map(p =>
    `<option value="${p.name}" ${p.name===selectedName?'selected':''}>${p.name}</option>`
  ).join('');
}

function renderCharSlots(prefix) {
  const container = $(prefix+'-char-slots');
  if (!container) return;
  if (!charSlots[prefix]) charSlots[prefix] = [];
  if (!charSlots[prefix].length) { container.innerHTML = ''; return; }
  container.innerHTML = charSlots[prefix].map((slot, idx) => `
    <div class="char-slot" id="${prefix}-slot-${idx}">
      <div class="char-slot-header">
        <input type="text" class="fi" style="flex:1;font-size:12px" placeholder="Character name (e.g. Alice)"
          value="${slot.name||''}" onchange="charSlots['${prefix}'][${idx}].name=this.value">
        <button class="btn btn-ghost btn-sm" style="color:var(--red);padding:.1rem .35rem" onclick="removeCharSlot('${prefix}',${idx})">✕</button>
      </div>
      <div class="char-slot-actions">
        <input type="file" accept="image/*" multiple class="fi" style="flex:1;font-size:11px"
          onchange="addCharSlotImages('${prefix}',${idx},this)">
        <select class="fselect" style="font-size:11px;max-width:130px"
          onchange="if(this.value)loadCharProfileIntoSlot('${prefix}',${idx},this.value);this.value=''">
          ${_profileOptions('')}
        </select>
        <button class="btn btn-ghost btn-sm" style="font-size:11px" onclick="saveCharSlotAsProfile('${prefix}',${idx})">Save</button>
      </div>
      <div class="char-refs" id="${prefix}-slot-thumbs-${idx}">
        ${slot.images.map((src,i)=>`<img class="char-thumb" src="${src}" title="Click to remove" onclick="removeCharSlotImage('${prefix}',${idx},${i})">`).join('')}
      </div>
    </div>`).join('');
}

function addCharSlot(prefix) {
  if (!charSlots[prefix]) charSlots[prefix] = [];
  charSlots[prefix].push({name:'', images:[]});
  renderCharSlots(prefix);
}

function removeCharSlot(prefix, idx) {
  charSlots[prefix].splice(idx, 1);
  renderCharSlots(prefix);
}

function addCharSlotImages(prefix, idx, input) {
  if (!charSlots[prefix]?.[idx]) return;
  Array.from(input.files).forEach(f => {
    const reader = new FileReader();
    reader.onload = e => {
      charSlots[prefix][idx].images.push(e.target.result);
      renderCharSlots(prefix);
    };
    reader.readAsDataURL(f);
  });
}

function removeCharSlotImage(prefix, idx, imgIdx) {
  charSlots[prefix]?.[idx]?.images.splice(imgIdx, 1);
  renderCharSlots(prefix);
}

async function loadCharProfileIntoSlot(prefix, idx, name) {
  try {
    const d = await fetch(ROOT_PATH + `/v1/characters/${encodeURIComponent(name)}`).then(r => r.json());
    if (!charSlots[prefix]?.[idx]) return;
    charSlots[prefix][idx].name = charSlots[prefix][idx].name || d.name;
    charSlots[prefix][idx].images = (d.images||[]).map(img => img.data);
    renderCharSlots(prefix);
  } catch(e) { showAlert('Failed to load profile: '+e.message); }
}

async function saveCharSlotAsProfile(prefix, idx) {
  const slot = charSlots[prefix]?.[idx];
  if (!slot || !slot.images.length) { showAlert('Add at least one image first.'); return; }
  const name = slot.name || prompt('Profile name:');
  if (!name) return;
  try {
    await post('/v1/characters', {
      name,
      description: '',
      images: slot.images.map((data, i) => ({label:`ref${i}`, data})),
    });
    charSlots[prefix][idx].name = name;
    await loadCharProfileList();
    renderCharSlots(prefix);
    showAlert(`Saved profile "${name}"`);
  } catch(e) { showAlert('Save failed: '+e.message); }
}

// ─────────────────────────────────────────────────────────────────
//  Saved character profile multi-slot system (up to 6 per panel)
// ─────────────────────────────────────────────────────────────────
const MAX_CHAR_PROFILES = 6;

function _charProfileOpts() {
  return '<option value="">— select profile —</option>' +
    _charProfiles.map(p =>
      `<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)}${p.description ? ' — ' + escapeHtml(p.description) : ''}</option>`
    ).join('');
}

function addCharProfileSlot(prefix) {
  const container = $(`${prefix}-char-profile-slots`);
  if (!container) return;
  const rows = container.querySelectorAll('.char-prof-row');
  if (rows.length >= MAX_CHAR_PROFILES) return;
  const row = document.createElement('div');
  row.className = 'frow char-prof-row';
  row.style.cssText = 'gap:.3rem;margin:.1rem 0';
  row.innerHTML =
    `<select class="fi char-prof-sel" data-prefix="${prefix}" style="flex:1">${_charProfileOpts()}</select>` +
    `<button class="btn btn-ghost btn-sm" onclick="removeCharProfileSlot('${prefix}',this)" ` +
    `style="padding:.15rem .45rem;font-size:12px;flex-shrink:0">✕</button>`;
  container.appendChild(row);
  _updateAddCharBtn(prefix);
}

function removeCharProfileSlot(prefix, btn) {
  btn.closest('.char-prof-row')?.remove();
  _updateAddCharBtn(prefix);
}

function _updateAddCharBtn(prefix) {
  const container = $(`${prefix}-char-profile-slots`);
  const btn = $(`${prefix}-add-char-btn`);
  if (!container || !btn) return;
  btn.disabled = container.querySelectorAll('.char-prof-row').length >= MAX_CHAR_PROFILES;
}

function getCharProfilesList(prefix) {
  const container = $(`${prefix}-char-profile-slots`);
  if (!container) return [];
  return Array.from(container.querySelectorAll('.char-prof-sel'))
    .map(s => s.value).filter(Boolean);
}

function initCharProfileSlots() {
  ['ig','vt','vi','vv','ti'].forEach(prefix => {
    const container = $(`${prefix}-char-profile-slots`);
    if (container && container.querySelectorAll('.char-prof-row').length === 0) {
      addCharProfileSlot(prefix);
    }
  });
}

// ─────────────────────────────────────────────────────────────────
//  Environment profile multi-slot system (up to 6 per panel)
// ─────────────────────────────────────────────────────────────────
let _envProfiles = [];   // cached list from /v1/environments

function _envProfileOpts() {
  return '<option value="">— select environment —</option>' +
    _envProfiles.map(p =>
      `<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)}${p.description ? ' — ' + escapeHtml(p.description) : ''}</option>`
    ).join('');
}

function addEnvProfileSlot(prefix) {
  const container = $(`${prefix}-env-profile-slots`);
  if (!container) return;
  const rows = container.querySelectorAll('.env-prof-row');
  if (rows.length >= MAX_CHAR_PROFILES) return;
  const row = document.createElement('div');
  row.className = 'frow env-prof-row';
  row.style.cssText = 'gap:.3rem;margin:.1rem 0';
  row.innerHTML =
    `<select class="fi env-prof-sel" data-prefix="${prefix}" style="flex:1">${_envProfileOpts()}</select>` +
    `<button class="btn btn-ghost btn-sm" onclick="removeEnvProfileSlot('${prefix}',this)" ` +
    `style="padding:.15rem .45rem;font-size:12px;flex-shrink:0">✕</button>`;
  container.appendChild(row);
  _updateAddEnvBtn(prefix);
}

function removeEnvProfileSlot(prefix, btn) {
  btn.closest('.env-prof-row')?.remove();
  _updateAddEnvBtn(prefix);
}

function _updateAddEnvBtn(prefix) {
  const container = $(`${prefix}-env-profile-slots`);
  const btn = $(`${prefix}-add-env-btn`);
  if (!container || !btn) return;
  btn.disabled = container.querySelectorAll('.env-prof-row').length >= MAX_CHAR_PROFILES;
}

function getEnvProfilesList(prefix) {
  const container = $(`${prefix}-env-profile-slots`);
  if (!container) return [];
  return Array.from(container.querySelectorAll('.env-prof-sel'))
    .map(s => s.value).filter(Boolean);
}

function refreshEnvSelectors() {
  const opts = _envProfileOpts();
  document.querySelectorAll('.env-prof-sel').forEach(sel => {
    const cur = sel.value;
    sel.innerHTML = opts;
    if (cur) sel.value = cur;
  });
}

function initEnvProfileSlots() {
  ['ig','vt','vi','vv','ti'].forEach(prefix => {
    const container = $(`${prefix}-env-profile-slots`);
    if (container && container.querySelectorAll('.env-prof-row').length === 0) {
      addEnvProfileSlot(prefix);
    }
  });
}

function buildCharactersPayload(prefix) {
  const slots = charSlots[prefix] || [];
  const valid = slots.filter(s => s.images.length > 0);
  if (!valid.length) return undefined;
  return valid.map(s => ({name: s.name || undefined, images: s.images}));
}

// ─────────────────────────────────────────────────────────────────
//  Character Dialog
// ─────────────────────────────────────────────────────────────────
const MAX_DIALOG_LINES = 6;

function _dialogCharOpts() {
  return '<option value="">— No character —</option>' +
    (_charProfiles||[]).map(p=>`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)}</option>`).join('');
}

function _dialogVoiceOpts() {
  return '<option value="">— TTS voice ID below —</option>' +
    (_voiceProfiles||[]).map(v=>`<option value="${escapeHtml(v.name)}">${escapeHtml(v.name)}${v.description?' — '+escapeHtml(v.description):''}</option>`).join('');
}

function _dialogSectionHtml(prefix) {
  return `<details style="margin-top:.25rem">
  <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Character Dialog</summary>
  <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.5rem">
    <div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;font-size:12px">
      <label style="display:flex;align-items:center;gap:.3rem">Timing:
        <select id="${prefix}-dlg-timing" class="fselect" style="font-size:12px" onchange="refreshDialogTiming('${prefix}')">
          <option value="sequential">Sequential</option>
          <option value="manual">Manual</option>
        </select>
      </label>
      <label style="display:flex;align-items:center;gap:.3rem">Lip sync:
        <select id="${prefix}-dlg-method" class="fselect" style="font-size:12px">
          <option value="wav2lip">Wav2Lip</option>
          <option value="sadtalker">SadTalker</option>
          <option value="">Disabled</option>
        </select>
      </label>
    </div>
    <div id="${prefix}-dialog-slots" style="display:flex;flex-direction:column;gap:.4rem"></div>
    <button class="btn btn-ghost btn-sm" id="${prefix}-add-dlg-btn" onclick="addDialogLine('${prefix}')" style="align-self:flex-start;font-size:11px">+ Add line</button>
  </div>
</details>`;
}

function addDialogLine(prefix) {
  const container = document.getElementById(`${prefix}-dialog-slots`);
  if (!container) return;
  if (container.querySelectorAll('.dlg-row').length >= MAX_DIALOG_LINES) return;
  const row = document.createElement('div');
  row.className = 'dlg-row';
  row.style.cssText = 'background:var(--surface-2);border-radius:6px;padding:.5rem;display:flex;flex-direction:column;gap:.3rem';
  const timing = (document.getElementById(`${prefix}-dlg-timing`)||{}).value||'sequential';
  row.innerHTML = `
    <div style="display:flex;gap:.3rem;align-items:center">
      <select class="fi dlg-char" style="flex:1;font-size:12px" title="Character profile (for lip sync)">${_dialogCharOpts()}</select>
      <select class="fi dlg-voice" style="flex:1;font-size:12px" title="Voice profile">${_dialogVoiceOpts()}</select>
      <button class="btn btn-ghost btn-sm" onclick="removeDialogLine('${prefix}',this)" style="flex-shrink:0;padding:0 .4rem;font-size:13px" title="Remove line">✕</button>
    </div>
    <textarea class="fs dlg-text" rows="2" placeholder="Spoken text for this character…" style="font-size:12px;resize:vertical"></textarea>
    <div style="display:flex;gap:.8rem;align-items:center;flex-wrap:wrap;font-size:12px">
      <input class="fi dlg-voice-id" placeholder="TTS voice ID (if no profile)" style="flex:1;min-width:6rem;font-size:12px">
      <label style="display:flex;align-items:center;gap:.3rem;white-space:nowrap"><input type="checkbox" class="dlg-lipsync" checked> Lip sync</label>
      <label class="dlg-starttime-wrap" style="display:${timing==='manual'?'flex':'none'};align-items:center;gap:.3rem;white-space:nowrap">
        Start (s): <input type="number" class="fi dlg-starttime" style="width:5rem;font-size:12px" min="0" step="0.1" placeholder="auto">
      </label>
    </div>`;
  container.appendChild(row);
  const addBtn = document.getElementById(`${prefix}-add-dlg-btn`);
  if (addBtn) addBtn.disabled = container.querySelectorAll('.dlg-row').length >= MAX_DIALOG_LINES;
}

function removeDialogLine(prefix, btn) {
  const row = btn.closest('.dlg-row');
  if (row) row.remove();
  const addBtn = document.getElementById(`${prefix}-add-dlg-btn`);
  if (addBtn) addBtn.disabled = false;
}

function refreshDialogTiming(prefix) {
  const timing = (document.getElementById(`${prefix}-dlg-timing`)||{}).value||'sequential';
  const container = document.getElementById(`${prefix}-dialog-slots`);
  if (!container) return;
  container.querySelectorAll('.dlg-starttime-wrap').forEach(el => {
    el.style.display = timing === 'manual' ? 'flex' : 'none';
  });
}

function getDialogLines(prefix) {
  const container = document.getElementById(`${prefix}-dialog-slots`);
  if (!container) return [];
  const lines = [];
  container.querySelectorAll('.dlg-row').forEach(row => {
    const text = (row.querySelector('.dlg-text')||{}).value||'';
    if (!text.trim()) return;
    const charSel = row.querySelector('.dlg-char');
    const voiceSel = row.querySelector('.dlg-voice');
    const voiceId = (row.querySelector('.dlg-voice-id')||{}).value||'';
    const lipsync = (row.querySelector('.dlg-lipsync')||{}).checked !== false;
    const startEl = row.querySelector('.dlg-starttime');
    const startVal = startEl && startEl.value !== '' ? parseFloat(startEl.value) : null;
    lines.push({
      text,
      character: (charSel && charSel.value) || undefined,
      voice: (voiceSel && voiceSel.value) || voiceId || undefined,
      lip_sync: lipsync,
      start_time: startVal,
    });
  });
  return lines;
}

function refreshDialogSelects() {
  const charOpts = _dialogCharOpts();
  const voiceOpts = _dialogVoiceOpts();
  document.querySelectorAll('.dlg-char').forEach(sel => {
    const cur = sel.value; sel.innerHTML = charOpts; if (cur) sel.value = cur;
  });
  document.querySelectorAll('.dlg-voice').forEach(sel => {
    const cur = sel.value; sel.innerHTML = voiceOpts; if (cur) sel.value = cur;
  });
}

// ─────────────────────────────────────────────────────────────────
//  Image Generation
// ─────────────────────────────────────────────────────────────────
async function genImage() {
  if (!activeModel) return;
  // Cancel any previous orphaned poll timer
  if (_imgPollTimer) { clearInterval(_imgPollTimer); _imgPollTimer = null; }
  $('ig-prog').textContent='Generating…';
  const wrap=$('ig-pbar-wrap'), fill=$('ig-pbar-fill'), lbl=$('ig-pbar-label');
  wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent='';
  $('ig-prog').scrollIntoView({behavior:'smooth', block:'nearest'});
  _imgPollTimer = setInterval(async()=>{
    try{
      const p=await (await fetch(ROOT_PATH + '/v1/images/progress')).json();
      if(p.total>0){
        fill.style.width=p.pct+'%';
        const spd = p.it_per_s>0 ? ` · ${p.it_per_s} it/s` : (p.elapsed>0 ? ` · ${p.elapsed}s` : '');
        lbl.textContent=`${p.current} / ${p.total} steps${spd}`;
      }
      if(!p.active){ clearInterval(_imgPollTimer); _imgPollTimer=null; }
    }catch(_){}
  },400);
  try {
    const igCharProfiles = getCharProfilesList('ig');
    const igEnvProfiles = getEnvProfilesList('ig');
    const _igLoras = getLorasForSub('img-gen');
    const _igVae = getVaeForSub('img-gen');
    const d = await post('/v1/images/generations', {
      model:modelForSub('img-gen'), prompt:val('ig-prompt'),
      size:val('ig-w')+'x'+val('ig-h'),
      steps:ival('ig-steps'), guidance_scale:fval('ig-cfg'),
      n:ival('ig-n')||1,
      ...(val('ig-seed') ? {seed:ival('ig-seed')} : {}),
      ...(val('ig-neg') ? {negative_prompt:val('ig-neg')} : {}),
      disable_safety_checker: chk('ig-nosafe'),
      response_format:'url',
      ...(_igVae ? {vae_model:_igVae} : {}),
      ...(_igLoras.length ? {loras:_igLoras} : {}),
      ...(igCharProfiles.length ? {character_profiles:igCharProfiles, character_strength:fval('ig-char-str')||0.6} : {}),
      ...(igEnvProfiles.length ? {environment_profiles:igEnvProfiles, environment_strength:fval('ig-env-str')||0.6} : {}),
    });
    clearInterval(_imgPollTimer); _imgPollTimer=null;
    fill.style.width='100%'; lbl.textContent='Done';
    const imgs = d.data.map(imgSrc).filter(Boolean);
    $('ig-out').innerHTML = `<div class="gen-out-inner">${
      imgs.map(s=>`<img class="out-img" src="${s}" onclick="window.open(this.src)">`).join('')
    }<a href="${imgs[0]}" download class="btn btn-ghost btn-sm dl">Download first</a></div>`;
    pushArtifactHistory({
      task:'Image generate',
      family:'image generation',
      model:modelForSub('img-gen'),
      summary:buildImageHistorySummary(imgs),
      links:buildImageLinks(imgs),
    });
    $('ig-prog').textContent='Done ✓';
    setTimeout(()=>{ wrap.classList.remove('active'); },2000);
  } catch(e) {
    clearInterval(_imgPollTimer); _imgPollTimer=null;
    wrap.classList.remove('active');
    $('ig-prog').textContent='Error: '+e.message;
  }
}

// ─────────────────────────────────────────────────────────────────
//  Image Edit (i2i)
// ─────────────────────────────────────────────────────────────────
async function genEdit() {
  if (!activeModel) return;
  const img = await b64OrNull('ie-src');
  if (!img) { $('ie-prog').textContent='Select a source image.'; return; }
  $('ie-prog').textContent='Editing…';
  try {
    const d = await post('/v1/images/edits', {
      model:modelForSub('img-edit'), prompt:val('ie-prompt'), image:img,
      strength:fval('ie-str'), steps:ival('ie-steps'), guidance_scale:fval('ie-cfg'),
      ...(val('ie-seed') ? {seed:ival('ie-seed')} : {}), response_format:'url',
    });
    showImg('ie-out', imgSrc(d.data[0]), 'ie-prog');
  } catch(e) { $('ie-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Inpaint
// ─────────────────────────────────────────────────────────────────
async function genInpaint() {
  if (!activeModel) return;
  const [img, mask] = await Promise.all([b64OrNull('ip-src'), b64OrNull('ip-mask')]);
  if (!img||!mask) { $('ip-prog').textContent='Select source image and mask.'; return; }
  $('ip-prog').textContent='Inpainting…';
  try {
    const d = await post('/v1/images/inpaint', {
      model:modelForSub('img-inpaint'), prompt:val('ip-prompt'), image:img, mask,
      strength:fval('ip-str'), steps:ival('ip-steps'), guidance_scale:fval('ip-cfg'),
      ...(val('ip-seed') ? {seed:ival('ip-seed')} : {}), response_format:'url',
    });
    showImg('ip-out', imgSrc(d.data[0]), 'ip-prog');
  } catch(e) { $('ip-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Image Upscale
// ─────────────────────────────────────────────────────────────────
async function genImgUpscale() {
  if (!activeModel) return;
  const img = await b64OrNull('iu-src');
  if (!img) { $('iu-prog').textContent='Select an image.'; return; }
  $('iu-prog').textContent='Upscaling…';
  try {
    const d = await post('/v1/images/upscale', {model:modelForSub('img-upscale'), image:img, scale:ival('iu-scale'), response_format:'url'});
    showImg('iu-out', imgSrc(d.data[0]), 'iu-prog');
  } catch(e) { $('iu-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Depth
// ─────────────────────────────────────────────────────────────────
async function genDepth() {
  if (!activeModel) return;
  const img = await b64OrNull('id-src');
  if (!img) { $('id-prog').textContent='Select an image.'; return; }
  $('id-prog').textContent='Estimating depth…';
  try {
    const d = await post('/v1/images/depth', {model:modelForSub('img-depth'), image:img, response_format:'url'});
    showImg('id-out', imgSrc(d.data[0]), 'id-prog');
  } catch(e) { $('id-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Segment
// ─────────────────────────────────────────────────────────────────
async function genSegment() {
  if (!activeModel) return;
  const img = await b64OrNull('is-src');
  if (!img) { $('is-prog').textContent='Select an image.'; return; }
  $('is-prog').textContent='Segmenting…';
  const pts = val('is-pts') ? JSON.parse(val('is-pts')) : null;
  const boxes = val('is-boxes') ? JSON.parse(val('is-boxes')) : null;
  try {
    const d = await post('/v1/images/segment', {model:modelForSub('img-seg'), image:img, points:pts, boxes, response_format:'url'});
    showImg('is-out', imgSrc(d.data[0]), 'is-prog');
  } catch(e) { $('is-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Video Generation (t2v / i2v / v2v)
// ─────────────────────────────────────────────────────────────────
async function genVideo(mode) {
  if (!activeModel) return;
  const progMap   = {t2v:'vt-prog',  i2v:'vi-prog',  v2v:'vv-prog'};
  const outMap    = {t2v:'vt-out',   i2v:'vi-out',   v2v:'vv-out'};
  const prefixMap = {t2v:'vt',       i2v:'vi',        v2v:'vv'};
  const prog = progMap[mode], outId = outMap[mode], prefix = prefixMap[mode];
  $(prog).textContent='Generating… (this may take several minutes)';
  _startVidPoll(prefix);
  const subId = {t2v:'vid-t2v', i2v:'vid-i2v', v2v:'vid-v2v'}[mode];
  const body = {model:modelForSub(subId), mode};
  if (mode==='t2v') {
    body.prompt=val('vt-prompt'); body.negative_prompt=val('vt-neg');
    body.width=ival('vt-w')||512; body.height=ival('vt-h')||512;
    body.num_frames=ival('vt-frames')||16; body.fps=ival('vt-fps')||8;
    body.num_inference_steps=ival('vt-steps')||25; body.guidance_scale=fval('vt-cfg')||7.5;
    if (val('vt-seed')) body.seed=ival('vt-seed');
    if (val('vt-cam')) body.camera_motion=val('vt-cam');
    body.disable_safety_checker=chk('vt-nosafe');
    const vtProfiles = getCharProfilesList('vt');
    if (vtProfiles.length) { body.character_profiles=vtProfiles; body.character_strength=fval('vt-char-str')||0.8; }
    const vtEnvProfiles = getEnvProfilesList('vt');
    if (vtEnvProfiles.length) { body.environment_profiles=vtEnvProfiles; body.environment_strength=fval('vt-env-str')||0.6; }
    const vtDialogs = getDialogLines('vt');
    if (vtDialogs.length) { body.dialogs=vtDialogs; body.lip_sync_method=val('vt-dlg-method')||'wav2lip'; }
  } else if (mode==='i2v') {
    const img = await b64OrNull('vi-src');
    if (!img) { $(prog).textContent='Select a source image.'; return; }
    body.init_image=img; body.prompt=val('vi-prompt')||'animate this image';
    body.num_frames=ival('vi-frames')||16; body.fps=ival('vi-fps')||8;
    body.num_inference_steps=ival('vi-steps')||25; body.guidance_scale=fval('vi-cfg')||7.5;
    if (val('vi-seed')) body.seed=ival('vi-seed');
    if (val('vi-cam')) body.camera_motion=val('vi-cam');
    body.disable_safety_checker=chk('vt-nosafe');
    const viProfiles = getCharProfilesList('vi');
    if (viProfiles.length) { body.character_profiles=viProfiles; body.character_strength=fval('vi-cstr')||0.8; }
    const viChars = buildCharactersPayload('vi');
    if (viChars) { body.characters=viChars; body.character_strength=fval('vi-cstr')||0.8; }
    const viEnvProfiles = getEnvProfilesList('vi');
    if (viEnvProfiles.length) { body.environment_profiles=viEnvProfiles; body.environment_strength=fval('vi-estr')||0.6; }
    const viDialogs = getDialogLines('vi');
    if (viDialogs.length) { body.dialogs=viDialogs; body.lip_sync_method=val('vi-dlg-method')||'wav2lip'; }
  } else {
    const vid = await b64OrNull('vv-src');
    if (!vid) { $(prog).textContent='Select a source video.'; return; }
    body.video=vid; body.prompt=val('vv-prompt'); body.strength=fval('vv-str');
    body.num_inference_steps=ival('vv-steps')||25; body.guidance_scale=fval('vv-cfg')||7.5;
    if (val('vv-seed')) body.seed=ival('vv-seed');
    if (val('vv-cam')) body.camera_motion=val('vv-cam');
    body.disable_safety_checker=chk('vt-nosafe');
    const vvProfiles = getCharProfilesList('vv');
    if (vvProfiles.length) { body.character_profiles=vvProfiles; body.character_strength=fval('vv-cstr')||0.8; }
    const vvChars = buildCharactersPayload('vv');
    if (vvChars) { body.characters=vvChars; body.character_strength=fval('vv-cstr')||0.8; }
    const vvEnvProfiles = getEnvProfilesList('vv');
    if (vvEnvProfiles.length) { body.environment_profiles=vvEnvProfiles; body.environment_strength=fval('vv-estr')||0.6; }
    const vvDialogs = getDialogLines('vv');
    if (vvDialogs.length) { body.dialogs=vvDialogs; body.lip_sync_method=val('vv-dlg-method')||'wav2lip'; }
  }
  try {
    const d = await post('/v1/video/generations', body);
    _stopVidPoll(prefix, true);
    showVideo(outId, vidSrc(d.data[0]), prog);
  } catch(e) { _stopVidPoll(prefix, false); $(prog).textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Ti2V  (text + init image + end image)
// ─────────────────────────────────────────────────────────────────
async function genTi2V() {
  if (!activeModel) return;
  $('ti-prog').textContent='Generating… (may take several minutes)';
  const [initImg, endImg, srcVid] = await Promise.all([b64OrNull('ti-init'), b64OrNull('ti-end'), b64OrNull('ti-vid')]);
  if (!srcVid && !initImg) { $('ti-prog').textContent='Select an initial image or source video.'; return; }
  _startVidPoll('ti');

  const body = {
    model:modelForSub('vid-ti2v'),
    mode: srcVid ? 'v2v' : endImg ? 'interp' : (val('ti-prompt') ? 'ti2v' : 'i2v'),
    prompt:val('ti-prompt'), negative_prompt:val('ti-neg'),
    init_image:initImg||undefined, end_image:endImg||undefined,
    video:srcVid||undefined,
    ...(srcVid ? {strength:fval('ti-vstr')||0.7} : {}),
    width:ival('ti-w')||512, height:ival('ti-h')||512,
    num_frames:ival('ti-frames')||16, fps:ival('ti-fps')||8,
    num_inference_steps:ival('ti-steps')||25, guidance_scale:fval('ti-cfg')||7.5,
    camera_motion:val('ti-cam')||undefined,
    ...(val('ti-seed') ? {seed:ival('ti-seed')} : {}),
    // Character
    ...(getCharProfilesList('ti').length ? {character_profiles:getCharProfilesList('ti')} : {}),
    characters: buildCharactersPayload('ti'),
    character_strength:fval('ti-cstr')||0.8,
    // Environment
    ...(getEnvProfilesList('ti').length ? {environment_profiles:getEnvProfilesList('ti'), environment_strength:fval('ti-estr')||0.6} : {}),
    // Audio
    add_audio:!!val('ti-atype'),
    audio_type:val('ti-atype')||undefined,
    audio_prompt:val('ti-aprompt')||undefined,
    tts_text:val('ti-ttstext')||undefined,
    tts_voice:val('ti-voice')||undefined,
    tts_speed:fval('ti-speed')||1.0,
    lip_sync:chk('ti-lipsync'),
    // Character dialog
    ...(getDialogLines('ti').length ? {dialogs:getDialogLines('ti'), lip_sync_method:val('ti-dlg-method')||'wav2lip'} : {}),
    // Subtitles
    generate_subtitles:chk('ti-gensub'),
    burn_subtitles:chk('ti-burnsub'),
    translate_subtitles:chk('ti-transsub'),
    subtitle_target_lang:val('ti-sublang')||undefined,
    // Post
    upscale_output:chk('ti-upscale'),
    upscale_factor:ival('ti-upfact')||2,
    interpolate_output:chk('ti-interp'),
    fps_multiplier:ival('ti-fmult')||2,
    // Safety
    disable_safety_checker:chk('ti-nosafe'),
  };

  try {
    const d = await post('/v1/video/generations', body);
    _stopVidPoll('ti', true);
    showVideo('ti-out', vidSrc(d.data[0]), 'ti-prog');
  } catch(e) { _stopVidPoll('ti', false); $('ti-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Frame Interpolation
// ─────────────────────────────────────────────────────────────────
async function genInterp() {
  if (!activeModel) return;
  $('vc-prog').textContent='Interpolating…';
  const [vid, init, end] = await Promise.all([b64OrNull('vc-src'), b64OrNull('vc-init'), b64OrNull('vc-end')]);
  const body = {model:modelForSub('vid-interp'), fps_multiplier:ival('vc-mult')||2};
  if (vid) body.video=vid;
  else if (init && end) { body.init_image=init; body.end_image=end; }
  else { $('vc-prog').textContent='Provide a video or both frame images.'; return; }
  try {
    const d = await post('/v1/video/interpolate', body);
    showVideo('vc-out', vidSrc(d.data[0]), 'vc-prog');
  } catch(e) { $('vc-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Subtitles
// ─────────────────────────────────────────────────────────────────
async function genSubtitles() {
  if (!activeModel) return;
  const vid = await b64OrNull('vs-src');
  if (!vid) { $('vs-prog').textContent='Select a video.'; return; }
  $('vs-prog').textContent='Generating subtitles…';
  const burn = val('vs-fmt') === 'burned_video';
  const body = {
    model:modelForSub('vid-sub'), video:vid,
    language:val('vs-lang')||undefined,
    burn, format:val('vs-fmt'),
    translate:chk('vs-translate'),
    target_lang:val('vs-tlang')||undefined,
  };
  try {
    const d = await post('/v1/video/subtitle', body);
    const item = d.data[0];
    if (item.url || item.b64_mp4) showVideo('vs-out', vidSrc(item), 'vs-prog');
    else if (item.text) {
      $('vs-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left">
        <pre style="white-space:pre-wrap;font-size:12px;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${item.text}</pre>
        <button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(${JSON.stringify(item.text)})">Copy</button>
      </div>`;
      $('vs-prog').textContent='Done ✓';
    }
  } catch(e) { $('vs-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Dub
// ─────────────────────────────────────────────────────────────────
async function genDub() {
  if (!activeModel) return;
  const vid = await b64OrNull('vd-src');
  if (!vid) { $('vd-prog').textContent='Select a video.'; return; }
  const preview = buildDubPreferencePreview();
  $('vd-prog').textContent='Dubbing…';
  try {
    const d = await post('/v1/video/dub', {
      stt_model: getAssignedModelId('vid-dub', 'speech_to_text'),
      tts_model: getAssignedModelId('vid-dub', 'text_to_speech'),
      video:vid,
      source_lang:preview.source_lang, target_lang:preview.target_lang,
      burn_subtitles:preview.burn_subtitles,
    });
    if (d && typeof d === 'object') d._studioPreview = preview;
    const item = d.data[0];
    showVideo('vd-out', vidSrc(item), 'vd-prog');
    pushArtifactHistory({
      task:'Video dub',
      family:buildDubFamilyLabel(),
      model:getAssignedModelId('vid-dub', 'speech_to_text') || activeModel?.id,
      summary:buildDubHistorySummary(preview),
      links:buildDubLinks(item),
    });
  } catch(e) { $('vd-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Video upscale
// ─────────────────────────────────────────────────────────────────
async function genVidUpscale() {
  if (!activeModel) return;
  const vid = await b64OrNull('vu-src');
  if (!vid) { $('vu-prog').textContent='Select a video.'; return; }
  $('vu-prog').textContent='Upscaling…';
  try {
    const d = await post('/v1/video/upscale', {model:modelForSub('vid-up'), video:vid, upscale_factor:ival('vu-scale')||2});
    showVideo('vu-out', vidSrc(d.data[0]), 'vu-prog');
  } catch(e) { $('vu-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  2D ↔ 3D  &  3D model generation
// ─────────────────────────────────────────────────────────────────

async function _readFileB64(inputId) {
  const el = $(inputId);
  if (!el || !el.files || !el.files[0]) return null;
  return new Promise(resolve => {
    const fr = new FileReader();
    fr.onload = e => resolve(e.target.result.split(',')[1]);
    fr.readAsDataURL(el.files[0]);
  });
}

async function genImageTo3D() {
  const img = await b64OrNull('i3-src');
  if (!img) { $('i3-prog').textContent='Select a source image.'; return; }
  const method = val('i3-method') || 'stereo';
  $('i3-prog').textContent='Converting…';
  try {
    const d = await post('/v1/images/to3d', {
      image: img,
      method,
      max_shift: ival('i3-shift') || 20,
      response_format: 'url',
    });
    const item = d.data[0];
    if (method === 'mesh') {
      const url = item.url || ('#data:model/gltf-binary;base64,' + item.b64_glb);
      const link = `<a class="btn btn-ghost btn-sm dl" href="${item.url || ''}" download="model.glb">Download GLB</a>`;
      $('i3-out').innerHTML = `<div class="gen-empty" style="padding:1rem">${link}<p style="font-size:12px;color:var(--text-3);margin-top:.5rem">GLB file ready</p></div>`;
      $('i3-prog').textContent = 'Done ✓';
    } else {
      showImg('i3-out', imgSrc(item), 'i3-prog');
    }
  } catch(e) { $('i3-prog').textContent='Error: '+e.message; }
}

async function genImageFrom3D() {
  const raw = await _readFileB64('f3-src');
  if (!raw) { $('f3-prog').textContent='Select a 3D model file.'; return; }
  $('f3-prog').textContent='Rendering…';
  try {
    const d = await post('/v1/images/from3d', {
      model_data: raw,
      format: val('f3-fmt') || 'glb',
      camera_distance: fval('f3-dist') || 2.0,
      camera_elevation: fval('f3-elev') || 30,
      camera_azimuth: fval('f3-azim') || 45,
      width: ival('f3-w') || 512,
      height: ival('f3-h') || 512,
      response_format: 'url',
    });
    showImg('f3-out', imgSrc(d.data[0]), 'f3-prog');
  } catch(e) { $('f3-prog').textContent='Error: '+e.message; }
}

async function genVideoTo3D() {
  const vid = await b64OrNull('v3-src');
  if (!vid) { $('v3-prog').textContent='Select a source video.'; return; }
  $('v3-prog').textContent='Converting frames… (may take a while)';
  try {
    const d = await post('/v1/video/to3d', {
      video: vid,
      method: val('v3-method') || 'anaglyph',
      max_shift: ival('v3-shift') || 15,
      response_format: 'url',
    });
    showVideo('v3-out', vidSrc(d.data[0]), 'v3-prog');
  } catch(e) { $('v3-prog').textContent='Error: '+e.message; }
}

async function genVideoFrom3D() {
  const raw = await _readFileB64('vf3-src');
  if (!raw) { $('vf3-prog').textContent='Select a 3D model file.'; return; }
  $('vf3-prog').textContent='Rendering turntable…';
  try {
    const d = await post('/v1/video/from3d', {
      model_data: raw,
      format: val('vf3-fmt') || 'glb',
      frames: ival('vf3-frames') || 36,
      fps: ival('vf3-fps') || 12,
      camera_elevation: fval('vf3-elev') || 20,
      camera_distance: fval('vf3-dist') || 2.5,
      width: ival('vf3-w') || 512,
      height: ival('vf3-h') || 512,
      response_format: 'url',
    });
    showVideo('vf3-out', vidSrc(d.data[0]), 'vf3-prog');
  } catch(e) { $('vf3-prog').textContent='Error: '+e.message; }
}

async function gen3DModel() {
  const prompt = val('g3-prompt');
  const img = await b64OrNull('g3-img');
  if (!prompt && !img) { $('g3-prog').textContent='Enter a prompt or select an image.'; return; }
  $('g3-prog').textContent='Generating 3D model… (may take several minutes)';
  try {
    const body = { response_format: 'url' };
    if (prompt) body.prompt = prompt;
    if (img) body.image = img;
    if (ival('g3-steps')) body.steps = ival('g3-steps');
    if (val('g3-seed')) body.seed = ival('g3-seed');
    const d = await post('/v1/3d/generate', body);
    const item = d.data[0];
    const backend = d.backend || 'unknown';
    const dlUrl = item.url;
    const dlHref = dlUrl ? `<a class="btn btn-primary btn-sm dl" href="${dlUrl}" download="model.glb">Download GLB</a>` : '';
    $('g3-out').innerHTML = `<div style="text-align:center;padding:1.5rem;display:flex;flex-direction:column;align-items:center;gap:.6rem">
      <div style="font-size:13px;color:var(--text-2)">3D model generated (backend: <strong>${escapeHtml(backend)}</strong>)</div>
      ${dlHref}
      <div style="font-size:11px;color:var(--text-3)">Open the GLB in any 3D viewer (e.g. Blender, model-viewer, Windows 3D Viewer)</div>
    </div>`;
    $('g3-prog').textContent = 'Done ✓';
  } catch(e) { $('g3-prog').textContent='Error: '+e.message; }
}

// 3D tab aliases (same logic, different input IDs)
async function gen3DImgTo3D() {
  const img = await b64OrNull('t3i-src');
  if (!img) { $('t3i-prog').textContent='Select a source image.'; return; }
  const method = val('t3i-method') || 'stereo';
  $('t3i-prog').textContent='Converting…';
  try {
    const d = await post('/v1/images/to3d', {
      image: img, method,
      max_shift: ival('t3i-shift') || 20,
      response_format: 'url',
    });
    const item = d.data[0];
    if (method === 'mesh') {
      $('t3i-out').innerHTML = `<div class="gen-empty" style="padding:1rem"><a class="btn btn-primary btn-sm dl" href="${item.url || ''}" download="model.glb">Download GLB</a></div>`;
      $('t3i-prog').textContent = 'Done ✓';
    } else {
      showImg('t3i-out', imgSrc(item), 't3i-prog');
    }
  } catch(e) { $('t3i-prog').textContent='Error: '+e.message; }
}

async function gen3DVidTo3D() {
  const vid = await b64OrNull('t3v-src');
  if (!vid) { $('t3v-prog').textContent='Select a source video.'; return; }
  $('t3v-prog').textContent='Converting frames… (may take a while)';
  try {
    const d = await post('/v1/video/to3d', {
      video: vid,
      method: val('t3v-method') || 'anaglyph',
      max_shift: ival('t3v-shift') || 15,
      response_format: 'url',
    });
    showVideo('t3v-out', vidSrc(d.data[0]), 't3v-prog');
  } catch(e) { $('t3v-prog').textContent='Error: '+e.message; }
}

function r3TypeChange() {
  const isVideo = val('r3-type') === 'video';
  $('r3-img-opts').style.display = isVideo ? 'none' : '';
  $('r3-vid-opts').style.display = isVideo ? '' : 'none';
}

async function gen3DRender() {
  const raw = await _readFileB64('r3-src');
  if (!raw) { $('r3-prog').textContent='Select a 3D model file.'; return; }
  const isVideo = val('r3-type') === 'video';
  $('r3-prog').textContent = isVideo ? 'Rendering turntable…' : 'Rendering…';
  try {
    if (isVideo) {
      const d = await post('/v1/video/from3d', {
        model_data: raw,
        format: val('r3-fmt') || 'glb',
        frames: ival('r3-frames') || 36,
        fps: ival('r3-fps') || 12,
        camera_elevation: fval('r3-velev') || 20,
        camera_distance: fval('r3-vdist') || 2.5,
        width: ival('r3-w') || 512,
        height: ival('r3-h') || 512,
        response_format: 'url',
      });
      showVideo('r3-out', vidSrc(d.data[0]), 'r3-prog');
    } else {
      const d = await post('/v1/images/from3d', {
        model_data: raw,
        format: val('r3-fmt') || 'glb',
        camera_distance: fval('r3-dist') || 2.0,
        camera_elevation: fval('r3-elev') || 30,
        camera_azimuth: fval('r3-azim') || 45,
        width: ival('r3-w') || 512,
        height: ival('r3-h') || 512,
        response_format: 'url',
      });
      showImg('r3-out', imgSrc(d.data[0]), 'r3-prog');
    }
  } catch(e) { $('r3-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Webcam / Microphone capture utility
//  openWebcam(boxId, fileInputId, previewId, mode)
//  mode: 'photo' | 'audio'
// ─────────────────────────────────────────────────────────────────
const _webcamState = {};  // boxId → { stream, recorder, chunks }

async function openWebcam(boxId, fileInputId, previewId, mode) {
  const box = $(boxId);
  if (!box) return;
  // Toggle off if already open
  if (box.style.display !== 'none') {
    _stopWebcam(boxId);
    box.style.display = 'none';
    return;
  }
  box.style.display = 'flex';
  box.innerHTML = '<div style="color:var(--text-3);font-size:12px">Requesting permission…</div>';

  try {
    const constraints = mode === 'audio'
      ? { audio: true }
      : { video: { facingMode: 'user' }, audio: false };
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    _webcamState[boxId] = { stream, recorder: null, chunks: [] };

    if (mode === 'photo') {
      const video = document.createElement('video');
      video.srcObject = stream; video.autoplay = true; video.muted = true;
      video.style.cssText = 'width:100%;max-height:200px;border-radius:6px;background:#000';
      const canvas = document.createElement('canvas');
      canvas.style.display = 'none';
      const snapBtn = document.createElement('button');
      snapBtn.className = 'btn btn-primary btn-sm'; snapBtn.textContent = '📸 Capture';
      const closeBtn = document.createElement('button');
      closeBtn.className = 'btn btn-ghost btn-sm'; closeBtn.textContent = '✕ Close';
      const ctrl = document.createElement('div');
      ctrl.className = 'webcam-controls';
      ctrl.append(snapBtn, closeBtn);
      box.innerHTML = ''; box.append(video, canvas, ctrl);

      snapBtn.onclick = () => {
        canvas.width = video.videoWidth; canvas.height = video.videoHeight;
        canvas.getContext('2d').drawImage(video, 0, 0);
        canvas.toBlob(blob => {
          const file = new File([blob], 'webcam.png', { type: 'image/png' });
          _setFileInput(fileInputId, file);
          if (previewId) previewMediaInput(fileInputId, previewId);
          _stopWebcam(boxId);
          box.style.display = 'none';
        }, 'image/png');
      };
      closeBtn.onclick = () => { _stopWebcam(boxId); box.style.display = 'none'; };

    } else {
      // Audio recording
      const recorder = new MediaRecorder(stream);
      _webcamState[boxId].recorder = recorder;
      _webcamState[boxId].chunks = [];
      recorder.ondataavailable = e => { if (e.data.size) _webcamState[boxId].chunks.push(e.data); };
      recorder.onstop = () => {
        const blob = new Blob(_webcamState[boxId].chunks, { type: 'audio/webm' });
        const file = new File([blob], 'recording.webm', { type: 'audio/webm' });
        _setFileInput(fileInputId, file);
        _stopWebcam(boxId);
        box.style.display = 'none';
        // Show confirmation
        const fi = $(fileInputId);
        if (fi) fi.title = 'Recorded audio ready';
      };

      const recBtn = document.createElement('button');
      recBtn.className = 'btn btn-primary btn-sm'; recBtn.textContent = '⏺ Start Recording';
      const stopBtn = document.createElement('button');
      stopBtn.className = 'btn btn-ghost btn-sm'; stopBtn.textContent = '⏹ Stop & Use';
      stopBtn.disabled = true;
      const closeBtn = document.createElement('button');
      closeBtn.className = 'btn btn-ghost btn-sm'; closeBtn.textContent = '✕ Cancel';
      const timer = document.createElement('span');
      timer.style.cssText = 'font-size:12px;color:var(--text-3);min-width:3rem';
      const ctrl = document.createElement('div');
      ctrl.className = 'webcam-controls'; ctrl.append(recBtn, stopBtn, closeBtn, timer);
      box.innerHTML = ''; box.append(ctrl);

      let _timerInterval;
      recBtn.onclick = () => {
        recorder.start();
        recBtn.disabled = true; stopBtn.disabled = false;
        recBtn.textContent = '🔴 Recording…';
        let secs = 0;
        _timerInterval = setInterval(() => { secs++; timer.textContent = secs + 's'; }, 1000);
      };
      stopBtn.onclick = () => {
        clearInterval(_timerInterval);
        recorder.stop();
      };
      closeBtn.onclick = () => { clearInterval(_timerInterval); _stopWebcam(boxId); box.style.display = 'none'; };
    }
  } catch(e) {
    box.innerHTML = `<div style="color:var(--error);font-size:12px">Permission denied: ${e.message}</div>`;
  }
}

function _stopWebcam(boxId) {
  const state = _webcamState[boxId];
  if (!state) return;
  if (state.stream) state.stream.getTracks().forEach(t => t.stop());
  delete _webcamState[boxId];
}

function _setFileInput(fileInputId, file) {
  const fi = $(fileInputId);
  if (!fi) return;
  const dt = new DataTransfer();
  dt.items.add(file);
  fi.files = dt.files;
  fi.dispatchEvent(new Event('change'));
}

function previewMediaInput(fileInputId, previewId) {
  const fi = $(fileInputId), prev = $(previewId);
  if (!fi || !prev || !fi.files[0]) return;
  const f = fi.files[0];
  const url = URL.createObjectURL(f);
  if (f.type.startsWith('image/'))
    prev.innerHTML = `<img src="${url}" style="max-width:100%;max-height:120px;border-radius:6px">`;
  else if (f.type.startsWith('video/'))
    prev.innerHTML = `<video src="${url}" controls style="max-width:100%;max-height:120px;border-radius:6px"></video>`;
  else if (f.type.startsWith('audio/'))
    prev.innerHTML = `<audio src="${url}" controls style="width:100%"></audio>`;
}

// ─────────────────────────────────────────────────────────────────
//  Deblur
// ─────────────────────────────────────────────────────────────────
async function genDeblur() {
  const f = fileOrNull('db-src');
  if (!f) { $('db-prog').textContent='Select an image.'; return; }
  $('db-prog').textContent='Deblurring…';
  try {
    const d = await post('/v1/images/deblur', {image: await fileToB64(f), strength: fval('db-str')||0.5, response_format:'url'});
    showImg('db-out', imgSrc(d.data[0]), 'db-prog');
  } catch(e) { $('db-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Unpixelate
// ─────────────────────────────────────────────────────────────────
async function genUnpixelate() {
  const f = fileOrNull('up-src');
  if (!f) { $('up-prog').textContent='Select an image.'; return; }
  $('up-prog').textContent='Restoring…';
  try {
    const d = await post('/v1/images/unpixelate', {image: await fileToB64(f), scale: ival('up-scale')||4, response_format:'url'});
    showImg('up-out', imgSrc(d.data[0]), 'up-prog');
  } catch(e) { $('up-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Outfit Change
// ─────────────────────────────────────────────────────────────────
function fsFaceSwapTypeChange() {
  const isVideo = val('fs-type') === 'video';
  const tgtInput = $('fs-tgt');
  if (tgtInput) tgtInput.accept = isVideo ? 'video/*' : 'image/*,video/*';
  const camBtn = $('fs-tgt-cam-btn');
  if (camBtn) camBtn.style.display = isVideo ? 'none' : '';
  const wrap = $('fs-tgt-wrap');
  if (wrap) wrap.querySelector('.fl').textContent = isVideo ? 'Target video' : 'Target image';
}

function otOutfitTypeChange() {
  const isVideo = val('ot-type') === 'video';
  $('ot-img-wrap').style.display = isVideo ? 'none' : '';
  $('ot-vid-wrap').style.display = isVideo ? '' : 'none';
}

async function genOutfit() {
  if (!activeModel) return;
  const isVideo = val('ot-type') === 'video';
  if (!val('ot-prompt')) { $('ot-prog').textContent='Enter an outfit prompt.'; return; }
  $('ot-prog').textContent = isVideo ? 'Changing outfit on video…' : 'Changing outfit…';
  const body = {
    model: modelForSub('img-outfit'),
    prompt: val('ot-prompt'),
    steps: ival('ot-steps')||30,
    guidance_scale: fval('ot-cfg')||7.5,
    response_format: 'url',
  };
  if (val('ot-neg')) body.negative_prompt = val('ot-neg');
  if (val('ot-seed')) body.seed = ival('ot-seed');
  const maskFile = fileOrNull('ot-mask');
  if (maskFile) body.mask = await fileToB64(maskFile);
  if (isVideo) {
    const f = fileOrNull('ot-vid');
    if (!f) { $('ot-prog').textContent='Select a source video.'; return; }
    body.video = await fileToB64(f);
  } else {
    const f = fileOrNull('ot-src');
    if (!f) { $('ot-prog').textContent='Select a source image.'; return; }
    body.image = await fileToB64(f);
  }
  try {
    const d = await post('/v1/images/outfit', body);
    if (isVideo) showVideo('ot-out', vidSrc(d.data[0]), 'ot-prog');
    else showImg('ot-out', imgSrc(d.data[0]), 'ot-prog');
  } catch(e) { $('ot-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Face Swap
// ─────────────────────────────────────────────────────────────────
async function genFaceSwap() {
  const srcFile = fileOrNull('fs-src'), tgtFile = fileOrNull('fs-tgt');
  if (!srcFile || !tgtFile) { $('fs-prog').textContent='Select source face and target.'; return; }
  $('fs-prog').textContent='Swapping face…';
  const [src, tgt] = await Promise.all([fileToB64(srcFile), fileToB64(tgtFile)]);
  const ttype = val('fs-type') || 'image';
  try {
    const d = await post('/v1/images/faceswap', { source_face:src, target:tgt, target_type:ttype, response_format:'url' });
    const item = d.data[0];
    if (ttype === 'video') showVideo('fs-out', vidSrc(item) || 'data:video/mp4;base64,'+item.b64_mp4, 'fs-prog');
    else showImg('fs-out', imgSrc(item), 'fs-prog');
  } catch(e) { $('fs-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Pipeline 5: Face Swap  (server-side)
// ─────────────────────────────────────────────────────────────────
async function runPipeline5() {
  const srcFile = fileOrNull('pp5-src'), tgtFile = fileOrNull('pp5-tgt');
  if (!srcFile || !tgtFile) { $('pp5-prog').textContent='Select source face and target.'; return; }
  $('pp5-prog').textContent='Swapping face…';
  const [src, tgt] = await Promise.all([fileToB64(srcFile), fileToB64(tgtFile)]);
  const ttype = val('pp5-type') || 'image';
  try {
    const d = await post('/v1/images/faceswap', { source_face:src, target:tgt, target_type:ttype, response_format:'url' });
    const item = d.data[0];
    if (ttype === 'video') showVideo('pp5-out', vidSrc(item) || 'data:video/mp4;base64,'+item.b64_mp4, 'pp5-prog');
    else showImg('pp5-out', imgSrc(item), 'pp5-prog');
  } catch(e) { $('pp5-prog').textContent='Error: '+e.message; }
}
let _voiceProfiles = [];

async function loadVoiceProfiles() {
  try {
    const d = await fetch(ROOT_PATH + '/v1/audio/voices').then(r => r.json());
    _voiceProfiles = d.voices || [];
    ['vc-voice', 'pp4-voice', 'cv-voice', 'pvc-voice', 'psvc-voice'].forEach(id => {
      const sel = $(id); if (!sel) return;
      const cur = sel.value;
      sel.innerHTML = '<option value="">— Or upload reference audio below —</option>' +
        _voiceProfiles.map(v => `<option value="${v.name}">${v.name}${v.description ? ' — '+v.description : ''}</option>`).join('');
      if (cur) sel.value = cur;
    });
    refreshDialogSelects();
  } catch(e) {}
}

async function saveVoiceProfile() {
  const name = val('vc-savename'), transcript = val('vc-savetxt');
  const f = fileOrNull('vc-saveref');
  if (!name || !transcript || !f) { $('vc-saveprog').textContent='Fill in name, transcript and audio file.'; return; }
  $('vc-saveprog').textContent='Saving…';
  const fd = new FormData();
  fd.append('name', name); fd.append('transcript', transcript);
  fd.append('description', val('vc-savedesc'));
  fd.append('audio', f);
  try {
    const r = await fetch(ROOT_PATH + '/v1/audio/voices', {method:'POST', body:fd});
    if (!r.ok) throw new Error(await r.text());
    $('vc-saveprog').textContent='Saved ✓';
    await loadVoiceProfiles();
  } catch(e) { $('vc-saveprog').textContent='Error: '+e.message; }
}

async function genVoiceClone() {
  const text = val('vc-text');
  if (!text) { $('vc-prog').textContent='Enter text to synthesize.'; return; }
  const voiceName = val('vc-voice');
  const refFile = fileOrNull('vc-ref');
  const refTxt = val('vc-reftxt');
  if (!voiceName && (!refFile || !refTxt)) {
    $('vc-prog').textContent='Select a voice profile or upload reference audio + transcript.'; return;
  }
  $('vc-prog').textContent='Cloning voice…';
  const body = {
    text,
    speed: fval('vc-speed') || 1.0,
    ...(val('vc-seed') ? {seed: ival('vc-seed')} : {}),
    response_format: 'url',
  };
  if (voiceName) {
    body.voice_name = voiceName;
  } else {
    body.ref_audio = await fileToB64(refFile);
    body.ref_text = refTxt;
  }
  try {
    const d = await post('/v1/audio/clone', body);
    const src = audSrc(d.data[0]);
    showAudio('vc-out', src, 'vc-prog', 'wav');
  } catch(e) { $('vc-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Voice Convert (SVC — preserves pitch/melody)
// ─────────────────────────────────────────────────────────────────
async function genVoiceConvert() {
  const srcFile = fileOrNull('cv-src');
  if (!srcFile) { $('cv-prog').textContent='Select source audio.'; return; }
  const voiceName = val('cv-voice');
  const refFile = fileOrNull('cv-ref');
  if (!voiceName && !refFile) { $('cv-prog').textContent='Select a voice profile or upload reference audio.'; return; }
  const singing = val('cv-mode') === 'singing';
  $('cv-prog').textContent = singing ? 'Converting (singing mode)…' : 'Converting…';
  const src = await fileToB64(srcFile);
  const body = {
    source_audio: src,
    f0_condition: singing,
    pitch_shift: ival('cv-pitch') || 0,
    diffusion_steps: ival('cv-steps') || 10,
    response_format: 'url',
  };
  if (voiceName) body.voice_name = voiceName;
  else body.target_voice = await fileToB64(refFile);
  try {
    const d = await post('/v1/audio/convert', body);
    const src2 = audSrc(d.data[0]);
    showAudio('cv-out', src2, 'cv-prog', 'wav');
  } catch(e) { $('cv-prog').textContent='Error: '+e.message; }
}

// Also populate cv-voice dropdown when voice profiles load

// ─────────────────────────────────────────────────────────────────
//  Pipeline VC: Voice Clone
// ─────────────────────────────────────────────────────────────────
async function runPipelineVC() {
  const text = val('pvc-text');
  if (!text) { $('pvc-prog').textContent='Enter text to synthesize.'; return; }
  const voiceName = val('pvc-voice'), refFile = fileOrNull('pvc-ref'), refTxt = val('pvc-reftxt');
  if (!voiceName && (!refFile || !refTxt)) { $('pvc-prog').textContent='Select a voice profile or upload reference audio + transcript.'; return; }
  $('pvc-prog').textContent='Cloning voice…';
  const body = { text, speed: fval('pvc-speed')||1.0, response_format:'url' };
  if (val('pvc-seed')) body.seed = ival('pvc-seed');
  if (voiceName) body.voice_name = voiceName;
  else { body.ref_audio = await fileToB64(refFile); body.ref_text = refTxt; }
  try {
    const d = await post('/v1/audio/clone', body);
    showAudio('pvc-out', audSrc(d.data[0]), 'pvc-prog', 'wav');
  } catch(e) { $('pvc-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Pipeline SVC: Voice Convert
// ─────────────────────────────────────────────────────────────────
async function runPipelineSVC() {
  const srcFile = fileOrNull('psvc-src');
  if (!srcFile) { $('psvc-prog').textContent='Select source audio.'; return; }
  const voiceName = val('psvc-voice'), refFile = fileOrNull('psvc-ref');
  if (!voiceName && !refFile) { $('psvc-prog').textContent='Select a voice profile or upload reference audio.'; return; }
  const singing = val('psvc-mode') === 'singing';
  $('psvc-prog').textContent = singing ? 'Converting (singing mode)…' : 'Converting…';
  const body = {
    source_audio: await fileToB64(srcFile),
    f0_condition: singing,
    pitch_shift: ival('psvc-pitch')||0,
    diffusion_steps: ival('psvc-steps')||10,
    response_format: 'url',
  };
  if (voiceName) body.voice_name = voiceName;
  else body.target_voice = await fileToB64(refFile);
  try {
    const d = await post('/v1/audio/convert', body);
    showAudio('psvc-out', audSrc(d.data[0]), 'psvc-prog', 'wav');
  } catch(e) { $('psvc-prog').textContent='Error: '+e.message; }
}

async function runPipeline4() {
  const f = fileOrNull('pp4-src');
  if (!f) { $('pp4-prog').textContent='Select a source file.'; return; }
  const voiceName = val('pp4-voice');
  const refFile = fileOrNull('pp4-ref');
  const refTxt = val('pp4-reftxt');
  if (!voiceName && (!refFile || !refTxt)) {
    $('pp4-prog').textContent='Select a voice profile or upload reference audio + transcript.'; return;
  }
  $('pp4-prog').textContent='Running audio dub pipeline…';
  const isVideo = f.type.startsWith('video/');
  const b64 = await fileToB64(f);
  const body = {
    speed: fval('pp4-speed') || 1.0,
    source_lang: val('pp4-slang') || undefined,
    target_lang: val('pp4-tlang') || undefined,
    whisper_model: val('pp4-whisper') || undefined,
    burn_subtitles: chk('pp4-burn'),
    response_format: 'url',
  };
  if (isVideo) body.video = b64; else body.audio = b64;
  if (voiceName) {
    body.voice_name = voiceName;
  } else {
    body.ref_audio = await fileToB64(refFile);
    body.ref_text = refTxt;
  }
  try {
    const d = await post('/v1/pipelines/audio-dub', body);
    const item = d.data[0];
    if (item.b64_mp4 || item.url) showVideo('pp4-out', vidSrc(item) || 'data:video/mp4;base64,'+item.b64_mp4, 'pp4-prog');
    else if (item.b64_wav) showAudio('pp4-out', 'data:audio/wav;base64,'+item.b64_wav, 'pp4-prog', 'wav');
    else $('pp4-prog').textContent='Done ✓';
  } catch(e) { $('pp4-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Audio Gen
// ─────────────────────────────────────────────────────────────────
async function genAudio() {
  if (!activeModel) return;
  $('ag-prog').textContent='Generating audio…';
  _startAudPoll('ag');
  const melody = await b64OrNull('ag-melody');
  const body = {
    model:modelForSub('aud-gen'), prompt:val('ag-prompt'),
    duration:fval('ag-dur')||10, temperature:fval('ag-temp')||1.0,
    top_k:ival('ag-topk')||250, cfg_coef:fval('ag-cfg')||3.0,
    ...(val('ag-seed') ? {seed:ival('ag-seed')} : {}),
    ...(melody ? {melody} : {}),
    response_format:'url',
  };
  try {
    const d = await post('/v1/audio/generate', body);
    _stopAudPoll('ag', true);
    const item = d.data[0];
    const src = audSrc(item);
    showAudio('ag-out', src, 'ag-prog', 'wav');
    pushArtifactHistory({
      task:'Audio generate',
      family:'audio generation',
      model:modelForSub('aud-gen'),
      summary:buildAudioHistorySummary(val('ag-prompt'), body.duration),
      links:buildAudioLinks(item),
    });
  } catch(e) { _stopAudPoll('ag', false); $('ag-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  TTS
// ─────────────────────────────────────────────────────────────────
async function genTTS() {
  if (!activeModel) return;
  $('at-prog').textContent='Synthesizing…';
  try {
    const ttsVoiceProfile = val('tts-voice-profile');
    const r = await fetch(ROOT_PATH + '/v1/audio/speech', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body:JSON.stringify({model:modelForSub('aud-tts'), input:val('at-text'),
        speed:fval('at-speed')||1.0, voice:val('at-voice')||undefined, response_format:'mp3',
        ...(ttsVoiceProfile ? {voice_profile:ttsVoiceProfile} : {}),
      })
    });
    if (!r.ok) throw new Error(await r.text());
    const blob = await r.blob();
    const src = URL.createObjectURL(blob);
    showAudio('at-out', src, 'at-prog', 'mp3');
    pushArtifactHistory({
      task:'Text to speech',
      family:'tts',
      model:modelForSub('aud-tts'),
      summary:buildTTSHistorySummary(val('at-text'), val('at-voice')),
      links: src ? [{ label:'Open', href:src }] : [],
    });
  } catch(e) { $('at-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  STT
// ─────────────────────────────────────────────────────────────────
async function genSTT() {
  const sttModelId = modelForSub('aud-stt');
  if (!sttModelId) return;
  const f = fileOrNull('as-file');
  if (!f) { $('as-prog').textContent='Select an audio file.'; return; }
  $('as-prog').textContent='Transcribing…';
  const fd = new FormData();
  fd.append('file', f); fd.append('model', sttModelId);
  if (val('as-lang')) fd.append('language', val('as-lang'));
  if (val('as-prompt')) fd.append('prompt', val('as-prompt'));
  try {
    const d = await postForm('/v1/audio/transcriptions', fd);
    $('as-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left">
      <pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${d.text || JSON.stringify(d,null,2)}</pre>
      <button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(${JSON.stringify(d.text||'')})">Copy</button>
    </div>`;
    pushArtifactHistory({
      task:'Transcription',
      family:'speech to text',
      model:sttModelId,
      summary:buildSTTHistorySummary(d.text || JSON.stringify(d), f.name),
      links:[],
    });
    $('as-prog').textContent='Done ✓';
  } catch(e) { $('as-prog').textContent='Error: '+e.message; }
}

async function genStems() {
  const f = fileOrNull('ast-file');
  if (!f) { $('ast-prog').textContent='Select a source mix.'; return; }
  $('ast-prog').textContent='Separating stems…';
  try {
    const body = {
      audio: await fileToB64(f),
      stem_mode: val('ast-mode') || 'vocals-instrumental',
      response_format: 'url',
    };
    const d = await post('/v1/audio/stems', body);
    const items = d.data || [];
    $('ast-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left">${items.map(item => {
      const src = audSrc(item);
      return `<div style="width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-bottom:.6rem;box-sizing:border-box">
        <div style="font-size:12px;font-weight:600;margin-bottom:.35rem">${escapeHtml(item.name || 'stem')} · ${escapeHtml(item.role || 'artifact')}</div>
        <audio class="out-audio" controls src="${src}"></audio>
        <div style="font-size:11px;color:var(--text-3);margin-top:.35rem">${escapeHtml((d.limitations || []).join(' · '))}</div>
        <a href="${src}" download="${escapeHtml(item.name || 'stem')}.wav" class="btn btn-ghost btn-sm dl">Download</a>
      </div>`;
    }).join('')}</div>`;
    pushArtifactHistory({
      task:'Stem separation',
      family:'audio stems',
      model:d.backend?.engine || 'ffmpeg',
      summary:buildStemHistorySummary(d.stem_mode, items.length, d.backend),
      links:buildMultiArtifactLinks(items),
    });
    $('ast-prog').textContent=`Done ✓ ${d.backend?.quality || 'best-effort'}`;
  } catch(e) { $('ast-prog').textContent='Error: '+e.message; }
}

async function genAudioCleanup() {
  const f = fileOrNull('ac-file');
  if (!f) { $('ac-prog').textContent='Select a source recording.'; return; }
  $('ac-prog').textContent='Cleaning audio…';
  try {
    const body = {
      audio: await fileToB64(f),
      noise_reduction: chk('ac-noise'),
      normalize: chk('ac-level'),
      remove_hum: chk('ac-hum'),
      repair_clicks: chk('ac-click'),
      response_format: 'url',
    };
    const d = await post('/v1/audio/cleanup', body);
    const item = d.data?.[0];
    const src = item ? audSrc(item) : null;
    $('ac-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left">
      <audio class="out-audio" controls src="${src}"></audio>
      <div style="font-size:12px;color:var(--text-2);width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:.7rem;box-sizing:border-box">
        <strong>Applied:</strong> ${escapeHtml((d.applied || []).join(', ') || 'none')}<br>
        <strong>Backend:</strong> ${escapeHtml(d.backend?.engine || 'ffmpeg')} · ${escapeHtml(d.backend?.quality || 'best-effort')}<br>
        <strong>Limitations:</strong> ${escapeHtml((d.limitations || []).join(' · '))}
      </div>
      <a href="${src}" download="cleaned.wav" class="btn btn-ghost btn-sm dl">Download</a>
    </div>`;
    pushArtifactHistory({
      task:'Audio cleanup',
      family:'audio cleanup',
      model:d.backend?.engine || 'ffmpeg',
      summary:buildCleanupHistorySummary(d.applied, f.name, d.backend),
      links: src ? [{ label:'Open', href:src }] : [],
    });
    $('ac-prog').textContent=`Done ✓ ${d.backend?.quality || 'best-effort'}`;
  } catch(e) { $('ac-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Embeddings
// ─────────────────────────────────────────────────────────────────
async function genEmbeddings() {
  if (!activeModel) return;
  $('em-prog').textContent='Embedding…';
  const lines = val('em-text').split('\n').filter(l => l.trim());
  if (!lines.length) { $('em-prog').textContent='Enter some text.'; return; }
  const input = lines.length === 1 ? lines[0] : lines;
  try {
    const d = await post('/v1/embeddings', {
      model:modelForSub('embed'), input,
      encoding_format:val('em-enc'),
      ...(val('em-dims') ? {dimensions:ival('em-dims')} : {}),
    });
    const preview = d.data.map((e,i) => {
      const vec = Array.isArray(e.embedding)
        ? '[' + e.embedding.slice(0,8).map(v=>v.toFixed(4)).join(', ') + ', …] dim=' + e.embedding.length
        : e.embedding.substring(0,60)+'…';
      return `<div style="margin-bottom:.4rem"><strong>${lines[i]?.substring(0,40)||i}</strong><br><code style="font-size:11px">${vec}</code></div>`;
    }).join('');
    $('em-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left">${preview}</div>`;
    $('em-prog').textContent='Done ✓';
  } catch(e) { $('em-prog').textContent='Error: '+e.message; }
}

async function genAudioUnderstand() {
  if (!activeModel) return;
  const f = fileOrNull('au-file');
  if (!f) { $('au-prog').textContent='Select an audio or video file.'; return; }
  $('au-prog').textContent='Analyzing audio…';
  try {
    const d = await post('/v1/pipelines/audio-understand', {
      audio: await fileToB64(f),
      audio_model: modelForSub('aud-understand'),
      ...(val('au-text-model') ? {text_model: val('au-text-model')} : {}),
      ...(val('au-goal') ? {input: val('au-goal')} : {}),
    });
    $('au-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left;gap:.75rem">
      <div style="width:100%"><strong>Transcript</strong><pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${escapeHtml(d.transcript || 'No transcript returned.')}</pre></div>
      ${d.summary ? `<div style="width:100%"><strong>Summary</strong><pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${escapeHtml(d.summary)}</pre></div>` : ''}
    </div>`;
    $('au-prog').textContent = d.summary ? 'Done ✓ transcript + reasoning' : 'Done ✓ transcript only';
  } catch(e) { $('au-prog').textContent='Error: '+e.message; }
}

async function genMusicDubPlan() {
  if (!activeModel) return;
  const f = fileOrNull('amd-file');
  if (!f) { $('amd-prog').textContent='Select a source song or vocal mix.'; return; }
  $('amd-prog').textContent='Inspecting music dub path…';
  try {
    const d = await post('/v1/pipelines/audio-music-dub', {
      audio: await fileToB64(f),
      stt_model: getAssignedModelId('aud-music-dub', 'speech_to_text'),
      tts_model: getAssignedModelId('aud-music-dub', 'text_to_speech'),
      ...(val('amd-slang') ? {source_lang: val('amd-slang')} : {}),
      ...(val('amd-tlang') ? {target_lang: val('amd-tlang')} : {}),
      ...(val('amd-notes') ? {notes: val('amd-notes')} : {}),
    });
    $('amd-out').innerHTML = `<div class="gen-out-inner" style="width:100%;text-align:left;gap:.75rem">
      <div style="width:100%"><strong>Transcript</strong><pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${escapeHtml(d.transcript || 'No transcript returned.')}</pre></div>
      <div style="width:100%"><strong>Translated lyrics</strong><pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${escapeHtml(d.translated_lyrics || 'No translated lyrics returned.')}</pre></div>
      <div style="width:100%"><strong>Final mix</strong><pre style="white-space:pre-wrap;font-size:13px;line-height:1.6;background:var(--surface-2);padding:.75rem;border-radius:6px;width:100%;box-sizing:border-box">${escapeHtml(d.final_mix?.path || 'No final mix path returned.')}</pre></div>
    </div>`;
    pushArtifactHistory({
      task:'Music dub',
      family:'audio music dub',
      model:d.backend?.model || activeModel.id,
      summary:buildMusicDubHistorySummary(d.transcript, d.translated_lyrics, d.backend),
      links:d.final_mix?.path ? [{ label:'Final mix', href:d.final_mix.path }] : [],
    });
    $('amd-prog').textContent='Done ✓ full-stage pipeline';
  } catch(e) { $('amd-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Pipeline 1: Image → Video  (server-side)
// ─────────────────────────────────────────────────────────────────
async function runPipeline1() {
  const prompt=val('pp1-prompt'), imodel=val('pp1-imodel'), vmodel=val('pp1-vmodel');
  if (!prompt||!imodel||!vmodel) { $('pp1-prog').textContent='Fill in prompt + both model IDs.'; return; }
  $('pp1-prog').textContent='Running pipeline… (image → video)';
  const body = {
    prompt, image_model:imodel, video_model:vmodel,
    image_size: val('pp1-isize')||'1024x1024',
    num_frames:ival('pp1-frames')||16, fps:ival('pp1-fps')||8,
    num_inference_steps:ival('pp1-vsteps')||25, guidance_scale:fval('pp1-vcfg')||7.5,
    ...(val('pp1-neg')   ? {negative_prompt:val('pp1-neg')} : {}),
    ...(val('pp1-isteps')? {image_steps:ival('pp1-isteps')} : {}),
    ...(val('pp1-icfg')  ? {image_cfg:fval('pp1-icfg')} : {}),
    ...(val('pp1-iseed') ? {image_seed:ival('pp1-iseed')} : {}),
    ...(val('pp1-vseed') ? {video_seed:ival('pp1-vseed')} : {}),
    ...(val('pp1-cam')   ? {camera_motion:val('pp1-cam')} : {}),
    ...(val('pp1-atype') ? {add_audio:true, audio_type:val('pp1-atype'), audio_prompt:val('pp1-aprompt')} : {}),
    response_format:'url',
  };
  try {
    const d = await post('/v1/pipelines/image-to-video', body);
    const item = d.data[0];
    if (item.image_url) $('pp1-out').innerHTML=`<img class="out-img" src="${item.image_url}" style="max-width:200px;border-radius:6px">`;
    if (item.url) showVideo('pp1-out', item.url, 'pp1-prog');
    else $('pp1-prog').textContent='Done ✓';
  } catch(e) { $('pp1-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Pipeline 2: Video → Dub + Subtitle  (server-side)
// ─────────────────────────────────────────────────────────────────
async function runPipeline2() {
  const f = fileOrNull('pp2-src');
  if (!f) { $('pp2-prog').textContent='Select a video.'; return; }
  const tlang = val('pp2-tlang');
  if (!tlang) { $('pp2-prog').textContent='Enter a target language.'; return; }
  const model = val('pp2-model') || (activeModel ? activeModel.id : '');
  $('pp2-prog').textContent='Dubbing + subtitling…';
  try {
    const vid = await fileToB64(f);
    const d = await post('/v1/pipelines/video-dub', {
      model, video:vid,
      source_lang:val('pp2-slang')||undefined,
      target_lang:tlang,
      voice_clone:chk('pp2-clone'),
      burn_subtitles:chk('pp2-burn'),
    });
    showVideo('pp2-out', vidSrc(d.data[0]), 'pp2-prog');
  } catch(e) { $('pp2-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  Pipeline 3: Full Story  (server-side)
// ─────────────────────────────────────────────────────────────────
async function runPipeline3() {
  const story=val('pp3-story'), tmodel=val('pp3-tmodel'), imodel=val('pp3-imodel'), vmodel=val('pp3-vmodel');
  if (!story||!tmodel||!imodel||!vmodel) { $('pp3-prog').textContent='Fill in story and text/image/video model IDs.'; return; }
  $('pp3-prog').textContent='Running full story pipeline…';
  try {
    const d = await post('/v1/pipelines/story', {
      story, text_model:tmodel, image_model:imodel, video_model:vmodel,
      tts_model:val('pp3-amodel')||undefined,
      tts_voice:val('pp3-voice')||'af_sarah',
      num_scenes:ival('pp3-scenes')||3,
      num_frames:ival('pp3-frames')||16,
      response_format:'url',
    });
    const item = d.data[0];
    const imgs = (item.image_urls||[]).map(s=>`<img src="${s}" style="max-width:120px;border-radius:4px">`).join('');
    if (imgs) $('pp3-out').innerHTML=`<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.5rem">${imgs}</div>`;
    if (item.video_url) showVideo('pp3-out', item.video_url, 'pp3-prog');
    if (item.audio_url) {
      const ext = item.audio_url.includes('mp3') ? 'mp3' : 'wav';
      showAudio('pp3-out', item.audio_url, null, ext);
    }
    if (!item.video_url) $('pp3-prog').textContent='Done ✓';
  } catch(e) { $('pp3-prog').textContent='Error: '+e.message; }
}

// The T2V panel (vid-t2v) needs its own controls inlined since Jinja
// fragment includes aren't available. Add them now:
document.getElementById('panel-vid-t2v').innerHTML = `<div class="gen-wrap">
  <div class="gen-ctrl">
    <div class="cap-card" id="cap-vid-t2v" style="display:none"></div>
    <div class="frow"><label class="fl">Prompt</label><textarea id="vt-prompt" class="fs" rows="4" placeholder="Describe the video…"></textarea></div>
    <div class="frow"><label class="fl">Negative prompt</label><textarea id="vt-neg" class="fs" rows="2" placeholder="Things to avoid…"></textarea></div>
    <div class="g2">
      <div class="frow"><label class="fl">Width</label><input type="number" id="vt-w" class="fi" value="512" step="64"></div>
      <div class="frow"><label class="fl">Height</label><input type="number" id="vt-h" class="fi" value="512" step="64"></div>
    </div>
    <div class="g2">
      <div class="frow"><label class="fl">Frames</label><input type="number" id="vt-frames" class="fi" value="16"></div>
      <div class="frow"><label class="fl">FPS</label><input type="number" id="vt-fps" class="fi" value="8"></div>
    </div>
    <div class="g2">
      <div class="frow"><label class="fl">Steps</label><input type="number" id="vt-steps" class="fi" value="25"></div>
      <div class="frow"><label class="fl">Guidance</label><input type="number" id="vt-cfg" class="fi" value="7.5" step="0.5"></div>
    </div>
    <div class="frow"><label class="fl">Seed</label><input type="number" id="vt-seed" class="fi" placeholder="random"></div>
    <div class="frow"><label class="fl" title="Removes the model's built-in safety checker. Only use with uncensored/NSFW fine-tunes.">Disable safety filter</label><input type="checkbox" id="vt-nosafe"></div>
    <div class="frow"><label class="fl">Camera motion</label>
      <select id="vt-cam" class="fselect">
        <option value="">None</option>
        <option value="zoom-in">Zoom in</option><option value="zoom-out">Zoom out</option>
        <option value="pan-left">Pan left</option><option value="pan-right">Pan right</option>
        <option value="tilt-up">Tilt up</option><option value="tilt-down">Tilt down</option>
        <option value="rotate">Rotate</option>
      </select>
    </div>
    <div class="frow" style="align-items:flex-start">
      <label class="fl" style="padding-top:.3rem">Characters</label>
      <div style="flex:1;display:flex;flex-direction:column;gap:.3rem">
        <div id="vt-char-profile-slots"></div>
        <button class="btn btn-ghost btn-sm" id="vt-add-char-btn" onclick="addCharProfileSlot('vt')" style="align-self:flex-start;font-size:11px">+ Add character</button>
      </div>
    </div>
    <div class="frow"><label class="fl">Character strength <span id="vt-char-sv">0.80</span></label>
      <input type="range" id="vt-char-str" min="0" max="1" step="0.05" value="0.8" oninput="document.getElementById('vt-char-sv').textContent=(+this.value).toFixed(2)" style="width:100%">
    </div>
    <div class="frow" style="align-items:flex-start">
      <label class="fl" style="padding-top:.3rem">Environments</label>
      <div style="flex:1;display:flex;flex-direction:column;gap:.3rem">
        <div id="vt-env-profile-slots"></div>
        <button class="btn btn-ghost btn-sm" id="vt-add-env-btn" onclick="addEnvProfileSlot('vt')" style="align-self:flex-start;font-size:11px">+ Add environment</button>
      </div>
    </div>
    <div class="frow"><label class="fl">Environment strength <span id="vt-env-sv">0.60</span></label>
      <input type="range" id="vt-env-str" min="0" max="1" step="0.05" value="0.6" oninput="document.getElementById('vt-env-sv').textContent=(+this.value).toFixed(2)" style="width:100%">
    </div>
    <div id="vt-dialog-section"></div>
    <button class="btn btn-primary" onclick="genVideo('t2v')">Generate Video</button>
    <div class="progress" id="vt-prog"></div>
    <div class="gen-progress-wrap" id="vt-pbar-wrap">
      <div class="gen-progress-bar-bg"><div class="gen-progress-bar-fill" id="vt-pbar-fill"></div></div>
      <div class="gen-progress-label" id="vt-pbar-label"></div>
    </div>
  </div>
  <div class="gen-out" id="vt-out"><div class="gen-empty">Video will appear here</div></div>
</div>`;

// Inject dialog sections into all video panels
['vt','vi','vv','ti'].forEach(p => {
  const el = document.getElementById(p+'-dialog-section');
  if (el) el.innerHTML = _dialogSectionHtml(p);
});

// ─────────────────────────────────────────────────────────────────
//  Character Profile Manager
// ─────────────────────────────────────────────────────────────────
let _pcImagesB64 = [];   // images staged for create

function profCharShowCreate() {
  _pcImagesB64 = [];
  $('pc-name').value = ''; $('pc-desc').value = ''; $('pc-max').value = '5';
  $('pc-img-previews').innerHTML = '';
  $('pc-form-status').textContent = '';
  $('pc-gen-prompt').value = '';
  $('pc-gen-n').value = '3';
  $('pc-gen-steps').value = '';
  $('pc-gen-w').value = '512'; $('pc-gen-h').value = '512';
  document.querySelectorAll('input[name="pc-mode"]').forEach(r => r.checked = r.value === 'extract');
  pcModeChange();
  pcPopulateModelSelect();
}
function profCharHideForm() { profCharShowCreate(); }

function pcModeChange() {
  const mode = (document.querySelector('input[name="pc-mode"]:checked') || {}).value || 'extract';
  $('pc-extract-fields').style.display = mode === 'extract' ? '' : 'none';
  $('pc-generate-fields').style.display = mode === 'generate' ? '' : 'none';
  $('pc-submit-btn').innerHTML = mode === 'generate' ? 'Generate &amp; Save' : 'Extract &amp; Save';
}

function profCharSubmit() {
  const mode = (document.querySelector('input[name="pc-mode"]:checked') || {}).value || 'extract';
  if (mode === 'generate') profCharGenerate(); else profCharSave();
}

function pcPopulateModelSelect() {
  const sel = $('pc-gen-model'); if (!sel) return;
  const cur = sel.value;
  const opts = ['<option value="">— select model —</option>'];
  (models || []).forEach(m => {
    const caps = m.capabilities || [];
    if (caps.includes('image_generation')) {
      const lbl = m.id.split('/').pop() + (m.load_mode === 'load' ? ' ●' : '');
      opts.push(`<option value="${escapeHtml(m.id)}">${escapeHtml(lbl)}</option>`);
    }
  });
  sel.innerHTML = opts.join('');
  if (cur) sel.value = cur;
}

function pcImgFilesChanged() {
  _pcImagesB64 = [];
  $('pc-img-previews').innerHTML = '';
  const files = $('pc-img-files').files;
  for (const f of files) {
    const reader = new FileReader();
    reader.onload = e => {
      _pcImagesB64.push(e.target.result);
      const img = document.createElement('img');
      img.src = e.target.result;
      img.style.cssText = 'height:64px;border-radius:4px;object-fit:cover';
      $('pc-img-previews').appendChild(img);
    };
    reader.readAsDataURL(f);
  }
}

async function profCharSave() {
  const name = ($('pc-name').value||'').trim();
  if (!name) { $('pc-form-status').textContent = 'Name is required.'; return; }
  const hasImgs = _pcImagesB64.length > 0;
  const hasVids = $('pc-vid-files').files.length > 0;
  if (!hasImgs && !hasVids) { $('pc-form-status').textContent = 'Provide at least one image or video.'; return; }
  $('pc-form-status').textContent = 'Extracting…';
  try {
    const body = { name, description: $('pc-desc').value||'', max_images: parseInt($('pc-max').value)||5 };
    if (hasImgs) body.images = _pcImagesB64;
    if (hasVids) {
      body.videos = [];
      for (const f of $('pc-vid-files').files) {
        const b64 = await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=rej;r.readAsDataURL(f);});
        body.videos.push(b64);
      }
    }
    const r = await fetch(ROOT_PATH + '/v1/characters/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
    if (!r.ok) throw new Error((await r.json()).detail || await r.text());
    const d = await r.json();
    $('pc-form-status').textContent = `Saved "${d.name}" (${d.image_count} reference images) ✓`;
    await profCharLoad();
    setTimeout(profCharHideForm, 1200);
  } catch(e) { $('pc-form-status').textContent = 'Error: ' + e.message; }
}

async function profCharGenerate() {
  const name = ($('pc-name').value||'').trim();
  if (!name) { $('pc-form-status').textContent = 'Name is required.'; return; }
  const prompt = ($('pc-gen-prompt').value||'').trim();
  if (!prompt) { $('pc-form-status').textContent = 'A visual prompt is required.'; return; }
  $('pc-form-status').textContent = 'Generating reference images… (this may take a while)';
  try {
    const body = {
      name,
      description: $('pc-desc').value||'',
      prompt,
      n: parseInt($('pc-gen-n').value)||3,
      width: parseInt($('pc-gen-w').value)||512,
      height: parseInt($('pc-gen-h').value)||512,
    };
    const mdl = $('pc-gen-model').value;
    if (mdl) body.model = mdl;
    const steps = parseInt($('pc-gen-steps').value);
    if (steps > 0) body.steps = steps;
    const r = await fetch(ROOT_PATH + '/v1/characters/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
    if (!r.ok) throw new Error((await r.json()).detail || await r.text());
    const d = await r.json();
    $('pc-form-status').textContent = `Generated "${d.name}" (${d.image_count} reference images) ✓`;
    await profCharLoad();
    setTimeout(profCharHideForm, 1500);
  } catch(e) { $('pc-form-status').textContent = 'Error: ' + e.message; }
}

async function profCharLoad() {
  try {
    const d = await fetch(ROOT_PATH + '/admin/api/characters').then(r=>r.json());
    _charProfiles = d.characters || [];
    renderCharList();
    // Ensure every panel has at least one slot, then refresh options
    initCharProfileSlots();
    refreshCharSelectors();
  } catch(e) { _charProfiles = []; }
}

function refreshCharSelectors() {
  const opts = _charProfileOpts();
  document.querySelectorAll('.char-prof-sel').forEach(sel => {
    const cur = sel.value;
    sel.innerHTML = opts;
    if (cur) sel.value = cur;
  });
  refreshDialogSelects();
}

function renderCharList() {
  const el = $('prof-char-list');
  if (!el) return;
  if (!_charProfiles.length) { el.innerHTML = '<div class="arch-empty">No character profiles yet.</div>'; return; }
  el.innerHTML = _charProfiles.map(p => {
    const n = escapeHtml(p.name);
    const thumb = p.image_count > 0
      ? `<img class="arch-thumb" src="${ROOT_PATH}/admin/api/characters/${encodeURIComponent(p.name)}/thumbnail" loading="lazy" onerror="this.outerHTML='<div class=arch-thumb-ph>\u{1F464}</div>'" onclick="profCharView('${n}')">`
      : `<div class="arch-thumb-ph" onclick="profCharView('${n}')">\u{1F464}</div>`;
    const date = new Date(p.created_at * 1000).toLocaleDateString();
    return `
    <div class="arch-card">
      ${thumb}
      <div class="prof-card-info">
        <div class="prof-card-name">${n}</div>
        ${p.description ? `<div class="prof-card-desc">${escapeHtml(p.description)}</div>` : ''}
        <div class="prof-card-meta">${p.image_count} ref image${p.image_count!==1?'s':''} · ${date}</div>
        <div class="prof-card-actions">
          <button class="btn btn-ghost btn-sm" onclick="profCharView('${n}')">View</button>
          <button class="btn btn-danger btn-sm" onclick="profCharDelete('${n}')">Delete</button>
        </div>
      </div>
    </div>`;
  }).join('');
}

function _openLightbox(src) {
  const lb = document.createElement('div');
  lb.className = 'prof-lightbox';
  lb.innerHTML = `<img src="${escapeHtml(src)}">`;
  lb.addEventListener('click', () => lb.remove());
  document.body.appendChild(lb);
}

function _openProfModal(title, description, images) {
  const existing = document.getElementById('prof-view-modal');
  if (existing) existing.remove();
  const imgHtml = images.length
    ? images.map((img, i) => `<img src="${escapeHtml(img.data)}" title="${escapeHtml(img.label||`image ${i+1}`)}" data-src="${escapeHtml(img.data)}" onclick="_openLightbox(this.dataset.src)">`).join('')
    : `<div class="prof-modal-empty">No images stored.</div>`;
  const backdrop = document.createElement('div');
  backdrop.id = 'prof-view-modal';
  backdrop.className = 'prof-modal-backdrop';
  backdrop.innerHTML = `
    <div class="prof-modal">
      <div class="prof-modal-hd">
        <h3>${escapeHtml(title)}</h3>
        <button class="btn btn-ghost btn-sm" onclick="document.getElementById('prof-view-modal').remove()">✕ Close</button>
      </div>
      <div class="prof-modal-body">
        ${description ? `<div class="prof-modal-desc">${escapeHtml(description)}</div>` : ''}
        <div class="prof-modal-imgs">${imgHtml}</div>
      </div>
    </div>`;
  backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove(); });
  document.body.appendChild(backdrop);
}

async function profCharView(name) {
  try {
    const d = await fetch(ROOT_PATH + '/admin/api/characters/'+encodeURIComponent(name)).then(r=>r.json());
    _openProfModal(`Character: ${d.name}`, d.description||'', d.images||[]);
  } catch(e) { showAlert('Failed to load character: ' + e.message); }
}

async function profCharDelete(name) {
  if (!confirm(`Delete character profile "${name}"?`)) return;
  const r = await fetch(ROOT_PATH + '/admin/api/characters/'+encodeURIComponent(name), {method:'DELETE'});
  if (r.ok) await profCharLoad();
  else showAlert('Delete failed: ' + await r.text());
}


// ─────────────────────────────────────────────────────────────────
//  Voice Profile Manager
// ─────────────────────────────────────────────────────────────────

function profVoiceShowCreate() {
  $('pv-name').value=''; $('pv-desc').value=''; $('pv-transcript').value='';
  $('pv-form-status').textContent='';
}
function profVoiceHideForm() { profVoiceShowCreate(); }

async function profVoiceSave() {
  const name = ($('pv-name').value||'').trim();
  const f = $('pv-file').files[0];
  if (!name) { $('pv-form-status').textContent='Name is required.'; return; }
  if (!f) { $('pv-form-status').textContent='Select an audio or video file.'; return; }
  $('pv-form-status').textContent='Extracting voice profile…';
  try {
    const b64 = await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=rej;r.readAsDataURL(f);});
    const isVideo = f.type.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/i.test(f.name);
    const body = { name, description: $('pv-desc').value||'', transcript: $('pv-transcript').value||'' };
    if (isVideo) body.video = b64; else body.audio = b64;
    const r = await fetch(ROOT_PATH + '/v1/audio/voices/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
    if (!r.ok) throw new Error((await r.json()).detail || await r.text());
    const d = await r.json();
    const transcript = d.voice?.transcript;
    $('pv-form-status').textContent = `Saved "${d.voice?.name}" ✓` + (transcript ? ` — transcript: "${transcript.slice(0,60)}…"` : '');
    await profVoiceLoad();
    setTimeout(profVoiceHideForm, 1800);
  } catch(e) { $('pv-form-status').textContent='Error: '+e.message; }
}

async function profVoiceLoad() {
  try {
    const d = await fetch(ROOT_PATH + '/admin/api/voices').then(r=>r.json());
    const voices = d.voices || [];
    renderVoiceList(voices);
    // Refresh voice selectors (existing + new)
    ['vc-voice','pp4-voice','cv-voice','pvc-voice','psvc-voice','tts-voice-profile'].forEach(id => {
      const sel = $(id); if (!sel) return;
      const cur = sel.value;
      const placeholder = sel.dataset.placeholder || '— none —';
      sel.innerHTML = `<option value="">${placeholder}</option>` +
        voices.map(v=>`<option value="${escapeHtml(v.name)}">${escapeHtml(v.name)}${v.description?' — '+escapeHtml(v.description):''}</option>`).join('');
      if (cur) sel.value = cur;
    });
    _voiceProfiles = voices;
  } catch(e) {}
}

function renderVoiceList(voices) {
  const el = $('prof-voice-list');
  if (!el) return;
  if (!voices.length) { el.innerHTML = '<div class="arch-empty">No voice profiles yet.</div>'; return; }
  el.innerHTML = voices.map(v => {
    const n = escapeHtml(v.name);
    const date = new Date(v.created_at * 1000).toLocaleDateString();
    const quote = v.transcript ? `<div class="prof-voice-quote">&ldquo;${escapeHtml(v.transcript.slice(0,90))}${v.transcript.length>90?'&hellip;':''}&rdquo;</div>` : '';
    return `
    <div class="prof-voice-card">
      <div class="prof-voice-icon">&#127908;</div>
      <div class="prof-voice-info">
        <div class="prof-voice-name">${n}</div>
        ${v.description ? `<div class="prof-voice-meta">${escapeHtml(v.description)}</div>` : ''}
        ${quote}
        <div class="prof-voice-meta">${date}</div>
        <div class="prof-voice-actions">
          <button class="btn btn-danger btn-sm" onclick="profVoiceDelete('${n}')">Delete</button>
        </div>
      </div>
    </div>`;
  }).join('');
}

async function profVoiceDelete(name) {
  if (!confirm(`Delete voice profile "${name}"?`)) return;
  const r = await fetch(ROOT_PATH + '/admin/api/voices/'+encodeURIComponent(name), {method:'DELETE'});
  if (r.ok) await profVoiceLoad();
  else showAlert('Delete failed: ' + await r.text());
}

// ─────────────────────────────────────────────────────────────────
//  Environment Profile Manager
// ─────────────────────────────────────────────────────────────────
let _peImagesB64 = [];

function profEnvShowCreate() {
  _peImagesB64 = [];
  $('pe-name').value = ''; $('pe-desc').value = ''; $('pe-max').value = '5';
  $('pe-img-previews').innerHTML = '';
  $('pe-form-status').textContent = '';
  $('pe-gen-prompt').value = '';
  $('pe-gen-n').value = '3'; $('pe-gen-steps').value = '';
  $('pe-gen-w').value = '512'; $('pe-gen-h').value = '512';
  document.querySelectorAll('input[name="pe-mode"]').forEach(r => r.checked = r.value === 'extract');
  peModeChange();
  pePopulateModelSelect();
}
function profEnvHideForm() { profEnvShowCreate(); }

function peModeChange() {
  const mode = (document.querySelector('input[name="pe-mode"]:checked') || {}).value || 'extract';
  $('pe-extract-fields').style.display = mode === 'extract' ? '' : 'none';
  $('pe-generate-fields').style.display = mode === 'generate' ? '' : 'none';
  $('pe-submit-btn').innerHTML = mode === 'generate' ? 'Generate &amp; Save' : 'Extract &amp; Save';
}

function profEnvSubmit() {
  const mode = (document.querySelector('input[name="pe-mode"]:checked') || {}).value || 'extract';
  if (mode === 'generate') profEnvGenerate(); else profEnvSave();
}

function pePopulateModelSelect() {
  const sel = $('pe-gen-model'); if (!sel) return;
  const cur = sel.value;
  const opts = ['<option value="">— select model —</option>'];
  (models || []).forEach(m => {
    const caps = m.capabilities || [];
    if (caps.includes('image_generation')) {
      const lbl = m.id.split('/').pop() + (m.load_mode === 'load' ? ' ●' : '');
      opts.push(`<option value="${escapeHtml(m.id)}">${escapeHtml(lbl)}</option>`);
    }
  });
  sel.innerHTML = opts.join('');
  if (cur) sel.value = cur;
}

function peImgFilesChanged() {
  _peImagesB64 = [];
  $('pe-img-previews').innerHTML = '';
  for (const f of $('pe-img-files').files) {
    const reader = new FileReader();
    reader.onload = e => {
      _peImagesB64.push(e.target.result);
      const img = document.createElement('img');
      img.src = e.target.result;
      img.style.cssText = 'height:64px;border-radius:4px;object-fit:cover';
      $('pe-img-previews').appendChild(img);
    };
    reader.readAsDataURL(f);
  }
}

async function profEnvSave() {
  const name = ($('pe-name').value||'').trim();
  if (!name) { $('pe-form-status').textContent = 'Name is required.'; return; }
  const hasImgs = _peImagesB64.length > 0;
  const hasVids = $('pe-vid-files').files.length > 0;
  if (!hasImgs && !hasVids) { $('pe-form-status').textContent = 'Provide at least one image or video.'; return; }
  $('pe-form-status').textContent = 'Extracting…';
  try {
    const body = { name, description: $('pe-desc').value||'', max_images: parseInt($('pe-max').value)||5 };
    if (hasImgs) body.images = _peImagesB64;
    if (hasVids) {
      body.videos = [];
      for (const f of $('pe-vid-files').files) {
        const b64 = await new Promise((res,rej)=>{const r=new FileReader();r.onload=e=>res(e.target.result);r.onerror=rej;r.readAsDataURL(f);});
        body.videos.push(b64);
      }
    }
    const r = await fetch(ROOT_PATH + '/v1/environments/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
    if (!r.ok) throw new Error((await r.json()).detail || await r.text());
    const d = await r.json();
    $('pe-form-status').textContent = `Saved "${d.name}" (${d.image_count} reference images) ✓`;
    await profEnvLoad();
    setTimeout(profEnvHideForm, 1200);
  } catch(e) { $('pe-form-status').textContent = 'Error: ' + e.message; }
}

async function profEnvGenerate() {
  const name = ($('pe-name').value||'').trim();
  if (!name) { $('pe-form-status').textContent = 'Name is required.'; return; }
  const prompt = ($('pe-gen-prompt').value||'').trim();
  if (!prompt) { $('pe-form-status').textContent = 'A visual prompt is required.'; return; }
  $('pe-form-status').textContent = 'Generating reference images… (this may take a while)';
  try {
    const body = {
      name, description: $('pe-desc').value||'', prompt,
      n: parseInt($('pe-gen-n').value)||3,
      width: parseInt($('pe-gen-w').value)||512,
      height: parseInt($('pe-gen-h').value)||512,
    };
    const mdl = $('pe-gen-model').value;
    if (mdl) body.model = mdl;
    const steps = parseInt($('pe-gen-steps').value);
    if (steps > 0) body.steps = steps;
    const r = await fetch(ROOT_PATH + '/v1/environments/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
    if (!r.ok) throw new Error((await r.json()).detail || await r.text());
    const d = await r.json();
    $('pe-form-status').textContent = `Generated "${d.name}" (${d.image_count} reference images) ✓`;
    await profEnvLoad();
    setTimeout(profEnvHideForm, 1500);
  } catch(e) { $('pe-form-status').textContent = 'Error: ' + e.message; }
}

async function profEnvLoad() {
  try {
    const d = await fetch(ROOT_PATH + '/admin/api/environments').then(r=>r.json());
    _envProfiles = d.environments || [];
    renderEnvList();
    initEnvProfileSlots();
    refreshEnvSelectors();
  } catch(e) { _envProfiles = []; }
}

function renderEnvList() {
  const el = $('prof-env-list');
  if (!el) return;
  if (!_envProfiles.length) { el.innerHTML = '<div class="arch-empty">No environment profiles yet.</div>'; return; }
  el.innerHTML = _envProfiles.map(p => {
    const n = escapeHtml(p.name);
    const thumb = p.image_count > 0
      ? `<img class="arch-thumb" src="${ROOT_PATH}/admin/api/environments/${encodeURIComponent(p.name)}/thumbnail" loading="lazy" onerror="this.outerHTML='<div class=arch-thumb-ph>\u{1F304}</div>'" onclick="profEnvView('${n}')">`
      : `<div class="arch-thumb-ph" onclick="profEnvView('${n}')">\u{1F304}</div>`;
    const date = new Date(p.created_at * 1000).toLocaleDateString();
    return `
    <div class="arch-card">
      ${thumb}
      <div class="prof-card-info">
        <div class="prof-card-name">${n}</div>
        ${p.description ? `<div class="prof-card-desc">${escapeHtml(p.description)}</div>` : ''}
        <div class="prof-card-meta">${p.image_count} ref image${p.image_count!==1?'s':''} · ${date}</div>
        <div class="prof-card-actions">
          <button class="btn btn-ghost btn-sm" onclick="profEnvView('${n}')">View</button>
          <button class="btn btn-danger btn-sm" onclick="profEnvDelete('${n}')">Delete</button>
        </div>
      </div>
    </div>`;
  }).join('');
}

async function profEnvView(name) {
  try {
    const d = await fetch(ROOT_PATH + '/admin/api/environments/'+encodeURIComponent(name)).then(r=>r.json());
    _openProfModal(`Environment: ${d.name}`, d.description||'', d.images||[]);
  } catch(e) { showAlert('Failed to load environment: ' + e.message); }
}

async function profEnvDelete(name) {
  if (!confirm(`Delete environment profile "${name}"?`)) return;
  const r = await fetch(ROOT_PATH + '/admin/api/environments/'+encodeURIComponent(name), {method:'DELETE'});
  if (r.ok) await profEnvLoad();
  else showAlert('Delete failed: ' + await r.text());
}

// ─────────────────────────────────────────────────────────────────
loadModels();
loadLocalCapabilities();
loadVoiceProfiles();
profCharLoad();
profEnvLoad();
profVoiceLoad();
initRequestPreviews();
initPipelineBuilder();
fetch(ROOT_PATH + '/admin/api/tokens').then(r => r.json()).then(tokens => {
  if (Array.isArray(tokens) && tokens.length) apiToken = tokens[0].token;
}).catch(() => {});

// ── Pipeline accordion: opening one card closes all others ────────
document.querySelector('.pipe-panel').addEventListener('click', e => {
  const summary = e.target.closest('.pipe-card > summary');
  if (!summary) return;
  const card = summary.parentElement;
  // If it's currently closed, it's about to open — close siblings
  if (!card.open) {
    card.closest('.pipe-panel').querySelectorAll('details.pipe-card[open]').forEach(d => {
      if (d !== card) d.removeAttribute('open');
    });
  }
});
// ─────────────────────────────────────────────────────────────────
//  Pipeline Builder
// ─────────────────────────────────────────────────────────────────
let _stepTypes = [];       // [{type, label, params:[...]}]
let _pbSteps = [];         // current builder steps [{type, label, params:{}}]
let _customPipelines = []; // saved custom pipelines
let _editingPipelineId = null;

async function initPipelineBuilder() {
  try {
    const [typesRes, pipesRes] = await Promise.all([
      fetch(ROOT_PATH + '/v1/pipelines/step-types').then(r => r.json()),
      fetch(ROOT_PATH + '/v1/pipelines/custom').then(r => r.json()),
    ]);
    _stepTypes = typesRes.step_types || [];
    _customPipelines = pipesRes.pipelines || [];

    const sel = $('pb-add-type');
    if (sel) sel.innerHTML = _stepTypes.map(t =>
      `<option value="${t.type}">${t.label}</option>`).join('');

    renderCustomPipelineCards();
  } catch(e) {
    console.warn('Pipeline builder init failed:', e);
  }
}

function renderCustomPipelineCards() {
  const container = $('custom-pipe-cards');
  if (!container) return;
  container.innerHTML = _customPipelines.map(p => `
    <details class="pipe-card">
      <summary>
        <div class="pipe-head">
          <div class="pipe-title">${escapeHtml(p.name || p.id)}</div>
          <div class="pipe-summary">${escapeHtml(p.description || 'Custom multi-step pipeline for combining model and utility actions.')}</div>
          <div class="pipe-steps">
            ${(p.steps || []).map(s => `<span class="pipe-step">${escapeHtml(s.label || s.type)}</span>`).join('<span class="pipe-arrow">→</span>')}
          </div>
          <div class="pipe-tags"><span class="pipe-tag">custom</span><span class="pipe-tag">pipeline</span><span class="pipe-tag">${(p.steps || []).length} steps</span></div>
        </div>
      </summary>
      <div class="pipe-card-body">
        ${p.description ? `<p style="font-size:12px;color:var(--text-2);margin:0 0 .4rem">${escapeHtml(p.description)}</p>` : ''}
        <div class="frow"><label class="fl">Input</label><input id="cpr-input-${p.id}" class="fi" placeholder="{{ '{{' }}input{{ '}}' }} value"></div>
        <div style="display:flex;gap:.4rem;margin-top:.4rem;flex-wrap:wrap">
          <button class="btn btn-primary btn-sm" onclick="runCustomPipeline('${p.id}')">▶ Run</button>
          <button class="btn btn-ghost btn-sm" onclick="editCustomPipeline('${p.id}')">✎ Edit</button>
          <button class="btn btn-ghost btn-sm" style="color:var(--red)" onclick="deleteCustomPipeline('${p.id}')">✕ Delete</button>
        </div>
        <div class="progress" id="cpr-prog-${p.id}"></div>
        <div id="cpr-out-${p.id}"></div>
      </div>
    </details>`).join('');
}

function createPipeline() {
  _editingPipelineId = null;
  _pbSteps = [];
  $('pb-name').value = '';
  $('pb-desc').value = '';
  $('pb-input').value = '';
  $('pb-prog').textContent = 'Creating a new pipeline draft.';
  renderBuilderSteps();
  $('pipe-builder-card').open = true;
  $('pipe-builder-card').scrollIntoView({behavior:'smooth', block:'start'});
}

function pbAddStep() {
  const sel = $('pb-add-type');
  if (!sel) return;
  const type = sel.value;
  const typeDef = _stepTypes.find(t => t.type === type);
  if (!typeDef) return;
  const step = { type, label: typeDef.label, params: {} };
  // Set defaults from param schema
  (typeDef.params || []).forEach(([key, , , def]) => { if (def) step.params[key] = def; });
  _pbSteps.push(step);
  renderBuilderSteps();
}

function pbRemoveStep(idx) {
  _pbSteps.splice(idx, 1);
  renderBuilderSteps();
}

function pbMoveStep(idx, dir) {
  const to = idx + dir;
  if (to < 0 || to >= _pbSteps.length) return;
  [_pbSteps[idx], _pbSteps[to]] = [_pbSteps[to], _pbSteps[idx]];
  renderBuilderSteps();
}

function renderBuilderSteps() {
  const container = $('pb-steps');
  if (!container) return;
  container.innerHTML = _pbSteps.map((step, i) => {
    const typeDef = _stepTypes.find(t => t.type === step.type) || {params:[]};
    const paramFields = (typeDef.params || []).map(([key, inputType, label, def]) => {
      const v = step.params[key] ?? def ?? '';
      const hint = 'step ' + i + ' → \x7B\x7Bstep' + i + '.output\x7D\x7D or \x7B\x7Bstep' + i + '.url\x7D\x7D';
      if (inputType === 'textarea')
        return `<div class="pb-step-param"><label title="${hint}">${label}</label><textarea class="fi" rows="2" onchange="_pbSteps[${i}].params['${key}']=this.value">${v}</textarea></div>`;
      if (inputType === 'checkbox')
        return `<div class="pb-step-param"><label>${label}</label><input type="checkbox" ${v?'checked':''} onchange="_pbSteps[${i}].params['${key}']=this.checked"></div>`;
      if (inputType.startsWith('select:')) {
        const opts = inputType.slice(7).split('|').map(o => `<option value="${o}" ${o===v?'selected':''}>${o}</option>`).join('');
        return `<div class="pb-step-param"><label>${label}</label><select class="fselect" style="font-size:12px" onchange="_pbSteps[${i}].params['${key}']=this.value">${opts}</select></div>`;
      }
      if (inputType === 'ref')
        return `<div class="pb-step-param"><label title="Use {{ '{{' }}stepN.url{{ '}}' }} to reference a previous step">${label}</label><input class="fi" value="${v}" placeholder="{{ '{{' }}step${i>0?i-1:0}.url{{ '}}' }} or base64" onchange="_pbSteps[${i}].params['${key}']=this.value"></div>`;
      return `<div class="pb-step-param"><label>${label}</label><input type="${inputType==='number'?'number':'text'}" class="fi" value="${v}" onchange="_pbSteps[${i}].params['${key}']=this.value"></div>`;
    }).join('');
    return `<div class="pb-step">
      <div class="pb-step-header">
        <span style="background:var(--accent-s);color:var(--accent);border-radius:4px;padding:.1rem .4rem;font-size:11px">${i}</span>
        <span style="flex:1">${step.label || step.type}</span>
        <button class="btn btn-ghost btn-sm" style="padding:.1rem .3rem" onclick="pbMoveStep(${i},-1)" ${i===0?'disabled':''}>↑</button>
        <button class="btn btn-ghost btn-sm" style="padding:.1rem .3rem" onclick="pbMoveStep(${i},1)" ${i===_pbSteps.length-1?'disabled':''}>↓</button>
        <button class="btn btn-ghost btn-sm" style="padding:.1rem .3rem;color:var(--red)" onclick="pbRemoveStep(${i})">✕</button>
      </div>
      <div class="frow" style="margin-bottom:.1rem"><label class="fl" style="font-size:11px">Label</label><input class="fi" style="font-size:12px" value="${step.label||''}" placeholder="${step.type}" onchange="_pbSteps[${i}].label=this.value"></div>
      <div class="pb-step-params">${paramFields}</div>
    </div>`;
  }).join('');
}

function _pbCollect() {
  return {
    id: _editingPipelineId || undefined,
    name: val('pb-name') || 'Untitled Pipeline',
    description: val('pb-desc') || '',
    steps: _pbSteps.map(s => ({ type: s.type, label: s.label, params: {...s.params} })),
  };
}

async function pbSave() {
  const def = _pbCollect();
  $('pb-prog').textContent = 'Saving…';
  try {
    let res;
    if (_editingPipelineId) {
      res = await fetch(ROOT_PATH + `/v1/pipelines/custom/${_editingPipelineId}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json());
    } else {
      res = await fetch(ROOT_PATH + '/v1/pipelines/custom', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json());
      _editingPipelineId = res.pipeline?.id;
    }
    _customPipelines = (await fetch(ROOT_PATH + '/v1/pipelines/custom').then(r => r.json())).pipelines || [];
    renderCustomPipelineCards();
    $('pb-prog').textContent = 'Saved ✓';
  } catch(e) { $('pb-prog').textContent = 'Error: '+e.message; }
}

async function pbRun() {
  const def = _pbCollect();
  $('pb-prog').textContent = 'Running…';
  $('pb-out').innerHTML = '';
  try {
    const d = await post('/v1/pipelines/run', {...def, input: val('pb-input')});
    _renderPipelineResult('pb-out', 'pb-prog', d);
  } catch(e) { $('pb-prog').textContent = 'Error: '+e.message; }
}

async function pbSaveAndRun() { await pbSave(); await pbRun(); }

async function runCustomPipeline(id) {
  const prog = $(`cpr-prog-${id}`), out = $(`cpr-out-${id}`);
  if (prog) prog.textContent = 'Running…';
  if (out) out.innerHTML = '';
  try {
    const input = val(`cpr-input-${id}`) || '';
    const d = await post(`/v1/pipelines/custom/${id}/run`, {input});
    _renderPipelineResult(`cpr-out-${id}`, `cpr-prog-${id}`, d);
  } catch(e) { if (prog) prog.textContent = 'Error: '+e.message; }
}

function editCustomPipeline(id) {
  const p = _customPipelines.find(x => x.id === id);
  if (!p) return;
  _editingPipelineId = id;
  $('pb-name').value = p.name || '';
  $('pb-desc').value = p.description || '';
  _pbSteps = p.steps.map(s => ({...s, params:{...s.params}}));
  renderBuilderSteps();
  renderCustomPipelineCards();
  $('pipe-builder-card').open = true;
  $('pipe-builder-card').scrollIntoView({behavior:'smooth'});
  $('pb-prog').textContent = `Editing "${p.name}"`;
}

async function deleteCustomPipeline(id) {
  if (!confirm('Delete this pipeline?')) return;
  try {
    await fetch(ROOT_PATH + `/v1/pipelines/custom/${id}`, {method:'DELETE'});
    _customPipelines = _customPipelines.filter(p => p.id !== id);
    if (_editingPipelineId === id) { _editingPipelineId = null; _pbSteps = []; renderBuilderSteps(); }
    renderCustomPipelineCards();
  } catch(e) { showAlert('Delete failed: '+e.message); }
}

function _renderPipelineResult(outId, progId, d) {
  const steps = d.steps || [];
  const last = steps[steps.length - 1] || {};
  // Show each step result
  const html = steps.map(s => {
    if (s.error) return `<div style="font-size:12px;color:var(--red);margin:.2rem 0">Step ${s.step} (${s.label}): ${s.error}</div>`;
    let media = '';
    if (s.url && s.url.match(/\.(mp4|webm)/i)) media = `<video controls src="${s.url}" style="max-width:100%;max-height:200px;border-radius:6px;margin-top:.3rem"></video>`;
    else if (s.url) media = `<img src="${s.url}" style="max-width:100%;max-height:200px;border-radius:6px;margin-top:.3rem">`;
    else if (s.b64_wav) media = `<audio controls src="data:audio/wav;base64,${s.b64_wav}" style="width:100%;margin-top:.3rem"></audio>`;
    else if (s.output) media = `<pre style="font-size:11px;white-space:pre-wrap;background:var(--surface-2);padding:.4rem;border-radius:4px;margin-top:.2rem">${s.output.substring(0,400)}${s.output.length>400?'…':''}</pre>`;
    return `<div style="font-size:12px;color:var(--text-2);margin:.2rem 0"><strong style="color:var(--text)">${s.label||s.type}</strong>${media}</div>`;
  }).join('');
  const out = $(outId);
  if (out) out.innerHTML = `<div style="display:flex;flex-direction:column;gap:.2rem;margin-top:.4rem">${html}</div>`;
  const prog = $(progId);
  if (prog) prog.textContent = steps.some(s => s.error) ? 'Completed with errors' : 'Done ✓';
}

// ─────────────────────────────────────────────────────────────────
//  Role picker (multi-cap sidebar assignment)
// ─────────────────────────────────────────────────────────────────
function showRolePicker(sub, model) {
  closeRolePicker();
  const studioRule = STUDIO_CAPABILITIES[sub];
  if (!studioRule) return;
  const caps = [...(studioRule.requires || []), ...(studioRule.optional || [])];
  if (!caps.length) return;

  const popup = document.createElement('div');
  popup.id = 'role-picker-popup';
  popup.className = 'role-picker-popup';

  const capBtns = caps.map(cap => {
    const currentId = capModelAssignments[sub]?.[cap];
    const isCurrent = currentId === model.id;
    const safe = JSON.stringify(model).replace(/"/g, '&quot;');
    return `<button class="role-pick-btn${isCurrent ? ' active' : ''}"
      onclick="assignModelToCap('${sub}','${cap}',${safe});closeRolePicker()">
      <span class="role-pick-cap">${cap.replace(/_/g,' ')}</span>
      ${isCurrent ? '<span class="role-pick-badge">assigned</span>' : ''}
    </button>`;
  }).join('');

  popup.innerHTML = `
    <div class="role-picker-header">Assign <strong>${escapeHtml(model.id.split('/').pop())}</strong> as:</div>
    <div class="role-picker-caps">${capBtns}</div>
    <button class="role-picker-close" onclick="closeRolePicker()">Cancel</button>
  `;
  document.body.appendChild(popup);

  // Position next to the active sidebar model item
  const activeItem = document.querySelector('.model-item.active');
  if (activeItem) {
    const rect = activeItem.getBoundingClientRect();
    const top = Math.min(rect.top, window.innerHeight - popup.offsetHeight - 16);
    popup.style.top = Math.max(8, top) + 'px';
    popup.style.left = (rect.right + 8) + 'px';
  }

  setTimeout(() => document.addEventListener('click', _rolePickerOutside, { once: true }), 0);
}

function _rolePickerOutside(e) {
  const popup = $('role-picker-popup');
  if (popup && !popup.contains(e.target)) closeRolePicker();
  else if (popup) document.addEventListener('click', _rolePickerOutside, { once: true });
}

function closeRolePicker() {
  const popup = $('role-picker-popup');
  if (popup) popup.remove();
  document.removeEventListener('click', _rolePickerOutside);
}

// ─────────────────────────────────────────────────────────────────
//  Archive
// ─────────────────────────────────────────────────────────────────
let _archiveFilter = 'all';
let _archiveFiles = [];

async function loadArchive() {
  const grid = $('archive-grid');
  if (!grid) return;
  grid.innerHTML = '<div class="arch-empty">Loading…</div>';
  try {
    const data = await (await fetch(ROOT_PATH + '/v1/archive')).json();
    _archiveFiles = data.files || [];
    renderArchive();
  } catch(e) {
    grid.innerHTML = `<div class="arch-empty">Error loading archive: ${escapeHtml(e.message)}</div>`;
  }
}

function archiveSetFilter(type) {
  _archiveFilter = type;
  document.querySelectorAll('.arch-filter').forEach(b =>
    b.classList.toggle('active', b.dataset.type === type));
  renderArchive();
}

function renderArchive() {
  const grid = $('archive-grid');
  if (!grid) return;
  const files = _archiveFilter === 'all'
    ? _archiveFiles
    : _archiveFiles.filter(f => f.type === _archiveFilter);
  if (!files.length) {
    const msg = _archiveFiles.length
      ? 'No files match the filter.'
      : 'No generated files found. Set --file-path to save outputs to disk.';
    grid.innerHTML = `<div class="arch-empty">${msg}</div>`;
    return;
  }
  grid.innerHTML = files.map(f => {
    const date = new Date(f.created * 1000).toLocaleString([], {
      month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'
    });
    const size = f.size > 1048576
      ? (f.size / 1048576).toFixed(1) + ' MB'
      : Math.round(f.size / 1024) + ' KB';
    const thumb = f.type === 'image'
      ? `<img class="arch-thumb" src="${escapeHtml(f.url)}" onclick="window.open('${escapeHtml(f.url)}')" loading="lazy">`
      : `<div class="arch-thumb-ph">${f.type === 'video' ? '[video]' : '[audio]'}</div>`;
    const playBtn = f.type !== 'image'
      ? `<button class="arch-btn" onclick="archivePlay(${JSON.stringify(f).replace(/"/g,'&quot;')})">Play</button>`
      : '';
    const safeUrl = escapeHtml(f.url);
    const safeName = escapeHtml(f.filename);
    return `<div class="arch-card">
      ${thumb}
      <div class="arch-info">
        <div class="arch-name" title="${safeName}">${safeName}</div>
        <div class="arch-meta">${date} &middot; ${size}</div>
      </div>
      <div class="arch-actions">
        <a class="arch-btn" href="${safeUrl}" download="${safeName}">Download</a>
        ${playBtn}
        <button class="arch-btn del" onclick="archiveDelete('${safeName}')">Delete</button>
      </div>
    </div>`;
  }).join('');
}

async function archiveDelete(filename) {
  if (!confirm(`Delete ${filename}?`)) return;
  try {
    const r = await fetch(ROOT_PATH + `/v1/archive/${encodeURIComponent(filename)}`, { method: 'DELETE' });
    if (!r.ok) throw new Error((await r.json()).detail || 'Delete failed');
    _archiveFiles = _archiveFiles.filter(f => f.filename !== filename);
    renderArchive();
  } catch(e) {
    showAlert('Delete failed: ' + e.message);
  }
}

function archivePlay(file) {
  const w = window.open('', '_blank', 'width=820,height=520');
  if (!w) return;
  const tag = file.type === 'video'
    ? `<video src="${escapeHtml(file.url)}" controls autoplay style="max-width:100%;max-height:100vh"></video>`
    : `<audio src="${escapeHtml(file.url)}" controls autoplay></audio>`;
  w.document.write(`<!doctype html><html><body style="margin:0;background:#000;display:flex;align-items:center;justify-content:center;height:100vh">${tag}</body></html>`);
}
</script>
{% endblock %}
