// ============================================================
// MotionPotion Repeater Baker  —  free After Effects script
// ------------------------------------------------------------
// Turns a shape layer's Repeater into real, separate shape groups you can edit
// and animate one by one. The Repeater itself is removed.
//
// Each copy k (power n = Offset + k) gets its own group transform:
//
//     Anchor   = Repeater Anchor
//     Position = Repeater Anchor + n · Repeater Position
//     Scale    = Repeater Scale ^ n
//     Rotation = n · Repeater Rotation
//     Opacity  = lerp(Start, End) across the copies
//
// That is exactly what AE draws: the copies rotate and scale around the
// Repeater's anchor while Position steps LINEARLY (n·P). It does not accumulate
// through the rotation — that would spiral. An off-centre anchor is what swings
// the copies into an arc; anchor [0,0] with Position + Rotation gives a straight
// row of spinning copies.
//
// Baked at the current time. An animated Repeater is flattened to this frame
// (and says so). Animated Copies is refused — the count would have to change.
//
// Install (either works):
//   • Dockable panel: put this .jsx in After Effects'  ScriptUI Panels  folder,
//     then open it from  Window > MotionPotion-RepeaterBaker.jsx
//   • One-off:  File > Scripts > Run Script File…  and pick this file.
//
// Part of MotionPotion — motionpotion.co
// ============================================================

