// ============================================================
// MotionPotion Decompose  —  free After Effects script
// ------------------------------------------------------------
// Pulls a precomp's layers out into the comp you are in, looking identical.
//
// Two paths, picked automatically:
//
//   1. CLEAN  — the precomp layer is quiet (no animation, scale 100, rotation 0,
//      2D, opacity 100). Its matrix is then a pure translation, which is baked
//      into the extracted layers' Position (every keyframe), and the precomp
//      layer is deleted. Nothing is left behind.
//
//   2. DRIVER — the precomp layer is animated (or scaled / rotated / 3D, or
//      something is parented to it). The layer STAYS, with every keyframe and
//      ease untouched, but its comp is emptied so it draws nothing; the
//      extracted layers are parented to it with setParentWithJump. Parenting
//      uses the very same matrix the precomp was rendered through, so this is
//      exact — and the animation still drives the now-editable layers.
//
// Static scale/rotation is NOT baked on purpose: composing two TRS transforms
// with non-uniform scale produces shear, which AE cannot represent.
//
// It refuses (and says why) when the precomp layer carries something that
// applies to the RENDERED RESULT and cannot survive parenting: effects, masks,
// opacity, blend mode, track matte, time remap, stretch, adjustment layer.
//
// Install (either works):
//   • Dockable panel: put this .jsx in After Effects'  ScriptUI Panels  folder,
//     then open it from  Window > MotionPotion-Decompose.jsx
//   • One-off:  File > Scripts > Run Script File…  and pick this file.
//
// Part of MotionPotion — motionpotion.co
// ============================================================

