Cocos 转 Spine

Cocos 转 Spine

Posted by Gumc on August 31, 2026

前提:近期将Lost Journey从Cocos2d-x迁移到Unity,最初的动画是由Cocos制作的,因为只有Spine在一直维护,所以计划将Cocos动画转化为spine动画,在Unity中使用。Dragonbone可以直接读取数据。但导出Spine后会有问题,要么节点位置不对,要么有奇怪的bug。

结论:最后总结出动画的转化步骤如下

1
2
3
4
5
6
1. 使用Dragonbone导入cocos的动画数据,并使用Dragonbone导出Dragonbone 5.5格式的动画,以hero为例,包括hero_ske.json, hero_tex.json, hero_tex.png
2. 使用DragonBoneToSpineData,将hero_ske.json, hero_tex.json, hero_tex.png整个文件夹拖入DragonBoneToSpineData转化为spine数据的armatureName.json(spine3.3支持的格式), hero_ske.atlas.txt,hero_tex.png
3. 使用spine 4.3的纹理解包器,导入hero_ske.atlas.txt可以解包出所有的图片资源
4. 使用spine3.3导入armatureName.json,设置图片的路径为前面解压的图片资源的文件夹。然后保存项目,然后用spine4.3重新打开项目,然后导出armatureName.json(即spine4.3版本的数据)
4. armatureName.json会存在问题:关键帧的alpha错误,duration错误,还有bone缩放错误。需要重建 slot 颜色轨道和 bone 缩放轨道,还有等等各种各样的问题。以下提供的Unity代码用于根据cocos动画重建armatureName.json后导出armatureName_output.json
5. 使用spine4.3导入armatureName_output.json,然后将图片文件夹设置为解包后的Image文件夹,将骨架的名字修改为旧的名字,然后导出即可。

附带4的Unity代码:

#pragma warning disable ET0004
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;

/// <summary>
/// 依据 Cocos Studio ExportJson 修复转换后的 Spine 4.3 动画数据。
/// </summary>
public static class CocosExportJsonToSpine43RepairEditor
{
    private const string MenuPath = "GameFramework/Project/Spine/5.(final) 修复 Cocos ExportJson 转 Spine4.3 动画数据";
    private const double CocosRuntimeFrameRate = 60d;
    private const double ValueTolerance = 0.000001d;
    private const double MatrixTolerance = 0.0001d;
    private const double EasingApproximationTolerance = 0.0001d;
    private const int MaxEasingSubdivisionDepth = 10;
    private const double RadiansToDegrees = 180d / Math.PI;
    private const double SineControlX1 = 0.36432160614986847d;
    private const double SineControlX2 = 0.6356762953556379d;

    [MenuItem(MenuPath)]
    private static void RepairAnimationData()
    {
        try
        {
            // 选择输入和独立输出路径。
            string sourcePath = EditorUtility.OpenFilePanel("选择 Cocos Studio ExportJson", Application.dataPath, "ExportJson");
            if (string.IsNullOrEmpty(sourcePath))
            {
                return;
            }

            string spinePath = EditorUtility.OpenFilePanel("选择未修复的 Spine 4.3 JSON", Path.GetDirectoryName(sourcePath), "json");
            if (string.IsNullOrEmpty(spinePath))
            {
                return;
            }

            string outputPath = Path.Combine(Path.GetDirectoryName(spinePath), Path.GetFileNameWithoutExtension(spinePath) + "_fixed.json");
            if (File.Exists(outputPath) && !EditorUtility.DisplayDialog("输出文件已存在", $"将覆盖已有文件:\n{outputPath}", "覆盖", "取消"))
            {
                return;
            }

            // 解析源数据并重建 setup pose、动画轨道和绘制顺序。
            JObject sourceRoot = JObject.Parse(File.ReadAllText(sourcePath));
            JObject spineRoot = JObject.Parse(File.ReadAllText(spinePath));
            JObject originalSpineRoot = spineRoot.DeepClone() as JObject;
            SourceData sourceData = ReadSourceData(sourceRoot);
            EnsureSpine43(spineRoot);
            SetupTransformData setupTransformData = BuildSetupTransformData(sourceData);
            RepairResult result = new RepairResult();
            RepairSetupPose(sourceData, setupTransformData, spineRoot, result);
            RepairAnimations(sourceData, setupTransformData, spineRoot, result);

            // 阻止修复误改 skins、附件和允许范围外的数据。
            EnsureOnlyRepairableDataChanged(originalSpineRoot, spineRoot, sourceData);
            ValidateRepairedSetupPose(sourceData, setupTransformData, spineRoot);
            ValidateRepairedTimelines(spineRoot);
            ValidateWithSpineRuntime(spineRoot);

            // 使用 UTF-8 无 BOM 和 LF 写入结果。
            string outputJson = NormalizeLineEndings(spineRoot.ToString(Formatting.Indented)) + "\n";
            File.WriteAllText(outputPath, outputJson, new UTF8Encoding(false));
            AssetDatabase.Refresh();

            Debug.Log(
                $"[CocosExportJsonToSpine43RepairEditor] Spine 4.3 数据修复完成。\n"
                    + $"Setup 骨骼: 修复 {result.RepairedSetupBoneCount}\n"
                    + $"Setup 插槽: 修复 {result.RepairedSetupSlotCount}\n"
                    + $"位移轨道: 重建 {result.RebuiltTranslateTimelineCount},删除误造 {result.RemovedTranslateTimelineCount}\n"
                    + $"旋转轨道: 重建 {result.RebuiltRotateTimelineCount},删除误造 {result.RemovedRotateTimelineCount}\n"
                    + $"缩放轨道: 重建 {result.RebuiltScaleTimelineCount},删除误造 {result.RemovedScaleTimelineCount}\n"
                    + $"剪切轨道: 重建 {result.RebuiltShearTimelineCount},删除误造 {result.RemovedShearTimelineCount}\n"
                    + $"附件轨道: 重建 {result.RebuiltAttachmentTimelineCount},删除误造 {result.RemovedAttachmentTimelineCount}\n"
                    + $"颜色轨道: 重建 {result.RebuiltColorTimelineCount},删除误造 {result.RemovedColorTimelineCount}\n"
                    + $"绘制顺序轨道: 重建 {result.RebuiltDrawOrderTimelineCount},删除误造 {result.RemovedDrawOrderTimelineCount}\n"
                    + $"输出文件: {outputPath}"
            );
            EditorUtility.RevealInFinder(outputPath);
        }
        catch (Exception exception)
        {
            Debug.LogError($"[CocosExportJsonToSpine43RepairEditor] 修复失败: {exception.Message}\n{exception.StackTrace}");
            EditorUtility.DisplayDialog("Spine 4.3 数据修复失败", exception.Message, "确定");
        }
    }

    /// <summary>
    /// 读取唯一 Cocos armature、同名 animation_data、setup 骨骼和 movement 索引。
    /// </summary>
    private static SourceData ReadSourceData(JObject sourceRoot)
    {
        JArray armatures = sourceRoot["armature_data"] as JArray;
        if (armatures == null || armatures.Count != 1)
        {
            throw new InvalidDataException($"期望 ExportJson 包含 1 个 armature_data,实际为 {armatures?.Count ?? 0} 个。");
        }

        JObject armature = armatures[0] as JObject;
        string armatureName = armature?.Value<string>("name");
        JArray sourceBones = armature?["bone_data"] as JArray;
        if (string.IsNullOrEmpty(armatureName) || sourceBones == null)
        {
            throw new InvalidDataException("ExportJson armature_data[0] 缺少 name 或 bone_data。");
        }

        // 建立 setup 骨骼及显示附件索引。
        Dictionary<string, JObject> sourceBoneMap = BuildNamedObjectMap(sourceBones, "ExportJson setup 骨骼");
        Dictionary<string, List<string>> displayNameMap = new Dictionary<string, List<string>>(StringComparer.Ordinal);
        foreach (KeyValuePair<string, JObject> bonePair in sourceBoneMap)
        {
            JArray displays = bonePair.Value["display_data"] as JArray;
            List<string> displayNames = new List<string>();
            if (displays != null)
            {
                for (int displayIndex = 0; displayIndex < displays.Count; displayIndex++)
                {
                    JObject display = displays[displayIndex] as JObject;
                    string displayName = StripExtension(display?.Value<string>("name"));
                    if (string.IsNullOrEmpty(displayName))
                    {
                        throw new InvalidDataException($"ExportJson 骨骼 {bonePair.Key} 第 {displayIndex} 个 display_data 名称无效。");
                    }

                    displayNames.Add(displayName);
                }
            }

            displayNameMap.Add(bonePair.Key, displayNames);
        }

        // 选择与 armature 同名的 animation_data。
        JArray animationDataList = sourceRoot["animation_data"] as JArray;
        JObject selectedAnimationData = null;
        if (animationDataList != null)
        {
            for (int animationDataIndex = 0; animationDataIndex < animationDataList.Count; animationDataIndex++)
            {
                JObject animationData = animationDataList[animationDataIndex] as JObject;
                if (!string.Equals(animationData?.Value<string>("name"), armatureName, StringComparison.Ordinal))
                {
                    continue;
                }

                if (selectedAnimationData != null)
                {
                    throw new InvalidDataException($"ExportJson 存在重复 animation_data: {armatureName}。");
                }

                selectedAnimationData = animationData;
            }
        }

        JArray movements = selectedAnimationData?["mov_data"] as JArray;
        if (movements == null)
        {
            throw new InvalidDataException($"ExportJson 缺少与 armature {armatureName} 同名的 animation_data.mov_data。");
        }

        Dictionary<string, JObject> movementMap = BuildNamedObjectMap(movements, "ExportJson movement");
        List<string> sourceBoneOrder = CollectNamedOrder(sourceBones, "ExportJson setup 骨骼");
        return new SourceData(sourceBoneMap, sourceBoneOrder, displayNameMap, movementMap);
    }

    /// <summary>
    /// 限定目标为当前工具支持的 Spine 4.3 JSON。
    /// </summary>
    private static void EnsureSpine43(JObject spineRoot)
    {
        string version = spineRoot["skeleton"]?.Value<string>("spine");
        if (string.IsNullOrEmpty(version) || !version.StartsWith("4.3", StringComparison.Ordinal))
        {
            throw new InvalidDataException($"当前工具只支持 Spine 4.3 JSON,输入版本为 {version ?? "缺失"}。");
        }
    }

    /// <summary>
    /// 按 Cocos 双轴角完整重建 Spine setup pose,保留旋转中编码的反射。
    /// </summary>
    /// <summary>
    /// 按 sceneext Bone::applyParentTransform 建立 setup 世界变换和 Spine 局部变换。
    /// </summary>
    private static SetupTransformData BuildSetupTransformData(SourceData sourceData)
    {
        Dictionary<string, CocosWorldTransform> worldTransformMap = new Dictionary<string, CocosWorldTransform>(StringComparer.Ordinal);
        Dictionary<string, SpineLocalTransform> spineLocalTransformMap = new Dictionary<string, SpineLocalTransform>(StringComparer.Ordinal);
        Dictionary<string, double> rotationDirectionMap = new Dictionary<string, double>(StringComparer.Ordinal);
        HashSet<string> visiting = new HashSet<string>(StringComparer.Ordinal);

        // 递归建立 Cocos setup 世界变换,允许源骨骼不是严格父级优先排列。
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            string boneName = sourceData.SourceBoneOrder[boneIndex];
            BuildCocosSetupWorldTransform(boneName, sourceData.SourceBoneMap, worldTransformMap, visiting);
        }

        // 将目标世界矩阵反解为 Spine Normal 继承需要的局部矩阵。
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            string boneName = sourceData.SourceBoneOrder[boneIndex];
            JObject sourceBone = sourceData.SourceBoneMap[boneName];
            string parentName = sourceBone.Value<string>("parent");
            CocosWorldTransform worldTransform = worldTransformMap[boneName];
            Matrix2D localMatrix = string.IsNullOrEmpty(parentName)
                ? worldTransform.Matrix
                : MultiplyInverse(worldTransformMap[parentName].Matrix, worldTransform.Matrix, boneName);
            spineLocalTransformMap.Add(boneName, DecomposeSpineLocalMatrix(localMatrix, boneName));

            double rotationDirection = 1d;
            if (!string.IsNullOrEmpty(parentName) && worldTransformMap[parentName].Matrix.Determinant < 0d)
            {
                rotationDirection = -1d;
            }

