/*
 * CompSetter Kit 1.0 for EffectCraft
 * ==================================
 *
 * Changes the settings of one or more compositions in one go and, if asked, of every
 * composition nested in them (comps used as layers, at any depth). Every setting has its own
 * switch: only the settings that are switched on are changed, everything else stays exactly as
 * it was. One click on Apply does the whole job as a single undo step ("CompSetter Kit").
 *
 * Settings it can change:
 *   Frame:        Width, Height, Pixel Aspect, Frame Rate, Duration (in frames or seconds)
 *   Advanced:     Renderer, Preserve frame rate when nested or in render queue,
 *                 Preserve resolution when nested
 *   Motion blur:  Shutter Angle, Shutter Phase, Samples Per Frame, Adaptive Sample Limit
 *   Layers:       the 3D switch of every layer of the comps processed (2D, 3D, or 3D with
 *                 per-character 3D for text layers)
 *
 * Installing
 *   As a docked panel: File > Scripts > Install ScriptUI Panel..., choose this file, then open
 *   it from the bottom of the Window menu (Window > CompSetterKit_EffectCraft.jsx).
 *   As a floating palette: File > Scripts > Run Script File..., choose this file.
 *
 * Design notes
 *   - The comps to change are chosen in the Apply to menu (Active composition, or Comps
 *     selected in Project panel): EffectCraft doesn't tell scripts which panel has the focus.
 *   - The renderer choices are EffectCraft's own: Classic 3D and Advanced 3D.
 *   - The field ranges follow EffectCraft's limits (pixel aspect 0.1 to 10, frame rate 1 to 999).
 *   - A Duration in frames is counted at the new frame rate when Frame Rate is changed as well.
 *   - Each comp is processed once per Apply, however many times it is nested.
 *   - With Include nested comps on, a nested comp becomes at least as long as every comp it is
 *     used in, rounded up to whole frames of its own rate, so its precomp layers reach those comps'
 *     ends; a comp used in several places gets the longest length any of them needs.
 *   - That length follows the comp a nested comp is used in, not where its layer sits: a precomp
 *     layer that starts before 0 or plays faster than normal still ends before the new end (one
 *     starting at -2 s in a comp made 10 s long ends at 8 s), and the status line counts it.
 *   - A comp that Apply to picked is never counted as nested in the status line, even when it is
 *     also used inside another comp it picked.
 *   - Frame Rate alone rounds each comp's length to whole frames of the new rate, as the
 *     Composition Settings dialog does.
 *   - 3D model and primitive layers are always 3D: the 3D menu leaves them alone, like cameras,
 *     lights and audio-only layers.
 *   - Layers are not lengthened past the end of their footage or nested comp (EffectCraft would
 *     hold the last frame): such a layer ends on the first comp frame at or after its source's end.
 *     Lengthening a comp never shortens a layer that already ran past its old end, and a layer
 *     that starts after a shortened comp's new end is left alone.
 *   - A value out of range is pulled to the nearest limit; text that isn't a number puts the last
 *     good value back.
 *   - The settings are saved as they change, not only when the window closes.
 *   - An error part-way takes the whole change back, and when nothing needs changing no undo step
 *     is added.
 *
 * How it works in EffectCraft
 *   - The project is only read (the prelude's own queries) until every change is known; then all
 *     the changes run as one engine.batch inside the undo group. The batch makes no comp active,
 *     so open comps, the active comp, selections and current times stay as they were, and if a
 *     step fails the batch restores the project and its history by itself.
 *   - Comp sizes are changed with the top-left anchor (layers keep their positions), never with
 *     comp.width / comp.height, which resize around the centre.
 *   - Comps are found with the engine's own queries (__get), not the prelude's item wrappers:
 *     those are cached by item id, and a docked panel outlives File > New / Open Project.
 *   - Levels of Undo (L, 32 unless changed in Settings > General): while an undo group is open,
 *     every command is recorded as a step until the group folds them into one. With k steps in
 *     the history, n <= L commands push max(0, k + n - L) of the user's steps out (a full history
 *     and 31 commands would lose 31); a group of more than L commands puts the old history back
 *     instead and records one step. So when n >= 2 and k + n > L, Apply reads L
 *     (prefs.get general.undoLevels) and adds pairs of Hide Shy Layers switches on its first comp
 *     that cancel out, enough to make the batch longer than L: Apply then keeps every old step,
 *     or loses only the oldest when the history is full, like any single edit. Apply is still one
 *     "CompSetter Kit" step, and an error part-way still rolls the whole batch back.
 *   - Apply sends few commands: one comp.settings per comp, one layer.setSwitch and one
 *     layer.timing per comp and value (layer.enablePerChar3D takes one layer at a time).
 *
 * For tests: set CompSetterKit_noUI = true before $.evalFile to load the functions without
 * opening the palette; they are then reachable as CompSetterKit (see the end of the file).
 */

