Implemented round trip Spine - Rhubarb - Spine
This commit is contained in:
parent
4d9ddf334f
commit
7bb1bec975
|
@ -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<ObservableList<String>>()
|
||||
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<MouthNaming>()
|
||||
var mouthNaming by mouthNamingProperty
|
||||
private set
|
||||
|
||||
val mouthShapesProperty = SimpleObjectProperty<List<MouthShape>>().alsoListen {
|
||||
mouthShapesError = getMouthShapesErrorString()
|
||||
}
|
||||
var mouthShapes by mouthShapesProperty
|
||||
private set
|
||||
|
||||
val mouthShapesErrorProperty = SimpleStringProperty()
|
||||
var mouthShapesError by mouthShapesErrorProperty
|
||||
private set
|
||||
|
||||
val audioFileModelsProperty = SimpleListProperty<AudioFileModel>(
|
||||
spineJson.audioEvents
|
||||
.map { event ->
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
AudioFileModel(event, this, executor, { result -> saveAnimation(result, event.name) })
|
||||
}
|
||||
.observable()
|
||||
)
|
||||
|
||||
private fun saveAnimation(mouthCues: List<MouthCue>, 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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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<MouthCue>) -> 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<Double?>(null)
|
||||
var animationProgress by animationProgressProperty
|
||||
private set
|
||||
|
||||
private val animatedPreviouslyProperty = SimpleBooleanProperty(false) // TODO: Initial value
|
||||
private var animatedPreviously by animatedPreviouslyProperty
|
||||
|
||||
private val futureProperty = SimpleObjectProperty<Future<*>?>()
|
||||
private var future by futureProperty
|
||||
|
||||
private val audioFileStateProperty = SimpleObjectProperty<AudioFileState>().apply {
|
||||
bind(object : ObjectBinding<AudioFileState>() {
|
||||
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)
|
|
@ -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()
|
||||
|
|
|
@ -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<String> {
|
||||
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<AudioFileModel> {
|
||||
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<AudioFileModel, String>() {
|
||||
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<DragEvent> { 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<ActionEvent> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,13 +6,20 @@ class MouthNaming(val prefix: String, val suffix: String, val mouthShapeCasing:
|
|||
|
||||
companion object {
|
||||
fun guess(mouthNames: List<String>): 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)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,17 @@
|
|||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||
|
||||
enum class MouthShape {
|
||||
A, B, C, D, E, F, G, H, X
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||
|
||||
// Modeled after C#'s IProgress<double>
|
||||
interface Progress {
|
||||
fun reportProgress(progress: Double)
|
||||
}
|
|
@ -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<MouthShape>,
|
||||
val progress: Progress
|
||||
val reportProgress: (Double?) -> Unit
|
||||
) : Callable<List<MouthCue>> {
|
||||
|
||||
override fun call(): List<MouthCue> {
|
||||
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<String>.commonPrefix: String get() {
|
||||
return if (isEmpty()) "" else this.reduce { result, string -> result.commonPrefixWith(string) }
|
||||
|
@ -11,7 +16,7 @@ val List<String>.commonSuffix: String get() {
|
|||
return if (isEmpty()) "" else this.reduce { result, string -> result.commonSuffixWith(string) }
|
||||
}
|
||||
|
||||
fun <TValue, TProperty : Property<TValue>> TProperty.applyListener(listener: (TValue) -> Unit) : TProperty {
|
||||
fun <TValue, TProperty : Property<TValue>> 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 <TValue, TProperty : Property<TValue>> 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 }
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue