@flighthq/skeleton2d-formats 0.3.0-edge.1458.0c01b63

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.
@@ -0,0 +1,1042 @@
1
+ import { easeCubicBezier } from '@flighthq/easing/contract';
2
+ import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
3
+ import {
4
+ applyAnimationClipToSkeleton2D,
5
+ cloneSkeleton2D,
6
+ getSkeleton2DSkin,
7
+ setSkeleton2DSkin,
8
+ } from '@flighthq/skeleton2d/contract';
9
+ import type {
10
+ AnimationChannel,
11
+ ImportDiagnostic,
12
+ MeshAttachment2D,
13
+ RegionAttachment2D,
14
+ Skeleton2DAnimationTarget,
15
+ } from '@flighthq/types/contract';
16
+ import {
17
+ MeshAttachment2DKind,
18
+ RegionAttachment2DKind,
19
+ Skeleton2DAnimationPath,
20
+ TransformMode2D,
21
+ } from '@flighthq/types/contract';
22
+ import { describe, expect, it } from 'vitest';
23
+
24
+ import { parseDragonBonesSkeleton } from './dragonBonesParse';
25
+
26
+ // Hand-authored minimal DragonBones JSON (per the real-asset rule: committed fixtures are hand-written,
27
+ // never transcribed from a rig). Bones are listed CHILD-FIRST to exercise the topological sort.
28
+ const DB_TWO_BONES = JSON.stringify({
29
+ frameRate: 24,
30
+ name: 'demo',
31
+ version: '5.5',
32
+ armature: [
33
+ {
34
+ name: 'armatureA',
35
+ bone: [
36
+ { name: 'arm', parent: 'root', length: 50, transform: { x: 10, y: 20, skX: 45, skY: 30, scX: 2, scY: 3 } },
37
+ { name: 'root' },
38
+ ],
39
+ },
40
+ ],
41
+ });
42
+
43
+ describe('parseDragonBonesSkeleton', () => {
44
+ it('parses the first armature, topologically sorts bones, and maps the skX/skY transform', () => {
45
+ const result = parseDragonBonesSkeleton(DB_TWO_BONES);
46
+ expect(result).not.toBeNull();
47
+ const bones = result!.skeleton.bones;
48
+ expect(bones.length).toBe(2);
49
+ // Topological sort puts the parent (listed second) before its child.
50
+ expect(bones[0].name).toBe('root');
51
+ expect(bones[0].parentIndex).toBe(-1);
52
+ const arm = bones[1];
53
+ expect(arm.name).toBe('arm');
54
+ expect(arm.parentIndex).toBe(0); // 'root' resolved to index 0
55
+ expect(arm.length).toBe(50);
56
+ expect(arm.x).toBe(10);
57
+ expect(arm.y).toBe(20);
58
+ // rotation = skY = 30, shearX = 0, shearY = skX − skY = 15; scale from scX/scY.
59
+ expect(arm.rotation).toBe(30);
60
+ expect(arm.shearX).toBe(0);
61
+ expect(arm.shearY).toBe(15);
62
+ expect(arm.scaleX).toBe(2);
63
+ expect(arm.scaleY).toBe(3);
64
+ expect(result!.animations).toEqual([]);
65
+ });
66
+
67
+ it('maps the newer rotate/skew transform form identically to skX/skY', () => {
68
+ const doc = { armature: [{ bone: [{ name: 'b', transform: { rotate: 30, skew: 15 } }] }] };
69
+ const bone = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0];
70
+ expect(bone.rotation).toBe(30);
71
+ expect(bone.shearY).toBe(15);
72
+ });
73
+
74
+ it('applies DragonBones defaults for an omitted transform', () => {
75
+ const doc = { armature: [{ bone: [{ name: 'b' }] }] };
76
+ const bone = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0];
77
+ expect(bone).toMatchObject({
78
+ x: 0,
79
+ y: 0,
80
+ rotation: 0,
81
+ shearX: 0,
82
+ shearY: 0,
83
+ scaleX: 1,
84
+ scaleY: 1,
85
+ length: 0,
86
+ parentIndex: -1,
87
+ transformMode: TransformMode2D.Normal,
88
+ });
89
+ });
90
+
91
+ it('maps the DragonBones inheritance booleans onto TransformMode2D', () => {
92
+ const doc = {
93
+ armature: [
94
+ {
95
+ bone: [
96
+ { name: 'normal' },
97
+ { name: 'onlyT', inheritRotation: false, inheritScale: false, inheritReflection: false },
98
+ { name: 'noScale', inheritScale: false },
99
+ { name: 'noScaleRefl', inheritScale: false, inheritReflection: false },
100
+ { name: 'noRot', inheritRotation: false, inheritReflection: false },
101
+ ],
102
+ },
103
+ ],
104
+ };
105
+ const bones = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones;
106
+ const mode = (name: string) => bones.find((b) => b.name === name)!.transformMode;
107
+ // Booleans map straight to TransformInherit2D; the five well-known combos equal their named presets.
108
+ expect(mode('normal')).toEqual(TransformMode2D.Normal);
109
+ expect(mode('onlyT')).toEqual(TransformMode2D.OnlyTranslation);
110
+ expect(mode('noScale')).toEqual(TransformMode2D.NoScale);
111
+ expect(mode('noScaleRefl')).toEqual(TransformMode2D.NoScaleOrReflection);
112
+ expect(mode('noRot')).toEqual(TransformMode2D.NoRotationOrReflection);
113
+ });
114
+
115
+ it('expresses an inherit combo the old five-value enum could not, with no Skip crumb', () => {
116
+ // "strip rotation, keep scale AND reflection" had no TransformMode2D preset; the factored boolean model
117
+ // holds it directly. It maps cleanly and emits no diagnostic.
118
+ const doc = { armature: [{ bone: [{ name: 'b', inheritRotation: false }] }] };
119
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
120
+ parseDragonBonesSkeleton(JSON.stringify(doc), sink),
121
+ );
122
+ expect(crumbs).toEqual([]);
123
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0].transformMode).toEqual({
124
+ reflection: true,
125
+ rotation: false,
126
+ scale: true,
127
+ translation: true,
128
+ });
129
+ });
130
+
131
+ it('parses slots with resolved bone index, image display, and ColorTransform tint', () => {
132
+ const doc = {
133
+ armature: [
134
+ {
135
+ bone: [{ name: 'root' }, { name: 'armBone', parent: 'root' }],
136
+ slot: [{ name: 'arm', parent: 'armBone', displayIndex: 0, color: { rM: 50, gM: 75, bM: 100, aM: 100 } }],
137
+ skin: [
138
+ {
139
+ name: 'default',
140
+ slot: [{ name: 'arm', display: [{ name: 'armImage', transform: { x: 1, y: 2, skX: 30, skY: 30 } }] }],
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ };
146
+ const slots = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots!;
147
+ expect(slots.length).toBe(1);
148
+ expect(slots[0].name).toBe('arm');
149
+ expect(slots[0].boneIndex).toBe(1); // 'armBone' resolved to its topo-sorted output index
150
+ // ColorTransform multiply 50/75/100/100 % → RR GG BB AA bytes 0x80 0xbf 0xff 0xff.
151
+ expect(slots[0].color).toBe(0x80bfffff);
152
+ const region = slots[0].attachment as RegionAttachment2D;
153
+ expect(region.kind).toBe(RegionAttachment2DKind);
154
+ expect(region).toMatchObject({ x: 1, y: 2, rotation: 30, scaleX: 1, scaleY: 1 });
155
+ });
156
+
157
+ it('parses an unweighted mesh display into a MeshAttachment2D (positions direct, skin null)', () => {
158
+ const doc = {
159
+ armature: [
160
+ {
161
+ bone: [{ name: 'root' }],
162
+ slot: [{ name: 's', parent: 'root' }],
163
+ skin: [
164
+ {
165
+ name: 'default',
166
+ slot: [
167
+ {
168
+ name: 's',
169
+ display: [
170
+ {
171
+ type: 'mesh',
172
+ name: 'm',
173
+ uvs: [0, 0, 1, 0, 1, 1],
174
+ triangles: [0, 1, 2],
175
+ vertices: [0, 0, 10, 0, 10, 10],
176
+ },
177
+ ],
178
+ },
179
+ ],
180
+ },
181
+ ],
182
+ },
183
+ ],
184
+ };
185
+ const mesh = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
186
+ expect(mesh.kind).toBe(MeshAttachment2DKind);
187
+ expect(mesh.skin).toBeNull();
188
+ expect(mesh.vertexCount).toBe(3);
189
+ expect(Array.from(mesh.vertices!)).toEqual([0, 0, 10, 0, 10, 10]);
190
+ expect(Array.from(mesh.triangles)).toEqual([0, 1, 2]);
191
+ });
192
+
193
+ // Wraps a single weighted mesh display in a full DragonBones doc. Bones are listed CHILD-FIRST so the
194
+ // topo-sort reorders (output [root=0, child=1]); the weights reference bones by armature FILE-ORDER index.
195
+ function weightedMeshDoc(mesh: Record<string, unknown>): string {
196
+ return JSON.stringify({
197
+ armature: [
198
+ {
199
+ bone: [{ name: 'child', parent: 'root' }, { name: 'root' }],
200
+ slot: [{ name: 's', parent: 'root' }],
201
+ skin: [{ name: 'default', slot: [{ name: 's', display: [{ type: 'mesh', name: 'm', ...mesh }] }] }],
202
+ },
203
+ ],
204
+ });
205
+ }
206
+
207
+ it('converts a WEIGHTED mesh to Skin2D offsets, remapping bone indices through the topo-sort (formula parity)', () => {
208
+ // slotPose identity; the used bone is armature raw index 0 ('child'), which the topo-sort places at
209
+ // OUTPUT index 1. Its bind matrix is translate(10,0), so the inverse maps vertex (12,5) → bind-local (2,5).
210
+ const mesh = parseDragonBonesSkeleton(
211
+ weightedMeshDoc({
212
+ uvs: [0, 0],
213
+ triangles: [],
214
+ vertices: [12, 5],
215
+ slotPose: [1, 0, 0, 1, 0, 0],
216
+ bonePose: [0, 1, 0, 0, 1, 10, 0], // rawBoneIndex 0, bind = translate(10,0)
217
+ weights: [1, 0, 1], // vertex 0: one influence, rawBoneIndex 0, weight 1
218
+ }),
219
+ )!.skeleton.slots![0].attachment as MeshAttachment2D;
220
+ expect(mesh.vertices).toBeNull();
221
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([1]);
222
+ // [outputBoneIndex, offsetX, offsetY, weight]: raw bone 0 ('child') → OUTPUT 1; offset (2,5).
223
+ expect(Array.from(mesh.skin!.influences)).toEqual([1, 2, 5, 1]);
224
+ });
225
+
226
+ it('applies the inverse bind ROTATION when converting a weighted mesh (formula parity)', () => {
227
+ // Bind matrix = a 90° rotation ([a,b,c,d]=[0,1,-1,0]); its inverse rotates the vertex (1,0) to (0,-1).
228
+ const mesh = parseDragonBonesSkeleton(
229
+ weightedMeshDoc({
230
+ uvs: [0, 0],
231
+ triangles: [],
232
+ vertices: [1, 0],
233
+ slotPose: [1, 0, 0, 1, 0, 0],
234
+ bonePose: [1, 0, 1, -1, 0, 0, 0], // rawBoneIndex 1 ('root' → output 0), bind = 90° rotation
235
+ weights: [1, 1, 1], // vertex 0: one influence, rawBoneIndex 1, weight 1
236
+ }),
237
+ )!.skeleton.slots![0].attachment as MeshAttachment2D;
238
+ const influences = Array.from(mesh.skin!.influences);
239
+ expect(influences[0]).toBe(0); // raw bone 1 ('root') → OUTPUT 0
240
+ expect(influences[1]).toBeCloseTo(0, 5); // offsetX
241
+ expect(influences[2]).toBeCloseTo(-1, 5); // offsetY
242
+ expect(influences[3]).toBe(1); // weight
243
+ });
244
+
245
+ it('recovers (Recover crumb) a weighted mesh whose stream is truncated, without reading past the end', () => {
246
+ const kinds = collectImportDiagnostics((sink) =>
247
+ parseDragonBonesSkeleton(
248
+ weightedMeshDoc({
249
+ uvs: [0, 0],
250
+ triangles: [],
251
+ vertices: [0, 0],
252
+ slotPose: [1, 0, 0, 1, 0, 0],
253
+ bonePose: [1, 1, 0, 0, 1, 0, 0],
254
+ weights: [2, 1, 1], // declares 2 influences but only supplies one before the stream ends
255
+ }),
256
+ sink,
257
+ ),
258
+ ).map((c) => c.kind);
259
+ expect(kinds).toContain('dragonbones.weighted-mesh-recovered');
260
+ });
261
+
262
+ it('recover-drops an influence whose bone index does not resolve, never emitting a -1 index (BLOCK 1)', () => {
263
+ // bonePose references raw bone 99, out of range for the 2-bone armature → the remap returns -1. A -1
264
+ // would index deformSkeleton2DMeshAttachment's world buffer from byte -6 and produce NaNs; it is dropped.
265
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
266
+ parseDragonBonesSkeleton(
267
+ weightedMeshDoc({
268
+ uvs: [0, 0],
269
+ triangles: [],
270
+ vertices: [0, 0],
271
+ slotPose: [1, 0, 0, 1, 0, 0],
272
+ bonePose: [99, 1, 0, 0, 1, 0, 0], // used bone's raw index 99 has no output bone
273
+ weights: [1, 99, 1],
274
+ }),
275
+ sink,
276
+ ),
277
+ );
278
+ const mesh = parseDragonBonesSkeleton(
279
+ weightedMeshDoc({
280
+ uvs: [0, 0],
281
+ triangles: [],
282
+ vertices: [0, 0],
283
+ slotPose: [1, 0, 0, 1, 0, 0],
284
+ bonePose: [99, 1, 0, 0, 1, 0, 0],
285
+ weights: [1, 99, 1],
286
+ }),
287
+ )!.skeleton.slots![0].attachment as MeshAttachment2D;
288
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([0]); // the unresolved influence was dropped
289
+ expect(Array.from(mesh.skin!.influences)).toEqual([]); // no -1 index emitted
290
+ expect(crumbs.map((c) => c.kind)).toContain('dragonbones.weighted-mesh-recovered');
291
+ });
292
+
293
+ it('remaps weighted bone indices positionally, so duplicate bone names do not collide (BLOCK 2)', () => {
294
+ // Two surviving bones share the name "dup". Resolving the weights remap by name alone would send both
295
+ // influences to the last "dup" (output 1); the positional remap keeps raw 0 → output 0, raw 1 → output 1.
296
+ const doc = {
297
+ armature: [
298
+ {
299
+ bone: [{ name: 'dup' }, { name: 'dup' }],
300
+ slot: [{ name: 's', parent: 'dup' }],
301
+ skin: [
302
+ {
303
+ name: 'default',
304
+ slot: [
305
+ {
306
+ name: 's',
307
+ display: [
308
+ {
309
+ type: 'mesh',
310
+ name: 'm',
311
+ uvs: [0, 0, 0, 0],
312
+ triangles: [],
313
+ vertices: [0, 0, 0, 0],
314
+ slotPose: [1, 0, 0, 1, 0, 0],
315
+ bonePose: [0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0], // used bones raw 0 and raw 1
316
+ weights: [1, 0, 1, 1, 1, 1], // vertex 0 → raw bone 0; vertex 1 → raw bone 1
317
+ },
318
+ ],
319
+ },
320
+ ],
321
+ },
322
+ ],
323
+ },
324
+ ],
325
+ };
326
+ const skin = (parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D)
327
+ .skin!;
328
+ // influences are [boneIndex, x, y, weight] per influence; vertex 0's bone then vertex 1's bone.
329
+ expect(skin.influences[0]).toBe(0); // raw bone 0 → OUTPUT 0
330
+ expect(skin.influences[4]).toBe(1); // raw bone 1 → OUTPUT 1, not collided onto 0
331
+ });
332
+
333
+ it('caps a vertex influence count at the representable maximum instead of wrapping Uint16 (BLOCK 3)', () => {
334
+ // A vertex declaring 65536 influences would wrap influenceCounts[0] to 0, breaking the deformer's
335
+ // invariant influences.length === 4 × Σ influenceCounts. The count is capped while the stream is consumed.
336
+ const pairs: number[] = [65536];
337
+ for (let k = 0; k < 65536; k++) pairs.push(0, 0); // 65536 influences on raw bone 0, weight 0
338
+ const mesh = parseDragonBonesSkeleton(
339
+ weightedMeshDoc({
340
+ uvs: [0, 0],
341
+ triangles: [],
342
+ vertices: [0, 0],
343
+ slotPose: [1, 0, 0, 1, 0, 0],
344
+ bonePose: [0, 1, 0, 0, 1, 0, 0],
345
+ weights: pairs,
346
+ }),
347
+ )!.skeleton.slots![0].attachment as MeshAttachment2D;
348
+ const total = Array.from(mesh.skin!.influenceCounts).reduce((a, c) => a + c, 0);
349
+ expect(total).toBe(0xffff); // capped, not wrapped to 0
350
+ expect(mesh.skin!.influences.length).toBe(total * 4); // deformer invariant holds
351
+ });
352
+
353
+ it('holds an unmodeled display at its displayIndex slot (null, not dropped) so indices stay aligned', () => {
354
+ // A boundingBox display at index 0 must NOT drop, or the image at index 1 would shift to 0 and
355
+ // displayIndex 1 would then address the wrong display (read-integrity axis 12 on the display array).
356
+ const doc = {
357
+ armature: [
358
+ {
359
+ bone: [{ name: 'root' }],
360
+ slot: [{ name: 's', parent: 'root', displayIndex: 1 }],
361
+ skin: [
362
+ {
363
+ name: 'default',
364
+ slot: [
365
+ {
366
+ name: 's',
367
+ display: [
368
+ { type: 'boundingBox', name: 'bb' },
369
+ { name: 'img', transform: { x: 9 } },
370
+ ],
371
+ },
372
+ ],
373
+ },
374
+ ],
375
+ },
376
+ ],
377
+ };
378
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
379
+ parseDragonBonesSkeleton(JSON.stringify(doc), sink),
380
+ );
381
+ expect(crumbs.map((c) => c.kind)).toContain('dragonbones.boundingBox-display-unsupported');
382
+ // displayIndex 1 still resolves to the image, not shifted down by the dropped mesh.
383
+ const region = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as RegionAttachment2D;
384
+ expect(region.kind).toBe(RegionAttachment2DKind);
385
+ expect(region.x).toBe(9);
386
+ });
387
+
388
+ it('Skip-crumbs additional armatures and IK constraints, but now PARSES alternate skins', () => {
389
+ const doc = {
390
+ armature: [
391
+ {
392
+ bone: [{ name: 'root' }],
393
+ slot: [{ name: 's', parent: 'root' }],
394
+ skin: [
395
+ { name: 'default', slot: [] },
396
+ { name: 'costume2', slot: [] },
397
+ ],
398
+ ik: [{ name: 'legIk', bone: 'root', target: 'root' }],
399
+ },
400
+ { bone: [{ name: 'other' }] },
401
+ ],
402
+ };
403
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
404
+ parseDragonBonesSkeleton(JSON.stringify(doc), sink),
405
+ );
406
+ const kinds = crumbs.map((c) => c.kind);
407
+ expect(kinds).toContain('dragonbones.multi-armature-unsupported');
408
+ expect(kinds).toContain('dragonbones.ik-constraint-unsupported');
409
+ // The alternate skin is a wardrobe entry now, not a Skip crumb.
410
+ expect(kinds).not.toContain('dragonbones.alternate-skin-unsupported');
411
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.skins!.map((s) => s.name)).toEqual([
412
+ 'default',
413
+ 'costume2',
414
+ ]);
415
+ // Only the first armature is parsed.
416
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0].name).toBe('root');
417
+ });
418
+
419
+ it('parses every armature skin into the wardrobe, keyed by display name', () => {
420
+ const doc = {
421
+ armature: [
422
+ {
423
+ bone: [{ name: 'root' }],
424
+ slot: [
425
+ { name: 'head', parent: 'root', displayIndex: 0 },
426
+ { name: 'hand', parent: 'root' },
427
+ ],
428
+ skin: [
429
+ { name: 'default', slot: [{ name: 'head', display: [{ name: 'face' }] }] },
430
+ {
431
+ name: 'costume2',
432
+ slot: [
433
+ { name: 'head', display: [{ name: 'face-alt' }] },
434
+ { name: 'hand', display: [{ name: 'axe' }] },
435
+ ],
436
+ },
437
+ ],
438
+ },
439
+ ],
440
+ };
441
+ const skeleton = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton;
442
+ expect(skeleton.skins!.map((s) => s.name)).toEqual(['default', 'costume2']);
443
+ const alt = skeleton.skins![1];
444
+ expect(alt.attachments.map((a) => [a.slotIndex, a.name])).toEqual([
445
+ [0, 'face-alt'],
446
+ [1, 'axe'],
447
+ ]);
448
+ // The setup pose still resolves through displayIndex against the DEFAULT skin's positional list.
449
+ expect((skeleton.slots![0].attachment as RegionAttachment2D).name).toBe('face');
450
+ });
451
+
452
+ it('wears an alternate DragonBones skin over the setup pose', () => {
453
+ const doc = {
454
+ armature: [
455
+ {
456
+ bone: [{ name: 'root' }],
457
+ slot: [{ name: 'head', parent: 'root', displayIndex: 0 }],
458
+ skin: [
459
+ { name: 'default', slot: [{ name: 'head', display: [{ name: 'face' }] }] },
460
+ { name: 'costume2', slot: [{ name: 'head', display: [{ name: 'face-alt' }] }] },
461
+ ],
462
+ },
463
+ ],
464
+ };
465
+ const skeleton = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton;
466
+ setSkeleton2DSkin(skeleton, getSkeleton2DSkin(skeleton, 'costume2')!);
467
+ expect((skeleton.slots![0].attachment as RegionAttachment2D).name).toBe('face-alt');
468
+ });
469
+
470
+ it('animates a slot display swap as a STEP channel indexing the slot display list', () => {
471
+ const doc = {
472
+ frameRate: 10,
473
+ armature: [
474
+ {
475
+ bone: [{ name: 'root' }],
476
+ slot: [{ name: 'head', parent: 'root', displayIndex: 0 }],
477
+ skin: [{ name: 'default', slot: [{ name: 'head', display: [{ name: 'face' }, { name: 'face-alt' }] }] }],
478
+ animation: [
479
+ {
480
+ name: 'blink',
481
+ slot: [
482
+ {
483
+ name: 'head',
484
+ displayFrame: [
485
+ { duration: 5, value: 0 },
486
+ { duration: 5, value: 1 },
487
+ { duration: 0, value: -1 },
488
+ ],
489
+ },
490
+ ],
491
+ },
492
+ ],
493
+ },
494
+ ],
495
+ };
496
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
497
+ const pose = cloneSkeleton2D(result.skeleton);
498
+ const clip = result.animations[0].clip;
499
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 0);
500
+ expect((pose.slots![0].attachment as RegionAttachment2D).name).toBe('face');
501
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 0.5); // 5 frames at 10fps
502
+ expect((pose.slots![0].attachment as RegionAttachment2D).name).toBe('face-alt');
503
+ // A negative display index is DragonBones' "show nothing".
504
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 1);
505
+ expect(pose.slots![0].attachment).toBeNull();
506
+ });
507
+
508
+ it('animates a slot colour, normalizing the 0-100 percent channels rather than bytes', () => {
509
+ const doc = {
510
+ frameRate: 10,
511
+ armature: [
512
+ {
513
+ bone: [{ name: 'root' }],
514
+ slot: [{ name: 'head', parent: 'root' }],
515
+ animation: [
516
+ {
517
+ name: 'fade',
518
+ slot: [
519
+ {
520
+ name: 'head',
521
+ colorFrame: [
522
+ { duration: 10, value: { aM: 100, rM: 100, gM: 100, bM: 100 } },
523
+ { duration: 0, value: { aM: 0, rM: 100, gM: 0, bM: 0 } },
524
+ ],
525
+ },
526
+ ],
527
+ },
528
+ ],
529
+ },
530
+ ],
531
+ };
532
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
533
+ const pose = cloneSkeleton2D(result.skeleton);
534
+ const clip = result.animations[0].clip;
535
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 0);
536
+ expect(pose.slots![0].color).toBe(0xffffffff);
537
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 1);
538
+ expect(pose.slots![0].color).toBe(0xff000000); // red kept, everything else faded out
539
+ });
540
+
541
+ it('accepts the older display/color frame spellings', () => {
542
+ const doc = {
543
+ frameRate: 10,
544
+ armature: [
545
+ {
546
+ bone: [{ name: 'root' }],
547
+ slot: [{ name: 'head', parent: 'root' }],
548
+ skin: [{ name: 'default', slot: [{ name: 'head', display: [{ name: 'a' }, { name: 'b' }] }] }],
549
+ animation: [
550
+ {
551
+ name: 'old',
552
+ slot: [{ name: 'head', display: [{ duration: 5, displayIndex: 1 }] }],
553
+ },
554
+ ],
555
+ },
556
+ ],
557
+ };
558
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
559
+ const pose = cloneSkeleton2D(result.skeleton);
560
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0);
561
+ expect((pose.slots![0].attachment as RegionAttachment2D).name).toBe('b');
562
+ });
563
+
564
+ it('Skip-crumbs a slot timeline naming a slot the armature does not have', () => {
565
+ const doc = {
566
+ armature: [
567
+ {
568
+ bone: [{ name: 'root' }],
569
+ slot: [{ name: 'head', parent: 'root' }],
570
+ animation: [{ name: 'a', slot: [{ name: 'ghost', displayFrame: [{ duration: 1, value: 0 }] }] }],
571
+ },
572
+ ],
573
+ };
574
+ const kinds = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).map(
575
+ (c) => c.kind,
576
+ );
577
+ expect(kinds).toContain('dragonbones.slot-timeline-unsupported');
578
+ });
579
+
580
+ it('Skip-crumbs a 5.6 BLEND TREE animation instead of emitting a silently empty clip', () => {
581
+ const doc = {
582
+ armature: [
583
+ {
584
+ bone: [{ name: 'root' }],
585
+ animation: [
586
+ { name: 'keyframed', bone: [{ name: 'root', rotateFrame: [{ duration: 1, rotate: 10 }] }] },
587
+ { name: 'blended', type: 'tree', duration: 0, timeline: [{ x: 0 }] },
588
+ ],
589
+ },
590
+ ],
591
+ };
592
+ const crumbs = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).filter(
593
+ (c) => c.kind === 'dragonbones.blend-tree-animation-unsupported',
594
+ );
595
+ expect(crumbs.length).toBe(1);
596
+ expect(crumbs[0].detail).toMatchObject({ animations: 1 });
597
+ // The name is still emitted so the rig's animation list stays complete; only the emptiness is reported.
598
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
599
+ expect(result.animations.map((a) => a.name)).toEqual(['keyframed', 'blended']);
600
+ expect(result.animations[1].clip.channels.length).toBe(0);
601
+ expect(result.animations[0].clip.channels.length).toBeGreaterThan(0);
602
+ });
603
+
604
+ it('HONORS a bezier curve frame, whose control points are already normalized', () => {
605
+ // DragonBones writes 4 values in the unit square — unlike Spine's absolute per-component form — so the
606
+ // curve maps straight onto easeCubicBezier with no rebasing.
607
+ const doc = {
608
+ frameRate: 10,
609
+ armature: [
610
+ {
611
+ bone: [{ name: 'b' }],
612
+ animation: [
613
+ {
614
+ name: 'a',
615
+ bone: [
616
+ {
617
+ name: 'b',
618
+ rotateFrame: [
619
+ { duration: 10, rotate: 0, curve: [0.42, 0, 1, 1] },
620
+ { duration: 0, rotate: 90 },
621
+ ],
622
+ },
623
+ ],
624
+ },
625
+ ],
626
+ },
627
+ ],
628
+ };
629
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
630
+ const pose = cloneSkeleton2D(result.skeleton);
631
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
632
+ expect(pose.bones[0].rotation).toBeCloseTo(90 * easeCubicBezier(0.42, 0, 1, 1)(0.5), 4);
633
+ expect(pose.bones[0].rotation).toBeLessThan(40); // materially different from the linear 45
634
+ });
635
+
636
+ it('no longer Skip-crumbs a curve, but still crumbs a QUADRATIC tweenEasing', () => {
637
+ const curved = {
638
+ frameRate: 10,
639
+ armature: [
640
+ {
641
+ bone: [{ name: 'b' }],
642
+ animation: [
643
+ {
644
+ name: 'a',
645
+ bone: [{ name: 'b', rotateFrame: [{ duration: 10, rotate: 0, curve: [0.42, 0, 1, 1] }, { rotate: 90 }] }],
646
+ },
647
+ ],
648
+ },
649
+ ],
650
+ };
651
+ expect(
652
+ collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(curved), sink)).map((c) => c.kind),
653
+ ).not.toContain('dragonbones.tween-easing-unsupported');
654
+
655
+ // The quadratic variants have no corpus coverage, so they stay reported rather than guessed.
656
+ const quad = {
657
+ frameRate: 10,
658
+ armature: [
659
+ {
660
+ bone: [{ name: 'b' }],
661
+ animation: [
662
+ {
663
+ name: 'a',
664
+ bone: [{ name: 'b', rotateFrame: [{ duration: 10, rotate: 0, tweenEasing: 0.5 }, { rotate: 90 }] }],
665
+ },
666
+ ],
667
+ },
668
+ ],
669
+ };
670
+ expect(
671
+ collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(quad), sink)).map((c) => c.kind),
672
+ ).toContain('dragonbones.tween-easing-unsupported');
673
+ });
674
+
675
+ it('leaves an uncurved DragonBones timeline with no segment easings', () => {
676
+ const doc = {
677
+ frameRate: 10,
678
+ armature: [
679
+ {
680
+ bone: [{ name: 'b' }],
681
+ animation: [{ name: 'a', bone: [{ name: 'b', rotateFrame: [{ duration: 10, rotate: 0 }, { rotate: 90 }] }] }],
682
+ },
683
+ ],
684
+ };
685
+ const track = parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.channels[0].track;
686
+ expect(track.segmentEasings).toBeNull();
687
+ });
688
+
689
+ it('emits a bone with an unresolved parent as a root and Skip-crumbs it', () => {
690
+ const doc = { armature: [{ bone: [{ name: 'orphan', parent: 'ghost' }] }] };
691
+ const kinds = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).map(
692
+ (c) => c.kind,
693
+ );
694
+ expect(kinds).toContain('dragonbones.unresolved-bone-parent');
695
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0].parentIndex).toBe(-1);
696
+ });
697
+
698
+ it('builds a named clip of RELATIVE deltas on a frameRate-converted time axis', () => {
699
+ const doc = {
700
+ frameRate: 20,
701
+ armature: [
702
+ {
703
+ bone: [{ name: 'root', transform: { x: 5, skX: 10, skY: 10, scX: 2 } }],
704
+ animation: [
705
+ {
706
+ name: 'walk',
707
+ duration: 10,
708
+ bone: [
709
+ {
710
+ name: 'root',
711
+ translateFrame: [
712
+ { duration: 10, x: 0, y: 0 },
713
+ { duration: 0, x: 20, y: 4 },
714
+ ],
715
+ rotateFrame: [
716
+ { duration: 10, rotate: 0 },
717
+ { duration: 0, rotate: 90 },
718
+ ],
719
+ scaleFrame: [
720
+ { duration: 10, x: 1, y: 1 },
721
+ { duration: 0, x: 3, y: 1 },
722
+ ],
723
+ },
724
+ ],
725
+ },
726
+ ],
727
+ },
728
+ ],
729
+ };
730
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
731
+ expect(result.animations.length).toBe(1);
732
+ expect(result.animations[0].name).toBe('walk');
733
+ // 10 frames at 20fps = 0.5s, so the second keyframe lands at t=0.5 on every timeline.
734
+ expect(
735
+ findChannel(result.animations[0].clip.channels, 0, Skeleton2DAnimationPath.Translation)!.track.times,
736
+ ).toEqual([0, 0.5]);
737
+ // Compose onto a pose clone at the end key: setup x 5 + 20, rotation 10 + 90, scaleX 2 × 3 (multiplier).
738
+ const setup = result.skeleton;
739
+ const pose = cloneSkeleton2D(setup);
740
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 0.5);
741
+ expect(pose.bones[0].x).toBeCloseTo(25, 5);
742
+ expect(pose.bones[0].y).toBeCloseTo(4, 5);
743
+ expect(pose.bones[0].rotation).toBeCloseTo(100, 5);
744
+ expect(pose.bones[0].scaleX).toBeCloseTo(6, 5);
745
+ expect(setup.bones[0].x).toBe(5); // the parsed setup pose is left intact
746
+ });
747
+
748
+ it('FORMULA: a keyframe time is the running frame-duration sum divided by the frame rate', () => {
749
+ // Durations 4, 6, 5 at 24fps → cumulative 0, 4, 10 frames → 0, 1/6, 5/12 seconds. An omitted `duration`
750
+ // is DragonBones' 1-frame default, and a negative one is clamped so the axis stays ascending.
751
+ const doc = {
752
+ frameRate: 24,
753
+ armature: [
754
+ {
755
+ bone: [{ name: 'b' }],
756
+ animation: [
757
+ {
758
+ name: 'a',
759
+ bone: [
760
+ {
761
+ name: 'b',
762
+ translateFrame: [
763
+ { duration: 4, x: 0 },
764
+ { duration: 6, x: 1 },
765
+ { duration: 5, x: 2 },
766
+ { x: 3 },
767
+ { duration: -8, x: 4 },
768
+ ],
769
+ },
770
+ ],
771
+ },
772
+ ],
773
+ },
774
+ ],
775
+ };
776
+ const times = parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.channels[0].track.times;
777
+ expect(Array.from(times)).toEqual([0, 4 / 24, 10 / 24, 15 / 24, 16 / 24]);
778
+ });
779
+
780
+ it('takes the armature frame rate over the document, and 24fps when neither is usable', () => {
781
+ const withArmatureRate = {
782
+ frameRate: 20,
783
+ armature: [{ frameRate: 10, bone: [{ name: 'b' }], animation: [{ bone: [translateTo('b', 12)] }] }],
784
+ };
785
+ expect(firstKeyEndTime(withArmatureRate)).toBeCloseTo(12 / 10, 5);
786
+ const withDocumentRate = {
787
+ frameRate: 20,
788
+ armature: [{ bone: [{ name: 'b' }], animation: [{ bone: [translateTo('b', 12)] }] }],
789
+ };
790
+ expect(firstKeyEndTime(withDocumentRate)).toBeCloseTo(12 / 20, 5);
791
+ // A zero rate would divide every time to Infinity, so it falls back rather than poisoning the axis.
792
+ const withZeroRate = {
793
+ frameRate: 0,
794
+ armature: [{ bone: [{ name: 'b' }], animation: [{ bone: [translateTo('b', 12)] }] }],
795
+ };
796
+ expect(firstKeyEndTime(withZeroRate)).toBeCloseTo(12 / 24, 5);
797
+ });
798
+
799
+ it('UNWRAPS a rotate sequence so a wrapped angle pair tweens the authored short step', () => {
800
+ // 170° followed by an authored −170° is a +20° step, not the 340° long way round through zero.
801
+ const doc = rotateDoc([
802
+ { duration: 10, rotate: 170 },
803
+ { duration: 10, rotate: -170 },
804
+ ]);
805
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
806
+ const channel = findChannel(result.animations[0].clip.channels, 0, Skeleton2DAnimationPath.Rotation)!;
807
+ expect(Array.from(channel.track.values)).toEqual([170, 190]);
808
+ const pose = cloneSkeleton2D(result.skeleton);
809
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.25);
810
+ expect(pose.bones[0].rotation).toBeCloseTo(180, 5); // halfway along the short step, not through 0
811
+ });
812
+
813
+ it('adds a whole turn per unconsumed `clockwise` when the authored angle wrapped past the previous one', () => {
814
+ const doc = rotateDoc([
815
+ { duration: 10, rotate: 90, clockwise: 1 },
816
+ { duration: 10, rotate: 0 },
817
+ ]);
818
+ const channel = findChannel(
819
+ parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.channels,
820
+ 0,
821
+ Skeleton2DAnimationPath.Rotation,
822
+ )!;
823
+ expect(Array.from(channel.track.values)).toEqual([90, 360]);
824
+ });
825
+
826
+ it('maps rotateFrame `skew` to a Shear channel, and emits none when no frame skews', () => {
827
+ const skewed = parseDragonBonesSkeleton(
828
+ JSON.stringify(
829
+ rotateDoc([
830
+ { duration: 10, rotate: 0, skew: 0 },
831
+ { duration: 10, rotate: 0, skew: 30 },
832
+ ]),
833
+ ),
834
+ )!;
835
+ const shear = findChannel(skewed.animations[0].clip.channels, 0, Skeleton2DAnimationPath.Shear)!;
836
+ // DragonBones' skew is Flight's shearY; shearX stays 0, the same split the setup transform uses.
837
+ expect(Array.from(shear.track.values)).toEqual([0, 0, 0, 30]);
838
+ const pose = cloneSkeleton2D(skewed.skeleton);
839
+ applyAnimationClipToSkeleton2D(skewed.animations[0].clip, skewed.skeleton, pose, 0.5);
840
+ expect(pose.bones[0].shearY).toBeCloseTo(30, 5);
841
+ expect(pose.bones[0].shearX).toBeCloseTo(0, 5);
842
+
843
+ const unskewed = parseDragonBonesSkeleton(JSON.stringify(rotateDoc([{ duration: 10, rotate: 45 }])))!;
844
+ expect(findChannel(unskewed.animations[0].clip.channels, 0, Skeleton2DAnimationPath.Shear)).toBeUndefined();
845
+ });
846
+
847
+ it('uses Step interpolation when every tweening frame declares no tween', () => {
848
+ const stepped = parseDragonBonesSkeleton(
849
+ JSON.stringify(
850
+ rotateDoc([
851
+ { duration: 10, rotate: 0, tweenEasing: null },
852
+ { duration: 10, rotate: 90, tweenEasing: null },
853
+ ]),
854
+ ),
855
+ )!;
856
+ const pose = cloneSkeleton2D(stepped.skeleton);
857
+ applyAnimationClipToSkeleton2D(stepped.animations[0].clip, stepped.skeleton, pose, 0.4);
858
+ expect(pose.bones[0].rotation).toBeCloseTo(0, 5); // holds the first key until the second
859
+
860
+ // An ABSENT tweenEasing means linear, not stepped — so the same times tween.
861
+ const tweened = parseDragonBonesSkeleton(
862
+ JSON.stringify(
863
+ rotateDoc([
864
+ { duration: 10, rotate: 0 },
865
+ { duration: 10, rotate: 90 },
866
+ ]),
867
+ ),
868
+ )!;
869
+ const tweenedPose = cloneSkeleton2D(tweened.skeleton);
870
+ applyAnimationClipToSkeleton2D(tweened.animations[0].clip, tweened.skeleton, tweenedPose, 0.25);
871
+ expect(tweenedPose.bones[0].rotation).toBeCloseTo(45, 5);
872
+ });
873
+
874
+ it("ignores the last frame's easing, which opens no segment", () => {
875
+ // Only the trailing frame tweens; DragonBones gives a final frame no tween either, so the track is Step.
876
+ const doc = rotateDoc([
877
+ { duration: 10, rotate: 0, tweenEasing: null },
878
+ { duration: 10, rotate: 90, tweenEasing: 0 },
879
+ ]);
880
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
881
+ const pose = cloneSkeleton2D(result.skeleton);
882
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.4);
883
+ expect(pose.bones[0].rotation).toBeCloseTo(0, 5);
884
+ });
885
+
886
+ it('applies the scale-frame identity default of 1 to an omitted component', () => {
887
+ const doc = {
888
+ frameRate: 24,
889
+ armature: [
890
+ {
891
+ bone: [{ name: 'b', transform: { scX: 2, scY: 3 } }],
892
+ animation: [{ bone: [{ name: 'b', scaleFrame: [{ duration: 10 }] }] }],
893
+ },
894
+ ],
895
+ };
896
+ const result = parseDragonBonesSkeleton(JSON.stringify(doc))!;
897
+ const pose = cloneSkeleton2D(result.skeleton);
898
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0);
899
+ expect(pose.bones[0].scaleX).toBeCloseTo(2, 5); // 2 × 1, unchanged from setup
900
+ expect(pose.bones[0].scaleY).toBeCloseTo(3, 5);
901
+ });
902
+
903
+ it('takes the clip duration from the declared frame count, which can outlast the last keyframe', () => {
904
+ const doc = {
905
+ frameRate: 20,
906
+ armature: [
907
+ {
908
+ bone: [{ name: 'b' }],
909
+ animation: [{ name: 'hold', duration: 30, bone: [{ name: 'b', translateFrame: [{ duration: 10, x: 1 }] }] }],
910
+ },
911
+ ],
912
+ };
913
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.duration).toBeCloseTo(1.5, 5);
914
+ });
915
+
916
+ it('holds a malformed keyframe in place so the later keyframes keep their times', () => {
917
+ const doc = {
918
+ frameRate: 20,
919
+ armature: [
920
+ {
921
+ bone: [{ name: 'b' }],
922
+ animation: [
923
+ {
924
+ bone: [{ name: 'b', translateFrame: [{ duration: 10, x: 0 }, null, { duration: 0, x: 30 }] }],
925
+ },
926
+ ],
927
+ },
928
+ ],
929
+ };
930
+ const kinds = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).map(
931
+ (c) => c.kind,
932
+ );
933
+ expect(kinds).toContain('dragonbones.malformed-frame-recovered');
934
+ // The placeholder keeps its default 1-frame duration: 0, 10/20, 11/20 — the last key is not pulled earlier.
935
+ const times = parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.channels[0].track.times;
936
+ expect(Array.from(times)).toEqual([0, 0.5, 0.55]);
937
+ });
938
+
939
+ it('drops a timeline naming an absent bone and Recover-crumbs it once', () => {
940
+ const doc = {
941
+ armature: [
942
+ {
943
+ bone: [{ name: 'b' }],
944
+ animation: [
945
+ { name: 'a', bone: [translateTo('ghost', 10), translateTo('alsoGhost', 10), translateTo('b', 10)] },
946
+ ],
947
+ },
948
+ ],
949
+ };
950
+ const crumbs = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).filter(
951
+ (c) => c.kind === 'dragonbones.animation-bone-unresolved',
952
+ );
953
+ expect(crumbs.length).toBe(1);
954
+ expect(crumbs[0].detail).toMatchObject({ bones: 2 });
955
+ // Only the resolvable bone produced a channel.
956
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.channels.length).toBe(1);
957
+ });
958
+
959
+ it('Skip-crumbs the unmodeled animation timelines and non-linear frame easing', () => {
960
+ const doc = {
961
+ armature: [
962
+ {
963
+ bone: [{ name: 'b' }],
964
+ animation: [
965
+ {
966
+ name: 'a',
967
+ bone: [
968
+ {
969
+ name: 'b',
970
+ frame: [{ duration: 1, transform: { x: 1 } }],
971
+ translateFrame: [
972
+ { duration: 5, x: 0, tweenEasing: 0.5 },
973
+ { duration: 5, x: 1, curve: [0.1, 0.2, 0.3, 0.4] },
974
+ { duration: 0, x: 2 },
975
+ ],
976
+ },
977
+ ],
978
+ slot: [{ name: 's' }],
979
+ ffd: [{ name: 'd' }],
980
+ ik: [{ name: 'k' }],
981
+ zOrder: { frame: [{ duration: 1 }] },
982
+ },
983
+ ],
984
+ },
985
+ ],
986
+ };
987
+ const kinds = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).map(
988
+ (c) => c.kind,
989
+ );
990
+ expect(kinds).toContain('dragonbones.slot-timeline-unsupported');
991
+ expect(kinds).toContain('dragonbones.deform-timeline-unsupported');
992
+ expect(kinds).toContain('dragonbones.ik-timeline-unsupported');
993
+ expect(kinds).toContain('dragonbones.zorder-timeline-unsupported');
994
+ expect(kinds).toContain('dragonbones.legacy-bone-frame-unsupported');
995
+ expect(kinds).toContain('dragonbones.tween-easing-unsupported');
996
+ });
997
+
998
+ it('returns null for malformed JSON and for a non-DragonBones document (no armature)', () => {
999
+ expect(parseDragonBonesSkeleton('{ not json')).toBeNull();
1000
+ expect(parseDragonBonesSkeleton('42')).toBeNull();
1001
+ expect(parseDragonBonesSkeleton(JSON.stringify({ bones: [] }))).toBeNull(); // a Spine-shaped doc
1002
+ expect(parseDragonBonesSkeleton(JSON.stringify({ armature: [] }))).toBeNull(); // empty armature list
1003
+ });
1004
+ });
1005
+
1006
+ // The channel a (bone, path) pair drives, or undefined when the parse emitted none for it.
1007
+ function findChannel(
1008
+ channels: readonly AnimationChannel[],
1009
+ boneIndex: number,
1010
+ path: Skeleton2DAnimationPath,
1011
+ ): AnimationChannel | undefined {
1012
+ return channels.find((channel) => {
1013
+ const target = channel.targetRef as Skeleton2DAnimationTarget;
1014
+ return target.boneIndex === boneIndex && target.path === path;
1015
+ });
1016
+ }
1017
+
1018
+ // The time of the SECOND keyframe of the document's first channel — the frame-rate conversion under test
1019
+ // (the first keyframe is always 0, so it proves nothing about the rate).
1020
+ function firstKeyEndTime(doc: unknown): number {
1021
+ return parseDragonBonesSkeleton(JSON.stringify(doc))!.animations[0].clip.channels[0].track.times[1];
1022
+ }
1023
+
1024
+ // A one-bone 20fps document whose single animation carries `frames` as bone `b`'s rotateFrame list.
1025
+ function rotateDoc(frames: readonly Record<string, unknown>[]): Record<string, unknown> {
1026
+ return {
1027
+ frameRate: 20,
1028
+ armature: [{ bone: [{ name: 'b' }], animation: [{ name: 'a', bone: [{ name: 'b', rotateFrame: frames }] }] }],
1029
+ };
1030
+ }
1031
+
1032
+ // A two-keyframe translate timeline for `boneName` whose first frame lasts `duration` FRAMES, so the second
1033
+ // keyframe's time is exactly `duration / frameRate`.
1034
+ function translateTo(boneName: string, duration: number): Record<string, unknown> {
1035
+ return {
1036
+ name: boneName,
1037
+ translateFrame: [
1038
+ { duration, x: 0 },
1039
+ { duration: 0, x: 1 },
1040
+ ],
1041
+ };
1042
+ }