(function compSetterKit(thisObj) {

    var NAME = "CompSetter Kit";
    var VERSION = "1.0";
    var UNDO_NAME = "CompSetter Kit";
    var STORE_SECTION = "CompSetterKit";
    var STORE_KEY = "settings";

    // ------------------------------------------------------------------ settings

    // The number fields. `decimals` is the precision kept (and shown); `def` is EffectCraft's
    // New Composition value.
    var FIELDS = [
        {key: "width", label: "Width:", unit: "px", min: 4, max: 30000, decimals: 0, def: 1920, block: "frame",
            tip: "Composition width in pixels (4 to 30000). Layers keep their positions."},
        {key: "height", label: "Height:", unit: "px", min: 4, max: 30000, decimals: 0, def: 1080, block: "frame",
            tip: "Composition height in pixels (4 to 30000). Layers keep their positions."},
        {key: "pixelAspect", label: "Pixel Aspect:", unit: "", min: 0.1, max: 10, decimals: 4, def: 1, block: "frame",
            tip: "Pixel Aspect Ratio (0.1 to 10; 1 is square pixels)."},
        {key: "frameRate", label: "Frame Rate:", unit: "fps", min: 1, max: 999, decimals: 3, def: 29.97, block: "frame",
            tip: "Frames per second (1 to 999). Layers and keyframes keep their times."},
        {key: "duration", label: "Duration:", unit: "", min: 1, max: 9000000, decimals: 0, def: 300, block: "frame",
            tip: "Composition length, in frames or in seconds."},
        {key: "shutterAngle", label: "Shutter Angle:", unit: "", min: 0, max: 720, decimals: 2, def: 180, block: "blur",
            tip: "Motion blur shutter angle in degrees (0 to 720)."},
        {key: "shutterPhase", label: "Shutter Phase:", unit: "", min: -360, max: 360, decimals: 2, def: -90, block: "blur",
            tip: "Motion blur shutter phase in degrees (-360 to 360)."},
        {key: "samples", label: "Samples Per Frame:", unit: "", min: 2, max: 64, decimals: 0, def: 16, block: "blur",
            tip: "Motion blur samples per frame (2 to 64)."},
        {key: "adaptiveLimit", label: "Adaptive Sample Limit:", unit: "", min: 16, max: 256, decimals: 0, def: 128, block: "blur",
            tip: "Motion blur adaptive sample limit (16 to 256)."}
    ];
    var FIELD = {};
    for (var fi = 0; fi < FIELDS.length; fi++) FIELD[FIELDS[fi].key] = FIELDS[fi];

    // A Duration is read in frames (whole numbers) or seconds.
    var DURATION = {
        frames: {min: 1, max: 9000000, decimals: 0},
        seconds: {min: 0.001, max: 9000000, decimals: 3}
    };

    // The drop-down menus: stored value, item text.
    var CHOICES = {
        renderer: [["leave", "Leave as is"], ["classic3D", "Classic 3D"], ["advanced3D", "Advanced 3D"]],
        preserveFrameRate: [["leave", "Leave as is"], ["on", "On"], ["off", "Off"]],
        preserveResolution: [["leave", "Leave as is"], ["on", "On"], ["off", "Off"]],
        threeD: [["leave", "Leave as is"], ["2d", "2D"], ["3d", "3D"], ["3dPerChar", "3D + per-character text"]],
        applyTo: [["active", "Active composition"], ["selection", "Comps selected in Project panel"]]
    };
    var CHOICE_DEFAULTS = {renderer: "leave", preserveFrameRate: "leave", preserveResolution: "leave", threeD: "leave", applyTo: "active"};

    function defaults() {
        var s = {};
        for (var i = 0; i < FIELDS.length; i++) {
            s[FIELDS[i].key + "On"] = false;
            s[FIELDS[i].key] = FIELDS[i].def;
        }
        s.durationUnit = "frames";
        for (var k in CHOICE_DEFAULTS) s[k] = CHOICE_DEFAULTS[k];
        s.includeNested = true;
        return s;
    }

    function rangeOf(key, unit) {
        return key === "duration" ? (DURATION[unit] || DURATION.frames) : FIELD[key];
    }

    var NUMBER = /^[+\-]?(\d+\.?\d*|\.\d+)([eE][+\-]?\d+)?$/;

    // The number typed into a field, or null when it isn't one. A number too big to hold (1e400)
    // comes back as +-Infinity, which the field then pulls to its limit.
    function parseNumber(text) {
        var t = String(text === undefined || text === null ? "" : text).replace(/^\s+|\s+$/g, "");
        if (!NUMBER.test(t)) return null;
        var n = Number(t);
        return isNaN(n) ? null : n;
    }

    // `n` rounded to `decimals` places as it is written (45.555 -> 45.56, though the double is
    // 45.55499...), halves away from zero.
    function roundTo(n, decimals) {
        var a = Math.abs(n), s = String(a), r = NaN;
        if (s.indexOf("e") < 0) r = Number(Math.round(Number(s + "e" + decimals)) + "e-" + decimals);
        if (!isFinite(r)) {
            var k = Math.pow(10, decimals);
            r = Math.round(a * k) / k;
        }
        return n < 0 && r !== 0 ? -r : r;
    }

    // A number as the field keeps it: at its precision, pulled into its range.
    function cleanValue(key, n, unit) {
        var r = rangeOf(key, unit);
        if (n === Infinity) return r.max;
        if (n === -Infinity) return r.min;
        var v = roundTo(n, r.decimals);
        if (v < r.min) v = r.min;
        if (v > r.max) v = r.max;
        return v;
    }

    function formatValue(key, v, unit) {
        var s = Number(v).toFixed(rangeOf(key, unit).decimals);
        if (s.indexOf(".") >= 0) s = s.replace(/0+$/, "").replace(/\.$/, "");
        return s === "-0" ? "0" : s;
    }

    function isChoice(key, v) {
        var list = CHOICES[key];
        for (var i = 0; i < list.length; i++) if (list[i][0] === v) return true;
        return false;
    }

    // Settings from `raw` (stored JSON or a caller's object) over the defaults. Values of the
    // wrong type are ignored; numbers out of range are ignored when `strict` (stored settings),
    // otherwise pulled into range.
    function readSettings(raw, strict) {
        var s = defaults();
        if (!raw || typeof raw !== "object") return s;
        if (raw.durationUnit === "frames" || raw.durationUnit === "seconds") s.durationUnit = raw.durationUnit;
        for (var i = 0; i < FIELDS.length; i++) {
            var f = FIELDS[i], on = raw[f.key + "On"], v = raw[f.key];
            if (typeof on === "boolean") s[f.key + "On"] = on;
            if (typeof v !== "number" || !isFinite(v)) continue;
            var clean = cleanValue(f.key, v, s.durationUnit);
            if (strict && Math.abs(clean - v) > 1e-9) continue;
            s[f.key] = clean;
        }
        for (var k in CHOICES) if (isChoice(k, raw[k])) s[k] = raw[k];
        if (typeof raw.includeNested === "boolean") s.includeNested = raw.includeNested;
        return s;
    }

    function loadSettings() {
        var raw = null;
        try {
            if (app.settings.haveSetting(STORE_SECTION, STORE_KEY)) raw = JSON.parse(app.settings.getSetting(STORE_SECTION, STORE_KEY));
        } catch (e) {
            raw = null;
        }
        return readSettings(raw, true);
    }

    // Kept in EffectCraft's settings folder: no undo step, the project isn't marked modified.
    function saveSettings(s) {
        try {
            app.settings.saveSetting(STORE_SECTION, STORE_KEY, JSON.stringify(s));
        } catch (e) {}
    }

    function anythingOn(s) {
        for (var i = 0; i < FIELDS.length; i++) if (s[FIELDS[i].key + "On"]) return true;
        return s.renderer !== "leave" || s.preserveFrameRate !== "leave" || s.preserveResolution !== "leave" || s.threeD !== "leave";
    }

    // ------------------------------------------------------------------ EffectCraft time

    // EffectCraft keeps time as whole ticks (254 016 000 000 a second). A comp's duration is
    // whole frames of its rate; these mirror the engine (crates/time) so a plan knows each new
    // duration before anything runs.
    var TICKS = 254016000000;
    var COMMON_RATES = [[24000, 1001], [24, 1], [25, 1], [30000, 1001], [30, 1], [48, 1], [50, 1],
        [60000, 1001], [60, 1], [120000, 1001], [120, 1]];

    function gcd(a, b) {
        while (b) {
            var t = a % b;
            a = b;
            b = t;
        }
        return a;
    }

    // The rate the engine makes of a frame rate in fps (FrameRate::from_f64).
    function engineRate(fps) {
        for (var i = 0; i < COMMON_RATES.length; i++) {
            var r = COMMON_RATES[i];
            if (Math.abs(r[0] / r[1] - fps) < 0.005) return {num: r[0], den: r[1]};
        }
        var n = Math.round(fps * 1000), g = gcd(n, 1000) || 1;
        return {num: n / g, den: 1000 / g};
    }

    function toTicks(seconds) {
        return Math.round(seconds * TICKS);
    }

    // Times read back as seconds can be off by a tick or two in very long comps: anything closer
    // than this (4 ns, far less than a frame) is the same time.
    var SAME_TICKS = 1000;

    function sameTicks(a, b) {
        return Math.abs(a - b) <= SAME_TICKS;
    }

    function frameTicks(rate) {
        return TICKS * rate.den / rate.num;
    }

    // The first tick of frame `f`.
    function tickOf(f, rate) {
        var ft = frameTicks(rate);
        if (ft === Math.floor(ft)) return f * ft;
        return Math.ceil(f * ft - 1e-6);
    }

    // A duration in seconds as comp.settings stores it, in ticks: the nearest whole frame of
    // `rate` (half way goes down), at least one frame.
    function snapToFrames(seconds, rate) {
        var t = toTicks(seconds), ft = frameTicks(rate);
        var f = Math.floor(t / ft);
        var a = tickOf(f, rate), b = tickOf(f + 1, rate);
        var snapped = (t - a) <= (b - t) ? a : b;
        var minimum = Math.floor(ft);
        return snapped < minimum ? minimum : snapped;
    }

    // `ticks` rounded up to whole frames of `rate` (at least one frame).
    function ceilToFrames(ticks, rate) {
        var f = Math.max(1, Math.ceil((ticks - SAME_TICKS) / frameTicks(rate)));
        return tickOf(f, rate);
    }

    // ------------------------------------------------------------------ reading the project

    // Read-only queries of the prelude (__get): unlike app.run with a `comp`, they never make a
    // comp active, so reading a comp that isn't open doesn't open it.
    var Q = {
        available: function () {
            return typeof __get === "function";
        },
        project: function () {
            return __get("project", {});
        },
        item: function (id) {
            return __get("item", {id: id});
        },
        layer: function (comp, id) {
            return __get("layer", {comp: comp, layer: id});
        },
        child: function (comp, layer, uid, key) {
            return __get("child", {comp: comp, layer: layer, uid: uid, key: key});
        },
        value: function (comp, layer, uid) {
            return __get("value", {comp: comp, layer: layer, uid: uid, time: 0, pre: true});
        }
    };

    function historyLists() {
        try {
            var h = app.run("edit.history", {});
            return {undo: h.undo || [], redo: h.redo || []};
        } catch (e) {
            // "nothing to undo"
            return {undo: [], redo: []};
        }
    }

    // Whether a text layer has per-character 3D (Text > Per-character 3D).
    function perCharOn(comp, layer) {
        var text = Q.child(comp, layer, 0, "text");
        if (!text) return false;
        var pc = Q.child(comp, layer, text.uid, "perChar3d");
        if (!pc) return false;
        return !!Q.value(comp, layer, pc.uid);
    }

    // The targets come from the engine's own answers (__get), never from the prelude's item
    // wrappers (app.project.activeItem / selection, instanceof CompItem): the prelude keeps one
    // wrapper per item id and forgets them only when this script itself replaces the project. A
    // docked panel outlives File > New / Open Project, where ids start again at 1, so its wrappers
    // can stand for items of the old project.
    function isComp(id) {
        try {
            return Q.item(id).kind === "comp";
        } catch (e) {
            return false;
        }
    }

    // The comp open in the Composition viewer.
    function activeCompId() {
        var id = Q.project().active;
        if (id === null || id === undefined || !isComp(id)) return null;
        // With no comp open, `active` is the one item selected in the Project panel.
        return Q.item(id).active ? id : null;
    }

    // The comps selected in the Project panel, in selection order.
    function selectedCompIds() {
        var sel = Q.project().selection || [], ids = [];
        for (var i = 0; i < sel.length; i++) if (isComp(sel[i])) ids.push(sel[i]);
        return ids;
    }

    // ------------------------------------------------------------------ planning

    // Footage types whose length doesn't limit a layer (stills are recognised by isStill).
    var NO_LENGTH_TYPES = {"Image": true, "3D Model": true, "Data": true};

    // What a layer is, for the 3D choice and the source-length rule.
    function layerType(L, ctx) {
        if (L.kind === "text") return "text";
        if (L.kind === "shape") return "shape";
        if (L.kind === "camera") return "camera";
        if (L.kind === "light") return "light";
        if (L.nullLayer) return "null";
        if (L.source === null || L.source === undefined) return "primitive";
        var it = ctx.item(L.source);
        if (it.kind === "comp") return "comp";
        if (it.kind === "solid") return "solid";
        if (it.typeName === "3D Model") return "model";
        if (!it.hasVideo) return "audio";
        return "footage";
    }

    // Layers with video that aren't cameras or lights. 3D model and primitive layers are always
    // 3D in EffectCraft and are left alone.
    var CAN_BE_3D = {footage: true, solid: true, "null": true, comp: true, text: true, shape: true};

    // Where a layer's source stops playing, in comp ticks, or null when it has no length of its
    // own (stills, solids, text, shapes, nulls...) or plays through Time Remapping.
    function sourceEnd(L, ctx) {
        if (L.timeRemapEnabled || L.source === null || L.source === undefined || !L.stretch) return null;
        var it = ctx.item(L.source), len, kind;
        if (it.kind === "comp") {
            var done = ctx.comps[L.source];
            len = done ? done.newDur : toTicks(it.duration);
            kind = "comp";
        } else if (it.kind === "footage" && !it.isStill && it.duration > 0 && !NO_LENGTH_TYPES[it.typeName]) {
            // Interpret Footage > Loop plays the footage that many times.
            len = toTicks(it.duration) * Math.max(it.loop || 1, 1);
            kind = "footage";
        } else {
            return null;
        }
        var start = toTicks(L.startTime);
        // A time-reversed layer (negative stretch) runs out where its source starts.
        return {end: Math.max(start, start + len * L.stretch / 100), kind: kind};
    }

    function sameNumber(a, b) {
        return Math.abs(a - b) <= 1e-9;
    }

    // The comp.settings parameters that differ from the comp's own (null when none do). Its new
    // frame rate and length are already worked out (c.newRate, c.newDur).
    function compChanges(c, s) {
        var info = c.info, p = {}, any = false;
        if (s.widthOn && info.width !== s.width) {
            p.width = s.width;
            any = true;
        }
        if (s.heightOn && info.height !== s.height) {
            p.height = s.height;
            any = true;
        }
        // Resize from the top-left corner: no layer moves.
        if (any) p.anchor = 0;
        if (s.pixelAspectOn && !sameNumber(info.pixelAspect, s.pixelAspect)) p.pixelAspect = s.pixelAspect;
        if (c.rateChanges) p.frameRate = s.frameRate;
        if (c.newDur !== c.oldDur) p.duration = c.newDur / TICKS;
        if (s.renderer !== "leave") {
            var cur = /advanced/i.test(String(info.renderer)) ? "advanced3D" : "classic3D";
            if (cur !== s.renderer) p.renderer = s.renderer;
        }
        if (s.preserveFrameRate !== "leave" && !!info.preserveNestedFrameRate !== (s.preserveFrameRate === "on")) {
            p.preserveFrameRate = s.preserveFrameRate === "on";
        }
        if (s.preserveResolution !== "leave" && !!info.preserveNestedResolution !== (s.preserveResolution === "on")) {
            p.preserveResolution = s.preserveResolution === "on";
        }
        if (s.shutterAngleOn && !sameNumber(info.shutterAngle, s.shutterAngle)) p.shutterAngle = s.shutterAngle;
        if (s.shutterPhaseOn && !sameNumber(info.shutterPhase, s.shutterPhase)) p.shutterPhase = s.shutterPhase;
        if (s.samplesOn && info.motionBlurSamplesPerFrame !== s.samples) p.motionBlurSamples = s.samples;
        if (s.adaptiveLimitOn && info.motionBlurAdaptiveSampleLimit !== s.adaptiveLimit) p.adaptiveSampleLimit = s.adaptiveLimit;
        for (var k in p) return p;
        return null;
    }

    // Each comp's new frame rate and length (ticks), parents before children.
    //   Duration on: a target gets the typed length (frames at its new rate), rounded as the engine
    //   rounds it; with Include nested comps on, every comp is also at least as long as each
    //   processed comp it is used in, rounded up to whole frames of its own rate, so its layer there
    //   can reach that comp's end. The longest of these wins.
    //   Duration off, Frame Rate changing: the length is rounded to whole frames of the new rate,
    //   as EffectCraft's Composition Settings does.
    function newLengths(list, s) {
        for (var i = 0; i < list.length; i++) {
            var c = list[i], fps = c.info.frameRate;
            c.oldRate = engineRate(fps);
            c.newRate = s.frameRateOn ? engineRate(s.frameRate) : c.oldRate;
            c.rateChanges = s.frameRateOn && !sameNumber(fps, c.newRate.num / c.newRate.den);
            c.oldDur = toTicks(c.info.duration);
            c.newDur = c.oldDur;
        }
        var order = parentsFirst(list);
        for (var j = 0; j < order.length; j++) {
            var d = order[j], len = null;
            if (s.durationOn) {
                if (d.top) {
                    len = snapToFrames(s.durationUnit === "frames" ? s.duration * d.newRate.den / d.newRate.num : s.duration, d.newRate);
                }
                for (var k = 0; k < d.parents.length; k++) {
                    var need = ceilToFrames(d.parents[k].newDur, d.newRate);
                    if (len === null || need > len) len = need;
                }
            } else if (d.rateChanges) {
                len = snapToFrames(d.oldDur / TICKS, d.newRate);
            }
            if (len !== null && !sameTicks(len, d.oldDur)) d.newDur = len;
        }
    }

    // The comps in an order where every comp comes after the processed comps it is used in
    // (they form a DAG; a cycle, which EffectCraft doesn't allow, would keep the visit order).
    function parentsFirst(list) {
        var waiting = {}, kids = {}, queue = [], out = [], seen = {};
        for (var i = 0; i < list.length; i++) {
            var c = list[i];
            waiting[c.id] = c.parents.length;
            for (var k = 0; k < c.parents.length; k++) (kids[c.parents[k].id] = kids[c.parents[k].id] || []).push(c);
        }
        for (var j = 0; j < list.length; j++) if (!waiting[list[j].id]) queue.push(list[j]);
        while (queue.length) {
            var d = queue.shift();
            out.push(d);
            seen[d.id] = true;
            var ks = kids[d.id] || [];
            for (var m = 0; m < ks.length; m++) if (--waiting[ks[m].id] === 0) queue.push(ks[m]);
        }
        for (var n = 0; n < list.length; n++) if (!seen[list[n].id]) out.push(list[n]);
        return out;
    }

    function threeDSteps(c, s, ctx) {
        var on = [], off = [];
        for (var i = 0; i < c.layers.length; i++) {
            var L = c.layers[i], type = layerType(L, ctx);
            if (!CAN_BE_3D[type]) continue;
            var three = !!L.threeDLayer, pc = type === "text" && perCharOn(c.id, L.id);
            var pcWanted = s.threeD === "3dPerChar" && type === "text";
            if (pc !== pcWanted) {
                // Switching per-character 3D on also makes the layer 3D.
                ctx.steps.push({command: "layer.enablePerChar3D", params: {comp: c.id, layer: L.id, enabled: pcWanted}});
                c.changed[L.id] = true;
                if (pcWanted) three = true;
            }
            if (s.threeD === "2d" && three) off.push(L.id);
            else if (s.threeD !== "2d" && !three) on.push(L.id);
        }
        if (on.length) ctx.steps.push({command: "layer.setSwitch", params: {comp: c.id, layers: on, "switch": "threeD", value: true}});
        if (off.length) ctx.steps.push({command: "layer.setSwitch", params: {comp: c.id, layers: off, "switch": "threeD", value: false}});
        for (var j = 0; j < on.length; j++) c.changed[on[j]] = true;
        for (var k = 0; k < off.length; k++) c.changed[off[k]] = true;
    }

    // The layers that ran to the old end keep running to the end (one layer.timing per target).
    function layerEndSteps(c, ctx) {
        var oldEnd = c.oldDur, newEnd = c.newDur, longer = newEnd > oldEnd;
        var half = frameTicks(c.oldRate) / 2, ft = frameTicks(c.newRate);
        var targets = [], byTarget = {};
        for (var i = 0; i < c.layers.length; i++) {
            var L = c.layers[i];
            var out = toTicks(L.outPoint), inn = toTicks(L.inPoint);
            // Ended before the old end (half a frame of rounding allowed), or starts at or after
            // the new end: left alone.
            if (out < oldEnd - half - 1 || inn >= newEnd) continue;
            var target = newEnd;
            if (longer) {
                var cap = newEnd, limited = null, src = sourceEnd(L, ctx);
                if (src) {
                    // The first comp frame at or after the source's end: out points are exclusive
                    // and a comp frame shows the source at the frame's start.
                    var limit = tickOf(Math.ceil((src.end - SAME_TICKS) / ft), c.newRate);
                    if (limit < cap - SAME_TICKS) {
                        cap = limit;
                        limited = src.kind;
                    }
                }
                // Never earlier than it was: a layer already past its source's end stays.
                target = Math.max(out, cap);
                // Its source kept it from reaching the new end.
                if (limited && target < newEnd - SAME_TICKS) ctx.stopped[limited]++;
            }
            if (sameTicks(target, out)) continue;
            var key = String(target);
            if (!byTarget[key]) {
                byTarget[key] = [];
                targets.push(target);
            }
            byTarget[key].push(L.id);
            c.changed[L.id] = true;
        }
        for (var t = 0; t < targets.length; t++) {
            ctx.steps.push({command: "layer.timing", params: {comp: c.id, layers: byTarget[String(targets[t])], out: targets[t] / TICKS}});
        }
    }

    // Everything Apply would do, as engine.batch steps, without changing anything:
    //   {message} when there is nothing to do, else {steps, comps, stopped}: comps in the order
    //   they are processed ({id, name, nested (used inside a processed comp and not picked by
    //   Apply to), settings (comp.settings parameters or null), layersChanged, oldDuration,
    //   newDuration}), stopped: {footage, comp} layer counts.
    function plan(input) {
        var s = readSettings(input, false);
        if (!anythingOn(s)) return {message: "Switch a setting on first."};
        if (!Q.available()) return {message: "CompSetter Kit can't read the project in this version of EffectCraft.", error: true};
        var tops;
        if (s.applyTo === "selection") {
            tops = selectedCompIds();
            if (!tops.length) return {message: "No composition is selected in the Project panel."};
        } else {
            var a = activeCompId();
            if (a === null) return {message: "Open a composition, or set Apply to to Comps selected in Project panel."};
            tops = [a];
        }

        var items = {};
        var ctx = {
            comps: {},
            order: [],
            steps: [],
            stopped: {footage: 0, comp: 0},
            item: function (id) {
                if (!items.hasOwnProperty(id)) items[id] = Q.item(id);
                return items[id];
            }
        };
        // Layers are read only when something needs them.
        var needLayers = s.includeNested || s.threeD !== "leave" || s.durationOn || s.frameRateOn;

        // 1. Which comps, each once, in the order first reached; the precomp layers between them.
        function visit(id, nested, depth) {
            var known = ctx.comps[id];
            if (known) return known;
            if (depth > 200) throw new Error("the comps are nested too deeply");
            var info = ctx.item(id);
            var c = {id: id, name: info.name, info: info, nested: nested, top: false, children: [], parents: [], layers: [], changed: {}};
            ctx.comps[id] = c;
            ctx.order.push(c);
            if (needLayers) {
                for (var i = 0; i < info.layers.length; i++) c.layers.push(Q.layer(id, info.layers[i]));
            }
            if (s.includeNested) {
                for (var j = 0; j < c.layers.length; j++) {
                    var L = c.layers[j];
                    if (L.source === null || L.source === undefined || layerType(L, ctx) !== "comp") continue;
                    var first = !ctx.comps[L.source];
                    var child = visit(L.source, true, depth + 1);
                    if (child.parents.indexOf(c) < 0) child.parents.push(c);
                    if (first) c.children.push(child);
                }
            }
            return c;
        }
        var roots = [];
        for (var t = 0; t < tops.length; t++) {
            var r = visit(tops[t], false, 0);
            r.top = true;
            if (!r.nested && roots.indexOf(r) < 0) roots.push(r);
        }

        // 2. New frame rates and lengths, then the settings to send.
        newLengths(ctx.order, s);
        for (var o = 0; o < ctx.order.length; o++) ctx.order[o].settings = compChanges(ctx.order[o], s);

        // 3. The commands, depth first: comp settings, 3D, nested comps, then layer ends (with
        // every comp's final length).
        function emit(c) {
            if (c.settings) {
                var p = {comp: c.id};
                for (var k in c.settings) p[k] = c.settings[k];
                ctx.steps.push({command: "comp.settings", params: p});
            }
            if (s.threeD !== "leave") threeDSteps(c, s, ctx);
            for (var i = 0; i < c.children.length; i++) emit(c.children[i]);
            if (c.newDur !== c.oldDur) layerEndSteps(c, ctx);
        }
        for (var e = 0; e < roots.length; e++) emit(roots[e]);

        var comps = [];
        for (var q = 0; q < ctx.order.length; q++) {
            var oc = ctx.order[q], n = 0;
            for (var lid in oc.changed) n++;
            // A comp Apply to picked is never counted as nested, whichever path reached it first.
            comps.push({id: oc.id, name: oc.name, nested: oc.nested && !oc.top, settings: oc.settings, layersChanged: n,
                oldDuration: oc.oldDur / TICKS, newDuration: oc.newDur / TICKS});
        }
        return {steps: ctx.steps, comps: comps, stopped: ctx.stopped};
    }

    // ------------------------------------------------------------------ applying

    function plural(n, one, many) {
        return n + " " + (n === 1 ? one : many);
    }

    function resultMessage(p) {
        var comps = 0, nested = 0, layers = 0;
        for (var i = 0; i < p.comps.length; i++) {
            var c = p.comps[i];
            if (!c.settings && !c.layersChanged) continue;
            comps++;
            if (c.nested) nested++;
            layers += c.layersChanged;
        }
        var m = "Changed " + plural(comps, "comp", "comps");
        if (nested) m += " (" + nested + " nested)";
        if (layers) m += " and " + plural(layers, "layer", "layers");
        m += ".";
        var stopped = p.stopped.footage + p.stopped.comp;
        if (stopped) {
            var what = !p.stopped.comp ? "footage" : !p.stopped.footage ? "nested comp" : "footage or nested comp";
            m += " " + (stopped === 1 ? "1 layer stops where its " : stopped + " layers stop where their ") + what + " ends.";
        }
        return {message: m, comps: comps, nested: nested, layers: layers, stopped: stopped};
    }

    // The engine's error, without the batch's wrapping.
    function reason(e) {
        var m = String(e && e.message !== undefined ? e.message : e);
        m = m.replace(/; the batch was rolled back \(nothing changed\)\s*$/, "");
        m = m.replace(/^step \d+ \([\w.]+\):\s*/, "");
        return m;
    }

    // Apply settings (a plain object like defaults(); missing values are the defaults) to the
    // comps they target. Returns {ok, changed, message, comps, nested, layers, stopped, steps}.
    function applySettings(input) {
        var p;
        try {
            p = plan(input);
        } catch (e) {
            return {ok: false, changed: false, message: "Nothing was changed: " + reason(e)};
        }
        return runPlan(p);
    }

    // Levels of Undo (Settings > General), or null when it can't be read. It comes back as a
    // number, or as text in some builds.
    function undoLevels() {
        try {
            var v = app.run("prefs.get", {key: "general.undoLevels"});
            var n = typeof v === "number" ? v : /^\s*\d+\s*$/.test(String(v)) ? parseInt(String(v), 10) : NaN;
            return isFinite(n) && n >= 1 ? Math.floor(n) : null;
        } catch (e) {
            return null;
        }
    }

    // The batch's steps, padded so that Apply keeps the user's undo history. Inside the undo
    // group every command is a step of its own until the group folds them into one, and the
    // history holds L (Levels of Undo): with k steps there, n commands push max(0, k + n - L) of
    // the user's steps out. A group of more than L commands is put back whole instead: the
    // history from before it, plus one step. So when n >= 2 commands would push steps out, pairs
    // of Hide Shy Layers switches on the first comp, which cancel out, make the batch longer than
    // L; then Apply costs no old step, or only the oldest when the history is full, like any
    // single edit. Without L, nothing is added.
    function paddedSteps(p, k) {
        var n = p.steps.length, levels = undoLevels();
        if (levels === null || n < 2 || k + n <= levels) return p.steps;
        var extra = levels + 1 - n;
        if (extra <= 0) return p.steps;
        if (extra % 2) extra++;
        var comp = p.comps[0].id, shy = !!Q.item(comp).hideShyLayers, steps = p.steps.slice();
        for (var i = 0; i < extra; i += 2) {
            steps.push({command: "comp.setSwitch", params: {comp: comp, "switch": "hideShy", value: !shy}});
            steps.push({command: "comp.setSwitch", params: {comp: comp, "switch": "hideShy", value: shy}});
        }
        return steps;
    }

    // Run what plan() worked out: all of it as one undo step, or nothing at all.
    function runPlan(p) {
        if (p.message) return {ok: !p.error, changed: false, message: p.message};
        if (!p.steps.length) {
            return {ok: true, changed: false, steps: 0,
                message: p.comps.length === 1 ? "The composition already has these settings." : "The comps already have these settings."};
        }
        var before = historyLists(), rev = Q.project().revision, failure = null;
        var steps = paddedSteps(p, before.undo.length), padding = steps.length - p.steps.length;
        app.beginUndoGroup(UNDO_NAME);
        try {
            app.run("engine.batch", {label: UNDO_NAME, steps: steps, atomic: true});
        } catch (e) {
            failure = e;
        } finally {
            app.endUndoGroup();
        }
        if (failure) {
            // The batch has restored the project; should a step of ours still be on top of the
            // old history (it never should), take it back too.
            var h = historyLists();
            if (Q.project().revision !== rev && h.undo[0] === UNDO_NAME && h.undo.join("\n") !== before.undo.join("\n")) {
                try {
                    app.run("edit.history", {steps: 1});
                } catch (e2) {}
            }
            return {ok: false, changed: false, steps: p.steps.length, padding: padding, message: "Nothing was changed: " + reason(failure)};
        }
        if (Q.project().revision === rev) {
            return {ok: true, changed: false, steps: p.steps.length, padding: padding,
                message: p.comps.length === 1 ? "The composition already has these settings." : "The comps already have these settings."};
        }
        var r = resultMessage(p);
        return {ok: true, changed: true, steps: p.steps.length, padding: padding, message: r.message, comps: r.comps, nested: r.nested,
            layers: r.layers, stopped: r.stopped};
    }

    // ------------------------------------------------------------------ text measure

    // EffectCraft draws ScriptUI text in Inter Regular 12 px but lays it out at 7 px a character,
    // so controls get their real width from Inter's advance widths (1/2048 em, " " to "~").
    var INTER = [576, 589, 954, 1297, 1314, 2011, 1319, 614, 747, 747, 1026, 1355, 590, 942, 590, 738,
        1292, 833, 1249, 1265, 1323, 1215, 1270, 1159, 1267, 1270, 590, 618, 1355, 1355, 1355, 1047,
        1978, 1413, 1340, 1496, 1478, 1231, 1209, 1528, 1522, 550, 1169, 1376, 1158, 1850, 1543, 1566,
        1308, 1566, 1318, 1314, 1322, 1524, 1413, 2018, 1397, 1390, 1288, 747, 738, 747, 965, 934,
        661, 1150, 1254, 1170, 1254, 1194, 758, 1256, 1211, 496, 496, 1124, 496, 1794, 1210, 1228,
        1254, 1254, 771, 1081, 670, 1211, 1151, 1676, 1118, 1151, 1131, 873, 681, 873, 1355];

    function textWidth(s) {
        var w = 0;
        s = String(s);
        for (var i = 0; i < s.length; i++) {
            var c = s.charCodeAt(i);
            w += c >= 32 && c <= 126 ? INTER[c - 32] : 1300;
        }
        return w * 12 / 2048;
    }

    // Lines no wider than `width` px, broken at spaces ("\n" starts a new line).
    function wrapLines(text, width) {
        var out = [], paras = String(text).split("\n");
        for (var p = 0; p < paras.length; p++) {
            var words = paras[p].split(" "), line = "";
            for (var w = 0; w < words.length; w++) {
                var next = line ? line + " " + words[w] : words[w];
                if (line && textWidth(next) > width) {
                    out.push(line);
                    line = words[w];
                } else {
                    line = next;
                }
            }
            out.push(line);
        }
        return out;
    }

    // ------------------------------------------------------------------ help

    var HELP = [
        "CompSetter Kit changes the settings of several compositions in one go.",
        "",
        "Only the settings you switch on are changed. Tick a setting's checkbox to type its value; a menu left on Leave as is keeps that setting as it is in every comp.",
        "",
        "Apply to picks the comps. Active composition is the comp open in the Composition viewer. Comps selected in Project panel takes every comp selected there, in the order they were selected; footage, solids and folders in the selection are skipped, and so are comps inside a selected folder.",
        "",
        "Include nested comps also changes every comp used as a layer inside them, at any depth. Each comp is changed once per Apply, even when it is used several times.",
        "",
        "Width and Height resize from the top-left corner: layers stay where they are. Frame Rate changes the frame rate only: layers and keyframes keep their times, and each comp's length is rounded to whole frames of the new rate.",
        "",
        "A Duration in frames is counted at the frame rate the comp will have: the Frame Rate value when it is switched on, else the comp's own. With Include nested comps on, every nested comp becomes as long as the comp it is used in, whatever its own frame rate (rounded up to its own whole frames, so it never ends first). A comp used in several places gets the longest length any of them needs.",
        "",
        "A nested comp gets the length of the comp it is used in, not the length its layer there would need: a nested comp layer that starts before 0 or is sped up still ends before the new end. For example, one starting at -2 s in a comp made 10 s long ends at 8 s, and the comp's last 2 s stay empty. The status line counts such layers.",
        "",
        "When a comp's length changes, the layers that ran to its old end keep running to its end. A shorter comp cuts them at the new end. A longer comp lengthens them to the new end, but a footage or nested comp layer only as far as its source lasts: it ends on the first comp frame at or after the end of its source (unless Time Remapping is on). The status line says how many stop short. A longer comp never makes a layer shorter: one that already runs past the new end keeps its out point. Layers that ended earlier, or start after the new end, are left alone.",
        "",
        "3D sets the 3D switch of every layer that can be 3D in the comps processed; cameras, lights, audio-only layers and 3D model layers are left alone. 2D makes them 2D. 3D makes them 3D, with per-character 3D switched off. 3D + per-character text makes them 3D and gives every text layer per-character 3D.",
        "",
        "Locked layers are changed too, and stay locked.",
        "",
        "Apply is one undo step: Edit > Undo CompSetter Kit takes it all back. If anything goes wrong part-way, nothing is changed. When the comps already have these settings, nothing is added to the undo history.",
        "",
        "A value out of range is pulled to the nearest limit, and text that isn't a number puts the last value back. Your settings are remembered.",
        "",
        "CompSetter Kit " + VERSION + " for EffectCraft"
    ].join("\n");

    var helpWindow = null;

    function openHelp() {
        if (helpWindow) return;
        var w = new Window("palette", NAME + " Help", undefined, {resizeable: false});
        w.orientation = "column";
        w.alignChildren = ["fill", "top"];
        w.spacing = 8;
        var list = w.add("listbox", undefined, wrapLines(HELP, 410));
        list.name = "helpText";
        list.preferredSize = [450, 300];
        var close = w.add("button", undefined, "Close");
        close.name = "helpClose";
        close.alignment = ["right", "top"];
        close.preferredSize = [80, 25];
        close.onClick = function () {
            w.close();
        };
        w.onClose = function () {
            helpWindow = null;
        };
        // Each control EffectCraft draws itself makes a floating window 4 px taller.
        w.margins = [12, 12, 12, 12 - 4 * 2];
        helpWindow = w;
        w.show();
    }

    // ------------------------------------------------------------------ the panel

    var FORM_W = 302;           // 326 px palette content less 12 px margins
    var FIELD_W = 60;           // about 7 digits
    var ROW_SPACING = 6;
    // EffectCraft draws a native checkbox or radio button as a 14 px box, a 4 px gap and the label,
    // centred in the control (in min(7 px a character + 26, its width)). Exactly that wide, the box
    // sits at the control's left edge; any wider, a short label's box drifts right.
    var TOGGLE_EXTRA = 18;
    // The thin rules: sRGB 0.424 at 25% (given as linear light) gives a separator shade in both
    // the dark and the light theme.
    var RULE_COLOR = [0.1503, 0.1503, 0.1503, 0.25];
    var NATIVE = {button: true, iconbutton: true, edittext: true, dropdownlist: true, listbox: true, checkbox: true,
        radiobutton: true, slider: true, scrollbar: true};

    function px(w) {
        return Math.ceil(w);
    }

    // Visible controls EffectCraft draws itself (each adds 4 px to a floating window).
    function nativeCount(c) {
        if (c.visible === false) return 0;
        var n = NATIVE[c.type] && typeof c.onDraw !== "function" ? 1 : 0;
        for (var i = 0; i < (c.children || []).length; i++) n += nativeCount(c.children[i]);
        return n;
    }

    // Build the panel in `root` (a docked Panel or a palette Window). Returns its controls.
    function buildUI(root, docked) {
        var s = loadSettings();
        var ui = {root: root, fields: {}, choices: {}};

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

        function group(parent, orientation, align, spacing) {
            var g = parent.add("group");
            g.orientation = orientation;
            g.alignChildren = align;
            g.spacing = spacing;
            g.margins = 0;
            return g;
        }
        function label(parent, text, width) {
            var t = parent.add("statictext", undefined, text);
            t.preferredSize = [width || px(textWidth(text)) + 4, 16];
            return t;
        }
        function rule(parent) {
            var g = parent.add("group");
            g.alignment = ["fill", "top"];
            g.preferredSize = [1, 9];
            g.onDraw = function () {
                var gr = this.graphics;
                gr.newPath();
                // Drawn long: EffectCraft clips it to the group's width, docked panels included.
                gr.rectPath(0, 4, 4000, 1);
                gr.fillPath(gr.newBrush(gr.BrushType.SOLID_COLOR, RULE_COLOR));
            };
            return g;
        }
        // The width EffectCraft draws a checkbox or radio button with this label at.
        function exactToggle(text) {
            return textWidth(text) + TOGGLE_EXTRA;
        }
        // The widest of them, rounded up to whole pixels (a cell that keeps what follows on whole
        // pixels and lines a column of rows up).
        function toggleWidth(labels) {
            var w = 0;
            for (var i = 0; i < labels.length; i++) w = Math.max(w, exactToggle(labels[i]));
            return px(w);
        }
        // A checkbox at its exact width, alone in a cell `cellWidth` wide.
        function checkboxCell(parent, text, cellWidth) {
            var cell = group(parent, "row", ["left", "center"], 0);
            cell.preferredSize = [Math.max(cellWidth || 0, toggleWidth([text])), 22];
            var c = cell.add("checkbox", undefined, text);
            c.preferredSize = [exactToggle(text), 22];
            return c;
        }
        function persist() {
            saveSettings(collect());
        }
        function durationUnit() {
            return ui.secondsRadio && ui.secondsRadio.value ? "seconds" : "frames";
        }
        function syncEnabled() {
            for (var k in ui.fields) {
                var f = ui.fields[k], on = !!f.check.value;
                f.edit.enabled = on;
                if (f.unit) f.unit.enabled = on;
            }
            var d = !!ui.fields.duration.check.value;
            ui.framesRadio.enabled = d;
            ui.secondsRadio.enabled = d;
        }

        // A row: [checkbox in a cell as wide as the block's widest] [field] [unit].
        function fieldRow(parent, def, cellWidth) {
            var g = group(parent, "row", ["left", "center"], ROW_SPACING);
            var check = checkboxCell(g, def.label, cellWidth);
            check.name = def.key + "On";
            check.value = !!s[def.key + "On"];
            check.helpTip = def.tip;
            var edit = g.add("edittext", undefined, formatValue(def.key, s[def.key], s.durationUnit));
            edit.name = def.key;
            edit.preferredSize = [FIELD_W, 22];
            edit.helpTip = def.tip;
            var unit = def.unit ? label(g, def.unit) : null;
            var f = {def: def, check: check, edit: edit, unit: unit, last: s[def.key], row: g};
            check.onClick = function () {
                syncEnabled();
                persist();
            };
            // The text reaches the script when the field loses focus.
            edit.onChange = function () {
                var n = parseNumber(edit.text);
                f.last = n === null ? f.last : cleanValue(def.key, n, durationUnit());
                edit.text = formatValue(def.key, f.last, durationUnit());
                persist();
            };
            ui.fields[def.key] = f;
            return f;
        }

        function choice(parent, key, width) {
            var list = CHOICES[key], items = [], sel = 0;
            for (var i = 0; i < list.length; i++) {
                items.push(list[i][1]);
                if (list[i][0] === s[key]) sel = i;
            }
            var dd = parent.add("dropdownlist", undefined, items);
            dd.name = key;
            // EffectCraft draws a menu as wide as its widest item needs (about 42 px more).
            dd.preferredSize = [width, 22];
            dd.selection = sel;
            dd.onChange = function () {
                if (!dd.selection) dd.selection = 0;
                persist();
            };
            ui.choices[key] = dd;
            return dd;
        }
        function choiceWidth(key) {
            var w = 0, list = CHOICES[key];
            for (var i = 0; i < list.length; i++) w = Math.max(w, textWidth(list[i][1]));
            return px(w) + 42;
        }

        // 1. Header: the name, and "?" at the far right.
        var head = group(root, "stack", ["left", "center"], 0);
        head.preferredSize = [FORM_W, 22];
        var title = label(head, NAME);
        title.alignment = ["left", "center"];
        var help = head.add("button", undefined, "?");
        help.name = "help";
        help.alignment = ["right", "center"];
        help.preferredSize = [24, 22];
        help.helpTip = "How CompSetter Kit works";
        help.onClick = openHelp;
        ui.help = help;

        // 2. Frame settings.
        var frameLabels = [], blurLabels = [];
        for (var i = 0; i < FIELDS.length; i++) (FIELDS[i].block === "frame" ? frameLabels : blurLabels).push(FIELDS[i].label);
        var frameW = toggleWidth(frameLabels), blurW = toggleWidth(blurLabels);
        var frame = group(root, "column", ["left", "top"], 4);
        for (var j = 0; j < FIELDS.length; j++) if (FIELDS[j].block === "frame") fieldRow(frame, FIELDS[j], frameW);
        // The unit radio buttons share a group (ScriptUI pairs radio buttons by their parent); the
        // spacing keeps the second on whole pixels.
        var units = group(ui.fields.duration.row, "row", ["left", "center"], 0);
        ui.framesRadio = units.add("radiobutton", undefined, "frames");
        ui.framesRadio.name = "durationFrames";
        ui.framesRadio.preferredSize = [exactToggle("frames"), 22];
        units.spacing = ROW_SPACING + px(exactToggle("frames")) - exactToggle("frames");
        ui.secondsRadio = units.add("radiobutton", undefined, "seconds");
        ui.secondsRadio.name = "durationSeconds";
        ui.secondsRadio.preferredSize = [exactToggle("seconds"), 22];
        ui.framesRadio.value = s.durationUnit !== "seconds";
        ui.secondsRadio.value = s.durationUnit === "seconds";
        ui.framesRadio.helpTip = "Read the Duration as frames (at the comp's new frame rate).";
        ui.secondsRadio.helpTip = "Read the Duration as seconds.";
        // The unit only says how the number is read; frames are whole.
        ui.framesRadio.onClick = function () {
            var d = ui.fields.duration;
            d.last = cleanValue("duration", d.last, "frames");
            d.edit.text = formatValue("duration", d.last, "frames");
            persist();
        };
        ui.secondsRadio.onClick = function () {
            persist();
        };

        rule(root);

        // 3. Advanced: label and menu, the long Preserve labels on their own line.
        var dropLabelW = px(Math.max(textWidth("Renderer:"), textWidth("3D:"), textWidth("Apply to:"))) + 4;
        var adv = group(root, "column", ["left", "top"], 4);
        var rRow = group(adv, "row", ["left", "center"], ROW_SPACING);
        label(rRow, "Renderer:", dropLabelW);
        choice(rRow, "renderer", choiceWidth("renderer"));
        var pfrText = "Preserve frame rate when nested or in render queue";
        label(adv, pfrText);
        var pfrRow = group(adv, "row", ["left", "center"], 0);
        pfrRow.margins = [dropLabelW + ROW_SPACING, 0, 0, 0];
        choice(pfrRow, "preserveFrameRate", choiceWidth("preserveFrameRate"));
        var presText = "Preserve resolution when nested";
        label(adv, presText);
        var presRow = group(adv, "row", ["left", "center"], 0);
        presRow.margins = [dropLabelW + ROW_SPACING, 0, 0, 0];
        choice(presRow, "preserveResolution", choiceWidth("preserveResolution"));

        // 4. Motion blur.
        var blur = group(root, "column", ["left", "top"], 4);
        blur.margins = [0, 4, 0, 0];
        for (var b = 0; b < FIELDS.length; b++) if (FIELDS[b].block === "blur") fieldRow(blur, FIELDS[b], blurW);

        rule(root);

        // 5. Layers.
        var layers = group(root, "column", ["left", "top"], 4);
        label(layers, "All layers in these comps:");
        var tRow = group(layers, "row", ["left", "center"], ROW_SPACING);
        label(tRow, "3D:", dropLabelW);
        choice(tRow, "threeD", choiceWidth("threeD"));

        rule(root);

        // 6. Scope and Apply.
        var scope = group(root, "column", ["left", "top"], 4);
        var aRow = group(scope, "row", ["left", "center"], ROW_SPACING);
        label(aRow, "Apply to:", dropLabelW);
        choice(aRow, "applyTo", choiceWidth("applyTo"));
        var act = group(scope, "stack", ["left", "center"], 0);
        act.alignment = ["fill", "top"];
        ui.nested = checkboxCell(act, "Include nested comps");
        ui.nested.name = "includeNested";
        ui.nested.parent.alignment = ["left", "center"];
        ui.nested.value = !!s.includeNested;
        ui.nested.helpTip = "Also change every comp used as a layer in them, at any depth.";
        ui.nested.onClick = persist;
        ui.apply = act.add("button", undefined, "Apply");
        ui.apply.name = "apply";
        ui.apply.alignment = ["right", "center"];
        ui.apply.preferredSize = [80, 25];

        // 7. Status line: takes no room until there is a message.
        ui.status = root.add("statictext", undefined, "");
        ui.status.name = "status";
        ui.status.alignment = ["fill", "top"];
        ui.status.preferredSize = [40, -1];
        ui.status.visible = false;

        function collect() {
            var out = defaults();
            for (var k in ui.fields) {
                var f = ui.fields[k];
                out[k + "On"] = !!f.check.value;
                out[k] = f.last;
            }
            out.durationUnit = durationUnit();
            for (var c in ui.choices) {
                var sel = ui.choices[c].selection;
                out[c] = CHOICES[c][sel ? sel.index : 0][0];
            }
            out.includeNested = !!ui.nested.value;
            return out;
        }
        // The status line's width: the panel's, as far as this script knows it (its own layout,
        // or bounds it was given; EffectCraft never tells a docked panel's script the dock's
        // size), never less than the form's.
        function statusWidth() {
            var w = FORM_W;
            try {
                root.layout.layout(true);
                var b = root.bounds, m = root.margins;
                w = Math.max(w, b[2] - b[0] - m[0] - m[2]);
            } catch (e) {}
            return w - 4;
        }
        function showStatus(text) {
            ui.status.text = wrapLines(text, statusWidth()).join("\n");
            ui.status.visible = true;
        }

        ui.apply.onClick = function () {
            var settings = collect();
            saveSettings(settings);
            var r;
            try {
                r = applySettings(settings);
            } catch (e) {
                r = {ok: false, message: "Nothing was changed: " + reason(e)};
            }
            showStatus(r.message);
            if (!r.ok) alert(NAME + ": " + r.message);
            ui.lastResult = r;
        };

        syncEnabled();
        ui.collect = collect;
        ui.showStatus = showStatus;
        if (!docked) {
            // Take back the 4 px each EffectCraft-drawn control adds to a floating window.
            root.margins = [12, 10, 12, 12 - 4 * nativeCount(root)];
        }
        return ui;
    }

    // ------------------------------------------------------------------ start

    var api = {
        name: NAME,
        version: VERSION,
        undoName: UNDO_NAME,
        storeSection: STORE_SECTION,
        storeKey: STORE_KEY,
        fields: FIELDS,
        choices: CHOICES,
        defaults: defaults,
        readSettings: readSettings,
        loadSettings: loadSettings,
        saveSettings: saveSettings,
        parseNumber: parseNumber,
        cleanValue: cleanValue,
        formatValue: formatValue,
        engineRate: engineRate,
        snapToFrames: snapToFrames,
        ticksPerSecond: TICKS,
        plan: plan,
        runPlan: runPlan,
        apply: applySettings,
        buildUI: buildUI,
        openHelp: openHelp,
        helpLines: function () {
            return wrapLines(HELP, 410);
        },
        textWidth: textWidth,
        wrapLines: wrapLines
    };
    try {
        $.global.CompSetterKit = api;
    } catch (e) {}

    if (thisObj instanceof Panel) {
        buildUI(thisObj, true);
        thisObj.layout.layout(true);
    } else if (!($.global.CompSetterKit_noUI === true)) {
        var win = new Window("palette", NAME, undefined, {resizeable: false});
        buildUI(win, false);
        win.center();
        win.show();
    }
})(this);
