{% 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;
}
.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-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; }
.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 { font-size:11px; color:var(--text-3); min-height:14px; }
.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); }
}

/* ── 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.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-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; }

/* ── 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-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; }
.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; }
</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="pipe" onclick="selectCat('pipe')">Pipelines <span class="tab-status" hidden></span></button>
      <button class="t1btn" data-cat="embed" onclick="selectCat('embed')">Embed <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>
      <!-- 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>
      <!-- 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>
    </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" style="font-size:11px;color:var(--text-3);height:14px;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>
          <button class="btn btn-primary" onclick="genImage()">Generate</button>
          <div class="progress" id="ig-prog"></div>
          <div class="req-preview" id="ig-preview"></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="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="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>

    <!-- ═══════════════ 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)">Character consistency</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.6rem">
            <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</button>
            <div class="frow"><label class="fl">IP-Adapter 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>
        </details>
        <button class="btn btn-primary" onclick="genVideo('i2v')">Animate</button>
        <div class="progress" id="vi-prog"></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>
        <!-- Character consistency -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Character consistency</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.6rem">
            <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</button>
            <div class="frow"><label class="fl">IP-Adapter 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>
        </details>
        <button class="btn btn-primary" onclick="genVideo('v2v')">Transform</button>
        <div class="progress" id="vv-prog"></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>
        <!-- Character consistency -->
        <details style="margin-top:.25rem">
          <summary style="font-size:12px;font-weight:600;cursor:pointer;color:var(--text-2)">Character consistency</summary>
          <div style="margin-top:.5rem;display:flex;flex-direction:column;gap:.6rem">
            <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</button>
            <div class="frow"><label class="fl">IP-Adapter 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>
        </details>
        <!-- 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>
      <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>

    <!-- ═══════════════ 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>
        <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</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>
          <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">
          <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">
          <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">
          <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">
          <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">
          <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">
          <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">
          <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="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><!-- studio-main -->
</div><!-- studio -->
{% endblock %}

{% block scripts %}
<script>
// ─────────────────────────────────────────────────────────────────
//  State
// ─────────────────────────────────────────────────────────────────
let models = [], activeModel = null, chatHistory = [], chatBusy = false, attachedImage = 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',
};
// 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',
  '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',
  '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',
};
// 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:'', className:'' },
  partial: { label:'Partial', className:'state-partial' },
  unavailable: { label:'Unavailable', className:'state-unavailable' },
};
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:'/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:'/v1/audio/cleanup',
    io:'Input: noisy audio. Output: cleaned audio artifact plus applied-operation metadata.'
  }
};
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', optional:['image_upscaling'] },
  'img-depth': { category:'image', requiresAny:['depth_estimation'] },
  'img-seg': { category:'image', requiresAny:['image_segmentation'] },
  'img-faceswap': { category:'image', optional:['image_to_image'] },
  'img-deblur': { category:'image', optional:['image_to_image'] },
  'img-unpix': { category:'image', optional:['image_to_image'] },
  'img-outfit': { category:'image', optional:['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', optional:['video_upscaling'], fallbackTypes:['video'] },
  'vid-faceswap': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] },
  'vid-outfit': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] },
  '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'] },
};
const CATEGORY_TABS = ['chat', 'image', 'video', 'audio', 'pipe', 'embed'];
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'),
};
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 hasTypeFallback(rule, type) {
  return Array.isArray(rule.fallbackTypes) && rule.fallbackTypes.includes(type);
}

function evaluateSubCapability(rule, caps, type) {
  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, type);

  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 'available';
  if (cat === 'embed') return caps.has('embeddings') || type === 'embedding' ? 'available' : 'unavailable';
  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-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 = capabilitySetForModel(activeModel);
  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,
  };
}

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' ? 'Available' : details.availability === 'partial' ? 'Partial' : 'Unavailable';
  const availabilityClass = details.availability === 'available' ? '' : details.availability === 'partial' ? ' warn' : ' dim';
  const missingBits = [];
  if (details.missingRequired.length) missingBits.push(`<div class="cap-missing"><strong>Missing required:</strong> ${details.missingRequired.join(', ')}</div>`);
  if (details.missingOptional.length) missingBits.push(`<div class="cap-missing"><strong>Limited without:</strong> ${details.missingOptional.join(', ')}</div>`);
  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>
    ${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';
    if (state === 'available') { shell.style.display = 'none'; shell.innerHTML = ''; 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 caps = capabilitySetForModel(activeModel);
    const missingRequired = (rule.requiresAny || []).filter(c => !caps.has(c));
    const missingOptional = (rule.optional || []).filter(c => !caps.has(c));
    const label = document.querySelector(`.t2btn[data-sub="${sub}"]`)?.childNodes[0]?.textContent?.trim() || sub;
    const missingBits = [];
    if (missingRequired.length) missingBits.push(`<div class="cap-missing"><strong>Missing required:</strong> ${missingRequired.join(', ')}</div>`);
    if (missingOptional.length) missingBits.push(`<div class="cap-missing"><strong>Limited without:</strong> ${missingOptional.join(', ')}</div>`);
    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>
      ${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 missing = [];
  if (details.missingRequired.length) missing.push(...details.missingRequired.map(item => `<strong>Required:</strong> ${item}`));
  if (details.missingOptional.length) missing.push(...details.missingOptional.map(item => `<strong>Optional:</strong> ${item}`));
  if (!missing.length && Array.isArray(details.notes)) missing.push(...details.notes);
  const title = details.availability === 'unavailable' ? '⚠ Feature unavailable' : '⚠ Feature partially available';
  const detailHtml = missing.length
    ? `<ul>${missing.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('/v1/audio/generate', {
    model: activeModel?.id || '',
    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('/v1/audio/speech', {
    model: activeModel?.id || '',
    input: val('at-text'),
    voice: val('at-voice') || undefined,
    speed: fval('at-speed') || 1.0,
    response_format: 'mp3',
  });
}

function buildSTTPreviewData() {
  return previewExportBody('/v1/audio/transcriptions', {
    model: activeModel?.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() {
  return previewExportBody('/v1/images/generations', {
    model: activeModel?.id || '',
    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,
  });
}

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

function buildAudioUnderstandPreviewData() {
  return previewExportBody('/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('/v1/pipelines/audio-music-dub', {
    audio: fileOrNull('amd-file') ? '<audio/video file data>' : undefined,
    audio_model: activeModel?.id || '',
    source_lang: val('amd-slang') || undefined,
    target_lang: val('amd-tlang') || undefined,
    notes: val('amd-notes') || undefined,
  });
}

function buildStemPreviewData() {
  return previewExportBody('/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('/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('/v1/video/dub', {
    model: activeModel?.id || '',
    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) {
  if (kind === 'curl') {
    return `curl -X POST http://localhost:8000${preview.endpoint} \\
  -H "Content-Type: application/json" \\
  -d '${preview.json.replace(/'/g, "'\\''")}'`;
  }
  if (kind === 'python') {
    return `import requests\n\npayload = ${preview.json}\nresponse = requests.post(\n    "http://localhost:8000${preview.endpoint}",\n    json=payload,\n    timeout=300,\n)\nprint(response.json())`;
  }
  return `const payload = ${preview.json};\nconst response = await fetch("${preview.endpoint}", {\n  method: "POST",\n  headers: { "Content-Type": "application/json" },\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 },
    ],
  },
  '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:'Audio model', value:preview => preview.body.audio_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:'Model', value:preview => preview.body.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
// ─────────────────────────────────────────────────────────────────
async function loadModels() {
  try {
    const d = await fetch('/v1/models').then(r => r.json());
    models = d.data || [];
    renderSidebar();
    if (models.length) selectModel(models[0]);
  } catch(e) {
    $('model-list').innerHTML = '<div class="muted small" style="padding:.5rem .6rem">Failed to load models</div>';
  }
}

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'};
const BLABEL = {text:'LLM',vision:'VLM',image:'IMG',video:'VID',audio:'STT',
                tts:'TTS',audio_gen:'MUS',embedding:'EMB'};

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>`;
  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 caps = capabilitySetForModel(m);
  const type = m.type || 'text';
  refreshAudioBackendHealth();
  const subStates = {};
  Object.entries(SUB_CAPABILITY_RULES).forEach(([sub, rule]) => {
    if (VIDEO_EXTRA_SUBS.includes(sub) && type === 'video' && !rule.fallbackTypes) {
      rule = Object.assign({}, rule, { fallbackTypes:['video'] });
    }
    subStates[sub] = evaluateSubCapability(rule, caps, type);
  });
  const categoryStates = {};
  CATEGORY_TABS.forEach(cat => {
    categoryStates[cat] = evaluateCategoryState(cat, subStates, caps, 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 = caps.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'].includes(cat);
  $('tabbar2').classList.toggle('visible', hasL2);
  if (!hasL2) {
    document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
    const panel = $('panel-' + cat);
    if (panel) panel.classList.add('active');
    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);
  });
  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);
}

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(); } }
}

// ─────────────────────────────────────────────────────────────────
//  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;
}

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…';
  try {
    const r = await fetch('/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;
    addMsg('assistant',reply);
    chatHistory.push({role:'assistant',content:reply});
  } catch(e) { addMsg('assistant','Error: '+e.message); }
  finally { chatBusy=false; $('send-btn').disabled=false; $('typing').textContent=''; }
}

$('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(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(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('/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(`/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) { alert('Failed to load profile: '+e.message); }
}

async function saveCharSlotAsProfile(prefix, idx) {
  const slot = charSlots[prefix]?.[idx];
  if (!slot || !slot.images.length) { alert('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);
    alert(`Saved profile "${name}"`);
  } catch(e) { alert('Save failed: '+e.message); }
}

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}));
}

// ─────────────────────────────────────────────────────────────────
//  Image Generation
// ─────────────────────────────────────────────────────────────────
async function genImage() {
  if (!activeModel) return;
  $('ig-prog').textContent='Generating…';
  try {
    const d = await post('/v1/images/generations', {
      model:activeModel.id, 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',
    });
    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:activeModel.id,
      summary:buildImageHistorySummary(imgs),
      links:buildImageLinks(imgs),
    });
    $('ig-prog').textContent='Done ✓';
  } catch(e) { $('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:activeModel.id, 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:activeModel.id, 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:activeModel.id, 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:activeModel.id, 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:activeModel.id, 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 prog = progMap[mode], outId = outMap[mode];
  $(prog).textContent='Generating… (this may take several minutes)';
  const body = {model:activeModel.id, 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');
  } 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 viChars = buildCharactersPayload('vi');
    if (viChars) { body.characters=viChars; body.character_strength=fval('vi-cstr')||0.8; }
  } 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 vvChars = buildCharactersPayload('vv');
    if (vvChars) { body.characters=vvChars; body.character_strength=fval('vv-cstr')||0.8; }
  }
  try {
    const d = await post('/v1/video/generations', body);
    showVideo(outId, vidSrc(d.data[0]), prog);
  } catch(e) { $(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; }

  const body = {
    model:activeModel.id,
    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
    characters: buildCharactersPayload('ti'),
    character_strength:fval('ti-cstr')||0.8,
    // 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'),
    // 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);
    showVideo('ti-out', vidSrc(d.data[0]), 'ti-prog');
  } catch(e) { $('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:activeModel.id, 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:activeModel.id, 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', {
      model:activeModel.id, 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: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:activeModel.id, 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; }
}

// ─────────────────────────────────────────────────────────────────
//  T2V controls helper (shared form – inline because Jinja include
//  isn't available here)
// ─────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────
//  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: activeModel.id,
    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('/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;
    });
  } 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('/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…';
  const melody = await b64OrNull('ag-melody');
  const body = {
    model:activeModel.id, 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);
    const item = d.data[0];
    const src = audSrc(item);
    showAudio('ag-out', src, 'ag-prog', 'wav');
    pushArtifactHistory({
      task:'Audio generate',
      family:'audio generation',
      model:activeModel.id,
      summary:buildAudioHistorySummary(val('ag-prompt'), body.duration),
      links:buildAudioLinks(item),
    });
  } catch(e) { $('ag-prog').textContent='Error: '+e.message; }
}

// ─────────────────────────────────────────────────────────────────
//  TTS
// ─────────────────────────────────────────────────────────────────
async function genTTS() {
  if (!activeModel) return;
  $('at-prog').textContent='Synthesizing…';
  try {
    const r = await fetch('/v1/audio/speech', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body:JSON.stringify({model:activeModel.id, input:val('at-text'),
        speed:fval('at-speed')||1.0, voice:val('at-voice')||undefined, response_format:'mp3'})
    });
    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:activeModel.id,
      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() {
  if (!activeModel) 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', activeModel.id);
  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:activeModel.id,
      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:activeModel.id, 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: activeModel.id,
      ...(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),
      audio_model: activeModel.id,
      ...(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>
    <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>`;

loadModels();
loadVoiceProfiles();
initRequestPreviews();
initPipelineBuilder();

// ── 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('/v1/pipelines/step-types').then(r => r.json()),
      fetch('/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(`/v1/pipelines/custom/${_editingPipelineId}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json());
    } else {
      res = await fetch('/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('/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(`/v1/pipelines/custom/${id}`, {method:'DELETE'});
    _customPipelines = _customPipelines.filter(p => p.id !== id);
    if (_editingPipelineId === id) { _editingPipelineId = null; _pbSteps = []; renderBuilderSteps(); }
    renderCustomPipelineCards();
  } catch(e) { alert('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 ✓';
}
</script>
{% endblock %}
