/* extras.js — features added on top of the existing FLV→MP3/MP4 converter
 * without touching its core conversion logic or visual layout.
 *
 *  1) Chat extraction:
 *     - Reads every ftchat*.xml from the user's selection.
 *     - Parses sendMessage6 events (Adobe Connect chat protocol).
 *     - Renders messages chronologically by their recording timestamp.
 *
 *  2) Full-class video build:
 *     - Reads indexstream.xml to find total class duration and when each
 *       cameraVoip / screenshare stream was added/removed.
 *     - Reads each cameraVoip_*.xml and screenshare_*.xml for the segment
 *       durations.
 *     - Encodes each cameraVoip → AAC m4a segment (44.1 kHz stereo).
 *     - Encodes each screenshare → H.264 mp4 segment (1280x720, 15 fps).
 *     - Fills audio gaps with anullsrc silence, video gaps with the site's
 *       logo rendered as a still video for the gap duration.
 *     - Concats all audio + video segments and muxes into one synced MP4.
 *
 *  All processing runs inside the browser via the same ffmpeg.wasm engine
 *  the existing converter uses; nothing is uploaded. */

(function () {
  'use strict';

  // -------------------------------------------------------------------
  // State / DOM refs
  // -------------------------------------------------------------------

  // XML files captured alongside the FLVs the existing app.js tracks.
  var xmlFiles = [];
  // FLV files mirrored here (we intentionally observe the same input
  // events as app.js so we can build the timeline ourselves without
  // poking at app.js's private state).
  var flvFiles = [];

  var fullCancelled = false;
  var fullRunning = false;
  var fullFFmpeg = null; // separate FFmpeg instance for the long timeline build
  var fullFFmpegLoading = null;

  // -------------------------------------------------------------------
  // Tiny utilities
  // -------------------------------------------------------------------

  function $(id) { return document.getElementById(id); }
  function txt(s) { return (s == null) ? '' : String(s); }

  function fmtMsAsHMS(ms) {
    if (!isFinite(ms) || ms < 0) ms = 0;
    var s = Math.floor(ms / 1000);
    var h = Math.floor(s / 3600);
    var m = Math.floor((s % 3600) / 60);
    var sec = s % 60;
    function pad(n) { return n < 10 ? '0' + n : '' + n; }
    if (h > 0) return pad(h) + ':' + pad(m) + ':' + pad(sec);
    return pad(m) + ':' + pad(sec);
  }

  function fmtBigNumber(n) {
    return (n || 0).toLocaleString('fa-IR');
  }

  function fileBase(name) {
    // strip dir prefix and trailing .flv/.xml
    var n = name.split(/[\\/]/).pop();
    return n.replace(/\.(flv|xml)$/i, '');
  }

  function readFileAsText(file) {
    return new Promise(function (resolve, reject) {
      var r = new FileReader();
      r.onload = function () { resolve(r.result); };
      r.onerror = function () { reject(r.error || new Error('read failed')); };
      r.readAsText(file);
    });
  }

  function fileToU8(file) {
    return file.arrayBuffer().then(function (b) { return new Uint8Array(b); });
  }

  // Concatenate Uint8Array list.
  function concatU8(parts) {
    var total = 0;
    for (var i = 0; i < parts.length; i++) total += parts[i].length;
    var out = new Uint8Array(total);
    var off = 0;
    for (var j = 0; j < parts.length; j++) { out.set(parts[j], off); off += parts[j].length; }
    return out;
  }

  // Add a log line into the full-build log panel.
  function fullLog(text, cls) {
    var $log = $('fullLog');
    if (!$log) return;
    var line = document.createElement('div');
    if (cls) line.className = cls;
    line.textContent = text;
    $log.appendChild(line);
    $log.scrollTop = $log.scrollHeight;
  }

  function fullStatus(kind, message) {
    var $b = $('fullStatus');
    if (!$b) return;
    $b.className = 'status-banner ' + (kind || '');
    $b.innerHTML = '';
    if (kind === 'loading' || kind === 'success' || kind === 'error') {
      var dot = document.createElement('span');
      dot.className = 'dot';
      $b.appendChild(dot);
    }
    var span = document.createElement('span');
    span.textContent = message || '';
    $b.appendChild(span);
    $b.style.display = message ? 'flex' : 'none';
  }

  function setFullProgress(v) {
    var pct = Math.max(0, Math.min(100, (v || 0) * 100));
    var bar = $('fullProgressBar');
    var lab = $('fullProgressText');
    if (bar) bar.style.width = pct.toFixed(1) + '%';
    if (lab) lab.textContent = pct.toFixed(0) + '%';
  }

  // -------------------------------------------------------------------
  // File capture — install our own listeners alongside app.js's
  // -------------------------------------------------------------------

  function captureFiles(list) {
    if (!list || !list.length) return false;
    var changed = false;
    for (var i = 0; i < list.length; i++) {
      var f = list[i];
      if (!f || !f.name) continue;
      var n = f.name.toLowerCase();
      if (n.endsWith('.xml')) {
        var dup = xmlFiles.some(function (x) { return x.name === f.name && x.size === f.size; });
        if (!dup) { xmlFiles.push(f); changed = true; }
      } else if (n.endsWith('.flv')) {
        var dup2 = flvFiles.some(function (x) { return x.name === f.name && x.size === f.size; });
        if (!dup2) { flvFiles.push(f); changed = true; }
      }
    }
    return changed;
  }

  function clearFiles() {
    xmlFiles = [];
    flvFiles = [];
    refreshChat();
    refreshTimelineSummary();
    var $out = $('fullOutputs');
    if ($out) $out.innerHTML = '';
    setFullProgress(0);
    fullStatus(null, '');
    var $cl = $('fullLog'); if ($cl) $cl.innerHTML = '';
  }

  function installFileListeners() {
    var fp = $('filePicker');
    var folder = $('folderPicker');

    // CRITICAL: app.js's change handler does `e.target.value = ''` which
    // immediately empties `e.target.files`. Because addEventListener
    // listeners on the target element run in registration order and
    // app.js registers first, a plain bubble-phase listener on the input
    // would see an empty FileList. We use a document-level *capture-phase*
    // listener instead — capture-phase listeners on ancestors fire before
    // any AT_TARGET listeners on the input itself, so we read the files
    // before app.js can clear them.
    document.addEventListener('change', function (e) {
      var t = e.target;
      if (!t || (t.id !== 'filePicker' && t.id !== 'folderPicker')) return;
      if (captureFiles(t.files)) onFilesUpdated();
    }, true);

    // Drag-and-drop into the existing drop zone — app.js doesn't mutate
    // the dataTransfer object, so a bubble-phase listener is fine here.
    var dz = $('dropZone');
    if (dz) {
      dz.addEventListener('drop', function (e) {
        var dt = e.dataTransfer;
        if (!dt || !dt.files) return;
        if (captureFiles(dt.files)) onFilesUpdated();
      });
    }

    // Clear button: app.js wipes its own list. We need to mirror that on
    // our XML/FLV pools too. Capture-phase keeps the order independent
    // of which listener was registered first.
    var clr = $('btnClear');
    if (clr) {
      clr.addEventListener('click', clearFiles, true);
    }

    // Loosen accept attr on the multi-file picker so users can hand-pick
    // .xml siblings if they want to. The folder picker already includes
    // every file in the chosen directory.
    if (fp) {
      try { fp.setAttribute('accept', '.flv,video/x-flv,.xml,application/xml,text/xml'); } catch (_) {}
    }
    // Suppress "no file selected" warnings on a no-op folder pick.
    if (folder) {
      try { folder.setAttribute('webkitdirectory', ''); } catch (_) {}
    }
  }

  function onFilesUpdated() {
    refreshChat();
    refreshTimelineSummary();
  }

  // -------------------------------------------------------------------
  // BINARY PARSER: AMF0 (used by Adobe Connect FLV script-data tags)
  // -------------------------------------------------------------------
  //
  // Adobe Connect's exporter writes the *.xml siblings of each *.flv with
  // names and message text REPLACED by whitespace (so e.g. "اميرمهدي سعيدي"
  // becomes " ", and "khaste nabashid" becomes ""). The original UTF-8
  // strings are still present in the FLV files themselves, encoded as AMF0.
  //
  // AMF0 reference: https://wwwimages2.adobe.com/content/dam/acom/en/devnet/pdf/amf0-file-format-specification.pdf
  //   0x00 = number (8-byte big-endian double)
  //   0x01 = boolean (1 byte)
  //   0x02 = string (uint16 length + UTF-8 bytes)
  //   0x03 = object (key:string-without-marker, value:any), terminated by
  //          uint16 length=0 + 0x09
  //   0x05 = null
  //   0x06 = undefined
  //   0x08 = ECMA array (uint32 count + same body as object)
  //   0x09 = object end marker
  //   0x0A = strict array (uint32 count + values)
  //   0x0C = long string (uint32 length + UTF-8 bytes)
  //
  // We deliberately implement only the subset used by the recordings.

  function AmfReader(u8) {
    this.u8 = u8;
    this.dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
    this.p = 0;
  }
  AmfReader.prototype.peek = function () { return this.u8[this.p]; };
  AmfReader.prototype.eof = function () { return this.p >= this.u8.length; };
  AmfReader.prototype.readBytes = function (n) {
    var s = this.p; this.p += n; return this.u8.subarray(s, this.p);
  };
  AmfReader.prototype.readU16 = function () {
    var v = (this.u8[this.p] << 8) | this.u8[this.p + 1]; this.p += 2; return v;
  };
  AmfReader.prototype.readU32 = function () {
    var v = ((this.u8[this.p] << 24) >>> 0) +
            ((this.u8[this.p + 1] << 16) >>> 0) +
            ((this.u8[this.p + 2] << 8) >>> 0) +
            this.u8[this.p + 3];
    this.p += 4; return v >>> 0;
  };
  AmfReader.prototype.readF64 = function () {
    var v = this.dv.getFloat64(this.p, false); this.p += 8; return v;
  };
  AmfReader.prototype.readUtf8 = function (n) {
    var bytes = this.u8.subarray(this.p, this.p + n);
    this.p += n;
    try { return new TextDecoder('utf-8').decode(bytes); }
    catch (_) { return ''; }
  };
  AmfReader.prototype.readShortString = function () {
    var n = this.readU16();
    return this.readUtf8(n);
  };
  AmfReader.prototype.readLongString = function () {
    var n = this.readU32();
    return this.readUtf8(n);
  };
  AmfReader.prototype.readObject = function () {
    // Caller has already consumed 0x03.
    var out = {};
    var guard = 0;
    while (!this.eof() && guard++ < 10000) {
      var keyLen = this.readU16();
      if (keyLen === 0 && this.peek() === 0x09) { this.p += 1; return out; }
      var key = this.readUtf8(keyLen);
      out[key] = this.readValue();
    }
    return out;
  };
  AmfReader.prototype.readValue = function () {
    var t = this.u8[this.p++];
    switch (t) {
      case 0x00: return this.readF64();
      case 0x01: return !!this.u8[this.p++];
      case 0x02: return this.readShortString();
      case 0x03: return this.readObject();
      case 0x05: return null;
      case 0x06: return undefined;
      case 0x08: { // ECMA array — has 4-byte count then object body
        this.p += 4;
        var ecma = {};
        var guard = 0;
        while (!this.eof() && guard++ < 100000) {
          var keyLen = this.readU16();
          if (keyLen === 0 && this.peek() === 0x09) { this.p += 1; return ecma; }
          var key = this.readUtf8(keyLen);
          ecma[key] = this.readValue();
        }
        return ecma;
      }
      case 0x09: return undefined; // stray end marker
      case 0x0A: { // strict array
        var n = this.readU32();
        var arr = [];
        for (var i = 0; i < n; i++) arr.push(this.readValue());
        return arr;
      }
      case 0x0C: return this.readLongString();
      default:
        // Unknown type — bail by leaving cursor in place; caller will skip.
        this.p--;
        throw new Error('AMF0 unknown type ' + t.toString(16) + ' @' + this.p);
    }
  };

  // Walk an FLV byte stream and yield the body (Uint8Array) and timestamp
  // (ms) of every script-data tag (tagType 18). Returns [{tagTime, body}].
  function readFLVScriptDataTags(u8) {
    var out = [];
    if (u8.length < 9 || u8[0] !== 0x46 || u8[1] !== 0x4C || u8[2] !== 0x56) return out;
    var p = 9; // skip FLV header
    while (p + 11 < u8.length) {
      p += 4; // PreviousTagSize
      if (p + 11 > u8.length) break;
      var tagType = u8[p];
      var dataSize = (u8[p + 1] << 16) | (u8[p + 2] << 8) | u8[p + 3];
      var ts = (u8[p + 4] << 16) | (u8[p + 5] << 8) | u8[p + 6];
      var tsExt = u8[p + 7];
      var tagTime = ((tsExt << 24) >>> 0) + (ts >>> 0);
      p += 11;
      if (p + dataSize > u8.length) break;
      if (tagType === 18) {
        out.push({ tagTime: tagTime, body: u8.subarray(p, p + dataSize) });
      }
      p += dataSize;
    }
    return out;
  }

  // Inside a script-data tag body, find every "<AMF0 string 'methodName'>"
  // and parse the AMF0 value that follows. Returns list of
  // {method, payload} pairs.
  function findAmfMethods(body, methodNames) {
    var hits = [];
    if (!methodNames || !methodNames.length) return hits;
    // Pre-build [marker bytes, name] tuples for fast scanning.
    var markers = methodNames.map(function (name) {
      var bytes = new TextEncoder().encode(name);
      var pre = new Uint8Array(3 + bytes.length);
      pre[0] = 0x02;
      pre[1] = (bytes.length >> 8) & 0xff;
      pre[2] = bytes.length & 0xff;
      pre.set(bytes, 3);
      return { name: name, marker: pre };
    });

    function indexOf(haystack, needle, from) {
      var nlen = needle.length;
      var hlen = haystack.length;
      if (nlen === 0) return from;
      outer: for (var i = from; i <= hlen - nlen; i++) {
        for (var j = 0; j < nlen; j++) {
          if (haystack[i + j] !== needle[j]) continue outer;
        }
        return i;
      }
      return -1;
    }

    for (var mi = 0; mi < markers.length; mi++) {
      var marker = markers[mi];
      var i = 0;
      while (true) {
        var j = indexOf(body, marker.marker, i);
        if (j < 0) break;
        var rdr = new AmfReader(body);
        rdr.p = j + marker.marker.length;
        var payload = null;
        try { payload = rdr.readValue(); }
        catch (_) { payload = null; }
        if (payload && typeof payload === 'object') {
          hits.push({ method: marker.name, payload: payload, offset: j });
        }
        i = j + 1;
      }
    }
    hits.sort(function (a, b) { return a.offset - b.offset; });
    return hits;
  }

  // -------------------------------------------------------------------
  // CHAT EXTRACTION
  // -------------------------------------------------------------------

  // Adobe Connect's *.xml exports strip names + most message text — we
  // parse the original AMF0 payload from ftchat*.flv directly. The XML
  // path is kept as a fallback for environments where only the .xml
  // is available (e.g. user dragged just the XMLs into the picker).
  function isChatFlv(f) { return /(?:^|[\\/])ftchat\d*\.flv$/i.test(f.name); }
  function isChatXml(f) { return /(?:^|[\\/])ftchat\d*\.xml$/i.test(f.name); }
  // Backwards-compat alias used by older code paths.
  function isChatFile(f) { return isChatFlv(f) || isChatXml(f); }

  async function parseChatFlv(file) {
    var u8;
    try { u8 = await fileToU8(file); }
    catch (e) { return []; }
    var tags = readFLVScriptDataTags(u8);
    var msgs = [];
    for (var i = 0; i < tags.length; i++) {
      var hits = findAmfMethods(tags[i].body, ['sendMessage6']);
      for (var h = 0; h < hits.length; h++) {
        var p = hits[h].payload || {};
        msgs.push({
          time: tags[i].tagTime,
          when: p.when != null ? String(p.when) : '',
          fromName: typeof p.fromName === 'string' ? p.fromName : '',
          fromPID: typeof p.fromPID === 'string' ? p.fromPID : (p.fromPID != null ? String(p.fromPID) : ''),
          text: typeof p.text === 'string' ? p.text : '',
          toName: typeof p.toName === 'string' ? p.toName : '',
          toPID: typeof p.toPID === 'string' ? p.toPID : (p.toPID != null ? String(p.toPID) : ''),
          type: p.type != null ? String(p.type) : '0',
          color: typeof p.color === 'string' ? p.color : '',
          src: file.name
        });
      }
    }
    return msgs;
  }

  // Legacy XML parser kept as a fallback. Only used when no ftchat*.flv
  // is present in the user's selection.
  async function parseChatFile(file) {
    var text;
    try { text = await readFileAsText(file); }
    catch (e) { return []; }
    var doc;
    try { doc = new DOMParser().parseFromString(text, 'application/xml'); }
    catch (e) { return []; }
    var msgs = [];
    var perr = doc.getElementsByTagName('parsererror');
    if (perr && perr.length) return regexParseChat(text, file.name);

    var msgEls = doc.getElementsByTagName('Message');
    for (var i = 0; i < msgEls.length; i++) {
      var msg = msgEls[i];
      var time = parseInt(msg.getAttribute('time') || '0', 10);
      var strings = msg.getElementsByTagName('String');
      var isChat = false;
      for (var k = 0; k < strings.length; k++) {
        if ((strings[k].textContent || '').trim() === 'sendMessage6') { isChat = true; break; }
      }
      if (!isChat) continue;
      var pickFirst = function (tag) {
        var el = msg.getElementsByTagName(tag)[0];
        return el ? (el.textContent || '').trim() : '';
      };
      msgs.push({
        time: time,
        when: pickFirst('when'),
        fromName: pickFirst('fromName'),
        fromPID: pickFirst('fromPID'),
        text: pickFirst('text'),
        toName: pickFirst('toName'),
        toPID: pickFirst('toPID'),
        type: pickFirst('type'),
        color: pickFirst('color'),
        src: file.name
      });
    }
    return msgs;
  }

  function regexParseChat(text, srcName) {
    var out = [];
    var msgRe = /<Message\s+time="(\d+)"[^>]*>([\s\S]*?)<\/Message>/g;
    var m;
    while ((m = msgRe.exec(text)) !== null) {
      var body = m[2];
      if (body.indexOf('sendMessage6') === -1) continue;
      var pick = function (tag) {
        var r = new RegExp('<' + tag + '><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/' + tag + '>');
        var x = r.exec(body);
        return x ? x[1] : '';
      };
      out.push({
        time: parseInt(m[1], 10),
        when: pick('when'),
        fromName: pick('fromName'),
        fromPID: pick('fromPID'),
        text: pick('text'),
        toName: pick('toName'),
        toPID: pick('toPID'),
        type: pick('type'),
        color: pick('color'),
        src: srcName
      });
    }
    return out;
  }

  // Parse indexstream.flv (preferred) or indexstream.xml (fallback) and
  // return a Map<pID, participant>. Adobe Connect emits a userCreated /
  // userJoined event for every participant; the AMF0 payload of those
  // events carries the real fullName / first-name / last-name / login,
  // which the .xml export anonymizes to whitespace.
  function isIndexstreamFlv(f) { return /(?:^|[\\/])indexstream\.flv$/i.test(f.name); }
  function isMainstreamFlv(f)  { return /(?:^|[\\/])mainstream\.flv$/i.test(f.name); }

  async function parseParticipantsFromFlv(file) {
    var u8;
    try { u8 = await fileToU8(file); }
    catch (_) { return {}; }
    var tags = readFLVScriptDataTags(u8);
    var byPid = Object.create(null);
    // Each userCreated / userJoined event in AMF looks like:
    //   <amf string "playEvent"> <amf object {id, name:"userCreated", time}>
    //   <amf object {customFields:{first-name,last-name}, fullName, login,
    //                pID, role, suffix, email, ...}>
    // We don't parse the playEvent wrapper — we just look for any AMF0
    // object that contains both pID and (role|login|fullName) and merge.
    function visit(val) {
      if (!val || typeof val !== 'object') return;
      if (Array.isArray(val)) { for (var i = 0; i < val.length; i++) visit(val[i]); return; }
      if (typeof val.pID === 'string' && val.pID.length &&
          (typeof val.role === 'string' || typeof val.login === 'string' ||
           typeof val.fullName === 'string')) {
        var pid = val.pID;
        var rec = byPid[pid] || (byPid[pid] = { pID: pid });
        var keys = ['fullName', 'name', 'originalName', 'login', 'role', 'suffix', 'email'];
        for (var k = 0; k < keys.length; k++) {
          var v = val[keys[k]];
          if (typeof v !== 'string') continue;
          v = v.replace(/^[\s\u00a0\u200c\u200b]+|[\s\u00a0\u200c\u200b]+$/g, '');
          if (!v) continue;
          if (!(rec[keys[k]] && rec[keys[k]].length)) rec[keys[k]] = v;
        }
        if (val.customFields && typeof val.customFields === 'object') {
          var fn = val.customFields['first-name'];
          var ln = val.customFields['last-name'];
          if (typeof fn === 'string') fn = fn.replace(/^\s+|\s+$/g, '');
          if (typeof ln === 'string') ln = ln.replace(/^\s+|\s+$/g, '');
          if (fn && !rec.firstName) rec.firstName = fn;
          if (ln && !rec.lastName)  rec.lastName  = ln;
        }
      }
      for (var key in val) {
        if (!Object.prototype.hasOwnProperty.call(val, key)) continue;
        if (typeof val[key] === 'object') visit(val[key]);
      }
    }

    for (var t = 0; t < tags.length; t++) {
      // Try parsing every AMF0 value in the script-data tag body. The body
      // typically starts with a method name string + an object pair, but
      // ftchat/indexstream pack many such pairs back-to-back.
      var body = tags[t].body;
      var rdr = new AmfReader(body);
      var safety = 0;
      while (rdr.p < body.length && safety++ < 100000) {
        try {
          var v = rdr.readValue();
          visit(v);
        } catch (_) {
          // Couldn't decode at this offset — skip one byte and retry. This
          // happens at tag boundaries / unknown markers.
          rdr.p++;
        }
      }
    }
    return byPid;
  }

  async function parseParticipantsFromXml(file) {
    var text;
    try { text = await readFileAsText(file); }
    catch (e) { return {}; }
    var byPid = Object.create(null);
    var blockRe = /<Object>([\s\S]*?)<\/Object>/g;
    var m;
    function pick(body, tag) {
      var r = new RegExp('<' + tag + '><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/' + tag + '>');
      var x = r.exec(body);
      return x ? x[1] : '';
    }
    while ((m = blockRe.exec(text)) !== null) {
      var body = m[1];
      if (body.indexOf('<pID>') === -1) continue;
      if (body.indexOf('<role>') === -1 && body.indexOf('<login>') === -1) continue;
      var pid = pick(body, 'pID');
      if (!pid) continue;
      var rec = byPid[pid] || (byPid[pid] = { pID: pid });
      var fields = ['fullName', 'name', 'originalName', 'login', 'role', 'suffix', 'email'];
      for (var f = 0; f < fields.length; f++) {
        var v = pick(body, fields[f]).trim();
        if (v && !(rec[fields[f]] && rec[fields[f]].length)) rec[fields[f]] = v;
      }
    }
    return byPid;
  }

  async function gatherParticipants() {
    var merged = Object.create(null);
    function mergeInto(src) {
      for (var pid in src) {
        if (!Object.prototype.hasOwnProperty.call(src, pid)) continue;
        var rec = merged[pid] || (merged[pid] = { pID: pid });
        var s = src[pid];
        for (var k in s) {
          if (!Object.prototype.hasOwnProperty.call(s, k)) continue;
          if (typeof s[k] !== 'string' || !s[k]) continue;
          if (!(rec[k] && rec[k].length)) rec[k] = s[k];
        }
      }
    }
    // FLVs first — they have the original Persian names. Then mainstream.flv
    // (carries the same userCreated events from a different perspective)
    // and finally the XML, in case some pID slipped through.
    var ixFlv  = flvFiles.filter(isIndexstreamFlv)[0];
    var mainFlv = flvFiles.filter(isMainstreamFlv)[0];
    if (ixFlv)   mergeInto(await parseParticipantsFromFlv(ixFlv));
    if (mainFlv) mergeInto(await parseParticipantsFromFlv(mainFlv));
    var ixXml = findIndexstreamXml();
    if (ixXml)   mergeInto(await parseParticipantsFromXml(ixXml));
    return merged;
  }

  async function gatherAllChats() {
    // Prefer ftchat*.flv (full names + full text). Fall back to ftchat*.xml
    // only if no FLV is in the user's selection (e.g. they handed us a
    // partial export that's missing the binary streams).
    var flvs = flvFiles.filter(isChatFlv);
    var all = [];
    if (flvs.length) {
      for (var i = 0; i < flvs.length; i++) {
        var batch = await parseChatFlv(flvs[i]);
        all = all.concat(batch);
      }
    } else {
      var xmls = xmlFiles.filter(isChatXml);
      for (var j = 0; j < xmls.length; j++) {
        var batch2 = await parseChatFile(xmls[j]);
        all = all.concat(batch2);
      }
    }
    all.sort(function (a, b) { return (a.time || 0) - (b.time || 0); });
    return all;
  }

  // Map every distinct fromPID to a Persian display label. We try, in order:
  //   1. fromName / name / originalName / fullName from the chat or
  //      participant record (real human-entered name)
  //   2. login from the participant record (the stable account identifier;
  //      e.g. "Shahed_4022170005" in Shahed University recordings — the
  //      student/staff username, which is what users actually want to see
  //      when names are anonymized).
  //   3. role-based fallback: "میزبان" (owner/host), "ارائه‌دهنده"
  //      (presenter), or "شرکت‌کننده ۱..N" (viewer) — assigned in the order
  //      the pIDs first appear in the chat, so the labels stay stable
  //      across re-renders.
  // The PID itself is never shown in the rendered label — it's a useless
  // 8-digit number from the user's perspective. We keep it as the 'pid'
  // attribute of the returned object so the UI can attach it to a tooltip
  // / chat-author title= for power users.
  function fmtParticipantNumber(n) {
    // Persian-friendly number for "شرکت‌کننده ۱۲" style labels.
    if (typeof n !== 'number') return String(n);
    var fa = String(n).replace(/[0-9]/g, function (d) {
      return '۰۱۲۳۴۵۶۷۸۹'.charAt(d.charCodeAt(0) - 48);
    });
    return fa;
  }
  function pickRealName(msg, participant) {
    // Synthesize "first last" from customFields when present — it carries
    // the most authoritative breakdown.
    var pf = participant && participant.firstName ? participant.firstName.trim() : '';
    var pl = participant && participant.lastName  ? participant.lastName.trim()  : '';
    var assembled = (pf || pl) ? (pf + (pf && pl ? ' ' : '') + pl) : '';
    var candidates = [
      msg && msg.fromName,
      participant && participant.fullName,
      participant && participant.originalName,
      participant && participant.name,
      assembled
    ];
    for (var i = 0; i < candidates.length; i++) {
      var v = (candidates[i] || '').trim();
      // Adobe Connect sometimes leaves a single space, NBSP, ZWNJ, or a
      // bare suffix digit (like " 2") in fullName when the recording has
      // been anonymized. Treat those as empty.
      if (!v) continue;
      if (/^[\s\u00a0\u200c\u200b]*$/.test(v)) continue;
      if (/^\s*\d+\s*$/.test(v)) continue;
      return v;
    }
    return '';
  }
  function roleLabel(role) {
    role = (role || '').toLowerCase();
    if (role === 'owner' || role === 'host') return 'میزبان';
    if (role === 'presenter') return 'ارائه‌دهنده';
    return ''; // viewer / unknown — fall back to numbered label
  }
  function makeAuthorLabeler(messages, participants) {
    participants = participants || {};
    // Pre-assign stable participant numbers per pID, in the order pIDs first
    // appear in the chat — keeps the UI consistent across re-renders.
    var viewerNum = {};
    var nextViewer = 0;
    for (var i = 0; i < messages.length; i++) {
      var pid = messages[i].fromPID || '';
      if (!pid || viewerNum[pid] !== undefined) continue;
      viewerNum[pid] = ++nextViewer;
    }

    return function (msg) {
      var pid = msg.fromPID || 'unknown';
      var p = participants[pid] || {};
      var real = pickRealName(msg, p);
      if (real) {
        return { name: real, pid: pid, role: p.role || '' };
      }
      var rl = roleLabel(p.role);
      var login = (p.login || '').trim();
      if (login) {
        return {
          name: rl ? rl + ' — ' + login : login,
          pid: pid,
          role: p.role || ''
        };
      }
      if (rl) return { name: rl, pid: pid, role: p.role || '' };
      var n = viewerNum[pid] || (viewerNum[pid] = ++nextViewer);
      return {
        name: 'شرکت‌کننده ' + fmtParticipantNumber(n),
        pid: pid,
        role: p.role || ''
      };
    };
  }

  function renderChat(messages, participants) {
    var $list = $('chatList');
    var $stats = $('chatStats');
    var $empty = $('chatEmpty');
    var $exportBtn = $('btnExportChat');
    if (!$list) return;
    $list.innerHTML = '';

    if (!messages.length) {
      if ($stats) $stats.textContent = '';
      if ($empty) $empty.style.display = 'block';
      if ($exportBtn) $exportBtn.disabled = true;
      return;
    }
    if ($empty) $empty.style.display = 'none';
    if ($exportBtn) $exportBtn.disabled = false;

    var nonEmpty = messages.filter(function (m) { return (m.text || '').trim(); });
    if ($stats) {
      $stats.innerHTML =
        'تعداد رویدادهای چت: <strong>' + fmtBigNumber(messages.length) + '</strong>' +
        ' — پیام‌های متن‌دار: <strong>' + fmtBigNumber(nonEmpty.length) + '</strong>' +
        (nonEmpty.length < messages.length
          ? ' — <span class="muted">سایر رویدادها معمولاً اعلام در حال تایپ یا پیام بدون متن هستند.</span>'
          : '');
    }

    var labeler = makeAuthorLabeler(messages, participants);
    var prevPid = null;
    var FRAG = document.createDocumentFragment();
    for (var i = 0; i < messages.length; i++) {
      var m = messages[i];
      var hasText = !!(m.text || '').trim();
      var item = document.createElement('div');
      item.className = 'chat-item' + (hasText ? '' : ' empty')
                     + (prevPid && prevPid === m.fromPID ? ' same-author' : '');
      // Header
      var head = document.createElement('div');
      head.className = 'chat-head';
      var who = document.createElement('span');
      who.className = 'chat-author';
      var label = labeler(m);
      who.textContent = label.name;
      // Keep the raw PID accessible via tooltip for power users without
      // cluttering the visible label.
      if (label.pid) who.title = 'PID: ' + label.pid + (label.role ? ' · ' + label.role : '');
      var when = document.createElement('span');
      when.className = 'chat-time';
      when.textContent = fmtMsAsHMS(m.time);
      head.appendChild(who);
      head.appendChild(when);
      item.appendChild(head);
      // Body
      var body = document.createElement('div');
      body.className = 'chat-text';
      if (hasText) {
        body.textContent = m.text;
      } else {
        body.innerHTML = '<span class="muted">— رویداد بدون متن (typing/heartbeat) —</span>';
      }
      item.appendChild(body);
      FRAG.appendChild(item);
      prevPid = m.fromPID;
    }
    $list.appendChild(FRAG);
  }

  async function refreshChat() {
    var msgs = await gatherAllChats();
    var participants = await gatherParticipants();
    renderChat(msgs, participants);
  }

  function exportChatTxt(messages, participants) {
    var lines = [];
    lines.push('# چت‌های کلاس — استخراج‌شده از ftchat*.xml');
    lines.push('# هر سطر: [زمان از ابتدای کلاس] نام/شناسه — (PID): متن');
    lines.push('# تعداد کل رویدادها: ' + messages.length);
    lines.push('');
    var labeler = makeAuthorLabeler(messages, participants || {});
    for (var i = 0; i < messages.length; i++) {
      var m = messages[i];
      var t = (m.text || '').trim();
      if (!t) continue; // skip empty events in the export
      var lab = labeler(m);
      lines.push('[' + fmtMsAsHMS(m.time) + '] ' +
                 lab.name + ' — (PID ' + (lab.pid || '?') + '): ' + t);
    }
    var blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
    var url = URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.href = url; a.download = 'class-chat.txt';
    document.body.appendChild(a); a.click();
    setTimeout(function () { URL.revokeObjectURL(url); a.remove(); }, 0);
  }

  // -------------------------------------------------------------------
  // TIMELINE PARSING (indexstream.xml + per-segment XMLs)
  // -------------------------------------------------------------------

  function findFile(list, predicate) {
    for (var i = 0; i < list.length; i++) {
      if (predicate(list[i])) return list[i];
    }
    return null;
  }

  function findIndexstreamXml() {
    return findFile(xmlFiles, function (f) {
      return /(?:^|[\\/])indexstream\.xml$/i.test(f.name);
    });
  }

  // Parse a per-segment XML (cameraVoip_X_Y.xml, screenshare_X_Y.xml) for
  // its `onMetaData` first Number (= duration in seconds).
  async function parseSegmentDuration(file) {
    if (!file) return null;
    var text;
    try { text = await readFileAsText(file); } catch (e) { return null; }
    // The first <Number> after <Method><![CDATA[onMetaData]]></Method> is
    // duration in seconds.
    var re = /<Method><!\[CDATA\[onMetaData\]\]><\/Method>[\s\S]*?<Number><!\[CDATA\[([\d.]+)\]\]><\/Number>/;
    var m = re.exec(text);
    if (!m) return null;
    var sec = parseFloat(m[1]);
    if (!isFinite(sec) || sec <= 0) return null;
    return sec;
  }

  async function parseTimeline() {
    var ix = findIndexstreamXml();
    if (!ix) return null;

    var text;
    try { text = await readFileAsText(ix); } catch (e) { return null; }

    // Total duration: first Number under the first onMetaData Array.
    var totalSec = 0;
    var dm = /<Method><!\[CDATA\[onMetaData\]\]><\/Method>[\s\S]*?<Array>[\s\S]*?<Number><!\[CDATA\[([\d.]+)\]\]><\/Number>/.exec(text);
    if (dm) totalSec = parseFloat(dm[1]) || 0;

    // Walk every <Message> and extract streamAdded/streamRemoved events.
    var msgRe = /<Message\s+time="(\d+)"[^>]*>([\s\S]*?)<\/Message>/g;
    var streamMsgs = [];
    var m;
    while ((m = msgRe.exec(text)) !== null) {
      var time = parseInt(m[1], 10);
      var body = m[2];
      if (body.indexOf('streamAdded') !== -1) {
        var snameAdd = (/<streamName><!\[CDATA\[(.*?)\]\]><\/streamName>/.exec(body) || [])[1];
        var startTime = (/<startTime><!\[CDATA\[(\d+)\]\]><\/startTime>/.exec(body) || [])[1];
        if (snameAdd) {
          streamMsgs.push({
            kind: 'add',
            time: time,
            streamName: snameAdd.replace(/^\//, ''),
            startTime: startTime ? parseInt(startTime, 10) : time
          });
        }
      } else if (body.indexOf('streamRemoved') !== -1) {
        var snameRem = (/<streamName><!\[CDATA\[(.*?)\]\]><\/streamName>/.exec(body) || [])[1];
        if (snameRem) {
          streamMsgs.push({
            kind: 'remove',
            time: time,
            streamName: snameRem.replace(/^\//, '')
          });
        }
      }
    }

    // Reduce add+remove pairs into segment objects.
    // Each segment: { name, kind ('audio'|'video'), startMs, endMs, durationMs, file, xml, durSec }
    var openByName = {};
    var segments = [];
    for (var i = 0; i < streamMsgs.length; i++) {
      var sm = streamMsgs[i];
      if (sm.kind === 'add') {
        openByName[sm.streamName] = { startMs: sm.startTime, addedAt: sm.time };
      } else if (sm.kind === 'remove') {
        var op = openByName[sm.streamName];
        var startMs = op ? op.startMs : 0;
        delete openByName[sm.streamName];
        var seg = {
          name: sm.streamName,
          startMs: startMs,
          endMs: sm.time
        };
        seg.kind = /^cameraVoip/i.test(seg.name) ? 'audio'
                 : /^screenshare/i.test(seg.name) ? 'video'
                 : 'other';
        segments.push(seg);
      }
    }
    // Streams still open at end of recording → close at totalSec.
    Object.keys(openByName).forEach(function (name) {
      var op = openByName[name];
      var seg = {
        name: name,
        startMs: op.startMs,
        endMs: Math.round(totalSec * 1000)
      };
      seg.kind = /^cameraVoip/i.test(seg.name) ? 'audio'
               : /^screenshare/i.test(seg.name) ? 'video'
               : 'other';
      segments.push(seg);
    });

    // Match each segment to its FLV file + per-segment XML, then read the
    // segment's reported duration. A segment's effective duration is the
    // min of (recorded duration, gap until next segment / end-of-class).
    for (var j = 0; j < segments.length; j++) {
      var s = segments[j];
      s.flv = findFile(flvFiles, function (f) {
        return fileBase(f.name).toLowerCase() === s.name.toLowerCase();
      });
      s.xml = findFile(xmlFiles, function (f) {
        return fileBase(f.name).toLowerCase() === s.name.toLowerCase();
      });
      s.recDurSec = await parseSegmentDuration(s.xml);
      var spanMs = Math.max(0, s.endMs - s.startMs);
      s.spanSec = spanMs / 1000;
      // Effective duration: trust the index span; cap by the recorded
      // duration if smaller (publisher stopped earlier than the index
      // says).
      if (s.recDurSec && s.recDurSec < s.spanSec) s.durationSec = s.recDurSec;
      else s.durationSec = s.spanSec;
    }

    // Now build the audio + video segment lists separately, sorted by start.
    var audioSegs = segments.filter(function (s) { return s.kind === 'audio' && s.flv; });
    var videoSegs = segments.filter(function (s) { return s.kind === 'video' && s.flv; });
    audioSegs.sort(function (a, b) { return a.startMs - b.startMs; });
    videoSegs.sort(function (a, b) { return a.startMs - b.startMs; });

    return {
      totalSec: totalSec,
      segments: segments,
      audioSegs: audioSegs,
      videoSegs: videoSegs,
      indexstream: ix
    };
  }

  // Render a friendly timeline summary card.
  async function refreshTimelineSummary() {
    var $sum = $('timelineSummary');
    var $btn = $('btnFullBuild');
    if (!$sum) return;

    var tl = await parseTimeline();
    if (!tl) {
      $sum.innerHTML = '<div class="muted">برای فعال شدن این بخش، باید فولدر کلاس (شامل <code>indexstream.xml</code> و فایل‌های <code>*.flv</code>) را در بالا انتخاب کنید.</div>';
      if ($btn) $btn.disabled = true;
      return;
    }

    var rows = [];
    rows.push('<div class="t-row"><span class="t-key">طول کل کلاس:</span> <strong>' +
      fmtMsAsHMS(Math.round(tl.totalSec * 1000)) + '</strong> (' +
      tl.totalSec.toFixed(1) + ' ثانیه)</div>');
    rows.push('<div class="t-row"><span class="t-key">سگمنت‌های صوتی (cameraVoip):</span> <strong>' +
      tl.audioSegs.length + '</strong></div>');
    rows.push('<div class="t-row"><span class="t-key">سگمنت‌های ویدیویی (screenshare):</span> <strong>' +
      tl.videoSegs.length + '</strong></div>');

    function segRows(label, list) {
      if (!list.length) return '';
      var out = '<div class="t-section"><div class="t-key">' + label + ':</div><ol class="t-list">';
      for (var i = 0; i < list.length; i++) {
        var s = list[i];
        out += '<li>' + s.name + ' — ' +
               'از <strong>' + fmtMsAsHMS(s.startMs) + '</strong> ' +
               'تا <strong>' + fmtMsAsHMS(s.endMs) + '</strong> ' +
               '(' + s.durationSec.toFixed(1) + ' ثانیه)' +
               (s.flv ? '' : ' <span class="warn">⚠ FLV یافت نشد</span>') +
               '</li>';
      }
      out += '</ol></div>';
      return out;
    }
    rows.push(segRows('🎙️ صوت', tl.audioSegs));
    rows.push(segRows('🖥️ اسکرین‌شیر', tl.videoSegs));

    var orphans = tl.segments.filter(function (s) {
      return (s.kind === 'audio' || s.kind === 'video') && !s.flv;
    });
    if (orphans.length) {
      rows.push('<div class="warn">⚠ ' + orphans.length +
        ' سگمنت در indexstream.xml پیدا شد ولی FLV متناظرش انتخاب نشده. حتماً فولدر کامل کلاس را انتخاب کنید.</div>');
    }

    $sum.innerHTML = rows.join('');
    if ($btn) {
      $btn.disabled = !(tl.audioSegs.length || tl.videoSegs.length);
    }
  }

  // -------------------------------------------------------------------
  // FFMPEG LOADER (separate instance so we don't disturb app.js)
  // -------------------------------------------------------------------

  async function loadFullFFmpeg() {
    if (fullFFmpeg && fullFFmpeg.loaded) return fullFFmpeg;
    if (fullFFmpegLoading) return fullFFmpegLoading;
    fullFFmpegLoading = (async function () {
      var FFmpeg = (window.FFmpegWASM || {}).FFmpeg;
      if (!FFmpeg) throw new Error('کتابخانه FFmpeg بارگذاری نشد.');
      var inst = new FFmpeg();
      inst.on('log', function (ev) {
        // very chatty — keep silent unless something breaks
      });
      inst.on('progress', function (ev) {
        if (ev && typeof ev.progress === 'number') {
          // Per-stage progress is added on top of the macro progress in
          // buildFullClass — only forward to the engine bar at idle.
          // We deliberately don't update the main bar from here so the
          // higher-level driver stays in control.
        }
      });
      // Reuse the same blob URLs the main app already cached.
      var coreURL = await fetchToBlobURL('assets/vendor/ffmpeg/ffmpeg-core.js', 'text/javascript');
      var wasmURL = await fetchToBlobURL('assets/vendor/ffmpeg/ffmpeg-core.wasm', 'application/wasm');
      await inst.load({ coreURL: coreURL, wasmURL: wasmURL });
      fullFFmpeg = inst;
      return inst;
    })();
    try { return await fullFFmpegLoading; }
    finally { fullFFmpegLoading = null; }
  }

  async function fetchToBlobURL(url, mime) {
    var r = await fetch(url);
    if (!r.ok) throw new Error('fetch ' + url + ' → ' + r.status);
    var b = await r.arrayBuffer();
    return URL.createObjectURL(new Blob([b], { type: mime }));
  }

  async function reloadFullFFmpeg() {
    var prev = fullFFmpeg;
    fullFFmpeg = null;
    fullFFmpegLoading = null;
    logoBaseReady = false;
    if (prev) {
      try { prev.terminate && prev.terminate(); } catch (_) {}
    }
    return await loadFullFFmpeg();
  }

  // Pull a file out of the wasm FS into a JS-side Uint8Array. Used to
  // pass intermediate audio.m4a / video.mp4 across an ffmpeg restart so
  // we can clear wasm heap pressure between phases.
  async function readFromFs(ff, name) {
    var data = await ff.readFile(name);
    return new Uint8Array(data.buffer ? data.buffer.slice(0) : data);
  }

  // -------------------------------------------------------------------
  // LOGO RASTERIZER — render the site logo to a PNG sized for the canvas
  // -------------------------------------------------------------------

  // Render assets/img/logo.svg onto a 1280x720 background PNG with the
  // logo centred. Returns a Uint8Array of the PNG bytes.
  async function renderLogoPng(canvasW, canvasH) {
    canvasW = canvasW || 1280;
    canvasH = canvasH || 720;
    var img = new Image();
    img.crossOrigin = 'anonymous';
    img.src = 'assets/img/logo.svg';
    await new Promise(function (resolve, reject) {
      img.onload = function () { resolve(); };
      img.onerror = function () { reject(new Error('logo load failed')); };
    });
    var canvas = document.createElement('canvas');
    canvas.width = canvasW;
    canvas.height = canvasH;
    var ctx = canvas.getContext('2d');
    // Background — light cream so the logo (which has its own bg) sits
    // nicely without an obvious frame.
    ctx.fillStyle = '#0f172a';
    ctx.fillRect(0, 0, canvasW, canvasH);
    // Center the logo at ~45% of the smaller dimension.
    var size = Math.round(Math.min(canvasW, canvasH) * 0.55);
    var x = Math.round((canvasW - size) / 2);
    var y = Math.round((canvasH - size) / 2 - size * 0.05);
    ctx.drawImage(img, x, y, size, size);
    // Footer caption
    ctx.fillStyle = '#e6edf6';
    ctx.font = '600 28px Vazirmatn, sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText('کلاس درس — امور دانشجویی', canvasW / 2, y + size + 60);
    var blob = await new Promise(function (resolve) {
      canvas.toBlob(function (b) { resolve(b); }, 'image/png');
    });
    var buf = await blob.arrayBuffer();
    return new Uint8Array(buf);
  }

  // -------------------------------------------------------------------
  // FULL-CLASS BUILDER
  // -------------------------------------------------------------------

  // Globals tunable for the wasm engine — keep moderate so the heap
  // doesn't explode on long classes.
  // Output canvas: matched to the existing per-file converter (max width
  // 960 for screenshare). Keeping the canvas modest dramatically reduces
  // wasm memory pressure on long classes (~74 min of source content).
  var CANVAS_W = 960;
  var CANVAS_H = 544;
  var FPS = 12;
  var AUDIO_HZ = 44100;
  var AUDIO_LAYOUT = 'stereo';
  // Cap total class duration we'll process in one run. Gives the user
  // a clear error rather than silently OOM'ing the browser tab.
  var MAX_CLASS_SEC = 4 * 3600;

  // Build a piecewise audio plan covering [0, totalSec) contiguously.
  // Each entry is one of:
  //   { type: 'segment', flv, name, startSec, durSec, sourceOffsetSec }
  //   { type: 'mix',     startSec, durSec,
  //                      sources: [{flv, name, sourceOffsetSec}, ...] }
  //   { type: 'silence', startSec, durSec }
  //
  // The plan is built by a sweep over segment start/end events: for each
  // half-open interval [t_k, t_{k+1}) we record which audio sources are
  // active. Zero active → silence; one → segment; two-or-more → mix
  // (multiple students/teacher speaking at once). This replaces the
  // previous behavior which trimmed the earlier segment whenever a later
  // one started before it ended (and effectively muted whichever speaker
  // happened to be sorted later for the duration of the overlap).
  function buildAudioPlan(totalSec, audioSegs) {
    // Build sweep events. Sort so 'end' events fire before 'start' events
    // at the exact same timestamp — that way back-to-back segments
    // (s1 ends at 10s, s2 starts at 10s) don't briefly look like a
    // 2-stream mix.
    var events = [];
    for (var i = 0; i < audioSegs.length; i++) {
      var s = audioSegs[i];
      var startSec = s.startMs / 1000;
      var endSec = startSec + s.durationSec;
      if (endSec > totalSec) endSec = totalSec;
      if (endSec <= startSec) continue;
      events.push({ t: startSec, kind: 1, seg: s }); // 1 = open
      events.push({ t: endSec,   kind: 0, seg: s }); // 0 = close
    }
    events.sort(function (a, b) {
      if (a.t !== b.t) return a.t - b.t;
      return a.kind - b.kind; // close (0) before open (1)
    });

    var plan = [];
    var active = []; // segments active in the current interval
    var cursor = 0;
    function emit(t) {
      var dur = t - cursor;
      if (dur < 0.05) { cursor = t; return; }
      if (active.length === 0) {
        plan.push({ type: 'silence', startSec: cursor, durSec: dur });
      } else if (active.length === 1) {
        var seg = active[0];
        plan.push({
          type: 'segment',
          flv: seg.flv,
          name: seg.name,
          startSec: cursor,
          sourceOffsetSec: Math.max(0, cursor - seg.startMs / 1000),
          durSec: dur
        });
      } else {
        // Multiple speakers active — mix them. Order by who started first
        // so the first speaker can be tagged "primary" downstream (so the
        // ducking weights are stable).
        var sorted = active.slice().sort(function (a, b) { return a.startMs - b.startMs; });
        plan.push({
          type: 'mix',
          startSec: cursor,
          durSec: dur,
          sources: sorted.map(function (seg) {
            return {
              flv: seg.flv,
              name: seg.name,
              sourceOffsetSec: Math.max(0, cursor - seg.startMs / 1000)
            };
          })
        });
      }
      cursor = t;
    }
    for (var k = 0; k < events.length; k++) {
      var ev = events[k];
      if (ev.t > cursor) emit(ev.t);
      if (ev.kind === 1) {
        active.push(ev.seg);
      } else {
        var idx = active.indexOf(ev.seg);
        if (idx >= 0) active.splice(idx, 1);
      }
    }
    if (totalSec - cursor > 0.05) {
      plan.push({ type: 'silence', startSec: cursor, durSec: totalSec - cursor });
    }
    return plan;
  }

  // Same idea but for video.
  function buildVideoPlan(totalSec, videoSegs) {
    var plan = [];
    var cursor = 0;
    for (var i = 0; i < videoSegs.length; i++) {
      var s = videoSegs[i];
      var startSec = s.startMs / 1000;
      if (startSec < cursor) startSec = cursor;
      if (startSec > cursor + 0.05) {
        plan.push({ type: 'logo', startSec: cursor, durSec: startSec - cursor });
      }
      var segDur = s.durationSec;
      if (startSec + segDur > totalSec) segDur = Math.max(0, totalSec - startSec);
      if (i + 1 < videoSegs.length) {
        var nextStart = videoSegs[i + 1].startMs / 1000;
        if (startSec + segDur > nextStart) segDur = Math.max(0, nextStart - startSec);
      }
      if (segDur > 0.05) {
        plan.push({ type: 'segment', flv: s.flv, name: s.name,
                    startSec: startSec, durSec: segDur });
        cursor = startSec + segDur;
      }
    }
    if (totalSec - cursor > 0.05) {
      plan.push({ type: 'logo', startSec: cursor, durSec: totalSec - cursor });
    }
    return plan;
  }

  // Run an ffmpeg.exec with cancellation handling.
  async function ffExec(ff, args) {
    if (fullCancelled) throw new Error('cancelled');
    var code = await ff.exec(args);
    if (fullCancelled) throw new Error('cancelled');
    return code;
  }

  // Encode one cameraVoip FLV → AAC m4a segment. `sourceOffsetSec` lets
  // us seek into the FLV when this plan entry covers only the tail of a
  // longer segment (e.g. the part of speaker A that plays alone before
  // speaker B joins is one entry; the overlap is a separate mix entry;
  // the part of A that continues alone after B leaves is another entry,
  // and that third entry needs to seek into A's FLV).
  async function encodeAudioSegment(ff, flv, durSec, outName, sourceOffsetSec) {
    var u8 = await fileToU8(flv);
    if (u8.length < 9 || u8[0] !== 0x46 || u8[1] !== 0x4c || u8[2] !== 0x56) {
      throw new Error('not an FLV: ' + flv.name);
    }
    await ff.writeFile('a_in.flv', u8);
    u8 = null; // help GC release the JS-side copy
    // apad pads with silence to durSec when the FLV ends short, ensuring
    // the produced m4a is exactly durSec long.
    var args = ['-fflags', '+genpts+igndts'];
    if (sourceOffsetSec && sourceOffsetSec > 0.01) {
      args.push('-ss', sourceOffsetSec.toFixed(3));
    }
    args.push(
      '-i', 'a_in.flv',
      '-vn',
      '-map', '0:a:0?',
      '-af', 'asetpts=N/SR/TB,aformat=sample_rates=' + AUDIO_HZ + ':channel_layouts=' + AUDIO_LAYOUT + ',aresample=async=1,apad',
      '-c:a', 'aac', '-b:a', '128k',
      '-ar', '' + AUDIO_HZ,
      '-t', durSec.toFixed(3),
      '-y', outName
    );
    var code = await ffExec(ff, args);
    try { await ff.deleteFile('a_in.flv'); } catch (_) {}
    if (code !== 0) throw new Error('audio encode failed for ' + flv.name);
  }

  // Encode an interval where 2+ cameraVoip streams overlap. We use ffmpeg's
  // amix with normalize=1, which divides the summed inputs by the input
  // count — so on a 2-speaker overlap each lands at 1/2 of its original
  // amplitude in the output (both clearly audible, no clipping). We deliberately
  // keep weights equal: we have no reliable way to tell teacher from
  // student per-segment, and on real Adobe Connect recordings the
  // first-started stream is often the silent one (mic stayed open after
  // the previous speaker finished), so favoring it would just duck the
  // person actually talking. dropout_transition=0 avoids amix's default
  // 2-second crossfade when one input ends mid-window.
  async function encodeMixedAudioSegment(ff, sources, durSec, outName) {
    if (!sources || sources.length === 0) {
      throw new Error('mix requested with no sources');
    }
    if (sources.length === 1) {
      // Degenerate case — fall back to plain segment.
      return encodeAudioSegment(ff, sources[0].flv, durSec, outName, sources[0].sourceOffsetSec);
    }
    var written = [];
    try {
      var args = ['-fflags', '+genpts+igndts'];
      for (var i = 0; i < sources.length; i++) {
        var u8 = await fileToU8(sources[i].flv);
        if (u8.length < 9 || u8[0] !== 0x46 || u8[1] !== 0x4c || u8[2] !== 0x56) {
          throw new Error('not an FLV: ' + sources[i].flv.name);
        }
        var fname = 'mx_' + i + '.flv';
        await ff.writeFile(fname, u8);
        u8 = null;
        written.push(fname);
        if (sources[i].sourceOffsetSec && sources[i].sourceOffsetSec > 0.01) {
          args.push('-ss', sources[i].sourceOffsetSec.toFixed(3));
        }
        args.push('-i', fname);
      }
      // Build per-input filter chains: select the audio stream, normalize
      // the format, async-resample to keep timestamps tight, and apad to
      // each input's duration so amix gets a full-length input even if
      // one stream's payload runs short of `durSec`.
      var fc = '';
      for (var j = 0; j < sources.length; j++) {
        fc +=
          '[' + j + ':a:0]' +
          'aformat=sample_rates=' + AUDIO_HZ + ':channel_layouts=' + AUDIO_LAYOUT + ',' +
          'aresample=async=1,' +
          'apad' +
          '[ax' + j + '];';
      }
      for (var k = 0; k < sources.length; k++) fc += '[ax' + k + ']';
      // Equal weights + normalize=1 ⇒ each input scaled by 1/N, summed.
      fc +=
        'amix=inputs=' + sources.length +
        ':duration=longest:dropout_transition=0:normalize=1' +
        '[mixed]';
      args.push(
        '-filter_complex', fc,
        '-map', '[mixed]',
        '-c:a', 'aac', '-b:a', '128k',
        '-ar', '' + AUDIO_HZ,
        '-t', durSec.toFixed(3),
        '-y', outName
      );
      var code = await ffExec(ff, args);
      if (code !== 0) {
        throw new Error('audio mix encode failed (' +
          sources.map(function (s) { return s.name; }).join('+') + ')');
      }
    } finally {
      for (var w = 0; w < written.length; w++) {
        try { await ff.deleteFile(written[w]); } catch (_) {}
      }
    }
  }

  async function encodeSilenceSegment(ff, durSec, outName) {
    var args = [
      '-f', 'lavfi',
      '-i', 'anullsrc=channel_layout=' + AUDIO_LAYOUT + ':sample_rate=' + AUDIO_HZ,
      '-t', durSec.toFixed(3),
      '-c:a', 'aac', '-b:a', '128k',
      '-y', outName
    ];
    var code = await ffExec(ff, args);
    if (code !== 0) throw new Error('silence segment failed (' + durSec + 's)');
  }

  // Long screenshare FLVs (an Adobe Connect class can be 30–60 minutes of
  // 1920×1088 VP6 in a single file) are chunked into sub-encodes so wasm
  // ffmpeg runs at a bounded heap footprint. Each chunk re-encodes a slice
  // of the source FLV; the chunks are then concatenated with -c copy.
  var VIDEO_CHUNK_SEC = 480; // 8 minutes per chunk

  function videoFilter() {
    // Source FLVs from Adobe Connect can have inconsistent timestamps and
    // truncate a few seconds short of their advertised duration. tpad
    // paired with -t makes the output exactly the requested duration by
    // cloning the last frame at the tail.
    return 'scale=' + CANVAS_W + ':' + CANVAS_H + ':force_original_aspect_ratio=decrease:flags=fast_bilinear,' +
           'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:color=black,' +
           'fps=' + FPS + ',setsar=1,format=yuv420p,' +
           // Generous stop_duration: in wasm a chunk's actual encoded
           // length can fall short of -t by 10–60s due to demuxer/seek
           // quirks on FLV. The filter graph stops as soon as -t is
           // satisfied, so a larger value here doesn't slow encoding
           // measurably but reliably reaches the requested duration.
           'tpad=stop_mode=clone:stop_duration=300';
  }

  function videoEncodeArgs(extraInputArgs, durSec, outName) {
    var pre = ['-fflags', '+genpts+igndts'].concat(extraInputArgs).concat([
      '-i', 'v_in.flv',
      '-an',
      '-vf', videoFilter(),
      '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'fastdecode',
      '-crf', '30', '-pix_fmt', 'yuv420p',
      '-r', '' + FPS,
      '-x264-params', 'keyint=' + (FPS * 2) + ':min-keyint=' + FPS + ':no-scenecut=1',
      '-t', durSec.toFixed(3),
      '-movflags', '+faststart',
      '-y', outName
    ]);
    return pre;
  }

  async function encodeVideoSegment(ff, flv, durSec, outName, onChunkProgress) {
    var u8 = await fileToU8(flv);
    if (u8.length < 9 || u8[0] !== 0x46 || u8[1] !== 0x4c || u8[2] !== 0x56) {
      throw new Error('not an FLV: ' + flv.name);
    }
    await ff.writeFile('v_in.flv', u8);
    u8 = null; // help GC release the JS-side copy

    if (durSec <= VIDEO_CHUNK_SEC) {
      var args = videoEncodeArgs([], durSec, outName);
      var code = await ffExec(ff, args);
      try { await ff.deleteFile('v_in.flv'); } catch (_) {}
      if (code !== 0) throw new Error('video encode failed for ' + flv.name);
      return;
    }

    // Chunked path. We size the chunks so the last one always absorbs
    // any remainder >= VIDEO_CHUNK_SEC*0.5 — this avoids creating a tiny
    // tail chunk that frequently fails to seek into FLVs whose final
    // keyframe is slightly before EOF.
    var nChunks = Math.max(1, Math.floor(durSec / VIDEO_CHUNK_SEC));
    var chunkSec = durSec / nChunks; // evenly distributed
    var chunks = [];
    for (var idx = 0; idx < nChunks; idx++) {
      if (fullCancelled) {
        for (var d = 0; d < chunks.length; d++) {
          try { await ff.deleteFile(chunks[d]); } catch (_) {}
        }
        try { await ff.deleteFile('v_in.flv'); } catch (_) {}
        throw new Error('cancelled');
      }
      var t = idx * chunkSec;
      var clen = chunkSec;
      var cname = '_vc_' + idx + '.mp4';
      var args2 = videoEncodeArgs(['-ss', t.toFixed(3)], clen, cname);
      if (onChunkProgress) onChunkProgress(idx, nChunks, t, clen);
      var c2 = await ffExec(ff, args2);
      if (c2 !== 0) {
        for (var dd = 0; dd < chunks.length; dd++) {
          try { await ff.deleteFile(chunks[dd]); } catch (_) {}
        }
        try { await ff.deleteFile('v_in.flv'); } catch (_) {}
        throw new Error('video chunk encode failed for ' + flv.name + ' at ' + t.toFixed(1) + 's');
      }
      chunks.push(cname);
    }

    // Free input FLV before concat to ease memory.
    try { await ff.deleteFile('v_in.flv'); } catch (_) {}

    var ok = await concatSegments(ff, chunks, outName);
    if (!ok) ok = await concatSegmentsReencode(ff, chunks, outName, 'video');
    for (var k = 0; k < chunks.length; k++) {
      try { await ff.deleteFile(chunks[k]); } catch (_) {}
    }
    if (!ok) throw new Error('video chunk concat failed for ' + flv.name);
  }

  // Pre-encode a small "base" logo clip once, then materialise each long
  // logo gap by looping the base with -c copy. This avoids re-encoding the
  // same still image hundreds of thousands of times.
  var LOGO_BASE_SEC = 10;
  var logoBaseReady = false;
  async function encodeLogoBase(ff) {
    var vf = 'scale=' + CANVAS_W + ':' + CANVAS_H + ':force_original_aspect_ratio=decrease,' +
             'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:color=black,' +
             'fps=' + FPS + ',setsar=1,format=yuv420p';
    var args = [
      '-loop', '1',
      '-i', 'logo.png',
      '-an',
      '-vf', vf,
      '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'stillimage',
      '-crf', '30', '-pix_fmt', 'yuv420p',
      '-r', '' + FPS,
      // small GOPs so concat-by-copy aligns predictably
      '-x264-params', 'keyint=' + FPS + ':min-keyint=' + FPS + ':no-scenecut=1',
      '-t', LOGO_BASE_SEC.toFixed(3),
      '-movflags', '+faststart',
      '-y', 'logo_base.mp4'
    ];
    var code = await ffExec(ff, args);
    if (code !== 0) throw new Error('logo base encode failed');
    logoBaseReady = true;
  }

  // Render logo segment of a given duration. Tries -stream_loop -c copy
  // first (fast); falls back to a fresh encode if the wasm build doesn't
  // support that combination.
  async function encodeLogoSegment(ff, durSec, outName) {
    if (logoBaseReady) {
      try {
        var args = [
          '-stream_loop', '-1',
          '-i', 'logo_base.mp4',
          '-t', durSec.toFixed(3),
          '-c', 'copy',
          '-movflags', '+faststart',
          '-y', outName
        ];
        var code = await ffExec(ff, args);
        if (code === 0) return;
      } catch (_) { /* fall through to encode */ }
    }
    // Fallback: encode from PNG.
    var vf = 'scale=' + CANVAS_W + ':' + CANVAS_H + ':force_original_aspect_ratio=decrease,' +
             'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:color=black,' +
             'fps=' + FPS + ',setsar=1,format=yuv420p';
    var args2 = [
      '-loop', '1', '-i', 'logo.png', '-an',
      '-vf', vf,
      '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'stillimage',
      '-crf', '30', '-pix_fmt', 'yuv420p',
      '-r', '' + FPS,
      '-x264-params', 'keyint=' + (FPS * 2) + ':min-keyint=' + FPS + ':no-scenecut=1',
      '-t', durSec.toFixed(3),
      '-movflags', '+faststart',
      '-y', outName
    ];
    var c2 = await ffExec(ff, args2);
    if (c2 !== 0) throw new Error('logo segment failed (' + durSec + 's)');
  }

  // Concat a list of identically-encoded segments using the concat demuxer.
  async function concatSegments(ff, names, outName) {
    var listText = names.map(function (n) { return "file '" + n + "'"; }).join('\n') + '\n';
    await ff.writeFile('concat_list.txt', new TextEncoder().encode(listText));
    var args = [
      '-f', 'concat', '-safe', '0',
      '-i', 'concat_list.txt',
      '-c', 'copy',
      '-y', outName
    ];
    if (outName.toLowerCase().endsWith('.mp4')) args.splice(args.length - 2, 0, '-movflags', '+faststart');
    var code = await ffExec(ff, args);
    try { await ff.deleteFile('concat_list.txt'); } catch (_) {}
    return code === 0;
  }

  // Concat with re-encode (filter_complex) — slower but tolerates minor
  // codec parameter mismatches.
  async function concatSegmentsReencode(ff, names, outName, kind) {
    if (!names.length) throw new Error('no segments to concat');
    var args = [];
    for (var i = 0; i < names.length; i++) args.push('-i', names[i]);
    var fc = '';
    if (kind === 'video') {
      for (var k = 0; k < names.length; k++) fc += '[' + k + ':v:0]';
      fc += 'concat=n=' + names.length + ':v=1:a=0[v]';
      args.push('-filter_complex', fc, '-map', '[v]',
                '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '30', '-pix_fmt', 'yuv420p',
                '-r', '' + FPS, '-movflags', '+faststart',
                '-y', outName);
    } else if (kind === 'audio') {
      for (var k2 = 0; k2 < names.length; k2++) fc += '[' + k2 + ':a:0]';
      fc += 'concat=n=' + names.length + ':v=0:a=1[a]';
      args.push('-filter_complex', fc, '-map', '[a]',
                '-c:a', 'aac', '-b:a', '128k', '-ar', '' + AUDIO_HZ,
                '-y', outName);
    }
    var code = await ffExec(ff, args);
    return code === 0;
  }

  // Mux a video.mp4 + audio.m4a into the final output.
  async function muxAv(ff, videoName, audioName, outName) {
    var args = [
      '-i', videoName,
      '-i', audioName,
      '-map', '0:v:0', '-map', '1:a:0',
      '-c:v', 'copy', '-c:a', 'copy',
      '-shortest',
      '-movflags', '+faststart',
      '-y', outName
    ];
    var code = await ffExec(ff, args);
    return code === 0;
  }

  async function buildFullClass() {
    if (fullRunning) return;
    fullRunning = true;
    fullCancelled = false;
    setFullProgress(0);
    fullStatus('loading', 'در حال آماده‌سازی…');

    var $btnBuild = $('btnFullBuild');
    var $btnStop = $('btnFullCancel');
    if ($btnBuild) $btnBuild.disabled = true;
    if ($btnStop) $btnStop.style.display = 'inline-flex';

    try {
      var tl = await parseTimeline();
      if (!tl) throw new Error('فایل indexstream.xml پیدا نشد. فولدر کامل کلاس را انتخاب کنید.');
      if (!tl.totalSec) throw new Error('طول کلاس از indexstream.xml خوانده نشد.');
      if (tl.totalSec > MAX_CLASS_SEC) throw new Error('طول کلاس بیش از ' + (MAX_CLASS_SEC / 3600) + ' ساعت است.');
      if (!tl.audioSegs.length && !tl.videoSegs.length) {
        throw new Error('هیچ سگمنت صوت/ویدیویی برای ساخت پیدا نشد.');
      }

      var audioPlan = buildAudioPlan(tl.totalSec, tl.audioSegs);
      var videoPlan = buildVideoPlan(tl.totalSec, tl.videoSegs);

      fullLog('— تایم‌لاین —', 'step');
      fullLog('طول کل کلاس: ' + fmtMsAsHMS(Math.round(tl.totalSec * 1000)) + ' (' + tl.totalSec.toFixed(1) + ' ث)');
      var nMix = 0, nSeg = 0, nSil = 0;
      for (var ai = 0; ai < audioPlan.length; ai++) {
        if (audioPlan[ai].type === 'mix') nMix++;
        else if (audioPlan[ai].type === 'segment') nSeg++;
        else nSil++;
      }
      fullLog('اجزای صوت: ' + audioPlan.length +
        ' (' + nSeg + ' سگمنت تک‌کانال + ' + nMix + ' بازه‌ٔ میکس + ' + nSil + ' سکوت)');
      fullLog('اجزای ویدیو: ' + videoPlan.length + ' (' + tl.videoSegs.length + ' سگمنت + ' + (videoPlan.length - tl.videoSegs.length) + ' لوگو)');

      fullStatus('loading', 'بارگذاری موتور تبدیل…');
      var ff = await loadFullFFmpeg();
      if (fullCancelled) throw new Error('cancelled');

      // Total work units = audio plan items + video plan items + 4
      // (concat audio, concat video, restart, mux).
      var totalUnits = audioPlan.length + videoPlan.length + 4;
      var doneUnits = 0;
      function bump() { doneUnits++; setFullProgress(doneUnits / totalUnits); }

      // ============== PHASE 1: audio segments + concat ==============
      fullLog('— مرحله صدا —', 'step');
      var audioSegNames = [];
      for (var i = 0; i < audioPlan.length; i++) {
        if (fullCancelled) throw new Error('cancelled');
        var p = audioPlan[i];
        var name = 'a_' + i + '.m4a';
        var lbl = '[' + (i + 1) + '/' + audioPlan.length + '] ';
        if (p.type === 'segment') {
          fullStatus('loading', lbl + 'تبدیل صوت ' + p.name + ' (' + p.durSec.toFixed(1) + ' ث)…');
          fullLog(lbl + 'صوت ' + p.name + ' — ' + p.durSec.toFixed(1) + ' ث');
          try { await encodeAudioSegment(ff, p.flv, p.durSec, name, p.sourceOffsetSec); }
          catch (e) {
            fullLog('  ⨯ ' + e.message + ' — این بازه با سکوت پر می‌شود.', 'err');
            await encodeSilenceSegment(ff, p.durSec, name);
          }
        } else if (p.type === 'mix') {
          var mixNames = p.sources.map(function (s) { return s.name; }).join(' + ');
          fullStatus('loading', lbl + 'میکس صوت همزمان ' + p.sources.length + ' کانال (' + p.durSec.toFixed(1) + ' ث)…');
          fullLog(lbl + '🎚️ میکس صوت — ' + mixNames + ' — ' + p.durSec.toFixed(1) + ' ث');
          try { await encodeMixedAudioSegment(ff, p.sources, p.durSec, name); }
          catch (e) {
            fullLog('  ⨯ ' + e.message + ' — تلاش با اولین کانال…', 'err');
            try { await encodeAudioSegment(ff, p.sources[0].flv, p.durSec, name, p.sources[0].sourceOffsetSec); }
            catch (e2) {
              fullLog('  ⨯⨯ ' + e2.message + ' — این بازه با سکوت پر می‌شود.', 'err');
              await encodeSilenceSegment(ff, p.durSec, name);
            }
          }
        } else {
          fullStatus('loading', lbl + 'سکوت ' + p.durSec.toFixed(1) + ' ث…');
          fullLog(lbl + 'سکوت — ' + p.durSec.toFixed(1) + ' ث');
          await encodeSilenceSegment(ff, p.durSec, name);
        }
        audioSegNames.push(name);
        bump();
      }

      fullStatus('loading', 'ادغام صوت…');
      var audioOk = false;
      try { audioOk = await concatSegments(ff, audioSegNames, 'audio.m4a'); }
      catch (_) { audioOk = false; }
      if (!audioOk) {
        fullLog('… ادغام سریع صوت ناموفق — تلاش با re-encode…');
        audioOk = await concatSegmentsReencode(ff, audioSegNames, 'audio.m4a', 'audio');
      }
      if (!audioOk) throw new Error('ادغام صوت ناموفق بود.');
      bump();

      // Pull audio.m4a out of wasm FS so we can restart the engine and
      // free its heap before the (much heavier) video phase.
      fullStatus('loading', 'پاک‌سازی موتور قبل از فاز ویدیو…');
      var audioBytes = await readFromFs(ff, 'audio.m4a');
      // Best-effort cleanup of audio segments inside wasm FS.
      for (var ac = 0; ac < audioSegNames.length; ac++) {
        try { await ff.deleteFile(audioSegNames[ac]); } catch (_) {}
      }
      try { await ff.deleteFile('audio.m4a'); } catch (_) {}

      // Restart wasm engine (releases internal buffers/heap from the
      // audio phase).
      ff = await reloadFullFFmpeg();
      if (fullCancelled) throw new Error('cancelled');

      // ============== PHASE 2: logo base + video segments + concat ==============
      fullLog('— مرحله ویدیو —', 'step');
      fullStatus('loading', 'آماده‌سازی لوگو…');
      var logoPng = await renderLogoPng(CANVAS_W, CANVAS_H);
      await ff.writeFile('logo.png', logoPng);
      fullLog('پیش‌رندر لوگوی پایه (' + LOGO_BASE_SEC + ' ث)');
      await encodeLogoBase(ff);

      var videoSegNames = [];
      for (var j = 0; j < videoPlan.length; j++) {
        if (fullCancelled) throw new Error('cancelled');
        var pv = videoPlan[j];
        var vname = 'v_' + j + '.mp4';
        var vlbl = '[' + (j + 1) + '/' + videoPlan.length + '] ';
        if (pv.type === 'segment') {
          fullStatus('loading', vlbl + 'تبدیل ویدیو ' + pv.name + ' (' + pv.durSec.toFixed(1) + ' ث)…');
          fullLog(vlbl + 'ویدیو ' + pv.name + ' — ' + pv.durSec.toFixed(1) + ' ث');
          try { await encodeVideoSegment(ff, pv.flv, pv.durSec, vname); }
          catch (e) {
            fullLog('  ⨯ ' + e.message + ' — این بازه با لوگو پر می‌شود.', 'err');
            await encodeLogoSegment(ff, pv.durSec, vname);
          }
        } else {
          fullStatus('loading', vlbl + 'لوگو ' + pv.durSec.toFixed(1) + ' ث…');
          fullLog(vlbl + 'لوگو (بدون اسکرین‌شیر) — ' + pv.durSec.toFixed(1) + ' ث');
          await encodeLogoSegment(ff, pv.durSec, vname);
        }
        videoSegNames.push(vname);
        bump();
      }

      fullStatus('loading', 'ادغام ویدیو…');
      var videoOk = false;
      try { videoOk = await concatSegments(ff, videoSegNames, 'video.mp4'); }
      catch (_) { videoOk = false; }
      if (!videoOk) {
        fullLog('… ادغام سریع ویدیو ناموفق — تلاش با re-encode…');
        videoOk = await concatSegmentsReencode(ff, videoSegNames, 'video.mp4', 'video');
      }
      if (!videoOk) throw new Error('ادغام ویدیو ناموفق بود.');
      bump();

      // Pull video.mp4 out of wasm FS, restart, then mux audio + video
      // in a fresh engine to avoid heap pressure during mux.
      fullStatus('loading', 'پاک‌سازی موتور قبل از mux…');
      var videoBytes = await readFromFs(ff, 'video.mp4');
      for (var vc = 0; vc < videoSegNames.length; vc++) {
        try { await ff.deleteFile(videoSegNames[vc]); } catch (_) {}
      }
      try { await ff.deleteFile('video.mp4'); } catch (_) {}
      try { await ff.deleteFile('logo.png'); } catch (_) {}
      try { await ff.deleteFile('logo_base.mp4'); } catch (_) {}
      ff = await reloadFullFFmpeg();
      if (fullCancelled) throw new Error('cancelled');
      bump();

      // ============== PHASE 3: mux ==============
      fullStatus('loading', 'mux نهایی صوت+ویدیو…');
      await ff.writeFile('audio.m4a', audioBytes);
      await ff.writeFile('video.mp4', videoBytes);
      var muxOk = await muxAv(ff, 'video.mp4', 'audio.m4a', 'class-full.mp4');
      if (!muxOk) throw new Error('mux نهایی ناموفق بود.');

      var u8 = await readFromFs(ff, 'class-full.mp4');
      var blob = new Blob([u8], { type: 'video/mp4' });
      var url = URL.createObjectURL(blob);

      // Cleanup.
      try { await ff.deleteFile('audio.m4a'); } catch (_) {}
      try { await ff.deleteFile('video.mp4'); } catch (_) {}
      try { await ff.deleteFile('class-full.mp4'); } catch (_) {}

      fullStatus('success', '✓ ویدیوی کامل آماده شد.');
      setFullProgress(1);
      mountFullOutput(url, blob);
    } catch (err) {
      console.error(err);
      if (err && err.message === 'cancelled') {
        fullStatus('error', 'عملیات متوقف شد توسط کاربر.');
      } else {
        fullStatus('error', (err && err.message) || 'خطای ناشناخته');
      }
    } finally {
      fullRunning = false;
      if ($btnStop) $btnStop.style.display = 'none';
      if ($btnBuild) $btnBuild.disabled = false;
    }
  }

  function mountFullOutput(url, blob) {
    var $out = $('fullOutputs');
    if (!$out) return;
    var card = document.createElement('div');
    card.className = 'output-card';
    var h = document.createElement('h3');
    h.textContent = '🎬 ویدیوی کامل کلاس (MP4)';
    card.appendChild(h);
    var v = document.createElement('video');
    v.controls = true;
    v.preload = 'metadata';
    v.style.maxHeight = '60vh';
    v.playsInline = true;
    v.src = url;
    card.appendChild(v);
    var row = document.createElement('div');
    row.className = 'btn-row';
    row.style.justifyContent = 'flex-start';
    var a = document.createElement('a');
    a.href = url;
    a.download = 'class-full.mp4';
    a.className = 'btn';
    a.textContent = '⬇️ دانلود class-full.mp4 (' + (blob.size / (1024 * 1024)).toFixed(1) + ' MB)';
    row.appendChild(a);
    card.appendChild(row);
    $out.appendChild(card);
  }

  function cancelFullBuild() {
    if (!fullRunning) return;
    fullCancelled = true;
    fullLog('🛑 کاربر درخواست توقف کرد.', 'err');
    if (fullFFmpeg) {
      try { fullFFmpeg.terminate(); } catch (_) {}
      fullFFmpeg = null;
    }
  }

  // -------------------------------------------------------------------
  // Boot
  // -------------------------------------------------------------------

  function boot() {
    installFileListeners();

    var $btnBuild = $('btnFullBuild');
    if ($btnBuild) {
      $btnBuild.addEventListener('click', function () {
        buildFullClass().catch(function (e) { console.error(e); });
      });
    }
    var $btnStop = $('btnFullCancel');
    if ($btnStop) $btnStop.addEventListener('click', cancelFullBuild);

    var $btnExport = $('btnExportChat');
    if ($btnExport) {
      $btnExport.addEventListener('click', async function () {
        var msgs = await gatherAllChats();
        if (!msgs.length) return;
        var participants = await gatherParticipants();
        exportChatTxt(msgs, participants);
      });
    }

    refreshChat();
    refreshTimelineSummary();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }

  // Expose a tiny debug hook so the headless tests in /tmp/test-flv can
  // inject Files synthesised in-page (avoids Playwright's CDP file-size
  // limit when feeding large screenshare FLVs from the host).
  try {
    window.__flvExtras = {
      captureFiles: function (files) {
        if (captureFiles(files)) onFilesUpdated();
      },
      clearFiles: clearFiles,
      gatherAllChats: gatherAllChats,
      gatherParticipants: gatherParticipants,
      makeAuthorLabeler: makeAuthorLabeler,
      parseTimeline: parseTimeline,
      buildAudioPlan: buildAudioPlan,
      buildFullClass: buildFullClass,
      cancelFullBuild: cancelFullBuild,
      _loadFFmpeg: loadFullFFmpeg,
      _encodeMixedAudioSegment: encodeMixedAudioSegment,
      _encodeAudioSegment: encodeAudioSegment,
      _state: function () { return { xml: xmlFiles.length, flv: flvFiles.length }; }
    };
  } catch (_) {}
})();
