/* PDF + ftcontent XML → synced MP4.
 *
 * The "ftcontent" stream from an Adobe Connect class recording records every
 * scroll / page-turn / pointer event the instructor performs while presenting
 * a PDF inside the Share pod. The XML companion file (ftcontent*.xml) carries
 * those events as `setPdfContentSo` / `setPointerSo` messages.
 *
 * Each `setPdfContentSo` event has a `memento` string of the form:
 *   |rotn-0|tPgNum-4|bPgNum-5|tPgPct-0.92|tLOff-5|tROff-21|bPgPct-0.94|
 *    AR-1.334|zmSt-NaN|zmR-1.24|bPgOff-...|
 * where:
 *   tPgNum / bPgNum = page numbers visible at top / bottom of the viewer pod
 *   tPgPct / bPgPct = scroll fraction (0..1) within those pages
 *   AR              = viewer pod aspect ratio (w/h)
 *   zmR             = zoom ratio
 *   bPgOff          = optional pixel offset (when bottom passes end of last page)
 *
 * For each unique state we render a canvas frame showing the visible portion
 * of the PDF page stack, then ffmpeg.wasm encodes the sequence as MP4 with
 * the correct per-frame duration. Optional audio gets muxed at the end.
 *
 * Nothing here touches app.js or extras.js — they continue to own their own
 * FFmpeg instance. This module spins up its own.
 */

