diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore index d047f52..3b6a86f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -.idea/ .vs/ build/ +*.user \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 81b1cb0..0fc9e7c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,11 +5,10 @@ include(appInfo.cmake) # Build and install main executable add_subdirectory(rhubarb) -# Install extras -install( - DIRECTORY extras - DESTINATION . -) +# Build and install extras +add_subdirectory("extras/AdobeAfterEffects") +add_subdirectory("extras/MagixVegas") +add_subdirectory("extras/EsotericSoftwareSpine") # Install misc. files install( diff --git a/README.adoc b/README.adoc index 9c994f5..e693694 100644 --- a/README.adoc +++ b/README.adoc @@ -19,7 +19,7 @@ image:img/logo.png[align="center"] https://github.com/DanielSWolf/rhubarb-lip-sync[Rhubarb Lip Sync] is a command-line tool that automatically creates 2D mouth animation from voice recordings. You can use it for animating speech in computer games, animated cartoons, or any similar project. -Rhubarb Lip Sync produces text files in various <> (<>/<>/<>). In addition, it comes with integrations for <> and <>. +Rhubarb Lip Sync produces text files in various <> (<>/<>/<>). In addition, it comes with integrations for <>, <>, and <>. == Demo video @@ -32,14 +32,21 @@ https://www.youtube.com/watch?v=zzdPSFJRlEo[image:http://img.youtube.com/vi/zzdP [[afterEffects]] === Adobe After Effects -You can use Rhubarb Lip Sync to animate dialog right from Adobe After Effects. For more information, see the `extras` directory of the download. +You can use Rhubarb Lip Sync to animate dialog right from Adobe After Effects. For more information, see the directory `extras/AdobeAfterEffects` of the download. image:img/after-effects.png[] -[[vegas]] -=== Magix Vegas +[[spine]] +=== Spine by Esoteric Software -Rhubarb Lip Sync also comes with two plugin scripts for Magix Vegas (previously Sony Vegas). For more information, see the `extras` directory of the download. +Rhubarb Lip Sync for Spine is a graphical tool that allows you to import a Spine project, perform automatic lip sync, then re-import the result into Spine. For more information, see the directory `extras/EsotericSoftwareSpine` of the download. + +image:img/spine.png[] + +[[vegas]] +=== Vegas Pro by Magix + +Rhubarb Lip Sync also comes with two plugin scripts for Vegas Pro (previously Sony Vegas). For more information, see the directory `extras/MagixVegas` of the download. image:img/vegas.png[] diff --git a/extras/AdobeAfterEffects/CMakeLists.txt b/extras/AdobeAfterEffects/CMakeLists.txt new file mode 100644 index 0000000..c69164b --- /dev/null +++ b/extras/AdobeAfterEffects/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.2) + +set(afterEffectsFiles + "Rhubarb Lip Sync.jsx" + "README.md" +) + +install( + FILES ${afterEffectsFiles} + DESTINATION "extras/AdobeAfterEffects" +) diff --git a/extras/EsotericSoftwareSpine/.gitignore b/extras/EsotericSoftwareSpine/.gitignore new file mode 100644 index 0000000..2f451d7 --- /dev/null +++ b/extras/EsotericSoftwareSpine/.gitignore @@ -0,0 +1,8 @@ +# Directory is generated when importing Gradle project +/.idea/ + +*.iml +/.gradle/ +/build/ +/out/ +/tmp/ diff --git a/extras/EsotericSoftwareSpine/CMakeLists.txt b/extras/EsotericSoftwareSpine/CMakeLists.txt new file mode 100644 index 0000000..eb6e23b --- /dev/null +++ b/extras/EsotericSoftwareSpine/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.2) + +add_custom_target( + rhubarbForSpine ALL + "./gradlew" "jar" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + COMMENT "Building Rhubarb for Spine through Gradle." +) + +install( + DIRECTORY "build/libs/" + DESTINATION "extras/EsotericSoftwareSpine" +) + +install( + FILES README.md + DESTINATION "extras/EsotericSoftwareSpine" +) diff --git a/extras/EsotericSoftwareSpine/README.md b/extras/EsotericSoftwareSpine/README.md new file mode 100644 index 0000000..941e4b1 --- /dev/null +++ b/extras/EsotericSoftwareSpine/README.md @@ -0,0 +1,51 @@ +# Rhubarb Lip Sync for Spine + +Rhubarb Lip Sync for Spine is a graphical tool that allows you to import a Spine project, perform automatic lip sync, then re-import the result into Spine. + +![](../../img/spine.png) + +## Installation + +[Download Rhubarb Lip Sync](https://github.com/DanielSWolf/rhubarb-lip-sync/releases) for your platform, then extract the archive file in a directory on your computer. You'll find Rhubarb Lip Sync for Spine in the directory `extras/EsotericSoftwareSpine`. + +To create lip sync animation, you'll need Spine 3.7 or better. As of this writing, Spine 3.7 is still in beta stage. To get the beta version, go to *Spine* | *Settings...*, then check "Beta updates". + +## Preparing your Spine project + +You can add lip-sync'ed dialog to any Spine skeleton. First, make sure it has a dedicated slot for its mouth. I'm naming the slot `mouth`, but you can choose any name you like. + +Next, add image attachments to the mouth slot, one attachment per mouth shape. For details about the expected mouth shapes, [refer to the Rhubarb Lip Sync documentation](https://github.com/DanielSWolf/rhubarb-lip-sync#user-content-mouth-shapes). You'll need at least the six basic mouth shapes A-F. If you add any of the extended mouth shapes, Rhubarb will automatically use them to create better-looking animation. I'm naming the attachments `mouth_a`, `mouth_b`, `mouth_c`, etc. You can choose any naming scheme you like and Rhubarb will detect it, as long as it's consistent (including upper and lower case). For instance, `A-Lips`, `B-Lips`, `C-Lips`, ... is fine; `mouth a`, `mouth B`, `Mouth-C`, ... isn't. + +Finally, you need to add some audio events, that is, events with associated audio path. These audio events will be the basis for animation. + +*Optionally*, you can enter the dialog text into each event's string property. If you do, this will help Rhubarb to create more reliable animation. But don't worry: If you don't enter the dialog text or if you already use the string property for something else, the results will normally still be good. For more information, see the [documentation on the `--dialogFile` option](https://github.com/DanielSWolf/rhubarb-lip-sync#user-content-options). + +## Exporting a JSON file + +Export the skeleton(s) by selecting *Spine* | *Export...*. + +Choose JSON format. Make sure the output folder is the same folder that contains your `.spine` file, or Rhubarb won't be able to locate your audio files. Also, make sure to check the *Nonessential data* checkbox. Despite the name, Rhubarb needs this information. Finally, click *Export*. This will create a file with the same name as your skeleton and the extension `.json`. + +## Performing lip sync + +Open Rhubarb Lip Sync for Spine by double-clicking `rhubarb-for-spine.jar` in the Windows Explorer (Windows) or Finder (OS X). Specify the input settings as follows: + +* **Spine JSON file:** This is the file you just exported. The most convenient way to fill this field is to drag-and-drop the JSON file anywhere onto the application window. Alternatively, you can use the '...' button or manually enter the file path. +* **Mouth slot:** This tells Rhubarb which of your Spine slots represents the mouth. The dropdown shows all the slots on your skeleton. If your mouth slot contains the word 'mouth', Rhubarb will automatically select it for you. Otherwise, select it manually. +* **Mouth naming:** Rhubarb will automatically detect the naming scheme you used for your mouth attachments and display it here. This is for your information only. +* **Mouth shapes:** This group of checkboxes tells you which mouth shapes were found. At least the basic mouth shapes A-F need to be present. This, too, is informational only. +* **Animation naming:** When animating, Rhubarb will create new Spine animations based on your existing audio events. The two text fields allow you to fine-tune the animation naming. + +At the bottom of the window, there is a grid with one row per audio event. To animate any audio event, click the corresponding *Animate* button. Animation jobs are queued, so the next animation job starts once the previous one has finished. + +Each time an animation job finishes, the JSON file is updated with the new animation. When you are done animating, you can close Rhubarb Lip Sync for Spine. + +## Importing the animated results + +Rhubarb Lip Sync for Spine has only changed the exported `.json` file, not your original `.spine` project file. To do that, switch back to Spine. + +Delete your skeleton by selecting it in the hierarchy tree and clicking the *Delete* button. If you don't, Spine will complain in the next step that a skeleton with this name already exists. + +Select *Spine* | *Import Data...*. Make sure the JSON file path is correct. Also make sure the checkbox *New project* is **not checked**, or else Spine will start confusing paths. Click OK to re-import the skeleton from the JSON file. + +If everything went well, you will now have a number of new, lip-sync'ed animations on your skeleton! diff --git a/extras/EsotericSoftwareSpine/build.gradle b/extras/EsotericSoftwareSpine/build.gradle new file mode 100644 index 0000000..21e6aa7 --- /dev/null +++ b/extras/EsotericSoftwareSpine/build.gradle @@ -0,0 +1,56 @@ +def getVersion() { + // Dynamically read version from CMake file + String text = new File('../../appInfo.cmake').getText('UTF-8') + String major = (text =~ /appVersionMajor\s+(\d+)/)[0][1] + String minor = (text =~ /appVersionMinor\s+(\d+)/)[0][1] + String patch = (text =~ /appVersionPatch\s+(\d+)/)[0][1] + String suffix = (text =~ /appVersionSuffix\s+"(.*?)"/)[0][1] + String result = "${major}.${minor}.${patch}${suffix}" + return result +} + +group 'com.rhubarb_lip_sync' +version = getVersion() + +buildscript { + ext.kotlin_version = '1.1.60' + + repositories { + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +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' +} +compileTestKotlin { + kotlinOptions.jvmTarget = '1.8' +} + +jar { + manifest { + attributes 'Main-Class': 'com.rhubarb_lip_sync.rhubarb_for_spine.MainKt' + } + + // This line of code recursively collects and copies all of a project's files + // and adds them to the JAR itself. One can extend this task, to skip certain + // files or particular types at will + from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } +} \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/gradle/wrapper/gradle-wrapper.jar b/extras/EsotericSoftwareSpine/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..27768f1 Binary files /dev/null and b/extras/EsotericSoftwareSpine/gradle/wrapper/gradle-wrapper.jar differ diff --git a/extras/EsotericSoftwareSpine/gradle/wrapper/gradle-wrapper.properties b/extras/EsotericSoftwareSpine/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62e1e30 --- /dev/null +++ b/extras/EsotericSoftwareSpine/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-4.3-bin.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/extras/EsotericSoftwareSpine/gradlew b/extras/EsotericSoftwareSpine/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/extras/EsotericSoftwareSpine/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/extras/EsotericSoftwareSpine/gradlew.bat b/extras/EsotericSoftwareSpine/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/extras/EsotericSoftwareSpine/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/extras/EsotericSoftwareSpine/settings.gradle b/extras/EsotericSoftwareSpine/settings.gradle new file mode 100644 index 0000000..dc991a3 --- /dev/null +++ b/extras/EsotericSoftwareSpine/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'rhubarb-for-spine' + diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt new file mode 100644 index 0000000..630b055 --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AnimationFileModel.kt @@ -0,0 +1,125 @@ +package com.rhubarb_lip_sync.rhubarb_for_spine + +import javafx.beans.binding.BooleanBinding +import javafx.beans.property.SimpleBooleanProperty +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.ExecutorService + +class AnimationFileModel(val parentModel: MainModel, animationFilePath: Path, private val executor: ExecutorService) { + val spineJson = SpineJson(animationFilePath) + + val slotsProperty = SimpleObjectProperty>() + var slots by slotsProperty + private set + + val mouthSlotProperty: SimpleStringProperty = SimpleStringProperty().alsoListen { + mouthNaming = if (mouthSlot != null) + MouthNaming.guess(spineJson.getSlotAttachmentNames(mouthSlot)) + else null + + mouthShapes = if (mouthSlot != null) { + val mouthNames = spineJson.getSlotAttachmentNames(mouthSlot) + MouthShape.values().filter { mouthNames.contains(mouthNaming.getName(it)) } + } else listOf() + + mouthSlotError = if (mouthSlot != null) null + else "No slot with mouth drawings specified." + } + var mouthSlot by mouthSlotProperty + + val mouthSlotErrorProperty = SimpleStringProperty() + var mouthSlotError by mouthSlotErrorProperty + private set + + val mouthNamingProperty = SimpleObjectProperty() + var mouthNaming by mouthNamingProperty + private set + + val mouthShapesProperty = SimpleObjectProperty>().alsoListen { + mouthShapesError = getMouthShapesErrorString() + } + var mouthShapes by mouthShapesProperty + private set + + val mouthShapesErrorProperty = SimpleStringProperty() + var mouthShapesError by mouthShapesErrorProperty + private set + + val audioFileModelsProperty = SimpleListProperty( + spineJson.audioEvents + .map { event -> + var audioFileModel: AudioFileModel? = null + val reportResult: (List) -> Unit = + { result -> saveAnimation(audioFileModel!!.animationName, event.name, result) } + audioFileModel = AudioFileModel(event, this, executor, reportResult) + return@map audioFileModel + } + .observable() + ) + val audioFileModels by audioFileModelsProperty + + val busyProperty = SimpleBooleanProperty().apply { + bind(object : BooleanBinding() { + init { + for (audioFileModel in audioFileModels) { + super.bind(audioFileModel.busyProperty) + } + } + override fun computeValue(): Boolean { + return audioFileModels.any { it.busy } + } + }) + } + val busy by busyProperty + + val validProperty = SimpleBooleanProperty().apply { + val errorProperties = arrayOf(mouthSlotErrorProperty, mouthShapesErrorProperty) + bind(object : BooleanBinding() { + init { + super.bind(*errorProperties) + } + override fun computeValue(): Boolean { + return errorProperties.all { it.value == null } + } + }) + } + val valid by validProperty + + private fun saveAnimation(animationName: String, audioEventName: String, mouthCues: List) { + spineJson.createOrUpdateAnimation(mouthCues, audioEventName, animationName, mouthSlot, mouthNaming) + spineJson.save() + } + + init { + 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() + val missingShapesString = missingBasicShapes.joinToString() + result.appendln( + if (missingBasicShapes.count() > 1) + "Mouth shapes $missingShapesString are missing." + else + "Mouth shape $missingShapesString is missing." + ) + + val first = MouthShape.basicShapes.first() + val last = MouthShape.basicShapes.last() + result.append("At least the basic mouth shapes $first-$last need corresponding image attachments.") + return result.toString() + } + +} \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt new file mode 100644 index 0000000..497acc6 --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/AudioFileModel.kt @@ -0,0 +1,191 @@ +package com.rhubarb_lip_sync.rhubarb_for_spine + +import javafx.application.Platform +import javafx.beans.binding.BooleanBinding +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 javafx.scene.control.ButtonType +import tornadofx.getValue +import tornadofx.setValue +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future + +class AudioFileModel( + audioEvent: SpineJson.AudioEvent, + private val parentModel: AnimationFileModel, + private val executor: ExecutorService, + private val reportResult: (List) -> Unit +) { + val spineJson = parentModel.spineJson + + val audioFilePath = spineJson.audioDirectoryPath.resolve(audioEvent.relativeAudioFilePath) + + val eventNameProperty = SimpleStringProperty(audioEvent.name) + val eventName by eventNameProperty + + val displayFilePathProperty = SimpleStringProperty(audioEvent.relativeAudioFilePath) + val displayFilePath by displayFilePathProperty + + val animationNameProperty = SimpleStringProperty().apply { + val mainModel = parentModel.parentModel + bind(object : ObjectBinding() { + init { + super.bind( + mainModel.animationPrefixProperty, + eventNameProperty, + mainModel.animationSuffixProperty + ) + } + override fun computeValue(): String { + return mainModel.animationPrefix + eventName + mainModel.animationSuffix + } + }) + } + val animationName by animationNameProperty + + val dialogProperty = SimpleStringProperty(audioEvent.dialog) + val dialog: String? by dialogProperty + + val animationProgressProperty = SimpleObjectProperty(null) + var animationProgress by animationProgressProperty + private set + + private val animatedProperty = SimpleBooleanProperty().apply { + bind(object : ObjectBinding() { + init { + super.bind(animationNameProperty, parentModel.spineJson.animationNames) + } + override fun computeValue(): Boolean { + return parentModel.spineJson.animationNames.contains(animationName) + } + }) + } + private var animated by animatedProperty + + private val futureProperty = SimpleObjectProperty?>() + private var future by futureProperty + + val audioFileStateProperty = SimpleObjectProperty().apply { + bind(object : ObjectBinding() { + init { + super.bind(animatedProperty, 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 (animated) + AudioFileState(AudioFileStatus.Done) + else + AudioFileState(AudioFileStatus.NotAnimated) + } + } + }) + } + val audioFileState by audioFileStateProperty + + val busyProperty = SimpleBooleanProperty().apply { + bind(object : BooleanBinding() { + init { + super.bind(futureProperty) + } + override fun computeValue(): Boolean { + return future != null + } + + }) + } + val busy by busyProperty + + 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) { + if (animated) { + Alert(Alert.AlertType.CONFIRMATION).apply { + headerText = "Animation '$animationName' already exists." + contentText = "Do you want to replace the existing animation?" + val result = showAndWait() + if (result.get() != ButtonType.OK) { + return + } + } + } + + 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) + } + } 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.message + show() + } + } + } + + } + future = executor.submit(wrapperTask) + } + + private fun cancelAnimation() { + future?.cancel(true) + } +} + +enum class AudioFileStatus { + NotAnimated, + Pending, + Animating, + Canceling, + Done +} + +data class AudioFileState(val status: AudioFileStatus, val progress: Double? = null) \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt new file mode 100644 index 0000000..3f20a63 --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/ErrorProperty.kt @@ -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, _: Boolean, _: Boolean -> + updateTooltipVisibility() + }) + + property.addListener({ + _: ObservableValue, _: 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 +} \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt new file mode 100644 index 0000000..d82c65e --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainApp.kt @@ -0,0 +1,42 @@ +package com.rhubarb_lip_sync.rhubarb_for_spine + +import javafx.scene.image.Image +import javafx.stage.Stage +import tornadofx.App +import tornadofx.addStageIcon +import java.lang.reflect.Method +import javax.swing.ImageIcon + +class MainApp : App(MainView::class) { + override fun start(stage: Stage) { + super.start(stage) + setIcon() + } + + private fun setIcon() { + // Set icon for windows + for (iconSize in listOf(16, 32, 48, 256)) { + addStageIcon(Image(this.javaClass.getResourceAsStream("/icon-$iconSize.png"))) + } + + // OS X requires the dock icon to be changed separately. + // Not all JDKs contain the class com.apple.eawt.Application, so we have to use reflection. + val classLoader = this.javaClass.classLoader + try { + val iconURL = this.javaClass.getResource("/icon-256.png") + val image: java.awt.Image = ImageIcon(iconURL).image + + // The following is reflection code for the line + // Application.getApplication().setDockIconImage(image) + val applicationClass: Class<*> = classLoader.loadClass("com.apple.eawt.Application") + val getApplicationMethod: Method = applicationClass.getMethod("getApplication") + val application: Any = getApplicationMethod.invoke(null) + val setDockIconImageMethod: Method = + applicationClass.getMethod("setDockIconImage", java.awt.Image::class.java) + setDockIconImageMethod.invoke(application, image); + } catch (e: Exception) { + // Works only on OS X + } + } + +} diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt new file mode 100644 index 0000000..ffe55e9 --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainModel.kt @@ -0,0 +1,52 @@ +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 +import java.util.concurrent.ExecutorService + +class MainModel(private val executor: ExecutorService) { + val filePathStringProperty = SimpleStringProperty(getDefaultPathString()).alsoListen { value -> + filePathError = getExceptionMessage { + 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(this, path, executor) + } + } + var filePathString by filePathStringProperty + + val filePathErrorProperty = SimpleStringProperty() + var filePathError by filePathErrorProperty + private set + + val animationFileModelProperty = SimpleObjectProperty() + var animationFileModel by animationFileModelProperty + private set + + val animationPrefixProperty = SimpleStringProperty("say_") + var animationPrefix by animationPrefixProperty + + val animationSuffixProperty = SimpleStringProperty("") + var animationSuffix by animationSuffixProperty + + private fun getDefaultPathString() = FX.application.parameters.raw.firstOrNull() +} \ No newline at end of file diff --git a/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt new file mode 100644 index 0000000..13e2316 --- /dev/null +++ b/extras/EsotericSoftwareSpine/src/main/kotlin/com/rhubarb_lip_sync/rhubarb_for_spine/MainView.kt @@ -0,0 +1,242 @@ +package com.rhubarb_lip_sync.rhubarb_for_spine + +import javafx.beans.property.Property +import javafx.beans.property.SimpleBooleanProperty +import javafx.beans.property.SimpleObjectProperty +import javafx.beans.property.SimpleStringProperty +import javafx.event.ActionEvent +import javafx.event.EventHandler +import javafx.event.EventTarget +import javafx.geometry.Pos +import javafx.scene.control.* +import javafx.scene.input.DragEvent +import javafx.scene.input.TransferMode +import javafx.scene.layout.* +import javafx.scene.paint.Color +import javafx.scene.text.Font +import javafx.scene.text.FontWeight +import javafx.scene.text.Text +import javafx.stage.FileChooser +import tornadofx.* +import java.io.File +import java.util.concurrent.Executors + +class MainView : View() { + private val executor = Executors.newSingleThreadExecutor() + private val mainModel = MainModel(executor) + + init { + title = "Rhubarb Lip Sync for Spine" + } + + override val root = form { + var filePathTextField: TextField? = null + var filePathButton: Button? = null + + val fileModelProperty = mainModel.animationFileModelProperty + + minWidth = 800.0 + prefWidth = 1000.0 + fieldset("Settings") { + disableProperty().bind(fileModelProperty.select { it!!.busyProperty }) + field("Spine JSON file") { + filePathTextField = textfield { + textProperty().bindBidirectional(mainModel.filePathStringProperty) + errorProperty().bind(mainModel.filePathErrorProperty) + } + filePathButton = button("...") + } + field("Mouth slot") { + combobox { + itemsProperty().bind(fileModelProperty.select { it!!.slotsProperty }) + valueProperty().bindBidirectional(fileModelProperty.select { it!!.mouthSlotProperty }) + errorProperty().bind(fileModelProperty.select { it!!.mouthSlotErrorProperty }) + } + } + field("Mouth naming") { + label { + textProperty().bind( + fileModelProperty + .select { it!!.mouthNamingProperty } + .select { SimpleStringProperty(it.displayString) } + ) + } + } + field("Mouth shapes") { + hbox { + errorProperty().bind(fileModelProperty.select { it!!.mouthShapesErrorProperty }) + gridpane { + hgap = 10.0 + vgap = 3.0 + row { + label("Basic:") + for (shape in MouthShape.basicShapes) { + renderShapeCheckbox(shape, fileModelProperty, this) + } + } + row { + label("Extended:") + for (shape in MouthShape.extendedShapes) { + renderShapeCheckbox(shape, fileModelProperty, this) + } + } + } + } + } + field("Animation naming") { + textfield { + maxWidth = 100.0 + textProperty().bindBidirectional(mainModel.animationPrefixProperty) + } + label("