diff --git a/.gitignore b/.gitignore
index 717f29f..3b6a86f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-.idea/
.vs/
build/
*.user
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.gitignore b/extras/rhubarb-for-spine/.gitignore
index e02cdcb..5889e76 100644
--- a/extras/rhubarb-for-spine/.gitignore
+++ b/extras/rhubarb-for-spine/.gitignore
@@ -1,4 +1,13 @@
+# User-specific files:
+/.idea/**/workspace.xml
+/.idea/**/tasks.xml
+/.idea/dictionaries/
+
+# Gradle:
/.gradle/
-/.idea/workspace.xml
-/.idea/tasks.xml
+/.idea/**/gradle.xml
+/.idea/**/libraries/
+/.idea/**/*.iml
+
/build/
+/out/
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.idea/codeStyleSettings.xml b/extras/rhubarb-for-spine/.idea/codeStyleSettings.xml
new file mode 100644
index 0000000..5555dd2
--- /dev/null
+++ b/extras/rhubarb-for-spine/.idea/codeStyleSettings.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.idea/compiler.xml b/extras/rhubarb-for-spine/.idea/compiler.xml
new file mode 100644
index 0000000..34ed3d3
--- /dev/null
+++ b/extras/rhubarb-for-spine/.idea/compiler.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.idea/kotlinc.xml b/extras/rhubarb-for-spine/.idea/kotlinc.xml
new file mode 100644
index 0000000..5806fb3
--- /dev/null
+++ b/extras/rhubarb-for-spine/.idea/kotlinc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.idea/misc.xml b/extras/rhubarb-for-spine/.idea/misc.xml
new file mode 100644
index 0000000..e208459
--- /dev/null
+++ b/extras/rhubarb-for-spine/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.idea/modules.xml b/extras/rhubarb-for-spine/.idea/modules.xml
new file mode 100644
index 0000000..58be9c9
--- /dev/null
+++ b/extras/rhubarb-for-spine/.idea/modules.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/.idea/vcs.xml b/extras/rhubarb-for-spine/.idea/vcs.xml
new file mode 100644
index 0000000..b2bdec2
--- /dev/null
+++ b/extras/rhubarb-for-spine/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/build.gradle b/extras/rhubarb-for-spine/build.gradle
index 8b6f299..d39c4cf 100644
--- a/extras/rhubarb-for-spine/build.gradle
+++ b/extras/rhubarb-for-spine/build.gradle
@@ -13,29 +13,33 @@ group 'com.rhubarb_lip_sync'
version = getVersion()
buildscript {
- ext.kotlin_version = '1.1.4-3'
+ ext.kotlin_version = '1.1.60'
- repositories {
- mavenCentral()
- }
- dependencies {
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- }
+ repositories {
+ mavenCentral()
+ }
+ dependencies {
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
}
apply plugin: 'kotlin'
repositories {
- mavenCentral()
+ mavenCentral()
+ jcenter()
}
dependencies {
- compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
+ compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
+ compile 'com.beust:klaxon:0.30'
+ compile 'org.apache.commons:commons-lang3:3.7'
+ compile 'no.tornado:tornadofx:1.7.12'
}
compileKotlin {
- kotlinOptions.jvmTarget = "1.8"
+ kotlinOptions.jvmTarget = '1.8'
}
compileTestKotlin {
- kotlinOptions.jvmTarget = "1.8"
+ kotlinOptions.jvmTarget = '1.8'
}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/gradle/wrapper/gradle-wrapper.properties b/extras/rhubarb-for-spine/gradle/wrapper/gradle-wrapper.properties
index c50fb35..22c733c 100644
--- a/extras/rhubarb-for-spine/gradle/wrapper/gradle-wrapper.properties
+++ b/extras/rhubarb-for-spine/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Tue Nov 14 20:30:34 CET 2017
+#Tue Nov 28 18:16:46 CET 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-3.5-rc-2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-3.5-rc-2-all.zip
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt
new file mode 100644
index 0000000..1c1a1d0
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt
@@ -0,0 +1,11 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import java.nio.file.Path
+
+class AnimationFileModel(animationFilePath: Path) {
+ val spineJson: SpineJson
+
+ init {
+ spineJson = SpineJson(animationFilePath)
+ }
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt
new file mode 100644
index 0000000..3f20a63
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt
@@ -0,0 +1,80 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import javafx.beans.property.SimpleStringProperty
+import javafx.beans.property.StringProperty
+import javafx.beans.value.ObservableValue
+import javafx.scene.Group
+import javafx.scene.Node
+import javafx.scene.Parent
+import javafx.scene.control.Tooltip
+import javafx.scene.paint.Color
+import tornadofx.addChildIfPossible
+import tornadofx.circle
+import tornadofx.rectangle
+import tornadofx.removeFromParent
+
+fun renderErrorIndicator(): Node {
+ return Group().apply {
+ isManaged = false
+ circle {
+ radius = 7.0
+ fill = Color.ORANGERED
+ }
+ rectangle {
+ x = -1.0
+ y = -5.0
+ width = 2.0
+ height = 7.0
+ fill = Color.WHITE
+ }
+ rectangle {
+ x = -1.0
+ y = 3.0
+ width = 2.0
+ height = 2.0
+ fill = Color.WHITE
+ }
+ }
+}
+
+fun Parent.errorProperty() : StringProperty {
+ return properties.getOrPut("rhubarb.errorProperty", {
+ val errorIndicator: Node = renderErrorIndicator()
+ val tooltip = Tooltip()
+ val property = SimpleStringProperty()
+
+ fun updateTooltipVisibility() {
+ if (tooltip.text.isNotEmpty() && isFocused) {
+ val bounds = localToScreen(boundsInLocal)
+ tooltip.show(scene.window, bounds.minX + 5, bounds.maxY + 2)
+ } else {
+ tooltip.hide()
+ }
+ }
+
+ focusedProperty().addListener({
+ _: ObservableValue, _: Boolean, _: Boolean ->
+ updateTooltipVisibility()
+ })
+
+ property.addListener({
+ _: ObservableValue, _: String?, newValue: String? ->
+
+ if (newValue != null) {
+ this.addChildIfPossible(errorIndicator)
+
+ tooltip.text = newValue
+ Tooltip.install(this, tooltip)
+ updateTooltipVisibility()
+ } else {
+ errorIndicator.removeFromParent()
+
+ tooltip.text = ""
+ tooltip.hide()
+ Tooltip.uninstall(this, tooltip)
+ updateTooltipVisibility()
+ }
+ })
+ return@getOrPut property
+ }) as StringProperty
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt
new file mode 100644
index 0000000..0d21765
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt
@@ -0,0 +1,5 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import tornadofx.App
+
+class MainApp : App(MainView::class)
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt
new file mode 100644
index 0000000..ca3dcb2
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt
@@ -0,0 +1,50 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import javafx.beans.property.SimpleObjectProperty
+import javafx.beans.property.SimpleStringProperty
+import tornadofx.FX
+import tornadofx.getValue
+import tornadofx.setValue
+import java.nio.file.Files
+import java.nio.file.InvalidPathException
+import java.nio.file.Paths
+
+class MainModel {
+ val filePathStringProperty = SimpleStringProperty(getDefaultPathString()).applyListener { value ->
+ try {
+ animationFileModel = null
+ if (value.isNullOrBlank()) {
+ throw Exception("No input file specified.")
+ }
+
+ val path = try {
+ val trimmed = value.removeSurrounding("\"")
+ Paths.get(trimmed)
+ } catch (e: InvalidPathException) {
+ throw Exception("Not a valid file path.")
+ }
+
+ if (!Files.exists(path)) {
+ throw Exception("File does not exist.")
+ }
+
+ animationFileModel = AnimationFileModel(path)
+
+ filePathError = null
+ } catch (e: Exception) {
+ filePathError = e.message
+ }
+ }
+
+ var filePathString by filePathStringProperty
+
+ val filePathErrorProperty = SimpleStringProperty()
+ var filePathError by filePathErrorProperty
+ private set
+
+ val animationFileModelProperty = SimpleObjectProperty()
+ var animationFileModel by animationFileModelProperty
+ private set
+
+ private fun getDefaultPathString() = FX.application.parameters.raw.firstOrNull()
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt
new file mode 100644
index 0000000..85d5b72
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt
@@ -0,0 +1,77 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import javafx.event.EventHandler
+import javafx.scene.control.TextField
+import javafx.scene.input.DragEvent
+import javafx.scene.input.TransferMode
+import tornadofx.*
+import java.time.LocalDate
+import java.time.Period
+
+class MainView : View() {
+
+ val mainModel = MainModel()
+
+ class Person(val id: Int, val name: String, val birthday: LocalDate) {
+ val age: Int get() = Period.between(birthday, LocalDate.now()).years
+ }
+
+ private val persons = listOf(
+ Person(1,"Samantha Stuart",LocalDate.of(1981,12,4)),
+ Person(2,"Tom Marks",LocalDate.of(2001,1,23)),
+ Person(3,"Stuart Gills",LocalDate.of(1989,5,23)),
+ Person(3,"Nicole Williams",LocalDate.of(1998,8,11))
+ ).observable()
+
+ init {
+ title = "Rhubarb Lip Sync for Spine"
+ }
+
+ override val root = form {
+ var filePathField: TextField? = null
+
+ minWidth = 800.0
+ fieldset("Settings") {
+ field("Spine JSON file") {
+ filePathField = textfield {
+ textProperty().bindBidirectional(mainModel.filePathStringProperty)
+ tooltip("Hello world")
+ errorProperty().bind(mainModel.filePathErrorProperty)
+ }
+ button("...")
+ }
+ field("Mouth slot") {
+ textfield()
+ }
+ field("Mouth naming") {
+ datepicker()
+ }
+ field("Mouth shapes") {
+ textfield()
+ }
+ }
+ fieldset("Audio events") {
+ tableview(persons) {
+ column("Event", Person::id)
+ column("Audio file", Person::name)
+ column("Dialog", Person::birthday)
+ column("Status", Person::age)
+ column("", Person::age)
+ }
+ }
+
+ onDragOver = EventHandler { event ->
+ if (event.dragboard.hasFiles()) {
+ event.acceptTransferModes(TransferMode.COPY)
+ event.consume()
+ }
+ }
+ onDragDropped = EventHandler { event ->
+ if (event.dragboard.hasFiles()) {
+ filePathField!!.text = event.dragboard.files.firstOrNull()?.path
+ event.isDropCompleted = true
+ event.consume()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthCue.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthCue.kt
new file mode 100644
index 0000000..4e85edf
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthCue.kt
@@ -0,0 +1,3 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+data class MouthCue(val time: Double, val mouthShape: MouthShape)
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthNaming.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthNaming.kt
new file mode 100644
index 0000000..25dbc21
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthNaming.kt
@@ -0,0 +1,48 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import java.util.*
+
+class MouthNaming(val prefix: String, val suffix: String, val mouthShapeCasing: MouthShapeCasing) {
+
+ companion object {
+ fun guess(mouthNames: List): MouthNaming {
+ if (mouthNames.isEmpty()) return MouthNaming("", "", guessMouthShapeCasing(""))
+
+ val commonPrefix = mouthNames.commonPrefix
+ val commonSuffix = mouthNames.commonSuffix
+ val shapeName = mouthNames.first().substring(
+ commonPrefix.length,
+ mouthNames.first().length - commonSuffix.length)
+ val mouthShapeCasing = guessMouthShapeCasing(shapeName)
+ return MouthNaming(commonPrefix, commonSuffix, mouthShapeCasing)
+ }
+
+ private fun guessMouthShapeCasing(shapeName: String): MouthShapeCasing {
+ return if (shapeName.isBlank() || shapeName[0].isLowerCase())
+ MouthShapeCasing.Lower
+ else
+ MouthShapeCasing.Upper
+ }
+ }
+
+ fun getName(mouthShape: MouthShape): String {
+ val name = if (mouthShapeCasing == MouthShapeCasing.Upper)
+ mouthShape.toString()
+ else
+ mouthShape.toString().toLowerCase(Locale.ROOT)
+ return "$prefix$name$suffix"
+ }
+
+ val displayString: String get() {
+ val casing = if (mouthShapeCasing == MouthShapeCasing.Upper)
+ ""
+ else
+ ""
+ return "\"$prefix$casing$suffix\""
+ }
+}
+
+enum class MouthShapeCasing {
+ Upper,
+ Lower
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthShape.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthShape.kt
new file mode 100644
index 0000000..5f27ec0
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthShape.kt
@@ -0,0 +1,5 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+enum class MouthShape {
+ A, B, C, D, E, F, G, H, X
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/Progress.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/Progress.kt
new file mode 100644
index 0000000..d8ce1bf
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/Progress.kt
@@ -0,0 +1,6 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+// Modeled after C#'s IProgress
+interface Progress {
+ fun reportProgress(progress: Double)
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/RhubarbTask.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/RhubarbTask.kt
new file mode 100644
index 0000000..24ede9f
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/RhubarbTask.kt
@@ -0,0 +1,173 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import com.beust.klaxon.JsonObject
+import com.beust.klaxon.array
+import com.beust.klaxon.double
+import com.beust.klaxon.string
+import com.beust.klaxon.Parser as JsonParser
+import org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS
+import java.io.BufferedReader
+import java.io.EOFException
+import java.io.InputStreamReader
+import java.io.StringReader
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.time.Duration
+import java.util.concurrent.Callable
+
+class RhubarbTask(
+ val audioFilePath: Path,
+ val dialog: String?,
+ val extendedMouthShapes: Set,
+ val progress: Progress
+) : Callable> {
+
+ override fun call(): List {
+ if (Thread.currentThread().isInterrupted) {
+ throw InterruptedException()
+ }
+ if (!Files.exists(audioFilePath)) {
+ throw IllegalArgumentException("File '$audioFilePath' does not exist.");
+ }
+
+
+ val dialogFile = if (dialog != null) TemporaryTextFile(dialog) else null
+ dialogFile.use {
+ val processBuilder = ProcessBuilder(createProcessBuilderArgs(dialogFile?.filePath))
+ val process: Process = processBuilder.start()
+ val stdout = BufferedReader(InputStreamReader(process.inputStream, StandardCharsets.UTF_8))
+ val stderr = BufferedReader(InputStreamReader(process.errorStream, StandardCharsets.UTF_8))
+ try {
+ while (true) {
+ val line = stderr.interruptibleReadLine()
+
+ val message = parseJsonObject(line)
+ when (message.string("type")!!) {
+ "progress" -> {
+ progress.reportProgress(message.double("value")!!)}
+ "success" -> {
+ progress.reportProgress(1.0)
+ val resultString = stdout.readText()
+ return parseRhubarbResult(resultString)
+ }
+ "failure" -> {
+ throw Exception(message.string("reason"))
+ }
+ }
+ }
+ } catch (e: InterruptedException) {
+ process.destroyForcibly()
+ throw e
+ } catch (e: EOFException) {
+ throw Exception("Rhubarb terminated unexpectedly.")
+ }
+ }
+
+ throw Exception("An unexpected error occurred.")
+ }
+
+ private fun parseRhubarbResult(jsonString: String): List {
+ val json = parseJsonObject(jsonString)
+ val mouthCues = json.array("mouthCues")!!
+ return mouthCues.map { mouthCue ->
+ val time = mouthCue.double("start")!!
+ val mouthShape = MouthShape.valueOf(mouthCue.string("value")!!)
+ return@map MouthCue(time, mouthShape)
+ }
+ }
+
+ private val jsonParser = JsonParser()
+ private fun parseJsonObject(jsonString: String): JsonObject {
+ return jsonParser.parse(StringReader(jsonString)) as JsonObject
+ }
+
+ private fun createProcessBuilderArgs(dialogFilePath: Path?): List {
+ val extendedMouthShapesString =
+ if (extendedMouthShapes.any()) extendedMouthShapes.joinToString(separator = "")
+ else "\"\""
+ return mutableListOf(
+ rhubarbBinFilePath.toString(),
+ "--machineReadable",
+ "--exportFormat", "json",
+ "--extendedShapes", extendedMouthShapesString
+ ).apply {
+ if (dialogFilePath != null) {
+ addAll(listOf(
+ "--dialogFile", dialogFilePath.toString()
+ ))
+ }
+ }.apply {
+ add(audioFilePath.toString())
+ }
+
+ }
+
+ private val guiBinDirectory: Path by lazy {
+ var path: String = ClassLoader.getSystemClassLoader().getResource(".")!!.path
+ if (path.length >= 3 && path[2] == ':') {
+ // Workaround for https://stackoverflow.com/questions/9834776/java-nio-file-path-issue
+ path = path.substring(1)
+ }
+ return@lazy Paths.get(path)
+ }
+
+ private val rhubarbBinFilePath: Path by lazy {
+ val rhubarbBinName = if (IS_OS_WINDOWS) "rhubarb.exe" else "rhubarb"
+ var currentDirectory: Path? = guiBinDirectory
+ while (currentDirectory != null) {
+ val candidate: Path = currentDirectory.resolve(rhubarbBinName)
+ if (Files.exists(candidate)) {
+ return@lazy candidate
+ }
+ currentDirectory = currentDirectory.parent
+ }
+ throw Exception("Could not find Rhubarb Lip Sync executable '$rhubarbBinName'."
+ + " Expected to find it in '$guiBinDirectory' or any directory above.")
+ }
+
+ private class TemporaryTextFile(val text: String) : AutoCloseable {
+ val filePath: Path = Files.createTempFile(null, null).also {
+ Files.write(it, text.toByteArray(StandardCharsets.UTF_8))
+ }
+
+ override fun close() {
+ Files.delete(filePath)
+ }
+
+ }
+
+ // Same as readLine, but can be interrupted.
+ // Note that this function handles linebreak characters differently from readLine.
+ // It only consumes the first linebreak character before returning and swallows any leading
+ // linebreak characters.
+ // This behavior is much easier to implement and doesn't make any difference for our purposes.
+ private fun BufferedReader.interruptibleReadLine(): String {
+ val result = StringBuilder()
+ while (true) {
+ val char = interruptibleReadChar()
+ if (char == '\r' || char == '\n') {
+ if (result.isNotEmpty()) return result.toString()
+ } else {
+ result.append(char)
+ }
+ }
+ }
+
+ private fun BufferedReader.interruptibleReadChar(): Char {
+ while (true) {
+ if (Thread.currentThread().isInterrupted) {
+ throw InterruptedException()
+ }
+ if (ready()) {
+ val result: Int = read()
+ if (result == -1) {
+ throw EOFException()
+ }
+ return result.toChar()
+ }
+ Thread.yield()
+ }
+ }
+}
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/SpineJson.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/SpineJson.kt
new file mode 100644
index 0000000..58c3728
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/SpineJson.kt
@@ -0,0 +1,141 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import com.beust.klaxon.*
+import java.nio.file.Files
+import java.nio.file.Path
+
+class SpineJson(val filePath: Path) {
+ val fileDirectoryPath: Path = filePath.parent
+ val json: JsonObject
+ private val skeleton: JsonObject
+ private val defaultSkin: JsonObject
+
+ init {
+ if (!Files.exists(filePath)) {
+ throw Exception("File '$filePath' does not exist.")
+ }
+ try {
+ json = Parser().parse(filePath.toString()) as JsonObject
+ } catch (e: Exception) {
+ throw Exception("Wrong file format. This is not a valid JSON file.")
+ }
+ skeleton = json.obj("skeleton") ?: throw Exception("JSON file is corrupted.")
+ val skins = json.obj("skins") ?: throw Exception("JSON file doesn't contain skins.")
+ defaultSkin = skins.obj("default") ?: throw Exception("JSON file doesn't have a default skin.")
+ validateProperties()
+ }
+
+ private fun validateProperties() {
+ imagesDirectoryPath
+ audioDirectoryPath
+ }
+
+ val imagesDirectoryPath: Path get() {
+ val relativeImagesDirectory = skeleton.string("images")
+ ?: throw Exception("JSON file is incomplete: Images path is missing."
+ + "Make sure to check 'Nonessential data' when exporting.")
+
+ val imagesDirectoryPath = fileDirectoryPath.resolve(relativeImagesDirectory)
+ if (!Files.exists(imagesDirectoryPath)) {
+ throw Exception("Could not find images directory relative to the JSON file."
+ + " Make sure the JSON file is in the same directory as the original Spine file.")
+ }
+
+ return imagesDirectoryPath
+ }
+
+ val audioDirectoryPath: Path get() {
+ val relativeAudioDirectory = skeleton.string("audio")
+ ?: throw Exception("JSON file is incomplete: Audio path is missing."
+ + "Make sure to check 'Nonessential data' when exporting.")
+
+ val audioDirectoryPath = fileDirectoryPath.resolve(relativeAudioDirectory)
+ if (!Files.exists(audioDirectoryPath)) {
+ throw Exception("Could not find audio directory relative to the JSON file."
+ + " Make sure the JSON file is in the same directory as the original Spine file.")
+ }
+
+ return audioDirectoryPath
+ }
+
+ val frameRate: Double get() {
+ return skeleton.double("fps") ?: 30.0
+ }
+
+ val slots: List get() {
+ val slots = json.array("slots") ?: listOf()
+ return slots.mapNotNull { it.string("name") }
+ }
+
+ val presumedMouthSlot: String? get() {
+ return slots.firstOrNull { it.contains("mouth", ignoreCase = true) }
+ ?: slots.firstOrNull()
+ }
+
+ data class AudioEvent(val name: String, val relativeAudioFilePath: String, val dialog: String?)
+
+ val audioEvents: List get() {
+ val events = json.obj("events") ?: JsonObject()
+ val result = mutableListOf()
+ for ((name, value) in events) {
+ if (value !is JsonObject) throw Exception("Invalid event found.")
+
+ val relativeAudioFilePath = value.string("audio") ?: continue
+
+ val dialog = value.string("string")
+ result.add(AudioEvent(name, relativeAudioFilePath, dialog))
+ }
+ return result
+ }
+
+ fun getSlotAttachmentNames(slotName: String): List {
+ val attachments = defaultSkin.obj(slotName) ?: JsonObject()
+ return attachments.map { it.key }
+ }
+
+ fun hasAnimation(animationName: String): Boolean {
+ val animations = json.obj("animations") ?: return false
+ return animations.any { it.key == animationName }
+ }
+
+ fun createOrUpdateAnimation(mouthCues: List, eventName: String, animationName: String,
+ mouthSlot: String, mouthNaming: MouthNaming
+ ) {
+ if (!json.containsKey("animations")) {
+ json["animations"] = JsonObject()
+ }
+ val animations: JsonObject = json.obj("animations")!!
+
+ // Round times to full frames. Always round down.
+ // If events coincide, prefer the latest one.
+ val keyframes = mutableMapOf()
+ for (mouthCue in mouthCues) {
+ val frameNumber = (mouthCue.time * frameRate).toInt()
+ keyframes[frameNumber] = mouthCue.mouthShape
+ }
+
+ animations[animationName] = JsonObject().apply {
+ this["slots"] = JsonObject().apply {
+ this[mouthSlot] = JsonObject().apply {
+ this["attachment"] = JsonArray(
+ keyframes
+ .toSortedMap()
+ .map { (frameNumber, mouthShape) ->
+ JsonObject().apply {
+ this["time"] = frameNumber / frameRate
+ this["name"] = mouthNaming.getName(mouthShape)
+ }
+ }
+ )
+ }
+ }
+ this["events"] = JsonArray(
+ JsonObject().apply {
+ this["time"] = 0.0
+ this["name"] = eventName
+ this["string"] = ""
+ }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/main.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/main.kt
new file mode 100644
index 0000000..1a99446
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/main.kt
@@ -0,0 +1,7 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import javafx.application.Application
+
+fun main(args: Array) {
+ Application.launch(MainApp::class.java, *args)
+}
diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/tools.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/tools.kt
new file mode 100644
index 0000000..7a3cf50
--- /dev/null
+++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/tools.kt
@@ -0,0 +1,23 @@
+package com.rhubarb_lip_sync.rhubarb_for_spine
+
+import javafx.application.Platform
+import javafx.beans.property.Property
+
+val List.commonPrefix: String get() {
+ return if (isEmpty()) "" else this.reduce { result, string -> result.commonPrefixWith(string) }
+}
+
+val List.commonSuffix: String get() {
+ return if (isEmpty()) "" else this.reduce { result, string -> result.commonSuffixWith(string) }
+}
+
+fun > TProperty.applyListener(listener: (TValue) -> Unit) : TProperty {
+ // Notify the listener of the initial value.
+ // If we did this synchronously, the listener's state would have to be fully initialized the
+ // moment this function is called. So calling this function during object initialization might
+ // result in access to uninitialized state.
+ Platform.runLater { listener(this.value) }
+
+ addListener({ _, _, newValue -> listener(newValue)})
+ return this
+}