// ============================================================
// MotionPotion Overtime  —  free After Effects script
// ------------------------------------------------------------
// What a property does after its keyframes run out. Select properties (or just
// layers) and click a mode:
//
//   Cycle      loopOut("cycle")      starts over
//   Ping Pong  loopOut("pingpong")   back and forth
//   Offset     loopOut("offset")     each repeat carries on from the last —
//                                    endless movement, not a reset
//   Continue   loopOut("continue")   keeps the last velocity, no repeat
//
// In / Out picks which end, and Keys limits how many keyframes take part
// (0 = all). "continue" never takes a keyframe count — AE's own signature has
// no room for one — so the field is ignored there.
//
// Match ends writes the FIRST keyframe's value into the LAST one, which is what
// stops a cycle from jumping. Remove strips the expression, but only if this
// script wrote it.
//
// Install (either works):
//   • Dockable panel: put this .jsx in After Effects'  ScriptUI Panels  folder,
//     then open it from  Window > MotionPotion-Overtime.jsx
//   • One-off:  File > Scripts > Run Script File…  and pick this file.
//
// Part of MotionPotion — motionpotion.co
// ============================================================

(function (thisObj) {

  var MAX_DEPTH = 30;
  var PREFS = "MotionPotion Overtime";      // AE settings, no file access needed

  function loadPref(key, dflt) {
    try { if (app.settings.haveSetting(PREFS, key)) return app.settings.getSetting(PREFS, key); } catch (e) {}
    return dflt;
  }
  function savePref(key, value) {
    try { app.settings.saveSetting(PREFS, key, String(value)); } catch (e) {}
  }

  function trim(s) { return String(s).replace(/^\s+|\s+$/g, ""); }

  // ---------- the expression ----------
  // "continue" has no numKeyframes argument in AE's signature, so the count is
  // dropped there rather than written out and silently ignored.
  function buildExpression(mode, dirIn, dirOut, keys) {
    var arg = (mode === "continue" || !keys || keys <= 0) ? "" : ", " + keys;
    var out = 'loopOut("' + mode + '"' + arg + ')';
    var inn = 'loopIn("' + mode + '"' + arg + ')';
    if (dirIn && dirOut) {
      // Both ends. The conditional form works for every property type; the usual
      // loopIn() + loopOut() - value trick only works on numbers and arrays, and
      // would throw on a path or a text source.
      return 'time < thisProperty.key(1).time ? ' + inn + ' : ' + out;
    }
    return dirIn ? inn : out;
  }

  // Only ever remove or overwrite what this script wrote.
  function isOurs(src) {
    var s = trim(src);
    if (s === "") return false;
    if (/^loop(In|Out)(Duration)?\s*\(/.test(s)) return true;
    if (/^time\s*<\s*thisProperty\.key\(1\)\.time\s*\?\s*loop(In|Out)\s*\(/.test(s)) return true;
    return false;
  }

  // ---------- gathering properties ----------
  function isLoopable(p) {
    try {
      if (p.propertyType !== PropertyType.PROPERTY) return false;
      if (!p.canSetExpression) return false;
    } catch (e) { return false; }
    return true;
  }

  function walkProperties(group, fn, depth) {
    if (depth > MAX_DEPTH) return;
    var n = 0;
    try { n = group.numProperties; } catch (e) { return; }
    for (var i = 1; i <= n; i++) {
      var p;
      try { p = group.property(i); } catch (e2) { continue; }
      if (!p) continue;
      try {
        if (p.propertyType === PropertyType.PROPERTY) fn(p);
        else walkProperties(p, fn, depth + 1);
      } catch (e3) {}
    }
  }

  // Selected properties if there are any; otherwise every animated property of the
  // selected layers — and the report says how many that turned out to be.
  function gather(comp, stats) {
    var out = [];
    var sel = comp.selectedProperties;
    for (var i = 0; i < sel.length; i++) {
      if (isLoopable(sel[i])) out.push(sel[i]);
    }
    if (out.length > 0) return out;

    var layers = comp.selectedLayers;
    for (var l = 0; l < layers.length; l++) {
      if (layers[l].locked) { stats.locked++; continue; }
      walkProperties(layers[l], function (p) {
        if (isLoopable(p) && p.numKeys > 0) out.push(p);
      }, 0);
    }
    stats.fromLayers = (out.length > 0);
    return out;
  }

  // ---------- actions ----------
  function applyExpression(props, expr, stats) {
    for (var i = 0; i < props.length; i++) {
      var p = props[i];
      if (p.numKeys < 2) { stats.noKeys++; continue; }        // nothing to repeat
      var cur = "";
      try { cur = p.expression || ""; } catch (e) {}
      if (trim(cur) !== "" && !isOurs(cur)) { stats.foreign++; continue; }
      try { p.expression = expr; stats.done++; }
      catch (e2) { stats.failed++; }
    }
  }

  function removeExpression(props, stats) {
    for (var i = 0; i < props.length; i++) {
      var p = props[i];
      var cur = "";
      try { cur = p.expression || ""; } catch (e) { continue; }
      if (trim(cur) === "") continue;
      if (!isOurs(cur)) { stats.foreign++; continue; }
      try { p.expression = ""; stats.done++; }
      catch (e2) { stats.failed++; }
    }
  }

  // The first keyframe's value written into the last one — the usual reason a
  // cycle looks broken is that those two do not match. Interpolation and eases on
  // the last key are left alone.
  function matchEnds(props, stats) {
    for (var i = 0; i < props.length; i++) {
      var p = props[i];
      if (p.numKeys < 2) { stats.noKeys++; continue; }
      try {
        p.setValueAtTime(p.keyTime(p.numKeys), p.keyValue(1));
        stats.done++;
      } catch (e) { stats.failed++; }
    }
  }

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

    var stats = { done: 0, noKeys: 0, foreign: 0, failed: 0, locked: 0, fromLayers: false };
    var props = gather(comp, stats);
    if (props.length === 0) {
      alert("Select a few keyframed properties — or just the layers, and every animated property on them is used.");
      return;
    }

    app.beginUndoGroup("MotionPotion Overtime");
    try {
      if (action === "remove") removeExpression(props, stats);
      else if (action === "match") matchEnds(props, stats);
      else applyExpression(props, buildExpression(action, opts.dirIn, opts.dirOut, opts.keys), stats);
    } finally {
      app.endUndoGroup();
    }

    var notes = [];
    if (stats.noKeys) notes.push(stats.noKeys + " property(s) had fewer than 2 keyframes");
    if (stats.foreign) notes.push(stats.foreign + " kept an expression this script did not write");
    if (stats.locked) notes.push(stats.locked + " locked layer(s) skipped");
    if (stats.failed) notes.push(stats.failed + " could not be written");

    if (stats.done === 0) {
      alert("Nothing changed" + (notes.length ? ":\n• " + notes.join("\n• ") : "."));
    } else if (notes.length || stats.fromLayers) {
      var head = stats.done + " property(s) " +
                 (action === "remove" ? "cleared." : action === "match" ? "matched." : "set.");
      if (stats.fromLayers) head += " (No properties were selected, so every animated one on the" +
                                    " selected layers was used.)";
      alert(head + (notes.length ? "\n\nSkipped:\n• " + notes.join("\n• ") : ""));
    }
  }

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

    var modes = [["cycle", "Cycle", "Start over from the first keyframe"],
                 ["pingpong", "Ping Pong", "Run back and forth"],
                 ["offset", "Offset", "Carry on from where the last repeat ended — endless movement"],
                 ["continue", "Continue", "Keep the last velocity, without repeating"]];

    function options() {
      var k = parseInt(keysEt.text, 10);
      if (isNaN(k) || k < 0) k = 0;
      return { dirIn: inCb.value, dirOut: outCb.value, keys: k };
    }

    function fire(action) {
      var o = options();
      if (action !== "remove" && action !== "match" && !o.dirIn && !o.dirOut) {
        alert("Tick In, Out, or both.");
        return;
      }
      try { run(action, o); }
      catch (e) { alert("MotionPotion Overtime\n" + (e && e.message ? e.message : e)); }
    }

    // Drawn, not native. A native button picks up macOS's default-button tint, and
    // there is no way to refuse it from ScriptUI — so the panel draws its own.
    function drawButton() {
      var g = this.graphics;
      var w = this.size.width, h = this.size.height;

      g.newPath(); g.rectPath(0, 0, w, h);
      g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, [0.20, 0.20, 0.20, 1]));

      g.newPath(); g.rectPath(0.5, 0.5, w - 1, h - 1);
      g.strokePath(g.newPen(g.PenType.SOLID_COLOR, [0.36, 0.36, 0.36, 1], 1));

      var font = ScriptUI.newFont("dialog", ScriptUI.FontStyle.REGULAR, 11);
      var d = g.measureString(this.label, font, w);
      g.drawString(this.label, g.newPen(g.PenType.SOLID_COLOR, [0.85, 0.85, 0.85, 1], 1),
                   (w - d.width) / 2, (h - d.height) / 2, font);
    }

    // A control with a custom onDraw can stop firing onClick on macOS, so listen for
    // both and de-dupe: the same button twice inside 300 ms is one physical click.
    var lastBtn = null, lastTime = 0;
    function addButton(parent, label, tip, action) {
      var b = parent.add("iconbutton", undefined, undefined, { style: "toolbutton" });
      b.preferredSize = [78, 22];
      b.minimumSize = [78, 22];
      b.label = label;
      b.helpTip = tip;
      b.onDraw = drawButton;
      function hit() {
        var now = (new Date()).getTime();
        if (b === lastBtn && (now - lastTime) < 300) return;
        lastBtn = b; lastTime = now;
        fire(action);
      }
      b.onClick = hit;
      b.addEventListener("mousedown", hit);
      return b;
    }

    for (var r = 0; r < 2; r++) {
      var row = win.add("group");
      row.orientation = "row";
      row.spacing = 4;
      row.alignment = ["center", "top"];
      for (var c = 0; c < 2; c++) {
        var m = modes[r * 2 + c];
        addButton(row, m[1], m[2], m[0]);
      }
    }

    var opt = win.add("group");
    opt.orientation = "row";
    opt.spacing = 6;
    opt.alignment = ["center", "top"];
    var inCb = opt.add("checkbox", undefined, "In");
    inCb.helpTip = "Also loop before the first keyframe.";
    var outCb = opt.add("checkbox", undefined, "Out");
    outCb.helpTip = "Loop after the last keyframe.";
    opt.add("statictext", undefined, "Keys");
    var keysEt = opt.add("edittext", undefined, "0");
    keysEt.characters = 3;
    keysEt.helpTip = "How many keyframes take part. 0 = all. Ignored by Continue.";

    inCb.value = loadPref("in", "0") === "1";
    outCb.value = loadPref("out", "1") === "1";
    keysEt.text = loadPref("keys", "0");
    inCb.onClick = function () { savePref("in", inCb.value ? 1 : 0); };
    outCb.onClick = function () { savePref("out", outCb.value ? 1 : 0); };
    keysEt.onChange = function () { savePref("keys", keysEt.text); };

    var foot = win.add("group");
    foot.orientation = "row";
    foot.spacing = 4;
    foot.alignment = ["center", "top"];
    addButton(foot, "Match ends",
              "Write the first keyframe's value into the last one, so a cycle does not jump.", "match");
    addButton(foot, "Remove",
              "Strip the loop expression — only where this script wrote it.", "remove");

    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);
