Merge pull request #33 from DanielSWolf/feature/spine
Implemented Spine integration
This commit is contained in:
commit
1a2c77ac5b
|
@ -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
|
|
@ -1,3 +1,3 @@
|
|||
.idea/
|
||||
.vs/
|
||||
build/
|
||||
*.user
|
|
@ -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(
|
||||
|
|
17
README.adoc
17
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 <<outputFormats,output formats>> (<<tsv,TSV>>/<<xml,XML>>/<<json,JSON>>). In addition, it comes with integrations for <<afterEffects,Adobe After Effects>> and <<vegas,Magix Vegas>>.
|
||||
Rhubarb Lip Sync produces text files in various <<outputFormats,output formats>> (<<tsv,TSV>>/<<xml,XML>>/<<json,JSON>>). In addition, it comes with integrations for <<afterEffects,Adobe After Effects>>, <<spine,Spine>>, and <<vegas,Vegas Pro>>.
|
||||
|
||||
== 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[]
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
cmake_minimum_required(VERSION 3.2)
|
||||
|
||||
set(afterEffectsFiles
|
||||
"Rhubarb Lip Sync.jsx"
|
||||
"README.md"
|
||||
)
|
||||
|
||||
install(
|
||||
FILES ${afterEffectsFiles}
|
||||
DESTINATION "extras/AdobeAfterEffects"
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
# Directory is generated when importing Gradle project
|
||||
/.idea/
|
||||
|
||||
*.iml
|
||||
/.gradle/
|
||||
/build/
|
||||
/out/
|
||||
/tmp/
|
|
@ -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"
|
||||
)
|
|
@ -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!
|
|
@ -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) } }
|
||||
}
|
Binary file not shown.
|
@ -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
|
|
@ -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" "$@"
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
rootProject.name = 'rhubarb-for-spine'
|
||||
|
|
@ -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<ObservableList<String>>()
|
||||
var slots by slotsProperty
|
||||
private set
|
||||
|
||||
val mouthSlotProperty: SimpleStringProperty = SimpleStringProperty().alsoListen {
|
||||
mouthNaming = if (mouthSlot != null)
|
||||
MouthNaming.guess(spineJson.getSlotAttachmentNames(mouthSlot))
|
||||
else null
|
||||
|
||||
mouthShapes = if (mouthSlot != null) {
|
||||
val mouthNames = spineJson.getSlotAttachmentNames(mouthSlot)
|
||||
MouthShape.values().filter { mouthNames.contains(mouthNaming.getName(it)) }
|
||||
} else 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<MouthNaming>()
|
||||
var mouthNaming by mouthNamingProperty
|
||||
private set
|
||||
|
||||
val mouthShapesProperty = SimpleObjectProperty<List<MouthShape>>().alsoListen {
|
||||
mouthShapesError = getMouthShapesErrorString()
|
||||
}
|
||||
var mouthShapes by mouthShapesProperty
|
||||
private set
|
||||
|
||||
val mouthShapesErrorProperty = SimpleStringProperty()
|
||||
var mouthShapesError by mouthShapesErrorProperty
|
||||
private set
|
||||
|
||||
val audioFileModelsProperty = SimpleListProperty<AudioFileModel>(
|
||||
spineJson.audioEvents
|
||||
.map { event ->
|
||||
var audioFileModel: AudioFileModel? = null
|
||||
val reportResult: (List<MouthCue>) -> 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<MouthCue>) {
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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<MouthCue>) -> Unit
|
||||
) {
|
||||
val spineJson = parentModel.spineJson
|
||||
|
||||
val audioFilePath = spineJson.audioDirectoryPath.resolve(audioEvent.relativeAudioFilePath)
|
||||
|
||||
val eventNameProperty = SimpleStringProperty(audioEvent.name)
|
||||
val eventName by eventNameProperty
|
||||
|
||||
val displayFilePathProperty = SimpleStringProperty(audioEvent.relativeAudioFilePath)
|
||||
val displayFilePath by displayFilePathProperty
|
||||
|
||||
val animationNameProperty = SimpleStringProperty().apply {
|
||||
val mainModel = parentModel.parentModel
|
||||
bind(object : ObjectBinding<String>() {
|
||||
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<Double?>(null)
|
||||
var animationProgress by animationProgressProperty
|
||||
private set
|
||||
|
||||
private val animatedProperty = SimpleBooleanProperty().apply {
|
||||
bind(object : ObjectBinding<Boolean>() {
|
||||
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<Future<*>?>()
|
||||
private var future by futureProperty
|
||||
|
||||
val audioFileStateProperty = SimpleObjectProperty<AudioFileState>().apply {
|
||||
bind(object : ObjectBinding<AudioFileState>() {
|
||||
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)
|
|
@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<AnimationFileModel?>()
|
||||
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()
|
||||
}
|
|
@ -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<String> {
|
||||
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("<audio event name>")
|
||||
textfield {
|
||||
maxWidth = 100.0
|
||||
textProperty().bindBidirectional(mainModel.animationSuffixProperty)
|
||||
}
|
||||
}
|
||||
}
|
||||
fieldset("Audio events") {
|
||||
tableview<AudioFileModel> {
|
||||
placeholder = Label("There are no events with associated audio files.")
|
||||
columnResizePolicy = SmartResize.POLICY
|
||||
column("Event", AudioFileModel::eventNameProperty)
|
||||
.weigthedWidth(1.0)
|
||||
column("Animation name", AudioFileModel::animationNameProperty)
|
||||
.weigthedWidth(1.0)
|
||||
column("Audio file", AudioFileModel::displayFilePathProperty)
|
||||
.weigthedWidth(1.0)
|
||||
column("Dialog", AudioFileModel::dialogProperty).apply {
|
||||
weigthedWidth(3.0)
|
||||
// Make dialog column wrap
|
||||
setCellFactory { tableColumn ->
|
||||
return@setCellFactory TableCell<AudioFileModel, String>().also { cell ->
|
||||
cell.graphic = Text().apply {
|
||||
textProperty().bind(cell.itemProperty())
|
||||
fillProperty().bind(cell.textFillProperty())
|
||||
val widthProperty = tableColumn.widthProperty()
|
||||
.minus(cell.paddingLeftProperty)
|
||||
.minus(cell.paddingRightProperty)
|
||||
wrappingWidthProperty().bind(widthProperty)
|
||||
}
|
||||
cell.prefHeight = Control.USE_COMPUTED_SIZE
|
||||
}
|
||||
}
|
||||
}
|
||||
column("Status", AudioFileModel::audioFileStateProperty).apply {
|
||||
weigthedWidth(1.0)
|
||||
setCellFactory {
|
||||
return@setCellFactory object : TableCell<AudioFileModel, AudioFileState>() {
|
||||
override fun updateItem(state: AudioFileState?, empty: Boolean) {
|
||||
super.updateItem(state, empty)
|
||||
graphic = if (state != null) {
|
||||
when (state.status) {
|
||||
AudioFileStatus.NotAnimated -> Text("Not animated").apply {
|
||||
fill = Color.GRAY
|
||||
}
|
||||
AudioFileStatus.Pending,
|
||||
AudioFileStatus.Animating -> HBox().apply {
|
||||
val progress: Double? = state.progress
|
||||
val indeterminate = -1.0
|
||||
val bar = progressbar(progress ?: indeterminate) {
|
||||
maxWidth = Double.MAX_VALUE
|
||||
}
|
||||
HBox.setHgrow(bar, Priority.ALWAYS)
|
||||
hbox {
|
||||
minWidth = 30.0
|
||||
if (progress != null) {
|
||||
text("${(progress * 100).toInt()}%") {
|
||||
alignment = Pos.BASELINE_RIGHT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioFileStatus.Canceling -> Text("Canceling")
|
||||
AudioFileStatus.Done -> Text("Done").apply {
|
||||
font = Font.font(font.family, FontWeight.BOLD, font.size)
|
||||
}
|
||||
}
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
column("", AudioFileModel::actionLabelProperty).apply {
|
||||
weigthedWidth(1.0)
|
||||
// Show button
|
||||
setCellFactory {
|
||||
return@setCellFactory object : TableCell<AudioFileModel, String>() {
|
||||
override fun updateItem(item: String?, empty: Boolean) {
|
||||
super.updateItem(item, empty)
|
||||
graphic = if (!empty)
|
||||
Button(item).apply {
|
||||
this.maxWidth = Double.MAX_VALUE
|
||||
setOnAction {
|
||||
val audioFileModel = this@tableview.items[index]
|
||||
audioFileModel.performAction()
|
||||
}
|
||||
val invalidProperty: Property<Boolean> = fileModelProperty
|
||||
.select { it!!.validProperty }
|
||||
.select { SimpleBooleanProperty(!it) }
|
||||
disableProperty().bind(invalidProperty)
|
||||
}
|
||||
else
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsProperty().bind(fileModelProperty.select { it!!.audioFileModelsProperty })
|
||||
}
|
||||
}
|
||||
|
||||
onDragOver = EventHandler<DragEvent> { event ->
|
||||
if (event.dragboard.hasFiles() && mainModel.animationFileModel?.busy != true) {
|
||||
event.acceptTransferModes(TransferMode.COPY)
|
||||
event.consume()
|
||||
}
|
||||
}
|
||||
onDragDropped = EventHandler<DragEvent> { event ->
|
||||
if (event.dragboard.hasFiles() && mainModel.animationFileModel?.busy != true) {
|
||||
filePathTextField!!.text = event.dragboard.files.firstOrNull()?.path
|
||||
event.isDropCompleted = true
|
||||
event.consume()
|
||||
}
|
||||
}
|
||||
|
||||
whenUndocked {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
filePathButton!!.onAction = EventHandler<ActionEvent> {
|
||||
val fileChooser = FileChooser().apply {
|
||||
title = "Open Spine JSON file"
|
||||
extensionFilters.addAll(
|
||||
FileChooser.ExtensionFilter("Spine JSON file (*.json)", "*.json"),
|
||||
FileChooser.ExtensionFilter("All files (*.*)", "*.*")
|
||||
)
|
||||
val lastDirectory = filePathTextField!!.text?.let { File(it).parentFile }
|
||||
if (lastDirectory != null && lastDirectory.isDirectory) {
|
||||
initialDirectory = lastDirectory
|
||||
}
|
||||
}
|
||||
val file = fileChooser.showOpenDialog(this@MainView.primaryStage)
|
||||
if (file != null) {
|
||||
filePathTextField!!.text = file.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderShapeCheckbox(shape: MouthShape, fileModelProperty: SimpleObjectProperty<AnimationFileModel?>, parent: EventTarget) {
|
||||
parent.label {
|
||||
textProperty().bind(
|
||||
fileModelProperty
|
||||
.select { it!!.mouthShapesProperty }
|
||||
.select { mouthShapes ->
|
||||
val hairSpace = "\u200A"
|
||||
val result = shape.toString() + hairSpace + if (mouthShapes.contains(shape)) "☑" else "☐"
|
||||
return@select SimpleStringProperty(result)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||
|
||||
data class MouthCue(val time: Double, val mouthShape: MouthShape)
|
|
@ -0,0 +1,55 @@
|
|||
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 firstMouthName = mouthNames.first()
|
||||
if (commonPrefix.length + commonSuffix.length >= firstMouthName.length) {
|
||||
return MouthNaming(commonPrefix, "", guessMouthShapeCasing(""))
|
||||
}
|
||||
|
||||
val shapeName = firstMouthName.substring(
|
||||
commonPrefix.length,
|
||||
firstMouthName.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,19 @@
|
|||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||
|
||||
enum class MouthShape {
|
||||
A, B, C, D, E, F, G, H, X;
|
||||
|
||||
val isBasic: Boolean
|
||||
get() = this.ordinal < basicShapeCount
|
||||
|
||||
val isExtended: Boolean
|
||||
get() = !this.isBasic
|
||||
|
||||
companion object {
|
||||
val basicShapeCount = 6
|
||||
|
||||
val basicShapes = MouthShape.values().take(basicShapeCount)
|
||||
|
||||
val extendedShapes = MouthShape.values().drop(basicShapeCount)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,174 @@
|
|||
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.*
|
||||
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 reportProgress: (Double?) -> Unit
|
||||
) : 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
|
||||
val outputFile = TemporaryTextFile()
|
||||
dialogFile.use { outputFile.use {
|
||||
val processBuilder = ProcessBuilder(createProcessBuilderArgs(dialogFile?.filePath)).apply {
|
||||
// See http://java-monitor.com/forum/showthread.php?t=4067
|
||||
redirectOutput(outputFile.filePath.toFile())
|
||||
}
|
||||
val process: Process = processBuilder.start()
|
||||
val stderr = BufferedReader(InputStreamReader(process.errorStream, StandardCharsets.UTF_8))
|
||||
try {
|
||||
while (true) {
|
||||
val line = stderr.interruptibleReadLine()
|
||||
val message = parseJsonObject(line)
|
||||
when (message.string("type")!!) {
|
||||
"progress" -> {
|
||||
reportProgress(message.double("value")!!)
|
||||
}
|
||||
"success" -> {
|
||||
reportProgress(1.0)
|
||||
val resultString = String(Files.readAllBytes(outputFile.filePath), StandardCharsets.UTF_8)
|
||||
return parseRhubarbResult(resultString)
|
||||
}
|
||||
"failure" -> {
|
||||
throw Exception(message.string("reason"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
process.destroyForcibly()
|
||||
throw e
|
||||
} catch (e: EOFException) {
|
||||
throw Exception("Rhubarb terminated unexpectedly.")
|
||||
} finally {
|
||||
process.waitFor();
|
||||
}
|
||||
}}
|
||||
|
||||
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(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,149 @@
|
|||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||
|
||||
import com.beust.klaxon.*
|
||||
import javafx.collections.FXCollections.observableSet
|
||||
import java.nio.charset.StandardCharsets
|
||||
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).normalize()
|
||||
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).normalize()
|
||||
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") }
|
||||
}
|
||||
|
||||
fun guessMouthSlot(): String? {
|
||||
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 }
|
||||
}
|
||||
|
||||
val animationNames = observableSet<String>(
|
||||
json.obj("animations")?.map{ it.key }?.toSet() ?: setOf()
|
||||
)
|
||||
|
||||
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"] = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
animationNames.add(animationName)
|
||||
}
|
||||
|
||||
fun save() {
|
||||
var string = json.toJsonString(prettyPrint = true)
|
||||
Files.write(filePath, listOf(string), StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
|
@ -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,71 @@
|
|||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||
|
||||
import javafx.application.Platform
|
||||
import javafx.beans.property.Property
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.locks.Condition
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
|
||||
val List<String>.commonPrefix: String get() {
|
||||
return if (isEmpty()) "" else this.reduce { result, string -> result.commonPrefixWith(string) }
|
||||
}
|
||||
|
||||
val List<String>.commonSuffix: String get() {
|
||||
return if (isEmpty()) "" else this.reduce { result, string -> result.commonSuffixWith(string) }
|
||||
}
|
||||
|
||||
fun <TValue, TProperty : Property<TValue>> TProperty.alsoListen(listener: (TValue) -> Unit) : TProperty {
|
||||
// Notify the listener of the initial value.
|
||||
// If we did this synchronously, the listener's state would have to be fully initialized the
|
||||
// moment this function is called. So calling this function during object initialization might
|
||||
// result in access to uninitialized state.
|
||||
Platform.runLater { listener(this.value) }
|
||||
|
||||
addListener({ _, _, newValue -> listener(newValue)})
|
||||
return this
|
||||
}
|
||||
|
||||
fun getExceptionMessage(action: () -> Unit): String? {
|
||||
try {
|
||||
action();
|
||||
} catch (e: Exception) {
|
||||
return e.message
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Invokes a Runnable on the JFX thread and waits until it's finished.
|
||||
* Similar to SwingUtilities.invokeAndWait.
|
||||
* Based on http://www.guigarage.com/2013/01/invokeandwait-for-javafx/
|
||||
*
|
||||
* @throws InterruptedException Execution was interrupted
|
||||
* @throws Throwable An exception occurred in the run method of the Runnable
|
||||
*/
|
||||
fun runAndWait(action: () -> Unit) {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action()
|
||||
} else {
|
||||
val lock = ReentrantLock()
|
||||
lock.withLock {
|
||||
val doneCondition = lock.newCondition()
|
||||
var throwable: Throwable? = null
|
||||
Platform.runLater {
|
||||
lock.withLock {
|
||||
try {
|
||||
action()
|
||||
} catch (e: Throwable) {
|
||||
throwable = e
|
||||
} finally {
|
||||
doneCondition.signal()
|
||||
}
|
||||
}
|
||||
}
|
||||
doneCondition.await()
|
||||
throwable?.let { throw it }
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 386 B |
Binary file not shown.
After Width: | Height: | Size: 6.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 942 B |
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1,14 @@
|
|||
cmake_minimum_required(VERSION 3.2)
|
||||
|
||||
set(vegasFiles
|
||||
"Debug Rhubarb.cs"
|
||||
"Debug Rhubarb.cs.config"
|
||||
"Import Rhubarb.cs"
|
||||
"Import Rhubarb.cs.config"
|
||||
"README.md"
|
||||
)
|
||||
|
||||
install(
|
||||
FILES ${vegasFiles}
|
||||
DESTINATION "extras/MagixVegas"
|
||||
)
|
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
|
@ -1,5 +1,7 @@
|
|||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue">ERROR</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=LocalizableElement/@EntryIndexedValue">DO_NOT_SHOW</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CommonFormatter/ALIGNMENT_TAB_FILL_STYLE/@EntryValue">USE_TABS_ONLY</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_ARGUMENT/@EntryValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_CALLS_CHAIN/@EntryValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CppFormatting/ALIGN_MULTILINE_CTOR_INIT/@EntryValue">False</s:Boolean>
|
||||
|
@ -25,6 +27,25 @@
|
|||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CppFormatting/SPACE_BEFORE_TEMPLATE_PARAMS/@EntryValue">False</s:Boolean>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CppFormatting/TYPE_DECLARATION_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CppFormatting/WRAP_ENUMERATION_STYLE/@EntryValue">CHOP_ALWAYS</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/ACCESSOR_DECLARATION_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/ACCESSOR_OWNER_DECLARATION_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/ALIGN_MULTILINE_BINARY_EXPRESSIONS_CHAIN/@EntryValue">False</s:Boolean>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/ANONYMOUS_METHOD_DECLARATION_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/CASE_BLOCK_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/EMPTY_BLOCK_STYLE/@EntryValue">TOGETHER</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_STYLE/@EntryValue">Tab</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INITIALIZER_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INVOCABLE_DECLARATION_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/OTHER_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_CATCH_ON_NEW_LINE/@EntryValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_ELSE_ON_NEW_LINE/@EntryValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_FINALLY_ON_NEW_LINE/@EntryValue">False</s:Boolean>
|
||||
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/TYPE_DECLARATION_BRACES/@EntryValue">END_OF_LINE</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/USE_INDENT_FROM_VS/@EntryValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/WRAP_BEFORE_BINARY_OPSIGN/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/WRAP_LINES/@EntryValue">False</s:Boolean>
|
||||
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForBuiltInTypes/@EntryValue">UseExplicitType</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForSimpleTypes/@EntryValue">UseVarWhenEvident</s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CppNaming/Rules/=Class_0020and_0020struct_0020fields/@EntryIndexedValue"><NamingElement Priority="10"><Descriptor Static="Indeterminate" Constexpr="Indeterminate" Const="Indeterminate" Volatile="Indeterminate" Accessibility="NOT_APPLICABLE"><type Name="class field" /><type Name="struct field" /></Descriptor><Policy Inspect="True" Prefix="" Suffix="_" Style="aaBb" /></NamingElement></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CppNaming/Rules/=Class_0020and_0020struct_0020methods/@EntryIndexedValue"><NamingElement Priority="9"><Descriptor Static="Indeterminate" Constexpr="Indeterminate" Const="Indeterminate" Volatile="Indeterminate" Accessibility="NOT_APPLICABLE"><type Name="member function" /></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></NamingElement></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CppNaming/Rules/=Class_0020and_0020struct_0020public_0020fields/@EntryIndexedValue"><NamingElement Priority="11"><Descriptor Static="Indeterminate" Constexpr="Indeterminate" Const="Indeterminate" Volatile="Indeterminate" Accessibility="PUBLIC"><type Name="class field" /><type Name="struct field" /></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></NamingElement></s:String>
|
||||
|
@ -42,6 +63,7 @@
|
|||
<s:String x:Key="/Default/CodeStyle/Naming/CppNaming/Rules/=Typedefs/@EntryIndexedValue"><NamingElement Priority="17"><Descriptor Static="Indeterminate" Constexpr="Indeterminate" Const="Indeterminate" Volatile="Indeterminate" Accessibility="NOT_APPLICABLE"><type Name="type alias" /><type Name="typedef" /></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></NamingElement></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CppNaming/Rules/=Union_0020members/@EntryIndexedValue"><NamingElement Priority="12"><Descriptor Static="Indeterminate" Constexpr="Indeterminate" Const="Indeterminate" Volatile="Indeterminate" Accessibility="NOT_APPLICABLE"><type Name="union member" /></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></NamingElement></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CppNaming/Rules/=Unions/@EntryIndexedValue"><NamingElement Priority="3"><Descriptor Static="Indeterminate" Constexpr="Indeterminate" Const="Indeterminate" Volatile="Indeterminate" Accessibility="NOT_APPLICABLE"><type Name="union" /></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></NamingElement></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateInstanceFields/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/JavaScriptNaming/UserRules/=JS_005FBLOCK_005FSCOPE_005FCONSTANT/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/JavaScriptNaming/UserRules/=JS_005FBLOCK_005FSCOPE_005FFUNCTION/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/JavaScriptNaming/UserRules/=JS_005FBLOCK_005FSCOPE_005FVARIABLE/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></s:String>
|
||||
|
@ -83,6 +105,10 @@
|
|||
<s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=NAMESPACE_005FALIAS/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=XAML_005FFIELD/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></s:String>
|
||||
<s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/UserRules/=XAML_005FRESOURCE/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></s:String>
|
||||
<s:String x:Key="/Default/Environment/Hierarchy/PsiConfigurationSettingsKey/CustomLocation/@EntryValue">C:\Users\Daniel\AppData\Local\JetBrains\Transient\ReSharperPlatformVs14\v09\SolutionCaches</s:String>
|
||||
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002ECpp_002ECodeStyle_002ESettingsUpgrade_002EFunctionReturnStyleSettingsUpgrader/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002ECpp_002ECodeStyle_002ESettingsUpgrade_002ENamespaceIndentationSettingsUpgrader/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EAddAccessorOwnerDeclarationBracesMigration/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean>
|
||||
</wpf:ResourceDictionary>
|
Loading…
Reference in New Issue