/* ===========================================================================
 *  OnMic -- ExtendScript host (loaded by the CEP panel)
 *  Exposes mcsReport() and the batched mcsApplyBegin/Step/Finish trio which
 *  the panel calls via evalScript. Returns JSON strings instead of dialogs.
 * ======================================================================== */

var TICKS_PER_SECOND = 254016000000;
// Drop-frame flag for the sequence being edited: true=DF, false=NDF, null=unknown
// (secToTimecode then falls back to the "NTSC rate ⇒ drop-frame" heuristic). Set at
// apply start from the sequence's real timecode display format so we don't guess.
var MCS_DF = null;
// Read whether the sequence uses DROP-FRAME timecode from its actual display format.
// Returns true/false, or null if the setting isn't exposed on this build (caller
// then uses the heuristic). Enum: 102=29.97 DF, 103=29.97 NDF, 106=59.94 DF, 107=59.94 NDF.
function mcsDetectDropFrame(seq) {
    try {
        var vdf = seq.getSettings().videoDisplayFormat;
        if (vdf === 102 || vdf === 106) return true;
        if (vdf === 103 || vdf === 107) return false;
    } catch (e) {}
    // v1.03: the display format isn't a timecode (e.g. Frames / Feet+Frames) or
    // the enum is unknown on this build. Before falling back to the NTSC⇒DF
    // heuristic, probe the QE sequence's timecode STRING: drop-frame timecode
    // uses ';' separators, non-drop uses ':'. This catches a 29.97 NDF sequence
    // whose ruler shows Frames — previously force-guessed as drop-frame, which
    // re-opened the exact drift class the v0.99 fix closed.
    try {
        if (typeof qe !== "undefined" && qe && qe.project) {
            var qseq = qe.project.getActiveSequence();
            var tc = qseq ? String(qseq.CurrentTimecode) : "";
            if (tc.indexOf(";") >= 0) return true;
            if (/^\d{1,2}:\d{2}:\d{2}:\d{2}$/.test(tc)) return false;
        }
    } catch (e2) {}
    return null;
}

