Loading Spine JSON file

This commit is contained in:
Daniel Wolf 2017-11-14 21:21:05 +01:00
parent 82c0a1531e
commit 4d9ddf334f
23 changed files with 704 additions and 16 deletions

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
.idea/
.vs/
build/
*.user

View File

@ -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/

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -13,7 +13,7 @@ group 'com.rhubarb_lip_sync'
version = getVersion()
buildscript {
ext.kotlin_version = '1.1.4-3'
ext.kotlin_version = '1.1.60'
repositories {
mavenCentral()
@ -27,15 +27,19 @@ apply plugin: 'kotlin'
repositories {
mavenCentral()
jcenter()
}
dependencies {
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'
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
package com.rhubarb_lip_sync.rhubarb_for_spine
import tornadofx.App
class MainApp : App(MainView::class)

View File

@ -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()
}

View File

@ -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()
}
}
}
}

View File

@ -0,0 +1,3 @@
package com.rhubarb_lip_sync.rhubarb_for_spine
data class MouthCue(val time: Double, val mouthShape: MouthShape)

View File

@ -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
}

View File

@ -0,0 +1,5 @@
package com.rhubarb_lip_sync.rhubarb_for_spine
enum class MouthShape {
A, B, C, D, E, F, G, H, X
}

View File

@ -0,0 +1,6 @@
package com.rhubarb_lip_sync.rhubarb_for_spine
// Modeled after C#'s IProgress<double>
interface Progress {
fun reportProgress(progress: Double)
}

View File

@ -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()
}
}
}

View File

@ -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"] = ""
}
)
}
}
}

View File

@ -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)
}

View File

@ -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
}