@glissade/lottie 0.45.0 → 0.46.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.
package/dist/index.d.ts CHANGED
@@ -35,7 +35,19 @@ interface ImageSpec extends BaseSpec {
35
35
  width: number;
36
36
  height: number;
37
37
  }
38
- type NodeSpec = GroupSpec | PathSpec | RectSpec | ImageSpec;
38
+ interface TextSpec extends BaseSpec {
39
+ kind: 'text';
40
+ text: string;
41
+ fill: string;
42
+ fontSize: number;
43
+ fontFamily: string;
44
+ fontWeight?: number;
45
+ fontStyle?: 'normal' | 'italic';
46
+ align?: 'left' | 'center' | 'right';
47
+ letterSpacing?: number;
48
+ lineHeight?: number;
49
+ }
50
+ type NodeSpec = GroupSpec | PathSpec | RectSpec | ImageSpec | TextSpec;
39
51
  interface LottieImportResult {
40
52
  size: {
41
53
  w: number;
@@ -125,6 +137,59 @@ interface LottieTransform {
125
137
  sk?: LottieProp;
126
138
  sa?: LottieProp;
127
139
  }
140
+ /**
141
+ * A text document (`t.d.k[n].s`) — the paint + font state of a ty:5 text layer
142
+ * at one keyframe. Modern bodymovin: `fc` is a 0–1 rgb(a) array, `j` is the
143
+ * justification (0 left, 1 right, 2 center — the Lottie/bodymovin convention).
144
+ * Optional fields (`tr`/`lh`) are OMITTED when at their glissade default so the
145
+ * emitted JSON stays minimal + deterministic (mirrors fontSpec()'s omissions).
146
+ */
147
+ interface LottieTextDocument {
148
+ /** the text string */
149
+ t: string;
150
+ /** font name — references a `fonts.list[n].fName` */
151
+ f: string;
152
+ /** font size (px) */
153
+ s: number;
154
+ /** fill color, 0–1 `[r,g,b]` or `[r,g,b,a]` */
155
+ fc: number[];
156
+ /** justification: 0 = left, 1 = right, 2 = center */
157
+ j: number;
158
+ /** tracking / letter-spacing (px) — omitted when unset */
159
+ tr?: number;
160
+ /** line height (px) — omitted when the glissade lineHeight is the 1.25 default */
161
+ lh?: number;
162
+ /** baseline shift — read-through only (never emitted) */
163
+ ls?: number;
164
+ /** wrap box size `[w,h]` — read-through only (never emitted) */
165
+ sz?: number[];
166
+ /** wrap box position `[x,y]` — read-through only */
167
+ ps?: number[];
168
+ }
169
+ /** One text-document keyframe: the document `s` applied from frame `t` (hold). */
170
+ interface LottieTextDocKeyframe {
171
+ t: number;
172
+ s: LottieTextDocument;
173
+ }
174
+ /** ty:5 text data: keyframed documents (`d.k`) + animators (`a`, always empty here). */
175
+ interface LottieTextData {
176
+ d: {
177
+ k: LottieTextDocKeyframe[];
178
+ };
179
+ a?: unknown[];
180
+ m?: unknown;
181
+ p?: unknown;
182
+ }
183
+ /** A `fonts.list[n]` entry — a font REFERENCE (never embedded; the player supplies it). */
184
+ interface LottieFont {
185
+ fName: string;
186
+ fFamily: string;
187
+ fStyle: string;
188
+ fWeight?: string;
189
+ fPath?: string;
190
+ origin?: number;
191
+ ascent?: number;
192
+ }
128
193
  interface LottieLayer {
129
194
  ty: number;
130
195
  nm?: string;
@@ -141,6 +206,8 @@ interface LottieLayer {
141
206
  ao?: number;
142
207
  /** shape layer */
143
208
  shapes?: LottieShapeItem[];
209
+ /** text layer (ty:5) */
210
+ t?: LottieTextData;
144
211
  /** solid */
145
212
  sw?: number;
146
213
  sh?: number;
@@ -181,6 +248,10 @@ interface LottieDocument {
181
248
  ddd?: number;
182
249
  layers: LottieLayer[];
183
250
  assets?: LottieAsset[];
251
+ /** Font references (ty:5 layers name into `fonts.list[n].fName`). */
252
+ fonts?: {
253
+ list: LottieFont[];
254
+ };
184
255
  }
185
256
  //#endregion
186
257
  //#region src/pathvalue.d.ts
@@ -234,4 +305,4 @@ interface ImportOptions {
234
305
  }
235
306
  declare function importLottie(json: unknown, opts?: ImportOptions): LottieImportResult;
236
307
  //#endregion
237
- export { type CodegenOptions, type ExportOptions, type GroupSpec, type ImageSpec, ImportOptions, KAPPA, type LottieDocument, LottieImportError, type LottieImportResult, type NodeSpec, type PathSpec, type RectSpec, buildNode, buildNodes, colorPropIsBytes, ellipseContour, exportLottie, generateSceneModule, importLottie, lottieColor, mergeContours, rectContour, reverseContour, shToContour };
308
+ export { type CodegenOptions, type ExportOptions, type GroupSpec, type ImageSpec, ImportOptions, KAPPA, type LottieDocument, LottieImportError, type LottieImportResult, type NodeSpec, type PathSpec, type RectSpec, type TextSpec, buildNode, buildNodes, colorPropIsBytes, ellipseContour, exportLottie, generateSceneModule, importLottie, lottieColor, mergeContours, rectContour, reverseContour, shToContour };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Circle, Group, ImageNode, Path, Rect, createScene } from "@glissade/scene";
1
+ import { Circle, Group, ImageNode, Path, Rect, Text, createScene } from "@glissade/scene";
2
2
  import { cubicBezier, formatColor, parseColor, sampleTrack, track } from "@glissade/core";
3
3
  import "@glissade/core/expr";
4
4
  //#region src/spec.ts
@@ -250,7 +250,8 @@ const SUPPORTED_LAYER_TYPES = new Set([
250
250
  1,
251
251
  2,
252
252
  3,
253
- 4
253
+ 4,
254
+ 5
254
255
  ]);
255
256
  function reject(ctx, errorClass, detail) {
256
257
  ctx.problems.push(`[${errorClass}] ${detail}`);
@@ -369,8 +370,23 @@ function auditLayer(ctx, layer, index, doc) {
369
370
  const asset = (doc.assets ?? []).find((a) => a.id === layer.refId);
370
371
  if (!asset || typeof asset.p !== "string") reject(ctx, "invalid-asset", `image ${where} references missing asset '${layer.refId ?? ""}'`);
371
372
  }
373
+ if (layer.ty === 5) auditTextLayer(ctx, layer, where);
372
374
  if (layer.shapes) auditShapeItems(ctx, layer.shapes, where);
373
375
  }
376
+ /**
377
+ * Text layers (ty:5) import a static-or-doc-keyframed document; the range-selector
378
+ * ANIMATOR list (`t.a`) has no glissade analogue, so a non-empty one is rejected —
379
+ * loud, not silent (§1). A malformed / missing document is an invalid document.
380
+ */
381
+ function auditTextLayer(ctx, layer, where) {
382
+ const t = layer.t;
383
+ const keys = t?.d?.k;
384
+ if (!Array.isArray(keys) || keys.length === 0 || typeof keys[0]?.s?.t !== "string") {
385
+ reject(ctx, "invalid-text", `${where}: text layer has no readable document (t.d.k[0].s.t)`);
386
+ return;
387
+ }
388
+ if (Array.isArray(t?.a) && t.a.length > 0) reject(ctx, "unsupported-text-animator", `${where}: text range-selector animators (t.a) are not supported`);
389
+ }
374
390
  /** Throws LottieImportError listing EVERY rejection; returns collected warnings. */
375
391
  function auditDocument(doc, allowDegraded) {
376
392
  const ctx = {
@@ -422,6 +438,18 @@ function buildNode(spec) {
422
438
  width: spec.width,
423
439
  height: spec.height
424
440
  });
441
+ case "text": return new Text({
442
+ ...base,
443
+ text: spec.text,
444
+ fill: spec.fill,
445
+ fontSize: spec.fontSize,
446
+ fontFamily: spec.fontFamily,
447
+ ...spec.fontWeight !== void 0 ? { fontWeight: spec.fontWeight } : {},
448
+ ...spec.fontStyle !== void 0 ? { fontStyle: spec.fontStyle } : {},
449
+ ...spec.align !== void 0 ? { align: spec.align } : {},
450
+ ...spec.letterSpacing !== void 0 ? { letterSpacing: spec.letterSpacing } : {},
451
+ ...spec.lineHeight !== void 0 ? { lineHeight: spec.lineHeight } : {}
452
+ });
425
453
  }
426
454
  }
427
455
  function buildNodes(specs) {
@@ -941,6 +969,80 @@ function denormItems(ctx, items, idBase, tm, where) {
941
969
  }
942
970
  return out;
943
971
  }
972
+ /** OTF/bodymovin weight-class names → numeric weight, the inverse of the exporter's map. */
973
+ const WEIGHT_BY_NAME = {
974
+ Thin: 100,
975
+ ExtraLight: 200,
976
+ Light: 300,
977
+ Regular: 400,
978
+ Medium: 500,
979
+ SemiBold: 600,
980
+ Bold: 700,
981
+ ExtraBold: 800,
982
+ Black: 900
983
+ };
984
+ /** Numeric weight from a font ref: prefer `fWeight`, else read a class name out of `fStyle`. */
985
+ function fontWeightFrom(font) {
986
+ if (font === void 0) return void 0;
987
+ if (font.fWeight !== void 0) {
988
+ const n = Number(font.fWeight);
989
+ if (Number.isFinite(n)) return n;
990
+ }
991
+ for (const word of font.fStyle.split(" ")) if (WEIGHT_BY_NAME[word] !== void 0) return WEIGHT_BY_NAME[word];
992
+ }
993
+ /** Justification (0 left / 1 right / 2 center) → glissade align. */
994
+ const justificationToAlign = (j) => j === 1 ? "right" : j === 2 ? "center" : "left";
995
+ /**
996
+ * A varying text-document field → a HOLD track (the document switches discretely
997
+ * between keyframes). Consecutive-equal values collapse; a field constant across
998
+ * the whole stream produces no track (the static base value on the spec suffices).
999
+ */
1000
+ function pushDocTrack(ctx, target, type, dks, tm, extract) {
1001
+ const keys = [];
1002
+ let prev;
1003
+ for (const dk of dks) {
1004
+ const value = extract(dk.s);
1005
+ const sig = JSON.stringify(value);
1006
+ if (sig === prev) continue;
1007
+ const k = {
1008
+ t: toSeconds(tm, dk.t),
1009
+ value
1010
+ };
1011
+ if (keys.length > 0) k.interp = "hold";
1012
+ keys.push(k);
1013
+ prev = sig;
1014
+ }
1015
+ if (keys.length <= 1) return;
1016
+ ctx.tracks.push(track(target, type, enforceMonotonic(keys)));
1017
+ }
1018
+ /** ty:5 layer → a Text spec (+ hold tracks for any animated text/fill/fontSize). */
1019
+ function convertTextLayer(ctx, layer, base, tm) {
1020
+ const dks = layer.t.d.k;
1021
+ const first = dks[0].s;
1022
+ const font = (ctx.doc.fonts?.list ?? []).find((f) => f.fName === first.f);
1023
+ const spec = {
1024
+ kind: "text",
1025
+ id: uid(ctx, `${base}__text`),
1026
+ text: first.t,
1027
+ fill: lottieColor(first.fc, colorPropIsBytes([first.fc])),
1028
+ fontSize: first.s,
1029
+ fontFamily: font?.fFamily ?? first.f
1030
+ };
1031
+ const weight = fontWeightFrom(font);
1032
+ if (weight !== void 0) spec.fontWeight = weight;
1033
+ if (font !== void 0 && /italic/i.test(font.fStyle)) spec.fontStyle = "italic";
1034
+ const align = justificationToAlign(first.j);
1035
+ if (align !== "left") spec.align = align;
1036
+ if (first.tr !== void 0) spec.letterSpacing = first.tr;
1037
+ if (first.lh !== void 0 && first.s > 0) spec.lineHeight = first.lh / first.s;
1038
+ if (dks.length > 1) {
1039
+ pushDocTrack(ctx, `${spec.id}/text`, "string", dks, tm, (s) => s.t);
1040
+ pushDocTrack(ctx, `${spec.id}/fontSize`, "number", dks, tm, (s) => s.s);
1041
+ const bytes = colorPropIsBytes(dks.map((k) => k.s.fc));
1042
+ pushDocTrack(ctx, `${spec.id}/fill`, "color", dks, tm, (s) => lottieColor(s.fc, bytes));
1043
+ }
1044
+ return spec;
1045
+ }
944
1046
  function visibilityWrapper(ctx, base, layer, content, ind) {
945
1047
  const { doc } = ctx;
946
1048
  const docDur = (doc.op - doc.ip) / doc.fr;
@@ -1026,7 +1128,7 @@ function convertLayer(ctx, layer, index) {
1026
1128
  position: [w / 2, h / 2]
1027
1129
  };
1028
1130
  contentChildren.push(image);
1029
- }
1131
+ } else if (layer.ty === 5 && layer.t) contentChildren.push(convertTextLayer(ctx, layer, base, tm));
1030
1132
  if (layer.ty !== 3 && layer.hd !== true) {
1031
1133
  const content = {
1032
1134
  kind: "group",
@@ -1084,7 +1186,8 @@ const CTOR = {
1084
1186
  group: "Group",
1085
1187
  path: "Path",
1086
1188
  rect: "Rect",
1087
- image: "ImageNode"
1189
+ image: "ImageNode",
1190
+ text: "Text"
1088
1191
  };
1089
1192
  function lit(value, indent) {
1090
1193
  return JSON.stringify(value, null, 2).split("\n").join(`\n${indent}`);
@@ -1117,6 +1220,14 @@ function emitNode(spec, indent) {
1117
1220
  case "image":
1118
1221
  props.push(`assetId: ${JSON.stringify(spec.assetId)}`, `width: ${spec.width}`, `height: ${spec.height}`);
1119
1222
  break;
1223
+ case "text":
1224
+ props.push(`text: ${JSON.stringify(spec.text)}`, `fill: ${JSON.stringify(spec.fill)}`, `fontSize: ${spec.fontSize}`, `fontFamily: ${JSON.stringify(spec.fontFamily)}`);
1225
+ if (spec.fontWeight !== void 0) props.push(`fontWeight: ${spec.fontWeight}`);
1226
+ if (spec.fontStyle !== void 0) props.push(`fontStyle: ${JSON.stringify(spec.fontStyle)}`);
1227
+ if (spec.align !== void 0) props.push(`align: ${JSON.stringify(spec.align)}`);
1228
+ if (spec.letterSpacing !== void 0) props.push(`letterSpacing: ${spec.letterSpacing}`);
1229
+ if (spec.lineHeight !== void 0) props.push(`lineHeight: ${spec.lineHeight}`);
1230
+ break;
1120
1231
  }
1121
1232
  return `new ${CTOR[spec.kind]}({\n${props.map((p) => `${inner}${p},`).join("\n")}\n${indent}})`;
1122
1233
  }
@@ -1386,10 +1497,15 @@ function decimateLinearKeys(keys, relEps = .002) {
1386
1497
  * silent). IN: Group hierarchy, Rect/Circle/Path with a SOLID fill (+ optional
1387
1498
  * stroke), transform channels (position / position.x/.y split, opacity, scale,
1388
1499
  * rotation → identity degrees), animated `fill` color, animated `d` path
1389
- * (constant topology). OUT (warned + dropped): Text, Image/Video, gradient/mesh
1390
- * paint (solid only), non-center anchors, group opacity compositing (Lottie
1391
- * parenting never inherits opacity). Animated primitive geometry (width/radius
1392
- * tracks) is SAMPLED, not channel-mapped.
1500
+ * (constant topology), and TEXT (ty:5): a Text node → a text layer with a font
1501
+ * reference (fonts.list) + a text-document keyframe stream (static = one doc;
1502
+ * animated text/fill/fontSize doc keyframes sampled on the frame grid, held).
1503
+ * 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
1506
+ * (`fontAxes`/`fontVariationSettings` — no Lottie doc field), `box` valign
1507
+ * (baseline-approximated) and wrap `width` (the player self-reflows), TokenHighlight.
1508
+ * Animated primitive geometry (width/radius tracks) is SAMPLED, not channel-mapped.
1393
1509
  */
1394
1510
  const EMPTY_TRACKS = /* @__PURE__ */ new Map();
1395
1511
  /** Convert a SceneModule to a Lottie document. Pure over (scene, timeline). */
@@ -1419,9 +1535,11 @@ function exportLottie(mod, opts) {
1419
1535
  op,
1420
1536
  warn,
1421
1537
  layers: [],
1422
- ind: 0
1538
+ ind: 0,
1539
+ fonts: /* @__PURE__ */ new Map()
1423
1540
  };
1424
1541
  walkChildren(ctx, scene.root.children, void 0, byNode);
1542
+ const fonts = [...ctx.fonts.values()];
1425
1543
  return {
1426
1544
  v: BODYMOVIN_VERSION,
1427
1545
  fr,
@@ -1430,7 +1548,8 @@ function exportLottie(mod, opts) {
1430
1548
  w: opts.width,
1431
1549
  h: opts.height,
1432
1550
  nm: "glissade export",
1433
- layers: ctx.layers
1551
+ layers: ctx.layers,
1552
+ ...fonts.length > 0 ? { fonts: { list: fonts } } : {}
1434
1553
  };
1435
1554
  }
1436
1555
  /**
@@ -1466,12 +1585,12 @@ function walkChildren(ctx, children, parentInd, byNode) {
1466
1585
  const node = children[i];
1467
1586
  const kind = classify(node);
1468
1587
  if (kind === "drop") {
1469
- ctx.warn(`${describe(node)} is not exportable (MVP: Group / Rect / Circle / Path) — dropped`);
1588
+ ctx.warn(`${describe(node)} is not exportable (MVP: Group / Rect / Circle / Path / Text) — dropped`);
1470
1589
  continue;
1471
1590
  }
1472
1591
  const myInd = ++ctx.ind;
1473
1592
  const tracks = (node.id !== void 0 ? byNode.get(node.id) : void 0) ?? EMPTY_TRACKS;
1474
- ctx.layers.push(kind === "group" ? buildNullLayer(ctx, node, myInd, parentInd, tracks) : buildShapeLayer(ctx, node, kind, myInd, parentInd, 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));
1475
1594
  if (node instanceof Group) walkChildren(ctx, node.children, myInd, byNode);
1476
1595
  }
1477
1596
  }
@@ -1479,6 +1598,7 @@ function classify(node) {
1479
1598
  if (node instanceof Rect) return "rect";
1480
1599
  if (node instanceof Circle) return "circle";
1481
1600
  if (node instanceof Path) return "path";
1601
+ if (node instanceof Text) return "text";
1482
1602
  if (node instanceof Group) return "group";
1483
1603
  return "drop";
1484
1604
  }
@@ -1625,6 +1745,133 @@ function buildShapeLayer(ctx, node, kind, ind, parentInd, tracks) {
1625
1745
  ...parentInd !== void 0 ? { parent: parentInd } : {}
1626
1746
  };
1627
1747
  }
1748
+ /** Human weight-class names (400 = Regular), the OTF/bodymovin `fStyle` convention. */
1749
+ const WEIGHT_NAMES = {
1750
+ 100: "Thin",
1751
+ 200: "ExtraLight",
1752
+ 300: "Light",
1753
+ 400: "Regular",
1754
+ 500: "Medium",
1755
+ 600: "SemiBold",
1756
+ 700: "Bold",
1757
+ 800: "ExtraBold",
1758
+ 900: "Black"
1759
+ };
1760
+ /** `fStyle` from weight + italic — e.g. 700 + italic → 'Bold Italic', 400 → 'Regular'. */
1761
+ function fontStyleName(weight, italic) {
1762
+ const name = WEIGHT_NAMES[weight] ?? String(weight);
1763
+ if (name === "Regular") return italic ? "Italic" : "Regular";
1764
+ return italic ? `${name} Italic` : name;
1765
+ }
1766
+ /**
1767
+ * Register (family, weight, style) once, returning the stable `fName` the text
1768
+ * document references. De-dupe is keyed on the derived fName, so two Text nodes
1769
+ * sharing a face share one `fonts.list` entry (pure + insertion-ordered). Fonts
1770
+ * are referenced by name only — never embedded; the player supplies the face
1771
+ * (the §3.6 registry papercut).
1772
+ */
1773
+ function registerFont(ctx, node) {
1774
+ const italic = node.fontStyle === "italic";
1775
+ const fStyle = fontStyleName(node.fontWeight, italic);
1776
+ const fName = `${node.fontFamily}-${fStyle.replace(/ /g, "")}`;
1777
+ if (!ctx.fonts.has(fName)) ctx.fonts.set(fName, {
1778
+ fName,
1779
+ fFamily: node.fontFamily,
1780
+ fStyle,
1781
+ fWeight: String(node.fontWeight)
1782
+ });
1783
+ return fName;
1784
+ }
1785
+ /** Justification: glissade align → the Lottie/bodymovin `j` (0 left, 1 right, 2 center). */
1786
+ function alignToJustification(align) {
1787
+ return align === "left" ? 0 : align === "right" ? 1 : 2;
1788
+ }
1789
+ /** The text document at time `t`, sampling the animatable text/fill/fontSize props. */
1790
+ function textDocAt(node, fName, tracks, t) {
1791
+ const text = sampleStr(tracks, "text", node.text(), t);
1792
+ const fill = sampleColor(tracks, "fill", node.fill(), t);
1793
+ const size = sampleNum(tracks, "fontSize", node.fontSize(), t);
1794
+ return {
1795
+ t: text,
1796
+ f: fName,
1797
+ s: size,
1798
+ fc: colorToLottie(fill),
1799
+ j: alignToJustification(node.align),
1800
+ ...node.letterSpacing !== void 0 ? { tr: node.letterSpacing } : {},
1801
+ ...node.lineHeight !== 1.25 ? { lh: size * node.lineHeight } : {}
1802
+ };
1803
+ }
1804
+ const sampleStr = (tracks, prop, staticVal, t) => {
1805
+ const tr = tracks.get(prop);
1806
+ return tr ? sampleTrack(tr, t) : staticVal;
1807
+ };
1808
+ const sampleNum = (tracks, prop, staticVal, t) => {
1809
+ const tr = tracks.get(prop);
1810
+ return tr ? sampleTrack(tr, t) : staticVal;
1811
+ };
1812
+ const sampleColor = (tracks, prop, staticVal, t) => {
1813
+ const tr = tracks.get(prop);
1814
+ return tr && tr.type === "color" ? sampleTrack(tr, t) : staticVal;
1815
+ };
1816
+ /**
1817
+ * The text-document keyframe stream. STATIC (no text/fill/fontSize track) = one
1818
+ * document at t=0. ANIMATED = one document per frame across the animated span,
1819
+ * SAMPLED and held (a Lottie text document switches discretely — smooth fill/size
1820
+ * animation degrades to a per-frame step, the honest MVP limit). Consecutive
1821
+ * identical documents collapse to their first frame so a constant prop stays lean.
1822
+ */
1823
+ function buildTextDocKeyframes(ctx, node, fName, tracks) {
1824
+ const docProps = [
1825
+ "text",
1826
+ "fill",
1827
+ "fontSize"
1828
+ ];
1829
+ if (!docProps.some((p) => tracks.has(p))) return [{
1830
+ t: ctx.ip,
1831
+ s: textDocAt(node, fName, tracks, ctx.ip / ctx.fr)
1832
+ }];
1833
+ ctx.warn(`${describe(node)}: animated text/fill/fontSize is sampled at ${ctx.fr} fps into stepped text documents (not smoothly interpolated)`);
1834
+ const [f0, f1] = frameSpan(ctx, docProps.map((p) => tracks.get(p)));
1835
+ const keys = [];
1836
+ let prev;
1837
+ for (let f = f0; f <= f1; f++) {
1838
+ const s = textDocAt(node, fName, tracks, f / ctx.fr);
1839
+ const sig = JSON.stringify(s);
1840
+ if (sig === prev) continue;
1841
+ keys.push({
1842
+ t: f,
1843
+ s
1844
+ });
1845
+ prev = sig;
1846
+ }
1847
+ return keys;
1848
+ }
1849
+ /** Warn (never silent) on the text features this MVP cannot represent, then drop them. */
1850
+ function warnTextUnsupported(ctx, node, tracks) {
1851
+ if (tracks.has("reveal") || Number.isFinite(node.reveal())) ctx.warn(`${describe(node)}: typewriter 'reveal' is not exported (Lottie range selectors are a later phase) — dropped, full text shown`);
1852
+ if (tracks.has("revealFraction") || !Number.isNaN(node.revealFraction())) ctx.warn(`${describe(node)}: 'revealFraction' is not exported — dropped, full text shown`);
1853
+ if (tracks.has("fontAxes") || Object.keys(node.fontAxes()).length > 0 || node.fontVariationSettings !== void 0) ctx.warn(`${describe(node)}: variable-font axes (fontAxes/fontVariationSettings) have no Lottie text-document field — dropped`);
1854
+ 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
+ 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
+ }
1857
+ function buildTextLayer(ctx, node, ind, parentInd, tracks) {
1858
+ const fName = registerFont(ctx, node);
1859
+ warnTextUnsupported(ctx, node, tracks);
1860
+ const t = {
1861
+ d: { k: buildTextDocKeyframes(ctx, node, fName, tracks) },
1862
+ a: []
1863
+ };
1864
+ return {
1865
+ ty: 5,
1866
+ nm: node.id ?? `text${ind}`,
1867
+ ind,
1868
+ ip: ctx.ip,
1869
+ op: ctx.op,
1870
+ ks: buildTransform(ctx, node, tracks),
1871
+ t,
1872
+ ...parentInd !== void 0 ? { parent: parentInd } : {}
1873
+ };
1874
+ }
1628
1875
  function buildGeometry(ctx, node, kind, tracks) {
1629
1876
  if (kind === "path") return buildPathGeometry(ctx, node, tracks);
1630
1877
  const paramNames = kind === "rect" ? [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/lottie",
3
- "version": "0.45.0",
3
+ "version": "0.46.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.45.0",
22
- "@glissade/scene": "0.45.0"
21
+ "@glissade/core": "0.46.0",
22
+ "@glissade/scene": "0.46.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@glissade/backend-skia": "0.45.0"
25
+ "@glissade/backend-skia": "0.46.0"
26
26
  },
27
27
  "repository": {
28
28
  "type": "git",