/* ---- minimal JSON.parse / stringify (ExtendScript has neither reliably) -- */
if (typeof JSON !== "object") { JSON = {}; }
(function () {
    if (typeof JSON.parse !== "function") {
        JSON.parse = function (text) {
            var t = String(text);
            if (/^[\],:{}\s]*$/.test(
                    t.replace(/\\["\\\/bfnrtu]/g, "@")
                     .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, "]")
                     .replace(/(?:^|:|,)(?:\s*\[)+/g, ""))) {
                return eval("(" + t + ")");
            }
            throw new SyntaxError("Invalid JSON");
        };
    }
}());

function jstr(v) {
    if (v === null || v === undefined) { return "null"; }
    var t = typeof v;
    if (t === "number") { return isFinite(v) ? String(v) : "null"; }  // NaN/Infinity are invalid JSON
    if (t === "boolean") { return String(v); }
    if (t === "string") {
        return '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
                      .replace(/\n/g, "\\n").replace(/\r/g, "\\r")
                      .replace(/\t/g, "\\t")
                      .replace(/[\u0000-\u001F]/g, function (ch) {   // remaining control chars are invalid JSON
                          var h = ch.charCodeAt(0).toString(16);
                          while (h.length < 4) { h = "0" + h; }
                          return "\\u" + h;
                      }) + '"';
    }
    if (v instanceof Array) {
        var a = [];
        for (var i = 0; i < v.length; i++) { a.push(jstr(v[i])); }
        return "[" + a.join(",") + "]";
    }
    var p = [];
    for (var k in v) { if (v.hasOwnProperty(k)) { p.push(jstr(k) + ":" + jstr(v[k])); } }
    return "{" + p.join(",") + "}";
}

/* ---- helpers ---- */
function normName(s) { return String(s).replace(/^\s+|\s+$/g, "").toLowerCase(); }

function secToTimecode(sec, fps) {
    if (sec < 0) { sec = 0; }
    var r = Math.round(fps);
    var frameNum = Math.round(sec * fps);
    function p(n) { return (n < 10 ? "0" : "") + n; }
    // DROP-FRAME timecode for NTSC rates (29.97 / 59.94). NTSC sequences are
    // drop-frame by default. The old code built a NON-drop timecode (grouping the
    // 29.97 frame count by 30), which Premiere then read as drop-frame and landed
    // on the wrong frame — a drift that GROWS over the timeline (~0.6s at 10min,
    // ~3s at 52min). Razor cuts then missed the intended boundary and, on short
    // (~1.5s) shots, the enable/disable assignment grabbed the neighbouring camera,
    // silently dropping ~40% of the planned cuts. True-30 sequences were unaffected.
    // DF only for NTSC rates; use the sequence's detected format when known
    // (MCS_DF), otherwise assume NTSC ⇒ drop-frame. MCS_DF===false forces NDF.
    var ntsc = (Math.abs(fps - 29.97) < 0.02) || (Math.abs(fps - 59.94) < 0.02);
    var isDF = ntsc && (MCS_DF !== false);
    if (isDF) {
        var dropFrames = Math.round(fps * 0.066666);   // 2 for 29.97, 4 for 59.94
        var framesPer10Min = Math.round(fps * 600);     // 17982 for 29.97
        var framesPerMin = r * 60;                       // 1800 (nominal)
        var d = Math.floor(frameNum / framesPer10Min);
        var mrem = frameNum % framesPer10Min;
        if (mrem > dropFrames) {
            frameNum += dropFrames * 9 * d + dropFrames * Math.floor((mrem - dropFrames) / (framesPerMin - dropFrames));
        } else {
            frameNum += dropFrames * 9 * d;
        }
        var fdf = frameNum % r, tdf = Math.floor(frameNum / r);
        var sdf = tdf % 60, mdf = Math.floor(tdf / 60) % 60, hdf = Math.floor(tdf / 3600);
        return p(hdf) + ":" + p(mdf) + ":" + p(sdf) + ";" + p(fdf);   // ';' marks drop-frame
    }
    var f = frameNum % r;
    var totalSecs = Math.floor(frameNum / r);
    var s = totalSecs % 60, m = Math.floor(totalSecs / 60) % 60, h = Math.floor(totalSecs / 3600);
    return p(h) + ":" + p(m) + ":" + p(s) + ":" + p(f);
}

function fmtTC(sec) {
    var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
    function p(n) { return (n < 10 ? "0" : "") + n; }
    return p(h) + ":" + p(m) + ":" + (s < 10 ? "0" : "") + s.toFixed(2);
}

function secToTicks(sec) {
    return String(Math.round((+sec || 0) * TICKS_PER_SECOND));
}

// Stable fingerprint of the exact clip layout OnMic analyzed. Sequence identity
// alone is not enough: a user can move, trim, insert, remove, or reorder clips in
// the same sequence after Preview and an old plan would otherwise still apply.
// Keep this deliberately independent of effects/metadata so harmless color or
// audio-effect changes do not force a re-analysis.
function mcsFpNum(v) {
    v = Number(v);
    return isFinite(v) ? String(Math.round(v * 1000000) / 1000000) : "?";
}
function mcsFpClipValue(clip, key) {
    try {
        var v = clip[key];
        if (v && v.seconds != null) return mcsFpNum(v.seconds);
        return mcsFpNum(v);
    } catch (e) { return "?"; }
}
function mcsFpHash(text) {
    // FNV-1a, returned as an unsigned fixed-width hex string. This is an
    // accidental-change detector, not a cryptographic trust boundary.
    var h = 2166136261;
    for (var i = 0; i < text.length; i++) {
        h ^= text.charCodeAt(i);
        h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
    }
    var out = (h >>> 0).toString(16);
    while (out.length < 8) out = "0" + out;
    return "layout-v1-" + out;
}
function mcsTimelineFingerprint(seq) {
    if (!seq || !seq.videoTracks || !seq.audioTracks) return "";
    var parts = ["layout-v1"];
    function addTracks(kind, tracks) {
        var n = 0;
        try { n = tracks.numTracks; } catch (eN) { return false; }
        parts.push(kind, String(n));
        for (var ti = 0; ti < n; ti++) {
            var tr = tracks[ti], clips = tr ? tr.clips : null, nc = clips ? clips.numItems : 0;
            parts.push("t", String(ti), String(nc));
            for (var ci = 0; ci < nc; ci++) {
                var cl = clips[ci], mp = "", nodeId = "", speed = "1";
                try { mp = String(cl.projectItem.getMediaPath() || ""); } catch (eP) {}
                try { nodeId = String(cl.projectItem.nodeId || ""); } catch (eI) {}
                try { if (typeof cl.getSpeed === "function") speed = mcsFpNum(cl.getSpeed()); } catch (eS) {}
                var gain = kind === "a" ? mcsFpNum(mcsClipGainDb(cl)) : "";
                parts.push("c", String(ci), mp, nodeId,
                    mcsFpClipValue(cl, "start"), mcsFpClipValue(cl, "end"),
                    mcsFpClipValue(cl, "inPoint"), mcsFpClipValue(cl, "outPoint"), speed, gain);
            }
        }
        return true;
    }
    if (!addTracks("v", seq.videoTracks) || !addTracks("a", seq.audioTracks)) return "";
    return mcsFpHash(parts.join("\u001f"));
}

function readCuts(path) {
    var f = new File(path);
    if (!f.exists) { throw new Error("cuts.json not found: " + path); }
    f.encoding = "UTF-8";   // cuts.json is UTF-8 (speaker/sequence names); platform-default mangles on Windows locales
    f.open("r");
    var c = f.read();
    f.close();
    if (c && c.charCodeAt(0) === 0xFEFF) { c = c.substring(1); }  // strip BOM
    var obj = JSON.parse(c);
    // Stamp the plan file's modification time so mcsSeqGuard can refuse a plan
    // generated BEFORE the last ripple edit (see MCS_EDITED).
    try { obj.__mtime_ms = (f.modified ? f.modified.getTime() : 0) || 0; } catch (eM) { obj.__mtime_ms = 0; }
    return obj;
}

// Session record of the last destructive RIPPLE edit per sequence (dead-air
// trim). Ripples shift every later time on the timeline, so any cuts.json
// written before the ripple is stale: applying it (or trimming again from it)
// would cut at shifted positions — into speech. mcsSeqGuard refuses those.
var MCS_EDITED = {};
var MCS_LAST_BACKUP_SEQUENCE = null;

// v1.05: MCS_EDITED alone dies with the ExtendScript session — after a Premiere
// restart, a pre-trim cuts.json would sail past mcsSeqGuard and be applied onto
// the post-trim timeline (timeline-destroying). Persist the stamps to disk and
// merge them back in (lazily, once) before the guard consults the map.
// Unreadable/corrupt file => fall back to the in-memory map only; absent file
// => behave exactly as before persistence existed.
var MCS_EDITED_LOADED = false;
function mcsEditedStampFile() {
    return new File(Folder.userData.fsName + "/OnMic/edited.json");
}
function mcsLoadEditedStamps() {
    if (MCS_EDITED_LOADED) { return; }
    MCS_EDITED_LOADED = true;
    try {
        var f = mcsEditedStampFile();
        if (!f.exists) { return; }
        f.encoding = "UTF-8";
        if (!f.open("r")) { return; }
        var c = f.read();
        f.close();
        if (c && c.charCodeAt(0) === 0xFEFF) { c = c.substring(1); }  // strip BOM
        var obj = JSON.parse(c);
        for (var k in obj) {
            if (!obj.hasOwnProperty(k)) { continue; }
            var v = Number(obj[k]);
            // keep the NEWER stamp when both disk and this session have one
            if (isFinite(v) && v > 0 && (!MCS_EDITED[k] || v > MCS_EDITED[k])) { MCS_EDITED[k] = v; }
        }
    } catch (e) {}
}
function mcsSaveEditedStamps() {
    try {
        mcsLoadEditedStamps();   // merge stamps from earlier sessions so we don't drop them
        var fo = new Folder(Folder.userData.fsName + "/OnMic");
        if (!fo.exists) { fo.create(); }
        var f = mcsEditedStampFile();
        f.encoding = "UTF-8";
        if (!f.open("w")) { return; }
        f.write(jstr(MCS_EDITED));
        f.close();
    } catch (e) {}
}

function clipCoveringTime(domTrack, sec) {
    var clips = domTrack.clips;
    for (var i = 0; i < clips.numItems; i++) {
        var it = clips[i];
        if (sec >= it.start.seconds - 1e-6 && sec < it.end.seconds - 1e-6) { return it; }
    }
    return null;
}

function clipIndexCoveringTime(domTrack, sec) {
    var clips = domTrack.clips;
    for (var i = 0; i < clips.numItems; i++) {
        var it = clips[i];
        if (sec >= it.start.seconds - 1e-6 && sec < it.end.seconds - 1e-6) { return i; }
    }
    return -1;
}

function uniqueCameraTracks(cuts) {
    var seen = {}, list = [];
    for (var i = 0; i < cuts.cameras.length; i++) {
        var t = cuts.cameras[i].track;
        if (!seen[t]) { seen[t] = true; list.push(t); }
    }
    for (var j = 0; j < cuts.segments.length; j++) {
        var ts = cuts.segments[j].camera_track;
        if (!seen[ts]) { seen[ts] = true; list.push(ts); }
    }
    // v1.03: ALWAYS ascending. The batched apply consumes razor ops with a
    // sequential cursor gated on "this op belongs to the current track", and the
    // ops are sorted by ascending track — if this list ever arrived in a
    // different order (it follows cuts.cameras order), a whole track's razors
    // would be silently skipped and its clips assigned on the uncut track.
    list.sort(function (a, b) { return a - b; });
    return list;
}

function validate(seq, cuts) {
    var errors = [];
    var nTracks = seq.videoTracks.numTracks;
    for (var i = 0; i < cuts.cameras.length; i++) {
        var cam = cuts.cameras[i], idx = cam.track;
        if (idx < 1 || idx > nTracks) {
            errors.push("Camera track V" + idx + " (" + cam.name +
                ") does not exist; sequence has " + nTracks + " video track(s).");
            continue;
        }
    }
    return errors;
}

function buildMapping(seq, cuts, modeOverride) {
    var lines = ["RESOLVED MAPPING", "----------------"];
    for (var i = 0; i < cuts.cameras.length; i++) {
        var cam = cuts.cameras[i], live = seq.videoTracks[cam.track - 1];
        lines.push("  V" + cam.track + '  "' + (live.name || ("Video " + cam.track)) + '"  OnMic tag: ' +
            (cam.speakers.length ? cam.speakers.join(", ") : "(untagged)"));
    }
    var mode = modeOverride ||
        (cuts.generated_with && cuts.generated_with.params &&
         cuts.generated_with.params.output_mode) || "enable_disable";
    lines.push("");
    lines.push("Segments: " + cuts.segments.length + " cuts over " + fmtTC(cuts.duration_s));
    lines.push("Output mode: " + mode);
    return { text: lines.join("\n"), mode: mode };
}

function lockedCameraTracks(seq, cuts) {
    var locked = [], camTracks = uniqueCameraTracks(cuts);
    for (var i = 0; i < camTracks.length; i++) {
        var idx = camTracks[i];
        try {
            if (seq.videoTracks[idx - 1] && mcsTrackIsLocked(seq.videoTracks[idx - 1])) {
                locked.push("V" + idx);
            }
        } catch (e) {}
    }
    return locked;
}

/* Pure logic (no Premiere DOM): for camera track `ti`, walk the time-ordered,
 * contiguous segments and merge consecutive ones with the same visibility into
 * spans {start, end, enabled}, where enabled = (segment.camera_track === ti).
 * The merged spans cover [segments[0].start_s, lastSegment.end_s] with no gaps
 * or overlaps. `transitions` are the start times where `enabled` flips (i.e. the
 * start of every span except the first), filtered to t > 0.0001 so we never
 * razor at the very start of the timeline -- mirroring the old `bounds` filter.
 * Razoring only at these flip points (instead of at every global boundary)
 * collapses the per-track clip count from ~#segments down to #spans. */
function trackSpans(segments, ti) {
    var spans = [], transitions = [];
    if (!segments || segments.length === 0) {
        return { spans: spans, transitions: transitions };
    }
    var cur = null;
    for (var i = 0; i < segments.length; i++) {
        var seg = segments[i];
        var enabled = (seg.camera_track === ti);
        if (cur && cur.enabled === enabled) {
            cur.end = seg.end_s;                 // extend the open span
        } else {
            cur = { start: seg.start_s, end: seg.end_s, enabled: enabled };
            spans.push(cur);
            if (spans.length > 1 && cur.start > 0.0001) {
                transitions.push(cur.start);     // visibility flips here
            }
        }
    }
    return { spans: spans, transitions: transitions };
}

/* Pure logic (no Premiere DOM): the sorted, de-duplicated UNION of every camera
 * track's visibility-flip points (trackSpans.transitions). These are the DISTINCT
 * cut points at which we add an edit across ALL camera tracks at once (one
 * sequence-level QE razor per point) instead of razoring each track separately at
 * its own points. Dedup tolerance is one frame-ish (1e-4 s, matching the >0.0001
 * filter in trackSpans). The resulting per-clip `.disabled` assignment is
 * IDENTICAL to the per-track approach: each track's enabled/disabled spans are
 * unchanged, so a clip's disabled state is still "is this track the live camera
 * for this clip's time span?" — the extra cut points merely split a track into
 * more clips that all map to the same span. */
function distinctCutPoints(cuts) {
    var camTracks = uniqueCameraTracks(cuts);
    var all = [];
    for (var t = 0; t < camTracks.length; t++) {
        var tr = trackSpans(cuts.segments, camTracks[t]).transitions;
        for (var i = 0; i < tr.length; i++) { all.push(tr[i]); }
    }
    all.sort(function (a, b) { return a - b; });
    var out = [];
    for (var k = 0; k < all.length; k++) {
        if (out.length === 0 || all[k] - out[out.length - 1] > 1e-4) { out.push(all[k]); }
    }
    return out;
}

/* Every distinct cut point that lies STRICTLY inside one of this track's clips,
 * so razoring there always splits a real clip and can't fail. Walks the sorted
 * clips and sorted points together (O(clips+points)). Callers gap-check first
 * (see cameraTrackGaps), so the only points this skips are ones that coincide
 * with an existing clip join on a multi-file angle (razoring there is a no-op
 * anyway). This is what lets us cut EVERY camera track at the union of all cut
 * points -- i.e. even, professional-style cutting where every angle has a clip at
 * every edit and can be freely toggled afterward. */
function cutPointsInsideTrack(dom, cutPts) {
    var out = [];
    if (!dom || !dom.clips || !cutPts || !cutPts.length) { return out; }
    var clips = dom.clips, n = clips.numItems, ci = 0, eps = 1e-4;
    for (var k = 0; k < cutPts.length; k++) {
        var p = cutPts[k];
        while (ci < n) {
            var en = -1; try { en = clips[ci].end.seconds; } catch (e) { en = -1; }
            if (en >= 0 && en <= p + eps) { ci++; } else { break; }
        }
        if (ci >= n) { break; }
        var stt = p; try { stt = clips[ci].start.seconds; } catch (e2) { stt = p; }
        if (p > stt + eps) { out.push(p); }
    }
    return out;
}

/* Detect coverage GAPS on any camera track across the analyzed edit range. Even,
 * professional-style cutting requires each angle to be one continuous piece of footage
 * over the edit; a hole (missing footage, or footage that starts late / ends
 * early) makes that impossible on that track, so Apply refuses and asks the user
 * to close the gap. Contiguous multi-file angles (clip A end == clip B start) are
 * NOT gaps. Returns a list of human-readable "V<ti> at <tc>" strings; [] means
 * every camera track is continuous. */
function cameraTrackGaps(seq, cuts) {
    var camTracks = uniqueCameraTracks(cuts);
    var segs = cuts.segments;
    if (!segs || !segs.length) { return []; }
    var editStart = segs[0].start_s, editEnd = segs[segs.length - 1].end_s;
    var fps = cuts.fps || 24, tol = 0.06, out = [];
    for (var t = 0; t < camTracks.length; t++) {
        var ti = camTracks[t], dom = seq.videoTracks[ti - 1];
        if (!dom || !dom.clips || dom.clips.numItems === 0) {
            out.push("V" + ti + " (no footage across the edit)"); continue;
        }
        var clips = dom.clips, n = clips.numItems, cover = null, firstStart = null, gapAt = -1;
        for (var ci = 0; ci < n; ci++) {
            var cs, ce;
            try { cs = clips[ci].start.seconds; ce = clips[ci].end.seconds; } catch (e) { continue; }
            if (ce <= editStart + tol || cs >= editEnd - tol) { continue; }  // outside edit range
            if (firstStart === null) { firstStart = cs; cover = cs; }
            if (cs > cover + tol) { gapAt = cover; break; }                  // hole before this clip
            if (ce > cover) { cover = ce; }
        }
        if (firstStart === null) { out.push("V" + ti + " (no footage across the edit)"); continue; }
        if (gapAt >= 0) { out.push("V" + ti + " at " + secToTimecode(gapAt, fps)); continue; }
        if (firstStart > editStart + tol) { out.push("V" + ti + " starts late at " + secToTimecode(firstStart, fps)); continue; }
        if (cover < editEnd - tol) { out.push("V" + ti + " ends early at " + secToTimecode(cover, fps)); continue; }
    }
    return out;
}

/* Set a track's lock state defensively. Tries the documented setLocked(0|1) API
 * first, then a couple of fallbacks seen across Premiere versions. Returns true if
 * a lock call appeared to succeed, false if no lock API was available (caller then
 * treats that track as "can't protect" and skips it gracefully). */
function mcsTrackSetLocked(track, locked) {
    if (!track) { return false; }
    var want = locked ? 1 : 0;
    try {
        if (typeof track.setLocked === "function") { track.setLocked(want); return true; }
    } catch (e1) {}
    // Some builds expose isLocked as a writable property.
    try {
        track.isLocked = locked ? true : false;
        return true;
    } catch (e2) {}
    return false;
}

function mcsProbeEditCommands() {
    var names = [
        "Add Edit",
        "Add Edit to All Tracks",
        "Add Edit at Playhead",
        "Add Edit to All Tracks at Playhead",
        "Razor",
        "Razor Tool",
        "Razor at Current Time Indicator",
        "Enable",
        "Enable Clip"
    ];
    var found = [];
    for (var i = 0; i < names.length; i++) {
        try {
            var id = app.findMenuCommandId(names[i]) || 0;
            if (id) { found.push({ name: names[i], id: id }); }
        } catch (e) {}
    }
    return found;
}

function applyEnableDisable(seq, cuts, fps) {
    app.enableQE();
    var qeSeq = qe.project.getActiveSequence();
    var camTracks = uniqueCameraTracks(cuts);
    var cutPts = distinctCutPoints(cuts);
    var razorErrors = 0, changed = 0;

    for (var t = 0; t < camTracks.length; t++) {
        var ti = camTracks[t];
        var ts = trackSpans(cuts.segments, ti);
        var spans = ts.spans;
        var dom = seq.videoTracks[ti - 1];

        // (2) Razor EVERY camera track at the union of all cut points (even,
        // professional-style cutting). cutPointsInsideTrack keeps each razor strictly
        // inside a real clip so it can't fail; callers gap-check first.
        var qeTrack = qeSeq.getVideoTrackAt(ti - 1);
        var trackCutPts = cutPointsInsideTrack(dom, cutPts);
        for (var bi = 0; bi < trackCutPts.length; bi++) {
            try { qeTrack.razor(secToTimecode(trackCutPts[bi], fps)); }
            catch (e) { razorErrors++; }
        }

        // (3) Single forward pass: dom clips are in track (= time) order, so we
        // advance one pointer over the spans. A clip is only touched when its
        // midpoint actually falls inside a span -- i.e. is "covered" -- using
        // the same `mid >= start - 1e-6 && mid < end - 1e-6` test as
        // clipCoveringTime. This reproduces the old per-segment behavior
        // exactly: clips with no covering segment (pre-roll, gaps, post-roll)
        // are left untouched. A clip ending exactly on a boundary is attributed
        // to the earlier span via the same epsilon.
        var clips = dom.clips;
        var si = 0;
        for (var ci = 0; ci < clips.numItems; ci++) {
            var item = clips[ci];
            var mid = (item.start.seconds + item.end.seconds) / 2.0;
            // Skip past spans that end at/before this midpoint (epsilon-matched).
            while (si < spans.length && !(mid < spans[si].end - 1e-6)) { si++; }
            if (si >= spans.length) { break; } // past the cut range -> leave rest
            var sp = spans[si];
            if (mid >= sp.start - 1e-6) {       // midpoint is covered by this span
                var dis = !sp.enabled;
                if (item.disabled !== dis) { item.disabled = dis; changed++; }
            }
            // else: midpoint sits in a gap before sp.start -> leave untouched
        }
    }
    return { changed: changed, razorErrors: razorErrors };
}

function preflightEnableDisable(seq, cuts) {
    var camTracks = uniqueCameraTracks(cuts);
    var usable = 0, missingChosen = 0, emptySegments = 0;
    for (var sg = 0; sg < cuts.segments.length; sg++) {
        var seg = cuts.segments[sg], mid = (seg.start_s + seg.end_s) / 2.0;
        var any = false, chosen = false;
        for (var t = 0; t < camTracks.length; t++) {
            var ti = camTracks[t], dom = seq.videoTracks[ti - 1];
            var item = clipCoveringTime(dom, mid);
            if (item) {
                any = true;
                usable++;
                if (ti === seg.camera_track) chosen = true;
            }
        }
        if (!any) emptySegments++;
        if (!chosen) missingChosen++;
    }
    return { usable: usable, missingChosen: missingChosen, emptySegments: emptySegments };
}

function mcsFindCommandId(names) {
    for (var i = 0; i < names.length; i++) {
        try {
            var id = app.findMenuCommandId(names[i]) || 0;
            if (id) { return id; }
        } catch (e) {}
    }
    return 0;
}

/* ===========================================================================
 *  PANEL ENTRY POINTS
 * ======================================================================== */

// Validate + build mapping report. Returns JSON.
function mcsReport(cutsPath, modeOverride) {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, errors: ["Open and select a sequence in Premiere first."] }); }
        var cuts = readCuts(cutsPath);
        if (!cuts.cameras || !cuts.segments) {
            return jstr({ ok: false, errors: ["That file is not a valid cuts.json (no cameras/segments)."] });
        }
        var sg = mcsSeqGuard(seq, cuts);
        if (sg) { return jstr({ ok: false, errors: [sg] }); }
        var errors = validate(seq, cuts);
        if (errors.length) { return jstr({ ok: false, errors: errors }); }
        var rep = buildMapping(seq, cuts, modeOverride);
        return jstr({ ok: true, errors: [], report: rep.text, mode: rep.mode,
                      segments: cuts.segments.length, duration: cuts.duration_s });
    } catch (e) {
        return jstr({ ok: false, errors: [e.toString()] });
    }
}

