// =====================================================================================
// Motion Kit 1.0 for EffectCraft
//
// Keyframe easing, anchor points, elastic / bounce overshoots, key cloning, sequencing
// and shape-layer tools in one dockable panel.
//
// A clean-room take on the workflow of Motion Tools 2 (an After Effects script): written
// from what that panel does, for EffectCraft's own scripting API. No code from the
// original is used.
//
// Install as a panel: File > Scripts > Install ScriptUI Panel..., pick this file, then
// open it from the bottom of the Window menu. Or run it once as a floating window:
// File > Scripts > Run Script File...
//
// Hover any button for its modifier keys (Alt / Shift + click), or press About.
// =====================================================================================

(function (thisObj) {
  var MK = { name: "Motion Kit", version: "1.0.0", section: "MotionKit" };

  // ----------------------------------------------------------------------------- errors

  // A message for the user (wrong selection and so on), not a bug.
  function MKError(msg) { this.message = msg; }
  function fail(msg) { throw new MKError(msg); }
  function errText(e) {
    if (e instanceof MKError) return e.message;
    return e && e.message ? e.message : String(e);
  }

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

  function getSetting(key, def) {
    try {
      if (app.settings.haveSetting(MK.section, key)) return app.settings.getSetting(MK.section, key);
    } catch (e) {}
    return def;
  }
  function saveSetting(key, v) {
    try { app.settings.saveSetting(MK.section, key, String(v)); } catch (e) {}
  }
  function intSetting(key, def, lo, hi) {
    var v = parseInt(getSetting(key, def), 10);
    if (isNaN(v)) v = def;
    return Math.max(lo, Math.min(hi, v));
  }

  // ----------------------------------------------------------------------------- status

  var ui = {};

  // Cut a message into at most three lines of about `width` characters.
  function wrapText(s, width) {
    var words = String(s).split(/\s+/), lines = [], cur = "";
    for (var i = 0; i < words.length; i++) {
      var w = words[i];
      if (!w) continue;
      if (cur && (cur + " " + w).length > width) {
        lines.push(cur);
        cur = w;
      } else cur = cur ? cur + " " + w : w;
    }
    if (cur) lines.push(cur);
    if (lines.length > 3) {
      lines = lines.slice(0, 3);
      lines[2] = lines[2].replace(/\s*\S*$/, "") + "...";
    }
    return lines.join("\n");
  }

  function status(msg) {
    if (ui.status) ui.status.text = wrapText(msg, 44);
    try { writeLn(MK.name + ": " + msg); } catch (e) {}
  }

  // Run one tool as a single undo step on the active composition. `fn(comp)` returns the
  // message to show.
  var busy = false;
  function act(name, fn) {
    if (busy) return;
    var comp = app.project.activeItem;
    if (!(comp instanceof CompItem)) {
      status("Open a composition first.");
      return "Open a composition first.";
    }
    busy = true;
    var msg = "";
    app.beginUndoGroup(MK.name + ": " + name);
    try {
      msg = fn(comp) || "";
    } catch (e) {
      msg = e instanceof MKError ? e.message : name + ": " + errText(e);
    } finally {
      try { app.endUndoGroup(); } catch (e2) {}
      busy = false;
    }
    status(msg || name + ": done.");
    return msg || name + ": done.";
  }

  // Modifier keys of a click (ScriptUI mouse event).
  function mods(e) {
    return { alt: !!(e && e.altKey), shift: !!(e && e.shiftKey), ctrl: !!(e && (e.ctrlKey || e.metaKey)) };
  }

  // ----------------------------------------------------------------------------- small helpers

  function clampInt(v, lo, hi) {
    v = Math.round(Number(v));
    if (isNaN(v)) v = lo;
    return Math.max(lo, Math.min(hi, v));
  }
  function plural(n, word, many) { return n + " " + (n === 1 ? word : many || word + "s"); }
  function quote(s) { return JSON.stringify(String(s)); }

  // a + b for numbers and arrays (b may be shorter: missing parts add 0).
  function addv(a, b) {
    if (!(a instanceof Array)) return a + (b instanceof Array ? b[0] : b);
    var out = [];
    for (var i = 0; i < a.length; i++) out.push(a[i] + (b instanceof Array ? (i < b.length ? b[i] : 0) : (i === 0 ? b : 0)));
    return out;
  }
  function slice(v, n) {
    if (!(v instanceof Array)) return v;
    var out = [];
    for (var i = 0; i < n; i++) out.push(i < v.length ? v[i] : 0);
    return out;
  }

  function layerById(comp, id) {
    try { return __layer(comp.id, id); } catch (e) {}
    for (var i = 1; i <= comp.numLayers; i++) if (comp.layer(i).id === id) return comp.layer(i);
    return null;
  }

  // Every layer of the comp in one call: [{id, index, name, in, out, start, stretch, parent, label}].
  function compLayers(comp) { return app.run("comp.info", { comp: comp.id }).layers || []; }

  function uniqueLayerName(comp, base) {
    var names = {}, list = compLayers(comp);
    for (var i = 0; i < list.length; i++) names[list[i].name] = true;
    if (!names[base]) return base;
    for (var n = 2; n < 10000; n++) if (!names[base + " " + n]) return base + " " + n;
    return base;
  }

  // A layer's property tree (values at `t`), from the engine in one call. layer.tree stops at
  // 16 levels by default (a shape group takes two); nodes cut off are marked `truncated`.
  // Its values are pre-expression: shape and mask properties driven by an expression get the
  // value the renderer uses.
  function layerTree(comp, L, t) {
    var tree = app.run("layer.tree", { comp: comp.id, layer: L.id, time: t, depth: 64 });
    var walk = function (node, depth) {
      if (!node || depth > 70) return;
      if (node.expression && node.uid !== undefined && !node.children) {
        try {
          var v = __get("value", { comp: comp.id, layer: L.id, uid: node.uid, time: t, pre: false });
          node.value = v && v.vertices ? { t: "Path", v: v } : v;
        } catch (e) {}
      }
      var c = node.children || [];
      for (var i = 0; i < c.length; i++) walk(c[i], depth + 1);
    };
    var top = tree && tree.properties && tree.properties.children ? tree.properties.children : [];
    for (var i = 0; i < top.length; i++) if (top[i].match === "contents" || top[i].match === "masks") walk(top[i], 0);
    return tree;
  }
  function childByMatch(node, match) {
    var c = node && node.children;
    if (!c) return null;
    for (var i = 0; i < c.length; i++) if (c[i].match === match) return c[i];
    return null;
  }
  function val(node, match, def) {
    var c = childByMatch(node, match);
    return c && c.value !== undefined && c.value !== null ? c.value : def;
  }
  function findNode(node, uid, depth) {
    if (!node || (depth || 0) > 40) return null;
    if (node.uid === uid) return node;
    var c = node.children || [];
    for (var i = 0; i < c.length; i++) {
      var r = findNode(c[i], uid, (depth || 0) + 1);
      if (r) return r;
    }
    return null;
  }

  function deselectAll() { app.run("keys.deselectAll", {}); }

  // Remove shape items (by uid) from a layer's Contents in one edit.
  function removeItems(comp, L, uids) {
    if (!uids.length) return;
    deselectAll();
    for (var i = 0; i < uids.length; i++) app.run("prop.select", { comp: comp.id, layer: L.id, prop: uids[i], add: true });
    app.run("edit.clear", { comp: comp.id });
  }

  function selectLayers(comp, layers) {
    var ids = [];
    for (var i = 0; i < layers.length; i++) ids.push(layers[i].id);
    if (ids.length) app.run("layer.select", { comp: comp.id, layers: ids });
  }

  function isCameraOrLight(L) { return L instanceof CameraLayer || L instanceof LightLayer; }

  // ----------------------------------------------------------------------------- 2D / 3D math

  // 2D affine [a, b, c, d, e, f]: x' = a x + c y + e, y' = b x + d y + f.
  var I2 = [1, 0, 0, 1, 0, 0];
  function aMul(m, n) {
    return [
      m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1],
      m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3],
      m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]
    ];
  }
  function aApply(m, p) { return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]]; }
  function aLinear(m, p) { return [m[0] * p[0] + m[2] * p[1], m[1] * p[0] + m[3] * p[1]]; }
  function aTranslate(x, y) { return [1, 0, 0, 1, x, y]; }
  function aRotate(deg) {
    var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
    return [c, s, -s, c, 0, 0];
  }
  function aScale(x, y) { return [x, 0, 0, y, 0, 0]; }
  // EffectCraft's shape skew: rotate to the axis, shear x by tan(skew), rotate back.
  function aSkew(deg, axisDeg) {
    var k = Math.tan(deg * Math.PI / 180);
    return aMul(aRotate(axisDeg), aMul([1, 0, k, 1, 0, 0], aRotate(-axisDeg)));
  }
  // A shape group's transform (Position * Rotation * Skew * Scale * -Anchor), from tree values.
  function groupMatrix(tr) {
    var a = val(tr, "anchor", [0, 0]), p = val(tr, "position", [0, 0]), s = val(tr, "scale", [100, 100]);
    var r = val(tr, "rotation", 0), sk = val(tr, "skew", 0), ax = val(tr, "skewAxis", 0);
    return aMul(aTranslate(p[0], p[1]), aMul(aRotate(r), aMul(aSkew(-sk, ax), aMul(aScale(s[0] / 100, s[1] / 100), aTranslate(-a[0], -a[1])))));
  }

  // 3x3 matrices, row-major.
  function mMul(a, b) {
    var o = [];
    for (var r = 0; r < 3; r++)
      for (var c = 0; c < 3; c++) o.push(a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c]);
    return o;
  }
  function mApply(m, v) {
    var z = v.length > 2 ? v[2] : 0;
    return [m[0] * v[0] + m[1] * v[1] + m[2] * z, m[3] * v[0] + m[4] * v[1] + m[5] * z, m[6] * v[0] + m[7] * v[1] + m[8] * z];
  }
  function mRotX(d) { var r = d * Math.PI / 180, c = Math.cos(r), s = Math.sin(r); return [1, 0, 0, 0, c, -s, 0, s, c]; }
  function mRotY(d) { var r = d * Math.PI / 180, c = Math.cos(r), s = Math.sin(r); return [c, 0, s, 0, 1, 0, -s, 0, c]; }
  function mRotZ(d) { var r = d * Math.PI / 180, c = Math.cos(r), s = Math.sin(r); return [c, -s, 0, s, c, 0, 0, 0, 1]; }
  function mScale(x, y, z) { return [x, 0, 0, 0, y, 0, 0, 0, z]; }

  // Bounding box with exact cubic Bezier extremes.
  function Box() { this.x0 = Infinity; this.y0 = Infinity; this.x1 = -Infinity; this.y1 = -Infinity; }
  Box.prototype.add = function (p) {
    if (!isFinite(p[0]) || !isFinite(p[1])) return;
    if (p[0] < this.x0) this.x0 = p[0];
    if (p[0] > this.x1) this.x1 = p[0];
    if (p[1] < this.y0) this.y0 = p[1];
    if (p[1] > this.y1) this.y1 = p[1];
  };
  Box.prototype.addBox = function (b) {
    if (b.empty()) return;
    this.add([b.x0, b.y0]);
    this.add([b.x1, b.y1]);
  };
  Box.prototype.empty = function () { return !(this.x1 >= this.x0 && this.y1 >= this.y0); };
  Box.prototype.addCubic = function (p0, p1, p2, p3) {
    this.add(p0);
    this.add(p3);
    for (var ax = 0; ax < 2; ax++) {
      var a = -p0[ax] + 3 * p1[ax] - 3 * p2[ax] + p3[ax];
      var b = 2 * (p0[ax] - 2 * p1[ax] + p2[ax]);
      var c = p1[ax] - p0[ax];
      var ts = [];
      if (Math.abs(a) < 1e-12) {
        if (Math.abs(b) > 1e-12) ts.push(-c / b);
      } else {
        var disc = b * b - 4 * a * c;
        if (disc >= 0) {
          var sq = Math.sqrt(disc);
          ts.push((-b + sq) / (2 * a), (-b - sq) / (2 * a));
        }
      }
      for (var i = 0; i < ts.length; i++) {
        var t = ts[i];
        if (!(t > 0 && t < 1)) continue;
        var u = 1 - t;
        this.add([
          u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
          u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]
        ]);
      }
    }
  };
  Box.prototype.rect = function () { return { left: this.x0, top: this.y0, width: this.x1 - this.x0, height: this.y1 - this.y0 }; };
  Box.prototype.contains = function (b, tol) {
    return this.x0 <= b.x0 + tol && this.y0 <= b.y0 + tol && this.x1 >= b.x1 - tol && this.y1 >= b.y1 - tol;
  };

  // ----------------------------------------------------------------------------- shape geometry

  var PRIMITIVES = { rect: 1, ellipse: 1, star: 1, polygon: 1, path: 1 };
  // Path operators whose result can reach past the paths they act on.
  var WARPING = { repeater: 1, offset: 1, zigzag: 1, pucker: 1, twist: 1, wiggle: 1 };

  function pathValue(v) {
    if (!v) return null;
    if (v.t === "Path" && v.v) return v.v;
    if (v.vertices) return v;
    return null;
  }
  function pathBounds(pv, m, box) {
    var vs = pv.vertices || [], ins = pv.in_tangents || pv.inTangents || [], outs = pv.out_tangents || pv.outTangents || [];
    var n = vs.length;
    if (!n) return;
    if (n === 1) {
      box.add(aApply(m, vs[0]));
      return;
    }
    var segs = pv.closed ? n : n - 1;
    for (var i = 0; i < segs; i++) {
      var j = (i + 1) % n, a = vs[i], b = vs[j], o = outs[i] || [0, 0], q = ins[j] || [0, 0];
      box.addCubic(aApply(m, a), aApply(m, [a[0] + o[0], a[1] + o[1]]), aApply(m, [b[0] + q[0], b[1] + q[1]]), aApply(m, b));
    }
  }
  function rectCorners(item) {
    var s = val(item, "size", [100, 100]), p = val(item, "position", [0, 0]);
    var w = Math.abs(s[0]) / 2, h = Math.abs(s[1]) / 2;
    return [[p[0] - w, p[1] - h], [p[0] + w, p[1] - h], [p[0] + w, p[1] + h], [p[0] - w, p[1] + h]];
  }
  function ellipseBounds(item, m, box) {
    var s = val(item, "size", [100, 100]), p = val(item, "position", [0, 0]);
    var rx = Math.abs(s[0]) / 2, ry = Math.abs(s[1]) / 2, k = 0.5522847498, cx = p[0], cy = p[1];
    var q = [
      [[cx + rx, cy], [cx + rx, cy + k * ry], [cx + k * rx, cy + ry], [cx, cy + ry]],
      [[cx, cy + ry], [cx - k * rx, cy + ry], [cx - rx, cy + k * ry], [cx - rx, cy]],
      [[cx - rx, cy], [cx - rx, cy - k * ry], [cx - k * rx, cy - ry], [cx, cy - ry]],
      [[cx, cy - ry], [cx + k * rx, cy - ry], [cx + rx, cy - k * ry], [cx + rx, cy]]
    ];
    for (var i = 0; i < 4; i++) box.addCubic(aApply(m, q[i][0]), aApply(m, q[i][1]), aApply(m, q[i][2]), aApply(m, q[i][3]));
  }
  function starBounds(item, m, box) {
    var star = item.match === "star" && Number(val(item, "type", 0)) === 0;
    var n = Math.max(3, Math.round(Number(val(item, "points", 5)))), verts = star ? n * 2 : n;
    var p = val(item, "position", [0, 0]), rot = Number(val(item, "rotation", 0));
    var ir = Number(val(item, "innerRadius", 50)), or = Number(val(item, "outerRadius", 100));
    var iround = Number(val(item, "innerRoundness", 0)), oround = Number(val(item, "outerRoundness", 0));
    var step = 2 * Math.PI / verts, start = (rot - 90) * Math.PI / 180;
    for (var i = 0; i < verts; i++) {
      var inner = star && i % 2 === 1, r = inner ? ir : or, a = start + step * i, round = inner ? iround : oround;
      var pt = [p[0] + r * Math.cos(a), p[1] + r * Math.sin(a)];
      box.add(aApply(m, pt));
      if (round) {
        // Round points: the curve stays within its handles.
        var len = Math.abs(r * step * 0.25 * round / 100 * 4 / 3 * 0.75), d = [-Math.sin(a) * len, Math.cos(a) * len];
        box.add(aApply(m, [pt[0] + d[0], pt[1] + d[1]]));
        box.add(aApply(m, [pt[0] - d[0], pt[1] - d[1]]));
      }
    }
  }
  function itemBounds(item, m, box, flags, depth) {
    if (item.truncated) {
      // Too deep for the tree: its geometry is unknown here.
      if (flags) flags.warped = true;
      return;
    }
    switch (item.match) {
      case "group":
        var tr = childByMatch(item, "transform");
        contentsBounds(childByMatch(item, "contents"), tr ? aMul(m, groupMatrix(tr)) : m, box, flags, depth + 1);
        break;
      case "rect":
        var c = rectCorners(item);
        for (var i = 0; i < 4; i++) box.add(aApply(m, c[i]));
        break;
      case "ellipse":
        ellipseBounds(item, m, box);
        break;
      case "star":
      case "polygon":
        starBounds(item, m, box);
        break;
      case "path":
        var pv = pathValue(val(item, "path", null));
        if (pv) pathBounds(pv, m, box);
        break;
      default:
        if (WARPING[item.match] && flags) flags.warped = true;
    }
  }
  // Bounds of the drawable items of a Contents node (`skip`: uid left out), through `m`.
  function contentsBounds(contents, m, box, flags, depth, skip) {
    if (!contents || !contents.children || (depth || 0) > 32) return;
    for (var i = 0; i < contents.children.length; i++) {
      var it = contents.children[i];
      if (it.enabled === false || it.uid === skip) continue;
      itemBounds(it, m, box, flags, depth || 0);
    }
  }
  function hasDrawable(contents, depth) {
    if (contents && contents.truncated) return true;
    if (!contents || !contents.children || depth > 32) return false;
    for (var i = 0; i < contents.children.length; i++) {
      var it = contents.children[i];
      if (PRIMITIVES[it.match] || it.truncated) return true;
      if (it.match === "group" && hasDrawable(childByMatch(it, "contents"), depth + 1)) return true;
    }
    return false;
  }

  // ----------------------------------------------------------------------------- layer bounds

  // Union of the layer's active masks (not None, not inverted), in layer space.
  function maskBox(tree) {
    var masks = childByMatch(tree.properties, "masks");
    if (!masks || !masks.children) return null;
    var box = new Box();
    for (var i = 0; i < masks.children.length; i++) {
      var mk = masks.children[i];
      var info = mk.mask || {};
      if (String(info.mode || "add").toLowerCase() === "none" || info.inverted) continue;
      var pv = pathValue(val(mk, "path", null));
      if (pv) pathBounds(pv, I2, box);
    }
    return box.empty() ? null : box;
  }

  // The layer's content bounds in layer space: masks first (when `useMasks`), shape geometry,
  // else the engine's source rectangle. null when empty.
  function layerBox(comp, L, tree, t, useMasks) {
    if (useMasks) {
      var mb = maskBox(tree);
      if (mb) return mb;
    }
    if (L instanceof ShapeLayer) {
      var box = new Box(), flags = {};
      contentsBounds(childByMatch(tree.properties, "contents"), I2, box, flags, 0);
      if (!box.empty() && !flags.warped) return box;
    }
    var r = L.sourceRectAtTime(t, false);
    if (!r || !(r.width > 0 || r.height > 0)) return null;
    var b = new Box();
    b.add([r.left, r.top]);
    b.add([r.left + r.width, r.top + r.height]);
    return b;
  }

  // ----------------------------------------------------------------------------- layer transforms

  function tprop(L, name) { return L.property("ADBE Transform Group").property(name); }
  // A transform value at time t, or `def` when the layer has no such property (lights).
  function tval(L, name, t, def) {
    var p = tprop(L, name);
    return p ? p.valueAtTime(t, false) : def;
  }

  function pixelRatio(comp, L) {
    try {
      if (L instanceof ShapeLayer || L instanceof TextLayer || !(L instanceof AVLayer)) return 1;
      var src = L.source;
      if (!src || !src.pixelAspect) return 1;
      var r = src.pixelAspect / (comp.pixelAspect || 1);
      return isFinite(r) && r > 0 && Math.abs(r - 1) > 1e-9 ? r : 1;
    } catch (e) {
      return 1;
    }
  }

  // Rotation and scale of a layer (layer space -> parent space, no translation) at time t.
  function layerLinear(comp, L, t, par) {
    if (isCameraOrLight(L)) return layerRotation(L, t, true);
    var s = tprop(L, "ADBE Scale").valueAtTime(t, false);
    var rz = tprop(L, "ADBE Rotate Z").valueAtTime(t, false);
    if (!L.threeDLayer) return mMul(mRotZ(rz), mScale(s[0] / 100 * par, s[1] / 100, 1));
    var S = mScale(s[0] / 100 * par, s[1] / 100, (s.length > 2 ? s[2] : 100) / 100);
    return mMul(layerRotation(L, t, false), S);
  }
  function layerRotation(L, t) {
    var o = tval(L, "ADBE Orientation", t, [0, 0, 0]);
    var rx = tval(L, "ADBE Rotate X", t, 0), ry = tval(L, "ADBE Rotate Y", t, 0), rz = tval(L, "ADBE Rotate Z", t, 0);
    var orient = mMul(mRotZ(o[2] || 0), mMul(mRotY(o[1] || 0), mRotX(o[0] || 0)));
    return mMul(orient, mMul(mRotZ(rz), mMul(mRotY(ry), mRotX(rx))));
  }
  function layerPosition(L, t) {
    var pos = tprop(L, "ADBE Position");
    if (!pos) return [0, 0, 0];
    if (pos.dimensionsSeparated) {
      var tr = pos.parentProperty;
      var out = [tr.property("ADBE Position_0").valueAtTime(t, false), tr.property("ADBE Position_1").valueAtTime(t, false), 0];
      var z = tr.property("ADBE Position_2");
      if (z) out[2] = z.valueAtTime(t, false);
      return out;
    }
    var v = pos.valueAtTime(t, false);
    return [v[0], v[1], v.length > 2 ? v[2] : 0];
  }
  // A layer-space point in comp (world) space, through the layer and its parents, without a camera.
  function worldPoint(comp, L, pt, t) {
    var v = [pt[0], pt[1], pt.length > 2 ? pt[2] : 0], cur = L, guard = 0, first = true;
    while (cur && guard++ < 64) {
      var three = isCameraOrLight(cur) || cur.threeDLayer;
      var M = layerLinear(comp, cur, t, first ? pixelRatio(comp, cur) : 1);
      var a = isCameraOrLight(cur) ? [0, 0, 0] : tval(cur, "ADBE Anchor Point", t, [0, 0, 0]);
      var p = layerPosition(cur, t);
      var local = [v[0] - a[0], v[1] - a[1], three ? v[2] - (a[2] || 0) : v[2]];
      var r = mApply(M, local);
      v = [r[0] + p[0], r[1] + p[1], r[2] + (three ? p[2] : 0)];
      cur = cur.parent;
      first = false;
    }
    return v;
  }

  // Add delta(t) to a property: to every keyframe's value, else to its value.
  function shiftProp(p, deltaAt, t) {
    var n = p.numKeys;
    if (n > 0) {
      for (var i = 1; i <= n; i++) p.setValueAtKey(i, addv(p.keyValue(i), deltaAt(p.keyTime(i))));
    } else {
      p.setValue(addv(p.valueAtTime(t, true), deltaAt(t)));
    }
  }

  // ----------------------------------------------------------------------------- keyframes

  // Properties with selected keyframes: [{prop, layer, keys: [indices]}].
  function selectedKeyProps(comp) {
    var props = comp.selectedProperties, out = [];
    for (var i = 0; i < props.length; i++) {
      var p = props[i];
      if (!(p instanceof Property)) continue;
      var keys = p.selectedKeys;
      if (keys.length) out.push({ prop: p, layer: p.__layer, keys: keys });
    }
    return out;
  }
  function countKeys(sel) {
    var n = 0;
    for (var i = 0; i < sel.length; i++) n += sel[i].keys.length;
    return n;
  }
  // All keys of a property, as the engine reports them ({time, layerTime, selected, ...}).
  function keyList(p) { return p.__keys(); }

  var EASE_NAMES = { out: "Ease out", "in": "Ease in", both: "Easy ease" };

  function applyEase(which, amount) {
    amount = clampInt(amount, 0, 100);
    return act(EASE_NAMES[which], function (comp) {
      var sel = selectedKeyProps(comp);
      if (!sel.length) fail("Select keyframes first, then set the ease.");
      var p = { comp: comp.id, continuous: false };
      if (amount === 0) {
        p.autoBezier = false;
        if (which !== "out") p["in"] = "linear";
        if (which !== "in") p.out = "linear";
        app.run("keys.interpolation", p);
      } else {
        var inf = Math.max(0.1, amount);
        if (which !== "out") {
          p.inSpeed = 0;
          p.inInfluence = inf;
        }
        if (which !== "in") {
          p.outSpeed = 0;
          p.outInfluence = inf;
        }
        app.run("keys.velocity", p);
      }
      return EASE_NAMES[which] + (amount === 0 ? " off (linear)" : " " + amount + "%") + " on " + plural(countKeys(sel), "key") + ".";
    });
  }

  function applyInterpolation(kind, m) {
    var label = { hold: "Hold", linear: "Linear", bezier: "Bezier" }[kind];
    return act(label + " keys", function (comp) {
      var sel = selectedKeyProps(comp);
      if (!sel.length) fail("Select keyframes first.");
      var p = { comp: comp.id, continuous: false, autoBezier: false }, side = "in and out";
      if (m.alt) {
        p["in"] = kind;
        side = "in only";
      } else if (m.shift) {
        p.out = kind;
        side = "out only";
      } else {
        p["in"] = kind;
        p.out = kind;
      }
      app.run("keys.interpolation", p);
      return label + " (" + side + ") on " + plural(countKeys(sel), "key") + ".";
    });
  }

  // ----------------------------------------------------------------------------- anchor point

  var GRID = [[0, 0], [0.5, 0], [1, 0], [0, 0.5], [0.5, 0.5], [1, 0.5], [0, 1], [0.5, 1], [1, 1]];
  var GRID_NAMES = ["top left", "top", "top right", "left", "center", "right", "bottom left", "bottom", "bottom right"];

  // Shape groups selected in the selected shape layers.
  function selectedShapeGroups(comp, matchNames) {
    var props = comp.selectedProperties, layers = comp.selectedLayers, ids = {}, out = [];
    for (var i = 0; i < layers.length; i++) ids[layers[i].id] = true;
    for (var j = 0; j < props.length; j++) {
      var p = props[j];
      if (!(p instanceof PropertyGroup) || !(p.__layer instanceof ShapeLayer)) continue;
      if (layers.length && !ids[p.__layer.id]) continue;
      if (matchNames[p.matchName]) out.push(p);
    }
    return out;
  }
  var GROUP_ONLY = { "ADBE Vector Group": 1 };
  var SHAPE_ITEMS = {
    "ADBE Vector Group": 1, "ADBE Vector Shape - Rect": 1, "ADBE Vector Shape - Ellipse": 1,
    "ADBE Vector Shape - Star": 1, "ADBE Vector Shape - Group": 1
  };

  function anchorClick(i, m) {
    var fx = GRID[i][0], fy = GRID[i][1], where = GRID_NAMES[i];
    if (m.alt) return addParentNull(fx, fy, where);
    return act("Move anchor point", function (comp) {
      var groups = selectedShapeGroups(comp, GROUP_ONLY);
      if (groups.length) return moveGroupAnchors(comp, groups, fx, fy, where);
      var layers = comp.selectedLayers;
      if (!layers.length) fail("Select layers (or shape groups) first.");
      return moveLayerAnchors(comp, layers, fx, fy, where, !m.shift);
    });
  }

  function moveLayerAnchors(comp, layers, fx, fy, where, useMasks) {
    var t = comp.time, moved = 0, skipped = [];
    for (var i = 0; i < layers.length; i++) {
      var L = layers[i];
      if (isCameraOrLight(L)) {
        skipped.push(L.name);
        continue;
      }
      if (L.locked) {
        skipped.push(L.name + " (locked)");
        continue;
      }
      var ap = tprop(L, "ADBE Anchor Point"), pos = tprop(L, "ADBE Position");
      var followers = [];
      if (pos.dimensionsSeparated) {
        for (var k = 0; k < (L.threeDLayer ? 3 : 2); k++) followers.push(pos.parentProperty.property("ADBE Position_" + k));
      }
      var expr = ap.expressionEnabled || (!followers.length && pos.expressionEnabled);
      for (var f = 0; f < followers.length; f++) if (followers[f].expressionEnabled) expr = true;
      if (expr) {
        skipped.push(L.name + " (expression)");
        continue;
      }
      var box = layerBox(comp, L, layerTree(comp, L, t), t, useMasks);
      if (!box) {
        skipped.push(L.name + " (empty)");
        continue;
      }
      var A = ap.valueAtTime(t, false);
      var target = [box.x0 + fx * (box.x1 - box.x0), box.y0 + fy * (box.y1 - box.y0)];
      var d = [target[0] - A[0], target[1] - A[1], 0];
      if (Math.abs(d[0]) > 1e-6 || Math.abs(d[1]) > 1e-6) {
        var par = pixelRatio(comp, L);
        var lin = (function (L, d, par) {
          return function (time) { return mApply(layerLinear(comp, L, time, par), d); };
        })(L, d, par);
        shiftProp(ap, function () { return d; }, t);
        if (followers.length) {
          for (var c = 0; c < followers.length; c++) {
            (function (c) { shiftProp(followers[c], function (time) { return lin(time)[c]; }, t); })(c);
          }
        } else shiftProp(pos, lin, t);
      }
      moved++;
    }
    var msg = moved ? "Anchor point to the " + where + " on " + plural(moved, "layer") + "." : "No anchor point moved.";
    if (skipped.length) msg += " Skipped: " + skipped.join(", ") + ".";
    return msg;
  }

  // Rotation, skew and scale of a shape group at time t (2D, no translation).
  function groupLinear(tr, t) {
    var s = tr.property("ADBE Vector Scale").valueAtTime(t, false);
    var r = tr.property("ADBE Vector Rotation").valueAtTime(t, false);
    var sk = tr.property("ADBE Vector Skew").valueAtTime(t, false), ax = tr.property("ADBE Vector Skew Axis").valueAtTime(t, false);
    return aMul(aRotate(r), aMul(aSkew(-sk, ax), aScale(s[0] / 100, s[1] / 100)));
  }

  function moveGroupAnchors(comp, groups, fx, fy, where) {
    var t = comp.time, trees = {}, moved = 0, skipped = [];
    for (var i = 0; i < groups.length; i++) {
      var g = groups[i], L = g.__layer;
      var tree = trees[L.id] || (trees[L.id] = layerTree(comp, L, t));
      var node = findNode(tree.properties, g.__uid, 0);
      var box = new Box(), gflags = {};
      contentsBounds(childByMatch(node, "contents"), I2, box, gflags, 0);
      if (box.empty() || gflags.warped) {
        skipped.push(g.name + (gflags.warped ? " (can't measure: repeater, warp or deep nesting)" : " (empty)"));
        continue;
      }
      var tr = g.property("ADBE Vector Transform Group");
      var ap = tr.property("ADBE Vector Anchor"), pos = tr.property("ADBE Vector Position");
      if (ap.expressionEnabled || pos.expressionEnabled) {
        skipped.push(g.name + " (expression)");
        continue;
      }
      var A = ap.valueAtTime(t, false);
      var d = [box.x0 + fx * (box.x1 - box.x0) - A[0], box.y0 + fy * (box.y1 - box.y0) - A[1]];
      if (Math.abs(d[0]) > 1e-6 || Math.abs(d[1]) > 1e-6) {
        shiftProp(ap, function () { return d; }, t);
        shiftProp(pos, (function (tr, d) { return function (time) { return aLinear(groupLinear(tr, time), d); }; })(tr, d), t);
      }
      moved++;
    }
    var msg = moved ? "Anchor point to the " + where + " on " + plural(moved, "shape group") + "." : "No anchor point moved.";
    if (skipped.length) msg += " Skipped: " + skipped.join(", ") + ".";
    return msg;
  }

  // ----------------------------------------------------------------------------- parent null

  function addParentNull(fx, fy, where) {
    return act("Add parent null", function (comp) {
      var layers = comp.selectedLayers;
      if (!layers.length) fail("Select the layers to parent first.");
      var t = comp.time, any3d = false, i;
      for (i = 0; i < layers.length; i++) if (isCameraOrLight(layers[i]) || layers[i].threeDLayer) any3d = true;
      var box = new Box(), z0 = Infinity, z1 = -Infinity;
      for (i = 0; i < layers.length; i++) {
        var L = layers[i], pts = [];
        if (isCameraOrLight(L) || L.nullLayer) {
          pts.push(isCameraOrLight(L) ? [0, 0, 0] : tprop(L, "ADBE Anchor Point").valueAtTime(t, false));
        } else {
          var b = layerBox(comp, L, layerTree(comp, L, t), t, true);
          if (b) pts.push([b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]);
          else pts.push(tprop(L, "ADBE Anchor Point").valueAtTime(t, false));
        }
        for (var k = 0; k < pts.length; k++) {
          var w;
          if (any3d) w = worldPoint(comp, L, pts[k], t);
          else w = L.sourcePointToComp([pts[k][0], pts[k][1]]);
          box.add(w);
          if (w.length > 2) {
            z0 = Math.min(z0, w[2]);
            z1 = Math.max(z1, w[2]);
          }
        }
      }
      if (box.empty()) fail("Couldn't measure the selected layers.");
      var target = [box.x0 + fx * (box.x1 - box.x0), box.y0 + fy * (box.y1 - box.y0)];
      if (any3d) target.push(isFinite(z0) ? (z0 + z1) / 2 : 0);

      // The parent the selection shares (outside the selection), if any.
      var sel = {}, shared, mixed = false;
      for (i = 0; i < layers.length; i++) sel[layers[i].id] = true;
      for (i = 0; i < layers.length; i++) {
        var par = layers[i].parent;
        if (par && sel[par.id]) continue;
        var pid = par ? par.id : 0;
        if (shared === undefined) shared = pid;
        else if (shared !== pid) mixed = true;
      }
      var top = layers[0];
      for (i = 1; i < layers.length; i++) if (layers[i].index < top.index) top = layers[i];

      var nul = comp.layers.addNull();
      nul.name = uniqueLayerName(comp, "Parent Null");
      if (any3d) nul.threeDLayer = true;
      tprop(nul, "ADBE Position").setValue(target);
      nul.moveBefore(top);
      if (!mixed && shared) nul.parent = layerById(comp, shared);
      var n = 0;
      for (i = 0; i < layers.length; i++) {
        var p = layers[i].parent;
        if (p && sel[p.id]) continue;
        layers[i].parent = nul;
        n++;
      }
      selectLayers(comp, [nul]);
      return "Parented " + plural(n, "layer") + " to “" + nul.name + "”" + (where ? " at the " + where : "") + ".";
    });
  }

  // ----------------------------------------------------------------------------- elastic / bounce

  var FX = {
    elastic: {
      title: "Elastic",
      controls: [["Amplitude", "slider", 5], ["Frequency", "slider", 2.5], ["Decay", "slider", 6]]
    },
    bounce: {
      title: "Bounce",
      // Time: length of the first bounce in seconds (later ones shrink with Elasticity), so the
      // same settings work on Position, Scale or Rotation alike.
      controls: [["Elasticity", "slider", 60], ["Time", "slider", 0.3], ["Count", "slider", 4], ["Overshoot", "checkbox", 0]]
    }
  };
  var EXPR_MARK = "// Motion Kit:";
  var NUMERIC_TYPES = {};
  (function () {
    var names = ["OneD", "TwoD", "ThreeD", "TwoD_SPATIAL", "ThreeD_SPATIAL", "COLOR"];
    for (var i = 0; i < names.length; i++) NUMERIC_TYPES[PropertyValueType[names[i]]] = true;
  })();

  function fxExpression(kind, ref, names) {
    var c = function (i, param) { return "ctl.effect(" + quote(names[i]) + ")(" + quote(param) + ")"; };
    if (kind === "elastic") {
      return [
        EXPR_MARK + " Elastic (overshoot after each keyframe; controls: " + names[0].replace(/ Amplitude$/, "") + ")",
        "var ctl = " + ref + ";",
        "var amp = " + c(0, "Slider") + " / 100;",
        "var freq = " + c(1, "Slider") + ";",
        "var decay = " + c(2, "Slider") + ";",
        "var out = value;",
        "if (numKeys > 1) {",
        "  var n = nearestKey(time).index;",
        "  if (key(n).time > time) n--;",
        "  if (n > 1) {",
        "    var t = time - key(n).time;",
        "    var v = velocityAtTime(key(n).time - thisComp.frameDuration / 10);",
        "    var k = amp * Math.sin(2 * Math.PI * freq * t) / Math.exp(decay * t);",
        "    out = add(value, mul(v, k));",
        "  }",
        "}",
        "out;"
      ].join("\n");
    }
    return [
      EXPR_MARK + " Bounce (bounces after each keyframe; controls: " + names[0].replace(/ Elasticity$/, "") + ")",
      "var ctl = " + ref + ";",
      "var e = Math.min(Math.max(" + c(0, "Slider") + " / 100, 0), 0.95);",
      "var first = Math.max(" + c(1, "Slider") + ", 0.01);",
      "var maxB = Math.round(" + c(2, "Slider") + ");",
      "var over = " + c(3, "Checkbox") + " == 1;",
      "var out = value;",
      "if (numKeys > 1 && maxB > 0 && e > 0) {",
      "  var n = nearestKey(time).index;",
      "  if (key(n).time > time) n--;",
      "  if (n > 1) {",
      "    var t = time - key(n).time;",
      "    var vel = velocityAtTime(key(n).time - thisComp.frameDuration / 10);",
      "    var speed = length(vel);",
      "    if (speed > 0 && t > 0) {",
      "      var dir = vel instanceof Array ? normalize(vel) : (vel > 0 ? 1 : -1);",
      "      var v0 = speed * e, g = 2 * v0 / first, start = 0, dur = first, b = 0;",
      "      while (b < maxB && t > start + dur) { start += dur; v0 *= e; dur *= e; b++; }",
      "      if (b < maxB) {",
      "        var s = t - start;",
      "        var h = v0 * s - g * s * s / 2;",
      "        out = over ? add(value, mul(dir, h)) : sub(value, mul(dir, h));",
      "      }",
      "    }",
      "  }",
      "}",
      "out;"
    ].join("\n");
  }

  // Effect names on a layer (one call).
  function effectNames(comp, L) {
    var names = {}, tree = layerTree(comp, L, comp.time), fx = childByMatch(tree.properties, "effects");
    if (fx && fx.children) for (var i = 0; i < fx.children.length; i++) names[fx.children[i].name] = true;
    return names;
  }

  // A set of controller effects on layer L named "<base> <control>"; an existing complete set
  // with that base is reused unless `fresh`. Returns the effect names.
  function controlSet(comp, L, spec, base, fresh) {
    var have = effectNames(comp, L), names = [], i, complete = true;
    var nameOf = function (b) {
      var out = [];
      for (var k = 0; k < spec.controls.length; k++) out.push(b + " " + spec.controls[k][0]);
      return out;
    };
    names = nameOf(base);
    for (i = 0; i < names.length; i++) if (!have[names[i]]) complete = false;
    if (complete && !fresh) return names;
    // A base whose names are all free.
    var b = base;
    for (var n = 2; n < 1000; n++) {
      var free = true, cand = nameOf(b);
      for (i = 0; i < cand.length; i++) if (have[cand[i]]) free = false;
      if (free) break;
      b = base + " " + n;
    }
    names = nameOf(b);
    var parade = L.property("ADBE Effect Parade");
    for (i = 0; i < spec.controls.length; i++) {
      var ctl = spec.controls[i];
      var fx = parade.addProperty(ctl[1] === "checkbox" ? "ADBE Checkbox Control" : "ADBE Slider Control");
      fx.name = names[i];
      fx.property(ctl[1] === "checkbox" ? "Checkbox" : "Slider").setValue(ctl[2]);
      // Controllers only hold values: switched off they still drive the expressions, and a
      // layer doesn't spend render time on them (nulls are never rendered).
      if (!L.nullLayer) {
        try { fx.enabled = false; } catch (e) {}
      }
    }
    return names;
  }

  // Leaf properties the overshoot expressions can go on.
  function fxTargets(comp, skipped) {
    var props = comp.selectedProperties, out = [], seen = {};
    for (var i = 0; i < props.length; i++) {
      var p = props[i];
      if (!(p instanceof Property)) continue;
      var key = p.__layer.id + ":" + p.__uid;
      if (seen[key]) continue;
      seen[key] = true;
      if (!p.canSetExpression || !NUMERIC_TYPES[p.propertyValueType]) {
        skipped.push(p.name + " (can't take an expression)");
        continue;
      }
      if (p.numKeys < 2) {
        skipped.push(p.name + " (needs 2+ keys)");
        continue;
      }
      var e = p.expression;
      if (e && e.indexOf(EXPR_MARK) !== 0) {
        skipped.push(p.name + " (has an expression)");
        continue;
      }
      out.push({ prop: p, layer: p.__layer });
    }
    return out;
  }

  function applyOvershoot(kind, m) {
    var spec = FX[kind];
    return act(spec.title, function (comp) {
      var skipped = [], targets = fxTargets(comp, skipped);
      if (!targets.length) {
        fail(skipped.length ? spec.title + ": nothing to do. " + skipped.join(", ") + "." : "Select animated properties (or their keyframes) first.");
      }
      var host = null, names = null, where = "";
      if (m.alt) {
        // A controller null at the top of the comp.
        host = comp.layers.addNull();
        host.name = uniqueLayerName(comp, spec.title + " Controls");
        names = controlSet(comp, host, spec, spec.title, true);
        where = " on “" + host.name + "”";
      } else if (!m.shift) {
        host = targets[0].layer;
        names = controlSet(comp, host, spec, spec.title, false);
        where = " on “" + host.name + "”";
      }
      var sets = 0;
      for (var i = 0; i < targets.length; i++) {
        var tg = targets[i], L = host, n = names;
        if (m.shift && !m.alt) {
          L = tg.layer;
          n = controlSet(comp, L, spec, spec.title + " " + tg.prop.name, false);
          sets++;
        }
        var ref = L.id === tg.layer.id ? "thisLayer" : "thisComp.layer(" + quote(L.name) + ")";
        tg.prop.expression = fxExpression(kind, ref, n);
      }
      if (!m.shift || m.alt) sets = 1;
      var msg = spec.title + " on " + plural(targets.length, "property", "properties") + ", " + plural(sets, "control set") + where + ".";
      if (skipped.length) msg += " Skipped: " + skipped.join(", ") + ".";
      return msg;
    });
  }

  // ----------------------------------------------------------------------------- clone keys

  function cloneKeys(m) {
    return act(m.shift ? "Clone keys reversed" : "Clone keys", function (comp) {
      var sel = selectedKeyProps(comp);
      if (!sel.length) fail("Select the keyframes to clone first.");
      var t = comp.time, first = Infinity, last = -Infinity, orig = [], layerIds = [], seen = {}, n = 0;
      for (var i = 0; i < sel.length; i++) {
        var s = sel[i], keys = keyList(s.prop);
        if (!seen[s.layer.id]) {
          seen[s.layer.id] = true;
          layerIds.push(s.layer.id);
        }
        for (var k = 0; k < keys.length; k++) {
          if (!keys[k].selected) continue;
          first = Math.min(first, keys[k].time);
          last = Math.max(last, keys[k].time);
          orig.push({ layer: s.layer.id, prop: s.prop.__uid, time: keys[k].layerTime });
          n++;
        }
      }
      var fd = comp.frameDuration || 1 / 30;
      if (t <= last + fd / 2 && t + (last - first) >= first - fd / 2) {
        fail("Move the playhead past the selected keys first: the copies would land on them.");
      }
      app.run("keys.copy", { comp: comp.id });
      layerIds.sort(function (a, b) { return a - b; });
      var p = { comp: comp.id, time: t, layers: layerIds };
      if (sel.length === 1) p.prop = sel[0].prop.__uid;
      app.run("keys.paste", p);
      if (m.shift) app.run("keys.timeReverse", { comp: comp.id });
      if (!m.alt) app.run("keys.select", { comp: comp.id, keys: orig });
      return "Cloned " + plural(n, "key") + (m.shift ? " (reversed)" : "") + " to the playhead" + (m.alt ? "; the copies are selected." : ".");
    });
  }

  // ----------------------------------------------------------------------------- sequence

  var ORDERS = ["top", "bottom", "random"];
  var ORDER_TIPS = {
    top: "Order: from the top layer down. Click to change.",
    bottom: "Order: from the bottom layer up. Click to change.",
    random: "Order: random. Click to change."
  };
  var seqOrder = ORDERS[intSetting("order", 0, 0, 2)];

  // Tree position of a property, for sorting: [index in layer, index in group, ...].
  function propOrder(p) {
    var out = [], cur = p, guard = 0;
    while (cur && !(cur instanceof Layer) && guard++ < 32) {
      out.unshift(cur.propertyIndex);
      cur = cur.parentProperty;
    }
    return out;
  }
  function cmpArrays(a, b) {
    for (var i = 0; i < Math.max(a.length, b.length); i++) {
      var x = i < a.length ? a[i] : -1, y = i < b.length ? b[i] : -1;
      if (x !== y) return x - y;
    }
    return 0;
  }

  // What Sequence and Arrange move: the selected keys of each property, else the selected layers.
  function sequenceUnits(comp) {
    var sel = selectedKeyProps(comp), units = [], i;
    if (sel.length) {
      var info = compLayers(comp), index = {};
      for (i = 0; i < info.length; i++) index[info[i].id] = info[i].index;
      for (i = 0; i < sel.length; i++) {
        if (sel[i].layer.locked) continue;
        var s = sel[i], keys = keyList(s.prop), refs = [], start = Infinity;
        for (var k = 0; k < keys.length; k++) {
          if (!keys[k].selected) continue;
          refs.push({ index: k, time: keys[k].time });
          start = Math.min(start, keys[k].time);
        }
        units.push({ kind: "keys", prop: s.prop, layer: s.layer, refs: refs, start: start, sort: [index[s.layer.id] || 0].concat(propOrder(s.prop)) });
      }
      return units;
    }
    var layers = comp.selectedLayers;
    for (i = 0; i < layers.length; i++) {
      if (layers[i].locked) continue;
      units.push({ kind: "layer", layer: layers[i], start: layers[i].inPoint, sort: [layers[i].index] });
    }
    return units;
  }
  function orderUnits(units, order) {
    var u = units.slice();
    if (order === "random") {
      for (var i = u.length - 1; i > 0; i--) {
        var j = Math.floor(Math.random() * (i + 1)), x = u[i];
        u[i] = u[j];
        u[j] = x;
      }
      return u;
    }
    u.sort(function (a, b) { return cmpArrays(a.sort, b.sort); });
    if (order === "bottom") u.reverse();
    return u;
  }

  // Move one unit so that it starts at `target` (comp seconds); new key refs are collected in
  // `reselect` for keys.
  function moveUnit(comp, u, target, reselect) {
    var delta = target - u.start, fd = comp.frameDuration || 1 / 30;
    if (u.kind === "layer") {
      if (Math.abs(delta) > 1e-9) u.layer.startTime = u.layer.startTime + delta;
      return;
    }
    var refs = [];
    for (var i = 0; i < u.refs.length; i++) refs.push({ layer: u.layer.id, prop: u.prop.__uid, index: u.refs[i].index });
    if (Math.abs(delta) > 1e-9) app.run("keys.move", { comp: comp.id, delta: delta, __keys: refs });
    // The moved keys, at their new times (moves snap to frames).
    var snapped = Math.round(delta / fd) * fd, keys = keyList(u.prop);
    for (var r = 0; r < u.refs.length; r++) {
      var want = u.refs[r].time + snapped;
      for (var k = 0; k < keys.length; k++) {
        if (Math.abs(keys[k].time - want) < fd / 4) {
          reselect.push({ layer: u.layer.id, prop: u.prop.__uid, time: keys[k].layerTime });
          break;
        }
      }
    }
  }

  function readInt(field, def) {
    var v = parseInt(field.text, 10);
    return isNaN(v) ? def : v;
  }

  function doSequence(m) {
    var offset = readInt(ui.offset, 1), step = Math.max(1, readInt(ui.step, 1));
    return act("Sequence", function (comp) {
      var units = sequenceUnits(comp);
      if (units.length < 2) fail("Select 2+ layers, or keyframes on 2+ properties, to sequence.");
      var ordered = orderUnits(units, seqOrder), base = Infinity, fd = comp.frameDuration || 1 / 30, reselect = [];
      for (var i = 0; i < units.length; i++) base = Math.min(base, units[i].start);
      for (var j = 0; j < ordered.length; j++) {
        var slot = m.alt ? j % step : Math.floor(j / step);
        moveUnit(comp, ordered[j], base + slot * offset * fd, reselect);
      }
      if (ordered[0].kind === "keys") app.run("keys.select", { comp: comp.id, keys: reselect });
      var what = ordered[0].kind === "keys" ? plural(units.length, "property", "properties") : plural(units.length, "layer");
      return "Sequenced " + what + " " + offset + " frame" + (Math.abs(offset) === 1 ? "" : "s") + " apart" +
        (step > 1 ? (m.alt ? ", repeating every " + step : ", " + step + " at a time") : "") + " (" + seqOrder + ").";
    });
  }

  function doArrange(m) {
    return act("Arrange", function (comp) {
      var units = sequenceUnits(comp);
      if (!units.length) fail("Select layers or keyframes first.");
      var ordered = orderUnits(units, seqOrder === "random" ? "top" : seqOrder), reselect = [];
      var target = m.alt ? 0 : m.shift ? ordered[0].start : comp.time;
      for (var i = 0; i < ordered.length; i++) moveUnit(comp, ordered[i], target, reselect);
      if (ordered[0].kind === "keys") app.run("keys.select", { comp: comp.id, keys: reselect });
      var to = m.alt ? "the comp start" : m.shift ? "the first one" : "the playhead";
      return "Lined up " + (ordered[0].kind === "keys" ? plural(units.length, "property", "properties") : plural(units.length, "layer")) + " at " + to + ".";
    });
  }

  // ----------------------------------------------------------------------------- shapes: extract

  // Child indices from a Contents node down to the item `uid`.
  function indexPath(contents, uid, depth) {
    if (!contents || !contents.children || depth > 32) return null;
    for (var i = 0; i < contents.children.length; i++) {
      var it = contents.children[i];
      if (it.uid === uid) return [i];
      if (it.match === "group") {
        var sub = indexPath(childByMatch(it, "contents"), uid, depth + 1);
        if (sub) return [i].concat(sub);
      }
    }
    return null;
  }
  // Uids of the groups and paths that aren't on `path` (paints and path operators stay: they
  // style what's kept).
  function itemsOffPath(contents, path) {
    var kill = [], node = contents;
    for (var d = 0; d < path.length && node && node.children; d++) {
      for (var i = 0; i < node.children.length; i++) {
        if (i === path[d]) continue;
        var it = node.children[i];
        if (it.match === "group" || PRIMITIVES[it.match]) kill.push(it.uid);
      }
      node = d < path.length - 1 ? childByMatch(node.children[path[d]], "contents") : null;
    }
    return kill;
  }

  function doExtract(m) {
    return act(m.alt ? "Extract shapes (keep source)" : "Extract shapes", function (comp) {
      var t = comp.time, jobs = [], byLayer = {}, i;
      var items = selectedShapeGroups(comp, SHAPE_ITEMS);
      for (i = 0; i < items.length; i++) {
        var L = items[i].__layer;
        if (!byLayer[L.id]) {
          byLayer[L.id] = { layer: L, uids: [], all: false };
          jobs.push(byLayer[L.id]);
        }
        byLayer[L.id].uids.push(items[i].__uid);
      }
      if (!jobs.length) {
        var layers = comp.selectedLayers;
        for (i = 0; i < layers.length; i++) if (layers[i] instanceof ShapeLayer) jobs.push({ layer: layers[i], uids: null, all: true });
      }
      if (!jobs.length) fail("Select shape layers, or shape groups inside one, to extract.");
      var made = [], sources = 0;
      for (var j = 0; j < jobs.length; j++) {
        var job = jobs[j], src = job.layer;
        if (src.locked) continue;
        var tree = layerTree(comp, src, t), contents = childByMatch(tree.properties, "contents");
        var targets = [];
        if (job.all) {
          for (i = 0; contents && i < contents.children.length; i++) {
            var it = contents.children[i];
            if (it.match === "group" || PRIMITIVES[it.match]) targets.push(it);
          }
          // Nothing to split in a layer with one group.
          if (targets.length < 2) continue;
        } else {
          for (i = 0; i < job.uids.length; i++) {
            var node = findNode(contents, job.uids[i], 0);
            if (node) targets.push(node);
          }
        }
        // Top to bottom: each copy goes straight above the source, so they keep the stacking order.
        targets.sort(function (a, b) { return cmpArrays(indexPath(contents, a.uid, 0) || [], indexPath(contents, b.uid, 0) || []); });
        for (i = 0; i < targets.length; i++) {
          var path = indexPath(contents, targets[i].uid, 0);
          if (!path) continue;
          var dup = src.duplicate();
          var dtree = layerTree(comp, dup, t);
          removeItems(comp, dup, itemsOffPath(childByMatch(dtree.properties, "contents"), path));
          dup.name = uniqueLayerName(comp, targets[i].name);
          made.push(dup);
        }
        sources++;
        if (!m.alt) {
          if (job.all) src.remove();
          else {
            var uids = [];
            for (i = 0; i < targets.length; i++) uids.push(targets[i].uid);
            removeItems(comp, src, uids);
          }
        }
      }
      if (!made.length) fail("Nothing to extract: the shape layers have fewer than 2 groups.");
      selectLayers(comp, made);
      return "Extracted " + plural(made.length, "shape layer") + " from " + plural(sources, "layer") + (m.alt ? " (source kept)." : ".");
    });
  }

  // ----------------------------------------------------------------------------- shapes: merge

  // Copy shape items (by uid, top to bottom) of layer `from` into the Contents group `dst` of
  // layer `to`, through EffectCraft's own shape clipboard (keyframes and expressions travel).
  function copyItemsInto(comp, from, uids, to, dst) {
    deselectAll();
    for (var i = 0; i < uids.length; i++) app.run("prop.select", { comp: comp.id, layer: from.id, prop: uids[i], add: true });
    app.run("edit.copy", { comp: comp.id });
    // Paste goes above the selected item of the target: a placeholder inside `dst`.
    var temp = dst.addProperty("ADBE Vector Graphic - Fill");
    deselectAll();
    app.run("prop.select", { comp: comp.id, layer: to.id, prop: temp.__uid });
    var r = app.run("edit.paste", { comp: comp.id, layers: [to.id] });
    temp.remove();
    deselectAll();
    return (r && r.contents) || [];
  }

  // Keyframes inside a group node of a layer tree: [{layer, prop, index}].
  function keysUnder(node, layerId, out, depth) {
    if (!node || depth > 40) return out;
    if (node.keys) for (var k = 0; k < node.keys.length; k++) out.push({ layer: layerId, prop: node.uid, index: k });
    var c = node.children || [];
    for (var i = 0; i < c.length; i++) keysUnder(c[i], layerId, out, depth + 1);
    return out;
  }

  function clampInfluence(e) {
    var out = [];
    for (var i = 0; i < e.length; i++) out.push(new KeyframeEase(e[i].speed, Math.max(0.1, Math.min(100, e[i].influence))));
    return out;
  }

  // Whether a spatial property's animation is more than straight moves eased from rest: a
  // curved path, or a key passed through at speed. A shape group's Position isn't spatial, so
  // that motion can't become keyframes on it as they are.
  function curvedMotion(src) {
    var keys = keyList(src);
    for (var i = 0; i < keys.length; i++) {
      var k = keys[i], sides = [k.spatialIn || [], k.spatialOut || []];
      for (var s = 0; s < 2; s++) for (var d = 0; d < sides[s].length; d++) if (Math.abs(sides[s][d]) > 1e-6) return true;
      var eases = [k.inInterp === "BEZIER" && i > 0 ? k.inEase : [], k.outInterp === "BEZIER" && i < keys.length - 1 ? k.outEase : []];
      for (s = 0; s < 2; s++) for (d = 0; d < eases[s].length; d++) if (Math.abs(eases[s][d].speed) > 1e-6) return true;
    }
    return false;
  }

  // A layer Transform property onto a shape group's (2D): value, or keyframes with their
  // interpolation, eases and spatial tangents.
  function copyChannel(src, dst, dims, t, comp, baked) {
    if (src.expressionEnabled && src.expression) {
      try { dst.expression = src.expression; } catch (e) {}
    }
    var n = src.numKeys;
    if (!n) {
      dst.setValue(slice(src.valueAtTime(t, true), dims));
      return;
    }
    if (n > 1 && src.isSpatial && !dst.isSpatial && curvedMotion(src)) {
      // One key per frame along the source's path.
      var fd = comp.frameDuration || 1 / 30, t0 = src.keyTime(1), t1 = src.keyTime(n), steps = Math.round((t1 - t0) / fd);
      for (var f = 0; f <= steps; f++) {
        var tf = f === steps ? t1 : t0 + f * fd;
        dst.setValueAtTime(tf, slice(src.valueAtTime(tf, true), dims));
      }
      if (baked) baked.push(src.name);
      return;
    }
    var spatial = dst.isSpatial;
    for (var i = 1; i <= n; i++) {
      var tk = src.keyTime(i);
      dst.setValueAtTime(tk, slice(src.keyValue(i), dims));
      var j = dst.nearestKeyIndex(tk);
      var tin = src.keyInInterpolationType(i), tout = src.keyOutInterpolationType(i);
      if (tin === KeyframeInterpolationType.BEZIER || tout === KeyframeInterpolationType.BEZIER) {
        dst.setTemporalEaseAtKey(j, clampInfluence(src.keyInTemporalEase(i)), clampInfluence(src.keyOutTemporalEase(i)));
      }
      dst.setInterpolationTypeAtKey(j, tin, tout);
      if (src.keyTemporalAutoBezier(i)) dst.setTemporalAutoBezierAtKey(j, true);
      else if (src.keyTemporalContinuous(i)) dst.setTemporalContinuousAtKey(j, true);
      if (spatial && src.isSpatial) {
        dst.setSpatialTangentsAtKey(j, slice(src.keyInSpatialTangent(i), dims), slice(src.keyOutSpatialTangent(i), dims));
        if (src.keySpatialAutoBezier(i)) dst.setSpatialAutoBezierAtKey(j, true);
        else if (src.keySpatialContinuous(i)) dst.setSpatialContinuousAtKey(j, true);
      }
      if (src.keyRoving(i)) dst.setRovingAtKey(j, true);
    }
  }

  function layerTransformToGroup(comp, S, g, t, baked) {
    var tr = g.property("ADBE Vector Transform Group");
    copyChannel(tprop(S, "ADBE Anchor Point"), tr.property("ADBE Vector Anchor"), 2, t, comp, baked);
    var pos = tprop(S, "ADBE Position");
    if (pos.dimensionsSeparated) {
      // One 2D position from the separated X and Y: sampled at their keyframes.
      var x = pos.parentProperty.property("ADBE Position_0"), y = pos.parentProperty.property("ADBE Position_1");
      var times = [], seen = {}, add = function (p) {
        for (var i = 1; i <= p.numKeys; i++) {
          var k = p.keyTime(i).toFixed(6);
          if (!seen[k]) {
            seen[k] = true;
            times.push(p.keyTime(i));
          }
        }
      };
      add(x);
      add(y);
      var gp = tr.property("ADBE Vector Position");
      if (!times.length) gp.setValue([x.valueAtTime(t, true), y.valueAtTime(t, true)]);
      for (var k = 0; k < times.length; k++) gp.setValueAtTime(times[k], [x.valueAtTime(times[k], true), y.valueAtTime(times[k], true)]);
    } else copyChannel(pos, tr.property("ADBE Vector Position"), 2, t, comp, baked);
    copyChannel(tprop(S, "ADBE Scale"), tr.property("ADBE Vector Scale"), 2, t, comp, baked);
    copyChannel(tprop(S, "ADBE Rotate Z"), tr.property("ADBE Vector Rotation"), 1, t, comp, baked);
    copyChannel(tprop(S, "ADBE Opacity"), tr.property("ADBE Vector Group Opacity"), 1, t, comp, baked);
  }

  function mergeLayers(comp, layers, m) {
    var t = comp.time, i, notes = [];
    var parentId, firstParent = true;
    for (i = 0; i < layers.length; i++) {
      var L = layers[i];
      if (L.threeDLayer) fail("Merge: “" + L.name + "” is 3D; shape groups are 2D only.");
      if (L.stretch !== 100) fail("Merge: “" + L.name + "” is time-stretched.");
      var pid = L.parent ? L.parent.id : 0;
      if (firstParent) {
        parentId = pid;
        firstParent = false;
      } else if (pid !== parentId) fail("Merge: the layers need the same parent (or none).");
    }
    // Top to bottom.
    layers = layers.slice().sort(function (a, b) { return a.index - b.index; });
    var start = Infinity, inP = Infinity, outP = -Infinity, labels = {}, best = 0, label = layers[0].label;
    for (i = 0; i < layers.length; i++) {
      start = Math.min(start, layers[i].startTime);
      inP = Math.min(inP, layers[i].inPoint);
      outP = Math.max(outP, layers[i].outPoint);
      var lb = layers[i].label;
      labels[lb] = (labels[lb] || 0) + 1;
      if (labels[lb] > best) {
        best = labels[lb];
        label = lb;
      }
    }
    var target = comp.layers.addShape();
    target.name = uniqueLayerName(comp, "Merged Shape Layer");
    target.moveBefore(layers[0]);
    if (parentId) target.setParentWithJump(layerById(comp, parentId));
    // Identity transform: the target's layer space is its parent's (or the comp's).
    var origin = parentId ? [0, 0] : [comp.width / 2, comp.height / 2];
    tprop(target, "ADBE Anchor Point").setValue(origin);
    tprop(target, "ADBE Position").setValue(origin);
    if (Math.abs(target.startTime - start) > 1e-9) target.startTime = start;
    target.inPoint = inP;
    target.outPoint = outP;
    try { target.label = label; } catch (e) {}
    var root = target.property("ADBE Root Vectors Group");
    var effects = 0, masks = 0, blends = 0, baked = [];
    for (i = 0; i < layers.length; i++) {
      var S = layers[i];
      var g = root.addProperty("ADBE Vector Group");
      g.name = S.name;
      var stree = layerTree(comp, S, t), scont = childByMatch(stree.properties, "contents"), uids = [];
      for (var k = 0; scont && k < scont.children.length; k++) uids.push(scont.children[k].uid);
      if (uids.length) copyItemsInto(comp, S, uids, target, g.property("ADBE Vectors Group"));
      // Keyframes inside the copies are in the source's layer time.
      var shift = S.startTime - target.startTime;
      if (Math.abs(shift) > 1e-9) {
        var gnode = findNode(layerTree(comp, target, t).properties, g.__uid, 0);
        var refs = keysUnder(childByMatch(gnode, "contents"), target.id, [], 0);
        if (refs.length) app.run("keys.move", { comp: comp.id, delta: shift, __keys: refs });
      }
      layerTransformToGroup(comp, S, g, t, baked);
      var fx = childByMatch(stree.properties, "effects"), mk = childByMatch(stree.properties, "masks");
      if (fx && fx.children && fx.children.length) effects++;
      if (mk && mk.children && mk.children.length) masks++;
      if (S.blendingMode !== BlendingMode.NORMAL) blends++;
    }
    for (i = 0; i < layers.length; i++) {
      if (m.alt) layers[i].enabled = false;
      else layers[i].remove();
    }
    selectLayers(comp, [target]);
    if (effects) notes.push(plural(effects, "layer") + "' effects");
    if (masks) notes.push(plural(masks, "layer") + "' masks");
    if (blends) notes.push("blending modes");
    return "Merged " + plural(layers.length, "layer") + " into “" + target.name + "”" + (m.alt ? " (sources hidden)" : "") + "." +
      (baked.length ? " Curved motion baked to one key per frame." : "") +
      (notes.length ? " Not carried over: " + notes.join(", ") + "." : "");
  }

  function mergeGroups(comp, groups) {
    var L = groups[0].__layer, i;
    for (i = 1; i < groups.length; i++) if (groups[i].__layer.id !== L.id) fail("Merge: select groups of one shape layer.");
    var parent = groups[0].parentProperty;
    for (i = 1; i < groups.length; i++) {
      if (groups[i].parentProperty.__uid !== parent.__uid) fail("Merge: the groups need to be side by side (same parent group).");
    }
    var tree = layerTree(comp, L, comp.time), pnode = findNode(tree.properties, parent.__uid, 0), uids = [], top = Infinity;
    for (i = 0; i < pnode.children.length; i++) {
      for (var j = 0; j < groups.length; j++) {
        if (pnode.children[i].uid === groups[j].__uid) {
          uids.push(groups[j].__uid);
          top = Math.min(top, i + 1);
        }
      }
    }
    var g = parent.addProperty("ADBE Vector Group");
    g.name = "Merged Group";
    g.moveTo(top);
    copyItemsInto(comp, L, uids, L, g.property("ADBE Vectors Group"));
    removeItems(comp, L, uids);
    return "Merged " + plural(uids.length, "group") + " into one group in “" + L.name + "”.";
  }

  function doMerge(m) {
    return act("Merge shapes", function (comp) {
      var groups = selectedShapeGroups(comp, GROUP_ONLY);
      if (groups.length >= 2) return mergeGroups(comp, groups);
      var layers = comp.selectedLayers, shapes = [];
      for (var i = 0; i < layers.length; i++) if (layers[i] instanceof ShapeLayer) shapes.push(layers[i]);
      if (shapes.length < 2) fail("Select 2+ shape layers (or 2+ groups in one) to merge.");
      return mergeLayers(comp, shapes, m);
    });
  }

  // ----------------------------------------------------------------------------- vectors and artboards

  // The plain axis-aligned rectangle an item draws (in the space `m` maps to), or null.
  function rectangleOf(item, m) {
    var corners = null, contents;
    if (item.match === "rect") {
      if (Number(val(item, "roundness", 0)) > 0) return null;
      corners = rectCorners(item);
    } else if (item.match === "path") {
      var pv = pathValue(val(item, "path", null));
      if (!pv || !pv.closed) return null;
      var vs = pv.vertices, n = vs.length;
      if (n === 5 && Math.abs(vs[4][0] - vs[0][0]) < 1e-6 && Math.abs(vs[4][1] - vs[0][1]) < 1e-6) n = 4;
      if (n !== 4) return null;
      for (var i = 0; i < 4; i++) {
        var a = (pv.in_tangents || [])[i] || [0, 0], b = (pv.out_tangents || [])[i] || [0, 0];
        if (Math.abs(a[0]) + Math.abs(a[1]) + Math.abs(b[0]) + Math.abs(b[1]) > 1e-6) return null;
      }
      corners = vs.slice(0, 4);
    } else if (item.match === "group") {
      contents = childByMatch(item, "contents");
      var tr = childByMatch(item, "transform"), shape = null;
      for (var k = 0; contents && k < contents.children.length; k++) {
        var c = contents.children[k];
        if (c.enabled === false) continue;
        if (c.match === "group" || PRIMITIVES[c.match]) {
          if (shape) return null;
          shape = c;
        } else if (WARPING[c.match] || c.match === "merge" || c.match === "trim" || c.match === "round") return null;
      }
      return shape ? rectangleOf(shape, tr ? aMul(m, groupMatrix(tr)) : m) : null;
    } else return null;
    var pts = [], box = new Box();
    for (var q = 0; q < 4; q++) {
      pts.push(aApply(m, corners[q]));
      box.add(pts[q]);
    }
    // Axis-aligned after the transforms: every corner on the box's edges.
    for (q = 0; q < 4; q++) {
      var onX = Math.abs(pts[q][0] - box.x0) < 1e-3 || Math.abs(pts[q][0] - box.x1) < 1e-3;
      var onY = Math.abs(pts[q][1] - box.y0) < 1e-3 || Math.abs(pts[q][1] - box.y1) < 1e-3;
      if (!onX || !onY) return null;
    }
    return box;
  }

  // Remove a converted vector's artboard: rectangle masks around the artwork (where Create
  // Shapes from Vector Layer puts an artboard clip) and a background rectangle at the bottom
  // of Contents that encloses everything else.
  function removeArtboards(comp, L) {
    var t = comp.time, tree = layerTree(comp, L, t);
    var contents = childByMatch(tree.properties, "contents"), masks = childByMatch(tree.properties, "masks");
    var items = 0, maskCount = 0, i;
    // The background sits at the bottom of the artwork: of Contents, or of the group that
    // wraps everything (an Illustrator layer).
    var cont = contents, m = I2, guard = 0;
    while (cont && cont.children && guard++ < 16) {
      var drawn = [];
      for (i = 0; i < cont.children.length; i++) {
        var it = cont.children[i];
        if (it.enabled !== false && (it.match === "group" || PRIMITIVES[it.match])) drawn.push(it);
      }
      if (!drawn.length) break;
      var bg = drawn[drawn.length - 1], r = drawn.length > 1 ? rectangleOf(bg, m) : null;
      if (r) {
        var rest = new Box(), rflags = {};
        contentsBounds(cont, m, rest, rflags, 0, bg.uid);
        if (!rest.empty() && !rflags.warped && r.contains(rest, 0.5)) {
          removeItems(comp, L, [bg.uid]);
          items++;
          break;
        }
      }
      if (drawn.length !== 1 || drawn[0].match !== "group") break;
      var tr = childByMatch(drawn[0], "transform");
      if (tr) m = aMul(m, groupMatrix(tr));
      cont = childByMatch(drawn[0], "contents");
    }
    if (masks && masks.children && masks.children.length) {
      var art = new Box(), aflags = {};
      if (contents) contentsBounds(childByMatch(layerTree(comp, L, t).properties, "contents"), I2, art, aflags, 0);
      // With warped or unmeasured artwork, the engine's own bounds (strokes included).
      if (aflags.warped) {
        var sr = L.sourceRectAtTime(t, false);
        art = new Box();
        if (sr && (sr.width > 0 || sr.height > 0)) {
          art.add([sr.left, sr.top]);
          art.add([sr.left + sr.width, sr.top + sr.height]);
        }
      }
      for (i = 0; i < masks.children.length; i++) {
        var mk = masks.children[i], info = mk.mask || {};
        if (String(info.mode || "").toLowerCase() === "none" || info.inverted) continue;
        var pv = pathValue(val(mk, "path", null));
        var shapeItem = { match: "path", children: [{ match: "path", value: pv ? { t: "Path", v: pv } : null }] };
        var mr = pv ? rectangleOf(shapeItem, I2) : null;
        if (mr && (art.empty() || mr.contains(art, 0.5))) {
          app.run("layer.mask.remove", { comp: comp.id, layer: L.id, mask: mk.uid });
          maskCount++;
        }
      }
    }
    return { items: items, masks: maskCount };
  }

  function removeEmptyGroups(comp, L) {
    var tree = layerTree(comp, L, comp.time), kill = [];
    (function walk(contents, depth) {
      if (!contents || !contents.children || depth > 32) return;
      for (var i = 0; i < contents.children.length; i++) {
        var it = contents.children[i];
        if (it.match !== "group") continue;
        var inner = childByMatch(it, "contents");
        if (!hasDrawable(inner, 0)) kill.push(it.uid);
        else walk(inner, depth + 1);
      }
    })(childByMatch(tree.properties, "contents"), 0);
    removeItems(comp, L, kill);
    return kill.length;
  }

  function selectedShapeLayers(comp) {
    var layers = comp.selectedLayers, out = [];
    for (var i = 0; i < layers.length; i++) if (layers[i] instanceof ShapeLayer && !layers[i].locked) out.push(layers[i]);
    return out;
  }

  function doRemoveArtboards(m) {
    return act(m.alt ? "Remove empty groups" : "Remove artboards", function (comp) {
      var layers = selectedShapeLayers(comp), i;
      if (!layers.length) fail("Select shape layers first.");
      if (m.alt) {
        var n = 0;
        for (i = 0; i < layers.length; i++) n += removeEmptyGroups(comp, layers[i]);
        return n ? "Removed " + plural(n, "empty group") + "." : "No empty groups found.";
      }
      var items = 0, masks = 0;
      for (i = 0; i < layers.length; i++) {
        var r = removeArtboards(comp, layers[i]);
        items += r.items;
        masks += r.masks;
      }
      if (!items && !masks) return "No artboard found (no rectangle around the whole artwork).";
      return "Removed " + (items ? plural(items, "artboard rectangle") : "") + (items && masks ? " and " : "") + (masks ? plural(masks, "artboard mask") : "") + ".";
    });
  }

  function doConvert(m) {
    return act("Convert to shapes", function (comp) {
      var layers = comp.selectedLayers, made = [], skipped = [], cleaned = 0, i;
      for (i = 0; i < layers.length; i++) {
        var L = layers[i];
        if (!(L instanceof AVLayer) || L instanceof ShapeLayer || L instanceof TextLayer || !(L.source instanceof FootageItem)) continue;
        var r;
        try {
          r = app.run("layer.create", { op: "shapesFromVector", comp: comp.id, layers: [L.id] });
        } catch (e) {
          skipped.push(L.name);
          continue;
        }
        var ids = (r && r.layers) || [];
        for (var k = 0; k < ids.length; k++) {
          var nl = layerById(comp, ids[k]);
          if (!nl) continue;
          made.push(nl);
          if (m.alt || m.shift) {
            var a = removeArtboards(comp, nl);
            if (a.items + a.masks) cleaned++;
          }
        }
        if (m.alt && ids.length) L.remove();
      }
      if (!made.length) fail(skipped.length ? "Not vector footage: " + skipped.join(", ") + "." : "Select vector footage layers (SVG, PDF, AI or EPS) first.");
      selectLayers(comp, made);
      var msg = "Converted " + plural(made.length, "shape layer") + (m.alt ? ", sources deleted" : "") + ".";
      if (m.alt || m.shift) msg += cleaned ? " Artboard removed from " + plural(cleaned, "layer") + "." : " No artboard found.";
      if (skipped.length) msg += " Skipped: " + skipped.join(", ") + ".";
      return msg;
    });
  }

  // ----------------------------------------------------------------------------- drawing

  function lin(v) { return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }
  // An sRGB colour for ScriptUIGraphics (EffectCraft takes pen and brush colours as linear).
  function rgb(r, g, b, a) { return [lin(r), lin(g), lin(b), a === undefined ? 1 : a]; }
  var ACCENT = rgb(0.6, 0.45, 1.0);
  var ACCENT_DIM = rgb(0.6, 0.45, 1.0, 0.45);
  var GREY = rgb(0.55, 0.55, 0.55, 0.9);

  function pen(g, c, w) { return g.newPen(g.PenType.SOLID_COLOR, c || "text", w || 1); }
  function brush(g, c) { return g.newBrush(g.BrushType.SOLID_COLOR, c); }
  function poly(g, pts, close) {
    g.newPath();
    g.moveTo(pts[0][0], pts[0][1]);
    for (var i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
    if (close) g.closePath();
  }

  // A button that keeps EffectCraft's look (hover, press) with an icon drawn over it.
  function iconButton(parent, w, h, text, draw, tip) {
    var b = parent.add("button", undefined, text || "");
    b.preferredSize = [w, h];
    b.helpTip = tip || "";
    b.onDraw = function () {
      var g = this.graphics, s = this.size;
      g.drawOSControl();
      draw(g, s[0], s[1]);
    };
    return b;
  }
  function onClick(btn, fn) {
    btn.addEventListener("click", function (e) { fn(mods(e)); });
  }

  function drawAnchor(fx, fy) {
    return function (g, w, h) {
      var s = 12, x = Math.round((w - s) / 2) + 0.5, y = Math.round((h - s) / 2) + 0.5;
      g.newPath();
      g.rectPath(x, y, s, s);
      g.strokePath(pen(g, GREY, 1));
      var d = 6;
      g.newPath();
      g.ellipsePath(x + fx * s - d / 2, y + fy * s - d / 2, d, d);
      g.fillPath(brush(g, ACCENT));
    };
  }
  function drawKey(kind) {
    return function (g, w, h) {
      var cx = w / 2, cy = h / 2, r = 5;
      g.newPath();
      if (kind === "hold") g.rectPath(cx - 4.5, cy - 4.5, 9, 9);
      else if (kind === "linear") poly(g, [[cx, cy - r - 1], [cx + r + 1, cy], [cx, cy + r + 1], [cx - r - 1, cy]], true);
      else g.ellipsePath(cx - r, cy - r, 2 * r, 2 * r);
      g.fillPath(brush(g, ACCENT));
    };
  }
  function drawElastic(g, w, h) {
    var x0 = w - 34, cy = h / 2, pts = [];
    for (var i = 0; i <= 12; i++) {
      var x = i * 2.2;
      pts.push([x0 + x, cy - 7 * Math.exp(-x / 9) * Math.sin(x * 0.75)]);
    }
    poly(g, pts, false);
    g.strokePath(pen(g, ACCENT, 1.5));
  }
  function drawBounce(g, w, h) {
    var x0 = w - 34, base = h / 2 + 5, pts = [], spans = [[0, 11, 10], [11, 19, 6], [19, 25, 3]];
    for (var s = 0; s < spans.length; s++) {
      var a = spans[s][0], b = spans[s][1], hh = spans[s][2];
      for (var i = 0; i <= 6; i++) {
        var u = i / 6, x = a + (b - a) * u;
        pts.push([x0 + x, base - hh * 4 * u * (1 - u)]);
      }
    }
    poly(g, pts, false);
    g.strokePath(pen(g, ACCENT, 1.5));
  }
  function drawClone(g, w, h) {
    var cy = h / 2, x0 = w - 32;
    for (var i = 0; i < 3; i++) {
      var cx = x0 + i * 9 + 3;
      poly(g, [[cx, cy - 3.5], [cx + 3.5, cy], [cx, cy + 3.5], [cx - 3.5, cy]], true);
      g.fillPath(brush(g, i === 0 ? ACCENT : ACCENT_DIM));
    }
  }
  function drawArrange(g, w, h) {
    var x = Math.round(w / 2 - 6) + 0.5, y = h / 2 - 7;
    poly(g, [[x, y - 1], [x, y + 15]], false);
    g.strokePath(pen(g, ACCENT, 1.5));
    var lens = [10, 7, 12];
    for (var i = 0; i < 3; i++) {
      g.newPath();
      g.rectPath(x + 2, y + 1 + i * 5, lens[i], 3);
      g.fillPath(brush(g, GREY));
    }
  }
  function drawOrder(g, w, h) {
    var x = Math.round(w / 2 - 7), y = h / 2 - 7, offs = seqOrder === "top" ? [0, 4, 8] : seqOrder === "bottom" ? [8, 4, 0] : [4, 0, 8];
    for (var i = 0; i < 3; i++) {
      g.newPath();
      g.rectPath(x + offs[i], y + 1 + i * 5, 7, 3);
      g.fillPath(brush(g, ACCENT));
    }
  }

  // A floating window grows 4 px at the bottom for every control EffectCraft draws itself
  // (each sits in an egui scope); a docked panel only scrolls. Take it back with the bottom
  // margin so the window ends `bottom` px below its last control.
  var NATIVE = { button: 1, iconbutton: 1, edittext: 1, dropdownlist: 1, listbox: 1, checkbox: 1, radiobutton: 1, slider: 1, scrollbar: 1 };
  function fitFloating(w, bottom) {
    var n = 0;
    (function walk(c, depth) {
      if (!c || c.visible === false || depth > 32) return;
      if (NATIVE[c.type]) n++;
      for (var i = 0; i < (c.children || []).length; i++) walk(c.children[i], depth + 1);
    })(w, 0);
    var m = w.margins;
    w.margins = [m[0], m[1], m[2], bottom - 4 * n];
  }

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

  var HELP = [
    MK.name + " " + MK.version + " for EffectCraft",
    "A clean-room take on the Motion Tools 2 workflow.",
    "",
    "EASING  (acts on the selected keyframes)",
    "  Out / In / Both: drag or type an influence (0-100%).",
    "    0 sets that side to Linear.",
    "  Square: Hold.  Diamond: Linear.  Circle: Bezier.",
    "    Alt+click: incoming side only.  Shift+click: outgoing only.",
    "",
    "ANCHOR GRID  (selected layers, or selected shape groups)",
    "  Click: move the anchor point to that spot of the content;",
    "    the layer stays where it is. Uses masks when the layer",
    "    has any.  Shift+click: ignore masks.",
    "  Alt+click: add a parent null at that spot of the selection.",
    "",
    "ELASTIC / BOUNCE  (selected animated properties or their keys)",
    "  Click: one shared control set, on the first property's layer.",
    "  Shift+click: a control set for every property.",
    "  Alt+click: put the controls on a new controller null.",
    "  Elastic controls: Amplitude, Frequency (Hz), Decay.",
    "  Bounce controls: Elasticity (%), Time (s, the first bounce),",
    "    Count, Overshoot (bounce past the keyframe instead of back).",
    "  The control effects are switched off (fx): they still drive",
    "  the expression and cost no render time.",
    "",
    "CLONE  (selected keyframes)",
    "  Click: copy the selected keys to the playhead.",
    "  Alt+click: and select the copies.  Shift+click: reversed.",
    "",
    "SEQUENCE  (selected layers, or keys on several properties)",
    "  Offset: frames between each one (negative works too).",
    "  Step 2: they move two at a time.",
    "  Alt+click: Step repeats instead (3 = 0, 1, 2, 0, 1, 2 ...).",
    "  Line-up button: all to the playhead.  Alt: to the comp start.",
    "    Shift: to the first one.",
    "  Order button: top down, bottom up or random.",
    "",
    "SHAPES",
    "  Extract: one shape layer per group (or per selected group).",
    "    Click deletes the source; Alt+click keeps it.",
    "  Merge: 2+ shape layers into one (each becomes a group with",
    "    the layer's transform). Alt+click hides the sources instead",
    "    of deleting them. 2+ groups of one layer: grouped together.",
    "  Add Null: a parent null at the centre of the selection.",
    "  Convert to Shape: vector footage (SVG, PDF, AI, EPS) to shape",
    "    layers.  Shift: and remove artboards.",
    "    Alt: remove artboards and delete the sources.",
    "  Remove Artboard: the rectangle masks or background",
    "    rectangle around the whole artwork.",
    "    Alt+click: remove empty groups instead.",
    "",
    "Every tool is one step in Edit > Undo. Clone and Merge go",
    "through EffectCraft's clipboard, so they replace what you copied."
  ];

  function showHelp() {
    var w = new Window("palette", MK.name + " Help");
    w.orientation = "column";
    w.alignChildren = ["fill", "top"];
    w.margins = [12, 12, 12, 12];
    var list = w.add("listbox", undefined, HELP);
    list.preferredSize = [440, 360];
    var close = w.add("button", undefined, "Close");
    close.alignment = ["right", "top"];
    close.preferredSize = [80, 24];
    close.onClick = function () { w.close(); };
    fitFloating(w, 12);
    w.show();
  }

  // ----------------------------------------------------------------------------- panel

  function buildUI(w) {
    w.orientation = "column";
    w.alignChildren = ["fill", "top"];
    w.spacing = 6;
    w.margins = [10, 10, 10, 8];

    // Header: name on the left, About on the right.
    var head = w.add("group");
    head.orientation = "stack";
    head.alignChildren = ["fill", "center"];
    var title = head.add("statictext", undefined, "MOTION KIT");
    title.preferredSize = [150, 22];
    title.alignment = ["left", "center"];
    title.onDraw = function () {
      var g = this.graphics;
      // EffectCraft draws script text in its regular face: twice, a hair apart, reads as bold.
      var f = ScriptUI.newFont("dialog", "BOLD", 14), p = pen(g, ACCENT);
      g.drawString("MOTION KIT", p, 0, 2, f);
      g.drawString("MOTION KIT", p, 0.6, 2, f);
      g.drawString("v" + MK.version, pen(g, GREY), 98, 6, ScriptUI.newFont("dialog", "REGULAR", 10));
    };
    var about = head.add("button", undefined, "About");
    about.preferredSize = [62, 22];
    about.alignment = ["right", "center"];
    about.helpTip = "What each button does, with its Alt / Shift clicks.";
    about.onClick = showHelp;

    // Easing.
    var ease = w.add("panel", undefined, "Easing");
    ease.alignChildren = ["center", "top"];
    ease.spacing = 4;
    ease.margins = [8, 16, 8, 8];
    var rows = [
      ["out", "Out", "hold", "Ease out: influence of the outgoing side of the selected keys.", "Hold keys. Alt+click: incoming side only. Shift+click: outgoing only."],
      ["in", "In", "linear", "Ease in: influence of the incoming side of the selected keys.", "Linear keys. Alt+click: incoming side only. Shift+click: outgoing only."],
      ["both", "Both", "bezier", "Easy ease: influence of both sides of the selected keys.", "Bezier keys. Alt+click: incoming side only. Shift+click: outgoing only."]
    ];
    ui.ease = {};
    for (var r = 0; r < rows.length; r++) {
      (function (row) {
        var which = row[0], amount = intSetting("ease_" + which, 33, 0, 100);
        var g = ease.add("group");
        g.spacing = 6;
        var label = g.add("statictext", undefined, row[1]);
        label.preferredSize = [34, 18];
        var slider = g.add("slider", undefined, amount, 0, 100);
        slider.preferredSize = [136, 22];
        slider.helpTip = row[3] + " 0 = Linear.";
        var field = g.add("edittext", undefined, String(amount));
        field.preferredSize = [40, 22];
        field.helpTip = row[3];
        var key = iconButton(g, 30, 22, "", drawKey(row[2]), row[4]);
        slider.onChanging = function () { field.text = String(Math.round(this.value)); };
        slider.onChange = function () {
          var v = Math.round(this.value);
          field.text = String(v);
          saveSetting("ease_" + which, v);
          applyEase(which, v);
        };
        field.onChange = function () {
          var v = parseInt(this.text, 10);
          if (isNaN(v)) {
            this.text = String(Math.round(slider.value));
            status("Type a number from 0 to 100.");
            return;
          }
          v = clampInt(v, 0, 100);
          this.text = String(v);
          slider.value = v;
          saveSetting("ease_" + which, v);
          applyEase(which, v);
        };
        onClick(key, function (m) { applyInterpolation(row[2], m); });
        ui.ease[which] = { slider: slider, field: field };
      })(rows[r]);
    }

    // Anchor grid and the motion tools.
    var mid = w.add("panel", undefined, "Anchor & Motion");
    mid.orientation = "row";
    mid.alignChildren = ["center", "center"];
    mid.spacing = 14;
    mid.margins = [8, 16, 8, 8];
    var grid = mid.add("group");
    grid.orientation = "column";
    grid.spacing = 3;
    for (var gr = 0; gr < 3; gr++) {
      var line = grid.add("group");
      line.spacing = 3;
      for (var gc = 0; gc < 3; gc++) {
        (function (i) {
          var b = iconButton(line, 26, 26, "", drawAnchor(GRID[i][0], GRID[i][1]),
            "Anchor point to the " + GRID_NAMES[i] + " (layer stays put). Shift+click: ignore masks. Alt+click: add a parent null there.");
          onClick(b, function (m) { anchorClick(i, m); });
        })(gr * 3 + gc);
      }
    }
    var motion = mid.add("group");
    motion.orientation = "column";
    motion.alignChildren = ["left", "center"];
    motion.spacing = 4;
    var elastic = iconButton(motion, 150, 26, "Elastic", drawElastic,
      "Overshoot after each keyframe of the selected properties. Shift+click: controls per property. Alt+click: controls on a new null.");
    onClick(elastic, function (m) { applyOvershoot("elastic", m); });
    var bounce = iconButton(motion, 150, 26, "Bounce", drawBounce,
      "Bounce after each keyframe of the selected properties. Shift+click: controls per property. Alt+click: controls on a new null.");
    onClick(bounce, function (m) { applyOvershoot("bounce", m); });
    var clone = iconButton(motion, 150, 26, "Clone", drawClone,
      "Copy the selected keys to the playhead. Alt+click: select the copies. Shift+click: reversed.");
    onClick(clone, function (m) { cloneKeys(m); });

    // Sequence.
    var seq = w.add("panel", undefined, "Sequence");
    seq.orientation = "row";
    seq.alignChildren = ["center", "bottom"];
    seq.spacing = 6;
    seq.margins = [8, 16, 8, 8];
    var seqBtn = seq.add("button", undefined, "Sequence");
    seqBtn.preferredSize = [86, 24];
    seqBtn.helpTip = "Stagger the selected layers (or the keys of the selected properties) by Offset frames. Step 2 moves them two at a time; Alt+click: Step repeats (0, 1, 2, 0, 1, 2...).";
    onClick(seqBtn, doSequence);
    var arrange = iconButton(seq, 30, 24, "", drawArrange,
      "Line them up: all start at the playhead. Alt+click: at the comp start. Shift+click: at the first one.");
    onClick(arrange, doArrange);
    var order = iconButton(seq, 30, 24, "", drawOrder, ORDER_TIPS[seqOrder]);
    order.addEventListener("click", function () {
      seqOrder = ORDERS[(ORDERS.indexOf(seqOrder) + 1) % ORDERS.length];
      saveSetting("order", ORDERS.indexOf(seqOrder));
      order.helpTip = ORDER_TIPS[seqOrder];
      status(ORDER_TIPS[seqOrder].replace(" Click to change.", ""));
    });
    var fieldCol = function (label, key, def, tip) {
      var c = seq.add("group");
      c.orientation = "column";
      c.alignChildren = ["left", "top"];
      c.spacing = 2;
      c.add("statictext", undefined, label);
      var f = c.add("edittext", undefined, String(intSetting(key, def, -9999, 9999)));
      f.preferredSize = [46, 22];
      f.helpTip = tip;
      f.onChange = function () {
        var v = parseInt(this.text, 10);
        if (isNaN(v) || (key === "step" && v < 1)) {
          this.text = String(intSetting(key, def, -9999, 9999));
          status(key === "step" ? "Step: a whole number, 1 or more." : "Offset: a whole number of frames.");
          return;
        }
        this.text = String(v);
        saveSetting(key, v);
      };
      return f;
    };
    ui.offset = fieldCol("Offset", "offset", 1, "Frames between each layer (or property) in the sequence.");
    ui.step = fieldCol("Step", "step", 1, "How many move together (Alt+click Sequence: how many before the pattern repeats).");

    // Shapes and layers.
    var shapes = w.add("panel", undefined, "Shapes & Layers");
    shapes.alignChildren = ["center", "top"];
    shapes.spacing = 4;
    shapes.margins = [8, 16, 8, 8];
    var r1 = shapes.add("group");
    r1.spacing = 6;
    var addBtn = function (parent, text, wd, tip, fn) {
      var b = parent.add("button", undefined, text);
      b.preferredSize = [wd, 24];
      b.helpTip = tip;
      onClick(b, fn);
      return b;
    };
    addBtn(r1, "Extract", 86, "One shape layer per group of the selected shape layers (or per selected group). Alt+click: keep the source.", doExtract);
    addBtn(r1, "Merge", 86, "Merge the selected shape layers into one (or the selected groups into one group). Alt+click: hide the sources instead of deleting them.", doMerge);
    addBtn(r1, "Add Null", 86, "A parent null at the centre of the selected layers. (Alt+click the anchor grid for another spot.)", function () { addParentNull(0.5, 0.5, ""); });
    var r2 = shapes.add("group");
    r2.spacing = 6;
    addBtn(r2, "Convert to Shape", 132, "Vector footage (SVG, PDF, AI, EPS) to shape layers. Shift+click: and remove artboards. Alt+click: remove artboards and delete the sources.", doConvert);
    addBtn(r2, "Remove Artboard", 132, "Remove the artboard rectangle (masks or background) around the artwork of the selected shape layers. Alt+click: remove empty groups.", doRemoveArtboards);

    ui.status = w.add("statictext", undefined, "Ready.");
    ui.status.preferredSize = [270, 48];
  }

  // ----------------------------------------------------------------------------- self-test hook

  // The tools with explicit modifier flags, for MotionKit_SelfTest_EffectCraft.jsx. Each returns
  // the message the panel shows.
  $.global.MotionKitAPI = {
    version: MK.version,
    ease: function (which, amount) { return applyEase(which, amount); },
    interpolation: function (kind, m) { return applyInterpolation(kind, m || {}); },
    anchor: function (i, m) { return anchorClick(i, m || {}); },
    addNull: function () { return addParentNull(0.5, 0.5, ""); },
    elastic: function (m) { return applyOvershoot("elastic", m || {}); },
    bounce: function (m) { return applyOvershoot("bounce", m || {}); },
    clone: function (m) { return cloneKeys(m || {}); },
    sequence: function (offset, step, order, m) {
      ui.offset.text = String(offset);
      ui.step.text = String(step);
      seqOrder = order || "top";
      return doSequence(m || {});
    },
    arrange: function (m) { return doArrange(m || {}); },
    extract: function (m) { return doExtract(m || {}); },
    merge: function (m) { return doMerge(m || {}); },
    convert: function (m) { return doConvert(m || {}); },
    removeArtboard: function (m) { return doRemoveArtboards(m || {}); }
  };

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

  var win = thisObj instanceof Panel ? thisObj : null;
  if (!win) {
    try {
      if ($.global.__motionKitWindow) $.global.__motionKitWindow.close();
    } catch (e) {}
    win = new Window("palette", MK.name, undefined, { resizeable: true });
    $.global.__motionKitWindow = win;
  }
  buildUI(win);
  if (win instanceof Window) {
    win.preferredSize = [326, -1];
    fitFloating(win, 8);
  }
  win.onResizing = win.onResize = function () { this.layout.resize(); };
  win.layout.layout(true);
  if (win instanceof Window) win.show();
})(this);
