/*
  CroppingKit_EffectCraft.jsx - CroppingKit for EffectCraft (written against EffectCraft 0.10.0)

  Crops a pre-comp to what is actually drawn in it, without moving anything on screen:
    Auto Crop      the selected pre-comp layer(s) at the current frame
    Crop Duration  every frame between the selected layer's in and out points
    Mask Crop      the outline of a mask drawn on the selected pre-comp layer
    Delete Mask    remove the mask Mask Crop used (on by default)
    Pad            extra pixels on every side of the crop
    PREVIOUS CROP INFO  sizes before / after and the pixels taken off each side

  Install: File > Scripts > Install ScriptUI Panel..., then open it from the bottom of the
  Window menu. File > Scripts > Run Script File... opens it as a floating palette.

  How it works
  - The crop region comes from the pre-comp's rendered pixels: a temporary null in the parent
    comp gets a Color Control whose expression scans the pre-comp layer with sampleImage
    (a coarse grid, then a binary search at each edge) and returns [left, top, right, bottom].
    The null is added and removed inside the tool's own undo step, so it never shows up in
    the project, in Undo / Redo or in the History panel. A tool that ends up changing nothing
    takes its step back, so Undo still reaches your own last step.
  - The pre-comp is resized with the engine's own Crop Comp to Region of Interest, which
    moves every unparented layer (all its Position keys too, X / Y included) by the offset.
  - Every layer that uses the pre-comp, in every composition, then gets its Anchor Point
    (value or keys), its masks and its effect points moved by the same offset, so it stays
    where it was.
  - Crop Duration reads every frame between the layer's in and out points. EffectCraft 0.10.0
    never runs tasks a script schedules, so the scan runs in the click and the panel says so
    while it runs. Where tasks run, it scans a few frames at a time with a progress bar and
    Cancel; the finished crop is one undo step either way.

  Behaviour
  - Messages show in the panel (EffectCraft's alert() only reaches the status bar).
  - The layer's own masks and effects are never counted: the crop fits what is inside the
    pre-comp. EffectCraft draws effects such as shadows and glows past a layer's edge, so a
    tight crop doesn't cut them.
  - Layers using the pre-comp are kept in place in every composition, not only in the current
    one: their Anchor Point, their masks (curves included) and their effect points (a Radial
    Blur's centre, a Gradient Ramp's ends) move with the content. Point Controls and Puppet
    pins are left alone, and a Puppet is reported.
  - A tool that fails part way, or finds nothing to crop, leaves the project and Undo as
    they were.
  - Mask Crop uses the mask's curves, not only its vertices, and the mask selected in the
    Timeline when there is one; with several masks and none selected it asks which one.
  - Auto Crop with several pre-comps selected crops each pre-comp once (a pre-comp used by two
    selected layers fits both) and reports them all.
  - Crop Duration only looks at frames inside the composition.
  - Pad is in whole pixels: a decimal is rounded and a negative value crops into the content.
    A value that isn't a number is reported, and no padding is added.
*/