// Timestamp helper for backup names: "YYYY-MM-DD HH.MM" (no colons — not valid in names).
function mcsStamp() {
    var d = new Date();
    function p(n) { return (n < 10 ? "0" : "") + n; }
    return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) +
           " " + p(d.getHours()) + "." + p(d.getMinutes());
}

// Make a safety copy of the sequence BEFORE we touch it, so the user's original
// timeline is preserved untouched (mirrors a standard pre-cut backup).
// Premiere has no "backup" API — the correct primitive is Sequence.clone(),
// which duplicates the sequence into the project panel. We then try to rename
// the clone to something clear; if rename isn't supported on this Premiere
// version the copy still exists (named "<name> Copy"), so the backup is intact.
// Returns the backup's name on success, "made" if the copy exists but couldn't
// be re-found to rename, or "" if no backup could be made.
function mcsBackupSequence(seq) {
    try {
        MCS_LAST_BACKUP_SEQUENCE = null;
        if (!seq || typeof seq.clone !== "function") { return ""; }
        // Snapshot existing sequence IDs so we can find the new clone afterward.
        var before = {}, coll = app.project.sequences, i;
        var beforeCount = coll.numSequences;
        for (i = 0; i < coll.numSequences; i++) { before[coll[i].sequenceID] = true; }
        seq.clone();
        var clone = null, coll2 = app.project.sequences, j;
        for (j = 0; j < coll2.numSequences; j++) {
            if (!before[coll2[j].sequenceID]) { clone = coll2[j]; break; }
        }
        // IMPORTANT: clone() makes the NEW copy the active sequence. Switch focus
        // back to the original so all subsequent edits (razor via the QE active
        // sequence, AND enable/disable) land on the same sequence — not split
        // across the original and the backup copy.
        mcsActivate(seq);
        if (clone) {
            MCS_LAST_BACKUP_SEQUENCE = clone;
            var nm = seq.name + " — OnMic backup " + mcsStamp();
            try { clone.name = nm; return nm; }
            catch (e1) {
                try { clone.projectItem.name = nm; return nm; }
                catch (e2) { return clone.name; }  // keep default "Copy" name
            }
        }
        // Couldn't identify the clone by id — only treat it as a real backup if the
        // sequence COUNT actually increased, so we never edit destructively without
        // a confirmed backup.
        if (app.project.sequences.numSequences > beforeCount) return "made";
        return "";
    } catch (e) {
        return "";
    }
}

// Find a sequence in the project by its sequenceID (used to re-target the
// original sequence if the user clicks another one mid-apply).
function mcsSequenceId(seq) {
    try {
        if (seq && seq.sequenceID !== undefined && seq.sequenceID !== null) {
            return "" + seq.sequenceID;
        }
    } catch (e) {}
    return "";
}

function mcsSameSequence(seq, seqId) {
    var liveId = mcsSequenceId(seq);
    return !!liveId && liveId === ("" + seqId);
}

function mcsFindSequenceById(seqId) {
    var wanted = "" + seqId;
    try {
        var coll = app.project.sequences;
        for (var i = 0; i < coll.numSequences; i++) {
            if (mcsSequenceId(coll[i]) === wanted) { return coll[i]; }
        }
    } catch (e) {}
    return null;
}

// Make `seq` the active/open sequence (so qe.project.getActiveSequence() and
// app.project.activeSequence both refer to it). Tries the property and the
// openSequence(id) API for cross-version safety.
function mcsActivate(seq, attempts) {
    try {
        if (!seq) return false;
        attempts = Math.max(1, Math.floor(attempts || 1));
        var seqId = mcsSequenceId(seq);
        if (!seqId) return false;
        // v1.04: ACTIVATE first, THEN read the drop-frame flag. mcsDetectDropFrame
        // falls back to probing qe.project.getActiveSequence()'s timecode string —
        // if we read it before switching, we'd get the PREVIOUSLY active sequence's
        // format and could stamp the wrong DF flag (the v0.99 drift class) when the
        // two sequences differ. Order matters only for the QE-probe fallback; the
        // enum path is seq-specific either way.
        for (var n = 0; n < attempts; n++) {
            try { app.project.activeSequence = seq; } catch (e1) {}
            try {
                // clone() can schedule a delayed UI activation of the backup tab.
                // Open the original explicitly even when the DOM already reports it
                // active so Premiere's DOM and QE views converge on the same tab.
                app.project.openSequence(seq.sequenceID);
            } catch (e2) {}
            if (mcsSameSequence(app.project.activeSequence, seqId)) {
                MCS_DF = mcsDetectDropFrame(seq);
                return true;
            }
            try {
                if (typeof $ !== "undefined" && $.sleep) { $.sleep(40); }
            } catch (e3) {}
        }
    } catch (e) {}
    return false;
}

function mcsQeMatchesSequence(qeSeq, seq) {
    if (!qeSeq || !seq) { return false; }
    try {
        if (qeSeq.sequenceID !== undefined && qeSeq.sequenceID !== null) {
            return ("" + qeSeq.sequenceID) === mcsSequenceId(seq);
        }
    } catch (e1) {}
    try {
        if (qeSeq.name !== undefined && seq.name !== undefined) {
            return ("" + qeSeq.name) === ("" + seq.name);
        }
    } catch (e2) {}
    // Older QE builds expose neither identity field. The DOM identity check in
    // mcsApplyStep still protects the target; accept the opaque QE object.
    return true;
}

function mcsVerifyAppliedVisibility(seq, tracks) {
    var checked = 0, mismatches = 0, first = "";
    if (!seq || !tracks) {
        return { ok: false, checked: 0, mismatches: 1, first: "original sequence unavailable" };
    }
    for (var t = 0; t < tracks.length; t++) {
        var trk = tracks[t], dom = seq.videoTracks[trk.ti - 1];
        if (!dom || !dom.clips) {
            return { ok: false, checked: checked, mismatches: mismatches + 1, first: "V" + trk.ti + " unavailable" };
        }
        var spans = trk.spans || [], si = 0;
        for (var ci = 0; ci < dom.clips.numItems; ci++) {
            var item = dom.clips[ci];
            var mid = (item.start.seconds + item.end.seconds) / 2.0;
            while (si < spans.length && !(mid < spans[si].end - 1e-6)) { si++; }
            if (si >= spans.length) { break; }
            var sp = spans[si];
            if (mid < sp.start - 1e-6) { continue; }
            checked++;
            var expectedDisabled = !sp.enabled;
            if (!!item.disabled !== expectedDisabled) {
                mismatches++;
                if (!first) {
                    first = "V" + trk.ti + " near " + secToTimecode(mid, MCS_APPLY ? MCS_APPLY.fps : 24);
                }
            }
        }
    }
    return { ok: checked > 0 && mismatches === 0, checked: checked, mismatches: mismatches, first: first };
}

/* ===========================================================================
 *  RESUMABLE / BATCHED enable_disable APPLY (long-podcast friendly)
 *  ---------------------------------------------------------------------------
 *  The monolithic applyEnableDisable() above is kept intact as a FALLBACK. The
 *  Begin/Step/Finish trio below produces the IDENTICAL per-clip `.disabled` result
 *  and uses the SAME per-track razor primitive (one qeTrack.razor per track at that
 *  track's own flip points) — but resumably, yielding control to the panel between
 *  batches so Premiere stays responsive and a live progress bar can be drawn.
 *
 *  (v0.54 briefly used a sequence-level all-tracks razor here. That cut EVERY
 *  unlocked track at the UNION of all flip points — ~50% more splits than needed —
 *  and required locking the mic/audio tracks during the sweep. v0.56 reverts to
 *  per-track razoring: fewer splits, and qeTrack.razor can't touch audio at all,
 *  so the audio-lock dance is gone.) Two phases:
 *
 *    PHASE 1 (CUT CURRENT TRACK): razor one camera track only at its own
 *      visibility-flip points, newest-to-oldest. qeTrack.razor cuts only its own
 *      track, so the user's mics/audio are never touched and never need locking.
 *
 *    PHASE 2 (ASSIGN CURRENT TRACK): immediately assign visibility for that same
 *      track before moving to the next camera track, using the proven midpoint
 *      scan. (v1.03 removed the "indexed hidden-only" fast path — it planned
 *      against per-track flip points but phase 1 razors at the union, so its
 *      layout check nearly always failed into this scan anyway.)
 *
 *  Coarse progress: phase 1 = "Cutting…" (work unit = one razor); phase
 *  2 = "Assigning…" (work unit = one clip visited). doneUnits advances
 *  monotonically toward totalUnits, so the caller's loop always terminates.
 * ======================================================================== */

var MCS_APPLY = null;

