Loading Spine JSON file
This commit is contained in:
parent
82c0a1531e
commit
4d9ddf334f
|
@ -1,4 +1,3 @@
|
||||||
.idea/
|
|
||||||
.vs/
|
.vs/
|
||||||
build/
|
build/
|
||||||
*.user
|
*.user
|
|
@ -1,4 +1,13 @@
|
||||||
|
# User-specific files:
|
||||||
|
/.idea/**/workspace.xml
|
||||||
|
/.idea/**/tasks.xml
|
||||||
|
/.idea/dictionaries/
|
||||||
|
|
||||||
|
# Gradle:
|
||||||
/.gradle/
|
/.gradle/
|
||||||
/.idea/workspace.xml
|
/.idea/**/gradle.xml
|
||||||
/.idea/tasks.xml
|
/.idea/**/libraries/
|
||||||
|
/.idea/**/*.iml
|
||||||
|
|
||||||
/build/
|
/build/
|
||||||
|
/out/
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectCodeStyleSettingsManager">
|
||||||
|
<option name="PER_PROJECT_SETTINGS">
|
||||||
|
<value />
|
||||||
|
</option>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel>
|
||||||
|
<module name="rhubarb-for-spine_main" target="1.8" />
|
||||||
|
<module name="rhubarb-for-spine_test" target="1.8" />
|
||||||
|
</bytecodeTargetLevel>
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Kotlin2JsCompilerArguments">
|
||||||
|
<option name="sourceMapEmbedSources" />
|
||||||
|
<option name="sourceMapPrefix" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/modules/rhubarb-for-spine.iml" filepath="$PROJECT_DIR$/.idea/modules/rhubarb-for-spine.iml" />
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/modules/rhubarb-for-spine_main.iml" filepath="$PROJECT_DIR$/.idea/modules/rhubarb-for-spine_main.iml" group="rhubarb-for-spine" />
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/modules/rhubarb-for-spine_test.iml" filepath="$PROJECT_DIR$/.idea/modules/rhubarb-for-spine_test.iml" group="rhubarb-for-spine" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -13,7 +13,7 @@ group 'com.rhubarb_lip_sync'
|
||||||
version = getVersion()
|
version = getVersion()
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.1.4-3'
|
ext.kotlin_version = '1.1.60'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
@ -27,15 +27,19 @@ apply plugin: 'kotlin'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
jcenter()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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 {
|
compileKotlin {
|
||||||
kotlinOptions.jvmTarget = "1.8"
|
kotlinOptions.jvmTarget = '1.8'
|
||||||
}
|
}
|
||||||
compileTestKotlin {
|
compileTestKotlin {
|
||||||
kotlinOptions.jvmTarget = "1.8"
|
kotlinOptions.jvmTarget = '1.8'
|
||||||
}
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
#Tue Nov 14 20:30:34 CET 2017
|
#Tue Nov 28 18:16:46 CET 2017
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<out Boolean>, _: Boolean, _: Boolean ->
|
||||||
|
updateTooltipVisibility()
|
||||||
|
})
|
||||||
|
|
||||||
|
property.addListener({
|
||||||
|
_: ObservableValue<out String?>, _: 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
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import tornadofx.App
|
||||||
|
|
||||||
|
class MainApp : App(MainView::class)
|
|
@ -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<AnimationFileModel?>()
|
||||||
|
var animationFileModel by animationFileModelProperty
|
||||||
|
private set
|
||||||
|
|
||||||
|
private fun getDefaultPathString() = FX.application.parameters.raw.firstOrNull()
|
||||||
|
}
|
|
@ -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<DragEvent> { event ->
|
||||||
|
if (event.dragboard.hasFiles()) {
|
||||||
|
event.acceptTransferModes(TransferMode.COPY)
|
||||||
|
event.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDragDropped = EventHandler<DragEvent> { event ->
|
||||||
|
if (event.dragboard.hasFiles()) {
|
||||||
|
filePathField!!.text = event.dragboard.files.firstOrNull()?.path
|
||||||
|
event.isDropCompleted = true
|
||||||
|
event.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
data class MouthCue(val time: Double, val mouthShape: MouthShape)
|
|
@ -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<String>): 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)
|
||||||
|
"<UPPER-CASE SHAPE NAME>"
|
||||||
|
else
|
||||||
|
"<lower-case shape name>"
|
||||||
|
return "\"$prefix$casing$suffix\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MouthShapeCasing {
|
||||||
|
Upper,
|
||||||
|
Lower
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
enum class MouthShape {
|
||||||
|
A, B, C, D, E, F, G, H, X
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
// Modeled after C#'s IProgress<double>
|
||||||
|
interface Progress {
|
||||||
|
fun reportProgress(progress: Double)
|
||||||
|
}
|
|
@ -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<MouthShape>,
|
||||||
|
val progress: Progress
|
||||||
|
) : Callable<List<MouthCue>> {
|
||||||
|
|
||||||
|
override fun call(): List<MouthCue> {
|
||||||
|
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<MouthCue> {
|
||||||
|
val json = parseJsonObject(jsonString)
|
||||||
|
val mouthCues = json.array<JsonObject>("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<String> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String> get() {
|
||||||
|
val slots = json.array("slots") ?: listOf<JsonObject>()
|
||||||
|
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<AudioEvent> get() {
|
||||||
|
val events = json.obj("events") ?: JsonObject()
|
||||||
|
val result = mutableListOf<AudioEvent>()
|
||||||
|
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<String> {
|
||||||
|
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<MouthCue>, 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<Int, MouthShape>()
|
||||||
|
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"] = ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import javafx.application.Application
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
Application.launch(MainApp::class.java, *args)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import javafx.application.Platform
|
||||||
|
import javafx.beans.property.Property
|
||||||
|
|
||||||
|
val List<String>.commonPrefix: String get() {
|
||||||
|
return if (isEmpty()) "" else this.reduce { result, string -> result.commonPrefixWith(string) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 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
|
||||||
|
}
|
Loading…
Reference in New Issue