Loading Spine JSON file
This commit is contained in:
parent
82c0a1531e
commit
4d9ddf334f
|
@ -1,4 +1,3 @@
|
|||
.idea/
|
||||
.vs/
|
||||
build/
|
||||
*.user
|
|
@ -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/
|
|
@ -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,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'
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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