function mcsApplyBegin(cutsPath, modeOverride, doBackup) {
    try {
        // v1.05: enable QE FIRST. mcsBackupSequence/mcsActivate below set the
        // drop-frame flag via mcsDetectDropFrame, whose fallback probes the QE
        // timecode string — with QE not yet enabled that probe silently failed
        // on the first apply of a session, so a 29.97 NDF sequence whose ruler
        // shows Frames could be stamped drop-frame (the v0.99 drift class).
        app.enableQE();
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, done: true, message: "No active sequence." }); }
        var cuts = readCuts(cutsPath);
        var sgA = mcsSeqGuard(seq, cuts);
        if (sgA) { return jstr({ ok: false, done: true, message: sgA }); }
        var errors = validate(seq, cuts);
        if (errors.length) { return jstr({ ok: false, done: true, message: errors.join("\n") }); }
        // v1.03: refuse a plan whose frame rate doesn't match the live sequence.
        // A mismatched fps makes every razor timecode denote a different real
        // time (cuts drift down the whole timeline).
        var liveFps = 0;
        try {
            var frG = seq.getSettings().videoFrameRate;
            var tkG = parseFloat(frG.ticks);
            liveFps = (tkG > 0) ? (TICKS_PER_SECOND / tkG) : 0;
            if (liveFps > 0 && cuts.fps > 0 && Math.abs(liveFps - cuts.fps) > 0.05) {
                return jstr({ ok: false, done: true, message: "This edit plan was analyzed at " + (Math.round(cuts.fps * 100) / 100) + " fps but the open sequence runs at " + (Math.round(liveFps * 100) / 100) + " fps, so the cuts would not land where planned. Re-read tracks and run Analyze again on this sequence. Nothing was changed." });
            }
        } catch (eFpsG) {}
        // v1.05: never fall back to a guessed 24 fps — a wrong rate makes every
        // razor timecode denote a different real time (cuts drift down the whole
        // timeline). Prefer the plan's fps, else the live sequence's; refuse if
        // neither is known. (Checked here, BEFORE any backup is made.)
        var fps = (cuts.fps > 0) ? cuts.fps : liveFps;
        if (!(fps > 0)) {
            return jstr({ ok: false, done: true, message: "OnMic couldn't determine this sequence's frame rate (the edit plan doesn't carry one and Premiere didn't report one), so the cuts can't be placed reliably. Re-read tracks and run Analyze again on this sequence. Nothing was changed." });
        }
        var lkc = lockedCameraTracks(seq, cuts);
        if (lkc.length) {
            return jstr({ ok: false, done: true, message: "Premiere says these camera track(s) are locked: " + lkc.join(", ") + ". Unlock them, then click Re-read tracks and try again. Nothing was changed." });
        }
        var rep = buildMapping(seq, cuts, modeOverride);

        if (rep.mode === "flatten") {
            // v1.03: the destructive Flatten output mode was removed (it had been
            // unreachable from the panel since the output-mode picker was fixed to
            // non-destructive switching). Refuse loudly rather than guessing.
            return jstr({ ok: false, done: true, message: "Flatten output was removed in v1.03 — OnMic applies non-destructive camera switching only. Nothing was changed.", mode: rep.mode, segments: cuts.segments.length });
        }

        // enable_disable: preflight, then build the resumable plan.
        var pf = preflightEnableDisable(seq, cuts);
        if (pf.usable < 1 || pf.emptySegments > 0 || pf.missingChosen > 0) {
            return jstr({ ok: false, done: true, message: "OnMic could not match these cuts to the live camera clips, so nothing was changed. Re-read tracks, make sure the same sequence is open, and check that every camera track has media across the edit.", mode: rep.mode, changed: 0, segments: cuts.segments.length });
        }
        // Even, professional-style cutting cuts every angle at every edit point, which
        // needs each camera track to be gapless across the edit. Refuse (change
        // nothing) if any track has a hole and tell the user where to fix it.
        var gaps = cameraTrackGaps(seq, cuts);
        if (gaps.length) {
            return jstr({ ok: false, done: true, message: "OnMic cuts every camera angle at every edit point so you can freely switch angles afterward, which needs each camera track to be one continuous piece of footage across the edit. These have a gap: " + gaps.join("; ") + ". Close the gap (make each angle gapless across the edit), Re-read tracks, and try again. Nothing was changed.", mode: rep.mode, changed: 0, segments: cuts.segments.length });
        }
        var backupName = "";
        if (doBackup) {
            backupName = mcsBackupSequence(seq);
            if (!backupName) {
                return jstr({ ok: false, done: true, message: "Couldn't create a backup copy of the sequence, so nothing was changed. Turn off 'Back up timeline first' to apply without one." });
            }
        }
        if (!mcsActivate(seq, 5)) {
            return jstr({ ok: false, done: true, message: "Premiere would not focus the original sequence" + (backupName ? " after OnMic created the backup" : "") + ", so nothing was cut. Click the original sequence tab, Re-read tracks, and try again." });
        }
        // (fps was resolved above, before the backup; QE was enabled at the top.)

        // Precompute, per camera track, the enabled spans (reusing trackSpans —
        // same source of truth as applyEnableDisable) for the phase-2 assignment,
        // AND the razor ops. Phase 1 razors EVERY camera track at the UNION of all
        // cut points (distinctCutPoints) so every angle has a clip at every edit
        // and can be freely toggled afterward (even, professional-style cutting). We
        // still use per-track qeTrack.razor (never a sequence razor), so mic/audio
        // tracks are never touched. Gaps were already rejected above, and
        // cutPointsInsideTrack keeps each razor strictly inside a real clip, so no
        // razor can fail.
        var camTracks = uniqueCameraTracks(cuts);
        var cutPts = distinctCutPoints(cuts);
        var tracks = [], totalAssignUnits = 0, ti, ts, dom, clipCount;
        var razorOps = [];      // [{ti, sec}] — each track's own flip points
        for (var t = 0; t < camTracks.length; t++) {
            ti = camTracks[t];
            ts = trackSpans(cuts.segments, ti);
            dom = seq.videoTracks[ti - 1];
            clipCount = (dom && dom.clips) ? dom.clips.numItems : 0;
            // v1.03: the "indexed_hidden" fast-assign path was removed. It planned
            // pieces against the track's OWN flip points, but phase 1 razors at the
            // UNION of all tracks' points, so its layout check nearly always failed
            // and it fell back to this scan anyway — while its batch-select
            // machinery added risk surface. The midpoint scan below is the proven
            // path every real apply used.
            totalAssignUnits += clipCount;
            // NOTE: phase 2 re-reads clip count after razoring (razor adds clips);
            // clipCount here is only used to seed the progress total. We refresh it
            // when phase 2 starts on the track (see mcsApplyStep).
            tracks.push({
                ti: ti,
                spans: ts.spans,
                clipsClaimed: false,    // phase-2: have we read post-razor clip count yet?
                ci: 0,                  // phase-2 clip cursor
                si: 0                   // phase-2 span cursor
            });
            var trackCutPts = cutPointsInsideTrack(dom, cutPts);
            for (var bi = 0; bi < trackCutPts.length; bi++) {
                razorOps.push({ ti: ti, sec: trackCutPts[bi] });
            }
        }
        // Cut one camera track at a time, newest-to-oldest within that track.
        // Reverse-time order keeps each track's later cuts from paying for all the
        // splits we just created, and grouping by track avoids constantly hopping
        // QE between V1/V2/V3/V4 on long episodes.
        razorOps.sort(function (a, b) {
            if (a.ti !== b.ti) { return a.ti - b.ti; }
            return b.sec - a.sec;
        });

        var addEditCommandId = 0, addEditAllTracksCommandId = 0;
        try { addEditCommandId = app.findMenuCommandId("Add Edit") || 0; } catch (eAE1) {}
        try { addEditAllTracksCommandId = app.findMenuCommandId("Add Edit to All Tracks") || 0; } catch (eAE2) {}
        var editCommandProbe = mcsProbeEditCommands();

        MCS_APPLY = {
            seqId: mcsSequenceId(seq),
            seqRef: seq,
            fps: fps,
            mode: "enable_disable",
            backupName: backupName,
            doBackup: !!doBackup,
            segments: cuts.segments.length,
            tracks: tracks,
            razorOps: razorOps,         // phase-1: per-track flip points {ti, sec}
            razorDone: 0,               // phase-1 cursor within razorOps
            phase: 1,                   // 1 = per-track razor (CUT), 2 = clip assignments
            trackIdx: 0,                // which track phase 2 is working on
            changed: 0,
            razorErrors: 0,
            doneUnits: 0,
            totalUnits: razorOps.length + totalAssignUnits,
            totalRazor: razorOps.length,
            totalAssignUnits: totalAssignUnits,
            startedAtMs: (new Date()).getTime(),
            stepCount: 0,
            razorMs: 0,
            assignMs: 0,
            maxStepMs: 0
        };
        return jstr({ ok: true, done: false, mode: "enable_disable", total: MCS_APPLY.totalUnits, totalRazor: MCS_APPLY.totalRazor, totalAssignUnits: MCS_APPLY.totalAssignUnits, cameraTracks: camTracks.length, addEditCommandId: addEditCommandId, addEditAllTracksCommandId: addEditAllTracksCommandId, editCommandProbe: editCommandProbe, backupName: backupName, segments: cuts.segments.length });
    } catch (e) {
        MCS_APPLY = null;
        return jstr({ ok: false, done: true, message: e.toString() + "  (Edit > Undo to revert.)" });
    }
}

function mcsApplyStepResult(st, finished, stepStartMs, stepRazorMs, stepAssignMs) {
    var stepMs = (new Date()).getTime() - stepStartMs;
    st.stepCount++;
    st.razorMs += stepRazorMs;
    st.assignMs += stepAssignMs;
    if (stepMs > st.maxStepMs) { st.maxStepMs = stepMs; }
    return jstr({
        ok: true,
        done: finished,
        doneUnits: st.doneUnits,
        totalUnits: st.totalUnits,
        changed: st.changed,
        razorErrors: st.razorErrors,
        phase: st.phase,
        stepMs: stepMs,
        stepRazorMs: stepRazorMs,
        stepAssignMs: stepAssignMs,
        stepCount: st.stepCount,
        razorDone: st.razorDone,
        totalRazor: st.totalRazor
    });
}

function mcsApplyStep(maxOps) {
    try {
        var st = MCS_APPLY;
        if (!st) { return jstr({ ok: true, done: true, doneUnits: 0, totalUnits: 0, changed: 0, razorErrors: 0 }); }
        if (st.mode !== "enable_disable") { return jstr({ ok: true, done: true, doneUnits: st.doneUnits, totalUnits: st.totalUnits, changed: st.changed, razorErrors: st.razorErrors }); }

        var ops = (typeof maxOps === "number" && maxOps > 0) ? Math.floor(maxOps) : 1;
        // v1.03: the batched apply yields to the panel between steps — sometimes
        // for minutes on a long episode. If the user clicked a DIFFERENT sequence
        // tab meanwhile, app.project.activeSequence is no longer the sequence this
        // apply began on, and razoring would corrupt that other timeline at
        // coordinates computed for this one. Re-verify and re-activate every step.
        var seq = app.project.activeSequence;
        if (!mcsSameSequence(seq, st.seqId)) {
            var orig = st.seqRef || mcsFindSequenceById(st.seqId);
            if (orig) { mcsActivate(orig, 5); seq = app.project.activeSequence; }
        }
        if (!mcsSameSequence(seq, st.seqId)) {
            MCS_APPLY = null;
            return jstr({ ok: false, done: true, message: "The apply stopped because a different sequence became active in Premiere and OnMic could not switch back. The original sequence was only partly cut — restore the OnMic backup from the Project panel, re-open it, and apply again." });
        }
        var qeSeq = qe.project.getActiveSequence();
        if (!mcsQeMatchesSequence(qeSeq, seq)) {
            if (mcsActivate(st.seqRef || seq, 5)) {
                seq = app.project.activeSequence;
                qeSeq = qe.project.getActiveSequence();
            }
        }
        if (!mcsSameSequence(seq, st.seqId) || !mcsQeMatchesSequence(qeSeq, seq)) {
            MCS_APPLY = null;
            return jstr({ ok: false, done: true, message: "Premiere's timeline and cutting engine were focused on different sequences, so OnMic stopped rather than risk cutting the wrong one. Restore the OnMic backup from the Project panel, then try again." });
        }
        var fps = st.fps;
        var done = 0;
        var stepStartMs = (new Date()).getTime();
        var stepRazorMs = 0;
        var stepAssignMs = 0;

        // Track-by-track apply: cut one camera track, assign that same track, then
        // move on. This keeps the normal non-destructive model but avoids doing
        // all visibility work only after every camera track has been split.
        while (done < ops && st.trackIdx < st.tracks.length) {
            var trk2 = st.tracks[st.trackIdx];

            // ---- PHASE 1 (CUT CURRENT TRACK) ----
            if (st.phase === 1) {
                var razorStartMs = (new Date()).getTime();
                var qt = null;
                try { qt = qeSeq.getVideoTrackAt(trk2.ti - 1); } catch (eGT) { qt = null; }
                while (st.razorDone < st.razorOps.length && st.razorOps[st.razorDone].ti === trk2.ti && done < ops) {
                    var op = st.razorOps[st.razorDone];
                    var razored = false;
                    if (qt) { try { qt.razor(secToTimecode(op.sec, fps)); razored = true; } catch (eR1) {} }
                    if (!razored) {
                        // v1.03: stop at the FIRST refused razor instead of cutting and
                        // re-assigning hundreds more clips on a timeline already known to
                        // be mis-cut, then reporting failure only at Finish. The backup
                        // (if enabled) already exists at this point.
                        st.razorErrors++;
                        stepRazorMs += ((new Date()).getTime() - razorStartMs);
                        var failTc = secToTimecode(op.sec, fps);
                        MCS_APPLY = null;
                        return jstr({ ok: false, done: true, message: "Premiere refused a razor command on V" + op.ti + " at " + failTc + ", so the apply stopped immediately. A backup was created if backup was enabled; restore it (Project panel) or Edit > Undo, re-read tracks, and try again.", changed: st.changed, razorErrors: st.razorErrors });
                    }
                    st.razorDone++;
                    st.doneUnits++;
                    done++;
                }
                stepRazorMs += ((new Date()).getTime() - razorStartMs);

                if (st.razorDone < st.razorOps.length && st.razorOps[st.razorDone].ti === trk2.ti) {
                    return mcsApplyStepResult(st, false, stepStartMs, stepRazorMs, stepAssignMs);
                }
                st.phase = 2;
            }

            if (done >= ops) { break; }

            // ---- PHASE 2 (ASSIGN CURRENT TRACK) ----
            var assignStartMs = (new Date()).getTime();
            var dom2 = seq.videoTracks[trk2.ti - 1];
            var clips2 = dom2.clips;
            if (!trk2.clipsClaimed) { trk2.clipsClaimed = true; }
            var nClips = clips2.numItems;
            var spans = trk2.spans;

            while (done < ops) {
                if (trk2.ci >= nClips || trk2.si >= spans.length) {
                    st.trackIdx++;
                    st.phase = 1;
                    break;
                }
                var item = clips2[trk2.ci];
                var mid = (item.start.seconds + item.end.seconds) / 2.0;
                while (trk2.si < spans.length && !(mid < spans[trk2.si].end - 1e-6)) { trk2.si++; }
                if (trk2.si >= spans.length) {
                    st.trackIdx++;
                    st.phase = 1;
                    break;
                }
                var sp = spans[trk2.si];
                if (mid >= sp.start - 1e-6) {
                    var dis = !sp.enabled;
                    if (item.disabled !== dis) { item.disabled = dis; st.changed++; }
                }
                trk2.ci++;
                st.doneUnits++;
                done++;
            }
            stepAssignMs += ((new Date()).getTime() - assignStartMs);
        }

        // Clamp doneUnits to totalUnits so the caller's frac never exceeds 1 and the
        // loop always terminates even if live clip counts differ from the seed total.
        var finished = (st.trackIdx >= st.tracks.length);
        if (finished && st.doneUnits < st.totalUnits) { st.doneUnits = st.totalUnits; }
        if (st.doneUnits > st.totalUnits) { st.totalUnits = st.doneUnits; }

        return mcsApplyStepResult(st, finished, stepStartMs, stepRazorMs, stepAssignMs);
    } catch (e) {
        MCS_APPLY = null;   // v1.03: don't leave stale state for a later Finish to misreport
        // v1.05: don't advise Edit > Undo — after hundreds of razor/assign ops a
        // single Undo cannot revert the apply. Point at the backup copy instead.
        return jstr({ ok: false, done: true, message: e.toString() + "  The apply stopped part-way, so this timeline is only partly cut — do not trust it as-is. Restore the OnMic backup from the Project panel (if backup was enabled; a single Edit > Undo will NOT revert hundreds of edits), re-read tracks, and try again." });
    }
}