(function (thisObj) {

  var EPS = 1e-6;
  var MAX_COPIES = 300;          // a runaway Copies value would hang AE
  var MAX_REPEATERS = 50;        // passes per layer — a backstop, not a real limit
  var COMPOSITE_ABOVE = 1;       // "ADBE Vector Repeater Order": 1 = Above, 2 = Below

  var MN = {
    root:    "ADBE Root Vectors Group",
    group:   "ADBE Vector Group",
    kids:    "ADBE Vectors Group",
    xform:   "ADBE Vector Transform Group",
    anchor:  "ADBE Vector Anchor",
    pos:     "ADBE Vector Position",
    scale:   "ADBE Vector Scale",
    skew:    "ADBE Vector Skew",
    skewAx:  "ADBE Vector Skew Axis",
    rot:     "ADBE Vector Rotation",
    op:      "ADBE Vector Group Opacity",
    rep:     "ADBE Vector Filter - Repeater",
    rCopies: "ADBE Vector Repeater Copies",
    rOffset: "ADBE Vector Repeater Offset",
    rOrder:  "ADBE Vector Repeater Order",
    rXform:  "ADBE Vector Repeater Transform",
    rAnchor: "ADBE Vector Repeater Anchor",
    rPos:    "ADBE Vector Repeater Position",
    rScale:  "ADBE Vector Repeater Scale",
    rRot:    "ADBE Vector Repeater Rotation",
    rOpS:    "ADBE Vector Repeater Opacity 1",
    rOpE:    "ADBE Vector Repeater Opacity 2"
  };

  var PREFS = "MotionPotion Repeater Baker";     // AE settings, no file access needed

  function loadPref(key, dflt) {
    try { if (app.settings.haveSetting(PREFS, key)) return app.settings.getSetting(PREFS, key) === "1"; } catch (e) {}
    return dflt;
  }
  function savePref(key, value) {
    try { app.settings.saveSetting(PREFS, key, value ? "1" : "0"); } catch (e) {}
  }

  // ---------- property helpers ----------
  function sub(group, matchName) {
    try { return group.property(matchName) || null; } catch (e) { return null; }
  }

  function propCount(layer, matchName) {
    try {
      var p = layer.property(matchName);
      return p ? p.numProperties : 0;
    } catch (e) { return 0; }
  }

  function animated(prop) {
    return !!prop && (prop.numKeys > 0 || prop.expressionEnabled);
  }

  function valAt(prop, t, dflt) {
    if (!prop) return dflt;
    try { return prop.valueAtTime(t, false); } catch (e) { return dflt; }
  }

  function setVec(prop, v) {
    if (!prop) return;
    var cur = prop.value, nv = [];
    for (var i = 0; i < cur.length; i++) nv[i] = (i < v.length) ? v[i] : cur[i];
    prop.setValue(nv);
  }

  // Add (dx,dy) at every keyframe, or to the static value.
  function offsetVec(prop, dx, dy) {
    if (!prop || prop.expressionEnabled) return false;
    if (prop.numKeys > 0) {
      for (var k = 1; k <= prop.numKeys; k++) {
        var v = prop.keyValue(k), nv = [];
        for (var j = 0; j < v.length; j++) nv[j] = v[j];
        nv[0] += dx; if (nv.length > 1) nv[1] += dy;
        prop.setValueAtTime(prop.keyTime(k), nv);
      }
    } else {
      var v2 = prop.value, nv2 = [];
      for (var j2 = 0; j2 < v2.length; j2++) nv2[j2] = v2[j2];
      nv2[0] += dx; if (nv2.length > 1) nv2[1] += dy;
      prop.setValue(nv2);
    }
    return true;
  }

  function scaleScalar(prop, factor) {
    if (!prop || prop.expressionEnabled) return false;
    if (Math.abs(factor - 1) < EPS) return true;
    if (prop.numKeys > 0) {
      for (var k = 1; k <= prop.numKeys; k++) prop.setValueAtTime(prop.keyTime(k), prop.keyValue(k) * factor);
    } else {
      prop.setValue(prop.value * factor);
    }
    return true;
  }

  // ---------- finding repeaters ----------
  // `path` is the chain of group indices from Contents down to this container, so a
  // copy can be found again in a duplicated layer.
  function collectRepeaters(container, out, depth, path) {
    var n = 0;
    try { n = container.numProperties; } catch (e) { return; }
    for (var i = 1; i <= n; i++) {
      var p;
      try { p = container.property(i); } catch (e2) { continue; }
      if (!p) continue;
      if (p.matchName === MN.rep) out.push({ rep: p, container: container, depth: depth, path: path });
      else if (p.matchName === MN.group) collectRepeaters(sub(p, MN.kids), out, depth + 1, path.concat([i]));
    }
  }

  // ---------- wrapping a bare input in a group ----------
  // A repeater fed by loose shapes ("Rectangle Path 1" + a Fill) has nowhere to put
  // a per-copy transform. Scripting cannot move a property into another group — but
  // it CAN select properties and invoke the same Group Shapes command as Cmd+G.
  var bakeSerial = 0;

  // Indices of every child of C whose name still carries the tag, top to bottom.
  // A duplicate keeps the tag as a prefix ("MP1BAKE 2"), which is how the block is
  // re-found without holding a single reference.
  function findTagged(C, tag) {
    var out = [];
    for (var i = 1; i <= C.numProperties; i++) {
      var p = C.property(i);
      if (p && p.name && p.name.indexOf(tag) === 0) out.push(i);
    }
    return out;
  }

  var groupCmd = null;
  function groupCommandId() {
    if (groupCmd !== null) return groupCmd;
    groupCmd = 0;
    var names = ["Group Shapes", "Group shapes", "Group Shape"];
    for (var i = 0; i < names.length; i++) {
      try {
        var id = app.findMenuCommandId(names[i]);
        if (id) { groupCmd = id; break; }
      } catch (e) {}
    }
    return groupCmd;
  }

  function deselectProperties(comp) {
    var sel = comp.selectedProperties;            // snapshot: we are about to change it
    var list = [];
    for (var i = 0; i < sel.length; i++) list.push(sel[i]);
    for (var j = 0; j < list.length; j++) { try { list[j].selected = false; } catch (e) {} }
  }

  function looseInputs(entry) {
    var out = [];
    for (var i = 1; i < entry.rep.propertyIndex; i++) {
      var p = entry.container.property(i);
      if (p && p.matchName !== MN.group) out.push(p);
    }
    return out;
  }

  // Select this repeater's loose input and let AE group it. Returns false if the
  // command is unavailable (a localised AE names it differently).
  function groupInput(layer, entry, comp) {
    var id = groupCommandId();
    if (!id) return false;

    var loose = looseInputs(entry);
    if (loose.length === 0) return false;

    deselectProperties(comp);
    try { layer.selected = true; } catch (e) {}
    for (var i = 0; i < loose.length; i++) {
      try { loose[i].selected = true; } catch (e2) { return false; }
    }
    try { app.executeCommand(id); } catch (e3) { return false; }
    deselectProperties(comp);
    return true;
  }

  // Menu commands restructure the tree, which can leave property references stale —
  // so group first, re-walk from the layer root, and only then bake.
  function ensureGrouped(layer, comp) {
    for (var pass = 0; pass < 20; pass++) {
      var found = [];
      collectRepeaters(layer.property(MN.root), found, 0, []);
      found.sort(function (a, b) { return b.depth - a.depth; });

      var acted = false;
      for (var i = 0; i < found.length; i++) {
        if (looseInputs(found[i]).length === 0) continue;
        if (!groupInput(layer, found[i], comp)) return;    // bake will refuse and explain
        acted = true;
        break;                                             // indices moved — walk again
      }
      if (!acted) return;
    }
  }

  // ---------- one repeater ----------
  function readRepeater(rep, t) {
    var x = sub(rep, MN.rXform);
    return {
      copiesProp: sub(rep, MN.rCopies),
      offset: valAt(sub(rep, MN.rOffset), t, 0),
      order:  valAt(sub(rep, MN.rOrder), t, 2),
      A:      valAt(sub(x, MN.rAnchor), t, [0, 0]),
      P:      valAt(sub(x, MN.rPos), t, [0, 0]),
      S:      valAt(sub(x, MN.rScale), t, [100, 100]),
      rot:    valAt(sub(x, MN.rRot), t, 0),
      opS:    valAt(sub(x, MN.rOpS), t, 100),
      opE:    valAt(sub(x, MN.rOpE), t, 100),
      isAnimated: animated(sub(rep, MN.rOffset)) || animated(sub(x, MN.rAnchor)) ||
                  animated(sub(x, MN.rPos)) || animated(sub(x, MN.rScale)) ||
                  animated(sub(x, MN.rRot)) || animated(sub(x, MN.rOpS)) || animated(sub(x, MN.rOpE))
    };
  }

  function transformIsIdentityLinear(xf) {
    var s = xf ? sub(xf, MN.scale) : null;
    var r = xf ? sub(xf, MN.rot) : null;
    var k = xf ? sub(xf, MN.skew) : null;
    if (s) { var v = s.value; if (Math.abs(v[0] - 100) > EPS || Math.abs(v[1] - 100) > EPS) return false; }
    if (r && Math.abs(r.value) > EPS) return false;
    if (k && Math.abs(k.value) > EPS) return false;
    return true;
  }

  function geometryAnimated(xf) {
    if (!xf) return false;
    var names = [MN.anchor, MN.pos, MN.scale, MN.skew, MN.skewAx, MN.rot];
    for (var i = 0; i < names.length; i++) if (animated(sub(xf, names[i]))) return true;
    return false;
  }

  function bakeRepeater(entry, comp, report, layerName, blocks) {
    var rep = entry.rep, C = entry.container;
    var t = comp.time;
    var R = readRepeater(rep, t);

    if (animated(R.copiesProp)) {
      report.blocked.push(layerName + " — Copies is animated; the number of copies would have to change over time");
      return 0;
    }
    var copies = Math.round(valAt(R.copiesProp, t, 1));
    if (copies < 1) { report.blocked.push(layerName + " — Copies is 0"); return 0; }
    if (copies > MAX_COPIES) {
      report.blocked.push(layerName + " — " + copies + " copies is over the " + MAX_COPIES + " limit");
      return 0;
    }

    // Everything ABOVE the repeater in the same group is its input — that is how
    // AE's shape operators work.
    var sources = [];
    for (var i = 1; i < rep.propertyIndex; i++) {
      var p = C.property(i);
      if (!p) continue;
      if (p.matchName !== MN.group) {
        // ensureGrouped() should have wrapped this already; it only gets here when
        // AE would not give up its Group Shapes command (a localised build).
        report.blocked.push(layerName + " — the repeater's input is not a group (\"" + p.name +
                            "\") and this After Effects did not accept the Group Shapes command." +
                            " Select the shapes, press Cmd+G, and bake again.");
        return 0;
      }
      sources.push(p);
    }
    if (sources.length === 0) { report.blocked.push(layerName + " — nothing above the repeater to repeat"); return 0; }

    var repLinearIdentity = (Math.abs(R.rot) < EPS &&
                             Math.abs(R.S[0] - 100) < EPS && Math.abs(R.S[1] - 100) < EPS);

    // Check every source before touching anything, so a refusal leaves no half-bake.
    for (var c = 0; c < sources.length; c++) {
      var xf = sub(sources[c], MN.xform);
      if (repLinearIdentity) {
        if (sub(xf, MN.pos) && sub(xf, MN.pos).expressionEnabled) {
          report.blocked.push(layerName + " / " + sources[c].name + " — its Position has an expression");
          return 0;
        }
        continue;                                   // a pure step folds into any transform
      }
      if (!transformIsIdentityLinear(xf) || geometryAnimated(xf)) {
        report.blocked.push(layerName + " / " + sources[c].name +
          " — the group has its own rotation/scale/skew (or an animated transform), which cannot be" +
          " folded with a rotating or scaling repeater without shear. Reset the group transform, or" +
          " group it one level deeper.");
        return 0;
      }
    }

    // Take the repeater out FIRST. Everything it fed sits above it, so no index we
    // are about to use moves — and there is no stale reference to it later.
    var sourceIndices = [];
    for (var si = 0; si < sources.length; si++) sourceIndices.push(sources[si].propertyIndex);
    sources = null;
    rep.remove();

    var made = 0;
    for (var s = sourceIndices.length - 1; s >= 0; s--) {    // bottom-up: earlier indices stay put
      var block = bakeGroupAt(C, sourceIndices[s], R, copies, repLinearIdentity, t);
      made += block.indices.length;
      // Where the copies ended up, for the "into new layers" split. Only the last
      // block written is still index-accurate: baking a group above shifts the ones
      // below it, so run() only splits when a layer produced exactly one block.
      block.path = entry.path;
      blocks.push(block);
    }

    if (R.isAnimated) {
      report.warn.push(layerName + " — the repeater was animated; it was baked at the current frame");
    }
    return made;
  }

  // Replace the group at `index` with `copies` copies of it, each carrying its own
  // per-copy transform.
  //
  // Not one property reference is held across a structural change: AE invalidates
  // references to a group's other children the moment one is added or removed
  // ("Object is invalid"). So the group is tagged with a unique name, and every
  // step re-finds what it needs by that name.
  // Returns { indices, names, path } describing where the copies landed.
  function bakeGroupAt(C, index, R, copies, repLinearIdentity, t) {
    // No trailing digit: AE increments a trailing number when naming a duplicate,
    // which would rewrite the tag out from under us.
    var tag = "MP" + (++bakeSerial) + "BAKE";
    var origName = C.property(index).name;
    C.property(index).name = tag;

    // The whole block shares one own-transform, so read the shift once, up front.
    var xform0 = sub(C.property(index), MN.xform);
    var ownAnchor = valAt(sub(xform0, MN.anchor), t, [0, 0]);
    var ownPos = valAt(sub(xform0, MN.pos), t, [0, 0]);
    var tx = ownPos[0] - ownAnchor[0], ty = ownPos[1] - ownAnchor[1];
    xform0 = null;

    for (var k = 1; k < copies; k++) {
      var src = findTagged(C, tag);                  // fresh lookup every single time
      if (src.length === 0) break;
      C.property(src[0]).duplicate();                // AE names the copy "<tag> 2", still tagged
    }

    var block = findTagged(C, tag);                  // top-to-bottom, whatever order AE chose
    var names = [];
    for (var j = 0; j < block.length; j++) {
      // Composite decides which end of the block is copy 0.
      var kIdx = (R.order === COMPOSITE_ABOVE) ? (block.length - 1 - j) : j;
      var n = R.offset + kIdx;
      var g = C.property(block[j]);
      var cx = sub(g, MN.xform);

      if (repLinearIdentity) {
        // Pure step: add n·P to Position and leave the rest of the transform alone,
        // keyframes and all.
        offsetVec(sub(cx, MN.pos), n * R.P[0], n * R.P[1]);
      } else {
        // copy(x) = A + R(n·rot)·(S^n ⊙ (x + t − A)) + n·P, written as a group transform.
        setVec(sub(cx, MN.anchor), [R.A[0] - tx, R.A[1] - ty]);
        setVec(sub(cx, MN.pos), [R.A[0] + n * R.P[0], R.A[1] + n * R.P[1]]);
        setVec(sub(cx, MN.scale), [Math.pow(R.S[0] / 100, n) * 100, Math.pow(R.S[1] / 100, n) * 100]);
        var rp = sub(cx, MN.rot);
        if (rp) rp.setValue(n * R.rot);
      }

      var lerp = (copies > 1) ? (kIdx / (copies - 1)) : 0;
      var opFactor = (R.opS + (R.opE - R.opS) * lerp) / 100;
      if (Math.abs(opFactor - 1) > EPS) scaleScalar(sub(cx, MN.op), opFactor);

      names[j] = origName + " " + (kIdx + 1);
      g.name = names[j];                             // drops the tag on the way out
    }
    return { indices: block, names: names, path: null };
  }

  // ---------- centring the anchor ----------
  // Each split-off layer inherits the whole set's anchor, which sits wherever the
  // repeater's did — so scaling or rotating a single copy would swing it around a
  // point outside itself. Same trick as MotionPotion Anchor: move the anchor to the
  // layer's own centre and give Position the cancelling offset, through the layer's
  // scale and rotation, so nothing moves on screen.
  function layerPosDelta(layer, dax, day, t) {
    var tr = layer.property("ADBE Transform Group");
    var s = tr.property("ADBE Scale").valueAtTime(t, false);
    var rp = tr.property("ADBE Rotate Z");
    var rot = rp ? rp.valueAtTime(t, false) : 0;
    var sx = s[0] / 100, sy = s[1] / 100;
    var c = Math.cos(rot * Math.PI / 180), sn = Math.sin(rot * Math.PI / 180);
    return [sx * c * dax - sy * sn * day, sx * sn * dax + sy * c * day];
  }

  function centreAnchor(layer, comp) {
    var t = comp.time;
    var tr = layer.property("ADBE Transform Group");
    var ap = tr.property("ADBE Anchor Point");
    var pos = tr.property("ADBE Position");
    if (!ap || !pos) return false;
    if (ap.expressionEnabled || ap.numKeys > 0) return false;      // target would move over time
    if (pos.expressionEnabled) return false;

    var px = pos.dimensionsSeparated ? tr.property("ADBE Position_0") : null;
    var py = pos.dimensionsSeparated ? tr.property("ADBE Position_1") : null;
    if (px && (px.expressionEnabled || py.expressionEnabled)) return false;

    var rect;
    try { rect = layer.sourceRectAtTime(t, false); } catch (e) { return false; }
    if (!rect || (rect.width <= 0 && rect.height <= 0)) return false;

    var cur = ap.value;
    var dax = rect.left + rect.width / 2 - cur[0];
    var day = rect.top + rect.height / 2 - cur[1];
    if (Math.abs(dax) < EPS && Math.abs(day) < EPS) return true;

    if (px) {
      var axis = [[px, 0], [py, 1]];
      for (var a = 0; a < 2; a++) {
        var p = axis[a][0], dim = axis[a][1];
        if (p.numKeys > 0) {
          for (var k = 1; k <= p.numKeys; k++) {
            var tk = p.keyTime(k);
            p.setValueAtTime(tk, p.keyValue(k) + layerPosDelta(layer, dax, day, tk)[dim]);
          }
        } else {
          p.setValue(p.value + layerPosDelta(layer, dax, day, t)[dim]);
        }
      }
    } else if (pos.numKeys > 0) {
      for (var k2 = 1; k2 <= pos.numKeys; k2++) {
        var tk2 = pos.keyTime(k2);
        var d = layerPosDelta(layer, dax, day, tk2);       // evaluated at that key's own time
        var v = pos.keyValue(k2), nv = [];
        for (var j = 0; j < v.length; j++) nv[j] = v[j];
        nv[0] += d[0]; nv[1] += d[1];
        pos.setValueAtTime(tk2, nv);
      }
    } else {
      var d2 = layerPosDelta(layer, dax, day, t);
      var v2 = pos.value, nv2 = [];
      for (var j2 = 0; j2 < v2.length; j2++) nv2[j2] = v2[j2];
      nv2[0] += d2[0]; nv2[1] += d2[1];
      pos.setValue(nv2);
    }

    var na = [];
    for (var i = 0; i < cur.length; i++) na[i] = cur[i];
    na[0] = rect.left + rect.width / 2;
    na[1] = rect.top + rect.height / 2;
    ap.setValue(na);
    return true;
  }

  // ---------- one copy per layer ----------
  // Walk down to the group that holds the copies. The copies are not necessarily at
  // Contents level — a repeater added inside a group (or one whose loose input AE
  // wrapped for us) leaves them one level down.
  function containerAt(layer, path) {
    var cur = layer.property(MN.root);
    for (var i = 0; i < path.length && cur; i++) {
      var g = cur.property(path[i]);
      cur = g ? g.property(MN.kids) : null;
    }
    return cur;
  }

  // AE cannot move a shape group to another layer, so each copy gets there the only
  // way it can: duplicate the whole layer once per copy and, in each duplicate, delete
  // the OTHER copies. Everything else — the ancestor groups and their transforms, any
  // loose shapes, effects, masks — is left exactly as it was on every layer.
  function explodeBlock(layer, comp, block, report) {
    var idx = block.indices;                    // ascending: top to bottom
    if (idx.length < 2) return 0;

    for (var l = 1; l <= comp.numLayers; l++) {
      if (comp.layer(l).parent === layer) {
        report.warn.push(layer.name + " — left as one layer: another layer is parented to it");
        return 0;
      }
    }
    if (propCount(layer, "ADBE Effect Parade") > 0) {
      report.warn.push(layer.name + " — its effects were copied onto every new layer, which is not" +
                       " the same as one effect over the whole set");
    }

    // duplicate() lands directly above the original, so making the top copy's layer
    // first keeps the stacking order
    var uncentred = 0;
    for (var j = 0; j < idx.length; j++) {
      var dup = layer.duplicate();
      var C = containerAt(dup, block.path);
      if (!C) { dup.remove(); report.warn.push(layer.name + " — left as one layer: lost the group on the way"); return 0; }
      for (var k = idx.length - 1; k >= 0; k--) {        // bottom-up: lower indices stay put
        if (k === j) continue;
        var p = C.property(idx[k]);
        if (p) p.remove();
      }
      dup.name = block.names[j];
      // after the siblings are gone, so the bounds are this copy's alone
      if (!centreAnchor(dup, comp)) uncentred++;
    }
    layer.remove();                                      // nothing touches it after this
    if (uncentred) {
      report.warn.push(layer.name + " — " + uncentred + " new layer(s) kept the old anchor" +
                       " (a keyframed anchor or a Position expression)");
    }
    return idx.length;
  }

  // ---------- run ----------
  function run(toLayers) {
    var comp = app.project.activeItem;
    if (!comp || !(comp instanceof CompItem)) { alert("Open a composition first."); return; }
    var sel = comp.selectedLayers;
    if (!sel || sel.length === 0) { alert("Select a shape layer first."); return; }

    var report = { blocked: [], warn: [] };
    var layers = 0, copies = 0, newLayers = 0;

    app.beginUndoGroup("MotionPotion Repeater Baker");
    try {
      for (var i = 0; i < sel.length; i++) {
        var layer = sel[i];
        var root = null;
        try { root = layer.property(MN.root); } catch (e) {}
        if (!root) { report.blocked.push(layer.name + " — not a shape layer"); continue; }
        if (layer.locked) { report.blocked.push(layer.name + " — the layer is locked"); continue; }

        ensureGrouped(layer, comp);      // wrap any loose input, then read the tree fresh

        // One repeater per pass, re-walking from the layer root each time: baking
        // restructures the contents, which invalidates every reference collected
        // before it. Innermost first, so nested repeaters come out right.
        var madeHere = 0, sawOne = false, blocks = [];
        for (var pass = 0; pass < MAX_REPEATERS; pass++) {
          var found = [];
          collectRepeaters(layer.property(MN.root), found, 0, []);
          if (found.length === 0) break;
          sawOne = true;
          found.sort(function (a, b) { return b.depth - a.depth; });

          var made = 0;
          try { made = bakeRepeater(found[0], comp, report, layer.name, blocks); }
          catch (e2) { report.blocked.push(layer.name + " — " + (e2 && e2.message ? e2.message : e2)); }
          if (made === 0) break;         // refused and still there — do not loop on it
          madeHere += made;
        }
        if (!sawOne) { report.blocked.push(layer.name + " — no repeater on this layer"); continue; }
        if (madeHere) {
          layers++;
          copies += madeHere;
          if (toLayers && blocks.length > 1) {
            report.warn.push(layer.name + " — left as one layer: it produced " + blocks.length +
                             " separate sets of copies, and only one can be split");
          } else if (toLayers && blocks.length === 1) {
            try { newLayers += explodeBlock(layer, comp, blocks[0], report); }
            catch (e3) { report.warn.push(layer.name + " — could not split into layers: " +
                                          (e3 && e3.message ? e3.message : e3)); }
          }
        }
      }
    } finally {
      app.endUndoGroup();
    }

    var msg = [];
    if (copies && newLayers) msg.push(copies + " group(s) baked, split into " + newLayers + " layer(s).");
    else if (copies) msg.push(copies + " group(s) baked on " + layers + " layer(s).");
    if (report.warn.length) msg.push("Heads up:\n• " + report.warn.join("\n• "));
    if (report.blocked.length) msg.push("Not baked:\n• " + report.blocked.join("\n• "));
    if (msg.length) alert(msg.join("\n\n"));
  }

  // ---------- UI ----------
  function buildUI(host) {
    var win = (host instanceof Panel) ? host : new Window("palette", "Repeater Baker", undefined, { resizeable: true });
    win.orientation = "column";
    win.alignChildren = ["fill", "top"];
    win.spacing = 3;
    win.margins = 4;

    var cb = win.add("checkbox", undefined, "Into new layers");
    cb.value = loadPref("layers", false);
    cb.helpTip = "Give every baked copy its own shape layer instead of a group inside this one.";
    cb.onClick = function () { savePref("layers", cb.value); };

    var btn = win.add("button", undefined, "Bake");
    btn.preferredSize.height = 22;
    btn.helpTip = "Turn the selected shape layer's repeater into real shape groups.";
    btn.onClick = function () {
      try { run(cb.value); } catch (e) { alert("MotionPotion Repeater Baker\n" + (e && e.message ? e.message : e)); }
    };

    win.layout.layout(true);
    win.layout.resize();
    win.onResizing = win.onResize = function () { this.layout.resize(); };
    return win;
  }

  var ui = buildUI(thisObj);
  if (ui instanceof Window) { ui.center(); ui.show(); }

})(this);
