/*
 ============================================================================
  SAMA.editor  -  SAMA.jsx                                   version 1.0.0
  Three dockable ScriptUI panels for Adobe After Effects (2020 / 17.x -> 2026+)

    1. SAMA Flow         - bezier easing graph editor
    2. SAMA Layer Vault  - .ffx layer preset library
    3. SAMA Quick Edit   - toolbox (nulls, text, camera, layers, keys, comp, expr)

  ONE FILE, ONE DOCKABLE PANEL
  SAMA.jsx opens a single dockable panel (Window > SAMA.jsx) with three tabs:
  Flow | Vault | Quick Edit. Run it from File > Scripts > Run Script File and
  it opens as a floating window with the same three tabs.

  Code is ExtendScript (ES3): no let/const, arrow functions, template strings,
  Array.indexOf/forEach/map, or native JSON (a small polyfill is included).
 ============================================================================
*/
#target aftereffects
#targetengine "SAMA"

var SAMA = (typeof SAMA === "object" && SAMA) ? SAMA : {};
SAMA.NAME = "SAMA.editor";
SAMA.VERSION = "1.0.0";
SAMA.SECTION = "SAMA_editor";          // app.settings section

/* ==========================================================================
   SHARED LIBRARY
   ========================================================================== */

// ---- small ES3 helpers ----------------------------------------------------
SAMA.trim = function (s) { return String(s).replace(/^\s+|\s+$/g, ""); };
SAMA.indexOf = function (arr, v) { var i; for (i = 0; i < arr.length; i++) { if (arr[i] === v) { return i; } } return -1; };
SAMA.num = function (txt, def) { var n = parseFloat(txt); return isNaN(n) ? def : n; };
SAMA.clamp = function (v, lo, hi) { return Math.max(lo, Math.min(hi, v)); };
SAMA.round3 = function (v) { return Math.round(v * 1000) / 1000; };
SAMA.modalN = 0;
SAMA.taskId = null;
SAMA.cancelTask = function () {          // AE refuses to run a scheduled script while a dialog waits, so none may be pending then
    if (SAMA.taskId !== null) { try { app.cancelTask(SAMA.taskId); } catch (e) { } SAMA.taskId = null; }
};
SAMA.modal = function (fn) {             // run anything that opens a dialog (alert, prompt, confirm, file dialogs, windows) through this
    var r;
    SAMA.modalN++; SAMA.cancelTask();
    try { r = fn(); } finally { SAMA.modalN--; }
    return r;
};
SAMA.prompt = function (m, d) { return SAMA.modal(function () { return prompt(m, d); }); };
SAMA.confirm = function (m) { return SAMA.modal(function () { return confirm(m); }); };
SAMA.openDlg = function (t, f, multi) { return SAMA.modal(function () { return File.openDialog(t, f, multi); }); };
SAMA.folderDlg = function (t) { return SAMA.modal(function () { return Folder.selectDialog(t); }); };
SAMA.alert = function (msg) { SAMA.modal(function () { alert(msg, SAMA.NAME); }); };
// Wraps an event handler: if it throws, the alert names the function and line (once per function) instead of failing silently.
SAMA.guard = function (name, fn) {
    return function () {
        try { return fn.apply(this, arguments); }
        catch (e) {
            if (!SAMA.warned) { SAMA.warned = {}; }
            if (!SAMA.warned[name]) { SAMA.warned[name] = true; SAMA.alert("Problem in: " + name + "\n" + e.toString() + (e.line ? "  (line " + e.line + ")" : "")); }
        }
    };
};
SAMA.aeMajor = function () { var v = parseInt(String(app.version), 10); return isNaN(v) ? 0 : v; };

// ---- JSON polyfill (ExtendScript has no JSON object) ---------------------
SAMA.json = {
    quote: function (s) {
        return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r")
            .replace(/\n/g, "\\n").replace(/\t/g, "\\t") + '"';
    },
    stringify: function (v) {
        var t = typeof v, out = [], i, k;
        if (v === null || v === undefined) { return "null"; }
        if (t === "number") { return isFinite(v) ? String(v) : "null"; }
        if (t === "boolean") { return v ? "true" : "false"; }
        if (t === "string") { return SAMA.json.quote(v); }
        if (v instanceof Array) {
            for (i = 0; i < v.length; i++) { out.push(SAMA.json.stringify(v[i])); }
            return "[" + out.join(",") + "]";
        }
        for (k in v) { if (v.hasOwnProperty(k)) { out.push(SAMA.json.quote(k) + ":" + SAMA.json.stringify(v[k])); } }
        return "{" + out.join(",") + "}";
    },
    parse: function (s) {
        s = String(s);
        // Crockford-style sanity check before eval
        var probe = s.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, "@")
            .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, "]")
            .replace(/(?:^|:|,)(?:\s*\[)+/g, "");
        if (!/^[\],:{}\s]*$/.test(probe)) { throw new Error("Invalid JSON"); }
        return eval("(" + s + ")");
    }
};

// ---- settings (app.settings) ----------------------------------------------
SAMA.settings = {
    get: function (key, def) {
        try { if (app.settings.haveSetting(SAMA.SECTION, key)) { return app.settings.getSetting(SAMA.SECTION, key); } } catch (e) { }
        return def;
    },
    set: function (key, val) { try { app.settings.saveSetting(SAMA.SECTION, key, String(val)); } catch (e) { } },
    getJSON: function (key, def) {
        var s = SAMA.settings.get(key, null);
        if (s === null) { return def; }
        try { return SAMA.json.parse(s); } catch (e) { return def; }
    },
    setJSON: function (key, val) { SAMA.settings.set(key, SAMA.json.stringify(val)); }
};

// ---- file-write permission --------------------------------------------------
SAMA.canWriteFiles = function () {
    try { return app.preferences.getPrefAsLong("Main Pref Section", "Pref_SCRIPTING_FILE_NETWORK_SECURITY") === 1; }
    catch (e) { return true; }   // unknown -> assume OK, real failures are caught later
};
SAMA.needWrite = function () {
    if (SAMA.canWriteFiles()) { return true; }
    SAMA.alert("SAMA needs permission to write files.\n\nOpen Preferences > Scripting & Expressions (Edit > Preferences on Windows, After Effects > Settings on Mac) and enable \"Allow Scripts to Write Files and Access Network\", then try again.");
    return false;
};

// ---- undo wrapper / guards ---------------------------------------------------
SAMA.run = function (label, fn) {
    if (SAMA.finishAll) { SAMA.finishAll(); }
    app.beginUndoGroup("SAMA: " + label);
    try { fn(); }
    catch (e) { SAMA.alert(label + " failed:\n" + e.toString() + (e.line ? "  (line " + e.line + ")" : "")); }
    finally { app.endUndoGroup(); }
};
SAMA.getComp = function () {
    var c = null;
    try { c = app.project ? app.project.activeItem : null; } catch (e) { }
    if (!(c instanceof CompItem)) { SAMA.alert("Open a composition and make it active (click its timeline or viewer) first."); return null; }
    return c;
};
SAMA.selProps = function (comp, needKeys, needExpr) {
    var sp = comp.selectedProperties, out = [], i, p;
    for (i = 0; i < sp.length; i++) {
        p = sp[i];
        if (p.propertyType !== PropertyType.PROPERTY) { continue; }
        if (needKeys && p.numKeys < 1) { continue; }
        if (needExpr && !p.canSetExpression) { continue; }
        out.push(p);
    }
    return out;
};
// need: "" | "layers" | "props" (keyed properties) | "exprprops" (expression-capable properties)
SAMA.act = function (label, need, fn) {
    var comp = SAMA.getComp(), layers, props = null;
    if (!comp) { return; }
    layers = comp.selectedLayers;
    if (need === "layers" && layers.length === 0) { SAMA.alert("Select one or more layers first."); return; }
    if (need === "props") {
        props = SAMA.selProps(comp, true, false);
        if (!props.length) { SAMA.alert("Select one or more properties that have keyframes (or select the keyframes in the timeline)."); return; }
    }
    if (need === "exprprops") {
        props = SAMA.selProps(comp, false, true);
        if (!props.length) { SAMA.alert("Select one or more properties first (click a property name in the timeline)."); return; }
    }
    SAMA.curExt = null; SAMA.curTop = null;
    if (layers && layers.length) { SAMA.curExt = SAMA.layerExtent(comp, layers, false); SAMA.curTop = SAMA.topLayer(layers); }   // a clip is selected: new layers get ITS length and sit just above it
    SAMA.run(label, function () { try { fn(comp, layers, props); } finally { SAMA.curExt = null; SAMA.curTop = null; } });
};
// New layers: with a clip selected they span exactly that clip (in to out); with nothing selected they keep the full comp length.
SAMA.layerDur = function (comp) { var e = SAMA.curExt; return (e && e.end - e.start >= comp.frameDuration) ? (e.end - e.start) : comp.duration; };
// the selected layer that is highest in the stack
SAMA.topLayer = function (layers) {
    var i, top = null;
    for (i = 0; i < layers.length; i++) { if (!top || layers[i].index < top.index) { top = layers[i]; } }
    return top;
};
// New layers: with a layer selected the new one is put just above it; with nothing selected After Effects' own placement (top of the stack) is kept.
SAMA.place = function (layer) {
    var t = SAMA.curTop;
    if (!t || !layer) { return layer; }
    try { if (layer.name !== "__sama_tmp__" && layer.index !== t.index - 1) { layer.moveBefore(t); } } catch (e) { }
    return layer;
};
SAMA.span = function (layer) {
    var e = SAMA.curExt, d = 0.01, len;
    SAMA.place(layer);
    try { d = layer.containingComp.frameDuration; } catch (e0) { }
    if (!e || !layer || !(e.end - e.start >= d)) { return layer; }
    len = e.end - e.start;
    try {
        if (Math.abs((layer.outPoint - layer.inPoint) - len) < d / 2) { layer.startTime = layer.startTime + (e.start - layer.inPoint); }   // already the right length (nulls, solids): one move, nothing is trimmed
        else { layer.inPoint = e.start; layer.outPoint = e.end; }
    } catch (e1) { try { layer.outPoint = e.end; layer.inPoint = e.start; } catch (e2) { } }
    return layer;
};

// ---- layer / property helpers ---------------------------------------------------
SAMA.tp = function (layer, match) { return layer.property("ADBE Transform Group").property(match); };
SAMA.layerOf = function (p) { return p.propertyGroup(p.propertyDepth); };
SAMA.setProp = function (p, comp, val) { if (p.numKeys > 0) { p.setValueAtTime(comp.time, val); } else { p.setValue(val); } };
SAMA.deselectAll = function (comp) {
    var s = comp.selectedLayers, i;
    for (i = 0; i < s.length; i++) { s[i].selected = false; }
};
SAMA.selectOnly = function (comp, list) {
    var i; SAMA.deselectAll(comp);
    for (i = 0; i < list.length; i++) { try { list[i].selected = true; } catch (e) { } }
};
SAMA.movePos = function (layer, comp, dx, dy) {
    var tg = layer.property("ADBE Transform Group"), p = tg.property("ADBE Position"), px, py, v;
    if (p.dimensionsSeparated) {
        px = tg.property("ADBE Position_0"); py = tg.property("ADBE Position_1");
        SAMA.setProp(px, comp, px.value + dx); SAMA.setProp(py, comp, py.value + dy);
    } else {
        v = p.value; v[0] += dx; v[1] += dy; SAMA.setProp(p, comp, v);
    }
};
// Evaluate an expression string on a scratch layer's Position and return the value.
SAMA.evalOn = function (scratch, expr) {
    var p = SAMA.tp(scratch, "ADBE Position"), v;
    p.expression = expr;
    v = p.value;
    p.expression = "";
    return v;
};
SAMA.worldPos = function (scratch, layer) {
    var idx = layer.index, v;
    try { v = SAMA.evalOn(scratch, "thisComp.layer(" + idx + ").toWorld(thisComp.layer(" + idx + ").anchorPoint)"); }
    catch (e) { v = SAMA.tp(layer, "ADBE Position").value; }
    return v;
};
SAMA.addNull = function (comp, name) { var n = comp.layers.addNull(SAMA.layerDur(comp)); n.name = name; SAMA.tag(n, "nullobj"); return SAMA.span(n); };
SAMA.setParent = function (child, parent) {
    if (typeof child.setParentWithJump === "function") { child.setParentWithJump(parent); } else { child.parent = parent; }
};
SAMA.ensureCtl = function (layer, matchName, name, val) {
    var fx = layer.property("ADBE Effect Parade"), i, e;
    for (i = 1; i <= fx.numProperties; i++) {
        if (fx.property(i).name === name && fx.property(i).matchName === matchName) { return fx.property(i); }
    }
    e = fx.addProperty(matchName); e.name = name;
    if (val !== undefined) { try { e.property(1).setValue(val); } catch (err) { } }
    return e;
};
SAMA.textLayers = function (layers) {
    var out = [], i;
    for (i = 0; i < layers.length; i++) { if (layers[i] instanceof TextLayer) { out.push(layers[i]); } }
    return out;
};
SAMA.tdProp = function (l) { return l.property("ADBE Text Properties").property("ADBE Text Document"); };

// ---- keyframe machinery (shared by Flow and Quick Edit) ---------------------------
SAMA.captureKey = function (p, i) {
    var d = { t: p.keyTime(i), v: p.keyValue(i), inI: p.keyInInterpolationType(i), outI: p.keyOutInterpolationType(i) };
    try { d.inE = p.keyInTemporalEase(i); d.outE = p.keyOutTemporalEase(i); } catch (e1) { }
    try { d.tc = p.keyTemporalContinuous(i); d.tab = p.keyTemporalAutoBezier(i); } catch (e2) { }
    if (p.isSpatial) {
        try {
            d.inT = p.keyInSpatialTangent(i); d.outT = p.keyOutSpatialTangent(i);
            d.sc = p.keySpatialContinuous(i); d.sab = p.keySpatialAutoBezier(i); d.rov = p.keyRoving(i);
        } catch (e3) { }
    }
    return d;
};
// swap = true mirrors the key (in <-> out) for reversed time
SAMA.restoreKey = function (p, t, d, swap) {
    var k, inE = swap ? d.outE : d.inE, outE = swap ? d.inE : d.outE;
    p.setValueAtTime(t, d.v);
    k = p.nearestKeyIndex(t);
    try { p.setInterpolationTypeAtKey(k, swap ? d.outI : d.inI, swap ? d.inI : d.outI); } catch (e1) { }
    try { if (inE && outE) { p.setTemporalEaseAtKey(k, inE, outE); } } catch (e2) { }
    try {
        if (d.inT && d.outT) { p.setSpatialTangentsAtKey(k, swap ? d.outT : d.inT, swap ? d.inT : d.outT); }
        if (d.sab) { p.setSpatialAutoBezierAtKey(k, true); } else if (d.sc) { p.setSpatialContinuousAtKey(k, true); }
    } catch (e3) { }
    try { if (d.tab) { p.setTemporalAutoBezierAtKey(k, true); } else if (d.tc) { p.setTemporalContinuousAtKey(k, true); } } catch (e4) { }
    try { if (d.rov) { p.setRovingAtKey(k, true); } } catch (e5) { }
    try { p.setSelectedAtKey(k, true); } catch (e6) { }
};
// Re-time selected keys of a property. mapFn(oldTime) -> newTime
SAMA.moveKeys = function (p, mapFn, swap) {
    var idx = p.selectedKeys.slice(0), data = [], i;
    idx.sort(function (a, b) { return a - b; });
    for (i = 0; i < idx.length; i++) { data.push(SAMA.captureKey(p, idx[i])); }
    for (i = idx.length - 1; i >= 0; i--) { p.removeKey(idx[i]); }
    for (i = 0; i < data.length; i++) { SAMA.restoreKey(p, mapFn(data[i].t), data[i], swap); }
};

// ---- bezier easing maths -------------------------------------------------------------
SAMA.bez = function (c, t) {   // cubic-bezier(x1,y1,x2,y2) point at parameter t, P0=(0,0) P3=(1,1)
    var u = 1 - t;
    return [3 * u * u * t * c[0] + 3 * u * t * t * c[2] + t * t * t,
            3 * u * u * t * c[1] + 3 * u * t * t * c[3] + t * t * t];
};
SAMA.deltaFor = function (p, v0, v1, d) {
    var j, s = 0;
    if (p.isSpatial && v0 instanceof Array) {
        for (j = 0; j < v0.length; j++) { s += (v1[j] - v0[j]) * (v1[j] - v0[j]); }
        return Math.sqrt(s);
    }
    if (v0 instanceof Array) { return (typeof v0[d] === "number") ? v1[d] - v0[d] : 0; }
    if (typeof v0 === "number") { return v1 - v0; }
    return 0;
};
SAMA.clampInf = function (v) { return SAMA.clamp(v, 0.1, 100); };
// Apply cubic-bezier curve c to the segment between keys a and b (a < b) of property p.
SAMA.applySeg = function (p, a, b, c, doOut, doIn) {
    var dT = p.keyTime(b) - p.keyTime(a), v0, v1, inA, outA, inB, outB, n, d, avg, dv;
    var x1, x2, slopeOut, slopeIn, iOut, iIn, newOut = [], newIn = [];
    if (dT <= 0) { return false; }
    v0 = p.keyValue(a); v1 = p.keyValue(b);
    inA = p.keyInTemporalEase(a); outA = p.keyOutTemporalEase(a);
    inB = p.keyInTemporalEase(b); outB = p.keyOutTemporalEase(b);
    n = outA.length;
    x1 = SAMA.clamp(c[0], 0.001, 1); x2 = SAMA.clamp(c[2], 0, 0.999);
    slopeOut = SAMA.clamp(c[1] / x1, -1000, 1000);
    slopeIn = SAMA.clamp((1 - c[3]) / (1 - x2), -1000, 1000);
    iOut = SAMA.clampInf(c[0] * 100); iIn = SAMA.clampInf((1 - c[2]) * 100);
    for (d = 0; d < n; d++) {
        dv = SAMA.deltaFor(p, v0, v1, d); avg = dv / dT;
        newOut.push(new KeyframeEase(avg * slopeOut, iOut));
        newIn.push(new KeyframeEase(avg * slopeIn, iIn));
    }
    if (doOut) {
        p.setInterpolationTypeAtKey(a, p.keyInInterpolationType(a), KeyframeInterpolationType.BEZIER);
        p.setTemporalEaseAtKey(a, inA, newOut);
    }
    if (doIn) {
        p.setInterpolationTypeAtKey(b, KeyframeInterpolationType.BEZIER, p.keyOutInterpolationType(b));
        p.setTemporalEaseAtKey(b, newIn, outB);
    }
    return true;
};
SAMA.applyCurveToProp = function (p, c, opt) {
    var keys = [], segs = [], i, k, count = 0;
    if (opt.allKeys) { for (i = 1; i <= p.numKeys; i++) { keys.push(i); } }
    else { keys = p.selectedKeys.slice(0); }
    keys.sort(function (a, b) { return a - b; });
    if (keys.length === 1) {
        k = keys[0];
        if (k < p.numKeys) { segs.push([k, k + 1]); } else if (k > 1) { segs.push([k - 1, k]); }
    } else {
        for (i = 0; i < keys.length - 1; i++) { if (keys[i + 1] === keys[i] + 1) { segs.push([keys[i], keys[i + 1]]); } }
    }
    for (i = 0; i < segs.length; i++) { if (SAMA.applySeg(p, segs[i][0], segs[i][1], c, opt.doOut, opt.doIn)) { count++; } }
    return count;
};
// opt: { allKeys:bool, doIn:bool, doOut:bool }
SAMA.applyCurve = function (curve, opt) {
    var comp = SAMA.getComp(), props, work = [], i, p;
    if (!comp) { return; }
    props = SAMA.selProps(comp, true, false);
    for (i = 0; i < props.length; i++) {
        p = props[i];
        if (opt.allKeys) { if (p.numKeys >= 2) { work.push(p); } }
        else if (p.selectedKeys.length > 0) { work.push(p); }
    }
    if (!work.length) {
        SAMA.alert(opt.allKeys ? "Select properties that contain at least two keyframes." : "Select one or more keyframes in the timeline first.");
        return;
    }
    if (!opt.doIn && !opt.doOut) { SAMA.alert("Enable at least one of Out / In."); return; }
    SAMA.run("Apply easing curve", function () {
        var total = 0, j;
        for (j = 0; j < work.length; j++) { total += SAMA.applyCurveToProp(work[j], curve, opt); }
        if (total === 0) { SAMA.alert("No adjacent keyframe pairs were found in the selection. Select two neighbouring keyframes (or one keyframe that has a neighbour)."); }
    });
};
// Same as applyCurve, but silent: used by "Apply while editing graph". Does nothing (no message) when no keyframes are selected.
SAMA.applyCurveQuiet = function (curve, opt) {
    var comp = SAMA.getComp(), props, work = [], i;
    if (!comp || (!opt.doIn && !opt.doOut)) { return; }
    try {
        props = SAMA.selProps(comp, true, false);
        for (i = 0; i < props.length; i++) { if (props[i].selectedKeys.length > 0) { work.push(props[i]); } }
    } catch (e) { return; }
    if (!work.length) { return; }
    SAMA.run("Apply easing curve", function () {
        var j;
        for (j = 0; j < work.length; j++) { SAMA.applyCurveToProp(work[j], curve, { allKeys: false, doIn: opt.doIn, doOut: opt.doOut }); }
    });
};
// Per-key ease helpers for Quick Edit (like F9 and friends). kind: ease|in|out|strong|linear|hold
SAMA.keyEase = function (kind) {
    SAMA.act("Keyframe " + kind, "props", function (comp, layers, props) {
        var i, j, k, p, n, d, infl, inE, outE, any = false, setIn, setOut, keys;
        for (i = 0; i < props.length; i++) {
            p = props[i]; keys = p.selectedKeys;
            for (j = 0; j < keys.length; j++) {
                k = keys[j]; any = true;
                if (kind === "linear") { p.setInterpolationTypeAtKey(k, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR); continue; }
                if (kind === "hold") { p.setInterpolationTypeAtKey(k, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD); continue; }
                infl = (kind === "strong") ? 80 : 33.333;
                setIn = (kind !== "out"); setOut = (kind !== "in");
                p.setInterpolationTypeAtKey(k, setIn ? KeyframeInterpolationType.BEZIER : p.keyInInterpolationType(k),
                                               setOut ? KeyframeInterpolationType.BEZIER : p.keyOutInterpolationType(k));
                inE = p.keyInTemporalEase(k); outE = p.keyOutTemporalEase(k); n = inE.length;
                for (d = 0; d < n; d++) {
                    if (setIn) { inE[d] = new KeyframeEase(0, infl); }
                    if (setOut) { outE[d] = new KeyframeEase(0, infl); }
                }
                p.setTemporalEaseAtKey(k, inE, outE);
            }
        }
        if (!any) { SAMA.alert("Select one or more keyframes in the timeline first."); }
    });
};

// ---- UI helpers ---------------------------------------------------------------------------
SAMA.ui = {
    page: function (parent) {
        var g = parent.add("group"); g.orientation = "column"; g.alignChildren = ["fill", "top"];
        g.alignment = ["fill", "top"]; g.spacing = 8; g.margins = 0; return g;
    },
    row: function (parent) {
        var g = parent.add("group"); g.orientation = "row"; g.alignChildren = ["fill", "center"];
        g.alignment = ["fill", "top"]; g.spacing = 8; g.margins = 0; return g;
    },
    label: function (parent, text) {
        var t = parent.add("statictext", undefined, text, { multiline: true }); t.alignment = ["fill", "top"]; SAMA.tint(t, SAMA.C.dim); return t;
    },
    btn: function (parent, label, tip, fn) {
        var b = parent.add("button", undefined, label);
        b.helpTip = tip; b.alignment = ["fill", "center"]; b.preferredSize = [44, 26]; b.minimumSize = [28, 24];
        b.onClick = fn; return b;
    },
    // defs: [[label, tip, fn], ...]
    buttons: function (parent, defs, perRow) {
        var i, row = null;
        for (i = 0; i < defs.length; i++) {
            if (i % perRow === 0) { row = SAMA.ui.row(parent); }
            SAMA.ui.btn(row, defs[i][0], defs[i][1], defs[i][2]);
        }
    },
    // labelled edittext that remembers its value in app.settings
    field: function (parent, key, label, def, tip) {
        var r = SAMA.ui.row(parent), t = r.add("statictext", undefined, label), e;
        t.alignment = ["left", "center"]; SAMA.ui.tint(t);
        e = r.add("edittext", undefined, SAMA.settings.get("q." + key, String(def)));
        e.alignment = ["fill", "center"]; e.minimumSize = [40, 22]; e.helpTip = tip || label;
        e.onChange = function () { SAMA.settings.set("q." + key, this.text); };
        return e;
    },
    check: function (parent, key, label, def, tip) {
        var c = parent.add("checkbox", undefined, label), s = SAMA.settings.get("q." + key, def ? "1" : "0");
        c.value = (s === "1"); c.helpTip = tip || label; c.alignment = ["fill", "top"];
        c.onClick = function () { SAMA.settings.set("q." + key, this.value ? "1" : "0"); };
        SAMA.ui.tint(c);
        return c;
    },
    done: function (win) {
        win.layout.layout(true);
        win.onResizing = win.onResize = function () { this.layout.resize(); };
    }
};
/* ==========================================================================
   VISUAL STYLE: palette, vector icons, tiles, reflowing grids, top bar
   Everything is drawn with ScriptUI graphics (no image files needed).
   ========================================================================== */
SAMA.reflowers = [];
// ---- light animation: short timed redraw loops (ScriptUI has no animation engine). Can be switched off in the top bar.
SAMA.anim = { on: false };
SAMA.alpha = 1;      // global fade factor used by every colour the UI draws
SAMA.busy = false;
SAMA.easeOut = function (t) { return 1 - Math.pow(1 - t, 4); };
SAMA.easeInOut = function (t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
SAMA.now = function () { return (new Date()).getTime(); };
// Short, time-based animation loops (ScriptUI has no animation engine). Calls step(p) with eased progress, always ends with exactly step(1), then done().
// They are kept brief (about 0.15 s) so After Effects is never held up long enough to show a busy cursor, and nothing depends on timers or the scheduler.
SAMA.slideHide = function (page, list) {
    function walk(n) {
        var i, k, t;
        try {
            t = n.type;
            if ((t === "edittext" || t === "slider" || t === "dropdownlist" || t === "listbox" || t === "checkbox" || t === "radiobutton" || n.noSlide) && n.visible) { n.visible = false; list.push(n); return; }
            k = n.children; if (k) { for (i = 0; i < k.length; i++) { walk(k[i]); } }
        } catch (e) { }
    }
    walk(page);
};
SAMA.finishAll = function () { };      // (kept so older call sites stay harmless)
SAMA.tween = function (ms, step, fn, done) {
    var t0, p, ease = fn || SAMA.easeOut;
    if (!SAMA.anim.on || SAMA.busy) { step(1); if (done) { done(); } return; }
    SAMA.busy = true;
    try {
        t0 = SAMA.now();
        for (;;) {
            p = (SAMA.now() - t0) / ms; if (p > 1) { p = 1; }
            step(p >= 1 ? 1 : ease(p));
            if (p >= 1) { break; }
            $.sleep(4);
        }
    } finally { SAMA.busy = false; }
    if (done) { done(); }
};
// Forces After Effects to paint what was just drawn right now. While a mouse button is held it otherwise batches repaints, which is what made
// hold-and-drag crawl at about one frame per second (clicking and moving, with the button up, was never affected).
SAMA.flush = function () {
    try { if (SAMA.win && SAMA.win.update) { SAMA.win.update(); } } catch (e) { }
};
SAMA.redrawTree = function (c) {
    var i;
    try { if (c.redraw) { c.redraw(); } } catch (e) { }
    if (c.children) { for (i = 0; i < c.children.length; i++) { SAMA.redrawTree(c.children[i]); } }
};
SAMA.C = {
    bg: [0.082, 0.082, 0.09, 1], tile: [0.135, 0.135, 0.148, 1], hot: [0.19, 0.19, 0.21, 1],
    down: [0.1, 0.1, 0.11, 1], line: [0.22, 0.22, 0.245, 1], text: [0.8, 0.8, 0.83, 1],
    dim: [0.52, 0.52, 0.57, 1], white: [0.95, 0.95, 0.96, 1], ink: [0.07, 0.07, 0.08, 1], accent: [0.36, 0.76, 1, 1],
    title: [0.95, 0.95, 0.96, 1],
    onHi: [0.07, 0.07, 0.08, 1]            // label colour on top of the highlight fill (C.title): dark on light fills, white on dark ones
};
SAMA.lift = function (c) { return [c[0] + (1 - c[0]) * 0.28, c[1] + (1 - c[1]) * 0.28, c[2] + (1 - c[2]) * 0.28, 1]; };
// Colour theme: the text colour can be changed (settings gear). The arrays are changed in place so every control that points at them follows.
SAMA.setTextColour = function (rgb, rgb2) {
    var C = SAMA.C, t = rgb || [0.8, 0.8, 0.83], h = rgb || [0.95, 0.95, 0.96], i, m = h;
    if (rgb2) { m = [(h[0] + rgb2[0]) / 2, (h[1] + rgb2[1]) / 2, (h[2] + rgb2[2]) / 2]; }          // with a gradient the label colour follows the middle of the two colours
    var lum = 0.299 * m[0] + 0.587 * m[1] + 0.114 * m[2], o = (rgb || rgb2) ? (lum > 0.6 ? [0.07, 0.07, 0.08] : [1, 1, 1]) : [0.07, 0.07, 0.08];
    for (i = 0; i < 3; i++) { C.text[i] = t[i]; C.title[i] = h[i]; C.onHi[i] = o[i]; }
    C.grad2 = rgb2 ? [rgb2[0], rgb2[1], rgb2[2], 1] : null;         // second colour of the gradient (null = plain colour)
};
SAMA.mix = function (a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, 1]; };
// A line through the points pts ([[x, y], ...]) whose colour goes from c1 to c2 along its length (drawn as short pieces, one colour each).
SAMA.strokeGrad = function (g, pts, c1, c2, w) {
    var i, n = pts.length - 1;
    for (i = 1; i <= n; i++) {
        g.newPath(); g.moveTo(pts[i - 1][0], pts[i - 1][1]); g.lineTo(pts[i][0], pts[i][1]);
        g.strokePath(SAMA.pen(g, SAMA.mix(c1, c2, n > 1 ? (i - 0.5) / n : 0), w));
    }
};
// A rounded shape filled with a left-to-right gradient. ScriptUI has no gradient brush, so it is drawn as thin vertical strips that follow the rounded corners.
SAMA.gradFill = function (g, x, y, w, h, r, c1, c2, under) {
    var n = Math.max(4, Math.min(72, Math.floor(w))), sw = w / n, i, x0, cx, inset, d, ww, k, a, ub = under || SAMA.C.bg, cs, q;
    r = Math.min(r, h / 2, w / 2);
    for (i = 0; i < n; i++) {
        x0 = x + i * sw; cx = (i + 0.5) * sw; inset = 0;
        if (cx < r) { d = r - cx; inset = r - Math.sqrt(Math.max(0, r * r - d * d)); }
        else if (cx > w - r) { d = cx - (w - r); inset = r - Math.sqrt(Math.max(0, r * r - d * d)); }
        ww = Math.min(sw + 1, x + w - x0);
        g.newPath(); g.rectPath(x0, y + inset, ww, Math.max(0.5, h - 2 * inset));
        g.fillPath(SAMA.brush(g, SAMA.mix(c1, c2, n > 1 ? i / (n - 1) : 0)));
    }
    // the strips are square-edged, so a few pixels stick out at the rounded corners: paint the space outside the true curve with the colour behind the button
    if (r > 0.5) {
        cs = [[x + r, y + r, 180], [x + w - r, y + r, 270], [x + w - r, y + h - r, 0], [x + r, y + h - r, 90]];
        for (k = 0; k < 4; k++) {
            q = cs[k]; a = q[2] * Math.PI / 180; d = (q[2] + 90) * Math.PI / 180;          // the arc runs from angle a to angle d
            g.newPath();
            g.moveTo(q[0] + (r + 1.5) * Math.cos(a), q[1] + (r + 1.5) * Math.sin(a));
            g.lineTo(q[0] + (r + 1.5) * (Math.cos(a) + Math.cos(d)), q[1] + (r + 1.5) * (Math.sin(a) + Math.sin(d)));
            g.lineTo(q[0] + (r + 1.5) * Math.cos(d), q[1] + (r + 1.5) * Math.sin(d));
            for (i = 10; i >= 0; i--) { cx = (q[2] + 90 * i / 10) * Math.PI / 180; g.lineTo(q[0] + r * Math.cos(cx), q[1] + r * Math.sin(cx)); }
            g.closePath(); g.fillPath(SAMA.brush(g, ub));
        }
    }
};