function mcsApplyFinish() {
    try {
        var st = MCS_APPLY;
        if (!st) { return jstr({ ok: false, message: "Nothing to finish — apply was not started." }); }
        var changed = st.changed, razorErrors = st.razorErrors, doBackup = st.doBackup, backupName = st.backupName, segments = st.segments;
        var razorDone = st.razorDone, totalRazor = st.totalRazor;
        var original = st.seqRef || mcsFindSequenceById(st.seqId);
        var activated = original && mcsActivate(original, 5);
        var visibility = activated ? mcsVerifyAppliedVisibility(original, st.tracks) :
            { ok: false, checked: 0, mismatches: 1, first: "original sequence unavailable" };
        var timing = {
            totalMs: (new Date()).getTime() - st.startedAtMs,
            razorMs: st.razorMs,
            assignMs: st.assignMs,
            steps: st.stepCount,
            maxStepMs: st.maxStepMs,
            totalRazor: st.totalRazor
        };
        MCS_APPLY = null;

        if (razorErrors > 0) {
            return jstr({ ok: false, message: "Premiere refused " + razorErrors + " razor command" + (razorErrors === 1 ? "" : "s") + ". A backup was created if backup was enabled; restore it before trusting this result, then re-read tracks and try again.", mode: "enable_disable", changed: changed, razorErrors: razorErrors, segments: segments, timing: timing });
        }
        // v1.03: every planned razor op must have been consumed. A shortfall means a
        // camera track's cuts were skipped (e.g. cursor/ordering bug) — the timeline
        // would look plausible but whole stretches would show the wrong camera.
        if (razorDone < totalRazor) {
            return jstr({ ok: false, message: "The apply finished but only placed " + razorDone + " of " + totalRazor + " planned cuts, so the result is not trustworthy. Restore the OnMic backup from the Project panel, re-read tracks, and try again.", mode: "enable_disable", changed: changed, razorErrors: razorErrors, segments: segments, timing: timing });
        }
        if (!visibility.ok) {
            var visibilityRecovery = doBackup
                ? "Restore the OnMic backup from the Project panel"
                : "No OnMic backup was made, so revert to the last saved project version";
            return jstr({ ok: false, message: "OnMic finished cutting but the final camera visibility check found " + visibility.mismatches + " incorrect track state" + (visibility.mismatches === 1 ? "" : "s") + (visibility.first ? " (first at " + visibility.first + ")" : "") + ". " + visibilityRecovery + "; do not export this partial result.", mode: "enable_disable", changed: changed, razorErrors: razorErrors, segments: segments, timing: timing });
        }
        if (changed === 0 && segments > 1) {
            // v1.03: distinguish a genuine no-op failure from an idempotent RE-RUN of
            // the same plan. On a re-run every razor point already exists (0 new
            // razor ops) and every clip is already in its planned state — success.
            if (totalRazor === 0) {
                var msg0 = "These cuts were already applied — the timeline is already in the planned state. Nothing needed to change.";
                return jstr({ ok: true, alreadyApplied: true, message: msg0, mode: "enable_disable", changed: 0, razorErrors: 0, segments: segments, timing: timing });
            }
            return jstr({ ok: false, message: "Premiere accepted the command but reported 0 changed clips. Nothing trustworthy was applied. Re-read tracks, make sure the camera tracks are unlocked, then try again.", mode: "enable_disable", changed: changed, razorErrors: razorErrors, segments: segments, timing: timing });
        }
        var msg = "Camera switches applied (non-destructive): " + changed + " track segments set.";
        if (doBackup) {
            msg += (backupName === "made")
                ? "  A backup copy of your sequence was saved in the Project panel."
                : "  Backup saved in the Project panel: \"" + backupName + "\".";
        }
        return jstr({ ok: true, message: msg, mode: "enable_disable", changed: changed, razorErrors: razorErrors, segments: segments, timing: timing });
    } catch (e) {
        MCS_APPLY = null;
        return jstr({ ok: false, message: e.toString() });
    }
}

function mcsApplyPrimitiveInfo() {
    try {
        var out = { ok: true, qeTrackRazor: true, addEditCommandId: 0, addEditAllTracksCommandId: 0, usingAddEdit: false };
        try { out.addEditCommandId = app.findMenuCommandId("Add Edit") || 0; } catch (e1) {}
        try { out.addEditAllTracksCommandId = app.findMenuCommandId("Add Edit to All Tracks") || 0; } catch (e2) {}
        out.note = "Add Edit is probed only. OnMic still uses per-video-track QE razor so audio tracks are never touched by Apply.";
        return jstr(out);
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

// ---- Dead-air trimming (BETA): ripple-delete long pauses ------------------
// Non-destructive PREVIEW: drop a sequence marker at every camera switch so the
// user can review the proposed cuts on the timeline WITHOUT changing anything.
function mcsMarkers(cutsPath) {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "Open and select a sequence first." }); }
        var cuts = readCuts(cutsPath);
        if (!cuts.segments || !cuts.segments.length) { return jstr({ ok: false, message: "No cuts to preview — run Analyze first." }); }
        var sgM = mcsSeqGuard(seq, cuts);
        if (sgM) { return jstr({ ok: false, message: sgM }); }
        var nameByTrack = {};
        if (cuts.cameras) { for (var c = 0; c < cuts.cameras.length; c++) { var cam = cuts.cameras[c]; var tk = (cam.track != null) ? cam.track : cam.camera_track; if (tk != null) nameByTrack[tk] = cam.name || cam.label || ("Cam " + tk); } }
        var mk = seq.markers, n = 0, prevTrack = null;
        // v1.05: re-previewing used to STACK a fresh duplicate marker set on top
        // of the old one every time. Delete previous OnMic preview markers first.
        // Walk with getFirstMarker/getNextMarker and COLLECT, then delete — never
        // delete while walking (deleteMarker invalidates the iteration).
        var removedOld = 0;
        try {
            var olds = [], w = (typeof mk.getFirstMarker === "function") ? mk.getFirstMarker() : null;
            while (w) {
                try { if (String(w.name).indexOf("OnMic → ") === 0) { olds.push(w); } } catch (eNm) {}
                w = mk.getNextMarker(w);
            }
            for (var d = 0; d < olds.length; d++) {
                try { mk.deleteMarker(olds[d]); removedOld++; } catch (eDel) {}
            }
        } catch (eOld) {}
        for (var i = 0; i < cuts.segments.length; i++) {
            var seg = cuts.segments[i];
            if (seg.camera_track === prevTrack) continue;   // only mark actual switches
            prevTrack = seg.camera_track;
            var label = nameByTrack[seg.camera_track] || ("Cam " + seg.camera_track);
            try {
                var m = mk.createMarker(seg.start_s);
                try { m.name = "OnMic → " + label; } catch (e1) {}
                try { m.comments = "Switch to " + label + " (V" + seg.camera_track + ")"; } catch (e2) {}
                n++;
            } catch (e3) {}
        }
        return jstr({ ok: true, message: "Added " + n + " preview marker" + (n === 1 ? "" : "s") + " (one per camera switch)" + (removedOld ? ", replacing " + removedOld + " from the previous preview" : "") + ". Review them on the timeline — nothing was cut. Delete the sequence markers when you're done." });
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

function qeTypeOf(qit){ try { return qit.type != null ? String(qit.type) : ""; } catch (e) { return ""; } }
function qeStartSec(qit){
    try { var s = qit.start; if (s == null) return null; if (typeof s.secs === "number") return s.secs; if (typeof s === "number") return s; } catch (e) {}
    return null;
}
// Find the QE track item that matches the DOM clip covering `sec` (matched by
// start time, so leading empty/filler QE items don't throw off the mapping).
function qeItemForDomClip(domTrack, qeTrack, sec, domClip){
    // v1.03 perf: callers that already found the covering DOM clip pass it in,
    // halving the DOM scans on long dead-air-trim runs. Behavior is identical.
    var target = null, i;
    if (domClip) { try { target = domClip.start.seconds; } catch (eDc) { target = null; } }
    if (target == null) {
        var clips = domTrack.clips;
        for (i = 0; i < clips.numItems; i++){ var it = clips[i]; if (sec >= it.start.seconds - 1e-6 && sec < it.end.seconds - 1e-6){ target = it.start.seconds; break; } }
    }
    if (target == null) return null;
    return qeItemStartingAt(qeTrack, target);
}
// The QE half of qeItemForDomClip: find the QE item whose start matches `target`
// (<½ frame — ambiguous matches resolve to "skip this pause" rather than mis-bind).
function qeItemStartingAt(qeTrack, target){
    for (var j = 0; j < qeTrack.numItems; j++){ var qit = qeTrack.getItemAt(j); if (!qit || qeTypeOf(qit) === "Empty") continue; var qs = qeStartSec(qit); if (qs != null && Math.abs(qs - target) < 0.012) return qit; }
    return null;
}
function trackHasContentAfter(domTrack, t){
    var clips = domTrack.clips; for (var i = 0; i < clips.numItems; i++){ if (clips[i].start.seconds >= t - 0.01) return true; } return false;
}

/* v1.05 perf: dead-air trim used to rescan EVERY clip on EVERY track for EVERY
 * pause (clipCoveringTime / trackHasContentAfter / qeItemForDomClip's DOM pass
 * are all linear), which goes quadratic on long episodes. Instead, snapshot each
 * track's DOM clip ranges ONCE, sorted by start, and binary-search them. Trim
 * processes pauses last-first and every edit (razor + ripple) happens at or
 * after the pause being processed, so ranges at earlier times stay valid for
 * these boolean coverage / has-content-after checks; the has-content-after
 * answer can only flip toward "no content" (which makes trim SKIP, never trim).
 * Only pure numbers are cached — no DOM/QE object refs — and the post-razor
 * verification inside mcsTrimPauses still re-reads the live DOM every time. */
function mcsClipRanges(domTrack){
    var out = [], clips = domTrack ? domTrack.clips : null, n = clips ? clips.numItems : 0;
    for (var i = 0; i < n; i++){
        var s = null, e = null;
        try { s = clips[i].start.seconds; e = clips[i].end.seconds; } catch (eRg) { s = null; }
        if (s != null && e != null) { out.push({ s: s, e: e }); }
    }
    out.sort(function (x, y){ return x.s - y.s; });
    return out;
}
// Same covering test as clipCoveringTime (sec >= start-1e-6 && sec < end-1e-6),
// but O(log n): returns the covering {s,e} range or null.
function mcsRangeCovering(ranges, sec){
    var lo = 0, hi = ranges.length - 1, ans = -1;
    while (lo <= hi) {
        var m = (lo + hi) >> 1;
        if (sec >= ranges[m].s - 1e-6) { ans = m; lo = m + 1; } else { hi = m - 1; }
    }
    if (ans >= 0 && sec < ranges[ans].e - 1e-6) { return ranges[ans]; }
    return null;
}
// Same test as trackHasContentAfter (any clip start >= t-0.01): ranges are
// sorted by start, so only the last one needs checking.
function mcsRangesHaveContentAfter(ranges, t){
    return ranges.length > 0 && ranges[ranges.length - 1].s >= t - 0.01;
}

