Add support for Spine 3.8 JSON format

Fixes #74
This commit is contained in:
Daniel Wolf 2019-07-17 21:46:23 +02:00
parent 01d37d110a
commit 5fcc8816f4
5 changed files with 199 additions and 5 deletions

View File

@ -3,6 +3,7 @@
## Unreleased ## 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** 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. * **Improved** animation rule for OW sound: animating it as E-F rather than F.
## Version 1.9.1 ## Version 1.9.1

View File

@ -22,8 +22,13 @@ class SpineJson(private val filePath: Path) {
throw EndUserException("Wrong file format. This is not a valid JSON file.") throw EndUserException("Wrong file format. This is not a valid JSON file.")
} }
skeleton = json.obj("skeleton") ?: throw EndUserException("JSON file is corrupted.") skeleton = json.obj("skeleton") ?: throw EndUserException("JSON file is corrupted.")
val skins = json.obj("skins") ?: throw EndUserException("JSON file doesn't contain skins.") val skins = json["skins"] ?: throw EndUserException("JSON file doesn't contain skins.")
defaultSkin = skins.obj("default") ?: throw EndUserException("JSON file doesn't have a default skin.") defaultSkin = when (skins) {
is JsonObject -> skins.obj("default")
is JsonArray<*> -> (skins as JsonArray<JsonObject>).find { it.string("name") == "default" }
else -> null
} ?: throw EndUserException("JSON file doesn't have a default skin.")
validateProperties() validateProperties()
} }
@ -91,7 +96,9 @@ class SpineJson(private val filePath: Path) {
} }
fun getSlotAttachmentNames(slotName: String): List<String> { fun getSlotAttachmentNames(slotName: String): List<String> {
val attachments = defaultSkin.obj(slotName) ?: JsonObject() val attachments = defaultSkin.obj(slotName)
?: defaultSkin.obj("attachments")?.obj(slotName)
?: JsonObject()
return attachments.map { it.key } return attachments.map { it.key }
} }
@ -142,8 +149,11 @@ class SpineJson(private val filePath: Path) {
animationNames.add(animationName) animationNames.add(animationName)
} }
override fun toString(): String {
return json.toJsonString(prettyPrint = true)
}
fun save() { fun save() {
var string = json.toJsonString(prettyPrint = true) Files.write(filePath, listOf(toString()), StandardCharsets.UTF_8)
Files.write(filePath, listOf(string), StandardCharsets.UTF_8)
} }
} }

View File

@ -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 }
]
}
}
}
}
}

View File

@ -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 }
]
}
}
}
}
}

View File

@ -36,4 +36,34 @@ class SpineJsonTest {
.hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.") .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.")
}
}
} }