From 7bb1bec975848b3eb60844094f09f9d301e7bcc3 Mon Sep 17 00:00:00 2001 From: Daniel Wolf Date: Mon, 8 Jan 2018 15:05:31 +0100 Subject: [PATCH] Implemented round trip Spine - Rhubarb - Spine --- .../rhubarb_for_spine/AnimationFileModel.kt | 85 +++++++++- .../rhubarb_for_spine/AudioFileModel.kt | 158 ++++++++++++++++++ .../rhubarb_for_spine/MainModel.kt | 9 +- .../rhubarb_for_spine/MainView.kt | 105 +++++++++--- .../rhubarb_for_spine/MouthNaming.kt | 13 +- .../rhubarb_for_spine/MouthShape.kt | 16 +- .../rhubarb_for_spine/Progress.kt | 6 - .../rhubarb_for_spine/RhubarbTask.kt | 28 ++-- .../rhubarb_for_spine/SpineJson.kt | 8 +- .../rhubarb_for_spine/tools.kt | 50 +++++- 10 files changed, 420 insertions(+), 58 deletions(-) create mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt delete mode 100644 extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/Progress.kt 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 index 1c1a1d0..14a6df3 100644 --- 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 @@ -1,11 +1,92 @@ package com.rhubarb_lip_sync.rhubarb_for_spine +import javafx.beans.property.SimpleListProperty +import javafx.beans.property.SimpleObjectProperty +import javafx.beans.property.SimpleStringProperty +import javafx.collections.ObservableList import java.nio.file.Path +import tornadofx.getValue +import tornadofx.observable +import tornadofx.setValue +import java.util.concurrent.Executors class AnimationFileModel(animationFilePath: Path) { - val spineJson: SpineJson + val spineJson = SpineJson(animationFilePath) + + val slotsProperty = SimpleObjectProperty>() + var slots by slotsProperty + private set + + val mouthSlotProperty: SimpleStringProperty = SimpleStringProperty().alsoListen { + mouthNaming = if (mouthSlot != null) + MouthNaming.guess(spineJson.getSlotAttachmentNames(mouthSlot)) + else null + + mouthShapes = if (mouthSlot != null) { + val mouthNames = spineJson.getSlotAttachmentNames(mouthSlot) + MouthShape.values().filter { mouthNames.contains(mouthNaming.getName(it)) } + } else null + + mouthSlotError = if (mouthSlot != null) null + else "No slot with mouth drawings specified." + } + var mouthSlot by mouthSlotProperty + + val mouthSlotErrorProperty = SimpleStringProperty() + var mouthSlotError by mouthSlotErrorProperty + private set + + val mouthNamingProperty = SimpleObjectProperty() + var mouthNaming by mouthNamingProperty + private set + + val mouthShapesProperty = SimpleObjectProperty>().alsoListen { + mouthShapesError = getMouthShapesErrorString() + } + var mouthShapes by mouthShapesProperty + private set + + val mouthShapesErrorProperty = SimpleStringProperty() + var mouthShapesError by mouthShapesErrorProperty + private set + + val audioFileModelsProperty = SimpleListProperty( + spineJson.audioEvents + .map { event -> + val executor = Executors.newSingleThreadExecutor() + AudioFileModel(event, this, executor, { result -> saveAnimation(result, event.name) }) + } + .observable() + ) + + private fun saveAnimation(mouthCues: List, audioEventName: String) { + val animationName = getAnimationName(audioEventName) + spineJson.createOrUpdateAnimation(mouthCues, audioEventName, animationName, mouthSlot, mouthNaming) + spineJson.save() + } + + private fun getAnimationName(audioEventName: String): String = "say_$audioEventName" + + val audioFileModels by audioFileModelsProperty init { - spineJson = SpineJson(animationFilePath) + slots = spineJson.slots.observable() + mouthSlot = spineJson.guessMouthSlot() } + + private fun getMouthShapesErrorString(): String? { + val missingBasicShapes = MouthShape.basicShapes + .filter{ !mouthShapes.contains(it) } + if (missingBasicShapes.isEmpty()) return null + + val result = StringBuilder() + result.append("Mouth shapes ${missingBasicShapes.joinToString()}") + result.appendln(if (missingBasicShapes.count() > 1) " are missing." else " is missing.") + + val first = MouthShape.basicShapes.first() + val last = MouthShape.basicShapes.last() + result.append("At least the basic mouth shapes $first-$last need corresponding image attachments.") + return result.toString() + } + } \ No newline at end of file diff --git a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt new file mode 100644 index 0000000..fb4b66e --- /dev/null +++ b/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt @@ -0,0 +1,158 @@ +package com.rhubarb_lip_sync.rhubarb_for_spine + +import javafx.application.Platform +import javafx.beans.binding.ObjectBinding +import javafx.beans.binding.StringBinding +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleObjectProperty +import javafx.beans.property.SimpleStringProperty +import javafx.scene.control.Alert +import tornadofx.getValue +import tornadofx.setValue +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future + +class AudioFileModel( + audioEvent: SpineJson.AudioEvent, + private val parentModel: AnimationFileModel, + private val executor: ExecutorService, + private val reportResult: (List) -> Unit +) { + val spineJson = parentModel.spineJson + + val audioFilePath = spineJson.audioDirectoryPath.resolve(audioEvent.relativeAudioFilePath) + + val eventNameProperty = SimpleStringProperty(audioEvent.name) + val eventName by eventNameProperty + + val displayFilePathProperty = SimpleStringProperty(audioEvent.relativeAudioFilePath) + val displayFilePath by displayFilePathProperty + + val dialogProperty = SimpleStringProperty(audioEvent.dialog) + val dialog: String? by dialogProperty + + val animationProgressProperty = SimpleObjectProperty(null) + var animationProgress by animationProgressProperty + private set + + private val animatedPreviouslyProperty = SimpleBooleanProperty(false) // TODO: Initial value + private var animatedPreviously by animatedPreviouslyProperty + + private val futureProperty = SimpleObjectProperty?>() + private var future by futureProperty + + private val audioFileStateProperty = SimpleObjectProperty().apply { + bind(object : ObjectBinding() { + init { + super.bind(animatedPreviouslyProperty, futureProperty, animationProgressProperty) + } + override fun computeValue(): AudioFileState { + return if (future != null) { + if (animationProgress != null) + if (future!!.isCancelled) + AudioFileState(AudioFileStatus.Canceling) + else + AudioFileState(AudioFileStatus.Animating, animationProgress) + else + AudioFileState(AudioFileStatus.Pending) + } else { + if (animatedPreviously) + AudioFileState(AudioFileStatus.Done) + else + AudioFileState(AudioFileStatus.NotAnimated) + } + } + }) + } + private val audioFileState by audioFileStateProperty + + val statusLabelProperty = SimpleStringProperty().apply { + bind(object : StringBinding() { + init { + super.bind(audioFileStateProperty) + } + override fun computeValue(): String { + return when (audioFileState.status) { + AudioFileStatus.NotAnimated -> "" + AudioFileStatus.Pending -> "Waiting" + AudioFileStatus.Animating -> "${((animationProgress ?: 0.0) * 100).toInt()}%" + AudioFileStatus.Canceling -> "Canceling" + AudioFileStatus.Done -> "Done" + } + } + }) + } + val statusLabel by statusLabelProperty + + val actionLabelProperty = SimpleStringProperty().apply { + bind(object : StringBinding() { + init { + super.bind(futureProperty) + } + override fun computeValue(): String { + return if (future != null) + "Cancel" + else + "Animate" + } + }) + } + val actionLabel by actionLabelProperty + + fun performAction() { + if (future == null) { + startAnimation() + } else { + cancelAnimation() + } + } + + private fun startAnimation() { + val wrapperTask = Runnable { + val extendedMouthShapes = parentModel.mouthShapes.filter { it.isExtended }.toSet() + val reportProgress: (Double?) -> Unit = { + progress -> runAndWait { this@AudioFileModel.animationProgress = progress } + } + val rhubarbTask = RhubarbTask(audioFilePath, dialog, extendedMouthShapes, reportProgress) + try { + try { + val result = rhubarbTask.call() + runAndWait { + reportResult(result) + animatedPreviously = true + } + } finally { + runAndWait { + animationProgress = null + future = null + } + } + } catch (e: InterruptedException) { + } catch (e: Exception) { + Platform.runLater { + Alert(Alert.AlertType.ERROR).apply { + headerText = "Error performing lip-sync for event $eventName." + contentText = e.toString() + show() + } + } + } + + } + future = executor.submit(wrapperTask) + } + + private fun cancelAnimation() { + future?.cancel(true) + } +} + +enum class AudioFileStatus { + NotAnimated, + Pending, + Animating, + Canceling, + Done +} + +data class AudioFileState(val status: AudioFileStatus, val progress: Double? = null) \ No newline at end of file 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 index ca3dcb2..678e436 100644 --- 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 @@ -10,8 +10,8 @@ import java.nio.file.InvalidPathException import java.nio.file.Paths class MainModel { - val filePathStringProperty = SimpleStringProperty(getDefaultPathString()).applyListener { value -> - try { + val filePathStringProperty = SimpleStringProperty(getDefaultPathString()).alsoListen { value -> + filePathError = getExceptionMessage { animationFileModel = null if (value.isNullOrBlank()) { throw Exception("No input file specified.") @@ -29,13 +29,8 @@ class MainModel { } animationFileModel = AnimationFileModel(path) - - filePathError = null - } catch (e: Exception) { - filePathError = e.message } } - var filePathString by filePathStringProperty val filePathErrorProperty = SimpleStringProperty() 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 index 85d5b72..38ec0bf 100644 --- 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 @@ -1,10 +1,17 @@ package com.rhubarb_lip_sync.rhubarb_for_spine +import javafx.beans.property.SimpleStringProperty +import javafx.event.ActionEvent import javafx.event.EventHandler +import javafx.scene.control.Button +import javafx.scene.control.TableCell +import javafx.scene.control.TableView import javafx.scene.control.TextField import javafx.scene.input.DragEvent import javafx.scene.input.TransferMode +import javafx.stage.FileChooser import tornadofx.* +import java.io.File import java.time.LocalDate import java.time.Period @@ -16,47 +23,85 @@ class MainView : View() { 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 + var filePathTextField: TextField? = null + var filePathButton: Button? = null + + val fileModelProperty = mainModel.animationFileModelProperty minWidth = 800.0 + prefWidth = 1000.0 fieldset("Settings") { field("Spine JSON file") { - filePathField = textfield { + filePathTextField = textfield { textProperty().bindBidirectional(mainModel.filePathStringProperty) - tooltip("Hello world") errorProperty().bind(mainModel.filePathErrorProperty) } - button("...") + filePathButton = button("...") } field("Mouth slot") { - textfield() + combobox { + itemsProperty().bind(fileModelProperty.select { it!!.slotsProperty }) + valueProperty().bindBidirectional(fileModelProperty.select { it!!.mouthSlotProperty }) + errorProperty().bind(fileModelProperty.select { it!!.mouthSlotErrorProperty }) + } } field("Mouth naming") { - datepicker() + label { + textProperty().bind( + fileModelProperty + .select { it!!.mouthNamingProperty } + .select { SimpleStringProperty(it.displayString) } + ) + } } field("Mouth shapes") { - textfield() + hbox { + label { + textProperty().bind( + fileModelProperty + .select { it!!.mouthShapesProperty } + .select { + val result = if (it.isEmpty()) "none" else it.joinToString() + SimpleStringProperty(result) + } + ) + } + errorProperty().bind(fileModelProperty.select { it!!.mouthShapesErrorProperty }) + } } } 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) + tableview { + columnResizePolicy = TableView.CONSTRAINED_RESIZE_POLICY + column("Event", AudioFileModel::eventNameProperty) + column("Audio file", AudioFileModel::displayFilePathProperty) + column("Dialog", AudioFileModel::dialogProperty) + column("Status", AudioFileModel::statusLabelProperty) + column("", AudioFileModel::actionLabelProperty).apply { + setCellFactory { tableColumn -> + return@setCellFactory object : TableCell() { + override fun updateItem(item: String?, empty: Boolean) { + super.updateItem(item, empty) + graphic = if (!empty) + Button(item).apply { + this.maxWidth = Double.MAX_VALUE + setOnAction { + val audioFileModel = this@tableview.items[index] + audioFileModel.performAction() + } + } + else + null + } + } + } + } + itemsProperty().bind(fileModelProperty.select { it!!.audioFileModelsProperty }) } } @@ -68,10 +113,28 @@ class MainView : View() { } onDragDropped = EventHandler { event -> if (event.dragboard.hasFiles()) { - filePathField!!.text = event.dragboard.files.firstOrNull()?.path + filePathTextField!!.text = event.dragboard.files.firstOrNull()?.path event.isDropCompleted = true event.consume() } } + + filePathButton!!.onAction = EventHandler { + val fileChooser = FileChooser().apply { + title = "Open Spine JSON file" + extensionFilters.addAll( + FileChooser.ExtensionFilter("Spine JSON file (*.json)", "*.json"), + FileChooser.ExtensionFilter("All files (*.*)", "*.*") + ) + val lastDirectory = filePathTextField!!.text?.let { File(it).parentFile } + if (lastDirectory != null && lastDirectory.isDirectory) { + initialDirectory = lastDirectory + } + } + val file = fileChooser.showOpenDialog(this@MainView.primaryStage) + if (file != null) { + filePathTextField!!.text = file.path + } + } } } \ No newline at end of file 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 index 25dbc21..8b6416b 100644 --- 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 @@ -6,13 +6,20 @@ class MouthNaming(val prefix: String, val suffix: String, val mouthShapeCasing: companion object { fun guess(mouthNames: List): MouthNaming { - if (mouthNames.isEmpty()) return MouthNaming("", "", guessMouthShapeCasing("")) + if (mouthNames.isEmpty()) { + return MouthNaming("", "", guessMouthShapeCasing("")) + } val commonPrefix = mouthNames.commonPrefix val commonSuffix = mouthNames.commonSuffix - val shapeName = mouthNames.first().substring( + val firstMouthName = mouthNames.first() + if (commonPrefix.length + commonSuffix.length >= firstMouthName.length) { + return MouthNaming(commonPrefix, "", guessMouthShapeCasing("")) + } + + val shapeName = firstMouthName.substring( commonPrefix.length, - mouthNames.first().length - commonSuffix.length) + firstMouthName.length - commonSuffix.length) val mouthShapeCasing = guessMouthShapeCasing(shapeName) return MouthNaming(commonPrefix, commonSuffix, mouthShapeCasing) } 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 index 5f27ec0..c41a425 100644 --- 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 @@ -1,5 +1,17 @@ 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 + A, B, C, D, E, F, G, H, X; + + val isBasic: Boolean + get() = this.ordinal < basicShapeCount + + val isExtended: Boolean + get() = !this.isBasic + + companion object { + val basicShapeCount = 6 + + val basicShapes = MouthShape.values().take(basicShapeCount) + } +} 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 deleted file mode 100644 index d8ce1bf..0000000 --- a/extras/rhubarb-for-spine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/Progress.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 index 24ede9f..34a5505 100644 --- 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 @@ -6,10 +6,7 @@ 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.io.* import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -21,7 +18,7 @@ class RhubarbTask( val audioFilePath: Path, val dialog: String?, val extendedMouthShapes: Set, - val progress: Progress + val reportProgress: (Double?) -> Unit ) : Callable> { override fun call(): List { @@ -32,24 +29,25 @@ class RhubarbTask( 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 outputFile = TemporaryTextFile() + dialogFile.use { outputFile.use { + val processBuilder = ProcessBuilder(createProcessBuilderArgs(dialogFile?.filePath)).apply { + // See http://java-monitor.com/forum/showthread.php?t=4067 + redirectOutput(outputFile.filePath.toFile()) + } 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")!!)} + reportProgress(message.double("value")!!)} "success" -> { - progress.reportProgress(1.0) - val resultString = stdout.readText() + reportProgress(1.0) + val resultString = String(Files.readAllBytes(outputFile.filePath), StandardCharsets.UTF_8) return parseRhubarbResult(resultString) } "failure" -> { @@ -63,7 +61,7 @@ class RhubarbTask( } catch (e: EOFException) { throw Exception("Rhubarb terminated unexpectedly.") } - } + }} throw Exception("An unexpected error occurred.") } @@ -127,7 +125,7 @@ class RhubarbTask( + " Expected to find it in '$guiBinDirectory' or any directory above.") } - private class TemporaryTextFile(val text: String) : AutoCloseable { + private class TemporaryTextFile(text: String = "") : AutoCloseable { val filePath: Path = Files.createTempFile(null, null).also { Files.write(it, text.toByteArray(StandardCharsets.UTF_8)) } 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 index 58c3728..91edb5f 100644 --- 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 @@ -1,6 +1,7 @@ package com.rhubarb_lip_sync.rhubarb_for_spine import com.beust.klaxon.* +import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path @@ -67,7 +68,7 @@ class SpineJson(val filePath: Path) { return slots.mapNotNull { it.string("name") } } - val presumedMouthSlot: String? get() { + fun guessMouthSlot(): String? { return slots.firstOrNull { it.contains("mouth", ignoreCase = true) } ?: slots.firstOrNull() } @@ -138,4 +139,9 @@ class SpineJson(val filePath: Path) { ) } } + + fun save() { + var string = json.toJsonString(prettyPrint = true) + Files.write(filePath, listOf(string), StandardCharsets.UTF_8) + } } \ No newline at end of file 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 index 7a3cf50..c5811c0 100644 --- 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 @@ -2,6 +2,11 @@ package com.rhubarb_lip_sync.rhubarb_for_spine import javafx.application.Platform import javafx.beans.property.Property +import java.util.concurrent.ExecutionException +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + val List.commonPrefix: String get() { return if (isEmpty()) "" else this.reduce { result, string -> result.commonPrefixWith(string) } @@ -11,7 +16,7 @@ val List.commonSuffix: String get() { return if (isEmpty()) "" else this.reduce { result, string -> result.commonSuffixWith(string) } } -fun > TProperty.applyListener(listener: (TValue) -> Unit) : TProperty { +fun > TProperty.alsoListen(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 @@ -21,3 +26,46 @@ fun > TProperty.applyListener(listener: (TV addListener({ _, _, newValue -> listener(newValue)}) return this } + +fun getExceptionMessage(action: () -> Unit): String? { + try { + action(); + } catch (e: Exception) { + return e.message + } + return null +} + + +/** + * Invokes a Runnable on the JFX thread and waits until it's finished. + * Similar to SwingUtilities.invokeAndWait. + * Based on http://www.guigarage.com/2013/01/invokeandwait-for-javafx/ + * + * @throws InterruptedException Execution was interrupted + * @throws Throwable An exception occurred in the run method of the Runnable + */ +fun runAndWait(action: () -> Unit) { + if (Platform.isFxApplicationThread()) { + action() + } else { + val lock = ReentrantLock() + lock.withLock { + val doneCondition = lock.newCondition() + var throwable: Throwable? = null + Platform.runLater { + lock.withLock { + try { + action() + } catch (e: Throwable) { + throwable = e + } finally { + doneCondition.signal() + } + } + } + doneCondition.await() + throwable?.let { throw it } + } + } +} \ No newline at end of file