function mcsTrackTailEnd(domTrack) {
    var clips = domTrack ? domTrack.clips : null, n = clips ? clips.numItems : 0, tail = null;
    for (var i = 0; i < n; i++) {
        try {
            var e = Number(clips[i].end.seconds);
            if (isFinite(e) && (tail == null || e > tail)) tail = e;
        } catch (err) {}
    }
    return tail;
}

// Verify a ripple deletion moved every track with downstream content by exactly
// the removed duration. Premiere APIs can fail silently without throwing; this
// catches both a track that did not move and tracks that moved by different amounts.
function mcsRippleShiftOk(before, after, expectedShift, tolerance) {
    if (!before || !after || before.length !== after.length) return false;
    var checked = 0;
    for (var i = 0; i < before.length; i++) {
        if (before[i] == null) continue;
        checked++;
        if (after[i] == null || Math.abs((before[i] - after[i]) - expectedShift) > tolerance) return false;
    }
    return checked > 0;
}

// Build explicit, step-by-step restore instructions naming the exact backup
// sequence, so a user faced with a half-applied ripple knows precisely how to
// get back to a clean timeline. (A single Edit > Undo won't fully revert a
// ripple, which is why we always restore the whole backup copy instead.)
function mcsRestoreSteps(backupName) {
    var named = (backupName && backupName !== "made");
    var which = named ? ("the sequence named \"" + backupName + "\"")
                      : ("the OnMic backup copy (its name ends with \"OnMic backup\" and today's date)");
    return " HOW TO RESTORE: 1) In Premiere's Project panel, find " + which + ". " +
           "2) Double-click it to open that backup sequence. " +
           "3) Use it as your timeline (or right-click it > Duplicate to keep working from a clean copy). " +
           "Do NOT keep editing the half-trimmed sequence — a single Undo will not fully revert a ripple delete.";
}

// Ripple-delete pauses longer than minSec, keeping keepSec of natural breath.
// SAFE BY DESIGN: per pause it's all-or-nothing — if any track that has
// downstream content isn't continuous across the cut, that pause is skipped
// (never a partial removal => never a desync). Processes pauses last-first.
function mcsTrimPauses(cutsPath, minSec, keepSec, doBackup) {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }
        var cuts = readCuts(cutsPath);
        var sgT = mcsSeqGuard(seq, cuts);
        if (sgT) { return jstr({ ok: false, message: sgT }); }
        // v1.05: same fps handling as Apply — a wrong rate makes every razor
        // timecode denote a different real time. Refuse a plan whose fps doesn't
        // match the live sequence, never guess 24, and refuse if no rate is
        // known at all.
        var liveFps = 0;
        try {
            var frT = seq.getSettings().videoFrameRate;
            var tkT = parseFloat(frT.ticks);
            liveFps = (tkT > 0) ? (TICKS_PER_SECOND / tkT) : 0;
        } catch (eFpsT) {}
        if (liveFps > 0 && cuts.fps > 0 && Math.abs(liveFps - cuts.fps) > 0.05) {
            return jstr({ ok: false, message: "This edit plan was analyzed at " + (Math.round(cuts.fps * 100) / 100) + " fps but the open sequence runs at " + (Math.round(liveFps * 100) / 100) + " fps, so the trims would not land where planned. Re-read tracks and run Analyze again on this sequence. Nothing was changed." });
        }
        var fps = (cuts.fps > 0) ? cuts.fps : liveFps;
        if (!(fps > 0)) {
            return jstr({ ok: false, message: "OnMic couldn't determine this sequence's frame rate (the edit plan doesn't carry one and Premiere didn't report one), so the trims can't be placed reliably. Re-read tracks and run Analyze again on this sequence. Nothing was changed." });
        }
        var sils = cuts.silences || [];
        minSec = (minSec != null) ? minSec : 1.5;
        keepSec = (keepSec != null) ? keepSec : 0.4;

        var trims = [], i, vi, ai;
        for (i = 0; i < sils.length; i++){
            var L = sils[i].end_s - sils[i].start_s;
            if (L > minSec){ var a = sils[i].start_s + keepSec / 2, b = sils[i].end_s - keepSec / 2;
                // Don't trim a pause that hosts a camera-switch cut: snap-to-pause may
                // have landed a cut inside this silence, and trimming it would delete
                // the switch point (cut would jump onto the next word). Skip it.
                var hostsCut = false;
                if (cuts.segments){ for (var sc = 0; sc < cuts.segments.length; sc++){ var cstart = cuts.segments[sc].start_s; if (cstart > a && cstart < b){ hostsCut = true; break; } } }
                if (b - a > 0.12 && !hostsCut) trims.push([a, b]); }
        }
        if (!trims.length) return jstr({ ok: true, message: "No pauses longer than " + minSec + "s found to trim." });

        app.enableQE();
        var qeSeq = qe.project.getActiveSequence();
        // capability probe: can we read QE clip timing in this build?
        var probeT = qeSeq.getVideoTrackAt(0), probeItem = null;
        if (probeT){ for (var pi = 0; pi < probeT.numItems; pi++){ var p0 = probeT.getItemAt(pi); if (p0 && qeTypeOf(p0) !== "Empty"){ probeItem = p0; break; } } }
        if (!probeItem || qeStartSec(probeItem) == null){
            return jstr({ ok: false, message: "Dead-air trim couldn't read clip timing in this Premiere build (a Premiere scripting quirk). Nothing was changed. Copy diagnostics and contact support." });
        }

        var nV = seq.videoTracks.numTracks, nA = seq.audioTracks.numTracks;
        // A locked track won't ripple with the others → permanent desync. Refuse
        // the whole trim rather than risk it.
        var lk = "";
        for (vi = 0; vi < nV; vi++){ try { if (mcsTrackIsLocked(seq.videoTracks[vi])) lk += " V" + (vi + 1); } catch (e) {} }
        for (ai = 0; ai < nA; ai++){ try { if (mcsTrackIsLocked(seq.audioTracks[ai])) lk += " A" + (ai + 1); } catch (e) {} }
        if (lk){ return jstr({ ok: false, message: "Unlock all tracks before trimming — locked track(s)" + lk + " would desync the others. Nothing was changed." }); }

        // v1.05 perf: snapshot every track's clip ranges ONCE (pure numbers, no
        // DOM refs) and binary-search them for the per-pause coverage checks
        // below, instead of rescanning all clips per pause × per track. See
        // mcsClipRanges for why the cache stays valid across the last-first loop.
        var vRanges = [], aRanges = [];
        for (vi = 0; vi < nV; vi++){ vRanges.push(mcsClipRanges(seq.videoTracks[vi])); }
        for (ai = 0; ai < nA; ai++){ aRanges.push(mcsClipRanges(seq.audioTracks[ai])); }

        // PRE-PASS (timeline still untouched, before any backup): dry-run the same
        // continuity + QE-mappability checks the real loop uses, so we can refuse
        // up front if NONE of the pauses are actually removable safely. This avoids
        // making a backup, razoring, and only then discovering nothing was trimmable.
        var safeCount = 0, contSkip = 0, mapSkip = 0;
        for (var pti = 0; pti < trims.length; pti++){
            var paa = trims[pti][0], pbb = trims[pti][1], pmid = (paa + pbb) / 2;
            var pSafe = true;
            for (vi = 0; vi < nV && pSafe; vi++){ if (mcsRangesHaveContentAfter(vRanges[vi], pbb) && !mcsRangeCovering(vRanges[vi], pmid)) pSafe = false; }
            for (ai = 0; ai < nA && pSafe; ai++){ if (mcsRangesHaveContentAfter(aRanges[ai], pbb) && !mcsRangeCovering(aRanges[ai], pmid)) pSafe = false; }
            if (!pSafe){ contSkip++; continue; }
            var pMiss = false;
            for (vi = 0; vi < nV && !pMiss; vi++){ var prV = mcsRangeCovering(vRanges[vi], pmid); if (prV && !qeItemStartingAt(qeSeq.getVideoTrackAt(vi), prV.s)) pMiss = true; }
            for (ai = 0; ai < nA && !pMiss; ai++){ var prA = mcsRangeCovering(aRanges[ai], pmid); if (prA && !qeItemStartingAt(qeSeq.getAudioTrackAt(ai), prA.s)) pMiss = true; }
            if (pMiss){ mapSkip++; continue; }
            safeCount++;
        }
        if (safeCount < 1){
            var why = contSkip ? " The tracks are not continuous across the pauses (a gap or layered clip would desync after a ripple)." :
                      mapSkip  ? " Premiere couldn't map the live clips at those pauses in this build." : "";
            return jstr({ ok: false, message: "Dead-air trim found " + trims.length + " candidate pause" + (trims.length === 1 ? "" : "s") + ", but none can be removed safely, so nothing was changed and no backup was made." + why + " Make sure every track runs continuously across the timeline, then try again." });
        }

        var backupName = "";
        if (doBackup){ backupName = mcsBackupSequence(seq); if (!backupName) return jstr({ ok: false, message: "Couldn't back up the sequence, so nothing was trimmed. Turn off 'Back up timeline first' only if you have your own copy." }); }
        // v1.03: ALWAYS (re)activate before razoring — this also sets MCS_DF from
        // THIS sequence's timecode format. With backup off, nothing else set it,
        // so razors used a stale flag from a previously-activated sequence (NDF
        // timecodes on a DF timeline drift ~2 frames/min — the v0.99 bug class).
        mcsActivate(seq);
        // v1.05: VERIFY the original sequence really is active again before any
        // razor. clone() makes the BACKUP the active sequence; if mcsActivate
        // failed to switch back, every razor below would land in the backup copy
        // while the report claimed success (mirrors Apply's per-step check).
        var actT = app.project.activeSequence;
        if (!actT || actT.sequenceID !== seq.sequenceID) {
            return jstr({ ok: false, message: "OnMic couldn't switch focus back to the original sequence" + (backupName ? " after creating the backup copy" : "") + ", so nothing was trimmed. Click the original sequence's timeline tab to make it active, then try again." });
        }
        qeSeq = qe.project.getActiveSequence();
        trims.sort(function (x, y){ return y[0] - x[0]; });   // last-first
        var done = 0, skipped = 0, boundsSkipped = 0, warned = false, postRazorProblem = false;

        for (var ti = 0; ti < trims.length; ti++){
            var aa = trims[ti][0], bb = trims[ti][1], mid = (aa + bb) / 2;
            // safety: any track with downstream content must be continuous across
            // the cut. v1.05: uses the pre-built range cache — valid here because
            // pauses run last-first, so every edit so far happened at or after bb
            // and coverage at mid/bb is unchanged (see mcsClipRanges).
            var safe = true;
            for (vi = 0; vi < nV && safe; vi++){ if (mcsRangesHaveContentAfter(vRanges[vi], bb) && !mcsRangeCovering(vRanges[vi], mid)) safe = false; }
            for (ai = 0; ai < nA && safe; ai++){ if (mcsRangesHaveContentAfter(aRanges[ai], bb) && !mcsRangeCovering(aRanges[ai], mid)) safe = false; }
            if (!safe){ skipped++; continue; }

            // Preflight the QE item lookup BEFORE placing razor cuts. If Premiere
            // cannot map a live clip at this pause, skip it while the timeline is
            // still untouched. (QE items are looked up LIVE; only the covering
            // clip's start comes from the cache — that start can't have moved,
            // because all edits so far were at or after this pause.)
            var preMissing = false;
            for (vi = 0; vi < nV && !preMissing; vi++){
                var rvV = mcsRangeCovering(vRanges[vi], mid);
                if (rvV && !qeItemStartingAt(qeSeq.getVideoTrackAt(vi), rvV.s)) preMissing = true;
            }
            for (ai = 0; ai < nA && !preMissing; ai++){
                var rvA = mcsRangeCovering(aRanges[ai], mid);
                if (rvA && !qeItemStartingAt(qeSeq.getAudioTrackAt(ai), rvA.s)) preMissing = true;
            }
            if (preMissing){ skipped++; continue; }

            var tcA = secToTimecode(aa, fps), tcB = secToTimecode(bb, fps);
            for (vi = 0; vi < nV; vi++){ var qv = qeSeq.getVideoTrackAt(vi); try { qv.razor(tcA); } catch (e) {} try { qv.razor(tcB); } catch (e) {} }
            for (ai = 0; ai < nA; ai++){ var qa = qeSeq.getAudioTrackAt(ai); try { qa.razor(tcA); } catch (e) {} try { qa.razor(tcB); } catch (e) {} }

            // v1.03 SAFETY: verify both razors actually LANDED before deleting
            // anything. After a successful double-razor, the clip covering the
            // pause midpoint on every populated track spans exactly [aa, bb]. If
            // it doesn't (a silently refused or mislanded razor — the catches
            // above hide those), the "covering clip" is a larger piece with real
            // content, and ripple-deleting it would remove speech (up to a whole
            // take) while still reporting success. It also catches a track whose
            // own clip inside the pause is SHORTER than [aa, bb] — deleting that
            // would ripple tracks by different amounts and desync them. Skip such
            // pauses instead; the stray razor splits are harmless (nothing moves).
            var btol = Math.max(0.075, 1.6 / fps);
            var items = [], expect = 0, missing = false, boundsBad = false;
            for (vi = 0; vi < nV && !boundsBad; vi++){
                var dv2 = seq.videoTracks[vi], dc = clipCoveringTime(dv2, mid);
                if (dc){
                    expect++;
                    if (Math.abs(dc.start.seconds - aa) > btol || Math.abs(dc.end.seconds - bb) > btol) { boundsBad = true; break; }
                    var it = qeItemForDomClip(dv2, qeSeq.getVideoTrackAt(vi), mid, dc);
                    if (!it) missing = true;
                    items.push(it);
                }
            }
            for (ai = 0; ai < nA && !boundsBad; ai++){
                var da2 = seq.audioTracks[ai], dca = clipCoveringTime(da2, mid);
                if (dca){
                    expect++;
                    if (Math.abs(dca.start.seconds - aa) > btol || Math.abs(dca.end.seconds - bb) > btol) { boundsBad = true; break; }
                    var ita = qeItemForDomClip(da2, qeSeq.getAudioTrackAt(ai), mid, dca);
                    if (!ita) missing = true;
                    items.push(ita);
                }
            }
            if (boundsBad){ skipped++; boundsSkipped++; continue; }
            if (missing){ skipped++; postRazorProblem = true; break; }

            // Snapshot each populated track's live tail immediately before the
            // deletion. A successful ripple must shorten every downstream track
            // by the same [aa,bb] duration.
            var beforeTails = [], afterTails = [];
            for (vi = 0; vi < nV; vi++) {
                var vtail = mcsTrackTailEnd(seq.videoTracks[vi]);
                beforeTails.push(vtail != null && vtail > bb + btol ? vtail : null);
            }
            for (ai = 0; ai < nA; ai++) {
                var atail = mcsTrackTailEnd(seq.audioTracks[ai]);
                beforeTails.push(atail != null && atail > bb + btol ? atail : null);
            }

            var rem = 0;
            for (var r = 0; r < items.length; r++){ if (items[r]){ try { items[r].remove(true, true); rem++; } catch (e) {} } }
            if (rem < expect) { warned = true; postRazorProblem = true; break; }
            for (vi = 0; vi < nV; vi++) afterTails.push(mcsTrackTailEnd(seq.videoTracks[vi]));
            for (ai = 0; ai < nA; ai++) afterTails.push(mcsTrackTailEnd(seq.audioTracks[ai]));
            if (!mcsRippleShiftOk(beforeTails, afterTails, bb - aa, btol)) {
                warned = true; postRazorProblem = true; break;
            }
            if (rem > 0) done++;
        }
        // v1.03: record that this sequence was ripple-edited, so mcsSeqGuard can
        // refuse any cuts.json written before this moment (its timings are stale).
        // v1.05: persist the stamp to disk too, so the guard still holds after a
        // Premiere restart (the in-memory map dies with the ExtendScript session).
        if (done > 0) { try { MCS_EDITED["" + seq.sequenceID] = (new Date()).getTime(); mcsSaveEditedStamps(); } catch (eStamp) {} }

        if (postRazorProblem) {
            // HALF-FAIL: Premiere trimmed some pauses, then refused one. The
            // timeline is now in a partly-trimmed (possibly desynced) state. Be
            // emphatic and give exact restore steps — this is the dangerous case.
            var pmsg = "⚠ Dead-air trim STOPPED part-way: Premiere removed " + done + " pause" + (done === 1 ? "" : "s") +
                       " and then refused another, so this timeline is only partly trimmed and may be out of sync. Do not trust or export it as-is.";
            if (backupName) pmsg += (backupName === "made" ? " A backup copy was saved in the Project panel." : " A backup was saved as \"" + backupName + "\".");
            else pmsg += " (No backup was made — 'Back up timeline first' was off.)";
            if (backupName && MCS_LAST_BACKUP_SEQUENCE) {
                mcsActivate(MCS_LAST_BACKUP_SEQUENCE);
                pmsg += " OnMic switched you to the clean backup automatically. The original sequence was left untouched from this point so it can be inspected, but do not export it.";
            } else if (backupName) pmsg += mcsRestoreSteps(backupName);
            else pmsg += " You have no OnMic backup to restore from; close this sequence WITHOUT saving if you can, or rebuild from your last saved version.";
            return jstr({ ok: false, message: pmsg });
        }
        if (done === 0) {
            var zmsg = skipped ? "Dead-air trim found pauses, but skipped all of them because the tracks were not continuous enough to trim safely. Nothing was removed." : "Dead-air trim did not remove any pauses.";
            if (backupName) zmsg += (backupName === "made" ? " A backup copy was saved in the Project panel." : " Backup saved: \"" + backupName + "\".");
            return jstr({ ok: false, message: zmsg });
        }
        var msg = "Dead-air trim (beta): removed " + done + " pause" + (done === 1 ? "" : "s");
        if (skipped) {
            msg += ", skipped " + skipped;
            msg += boundsSkipped ? " (cuts didn't land cleanly there — nothing was removed at those pauses)" : " (tracks not continuous there)";
        }
        msg += ".";
        if (done > 0) msg += "  Note: the timeline timings have shifted — run Analyze / Create my edit again before applying camera cuts.";
        if (warned) {
            msg += "  ⚠ IMPORTANT: some pieces didn't remove cleanly, so tracks may be out of sync — do NOT keep this result.";
            if (backupName) msg += mcsRestoreSteps(backupName);
            else msg += " No OnMic backup was made (backup was off); revert to your last saved version instead.";
            return jstr({ ok: true, message: msg });
        }
        if (backupName) msg += (backupName === "made" ? "  A backup copy was saved in the Project panel." : "  Backup saved: \"" + backupName + "\".");
        if (backupName) msg += mcsRestoreSteps(backupName);
        else msg += "  Note: no backup was made (backup was off). A single Undo won't fully revert a ripple, so revert to a saved version if you need the original.";
        return jstr({ ok: true, message: msg });
    } catch (e) {
        // v1.05: don't advise Edit > Undo — a single Undo can't revert a
        // ripple-heavy trim. Point at the backup copy instead (same advice as
        // the half-fail path above). backupName is hoisted: "" / undefined if
        // the error hit before a backup was made.
        var emsg = e.toString() + "  ⚠ Dead-air trim stopped on an error and may have already removed some pauses — do not trust or export this timeline as-is. A single Edit > Undo will NOT fully revert a ripple delete.";
        if (backupName) emsg += mcsRestoreSteps(backupName);
        else emsg += " If a backup was made, restore it from the Project panel; otherwise revert to your last saved version.";
        return jstr({ ok: false, message: emsg });
    }
}

