diff --git a/CHANGELOG.md b/CHANGELOG.md index da932ff..afe1e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased * **Added** switch data file exporter for Moho (formerly Anime Studio) and OpenToonz ([issue #69](https://github.com/DanielSWolf/rhubarb-lip-sync/issues/69)) +* **Added** support for Spine 3.8 beta ([issue #74](https://github.com/DanielSWolf/rhubarb-lip-sync/issues/74)) * **Improved** animation rule for OW sound: animating it as E-F rather than F. ## Version 1.9.1 diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/SpineJson.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/SpineJson.kt index eb8688c..22d4bef 100644 --- a/extras/EsotericSoftwareSpine/src/main/kotlin/SpineJson.kt +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/SpineJson.kt @@ -22,8 +22,13 @@ class SpineJson(private val filePath: Path) { throw EndUserException("Wrong file format. This is not a valid JSON file.") } skeleton = json.obj("skeleton") ?: throw EndUserException("JSON file is corrupted.") - val skins = json.obj("skins") ?: throw EndUserException("JSON file doesn't contain skins.") - defaultSkin = skins.obj("default") ?: throw EndUserException("JSON file doesn't have a default skin.") + val skins = json["skins"] ?: throw EndUserException("JSON file doesn't contain skins.") + defaultSkin = when (skins) { + is JsonObject -> skins.obj("default") + is JsonArray<*> -> (skins as JsonArray).find { it.string("name") == "default" } + else -> null + } ?: throw EndUserException("JSON file doesn't have a default skin.") + validateProperties() } @@ -91,7 +96,9 @@ class SpineJson(private val filePath: Path) { } fun getSlotAttachmentNames(slotName: String): List { - val attachments = defaultSkin.obj(slotName) ?: JsonObject() + val attachments = defaultSkin.obj(slotName) + ?: defaultSkin.obj("attachments")?.obj(slotName) + ?: JsonObject() return attachments.map { it.key } } @@ -142,8 +149,11 @@ class SpineJson(private val filePath: Path) { animationNames.add(animationName) } + override fun toString(): String { + return json.toJsonString(prettyPrint = true) + } + fun save() { - var string = json.toJsonString(prettyPrint = true) - Files.write(filePath, listOf(string), StandardCharsets.UTF_8) + Files.write(filePath, listOf(toString()), StandardCharsets.UTF_8) } } \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/test/data/jsonFiles/matt-3.8-essential.json b/extras/EsotericSoftwareSpine/src/test/data/jsonFiles/matt-3.8-essential.json new file mode 100644 index 0000000..5c63e75 --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/test/data/jsonFiles/matt-3.8-essential.json @@ -0,0 +1,72 @@ +{ +"skeleton": { "hash": "rSEJPpMBeapC2jv56cUew+IkQd0", "spine": "3.8.42-beta", "x": -394.13, "y": -0.43, "width": 795, "height": 1249.62 }, +"bones": [ + { "name": "root" }, + { "name": "torso", "parent": "root", "length": 394.49, "rotation": 90, "y": 100 }, + { "name": "head", "parent": "torso", "length": 515.83, "x": 390 }, + { "name": "legs", "parent": "torso", "length": 79.85, "rotation": 180, "x": -6 } +], +"slots": [ + { "name": "legs", "bone": "legs", "attachment": "legs" }, + { "name": "torso", "bone": "torso", "attachment": "torso" }, + { "name": "head", "bone": "head", "attachment": "head" }, + { "name": "mouth", "bone": "head", "attachment": "mouth_c" } +], +"skins": [ + { + "name": "default", + "attachments": { + "mouth": { + "mouth_a": { "x": -53.21, "y": -2.6, "rotation": -90, "width": 118, "height": 27 }, + "mouth_b": { "x": -38.68, "y": -0.88, "rotation": -90, "width": 170, "height": 59 }, + "mouth_c": { "x": -45.57, "y": -2.21, "rotation": -90, "width": 145, "height": 71 }, + "mouth_d": { "x": -50.58, "y": -16.55, "rotation": -90, "width": 122, "height": 91 }, + "mouth_e": { "x": -47.51, "y": 1.69, "rotation": -90, "width": 105, "height": 73 }, + "mouth_f": { "x": -42.7, "y": -1.9, "rotation": -90, "width": 55, "height": 54 }, + "mouth_g": { "x": -42.77, "y": 2.56, "rotation": -90, "width": 141, "height": 37 }, + "mouth_h": { "x": -44.53, "y": 1.07, "rotation": -90, "width": 141, "height": 71 } + }, + "head": { + "head": { "x": 305.19, "y": -3.37, "rotation": -90, "width": 795, "height": 908 } + }, + "legs": { + "legs": { "x": 20.93, "y": 9.45, "rotation": 90, "width": 602, "height": 147 } + }, + "torso": { + "torso": { "x": 185.9, "y": 0.39, "rotation": -90, "width": 741, "height": 449 } + } + } + } +], +"events": { + "1-have-you-heard": { "audio": "1-have-you-heard.wav" }, + "2-it's-a-tool": { "audio": "2-it's-a-tool.wav" }, + "3-and-now-you-can": { "audio": "3-and-now-you-can.wav" } +}, +"animations": { + "shake_head": { + "bones": { + "head": { + "rotate": [ + { "curve": 0.25, "c3": 0.75 }, + { "time": 0.1667, "angle": 10.02, "curve": 0.25, "c3": 0.75 }, + { "time": 0.5, "angle": -9.37, "curve": 0.25, "c3": 0.75 }, + { "time": 0.8333, "angle": 10.39, "curve": 0.574, "c3": 0.666 }, + { "time": 1.5 } + ] + } + } + }, + "walk": { + "bones": { + "torso": { + "translate": [ + { "curve": 0, "c2": 0.5, "c3": 0.75 }, + { "time": 0.1333, "y": 30, "curve": 0.25, "c4": 0.49 }, + { "time": 0.2667 } + ] + } + } + } +} +} \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/test/data/jsonFiles/matt-3.8.json b/extras/EsotericSoftwareSpine/src/test/data/jsonFiles/matt-3.8.json new file mode 100644 index 0000000..583d70b --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/test/data/jsonFiles/matt-3.8.json @@ -0,0 +1,81 @@ +{ +"skeleton": { + "hash": "sH1atSHvppLIr/A6E6H7PXWiU4s", + "spine": "3.8.42-beta", + "x": -394.13, + "y": -0.43, + "width": 795, + "height": 1249.62, + "images": "./images/", + "audio": "./audio/" +}, +"bones": [ + { "name": "root" }, + { "name": "torso", "parent": "root", "length": 394.49, "rotation": 90, "y": 100 }, + { "name": "head", "parent": "torso", "length": 515.83, "x": 390 }, + { "name": "legs", "parent": "torso", "length": 79.85, "rotation": 180, "x": -6 } +], +"slots": [ + { "name": "legs", "bone": "legs", "attachment": "legs" }, + { "name": "torso", "bone": "torso", "attachment": "torso" }, + { "name": "head", "bone": "head", "attachment": "head" }, + { "name": "mouth", "bone": "head", "attachment": "mouth_c" } +], +"skins": [ + { + "name": "default", + "attachments": { + "mouth": { + "mouth_a": { "x": -53.21, "y": -2.6, "rotation": -90, "width": 118, "height": 27 }, + "mouth_b": { "x": -38.68, "y": -0.88, "rotation": -90, "width": 170, "height": 59 }, + "mouth_c": { "x": -45.57, "y": -2.21, "rotation": -90, "width": 145, "height": 71 }, + "mouth_d": { "x": -50.58, "y": -16.55, "rotation": -90, "width": 122, "height": 91 }, + "mouth_e": { "x": -47.51, "y": 1.69, "rotation": -90, "width": 105, "height": 73 }, + "mouth_f": { "x": -42.7, "y": -1.9, "rotation": -90, "width": 55, "height": 54 }, + "mouth_g": { "x": -42.77, "y": 2.56, "rotation": -90, "width": 141, "height": 37 }, + "mouth_h": { "x": -44.53, "y": 1.07, "rotation": -90, "width": 141, "height": 71 } + }, + "head": { + "head": { "x": 305.19, "y": -3.37, "rotation": -90, "width": 795, "height": 908 } + }, + "legs": { + "legs": { "x": 20.93, "y": 9.45, "rotation": 90, "width": 602, "height": 147 } + }, + "torso": { + "torso": { "x": 185.9, "y": 0.39, "rotation": -90, "width": 741, "height": 449 } + } + } + } +], +"events": { + "1-have-you-heard": { "audio": "1-have-you-heard.wav" }, + "2-it's-a-tool": { "audio": "2-it's-a-tool.wav" }, + "3-and-now-you-can": { "audio": "3-and-now-you-can.wav" } +}, +"animations": { + "shake_head": { + "bones": { + "head": { + "rotate": [ + { "curve": 0.25, "c3": 0.75 }, + { "time": 0.1667, "angle": 10.02, "curve": 0.25, "c3": 0.75 }, + { "time": 0.5, "angle": -9.37, "curve": 0.25, "c3": 0.75 }, + { "time": 0.8333, "angle": 10.39, "curve": 0.574, "c3": 0.666 }, + { "time": 1.5 } + ] + } + } + }, + "walk": { + "bones": { + "torso": { + "translate": [ + { "curve": 0, "c2": 0.5, "c3": 0.75 }, + { "time": 0.1333, "y": 30, "curve": 0.25, "c4": 0.49 }, + { "time": 0.2667 } + ] + } + } + } +} +} \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/test/kotlin/SpineJsonTest.kt b/extras/EsotericSoftwareSpine/src/test/kotlin/SpineJsonTest.kt index 85e8258..166054f 100644 --- a/extras/EsotericSoftwareSpine/src/test/kotlin/SpineJsonTest.kt +++ b/extras/EsotericSoftwareSpine/src/test/kotlin/SpineJsonTest.kt @@ -36,4 +36,34 @@ class SpineJsonTest { .hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.") } } + + @Nested + inner class `file format 3_8` { + @Test + fun `correctly reads valid file`() { + val path = Paths.get("src/test/data/jsonFiles/matt-3.8.json").toAbsolutePath() + val spine = SpineJson(path) + + assertThat(spine.audioDirectoryPath) + .isEqualTo(Paths.get("src/test/data/jsonFiles/audio").toAbsolutePath()) + assertThat(spine.frameRate).isEqualTo(30.0) + assertThat(spine.slots).containsExactly("legs", "torso", "head", "mouth") + assertThat(spine.guessMouthSlot()).isEqualTo("mouth") + assertThat(spine.audioEvents).containsExactly( + SpineJson.AudioEvent("1-have-you-heard", "1-have-you-heard.wav", null), + SpineJson.AudioEvent("2-it's-a-tool", "2-it's-a-tool.wav", null), + SpineJson.AudioEvent("3-and-now-you-can", "3-and-now-you-can.wav", null) + ) + assertThat(spine.getSlotAttachmentNames("mouth")).isEqualTo(('a'..'h').map{ "mouth_$it" }) + assertThat(spine.animationNames).containsExactly("shake_head", "walk") + } + + @Test + fun `throws on file without nonessential data`() { + val path = Paths.get("src/test/data/jsonFiles/matt-3.8-essential.json").toAbsolutePath() + val throwable = catchThrowable { SpineJson(path) } + assertThat(throwable) + .hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.") + } + } }