/* OnMic panel -- professional-style layout */
(function () {
  "use strict";
  try {

  var VERSION = "1.22";   // panel version. CEP bundle version (manifest) keeps its own increasing 1.0.NN counter; see CHANGELOG.txt
  // v1.09: expose the running version so auth.js can report it on each verify
  // (the admin dashboard shows which OnMic version each user is on). auth.js
  // loads before main.js, so it reads this global lazily at verify time.
  try { if (typeof window !== "undefined") window.__ONMIC_PANEL_VERSION = VERSION; } catch (eV) {}

  var fs, os, crypto;
  try { fs = CEP.require("fs"); os = CEP.require("os"); crypto = CEP.require("crypto"); }
  catch (e) {}
  var IS_WIN = (typeof process !== "undefined" && process.platform === "win32");
  // App-support base path for either OS, using forward slashes throughout
  // (Node fs and ExtendScript File both accept "/" on Windows).
  function appSupportDir(rel){
    if (IS_WIN){
      var base = ((typeof process!=="undefined" && process.env.APPDATA) || (os.homedir()+"/AppData/Roaming")).replace(/\\/g,"/");
      return base + "/" + rel;
    }
    return os.homedir() + "/Library/Application Support/" + rel;
  }
  var Engine = (typeof OnMicEngine !== "undefined") ? OnMicEngine
             : (typeof window !== "undefined" ? window.OnMicEngine : null);

  function outputMode(){
    var el = $("output_mode");
    if (el) { el.value = "enable_disable"; }
    return "enable_disable";
  }

  // Edit Pace presets (the non-exposed dials live here).
  var PACE = {
    // Balanced = calm baseline. Quick Cuts = snappy. Relaxed = slower holds.
    standard: { min_shot_s:1.2, max_shot_s:35, switch_delay_ms:400, lead_offset_ms:120, dominance_margin_db:13, overlap_min_ms:1600, min_interject_s:1.0, establish_wide_frequency:0.25, establish_len_ms:3000, establish_min_pause_ms:400 },
    fast:     { min_shot_s:0.8, max_shot_s:8,  switch_delay_ms:80,  lead_offset_ms:150, dominance_margin_db:7,  overlap_min_ms:400, min_interject_s:0.8 },
    relaxed:  { min_shot_s:4.0, max_shot_s:16, switch_delay_ms:250, lead_offset_ms:100, dominance_margin_db:11, overlap_min_ms:800, min_interject_s:2.0 }
  };

  var SEQ_FPS = 23.976;
  var audioTracks = [];   // index-aligned: audioTracks[i] == audio track i+1 (A(i+1))
  var videoTracks = [];
  var camSel = {};        // camSel[cameraRow] = { speakerSlot: true }
  var firstRead = true;
  var channelInfo = null; // {channels, levels:[{ch,peak,median}], mix:[], suggested_iso:[]}
  var curSeqId = "";      // active sequence identity at last track read — stamped into cuts.json
  var curSeqName = "";    // so apply/trim/markers can refuse a cuts file from another sequence
  var curTimelineFingerprint = ""; // exact A/V clip layout captured by the same read
  var premiereVersion = ""; // app.version, captured from mcsPing() — used in the support bundle
  var vadMaskCache = {};  // panel-session cache: same mic clip + gain + hop => same voice mask
  var sourceChannelCountCache = {}; // WAV channel count by file signature

  function cleanSourceChannel(v){
    if (v == null) return null;
    var n = parseInt(v, 10);
    return (isFinite(n) && n >= 0 && n < 64) ? n : null;
  }

  function explicitTrackSourceChannel(t){
    var ch = cleanSourceChannel(t && t.sourceChannel);
    if (ch != null) return ch;
    var clips = (t && t.clips) || [];
    for (var i=0;i<clips.length;i++){
      ch = cleanSourceChannel(clips[i] && clips[i].sourceChannel);
      if (ch != null) return ch;
    }
    return null;
  }

  // Source channel for speaker slot i -- determined automatically: skip the
  // detected mix channels and use the isolated mics in order. No user picking.
  function chan(i){
    // v1.03: the isolated-channel suggestion comes from analyzing ONE media file
    // (the first track's). Only apply it to speakers whose clips use THAT file —
    // indexing by their position among same-file speakers. A speaker on a
    // different file (e.g. their own mono USB-mic WAV alongside a recorder poly
    // WAV) used to be handed a channel index from the wrong file, crashing the
    // analysis ("channel N >= M") or silently reading the wrong channel.
    var t = audioTracks[i];
    var timelineChannel = explicitTrackSourceChannel(t);
    if (timelineChannel != null) return timelineChannel;
    if (channelInfo && channelInfo.suggested_iso && channelInfo._path &&
        t && t.mediaPath === channelInfo._path){
      var pos = 0;
      for (var k = 0; k < i; k++){
        var o = audioTracks[k];
        if (o && o.mediaPath === channelInfo._path) pos++;
      }
      if (channelInfo.suggested_iso[pos] != null) return channelInfo.suggested_iso[pos];
    }
    return (t && t.channel) || 0;
  }

  function sourceChannelCount(path){
    if(!path || !Engine || !Engine.wavChannelCount) return 0;
    var sig=fileSig(path);
    var key=(sig && sig.size>0) ? (sig.path+"|"+sig.size+"|"+sig.mtime) : String(path);
    if(sourceChannelCountCache[key]!=null) return sourceChannelCountCache[key];
    var n=0;
    try { n=Engine.wavChannelCount(path)|0; } catch(e){ n=0; }
    sourceChannelCountCache[key]=n;
    return n;
  }

  // Preserve a speaker's isolated source channel across consecutive files from
  // the same recorder. A different file is only treated as a continuation when
  // it appears on multiple mic tracks and its channel layout matches the first
  // file. A standalone/mono pickup keeps the established channel-0 fallback.
  function clipSourceChannel(primaryChannel, primaryPath, clipPath, pathTrackUses, channelCounts, explicitChannel){
    primaryChannel=primaryChannel|0;
    var timelineChannel = cleanSourceChannel(explicitChannel);
    if(timelineChannel != null) return timelineChannel;
    if(!clipPath || clipPath===primaryPath) return primaryChannel;
    if(!pathTrackUses || (pathTrackUses[clipPath]||0)<2) return 0;
    var primaryCount=channelCounts && (channelCounts[primaryPath]||0);
    var clipCount=channelCounts && (channelCounts[clipPath]||0);
    if(primaryCount>primaryChannel && clipCount===primaryCount) return primaryChannel;
    return 0;
  }

  function roundedKeyNum(v){
    v = Number(v) || 0;
    return Math.round(v * 1000) / 1000;
  }

  function copyMask(mask){
    if(!mask) return null;
    var out = new Uint8Array(mask.length);
    out.set(mask);
    return out;
  }

  function vadCacheDir(){
    try {
      var d = appSupportDir("OnMic/vad-cache");
      if(fs && !fs.existsSync(d)) fs.mkdirSync(d,{recursive:true});
      return d;
    } catch(e){ return ""; }
  }

  function sha1(s){
    try { if(crypto) return crypto.createHash("sha1").update(String(s)).digest("hex"); } catch(e){}
    var h=0, str=String(s);
    for(var i=0;i<str.length;i++){ h=((h<<5)-h+str.charCodeAt(i))|0; }
    return "h"+Math.abs(h);
  }

  function vadCacheKey(c, hopS){
    if(!c || !c.wav || !fs) return "";
    try {
      var st = fs.statSync(c.wav);
      var mt = st && st.mtime ? st.mtime.getTime() : 0;
      return [
        "silero-v4", c.wav, st ? st.size : 0, mt,   // v4: masks are only SAVED when silero actually ran (see runAnalysis)
        c.channel || 0, roundedKeyNum(c.src_in_s || 0), roundedKeyNum(c.dur_s || 0),
        roundedKeyNum(c.gain_db || 0), roundedKeyNum(hopS || 0)
      ].join("|");
    } catch(e){ return ""; }
  }

  function loadVadMaskFromDisk(key, nHops){
    if(!key || !fs) return null;
    try {
      var d=vadCacheDir(); if(!d) return null;
      var p=d+"/"+sha1(key)+".json";
      if(!fs.existsSync(p)) return null;
      var o=JSON.parse(fs.readFileSync(p,"utf8"));
      if(!o || o.key!==key || o.n!==nHops || !o.data) return null;
      var buf = (typeof Buffer!=="undefined") ? Buffer.from(o.data,"base64") : null;
      if(!buf || buf.length!==nHops) return null;
      var out=new Uint8Array(nHops);
      for(var i=0;i<nHops;i++) out[i]=buf[i] ? 1 : 0;
      return out;
    } catch(e){ return null; }
  }

  function saveVadMaskToDisk(key, mask){
    if(!key || !mask || !fs || typeof Buffer==="undefined") return;
    try {
      var d=vadCacheDir(); if(!d) return;
      var p=d+"/"+sha1(key)+".json";
      var buf=Buffer.from(mask);
      fs.writeFileSync(p, JSON.stringify({ key:key, n:mask.length, data:buf.toString("base64") }), "utf8");
    } catch(e){}
  }

  function cutsCacheDir(){
    try {
      var d = appSupportDir("OnMic/cuts-cache");
      if(fs && !fs.existsSync(d)) fs.mkdirSync(d,{recursive:true});
      return d;
    } catch(e){ return ""; }
  }

  function fileSig(path){
    try {
      if(!fs || !path) return null;
      var st = fs.statSync(path);
      var mt = st && st.mtime && st.mtime.getTime ? st.mtime.getTime() : 0;
      return { path:String(path), size:st.size||0, mtime:Math.round(mt/1000) };
    } catch(e){ return { path:String(path||""), size:0, mtime:0 }; }
  }

  function cutsCacheKey(cfg){
    try {
      if(!crypto || !cfg) return "";
      var speakers=(cfg.speakers||[]).map(function(s){
        return {
          name:s.name||"",
          channel:s.channel||0,
          gain_db:roundedKeyNum(s.gain_db||0),
          clips:(s.clips||[]).map(function(c){
            return {
              file:fileSig(c.wav),
              channel:c.channel||0,
              seq_start_s:roundedKeyNum(c.seq_start_s||0),
              dur_s:roundedKeyNum(c.dur_s||0),
              src_in_s:roundedKeyNum(c.src_in_s||0),
              gain_db:roundedKeyNum(c.gain_db||0)
            };
          })
        };
      });
      var payload={
        cache:"onmic-cuts-v3",
        version:VERSION,
        seq_id:curSeqId||"",
        timeline_fingerprint:curTimelineFingerprint||"",
        seq_name:curSeqName||"",
        fps:roundedKeyNum(cfg.fps||0),
        // v1.03: whether AI voice detection is actually available is part of the
        // key. A plan built on the loudness fallback must never be served after
        // the AI self-heals (and vice versa).
        vad_ready: !!voiceReady(),
        speakers:speakers,
        cameras:cfg.cameras||[],
        params:cfg.params||{}
      };
      return crypto.createHash("sha1").update(JSON.stringify(payload)).digest("hex");
    } catch(e){ return ""; }
  }

  function loadCutsFromCache(key){
    try {
      var d=cutsCacheDir(); if(!d || !key) return null;
      var p=d+"/"+key+".json";
      if(!fs.existsSync(p)) return null;
      var o=JSON.parse(fs.readFileSync(p,"utf8"));
      if(!o || !o.segments || !o.cameras) return null;
      return o;
    } catch(e){ return null; }
  }

  function saveCutsToCache(key, out){
    try {
      var d=cutsCacheDir(); if(!d || !key || !out) return;
      fs.writeFileSync(d+"/"+key+".json", JSON.stringify(out), "utf8");
    } catch(e){}
  }

  function $(id){ return document.getElementById(id); }
  function log(m){ var l=$("log"); l.textContent += m + "\n"; l.scrollTop=l.scrollHeight; }
  function clearLog(){ $("log").textContent=""; }
  function status(m,bad){ var s=$("status"); s.textContent=m; s.className=bad?"bad":""; }
  function val(id){ return $(id).value; }
  function baseName(p){ return String(p||"").split("/").pop(); }
  function esc(s){ return String(s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
  function iSp(){ return parseInt(val("numSpeakers"),10); }
  function iCam(){ return parseInt(val("numCameras"),10); }
  function clampCount(n){ n=parseInt(n,10)||2; return Math.max(2, Math.min(12, n)); }

  /* ---- panel-rendered Speakers / Cameras count menus ---- */
  var openCountOwner=null, openCountPop=null;

  function selectedOptionText(sel){
    if(!sel) return "";
    for(var i=0;i<sel.options.length;i++){
      if(sel.options[i].value===sel.value) return sel.options[i].text;
    }
    return "";
  }

  function syncCountPicker(id){
    var sel=$(id), btn=$(id+"Button"), pop=$(id+"Menu");
    if(!sel || !btn) return;
    btn.textContent=selectedOptionText(sel);
    if(pop){
      var opts=pop.querySelectorAll(".count-option");
      for(var i=0;i<opts.length;i++){
        var on=opts[i].getAttribute("data-value")===sel.value;
        opts[i].className="count-option"+(on?" selected":"");
        opts[i].setAttribute("aria-selected",on?"true":"false");
      }
    }
  }

  function positionCountPopup(owner,pop){
    if(!owner || !pop) return;
    var field=owner.querySelector(".count-field");
    if(!field || !field.getBoundingClientRect) return;
    var r=field.getBoundingClientRect();
    var doc=document.documentElement||{};
    var vw=window.innerWidth||doc.clientWidth||320;
    var vh=window.innerHeight||doc.clientHeight||480;
    var margin=8, gap=3;
    var width=Math.max(156,Math.round(r.width||156));
    width=Math.min(width,Math.max(156,vw-(margin*2)));
    var left=Math.max(margin,Math.min(Math.round(r.left),vw-width-margin));
    var natural=Math.min(240,Math.max(44,pop.scrollHeight||44));
    var below=Math.max(0,vh-r.bottom-margin-gap);
    var above=Math.max(0,r.top-margin-gap);
    var useAbove=below<Math.min(natural,120) && above>below;
    var room=useAbove?above:below;
    var height=Math.max(44,Math.min(natural,room||natural));
    pop.style.left=left+"px";
    pop.style.width=width+"px";
    pop.style.maxHeight=height+"px";
    pop.style.top=Math.round(useAbove?(r.top-gap-height):(r.bottom+gap))+"px";
  }

  function closeCountPicker(){
    if(openCountOwner && openCountPop){
      var btn=openCountOwner.querySelector(".count-field");
      openCountPop.className="count-pop";
      openCountPop.removeAttribute("style");
      openCountOwner.appendChild(openCountPop);
      openCountOwner.className="count-picker";
      if(btn) btn.setAttribute("aria-expanded","false");
    }
    openCountOwner=null; openCountPop=null;
  }

  function openCountPicker(id){
    closeCountPicker();
    closeAllMS();
    var owner=$(id+"Picker"), pop=$(id+"Menu"), btn=$(id+"Button");
    if(!owner || !pop || !btn) return;
    openCountOwner=owner; openCountPop=pop;
    owner.className="count-picker open";
    btn.setAttribute("aria-expanded","true");
    document.body.appendChild(pop);
    pop.className="count-pop portal-open";
    positionCountPopup(owner,pop);
    syncCountPicker(id);
  }

  function fireSelectChange(sel){
    if(!sel) return;
    var ev=document.createEvent("HTMLEvents");
    ev.initEvent("change",true,false);
    sel.dispatchEvent(ev);
  }

  function setupCountPicker(id){
    var sel=$(id), btn=$(id+"Button"), pop=$(id+"Menu");
    if(!sel || !btn || !pop) return;
    var html="";
    for(var i=0;i<sel.options.length;i++){
      html+='<button class="count-option" type="button" role="option" data-value="'+
            esc(sel.options[i].value)+'">'+esc(sel.options[i].text)+'</button>';
    }
    pop.innerHTML=html;
    btn.addEventListener("click",function(e){
      e.stopPropagation();
      var owner=$(id+"Picker");
      var open=owner && owner.className.indexOf("open")>=0;
      closeCountPicker();
      if(!open) openCountPicker(id);
    });
    btn.addEventListener("keydown",function(e){
      var key=e.keyCode||0;
      if(key===13 || key===32 || key===38 || key===40){
        if(e.preventDefault)e.preventDefault();
        openCountPicker(id);
      }
    });
    var opts=pop.querySelectorAll(".count-option");
    for(var j=0;j<opts.length;j++){
      opts[j].addEventListener("click",function(e){
        if(e.stopPropagation)e.stopPropagation();
        sel.value=this.getAttribute("data-value");
        syncCountPicker(id);
        closeCountPicker();
        fireSelectChange(sel);
        btn.focus();
      });
    }
    pop.addEventListener("click",function(e){ e.stopPropagation(); });
    pop.addEventListener("wheel",function(e){
      var d=(e.deltaY!=null)?e.deltaY:(-(e.wheelDelta||0));
      this.scrollTop+=d;
      if(e.preventDefault)e.preventDefault();
      if(e.stopPropagation)e.stopPropagation();
      return false;
    });
    sel.addEventListener("change",function(){ syncCountPicker(id); });
    syncCountPicker(id);
  }

  /* ---- project folder ---- */
  function panelFolder(){ try { return appSupportDir("Adobe/CEP/extensions/MulticamSwitcherPanel"); } catch(e){ return ""; } }

  /* ---- AI self-heal: download the silero model + ONNX runtime if missing ----
   * The AI files are large vendored binaries that the installer bundles but the
   * in-panel code update does NOT carry. If a machine is missing them, the panel
   * silently fell back to crude loudness detection (under-serving quiet speakers).
   * This downloads them on demand from the SAME CDNs fetch_vendor uses, so AI
   * self-repairs instead of degrading. Writes into the panel's own folder. */
  var AI_ORT_VER = "1.14.0";   // pinned for Premiere's CEF 88, matches fetch_vendor
  function aiDownload(url, dest, cb){
    var https, http;
    try { https = require("https"); http = require("http"); } catch(e){ cb(false, "node-https-unavailable"); return; }
    var tmp = dest + ".part", redirects = 0, settled = false;
    function finish(ok, reason){
      if(settled) return;
      settled = true;
      if(!ok){ try { if(fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch(_){} }
      cb(ok, reason);
    }
    function go(u){
      var mod = (u.indexOf("http://")===0) ? http : https;
      var req;
      try {
        req = mod.get(u, function(res){
          if(res.statusCode>=300 && res.statusCode<400 && res.headers.location && redirects<6){
            redirects++; res.resume();
            var loc = res.headers.location;
            if(loc.indexOf("http")!==0){ var m=u.match(/^https?:\/\/[^\/]+/); loc=(m?m[0]:"")+loc; }
            go(loc); return;
          }
          if(res.statusCode!==200){ res.resume(); finish(false, "http "+res.statusCode); return; }
          var out = fs.createWriteStream(tmp);
          res.pipe(out);
          out.on("finish", function(){ out.close(function(){ try { fs.renameSync(tmp, dest); finish(true); } catch(e){ finish(false, String(e)); } }); });
          out.on("error", function(e){ finish(false, String(e)); });
        });
        req.on("error", function(e){ finish(false, String(e)); });
        req.setTimeout(90000, function(){ try{ req.destroy(); }catch(_){}; finish(false, "timeout"); });
      } catch(e){ finish(false, String(e)); }
    }
    go(url);
  }
  // v1.05 (audit #7): pinned SHA-256 for every self-heal download (hashes of the
  // vendored files the installer ships; verified to match the pinned CDN URLs).
  // A minimum-size check alone would accept a tampered or corrupted file, and
  // ort.min.js is executable code — so verify-or-delete after each download.
  var AI_SHA256 = {
    "silero_vad.onnx": "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3",
    "ort.min.js":      "bf72399ee0e749222bc7c0dd1cf3da41b66fc7ef89f437127944199d605ecffe",
    "ort-wasm.wasm":   "bbdcb6b3c7d294577d806077630460be4e13ca35a345acb5e81188e9649fa74a"
  };
  function sha256File(p){
    try { return crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex"); }
    catch(e){ return ""; }
  }
  // Download any missing AI files; calls done(changed:boolean). Sequential, with URL fallbacks.
  function aiEnsure(done){
    var dir = panelFolder();
    if(!dir || !fs){ done(false); return; }
    var need = [
      { name:"silero_vad.onnx", f:dir+"/models/silero_vad.onnx", min:500000,
        // Pinned to the same immutable commit fetch_vendor uses — @master would
        // eventually drift from AI_SHA256 and turn self-heal into a delete loop.
        urls:["https://cdn.jsdelivr.net/gh/snakers4/silero-vad@bfdc0193023f121ea5b3cc7b176dbed570a68a59/src/silero_vad/data/silero_vad.onnx",
              "https://raw.githubusercontent.com/snakers4/silero-vad/bfdc0193023f121ea5b3cc7b176dbed570a68a59/src/silero_vad/data/silero_vad.onnx"] },
      { name:"ort.min.js", f:dir+"/vendor/ort.min.js", min:100000,
        urls:["https://cdn.jsdelivr.net/npm/onnxruntime-web@"+AI_ORT_VER+"/dist/ort.min.js"] },
      { name:"ort-wasm.wasm", f:dir+"/vendor/ort-wasm.wasm", min:100000,
        urls:["https://cdn.jsdelivr.net/npm/onnxruntime-web@"+AI_ORT_VER+"/dist/ort-wasm.wasm"] }
    ];
    function present(n){
      try {
        if(!fs.existsSync(n.f) || fs.statSync(n.f).size < n.min) return false;
        var want = AI_SHA256[n.name];
        if(!want || sha256File(n.f) === want) return true;
        log("[ai] Existing "+n.name+" failed its integrity check — replacing it.");
        try { fs.unlinkSync(n.f); } catch(_){}
      } catch(e){}
      return false;
    }
    var missing = need.filter(function(n){ return !present(n); });
    if(!missing.length){ done(false); return; }
    try { if(!fs.existsSync(dir+"/models")) fs.mkdirSync(dir+"/models",{recursive:true}); } catch(e){}
    try { if(!fs.existsSync(dir+"/vendor")) fs.mkdirSync(dir+"/vendor",{recursive:true}); } catch(e){}
    status("Installing AI voice detection (one-time download)…");
    log("[ai] AI files missing ("+missing.map(function(m){return m.name;}).join(", ")+") — downloading from CDN…");
    var i=0, anyFail=false;
    (function nextFile(){
      if(i>=missing.length){
        if(anyFail){ log("[ai] Some AI files failed to download — check internet and relaunch."); status("AI download didn't finish — check internet and relaunch the panel.", true); }
        else { log("[ai] AI files installed."); }
        done(!anyFail);
        return;
      }
      var m=missing[i++];
      (function tryUrl(k){
        if(k>=m.urls.length){ anyFail=true; log("[ai] FAILED to download "+m.name); nextFile(); return; }
        aiDownload(m.urls[k], m.f, function(ok){
          // v1.05 (audit #7): pin the content, not just the size — delete and
          // report on a SHA-256 mismatch instead of trusting whatever arrived.
          if(ok && AI_SHA256[m.name]){
            var got = sha256File(m.f);
            if(got !== AI_SHA256[m.name]){
              try { fs.unlinkSync(m.f); } catch(_){}
              log("[ai] "+m.name+" failed its integrity check (SHA-256 mismatch) — deleted, trying the next source.");
              ok = false;
            }
          }
          if(ok){ log("[ai] downloaded "+m.name); nextFile(); }
          else { tryUrl(k+1); }
        });
      })(0);
    })();
  }
  // True if the AI voice model is loaded and ready right now.
  function voiceReady(){ try { return typeof OnMicVAD!=="undefined" && OnMicVAD.isReady && OnMicVAD.isReady(); } catch(e){ return false; } }
  function detectProjectFolder(){
    try { var p=panelFolder()+"/project_path.txt"; if(fs&&fs.existsSync(p)){ var t=fs.readFileSync(p,"utf8").replace(/\s+$/,"").replace(/\/+$/,""); if(t) return t; } } catch(e){}
    try { var r=window.localStorage.getItem("mcs_folder"); if(r) return r; } catch(e){}
    return "";
  }
  function projectFolder(){ return ($("projectFolder").value||"").replace(/\/+$/,""); }
  // Writable working dir for config.json / cuts.json. Uses the dev "project
  // folder" if set, otherwise a per-user app-support dir (distribution default).
  function supportDir(){
    try { var d=appSupportDir("OnMic"); if(fs && !fs.existsSync(d)) fs.mkdirSync(d,{recursive:true}); return d; } catch(e){ return ""; }
  }
  function workDir(){ return projectFolder() || supportDir(); }
  function configPath(){ return workDir()+"/config.json"; }
  function cutsPath(){ return workDir()+"/cuts.json"; }

  /* ---- read tracks (silent) ---- */
  // Default speaker name. Use the track's name only if it's a real label
  // (not "Audio 1" and not a media filename); otherwise a distinct "Speaker N"
  // so the four fields never all default to the same thing (e.g. "MixPre-002").
  function nameGuess(t, i){
    var def = "Speaker " + ((i || 0) + 1);
    if (!t) return def;
    var nm = (t.name || "").replace(/^\s+|\s+$/g, "");
    if (nm && !/^audio ?\d*$/i.test(nm) && !/\.(wav|aif|aiff|mp3|m4a|mov|mp4|mxf)$/i.test(nm))
      return nm;
    return def;
  }
  function readTracks(cb){
    CEP.evalScript("mcsReadTracks()", function(res){
      var r; try { r=JSON.parse(res); } catch(e){ r=null; }
      if (!r || !r.ok){
        // v1.03: surface the failure instead of silently keeping stale track
        // data (the pipeline would then edit from an outdated clip layout).
        log("[tracks] Couldn't read the sequence"+(r&&r.message?(": "+r.message):".")+" Open the right sequence and click Re-read tracks.");
        status("Couldn't read the sequence — click Re-read tracks.", true);
        if(cb)cb(false); return;
      }
      // v1.03: refresh the frame rate on EVERY track read, not just ping() —
      // creating an edit right after switching sequences used the old fps.
      if (r.fps && r.fps>0) SEQ_FPS=Math.round(r.fps*1000)/1000;
      curSeqId = (r.seqId!=null) ? (""+r.seqId) : "";
      curSeqName = r.seqName || "";
      curTimelineFingerprint = r.timelineFingerprint || "";
      audioTracks = (r.audio||[]).map(function(t){ return {
        index:t.index, name:t.name, clipName:t.clipName, mediaPath:t.mediaPath,
        startSec:t.startSec, inPointSec:t.inPointSec, numClips:t.numClips,
        locked:!!t.locked, sourceChannel:cleanSourceChannel(t.sourceChannel),
        gainDb:(+t.gainDb||0), clips:(t.clips||[]), channel:0, offset_s:0 }; });
      var byPath={};
      audioTracks.forEach(function(t){ if(t.mediaPath){ (byPath[t.mediaPath]=byPath[t.mediaPath]||[]).push(t); } });
      audioTracks.forEach(function(t){ var g=byPath[t.mediaPath]; t.channel=(g&&g.length>1)?g.indexOf(t):0; t.offset_s=Math.round((t.startSec-t.inPointSec)*1000)/1000; });
      videoTracks = (r.video||[]).map(function(v){ return { index:v.index, name:v.name, numClips:v.numClips, locked:!!v.locked, clips:(v.clips||[]) }; });

      if (firstRead){
        firstRead=false;
        var mics=audioTracks.filter(function(t){ return t.numClips>0 && t.mediaPath; }).length;
        if (mics>=2) $("numSpeakers").value=String(clampCount(mics));
        var cams=videoTracks.filter(function(v){ return v.numClips>0; }).length;
        if (cams>=2) $("numCameras").value=String(clampCount(cams));
      }
      detectChannels(function(){ rebuildSpeakers(); if (cb) cb(true); });
    });
  }

  // Inspect the mic source file to find isolated vs mix channels (in-panel, no Python).
  // v1.05 (audit #7): cache the result per file signature — this ran on EVERY
  // track read (boot, re-reads, before each Preview/Apply/Trim), re-decoding 60s
  // of every channel of identical media each time and stalling the UI.
  // Invalidates automatically when the file's size/mtime changes.
  var chanCache = {};
  function detectChannels(done){
    channelInfo = null;
    var path = "";
    for (var i=0;i<audioTracks.length;i++){ if (audioTracks[i].mediaPath){ path=audioTracks[i].mediaPath; break; } }
    if (!path || !Engine){ done(); return; }
    var sig = fileSig(path);
    var sigKey = (sig && sig.size>0) ? (sig.path+"|"+sig.size+"|"+sig.mtime) : "";
    if (sigKey && chanCache[sigKey]){
      channelInfo = chanCache[sigKey];
    } else {
      try { channelInfo = Engine.detectChannels(path); if (channelInfo) channelInfo._path = path; } catch(e){ channelInfo=null; }
      if (channelInfo && sigKey) chanCache[sigKey] = channelInfo;
      if (channelInfo && channelInfo.mix && channelInfo.mix.length)
        log("Detected MIX channels (skipped): ch"+channelInfo.mix.join(", ch")+
            "  ·  isolated mics: ch"+(channelInfo.suggested_iso||[]).join(", ch"));
    }
    // Safety: if the count of isolated mic channels doesn't match the speaker
    // count, the auto-mapping can silently pair a speaker to the wrong channel.
    // Surface it so the user verifies the speaker count/timeline before analyzing.
    if (channelInfo && channelInfo.suggested_iso){
      var nIso = channelInfo.suggested_iso.length;
      if (nIso > 0 && nIso !== iSp()){
        log("[channels] NOTE: "+nIso+" isolated mic channel(s) found but "+iSp()+" speaker(s) set — match the Speakers count to the isolated mics before analyzing.");
        status("Note: "+nIso+" mic channel(s) detected vs "+iSp()+" speaker(s) selected.", true);
      }
    }
    done();
  }

  function timelineSourceChannelLabel(){
    var rows=[];
    for (var i=0;i<iSp();i++){
      var t=audioTracks[i];
      var ch=explicitTrackSourceChannel(t);
      if (ch != null) rows.push("A"+(i+1)+" → Ch. "+(ch+1));
    }
    return rows.join(", ");
  }
  function automaticChannelPlanLabel(cfg){
    try {
      var rows=[];
      var speakers=(cfg&&cfg.speakers)||[];
      for(var i=0;i<speakers.length;i++){
        rows.push(speakers[i].name+" → Ch. "+((speakers[i].channel|0)+1));
      }
      return rows.join(", ");
    } catch(e){ return ""; }
  }

  /* ---- speaker (A) rows ---- */
  function rebuildSpeakers(){
    syncCountPicker("numSpeakers");
    var n=iSp();
    var savedN=[];
    for (var k=0;k<12;k++){ var e=$("aname"+k); if(e) savedN[k]=e.value; }   // v1.03: was 8 — names 9-12 were wiped on every rebuild
    var html="";
    for (var i=0;i<n;i++){
      html += '<div class="field speaker-row"><label>A'+(i+1)+'</label><div class="val">'+
              '<input type="text" class="name" id="aname'+i+'" placeholder="Enter name.."/></div></div>';
    }
    $("speakerNames").innerHTML=html;
    for (var j=0;j<n;j++){
      if($("aname"+j)){ if(savedN[j]!==undefined && savedN[j]!=="") $("aname"+j).value=savedN[j]; else $("aname"+j).value=nameGuess(audioTracks[j], j); }
    }
    rebuildTags();
  }

  function speakerList(){
    var n=iSp(), out=[];
    for (var i=0;i<n;i++){
      var v=$("aname"+i)?$("aname"+i).value.replace(/^\s+|\s+$/g,""):"";
      out.push({ slot:i, name: v || nameGuess(audioTracks[i], i) });
    }
    return out;
  }

  /* ---- Tag Speakers (V) rows -- custom multi-select ---- */
  var openMSOwner=null, openMSPop=null;

  function positionMSPopup(ms,pop){
    if(!ms || !pop) return;
    var field=ms.querySelector(".ms-field");
    if(!field || !field.getBoundingClientRect) return;
    var r=field.getBoundingClientRect();
    var doc=document.documentElement||{};
    var vw=window.innerWidth||doc.clientWidth||320;
    var vh=window.innerHeight||doc.clientHeight||480;
    var margin=8, gap=3;
    var width=Math.max(160,Math.round(r.width||156));
    width=Math.min(width,Math.max(160,vw-(margin*2)));
    var left=Math.max(margin,Math.min(Math.round(r.left),vw-width-margin));
    var natural=Math.min(240,Math.max(44,pop.scrollHeight||44));
    var below=Math.max(0,vh-r.bottom-margin-gap);
    var above=Math.max(0,r.top-margin-gap);
    var useAbove=below<Math.min(natural,120) && above>below;
    var room=useAbove?above:below;
    var height=Math.max(44,Math.min(natural,room||natural));
    pop.style.left=left+"px";
    pop.style.width=width+"px";
    pop.style.maxHeight=height+"px";
    pop.style.top=Math.round(useAbove?(r.top-gap-height):(r.bottom+gap))+"px";
  }

  function openMS(ms){
    closeAllMS();
    if(!ms) return;
    var pop=ms.querySelector(".ms-pop");
    if(!pop) return;
    openMSOwner=ms; openMSPop=pop;
    ms.className="ms open";
    document.body.appendChild(pop);
    pop.className="ms-pop portal-open";
    positionMSPopup(ms,pop);
  }

  function rebuildTags(){
    syncCountPicker("numCameras");
    closeAllMS();
    var nCam=iCam(), sp=speakerList();
    var html="";
    for (var v=0; v<nCam; v++){
      html += '<div class="field camera-row"><label>V'+(v+1)+'</label><div class="val">'+
              '<div class="ms" id="ms'+v+'"><div class="ms-field" id="msf'+v+'">Select speakers..</div><div class="ms-pop">';
      for (var s=0;s<sp.length;s++){
        var on=(camSel[v]&&camSel[v][sp[s].slot])?"checked":"";
        html += '<label><input type="checkbox" data-v="'+v+'" data-s="'+sp[s].slot+'" '+on+'/> '+esc(sp[s].name)+'</label>';
      }
      html += '</div></div></div></div>';
    }
    $("cameraTags").innerHTML=html;
    // wire field toggles
    for (var vv=0; vv<nCam; vv++){
      (function(idx){
        $("msf"+idx).addEventListener("click", function(e){
          e.stopPropagation();
          var ms=$("ms"+idx); var open=ms.className.indexOf("open")>=0;
          closeAllMS(); if(!open) openMS(ms);
        });
      })(vv);
    }
    var pops=$("cameraTags").querySelectorAll(".ms-pop");
    for(var p=0;p<pops.length;p++){
      pops[p].addEventListener("click", function(e){ e.stopPropagation(); });
      pops[p].addEventListener("wheel", function(e){
        var d=(e.deltaY!=null)?e.deltaY:(-(e.wheelDelta||0));
        this.scrollTop+=d;
        if(e.preventDefault)e.preventDefault();
        if(e.stopPropagation)e.stopPropagation();
        return false;
      });
    }
    var boxes=$("cameraTags").querySelectorAll('input[type="checkbox"]');
    for (var b=0;b<boxes.length;b++){
      boxes[b].addEventListener("change", function(){
        var v=+this.getAttribute("data-v"), s=+this.getAttribute("data-s");
        camSel[v]=camSel[v]||{}; camSel[v][s]=this.checked; updateMSField(v);
        try { renderChecklist(); } catch(e){}
        clearPreview();
      });
    }
    for (var u=0; u<nCam; u++) updateMSField(u);
    try { renderChecklist(); } catch(e){}   // keep the "Before you cut" checklist in sync
  }
  function closeAllMS(){
    if(openMSOwner && openMSPop){
      openMSPop.className="ms-pop";
      openMSPop.removeAttribute("style");
      openMSOwner.appendChild(openMSPop);
    }
    openMSOwner=null; openMSPop=null;
    var all=document.querySelectorAll(".ms");
    for(var i=0;i<all.length;i++) all[i].className="ms";
  }
  function camNames(v){
    var sp=speakerList(), names=[];
    sp.forEach(function(s){ if(camSel[v]&&camSel[v][s.slot]) names.push(s.name); });
    return names;
  }
  function updateMSField(v){
    var f=$("msf"+v); if(!f) return;
    var names=camNames(v);
    f.textContent = names.length ? names.join(", ") : "Select speakers..";
    f.style.color = names.length ? "#eee" : "#cfcfcf";
  }

  /* ---- params ---- */
  function applyPace(){
    var p=PACE[val("pace")]||PACE.standard;
    $("delayCuts").value = (p.switch_delay_ms/1000);
    $("ignoreCuts").value = p.min_shot_s;
  }
  // v1.05 (audit #7): numeric backstop. A blanked/garbage field parses to NaN,
  // which used to flow into the engine as min_shot_s / switch_delay_ms and
  // silently produce ONE segment for the whole episode that still reported
  // success. validateUI() shows the clear error; this clamp keeps the engine
  // safe no matter what.
  function numOr(raw, def, lo, hi){
    var v = parseFloat(raw);
    if (!isFinite(v)) return def;
    if (v < lo) return lo;
    if (v > hi) return hi;
    return v;
  }
  function buildParams(){
    var p=PACE[val("pace")]||PACE.standard;
    return {
      min_shot_s: numOr(val("ignoreCuts"), p.min_shot_s, 0, 120),
      max_shot_s: p.max_shot_s,
      switch_delay_ms: Math.round(numOr(val("delayCuts"), p.switch_delay_ms/1000, 0, 30)*1000),
      lead_offset_ms: p.lead_offset_ms,
      speaking_threshold_db: -40,
      dominance_margin_db: p.dominance_margin_db,
      overlap_min_ms: p.overlap_min_ms,
      min_interject_s: p.min_interject_s,
      overlap_to_wide: true,
      vad_enabled: true,
      wide_frequency: numOr(val("wideFreq"), 0.25, 0, 1),
      output_mode: outputMode(),
      snap_to_pause: $("snapPause") ? !!$("snapPause").checked : true,
      reaction_frequency: $("reactionFreq") ? (parseInt(val("reactionFreq"),10)||0) : 0,
      establish_wide_frequency: (p.establish_wide_frequency!=null ? p.establish_wide_frequency : 0),
      establish_len_ms: (p.establish_len_ms!=null ? p.establish_len_ms : 1800),
      establish_min_pause_ms: (p.establish_min_pause_ms!=null ? p.establish_min_pause_ms : 700)
    };
  }

  /* ---- build + validate ---- */
  function buildConfig(){
    var nSp=iSp(), nCam=iCam(), sp=speakerList();
    var speakers=[];
    var pathTrackUses={}, channelCounts={};
    audioTracks.forEach(function(track){
      var seen={};
      (track.clips||[]).forEach(function(c){ if(c.mediaPath) seen[c.mediaPath]=true; });
      Object.keys(seen).forEach(function(path){ pathTrackUses[path]=(pathTrackUses[path]||0)+1; });
    });
    Object.keys(pathTrackUses).forEach(function(path){ channelCounts[path]=sourceChannelCount(path); });
    for (var i=0;i<nSp;i++){
      var t=audioTracks[i]||{};
      var ch=chan(i);
      var primaryPath = t.mediaPath || "";
      var clips=(t.clips||[]).map(function(c){
        var cch=clipSourceChannel(ch,primaryPath,c.mediaPath,pathTrackUses,channelCounts,c.sourceChannel);
        return { wav:c.mediaPath, channel:cch,
                 seq_start_s:c.startSec, dur_s:Math.max(0,(c.endSec-c.startSec)), src_in_s:c.inPointSec,
                 gain_db:(+c.gainDb||+t.gainDb||0) };
      });
      var spk={ name:sp[i].name, wav:(t.mediaPath||""), channel:ch, offset_s:(t.offset_s||0), gain_db:(+t.gainDb||0) };
      if (clips.length) spk.clips=clips;   // clips-mode => exact timeline reconstruction
      speakers.push(spk);
    }
    var cameras=[];
    for (var v=0;v<nCam;v++){ var names=camNames(v); cameras.push({ track:v+1, name:names.join(","), speakers:names }); }
    return { fps:SEQ_FPS, speakers:speakers, cameras:cameras, params:buildParams() };
  }
  function validateUI(cfg){
    var e=[];
    if (!workDir()) e.push("No writable working folder available. Quit and reopen Premiere; if it persists, copy diagnostics and contact support.");
    // v1.05 (audit #7): a blanked/non-numeric Advanced field must be a clear
    // error up front, not a silent NaN (see numOr in buildParams).
    [["ignoreCuts","Ignore Cuts Less Than"],["delayCuts","Delay Cuts"]].forEach(function(f){
      var v=parseFloat(val(f[0]));
      if (!isFinite(v)) e.push('"'+f[1]+'" (Advanced) needs a number — it is blank or not a number.');
      else if (v<0) e.push('"'+f[1]+'" (Advanced) can\'t be negative.');
    });
    function audioLooksReadable(p){ return /\.(wav|wave)$/i.test(String(p||"").split("?")[0]); }
    var seen={};
    cfg.speakers.forEach(function(s,i){
      if (!s.wav) e.push("A"+(i+1)+' ("'+s.name+'") has no audio — is there an audio track '+(i+1)+" with a clip? Click Re-read tracks.");
      else {
        try { if (fs&&!fs.existsSync(s.wav)) e.push("Audio file not found for "+s.name+":\n  "+s.wav); } catch(x){}
        if (!audioLooksReadable(s.wav)) e.push("A"+(i+1)+' ("'+s.name+'") is not a WAV file. OnMic needs isolated WAV mic audio; export or relink that mic as WAV, then re-read tracks.');
      }
      (s.clips||[]).forEach(function(c,ci){
        if (!c.wav) return;
        try { if (fs&&!fs.existsSync(c.wav)) e.push("Audio file not found for "+s.name+" clip "+(ci+1)+":\n  "+c.wav); } catch(x2){}
        if (!audioLooksReadable(c.wav)) e.push("A"+(i+1)+' ("'+s.name+'") clip '+(ci+1)+" is not a WAV file. OnMic needs isolated WAV mic audio; export or relink that mic as WAV, then re-read tracks.");
      });
      var k=s.name.toLowerCase(); if(seen[k]) e.push('Two speakers are named "'+s.name+'". Names must be unique.'); seen[k]=true;
    });
    var covered={};
    cfg.cameras.forEach(function(c){
      if (!c.speakers.length) e.push("V"+c.track+" has no speakers tagged.");
      c.speakers.forEach(function(n){ covered[n.toLowerCase()]=true; });
    });
    cfg.speakers.forEach(function(s){ if(!covered[s.name.toLowerCase()]) e.push('"'+s.name+'" isn\'t tagged on any camera.'); });
    return e;
  }
  function writeConfig(cfg){
    var ex={}; try { ex=JSON.parse(fs.readFileSync(configPath(),"utf8")); } catch(e){}
    ex.fps=cfg.fps; ex.speakers=cfg.speakers; ex.cameras=cfg.cameras; ex.params=cfg.params;
    fs.writeFileSync(configPath(), JSON.stringify(ex,null,2),"utf8");
  }

  /* ---- pipeline ---- */
  var cancelRequested=false;
  var previewReady=false;
  function setBusy(b){
    // v1.05 (audit #7): "backupTimeline" included so the backup toggle can't be
    // flipped mid-apply (the finish report reflects the value captured at begin).
    var ids=["btnGo","btnPreview","btnTrim","reread","btnCheckSeq","btnDetectCams","btnProfileSave","btnProfileLoad","btnProfileDel","btnProfileExport","btnProfileImport","btnApply","btnMarkers","btnResetLocal","backupTimeline"];
    for(var i=0;i<ids.length;i++){ var el=$(ids[i]); if(el) el.disabled=b; }
  }
  function clearPreview(){
    previewReady=false;
    var c=$("confirmBox"); if(c) c.style.display="none";
  }
  function setupChanged(){
    try { renderChecklist(); } catch(e){}   // backup toggle / counts affect the checklist
    clearPreview();
  }
  function showProg(){ $("progWrap").style.display="block"; var st=$("btnStop"); if(st) st.style.display="block"; setProg(0); }
  function hideProg(){ $("progWrap").style.display="none"; var st=$("btnStop"); if(st) st.style.display="none"; }
  function setProg(frac){ $("progBar").style.width=Math.max(0,Math.min(100,Math.round(frac*100)))+"%"; }
  function stopAnalysis(){
    cancelRequested=true;
    status("Stopping after the current audio chunk finishes...", true);
  }
  function checkCancelled(){
    if(cancelRequested) throw new Error("cancelled");
  }

  // v1.05 (audit #7): true if EVERY mic clip in the current setup already has an
  // AI voice mask in the memory/disk cache. Masks are only ever SAVED when
  // silero actually ran (see runAnalysis), and runAnalysis loads them whether or
  // not the live model is ready — so a fully covered run is AI-quality even with
  // the model missing, and the scary loudness-fallback warning would be wrong.
  function allClipsHaveCachedMasks(){
    try {
      var cfg = buildConfig();
      var hopS = (cfg.params.analysis_hop_ms||20)/1000;
      var any = false;
      for (var i=0;i<cfg.speakers.length;i++){
        var clips = cfg.speakers[i].clips||[];
        for (var ci=0;ci<clips.length;ci++){
          var c = clips[ci];
          if (!c.wav) return false;
          var ck = vadCacheKey(c, hopS);
          if (!ck) return false;
          if (!vadMaskCache[ck] && !loadVadMaskFromDisk(ck, Math.ceil((c.dur_s||0)/hopS))) return false;
          any = true;
        }
      }
      return any;
    } catch(e){ return false; }
  }

  // Always re-read the sequence first so trims/edits are never stale.
  function runPipeline(action){
    if (!gateAllowed){ status("Sign in to use OnMic.",true); checkAuth(); return; }
    status("Re-reading tracks…");
    readTracks(function(readOk){
      if (readOk===false){ status("Couldn't read the sequence — nothing was run. Open the right sequence and try again.", true); return; }
      // Guard: don't let a real edit run on the crude loudness fallback without the
      // user knowing. AI voice detection is what makes quiet speakers switch correctly;
      // without it, OnMic under-serves them. Warn (and offer to wait for the download)
      // rather than silently producing a worse edit.
      // v1.04: dead-air trim is DESTRUCTIVE (ripple-deletes silence), so it gets
      // the AI-not-ready warning too — on the loudness fallback a quiet speaker's
      // speech can be misread as silence and deleted.
      // v1.05 (audit #7): checked AFTER the re-read so clip coverage is fresh, and
      // SKIPPED when every clip already has a disk-cached AI mask (those get
      // loaded regardless of the live model, so the run is full AI quality).
      function proceed(){
        // Proactive linter: surface likely setup problems in plain language BEFORE
        // committing to a full edit. Non-blocking — it only shows the panel; the run
        // continues. (validateUI() in runPipelineInner is still the hard gate.)
        if (action==="apply" || action==="preview"){
          try { renderLint(lintSequence()); } catch(e){}
        }
        runPipelineInner(action);
      }
      if ((action==="apply" || action==="preview" || action==="trim") && !voiceReady()){
        if (allClipsHaveCachedMasks()){
          log("[info] The AI voice model isn't loaded, but every mic clip in this run has a saved AI voice mask — using those (no quality loss).");
        } else {
          var trimExtra = (action==="trim") ? "\n\nThis is Trim dead air, which DELETES timeline sections it thinks are silent — on the fallback it may cut a quiet speaker's words." : "";
          confirmDlg({
            title: "AI voice detection isn't installed yet",
            msg: "Without it, OnMic uses a much less accurate loudness-only fallback — quiet speakers often won't get the camera. OnMic tries to download the AI automatically (watch the log); give it a minute and relaunch the panel if it just finished."+trimExtra,
            yes: "Run the lower-quality "+(action==="trim"?"trim":"edit")+" anyway",
            no: "Wait for the AI (recommended)"
          }, function(ok){
            if(!ok){
              status("Waiting on AI voice detection — check the log; relaunch the panel once it says 'AI voice detection ready'.", true);
              // kick a download attempt in case it hasn't started
              try { aiEnsure(function(){}); } catch(e){}
              return;
            }
            proceed();
          });
          return;
        }
      }
      proceed();
    });
  }
  function fmtDur(s){ var h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=(s%60); function p(n){return(n<10?"0":"")+n;} return p(h)+":"+p(m)+":"+(x<10?"0":"")+x.toFixed(2); }
  function logDiag(out){
    var d=out.diag; if(!d) return;
    log("[diag] detection over "+fmtDur(d.duration_s)+":");
    d.speakers.forEach(function(r){ log("  "+r.name+"  on-screen "+r.onscreen+"% · talks "+r.talks+"% · +"+r.gain+"dB · peak "+r.peak+"dB"); });
    log("[diag] silence "+d.silence+"%");
  }
  // Visual speaking-balance summary (talk% and on-screen% per speaker).
  function renderBalance(out){
    var d=out&&out.diag, el=$("balance"); if(!d||!el) return;
    var reactions=(out.segments||[]).filter(function(s){ return s.reason==="reaction"; }).length;
    var rows=d.speakers.map(function(r){
      return '<div class="brow"><span class="bname">'+esc(r.name)+'</span>'+
        '<span class="bgrp"><span class="bbar talk"><i style="width:'+Math.min(100,r.talks)+'%"></i></span><span class="bpct">'+r.talks+'% talk</span></span>'+
        '<span class="bgrp"><span class="bbar screen"><i style="width:'+Math.min(100,r.onscreen)+'%"></i></span><span class="bpct">'+r.onscreen+'% screen</span></span>'+
      '</div>';
    }).join("");
    el.innerHTML='<div class="btitle">Speaking balance</div>'+rows+
      '<div class="bfoot">Silence '+d.silence+'%'+(reactions?(' · '+reactions+' reaction shot'+(reactions===1?'':'s')):'')+'</div>';
    el.style.display="block";
  }
  function renderRunWarnings(out){
    var d=out&&out.diag, el=$("balance"); if(!d||!el) return;
    var warns=[];
    if(d.silence>=55) warns.push("More than half the timeline looks silent. Check the mic tracks and trim settings before applying.");
    (d.speakers||[]).forEach(function(r){
      if(r.talks<=1 && r.peak<-45) warns.push(r.name+"'s mic looks empty or mostly noise. Check that this is the right isolated WAV before applying.");
      else if(r.talks<=3 && r.gain>=44) warns.push(r.name+"'s source audio is still extremely quiet after any Premiere gain OnMic could read. Check the source channel if this speaker should be cutting more often.");
      else if(r.talks<=3) warns.push(r.name+" barely registered as talking. Check that speaker's mic channel.");
      if(r.onscreen<=2 && r.talks>=8) warns.push(r.name+" talks but is almost never on screen. Check camera tags for that speaker.");
    });
    if(!warns.length) return;
    var html='<div class="awarn"><div class="awtitle">Check before applying</div>';
    for(var i=0;i<warns.length;i++) html+='<div class="awitem">'+esc(warns[i])+'</div>';
    html+='</div>';
    el.innerHTML += html;
    log("[check] "+warns.join(" | "));
  }

  /* ---- "hours saved" value dashboard ----
     Honest, transparent estimate of the manual multicam work OnMic just did for
     the editor. We have two facts from the engine: the number of camera switches
     (cuts) and the program duration. A human editing multicam by hand does two
     kinds of work, so we BLEND two estimates and take the larger (the bottleneck):
       (a) per-cut work: every switch means scrubbing to the moment, finding the
           talker, razoring and assigning a camera ≈ ~30s of fiddling per cut.
       (b) per-minute work: even with few cuts you still review the whole episode
           in a multicam workflow ≈ ~3 min of editor time per minute of program.
     estimate_seconds = max(cuts*30, episode_minutes*3*60). Deliberately
     conservative and always shown as "about ~". */
  var EDIT_REALTIME_MULT = 1.3;   // a careful manual multicam pass ≈ 1.3x the program length
  function cutsFromOut(out){
    // a "cut" is a camera switch = a boundary between two adjacent segments.
    var segs=(out&&out.segments)||[]; return segs.length>0 ? (segs.length-1) : 0;
  }
  function estimateSavedSeconds(cuts, durationS){
    // Manual multicam editing time tracks PROGRAM LENGTH (you watch through and
    // switch as you go) — NOT 30s per cut, which wildly overcounts a fast podcast.
    // Add a small density bump for very cut-heavy episodes, capped so it can never
    // dominate the length-based estimate.
    var base = (durationS||0) * EDIT_REALTIME_MULT;
    var dense = Math.min((cuts||0) * 1.0, (durationS||0) * 0.25);
    var est = base + dense;
    return est>0 ? est : 0;
  }
  function fmtHrMin(sec){
    sec=Math.round(sec||0);
    var h=Math.floor(sec/3600), m=Math.round((sec%3600)/60);
    if (h<=0 && m<=0) return "under a minute";
    if (h<=0) return "~"+m+" min";
    return "~"+h+" hr"+(m>0?(" "+m+" min"):"");
  }
  function fmtMMSS(sec){
    sec=Math.round(sec||0); var m=Math.floor(sec/60), s=sec%60;
    return m+":"+(s<10?"0":"")+s;
  }
  // A deliberately fuzzy range for the savings estimate so it never reads as an
  // exact figure (e.g. "20–40 min"). ±33% around the conservative point estimate.
  function fmtSavedRange(sec){
    sec=Math.round(sec||0);
    if(sec<=0) return "a few minutes";
    var lo=sec*0.75, hi=sec*1.25;
    return fmtHrMin(lo).replace(/^~/,'')+"–"+fmtHrMin(hi).replace(/^~/,'');
  }
  function fmtPlainDur(sec){
    sec=Math.round(sec||0);
    var m=Math.floor(sec/60), s=sec%60;
    if(m<=0) return s+" sec";
    return m+" min "+(s<10?"0":"")+s+" sec";
  }
  // Lifetime "hours saved" stats ALWAYS live in the per-user private OnMic
  // app-support folder (~/Library/Application Support/OnMic on mac,
  // %APPDATA%/OnMic on win) — never beside a project/repo even when workDir()
  // points at a dev project folder. (resetLocalData() deletes the same path.)
  function statsPath(){ return supportDir()+"/onmic_stats.json"; }
  function readStats(){
    var def={ lifetime_seconds_saved:0, lifetime_cuts:0, episodes:0, applied_runs:{} };
    try {
      if (!fs || !fs.existsSync(statsPath())) return def;
      var o=JSON.parse(fs.readFileSync(statsPath(),"utf8"));
      if (!o || typeof o!=="object") return def;
      return {
        lifetime_seconds_saved: (+o.lifetime_seconds_saved>0 ? +o.lifetime_seconds_saved : 0),
        lifetime_cuts: (+o.lifetime_cuts>0 ? +o.lifetime_cuts : 0),
        episodes: (+o.episodes>0 ? +o.episodes : 0),
        applied_runs: (o.applied_runs && typeof o.applied_runs==="object") ? o.applied_runs : {}
      };
    } catch(e){ return def; }   // corrupt/unreadable → start fresh, never throw
  }
  function hashRun(out){
    try {
      if(!crypto || !out) return "";
      var payload=JSON.stringify({ seq_id:out.seq_id||"", duration_s:out.duration_s||0, segments:out.segments||[] });
      return crypto.createHash("sha256").update(payload).digest("hex");
    } catch(e){ return ""; }
  }
  function writeStats(s){ try { if(fs) fs.writeFileSync(statsPath(), JSON.stringify(s,null,2),"utf8"); } catch(e){} }
  function recordSaved(out){
    // called after a successful apply: increment the lifetime totals on disk.
    var cuts=cutsFromOut(out), dur=(out&&out.duration_s)||0;
    var saved=estimateSavedSeconds(cuts, dur);
    var s=readStats();
    var h=hashRun(out);
    if(h && s.applied_runs && s.applied_runs[h]) return { cuts:cuts, dur:dur, saved:saved, duplicate:true };
    s.lifetime_seconds_saved += saved;
    s.lifetime_cuts += cuts;
    s.episodes += 1;
    if(h){
      s.applied_runs=s.applied_runs||{};
      s.applied_runs[h]=Date.now();
      var keys=Object.keys(s.applied_runs).sort(function(a,b){ return s.applied_runs[b]-s.applied_runs[a]; });
      if(keys.length>100){ var keep={}; keys.slice(0,100).forEach(function(k){ keep[k]=s.applied_runs[k]; }); s.applied_runs=keep; }
    }
    writeStats(s);
    return { cuts:cuts, dur:dur, saved:saved };
  }
  // Render the per-run line + lifetime total into the balance card. `last` is the
  // {cuts,dur,saved} from this run (omit to show lifetime only, e.g. on load).
  function renderSaved(last){
    var el=$("balance"); if(!el) return;
    var s=readStats();
    var html='<div class="saved">';
    if (last && last.cuts!=null){
      html += '<div class="srow"><span class="sbig">~'+fmtHrMin(last.saved).replace(/^~/,'')+' saved</span>'+
              '<span class="sdesc">OnMic made '+last.cuts+' cut'+(last.cuts===1?'':'s')+
              ' across '+fmtMMSS(last.dur)+' — an estimated '+fmtSavedRange(last.saved)+
              ' of manual multicam editing avoided. This is a rough estimate, not exact.'+
              (last.duplicate?' This exact edit was already counted, so the lifetime total stayed the same.':'')+'</span></div>';
    }
    if (s.episodes>0){
      html += '<div class="slife">Estimated manual editing avoided so far: <b>~'+
              fmtHrMin(s.lifetime_seconds_saved).replace(/^~/,'')+'</b> across '+
              s.episodes+' edit'+(s.episodes===1?'':'s')+' (rough estimate).</div>';
    }
    html += '</div>';
    // append into the balance card if it's showing; otherwise show the card with
    // just the saved block (e.g. lifetime-only on load).
    if (el.style.display==="block" && el.innerHTML.indexOf("btitle")>=0){
      // strip any previous saved block, then append fresh
      el.innerHTML = el.innerHTML.replace(/<div class="saved">[\s\S]*?<\/div>\s*$/,"") + html;
    } else if (last && last.cuts!=null){
      el.innerHTML = html; el.style.display="block";
    } else if (s.episodes>0){
      el.innerHTML = html; el.style.display="block";
    }
  }
  function runPipelineInner(action){
    var cfg=buildConfig();
    var errs=validateUI(cfg);
    if (errs.length){ status("Fix these first:",true); clearLog(); log("- "+errs.join("\n- ")); return; }
    if (!Engine){ status("Engine did not load. Reopen Premiere; if it persists, copy diagnostics and contact support.",true); return; }
    clearLog(); clearPreview(); cancelRequested=false; setBusy(true);
    try { writeConfig(cfg); } catch(e){}   // optional, for reference/debugging
    log("Config: "+cfg.speakers.length+" speakers, "+cfg.cameras.length+" cameras.");
    cfg.cameras.forEach(function(c){ log("  V"+c.track+" → "+(c.speakers.join(", ")||"(none)")); });
    var timelineChannels = timelineSourceChannelLabel();
    if (timelineChannels) log("[channels] Using Premiere source channels: "+timelineChannels+".");
    else {
      var autoChannels = automaticChannelPlanLabel(cfg);
      if (autoChannels) log("[channels] Premiere did not expose timeline source-channel labels; using automatic WAV channel mapping: "+autoChannels+".");
    }
    status(action==="trim"?"Finding dead air…":"Analyzing audio…"); showProg(); setProg(0.04);
    runAnalysis(cfg, action);
  }

  async function runAnalysis(cfg, action){
    try {
      checkCancelled();
      var hopS=(cfg.params.analysis_hop_ms||20)/1000;
      var cutsKey = (action!=="trim") ? cutsCacheKey(cfg) : "";
      var cachedCuts = cutsKey ? loadCutsFromCache(cutsKey) : null;
      if (cachedCuts){
        cachedCuts.seq_id = curSeqId; cachedCuts.seq_name = curSeqName; cachedCuts.timeline_fingerprint = curTimelineFingerprint;
        fs.writeFileSync(cutsPath(), JSON.stringify(cachedCuts,null,2),"utf8");
        setProg(1); log("[preview] reused cached cut plan for this sequence and setup."); logDiag(cachedCuts); renderBalance(cachedCuts); renderRunWarnings(cachedCuts); hideProg();
        try { var ccuts=cutsFromOut(cachedCuts), cdur=(cachedCuts.duration_s||0); renderSaved({ cuts:ccuts, dur:cdur, saved:estimateSavedSeconds(ccuts,cdur) }); } catch(eC){}
        log("Analysis done.");
        afterAnalysis(cfg, action);
        return;
      }
      var useVad = (cfg.params.vad_enabled!==false) && (typeof OnMicVAD!=="undefined");
      if (useVad){
        if (OnMicVAD.resetStats) OnMicVAD.resetStats();
        var nSp=cfg.speakers.length, usedAI=false, reusedVad=0, diskVad=0, computedVad=0;
        for (var i=0;i<nSp;i++){
          checkCancelled();
          status("AI listening to "+cfg.speakers[i].name+"…");
          var clips=cfg.speakers[i].clips||[];
          // v1.05 (audit #7): weight per-clip progress by clip duration so the
          // bar is monotonic for multi-clip speakers (the raw per-clip fraction
          // made it jump backwards at every clip boundary).
          var clipDurBefore=[], spTotDur=0;
          for (var cd=0; cd<clips.length; cd++){ clipDurBefore.push(spTotDur); spTotDur += (clips[cd].dur_s||0); }
          for (var ci=0;ci<clips.length;ci++){
            checkCancelled();
            var c=clips[ci];
            var nH=Math.ceil((c.dur_s||0)/hopS);
            var ck = vadCacheKey(c, hopS);
            if (ck && vadMaskCache[ck]){
              c._vadMask = copyMask(vadMaskCache[ck]);
              reusedVad++;
              continue;
            }
            if (ck){
              var diskMask = loadVadMaskFromDisk(ck, nH);
              if (diskMask){
                c._vadMask = diskMask;
                vadMaskCache[ck] = copyMask(diskMask);
                diskVad++;
                continue;
              }
            }
            /* eslint-disable no-await-in-loop */
            // windowed VAD: decodes the mic in 60s chunks (tens of MB) instead of
            // loading the whole clip (~0.9GB) — bit-identical mask.
            // v1.04: snapshot the per-clip fallback counter so we can tell if THIS
            // clip silently degraded to the loudness heuristic (read glitch,
            // inference error, or the <2% safety net) even though the model is
            // loaded. voiceReady() alone is global and misses that — caching a
            // degraded clip under the silero key was reintroducing the exact
            // poisoning the v1.03 change meant to stop.
            var fbBefore = (OnMicVAD.fallbacks ? OnMicVAD.fallbacks().count : 0);
            c._vadMask = await OnMicVAD.maskForClip(
              (function(cc){ return function(inS,durS){
                var rd=Engine.readChannelWindow(cc.wav, cc.channel||0, (cc.src_in_s||0)+inS, durS);
                var gd=+cc.gain_db||0;
                if(gd && rd && rd.samples && rd.samples.length){
                  var mul=Math.pow(10, gd/20);
                  for(var gi=0; gi<rd.samples.length; gi++) rd.samples[gi]=Math.max(-1,Math.min(1,rd.samples[gi]*mul));
                }
                return rd;
              }; })(c),
              c.dur_s, hopS, nH,
              (function(idx, doneS, clipD, totD){ return function(fr){
                var spFr = totD>0 ? (doneS + fr*clipD)/totD : fr;
                setProg(((idx+spFr)/nSp)*0.75);
              }; })(i, clipDurBefore[ci], (c.dur_s||0), spTotDur),
              // v1.05 (audit #7): per-chunk cancellation — Stop used to only take
              // effect between clips; maskForClip now checks this each chunk and
              // rejects with the same "cancelled" error this pipeline handles.
              { shouldCancel: function(){ return cancelRequested; } });
            computedVad++;
            // Only cache a mask that genuinely came from silero for THIS clip:
            // the model is ready AND the fallback counter didn't tick during this
            // clip. Otherwise a heuristic mask would be replayed under the silero
            // key on every future run.
            var fbClean = (OnMicVAD.fallbacks ? OnMicVAD.fallbacks().count : 0) === fbBefore;
            if (ck && c._vadMask && voiceReady() && fbClean) { vadMaskCache[ck] = copyMask(c._vadMask); saveVadMaskToDisk(ck, c._vadMask); }
          }
          setProg(((i+1)/nSp)*0.75);
        }
        checkCancelled();
        usedAI = OnMicVAD.isReady();
        if (usedAI && computedVad>0){ log("[vad] "+OnMicVAD.modelInfo()+" · max prob "+(OnMicVAD.maxProb?OnMicVAD.maxProb().toFixed(3):"?")); if(OnMicVAD.firstFrame) log("[vad] first "+OnMicVAD.firstFrame()); if(OnMicVAD.audioInfo) log("[vad] audio "+OnMicVAD.audioInfo()); }
        else if (usedAI){ log("[vad] "+OnMicVAD.modelInfo()+" · reused cached voice masks"); }
        if (reusedVad || diskVad){ log("[vad] reused cached voice detection for "+(reusedVad+diskVad)+" clip"+((reusedVad+diskVad)===1?"":"s")+" ("+reusedVad+" memory, "+diskVad+" saved); analyzed "+computedVad+" new clip"+(computedVad===1?"":"s")+"."); }
        log("[info] Smart voice detection active"+(usedAI?" (silero + voice filter).":" (voice filter).") );
        // v1.03: surface per-clip silent fallbacks — a clip can degrade to the
        // loudness heuristic mid-run (inference error, <2% speech net, read
        // failure) while the model is loaded, and the log used to still claim
        // full silero. Bad edits with no visible cause.
        try {
          if (usedAI && OnMicVAD.fallbacks && OnMicVAD.fallbacks().count > 0){
            var _fb = OnMicVAD.fallbacks();
            log("[warn] "+_fb.count+" clip"+(_fb.count===1?"":"s")+" fell back to loudness-only detection ("+_fb.reason+") — cut accuracy may be reduced for the affected mic(s).");
          }
        } catch(eFb){}
        // Safety net: if the AI marked almost nothing as speech, it's misbehaving
        // for this model — fall back to loudness so you still get a usable edit.
        var ones=0, hops=0;
        cfg.speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ if(c._vadMask){ hops+=c._vadMask.length; for(var k=0;k<c._vadMask.length;k++) ones+=c._vadMask[k]; } }); });
        if (hops>0 && ones/hops < 0.01){
          log("[warn] AI detected almost no speech — using loudness for this run instead.");
          cfg.speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ delete c._vadMask; }); });
          useVad=false;
        }
      }
      status("Building cuts…");
      checkCancelled();
      var out = Engine.analyze(cfg, { useVad:useVad, log:log, dumpPath: (projectFolder() ? projectFolder()+"/analyze_dump.json" : null), progress:function(i,t){
        var pct = Math.round(100*(0.75+0.25*i/Math.max(1,t)));
        setProg(0.75+0.25*i/Math.max(1,t));
        status("Building cuts… "+pct+"%");
      } });
      checkCancelled();
      out.seq_id = curSeqId; out.seq_name = curSeqName; out.timeline_fingerprint = curTimelineFingerprint;   // stamp exact timeline identity so stale plans fail closed
      fs.writeFileSync(cutsPath(), JSON.stringify(out,null,2),"utf8");
      // v1.03: never cache a DEGRADED run (AI missing, or the almost-no-speech
      // safety net kicked in) — a cached loudness-only plan used to be replayed
      // even after the AI self-healed.
      // v1.04: also treat a run where ANY clip fell back per-clip as degraded, so
      // a partially-heuristic cut plan isn't cached and replayed after the cause
      // (read glitch / quiet mic) is gone.
      var anyFallback = (typeof OnMicVAD!=="undefined" && OnMicVAD.fallbacks && OnMicVAD.fallbacks().count > 0);
      var runDegraded = (cfg.params.vad_enabled!==false) && (!useVad || !voiceReady() || anyFallback);
      if (cutsKey && !runDegraded) { saveCutsToCache(cutsKey, out); }
      else if (cutsKey) { log("[cache] not caching this plan (AI voice detection wasn't fully active for this run)."); }
      setProg(1); logDiag(out); renderBalance(out); renderRunWarnings(out); hideProg();
      // Show the estimated manual work for THIS edit right away (preview/apply).
      // Lifetime total is only incremented once the user actually applies.
      if (action!=="trim"){
        try { var cuts=cutsFromOut(out), dur=(out.duration_s||0); renderSaved({ cuts:cuts, dur:dur, saved:estimateSavedSeconds(cuts,dur) }); } catch(e){}
      }
      if (action==="trim"){ doTrim(cfg, out); return; }
      log("Analysis done.");
      afterAnalysis(cfg, action);
    } catch(e){
      var msg=(e&&e.message)||String(e);
      if(msg==="cancelled"){ status("Stopped.", true); log("Stopped by user."); }
      else { status("Analysis failed — see log.",true); log("ERROR: "+msg); }
      setBusy(false); hideProg(); cancelRequested=false;
    }
  }

  function afterAnalysis(cfg, action){
    var autoApply = (action==="apply");
    log("Analysis done. Building preview…");
    CEP.evalScript("mcsReport("+CEP.q(cutsPath())+","+CEP.q(outputMode())+")", function(res2){
      var rep; try { rep=JSON.parse(res2); } catch(e){ status("Bad response from Premiere.",true); setBusy(false); return; }
      if (!rep.ok){ status("Can't proceed — see log.",true); log("VALIDATION:\n- "+(rep.errors||[]).join("\n- ")); setBusy(false); return; }
      if (autoApply){ apply(); }
      else {
        var explain = "Click 'Apply to timeline' to make these switches. Non-destructive (toggles clip visibility); audio is untouched. Use Edit > Undo or restore the OnMic backup copy in your Project panel to revert.";
        $("confirmText").textContent=rep.report+"\n\n"+explain; previewReady=true; $("confirmBox").style.display="block"; status("Preview ready - review it, then Apply. If you change setup, run Preview again."); setBusy(false);
      }
    });
  }
  // Dead-air trimming (beta): cuts.json now carries silence regions; ask the host
  // to ripple-delete pauses longer than the threshold.
  function doTrim(cfg, out){
    var minSec = parseFloat(val("trimSec")); if(!(minSec>0)) minSec=1.5;
    if (minSec < 0.5) minSec = 0.5;   // v1.05 (audit #7): the host keeps 0.4s of every pause; below 0.5s there is nothing meaningful to remove (input min raised to match)
    var totalRemove = 0;
    // v1.05 (audit #7 + regression review): count EXACTLY what the host will
    // trim — mcsTrimPauses removes every pause longer than minSec (keeping a
    // 0.4s breath, and skipping removals under 0.12s), so mirror both gates
    // here. The consent dialog must never understate a destructive ripple edit.
    var nPauses = (out.silences||[]).filter(function(s){
      var len=s.end_s-s.start_s;
      if(len>minSec && (len-0.4)>0.12){ totalRemove += len-0.4; return true; }
      return false;
    }).length;
    if (!nPauses){ status("No pauses long enough to trim (longer than ~"+minSec.toFixed(1)+"s, so something is left after keeping a natural 0.4s breath).",true); setBusy(false); return; }
    confirmDlg({
      title: "Trim dead air?",
      msg: "OnMic found "+nPauses+" pause"+(nPauses===1?"":"s")+" and will remove about "+fmtPlainDur(totalRemove)+". This changes timing across the timeline.\n\nOnMic will duplicate your sequence first; if anything looks wrong, restore that backup from the Project panel.",
      yes: "Trim "+nPauses+" pause"+(nPauses===1?"":"s"),
      no: "Cancel"
    }, function(ok){
    if(!ok){ status("Dead-air trim cancelled."); setBusy(false); return; }
    var backup = "true";   // dead-air trim is destructive — always back up first
    status("Trimming dead air ("+nPauses+" pause"+(nPauses===1?"":"s")+")…");
    CEP.evalScript("mcsTrimPauses("+CEP.q(cutsPath())+","+minSec+",0.4,"+backup+")", function(res){
      setBusy(false);
      var r; try { r=JSON.parse(res); } catch(e){ status("Bad response: "+res,true); return; }
      if (r.ok){
        status("Done - dead air trimmed."); log(r.message);
        // v1.05 (audit #7, item 27): the trim changed every downstream timing, so
        // any previous cut plan is stale by design — offer the re-analysis in one
        // click instead of relying on the user to remember.
        confirmDlg({
          title: "Trim done",
          msg: "The timeline timings changed, so the previous cut analysis is now out of date.\n\nRe-analyze now? (Same as clicking Preview cuts.)",
          yes: "Re-analyze now", no: "Not now", focus: "yes"
        }, function(again){ if(again) runPipeline("preview"); });
      }
      else { status("Trim failed — see log.",true); log("ERROR: "+r.message); }
    });
    });
  }

  // Shared success handler for the resumable apply finish: status line, log,
  // restore hint, and the "manual work saved" dashboard. `backup` is the
  // "true"/"false" string captured at mcsApplyBegin — v1.05 (audit #7): the
  // restore hint used to re-read the checkbox at finish, so a toggle mid-apply
  // reported the wrong state (the failure path already used the captured value).
  function applySucceeded(r, backup){
    var msg = "Done - check your timeline.";
    var warnApply = false;
    if (r.alreadyApplied){
      msg = "These cuts were already applied — nothing needed to change.";   // v1.03: idempotent re-run is a success, not a warning
    } else if (outputMode()==="enable_disable" && r.changed===0 && r.segments>1){
      msg = "Premiere reported 0 changed clips.";
      warnApply = true;
      log("WARNING: Premiere reported 0 changed clips. Re-read tracks and check that camera tracks are unlocked before trusting this result.");
    }
    status(msg, warnApply); log(r.message);
    var didBackup = (backup === "true");
    log(didBackup ? "[restore] If anything looks wrong, open the OnMic backup sequence in the Project panel."
                  : "[restore] No backup was made (backup was off). If anything looks wrong, use Edit > Undo repeatedly or revert to your last save — there is no OnMic backup to open.");
    clearPreview();
    // Value dashboard: tally the manual work OnMic just saved + lifetime total.
    try {
      var out=JSON.parse(fs.readFileSync(cutsPath(),"utf8"));
      var last=recordSaved(out);
      renderSaved(last);
      log(last.duplicate ? "[saved] This exact edit was already counted before; lifetime total was not incremented." : "[saved] "+last.cuts+" cuts across "+fmtMMSS(last.dur)+" → "+fmtHrMin(last.saved)+" of manual work saved.");
    } catch(e){}
    try {
      if (r && r.timing) {
        var extraBits = "";
        var hostMs = Math.max(0, (+r.timing.razorMs||0) + (+r.timing.assignMs||0));
        var waitMs = Math.max(0, (+r.timing.totalMs||0) - hostMs);
        if (waitMs > 500){ extraBits += "; Premiere/panel overhead "+fmtApplyMs(waitMs); }
        log("[apply] Timing: "+fmtApplyMs(r.timing.totalMs)+" total; razor "+fmtApplyMs(r.timing.razorMs)+"; assign "+fmtApplyMs(r.timing.assignMs)+"; "+(r.timing.steps||0)+" batch"+((r.timing.steps||0)===1?"":"es")+", slowest "+fmtApplyMs(r.timing.maxStepMs)+extraBits+".");
      }
    } catch(eT){}
  }
  function fmtApplyMs(ms){
    ms = Math.max(0, Math.round(+ms||0));
    if (ms < 1000) return ms+"ms";
    return (Math.round((ms/1000)*10)/10)+"s";
  }

  // ~0.5–1s of Premiere work per batch (razor ops are the slow part). Starting at
  // 200; reassess on real episodes if a batch feels longer than ~1s.
  var APPLY_BATCH = 200;
  // Target this many visible repaint "waves" across the whole apply. Fewer waves
  // means fewer Premiere repaint/round-trip pauses, so long episodes apply faster,
  // with the tradeoff that the progress bar updates less often.
  var APPLY_WAVES = 4;

  function apply(){
    if (!gateAllowed){ status("Sign in to use OnMic.",true); checkAuth(); return; }
    if (!previewReady && $("confirmBox") && $("confirmBox").style.display==="block"){ status("Preview expired because setup changed — run Preview again before applying.", true); clearPreview(); return; }
    status("Applying to timeline…"); $("btnApply").disabled=true; setBusy(true);
    var backup = ($("backupTimeline") && $("backupTimeline").checked) ? "true" : "false";
    if(backup==="false"){
      // Applying with NO backup is destructive and irreversible by a single Undo.
      // Require an explicit, typed affirmative so it can never happen on a stray
      // click. (window.confirm's default button is OK, which is too easy to hit.)
      var typed=window.prompt("⚠ APPLY WITHOUT A BACKUP\n\n\"Back up timeline first\" is OFF, so OnMic will change this sequence in place with no safety copy to restore from. A single Undo may not fully revert the result.\n\nTo continue anyway, type  YES  below. Anything else cancels.");
      var okNoBackup = (typed!=null && /^\s*yes\s*$/i.test(typed));
      if(!okNoBackup){ $("btnApply").disabled=false; setBusy(false); status("Apply cancelled — backup is off. Turn 'Back up timeline first' on (Advanced), or type YES to apply without one.", true); return; }
    }

    function reEnable(){ $("btnApply").disabled=false; setBusy(false); hideProg(); }
    function fail(message){ reEnable(); status("Apply failed — see log.",true); log("ERROR: "+message); }

    // Begin the apply: sets up the resumable plan; we drive it in batches.
    CEP.evalScript("mcsApplyBegin("+CEP.q(cutsPath())+","+CEP.q(outputMode())+","+backup+")", function(res0){
      var r0; try { r0=JSON.parse(res0); } catch(e){ fail("Bad response: "+res0); return; }
      if (!r0.ok){ fail(r0.message); return; }
      if (r0.done){ reEnable(); applySucceeded(r0, backup); return; }   // completed synchronously (e.g. already-applied no-op)

      // ---- resumable enable_disable: loop mcsApplyStep, repaint between batches ----
      var total = r0.total || 0;
      // v1.05 (audit #7): START at APPLY_BATCH (~200 ops) so the very first wave
      // can never freeze Premiere for minutes on a long episode (it used to be
      // ceil(total/4) — thousands of ops with no repaint). The adaptive growth
      // below (×1.35 after every fast batch) still reaches wave-sized batches
      // quickly; APPLY_WAVES now only sizes the growth CEILING.
      var stepOps = APPLY_BATCH;
      var minStepOps = APPLY_BATCH;
      var maxStepOps = Math.max(Math.ceil(total / APPLY_WAVES), APPLY_BATCH * 4);
      showProg(); setProg(0);
      // v1.03: the Stop button only cancels ANALYSIS; the host apply loop can't
      // be safely interrupted mid-batch. It used to stay visible here and
      // silently do nothing — hide it so the user isn't misled.
      (function(){ var stb=$("btnStop"); if(stb) stb.style.display="none"; })();
      status("Applying "+r0.segments+" cuts — long episodes can take a few minutes; Premiere will be busy until it finishes.");
      if (r0.totalRazor != null) {
        log("[apply] Cutting "+r0.totalRazor+" camera-track split"+(r0.totalRazor===1?"":"s")+" newest-to-oldest, assigning each camera track before moving to the next. Audio tracks are untouched.");
      }
      log("[apply] Adaptive batches start at "+stepOps+" operation"+(stepOps===1?"":"s")+" per wave, then adjust if Premiere is moving especially fast or slow.");
      log("[apply] Add Edit probe: regular="+(r0.addEditCommandId||"not found")+", all-tracks="+(r0.addEditAllTracksCommandId||"not found")+"; using safe per-video-track razor.");
      if (r0.editCommandProbe && r0.editCommandProbe.length){
        var cmdBits=[]; for(var cpi=0;cpi<r0.editCommandProbe.length;cpi++) cmdBits.push(r0.editCommandProbe[cpi].name+"="+r0.editCommandProbe[cpi].id);
        log("[apply] Edit command probe: "+cmdBits.join(", ")+" (diagnostic only).");
      }

      function step(){
        CEP.evalScript("mcsApplyStep("+stepOps+")", function(resS){
          var rs; try { rs=JSON.parse(resS); } catch(e){ fail("Bad response: "+resS); return; }
          if (!rs.ok){
            // Stop and tell the user to restore the backup; nothing trustworthy.
            reEnable();
            if (backup==="true"){
              status("Apply failed — restore the OnMic backup from the Project panel.", true);
              log("ERROR: "+(rs.message||"apply step failed")+"  [restore] Open the OnMic backup sequence in the Project panel.");
            } else {
              status("Apply failed — no backup was made (backup was off). Edit > Undo repeatedly, or revert to your last save.", true);
              log("ERROR: "+(rs.message||"apply step failed")+"  [restore] No OnMic backup exists — backup was off.");
            }
            return;
          }
          var frac = (rs.totalUnits>0) ? (rs.doneUnits/rs.totalUnits) : 1;
          setProg(frac);
          status("Applying cuts… "+Math.round(frac*100)+"%");
          if (rs.stepMs != null) {
            var phaseName = (rs.stepRazorMs && rs.stepAssignMs) ? "cutting + assigning" : (rs.stepRazorMs ? "cutting" : "assigning");
            log("[apply] Batch "+(rs.stepCount||"?")+" "+phaseName+": "+fmtApplyMs(rs.stepMs)+" ("+(rs.razorDone||0)+"/"+(rs.totalRazor||0)+" splits cut).");
            if (!rs.done && rs.stepMs < 5000 && stepOps < maxStepOps) {
              stepOps = Math.min(maxStepOps, Math.ceil(stepOps * 1.35));
            } else if (!rs.done && rs.stepMs > 18000 && stepOps > minStepOps) {
              stepOps = Math.max(minStepOps, Math.floor(stepOps * 0.75));
            }
          }
          if (rs.done){
            // Finalize: get the exact final message + warnings, then report.
            CEP.evalScript("mcsApplyFinish()", function(resF){
              var rf; try { rf=JSON.parse(resF); } catch(e){ fail("Bad response: "+resF); return; }
              reEnable();
              if (rf.ok){ applySucceeded(rf, backup); }
              else {
                status("Apply finished with a problem — see log; restore the OnMic backup if needed.", true);
                log("ERROR: "+rf.message);
              }
            });
            return;
          }
          // setTimeout(0) so the panel repaints the progress bar AND Premiere gets
          // a breather between batches (otherwise the next evalScript would queue
          // back-to-back and the panel would still look frozen).
          setTimeout(step, 0);
        });
      }
      setTimeout(step, 0);
    });
  }
  // Non-destructive preview: drop a sequence marker at every camera switch so the
  // user can review the proposed cuts on the timeline before applying anything.
  function doMarkers(){
    if (!previewReady){ status("Run Preview first, then add markers.", true); clearPreview(); return; }
    status("Dropping preview markers on the timeline…");
    CEP.evalScript("mcsMarkers("+CEP.q(cutsPath())+")", function(res){
      var r; try { r=JSON.parse(res); } catch(e){ status("Bad response: "+res,true); return; }
      if (r.ok){ status("Preview markers added - scrub the timeline to review. Nothing was cut."); log(r.message); }
      else { status("Couldn't add markers — see log.",true); log("ERROR: "+r.message); }
    });
  }

  // "Fix my setup" action: rename each camera video track to its tagged speaker
  // names. Purely a track rename in Premiere (no clip/timeline edits) via the
  // defensive host function mcsRenameVideoTracksFromTags.
  function renameTracksFromSpeakers(){
    var nCam=iCam(), cams=[];
    for (var v=0; v<nCam; v++){ var names=camNames(v); if(names.length) cams.push({ track:v+1, name:names.join(", ") }); }
    if(!cams.length){ status("Tag at least one camera with speaker names first.", true); return; }
    status("Renaming camera tracks from speaker names…");
    CEP.evalScript("mcsRenameVideoTracksFromTags("+CEP.q(JSON.stringify(cams))+")", function(res){
      var r; try { r=JSON.parse(res); } catch(e){ status("Bad response from Premiere.",true); return; }
      if (r.ok){ status(r.message); log("[rename] "+r.message); }
      else { status("Couldn't rename tracks — see the Log.",true); log("ERROR: "+(r.message||"rename failed")); }
    });
  }

  /* ---- profiles (saved setups) ---- */
  function getProfiles(){ try { return JSON.parse(window.localStorage.getItem("mcs_profiles")||"{}"); } catch(e){ return {}; } }
  function setProfiles(p){ try { window.localStorage.setItem("mcs_profiles", JSON.stringify(p)); } catch(e){} }
  function refreshProfileList(){
    var p=getProfiles(), sel=$("profileList"), cur=sel.value;
    var names=Object.keys(p).sort();
    sel.innerHTML='<option value="">— select profile —</option>'+names.map(function(n){ return '<option'+(n===cur?" selected":"")+'>'+esc(n)+'</option>'; }).join("");
  }
  function collectProfile(){
    var ns=iSp(), names=[];
    for (var i=0;i<ns;i++){ names.push($("aname"+i)?$("aname"+i).value:""); }
    var nc=iCam(), cams={};
    for (var v=0;v<nc;v++){ cams[v]={}; if(camSel[v]){ for (var s in camSel[v]){ if(camSel[v][s]) cams[v][s]=true; } } }
    return { numSpeakers:val("numSpeakers"), numCameras:val("numCameras"), names:names, camSel:cams,
             pace:val("pace"), wideFreq:val("wideFreq"), output:outputMode(),
             delayCuts:val("delayCuts"), ignoreCuts:val("ignoreCuts"),
             backup:($("backupTimeline")?$("backupTimeline").checked:true),
             reactionFreq:($("reactionFreq")?val("reactionFreq"):"0"),
             snapPause:($("snapPause")?$("snapPause").checked:true) };
  }
  function applyProfile(p){
    if (!p) return;
    if (p.numSpeakers) $("numSpeakers").value=p.numSpeakers;
    if (p.numCameras) $("numCameras").value=p.numCameras;
    if (p.pace){ $("pace").value=p.pace; applyPace(); }
    if (p.delayCuts!==undefined) $("delayCuts").value=p.delayCuts;
    if (p.ignoreCuts!==undefined) $("ignoreCuts").value=p.ignoreCuts;
    if (p.wideFreq!==undefined) $("wideFreq").value=p.wideFreq;
    outputMode();
    if (p.backup!==undefined && $("backupTimeline")) $("backupTimeline").checked=!!p.backup;
    if (p.reactionFreq!==undefined && $("reactionFreq")) $("reactionFreq").value=p.reactionFreq;
    if (p.snapPause!==undefined && $("snapPause")) $("snapPause").checked=!!p.snapPause;
    camSel={};
    var cs=p.camSel||{};
    Object.keys(cs).forEach(function(v){ var vi=parseInt(v,10); camSel[vi]={}; Object.keys(cs[v]).forEach(function(s){ camSel[vi][parseInt(s,10)]=true; }); });
    rebuildSpeakers();
    (p.names||[]).forEach(function(nm,i){ if($("aname"+i)) $("aname"+i).value=nm; });
    rebuildTags();
    status("Loaded profile.");
  }
  function saveProfile(){
    var name=val("profileName").replace(/^\s+|\s+$/g,"");
    if (!name){ status("Type a name for the profile first.",true); return; }
    var p=getProfiles(); p[name]=collectProfile(); setProfiles(p);
    refreshProfileList(); $("profileList").value=name; $("profileName").value="";
    status("Saved profile “"+name+"”.");
  }
  function deleteProfile(){
    var name=val("profileList"); if(!name) return;
    var p=getProfiles(); delete p[name]; setProfiles(p); refreshProfileList();
    status("Deleted profile “"+name+"”.");
  }
  function exportProfiles(){
    var p=getProfiles(), names=Object.keys(p);
    if(!names.length){ status("No profiles to export.", true); return; }
    try {
      var blob=new Blob([JSON.stringify({ app:"OnMic", version:VERSION, profiles:p }, null, 2)], { type:"application/json" });
      var a=document.createElement("a");
      a.href=URL.createObjectURL(blob);
      a.download="onmic-profiles.json";
      document.body.appendChild(a); a.click();
      setTimeout(function(){ try{ URL.revokeObjectURL(a.href); document.body.removeChild(a); }catch(e){} }, 1000);
      status("Profiles exported.");
    } catch(e){ status("Could not export profiles.", true); }
  }
  function importProfilesFile(file){
    if(!file){ return; }
    var r=new FileReader();
    r.onload=function(){
      try {
        var o=JSON.parse(String(r.result||""));
        var incoming=o.profiles||o;
        if(!incoming || typeof incoming!=="object") throw new Error("bad");
        var p=getProfiles(), n=0;
        Object.keys(incoming).forEach(function(k){
          if(incoming[k] && typeof incoming[k]==="object"){
            p[String(k).slice(0,80)] = incoming[k];
            n++;
          }
        });
        setProfiles(p); refreshProfileList();
        status(n ? ("Imported "+n+" profile"+(n===1?"":"s")+".") : "No profiles found in that file.", !n);
      } catch(e){ status("That profile file could not be read.", true); }
    };
    r.onerror=function(){ status("That profile file could not be read.", true); };
    r.readAsText(file);
  }

  function rmDirRecursive(d){
    if(!fs || !d) return;
    try {
      if (fs.rmSync) fs.rmSync(d, { recursive:true, force:true });
      else if (fs.rmdirSync) fs.rmdirSync(d, { recursive:true });
    } catch(e){}
  }
  // v1.03: age-prune the mask/plan caches on startup (they were unbounded — one
  // file per clip x gain x hop combination, kept forever).
  function pruneCacheDir(d, maxAgeDays){
    try {
      if(!fs || !d || !fs.existsSync(d)) return;
      var cutoff = Date.now() - (maxAgeDays*24*3600*1000);
      var names = fs.readdirSync(d);
      var removed = 0;
      for (var i=0;i<names.length;i++){
        try {
          var f = d + "/" + names[i];
          var st = fs.statSync(f);
          if (st.isFile() && st.mtime && st.mtime.getTime() < cutoff){ fs.unlinkSync(f); removed++; }
        } catch(eF){}
      }
      if (removed) log("[cache] pruned "+removed+" cache file"+(removed===1?"":"s")+" older than "+maxAgeDays+" days.");
    } catch(e){}
  }

  function resetLocalData(){
    confirmDlg({
      title: "Reset local OnMic data?",
      msg: "This signs you out and removes saved setup files, cuts, stats, and profiles from this panel on this computer.\n\nIt does not delete any Premiere media and does not change your WhereToPodcast account.",
      yes: "Reset local data", no: "Cancel"
    }, function(ok){
    if(!ok) return;
    try { if (window.OnMicAuth && OnMicAuth.signOut) OnMicAuth.signOut(); } catch(e){}
    try { window.localStorage.removeItem("mcs_profiles"); } catch(e1){}
    try { window.localStorage.removeItem("mcs_folder"); } catch(e2){}
    var paths=[];
    try { paths.push(configPath()); } catch(e3){}
    try { paths.push(cutsPath()); } catch(e4){}
    try { paths.push(supportDir()+"/onmic_stats.json"); } catch(e5){}
    try { paths.push(panelFolder()+"/project_path.txt"); } catch(e7){}
    for(var i=0;i<paths.length;i++){
      try { if(paths[i] && fs && fs.existsSync(paths[i])) fs.unlinkSync(paths[i]); } catch(e6){}
    }
    // v1.03: the reset always promised to remove cuts/analysis data but left the
    // voice-detection + cut-plan caches behind (they also grew forever).
    try { rmDirRecursive(appSupportDir("OnMic/vad-cache")); } catch(e8){}
    try { rmDirRecursive(appSupportDir("OnMic/cuts-cache")); } catch(e9){}
    try { vadMaskCache = {}; } catch(e10){}
    clearPreview(); clearLog(); refreshProfileList();
    status("Local OnMic data reset. Sign in again to continue.", true);
    checkAuth();
    });
  }

  function hideSignoutDialog(){
    var d=$("signoutDialog");
    if(!d) return;
    d.style.display="none";
    d.setAttribute("aria-hidden","true");
  }
  // v1.13: panel-native replacement for window.confirm(), which CEP renders as
  // a bare OS dialog titled "JavaScript Confirm" (user report). Callback style;
  // no call site nests confirms, so one shared dialog is enough. Esc / shade
  // click / the left button all mean "no".
  var _confirmCb = null;
  function confirmDlg(o, cb){
    var d=$("confirmDialog");
    if(!d){ cb(window.confirm((o.title?o.title+"\n\n":"")+(o.msg||""))); return; }   // markup missing: degrade, never block
    _confirmCb = cb || function(){};
    $("confirmTitle").textContent = o.title || "OnMic";
    $("confirmMsg").textContent = o.msg || "";
    $("confirmYes").textContent = o.yes || "OK";
    $("confirmNo").textContent  = o.no  || "Cancel";
    d.style.display="flex";
    d.setAttribute("aria-hidden","false");
    try { $(o.focus==="yes" ? "confirmYes" : "confirmNo").focus(); } catch(e){}
  }
  function settleConfirm(v){
    var cb=_confirmCb; _confirmCb=null;
    var d=$("confirmDialog");
    if(d){ d.style.display="none"; d.setAttribute("aria-hidden","true"); }
    if(cb) cb(v);
  }
  function showSignoutDialog(){
    var d=$("signoutDialog");
    if(!d){
      doSignOut();
      return;
    }
    d.style.display="flex";
    d.setAttribute("aria-hidden","false");
    try { $("signoutCancel").focus(); } catch(e){}
  }
  function doSignOut(){
    hideSignoutDialog();
    try{ OnMicAuth.signOut(); }catch(e){}
    gateAllowed=false; $("acctBar").style.display="none"; checkAuth();
  }

  /* ---- init ---- */
  function ping(){
    CEP.evalScript("mcsPing()", function(res){
      var r; try { r=JSON.parse(res); } catch(e){ status("Premiere connection did not load. Reopen Premiere; if it persists, reinstall OnMic.",true); return; }
      if (r.fps && r.fps>0) SEQ_FPS=Math.round(r.fps*1000)/1000;
      if (r.appVersion) premiereVersion=String(r.appVersion);
      if (r.hasSequence) status('Connected · "'+r.sequenceName+'" · '+r.videoTracks+" video / "+r.audioTracks+" audio · "+SEQ_FPS+" fps");
      else status("Connected. Open a sequence to begin.");
    });
  }

  /* ---- licensing / sign-in gate ---- */
  var gateAllowed = false;   // true once the user is verified (trial / paid / comp)
  var gateMode = "signin";   // what the gate's primary button does: signin | site | retry | devices
  var gateDevicesUrl = "";
  var _gateBackgroundState = null;

  // The fixed auth gate visually covers the panel, but covered controls remain
  // reachable by Tab and screen readers unless we isolate them explicitly.
  function isolateGateBackground(on){
    var gate = $("authGate"), i, el;
    if (!gate) return;
    var kids = document.body.children;
    for (i=0; i<kids.length; i++){
      el=kids[i];
      if (el===gate || String(el.tagName).toLowerCase()==="script") continue;
      if (on) el.setAttribute("aria-hidden","true");
      else el.removeAttribute("aria-hidden");
    }
    if (on){
      if (_gateBackgroundState) return;
      _gateBackgroundState=[];
      var focusable=document.querySelectorAll("button,input,select,textarea,a,summary,[tabindex]");
      for (i=0; i<focusable.length; i++){
        el=focusable[i];
        if (gate.contains(el)) continue;
        _gateBackgroundState.push({
          el:el,
          disabled:("disabled" in el) ? !!el.disabled : null,
          tabindex:el.getAttribute("tabindex")
        });
        if ("disabled" in el) el.disabled=true;
        el.setAttribute("tabindex","-1");
      }
    } else if (_gateBackgroundState) {
      for (i=0; i<_gateBackgroundState.length; i++){
        var st=_gateBackgroundState[i];
        if (!st.el) continue;
        if (st.disabled!==null) st.el.disabled=st.disabled;
        if (st.tabindex===null) st.el.removeAttribute("tabindex");
        else st.el.setAttribute("tabindex",st.tabindex);
      }
      _gateBackgroundState=null;
    }
  }

  function showGate(o){
    gateMode = o.mode || "signin";
    gateDevicesUrl = o.devicesUrl || "";
    $("authTitle").textContent = o.title || "Sign in to OnMic";
    $("authMsg").textContent = o.msg || "";
    var b=$("btnSignIn"); b.textContent=o.btn || "Sign in with WhereToPodcast"; b.disabled=false;
    var n=$("authNote"); n.textContent=o.note||""; n.className="authnote"+(o.bad?" bad":"");
    var lk=$("authLink");
    if (o.link){ lk.style.display="block"; lk.setAttribute("data-mode", o.link.mode);
      if (o.link.url){ lk.setAttribute("data-url", o.link.url); lk.href=o.link.url; } else { lk.removeAttribute("href"); }
      lk.textContent=o.link.text; }
    else { lk.style.display="none"; }
    // v1.07: reset the sign-in-pending controls on every render.
    $("authCancel").style.display="none";
    $("authGate").style.display="flex";
    $("authGate").setAttribute("aria-hidden","false");
    isolateGateBackground(true);
    setTimeout(function(){ try { $("btnSignIn").focus(); } catch(e){} }, 0);
    // Surface any known update on the gate, and fire a tokenless check so even a
    // signed-out user (whose response carried no version) still gets the notice.
    showUpdateBars();
    checkUpdateOnGate();
  }
  function hideGate(){
    $("authGate").style.display="none";
    $("authGate").setAttribute("aria-hidden","true");
    isolateGateBackground(false);
  }

  // Compare versions. >0 if a is newer than b.
  // v1.03: OnMic versions are DECIMAL-style (1.0, 1.01, 1.02, … then 1.1, 1.2),
  // so the part after the first dot compares as a decimal FRACTION, not an
  // integer. The old integer compare made 1.1 look OLDER than 1.02 (1 < 2), so
  // once a v1.1 shipped, no panel would ever show the update banner again.
  // Convention note: 1.10 == 1.1 numerically — after 1.09 bump to 1.1.
  // The optional 3rd segment (bundle counter 1.0.NN) is a tiebreaker only; it is
  // compared as an int, so two versions with the same major.minor order by it.
  function cmpVer(a,b){
    function parts(v){
      var m = String(v||"").match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/) || [];
      return [ parseInt(m[1]||"0",10)||0,
               parseFloat("0."+(m[2]||"0"))||0,
               parseInt(m[3]||"0",10)||0 ];
    }
    var pa=parts(a), pb=parts(b);
    for(var i=0;i<3;i++){ if(pa[i]!==pb[i]) return pa[i]>pb[i] ? 1 : -1; }
    return 0;
  }
  // v1.05: the update banner must only ever compare PUBLIC versions (2-segment,
  // e.g. "1.05"). If the server's latest_version were ever set to a 3-segment
  // BUNDLE counter ("1.0.NN") by mistake, cmpVer would read its minor as 0.0 and
  // rank it OLDER than this public build — silently hiding every future update.
  // Guard: reject a candidate latest that isn't a clean 2-segment public version.
  function isPublicVer(v){ return /^\d+\.\d+$/.test(String(v||"")); }
  // The verify response carries the latest shipped version; show a non-blocking
  // banner if this panel is older.
  // Remember the newest version we've ever been told about, so the notice can be
  // shown on the sign-in / expired-trial gate too — even if the latest server
  // response (e.g. a {signedOut} shortcut that never hit the network) didn't
  // carry it. The banner appears in BOTH the main panel and the auth gate.
  var _knownLatest = null, _knownDownload = "";
  function showUpdateBars(){
    var ver=_knownLatest, dl=_knownDownload || (OnMicAuth?OnMicAuth.siteURL+"/onmic":"https://wheretopodcast.com/onmic");
    // v1.05: only trust a well-formed public version for the banner (see isPublicVer).
    var show = !!(ver && isPublicVer(ver) && cmpVer(ver, VERSION)>0);
    var bar=$("updateBar");
    if (bar){
      if (show){ $("updateText").textContent="Update available — v"+ver+" (you have v"+VERSION+")"; $("updateLink").setAttribute("data-url", dl); bar.style.display="flex"; }
      else { bar.style.display="none"; }
    }
    // Mirror onto the auth gate so a signed-out / expired user still learns an
    // update exists (important if an old build has a sign-in bug).
    var gbar=$("authUpdateBar");
    if (gbar){
      if (show){ $("authUpdateText").textContent="Update available — v"+ver+" (you have v"+VERSION+")"; $("authUpdateLink").setAttribute("data-url", dl); gbar.style.display="flex"; }
      else { gbar.style.display="none"; }
    }
  }
  function maybeShowUpdate(res){
    if (res && res.latestVersion){
      // Capture even on non-allowed responses (expired/invalid still hit the
      // server and return the latest version); ok-gating happens in cmpVer.
      _knownLatest = res.latestVersion;
      _knownDownload = (IS_WIN ? res.downloadUrlWin : res.downloadUrl) || res.downloadUrl || _knownDownload;
    }
    showUpdateBars();
  }
  // Best-effort, tokenless update check so the sign-in screen can show a notice
  // even when the user was never signed in (no token => verify is never called).
  function checkUpdateOnGate(){
    try {
      if (!window.OnMicAuth || !OnMicAuth.latestVersion) return;
      OnMicAuth.latestVersion(function(info){
        if (info && info.latestVersion){
          _knownLatest = info.latestVersion;
          _knownDownload = (IS_WIN ? info.downloadUrlWin : info.downloadUrl) || info.downloadUrl || _knownDownload;
          showUpdateBars();
        }
      });
    } catch(e){}
  }

  function renderAcct(ent){
    var bar=$("acctBar"), el=$("acctState");
    if (!ent || !ent.allowed){ bar.style.display="none"; return; }
    var html="";
    if (ent.offline){ html='· <span class="pill paid">signed in recently</span>'; }
    else if (ent.state==="trial"){ var d=parseInt(ent.daysLeft,10)||0; html='· trial <span class="pill trial">'+d+' day'+(d===1?'':'s')+' left</span>'; }   // v1.03: numeric-coerce a server value before innerHTML
    else if (ent.state==="paid"){ html='· <span class="pill paid">WhereToPodcast subscribed</span>'; }
    else { html='· <span class="pill paid">active</span>'; }
    el.innerHTML=html; bar.style.display="inline-flex";
  }

  // Interpret a result from OnMicAuth.verify()/signIn() and set the gate state.
  function applyEntitlement(res){
    maybeShowUpdate(res);
    if (res.signedOut){ gateAllowed=false; $("acctBar").style.display="none";
      showGate({ mode:"signin", title:"Sign in to OnMic",
        msg:"Use your WhereToPodcast account to activate OnMic. New accounts get a 14-day free trial.",
        btn:"Sign in with WhereToPodcast" }); return; }
    if (res.invalid){ gateAllowed=false; try{OnMicAuth.signOut();}catch(e){} $("acctBar").style.display="none";
      showGate({ mode:"signin", title:"Please sign in again",
        msg:"Your OnMic session expired. Sign in with WhereToPodcast to continue.", btn:"Sign in" }); return; }
    if (res.rateLimited){ gateAllowed=false; $("acctBar").style.display="none";
      showGate({ mode:"retry", title:"Try again in a minute", bad:true,
        msg:"OnMic checked your account too many times in a short window. Wait a minute, then check again.",
        btn:"Check again" }); return; }
    if (res.noDevice){ gateAllowed=false; $("acctBar").style.display="none";
      showGate({ mode:"retry", title:"OnMic can't identify this computer", bad:true,
        msg:"OnMic could not read this computer's device ID, so it can't start a device-bound free trial. Restart Premiere and try again; if it keeps happening, copy diagnostics and contact support.",
        btn:"Try again" }); return; }
    if (res.offline){
      if (res.allowed){ gateAllowed=true; hideGate(); renderAcct({ allowed:true, offline:true });
        status("Signed in recently — OnMic will recheck your account when you're back online."); }
      else { gateAllowed=false; $("acctBar").style.display="none";
        showGate({ mode:"retry", title:"Can't verify OnMic", bad:true,
          msg:"OnMic couldn't reach WhereToPodcast to check your account. Connect to the internet and try again.",
          btn:"Try again" }); }
      return;
    }
    if (res.ok && res.allowed){ gateAllowed=true; hideGate(); renderAcct(res);
      if (res.state==="trial") status("Free trial — "+res.daysLeft+" day"+(res.daysLeft===1?"":"s")+" left.");
      return; }
    if (res.ok && res.state==="device_limit"){ gateAllowed=false; $("acctBar").style.display="none";
      showGate({ mode:"devices", title:"Device limit reached", devicesUrl: res.manageUrl,
        msg:"OnMic is already active on 2 devices for your account. Remove one to use it on this computer.",
        btn:"Manage my devices",
        link:{ mode:"recheck", text:"I removed one — check again" } }); return; }
    // signed in but not entitled (trial ended, no paid plan)
    gateAllowed=false; $("acctBar").style.display="none";
    showGate({ mode:"site", title:"Your free trial has ended",
      msg:"Subscribe monthly or annually to keep using OnMic. Already a WhereToPodcast Premium member? It's included — just check again.",
      btn:"Subscribe",
      link:{ mode:"recheck", text:"I've subscribed — check again" } });
  }

  // v1.05 (audit #7): guard so verifies can never stack/overlap (boot, user
  // retries and the daily re-check all funnel through checkAuth). postJSON's
  // settle-once + timeout guarantees the callback always fires, so the flag
  // can't wedge.
  var AUTH_RECHECK_MS = 24*60*60*1000;   // re-verify roughly daily while the panel stays open
  var _verifyInFlight = false;
  var _lastVerifyAt = 0;
  function checkAuth(){
    if (typeof OnMicAuth==="undefined" || !window.OnMicAuth){
      // Fail CLOSED: if the licensing component didn't load, lock the panel
      // rather than granting access.
      gateAllowed=false;
      showGate({ mode:"retry", title:"OnMic couldn't start", bad:true,
        msg:"OnMic's licensing component didn't load. Fully quit and reopen Premiere; if it persists, reinstall OnMic.", btn:"Retry" });
      return; }
    if (_verifyInFlight) return;
    _verifyInFlight = true; _lastVerifyAt = Date.now();
    OnMicAuth.verify(function(res){ _verifyInFlight=false; applyEntitlement(res); });
  }
  // v1.07 (Adobe review issue 2): while we wait on the browser the gate now
  // shows a live status AND a working Cancel. Closing the browser window sends
  // the panel no signal, so previously the UI sat on "Opening your browser…"
  // with a disabled button until the 5-minute timeout.
  var _signInHandle = null;
  var _signInSeq = 0;   // v1.07: ties each callback to its own attempt — a late
                        // callback from a previous sign-in (the manual-URL flow
                        // keeps its listener alive up to 15 min) must not
                        // clobber the ACTIVE attempt's Cancel/handle/UI.
  function doSignIn(){
    var seq = ++_signInSeq;
    $("btnSignIn").disabled=true;
    $("authNote").className="authnote"; $("authNote").textContent="Waiting for you to finish signing in in your browser… (closed it? click Cancel below to try again)";
    $("authCancel").style.display="block";
    _signInHandle = OnMicAuth.signIn(function(res){
      if (seq !== _signInSeq) return;   // stale attempt — ignore entirely
      _signInHandle = null;
      $("authCancel").style.display="none";
      if (res && res.cancelled){
        $("btnSignIn").disabled=false;
        $("authNote").className="authnote"; $("authNote").textContent="Sign-in cancelled — not signed in. Click the button to try again.";
        return;
      }
      if (res && res.manualURL){
        showGate({ mode:"retry", title:"Finish sign-in in your browser",
          msg:"Open this link, sign in, then come back:", btn:"I've signed in — check",
          link:{ mode:"open", url:res.manualURL, text:res.manualURL } });
        return;
      }
      if (res && res.error){ $("btnSignIn").disabled=false; $("authNote").className="authnote bad"; $("authNote").textContent=res.error; return; }
      applyEntitlement(res);  // verify-shaped result on success
    });
  }
  function onGateCancel(e){
    if (e && e.preventDefault) e.preventDefault();
    if (_signInHandle){ try { _signInHandle.cancel(); } catch(err){} }
    return false;
  }
  function onGateButton(){
    if (gateMode==="signin") doSignIn();
    else if (gateMode==="site") CEP.openURL(OnMicAuth.siteURL + "/onmic/subscribe");
    else if (gateMode==="devices") CEP.openURL(gateDevicesUrl || (OnMicAuth.siteURL + "/onmic/devices"));
    else checkAuth();  // retry
  }
  function onGateLink(e){
    if (e && e.preventDefault) e.preventDefault();
    var lk=$("authLink"), mode=lk.getAttribute("data-mode");
    if (mode==="open") CEP.openURL(lk.getAttribute("data-url"));
    else checkAuth();
    return false;
  }

  function fileURL(p){ if(!p) return ""; p=String(p).replace(/\\/g,"/"); return (/^[A-Za-z]:/.test(p)?"file:///":"file://")+encodeURI(p).replace(/#/g,"%23").replace(/\?/g,"%3F"); }   // v1.03: '#'/'?' in a filename used to truncate the URL and mis-report the camera as undecodable

  /* ---- camera suggestions (beta): mouth motion vs. mic-speaking correlation ----
     We already know WHO is talking each moment from the mics. So for each camera
     we sample frames at spread-out moments, measure each visible person's mouth
     motion, and correlate that with each speaker's talk timeline. The face whose
     mouth moves when Abby talks is Abby. It's a
     proposal the user reviews. Works on footage the panel can decode (H.264 etc.);
     undecodable cameras are left for manual tagging. */
  var _vcache = {}, _grabCanvas = null, _rgbaCanvas = null;
  var _decodeNotes = {};
  function evalScriptP(code){ return new Promise(function(res){ CEP.evalScript(code, function(r){ res(r); }); }); }
  function yieldUI(){ return new Promise(function(r){ setTimeout(r, 0); }); }
  function noteDecode(path, msg){
    var k=String(path||"");
    if(!k || _decodeNotes[k]) return;
    _decodeNotes[k]=true;
    log("[detect] "+baseName(k)+": "+msg);
  }
  function getVideoEl(path){
    if(_vcache[path]) return _vcache[path];
    var v=document.createElement("video"); v.muted=true; v.preload="auto";
    var entry={ v:v, ok:false, dur:0 };
    entry.ready=new Promise(function(resolve){
      var settled=false;
      function done(ok){ if(settled) return; settled=true; entry.ok=ok; resolve(ok); }
      v.addEventListener("loadedmetadata", function(){ if(v.videoWidth){ entry.dur=v.duration||0; done(true); } else done(false); });
      v.addEventListener("error", function(){ noteDecode(path, "video frames could not be decoded by the panel; tag this camera manually."); done(false); });
      setTimeout(function(){ noteDecode(path, "timed out while opening video frames; tag this camera manually."); done(false); }, 5000);
      try{ v.src=fileURL(path); v.load(); }catch(e){ done(false); }
    });
    _vcache[path]=entry; return entry;
  }
  function seekTo(entry, srcT){
    return new Promise(function(resolve){
      var v=entry.v, settled=false;
      function onSeeked(){ if(settled) return; settled=true; v.removeEventListener("seeked", onSeeked); resolve(true); }
      v.addEventListener("seeked", onSeeked);
      setTimeout(function(){ if(!settled){ settled=true; v.removeEventListener("seeked", onSeeked); resolve(false); } }, 2200);
      try{
        var lim=(entry.dur||srcT+1)-0.05;
        var t=Math.max(0, Math.min(lim, srcT));
        if(Math.abs(v.currentTime-t)<0.002) t=Math.min(lim, t+0.02);
        v.currentTime=t;
      }catch(e){ settled=true; v.removeEventListener("seeked", onSeeked); resolve(false); }
    });
  }
  // grayscale of the whole frame, scaled to w×h (v1 fallback motion)
  function grabGray(path, srcT, w, h){
    var entry=getVideoEl(path);
    return entry.ready.then(function(ok){ if(!ok) return null; return seekTo(entry, srcT).then(function(s){ if(!s) return null;
      try{ if(!_grabCanvas) _grabCanvas=document.createElement("canvas"); var c=_grabCanvas; c.width=w; c.height=h; var ctx=c.getContext("2d");
        ctx.drawImage(entry.v,0,0,w,h); var d=ctx.getImageData(0,0,w,h).data, g=new Float32Array(w*h);
        for(var i=0,p=0;i<d.length;i+=4,p++) g[p]=0.299*d[i]+0.587*d[i+1]+0.114*d[i+2]; return g;
      }catch(e){ return null; } }); });
  }
  // full RGBA frame scaled to w×h (for the face detector)
  function grabRGBA(path, srcT, w, h){
    var entry=getVideoEl(path);
    return entry.ready.then(function(ok){ if(!ok) return null; return seekTo(entry, srcT).then(function(s){ if(!s) return null;
      try{ if(!_rgbaCanvas) _rgbaCanvas=document.createElement("canvas"); var c=_rgbaCanvas; c.width=w; c.height=h; var ctx=c.getContext("2d");
        ctx.drawImage(entry.v,0,0,w,h); return ctx.getImageData(0,0,w,h);
      }catch(e){ return null; } }); });
  }
  // RGBA frame letterboxed (aspect preserved, black bars) into S×S — so the face
  // detector sees undistorted faces (squashing a 16:9 frame into a square makes
  // the detector miss small/side faces in a wide). Returns the placement box too.
  function grabRGBALB(path, srcT, S){
    var entry=getVideoEl(path);
    return entry.ready.then(function(ok){ if(!ok) return null; return seekTo(entry, srcT).then(function(s){ if(!s) return null;
      try{
        var v=entry.v, vw=v.videoWidth||16, vh=v.videoHeight||9;
        var scale=Math.min(S/vw, S/vh), dw=Math.max(1,Math.round(vw*scale)), dh=Math.max(1,Math.round(vh*scale));
        var ox=Math.floor((S-dw)/2), oy=Math.floor((S-dh)/2);
        if(!_rgbaCanvas) _rgbaCanvas=document.createElement("canvas");
        var c=_rgbaCanvas; c.width=S; c.height=S; var ctx=c.getContext("2d");
        ctx.fillStyle="#000"; ctx.fillRect(0,0,S,S);
        ctx.drawImage(v, 0,0,vw,vh, ox,oy,dw,dh);
        return { img: ctx.getImageData(0,0,S,S), ox:ox, oy:oy, dw:dw, dh:dh };
      }catch(e){ return null; } }); });
  }
  // grayscale of just the mouth ROI (normalized rect), scaled to outW×outH
  function grabROIGray(path, srcT, roi, outW, outH){
    var entry=getVideoEl(path);
    return entry.ready.then(function(ok){ if(!ok) return null; return seekTo(entry, srcT).then(function(s){ if(!s) return null;
      try{ var v=entry.v, vw=v.videoWidth, vh=v.videoHeight;
        var sx=Math.max(0,Math.min(vw-2, roi.x*vw)), sy=Math.max(0,Math.min(vh-2, roi.y*vh));
        var sw=Math.max(2,Math.min(vw-sx, roi.w*vw)), sh=Math.max(2,Math.min(vh-sy, roi.h*vh));
        if(!_grabCanvas) _grabCanvas=document.createElement("canvas"); var c=_grabCanvas; c.width=outW; c.height=outH; var ctx=c.getContext("2d");
        ctx.drawImage(v, sx,sy,sw,sh, 0,0,outW,outH);
        var d=ctx.getImageData(0,0,outW,outH).data, g=new Float32Array(outW*outH);
        for(var i=0,p=0;i<d.length;i+=4,p++) g[p]=0.299*d[i]+0.587*d[i+1]+0.114*d[i+2]; return g;
      }catch(e){ return null; } }); });
  }
  function clipAtTime(clips, seqT){
    for(var i=0;i<clips.length;i++){ var c=clips[i]; if(seqT>=c.seq_start_s && seqT < c.seq_start_s + c.dur_s) return { mediaPath:c.mediaPath, srcT: c.src_in_s + (seqT - c.seq_start_s) }; }
    return null;
  }
  // Find each STABLE face on a camera by clustering detections across frames by
  // position, and return their mouth ROIs (normalized rects), left→right. One ROI
  // = solo shot; several = a group/2-shot we then resolve per person.
  async function findFaces(clips, sampleTimes){
    if(typeof OnMicFace==="undefined" || !OnMicFace.isReady()) return { rois:[], faceW:0 };
    var INSZ=OnMicFace.INSZ, picks=Math.min(8, sampleTimes.length), dets=[], frames=0;
    for(var d=0; d<picks; d++){
      var t=sampleTimes[Math.floor((d+0.5)*sampleTimes.length/picks)];
      var hit=clipAtTime(clips, t); if(!hit) continue;
      var lb=await grabRGBALB(hit.mediaPath, hit.srcT, INSZ); if(!lb) continue;
      frames++;
      var faces=await OnMicFace.detect(lb.img, 0.5);
      var dw=lb.dw||INSZ, dh=lb.dh||INSZ;
      for(var fi=0; fi<faces.length; fi++){
        var fc=faces[fi]; if(fc.score<0.5) continue;
        var mr=OnMicFace.mouthRect(fc); if(!mr) continue;
        // map detector coords (letterboxed S×S) back to original-frame normalized
        dets.push({ cx:((fc.x+fc.w/2)-lb.ox)/dw, cy:((fc.y+fc.h/2)-lb.oy)/dh, fw:(fc.w/dw),
          roi:{ x:(mr.x-lb.ox)/dw, y:(mr.y-lb.oy)/dh, w:mr.w/dw, h:mr.h/dh } });
      }
      await yieldUI();
    }
    if(!frames || !dets.length) return { rois:[], faceW:0 };
    // cluster by face-center proximity (different people sit > ~14% apart)
    var clusters=[];
    for(var i=0;i<dets.length;i++){
      var det=dets[i], best=null, bd=1e9;
      for(var ci=0;ci<clusters.length;ci++){ var cl=clusters[ci]; var dx=cl.cx-det.cx, dy=cl.cy-det.cy, dd=dx*dx+dy*dy; if(dd<bd){bd=dd;best=cl;} }
      if(best && bd < 0.02){ best.dets.push(det); best.cx=(best.cx*best.n+det.cx)/(best.n+1); best.cy=(best.cy*best.n+det.cy)/(best.n+1); best.n++; }
      else clusters.push({ cx:det.cx, cy:det.cy, n:1, dets:[det] });
    }
    function med(a){ a=a.slice().sort(function(x,y){return x-y;}); return a[Math.floor(a.length/2)]; }
    var minSeen=Math.max(2, Math.ceil(frames*0.4)), out=[];
    for(var c2=0;c2<clusters.length;c2++){ var cc=clusters[c2]; if(cc.dets.length<minSeen) continue;
      out.push({ x:med(cc.dets.map(function(z){return z.roi.x;})), y:med(cc.dets.map(function(z){return z.roi.y;})),
                 w:med(cc.dets.map(function(z){return z.roi.w;})), h:med(cc.dets.map(function(z){return z.roi.h;})), cx:cc.cx });
    }
    out.sort(function(a,b){ return a.cx-b.cx; });
    var fwAll=dets.map(function(z){ return z.fw; });
    return { rois:out, faceW: fwAll.length? med(fwAll) : 0 };
  }
  // Mean absolute frame-to-frame difference inside a normalized ROI of two W×H
  // grayscale frames. High = movement (a talking mouth).
  function roiMotion(gA, gB, W, H, roi){
    var x0=Math.max(0,Math.floor(roi.x*W)), y0=Math.max(0,Math.floor(roi.y*H));
    var x1=Math.min(W,Math.ceil((roi.x+roi.w)*W)), y1=Math.min(H,Math.ceil((roi.y+roi.h)*H));
    if(x1-x0<1 || y1-y0<1) return NaN;
    var sum=0,cnt=0;
    for(var y=y0;y<y1;y++){ var row=y*W; for(var x=x0;x<x1;x++){ var i=row+x; var dd=gA[i]-gB[i]; sum+= dd<0?-dd:dd; cnt++; } }
    return cnt? sum/cnt : NaN;
  }
  // Precompute silero VAD speech masks per mic so the speaking timeline is
  // AI-clean (sharper correlation) rather than raw loudness. Best-effort.
  async function precomputeVad(cfg){
    if(typeof OnMicVAD==="undefined") return false;
    try { if(!OnMicVAD.isReady()) await OnMicVAD.init(panelFolder(), fs, log); } catch(e){}
    if(!OnMicVAD.isReady || !OnMicVAD.isReady()) return false;
    var hopS=(cfg.params.analysis_hop_ms||20)/1000, nSp=cfg.speakers.length, any=false;
    var reusedMasks=0, computedMasks=0;
    for(var i=0;i<nSp;i++){
      status("Detecting cameras… AI listening to "+cfg.speakers[i].name);
      var clips=cfg.speakers[i].clips||[];
      for(var ci=0;ci<clips.length;ci++){
        var c=clips[ci];
        try {
          var nH=Math.ceil((c.dur_s||0)/hopS);
          // v1.03: reuse the same memory/disk mask caches as the main analysis —
          // camera detection used to redo minutes of AI inference on masks the
          // analysis had already computed for the identical clips.
          var ck = vadCacheKey(c, hopS);
          if (ck && vadMaskCache[ck]){ c._vadMask = copyMask(vadMaskCache[ck]); any=true; reusedMasks++; continue; }
          if (ck){
            var diskMask = loadVadMaskFromDisk(ck, nH);
            if (diskMask){ c._vadMask = diskMask; vadMaskCache[ck] = copyMask(diskMask); any=true; reusedMasks++; continue; }
          }
          var fbBefore2 = (OnMicVAD.fallbacks ? OnMicVAD.fallbacks().count : 0);
          c._vadMask = await OnMicVAD.maskForClip(
            (function(cc){ return function(inS,durS){
              var rd=Engine.readChannelWindow(cc.wav, cc.channel||0, (cc.src_in_s||0)+inS, durS);
              var gd=+cc.gain_db||0;
              if(gd && rd && rd.samples && rd.samples.length){
                var mul=Math.pow(10, gd/20);
                for(var gi=0; gi<rd.samples.length; gi++) rd.samples[gi]=Math.max(-1,Math.min(1,rd.samples[gi]*mul));
              }
              return rd;
            }; })(c),
            c.dur_s, hopS, nH, null,
            // v1.05 (audit #7): per-chunk cancellation, same as the main analysis.
            { shouldCancel: function(){ return cancelRequested; } }); any=true; computedMasks++;
          // v1.04: don't cache a clip that silently degraded to loudness (see runAnalysis).
          var fbClean2 = (OnMicVAD.fallbacks ? OnMicVAD.fallbacks().count : 0) === fbBefore2;
          if (ck && c._vadMask && voiceReady() && fbClean2) { vadMaskCache[ck] = copyMask(c._vadMask); saveVadMaskToDisk(ck, c._vadMask); }
        } catch(e){ if(e && e.message==="cancelled") throw e; }
      }
      await yieldUI();
    }
    if (reusedMasks) log("[detect] reused "+reusedMasks+" cached voice mask"+(reusedMasks===1?"":"s")+"; analyzed "+computedMasks+" new.");
    // safety: if VAD marked almost nothing, drop it (fall back to energy gating)
    var ones=0,hops=0;
    cfg.speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ if(c._vadMask){ hops+=c._vadMask.length; for(var k=0;k<c._vadMask.length;k++) ones+=c._vadMask[k]; } }); });
    if(hops>0 && ones/hops < 0.01){ cfg.speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ delete c._vadMask; }); }); return false; }
    return any;
  }
  // v1.05 (audit #7): thin wrapper so the cached <video> elements (and their
  // open file handles) are released on EVERY exit — the cleanup used to run only
  // on the success path, so Stop or an error left camera files held open.
  async function detectCameras(){
    try { await detectCamerasInner(); }
    finally {
      try { for(var pth in _vcache){ var ve=_vcache[pth]; if(ve&&ve.v){ ve.v.removeAttribute("src"); ve.v.load(); } } } catch(e){}
      _vcache={};
    }
  }
  async function detectCamerasInner(){
    checkCancelled();
    if(!Engine){ status("Engine not loaded.", true); return; }
    // v1.05 (audit #7): re-read the sequence first, exactly like runPipeline —
    // this used to correlate FRESH video frames against a config built from
    // STALE in-memory audio tracks, mismatching mics and cameras after any
    // timeline change since the last read.
    status("Re-reading tracks…");
    var readOk = await new Promise(function(res){ readTracks(res); });
    if (readOk===false){ status("Couldn't read the sequence — nothing was run. Open the right sequence and try again.", true); return; }
    checkCancelled();
    var STEP=0.5, MAXS=80, DELTA=0.15, MW=256, MH=144;   // motion res quartered + fewer samples = ~5x faster detect (mouth-motion correlation is robust at this res)
    var cfg; try { cfg=buildConfig(); } catch(e){ status("Couldn't read setup: "+e.message, true); return; }
    if(!cfg.speakers.some(function(s){ return s.wav; })){ status("No mic audio found — set speakers first, then click Re-read tracks.", true); return; }
    clearPreview();
    log("[detect] Camera detection is a best-effort suggestion. Review every tag before previewing cuts.");

    // Load the face model up front — mouth detection is the main signal now.
    var faceReady=false;
    if(typeof OnMicFace!=="undefined"){
      status("Detecting cameras… loading face model"); await yieldUI();
      checkCancelled();
      try { faceReady = await OnMicFace.init(panelFolder(), fs, log); } catch(e){ faceReady=false; }
      if(!faceReady) log("[detect] face model unavailable ("+(OnMicFace.getError?OnMicFace.getError():"?")+") — falling back to whole-frame motion");
    }

    // Cleaner "who's talking when" timeline from the AI voice model when available.
    status("Detecting cameras… preparing voice timeline"); await yieldUI();
    checkCancelled();
    var usedVad=false; try { usedVad = await precomputeVad(cfg); } catch(e){}
    log("[detect] speaking timeline: "+(usedVad?"AI voice detection (silero)":"mic loudness"));

    var sm; try { sm=Engine.speakingMatrix(cfg, {}, STEP); } catch(e){ status("Couldn't analyze mics: "+e.message, true); return; }
    var totalDur=sm.nSteps*sm.stepS;
    if(totalDur<6){ status("Timeline too short to suggest camera tags.", true); return; }

    var n=Math.max(12, Math.min(MAXS, sm.nSteps));
    var sampleTimes=new Array(n);
    for(var i=0;i<n;i++) sampleTimes[i]= 0.5 + (totalDur-1.5)*(i/(n-1));
    function spkAt(arr, t){ var idx=Math.floor(t/sm.stepS); if(idx<0) idx=0; if(idx>=sm.nSteps) idx=sm.nSteps-1; return arr[idx]; }
    var spkSampled=[], anySampled=new Float32Array(n);
    for(var s=0;s<sm.names.length;s++){ var a=new Float32Array(n); for(var j=0;j<n;j++) a[j]=spkAt(sm.speaking[s], sampleTimes[j]); spkSampled.push(a); }
    for(var k=0;k<n;k++) anySampled[k]=spkAt(sm.anyone, sampleTimes[k]);
    var smSampled={ names:sm.names, speaking:spkSampled, anyone:anySampled };

    var vr; try { vr=JSON.parse(await evalScriptP("mcsReadVideoClips()")); } catch(e){ vr=null; }
    if(!vr||!vr.ok){ status("Couldn't read video tracks for detection.", true); return; }
    var byIndex={}; vr.tracks.forEach(function(t){ byIndex[t.index]=t; });

    var nCam=iCam(), camResult=[];
    for(var c=0;c<nCam;c++){
      checkCancelled();
      var trk=byIndex[c+1];
      if(!trk || !trk.clips || !trk.clips.length){ camResult.push(null); continue; }
      status("Detecting cameras… V"+(c+1)+" finding faces"); await yieldUI();
      var ff = faceReady ? await findFaces(trk.clips, sampleTimes) : { rois:[], faceW:0 };
      var rois = ff.rois;

      if(rois.length){
        // sample each frame ONCE, crop EVERY face's mouth ROI from it (cheap)
        var faceMotions=[], valids=[];
        for(var r0=0;r0<rois.length;r0++){ faceMotions.push(new Float32Array(n)); valids.push(0); }
        for(var p=0;p<n;p++){
          checkCancelled();
          var hit=clipAtTime(trk.clips, sampleTimes[p]);
          var gA=hit? await grabGray(hit.mediaPath, hit.srcT, MW, MH) : null;
          var gB=(hit&&gA)? await grabGray(hit.mediaPath, hit.srcT+DELTA, MW, MH) : null;
          for(var ri=0;ri<rois.length;ri++){
            var mv=(gA&&gB)? roiMotion(gA,gB,MW,MH,rois[ri]) : NaN;
            faceMotions[ri][p]=mv; if(mv===mv) valids[ri]++;
          }
          if((p%10)===0){ status("Detecting cameras… V"+(c+1)+" "+Math.round(100*p/n)+"%"); await yieldUI(); }
        }
        var keep=[]; for(var ri2=0;ri2<rois.length;ri2++){ if(valids[ri2]>=n*0.3) keep.push(faceMotions[ri2]); }
        if(keep.length){
          // every visible face is a real person → assign each one (don't drop on
          // weak match); membership = the people the camera shows, identity = best match.
          // Weak mouth-motion correlation is not a usable identity. Applying it
          // anyway made a nodding listener look like an intentional reaction
          // shot later, even though Reaction Shots was Off.
          var asg=Engine.assignFacesToSpeakers(keep, smSampled, { minCorr:0.12 });
          var _cc=(asg.corr||[]).filter(function(x){return x===x;}); var _conf=_cc.length?Math.max.apply(null,_cc):0;
          camResult.push({ speakers:asg.speakers.slice(), faces:keep.length, faceW:ff.faceW, undetected:asg.speakers.length===0, conf:_conf });
          log("[detect] V"+(c+1)+": "+keep.length+" face"+(keep.length===1?"":"s")+" → "+
              (asg.speakers.length? asg.speakers.map(function(si){ return sm.names[si]; }).join(", ") : "unsure")+
              "  [corr "+asg.corr.map(function(cv){ return (cv||0).toFixed(2); }).join("/")+"]");
        } else { camResult.push({ speakers:[], faces:0, undetected:true }); log("[detect] V"+(c+1)+": faces found but frames unreadable"); }
      } else {
        // Whole-frame movement cannot distinguish talking from nodding, hand
        // gestures, reframing, or lighting changes. Leave this camera manual
        // instead of silently applying a plausible-looking wrong speaker tag.
        camResult.push({ speakers:[], faces:0, undetected:true });
        log("[detect] V"+(c+1)+": no stable face — tag manually (whole-frame motion is not used for identity)");
      }
    }
    // (video-element cleanup moved to detectCameras()'s finally — v1.05, audit #7)
    cfg.speakers.forEach(function(s){ (s.clips||[]).forEach(function(c){ delete c._vadMask; }); });   // free VAD masks

    // Wide-shot rule: a camera whose faces are much smaller than the tightest
    // shot's faces is framing the whole room → tag everyone (the detector can't
    // reliably find every face in a wide, esp. profile / mic-blocked ones).
    var maxFW=0; for(var mi=0;mi<camResult.length;mi++){ if(camResult[mi] && camResult[mi].faceW>maxFW) maxFW=camResult[mi].faceW; }
    if(maxFW>0){
      for(var mj=0;mj<camResult.length;mj++){
        var rj=camResult[mj];
        if(rj && rj.faces>=2 && rj.faceW>0 && rj.faceW < 0.5*maxFW){
          var allz=[]; for(var z2=0;z2<sm.names.length;z2++) allz.push(z2);
          rj.speakers=allz; rj.wide=true;
          log("[detect] V"+(mj+1)+": faces "+(rj.faceW/maxFW).toFixed(2)+"× the tightest shot → wide / everyone");
        }
      }
    }

    applyDetectResult(camResult);
  }
  function applyDetectResult(camResult){
    var sp=speakerList(), nCam=iCam(), lines=[], detected=0, unread=0;
    for(var v=0; v<nCam; v++){
      var r=camResult[v];
      if(!r || r.undetected || !r.speakers || !r.speakers.length){ unread++; lines.push("V"+(v+1)+": couldn't read video frames here - tag manually"); continue; }
      camSel[v]={};
      var names=[];
      for(var si=0; si<r.speakers.length; si++){ var idx=r.speakers[si]; if(idx>=0 && idx<sp.length){ camSel[v][sp[idx].slot]=true; names.push(sp[idx].name); } }
      if(!names.length){ unread++; lines.push("V"+(v+1)+": unsure - tag manually"); continue; }
      lines.push("V"+(v+1)+" -> "+(r.wide?"wide / everyone":names.join(" + ")));
      detected++;
    }
    rebuildTags();
    clearPreview();
    renderDetectConf(camResult);
    log("[detect] "+lines.join("   |   "));
    if(detected) status("Suggested tags for "+detected+" camera"+(detected===1?"":"s")+(unread?(" ("+unread+" to tag manually)"):"")+" - double-check before previewing.", false);
    else status("Couldn't read enough video frames to suggest tags. Tag the cameras manually.", true);
  }

  // Per-camera confidence strip so the user knows which auto-tags to trust vs review.
  function confLevel(c){ if(c>=0.25) return ["high","#7fce9a"]; if(c>=0.12) return ["medium","#e0b870"]; return ["low - review","#e88a8a"]; }
  function renderDetectConf(camResult){
    var el=$("detectConf"); if(!el) return;
    var nCam=iCam(), rows="";
    for(var v=0; v<nCam; v++){
      var r=camResult[v], label, col, pct;
      if(!r || r.undetected || !r.speakers || !r.speakers.length){ label="tag manually"; col="#e88a8a"; pct=8; }
      else if(r.wide){ label="wide / everyone"; col="#878d99"; pct=100; }
      else if(typeof r.conf==="number"){ var L=confLevel(r.conf); col=L[1]; label=L[0]; pct=Math.max(8,Math.min(100,Math.round(r.conf/0.35*100))); }
      else { label="low - review"; col="#e88a8a"; pct=15; }
      rows+='<div class="cfrow"><span class="cflbl">V'+(v+1)+'</span>'+
            '<span class="cfbar"><i style="width:'+pct+'%;background:'+col+'"></i></span>'+
            '<span class="cftxt" style="color:'+col+'">'+label+'</span></div>';
    }
    el.innerHTML='<div class="cfhead">Suggested camera tags - review before using</div>'+rows;
    el.style.display="block";
  }

  // Hidden diagnostic (Option/Alt-click the version number): tests whether this
  // Premiere build's panel can decode video frames natively. No visible UI.
  function probeFrames(){
    status("Testing camera frame access…"); log("[frametest] starting…");
    CEP.evalScript("mcsProbeVideo()", function(res){
      var r; try{ r=JSON.parse(res); }catch(e){ log("[frametest] bad host response: "+res); return; }
      if(!r.ok){ log("[frametest] "+r.message); status("Frame test: "+r.message,true); return; }
      log("[frametest] V"+r.track+" clip: "+(r.name||r.path)); var url=fileURL(r.path); log("[frametest] "+url);
      var v=document.createElement("video"); v.muted=true; v.preload="auto"; var done=false;
      function finish(msg,ok){ if(done) return; done=true; log("[frametest] RESULT: "+msg); status(ok?"Frame test: works ✓":"Frame test: see log",!ok); try{v.removeAttribute("src"); v.load();}catch(e){} }
      v.addEventListener("error", function(){ finish("video error — codec NOT decodable in this panel (would need ffmpeg).",false); });
      v.addEventListener("loadedmetadata", function(){ log("[frametest] metadata "+v.videoWidth+"x"+v.videoHeight+" dur "+((v.duration||0).toFixed(1))+"s"); if(!v.videoWidth){ finish("0x0 — no decode (would need ffmpeg).",false); return; } try{ v.currentTime=Math.min(1,(v.duration||2)/2); }catch(e){ finish("seek failed: "+(e&&e.message),false); } });
      v.addEventListener("seeked", function(){ try{ var c=document.createElement("canvas"); c.width=v.videoWidth; c.height=v.videoHeight; var ctx=c.getContext("2d"); ctx.drawImage(v,0,0,c.width,c.height); var w=Math.min(80,c.width),h=Math.min(80,c.height),d=ctx.getImageData(0,0,w,h).data,mx=0,k; for(k=0;k<d.length;k+=4) if(d[k]>mx) mx=d[k]; finish("DECODED a frame "+v.videoWidth+"x"+v.videoHeight+" (pixel max "+mx+"). Browser frame-grab WORKS.", mx>5); }catch(e){ finish("draw/read failed: "+(e&&e.message)+" — would need ffmpeg.",false); } });
      setTimeout(function(){ finish("timed out (8s) — no decode; would need ffmpeg.",false); }, 8000);
      try{ v.src=url; v.load(); }catch(e){ finish("could not set src: "+(e&&e.message),false); }
    });
  }

  /* ---- sequence linter (proactive pre-flight check) ----
     Uses data already gathered by readTracks(): audioTracks (numClips/mediaPath/
     channel), videoTracks (numClips), channelInfo (mix vs isolated mics) and the
     current speaker/camera tagging. Produces plain-language findings. Non-blocking.
     Returns { items:[{level,text}], blocking:Bool(false), warnings:Int }. */
  function lintSequence(){
    var items=[];
    function add(level,text){ items.push({ level:level, text:text }); }

    // AI voice detection status — the single biggest factor in cut accuracy. If it
    // isn't loaded, OnMic is on the loudness fallback and quiet speakers get missed.
    if(!voiceReady()){
      add("bad","<b>AI voice detection isn't loaded</b> — OnMic is using a much less accurate loudness-only fallback, so quieter speakers may not get the camera. OnMic auto-downloads the AI (watch the Log); once it says \"AI voice detection ready\", relaunch the OnMic panel. If it never downloads, run fetch_vendor or reinstall.");
    }

    var camTracks = videoTracks.filter(function(v){ return v && v.numClips>0; });
    var nCamTracks = camTracks.length;
    var micTracks = audioTracks.filter(function(t){ return t && t.numClips>0 && t.mediaPath; });
    var nMicTracks = micTracks.length;
    var locked=[];
    audioTracks.forEach(function(t){ if(t&&t.locked) locked.push("A"+t.index); });
    videoTracks.forEach(function(v){ if(v&&v.locked) locked.push("V"+v.index); });
    if(locked.length){
      var nonEmpty = nCamTracks + nMicTracks;
      if(nonEmpty>0 && locked.length>=nonEmpty){
        add("warn","<b>Premiere says nearly every track is locked:</b> "+locked.join(", ")+". This is usually a stale read, not real locks. <b>Try these in order:</b> 1) click <b>Re-read tracks</b>; 2) if they still show locked, close and reopen the sequence; 3) if it persists, restart Premiere, then re-read. OnMic will refuse to cut or trim while Premiere reports locks, to protect your timeline.");
      } else {
        add("bad","<b>Some tracks are locked:</b> "+locked.join(", ")+". Unlock them before applying cuts or trimming, so Premiere can move everything together.");
      }
    }

    // 1) cameras stacked on separate video tracks?
    if (nCamTracks<=1){
      add("bad","<b>Cameras look merged onto one video track.</b> Put each camera angle on its own stacked video track (V1, V2, V3...) so OnMic can switch between them.");
    } else {
      add("ok",nCamTracks+" video tracks have clips — cameras look stacked correctly.");
    }

    // 2) minimum cameras
    if (iCam()<2 || nCamTracks<2){
      add("warn","<b>OnMic needs at least 2 cameras.</b> With one angle there is nothing to cut between. Add another camera on its own video track.");
    }
    if (iCam()>nCamTracks){
      add("warn","<b>You selected "+iCam()+" cameras, but only "+nCamTracks+" video track"+(nCamTracks===1?" has":"s have")+" clips.</b> Lower the Cameras count or add the missing camera track.");
    }
    // 2b) minimum mics
    if (iSp()<2 || nMicTracks<2){
      add("warn","<b>OnMic needs at least 2 isolated mics.</b> Each person needs their own mic track or isolated channel so OnMic can tell who is talking.");
    }
    if (iSp()>nMicTracks && !(channelInfo && channelInfo.suggested_iso && channelInfo.suggested_iso.length>=iSp())){
      add("warn","<b>You selected "+iSp()+" speakers, but OnMic only sees "+nMicTracks+" audio track"+(nMicTracks===1?"":"s")+" with media.</b> Re-read the sequence or check that every speaker's mic is present.");
    }

    // 3) single stereo MIX rather than isolated mics (uses channelInfo).
    var nIso = (channelInfo && channelInfo.suggested_iso) ? channelInfo.suggested_iso.length : -1;
    var nMix = (channelInfo && channelInfo.mix) ? channelInfo.mix.length : 0;
    if (channelInfo){
      if (nIso<=1 && nMix>=1 && nMicTracks<=1){
        add("bad","<b>Your audio looks like one mixed track.</b> OnMic needs one isolated mic per person, either as separate audio tracks or separate channels.");
      } else if (nIso>=2){
        add("ok",nIso+" isolated mic channels detected — good.");
      }
    } else if (nMicTracks>=2){
      add("ok",nMicTracks+" audio tracks have mic clips — looks like isolated mics.");
    }
    var suspicious=[];
    audioTracks.forEach(function(t){
      var nm=String((t&&t.name)||"").toLowerCase();
      if(t&&t.numClips>0 && /(mix|music|scratch|guide|camera|cam audio|reference|program|master)/i.test(nm)){
        suspicious.push("A"+t.index+" \""+esc(t.name)+"\"");
      }
    });
    if(suspicious.length){
      add("warn","<b>Some mic tracks look like they may not be isolated speaker mics:</b> "+suspicious.slice(0,4).join(", ")+(suspicious.length>4?"...":"")+". OnMic needs one clean mic per person.");
    }

    var missingAudio=[], nonWavAudio=[];
    audioTracks.forEach(function(t){
      (t.clips||[]).forEach(function(c){
        if(!c.mediaPath) return;
        try { if(fs && !fs.existsSync(c.mediaPath)) missingAudio.push("A"+t.index+" "+esc(baseName(c.mediaPath))); } catch(e){}
        if(!/\.(wav|wave)$/i.test(String(c.mediaPath).split("?")[0])) nonWavAudio.push("A"+t.index+" "+esc(baseName(c.mediaPath)));
      });
    });
    if(missingAudio.length){
      add("bad","<b>Some mic files are missing from disk.</b> Premiere points to files OnMic cannot read: "+missingAudio.slice(0,3).join(", ")+(missingAudio.length>3?"…":"")+". Relink the media, then re-read.");
    }
    if(nonWavAudio.length){
      add("bad","<b>Some mic clips are not WAV files.</b> OnMic needs isolated WAV mic audio. Export or relink these as WAV, then re-read: "+nonWavAudio.slice(0,3).join(", ")+(nonWavAudio.length>3?"…":"")+".");
    }

    var missingVideo=[], speedClips=0;
    videoTracks.forEach(function(vt){
      (vt.clips||[]).forEach(function(c){
        if(c.speedChanged) speedClips++;
        if(!c.mediaPath) return;
        try { if(fs && !fs.existsSync(c.mediaPath)) missingVideo.push("V"+vt.index+" "+esc(baseName(c.mediaPath))); } catch(e){}
      });
    });
    if(missingVideo.length){
      add("warn","<b>Some camera files look offline.</b> OnMic can still analyze the mics, but camera detection may fail until you relink: "+missingVideo.slice(0,3).join(", ")+(missingVideo.length>3?"…":"")+".");
    }
    if(speedClips){
      add("warn","<b>"+speedClips+" speed-changed video clip"+(speedClips===1?"":"s")+" found.</b> Non-destructive switching is safest here; check the backup if a speed-changed clip behaves strangely.");
    }

    function selectedTimelineTracks(list, count){
      var out=[], n=Math.max(0, +count||0);
      for(var i=0;i<n && i<list.length;i++){
        if(list[i] && list[i].numClips>0) out.push(list[i]);
      }
      return out;
    }
    function trackRange(t){
      var clips=(t&&t.clips)||[], ok=false, st=0, en=0;
      for(var i=0;i<clips.length;i++){
        var c=clips[i];
        if(c.startSec==null || c.endSec==null) continue;
        var cs=+c.startSec, ce=+c.endSec;
        if(!isFinite(cs) || !isFinite(ce) || ce<=cs) continue;
        if(!ok || cs<st) st=cs;
        if(!ok || ce>en) en=ce;
        ok=true;
      }
      return ok ? { ok:true, start:st, end:en, track:t } : { ok:false };
    }
    function groupRange(list){
      var ranges=[], ok=false, st=0, en=0;
      list.forEach(function(t){
        var r=trackRange(t);
        if(!r.ok) return;
        ranges.push(r);
        if(!ok || r.start<st) st=r.start;
        if(!ok || r.end>en) en=r.end;
        ok=true;
      });
      return { ok:ok, start:st, end:en, ranges:ranges };
    }
    function trLabel(prefix, r){ return prefix+((r&&r.track&&r.track.index)||"?"); }
    function addSyncSpreadWarning(kind, prefix, tracks){
      var g=groupRange(tracks);
      if(!g.ok || g.ranges.length<2) return;
      var starts=g.ranges.slice().sort(function(a,b){ return a.start-b.start; });
      var first=starts[0], last=starts[starts.length-1], startDiff=last.start-first.start;
      if(startDiff>2){
        add("warn","<b>"+kind+" tracks do not start together.</b> "+trLabel(prefix,last)+" starts about "+fmtPlainDur(startDiff)+" after "+trLabel(prefix,first)+". Check sync before applying cuts.");
      }
      var ends=g.ranges.slice().sort(function(a,b){ return a.end-b.end; });
      var early=ends[0], late=ends[ends.length-1], endDiff=late.end-early.end;
      if(endDiff>10){
        add("warn","<b>"+kind+" tracks do not end together.</b> "+trLabel(prefix,early)+" ends about "+fmtPlainDur(endDiff)+" before "+trLabel(prefix,late)+". That can be normal if a source stopped early, but check the end of the edit first.");
      }
    }
    var syncMicTracks=selectedTimelineTracks(audioTracks, iSp());
    var syncCamTracks=selectedTimelineTracks(videoTracks, iCam());
    addSyncSpreadWarning("Mic", "A", syncMicTracks);
    addSyncSpreadWarning("Camera", "V", syncCamTracks);
    var micRange=groupRange(syncMicTracks), camRange=groupRange(syncCamTracks);
    if(micRange.ok && camRange.ok){
      var startGap=micRange.start-camRange.start;
      if(Math.abs(startGap)>2){
        add("warn","<b>Mics and cameras do not start at the same time.</b> The first "+(startGap<0?"mic":"camera")+" starts about "+fmtPlainDur(Math.abs(startGap))+" before the first "+(startGap<0?"camera":"mic")+". Check sync before analyzing or cutting.");
      }
      var endGap=micRange.end-camRange.end;
      if(Math.abs(endGap)>10){
        add("warn","<b>Audio and video lengths do not quite match.</b> Mics end around "+fmtMMSS(micRange.end)+" and cameras end around "+fmtMMSS(camRange.end)+". That can be fine, but check the timeline is synced.");
      }
    }

    // 5) cameras / speakers fully tagged?
    var nCam=iCam(), untagged=[];
    for (var v=0; v<nCam; v++){ if(!camNames(v).length) untagged.push("V"+(v+1)); }
    var sp=speakerList(), covered={};
    for (var c=0;c<nCam;c++){ camNames(c).forEach(function(nm){ covered[nm.toLowerCase()]=true; }); }
    var unshown=sp.filter(function(s){ return !covered[s.name.toLowerCase()]; }).map(function(s){ return s.name; });
    if (untagged.length){
      add("warn","<b>"+untagged.join(", ")+" "+(untagged.length===1?"has":"have")+" no speakers tagged.</b> In Tag Speakers, choose who appears on each camera.");
    }
    if (unshown.length){
      add("warn","<b>"+unshown.join(", ")+" "+(unshown.length===1?"isn’t":"aren’t")+" tagged on any camera.</b> Tag at least one camera that shows "+(unshown.length===1?"that speaker":"each speaker")+".");
    }
    if (!untagged.length && !unshown.length && nCam>=2){
      add("ok","Every camera and speaker is tagged.");
    }

    var warnings=0; items.forEach(function(it){ if(it.level!=="ok" && it.level!=="note") warnings++; });
    return { items:items, blocking:false, warnings:warnings };
  }
  function renderLint(res){
    var el=$("lintPanel"); if(!el) return;
    var icon={ ok:"✓", note:"i", warn:"!", bad:"✕" };
    var head = res.warnings===0
      ? '<span style="color:var(--good)">✓</span> Your sequence looks ready for OnMic.'
      : '<span style="color:#e0b870">!</span> '+res.warnings+' thing'+(res.warnings===1?'':'s')+' to check before you cut:';
    var rows=res.items.map(function(it){
      return '<div class="litem '+it.level+'"><span class="lic">'+(icon[it.level]||'•')+'</span><span class="ltext">'+it.text+'</span></div>';
    }).join("");
    el.innerHTML='<div class="ltitle">'+head+'</div>'+rows+lintActionsHTML(res);
    el.style.display="block";
    wireLintActions();
  }
  // "FIX MY SETUP" — a compact row of SAFE one-click actions shown under the
  // linter results. Only the actions relevant to the problems found are shown;
  // nothing here is destructive.
  function lintActionsHTML(res){
    var acts=[];
    // Re-read tracks: always useful — most lint items resolve after a fresh read.
    acts.push('<button class="small ghost" data-fix="reread">Re-read tracks</button>');
    // Rename tracks from speaker names: relevant when there are cameras tagged
    // (helps when track order / tagging looks confusing). Safe rename only.
    var anyTagged=false; for (var v=0; v<iCam(); v++){ if(camNames(v).length){ anyTagged=true; break; } }
    if(anyTagged) acts.push('<button class="small ghost" data-fix="rename">Rename tracks from speaker names</button>');
    // Add preview markers: only meaningful once a Preview has produced cuts.json.
    if(previewReady) acts.push('<button class="small ghost" data-fix="markers">Add preview markers</button>');
    if(acts.length<=1) return "";   // nothing actionable beyond re-read
    return '<div class="lactions"><span class="latitle">Fix my setup:</span>'+acts.join("")+'</div>';
  }
  function wireLintActions(){
    var el=$("lintPanel"); if(!el) return;
    var btns=el.querySelectorAll('button[data-fix]');
    for (var i=0;i<btns.length;i++){
      btns[i].addEventListener("click", function(e){
        if(e) e.stopPropagation();
        var fix=this.getAttribute("data-fix");
        if(fix==="reread") checkSequence();
        else if(fix==="rename") renameTracksFromSpeakers();
        else if(fix==="markers") doMarkers();
      });
    }
  }
  // Run a check; re-reads tracks first so it reflects the live sequence.
  function checkSequence(){
    status("Checking your sequence…");
    readTracks(function(readOk){
      if (readOk===false) return;   // v1.04: don't lint stale data / overwrite the read-failure status
      var res=lintSequence();
      renderLint(res);
      status(res.warnings===0 ? "Sequence looks ready." : res.warnings+" thing"+(res.warnings===1?'':'s')+" to check - see the panel above the buttons.", res.warnings>0);
    });
  }

  // "BEFORE YOU CUT" mini checklist near the Create button. Auto-ticks the items
  // OnMic can verify from the current track read / settings; leaves the rest as
  // plain manual reminders. Re-evaluates whenever tracks are re-read (renderAll →
  // readTracks callbacks all funnel through here). Practical, not a tutorial.
  function renderChecklist(){
    var el=$("checklist"); if(!el) return;
    var camTracks=videoTracks.filter(function(v){ return v && v.numClips>0; });
    var nCamTracks=camTracks.length;
    var micTracks=audioTracks.filter(function(t){ return t && t.numClips>0 && t.mediaPath; });
    var nMicTracks=micTracks.length;

    // each item: {state:"on"|"off"|"manual", text}. "on"/"off" = verified;
    // "manual" = OnMic can't check it, shown as a reminder (open circle).
    var items=[];

    // 1) cameras on separate stacked video tracks  (verifiable: ≥2 cam tracks)
    items.push({ state: nCamTracks>=2 ? "on" : "off",
      text:"Each camera is on its <b>own stacked video track</b> (V1, V2, …)." });

    // 2) each speaker on an isolated WAV mic  (verifiable-ish: ≥2 mic tracks AND
    //    the channel detector doesn't think it's a single mix)
    var nIso=(channelInfo&&channelInfo.suggested_iso)?channelInfo.suggested_iso.length:-1;
    var nMix=(channelInfo&&channelInfo.mix)?channelInfo.mix.length:0;
    var looksMix=(nIso<=1 && nMix>=1 && nMicTracks<=1);
    var micsIsolated=(nMicTracks>=2 || nIso>=2) && !looksMix;
    items.push({ state: micsIsolated ? "on" : "off",
      text:"Each speaker is on an <b>isolated WAV mic</b> (not one mixed track)." });

    // 3) every camera tagged  (verifiable from the panel's tags)
    var nCam=iCam(), allTagged=(nCam>=1);
    for (var v=0; v<nCam; v++){ if(!camNames(v).length){ allTagged=false; break; } }
    items.push({ state: (allTagged && nCam>=2) ? "on" : "off",
      text:"<b>Every camera is tagged</b> with who it shows." });

    // 4) "Back up timeline first" is ON  (verifiable from the toggle)
    var backupOn=!($("backupTimeline")) || !!$("backupTimeline").checked;
    items.push({ state: backupOn ? "on" : "off",
      text:"<b>“Back up timeline first”</b> is on (Advanced) — a safety copy before cutting." });

    var icon={ on:"✓", off:"○", manual:"○" };
    var rowCls={ on:"on", off:"off", manual:"off" };
    el.innerHTML=items.map(function(it){
      var tag=(it.state==="manual")?'<span class="ctag">check</span>':"";
      return '<div class="crow '+rowCls[it.state]+'"><span class="cbox">'+icon[it.state]+
             '</span><span class="ctext">'+it.text+tag+'</span></div>';
    }).join("");
  }

  function copyText(txt, cb){
    if(navigator.clipboard && navigator.clipboard.writeText){
      navigator.clipboard.writeText(txt).then(function(){ cb(true); }).catch(function(){ cb(false); });
      return;
    }
    try {
      var ta=document.createElement("textarea");
      ta.value=txt; ta.style.position="fixed"; ta.style.left="-9999px";
      document.body.appendChild(ta); ta.select();
      var ok=document.execCommand("copy");
      document.body.removeChild(ta);
      cb(!!ok);
    } catch(e){ cb(false); }
  }
  // Build a full support bundle: OnMic version, OS, Premiere version, sequence
  // track counts + lock state, sign-in/device status and the recent in-panel log.
  // (Extends the older "Copy diagnostics", added ~v0.38, rather than duplicating.)
  function osDescription(){
    try {
      var plat=(typeof process!=="undefined"&&process.platform)||"unknown";
      var rel=(os&&os.release)?os.release():"";
      var arch=(typeof process!=="undefined"&&process.arch)?process.arch:"";
      var name=plat==="darwin"?"macOS":(plat==="win32"?"Windows":plat);
      return name+(rel?(" "+rel):"")+(arch?(" ("+arch+")"):"");
    } catch(e){ return (typeof process!=="undefined"&&process.platform)||"unknown"; }
  }
  function signInDescription(){
    try {
      if(gateAllowed){
        var who=($("acctState")&&$("acctState").textContent)||"";
        who=who.replace(/\s+/g," ").replace(/^\s+|\s+$/g,"");
        return "signed in"+(who?(" — "+who):"");
      }
      var si=(window.OnMicAuth&&OnMicAuth.isSignedIn&&OnMicAuth.isSignedIn());
      return si?"signed in (not yet verified this session)":"not signed in";
    } catch(e){ return "unknown"; }
  }
  function copyDiagnostics(){
    var lines=[];
    lines.push("OnMic support bundle");
    lines.push("version: "+VERSION);
    lines.push("os: "+osDescription());
    lines.push("premiere version: "+(premiereVersion||"(unknown — re-read tracks / reconnect)"));
    lines.push("platform: "+((typeof process!=="undefined"&&process.platform)||"unknown"));
    lines.push("sign-in: "+signInDescription());
    lines.push("premiere status: "+(($("status")&&$("status").textContent)||""));
    lines.push("sequence: "+(curSeqName||"(none)"));
    lines.push("fps: "+SEQ_FPS);
    lines.push("audio tracks: "+audioTracks.length+" ("+audioTracks.filter(function(t){return t&&t.numClips>0;}).length+" with clips)");
    lines.push("video tracks: "+videoTracks.length+" ("+videoTracks.filter(function(t){return t&&t.numClips>0;}).length+" with clips)");
    lines.push("source channels: "+(timelineSourceChannelLabel()||"auto"));
    var locked=[];
    audioTracks.forEach(function(t){ if(t&&t.locked) locked.push("A"+t.index); });
    videoTracks.forEach(function(v){ if(v&&v.locked) locked.push("V"+v.index); });
    lines.push("locked tracks: "+(locked.length?locked.join(", "):"none"));
    lines.push("speakers selected: "+iSp());
    lines.push("cameras selected: "+iCam());
    lines.push("voice model ready: "+(typeof OnMicVAD!=="undefined"&&OnMicVAD.isReady&&OnMicVAD.isReady()?"yes":"no"));
    lines.push("face model ready: "+(typeof OnMicFace!=="undefined"&&OnMicFace.isReady&&OnMicFace.isReady()?"yes":"no"));
    lines.push("server latest: "+(($("updateText")&&$("updateText").textContent)||"not shown"));
    try {
      var dh=(window.OnMicAuth&&OnMicAuth.deviceStatus)?OnMicAuth.deviceStatus():null;
      if(dh) lines.push("device id: "+dh);
    } catch(eDev) {}
    if(typeof OnMicVAD!=="undefined"&&OnMicVAD.error) lines.push("voice model note: "+(OnMicVAD.error()||""));
    if(typeof OnMicFace!=="undefined"&&OnMicFace.getError) lines.push("face model note: "+(OnMicFace.getError()||""));
    lines.push("settings: pace="+val("pace")+", output="+outputMode()+", wide="+val("wideFreq")+", reactions="+(val("reactionFreq")||"0"));
    var lg=$("log")?$("log").textContent:"";
    if(lg) lines.push("recent log:\n"+lg.slice(-3000));
    copyText(lines.join("\n"), function(ok){ status(ok?"Diagnostics copied.":"Couldn't copy diagnostics — see the Log.", !ok); if(!ok) log(lines.join("\n")); });
  }

  function __onmicBoot(){
    // Boot is wrapped so NO single step can ever blank the panel or leave it
    // stuck on "Connecting…". Each step is isolated; failures are logged (and the
    // first one surfaced to the status line) but never abort the rest of startup.
    var bootErr = null;
    function safe(label, fn){ try { fn(); } catch(e){ if(!bootErr) bootErr=label+": "+(e&&e.message?e.message:e); try{ log("[boot] "+label+" failed: "+(e&&e.message?e.message:e)); }catch(_){} } }

    safe("version", function(){
      var verEl=$("ver"); if(!verEl) return;
      verEl.textContent="v"+VERSION; verEl.title="View changelog";
      verEl.addEventListener("click", function(e){ if(e&&e.altKey){ probeFrames(); return; } CEP.openURL((window.OnMicAuth?OnMicAuth.siteURL:"https://wheretopodcast.com")+"/onmic/changelog"); });
    });

    // --- CORE: connect to Premiere + build the panel UI first ---
    safe("pace", applyPace);
    safe("projectFolder", function(){ if($("projectFolder")) $("projectFolder").value=detectProjectFolder(); });
    safe("ping", ping);
    safe("readTracks", function(){ readTracks(); });   // maps A-numbers to audio sources, then builds rows
    safe("rebuildSpeakers", rebuildSpeakers);
    safe("checklist", function(){ renderChecklist(); });
    // Premiere may still be loading the sequence at startup — re-read shortly after.
    setTimeout(function(){ safe("ping", ping); safe("readTracks", function(){ readTracks(); }); }, 2500);
    setTimeout(function(){ safe("ping", ping); }, 6000);
    // v1.03: age-prune the voice-mask + cut-plan caches (previously unbounded).
    setTimeout(function(){ safe("cachePrune", function(){
      pruneCacheDir(appSupportDir("OnMic/vad-cache"), 30);
      pruneCacheDir(appSupportDir("OnMic/cuts-cache"), 30);
    }); }, 10000);

    // --- AI voice model preload, with one-time auto-download self-heal ---
    safe("vad", function(){
      if (typeof OnMicVAD==="undefined" || !fs) return;
      OnMicVAD.init(panelFolder(), fs, log).then(function(ok){
        if(ok){ log("AI voice detection ready (silero)."); return; }
        // Not ready — the model and/or runtime is missing. Auto-download it once,
        // then re-init (if the ORT runtime is already loaded) or ask for a relaunch.
        log("AI model not found — attempting one-time download…");
        aiEnsure(function(changed){
          if(!changed){ log("AI model not found — using loudness fallback. (Run fetch_vendor to enable AI.)"); return; }
          if(typeof ort!=="undefined"){
            OnMicVAD.init(panelFolder(), fs, log).then(function(ok2){
              if(ok2){ log("AI voice detection ready (silero)."); status("AI voice detection installed and ready."); }
              else { log("AI files installed — relaunch the OnMic panel to enable AI."); status("AI installed — relaunch the OnMic panel to enable it."); }
            });
          } else {
            log("AI files installed — relaunch the OnMic panel (or Premiere) to enable AI.");
            status("AI voice model installed — relaunch the OnMic panel to enable it.");
          }
        });
      });
    });

    // --- optional: suggest camera tags (fully isolated; manual tagging is the main way) ---
    safe("detectButton", function(){
      if($("btnDetectCams")) $("btnDetectCams").addEventListener("click", function(){
        if(this.disabled) return;
        cancelRequested=false; setBusy(true); showProg(); setProg(0.02);
        detectCameras().catch(function(e){
          var msg=(e&&e.message)||String(e);
          if(msg==="cancelled") status("Stopped.", true);
          else status("Camera tag suggestion failed: "+msg, true);
        }).then(function(){ setBusy(false); hideProg(); cancelRequested=false; });
      });
    });

    // --- the rest of the control wiring ---
    safe("wiring", function(){
      $("numSpeakers").addEventListener("change", rebuildSpeakers);
      $("numSpeakers").addEventListener("change", setupChanged);
      $("numCameras").addEventListener("change", rebuildTags);
      $("numCameras").addEventListener("change", setupChanged);
      setupCountPicker("numSpeakers");
      setupCountPicker("numCameras");
      $("pace").addEventListener("change", applyPace);
      $("pace").addEventListener("change", setupChanged);
      ["wideFreq","reactionFreq","delayCuts","ignoreCuts","output_mode","backupTimeline","snapPause","trimSec"].forEach(function(id){
        var el=$(id); if(el) el.addEventListener("change", setupChanged);
      });
      refreshProfileList();
      $("btnProfileSave").addEventListener("click", saveProfile);
      $("btnProfileDel").addEventListener("click", deleteProfile);
      if($("btnProfileExport")) $("btnProfileExport").addEventListener("click", exportProfiles);
      if($("btnProfileImport")) $("btnProfileImport").addEventListener("click", function(){ if($("profileImportFile")) $("profileImportFile").click(); });
      if($("profileImportFile")) $("profileImportFile").addEventListener("change", function(){ importProfilesFile(this.files&&this.files[0]); this.value=""; setupChanged(); });
      $("btnProfileLoad").addEventListener("click", function(){ applyProfile(getProfiles()[val("profileList")]); setupChanged(); });
      $("profileList").addEventListener("change", function(){ if(val("profileList")) { applyProfile(getProfiles()[val("profileList")]); setupChanged(); } });
      $("reread").addEventListener("click", function(){ setupChanged(); status("Re-reading tracks…"); readTracks(function(readOk){ if(readOk!==false) ping(); }); });   // v1.04: don't let ping() overwrite a read-failure status
      document.addEventListener("input", function(e){ if(e.target&&e.target.id&&e.target.id.indexOf("aname")===0){ refreshTagLabels(); setupChanged(); } });
      document.addEventListener("click", closeAllMS);
      document.addEventListener("click", closeCountPicker);
      window.addEventListener("resize", closeAllMS);
      window.addEventListener("scroll", closeAllMS);
      window.addEventListener("resize", closeCountPicker);
      window.addEventListener("scroll", closeCountPicker);
      $("btnGo").addEventListener("click", function(){ runPipeline("apply"); });
      $("btnPreview").addEventListener("click", function(){ runPipeline("preview"); });
      if ($("btnTrim")) $("btnTrim").addEventListener("click", function(){ runPipeline("trim"); });
      if ($("btnResetLocal")) $("btnResetLocal").addEventListener("click", resetLocalData);
      $("btnStop").style.display="none";   // analysis is now in-process (no separate task to stop)
      $("btnStop").addEventListener("click", stopAnalysis);
      $("btnApply").addEventListener("click", apply);
      var _bM=$("btnMarkers"); if(_bM) _bM.addEventListener("click", doMarkers);
      $("btnCancel").addEventListener("click", function(){ clearPreview(); status("Cancelled."); setBusy(false); });
      $("btnSignIn").addEventListener("click", onGateButton);
      $("authLink").addEventListener("click", onGateLink);
      $("authCancel").addEventListener("click", onGateCancel);
      $("btnSignOut").addEventListener("click", function(e){
        if(e && e.preventDefault) e.preventDefault();
        showSignoutDialog();
      });
      if($("signoutCancel")) $("signoutCancel").addEventListener("click", hideSignoutDialog);
      if($("signoutConfirm")) $("signoutConfirm").addEventListener("click", doSignOut);
      if($("signoutDialog")) $("signoutDialog").addEventListener("click", function(e){ if(e && e.target===$("signoutDialog")) hideSignoutDialog(); });
      if($("confirmYes")) $("confirmYes").addEventListener("click", function(){ settleConfirm(true); });
      if($("confirmNo")) $("confirmNo").addEventListener("click", function(){ settleConfirm(false); });
      if($("confirmDialog")) $("confirmDialog").addEventListener("click", function(e){ if(e && e.target===$("confirmDialog")) settleConfirm(false); });
      document.addEventListener("keydown", function(e){ if(e.key==="Escape" && _confirmCb) settleConfirm(false); });
      document.addEventListener("keydown", function(e){ if(e && e.keyCode===27){ hideSignoutDialog(); } });
      document.addEventListener("keydown", function(e){ if(e && e.keyCode===27){ closeCountPicker(); closeAllMS(); } });
      $("productLink").addEventListener("click", function(){ CEP.openURL((window.OnMicAuth?OnMicAuth.siteURL:"https://wheretopodcast.com")+"/onmic"); });
      $("updateLink").addEventListener("click", function(){ var u=$("updateLink").getAttribute("data-url"); CEP.openURL(u||"https://wheretopodcast.com/onmic"); });
      if($("authUpdateLink")) $("authUpdateLink").addEventListener("click", function(){ var u=$("authUpdateLink").getAttribute("data-url"); CEP.openURL(u||"https://wheretopodcast.com/onmic"); });
      $("diagLink").addEventListener("click", copyDiagnostics);
      if ($("btnSupportBundle")) $("btnSupportBundle").addEventListener("click", copyDiagnostics);
      // Sequence linter wiring (optional / non-blocking).
      if ($("btnCheckSeq")) $("btnCheckSeq").addEventListener("click", checkSequence);
    });

    // Value dashboard: show the lifetime "hours saved" total on load (if any).
    safe("savedLifetime", function(){ renderSaved(); });

    // Licensing gate: verify on launch.
    safe("auth", checkAuth);
    // v1.05 (audit #7): panels stay open for days — re-verify the entitlement
    // (which also re-checks the latest version for the update banner) about
    // every 24h. Hourly tick; the verify only fires once the 24h window has
    // elapsed, never while a run/apply is busy, and checkAuth itself refuses to
    // overlap an in-flight verify.
    safe("authRecheck", function(){
      setInterval(function(){
        try {
          if (Date.now() - _lastVerifyAt < AUTH_RECHECK_MS) return;
          var go=$("btnGo"); if (go && go.disabled) return;   // mid-run — try again next hour
          checkAuth();
        } catch(e){}
      }, 60*60*1000);
    });

    // If something genuinely failed during startup, show it instead of a silent
    // "Connecting…" hang, so it's never a mystery.
    if (bootErr) { try { status("Startup issue ("+bootErr+"). Panel still usable; copy diagnostics if support asks.", true); } catch(_){} }
  }

  function refreshTagLabels(){
    var sp=speakerList(), root=$("cameraTags");
    if(root){
      var boxes=root.querySelectorAll('input[type="checkbox"]');
      for(var i=0;i<boxes.length;i++){
        var s=+boxes[i].getAttribute("data-s");
        for(var j=0;j<sp.length;j++){
          if(sp[j].slot===s){
            var parent=boxes[i].parentNode, checked=boxes[i].checked;
            if(parent){
              parent.textContent=" "+sp[j].name;
              parent.insertBefore(boxes[i], parent.firstChild);
              boxes[i].checked=checked;
            }
            break;
          }
        }
      }
    }
    var n=iCam(); for(var v=0;v<n;v++) updateMSField(v);
  }

  // Scripts load at the end of <body>, so DOMContentLoaded may already have fired
  // by the time this file runs — in that case a listener never fires. Run boot
  // immediately when the DOM is already parsed; otherwise wait for the event.
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", __onmicBoot);
  else __onmicBoot();

  } catch(__bootErr){
    // Never fail silently: surface the real error on the status line.
    try { var __se=document.getElementById("status"); if(__se){ __se.textContent="JS startup error: "+((__bootErr&&__bootErr.message)||String(__bootErr)); __se.className="bad"; } } catch(__x){}
  }
})();