// Diagnostic: hand the panel the first video clip's media path so it can test
// whether this Premiere build can decode video frames. (Hidden; dev use.)
function mcsProbeVideo() {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }
        var nV = seq.videoTracks.numTracks, i;
        for (i = 0; i < nV; i++) {
            var tr = seq.videoTracks[i];
            if (tr.clips && tr.clips.numItems > 0) {
                var it = tr.clips[0], pi = it.projectItem, path = "";
                try { path = (pi && pi.getMediaPath) ? pi.getMediaPath() : ""; } catch (e1) {}
                return jstr({ ok: true, track: i + 1, path: path, name: pi ? pi.name : "", durSec: (it.end.seconds - it.start.seconds) });
            }
        }
        return jstr({ ok: false, message: "No video clips found on any video track." });
    } catch (e) { return jstr({ ok: false, message: e.toString() }); }
}

// Name the video tracks from the panel's camera tags, so the timeline labels
// stay in sync with what the panel knows. camerasJson = [{track,name},...].
function mcsSetTrackNames(camerasJson) {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }
        var cams = JSON.parse(camerasJson);
        var n = seq.videoTracks.numTracks, missing = [];
        for (var i = 0; i < cams.length; i++) {
            var idx = cams[i].track;
            if (idx < 1 || idx > n) { missing.push("V" + idx); continue; }
            try { seq.videoTracks[idx - 1].setName(cams[i].name); }
            catch (e1) { try { seq.videoTracks[idx - 1].name = cams[i].name; } catch (e2) {} }
        }
        if (missing.length) {
            return jstr({ ok: false, message: "These camera tracks don't exist: " +
                missing.join(", ") + ". Lower the Cameras count or add tracks." });
        }
        return jstr({ ok: true });
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

// SAFE rename helper: set each camera VIDEO track's NAME to the speaker names it
// is tagged with (from the panel's camera tags). This ONLY renames tracks — it
// never touches clips, edits, or the timeline. camerasJson = [{track,name},...]
// where `name` is the already-joined tagged speaker names for that camera track.
// Defensive throughout; returns JSON {ok, renamed, skipped, message}.
function mcsRenameVideoTracksFromTags(camerasJson) {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }
        var cams;
        try { cams = JSON.parse(camerasJson); } catch (eP) { return jstr({ ok: false, message: "Could not read camera tags." }); }
        if (!cams || cams.length == null) { return jstr({ ok: false, message: "No camera tags to rename from." }); }
        var n = seq.videoTracks.numTracks;
        var renamed = 0, skipped = [];
        for (var i = 0; i < cams.length; i++) {
            try {
                var idx = cams[i] && cams[i].track;
                var nm = (cams[i] && cams[i].name != null) ? String(cams[i].name) : "";
                // Only rename real, in-range tracks that actually have a tagged name.
                if (idx == null || idx < 1 || idx > n) { skipped.push("V" + idx); continue; }
                if (!nm || nm.replace(/^\s+|\s+$/g, "") === "") { continue; }
                var vt = seq.videoTracks[idx - 1];
                try { vt.setName(nm); renamed++; }
                catch (e1) { try { vt.name = nm; renamed++; } catch (e2) { skipped.push("V" + idx); } }
            } catch (eRow) {}
        }
        var msg = "Renamed " + renamed + " camera video track" + (renamed === 1 ? "" : "s") +
                  " from tagged speaker names. No clips or edits were changed.";
        if (skipped.length) { msg += " Skipped: " + skipped.join(", ") + "."; }
        return jstr({ ok: true, renamed: renamed, skipped: skipped, message: msg });
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

// Read each audio track's source media + sync info, so the panel can analyze the
// mics straight from the timeline (no external file picking).
function mcsReadAudioTracks() {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }
        var out = [];
        var n = seq.audioTracks.numTracks;
        for (var i = 0; i < n; i++) {
            var tr = seq.audioTracks[i];
            var clips = tr.clips;
            if (!clips || clips.numItems < 1) { continue; }
            var clip = clips[0];
            var mp = "";
            try { mp = clip.projectItem.getMediaPath(); } catch (e1) { mp = ""; }
            var startSec = 0, inSec = 0;
            try { startSec = clip.start.seconds; } catch (e2) {}
            try { inSec = clip.inPoint.seconds; } catch (e3) {}
            var sourceChannel = mcsClipSourceChannel(clip, tr.name || "");
            out.push({
                index: i + 1,
                name: (tr.name || ("Audio " + (i + 1))),
                clipName: (clip.name || ""),
                mediaPath: mp,
                startSec: startSec,
                inPointSec: inSec,
                sourceChannel: sourceChannel >= 0 ? sourceChannel : null
            });
        }
        return jstr({ ok: true, tracks: out });
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

function mcsClipSpeedChanged(clip) {
    try {
        if (clip && typeof clip.getSpeed === "function") {
            var spd = clip.getSpeed();
            return !!(spd && Math.abs(spd - 1) > 0.001);
        }
    } catch (e) {}
    return false;
}

function mcsClipGainDb(clip) {
    try {
        var comps = clip && clip.components;
        if (!comps) return 0;
        for (var i = 0; i < comps.numItems; i++) {
            var comp = comps[i], cn = "";
            try { cn = String(comp.displayName || comp.name || "").toLowerCase(); } catch (eC) {}
            var looksVolume = cn.indexOf("volume") >= 0 || cn.indexOf("audio") >= 0;
            if (!looksVolume) continue;
            var props = comp.properties;
            if (!props) continue;
            for (var p = 0; p < props.numItems; p++) {
                var prop = props[p], pn = "";
                try { pn = String(prop.displayName || prop.name || "").toLowerCase(); } catch (eP) {}
                if (pn.indexOf("level") < 0 && pn.indexOf("volume") < 0) continue;
                var v = null;
                try { v = prop.getValue(); } catch (eV) { try { v = prop.getValueAtTime(0, false); } catch (eT) {} }
                if (v != null && typeof v === "object" && v.length != null) v = v[0];
                var db = mcsPremiereLevelToDb(v, cn, pn);
                if (isFinite(db) && Math.abs(db) <= 96) return db;
            }
        }
    } catch (e) {}
    return 0;
}