SAMA.refreshTheme = function () {         // re-tint native text controls and repaint the custom-drawn ones
    function walk(n) {
        var i, k;
        try {
            if (n.samaTint === SAMA.C.text || n.samaTint === SAMA.C.title) { SAMA.tint(n, n.samaTint); }
            if (n.type === "customButton") { try { n.notify("onDraw"); } catch (e1) { } }
            k = n.children; if (k) { for (i = 0; i < k.length; i++) { walk(k[i]); } }
        } catch (e) { }
    }
    walk(SAMA.win);
    try { SAMA.win.update(); } catch (e2) { }
};
SAMA.loadTheme = function () {
    SAMA.anim.on = false;
    var dy = parseFloat(SAMA.settings.get("ui.textDy", "0"));
    SAMA.textDy = (isNaN(dy) ? 0 : Math.max(-12, Math.min(12, dy)));
    var hx = SAMA.settings.get("ui.text", ""), c = hx ? SAMA.hex2rgb(hx) : null;
    var hx2 = SAMA.settings.get("ui.text2", ""), c2 = hx2 ? SAMA.hex2rgb(hx2) : null;
    SAMA.setTextColour(c, c2);
};
// Icons live on a 24x24 grid. p = open polyline, P = closed polyline, r = rect, o = circle, b = bezier
SAMA.icons = {
    curve: [["b", 4, 19, 11, 19, 13, 5, 20, 5]],
    gear: [["o", 12, 12, 6.5], ["o", 12, 12, 2.5], ["p", 18.5, 12, 21.5, 12], ["p", 12, 18.5, 12, 21.5], ["p", 5.5, 12, 2.5, 12], ["p", 12, 5.5, 12, 2.5],
           ["p", 16.6, 16.6, 18.7, 18.7], ["p", 7.4, 16.6, 5.3, 18.7], ["p", 7.4, 7.4, 5.3, 5.3], ["p", 16.6, 7.4, 18.7, 5.3]],
    easein: [["b", 4, 19, 13, 19, 16, 10, 20, 5]],
    easeout: [["b", 4, 19, 8, 14, 11, 5, 20, 5]],
    linear: [["p", 4, 19, 20, 5]],
    hold: [["p", 4, 17, 12, 17, 12, 7, 20, 7]],
    layers: [["P", 12, 4, 21, 9, 12, 14, 3, 9], ["p", 3, 13, 12, 18, 21, 13]],
    wave: [["p", 4, 10, 4, 14], ["p", 8, 7, 8, 17], ["p", 12, 4, 12, 20], ["p", 16, 8, 16, 16], ["p", 20, 10, 20, 14]],
    bookmark: [["P", 7, 3.5, 17, 3.5, 17, 20.5, 12, 16, 7, 20.5]],
    wrench: [["P", 3.17, 18.57, 5.43, 20.83, 12.64, 13.62, 12.28, 13.98, 13.47, 14.34, 14.71, 14.37, 15.91, 14.07, 16.98, 13.46, 17.86, 12.59, 18.47, 11.51, 18.77, 10.31, 18.74, 9.07, 18.38, 7.89, 15.68, 10.58, 13.42, 8.32, 16.11, 5.62, 14.93, 5.26, 13.69, 5.23, 12.49, 5.53, 11.41, 6.14, 10.54, 7.02, 9.93, 8.09, 9.63, 9.29, 9.66, 10.53, 10.02, 11.72, 10.38, 11.36]],
    grid: [["r", 4, 4, 7, 7], ["r", 13, 4, 7, 7], ["r", 4, 13, 7, 7], ["r", 13, 13, 7, 7]],
    text: [["p", 5, 6, 19, 6], ["p", 12, 6, 12, 19]],
    adjust: [["o", 12, 12, 7], ["p", 12, 5, 12, 19]],
    solid: [["r", 5, 5, 14, 14]],
    nullbox: [["r", 5, 5, 14, 14], ["p", 12, 9, 12, 15], ["p", 9, 12, 15, 12]],
    shape: [["P", 12, 5, 20, 19, 4, 19]],
    camera: [["r", 3, 7, 13, 11], ["P", 16, 11, 21, 8, 21, 17, 16, 14]],
    camrig: [["r", 3, 9, 11, 9], ["P", 14, 12, 19, 10, 19, 17, 14, 15], ["p", 8, 3, 8, 7], ["p", 6, 5, 10, 5]],
    light: [["o", 12, 10, 5], ["p", 10, 17, 14, 17], ["p", 10, 20, 14, 20]],
    precomp: [["r", 4, 4, 12, 12], ["r", 8, 8, 12, 12]],
    nullat: [["r", 4, 4, 16, 16], ["o", 12, 12, 3]],
    parent: [["o", 12, 6, 3], ["p", 12, 9, 12, 14], ["p", 6, 14, 18, 14], ["p", 6, 14, 6, 18], ["p", 18, 14, 18, 18]],
    center: [["o", 12, 12, 6], ["p", 12, 3, 12, 7], ["p", 12, 17, 12, 21], ["p", 3, 12, 7, 12], ["p", 17, 12, 21, 12]],
    controller: [["p", 5, 8, 19, 8], ["p", 5, 16, 19, 16], ["o", 9, 8, 2], ["o", 15, 16, 2]],
    lines: [["p", 5, 7, 19, 7], ["p", 5, 12, 19, 12], ["p", 5, 17, 14, 17]],
    words: [["r", 3, 9, 8, 6], ["r", 13, 9, 8, 6]],
    chars: [["r", 3, 7, 5, 10], ["r", 9.5, 7, 5, 10], ["r", 16, 7, 5, 10]],
    txtleft: [["p", 4, 6, 20, 6], ["p", 4, 11, 14, 11], ["p", 4, 16, 18, 16]],
    txtcenter: [["p", 4, 6, 20, 6], ["p", 7, 11, 17, 11], ["p", 5, 16, 19, 16]],
    txtright: [["p", 4, 6, 20, 6], ["p", 10, 11, 20, 11], ["p", 6, 16, 20, 16]],
    nodes: [["o", 6, 12, 3], ["o", 18, 12, 3], ["p", 9, 12, 15, 12]],
    dof: [["o", 12, 12, 2], ["o", 12, 12, 8]],
    zoomin: [["o", 11, 11, 6], ["p", 16, 16, 20, 20], ["p", 8, 11, 14, 11], ["p", 11, 8, 11, 14]],
    zoomout: [["o", 11, 11, 6], ["p", 16, 16, 20, 20], ["p", 8, 11, 14, 11]],
    dollyin: [["p", 12, 20, 12, 5], ["p", 7, 10, 12, 5, 17, 10]],
    dollyout: [["p", 12, 4, 12, 19], ["p", 7, 14, 12, 19, 17, 14]],
    trimin: [["p", 7, 4, 7, 20], ["p", 7, 12, 20, 12], ["p", 16, 8, 20, 12, 16, 16]],
    trimout: [["p", 17, 4, 17, 20], ["p", 17, 12, 4, 12], ["p", 8, 8, 4, 12, 8, 16]],
    workarea: [["p", 6, 5, 6, 19], ["p", 18, 5, 18, 19], ["p", 6, 12, 18, 12]],
    sequence: [["r", 4, 5, 8, 4], ["r", 9, 10, 8, 4], ["r", 14, 15, 6, 4]],
    reverse: [["p", 4, 9, 20, 9], ["p", 16, 5, 20, 9, 16, 13], ["p", 20, 15, 4, 15], ["p", 8, 11, 4, 15, 8, 19]],
    anchor: [["o", 12, 12, 2], ["r", 5, 5, 14, 14]],
    alignL: [["p", 5, 4, 5, 20], ["r", 8, 6, 11, 4], ["r", 8, 14, 6, 4]],
    alignC: [["p", 12, 4, 12, 20], ["r", 6, 6, 12, 4], ["r", 8, 14, 8, 4]],
    alignR: [["p", 19, 4, 19, 20], ["r", 5, 6, 11, 4], ["r", 10, 14, 6, 4]],
    alignT: [["p", 4, 5, 20, 5], ["r", 6, 8, 4, 11], ["r", 14, 8, 4, 6]],
    alignM: [["p", 4, 12, 20, 12], ["r", 6, 6, 4, 12], ["r", 14, 8, 4, 8]],
    alignB: [["p", 4, 19, 20, 19], ["r", 6, 5, 4, 11], ["r", 14, 10, 4, 6]],
    distH: [["p", 4, 4, 4, 20], ["p", 20, 4, 20, 20], ["r", 9, 8, 6, 8]],
    distV: [["p", 4, 4, 20, 4], ["p", 4, 20, 20, 20], ["r", 8, 9, 8, 6]],
    solo: [["o", 12, 12, 7], ["o", 12, 12, 2]],
    lock: [["r", 6, 11, 12, 9], ["p", 8, 11, 8, 8, 10, 5, 14, 5, 16, 8, 16, 11]],
    shy: [["P", 3, 12, 12, 6, 21, 12, 12, 18], ["o", 12, 12, 2]],
    hideshy: [["P", 3, 12, 12, 6, 21, 12, 12, 18], ["p", 4, 20, 20, 4]],
    offback: [["p", 19, 12, 5, 12], ["p", 9, 8, 5, 12, 9, 16]],
    offfwd: [["p", 5, 12, 19, 12], ["p", 15, 8, 19, 12, 15, 16]],
    stretch: [["p", 4, 12, 20, 12], ["p", 7, 9, 4, 12, 7, 15], ["p", 17, 9, 20, 12, 17, 15]],
    loop: [["b", 6, 9, 6, 5, 18, 5, 18, 9], ["p", 16, 7, 18, 9, 20, 7], ["b", 18, 15, 18, 19, 6, 19, 6, 15], ["p", 4, 17, 6, 15, 8, 17]],
    get: [["p", 12, 5, 12, 15], ["p", 8, 11, 12, 15, 16, 11], ["p", 5, 19, 19, 19]],
    check: [["p", 5, 12, 10, 17, 19, 7]],
    crop: [["p", 7, 3, 7, 17, 21, 17], ["p", 3, 7, 17, 7, 17, 21]],
    wiggle: [["p", 3, 12, 6, 6, 9, 18, 12, 6, 15, 18, 18, 6, 21, 12]],
    inertia: [["p", 3, 12, 6, 5, 9, 18, 12, 8, 15, 15, 18, 11, 21, 12]],
    bounce: [["p", 4, 6, 8, 19, 12, 9, 15, 19, 18, 13, 20, 19]],
    sine: [["b", 3, 12, 6, 3, 9, 3, 12, 12], ["b", 12, 12, 15, 21, 18, 21, 21, 12]],
    drift: [["p", 4, 12, 20, 12], ["p", 16, 8, 20, 12, 16, 16]],
    clear: [["p", 6, 6, 18, 18], ["p", 18, 6, 6, 18]],
    diamond: [["P", 12, 4, 20, 12, 12, 20, 4, 12]],
    save: [["r", 4, 4, 16, 16], ["r", 8, 4, 8, 5], ["r", 7, 13, 10, 7]],
    refresh: [["b", 18, 9, 16, 4, 7, 4, 5, 10], ["p", 3, 8, 5, 10, 8, 8], ["b", 6, 15, 8, 20, 17, 20, 19, 14], ["p", 21, 16, 19, 14, 16, 16]],
    star: [["P", 12, 3, 14.5, 9, 21, 9.5, 16, 14, 17.5, 21, 12, 17.5, 6.5, 21, 8, 14, 3, 9.5, 9.5, 9]],
    tag: [["P", 4, 4, 12, 4, 20, 12, 12, 20, 4, 12], ["o", 8, 8, 1]],
    pencil: [["P", 5, 19, 6, 14, 16, 4, 20, 8, 10, 18]],
    trash: [["p", 5, 7, 19, 7], ["P", 7, 7, 8, 20, 16, 20, 17, 7], ["p", 10, 4, 14, 4]],
    plusbox: [["r", 4, 4, 16, 16], ["p", 12, 8, 12, 16], ["p", 8, 12, 16, 12]],
    "import": [["p", 12, 4, 12, 15], ["p", 8, 11, 12, 15, 16, 11], ["p", 4, 19, 20, 19]],
    "export": [["p", 12, 15, 12, 4], ["p", 8, 8, 12, 4, 16, 8], ["p", 4, 19, 20, 19]],
    folder: [["P", 3, 7, 10, 7, 12, 9, 21, 9, 21, 19, 3, 19]],
    copy: [["r", 8, 8, 11, 11], ["p", 5, 15, 5, 5, 15, 5]],
    paste: [["r", 5, 6, 14, 15], ["r", 9, 3, 6, 4]],
    fitcomp: [["r", 4, 6, 16, 12], ["p", 8, 12, 16, 12], ["p", 10, 10, 8, 12, 10, 14], ["p", 14, 10, 16, 12, 14, 14]],
    motion: [["p", 12, 3, 12, 21], ["p", 3, 12, 21, 12], ["p", 6, 6, 18, 18], ["p", 18, 6, 6, 18]]
};
SAMA.font = function (g, size) {
    try { return ScriptUI.newFont(g.font.name, ScriptUI.FontStyle.REGULAR, size); } catch (e) { return g.font; }
};
SAMA.mulA = function (c) {
    if (SAMA.alpha >= 1) { return c; }
    return [c[0], c[1], c[2], (c[3] === undefined ? 1 : c[3]) * SAMA.alpha];
};
// Every custom-drawn control draws through this wrapper. The global fade factor SAMA.alpha is set back to 1 before and after each draw:
// if one draw failed half way, the factor used to stay near 0 and ALL custom controls (tabs, tiles, list) became invisible while the native ones stayed.
SAMA.safeDraw = function (fn) {
    return function () {
        var r;
        SAMA.alpha = 1;
        try { r = fn.apply(this, arguments); } catch (e) { SAMA.lastDrawErr = String(e); }
        SAMA.alpha = 1;
        return r;
    };
};
SAMA.hardenDraw = function (c) {
    var i;
    try { if (c.type === "customButton" && typeof c.onDraw === "function" && !c.samaSafe) { c.onDraw = SAMA.safeDraw(c.onDraw); c.samaSafe = true; } } catch (e0) { }
    try { if (c.children) { for (i = 0; i < c.children.length; i++) { SAMA.hardenDraw(c.children[i]); } } } catch (e1) { }
};
SAMA.brush = function (g, c) { return g.newBrush(g.BrushType.SOLID_COLOR, SAMA.mulA(c)); };
SAMA.pen = function (g, c, w) { return g.newPen(g.PenType.SOLID_COLOR, SAMA.mulA(c), w || 1); };
SAMA.skin = function (ctrl) {
    try { ctrl.graphics.backgroundColor = ctrl.graphics.newBrush(ctrl.graphics.BrushType.SOLID_COLOR, SAMA.C.bg); } catch (e) { }
};
SAMA.tint = function (ctrl, col) {
    col = col || SAMA.C.text;
    try { ctrl.samaTint = col; } catch (e0) { }
    try { ctrl.graphics.foregroundColor = ctrl.graphics.newPen(ctrl.graphics.PenType.SOLID_COLOR, col, 1); } catch (e) { }
};
SAMA.relayout = function () {
    try { SAMA.win.layout.layout(true); } catch (e) { }
    SAMA.fitScroll();
    if (SAMA.flowTextReset) { SAMA.flowTextReset(); }
};
SAMA.SB_W = 12;
SAMA.scrollY = 0;
SAMA.isContainer = function (c) { var t = c.type; return t === "group" || t === "panel" || t === "tab" || t === "tabbedpanel"; };
SAMA.normMargins = function (m) {
    if (m instanceof Array) { return m.length === 4 ? m : [0, 0, 0, 0]; }
    if (typeof m === "number") { return [m, m, m, m]; }
    return [0, 0, 0, 0];
};
// height the content really needs: sums children / spacing / margins of the VISIBLE parts only
SAMA.needH = function (c) {
    var i, k, h = 0, n = 0, ch, m, sp, ori, pv;
    if (SAMA.isContainer(c) && c.children && c.children.length) {
        m = SAMA.normMargins(c.margins); sp = (typeof c.spacing === "number") ? c.spacing : 0; ori = c.orientation;
        for (i = 0; i < c.children.length; i++) {
            k = c.children[i];
            if (k.visible === false) { continue; }
            ch = SAMA.needH(k); n++;
            if (ori === "column") { h += ch; } else { h = Math.max(h, ch); }
        }
        if (ori === "column" && n > 1) { h += sp * (n - 1); }
        return h + m[1] + m[3];
    }
    pv = c.preferredSize;
    if (pv && pv[1] > 0) { return pv[1]; }
    return (c.size && c.size.height > 0) ? c.size.height : 24;
};
// SCROLLING that never decides the width: After Effects' own layout manager sizes the panel exactly like it did before scrolling existed
// (top bar, then the body filling all the space the dock gives us).  Only when the current page is TALLER than the body do we
// (1) narrow that one page by the scrollbar width, (2) give it its natural height and (3) slide it up and down inside the clipping body.
// Real bottom edge of everything visible inside container c (children report their own position after layout), so wrapped text,
// wrapped rows and narrow-panel grids are counted exactly - an estimate from preferred sizes was too small and hid the last buttons.
SAMA.measureBottom = function (c, off) {
    var i, k, b, bt = 0, bb, r;
    if (!c || !c.children) { return 0; }
    for (i = 0; i < c.children.length; i++) {
        k = c.children[i];
        if (k.visible === false) { continue; }
        try { b = k.bounds; bb = (b.bottom !== undefined) ? b.bottom : b[3]; } catch (e) { continue; }
        if (typeof bb !== "number") { continue; }
        if (off + bb > bt) { bt = off + bb; }
        if (SAMA.isContainer(k) && k.children && k.children.length) {
            r = SAMA.measureBottom(k, off + ((b.top !== undefined) ? b.top : b[1]));
            if (r > bt) { bt = r; }
        }
    }
    return bt;
};
SAMA.fitScroll = function () {
    var b = SAMA.body, p = SAMA.page, sb = SAMA.sb, pw, ph, need, max;
    if (!b || !p || !sb) { return; }
    try {
        pw = b.size.width - 2 * SAMA.BODY_M;             // width: whatever the layout manager gave the body (never a fixed number)
        ph = b.size.height - 2 * SAMA.BODY_M;            // height we can show: the body, but never more than the dock really offers
        try { ph = Math.min(ph, SAMA.win.size.height - SAMA.bar.size.height - 2 * SAMA.BODY_M); } catch (e0) { }
        ph = Math.max(60, ph);
        p.size = [Math.max(60, pw), Math.max(60, ph)];   // start every measurement from the plain, un-stretched layout (keeps it repeatable)
        try { p.layout.layout(true); } catch (e00) { }
        if (p.fit) { try { p.fit(ph); p.layout.layout(true); } catch (e01) { } }
        if (p.compact) { try { p.size = [Math.max(60, pw), Math.max(60, Math.min(ph, Math.max(SAMA.needH(p), SAMA.measureBottom(p, 0))))]; p.layout.layout(true); } catch (e02) { } }      // a page with a growing list sizes it exactly: no leftover space for AE to spread out
        need = Math.max(SAMA.needH(p), SAMA.measureBottom(p, 0));
        if (need > ph + 1) { need += 6; }                  // a little breathing room under the last row
        max = Math.max(0, Math.round(need - ph));
        if (SAMA.scrollY > max) { SAMA.scrollY = max; }
        if (SAMA.scrollY < 0) { SAMA.scrollY = 0; }
        sb.sMax = max; sb.visible = max > 0;
        if (max > 0) {
            p.size = [Math.max(60, pw), need];
            try { p.layout.layout(true); } catch (e1) { }
            p.location = [SAMA.BODY_M, SAMA.BODY_M - SAMA.scrollY];
            sb.preferredSize = [SAMA.SB_W, ph]; sb.size = [SAMA.SB_W, ph]; sb.location = [b.size.width - SAMA.SB_W - 1, SAMA.BODY_M];
            sb.sVal = SAMA.scrollY; SAMA.pokeBar();
        } else {
            SAMA.scrollY = 0;                            // nothing to scroll: leave the page exactly where the layout manager put it
        }
    } catch (e) { }
};
SAMA.BODY_M = 12;
SAMA.rrect = function (g, x, y, w, h, r) {
    var n = 5, i, k, a, c, cs;
    r = Math.max(0, Math.min(r, w / 2, h / 2));
    cs = [[x + w - r, y + r, -90], [x + w - r, y + h - r, 0], [x + r, y + h - r, 90], [x + r, y + r, 180]];
    g.newPath(); g.moveTo(x + r, y);
    for (k = 0; k < 4; k++) {
        c = cs[k];
        for (i = 0; i <= n; i++) { a = (c[2] + 90 * i / n) * Math.PI / 180; g.lineTo(c[0] + r * Math.cos(a), c[1] + r * Math.sin(a)); }
    }
    g.closePath();
};
// one icon shape as a list of points (closed = the last point joins the first); used for gradient icons
SAMA.iconPts = function (q, ox, oy, s) {
    var t = q[0], pts = [], j, k, u, n, a, r, closed = false;
    if (t === "p" || t === "P") {
        for (j = 1; j < q.length; j += 2) { pts.push([ox + q[j] * s, oy + q[j + 1] * s]); }
        closed = (t === "P");
    } else if (t === "r") {
        pts = [[ox + q[1] * s, oy + q[2] * s], [ox + (q[1] + q[3]) * s, oy + q[2] * s], [ox + (q[1] + q[3]) * s, oy + (q[2] + q[4]) * s], [ox + q[1] * s, oy + (q[2] + q[4]) * s]]; closed = true;
    } else if (t === "o") {
        r = q[3] * s;
        for (k = 0; k < 20; k++) { a = k / 20 * 2 * Math.PI; pts.push([ox + q[1] * s + r * Math.cos(a), oy + q[2] * s + r * Math.sin(a)]); }
        closed = true;
    } else if (t === "b") {
        for (k = 0; k <= 12; k++) {
            u = k / 12; n = 1 - u;
            pts.push([ox + (n * n * n * q[1] + 3 * n * n * u * q[3] + 3 * n * u * u * q[5] + u * u * u * q[7]) * s, oy + (n * n * n * q[2] + 3 * n * n * u * q[4] + 3 * n * u * u * q[6] + u * u * u * q[8]) * s]);
        }
    }
    return { pts: pts, closed: closed };
};
SAMA.drawIcon = function (g, name, cx, cy, size, col, lw) {
    var sh = SAMA.icons[name], pen, s = size / 24, ox = cx - size / 2, oy = cy - size / 2, i, j, q, t, k, u, n, C = SAMA.C, grad, sp, c1, w = lw || 1.4;
    if (!sh) { return; }
    grad = !!C.grad2 && (col === C.title || col === C.text);          // icons in the theme colour follow the gradient (left to right); icons on a highlight keep their own colour
    c1 = col;
    pen = SAMA.pen(g, col, w);
    for (i = 0; i < sh.length; i++) {
        q = sh[i]; t = q[0];
        if (grad) {
            sp = SAMA.iconPts(q, ox, oy, s);
            for (j = 1; j < sp.pts.length + (sp.closed ? 1 : 0); j++) {
                k = sp.pts[j - 1]; u = sp.pts[j % sp.pts.length];
                g.newPath(); g.moveTo(k[0], k[1]); g.lineTo(u[0], u[1]);
                g.strokePath(SAMA.pen(g, SAMA.mix(c1, C.grad2, SAMA.sweepT((k[0] + u[0]) / 2, ox, ox + size)), w));
            }
            continue;
        }
        g.newPath();
        if (t === "p" || t === "P") {
            g.moveTo(ox + q[1] * s, oy + q[2] * s);
            for (j = 3; j < q.length; j += 2) { g.lineTo(ox + q[j] * s, oy + q[j + 1] * s); }
            if (t === "P") { g.closePath(); }
        } else if (t === "r") {
            g.rectPath(ox + q[1] * s, oy + q[2] * s, q[3] * s, q[4] * s);
        } else if (t === "o") {
            g.ellipsePath(ox + (q[1] - q[3]) * s, oy + (q[2] - q[3]) * s, q[3] * 2 * s, q[3] * 2 * s);
        } else if (t === "b") {
            g.moveTo(ox + q[1] * s, oy + q[2] * s);
            for (k = 1; k <= 12; k++) {
                u = k / 12; n = 1 - u;
                g.lineTo(ox + (n * n * n * q[1] + 3 * n * n * u * q[3] + 3 * n * u * u * q[5] + u * u * u * q[7]) * s,
                         oy + (n * n * n * q[2] + 3 * n * n * u * q[4] + 3 * n * u * u * q[6] + u * u * u * q[8]) * s);
            }
        }
        g.strokePath(pen);
    }
};
// draw text, shortened with ".." to fit maxW; center = true treats x as the centre
SAMA.TITLE_FONT = "Agency FB";            // installed with Microsoft Office; if it is missing the normal panel font is used
// The dragon mark, traced to polygons on a 24-unit grid (centred on 0,0) so it can be filled with the theme colour at any size.
SAMA.LOGO = [
    [-9, 1.9, -9.2, 3.4, -9.1, 6.3, -8.6, 8.5, -7.6, 10.4, -7.6, 8.4, -7.2, 7.2, -6.2, 8.6, -5, 9.8, -3.7, 10.8, -2.2, 11.5, -1.1, 11.8, 0.3, 11.9, 1.4, 11.7, 2.5, 10.8, 3, 9.8, 3.1, 8.7, 2.6, 7.3, 1.7, 6.3, 0.1, 5.5, 0.9, 6.4, 1.1, 7.2, 0.8, 8, -0.2, 8.7, -2.1, 8.8, -3.7, 8.3, -5.1, 7.6, -7, 5.9, -7, 5.7, -6.2, 5.9, -4.8, 5.9, -6.2, 4.9],
    [-1.4, -1.2, -2.2, -0.5, -2.6, 0.2, -2.6, 1.1, -2.2, 2, -1.2, 3, 3.4, 5.8, 4.3, 6.7, 4.7, 7.4, 4.9, 9.1, 4.7, 9.8, 3.6, 11.2, 5.2, 10.4, 6.2, 9.4, 6.8, 8.2, 6.9, 6.3, 6.2, 5, 6.9, 4.9, 8.2, 5.4, 7.7, 4.4, 6.3, 3.2, 1.6, 1.9, 0.5, 1.4, -0.2, 0.8, -0.5, 0.1, -0.3, -0.7, 0.2, -1.3, -0.9, -1.4],
    [-3.2, -10.7, -2.1, -8.6, -0.9, -7.1, -4.7, -6.8, -2.8, -6.4, -2.7, -6.3, -5.7, -5.2, -7.7, -3.6, -6.6, -3.9, -5.7, -3.8, -6.6, -1.8, -6.6, -0.5, -6.4, 0.9, -5.3, 2.9, -4.2, 3.9, -2.9, 4.7, -4, 2.6, -4.2, 0.8, -3.5, -1.1, -2.1, -2.6, -0.6, -3.2, 0.5, -3.2, 1, -2.8, 1, -2.2, 0.8, -1.8, 2.2, -2.5, 3.2, -2.5, 3.5, -2, 3.2, -1.5, 4.8, -2, 6.5, -2.1, 6.9, -1.8, 6.4, -1, 8.6, -1.8, 9.1, -2.5, 9.1, -3.1, 8.6, -4.1, 7.6, -5, 7.2, -4.5, 6.3, -4.7, 5.2, -5.6, 4.7, -6.5, 2.3, -7.1, 0, -8.2, -1.5, -9.2],
    [-0.2, -12, 0.2, -10.3, 1.1, -8.4, 2.1, -7.9, 3.6, -7.5, 1.1, -10]
];
SAMA.LOGO_HOLES = [
    [2.8, -5.8, 3.9, -5.7, 4.8, -4.9, 3.7, -4.9]
];
// col2 / x0 / x1 (optional): a gradient from col at x0 to col2 at x1 across the logo and the title
SAMA.sweepT = function (x, x0, x1) { return x1 > x0 ? Math.max(0, Math.min(1, (x - x0) / (x1 - x0))) : 0; };
SAMA.drawLogo = function (g, cx, cy, size, col, col2, x0, x1) {
    var k = size / 24, i, j, p, bg = SAMA.C.bg, sx;
    function poly(pts, c) {
        if (col2 && c === col) {                                          // each piece of the logo takes the colour of its place in the sweep
            sx = 0; for (j = 0; j < pts.length; j += 2) { sx += pts[j]; }
            c = SAMA.mix(col, col2, SAMA.sweepT(cx + (sx / (pts.length / 2)) * k, x0, x1));
        }
        g.newPath(); g.moveTo(cx + pts[0] * k, cy + pts[1] * k);
        for (j = 2; j < pts.length; j += 2) { g.lineTo(cx + pts[j] * k, cy + pts[j + 1] * k); }
        g.closePath(); g.fillPath(SAMA.brush(g, c));
    }
    for (i = 0; i < SAMA.LOGO.length; i++) { poly(SAMA.LOGO[i], col); }
    if (size >= 14) { for (i = 0; i < SAMA.LOGO_HOLES.length; i++) { poly(SAMA.LOGO_HOLES[i], bg); } }      // the eye: only when big enough to be seen
};
SAMA.titleReal = undefined;
SAMA.titleOk = function (g) {             // did the title font really load? (a missing font silently becomes the normal one, which has a different width)
    var a, b;
    if (SAMA.titleReal !== undefined) { return SAMA.titleReal; }
    SAMA.titleReal = false;
    try {
        a = g.measureString("SAMA", ScriptUI.newFont(SAMA.TITLE_FONT, ScriptUI.FontStyle.BOLD, 20))[0];
        b = g.measureString("SAMA", ScriptUI.newFont(g.font.name, ScriptUI.FontStyle.BOLD, 20))[0];
        SAMA.titleReal = Math.abs(a - b) > 1.5;
    } catch (e) { SAMA.titleReal = false; }
    return SAMA.titleReal;
};
SAMA.titleWidth = function (g, small) {
    var fs = small ? 16 : 20;
    try { if (SAMA.titleOk(g)) { return g.measureString("SAMA", SAMA.titleFont(g, fs))[0]; } } catch (e) { }
    return 4 * 18 * (small ? 0.58 : 0.72);
};
SAMA.drawTitle = function (g, x, cy, col, small, col2, x0, x1) {
    var L = [
        [[13, 1], [3, 1], [1, 3], [1, 8], [3, 10], [11, 10], [13, 12], [13, 17], [11, 19], [1, 19]],      // S
        [[1, 19], [1, 5], [4, 1], [10, 1], [13, 5], [13, 19]], [[1, 12], [13, 12]],                          // A
        [[1, 19], [1, 1], [7, 10], [13, 1], [13, 19]]                                                        // M
    ], order = [0, 1, 3, 1], k = small ? 0.58 : 0.72, adv = 18 * k, i, j, q, pts, pen, ox, oy = cy - 10 * k;
    if (SAMA.titleOk(g)) {
        var fs = small ? 16 : 20, tf = SAMA.titleFont(g, fs), ci, ch, cx0, cw;
        if (col2) {                                                           // gradient: every letter takes the colour of its place in the sweep
            for (ci = 0; ci < 4; ci++) {
                ch = "SAMA".charAt(ci); cx0 = x + (ci ? g.measureString("SAMA".substr(0, ci), tf)[0] : 0); cw = g.measureString(ch, tf)[0];
                SAMA.text(g, ch, tf, SAMA.mix(col, col2, SAMA.sweepT(cx0 + cw / 2, x0, x1)), cx0, cy - fs * 0.6, 40, false);
            }
            return;
        }
        SAMA.text(g, "SAMA", tf, col, x, cy - fs * 0.6, 64, false); return;
    }
    pen = SAMA.pen(g, col, small ? 2.1 : 2.6);
    for (i = 0; i < 4; i++) {
        ox = x + i * adv;
        if (col2) { pen = SAMA.pen(g, SAMA.mix(col, col2, SAMA.sweepT(ox + 6 * k, x0, x1)), small ? 2.1 : 2.6); }
        q = (order[i] === 0) ? [0] : (order[i] === 1 ? [1, 2] : [3]);
        for (j = 0; j < q.length; j++) {
            pts = L[q[j]];
            g.newPath(); g.moveTo(ox + pts[0][0] * k, oy + pts[0][1] * k);
            for (var m = 1; m < pts.length; m++) { g.lineTo(ox + pts[m][0] * k, oy + pts[m][1] * k); }
            g.strokePath(pen);
        }
    }
};
SAMA.titleFont = function (g, size) {
    var f = null;
    try { f = ScriptUI.newFont(SAMA.TITLE_FONT, ScriptUI.FontStyle.BOLD, size); } catch (e) { f = null; }
    return f || SAMA.font(g, size - 3);
};
// Older After Effects builds (2020) / other display settings report taller text lines, which made every label sit too low.
// A line is assumed to be about 1.3 x the font size tall; if the measured height is clearly taller the text is lifted by half the excess.
SAMA.textDy = 0;
SAMA.textShiftCache = {};
SAMA.textShift = function (g, f) {
    var key, sz, th, ex, v;
    try { sz = f.size || 10; key = (f.name || "") + "|" + sz; } catch (e0) { return SAMA.textDy; }
    v = SAMA.textShiftCache[key];
    if (v === undefined) {
        v = 0;
        try { th = g.measureString("Ag", f)[1]; ex = th - sz * 1.3; if (ex > 2) { v = Math.min(12, Math.round(ex / 2)); } } catch (e1) { v = 0; }
        SAMA.textShiftCache[key] = v;
    }
    return v + SAMA.textDy;
};
SAMA.text = function (g, s, f, col, x, y, maxW, center) {
    var t = String(s), w = g.measureString(t, f)[0];
    if (w > maxW) {
        while (t.length > 1 && g.measureString(t + "..", f)[0] > maxW) { t = t.substr(0, t.length - 1); }
        t += ".."; w = g.measureString(t, f)[0];
    }
    g.drawString(t, SAMA.pen(g, col, 1), center ? x - w / 2 : x, y - SAMA.textShift(g, f), f);
};
SAMA.ui.section = function (parent, text) {
    var s = parent.add("statictext", undefined, String(text).toUpperCase());
    s.alignment = ["fill", "top"]; SAMA.tint(s, SAMA.C.dim);
    try { s.graphics.font = ScriptUI.newFont(s.graphics.font.name, ScriptUI.FontStyle.REGULAR, 10); } catch (e) { }
    return s;
};
SAMA.ui.tint = function (ctrl) { SAMA.tint(ctrl, SAMA.C.text); };

