greenlight-motion 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6868,16 +6868,41 @@ var TXA = (function() {
6868
6868
  };
6869
6869
  const LAYER_FIELDS2 = {
6870
6870
  pivotX: num8("Pivot X", 0, -100, 100, 1, "%", false, "% of the unit from its centre: \u2212100 = left edge, 100 = right edge"),
6871
- pivotY: num8("Pivot Y", 0, -100, 100, 1, "%", false, "% of the unit from its centre: \u2212100 = top, 100 = bottom")
6871
+ pivotY: num8("Pivot Y", 0, -100, 100, 1, "%", false, "% of the unit from its centre: \u2212100 = top, 100 = bottom"),
6872
+ // Who films the units' depth (Position Z, Rotate 3D). Each unit = its own camera in
6873
+ // front of it (letters turn like separate cards). Layer / Project = one camera for
6874
+ // the whole text: nearer letters grow and every letter converges to one vanishing
6875
+ // point. Project = the project's Camera layer itself (dolly, pan, tilt, zoom).
6876
+ camera: en(
6877
+ "Camera",
6878
+ "unit",
6879
+ { unit: "Each unit", layer: "Layer", project: "Project" },
6880
+ "Each unit: every letter (word, line) has its own little camera. Layer: one camera in front of the text. Project camera: the project's Camera layer films the letters in depth"
6881
+ ),
6882
+ perspective: num8(
6883
+ "Perspective",
6884
+ 0,
6885
+ 0,
6886
+ 2e4,
6887
+ 10,
6888
+ "px",
6889
+ false,
6890
+ "The layer camera's distance from the text. 0 = the composition camera's, so a letter moved in Z looks like a layer moved in Z"
6891
+ )
6872
6892
  };
6873
- const LAYER_ITEM_KEYS2 = { pivotX: "textAnimatorPivotX", pivotY: "textAnimatorPivotY" };
6893
+ const LAYER_ITEM_KEYS2 = { pivotX: "textAnimatorPivotX", pivotY: "textAnimatorPivotY", camera: "textAnimatorCamera", perspective: "textAnimatorPerspective" };
6894
+ const LAYER_SPARSE = { camera: "unit", perspective: 0 };
6874
6895
  const EFFECTS2 = [
6875
6896
  {
6876
6897
  key: "position",
6877
6898
  label: "Position",
6878
6899
  group: "Transform",
6879
6900
  desc: "Move the selected units",
6880
- fields: { x: num8("X", 0, -2e3, 2e3, 1, "px"), y: num8("Y", -80, -2e3, 2e3, 1, "px") }
6901
+ fields: {
6902
+ x: num8("X", 0, -2e3, 2e3, 1, "px"),
6903
+ y: num8("Y", -80, -2e3, 2e3, 1, "px"),
6904
+ z: num8("Z", 0, -2e4, 2e4, 1, "px", true, "Depth: + toward you, \u2212 away. Under a shared camera (More options \u25B8 Camera) letters converge to one vanishing point; with each unit's own camera a unit grows or shrinks in place")
6905
+ }
6881
6906
  },
6882
6907
  {
6883
6908
  key: "scale",
@@ -7111,7 +7136,10 @@ var TXA = (function() {
7111
7136
  for (const [k, key] of Object.entries(LAYER_ITEM_KEYS2)) raw[k] = item[key];
7112
7137
  const lf = sanitizeGroup(LAYER_FIELDS2, raw);
7113
7138
  const out = { textAnimators: list };
7114
- for (const [k, key] of Object.entries(LAYER_ITEM_KEYS2)) out[key] = lf[k];
7139
+ for (const [k, key] of Object.entries(LAYER_ITEM_KEYS2)) {
7140
+ if (k in LAYER_SPARSE && (lf[k] === LAYER_SPARSE[k] || lf.camera === "unit")) continue;
7141
+ out[key] = lf[k];
7142
+ }
7115
7143
  return out;
7116
7144
  }
7117
7145
  function layerSettings2(item) {
@@ -7415,7 +7443,7 @@ var TXA = (function() {
7415
7443
  return n1 * Math.pow(p - 2.625 / c, 2) + 0.984375;
7416
7444
  }
7417
7445
  const elasticOut = (p) => p === 1 ? 1 : Math.pow(2, -10 * p) * Math.sin((p - 0.075) * (2 * Math.PI / 0.3)) + 1;
7418
- function bezier(p1x, p1y, p2x, p2y) {
7446
+ function bezier2(p1x, p1y, p2x, p2y) {
7419
7447
  const bx = (t) => 3 * p1x * t * (1 - t) * (1 - t) + 3 * p2x * t * t * (1 - t) + t * t * t;
7420
7448
  const by = (t) => 3 * p1y * t * (1 - t) * (1 - t) + 3 * p2y * t * t * (1 - t) + t * t * t;
7421
7449
  return (x) => {
@@ -7468,22 +7496,22 @@ var TXA = (function() {
7468
7496
  Impulse: sampled(SAMPLED.Impulse),
7469
7497
  Swing: sampled(SAMPLED.Swing),
7470
7498
  Anticipate: sampled(SAMPLED.Anticipate),
7471
- Natural: bezier(0.8, 0, 0.2, 1),
7472
- "Slow Down": bezier(0, 0, 0, 1),
7473
- "AE In (arrive)": bezier(0.3141666673, 0, 0.0875000209, 1),
7474
- "AE Smooth": bezier(0.882083327, 0, 0.2445833683, 1),
7475
- "AE Out (leave)": bezier(0.8699999754, 0, 0.6433336333, 1),
7476
- Punch: bezier(0.7, 0, 0.1, 1),
7477
- "Apple Out": bezier(0.32, 0.72, 0, 1),
7478
- "Smooth Glide": bezier(0.2111, 0.7721, 0.8541, 0.112),
7479
- "Glide Easing Stop": bezier(0, 0.9125, 1, -0.0338)
7499
+ Natural: bezier2(0.8, 0, 0.2, 1),
7500
+ "Slow Down": bezier2(0, 0, 0, 1),
7501
+ "AE In (arrive)": bezier2(0.3141666673, 0, 0.0875000209, 1),
7502
+ "AE Smooth": bezier2(0.882083327, 0, 0.2445833683, 1),
7503
+ "AE Out (leave)": bezier2(0.8699999754, 0, 0.6433336333, 1),
7504
+ Punch: bezier2(0.7, 0, 0.1, 1),
7505
+ "Apple Out": bezier2(0.32, 0.72, 0, 1),
7506
+ "Smooth Glide": bezier2(0.2111, 0.7721, 0.8541, 0.112),
7507
+ "Glide Easing Stop": bezier2(0, 0.9125, 1, -0.0338)
7480
7508
  };
7481
7509
  })();
7482
7510
  let lastBez = null;
7483
7511
  const curveFn2 = (name, handles) => {
7484
7512
  if (name === "Custom" && Array.isArray(handles) && handles.length === 4) {
7485
7513
  const key = handles.join(",");
7486
- if (!lastBez || lastBez.key !== key) lastBez = { key, fn: bezier(...handles) };
7514
+ if (!lastBez || lastBez.key !== key) lastBez = { key, fn: bezier2(...handles) };
7487
7515
  return lastBez.fn;
7488
7516
  }
7489
7517
  return CURVE_FNS[name] || CURVE_FNS["Ease Out"];
@@ -7935,7 +7963,7 @@ var TXA = (function() {
7935
7963
  }
7936
7964
  function xfOf(st, level) {
7937
7965
  const xf = st.xf || (st.xf = {});
7938
- return xf[level] || (xf[level] = { tx: 0, ty: 0, s: 1, sx: 1, sy: 1, rot: 0, rx: 0, ry: 0, skew: 0, axis: 0, persp: 0 });
7966
+ return xf[level] || (xf[level] = { tx: 0, ty: 0, tz: 0, s: 1, sx: 1, sy: 1, rot: 0, rx: 0, ry: 0, skew: 0, axis: 0, persp: 0 });
7939
7967
  }
7940
7968
  function applyEffect(st, type, v, k, i, time, g, fillRgb, level = "char") {
7941
7969
  const kp = clamp6(k, 0, 1);
@@ -7944,6 +7972,7 @@ var TXA = (function() {
7944
7972
  const e2 = xfOf(st, level);
7945
7973
  e2.tx += v.x * k;
7946
7974
  e2.ty += v.y * k;
7975
+ e2.tz += (v.z || 0) * k;
7947
7976
  break;
7948
7977
  }
7949
7978
  case "scale": {
@@ -8075,13 +8104,20 @@ var TXA = (function() {
8075
8104
  return [H[0] / w, H[3] / w, H[1] / w, H[4] / w, H[2] / w, H[5] / w];
8076
8105
  }
8077
8106
  const W_NEAR = 0.25;
8078
- function levelH(e2, box2, pivot) {
8107
+ const Z_PERSP = 2e3;
8108
+ function levelH(e2, box2, pivot, zPersp = 0) {
8079
8109
  const cx = (box2.x0 + box2.x1) / 2;
8080
8110
  const cy = (box2.y0 + box2.y1) / 2;
8081
8111
  const px = cx + pivot.x * (box2.x1 - box2.x0) / 2;
8082
8112
  const py = cy + pivot.y * (box2.y1 - box2.y0) / 2;
8083
- const sx = e2.s * e2.sx;
8084
- const sy = e2.s * e2.sy;
8113
+ let sx = e2.s * e2.sx;
8114
+ let sy = e2.s * e2.sy;
8115
+ if (e2.tz) {
8116
+ const Dz = e2.persp > 0 ? e2.persp : zPersp > 0 ? zPersp : Z_PERSP;
8117
+ const f2 = Dz / Math.max(Dz * W_NEAR, Dz - e2.tz);
8118
+ sx *= f2;
8119
+ sy *= f2;
8120
+ }
8085
8121
  let k00 = 1, k01 = 0, k10 = 0, k11 = 1;
8086
8122
  if (e2.skew) {
8087
8123
  const t = Math.tan(clamp6(e2.skew, -85, 85) * Math.PI / 180);
@@ -8201,7 +8237,7 @@ var TXA = (function() {
8201
8237
  ];
8202
8238
  const tx = px + e2.tx - (L[0] * px + L[1] * py);
8203
8239
  const ty = py + e2.ty - (L[3] * px + L[4] * py);
8204
- const tz = -(L[6] * px + L[7] * py);
8240
+ const tz = -(L[6] * px + L[7] * py) - (e2.tz || 0);
8205
8241
  return [L[0], L[1], L[2], tx, L[3], L[4], L[5], ty, L[6], L[7], L[8], tz, 0, 0, 0, 1];
8206
8242
  }
8207
8243
  function unitTransform3d2(xf, boxOf, pivot) {
@@ -8214,13 +8250,36 @@ var TXA = (function() {
8214
8250
  }
8215
8251
  return M;
8216
8252
  }
8217
- function unitTransform2(xf, boxOf, pivot) {
8253
+ const CAM_NEAR = 0.05;
8254
+ function unitTransformCam2(xf, boxOf, pivot, cam) {
8255
+ const M = unitTransform3d2(xf, boxOf, pivot);
8256
+ if (!M) return null;
8257
+ const d = cam.d;
8258
+ const H = [
8259
+ M[0] + cam.x * M[8] / d,
8260
+ M[1] + cam.x * M[9] / d,
8261
+ M[3] + cam.x * M[11] / d,
8262
+ M[4] + cam.y * M[8] / d,
8263
+ M[5] + cam.y * M[9] / d,
8264
+ M[7] + cam.y * M[11] / d,
8265
+ M[8] / d,
8266
+ M[9] / d,
8267
+ 1 + M[11] / d
8268
+ ];
8269
+ const b2 = boxOf("char");
8270
+ let wMin = Infinity;
8271
+ for (const [x, y] of [[b2.x0, b2.y0], [b2.x1, b2.y0], [b2.x0, b2.y1], [b2.x1, b2.y1]]) wMin = Math.min(wMin, H[6] * x + H[7] * y + H[8]);
8272
+ const depth = M[8] * ((b2.x0 + b2.x1) / 2) + M[9] * ((b2.y0 + b2.y1) / 2) + M[11];
8273
+ const projective = Math.abs(H[6]) > 1e-12 || Math.abs(H[7]) > 1e-12;
8274
+ return { H, projective, depth, hidden: !(wMin > CAM_NEAR) };
8275
+ }
8276
+ function unitTransform2(xf, boxOf, pivot, zPersp = 0) {
8218
8277
  let H = null;
8219
8278
  let projective = false;
8220
8279
  for (const lv of LEVELS2) {
8221
8280
  const e2 = xf && xf[lv];
8222
8281
  if (!e2) continue;
8223
- const r = levelH(e2, boxOf(lv), pivot);
8282
+ const r = levelH(e2, boxOf(lv), pivot, zPersp);
8224
8283
  H = H ? hMul(r.H, H) : r.H;
8225
8284
  if (r.projective) projective = true;
8226
8285
  }
@@ -8656,6 +8715,7 @@ var TXA = (function() {
8656
8715
  maskedXf: maskedXf2,
8657
8716
  unitTransform: unitTransform2,
8658
8717
  unitTransform3d: unitTransform3d2,
8718
+ unitTransformCam: unitTransformCam2,
8659
8719
  m4Mul: m4Mul2,
8660
8720
  applyH: applyH2,
8661
8721
  hToCanvas: hToCanvas2,
@@ -8730,6 +8790,7 @@ var {
8730
8790
  maskedXf,
8731
8791
  unitTransform,
8732
8792
  unitTransform3d,
8793
+ unitTransformCam,
8733
8794
  m4Mul,
8734
8795
  applyH,
8735
8796
  hToCanvas,
@@ -8765,6 +8826,7 @@ var factory = function(T) {
8765
8826
  const CATEGORIES2 = [
8766
8827
  { id: "sweep", name: "Sweep Reveal" },
8767
8828
  { id: "kinetic", name: "Kinetic" },
8829
+ { id: "depth", name: "Depth" },
8768
8830
  { id: "shape", name: "Type on Shape" },
8769
8831
  { id: "emphasis", name: "Emphasis" },
8770
8832
  { id: "loop", name: "Loop" },
@@ -9639,6 +9701,234 @@ var factory = function(T) {
9639
9701
  }
9640
9702
  ]
9641
9703
  },
9704
+ // The two after it film the text with ONE camera (layer: { camera: 'layer' }): every
9705
+ // letter at its own depth, all converging to one vanishing point, nearer covering
9706
+ // farther — what Position Z and a shared Rotate 3D are for.
9707
+ // Title Push 3D = MotionFast template5 "Push 1 — 3D Pop" (the title-push-3d template):
9708
+ // its stage's 1000 px CSS perspective on 4 vw type is a layer camera ~13 em away. Each
9709
+ // word pops in over 0.8 s on GSAP's power1.out, 0.2–0.4 s after the one before (0.2 s —
9710
+ // its shortest — and 0.75 s here, so seven words land by 2 s like every Kinetic preset; the modal's Speed
9711
+ // stretches both); here it flies in from deep behind the text and tips up flat, and Back
9712
+ // Out carries it a little past the line toward the camera before it settles. The
9713
+ // template slides its container left as each word lands so the words shown stay
9714
+ // centred; that needs the shown words' width, so here the whole text makes one Sine Out
9715
+ // glide left across the arrivals. At the end the words rush past the camera, last first.
9716
+ {
9717
+ id: "title-push-3d",
9718
+ name: "Title Push 3D",
9719
+ cat: "kinetic",
9720
+ sides: "inOut",
9721
+ anchor: "drive",
9722
+ sample: "Kinetic type with depth",
9723
+ tile: KIN_TILE,
9724
+ labLayer: { size: 110, weight: 800 },
9725
+ layer: { camera: "layer", perspective: em(13) },
9726
+ description: "Words fly in from depth one by one, overshoot toward the camera and settle as the line glides over to make room; at the end they rush past the camera",
9727
+ animators: [
9728
+ {
9729
+ name: "Fly in",
9730
+ range: kIn("words", "word", { stagger: 0.2, duration: 0.75, curve: "Back Out" }),
9731
+ effects: [["position", { x: 0, y: em(0.35), z: em(-26) }], ["rotate3d", { x: -50, y: 0, perspective: em(6) }], ["opacity", { opacity: 0 }]]
9732
+ },
9733
+ {
9734
+ name: "Push",
9735
+ range: kIn("lines", "all", { stagger: 0, duration: 1.6, curve: "Sine Out" }),
9736
+ effects: [["position", { x: em(2.4), y: 0 }]]
9737
+ },
9738
+ {
9739
+ name: "Rush out",
9740
+ range: kOut("words", "word", { order: "backward", stagger: 0.05, duration: 0.55, curve: "Ease In" }),
9741
+ effects: [["position", { x: 0, y: 0, z: em(9) }], ["opacity", { opacity: 0 }]]
9742
+ }
9743
+ ]
9744
+ },
9745
+ // Perspective Type = the Kinetic Perspective Type template (kinetic-perspective-type,
9746
+ // look-dev html/kinetic-perspective-type.html): the whole text lies on a plane tilted
9747
+ // toward the camera (X 32°, Y 25°, Z −6°, 150 %; its focal 520 px on 250 px type is a
9748
+ // layer camera ~2 em away — 3 em here, the same keystone on lines ~4 em wide), the lines
9749
+ // drive in one after another (0.5 s apart, 0.5 s each, AE Smooth), rising from 1.45 line
9750
+ // heights below and growing from 55 %, and at 1.2 s the plane swings flat (0.7 s,
9751
+ // Natural: a Time entrance with a 1.2 s delay, so no keys). Not here: each arrival
9752
+ // shoving the earlier lines up the plane (a line cannot move on its neighbours' clocks),
9753
+ // the pushed word and its reflow, and the template's own motion blur. At the end the
9754
+ // lines sink back into depth.
9755
+ {
9756
+ id: "perspective-type",
9757
+ name: "Perspective Type",
9758
+ cat: "kinetic",
9759
+ sides: "inOut",
9760
+ anchor: "drive",
9761
+ sample: "Big\nbold\nideas",
9762
+ tile: KIN_TILE,
9763
+ // Centred like the template's block (its lines are left-aligned inside a block it
9764
+ // centres in frame): a left-aligned layer swings its tilted plane off the left edge.
9765
+ labLayer: { size: 160, weight: 800, lineHeight: 0.95 },
9766
+ layer: { camera: "layer", perspective: em(3) },
9767
+ description: "Lines drive in one after another on a plane tilted toward the camera, which then swings flat; at the end they sink back into depth",
9768
+ animators: [
9769
+ {
9770
+ name: "Lines in",
9771
+ range: kIn("lines", "line", { stagger: 0.5, duration: 0.5, curve: "AE Smooth" }),
9772
+ effects: [["position", { x: 0, y: em(1.4) }], ["scale", { scale: 55 }], ["opacity", { opacity: 0 }]]
9773
+ },
9774
+ {
9775
+ name: "Tilted plane",
9776
+ range: kIn("lines", "all", { delay: 1.2, stagger: 0, duration: 0.7, curve: "Natural" }),
9777
+ effects: [["rotate3d", { x: 32, y: 25, perspective: em(3) }], ["rotation", { angle: -6 }], ["scale", { scale: 150 }], ["position", { x: em(0.16), y: em(-0.04) }]]
9778
+ },
9779
+ {
9780
+ name: "Sink out",
9781
+ range: kOut("lines", "line", { order: "backward", stagger: 0.08, duration: 0.5, curve: "Ease In" }),
9782
+ effects: [["position", { x: 0, y: 0, z: em(-8) }], ["opacity", { opacity: 0 }]]
9783
+ }
9784
+ ]
9785
+ },
9786
+ // ── Depth ──
9787
+ // Position Z under ONE camera (layer: { camera: 'layer', perspective }): a letter
9788
+ // moved in depth really goes there — nearer grows and moves out from the centre,
9789
+ // farther shrinks toward one vanishing point, nearer covers farther. Picked by the
9790
+ // owner from the Text in Depth lab (html/text-depth-lab, 2026-11-07).
9791
+ {
9792
+ id: "rack-focus",
9793
+ name: "Rack Focus",
9794
+ cat: "depth",
9795
+ sides: "inOut",
9796
+ anchor: "start",
9797
+ sample: "Pull focus to every word",
9798
+ tile: "Pull focus\nto every word",
9799
+ labLayer: { size: 130, weight: 600 },
9800
+ layer: { camera: "layer", perspective: em(14) },
9801
+ description: "Words come forward out of a soft background and snap into focus; at the end they pass the focus toward you",
9802
+ animators: [
9803
+ {
9804
+ name: "Focus in",
9805
+ range: kIn("words", "word", { stagger: 0.12, duration: 1.3, curve: "Expo Out" }),
9806
+ effects: [["position", { x: 0, y: 0, z: em(-9) }], ["blur", { amount: em(0.12) }], ["opacity", { opacity: 0 }]]
9807
+ },
9808
+ {
9809
+ name: "Focus out",
9810
+ range: kOut("words", "word", { stagger: 0.05, duration: 0.6, curve: "Ease In" }),
9811
+ effects: [["position", { x: 0, y: 0, z: em(5) }], ["blur", { amount: em(0.12) }], ["opacity", { opacity: 0 }]]
9812
+ }
9813
+ ]
9814
+ },
9815
+ // Pivot Y 100: each letter turns on a hinge at its feet; under one camera the letters lie
9816
+ // on a real floor that recedes to the vanishing point.
9817
+ {
9818
+ id: "stand-up",
9819
+ name: "Stand Up",
9820
+ cat: "depth",
9821
+ sides: "inOut",
9822
+ anchor: "start",
9823
+ sample: "Stand up and be seen",
9824
+ tile: "Stand up\nand be seen",
9825
+ labLayer: { size: 150, weight: 800 },
9826
+ layer: { camera: "layer", perspective: em(6), pivotY: 100 },
9827
+ description: "Letters slide forward across the floor and stand up one after another, hinged at their feet; at the end they fall forward toward you",
9828
+ animators: [
9829
+ {
9830
+ name: "Stand",
9831
+ range: kIn("charsNoSpace", "char", { stagger: 0.04, duration: 0.9, curve: "Cubic Out" }),
9832
+ effects: [["position", { x: 0, y: 0, z: em(-3) }], ["rotate3d", { x: 90, y: 0, perspective: em(6) }], ["opacity", { opacity: 0 }]]
9833
+ },
9834
+ {
9835
+ name: "Fall",
9836
+ range: kOut("charsNoSpace", "char", { stagger: 0.025, duration: 0.6, curve: "Ease In" }),
9837
+ effects: [["position", { x: 0, y: 0, z: em(1.5) }], ["rotate3d", { x: -90, y: 0, perspective: em(6) }], ["opacity", { opacity: 0 }]]
9838
+ }
9839
+ ]
9840
+ },
9841
+ // Words rest at their own depths, only behind the plate. One camera pulls a far word toward
9842
+ // the centre, so a nearer word just outside it would cover it: the depths form a bowl (the
9843
+ // middle words deepest, Shape Smooth) with a fixed scatter added (Wiggle at 0 / s), so an
9844
+ // outer neighbour is never much farther. Drift is the camera move, spread over the whole
9845
+ // layer: every word slides by the same amount past the fixed camera, which films exactly
9846
+ // like a slow pan and push-in — the nearer words travel farther (parallax). The bowl runs
9847
+ // in reading order, so it is written for ONE line: on two, the deep middle words are the
9848
+ // rows' outer ends and get pulled into their neighbours.
9849
+ {
9850
+ id: "strata",
9851
+ name: "Strata",
9852
+ cat: "depth",
9853
+ sides: "inOut",
9854
+ anchor: "start",
9855
+ sample: "Each word at its own depth",
9856
+ tile: "Layers of depth",
9857
+ labLayer: { size: 108, weight: 800 },
9858
+ layer: { camera: "layer", perspective: em(10) },
9859
+ description: "Words float in and rest at their own depths while a slow camera drift slides them past each other; at the end they fall back",
9860
+ animators: [
9861
+ {
9862
+ name: "Layers",
9863
+ range: { basedOn: "words", around: "word", shape: "smooth" },
9864
+ wiggle: { mode: "add", min: 0, max: 40, rate: 0, corr: 0, seed: 8 },
9865
+ effects: [["position", { x: 0, y: 0, z: em(-7) }]]
9866
+ },
9867
+ {
9868
+ name: "Drift",
9869
+ anchor: "clip",
9870
+ range: { basedOn: "words", around: "word" },
9871
+ effects: [["position", { x: em(0.7), y: 0, z: em(-1) }]],
9872
+ keys: { amount: [[0, 100, "Sine Smooth"], [100, -100]] }
9873
+ },
9874
+ {
9875
+ name: "Float in",
9876
+ range: kIn("words", "word", { stagger: 0.12, duration: 1.2, curve: "Cubic Out" }),
9877
+ effects: [["position", { x: 0, y: 0, z: em(-12) }], ["opacity", { opacity: 0 }]]
9878
+ },
9879
+ {
9880
+ name: "Fall back",
9881
+ range: kOut("words", "word", { order: "backward", stagger: 0.05, duration: 0.6, curve: "Ease In" }),
9882
+ effects: [["position", { x: 0, y: 0, z: em(-12) }], ["opacity", { opacity: 0 }]]
9883
+ }
9884
+ ]
9885
+ },
9886
+ // Fill folds top to bottom: Sit back greys every word, the Spotlight's white wins at its turn.
9887
+ {
9888
+ id: "spotlight",
9889
+ name: "Spotlight",
9890
+ cat: "depth",
9891
+ sides: "once",
9892
+ anchor: "start",
9893
+ sample: "Every word takes the stage",
9894
+ tile: "Every word\ntakes the stage",
9895
+ labLayer: { size: 130, weight: 800 },
9896
+ layer: { camera: "layer", perspective: em(12) },
9897
+ description: "Words wait set back and dim; each in turn steps forward into the light, and the last one keeps it",
9898
+ animators: [
9899
+ {
9900
+ name: "Sit back",
9901
+ range: { basedOn: "words", around: "word" },
9902
+ effects: [["position", { x: 0, y: 0, z: em(-1.6) }], ["fill", { color: "#5d636b" }]]
9903
+ },
9904
+ {
9905
+ name: "Spotlight",
9906
+ range: { drive: "time", animate: "active", basedOn: "words", around: "word", delay: 0.3, stagger: 0.55, duration: 0.35, hold: 0, fadeOut: 0.3, keepLast: true, curve: "Cubic Out" },
9907
+ effects: [["position", { x: 0, y: 0, z: em(1.6) }], ["fill", { color: "#ffffff" }]]
9908
+ }
9909
+ ]
9910
+ },
9911
+ // A Time cascade from the centre, looping: each letter swells toward the camera on its
9912
+ // turn and settles back, so a ring of depth spreads outward like a ripple on water.
9913
+ {
9914
+ id: "ripple",
9915
+ name: "Ripple",
9916
+ cat: "depth",
9917
+ sides: "loop",
9918
+ anchor: "none",
9919
+ sample: "Ripples from the centre",
9920
+ tile: "Ripples from\nthe centre",
9921
+ labLayer: { size: 130, weight: 700 },
9922
+ layer: { camera: "layer", perspective: em(10) },
9923
+ description: "Rings of depth spread from the centre of the text to its edges, over and over, like ripples on still water",
9924
+ animators: [
9925
+ {
9926
+ name: "Ripple",
9927
+ range: { drive: "time", animate: "active", basedOn: "charsNoSpace", around: "char", order: "center", delay: 0.2, stagger: 0.1, duration: 0.14, hold: 0, fadeOut: 0.3, loop: true, curve: "Sine Out" },
9928
+ effects: [["position", { x: 0, y: 0, z: em(1.1) }], ["fill", { color: "#8fe3ff", mix: 65 }]]
9929
+ }
9930
+ ]
9931
+ },
9642
9932
  // ── Type on Shape ──
9643
9933
  // The typing animator carries a Highlight box set to Behind ▸ Revealed, so the
9644
9934
  // shape grows with the letters on one set of keys. Per line = a bar per row,
@@ -10233,6 +10523,8 @@ var factory = function(T) {
10233
10523
  return span;
10234
10524
  }
10235
10525
  const isTimed2 = (p) => p.anchor === "start" || p.anchor === "end";
10526
+ const keyedAt = (p, anchor) => p.animators.some((a) => a.keys && anchorOf2(p, a) === anchor);
10527
+ const keyedBothEnds2 = (p) => keyedAt(p, "start") && keyedAt(p, "end");
10236
10528
  const resolveEm2 = (v, fontPx) => {
10237
10529
  if (!v || typeof v !== "object" || Array.isArray(v)) return v;
10238
10530
  let n2 = (Number(v.px) || 0) + (Number(v.em) || 0) * fontPx;
@@ -10252,8 +10544,9 @@ var factory = function(T) {
10252
10544
  };
10253
10545
  const span = presetSpan2(p);
10254
10546
  const clip = Math.max(0.1, clipSec);
10547
+ const room = keyedBothEnds2(p) ? clip / 2 : clip;
10255
10548
  let scale = span > 0 && duration > 0 ? duration / span : 1;
10256
- if (span > 0 && span * scale > clip) scale = clip / span;
10549
+ if (span > 0 && span * scale > room) scale = room / span;
10257
10550
  const pos = (t, anchor) => {
10258
10551
  let v;
10259
10552
  if (anchor === "clip") v = t;
@@ -10299,7 +10592,8 @@ var factory = function(T) {
10299
10592
  return {
10300
10593
  animators: T.sanitizeAnimators(animators),
10301
10594
  tracks,
10302
- layer: { ...T.defaultsOf(T.LAYER_FIELDS), ...p.layer || {} }
10595
+ // Layer fields may be sizes too (a shared camera's Perspective in em).
10596
+ layer: { ...T.defaultsOf(T.LAYER_FIELDS), ...Object.fromEntries(Object.entries(p.layer || {}).map(([k, v]) => [k, resolveEm2(v, fontPx)])) }
10303
10597
  };
10304
10598
  }
10305
10599
  function fakeVoice2(text, { start = 0.35, rate = 1 } = {}) {
@@ -10314,22 +10608,106 @@ var factory = function(T) {
10314
10608
  }
10315
10609
  const usesVoice2 = (p) => !!(p && Array.isArray(p.animators) && p.animators.some((a) => a.range && a.range.drive === "voice"));
10316
10610
  const hasExit2 = (p) => !!(p && Array.isArray(p.animators) && p.animators.some((a) => a.range && (a.range.animate === "out" || a.range.animate === "inOut")));
10611
+ const exitStagger = (basedOn) => basedOn === "words" ? 0.04 : basedOn === "lines" ? 0.08 : 0.015;
10317
10612
  function withExit2(p) {
10318
- if (!p || p.sides === "out" || p.sides === "loop" || hasExit2(p)) return p;
10613
+ if (!p || p.sides === "out" || p.sides === "inOut" || p.sides === "loop" || hasExit2(p)) return p;
10319
10614
  const first = p.animators[0] && p.animators[0].range;
10320
10615
  const basedOn = first && first.basedOn || "chars";
10321
- const stagger = basedOn === "words" ? 0.04 : basedOn === "lines" ? 0.08 : 0.015;
10322
10616
  return {
10323
10617
  ...p,
10324
10618
  sides: "inOut",
10325
10619
  animators: [...p.animators, {
10326
10620
  name: "Out",
10327
- range: { drive: "time", animate: "out", basedOn, stagger, duration: 0.3, curve: "Ease In" },
10621
+ range: { drive: "time", animate: "out", basedOn, stagger: exitStagger(basedOn), duration: 0.3, curve: "Ease In" },
10328
10622
  effects: [["opacity", { opacity: 0 }]]
10329
10623
  }]
10330
10624
  };
10331
10625
  }
10332
- const api = { CATEGORIES: CATEGORIES2, PRESETS: PRESETS3, anchorOf: anchorOf2, presetSpan: presetSpan2, isTimed: isTimed2, resolveEm: resolveEm2, build: build2, fakeVoice: fakeVoice2, usesVoice: usesVoice2, hasExit: hasExit2, withExit: withExit2 };
10626
+ const REVERSE_CURVE = {
10627
+ "Ease In": "Ease Out",
10628
+ "Ease Out": "Ease In",
10629
+ "Cubic In": "Cubic Out",
10630
+ "Cubic Out": "Cubic In",
10631
+ "Sine In": "Sine Out",
10632
+ "Sine Out": "Sine In",
10633
+ "Expo In": "Expo Out",
10634
+ "Expo Out": "Expo In",
10635
+ "Back In": "Back Out",
10636
+ "Back Out": "Back In",
10637
+ "AE In (arrive)": "AE Out (leave)",
10638
+ "AE Out (leave)": "AE In (arrive)",
10639
+ Overshoot: "Anticipate",
10640
+ "Smooth Overshoot": "Anticipate",
10641
+ Elastic: "Anticipate",
10642
+ Anticipate: "Overshoot",
10643
+ "Bounce Out": "Ease In",
10644
+ Power: "Cubic In",
10645
+ "Slow Down": "Expo In",
10646
+ "Apple Out": "Ease In"
10647
+ };
10648
+ const reverseCurve = (c) => REVERSE_CURVE[c] || c;
10649
+ const REVERSE_ORDER = { forward: "backward", backward: "forward", center: "edges", edges: "center" };
10650
+ const driven = (r) => r.drive === "time" || r.drive === "voice";
10651
+ function sideOf(p, a) {
10652
+ const r = a.range || {};
10653
+ if (r.loop || a.wiggle) return null;
10654
+ if (driven(r)) {
10655
+ const animate = r.animate || "in";
10656
+ return animate === "in" || animate === "out" ? animate : null;
10657
+ }
10658
+ if (!a.keys || !Object.keys(a.keys).length) return null;
10659
+ const anchor = anchorOf2(p, a);
10660
+ return anchor === "start" ? "in" : anchor === "end" ? "out" : null;
10661
+ }
10662
+ function sideVersions2(p) {
10663
+ if (!p || p.sides !== "in" && p.sides !== "out") return null;
10664
+ const sides = p.animators.map((a) => sideOf(p, a));
10665
+ return sides.includes(p.sides) && !sides.includes(p.sides === "in" ? "out" : "in") ? ["in", "out", "inOut"] : null;
10666
+ }
10667
+ function mirror(p, a) {
10668
+ const toOut = sideOf(p, a) === "in";
10669
+ const word = toOut ? "out" : "in";
10670
+ const m = new RegExp(`\\b${toOut ? "in" : "out"}$`, "i").exec(a.name);
10671
+ const name = m ? a.name.slice(0, m.index) + (m[0][0] === m[0][0].toUpperCase() ? word[0].toUpperCase() + word.slice(1) : word) : `${a.name} ${word}`;
10672
+ const r = a.range || {};
10673
+ if (driven(r)) {
10674
+ const range = { ...r, animate: word, order: REVERSE_ORDER[r.order || "forward"] || r.order, curve: reverseCurve(r.curve || "Ease Out") };
10675
+ if (r.curve === "Custom" && Array.isArray(r.bezier) && r.bezier.length === 4) {
10676
+ const [x1, y1, x2, y2] = r.bezier;
10677
+ range.bezier = [1 - x2, 1 - y2, 1 - x1, 1 - y1];
10678
+ }
10679
+ if (r.drive === "voice") {
10680
+ range.drive = "time";
10681
+ if (r.stagger == null) range.stagger = exitStagger(r.basedOn);
10682
+ }
10683
+ return { ...a, name, range };
10684
+ }
10685
+ const keys = {};
10686
+ for (const [chan, ks] of Object.entries(a.keys)) {
10687
+ const n2 = ks.length;
10688
+ keys[chan] = ks.map((_, i) => {
10689
+ const k = ks[n2 - 1 - i];
10690
+ const into = ks[n2 - 2 - i];
10691
+ const t = -k[0] || 0;
10692
+ return into ? [t, k[1], reverseCurve(into[2] || "Smooth")] : [t, k[1]];
10693
+ });
10694
+ }
10695
+ return { ...a, name, anchor: toOut ? "end" : "start", keys };
10696
+ }
10697
+ function withSides2(p, sides) {
10698
+ const versions = sideVersions2(p);
10699
+ if (!versions || !versions.includes(sides) || sides === p.sides) return p;
10700
+ const flip = { start: "end", end: "start" };
10701
+ const pinned = (a) => a.keys && !a.anchor ? { ...a, anchor: anchorOf2(p, a) } : a;
10702
+ const moving = (a) => sideOf(p, a) === p.sides;
10703
+ if (sides === "inOut") {
10704
+ const back = p.animators.filter(moving).map((a) => mirror(p, a));
10705
+ const own = p.animators.map(pinned);
10706
+ return { ...p, sides, anchor: p.anchor === "end" ? "start" : p.anchor, animators: p.sides === "in" ? [...own, ...back] : [...back, ...own] };
10707
+ }
10708
+ return { ...p, sides, anchor: flip[p.anchor] || p.anchor, animators: p.animators.map((a) => moving(a) ? mirror(p, a) : pinned(a)) };
10709
+ }
10710
+ const api = { CATEGORIES: CATEGORIES2, PRESETS: PRESETS3, anchorOf: anchorOf2, presetSpan: presetSpan2, isTimed: isTimed2, keyedBothEnds: keyedBothEnds2, resolveEm: resolveEm2, build: build2, fakeVoice: fakeVoice2, usesVoice: usesVoice2, hasExit: hasExit2, withExit: withExit2, sideVersions: sideVersions2, withSides: withSides2 };
10333
10711
  return api;
10334
10712
  };
10335
10713
  var PRESETS_API = factory(textAnimators_default);
@@ -10339,12 +10717,15 @@ var {
10339
10717
  anchorOf,
10340
10718
  presetSpan,
10341
10719
  isTimed,
10720
+ keyedBothEnds,
10342
10721
  resolveEm,
10343
10722
  build,
10344
10723
  fakeVoice,
10345
10724
  usesVoice,
10346
10725
  hasExit,
10347
- withExit
10726
+ withExit,
10727
+ sideVersions,
10728
+ withSides
10348
10729
  } = PRESETS_API;
10349
10730
 
10350
10731
  // src/components/videoEditor/textAnimatorClassicPresets.js
@@ -12584,13 +12965,21 @@ function presetPatch(item, preset, { clipSec, fontPx, duration = null, speed = 1
12584
12965
  });
12585
12966
  list = [...existing, ...renamed].slice(0, MAX_ANIMATORS);
12586
12967
  }
12968
+ const shared = built.layer.camera && built.layer.camera !== "unit";
12969
+ const camera = {
12970
+ textAnimatorCamera: shared ? built.layer.camera : null,
12971
+ textAnimatorPerspective: shared && built.layer.perspective > 0 ? built.layer.perspective : null
12972
+ };
12973
+ const hasCamera = item?.textAnimatorCamera === "layer" || item?.textAnimatorCamera === "project";
12587
12974
  return {
12588
12975
  textAnimators: list,
12589
12976
  propertyKeyframes: pk,
12590
12977
  ...adding ? null : {
12591
12978
  textAnimatorPivotX: built.layer.pivotX,
12592
- textAnimatorPivotY: built.layer.pivotY
12593
- }
12979
+ textAnimatorPivotY: built.layer.pivotY,
12980
+ ...camera
12981
+ },
12982
+ ...adding && shared && !hasCamera ? camera : null
12594
12983
  };
12595
12984
  }
12596
12985
 
@@ -12712,6 +13101,8 @@ var LayerTransitionType = {
12712
13101
  * reveals it; its exit maps the layer and dissolves the map to nothing.
12713
13102
  * Pixel Blocks block the layer in (or out) over what is under it; a colour
12714
13103
  * wipe fills only the layer's own pixels.
13104
+ * The Zoom Ruler's entrance opens the layer inside the growing ruler box over
13105
+ * what is under it; its exit keeps the layer outside the box until it clears.
12715
13106
  *
12716
13107
  * The options live in transitionIn/OutOptions, resolved through the SAME
12717
13108
  * ADVANCED_TRANSITION_PRESETS descriptor the cross slot reads.
@@ -12723,6 +13114,7 @@ var LayerTransitionType = {
12723
13114
  GLOW_STACK: "Gradient Glow Stack",
12724
13115
  GRADIENT_MAP: "Gradient Map",
12725
13116
  PIXEL_BLOCKS: "Pixel Blocks",
13117
+ ZOOM_RULER: "Zoom Ruler",
12726
13118
  CUSTOM: "Custom"
12727
13119
  };
12728
13120
  var ADVANCED_LAYER_TRANSITIONS = Object.freeze([
@@ -12732,18 +13124,26 @@ var ADVANCED_LAYER_TRANSITIONS = Object.freeze([
12732
13124
  LayerTransitionType.CAMERA_WHIP,
12733
13125
  LayerTransitionType.GLOW_STACK,
12734
13126
  LayerTransitionType.GRADIENT_MAP,
12735
- LayerTransitionType.PIXEL_BLOCKS
13127
+ LayerTransitionType.PIXEL_BLOCKS,
13128
+ LayerTransitionType.ZOOM_RULER
12736
13129
  ]);
12737
13130
  var TransitionEasing = {
13131
+ // The order is the easing picker's (EasingPicker.jsx GROUPS) so every plain dropdown
13132
+ // lists the curves the same way: Basic, After Effects, Expressive, springs, families.
12738
13133
  LINEAR: "Linear",
12739
13134
  EASE_IN: "Ease In",
12740
13135
  EASE_OUT: "Ease Out",
12741
13136
  EASE_IN_OUT: "Smooth",
12742
- // The owner's own curves from the "Easing study" board (2026-10-16): Smooth Glide arrives fast, glides slowly
12743
- // through the middle and leaves fast (its "Glide Easing" layer); Glide Easing Stop jumps in, all but stops,
12744
- // then goes (its "Glide Easing Stop" layer). Both are cubic beziers — see EASING_BEZIER.
12745
- SMOOTH_GLIDE: "Smooth Glide",
12746
- GLIDE_STOP: "Glide Easing Stop",
13137
+ // Expressive curves ported from the legacy MotionFast template easing catalogue
13138
+ // (greenlight-render-templates/templates/common/easings.ts). Natural and Slow Down
13139
+ // are cubic beziers; Overshoot/Impulse/Swing are sampled spring curves.
13140
+ NATURAL: "Natural",
13141
+ // From the "Easing Lab" board (2026-11-01), both plain cubic beziers. Punch winds up
13142
+ // slowly, hits hard and trails a long tail (scene changes on the beat, a logo hit, a
13143
+ // screen swipe); nothing else in the list is within 0.23 of it. Apple Out starts at
13144
+ // full speed and spends most of the move in a soft stop (UI panels, drawers, a
13145
+ // camera landing).
13146
+ PUNCH: "Punch",
12747
13147
  // After Effects-style bezier curves — same curves as MotionFast's aeIn/aeSmooth/aeOut.
12748
13148
  // AE names a curve by the side of the key its influence sits on, so the shapes read
12749
13149
  // backwards to the CSS names: AE In starts fast and brakes into the key (an arrival),
@@ -12751,42 +13151,37 @@ var TransitionEasing = {
12751
13151
  AE_IN: "AE In (arrive)",
12752
13152
  AE_SMOOTH: "AE Smooth",
12753
13153
  AE_OUT: "AE Out (leave)",
12754
- SINE_IN: "Sine In",
12755
- SINE_OUT: "Sine Out",
12756
- SINE_IN_OUT: "Sine Smooth",
12757
- CUBIC_IN: "Cubic In",
12758
- CUBIC_OUT: "Cubic Out",
12759
- EXPO_IN: "Expo In",
12760
- EXPO_OUT: "Expo Out",
12761
- EXPO_IN_OUT: "Expo Smooth",
12762
- // A hard, decisive stop (GSAP power4.out; drawn as cubic-bezier(0.23, 1, 0.32, 1)).
12763
- POWER: "Power",
12764
- // From the "Easing Lab" board (2026-11-01), both plain cubic beziers. Punch winds up
12765
- // slowly, hits hard and trails a long tail (scene changes on the beat, a logo hit, a
12766
- // screen swipe); nothing else in the list is within 0.23 of it. Apple Out starts at
12767
- // full speed and spends most of the move in a soft stop (UI panels, drawers, a
12768
- // camera landing).
12769
- PUNCH: "Punch",
13154
+ // The owner's own curves from the "Easing study" board (2026-10-16): Smooth Glide arrives fast, glides slowly
13155
+ // through the middle and leaves fast (its "Glide Easing" layer); Glide Easing Stop jumps in, all but stops,
13156
+ // then goes (its "Glide Easing Stop" layer). Both are cubic beziers — see EASING_BEZIER.
13157
+ SMOOTH_GLIDE: "Smooth Glide",
13158
+ GLIDE_STOP: "Glide Easing Stop",
12770
13159
  APPLE_OUT: "Apple Out",
13160
+ SLOW_DOWN: "Slow Down",
13161
+ OVERSHOOT: "Overshoot",
13162
+ IMPULSE: "Impulse",
13163
+ SWING: "Swing",
13164
+ // Winds back first, then snaps through to the end: an exit. Sampled.
13165
+ ANTICIPATE: "Anticipate",
12771
13166
  BACK_IN: "Back In",
12772
13167
  BACK_OUT: "Back Out",
12773
13168
  BACK_IN_OUT: "Back Smooth",
12774
13169
  BOUNCE_OUT: "Bounce Out",
12775
13170
  ELASTIC_OUT: "Elastic",
12776
- // Expressive curves ported from the legacy MotionFast template easing catalogue
12777
- // (greenlight-render-templates/templates/common/easings.ts). Natural and Slow Down
12778
- // are cubic beziers; Overshoot/Impulse/Swing are sampled spring curves.
12779
- NATURAL: "Natural",
12780
- SLOW_DOWN: "Slow Down",
12781
- OVERSHOOT: "Overshoot",
12782
- IMPULSE: "Impulse",
12783
- SWING: "Swing",
12784
13171
  // The smooth sibling of Overshoot/Elastic: the same springy character, but it
12785
13172
  // leaves 0 at zero velocity instead of being thrown, and rings once instead
12786
13173
  // of several times. Sampled — see SAMPLED_EASING_VALUES.
12787
13174
  SMOOTH_OVERSHOOT: "Smooth Overshoot",
12788
- // Winds back first, then snaps through to the end: an exit. Sampled.
12789
- ANTICIPATE: "Anticipate",
13175
+ SINE_IN: "Sine In",
13176
+ SINE_OUT: "Sine Out",
13177
+ SINE_IN_OUT: "Sine Smooth",
13178
+ CUBIC_IN: "Cubic In",
13179
+ CUBIC_OUT: "Cubic Out",
13180
+ EXPO_IN: "Expo In",
13181
+ EXPO_OUT: "Expo Out",
13182
+ EXPO_IN_OUT: "Expo Smooth",
13183
+ // A hard, decisive stop (GSAP power4.out; drawn as cubic-bezier(0.23, 1, 0.32, 1)).
13184
+ POWER: "Power",
12790
13185
  // ── Keyframe-only easings ──
12791
13186
  // CUSTOM reads the key's own `bezier: [x1, y1, x2, y2]` (the graph editor writes
12792
13187
  // it by dragging handles); HOLD steps to the next key instead of interpolating.
@@ -13611,6 +14006,10 @@ function buildGradient(type = "linear", angle = 90, stops = []) {
13611
14006
  // src/components/videoEditor/shapePath.js
13612
14007
  init_define_import_meta_env();
13613
14008
 
14009
+ // src/components/videoEditor/borderKeyframes.js
14010
+ init_define_import_meta_env();
14011
+ var RADIUS_KF_KEYS = ["borderRadius", "borderRadiusTL", "borderRadiusTR", "borderRadiusBR", "borderRadiusBL"];
14012
+
13614
14013
  // src/components/videoEditor/objectMotionPath.js
13615
14014
  init_define_import_meta_env();
13616
14015
  var OBJECT_MOTION_PATH_AIMS = ["tangent", "points", "free"];
@@ -13863,7 +14262,7 @@ var LINE_PATH_PRESETS = [
13863
14262
  ];
13864
14263
  var ALL_PRESETS = [...SHAPE_PATH_PRESETS, ...LINE_PATH_PRESETS];
13865
14264
  var LINE_PRESET_IDS = new Set(LINE_PATH_PRESETS.map((p) => p.id));
13866
- var SHAPE_CORNER_KF_KEYS = ["borderRadius", "borderRadiusTL", "borderRadiusTR", "borderRadiusBR", "borderRadiusBL"];
14265
+ var SHAPE_CORNER_KF_KEYS = RADIUS_KF_KEYS;
13867
14266
  var SHAPE_SIZE_KF_KEYS = ["shapeWidthPx", "shapeHeightPx", "shapeWidthPct", "shapeHeightPct", "shapeRadiusPx", "shapeRadiusPct", ...SHAPE_CORNER_KF_KEYS];
13868
14267
  var SIZE_KF_META = {
13869
14268
  shapeWidthPx: { displayLabel: "Shape width", unit: "px", max: 2e4 },
@@ -15911,6 +16310,58 @@ function formatCounterValue(units, spec) {
15911
16310
  }
15912
16311
  var SHUTTER_SEC = 1 / 60;
15913
16312
 
16313
+ // src/components/videoEditor/textMorph.js
16314
+ init_define_import_meta_env();
16315
+ var MORPH_MATCH = Object.freeze(["order", "any"]);
16316
+ var MORPH_LEAVE = Object.freeze(["mask", "fade", "blur", "scale"]);
16317
+ var MORPH_ORDER = Object.freeze(["left", "center", "all"]);
16318
+ var MORPH_DEFAULTS = Object.freeze({
16319
+ duration: 0.8,
16320
+ easing: "EXPO_IN_OUT",
16321
+ match: "order",
16322
+ ignoreCase: true,
16323
+ letterStagger: 0.03,
16324
+ arc: 0,
16325
+ leave: "fade",
16326
+ order: "left"
16327
+ });
16328
+ function bezier(x1, y1, x2, y2) {
16329
+ const cx = 3 * x1;
16330
+ const bx = 3 * (x2 - x1) - cx;
16331
+ const ax = 1 - cx - bx;
16332
+ const cy = 3 * y1;
16333
+ const by = 3 * (y2 - y1) - cy;
16334
+ const ay = 1 - cy - by;
16335
+ const X = (s) => ((ax * s + bx) * s + cx) * s;
16336
+ const Y = (s) => ((ay * s + by) * s + cy) * s;
16337
+ const dX = (s) => (3 * ax * s + 2 * bx) * s + cx;
16338
+ return (u) => {
16339
+ if (u <= 0) return 0;
16340
+ if (u >= 1) return 1;
16341
+ let s = u;
16342
+ for (let i = 0; i < 8; i += 1) {
16343
+ const e2 = X(s) - u;
16344
+ const d = dX(s);
16345
+ if (Math.abs(e2) < 1e-7 || Math.abs(d) < 1e-6) break;
16346
+ s -= e2 / d;
16347
+ }
16348
+ if (Math.abs(X(s) - u) > 1e-6) {
16349
+ let lo = 0;
16350
+ let hi = 1;
16351
+ s = u;
16352
+ for (let i = 0; i < 40; i += 1) {
16353
+ if (X(s) < u) lo = s;
16354
+ else hi = s;
16355
+ s = (lo + hi) / 2;
16356
+ }
16357
+ }
16358
+ return Y(s);
16359
+ };
16360
+ }
16361
+ var EASE_LEAVE = bezier(0.55, 0, 0.8, 0.3);
16362
+ var EASE_ARRIVE = bezier(0.22, 1, 0.36, 1);
16363
+ var EASE_SWAP = bezier(0.45, 0, 0.2, 1);
16364
+
15914
16365
  // src/components/videoEditor/textPlaceholders.js
15915
16366
  var TOKEN_RE = /\{\{\{\s*([A-Za-z]+)\s*(?::([^{}]*?))?\s*\}\}\}/g;
15916
16367
  var PLACEHOLDER_CATALOG = Object.freeze([
@@ -15930,7 +16381,13 @@ var PLACEHOLDER_CATALOG = Object.freeze([
15930
16381
  id: "CYCLE",
15931
16382
  label: "Word cycle",
15932
16383
  example: "{{{CYCLE:fast|simple|yours}}}",
15933
- description: "Words separated by | take turns in the sentence: each one holds, then rolls or fades to the next."
16384
+ description: "Words or phrases separated by | take turns in the sentence: each one holds, then rolls, fades or morphs to the next (Morph: the letters they share travel to their new place). Pace: add hold=2 (seconds each word stays) and duration=0.6 (seconds the change takes), or set Hold and Duration in its card."
16385
+ },
16386
+ {
16387
+ id: "MORPH",
16388
+ label: "Text Morph",
16389
+ example: "{{{CYCLE:fast|simple|yours; style=morph; hold=1.2; duration=0.8}}}",
16390
+ description: "A word cycle whose letters travel: the letters two words share slide to their new place, the others rise out and in. Pace: hold = seconds each word stays, duration = seconds the morph takes (Hold and Travel in its card). Whole lines instead of words: write each on its own line and turn on Each line is a state."
15934
16391
  },
15935
16392
  {
15936
16393
  id: "SCRAMBLE",
@@ -15964,6 +16421,7 @@ var PLACEHOLDER_CATALOG = Object.freeze([
15964
16421
  }
15965
16422
  ]);
15966
16423
  var clamp3 = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
16424
+ var CYCLE_STYLES = ["roll", "fade", "blur", "morph"];
15967
16425
  var CYCLE_DEFAULTS = Object.freeze({
15968
16426
  textCycleStyle: "roll",
15969
16427
  textCycleDelay: 0,
@@ -16008,14 +16466,22 @@ var PLACEHOLDER_OPTIONS = Object.freeze({
16008
16466
  kf: { kf: true }
16009
16467
  },
16010
16468
  cycle: {
16011
- style: { values: ["roll", "fade", "blur"] },
16469
+ style: { values: CYCLE_STYLES },
16012
16470
  start: { num: [0, 3600] },
16013
16471
  hold: { num: [0, 60] },
16014
16472
  duration: { num: [0.05, 10] },
16015
16473
  easing: { easing: true },
16016
16474
  width: { values: ["fit", "fixed"] },
16017
16475
  direction: { values: ["up", "down"] },
16018
- loop: { values: ["on", "off"] }
16476
+ loop: { values: ["on", "off"] },
16477
+ // Morph only (textMorph.js): how letters pair up, the letter stagger (s), the
16478
+ // travel's arc (0–1), how the unpaired letters leave / arrive, the rank order.
16479
+ match: { values: ["order", "any"] },
16480
+ case: { values: ["ignore", "keep"] },
16481
+ stagger: { num: [0, 2] },
16482
+ arc: { num: [0, 1] },
16483
+ leave: { values: ["mask", "fade", "blur", "scale"] },
16484
+ order: { values: ["left", "center", "centre", "all"] }
16019
16485
  },
16020
16486
  scramble: {
16021
16487
  start: { num: [0, 3600] },
@@ -16107,13 +16573,15 @@ var PLACEHOLDER_LABELS = Object.freeze({
16107
16573
  CYCLE: "Word cycle",
16108
16574
  SCRAMBLE: "Scramble",
16109
16575
  TIMECODE: "Timecode",
16110
- FRAME: "Frame number"
16576
+ FRAME: "Frame number",
16577
+ LINES: "Line states"
16111
16578
  });
16112
16579
  function findVariable(variables, name) {
16113
16580
  if (!Array.isArray(variables)) return null;
16114
16581
  const key = name.toLowerCase();
16115
16582
  return variables.find((v) => String(v?.name || "").toLowerCase() === key) || null;
16116
16583
  }
16584
+ var LINE_STATE_STYLES = Object.freeze(["morph", "roll", "fade", "blur"]);
16117
16585
  function formatTimecode(sec, fps, format = "hh:mm:ss:ff") {
16118
16586
  const f2 = Math.max(1, Math.round(Number(fps) || 30));
16119
16587
  const frames = Math.max(0, Math.floor(sec * f2 + 1e-6));
@@ -16655,11 +17123,11 @@ var PT_KF_KEYS = PT_NUMBER_FIELDS.filter((f2) => f2.kf).map((f2) => f2.key);
16655
17123
  // src/components/videoEditor/gsapTransitions.js
16656
17124
  var HOLD_EASE = () => 0;
16657
17125
  var customEaseCache = /* @__PURE__ */ new Map();
16658
- function customEase(bezier) {
16659
- const key = bezier.join(",");
17126
+ function customEase(bezier2) {
17127
+ const key = bezier2.join(",");
16660
17128
  let fn = customEaseCache.get(key);
16661
17129
  if (!fn) {
16662
- fn = bezierEase(...bezier);
17130
+ fn = bezierEase(...bezier2);
16663
17131
  if (customEaseCache.size > 256) customEaseCache.clear();
16664
17132
  customEaseCache.set(key, fn);
16665
17133
  }
@@ -16689,8 +17157,8 @@ function resolveWholeKeyEase(key) {
16689
17157
  const easing = key?.easing || TransitionEasing.LINEAR;
16690
17158
  if (easing === TransitionEasing.HOLD) return HOLD_EASE;
16691
17159
  if (easing === TransitionEasing.CUSTOM) {
16692
- const bezier = sanitizeBezier(key?.bezier);
16693
- if (bezier) return customEase(bezier);
17160
+ const bezier2 = sanitizeBezier(key?.bezier);
17161
+ if (bezier2) return customEase(bezier2);
16694
17162
  return playedEase(TransitionEasing.EASE_IN_OUT);
16695
17163
  }
16696
17164
  return playedEase(easing);
@@ -18661,8 +19129,9 @@ var CAMERA_KF_PROPS = [
18661
19129
  { label: "camY", displayLabel: "Pan Y", group: "Motion", placeholder: "0", unit: "%", type: "number", min: -200, max: 200, step: 0.5 },
18662
19130
  { label: "camRoll", displayLabel: "Roll", group: "Motion", placeholder: "0", unit: "deg", type: "number", min: -180, max: 180, step: 0.5 },
18663
19131
  { label: "camDolly", displayLabel: "Dolly", group: "Motion", placeholder: "0", unit: "px", type: "number", min: -3e3, max: 3e3, step: 10 },
18664
- { label: "camTiltX", displayLabel: "Tilt X", group: "Motion", placeholder: "0", unit: "deg", type: "number", min: -60, max: 60, step: 0.5 },
18665
- { label: "camTiltY", displayLabel: "Tilt Y", group: "Motion", placeholder: "0", unit: "deg", type: "number", min: -60, max: 60, step: 0.5 },
19132
+ // No min/max on tilt: an orbit may go past 90° and all the way round; the evaluators hold any angle.
19133
+ { label: "camTiltX", displayLabel: "Tilt X", group: "Motion", placeholder: "0", unit: "deg", type: "number", step: 0.5 },
19134
+ { label: "camTiltY", displayLabel: "Tilt Y", group: "Motion", placeholder: "0", unit: "deg", type: "number", step: 0.5 },
18666
19135
  { label: "camDofEnabled", displayLabel: "Depth of Field", group: "Depth of Field", placeholder: "0", unit: "", type: "number", min: 0, max: 1, step: 1 },
18667
19136
  { label: "camFocusDistance", displayLabel: "Focus Distance", group: "Depth of Field", placeholder: "0", unit: "px", type: "number", min: 0, max: 2e4, step: 10 },
18668
19137
  { label: "camAperture", displayLabel: "Aperture", group: "Depth of Field", placeholder: "24", unit: "px", type: "number", min: 0, max: 300, step: 1 },
@@ -18702,8 +19171,8 @@ function cameraMoveHash(propertyKeyframes) {
18702
19171
  const seen = /* @__PURE__ */ new Set();
18703
19172
  const keys = track.filter((k) => k && Number.isFinite(Number.parseFloat(k.position))).map((k) => {
18704
19173
  const easing = easings.has(k.easing) ? k.easing : TransitionEasing.EASE_IN_OUT;
18705
- const bezier = easing === TransitionEasing.CUSTOM && Array.isArray(k.bezier) ? k.bezier.map((n2) => Math.round(Number(n2) * 1e4) / 1e4).join(",") : "";
18706
- return [Math.round(Number.parseFloat(k.position) * 1e3) / 1e3, String(k.value ?? ""), easing, bezier];
19174
+ const bezier2 = easing === TransitionEasing.CUSTOM && Array.isArray(k.bezier) ? k.bezier.map((n2) => Math.round(Number(n2) * 1e4) / 1e4).join(",") : "";
19175
+ return [Math.round(Number.parseFloat(k.position) * 1e3) / 1e3, String(k.value ?? ""), easing, bezier2];
18707
19176
  }).sort((a, b2) => a[0] - b2[0]).filter((k) => seen.has(k[0]) ? false : (seen.add(k[0]), true));
18708
19177
  parts.push(`${prop}=${keys.map((k) => k.join("|")).join(";")}`);
18709
19178
  }
@@ -21791,8 +22260,8 @@ function compileScript(script, caps, files = /* @__PURE__ */ new Map()) {
21791
22260
  // src/motionEngine/sceneScriptGuide.js
21792
22261
  init_define_import_meta_env();
21793
22262
 
21794
- // ../skills/greenlight-dash/references/video-design.md
21795
- var video_design_default = "# Motion design that looks made, not generated\n\nWhat makes a video read as designed rather than generated. The rules hold however the video is built \u2014 a project\nthrough the app's API, `project.json` with the engine's CLI, or a scene script \u2014 so read them before the first version\nand check every version against them.\n\n## Story and words\n\n- **One idea per scene**: 3\u20136 scenes of 2\u20133 s for a short video. A scene that says two things says neither.\n- **At most ~8 words on screen at once, held at least 1 s.** People read about 3 words a second; text that leaves\n before it is read is noise.\n- **9:16**: bigger text, stacked vertically, everything in the middle 80 %.\n\n## Hierarchy\n\n- One headline per scene; supporting text much smaller. Hierarchy comes from size and weight, not colour alone.\n- One accent colour and strong contrast.\n- **No text shadows unless the user asks for one.** A shadow under type reads as a stock default and softens the\n letter edges. When text has to read over footage or a busy picture, get the contrast from the design instead: a\n calmer or darker part of the frame, a caption bar (`textBgEnabled`), or a shape behind the text. So no\n `textShadowX` / `textShadowY` / `textShadowBlur` / `textShadowSpread` on a text layer, no `shadow` property in a\n Text Animator and no `longShadow` effect on text. A shadow already in the user's project stays.\n- Keep text inside the middle 80 % of the frame (in a project: |panX| and |panY| \u2264 40): players and feeds crop the\n edges.\n\n## Timing\n\n- Stagger related elements by 0.06\u20130.12 s.\n- **One focal move at a time.** If everything moves, nothing reads: supporting elements move less and later.\n- Shapes and pictures enter in 0.4\u20130.8 s; exits are faster than entrances.\n- A text animation takes at least 0.8\u20131 s over the whole line, first unit in to last unit settled, unless the video\n needs snap (a beat hit, a glitch flick): faster text flashes past before it is read. The arithmetic per kind of\n animation: video-projects.md, Text animation pace.\n\n## Easing by intent\n\n- Entrances: `AE In (arrive)`, `Expo Out` or `Power` (fast start, soft landing); UI panels and drawers: `Apple Out`.\n Camera and big moves: `AE Smooth`.\n- Scene changes on the beat, a logo hit, a screen swipe: `Punch` (slow wind-up, hard hit, long tail).\n- A layer that passes through (in, glide, out): `Smooth Glide` with the `glide` expression \u2014 the Glide keyframe\n presets build it (video-keyframes.md).\n- Playful arrivals: `Back Out` or `Overshoot`; a real spring: the `spring` expression (Snappy for buttons, icons and\n pop-ins, Gentle for mascots, big cards and the camera). Exits: `Ease In` or `AE Out (leave)`.\n- `Linear` only for constant motion: tickers, rotation, progress along a path.\n\n## Text animation\n\n- Animate words or letters, never a whole line at once \u2014 the text presets do this.\n- One family per video. Calm presets for lines and captions: Glide Text (`glide-text`), Classic Fade Up\n (`classic-fade-up`), Classic Rise (Mask) (`classic-rise-mask`); Focus Type (`focus-type`) for a title.\n- Punchy presets that tilt or overshoot \u2014 Swing Pop (`swing-pop`), Whip (`whip`), Letter Vortex (`letter-vortex`) \u2014\n only on 1\u20133 words when the video wants energy. On a longer caption they read cheap.\n- Glide Continuous (`glide-continuous`) and Glide Soft Stop (`glide-soft-stop`) never let the text rest: use them\n only when the user asks for drifting text.\n- On a coloured card, pick a preset without a colour band (Classic Rise (Mask), Classic Fade Up): a band's colour can\n clash with the card.\n\n## Transitions and camera\n\n- Vary two or three kinds of transition across a video: one kind everywhere looks like a template, a new one at\n every cut looks random.\n- Camera moves are slow and small unless they are the point. Fast motion looks best at 60 fps with motion blur.\n- A real 3D camera earns its place on one or two moments, not every scene: a zoom onto the one thing a scene is about\n (a number, the button a screen recording clicks; 1.3\u20132\xD7, then back out), or a slow orbit or push that shows depth.\n- Depth needs layers at different distances: a soft shape or picture far behind, the words at the front. Depth of\n field reads only then; keep the words sharp.\n- Keep an orbit in front of flat layers (within about \xB170\xB0): text turned further goes edge-on, then mirrored.\n\n## Sound\n\n- A soft whoosh on each transition and on the first headline is a good base. `data-1` or `selection-data-click` for\n UI-like pops, `cinematic-transition-13` for one big reveal.\n\n## What to check in every version\n\nLook at real frames across the video (a contact sheet) before the user sees it \u2014 the JSON doesn't show these:\nblank or empty frames, overlapping or cut-off text, text too small or gone too fast, nothing moving or everything\nmoving at once, weak hierarchy, clashing colours. Fix them and look again; two rounds are usually enough.\n";
22263
+ // ../skills/greenlight-dash/references/video/design.md
22264
+ var design_default = "# Motion design that looks made, not generated\n\nWhat makes a video read as designed rather than generated. The rules hold however the video is built \u2014 a project\nthrough the app's API, `project.json` with the engine's CLI, or a scene script \u2014 so read them before the first version\nand check every version against them.\n\n## Story and words\n\n- **One idea per scene**: 3\u20136 scenes of 2\u20133 s for a short video. A scene that says two things says neither.\n- **At most ~8 words on screen at once, held at least 1 s.** People read about 3 words a second; text that leaves\n before it is read is noise.\n- **9:16**: bigger text, stacked vertically, everything in the middle 80 %.\n\n## Hierarchy\n\n- One headline per scene; supporting text much smaller. Hierarchy comes from size and weight, not colour alone.\n- One accent colour and strong contrast.\n- **No text shadows unless the user asks for one.** A shadow under type reads as a stock default and softens the\n letter edges. When text has to read over footage or a busy picture, get the contrast from the design instead: a\n calmer or darker part of the frame, a caption bar (`textBgEnabled`), or a shape behind the text. So no\n `textShadowX` / `textShadowY` / `textShadowBlur` / `textShadowSpread` on a text layer, no `shadow` property in a\n Text Animator and no `longShadow` effect on text. A shadow already in the user's project stays.\n- Keep text inside the middle 80 % of the frame (in a project: |panX| and |panY| \u2264 40): players and feeds crop the\n edges.\n\n## Timing\n\n- Stagger related elements by 0.06\u20130.12 s.\n- **One focal move at a time.** If everything moves, nothing reads: supporting elements move less and later.\n- Shapes and pictures enter in 0.4\u20130.8 s; exits are faster than entrances.\n- A text animation takes at least 0.8\u20131 s over the whole line, first unit in to last unit settled, unless the video\n needs snap (a beat hit, a glitch flick): faster text flashes past before it is read. The arithmetic per kind of\n animation: [Text animation pace](#text-animation-pace).\n\n## Easing by intent\n\n- Entrances: `AE In (arrive)`, `Expo Out` or `Power` (fast start, soft landing); UI panels and drawers: `Apple Out`.\n Camera and big moves: `AE Smooth`.\n- Scene changes on the beat, a logo hit, a screen swipe: `Punch` (slow wind-up, hard hit, long tail).\n- A layer that passes through (in, glide, out): `Smooth Glide` with the `glide` expression \u2014 the Glide keyframe\n presets build it ([keyframes.md](keyframes.md)).\n- Playful arrivals: `Back Out` or `Overshoot`; a real spring: the `spring` expression (Snappy for buttons, icons and\n pop-ins, Gentle for mascots, big cards and the camera). Exits: `Ease In` or `AE Out (leave)`; the Kinetic recipes'\n `Anticipate` exits (wind back, then snap away) are the exception and stay as tuned.\n- `Linear` only for constant motion: tickers, rotation, progress along a path.\n\n## Text animation\n\n- Animate words or letters, never a whole line at once \u2014 the text presets do this.\n- One family per video. Calm presets for lines and captions: Classic Fade Up (`classic-fade-up`), Classic Rise (Mask)\n (`classic-rise-mask`), Glide Text (`glide-text`); Focus Type (`focus-type`) for a title. All are in\n [text-presets.md](text-presets.md).\n- Punchy presets that tilt or overshoot ([text-kinetic.md](text-kinetic.md): Swing Pop (`swing-pop`), Whip (`whip`),\n Letter Vortex (`letter-vortex`)\u2026) only on short titles, hooks and calls to action \u2014 up to about 5 words, or 2\u20134 short\n stacked lines \u2014 and only when the video wants energy. On a longer line or a caption under speech they read cheap.\n- Glide Continuous (`glide-continuous`) and Glide Soft Stop (`glide-soft-stop`) never let the text rest: use them\n only when the user asks for drifting text.\n- On a coloured card, pick a preset without a colour band (Classic Rise (Mask), Classic Fade Up): a band's colour can\n clash with the card.\n\n## Text animation pace\n\nBy default, text that animates in, animates out or gets a one-time pass (a sweep, a highlight, a typed line) takes\n**at least 0.8\u20131 s**, counted from the moment its first unit starts to the moment its last unit settles. Faster than\nthat, the words flash past before anyone can read them. In a Video Editor project the rule holds however the motion is\nbuilt:\n\n- **Advanced Animations on Time** (`textAnimators[].range.drive: \"time\"`): the span is\n `(units \u2212 1) \xD7 stagger + duration`. For example, 4 words with `stagger` 0.06 and `duration` 0.8 take 0.98 s. For\n moves (rise, slide, scale, rotate, blur), reach the span with `duration`, because a long `stagger` makes a long line\n crawl. A typewriter (`duration` 0) gets its pace from `stagger`.\n- **Keyframes** (`txa:` range tracks such as Start or Offset, or a text layer's own position, scale and opacity\n tracks): the first and last key of one move sit at least 0.8 s apart. Key positions are % of the layer, so a move\n from `p1` to `p2` lasts `(p2 \u2212 p1) / 100 \xD7 layer seconds`. On a layer with a speed curve, a `txa:` key's position is\n a % of the clip's source length (`trimEnd \u2212 trimStart`) instead, so time the move on that clock\n ([text.md](text.md)).\n- **Layer entrances and exits** on a text layer: `transitionInDuration` / `transitionOutDuration` of 0.8 or more\n (the most they take is 3 s).\n- **Recipes** from [text-kinetic.md](text-kinetic.md) and [text-presets.md](text-presets.md) keep their tuned timing.\n On very short text (one or two words), where the span works out under 0.8 s, raise `duration` until it reaches 0.8 s.\n\nGo faster only when the edit really needs it: the user asks for snappy or fast text, a word hits on a beat or a fast\ncut, a glitch or scramble flicks, the layer is too short to fit a 0.8 s move, or the animator uses `drive: \"voice\"`\n(the speech sets the pace there).\n\n## Transitions and camera\n\n- Vary two or three kinds of transition across a video: one kind everywhere looks like a template, a new one at\n every cut looks random.\n- Camera moves are slow and small unless they are the point. Fast motion looks best at 60 fps with motion blur.\n- A real 3D camera earns its place on one or two moments, not every scene: a zoom onto the one thing a scene is about\n (a number, the button a screen recording clicks; 1.3\u20132\xD7, then back out), or a slow orbit or push that shows depth.\n- Depth needs layers at different distances: a soft shape or picture far behind, the words at the front. Depth of\n field reads only then; keep the words sharp.\n- Keep an orbit in front of flat layers (within about \xB170\xB0): text turned further goes edge-on, then mirrored.\n\n## Sound\n\n- A soft whoosh on each transition and on the first headline is a good base. `data-1` or `selection-data-click` for\n UI-like pops, `cinematic-transition-13` for one big reveal.\n- Take sound effects from the user's Sound Library first\n ([media.md](../media.md#asset-libraries-sound--assets--music)). The ids above are GreenLight Motion library sounds, not\n Sound Library files: `GET /api/motion-sounds/v1/<id>.wav` serves one (e.g. `/api/motion-sounds/v1/data-1.wav`). Save\n it into the board folder and use the board url as an `audio` clip's `url`.\n\n## What to check in every version\n\nLook at real frames across the video (a contact sheet) before the user sees it \u2014 the JSON doesn't show these:\nblank or empty frames, overlapping or cut-off text, text too small or gone too fast, nothing moving or everything\nmoving at once, weak hierarchy, clashing colours. Fix them and look again; two rounds are usually enough.\n";
21796
22265
 
21797
22266
  // src/motionEngine/sceneScriptGuide.js
21798
22267
  var PREFERRED = {
@@ -21900,7 +22369,7 @@ matte, masks, particles, 3D) goes on from \`project.json\`: SKILL.md, "Beyond th
21900
22369
  `;
21901
22370
  }
21902
22371
  function designGuide() {
21903
- return `${video_design_default.replace(/^(#+) /gm, "#$1 ").trim()}
22372
+ return `${design_default.replace(/^(#+) /gm, "#$1 ").trim()}
21904
22373
 
21905
22374
  `;
21906
22375
  }