function mcsPremiereLevelToDb(value, compName, propName) {
    var v = Number(value);
    if (!isFinite(v)) return 0;
    var cn = String(compName || "").toLowerCase();
    var pn = String(propName || "").toLowerCase();
    // Premiere's intrinsic Volume > Level stream is stored as a scaled value:
    // UI 0.0 dB reads around 0.1778279. Convert it back to the dB users see.
    if (v > 0 && cn.indexOf("volume") >= 0 && (pn.indexOf("level") >= 0 || pn.indexOf("volume") >= 0)) {
        var db = 20 * Math.log(v) * Math.LOG10E + 15;
        if (isFinite(db) && Math.abs(db) <= 96) return db;
    }
    if (Math.abs(v) <= 96) return v;
    return 0;
}

function mcsParseSourceChannelLabel(value) {
    try {
        var s = String(value || "");
        var m = /\b(?:ch|channel)\.?\s*([0-9]{1,2})\b/i.exec(s);
        if (m && m[1]) {
            var n = parseInt(m[1], 10);
            if (n > 0 && n <= 64) return n - 1;
        }
    } catch (e) {}
    return -1;
}

function mcsClipSourceChannel(clip, trackName) {
    var ch = -1;
    try { ch = mcsParseSourceChannelLabel(clip && clip.name); } catch (e1) {}
    if (ch >= 0) return ch;
    try { ch = mcsParseSourceChannelLabel(clip && clip.projectItem && clip.projectItem.name); } catch (e2) {}
    if (ch >= 0) return ch;
    try { ch = mcsParseSourceChannelLabel(trackName); } catch (e3) {}
    return ch >= 0 ? ch : -1;
}

function mcsTrackIsLocked(track) {
    try {
        if (!track) return false;
        var v = track.isLocked;
        if (typeof v === "function") v = v.call(track);
        return v === true || v === 1 || v === "1" || v === "true";
    } catch (e) {}
    return false;
}

// Read BOTH audio and video tracks so the panel can let the user map them
// explicitly (which audio tracks are mics, which video tracks are cameras).
function mcsReadTracks() {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }

        var audio = [];
        var na = seq.audioTracks.numTracks;
        for (var i = 0; i < na; i++) {
            var tr = seq.audioTracks[i];
            var clips = tr.clips;
            var n = (clips ? clips.numItems : 0);
            var locked = mcsTrackIsLocked(tr);
            var clipList = [];
            for (var ci = 0; ci < n; ci++) {
                var cl = clips[ci];
                var mpath = "", st = 0, en = 0, inp = 0, outp = 0;
                try { mpath = cl.projectItem.getMediaPath(); } catch (eA) {}
                try { st = cl.start.seconds; } catch (eB) {}
                try { en = cl.end.seconds; } catch (eC) {}
                try { inp = cl.inPoint.seconds; } catch (eD) {}
                try { outp = cl.outPoint.seconds; } catch (eE) {}
                var sourceChannel = mcsClipSourceChannel(cl, tr.name || "");
                clipList.push({ mediaPath: mpath, startSec: st, endSec: en,
                                inPointSec: inp, outPointSec: outp, name: (cl.name || ""),
                                sourceChannel: sourceChannel >= 0 ? sourceChannel : null,
                                gainDb: mcsClipGainDb(cl) });
            }
            var first = clipList.length ? clipList[0] : { mediaPath:"", startSec:0, inPointSec:0, name:"", sourceChannel:null, gainDb:0 };
            audio.push({ index: i + 1, name: (tr.name || ""), clipName: first.name,
                         mediaPath: first.mediaPath, startSec: first.startSec,
                         inPointSec: first.inPointSec, numClips: n, locked: locked,
                         sourceChannel: first.sourceChannel, gainDb: first.gainDb || 0, clips: clipList });
        }

        var video = [];
        var nv = seq.videoTracks.numTracks;
        for (var v = 0; v < nv; v++) {
            var vt = seq.videoTracks[v];
            var vc = vt.clips ? vt.clips.numItems : 0;
            var vlocked = mcsTrackIsLocked(vt);
            var vclips = [];
            for (var vi = 0; vi < vc; vi++) {
                var vcl = vt.clips[vi], vmp = "", vst = 0, ven = 0;
                try { vmp = vcl.projectItem.getMediaPath(); } catch (eP) {}
                try { vst = vcl.start.seconds; } catch (eSt) {}
                try { ven = vcl.end.seconds; } catch (eEn) {}
                vclips.push({ mediaPath: vmp, startSec: vst, endSec: ven,
                              name: (vcl.name || ""), speedChanged: mcsClipSpeedChanged(vcl) });
            }
            video.push({ index: v + 1, name: (vt.name || ""), numClips: vc, locked: vlocked, clips: vclips });
        }
        var sid = "", snm = "";
        try { sid = "" + seq.sequenceID; } catch (eS) {}
        try { snm = seq.name || ""; } catch (eN) {}
        // v1.03: also report the sequence frame rate, so the panel can refresh its
        // fps on every track read — not just on ping. (Creating an edit right
        // after switching sequences used the OLD sequence's fps for every razor
        // timecode, drifting all cuts.)
        var sfps = 0;
        try {
            var frR = seq.getSettings().videoFrameRate;
            var tkR = parseFloat(frR.ticks);
            if (tkR > 0) { sfps = TICKS_PER_SECOND / tkR; }
        } catch (eF) {}
        var layoutFp = mcsTimelineFingerprint(seq);
        if (!layoutFp) { return jstr({ ok: false, message: "OnMic couldn't fingerprint this timeline safely. Close and reopen the sequence, then click Re-read tracks." }); }
        return jstr({ ok: true, audio: audio, video: video, seqId: sid, seqName: snm, fps: sfps, timelineFingerprint: layoutFp });
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

// Guard: refuse to act on a cuts.json that was analyzed for a DIFFERENT sequence
// than the one currently active. cuts.seq_id is stamped at analyze time (panel).
// Missing identity is unsafe too: an old/malformed plan cannot be tied to the
// open sequence and also cannot participate in the post-trim stale-plan guard.
// Returns "" only when the plan can be tied to the current sequence.
function mcsSeqGuard(seq, cuts) {
    try {
        if (!cuts || cuts.seq_id == null || cuts.seq_id === "") {
            return "This edit plan is missing its sequence identity, so OnMic can't verify that it belongs to the timeline open now. Run Analyze / Create my edit again on this sequence, then retry. Nothing was changed.";
        }
        var cur = "" + seq.sequenceID;
        if (("" + cuts.seq_id) !== cur) {
            var was = cuts.seq_name ? (" (“" + cuts.seq_name + "”)") : "";
            return "These cuts were analyzed for a different sequence" + was +
                   ", not the one open now. Switch to that sequence, or re-run Analyze on this one.";
        }
        if (!cuts.timeline_fingerprint) {
            return "This edit plan is missing its timeline fingerprint, so OnMic can't prove the clips are still where they were during analysis. Run Analyze / Create my edit again. Nothing was changed.";
        }
        var liveFp = mcsTimelineFingerprint(seq);
        if (!liveFp) {
            return "OnMic couldn't verify the current clip layout, so this action was refused rather than risk applying stale cuts. Re-read tracks and run Analyze again. Nothing was changed.";
        }
        if (String(cuts.timeline_fingerprint) !== String(liveFp)) {
            return "This timeline changed after OnMic analyzed it (a clip was moved, trimmed, inserted, removed, or reordered). Run Analyze / Create my edit again before applying, trimming, adding markers, or exporting the report. Nothing was changed.";
        }
        // v1.03: refuse a plan written BEFORE the last ripple edit on this
        // sequence. A dead-air trim shifts every later time on the timeline, so
        // an older cuts.json would land cuts/trims at shifted positions (into
        // speech) while all the other guards still pass.
        // v1.05: merge the stamps persisted to disk first, so the guard still
        // holds after a Premiere restart. (mcsLoadEditedStamps never throws and
        // loads at most once per session.)
        mcsLoadEditedStamps();
        if (MCS_EDITED[cur]) {
            if (!(cuts.__mtime_ms > 0)) {
                return "OnMic knows this timeline was trimmed, but couldn't verify when this edit plan was created. Run Analyze / Create my edit again on the trimmed timeline, then retry. Nothing was changed.";
            }
            if (cuts.__mtime_ms < MCS_EDITED[cur]) {
                return "The timeline was trimmed after this edit plan was created, so its timings no longer line up. " +
                       "Run Analyze / Create my edit again on the trimmed timeline, then retry.";
            }
        }
    } catch (e) {
        // v1.05: FAIL CLOSED. This guard exists to stop edits from landing on the
        // wrong timeline; silently returning "" on an exception approved exactly
        // the cases it couldn't check.
        return "OnMic's sequence safety check itself failed (" + e.toString() +
               "), so this action was refused rather than risk editing the wrong timeline. " +
               "Click the sequence's timeline tab, Re-read tracks, and try again.";
    }
    return "";
}

// Per video track, list its clips with the timing the panel needs to sample
// frames by sequence time: media path, where the clip sits on the timeline
// (seq_start_s), its length (dur_s), and the source in-point (src_in_s).
// Used by the camera auto-detect (frame motion vs. mic correlation).
function mcsReadVideoClips() {
    try {
        var seq = app.project && app.project.activeSequence;
        if (!seq) { return jstr({ ok: false, message: "No active sequence." }); }
        var tracks = [];
        var nv = seq.videoTracks.numTracks;
        for (var v = 0; v < nv; v++) {
            var vt = seq.videoTracks[v];
            var clips = vt.clips, n = (clips ? clips.numItems : 0), list = [];
            for (var ci = 0; ci < n; ci++) {
                var cl = clips[ci], mp = "", st = 0, en = 0, inp = 0;
                try { mp = cl.projectItem.getMediaPath(); } catch (e1) {}
                try { st = cl.start.seconds; } catch (e2) {}
                try { en = cl.end.seconds; } catch (e3) {}
                try { inp = cl.inPoint.seconds; } catch (e4) {}
                // skip clips with no real media file (titles, adjustment layers, etc.)
                if (!mp) { continue; }
                list.push({ mediaPath: mp, seq_start_s: st, dur_s: (en - st), src_in_s: inp });
            }
            tracks.push({ index: v + 1, name: (vt.name || ""), clips: list });
        }
        return jstr({ ok: true, tracks: tracks });
    } catch (e) {
        return jstr({ ok: false, message: e.toString() });
    }
}

// Simple connectivity check the panel calls on load. Also reports fps + track counts.
function mcsPing() {
    var hasSeq = !!(app.project && app.project.activeSequence);
    var name = "", fps = 0, vtracks = 0, atracks = 0;
    if (hasSeq) {
        var seq = app.project.activeSequence;
        name = seq.name;
        vtracks = seq.videoTracks.numTracks;
        atracks = seq.audioTracks.numTracks;
        try {
            var fr = seq.getSettings().videoFrameRate;
            var ticks = parseFloat(fr.ticks);
            if (ticks > 0) { fps = TICKS_PER_SECOND / ticks; }
        } catch (e) {}
    }
    return jstr({ ok: true, hasSequence: hasSeq, sequenceName: name,
                  fps: fps, videoTracks: vtracks, audioTracks: atracks,
                  appVersion: (app.version || "") });
}

/* ---------------------------------------------------------------------------
 * Node test hook (ignored by ExtendScript, which has no `module`). Lets a Node
 * harness require this file to exercise the pure batching logic against mocked
 * Premiere/QE globals. ExtendScript never defines `module`, so this is inert
 * inside Premiere.
 * ------------------------------------------------------------------------- */
if (typeof module !== "undefined" && module.exports) {
    module.exports = {
        trackSpans: trackSpans,
        uniqueCameraTracks: uniqueCameraTracks,
        distinctCutPoints: distinctCutPoints,
        secToTimecode: secToTimecode,
        jstr: jstr,
        mcsSeqGuard: mcsSeqGuard,
        mcsTimelineFingerprint: mcsTimelineFingerprint,
        mcsRippleShiftOk: mcsRippleShiftOk,
        mcsSameSequence: mcsSameSequence,
        mcsFindSequenceById: mcsFindSequenceById,
        mcsActivate: mcsActivate,
        mcsQeMatchesSequence: mcsQeMatchesSequence,
        mcsVerifyAppliedVisibility: mcsVerifyAppliedVisibility,
        mcsPremiereLevelToDb: mcsPremiereLevelToDb,
        applyEnableDisable: applyEnableDisable,
        mcsApplyBegin: mcsApplyBegin,
        mcsApplyStep: mcsApplyStep,
        mcsApplyFinish: mcsApplyFinish,
        _getState: function () { return MCS_APPLY; }
    };
}
