domotion-svg 0.1.1

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 (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Unit tests for the frame merger. Uses handcrafted SVG-ish fragments that
3
+ * exercise the identity, hoisting, and recursive-merge paths without needing
4
+ * to capture real DOM.
5
+ */
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import { parseSiblings, mergeFrames, structuralFingerprint } from "./frame-merge.js";
9
+
10
+ function simpleTiming(count: number) {
11
+ // Equally spaced frames, each holding 10% with no transition.
12
+ const startPct: number[] = [];
13
+ const holdEndPct: number[] = [];
14
+ const transEndPct: number[] = [];
15
+ for (let i = 0; i < count; i++) {
16
+ startPct.push((i / count) * 100);
17
+ holdEndPct.push(((i + 0.9) / count) * 100);
18
+ transEndPct.push(((i + 1) / count) * 100);
19
+ }
20
+ return { startPct, holdEndPct, transEndPct };
21
+ }
22
+
23
+ describe("frame-merge: parseSiblings", () => {
24
+ it("parses a simple self-closing tag", () => {
25
+ const r = parseSiblings(`<rect x="1" y="2"/>`);
26
+ expect(r).toHaveLength(1);
27
+ expect(r[0].tag).toBe("rect");
28
+ expect(r[0].selfClosing).toBe(true);
29
+ expect(r[0].attrs!["x"]).toBe("1");
30
+ });
31
+
32
+ it("parses nested elements", () => {
33
+ const r = parseSiblings(`<g transform="translate(1,2)"><use href="#g0"/></g>`);
34
+ expect(r).toHaveLength(1);
35
+ expect(r[0].children).toHaveLength(1);
36
+ expect(r[0].children![0].tag).toBe("use");
37
+ });
38
+
39
+ it("parses multiple siblings", () => {
40
+ const r = parseSiblings(`<g><a/></g><rect/><circle/>`);
41
+ expect(r).toHaveLength(3);
42
+ expect(r.map((n) => n.tag)).toEqual(["g", "rect", "circle"]);
43
+ });
44
+ });
45
+
46
+ describe("frame-merge: mergeFrames", () => {
47
+ it("hoists elements identical across all frames (no timeline class)", () => {
48
+ const frames = [
49
+ `<g transform="translate(10,20)"><rect fill="red"/></g>`,
50
+ `<g transform="translate(10,20)"><rect fill="red"/></g>`,
51
+ `<g transform="translate(10,20)"><rect fill="red"/></g>`,
52
+ ];
53
+ const r = mergeFrames(frames, simpleTiming(3));
54
+ // Element appears exactly once, with no class attribute.
55
+ expect(r.merged).toContain(`<g transform="translate(10,20)">`);
56
+ expect(r.merged.match(/<rect fill="red"/g)).toHaveLength(1);
57
+ // No timeline CSS because it's always visible.
58
+ expect(r.css).toBe("");
59
+ });
60
+
61
+ it("emits per-frame classes for elements visible in a subset of frames", () => {
62
+ const frames = [
63
+ `<g transform="translate(0,0)"><use href="#g1" x="0"/></g>`,
64
+ `<g transform="translate(0,0)"><use href="#g1" x="0"/><use href="#g2" x="100"/></g>`,
65
+ `<g transform="translate(0,0)"><use href="#g1" x="0"/><use href="#g2" x="100"/><use href="#g3" x="200"/></g>`,
66
+ ];
67
+ const r = mergeFrames(frames, simpleTiming(3));
68
+ // g1 should be emitted once with no class (always visible).
69
+ const g1Occurrences = r.merged.match(/<use href="#g1"[^/]*\/>/g) ?? [];
70
+ expect(g1Occurrences.length).toBe(1);
71
+ expect(g1Occurrences[0]).not.toContain("class=");
72
+ // g2 should appear once with a timeline class (visible in frames 1,2).
73
+ const g2Occurrences = r.merged.match(/<use href="#g2"[^/]*\/>/g) ?? [];
74
+ expect(g2Occurrences.length).toBe(1);
75
+ expect(g2Occurrences[0]).toContain("class=");
76
+ // g3 should appear once with a timeline class (visible in frame 2 only).
77
+ const g3Occurrences = r.merged.match(/<use href="#g3"[^/]*\/>/g) ?? [];
78
+ expect(g3Occurrences.length).toBe(1);
79
+ expect(g3Occurrences[0]).toContain("class=");
80
+ // CSS should include 2 @keyframes blocks (one per unique timeline).
81
+ const kfCount = (r.css.match(/@keyframes /g) ?? []).length;
82
+ expect(kfCount).toBe(2);
83
+ });
84
+
85
+ it("merges wrappers with same fingerprint but differing children (typing case)", () => {
86
+ // Simulates capturing a text input as text is typed: the wrapper <g> at the
87
+ // same transform is logically the same text field; children accumulate.
88
+ const frames = [
89
+ `<g transform="translate(32,28)" role="img" aria-label="a"><use href="#gA" x="0"/></g>`,
90
+ `<g transform="translate(32,28)" role="img" aria-label="ab"><use href="#gA" x="0"/><use href="#gB" x="10"/></g>`,
91
+ `<g transform="translate(32,28)" role="img" aria-label="abc"><use href="#gA" x="0"/><use href="#gB" x="10"/><use href="#gC" x="20"/></g>`,
92
+ ];
93
+ const r = mergeFrames(frames, simpleTiming(3));
94
+ // Wrapper <g> should appear once.
95
+ const wrapperOpens = r.merged.match(/<g transform="translate\(32,28\)"/g) ?? [];
96
+ expect(wrapperOpens.length).toBe(1);
97
+ // Each <use> child appears once in the merged output.
98
+ expect((r.merged.match(/href="#gA"/g) ?? []).length).toBe(1);
99
+ expect((r.merged.match(/href="#gB"/g) ?? []).length).toBe(1);
100
+ expect((r.merged.match(/href="#gC"/g) ?? []).length).toBe(1);
101
+ });
102
+ });
103
+
104
+ describe("frame-merge: structuralFingerprint", () => {
105
+ it("matches wrappers by transform/role even if aria-label differs", () => {
106
+ const a = parseSiblings(`<g transform="translate(1,2)" role="img" aria-label="A"/>`)[0];
107
+ const b = parseSiblings(`<g transform="translate(1,2)" role="img" aria-label="B"/>`)[0];
108
+ expect(structuralFingerprint(a)).toBe(structuralFingerprint(b));
109
+ });
110
+
111
+ it("does not match wrappers at different positions", () => {
112
+ const a = parseSiblings(`<g transform="translate(1,2)"/>`)[0];
113
+ const b = parseSiblings(`<g transform="translate(3,4)"/>`)[0];
114
+ expect(structuralFingerprint(a)).not.toBe(structuralFingerprint(b));
115
+ });
116
+
117
+ it("preserves distinct ids for paths with identical d", () => {
118
+ // Regression: fontkit emits the same path data for visually-identical
119
+ // glyphs at different code points. The merger must NOT collapse two
120
+ // <path id="gA" d="X"/> and <path id="gB" d="X"/> into one — that would
121
+ // break <use href="#gB"/> references.
122
+ const a = parseSiblings(`<path id="gA" d="M0 0h10v10z"/>`)[0];
123
+ const b = parseSiblings(`<path id="gB" d="M0 0h10v10z"/>`)[0];
124
+ expect(structuralFingerprint(a)).not.toBe(structuralFingerprint(b));
125
+ });
126
+
127
+ it("keeps both glyph defs when one frame has duplicate-d paths under different ids", () => {
128
+ // End-to-end regression for the install-demo bug: each frame defined
129
+ // <path id="g113" d="X"/> AND <path id="g198" d="X"/>. The merger
130
+ // collapsed them into one def, so <use href="#g198"/> rendered nothing.
131
+ const frames = [
132
+ `<defs><path id="g113" d="M0 0h10z"/><path id="g198" d="M0 0h10z"/></defs><g><use href="#g198"/></g>`,
133
+ `<defs><path id="g113" d="M0 0h10z"/><path id="g198" d="M0 0h10z"/></defs><g><use href="#g198"/></g>`,
134
+ ];
135
+ const r = mergeFrames(frames, simpleTiming(2));
136
+ expect(r.merged).toContain(`id="g113"`);
137
+ expect(r.merged).toContain(`id="g198"`);
138
+ });
139
+
140
+ it("does not apply visibility classes to defs children", () => {
141
+ // Regression: glyph defs introduced in later frames were being given
142
+ // visibility classes (e.g. class="t1"). Then <use href="#gX"/> from a
143
+ // different frame's visible group rendered nothing because the def's
144
+ // own opacity animation hid it during the using frame.
145
+ const frames = [
146
+ `<defs><path id="gA" d="M0 0z"/></defs><g><use href="#gA"/></g>`,
147
+ `<defs><path id="gA" d="M0 0z"/><path id="gB" d="M1 1z"/></defs><g><use href="#gA"/><use href="#gB"/></g>`,
148
+ `<defs><path id="gA" d="M0 0z"/><path id="gB" d="M1 1z"/></defs><g><use href="#gA"/><use href="#gB"/></g>`,
149
+ ];
150
+ const r = mergeFrames(frames, simpleTiming(3));
151
+ // gB is only in frames 2-3, but the def itself must not be hidden by
152
+ // a class — frames 2-3 visible groups need to <use> it.
153
+ const gBdef = r.merged.match(/<path id="gB"[^/]*\/>/);
154
+ expect(gBdef).not.toBeNull();
155
+ expect(gBdef![0]).not.toMatch(/class=/);
156
+ });
157
+ });
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Frame merge: deduplicate elements across animation frames.
3
+ *
4
+ * The scene runner captures one full SVG per frame. Adjacent frames share
5
+ * most of their content (e.g. a terminal prompt that's visible the whole
6
+ * scene, or characters of typed text that stay on-screen once typed). Naively
7
+ * emitting every frame atomically means:
8
+ * - visible flicker: shared elements fade out + back in at every crossfade
9
+ * because CSS blends the two frame groups instead of holding the shared
10
+ * pixels still
11
+ * - huge file sizes: the same `<g>`, `<use>`, `<rect>` markup is repeated
12
+ * N times across N frames
13
+ *
14
+ * This module diffs frames at the element tree level and produces a compact
15
+ * structure where each element appears exactly once, with a CSS-driven
16
+ * visibility timeline. Elements that stay visible the whole scene have
17
+ * opacity: 1 always (no animation). Elements that appear partway and persist
18
+ * get a simple "visible from frame K" timeline. Elements that show up in only
19
+ * a subset of frames get a precise timeline.
20
+ *
21
+ * The parser is deliberately minimal — the generator in dom-to-svg.ts
22
+ * produces a predictable dialect of SVG so we can get away without a full
23
+ * XML parser. We handle tags with balanced open/close, self-closing tags,
24
+ * attributes with double-quoted values, and PCDATA. That's it.
25
+ */
26
+
27
+ // ── Parser ──────────────────────────────────────────────────────────────────
28
+
29
+ export interface ParsedNode {
30
+ kind: "element" | "text";
31
+ /** For element nodes. */
32
+ tag?: string;
33
+ /** Raw attribute string as it appeared on the source element (e.g. ` id="x" fill="red"`). */
34
+ rawAttrs?: string;
35
+ /** Parsed attrs (lowercase keys). */
36
+ attrs?: Record<string, string>;
37
+ children?: ParsedNode[];
38
+ selfClosing?: boolean;
39
+ /** For text nodes. */
40
+ text?: string;
41
+ /** Full serialized form — stable for identity hashing. */
42
+ raw: string;
43
+ }
44
+
45
+ /** Parse a (well-formed) SVG fragment into a list of top-level siblings. */
46
+ export function parseSiblings(src: string): ParsedNode[] {
47
+ const nodes: ParsedNode[] = [];
48
+ let i = 0;
49
+ while (i < src.length) {
50
+ if (src[i] === "<") {
51
+ if (src.startsWith("<!--", i)) {
52
+ const end = src.indexOf("-->", i);
53
+ if (end === -1) throw new Error("frame-merge parse: unterminated comment");
54
+ i = end + 3;
55
+ continue;
56
+ }
57
+ const [node, next] = parseElement(src, i);
58
+ nodes.push(node);
59
+ i = next;
60
+ } else if (/\s/.test(src[i])) {
61
+ i++;
62
+ } else {
63
+ // Loose text between tags — rare at top level in our generator output.
64
+ const start = i;
65
+ while (i < src.length && src[i] !== "<") i++;
66
+ const text = src.slice(start, i);
67
+ if (text.trim().length > 0) {
68
+ nodes.push({ kind: "text", text, raw: text });
69
+ }
70
+ }
71
+ }
72
+ return nodes;
73
+ }
74
+
75
+ function parseElement(src: string, start: number): [ParsedNode, number] {
76
+ // Assume src[start] === "<".
77
+ const tagStart = start + 1;
78
+ const gtEnd = src.indexOf(">", tagStart);
79
+ if (gtEnd === -1) throw new Error("frame-merge parse: unterminated tag");
80
+ const tagAndAttrs = src.slice(tagStart, gtEnd);
81
+ const selfClosing = tagAndAttrs.endsWith("/");
82
+ const tagBody = selfClosing ? tagAndAttrs.slice(0, -1).trimEnd() : tagAndAttrs;
83
+ const firstSpace = tagBody.search(/\s/);
84
+ const tag = firstSpace === -1 ? tagBody : tagBody.slice(0, firstSpace);
85
+ const rawAttrs = firstSpace === -1 ? "" : tagBody.slice(firstSpace);
86
+ const attrs = parseAttrs(rawAttrs);
87
+
88
+ if (selfClosing) {
89
+ const raw = src.slice(start, gtEnd + 1);
90
+ return [{ kind: "element", tag, rawAttrs, attrs, children: [], selfClosing: true, raw }, gtEnd + 1];
91
+ }
92
+
93
+ // Consume children until </tag>.
94
+ let i = gtEnd + 1;
95
+ const children: ParsedNode[] = [];
96
+ const closeToken = `</${tag}>`;
97
+ while (i < src.length) {
98
+ if (src.startsWith(closeToken, i)) {
99
+ const end = i + closeToken.length;
100
+ const raw = src.slice(start, end);
101
+ return [{ kind: "element", tag, rawAttrs, attrs, children, selfClosing: false, raw }, end];
102
+ }
103
+ if (src[i] === "<") {
104
+ if (src.startsWith("<!--", i)) {
105
+ const endC = src.indexOf("-->", i);
106
+ if (endC === -1) throw new Error("frame-merge parse: unterminated comment");
107
+ i = endC + 3;
108
+ continue;
109
+ }
110
+ const [child, next] = parseElement(src, i);
111
+ children.push(child);
112
+ i = next;
113
+ } else {
114
+ // Text content.
115
+ const ts = i;
116
+ while (i < src.length && src[i] !== "<") i++;
117
+ const text = src.slice(ts, i);
118
+ if (text.length > 0) {
119
+ children.push({ kind: "text", text, raw: text });
120
+ }
121
+ }
122
+ }
123
+ throw new Error(`frame-merge parse: missing ${closeToken}`);
124
+ }
125
+
126
+ function parseAttrs(s: string): Record<string, string> {
127
+ const out: Record<string, string> = {};
128
+ const re = /\s+([\w:-]+)\s*=\s*"([^"]*)"/g;
129
+ let m: RegExpExecArray | null;
130
+ while ((m = re.exec(s)) !== null) {
131
+ out[m[1]] = m[2];
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // ── Identity / fingerprinting ──────────────────────────────────────────────
137
+
138
+ /**
139
+ * A "structural fingerprint" for cross-frame matching: two nodes with the
140
+ * same fingerprint are treated as the same logical element even if their
141
+ * inner content (or some attributes) differ.
142
+ *
143
+ * We match on tag + position-identifying attributes. `transform` is the
144
+ * primary positioner in our generator output. Role/fill identify visual
145
+ * "slots" without tying to the text content (aria-label changes per frame as
146
+ * typing progresses — excluding it lets us merge).
147
+ *
148
+ * `id` is included because it's an identity attribute: two `<path id="g113"
149
+ * d="X"/>` and `<path id="g198" d="X"/>` glyph defs are NOT the same logical
150
+ * element even if their geometry is identical (fontkit emits matching path
151
+ * data for visually-identical glyphs at different code points). Without
152
+ * including `id`, those two defs would collapse into one and `<use href="#g198"/>`
153
+ * references would break — see frame-merge.test.ts "preserves distinct ids
154
+ * for paths with identical d".
155
+ */
156
+ export function structuralFingerprint(n: ParsedNode): string {
157
+ if (n.kind === "text") return `T:${n.text}`;
158
+ const a = n.attrs ?? {};
159
+ const keyAttrs = ["id", "transform", "href", "x", "y", "d", "width", "height", "fill", "role", "class"];
160
+ const pairs = keyAttrs.filter((k) => a[k] != null).map((k) => `${k}=${a[k]}`);
161
+ return `${n.tag}|${pairs.join(",")}`;
162
+ }
163
+
164
+ // ── Merge ───────────────────────────────────────────────────────────────────
165
+
166
+ export interface MergeResult {
167
+ /**
168
+ * CSS `@keyframes` blocks to append to the animated SVG's style sheet. One
169
+ * block per unique visibility timeline class.
170
+ */
171
+ css: string;
172
+ /**
173
+ * A single `<g>` subtree representing the merged, de-duplicated content
174
+ * across all frames. Elements carry a `class` attribute linking them to
175
+ * their timeline keyframe when they're not always-visible.
176
+ */
177
+ merged: string;
178
+ }
179
+
180
+ interface FrameTimingModel {
181
+ /** 0..100 percent at which frame i becomes fully visible. */
182
+ startPct: number[];
183
+ /** 0..100 percent at which frame i begins fading out. */
184
+ holdEndPct: number[];
185
+ /** 0..100 percent at which frame i is fully faded out. */
186
+ transEndPct: number[];
187
+ }
188
+
189
+ /**
190
+ * Merge parallel frames of SVG top-level content into a single de-duplicated
191
+ * element tree with CSS visibility timelines.
192
+ *
193
+ * @param framesSvg The svgContent of each frame (the inner markup that the
194
+ * animator would otherwise wrap in `<g class="f f-N">`).
195
+ * @param timing Per-frame timing (computed by the animator) used to build
196
+ * the visibility keyframes.
197
+ * @param classPrefix Unique prefix for timeline class names to avoid colliding
198
+ * with other CSS in the same SVG.
199
+ */
200
+ export function mergeFrames(
201
+ framesSvg: string[],
202
+ timing: FrameTimingModel,
203
+ classPrefix: string = "t",
204
+ ): MergeResult {
205
+ const frameNodes = framesSvg.map((s) => parseSiblings(s));
206
+
207
+ // Build a "virtual root" per frame whose children are the top-level siblings.
208
+ const rootsPerFrame: ParsedNode[] = frameNodes.map((siblings) => ({
209
+ kind: "element",
210
+ tag: "__root__",
211
+ rawAttrs: "",
212
+ attrs: {},
213
+ children: siblings,
214
+ selfClosing: false,
215
+ raw: "",
216
+ }));
217
+
218
+ // Assign each unique timeline (set of visible frames) to a class name.
219
+ const timelineClasses = new Map<string, string>(); // key = "0,1,2,3" → class "t0"
220
+ const timelineCss: string[] = [];
221
+ const totalFrames = framesSvg.length;
222
+
223
+ const getTimelineClass = (visibleFrames: number[]): string => {
224
+ const key = visibleFrames.join(",");
225
+ const existing = timelineClasses.get(key);
226
+ if (existing != null) return existing;
227
+ if (visibleFrames.length === totalFrames) {
228
+ // Always visible — no class needed.
229
+ timelineClasses.set(key, "");
230
+ return "";
231
+ }
232
+ const name = `${classPrefix}${timelineClasses.size}`;
233
+ timelineClasses.set(key, name);
234
+ timelineCss.push(buildTimelineKeyframes(name, visibleFrames, timing));
235
+ return name;
236
+ };
237
+
238
+ const merged = mergeNode(rootsPerFrame, getTimelineClass);
239
+ // mergeNode returns the inner content of the virtual root.
240
+ return { css: timelineCss.join("\n"), merged };
241
+ }
242
+
243
+ /**
244
+ * Recursively merge the CHILDREN of a set of parallel nodes (one per frame),
245
+ * returning the merged inner-content string. If the inputs are the top-level
246
+ * virtual roots, the returned string is the merged scene (siblings).
247
+ *
248
+ * Invariant: all inputs share the same structural fingerprint (the caller
249
+ * already matched them).
250
+ */
251
+ function mergeNode(
252
+ perFrame: ParsedNode[],
253
+ getClass: (visibleFrames: number[]) => string,
254
+ /**
255
+ * When true, do not apply visibility classes to children. Used for
256
+ * `<defs>` content (glyph paths, gradients, clipPaths) — these are
257
+ * referenced by `<use>` elements in visible groups, so they must
258
+ * always be defined regardless of which frames they originated from.
259
+ * Applying a class would hide the def's contents during other frames
260
+ * and break the references.
261
+ */
262
+ alwaysVisible: boolean = false,
263
+ ): string {
264
+ // Collect child groups. Each group = children at the same "slot" across
265
+ // frames that share a fingerprint. For frames lacking a matching child at
266
+ // that slot, we record an absence.
267
+ //
268
+ // Matching strategy: walk each frame's children in order; for each unique
269
+ // fingerprint, collect the indices of frames it appears in and the raw
270
+ // content per frame. This preserves scene order (new elements append at
271
+ // the end).
272
+ interface ChildGroup {
273
+ /** Fingerprint shared by all occurrences. */
274
+ fingerprint: string;
275
+ /** Per-frame occurrence, or null if the frame lacks this child. */
276
+ occurrences: Array<ParsedNode | null>;
277
+ /** First frame index that contains this child (for ordering). */
278
+ firstFrame: number;
279
+ /** Child order within its first-containing frame (for ordering). */
280
+ firstPosition: number;
281
+ }
282
+
283
+ const groupsByFp = new Map<string, ChildGroup>();
284
+ perFrame.forEach((parent, frameIdx) => {
285
+ const children = parent.children ?? [];
286
+ children.forEach((child, pos) => {
287
+ const fp = structuralFingerprint(child);
288
+ let group = groupsByFp.get(fp);
289
+ if (group == null) {
290
+ group = {
291
+ fingerprint: fp,
292
+ occurrences: new Array(perFrame.length).fill(null),
293
+ firstFrame: frameIdx,
294
+ firstPosition: pos,
295
+ };
296
+ groupsByFp.set(fp, group);
297
+ }
298
+ // If multiple matches in same frame, prefer the first.
299
+ if (group.occurrences[frameIdx] == null) {
300
+ group.occurrences[frameIdx] = child;
301
+ }
302
+ });
303
+ });
304
+
305
+ // Sort groups by (firstFrame, firstPosition) so new additions appear in the
306
+ // order they first showed up.
307
+ const groups = [...groupsByFp.values()].sort((a, b) => {
308
+ if (a.firstFrame !== b.firstFrame) return a.firstFrame - b.firstFrame;
309
+ return a.firstPosition - b.firstPosition;
310
+ });
311
+
312
+ const parts: string[] = [];
313
+ for (const g of groups) {
314
+ const visibleFrames = g.occurrences.map((o, i) => (o != null ? i : -1)).filter((i) => i >= 0);
315
+ const contents = g.occurrences.filter((o) => o != null) as ParsedNode[];
316
+
317
+ // Fast path: this element is byte-identical across all its occurrences.
318
+ const raws = new Set(contents.map((c) => c.raw));
319
+ if (raws.size === 1) {
320
+ const cls = alwaysVisible ? "" : getClass(visibleFrames);
321
+ parts.push(renderWithClass(contents[0], cls));
322
+ continue;
323
+ }
324
+
325
+ // Slow path: same fingerprint but differing inner content — try recursing.
326
+ const firstKind = contents[0].kind;
327
+ if (firstKind !== "element" || contents.some((c) => c.kind !== "element")) {
328
+ // Text nodes with differing text, or mixed kinds — just keep them
329
+ // per-frame (each with its own timeline).
330
+ for (const [frameIdx, occ] of g.occurrences.entries()) {
331
+ if (occ == null) continue;
332
+ const cls = getClass([frameIdx]);
333
+ parts.push(renderWithClass(occ, cls));
334
+ }
335
+ continue;
336
+ }
337
+
338
+ // For element nodes: recurse into children. Use the FIRST occurrence's
339
+ // wrapper attributes (aria-label, title text, etc. diverge across frames,
340
+ // but the wrapper is logically "the same slot").
341
+ const wrapper = contents[0];
342
+ const perFrameChildrenParents: ParsedNode[] = g.occurrences.map((occ) => {
343
+ if (occ != null && occ.kind === "element") return occ;
344
+ // For frames where this slot is absent, use an empty placeholder so the
345
+ // recursion sees no children for that frame.
346
+ return { kind: "element", tag: wrapper.tag ?? "g", rawAttrs: "", attrs: {}, children: [], selfClosing: false, raw: "" };
347
+ });
348
+
349
+ // Children of `<defs>` are reusable resources (glyph paths, gradients,
350
+ // clipPaths) referenced by `<use>` from visible groups. They must always
351
+ // be defined regardless of which frames they originated from, otherwise
352
+ // a `<use>` from one frame referencing a def introduced in another frame
353
+ // would render nothing.
354
+ const childAlwaysVisible = alwaysVisible || wrapper.tag === "defs";
355
+ const innerMerged = mergeNode(perFrameChildrenParents, getClass, childAlwaysVisible);
356
+ const visibilityClass = alwaysVisible ? "" : getClass(visibleFrames);
357
+ const merged: string = renderWrapperWithInner(wrapper, innerMerged, visibilityClass);
358
+ parts.push(merged);
359
+ }
360
+
361
+ return parts.join("\n");
362
+ }
363
+
364
+ function renderWithClass(n: ParsedNode, cls: string): string {
365
+ if (n.kind === "text") return n.text ?? "";
366
+ if (cls === "") return n.raw;
367
+ return injectClass(n, cls);
368
+ }
369
+
370
+ function renderWrapperWithInner(wrapper: ParsedNode, inner: string, cls: string): string {
371
+ if (wrapper.kind !== "element") return wrapper.raw;
372
+ const attrs = cls === "" ? (wrapper.rawAttrs ?? "") : mergeClassInAttrs(wrapper.rawAttrs ?? "", cls);
373
+ const open = `<${wrapper.tag}${attrs}>`;
374
+ const close = `</${wrapper.tag}>`;
375
+ return `${open}${inner}${close}`;
376
+ }
377
+
378
+ function injectClass(n: ParsedNode, cls: string): string {
379
+ if (n.kind !== "element") return n.raw;
380
+ const attrs = mergeClassInAttrs(n.rawAttrs ?? "", cls);
381
+ if (n.selfClosing === true) return `<${n.tag}${attrs}/>`;
382
+ // Preserve children verbatim.
383
+ const inner = (n.children ?? []).map((c) => c.raw).join("");
384
+ return `<${n.tag}${attrs}>${inner}</${n.tag}>`;
385
+ }
386
+
387
+ function mergeClassInAttrs(rawAttrs: string, cls: string): string {
388
+ if (cls === "") return rawAttrs;
389
+ // If there's already a class attribute, append to it.
390
+ const classMatch = /\sclass="([^"]*)"/.exec(rawAttrs);
391
+ if (classMatch != null) {
392
+ const merged = `${classMatch[1]} ${cls}`;
393
+ return rawAttrs.replace(classMatch[0], ` class="${merged}"`);
394
+ }
395
+ return `${rawAttrs} class="${cls}"`;
396
+ }
397
+
398
+ // ── Timeline CSS ────────────────────────────────────────────────────────────
399
+
400
+ /**
401
+ * Build an @keyframes block that makes the given class visible (opacity 1)
402
+ * during exactly the given set of frames, and invisible otherwise.
403
+ *
404
+ * Visibility windows are contiguous ranges of frames (e.g. [3,4,5] → visible
405
+ * from frame 3's startPct to frame 5's transEndPct). The produced rule uses
406
+ * `animation-timing-function: step-end` so there's no interpolation — the
407
+ * class flips on and off instantly at frame boundaries. This is what we want
408
+ * for "character appears at this frame and stays" semantics, and it avoids
409
+ * the cross-fade artifact that caused the original flicker bug.
410
+ */
411
+ function buildTimelineKeyframes(name: string, visibleFrames: number[], timing: FrameTimingModel): string {
412
+ // Collapse visible frames into contiguous ranges.
413
+ const ranges: Array<[number, number]> = [];
414
+ let rangeStart: number | null = null;
415
+ let prev: number | null = null;
416
+ for (const f of visibleFrames) {
417
+ if (rangeStart == null) {
418
+ rangeStart = f;
419
+ prev = f;
420
+ } else if (prev != null && f === prev + 1) {
421
+ prev = f;
422
+ } else {
423
+ if (prev != null) ranges.push([rangeStart, prev]);
424
+ rangeStart = f;
425
+ prev = f;
426
+ }
427
+ }
428
+ if (rangeStart != null && prev != null) ranges.push([rangeStart, prev]);
429
+
430
+ // Build keyframe stops. Use step-end so opacity switches instantly.
431
+ const stops: Array<[number, number]> = []; // (pct, opacity)
432
+ stops.push([0, 0]);
433
+ for (const [lo, hi] of ranges) {
434
+ const onPct = timing.startPct[lo];
435
+ const offPct = timing.transEndPct[hi];
436
+ // Ensure monotonic increase (shouldn't happen out of order given visible
437
+ // frames are sorted, but guard anyway).
438
+ if (stops[stops.length - 1][0] < onPct) stops.push([Math.max(0, onPct - 0.001), 0]);
439
+ stops.push([onPct, 1]);
440
+ stops.push([Math.min(100, offPct), 1]);
441
+ stops.push([Math.min(100, offPct + 0.001), 0]);
442
+ }
443
+ stops.push([100, 0]);
444
+
445
+ const lines = stops.map(([p, o]) => ` ${p.toFixed(3)}% { opacity: ${o}; }`);
446
+ return ` @keyframes ${name} {\n${lines.join("\n")}\n }\n .${name} { animation: ${name} var(--scene-dur) infinite; animation-timing-function: step-end; }`;
447
+ }
@@ -0,0 +1,2 @@
1
+ declare module "bidi-js";
2
+ declare module "fontkit";