/* 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');

  // 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 -----
  var state = {
    pdfFile: null,
    xmlFile: null,
    audioFile: null,        // optional manual audio override
    events: null,           // parsed setPdfContentSo events
    pointerEvents: null,    // parsed setPointerSo events
    durationMs: 0,          // class duration in ms (from onMetaData)
    pdfDoc: null,           // pdfjs document proxy
    pageCanvases: null,     // array of OffscreenCanvas/HTMLCanvasElement per page
    pagePxHeights: null,    // each page height in px (after rendering)
    pagePxWidth: 0,         // common width of every rendered page
    cancelled: false,
    running: false,
    // Auto-discovered files from folder picker (same logic as the full-build
    // section). Used to mux audio and splice screenshare segments into the
    // final video — same convention as "ساخت ویدیوی کامل کلاس".
    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 ready = !!(state.pdfFile && state.xmlFile) && !state.running;
    $btnBuild.disabled = !ready;
    $btnBuild.title = ready ? '' :
      'برای فعال شدن: PDF را انتخاب کنید و در بخش بالا فولدر کلاس را بدهید تا ftcontent*.xml پیدا شود.';
  }

  function refreshPdfInfo() {
    if (!state.pdfFile) {
      $pdfInfo.textContent = 'هنوز فایلی انتخاب نشده است.';
      return;
    }
    $pdfInfo.innerHTML = '<strong>' + escapeHtml(state.pdfFile.name) + '</strong> — ' +
      fmtSize(state.pdfFile.size);
  }

  // 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;
    if (!state.xmlFile && !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 (state.xmlFile) {
      parts.push('فایل رویدادها: <strong>' + escapeHtml(state.xmlFile.name) + '</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 = !!state.xmlFile;
    $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 f = e.target.files && e.target.files[0];
    if (f) { state.pdfFile = 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 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 prevXml = state.xmlFile;
    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.xmlFile = null;

    var foundFt = null;
    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)) {
        if (!foundFt) foundFt = f;
      }
      if (/(?:^|[\\/])indexstream\.xml$/i.test(name) ||
          /(?:^|[\\/])indexstream\.xml$/i.test(path)) {
        state.indexstreamXml = f;
      }
    }
    if (foundFt) state.xmlFile = foundFt;

    refreshFolderStatus();

    // Re-run the summary only when something useful actually changed.
    if (state.xmlFile !== prevXml || 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 -----

  async function parseInputsAndSummarise() {
    updateBuildBtn();
    if (!state.xmlFile) {
      state.events = null;
      state.durationMs = 0;
      $summary.style.display = 'none';
      return;
    }
    try {
      var text = await state.xmlFile.text();
      var res = parseFtcontentXml(text);
      state.events = res.events;
      state.pointerEvents = res.pointerEvents;
      state.durationMs = res.durationMs;
      // Re-parse indexstream segments if available; this lets the summary
      // surface how many audio / video streams will be auto-muxed.
      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;
          if (tl.totalSec && tl.totalSec * 1000 > state.durationMs) {
            state.durationMs = Math.round(tl.totalSec * 1000);
          }
        } catch (eIx) {
          state.audioSegments = [];
          state.videoSegments = [];
        }
      }
      renderSummary();
    } catch (err) {
      setStatus('error', 'خواندن XML ناموفق بود: ' + (err && err.message || err));
      state.events = null;
    }
    updateBuildBtn();
  }

  function renderSummary() {
    if (!state.events) { $summary.style.display = 'none'; return; }
    var minPg = Infinity, maxPg = -Infinity, changes = 0, prev = null;
    for (var i = 0; i < state.events.length; i++) {
      var e = state.events[i];
      if (e.tPgNum < minPg) minPg = e.tPgNum;
      if (e.bPgNum > maxPg) maxPg = e.bPgNum;
      var keyNow = e.tPgNum + ':' + e.bPgNum + ':' + Math.round((e.tPgPct || 0) * 1000) +
        ':' + Math.round((e.bPgPct || 0) * 1000);
      if (keyNow !== prev) { changes++; prev = keyNow; }
    }
    if (!isFinite(minPg)) minPg = 0;
    if (!isFinite(maxPg)) maxPg = 0;
    // Memento uses 0-indexed page numbers; display 1-indexed for humans.
    var dispMin = minPg + 1;
    var dispMax = maxPg + 1;

    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">رویدادهای PDF:</span> <strong>' +
      state.events.length + '</strong></div>' +
      '<div class="t-row"><span class="t-key">تغییرات منحصربه‌فرد state:</span> <strong>' +
      changes + '</strong></div>' +
      '<div class="t-row"><span class="t-key">محدوده صفحات نمایش‌داده‌شده:</span> <strong>' +
      dispMin + ' تا ' + dispMax + '</strong></div>' +
      '<div class="t-row"><span class="t-key">رویدادهای اشاره‌گر:</span> <strong>' +
      (state.pointerEvents ? state.pointerEvents.length : 0) + '</strong></div>';

    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.
  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;
    state.pdfDoc = pdfDoc;

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

    state.pageCanvases = pageCanvases;
    state.pagePxHeights = pagePxHeights;
    state.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(n) {
    if (!state.pagePxHeights) return 0;
    if (n < 0) n = 0;
    if (n >= state.pagePxHeights.length) n = state.pagePxHeights.length - 1;
    var y = 0;
    for (var i = 0; i < n; i++) {
      y += state.pagePxHeights[i] + PAGE_GAP_PX;
    }
    return y;
  }

  function totalStackHeight() {
    if (!state.pagePxHeights || !state.pagePxHeights.length) return 0;
    var total = 0;
    for (var i = 0; i < state.pagePxHeights.length; i++) total += state.pagePxHeights[i];
    total += PAGE_GAP_PX * (state.pagePxHeights.length - 1);
    return total;
  }

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

    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 = state.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 = state.pagePxHeights[pgTop];
    var botH = state.pagePxHeights[pgBot];

    // y coordinates of the viewer's top/bottom edges in the stack image.
    var yTop = pageStackTop(pgTop) + pctTop * topH;
    var yBot = pageStackTop(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 state.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 = state.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 = state.pageCanvases[p];
      if (!pageCanvas) continue;
      var pageY = pageStackTop(p);
      var pageH = state.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 = state.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 -----

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

    try {
      // ---- Step 1: parse XML (re-parse to get freshest events) ----
      logLine('۱) خواندن فایل XML…');
      var xmlText = await state.xmlFile.text();
      var parsed = parseFtcontentXml(xmlText);
      state.events = parsed.events;
      state.pointerEvents = parsed.pointerEvents;
      state.durationMs = parsed.durationMs;
      if (!state.durationMs) {
        // Fall back to last event time + 5s
        var last = state.events && state.events.length ? state.events[state.events.length - 1] : null;
        state.durationMs = last ? last.timeMs + 5000 : 60000;
      }
      logLine('   - طول کلاس: ' + fmtDuration(state.durationMs / 1000));
      logLine('   - رویدادهای PDF: ' + state.events.length);
      logLine('   - رویدادهای اشاره‌گر: ' + state.pointerEvents.length);
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 2: rasterise PDF ----
      logLine('۲) رندر کردن صفحات PDF…');
      setStatus('loading', 'در حال رندر کردن صفحات PDF…');
      await rasterisePdfPages(state.pdfFile, function (frac) {
        setProgress(0.05 + frac * 0.15);
      });
      logLine('   - تعداد صفحات: ' + state.pdfDoc.numPages +
        ' (' + state.pagePxWidth + 'px عرض)');
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 3: build keyframes ----
      logLine('۳) ساخت فهرست کلیدفریم‌ها…');
      var keyframes = buildKeyframes(state.events, state.pointerEvents, state.durationMs);
      logLine('   - تعداد کلیدفریم‌ها: ' + keyframes.length);
      if (keyframes.length > MAX_SEGMENTS) {
        throw new Error('تعداد کلیدفریم‌ها از حد مجاز بیشتر است (' + keyframes.length + ').');
      }
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 4: render each keyframe → PNG, write to ffmpeg FS ----
      logLine('۴) رندر کردن فریم‌ها و انتقال به موتور تبدیل…');
      setStatus('loading', 'در حال رندر کردن فریم‌ها…');
      var ff = await loadFFmpeg();
      var workCanvas = document.createElement('canvas');
      workCanvas.width = CANVAS_W;
      workCanvas.height = CANVAS_H;

      var listLines = [];
      for (var i = 0; i < keyframes.length; i++) {
        if (state.cancelled) throw new Error('عملیات لغو شد.');
        var kf = keyframes[i];
        renderFrameToCanvas(workCanvas, kf.ev, kf.pointer);
        var blob = await canvasToBlobPng(workCanvas);
        var ab = await blob.arrayBuffer();
        var fname = 'f_' + pad4(i) + '.png';
        await ff.writeFile(fname, new Uint8Array(ab));
        listLines.push("file '" + fname + "'");
        listLines.push('duration ' + (kf.durMs / 1000).toFixed(3));
        setProgress(0.20 + (i / keyframes.length) * 0.45);
      }
      // ffmpeg concat demuxer requires the last file to be repeated without
      // a duration directive, otherwise the very last frame is dropped.
      listLines.push("file '" + 'f_' + pad4(keyframes.length - 1) + ".png'");
      await ff.writeFile('list.txt', new TextEncoder().encode(listLines.join('\n') + '\n'));
      logLine('   - ' + keyframes.length + ' فریم به FS موتور نوشته شد');

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

      // ---- Step 5a: encode silent PDF video ----
      logLine('۵) کدگذاری ویدیو پایه (H.264)…');
      setStatus('loading', 'در حال کدگذاری ویدیو…');
      var pdfArgs = [
        '-f', 'concat', '-safe', '0', '-i', 'list.txt',
        '-vf', 'fps=' + FPS + ',format=yuv420p',
        '-c:v', 'libx264',
        '-preset', 'ultrafast',
        '-profile:v', 'baseline',
        '-level', '3.1',
        '-crf', '28',
        '-pix_fmt', 'yuv420p',
        '-an',
        '-movflags', '+faststart',
        '-y', 'pdf_silent.mp4'
      ];
      ff.on('progress', function (ev) {
        if (ev && typeof ev.progress === 'number') {
          var p = Math.min(1, Math.max(0, ev.progress));
          setProgress(0.65 + p * 0.20);
        }
      });
      var code = await ffExec(ff, pdfArgs);
      if (code !== 0) throw new Error('کدگذاری ویدیو ناموفق بود (code=' + code + ').');
      if (state.cancelled) throw new Error('عملیات لغو شد.');

      // ---- Step 5b: build audio track (manual or auto) ----
      var audioFsName = null;          // path inside ffmpeg FS holding the
                                       // final mux-ready audio (m4a/AAC)
      var cleanupAudioFiles = [];      // FS files to delete at the end

      if (state.audioFile) {
        // Manual audio override — write as-is and let ffmpeg's AAC encoder
        // re-encode it during mux.
        logLine('۵.۱) آماده‌سازی صدای دستی…');
        var aData = new Uint8Array(await state.audioFile.arrayBuffer());
        audioFsName = 'audio_manual' + audioExtFromName(state.audioFile.name);
        await ff.writeFile(audioFsName, aData);
        cleanupAudioFiles.push(audioFsName);
      } else if (state.audioSegments && state.audioSegments.length) {
        // Build a synchronised audio track from cameraVoip segments in the
        // folder. Each segment is delayed to its absolute timestamp and the
        // whole thing is mixed down to a single AAC m4a.
        var withFlv = state.audioSegments.filter(function (s) { return s.flv; });
        if (withFlv.length) {
          logLine('۵.۱) ساخت صدای کلاس از ' + withFlv.length + ' فایل cameraVoip…');
          setStatus('loading', 'در حال ساخت صدای کلاس…');
          audioFsName = await buildAudioFromSegments(ff, withFlv, state.durationMs / 1000,
            function (frac) { setProgress(0.85 + frac * 0.05); });
          if (audioFsName) cleanupAudioFiles.push(audioFsName);
        }
      }

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

      // ---- Step 5c: splice screenshare segments over PDF video ----
      var videoTrackFs = 'pdf_silent.mp4';
      var screenshareFiles = [];
      if (state.videoSegments && state.videoSegments.length) {
        var ssWithFlv = state.videoSegments.filter(function (s) { return s.flv; });
        if (ssWithFlv.length) {
          logLine('۵.۲) درج ' + ssWithFlv.length + ' بخش اسکرین‌شیر روی ویدیو پایه…');
          setStatus('loading', 'در حال درج اسکرین‌شیرها…');
          var spliced = await spliceScreenshareOverVideo(ff, 'pdf_silent.mp4',
            ssWithFlv, state.durationMs / 1000,
            function (frac) { setProgress(0.90 + frac * 0.04); });
          if (spliced) {
            videoTrackFs = spliced.outName;
            screenshareFiles = spliced.cleanup;
          }
        }
      }

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

      // ---- Step 5d: final mux of video + audio ----
      logLine('۵.۳) ادغام نهایی صدا و تصویر…');
      setStatus('loading', 'در حال ادغام نهایی…');
      var muxArgs = ['-i', videoTrackFs];
      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'
        );
        // For the manual-audio path we may receive a track shorter than the
        // class; let ffmpeg trim to the shorter of the two streams so the
        // file ends cleanly. For the auto-folder path buildAudioFromSegments
        // already pads with silence to exactly totalSec, so -shortest is a
        // no-op there but still harmless.
        muxArgs.push('-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 6: read result ----
      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 baseName = (state.pdfFile.name || 'class').replace(/\.pdf$/i, '');
      var outName = baseName + '-pdfvideo.mp4';

      renderOutput(outName, outUrl, outBlob.size);

      // Clean wasm FS
      try { await ff.deleteFile('out.mp4'); } catch (_) {}
      try { await ff.deleteFile('pdf_silent.mp4'); } catch (_) {}
      if (videoTrackFs !== 'pdf_silent.mp4') {
        try { await ff.deleteFile(videoTrackFs); } catch (_) {}
      }
      for (var fi = 0; fi < screenshareFiles.length; fi++) {
        try { await ff.deleteFile(screenshareFiles[fi]); } catch (_) {}
      }
      for (var fa = 0; fa < cleanupAudioFiles.length; fa++) {
        try { await ff.deleteFile(cleanupAudioFiles[fa]); } catch (_) {}
      }
      try { await ff.deleteFile('list.txt'); } catch (_) {}
      for (var j = 0; j < keyframes.length; j++) {
        try { await ff.deleteFile('f_' + pad4(j) + '.png'); } catch (_) {}
      }

      setProgress(1);
      setStatus('success', 'ویدیو با موفقیت ساخته شد.');
      logLine('پایان موفقیت‌آمیز.', 'ok');
    } catch (err) {
      var msg = (err && err.message) || String(err);
      setStatus('error', msg);
      logLine('خطا: ' + msg, 'err');
    } 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);

})();