(function () {
  'use strict';

  // ----- Output / encoding constants -----

  var CANVAS_W = 1024;   // 4:3 — matches typical Adobe Connect PDF pod AR
  var CANVAS_H = 768;
  var FPS = 2;           // PDF content is essentially static between keyframes; 2 fps is plenty
  var PDF_RENDER_SCALE = 1.5;  // PDF.js scale when rasterising pages (higher = sharper)
  var PAGE_GAP_PX = 12;        // gap between stacked pages
  var MAX_SEGMENTS = 5000;     // safety guard

  // ----- DOM refs -----
  var $btnPickPdf      = document.getElementById('btnPvPickPdf');
  var $pdfPicker       = document.getElementById('pvPdfPicker');
  var $pdfInfo         = document.getElementById('pvPdfInfo');
  var $pdfList         = document.getElementById('pvPdfList');
  var $pdfMapHint      = document.getElementById('pvPdfMapHint');

  // Shared folder picker / file picker from the top FLV section. The
  // PDF-video flow no longer owns its own pickers; it observes whatever
  // the user drops into the top section and reuses those files.
  var $sharedFolderPicker = document.getElementById('folderPicker');
  var $sharedFilePicker   = document.getElementById('filePicker');
  var $sharedDropZone     = document.getElementById('dropZone');
  var $folderStatus       = document.getElementById('pvFolderStatus');

  var $summary         = document.getElementById('pvSummary');
  var $btnBuild        = document.getElementById('btnPvBuild');
  var $btnCancel       = document.getElementById('btnPvCancel');
  var $status          = document.getElementById('pvStatus');
  var $progressBar     = document.getElementById('pvProgressBar');
  var $progressText    = document.getElementById('pvProgressText');
  var $log             = document.getElementById('pvLog');
  var $outputs         = document.getElementById('pvOutputs');

  // If any of the section's DOM elements are missing (e.g. index.php was
  // trimmed for testing), abort silently rather than throwing on load.
  if (!$btnPickPdf || !$btnBuild) return;

  // ----- pdf.js worker -----
  if (window.pdfjsLib && window.pdfjsLib.GlobalWorkerOptions) {
    window.pdfjsLib.GlobalWorkerOptions.workerSrc =
      'assets/vendor/pdfjs/pdf.worker.min.js?v=1';
  }

  // ----- Local state -----
  //
  // The PDF section supports MULTIPLE PDFs in a single class. Each slot pairs
  // one user-supplied PDF with one ftcontent*.xml in the folder. Slot order is
  // the order they appear on the user's playback timeline (= the order their
  // ftcontent stream first becomes active in class time).
  //
  // `pdfFiles` keeps the user's pick order (drag-to-reorder UI) and is
  // re-paired with `ftcontentXmls` (sorted by class-time) on every change.
  var state = {
    pdfFiles: [],           // [File, ...] in user-controlled order
    ftcontentXmls: [],      // [{ file, startMs, endMs, durationMs, eventCount }]
                            //   sorted by ascending startMs (class-time order)
    slots: [],              // [{ pdfFile, xml, events, pointerEvents, durationMs,
                            //    pageCanvases, pagePxHeights, pagePxWidth, pdfDoc,
                            //    startMs, endMs }]
    audioFile: null,        // optional manual audio override (no UI; reserved)
    durationMs: 0,          // total class duration in ms (from indexstream onMetaData)
    cancelled: false,
    running: false,
    // Auto-discovered files from folder picker (same convention as the
    // full-build section). Used to mux audio and splice screenshare segments
    // into the final video.
    folderFlvFiles: [],     // all *.flv from folder
    folderXmlFiles: [],     // all *.xml from folder
    indexstreamXml: null,   // indexstream.xml file (if any)
    audioSegments: [],      // [{ name, startMs, endMs, durationSec, flv, xml }]
    videoSegments: []       // [{ name, startMs, endMs, durationSec, flv, xml }] — screenshare
  };

  var ffmpeg = null;
  var ffmpegLoading = null;

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

  function setProgress(p) {
    var pct = Math.max(0, Math.min(100, (p || 0) * 100));
    if ($progressBar) $progressBar.style.width = pct.toFixed(1) + '%';
    if ($progressText) $progressText.textContent = pct.toFixed(0) + '%';
  }

  function logLine(text, cls) {
    if (!$log) return;
    var line = document.createElement('div');
    if (cls) line.className = cls;
    line.textContent = text;
    $log.appendChild(line);
    $log.scrollTop = $log.scrollHeight;
  }

  function clearLog() { if ($log) $log.innerHTML = ''; }

  function fmtSize(bytes) {
    if (!bytes && bytes !== 0) return '';
    var u = ['B', 'KB', 'MB', 'GB'];
    var i = 0, n = bytes;
    while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
    return n.toFixed(n >= 10 ? 0 : 1) + ' ' + u[i];
  }

  function fmtDuration(seconds) {
    var s = Math.max(0, Math.floor(seconds));
    var h = Math.floor(s / 3600);
    var m = Math.floor((s % 3600) / 60);
    var sec = s % 60;
    var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
    return (h ? h + ':' : '') + pad(m) + ':' + pad(sec);
  }

  function updateBuildBtn() {
    var hasPdfs = state.pdfFiles.length > 0;
    var hasXml = state.ftcontentXmls.length > 0;
    var ready = hasPdfs && hasXml && !state.running;
    $btnBuild.disabled = !ready;
    if (state.running) {
      $btnBuild.title = 'در حال ساخت‌…';
    } else if (!hasPdfs) {
      $btnBuild.title = 'حداقل یک فایل PDF انتخاب کنید.';
    } else if (!hasXml) {
      $btnBuild.title = 'در بخش بالای صفحه، فولدر کلاس (شامل ftcontent*.xml) را انتخاب کنید.';
    } else {
      $btnBuild.title = '';
    }
  }

  function refreshPdfInfo() {
    if (!state.pdfFiles.length) {
      $pdfInfo.textContent = 'هنوز فایلی انتخاب نشده است.';
    } else if (state.pdfFiles.length === 1) {
      $pdfInfo.innerHTML = '<strong>' + escapeHtml(state.pdfFiles[0].name) + '</strong> — ' +
        fmtSize(state.pdfFiles[0].size);
    } else {
      $pdfInfo.innerHTML = '<strong>' + state.pdfFiles.length + '</strong> فایل PDF انتخاب شده است.';
    }
    renderPdfList();
  }

  // Render the ordered list of PDFs with up/down/remove buttons. Each row
  // also shows which ftcontent*.xml slot it will be paired with (if any),
  // along with the absolute class-time range that slot covers.
  function renderPdfList() {
    if (!$pdfList) return;
    $pdfList.innerHTML = '';
    var n = state.pdfFiles.length;
    if (!n) {
      if ($pdfMapHint) $pdfMapHint.style.display = 'none';
      return;
    }
    for (var i = 0; i < n; i++) {
      var f = state.pdfFiles[i];
      var slotInfo = state.ftcontentXmls[i] || null;
      var li = document.createElement('li');

      var idx = document.createElement('span');
      idx.className = 'pv-pdf-idx';
      idx.textContent = String(i + 1);
      li.appendChild(idx);

      var meta = document.createElement('div');
      meta.className = 'pv-pdf-meta';
      var nameSpan = document.createElement('span');
      nameSpan.className = 'pv-pdf-name';
      nameSpan.textContent = f.name;
      meta.appendChild(nameSpan);
      var subSpan = document.createElement('span');
      if (slotInfo) {
        subSpan.className = 'pv-pdf-sub ok';
        subSpan.innerHTML = 'XML: <strong>' + escapeHtml(slotInfo.file.name) + '</strong> · ' +
          'بازه‌ی فعالیت: ' +
          fmtDuration(slotInfo.startMs / 1000) + ' تا ' +
          fmtDuration(slotInfo.endMs / 1000) + ' · ' +
          fmtSize(f.size);
      } else {
        subSpan.className = 'pv-pdf-sub warn';
        subSpan.textContent = 'هیچ ftcontent*.xml برای این PDF پیدا نشد — رندر بدون رویداد.';
      }
      meta.appendChild(subSpan);
      li.appendChild(meta);

      var actions = document.createElement('div');
      actions.className = 'pv-pdf-actions';
      var btnUp = document.createElement('button');
      btnUp.type = 'button';
      btnUp.textContent = '▲';
      btnUp.title = 'یک جایگاه بالا';
      btnUp.disabled = (i === 0);
      (function (idxCap) {
        btnUp.addEventListener('click', function () { movePdf(idxCap, -1); });
      })(i);
      var btnDown = document.createElement('button');
      btnDown.type = 'button';
      btnDown.textContent = '▼';
      btnDown.title = 'یک جایگاه پایین';
      btnDown.disabled = (i === n - 1);
      (function (idxCap) {
        btnDown.addEventListener('click', function () { movePdf(idxCap, 1); });
      })(i);
      var btnDel = document.createElement('button');
      btnDel.type = 'button';
      btnDel.className = 'danger';
      btnDel.textContent = '✖';
      btnDel.title = 'حذف';
      (function (idxCap) {
        btnDel.addEventListener('click', function () { removePdf(idxCap); });
      })(i);
      actions.appendChild(btnUp);
      actions.appendChild(btnDown);
      actions.appendChild(btnDel);
      li.appendChild(actions);

      $pdfList.appendChild(li);
    }

    // Map hint: explain auto / manual matching of PDFs to XML slots.
    if ($pdfMapHint) {
      var nx = state.ftcontentXmls.length;
      $pdfMapHint.classList.remove('ok', 'warn');
      if (!nx) {
        $pdfMapHint.style.display = 'none';
      } else if (n === nx) {
        $pdfMapHint.classList.add('ok');
        $pdfMapHint.style.display = '';
        $pdfMapHint.innerHTML = 'تعداد PDFها با تعداد فایل‌های <code>ftcontent*.xml</code> برابر است (' +
          n + ' × ' + nx + ').‏ ' +
          'ترتیب فعلی با ترتیب فعال شدن هر XML در تایم‌لاین کلاس متناظر می‌شود. ' +
          'اگر ترتیب درست نیست، با دکمه‌های ▲ / ▼ جابه‌جا کنید.';
      } else {
        $pdfMapHint.classList.add('warn');
        $pdfMapHint.style.display = '';
        $pdfMapHint.innerHTML = '<strong>توجه:</strong> تعداد PDF (' + n + ') با تعداد <code>ftcontent*.xml</code> (' +
          nx + ') برابر نیست. ویدیو فقط برای اولین ' + Math.min(n, nx) +
          ' جفت ساخته می‌شود.';
      }
    }
  }

  function movePdf(idx, delta) {
    var j = idx + delta;
    if (j < 0 || j >= state.pdfFiles.length) return;
    var arr = state.pdfFiles;
    var tmp = arr[idx]; arr[idx] = arr[j]; arr[j] = tmp;
    refreshPdfInfo();
    updateBuildBtn();
  }

  function removePdf(idx) {
    state.pdfFiles.splice(idx, 1);
    refreshPdfInfo();
    updateBuildBtn();
  }

  // Render a summary of what was discovered in the shared top folder.
  // Called whenever the user (re)picks files via the top FLV picker.
  function refreshFolderStatus() {
    if (!$folderStatus) return;
    var nXml = state.ftcontentXmls.length;
    if (!nXml && !state.folderFlvFiles.length && !state.indexstreamXml) {
      $folderStatus.classList.remove('ok', 'warn');
      $folderStatus.innerHTML =
        '<strong>فولدر کلاس:</strong> هنوز انتخاب نشده است. از بخش بالای صفحه «📂 انتخاب فولدر» ' +
        'را بزنید تا فایل‌های <code>ftcontent*.xml</code>، <code>cameraVoip_*.flv</code> و ' +
        '<code>screenshare_*.flv</code> به‌صورت خودکار برای این بخش هم در دسترس باشند.';
      return;
    }
    var voipCount = 0, ssCount = 0;
    for (var i = 0; i < state.folderFlvFiles.length; i++) {
      var n = state.folderFlvFiles[i].name;
      if (/cameraVoip_/i.test(n)) voipCount++;
      else if (/screenshare_/i.test(n)) ssCount++;
    }
    var parts = [];
    if (nXml) {
      var ftNames = state.ftcontentXmls.map(function (x) { return escapeHtml(x.file.name); }).join('، ');
      parts.push('فایل‌های رویدادها (' + nXml + '): <strong>' + ftNames + '</strong>');
    } else {
      parts.push('<span class="warn">هیچ <code>ftcontent*.xml</code> پیدا نشد.</span>');
    }
    if (state.indexstreamXml) {
      parts.push('تایم‌لاین: <strong>' + escapeHtml(state.indexstreamXml.name) + '</strong>');
    }
    if (voipCount) parts.push('صدای کلاس: <strong>' + voipCount + '</strong> فایل cameraVoip');
    if (ssCount) parts.push('اسکرین‌شیر: <strong>' + ssCount + '</strong> فایل screenshare');

    var ok = nXml > 0;
    $folderStatus.classList.toggle('ok', ok);
    $folderStatus.classList.toggle('warn', !ok);
    $folderStatus.innerHTML = '<strong>فولدر کلاس:</strong> ' + parts.join(' · ');
  }

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

  // ----- File pickers -----

  $btnPickPdf.addEventListener('click', function () { $pdfPicker.click(); });
  $pdfPicker.addEventListener('change', function (e) {
    var files = e.target.files;
    if (files && files.length) {
      // Append rather than replace so the user can build up the list over
      // multiple clicks. Skip duplicates by name+size.
      for (var i = 0; i < files.length; i++) {
        var f = files[i];
        var dup = false;
        for (var j = 0; j < state.pdfFiles.length; j++) {
          if (state.pdfFiles[j].name === f.name && state.pdfFiles[j].size === f.size) {
            dup = true; break;
          }
        }
        if (!dup) state.pdfFiles.push(f);
      }
      refreshPdfInfo();
    }
    e.target.value = '';
    parseInputsAndSummarise();
  });

  // Observe the shared folder/file pickers and drop zone from the top FLV
  // section. Whenever the user picks files there, we ingest the FileList
  // and pull out ALL ftcontent*.xml, indexstream.xml, cameraVoip_*.flv and
  // screenshare_*.flv. This means the user only ever has to pick the class
  // folder once at the top of the page.
  function ingestSharedFileList(files) {
    if (!files || !files.length) return;
    var prevXmlNames = state.ftcontentXmls.map(function (x) { return x.file.name; }).join('|');
    var prevFlvCount = state.folderFlvFiles.length;

    // Reset folder-derived state on every fresh pick so old files don't
    // linger across folder changes.
    state.folderFlvFiles = [];
    state.folderXmlFiles = [];
    state.indexstreamXml = null;
    state.audioSegments = [];
    state.videoSegments = [];
    state.ftcontentXmls = [];

    var ftFiles = [];
    for (var i = 0; i < files.length; i++) {
      var f = files[i];
      var name = f.name;
      var path = f.webkitRelativePath || name;
      if (/\.flv$/i.test(name)) state.folderFlvFiles.push(f);
      else if (/\.xml$/i.test(name)) state.folderXmlFiles.push(f);
      if (/(?:^|[\\/])ftcontent\d*\.xml$/i.test(name) ||
          /(?:^|[\\/])ftcontent\d*\.xml$/i.test(path)) {
        ftFiles.push(f);
      }
      if (/(?:^|[\\/])indexstream\.xml$/i.test(name) ||
          /(?:^|[\\/])indexstream\.xml$/i.test(path)) {
        state.indexstreamXml = f;
      }
    }
    // Default sort by numeric suffix (ftcontent1 < ftcontent2 < ftcontent21).
    // The real chronological order is determined later from event timestamps
    // and (when available) indexstream's setContentSo activations.
    ftFiles.sort(function (a, b) {
      var na = parseInt((/(\d+)\.xml$/i.exec(a.name) || [])[1] || '0', 10);
      var nb = parseInt((/(\d+)\.xml$/i.exec(b.name) || [])[1] || '0', 10);
      return na - nb;
    });
    // Stub entries; parseInputsAndSummarise will fill in startMs/endMs etc.
    state.ftcontentXmls = ftFiles.map(function (f) {
      return { file: f, startMs: 0, endMs: 0, durationMs: 0, eventCount: 0 };
    });

    refreshFolderStatus();

    // Re-run the summary only when something useful actually changed.
    var newXmlNames = state.ftcontentXmls.map(function (x) { return x.file.name; }).join('|');
    if (newXmlNames !== prevXmlNames || state.folderFlvFiles.length !== prevFlvCount) {
      parseInputsAndSummarise();
    }
  }

  if ($sharedFolderPicker) {
    $sharedFolderPicker.addEventListener('change', function (e) {
      ingestSharedFileList(e.target.files);
    });
  }
  if ($sharedFilePicker) {
    $sharedFilePicker.addEventListener('change', function (e) {
      ingestSharedFileList(e.target.files);
    });
  }
  if ($sharedDropZone) {
    $sharedDropZone.addEventListener('drop', function (e) {
      var dt = e.dataTransfer;
      if (!dt) return;
      ingestSharedFileList(dt.files);
    });
  }

  refreshFolderStatus();

  // ----- XML parsing -----

  // Parse the ftcontent XML. Returns:
  //   { durationMs, events, pointerEvents }
  // events: [{ timeMs, tPgNum, bPgNum, tPgPct, bPgPct, AR, zmR, ... }]
  // pointerEvents: [{ timeMs, x, y, visible, tID }]
  function parseFtcontentXml(xmlText) {
    var parser = new DOMParser();
    var doc = parser.parseFromString(xmlText, 'text/xml');
    var perr = doc.getElementsByTagName('parsererror');
    if (perr && perr.length) throw new Error('فایل XML معتبر نیست.');

    var events = [];
    var pointerEvents = [];
    var durationMs = 0;

    var messages = doc.getElementsByTagName('Message');
    for (var i = 0; i < messages.length; i++) {
      var msg = messages[i];
      var timeAttr = msg.getAttribute('time');
      var timeMs = timeAttr ? parseInt(timeAttr, 10) : 0;

      // The outermost Method tag tells us metadata vs playEvent.
      var method = firstChildText(msg, 'Method');

      // Capture class duration from the onMetaData record (first Number in
      // the Array is the total seconds).
      if (method === 'onMetaData') {
        var arr = msg.getElementsByTagName('Array')[0];
        if (arr) {
          var nums = arr.getElementsByTagName('Number');
          if (nums && nums.length) {
            var sec = parseFloat(nums[0].textContent || '0');
            if (!isNaN(sec)) durationMs = Math.max(durationMs, Math.floor(sec * 1000));
          }
        }
        continue;
      }
      if (method !== 'playEvent') continue;

      // The next direct <String> child after the playEvent <Object> is the
      // shared-object event name (setPdfContentSo / setPointerSo / etc).
      var soName = topLevelEventName(msg);
      if (!soName) continue;

      if (soName === 'setPdfContentSo') {
        var memento = extractMemento(msg);
        if (memento) {
          var parsed = parseMemento(memento);
          if (parsed && parsed.tPgNum != null) {
            parsed.timeMs = timeMs;
            events.push(parsed);
          }
        }
      } else if (soName === 'setPointerSo') {
        var fields = extractFieldsByName(msg, ['x', 'y', 'visible', 'tID']);
        if (fields && (fields.x != null || fields.y != null || fields.visible != null)) {
          pointerEvents.push({
            timeMs: timeMs,
            x: fields.x != null ? parseFloat(fields.x) : null,
            y: fields.y != null ? parseFloat(fields.y) : null,
            visible: fields.visible != null ? (fields.visible === 'true') : null,
            tID: fields.tID
          });
        }
      }
    }

    // Sort defensively
    events.sort(function (a, b) { return a.timeMs - b.timeMs; });
    pointerEvents.sort(function (a, b) { return a.timeMs - b.timeMs; });

    return { durationMs: durationMs, events: events, pointerEvents: pointerEvents };
  }

  function firstChildText(parent, tag) {
    for (var i = 0; i < parent.childNodes.length; i++) {
      var c = parent.childNodes[i];
      if (c.nodeType === 1 && c.tagName === tag) {
        return (c.textContent || '').trim();
      }
    }
    return null;
  }

  // First direct <String> child of the Message (after the playEvent Object).
  function topLevelEventName(msg) {
    for (var i = 0; i < msg.childNodes.length; i++) {
      var c = msg.childNodes[i];
      if (c.nodeType === 1 && c.tagName === 'String') {
        return (c.textContent || '').trim();
      }
    }
    return null;
  }

  // Find the `memento` newValue inside the message's Array of Objects.
  function extractMemento(msg) {
    var objects = msg.getElementsByTagName('Object');
    for (var i = 0; i < objects.length; i++) {
      var nameEl = objects[i].getElementsByTagName('name')[0];
      var nv = objects[i].getElementsByTagName('newValue')[0];
      if (nameEl && nv && (nameEl.textContent || '').trim() === 'memento') {
        return (nv.textContent || '').trim();
      }
    }
    return null;
  }

  // Collect named newValue strings from the Array of Objects in the message.
  function extractFieldsByName(msg, names) {
    var wanted = {};
    for (var k = 0; k < names.length; k++) wanted[names[k]] = true;
    var out = {};
    var objects = msg.getElementsByTagName('Object');
    for (var i = 0; i < objects.length; i++) {
      var nameEl = objects[i].getElementsByTagName('name')[0];
      var nv = objects[i].getElementsByTagName('newValue')[0];
      if (!nameEl || !nv) continue;
      var key = (nameEl.textContent || '').trim();
      if (wanted[key]) out[key] = (nv.textContent || '').trim();
    }
    return out;
  }

  // Parse the `|key-val|key-val|...` memento string into an object.
  // Numeric values are converted to Number; unknown values stay as strings.
  function parseMemento(s) {
    var out = {};
    var parts = String(s).split('|');
    for (var i = 0; i < parts.length; i++) {
      var p = parts[i];
      if (!p) continue;
      var idx = p.indexOf('-');
      if (idx <= 0) continue;
      var key = p.substring(0, idx);
      var val = p.substring(idx + 1);
      var num = parseFloat(val);
      if (!isNaN(num)) out[key] = num;
      else out[key] = val;
    }
    return out;
  }

  // ----- Summary preview -----

  // Parse each ftcontent XML enough to discover its in-class time range
  // (first event timeMs … last event timeMs) and total event count. The
  // result is stored on each `state.ftcontentXmls[i]` entry. XMLs are then
  // sorted by ascending startMs so that user-supplied PDFs (in their list
  // order) line up with the natural class-time order of the ftcontent files.
  async function preflightFtcontentXmls() {
    if (!state.ftcontentXmls.length) return;
    for (var i = 0; i < state.ftcontentXmls.length; i++) {
      var entry = state.ftcontentXmls[i];
      try {
        var text = await entry.file.text();
        var res = parseFtcontentXml(text);
        entry.eventCount = res.events.length;
        entry.startMs = res.events.length ? res.events[0].timeMs : 0;
        entry.endMs = res.events.length
          ? Math.max(res.events[res.events.length - 1].timeMs, entry.startMs + 1000)
          : (res.durationMs || 0);
        entry.durationMs = res.durationMs || (entry.endMs - entry.startMs);
      } catch (e) {
        entry.eventCount = 0;
        entry.startMs = 0;
        entry.endMs = 0;
        entry.durationMs = 0;
      }
    }
    state.ftcontentXmls.sort(function (a, b) { return a.startMs - b.startMs; });
  }

  async function parseInputsAndSummarise() {
    updateBuildBtn();
    if (!state.ftcontentXmls.length && !state.pdfFiles.length) {
      $summary.style.display = 'none';
      renderPdfList();
      return;
    }
    try {
      // 1) Discover time ranges of every ftcontent XML so the slot map can
      //    sort them chronologically.
      await preflightFtcontentXmls();

      // 2) Re-parse indexstream segments if available so the summary can
      //    surface audio / screenshare counts AND a more accurate total
      //    class duration.
      if (state.indexstreamXml) {
        try {
          var ixText = await state.indexstreamXml.text();
          var tl = parseIndexstream(ixText, state.folderFlvFiles, state.folderXmlFiles);
          state.audioSegments = tl.audio;
          state.videoSegments = tl.video;
          state.durationMs = Math.max(state.durationMs || 0, Math.round(tl.totalSec * 1000));
        } catch (eIx) {
          state.audioSegments = [];
          state.videoSegments = [];
        }
      }
      // Fall back to the latest XML end if indexstream was missing.
      if (!state.durationMs && state.ftcontentXmls.length) {
        for (var k = 0; k < state.ftcontentXmls.length; k++) {
          if (state.ftcontentXmls[k].endMs > state.durationMs) {
            state.durationMs = state.ftcontentXmls[k].endMs;
          }
        }
      }
      renderSummary();
      renderPdfList();
    } catch (err) {
      setStatus('error', 'خواندن XML ناموفق بود: ' + (err && err.message || err));
    }
    updateBuildBtn();
  }

  function renderSummary() {
    if (!state.ftcontentXmls.length) { $summary.style.display = 'none'; return; }
    var totalEvents = 0;
    for (var i = 0; i < state.ftcontentXmls.length; i++) {
      totalEvents += state.ftcontentXmls[i].eventCount || 0;
    }
    var html = '<div class="t-row"><span class="t-key">طول کلاس:</span> <strong>' +
      fmtDuration(state.durationMs / 1000) + '</strong></div>' +
      '<div class="t-row"><span class="t-key">تعداد فایل‌های ftcontent:</span> <strong>' +
      state.ftcontentXmls.length + '</strong></div>' +
      '<div class="t-row"><span class="t-key">مجموع رویدادهای PDF:</span> <strong>' +
      totalEvents + '</strong></div>';

    // Per-XML breakdown
    var rowsHtml = '';
    for (var j = 0; j < state.ftcontentXmls.length; j++) {
      var x = state.ftcontentXmls[j];
      rowsHtml += '<div class="t-row" style="padding-right:14px"><span class="t-key">' +
        '◀ ' + escapeHtml(x.file.name) + ':</span> <strong>' +
        fmtDuration(x.startMs / 1000) + ' → ' + fmtDuration(x.endMs / 1000) +
        '</strong> · ' + x.eventCount + ' رویداد</div>';
    }
    html += rowsHtml;

    var nAudio = (state.audioSegments && state.audioSegments.filter(function (s) { return s.flv; }).length) || 0;
    var nVideo = (state.videoSegments && state.videoSegments.filter(function (s) { return s.flv; }).length) || 0;
    if (state.indexstreamXml) {
      html += '<div class="t-row"><span class="t-key">فایل‌های صوت (cameraVoip) از فولدر:</span> <strong>' +
        nAudio + '</strong></div>';
      html += '<div class="t-row"><span class="t-key">فایل‌های اسکرین‌شیر (screenshare) از فولدر:</span> <strong>' +
        nVideo + '</strong></div>';
    }
    $summary.innerHTML = html;
    $summary.style.display = '';
  }

  // ----- indexstream parsing (subset of extras.js full-build logic) -----

  // Parse indexstream.xml to extract:
  //   - totalSec: total class duration (first onMetaData Number)
  //   - audio: [{ name, startMs, endMs, durationSec, flv, xml }] for cameraVoip
  //   - video: [{ name, startMs, endMs, durationSec, flv, xml }] for screenshare
  // Each segment is matched to the FLV / XML files passed in. Segments without
  // a matching FLV are still returned (flv === null) so the caller can warn.
  function parseIndexstream(text, flvFiles, xmlFiles) {
    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;

    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(/^\//, '') });
        }
      }
    }

    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];
        segments.push({ name: sm.streamName, startMs: startMs, endMs: sm.time });
      }
    }
    Object.keys(openByName).forEach(function (name) {
      var op = openByName[name];
      segments.push({ name: name, startMs: op.startMs, endMs: Math.round(totalSec * 1000) });
    });

    function matchFile(list, base) {
      for (var k = 0; k < list.length; k++) {
        var n = list[k].name.replace(/\.[^.]+$/, '').toLowerCase();
        if (n === base) return list[k];
      }
      return null;
    }

    var audio = [], video = [];
    for (var j = 0; j < segments.length; j++) {
      var s = segments[j];
      var lname = s.name.toLowerCase();
      var isAudio = /^cameravoip/i.test(s.name);
      var isVideo = /^screenshare/i.test(s.name);
      if (!isAudio && !isVideo) continue;
      s.flv = matchFile(flvFiles || [], lname);
      s.xml = matchFile(xmlFiles || [], lname);
      s.spanSec = Math.max(0, (s.endMs - s.startMs) / 1000);
      s.durationSec = s.spanSec;
      (isAudio ? audio : video).push(s);
    }
    audio.sort(function (a, b) { return a.startMs - b.startMs; });
    video.sort(function (a, b) { return a.startMs - b.startMs; });
    return { totalSec: totalSec, audio: audio, video: video };
  }

  // ----- PDF rendering -----

  // Load the PDF and rasterise every page to an HTMLCanvasElement. Returns
  // a `pdfRender` bundle: { pdfDoc, pageCanvases, pagePxHeights, pagePxWidth }.
  // (Per-PDF rather than global state so multi-PDF builds work.)
  async function rasterisePdfPages(file, progressCb) {
    var buf = await file.arrayBuffer();
    var pdfjsLib = window.pdfjsLib;
    if (!pdfjsLib) throw new Error('کتابخانه PDF.js بارگذاری نشد.');
    var loadingTask = pdfjsLib.getDocument({ data: buf });
    var pdfDoc = await loadingTask.promise;

    var pageCanvases = [];
    var pagePxHeights = [];
    var commonPxWidth = 0;

    // First pass: pick a target width that gives every page the same px width
    // (the AC PDF pod renders all pages at the same width). We use the first
    // page's metrics at PDF_RENDER_SCALE.
    var firstPg = await pdfDoc.getPage(1);
    var fpVp = firstPg.getViewport({ scale: PDF_RENDER_SCALE });
    commonPxWidth = Math.round(fpVp.width);
    firstPg.cleanup();

    for (var pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
      if (state.cancelled) throw new Error('عملیات لغو شد.');
      var page = await pdfDoc.getPage(pageNum);
      var vp = page.getViewport({ scale: PDF_RENDER_SCALE });
      // Scale to make width exactly commonPxWidth (in case PDF pages vary).
      var scale = commonPxWidth / vp.width * PDF_RENDER_SCALE;
      var finalVp = page.getViewport({ scale: scale });
      var c = document.createElement('canvas');
      c.width = Math.round(finalVp.width);
      c.height = Math.round(finalVp.height);
      var ctx = c.getContext('2d');
      await page.render({ canvasContext: ctx, viewport: finalVp }).promise;
      pageCanvases.push(c);
      pagePxHeights.push(c.height);
      page.cleanup();
      if (progressCb) progressCb(pageNum / pdfDoc.numPages);
    }

    return {
      pdfDoc: pdfDoc,
      pageCanvases: pageCanvases,
      pagePxHeights: pagePxHeights,
      pagePxWidth: commonPxWidth
    };
  }

  // Compute the y-coordinate of the top edge of page `n` (0-based) in the
  // big stacked-pages coordinate system (page0 starts at y=0, then PAGE_GAP_PX,
  // then page1, ...).
  function pageStackTop(pdfRender, n) {
    if (!pdfRender || !pdfRender.pagePxHeights) return 0;
    if (n < 0) n = 0;
    if (n >= pdfRender.pagePxHeights.length) n = pdfRender.pagePxHeights.length - 1;
    var y = 0;
    for (var i = 0; i < n; i++) {
      y += pdfRender.pagePxHeights[i] + PAGE_GAP_PX;
    }
    return y;
  }

  // Given a memento-state and a pdfRender bundle, draw the visible region of
  // the PDF page stack to the target canvas (CANVAS_W × CANVAS_H), letterboxed
  // in white.
  function renderFrameToCanvas(canvas, pdfRender, ev, pointerNow) {
    var ctx = canvas.getContext('2d');
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    if (!pdfRender || !pdfRender.pagePxHeights || !pdfRender.pagePxHeights.length) return;

    if (!ev) {
      // No event yet — show page 0 (first page)
      ev = { tPgNum: 0, bPgNum: 0, tPgPct: 0, bPgPct: 1, AR: canvas.width / canvas.height, zmR: 1 };
    }

    // Memento page numbers are 0-indexed (Adobe Connect convention). The
    // first PDF page is tPgNum=0 / bPgNum=0.
    var pageCount = pdfRender.pagePxHeights.length;
    var rawTop = ev.tPgNum != null ? ev.tPgNum : 0;
    var rawBot = ev.bPgNum != null ? ev.bPgNum : rawTop;
    var pgTop = Math.max(0, Math.min(pageCount - 1, rawTop));
    var pgBot = Math.max(pgTop, Math.min(pageCount - 1, rawBot));
    // tPgPct / bPgPct missing in transition events (tPgOff / bPgOff used
    // instead). Fall back to "page just entered viewer" semantics: 0 for top
    // and 1 for bottom.
    var pctTop = ev.tPgPct != null ? clamp(ev.tPgPct, 0, 1) : 0;
    var pctBot = ev.bPgPct != null ? clamp(ev.bPgPct, 0, 1) : 1;

    // When the viewer spans two pages, snap the canvas to whichever page
    // has more visible area. The Adobe Connect PDF pod always shows the
    // bottom page's title at the top of the viewport once you've scrolled
    // past most of the previous page, so this matches what the instructor
    // actually saw on screen and avoids rendering a thin sliver of the
    // previous page that confuses viewers.
    if (pgTop !== pgBot) {
      var visibleTop = 1 - pctTop;     // fraction of pgTop still on screen
      var visibleBot = pctBot;          // fraction of pgBot already on screen
      if (visibleBot >= visibleTop) {
        // Bottom page dominates → render only it, full-screen from the top.
        pgTop = pgBot;
        pctTop = 0;
      } else {
        // Top page dominates → render only it, full-screen.
        pgBot = pgTop;
        pctBot = 1;
      }
    }

    var topH = pdfRender.pagePxHeights[pgTop];
    var botH = pdfRender.pagePxHeights[pgBot];

    // y coordinates of the viewer's top/bottom edges in the stack image.
    var yTop = pageStackTop(pdfRender, pgTop) + pctTop * topH;
    var yBot = pageStackTop(pdfRender, pgBot) + pctBot * botH;
    // Guard against degenerate viewer height (events at t=0 sometimes record
    // tPct~=bPct on the same page).
    var viewerHeightStack = Math.max(50, yBot - yTop);

    // Viewer aspect ratio drives the viewer's width in stack coords.
    var viewerAR = ev.AR && ev.AR > 0 ? ev.AR : (canvas.width / canvas.height);
    var viewerWidthStack = viewerHeightStack * viewerAR;

    // The page itself has known width pdfRender.pagePxWidth. If the viewer is
    // wider than the page, the page is centered with white margins; if it
    // is narrower, the user has zoomed-in and we centre horizontally.
    var xCenter = pdfRender.pagePxWidth / 2;
    var xLeft = xCenter - viewerWidthStack / 2;

    // Scale factor from stack coordinates → canvas coordinates.
    var sx = canvas.width / viewerWidthStack;
    var sy = canvas.height / viewerHeightStack;
    var s = Math.min(sx, sy); // letterbox

    // Final pixel size of the viewer region on the canvas.
    var drawW = viewerWidthStack * s;
    var drawH = viewerHeightStack * s;
    var offX = Math.round((canvas.width - drawW) / 2);
    var offY = Math.round((canvas.height - drawH) / 2);

    // Set a clip to the drawn region so pages outside the viewport don't
    // leak into the canvas.
    ctx.save();
    ctx.beginPath();
    ctx.rect(offX, offY, drawW, drawH);
    ctx.clip();

    // Paint every page that overlaps the viewer rectangle. After the
    // snap-to-dominant-page logic above, this is normally a single page.
    for (var p = pgTop; p <= pgBot; p++) {
      var pageCanvas = pdfRender.pageCanvases[p];
      if (!pageCanvas) continue;
      var pageY = pageStackTop(pdfRender, p);
      var pageH = pdfRender.pagePxHeights[p];
      // Map stack-coord rect (xLeft, pageY) → (xLeft+pageWidth, pageY+pageH)
      // into canvas coords:
      var dx = offX + (0 - xLeft) * s;
      var dy = offY + (pageY - yTop) * s;
      var dw = pdfRender.pagePxWidth * s;
      var dh = pageH * s;
      ctx.drawImage(pageCanvas, dx, dy, dw, dh);
    }

    ctx.restore();

    // Pointer overlay
    if (pointerNow && pointerNow.visible && pointerNow.x != null && pointerNow.y != null) {
      // Adobe Connect pointer coords appear to be percentages (0..100) of
      // the pod's inner viewer area. Map directly onto the drawn viewer rect.
      var px = offX + (pointerNow.x / 100) * drawW;
      var py = offY + (pointerNow.y / 100) * drawH;
      drawPointer(ctx, px, py);
    }
  }

  function drawPointer(ctx, x, y) {
    var R = 18;
    // Outer soft halo
    var grad = ctx.createRadialGradient(x, y, 0, x, y, R);
    grad.addColorStop(0, 'rgba(255, 80, 60, 0.95)');
    grad.addColorStop(0.45, 'rgba(255, 80, 60, 0.55)');
    grad.addColorStop(1, 'rgba(255, 80, 60, 0)');
    ctx.fillStyle = grad;
    ctx.beginPath(); ctx.arc(x, y, R, 0, 2 * Math.PI); ctx.fill();
    // Bright core
    ctx.fillStyle = '#ff3a25';
    ctx.beginPath(); ctx.arc(x, y, 5, 0, 2 * Math.PI); ctx.fill();
    // White outline for contrast
    ctx.lineWidth = 1.5;
    ctx.strokeStyle = 'rgba(255,255,255,0.85)';
    ctx.stroke();
  }

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

  // ----- Timeline construction -----

  // Collapse events into "keyframes": one entry per unique state, with the
  // duration each keyframe must hold before the next change. Last keyframe
  // holds until the class duration ends.
  function buildKeyframes(events, pointerEvents, totalMs) {
    var KEYFRAME_SAMPLE_RES = 4; // round percents to 0.25%
    if (!events || !events.length) {
      // No events → static page 1 for the whole class
      return [{
        timeMs: 0,
        durMs: Math.max(1000, totalMs || 0),
        ev: null,
        pointer: null
      }];
    }
    // Merge state changes from event arrays into a single sorted sequence of
    // "(timeMs, action)" tuples; for each unique time, capture state.
    var lastSig = null, lastPointerSig = null;
    var lastEv = null, lastPointer = null;
    var keyframes = [];

    var ei = 0, pi = 0;
    var evCount = events.length;
    var pCount = pointerEvents ? pointerEvents.length : 0;

    function evSig(e) {
      if (!e) return 'null';
      return e.tPgNum + '|' + e.bPgNum + '|' +
        Math.round((e.tPgPct || 0) * KEYFRAME_SAMPLE_RES * 100) + '|' +
        Math.round((e.bPgPct || 0) * KEYFRAME_SAMPLE_RES * 100) + '|' +
        (e.AR ? e.AR.toFixed(3) : '') + '|' + (e.zmR ? e.zmR.toFixed(3) : '');
    }
    function pSig(p) {
      if (!p) return 'null';
      return (p.visible ? '1' : '0') + ':' +
        (p.x != null ? Math.round(p.x * 10) : '_') + ':' +
        (p.y != null ? Math.round(p.y * 10) : '_');
    }

    while (ei < evCount || pi < pCount) {
      var tEv = ei < evCount ? events[ei].timeMs : Infinity;
      var tP = pi < pCount ? pointerEvents[pi].timeMs : Infinity;
      var t = Math.min(tEv, tP);
      if (tEv <= tP) { lastEv = events[ei]; ei++; }
      if (tP <= tEv) { lastPointer = pointerEvents[pi]; pi++; }
      // Capture all events at exactly the same timestamp before emitting
      // a keyframe.
      while (ei < evCount && events[ei].timeMs === t) { lastEv = events[ei]; ei++; }
      while (pi < pCount && pointerEvents[pi].timeMs === t) { lastPointer = pointerEvents[pi]; pi++; }

      var sig = evSig(lastEv) + '~' + pSig(lastPointer);
      var combo = sig;
      if (combo !== lastSig) {
        keyframes.push({ timeMs: t, ev: lastEv, pointer: lastPointer });
        lastSig = combo;
      }
    }

    // Compute durations & enforce safety cap on count.
    var endMs = Math.max(totalMs || 0, keyframes[keyframes.length - 1].timeMs + 1000);
    for (var i = 0; i < keyframes.length; i++) {
      var nextT = i + 1 < keyframes.length ? keyframes[i + 1].timeMs : endMs;
      keyframes[i].durMs = Math.max(50, nextT - keyframes[i].timeMs);
    }

    if (keyframes.length > MAX_SEGMENTS) {
      // Down-sample by merging adjacent micro-changes that are < 200ms apart.
      var merged = [keyframes[0]];
      for (var k = 1; k < keyframes.length; k++) {
        var last = merged[merged.length - 1];
        if (keyframes[k].timeMs - last.timeMs < 200) {
          last.durMs = (last.durMs || 0) +
            (keyframes[k + 1] ? keyframes[k + 1].timeMs - keyframes[k].timeMs : keyframes[k].durMs);
          // overwrite last with the newer state to keep latest content
          last.ev = keyframes[k].ev;
          last.pointer = keyframes[k].pointer;
        } else {
          merged.push(keyframes[k]);
        }
      }
      keyframes = merged;
    }

    return keyframes;
  }

  // ----- FFmpeg loader (own instance) -----

  async function loadFFmpeg() {
    if (ffmpeg && ffmpeg.loaded) return ffmpeg;
    if (ffmpegLoading) return ffmpegLoading;
    ffmpegLoading = (async function () {
      var FFmpeg = (window.FFmpegWASM || {}).FFmpeg;
      if (!FFmpeg) throw new Error('کتابخانه FFmpeg بارگذاری نشد.');
      var inst = new FFmpeg();
      inst.on('log', function (ev) {
        // Silent by default; comment in for debugging.
        // if (ev && ev.message) console.log('[pv-ff]', ev.message);
      });
      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 });
      ffmpeg = inst;
      return inst;
    })();
    try { return await ffmpegLoading; }
    finally { ffmpegLoading = 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 ffExec(ff, args) {
    try {
      var code = await ff.exec(args);
      return code;
    } catch (err) {
      throw err;
    }
  }

  function canvasToBlobPng(canvas) {
    return new Promise(function (resolve, reject) {
      canvas.toBlob(function (b) {
        if (!b) return reject(new Error('canvas.toBlob() returned null'));
        resolve(b);
      }, 'image/png');
    });
  }

  // ----- Build pipeline (multi-PDF + mixed timeline) -----

  async function build() {
    if (state.running) return;
    state.running = true;
    state.cancelled = false;
    $btnBuild.disabled = true;
    $btnCancel.style.display = 'inline-flex';
    $outputs.innerHTML = '';
    clearLog();
    setStatus('loading', 'در حال آماده‌سازی…');
    setProgress(0);

    var ff = null;
    var allCleanup = [];   // FS files to delete after build (best-effort)

    try {
      // ---- Step 1: build the slot list (one entry per (pdf, xml) pair) ----
      logLine('۱) جفت‌سازی PDFها با فایل‌های ftcontent…');
      var slotCount = Math.min(state.pdfFiles.length, state.ftcontentXmls.length);
      if (!slotCount) {
        if (!state.pdfFiles.length) throw new Error('هیچ PDFی انتخاب نشده است.');
        throw new Error('هیچ ftcontent*.xml در فولدر کلاس پیدا نشد.');
      }
      var slots = [];
      var nextStartMs = 0;
      for (var si = 0; si < slotCount; si++) {
        var pdfFile = state.pdfFiles[si];
        var xmlEntry = state.ftcontentXmls[si];
        var xmlText = await xmlEntry.file.text();
        var parsed = parseFtcontentXml(xmlText);
        var xmlDurMs = Math.max(parsed.durationMs || 0, xmlEntry.durationMs || 0);
        // If the XML's own event timestamps look class-absolute (first event > 5s),
        // use them as-is. Otherwise place this slot right after the previous
        // one and offset all of its event timestamps so they fall within the
        // slot's class-time window.
        var startMs, useAbsolute;
        if (parsed.events.length && parsed.events[0].timeMs > 5000) {
          startMs = parsed.events[0].timeMs;
          useAbsolute = true;
        } else {
          startMs = nextStartMs;
          useAbsolute = false;
        }
        var endMs = startMs + xmlDurMs;
        // Re-base events to absolute class-time if needed.
        var absEvents = parsed.events.map(function (e) {
          var copy = {};
          for (var k in e) if (Object.prototype.hasOwnProperty.call(e, k)) copy[k] = e[k];
          copy.timeMs = useAbsolute ? e.timeMs : (startMs + e.timeMs);
          return copy;
        });
        var absPointer = parsed.pointerEvents.map(function (p) {
          return {
            timeMs: useAbsolute ? p.timeMs : (startMs + p.timeMs),
            x: p.x, y: p.y, visible: p.visible, tID: p.tID
          };
        });
        slots.push({
          pdfFile: pdfFile,
          xmlFile: xmlEntry.file,
          events: absEvents,
          pointerEvents: absPointer,
          startMs: startMs,
          endMs: endMs,
          pdfRender: null
        });
        nextStartMs = endMs;
        logLine('   - اسلات ' + (si + 1) + ': ' + escapeHtml(pdfFile.name) + ' ↔ ' +
          escapeHtml(xmlEntry.file.name) +
          ' (' + fmtDuration(startMs / 1000) + ' → ' + fmtDuration(endMs / 1000) + ', ' +
          absEvents.length + ' رویداد)');
      }

      // Sort by startMs and clamp overlapping windows so each slot owns a
      // disjoint stretch of the class timeline. When slot[i] overlaps with
      // slot[i+1], we cut slot[i] short at slot[i+1].startMs.
      slots.sort(function (a, b) { return a.startMs - b.startMs; });
      for (var sc = 0; sc < slots.length - 1; sc++) {
        if (slots[sc].endMs > slots[sc + 1].startMs) {
          slots[sc].endMs = slots[sc + 1].startMs;
        }
      }
      // Drop events/pointers that now fall outside their (clamped) window.
      for (var sd = 0; sd < slots.length; sd++) {
        var sl = slots[sd];
        sl.events = sl.events.filter(function (e) { return e.timeMs >= sl.startMs && e.timeMs < sl.endMs; });
        sl.pointerEvents = sl.pointerEvents.filter(function (e) { return e.timeMs >= sl.startMs && e.timeMs < sl.endMs; });
      }

      // Ensure overall duration covers all slots + indexstream.
      var totalMs = state.durationMs || 0;
      for (var s = 0; s < slots.length; s++) totalMs = Math.max(totalMs, slots[s].endMs);
      totalMs = Math.max(totalMs, 1000);
      state.durationMs = totalMs;
      var totalSec = totalMs / 1000;
      logLine('   - طول کل ویدیو: ' + fmtDuration(totalSec));

      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 2: rasterise each PDF ----
      logLine('۲) رندر کردن صفحات PDF (' + slots.length + ' فایل)…');
      setStatus('loading', 'در حال رندر کردن صفحات PDF…');
      for (var pi = 0; pi < slots.length; pi++) {
        if (state.cancelled) throw new Error('عملیات لغو شد.');
        var slot = slots[pi];
        // (function-scope capture for progress closure)
        var slotIdxForLog = pi;
        slot.pdfRender = await rasterisePdfPages(slot.pdfFile, function (frac) {
          var slotShare = 1 / slots.length;
          setProgress(0.05 + (slotIdxForLog * slotShare + frac * slotShare) * 0.15);
        });
        logLine('   - ' + escapeHtml(slot.pdfFile.name) + ': ' +
          slot.pdfRender.pageCanvases.length + ' صفحه (' + slot.pdfRender.pagePxWidth + 'px عرض)');
      }
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 3: compute screenshare segments (non-overlapping) and the
      //              final timeline of "pieces" (pdfN | screenshare | gap). ----
      logLine('۳) ساخت تایم‌لاین ترکیبی…');
      var screenshare = (state.videoSegments || []).filter(function (sg) { return sg.flv; })
        .map(function (sg) {
          return { startMs: Math.max(0, sg.startMs), endMs: Math.min(totalMs, sg.endMs), flv: sg.flv };
        }).filter(function (sg) { return sg.endMs > sg.startMs; })
        .sort(function (a, b) { return a.startMs - b.startMs; });

      // Master timeline. Where a slot overlaps with a screenshare segment,
      // PDF wins (matches the "first-started-wins" convention extras.js uses
      // for screenshare overlap). Screenshare is only inserted in gaps
      // between PDF slots and outside the last slot.
      var timeline = [];   // [{ kind:'pdf', startSec, endSec, slotIdx } | { kind:'ss', ..., flv } | { kind:'gap', ... }]
      function addSegment(seg) {
        if (seg.endSec - seg.startSec < 0.001) return;
        timeline.push(seg);
      }

      // Collect non-overlapping "non-PDF" intervals.
      var nonPdfIntervals = [{ startSec: 0, endSec: totalSec }];
      for (var ip = 0; ip < slots.length; ip++) {
        var s0 = slots[ip].startMs / 1000;
        var e0 = slots[ip].endMs / 1000;
        var next = [];
        for (var ni = 0; ni < nonPdfIntervals.length; ni++) {
          var iv = nonPdfIntervals[ni];
          if (e0 <= iv.startSec || s0 >= iv.endSec) {
            next.push(iv); continue;
          }
          if (s0 > iv.startSec) next.push({ startSec: iv.startSec, endSec: s0 });
          if (e0 < iv.endSec)   next.push({ startSec: e0, endSec: iv.endSec });
        }
        nonPdfIntervals = next;
      }
      // Fill each non-PDF interval with screenshare slices + gap.
      for (var nv = 0; nv < nonPdfIntervals.length; nv++) {
        var iv2 = nonPdfIntervals[nv];
        var cur = iv2.startSec;
        for (var sv = 0; sv < screenshare.length; sv++) {
          var sb = Math.max(screenshare[sv].startMs / 1000, iv2.startSec);
          var se = Math.min(screenshare[sv].endMs / 1000, iv2.endSec);
          if (se <= cur) continue;
          if (sb > cur) addSegment({ kind: 'gap', startSec: cur, endSec: sb });
          addSegment({ kind: 'ss', startSec: Math.max(sb, cur), endSec: se, flv: screenshare[sv].flv });
          cur = se;
        }
        if (cur < iv2.endSec) addSegment({ kind: 'gap', startSec: cur, endSec: iv2.endSec });
      }
      // Insert each PDF slot.
      for (var pj = 0; pj < slots.length; pj++) {
        addSegment({
          kind: 'pdf',
          startSec: slots[pj].startMs / 1000,
          endSec: slots[pj].endMs / 1000,
          slotIdx: pj
        });
      }
      // Sort the whole timeline.
      timeline.sort(function (a, b) { return a.startSec - b.startSec; });
      logLine('   - تعداد قطعات تایم‌لاین: ' + timeline.length +
        ' (PDF=' + slots.length + '، اسکرین‌شیر=' +
        timeline.filter(function (t) { return t.kind === 'ss'; }).length +
        '، فاصله=' + timeline.filter(function (t) { return t.kind === 'gap'; }).length + ')');

      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 4: encode each timeline piece → its own mp4 ----
      logLine('۴) کدگذاری قطعات…');
      setStatus('loading', 'در حال کدگذاری قطعات…');
      ff = await loadFFmpeg();
      var workCanvas = document.createElement('canvas');
      workCanvas.width = CANVAS_W;
      workCanvas.height = CANVAS_H;

      var pieces = []; // [{ fs, durSec }]
      for (var ti = 0; ti < timeline.length; ti++) {
        if (state.cancelled) throw new Error('عملیات لغو شد.');
        var t = timeline[ti];
        var durSec = t.endSec - t.startSec;
        var pieceFs = 'piece_' + pad4(ti) + '.mp4';
        var prog = 0.20 + ti / timeline.length * 0.55;
        setProgress(prog);
        if (t.kind === 'pdf') {
          var slotForPiece = slots[t.slotIdx];
          var pdfRender = slotForPiece.pdfRender;
          // Build per-slot keyframes, clamped to the slot window.
          var kfs = buildKeyframes(slotForPiece.events, slotForPiece.pointerEvents,
            slotForPiece.endMs);
          // Drop keyframes outside the slot window and re-base to slot-relative time.
          var localKfs = [];
          for (var kf = 0; kf < kfs.length; kf++) {
            var thisT = kfs[kf];
            if (thisT.timeMs >= slotForPiece.endMs) break;
            localKfs.push({
              timeMs: Math.max(thisT.timeMs, slotForPiece.startMs) - slotForPiece.startMs,
              ev: thisT.ev,
              pointer: thisT.pointer,
              durMs: 0
            });
          }
          if (!localKfs.length) {
            // Render a single frame for the slot's first page.
            localKfs = [{ timeMs: 0, ev: null, pointer: null, durMs: durSec * 1000 }];
          }
          // Compute per-keyframe duration up to the end of the slot.
          var slotEndLocalMs = (slotForPiece.endMs - slotForPiece.startMs);
          for (var kk = 0; kk < localKfs.length; kk++) {
            var nT = kk + 1 < localKfs.length ? localKfs[kk + 1].timeMs : slotEndLocalMs;
            localKfs[kk].durMs = Math.max(50, nT - localKfs[kk].timeMs);
          }
          // Render frames + concat list for this piece.
          var listLines = [];
          for (var rf = 0; rf < localKfs.length; rf++) {
            if (state.cancelled) throw new Error('عملیات لغو شد.');
            var k = localKfs[rf];
            renderFrameToCanvas(workCanvas, pdfRender, k.ev, k.pointer);
            var blob = await canvasToBlobPng(workCanvas);
            var ab = await blob.arrayBuffer();
            var fName = 'p' + pad4(ti) + '_f' + pad4(rf) + '.png';
            await ff.writeFile(fName, new Uint8Array(ab));
            allCleanup.push(fName);
            listLines.push("file '" + fName + "'");
            listLines.push('duration ' + (k.durMs / 1000).toFixed(3));
          }
          listLines.push("file '" + 'p' + pad4(ti) + '_f' + pad4(localKfs.length - 1) + ".png'");
          var listFs = 'p' + pad4(ti) + '_list.txt';
          await ff.writeFile(listFs, new TextEncoder().encode(listLines.join('\n') + '\n'));
          allCleanup.push(listFs);
          var rcPdf = await ffExec(ff, [
            '-f', 'concat', '-safe', '0', '-i', listFs,
            '-vf', 'fps=' + FPS + ',scale=' + CANVAS_W + ':' + CANVAS_H +
                   ':force_original_aspect_ratio=decrease,' +
                   'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:white,' +
                   'setsar=1,format=yuv420p',
            '-t', durSec.toFixed(3),
            '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
            '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
            '-an', '-y', pieceFs
          ]);
          if (rcPdf !== 0) throw new Error('کدگذاری بخش PDF ناموفق بود (code=' + rcPdf + ').');
        } else if (t.kind === 'ss') {
          // Encode the screenshare FLV, scaled+padded to canvas size.
          var ssFs = 'ss_in_' + pad4(ti) + '.flv';
          var ssData = new Uint8Array(await t.flv.arrayBuffer());
          await ff.writeFile(ssFs, ssData);
          var rcSs = await ffExec(ff, [
            '-i', ssFs, '-t', durSec.toFixed(3),
            '-vf', 'fps=' + FPS + ',scale=' + CANVAS_W + ':' + CANVAS_H +
                   ':force_original_aspect_ratio=decrease,' +
                   'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:black,' +
                   'setsar=1,format=yuv420p',
            '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
            '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
            '-an', '-y', pieceFs
          ]);
          try { await ff.deleteFile(ssFs); } catch (_) {}
          if (rcSs !== 0) throw new Error('کدگذاری بخش اسکرین‌شیر ناموفق بود (code=' + rcSs + ').');
        } else {
          // gap → solid white background of the requested duration
          var rcGap = await ffExec(ff, [
            '-f', 'lavfi', '-i', 'color=c=white:s=' + CANVAS_W + 'x' + CANVAS_H + ':r=' + FPS,
            '-t', durSec.toFixed(3),
            '-vf', 'format=yuv420p,setsar=1',
            '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
            '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
            '-an', '-y', pieceFs
          ]);
          if (rcGap !== 0) throw new Error('ساخت بخش پر‌کننده ناموفق بود (code=' + rcGap + ').');
        }
        pieces.push({ fs: pieceFs, durSec: durSec });
        allCleanup.push(pieceFs);
      }
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 5: concat all pieces into one silent video track ----
      logLine('۵) ادغام قطعات تصویری…');
      setStatus('loading', 'در حال ادغام قطعات تصویری…');
      var concatLines = pieces.map(function (p) { return "file '" + p.fs + "'"; }).join('\n');
      await ff.writeFile('concat.txt', new TextEncoder().encode(concatLines + '\n'));
      allCleanup.push('concat.txt');
      var rcConcat = await ffExec(ff, [
        '-f', 'concat', '-safe', '0', '-i', 'concat.txt',
        '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
        '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
        '-an', '-movflags', '+faststart', '-y', 'video_track.mp4'
      ]);
      if (rcConcat !== 0) throw new Error('ادغام قطعات تصویری ناموفق بود (code=' + rcConcat + ').');
      allCleanup.push('video_track.mp4');

      // ---- Step 6: build audio track ----
      //
      // Use the same multi-track parallel-mixing logic as the full-class
      // video builder (extras.js): every cameraVoip FLV is delayed to its
      // absolute timestamp and amix'd. Two concurrent speakers stay audible
      // at once (no "first-started-wins" drop).
      var audioFsName = null;
      if (state.audioSegments && state.audioSegments.length) {
        var withFlv = state.audioSegments.filter(function (s) { return s.flv; });
        if (withFlv.length) {
          logLine('۶) ساخت صدای کلاس از ' + withFlv.length + ' فایل cameraVoip (mix موازی)…');
          setStatus('loading', 'در حال ساخت صدای کلاس…');
          audioFsName = await buildAudioFromSegments(ff, withFlv, totalSec,
            function (frac) { setProgress(0.80 + frac * 0.10); });
          if (audioFsName) allCleanup.push(audioFsName);
        }
      }
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 7: final mux ----
      logLine('۷) ادغام نهایی صدا و تصویر…');
      setStatus('loading', 'در حال ادغام نهایی…');
      var muxArgs = ['-i', 'video_track.mp4'];
      if (audioFsName) muxArgs.push('-i', audioFsName);
      muxArgs.push('-map', '0:v:0', '-c:v', 'copy');
      if (audioFsName) {
        muxArgs.push('-map', '1:a:0',
          '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2',
          '-shortest');
      }
      muxArgs.push('-movflags', '+faststart', '-y', 'out.mp4');
      var muxCode = await ffExec(ff, muxArgs);
      if (muxCode !== 0) throw new Error('ادغام نهایی ناموفق بود (code=' + muxCode + ').');

      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 8: read result + present ----
      logLine('۸) آماده‌سازی خروجی…');
      setProgress(0.98);
      var outData = await ff.readFile('out.mp4');
      var outBlob = new Blob([outData.buffer ? outData.buffer.slice(0) : outData],
        { type: 'video/mp4' });
      var outUrl = URL.createObjectURL(outBlob);

      var firstName = state.pdfFiles[0].name || 'class';
      var baseName = firstName.replace(/\.pdf$/i, '');
      if (state.pdfFiles.length > 1) baseName += '-multi';
      var outName = baseName + '-pdfvideo.mp4';

      renderOutput(outName, outUrl, outBlob.size);

      // Clean wasm FS
      try { await ff.deleteFile('out.mp4'); } catch (_) {}
      for (var ci = 0; ci < allCleanup.length; ci++) {
        try { await ff.deleteFile(allCleanup[ci]); } catch (_) {}
      }

      setProgress(1);
      setStatus('success', 'ویدیو با موفقیت ساخته شد.');
      logLine('پایان موفقیت‌آمیز.', 'ok');
    } catch (err) {
      var msg = (err && err.message) || String(err);
      setStatus('error', msg);
      logLine('خطا: ' + msg, 'err');
      // Best-effort cleanup so a failed build doesn't leak files.
      if (ff) {
        for (var ce = 0; ce < allCleanup.length; ce++) {
          try { await ff.deleteFile(allCleanup[ce]); } catch (_) {}
        }
      }
    } finally {
      state.running = false;
      $btnCancel.style.display = 'none';
      updateBuildBtn();
    }
  }

  function pad4(n) { return ('0000' + n).slice(-4); }

  function audioExtFromName(name) {
    var m = /\.([a-z0-9]{1,5})$/i.exec(name || '');
    return m ? '.' + m[1].toLowerCase() : '.mp3';
  }

  // ----- Audio assembly from cameraVoip segments -----

  // Build a single AAC m4a covering [0..totalSec] from cameraVoip FLVs, each
  // placed at its absolute startMs offset. Returns the FS filename of the
  // produced audio (or null on failure / no inputs).
  //
  // Strategy: decode each FLV's audio, push it to its absolute time with
  // `adelay`, then mix everything with amix(normalize=0). amix dropouts use
  // dropout_transition=0 so amplitudes stay clean at segment boundaries.
  // Output is trimmed to the full class duration.
  async function buildAudioFromSegments(ff, segments, totalSec, progressCb) {
    if (!segments || !segments.length) return null;

    // Adobe Connect frequently emits header-only cameraVoip FLVs for users
    // whose mic stayed muted — they are ~430–500 bytes and carry no audio
    // stream. Including them in a filter_complex graph causes ffmpeg to
    // abort with "Stream specifier ':a' matches no streams". Filter them
    // out up front. The smallest real cameraVoip with audio observed in
    // practice was ~5 KB, so a 2 KB threshold is safely below.
    var EMPTY_FLV_THRESHOLD = 2048;
    var skipped = 0;

    // Write each FLV to the FS first.
    var inputs = [];
    for (var i = 0; i < segments.length; i++) {
      if (state.cancelled) return null;
      var seg = segments[i];
      if (!seg.flv) continue;
      if (seg.flv.size < EMPTY_FLV_THRESHOLD) { skipped++; continue; }
      var data = new Uint8Array(await seg.flv.arrayBuffer());
      var name = 'av_' + pad4(i) + '.flv';
      await ff.writeFile(name, data);
      inputs.push({
        fsName: name,
        startMs: Math.max(0, seg.startMs | 0)
      });
      if (progressCb) progressCb((i + 1) / segments.length * 0.5);
    }
    if (skipped) {
      logLine('   - ' + skipped + ' فایل cameraVoip بدون صدا نادیده گرفته شد.', 'muted');
    }
    if (!inputs.length) {
      logLine('   - هیچ فایل صوتی معتبری پیدا نشد؛ خروجی بی‌صدا تولید می‌شود.', 'warn');
      return null;
    }

    // Build ffmpeg arguments.
    var args = [];
    for (var k = 0; k < inputs.length; k++) {
      args.push('-i', inputs[k].fsName);
    }

    // filter_complex: delay each input to its absolute offset, then amix.
    // aresample=44100:async=1 keeps timestamps tight; aformat normalises
    // sample format before mixing so amix doesn't choke on FLV speex/nellymoser
    // edge cases.
    var fc = '';
    for (var j = 0; j < inputs.length; j++) {
      var ms = inputs[j].startMs;
      fc += '[' + j + ':a]aresample=44100:async=1,' +
            'aformat=sample_fmts=fltp:channel_layouts=stereo,' +
            'adelay=' + ms + '|' + ms + '[a' + j + '];';
    }
    for (var l = 0; l < inputs.length; l++) {
      fc += '[a' + l + ']';
    }
    // normalize=0 + dropout_transition=0 means whichever input has samples at
    // a given instant contributes 1.0× to the output; amix sums them.
    // For typical class recordings where only one speaker talks at a time,
    // overlap is rare and the sum stays within range. duration=longest forces
    // the mix to run until the latest input ends so trailing audio isn't cut.
    // apad pads with silence after the last input ends so the audio track
    // always covers the full class duration even if the last cameraVoip
    // segment finishes earlier; atrim then caps the track at totalSec.
    fc += 'amix=inputs=' + inputs.length + ':duration=longest:' +
          'dropout_transition=0:normalize=0,' +
          'apad,' +
          'atrim=duration=' + totalSec.toFixed(3) + ',' +
          'asetpts=PTS-STARTPTS[outa]';

    args.push(
      '-filter_complex', fc,
      '-map', '[outa]',
      '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2',
      '-y', 'audio_track.m4a'
    );

    if (progressCb) progressCb(0.6);
    var code = await ffExec(ff, args);
    // Clean up input FLVs regardless of success.
    for (var x = 0; x < inputs.length; x++) {
      try { await ff.deleteFile(inputs[x].fsName); } catch (_) {}
    }
    if (code !== 0) {
      logLine('   - هشدار: ساخت صدا ناموفق بود (code=' + code +
        '). خروجی بدون صدا تولید می‌شود.', 'warn');
      return null;
    }
    if (progressCb) progressCb(1);
    return 'audio_track.m4a';
  }

  // ----- Screenshare splicing over PDF video -----

  // Splice each screenshare segment into the silent PDF video at its absolute
  // time window. Returns { outName, cleanup } where outName is the FS file
  // containing the final video track and cleanup is the list of intermediate
  // files to delete.
  async function spliceScreenshareOverVideo(ff, pdfMp4Name, segments, totalSec, progressCb) {
    if (!segments || !segments.length) return null;

    // Sort segments by start time and clip to [0, totalSec].
    var segs = segments.slice().sort(function (a, b) { return a.startMs - b.startMs; });
    var cleanup = [];

    // Build a non-overlapping timeline: PDF chunks interleaved with screenshare
    // segments. If two segments overlap, keep the earlier one (later one
    // tail-trimmed) — matches extras.js's first-started-wins convention.
    var timeline = []; // [{kind:'pdf'|'ss', startSec, endSec, srcName?}]
    var cursor = 0;
    for (var i = 0; i < segs.length; i++) {
      var s = segs[i];
      var segStart = Math.max(0, s.startMs / 1000);
      var segEnd = Math.min(totalSec, s.endMs / 1000);
      if (segEnd <= cursor) continue; // already covered by previous segment
      segStart = Math.max(segStart, cursor);
      if (segStart > cursor) {
        timeline.push({ kind: 'pdf', startSec: cursor, endSec: segStart });
      }
      timeline.push({ kind: 'ss', startSec: segStart, endSec: segEnd, flv: s.flv });
      cursor = segEnd;
    }
    if (cursor < totalSec) {
      timeline.push({ kind: 'pdf', startSec: cursor, endSec: totalSec });
    }

    // Encode each timeline piece to its own intermediate mp4 (same codec
    // parameters as PDF video) so they can be concat-demuxed.
    var pieceNames = [];
    for (var p = 0; p < timeline.length; p++) {
      if (state.cancelled) return null;
      var t = timeline[p];
      var dur = Math.max(0.001, t.endSec - t.startSec);
      var pieceName = 'piece_' + pad4(p) + '.mp4';

      if (t.kind === 'pdf') {
        // Trim from pdf_silent.mp4
        var trimArgs = [
          '-ss', t.startSec.toFixed(3),
          '-t', dur.toFixed(3),
          '-i', pdfMp4Name,
          '-vf', 'fps=' + FPS + ',scale=' + CANVAS_W + ':' + CANVAS_H +
                 ':force_original_aspect_ratio=decrease,' +
                 'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:white,' +
                 'setsar=1,format=yuv420p',
          '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
          '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
          '-an', '-y', pieceName
        ];
        var rc = await ffExec(ff, trimArgs);
        if (rc !== 0) {
          logLine('   - هشدار: برش بخش PDF در ' + t.startSec.toFixed(1) +
            's ناموفق بود. ادامه با ویدیوی پایه.', 'warn');
          return null;
        }
      } else {
        // Encode the screenshare FLV, scaled+padded to canvas size.
        var flvFs = 'ss_in_' + pad4(p) + '.flv';
        var data = new Uint8Array(await t.flv.arrayBuffer());
        await ff.writeFile(flvFs, data);
        var ssArgs = [
          '-i', flvFs,
          '-t', dur.toFixed(3),
          '-vf', 'fps=' + FPS + ',scale=' + CANVAS_W + ':' + CANVAS_H +
                 ':force_original_aspect_ratio=decrease,' +
                 'pad=' + CANVAS_W + ':' + CANVAS_H + ':(ow-iw)/2:(oh-ih)/2:black,' +
                 'setsar=1,format=yuv420p',
          '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
          '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
          '-an', '-y', pieceName
        ];
        var rcs = await ffExec(ff, ssArgs);
        try { await ff.deleteFile(flvFs); } catch (_) {}
        if (rcs !== 0) {
          logLine('   - هشدار: کدگذاری اسکرین‌شیر در ' + t.startSec.toFixed(1) +
            's ناموفق بود. ادامه با ویدیوی پایه.', 'warn');
          return null;
        }
      }
      pieceNames.push(pieceName);
      cleanup.push(pieceName);
      if (progressCb) progressCb((p + 1) / timeline.length * 0.85);
    }

    // Build concat list.
    var concatLines = pieceNames.map(function (n) { return "file '" + n + "'"; }).join('\n');
    await ff.writeFile('ss_concat.txt', new TextEncoder().encode(concatLines + '\n'));
    cleanup.push('ss_concat.txt');

    // Re-encode concat (more tolerant than copy when piece codec params drift).
    var concatArgs = [
      '-f', 'concat', '-safe', '0', '-i', 'ss_concat.txt',
      '-c:v', 'libx264', '-preset', 'ultrafast', '-profile:v', 'baseline',
      '-level', '3.1', '-crf', '28', '-pix_fmt', 'yuv420p',
      '-an', '-movflags', '+faststart', '-y', 'video_track.mp4'
    ];
    var rcc = await ffExec(ff, concatArgs);
    if (rcc !== 0) {
      logLine('   - هشدار: ادغام بخش‌های اسکرین‌شیر ناموفق بود. ادامه با ویدیوی پایه.', 'warn');
      return null;
    }
    cleanup.push('video_track.mp4');
    if (progressCb) progressCb(1);
    return { outName: 'video_track.mp4', cleanup: cleanup };
  }

  function renderOutput(name, url, size) {
    var card = document.createElement('div');
    card.className = 'output-card';
    var h = document.createElement('h3');
    h.textContent = '🖼️ ویدیوی PDF — ' + name + ' (' + fmtSize(size) + ')';
    card.appendChild(h);

    var video = document.createElement('video');
    video.controls = true;
    video.preload = 'metadata';
    video.playsInline = true;
    video.src = url;
    video.style.maxHeight = '60vh';
    card.appendChild(video);

    var row = document.createElement('div');
    row.className = 'btn-row';
    row.style.justifyContent = 'flex-start';
    var link = document.createElement('a');
    link.href = url;
    link.download = name;
    link.className = 'btn';
    link.textContent = '⬇️ دانلود ' + name;
    row.appendChild(link);
    card.appendChild(row);

    $outputs.appendChild(card);
  }

  // ----- Cancel -----

  $btnCancel.addEventListener('click', function () {
    if (!state.running) return;
    state.cancelled = true;
    logLine('در حال توقف…', 'warn');
    try { ffmpeg && ffmpeg.terminate && ffmpeg.terminate(); } catch (_) {}
    ffmpeg = null;
  });

  // ----- Build trigger -----
  $btnBuild.addEventListener('click', function () {
    build();
  });

  // Pre-load ffmpeg in the background so the first build is faster.
  setTimeout(function () {
    loadFFmpeg().catch(function () { /* shared with main app loader — ignore */ });
  }, 1500);

})();