// ---- tile: one custom-drawn button.  d = { kind, icon, glyph, label, tip, fn, isSel, c }
//      kind: tile (default) | primary | pill | toggle | curve
// Mouse-wheel scrolling from any control (only effective if this After Effects build delivers wheel events to ScriptUI).
SAMA.hookScroll = function (c) {
    function wheel(ev) {
        var d = 0;
        if (!SAMA.sb || !SAMA.sb.visible) { return; }
        if (ev.deltaY !== undefined && ev.deltaY !== 0) { d = ev.deltaY; }
        else if (ev.wheelDelta !== undefined && ev.wheelDelta !== 0) { d = -ev.wheelDelta; }
        else if (ev.detail) { d = ev.detail; }
        if (d) { SAMA.scrollTo(SAMA.scrollY + (d > 0 ? 1 : -1) * 60); }
        try { ev.preventDefault(); } catch (e) { }
    }
    try { c.addEventListener("mousewheel", wheel); } catch (e1) { }
    try { c.addEventListener("wheel", wheel); } catch (e2) { }
};
SAMA.wasDrag = function () { return false; };
SAMA.tile = function (parent, d, w, h) {
    var b = parent.add("customButton", undefined, ""), st = { hot: false, down: false }, C = SAMA.C;
    b.preferredSize = [w || 60, h || 50]; b.minimumSize = [d.minW || w || 60, h || 50]; b.alignment = ["fill", "center"];
    if (d.tip) { b.helpTip = d.tip; }
    function redraw() { try { b.notify("onDraw"); } catch (e) { } }
    b.redraw = redraw;
    b.onDraw = function () {
        var g = this.graphics, W = this.size.width, H = this.size.height, kind = d.kind || "tile";
        var sel = d.isSel ? d.isSel() : !!d.sel, fill = C.tile, ink = sel ? C.title : C.text, lab = (typeof d.label === "function") ? d.label() : (d.label || "");
        var f = SAMA.font(g, 10), i, pt, x0, x1, y0, y1, top, tw, lineCol, gA, gB, gp;
        if (kind === "primary") { fill = st.down ? C.dim : (st.hot ? SAMA.lift(C.title) : C.title); ink = C.onHi; }
        else if ((kind === "pill" || kind === "toggle") && sel) { fill = C.title; ink = C.onHi; }
        else { fill = st.down ? C.down : (st.hot ? C.hot : C.tile); }
        g.newPath(); g.rectPath(0, 0, W, H); g.fillPath(SAMA.brush(g, C.bg));
        if (C.grad2 && (kind === "primary" || ((kind === "pill" || kind === "toggle") && sel))) {        // gradient highlight (only when a second colour is set)
            gA = C.title; gB = C.grad2;
            if (kind === "primary" && st.down) { gA = SAMA.mix(gA, [0, 0, 0, 1], 0.4); gB = SAMA.mix(gB, [0, 0, 0, 1], 0.4); }
            else if (kind === "primary" && st.hot) { gA = SAMA.lift(gA); gB = SAMA.lift(gB); }
            SAMA.gradFill(g, 0.5, 0.5, W - 1, H - 1, kind === "pill" ? H / 2 : 8, gA, gB, C.bg);
        } else {
            SAMA.rrect(g, 0.5, 0.5, W - 1, H - 1, kind === "pill" ? H / 2 : 8); g.fillPath(SAMA.brush(g, fill));
        }
        lineCol = (sel && (kind === "tile" || kind === "curve")) ? C.title : C.line;
        SAMA.rrect(g, 0.5, 0.5, W - 1, H - 1, kind === "pill" ? H / 2 : 8); g.strokePath(SAMA.pen(g, lineCol, 1));

        if (kind === "curve" && d.c) {
            x0 = 12; x1 = W - 12; y0 = 8; y1 = H - 19;
            gp = [];
            for (i = 0; i <= 24; i++) {
                pt = SAMA.bez(d.c, i / 24);
                pt[1] = SAMA.clamp(pt[1], -0.35, 1.35);
                gp.push([x0 + pt[0] * (x1 - x0), y1 - (pt[1] + 0.35) / 1.7 * (y1 - y0)]);
            }
            if (C.grad2) { SAMA.strokeGrad(g, gp, C.title, C.grad2, 1.6); }          // every small curve picture takes the gradient
            else {
                g.newPath(); g.moveTo(gp[0][0], gp[0][1]);
                for (i = 1; i < gp.length; i++) { g.lineTo(gp[i][0], gp[i][1]); }
                g.strokePath(SAMA.pen(g, sel ? C.accent : C.text, 1.6));
            }
            SAMA.text(g, lab, f, sel ? C.title : C.dim, W / 2, H - 15, W - 6, true);
        } else if (kind === "primary" || kind === "pill" || kind === "toggle") {
            tw = g.measureString(lab, f)[0];
            if (d.left && d.icon) {                                  // icon and text start at the same place as the other left-aligned buttons
                SAMA.drawIcon(g, d.icon, 16, H / 2, 16, ink, 1.6);
                SAMA.text(g, lab, f, ink, 30, (H - 13) / 2, W - 34, false);
            } else {
            if (d.icon && kind === "primary") {
                SAMA.drawIcon(g, d.icon, Math.max(16, W / 2 - tw / 2 - 12), H / 2, 15, ink, 1.6);
            }
            SAMA.text(g, lab, f, ink, W / 2 + (d.icon && kind === "primary" ? 8 : 0), (H - 13) / 2, (d.icon && kind === "primary") ? W - 34 : W - 8, true);
            }
        } else if (d.glyph) {
            SAMA.text(g, d.glyph, SAMA.font(g, 14), ink, W / 2, (lab && H >= 44) ? H / 2 - 16 : (H - 17) / 2, W - 6, true);
            if (lab && H >= 44) { SAMA.text(g, lab, f, C.dim, W / 2, H / 2 + 6, W - 6, true); }
        } else if (d.icon && lab && H >= 44) {
            top = (H - 33) / 2;
            SAMA.drawIcon(g, d.icon, W / 2, top + 9, 18, ink, 1.5);
            SAMA.text(g, lab, f, sel ? C.title : C.dim, W / 2, top + 22, W - 6, true);
        } else if (d.icon && lab) {
            SAMA.drawIcon(g, d.icon, 16, H / 2, 16, ink, 1.5);
            SAMA.text(g, lab, f, ink, 30, (H - 13) / 2, W - 34, false);
        } else if (d.icon) {
            SAMA.drawIcon(g, d.icon, W / 2, H / 2, 18, ink, 1.5);
        } else {
            SAMA.text(g, lab, f, ink, W / 2, (H - 13) / 2, W - 6, true);
        }
    };
    b.addEventListener("mouseover", function () { st.hot = true; redraw(); });
    b.addEventListener("mouseout", function () { st.hot = false; st.down = false; redraw(); });
    b.addEventListener("mousedown", function () { st.down = true; redraw(); });
    b.addEventListener("mouseup", function () { st.down = false; redraw(); });
    SAMA.hookScroll(b);
    b.onClick = function () { if (SAMA.wasDrag()) { return; } if (d.fn) { d.fn(); } redraw(); };
    return b;
};
// ---- grid: tiles in rows; the column count follows the panel width (portrait panels get 2-3 columns)
SAMA.grid = function (parent, defs, o) {
    o = o || {};
    var gr = { defs: defs, cols: o.cols || 3, tiles: [] }, gap = 8, minW = o.minW || 64, h = o.h || 50, box;
    box = parent.add("group"); box.orientation = "column"; box.alignChildren = ["fill", "top"];
    box.alignment = ["fill", "top"]; box.spacing = gap; box.margins = 0;
    gr.box = box;
    gr.build = function () {
        var i, r = null, k, sp;
        while (box.children.length) { box.remove(box.children[0]); }
        gr.tiles = [];
        for (i = 0; i < gr.defs.length; i++) {
            if (i % gr.cols === 0) {
                r = box.add("group"); r.orientation = "row"; r.alignChildren = ["fill", "center"];
                r.alignment = ["fill", "top"]; r.spacing = gap; r.margins = 0;
            }
            gr.tiles.push(SAMA.tile(r, gr.defs[i], minW, h));
        }
        k = gr.defs.length % gr.cols;
        if (r && k && gr.defs.length > gr.cols) {
            for (i = k; i < gr.cols; i++) { sp = r.add("group"); sp.alignment = ["fill", "center"]; sp.preferredSize = [minW, h]; sp.minimumSize = [minW, h]; }
        }
    };
    gr.reflow = function () {
        var wd = 0, c;
        try { wd = parent.size.width; } catch (e) { }
        if (!wd || wd < 40) { return false; }
        c = SAMA.clamp(Math.floor((wd + gap) / (minW + gap)), (o.minCols === undefined ? 1 : o.minCols), o.maxCols || 6);
        if (c === gr.cols) { return false; }
        gr.cols = c; gr.build(); return true;
    };
    gr.redraw = function () { var i; for (i = 0; i < gr.tiles.length; i++) { gr.tiles[i].redraw(); } };
    gr.setDefs = function (d) { gr.defs = d; gr.build(); };
    gr.build();
    SAMA.reflowers.push(gr.reflow);
    return gr;
};
// ---- wrapping row: tiles of different widths on one line, wrapping to a second line when the panel is too narrow.
//      add with SAMA.rowAdd(list, fixedWidth, def, prefWidth, height), then SAMA.flowRow(parent, list, { h: height })
SAMA.rowAdd = function (list, fixedW, d, w, h) {
    d.w = w; d.fixed = fixedW; d.h = h; d.minW = d.minW || Math.min(w, 28);
    list.push(d); return d;
};
SAMA.flowRow = function (parent, defs, o) {
    o = o || {};
    var fr = { sig: "", tiles: [] }, gap = 8, h = o.h || 30, box;
    box = parent.add("group"); box.orientation = "column"; box.alignChildren = ["fill", "top"];
    box.alignment = ["fill", "top"]; box.spacing = gap; box.margins = 0;
    function plan(wd) {
        var lines = [[]], used = 0, i, d, m, cur;
        for (i = 0; i < defs.length; i++) {
            d = defs[i]; m = d.w || d.minW || 40; cur = lines[lines.length - 1];   // tiles are never squeezed below their preferred width, so budget with it
            if (cur.length && used + gap + m > wd) { lines.push([]); cur = lines[lines.length - 1]; used = 0; }
            used += (cur.length ? gap : 0) + m; cur.push(i);
        }
        return lines;
    }
    fr.build = function (wd) {
        var lines = plan(wd), li, k, r, d, t, sig = "";
        for (li = 0; li < lines.length; li++) { sig += lines[li].length + ","; }
        if (sig === fr.sig) { return false; }
        fr.sig = sig;
        while (box.children.length) { box.remove(box.children[0]); }
        fr.tiles = [];
        for (li = 0; li < lines.length; li++) {
            r = box.add("group"); r.orientation = "row"; r.alignChildren = ["fill", "center"]; r.alignment = ["fill", "top"]; r.spacing = gap; r.margins = 0;
            for (k = 0; k < lines[li].length; k++) {
                d = defs[lines[li][k]];
                t = SAMA.tile(r, d, d.w || 60, d.h || h);
                t.alignment = d.fixed ? ["left", "center"] : ["fill", "center"];
                fr.tiles.push(t);
            }
        }
        return true;
    };
    fr.reflow = function () {
        var wd = 0;
        try { wd = parent.size.width; } catch (e) { }
        if (!wd || wd < 40) { return false; }
        return fr.build(wd);
    };
    fr.redraw = function () { var i; for (i = 0; i < fr.tiles.length; i++) { fr.tiles[i].redraw(); } };
    fr.build(o.width || 300);
    SAMA.reflowers.push(fr.reflow);
    return fr;
};
// ---- top bar: brand + three round icon tabs (the white highlight slides) + animation switch
SAMA.topBar = function (parent, icons, tips, getIdx, onPick, onToggle) {
    var cv = parent.add("customButton", undefined, ""), hot = -1, C = SAMA.C, curX = null;
    cv.preferredSize = [200, 46]; cv.minimumSize = [100, 40]; cv.alignment = ["fill", "top"];
    var lastW = -1;
    function tier() { var w = 400; try { w = cv.size.width; } catch (e) { } return w >= 310 ? 0 : (w >= 250 ? 1 : 2); }      // 0 roomy, 1 compact, 2 tiny (title and icons packed tighter)
    function narrow() { return tier() > 0; }
    function slot(k) { var t = tier(); return t === 0 ? 112 + k * 40 : (t === 1 ? 98 + k * 32 : 94 + k * 30); }
    function xs(i) { if (i === 3) { return gearX(); } if (i === 5) { return slot(3); } if (i >= 4) { return -60; } return slot(i); }
    function gearX() { var w = 0; try { w = cv.size.width; } catch (e) { } return Math.max(w - 24, slot(icons.length - 1) + 32); }
    function redraw() { try { cv.notify("onDraw"); } catch (e) { } }
    cv.redraw = redraw;
    cv.xOf = xs;
    cv.setX = function (x) { curX = x; redraw(); };
    cv.onDraw = function () {
        var g = this.graphics, W = this.size.width, H = this.size.height, i, x, d, keep = SAMA.alpha, gx0, gx1;
        SAMA.alpha = 1;
        if (curX === null || W !== lastW) { curX = xs(getIdx()); lastW = W; }
        g.newPath(); g.rectPath(0, 0, W, H); g.fillPath(SAMA.brush(g, C.bg));
        if (C.grad2) {                                                                // gradient across the logo and the title
            gx0 = (narrow() ? 18 : 20) - (narrow() ? 8 : 10); gx1 = (narrow() ? 31 : 34) + SAMA.titleWidth(g, narrow());
            SAMA.drawLogo(g, narrow() ? 18 : 20, H / 2, narrow() ? 16 : 20, C.title, C.grad2, gx0, gx1);
            SAMA.drawTitle(g, narrow() ? 31 : 34, H / 2, C.title, narrow(), C.grad2, gx0, gx1);
        } else {
            SAMA.drawLogo(g, narrow() ? 18 : 20, H / 2, narrow() ? 16 : 20, C.title);
            SAMA.drawTitle(g, narrow() ? 31 : 34, H / 2, C.title, narrow());
        }
        for (i = 0; i < icons.length; i++) {
            if (hot === i) { g.newPath(); g.ellipsePath(slot(i) - 15, H / 2 - 15, 30, 30); g.fillPath(SAMA.brush(g, C.hot)); }
        }
        if (hot === 99) { g.newPath(); g.ellipsePath(gearX() - 15, H / 2 - 15, 30, 30); g.fillPath(SAMA.brush(g, C.hot)); }       // under the selected circle (like the other tabs), so the open Settings page keeps its highlight
        if (C.grad2) { SAMA.gradFill(g, curX - 15, H / 2 - 15, 30, 30, 15, C.title, C.grad2, C.bg); }
        else { g.newPath(); g.ellipsePath(curX - 15, H / 2 - 15, 30, 30); g.fillPath(SAMA.brush(g, C.title)); }
        for (i = 0; i < icons.length; i++) {
            x = slot(i); d = Math.abs(curX - x);
            SAMA.drawIcon(g, icons[i], x, H / 2, 16, d < 12 ? C.onHi : C.dim, 1.6);
        }
        SAMA.drawIcon(g, "gear", gearX(), H / 2, 16, Math.abs(curX - gearX()) < 12 ? C.onHi : (hot === 99 ? C.text : C.dim), 1.5);
        g.newPath(); g.moveTo(0, H - 0.5); g.lineTo(W, H - 0.5); g.strokePath(SAMA.pen(g, C.line, 1));
        SAMA.alpha = keep;
        if (SAMA.sizeCheck) { SAMA.sizeCheck(); }
    };
    function pick(x) {
        var i;
        if (Math.abs(x - gearX()) <= 16) { return 99; }
        for (i = 0; i < icons.length; i++) { if (Math.abs(x - slot(i)) <= 17) { return i; } }
        return -1;
    }
    cv.addEventListener("mousedown", function (ev) {
        var i = pick(ev.clientX);
        if (i === 99) { onPick(99); }
        else if (i >= 0) { onPick(i); }
    });
    cv.addEventListener("mousemove", function (ev) {
        var i = pick(ev.clientX);
        if (i !== hot) { hot = i; cv.helpTip = (i === 99 ? "Settings" : (i >= 0 ? tips[i] : "")); redraw(); }
    });
    cv.addEventListener("mouseout", function () { hot = -1; redraw(); });
    return cv;
};

/* ==========================================================================
   PANEL 1 - SAMA FLOW  (easing graph editor)
   ========================================================================== */
SAMA.flowPresets = [
    { name: "Linear", c: [0, 0, 1, 1] },
    { name: "Easy Ease", c: [0.33, 0, 0.67, 1] },
    { name: "Ease In", c: [0.42, 0, 1, 1] },
    { name: "Ease Out", c: [0, 0, 0.58, 1] },
    { name: "Ease In-Out", c: [0.42, 0, 0.58, 1] },
    { name: "Sine Out", c: [0.39, 0.575, 0.565, 1] },
    { name: "Cubic Out", c: [0.215, 0.61, 0.355, 1] },
    { name: "Expo In", c: [0.7, 0, 0.84, 0] },
    { name: "Expo Out", c: [0.16, 1, 0.3, 1] },
    { name: "Expo In-Out", c: [0.87, 0, 0.13, 1] },
    { name: "Back In", c: [0.36, 0, 0.66, -0.56] },
    { name: "Back Out", c: [0.34, 1.56, 0.64, 1] },
    { name: "Back In-Out", c: [0.68, -0.6, 0.32, 1.6] },
    { name: "Bounce Out (approx)", c: [0.25, 1.75, 0.5, 0.95] },
    { name: "Bounce In (approx)", c: [0.5, 0.05, 0.75, -0.75] }
];
SAMA.flowDefault = [0.33, 0, 0.67, 1];
SAMA.flowYMin = -0.8; SAMA.flowYMax = 1.8;

SAMA.parseCurve = function (txt) {
    var parts = String(txt).replace(/[\(\)]/g, "").split(/[\s,;]+/), out = [], i, n;
    for (i = 0; i < parts.length; i++) { if (parts[i] !== "") { n = parseFloat(parts[i]); if (!isNaN(n)) { out.push(n); } } }
    if (out.length !== 4) { return null; }
    out[0] = SAMA.clamp(out[0], 0, 1); out[2] = SAMA.clamp(out[2], 0, 1);
    out[1] = SAMA.clamp(out[1], SAMA.flowYMin, SAMA.flowYMax); out[3] = SAMA.clamp(out[3], SAMA.flowYMin, SAMA.flowYMax);
    return out;
};
SAMA.curveText = function (c) {
    return SAMA.round3(c[0]) + ", " + SAMA.round3(c[1]) + ", " + SAMA.round3(c[2]) + ", " + SAMA.round3(c[3]);
};

SAMA.buildFlow = function (root) {
    var C = SAMA.C, ui = SAMA.ui, PAD = 10, LM = 38, drag = -1, selName = "";
    var curve = SAMA.parseCurve(SAMA.settings.get("flow.curve", SAMA.curveText(SAMA.flowDefault))) || SAMA.flowDefault.slice(0);
    var custom = SAMA.settings.getJSON("flow.custom", []);
    var mode = SAMA.settings.get("flow.mode", "built");
    var opt = { out: SAMA.settings.get("flow.out", "1") === "1", inn: SAMA.settings.get("flow.in", "1") === "1" };
    var cv, txt, grid, rowE, rowE2, rowO, rowP, chkLive;
    function liveApply() {                                                              // "Apply while editing graph": push the finished curve onto the selected keyframes
        if (chkLive && chkLive.value) { SAMA.applyCurveQuiet(curve.slice(0), { doIn: opt.inn, doOut: opt.out }); }
    }

    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.margins = 0; root.spacing = 10;

    // ---- graph canvas (takes all spare height)
    cv = root.add("customButton", undefined, "");
    cv.preferredSize = [240, 250]; cv.minimumSize = [120, 120]; cv.alignment = ["fill", "fill"];
    cv.helpTip = "Drag the two handles to shape the easing curve";
    // The graph box is as big as the space allows: the visible y-range hugs 0..1 (a little headroom) and only grows for curves that overshoot.
    // It is re-fitted whenever the curve is set (preset, typing, release) and stays fixed while a handle is being dragged.
    var vMin = -0.2, vMax = 1.2;
    function fitView(c) {
        var lo = Math.min(c[1], c[3]), hi = Math.max(c[1], c[3]);
        vMin = Math.min(-0.2, lo - 0.12); vMax = Math.max(1.2, hi + 0.12);
        vMin = Math.max(vMin, SAMA.flowYMin); vMax = Math.min(vMax, SAMA.flowYMax);
    }
    fitView(curve);
    function boxH() { return cv.size.height - 2 * PAD; }                                   // pixels for the whole y-range
    function unitH() { return boxH() / (vMax - vMin); }                                   // pixels for one unit of y
    function boxW() { return Math.max(40, Math.min(cv.size.width - LM - PAD, unitH() * 2.2)); }   // keeps a pleasant proportion on wide panels
    function boxX() { return LM + (cv.size.width - LM - PAD - boxW()) / 2; }
    function px(x) { return boxX() + x * boxW(); }
    function py(y) { return PAD + (vMax - y) * unitH(); }
    function ux(v) { return (v - boxX()) / boxW(); }
    SAMA.flowGeo = { px: px, py: py };                                                      // (used by the self-tests)
    function uy(v) { return vMax - (v - PAD) / unitH(); }
    cv.onDraw = function () {
        var g = this.graphics, W = this.size.width, H = this.size.height, i, pt, k, pen, steps, gp;
        g.newPath(); g.rectPath(0, 0, W, H); g.fillPath(SAMA.brush(g, C.bg));
        SAMA.rrect(g, 0.5, 0.5, W - 1, H - 1, 10); g.fillPath(SAMA.brush(g, C.tile));
        g.newPath(); g.rectPath(px(0), py(1), px(1) - px(0), py(0) - py(1)); g.fillPath(SAMA.brush(g, [0.1, 0.1, 0.11, 1]));
        pen = SAMA.pen(g, [0.2, 0.2, 0.22, 1], 1);
        for (k = 1; k < 4; k++) {
            g.newPath(); g.moveTo(px(k / 4), py(1)); g.lineTo(px(k / 4), py(0)); g.strokePath(pen);
            g.newPath(); g.moveTo(px(0), py(k / 4)); g.lineTo(px(1), py(k / 4)); g.strokePath(pen);
        }
        g.newPath(); g.rectPath(px(0), py(1), px(1) - px(0), py(0) - py(1)); g.strokePath(SAMA.pen(g, [0.32, 0.32, 0.36, 1], 1));
        g.newPath(); g.moveTo(px(0), py(0)); g.lineTo(px(1), py(1)); g.strokePath(SAMA.pen(g, [0.26, 0.26, 0.29, 1], 1));
        pen = SAMA.pen(g, [C.text[0], C.text[1], C.text[2], 0.65], 1);
        g.newPath(); g.moveTo(px(0), py(0)); g.lineTo(px(curve[0]), py(curve[1])); g.strokePath(pen);
        g.newPath(); g.moveTo(px(1), py(1)); g.lineTo(px(curve[2]), py(curve[3])); g.strokePath(pen);
        steps = (drag >= 0) ? 24 : 48;                                  // a lighter curve while a handle is being dragged
        if (C.grad2) {                                                  // gradient: the curve fades from colour 1 to colour 2
            gp = [[px(0), py(0)]];
            for (i = 1; i <= steps; i++) { pt = SAMA.bez(curve, i / steps); gp.push([px(pt[0]), py(pt[1])]); }
            SAMA.strokeGrad(g, gp, C.title, C.grad2, 2.5);
        } else {
            g.newPath(); g.moveTo(px(0), py(0));
            for (i = 1; i <= steps; i++) { pt = SAMA.bez(curve, i / steps); g.lineTo(px(pt[0]), py(pt[1])); }
            g.strokePath(SAMA.pen(g, C.title, 2.5));
        }
        if (boxX() >= 34) {                                              // small axis labels (progress in %), when there is room beside the box
            var lf = SAMA.font(g, 9);
            SAMA.text(g, "100%", lf, C.text, boxX() - 36, py(1) - 6, 34, false);
            SAMA.text(g, "50%", lf, C.text, boxX() - 36, py(0.5) - 6, 34, false);
            SAMA.text(g, "0%", lf, C.text, boxX() - 36, py(0) - 6, 34, false);
        }
        for (i = 0; i < 2; i++) {
            g.newPath(); g.ellipsePath(px(curve[i * 2]) - 6, py(curve[i * 2 + 1]) - 6, 12, 12);
            g.fillPath(SAMA.brush(g, i === drag ? C.accent : (C.grad2 && i === 1 ? C.grad2 : C.title)));
        }
        g.newPath(); g.ellipsePath(px(0) - 3, py(0) - 3, 6, 6); g.fillPath(SAMA.brush(g, C.dim));
        g.newPath(); g.ellipsePath(px(1) - 3, py(1) - 3, 6, 6); g.fillPath(SAMA.brush(g, C.dim));
    };
    function hit(mx, my) {
        var d0 = Math.pow(mx - px(curve[0]), 2) + Math.pow(my - py(curve[1]), 2);
        var d1 = Math.pow(mx - px(curve[2]), 2) + Math.pow(my - py(curve[3]), 2);
        if (Math.min(d0, d1) > 22 * 22) { return -1; }
        return d0 <= d1 ? 0 : 1;
    }
    function redraw() { try { cv.notify("onDraw"); } catch (e) { } }
    function sync(persist) {
        if (drag < 0) { fitView(curve); }
        if (txt) { txt.text = SAMA.curveText(curve); }
        cv.helpTip = "Out " + Math.round(curve[0] * 100) + "% influence, In " + Math.round((1 - curve[2]) * 100) + "% influence - drag the handles";
        if (persist) { SAMA.settings.set("flow.curve", SAMA.curveText(curve)); }
        redraw();
    }
    // glide the curve to a new shape (instant when animations are off)
    function goTo(target, name) {
        var from = curve.slice(0), to = target.slice(0);
        SAMA.finishAll(); fitView(to);
        selName = name; if (grid) { grid.redraw(); }
        SAMA.tween(150, function (p) { var i; for (i = 0; i < 4; i++) { curve[i] = from[i] + (to[i] - from[i]) * p; } redraw(); }, SAMA.easeOut, function () { sync(true); liveApply(); });
    }
    // ---- editing the curve. Two ways, both always available, so it works however After Effects delivers the mouse:
    //   HOLD:  press a handle, drag, let go                      (the handle is dropped when the button is released, or the pointer leaves the graph)
    //   CLICK: click a handle (it lights up), move, click again  (the handle follows the pointer until the next click drops it)
    // A press-and-release without movement is treated as a CLICK, so a release that arrives too early can never cancel the grab.
    var lastText = 0, pressX = 0, pressY = 0, moved = false, sticky = false;
    function moveHandle(ev) {
        curve[drag * 2] = SAMA.clamp(ux(ev.clientX), 0, 1);
        curve[drag * 2 + 1] = SAMA.clamp(uy(ev.clientY), vMin, vMax);
    }
    function endDrag(noApply) {
        if (drag < 0) { return; }
        drag = -1; moved = false; sticky = false; sync(true); if (grid) { grid.redraw(); }
        if (noApply !== true) { liveApply(); }
    }
    SAMA.cancelDrag = function () { endDrag(true); };                  // dropped because the tab changed: nothing is applied
    cv.addEventListener("mousedown", SAMA.guard("Flow graph: press", function (ev) {
        if (drag >= 0) { moveHandle(ev); endDrag(); return; }          // second click: drop the handle right here
        drag = hit(ev.clientX, ev.clientY);
        if (drag >= 0) { pressX = ev.clientX; pressY = ev.clientY; moved = false; sticky = false; selName = ""; if (grid) { grid.redraw(); } redraw(); }
    }));
    var lastDraw = 0, lastX = -1, lastY = -1;
    cv.addEventListener("mousemove", SAMA.guard("Flow graph: drag", function (ev) {
        var now;
        if (drag < 0) { return; }
        if (!moved && Math.abs(ev.clientX - pressX) + Math.abs(ev.clientY - pressY) > 4) { moved = true; }
        if (ev.clientX === lastX && ev.clientY === lastY) { return; }   // same pixel again: nothing to do
        lastX = ev.clientX; lastY = ev.clientY;
        moveHandle(ev);
        now = SAMA.now();
        // Smooth dragging: update the curve on every event but repaint at most every ~14 ms (a repaint per queued event is what made the
        // graph crawl), and keep the number box out of the hot path - it repaints a whole text control, so it only refreshes ~4x per second.
        if (now - lastDraw >= 14) { lastDraw = now; redraw(); if (!sticky) { SAMA.flush(); } }
        if (txt && now - lastText > 250) { txt.text = SAMA.curveText(curve); lastText = now; }
    }));
    cv.addEventListener("mouseup", SAMA.guard("Flow graph: release", function (ev) {
        if (drag < 0) { return; }
        if (!moved) { sticky = true; return; }                          // a bare click: the handle stays grabbed until the next click
        if (!sticky) { moveHandle(ev); endDrag(); }                     // a real drag finished
    }));
    cv.addEventListener("mouseout", SAMA.guard("Flow graph: leave", function (ev) {
        if (drag >= 0 && moved && !sticky) {                            // a held drag that left the graph: land on the edge and let go
            if (typeof ev.clientX === "number") { moveHandle(ev); }
            endDrag();
        }
    }));
    cv.onClick = function () { };

    function fixed(parent, d, w, h) { d.minW = d.minW || Math.min(w, 26); var t = SAMA.tile(parent, d, w, h); t.alignment = ["left", "center"]; return t; }

    // ---- numeric entry + copy / paste / reset
    rowE = ui.row(root);
    rowE.margins = [0, 8, 0, 0];   // extra gap between the graph and the buttons under it
    txt = rowE.add("edittext", undefined, ""); txt.alignment = ["fill", "center"]; txt.minimumSize = [100, 24];
    SAMA.tint(txt, SAMA.C.text);              // the number follows the colour theme
    // A native edit box keeps its own horizontal scroll: after the panel was re-laid out (tab switches) the numbers sat a few pixels to the left.
    // Writing the text again resets that view to the start.
    SAMA.flowTextReset = function () {
        if (!txt || drag >= 0) { return; }
        try { txt.text = ""; txt.text = SAMA.curveText(curve); } catch (e) { }
    };
    txt.helpTip = "cubic-bezier(x1, y1, x2, y2) - type or paste values and press Enter";
    txt.onChange = function () {
        var c = SAMA.parseCurve(this.text);
        if (c) { goTo(c, ""); } else { SAMA.alert("Enter four numbers: x1, y1, x2, y2  (x values 0-1)."); sync(false); }
    };
    rowE2 = ui.row(root);
    fixed(rowE2, { icon: "copy", tip: "Copy this curve to the SAMA curve clipboard", fn: function () { SAMA.settings.set("flow.clip", SAMA.curveText(curve)); } }, 32, 26);
    fixed(rowE2, { icon: "paste", tip: "Paste the curve from the SAMA curve clipboard", fn: function () {
        var c = SAMA.parseCurve(SAMA.settings.get("flow.clip", ""));
        if (c) { goTo(c, ""); } else { SAMA.alert("The curve clipboard is empty. Use Copy first."); }
    } }, 32, 26);
    fixed(rowE2, { icon: "refresh", tip: "Reset to the default Easy Ease curve", fn: function () { goTo(SAMA.flowDefault, ""); } }, 32, 26);

    // ---- apply row: Out / In toggles, all-properties, Apply
    rowO = [];
    SAMA.rowAdd(rowO, true, { kind: "toggle", label: "Out", tip: "Write the outgoing ease on the first key of each pair", isSel: function () { return opt.out; },
        fn: function () { opt.out = !opt.out; SAMA.settings.set("flow.out", opt.out ? "1" : "0"); } }, 38, 34);
    SAMA.rowAdd(rowO, true, { kind: "toggle", label: "In", tip: "Write the incoming ease on the second key of each pair", isSel: function () { return opt.inn; },
        fn: function () { opt.inn = !opt.inn; SAMA.settings.set("flow.in", opt.inn ? "1" : "0"); } }, 38, 34);
    SAMA.rowAdd(rowO, true, { icon: "layers", tip: "Apply the curve to every keyframe pair of all selected properties",
        fn: SAMA.guard("Flow: apply to all keyframes", function () { SAMA.applyCurve(curve.slice(0), { allKeys: true, doIn: opt.inn, doOut: opt.out }); }) }, 36, 34);
    SAMA.rowAdd(rowO, false, { kind: "primary", minW: 56, icon: "check", label: "Apply", tip: "Apply the curve to the selected keyframes",
        fn: SAMA.guard("Flow: Apply", function () { SAMA.applyCurve(curve.slice(0), { allKeys: false, doIn: opt.inn, doOut: opt.out }); }) }, 90, 34);
    SAMA.flowRow(root, rowO, { h: 34 });
    chkLive = root.add("checkbox", undefined, "Apply while editing graph");
    chkLive.value = SAMA.settings.get("flow.live", "0") === "1"; chkLive.alignment = ["fill", "top"]; SAMA.tint(chkLive, C.text);
    chkLive.helpTip = "On: every time you finish editing the graph (drag a handle, pick a preset, type or paste values) the curve is also applied to the selected keyframes.";
    chkLive.onClick = function () { SAMA.settings.set("flow.live", this.value ? "1" : "0"); };

    // ---- curve library: Presets / Saved toggle + save, rename, delete
    function mkDef(item) {
        return {
            kind: "curve", c: item.c, label: item.name.replace(" (approx)", ""), tip: item.name + "   " + SAMA.curveText(item.c),
            isSel: function () { return selName === item.name; },
            fn: function () { goTo(item.c, item.name); }
        };
    }
    function curveDefs() {
        var out = [], i, list = (mode === "saved") ? custom : SAMA.flowPresets;
        for (i = 0; i < list.length; i++) { out.push(mkDef(list[i])); }
        if (!out.length) { out.push({ label: "No saved curves yet", tip: "Use Save to store the current curve" }); }
        return out;
    }
    function refreshGrid() { grid.setDefs(curveDefs()); SAMA.relayout(); }
    function setMode(m) { mode = m; SAMA.settings.set("flow.mode", m); refreshGrid(); }
    function customIndex() {
        var i;
        for (i = 0; i < custom.length; i++) { if (custom[i].name === selName) { return i; } }
        SAMA.alert("Select one of your saved curves first (Saved tab)."); return -1;
    }
    rowP = [];
    SAMA.rowAdd(rowP, true, { kind: "pill", minW: 46, label: "Presets", isSel: function () { return mode === "built"; }, fn: function () { setMode("built"); } }, 62, 26);
    SAMA.rowAdd(rowP, true, { kind: "pill", minW: 42, label: "Saved", isSel: function () { return mode === "saved"; }, fn: function () { setMode("saved"); } }, 56, 26);
    SAMA.rowAdd(rowP, false, { icon: "save", minW: 28, tip: "Save the current curve as a custom preset", fn: function () {
        var name = SAMA.prompt("Name for this curve:", "My curve"), i, found = -1;
        if (!name || !SAMA.trim(name)) { return; }
        name = SAMA.trim(name);
        for (i = 0; i < custom.length; i++) { if (custom[i].name === name) { found = i; } }
        if (found >= 0) { custom[found].c = curve.slice(0); } else { custom.push({ name: name, c: curve.slice(0) }); }
        SAMA.settings.setJSON("flow.custom", custom); selName = name; mode = "saved"; SAMA.settings.set("flow.mode", mode); refreshGrid();
    } }, 32, 26);
    SAMA.rowAdd(rowP, false, { icon: "pencil", minW: 28, tip: "Rename the selected saved curve", fn: function () {
        var i = customIndex(), name;
        if (i < 0) { return; }
        name = SAMA.prompt("New name:", custom[i].name);
        if (!name || !SAMA.trim(name)) { return; }
        custom[i].name = SAMA.trim(name); selName = custom[i].name;
        SAMA.settings.setJSON("flow.custom", custom); refreshGrid();
    } }, 32, 26);
    SAMA.rowAdd(rowP, false, { icon: "trash", minW: 28, tip: "Delete the selected saved curve", fn: function () {
        var i = customIndex();
        if (i < 0 || !SAMA.confirm("Delete curve \"" + custom[i].name + "\"?")) { return; }
        custom.splice(i, 1); selName = ""; SAMA.settings.setJSON("flow.custom", custom); refreshGrid();
    } }, 32, 26);
    SAMA.flowRow(root, rowP, { h: 26 });
    grid = SAMA.grid(root, curveDefs(), { cols: 4, minW: 62, h: 42, minCols: 2, maxCols: 5 });
    sync(false);
};

/* ==========================================================================
   PANEL 2 - SAMA LAYER VAULT  (.ffx preset library)
   ========================================================================== */
SAMA.libRoot = function () {
    var custom = SAMA.settings.get("libPath", ""), f, base;
    if (custom) { f = new Folder(custom); }
    else { f = new Folder(Folder.userData.fsName + "/SAMA/Layers"); }
    if (!f.exists) {
        try {
            base = new Folder(Folder.userData.fsName + "/SAMA"); if (!base.exists) { base.create(); }
            f.create();
        } catch (e) { }
    }
    return f;
};
SAMA.indexFile = function () { return new File(SAMA.libRoot().fsName + "/_sama_index.json"); };
SAMA.loadIndex = function () {
    var f = SAMA.indexFile(), s;
    if (!f.exists) { return {}; }
    try { f.encoding = "UTF-8"; f.open("r"); s = f.read(); f.close(); return SAMA.json.parse(s); }
    catch (e) { try { f.close(); } catch (e2) { } return {}; }
};
SAMA.saveIndex = function (idx) {
    var f = SAMA.indexFile();
    if (!SAMA.needWrite()) { return false; }
    try { f.encoding = "UTF-8"; f.open("w"); f.write(SAMA.json.stringify(idx)); f.close(); return true; }
    catch (e) { SAMA.alert("Could not write the library index:\n" + e.toString()); return false; }
};
SAMA.scanLibrary = function () {
    var idx = SAMA.loadIndex(), files = SAMA.libRoot().getFiles(), out = [], i, f, base, meta;
    for (i = 0; i < files.length; i++) {
        f = files[i];
        if (!(f instanceof File) || !/\.(ffx|samalayer)$/i.test(f.displayName)) { continue; }
        base = SAMA.libBase(f);
        meta = idx[base] || {};
        out.push({ file: f, base: base, fav: !!meta.fav, cat: meta.cat || "" });
    }
    out.sort(function (a, b) { return a.base.toLowerCase() < b.base.toLowerCase() ? -1 : (a.base.toLowerCase() > b.base.toLowerCase() ? 1 : 0); });
    return out;
};
SAMA.findCmd = function (names, fallback) {
    var i, id;
    for (i = 0; i < names.length; i++) {
        try { id = app.findMenuCommandId(names[i]); if (id) { return id; } } catch (e) { }
    }
    return fallback;
};

