@glissade/lottie 0.47.0 → 0.48.0-pre.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.
Files changed (2) hide show
  1. package/dist/index.js +103 -14
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -1500,14 +1500,32 @@ function decimateLinearKeys(keys, relEps = .002) {
1500
1500
  * (constant topology), and TEXT (ty:5): a Text node → a text layer with a font
1501
1501
  * reference (fonts.list) + a text-document keyframe stream (static = one doc;
1502
1502
  * animated text/fill/fontSize → doc keyframes sampled on the frame grid, held).
1503
+ *
1504
+ * GROUP OPACITY (baked into descendants): Lottie null-parent parenting inherits
1505
+ * the transform MATRIX only, never opacity, so a `Group{opacity<1}` (or an
1506
+ * animated `opacity` track) is composited by MULTIPLYING its opacity into every
1507
+ * LEAF descendant's `ks.o` (static → a product; animated → the product sampled on
1508
+ * the frame grid + decimated, the sampleComponentVec discipline) while the group
1509
+ * null keeps its p/r/s and carries `o:{a:0,k:100}`. This is EXACT when a group's
1510
+ * translucent descendants DON'T overlap (or the group is near-0/near-1 — the
1511
+ * reported leak-through case: a group fading to ~0 hides its children since
1512
+ * 0×anything≈0). LIMIT (warned, never silent): OVERLAPPING translucent siblings
1513
+ * double-composite — glissade composites the subtree as a unit then applies the
1514
+ * group alpha once, whereas per-child baking stacks the alphas (0.5 over 0.5 ≈
1515
+ * 0.75, not the correct single 0.5). Same limitation the importer documents at
1516
+ * convert.ts:470-475. A correct-for-overlap precomp (ty:0 + assets) is a later phase.
1517
+ *
1503
1518
  * OUT (warned + dropped): Image/Video, gradient/mesh paint (solid only),
1504
- * non-center anchors, group opacity compositing (Lottie parenting never inherits
1505
- * opacity), text typewriter `reveal`/`revealFraction`, variable-font axes
1519
+ * non-center anchors, text typewriter `reveal`/`revealFraction`, variable-font axes
1506
1520
  * (`fontAxes`/`fontVariationSettings` — no Lottie doc field), `box` valign
1507
1521
  * (baseline-approximated) and wrap `width` (the player self-reflows), TokenHighlight.
1508
1522
  * Animated primitive geometry (width/radius tracks) is SAMPLED, not channel-mapped.
1509
1523
  */
1510
1524
  const EMPTY_TRACKS = /* @__PURE__ */ new Map();
1525
+ const IDENTITY_OPACITY = {
1526
+ factor: 1,
1527
+ tracks: []
1528
+ };
1511
1529
  /** Convert a SceneModule to a Lottie document. Pure over (scene, timeline). */
