From 4d9ddf334f3aebf5baf4365653ce60d268c58534 Mon Sep 17 00:00:00 2001 From: Daniel Wolf Date: Tue, 14 Nov 2017 21:21:05 +0100 Subject: [PATCH] Loading Spine JSON file --- .gitignore | 1 - extras/rhubarb-for-spine/.gitignore | 13 +- .../.idea/codeStyleSettings.xml | 9 + extras/rhubarb-for-spine/.idea/compiler.xml | 9 + extras/rhubarb-for-spine/.idea/kotlinc.xml | 7 + extras/rhubarb-for-spine/.idea/misc.xml | 6 + extras/rhubarb-for-spine/.idea/modules.xml | 10 + extras/rhubarb-for-spine/.idea/vcs.xml | 6 + extras/rhubarb-for-spine/build.gradle | 26 +-- .../gradle/wrapper/gradle-wrapper.properties | 4 +- .../rhubarb_for_spine/AnimationFileModel.kt | 11 ++ .../rhubarb_for_spine/ErrorProperty.kt | 80 ++++++++ .../rhubarb_for_spine/MainApp.kt | 5 + .../rhubarb_for_spine/MainModel.kt | 50 +++++ .../rhubarb_for_spine/MainView.kt | 77 ++++++++ .../rhubarb_for_spine/MouthCue.kt | 3 + .../rhubarb_for_spine/MouthNaming.kt | 48 +++++ .../rhubarb_for_spine/MouthShape.kt | 5 + .../rhubarb_for_spine/Progress.kt | 6 + .../rhubarb_for_spine/RhubarbTask.kt | 173 ++++++++++++++++++ .../rhubarb_for_spine/SpineJson.kt | 141 ++++++++++++++ .../rhubarb_for_spine/main.kt | 7 + .../rhubarb_for_spine/tools.kt | 23 +++ 23 files changed, 704 insertions(+), 16 deletions(-) create mode 100644 extras/rhubarb-for-spine/.idea/codeStyleSettings.xml create mode 100644 extras/rhubarb-for-spine/.idea/compiler.xml create mode 100644 extras/rhubarb-for-spine/.idea/kotlinc.xml create mode 100644 extras/rhubarb-for-spine/.idea/misc.xml create mode 100644 extras/rhubarb-for-spine/.idea/modules.xml create mode 100644 extras/rhubarb-for-spine/.idea/vcs.xml create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthCue.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthNaming.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MouthShape.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/Progress.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/RhubarbTask.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/SpineJson.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/main.kt create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/tools.kt 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 +}