// Save: After Effects offers no script call that writes .ffx files, so SAMA selects the
// layer's properties and opens AE's own "Save Animation Preset" dialog for each layer.
// mode "keys": only the animated (keyframed) properties go into the preset. mode "layers": the whole layer (keyframes, expressions, effects, masks).
SAMA.selectKeyed = function (grp) {
    var j, p, n = 0, c;
    for (j = 1; j <= grp.numProperties; j++) {
        try {
            p = grp.property(j);
            if (p.propertyType === PropertyType.PROPERTY) { if (p.numKeys > 0) { p.selected = true; n++; } }
            else if (p.matchName === "ADBE Effect Parade") {                        // effects go in whole, with all their values
                for (c = 1; c <= p.numProperties; c++) { try { SAMA.selectAllProps(p.property(c)); p.property(c).selected = true; n++; } catch (e1) { } }
            }
            else { c = SAMA.selectKeyed(p); n += c; }
        } catch (e) { }
    }
    return n;
};
// clears the selection of every property under a layer, so the layer alone is selected
SAMA.clearPropSel = function (grp) {
    var j, p;
    try { grp.selected = false; } catch (e0) { }
    try {
        for (j = 1; j <= grp.numProperties; j++) {
            p = grp.property(j);
            try { p.selected = false; } catch (e1) { }
            if (p.propertyType !== PropertyType.PROPERTY) { SAMA.clearPropSel(p); }
        }
    } catch (e2) { }
};
// ---- Complete layers -------------------------------------------------------------------------------------------------------------------
// The .ffx preset keeps everything that is a property: transform, keyframes and easing, expressions, effects and their values, masks, layer styles,
// text / shape contents, 3D material options. It does NOT keep what the layer itself is: its type, source, blend mode, track matte, switches,
// in / out points or parent. SAMA saves those next to the preset in "<name>.sama.json" and rebuilds the layer from them when you press "Apply layer".
SAMA.metaFile = function (ffx) { return new File(ffx.parent.fsName + "/" + ffx.displayName.replace(/\.ffx$/i, "") + ".sama.json"); };
SAMA.readLayerMeta = function (ffx) {
    var f = SAMA.metaFile(ffx), s, o = null;
    if (!f.exists) { return null; }
    try { f.encoding = "UTF-8"; f.open("r"); s = f.read(); f.close(); o = SAMA.json.parse(s); } catch (e) { try { f.close(); } catch (e2) { } return null; }
    return (o && o.v === 1) ? o : null;
};
SAMA.writeLayerMeta = function (ffx, meta) {
    var f = SAMA.metaFile(ffx);
    try { f.encoding = "UTF-8"; f.open("w"); f.write(SAMA.json.stringify(meta)); f.close(); return true; } catch (e) { try { f.close(); } catch (e2) { } return false; }
};
SAMA.layerKind = function (l) {
    var src;
    try { if (l instanceof CameraLayer) { return "camera"; } } catch (e0) { }
    try { if (l instanceof LightLayer) { return "light"; } } catch (e1) { }
    try { if (l instanceof ShapeLayer) { return "shape"; } } catch (e2) { }
    try { if (l instanceof TextLayer) { return "text"; } } catch (e3) { }
    try { if (l.nullLayer) { return "null"; } } catch (e4) { }
    try { if (l.adjustmentLayer) { return "adjustment"; } } catch (e5) { }
    try {
        src = l.source;
        if (src instanceof CompItem) { return "precomp"; }
        if (src instanceof FootageItem) { return (src.mainSource instanceof SolidSource) ? "solid" : "footage"; }
    } catch (e6) { }
    return "av";
};
SAMA.layerMeta = function (l, keepParent) {
    var m = { v: 1, kind: SAMA.layerKind(l), name: l.name }, comp = l.containingComp, src, mt;
    function put(key, fn) { try { m[key] = fn(); } catch (e) { } }
    put("dur", function () { return l.outPoint - l.inPoint; });
    put("inOff", function () { return l.inPoint - l.startTime; });
    put("blend", function () { return l.blendingMode; });
    put("threeD", function () { return !!l.threeDLayer; });
    put("motionBlur", function () { return !!l.motionBlur; });
    put("collapse", function () { return !!l.collapseTransformation; });
    put("guide", function () { return !!l.guideLayer; });
    put("shy", function () { return !!l.shy; });
    put("enabled", function () { return !!l.enabled; });
    put("fxOn", function () { return !!l.effectsActive; });
    put("audio", function () { return !!l.audioEnabled; });
    put("preserve", function () { return !!l.preserveTransparency; });
    put("label", function () { return l.label; });
    put("quality", function () { return l.quality; });
    put("autoOrient", function () { return Number(l.autoOrient); });
    if (m.kind === "light") { put("lightType", function () { return Number(l.lightType); }); }
    if (m.kind === "adjustment") { m.adj = true; }
    try {
        src = l.source;
        if (m.kind === "solid" || m.kind === "adjustment") { m.w = src.width; m.h = src.height; m.pa = src.pixelAspect; m.color = src.mainSource.color; }
        else if (m.kind === "precomp") { m.srcName = src.name; }
        else if (m.kind === "footage" || m.kind === "av") { m.srcName = src.name; try { m.srcPath = src.file ? src.file.fsName : ""; } catch (e7) { m.srcPath = ""; } }
    } catch (e8) { }
    try {
        mt = l.trackMatteType;
        if (mt && mt !== TrackMatteType.NO_TRACK_MATTE) { m.matte = mt; if (l.index > 1) { m.matteName = comp.layer(l.index - 1).name; } }
    } catch (e9) { }
    if (keepParent) { try { if (l.parent) { m.parent = l.parent.name; } } catch (e10) { } }
    return m;
};
// builds the layer a saved item describes (before the preset is applied to it)
SAMA.layerFromMeta = function (comp, m, fallbackName) {
    var l = null, dur = (m.dur > comp.frameDuration) ? m.dur : comp.duration, item, i, it, f, mid = [comp.width / 2, comp.height / 2], note = "";
    function findItem(name, cls) {
        var k, p = app.project;
        for (k = 1; k <= p.numItems; k++) { try { if (p.item(k) instanceof cls && p.item(k).name === name && p.item(k) !== comp) { return p.item(k); } } catch (e) { } }
        return null;
    }
    try {
        if (m.kind === "solid") { l = comp.layers.addSolid(m.color || [1, 1, 1], m.name || fallbackName, m.w || comp.width, m.h || comp.height, m.pa || comp.pixelAspect, dur); }
        else if (m.kind === "adjustment") { l = comp.layers.addSolid([1, 1, 1], m.name || fallbackName, m.w || comp.width, m.h || comp.height, m.pa || comp.pixelAspect, dur); l.adjustmentLayer = true; }
        else if (m.kind === "shape") { l = comp.layers.addShape(); }
        else if (m.kind === "text") { l = comp.layers.addText("Text"); }
        else if (m.kind === "camera") { l = comp.layers.addCamera(m.name || fallbackName, mid); }
        else if (m.kind === "light") { l = comp.layers.addLight(m.name || fallbackName, mid); try { if (m.lightType) { l.lightType = m.lightType; } } catch (eL) { } }
        else if (m.kind === "precomp") {
            item = findItem(m.srcName, CompItem);
            if (item) { l = comp.layers.add(item, dur); } else { note = "The pre-composition \"" + m.srcName + "\" is not in this project, so a null layer was made instead."; }
        } else if (m.kind === "footage" || m.kind === "av") {
            item = findItem(m.srcName, FootageItem);
            if (!item && m.srcPath) { try { f = new File(m.srcPath); if (f.exists) { item = app.project.importFile(new ImportOptions(f)); } } catch (e1) { item = null; } }
            if (item) { l = comp.layers.add(item, dur); } else { note = "The footage \"" + m.srcName + "\" could not be found, so a null layer was made instead."; }
        }
    } catch (e) { l = null; }
    if (!l) { l = comp.layers.addNull(dur); if (!note && m.kind !== "null") { note = "The layer type \"" + m.kind + "\" could not be rebuilt, so a null layer was made instead."; } }
    try { l.name = m.name || fallbackName; } catch (e2) { }
    try { if (m.threeD && !(m.kind === "camera" || m.kind === "light")) { l.threeDLayer = true; } else if (m.threeD) { /* camera and light layers are always 3D */ } } catch (e3) { }
    try { if (l.outPoint - l.inPoint > dur + comp.frameDuration / 2 || (m.kind === "shape" || m.kind === "text")) { l.outPoint = l.inPoint + dur; } } catch (e4) { }
    SAMA.layerNote = note;
    return l;
};
// everything that is not a property, set after the preset was applied
SAMA.layerFromMetaAfter = function (comp, l, m) {
    var matteL = null, k, p, warn = "";
    function tryset(fn) { try { fn(); } catch (e) { } }
    if (m.blend !== undefined) { tryset(function () { l.blendingMode = m.blend; }); }
    if (m.motionBlur !== undefined) { tryset(function () { l.motionBlur = m.motionBlur; }); }
    if (m.collapse !== undefined) { tryset(function () { l.collapseTransformation = m.collapse; }); }
    if (m.guide !== undefined) { tryset(function () { l.guideLayer = m.guide; }); }
    if (m.shy !== undefined) { tryset(function () { l.shy = m.shy; }); }
    if (m.enabled !== undefined) { tryset(function () { l.enabled = m.enabled; }); }
    if (m.fxOn !== undefined) { tryset(function () { l.effectsActive = m.fxOn; }); }
    if (m.audio !== undefined) { tryset(function () { l.audioEnabled = m.audio; }); }
    if (m.preserve !== undefined) { tryset(function () { l.preserveTransparency = m.preserve; }); }
    if (m.label !== undefined) { tryset(function () { l.label = m.label; }); }
    if (m.autoOrient !== undefined) { tryset(function () { l.autoOrient = m.autoOrient; }); }
    if (m.matte && m.matteName) {
        for (k = 1; k <= comp.numLayers; k++) { try { if (comp.layer(k).name === m.matteName && comp.layer(k).index !== l.index) { matteL = comp.layer(k); break; } } catch (e) { } }
        if (matteL) {
            tryset(function () {
                if (typeof l.setTrackMatte === "function") { l.setTrackMatte(matteL, m.matte); }
                else { l.moveAfter(matteL); l.trackMatteType = m.matte; }
            });
        } else { warn = "Track matte: the layer \"" + m.matteName + "\" is not in this composition, so no matte was set."; }
    }
    if (m.parent) {
        for (k = 1; k <= comp.numLayers; k++) { try { if (comp.layer(k).name === m.parent && comp.layer(k).index !== l.index) { p = comp.layer(k); break; } } catch (e2) { } }
        if (p) { tryset(function () { l.parent = p; }); } else { warn += (warn ? "\n" : "") + "Parent: the layer \"" + m.parent + "\" is not in this composition."; }
    }
    return warn;
};
// ---- Whole layers saved by SAMA itself --------------------------------------------------------------------------------------------------
// "Layers" does not make an .ffx. SAMA reads the whole layer through the scripting interface (every property, keyframe, ease, expression, effect,
// mask, shape, text, layer style) and writes it to "<name>.samalayer" in the library. Choosing it in the Vault builds that layer again on the timeline.
SAMA.LAYEREXT = ".samalayer";
SAMA.isOwn = function (f) { return /\.samalayer$/i.test(f.displayName || f.name); };
SAMA.libExt = function (f) { return SAMA.isOwn(f) ? SAMA.LAYEREXT : ".ffx"; };
SAMA.libBase = function (f) { return (f.displayName || f.name).replace(/\.(ffx|samalayer)$/i, ""); };
SAMA.num = function (v) { var n = Number(v); return isFinite(n) ? n : 0; };
SAMA.plain = function (v) {                 // arrays of numbers (or arrays of arrays) copied into plain arrays; enums become numbers
    var out, i;
    if (v instanceof Array) { out = []; for (i = 0; i < v.length; i++) { out.push(SAMA.plain(v[i])); } return out; }
    if (v === null || v === undefined) { return null; }
    if (typeof v === "object") { return SAMA.num(v); }
    return v;
};
SAMA.TEXTFIELDS = ["text", "font", "fontSize", "fillColor", "strokeColor", "applyFill", "applyStroke", "strokeWidth", "strokeOverFill", "justification", "tracking", "leading", "autoLeading", "tsume", "fauxBold", "fauxItalic", "allCaps", "smallCaps", "superscript", "subscript", "horizontalScale", "verticalScale", "baselineShift", "boxText", "boxTextSize", "boxTextPos"];
SAMA.MARKFIELDS = ["comment", "duration", "chapter", "url", "frameTarget", "cuePointName", "label"];
SAMA.STYLES = { "ADBE Drop Shadow": "Drop Shadow", "ADBE Inner Shadow": "Inner Shadow", "ADBE Outer Glow": "Outer Glow", "ADBE Inner Glow": "Inner Glow", "ADBE Bevel Emboss": "Bevel and Emboss", "ADBE Satin": "Satin", "ADBE Color Overlay": "Color Overlay", "ADBE Gradient Overlay": "Gradient Overlay", "ADBE Stroke": "Stroke" };
// value -> plain data
SAMA.valPack = function (t, v) {
    var o = {}, i, f, x;
    if (t === PropertyValueType.SHAPE) {
        o.vertices = SAMA.plain(v.vertices); o.inTangents = SAMA.plain(v.inTangents); o.outTangents = SAMA.plain(v.outTangents); o.closed = !!v.closed;
        return o;
    }
    if (t === PropertyValueType.TEXT_DOCUMENT) {
        for (i = 0; i < SAMA.TEXTFIELDS.length; i++) { f = SAMA.TEXTFIELDS[i]; try { x = v[f]; if (x !== undefined) { o[f] = SAMA.plain(x); } } catch (e) { } }
        return o;
    }
    if (t === PropertyValueType.MARKER) {
        for (i = 0; i < SAMA.MARKFIELDS.length; i++) { f = SAMA.MARKFIELDS[i]; try { x = v[f]; if (x !== undefined) { o[f] = SAMA.plain(x); } } catch (e2) { } }
        return o;
    }
    return SAMA.plain(v);
};
// plain data -> value for the property d
SAMA.valUnpack = function (d, v) {
    var t = d.propertyValueType, o, f;
    if (t === PropertyValueType.SHAPE) {
        o = new Shape(); o.vertices = v.vertices; o.inTangents = v.inTangents; o.outTangents = v.outTangents; o.closed = v.closed; return o;
    }
    if (t === PropertyValueType.TEXT_DOCUMENT) {
        o = d.value;
        for (f in v) { if (v.hasOwnProperty(f)) { try { o[f] = v[f]; } catch (e) { } } }
        return o;
    }
    if (t === PropertyValueType.MARKER) {
        o = new MarkerValue(v.comment || "");
        for (f in v) { if (v.hasOwnProperty(f) && f !== "comment") { try { o[f] = v[f]; } catch (e2) { } } }
        return o;
    }
    return v;
};
SAMA.interpEnum = function (n) {
    var list = [KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.HOLD], i;
    for (i = 0; i < list.length; i++) { if (Number(list[i]) === n) { return list[i]; } }
    return n;
};
SAMA.keysOut = function (p, base) {
    var out = [], k, o, t = p.propertyValueType, sp = false, ea, i, n = p.numKeys, arr;
    try { sp = !!p.isSpatial; } catch (e0) { }
    function eases(fn, key) {
        var a = fn.call(p, key), r = [], j;
        for (j = 0; j < a.length; j++) { r.push([SAMA.num(a[j].speed), SAMA.num(a[j].influence)]); }
        return r;
    }
    for (k = 1; k <= n; k++) {
        o = { t: p.keyTime(k) - base, v: SAMA.valPack(t, p.keyValue(k)) };
        try { o.ii = SAMA.num(p.keyInInterpolationType(k)); o.oi = SAMA.num(p.keyOutInterpolationType(k)); } catch (e1) { }
        try { o.ie = eases(p.keyInTemporalEase, k); o.oe = eases(p.keyOutTemporalEase, k); } catch (e2) { }
        try { o.tc = !!p.keyTemporalContinuous(k); o.ta = !!p.keyTemporalAutoBezier(k); } catch (e3) { }
        if (sp) {
            try { o.si = SAMA.plain(p.keyInSpatialTangent(k)); o.so = SAMA.plain(p.keyOutSpatialTangent(k)); o.sc = !!p.keySpatialContinuous(k); o.sa = !!p.keySpatialAutoBezier(k); o.rv = !!p.keyRoving(k); } catch (e4) { }
        }
        out.push(o);
    }
    return out;
};
// one property or group -> plain data (null = nothing worth saving)
SAMA.propOut = function (p, base, st, parentIndexed, parentMn) {
    var pt = p.propertyType, node = { mn: p.matchName, n: p.name }, t, j, c, mod = true, hasKeys = false, ex = "", kids;
    if (pt === PropertyType.PROPERTY) {
        t = p.propertyValueType;
        if (t === PropertyValueType.NO_VALUE) { return null; }
        if (t === PropertyValueType.CUSTOM_VALUE) { st.skipped.push(p.name); return null; }
        try { if (p.isModified === false && !st.inFx) { mod = false; } } catch (e0) { }     // effect settings are always kept whole
        if (t === PropertyValueType.SHAPE || t === PropertyValueType.TEXT_DOCUMENT) { mod = true; }
        try { hasKeys = p.numKeys > 0; } catch (e1) { }
        try { if (p.canSetExpression) { ex = p.expression; } } catch (e2) { ex = ""; }
        try { if (p.dimensionsSeparated) { node.sep = true; } } catch (e3) { }
        if (!mod && !hasKeys && !ex && !node.sep) { return null; }
        if (!node.sep) {
            try { node.v = SAMA.valPack(t, p.value); } catch (e4) { }
            if (hasKeys) { try { node.k = SAMA.keysOut(p, base); } catch (e5) { } }
        }
        if (ex) { node.x = ex; try { node.xe = !!p.expressionEnabled; } catch (e6) { } }
        return node;
    }
    node.g = [];
    try { node.en = !!p.enabled; } catch (e7) { }
    if (parentMn === "ADBE Layer Styles" && node.en === false) { return null; }
    if (p.matchName === "ADBE Mask Atom") {
        node.mask = {};
        try { node.mask.mode = SAMA.num(p.maskMode); node.mask.inv = !!p.inverted; node.mask.color = SAMA.plain(p.color); node.mask.locked = !!p.locked; node.mask.roto = !!p.rotoBezier; } catch (e8) { }
    }
    if (parentMn === "ADBE Effect Parade") { st.inFx = true; }
    for (j = 1; j <= p.numProperties; j++) {
        try { c = SAMA.propOut(p.property(j), base, st, pt === PropertyType.INDEXED_GROUP, p.matchName); } catch (e9) { c = null; }
        if (c) { node.g.push(c); }
    }
    if (parentMn === "ADBE Effect Parade") { st.inFx = false; }
    if (!node.g.length && !parentIndexed && node.mask === undefined && !(node.en === false)) { return null; }
    return node;
};
SAMA.layerTree = function (l, st) {
    var tree = [], j, n, base = l.inPoint;
    for (j = 1; j <= l.numProperties; j++) {
        try { n = SAMA.propOut(l.property(j), base, st, false, ""); } catch (e) { n = null; }
        if (n) { tree.push(n); }
    }
    return tree;
};
// how much a saved tree holds: animated properties, effects, masks
SAMA.countTree = function (nodes, c) {
    var i, n;
    c = c || { keyed: 0, fx: 0, masks: 0 };
    for (i = 0; i < nodes.length; i++) {
        n = nodes[i];
        if (n.k && n.k.length) { c.keyed++; }
        if (n.g) {
            if (n.mn === "ADBE Effect Parade") { c.fx += n.g.length; }
            if (n.mn === "ADBE Mask Parade") { c.masks += n.g.length; }
            SAMA.countTree(n.g, c);
        }
    }
    return c;
};
SAMA.countText = function (c) {
    var t = [];
    t.push(c.keyed + " animated propert" + (c.keyed === 1 ? "y" : "ies"));
    t.push(c.fx + " effect" + (c.fx === 1 ? "" : "s"));
    if (c.masks) { t.push(c.masks + " mask" + (c.masks === 1 ? "" : "s")); }
    return t.join(", ");
};
SAMA.safeName = function (s) { var r = SAMA.trim(String(s)).replace(/[\\\/:*?"<>|]/g, "_"); return r || "Layer"; };
// Saves every layer straight into the library. Returns { files: [paths], notes: [text] }.
SAMA.saveOwnLayers = function (comp, layers, root, keepParent) {
    var res = { files: [], notes: [] }, i, k, l, st, obj, f, base, n, ok, entry, c, tot = { keyed: 0, fx: 0, masks: 0 }, pi, nm;
    obj = { sama: "layer", v: 3, layers: [] };
    for (i = 0; i < layers.length; i++) {
        l = layers[i]; st = { skipped: [] };
        try {
            entry = { meta: SAMA.layerMeta(l, keepParent), tree: SAMA.layerTree(l, st), off: l.inPoint - layers[0].inPoint };
            pi = -1;                                                           // a parent that is saved in the same preset always comes back
            try { if (l.parent) { for (k = 0; k < layers.length; k++) { if (k !== i && layers[k].index === l.parent.index) { pi = k; break; } } } } catch (eP) { }
            if (pi >= 0) { entry.pi = pi; }
            obj.layers.push(entry);
            c = SAMA.countTree(entry.tree); tot.keyed += c.keyed; tot.fx += c.fx; tot.masks += c.masks;
            if (st.skipped.length) { res.notes.push(l.name + ": scripts cannot read " + st.skipped.join(", ") + " - left out"); }
        } catch (e) { res.notes.push(l.name + ": not saved (" + e.toString() + ")"); }
    }
    if (!obj.layers.length) { return res; }
    // the earliest layer starts the group at 0
    st = null;
    for (i = 0; i < obj.layers.length; i++) { if (st === null || obj.layers[i].off < st) { st = obj.layers[i].off; } }
    for (i = 0; i < obj.layers.length; i++) { obj.layers[i].off = Math.max(0, obj.layers[i].off - st); }
    try {
        nm = layers[0].name;
        base = SAMA.safeName(layers.length > 1 ? (nm + " + " + (layers.length - 1) + (layers.length === 2 ? " layer" : " layers")) : nm);
        f = new File(root.fsName + "/" + base + SAMA.LAYEREXT); n = 2;
        while (f.exists) { f = new File(root.fsName + "/" + base + " (" + n + ")" + SAMA.LAYEREXT); n++; }
        f.encoding = "UTF-8"; ok = f.open("w"); if (!ok) { throw new Error("cannot write " + f.fsName); }
        f.write(SAMA.json.stringify(obj)); f.close();
        res.files.push(f.fsName + "\n      " + obj.layers.length + " layer" + (obj.layers.length === 1 ? "" : "s") + " in one preset, kept: " + SAMA.countText(tot));
    } catch (e2) { try { f.close(); } catch (e3) { } res.notes.push("not saved (" + e2.toString() + ")"); }
    return res;
};
SAMA.readOwn = function (f) {
    var s, o = null;
    try { f.encoding = "UTF-8"; f.open("r"); s = f.read(); f.close(); o = SAMA.json.parse(s); } catch (e) { try { f.close(); } catch (e2) { } return null; }
    if (!o || o.sama !== "layer") { return null; }
    if (!o.layers) { if (!o.tree) { return null; } o.layers = [{ meta: o.meta, tree: o.tree, off: 0 }]; }      // single-layer files of the older format
    return o.layers.length ? o : null;
};
// ---- building the layer again
SAMA.keysIn = function (d, node, base) {
    var ks = node.k, i, o, k;
    for (i = 0; i < ks.length; i++) { try { d.setValueAtTime(base + ks[i].t, SAMA.valUnpack(d, ks[i].v)); } catch (e) { } }
    function E(a) { var r = [], j; for (j = 0; j < a.length; j++) { r.push(new KeyframeEase(a[j][0], Math.max(0.1, Math.min(100, a[j][1])))); } return r; }
    if (d.numKeys !== ks.length) { return; }
    for (i = 0; i < ks.length; i++) {
        o = ks[i]; k = i + 1;
        try { if (o.ie && o.oe) { d.setTemporalEaseAtKey(k, E(o.ie), E(o.oe)); } } catch (e1) { }
        try { if (o.ii !== undefined) { d.setInterpolationTypeAtKey(k, SAMA.interpEnum(o.ii), SAMA.interpEnum(o.oi)); } } catch (e2) { }
        try { if (o.tc !== undefined) { d.setTemporalContinuousAtKey(k, o.tc); } } catch (e3) { }
        try { if (o.ta) { d.setTemporalAutoBezierAtKey(k, true); } } catch (e4) { }
        if (o.si) {
            try { if (o.sa) { d.setSpatialAutoBezierAtKey(k, true); } else { d.setSpatialTangentsAtKey(k, o.si, o.so); d.setSpatialContinuousAtKey(k, !!o.sc); } } catch (e5) { }
        }
    }
    for (i = 0; i < ks.length; i++) { if (ks[i].rv) { try { d.setRovingAtKey(i + 1, true); } catch (e6) { } } }
};
SAMA.nodeFill = function (d, node, ctx) {
    var m;
    if (node.g) {
        try { if (node.en === false) { d.enabled = false; } } catch (e0) { }
        if (node.mask) {
            m = node.mask;
            try { if (m.mode !== undefined) { d.maskMode = m.mode; } } catch (e1) { }
            try { d.inverted = m.inv; } catch (e2) { }
            try { if (m.color) { d.color = m.color; } } catch (e3) { }
            try { d.rotoBezier = m.roto; } catch (e4) { }
            try { d.locked = m.locked; } catch (e5) { }
        }
        SAMA.nodesIn(d, node.g, ctx);
        return;
    }
    if (node.sep) { try { d.dimensionsSeparated = true; } catch (e6) { } return; }
    try {
        if (node.k && node.k.length) { SAMA.keysIn(d, node, ctx.base); }
        else if (node.v !== undefined && node.v !== null) { d.setValue(SAMA.valUnpack(d, node.v)); }
    } catch (e7) { if (!ctx.inFx) { ctx.missed.push(node.n); } }       // inside an effect, hidden / internal settings (Show Buffers, Output Buffer Dims ...) cannot be set by scripts and are not reported
    if (node.x) { try { d.expression = node.x; if (node.xe === false) { d.expressionEnabled = false; } } catch (e8) { if (!ctx.inFx) { ctx.missed.push(node.n + " (expression)"); } } }
};
SAMA.nodesIn = function (dest, nodes, ctx) {
    var indexed = false, i, node, d, last, id, q;
    try { indexed = (dest.propertyType === PropertyType.INDEXED_GROUP); } catch (e) { }
    for (i = 0; i < nodes.length; i++) {
        node = nodes[i]; d = null;
        if (indexed) {
            try { d = dest.addProperty(node.mn); } catch (e1) { d = null; }
            if (d) {
                try { last = dest.numProperties; if (last > 1 && d.propertyIndex !== last) { d.moveTo(last); d = dest.property(last); } } catch (e2) { }
                try { if (node.n) { d.name = node.n; } } catch (e3) { }
            }
        } else {
            try { d = dest.property(node.mn); } catch (e4) { d = null; }
            if (!d && /_[0-2]$/.test(node.mn)) {                                  // X / Y / Z Position of a layer with separated dimensions: look through the group by name
                for (q = 1; q <= dest.numProperties && !d; q++) { try { if (dest.property(q).matchName === node.mn || dest.property(q).name === node.n) { d = dest.property(q); } } catch (eq) { } }
                if (!d) { continue; }                                              // nothing to put there; the Position itself already holds the movement
            }
            if (!d) { try { d = dest.addProperty(node.mn); } catch (e5) { d = null; } }
            if (!d && node.mn === "ADBE Time Remapping") { try { ctx.layer.timeRemapEnabled = true; d = dest.property(node.mn); } catch (e6) { d = null; } }
            if (!d && SAMA.STYLES[node.mn]) {
                try { SAMA.selectOnly(ctx.comp, [ctx.layer]); id = app.findMenuCommandId(SAMA.STYLES[node.mn]); if (id) { app.executeCommand(id); } d = dest.property(node.mn); } catch (e7) { d = null; }
            }
        }
        if (!d) { if (!ctx.inFx) { ctx.missed.push(node.n); } continue; }
        if (indexed && dest.matchName === "ADBE Effect Parade") { ctx.inFx = true; try { SAMA.nodeFill(d, node, ctx); } finally { ctx.inFx = false; } continue; }
        SAMA.nodeFill(d, node, ctx);
    }
};
// puts the saved tree onto layer l; key times start at "base"
SAMA.applyOwn = function (comp, l, saved, base) {
    var ctx = { comp: comp, layer: l, base: base, missed: [] };
    try { if (saved.meta && saved.meta.threeD && !(l instanceof CameraLayer) && !(l instanceof LightLayer)) { l.threeDLayer = true; } } catch (e) { }
    SAMA.nodesIn(l, saved.tree, ctx);
    return ctx.missed;
};
// After Effects has no script command that writes a preset file; its own "Save Animation Preset" dialog has to be used, and a script cannot choose
// where that dialog saves. So: every preset it just wrote (in After Effects' User Presets folder, or anywhere under it) is moved into the SAMA library
// automatically, which is the same as saving it there.
// Every folder a saved preset may end up in: After Effects' User Presets, the Desktop, Documents, Downloads and the SAMA library itself.
SAMA.presetFiles = function (root) {
    var out = [], seen = {}, dirs = [], i, adobe, kids, up, kidsN;
    function scan(folder, depth) {
        var list, k, it;
        try { list = folder.getFiles(); } catch (e) { return; }
        for (k = 0; k < list.length; k++) {
            it = list[k];
            if (it instanceof Folder) { if (depth < 3) { scan(it, depth + 1); } }
            else if (it instanceof File && /\.ffx$/i.test(it.name)) { if (!seen[it.fsName]) { seen[it.fsName] = true; out.push(it); } }
        }
    }
    try {
        adobe = new Folder(Folder.myDocuments.fsName + "/Adobe");
        kids = adobe.getFiles();
        for (i = 0; i < kids.length; i++) {
            if (kids[i] instanceof Folder && /^After Effects/i.test(kids[i].name)) { up = new Folder(kids[i].fsName + "/User Presets"); if (up.exists) { dirs.push(up); } }
        }
    } catch (e0) { }
    kidsN = dirs.length;
    try { dirs.push(Folder.desktop); dirs.push(Folder.myDocuments); dirs.push(new Folder(Folder.myDocuments.parent.fsName + "/Downloads")); dirs.push(root.parent); } catch (e4) { }
    dirs.push(root);
    SAMA.lastDirs = [];
    for (i = 0; i < dirs.length; i++) {
        if (dirs[i] && dirs[i].exists) { SAMA.lastDirs.push(dirs[i].fsName); scan(dirs[i], (i < kidsN) ? 0 : (i === dirs.length - 1 ? 3 : 2)); }
    }
    return out;
};
// what exists (and when it was last changed) before the Save window opens; compared afterwards, so no clock is involved
SAMA.presetSnapshot = function (root) {
    var files = SAMA.presetFiles(root), snap = {}, i;
    for (i = 0; i < files.length; i++) { try { snap[files[i].fsName] = String(files[i].modified.getTime()) + ":" + files[i].length; } catch (e) { snap[files[i].fsName] = "?"; } }
    return snap;
};
// every preset that is new or changed since the snapshot is moved into the library (or, when it was saved there, left in place). Returns the files.
SAMA.lastFrom = {};
SAMA.collectNewPresets = function (snap, root) {
    var files = SAMA.presetFiles(root), moved = [], i, f, key, dest, base, n;
    for (i = 0; i < files.length; i++) {
        f = files[i];
        try { key = String(f.modified.getTime()) + ":" + f.length; } catch (e) { key = "?"; }
        if (snap && snap[f.fsName] === key) { continue; }                 // unchanged: not saved just now
        try {
            if (f.parent && f.parent.fsName === root.fsName) { moved.push(f); continue; }      // saved straight into the library: already in place
            base = f.name.replace(/\.ffx$/i, ""); dest = new File(root.fsName + "/" + base + ".ffx"); n = 2;
            while (dest.exists) { dest = new File(root.fsName + "/" + base + " (" + n + ").ffx"); n++; }
            if (f.copy(dest)) { SAMA.lastFrom[dest.fsName] = f.fsName; f.remove(); moved.push(dest); }
        } catch (e3) { }
    }
    return moved;
};
// mode "keys": only the animated (keyframed) properties go into the preset. mode "layers": the whole layer.
// selects every property of a layer (and every effect / mask / shape as a whole), as a fallback for the whole-layer save
SAMA.selectAllProps = function (grp) {
    var j, p;
    for (j = 1; j <= grp.numProperties; j++) {
        try {
            p = grp.property(j);
            if (p.propertyType === PropertyType.PROPERTY) { p.selected = true; }
            else { SAMA.selectAllProps(p); if (grp.propertyType === PropertyType.INDEXED_GROUP) { p.selected = true; } }
        } catch (e) { }
    }
};
// Saving works as a short step-by-step page inside the panel (page 8). After Effects' Save window may stay open while the script goes on, so the
// script never assumes a layer is finished: it waits for "Next layer", and collects the new file into the library at that moment.
SAMA.sv = null;
SAMA.svText = function () {
    var sv = SAMA.sv, l, t;
    if (!sv || !SAMA.svUI) { return; }
    l = sv.layers[sv.idx];
    t = l ? ("Layer " + (sv.idx + 1) + " of " + sv.layers.length + ":  " + l.name + "\n\nIn After Effects' Save window type a name and press Save. Then press the button below.") : "Done.";
    try { SAMA.svUI.st.text = t; } catch (e) { }
    try { SAMA.svUI.fr.redraw(); } catch (e2) { }
};
SAMA.svCollect = function (sv) {
    var got = SAMA.collectNewPresets(sv.snap, sv.root), i;
    for (i = 0; i < got.length; i++) {
        try { if (sv.meta) { SAMA.writeLayerMeta(got[i], sv.meta); } } catch (e) { }
        try { sv.files.push(got[i].fsName + (SAMA.lastFrom[got[i].fsName] ? "   (saved by After Effects in " + SAMA.lastFrom[got[i].fsName] + " and moved here)" : "")); } catch (e2) { }
    }
    return got.length;
};
SAMA.svOpen = function (alt) {
    var sv = SAMA.sv, l, nk = 0, t0;
    while (sv.idx < sv.layers.length) {
        l = sv.layers[sv.idx];
        SAMA.run("Save layer presets", function () {
            SAMA.selectOnly(sv.comp, [l]); SAMA.clearPropSel(l); SAMA.selectOnly(sv.comp, [l]);
            if (sv.mode === "keys") { nk = SAMA.selectKeyed(l); }
            else if (!alt) { SAMA.selectAllProps(l); }          // the whole layer: every property and every effect / mask / shape is selected, which is what After Effects needs to write them
            // alt (the "Window did not open" button): only the layer itself is selected
        });
        if (sv.mode === "keys" && !nk) { sv.skipped.push(l.name); sv.idx++; continue; }
        sv.meta = (sv.mode === "layers") ? SAMA.layerMeta(l, sv.keepParent) : null;   // what the layer is, saved next to the preset
        sv.snap = SAMA.presetSnapshot(sv.root); t0 = (new Date()).getTime();
        try { SAMA.modal(function () { app.executeCommand(sv.cmd); }); sv.opened++; }
        catch (e) { SAMA.alert("Could not open After Effects' Save Animation Preset window (command id " + sv.cmd + ").\nUse Animation > Save Animation Preset... by hand."); SAMA.svFinish(); return; }
        if ((new Date()).getTime() - t0 > 800) {          // the call waited for the window: it is closed now, so the file can be collected
            if (SAMA.svCollect(sv) > 0) { sv.saved++; sv.idx++; alt = false; continue; }
        }
        SAMA.svText();                                       // otherwise wait for the person to press the button
        return;
    }
    SAMA.svFinish();
};
SAMA.svNext = function (skip) {
    var sv = SAMA.sv;
    if (!sv) { return; }
    if (!skip && SAMA.svCollect(sv) > 0) { sv.saved++; }
    sv.idx++;
    SAMA.svOpen(false);
};
SAMA.svFinish = function () {
    var sv = SAMA.sv, done, msg;
    if (!sv) { return; }
    SAMA.sv = null;
    if (sv.idx < sv.layers.length && SAMA.svCollect(sv) > 0) { sv.saved++; }   // Stop pressed after saving the current layer
    try { SAMA.run("Save layer presets", function () { SAMA.selectOnly(sv.comp, sv.layers); }); } catch (e) { }
    SAMA.svBack();
    if (sv.onDone) { sv.onDone(); }
    // no pop-up questions: the result is shown on the Vault page itself
    msg = "";
    if (sv.opened && !sv.saved) { msg = "Nothing was saved - in the Save window press Save, then Next layer"; }
    else if (sv.saved) { msg = "Saved " + sv.saved + " preset" + (sv.saved === 1 ? "" : "s"); }
    if (sv.skipped.length) { msg = (msg ? msg + " - " : "") + "no keyframes or effects on: " + sv.skipped.join(", "); }
    if (msg) { SAMA.vaultFlash = msg; if (SAMA.vaultReload) { SAMA.vaultReload(); } }
    // a script alert with the full path(s), because the line on the panel is too short to show them
    if (sv.files.length) { SAMA.alert("Saved here:\n\n" + sv.files.join("\n")); }
    else if (sv.opened) { SAMA.alert("No new preset file was found.\n\nSAMA looked in:\n" + (SAMA.lastDirs || []).join("\n")); }
};
// mode "keys": only the animated (keyframed) properties go into the preset. mode "layers": the whole layer.
SAMA.vaultSave = function (onDone, mode) {
    var comp = SAMA.getComp(), layers, i, arr = [];
    if (!comp) { return; }
    layers = comp.selectedLayers;
    if (!layers.length) { SAMA.alert("Select one or more layers to save as presets first."); return; }
    if (!SAMA.needWrite()) { return; }
    for (i = 0; i < layers.length; i++) { arr.push(layers[i]); }
    if (mode === "layers") { SAMA.vaultSaveLayers(comp, arr, onDone); return; }
    SAMA.sv = { comp: comp, layers: arr, idx: 0, mode: mode, onDone: onDone, root: SAMA.libRoot(), cmd: SAMA.findCmd(["Save Animation Preset...", "Save Animation Preset\u2026"], 3075), saved: 0, opened: 0, files: [], skipped: [], snap: null, meta: null, keepParent: (SAMA.settings.get("vault.parent", "0") === "1") };
    SAMA.svGo();
    SAMA.svOpen(false);
};
// "Layers": SAMA writes the whole layer to the library itself - no After Effects window, no .ffx
SAMA.vaultSaveLayers = function (comp, arr, onDone) {
    var res = null, msg;
    SAMA.run("Save layers", function () { res = SAMA.saveOwnLayers(comp, arr, SAMA.libRoot(), (SAMA.settings.get("vault.parent", "0") === "1")); });
    if (!res) { return; }
    msg = res.files.length ? ("Saved " + res.files.length + " layer" + (res.files.length === 1 ? "" : "s")) : "Nothing was saved";
    SAMA.vaultFlash = msg;
    if (onDone) { onDone(); }
    if (res.files.length || res.notes.length) { SAMA.alert((res.files.length ? "Saved here:\n\n" + res.files.join("\n") : "") + (res.notes.length ? (res.files.length ? "\n\n" : "") + res.notes.join("\n") : "")); }
};
SAMA.buildSaveRun = function (root) {
    var C = SAMA.C, defs = [], st;
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    SAMA.boldLabel(root, "Saving presets");
    st = root.add("statictext", undefined, "Layer 1 of 1\n\nIn After Effects' Save window type a name and press Save.\nThen press the button below.", { multiline: true });   // starts with a full-size text so the space is reserved
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 96]; st.minimumSize = [100, 96]; SAMA.tint(st, C.text);
    SAMA.rowAdd(defs, false, { kind: "primary", minW: 90, icon: "check", label: function () { var sv = SAMA.sv; return (sv && sv.idx >= sv.layers.length - 1) ? "Finish" : "Next layer"; }, tip: "Press this after you saved in After Effects' window", fn: SAMA.guard("Save: next", function () { SAMA.svNext(false); }) }, 150, 34);
    SAMA.rowAdd(defs, false, { minW: 90, label: "Skip", tip: "Do not save this layer", fn: SAMA.guard("Save: skip", function () { SAMA.svNext(true); }) }, 100, 34);
    SAMA.svUI = { st: st, fr: SAMA.flowRow(root, defs, { h: 34 }) };
    defs = [];
    SAMA.rowAdd(defs, false, { minW: 90, label: "Window did not open", tip: "Try again with only the layer selected", fn: SAMA.guard("Save: retry", function () { if (SAMA.sv) { SAMA.svOpen(true); } }) }, 190, 30);
    SAMA.rowAdd(defs, false, { minW: 60, label: "Stop", tip: "Stop and go back to the Vault", fn: SAMA.guard("Save: stop", function () { SAMA.svFinish(); }) }, 80, 30);
    SAMA.flowRow(root, defs, { h: 30 });
};
SAMA.vaultImportFiles = function (files) {
    var i, f, dest, n = 0, root;
    if (!files) { return 0; }
    if (!(files instanceof Array)) { files = [files]; }
    if (!SAMA.needWrite()) { return 0; }
    root = SAMA.libRoot();
    for (i = 0; i < files.length; i++) {
        f = files[i];
        if (!(f instanceof File) || !/\.(ffx|samalayer)$/i.test(f.displayName)) { continue; }
        dest = new File(root.fsName + "/" + f.displayName);
        if (dest.exists && !SAMA.confirm("\"" + f.displayName + "\" already exists in the library. Overwrite?")) { continue; }
        if (f.copy(dest)) { n++; try { if (SAMA.metaFile(f).exists) { SAMA.metaFile(f).copy(SAMA.metaFile(dest)); } } catch (e5) { } }
    }
    return n;
};
SAMA.vaultApply = function (rec, toNew, atCTI) {
    var comp = SAMA.getComp(), targets, prev, oldTime, i, t, n, meta = null, warn = "";
    if (!comp) { return; }
    if (!rec) { SAMA.alert("Pick a preset in the list first."); return; }
    if (!rec.file.exists) { SAMA.alert("That preset file is missing. Refresh the library."); return; }
    if (!toNew && comp.selectedLayers.length === 0) { SAMA.alert("Select a layer to apply the preset to, or use \"Apply layer\"."); return; }
    if (SAMA.isOwn(rec.file)) { SAMA.vaultApplyOwn(comp, rec, toNew, atCTI); return; }
    SAMA.run("Apply layer preset", function () {
        prev = comp.selectedLayers; oldTime = comp.time;
        SAMA.curExt = prev.length ? SAMA.layerExtent(comp, prev, false) : null; SAMA.curTop = prev.length ? SAMA.topLayer(prev) : null;
        if (toNew) {
            meta = SAMA.readLayerMeta(rec.file);
            if (meta) { SAMA.layerNote = ""; n = SAMA.layerFromMeta(comp, meta, rec.base); warn = SAMA.layerNote; SAMA.place(n); if (!SAMA.curExt) { n.startTime = atCTI ? oldTime : 0; } }   // a complete layer: the right type, source and length
            else { n = SAMA.addNull(comp, rec.base); if (!SAMA.curExt) { n.startTime = atCTI ? oldTime : 0; } }
            targets = [n];
        } else { targets = prev; }
        try {
            for (i = 0; i < targets.length; i++) {
                // presets start at the current time indicator; park it at the layer's start when offset is off
                t = atCTI ? oldTime : targets[i].inPoint;
                comp.time = t;
                SAMA.selectOnly(comp, [targets[i]]);
                targets[i].applyPreset(rec.file);
                if (meta && toNew) { t = SAMA.layerFromMetaAfter(comp, targets[i], meta); if (t) { warn += (warn ? "\n" : "") + t; } }
            }
        } finally {
            SAMA.curExt = null; SAMA.curTop = null;
            comp.time = oldTime;
            SAMA.selectOnly(comp, toNew ? targets : prev);
        }
    });
    if (warn) { SAMA.alert(warn); }
};

// A saved layer (.samalayer): "Apply layer" builds it on the timeline; "Apply preset/.ffx" puts its effects, animation and settings on the selected layer(s).
SAMA.vaultApplyOwn = function (comp, rec, toNew, atCTI) {
    var file = SAMA.readOwn(rec.file), prev, oldTime, targets, nl = [], n, i, k, warn = "", missed = [], base, mm, want, got, lack = "", e, m2, gs, ent, par;
    if (!file) { SAMA.alert("That layer file could not be read. It may be damaged."); return; }
    SAMA.run("Apply saved layer", function () {
        prev = comp.selectedLayers; oldTime = comp.time;
        SAMA.curExt = prev.length ? SAMA.layerExtent(comp, prev, false) : null; SAMA.curTop = prev.length ? SAMA.topLayer(prev) : null;
        try {
            if (toNew) {
                gs = SAMA.curExt ? SAMA.curExt.start : (atCTI ? oldTime : 0);
                for (i = 0; i < file.layers.length; i++) {
                    ent = file.layers[i];
                    SAMA.layerNote = ""; n = SAMA.layerFromMeta(comp, ent.meta || { kind: "null", name: rec.base }, rec.base);
                    if (SAMA.layerNote) { warn += (warn ? "\n" : "") + SAMA.layerNote; }
                    if (file.layers.length > 1) { try { n.startTime = gs + (ent.off || 0); } catch (eS) { } }
                    else if (!SAMA.curExt) { n.startTime = atCTI ? oldTime : 0; }
                    nl.push(n);
                }
                for (i = nl.length - 2; i >= 0; i--) { try { nl[i].moveBefore(nl[i + 1]); } catch (eM) { } }     // the stack order of the original
                SAMA.place(nl[nl.length - 1]);                                                                     // keep the order: move the lowest one, then bring the rest along
                for (i = nl.length - 2; i >= 0; i--) { try { nl[i].moveBefore(nl[i + 1]); } catch (eM2) { } }
                targets = nl;
            } else { targets = prev; }
            for (i = 0; i < targets.length; i++) {
                ent = toNew ? file.layers[i] : file.layers[(file.layers.length === targets.length) ? i : 0];
                base = toNew ? targets[i].inPoint : (atCTI ? oldTime : targets[i].inPoint);
                SAMA.selectOnly(comp, [targets[i]]);
                mm = SAMA.applyOwn(comp, targets[i], { meta: ent.meta, tree: ent.tree }, base);
                if (mm.length) { missed = missed.concat(mm); }
                try {                                                           // check what really arrived on the layer
                    want = SAMA.countTree(ent.tree); got = SAMA.countTree(SAMA.layerTree(targets[i], { skipped: [] }));
                    if (got.keyed < want.keyed || got.fx < want.fx) { lack += (lack ? "\n" : "") + targets[i].name + ": saved " + SAMA.countText(want) + " - rebuilt " + SAMA.countText(got); }
                } catch (eC) { }
            }
            if (toNew) {
                for (i = 0; i < targets.length; i++) {
                    ent = file.layers[i]; par = null;
                    if (!ent.meta) { continue; }
                    m2 = {}; for (k in ent.meta) { m2[k] = ent.meta[k]; }
                    if (ent.pi !== undefined && targets[ent.pi]) { par = targets[ent.pi]; delete m2.parent; }
                    mm = SAMA.layerFromMetaAfter(comp, targets[i], m2); if (mm) { warn += (warn ? "\n" : "") + mm; }
                    if (par) { try { targets[i].parent = par; } catch (eP) { } }
                }
            }
        } finally {
            SAMA.curExt = null; SAMA.curTop = null;
            comp.time = oldTime;
            SAMA.selectOnly(comp, toNew ? targets : prev);
        }
    });
    if (missed.length) { warn += (warn ? "\n" : "") + "Could not restore: " + missed.slice(0, 12).join(", ") + (missed.length > 12 ? " ..." : ""); }
    if (lack) { warn += (warn ? "\n" : "") + "Not everything came back:\n" + lack; }
    if (warn) { SAMA.alert(warn); }
};

// Preset list: drawn by us so it matches the theme and scrolls with two small arrows inside it (no fat native scrollbar).
SAMA.listBox = function (parent) {
    var cv = parent.add("customButton", undefined, ""), C = SAMA.C, RH = 22, rows = [], sel = -1, top = 0, hot = -1, lastT = 0, lastRow = -1;
    cv.alignment = ["fill", "top"]; cv.preferredSize = [220, 120]; cv.minimumSize = [100, 70];
    function vis() { return Math.max(1, Math.floor((cv.size.height - 4) / RH)); }
    function maxTop() { return Math.max(0, rows.length - vis()); }
    function redraw() { try { cv.notify("onDraw"); } catch (e) { } }
    cv.redraw = redraw;
    cv.removeAll = function () { rows = []; sel = -1; top = 0; hot = -1; };
    cv.add = function (kind, label) { rows.push(label); return null; };
    cv.getSel = function () { return sel; };
    cv.setSel = function (i) {
        sel = i;
        if (i >= 0) { if (i < top) { top = i; } else if (i >= top + vis()) { top = i - vis() + 1; } }
        top = Math.max(0, Math.min(maxTop(), top)); redraw();
    };
    cv.scrollRows = function (d) { var t = Math.max(0, Math.min(maxTop(), top + d)); if (t !== top) { top = t; redraw(); } };
    cv.onDraw = function () {
        var g = this.graphics, W = this.size.width, H = this.size.height, f = SAMA.font(g, 10), i, y, n = vis(), over = rows.length > n, tw = over ? W - 26 : W - 12, keep = SAMA.alpha, c, z;
        g.newPath(); g.rectPath(0, 0, W, H); g.fillPath(SAMA.brush(g, C.bg));
        SAMA.rrect(g, 0.5, 0.5, W - 1, H - 1, 8); g.fillPath(SAMA.brush(g, [0.1, 0.1, 0.11, 1]));
        if (!rows.length) { SAMA.text(g, "No presets yet - use Save", f, C.dim, W / 2, H / 2 - 7, W - 16, true); }
        for (i = top; i < rows.length && i < top + n + 1; i++) {
            y = 3 + (i - top) * RH;
            if (y + RH > H - 2) { break; }
            if (i === sel) {
                if (C.grad2) { SAMA.gradFill(g, 4, y, W - (over ? 24 : 8), RH - 2, 6, C.title, C.grad2, [0.1, 0.1, 0.11, 1]); }
                else { SAMA.rrect(g, 4, y, W - (over ? 24 : 8), RH - 2, 6); g.fillPath(SAMA.brush(g, C.title)); }
            }
            else if (i === hot) { SAMA.rrect(g, 4, y, W - (over ? 24 : 8), RH - 2, 6); g.fillPath(SAMA.brush(g, C.hot)); }
            SAMA.text(g, rows[i], f, i === sel ? C.onHi : C.text, 10, y + (RH - 14) / 2, tw - 4, false);
        }
        if (over) {
            for (z = 0; z < 2; z++) {
                c = (z === 0) ? top <= 0 : top >= maxTop();
                SAMA.alpha = c ? 0.35 : 1;
                y = (z === 0) ? 13 : H - 13;
                g.newPath(); g.moveTo(W - 17, y + (z === 0 ? 2 : -2)); g.lineTo(W - 12, y + (z === 0 ? -2 : 2)); g.lineTo(W - 7, y + (z === 0 ? 2 : -2));
                g.strokePath(SAMA.pen(g, C.dim, 1.8));
            }
            SAMA.alpha = keep;
        }
        SAMA.rrect(g, 0.5, 0.5, W - 1, H - 1, 8); g.strokePath(SAMA.pen(g, C.line, 1));
    };
    function rowAt(y) { var r = top + Math.floor((y - 3) / RH); return (r >= 0 && r < rows.length && r < top + vis()) ? r : -1; }
    cv.addEventListener("mousedown", SAMA.guard("Preset list: click", function (ev) {
        var W = cv.size.width, H = cv.size.height, r, now = SAMA.now();
        if (rows.length > vis() && ev.clientX >= W - 24) {                       // the two arrows
            if (ev.clientY < H / 2) { cv.scrollRows(-3); } else { cv.scrollRows(3); }
            return;
        }
        r = rowAt(ev.clientY);
        if (r < 0) { return; }
        sel = r; redraw();
        if (r === lastRow && now - lastT < 450) { lastRow = -1; if (cv.onDoubleClick) { cv.onDoubleClick(); } }
        else { lastRow = r; lastT = now; }
    }));
    cv.addEventListener("mousemove", function (ev) { var r = (ev.clientX >= cv.size.width - 24 && rows.length > vis()) ? -1 : rowAt(ev.clientY); if (r !== hot) { hot = r; redraw(); } });
    cv.addEventListener("mouseout", function () { if (hot !== -1) { hot = -1; redraw(); } });
    try { cv.addEventListener("mousewheel", function (ev) { var d = ev.deltaY || -ev.wheelDelta || ev.detail || 0; if (d) { cv.scrollRows(d > 0 ? 2 : -2); } }); } catch (e1) { }
    return cv;
};
SAMA.buildVault = function (root) {
    var recs = [], shown = [], idx = {}, ui = SAMA.ui, C = SAMA.C;
    var search, catDD, lb, chkCTI, folderInfo, top, rowA, acts;
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.margins = 0; root.spacing = 10;

    top = ui.row(root);
    search = top.add("edittext", undefined, SAMA.settings.get("vault.search", "")); search.alignment = ["fill", "center"];
    search.helpTip = "Search presets by name or category"; search.minimumSize = [50, 24];
    catDD = top.add("dropdownlist", undefined, ["All"]); catDD.alignment = ["right", "center"]; catDD.preferredSize = [96, 24]; catDD.minimumSize = [72, 24];
    catDD.helpTip = "Filter by category or favorites";

    lb = SAMA.listBox(root);
    root.fit = function (ph) {            // the list takes exactly the height that is left over
        var i, ch, others = 0, n = 0, h;
        for (i = 0; i < root.children.length; i++) {
            ch = root.children[i]; if (ch === lb) { continue; }
            h = 0; try { h = ch.size.height; } catch (e) { }
            if (!(h > 0)) { try { h = ch.preferredSize.height; } catch (e2) { } }
            others += h; n++;
        }
        h = Math.floor(ph - others - n * root.spacing);
        root.compact = h > 150;                // a short list: the page keeps its natural height instead of stretching
        h = Math.max(90, Math.min(150, h));
        lb.preferredSize = [lb.preferredSize.width, h]; lb.minimumSize = [100, h];
    };
    folderInfo = root.add("statictext", undefined, "", { truncate: "middle" }); folderInfo.alignment = ["fill", "top"]; SAMA.tint(folderInfo, C.dim);
    chkCTI = root.add("checkbox", undefined, "Start at current time indicator");
    chkCTI.value = SAMA.settings.get("vault.cti", "1") === "1"; chkCTI.alignment = ["fill", "top"]; SAMA.tint(chkCTI, C.text);
    chkCTI.helpTip = "On: the preset's animation starts at the CTI. Off: it starts at the layer's in point.";
    chkCTI.onClick = function () { SAMA.settings.set("vault.cti", this.value ? "1" : "0"); };

    function currentCat() { return catDD.selection ? catDD.selection.text : "All"; }
    function selectedRec() { return lb.getSel() >= 0 ? shown[lb.getSel()] : null; }
    function refreshCats(keep) {
        var cats = {}, names = [], i, k, want = keep || currentCat();
        for (i = 0; i < recs.length; i++) { if (recs[i].cat) { cats[recs[i].cat] = true; } }
        for (k in cats) { if (cats.hasOwnProperty(k)) { names.push(k); } }
        names.sort();
        catDD.removeAll();
        catDD.add("item", "All"); catDD.add("item", "★ Favorites"); catDD.add("item", "Uncategorized");
        for (i = 0; i < names.length; i++) { catDD.add("item", names[i]); }
        catDD.selection = 0;
        for (i = 0; i < catDD.items.length; i++) { if (catDD.items[i].text === want) { catDD.selection = i; } }
    }
    function refreshList(keepBase) {
        var q = SAMA.trim(search.text).toLowerCase(), cat = currentCat(), i, r, label, sel = -1;
        lb.removeAll(); shown = [];
        for (i = 0; i < recs.length; i++) {
            r = recs[i];
            if (cat === "★ Favorites" && !r.fav) { continue; }
            if (cat === "Uncategorized" && r.cat) { continue; }
            if (cat !== "All" && cat !== "★ Favorites" && cat !== "Uncategorized" && r.cat !== cat) { continue; }
            if (q && (r.base.toLowerCase().indexOf(q) < 0 && r.cat.toLowerCase().indexOf(q) < 0)) { continue; }
            label = (r.fav ? "★ " : "    ") + r.base + (r.cat ? "   [" + r.cat + "]" : "");
            lb.add("item", label); shown.push(r);
            if (keepBase && r.base === keepBase) { sel = shown.length - 1; }
        }
        if (sel >= 0) { lb.setSel(sel); }
        lb.redraw();
        folderInfo.text = SAMA.vaultFlash ? SAMA.vaultFlash : (recs.length + " preset" + (recs.length === 1 ? "" : "s") + "  -  " + SAMA.libRoot().fsName);
        SAMA.vaultFlash = null;
    }
    function reload(keepBase) { recs = SAMA.scanLibrary(); idx = SAMA.loadIndex(); refreshCats(); refreshList(keepBase); }
    function writeMeta(base, patch) {
        var m = idx[base] || {}, k;
        for (k in patch) { if (patch.hasOwnProperty(k)) { m[k] = patch[k]; } }
        idx[base] = m;
        return SAMA.saveIndex(idx);
    }
    search.onChanging = function () { SAMA.settings.set("vault.search", this.text); refreshList(selectedRec() ? selectedRec().base : null); };
    catDD.onChange = function () { refreshList(null); };
    lb.onDoubleClick = function () { SAMA.vaultApply(selectedRec(), false, chkCTI.value); };

    rowA = [];
    SAMA.rowAdd(rowA, false, { kind: "primary", left: true, minW: 70, icon: "check", label: "Apply preset/.ffx", tip: "Apply the selected preset (.ffx, or a saved layer's effects and animation) to the selected layer(s)",
        fn: function () { SAMA.vaultApply(selectedRec(), false, chkCTI.value); } }, 140, 34);
    SAMA.rowAdd(rowA, false, { icon: "plusbox", minW: 80, label: "Apply layer", tip: "Bring the saved layer onto the timeline as a new layer",
        fn: function () { SAMA.vaultApply(selectedRec(), true, chkCTI.value); } }, 90, 34);
    SAMA.flowRow(root, rowA, { h: 34 });

    acts = [
        { icon: "save", label: "Save", tip: "Save the selected layer(s): the whole layer, or only the keyframes (.ffx)", fn: function () {
            if (!SAMA.getComp()) { return; }
            if (!SAMA.getComp().selectedLayers.length) { SAMA.alert("Select one or more layers to save as presets first."); return; }
            if (SAMA.askSave) { SAMA.askSave(function (mode) { SAMA.vaultSave(function () { reload(null); }, mode); }); } else { SAMA.vaultSave(function () { reload(null); }, "layers"); }
        } },
        { icon: "refresh", label: "Refresh", tip: "Rescan the library folder", fn: function () { reload(selectedRec() ? selectedRec().base : null); } },
        { icon: "star", label: "Favorite", tip: "Toggle favorite", fn: function () {
            var r = selectedRec(); if (!r) { return; }
            if (writeMeta(r.base, { fav: !r.fav })) { reload(r.base); }
        } },
        { icon: "tag", label: "Category", tip: "Set the category of the selected preset", fn: function () {
            var r = selectedRec(), c; if (!r) { return; }
            c = SAMA.prompt("Category (leave empty for none):", r.cat);
            if (c === null) { return; }
            if (writeMeta(r.base, { cat: SAMA.trim(c) })) { reload(r.base); }
        } },
        { icon: "pencil", label: "Rename", tip: "Rename the selected preset file", fn: function () {
            var r = selectedRec(), nn, safe, meta, sc;
            if (!r) { return; }
            nn = SAMA.prompt("New preset name:", r.base);
            if (!nn) { return; }
            safe = SAMA.trim(nn).replace(/[\\\/:*?"<>|]/g, "_");
            if (!safe || safe === r.base || !SAMA.needWrite()) { return; }
            if (new File(r.file.parent.fsName + "/" + safe + SAMA.libExt(r.file)).exists) { SAMA.alert("A preset with that name already exists."); return; }
            sc = SAMA.metaFile(r.file);
            if (!r.file.rename(safe + SAMA.libExt(r.file))) { SAMA.alert("Rename failed."); return; }
            try { if (sc.exists) { sc.rename(safe + ".sama.json"); } } catch (e8) { }
            meta = idx[r.base]; delete idx[r.base]; if (meta) { idx[safe] = meta; }
            SAMA.saveIndex(idx); reload(safe);
        } },
        { icon: "trash", label: "Delete", tip: "Delete the selected preset file", fn: function () {
            var r = selectedRec();
            if (!r || !SAMA.needWrite()) { return; }
            if (!SAMA.confirm("Permanently delete preset \"" + r.base + "\"?")) { return; }
            try { SAMA.metaFile(r.file).remove(); } catch (e9) { }
            r.file.remove(); delete idx[r.base]; SAMA.saveIndex(idx); reload(null);
        } },
        { icon: "import", label: "Import", tip: "Import saved layers (.samalayer) or .ffx presets into the library", fn: function () {
            var n = SAMA.vaultImportFiles(SAMA.openDlg("Choose presets to import", "SAMA presets:*.samalayer;*.ffx,All files:*.*", true));
            if (n) { reload(null); }
        } },
        { icon: "export", label: "Export", tip: "Copy the whole library (presets + index) to a folder", fn: function () {
            var dest = SAMA.folderDlg("Choose a folder to export the library into"), files, i, n = 0, ix;
            if (!dest || !SAMA.needWrite()) { return; }
            files = SAMA.libRoot().getFiles();
            for (i = 0; i < files.length; i++) { if (files[i] instanceof File && /\.(ffx|samalayer|json)$/i.test(files[i].displayName) && files[i].displayName !== "_sama_index.json" && files[i].copy(dest.fsName + "/" + files[i].displayName)) { if (!/\.json$/i.test(files[i].displayName)) { n++; } } }
            ix = SAMA.indexFile(); if (ix.exists) { ix.copy(dest.fsName + "/_sama_index.json"); }
            SAMA.alert("Exported " + n + " preset(s) to:\n" + dest.fsName);
        } },
        { icon: "folder", label: "Open", tip: "Open the library folder in Explorer / Finder", fn: function () { SAMA.libRoot().execute(); } },
        { icon: "get", label: "Folder", tip: "Choose a different library folder", fn: function () {
            var f = SAMA.folderDlg("Choose the SAMA library folder");
            if (f) { SAMA.settings.set("libPath", f.fsName); reload(null); }
        } }
    ];
    SAMA.grid(root, acts, { cols: 4, minW: 56, h: 46, minCols: 2, maxCols: 5 });
    SAMA.vaultReload = function () { reload(null); };
    reload(null);
};

/* ==========================================================================
   PANEL 3 - SAMA QUICK EDIT
   ========================================================================== */

// ---------------- Nulls ----------------
SAMA.nullAtSelected = function () {
    SAMA.act("Null at layers", "layers", function (comp, layers) {
        var made = [], i, l, n, v;
        for (i = 0; i < layers.length; i++) {
            l = layers[i];
            n = SAMA.addNull(comp, "Null - " + l.name);
            try { n.threeDLayer = l.threeDLayer; } catch (e) { }
            v = SAMA.worldPos(n, l);
            try { SAMA.tp(n, "ADBE Position").setValue(v); } catch (e2) { }
            n.inPoint = l.inPoint; n.outPoint = l.outPoint;
            n.moveBefore(l); made.push(n);
        }
        SAMA.selectOnly(comp, made);
    });
};
SAMA.parentToNull = function () {
    SAMA.act("Parent to new null", "layers", function (comp, layers) {
        var n = SAMA.addNull(comp, "Parent Null"), sx = 0, sy = 0, sz = 0, i, v, top = layers[0], anyTD = false;
        for (i = 0; i < layers.length; i++) { if (layers[i].threeDLayer) { anyTD = true; } if (layers[i].index < top.index) { top = layers[i]; } }
        n.threeDLayer = anyTD;
        for (i = 0; i < layers.length; i++) {
            v = SAMA.worldPos(n, layers[i]);
            sx += v[0]; sy += v[1]; sz += (v.length > 2 ? v[2] : 0);
        }
        v = anyTD ? [sx / layers.length, sy / layers.length, sz / layers.length] : [sx / layers.length, sy / layers.length];
        SAMA.tp(n, "ADBE Position").setValue(v);
        n.moveBefore(top);
        for (i = 0; i < layers.length; i++) { SAMA.setParent(layers[i], n); }
        SAMA.selectOnly(comp, [n]);
    });
};
SAMA.centerNull = function () {
    SAMA.act("Center null", "", function (comp) {
        var n = SAMA.addNull(comp, "Center Null");
        SAMA.tp(n, "ADBE Position").setValue([comp.width / 2, comp.height / 2]);
        SAMA.tp(n, "ADBE Anchor Point").setValue([50, 50]);   // centre of the 100x100 null
        SAMA.selectOnly(comp, [n]);
    });
};
SAMA.controllerNull = function () {
    SAMA.act("Controller null", "", function (comp) {
        var n = SAMA.addNull(comp, "Controller");
        SAMA.ensureCtl(n, "ADBE Slider Control", "Amount", 50);
        SAMA.ensureCtl(n, "ADBE Slider Control", "Speed", 1);
        SAMA.ensureCtl(n, "ADBE Slider Control", "Offset", 0);
        SAMA.ensureCtl(n, "ADBE Checkbox Control", "Toggle", 1);
        SAMA.ensureCtl(n, "ADBE Color Control", "Color");
        SAMA.tp(n, "ADBE Position").setValue([comp.width / 2, comp.height / 2]);
        SAMA.selectOnly(comp, [n]);
    });
};

// ---------------- Text ----------------
// ---------------- Colours: layer label colours + the picked fill colour ----------------
// After Effects label numbers: 0 None (default grey), 1 Red, 2 Yellow, 3 Aqua, 4 Pink, 5 Lavender, 6 Peach, 7 Sea Foam, 8 Blue, 9 Green, 10 Purple, 11 Orange ...
SAMA.labelFor = { text: 1, adjustment: 0, solid: 8, nullobj: 2, shape: 10, light: 11, camera: 9 };
SAMA.tag = function (layer, kind) {
    if (SAMA.settings.get("q.labels", "1") !== "1") { return layer; }
    try { layer.label = SAMA.labelFor[kind]; } catch (e) { }
    return layer;
};
SAMA.hsv2rgb = function (h, s, v) {
    var i, f, p, q, t;
    h = (h - Math.floor(h)) * 6; i = Math.floor(h); f = h - i;
    p = v * (1 - s); q = v * (1 - f * s); t = v * (1 - (1 - f) * s);
    switch (i % 6) {
        case 0: return [v, t, p]; case 1: return [q, v, p]; case 2: return [p, v, t];
        case 3: return [p, q, v]; case 4: return [t, p, v]; default: return [v, p, q];
    }
};
SAMA.rgb2hsv = function (c) {
    var r = c[0], g = c[1], b = c[2], mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn, h = 0;
    if (d > 0) {
        if (mx === r) { h = ((g - b) / d) % 6; } else if (mx === g) { h = (b - r) / d + 2; } else { h = (r - g) / d + 4; }
        h /= 6; if (h < 0) { h += 1; }
    }
    return [h, mx > 0 ? d / mx : 0, mx];
};
SAMA.rgb2hex = function (c) {
    var i, o = "#", n, t;
    for (i = 0; i < 3; i++) { n = Math.round(SAMA.clamp(c[i], 0, 1) * 255); t = n.toString(16).toUpperCase(); o += (t.length < 2 ? "0" : "") + t; }
    return o;
};
SAMA.hex2rgb = function (t) {
    var m = /^\s*#?([0-9a-fA-F]{6})\s*$/.exec(String(t));
    if (!m) { return null; }
    return [parseInt(m[1].substr(0, 2), 16) / 255, parseInt(m[1].substr(2, 2), 16) / 255, parseInt(m[1].substr(4, 2), 16) / 255];
};
SAMA.pick = SAMA.hex2rgb(SAMA.settings.get("q.color", ""));      // null = no colour chosen yet (solids stay grey, text keeps its default)
SAMA.setPick = function (c, save) {
    SAMA.pick = c ? [c[0], c[1], c[2]] : null;
    if (save) { SAMA.settings.set("q.color", c ? SAMA.rgb2hex(c) : ""); }
};
// A pop-up colour wheel. Returns [r,g,b] (0-1) or null when cancelled.
SAMA.askColor = function (title, start) {
    var w, cur, wheel, row, sw, hex, bt, res = null, lastHex = 0;
    cur = start ? [start[0], start[1], start[2]] : [1, 1, 1];
    w = new Window("dialog", title);
    w.orientation = "column"; w.alignChildren = ["fill", "top"]; w.margins = 14; w.spacing = 10;
    SAMA.skin(w);
    wheel = SAMA.colorWheel(w, function (rgb) {
        var now = SAMA.now();
        cur = rgb; try { sw.notify("onDraw"); } catch (e) { }
        if (now - lastHex > 90) { lastHex = now; hex.text = SAMA.rgb2hex(rgb); }
    }, function () { hex.text = SAMA.rgb2hex(cur); try { sw.notify("onDraw"); } catch (e) { } });
    wheel.preferredSize = [250, 214];
    row = w.add("group"); row.orientation = "row"; row.alignChildren = ["left", "center"]; row.spacing = 10;
    sw = row.add("customButton", undefined, ""); sw.preferredSize = [44, 26]; sw.minimumSize = [44, 26];
    sw.onDraw = function () {
        var g = this.graphics;
        SAMA.rrect(g, 0, 0, this.size.width, this.size.height, 6); g.fillPath(SAMA.brush(g, [cur[0], cur[1], cur[2], 1]));
    };
    hex = row.add("edittext", undefined, SAMA.rgb2hex(cur)); hex.preferredSize = [92, 24]; hex.helpTip = "Type a colour such as #FF3B30 and press Enter";
    hex.onChange = function () {
        var c = SAMA.hex2rgb(this.text);
        if (c) { cur = c; wheel.setColor(c); try { sw.notify("onDraw"); } catch (e) { } } else { this.text = SAMA.rgb2hex(cur); }
    };
    bt = row.add("button", undefined, "System picker..."); bt.helpTip = "Use the operating system's own colour picker instead";
    bt.onClick = function () {
        var n = -1;
        try { n = SAMA.modal(function () { return $.colorPicker(Math.round(cur[0] * 255) * 65536 + Math.round(cur[1] * 255) * 256 + Math.round(cur[2] * 255)); }); } catch (e) { }
        if (n >= 0) { cur = [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; wheel.setColor(cur); hex.text = SAMA.rgb2hex(cur); try { sw.notify("onDraw"); } catch (e2) { } }
    };
    row = w.add("group"); row.orientation = "row"; row.alignment = ["right", "top"]; row.spacing = 10;
    bt = row.add("button", undefined, "OK", { name: "ok" }); bt.onClick = function () { wheel.release(); res = cur; w.close(1); };
    try { bt.addEventListener("mousedown", function () { wheel.release(); }); } catch (e4) { }      // pressing OK ends any drag still pending
    bt = row.add("button", undefined, "Cancel", { name: "cancel" }); bt.onClick = function () { wheel.release(); w.close(0); };
    wheel.setColor(cur);
    try { w.addEventListener("mouseup", function () { wheel.release(); }); } catch (e3) { }   // a button released anywhere in the window ends the drag
    SAMA.modal(function () { w.show(); });
    return res;
};
// The colour to use for a new solid / text. false = the user cancelled (do nothing), null = no colour chosen yet.
SAMA.colorFor = function (title, force) {
    var c;
    if (!SAMA.getComp()) { return false; }
    if (force || SAMA.settings.get("q.askColor", "1") === "1") {
        c = SAMA.askColor(title, SAMA.pick);
        if (!c) { return false; }
        SAMA.setPick(c, true);
        return c;
    }
    return SAMA.pick;
};
// Recolour the selection: text fill colour, and the colour of solid layers (a solid's colour belongs to its source, so layers sharing it change together)
SAMA.recolour = function () {
    var c;
    if (!SAMA.getComp()) { return; }
    if (!SAMA.getComp().selectedLayers.length) { SAMA.alert("Select text layers or solid layers to recolour."); return; }
    c = SAMA.colorFor("Recolour selected layers", true);
    if (!c) { return; }
    SAMA.act("Recolour", "layers", function (comp, layers) {
        var i, l, n = 0, p, td;
        for (i = 0; i < layers.length; i++) {
            l = layers[i];
            try {
                if (l instanceof TextLayer) {
                    p = SAMA.tdProp(l);
                    if (p.numKeys > 0) { continue; }
                    td = p.value; td.fillColor = [c[0], c[1], c[2]]; td.applyFill = true; p.setValue(td); n++;
                } else if (l.source && !l.adjustmentLayer && l.source.mainSource instanceof SolidSource) {
                    l.source.mainSource.color = [c[0], c[1], c[2]]; n++;
                }
            } catch (e) { }
        }
        if (!n) { SAMA.alert("Select text layers or solid layers to recolour."); }
    });
};
SAMA.newText = function () {
    var col = SAMA.colorFor("Text colour");
    if (col === false) { return; }
    SAMA.act("New text layer", "", function (comp) {
        var t = comp.layers.addText("Text"), p = SAMA.tdProp(t), td = p.value; SAMA.span(t);
        td.justification = ParagraphJustification.CENTER_JUSTIFY;
        if (col) { td.fillColor = [col[0], col[1], col[2]]; td.applyFill = true; }
        p.setValue(td);
        SAMA.tp(t, "ADBE Position").setValue([comp.width / 2, comp.height / 2]);
        SAMA.tag(t, "text");
        SAMA.selectOnly(comp, [t]);
    });
};
SAMA.mapText = function (label, fn) {   // fn(td, layer) mutates the TextDocument
    SAMA.act(label, "layers", function (comp, layers) {
        var tl = SAMA.textLayers(layers), i, p, td, skipped = 0;
        if (!tl.length) { SAMA.alert("Select one or more text layers."); return; }
        for (i = 0; i < tl.length; i++) {
            p = SAMA.tdProp(tl[i]);
            if (p.numKeys > 0) { skipped++; continue; }
            td = p.value; fn(td, tl[i]); p.setValue(td);
        }
        if (skipped) { SAMA.alert(skipped + " layer(s) with Source Text keyframes were skipped."); }
    });
};
SAMA.textAlign = function (j) { SAMA.mapText("Text align", function (td) { td.justification = j; }); };
SAMA.textCase = function (kind) {
    SAMA.mapText("Text case", function (td) {
        var s = td.text;
        if (kind === "upper") { s = s.toUpperCase(); }
        else if (kind === "lower") { s = s.toLowerCase(); }
        else { s = s.toLowerCase().replace(/(^|[\s\-_(])([a-z\u00e0-\u00ff])/g, function (m, a, b) { return a + b.toUpperCase(); }); }
        td.text = s;
    });
};
SAMA.textStep = function (kind, sign, step) {
    SAMA.mapText("Text " + kind, function (td) {
        if (kind === "tracking") { td.tracking = (td.tracking || 0) + sign * step; }
        else {
            try { if (td.autoLeading) { td.autoLeading = false; td.leading = td.fontSize * 1.2; } } catch (e) { }
            td.leading = td.leading + sign * step;
        }
    });
};
// Split a text layer into one layer per line / word / character.
// Lines keep their position by prefixing blank lines; words and characters are positioned by
// measuring the text with sourceRectAtTime. Rotation / parenting of the source layer is not compensated.
SAMA.splitOne = function (comp, L, mode) {
    var srcP = SAMA.tdProp(L), td0, full, lines, t = comp.time, tmp = null, li, b, line, blanks, pieces, m, re, c, q, pc;
    var made = [], sx;
    function setText(layer, s, just) {
        var p = SAMA.tdProp(layer), td = p.value; td.text = s; if (just !== undefined) { td.justification = just; } p.setValue(td);
    }
    function rect(layer) { return layer.sourceRectAtTime(t, false); }
    if (srcP.numKeys > 0) { SAMA.alert("\"" + L.name + "\" has Source Text keyframes and was skipped."); return 0; }
    td0 = srcP.value; full = td0.text;
    lines = full.split(/\r\n|\r|\n/);
    sx = SAMA.tp(L, "ADBE Scale").value[0] / 100;
    if (mode !== "lines") { tmp = L.duplicate(); }
    for (li = 0; li < lines.length; li++) {
        line = lines[li];
        if (SAMA.trim(line) === "") { continue; }
        blanks = ""; for (b = 0; b < li; b++) { blanks += "\r"; }
        if (mode === "lines") {
            q = L.duplicate(); setText(q, blanks + line); q.name = line.substr(0, 30); made.push(q); continue;
        }
        pieces = [];
        if (mode === "words") { re = /\S+/g; while ((m = re.exec(line)) !== null) { pieces.push({ s: m.index, t: m[0] }); } }
        else { for (c = 0; c < line.length; c++) { if (!/\s/.test(line.charAt(c))) { pieces.push({ s: c, t: line.charAt(c) }); } } }
        setText(tmp, line, td0.justification); var lineRect = rect(tmp);
        setText(tmp, line, ParagraphJustification.LEFT_JUSTIFY); var lineLJ = rect(tmp);
        for (c = 0; c < pieces.length; c++) {
            pc = pieces[c];
            setText(tmp, line.substr(0, pc.s + pc.t.length), ParagraphJustification.LEFT_JUSTIFY);
            var rF = rect(tmp), fullRight = rF.left + rF.width;
            setText(tmp, pc.t, ParagraphJustification.LEFT_JUSTIFY);
            var rW = rect(tmp);
            var inkLeft = lineRect.left + ((fullRight - rW.width) - lineLJ.left);
            var originX = inkLeft - rW.left;
            q = L.duplicate(); setText(q, blanks + pc.t, ParagraphJustification.LEFT_JUSTIFY);
            SAMA.movePos(q, comp, originX * sx, 0);
            q.name = pc.t; made.push(q);
        }
    }
    if (tmp) { tmp.remove(); }
    L.remove();
    return made.length;
};
SAMA.splitText = function (mode) {
    SAMA.act("Split text into " + mode, "layers", function (comp, layers) {
        var tl = SAMA.textLayers(layers), i, est = 0, n = 0;
        if (!tl.length) { SAMA.alert("Select one or more text layers."); return; }
        for (i = 0; i < tl.length; i++) { est += SAMA.tdProp(tl[i]).value.text.length; }
        if (mode === "chars" && est > 300 && !SAMA.confirm("This will create about " + est + " layers. Continue?")) { return; }
        for (i = 0; i < tl.length; i++) { n += SAMA.splitOne(comp, tl[i], mode); }
        SAMA.alert("Created " + n + " text layer(s). Check the result and Undo (Ctrl/Cmd+Z) if the spacing looks off for your font.");
    });
};

// ---------------- Camera ----------------
SAMA.camTargets = function (comp, layers) {
    var out = [], i;
    for (i = 0; i < layers.length; i++) { if (layers[i] instanceof CameraLayer) { out.push(layers[i]); } }
    if (!out.length) { try { if (comp.activeCamera) { out.push(comp.activeCamera); } } catch (e) { } }
    if (!out.length) { SAMA.alert("Select a camera layer (or make one active) first."); }
    return out;
};
SAMA.newCameraRig = function () {
    SAMA.act("New camera + rig", "", function (comp) {
        var cam = comp.layers.addCamera("Camera", [comp.width / 2, comp.height / 2]), rig = comp.layers.addNull(SAMA.layerDur(comp));
        SAMA.span(cam); SAMA.span(rig); SAMA.tag(cam, "camera"); SAMA.tag(rig, "camera");
        rig.name = "Camera Rig"; rig.threeDLayer = true;
        SAMA.tp(rig, "ADBE Position").setValue([comp.width / 2, comp.height / 2, 0]);
        SAMA.setParent(cam, rig);
        SAMA.selectOnly(comp, [cam]);
    });
};
SAMA.cameraNodeToggle = function () {
    SAMA.act("Toggle one/two-node camera", "", function (comp, layers) {
        var cams = SAMA.camTargets(comp, layers), i, two, msg = "";
        for (i = 0; i < cams.length; i++) {
            two = (cams[i].autoOrient === AutoOrientType.CAMERA_OR_POINT_OF_INTEREST);
            cams[i].autoOrient = two ? AutoOrientType.NO_AUTO_ORIENT : AutoOrientType.CAMERA_OR_POINT_OF_INTEREST;
        }
    });
};
SAMA.cameraZoom = function (factor) {
    SAMA.act("Camera zoom", "", function (comp, layers) {
        var cams = SAMA.camTargets(comp, layers), i, z;
        for (i = 0; i < cams.length; i++) {
            z = cams[i].property("ADBE Camera Options Group").property("ADBE Camera Zoom");
            SAMA.setProp(z, comp, z.value * factor);
        }
    });
};
SAMA.cameraDolly = function (amount) {
    SAMA.act("Camera dolly", "", function (comp, layers) {
        var cams = SAMA.camTargets(comp, layers), i, p, v;
        for (i = 0; i < cams.length; i++) {
            p = SAMA.tp(cams[i], "ADBE Position"); v = p.value; v[2] += amount; SAMA.setProp(p, comp, v);
        }
    });
};
SAMA.cameraDoF = function () {
    SAMA.act("Toggle depth of field", "", function (comp, layers) {
        var cams = SAMA.camTargets(comp, layers), i, p;
        for (i = 0; i < cams.length; i++) {
            p = cams[i].property("ADBE Camera Options Group").property("ADBE Camera Depth of Field");
            p.setValue(p.value ? 0 : 1);
        }
    });
};

// ---------------- Layers ----------------
SAMA.precompose = function () {
    SAMA.act("Precompose", "layers", function (comp, layers) {
        var idx = [], i, name;
        for (i = 0; i < layers.length; i++) { idx.push(layers[i].index); }
        idx.sort(function (a, b) { return a - b; });
        name = SAMA.prompt("Pre-comp name:", "Pre-comp " + layers[0].name);
        if (!name) { return; }
        comp.layers.precompose(idx, name, true);
    });
};
SAMA.trimTo = function (which) {
    SAMA.act("Trim layers", "layers", function (comp, layers) {
        var i, l;
        for (i = 0; i < layers.length; i++) {
            l = layers[i];
            try {
                if (which === "in") { l.inPoint = comp.time; }
                else if (which === "out") { l.outPoint = comp.time; }
                else { l.inPoint = comp.workAreaStart; l.outPoint = comp.workAreaStart + comp.workAreaDuration; }
            } catch (e) { }
        }
    });
};
SAMA.sequence = function (gapFrames, reverse) {
    SAMA.act("Sequence layers", "layers", function (comp, layers) {
        var arr = layers.slice(0), i, cursor, gap = gapFrames * comp.frameDuration, l;
        arr.sort(function (a, b) { return a.index - b.index; });
        if (reverse) { arr.reverse(); }
        cursor = arr[0].inPoint;
        for (i = 0; i < arr.length; i++) {
            l = arr[i];
            l.startTime = l.startTime + (cursor - l.inPoint);
            cursor = l.outPoint + gap;
        }
    });
};
SAMA.reverseLayers = function () {
    SAMA.act("Reverse layer order", "layers", function (comp, layers) {
        var arr = layers.slice(0), n, i, a, b, ia, ib;
        if (arr.length < 2) { SAMA.alert("Select two or more layers."); return; }
        arr.sort(function (x, y) { return x.index - y.index; });
        n = arr.length;
        for (i = 0; i < Math.floor(n / 2); i++) {
            a = arr[i]; b = arr[n - 1 - i];
            ia = a.index; ib = b.index;
            b.moveBefore(a);                       // b takes a's slot, a slides down by one
            if (ib !== ia + 1) { a.moveAfter(comp.layer(ib)); }   // a drops into b's old slot
        }
    });
};
SAMA.centerAnchor = function () {
    SAMA.act("Center anchor point", "layers", function (comp, layers) {
        var i, l, r, a, p, sc, rot, old, na, dx, dy, ang, sk = 0;
        for (i = 0; i < layers.length; i++) {
            l = layers[i];
            if (!(l instanceof AVLayer) || l instanceof CameraLayer || l instanceof LightLayer) { continue; }
            a = SAMA.tp(l, "ADBE Anchor Point"); p = SAMA.tp(l, "ADBE Position");
            if (a.numKeys > 0 || p.numKeys > 0 || p.dimensionsSeparated) { sk++; continue; }
            r = l.sourceRectAtTime(comp.time, false);
            old = a.value; na = old.slice(0);
            na[0] = r.left + r.width / 2; na[1] = r.top + r.height / 2;
            sc = SAMA.tp(l, "ADBE Scale").value; rot = SAMA.tp(l, "ADBE Rotate Z");
            ang = (rot ? rot.value : 0) * Math.PI / 180;
            dx = (na[0] - old[0]) * sc[0] / 100; dy = (na[1] - old[1]) * sc[1] / 100;
            a.setValue(na);
            SAMA.movePos(l, comp, dx * Math.cos(ang) - dy * Math.sin(ang), dx * Math.sin(ang) + dy * Math.cos(ang));
        }
        if (sk) { SAMA.alert(sk + " layer(s) with keyframed anchor/position (or separated dimensions) were skipped."); }
    });
};
// Bounding box of a layer in comp space (uses a scratch null + toComp so rotation/parents are respected)
SAMA.layerBounds = function (comp, scratch, l) {
    var r = l.sourceRectAtTime(comp.time, false), cs = [[r.left, r.top], [r.left + r.width, r.top], [r.left, r.top + r.height], [r.left + r.width, r.top + r.height]];
    var xs = [], ys = [], i, v;
    for (i = 0; i < 4; i++) {
        v = SAMA.evalOn(scratch, "thisComp.layer(" + l.index + ").toComp([" + cs[i][0] + "," + cs[i][1] + "])");
        xs.push(v[0]); ys.push(v[1]);
    }
    return { l: Math.min(xs[0], xs[1], xs[2], xs[3]), r: Math.max(xs[0], xs[1], xs[2], xs[3]),
             t: Math.min(ys[0], ys[1], ys[2], ys[3]), b: Math.max(ys[0], ys[1], ys[2], ys[3]) };
};
SAMA.alignLayers = function (mode, toComp) {
    SAMA.act("Align layers", "layers", function (comp, layers) {
        var scratch = SAMA.addNull(comp, "__sama_tmp__"), items = [], i, l, bnd, ref, dx, dy;
        try {
            for (i = 0; i < layers.length; i++) {
                l = layers[i];
                if (l instanceof CameraLayer || l instanceof LightLayer) { continue; }
                items.push({ layer: l, b: SAMA.layerBounds(comp, scratch, l) });
            }
            if (!items.length) { return; }
            if (toComp || items.length === 1) { ref = { l: 0, t: 0, r: comp.width, b: comp.height }; }
            else {
                ref = { l: items[0].b.l, t: items[0].b.t, r: items[0].b.r, b: items[0].b.b };
                for (i = 1; i < items.length; i++) {
                    ref.l = Math.min(ref.l, items[i].b.l); ref.t = Math.min(ref.t, items[i].b.t);
                    ref.r = Math.max(ref.r, items[i].b.r); ref.b = Math.max(ref.b, items[i].b.b);
                }
            }
            for (i = 0; i < items.length; i++) {
                bnd = items[i].b; dx = 0; dy = 0;
                if (mode === "l") { dx = ref.l - bnd.l; } else if (mode === "r") { dx = ref.r - bnd.r; }
                else if (mode === "c") { dx = (ref.l + ref.r) / 2 - (bnd.l + bnd.r) / 2; }
                else if (mode === "t") { dy = ref.t - bnd.t; } else if (mode === "b") { dy = ref.b - bnd.b; }
                else if (mode === "m") { dy = (ref.t + ref.b) / 2 - (bnd.t + bnd.b) / 2; }
                SAMA.movePos(items[i].layer, comp, dx, dy);
            }
        } finally { scratch.remove(); SAMA.selectOnly(comp, layers); }
    });
};
SAMA.distribute = function (horizontal) {
    SAMA.act("Distribute layers", "layers", function (comp, layers) {
        var scratch = SAMA.addNull(comp, "__sama_tmp__"), items = [], i, l, n, sumW = 0, span, gap, cursor, lo, hi, size;
        try {
            for (i = 0; i < layers.length; i++) {
                l = layers[i];
                if (l instanceof CameraLayer || l instanceof LightLayer) { continue; }
                items.push({ layer: l, b: SAMA.layerBounds(comp, scratch, l) });
            }
            n = items.length;
            if (n < 3) { SAMA.alert("Select three or more layers to distribute."); return; }
            items.sort(function (a, b) { return horizontal ? (a.b.l + a.b.r) - (b.b.l + b.b.r) : (a.b.t + a.b.b) - (b.b.t + b.b.b); });
            for (i = 0; i < n; i++) { sumW += horizontal ? (items[i].b.r - items[i].b.l) : (items[i].b.b - items[i].b.t); }
            lo = horizontal ? items[0].b.l : items[0].b.t;
            hi = horizontal ? items[n - 1].b.r : items[n - 1].b.b;
            span = hi - lo; gap = (span - sumW) / (n - 1); cursor = lo;
            for (i = 0; i < n; i++) {
                size = horizontal ? (items[i].b.r - items[i].b.l) : (items[i].b.b - items[i].b.t);
                if (horizontal) { SAMA.movePos(items[i].layer, comp, cursor - items[i].b.l, 0); }
                else { SAMA.movePos(items[i].layer, comp, 0, cursor - items[i].b.t); }
                cursor += size + gap;
            }
        } finally { scratch.remove(); SAMA.selectOnly(comp, layers); }
    });
};
SAMA.toggleSwitch = function (name) {
    SAMA.act("Toggle " + name, "layers", function (comp, layers) {
        var state = !layers[0][name], i;
        for (i = 0; i < layers.length; i++) { try { layers[i][name] = state; } catch (e) { } }
    });
};
SAMA.toggleHideShy = function () {
    SAMA.act("Toggle hide shy layers", "", function (comp) { comp.hideShyLayers = !comp.hideShyLayers; });
};

// ---------------- Keyframes ----------------
SAMA.offsetKeys = function (frames) {
    SAMA.act("Offset keyframes", "props", function (comp, layers, props) {
        var i, dt = frames * comp.frameDuration, n = 0;
        for (i = 0; i < props.length; i++) {
            if (props[i].selectedKeys.length) { SAMA.moveKeys(props[i], function (t) { return t + dt; }, false); n++; }
        }
        if (!n) { SAMA.alert("Select one or more keyframes in the timeline first."); }
    });
};
SAMA.stretchKeys = function (pct) {
    SAMA.act("Time-stretch keyframes", "props", function (comp, layers, props) {
        var i, p, keys, pivot, f = pct / 100, j, n = 0;
        if (f <= 0) { SAMA.alert("Stretch % must be greater than 0."); return; }
        for (i = 0; i < props.length; i++) {
            p = props[i]; keys = p.selectedKeys;
            if (keys.length < 2) { continue; }
            pivot = p.keyTime(keys[0]);
            for (j = 1; j < keys.length; j++) { pivot = Math.min(pivot, p.keyTime(keys[j])); }
            SAMA.moveKeys(p, (function (pv) { return function (t) { return pv + (t - pv) * f; }; })(pivot), false); n++;
        }
        if (!n) { SAMA.alert("Select two or more keyframes on a property to stretch them."); }
    });
};
SAMA.reverseKeys = function () {
    SAMA.act("Reverse keyframes", "props", function (comp, layers, props) {
        var i, p, keys, lo, hi, j, n = 0;
        for (i = 0; i < props.length; i++) {
            p = props[i]; keys = p.selectedKeys;
            if (keys.length < 2) { continue; }
            lo = p.keyTime(keys[0]); hi = lo;
            for (j = 1; j < keys.length; j++) { lo = Math.min(lo, p.keyTime(keys[j])); hi = Math.max(hi, p.keyTime(keys[j])); }
            SAMA.moveKeys(p, (function (a, b) { return function (t) { return a + b - t; }; })(lo, hi), true); n++;
        }
        if (!n) { SAMA.alert("Select two or more keyframes on a property to reverse them."); }
    });
};
SAMA.loopExpr = function (fn, type) {
    SAMA.act("Loop expression", "exprprops", function (comp, layers, props) {
        var i, n = 0;
        for (i = 0; i < props.length; i++) {
            if (props[i].numKeys < 2) { continue; }
            props[i].expression = fn + "(\"" + type + "\")"; n++;
        }
        if (!n) { SAMA.alert("Loop expressions need properties with at least two keyframes."); }
    });
};
SAMA.clearExpr = function () {
    SAMA.act("Clear expressions", "exprprops", function (comp, layers, props) {
        var i; for (i = 0; i < props.length; i++) { if (props[i].expressionEnabled || props[i].expression !== "") { props[i].expression = ""; } }
    });
};

// ---------------- Comp ----------------
SAMA.compApply = function (w, h, fps, dur) {
    var comp = SAMA.getComp();
    if (!comp) { return; }
    if (isNaN(w) || isNaN(h) || isNaN(fps) || isNaN(dur) || w < 4 || h < 4 || w > 30000 || h > 30000 || fps < 1 || fps > 99 || dur <= 0) {
        SAMA.alert("Check the values: width/height 4-30000, fps 1-99, duration > 0 seconds."); return;
    }
    SAMA.run("Comp settings", function () {
        comp.width = Math.round(w); comp.height = Math.round(h);
        comp.frameRate = fps; comp.duration = dur;
    });
};
SAMA.adjustmentLayer = function () {
    SAMA.act("Adjustment layer", "", function (comp, layers) {
        var a = comp.layers.addSolid([1, 1, 1], "Adjustment Layer", comp.width, comp.height, comp.pixelAspect, SAMA.layerDur(comp)), i, top = null;
        a.adjustmentLayer = true; a.startTime = 0; SAMA.span(a); SAMA.tag(a, "adjustment");
        for (i = 0; i < layers.length; i++) { if (!top || layers[i].index < top.index) { top = layers[i]; } }
        if (top) { a.moveBefore(top); }
        SAMA.selectOnly(comp, [a]);
    });
};
SAMA.cropToWorkArea = function () {
    SAMA.act("Crop comp to work area", "", function (comp) {
        var ws = comp.workAreaStart, wd = comp.workAreaDuration, i, l, skipped = 0;
        if (wd < comp.frameDuration) { SAMA.alert("The work area is too short."); return; }
        if (ws > 0) {
            for (i = 1; i <= comp.numLayers; i++) {
                l = comp.layer(i);
                if (l.locked) { skipped++; continue; }
                l.startTime = l.startTime - ws;
            }
        }
        comp.duration = wd; comp.workAreaStart = 0; comp.workAreaDuration = wd; comp.time = 0;
        if (skipped) { SAMA.alert(skipped + " locked layer(s) were not shifted. Unlock them and re-time manually."); }
    });
};

// ---------------- Expressions ----------------
SAMA.vecExpr = function (p, scalarName) {   // "value + s" that works for 1-D and N-D properties
    var dim = (p.value instanceof Array) ? p.value.length : 1, parts = [], i;
    if (dim === 1) { return "value + " + scalarName; }
    for (i = 0; i < dim; i++) { parts.push(scalarName); }
    return "value + [" + parts.join(", ") + "]";
};
SAMA.exprSnippet = function (label, builder) {
    SAMA.act(label, "exprprops", function (comp, layers, props) {
        var i, l;
        for (i = 0; i < props.length; i++) {
            l = SAMA.layerOf(props[i]);
            props[i].expression = builder(l, props[i]);
        }
    });
};
SAMA.exprWiggle = function () {
    SAMA.exprSnippet("Wiggle", function (l) {
        SAMA.ensureCtl(l, "ADBE Slider Control", "Wiggle Freq", 2);
        SAMA.ensureCtl(l, "ADBE Slider Control", "Wiggle Amp", 50);
        return ["freq = effect(\"Wiggle Freq\")(1);", "amp = effect(\"Wiggle Amp\")(1);", "wiggle(freq, amp)"].join("\n");
    });
};
SAMA.exprInertia = function () {
    SAMA.exprSnippet("Inertia", function (l) {
        SAMA.ensureCtl(l, "ADBE Slider Control", "Inertia Amp", 0.1);
        SAMA.ensureCtl(l, "ADBE Slider Control", "Inertia Freq", 2);
        SAMA.ensureCtl(l, "ADBE Slider Control", "Inertia Decay", 5);
        return [
            "amp = effect(\"Inertia Amp\")(1); freq = effect(\"Inertia Freq\")(1); decay = effect(\"Inertia Decay\")(1);",
            "n = 0;",
            "if (numKeys > 0) { n = nearestKey(time).index; if (key(n).time > time) n--; }",
            "if (n > 0) {",
            "  t = time - key(n).time;",
            "  v = velocityAtTime(key(n).time - thisComp.frameDuration / 10);",
            "  value + v * amp * Math.sin(t * freq * 2 * Math.PI) / Math.exp(t * decay);",
            "} else { value }"].join("\n");
    });
};
SAMA.exprBounce = function () {
    SAMA.exprSnippet("Bounce", function (l) {
        SAMA.ensureCtl(l, "ADBE Slider Control", "Bounce Elasticity", 0.7);
        SAMA.ensureCtl(l, "ADBE Slider Control", "Bounce Gravity", 5000);
        return [
            "e = effect(\"Bounce Elasticity\")(1); g = effect(\"Bounce Gravity\")(1); nMax = 9;",
            "n = 0;",
            "if (numKeys > 0) { n = nearestKey(time).index; if (key(n).time > time) n--; }",
            "if (n > 0) {",
            "  v = -velocityAtTime(key(n).time - 0.001) * e;",
            "  vl = length(v);",
            "  if (value instanceof Array) { vu = (vl > 0) ? normalize(v) : [0, 0, 0]; } else { vu = (v < 0) ? -1 : 1; }",
            "  tCur = 0; segDur = 2 * vl / g; tNext = segDur; nb = 1;",
            "  while (tNext < (time - key(n).time) && nb <= nMax) { vl *= e; segDur *= e; tCur = tNext; tNext += segDur; nb++; }",
            "  if (nb <= nMax) { delta = time - key(n).time - tCur; value + vu * delta * (vl - g * delta / 2); } else { value }",
            "} else { value }"].join("\n");
    });
};
SAMA.exprSine = function () {
    SAMA.exprSnippet("Sine oscillation", function (l, p) {
        SAMA.ensureCtl(l, "ADBE Slider Control", "Sine Amp", 50);
        SAMA.ensureCtl(l, "ADBE Slider Control", "Sine Freq", 1);
        return ["s = effect(\"Sine Amp\")(1) * Math.sin(time * effect(\"Sine Freq\")(1) * Math.PI * 2);", SAMA.vecExpr(p, "s")].join("\n");
    });
};
SAMA.exprDrift = function () {
    SAMA.exprSnippet("Time drift", function (l, p) {
        SAMA.ensureCtl(l, "ADBE Slider Control", "Drift Speed", 30);
        return ["s = time * effect(\"Drift Speed\")(1);", SAMA.vecExpr(p, "s")].join("\n");
    });
};

// ---------------- Quick Edit UI ----------------
// ---------------- Create (plain layers) ----------------
SAMA.newSolid = function () {
    var col = SAMA.colorFor("Solid colour");
    if (col === false) { return; }
    SAMA.act("New solid", "", function (comp) {
        var s = comp.layers.addSolid(col ? [col[0], col[1], col[2]] : [0.5, 0.5, 0.5], "Solid", comp.width, comp.height, comp.pixelAspect, SAMA.layerDur(comp));
        SAMA.span(s); SAMA.tag(s, "solid");
        SAMA.selectOnly(comp, [s]);
    });
};
SAMA.newPlainNull = function () {
    SAMA.act("New null", "", function (comp) { SAMA.selectOnly(comp, [SAMA.addNull(comp, "Null")]); });
};
SAMA.newShape = function () {
    SAMA.act("New shape layer", "", function (comp) {
        var s = comp.layers.addShape(); s.name = "Shape Layer"; SAMA.span(s); SAMA.tag(s, "shape");
        SAMA.tp(s, "ADBE Position").setValue([comp.width / 2, comp.height / 2]);
        SAMA.selectOnly(comp, [s]);
    });
};
SAMA.newLight = function () {
    SAMA.act("New light", "", function (comp) {
        var l = comp.layers.addLight("Light", [comp.width / 2, comp.height / 2]); SAMA.span(l); SAMA.tag(l, "light");
        SAMA.selectOnly(comp, [l]);
    });
};

// ---------------- Quick Edit UI ----------------
// ---------------- Duration helpers (selection aware) ----------------
// With layers selected: the exact in/out of that clip (earliest in point to latest out point of the selection).
// With nothing selected: the full comp (or, when allLayers is true, the extent of every layer).
SAMA.layerExtent = function (comp, layers, allLayers) {
    var list = [], i, l, st = null, en = null;
    if (layers && layers.length) { list = layers; }
    else if (allLayers) { for (i = 1; i <= comp.numLayers; i++) { list.push(comp.layer(i)); } }
    for (i = 0; i < list.length; i++) {
        l = list[i];
        if (st === null || l.inPoint < st) { st = l.inPoint; }
        if (en === null || l.outPoint > en) { en = l.outPoint; }
    }
    if (st === null) { return { start: 0, end: comp.duration, selected: false }; }
    return { start: st, end: en, selected: !!(layers && layers.length) };
};
SAMA.workAreaFit = function () {
    SAMA.act("Work area", "", function (comp, layers) {
        var ext = SAMA.layerExtent(comp, layers, false), ws, we;
        ws = SAMA.clamp(ext.start, 0, comp.duration);
        we = SAMA.clamp(ext.end, ws + comp.frameDuration, comp.duration);
        comp.workAreaStart = ws; comp.workAreaDuration = we - ws;
    });
};
SAMA.fitComp = function () {
    SAMA.act("Fit comp to clip", "", function (comp, layers) {
        var ext = SAMA.layerExtent(comp, layers, true), len = ext.end - ext.start, i, l, locked = 0;
        if (len < comp.frameDuration) { SAMA.alert("There is nothing to fit the composition to. Add or select a layer first."); return; }
        if (ext.start > 0) {
            for (i = 1; i <= comp.numLayers; i++) {
                l = comp.layer(i);
                if (l.locked) { locked++; continue; }
                l.startTime = l.startTime - ext.start;
            }
        }
        comp.duration = len; comp.workAreaStart = 0; comp.workAreaDuration = len; comp.time = 0;
        if (locked) { SAMA.alert(locked + " locked layer(s) were not shifted. Unlock them and re-time manually."); }
    });
};

// ---------------- Colour wheel widget (drawn with ScriptUI graphics): hue = angle, saturation = distance from the centre, brightness = strip below ----------------
SAMA.colorWheel = function (parent, onPick, onDone) {
    var cv = parent.add("customButton", undefined, ""), st = { h: 0, s: 0, v: 1, down: 0, last: 0 }, C = SAMA.C;
    cv.preferredSize = [220, 186]; cv.minimumSize = [120, 130]; cv.alignment = ["fill", "top"];
    cv.helpTip = "Click or drag on the wheel to pick a colour, and on the strip below it for brightness";
    function geom() {
        var W = cv.size.width, H = cv.size.height, bar = 16, gap = 10, D = Math.max(40, Math.min(W - 8, H - bar - gap - 8)), R = D / 2;
        return { W: W, H: H, R: R, cx: W / 2, cy: 4 + R, bx: Math.max(6, W / 2 - R), bw: Math.min(D, W - 12), by: 4 + D + gap, bh: bar };
    }
    function redraw() { try { cv.notify("onDraw"); } catch (e) { } }
    cv.redraw = redraw;
    cv.setColor = function (rgb) { var h = SAMA.rgb2hsv(rgb); st.h = h[0]; st.s = h[1]; st.v = h[2]; redraw(); };
    cv.onDraw = function () {
        var g = this.graphics, k = geom(), nW = st.down ? 24 : 40, nR = st.down ? 5 : 7, i, j, a0, a1, r0, r1, col, pt, n = st.down ? 12 : 24, x;
        g.newPath(); g.rectPath(0, 0, k.W, k.H); g.fillPath(SAMA.brush(g, C.bg));
        for (j = 0; j < nR; j++) {
            r0 = k.R * j / nR; r1 = k.R * (j + 1) / nR;
            for (i = 0; i < nW; i++) {
                a0 = 2 * Math.PI * i / nW; a1 = 2 * Math.PI * (i + 1) / nW + 0.03;
                col = SAMA.hsv2rgb((i + 0.5) / nW, (j + 0.5) / nR, st.v);
                g.newPath(); g.moveTo(k.cx + r0 * Math.cos(a0), k.cy + r0 * Math.sin(a0));
                g.lineTo(k.cx + r1 * Math.cos(a0), k.cy + r1 * Math.sin(a0));
                g.lineTo(k.cx + r1 * Math.cos(a1), k.cy + r1 * Math.sin(a1));
                g.lineTo(k.cx + r0 * Math.cos(a1), k.cy + r0 * Math.sin(a1));
                g.closePath(); g.fillPath(SAMA.brush(g, [col[0], col[1], col[2], 1]));
            }
        }
        pt = [k.cx + st.s * k.R * Math.cos(st.h * 2 * Math.PI), k.cy + st.s * k.R * Math.sin(st.h * 2 * Math.PI)];
        g.newPath(); g.ellipsePath(pt[0] - 7, pt[1] - 7, 14, 14); g.strokePath(SAMA.pen(g, [0, 0, 0, 1], 3));
        g.newPath(); g.ellipsePath(pt[0] - 7, pt[1] - 7, 14, 14); g.strokePath(SAMA.pen(g, [1, 1, 1, 1], 1.6));
        for (i = 0; i < n; i++) {                       // brightness strip: black -> the current hue / saturation at full brightness
            col = SAMA.hsv2rgb(st.h, st.s, i / (n - 1));
            g.newPath(); g.rectPath(k.bx + k.bw * i / n, k.by, k.bw / n + 1, k.bh); g.fillPath(SAMA.brush(g, [col[0], col[1], col[2], 1]));
        }
        x = k.bx + st.v * k.bw;
        g.newPath(); g.rectPath(x - 2, k.by - 3, 4, k.bh + 6); g.fillPath(SAMA.brush(g, [0, 0, 0, 1]));
        g.newPath(); g.rectPath(x - 1, k.by - 2, 2, k.bh + 4); g.fillPath(SAMA.brush(g, [1, 1, 1, 1]));
    };
    function apply(ev, forceDraw) {
        var k = geom(), dx = ev.clientX - k.cx, dy = ev.clientY - k.cy, d = Math.sqrt(dx * dx + dy * dy), now = SAMA.now(), rgb;
        if (st.down === 1) {
            st.h = Math.atan2(dy, dx) / (2 * Math.PI); if (st.h < 0) { st.h += 1; }
            st.s = Math.min(1, d / k.R);
        } else if (st.down === 2) {
            st.v = SAMA.clamp((ev.clientX - k.bx) / k.bw, 0, 1);
        } else { return; }
        rgb = SAMA.hsv2rgb(st.h, st.s, st.v);
        onPick(rgb);
        if (forceDraw || now - st.last >= 16) { st.last = now; redraw(); SAMA.flush(); }
    }
    function release() { if (st.down) { st.down = 0; redraw(); if (onDone) { onDone(); } } }
    cv.release = release;
    cv.addEventListener("mousedown", SAMA.guard("Colour wheel: press", function (ev) {
        var k = geom(), dx = ev.clientX - k.cx, dy = ev.clientY - k.cy;
        if (Math.sqrt(dx * dx + dy * dy) <= k.R + 6) { st.down = 1; }
        else if (ev.clientY >= k.by - 6 && ev.clientY <= k.by + k.bh + 6) { st.down = 2; }
        else { st.down = 0; return; }
        apply(ev, true);
    }));
    cv.addEventListener("mousemove", SAMA.guard("Colour wheel: drag", function (ev) {
        if (!st.down) { return; }
        // AE keeps sending moves to the wheel after the cursor left it (and may lose the button release): outside the wheel nothing may change the colour
        if (ev.clientX < 0 || ev.clientY < 0 || ev.clientX > cv.size.width || ev.clientY > cv.size.height) { release(); return; }
        apply(ev, false);
    }));
    cv.addEventListener("mouseup", SAMA.guard("Colour wheel: release", release));
    cv.addEventListener("mouseout", SAMA.guard("Colour wheel: leave", release));
    cv.addEventListener("mouseover", SAMA.guard("Colour wheel: enter", release));       // coming back in with no button held = the release was missed
    return cv;
};

SAMA.buildQuick = function (root) {
    var cats = ["Create", "Nulls", "Text", "Camera", "Layers", "Keys", "Comp", "Expr"];
    var ui = SAMA.ui, stack, pages = [], pills, cur, i, pg, f1, f2, f3, f4, chkRev, chkComp;

    function T(icon, label, tip, fn) { return { icon: icon, label: label, tip: tip, fn: fn }; }
    function Gl(glyph, label, tip, fn) { return { glyph: glyph, label: label, tip: tip, fn: fn }; }
    function G(parent, defs, o) { o = o || {}; return SAMA.grid(parent, defs, { cols: o.cols || 3, minW: o.minW || 64, h: o.h || 50, minCols: 1, maxCols: o.maxCols || 5 }); }

    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.margins = 0; root.spacing = 10;
    cur = parseInt(SAMA.settings.get("quick.cat", "0"), 10);
    if (isNaN(cur) || cur < 0 || cur >= cats.length) { cur = 0; }

    function mkPill(n) { return { kind: "pill", label: cats[n], isSel: function () { return cur === n; }, fn: function () { show(n); } }; }
    var pillDefs = [];
    for (i = 0; i < cats.length; i++) { pillDefs.push(mkPill(i)); }
    pills = SAMA.grid(root, pillDefs, { cols: 4, minW: 56, h: 24, minCols: 2, maxCols: 4 });

    stack = root.add("group"); stack.orientation = "stack"; stack.alignment = ["fill", "top"]; stack.alignChildren = ["fill", "top"];

    // --- Create
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Create");
    G(pg, [
        T("text", "Text", "New centred text layer", SAMA.newText),
        T("adjust", "Adjust", "Add an adjustment layer above the selection", SAMA.adjustmentLayer),
        T("solid", "Solid", "New solid layer", SAMA.newSolid),
        T("nullbox", "Null", "New null object", SAMA.newPlainNull),
        T("shape", "Shape", "New empty shape layer", SAMA.newShape),
        T("camera", "Camera", "New camera parented to a camera-rig null", SAMA.newCameraRig),
        T("light", "Light", "New light", SAMA.newLight),
        T("adjust", "Recolour", "Pick a colour and apply it to the selected text and solid layers", SAMA.recolour)
    ]);
    ui.check(pg, "labels", "Colour-code new layers", true, "Text red, solid blue, null yellow, shape purple, light orange, camera green, adjustment grey (default)");
    ui.check(pg, "askColor", "Ask for a colour when making solids and text", true, "A colour wheel opens when you click Solid or Text. Off: the last colour you picked is used");
    pages.push(pg);

    // --- Nulls
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Nulls");
    G(pg, [
        T("nullat", "At layers", "Null at each selected layer (position, in/out)", SAMA.nullAtSelected),
        T("parent", "Parent", "Parent selected layers to a new null at their centre", SAMA.parentToNull),
        T("center", "Center", "New null at the composition centre", SAMA.centerNull),
        T("controller", "Controller", "Controller null with slider, checkbox and colour controls", SAMA.controllerNull)
    ]);
    pages.push(pg);

    // --- Text
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Split text");
    G(pg, [
        T("lines", "Lines", "Split selected text into one layer per line", function () { SAMA.splitText("lines"); }),
        T("words", "Words", "Split selected text into one layer per word", function () { SAMA.splitText("words"); }),
        T("chars", "Chars", "Split selected text into one layer per character", function () { SAMA.splitText("chars"); })
    ]);
    ui.section(pg, "Align and case");
    G(pg, [
        T("txtleft", "", "Align text left", function () { SAMA.textAlign(ParagraphJustification.LEFT_JUSTIFY); }),
        T("txtcenter", "", "Align text centre", function () { SAMA.textAlign(ParagraphJustification.CENTER_JUSTIFY); }),
        T("txtright", "", "Align text right", function () { SAMA.textAlign(ParagraphJustification.RIGHT_JUSTIFY); }),
        Gl("AA", "", "UPPERCASE", function () { SAMA.textCase("upper"); }),
        Gl("aa", "", "lowercase", function () { SAMA.textCase("lower"); }),
        Gl("Aa", "", "Title Case", function () { SAMA.textCase("title"); })
    ], { h: 34, minW: 40, maxCols: 6, cols: 6 });
    ui.section(pg, "Tracking and leading");
    f1 = ui.field(pg, "textStep", "Step", 10, "Amount added/removed by the buttons below");
    G(pg, [
        Gl("T -", "Tracking", "Decrease tracking", function () { SAMA.textStep("tracking", -1, SAMA.num(f1.text, 10)); }),
        Gl("T +", "Tracking", "Increase tracking", function () { SAMA.textStep("tracking", 1, SAMA.num(f1.text, 10)); }),
        Gl("L -", "Leading", "Decrease leading", function () { SAMA.textStep("leading", -1, SAMA.num(f1.text, 10)); }),
        Gl("L +", "Leading", "Increase leading", function () { SAMA.textStep("leading", 1, SAMA.num(f1.text, 10)); })
    ], { cols: 4, minW: 56, h: 46 });
    pages.push(pg);

    // --- Camera
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Camera");
    G(pg, [
        T("camrig", "Rig", "New camera parented to a camera-rig null", SAMA.newCameraRig),
        T("nodes", "1 / 2 node", "Toggle one-node / two-node camera", SAMA.cameraNodeToggle),
        T("dof", "Depth", "Toggle depth of field", SAMA.cameraDoF)
    ]);
    ui.section(pg, "Zoom and dolly");
    f1 = ui.field(pg, "zoomPct", "Zoom %", 10, "Zoom step in percent");
    f2 = ui.field(pg, "dollyStep", "Dolly px", 100, "Dolly step in pixels along Z");
    G(pg, [
        T("zoomin", "Zoom in", "Zoom in", function () { SAMA.cameraZoom(1 + SAMA.num(f1.text, 10) / 100); }),
        T("zoomout", "Zoom out", "Zoom out", function () { SAMA.cameraZoom(1 / (1 + SAMA.num(f1.text, 10) / 100)); }),
        T("dollyin", "Dolly in", "Dolly forward (+Z)", function () { SAMA.cameraDolly(SAMA.num(f2.text, 100)); }),
        T("dollyout", "Dolly out", "Dolly back (-Z)", function () { SAMA.cameraDolly(-SAMA.num(f2.text, 100)); })
    ], { cols: 4, minW: 56, h: 46 });
    pages.push(pg);

    // --- Layers
    pg = ui.page(stack); pg.spacing = 8;
    ui.section(pg, "Layers");
    G(pg, [
        T("precomp", "Precomp", "Precompose selected layers", SAMA.precompose),
        T("trimin", "Trim in", "Trim layer in-point to the current time", function () { SAMA.trimTo("in"); }),
        T("trimout", "Trim out", "Trim layer out-point to the current time", function () { SAMA.trimTo("out"); }),
        T("workarea", "Work area", "Trim layers to the work area", function () { SAMA.trimTo("wa"); })
    ], { cols: 4, minW: 56, h: 44 });
    f3 = ui.field(pg, "gap", "Gap (frames)", 0, "Gap between layers in frames (negative = overlap)");
    chkRev = ui.check(pg, "seqRev", "Sequence from bottom layer", false, "Sequence from the bottom-most selected layer upward");
    G(pg, [
        T("sequence", "Sequence", "Sequence selected layers with the gap", function () { SAMA.sequence(SAMA.num(f3.text, 0), chkRev.value); }),
        T("reverse", "Reverse", "Reverse stacking order of selected layers", SAMA.reverseLayers),
        T("anchor", "Anchor", "Centre the anchor point without moving the layer", SAMA.centerAnchor)
    ], { cols: 3, minW: 56, h: 44 });
    chkComp = ui.check(pg, "alignComp", "Align to composition", true, "Off: align to the selection's bounds");
    G(pg, [
        T("alignL", "", "Align left", function () { SAMA.alignLayers("l", chkComp.value); }),
        T("alignC", "", "Align horizontal centre", function () { SAMA.alignLayers("c", chkComp.value); }),
        T("alignR", "", "Align right", function () { SAMA.alignLayers("r", chkComp.value); }),
        T("alignT", "", "Align top", function () { SAMA.alignLayers("t", chkComp.value); }),
        T("alignM", "", "Align vertical middle", function () { SAMA.alignLayers("m", chkComp.value); }),
        T("alignB", "", "Align bottom", function () { SAMA.alignLayers("b", chkComp.value); }),
        T("distH", "", "Distribute horizontally (equal gaps)", function () { SAMA.distribute(true); }),
        T("distV", "", "Distribute vertically (equal gaps)", function () { SAMA.distribute(false); })
    ], { cols: 4, minW: 40, h: 32, maxCols: 8 });
    G(pg, [
        T("solo", "Solo", "Toggle solo", function () { SAMA.toggleSwitch("solo"); }),
        T("lock", "Lock", "Toggle lock", function () { SAMA.toggleSwitch("locked"); }),
        T("shy", "Shy", "Toggle shy", function () { SAMA.toggleSwitch("shy"); }),
        T("hideshy", "Hide shy", "Toggle 'hide shy layers' for the comp", SAMA.toggleHideShy)
    ], { cols: 4, minW: 56, h: 40 });
    pages.push(pg);

    // --- Keys
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Ease");
    G(pg, [
        T("curve", "Easy ease", "Easy Ease (in + out) on selected keys", function () { SAMA.keyEase("ease"); }),
        T("easein", "Ease in", "Easy Ease In", function () { SAMA.keyEase("in"); }),
        T("easeout", "Ease out", "Easy Ease Out", function () { SAMA.keyEase("out"); }),
        T("curve", "Strong", "Strong ease (80% influence)", function () { SAMA.keyEase("strong"); }),
        T("linear", "Linear", "Linear interpolation", function () { SAMA.keyEase("linear"); }),
        T("hold", "Hold", "Hold interpolation", function () { SAMA.keyEase("hold"); })
    ], { cols: 3, minW: 64, h: 44 });
    ui.section(pg, "Timing");
    f1 = ui.field(pg, "kOffset", "Offset (frames)", 5, "Offset amount in frames");
    f2 = ui.field(pg, "kStretch", "Stretch %", 150, "Time-stretch factor in percent (100 = unchanged)");
    G(pg, [
        T("offback", "Earlier", "Move selected keys earlier", function () { SAMA.offsetKeys(-SAMA.num(f1.text, 5)); }),
        T("offfwd", "Later", "Move selected keys later", function () { SAMA.offsetKeys(SAMA.num(f1.text, 5)); }),
        T("stretch", "Stretch", "Time-stretch selected keys around the first one", function () { SAMA.stretchKeys(SAMA.num(f2.text, 100)); }),
        T("reverse", "Reverse", "Reverse selected keyframes in time", SAMA.reverseKeys)
    ], { cols: 4, minW: 56, h: 44 });
    ui.section(pg, "Loop");
    G(pg, [
        T("loop", "Cycle", "loopOut(\"cycle\")", function () { SAMA.loopExpr("loopOut", "cycle"); }),
        T("loop", "Ping-pong", "loopOut(\"pingpong\")", function () { SAMA.loopExpr("loopOut", "pingpong"); }),
        T("loop", "Offset", "loopOut(\"offset\")", function () { SAMA.loopExpr("loopOut", "offset"); }),
        T("loop", "Continue", "loopOut(\"continue\")", function () { SAMA.loopExpr("loopOut", "continue"); }),
        T("loop", "In cycle", "loopIn(\"cycle\")", function () { SAMA.loopExpr("loopIn", "cycle"); }),
        T("clear", "Remove", "Remove expressions from selected properties", SAMA.clearExpr)
    ], { cols: 3, minW: 64, h: 40 });
    pages.push(pg);

    // --- Comp
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Composition");
    f1 = ui.field(pg, "cW", "Width", 1920, "Composition width in pixels");
    f2 = ui.field(pg, "cH", "Height", 1080, "Composition height in pixels");
    f3 = ui.field(pg, "cF", "Frame rate", 30, "Frame rate");
    f4 = ui.field(pg, "cD", "Duration (s)", 10, "Duration in seconds");
    G(pg, [
        T("get", "Read", "Read the comp settings into the fields. Duration = the exact length of the selected layer(s), or the full comp when no layer is selected", function () {
            var c = SAMA.getComp(), ext; if (!c) { return; }
            ext = SAMA.layerExtent(c, c.selectedLayers, false);
            f1.text = String(c.width); f2.text = String(c.height); f3.text = String(c.frameRate); f4.text = String(SAMA.round3(ext.end - ext.start));
        }),
        T("check", "Apply", "Apply width / height / fps / duration to the active comp", function () {
            SAMA.compApply(parseFloat(f1.text), parseFloat(f2.text), parseFloat(f3.text), parseFloat(f4.text));
        }),
        T("adjust", "Adjustment", "Add an adjustment layer above the selection", SAMA.adjustmentLayer),
        T("crop", "Crop", "Auto-crop the comp duration to the work area", SAMA.cropToWorkArea),
        T("workarea", "Work area", "Set the work area to the selected layer(s) exactly, or to the full comp when nothing is selected", SAMA.workAreaFit),
        T("fitcomp", "Fit comp", "Make the comp exactly as long as the selected layer(s), or as long as all layers when nothing is selected", SAMA.fitComp)
    ], { cols: 3, minW: 64, h: 46 });
    pages.push(pg);

    // --- Expressions
    pg = ui.page(stack); pg.spacing = 10;
    ui.section(pg, "Expressions");
    ui.label(pg, "Select properties, then click. Sliders are added to the layer.");
    G(pg, [
        T("wiggle", "Wiggle", "wiggle() driven by frequency / amplitude sliders", SAMA.exprWiggle),
        T("inertia", "Inertia", "Inertial overshoot after the last keyframe", SAMA.exprInertia),
        T("bounce", "Bounce", "Bounce after the last keyframe", SAMA.exprBounce),
        T("sine", "Sine", "Sine oscillation (amp / freq sliders)", SAMA.exprSine),
        T("drift", "Drift", "Linear drift: time * speed slider", SAMA.exprDrift),
        T("clear", "Clear", "Remove expressions from selected properties", SAMA.clearExpr)
    ], { cols: 3, minW: 64, h: 50 });
    pages.push(pg);

    function show(n) {
        var j;
        cur = n;
        for (j = 0; j < pages.length; j++) { pages[j].visible = (j === n); }
        SAMA.settings.set("quick.cat", n);
        pills.redraw();
        SAMA.scrollY = 0;
        SAMA.relayout();
    }
    for (i = 0; i < pages.length; i++) { pages[i].visible = (i === cur); }
};

/* ==========================================================================
   HUB PANEL + LAUNCHER
   ========================================================================== */
// First-run page (also reachable from Settings): where should presets be saved? Everything happens inside the panel, no pop-up of our own.
SAMA.defaultLibPath = function () { return Folder.userData.fsName + "/SAMA/Layers"; };
// ---- activation code: first thing shown (inside the panel, no pop-up). A 6 character code (letters and numbers) that changes every 24 hours.
// The code of a day is worked out from the date and a private word, so nothing has to be sent anywhere. The days change at 00:00 UTC (05:30 in India).
// This is a deterrent only: the .jsx is a text file, so anyone who reads it can also work the codes out.
SAMA.DAY_SECRET = "Qx2GULL6A7DbM3AlJmG7KWJF";
SAMA.ACT_DAILY = false;                       // true: the panel asks for the code again every day; false: once entered, a computer stays activated
SAMA.CODE_ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";   // no 0 O 1 I L, so nothing is mistaken for something else
SAMA.keyHash = function (str) {
    var out = [], seeds = [5381, 7919], k, i, h;
    for (k = 0; k < 2; k++) {
        h = seeds[k];
        for (i = 0; i < str.length; i++) { h = ((h * 33) ^ str.charCodeAt(i)) >>> 0; h = (h + ((h << 7) >>> 0)) >>> 0; }
        out.push(h.toString(16));
    }
    return out.join("-");
};
SAMA.dayNumber = function () { return Math.floor((new Date()).getTime() / 86400000); };      // whole days since 1970, in UTC
SAMA.dayCode = function (day) {
    var s = SAMA.DAY_SECRET + ":" + day, A = SAMA.CODE_ALPHA, h = 2166136261, i, k, out = [], hasD = false, hasL = false, ch;
    function mul(a, b) { return (((a & 65535) * b) + ((((a >>> 16) * b) & 65535) << 16)) >>> 0; }
    for (i = 0; i < s.length; i++) { h = mul((h ^ s.charCodeAt(i)) >>> 0, 16777619); }
    for (k = 0; k < 6; k++) {
        h = (h ^ (h >>> 16)) >>> 0; h = mul(h, 2246822507); h = (h ^ (h >>> 13)) >>> 0; h = mul(h, 3266489909); h = (h ^ (h >>> 16)) >>> 0;
        out.push(A.charAt(h % A.length));
    }
    for (k = 0; k < 6; k++) { ch = out[k]; if (ch >= "2" && ch <= "9") { hasD = true; } else { hasL = true; } }
    if (!hasD) { out[5] = "23456789".charAt(h % 8); }                        // always both letters and numbers
    if (!hasL) { out[0] = "ABCDEFGHJKMNPQRSTUVWXYZ".charAt(h % 23); }
    return out.join("");
};
SAMA.cleanCode = function (t) { return String(t || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); };
SAMA.codeOk = function (t) { return SAMA.cleanCode(t) === SAMA.dayCode(SAMA.dayNumber()); };
// People who already activated with the old fixed key keep their activation: it is a one-time process, they are never asked again.
SAMA.LEGACY_OK = "104797e5-59d4454f";
SAMA.isActivated = function () {
    var v = SAMA.settings.get("act.v2", "");
    if (SAMA.settings.get("act.ok", "") === SAMA.LEGACY_OK) { return true; }
    if (SAMA.ACT_DAILY) { return v === SAMA.keyHash(SAMA.DAY_SECRET + ":" + SAMA.dayNumber()); }
    return v === SAMA.keyHash(SAMA.DAY_SECRET + ":ok");
};
SAMA.changeTime = function () {                // when the code changes, in the computer's own time, e.g. "05:30"
    var d = new Date((SAMA.dayNumber() + 1) * 86400000), hh = d.getHours(), mm = d.getMinutes();
    return (hh < 10 ? "0" : "") + hh + ":" + (mm < 10 ? "0" : "") + mm;
};
SAMA.buildActivation = function (root) {
    var C = SAMA.C, st, keyE, msg, defs = [];
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    SAMA.boldLabel(root, "Activation code");
    st = root.add("statictext", undefined, "Enter today's 6 character activation code (letters and numbers). It changes every 24 hours, at " + SAMA.changeTime() + " your time.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 56]; st.minimumSize = [100, 56]; SAMA.tint(st, C.dim);
    keyE = root.add("edittext", undefined, ""); keyE.alignment = ["fill", "top"]; keyE.preferredSize = [200, 26]; SAMA.tint(keyE, C.text);
    keyE.helpTip = "Type or paste today's activation code";
    msg = root.add("statictext", undefined, "", { multiline: true }); msg.alignment = ["fill", "top"]; msg.preferredSize = [200, 32]; SAMA.tint(msg, C.dim);
    function tryKey() {
        var k = SAMA.cleanCode(keyE.text || "");
        if (k && SAMA.codeOk(k)) {
            SAMA.settings.set("act.v2", SAMA.ACT_DAILY ? SAMA.keyHash(SAMA.DAY_SECRET + ":" + SAMA.dayNumber()) : SAMA.keyHash(SAMA.DAY_SECRET + ":ok"));
            keyE.text = ""; msg.text = "";
            if (SAMA.afterActivate) { SAMA.afterActivate(); }
        } else {
            msg.text = k ? "That code is not correct. It changes every day, so make sure you have today's code." : "Please type the activation code first.";
        }
    }
    SAMA.rowAdd(defs, true, { kind: "primary", minW: 90, icon: "check", label: "Activate", tip: "Check the code and unlock SAMA-toolkit", fn: SAMA.guard("Activate", tryKey) }, 150, 34);
    SAMA.flowRow(root, defs, { h: 34 });
};
// "Allow Scripts to Write Files and Access Network" check: shown in the panel after the activation code and before the preset folder question.
SAMA.accessState = function () {
    try { return app.preferences.getPrefAsLong("Main Pref Section", "Pref_SCRIPTING_FILE_NETWORK_SECURITY") === 1 ? "on" : "off"; }
    catch (e) { return "unknown"; }
};
SAMA.buildAccess = function (root) {
    var C = SAMA.C, st, stat, defs = [];
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    SAMA.boldLabel(root, "One quick check");
    st = root.add("statictext", undefined, "SAMA saves your presets as files, so After Effects has to allow scripts to write files.\n\nOpen Edit > Preferences > Scripting & Expressions (Mac: After Effects > Settings) and tick \"Allow Scripts to Write Files and Access Network\".", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 130]; st.minimumSize = [100, 130]; SAMA.tint(st, C.dim);
    stat = root.add("statictext", undefined, "", { multiline: true });
    stat.alignment = ["fill", "top"]; stat.preferredSize = [200, 36]; stat.minimumSize = [100, 36]; SAMA.tint(stat, C.text);
    function show() {
        var v = SAMA.accessState();
        stat.text = (v === "on") ? "Status: ON - everything is ready." : ((v === "off") ? "Status: OFF - tick the box in Preferences, then press Check again." : "Status: could not be checked on this version.");
        return v;
    }
    SAMA.accessRefresh = show;
    SAMA.rowAdd(defs, true, { kind: "primary", minW: 90, icon: "check", label: "Check again", tip: "Check the setting and continue", fn: SAMA.guard("Access check", function () {
        var v = show();
        if (v !== "off" && SAMA.afterAccess) { SAMA.afterAccess(); }
    }) }, 150, 34);
    SAMA.rowAdd(defs, true, { minW: 90, label: "Open preferences", tip: "Try to open Preferences > Scripting & Expressions", fn: SAMA.guard("Access: open prefs", function () {
        var id = 0;
        try { id = app.findMenuCommandId("Scripting & Expressions..."); } catch (e) { id = 0; }
        if (!id) { try { id = app.findMenuCommandId("Scripting & Expressions…"); } catch (e2) { id = 0; } }
        if (id) { try { SAMA.modal(function () { app.executeCommand(id); }); } catch (e3) { } show(); }
        else { stat.text = "Please open it by hand: Edit > Preferences > Scripting & Expressions."; }
    }) }, 150, 34);
    SAMA.flowRow(root, defs, { h: 34 });
    show();
};
SAMA.buildSetup = function (root) {
    var C = SAMA.C, st, row, pathE, defs = [];
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    SAMA.boldLabel(root, "Where should SAMA save your presets?");
    st = root.add("statictext", undefined, "Pick a folder for your layer presets (.ffx files). You can change it later from Settings or from the Vault tab.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 52]; SAMA.tint(st, C.dim);
    pathE = root.add("edittext", undefined, ""); pathE.alignment = ["fill", "top"]; pathE.preferredSize = [200, 26]; SAMA.tint(pathE, C.text);
    pathE.helpTip = "Type or paste a folder path, or press Browse";
    function current() { var c = SAMA.settings.get("libPath", ""); return c ? c : SAMA.defaultLibPath(); }
    function finish(path) {
        var f, def = SAMA.defaultLibPath(), same;
        path = SAMA.trim(path || "");
        if (!path) { path = def; }
        f = new Folder(path);
        if (!f.exists) { try { f.create(); } catch (e) { } }
        if (!f.exists) { SAMA.alert("That folder could not be created. Check the path or choose another folder."); return; }
        same = (f.fsName.replace(/\\/g, "/") === new Folder(def).fsName.replace(/\\/g, "/"));
        SAMA.settings.set("libPath", same ? "" : f.fsName);
        SAMA.settings.set("setup.done", "1");
        if (SAMA.vaultReload) { SAMA.vaultReload(); }
        if (SAMA.settingsRefresh) { SAMA.settingsRefresh(); }
        if (SAMA.leaveSetup) { SAMA.leaveSetup(); }
    }
    SAMA.rowAdd(defs, true, { icon: "folder", label: "Browse", tip: "Choose the folder in Explorer / Finder", fn: SAMA.guard("Setup: Browse", function () {
        var f = SAMA.folderDlg("Choose where SAMA should save presets");
        if (f) { pathE.text = f.fsName; }
    }) }, 96, 34);
    SAMA.rowAdd(defs, false, { kind: "primary", minW: 90, icon: "check", label: "Use this folder", tip: "Save presets in the folder above", fn: SAMA.guard("Setup: Use this folder", function () { finish(pathE.text); }) }, 150, 34);
    st = root.add("statictext", undefined, ""); st.preferredSize = [10, 2];
    SAMA.flowRow(root, defs, { h: 34 });
    defs = [];
    SAMA.rowAdd(defs, false, { label: "Use the default location", tip: "Keep the standard SAMA folder", fn: SAMA.guard("Setup: default", function () { pathE.text = SAMA.defaultLibPath(); finish(""); }) }, 200, 30);
    SAMA.flowRow(root, defs, { h: 30 });
    SAMA.setupRefresh = function () { pathE.text = current(); };
    SAMA.setupRefresh();
};
// "What do you want to save?" page: shown inside the panel when Save is pressed in the Vault.
SAMA.buildSaveAsk = function (root) {
    var C = SAMA.C, st, defs, chk;
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    SAMA.boldLabel(root, "What do you want to save?");
    st = root.add("statictext", undefined, "Choose what you want to save.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 20]; SAMA.tint(st, C.dim);
    defs = [];
    SAMA.rowAdd(defs, false, { kind: "primary", minW: 90, icon: "diamond", label: "Keyframes/effects", tip: "The animated properties and the effects, saved as an After Effects .ffx preset", fn: SAMA.guard("Save: keyframes", function () { SAMA.chooseSave("keys"); }) }, 150, 34);
    SAMA.rowAdd(defs, false, { minW: 90, icon: "sequence", label: "Layers", tip: "The whole layer, saved by SAMA; picking it later brings the layer back", fn: SAMA.guard("Save: layers", function () { SAMA.chooseSave("layers"); }) }, 150, 34);
    SAMA.flowRow(root, defs, { h: 34 });
    st = root.add("statictext", undefined, "Keyframes/effects: the animated properties and the effects (an .ffx preset).\nLayers: the complete layer is saved in your library. Pick it later and press \"Apply layer\" to bring it back - type, transform, keyframes, expressions, effects, masks, text / shapes, layer styles, blend mode, track matte, 3D.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 96]; st.minimumSize = [100, 96]; SAMA.tint(st, C.dim);
    chk = root.add("checkbox", undefined, "Layers: also remember the parent layer"); chk.alignment = ["fill", "top"]; SAMA.tint(chk, C.text);
    chk.value = (SAMA.settings.get("vault.parent", "0") === "1");
    chk.onClick = function () { SAMA.settings.set("vault.parent", chk.value ? "1" : "0"); };
    defs = [];
    SAMA.rowAdd(defs, false, { label: "Cancel", tip: "Go back without saving", fn: SAMA.guard("Save: cancel", function () { SAMA.chooseSave(null); }) }, 120, 30);
    SAMA.flowRow(root, defs, { h: 30 });
};
// Settings page (gear in the top bar): lives inside the panel as a fourth page, no pop-up.
SAMA.boldLabel = function (parent, text) {            // a section title in bold
    var st = parent.add("statictext", undefined, text);
    try { st.graphics.font = ScriptUI.newFont(st.graphics.font.name, ScriptUI.FontStyle.BOLD, 13); } catch (e) { }
    st.alignment = ["fill", "top"]; SAMA.tint(st, SAMA.C.title);
    return st;
};
SAMA.buildSettings = function (root) {
    var C = SAMA.C, row, hex, sw, dyE, st, defs = [], folderInfo, chk, row2, hex2, sw2, lab1, lab2;
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    function shown() { var c = hex.text ? SAMA.hex2rgb(hex.text) : null; return c || [0.8, 0.8, 0.83]; }
    function drawSwatch() { try { sw.notify("onDraw"); } catch (e) { } try { sw2.notify("onDraw"); } catch (e1) { } }

    SAMA.boldLabel(root, "Customise colour theme");
    st = root.add("statictext", undefined, "Text colour (hex code, e.g. #00ff00). The highlight buttons use the same colour.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 58]; SAMA.tint(st, C.dim);
    row = root.add("group"); row.orientation = "row"; row.alignChildren = ["left", "center"]; row.alignment = ["fill", "top"]; row.spacing = 8; row.margins = 0;
    hex = row.add("edittext", undefined, SAMA.settings.get("ui.text", "")); hex.preferredSize = [92, 26]; hex.minimumSize = [92, 26]; hex.helpTip = "Type a hex colour, for example #00ff00, then press Apply. Empty = default.";
    SAMA.tint(hex, C.text);
    sw = row.add("customButton", undefined, ""); sw.preferredSize = [26, 26]; sw.noSlide = true;
    sw.onDraw = function () {
        var g = this.graphics, c = shown(), bad = (hex.text && !SAMA.hex2rgb(hex.text));
        SAMA.rrect(g, 0, 0, 26, 26, 7); g.fillPath(SAMA.brush(g, bad ? [0.5, 0.15, 0.15, 1] : [c[0], c[1], c[2], 1]));
    };
    hex.onChanging = drawSwatch;
    lab1 = row.add("customButton", undefined, ""); lab1.preferredSize = [10, 26]; lab1.minimumSize = [10, 26]; lab1.alignment = ["fill", "center"]; lab1.noSlide = true;   // small minimum size: the labels never widen the panel
    lab1.onDraw = function () { var g = this.graphics; SAMA.text(g, "1st colour", SAMA.font(g, 10), C.text, 0, 6, this.size.width, false); };
    row2 = root.add("group"); row2.orientation = "row"; row2.alignChildren = ["left", "center"]; row2.alignment = ["fill", "top"]; row2.spacing = 8; row2.margins = 0;
    hex2 = row2.add("edittext", undefined, SAMA.settings.get("ui.text2", "")); hex2.preferredSize = [92, 26]; hex2.minimumSize = [92, 26];       // 92 + 8 + 26 + 8 + 10 = 144: the same total width as the single-colour row had hex2.helpTip = "Optional second colour for a gradient, for example #ff00aa. Empty = no gradient.";
    SAMA.tint(hex2, C.text);
    sw2 = row2.add("customButton", undefined, ""); sw2.preferredSize = [26, 26]; sw2.noSlide = true;       // same size as the first box; shows the two colours together
    sw2.onDraw = function () {
        var g = this.graphics, a = shown(), b = hex2.text ? SAMA.hex2rgb(hex2.text) : null, bad = (hex2.text && !b);
        if (bad) { SAMA.rrect(g, 0, 0, 26, 26, 7); g.fillPath(SAMA.brush(g, [0.5, 0.15, 0.15, 1])); }
        else if (b) { SAMA.gradFill(g, 0, 0, 26, 26, 7, [a[0], a[1], a[2], 1], [b[0], b[1], b[2], 1], C.bg); }
        else { SAMA.rrect(g, 0.5, 0.5, 25, 25, 7); g.fillPath(SAMA.brush(g, C.tile)); SAMA.rrect(g, 0.5, 0.5, 25, 25, 7); g.strokePath(SAMA.pen(g, C.line, 1)); }
    };
    hex2.onChanging = drawSwatch;
    lab2 = row2.add("customButton", undefined, ""); lab2.preferredSize = [10, 30]; lab2.minimumSize = [10, 30]; lab2.alignment = ["fill", "center"]; lab2.noSlide = true;
    lab2.onDraw = function () { var g = this.graphics, f = SAMA.font(g, 10); SAMA.text(g, "2nd colour", f, C.text, 0, 1, this.size.width, false); SAMA.text(g, "(for gradient)", f, C.text, 0, 15, this.size.width, false); };

    st = root.add("statictext", undefined, ""); st.preferredSize = [10, 6];      // a little air between the two sections
    SAMA.boldLabel(root, "Text height fix");
    st = root.add("statictext", undefined, "In pixels. 0 = automatic. A negative number moves all text up, a positive one moves it down.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 58];       // tall enough for three lines on a narrow panel SAMA.tint(st, C.dim);
    dyE = root.add("edittext", undefined, SAMA.settings.get("ui.textDy", "0")); dyE.preferredSize = [70, 26]; dyE.alignment = ["left", "top"];
    SAMA.tint(dyE, C.text);

    function apply() {
        var t = SAMA.trim(hex.text), t2 = SAMA.trim(hex2.text), dy = parseFloat(dyE.text);
        if (t && !SAMA.hex2rgb(t)) { SAMA.alert("That is not a hex colour. Use six digits such as #00ff00."); return; }
        if (t2 && !SAMA.hex2rgb(t2)) { SAMA.alert("The second colour is not a hex colour. Use six digits such as #ff00aa."); return; }
        if (SAMA.trim(dyE.text) !== "" && isNaN(dy)) { SAMA.alert("The text height fix must be a number such as -4 or 0."); return; }
        SAMA.settings.set("ui.text", t ? SAMA.rgb2hex(SAMA.hex2rgb(t)) : "");
        SAMA.settings.set("ui.text2", t2 ? SAMA.rgb2hex(SAMA.hex2rgb(t2)) : "");
        SAMA.settings.set("ui.textDy", String(isNaN(dy) ? 0 : dy));
        SAMA.loadTheme(); SAMA.refreshTheme();
    }
    SAMA.rowAdd(defs, false, { kind: "primary", minW: 70, icon: "check", label: "Apply", tip: "Apply and remember these settings", fn: SAMA.guard("Settings: Apply", apply) }, 90, 34);
    SAMA.rowAdd(defs, true, { label: "Default", tip: "Back to the default colour and automatic text height",
        fn: SAMA.guard("Settings: Default", function () { hex.text = ""; hex2.text = ""; dyE.text = "0"; drawSwatch(); apply(); }) }, 80, 34);
    st = root.add("statictext", undefined, ""); st.preferredSize = [10, 4];
    SAMA.flowRow(root, defs, { h: 34 });
    st = root.add("statictext", undefined, ""); st.preferredSize = [10, 6];
    SAMA.boldLabel(root, "Preset folder");
    folderInfo = root.add("statictext", undefined, "", { multiline: true }); folderInfo.alignment = ["fill", "top"]; folderInfo.preferredSize = [200, 40]; SAMA.tint(folderInfo, C.dim);
    defs = [];
    SAMA.rowAdd(defs, false, { icon: "folder", label: "Change folder", tip: "Choose where presets are saved", minW: 100, fn: SAMA.guard("Settings: change folder", function () { if (SAMA.openSetup) { SAMA.openSetup(); } }) }, 140, 34);
    SAMA.flowRow(root, defs, { h: 34 });
    SAMA.settingsRefresh = function () {
        var c = SAMA.settings.get("libPath", ""); folderInfo.text = c ? c : SAMA.defaultLibPath(); hex.text = SAMA.settings.get("ui.text", ""); hex2.text = SAMA.settings.get("ui.text2", ""); dyE.text = SAMA.settings.get("ui.textDy", "0"); drawSwatch(); };
};

/* ==========================================================================
   AUTO BEAT MARKER  (own implementation of the usual method: audio -> amplitude keyframes -> peaks -> markers)
   ========================================================================== */
// Finds the beats in a list of (time, loudness) samples. sens 0..100: higher finds more beats. Returns [{t, v}].
SAMA.beatDetect = function (times, vals, sens, minGap) {
    var n = vals.length, s = [], i, lo, hi, w, m, m2, sd, k, thr, maxV = 0, dt, ps = [0], pq = [0], out = [], last, cnt;
    if (n < 5) { return out; }
    for (i = 0; i < n; i++) {
        s[i] = (i === 0 || i === n - 1) ? vals[i] : (vals[i - 1] + 2 * vals[i] + vals[i + 1]) / 4;
        if (s[i] > maxV) { maxV = s[i]; }
        ps[i + 1] = ps[i] + s[i]; pq[i + 1] = pq[i] + s[i] * s[i];
    }
    if (maxV <= 0) { return out; }
    dt = (times[n - 1] - times[0]) / (n - 1); if (!(dt > 0)) { dt = 1 / 30; }
    w = Math.max(5, Math.round(1.0 / dt));                          // compare each sample with the loudness of the surrounding second
    k = 2.2 - 2.0 * (Math.max(0, Math.min(100, sens)) / 100);        // sensitive = a small step above the surroundings is enough
    for (i = 1; i < n - 1; i++) {
        if (!(s[i] >= s[i - 1] && s[i] > s[i + 1])) { continue; }
        if (s[i] < 0.12 * maxV) { continue; }
        lo = Math.max(0, i - w); hi = Math.min(n - 1, i + w); cnt = hi - lo + 1;
        m = (ps[hi + 1] - ps[lo]) / cnt; m2 = (pq[hi + 1] - pq[lo]) / cnt; sd = Math.sqrt(Math.max(0, m2 - m * m));
        thr = m + k * sd;
        if (s[i] <= thr) { continue; }
        last = out.length ? out[out.length - 1] : null;
        if (last && times[i] - last.t < minGap) { if (s[i] > last.v) { last.t = times[i]; last.v = s[i]; } continue; }     // too close: keep the stronger one
        out.push({ t: times[i], v: s[i] });
    }
    return out;
};
// Steady-beat finder (for songs with vocals / chords, where "every hit" picks up words instead of the beat).
// 1) the loudness is turned into an onset curve (how fast it rises), 2) the tempo is found by autocorrelation (60-200 BPM, favouring about 120),
// 3) dynamic programming picks the chain of onsets that is closest to that tempo (the classic beat-tracking method of D. Ellis).
// bpmHint > 0 fixes the tempo. Returns { beats: [{t, v}], bpm }.
SAMA.beatTrack = function (times, vals, bpmHint, stiff) {
    var n = vals.length, dt, i, j, k, s = [], o = [], mx = -1e9, rms = 0, cnt = 0, lagLo, lagHi, ac = [], ad = [], lagTop, w, bestLag = 0, bestSc = -1e9, sc, wgt, T, C = [], P = [], best, bp, lag, r, lMin, lMax, out = [], e, a0, a1, a2, den;
    if (n < 30) { return { beats: out, bpm: 0 }; }
    dt = (times[n - 1] - times[0]) / (n - 1);
    if (!(dt > 0)) { return { beats: out, bpm: 0 }; }
    for (i = 0; i < n; i++) { if (vals[i] > mx) { mx = vals[i]; } }
    for (i = 0; i < n; i++) { s[i] = Math.max(vals[i], mx - 40); }                       // silence must not dominate
    for (i = 0; i < n; i++) { s[i] = (i === 0 || i === n - 1) ? s[i] : (s[i - 1] + 2 * s[i] + s[i + 1]) / 4; }
    o[0] = 0;
    for (i = 1; i < n; i++) { o[i] = Math.max(0, s[i] - s[i - 1]); if (o[i] > 0) { rms += o[i] * o[i]; cnt++; } }
    rms = cnt ? Math.sqrt(rms / cnt) : 0;
    if (!(rms > 0)) { return { beats: out, bpm: 0 }; }
    for (i = 0; i < n; i++) { o[i] = Math.min(o[i] / rms, 4); }                           // unit scale, one huge hit cannot rule everything
    lagLo = Math.max(2, Math.floor(60 / 190 / dt)); lagHi = Math.max(lagLo + 2, Math.ceil(60 / 65 / dt));
    if (bpmHint > 0) { T = 60 / bpmHint / dt; }
    else {
        // autocorrelation, with its smooth downward trend removed so that the shortest lag cannot win just for being short
        lagTop = Math.min(n - 2, lagHi * 4 + 2);
        for (lag = lagLo - 1; lag <= lagTop; lag++) {
            sc = 0; for (i = lag; i < n; i++) { sc += o[i] * o[i - lag]; }
            ac[lag] = sc / (n - lag);
        }
        w = Math.max(2, Math.round(0.25 / dt));
        for (lag = lagLo - 1; lag <= lagTop; lag++) {
            sc = 0; cnt = 0;
            for (j = Math.max(lagLo - 1, lag - w); j <= Math.min(lagTop, lag + w); j++) { sc += ac[j]; cnt++; }
            ad[lag] = ac[lag] - sc / cnt;
        }
        for (lag = lagLo; lag <= lagHi; lag++) {
            if (!(ad[lag] > 0 && ad[lag] >= ad[lag - 1] && ad[lag] >= ad[lag + 1])) { continue; }          // only real peaks count
            sc = ad[lag];
            if (2 * lag <= lagTop && ad[2 * lag] > 0) { sc += 0.6 * ad[2 * lag]; }                          // a real beat repeats every 2 and 4 beats too
            if (4 * lag <= lagTop && ad[4 * lag] > 0) { sc += 0.4 * ad[4 * lag]; }
            r = Math.log(lag * dt / 0.6) / Math.LN2;                                     // distance from about 100 BPM in octaves
            sc = sc * Math.exp(-0.5 * (r / 0.45) * (r / 0.45));
            if (sc > bestSc) { bestSc = sc; bestLag = lag; }
        }
        if (!bestLag) { bestLag = Math.round(0.6 / dt); }
        T = bestLag; a0 = ac[bestLag - 1]; a1 = ac[bestLag]; a2 = ac[bestLag + 1]; den = a0 - 2 * a1 + a2;
        if (den < 0) { T = bestLag + 0.5 * (a0 - a2) / den; }                              // parabola through the peak: finer than one sample
        while (60 / (T * dt) > 135) { T = T * 2; }                                          // a beat you can nod to is 65-135 BPM; type the BPM for faster songs
        while (60 / (T * dt) < 65) { T = T / 2; }
    }
    if (!(T > 1)) { return { beats: out, bpm: 0 }; }
    lMin = Math.max(1, Math.round(T * 0.8)); lMax = Math.max(lMin + 1, Math.round(T * 1.35));   // beats are never closer than 0.8 of the tempo, so a busy song cannot fill up with markers
    for (i = 0; i < n; i++) {
        best = -1e9; bp = -1;
        for (lag = lMin; lag <= lMax; lag++) {
            j = i - lag; if (j < 0) { break; }
            r = Math.log(lag / T);
            sc = C[j] - stiff * r * r;
            if (sc > best) { best = sc; bp = j; }
        }
        if (bp >= 0 && best > 0) { C[i] = o[i] + best; P[i] = bp; } else { C[i] = o[i]; P[i] = -1; }
    }
    e = n - 1; best = -1e9;
    for (i = Math.max(0, n - lMax); i < n; i++) { if (C[i] > best) { best = C[i]; e = i; } }
    while (e >= 0) { out.push({ t: times[e], v: o[e] }); e = P[e]; }
    out.reverse();
    return { beats: out, bpm: 60 / (T * dt) };
};
// Runs After Effects' own "Convert Audio to Keyframes" on one layer and reads the loudness back. Returns {times, vals} or null.
SAMA.beatLoudness = function (comp, layer) {
    var sel = comp.selectedLayers, i, ws = comp.workAreaStart, wd = comp.workAreaDuration, cmd, n0, amp, fx, sl, p, k, res = { times: [], vals: [] }, a, b, step;
    cmd = app.findMenuCommandId("Convert Audio to Keyframes");
    if (!cmd) { SAMA.alert("This After Effects language has no menu item named \"Convert Audio to Keyframes\", so the beat finder cannot start.\nTip: temporarily switch After Effects to English, or run Layer > Keyframe Assistant > Convert Audio to Keyframes yourself and tell me the layer name."); return null; }
    try {
        a = Math.max(0, layer.inPoint); b = Math.min(comp.duration, layer.outPoint);
        if (b - a < 0.2) { SAMA.alert("The selected layer is not inside the composition's time range."); return null; }
        comp.workAreaStart = a; comp.workAreaDuration = b - a;
        for (i = 1; i <= comp.numLayers; i++) { comp.layer(i).selected = false; }
        layer.selected = true;
        n0 = comp.numLayers;
        SAMA.cancelTask();
        app.executeCommand(cmd);
        if (comp.numLayers !== n0 + 1) { SAMA.alert("After Effects did not create the loudness layer. Make sure the layer really has audio and that the audio switch (speaker) is on."); return null; }
        amp = null;                                   // find the new layer by name instead of trusting that it sits on top, so a layer of yours can never be removed by mistake
        for (i = 1; i <= comp.numLayers; i++) {
            try { if (/^Audio Amplitude/.test(comp.layer(i).name) && comp.layer(i).property("ADBE Effect Parade").property("Both Channels")) { amp = comp.layer(i); break; } } catch (e5) { }
        }
        if (!amp) { SAMA.alert("After Effects created the loudness layer under another name, so it was left in place. Delete it yourself and try again."); return null; }
        try { fx = amp.property("ADBE Effect Parade").property("Both Channels"); } catch (e1) { fx = null; }
        sl = fx ? fx.property(1) : null;
        if (sl && sl.numKeys > 4) {
            step = Math.max(1, Math.ceil(sl.numKeys / 40000));      // very long audio: read at most about 40000 keyframes, so normal songs are read in full
            for (k = 1; k <= sl.numKeys; k += step) { res.times.push(sl.keyTime(k)); res.vals.push(sl.keyValue(k)); }
        }
        fx = null; sl = null;
        amp.remove(); amp = null;
    } finally {
        try { comp.workAreaStart = ws; comp.workAreaDuration = wd; } catch (e3) { }
        try { for (i = 1; i <= comp.numLayers; i++) { comp.layer(i).selected = false; } for (i = 0; i < sel.length; i++) { sel[i].selected = true; } } catch (e4) { }
    }
    return res.times.length ? res : null;
};
SAMA.beatMarkerProp = function (comp, layer, onComp) {
    if (onComp) { try { if (comp.markerProperty) { return comp.markerProperty; } } catch (e) { } }
    return layer.property("ADBE Marker");
};
SAMA.beatClear = function (prop) {
    var k, n = 0, c;
    for (k = prop.numKeys; k >= 1; k--) {
        try { c = prop.keyValue(k).comment; if (/^beat \d+$/.test(c)) { prop.removeKey(k); n++; } } catch (e) { }
    }
    return n;
};
SAMA.buildBeat = function (root) {
    var C = SAMA.C, st, row, sl, slVal, gap, chk, status, defs = [], mode, styleRow, styleFr, styleHelp, bpmE, lblBpm, lblSens, lblGap;
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.spacing = 8;
    mode = (SAMA.settings.get("beat.mode", "steady") === "hit") ? "hit" : "steady";
    function setMode(m) {
        mode = m; SAMA.settings.set("beat.mode", m);
        styleHelp.text = (m === "steady") ? "For songs with singing, like pop and English songs. Follows the tempo." : "For loud drum songs like phonk or funk. A marker on every strong hit.";
        try { styleFr.redraw(); } catch (e) { }
        if (lblBpm) {                                    // each setting says which style it belongs to
            lblBpm.text = (m === "steady") ? "BPM (0 = find it for me)" : "BPM (only for Pop & vocals)";
            lblSens.text = (m === "hit") ? "Sensitivity" : "Sensitivity (only for Beats/Drums)";
            lblGap.text = (m === "hit") ? "Minimum gap (seconds)" : "Minimum gap (only for Beats/Drums)";
        }
    }
    SAMA.boldLabel(root, "Auto Beat Marker");
    st = root.add("statictext", undefined, "Select an audio layer, then press Detect beats. Markers are placed on the beats.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 40]; SAMA.tint(st, C.dim);

    SAMA.boldLabel(root, "Style");
    styleRow = [];
    SAMA.rowAdd(styleRow, false, { kind: "pill", minW: 70, label: "Pop & vocals", tip: "For songs with singing, like pop and English songs: follows the tempo of the song", isSel: function () { return mode === "steady"; }, fn: function () { setMode("steady"); } }, 104, 28);
    SAMA.rowAdd(styleRow, false, { kind: "pill", minW: 70, label: "Beats/Drums", tip: "For loud drum songs like phonk or funk: a marker on every strong hit", isSel: function () { return mode === "hit"; }, fn: function () { setMode("hit"); } }, 104, 28);
    styleFr = SAMA.flowRow(root, styleRow, { h: 28 });
    styleHelp = root.add("statictext", undefined, "", { multiline: true }); styleHelp.alignment = ["fill", "top"]; styleHelp.preferredSize = [200, 42]; SAMA.tint(styleHelp, C.dim);
    lblBpm = SAMA.boldLabel(root, "BPM (0 = find it for me)");
    bpmE = root.add("edittext", undefined, SAMA.settings.get("beat.bpm", "0")); bpmE.preferredSize = [70, 26]; bpmE.alignment = ["left", "top"]; SAMA.tint(bpmE, C.text);
    bpmE.helpTip = "Pop & vocals only. If the markers come out twice as fast or half as fast as you feel the song, type its real BPM here";

    lblSens = SAMA.boldLabel(root, "Sensitivity");
    row = root.add("group"); row.orientation = "row"; row.alignment = ["fill", "top"]; row.alignChildren = ["fill", "center"]; row.spacing = 8; row.margins = 0;
    sl = row.add("slider", undefined, parseFloat(SAMA.settings.get("beat.sens", "55")), 0, 100); sl.alignment = ["fill", "center"]; sl.preferredSize = [120, 20];
    slVal = row.add("statictext", undefined, "55"); slVal.preferredSize = [30, 18]; slVal.alignment = ["right", "center"]; SAMA.tint(slVal, C.text);
    sl.onChanging = function () { slVal.text = String(Math.round(sl.value)); };
    sl.onChange = function () { SAMA.settings.set("beat.sens", String(Math.round(sl.value))); };
    slVal.text = String(Math.round(parseFloat(SAMA.settings.get("beat.sens", "55"))));
    st = root.add("statictext", undefined, "Higher finds more beats (weaker hits too). Lower finds only the strongest.", { multiline: true });
    st.alignment = ["fill", "top"]; st.preferredSize = [200, 28]; SAMA.tint(st, C.dim);

    lblGap = SAMA.boldLabel(root, "Minimum gap (seconds)");
    gap = root.add("edittext", undefined, SAMA.settings.get("beat.gap", "0.25")); gap.preferredSize = [70, 26]; gap.alignment = ["left", "top"]; SAMA.tint(gap, C.text);
    gap.helpTip = "Beats closer together than this are merged into the stronger one";
    chk = root.add("checkbox", undefined, "Put the markers on the composition"); chk.alignment = ["fill", "top"]; SAMA.tint(chk, C.text);
    chk.value = (SAMA.settings.get("beat.comp", "0") === "1");
    chk.onClick = function () { SAMA.settings.set("beat.comp", chk.value ? "1" : "0"); };

    setMode(mode);
    function readGap() { var g = parseFloat(gap.text); if (isNaN(g) || g < 0.05) { g = 0.25; } SAMA.settings.set("beat.gap", String(g)); return g; }
    function detect() {
        SAMA.act("Auto beat markers", "layers", function (comp, layers) {
            var lay = layers[0], data, beats, tr, prop, i, mg = readGap(), ints = [], bpm, med, mv, made = 0;
            if (layers.length > 1) { SAMA.alert("Select just one audio layer."); return; }
            if (!lay.hasAudio) { SAMA.alert("That layer has no audio. Select an audio layer (or a video with sound)."); return; }
            data = SAMA.beatLoudness(comp, lay);
            if (!data) { status.text = "Nothing was measured. Check that the layer has audio."; return; }
            if (mode === "steady") {
                tr = SAMA.beatTrack(data.times, data.vals, SAMA.num(bpmE.text, 0), 150);
                SAMA.settings.set("beat.bpm", String(SAMA.num(bpmE.text, 0)));
                beats = tr.beats;
                if (!beats.length) { status.text = "No beats found. Try the Beats/Drums style."; return; }
            } else {
                beats = SAMA.beatDetect(data.times, data.vals, sl.value, mg);
                if (!beats.length) { status.text = "No beats found. Try a higher sensitivity."; return; }
            }
            prop = SAMA.beatMarkerProp(comp, lay, chk.value);
            SAMA.beatClear(prop);
            for (i = 0; i < beats.length; i++) {
                mv = new MarkerValue("beat " + (i + 1));
                prop.setValueAtTime(beats[i].t, mv); made++;
                if (i > 0) { ints.push(beats[i].t - beats[i - 1].t); }
            }
            ints.sort(function (a, b) { return a - b; });
            med = ints.length ? ints[Math.floor(ints.length / 2)] : 0;
            bpm = med > 0 ? Math.round(60 / med) : 0;
            if (mode === "steady" && tr && tr.bpm) { bpm = Math.round(tr.bpm); }
            status.text = made + " markers added" + (bpm ? "  (about " + bpm + " BPM)" : "") + ((chk.value && !(prop === comp.markerProperty)) ? "  - on the layer, because this After Effects has no composition markers" : "") + ".";
        });
    }
    function clearAll() {
        SAMA.act("Clear beat markers", "layers", function (comp, layers) {
            var n = 0, i;
            for (i = 0; i < layers.length; i++) { n += SAMA.beatClear(layers[i].property("ADBE Marker")); }
            try { if (comp.markerProperty) { n += SAMA.beatClear(comp.markerProperty); } } catch (e) { }
            status.text = n + " beat markers removed.";
        });
    }
    SAMA.rowAdd(defs, false, { kind: "primary", minW: 90, icon: "check", label: "Detect beats", tip: "Analyse the selected audio layer and add markers", fn: SAMA.guard("Beat: detect", detect) }, 130, 34);
    SAMA.rowAdd(defs, true, { icon: "trash", label: "Clear", tip: "Remove the markers made by this tool", fn: SAMA.guard("Beat: clear", clearAll) }, 90, 34);
    SAMA.flowRow(root, defs, { h: 34 });
    status = root.add("statictext", undefined, "", { multiline: true }); status.alignment = ["fill", "top"]; status.preferredSize = [200, 44]; SAMA.tint(status, C.text);
};

// Older After Effects (2020 and before) sizes a text line by its full text and will not squeeze it, so one long line (a folder path, a sentence) made the whole
// panel wider than the space it was given and pushed the graph, buttons and scroll arrows off the edge. Long text lines and long check boxes may now shrink.
SAMA.softenMins = function (c) {
    var i, k, t, ms, txt;
    if (!c || !c.children) { return; }
    for (i = 0; i < c.children.length; i++) {
        k = c.children[i]; t = k.type;
        if (t === "statictext" || t === "checkbox") {
            try {
                txt = String(k.text || "");
                if (txt.length > 24 || (t === "statictext" && k.alignment && k.alignment[0] === "fill")) {
                    ms = k.minimumSize;
                    k.minimumSize = [t === "checkbox" ? 90 : 24, (ms && ms.height) ? ms.height : (k.size ? k.size.height : 0)];
                }
            } catch (e) { }
        }
        if (k.children) { SAMA.softenMins(k); }
    }
};
SAMA.buildAll = function (root) {
    SAMA.loadTheme();
    var bar, body, sb, pages = [], cur, i;
    SAMA.reflowers = [];
    SAMA.skin(root);
    root.orientation = "column"; root.alignChildren = ["fill", "top"]; root.margins = 0; root.spacing = 0;
    // 9:16 portrait: this is the size a floating window opens at and the size hint a docked panel starts with
    root.preferredSize = [324, 576]; root.minimumSize = [190, 280];

    cur = parseInt(SAMA.settings.get("tab", "0"), 10);
    if (isNaN(cur) || cur < 0 || (cur > 2 && cur !== 5)) { cur = 0; }
    var firstRun = (SAMA.settings.get("setup.done", "") !== "1"), startTab = cur, locked = !SAMA.isActivated();
    if (firstRun) { cur = (SAMA.accessState() === "on") ? 4 : 9; }      // first run: scripting permission check (only when it is not already on), then the preset folder
    if (locked) { cur = 6; }

    var prevTab = (firstRun || locked) ? startTab : cur;
    function switchTo(n) {
        var j, old = cur, x0, x1, oldP, newP, dir, W, anim, hidden;
        if (locked && n !== 6) { return; }                        // nothing else opens until the key is accepted
        if (n === 3 && cur === 3) { n = prevTab; }               // the gear is a toggle: a second click goes back
        if (n === cur) { return; }
        if (n === 3) { prevTab = (cur === 4) ? prevTab : cur; if (SAMA.settingsRefresh) { SAMA.settingsRefresh(); } }
        if (n === 4) { if (cur !== 3) { prevTab = cur; } if (SAMA.setupRefresh) { SAMA.setupRefresh(); } }
        if (SAMA.cancelDrag) { SAMA.cancelDrag(); }
        oldP = pages[old]; newP = pages[n]; dir = (n === 5 ? 2.5 : n) > (old === 5 ? 2.5 : old) ? 1 : -1;
        cur = n; SAMA.page = newP; SAMA.scrollY = 0;
        if (n < 3 || n === 5) { SAMA.settings.set("tab", n); }
        x0 = bar.xOf(old); x1 = bar.xOf(n);
        SAMA.finishAll();
        anim = SAMA.anim.on && !SAMA.busy;
        if (!anim) {
            for (j = 0; j < pages.length; j++) { pages[j].visible = (j === n); }
            SAMA.relayout();
            bar.setX(x1); bar.redraw();
            return;
        }
        // slide: the new page comes in from the side while the old one leaves the other way, and the highlight glides along.
        // Runs as scheduled frames (non-blocking); finishing - or being interrupted - always leaves the final, clean state.
        newP.visible = true; oldP.visible = true;
        SAMA.relayout();
        hidden = [];
        SAMA.slideHide(oldP, hidden); SAMA.slideHide(newP, hidden);      // native boxes smear ghost copies of their text while they move: hidden for the slide
        W = Math.max(60, SAMA.body.size.width);
        SAMA.tween(170, function (p) {
            try {
                newP.location = [SAMA.BODY_M + dir * W * (1 - p), SAMA.BODY_M];
                oldP.location = [SAMA.BODY_M - dir * W * p, SAMA.BODY_M];
            } catch (e) { }
            bar.setX(x0 + (x1 - x0) * p);
        }, SAMA.easeInOut, function () {
            for (j = 0; j < hidden.length; j++) { try { hidden[j].visible = true; } catch (e3) { } }
            oldP.visible = false;
            try { newP.location = [SAMA.BODY_M, SAMA.BODY_M]; oldP.location = [SAMA.BODY_M, SAMA.BODY_M]; } catch (e2) { }
            SAMA.relayout();
            bar.setX(x1); bar.redraw();
        });
    }
    bar = SAMA.topBar(root, ["curve", "bookmark", "wrench", "wave"], ["SAMA-toolkit Flow - easing curves", "SAMA-toolkit Vault - preset library", "SAMA-toolkit Quick Edit - toolbox", "SAMA-toolkit Auto Beat Marker"],
        function () { return cur; }, function (i) { switchTo(i === 99 ? 3 : (i === 3 ? 5 : i)); });      // tab 4 is the beat page (5); 99 is the gear (settings page 3)
    SAMA.bar = bar;

    // body: same fill / margins as before; a borderless panel so that a page slid upwards is clipped instead of covering the bar
    body = root.add("panel", undefined, "", { borderStyle: "none" });
    body.orientation = "stack"; body.alignment = ["fill", "fill"]; body.alignChildren = ["fill", "fill"]; body.margins = [12, 12, 12, 12];
    body.minimumSize = [0, 0]; body.preferredSize = [200, 300];   // never let page content push the body past the dock
    SAMA.skin(body);
    for (i = 0; i < 10; i++) {
        pages[i] = body.add("group"); pages[i].alignment = ["fill", "fill"]; pages[i].alignChildren = ["fill", "top"]; pages[i].margins = 0; pages[i].minimumSize = [0, 0];
        SAMA.skin(pages[i]);
    }
    SAMA.buildFlow(pages[0]);
    SAMA.buildVault(pages[1]);
    SAMA.buildQuick(pages[2]);
    SAMA.buildSettings(pages[3]);
    SAMA.buildSetup(pages[4]);
    SAMA.buildBeat(pages[5]);
    SAMA.buildActivation(pages[6]);
    SAMA.buildSaveAsk(pages[7]);
    SAMA.buildSaveRun(pages[8]);
    SAMA.buildAccess(pages[9]);
    SAMA.svGo = function () { switchTo(8); };
    SAMA.svBack = function () { switchTo(1); };
    SAMA.askSave = function (cb) { if (locked) { return; } SAMA.saveCb = cb; switchTo(7); };
    SAMA.chooseSave = function (mode) {
        var cb = SAMA.saveCb; SAMA.saveCb = null;
        switchTo(1);
        if (mode && cb) { cb(mode); }
    };
    SAMA.openBeat = function () { switchTo(5); };
    SAMA.openSettings = function () { switchTo(3); };
    SAMA.openSetup = function () { switchTo(4); };
    SAMA.leaveSetup = function () { switchTo(prevTab === 3 || prevTab === 4 || prevTab === 6 || prevTab === 9 ? 0 : prevTab); };
    SAMA.afterAccess = function () { switchTo((SAMA.settings.get("setup.done", "") !== "1") ? 4 : 0); };
    SAMA.afterActivate = function () {
        locked = false;
        var go = (SAMA.settings.get("setup.done", "") !== "1") ? ((SAMA.accessState() === "on") ? 4 : 9) : ((startTab === 0 || startTab === 1 || startTab === 2 || startTab === 5) ? startTab : 0);
        switchTo(go);
        prevTab = (startTab === 0 || startTab === 1 || startTab === 2 || startTab === 5) ? startTab : 0;
    };
    for (i = 0; i < 10; i++) { pages[i].visible = (i === cur); }
    // hair-thin scroll line on the right edge: fades out when idle, comes back when the cursor reaches it (or when the page scrolls)
    sb = body.add("customButton", undefined, ""); sb.alignment = ["right", "top"]; sb.preferredSize = [SAMA.SB_W, 100]; sb.visible = false;
    sb.sMax = 0; sb.sVal = 0; sb.a = 0; sb.hot = false; sb.down = false;   // own fields: a customButton has no value / maxvalue
    sb.redraw = function () { try { this.notify("onDraw"); } catch (e) { } };
    sb.zone = 0;                          // 1 = cursor on the up arrow, 2 = on the down arrow
    SAMA.ARROW_H = 26;
    sb.onDraw = function () {
        var g = this.graphics, W = this.size.width, H = this.size.height, keep = SAMA.alpha, cx = W / 2, z, y, dir, on, atEnd, col;
        if (this.a <= 0.01) { return; }
        for (z = 1; z <= 2; z++) {
            dir = (z === 1) ? -1 : 1; y = (z === 1) ? SAMA.ARROW_H / 2 : H - SAMA.ARROW_H / 2;
            on = (this.zone === z); atEnd = (z === 1) ? (this.sVal <= 0) : (this.sVal >= this.sMax);
            SAMA.alpha = this.a * (atEnd ? 0.35 : 1);
            col = (on && !atEnd) ? SAMA.C.accent : SAMA.C.dim;
            g.newPath(); g.moveTo(cx - 4, y - dir * 2); g.lineTo(cx, y + dir * 2); g.lineTo(cx + 4, y - dir * 2);
            g.strokePath(SAMA.pen(g, col, (on && !atEnd) ? 2.4 : 1.8));
        }
        SAMA.alpha = keep;
    };
    SAMA.sbAct = 0;
    SAMA.noSched = true;                 // no scheduled tasks any more: After Effects refused to run them while any dialog was open (script error at line 0)
    SAMA.sbSchedule = function () { };
    SAMA.pokeBar = function () {         // show the line and (re)start the idle countdown
        var b = SAMA.sb;
        if (!b || !(b.sMax > 0)) { return; }
        SAMA.sbAct = SAMA.now(); if (b.hot || b.down) { b.a = 1; } b.redraw();
    };
    SAMA.sbFadeNow = function () {
        var b = SAMA.sb, a0;
        if (!b || b.a <= 0.46 || b.hot || b.down) { return; }
        b.a = 0.45; b.redraw();            // the arrows rest at a soft level and never vanish (no animation: it ran from a timer and showed the busy circle)
    };
    SAMA.scrollTo = function (v) {
        var m = SAMA.sb.sMax;
        v = Math.max(0, Math.min(m, Math.round(v)));
        if (v === SAMA.scrollY) { return; }
        SAMA.scrollY = v; SAMA.sb.sVal = v;
        try { SAMA.page.location = [SAMA.BODY_M, SAMA.BODY_M - v]; } catch (e) { }
        SAMA.pokeBar();
    };
    sb.setValue = function (v) { SAMA.scrollTo(v); };
    SAMA.scrollBy = function (d) {         // a short glide to the new position
        var from = SAMA.scrollY, to = Math.max(0, Math.min(sb.sMax, from + d));
        if (to === from) { return; }
        SAMA.scrollTo(to);                 // instant: any waiting loop here makes Windows show its busy circle
    };
    function zoneOf(ev) { var H = sb.size.height; return ev.clientY <= SAMA.ARROW_H ? 1 : (ev.clientY >= H - SAMA.ARROW_H ? 2 : 0); }
    function setZone(z) { if (sb.zone !== z) { sb.zone = z; sb.redraw(); } }
    sb.addEventListener("mouseover", function (ev) { sb.hot = true; sb.a = 1; setZone(zoneOf(ev)); sb.redraw(); });
    sb.addEventListener("mousemove", function (ev) { setZone(zoneOf(ev)); });
    sb.addEventListener("mousedown", SAMA.guard("Scroll arrows", function (ev) {
        var z = zoneOf(ev);
        sb.down = true; sb.a = 1;
        if (z === 1) { SAMA.scrollBy(-Math.max(60, Math.round(SAMA.page.size.height * 0.35))); }
        else if (z === 2) { SAMA.scrollBy(Math.max(60, Math.round(SAMA.page.size.height * 0.35))); }
        sb.redraw();
    }));
    sb.addEventListener("mouseup", function () { sb.down = false; sb.redraw(); });
    sb.addEventListener("mouseout", function () {
        sb.hot = false; sb.down = false; sb.zone = 0; sb.redraw(); SAMA.sbAct = SAMA.now();
        SAMA.sbSchedule();
        if (SAMA.noSched) { SAMA.sbFadeNow(); }
    });
    // mouse wheel from the page itself
    for (i = 0; i < pages.length; i++) { pages[i].isPage = true; SAMA.hookScroll(pages[i]); }
    SAMA.body = body; SAMA.sb = sb; SAMA.page = pages[cur]; SAMA.scrollY = 0;

    function onSize() {
        var n, j, changed;
        try { this.layout.resize(); } catch (e) { }
        for (n = 0; n < 3; n++) {      // grids may change their column count, which changes the height: settle in a few passes
            changed = false;
            for (j = 0; j < SAMA.reflowers.length; j++) { if (SAMA.reflowers[j]()) { changed = true; } }
            if (!changed) { break; }
            try { this.layout.layout(true); } catch (e2) { }
        }
        SAMA.fitScroll();
        for (j = 0; j < SAMA.reflowers.length; j++) { if (SAMA.reflowers[j]()) { changed = true; } }   // the page may just have become narrower (scrollbar)
        if (changed) { try { this.layout.layout(true); } catch (e3) { } SAMA.fitScroll(); }
    }
    SAMA.softenMins(root);
    root.layout.layout(true);
    root.onResizing = root.onResize = onSize;
    onSize.call(root);
    // Some versions of After Effects do not report every size change of a docked panel: look at the size whenever the top bar repaints and re-fit when it changed
    SAMA.lastSize = [root.size.width, root.size.height]; SAMA.inSizeCheck = false;
    SAMA.sizeCheck = function () {
        var w, h;
        if (SAMA.inSizeCheck) { return; }
        try { w = root.size.width; h = root.size.height; } catch (e) { return; }
        if (w === SAMA.lastSize[0] && h === SAMA.lastSize[1]) { return; }
        SAMA.lastSize = [w, h]; SAMA.inSizeCheck = true;
        try { onSize.call(root); } catch (e2) { }
        SAMA.inSizeCheck = false;
    };
    SAMA.hardenDraw(root);
    SAMA.repaintAll = function () { SAMA.alpha = 1; (function walk(c) { var i; try { if (c.type === "customButton") { c.notify("onDraw"); } if (c.children) { for (i = 0; i < c.children.length; i++) { walk(c.children[i]); } } } catch (e) { } })(root); };
    try { root.onShow = function () { SAMA.repaintAll(); }; } catch (e5) { }
};
SAMA.launch = function (thisObj) {
    var isPanel = (thisObj instanceof Panel);
    var win = isPanel ? thisObj : new Window("palette", SAMA.NAME, undefined, { resizeable: true });
    if (win === null) { return; }
    SAMA.win = win;
    SAMA.buildAll(win);
    if (!isPanel) { win.onShow = function () { try { win.onResize(); } catch (e) { } }; win.center(); win.show(); }
    return win;
};

SAMA.launch(this);