(function (thisObj) {

  var EPS = 1e-6;

  // ---------- small helpers ----------
  function isAnimated(prop) {
    return !!prop && (prop.numKeys > 0 || prop.expressionEnabled);
  }

  function tg(layer) { return layer.property("ADBE Transform Group"); }

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

  // Position, anchor-aware, separated-dimensions aware.
  function positionXY(layer) {
    var tr = tg(layer);
    var p = tr.property("ADBE Position");
    if (p.dimensionsSeparated) {
      return [tr.property("ADBE Position_0").value, tr.property("ADBE Position_1").value];
    }
    var v = p.value;
    return [v[0], v[1]];
  }

  function shiftScalar(prop, d, warn) {
    if (!prop) return;
    if (prop.expressionEnabled) { warn.expr++; 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 shiftVec(prop, dx, dy, warn) {
    if (prop.expressionEnabled) { warn.expr++; 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 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);
    }
  }

  function shiftPosition(layer, dx, dy, warn) {
    var tr = tg(layer);
    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);
    }
  }

  // ---------- inspection ----------
  // Everything here applies to the precomp's RENDERED RESULT. Parenting carries
  // the transform and nothing else, so an emptied driver would silently drop it.
  function blockersFor(layer, comp) {
    var out = [];
    var tr = tg(layer);

    if (layer.locked) out.push("the layer is locked");
    if (propCount(layer, "ADBE Effect Parade") > 0) out.push("it has effects");
    if (propCount(layer, "ADBE Mask Parade") > 0) out.push("it has masks");

    var op = tr.property("ADBE Opacity");
    if (isAnimated(op) || Math.abs(op.value - 100) > EPS) out.push("its opacity is not a flat 100%");

    try {
      if (layer.blendingMode !== BlendingMode.NORMAL) out.push("its blending mode is not Normal");
    } catch (e) {}

    try {
      if (layer.trackMatteType && layer.trackMatteType !== TrackMatteType.NO_TRACK_MATTE) out.push("it uses a track matte");
    } catch (e2) {}
    // Newer AE exposes isTrackMatte; before that, the matte is simply the layer
    // sitting directly above a matted one.
    var isMatte = null;
    try { if (typeof layer.isTrackMatte === "boolean") isMatte = layer.isTrackMatte; } catch (e3) {}
    if (isMatte === null) {
      try {
        var below = (layer.index < comp.numLayers) ? comp.layer(layer.index + 1) : null;
        isMatte = !!(below && below.trackMatteType && below.trackMatteType !== TrackMatteType.NO_TRACK_MATTE);
      } catch (e4) { isMatte = false; }
    }
    if (isMatte) out.push("it IS a track matte for another layer");

    try { if (layer.timeRemapEnabled) out.push("Time Remap is on"); } catch (e5) {}
    try { if (Math.abs(layer.stretch - 100) > EPS) out.push("its time stretch is not 100%"); } catch (e6) {}
    try { if (layer.adjustmentLayer) out.push("it is an adjustment layer"); } catch (e7) {}

    return out;
  }

  // Can the precomp layer's transform be folded into the children's Position?
  // Only when it reduces to a pure translation and nothing hangs off it.
  function needsDriver(layer, comp) {
    if (layer.threeDLayer) return true;

    var tr = tg(layer);
    var names = ["ADBE Anchor Point", "ADBE Position", "ADBE Position_0", "ADBE Position_1",
                 "ADBE Scale", "ADBE Rotate Z", "ADBE Opacity"];
    for (var i = 0; i < names.length; i++) {
      if (isAnimated(tr.property(names[i]))) return true;
    }

    var s = tr.property("ADBE Scale").value;
    if (Math.abs(s[0] - 100) > EPS || Math.abs(s[1] - 100) > EPS) return true;

    var rp = tr.property("ADBE Rotate Z");
    if (rp && Math.abs(rp.value) > EPS) return true;

    for (var j = 1; j <= comp.numLayers; j++) {          // someone rides this layer
      if (comp.layer(j).parent === layer) return true;
    }
    return false;
  }

  function warningsFor(layer, src, comp) {
    var out = [];
    var blend = 0, adj = 0;
    try {
      if (Math.abs(src.frameRate - comp.frameRate) > 1e-4) {
        out.push("the precomp runs at " + src.frameRate + " fps, this comp at " + comp.frameRate + " — keyframes may land off-frame");
      }
    } catch (e0) {}
    for (var i = 1; i <= src.numLayers; i++) {
      var l = src.layer(i);
      try { if (l.blendingMode !== BlendingMode.NORMAL) blend++; } catch (e) {}
      try { if (l.adjustmentLayer) adj++; } catch (e2) {}
    }
    if (blend) out.push(blend + " inner layer(s) have a blending mode — they now composite against this comp, not against the precomp's empty background");
    if (adj)   out.push(adj + " inner adjustment layer(s) — they now affect everything below them here");
    try { if (layer.collapseTransformation) out.push("Collapse Transformations was on — rasterisation may differ"); } catch (e3) {}
    return out;
  }

  // ---------- the work ----------
  function decompose(layer, comp, report) {
    var src = layer.source;
    var name = layer.name;

    var blockers = blockersFor(layer, comp);
    if (blockers.length) { report.blocked.push(name + " — " + blockers.join("; ")); return; }
    if (src.numLayers === 0) { report.blocked.push(name + " — the precomp is empty"); return; }

    var driver = needsDriver(layer, comp);
    var warn = { expr: 0 };
    var notes = warningsFor(layer, src, comp);

    // In the driver path the source comp gets emptied, so protect other uses of it.
    if (driver) {
      var uses = 0;
      for (var u = 1; u <= comp.numLayers; u++) if (comp.layer(u).source === src) uses++;
      var elsewhere = (src.usedIn && src.usedIn.length > 1) || uses > 1;
      if (elsewhere) {
        var dup = src.duplicate();
        dup.name = src.name + " [emptied]";
        layer.replaceSource(dup, false);
        src = dup;
        notes.push("the comp was used elsewhere, so a copy was emptied instead: \"" + dup.name + "\"");
      } else {
        src.name = src.name + " [emptied]";
      }
    }

    // 1. snapshot the source layers (index-aligned: srcLayers[k].index === k + 1)
    var srcLayers = [];
    for (var i = 1; i <= src.numLayers; i++) srcLayers.push(src.layer(i));

    // 2. copy them out. Each copy lands at index 1, so copying back-to-front
    //    leaves them in the right relative order.
    var copies = [];
    for (var c = srcLayers.length - 1; c >= 0; c--) {
      srcLayers[c].copyToComp(comp);
      copies[c] = comp.layer(1);
    }

    // 3. put the block exactly where the precomp layer sits
    for (var m = 0; m < copies.length; m++) copies[m].moveBefore(layer);

    // 4. copyToComp does not carry parenting — rebuild it inside the copied set.
    //    WithJump keeps the authored values, which is what we want: they were
    //    authored under that parent already.
    var unlocked = [];
    for (var q = 0; q < copies.length; q++) {
      if (copies[q].locked) { copies[q].locked = false; unlocked.push(copies[q]); }
    }
    for (var p = 0; p < srcLayers.length; p++) {
      var par = srcLayers[p].parent;
      try {
        if (par) copies[p].setParentWithJump(copies[par.index - 1]);
        else if (copies[p].parent) copies[p].setParentWithJump(null);
      } catch (e) { report.warn.push(name + " — could not re-link the parent of \"" + copies[p].name + "\""); }
    }

    // 5. time: the precomp's internal clock is offset by the layer's startTime,
    //    and only the layer's trim window is visible.
    var dt = layer.startTime;
    var hidden = 0;
    for (var t = 0; t < copies.length; t++) {
      var cl = copies[t];
      try {
        if (Math.abs(dt) > EPS) cl.startTime = cl.startTime + dt;   // moves its keys with it
        var inT = Math.max(cl.inPoint, layer.inPoint);
        var outT = Math.min(cl.outPoint, layer.outPoint);
        if (outT - inT <= EPS) {
          cl.enabled = false; hidden++;
        } else {
          if (inT > cl.inPoint + EPS) cl.inPoint = inT;
          if (outT < cl.outPoint - EPS) cl.outPoint = outT;
        }
      } catch (e2) {}
      if (!layer.enabled) { try { cl.enabled = false; } catch (e3) {} }
    }
    if (hidden) notes.push(hidden + " layer(s) fell outside the precomp layer's trim and were switched off");

    // 6. hand the transform over
    if (driver) {
      for (var d = 0; d < copies.length; d++) {
        if (srcLayers[d].parent) continue;              // rides its own parent already
        try { copies[d].setParentWithJump(layer); } catch (e4) {
          report.warn.push(name + " — could not parent \"" + copies[d].name + "\" to the driver");
        }
      }
      for (var k = src.numLayers; k >= 1; k--) src.layer(k).remove();   // empty the comp
    } else {
      var pos = positionXY(layer);
      var anc = tg(layer).property("ADBE Anchor Point").value;
      var dx = pos[0] - anc[0], dy = pos[1] - anc[1];
      if (Math.abs(dx) > EPS || Math.abs(dy) > EPS) {
        for (var b = 0; b < copies.length; b++) {
          if (srcLayers[b].parent) continue;            // only roots carry the offset
          try { shiftPosition(copies[b], dx, dy, warn); } catch (e5) {}
        }
      }
      layer.remove();
    }

    for (var r = 0; r < unlocked.length; r++) { try { unlocked[r].locked = true; } catch (e6) {} }
    for (var s = 0; s < copies.length; s++) { try { copies[s].selected = true; } catch (e7) {} }

    if (warn.expr) notes.push(warn.expr + " Position expression(s) could not be offset");
    report.done.push(name + " → " + copies.length + " layer(s)" + (driver ? ", animation kept on the emptied layer" : ""));
    for (var n = 0; n < notes.length; n++) report.warn.push(name + " — " + notes[n]);
  }

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

    var targets = [];
    var sel = comp.selectedLayers;
    for (var i = 0; i < sel.length; i++) {
      try { if (sel[i].source instanceof CompItem) targets.push(sel[i]); } catch (e) {}
    }
    if (targets.length === 0) { alert("Select a precomp layer first."); return; }

    var report = { done: [], blocked: [], warn: [] };
    app.beginUndoGroup("MotionPotion Decompose");
    try {
      for (var t = 0; t < targets.length; t++) {
        try { decompose(targets[t], comp, report); }
        catch (e2) { report.blocked.push(targets[t].name + " — " + (e2 && e2.message ? e2.message : e2)); }
      }
    } finally {
      app.endUndoGroup();
    }

    // Silent on a clean run — the timeline shows the result. Speak up only when
    // something was refused or is worth knowing, and then say what DID happen too.
    var msg = [];
    if (report.blocked.length) msg.push("Not decomposed:\n• " + report.blocked.join("\n• "));
    if (report.warn.length) {
      if (report.done.length) msg.push("Decomposed:\n• " + report.done.join("\n• "));
      msg.push("Heads up:\n• " + report.warn.join("\n• "));
    }
    if (msg.length) alert(msg.join("\n\n"));
  }

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

    var btn = win.add("button", undefined, "Decompose");
    btn.helpTip = "Pull the selected precomp's layers out into this comp.";
    btn.onClick = function () {
      try { run(); } catch (e) { alert("MotionPotion Decompose\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);