1512
1530
  function exportLottie(mod, opts) {
1513
1531
  const scene = mod.createScene();
@@ -1538,7 +1556,7 @@ function exportLottie(mod, opts) {
1538
1556
  ind: 0,
1539
1557
  fonts: /* @__PURE__ */ new Map()
1540
1558
  };
1541
- walkChildren(ctx, scene.root.children, void 0, byNode);
1559
+ walkChildren(ctx, scene.root.children, void 0, byNode, IDENTITY_OPACITY);
1542
1560
  const fonts = [...ctx.fonts.values()];
1543
1561
  return {
1544
1562
  v: BODYMOVIN_VERSION,
@@ -1580,7 +1598,7 @@ function computeOp(tl, fr) {
1580
1598
  * node gets the SMALLER `ind` — the importer reconstructs paint order from
1581
1599
  * `zIndex = -ind`, so a descending ind per sibling group preserves it.
1582
1600
  */
1583
- function walkChildren(ctx, children, parentInd, byNode) {
1601
+ function walkChildren(ctx, children, parentInd, byNode, opacity) {
1584
1602
  for (let i = children.length - 1; i >= 0; i--) {
1585
1603
  const node = children[i];
1586
1604
  const kind = classify(node);
@@ -1590,10 +1608,34 @@ function walkChildren(ctx, children, parentInd, byNode) {
1590
1608
  }
1591
1609
  const myInd = ++ctx.ind;
1592
1610
  const tracks = (node.id !== void 0 ? byNode.get(node.id) : void 0) ?? EMPTY_TRACKS;
1593
- ctx.layers.push(kind === "group" ? buildNullLayer(ctx, node, myInd, parentInd, tracks) : kind === "text" ? buildTextLayer(ctx, node, myInd, parentInd, tracks) : buildShapeLayer(ctx, node, kind, myInd, parentInd, tracks));
1594
- if (node instanceof Group) walkChildren(ctx, node.children, myInd, byNode);
1611
+ ctx.layers.push(kind === "group" ? buildNullLayer(ctx, node, myInd, parentInd, tracks) : kind === "text" ? buildTextLayer(ctx, node, myInd, parentInd, tracks, opacity) : buildShapeLayer(ctx, node, kind, myInd, parentInd, tracks, opacity));
1612
+ if (node instanceof Group) walkChildren(ctx, node.children, myInd, byNode, childOpacity(node, tracks, opacity));
1595
1613
  }
1596
1614
  }
1615
+ /**
1616
+ * The opacity accumulator for a group's children: a group WITH an opacity track
1617
+ * contributes that track (sampled per-frame); one WITHOUT multiplies its static
1618
+ * opacity into `factor`. The group's own opacity is thus pushed down onto its
1619
+ * descendants (the null layer itself carries `o:100`).
1620
+ */
1621
+ function childOpacity(group, tracks, parent) {
1622
+ const track = tracks.get("opacity");
1623
+ if (track) return {
1624
+ factor: parent.factor,
1625
+ tracks: [...parent.tracks, track]
1626
+ };
1627
+ return {
1628
+ factor: parent.factor * group.opacity(),
1629
+ tracks: parent.tracks
1630
+ };
1631
+ }
1632
+ /** Count exportable LEAF (non-group, non-drop) descendants — the overlap-warn heuristic. */
1633
+ function countLeafDescendants(group) {
1634
+ let n = 0;
1635
+ for (const child of group.children) if (child instanceof Group) n += countLeafDescendants(child);
1636
+ else if (classify(child) !== "drop") n += 1;
1637
+ return n;
1638
+ }
1597
1639
  function classify(node) {
1598
1640
  if (node instanceof Rect) return "rect";
1599
1641
  if (node instanceof Circle) return "circle";
@@ -1603,7 +1645,7 @@ function classify(node) {
1603
1645
  return "drop";
1604
1646
  }
1605
1647
  const describe = (node) => `${node.describeType}${node.id !== void 0 ? ` '${node.id}'` : ""}`;
1606
- function buildTransform(ctx, node, tracks) {
1648
+ function buildTransform(ctx, node, tracks, o) {
1607
1649
  if (node.hasAnchor && (node.anchor[0] !== .5 || node.anchor[1] !== .5)) ctx.warn(`${describe(node)}: a non-center anchor is not exported (MVP centers geometry) — placement may shift`);
1608
1650
  return {
1609
1651
  a: {
@@ -1613,7 +1655,51 @@ function buildTransform(ctx, node, tracks) {
1613
1655
  p: positionProp(ctx, tracks, node.position()),
1614
1656
  s: vecProp(ctx, tracks, "scale", node.scale(), (v) => [v[0] * 100, v[1] * 100]),
1615
1657
  r: scalarProp(ctx, tracks, "rotation", node.rotation(), (v) => v),
1616
- o: scalarProp(ctx, tracks, "opacity", node.opacity(), (v) => v * 100)
1658
+ o
1659
+ };
1660
+ }
1661
+ /**
1662
+ * A leaf's `ks.o`, folding in the group opacity accumulated from its ancestors.
1663
+ * With NO ancestor contribution (identity accumulator) this is byte-identical to
1664
+ * the pre-feature `scalarProp` path — the exact cubicBezier/hold inversion is
1665
+ * preserved. A static-only accumulator multiplies into a single `{a:0,k}`.
1666
+ * Anything animated (a leaf `opacity` track and/or an ancestor track) samples
1667
+ * the product `leaf_opacity(t) × Π ancestor_opacity(t)` on the union frame span
1668
+ * and decimates — the identical discipline sampleComponentVec uses.
1669
+ */
1670
+ function combineOpacity(ctx, node, tracks, accum) {
1671
+ const leafTrack = tracks.get("opacity");
1672
+ const leafStatic = node.opacity();
1673
+ if (accum.factor === 1 && accum.tracks.length === 0) return scalarProp(ctx, tracks, "opacity", leafStatic, (v) => v * 100);
1674
+ if (leafTrack === void 0 && accum.tracks.length === 0) return {
1675
+ a: 0,
1676
+ k: leafStatic * accum.factor * 100
1677
+ };
1678
+ const [f0, f1] = frameSpan(ctx, leafTrack ? [leafTrack, ...accum.tracks] : [...accum.tracks]);
1679
+ const out = [];
1680
+ for (let f = f0; f <= f1; f++) {
1681
+ const t = f / ctx.fr;
1682
+ let product = (leafTrack ? sampleTrack(leafTrack, t) : leafStatic) * accum.factor;
1683
+ for (const at of accum.tracks) product *= sampleTrack(at, t);
1684
+ const frame = {
1685
+ t: f,
1686
+ s: [product * 100]
1687
+ };
1688
+ if (f < f1) {
1689
+ frame.o = {
1690
+ x: 0,
1691
+ y: 0
1692
+ };
1693
+ frame.i = {
1694
+ x: 1,
1695
+ y: 1
1696
+ };
1697
+ }
1698
+ out.push(frame);
1699
+ }
1700
+ return {
1701
+ a: 1,
1702
+ k: decimateLinearKeys(out)
1617
1703
  };
1618
1704
  }
1619
1705
  function scalarProp(ctx, tracks, prop, staticVal, map) {
@@ -1717,18 +1803,21 @@ function frameSpan(ctx, tracks) {
1717
1803
  return bounds.length > 0 ? [Math.min(...bounds), Math.max(...bounds)] : [ctx.ip, ctx.op];
1718
1804
  }
1719
1805
  function buildNullLayer(ctx, node, ind, parentInd, tracks) {
1720
- if (node.opacity() !== 1 || tracks.has("opacity")) ctx.warn(`${describe(node)}: group opacity is exported on the null layer, but Lottie parenting does not composite it over children`);
1806
+ if ((node.opacity() !== 1 || tracks.has("opacity")) && node instanceof Group && countLeafDescendants(node) >= 2) ctx.warn(`${describe(node)}: group opacity is baked into descendant leaves; OVERLAPPING translucent descendants may double-composite (exact when they don't overlap)`);
1721
1807
  return {
1722
1808
  ty: 3,
1723
1809
  nm: node.id ?? `group${ind}`,
1724
1810
  ind,
1725
1811
  ip: ctx.ip,
1726
1812
  op: ctx.op,
1727
- ks: buildTransform(ctx, node, tracks),
1813
+ ks: buildTransform(ctx, node, tracks, {
1814
+ a: 0,
1815
+ k: 100
1816
+ }),
1728
1817
  ...parentInd !== void 0 ? { parent: parentInd } : {}
1729
1818
  };
1730
1819
  }
1731
- function buildShapeLayer(ctx, node, kind, ind, parentInd, tracks) {
1820
+ function buildShapeLayer(ctx, node, kind, ind, parentInd, tracks, opacity) {
1732
1821
  const shapes = [buildGeometry(ctx, node, kind, tracks)];
1733
1822
  const stroke = buildStroke(ctx, node, tracks);
1734
1823
  if (stroke) shapes.push(stroke);
@@ -1740,7 +1829,7 @@ function buildShapeLayer(ctx, node, kind, ind, parentInd, tracks) {
1740
1829
  ind,
1741
1830
  ip: ctx.ip,
1742
1831
  op: ctx.op,
1743
- ks: buildTransform(ctx, node, tracks),
1832
+ ks: buildTransform(ctx, node, tracks, combineOpacity(ctx, node, tracks, opacity)),
1744
1833
  shapes,
1745
1834
  ...parentInd !== void 0 ? { parent: parentInd } : {}
1746
1835
  };
@@ -1854,7 +1943,7 @@ function warnTextUnsupported(ctx, node, tracks) {
1854
1943
  if (node.box !== void 0) ctx.warn(`${describe(node)}: box valign is approximated as baseline-anchored (no Lottie ink-box anchor) — vertical placement may shift`);
1855
1944
  if (tracks.has("width") || node.width() > 0) ctx.warn(`${describe(node)}: wrap 'width' relies on the player's own line reflow — wrapping may diverge from glissade's`);
1856
1945
  }
1857
- function buildTextLayer(ctx, node, ind, parentInd, tracks) {
1946
+ function buildTextLayer(ctx, node, ind, parentInd, tracks, opacity) {
1858
1947
  const fName = registerFont(ctx, node);
1859
1948
  warnTextUnsupported(ctx, node, tracks);
1860
1949
  const t = {
@@ -1867,7 +1956,7 @@ function buildTextLayer(ctx, node, ind, parentInd, tracks) {
1867
1956
  ind,
1868
1957
  ip: ctx.ip,
1869
1958
  op: ctx.op,
1870
- ks: buildTransform(ctx, node, tracks),
1959
+ ks: buildTransform(ctx, node, tracks, combineOpacity(ctx, node, tracks, opacity)),
1871
1960
  t,
1872
1961
  ...parentInd !== void 0 ? { parent: parentInd } : {}
1873
1962
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/lottie",
3
- "version": "0.47.0",
3
+ "version": "0.48.0-pre.0",
4
4
  "description": "glissade Lottie import (S1 MVP): pure .json (Lottie/bodymovin) → node specs + a v1 Timeline. Fail-fast feature audit; no DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -18,11 +18,11 @@
18
18
  "dist"
19
19
  ],
20
20
  "dependencies": {
21
- "@glissade/core": "0.47.0",
22
- "@glissade/scene": "0.47.0"
21
+ "@glissade/core": "0.48.0-pre.0",
22
+ "@glissade/scene": "0.48.0-pre.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@glissade/backend-skia": "0.47.0"
25
+ "@glissade/backend-skia": "0.48.0-pre.0"
26
26
  },
27
27
  "repository": {
28
28
  "type": "git",