(function CroppingKitEffectCraft(thisObj) {
  var NAME = "CroppingKit";
  var VERSION = "1.0";
  var SCAN_LABEL = NAME + " scan";
  var STEP_MS = 250;
  var G = $.global;
  if (!G.__croppingKitEC) G.__croppingKitEC = {};
  var KEY = "p" + new Date().getTime() + "_" + Math.floor(Math.random() * 1000000);

  // ---------------------------------------------------------------- text and colour

  // Inter Regular advance widths (1/1000 em) of characters 32..126: EffectCraft draws ScriptUI
  // text in Inter, so labels can be centred and numbers right-aligned.
  var INTER = [281, 288, 466, 633, 642, 982, 644, 300, 365, 365, 501, 662, 288, 460, 288, 360, 631, 407, 610, 618, 646, 593, 620, 566,
    619, 620, 288, 302, 662, 662, 662, 511, 966, 690, 654, 730, 722, 601, 590, 746, 743, 269, 571, 672, 565, 903, 753, 765, 639, 765,
    644, 642, 646, 744, 690, 985, 682, 679, 629, 365, 360, 365, 471, 456, 323, 562, 612, 571, 612, 583, 370, 613, 591, 242, 242, 549,
    242, 876, 591, 600, 612, 612, 376, 528, 327, 591, 562, 818, 546, 562, 552, 426, 333, 426, 662];
  var INTER_MORE = { "\u00d7": 662, "\u00b7": 288, "\u2013": 500, "\u2014": 1000, "\u2026": 864, "\u201c": 437, "\u201d": 437, "\u2019": 288, "\u2192": 1000 };
  function textW(s, size) {
    var w = 0;
    s = String(s);
    for (var i = 0; i < s.length; i++) {
      var c = s.charCodeAt(i);
      w += c >= 32 && c <= 126 ? INTER[c - 32] : INTER_MORE[s.charAt(i)] || 600;
    }
    return (w * size) / 1000;
  }
  // Word-wrap to `maxW` px at `size`.
  function wrap(text, size, maxW) {
    var out = [], paras = String(text).split("\n");
    for (var p = 0; p < paras.length; p++) {
      var words = paras[p].split(" "), line = "";
      for (var i = 0; i < words.length; i++) {
        var next = line ? line + " " + words[i] : words[i];
        if (line && textW(next, size) > maxW) {
          out.push(line);
          line = words[i];
        } else line = next;
      }
      out.push(line);
    }
    return out;
  }
  // ScriptUIGraphics colours are linear light: convert from sRGB first.
  function lin(v) { return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }
  function rgba(r, g, b, a) { return [lin(r), lin(g), lin(b), a === undefined ? 1 : a]; }
  var C_DIM = rgba(0.5, 0.5, 0.5);
  var C_FRAME = rgba(0.55, 0.55, 0.55, 0.75);
  var C_ACCENT = rgba(0.176, 0.549, 0.922);
  var C_ACCENT_FILL = rgba(0.176, 0.549, 0.922, 0.3);
  var C_WARN = rgba(0.93, 0.66, 0.24);
  var C_DISC = rgba(0.45, 0.45, 0.45);
  var F11 = ScriptUI.newFont("dialog", "REGULAR", 11);
  var F12 = ScriptUI.newFont("dialog", "REGULAR", 12);

  function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
  function quote(s) { return "\u201c" + s + "\u201d"; }

  // ---------------------------------------------------------------- state

  var S = {
    report: null,       // the last crop (drawn in PREVIOUS CROP INFO)
    message: "",        // the last message (bottom of PREVIOUS CROP INFO)
    warn: false,        // the message is a problem
    scan: null,         // the Crop Duration in progress
    pending: null       // a Crop Duration about to run in the click (shown while it runs)
  };

  // ---------------------------------------------------------------- project helpers

  function activeComp() {
    var c = app.project.activeItem;
    return c && c instanceof CompItem ? c : null;
  }
  function sourceComp(layer) {
    try {
      var s = layer.source;
      return s && s instanceof CompItem ? s : null;
    } catch (e) {
      return null;
    }
  }
  function itemById(id) {
    try {
      return app.project.itemByID(id);
    } catch (e) {
      return null;
    }
  }
  function layerById(comp, id) {
    if (!comp) return null;
    for (var i = 1; i <= comp.numLayers; i++) {
      var l = comp.layer(i);
      if (l.id === id) return l;
    }
    return null;
  }
  // The selected pre-comp layers, or {error}.
  function pickLayers(tool, many) {
    var comp = activeComp();
    if (!comp) return { error: "Open a composition and select a pre-comp layer in its Timeline." };
    var sel = comp.selectedLayers;
    if (!sel.length) return { error: "Select a pre-comp layer in the Timeline first." };
    if (!many && sel.length > 1) return { error: tool + " works on one layer at a time: select just one pre-comp layer." };
    var pre = [], skipped = 0;
    for (var i = 0; i < sel.length; i++) {
      if (sourceComp(sel[i])) pre.push(sel[i]);
      else skipped++;
    }
    if (!pre.length) {
      return {
        error: sel.length === 1
          ? quote(sel[0].name) + " is not a pre-comp. " + NAME + " only resizes nested compositions (pre-comps)."
          : "None of the selected layers is a pre-comp. " + NAME + " only resizes nested compositions (pre-comps)."
      };
    }
    return { comp: comp, layers: pre, skipped: skipped };
  }
  function selectedIds(comp) {
    var out = [], sel = comp.selectedLayers;
    for (var i = 0; i < sel.length; i++) out.push(sel[i].id);
    return out;
  }
  function restoreSelection(comp, ids) {
    try {
      var now = selectedIds(comp);
      for (var i = 1; i <= comp.numLayers; i++) {
        var l = comp.layer(i), want = false, has = false;
        for (var j = 0; j < ids.length; j++) if (ids[j] === l.id) want = true;
        for (var k = 0; k < now.length; k++) if (now[k] === l.id) has = true;
        if (want !== has) l.selected = want;
      }
    } catch (e) {}
  }
  function revision() {
    try {
      if (typeof __get === "function") return __get("project", {}).revision;
    } catch (e) {}
    try {
      return JSON.stringify(app.run("edit.history", {}));
    } catch (e2) {
      return "";
    }
  }
  function frameOf(comp, t) { return Math.round(t / comp.frameDuration); }

  // ---------------------------------------------------------------- finding the crop

  // An expression that returns the pixel box [left, top, right, bottom] of everything drawn in
  // the pre-comp layer `index` (its own pixels: no masks, effects or transform), or -1s when
  // nothing is. It reads sampleImage over a grid of S px tiles, then halves the search range
  // at each edge. A sampleImage the engine hasn't answered yet throws a "pending" error (first
  // character \u0001): all pending reads of a run are collected, answered, and the
  // expression runs again with them.
  // With `inside` = a box already found (Crop Duration), it first checks whether anything is
  // drawn outside that box and returns -2s when nothing is: most frames then cost two short
  // runs instead of a full search.
  function boundsExpression(index, w, h, inside) {
    var S = 64;
    while (Math.ceil(w / S) * Math.ceil(h / S) > 48) S *= 2;
    var outside = inside ? [
      "var U = [" + inside.join(", ") + "];",
      "var o = [any(0, 0, W, U[1]), any(0, U[3], W, H), any(0, U[1], U[0], U[3]), any(U[2], U[1], W, U[3])];",
      "if (P) return [0, 0, 0, 0];",
      "if (!o[0] && !o[1] && !o[2] && !o[3]) return [-2, -2, -2, -2];"
    ] : [];
    return [
      "(function () {",
      "var L = thisComp.layer(" + index + "), W = " + w + ", H = " + h + ", S = " + S + ", T = time, P = false;",
      "function any(x0, y0, x1, y1) {",
      "  if (x1 <= x0 || y1 <= y0) return false;",
      "  var hit = false, miss = false, x, y, xb, yb;",
      "  for (y = y0; y < y1; y = yb) {",
      "    yb = Math.min(y + 4000, y1);",
      "    for (x = x0; x < x1; x = xb) {",
      "      xb = Math.min(x + 4000, x1);",
      "      try { if (L.sampleImage([(x + xb) / 2, (y + yb) / 2], [(xb - x) / 2, (yb - y) / 2], false, T)[3] > 0) hit = true; }",
      "      catch (e) { if (e && e.message && e.message.charCodeAt(0) === 1) miss = true; else throw e; }",
      "    }",
      "  }",
      "  if (miss) { P = true; return null; }",
      "  return hit;",
      "}"
    ].concat(outside, [
      "var nx = Math.ceil(W / S), ny = Math.ceil(H / S), cx0 = nx, cx1 = -1, cy0 = ny, cy1 = -1, i, j, r;",
      "for (j = 0; j < ny; j++) for (i = 0; i < nx; i++) {",
      "  r = any(i * S, j * S, Math.min(i * S + S, W), Math.min(j * S + S, H));",
      "  if (r) { if (i < cx0) cx0 = i; if (i > cx1) cx1 = i; if (j < cy0) cy0 = j; if (j > cy1) cy1 = j; }",
      "}",
      "if (P) return [0, 0, 0, 0];",
      "if (cx1 < 0) return [-1, -1, -1, -1];",
      "var X0 = cx0 * S, X1 = Math.min(cx1 * S + S, W), Y0 = cy0 * S, Y1 = Math.min(cy1 * S + S, H);",
      "var a = [X0, Math.min(X0 + S, W) - 1], b = [cx1 * S, X1 - 1], c = [Y0, Math.min(Y0 + S, H) - 1], d = [cy1 * S, Y1 - 1], m, q, moved;",
      "while (!P && (a[0] < a[1] || b[0] < b[1] || c[0] < c[1] || d[0] < d[1])) {",
      "  moved = false;",
      "  if (a[0] < a[1]) { m = Math.floor((a[0] + a[1]) / 2); q = any(X0, Y0, m + 1, Y1); if (q !== null) { if (q) a[1] = m; else a[0] = m + 1; moved = true; } }",
      "  if (b[0] < b[1]) { m = Math.ceil((b[0] + b[1]) / 2); q = any(m, Y0, X1, Y1); if (q !== null) { if (q) b[0] = m; else b[1] = m - 1; moved = true; } }",
      "  if (c[0] < c[1]) { m = Math.floor((c[0] + c[1]) / 2); q = any(X0, Y0, X1, m + 1); if (q !== null) { if (q) c[1] = m; else c[0] = m + 1; moved = true; } }",
      "  if (d[0] < d[1]) { m = Math.ceil((d[0] + d[1]) / 2); q = any(X0, m, X1, Y1); if (q !== null) { if (q) d[0] = m; else d[1] = m - 1; moved = true; } }",
      "  if (!moved) break;",
      "}",
      "if (P) return [0, 0, 0, 0];",
      "return [a[0], c[0], b[0] + 1, d[0] + 1];",
      "})();"
    ]).join("\n");
  }

  // A temporary null in `comp` whose Color Control reads a pre-comp layer's pixel box.
  function Probe(comp) {
    this.layer = comp.layers.addNull(comp.duration);
    this.layer.name = NAME + " probe";
    this.prop = this.layer.property("ADBE Effect Parade").addProperty("ADBE Color Control").property(1);
    this.w = 0;
    this.h = 0;
  }
  Probe.prototype.aim = function (layer, inside) {
    var src = sourceComp(layer);
    this.w = src.width;
    this.h = src.height;
    this.prop.expression = boundsExpression(layer.index, this.w, this.h, inside);
  };
  // [left, top, right, bottom] at comp time `t`; null when nothing is drawn, INSIDE when
  // nothing is drawn outside the box given to aim().
  var INSIDE = "inside";
  Probe.prototype.read = function (t) {
    var v = this.prop.valueAtTime(t, false);
    if (v[0] === -1 && v[1] === -1 && v[2] === -1 && v[3] === -1) return null;
    if (v[0] === -2 && v[1] === -2 && v[2] === -2 && v[3] === -2) return INSIDE;
    if (!(v[2] > v[0] && v[3] > v[1] && v[0] >= 0 && v[1] >= 0 && v[2] <= this.w && v[3] <= this.h)) {
      var err = "";
      try { err = this.prop.expressionError; } catch (e) {}
      throw new Error("Couldn't read the pre-comp's pixels" + (err ? ": " + err : "."));
    }
    return [v[0], v[1], v[2], v[3]];
  };
  Probe.prototype.close = function () {
    try { this.layer.remove(); } catch (e) {}
  };

  // Run fn(probe) with a probe in `comp`, then remove the probe and give the layer selection
  // back. It runs inside the tool's undo step (oneStep): the probe is gone before that step is
  // recorded, so no state of the undo history (Undo, Redo, the History panel) holds it.
  function withProbe(comp, fn) {
    var keep = selectedIds(comp), probe = null;
    try {
      probe = new Probe(comp);
      return fn(probe);
    } finally {
      if (probe) probe.close();
      restoreSelection(comp, keep);
    }
  }

  // fn() as one undo step named `label`. When it changes nothing (returns false) or fails,
  // the step is taken back, so Undo still reaches the user's own last step (what is left on
  // the Redo side changes nothing).
  function oneStep(label, fn) {
    var before = undoLabels(), rev = revision(), changed = false;
    app.beginUndoGroup(label);
    try {
      changed = !!fn();
    } finally {
      app.endUndoGroup();
      if (!changed && revision() !== rev && isNewStep(before, label)) {
        try { app.run("edit.history", { steps: 1 }); } catch (e) {}
      }
    }
    return changed;
  }
  function undoLabels() {
    try {
      var h = app.run("edit.history", {});
      return h && h.undo ? h.undo : [];
    } catch (e) {
      return [];
    }
  }
  // Whether the undo list is `before` with one more step, `label`, on top (Levels of Undo may
  // have dropped the oldest one).
  function isNewStep(before, label) {
    var now = undoLabels();
    if (!now.length || now[0] !== label || now.length > before.length + 1) return false;
    for (var i = 1; i < now.length; i++) if (now[i] !== before[i - 1]) return false;
    return true;
  }

  function union(a, b) {
    if (!a) return b ? b.slice() : null;
    if (!b) return a.slice();
    return [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
  }

  // The Pad field as a whole number of pixels; {pad, note}.
  function readPad() {
    var raw = String(ui.pad.text).replace(/^\s+|\s+$/g, "");
    if (raw === "") return { pad: 0, note: "" };
    var v = Number(raw.replace(",", "."));
    if (isNaN(v) || !isFinite(v)) return { pad: 0, note: "Pad must be a number of pixels: no padding was added." };
    var r = Math.round(v);
    if (r !== v) return { pad: r, note: "Pad is in whole pixels: " + raw + " was rounded to " + r + "." };
    return { pad: r, note: "" };
  }

  // ---------------------------------------------------------------- applying a crop

  function moveShape(s, dx, dy) {
    var o = new Shape(), v = s.vertices || [];
    o.vertices = [];
    for (var i = 0; i < v.length; i++) o.vertices.push([v[i][0] - dx, v[i][1] - dy]);
    o.inTangents = (s.inTangents || []).slice();
    o.outTangents = (s.outTangents || []).slice();
    o.closed = s.closed;
    return o;
  }
  function moveKeysOrValue(prop, f) {
    if (prop.numKeys > 0) {
      for (var k = 1; k <= prop.numKeys; k++) prop.setValueAtKey(k, f(prop.keyValue(k)));
    } else {
      prop.setValue(f(prop.valueAtTime(0, true)));
    }
  }
  // Every layer, in every composition, whose source is `src`.
  function usersOf(src) {
    var out = [], comps = src.usedIn;
    for (var c = 0; c < comps.length; c++) {
      var comp = comps[c];
      for (var i = 1; i <= comp.numLayers; i++) {
        var l = comp.layer(i), s = sourceComp(l);
        if (s && s.id === src.id) out.push(l);
      }
    }
    return out;
  }
  // Things inside the pre-comp that a crop can still move: Position expressions on unparented
  // layers (the crop moves their value, not the expression's result) and 3D layers / cameras
  // (their perspective is centred on the comp).
  function insideNotes(src) {
    var expr = 0, threeD = 0;
    for (var i = 1; i <= src.numLayers; i++) {
      var l = src.layer(i);
      try {
        if (l instanceof CameraLayer || l.threeDLayer) threeD++;
        if (l.parent) continue;
        var tr = l.property("ADBE Transform Group"), p = tr.property("ADBE Position");
        var e = p.dimensionsSeparated
          ? tr.property("ADBE Position_0").expression + tr.property("ADBE Position_1").expression
          : p.expression;
        if (e) expr++;
      } catch (err) {}
    }
    return { expr: expr, threeD: threeD };
  }

  // Effect points on a layer that uses the pre-comp (a Radial Blur's centre, a Gradient Ramp's
  // ends...) are in the layer's own pixels, like its masks: move them with the content. Point
  // Controls are left as they are (expressions may read them as plain numbers), and so are
  // Puppet pins (their mesh can't be moved): counted for the report.
  var KEEP_FX = { "ADBE Point Control": 1, "ADBE Point3D Control": 1, "ADBE FreePin3": 1 };
  function moveEffectPoints(layer, dx, dy, n) {
    var fx = layer.property("ADBE Effect Parade");
    for (var i = 1; fx && i <= fx.numProperties; i++) {
      var e = fx.property(i);
      if (e.matchName === "ADBE FreePin3") n.puppet++;
      if (!KEEP_FX[e.matchName]) movePoints(e, dx, dy, n);
    }
  }
  function movePoints(g, dx, dy, n) {
    for (var j = 1; j <= g.numProperties; j++) {
      var p = g.property(j);
      if (p.propertyType !== PropertyType.PROPERTY) {
        movePoints(p, dx, dy, n);
        continue;
      }
      var t = p.propertyValueType;
      if (t !== PropertyValueType.TwoD_SPATIAL && t !== PropertyValueType.ThreeD_SPATIAL) continue;
      if (p.expression) n.expr = true;
      moveKeysOrValue(p, function (v) {
        var o = v.slice();
        o[0] -= dx;
        o[1] -= dy;
        return o;
      });
    }
  }

  // Crop `src` to `rect` = [x, y, w, h] (pre-comp pixels) and keep every layer that uses it
  // where it was. Runs inside the caller's undo group.
  function cropSource(src, rect) {
    var users = usersOf(src), comps = {}, ncomps = 0, anchorExpr = 0, puppet = 0;
    var before = [src.width, src.height];
    var inside = insideNotes(src);
    app.run("view.setRegionOfInterest", { rect: rect });
    app.run("comp.cropToRegionOfInterest", { comp: src.id });
    var dx = rect[0], dy = rect[1];
    for (var i = 0; i < users.length; i++) {
      var l = users[i], locked = false, n = { expr: false, puppet: 0 };
      if (!comps[l.containingComp.id]) {
        comps[l.containingComp.id] = true;
        ncomps++;
      }
      try {
        locked = l.locked;
        if (locked) l.locked = false;
        var ap = l.property("ADBE Transform Group").property("ADBE Anchor Point");
        if (ap.expression) n.expr = true;
        moveKeysOrValue(ap, function (v) { return [v[0] - dx, v[1] - dy, v.length > 2 ? v[2] : 0]; });
        var masks = l.property("ADBE Mask Parade");
        for (var m = 1; m <= masks.numProperties; m++) {
          var mask = masks.property(m), mlocked = mask.locked;
          if (mlocked) mask.locked = false;
          moveKeysOrValue(mask.property("ADBE Mask Shape"), function (s) { return moveShape(s, dx, dy); });
          if (mlocked) mask.locked = true;
        }
        moveEffectPoints(l, dx, dy, n);
      } finally {
        if (locked) l.locked = true;
      }
      if (n.expr) anchorExpr++;
      if (n.puppet) puppet++;
    }
    return {
      name: src.name,
      before: before,
      after: [src.width, src.height],
      rect: rect,
      users: users.length,
      comps: ncomps,
      anchorExpr: anchorExpr,
      puppet: puppet,
      posExpr: inside.expr,
      threeD: inside.threeD
    };
  }

  function padded(box, pad) {
    return [box[0] - pad, box[1] - pad, box[2] - box[0] + 2 * pad, box[3] - box[1] + 2 * pad];
  }
  // A feathered mask on a layer that uses the pre-comp fades out against the layer's edge, so
  // a crop close to the content would soften it: the room it needs (about 1.25 x the feather,
  // measured in EffectCraft 0.10), or 0.
  function featherRoom(src) {
    var f = 0, users = usersOf(src);
    for (var i = 0; i < users.length; i++) {
      var masks = users[i].property("ADBE Mask Parade");
      for (var m = 1; m <= masks.numProperties; m++) {
        var mask = masks.property(m);
        try {
          if (mask.maskMode === MaskMode.NONE) continue;
          var p = mask.property("ADBE Mask Feather"), vals = [p.valueAtTime(0, true)];
          for (var k = 1; k <= p.numKeys; k++) vals.push(p.keyValue(k));
          for (var v = 0; v < vals.length; v++) f = Math.max(f, vals[v][0], vals[v][1]);
        } catch (e) {}
      }
    }
    return f > 0 ? Math.ceil(f * 1.25) + 1 : 0;
  }
  // The crop for a content box: the Pad around it, and room for feathered masks (inside the old
  // frame only, so the crop never shows more than before). {rect, note}
  function cropRect(src, box, pad) {
    // A negative Pad crops into the content: no room is kept then.
    var rect = padded(box, pad);
    if (pad < 0) return { rect: rect, note: "" };
    var room = featherRoom(src);
    if (room <= pad) return { rect: rect, note: "" };
    var x0 = Math.min(rect[0], Math.max(0, box[0] - room)), y0 = Math.min(rect[1], Math.max(0, box[1] - room));
    var x1 = Math.max(rect[0] + rect[2], Math.min(src.width, box[2] + room)), y1 = Math.max(rect[1] + rect[3], Math.min(src.height, box[3] + room));
    return { rect: [x0, y0, x1 - x0, y1 - y0], note: "Left up to " + room + " px around the content for a feathered mask." };
  }

  // ---------------------------------------------------------------- Auto Crop

  function autoCrop() {
    if (S.scan) return;
    var pick = pickLayers("Auto Crop", true);
    if (pick.error) return say(pick.error, true);
    var comp = pick.comp, t = comp.time, padInfo = readPad();
    var done = [], empty = [], small = [], same = [], notes = [];
    if (padInfo.note) notes.push(padInfo.note);
    // The scan and the crops are one undo step (taken back when nothing was cropped).
    try {
      oneStep("Auto Crop", function () {
        // One crop per pre-comp: the union of every selected layer that shows it.
        var jobs = [], byId = {};
        withProbe(comp, function (probe) {
          for (var i = 0; i < pick.layers.length; i++) {
            var l = pick.layers[i], src = sourceComp(l);
            probe.aim(l);
            var box = probe.read(t);
            var job = byId[src.id];
            if (!job) {
              job = byId[src.id] = { src: src, box: null };
              jobs.push(job);
            }
            job.box = union(job.box, box);
          }
        });
        for (var j = 0; j < jobs.length; j++) {
          var jb = jobs[j];
          if (!jb.box) { empty.push(jb.src.name); continue; }
          var cr = cropRect(jb.src, jb.box, padInfo.pad), rect = cr.rect;
          if (rect[2] < 1 || rect[3] < 1) { small.push(jb.src.name); continue; }
          if (rect[0] === 0 && rect[1] === 0 && rect[2] === jb.src.width && rect[3] === jb.src.height) { same.push(jb.src.name); continue; }
          var r = cropSource(jb.src, rect);
          if (cr.note && notes.indexOf(cr.note) < 0) notes.push(cr.note);
          r.tool = "Auto Crop";
          r.detail = "at frame " + frameOf(comp, t);
          done.push(r);
        }
        return done.length > 0;
      });
    } catch (e) {
      return say("Auto Crop failed: " + (e && e.message ? e.message : e) + " Nothing was changed.", true);
    }
    if (pick.skipped) notes.push("Skipped " + plural(pick.skipped, "layer that isn't a pre-comp", "layers that aren't pre-comps") + ".");
    if (empty.length) notes.push("Nothing is drawn in " + quote(empty.join("\u201d, \u201c")) + " at this frame, so it was left as it is.");
    if (small.length) notes.push("The padding leaves nothing of " + quote(small.join("\u201d, \u201c")) + ", so it was left as it is.");
    if (same.length) notes.push(quote(same.join("\u201d, \u201c")) + " already fits " + (same.length === 1 ? "its" : "their") + " contents.");
    if (done.length) {
      done[done.length - 1].others = done.length - 1;
      show(done[done.length - 1], notes);
    } else {
      say(notes.join(" ") || "Nothing to crop.", true);
    }
  }

  // ---------------------------------------------------------------- Crop Duration

  // Whether this EffectCraft runs the tasks a handler schedules (app.scheduleTask). 0.10.0
  // doesn't: its app session has no task runner, so script.runTasks fails. Then the whole
  // scan runs in the click (EffectCraft waits until it is done); otherwise a few frames per
  // task, with progress and Cancel.
  var TASKS = null;
  function tasksRun() {
    if (TASKS === null) {
      try {
        app.run("script.runTasks", {});
        TASKS = true;
      } catch (e) {
        TASKS = false;
      }
    }
    return TASKS;
  }

  // The selected layer's frames in its comp: {comp, layer, src, frames} or {error}.
  function durationPlan() {
    var pick = pickLayers("Crop Duration", false);
    if (pick.error) return pick;
    var comp = pick.comp, layer = pick.layers[0], src = sourceComp(layer);
    var fd = comp.frameDuration, from = Math.max(layer.inPoint, 0), to = Math.min(layer.outPoint, comp.duration);
    var frames = [];
    for (var k = Math.ceil(from / fd - 1e-6); k * fd < to - 1e-9; k++) frames.push(k * fd);
    if (!frames.length) return { error: quote(layer.name) + " isn't on screen at any frame of " + quote(comp.name) + "." };
    return { comp: comp, layer: layer, src: src, frames: frames };
  }
  function cropDuration() {
    if (S.scan) return;
    var plan = durationPlan();
    S.pending = null;
    if (plan.error) return say(plan.error, true);
    S.scan = {
      comp: plan.comp.id, layer: plan.layer.id, src: plan.src.id, name: plan.src.name, layerName: plan.layer.name,
      frames: plan.frames, next: 0, box: null, started: new Date().getTime(), rev: revision(),
      pad: readPad(), task: 0, cancelled: false
    };
    S.message = "";
    if (tasksRun()) {
      busy(true);
      nextStep();
      return;
    }
    // The scan and the crop are one undo step (taken back when nothing was cropped).
    var res = null;
    try {
      oneStep("Crop Duration", function () {
        withProbe(plan.comp, function (probe) {
          scanFrames(probe, plan.layer, S.scan, Infinity);
        });
        res = cropScanned(plan.src, S.scan);
        return !res.skip;
      });
    } catch (e) {
      return endScan("Crop Duration failed: " + (e && e.message ? e.message : e) + " Nothing was changed.", true);
    }
    reportScan(res);
  }
  // Read frames from sc.next on until `stop` (a time); the union of what they show in sc.box.
  function scanFrames(probe, layer, sc, stop) {
    probe.aim(layer, sc.box);
    do {
      var b = probe.read(sc.frames[sc.next]);
      if (b && b !== INSIDE) {
        var u = union(sc.box, b);
        if (!sc.box || u.join() !== sc.box.join()) {
          sc.box = u;
          probe.aim(layer, sc.box);
        }
      }
      sc.next++;
    } while (sc.next < sc.frames.length && new Date().getTime() < stop);
  }
  function nextStep() {
    S.scan.task = app.scheduleTask("var __ac = $.global.__croppingKitEC && $.global.__croppingKitEC[\"" + KEY + "\"]; if (__ac) __ac.step();", 1, false);
  }
  function endScan(message, warn) {
    S.scan = null;
    busy(false);
    if (message) say(message, warn);
    else refresh();
  }
  function cancelScan() {
    if (!S.scan) return;
    try { app.cancelTask(S.scan.task); } catch (e) {}
    endScan("Crop Duration was cancelled: nothing was changed.", false);
  }
  function scanStep() {
    var sc = S.scan;
    if (!sc || sc.cancelled) return;
    if (revision() !== sc.rev) return endScan("Crop Duration stopped because the project changed during the scan. Run it again.", true);
    var comp = itemById(sc.comp), layer = comp ? layerById(comp, sc.layer) : null;
    if (!layer) return endScan("Crop Duration stopped: the pre-comp layer is gone.", true);
    var stop = new Date().getTime() + STEP_MS, res = null;
    try {
      // A step of the scan changes nothing, so its undo step is taken back: the project and
      // Undo stay as they were between steps, and Cancel leaves nothing behind.
      oneStep(SCAN_LABEL, function () {
        withProbe(comp, function (probe) {
          scanFrames(probe, layer, sc, stop);
        });
        return false;
      });
      if (sc.next >= sc.frames.length) {
        oneStep("Crop Duration", function () {
          res = cropScanned(sourceComp(layer), sc);
          return !res.skip;
        });
      }
    } catch (e) {
      return endScan("Crop Duration failed: " + (e && e.message ? e.message : e) + " Nothing was changed.", true);
    }
    if (!res) {
      sc.rev = revision();
      refresh();
      nextStep();
      return;
    }
    reportScan(res);
  }
  // Crop the pre-comp to what the scan found (inside the caller's undo step): the crop's
  // report, or {skip: message, warn} when there is nothing to crop.
  function cropScanned(src, sc) {
    if (!sc.box) return { skip: "Nothing is drawn in " + quote(sc.name) + " between the layer's in and out points, so it was left as it is.", warn: true };
    var cr = cropRect(src, sc.box, sc.pad.pad), rect = cr.rect;
    if (rect[2] < 1 || rect[3] < 1) return { skip: "The padding leaves nothing of " + quote(sc.name) + ", so it was left as it is.", warn: true };
    if (rect[0] === 0 && rect[1] === 0 && rect[2] === src.width && rect[3] === src.height) {
      return { skip: quote(sc.name) + " already fits everything it shows over " + plural(sc.frames.length, "frame", "frames") + ".", warn: false };
    }
    var r = cropSource(src, rect);
    r.tool = "Crop Duration";
    r.notes = (sc.pad.note ? [sc.pad.note] : []).concat(cr.note ? [cr.note] : []);
    return r;
  }
  function reportScan(res) {
    var sc = S.scan, seconds = (new Date().getTime() - sc.started) / 1000;
    if (res.skip) return endScan(res.skip, res.warn);
    res.detail = plural(sc.frames.length, "frame", "frames") + " in " + (seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)) + " s";
    S.scan = null;
    busy(false);
    show(res, res.notes);
  }

  // ---------------------------------------------------------------- Mask Crop

  // The box of a mask path, curves included: [minX, minY, maxX, maxY].
  function pathBox(shape) {
    var v = shape.vertices || [], it = shape.inTangents || [], ot = shape.outTangents || [], n = v.length;
    var b = [Infinity, Infinity, -Infinity, -Infinity];
    function add(x, y) {
      if (x < b[0]) b[0] = x;
      if (y < b[1]) b[1] = y;
      if (x > b[2]) b[2] = x;
      if (y > b[3]) b[3] = y;
    }
    function at(p0, p1, p2, p3, t) {
      var u = 1 - t;
      return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
    }
    for (var i = 0; i < n; i++) add(v[i][0], v[i][1]);
    var segs = shape.closed ? n : n - 1;
    for (var s = 0; s < segs; s++) {
      var j = (s + 1) % n, o = ot[s] || [0, 0], q = it[j] || [0, 0];
      var P = [
        [v[s][0], v[s][0] + o[0], v[j][0] + q[0], v[j][0]],
        [v[s][1], v[s][1] + o[1], v[j][1] + q[1], v[j][1]]
      ];
      var roots = [];
      for (var ax = 0; ax < 2; ax++) {
        var p = P[ax];
        var A = -p[0] + 3 * p[1] - 3 * p[2] + p[3], B = 2 * (p[0] - 2 * p[1] + p[2]), Cc = p[1] - p[0];
        if (Math.abs(A) < 1e-12) {
          if (Math.abs(B) > 1e-12) roots.push(-Cc / B);
        } else {
          var D = B * B - 4 * A * Cc;
          if (D >= 0) {
            var sq = Math.sqrt(D);
            roots.push((-B + sq) / (2 * A), (-B - sq) / (2 * A));
          }
        }
      }
      for (var r = 0; r < roots.length; r++) {
        var t = roots[r];
        if (t > 0 && t < 1) add(at(P[0][0], P[0][1], P[0][2], P[0][3], t), at(P[1][0], P[1][1], P[1][2], P[1][3], t));
      }
    }
    return b;
  }

  // The mask to crop to: the one selected in the Timeline, the only one, or ask.
  function chooseMask(layer, then) {
    var masks = layer.property("ADBE Mask Parade"), n = masks.numProperties;
    if (!n) return say("Draw a mask on " + quote(layer.name) + " first (a rectangle mask gives an exact box), then click Mask Crop.", true);
    if (n === 1) return then(1);
    try {
      var sel = layer.selectedProperties;
      for (var i = 0; i < sel.length; i++) {
        var p = sel[i];
        while (p && p.matchName !== "ADBE Mask Atom" && p.propertyDepth > 1) p = p.propertyGroup(1);
        if (p && p.matchName === "ADBE Mask Atom") return then(p.propertyIndex);
      }
    } catch (e) {}
    var names = [];
    for (var k = 1; k <= n; k++) names.push(masks.property(k).name);
    var dlg = new Window("dialog", "Mask Crop");
    dlg.orientation = "column";
    dlg.alignChildren = ["fill", "top"];
    dlg.margins = [16, 14, 16, 12];
    dlg.spacing = 10;
    dlg.add("statictext", undefined, quote(layer.name) + " has " + n + " masks. Crop to:");
    var list = dlg.add("listbox", undefined, names);
    list.preferredSize = [294, Math.min(n, 8) * 20 + 8];
    list.selection = 0;
    var row = dlg.add("group");
    row.alignment = ["right", "top"];
    var cancel = row.add("button", undefined, "Cancel");
    var ok = row.add("button", undefined, "OK");
    // In the app show() waits until the dialog closes; in the Script Console it returns at
    // once, and OK does the crop itself.
    var st = { chosen: 0, returned: false };
    ok.onClick = function () {
      st.chosen = list.selection ? list.selection.index + 1 : 1;
      dlg.close(1);
      if (st.returned) then(st.chosen);
    };
    list.onDoubleClick = ok.onClick;
    cancel.onClick = function () { dlg.close(2); };
    dlg.show();
    st.returned = true;
    if (st.chosen) then(st.chosen);
  }

  function maskCrop() {
    if (S.scan) return;
    var pick = pickLayers("Mask Crop", false);
    if (pick.error) return say(pick.error, true);
    var layer = pick.layers[0], src = sourceComp(layer), padInfo = readPad();
    chooseMask(layer, function (index) {
      var mask = layer.property("ADBE Mask Parade").property(index);
      var shape = mask.property("ADBE Mask Shape").value;
      if (!shape || !shape.vertices || shape.vertices.length < 2) return say("Mask " + quote(mask.name) + " needs at least two points to crop to.", true);
      var b = pathBox(shape);
      var x0 = Math.floor(b[0]), y0 = Math.floor(b[1]), x1 = Math.ceil(b[2]), y1 = Math.ceil(b[3]);
      if (x1 - x0 < 1 || y1 - y0 < 1) return say("Mask " + quote(mask.name) + " has no area (a straight line), so there is nothing to crop to.", true);
      var rect = padded([x0, y0, x1, y1], padInfo.pad);
      if (rect[2] < 1 || rect[3] < 1) return say("The padding leaves nothing of the mask's area, so nothing was cropped.", true);
      if (rect[0] === 0 && rect[1] === 0 && rect[2] === src.width && rect[3] === src.height) {
        return say("Mask " + quote(mask.name) + " already frames the whole pre-comp, so there is nothing to crop.", false);
      }
      var r, maskName = mask.name, removed = false;
      try {
        oneStep("Mask Crop", function () {
          r = cropSource(src, rect);
          if (ui.del.value) {
            layer.property("ADBE Mask Parade").property(index).remove();
            removed = true;
          }
          return true;
        });
      } catch (e) {
        return say("Mask Crop failed: " + (e && e.message ? e.message : e) + " Nothing was changed.", true);
      }
      r.tool = "Mask Crop";
      r.detail = "to " + quote(maskName) + (removed ? " (deleted)" : "");
      show(r, padInfo.note ? [padInfo.note] : []);
    });
  }

  // ---------------------------------------------------------------- messages and report

  function say(text, warn) {
    S.message = text;
    S.warn = !!warn;
    try { alert(text); } catch (e) {}
    refresh();
  }
  function show(report, notes) {
    S.report = report;
    var lines = (notes || []).slice();
    if (report.others) lines.push("Also cropped " + plural(report.others, "other pre-comp", "other pre-comps") + ".");
    if (report.posExpr) lines.push(plural(report.posExpr, "layer", "layers") + " in the pre-comp " + (report.posExpr === 1 ? "has" : "have") + " a Position expression: check " + (report.posExpr === 1 ? "it" : "them") + ".");
    if (report.threeD) lines.push("3D layers and cameras in the pre-comp look different after a crop (their view is centred on the comp).");
    if (report.anchorExpr) lines.push(plural(report.anchorExpr, "layer", "layers") + " using the pre-comp " + (report.anchorExpr === 1 ? "has" : "have") + " an expression on the Anchor Point or an effect point and may have moved.");
    if (report.puppet) lines.push(plural(report.puppet, "layer", "layers") + " using the pre-comp " + (report.puppet === 1 ? "has" : "have") + " a Puppet effect: its pins don't follow a crop, so check " + (report.puppet === 1 ? "it" : "them") + ".");
    S.message = lines.join(" ");
    S.warn = !!(report.posExpr || report.threeD || report.anchorExpr || report.puppet);
    refresh();
  }

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

  var W = 334, H = 258;
  var win = thisObj instanceof Panel ? thisObj : new Window("palette", NAME, undefined, { resizeable: true });
  win.orientation = "stack";
  win.alignChildren = ["left", "top"];
  win.margins = 0;
  win.spacing = 0;
  win.preferredSize = [W, H];
  var ui = {};

  // Icons, drawn in a 16 x 16 box at (x, y) in the theme's text colour.
  function line(g, pen, pts) {
    g.newPath();
    g.moveTo(pts[0], pts[1]);
    for (var i = 2; i < pts.length; i += 2) g.lineTo(pts[i], pts[i + 1]);
    g.strokePath(pen);
  }
  function ring(g, pen, cx, cy, r) {
    g.newPath();
    g.ellipsePath(cx - r, cy - r, 2 * r, 2 * r);
    g.strokePath(pen);
  }
  // A disc of radius r: a circle of radius r/2 stroked r wide (fills take the field colour,
  // strokes the text colour).
  function dot(g, cx, cy, r, col) {
    ring(g, g.newPen(g.PenType.SOLID_COLOR, col || "text", r), cx, cy, r / 2);
  }
  // A thick polyline as a filled outline (fills take the field colour: cut-outs in a disc).
  function cutStroke(g, pts, hw) {
    var left = [], right = [], n = pts.length / 2;
    for (var i = 0; i < n; i++) {
      var x = pts[2 * i], y = pts[2 * i + 1];
      var ax = i > 0 ? pts[2 * i - 2] : x, ay = i > 0 ? pts[2 * i - 1] : y;
      var bx = i < n - 1 ? pts[2 * i + 2] : x, by = i < n - 1 ? pts[2 * i + 3] : y;
      var dx = bx - ax, dy = by - ay, l = Math.sqrt(dx * dx + dy * dy) || 1;
      left.push(x - (dy / l) * hw, y + (dx / l) * hw);
      right.push(x + (dy / l) * hw, y - (dx / l) * hw);
    }
    g.newPath();
    g.moveTo(left[0], left[1]);
    for (var k = 2; k < left.length; k += 2) g.lineTo(left[k], left[k + 1]);
    for (var m = right.length - 2; m >= 0; m -= 2) g.lineTo(right[m], right[m + 1]);
    g.closePath();
    g.fillPath(null);
  }
  function arcPts(cx, cy, r, a0, a1, steps) {
    var out = [];
    for (var i = 0; i <= steps; i++) {
      var a = ((a0 + ((a1 - a0) * i) / steps) * Math.PI) / 180;
      out.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
    }
    return out;
  }
  var ICONS = {
    auto: function (g, x, y, col) {
      var p = g.newPen(g.PenType.SOLID_COLOR, col || "text", 1.3);
      line(g, p, [x + 1, y + 5, x + 1, y + 1, x + 5, y + 1]);
      line(g, p, [x + 11, y + 1, x + 15, y + 1, x + 15, y + 5]);
      line(g, p, [x + 1, y + 11, x + 1, y + 15, x + 5, y + 15]);
      line(g, p, [x + 11, y + 15, x + 15, y + 15, x + 15, y + 11]);
      ring(g, p, x + 8, y + 8, 3.6);
      line(g, p, [x + 8, y + 2.6, x + 8, y + 4.6]);
      line(g, p, [x + 8, y + 11.4, x + 8, y + 13.4]);
      line(g, p, [x + 2.6, y + 8, x + 4.6, y + 8]);
      line(g, p, [x + 11.4, y + 8, x + 13.4, y + 8]);
      dot(g, x + 8, y + 8, 1.6, col);
    },
    duration: function (g, x, y, col) {
      var p = g.newPen(g.PenType.SOLID_COLOR, col || "text", 1.3);
      line(g, p, [x + 3.5, y + 0.8, x + 14.5, y + 0.8, x + 14.5, y + 6.5]);
      line(g, p, [x + 7, y + 12.6, x + 1, y + 12.6, x + 1, y + 3.4, x + 12, y + 3.4, x + 12, y + 7]);
      ring(g, p, x + 12, y + 12.2, 3.6);
      line(g, p, [x + 12, y + 10, x + 12, y + 12.4, x + 13.8, y + 12.4]);
    },
    mask: function (g, x, y, col) {
      // The Pen tool's nib, pointing down-left, with a curve and its points above it.
      var p = g.newPen(g.PenType.SOLID_COLOR, col || "text", 1.2);
      line(g, p, [x + 2.5, y + 14.5, x + 8.3, y + 12.2, x + 10.8, y + 9.7, x + 6.3, y + 5.2, x + 3.8, y + 7.7, x + 2.5, y + 14.5]);
      line(g, p, [x + 2.7, y + 14.3, x + 5.4, y + 11.6]);
      line(g, p, [x + 7.5, y + 3.8, x + 12.4, y + 8.6]);
      dot(g, x + 6.1, y + 10, 1.1, col);
      var q = g.newPen(g.PenType.SOLID_COLOR, col || "text", 1);
      line(g, q, arcPts(x + 15.5, y + 11.5, 10.5, 213, 268, 6));
      g.newPath();
      g.rectPath(x + 5.1, y + 4.4, 2.2, 2.2);
      g.rectPath(x + 13.9, y - 0.1, 2.2, 2.2);
      g.strokePath(q);
    },
    help: function (g, x, y, col) {
      dot(g, x + 8, y + 8, 7.5, col);
      cutStroke(g, arcPts(x + 8, y + 6.2, 2.5, 190, 395, 9).concat([x + 8.15, y + 8.9, x + 8.15, y + 10]), 0.95);
      g.newPath();
      g.ellipsePath(x + 7.05, y + 11.2, 2.1, 2.1);
      g.fillPath(null);
    }
  };

  // A button drawn by EffectCraft (hover, press, help tip) with an icon and its label on it.
  function iconButton(bounds, label, icon, tip, name) {
    var b = win.add("button", bounds, "");
    b.helpTip = tip;
    b.name = name;
    b.onDraw = function () {
      var g = this.graphics, w = this.size[0], h = this.size[1];
      g.drawOSControl();
      var tw = label ? textW(label, 12) : 0, gap = label ? 7 : 0;
      var x = Math.round((w - (16 + gap + tw)) / 2), y = Math.round(h / 2 - 8);
      var col = this.enabled ? null : C_DIM;
      ICONS[icon](g, x, y, col);
      if (label) g.drawString(label, col ? g.newPen(g.PenType.SOLID_COLOR, col, 1) : null, x + 16 + gap, y + 1, F12);
    };
    return b;
  }

  ui.auto = iconButton([6, 6, 124, 34], "Auto Crop", "auto",
    "Crop the selected pre-comp(s) to what is drawn at the current frame", "autoCrop");
  ui.duration = iconButton([6, 46, 124, 74], "Crop Duration", "duration",
    "Crop the selected pre-comp to everything it shows between its in and out points", "cropDuration");
  ui.mask = iconButton([6, 116, 124, 144], "Mask Crop", "mask",
    "Crop the selected pre-comp to a mask drawn on its layer", "maskCrop");
  ui.del = win.add("checkbox", [16, 151, 124, 169], "Delete Mask");
  ui.del.value = true;
  ui.del.helpTip = "Remove the mask Mask Crop used once the crop is done";
  ui.del.name = "deleteMask";
  ui.padLabel = win.add("statictext", [16, 182, 46, 198], "Pad:");
  ui.pad = win.add("edittext", [47, 179, 87, 201], "0");
  ui.pad.helpTip = "Pixels to leave around the crop on every side";
  ui.pad.name = "pad";
  ui.pad.onChanging = function () {};
  ui.px = win.add("statictext", [92, 182, 116, 198], "px");
  ui.help = iconButton([6, 228, 44, 252], "", "help", "About " + NAME, "help");

  ui.info = win.add("panel", [132, 2, 330, 254], "PREVIOUS CROP INFO");
  ui.info.name = "info";
  ui.body = ui.info.add("group", [1, 12, 197, 251]);
  ui.body.name = "infoBody";
  ui.bar = ui.info.add("progressbar", [22, 104, 176, 112], 0, 100);
  ui.bar.visible = false;
  ui.cancel = ui.info.add("button", [59, 140, 139, 164], "Cancel");
  ui.cancel.visible = false;
  ui.cancel.name = "cancel";

  ui.body.onDraw = function () {
    var g = this.graphics, w = this.size[0], h = this.size[1];
    var dim = g.newPen(g.PenType.SOLID_COLOR, C_DIM, 1);
    var top = S.scan ? 166 : 184;
    // The message, wrapped, under the content (at most what fits).
    if (S.message) {
      var lines = wrap(S.message, 11, w - 24), pen = S.warn ? g.newPen(g.PenType.SOLID_COLOR, C_WARN, 1) : dim;
      var room = Math.max(1, Math.floor((h - 6 - top) / 14));
      if (!S.report && !S.scan) {
        // No report: the message goes under the empty-state text, centred.
        for (var j = 0; j < Math.min(lines.length, 5); j++) g.drawString(lines[j], pen, Math.round((w - textW(lines[j], 11)) / 2), 142 + j * 14, F11);
      } else {
        if (lines.length > room) {
          lines = lines.slice(0, room);
          lines[room - 1] = fit(lines[room - 1] + "\u2026", 11, w - 24);
        }
        for (var i = 0; i < lines.length; i++) g.drawString(lines[i], pen, 12, top + i * 14, F11);
      }
    }
    if (S.pending) return drawPending(g, w, dim);
    if (S.scan) return drawScan(g, w, dim);
    if (!S.report) return drawEmpty(g, w, S.message ? 92 : 108);
    drawReport(g, w, dim);
  };
  function drawEmpty(g, w, cy) {
    var cx = Math.round(w / 2);
    var disc = g.newPen(g.PenType.SOLID_COLOR, C_DISC, 10.5);
    ring(g, disc, cx, cy, 5.25);
    g.newPath();
    g.ellipsePath(cx - 1.25, cy - 6, 2.5, 2.5);
    g.fillPath(null);
    g.newPath();
    g.rectPath(cx - 1.05, cy - 2, 2.1, 7.4);
    g.fillPath(null);
    var t = "You have not performed a crop.";
    g.drawString(t, null, Math.round((w - textW(t, 12)) / 2), cy + 20, F12);
  }
  function drawScan(g, w, dim) {
    var sc = S.scan, done = sc.next, total = sc.frames.length;
    g.drawString("Crop Duration", null, 12, 8, F12);
    g.drawString(fit(quote(sc.name), 11, w - 24), dim, 12, 25, F11);
    var t = "Scanning frame " + Math.min(done + 1, total) + " of " + total;
    g.drawString(t, null, Math.round((w - textW(t, 12)) / 2), 70, F12);
    var p = Math.round((100 * done) / total) + "%";
    g.drawString(p, dim, Math.round((w - textW(p, 11)) / 2), 106, F11);
  }
  // While a Crop Duration runs in the click, EffectCraft shows the panel as it was drawn on
  // the button's mousedown: say what is happening.
  function drawPending(g, w, dim) {
    var p = S.pending;
    g.drawString("Crop Duration", null, 12, 8, F12);
    g.drawString(fit(quote(p.src.name), 11, w - 24), dim, 12, 25, F11);
    var t = "Scanning " + plural(p.frames.length, "frame", "frames") + "\u2026";
    g.drawString(t, null, Math.round((w - textW(t, 12)) / 2), 82, F12);
    var lines = ["EffectCraft pauses until", "the scan is finished."];
    for (var i = 0; i < lines.length; i++) g.drawString(lines[i], dim, Math.round((w - textW(lines[i], 11)) / 2), 104 + i * 14, F11);
  }
  // `s` cut to `maxW` px with an ellipsis.
  function fit(s, size, maxW) {
    if (textW(s, size) <= maxW) return s;
    while (s.length > 1 && textW(s + "\u2026", size) > maxW) s = s.slice(0, -1);
    return s + "\u2026";
  }
  function drawReport(g, w, dim) {
    var r = S.report;
    g.drawString(r.tool, null, 12, 8, F12);
    var sub = fit(quote(r.name), 11, w - 24 - textW("  " + r.detail, 11)) + "  " + r.detail;
    g.drawString(fit(sub, 11, w - 24), dim, 12, 25, F11);
    // Diagram: the old frame, the kept box (blue), and the pixels taken off each side (+ added).
    var ox = r.before[0], oy = r.before[1], rx = r.rect[0], ry = r.rect[1], rw = r.rect[2], rh = r.rect[3];
    var ux0 = Math.min(0, rx), uy0 = Math.min(0, ry), ux1 = Math.max(ox, rx + rw), uy1 = Math.max(oy, ry + rh);
    var k = Math.min(104 / (ux1 - ux0), 50 / (uy1 - uy0));
    var cx = w / 2, cy = 78;
    var X = function (v) { return cx + (v - (ux0 + ux1) / 2) * k; };
    var Y = function (v) { return cy + (v - (uy0 + uy1) / 2) * k; };
    g.newPath();
    g.rectPath(Math.round(X(0)) + 0.5, Math.round(Y(0)) + 0.5, Math.max(1, Math.round(ox * k)), Math.max(1, Math.round(oy * k)));
    g.strokePath(g.newPen(g.PenType.SOLID_COLOR, C_FRAME, 1));
    g.newPath();
    g.rectPath(Math.round(X(rx)) + 0.5, Math.round(Y(ry)) + 0.5, Math.max(2, Math.round(rw * k)), Math.max(2, Math.round(rh * k)));
    g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, C_ACCENT_FILL));
    g.strokePath(g.newPen(g.PenType.SOLID_COLOR, C_ACCENT, 1));
    var cut = function (v) { return v < 0 ? "+" + -v : String(v); };
    var t = cut(ry), b = cut(oy - (ry + rh)), l = cut(rx), rr = cut(ox - (rx + rw));
    var fx0 = Math.min(X(0), X(rx)), fx1 = Math.max(X(ox), X(rx + rw)), fy0 = Math.min(Y(0), Y(ry)), fy1 = Math.max(Y(oy), Y(ry + rh));
    g.drawString(t, null, Math.round(cx - textW(t, 11) / 2), Math.round(fy0) - 16, F11);
    g.drawString(b, null, Math.round(cx - textW(b, 11) / 2), Math.round(fy1) + 3, F11);
    g.drawString(l, null, Math.round(fx0 - 6 - textW(l, 11)), Math.round(cy - 7), F11);
    g.drawString(rr, null, Math.round(fx1 + 6), Math.round(cy - 7), F11);
    var rows = [["Before", r.before[0] + " \u00d7 " + r.before[1] + " px"], ["After", r.after[0] + " \u00d7 " + r.after[1] + " px"],
      ["In place", plural(r.users, "layer", "layers") + (r.comps > 1 ? ", " + r.comps + " comps" : "")]];
    for (var i = 0; i < rows.length; i++) {
      var y = 126 + i * 18;
      g.drawString(rows[i][0], dim, 12, y + 1, F11);
      g.drawString(rows[i][1], null, Math.round(w - 12 - textW(rows[i][1], 12)), y, F12);
    }
  }

  // EffectCraft runs every onDraw handler again after each event (and scheduled task): only
  // the progress bar needs its value.
  function refresh() {
    try {
      ui.bar.value = S.scan ? Math.round((100 * S.scan.next) / S.scan.frames.length) : 0;
    } catch (e) {}
  }
  function busy(on) {
    ui.auto.enabled = ui.duration.enabled = ui.mask.enabled = !on;
    ui.bar.visible = ui.cancel.visible = on;
    refresh();
  }

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

  var HELP = [
    ["", "Crops a pre-comp to what is drawn in it and keeps every layer that uses it exactly where it was."],
    ["Auto Crop", "Crops each selected pre-comp to the pixels it shows at the current frame. Layers that are switched off or transparent don't count, and neither do the layer's own masks and effects."],
    ["Crop Duration", "Like Auto Crop, but over every frame between the layer's in and out points, so nothing that moves gets cut. EffectCraft pauses until the scan is done."],
    ["Mask Crop", "Crops the pre-comp to the outline of a mask on its layer (a rectangle mask gives an exact box). With several masks, select one in the Timeline or pick it from the list."],
    ["Delete Mask", "Removes that mask after the crop. Untick it to keep the mask: it stays lined up with the content."],
    ["Pad", "Pixels to leave around the crop on every side: whole pixels, and a negative value crops into the content."],
    ["Previous Crop Info", "The last crop: the sizes before and after, and the pixels taken off each side (+ where the padding added some)."],
    ["What a crop changes", "The pre-comp's top-level layers move by the crop's offset, keys included, and every layer that uses the pre-comp, in every composition, gets its Anchor Point, masks and effect points moved to match. One Undo takes it all back. Worth checking: Position expressions inside the pre-comp that ignore value, Anchor Point expressions on the layers that use it, and 3D layers or cameras in the pre-comp (their view is centred on the comp)."]
  ];
  function about() {
    var dlg = new Window("dialog", NAME);
    dlg.orientation = "column";
    dlg.alignChildren = ["fill", "top"];
    dlg.margins = [18, 14, 18, 12];
    dlg.spacing = 2;
    var title = dlg.add("statictext", undefined, NAME + " " + VERSION + " for EffectCraft");
    title.preferredSize = [440, 22];
    title.onDraw = function () {
      // No bold in drawString: the same text twice, 0.3 px apart.
      this.graphics.drawString(this.text, null, 0, 1, F12);
      this.graphics.drawString(this.text, null, 0.3, 1, F12);
    };
    for (var i = 0; i < HELP.length; i++) {
      if (HELP[i][0]) {
        var head = dlg.add("statictext", undefined, HELP[i][0]);
        head.preferredSize = [440, 22];
        head.onDraw = function () {
          var g = this.graphics;
          g.drawString(this.text, g.newPen(g.PenType.SOLID_COLOR, C_ACCENT, 1), 0, 6, F12);
        };
      }
      var lines = wrap(HELP[i][1], 12, 436);
      var body = dlg.add("statictext", undefined, lines.join("\n"));
      body.preferredSize = [440, lines.length * 16 + 2];
    }
    var credit = dlg.add("statictext", undefined, "Made for EffectCraft 0.10.0.");
    credit.preferredSize = [440, 26];
    credit.onDraw = function () {
      var g = this.graphics;
      g.drawString(this.text, g.newPen(g.PenType.SOLID_COLOR, C_DIM, 1), 0, 10, F11);
    };
    var close = dlg.add("button", undefined, "Close");
    close.alignment = ["right", "top"];
    close.onClick = function () { dlg.close(); };
    dlg.show();
  }

  // ---------------------------------------------------------------- wiring

  ui.auto.onClick = function () { guard(autoCrop); };
  ui.duration.onClick = function () { guard(cropDuration); };
  ui.mask.onClick = function () { guard(maskCrop); };
  ui.cancel.onClick = cancelScan;
  // A press on Crop Duration shows the scan in PREVIOUS CROP INFO before the click runs it.
  ui.duration.addEventListener("mousedown", function () {
    try {
      if (S.scan || tasksRun()) return;
      var plan = durationPlan();
      S.pending = plan.error ? null : plan;
    } catch (e) {
      S.pending = null;
    }
  });
  ui.duration.addEventListener("mouseout", function () { S.pending = null; });
  ui.help.onClick = about;
  function guard(fn) {
    try {
      fn();
    } catch (e) {
      say("Something went wrong: " + (e && e.message ? e.message : e), true);
    }
  }

  G.__croppingKitEC[KEY] = {
    step: function () {
      try {
        scanStep();
      } catch (e) {
        endScan("Crop Duration failed: " + (e && e.message ? e.message : e), true);
      }
    },
    // For scripted tests: the panel's state and tools.
    state: S,
    ui: ui,
    autoCrop: autoCrop,
    cropDuration: cropDuration,
    maskCrop: maskCrop,
    cancel: cancelScan,
    pathBox: pathBox,
    boundsExpression: boundsExpression,
    forceTasks: function (v) { TASKS = v; }
  };
  G.__croppingKitLast = G.__croppingKitEC[KEY];

  if (win instanceof Window) {
    win.center();
    win.show();
  } else {
    win.layout.layout(true);
  }
})(this);