            rotationDirectionMap.Add(boneName, rotationDirection);
        }

        return new SetupTransformData(worldTransformMap, spineLocalTransformMap, rotationDirectionMap);
    }

    /// <summary>
    /// 递归计算单根 Cocos setup 骨骼的世界变换。
    /// </summary>
    private static CocosWorldTransform BuildCocosSetupWorldTransform(
        string boneName,
        Dictionary<string, JObject> sourceBoneMap,
        Dictionary<string, CocosWorldTransform> worldTransformMap,
        HashSet<string> visiting
    )
    {
        if (worldTransformMap.TryGetValue(boneName, out CocosWorldTransform existingTransform))
        {
            return existingTransform;
        }

        if (!visiting.Add(boneName))
        {
            throw new InvalidDataException($"ExportJson setup 骨骼层级存在循环: {boneName}。");
        }

        JObject sourceBone = sourceBoneMap[boneName];
        string context = $"ExportJson setup 骨骼 {boneName}";
        double x = ReadRequiredDouble(sourceBone, "x", context);
        double y = ReadRequiredDouble(sourceBone, "y", context);
        double scaleX = ReadRequiredDouble(sourceBone, "cX", context);
        double scaleY = ReadRequiredDouble(sourceBone, "cY", context);
        double skewX = ReadRequiredDouble(sourceBone, "kX", context);
        double skewY = ReadRequiredDouble(sourceBone, "kY", context);
        string parentName = sourceBone.Value<string>("parent");

        // 精确复现 Bone::applyParentTransform 的位置矩阵和分量式继承。
        if (!string.IsNullOrEmpty(parentName))
        {
            if (!sourceBoneMap.ContainsKey(parentName))
            {
                throw new InvalidDataException($"ExportJson setup 骨骼 {boneName} 的父骨骼不存在: {parentName}。");
            }

            CocosWorldTransform parentTransform = BuildCocosSetupWorldTransform(parentName, sourceBoneMap, worldTransformMap, visiting);
            double localX = x;
            double localY = y;
            x = localX * parentTransform.Matrix.A + localY * parentTransform.Matrix.B + parentTransform.X;
            y = localX * parentTransform.Matrix.C + localY * parentTransform.Matrix.D + parentTransform.Y;
            scaleX *= parentTransform.ScaleX;
            scaleY *= parentTransform.ScaleY;
            skewX += parentTransform.SkewX;
            skewY += parentTransform.SkewY;
        }

        CocosWorldTransform result = new CocosWorldTransform(x, y, scaleX, scaleY, skewX, skewY, CreateCocosMatrix(scaleX, scaleY, skewX, skewY));
        worldTransformMap.Add(boneName, result);
        visiting.Remove(boneName);
        return result;
    }

    /// <summary>
    /// 按 TransformHelp::nodeToMatrix 创建 Spine 坐标布局的二维矩阵。
    /// </summary>
    private static Matrix2D CreateCocosMatrix(double scaleX, double scaleY, double skewX, double skewY)
    {
        return new Matrix2D(scaleX * Math.Cos(skewY), scaleY * Math.Sin(skewX), scaleX * Math.Sin(skewY), scaleY * Math.Cos(skewX));
    }

    /// <summary>
    /// 计算 inverse(parent) * world,得到 Spine Normal 继承下的局部矩阵。
    /// </summary>
    private static Matrix2D MultiplyInverse(Matrix2D parent, Matrix2D world, string boneName)
    {
        double determinant = parent.Determinant;
        if (Math.Abs(determinant) <= ValueTolerance)
        {
            throw new InvalidDataException($"ExportJson setup 骨骼 {boneName} 的父矩阵不可逆。");
        }

        return new Matrix2D(
            (parent.D * world.A - parent.B * world.C) / determinant,
            (parent.D * world.B - parent.B * world.D) / determinant,
            (-parent.C * world.A + parent.A * world.C) / determinant,
            (-parent.C * world.B + parent.A * world.D) / determinant
        );
    }

    /// <summary>
    /// 将任意可逆二维矩阵分解为 Spine rotation、signed scale 和 shearY。
    /// </summary>
    private static SpineLocalTransform DecomposeSpineLocalMatrix(Matrix2D matrix, string boneName)
    {
        double scaleX = Math.Sqrt(matrix.A * matrix.A + matrix.C * matrix.C);
        double scaleYLength = Math.Sqrt(matrix.B * matrix.B + matrix.D * matrix.D);
        if (scaleX <= ValueTolerance || scaleYLength <= ValueTolerance)
        {
            throw new InvalidDataException($"ExportJson setup 骨骼 {boneName} 含不可分解的零缩放矩阵。");
        }

        double rotation = Math.Atan2(matrix.C, matrix.A) * RadiansToDegrees;
        double scaleY = matrix.Determinant < 0d ? -scaleYLength : scaleYLength;
        double yAxisRotation = Math.Atan2(matrix.D / scaleY, matrix.B / scaleY) * RadiansToDegrees;
        double shearY = NormalizeDegrees(yAxisRotation - rotation - 90d);
        return new SpineLocalTransform(NormalizeDegrees(rotation), scaleX, scaleY, shearY);
    }

    /// <summary>
    /// 将角度归一到 (-180, 180]。
    /// </summary>
    private static double NormalizeDegrees(double value)
    {
        value %= 360d;
        if (value <= -180d)
        {
            value += 360d;
        }
        else if (value > 180d)
        {
            value -= 360d;
        }

        return value;
    }

    /// <summary>
    /// 写入按 Cocos 世界矩阵反解后的 Spine setup 局部变换。
    /// </summary>
    private static void RepairSetupPose(SourceData sourceData, SetupTransformData setupTransformData, JObject spineRoot, RepairResult result)
    {
        Dictionary<string, JObject> spineBoneMap = BuildNamedObjectMap(spineRoot["bones"] as JArray, "Spine setup 骨骼");

        // 位置保持 Cocos 局部坐标;轴矩阵使用层级反解结果。
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            string boneName = sourceData.SourceBoneOrder[boneIndex];
            JObject sourceBone = sourceData.SourceBoneMap[boneName];
            if (!spineBoneMap.TryGetValue(boneName, out JObject spineBone))
            {
                throw new InvalidDataException($"Spine setup pose 缺少 Cocos 同名骨骼: {boneName}。");
            }

            SpineLocalTransform transform = setupTransformData.SpineLocalTransformMap[boneName];
            WriteOptionalDouble(spineBone, "x", ReadRequiredDouble(sourceBone, "x", $"setup 骨骼 {boneName}"), 0d);
            WriteOptionalDouble(spineBone, "y", ReadRequiredDouble(sourceBone, "y", $"setup 骨骼 {boneName}"), 0d);
            WriteOptionalDouble(spineBone, "rotation", transform.Rotation, 0d);
            WriteOptionalDouble(spineBone, "scaleX", transform.ScaleX, 1d);
            WriteOptionalDouble(spineBone, "scaleY", transform.ScaleY, 1d);
            spineBone.Remove("shearX");
            WriteOptionalDouble(spineBone, "shearY", transform.ShearY, 0d);
            result.RepairedSetupBoneCount++;
        }

        // 按 setup dI 修复 Spine attachment;任意负索引均表示隐藏。
        Dictionary<string, JObject> spineSlotMap = BuildNamedObjectMap(spineRoot["slots"] as JArray, "Spine setup 插槽");
        foreach (KeyValuePair<string, List<string>> displayPair in sourceData.DisplayNameMap)
        {
            if (displayPair.Value.Count == 0)
            {
                continue;
            }

            if (!spineSlotMap.TryGetValue(displayPair.Key, out JObject spineSlot))
            {
                throw new InvalidDataException($"Spine setup pose 缺少 Cocos 同名插槽: {displayPair.Key}。");
            }

            int displayIndex = ReadNormalizedDisplayIndex(
                sourceData.SourceBoneMap[displayPair.Key],
                displayPair.Value.Count,
                $"setup 骨骼 {displayPair.Key}"
            );
            if (displayIndex < 0)
            {
                spineSlot.Remove("attachment");
            }
            else
            {
                spineSlot["attachment"] = displayPair.Value[displayIndex];
            }

            result.RepairedSetupSlotCount++;
        }
    }

    /// <summary>
    /// 逐骨骼比较 Cocos 与 Spine 局部仿射矩阵,验证旋转、反射、缩放和位移均未丢失。
    /// </summary>
    private static void ValidateRepairedSetupPose(SourceData sourceData, SetupTransformData setupTransformData, JObject spineRoot)
    {
        Dictionary<string, JObject> spineBoneMap = BuildNamedObjectMap(spineRoot["bones"] as JArray, "Spine setup 骨骼");
        Dictionary<string, Matrix2D> spineWorldMatrixMap = new Dictionary<string, Matrix2D>(StringComparer.Ordinal);
        Dictionary<string, Vector2D> spineWorldPositionMap = new Dictionary<string, Vector2D>(StringComparer.Ordinal);

        // 按 Spine Normal 继承重新计算世界矩阵和位置。
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            string boneName = sourceData.SourceBoneOrder[boneIndex];
            JObject sourceBone = sourceData.SourceBoneMap[boneName];
            JObject spineBone = spineBoneMap[boneName];
            string parentName = sourceBone.Value<string>("parent");
            double x = spineBone.Value<double?>("x") ?? 0d;
            double y = spineBone.Value<double?>("y") ?? 0d;
            double rotation = spineBone.Value<double?>("rotation") ?? 0d;
            double scaleX = spineBone.Value<double?>("scaleX") ?? 1d;
            double scaleY = spineBone.Value<double?>("scaleY") ?? 1d;
            double shearX = spineBone.Value<double?>("shearX") ?? 0d;
            double shearY = spineBone.Value<double?>("shearY") ?? 0d;
            double xAxis = (rotation + shearX) / RadiansToDegrees;
            double yAxis = (rotation + 90d + shearY) / RadiansToDegrees;
            Matrix2D localMatrix = new Matrix2D(Math.Cos(xAxis) * scaleX, Math.Cos(yAxis) * scaleY, Math.Sin(xAxis) * scaleX, Math.Sin(yAxis) * scaleY);

            Matrix2D worldMatrix = localMatrix;
            Vector2D worldPosition = new Vector2D(x, y);
            if (!string.IsNullOrEmpty(parentName))
            {
                Matrix2D parentMatrix = spineWorldMatrixMap[parentName];
                Vector2D parentPosition = spineWorldPositionMap[parentName];
                worldMatrix = Matrix2D.Multiply(parentMatrix, localMatrix);
                worldPosition = new Vector2D(
                    parentMatrix.A * x + parentMatrix.B * y + parentPosition.X,
                    parentMatrix.C * x + parentMatrix.D * y + parentPosition.Y
                );
            }

            spineWorldMatrixMap.Add(boneName, worldMatrix);
            spineWorldPositionMap.Add(boneName, worldPosition);

            CocosWorldTransform expected = setupTransformData.WorldTransformMap[boneName];
            if (
                Math.Abs(expected.X - worldPosition.X) > MatrixTolerance
                || Math.Abs(expected.Y - worldPosition.Y) > MatrixTolerance
                || Math.Abs(expected.Matrix.A - worldMatrix.A) > MatrixTolerance
                || Math.Abs(expected.Matrix.B - worldMatrix.B) > MatrixTolerance
                || Math.Abs(expected.Matrix.C - worldMatrix.C) > MatrixTolerance
                || Math.Abs(expected.Matrix.D - worldMatrix.D) > MatrixTolerance
            )
            {
                throw new InvalidDataException($"Spine setup 骨骼 {boneName} 未保持 Cocos sceneext 世界变换。");
            }
        }
    }

    /// <summary>
    /// 校验两侧 setup 和动画集合,并按每个 Cocos movement 重建动画数据。
    /// </summary>
    private static void RepairAnimations(SourceData sourceData, SetupTransformData setupTransformData, JObject spineRoot, RepairResult result)
    {
        JObject spineAnimations = spineRoot["animations"] as JObject;
        if (spineAnimations == null)
        {
            throw new InvalidDataException("Spine 文件缺少 animations。");
        }

        // 校验 setup 骨骼、slot 和 attachment 映射。
        Dictionary<string, JObject> spineBoneMap = BuildNamedObjectMap(spineRoot["bones"] as JArray, "Spine setup 骨骼");
        Dictionary<string, JObject> spineSlotMap = BuildNamedObjectMap(spineRoot["slots"] as JArray, "Spine setup 插槽");
        foreach (KeyValuePair<string, JObject> sourceBonePair in sourceData.SourceBoneMap)
        {
            if (!spineBoneMap.ContainsKey(sourceBonePair.Key))
            {
                throw new InvalidDataException($"Spine setup pose 缺少 Cocos 同名骨骼: {sourceBonePair.Key}。");
            }

            List<string> displayNames = sourceData.DisplayNameMap[sourceBonePair.Key];
            if (displayNames.Count == 0)
            {
                continue;
            }

            if (!spineSlotMap.TryGetValue(sourceBonePair.Key, out JObject spineSlot))
            {
                throw new InvalidDataException($"Spine setup pose 缺少 Cocos 同名插槽: {sourceBonePair.Key}。");
            }

            int sourceDisplayIndex = ReadNormalizedDisplayIndex(
                sourceBonePair.Value,
                displayNames.Count,
                $"setup 骨骼 {sourceBonePair.Key}"
            );
            string expectedAttachment = sourceDisplayIndex < 0 ? null : displayNames[sourceDisplayIndex];
            string setupAttachment = spineSlot.Value<string>("attachment");
            if (!string.Equals(setupAttachment, expectedAttachment, StringComparison.Ordinal))
            {
                throw new InvalidDataException(
                    $"Spine setup attachment 与 Cocos dI 不一致: {sourceBonePair.Key},"
                        + $"{setupAttachment ?? "隐藏"} / {expectedAttachment ?? "隐藏"}。"
                );
            }
        }

        // 要求 movement 与 Spine animation 集合完全一致。
        foreach (JProperty spineAnimationProperty in spineAnimations.Properties())
        {
            if (!sourceData.MovementMap.ContainsKey(spineAnimationProperty.Name))
            {
                throw new InvalidDataException($"ExportJson 缺少 Spine 同名 movement: {spineAnimationProperty.Name}。");
            }
        }

        if (sourceData.MovementMap.Count != spineAnimations.Count)
        {
            throw new InvalidDataException($"ExportJson movement 与 Spine animation 数量不一致: {sourceData.MovementMap.Count} / {spineAnimations.Count}。");
        }

        // 逐 movement 重建骨骼、附件、颜色和绘制顺序轨道。
        List<string> setupSlotOrder = CollectNamedOrder(spineRoot["slots"] as JArray, "Spine setup 插槽");
        foreach (KeyValuePair<string, JObject> movementPair in sourceData.MovementMap)
        {
            JObject spineAnimation = spineAnimations[movementPair.Key] as JObject;
            if (spineAnimation == null)
            {
                throw new InvalidDataException($"Spine 动画 {movementPair.Key} 不是有效对象。");
            }

            MovementData movementData = ReadMovementData(
                movementPair.Key,
                movementPair.Value,
                sourceData.SourceBoneMap,
                sourceData.SourceBoneOrder,
                setupSlotOrder,
                setupTransformData.RotationDirectionMap
            );
            RepairBoneTimelines(sourceData, setupTransformData, movementData, spineAnimation, result);
            RepairSlotTimelines(sourceData, movementData, spineAnimation, result);
            RepairDrawOrderTimeline(sourceData, movementData, setupSlotOrder, spineAnimation, result);
        }
    }

    /// <summary>
    /// 读取 movement 播放速度、时长和骨骼帧,并拒绝无法无损映射的运行时功能。
    /// </summary>
    private static MovementData ReadMovementData(
        string movementName,
        JObject movement,
        Dictionary<string, JObject> sourceBoneMap,
        List<string> sourceBoneOrder,
        List<string> setupSlotOrder,
        Dictionary<string, double> rotationDirectionMap
    )
    {
        int duration = ReadRequiredInt(movement, "dr", $"movement {movementName}");
        double movementScale = ReadRequiredDouble(movement, "sc", $"movement {movementName}");
        if (duration < 0 || !IsFinite(movementScale) || movementScale <= 0d)
        {
            throw new InvalidDataException($"ExportJson movement {movementName} 的 dr 或 sc 无效。");
        }

        EnsureZeroInt(movement, "to", $"movement {movementName}");
        EnsureZeroInt(movement, "drTW", $"movement {movementName}");
        EnsureZeroInt(movement, "twE", $"movement {movementName}");
        double secondsPerFrame = 1d / (CocosRuntimeFrameRate * movementScale);

        JArray movementBones = movement["mov_bone_data"] as JArray;
        if (movementBones == null)
        {
            throw new InvalidDataException($"ExportJson movement {movementName} 缺少 mov_bone_data。");
        }

        Dictionary<string, JObject> movementBoneMap = BuildNamedObjectMap(movementBones, $"ExportJson movement {movementName} 骨骼");
        List<string> movementBoneOrder = CollectNamedOrder(movementBones, $"ExportJson movement {movementName} 骨骼");

        // 校验骨骼引用和动态 z-order 语义。
        ValidateMovementZOrder(movementName, sourceBoneMap, movementBoneMap, setupSlotOrder);

        // 校验全部关键帧字段。
        foreach (KeyValuePair<string, JObject> movementBonePair in movementBoneMap)
        {
            if (!sourceBoneMap.ContainsKey(movementBonePair.Key))
            {
                throw new InvalidDataException($"ExportJson movement {movementName} 引用了 setup 中不存在的骨骼: {movementBonePair.Key}。");
            }

            double delay = movementBonePair.Value.Value<double?>("dl") ?? 0d;
            if (!IsFinite(delay) || Math.Abs(delay) > ValueTolerance)
            {
                throw new InvalidDataException($"ExportJson movement {movementName}.{movementBonePair.Key} 使用未支持的 delay={delay}。");
            }

            JArray frames = movementBonePair.Value["frame_data"] as JArray;
            if (frames == null || frames.Count == 0)
            {
                throw new InvalidDataException($"ExportJson movement {movementName}.{movementBonePair.Key} 缺少 frame_data。");
            }

            int previousFrameIndex = -1;
            for (int frameIndex = 0; frameIndex < frames.Count; frameIndex++)
            {
                JObject frame = RequireFrame(frames, movementName, movementBonePair.Key, frameIndex);
                int sourceFrameIndex = ReadRequiredInt(frame, "fi", $"movement {movementName}.{movementBonePair.Key}.frame_data[{frameIndex}]");
                if (sourceFrameIndex <= previousFrameIndex || sourceFrameIndex > duration)
                {
                    throw new InvalidDataException(
                        $"ExportJson {movementName}.{movementBonePair.Key}.frame_data[{frameIndex}] fi 无效: "
                            + $"{sourceFrameIndex},前一帧 {previousFrameIndex},movement dr={duration}。"
                    );
                }

                ValidateFrame(movementName, movementBonePair.Key, frameIndex, frame);
                previousFrameIndex = sourceFrameIndex;
            }
        }

        return new MovementData(
            movementName,
            duration,
            secondsPerFrame,
            sourceBoneMap,
            sourceBoneOrder,
            movementBoneMap,
            movementBoneOrder,
            rotationDirectionMap
        );
    }

    /// <summary>
    /// 按 Cocos setup z 与帧 z 验证 movement 全部关键时刻的稳定绘制顺序。
    /// </summary>
    private static void ValidateMovementZOrder(
        string movementName,
        Dictionary<string, JObject> sourceBoneMap,
        Dictionary<string, JObject> movementBoneMap,
        List<string> setupSlotOrder
    )
    {
        // Spine slot 必须存在同名 Cocos setup 骨骼,才能计算动态绘制顺序。
        for (int slotIndex = 0; slotIndex < setupSlotOrder.Count; slotIndex++)
        {
            string slotName = setupSlotOrder[slotIndex];
            if (!sourceBoneMap.ContainsKey(slotName))
            {
                throw new InvalidDataException($"ExportJson setup 骨骼缺少 Spine slot 同名骨骼: {slotName}。");
            }
        }

        // movement 骨骼必须来自同一 setup;具体 z 变化由 drawOrder 重建阶段处理。
        foreach (string boneName in movementBoneMap.Keys)
        {
            if (!sourceBoneMap.ContainsKey(boneName))
            {
                throw new InvalidDataException($"ExportJson movement {movementName} 引用了 setup 中不存在的骨骼: {boneName}。");
            }
        }
    }

    /// <summary>
    /// 比较 Cocos 绘制顺序;z 相同时保留 Spine setup slot 次序。
    /// </summary>
    /// <summary>
    /// 读取指定时刻 setup z 与最新帧 z 的和。
    /// </summary>
    /// <summary>
    /// 校验单个 Cocos 帧的变换、显示、颜色和补间字段。
    /// </summary>
    private static void ValidateFrame(string movementName, string boneName, int frameIndex, JObject frame)
    {
        string context = $"movement {movementName}.{boneName}.frame_data[{frameIndex}]";
        double x = ReadRequiredDouble(frame, "x", context);
        double y = ReadRequiredDouble(frame, "y", context);
        double scaleX = ReadRequiredDouble(frame, "cX", context);
        double scaleY = ReadRequiredDouble(frame, "cY", context);
        double skewX = ReadRequiredDouble(frame, "kX", context);
        double skewY = ReadRequiredDouble(frame, "kY", context);
        ReadRequiredInt(frame, "dI", context);
        ReadRequiredInt(frame, "z", context);
        int tweenEasing = ReadRequiredInt(frame, "twE", context);
        ReadRequiredBoolean(frame, "tweenFrame", context);

        if (!IsFinite(x) || !IsFinite(y) || !IsFinite(scaleX) || !IsFinite(scaleY) || !IsFinite(skewX) || !IsFinite(skewY))
        {
            throw new InvalidDataException($"ExportJson {context} 含无效数值。");
        }

        EnsureZeroInt(frame, "twR", context);
        ValidateEasingParameters(frame, tweenEasing, context);
        EnsureEmptyProperty(frame, "evt", context);
        EnsureEmptyProperty(frame, "sd", context);
        EnsureEmptyProperty(frame, "sdE", context);
        EnsureEmptyProperty(frame, "mov", context);

        JObject color = frame["color"] as JObject;
        if (frame["color"] != null && color == null)
        {
            throw new InvalidDataException($"ExportJson {context}.color 不是有效对象。");
        }

        if (color != null)
        {
            ReadColorByte(color, "r", context);
            ReadColorByte(color, "g", context);
            ReadColorByte(color, "b", context);
            ReadColorByte(color, "a", context);
        }
    }

    /// <summary>
    /// 校验自定义缓动参数,并拒绝普通缓动携带无效参数。
    /// </summary>
    private static void ValidateEasingParameters(JObject frame, int tweenEasing, string context)
    {
        if (tweenEasing != -1)
        {
            EnsureEmptyProperty(frame, "twEP", context);
            return;
        }

        JArray parameters = RequireCustomEasingParameters(frame, context);
        if (Math.Abs(parameters[1].Value<double>()) > ValueTolerance || Math.Abs(parameters[7].Value<double>() - 1d) > ValueTolerance)
        {
            throw new InvalidDataException($"ExportJson {context}.twEP 必须从进度 0 连续过渡到 1。");
        }
    }

    /// <summary>
    /// 读取 Cocos CUSTOM_EASING 使用的 4 个二维 Bezier 点。
    /// </summary>
    private static JArray RequireCustomEasingParameters(JObject frame, string context)
    {
        JArray parameters = frame["twEP"] as JArray;
        if (parameters == null || parameters.Count != 8)
        {
            throw new InvalidDataException($"ExportJson {context}.twEP 必须包含 8 个数值。");
        }

        for (int parameterIndex = 0; parameterIndex < parameters.Count; parameterIndex++)
        {
            double value = parameters[parameterIndex].Value<double>();
            if (!IsFinite(value))
            {
                throw new InvalidDataException($"ExportJson {context}.twEP[{parameterIndex}] 数值无效。");
            }
        }

        return parameters;
    }

    /// <summary>
    /// 删除无源语义的变换轨道,并按 Cocos 关键帧重建位移、旋转、缩放和剪切。
    /// </summary>
    private static void RepairBoneTimelines(
        SourceData sourceData,
        SetupTransformData setupTransformData,
        MovementData movementData,
        JObject spineAnimation,
        RepairResult result
    )
    {
        JObject spineBones = spineAnimation["bones"] as JObject;
        Dictionary<string, BakedTransformTimelines> bakedTimelineMap = BuildBakedTransformTimelines(sourceData, setupTransformData, movementData);

        // 先删除无源通道,以及将由逐帧反解完全替换的旧轨道。
        if (spineBones != null)
        {
            List<JProperty> emptyBoneProperties = new List<JProperty>();
            foreach (JProperty boneProperty in spineBones.Properties())
            {
                JObject targetTimelines = boneProperty.Value as JObject;
                if (bakedTimelineMap.ContainsKey(boneProperty.Name))
                {
                    RemoveBoneTimeline(targetTimelines, TimelineKind.Translate, result);
                    RemoveBoneTimeline(targetTimelines, TimelineKind.Rotate, result);
                    RemoveBoneTimeline(targetTimelines, TimelineKind.Scale, result);
                    RemoveBoneTimeline(targetTimelines, TimelineKind.Shear, result);
                }
                else
                {
                    movementData.MovementBoneMap.TryGetValue(boneProperty.Name, out JObject movementBone);
                    JArray frames = movementBone?["frame_data"] as JArray;
                    RemoveUnexpectedBoneTimeline(targetTimelines, frames, TimelineKind.Translate, result);
                    RemoveUnexpectedBoneTimeline(targetTimelines, frames, TimelineKind.Rotate, result);
                    RemoveUnexpectedBoneTimeline(targetTimelines, frames, TimelineKind.Scale, result);
                    RemoveUnexpectedBoneTimeline(targetTimelines, frames, TimelineKind.Shear, result);
                }

                if (targetTimelines != null && !targetTimelines.HasValues)
                {
                    emptyBoneProperties.Add(boneProperty);
                }
            }

            for (int propertyIndex = 0; propertyIndex < emptyBoneProperties.Count; propertyIndex++)
            {
                emptyBoneProperties[propertyIndex].Remove();
            }
        }

        // 普通骨骼继续按源关键帧和原始缓动重建轨道。
        foreach (KeyValuePair<string, JObject> movementBonePair in movementData.MovementBoneMap)
        {
            if (bakedTimelineMap.ContainsKey(movementBonePair.Key))
            {
                continue;
            }

            JArray frames = movementBonePair.Value["frame_data"] as JArray;
            bool hasTranslate = HasNonDefaultTimeline(frames, TimelineKind.Translate);
            bool hasRotate = HasNonDefaultTimeline(frames, TimelineKind.Rotate);
            bool hasScale = HasNonDefaultTimeline(frames, TimelineKind.Scale);
            bool hasShear = HasNonDefaultTimeline(frames, TimelineKind.Shear);
            if (!hasTranslate && !hasRotate && !hasScale && !hasShear)
            {
                continue;
            }

            if (spineBones == null)
            {
                spineBones = new JObject();
                spineAnimation["bones"] = spineBones;
            }

            JObject targetTimelines = GetOrCreateObject(spineBones, movementBonePair.Key);
            WriteRebuiltTimeline(targetTimelines, hasTranslate ? BuildTimeline(movementData, movementBonePair.Key, frames, TimelineKind.Translate) : null, TimelineKind.Translate, result);
            WriteRebuiltTimeline(targetTimelines, hasRotate ? BuildTimeline(movementData, movementBonePair.Key, frames, TimelineKind.Rotate) : null, TimelineKind.Rotate, result);
            WriteRebuiltTimeline(targetTimelines, hasScale ? BuildTimeline(movementData, movementBonePair.Key, frames, TimelineKind.Scale) : null, TimelineKind.Scale, result);
            WriteRebuiltTimeline(targetTimelines, hasShear ? BuildTimeline(movementData, movementBonePair.Key, frames, TimelineKind.Shear) : null, TimelineKind.Shear, result);
        }

        // 受动态非等比缩放影响的后代使用逐帧世界矩阵反解结果。
        foreach (KeyValuePair<string, BakedTransformTimelines> bakedPair in bakedTimelineMap)
        {
            if (spineBones == null)
            {
                spineBones = new JObject();
                spineAnimation["bones"] = spineBones;
            }

            JObject targetTimelines = GetOrCreateObject(spineBones, bakedPair.Key);
            WriteRebuiltTimeline(targetTimelines, bakedPair.Value.Translate, TimelineKind.Translate, result);
            WriteRebuiltTimeline(targetTimelines, bakedPair.Value.Rotate, TimelineKind.Rotate, result);
            WriteRebuiltTimeline(targetTimelines, bakedPair.Value.Scale, TimelineKind.Scale, result);
            WriteRebuiltTimeline(targetTimelines, bakedPair.Value.Shear, TimelineKind.Shear, result);
            if (!targetTimelines.HasValues)
            {
                targetTimelines.Parent?.Remove();
            }
        }
    }

    /// <summary>
    /// 写入重建轨道并更新对应统计。
    /// </summary>
    private static void WriteRebuiltTimeline(JObject targetTimelines, JArray timeline, TimelineKind timelineKind, RepairResult result)
    {
        if (timeline == null)
        {
            return;
        }

        targetTimelines[GetTimelineName(timelineKind)] = timeline;
        if (timelineKind == TimelineKind.Translate)
        {
            result.RebuiltTranslateTimelineCount++;
        }
        else if (timelineKind == TimelineKind.Rotate)
        {
            result.RebuiltRotateTimelineCount++;
        }
        else if (timelineKind == TimelineKind.Scale)
        {
            result.RebuiltScaleTimelineCount++;
        }
        else if (timelineKind == TimelineKind.Shear)
        {
            result.RebuiltShearTimelineCount++;
        }
    }

    /// <summary>
    /// 找出动态非等比缩放后代,并按每个 Cocos 帧反解 Spine 局部变换轨道。
    /// </summary>
    private static Dictionary<string, BakedTransformTimelines> BuildBakedTransformTimelines(
        SourceData sourceData,
        SetupTransformData setupTransformData,
        MovementData movementData
    )
    {
        HashSet<string> nonUniformScaleBoneNames = CollectNonUniformScaleBoneNames(movementData);
        HashSet<string> bakedBoneNames = CollectDescendantsOfBones(sourceData, nonUniformScaleBoneNames);
        Dictionary<string, BakedTransformTimelines> result = new Dictionary<string, BakedTransformTimelines>(StringComparer.Ordinal);
        if (bakedBoneNames.Count == 0)
        {
            return result;
        }

        // 按 setup 顺序建立稳定输出和角度连续状态。
        Dictionary<string, double> previousRotationMap = new Dictionary<string, double>(StringComparer.Ordinal);
        Dictionary<string, double> previousShearMap = new Dictionary<string, double>(StringComparer.Ordinal);
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            string boneName = sourceData.SourceBoneOrder[boneIndex];
            if (bakedBoneNames.Contains(boneName))
            {
                result.Add(boneName, new BakedTransformTimelines());
            }
        }

        // 逐 Cocos 动画帧计算完整世界变换,再相对父世界矩阵反解 Spine 局部值。
        for (int sourceFrameIndex = 0; sourceFrameIndex <= movementData.Duration; sourceFrameIndex++)
        {
            Dictionary<string, CocosWorldTransform> worldTransformMap = BuildAnimatedCocosWorldTransforms(sourceData, movementData, sourceFrameIndex);
            foreach (KeyValuePair<string, BakedTransformTimelines> bakedPair in result)
            {
                string boneName = bakedPair.Key;
                JObject sourceBone = sourceData.SourceBoneMap[boneName];
                string parentName = sourceBone.Value<string>("parent");
                CocosWorldTransform worldTransform = worldTransformMap[boneName];
                Matrix2D localMatrix;
                Vector2D localPosition;
                if (string.IsNullOrEmpty(parentName))
                {
                    localMatrix = worldTransform.Matrix;
                    localPosition = new Vector2D(worldTransform.X, worldTransform.Y);
                }
                else
                {
                    CocosWorldTransform parentTransform = worldTransformMap[parentName];
                    localMatrix = MultiplyInverse(parentTransform.Matrix, worldTransform.Matrix, boneName);
                    localPosition = TransformPointByInverseParent(parentTransform, worldTransform, boneName);
                }

                SpineLocalTransform localTransform = DecomposeSpineLocalMatrix(localMatrix, boneName);
                SpineLocalTransform setupTransform = setupTransformData.SpineLocalTransformMap[boneName];
                double translateX = localPosition.X - ReadRequiredDouble(sourceBone, "x", $"setup 骨骼 {boneName}");
                double translateY = localPosition.Y - ReadRequiredDouble(sourceBone, "y", $"setup 骨骼 {boneName}");
                double rotation = NormalizeDegrees(localTransform.Rotation - setupTransform.Rotation);
                double shearY = NormalizeDegrees(localTransform.ShearY - setupTransform.ShearY);
                if (previousRotationMap.TryGetValue(boneName, out double previousRotation))
                {
                    rotation = UnwrapDegrees(rotation, previousRotation);
                }

                if (previousShearMap.TryGetValue(boneName, out double previousShear))
                {
                    shearY = UnwrapDegrees(shearY, previousShear);
                }

                previousRotationMap[boneName] = rotation;
                previousShearMap[boneName] = shearY;
                double scaleX = localTransform.ScaleX / setupTransform.ScaleX;
                double scaleY = localTransform.ScaleY / setupTransform.ScaleY;
                ValidateBakedLocalTransform(
                    movementData.Name,
                    boneName,
                    sourceFrameIndex,
                    localMatrix,
                    setupTransform,
                    rotation,
                    scaleX,
                    scaleY,
                    shearY
                );
                double time = sourceFrameIndex * movementData.SecondsPerFrame;
                bakedPair.Value.AddFrame(time, translateX, translateY, rotation, scaleX, scaleY, shearY);
            }
        }

        return result;
    }

    /// <summary>
    /// 校验烘焙后的 Spine setup 加动画值能重建目标局部轴矩阵。
    /// </summary>
    private static void ValidateBakedLocalTransform(
        string movementName,
        string boneName,
        int sourceFrameIndex,
        Matrix2D expectedMatrix,
        SpineLocalTransform setupTransform,
        double rotation,
        double scaleX,
        double scaleY,
        double shearY
    )
    {
        double xAxis = (setupTransform.Rotation + rotation) / RadiansToDegrees;
        double yAxis = (setupTransform.Rotation + rotation + 90d + setupTransform.ShearY + shearY) / RadiansToDegrees;
        Matrix2D actualMatrix = new Matrix2D(
            Math.Cos(xAxis) * setupTransform.ScaleX * scaleX,
            Math.Cos(yAxis) * setupTransform.ScaleY * scaleY,
            Math.Sin(xAxis) * setupTransform.ScaleX * scaleX,
            Math.Sin(yAxis) * setupTransform.ScaleY * scaleY
        );
        if (
            Math.Abs(expectedMatrix.A - actualMatrix.A) > MatrixTolerance
            || Math.Abs(expectedMatrix.B - actualMatrix.B) > MatrixTolerance
            || Math.Abs(expectedMatrix.C - actualMatrix.C) > MatrixTolerance
            || Math.Abs(expectedMatrix.D - actualMatrix.D) > MatrixTolerance
        )
        {
            throw new InvalidDataException(
                $"ExportJson movement {movementName}.{boneName} 第 {sourceFrameIndex} 帧层级矩阵反解校验失败。"
            );
        }
    }

    /// <summary>
    /// 收集 movement 中相对 setup 比例为非等比缩放的骨骼。
    /// </summary>
    private static HashSet<string> CollectNonUniformScaleBoneNames(MovementData movementData)
    {
        HashSet<string> result = new HashSet<string>(StringComparer.Ordinal);
        foreach (KeyValuePair<string, JObject> movementBonePair in movementData.MovementBoneMap)
        {
            JObject sourceBone = movementData.SourceBoneMap[movementBonePair.Key];
            JArray frames = movementBonePair.Value["frame_data"] as JArray;
            for (int frameIndex = 0; frameIndex < frames.Count; frameIndex++)
            {
                JObject frame = RequireFrame(frames, movementData.Name, movementBonePair.Key, frameIndex);
                double[] scaleRatios = ReadScaleTimelineValues(
                    sourceBone,
                    frame,
                    $"movement {movementData.Name}.{movementBonePair.Key}.frame_data[{frameIndex}]"
                );
                if (Math.Abs(scaleRatios[0] - scaleRatios[1]) > ValueTolerance)
                {
                    result.Add(movementBonePair.Key);
                    break;
                }
            }
        }

        return result;
    }

    /// <summary>
    /// 收集指定骨骼集合的全部后代,不包含非等比缩放骨骼自身。
    /// </summary>
    private static HashSet<string> CollectDescendantsOfBones(SourceData sourceData, HashSet<string> ancestorNames)
    {
        HashSet<string> result = new HashSet<string>(StringComparer.Ordinal);
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            string boneName = sourceData.SourceBoneOrder[boneIndex];
            string parentName = sourceData.SourceBoneMap[boneName].Value<string>("parent");
            while (!string.IsNullOrEmpty(parentName))
            {
                if (ancestorNames.Contains(parentName))
                {
                    result.Add(boneName);
                    break;
                }

                parentName = sourceData.SourceBoneMap[parentName].Value<string>("parent");
            }
        }

        return result;
    }

    /// <summary>
    /// 计算 movement 指定帧的全部 Cocos sceneext 世界变换。
    /// </summary>
    private static Dictionary<string, CocosWorldTransform> BuildAnimatedCocosWorldTransforms(
        SourceData sourceData,
        MovementData movementData,
        int sourceFrameIndex
    )
    {
        Dictionary<string, CocosWorldTransform> result = new Dictionary<string, CocosWorldTransform>(StringComparer.Ordinal);
        HashSet<string> visiting = new HashSet<string>(StringComparer.Ordinal);
        for (int boneIndex = 0; boneIndex < sourceData.SourceBoneOrder.Count; boneIndex++)
        {
            BuildAnimatedCocosWorldTransform(sourceData.SourceBoneOrder[boneIndex], sourceData, movementData, sourceFrameIndex, result, visiting);
        }

        return result;
    }

    /// <summary>
    /// 递归计算 movement 指定帧的单根 Cocos sceneext 世界变换。
    /// </summary>
    private static CocosWorldTransform BuildAnimatedCocosWorldTransform(
        string boneName,
        SourceData sourceData,
        MovementData movementData,
        int sourceFrameIndex,
        Dictionary<string, CocosWorldTransform> worldTransformMap,
        HashSet<string> visiting
    )
    {
        if (worldTransformMap.TryGetValue(boneName, out CocosWorldTransform existingTransform))
        {
            return existingTransform;
        }

        if (!visiting.Add(boneName))
        {
            throw new InvalidDataException($"ExportJson movement {movementData.Name} 骨骼层级存在循环: {boneName}。");
        }

        JObject sourceBone = sourceData.SourceBoneMap[boneName];
        CocosFrameTransform frameTransform = SampleCocosFrameTransform(movementData, boneName, sourceFrameIndex);
        double x = ReadRequiredDouble(sourceBone, "x", $"setup 骨骼 {boneName}") + frameTransform.X;
        double y = ReadRequiredDouble(sourceBone, "y", $"setup 骨骼 {boneName}") + frameTransform.Y;
        double scaleX = ReadRequiredDouble(sourceBone, "cX", $"setup 骨骼 {boneName}") + frameTransform.ScaleX - 1d;
        double scaleY = ReadRequiredDouble(sourceBone, "cY", $"setup 骨骼 {boneName}") + frameTransform.ScaleY - 1d;
        double skewX = ReadRequiredDouble(sourceBone, "kX", $"setup 骨骼 {boneName}") + frameTransform.SkewX;
        double skewY = ReadRequiredDouble(sourceBone, "kY", $"setup 骨骼 {boneName}") + frameTransform.SkewY;
        string parentName = sourceBone.Value<string>("parent");

        // 完整复现 Bone::applyParentTransform 的位置矩阵和分量式轴继承。
        if (!string.IsNullOrEmpty(parentName))
        {
            CocosWorldTransform parentTransform = BuildAnimatedCocosWorldTransform(
                parentName,
                sourceData,
                movementData,
                sourceFrameIndex,
                worldTransformMap,
                visiting
            );
            double localX = x;
            double localY = y;
            x = localX * parentTransform.Matrix.A + localY * parentTransform.Matrix.B + parentTransform.X;
            y = localX * parentTransform.Matrix.C + localY * parentTransform.Matrix.D + parentTransform.Y;
            scaleX *= parentTransform.ScaleX;
            scaleY *= parentTransform.ScaleY;
            skewX += parentTransform.SkewX;
            skewY += parentTransform.SkewY;
        }

        CocosWorldTransform result = new CocosWorldTransform(x, y, scaleX, scaleY, skewX, skewY, CreateCocosMatrix(scaleX, scaleY, skewX, skewY));
        worldTransformMap.Add(boneName, result);
        visiting.Remove(boneName);
        return result;
    }

    /// <summary>
    /// 按 BoneTweenController 插值规则读取 movement 指定帧的局部增量。
    /// </summary>
    private static CocosFrameTransform SampleCocosFrameTransform(MovementData movementData, string boneName, int sourceFrameIndex)
    {
        if (!movementData.MovementBoneMap.TryGetValue(boneName, out JObject movementBone))
        {
            return CocosFrameTransform.Identity;
        }

        JArray frames = movementBone["frame_data"] as JArray;
        JObject firstFrame = RequireFrame(frames, movementData.Name, boneName, 0);
        int firstFrameIndex = ReadRequiredInt(firstFrame, "fi", $"movement {movementData.Name}.{boneName}.frame_data[0]");
        if (sourceFrameIndex <= firstFrameIndex)
        {
            return ReadCocosFrameTransform(firstFrame, $"movement {movementData.Name}.{boneName}.frame_data[0]");
        }

        // 精确关键帧进入下一段;关键帧之后使用当前帧到下一帧的补间。
        for (int frameIndex = 0; frameIndex < frames.Count - 1; frameIndex++)
        {
            JObject fromFrame = RequireFrame(frames, movementData.Name, boneName, frameIndex);
            JObject toFrame = RequireFrame(frames, movementData.Name, boneName, frameIndex + 1);
            int fromFrameIndex = ReadRequiredInt(fromFrame, "fi", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]");
            int toFrameIndex = ReadRequiredInt(toFrame, "fi", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex + 1}]");
            if (sourceFrameIndex >= toFrameIndex)
            {
                continue;
            }

            CocosFrameTransform fromTransform = ReadCocosFrameTransform(fromFrame, $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]");
            CocosFrameTransform toTransform = ReadCocosFrameTransform(toFrame, $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex + 1}]");
            int fromDisplayIndex = ReadRequiredInt(fromFrame, "dI", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]");
            int toDisplayIndex = ReadRequiredInt(toFrame, "dI", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex + 1}]");
            if (fromDisplayIndex < 0 && toDisplayIndex >= 0)
            {
                return toTransform;
            }

            if (toDisplayIndex < 0 && fromDisplayIndex >= 0)
            {
                return fromTransform;
            }

            bool tweenFrame = ReadRequiredBoolean(fromFrame, "tweenFrame", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]");
            if (!tweenFrame)
            {
                return fromTransform;
            }

            double percent = (sourceFrameIndex - fromFrameIndex) / (double)(toFrameIndex - fromFrameIndex);
            int tweenEasing = ReadRequiredInt(fromFrame, "twE", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]");
            double easedPercent = EvaluateCocosEasing(
                tweenEasing,
                percent,
                fromFrame,
                $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]"
            );
            return CocosFrameTransform.Interpolate(fromTransform, toTransform, easedPercent);
        }

        JObject lastFrame = RequireFrame(frames, movementData.Name, boneName, frames.Count - 1);
        return ReadCocosFrameTransform(lastFrame, $"movement {movementData.Name}.{boneName}.frame_data[{frames.Count - 1}]");
    }

    /// <summary>
    /// 读取单个 Cocos movement 帧的变换分量。
    /// </summary>
    private static CocosFrameTransform ReadCocosFrameTransform(JObject frame, string context)
    {
        return new CocosFrameTransform(
            ReadRequiredDouble(frame, "x", context),
            ReadRequiredDouble(frame, "y", context),
            ReadRequiredDouble(frame, "cX", context),
            ReadRequiredDouble(frame, "cY", context),
            ReadRequiredDouble(frame, "kX", context),
            ReadRequiredDouble(frame, "kY", context)
        );
    }

    /// <summary>
    /// 将 Cocos 世界位置反解到父骨骼局部坐标。
    /// </summary>
    private static Vector2D TransformPointByInverseParent(
        CocosWorldTransform parentTransform,
        CocosWorldTransform worldTransform,
        string boneName
    )
    {
        double determinant = parentTransform.Matrix.Determinant;
        if (Math.Abs(determinant) <= ValueTolerance)
        {
            throw new InvalidDataException($"ExportJson movement 骨骼 {boneName} 的父矩阵不可逆。");
        }

        double deltaX = worldTransform.X - parentTransform.X;
        double deltaY = worldTransform.Y - parentTransform.Y;
        return new Vector2D(
            (parentTransform.Matrix.D * deltaX - parentTransform.Matrix.B * deltaY) / determinant,
            (-parentTransform.Matrix.C * deltaX + parentTransform.Matrix.A * deltaY) / determinant
        );
    }

    /// <summary>
    /// 将角度展开到最接近上一帧的等价值。
    /// </summary>
    private static double UnwrapDegrees(double value, double previousValue)
    {
        while (value - previousValue > 180d)
        {
            value -= 360d;
        }

        while (value - previousValue <= -180d)
        {
            value += 360d;
        }

        return value;
    }

    /// <summary>
    /// 按 Cocos 显示索引、缺失骨骼隐藏和颜色插值语义重建 slot 轨道。
    /// </summary>
    private static void RepairSlotTimelines(SourceData sourceData, MovementData movementData, JObject spineAnimation, RepairResult result)
    {
        JObject spineSlots = spineAnimation["slots"] as JObject;

        // 每个有 display_data 的 Cocos 骨骼对应一个同名 Spine slot。
        foreach (KeyValuePair<string, List<string>> displayPair in sourceData.DisplayNameMap)
        {
            if (displayPair.Value.Count == 0)
            {
                continue;
            }

            JObject movementBone = null;
            movementData.MovementBoneMap.TryGetValue(displayPair.Key, out movementBone);
            JArray frames = movementBone?["frame_data"] as JArray;
            JArray attachmentTimeline = BuildAttachmentTimeline(movementData, displayPair.Key, displayPair.Value, frames);
            JArray colorTimeline = HasColorTimeline(frames) ? BuildTimeline(movementData, displayPair.Key, frames, TimelineKind.Rgba) : null;

            JObject targetTimelines = spineSlots?[displayPair.Key] as JObject;
            bool needsTarget = attachmentTimeline != null || colorTimeline != null;
            if (targetTimelines == null && needsTarget)
            {
                if (spineSlots == null)
                {
                    spineSlots = new JObject();
                    spineAnimation["slots"] = spineSlots;
                }

                targetTimelines = new JObject();
                spineSlots[displayPair.Key] = targetTimelines;
            }

            // 重建离散 attachment 轨道。
            if (attachmentTimeline == null)
            {
                if (targetTimelines != null && targetTimelines.Remove("attachment"))
                {
                    result.RemovedAttachmentTimelineCount++;
                }
            }
            else
            {
                targetTimelines["attachment"] = attachmentTimeline;
                result.RebuiltAttachmentTimelineCount++;
            }

            // 重建 Spine 4.3 rgba 轨道。
            if (colorTimeline == null)
            {
                if (targetTimelines != null && targetTimelines.Remove("rgba"))
                {
                    result.RemovedColorTimelineCount++;
                }
            }
            else
            {
                targetTimelines["rgba"] = colorTimeline;
                result.RebuiltColorTimelineCount++;
            }

            if (targetTimelines != null && !targetTimelines.HasValues)
            {
                targetTimelines.Parent?.Remove();
            }
        }
    }

    /// <summary>
    /// 模拟 Cocos local z 与 order-of-arrival,重建 Spine drawOrder 轨道。
    /// </summary>
    private static void RepairDrawOrderTimeline(
        SourceData sourceData,
        MovementData movementData,
        List<string> setupSlotOrder,
        JObject spineAnimation,
        RepairResult result
    )
    {
        Dictionary<string, int> setupZMap = new Dictionary<string, int>(StringComparer.Ordinal);
        Dictionary<string, int> effectiveZMap = new Dictionary<string, int>(StringComparer.Ordinal);
        Dictionary<string, long> arrivalOrderMap = new Dictionary<string, long>(StringComparer.Ordinal);

        // 建立 setup z 和初始到达顺序。
        for (int slotIndex = 0; slotIndex < setupSlotOrder.Count; slotIndex++)
        {
            string slotName = setupSlotOrder[slotIndex];
            if (!sourceData.SourceBoneMap.TryGetValue(slotName, out JObject sourceBone))
            {
                throw new InvalidDataException($"ExportJson setup 骨骼缺少 Spine slot 同名骨骼: {slotName}。");
            }

            int setupZ = sourceBone.Value<int?>("z") ?? 0;
            setupZMap.Add(slotName, setupZ);
            effectiveZMap.Add(slotName, setupZ);
            arrivalOrderMap.Add(slotName, slotIndex);
        }

        // 收集所有可能重置或改变 z 的关键时刻。
        SortedSet<int> sourceFrameIndices = new SortedSet<int>();
        foreach (string boneName in movementData.MovementBoneOrder)
        {
            JArray frames = movementData.MovementBoneMap[boneName]["frame_data"] as JArray;
            for (int frameIndex = 0; frameIndex < frames.Count; frameIndex++)
            {
                JObject frame = RequireFrame(frames, movementData.Name, boneName, frameIndex);
                sourceFrameIndices.Add(ReadRequiredInt(frame, "fi", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]"));
            }
        }

        // Cocos 只在 effective z 真正改变时刷新 order-of-arrival。
        long nextArrivalOrder = setupSlotOrder.Count;
        List<string> previousOrder = new List<string>(setupSlotOrder);
        JArray drawOrderTimeline = new JArray();
        foreach (int sourceFrameIndex in sourceFrameIndices)
        {
            for (int boneOrderIndex = 0; boneOrderIndex < movementData.MovementBoneOrder.Count; boneOrderIndex++)
            {
                string boneName = movementData.MovementBoneOrder[boneOrderIndex];
                if (!setupZMap.ContainsKey(boneName))
                {
                    continue;
                }

                JObject frame = FindFrameAt(movementData.MovementBoneMap[boneName]["frame_data"] as JArray, sourceFrameIndex);
                if (frame == null)
                {
                    continue;
                }

                int effectiveZ = setupZMap[boneName] + (frame.Value<int?>("z") ?? 0);
                if (effectiveZ == effectiveZMap[boneName])
                {
                    continue;
                }

                effectiveZMap[boneName] = effectiveZ;
                arrivalOrderMap[boneName] = nextArrivalOrder++;
            }

            List<string> currentOrder = new List<string>(setupSlotOrder);
            currentOrder.Sort(
                (left, right) =>
                {
                    int zComparison = effectiveZMap[left].CompareTo(effectiveZMap[right]);
                    return zComparison != 0 ? zComparison : arrivalOrderMap[left].CompareTo(arrivalOrderMap[right]);
                }
            );
            if (AreOrdersEqual(previousOrder, currentOrder))
            {
                continue;
            }

            JObject drawOrderFrame = new JObject();
            WriteOptionalDouble(drawOrderFrame, "time", sourceFrameIndex * movementData.SecondsPerFrame, 0d);
            JArray offsets = new JArray();
            for (int setupIndex = 0; setupIndex < setupSlotOrder.Count; setupIndex++)
            {
                string slotName = setupSlotOrder[setupIndex];
                offsets.Add(new JObject { ["slot"] = slotName, ["offset"] = currentOrder.IndexOf(slotName) - setupIndex });
            }

            drawOrderFrame["offsets"] = offsets;
            drawOrderTimeline.Add(drawOrderFrame);
            previousOrder = currentOrder;
        }

        if (drawOrderTimeline.Count == 0)
        {
            if (spineAnimation.Remove("drawOrder"))
            {
                result.RemovedDrawOrderTimelineCount++;
            }

            return;
        }

        spineAnimation["drawOrder"] = drawOrderTimeline;
        result.RebuiltDrawOrderTimelineCount++;
    }

    /// <summary>
    /// 查找指定 Cocos 帧号的关键帧。
    /// </summary>
    private static JObject FindFrameAt(JArray frames, int sourceFrameIndex)
    {
        if (frames == null)
        {
            return null;
        }

        for (int frameIndex = 0; frameIndex < frames.Count; frameIndex++)
        {
            JObject frame = frames[frameIndex] as JObject;
            int framePosition = frame?.Value<int?>("fi") ?? int.MinValue;
            if (framePosition == sourceFrameIndex)
            {
                return frame;
            }

            if (framePosition > sourceFrameIndex)
            {
                break;
            }
        }

        return null;
    }

    /// <summary>
    /// 判断两个 slot 顺序是否完全一致。
    /// </summary>
    private static bool AreOrdersEqual(List<string> left, List<string> right)
    {
        if (left.Count != right.Count)
        {
            return false;
        }

        for (int index = 0; index < left.Count; index++)
        {
            if (!string.Equals(left[index], right[index], StringComparison.Ordinal))
            {
                return false;
            }
        }

        return true;
    }

    /// <summary>
    /// 删除没有对应源通道的目标骨骼轨道。
    /// </summary>
    private static void RemoveUnexpectedBoneTimeline(JObject targetTimelines, JArray sourceFrames, TimelineKind timelineKind, RepairResult result)
    {
        if (targetTimelines == null || HasNonDefaultTimeline(sourceFrames, timelineKind))
        {
            return;
        }

        RemoveBoneTimeline(targetTimelines, timelineKind, result);
    }

    /// <summary>
    /// 删除指定目标骨骼轨道并更新统计。
    /// </summary>
    private static void RemoveBoneTimeline(JObject targetTimelines, TimelineKind timelineKind, RepairResult result)
    {
        if (targetTimelines == null || !targetTimelines.Remove(GetTimelineName(timelineKind)))
        {
            return;
        }

        if (timelineKind == TimelineKind.Translate)
        {
            result.RemovedTranslateTimelineCount++;
        }
        else if (timelineKind == TimelineKind.Rotate)
        {
            result.RemovedRotateTimelineCount++;
        }
        else if (timelineKind == TimelineKind.Scale)
        {
            result.RemovedScaleTimelineCount++;
        }
        else if (timelineKind == TimelineKind.Shear)
        {
            result.RemovedShearTimelineCount++;
        }
    }

    /// <summary>
    /// 将一条 Cocos 连续轨道转为 Spine 4.3 关键帧,并在二次缓动中点拆段。
    /// </summary>
    private static JArray BuildTimeline(MovementData movementData, string targetName, JArray sourceFrames, TimelineKind timelineKind)
    {
        JArray result = new JArray();
        double rotationDirection = movementData.RotationDirectionMap[targetName];
        for (int frameIndex = 0; frameIndex < sourceFrames.Count; frameIndex++)
        {
            JObject sourceFrame = RequireFrame(sourceFrames, movementData.Name, targetName, frameIndex);
            double sourceFrameIndex = ReadRequiredInt(sourceFrame, "fi", $"movement {movementData.Name}.{targetName}.frame_data[{frameIndex}]");
            double time = sourceFrameIndex * movementData.SecondsPerFrame;
            double[] values = ReadSpineTimelineValues(movementData, targetName, sourceFrame, timelineKind, rotationDirection, frameIndex);

            // sceneext 在 movement 开始时立即应用首帧,即使首帧 fi 大于 0。
            if (frameIndex == 0 && sourceFrameIndex > 0d)
            {
                result.Add(CreateTimelineFrame(0d, values, timelineKind));
            }

            JObject spineFrame = CreateTimelineFrame(time, values, timelineKind);
            if (frameIndex >= sourceFrames.Count - 1)
            {
                result.Add(spineFrame);
                continue;
            }

            JObject nextSourceFrame = RequireFrame(sourceFrames, movementData.Name, targetName, frameIndex + 1);
            double nextSourceFrameIndex = ReadRequiredInt(nextSourceFrame, "fi", $"movement {movementData.Name}.{targetName}.frame_data[{frameIndex + 1}]");
            double nextTime = nextSourceFrameIndex * movementData.SecondsPerFrame;
            double[] nextValues = ReadSpineTimelineValues(
                movementData,
                targetName,
                nextSourceFrame,
                timelineKind,
                rotationDirection,
                frameIndex + 1
            );
            bool tweenFrame = ReadRequiredBoolean(sourceFrame, "tweenFrame", $"movement {movementData.Name}.{targetName}.frame_data[{frameIndex}]");
            int tweenEasing = ReadRequiredInt(sourceFrame, "twE", $"movement {movementData.Name}.{targetName}.frame_data[{frameIndex}]");

            // stepped 优先于缓动类型。
            if (!tweenFrame)
            {
                spineFrame["curve"] = "stepped";
                result.Add(spineFrame);
                continue;
            }

            if (tweenEasing == 0)
            {
                result.Add(spineFrame);
                continue;
            }

            if (tweenEasing == 3)
            {
                spineFrame["curve"] = CreateBezierCurve(time, nextTime, values, nextValues, SineControlX1, 0d, SineControlX2, 1d);
                result.Add(spineFrame);
                continue;
            }

            if (tweenEasing == 7)
            {
                spineFrame["curve"] = CreateBezierCurve(time, nextTime, values, nextValues, 1d / 3d, 0d, 2d / 3d, 0d);
                result.Add(spineFrame);
                continue;
            }

            if (tweenEasing == 6)
            {
                double middleTime = (time + nextTime) * 0.5d;
                double[] middleValues = InterpolateValues(values, nextValues, 0.5d);
                spineFrame["curve"] = CreateBezierCurve(time, middleTime, values, middleValues, 1d / 3d, 0d, 2d / 3d, 1d / 3d);
                result.Add(spineFrame);

                JObject middleFrame = CreateTimelineFrame(middleTime, middleValues, timelineKind);
                middleFrame["curve"] = CreateBezierCurve(middleTime, nextTime, middleValues, nextValues, 1d / 3d, 2d / 3d, 2d / 3d, 1d);
                result.Add(middleFrame);
                continue;
            }

            string easingContext = $"movement {movementData.Name}.{targetName}.frame_data[{frameIndex}]";
            if (tweenEasing == 21)
            {
                AppendCircularEaseInOutFrames(result, time, nextTime, values, nextValues, timelineKind);
                continue;
            }

            if (IsBezierApproximationEasing(tweenEasing))
            {
                AppendApproximatedEasingFrames(
                    result,
                    time,
                    nextTime,
                    values,
                    nextValues,
                    timelineKind,
                    tweenEasing,
                    sourceFrame,
                    easingContext
                );
                continue;
            }

            throw new InvalidDataException($"ExportJson {easingContext} 使用未支持的 twE={tweenEasing}。");
        }

        return result;
    }

    /// <summary>
    /// 判断缓动是否使用自适应三次 Bezier 分段逼近。
    /// </summary>
    private static bool IsBezierApproximationEasing(int tweenEasing)
    {
        return tweenEasing == -1
            || tweenEasing == 1
            || tweenEasing == 2
            || tweenEasing == 4
            || tweenEasing == 9
            || tweenEasing == 13
            || tweenEasing == 27;
    }

    /// <summary>
    /// 使用自适应三次 Bezier 分段重建 Cocos 缓动。
    /// </summary>
    private static void AppendApproximatedEasingFrames(
        JArray result,
        double time,
        double nextTime,
        double[] values,
        double[] nextValues,
        TimelineKind timelineKind,
        int tweenEasing,
        JObject sourceFrame,
        string context
    )
    {
        List<EasingBezierSegment> segments = new List<EasingBezierSegment>();

        // 自适应拆分缓动,三次及以下多项式会自然收敛为最少分段。
        BuildEasingBezierSegments(tweenEasing, sourceFrame, context, 0d, 1d, 0, segments);

        // 每段写入起点和绝对控制点;终点由下一段或源关键帧提供。
        for (int segmentIndex = 0; segmentIndex < segments.Count; segmentIndex++)
        {
            EasingBezierSegment segment = segments[segmentIndex];
            double segmentTime = time + (nextTime - time) * segment.Start;
            double[] segmentValues = InterpolateValues(values, nextValues, segment.StartProgress);
            JObject segmentFrame = CreateTimelineFrame(segmentTime, segmentValues, timelineKind);
            segmentFrame["curve"] = CreateEasingBezierCurve(time, nextTime, values, nextValues, segment);
            result.Add(segmentFrame);
        }
    }

    /// <summary>
    /// 使用四段圆弧 Bezier 重建 Circ_EaseInOut,避免中点无限斜率失真。
    /// </summary>
    private static void AppendCircularEaseInOutFrames(
        JArray result,
        double time,
        double nextTime,
        double[] values,
        double[] nextValues,
        TimelineKind timelineKind
    )
    {
        const double arcControl = 0.265216489839544d;
        double quarterAngle = Math.PI * 0.25d;
        List<EasingArcSegment> segments = new List<EasingArcSegment>();

        // 前半段沿圆心 (0, 0.5) 的四分之一圆拆成两段。
        for (int segmentIndex = 0; segmentIndex < 2; segmentIndex++)
        {
            double startAngle = segmentIndex * quarterAngle;
            double endAngle = startAngle + quarterAngle;
            Vector2D startPoint = new Vector2D(0.5d * Math.Sin(startAngle), 0.5d * (1d - Math.Cos(startAngle)));
            Vector2D endPoint = new Vector2D(0.5d * Math.Sin(endAngle), 0.5d * (1d - Math.Cos(endAngle)));
            Vector2D startTangent = new Vector2D(0.5d * Math.Cos(startAngle), 0.5d * Math.Sin(startAngle));
            Vector2D endTangent = new Vector2D(0.5d * Math.Cos(endAngle), 0.5d * Math.Sin(endAngle));
            segments.Add(
                new EasingArcSegment(
                    startPoint,
                    new Vector2D(startPoint.X + arcControl * startTangent.X, startPoint.Y + arcControl * startTangent.Y),
                    new Vector2D(endPoint.X - arcControl * endTangent.X, endPoint.Y - arcControl * endTangent.Y)
                )
            );
        }

        // 后半段沿圆心 (1, 0.5) 的四分之一圆拆成两段。
        for (int segmentIndex = 0; segmentIndex < 2; segmentIndex++)
        {
            double startAngle = -Math.PI * 0.5d + segmentIndex * quarterAngle;
            double endAngle = startAngle + quarterAngle;
            Vector2D startPoint = new Vector2D(1d + 0.5d * Math.Sin(startAngle), 0.5d + 0.5d * Math.Cos(startAngle));
            Vector2D endPoint = new Vector2D(1d + 0.5d * Math.Sin(endAngle), 0.5d + 0.5d * Math.Cos(endAngle));
            Vector2D startTangent = new Vector2D(0.5d * Math.Cos(startAngle), -0.5d * Math.Sin(startAngle));
            Vector2D endTangent = new Vector2D(0.5d * Math.Cos(endAngle), -0.5d * Math.Sin(endAngle));
            segments.Add(
                new EasingArcSegment(
                    startPoint,
                    new Vector2D(startPoint.X + arcControl * startTangent.X, startPoint.Y + arcControl * startTangent.Y),
                    new Vector2D(endPoint.X - arcControl * endTangent.X, endPoint.Y - arcControl * endTangent.Y)
                )
            );
        }

        // 写入每段起点及绝对时间和值控制点。
        for (int segmentIndex = 0; segmentIndex < segments.Count; segmentIndex++)
        {
            EasingArcSegment segment = segments[segmentIndex];
            double segmentTime = time + (nextTime - time) * segment.Start.X;
            JObject segmentFrame = CreateTimelineFrame(
                segmentTime,
                InterpolateValues(values, nextValues, segment.Start.Y),
                timelineKind
            );
            segmentFrame["curve"] = CreateAbsoluteEasingBezierCurve(
                time,
                nextTime,
                values,
                nextValues,
                segment.FirstControl,
                segment.SecondControl
            );
            result.Add(segmentFrame);
        }
    }

    /// <summary>
    /// 递归生成满足误差阈值的 Cocos 缓动 Bezier 分段。
    /// </summary>
    private static void BuildEasingBezierSegments(
        int tweenEasing,
        JObject sourceFrame,
        string context,
        double start,
        double end,
        int depth,
        List<EasingBezierSegment> result
    )
    {
        double length = end - start;
        double startProgress = EvaluateCocosEasing(tweenEasing, start, sourceFrame, context);
        double endProgress = EvaluateCocosEasing(tweenEasing, end, sourceFrame, context);
        double firstSample = EvaluateCocosEasing(tweenEasing, start + length / 3d, sourceFrame, context);
        double secondSample = EvaluateCocosEasing(tweenEasing, start + length * 2d / 3d, sourceFrame, context);
        double firstEquation = 27d * firstSample - 8d * startProgress - endProgress;
        double secondEquation = 27d * secondSample - startProgress - 8d * endProgress;
        double firstControlProgress = (2d * firstEquation - secondEquation) / 18d;
        double secondControlProgress = (2d * secondEquation - firstEquation) / 18d;

        // 检查分段内部误差,超限时从中点继续拆分。
        double maximumError = 0d;
        double[] samples = { 1d / 6d, 0.5d, 5d / 6d };
        for (int sampleIndex = 0; sampleIndex < samples.Length; sampleIndex++)
        {
            double localPercent = samples[sampleIndex];
            double expected = EvaluateCocosEasing(tweenEasing, start + length * localPercent, sourceFrame, context);
            double actual = EvaluateCubicBezier(startProgress, firstControlProgress, secondControlProgress, endProgress, localPercent);
            maximumError = Math.Max(maximumError, Math.Abs(expected - actual));
        }

        if (maximumError > EasingApproximationTolerance && depth < MaxEasingSubdivisionDepth)
        {
            double middle = (start + end) * 0.5d;
            BuildEasingBezierSegments(tweenEasing, sourceFrame, context, start, middle, depth + 1, result);
            BuildEasingBezierSegments(tweenEasing, sourceFrame, context, middle, end, depth + 1, result);
            return;
        }

        result.Add(new EasingBezierSegment(start, end, startProgress, firstControlProgress, secondControlProgress));
    }

    /// <summary>
    /// 计算 Cocos TweenFunction 对应的归一化缓动进度。
    /// </summary>
    private static double EvaluateCocosEasing(int tweenEasing, double time, JObject sourceFrame, string context)
    {
        if (tweenEasing == 0)
        {
            return time;
        }

        if (tweenEasing == -1)
        {
            JArray parameters = RequireCustomEasingParameters(sourceFrame, context);
            double inverse = 1d - time;
            return parameters[1].Value<double>() * inverse * inverse * inverse
                + 3d * parameters[3].Value<double>() * time * inverse * inverse
                + 3d * parameters[5].Value<double>() * time * time * inverse
                + parameters[7].Value<double>() * time * time * time;
        }

        if (tweenEasing == 1)
        {
            return -Math.Cos(time * Math.PI * 0.5d) + 1d;
        }

        if (tweenEasing == 2)
        {
            return Math.Sin(time * Math.PI * 0.5d);
        }

        if (tweenEasing == 3)
        {
            return -0.5d * (Math.Cos(Math.PI * time) - 1d);
        }

        if (tweenEasing == 4)
        {
            return time * time;
        }

        if (tweenEasing == 6)
        {
            double doubled = time * 2d;
            if (doubled < 1d)
            {
                return 0.5d * doubled * doubled;
            }

            doubled -= 1d;
            return -0.5d * (doubled * (doubled - 2d) - 1d);
        }

        if (tweenEasing == 7)
        {
            return time * time * time;
        }

        if (tweenEasing == 9)
        {
            double doubled = time * 2d;
            if (doubled < 1d)
            {
                return 0.5d * doubled * doubled * doubled;
            }

            doubled -= 2d;
            return 0.5d * (doubled * doubled * doubled + 2d);
        }

        if (tweenEasing == 13)
        {
            return time * time * time * time * time;
        }

        if (tweenEasing == 21)
        {
            double doubled = time * 2d;
            if (doubled < 1d)
            {
                return -0.5d * (Math.Sqrt(1d - doubled * doubled) - 1d);
            }

            doubled -= 2d;
            return 0.5d * (Math.Sqrt(1d - doubled * doubled) + 1d);
        }

        if (tweenEasing == 27)
        {
            const double overshoot = 1.70158d * 1.525d;
            double doubled = time * 2d;
            if (doubled < 1d)
            {
                return doubled * doubled * ((overshoot + 1d) * doubled - overshoot) * 0.5d;
            }

            doubled -= 2d;
            return doubled * doubled * ((overshoot + 1d) * doubled + overshoot) * 0.5d + 1d;
        }

        throw new InvalidDataException($"ExportJson {context} 使用未支持的 twE={tweenEasing}。");
    }

    /// <summary>
    /// 计算三次 Bezier 在指定参数处的数值。
    /// </summary>
    private static double EvaluateCubicBezier(double start, double control1, double control2, double end, double time)
    {
        double inverse = 1d - time;
        return inverse * inverse * inverse * start
            + 3d * inverse * inverse * time * control1
            + 3d * inverse * time * time * control2
            + time * time * time * end;
    }

    /// <summary>
    /// 使用归一化绝对时间和进度创建 Spine Bezier 控制点。
    /// </summary>
    private static JArray CreateAbsoluteEasingBezierCurve(
        double time,
        double nextTime,
        double[] values,
        double[] nextValues,
        Vector2D firstControl,
        Vector2D secondControl
    )
    {
        JArray curve = new JArray();
        double duration = nextTime - time;
        for (int valueIndex = 0; valueIndex < values.Length; valueIndex++)
        {
            double valueDelta = nextValues[valueIndex] - values[valueIndex];
            curve.Add(time + duration * firstControl.X);
            curve.Add(values[valueIndex] + valueDelta * firstControl.Y);
            curve.Add(time + duration * secondControl.X);
            curve.Add(values[valueIndex] + valueDelta * secondControl.Y);
        }

        return curve;
    }

    /// <summary>
    /// 创建单个缓动分段的 Spine 绝对时间和值控制点。
    /// </summary>
    private static JArray CreateEasingBezierCurve(
        double time,
        double nextTime,
        double[] values,
        double[] nextValues,
        EasingBezierSegment segment
    )
    {
        JArray curve = new JArray();
        double duration = nextTime - time;
        double segmentLength = segment.End - segment.Start;
        double firstControlTime = time + duration * (segment.Start + segmentLength / 3d);
        double secondControlTime = time + duration * (segment.Start + segmentLength * 2d / 3d);
        for (int valueIndex = 0; valueIndex < values.Length; valueIndex++)
        {
            double valueDelta = nextValues[valueIndex] - values[valueIndex];
            curve.Add(firstControlTime);
            curve.Add(values[valueIndex] + valueDelta * segment.FirstControlProgress);
            curve.Add(secondControlTime);
            curve.Add(values[valueIndex] + valueDelta * segment.SecondControlProgress);
        }

        return curve;
    }

    /// <summary>
    /// 按 Cocos display index 重建附件切换;movement 未包含骨骼时在 0 秒隐藏。
    /// </summary>
    private static JArray BuildAttachmentTimeline(MovementData movementData, string boneName, List<string> displayNames, JArray sourceFrames)
    {
        if (sourceFrames == null)
        {
            return new JArray(new JObject());
        }

        JArray result = new JArray();
        int currentDisplayIndex = ReadNormalizedDisplayIndex(
            movementData.SourceBoneMap[boneName],
            displayNames.Count,
            $"setup 骨骼 {boneName}"
        );
        for (int frameIndex = 0; frameIndex < sourceFrames.Count; frameIndex++)
        {
            JObject sourceFrame = RequireFrame(sourceFrames, movementData.Name, boneName, frameIndex);
            int displayIndex = ReadNormalizedDisplayIndex(
                sourceFrame,
                displayNames.Count,
                $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]"
            );
            if (displayIndex == currentDisplayIndex)
            {
                continue;
            }

            // sceneext 播放 movement 时立即应用首帧 displayIndex,后续帧才使用 fi 时间。
            double time = frameIndex == 0
                ? 0d
                : ReadRequiredInt(sourceFrame, "fi", $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]")
                    * movementData.SecondsPerFrame;
            JObject attachmentFrame = new JObject();
            WriteOptionalDouble(attachmentFrame, "time", time, 0d);
            if (displayIndex >= 0)
            {
                attachmentFrame["name"] = displayNames[displayIndex];
            }

            result.Add(attachmentFrame);
            currentDisplayIndex = displayIndex;
        }

        return result.Count == 0 ? null : result;
    }

    /// <summary>
    /// 读取转换为 Spine 相对 setup 语义后的轨道值。
    /// </summary>
    private static double[] ReadSpineTimelineValues(
        MovementData movementData,
        string boneName,
        JObject sourceFrame,
        TimelineKind timelineKind,
        double rotationDirection,
        int frameIndex
    )
    {
        if (timelineKind != TimelineKind.Scale)
        {
            return ReadTimelineValues(sourceFrame, timelineKind, rotationDirection);
        }

        return ReadScaleTimelineValues(
            movementData.SourceBoneMap[boneName],
            sourceFrame,
            $"movement {movementData.Name}.{boneName}.frame_data[{frameIndex}]"
        );
    }

    /// <summary>
    /// 将 Cocos 的 setup 加帧偏移缩放转换为 Spine 相对 setup 倍率。
    /// </summary>
    private static double[] ReadScaleTimelineValues(JObject sourceBone, JObject sourceFrame, string context)
    {
        // 校验 setup 缩放可作为 Spine 相对倍率分母。
        double setupScaleX = ReadRequiredDouble(sourceBone, "cX", $"{context} 对应 setup 骨骼");
        double setupScaleY = ReadRequiredDouble(sourceBone, "cY", $"{context} 对应 setup 骨骼");
        if (Math.Abs(setupScaleX) <= ValueTolerance || Math.Abs(setupScaleY) <= ValueTolerance)
        {
            throw new InvalidDataException($"ExportJson {context} 对应 setup 骨骼含零缩放,无法转换 Spine 相对缩放。");
        }

        // 按 Cocos 加法语义还原缩放,再转换为 Spine setup 倍率。
        double frameScaleX = ReadRequiredDouble(sourceFrame, "cX", context);
        double frameScaleY = ReadRequiredDouble(sourceFrame, "cY", context);
        double spineScaleX = Math.Abs(setupScaleX - 1d) <= ValueTolerance ? frameScaleX : (setupScaleX + frameScaleX - 1d) / setupScaleX;
        double spineScaleY = Math.Abs(setupScaleY - 1d) <= ValueTolerance ? frameScaleY : (setupScaleY + frameScaleY - 1d) / setupScaleY;
        return new[] { spineScaleX, spineScaleY };
    }

    /// <summary>
    /// 判断 Cocos 帧列表是否包含颜色信息。
    /// </summary>
    private static bool HasColorTimeline(JArray sourceFrames)
    {
        if (sourceFrames == null)
        {
            return false;
        }

        for (int frameIndex = 0; frameIndex < sourceFrames.Count; frameIndex++)
        {
            if (sourceFrames[frameIndex]?["color"] != null)
            {
                return true;
            }
        }

        return false;
    }

    /// <summary>
    /// 判断 Cocos 帧列表的指定变换通道是否偏离默认值。
    /// </summary>
    private static bool HasNonDefaultTimeline(JArray sourceFrames, TimelineKind timelineKind)
    {
        if (sourceFrames == null || timelineKind == TimelineKind.Rgba)
        {
            return false;
        }

        for (int frameIndex = 0; frameIndex < sourceFrames.Count; frameIndex++)
        {
            JObject sourceFrame = sourceFrames[frameIndex] as JObject;
            if (sourceFrame == null)
            {
                return false;
            }

            double[] values = ReadTimelineValues(sourceFrame, timelineKind, 1d);
            double defaultValue = timelineKind == TimelineKind.Scale ? 1d : 0d;
            for (int valueIndex = 0; valueIndex < values.Length; valueIndex++)
            {
                if (Math.Abs(values[valueIndex] - defaultValue) > ValueTolerance)
                {
                    return true;
                }
            }
        }

        return false;
    }

    /// <summary>
    /// 读取 Cocos 帧在指定 Spine 轨道中的值。
    /// </summary>
    private static double[] ReadTimelineValues(JObject sourceFrame, TimelineKind timelineKind, double rotationDirection)
    {
        if (timelineKind == TimelineKind.Translate)
        {
            return new[] { sourceFrame.Value<double?>("x") ?? 0d, sourceFrame.Value<double?>("y") ?? 0d };
        }

        if (timelineKind == TimelineKind.Rotate)
        {
            double skewY = sourceFrame.Value<double?>("kY") ?? 0d;
            return new[] { skewY * RadiansToDegrees * rotationDirection };
        }

        if (timelineKind == TimelineKind.Scale)
        {
            return new[] { sourceFrame.Value<double?>("cX") ?? 1d, sourceFrame.Value<double?>("cY") ?? 1d };
        }

        if (timelineKind == TimelineKind.Shear)
        {
            double skewX = sourceFrame.Value<double?>("kX") ?? 0d;
            double skewY = sourceFrame.Value<double?>("kY") ?? 0d;
            return new[] { 0d, -(skewX + skewY) * RadiansToDegrees * rotationDirection };
        }

        JObject color = sourceFrame["color"] as JObject;
        if (color == null)
        {
            return new[] { 1d, 1d, 1d, 1d };
        }

        return new[]
        {
            ReadColorByte(color, "r", "颜色帧") / 255d,
            ReadColorByte(color, "g", "颜色帧") / 255d,
            ReadColorByte(color, "b", "颜色帧") / 255d,
            ReadColorByte(color, "a", "颜色帧") / 255d,
        };
    }

    /// <summary>
    /// 创建 Spine 4.3 关键帧并省略默认数值字段。
    /// </summary>
    private static JObject CreateTimelineFrame(double time, double[] values, TimelineKind timelineKind)
    {
        JObject frame = new JObject();
        WriteOptionalDouble(frame, "time", time, 0d);
        if (timelineKind == TimelineKind.Translate)
        {
            WriteOptionalDouble(frame, "x", values[0], 0d);
            WriteOptionalDouble(frame, "y", values[1], 0d);
        }
        else if (timelineKind == TimelineKind.Rotate)
        {
            WriteOptionalDouble(frame, "value", values[0], 0d);
        }
        else if (timelineKind == TimelineKind.Scale)
        {
            WriteOptionalDouble(frame, "x", values[0], 1d);
            WriteOptionalDouble(frame, "y", values[1], 1d);
        }
        else if (timelineKind == TimelineKind.Shear)
        {
            WriteOptionalDouble(frame, "x", values[0], 0d);
            WriteOptionalDouble(frame, "y", values[1], 0d);
        }
        else
        {
            frame["color"] = ConvertColorValues(values);
        }

        return frame;
    }

    /// <summary>
    /// 将归一化 Bezier 控制点转换为 Spine 4.3 使用的绝对时间和值控制点。
    /// </summary>
    private static JArray CreateBezierCurve(
        double time1,
        double time2,
        double[] values1,
        double[] values2,
        double controlX1,
        double controlY1,
        double controlX2,
        double controlY2
    )
    {
        JArray curve = new JArray();
        double duration = time2 - time1;
        for (int valueIndex = 0; valueIndex < values1.Length; valueIndex++)
        {
            double valueDelta = values2[valueIndex] - values1[valueIndex];
            curve.Add(time1 + duration * controlX1);
            curve.Add(values1[valueIndex] + valueDelta * controlY1);
            curve.Add(time1 + duration * controlX2);
            curve.Add(values1[valueIndex] + valueDelta * controlY2);
        }

        return curve;
    }

    /// <summary>
    /// 线性计算多通道中间值。
    /// </summary>
    private static double[] InterpolateValues(double[] from, double[] to, double percent)
    {
        double[] result = new double[from.Length];
        for (int valueIndex = 0; valueIndex < from.Length; valueIndex++)
        {
            result[valueIndex] = from[valueIndex] + (to[valueIndex] - from[valueIndex]) * percent;
        }

        return result;
    }

    /// <summary>
    /// 将 RGBA 数值转换为 Spine RRGGBBAA。
    /// </summary>
    private static string ConvertColorValues(double[] values)
    {
        byte red = ConvertColorByte(values[0] * 255d);
        byte green = ConvertColorByte(values[1] * 255d);
        byte blue = ConvertColorByte(values[2] * 255d);
        byte alpha = ConvertColorByte(values[3] * 255d);
        return $"{red:X2}{green:X2}{blue:X2}{alpha:X2}";
    }

    /// <summary>
    /// 将颜色数值限制并转换为字节。
    /// </summary>
    private static byte ConvertColorByte(double value)
    {
        if (!IsFinite(value))
        {
            throw new InvalidDataException($"颜色数值无效: {value}。");
        }

        double clamped = Math.Max(0d, Math.Min(255d, value));
        return (byte)Math.Round(clamped, MidpointRounding.AwayFromZero);
    }

    /// <summary>
    /// 读取 0~255 的必需颜色分量。
    /// </summary>
    private static int ReadColorByte(JObject color, string propertyName, string context)
    {
        int value = ReadRequiredInt(color, propertyName, context + ".color");
        if (value < 0 || value > 255)
        {
            throw new InvalidDataException($"ExportJson {context}.color.{propertyName} 越界: {value}。");
        }

        return value;
    }

    /// <summary>
    /// 校验修复前后只改变允许重建的动画轨道。
    /// </summary>
    private static void EnsureOnlyRepairableDataChanged(JObject before, JObject after, SourceData sourceData)
    {
        if (before == null)
        {
            throw new InvalidDataException("无法复制修复前 Spine JSON。");
        }

        JObject strippedBefore = before.DeepClone() as JObject;
        JObject strippedAfter = after.DeepClone() as JObject;
        StripRepairableSetupPose(strippedBefore, sourceData);
        StripRepairableSetupPose(strippedAfter, sourceData);
        StripRepairableTimelines(strippedBefore);
        StripRepairableTimelines(strippedAfter);
        if (!JToken.DeepEquals(strippedBefore, strippedAfter))
        {
            throw new InvalidDataException("结构保护失败:修复修改了 skins、附件 setup、骨骼层级或允许范围外的动画数据。");
        }
    }

    /// <summary>
    /// 从 JSON 副本移除允许变化的源骨骼 setup 变换字段。
    /// </summary>
    private static void StripRepairableSetupPose(JObject spineRoot, SourceData sourceData)
    {
        Dictionary<string, JObject> spineBoneMap = BuildNamedObjectMap(spineRoot["bones"] as JArray, "Spine setup 骨骼");
        string[] transformNames = { "x", "y", "rotation", "scaleX", "scaleY", "shearX", "shearY" };
        foreach (string boneName in sourceData.SourceBoneMap.Keys)
        {
            if (!spineBoneMap.TryGetValue(boneName, out JObject spineBone))
            {
                continue;
            }

            for (int propertyIndex = 0; propertyIndex < transformNames.Length; propertyIndex++)
            {
                spineBone.Remove(transformNames[propertyIndex]);
            }
        }

        // setup attachment 由 Cocos dI 决定,属于允许修复范围。
        Dictionary<string, JObject> spineSlotMap = BuildNamedObjectMap(spineRoot["slots"] as JArray, "Spine setup 插槽");
        foreach (KeyValuePair<string, List<string>> displayPair in sourceData.DisplayNameMap)
        {
            if (displayPair.Value.Count > 0 && spineSlotMap.TryGetValue(displayPair.Key, out JObject spineSlot))
            {
                spineSlot.Remove("attachment");
            }
        }
    }

    /// <summary>
    /// 从 JSON 副本移除允许变化的六类轨道和 drawOrder,用于结构保护比较。
    /// </summary>
    private static void StripRepairableTimelines(JObject spineRoot)
    {
        JObject animations = spineRoot?["animations"] as JObject;
        if (animations == null)
        {
            return;
        }

        foreach (JProperty animationProperty in animations.Properties())
        {
            JObject animation = animationProperty.Value as JObject;
            JObject bones = animation?["bones"] as JObject;
            RemoveTimelineNamesAndEmptyTargets(bones, new[] { "translate", "rotate", "scale", "shear" });
            if (bones != null && !bones.HasValues)
            {
                animation.Remove("bones");
            }

            JObject slots = animation?["slots"] as JObject;
            RemoveTimelineNamesAndEmptyTargets(slots, new[] { "attachment", "rgba" });
            if (slots != null && !slots.HasValues)
            {
                animation.Remove("slots");
            }

            animation?.Remove("drawOrder");
        }
    }

    /// <summary>
    /// 删除目标对象中的指定轨道,并清理因此变空的目标节点。
    /// </summary>
    private static void RemoveTimelineNamesAndEmptyTargets(JObject container, string[] timelineNames)
    {
        if (container == null)
        {
            return;
        }

        List<JProperty> emptyProperties = new List<JProperty>();
        foreach (JProperty targetProperty in container.Properties())
        {
            JObject timelines = targetProperty.Value as JObject;
            if (timelines == null)
            {
                continue;
            }

            for (int timelineIndex = 0; timelineIndex < timelineNames.Length; timelineIndex++)
            {
                timelines.Remove(timelineNames[timelineIndex]);
            }

            if (!timelines.HasValues)
            {
                emptyProperties.Add(targetProperty);
            }
        }

        for (int propertyIndex = 0; propertyIndex < emptyProperties.Count; propertyIndex++)
        {
            emptyProperties[propertyIndex].Remove();
        }
    }

    /// <summary>
    /// 校验修复轨道帧结构、严格递增时间、字段和 Spine 4.3 曲线格式。
    /// </summary>
    private static void ValidateRepairedTimelines(JObject spineRoot)
    {
        JObject animations = spineRoot["animations"] as JObject;
        if (animations == null)
        {
            throw new InvalidDataException("Spine 文件缺少 animations。");
        }

        // 校验骨骼连续轨道。
        foreach (JProperty animationProperty in animations.Properties())
        {
            JObject bones = animationProperty.Value?["bones"] as JObject;
            if (bones != null)
            {
                foreach (JProperty boneProperty in bones.Properties())
                {
                    JObject timelines = boneProperty.Value as JObject;
                    ValidateTimeline(animationProperty.Name, boneProperty.Name, "translate", timelines?["translate"] as JArray, 2);
                    ValidateTimeline(animationProperty.Name, boneProperty.Name, "rotate", timelines?["rotate"] as JArray, 1);
                    ValidateTimeline(animationProperty.Name, boneProperty.Name, "scale", timelines?["scale"] as JArray, 2);
                    ValidateTimeline(animationProperty.Name, boneProperty.Name, "shear", timelines?["shear"] as JArray, 2);
                }
            }

            // 校验 slot 离散轨道和颜色轨道。
            JObject slots = animationProperty.Value?["slots"] as JObject;
            if (slots == null)
            {
                continue;
            }

            foreach (JProperty slotProperty in slots.Properties())
            {
                JObject timelines = slotProperty.Value as JObject;
                ValidateAttachmentTimeline(animationProperty.Name, slotProperty.Name, timelines?["attachment"] as JArray);
                ValidateTimeline(animationProperty.Name, slotProperty.Name, "rgba", timelines?["rgba"] as JArray, 4);
            }
        }
    }

    /// <summary>
    /// 校验一条连续数值或颜色轨道。
    /// </summary>
    private static void ValidateTimeline(string animationName, string targetName, string timelineName, JArray timeline, int valueCount)
    {
        if (timeline == null)
        {
            return;
        }

        if (timeline.Count == 0)
        {
            throw new InvalidDataException($"Spine 轨道为空: {animationName}.{targetName}.{timelineName}。");
        }

        double previousTime = -1d;
        for (int frameIndex = 0; frameIndex < timeline.Count; frameIndex++)
        {
            JObject frame = timeline[frameIndex] as JObject;
            double time = frame?.Value<double?>("time") ?? 0d;
            if (frame == null || !IsFinite(time) || time <= previousTime)
            {
                throw new InvalidDataException($"Spine 轨道时间无效: {animationName}.{targetName}.{timelineName}[{frameIndex}]。");
            }

            if (timelineName == "rgba")
            {
                string color = frame.Value<string>("color");
                if (string.IsNullOrEmpty(color) || color.Length != 8 || !IsHexColor(color))
                {
                    throw new InvalidDataException($"Spine rgba 颜色无效: {animationName}.{targetName}.rgba[{frameIndex}]。");
                }
            }
            else
            {
                ValidateNumericFrame(animationName, targetName, timelineName, frameIndex, frame);
            }

            ValidateCurve(animationName, targetName, timelineName, frameIndex, frame["curve"], valueCount);
            previousTime = time;
        }
    }

    /// <summary>
    /// 校验骨骼数值关键帧字段。
    /// </summary>
    private static void ValidateNumericFrame(string animationName, string targetName, string timelineName, int frameIndex, JObject frame)
    {
        double firstValue;
        double secondValue;
        if (timelineName == "rotate")
        {
            firstValue = frame.Value<double?>("value") ?? 0d;
            secondValue = 0d;
        }
        else
        {
            double defaultValue = timelineName == "scale" ? 1d : 0d;
            firstValue = frame.Value<double?>("x") ?? defaultValue;
            secondValue = frame.Value<double?>("y") ?? defaultValue;
        }

        if (!IsFinite(firstValue) || !IsFinite(secondValue))
        {
            throw new InvalidDataException($"Spine 轨道数值无效: {animationName}.{targetName}.{timelineName}[{frameIndex}]。");
        }
    }

    /// <summary>
    /// 校验 Spine 4.3 stepped 或绝对控制点 Bezier 曲线。
    /// </summary>
    private static void ValidateCurve(string animationName, string targetName, string timelineName, int frameIndex, JToken curve, int valueCount)
    {
        if (curve == null)
        {
            return;
        }

        if (curve.Type == JTokenType.String && string.Equals(curve.Value<string>(), "stepped", StringComparison.Ordinal))
        {
            return;
        }

        JArray controlPoints = curve as JArray;
        if (controlPoints == null || controlPoints.Count != valueCount * 4)
        {
            throw new InvalidDataException($"Spine 曲线格式无效: {animationName}.{targetName}.{timelineName}[{frameIndex}]。");
        }

        for (int pointIndex = 0; pointIndex < controlPoints.Count; pointIndex++)
        {
            double value = controlPoints[pointIndex].Value<double>();
            if (!IsFinite(value))
            {
                throw new InvalidDataException($"Spine 曲线控制点无效: {animationName}.{targetName}.{timelineName}[{frameIndex}]。");
            }
        }
    }

    /// <summary>
    /// 校验 attachment 关键帧时间和可选名称。
    /// </summary>
    private static void ValidateAttachmentTimeline(string animationName, string slotName, JArray timeline)
    {
        if (timeline == null)
        {
            return;
        }

        double previousTime = -1d;
        for (int frameIndex = 0; frameIndex < timeline.Count; frameIndex++)
        {
            JObject frame = timeline[frameIndex] as JObject;
            double time = frame?.Value<double?>("time") ?? 0d;
            JToken name = frame?["name"];
            if (frame == null || !IsFinite(time) || time <= previousTime || (name != null && name.Type != JTokenType.String && name.Type != JTokenType.Null))
            {
                throw new InvalidDataException($"Spine attachment 轨道无效: {animationName}.{slotName}.attachment[{frameIndex}]。");
            }

            previousTime = time;
        }
    }

    /// <summary>
    /// 使用项目当前 Spine 4.3 runtime 完整解析输出 JSON。
    /// </summary>
    private static void ValidateWithSpineRuntime(JObject spineRoot)
    {
        string json = spineRoot.ToString(Formatting.None);
        Spine.Unity.RegionlessAttachmentLoader attachmentLoader = new Spine.Unity.RegionlessAttachmentLoader();
        Spine.SkeletonJson skeletonJson = new Spine.SkeletonJson(attachmentLoader);
        using (StringReader reader = new StringReader(json))
        {
            Spine.SkeletonData skeletonData = skeletonJson.ReadSkeletonData(reader);
            if (skeletonData == null)
            {
                throw new InvalidDataException("Spine runtime 未能生成 SkeletonData。");
            }
        }
    }

    /// <summary>
    /// 将对象数组转换为名称唯一的索引。
    /// </summary>
    private static Dictionary<string, JObject> BuildNamedObjectMap(JArray sourceItems, string context)
    {
        if (sourceItems == null)
        {
            throw new InvalidDataException($"{context} 数组缺失。");
        }

        Dictionary<string, JObject> result = new Dictionary<string, JObject>(StringComparer.Ordinal);
        for (int itemIndex = 0; itemIndex < sourceItems.Count; itemIndex++)
        {
            JObject sourceItem = sourceItems[itemIndex] as JObject;
            string itemName = sourceItem?.Value<string>("name");
            if (string.IsNullOrEmpty(itemName) || !result.TryAdd(itemName, sourceItem))
            {
                throw new InvalidDataException($"{context} 第 {itemIndex} 项名称无效或重复: {itemName}。");
            }
        }

        return result;
    }

    /// <summary>
    /// 收集名称数组的稳定顺序。
    /// </summary>
    private static List<string> CollectNamedOrder(JArray sourceItems, string context)
    {
        Dictionary<string, JObject> itemMap = BuildNamedObjectMap(sourceItems, context);
        List<string> result = new List<string>(itemMap.Count);
        foreach (KeyValuePair<string, JObject> itemPair in itemMap)
        {
            result.Add(itemPair.Key);
        }

        return result;
    }

    /// <summary>
    /// 读取并校验 Cocos 帧对象。
    /// </summary>
    private static JObject RequireFrame(JArray sourceFrames, string movementName, string boneName, int frameIndex)
    {
        JObject sourceFrame = sourceFrames[frameIndex] as JObject;
        if (sourceFrame == null)
        {
            throw new InvalidDataException($"ExportJson {movementName}.{boneName}.frame_data[{frameIndex}] 不是有效对象。");
        }

        return sourceFrame;
    }

    /// <summary>
    /// 读取必需整数属性。
    /// </summary>
    private static int ReadRequiredInt(JObject source, string propertyName, string context)
    {
        JToken token = source[propertyName];
        if (token == null || token.Type != JTokenType.Integer)
        {
            throw new InvalidDataException($"ExportJson {context} 缺少整数 {propertyName}。");
        }

        return token.Value<int>();
    }

    /// <summary>
    /// 读取必需浮点属性。
    /// </summary>
    private static double ReadRequiredDouble(JObject source, string propertyName, string context)
    {
        JToken token = source[propertyName];
        if (token == null || (token.Type != JTokenType.Integer && token.Type != JTokenType.Float))
        {
            throw new InvalidDataException($"ExportJson {context} 缺少数值 {propertyName}。");
        }

        return token.Value<double>();
    }

    /// <summary>
    /// 读取必需布尔属性。
    /// </summary>
    private static bool ReadRequiredBoolean(JObject source, string propertyName, string context)
    {
        JToken token = source[propertyName];
        if (token == null || token.Type != JTokenType.Boolean)
        {
            throw new InvalidDataException($"ExportJson {context} 缺少布尔值 {propertyName}。");
        }

        return token.Value<bool>();
    }

    /// <summary>
    /// 要求可选整数属性缺失或为零。
    /// </summary>
    private static void EnsureZeroInt(JObject source, string propertyName, string context)
    {
        JToken token = source[propertyName];
        if (token == null)
        {
            return;
        }

        if (token.Type != JTokenType.Integer || token.Value<int>() != 0)
        {
            throw new InvalidDataException($"ExportJson {context} 使用未支持的 {propertyName}={token.ToString(Formatting.None)}。");
        }
    }

    /// <summary>
    /// 要求可选扩展属性缺失、null、空字符串或空数组。
    /// </summary>
    private static void EnsureEmptyProperty(JObject source, string propertyName, string context)
    {
        JToken token = source[propertyName];
        if (token == null || token.Type == JTokenType.Null)
        {
            return;
        }

        if (token.Type == JTokenType.String && string.IsNullOrEmpty(token.Value<string>()))
        {
            return;
        }

        JArray array = token as JArray;
        if (array != null && array.Count == 0)
        {
            return;
        }

        throw new InvalidDataException($"ExportJson {context} 使用未支持的 {propertyName}={token.ToString(Formatting.None)}。");
    }

    /// <summary>
    /// 读取 Cocos displayIndex,并将任意负值归一为隐藏状态 -1。
    /// </summary>
    private static int ReadNormalizedDisplayIndex(JObject source, int displayCount, string context)
    {
        int sourceDisplayIndex = ReadRequiredInt(source, "dI", context);
        if (sourceDisplayIndex >= displayCount)
        {
            throw new InvalidDataException($"ExportJson {context} dI 越界: {sourceDisplayIndex},display 数量 {displayCount}。");
        }

        return sourceDisplayIndex < 0 ? -1 : sourceDisplayIndex;
    }

    /// <summary>
    /// 获取或创建 JObject 子节点。
    /// </summary>
    private static JObject GetOrCreateObject(JObject parent, string propertyName)
    {
        JObject result = parent[propertyName] as JObject;
        if (result == null)
        {
            result = new JObject();
            parent[propertyName] = result;
        }

        return result;
    }

    /// <summary>
    /// 返回轨道对应的 Spine 字段名。
    /// </summary>
    private static string GetTimelineName(TimelineKind timelineKind)
    {
        if (timelineKind == TimelineKind.Translate)
        {
            return "translate";
        }

        if (timelineKind == TimelineKind.Rotate)
        {
            return "rotate";
        }

        if (timelineKind == TimelineKind.Scale)
        {
            return "scale";
        }

        if (timelineKind == TimelineKind.Shear)
        {
            return "shear";
        }

        return "rgba";
    }

    /// <summary>
    /// 去除 Cocos display 名称末尾扩展名并统一路径分隔符。
    /// </summary>
    private static string StripExtension(string path)
    {
        if (string.IsNullOrEmpty(path))
        {
            return string.Empty;
        }

        string normalized = path.Replace('\\', '/');
        int slashIndex = normalized.LastIndexOf('/');
        int extensionIndex = normalized.LastIndexOf('.');
        if (extensionIndex > slashIndex)
        {
            return normalized.Substring(0, extensionIndex);
        }

        return normalized;
    }

    /// <summary>
    /// 写入非默认浮点字段。
    /// </summary>
    private static void WriteOptionalDouble(JObject target, string propertyName, double value, double defaultValue)
    {
        if (!IsFinite(value))
        {
            throw new InvalidDataException($"待写入数值无效: {propertyName}={value}。");
        }

        if (Math.Abs(value - defaultValue) <= ValueTolerance)
        {
            target.Remove(propertyName);
            return;
        }

        target[propertyName] = value;
    }

    /// <summary>
    /// 将弧度归一到 (-π, π]。
    /// </summary>
    private static double NormalizeRadians(double value)
    {
        double twoPi = Math.PI * 2d;
        value %= twoPi;
        if (value <= -Math.PI)
        {
            value += twoPi;
        }
        else if (value > Math.PI)
        {
            value -= twoPi;
        }

        return value;
    }

    /// <summary>
    /// 判断字符串是否为十六进制颜色。
    /// </summary>
    private static bool IsHexColor(string color)
    {
        for (int index = 0; index < color.Length; index++)
        {
            char value = color[index];
            bool isDigit = value >= '0' && value <= '9';
            bool isUpper = value >= 'A' && value <= 'F';
            bool isLower = value >= 'a' && value <= 'f';
            if (!isDigit && !isUpper && !isLower)
            {
                return false;
            }
        }

        return true;
    }

    /// <summary>
    /// 判断双精度数是否有效。
    /// </summary>
    private static bool IsFinite(double value)
    {
        return !double.IsNaN(value) && !double.IsInfinity(value);
    }

    /// <summary>
    /// 将文本行尾统一为 LF。
    /// </summary>
    private static string NormalizeLineEndings(string value)
    {
        return value.Replace("\r\n", "\n").Replace('\r', '\n');
    }

    private readonly struct EasingArcSegment
    {
        internal readonly Vector2D Start;
        internal readonly Vector2D FirstControl;
        internal readonly Vector2D SecondControl;

        /// <summary>
        /// 创建归一化圆弧 Bezier 分段。
        /// </summary>
        internal EasingArcSegment(Vector2D start, Vector2D firstControl, Vector2D secondControl)
        {
            Start = start;
            FirstControl = firstControl;
            SecondControl = secondControl;
        }
    }

    private readonly struct EasingBezierSegment
    {
        internal readonly double Start;
        internal readonly double End;
        internal readonly double StartProgress;
        internal readonly double FirstControlProgress;
        internal readonly double SecondControlProgress;

        /// <summary>
        /// 创建归一化缓动 Bezier 分段。
        /// </summary>
        internal EasingBezierSegment(
            double start,
            double end,
            double startProgress,
            double firstControlProgress,
            double secondControlProgress
        )
        {
            Start = start;
            End = end;
            StartProgress = startProgress;
            FirstControlProgress = firstControlProgress;
            SecondControlProgress = secondControlProgress;
        }
    }

    private enum TimelineKind
    {
        Translate,
        Rotate,
        Scale,
        Shear,
        Rgba,
    }

    private sealed class SourceData
    {
        /// <summary>
        /// Cocos setup 骨骼索引。
        /// </summary>
        internal readonly Dictionary<string, JObject> SourceBoneMap;

        /// <summary>
        /// ExportJson 中 bone_data 的原始顺序。
        /// </summary>
        internal readonly List<string> SourceBoneOrder;

        /// <summary>
        /// 每根骨骼按 display index 排列的附件名称。
        /// </summary>
        internal readonly Dictionary<string, List<string>> DisplayNameMap;

        /// <summary>
        /// Cocos movement 索引。
        /// </summary>
        internal readonly Dictionary<string, JObject> MovementMap;

        /// <summary>
        /// 创建完成校验的 Cocos 源数据索引。
        /// </summary>
        internal SourceData(
            Dictionary<string, JObject> sourceBoneMap,
            List<string> sourceBoneOrder,
            Dictionary<string, List<string>> displayNameMap,
            Dictionary<string, JObject> movementMap
        )
        {
            SourceBoneMap = sourceBoneMap;
            SourceBoneOrder = sourceBoneOrder;
            DisplayNameMap = displayNameMap;
            MovementMap = movementMap;
        }
    }

    private sealed class SetupTransformData
    {
        /// <summary>
        /// 按 sceneext 规则计算的 Cocos setup 世界变换。
        /// </summary>
        internal readonly Dictionary<string, CocosWorldTransform> WorldTransformMap;

        /// <summary>
        /// 反解后的 Spine setup 局部变换。
        /// </summary>
        internal readonly Dictionary<string, SpineLocalTransform> SpineLocalTransformMap;

        /// <summary>
        /// 每根骨骼动画旋转增量的方向;父世界为反射时取 -1。
        /// </summary>
        internal readonly Dictionary<string, double> RotationDirectionMap;

        /// <summary>
        /// 创建层级变换数据。
        /// </summary>
        internal SetupTransformData(
            Dictionary<string, CocosWorldTransform> worldTransformMap,
            Dictionary<string, SpineLocalTransform> spineLocalTransformMap,
            Dictionary<string, double> rotationDirectionMap
        )
        {
            WorldTransformMap = worldTransformMap;
            SpineLocalTransformMap = spineLocalTransformMap;
            RotationDirectionMap = rotationDirectionMap;
        }
    }

    private sealed class CocosWorldTransform
    {
        internal readonly double X;
        internal readonly double Y;
        internal readonly double ScaleX;
        internal readonly double ScaleY;
        internal readonly double SkewX;
        internal readonly double SkewY;
        internal readonly Matrix2D Matrix;

        /// <summary>
        /// 创建 Cocos 世界变换。
        /// </summary>
        internal CocosWorldTransform(double x, double y, double scaleX, double scaleY, double skewX, double skewY, Matrix2D matrix)
        {
            X = x;
            Y = y;
            ScaleX = scaleX;
            ScaleY = scaleY;
            SkewX = skewX;
            SkewY = skewY;
            Matrix = matrix;
        }
    }

    private readonly struct SpineLocalTransform
    {
        internal readonly double Rotation;
        internal readonly double ScaleX;
        internal readonly double ScaleY;
        internal readonly double ShearY;

        /// <summary>
        /// 创建 Spine 局部变换。
        /// </summary>
        internal SpineLocalTransform(double rotation, double scaleX, double scaleY, double shearY)
        {
            Rotation = rotation;
            ScaleX = scaleX;
            ScaleY = scaleY;
            ShearY = shearY;
        }
    }

    private readonly struct Matrix2D
    {
        internal readonly double A;
        internal readonly double B;
        internal readonly double C;
        internal readonly double D;

        internal double Determinant => A * D - B * C;

        /// <summary>
        /// 创建二维轴矩阵。
        /// </summary>
        internal Matrix2D(double a, double b, double c, double d)
        {
            A = a;
            B = b;
            C = c;
            D = d;
        }

        /// <summary>
        /// 矩阵相乘。
        /// </summary>
        internal static Matrix2D Multiply(Matrix2D left, Matrix2D right)
        {
            return new Matrix2D(
                left.A * right.A + left.B * right.C,
                left.A * right.B + left.B * right.D,
                left.C * right.A + left.D * right.C,
                left.C * right.B + left.D * right.D
            );
        }
    }

    private readonly struct Vector2D
    {
        internal readonly double X;
        internal readonly double Y;

        /// <summary>
        /// 创建二维位置。
        /// </summary>
        internal Vector2D(double x, double y)
        {
            X = x;
            Y = y;
        }
    }

    private sealed class BakedTransformTimelines
    {
        private readonly JArray translate = new JArray();
        private readonly JArray rotate = new JArray();
        private readonly JArray scale = new JArray();
        private readonly JArray shear = new JArray();
        private bool hasTranslate;
        private bool hasRotate;
        private bool hasScale;
        private bool hasShear;

        /// <summary>
        /// 非默认位移轨道;全部帧为默认值时返回 null。
        /// </summary>
        internal JArray Translate => hasTranslate ? translate : null;

        /// <summary>
        /// 非默认旋转轨道;全部帧为默认值时返回 null。
        /// </summary>
        internal JArray Rotate => hasRotate ? rotate : null;

        /// <summary>
        /// 非默认缩放轨道;全部帧为默认值时返回 null。
        /// </summary>
        internal JArray Scale => hasScale ? scale : null;

        /// <summary>
        /// 非默认剪切轨道;全部帧为默认值时返回 null。
        /// </summary>
        internal JArray Shear => hasShear ? shear : null;

        /// <summary>
        /// 追加一帧逐层级反解后的 Spine 局部变换。
        /// </summary>
        internal void AddFrame(
            double time,
            double translateX,
            double translateY,
            double rotation,
            double scaleX,
            double scaleY,
            double shearY
        )
        {
            translate.Add(CreateTimelineFrame(time, new[] { translateX, translateY }, TimelineKind.Translate));
            rotate.Add(CreateTimelineFrame(time, new[] { rotation }, TimelineKind.Rotate));
            scale.Add(CreateTimelineFrame(time, new[] { scaleX, scaleY }, TimelineKind.Scale));
            shear.Add(CreateTimelineFrame(time, new[] { 0d, shearY }, TimelineKind.Shear));
            hasTranslate |= Math.Abs(translateX) > ValueTolerance || Math.Abs(translateY) > ValueTolerance;
            hasRotate |= Math.Abs(rotation) > ValueTolerance;
            hasScale |= Math.Abs(scaleX - 1d) > ValueTolerance || Math.Abs(scaleY - 1d) > ValueTolerance;
            hasShear |= Math.Abs(shearY) > ValueTolerance;
        }
    }

    private readonly struct CocosFrameTransform
    {
        internal static readonly CocosFrameTransform Identity = new CocosFrameTransform(0d, 0d, 1d, 1d, 0d, 0d);
        internal readonly double X;
        internal readonly double Y;
        internal readonly double ScaleX;
        internal readonly double ScaleY;
        internal readonly double SkewX;
        internal readonly double SkewY;

        /// <summary>
        /// 创建 Cocos movement 局部增量。
        /// </summary>
        internal CocosFrameTransform(double x, double y, double scaleX, double scaleY, double skewX, double skewY)
        {
            X = x;
            Y = y;
            ScaleX = scaleX;
            ScaleY = scaleY;
            SkewX = skewX;
            SkewY = skewY;
        }

        /// <summary>
        /// 按 Cocos BoneTweenController 分量线性插值两个 movement 帧。
        /// </summary>
        internal static CocosFrameTransform Interpolate(CocosFrameTransform from, CocosFrameTransform to, double percent)
        {
            return new CocosFrameTransform(
                from.X + (to.X - from.X) * percent,
                from.Y + (to.Y - from.Y) * percent,
                from.ScaleX + (to.ScaleX - from.ScaleX) * percent,
                from.ScaleY + (to.ScaleY - from.ScaleY) * percent,
                from.SkewX + (to.SkewX - from.SkewX) * percent,
                from.SkewY + (to.SkewY - from.SkewY) * percent
            );
        }
    }

    private sealed class MovementData
    {
        /// <summary>
        /// movement 名称。
        /// </summary>
        internal readonly string Name;

        /// <summary>
        /// movement 总帧数。
        /// </summary>
        internal readonly int Duration;

        /// <summary>
        /// Cocos 运行时每动画帧对应的秒数。
        /// </summary>
        internal readonly double SecondsPerFrame;

        /// <summary>
        /// Cocos setup 骨骼索引,用于转换 movement 相对缩放。
        /// </summary>
        internal readonly Dictionary<string, JObject> SourceBoneMap;

        /// <summary>
        /// ExportJson setup 骨骼原始顺序。
        /// </summary>
        internal readonly List<string> SourceBoneOrder;

        /// <summary>
        /// movement 骨骼轨道索引。
        /// </summary>
        internal readonly Dictionary<string, JObject> MovementBoneMap;

        /// <summary>
        /// ExportJson 中 mov_bone_data 的原始顺序。
        /// </summary>
        internal readonly List<string> MovementBoneOrder;

        /// <summary>
        /// 每根骨骼动画旋转增量的方向。
        /// </summary>
        internal readonly Dictionary<string, double> RotationDirectionMap;

        /// <summary>
        /// 创建完成校验的 movement 数据。
        /// </summary>
        internal MovementData(
            string name,
            int duration,
            double secondsPerFrame,
            Dictionary<string, JObject> sourceBoneMap,
            List<string> sourceBoneOrder,
            Dictionary<string, JObject> movementBoneMap,
            List<string> movementBoneOrder,
            Dictionary<string, double> rotationDirectionMap
        )
        {
            Name = name;
            Duration = duration;
            SecondsPerFrame = secondsPerFrame;
            SourceBoneMap = sourceBoneMap;
            SourceBoneOrder = sourceBoneOrder;
            MovementBoneMap = movementBoneMap;
            MovementBoneOrder = movementBoneOrder;
            RotationDirectionMap = rotationDirectionMap;
        }
    }

    private sealed class RepairResult
    {
        /// <summary>
        /// 修复的 setup 骨骼数。
        /// </summary>
        internal int RepairedSetupBoneCount;

        /// <summary>
        /// 修复的 setup 插槽数。
        /// </summary>
        internal int RepairedSetupSlotCount;

        /// <summary>
        /// 重建的位移轨道数。
        /// </summary>
        internal int RebuiltTranslateTimelineCount;

        /// <summary>
        /// 删除的误造位移轨道数。
        /// </summary>
        internal int RemovedTranslateTimelineCount;

        /// <summary>
        /// 重建的旋转轨道数。
        /// </summary>
        internal int RebuiltRotateTimelineCount;

        /// <summary>
        /// 删除的误造旋转轨道数。
        /// </summary>
        internal int RemovedRotateTimelineCount;

        /// <summary>
        /// 重建的缩放轨道数。
        /// </summary>
        internal int RebuiltScaleTimelineCount;

        /// <summary>
        /// 删除的误造缩放轨道数。
        /// </summary>
        internal int RemovedScaleTimelineCount;

        /// <summary>
        /// 重建的剪切轨道数。
        /// </summary>
        internal int RebuiltShearTimelineCount;

        /// <summary>
        /// 删除的误造剪切轨道数。
        /// </summary>
        internal int RemovedShearTimelineCount;

        /// <summary>
        /// 重建的附件轨道数。
        /// </summary>
        internal int RebuiltAttachmentTimelineCount;

        /// <summary>
        /// 删除的误造附件轨道数。
        /// </summary>
        internal int RemovedAttachmentTimelineCount;

        /// <summary>
        /// 重建的颜色轨道数。
        /// </summary>
        internal int RebuiltColorTimelineCount;

        /// <summary>
        /// 删除的误造颜色轨道数。
        /// </summary>
        internal int RemovedColorTimelineCount;

        /// <summary>
        /// 重建的绘制顺序轨道数。
        /// </summary>
        internal int RebuiltDrawOrderTimelineCount;

        /// <summary>
        /// 删除的误造绘制顺序轨道数。
        /// </summary>
        internal int RemovedDrawOrderTimelineCount;
    }
}
#pragma warning restore ET0004