// ============================================================
// MotionPotion Auto-Crop  —  free After Effects script
// ------------------------------------------------------------
// Crops the active composition to its content. Accounts for ANIMATION:
// it samples each layer's bounding box across the whole timeline and fits
// the comp to the UNION of those boxes, so nothing clips as the animation
// plays. Static layers just contribute their single box.
//
// Install (either works):
//   • Dockable panel: put this .jsx in After Effects'  ScriptUI Panels  folder,
//     then open it from  Window > MotionPotion-AutoCrop.jsx
//   • One-off:  File > Scripts > Run Script File…  and pick this file.
// No file access needed; "Allow Scripts to Write Files" can stay off.
//
// Part of MotionPotion — motionpotion.co
// ============================================================

(function (thisObj) {

  // ---------- math ----------
  function rad(d) { return d * Math.PI / 180; }

  // Affine matrix [A,B,C,D,E,F] maps a point (x,y):  x' = A*x + C*y + E,  y' = B*x + D*y + F
  // Build the matrix that takes a LAYER-space point into its PARENT's space at time t.
  function localMatrix(layer, t) {
    var tr = layer.property("ADBE Transform Group");
    var a = tr.property("ADBE Anchor Point").valueAtTime(t, false);
    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 posP = tr.property("ADBE Position");
    var px, py;
    if (posP.dimensionsSeparated) {
      px = tr.property("ADBE Position_0").valueAtTime(t, false);
      py = tr.property("ADBE Position_1").valueAtTime(t, false);
    } else {
      var p = posP.valueAtTime(t, false);
      px = p[0]; py = p[1];
    }

    var sx = s[0] / 100, sy = s[1] / 100;
    var c = Math.cos(rad(rot)), sn = Math.sin(rad(rot));
    var ax = a[0], ay = a[1];

    // (pt - anchor) * scale, rotate, + position  →  affine coefficients
    var A = sx * c,  C = -sy * sn, E = -ax * sx * c  + ay * sy * sn + px;
    var B = sx * sn, D =  sy * c,  F = -ax * sx * sn - ay * sy * c  + py;
    return [A, B, C, D, E, F];
  }

  // Apply M then P (P ∘ M).
  function composeM(P, M) {
    return [
      P[0] * M[0] + P[2] * M[1],
      P[1] * M[0] + P[3] * M[1],
      P[0] * M[2] + P[2] * M[3],
      P[1] * M[2] + P[3] * M[3],
      P[0] * M[4] + P[2] * M[5] + P[4],
      P[1] * M[4] + P[3] * M[5] + P[5]
    ];
  }

  // Full layer-space → comp-space matrix at time t, composing the parent chain.
  function matrixForLayer(layer, t) {
    var M = localMatrix(layer, t);
    var parent = layer.parent;
    var guard = 0;
    while (parent && guard < 100) {
      M = composeM(localMatrix(parent, t), M);
      parent = parent.parent;
      guard++;
    }
    return M;
  }

  function applyM(M, x, y) { return [M[0] * x + M[2] * y + M[4], M[1] * x + M[3] * y + M[5]]; }

  // ---------- layer selection ----------
  // Note the check is NEGATIVE on purpose: `instanceof AVLayer` is false for shape
  // and text layers in ExtendScript (host objects have no real prototype chain), so
  // testing for AVLayer would throw out most of what a comp is made of. CameraLayer
  // and LightLayer are exact classes, and those do work.
  function isCameraOrLight(layer) {
    try { if (layer instanceof CameraLayer || layer instanceof LightLayer) return true; } catch (e) {}
    try { if (typeof layer.sourceRectAtTime !== "function") return true; } catch (e2) { return true; }
    return false;
  }

  function countsForBounds(layer) {
    if (isCameraOrLight(layer)) return false;        // nothing to measure
    if (!layer.enabled) return false;                // video switch off
    if (layer.nullLayer) return false;               // invisible
    if (layer.guideLayer) return false;              // non-rendering
    if (layer.adjustmentLayer) return false;         // covers the whole comp
    return true;
  }

  function layerIsAnimated(layer) {
    try {
      var tr = layer.property("ADBE Transform Group");
      var names = ["ADBE Anchor Point", "ADBE Position", "ADBE Scale", "ADBE Rotate Z", "ADBE Position_0", "ADBE Position_1"];
      for (var i = 0; i < names.length; i++) {
        var p = tr.property(names[i]);
        if (p && (p.numKeys > 0 || p.expressionEnabled)) return true;
      }
    } catch (e) {}
    return false;
  }

  // ---------- sampling ----------
  function sampleTimes(comp, animate) {
    var times = [];
    if (!animate) { times.push(comp.time); return times; }
    var fd = comp.frameDuration;
    var n = Math.round(comp.duration / fd);
    if (n < 1) n = 1;
    var MAX = 500;                       // cap the work on very long comps
    var step = (n > MAX) ? Math.ceil(n / MAX) : 1;
    for (var i = 0; i <= n; i += step) times.push(i * fd);
    var last = (comp.duration - fd);
    if (last > 0 && times[times.length - 1] < last - 1e-6) times.push(last);
    return times;
  }

  // ---------- bounds ----------
  function computeBounds(layers, times) {
    var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    var counted = 0, animated = 0;

    for (var li = 0; li < layers.length; li++) {
      var layer = layers[li];
      if (!countsForBounds(layer)) continue;
      counted++;
      if (layerIsAnimated(layer)) animated++;

      for (var ti = 0; ti < times.length; ti++) {
        var t = times[ti];
        if (t < layer.inPoint - 1e-6 || t > layer.outPoint + 1e-6) continue;   // only while visible

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

        var L = rect.left, T = rect.top, R = rect.left + rect.width, B = rect.top + rect.height;
        var M = matrixForLayer(layer, t);
        var corners = [[L, T], [R, T], [R, B], [L, B]];
        for (var ci = 0; ci < 4; ci++) {
          var q = applyM(M, corners[ci][0], corners[ci][1]);
          if (q[0] < minX) minX = q[0];
          if (q[1] < minY) minY = q[1];
          if (q[0] > maxX) maxX = q[0];
          if (q[1] > maxY) maxY = q[1];
        }
      }
    }

    if (minX === Infinity) return null;
    return { minX: minX, minY: minY, maxX: maxX, maxY: maxY, counted: counted, animated: animated };
  }

  // ---------- shifting (keep content in place after the resize) ----------
  function shiftVec(prop, dx, dy, warn) {
    if (prop.expressionEnabled) { warn.count++; return; }
    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 v = prop.value, nv = [];
      for (var j = 0; j < v.length; j++) nv[j] = v[j];
      nv[0] += dx; if (nv.length > 1) nv[1] += dy;
      prop.setValue(nv);
    }
  }

  function shiftScalar(prop, d, warn) {
    if (!prop) return;
    if (prop.expressionEnabled) { warn.count++; return; }
    if (prop.numKeys > 0) {
      for (var k = 1; k <= prop.numKeys; k++) prop.setValueAtTime(prop.keyTime(k), prop.keyValue(k) + d);
    } else {
      prop.setValue(prop.value + d);
    }
  }

  function shiftLayer(layer, dx, dy, warn) {
    var tr = layer.property("ADBE Transform Group");
    var pos = tr.property("ADBE Position");
    if (pos.dimensionsSeparated) {
      shiftScalar(tr.property("ADBE Position_0"), dx, warn);
      shiftScalar(tr.property("ADBE Position_1"), dy, warn);
    } else {
      shiftVec(pos, dx, dy, warn);
    }
  }

  // ---------- run ----------
  function run(opts) {
    var comp = app.project.activeItem;
    if (!comp || !(comp instanceof CompItem)) { alert("Open a composition first."); return null; }

    var layers = [];
    if (opts.selectedOnly && comp.selectedLayers.length > 0) {
      for (var i = 0; i < comp.selectedLayers.length; i++) layers.push(comp.selectedLayers[i]);
    } else {
      for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i));
    }

    var times = sampleTimes(comp, opts.animate);
    var b = computeBounds(layers, times);
    if (!b) { alert("No croppable content found (no enabled shape/footage/text layers)."); return null; }

    var pad = opts.padding || 0;
    var minX = b.minX - pad, minY = b.minY - pad, maxX = b.maxX + pad, maxY = b.maxY + pad;
    var newW = Math.ceil(maxX - minX), newH = Math.ceil(maxY - minY);
    if (newW < 1) newW = 1;
    if (newH < 1) newH = 1;
    if (newW > 30000) newW = 30000;
    if (newH > 30000) newH = 30000;

    var warn = { count: 0 };
    app.beginUndoGroup("MotionPotion Auto-Crop");
    try {
      comp.width = newW;
      comp.height = newH;
      // Shift every ROOT layer so the content's top-left lands at (0,0). Parented
      // layers ride their root; cameras / lights are left alone.
      for (var i = 1; i <= comp.numLayers; i++) {
        var ly = comp.layer(i);
        if (ly.parent) continue;
        if ((ly instanceof CameraLayer) || (ly instanceof LightLayer)) continue;
        try { shiftLayer(ly, -minX, -minY, warn); } catch (e) {}
      }
    } finally {
      app.endUndoGroup();
    }

    var msg = "Cropped to " + newW + " x " + newH + " px";
    if (opts.animate) msg += "  (" + b.animated + "/" + b.counted + " layers animated)";
    if (warn.count) msg += "\n" + warn.count + " layer(s) have a Position expression and were not shifted.";
    return msg;
  }

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

    var info = win.add("statictext", undefined, "Crop the comp to its content.", { multiline: true });
    info.alignment = ["fill", "top"];

    var animCb = win.add("checkbox", undefined, "Fit animated bounds (sample over time)");
    animCb.value = true;

    var selCb = win.add("checkbox", undefined, "Selected layers only");
    selCb.value = false;

    var padGrp = win.add("group");
    padGrp.alignment = ["fill", "top"];
    padGrp.add("statictext", undefined, "Padding (px):");
    var padEt = padGrp.add("edittext", undefined, "0");
    padEt.characters = 5;

    var cropBtn = win.add("button", undefined, "Crop comp");

    var status = win.add("statictext", undefined, "", { multiline: true });
    status.alignment = ["fill", "top"];
    status.preferredSize.height = 42;

    var foot = win.add("statictext", undefined, "MotionPotion — free  ·  motionpotion.co");
    foot.alignment = ["center", "bottom"];

    cropBtn.onClick = function () {
      var pad = parseFloat(padEt.text);
      if (isNaN(pad)) pad = 0;
      try {
        var msg = run({ animate: animCb.value, selectedOnly: selCb.value, padding: pad });
        if (msg) status.text = msg;
      } catch (e) {
        status.text = "Error: " + (e && e.message ? e.message : e);
      }
    };

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

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

})(this);
