Compare commits
11 Commits
7f4f042121
...
f239a51fb0
Author | SHA1 | Date |
---|---|---|
|
f239a51fb0 | |
|
3283e66db4 | |
|
4a30e5a7d2 | |
|
3847b2fe59 | |
|
99315be8df | |
|
79c923b916 | |
|
260c22c7ed | |
|
00e4996cd6 | |
|
9d3782a08b | |
|
b365c4c1d5 | |
|
71259421a9 |
|
@ -0,0 +1,26 @@
|
||||||
|
# Config file for clang-format, a C/C++/... code formatter.
|
||||||
|
|
||||||
|
BasedOnStyle: Chromium
|
||||||
|
# TODO: Uncomment once clang-format 20 is out
|
||||||
|
# BreakBinaryOperations: RespectPrecedence
|
||||||
|
BreakConstructorInitializers: AfterColon
|
||||||
|
AccessModifierOffset: -4
|
||||||
|
AlignAfterOpenBracket: BlockIndent
|
||||||
|
AlignOperands: DontAlign
|
||||||
|
AllowAllParametersOfDeclarationOnNextLine: true
|
||||||
|
AllowShortCaseLabelsOnASingleLine: true
|
||||||
|
AllowShortFunctionsOnASingleLine: Empty
|
||||||
|
AllowShortIfStatementsOnASingleLine: WithoutElse
|
||||||
|
BinPackArguments: false
|
||||||
|
BreakBeforeBinaryOperators: NonAssignment
|
||||||
|
BreakStringLiterals: false
|
||||||
|
ColumnLimit: 100
|
||||||
|
CompactNamespaces: true
|
||||||
|
IncludeBlocks: Regroup
|
||||||
|
IndentWidth: 4
|
||||||
|
InsertNewlineAtEOF: true
|
||||||
|
LineEnding: LF
|
||||||
|
PackConstructorInitializers: Never
|
||||||
|
SeparateDefinitionBlocks: Always
|
||||||
|
SortIncludes: CaseInsensitive
|
||||||
|
SpacesBeforeTrailingComments: 1
|
|
@ -5,11 +5,10 @@ root = true
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_style = tab
|
indent_style = space
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
[*.{js,ts,yaml,yml}]
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Config file for gersemi, a CMake code formatter.
|
||||||
|
|
||||||
|
line_length: 100
|
||||||
|
warn_about_unknown_commands: false
|
|
@ -10,35 +10,52 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- description: Windows - Visual Studio
|
- description: Windows - Visual Studio
|
||||||
os: windows-2019
|
os: windows-2022
|
||||||
cmakeOptions: '-G "Visual Studio 16 2019" -A x64'
|
cmakeOptions: '-G "Visual Studio 17 2022" -A x64'
|
||||||
publish: true
|
publish: true
|
||||||
- description: macOS - Xcode
|
- description: macOS - Xcode
|
||||||
os: macos-13
|
os: macos-14
|
||||||
cmakeOptions: ""
|
cmakeOptions: ''
|
||||||
publish: true
|
publish: true
|
||||||
- description: Linux - GCC
|
- description: Linux - GCC
|
||||||
os: ubuntu-20.04
|
os: ubuntu-24.04
|
||||||
cmakeOptions: "-D CMAKE_C_COMPILER=gcc-10 -D CMAKE_CXX_COMPILER=g++-10"
|
cmakeOptions: '-D CMAKE_C_COMPILER=gcc-14 -D CMAKE_CXX_COMPILER=g++-14'
|
||||||
publish: true
|
publish: true
|
||||||
- description: Linux - Clang
|
- description: Linux - Clang
|
||||||
os: ubuntu-20.04
|
os: ubuntu-24.04
|
||||||
cmakeOptions: "-D CMAKE_C_COMPILER=clang-12 -D CMAKE_CXX_COMPILER=clang++-12"
|
cmakeOptions: '-D CMAKE_C_COMPILER=clang-18 -D CMAKE_CXX_COMPILER=clang++-18'
|
||||||
publish: false
|
publish: false
|
||||||
env:
|
env:
|
||||||
BOOST_ROOT: ${{ github.workspace }}/lib/boost
|
BOOST_ROOT: ${{ github.workspace }}/lib/boost
|
||||||
BOOST_URL: https://sourceforge.net/projects/boost/files/boost/1.86.0/boost_1_86_0.tar.bz2/download
|
BOOST_URL: https://sourceforge.net/projects/boost/files/boost/1.86.0/boost_1_86_0.tar.bz2/download
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install Deno
|
||||||
|
uses: denoland/setup-deno@v2
|
||||||
|
- name: Deactivate EOL conversion
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
git config --global core.autocrlf false
|
||||||
|
git config --global core.eol lf
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
lfs: true
|
lfs: true
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
- name: Install Python wheels
|
||||||
|
shell: bash
|
||||||
|
run: pip install -r requirements.txt
|
||||||
- name: Restore Boost from cache
|
- name: Restore Boost from cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
id: cache-boost
|
id: cache-boost
|
||||||
with:
|
with:
|
||||||
path: ${{ env.BOOST_ROOT }}
|
path: ${{ env.BOOST_ROOT }}
|
||||||
key: ${{ env.BOOST_URL }}
|
key: ${{ env.BOOST_URL }}
|
||||||
|
- name: Lint
|
||||||
|
shell: bash
|
||||||
|
run: doit check-formatted
|
||||||
- name: Download Boost
|
- name: Download Boost
|
||||||
if: steps.cache-boost.outputs.cache-hit != 'true'
|
if: steps.cache-boost.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
@ -49,28 +66,29 @@ jobs:
|
||||||
fi
|
fi
|
||||||
mkdir -p $BOOST_ROOT
|
mkdir -p $BOOST_ROOT
|
||||||
curl --insecure -L $BOOST_URL | tar -xj --strip-components=1 -C $BOOST_ROOT
|
curl --insecure -L $BOOST_URL | tar -xj --strip-components=1 -C $BOOST_ROOT
|
||||||
- name: Build Rhubarb
|
- name: Build and package
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
JAVA_HOME=$JAVA_HOME_11_X64
|
JAVA_HOME=$JAVA_HOME_11_X64
|
||||||
mkdir build
|
mkdir rhubarb/build
|
||||||
cd build
|
(cd rhubarb/build && cmake ${{ matrix.cmakeOptions }} ..)
|
||||||
cmake ${{ matrix.cmakeOptions }} ..
|
doit package
|
||||||
cmake --build . --config Release --target package
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||||
./build/rhubarb/Release/runTests.exe
|
./rhubarb/build/Release/runTests.exe
|
||||||
else
|
else
|
||||||
./build/rhubarb/runTests
|
./rhubarb/build/runTests
|
||||||
fi
|
fi
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
if: ${{ matrix.publish }}
|
if: ${{ matrix.publish }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.description }}
|
name: 'binaries ${{ matrix.description }}'
|
||||||
path: build/*.zip
|
path: |
|
||||||
|
artifacts/*.zip
|
||||||
|
artifacts/*.tar.gz
|
||||||
release:
|
release:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -82,6 +100,8 @@ jobs:
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
files: "*.zip"
|
files: |
|
||||||
|
'*.zip'
|
||||||
|
'*.tar.gz'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
.vs/
|
.vs/
|
||||||
build/
|
.vscode/
|
||||||
*.user
|
*.user
|
||||||
|
artifacts/
|
||||||
|
build/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
.doit.db.*
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Config file for Prettier, a JavaScript/TypeScript code formatter.
|
||||||
|
|
||||||
|
tabWidth: 2
|
||||||
|
printWidth: 100
|
||||||
|
singleQuote: true
|
||||||
|
arrowParens: avoid
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
- files: '*.jsx' # Adobe JSX, not React
|
||||||
|
options:
|
||||||
|
trailingComma: none
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Config file for Ruff, a Python code formatter.
|
||||||
|
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[format]
|
||||||
|
quote-style = "single"
|
||||||
|
skip-magic-trailing-comma = true
|
|
@ -1,41 +0,0 @@
|
||||||
cmake_minimum_required(VERSION 3.2)
|
|
||||||
|
|
||||||
include(appInfo.cmake)
|
|
||||||
|
|
||||||
project(${appName})
|
|
||||||
|
|
||||||
# Build and install main executable
|
|
||||||
add_subdirectory(rhubarb)
|
|
||||||
|
|
||||||
# Build and install extras
|
|
||||||
add_subdirectory("extras/AdobeAfterEffects")
|
|
||||||
add_subdirectory("extras/MagixVegas")
|
|
||||||
add_subdirectory("extras/EsotericSoftwareSpine")
|
|
||||||
|
|
||||||
# Install misc. files
|
|
||||||
install(
|
|
||||||
FILES README.adoc LICENSE.md CHANGELOG.md
|
|
||||||
DESTINATION .
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure CPack
|
|
||||||
function(get_short_system_name variable)
|
|
||||||
if ("${CMAKE_SYSTEM_NAME}" STREQUAL "Darwin")
|
|
||||||
set(${variable} "macOS" PARENT_SCOPE)
|
|
||||||
else()
|
|
||||||
set(${variable} "${CMAKE_SYSTEM_NAME}" PARENT_SCOPE)
|
|
||||||
endif()
|
|
||||||
endfunction()
|
|
||||||
|
|
||||||
set(CPACK_PACKAGE_NAME ${appName})
|
|
||||||
string(REPLACE " " "-" CPACK_PACKAGE_NAME "${CPACK_PACKAGE_NAME}")
|
|
||||||
get_short_system_name(CPACK_SYSTEM_NAME)
|
|
||||||
set(CPACK_PACKAGE_VERSION_MAJOR ${appVersionMajor})
|
|
||||||
set(CPACK_PACKAGE_VERSION_MINOR ${appVersionMinor})
|
|
||||||
set(CPACK_PACKAGE_VERSION_PATCH ${appVersionPatch})
|
|
||||||
set(CPACK_PACKAGE_VERSION ${appVersion})
|
|
||||||
set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CPACK_SYSTEM_NAME}")
|
|
||||||
set(CPACK_GENERATOR ZIP)
|
|
||||||
|
|
||||||
# Run CPack
|
|
||||||
include(CPack)
|
|
28
README.adoc
|
@ -44,7 +44,7 @@ https://www.youtube.com/watch?v=zzdPSFJRlEo[image:http://img.youtube.com/vi/zzdP
|
||||||
[[afterEffects]]
|
[[afterEffects]]
|
||||||
=== Adobe After Effects
|
=== Adobe After Effects
|
||||||
|
|
||||||
You can use Rhubarb Lip Sync to animate dialog right from Adobe After Effects. For more information, <<extras/AdobeAfterEffects/README.adoc#,follow this link>> or see the directory `extras/AdobeAfterEffects`.
|
You can use Rhubarb Lip Sync to animate dialog right from Adobe After Effects. For more information, <<extras/adobe-after-effects/README.adoc#,follow this link>> or see the directory `extras/adobe-after-effects`.
|
||||||
|
|
||||||
image:img/after-effects.png[]
|
image:img/after-effects.png[]
|
||||||
|
|
||||||
|
@ -58,14 +58,14 @@ image:img/moho.png[]
|
||||||
[[spine]]
|
[[spine]]
|
||||||
=== Spine by Esoteric Software
|
=== Spine by Esoteric Software
|
||||||
|
|
||||||
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, <<extras/EsotericSoftwareSpine/README.adoc#,follow this link>> or see the directory `extras/EsotericSoftwareSpine` 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, <<extras/esoteric-software-spine/README.adoc#,follow this link>> or see the directory `extras/esoteric-software-spine` of the download.
|
||||||
|
|
||||||
image:img/spine.png[]
|
image:img/spine.png[]
|
||||||
|
|
||||||
[[vegas]]
|
[[vegas]]
|
||||||
=== Vegas Pro by Magix
|
=== Vegas Pro by Magix
|
||||||
|
|
||||||
Rhubarb Lip Sync also comes with two plugin scripts for Vegas Pro (previously Sony Vegas). For more information, <<extras/MagixVegas/README.adoc#,follow this link>> or see the directory `extras/MagixVegas` of the download.
|
Rhubarb Lip Sync also comes with two plugin scripts for Vegas Pro (previously Sony Vegas). For more information, <<extras/magix-vegas/README.adoc#,follow this link>> or see the directory `extras/magix-vegas` of the download.
|
||||||
|
|
||||||
image:img/vegas.png[]
|
image:img/vegas.png[]
|
||||||
|
|
||||||
|
@ -195,9 +195,9 @@ _Default value: 24_
|
||||||
! X ! rest
|
! X ! rest
|
||||||
!===
|
!===
|
||||||
|
|
||||||
*Caution:* This mapping is only applied when exporting, _after_ the recording has been animated. To control which mouth shapes to use, use the <<extendedShapes,`extendedShapes`>> option _with the alphabetic names_.
|
CAUTION: This mapping is only applied when exporting, _after_ the recording has been animated. To control which mouth shapes to use, use the <<extendedShapes,`extendedShapes`>> option _with the alphabetic names_.
|
||||||
|
|
||||||
*Tip:* For optimal results, make sure your mouth drawings follow the guidelines in the <<mouth-shapes>> section. This is easier if you stick to the alphabetic names instead of the Preston Blair names. The only situation where you _need_ to use the Preston Blair names is when you're using OpenToonz, because OpenToonz only supports the Preston Blair names.
|
*Tip*: For optimal results, make sure your mouth drawings follow the guidelines in the <<mouth-shapes>> section. This is easier if you stick to the alphabetic names instead of the Preston Blair names. The only situation where you _need_ to use the Preston Blair names is when you're using OpenToonz, because OpenToonz only supports the Preston Blair names.
|
||||||
|
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
@ -380,26 +380,8 @@ Rhubarb Lip Sync uses Semantic Versioning (SemVer) for its command-line interfac
|
||||||
|
|
||||||
As a rule of thumb, everything you can use through the command-line interface adheres to SemVer. Everything else (i.e., the source code, integrations with third-party software, etc.) does not.
|
As a rule of thumb, everything you can use through the command-line interface adheres to SemVer. Everything else (i.e., the source code, integrations with third-party software, etc.) does not.
|
||||||
|
|
||||||
[[building-from-source]]
|
|
||||||
== Building from source
|
== Building from source
|
||||||
|
|
||||||
To use Rhubarb Lip Sync on Windows, macOS, or Linux, you can just download the binary release for your operating system. If you want to modify the code or use Rhubarb on a less-common operating system, this section describes how to build it yourself.
|
|
||||||
|
|
||||||
You'll need the following software installed:
|
|
||||||
|
|
||||||
* CMake 3.10+
|
|
||||||
* A C{plus}{plus} compiler that supports C{plus}{plus}17 +
|
|
||||||
(Rhubarb Lip Sync is regularly built using Visual Studio 2019, Xcode 14, GCC 10, and Clang 12.)
|
|
||||||
* A current version of Boost
|
|
||||||
* JDK 8.x (for building Rhubarb for Spine)
|
|
||||||
|
|
||||||
Then, follow these steps:
|
|
||||||
|
|
||||||
. Create an empty directory `/build` within the Rhubarb repository
|
|
||||||
. Move to the new `/build` directory
|
|
||||||
. Configure CMake by running `cmake ..` +
|
|
||||||
Optionally, pass flags for setting the generator, compiler etc.. For working examples, see `.github\workflows\ci.yml`.
|
|
||||||
. Build Rhubarb Lip Sync by running `cmake --build . --config Release`
|
|
||||||
|
|
||||||
== I'd love to hear from you!
|
== I'd love to hear from you!
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
appName = "Rhubarb Lip Sync"
|
||||||
|
|
||||||
|
# Can be any valid SemVer version, including suffixes
|
||||||
|
appVersion = "1.13.0"
|
|
@ -1,8 +0,0 @@
|
||||||
cmake_minimum_required(VERSION 3.2)
|
|
||||||
|
|
||||||
set(appName "Rhubarb Lip Sync")
|
|
||||||
set(appVersionMajor 1)
|
|
||||||
set(appVersionMinor 13)
|
|
||||||
set(appVersionPatch 0)
|
|
||||||
set(appVersionSuffix "")
|
|
||||||
set(appVersion "${appVersionMajor}.${appVersionMinor}.${appVersionPatch}${appVersionSuffix}")
|
|
|
@ -0,0 +1,279 @@
|
||||||
|
"""Collection of tasks. Run using `doit <task>`."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
from functools import cache
|
||||||
|
from gitignore_parser import parse_gitignore
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
from shutil import rmtree, copy, copytree, make_archive
|
||||||
|
import platform
|
||||||
|
import tomllib
|
||||||
|
import re
|
||||||
|
|
||||||
|
root_dir = Path(__file__).parent
|
||||||
|
rhubarb_dir = root_dir / 'rhubarb'
|
||||||
|
rhubarb_build_dir = rhubarb_dir / 'build'
|
||||||
|
extras_dir = root_dir / 'extras'
|
||||||
|
|
||||||
|
|
||||||
|
def task_format():
|
||||||
|
"""Format source files"""
|
||||||
|
|
||||||
|
files_by_formatters = get_files_by_formatters()
|
||||||
|
for formatter, files in files_by_formatters.items():
|
||||||
|
yield {
|
||||||
|
'name': formatter.value,
|
||||||
|
'actions': [(format, [files, formatter])],
|
||||||
|
'file_dep': files,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def task_check_formatted():
|
||||||
|
"""Fails unless source files are formatted"""
|
||||||
|
|
||||||
|
files_by_formatters = get_files_by_formatters()
|
||||||
|
for formatter, files in files_by_formatters.items():
|
||||||
|
yield {
|
||||||
|
'basename': 'check-formatted',
|
||||||
|
'name': formatter.value,
|
||||||
|
'actions': [(format, [files, formatter], {'check_only': True})],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Formatter(Enum):
|
||||||
|
"""A source code formatter."""
|
||||||
|
|
||||||
|
CLANG_FORMAT = 'clang-format'
|
||||||
|
GERSEMI = 'gersemi'
|
||||||
|
PRETTIER = 'prettier'
|
||||||
|
RUFF = 'ruff'
|
||||||
|
|
||||||
|
|
||||||
|
def format(files: List[Path], formatter: Formatter, *, check_only: bool = False):
|
||||||
|
match formatter:
|
||||||
|
case Formatter.CLANG_FORMAT:
|
||||||
|
# Pass relative paths to avoid exceeding the maximum command line length
|
||||||
|
relative_paths = [file.relative_to(root_dir) for file in files]
|
||||||
|
subprocess.run(
|
||||||
|
['clang-format', '--dry-run' if check_only else '-i', '--Werror', *relative_paths],
|
||||||
|
cwd=root_dir,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
case Formatter.GERSEMI:
|
||||||
|
subprocess.run(['gersemi', '--check' if check_only else '-i', *files], check=True)
|
||||||
|
case Formatter.PRETTIER:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
*['deno', 'run', '-A', 'npm:prettier@3.4.2'],
|
||||||
|
*['--check' if check_only else '--write', '--log-level', 'warn', *files],
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
case Formatter.RUFF:
|
||||||
|
subprocess.run(
|
||||||
|
['ruff', '--quiet', 'format', *(['--check'] if check_only else []), *files],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
raise ValueError(f'Unknown formatter: {formatter}')
|
||||||
|
|
||||||
|
|
||||||
|
def task_configure_rhubarb():
|
||||||
|
"""Configure CMake for the Rhubarb binary"""
|
||||||
|
|
||||||
|
def configure_rhubarb():
|
||||||
|
ensure_dir(rhubarb_build_dir)
|
||||||
|
subprocess.run(['cmake', '..'], cwd=rhubarb_build_dir, check=True)
|
||||||
|
|
||||||
|
return {'basename': 'configure-rhubarb', 'actions': [configure_rhubarb]}
|
||||||
|
|
||||||
|
|
||||||
|
def task_build_rhubarb():
|
||||||
|
"""Build the Rhubarb binary"""
|
||||||
|
|
||||||
|
def build_rhubarb():
|
||||||
|
subprocess.run(
|
||||||
|
['cmake', '--build', '.', '--config', 'Release'], cwd=rhubarb_build_dir, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'basename': 'build-rhubarb', 'actions': [build_rhubarb]}
|
||||||
|
|
||||||
|
|
||||||
|
def task_build_spine():
|
||||||
|
"""Build Rhubarb for Spine"""
|
||||||
|
|
||||||
|
def build_spine():
|
||||||
|
onWindows = platform.system() == 'Windows'
|
||||||
|
subprocess.run(
|
||||||
|
['gradlew.bat' if onWindows else './gradlew', 'build'],
|
||||||
|
cwd=extras_dir / 'esoteric-software-spine',
|
||||||
|
check=True,
|
||||||
|
shell=onWindows,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {'basename': 'build-spine', 'actions': [build_spine]}
|
||||||
|
|
||||||
|
|
||||||
|
def task_package():
|
||||||
|
"""Package all artifacts into an archive file"""
|
||||||
|
|
||||||
|
with open(root_dir / 'app-info.toml', 'rb') as file:
|
||||||
|
appInfo = tomllib.load(file)
|
||||||
|
|
||||||
|
os_name = 'macOS' if platform.system() == 'Darwin' else platform.system()
|
||||||
|
file_name = f"{appInfo['appName'].replace(' ', '-')}-{appInfo['appVersion']}-{os_name}"
|
||||||
|
|
||||||
|
artifacts_dir = ensure_empty_dir(root_dir / 'artifacts')
|
||||||
|
tree_dir = ensure_dir(artifacts_dir.joinpath(file_name))
|
||||||
|
|
||||||
|
def collect_artifacts():
|
||||||
|
# Misc. files
|
||||||
|
asciidoc_to_html(root_dir / 'README.adoc', tree_dir / 'README.html')
|
||||||
|
markdown_to_html(root_dir / 'LICENSE.md', tree_dir / 'LICENSE.html')
|
||||||
|
markdown_to_html(root_dir / 'CHANGELOG.md', tree_dir / 'CHANGELOG.html')
|
||||||
|
copytree(root_dir / 'img', tree_dir / 'img')
|
||||||
|
|
||||||
|
# Rhubarb
|
||||||
|
subprocess.run(
|
||||||
|
['cmake', '--install', '.', '--prefix', tree_dir], cwd=rhubarb_build_dir, check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Adobe After Effects script
|
||||||
|
src = extras_dir / 'adobe-after-effects'
|
||||||
|
dst_extras_dir = ensure_dir(tree_dir / 'extras')
|
||||||
|
dst = ensure_dir(dst_extras_dir / 'adobe-after-effects')
|
||||||
|
asciidoc_to_html(src / 'README.adoc', dst / 'README.html')
|
||||||
|
copy(src / 'Rhubarb Lip Sync.jsx', dst)
|
||||||
|
|
||||||
|
# Rhubarb for Spine
|
||||||
|
src = extras_dir / 'esoteric-software-spine'
|
||||||
|
dst = ensure_dir(dst_extras_dir / 'esoteric-software-spine')
|
||||||
|
asciidoc_to_html(src / 'README.adoc', dst / 'README.html')
|
||||||
|
for file in (src / 'build' / 'libs').iterdir():
|
||||||
|
copy(file, dst)
|
||||||
|
|
||||||
|
# Magix Vegas
|
||||||
|
src = extras_dir / 'magix-vegas'
|
||||||
|
dst = ensure_dir(dst_extras_dir / 'magix-vegas')
|
||||||
|
asciidoc_to_html(src / 'README.adoc', dst / 'README.html')
|
||||||
|
copy(src / 'Debug Rhubarb.cs', dst)
|
||||||
|
copy(src / 'Debug Rhubarb.cs.config', dst)
|
||||||
|
copy(src / 'Import Rhubarb.cs', dst)
|
||||||
|
copy(src / 'Import Rhubarb.cs.config', dst)
|
||||||
|
|
||||||
|
def pack_artifacts():
|
||||||
|
zip_base_name = tree_dir
|
||||||
|
format = 'gztar' if platform.system() == 'Linux' else 'zip'
|
||||||
|
make_archive(zip_base_name, format, tree_dir)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'actions': [collect_artifacts, pack_artifacts],
|
||||||
|
'task_dep': ['build-rhubarb', 'build-spine'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_files_by_formatters() -> Dict[Formatter, List[Path]]:
|
||||||
|
"""Returns a dict with all formattable code files grouped by formatter."""
|
||||||
|
|
||||||
|
is_gitignored = parse_gitignore(root_dir / '.gitignore')
|
||||||
|
|
||||||
|
def is_hidden(path: Path):
|
||||||
|
return path.name.startswith('.')
|
||||||
|
|
||||||
|
def is_third_party(path: Path):
|
||||||
|
return path.name == 'lib' or path.name == 'gradle'
|
||||||
|
|
||||||
|
result = {formatter: [] for formatter in Formatter}
|
||||||
|
|
||||||
|
def visit(dir: Path):
|
||||||
|
for path in dir.iterdir():
|
||||||
|
if is_gitignored(path) or is_hidden(path) or is_third_party(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
formatter = get_formatter(path)
|
||||||
|
if formatter is not None:
|
||||||
|
result[formatter].append(path)
|
||||||
|
else:
|
||||||
|
visit(path)
|
||||||
|
|
||||||
|
visit(root_dir)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_formatter(path: Path) -> Optional[Formatter]:
|
||||||
|
"""Returns the formatter to use for the given code file, if any."""
|
||||||
|
|
||||||
|
match path.suffix.lower():
|
||||||
|
case '.c' | '.cpp' | '.h':
|
||||||
|
return Formatter.CLANG_FORMAT
|
||||||
|
case '.cmake':
|
||||||
|
return Formatter.GERSEMI
|
||||||
|
case _ if path.name.lower() == 'cmakelists.txt':
|
||||||
|
return Formatter.GERSEMI
|
||||||
|
case '.js' | '.jsx' | '.ts':
|
||||||
|
return Formatter.PRETTIER
|
||||||
|
case '.py':
|
||||||
|
return Formatter.RUFF
|
||||||
|
|
||||||
|
|
||||||
|
def asciidoc_to_html(src: Path, dst: Path):
|
||||||
|
subprocess.run(
|
||||||
|
['deno', 'run', '-A', 'npm:asciidoctor@3.0.0', '-a', 'toc=left', '-o', dst, src], check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_html(src: Path, dst: Path):
|
||||||
|
tmp = dst.parent.joinpath(f'{src.stem}-tmp.adoc')
|
||||||
|
try:
|
||||||
|
markdown_to_asciidoc(src, tmp)
|
||||||
|
asciidoc_to_html(tmp, dst)
|
||||||
|
finally:
|
||||||
|
tmp.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_asciidoc(src: Path, dst: Path):
|
||||||
|
"""Cheap best-effort heuristics for converting Markdown to AsciiDoc"""
|
||||||
|
|
||||||
|
markup = src.read_text()
|
||||||
|
|
||||||
|
# Convert headings
|
||||||
|
markup = re.sub('^#+', lambda match: '=' * len(match[0]), markup, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
# Convert italics
|
||||||
|
markup = re.sub(
|
||||||
|
r'(?<![*_])[*_]((?!\s)[^*_\n]+(?<!\s))[*_](?![*_])', lambda match: f'_{match[1]}_', markup
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert boldface
|
||||||
|
markup = re.sub(
|
||||||
|
r'(?<![*_])[*_]{2}((?!\s)[^*_\n]+(?<!\s))[*_]{2}(?![*_])',
|
||||||
|
lambda match: f'*{match[1]}*',
|
||||||
|
markup,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert links
|
||||||
|
markup = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', lambda match: f'{match[2]}[{match[1]}]', markup)
|
||||||
|
|
||||||
|
# Convert continuations
|
||||||
|
markup = re.sub(r'\n\n\s+', '\n+\n', markup)
|
||||||
|
|
||||||
|
dst.write_text(markup)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(dir: Path) -> Path:
|
||||||
|
"""Makes sure the given directory exists."""
|
||||||
|
|
||||||
|
if not dir.exists():
|
||||||
|
dir.mkdir()
|
||||||
|
return dir
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_empty_dir(dir: Path) -> Path:
|
||||||
|
"""Makes sure the given directory exists and is empty."""
|
||||||
|
|
||||||
|
if dir.exists():
|
||||||
|
rmtree(dir)
|
||||||
|
return ensure_dir(dir)
|
|
@ -1,11 +0,0 @@
|
||||||
cmake_minimum_required(VERSION 3.2)
|
|
||||||
|
|
||||||
set(afterEffectsFiles
|
|
||||||
"Rhubarb Lip Sync.jsx"
|
|
||||||
"README.adoc"
|
|
||||||
)
|
|
||||||
|
|
||||||
install(
|
|
||||||
FILES ${afterEffectsFiles}
|
|
||||||
DESTINATION "extras/AdobeAfterEffects"
|
|
||||||
)
|
|
|
@ -1,758 +0,0 @@
|
||||||
// Polyfill for Object.assign
|
|
||||||
"function"!=typeof Object.assign&&(Object.assign=function(a,b){"use strict";if(null==a)throw new TypeError("Cannot convert undefined or null to object");for(var c=Object(a),d=1;d<arguments.length;d++){var e=arguments[d];if(null!=e)for(var f in e)Object.prototype.hasOwnProperty.call(e,f)&&(c[f]=e[f])}return c});
|
|
||||||
|
|
||||||
// Polyfill for Array.isArray
|
|
||||||
Array.isArray||(Array.isArray=function(r){return"[object Array]"===Object.prototype.toString.call(r)});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.map
|
|
||||||
Array.prototype.map||(Array.prototype.map=function(r){var t,n,o;if(null==this)throw new TypeError("this is null or not defined");var e=Object(this),i=e.length>>>0;if("function"!=typeof r)throw new TypeError(r+" is not a function");for(arguments.length>1&&(t=arguments[1]),n=new Array(i),o=0;o<i;){var a,p;o in e&&(a=e[o],p=r.call(t,a,o,e),n[o]=p),o++}return n});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.every
|
|
||||||
Array.prototype.every||(Array.prototype.every=function(r,t){"use strict";var e,n;if(null==this)throw new TypeError("this is null or not defined");var o=Object(this),i=o.length>>>0;if("function"!=typeof r)throw new TypeError;for(arguments.length>1&&(e=t),n=0;n<i;){var y;if(n in o&&(y=o[n],!r.call(e,y,n,o)))return!1;n++}return!0});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.find
|
|
||||||
Array.prototype.find||(Array.prototype.find=function(r){if(null===this)throw new TypeError("Array.prototype.find called on null or undefined");if("function"!=typeof r)throw new TypeError("callback must be a function");for(var n=Object(this),t=n.length>>>0,o=arguments[1],e=0;e<t;e++){var f=n[e];if(r.call(o,f,e,n))return f}});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.filter
|
|
||||||
Array.prototype.filter||(Array.prototype.filter=function(r){"use strict";if(void 0===this||null===this)throw new TypeError;var t=Object(this),e=t.length>>>0;if("function"!=typeof r)throw new TypeError;for(var i=[],o=arguments.length>=2?arguments[1]:void 0,n=0;n<e;n++)if(n in t){var f=t[n];r.call(o,f,n,t)&&i.push(f)}return i});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.forEach
|
|
||||||
Array.prototype.forEach||(Array.prototype.forEach=function(a,b){var c,d;if(null===this)throw new TypeError(" this is null or not defined");var e=Object(this),f=e.length>>>0;if("function"!=typeof a)throw new TypeError(a+" is not a function");for(arguments.length>1&&(c=b),d=0;d<f;){var g;d in e&&(g=e[d],a.call(c,g,d,e)),d++}});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.includes
|
|
||||||
Array.prototype.includes||(Array.prototype.includes=function(r,t){if(null==this)throw new TypeError('"this" is null or not defined');var e=Object(this),n=e.length>>>0;if(0===n)return!1;for(var i=0|t,o=Math.max(i>=0?i:n-Math.abs(i),0);o<n;){if(function(r,t){return r===t||"number"==typeof r&&"number"==typeof t&&isNaN(r)&&isNaN(t)}(e[o],r))return!0;o++}return!1});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.indexOf
|
|
||||||
Array.prototype.indexOf||(Array.prototype.indexOf=function(r,t){var n;if(null==this)throw new TypeError('"this" is null or not defined');var e=Object(this),i=e.length>>>0;if(0===i)return-1;var o=0|t;if(o>=i)return-1;for(n=Math.max(o>=0?o:i-Math.abs(o),0);n<i;){if(n in e&&e[n]===r)return n;n++}return-1});
|
|
||||||
|
|
||||||
// Polyfill for Array.prototype.some
|
|
||||||
Array.prototype.some||(Array.prototype.some=function(r){"use strict";if(null==this)throw new TypeError("Array.prototype.some called on null or undefined");if("function"!=typeof r)throw new TypeError;for(var e=Object(this),o=e.length>>>0,t=arguments.length>=2?arguments[1]:void 0,n=0;n<o;n++)if(n in e&&r.call(t,e[n],n,e))return!0;return!1});
|
|
||||||
|
|
||||||
// Polyfill for String.prototype.trim
|
|
||||||
String.prototype.trim||(String.prototype.trim=function(){return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")});
|
|
||||||
|
|
||||||
// Polyfill for JSON
|
|
||||||
"object"!=typeof JSON&&(JSON={}),function(){"use strict";function f(a){return a<10?"0"+a:a}function this_value(){return this.valueOf()}function quote(a){return rx_escapable.lastIndex=0,rx_escapable.test(a)?'"'+a.replace(rx_escapable,function(a){var b=meta[a];return"string"==typeof b?b:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+a+'"'}function str(a,b){var c,d,e,f,h,g=gap,i=b[a];switch(i&&"object"==typeof i&&"function"==typeof i.toJSON&&(i=i.toJSON(a)),"function"==typeof rep&&(i=rep.call(b,a,i)),typeof i){case"string":return quote(i);case"number":return isFinite(i)?String(i):"null";case"boolean":case"null":return String(i);case"object":if(!i)return"null";if(gap+=indent,h=[],"[object Array]"===Object.prototype.toString.apply(i)){for(f=i.length,c=0;c<f;c+=1)h[c]=str(c,i)||"null";return e=0===h.length?"[]":gap?"[\n"+gap+h.join(",\n"+gap)+"\n"+g+"]":"["+h.join(",")+"]",gap=g,e}if(rep&&"object"==typeof rep)for(f=rep.length,c=0;c<f;c+=1)"string"==typeof rep[c]&&(d=rep[c],(e=str(d,i))&&h.push(quote(d)+(gap?": ":":")+e));else for(d in i)Object.prototype.hasOwnProperty.call(i,d)&&(e=str(d,i))&&h.push(quote(d)+(gap?": ":":")+e);return e=0===h.length?"{}":gap?"{\n"+gap+h.join(",\n"+gap)+"\n"+g+"}":"{"+h.join(",")+"}",gap=g,e}}var rx_one=/^[\],:{}\s]*$/,rx_two=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,rx_three=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,rx_four=/(?:^|:|,)(?:\s*\[)+/g,rx_escapable=/[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,rx_dangerous=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;"function"!=typeof Date.prototype.toJSON&&(Date.prototype.toJSON=function(){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null},Boolean.prototype.toJSON=this_value,Number.prototype.toJSON=this_value,String.prototype.toJSON=this_value);var gap,indent,meta,rep;"function"!=typeof JSON.stringify&&(meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},JSON.stringify=function(a,b,c){var d;if(gap="",indent="","number"==typeof c)for(d=0;d<c;d+=1)indent+=" ";else"string"==typeof c&&(indent=c);if(rep=b,b&&"function"!=typeof b&&("object"!=typeof b||"number"!=typeof b.length))throw new Error("JSON.stringify");return str("",{"":a})}),"function"!=typeof JSON.parse&&(JSON.parse=function(text,reviver){function walk(a,b){var c,d,e=a[b];if(e&&"object"==typeof e)for(c in e)Object.prototype.hasOwnProperty.call(e,c)&&(d=walk(e,c),void 0!==d?e[c]=d:delete e[c]);return reviver.call(a,b,e)}var j;if(text=String(text),rx_dangerous.lastIndex=0,rx_dangerous.test(text)&&(text=text.replace(rx_dangerous,function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})),rx_one.test(text.replace(rx_two,"@").replace(rx_three,"]").replace(rx_four,"")))return j=eval("("+text+")"),"function"==typeof reviver?walk({"":j},""):j;throw new SyntaxError("JSON.parse")})}();
|
|
||||||
|
|
||||||
function last(array) {
|
|
||||||
return array[array.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGuid() {
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
||||||
var r = Math.random() * 16 | 0;
|
|
||||||
var v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArray(list) {
|
|
||||||
var result = [];
|
|
||||||
for (var i = 0; i < list.length; i++) {
|
|
||||||
result.push(list[i]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toArrayBase1(list) {
|
|
||||||
var result = [];
|
|
||||||
for (var i = 1; i <= list.length; i++) {
|
|
||||||
result.push(list[i]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad(n, width, z) {
|
|
||||||
z = z || '0';
|
|
||||||
n = String(n);
|
|
||||||
return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks whether scripts are allowed to write files by creating and deleting a dummy file
|
|
||||||
function canWriteFiles() {
|
|
||||||
try {
|
|
||||||
var file = new File();
|
|
||||||
file.open('w');
|
|
||||||
file.writeln('');
|
|
||||||
file.close();
|
|
||||||
file.remove();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function frameToTime(frameNumber, compItem) {
|
|
||||||
return frameNumber * compItem.frameDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeToFrame(time, compItem) {
|
|
||||||
return time * compItem.frameRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// To prevent rounding errors
|
|
||||||
var epsilon = 0.001;
|
|
||||||
|
|
||||||
function isFrameVisible(compItem, frameNumber) {
|
|
||||||
if (!compItem) return false;
|
|
||||||
|
|
||||||
var time = frameToTime(frameNumber + epsilon, compItem);
|
|
||||||
var videoLayers = toArrayBase1(compItem.layers).filter(function(layer) {
|
|
||||||
return layer.hasVideo;
|
|
||||||
});
|
|
||||||
var result = videoLayers.find(function(layer) {
|
|
||||||
return layer.activeAtTime(time);
|
|
||||||
});
|
|
||||||
return Boolean(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
var appName = 'Rhubarb Lip Sync';
|
|
||||||
|
|
||||||
var settingsFilePath = Folder.userData.fullName + '/rhubarb-ae-settings.json';
|
|
||||||
|
|
||||||
function readTextFile(fileOrPath) {
|
|
||||||
var filePath = fileOrPath.fsName || fileOrPath;
|
|
||||||
var file = new File(filePath);
|
|
||||||
function check() {
|
|
||||||
if (file.error) throw new Error('Error reading file "' + filePath + '": ' + file.error);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
file.open('r'); check();
|
|
||||||
file.encoding = 'UTF-8'; check();
|
|
||||||
var result = file.read(); check();
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
file.close(); check();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeTextFile(fileOrPath, text) {
|
|
||||||
var filePath = fileOrPath.fsName || fileOrPath;
|
|
||||||
var file = new File(filePath);
|
|
||||||
function check() {
|
|
||||||
if (file.error) throw new Error('Error writing file "' + filePath + '": ' + file.error);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
file.open('w'); check();
|
|
||||||
file.encoding = 'UTF-8'; check();
|
|
||||||
file.write(text); check();
|
|
||||||
} finally {
|
|
||||||
file.close(); check();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSettingsFile() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(readTextFile(settingsFilePath));
|
|
||||||
} catch (e) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSettingsFile(settings) {
|
|
||||||
try {
|
|
||||||
writeTextFile(settingsFilePath, JSON.stringify(settings, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
alert('Error persisting settings. ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var osIsWindows = (system.osName || $.os).match(/windows/i);
|
|
||||||
|
|
||||||
// Depending on the operating system, the syntax for escaping command-line arguments differs.
|
|
||||||
function cliEscape(argument) {
|
|
||||||
return osIsWindows
|
|
||||||
? '"' + argument + '"'
|
|
||||||
: "'" + argument.replace(/'/g, "'\\''") + "'";
|
|
||||||
}
|
|
||||||
|
|
||||||
function exec(command) {
|
|
||||||
return system.callSystem(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
function execInWindow(command) {
|
|
||||||
if (osIsWindows) {
|
|
||||||
system.callSystem('cmd /C "' + command + '"');
|
|
||||||
} else {
|
|
||||||
// I didn't think it could be so complicated on OS X to open a new Terminal window,
|
|
||||||
// execute a command, then close the Terminal window.
|
|
||||||
// If you know a better solution, let me know!
|
|
||||||
var escapedCommand = command.replace(/"/g, '\\"');
|
|
||||||
var appleScript = '\
|
|
||||||
tell application "Terminal" \
|
|
||||||
-- Quit terminal \
|
|
||||||
-- Yes, that\'s undesirable if there was an open window before. \
|
|
||||||
-- But all solutions I could find were at least as hacky. \
|
|
||||||
quit \
|
|
||||||
-- Open terminal \
|
|
||||||
activate \
|
|
||||||
-- Run command in new tab \
|
|
||||||
set newTab to do script ("' + escapedCommand + '") \
|
|
||||||
-- Wait until command is done \
|
|
||||||
tell newTab \
|
|
||||||
repeat while busy \
|
|
||||||
delay 0.1 \
|
|
||||||
end repeat \
|
|
||||||
end tell \
|
|
||||||
quit \
|
|
||||||
end tell';
|
|
||||||
exec('osascript -e ' + cliEscape(appleScript));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var rhubarbPath = osIsWindows ? 'rhubarb.exe' : '/usr/local/bin/rhubarb';
|
|
||||||
|
|
||||||
// ExtendScript's resource strings are a pain to write.
|
|
||||||
// This function allows them to be written in JSON notation, then converts them into the required
|
|
||||||
// format.
|
|
||||||
// For instance, this string: '{ "__type__": "StaticText", "text": "Hello world" }'
|
|
||||||
// is converted to this: 'StaticText { "text": "Hello world" }'.
|
|
||||||
// This code relies on the fact that, contrary to the language specification, all major JavaScript
|
|
||||||
// implementations keep object properties in insertion order.
|
|
||||||
function createResourceString(tree) {
|
|
||||||
var result = JSON.stringify(tree, null, 2);
|
|
||||||
result = result.replace(/(\{\s*)"__type__":\s*"(\w+)",?\s*/g, '$2 $1');
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object containing functions to create control description trees.
|
|
||||||
// For instance, `controls.StaticText({ text: 'Hello world' })`
|
|
||||||
// returns `{ __type__: StaticText, text: 'Hello world' }`.
|
|
||||||
var controlFunctions = (function() {
|
|
||||||
var controlTypes = [
|
|
||||||
// Strangely, 'dialog' and 'palette' need to start with a lower-case character
|
|
||||||
['Dialog', 'dialog'], ['Palette', 'palette'],
|
|
||||||
'Panel', 'Group', 'TabbedPanel', 'Tab', 'Button', 'IconButton', 'Image', 'StaticText',
|
|
||||||
'EditText', 'Checkbox', 'RadioButton', 'Progressbar', 'Slider', 'Scrollbar', 'ListBox',
|
|
||||||
'DropDownList', 'TreeView', 'ListItem', 'FlashPlayer'
|
|
||||||
];
|
|
||||||
var result = {};
|
|
||||||
controlTypes.forEach(function(type){
|
|
||||||
var isArray = Array.isArray(type);
|
|
||||||
var key = isArray ? type[0] : type;
|
|
||||||
var value = isArray ? type[1] : type;
|
|
||||||
result[key] = function(options) {
|
|
||||||
return Object.assign({ __type__: value }, options);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Returns the path of a project item within the project
|
|
||||||
function getItemPath(item) {
|
|
||||||
if (item === app.project.rootFolder) {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = item.name;
|
|
||||||
while (item.parentFolder !== app.project.rootFolder) {
|
|
||||||
result = item.parentFolder.name + ' / ' + result;
|
|
||||||
item = item.parentFolder;
|
|
||||||
}
|
|
||||||
return '/ ' + result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selects the item within an item control whose text matches the specified text.
|
|
||||||
// If no such item exists, selects the first item, if present.
|
|
||||||
function selectByTextOrFirst(itemControl, text) {
|
|
||||||
var targetItem = toArray(itemControl.items).find(function(item) {
|
|
||||||
return item.text === text;
|
|
||||||
});
|
|
||||||
if (!targetItem && itemControl.items.length) {
|
|
||||||
targetItem = itemControl.items[0];
|
|
||||||
}
|
|
||||||
if (targetItem) {
|
|
||||||
itemControl.selection = targetItem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAudioFileProjectItems() {
|
|
||||||
var result = toArrayBase1(app.project.items).filter(function(item) {
|
|
||||||
var isAudioFootage = item instanceof FootageItem && item.hasAudio && !item.hasVideo;
|
|
||||||
return isAudioFootage;
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
var mouthShapeNames = 'ABCDEFGHX'.split('');
|
|
||||||
var basicMouthShapeCount = 6;
|
|
||||||
var mouthShapeCount = mouthShapeNames.length;
|
|
||||||
var basicMouthShapeNames = mouthShapeNames.slice(0, basicMouthShapeCount);
|
|
||||||
var extendedMouthShapeNames = mouthShapeNames.slice(basicMouthShapeCount);
|
|
||||||
|
|
||||||
function getMouthCompHelpTip() {
|
|
||||||
var result = 'A composition containing the mouth shapes, one drawing per frame. They must be '
|
|
||||||
+ 'arranged as follows:\n';
|
|
||||||
mouthShapeNames.forEach(function(mouthShapeName, i) {
|
|
||||||
var isOptional = i >= basicMouthShapeCount;
|
|
||||||
result += '\n00:' + pad(i, 2) + '\t' + mouthShapeName + (isOptional ? ' (optional)' : '');
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createExtendedShapeCheckboxes() {
|
|
||||||
var result = {};
|
|
||||||
extendedMouthShapeNames.forEach(function(shapeName) {
|
|
||||||
result[shapeName.toLowerCase()] = controlFunctions.Checkbox({
|
|
||||||
text: shapeName,
|
|
||||||
helpTip: 'Controls whether to use the optional ' + shapeName + ' shape.'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDialogWindow() {
|
|
||||||
var resourceString;
|
|
||||||
with (controlFunctions) {
|
|
||||||
resourceString = createResourceString(
|
|
||||||
Dialog({
|
|
||||||
text: appName,
|
|
||||||
settings: Group({
|
|
||||||
orientation: 'column',
|
|
||||||
alignChildren: ['left', 'top'],
|
|
||||||
audioFile: Group({
|
|
||||||
label: StaticText({
|
|
||||||
text: 'Audio file:',
|
|
||||||
// If I don't explicitly activate a control, After Effects has trouble
|
|
||||||
// with keyboard focus, so I can't type in the text edit field below.
|
|
||||||
active: true
|
|
||||||
}),
|
|
||||||
value: DropDownList({
|
|
||||||
helpTip: 'An audio file containing recorded dialog.\n'
|
|
||||||
+ 'This field shows all audio files that exist in '
|
|
||||||
+ 'your After Effects project.'
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
recognizer: Group({
|
|
||||||
label: StaticText({ text: 'Recognizer:' }),
|
|
||||||
value: DropDownList({
|
|
||||||
helpTip: 'The dialog recognizer.'
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
dialogText: Group({
|
|
||||||
label: StaticText({ text: 'Dialog text (optional):' }),
|
|
||||||
value: EditText({
|
|
||||||
properties: { multiline: true },
|
|
||||||
characters: 60,
|
|
||||||
minimumSize: [0, 100],
|
|
||||||
helpTip: 'For better animation results, you can specify the text of '
|
|
||||||
+ 'the recording here. This field is optional.'
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
mouthComp: Group({
|
|
||||||
label: StaticText({ text: 'Mouth composition:' }),
|
|
||||||
value: DropDownList({ helpTip: getMouthCompHelpTip() })
|
|
||||||
}),
|
|
||||||
extendedMouthShapes: Group(
|
|
||||||
Object.assign(
|
|
||||||
{ label: StaticText({ text: 'Extended mouth shapes:' }) },
|
|
||||||
createExtendedShapeCheckboxes()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
targetFolder: Group({
|
|
||||||
label: StaticText({ text: 'Target folder:' }),
|
|
||||||
value: DropDownList({
|
|
||||||
helpTip: 'The project folder in which to create the animation '
|
|
||||||
+ 'composition. The composition will be named like the audio file.'
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
frameRate: Group({
|
|
||||||
label: StaticText({ text: 'Frame rate:' }),
|
|
||||||
value: EditText({
|
|
||||||
characters: 8,
|
|
||||||
helpTip: 'The frame rate for the animation.'
|
|
||||||
}),
|
|
||||||
auto: Checkbox({
|
|
||||||
text: 'From mouth composition',
|
|
||||||
helpTip: 'If checked, the animation will use the same frame rate as '
|
|
||||||
+ 'the mouth composition.'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
separator: Group({ preferredSize: ['', 3] }),
|
|
||||||
buttons: Group({
|
|
||||||
alignment: 'right',
|
|
||||||
animate: Button({
|
|
||||||
properties: { name: 'ok' },
|
|
||||||
text: 'Animate'
|
|
||||||
}),
|
|
||||||
cancel: Button({
|
|
||||||
properties: { name: 'cancel' },
|
|
||||||
text: 'Cancel'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create window and child controls
|
|
||||||
var window = new Window(resourceString);
|
|
||||||
var controls = {
|
|
||||||
audioFile: window.settings.audioFile.value,
|
|
||||||
dialogText: window.settings.dialogText.value,
|
|
||||||
recognizer: window.settings.recognizer.value,
|
|
||||||
mouthComp: window.settings.mouthComp.value,
|
|
||||||
targetFolder: window.settings.targetFolder.value,
|
|
||||||
frameRate: window.settings.frameRate.value,
|
|
||||||
autoFrameRate: window.settings.frameRate.auto,
|
|
||||||
animateButton: window.buttons.animate,
|
|
||||||
cancelButton: window.buttons.cancel
|
|
||||||
};
|
|
||||||
extendedMouthShapeNames.forEach(function(shapeName) {
|
|
||||||
controls['mouthShape' + shapeName] =
|
|
||||||
window.settings.extendedMouthShapes[shapeName.toLowerCase()];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add audio file options
|
|
||||||
getAudioFileProjectItems().forEach(function(projectItem) {
|
|
||||||
var listItem = controls.audioFile.add('item', getItemPath(projectItem));
|
|
||||||
listItem.projectItem = projectItem;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add recognizer options
|
|
||||||
const recognizerOptions = [
|
|
||||||
{ text: 'PocketSphinx (use for English recordings)', value: 'pocketSphinx' },
|
|
||||||
{ text: 'Phonetic (use for non-English recordings)', value: 'phonetic' }
|
|
||||||
];
|
|
||||||
recognizerOptions.forEach(function(option) {
|
|
||||||
var listItem = controls.recognizer.add('item', option.text);
|
|
||||||
listItem.value = option.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add mouth composition options
|
|
||||||
var comps = toArrayBase1(app.project.items).filter(function (item) {
|
|
||||||
return item instanceof CompItem;
|
|
||||||
});
|
|
||||||
comps.forEach(function(projectItem) {
|
|
||||||
var listItem = controls.mouthComp.add('item', getItemPath(projectItem));
|
|
||||||
listItem.projectItem = projectItem;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add target folder options
|
|
||||||
var projectFolders = toArrayBase1(app.project.items).filter(function (item) {
|
|
||||||
return item instanceof FolderItem;
|
|
||||||
});
|
|
||||||
projectFolders.unshift(app.project.rootFolder);
|
|
||||||
projectFolders.forEach(function(projectFolder) {
|
|
||||||
var listItem = controls.targetFolder.add('item', getItemPath(projectFolder));
|
|
||||||
listItem.projectItem = projectFolder;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load persisted settings
|
|
||||||
var settings = readSettingsFile();
|
|
||||||
selectByTextOrFirst(controls.audioFile, settings.audioFile);
|
|
||||||
controls.dialogText.text = settings.dialogText || '';
|
|
||||||
selectByTextOrFirst(controls.recognizer, settings.recognizer);
|
|
||||||
selectByTextOrFirst(controls.mouthComp, settings.mouthComp);
|
|
||||||
extendedMouthShapeNames.forEach(function(shapeName) {
|
|
||||||
controls['mouthShape' + shapeName].value =
|
|
||||||
(settings.extendedMouthShapes || {})[shapeName.toLowerCase()];
|
|
||||||
});
|
|
||||||
selectByTextOrFirst(controls.targetFolder, settings.targetFolder);
|
|
||||||
controls.frameRate.text = settings.frameRate || '';
|
|
||||||
controls.autoFrameRate.value = settings.autoFrameRate;
|
|
||||||
|
|
||||||
// Align controls
|
|
||||||
window.onShow = function() {
|
|
||||||
// Give uniform width to all labels
|
|
||||||
var groups = toArray(window.settings.children);
|
|
||||||
var labelWidths = groups.map(function(group) { return group.children[0].size.width; });
|
|
||||||
var maxLabelWidth = Math.max.apply(Math, labelWidths);
|
|
||||||
groups.forEach(function (group) {
|
|
||||||
group.children[0].size.width = maxLabelWidth;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Give uniform width to inputs
|
|
||||||
var valueWidths = groups.map(function(group) {
|
|
||||||
return last(group.children).bounds.right - group.children[1].bounds.left;
|
|
||||||
});
|
|
||||||
var maxValueWidth = Math.max.apply(Math, valueWidths);
|
|
||||||
groups.forEach(function (group) {
|
|
||||||
var multipleControls = group.children.length > 2;
|
|
||||||
if (!multipleControls) {
|
|
||||||
group.children[1].size.width = maxValueWidth;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.layout.layout(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
var updating = false;
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
if (updating) return;
|
|
||||||
|
|
||||||
updating = true;
|
|
||||||
try {
|
|
||||||
// Handle auto frame rate
|
|
||||||
var autoFrameRate = controls.autoFrameRate.value;
|
|
||||||
controls.frameRate.enabled = !autoFrameRate;
|
|
||||||
if (autoFrameRate) {
|
|
||||||
// Take frame rate from mouth comp
|
|
||||||
var mouthComp = (controls.mouthComp.selection || {}).projectItem;
|
|
||||||
controls.frameRate.text = mouthComp ? mouthComp.frameRate : '';
|
|
||||||
} else {
|
|
||||||
// Sanitize frame rate
|
|
||||||
var sanitizedFrameRate = controls.frameRate.text.match(/\d*\.?\d*/)[0];
|
|
||||||
if (sanitizedFrameRate !== controls.frameRate.text) {
|
|
||||||
controls.frameRate.text = sanitizedFrameRate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store settings
|
|
||||||
var settings = {
|
|
||||||
audioFile: (controls.audioFile.selection || {}).text,
|
|
||||||
recognizer: (controls.recognizer.selection || {}).text,
|
|
||||||
dialogText: controls.dialogText.text,
|
|
||||||
mouthComp: (controls.mouthComp.selection || {}).text,
|
|
||||||
extendedMouthShapes: {},
|
|
||||||
targetFolder: (controls.targetFolder.selection || {}).text,
|
|
||||||
frameRate: Number(controls.frameRate.text),
|
|
||||||
autoFrameRate: controls.autoFrameRate.value
|
|
||||||
};
|
|
||||||
extendedMouthShapeNames.forEach(function(shapeName) {
|
|
||||||
settings.extendedMouthShapes[shapeName.toLowerCase()] =
|
|
||||||
controls['mouthShape' + shapeName].value;
|
|
||||||
});
|
|
||||||
writeSettingsFile(settings);
|
|
||||||
} finally {
|
|
||||||
updating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate user input. Possible return values:
|
|
||||||
// * Non-empty string: Validation failed. Show error message.
|
|
||||||
// * Empty string: Validation failed. Don't show error message.
|
|
||||||
// * Undefined: Validation succeeded.
|
|
||||||
function validate() {
|
|
||||||
// Check input values
|
|
||||||
if (!controls.audioFile.selection) return 'Please select an audio file.';
|
|
||||||
if (!controls.mouthComp.selection) return 'Please select a mouth composition.';
|
|
||||||
if (!controls.targetFolder.selection) return 'Please select a target folder.';
|
|
||||||
if (Number(controls.frameRate.text) < 12) {
|
|
||||||
return 'Please enter a frame rate of at least 12 fps.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check mouth shape visibility
|
|
||||||
var comp = controls.mouthComp.selection.projectItem;
|
|
||||||
for (var i = 0; i < mouthShapeCount; i++) {
|
|
||||||
var shapeName = mouthShapeNames[i];
|
|
||||||
var required = i < basicMouthShapeCount || controls['mouthShape' + shapeName].value;
|
|
||||||
if (required && !isFrameVisible(comp, i)) {
|
|
||||||
return 'The mouth comp does not seem to contain an image for shape '
|
|
||||||
+ shapeName + ' at frame ' + i + '.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!comp.preserveNestedFrameRate) {
|
|
||||||
var fix = Window.confirm(
|
|
||||||
'The setting "Preserve frame rate when nested or in render queue" is not active '
|
|
||||||
+ 'for the mouth composition. This can result in incorrect animation.\n\n'
|
|
||||||
+ 'Activate this setting now?',
|
|
||||||
false,
|
|
||||||
'Fix composition setting?');
|
|
||||||
if (fix) {
|
|
||||||
app.beginUndoGroup(appName + ': Mouth composition setting');
|
|
||||||
comp.preserveNestedFrameRate = true;
|
|
||||||
app.endUndoGroup();
|
|
||||||
} else {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for correct Rhubarb version
|
|
||||||
var version = exec(rhubarbPath + ' --version') || '';
|
|
||||||
var match = version.match(/Rhubarb Lip Sync version ((\d+)\.(\d+).(\d+)(-[0-9A-Za-z-.]+)?)/);
|
|
||||||
if (!match) {
|
|
||||||
var instructions = osIsWindows
|
|
||||||
? 'Make sure your PATH environment variable contains the ' + appName + ' '
|
|
||||||
+ 'application directory.'
|
|
||||||
: 'Make sure you have created this file as a symbolic link to the ' + appName + ' '
|
|
||||||
+ 'executable (rhubarb).';
|
|
||||||
return 'Cannot find executable file "' + rhubarbPath + '". \n' + instructions;
|
|
||||||
}
|
|
||||||
var versionString = match[1];
|
|
||||||
var major = Number(match[2]);
|
|
||||||
var minor = Number(match[3]);
|
|
||||||
var requiredMajor = 1;
|
|
||||||
var minRequiredMinor = 9;
|
|
||||||
if (major != requiredMajor || minor < minRequiredMinor) {
|
|
||||||
return 'This script requires ' + appName + ' ' + requiredMajor + '.' + minRequiredMinor
|
|
||||||
+ '.0 or a later ' + requiredMajor + '.x version. '
|
|
||||||
+ 'Your installed version is ' + versionString + ', which is not compatible.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMouthCues(audioFileFootage, recognizer, dialogText, mouthComp, extendedMouthShapeNames,
|
|
||||||
targetProjectFolder, frameRate)
|
|
||||||
{
|
|
||||||
var basePath = Folder.temp.fsName + '/' + createGuid();
|
|
||||||
var dialogFile = new File(basePath + '.txt');
|
|
||||||
var logFile = new File(basePath + '.log');
|
|
||||||
var jsonFile = new File(basePath + '.json');
|
|
||||||
try {
|
|
||||||
// Create text file containing dialog
|
|
||||||
writeTextFile(dialogFile, dialogText);
|
|
||||||
|
|
||||||
// Create command line
|
|
||||||
var commandLine = rhubarbPath
|
|
||||||
+ ' --dialogFile ' + cliEscape(dialogFile.fsName)
|
|
||||||
+ ' --recognizer ' + recognizer
|
|
||||||
+ ' --exportFormat json'
|
|
||||||
+ ' --extendedShapes ' + cliEscape(extendedMouthShapeNames.join(''))
|
|
||||||
+ ' --logFile ' + cliEscape(logFile.fsName)
|
|
||||||
+ ' --logLevel fatal'
|
|
||||||
+ ' --output ' + cliEscape(jsonFile.fsName)
|
|
||||||
+ ' ' + cliEscape(audioFileFootage.file.fsName);
|
|
||||||
|
|
||||||
// Run Rhubarb
|
|
||||||
execInWindow(commandLine);
|
|
||||||
|
|
||||||
// Check log for fatal errors
|
|
||||||
if (logFile.exists) {
|
|
||||||
var fatalLog = readTextFile(logFile).trim();
|
|
||||||
if (fatalLog) {
|
|
||||||
// Try to extract only the actual error message
|
|
||||||
var match = fatalLog.match(/\[Fatal\] ([\s\S]*)/);
|
|
||||||
var message = match ? match[1] : fatalLog;
|
|
||||||
throw new Error('Error running ' + appName + '.\n' + message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result;
|
|
||||||
try {
|
|
||||||
result = JSON.parse(readTextFile(jsonFile));
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('No animation result. Animation was probably canceled.');
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
dialogFile.remove();
|
|
||||||
logFile.remove();
|
|
||||||
jsonFile.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function animateMouthCues(mouthCues, audioFileFootage, mouthComp, targetProjectFolder,
|
|
||||||
frameRate)
|
|
||||||
{
|
|
||||||
// Find an unconflicting comp name
|
|
||||||
// ... strip extension, if present
|
|
||||||
var baseName = audioFileFootage.name.match(/^(.*?)(\..*)?$/i)[1];
|
|
||||||
var compName = baseName;
|
|
||||||
// ... add numeric suffix, if needed
|
|
||||||
var existingItems = toArrayBase1(targetProjectFolder.items);
|
|
||||||
var counter = 1;
|
|
||||||
while (existingItems.some(function(item) { return item.name === compName; })) {
|
|
||||||
counter++;
|
|
||||||
compName = baseName + ' ' + counter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new comp
|
|
||||||
var comp = targetProjectFolder.items.addComp(compName, mouthComp.width, mouthComp.height,
|
|
||||||
mouthComp.pixelAspect, audioFileFootage.duration, frameRate);
|
|
||||||
|
|
||||||
// Show new comp
|
|
||||||
comp.openInViewer();
|
|
||||||
|
|
||||||
// Add audio layer
|
|
||||||
comp.layers.add(audioFileFootage);
|
|
||||||
|
|
||||||
// Add mouth layer
|
|
||||||
var mouthLayer = comp.layers.add(mouthComp);
|
|
||||||
mouthLayer.timeRemapEnabled = true;
|
|
||||||
mouthLayer.outPoint = comp.duration;
|
|
||||||
|
|
||||||
// Animate mouth layer
|
|
||||||
var timeRemap = mouthLayer['Time Remap'];
|
|
||||||
// Enabling time remapping automatically adds two keys. Remove the second.
|
|
||||||
timeRemap.removeKey(2);
|
|
||||||
mouthCues.mouthCues.forEach(function(mouthCue) {
|
|
||||||
// Round down keyframe time. In animation, earlier is better than later.
|
|
||||||
// Set keyframe time to *just before* the exact frame to prevent rounding errors
|
|
||||||
var frame = Math.floor(timeToFrame(mouthCue.start, comp));
|
|
||||||
var time = frame !== 0 ? frameToTime(frame - epsilon, comp) : 0;
|
|
||||||
// Set remapped time to *just after* the exact frame to prevent rounding errors
|
|
||||||
var mouthCompFrame = mouthShapeNames.indexOf(mouthCue.value);
|
|
||||||
var remappedTime = frameToTime(mouthCompFrame + epsilon, mouthComp);
|
|
||||||
timeRemap.setValueAtTime(time, remappedTime);
|
|
||||||
});
|
|
||||||
for (var i = 1; i <= timeRemap.numKeys; i++) {
|
|
||||||
timeRemap.setInterpolationTypeAtKey(i, KeyframeInterpolationType.HOLD);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function animate(audioFileFootage, recognizer, dialogText, mouthComp, extendedMouthShapeNames,
|
|
||||||
targetProjectFolder, frameRate)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
var mouthCues = generateMouthCues(audioFileFootage, recognizer, dialogText, mouthComp,
|
|
||||||
extendedMouthShapeNames, targetProjectFolder, frameRate);
|
|
||||||
|
|
||||||
app.beginUndoGroup(appName + ': Animation');
|
|
||||||
animateMouthCues(mouthCues, audioFileFootage, mouthComp, targetProjectFolder,
|
|
||||||
frameRate);
|
|
||||||
app.endUndoGroup();
|
|
||||||
} catch (e) {
|
|
||||||
Window.alert(e.message, appName, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle changes
|
|
||||||
update();
|
|
||||||
controls.audioFile.onChange = update;
|
|
||||||
controls.recognizer.onChange = update;
|
|
||||||
controls.dialogText.onChanging = update;
|
|
||||||
controls.mouthComp.onChange = update;
|
|
||||||
extendedMouthShapeNames.forEach(function(shapeName) {
|
|
||||||
controls['mouthShape' + shapeName].onClick = update;
|
|
||||||
});
|
|
||||||
controls.targetFolder.onChange = update;
|
|
||||||
controls.frameRate.onChanging = update;
|
|
||||||
controls.autoFrameRate.onClick = update;
|
|
||||||
|
|
||||||
// Handle animation
|
|
||||||
controls.animateButton.onClick = function() {
|
|
||||||
var validationError = validate();
|
|
||||||
if (typeof validationError === 'string') {
|
|
||||||
if (validationError) {
|
|
||||||
Window.alert(validationError, appName, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.close();
|
|
||||||
animate(
|
|
||||||
controls.audioFile.selection.projectItem,
|
|
||||||
controls.recognizer.selection.value,
|
|
||||||
controls.dialogText.text || '',
|
|
||||||
controls.mouthComp.selection.projectItem,
|
|
||||||
extendedMouthShapeNames.filter(function(shapeName) {
|
|
||||||
return controls['mouthShape' + shapeName].value;
|
|
||||||
}),
|
|
||||||
controls.targetFolder.selection.projectItem,
|
|
||||||
Number(controls.frameRate.text)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle cancelation
|
|
||||||
controls.cancelButton.onClick = function() {
|
|
||||||
window.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPreconditions() {
|
|
||||||
if (!canWriteFiles()) {
|
|
||||||
Window.alert('This script requires file system access.\n\n'
|
|
||||||
+ 'Please enable Preferences > General > Allow Scripts to Write Files and Access Network.',
|
|
||||||
appName, true);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkPreconditions()) {
|
|
||||||
createDialogWindow().show();
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
cmake_minimum_required(VERSION 3.2)
|
|
||||||
|
|
||||||
add_custom_target(
|
|
||||||
rhubarbForSpine ALL
|
|
||||||
"./gradlew" "build"
|
|
||||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
|
||||||
COMMENT "Building Rhubarb for Spine through Gradle."
|
|
||||||
)
|
|
||||||
|
|
||||||
install(
|
|
||||||
DIRECTORY "build/libs/"
|
|
||||||
DESTINATION "extras/EsotericSoftwareSpine"
|
|
||||||
)
|
|
||||||
|
|
||||||
install(
|
|
||||||
FILES README.adoc
|
|
||||||
DESTINATION "extras/EsotericSoftwareSpine"
|
|
||||||
)
|
|
|
@ -1,125 +0,0 @@
|
||||||
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 tornadofx.asObservable
|
|
||||||
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>>()
|
|
||||||
private var slots: ObservableList<String> by slotsProperty
|
|
||||||
|
|
||||||
val mouthSlotProperty: SimpleStringProperty = SimpleStringProperty().alsoListen {
|
|
||||||
val mouthSlot = this.mouthSlot
|
|
||||||
val mouthNaming = if (mouthSlot != null)
|
|
||||||
MouthNaming.guess(spineJson.getSlotAttachmentNames(mouthSlot))
|
|
||||||
else null
|
|
||||||
this.mouthNaming = mouthNaming
|
|
||||||
|
|
||||||
mouthShapes = if (mouthSlot != null && mouthNaming != 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."
|
|
||||||
}
|
|
||||||
private var mouthSlot: String? by mouthSlotProperty
|
|
||||||
|
|
||||||
val mouthSlotErrorProperty = SimpleStringProperty()
|
|
||||||
private var mouthSlotError: String? by mouthSlotErrorProperty
|
|
||||||
|
|
||||||
val mouthNamingProperty = SimpleObjectProperty<MouthNaming>()
|
|
||||||
private var mouthNaming: MouthNaming? by mouthNamingProperty
|
|
||||||
|
|
||||||
val mouthShapesProperty = SimpleObjectProperty<List<MouthShape>>().alsoListen {
|
|
||||||
mouthShapesError = getMouthShapesErrorString()
|
|
||||||
}
|
|
||||||
var mouthShapes: List<MouthShape> by mouthShapesProperty
|
|
||||||
private set
|
|
||||||
|
|
||||||
val mouthShapesErrorProperty = SimpleStringProperty()
|
|
||||||
private var mouthShapesError: String? by mouthShapesErrorProperty
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
.asObservable()
|
|
||||||
)
|
|
||||||
val audioFileModels: ObservableList<AudioFileModel> 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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveAnimation(animationName: String, audioEventName: String, mouthCues: List<MouthCue>) {
|
|
||||||
spineJson.createOrUpdateAnimation(mouthCues, audioEventName, animationName, mouthSlot!!, mouthNaming!!)
|
|
||||||
spineJson.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
slots = spineJson.slots.asObservable()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,196 +0,0 @@
|
||||||
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.nio.file.Path
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
private val spineJson = parentModel.spineJson
|
|
||||||
|
|
||||||
private val audioFilePath: Path = spineJson.audioDirectoryPath.resolve(audioEvent.relativeAudioFilePath)
|
|
||||||
|
|
||||||
val eventNameProperty = SimpleStringProperty(audioEvent.name)
|
|
||||||
val eventName: String by eventNameProperty
|
|
||||||
|
|
||||||
val displayFilePathProperty = SimpleStringProperty(audioEvent.relativeAudioFilePath)
|
|
||||||
|
|
||||||
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: String by animationNameProperty
|
|
||||||
|
|
||||||
val dialogProperty = SimpleStringProperty(audioEvent.dialog)
|
|
||||||
private val dialog: String? by dialogProperty
|
|
||||||
|
|
||||||
val animationProgressProperty = SimpleObjectProperty<Double?>(null)
|
|
||||||
var animationProgress: Double? 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 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"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 recognizer = parentModel.parentModel.recognizer.value
|
|
||||||
val extendedMouthShapes = parentModel.mouthShapes.filter { it.isExtended }.toSet()
|
|
||||||
val reportProgress: (Double?) -> Unit = {
|
|
||||||
progress -> runAndWait { this@AudioFileModel.animationProgress = progress }
|
|
||||||
}
|
|
||||||
val rhubarbTask = RhubarbTask(audioFilePath, recognizer, dialog, extendedMouthShapes, reportProgress)
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
val result = rhubarbTask.call()
|
|
||||||
runAndWait {
|
|
||||||
reportResult(result)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
runAndWait {
|
|
||||||
animationProgress = null
|
|
||||||
future = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: InterruptedException) {
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace(System.err)
|
|
||||||
|
|
||||||
Platform.runLater {
|
|
||||||
Alert(Alert.AlertType.ERROR).apply {
|
|
||||||
headerText = "Error performing lip sync for event '$eventName'."
|
|
||||||
contentText = if (e is EndUserException)
|
|
||||||
e.message
|
|
||||||
else
|
|
||||||
("An internal error occurred.\n"
|
|
||||||
+ "Please report an issue, including the following information.\n"
|
|
||||||
+ getStackTrace(e))
|
|
||||||
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)
|
|
|
@ -1,80 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
|
||||||
|
|
||||||
import javafx.beans.property.SimpleObjectProperty
|
|
||||||
import javafx.beans.property.SimpleStringProperty
|
|
||||||
import javafx.collections.FXCollections
|
|
||||||
import javafx.collections.ObservableList
|
|
||||||
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 EndUserException("No input file specified.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = try {
|
|
||||||
val trimmed = value.removeSurrounding("\"")
|
|
||||||
Paths.get(trimmed)
|
|
||||||
} catch (e: InvalidPathException) {
|
|
||||||
throw EndUserException("Not a valid file path.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.exists(path)) {
|
|
||||||
throw EndUserException("File does not exist.")
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFileModel = AnimationFileModel(this, path, executor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filePathErrorProperty = SimpleStringProperty()
|
|
||||||
private var filePathError: String? by filePathErrorProperty
|
|
||||||
|
|
||||||
val animationFileModelProperty = SimpleObjectProperty<AnimationFileModel?>()
|
|
||||||
var animationFileModel by animationFileModelProperty
|
|
||||||
private set
|
|
||||||
|
|
||||||
val recognizersProperty = SimpleObjectProperty<ObservableList<Recognizer>>(FXCollections.observableArrayList(
|
|
||||||
Recognizer("pocketSphinx", "PocketSphinx (use for English recordings)"),
|
|
||||||
Recognizer("phonetic", "Phonetic (use for non-English recordings)")
|
|
||||||
))
|
|
||||||
private var recognizers: ObservableList<Recognizer> by recognizersProperty
|
|
||||||
|
|
||||||
val recognizerProperty = SimpleObjectProperty<Recognizer>(recognizers[0])
|
|
||||||
var recognizer: Recognizer by recognizerProperty
|
|
||||||
|
|
||||||
val animationPrefixProperty = SimpleStringProperty("say_")
|
|
||||||
var animationPrefix: String by animationPrefixProperty
|
|
||||||
|
|
||||||
val animationSuffixProperty = SimpleStringProperty("")
|
|
||||||
var animationSuffix: String by animationSuffixProperty
|
|
||||||
|
|
||||||
private fun getDefaultPathString() = FX.application.parameters.raw.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
class Recognizer(val value: String, val description: String)
|
|
|
@ -1,257 +0,0 @@
|
||||||
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 javafx.util.StringConverter
|
|
||||||
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("Dialog recognizer") {
|
|
||||||
combobox<Recognizer> {
|
|
||||||
itemsProperty().bind(mainModel.recognizersProperty)
|
|
||||||
this.converter = object : StringConverter<Recognizer>() {
|
|
||||||
override fun toString(recognizer: Recognizer?): String {
|
|
||||||
return recognizer?.description ?: ""
|
|
||||||
}
|
|
||||||
override fun fromString(string: String?): Recognizer {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
valueProperty().bindBidirectional(mainModel.recognizerProperty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
.weightedWidth(1.0)
|
|
||||||
column("Animation name", AudioFileModel::animationNameProperty)
|
|
||||||
.weightedWidth(1.0)
|
|
||||||
column("Audio file", AudioFileModel::displayFilePathProperty)
|
|
||||||
.weightedWidth(1.0)
|
|
||||||
column("Dialog", AudioFileModel::dialogProperty).apply {
|
|
||||||
weightedWidth(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 {
|
|
||||||
weightedWidth(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 {
|
|
||||||
weightedWidth(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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
|
||||||
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class MouthNaming(private val prefix: String, private val suffix: String, private 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
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
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 {
|
|
||||||
const val basicShapeCount = 6
|
|
||||||
|
|
||||||
val basicShapes = MouthShape.values().take(basicShapeCount)
|
|
||||||
|
|
||||||
val extendedShapes = MouthShape.values().drop(basicShapeCount)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,166 +0,0 @@
|
||||||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
|
||||||
|
|
||||||
import com.beust.klaxon.JsonObject
|
|
||||||
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.util.concurrent.Callable
|
|
||||||
|
|
||||||
class RhubarbTask(
|
|
||||||
val audioFilePath: Path,
|
|
||||||
val recognizer: String,
|
|
||||||
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 EndUserException("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 EndUserException(message.string("reason") ?: "Rhubarb failed without reason.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: InterruptedException) {
|
|
||||||
process.destroyForcibly()
|
|
||||||
throw e
|
|
||||||
} catch (e: EOFException) {
|
|
||||||
throw EndUserException("Rhubarb terminated unexpectedly.")
|
|
||||||
} finally {
|
|
||||||
process.waitFor()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
|
|
||||||
throw EndUserException("Audio file processing terminated in an unexpected way.")
|
|
||||||
}
|
|
||||||
|
|
||||||
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.default()
|
|
||||||
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",
|
|
||||||
"--recognizer", recognizer,
|
|
||||||
"--exportFormat", "json",
|
|
||||||
"--extendedShapes", extendedMouthShapesString
|
|
||||||
).apply {
|
|
||||||
if (dialogFilePath != null) {
|
|
||||||
addAll(listOf(
|
|
||||||
"--dialogFile", dialogFilePath.toString()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}.apply {
|
|
||||||
add(audioFilePath.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val guiBinDirectory: Path by lazy {
|
|
||||||
val path = urlToPath(getLocation(RhubarbTask::class.java))
|
|
||||||
return@lazy if (Files.isDirectory(path)) path.parent else 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 EndUserException("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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,163 +0,0 @@
|
||||||
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(private val filePath: Path) {
|
|
||||||
private val fileDirectoryPath: Path = filePath.parent
|
|
||||||
private val json: JsonObject
|
|
||||||
private val skeleton: JsonObject
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!Files.exists(filePath)) {
|
|
||||||
throw EndUserException("File '$filePath' does not exist.")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
json = Parser.default().parse(filePath.toString()) as JsonObject
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw EndUserException("Wrong file format. This is not a valid JSON file.")
|
|
||||||
}
|
|
||||||
skeleton = json.obj("skeleton") ?: throw EndUserException("JSON file is corrupted.")
|
|
||||||
|
|
||||||
validateProperties()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateProperties() {
|
|
||||||
imagesDirectoryPath
|
|
||||||
audioDirectoryPath
|
|
||||||
}
|
|
||||||
|
|
||||||
private val imagesDirectoryPath: Path get() {
|
|
||||||
val relativeImagesDirectory = skeleton.string("images")
|
|
||||||
?: throw EndUserException("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 EndUserException("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 EndUserException("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 EndUserException("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 EndUserException("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> {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val skins: Collection<JsonObject> = when (val skinsObject = json["skins"]) {
|
|
||||||
is JsonObject -> skinsObject.values as Collection<JsonObject>
|
|
||||||
is JsonArray<*> -> skinsObject as Collection<JsonObject>
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get attachment names for all skins
|
|
||||||
return skins
|
|
||||||
.flatMap { skin ->
|
|
||||||
skin.obj(slotName)?.keys?.toList()
|
|
||||||
?: skin.obj("attachments")?.obj(slotName)?.keys?.toList()
|
|
||||||
?: emptyList<String>()
|
|
||||||
}
|
|
||||||
.distinct()
|
|
||||||
}
|
|
||||||
|
|
||||||
val animationNames = observableSet<String>(
|
|
||||||
json.obj("animations")?.map{ it.key }?.toMutableSet() ?: mutableSetOf()
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return json.toJsonString(prettyPrint = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun save() {
|
|
||||||
Files.write(filePath, listOf(toString()), StandardCharsets.UTF_8)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,92 +0,0 @@
|
||||||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
|
||||||
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.net.MalformedURLException
|
|
||||||
import java.net.URISyntaxException
|
|
||||||
import java.net.URL
|
|
||||||
import org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS
|
|
||||||
import java.nio.file.Path
|
|
||||||
import java.nio.file.Paths
|
|
||||||
|
|
||||||
// The following code is adapted from https://stackoverflow.com/a/12733172/52041
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the base location of the given class.
|
|
||||||
*
|
|
||||||
* If the class is directly on the file system (e.g.,
|
|
||||||
* "/path/to/my/package/MyClass.class") then it will return the base directory
|
|
||||||
* (e.g., "file:/path/to").
|
|
||||||
*
|
|
||||||
* If the class is within a JAR file (e.g.,
|
|
||||||
* "/path/to/my-jar.jar!/my/package/MyClass.class") then it will return the
|
|
||||||
* path to the JAR (e.g., "file:/path/to/my-jar.jar").
|
|
||||||
*
|
|
||||||
* @param c The class whose location is desired.
|
|
||||||
*/
|
|
||||||
fun getLocation(c: Class<*>): URL {
|
|
||||||
// Try the easy way first
|
|
||||||
try {
|
|
||||||
val codeSourceLocation = c.protectionDomain.codeSource.location
|
|
||||||
if (codeSourceLocation != null) return codeSourceLocation
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
// Cannot access protection domain
|
|
||||||
} catch (e: NullPointerException) {
|
|
||||||
// Protection domain or code source is null
|
|
||||||
}
|
|
||||||
|
|
||||||
// The easy way failed, so we try the hard way. We ask for the class
|
|
||||||
// itself as a resource, then strip the class's path from the URL string,
|
|
||||||
// leaving the base path.
|
|
||||||
|
|
||||||
// Get the class's raw resource path
|
|
||||||
val classResource = c.getResource(c.simpleName + ".class")
|
|
||||||
?: throw Exception("Cannot find class resource.")
|
|
||||||
|
|
||||||
val url = classResource.toString()
|
|
||||||
val suffix = c.canonicalName.replace('.', '/') + ".class"
|
|
||||||
if (!url.endsWith(suffix)) throw Exception("Malformed URL.")
|
|
||||||
|
|
||||||
// strip the class's path from the URL string
|
|
||||||
val base = url.substring(0, url.length - suffix.length)
|
|
||||||
|
|
||||||
var path = base
|
|
||||||
|
|
||||||
// remove the "jar:" prefix and "!/" suffix, if present
|
|
||||||
if (path.startsWith("jar:")) path = path.substring(4, path.length - 2)
|
|
||||||
|
|
||||||
return URL(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts the given URL to its corresponding [Path].
|
|
||||||
*
|
|
||||||
* @param url The URL to convert.
|
|
||||||
* @return A file path suitable for use with e.g. [FileInputStream]
|
|
||||||
*/
|
|
||||||
fun urlToPath(url: URL): Path {
|
|
||||||
var pathString = url.toString()
|
|
||||||
|
|
||||||
if (pathString.startsWith("jar:")) {
|
|
||||||
// Remove "jar:" prefix and "!/" suffix
|
|
||||||
val index = pathString.indexOf("!/")
|
|
||||||
pathString = pathString.substring(4, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (IS_OS_WINDOWS && pathString.matches("file:[A-Za-z]:.*".toRegex())) {
|
|
||||||
pathString = "file:/" + pathString.substring(5)
|
|
||||||
}
|
|
||||||
return Paths.get(URL(pathString).toURI())
|
|
||||||
} catch (e: MalformedURLException) {
|
|
||||||
// URL is not completely well-formed.
|
|
||||||
} catch (e: URISyntaxException) {
|
|
||||||
// URL is not completely well-formed.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathString.startsWith("file:")) {
|
|
||||||
// Pass through the URL as-is, minus "file:" prefix
|
|
||||||
pathString = pathString.substring(5)
|
|
||||||
return Paths.get(pathString)
|
|
||||||
}
|
|
||||||
throw IllegalArgumentException("Invalid URL: $url")
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
|
||||||
|
|
||||||
import javafx.application.Platform
|
|
||||||
import javafx.beans.property.Property
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getStackTrace(e: Exception): String {
|
|
||||||
val stringWriter = StringWriter()
|
|
||||||
e.printStackTrace(PrintWriter(stringWriter))
|
|
||||||
return stringWriter.toString()
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
package com.rhubarb_lip_sync.rhubarb_for_spine
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Nested
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.nio.file.Paths
|
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
|
||||||
import org.assertj.core.api.Assertions.catchThrowable
|
|
||||||
|
|
||||||
class SpineJsonTest {
|
|
||||||
@Nested
|
|
||||||
inner class `file format 3_7` {
|
|
||||||
@Test
|
|
||||||
fun `correctly reads valid file`() {
|
|
||||||
val path = Paths.get("src/test/data/jsonFiles/matt-3.7.json").toAbsolutePath()
|
|
||||||
val spine = SpineJson(path)
|
|
||||||
|
|
||||||
assertThat(spine.audioDirectoryPath)
|
|
||||||
.isEqualTo(Paths.get("src/test/data/jsonFiles/audio").toAbsolutePath())
|
|
||||||
assertThat(spine.frameRate).isEqualTo(30.0)
|
|
||||||
assertThat(spine.slots).containsExactly("legs", "torso", "head", "mouth")
|
|
||||||
assertThat(spine.guessMouthSlot()).isEqualTo("mouth")
|
|
||||||
assertThat(spine.audioEvents).containsExactly(
|
|
||||||
SpineJson.AudioEvent("1-have-you-heard", "1-have-you-heard.wav", null),
|
|
||||||
SpineJson.AudioEvent("2-it's-a-tool", "2-it's-a-tool.wav", null),
|
|
||||||
SpineJson.AudioEvent("3-and-now-you-can", "3-and-now-you-can.wav", null)
|
|
||||||
)
|
|
||||||
assertThat(spine.getSlotAttachmentNames("mouth")).isEqualTo(('a'..'h').map{ "mouth_$it" })
|
|
||||||
assertThat(spine.animationNames).containsExactly("shake_head", "walk")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `throws on file without nonessential data`() {
|
|
||||||
val path = Paths.get("src/test/data/jsonFiles/matt-3.7-essential.json").toAbsolutePath()
|
|
||||||
val throwable = catchThrowable { SpineJson(path) }
|
|
||||||
assertThat(throwable)
|
|
||||||
.hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
|
||||||
inner class `file format 3_8` {
|
|
||||||
@Test
|
|
||||||
fun `correctly reads valid file`() {
|
|
||||||
val path = Paths.get("src/test/data/jsonFiles/matt-3.8.json").toAbsolutePath()
|
|
||||||
val spine = SpineJson(path)
|
|
||||||
|
|
||||||
assertThat(spine.audioDirectoryPath)
|
|
||||||
.isEqualTo(Paths.get("src/test/data/jsonFiles/audio").toAbsolutePath())
|
|
||||||
assertThat(spine.frameRate).isEqualTo(30.0)
|
|
||||||
assertThat(spine.slots).containsExactly("legs", "torso", "head", "mouth")
|
|
||||||
assertThat(spine.guessMouthSlot()).isEqualTo("mouth")
|
|
||||||
assertThat(spine.audioEvents).containsExactly(
|
|
||||||
SpineJson.AudioEvent("1-have-you-heard", "1-have-you-heard.wav", null),
|
|
||||||
SpineJson.AudioEvent("2-it's-a-tool", "2-it's-a-tool.wav", null),
|
|
||||||
SpineJson.AudioEvent("3-and-now-you-can", "3-and-now-you-can.wav", null)
|
|
||||||
)
|
|
||||||
assertThat(spine.getSlotAttachmentNames("mouth")).isEqualTo(('a'..'h').map{ "mouth_$it" })
|
|
||||||
assertThat(spine.animationNames).containsExactly("shake_head", "walk")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `throws on file without nonessential data`() {
|
|
||||||
val path = Paths.get("src/test/data/jsonFiles/matt-3.8-essential.json").toAbsolutePath()
|
|
||||||
val throwable = catchThrowable { SpineJson(path) }
|
|
||||||
assertThat(throwable)
|
|
||||||
.hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
cmake_minimum_required(VERSION 3.2)
|
|
||||||
|
|
||||||
set(vegasFiles
|
|
||||||
"Debug Rhubarb.cs"
|
|
||||||
"Debug Rhubarb.cs.config"
|
|
||||||
"Import Rhubarb.cs"
|
|
||||||
"Import Rhubarb.cs.config"
|
|
||||||
"README.adoc"
|
|
||||||
)
|
|
||||||
|
|
||||||
install(
|
|
||||||
FILES ${vegasFiles}
|
|
||||||
DESTINATION "extras/MagixVegas"
|
|
||||||
)
|
|
|
@ -1,345 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.ComponentModel.Design;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Drawing.Design;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Web.UI.Design;
|
|
||||||
using System.Windows.Forms;
|
|
||||||
using System.Windows.Forms.Design;
|
|
||||||
using System.Xml;
|
|
||||||
using System.Xml.Serialization;
|
|
||||||
using ScriptPortal.Vegas; // For older versions, this should say Sony.Vegas
|
|
||||||
using Region = ScriptPortal.Vegas.Region; // For older versions, this should say Sony.Vegas.Region
|
|
||||||
|
|
||||||
public class EntryPoint {
|
|
||||||
public void FromVegas(Vegas vegas) {
|
|
||||||
Config config = Config.Load();
|
|
||||||
ImportDialog importDialog = new ImportDialog(config, delegate { Import(config, vegas); });
|
|
||||||
importDialog.ShowDialog();
|
|
||||||
config.Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Import(Config config, Vegas vegas) {
|
|
||||||
Project project = vegas.Project;
|
|
||||||
|
|
||||||
// Clear markers and regions
|
|
||||||
if (config.ClearMarkers) {
|
|
||||||
project.Markers.Clear();
|
|
||||||
}
|
|
||||||
if (config.ClearRegions) {
|
|
||||||
project.Regions.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load log file
|
|
||||||
if (!File.Exists(config.LogFile)) {
|
|
||||||
throw new Exception("Log file does not exist.");
|
|
||||||
}
|
|
||||||
Dictionary<EventType, List<TimedEvent>> timedEvents = ParseLogFile(config);
|
|
||||||
|
|
||||||
// Add markers/regions
|
|
||||||
foreach (EventType eventType in timedEvents.Keys) {
|
|
||||||
foreach (Visualization visualization in config.Visualizations) {
|
|
||||||
if (visualization.EventType != eventType) continue;
|
|
||||||
|
|
||||||
List<TimedEvent> filteredEvents = FilterEvents(timedEvents[eventType], visualization.Regex);
|
|
||||||
foreach (TimedEvent timedEvent in filteredEvents) {
|
|
||||||
Timecode start = Timecode.FromSeconds(timedEvent.Start);
|
|
||||||
Timecode end = Timecode.FromSeconds(timedEvent.End);
|
|
||||||
Timecode length = end - start;
|
|
||||||
if (config.LoopRegionOnly) {
|
|
||||||
Timecode loopRegionStart = vegas.Transport.LoopRegionStart;
|
|
||||||
Timecode loopRegionEnd = loopRegionStart + vegas.Transport.LoopRegionLength;
|
|
||||||
if (start < loopRegionStart || start > loopRegionEnd || end < loopRegionStart || end > loopRegionEnd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (visualization.VisualizationType) {
|
|
||||||
case VisualizationType.Marker:
|
|
||||||
project.Markers.Add(new Marker(start, timedEvent.Value));
|
|
||||||
break;
|
|
||||||
case VisualizationType.Region:
|
|
||||||
project.Regions.Add(new Region(start, length, timedEvent.Value));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<TimedEvent> FilterEvents(List<TimedEvent> timedEvents, Regex filterRegex) {
|
|
||||||
if (filterRegex == null) return timedEvents;
|
|
||||||
|
|
||||||
StringBuilder stringBuilder = new StringBuilder();
|
|
||||||
Dictionary<int, TimedEvent> timedEventsByCharPosition = new Dictionary<int, TimedEvent>();
|
|
||||||
foreach (TimedEvent timedEvent in timedEvents) {
|
|
||||||
string inAngleBrackets = "<" + timedEvent.Value + ">";
|
|
||||||
for (int charPosition = stringBuilder.Length;
|
|
||||||
charPosition < stringBuilder.Length + inAngleBrackets.Length;
|
|
||||||
charPosition++) {
|
|
||||||
timedEventsByCharPosition[charPosition] = timedEvent;
|
|
||||||
}
|
|
||||||
stringBuilder.Append(inAngleBrackets);
|
|
||||||
}
|
|
||||||
|
|
||||||
MatchCollection matches = filterRegex.Matches(stringBuilder.ToString());
|
|
||||||
List<TimedEvent> result = new List<TimedEvent>();
|
|
||||||
foreach (Match match in matches) {
|
|
||||||
if (match.Length == 0) continue;
|
|
||||||
|
|
||||||
for (int charPosition = match.Index; charPosition < match.Index + match.Length; charPosition++) {
|
|
||||||
TimedEvent matchedEvent = timedEventsByCharPosition[charPosition];
|
|
||||||
if (!result.Contains(matchedEvent)) {
|
|
||||||
result.Add(matchedEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<EventType, List<TimedEvent>> ParseLogFile(Config config) {
|
|
||||||
string[] lines = File.ReadAllLines(config.LogFile);
|
|
||||||
Regex structuredLogLine = new Regex(@"##(\w+)\[(\d*\.\d*)-(\d*\.\d*)\]: (.*)");
|
|
||||||
Dictionary<EventType, List<TimedEvent>> timedEvents = new Dictionary<EventType, List<TimedEvent>>();
|
|
||||||
foreach (string line in lines) {
|
|
||||||
Match match = structuredLogLine.Match(line);
|
|
||||||
if (!match.Success) continue;
|
|
||||||
|
|
||||||
EventType eventType = (EventType) Enum.Parse(typeof(EventType), match.Groups[1].Value, true);
|
|
||||||
double start = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
|
||||||
double end = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
|
||||||
string value = match.Groups[4].Value;
|
|
||||||
|
|
||||||
if (!timedEvents.ContainsKey(eventType)) {
|
|
||||||
timedEvents[eventType] = new List<TimedEvent>();
|
|
||||||
}
|
|
||||||
timedEvents[eventType].Add(new TimedEvent(eventType, start, end, value));
|
|
||||||
}
|
|
||||||
return timedEvents;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TimedEvent {
|
|
||||||
private readonly EventType eventType;
|
|
||||||
private readonly double start;
|
|
||||||
private readonly double end;
|
|
||||||
private readonly string value;
|
|
||||||
|
|
||||||
public TimedEvent(EventType eventType, double start, double end, string value) {
|
|
||||||
this.eventType = eventType;
|
|
||||||
this.start = start;
|
|
||||||
this.end = end;
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public EventType EventType {
|
|
||||||
get { return eventType; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public double Start {
|
|
||||||
get { return start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public double End {
|
|
||||||
get { return end; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Value {
|
|
||||||
get { return value; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Config {
|
|
||||||
private string logFile;
|
|
||||||
private bool clearMarkers;
|
|
||||||
private bool clearRegions;
|
|
||||||
private bool loopRegionOnly;
|
|
||||||
private List<Visualization> visualizations = new List<Visualization>();
|
|
||||||
|
|
||||||
[DisplayName("Log File")]
|
|
||||||
[Description("A log file generated by Rhubarb Lip Sync.")]
|
|
||||||
[Editor(typeof(FileNameEditor), typeof(UITypeEditor))]
|
|
||||||
public string LogFile {
|
|
||||||
get { return logFile; }
|
|
||||||
set { logFile = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Clear Markers")]
|
|
||||||
[Description("Clear all markers in the current project.")]
|
|
||||||
public bool ClearMarkers {
|
|
||||||
get { return clearMarkers; }
|
|
||||||
set { clearMarkers = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Clear Regions")]
|
|
||||||
[Description("Clear all regions in the current project.")]
|
|
||||||
public bool ClearRegions {
|
|
||||||
get { return clearRegions; }
|
|
||||||
set { clearRegions = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Loop region only")]
|
|
||||||
[Description("Adds regions or markers to the loop region only.")]
|
|
||||||
public bool LoopRegionOnly {
|
|
||||||
get { return loopRegionOnly; }
|
|
||||||
set { loopRegionOnly = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Visualization rules")]
|
|
||||||
[Description("Specify how to visualize various log events.")]
|
|
||||||
[Editor(typeof(CollectionEditor), typeof(UITypeEditor))]
|
|
||||||
[XmlIgnore]
|
|
||||||
public List<Visualization> Visualizations {
|
|
||||||
get { return visualizations; }
|
|
||||||
set { visualizations = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Browsable(false)]
|
|
||||||
public Visualization[] VisualizationArray {
|
|
||||||
get { return visualizations.ToArray(); }
|
|
||||||
set { visualizations = new List<Visualization>(value); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ConfigFileName {
|
|
||||||
get {
|
|
||||||
string folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
|
||||||
return Path.Combine(folder, "DebugRhubarbSettings.xml");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Config Load() {
|
|
||||||
try {
|
|
||||||
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
|
||||||
using (FileStream file = File.OpenRead(ConfigFileName)) {
|
|
||||||
return (Config) serializer.Deserialize(file);
|
|
||||||
}
|
|
||||||
} catch (Exception) {
|
|
||||||
return new Config();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Save() {
|
|
||||||
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
|
||||||
using (StreamWriter file = File.CreateText(ConfigFileName)) {
|
|
||||||
XmlWriterSettings settings = new XmlWriterSettings();
|
|
||||||
settings.Indent = true;
|
|
||||||
settings.IndentChars = "\t";
|
|
||||||
using (XmlWriter writer = XmlWriter.Create(file, settings)) {
|
|
||||||
serializer.Serialize(writer, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Visualization {
|
|
||||||
private EventType eventType;
|
|
||||||
private string regexString;
|
|
||||||
private VisualizationType visualizationType = VisualizationType.Marker;
|
|
||||||
|
|
||||||
[DisplayName("Event Type")]
|
|
||||||
[Description("The type of event to visualize.")]
|
|
||||||
public EventType EventType {
|
|
||||||
get { return eventType; }
|
|
||||||
set { eventType = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Regular Expression")]
|
|
||||||
[Description("A regular expression used to filter events. Leave empty to disable filtering.\nInput is a string of events in angle brackets. Example: '<AO>(?=<T>)' finds every AO phone followed by a T phone.")]
|
|
||||||
public string RegexString {
|
|
||||||
get { return regexString; }
|
|
||||||
set { regexString = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Browsable(false)]
|
|
||||||
public Regex Regex {
|
|
||||||
get { return string.IsNullOrEmpty(RegexString) ? null : new Regex(RegexString); }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Visualization Type")]
|
|
||||||
[Description("Specify how to visualize events.")]
|
|
||||||
public VisualizationType VisualizationType {
|
|
||||||
get { return visualizationType; }
|
|
||||||
set { visualizationType = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() {
|
|
||||||
return string.Format("{0} -> {1}", EventType, VisualizationType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum EventType {
|
|
||||||
Utterance,
|
|
||||||
Word,
|
|
||||||
RawPhone,
|
|
||||||
Phone,
|
|
||||||
Shape,
|
|
||||||
Segment
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum VisualizationType {
|
|
||||||
None,
|
|
||||||
Marker,
|
|
||||||
Region
|
|
||||||
}
|
|
||||||
|
|
||||||
public delegate void ImportAction();
|
|
||||||
|
|
||||||
public class ImportDialog : Form {
|
|
||||||
private readonly Config config;
|
|
||||||
private readonly ImportAction import;
|
|
||||||
|
|
||||||
public ImportDialog(Config config, ImportAction import) {
|
|
||||||
this.config = config;
|
|
||||||
this.import = import;
|
|
||||||
SuspendLayout();
|
|
||||||
InitializeComponent();
|
|
||||||
ResumeLayout(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
// Configure dialog
|
|
||||||
Text = "Debug Rhubarb";
|
|
||||||
Size = new Size(600, 400);
|
|
||||||
Font = new Font(Font.FontFamily, 10);
|
|
||||||
|
|
||||||
// Add property grid
|
|
||||||
PropertyGrid propertyGrid1 = new PropertyGrid();
|
|
||||||
propertyGrid1.SelectedObject = config;
|
|
||||||
Controls.Add(propertyGrid1);
|
|
||||||
propertyGrid1.Dock = DockStyle.Fill;
|
|
||||||
|
|
||||||
// Add button panel
|
|
||||||
FlowLayoutPanel buttonPanel = new FlowLayoutPanel();
|
|
||||||
buttonPanel.FlowDirection = FlowDirection.RightToLeft;
|
|
||||||
buttonPanel.AutoSize = true;
|
|
||||||
buttonPanel.Dock = DockStyle.Bottom;
|
|
||||||
Controls.Add(buttonPanel);
|
|
||||||
|
|
||||||
// Add Cancel button
|
|
||||||
Button cancelButton1 = new Button();
|
|
||||||
cancelButton1.Text = "Cancel";
|
|
||||||
cancelButton1.DialogResult = DialogResult.Cancel;
|
|
||||||
buttonPanel.Controls.Add(cancelButton1);
|
|
||||||
CancelButton = cancelButton1;
|
|
||||||
|
|
||||||
// Add OK button
|
|
||||||
Button okButton1 = new Button();
|
|
||||||
okButton1.Text = "OK";
|
|
||||||
okButton1.Click += OkButtonClickedHandler;
|
|
||||||
buttonPanel.Controls.Add(okButton1);
|
|
||||||
AcceptButton = okButton1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OkButtonClickedHandler(object sender, EventArgs e) {
|
|
||||||
try {
|
|
||||||
import();
|
|
||||||
DialogResult = DialogResult.OK;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
MessageBox.Show(exception.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,233 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Drawing.Design;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Web.UI.Design;
|
|
||||||
using System.Windows.Forms;
|
|
||||||
using System.Windows.Forms.Design;
|
|
||||||
using System.Xml;
|
|
||||||
using System.Xml.Serialization;
|
|
||||||
using ScriptPortal.Vegas; // For older versions, this should say Sony.Vegas
|
|
||||||
|
|
||||||
public class EntryPoint {
|
|
||||||
public void FromVegas(Vegas vegas) {
|
|
||||||
Config config = Config.Load();
|
|
||||||
ImportDialog importDialog = new ImportDialog(config, delegate { Import(config, vegas); });
|
|
||||||
importDialog.ShowDialog();
|
|
||||||
config.Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Import(Config config, Vegas vegas) {
|
|
||||||
// Load XML file
|
|
||||||
if (!File.Exists(config.XmlFile)) {
|
|
||||||
throw new Exception("XML file does not exist.");
|
|
||||||
}
|
|
||||||
XmlDocument xmlDocument = new XmlDocument();
|
|
||||||
xmlDocument.Load(config.XmlFile);
|
|
||||||
|
|
||||||
// Determine image file names
|
|
||||||
XmlNodeList mouthCueElements = xmlDocument.SelectNodes("//mouthCue");
|
|
||||||
List<string> shapeNames = new List<string>();
|
|
||||||
foreach (XmlElement mouthCueElement in mouthCueElements) {
|
|
||||||
if (!shapeNames.Contains(mouthCueElement.InnerText)) {
|
|
||||||
shapeNames.Add(mouthCueElement.InnerText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Dictionary<string, string> imageFileNames = GetImageFileNames(config.OneImageFile, shapeNames.ToArray());
|
|
||||||
|
|
||||||
// Create new project
|
|
||||||
bool promptSave = !config.DiscardChanges;
|
|
||||||
bool showDialog = false;
|
|
||||||
Project project = new Project(promptSave, showDialog);
|
|
||||||
|
|
||||||
// Set frame size
|
|
||||||
Bitmap testImage = new Bitmap(config.OneImageFile);
|
|
||||||
project.Video.Width = testImage.Width;
|
|
||||||
project.Video.Height = testImage.Height;
|
|
||||||
|
|
||||||
// Set frame rate
|
|
||||||
if (config.FrameRate < 0.1 || config.FrameRate > 100) {
|
|
||||||
throw new Exception("Invalid frame rate.");
|
|
||||||
}
|
|
||||||
project.Video.FrameRate = config.FrameRate;
|
|
||||||
|
|
||||||
// Set other video settings
|
|
||||||
project.Video.FieldOrder = VideoFieldOrder.ProgressiveScan;
|
|
||||||
project.Video.PixelAspectRatio = 1;
|
|
||||||
|
|
||||||
// Add video track with images
|
|
||||||
VideoTrack videoTrack = vegas.Project.AddVideoTrack();
|
|
||||||
foreach (XmlElement mouthCueElement in mouthCueElements) {
|
|
||||||
Timecode start = GetTimecode(mouthCueElement.Attributes["start"]);
|
|
||||||
Timecode length = GetTimecode(mouthCueElement.Attributes["end"]) - start;
|
|
||||||
VideoEvent videoEvent = videoTrack.AddVideoEvent(start, length);
|
|
||||||
Media imageMedia = new Media(imageFileNames[mouthCueElement.InnerText]);
|
|
||||||
videoEvent.AddTake(imageMedia.GetVideoStreamByIndex(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add audio track with original sound file
|
|
||||||
AudioTrack audioTrack = vegas.Project.AddAudioTrack();
|
|
||||||
Media audioMedia = new Media(xmlDocument.SelectSingleNode("//soundFile").InnerText);
|
|
||||||
AudioEvent audioEvent = audioTrack.AddAudioEvent(new Timecode(0), audioMedia.Length);
|
|
||||||
audioEvent.AddTake(audioMedia.GetAudioStreamByIndex(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Timecode GetTimecode(XmlAttribute valueAttribute) {
|
|
||||||
double seconds = Double.Parse(valueAttribute.Value, CultureInfo.InvariantCulture);
|
|
||||||
return Timecode.FromSeconds(seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Dictionary<string, string> GetImageFileNames(string oneImageFile, string[] shapeNames) {
|
|
||||||
if (oneImageFile == null) {
|
|
||||||
throw new Exception("Image file name not set.");
|
|
||||||
}
|
|
||||||
Regex nameRegex = new Regex(@"(?<=-)([^-]*)(?=\.[^.]+$)");
|
|
||||||
if (!nameRegex.IsMatch(oneImageFile)) {
|
|
||||||
throw new Exception("Image file name doesn't have expected format.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, string> result = new Dictionary<string, string>();
|
|
||||||
foreach (string shapeName in shapeNames) {
|
|
||||||
string imageFileName = nameRegex.Replace(oneImageFile, shapeName);
|
|
||||||
if (!File.Exists(imageFileName)) {
|
|
||||||
throw new Exception(string.Format("Image file '{0}' not found.", imageFileName));
|
|
||||||
}
|
|
||||||
result[shapeName] = imageFileName;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Config {
|
|
||||||
|
|
||||||
private string xmlFile;
|
|
||||||
private string oneImageFile;
|
|
||||||
private double frameRate = 100;
|
|
||||||
private bool discardChanges = false;
|
|
||||||
|
|
||||||
[DisplayName("XML File")]
|
|
||||||
[Description("An XML file generated by Rhubarb Lip Sync.")]
|
|
||||||
[Editor(typeof(XmlFileEditor), typeof(UITypeEditor))]
|
|
||||||
public string XmlFile {
|
|
||||||
get { return xmlFile; }
|
|
||||||
set { xmlFile = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("One image file")]
|
|
||||||
[Description("Any image file out of the set of image files representing the mouth chart.")]
|
|
||||||
[Editor(typeof(FileNameEditor), typeof(UITypeEditor))]
|
|
||||||
public string OneImageFile {
|
|
||||||
get { return oneImageFile; }
|
|
||||||
set { oneImageFile = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Frame rate")]
|
|
||||||
[Description("The frame rate for the new project.")]
|
|
||||||
public double FrameRate {
|
|
||||||
get { return frameRate; }
|
|
||||||
set { frameRate = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[DisplayName("Discard Changes")]
|
|
||||||
[Description("Discard all changes to the current project without prompting to save.")]
|
|
||||||
public bool DiscardChanges {
|
|
||||||
get { return discardChanges; }
|
|
||||||
set { discardChanges = value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ConfigFileName {
|
|
||||||
get {
|
|
||||||
string folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
|
||||||
return Path.Combine(folder, "ImportRhubarbSettings.xml");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Config Load() {
|
|
||||||
try {
|
|
||||||
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
|
||||||
using (FileStream file = File.OpenRead(ConfigFileName)) {
|
|
||||||
return (Config) serializer.Deserialize(file);
|
|
||||||
}
|
|
||||||
} catch (Exception) {
|
|
||||||
return new Config();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Save() {
|
|
||||||
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
|
||||||
using (StreamWriter file = File.CreateText(ConfigFileName)) {
|
|
||||||
XmlWriterSettings settings = new XmlWriterSettings();
|
|
||||||
settings.Indent = true;
|
|
||||||
settings.IndentChars = "\t";
|
|
||||||
using (XmlWriter writer = XmlWriter.Create(file, settings)) {
|
|
||||||
serializer.Serialize(writer, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public delegate void ImportAction();
|
|
||||||
|
|
||||||
public class ImportDialog : Form {
|
|
||||||
|
|
||||||
private readonly Config config;
|
|
||||||
private readonly ImportAction import;
|
|
||||||
|
|
||||||
public ImportDialog(Config config, ImportAction import) {
|
|
||||||
this.config = config;
|
|
||||||
this.import = import;
|
|
||||||
SuspendLayout();
|
|
||||||
InitializeComponent();
|
|
||||||
ResumeLayout(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InitializeComponent() {
|
|
||||||
// Configure dialog
|
|
||||||
Text = "Import Rhubarb";
|
|
||||||
Size = new Size(600, 400);
|
|
||||||
Font = new Font(Font.FontFamily, 10);
|
|
||||||
|
|
||||||
// Add property grid
|
|
||||||
PropertyGrid propertyGrid1 = new PropertyGrid();
|
|
||||||
propertyGrid1.SelectedObject = config;
|
|
||||||
Controls.Add(propertyGrid1);
|
|
||||||
propertyGrid1.Dock = DockStyle.Fill;
|
|
||||||
|
|
||||||
// Add button panel
|
|
||||||
FlowLayoutPanel buttonPanel = new FlowLayoutPanel();
|
|
||||||
buttonPanel.FlowDirection = FlowDirection.RightToLeft;
|
|
||||||
buttonPanel.AutoSize = true;
|
|
||||||
buttonPanel.Dock = DockStyle.Bottom;
|
|
||||||
Controls.Add(buttonPanel);
|
|
||||||
|
|
||||||
// Add Cancel button
|
|
||||||
Button cancelButton1 = new Button();
|
|
||||||
cancelButton1.Text = "Cancel";
|
|
||||||
cancelButton1.DialogResult = DialogResult.Cancel;
|
|
||||||
buttonPanel.Controls.Add(cancelButton1);
|
|
||||||
CancelButton = cancelButton1;
|
|
||||||
|
|
||||||
// Add OK button
|
|
||||||
Button okButton1 = new Button();
|
|
||||||
okButton1.Text = "OK";
|
|
||||||
okButton1.Click += OkButtonClickedHandler;
|
|
||||||
buttonPanel.Controls.Add(okButton1);
|
|
||||||
AcceptButton = okButton1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OkButtonClickedHandler(object sender, EventArgs e) {
|
|
||||||
try {
|
|
||||||
import();
|
|
||||||
DialogResult = DialogResult.OK;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
MessageBox.Show(exception.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,852 @@
|
||||||
|
// prettier-ignore
|
||||||
|
(function polyfill() {
|
||||||
|
// Polyfill for Object.assign
|
||||||
|
"function"!=typeof Object.assign&&(Object.assign=function(a,b){"use strict";if(null==a)throw new TypeError("Cannot convert undefined or null to object");for(var c=Object(a),d=1;d<arguments.length;d++){var e=arguments[d];if(null!=e)for(var f in e)Object.prototype.hasOwnProperty.call(e,f)&&(c[f]=e[f])}return c});
|
||||||
|
|
||||||
|
// Polyfill for Array.isArray
|
||||||
|
Array.isArray||(Array.isArray=function(r){return"[object Array]"===Object.prototype.toString.call(r)});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.map
|
||||||
|
Array.prototype.map||(Array.prototype.map=function(r){var t,n,o;if(null==this)throw new TypeError("this is null or not defined");var e=Object(this),i=e.length>>>0;if("function"!=typeof r)throw new TypeError(r+" is not a function");for(arguments.length>1&&(t=arguments[1]),n=new Array(i),o=0;o<i;){var a,p;o in e&&(a=e[o],p=r.call(t,a,o,e),n[o]=p),o++}return n});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.every
|
||||||
|
Array.prototype.every||(Array.prototype.every=function(r,t){"use strict";var e,n;if(null==this)throw new TypeError("this is null or not defined");var o=Object(this),i=o.length>>>0;if("function"!=typeof r)throw new TypeError;for(arguments.length>1&&(e=t),n=0;n<i;){var y;if(n in o&&(y=o[n],!r.call(e,y,n,o)))return!1;n++}return!0});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.find
|
||||||
|
Array.prototype.find||(Array.prototype.find=function(r){if(null===this)throw new TypeError("Array.prototype.find called on null or undefined");if("function"!=typeof r)throw new TypeError("callback must be a function");for(var n=Object(this),t=n.length>>>0,o=arguments[1],e=0;e<t;e++){var f=n[e];if(r.call(o,f,e,n))return f}});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.filter
|
||||||
|
Array.prototype.filter||(Array.prototype.filter=function(r){"use strict";if(void 0===this||null===this)throw new TypeError;var t=Object(this),e=t.length>>>0;if("function"!=typeof r)throw new TypeError;for(var i=[],o=arguments.length>=2?arguments[1]:void 0,n=0;n<e;n++)if(n in t){var f=t[n];r.call(o,f,n,t)&&i.push(f)}return i});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.forEach
|
||||||
|
Array.prototype.forEach||(Array.prototype.forEach=function(a,b){var c,d;if(null===this)throw new TypeError(" this is null or not defined");var e=Object(this),f=e.length>>>0;if("function"!=typeof a)throw new TypeError(a+" is not a function");for(arguments.length>1&&(c=b),d=0;d<f;){var g;d in e&&(g=e[d],a.call(c,g,d,e)),d++}});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.includes
|
||||||
|
Array.prototype.includes||(Array.prototype.includes=function(r,t){if(null==this)throw new TypeError('"this" is null or not defined');var e=Object(this),n=e.length>>>0;if(0===n)return!1;for(var i=0|t,o=Math.max(i>=0?i:n-Math.abs(i),0);o<n;){if(function(r,t){return r===t||"number"==typeof r&&"number"==typeof t&&isNaN(r)&&isNaN(t)}(e[o],r))return!0;o++}return!1});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.indexOf
|
||||||
|
Array.prototype.indexOf||(Array.prototype.indexOf=function(r,t){var n;if(null==this)throw new TypeError('"this" is null or not defined');var e=Object(this),i=e.length>>>0;if(0===i)return-1;var o=0|t;if(o>=i)return-1;for(n=Math.max(o>=0?o:i-Math.abs(o),0);n<i;){if(n in e&&e[n]===r)return n;n++}return-1});
|
||||||
|
|
||||||
|
// Polyfill for Array.prototype.some
|
||||||
|
Array.prototype.some||(Array.prototype.some=function(r){"use strict";if(null==this)throw new TypeError("Array.prototype.some called on null or undefined");if("function"!=typeof r)throw new TypeError;for(var e=Object(this),o=e.length>>>0,t=arguments.length>=2?arguments[1]:void 0,n=0;n<o;n++)if(n in e&&r.call(t,e[n],n,e))return!0;return!1});
|
||||||
|
|
||||||
|
// Polyfill for String.prototype.trim
|
||||||
|
String.prototype.trim||(String.prototype.trim=function(){return this.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")});
|
||||||
|
|
||||||
|
// Polyfill for JSON
|
||||||
|
"object"!=typeof JSON&&(JSON={}),function(){"use strict";function f(a){return a<10?"0"+a:a}function this_value(){return this.valueOf()}function quote(a){return rx_escapable.lastIndex=0,rx_escapable.test(a)?'"'+a.replace(rx_escapable,function(a){var b=meta[a];return"string"==typeof b?b:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+a+'"'}function str(a,b){var c,d,e,f,h,g=gap,i=b[a];switch(i&&"object"==typeof i&&"function"==typeof i.toJSON&&(i=i.toJSON(a)),"function"==typeof rep&&(i=rep.call(b,a,i)),typeof i){case"string":return quote(i);case"number":return isFinite(i)?String(i):"null";case"boolean":case"null":return String(i);case"object":if(!i)return"null";if(gap+=indent,h=[],"[object Array]"===Object.prototype.toString.apply(i)){for(f=i.length,c=0;c<f;c+=1)h[c]=str(c,i)||"null";return e=0===h.length?"[]":gap?"[\n"+gap+h.join(",\n"+gap)+"\n"+g+"]":"["+h.join(",")+"]",gap=g,e}if(rep&&"object"==typeof rep)for(f=rep.length,c=0;c<f;c+=1)"string"==typeof rep[c]&&(d=rep[c],(e=str(d,i))&&h.push(quote(d)+(gap?": ":":")+e));else for(d in i)Object.prototype.hasOwnProperty.call(i,d)&&(e=str(d,i))&&h.push(quote(d)+(gap?": ":":")+e);return e=0===h.length?"{}":gap?"{\n"+gap+h.join(",\n"+gap)+"\n"+g+"}":"{"+h.join(",")+"}",gap=g,e}}var rx_one=/^[\],:{}\s]*$/,rx_two=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,rx_three=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,rx_four=/(?:^|:|,)(?:\s*\[)+/g,rx_escapable=/[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,rx_dangerous=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;"function"!=typeof Date.prototype.toJSON&&(Date.prototype.toJSON=function(){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null},Boolean.prototype.toJSON=this_value,Number.prototype.toJSON=this_value,String.prototype.toJSON=this_value);var gap,indent,meta,rep;"function"!=typeof JSON.stringify&&(meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},JSON.stringify=function(a,b,c){var d;if(gap="",indent="","number"==typeof c)for(d=0;d<c;d+=1)indent+=" ";else"string"==typeof c&&(indent=c);if(rep=b,b&&"function"!=typeof b&&("object"!=typeof b||"number"!=typeof b.length))throw new Error("JSON.stringify");return str("",{"":a})}),"function"!=typeof JSON.parse&&(JSON.parse=function(text,reviver){function walk(a,b){var c,d,e=a[b];if(e&&"object"==typeof e)for(c in e)Object.prototype.hasOwnProperty.call(e,c)&&(d=walk(e,c),void 0!==d?e[c]=d:delete e[c]);return reviver.call(a,b,e)}var j;if(text=String(text),rx_dangerous.lastIndex=0,rx_dangerous.test(text)&&(text=text.replace(rx_dangerous,function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})),rx_one.test(text.replace(rx_two,"@").replace(rx_three,"]").replace(rx_four,"")))return j=eval("("+text+")"),"function"==typeof reviver?walk({"":j},""):j;throw new SyntaxError("JSON.parse")})}();
|
||||||
|
})()
|
||||||
|
|
||||||
|
function last(array) {
|
||||||
|
return array[array.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGuid() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
var r = (Math.random() * 16) | 0;
|
||||||
|
var v = c == 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(list) {
|
||||||
|
var result = [];
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
result.push(list[i]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBase1(list) {
|
||||||
|
var result = [];
|
||||||
|
for (var i = 1; i <= list.length; i++) {
|
||||||
|
result.push(list[i]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n, width, z) {
|
||||||
|
z = z || '0';
|
||||||
|
n = String(n);
|
||||||
|
return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks whether scripts are allowed to write files by creating and deleting a dummy file
|
||||||
|
function canWriteFiles() {
|
||||||
|
try {
|
||||||
|
var file = new File();
|
||||||
|
file.open('w');
|
||||||
|
file.writeln('');
|
||||||
|
file.close();
|
||||||
|
file.remove();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameToTime(frameNumber, compItem) {
|
||||||
|
return frameNumber * compItem.frameDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToFrame(time, compItem) {
|
||||||
|
return time * compItem.frameRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// To prevent rounding errors
|
||||||
|
var epsilon = 0.001;
|
||||||
|
|
||||||
|
function isFrameVisible(compItem, frameNumber) {
|
||||||
|
if (!compItem) return false;
|
||||||
|
|
||||||
|
var time = frameToTime(frameNumber + epsilon, compItem);
|
||||||
|
var videoLayers = toArrayBase1(compItem.layers).filter(function (layer) {
|
||||||
|
return layer.hasVideo;
|
||||||
|
});
|
||||||
|
var result = videoLayers.find(function (layer) {
|
||||||
|
return layer.activeAtTime(time);
|
||||||
|
});
|
||||||
|
return Boolean(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var appName = 'Rhubarb Lip Sync';
|
||||||
|
|
||||||
|
var settingsFilePath = Folder.userData.fullName + '/rhubarb-ae-settings.json';
|
||||||
|
|
||||||
|
function readTextFile(fileOrPath) {
|
||||||
|
var filePath = fileOrPath.fsName || fileOrPath;
|
||||||
|
var file = new File(filePath);
|
||||||
|
function check() {
|
||||||
|
if (file.error) throw new Error('Error reading file "' + filePath + '": ' + file.error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
file.open('r');
|
||||||
|
check();
|
||||||
|
file.encoding = 'UTF-8';
|
||||||
|
check();
|
||||||
|
var result = file.read();
|
||||||
|
check();
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
file.close();
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTextFile(fileOrPath, text) {
|
||||||
|
var filePath = fileOrPath.fsName || fileOrPath;
|
||||||
|
var file = new File(filePath);
|
||||||
|
function check() {
|
||||||
|
if (file.error) throw new Error('Error writing file "' + filePath + '": ' + file.error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
file.open('w');
|
||||||
|
check();
|
||||||
|
file.encoding = 'UTF-8';
|
||||||
|
check();
|
||||||
|
file.write(text);
|
||||||
|
check();
|
||||||
|
} finally {
|
||||||
|
file.close();
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettingsFile() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readTextFile(settingsFilePath));
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSettingsFile(settings) {
|
||||||
|
try {
|
||||||
|
writeTextFile(settingsFilePath, JSON.stringify(settings, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error persisting settings. ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var osIsWindows = (system.osName || $.os).match(/windows/i);
|
||||||
|
|
||||||
|
// Depending on the operating system, the syntax for escaping command-line arguments differs.
|
||||||
|
function cliEscape(argument) {
|
||||||
|
return osIsWindows ? '"' + argument + '"' : "'" + argument.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
function exec(command) {
|
||||||
|
return system.callSystem(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function execInWindow(command) {
|
||||||
|
if (osIsWindows) {
|
||||||
|
system.callSystem('cmd /C "' + command + '"');
|
||||||
|
} else {
|
||||||
|
// I didn't think it could be so complicated on OS X to open a new Terminal window,
|
||||||
|
// execute a command, then close the Terminal window.
|
||||||
|
// If you know a better solution, let me know!
|
||||||
|
var escapedCommand = command.replace(/"/g, '\\"');
|
||||||
|
var appleScript =
|
||||||
|
'\
|
||||||
|
tell application "Terminal" \
|
||||||
|
-- Quit terminal \
|
||||||
|
-- Yes, that\'s undesirable if there was an open window before. \
|
||||||
|
-- But all solutions I could find were at least as hacky. \
|
||||||
|
quit \
|
||||||
|
-- Open terminal \
|
||||||
|
activate \
|
||||||
|
-- Run command in new tab \
|
||||||
|
set newTab to do script ("' +
|
||||||
|
escapedCommand +
|
||||||
|
'") \
|
||||||
|
-- Wait until command is done \
|
||||||
|
tell newTab \
|
||||||
|
repeat while busy \
|
||||||
|
delay 0.1 \
|
||||||
|
end repeat \
|
||||||
|
end tell \
|
||||||
|
quit \
|
||||||
|
end tell';
|
||||||
|
exec('osascript -e ' + cliEscape(appleScript));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rhubarbPath = osIsWindows ? 'rhubarb.exe' : '/usr/local/bin/rhubarb';
|
||||||
|
|
||||||
|
// ExtendScript's resource strings are a pain to write.
|
||||||
|
// This function allows them to be written in JSON notation, then converts them into the required
|
||||||
|
// format.
|
||||||
|
// For instance, this string: '{ "__type__": "StaticText", "text": "Hello world" }'
|
||||||
|
// is converted to this: 'StaticText { "text": "Hello world" }'.
|
||||||
|
// This code relies on the fact that, contrary to the language specification, all major JavaScript
|
||||||
|
// implementations keep object properties in insertion order.
|
||||||
|
function createResourceString(tree) {
|
||||||
|
var result = JSON.stringify(tree, null, 2);
|
||||||
|
result = result.replace(/(\{\s*)"__type__":\s*"(\w+)",?\s*/g, '$2 $1');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object containing functions to create control description trees.
|
||||||
|
// For instance, `controls.StaticText({ text: 'Hello world' })`
|
||||||
|
// returns `{ __type__: StaticText, text: 'Hello world' }`.
|
||||||
|
var controlFunctions = (function () {
|
||||||
|
var controlTypes = [
|
||||||
|
// Strangely, 'dialog' and 'palette' need to start with a lower-case character
|
||||||
|
['Dialog', 'dialog'],
|
||||||
|
['Palette', 'palette'],
|
||||||
|
'Panel',
|
||||||
|
'Group',
|
||||||
|
'TabbedPanel',
|
||||||
|
'Tab',
|
||||||
|
'Button',
|
||||||
|
'IconButton',
|
||||||
|
'Image',
|
||||||
|
'StaticText',
|
||||||
|
'EditText',
|
||||||
|
'Checkbox',
|
||||||
|
'RadioButton',
|
||||||
|
'Progressbar',
|
||||||
|
'Slider',
|
||||||
|
'Scrollbar',
|
||||||
|
'ListBox',
|
||||||
|
'DropDownList',
|
||||||
|
'TreeView',
|
||||||
|
'ListItem',
|
||||||
|
'FlashPlayer'
|
||||||
|
];
|
||||||
|
var result = {};
|
||||||
|
controlTypes.forEach(function (type) {
|
||||||
|
var isArray = Array.isArray(type);
|
||||||
|
var key = isArray ? type[0] : type;
|
||||||
|
var value = isArray ? type[1] : type;
|
||||||
|
result[key] = function (options) {
|
||||||
|
return Object.assign({ __type__: value }, options);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Returns the path of a project item within the project
|
||||||
|
function getItemPath(item) {
|
||||||
|
if (item === app.project.rootFolder) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = item.name;
|
||||||
|
while (item.parentFolder !== app.project.rootFolder) {
|
||||||
|
result = item.parentFolder.name + ' / ' + result;
|
||||||
|
item = item.parentFolder;
|
||||||
|
}
|
||||||
|
return '/ ' + result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selects the item within an item control whose text matches the specified text.
|
||||||
|
// If no such item exists, selects the first item, if present.
|
||||||
|
function selectByTextOrFirst(itemControl, text) {
|
||||||
|
var targetItem = toArray(itemControl.items).find(function (item) {
|
||||||
|
return item.text === text;
|
||||||
|
});
|
||||||
|
if (!targetItem && itemControl.items.length) {
|
||||||
|
targetItem = itemControl.items[0];
|
||||||
|
}
|
||||||
|
if (targetItem) {
|
||||||
|
itemControl.selection = targetItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioFileProjectItems() {
|
||||||
|
var result = toArrayBase1(app.project.items).filter(function (item) {
|
||||||
|
var isAudioFootage = item instanceof FootageItem && item.hasAudio && !item.hasVideo;
|
||||||
|
return isAudioFootage;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mouthShapeNames = 'ABCDEFGHX'.split('');
|
||||||
|
var basicMouthShapeCount = 6;
|
||||||
|
var mouthShapeCount = mouthShapeNames.length;
|
||||||
|
var basicMouthShapeNames = mouthShapeNames.slice(0, basicMouthShapeCount);
|
||||||
|
var extendedMouthShapeNames = mouthShapeNames.slice(basicMouthShapeCount);
|
||||||
|
|
||||||
|
function getMouthCompHelpTip() {
|
||||||
|
var result =
|
||||||
|
'A composition containing the mouth shapes, one drawing per frame. They must be ' +
|
||||||
|
'arranged as follows:\n';
|
||||||
|
mouthShapeNames.forEach(function (mouthShapeName, i) {
|
||||||
|
var isOptional = i >= basicMouthShapeCount;
|
||||||
|
result += '\n00:' + pad(i, 2) + '\t' + mouthShapeName + (isOptional ? ' (optional)' : '');
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExtendedShapeCheckboxes() {
|
||||||
|
var result = {};
|
||||||
|
extendedMouthShapeNames.forEach(function (shapeName) {
|
||||||
|
result[shapeName.toLowerCase()] = controlFunctions.Checkbox({
|
||||||
|
text: shapeName,
|
||||||
|
helpTip: 'Controls whether to use the optional ' + shapeName + ' shape.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDialogWindow() {
|
||||||
|
var resourceString;
|
||||||
|
with (controlFunctions) {
|
||||||
|
resourceString = createResourceString(
|
||||||
|
Dialog({
|
||||||
|
text: appName,
|
||||||
|
settings: Group({
|
||||||
|
orientation: 'column',
|
||||||
|
alignChildren: ['left', 'top'],
|
||||||
|
audioFile: Group({
|
||||||
|
label: StaticText({
|
||||||
|
text: 'Audio file:',
|
||||||
|
// If I don't explicitly activate a control, After Effects has trouble
|
||||||
|
// with keyboard focus, so I can't type in the text edit field below.
|
||||||
|
active: true
|
||||||
|
}),
|
||||||
|
value: DropDownList({
|
||||||
|
helpTip:
|
||||||
|
'An audio file containing recorded dialog.\n' +
|
||||||
|
'This field shows all audio files that exist in ' +
|
||||||
|
'your After Effects project.'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
recognizer: Group({
|
||||||
|
label: StaticText({ text: 'Recognizer:' }),
|
||||||
|
value: DropDownList({
|
||||||
|
helpTip: 'The dialog recognizer.'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
dialogText: Group({
|
||||||
|
label: StaticText({ text: 'Dialog text (optional):' }),
|
||||||
|
value: EditText({
|
||||||
|
properties: { multiline: true },
|
||||||
|
characters: 60,
|
||||||
|
minimumSize: [0, 100],
|
||||||
|
helpTip:
|
||||||
|
'For better animation results, you can specify the text of ' +
|
||||||
|
'the recording here. This field is optional.'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
mouthComp: Group({
|
||||||
|
label: StaticText({ text: 'Mouth composition:' }),
|
||||||
|
value: DropDownList({ helpTip: getMouthCompHelpTip() })
|
||||||
|
}),
|
||||||
|
extendedMouthShapes: Group(
|
||||||
|
Object.assign(
|
||||||
|
{ label: StaticText({ text: 'Extended mouth shapes:' }) },
|
||||||
|
createExtendedShapeCheckboxes()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
targetFolder: Group({
|
||||||
|
label: StaticText({ text: 'Target folder:' }),
|
||||||
|
value: DropDownList({
|
||||||
|
helpTip:
|
||||||
|
'The project folder in which to create the animation ' +
|
||||||
|
'composition. The composition will be named like the audio file.'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
frameRate: Group({
|
||||||
|
label: StaticText({ text: 'Frame rate:' }),
|
||||||
|
value: EditText({
|
||||||
|
characters: 8,
|
||||||
|
helpTip: 'The frame rate for the animation.'
|
||||||
|
}),
|
||||||
|
auto: Checkbox({
|
||||||
|
text: 'From mouth composition',
|
||||||
|
helpTip:
|
||||||
|
'If checked, the animation will use the same frame rate as ' +
|
||||||
|
'the mouth composition.'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
separator: Group({ preferredSize: ['', 3] }),
|
||||||
|
buttons: Group({
|
||||||
|
alignment: 'right',
|
||||||
|
animate: Button({
|
||||||
|
properties: { name: 'ok' },
|
||||||
|
text: 'Animate'
|
||||||
|
}),
|
||||||
|
cancel: Button({
|
||||||
|
properties: { name: 'cancel' },
|
||||||
|
text: 'Cancel'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create window and child controls
|
||||||
|
var window = new Window(resourceString);
|
||||||
|
var controls = {
|
||||||
|
audioFile: window.settings.audioFile.value,
|
||||||
|
dialogText: window.settings.dialogText.value,
|
||||||
|
recognizer: window.settings.recognizer.value,
|
||||||
|
mouthComp: window.settings.mouthComp.value,
|
||||||
|
targetFolder: window.settings.targetFolder.value,
|
||||||
|
frameRate: window.settings.frameRate.value,
|
||||||
|
autoFrameRate: window.settings.frameRate.auto,
|
||||||
|
animateButton: window.buttons.animate,
|
||||||
|
cancelButton: window.buttons.cancel
|
||||||
|
};
|
||||||
|
extendedMouthShapeNames.forEach(function (shapeName) {
|
||||||
|
controls['mouthShape' + shapeName] =
|
||||||
|
window.settings.extendedMouthShapes[shapeName.toLowerCase()];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add audio file options
|
||||||
|
getAudioFileProjectItems().forEach(function (projectItem) {
|
||||||
|
var listItem = controls.audioFile.add('item', getItemPath(projectItem));
|
||||||
|
listItem.projectItem = projectItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add recognizer options
|
||||||
|
const recognizerOptions = [
|
||||||
|
{ text: 'PocketSphinx (use for English recordings)', value: 'pocketSphinx' },
|
||||||
|
{ text: 'Phonetic (use for non-English recordings)', value: 'phonetic' }
|
||||||
|
];
|
||||||
|
recognizerOptions.forEach(function (option) {
|
||||||
|
var listItem = controls.recognizer.add('item', option.text);
|
||||||
|
listItem.value = option.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add mouth composition options
|
||||||
|
var comps = toArrayBase1(app.project.items).filter(function (item) {
|
||||||
|
return item instanceof CompItem;
|
||||||
|
});
|
||||||
|
comps.forEach(function (projectItem) {
|
||||||
|
var listItem = controls.mouthComp.add('item', getItemPath(projectItem));
|
||||||
|
listItem.projectItem = projectItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add target folder options
|
||||||
|
var projectFolders = toArrayBase1(app.project.items).filter(function (item) {
|
||||||
|
return item instanceof FolderItem;
|
||||||
|
});
|
||||||
|
projectFolders.unshift(app.project.rootFolder);
|
||||||
|
projectFolders.forEach(function (projectFolder) {
|
||||||
|
var listItem = controls.targetFolder.add('item', getItemPath(projectFolder));
|
||||||
|
listItem.projectItem = projectFolder;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load persisted settings
|
||||||
|
var settings = readSettingsFile();
|
||||||
|
selectByTextOrFirst(controls.audioFile, settings.audioFile);
|
||||||
|
controls.dialogText.text = settings.dialogText || '';
|
||||||
|
selectByTextOrFirst(controls.recognizer, settings.recognizer);
|
||||||
|
selectByTextOrFirst(controls.mouthComp, settings.mouthComp);
|
||||||
|
extendedMouthShapeNames.forEach(function (shapeName) {
|
||||||
|
controls['mouthShape' + shapeName].value = (settings.extendedMouthShapes || {})[
|
||||||
|
shapeName.toLowerCase()
|
||||||
|
];
|
||||||
|
});
|
||||||
|
selectByTextOrFirst(controls.targetFolder, settings.targetFolder);
|
||||||
|
controls.frameRate.text = settings.frameRate || '';
|
||||||
|
controls.autoFrameRate.value = settings.autoFrameRate;
|
||||||
|
|
||||||
|
// Align controls
|
||||||
|
window.onShow = function () {
|
||||||
|
// Give uniform width to all labels
|
||||||
|
var groups = toArray(window.settings.children);
|
||||||
|
var labelWidths = groups.map(function (group) {
|
||||||
|
return group.children[0].size.width;
|
||||||
|
});
|
||||||
|
var maxLabelWidth = Math.max.apply(Math, labelWidths);
|
||||||
|
groups.forEach(function (group) {
|
||||||
|
group.children[0].size.width = maxLabelWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give uniform width to inputs
|
||||||
|
var valueWidths = groups.map(function (group) {
|
||||||
|
return last(group.children).bounds.right - group.children[1].bounds.left;
|
||||||
|
});
|
||||||
|
var maxValueWidth = Math.max.apply(Math, valueWidths);
|
||||||
|
groups.forEach(function (group) {
|
||||||
|
var multipleControls = group.children.length > 2;
|
||||||
|
if (!multipleControls) {
|
||||||
|
group.children[1].size.width = maxValueWidth;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.layout.layout(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
var updating = false;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (updating) return;
|
||||||
|
|
||||||
|
updating = true;
|
||||||
|
try {
|
||||||
|
// Handle auto frame rate
|
||||||
|
var autoFrameRate = controls.autoFrameRate.value;
|
||||||
|
controls.frameRate.enabled = !autoFrameRate;
|
||||||
|
if (autoFrameRate) {
|
||||||
|
// Take frame rate from mouth comp
|
||||||
|
var mouthComp = (controls.mouthComp.selection || {}).projectItem;
|
||||||
|
controls.frameRate.text = mouthComp ? mouthComp.frameRate : '';
|
||||||
|
} else {
|
||||||
|
// Sanitize frame rate
|
||||||
|
var sanitizedFrameRate = controls.frameRate.text.match(/\d*\.?\d*/)[0];
|
||||||
|
if (sanitizedFrameRate !== controls.frameRate.text) {
|
||||||
|
controls.frameRate.text = sanitizedFrameRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store settings
|
||||||
|
var settings = {
|
||||||
|
audioFile: (controls.audioFile.selection || {}).text,
|
||||||
|
recognizer: (controls.recognizer.selection || {}).text,
|
||||||
|
dialogText: controls.dialogText.text,
|
||||||
|
mouthComp: (controls.mouthComp.selection || {}).text,
|
||||||
|
extendedMouthShapes: {},
|
||||||
|
targetFolder: (controls.targetFolder.selection || {}).text,
|
||||||
|
frameRate: Number(controls.frameRate.text),
|
||||||
|
autoFrameRate: controls.autoFrameRate.value
|
||||||
|
};
|
||||||
|
extendedMouthShapeNames.forEach(function (shapeName) {
|
||||||
|
settings.extendedMouthShapes[shapeName.toLowerCase()] =
|
||||||
|
controls['mouthShape' + shapeName].value;
|
||||||
|
});
|
||||||
|
writeSettingsFile(settings);
|
||||||
|
} finally {
|
||||||
|
updating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate user input. Possible return values:
|
||||||
|
// * Non-empty string: Validation failed. Show error message.
|
||||||
|
// * Empty string: Validation failed. Don't show error message.
|
||||||
|
// * Undefined: Validation succeeded.
|
||||||
|
function validate() {
|
||||||
|
// Check input values
|
||||||
|
if (!controls.audioFile.selection) return 'Please select an audio file.';
|
||||||
|
if (!controls.mouthComp.selection) return 'Please select a mouth composition.';
|
||||||
|
if (!controls.targetFolder.selection) return 'Please select a target folder.';
|
||||||
|
if (Number(controls.frameRate.text) < 12) {
|
||||||
|
return 'Please enter a frame rate of at least 12 fps.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mouth shape visibility
|
||||||
|
var comp = controls.mouthComp.selection.projectItem;
|
||||||
|
for (var i = 0; i < mouthShapeCount; i++) {
|
||||||
|
var shapeName = mouthShapeNames[i];
|
||||||
|
var required = i < basicMouthShapeCount || controls['mouthShape' + shapeName].value;
|
||||||
|
if (required && !isFrameVisible(comp, i)) {
|
||||||
|
return (
|
||||||
|
'The mouth comp does not seem to contain an image for shape ' +
|
||||||
|
shapeName +
|
||||||
|
' at frame ' +
|
||||||
|
i +
|
||||||
|
'.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!comp.preserveNestedFrameRate) {
|
||||||
|
var fix = Window.confirm(
|
||||||
|
'The setting "Preserve frame rate when nested or in render queue" is not active ' +
|
||||||
|
'for the mouth composition. This can result in incorrect animation.\n\n' +
|
||||||
|
'Activate this setting now?',
|
||||||
|
false,
|
||||||
|
'Fix composition setting?'
|
||||||
|
);
|
||||||
|
if (fix) {
|
||||||
|
app.beginUndoGroup(appName + ': Mouth composition setting');
|
||||||
|
comp.preserveNestedFrameRate = true;
|
||||||
|
app.endUndoGroup();
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for correct Rhubarb version
|
||||||
|
var version = exec(rhubarbPath + ' --version') || '';
|
||||||
|
var match = version.match(/Rhubarb Lip Sync version ((\d+)\.(\d+).(\d+)(-[0-9A-Za-z-.]+)?)/);
|
||||||
|
if (!match) {
|
||||||
|
var instructions = osIsWindows
|
||||||
|
? 'Make sure your PATH environment variable contains the ' +
|
||||||
|
appName +
|
||||||
|
' ' +
|
||||||
|
'application directory.'
|
||||||
|
: 'Make sure you have created this file as a symbolic link to the ' +
|
||||||
|
appName +
|
||||||
|
' ' +
|
||||||
|
'executable (rhubarb).';
|
||||||
|
return 'Cannot find executable file "' + rhubarbPath + '". \n' + instructions;
|
||||||
|
}
|
||||||
|
var versionString = match[1];
|
||||||
|
var major = Number(match[2]);
|
||||||
|
var minor = Number(match[3]);
|
||||||
|
var requiredMajor = 1;
|
||||||
|
var minRequiredMinor = 9;
|
||||||
|
if (major != requiredMajor || minor < minRequiredMinor) {
|
||||||
|
return (
|
||||||
|
'This script requires ' +
|
||||||
|
appName +
|
||||||
|
' ' +
|
||||||
|
requiredMajor +
|
||||||
|
'.' +
|
||||||
|
minRequiredMinor +
|
||||||
|
'.0 or a later ' +
|
||||||
|
requiredMajor +
|
||||||
|
'.x version. ' +
|
||||||
|
'Your installed version is ' +
|
||||||
|
versionString +
|
||||||
|
', which is not compatible.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMouthCues(
|
||||||
|
audioFileFootage,
|
||||||
|
recognizer,
|
||||||
|
dialogText,
|
||||||
|
mouthComp,
|
||||||
|
extendedMouthShapeNames,
|
||||||
|
targetProjectFolder,
|
||||||
|
frameRate
|
||||||
|
) {
|
||||||
|
var basePath = Folder.temp.fsName + '/' + createGuid();
|
||||||
|
var dialogFile = new File(basePath + '.txt');
|
||||||
|
var logFile = new File(basePath + '.log');
|
||||||
|
var jsonFile = new File(basePath + '.json');
|
||||||
|
try {
|
||||||
|
// Create text file containing dialog
|
||||||
|
writeTextFile(dialogFile, dialogText);
|
||||||
|
|
||||||
|
// Create command line
|
||||||
|
var commandLine =
|
||||||
|
rhubarbPath +
|
||||||
|
(' --dialogFile ' + cliEscape(dialogFile.fsName)) +
|
||||||
|
(' --recognizer ' + recognizer) +
|
||||||
|
' --exportFormat json' +
|
||||||
|
(' --extendedShapes ' + cliEscape(extendedMouthShapeNames.join(''))) +
|
||||||
|
(' --logFile ' + cliEscape(logFile.fsName)) +
|
||||||
|
' --logLevel fatal' +
|
||||||
|
(' --output ' + cliEscape(jsonFile.fsName)) +
|
||||||
|
(' ' + cliEscape(audioFileFootage.file.fsName));
|
||||||
|
|
||||||
|
// Run Rhubarb
|
||||||
|
execInWindow(commandLine);
|
||||||
|
|
||||||
|
// Check log for fatal errors
|
||||||
|
if (logFile.exists) {
|
||||||
|
var fatalLog = readTextFile(logFile).trim();
|
||||||
|
if (fatalLog) {
|
||||||
|
// Try to extract only the actual error message
|
||||||
|
var match = fatalLog.match(/\[Fatal\] ([\s\S]*)/);
|
||||||
|
var message = match ? match[1] : fatalLog;
|
||||||
|
throw new Error('Error running ' + appName + '.\n' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(readTextFile(jsonFile));
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('No animation result. Animation was probably canceled.');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
dialogFile.remove();
|
||||||
|
logFile.remove();
|
||||||
|
jsonFile.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateMouthCues(
|
||||||
|
mouthCues,
|
||||||
|
audioFileFootage,
|
||||||
|
mouthComp,
|
||||||
|
targetProjectFolder,
|
||||||
|
frameRate
|
||||||
|
) {
|
||||||
|
// Find an unconflicting comp name
|
||||||
|
// ... strip extension, if present
|
||||||
|
var baseName = audioFileFootage.name.match(/^(.*?)(\..*)?$/i)[1];
|
||||||
|
var compName = baseName;
|
||||||
|
// ... add numeric suffix, if needed
|
||||||
|
var existingItems = toArrayBase1(targetProjectFolder.items);
|
||||||
|
var counter = 1;
|
||||||
|
while (
|
||||||
|
existingItems.some(function (item) {
|
||||||
|
return item.name === compName;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
counter++;
|
||||||
|
compName = baseName + ' ' + counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new comp
|
||||||
|
var comp = targetProjectFolder.items.addComp(
|
||||||
|
compName,
|
||||||
|
mouthComp.width,
|
||||||
|
mouthComp.height,
|
||||||
|
mouthComp.pixelAspect,
|
||||||
|
audioFileFootage.duration,
|
||||||
|
frameRate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show new comp
|
||||||
|
comp.openInViewer();
|
||||||
|
|
||||||
|
// Add audio layer
|
||||||
|
comp.layers.add(audioFileFootage);
|
||||||
|
|
||||||
|
// Add mouth layer
|
||||||
|
var mouthLayer = comp.layers.add(mouthComp);
|
||||||
|
mouthLayer.timeRemapEnabled = true;
|
||||||
|
mouthLayer.outPoint = comp.duration;
|
||||||
|
|
||||||
|
// Animate mouth layer
|
||||||
|
var timeRemap = mouthLayer['Time Remap'];
|
||||||
|
// Enabling time remapping automatically adds two keys. Remove the second.
|
||||||
|
timeRemap.removeKey(2);
|
||||||
|
mouthCues.mouthCues.forEach(function (mouthCue) {
|
||||||
|
// Round down keyframe time. In animation, earlier is better than later.
|
||||||
|
// Set keyframe time to *just before* the exact frame to prevent rounding errors
|
||||||
|
var frame = Math.floor(timeToFrame(mouthCue.start, comp));
|
||||||
|
var time = frame !== 0 ? frameToTime(frame - epsilon, comp) : 0;
|
||||||
|
// Set remapped time to *just after* the exact frame to prevent rounding errors
|
||||||
|
var mouthCompFrame = mouthShapeNames.indexOf(mouthCue.value);
|
||||||
|
var remappedTime = frameToTime(mouthCompFrame + epsilon, mouthComp);
|
||||||
|
timeRemap.setValueAtTime(time, remappedTime);
|
||||||
|
});
|
||||||
|
for (var i = 1; i <= timeRemap.numKeys; i++) {
|
||||||
|
timeRemap.setInterpolationTypeAtKey(i, KeyframeInterpolationType.HOLD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate(
|
||||||
|
audioFileFootage,
|
||||||
|
recognizer,
|
||||||
|
dialogText,
|
||||||
|
mouthComp,
|
||||||
|
extendedMouthShapeNames,
|
||||||
|
targetProjectFolder,
|
||||||
|
frameRate
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
var mouthCues = generateMouthCues(
|
||||||
|
audioFileFootage,
|
||||||
|
recognizer,
|
||||||
|
dialogText,
|
||||||
|
mouthComp,
|
||||||
|
extendedMouthShapeNames,
|
||||||
|
targetProjectFolder,
|
||||||
|
frameRate
|
||||||
|
);
|
||||||
|
|
||||||
|
app.beginUndoGroup(appName + ': Animation');
|
||||||
|
animateMouthCues(mouthCues, audioFileFootage, mouthComp, targetProjectFolder, frameRate);
|
||||||
|
app.endUndoGroup();
|
||||||
|
} catch (e) {
|
||||||
|
Window.alert(e.message, appName, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle changes
|
||||||
|
update();
|
||||||
|
controls.audioFile.onChange = update;
|
||||||
|
controls.recognizer.onChange = update;
|
||||||
|
controls.dialogText.onChanging = update;
|
||||||
|
controls.mouthComp.onChange = update;
|
||||||
|
extendedMouthShapeNames.forEach(function (shapeName) {
|
||||||
|
controls['mouthShape' + shapeName].onClick = update;
|
||||||
|
});
|
||||||
|
controls.targetFolder.onChange = update;
|
||||||
|
controls.frameRate.onChanging = update;
|
||||||
|
controls.autoFrameRate.onClick = update;
|
||||||
|
|
||||||
|
// Handle animation
|
||||||
|
controls.animateButton.onClick = function () {
|
||||||
|
var validationError = validate();
|
||||||
|
if (typeof validationError === 'string') {
|
||||||
|
if (validationError) {
|
||||||
|
Window.alert(validationError, appName, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
window.close();
|
||||||
|
animate(
|
||||||
|
controls.audioFile.selection.projectItem,
|
||||||
|
controls.recognizer.selection.value,
|
||||||
|
controls.dialogText.text || '',
|
||||||
|
controls.mouthComp.selection.projectItem,
|
||||||
|
extendedMouthShapeNames.filter(function (shapeName) {
|
||||||
|
return controls['mouthShape' + shapeName].value;
|
||||||
|
}),
|
||||||
|
controls.targetFolder.selection.projectItem,
|
||||||
|
Number(controls.frameRate.text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle cancelation
|
||||||
|
controls.cancelButton.onClick = function () {
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPreconditions() {
|
||||||
|
if (!canWriteFiles()) {
|
||||||
|
Window.alert(
|
||||||
|
'This script requires file system access.\n\n' +
|
||||||
|
'Please enable Preferences > General > Allow Scripts to Write Files and Access Network.',
|
||||||
|
appName,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkPreconditions()) {
|
||||||
|
createDialogWindow().show();
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ image:../../img/spine.png[image]
|
||||||
|
|
||||||
== Installation
|
== Installation
|
||||||
|
|
||||||
https://github.com/DanielSWolf/rhubarb-lip-sync/releases[Download Rhubarb Lip Sync] 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`.
|
https://github.com/DanielSWolf/rhubarb-lip-sync/releases[Download Rhubarb Lip Sync] 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/esoteric-software-spine`.
|
||||||
|
|
||||||
To create lip sync animation, you’ll need Spine 3.7 or better.
|
To create lip sync animation, you’ll need Spine 3.7 or better.
|
||||||
|
|
|
@ -8,13 +8,9 @@ plugins {
|
||||||
|
|
||||||
fun getVersion(): String {
|
fun getVersion(): String {
|
||||||
// Dynamically read version from CMake file
|
// Dynamically read version from CMake file
|
||||||
val file = File(rootDir.parentFile.parentFile, "appInfo.cmake")
|
val file = File(rootDir.parentFile.parentFile, "app-info.toml")
|
||||||
val text = file.readText()
|
val text = file.readText()
|
||||||
val major = Regex("""appVersionMajor\s+(\d+)""").find(text)!!.groupValues[1]
|
return Regex("""appVersion\s*=\s*"(.*?)"(?:)""").find(text)!!.groupValues[1]
|
||||||
val minor = Regex("""appVersionMinor\s+(\d+)""").find(text)!!.groupValues[1]
|
|
||||||
val patch = Regex("""appVersionPatch\s+(\d+)""").find(text)!!.groupValues[1]
|
|
||||||
val suffix = Regex("""appVersionSuffix\s+"(.*?)"""").find(text)!!.groupValues[1]
|
|
||||||
return "$major.$minor.$patch$suffix"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "com.rhubarb_lip_sync"
|
group = "com.rhubarb_lip_sync"
|
|
@ -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 tornadofx.asObservable
|
||||||
|
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>>()
|
||||||
|
private var slots: ObservableList<String> by slotsProperty
|
||||||
|
|
||||||
|
val mouthSlotProperty: SimpleStringProperty = SimpleStringProperty().alsoListen {
|
||||||
|
val mouthSlot = this.mouthSlot
|
||||||
|
val mouthNaming = if (mouthSlot != null)
|
||||||
|
MouthNaming.guess(spineJson.getSlotAttachmentNames(mouthSlot))
|
||||||
|
else null
|
||||||
|
this.mouthNaming = mouthNaming
|
||||||
|
|
||||||
|
mouthShapes = if (mouthSlot != null && mouthNaming != 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."
|
||||||
|
}
|
||||||
|
private var mouthSlot: String? by mouthSlotProperty
|
||||||
|
|
||||||
|
val mouthSlotErrorProperty = SimpleStringProperty()
|
||||||
|
private var mouthSlotError: String? by mouthSlotErrorProperty
|
||||||
|
|
||||||
|
val mouthNamingProperty = SimpleObjectProperty<MouthNaming>()
|
||||||
|
private var mouthNaming: MouthNaming? by mouthNamingProperty
|
||||||
|
|
||||||
|
val mouthShapesProperty = SimpleObjectProperty<List<MouthShape>>().alsoListen {
|
||||||
|
mouthShapesError = getMouthShapesErrorString()
|
||||||
|
}
|
||||||
|
var mouthShapes: List<MouthShape> by mouthShapesProperty
|
||||||
|
private set
|
||||||
|
|
||||||
|
val mouthShapesErrorProperty = SimpleStringProperty()
|
||||||
|
private var mouthShapesError: String? by mouthShapesErrorProperty
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
.asObservable()
|
||||||
|
)
|
||||||
|
val audioFileModels: ObservableList<AudioFileModel> 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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAnimation(animationName: String, audioEventName: String, mouthCues: List<MouthCue>) {
|
||||||
|
spineJson.createOrUpdateAnimation(mouthCues, audioEventName, animationName, mouthSlot!!, mouthNaming!!)
|
||||||
|
spineJson.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
slots = spineJson.slots.asObservable()
|
||||||
|
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,196 @@
|
||||||
|
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.nio.file.Path
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
private val spineJson = parentModel.spineJson
|
||||||
|
|
||||||
|
private val audioFilePath: Path = spineJson.audioDirectoryPath.resolve(audioEvent.relativeAudioFilePath)
|
||||||
|
|
||||||
|
val eventNameProperty = SimpleStringProperty(audioEvent.name)
|
||||||
|
val eventName: String by eventNameProperty
|
||||||
|
|
||||||
|
val displayFilePathProperty = SimpleStringProperty(audioEvent.relativeAudioFilePath)
|
||||||
|
|
||||||
|
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: String by animationNameProperty
|
||||||
|
|
||||||
|
val dialogProperty = SimpleStringProperty(audioEvent.dialog)
|
||||||
|
private val dialog: String? by dialogProperty
|
||||||
|
|
||||||
|
val animationProgressProperty = SimpleObjectProperty<Double?>(null)
|
||||||
|
var animationProgress: Double? 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 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"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recognizer = parentModel.parentModel.recognizer.value
|
||||||
|
val extendedMouthShapes = parentModel.mouthShapes.filter { it.isExtended }.toSet()
|
||||||
|
val reportProgress: (Double?) -> Unit = {
|
||||||
|
progress -> runAndWait { this@AudioFileModel.animationProgress = progress }
|
||||||
|
}
|
||||||
|
val rhubarbTask = RhubarbTask(audioFilePath, recognizer, dialog, extendedMouthShapes, reportProgress)
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
val result = rhubarbTask.call()
|
||||||
|
runAndWait {
|
||||||
|
reportResult(result)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runAndWait {
|
||||||
|
animationProgress = null
|
||||||
|
future = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace(System.err)
|
||||||
|
|
||||||
|
Platform.runLater {
|
||||||
|
Alert(Alert.AlertType.ERROR).apply {
|
||||||
|
headerText = "Error performing lip sync for event '$eventName'."
|
||||||
|
contentText = if (e is EndUserException)
|
||||||
|
e.message
|
||||||
|
else
|
||||||
|
("An internal error occurred.\n"
|
||||||
|
+ "Please report an issue, including the following information.\n"
|
||||||
|
+ getStackTrace(e))
|
||||||
|
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,92 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.net.MalformedURLException
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import java.net.URL
|
||||||
|
import org.apache.commons.lang3.SystemUtils.IS_OS_WINDOWS
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
// The following code is adapted from https://stackoverflow.com/a/12733172/52041
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base location of the given class.
|
||||||
|
*
|
||||||
|
* If the class is directly on the file system (e.g.,
|
||||||
|
* "/path/to/my/package/MyClass.class") then it will return the base directory
|
||||||
|
* (e.g., "file:/path/to").
|
||||||
|
*
|
||||||
|
* If the class is within a JAR file (e.g.,
|
||||||
|
* "/path/to/my-jar.jar!/my/package/MyClass.class") then it will return the
|
||||||
|
* path to the JAR (e.g., "file:/path/to/my-jar.jar").
|
||||||
|
*
|
||||||
|
* @param c The class whose location is desired.
|
||||||
|
*/
|
||||||
|
fun getLocation(c: Class<*>): URL {
|
||||||
|
// Try the easy way first
|
||||||
|
try {
|
||||||
|
val codeSourceLocation = c.protectionDomain.codeSource.location
|
||||||
|
if (codeSourceLocation != null) return codeSourceLocation
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Cannot access protection domain
|
||||||
|
} catch (e: NullPointerException) {
|
||||||
|
// Protection domain or code source is null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The easy way failed, so we try the hard way. We ask for the class
|
||||||
|
// itself as a resource, then strip the class's path from the URL string,
|
||||||
|
// leaving the base path.
|
||||||
|
|
||||||
|
// Get the class's raw resource path
|
||||||
|
val classResource = c.getResource(c.simpleName + ".class")
|
||||||
|
?: throw Exception("Cannot find class resource.")
|
||||||
|
|
||||||
|
val url = classResource.toString()
|
||||||
|
val suffix = c.canonicalName.replace('.', '/') + ".class"
|
||||||
|
if (!url.endsWith(suffix)) throw Exception("Malformed URL.")
|
||||||
|
|
||||||
|
// strip the class's path from the URL string
|
||||||
|
val base = url.substring(0, url.length - suffix.length)
|
||||||
|
|
||||||
|
var path = base
|
||||||
|
|
||||||
|
// remove the "jar:" prefix and "!/" suffix, if present
|
||||||
|
if (path.startsWith("jar:")) path = path.substring(4, path.length - 2)
|
||||||
|
|
||||||
|
return URL(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given URL to its corresponding [Path].
|
||||||
|
*
|
||||||
|
* @param url The URL to convert.
|
||||||
|
* @return A file path suitable for use with e.g. [FileInputStream]
|
||||||
|
*/
|
||||||
|
fun urlToPath(url: URL): Path {
|
||||||
|
var pathString = url.toString()
|
||||||
|
|
||||||
|
if (pathString.startsWith("jar:")) {
|
||||||
|
// Remove "jar:" prefix and "!/" suffix
|
||||||
|
val index = pathString.indexOf("!/")
|
||||||
|
pathString = pathString.substring(4, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (IS_OS_WINDOWS && pathString.matches("file:[A-Za-z]:.*".toRegex())) {
|
||||||
|
pathString = "file:/" + pathString.substring(5)
|
||||||
|
}
|
||||||
|
return Paths.get(URL(pathString).toURI())
|
||||||
|
} catch (e: MalformedURLException) {
|
||||||
|
// URL is not completely well-formed.
|
||||||
|
} catch (e: URISyntaxException) {
|
||||||
|
// URL is not completely well-formed.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathString.startsWith("file:")) {
|
||||||
|
// Pass through the URL as-is, minus "file:" prefix
|
||||||
|
pathString = pathString.substring(5)
|
||||||
|
return Paths.get(pathString)
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Invalid URL: $url")
|
||||||
|
}
|
|
@ -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,63 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import javafx.beans.property.SimpleObjectProperty
|
||||||
|
import javafx.beans.property.SimpleStringProperty
|
||||||
|
import javafx.collections.FXCollections
|
||||||
|
import javafx.collections.ObservableList
|
||||||
|
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 EndUserException("No input file specified.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val path = try {
|
||||||
|
val trimmed = value.removeSurrounding("\"")
|
||||||
|
Paths.get(trimmed)
|
||||||
|
} catch (e: InvalidPathException) {
|
||||||
|
throw EndUserException("Not a valid file path.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
throw EndUserException("File does not exist.")
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFileModel = AnimationFileModel(this, path, executor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filePathErrorProperty = SimpleStringProperty()
|
||||||
|
private var filePathError: String? by filePathErrorProperty
|
||||||
|
|
||||||
|
val animationFileModelProperty = SimpleObjectProperty<AnimationFileModel?>()
|
||||||
|
var animationFileModel by animationFileModelProperty
|
||||||
|
private set
|
||||||
|
|
||||||
|
val recognizersProperty = SimpleObjectProperty<ObservableList<Recognizer>>(FXCollections.observableArrayList(
|
||||||
|
Recognizer("pocketSphinx", "PocketSphinx (use for English recordings)"),
|
||||||
|
Recognizer("phonetic", "Phonetic (use for non-English recordings)")
|
||||||
|
))
|
||||||
|
private var recognizers: ObservableList<Recognizer> by recognizersProperty
|
||||||
|
|
||||||
|
val recognizerProperty = SimpleObjectProperty<Recognizer>(recognizers[0])
|
||||||
|
var recognizer: Recognizer by recognizerProperty
|
||||||
|
|
||||||
|
val animationPrefixProperty = SimpleStringProperty("say_")
|
||||||
|
var animationPrefix: String by animationPrefixProperty
|
||||||
|
|
||||||
|
val animationSuffixProperty = SimpleStringProperty("")
|
||||||
|
var animationSuffix: String by animationSuffixProperty
|
||||||
|
|
||||||
|
private fun getDefaultPathString() = FX.application.parameters.raw.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Recognizer(val value: String, val description: String)
|
|
@ -0,0 +1,257 @@
|
||||||
|
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 javafx.util.StringConverter
|
||||||
|
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("Dialog recognizer") {
|
||||||
|
combobox<Recognizer> {
|
||||||
|
itemsProperty().bind(mainModel.recognizersProperty)
|
||||||
|
this.converter = object : StringConverter<Recognizer>() {
|
||||||
|
override fun toString(recognizer: Recognizer?): String {
|
||||||
|
return recognizer?.description ?: ""
|
||||||
|
}
|
||||||
|
override fun fromString(string: String?): Recognizer {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valueProperty().bindBidirectional(mainModel.recognizerProperty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
.weightedWidth(1.0)
|
||||||
|
column("Animation name", AudioFileModel::animationNameProperty)
|
||||||
|
.weightedWidth(1.0)
|
||||||
|
column("Audio file", AudioFileModel::displayFilePathProperty)
|
||||||
|
.weightedWidth(1.0)
|
||||||
|
column("Dialog", AudioFileModel::dialogProperty).apply {
|
||||||
|
weightedWidth(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 {
|
||||||
|
weightedWidth(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 {
|
||||||
|
weightedWidth(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,55 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class MouthNaming(private val prefix: String, private val suffix: String, private 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 {
|
||||||
|
const val basicShapeCount = 6
|
||||||
|
|
||||||
|
val basicShapes = MouthShape.values().take(basicShapeCount)
|
||||||
|
|
||||||
|
val extendedShapes = MouthShape.values().drop(basicShapeCount)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,166 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import com.beust.klaxon.JsonObject
|
||||||
|
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.util.concurrent.Callable
|
||||||
|
|
||||||
|
class RhubarbTask(
|
||||||
|
val audioFilePath: Path,
|
||||||
|
val recognizer: String,
|
||||||
|
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 EndUserException("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 EndUserException(message.string("reason") ?: "Rhubarb failed without reason.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
throw e
|
||||||
|
} catch (e: EOFException) {
|
||||||
|
throw EndUserException("Rhubarb terminated unexpectedly.")
|
||||||
|
} finally {
|
||||||
|
process.waitFor()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
throw EndUserException("Audio file processing terminated in an unexpected way.")
|
||||||
|
}
|
||||||
|
|
||||||
|
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.default()
|
||||||
|
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",
|
||||||
|
"--recognizer", recognizer,
|
||||||
|
"--exportFormat", "json",
|
||||||
|
"--extendedShapes", extendedMouthShapesString
|
||||||
|
).apply {
|
||||||
|
if (dialogFilePath != null) {
|
||||||
|
addAll(listOf(
|
||||||
|
"--dialogFile", dialogFilePath.toString()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
add(audioFilePath.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val guiBinDirectory: Path by lazy {
|
||||||
|
val path = urlToPath(getLocation(RhubarbTask::class.java))
|
||||||
|
return@lazy if (Files.isDirectory(path)) path.parent else 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 EndUserException("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,163 @@
|
||||||
|
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(private val filePath: Path) {
|
||||||
|
private val fileDirectoryPath: Path = filePath.parent
|
||||||
|
private val json: JsonObject
|
||||||
|
private val skeleton: JsonObject
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
throw EndUserException("File '$filePath' does not exist.")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
json = Parser.default().parse(filePath.toString()) as JsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw EndUserException("Wrong file format. This is not a valid JSON file.")
|
||||||
|
}
|
||||||
|
skeleton = json.obj("skeleton") ?: throw EndUserException("JSON file is corrupted.")
|
||||||
|
|
||||||
|
validateProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateProperties() {
|
||||||
|
imagesDirectoryPath
|
||||||
|
audioDirectoryPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private val imagesDirectoryPath: Path get() {
|
||||||
|
val relativeImagesDirectory = skeleton.string("images")
|
||||||
|
?: throw EndUserException("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 EndUserException("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 EndUserException("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 EndUserException("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 EndUserException("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> {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val skins: Collection<JsonObject> = when (val skinsObject = json["skins"]) {
|
||||||
|
is JsonObject -> skinsObject.values as Collection<JsonObject>
|
||||||
|
is JsonArray<*> -> skinsObject as Collection<JsonObject>
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attachment names for all skins
|
||||||
|
return skins
|
||||||
|
.flatMap { skin ->
|
||||||
|
skin.obj(slotName)?.keys?.toList()
|
||||||
|
?: skin.obj("attachments")?.obj(slotName)?.keys?.toList()
|
||||||
|
?: emptyList<String>()
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
val animationNames = observableSet<String>(
|
||||||
|
json.obj("animations")?.map{ it.key }?.toMutableSet() ?: mutableSetOf()
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return json.toJsonString(prettyPrint = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
Files.write(filePath, listOf(toString()), StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import javafx.application.Platform
|
||||||
|
import javafx.beans.property.Property
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStackTrace(e: Exception): String {
|
||||||
|
val stringWriter = StringWriter()
|
||||||
|
e.printStackTrace(PrintWriter(stringWriter))
|
||||||
|
return stringWriter.toString()
|
||||||
|
}
|
Before Width: | Height: | Size: 386 B After Width: | Height: | Size: 386 B |
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 942 B After Width: | Height: | Size: 942 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1,69 @@
|
||||||
|
package com.rhubarb_lip_sync.rhubarb_for_spine
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Nested
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.catchThrowable
|
||||||
|
|
||||||
|
class SpineJsonTest {
|
||||||
|
@Nested
|
||||||
|
inner class `file format 3_7` {
|
||||||
|
@Test
|
||||||
|
fun `correctly reads valid file`() {
|
||||||
|
val path = Paths.get("src/test/data/jsonFiles/matt-3.7.json").toAbsolutePath()
|
||||||
|
val spine = SpineJson(path)
|
||||||
|
|
||||||
|
assertThat(spine.audioDirectoryPath)
|
||||||
|
.isEqualTo(Paths.get("src/test/data/jsonFiles/audio").toAbsolutePath())
|
||||||
|
assertThat(spine.frameRate).isEqualTo(30.0)
|
||||||
|
assertThat(spine.slots).containsExactly("legs", "torso", "head", "mouth")
|
||||||
|
assertThat(spine.guessMouthSlot()).isEqualTo("mouth")
|
||||||
|
assertThat(spine.audioEvents).containsExactly(
|
||||||
|
SpineJson.AudioEvent("1-have-you-heard", "1-have-you-heard.wav", null),
|
||||||
|
SpineJson.AudioEvent("2-it's-a-tool", "2-it's-a-tool.wav", null),
|
||||||
|
SpineJson.AudioEvent("3-and-now-you-can", "3-and-now-you-can.wav", null)
|
||||||
|
)
|
||||||
|
assertThat(spine.getSlotAttachmentNames("mouth")).isEqualTo(('a'..'h').map{ "mouth_$it" })
|
||||||
|
assertThat(spine.animationNames).containsExactly("shake_head", "walk")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `throws on file without nonessential data`() {
|
||||||
|
val path = Paths.get("src/test/data/jsonFiles/matt-3.7-essential.json").toAbsolutePath()
|
||||||
|
val throwable = catchThrowable { SpineJson(path) }
|
||||||
|
assertThat(throwable)
|
||||||
|
.hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
inner class `file format 3_8` {
|
||||||
|
@Test
|
||||||
|
fun `correctly reads valid file`() {
|
||||||
|
val path = Paths.get("src/test/data/jsonFiles/matt-3.8.json").toAbsolutePath()
|
||||||
|
val spine = SpineJson(path)
|
||||||
|
|
||||||
|
assertThat(spine.audioDirectoryPath)
|
||||||
|
.isEqualTo(Paths.get("src/test/data/jsonFiles/audio").toAbsolutePath())
|
||||||
|
assertThat(spine.frameRate).isEqualTo(30.0)
|
||||||
|
assertThat(spine.slots).containsExactly("legs", "torso", "head", "mouth")
|
||||||
|
assertThat(spine.guessMouthSlot()).isEqualTo("mouth")
|
||||||
|
assertThat(spine.audioEvents).containsExactly(
|
||||||
|
SpineJson.AudioEvent("1-have-you-heard", "1-have-you-heard.wav", null),
|
||||||
|
SpineJson.AudioEvent("2-it's-a-tool", "2-it's-a-tool.wav", null),
|
||||||
|
SpineJson.AudioEvent("3-and-now-you-can", "3-and-now-you-can.wav", null)
|
||||||
|
)
|
||||||
|
assertThat(spine.getSlotAttachmentNames("mouth")).isEqualTo(('a'..'h').map{ "mouth_$it" })
|
||||||
|
assertThat(spine.animationNames).containsExactly("shake_head", "walk")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `throws on file without nonessential data`() {
|
||||||
|
val path = Paths.get("src/test/data/jsonFiles/matt-3.8-essential.json").toAbsolutePath()
|
||||||
|
val throwable = catchThrowable { SpineJson(path) }
|
||||||
|
assertThat(throwable)
|
||||||
|
.hasMessage("JSON file is incomplete: Images path is missing. Make sure to check 'Nonessential data' when exporting.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,345 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.ComponentModel.Design;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Design;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Web.UI.Design;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using System.Windows.Forms.Design;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
using ScriptPortal.Vegas; // For older versions, this should say Sony.Vegas
|
||||||
|
using Region = ScriptPortal.Vegas.Region; // For older versions, this should say Sony.Vegas.Region
|
||||||
|
|
||||||
|
public class EntryPoint {
|
||||||
|
public void FromVegas(Vegas vegas) {
|
||||||
|
Config config = Config.Load();
|
||||||
|
ImportDialog importDialog = new ImportDialog(config, delegate { Import(config, vegas); });
|
||||||
|
importDialog.ShowDialog();
|
||||||
|
config.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Import(Config config, Vegas vegas) {
|
||||||
|
Project project = vegas.Project;
|
||||||
|
|
||||||
|
// Clear markers and regions
|
||||||
|
if (config.ClearMarkers) {
|
||||||
|
project.Markers.Clear();
|
||||||
|
}
|
||||||
|
if (config.ClearRegions) {
|
||||||
|
project.Regions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load log file
|
||||||
|
if (!File.Exists(config.LogFile)) {
|
||||||
|
throw new Exception("Log file does not exist.");
|
||||||
|
}
|
||||||
|
Dictionary<EventType, List<TimedEvent>> timedEvents = ParseLogFile(config);
|
||||||
|
|
||||||
|
// Add markers/regions
|
||||||
|
foreach (EventType eventType in timedEvents.Keys) {
|
||||||
|
foreach (Visualization visualization in config.Visualizations) {
|
||||||
|
if (visualization.EventType != eventType) continue;
|
||||||
|
|
||||||
|
List<TimedEvent> filteredEvents = FilterEvents(timedEvents[eventType], visualization.Regex);
|
||||||
|
foreach (TimedEvent timedEvent in filteredEvents) {
|
||||||
|
Timecode start = Timecode.FromSeconds(timedEvent.Start);
|
||||||
|
Timecode end = Timecode.FromSeconds(timedEvent.End);
|
||||||
|
Timecode length = end - start;
|
||||||
|
if (config.LoopRegionOnly) {
|
||||||
|
Timecode loopRegionStart = vegas.Transport.LoopRegionStart;
|
||||||
|
Timecode loopRegionEnd = loopRegionStart + vegas.Transport.LoopRegionLength;
|
||||||
|
if (start < loopRegionStart || start > loopRegionEnd || end < loopRegionStart || end > loopRegionEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (visualization.VisualizationType) {
|
||||||
|
case VisualizationType.Marker:
|
||||||
|
project.Markers.Add(new Marker(start, timedEvent.Value));
|
||||||
|
break;
|
||||||
|
case VisualizationType.Region:
|
||||||
|
project.Regions.Add(new Region(start, length, timedEvent.Value));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TimedEvent> FilterEvents(List<TimedEvent> timedEvents, Regex filterRegex) {
|
||||||
|
if (filterRegex == null) return timedEvents;
|
||||||
|
|
||||||
|
StringBuilder stringBuilder = new StringBuilder();
|
||||||
|
Dictionary<int, TimedEvent> timedEventsByCharPosition = new Dictionary<int, TimedEvent>();
|
||||||
|
foreach (TimedEvent timedEvent in timedEvents) {
|
||||||
|
string inAngleBrackets = "<" + timedEvent.Value + ">";
|
||||||
|
for (int charPosition = stringBuilder.Length;
|
||||||
|
charPosition < stringBuilder.Length + inAngleBrackets.Length;
|
||||||
|
charPosition++) {
|
||||||
|
timedEventsByCharPosition[charPosition] = timedEvent;
|
||||||
|
}
|
||||||
|
stringBuilder.Append(inAngleBrackets);
|
||||||
|
}
|
||||||
|
|
||||||
|
MatchCollection matches = filterRegex.Matches(stringBuilder.ToString());
|
||||||
|
List<TimedEvent> result = new List<TimedEvent>();
|
||||||
|
foreach (Match match in matches) {
|
||||||
|
if (match.Length == 0) continue;
|
||||||
|
|
||||||
|
for (int charPosition = match.Index; charPosition < match.Index + match.Length; charPosition++) {
|
||||||
|
TimedEvent matchedEvent = timedEventsByCharPosition[charPosition];
|
||||||
|
if (!result.Contains(matchedEvent)) {
|
||||||
|
result.Add(matchedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<EventType, List<TimedEvent>> ParseLogFile(Config config) {
|
||||||
|
string[] lines = File.ReadAllLines(config.LogFile);
|
||||||
|
Regex structuredLogLine = new Regex(@"##(\w+)\[(\d*\.\d*)-(\d*\.\d*)\]: (.*)");
|
||||||
|
Dictionary<EventType, List<TimedEvent>> timedEvents = new Dictionary<EventType, List<TimedEvent>>();
|
||||||
|
foreach (string line in lines) {
|
||||||
|
Match match = structuredLogLine.Match(line);
|
||||||
|
if (!match.Success) continue;
|
||||||
|
|
||||||
|
EventType eventType = (EventType) Enum.Parse(typeof(EventType), match.Groups[1].Value, true);
|
||||||
|
double start = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||||
|
double end = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
||||||
|
string value = match.Groups[4].Value;
|
||||||
|
|
||||||
|
if (!timedEvents.ContainsKey(eventType)) {
|
||||||
|
timedEvents[eventType] = new List<TimedEvent>();
|
||||||
|
}
|
||||||
|
timedEvents[eventType].Add(new TimedEvent(eventType, start, end, value));
|
||||||
|
}
|
||||||
|
return timedEvents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TimedEvent {
|
||||||
|
private readonly EventType eventType;
|
||||||
|
private readonly double start;
|
||||||
|
private readonly double end;
|
||||||
|
private readonly string value;
|
||||||
|
|
||||||
|
public TimedEvent(EventType eventType, double start, double end, string value) {
|
||||||
|
this.eventType = eventType;
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EventType EventType {
|
||||||
|
get { return eventType; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Start {
|
||||||
|
get { return start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public double End {
|
||||||
|
get { return end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value {
|
||||||
|
get { return value; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Config {
|
||||||
|
private string logFile;
|
||||||
|
private bool clearMarkers;
|
||||||
|
private bool clearRegions;
|
||||||
|
private bool loopRegionOnly;
|
||||||
|
private List<Visualization> visualizations = new List<Visualization>();
|
||||||
|
|
||||||
|
[DisplayName("Log File")]
|
||||||
|
[Description("A log file generated by Rhubarb Lip Sync.")]
|
||||||
|
[Editor(typeof(FileNameEditor), typeof(UITypeEditor))]
|
||||||
|
public string LogFile {
|
||||||
|
get { return logFile; }
|
||||||
|
set { logFile = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Clear Markers")]
|
||||||
|
[Description("Clear all markers in the current project.")]
|
||||||
|
public bool ClearMarkers {
|
||||||
|
get { return clearMarkers; }
|
||||||
|
set { clearMarkers = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Clear Regions")]
|
||||||
|
[Description("Clear all regions in the current project.")]
|
||||||
|
public bool ClearRegions {
|
||||||
|
get { return clearRegions; }
|
||||||
|
set { clearRegions = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Loop region only")]
|
||||||
|
[Description("Adds regions or markers to the loop region only.")]
|
||||||
|
public bool LoopRegionOnly {
|
||||||
|
get { return loopRegionOnly; }
|
||||||
|
set { loopRegionOnly = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Visualization rules")]
|
||||||
|
[Description("Specify how to visualize various log events.")]
|
||||||
|
[Editor(typeof(CollectionEditor), typeof(UITypeEditor))]
|
||||||
|
[XmlIgnore]
|
||||||
|
public List<Visualization> Visualizations {
|
||||||
|
get { return visualizations; }
|
||||||
|
set { visualizations = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Browsable(false)]
|
||||||
|
public Visualization[] VisualizationArray {
|
||||||
|
get { return visualizations.ToArray(); }
|
||||||
|
set { visualizations = new List<Visualization>(value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConfigFileName {
|
||||||
|
get {
|
||||||
|
string folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
return Path.Combine(folder, "DebugRhubarbSettings.xml");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config Load() {
|
||||||
|
try {
|
||||||
|
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
||||||
|
using (FileStream file = File.OpenRead(ConfigFileName)) {
|
||||||
|
return (Config) serializer.Deserialize(file);
|
||||||
|
}
|
||||||
|
} catch (Exception) {
|
||||||
|
return new Config();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save() {
|
||||||
|
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
||||||
|
using (StreamWriter file = File.CreateText(ConfigFileName)) {
|
||||||
|
XmlWriterSettings settings = new XmlWriterSettings();
|
||||||
|
settings.Indent = true;
|
||||||
|
settings.IndentChars = "\t";
|
||||||
|
using (XmlWriter writer = XmlWriter.Create(file, settings)) {
|
||||||
|
serializer.Serialize(writer, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Visualization {
|
||||||
|
private EventType eventType;
|
||||||
|
private string regexString;
|
||||||
|
private VisualizationType visualizationType = VisualizationType.Marker;
|
||||||
|
|
||||||
|
[DisplayName("Event Type")]
|
||||||
|
[Description("The type of event to visualize.")]
|
||||||
|
public EventType EventType {
|
||||||
|
get { return eventType; }
|
||||||
|
set { eventType = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Regular Expression")]
|
||||||
|
[Description("A regular expression used to filter events. Leave empty to disable filtering.\nInput is a string of events in angle brackets. Example: '<AO>(?=<T>)' finds every AO phone followed by a T phone.")]
|
||||||
|
public string RegexString {
|
||||||
|
get { return regexString; }
|
||||||
|
set { regexString = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Browsable(false)]
|
||||||
|
public Regex Regex {
|
||||||
|
get { return string.IsNullOrEmpty(RegexString) ? null : new Regex(RegexString); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Visualization Type")]
|
||||||
|
[Description("Specify how to visualize events.")]
|
||||||
|
public VisualizationType VisualizationType {
|
||||||
|
get { return visualizationType; }
|
||||||
|
set { visualizationType = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() {
|
||||||
|
return string.Format("{0} -> {1}", EventType, VisualizationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EventType {
|
||||||
|
Utterance,
|
||||||
|
Word,
|
||||||
|
RawPhone,
|
||||||
|
Phone,
|
||||||
|
Shape,
|
||||||
|
Segment
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VisualizationType {
|
||||||
|
None,
|
||||||
|
Marker,
|
||||||
|
Region
|
||||||
|
}
|
||||||
|
|
||||||
|
public delegate void ImportAction();
|
||||||
|
|
||||||
|
public class ImportDialog : Form {
|
||||||
|
private readonly Config config;
|
||||||
|
private readonly ImportAction import;
|
||||||
|
|
||||||
|
public ImportDialog(Config config, ImportAction import) {
|
||||||
|
this.config = config;
|
||||||
|
this.import = import;
|
||||||
|
SuspendLayout();
|
||||||
|
InitializeComponent();
|
||||||
|
ResumeLayout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent() {
|
||||||
|
// Configure dialog
|
||||||
|
Text = "Debug Rhubarb";
|
||||||
|
Size = new Size(600, 400);
|
||||||
|
Font = new Font(Font.FontFamily, 10);
|
||||||
|
|
||||||
|
// Add property grid
|
||||||
|
PropertyGrid propertyGrid1 = new PropertyGrid();
|
||||||
|
propertyGrid1.SelectedObject = config;
|
||||||
|
Controls.Add(propertyGrid1);
|
||||||
|
propertyGrid1.Dock = DockStyle.Fill;
|
||||||
|
|
||||||
|
// Add button panel
|
||||||
|
FlowLayoutPanel buttonPanel = new FlowLayoutPanel();
|
||||||
|
buttonPanel.FlowDirection = FlowDirection.RightToLeft;
|
||||||
|
buttonPanel.AutoSize = true;
|
||||||
|
buttonPanel.Dock = DockStyle.Bottom;
|
||||||
|
Controls.Add(buttonPanel);
|
||||||
|
|
||||||
|
// Add Cancel button
|
||||||
|
Button cancelButton1 = new Button();
|
||||||
|
cancelButton1.Text = "Cancel";
|
||||||
|
cancelButton1.DialogResult = DialogResult.Cancel;
|
||||||
|
buttonPanel.Controls.Add(cancelButton1);
|
||||||
|
CancelButton = cancelButton1;
|
||||||
|
|
||||||
|
// Add OK button
|
||||||
|
Button okButton1 = new Button();
|
||||||
|
okButton1.Text = "OK";
|
||||||
|
okButton1.Click += OkButtonClickedHandler;
|
||||||
|
buttonPanel.Controls.Add(okButton1);
|
||||||
|
AcceptButton = okButton1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OkButtonClickedHandler(object sender, EventArgs e) {
|
||||||
|
try {
|
||||||
|
import();
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
MessageBox.Show(exception.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,233 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Design;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Web.UI.Design;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using System.Windows.Forms.Design;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
using ScriptPortal.Vegas; // For older versions, this should say Sony.Vegas
|
||||||
|
|
||||||
|
public class EntryPoint {
|
||||||
|
public void FromVegas(Vegas vegas) {
|
||||||
|
Config config = Config.Load();
|
||||||
|
ImportDialog importDialog = new ImportDialog(config, delegate { Import(config, vegas); });
|
||||||
|
importDialog.ShowDialog();
|
||||||
|
config.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Import(Config config, Vegas vegas) {
|
||||||
|
// Load XML file
|
||||||
|
if (!File.Exists(config.XmlFile)) {
|
||||||
|
throw new Exception("XML file does not exist.");
|
||||||
|
}
|
||||||
|
XmlDocument xmlDocument = new XmlDocument();
|
||||||
|
xmlDocument.Load(config.XmlFile);
|
||||||
|
|
||||||
|
// Determine image file names
|
||||||
|
XmlNodeList mouthCueElements = xmlDocument.SelectNodes("//mouthCue");
|
||||||
|
List<string> shapeNames = new List<string>();
|
||||||
|
foreach (XmlElement mouthCueElement in mouthCueElements) {
|
||||||
|
if (!shapeNames.Contains(mouthCueElement.InnerText)) {
|
||||||
|
shapeNames.Add(mouthCueElement.InnerText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Dictionary<string, string> imageFileNames = GetImageFileNames(config.OneImageFile, shapeNames.ToArray());
|
||||||
|
|
||||||
|
// Create new project
|
||||||
|
bool promptSave = !config.DiscardChanges;
|
||||||
|
bool showDialog = false;
|
||||||
|
Project project = new Project(promptSave, showDialog);
|
||||||
|
|
||||||
|
// Set frame size
|
||||||
|
Bitmap testImage = new Bitmap(config.OneImageFile);
|
||||||
|
project.Video.Width = testImage.Width;
|
||||||
|
project.Video.Height = testImage.Height;
|
||||||
|
|
||||||
|
// Set frame rate
|
||||||
|
if (config.FrameRate < 0.1 || config.FrameRate > 100) {
|
||||||
|
throw new Exception("Invalid frame rate.");
|
||||||
|
}
|
||||||
|
project.Video.FrameRate = config.FrameRate;
|
||||||
|
|
||||||
|
// Set other video settings
|
||||||
|
project.Video.FieldOrder = VideoFieldOrder.ProgressiveScan;
|
||||||
|
project.Video.PixelAspectRatio = 1;
|
||||||
|
|
||||||
|
// Add video track with images
|
||||||
|
VideoTrack videoTrack = vegas.Project.AddVideoTrack();
|
||||||
|
foreach (XmlElement mouthCueElement in mouthCueElements) {
|
||||||
|
Timecode start = GetTimecode(mouthCueElement.Attributes["start"]);
|
||||||
|
Timecode length = GetTimecode(mouthCueElement.Attributes["end"]) - start;
|
||||||
|
VideoEvent videoEvent = videoTrack.AddVideoEvent(start, length);
|
||||||
|
Media imageMedia = new Media(imageFileNames[mouthCueElement.InnerText]);
|
||||||
|
videoEvent.AddTake(imageMedia.GetVideoStreamByIndex(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audio track with original sound file
|
||||||
|
AudioTrack audioTrack = vegas.Project.AddAudioTrack();
|
||||||
|
Media audioMedia = new Media(xmlDocument.SelectSingleNode("//soundFile").InnerText);
|
||||||
|
AudioEvent audioEvent = audioTrack.AddAudioEvent(new Timecode(0), audioMedia.Length);
|
||||||
|
audioEvent.AddTake(audioMedia.GetAudioStreamByIndex(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Timecode GetTimecode(XmlAttribute valueAttribute) {
|
||||||
|
double seconds = Double.Parse(valueAttribute.Value, CultureInfo.InvariantCulture);
|
||||||
|
return Timecode.FromSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> GetImageFileNames(string oneImageFile, string[] shapeNames) {
|
||||||
|
if (oneImageFile == null) {
|
||||||
|
throw new Exception("Image file name not set.");
|
||||||
|
}
|
||||||
|
Regex nameRegex = new Regex(@"(?<=-)([^-]*)(?=\.[^.]+$)");
|
||||||
|
if (!nameRegex.IsMatch(oneImageFile)) {
|
||||||
|
throw new Exception("Image file name doesn't have expected format.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, string> result = new Dictionary<string, string>();
|
||||||
|
foreach (string shapeName in shapeNames) {
|
||||||
|
string imageFileName = nameRegex.Replace(oneImageFile, shapeName);
|
||||||
|
if (!File.Exists(imageFileName)) {
|
||||||
|
throw new Exception(string.Format("Image file '{0}' not found.", imageFileName));
|
||||||
|
}
|
||||||
|
result[shapeName] = imageFileName;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Config {
|
||||||
|
|
||||||
|
private string xmlFile;
|
||||||
|
private string oneImageFile;
|
||||||
|
private double frameRate = 100;
|
||||||
|
private bool discardChanges = false;
|
||||||
|
|
||||||
|
[DisplayName("XML File")]
|
||||||
|
[Description("An XML file generated by Rhubarb Lip Sync.")]
|
||||||
|
[Editor(typeof(XmlFileEditor), typeof(UITypeEditor))]
|
||||||
|
public string XmlFile {
|
||||||
|
get { return xmlFile; }
|
||||||
|
set { xmlFile = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("One image file")]
|
||||||
|
[Description("Any image file out of the set of image files representing the mouth chart.")]
|
||||||
|
[Editor(typeof(FileNameEditor), typeof(UITypeEditor))]
|
||||||
|
public string OneImageFile {
|
||||||
|
get { return oneImageFile; }
|
||||||
|
set { oneImageFile = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Frame rate")]
|
||||||
|
[Description("The frame rate for the new project.")]
|
||||||
|
public double FrameRate {
|
||||||
|
get { return frameRate; }
|
||||||
|
set { frameRate = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DisplayName("Discard Changes")]
|
||||||
|
[Description("Discard all changes to the current project without prompting to save.")]
|
||||||
|
public bool DiscardChanges {
|
||||||
|
get { return discardChanges; }
|
||||||
|
set { discardChanges = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConfigFileName {
|
||||||
|
get {
|
||||||
|
string folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
return Path.Combine(folder, "ImportRhubarbSettings.xml");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Config Load() {
|
||||||
|
try {
|
||||||
|
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
||||||
|
using (FileStream file = File.OpenRead(ConfigFileName)) {
|
||||||
|
return (Config) serializer.Deserialize(file);
|
||||||
|
}
|
||||||
|
} catch (Exception) {
|
||||||
|
return new Config();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save() {
|
||||||
|
XmlSerializer serializer = new XmlSerializer(typeof(Config));
|
||||||
|
using (StreamWriter file = File.CreateText(ConfigFileName)) {
|
||||||
|
XmlWriterSettings settings = new XmlWriterSettings();
|
||||||
|
settings.Indent = true;
|
||||||
|
settings.IndentChars = "\t";
|
||||||
|
using (XmlWriter writer = XmlWriter.Create(file, settings)) {
|
||||||
|
serializer.Serialize(writer, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public delegate void ImportAction();
|
||||||
|
|
||||||
|
public class ImportDialog : Form {
|
||||||
|
|
||||||
|
private readonly Config config;
|
||||||
|
private readonly ImportAction import;
|
||||||
|
|
||||||
|
public ImportDialog(Config config, ImportAction import) {
|
||||||
|
this.config = config;
|
||||||
|
this.import = import;
|
||||||
|
SuspendLayout();
|
||||||
|
InitializeComponent();
|
||||||
|
ResumeLayout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent() {
|
||||||
|
// Configure dialog
|
||||||
|
Text = "Import Rhubarb";
|
||||||
|
Size = new Size(600, 400);
|
||||||
|
Font = new Font(Font.FontFamily, 10);
|
||||||
|
|
||||||
|
// Add property grid
|
||||||
|
PropertyGrid propertyGrid1 = new PropertyGrid();
|
||||||
|
propertyGrid1.SelectedObject = config;
|
||||||
|
Controls.Add(propertyGrid1);
|
||||||
|
propertyGrid1.Dock = DockStyle.Fill;
|
||||||
|
|
||||||
|
// Add button panel
|
||||||
|
FlowLayoutPanel buttonPanel = new FlowLayoutPanel();
|
||||||
|
buttonPanel.FlowDirection = FlowDirection.RightToLeft;
|
||||||
|
buttonPanel.AutoSize = true;
|
||||||
|
buttonPanel.Dock = DockStyle.Bottom;
|
||||||
|
Controls.Add(buttonPanel);
|
||||||
|
|
||||||
|
// Add Cancel button
|
||||||
|
Button cancelButton1 = new Button();
|
||||||
|
cancelButton1.Text = "Cancel";
|
||||||
|
cancelButton1.DialogResult = DialogResult.Cancel;
|
||||||
|
buttonPanel.Controls.Add(cancelButton1);
|
||||||
|
CancelButton = cancelButton1;
|
||||||
|
|
||||||
|
// Add OK button
|
||||||
|
Button okButton1 = new Button();
|
||||||
|
okButton1.Text = "OK";
|
||||||
|
okButton1.Click += OkButtonClickedHandler;
|
||||||
|
buttonPanel.Controls.Add(okButton1);
|
||||||
|
AcceptButton = okButton1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OkButtonClickedHandler(object sender, EventArgs e) {
|
||||||
|
try {
|
||||||
|
import();
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
MessageBox.Show(exception.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,6 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
rm -rf build
|
|
||||||
mkdir build
|
|
||||||
cd build
|
|
||||||
cmake .. -G Xcode
|
|
||||||
cmake --build . --config Release --target package
|
|
|
@ -1,5 +0,0 @@
|
||||||
rmdir /s /q build
|
|
||||||
mkdir build
|
|
||||||
cd build
|
|
||||||
cmake .. -G "Visual Studio 16 2019"
|
|
||||||
cmake --build . --config Release --target package
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
doit==0.36.0
|
||||||
|
clang-format==19.1.5
|
||||||
|
gersemi==0.17.1
|
||||||
|
gitignore_parser==0.1.11
|
||||||
|
ruff==0.8.3
|
|
@ -1,6 +1,29 @@
|
||||||
cmake_minimum_required(VERSION 3.10)
|
cmake_minimum_required(VERSION 3.10)
|
||||||
|
|
||||||
include("../appInfo.cmake")
|
# Parse app info
|
||||||
|
file(READ "../app-info.toml" tomlContent)
|
||||||
|
string(REGEX MATCH "appName *= *\"[^\"]+\"" appName "${tomlContent}")
|
||||||
|
string(REGEX REPLACE ".*\"([^\"]+)\"" "\\1" appName "${appName}")
|
||||||
|
string(REGEX MATCH "appVersion *= *\"[^\"]+\"" appVersion "${tomlContent}")
|
||||||
|
string(REGEX REPLACE ".*\"([^\"]+)\"" "\\1" appVersion "${appVersion}")
|
||||||
|
|
||||||
|
project("${appName}")
|
||||||
|
|
||||||
|
# Store compiler information
|
||||||
|
if(
|
||||||
|
"${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU"
|
||||||
|
OR "${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang"
|
||||||
|
OR "${CMAKE_CXX_COMPILER_ID}" STREQUAL "AppleClang"
|
||||||
|
)
|
||||||
|
set(compilerIsGccOrClang true)
|
||||||
|
else()
|
||||||
|
set(compilerIsGccOrClang false)
|
||||||
|
endif()
|
||||||
|
if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC")
|
||||||
|
set(compilerIsMsvc true)
|
||||||
|
else()
|
||||||
|
set(compilerIsMsvc false)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Support legacy OS X versions
|
# Support legacy OS X versions
|
||||||
set(CMAKE_OSX_DEPLOYMENT_TARGET "10.10" CACHE STRING "Minimum OS X deployment version")
|
set(CMAKE_OSX_DEPLOYMENT_TARGET "10.10" CACHE STRING "Minimum OS X deployment version")
|
||||||
|
@ -10,34 +33,31 @@ set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
# Enable POSIX threads
|
# Enable POSIX threads
|
||||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR "${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
if(compilerIsGccOrClang)
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread")
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pthread")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Use static run-time
|
# Use static run-time
|
||||||
if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC")
|
if(compilerIsMsvc)
|
||||||
add_compile_options(/MT$<$<CONFIG:Debug>:d>)
|
add_compile_options(/MT$<$<CONFIG:Debug>:d>)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Set global flags and define flags variables for later use
|
# Set global warning flags and define flags variables for later use
|
||||||
if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR "${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
if(compilerIsGccOrClang)
|
||||||
set(enableWarningsFlags "-Wall;-Wextra")
|
set(enableWarningsFlags "-Wall;-Wextra")
|
||||||
set(disableWarningsFlags "-w")
|
set(disableWarningsFlags "-w")
|
||||||
elseif("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC")
|
elseif(compilerIsMsvc)
|
||||||
set(enableWarningsFlags "/W4")
|
set(enableWarningsFlags "/W4")
|
||||||
set(disableWarningsFlags "/W0")
|
set(disableWarningsFlags "/W0")
|
||||||
|
|
||||||
# Disable warning C4456: declaration of '...' hides previous local declaration
|
# Disable warning C4456: declaration of '...' hides previous local declaration
|
||||||
# I'm doing that on purpose.
|
# I'm doing that on purpose.
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /wd4458")
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /wd4458")
|
||||||
|
|
||||||
# Assume UTF-8 encoding for source files and encode string constants in UTF-8
|
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /utf-8")
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Use UTF-8 throughout
|
# Assume UTF-8 encoding for source files and encode string constants in UTF-8
|
||||||
if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "MSVC")
|
if(compilerIsMsvc)
|
||||||
add_compile_options("/utf-8")
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /utf-8")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(${UNIX})
|
if(${UNIX})
|
||||||
|
@ -59,30 +79,31 @@ include_directories(SYSTEM ${Boost_INCLUDE_DIRS})
|
||||||
link_libraries(${Boost_LIBRARIES}) # Just about every project needs Boost
|
link_libraries(${Boost_LIBRARIES}) # Just about every project needs Boost
|
||||||
|
|
||||||
# ... C++ Format
|
# ... C++ Format
|
||||||
FILE(GLOB cppFormatFiles "lib/cppformat/*.cc")
|
file(GLOB cppFormatFiles "lib/cppformat/*.cc")
|
||||||
add_library(cppFormat ${cppFormatFiles})
|
add_library(cppFormat ${cppFormatFiles})
|
||||||
target_include_directories(cppFormat SYSTEM PUBLIC "lib/cppformat")
|
target_include_directories(cppFormat SYSTEM PUBLIC "lib/cppformat")
|
||||||
target_compile_options(cppFormat PRIVATE ${disableWarningsFlags})
|
target_compile_options(cppFormat PRIVATE ${disableWarningsFlags})
|
||||||
set_target_properties(cppFormat PROPERTIES FOLDER lib)
|
set_target_properties(cppFormat PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
# ... sphinxbase
|
# ... sphinxbase
|
||||||
FILE(GLOB_RECURSE sphinxbaseFiles "lib/sphinxbase-rev13216/src/libsphinxbase/*.c")
|
file(GLOB_RECURSE sphinxbaseFiles "lib/sphinxbase-rev13216/src/libsphinxbase/*.c")
|
||||||
add_library(sphinxbase ${sphinxbaseFiles})
|
add_library(sphinxbase ${sphinxbaseFiles})
|
||||||
target_include_directories(sphinxbase SYSTEM PUBLIC
|
target_include_directories(
|
||||||
"lib/sphinxbase-rev13216/include"
|
sphinxbase
|
||||||
"lib/sphinxbase-rev13216/src"
|
SYSTEM
|
||||||
"lib/sphinx_config"
|
PUBLIC "lib/sphinxbase-rev13216/include" "lib/sphinxbase-rev13216/src" "lib/sphinx_config"
|
||||||
)
|
)
|
||||||
target_compile_options(sphinxbase PRIVATE ${disableWarningsFlags})
|
target_compile_options(sphinxbase PRIVATE ${disableWarningsFlags})
|
||||||
target_compile_definitions(sphinxbase PUBLIC __SPHINXBASE_EXPORT_H__=1 SPHINXBASE_EXPORT=) # Compile as static lib
|
target_compile_definitions(sphinxbase PUBLIC __SPHINXBASE_EXPORT_H__=1 SPHINXBASE_EXPORT=) # Compile as static lib
|
||||||
set_target_properties(sphinxbase PROPERTIES FOLDER lib)
|
set_target_properties(sphinxbase PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
# ... PocketSphinx
|
# ... PocketSphinx
|
||||||
FILE(GLOB pocketSphinxFiles "lib/pocketsphinx-rev13216/src/libpocketsphinx/*.c")
|
file(GLOB pocketSphinxFiles "lib/pocketsphinx-rev13216/src/libpocketsphinx/*.c")
|
||||||
add_library(pocketSphinx ${pocketSphinxFiles})
|
add_library(pocketSphinx ${pocketSphinxFiles})
|
||||||
target_include_directories(pocketSphinx SYSTEM PUBLIC
|
target_include_directories(
|
||||||
"lib/pocketsphinx-rev13216/include"
|
pocketSphinx
|
||||||
"lib/pocketsphinx-rev13216/src/libpocketsphinx"
|
SYSTEM
|
||||||
|
PUBLIC "lib/pocketsphinx-rev13216/include" "lib/pocketsphinx-rev13216/src/libpocketsphinx"
|
||||||
)
|
)
|
||||||
target_link_libraries(pocketSphinx sphinxbase)
|
target_link_libraries(pocketSphinx sphinxbase)
|
||||||
target_compile_options(pocketSphinx PRIVATE ${disableWarningsFlags})
|
target_compile_options(pocketSphinx PRIVATE ${disableWarningsFlags})
|
||||||
|
@ -93,6 +114,7 @@ set_target_properties(pocketSphinx PROPERTIES FOLDER lib)
|
||||||
include_directories(SYSTEM "lib/tclap-1.2.1/include")
|
include_directories(SYSTEM "lib/tclap-1.2.1/include")
|
||||||
|
|
||||||
# ... Google Test
|
# ... Google Test
|
||||||
|
set(INSTALL_GTEST OFF) # Prevent library files from ending up in our artifacts
|
||||||
add_subdirectory("lib/googletest")
|
add_subdirectory("lib/googletest")
|
||||||
target_compile_options(gmock PRIVATE ${disableWarningsFlags})
|
target_compile_options(gmock PRIVATE ${disableWarningsFlags})
|
||||||
set_target_properties(gmock PROPERTIES FOLDER lib)
|
set_target_properties(gmock PROPERTIES FOLDER lib)
|
||||||
|
@ -129,10 +151,10 @@ set(webRtcFiles
|
||||||
add_library(webRtc ${webRtcFiles})
|
add_library(webRtc ${webRtcFiles})
|
||||||
target_include_directories(webRtc SYSTEM PUBLIC "lib/webrtc-8d2248ff")
|
target_include_directories(webRtc SYSTEM PUBLIC "lib/webrtc-8d2248ff")
|
||||||
target_compile_options(webRtc PRIVATE ${disableWarningsFlags})
|
target_compile_options(webRtc PRIVATE ${disableWarningsFlags})
|
||||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU")
|
if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU")
|
||||||
target_compile_options(webRtc PRIVATE -pthread -lpthread)
|
target_compile_options(webRtc PRIVATE -pthread -lpthread)
|
||||||
endif()
|
endif()
|
||||||
if (NOT WIN32)
|
if(NOT WIN32)
|
||||||
target_compile_definitions(webRtc PRIVATE WEBRTC_POSIX)
|
target_compile_definitions(webRtc PRIVATE WEBRTC_POSIX)
|
||||||
endif()
|
endif()
|
||||||
set_target_properties(webRtc PROPERTIES FOLDER lib)
|
set_target_properties(webRtc PROPERTIES FOLDER lib)
|
||||||
|
@ -203,34 +225,26 @@ set(fliteFiles
|
||||||
lib/flite-1.4/src/utils/cst_val_user.c
|
lib/flite-1.4/src/utils/cst_val_user.c
|
||||||
)
|
)
|
||||||
add_library(flite ${fliteFiles})
|
add_library(flite ${fliteFiles})
|
||||||
target_include_directories(flite SYSTEM PUBLIC
|
target_include_directories(flite SYSTEM PUBLIC "lib/flite-1.4/include" "lib/flite-1.4")
|
||||||
"lib/flite-1.4/include"
|
|
||||||
"lib/flite-1.4"
|
|
||||||
)
|
|
||||||
target_compile_options(flite PRIVATE ${disableWarningsFlags})
|
target_compile_options(flite PRIVATE ${disableWarningsFlags})
|
||||||
set_target_properties(flite PROPERTIES FOLDER lib)
|
set_target_properties(flite PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
# ... UTF8-CPP
|
# ... UTF8-CPP
|
||||||
add_library(utfcpp
|
add_library(utfcpp lib/header-only.c lib/utfcpp-2.3.5/source/utf8.h)
|
||||||
lib/header-only.c
|
|
||||||
lib/utfcpp-2.3.5/source/utf8.h
|
|
||||||
)
|
|
||||||
target_include_directories(utfcpp SYSTEM PUBLIC "lib/utfcpp-2.3.5/source")
|
target_include_directories(utfcpp SYSTEM PUBLIC "lib/utfcpp-2.3.5/source")
|
||||||
target_compile_options(utfcpp PRIVATE ${disableWarningsFlags})
|
target_compile_options(utfcpp PRIVATE ${disableWarningsFlags})
|
||||||
set_target_properties(utfcpp PROPERTIES FOLDER lib)
|
set_target_properties(utfcpp PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
# ... utf8proc
|
# ... utf8proc
|
||||||
add_library(utf8proc
|
add_library(utf8proc lib/utf8proc-2.2.0/utf8proc.c lib/utf8proc-2.2.0/utf8proc.h)
|
||||||
lib/utf8proc-2.2.0/utf8proc.c
|
|
||||||
lib/utf8proc-2.2.0/utf8proc.h
|
|
||||||
)
|
|
||||||
target_include_directories(utf8proc SYSTEM PUBLIC "lib/utf8proc-2.2.0")
|
target_include_directories(utf8proc SYSTEM PUBLIC "lib/utf8proc-2.2.0")
|
||||||
target_compile_options(utf8proc PRIVATE ${disableWarningsFlags})
|
target_compile_options(utf8proc PRIVATE ${disableWarningsFlags})
|
||||||
target_compile_definitions(utf8proc PUBLIC UTF8PROC_STATIC=1) # Compile as static lib
|
target_compile_definitions(utf8proc PUBLIC UTF8PROC_STATIC=1) # Compile as static lib
|
||||||
set_target_properties(utf8proc PROPERTIES FOLDER lib)
|
set_target_properties(utf8proc PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
# ... Ogg
|
# ... Ogg
|
||||||
add_library(ogg
|
add_library(
|
||||||
|
ogg
|
||||||
lib/ogg-1.3.3/include/ogg/ogg.h
|
lib/ogg-1.3.3/include/ogg/ogg.h
|
||||||
lib/ogg-1.3.3/src/bitwise.c
|
lib/ogg-1.3.3/src/bitwise.c
|
||||||
lib/ogg-1.3.3/src/framing.c
|
lib/ogg-1.3.3/src/framing.c
|
||||||
|
@ -240,7 +254,8 @@ target_compile_options(ogg PRIVATE ${disableWarningsFlags})
|
||||||
set_target_properties(ogg PROPERTIES FOLDER lib)
|
set_target_properties(ogg PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
# ... Vorbis
|
# ... Vorbis
|
||||||
add_library(vorbis
|
add_library(
|
||||||
|
vorbis
|
||||||
lib/vorbis-1.3.6/include/vorbis/vorbisfile.h
|
lib/vorbis-1.3.6/include/vorbis/vorbisfile.h
|
||||||
lib/vorbis-1.3.6/lib/bitrate.c
|
lib/vorbis-1.3.6/lib/bitrate.c
|
||||||
lib/vorbis-1.3.6/lib/block.c
|
lib/vorbis-1.3.6/lib/block.c
|
||||||
|
@ -263,9 +278,7 @@ add_library(vorbis
|
||||||
lib/vorbis-1.3.6/lib/window.c
|
lib/vorbis-1.3.6/lib/window.c
|
||||||
)
|
)
|
||||||
target_include_directories(vorbis SYSTEM PUBLIC "lib/vorbis-1.3.6/include")
|
target_include_directories(vorbis SYSTEM PUBLIC "lib/vorbis-1.3.6/include")
|
||||||
target_link_libraries(vorbis
|
target_link_libraries(vorbis ogg)
|
||||||
ogg
|
|
||||||
)
|
|
||||||
target_compile_options(vorbis PRIVATE ${disableWarningsFlags})
|
target_compile_options(vorbis PRIVATE ${disableWarningsFlags})
|
||||||
set_target_properties(vorbis PROPERTIES FOLDER lib)
|
set_target_properties(vorbis PROPERTIES FOLDER lib)
|
||||||
|
|
||||||
|
@ -274,60 +287,59 @@ set_target_properties(vorbis PROPERTIES FOLDER lib)
|
||||||
include_directories("src")
|
include_directories("src")
|
||||||
|
|
||||||
# ... rhubarb-animation
|
# ... rhubarb-animation
|
||||||
add_library(rhubarb-animation
|
add_library(
|
||||||
src/animation/animationRules.cpp
|
rhubarb-animation
|
||||||
src/animation/animationRules.h
|
src/animation/animation-rules.cpp
|
||||||
src/animation/mouthAnimation.cpp
|
src/animation/animation-rules.h
|
||||||
src/animation/mouthAnimation.h
|
src/animation/mouth-animation.cpp
|
||||||
src/animation/pauseAnimation.cpp
|
src/animation/mouth-animation.h
|
||||||
src/animation/pauseAnimation.h
|
src/animation/pause-animation.cpp
|
||||||
src/animation/roughAnimation.cpp
|
src/animation/pause-animation.h
|
||||||
src/animation/roughAnimation.h
|
src/animation/rough-animation.cpp
|
||||||
src/animation/ShapeRule.cpp
|
src/animation/rough-animation.h
|
||||||
src/animation/ShapeRule.h
|
src/animation/shape-rule.cpp
|
||||||
src/animation/shapeShorthands.h
|
src/animation/shape-rule.h
|
||||||
src/animation/staticSegments.cpp
|
src/animation/shape-shorthands.h
|
||||||
src/animation/staticSegments.h
|
src/animation/static-segments.cpp
|
||||||
src/animation/targetShapeSet.cpp
|
src/animation/static-segments.h
|
||||||
src/animation/targetShapeSet.h
|
src/animation/target-shape-set.cpp
|
||||||
src/animation/timingOptimization.cpp
|
src/animation/target-shape-set.h
|
||||||
src/animation/timingOptimization.h
|
src/animation/timing-optimization.cpp
|
||||||
|
src/animation/timing-optimization.h
|
||||||
src/animation/tweening.cpp
|
src/animation/tweening.cpp
|
||||||
src/animation/tweening.h
|
src/animation/tweening.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-animation PRIVATE "src/animation")
|
target_include_directories(rhubarb-animation PRIVATE "src/animation")
|
||||||
target_link_libraries(rhubarb-animation
|
target_link_libraries(rhubarb-animation rhubarb-core rhubarb-logging rhubarb-time)
|
||||||
rhubarb-core
|
|
||||||
rhubarb-logging
|
|
||||||
rhubarb-time
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rhubarb-audio
|
# ... rhubarb-audio
|
||||||
add_library(rhubarb-audio
|
add_library(
|
||||||
src/audio/AudioClip.cpp
|
rhubarb-audio
|
||||||
src/audio/AudioClip.h
|
src/audio/audio-clip.cpp
|
||||||
src/audio/audioFileReading.cpp
|
src/audio/audio-clip.h
|
||||||
src/audio/audioFileReading.h
|
src/audio/audio-file-reading.cpp
|
||||||
src/audio/AudioSegment.cpp
|
src/audio/audio-file-reading.h
|
||||||
src/audio/AudioSegment.h
|
src/audio/audio-segment.cpp
|
||||||
src/audio/DcOffset.cpp
|
src/audio/audio-segment.h
|
||||||
src/audio/DcOffset.h
|
src/audio/dc-offset.cpp
|
||||||
src/audio/ioTools.h
|
src/audio/dc-offset.h
|
||||||
src/audio/OggVorbisFileReader.cpp
|
src/audio/io-tools.h
|
||||||
src/audio/OggVorbisFileReader.h
|
src/audio/ogg-vorbis-file-reader.cpp
|
||||||
|
src/audio/ogg-vorbis-file-reader.h
|
||||||
src/audio/processing.cpp
|
src/audio/processing.cpp
|
||||||
src/audio/processing.h
|
src/audio/processing.h
|
||||||
src/audio/SampleRateConverter.cpp
|
src/audio/sample-rate-converter.cpp
|
||||||
src/audio/SampleRateConverter.h
|
src/audio/sample-rate-converter.h
|
||||||
src/audio/voiceActivityDetection.cpp
|
src/audio/voice-activity-detection.cpp
|
||||||
src/audio/voiceActivityDetection.h
|
src/audio/voice-activity-detection.h
|
||||||
src/audio/WaveFileReader.cpp
|
src/audio/wave-file-reader.cpp
|
||||||
src/audio/WaveFileReader.h
|
src/audio/wave-file-reader.h
|
||||||
src/audio/waveFileWriting.cpp
|
src/audio/wave-file-writing.cpp
|
||||||
src/audio/waveFileWriting.h
|
src/audio/wave-file-writing.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-audio PRIVATE "src/audio")
|
target_include_directories(rhubarb-audio PRIVATE "src/audio")
|
||||||
target_link_libraries(rhubarb-audio
|
target_link_libraries(
|
||||||
|
rhubarb-audio
|
||||||
webRtc
|
webRtc
|
||||||
vorbis
|
vorbis
|
||||||
rhubarb-logging
|
rhubarb-logging
|
||||||
|
@ -336,48 +348,42 @@ target_link_libraries(rhubarb-audio
|
||||||
)
|
)
|
||||||
|
|
||||||
# ... rhubarb-core
|
# ... rhubarb-core
|
||||||
configure_file(src/core/appInfo.cpp.in appInfo.cpp ESCAPE_QUOTES)
|
configure_file(src/core/app-info.cpp.in app-info.cpp)
|
||||||
add_library(rhubarb-core
|
add_library(
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/appInfo.cpp
|
rhubarb-core
|
||||||
src/core/appInfo.h
|
${CMAKE_CURRENT_BINARY_DIR}/app-info.cpp
|
||||||
src/core/Phone.cpp
|
src/core/app-info.h
|
||||||
src/core/Phone.h
|
src/core/phone.cpp
|
||||||
src/core/Shape.cpp
|
src/core/phone.h
|
||||||
src/core/Shape.h
|
src/core/shape.cpp
|
||||||
|
src/core/shape.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-core PRIVATE "src/core")
|
target_include_directories(rhubarb-core PRIVATE "src/core")
|
||||||
target_link_libraries(rhubarb-core
|
target_link_libraries(rhubarb-core rhubarb-tools)
|
||||||
rhubarb-tools
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rhubarb-exporters
|
# ... rhubarb-exporters
|
||||||
add_library(rhubarb-exporters
|
add_library(
|
||||||
src/exporters/DatExporter.cpp
|
rhubarb-exporters
|
||||||
src/exporters/DatExporter.h
|
src/exporters/dat-exporter.cpp
|
||||||
src/exporters/Exporter.h
|
src/exporters/dat-exporter.h
|
||||||
src/exporters/exporterTools.cpp
|
src/exporters/exporter.h
|
||||||
src/exporters/exporterTools.h
|
src/exporters/exporter-tools.cpp
|
||||||
src/exporters/JsonExporter.cpp
|
src/exporters/exporter-tools.h
|
||||||
src/exporters/JsonExporter.h
|
src/exporters/json-exporter.cpp
|
||||||
src/exporters/TsvExporter.cpp
|
src/exporters/json-exporter.h
|
||||||
src/exporters/TsvExporter.h
|
src/exporters/tsv-exporter.cpp
|
||||||
src/exporters/XmlExporter.cpp
|
src/exporters/tsv-exporter.h
|
||||||
src/exporters/XmlExporter.h
|
src/exporters/xml-exporter.cpp
|
||||||
|
src/exporters/xml-exporter.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-exporters PRIVATE "src/exporters")
|
target_include_directories(rhubarb-exporters PRIVATE "src/exporters")
|
||||||
target_link_libraries(rhubarb-exporters
|
target_link_libraries(rhubarb-exporters rhubarb-animation rhubarb-core rhubarb-time)
|
||||||
rhubarb-animation
|
|
||||||
rhubarb-core
|
|
||||||
rhubarb-time
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rhubarb-lib
|
# ... rhubarb-lib
|
||||||
add_library(rhubarb-lib
|
add_library(rhubarb-lib src/lib/rhubarb-lib.cpp src/lib/rhubarb-lib.h)
|
||||||
src/lib/rhubarbLib.cpp
|
|
||||||
src/lib/rhubarbLib.h
|
|
||||||
)
|
|
||||||
target_include_directories(rhubarb-lib PRIVATE "src/lib")
|
target_include_directories(rhubarb-lib PRIVATE "src/lib")
|
||||||
target_link_libraries(rhubarb-lib
|
target_link_libraries(
|
||||||
|
rhubarb-lib
|
||||||
rhubarb-animation
|
rhubarb-animation
|
||||||
rhubarb-audio
|
rhubarb-audio
|
||||||
rhubarb-core
|
rhubarb-core
|
||||||
|
@ -387,43 +393,44 @@ target_link_libraries(rhubarb-lib
|
||||||
)
|
)
|
||||||
|
|
||||||
# ... rhubarb-logging
|
# ... rhubarb-logging
|
||||||
add_library(rhubarb-logging
|
add_library(
|
||||||
src/logging/Entry.cpp
|
rhubarb-logging
|
||||||
src/logging/Entry.h
|
src/logging/entry.cpp
|
||||||
src/logging/Formatter.h
|
src/logging/entry.h
|
||||||
|
src/logging/formatter.h
|
||||||
src/logging/formatters.cpp
|
src/logging/formatters.cpp
|
||||||
src/logging/formatters.h
|
src/logging/formatters.h
|
||||||
src/logging/Level.cpp
|
src/logging/level.cpp
|
||||||
src/logging/Level.h
|
src/logging/level.h
|
||||||
src/logging/logging.cpp
|
src/logging/logging.cpp
|
||||||
src/logging/logging.h
|
src/logging/logging.h
|
||||||
src/logging/Sink.h
|
src/logging/sink.h
|
||||||
src/logging/sinks.cpp
|
src/logging/sinks.cpp
|
||||||
src/logging/sinks.h
|
src/logging/sinks.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-logging PRIVATE "src/logging")
|
target_include_directories(rhubarb-logging PRIVATE "src/logging")
|
||||||
target_link_libraries(rhubarb-logging
|
target_link_libraries(rhubarb-logging rhubarb-tools)
|
||||||
rhubarb-tools
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rhubarb-recognition
|
# ... rhubarb-recognition
|
||||||
add_library(rhubarb-recognition
|
add_library(
|
||||||
|
rhubarb-recognition
|
||||||
src/recognition/g2p.cpp
|
src/recognition/g2p.cpp
|
||||||
src/recognition/g2p.h
|
src/recognition/g2p.h
|
||||||
src/recognition/languageModels.cpp
|
src/recognition/language-models.cpp
|
||||||
src/recognition/languageModels.h
|
src/recognition/language-models.h
|
||||||
src/recognition/PhoneticRecognizer.cpp
|
src/recognition/phonetic-recognizer.cpp
|
||||||
src/recognition/PhoneticRecognizer.h
|
src/recognition/phonetic-recognizer.h
|
||||||
src/recognition/PocketSphinxRecognizer.cpp
|
src/recognition/pocket-sphinx-recognizer.cpp
|
||||||
src/recognition/PocketSphinxRecognizer.h
|
src/recognition/pocket-sphinx-recognizer.h
|
||||||
src/recognition/pocketSphinxTools.cpp
|
src/recognition/pocket-sphinx-tools.cpp
|
||||||
src/recognition/pocketSphinxTools.h
|
src/recognition/pocket-sphinx-tools.h
|
||||||
src/recognition/Recognizer.h
|
src/recognition/recognizer.h
|
||||||
src/recognition/tokenization.cpp
|
src/recognition/tokenization.cpp
|
||||||
src/recognition/tokenization.h
|
src/recognition/tokenization.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-recognition PRIVATE "src/recognition")
|
target_include_directories(rhubarb-recognition PRIVATE "src/recognition")
|
||||||
target_link_libraries(rhubarb-recognition
|
target_link_libraries(
|
||||||
|
rhubarb-recognition
|
||||||
flite
|
flite
|
||||||
pocketSphinx
|
pocketSphinx
|
||||||
rhubarb-audio
|
rhubarb-audio
|
||||||
|
@ -432,96 +439,89 @@ target_link_libraries(rhubarb-recognition
|
||||||
)
|
)
|
||||||
|
|
||||||
# ... rhubarb-time
|
# ... rhubarb-time
|
||||||
add_library(rhubarb-time
|
add_library(
|
||||||
src/time/BoundedTimeline.h
|
rhubarb-time
|
||||||
|
src/time/bounded-timeline.h
|
||||||
src/time/centiseconds.cpp
|
src/time/centiseconds.cpp
|
||||||
src/time/centiseconds.h
|
src/time/centiseconds.h
|
||||||
src/time/ContinuousTimeline.h
|
src/time/continuous-timeline.h
|
||||||
src/time/Timed.h
|
src/time/timed.h
|
||||||
src/time/timedLogging.h
|
src/time/timed-logging.h
|
||||||
src/time/Timeline.h
|
src/time/timeline.h
|
||||||
src/time/TimeRange.cpp
|
src/time/time-range.cpp
|
||||||
src/time/TimeRange.h
|
src/time/time-range.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-time PRIVATE "src/time")
|
target_include_directories(rhubarb-time PRIVATE "src/time")
|
||||||
target_link_libraries(rhubarb-time
|
target_link_libraries(rhubarb-time cppFormat rhubarb-logging)
|
||||||
cppFormat
|
|
||||||
rhubarb-logging
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rhubarb-tools
|
# ... rhubarb-tools
|
||||||
add_library(rhubarb-tools
|
add_library(
|
||||||
|
rhubarb-tools
|
||||||
src/tools/array.h
|
src/tools/array.h
|
||||||
src/tools/EnumConverter.h
|
src/tools/enum-converter.h
|
||||||
src/tools/exceptions.cpp
|
src/tools/exceptions.cpp
|
||||||
src/tools/exceptions.h
|
src/tools/exceptions.h
|
||||||
src/tools/fileTools.cpp
|
src/tools/file-tools.cpp
|
||||||
src/tools/fileTools.h
|
src/tools/file-tools.h
|
||||||
src/tools/Lazy.h
|
src/tools/lazy.h
|
||||||
src/tools/nextCombination.h
|
src/tools/next-combination.h
|
||||||
src/tools/NiceCmdLineOutput.cpp
|
src/tools/nice-cmd-line-output.cpp
|
||||||
src/tools/NiceCmdLineOutput.h
|
src/tools/nice-cmd-line-output.h
|
||||||
src/tools/ObjectPool.h
|
src/tools/object-pool.h
|
||||||
src/tools/pairs.h
|
src/tools/pairs.h
|
||||||
src/tools/parallel.h
|
src/tools/parallel.h
|
||||||
src/tools/platformTools.cpp
|
src/tools/platform-tools.cpp
|
||||||
src/tools/platformTools.h
|
src/tools/platform-tools.h
|
||||||
src/tools/progress.cpp
|
src/tools/progress.cpp
|
||||||
src/tools/progress.h
|
src/tools/progress.h
|
||||||
src/tools/ProgressBar.cpp
|
src/tools/progress-bar.cpp
|
||||||
src/tools/ProgressBar.h
|
src/tools/progress-bar.h
|
||||||
src/tools/stringTools.cpp
|
src/tools/string-tools.cpp
|
||||||
src/tools/stringTools.h
|
src/tools/string-tools.h
|
||||||
src/tools/TablePrinter.cpp
|
src/tools/table-printer.cpp
|
||||||
src/tools/TablePrinter.h
|
src/tools/table-printer.h
|
||||||
src/tools/textFiles.cpp
|
src/tools/text-files.cpp
|
||||||
src/tools/textFiles.h
|
src/tools/text-files.h
|
||||||
src/tools/tools.cpp
|
src/tools/tools.cpp
|
||||||
src/tools/tools.h
|
src/tools/tools.h
|
||||||
src/tools/tupleHash.h
|
src/tools/tuple-hash.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb-tools PRIVATE "src/tools")
|
target_include_directories(rhubarb-tools PRIVATE "src/tools")
|
||||||
target_link_libraries(rhubarb-tools
|
target_link_libraries(rhubarb-tools cppFormat whereami utfcpp utf8proc)
|
||||||
cppFormat
|
|
||||||
whereami
|
|
||||||
utfcpp
|
|
||||||
utf8proc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define Rhubarb executable
|
# Define Rhubarb executable
|
||||||
add_executable(rhubarb
|
add_executable(
|
||||||
|
rhubarb
|
||||||
src/rhubarb/main.cpp
|
src/rhubarb/main.cpp
|
||||||
src/rhubarb/ExportFormat.cpp
|
src/rhubarb/export-format.cpp
|
||||||
src/rhubarb/ExportFormat.h
|
src/rhubarb/export-format.h
|
||||||
src/rhubarb/RecognizerType.cpp
|
src/rhubarb/recognizer-type.cpp
|
||||||
src/rhubarb/RecognizerType.h
|
src/rhubarb/recognizer-type.h
|
||||||
src/rhubarb/semanticEntries.cpp
|
src/rhubarb/semantic-entries.cpp
|
||||||
src/rhubarb/semanticEntries.h
|
src/rhubarb/semantic-entries.h
|
||||||
src/rhubarb/sinks.cpp
|
src/rhubarb/sinks.cpp
|
||||||
src/rhubarb/sinks.h
|
src/rhubarb/sinks.h
|
||||||
)
|
)
|
||||||
target_include_directories(rhubarb PUBLIC "src/rhubarb")
|
target_include_directories(rhubarb PUBLIC "src/rhubarb")
|
||||||
target_link_libraries(rhubarb
|
target_link_libraries(rhubarb rhubarb-exporters rhubarb-lib)
|
||||||
rhubarb-exporters
|
|
||||||
rhubarb-lib
|
|
||||||
)
|
|
||||||
target_compile_options(rhubarb PUBLIC ${enableWarningsFlags})
|
target_compile_options(rhubarb PUBLIC ${enableWarningsFlags})
|
||||||
|
|
||||||
# Define test project
|
# Define test project
|
||||||
#include_directories("${gtest_SOURCE_DIR}/include")
|
#include_directories("${gtest_SOURCE_DIR}/include")
|
||||||
set(TEST_FILES
|
set(TEST_FILES
|
||||||
tests/stringToolsTests.cpp
|
tests/string-tools-tests.cpp
|
||||||
tests/TimelineTests.cpp
|
tests/timeline-tests.cpp
|
||||||
tests/BoundedTimelineTests.cpp
|
tests/bounded-timeline-tests.cpp
|
||||||
tests/ContinuousTimelineTests.cpp
|
tests/continuous-timeline-tests.cpp
|
||||||
tests/pairsTests.cpp
|
tests/pairs-tests.cpp
|
||||||
tests/tokenizationTests.cpp
|
tests/tokenization-tests.cpp
|
||||||
tests/g2pTests.cpp
|
tests/g2p-tests.cpp
|
||||||
tests/LazyTests.cpp
|
tests/lazy-tests.cpp
|
||||||
tests/WaveFileReaderTests.cpp
|
tests/wave-file-reader-tests.cpp
|
||||||
)
|
)
|
||||||
add_executable(runTests ${TEST_FILES})
|
add_executable(runTests ${TEST_FILES})
|
||||||
target_link_libraries(runTests
|
target_link_libraries(
|
||||||
|
runTests
|
||||||
gtest
|
gtest
|
||||||
gmock
|
gmock
|
||||||
gmock_main
|
gmock_main
|
||||||
|
@ -541,16 +541,17 @@ function(copy_and_install sourceGlob relativeTargetDirectory)
|
||||||
get_filename_component(fileName "${sourcePath}" NAME)
|
get_filename_component(fileName "${sourcePath}" NAME)
|
||||||
|
|
||||||
# Copy file during build
|
# Copy file during build
|
||||||
add_custom_command(TARGET rhubarb POST_BUILD
|
add_custom_command(
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy "${sourcePath}" "$<TARGET_FILE_DIR:rhubarb>/${relativeTargetDirectory}/${fileName}"
|
TARGET rhubarb
|
||||||
|
POST_BUILD
|
||||||
|
COMMAND
|
||||||
|
${CMAKE_COMMAND} -E copy "${sourcePath}"
|
||||||
|
"$<TARGET_FILE_DIR:rhubarb>/${relativeTargetDirectory}/${fileName}"
|
||||||
COMMENT "Creating '${relativeTargetDirectory}/${fileName}'"
|
COMMENT "Creating '${relativeTargetDirectory}/${fileName}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Install file
|
# Install file
|
||||||
install(
|
install(FILES "${sourcePath}" DESTINATION "${relativeTargetDirectory}")
|
||||||
FILES "${sourcePath}"
|
|
||||||
DESTINATION "${relativeTargetDirectory}"
|
|
||||||
)
|
|
||||||
endif()
|
endif()
|
||||||
endforeach()
|
endforeach()
|
||||||
endfunction()
|
endfunction()
|
||||||
|
@ -566,8 +567,12 @@ function(copy sourceGlob relativeTargetDirectory)
|
||||||
get_filename_component(fileName "${sourcePath}" NAME)
|
get_filename_component(fileName "${sourcePath}" NAME)
|
||||||
|
|
||||||
# Copy file during build
|
# Copy file during build
|
||||||
add_custom_command(TARGET rhubarb POST_BUILD
|
add_custom_command(
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy "${sourcePath}" "$<TARGET_FILE_DIR:rhubarb>/${relativeTargetDirectory}/${fileName}"
|
TARGET rhubarb
|
||||||
|
POST_BUILD
|
||||||
|
COMMAND
|
||||||
|
${CMAKE_COMMAND} -E copy "${sourcePath}"
|
||||||
|
"$<TARGET_FILE_DIR:rhubarb>/${relativeTargetDirectory}/${fileName}"
|
||||||
COMMENT "Creating '${relativeTargetDirectory}/${fileName}'"
|
COMMENT "Creating '${relativeTargetDirectory}/${fileName}'"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
@ -576,11 +581,4 @@ endfunction()
|
||||||
|
|
||||||
copy_and_install("lib/pocketsphinx-rev13216/model/en-us/*" "res/sphinx")
|
copy_and_install("lib/pocketsphinx-rev13216/model/en-us/*" "res/sphinx")
|
||||||
copy_and_install("lib/cmusphinx-en-us-5.2/*" "res/sphinx/acoustic-model")
|
copy_and_install("lib/cmusphinx-en-us-5.2/*" "res/sphinx/acoustic-model")
|
||||||
|
install(TARGETS rhubarb RUNTIME DESTINATION .)
|
||||||
copy_and_install("tests/resources/*" "tests/resources")
|
|
||||||
|
|
||||||
install(
|
|
||||||
TARGETS rhubarb
|
|
||||||
RUNTIME
|
|
||||||
DESTINATION .
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,86 +0,0 @@
|
||||||
#include "ShapeRule.h"
|
|
||||||
#include <boost/range/adaptor/transformed.hpp>
|
|
||||||
#include <utility>
|
|
||||||
#include "time/ContinuousTimeline.h"
|
|
||||||
|
|
||||||
using boost::optional;
|
|
||||||
using boost::adaptors::transformed;
|
|
||||||
|
|
||||||
template<typename T, bool AutoJoin>
|
|
||||||
ContinuousTimeline<optional<T>, AutoJoin> boundedTimelinetoContinuousOptional(
|
|
||||||
const BoundedTimeline<T, AutoJoin>& timeline
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
timeline.getRange(),
|
|
||||||
boost::none,
|
|
||||||
timeline | transformed([](const Timed<T>& timedValue) {
|
|
||||||
return Timed<optional<T>>(timedValue.getTimeRange(), timedValue.getValue());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ShapeRule::ShapeRule(
|
|
||||||
ShapeSet shapeSet,
|
|
||||||
optional<Phone> phone,
|
|
||||||
TimeRange phoneTiming
|
|
||||||
) :
|
|
||||||
shapeSet(std::move(shapeSet)),
|
|
||||||
phone(std::move(phone)),
|
|
||||||
phoneTiming(phoneTiming)
|
|
||||||
{}
|
|
||||||
|
|
||||||
ShapeRule ShapeRule::getInvalid() {
|
|
||||||
return { {}, boost::none, { 0_cs, 0_cs } };
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ShapeRule::operator==(const ShapeRule& rhs) const {
|
|
||||||
return shapeSet == rhs.shapeSet && phone == rhs.phone && phoneTiming == rhs.phoneTiming;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ShapeRule::operator!=(const ShapeRule& rhs) const {
|
|
||||||
return !operator==(rhs);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ShapeRule::operator<(const ShapeRule& rhs) const {
|
|
||||||
return shapeSet < rhs.shapeSet
|
|
||||||
|| phone < rhs.phone
|
|
||||||
|| phoneTiming.getStart() < rhs.phoneTiming.getStart()
|
|
||||||
|| phoneTiming.getEnd() < rhs.phoneTiming.getEnd();
|
|
||||||
}
|
|
||||||
|
|
||||||
ContinuousTimeline<ShapeRule> getShapeRules(const BoundedTimeline<Phone>& phones) {
|
|
||||||
// Convert to continuous timeline so that silences aren't skipped when iterating
|
|
||||||
auto continuousPhones = boundedTimelinetoContinuousOptional(phones);
|
|
||||||
|
|
||||||
// Create timeline of shape rules
|
|
||||||
ContinuousTimeline<ShapeRule> shapeRules(
|
|
||||||
phones.getRange(),
|
|
||||||
{ { Shape::X }, boost::none, { 0_cs, 0_cs } }
|
|
||||||
);
|
|
||||||
centiseconds previousDuration = 0_cs;
|
|
||||||
for (const auto& timedPhone : continuousPhones) {
|
|
||||||
optional<Phone> phone = timedPhone.getValue();
|
|
||||||
const centiseconds duration = timedPhone.getDuration();
|
|
||||||
|
|
||||||
if (phone) {
|
|
||||||
// Animate one phone
|
|
||||||
Timeline<ShapeSet> phoneShapeSets = getShapeSets(*phone, duration, previousDuration);
|
|
||||||
|
|
||||||
// Result timing is relative to phone. Make absolute.
|
|
||||||
phoneShapeSets.shift(timedPhone.getStart());
|
|
||||||
|
|
||||||
// Copy to timeline.
|
|
||||||
// Later shape sets may overwrite earlier ones if overlapping.
|
|
||||||
for (const auto& timedShapeSet : phoneShapeSets) {
|
|
||||||
shapeRules.set(
|
|
||||||
timedShapeSet.getTimeRange(),
|
|
||||||
ShapeRule(timedShapeSet.getValue(), phone, timedPhone.getTimeRange())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousDuration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
return shapeRules;
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "core/Phone.h"
|
|
||||||
#include "animationRules.h"
|
|
||||||
#include "time/BoundedTimeline.h"
|
|
||||||
#include "time/ContinuousTimeline.h"
|
|
||||||
#include "time/TimeRange.h"
|
|
||||||
|
|
||||||
struct ShapeRule {
|
|
||||||
ShapeSet shapeSet;
|
|
||||||
boost::optional<Phone> phone;
|
|
||||||
TimeRange phoneTiming;
|
|
||||||
|
|
||||||
ShapeRule(ShapeSet shapeSet, boost::optional<Phone> phone, TimeRange phoneTiming);
|
|
||||||
|
|
||||||
static ShapeRule getInvalid();
|
|
||||||
|
|
||||||
bool operator==(const ShapeRule&) const;
|
|
||||||
bool operator!=(const ShapeRule&) const;
|
|
||||||
bool operator<(const ShapeRule&) const;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns shape rules for an entire timeline of phones.
|
|
||||||
ContinuousTimeline<ShapeRule> getShapeRules(const BoundedTimeline<Phone>& phones);
|
|
|
@ -0,0 +1,165 @@
|
||||||
|
#include "animation-rules.h"
|
||||||
|
|
||||||
|
#include <boost/algorithm/clamp.hpp>
|
||||||
|
|
||||||
|
#include "shape-shorthands.h"
|
||||||
|
#include "time/continuous-timeline.h"
|
||||||
|
#include "tools/array.h"
|
||||||
|
|
||||||
|
using boost::optional;
|
||||||
|
using boost::algorithm::clamp;
|
||||||
|
using std::array;
|
||||||
|
using std::map;
|
||||||
|
using std::pair;
|
||||||
|
using std::chrono::duration_cast;
|
||||||
|
|
||||||
|
constexpr size_t shapeValueCount = static_cast<size_t>(Shape::EndSentinel);
|
||||||
|
|
||||||
|
Shape getBasicShape(Shape shape) {
|
||||||
|
static constexpr array<Shape, shapeValueCount> basicShapes =
|
||||||
|
make_array(A, B, C, D, E, F, A, C, A);
|
||||||
|
return basicShapes[static_cast<size_t>(shape)];
|
||||||
|
}
|
||||||
|
|
||||||
|
Shape relax(Shape shape) {
|
||||||
|
static constexpr array<Shape, shapeValueCount> relaxedShapes =
|
||||||
|
make_array(A, B, B, C, C, B, X, B, X);
|
||||||
|
return relaxedShapes[static_cast<size_t>(shape)];
|
||||||
|
}
|
||||||
|
|
||||||
|
Shape getClosestShape(Shape reference, ShapeSet shapes) {
|
||||||
|
if (shapes.empty()) {
|
||||||
|
throw std::invalid_argument("Cannot select from empty set of shapes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A matrix that for each shape contains all shapes in ascending order of effort required to
|
||||||
|
// move to them
|
||||||
|
constexpr static array<array<Shape, shapeValueCount>, shapeValueCount> effortMatrix =
|
||||||
|
make_array(
|
||||||
|
/* A */ make_array(A, X, G, B, C, H, E, D, F),
|
||||||
|
/* B */ make_array(B, G, A, X, C, H, E, D, F),
|
||||||
|
/* C */ make_array(C, H, B, G, D, A, X, E, F),
|
||||||
|
/* D */ make_array(D, C, H, B, G, A, X, E, F),
|
||||||
|
/* E */ make_array(E, C, H, B, G, A, X, D, F),
|
||||||
|
/* F */ make_array(F, B, G, A, X, C, H, E, D),
|
||||||
|
/* G */ make_array(G, A, B, C, H, X, E, D, F),
|
||||||
|
/* H */ make_array(H, C, B, G, D, A, X, E, F), // Like C
|
||||||
|
/* X */ make_array(X, A, G, B, C, H, E, D, F) // Like A
|
||||||
|
);
|
||||||
|
|
||||||
|
auto& closestShapes = effortMatrix.at(static_cast<size_t>(reference));
|
||||||
|
for (Shape closestShape : closestShapes) {
|
||||||
|
if (shapes.find(closestShape) != shapes.end()) {
|
||||||
|
return closestShape;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw std::invalid_argument("Unable to find closest shape.");
|
||||||
|
}
|
||||||
|
|
||||||
|
optional<pair<Shape, TweenTiming>> getTween(Shape first, Shape second) {
|
||||||
|
// Note that most of the following rules work in one direction only.
|
||||||
|
// That's because in animation, the mouth should usually "pop" open without inbetweens,
|
||||||
|
// then close slowly.
|
||||||
|
static const map<pair<Shape, Shape>, pair<Shape, TweenTiming>> lookup{
|
||||||
|
{{D, A}, {C, TweenTiming::Early}},
|
||||||
|
{{D, B}, {C, TweenTiming::Centered}},
|
||||||
|
{{D, G}, {C, TweenTiming::Early}},
|
||||||
|
{{D, X}, {C, TweenTiming::Late}},
|
||||||
|
{{C, F}, {E, TweenTiming::Centered}},
|
||||||
|
{{F, C}, {E, TweenTiming::Centered}},
|
||||||
|
{{D, F}, {E, TweenTiming::Centered}},
|
||||||
|
{{H, F}, {E, TweenTiming::Late}},
|
||||||
|
{{F, H}, {E, TweenTiming::Early}}
|
||||||
|
};
|
||||||
|
const auto it = lookup.find({first, second});
|
||||||
|
return it != lookup.end() ? it->second : optional<pair<Shape, TweenTiming>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timeline<ShapeSet> getShapeSets(Phone phone, centiseconds duration, centiseconds previousDuration) {
|
||||||
|
// Returns a timeline with a single shape set
|
||||||
|
const auto single = [duration](ShapeSet value) {
|
||||||
|
return Timeline<ShapeSet>{{0_cs, duration, value}};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns a timeline with two shape sets, timed as a diphthong
|
||||||
|
const auto diphthong = [duration](ShapeSet first, ShapeSet second) {
|
||||||
|
const centiseconds firstDuration = duration_cast<centiseconds>(duration * 0.6);
|
||||||
|
return Timeline<ShapeSet>{{0_cs, firstDuration, first}, {firstDuration, duration, second}};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns a timeline with two shape sets, timed as a plosive
|
||||||
|
const auto plosive = [duration, previousDuration](ShapeSet first, ShapeSet second) {
|
||||||
|
const centiseconds minOcclusionDuration = 4_cs;
|
||||||
|
const centiseconds maxOcclusionDuration = 12_cs;
|
||||||
|
const centiseconds occlusionDuration =
|
||||||
|
clamp(previousDuration / 2, minOcclusionDuration, maxOcclusionDuration);
|
||||||
|
return Timeline<ShapeSet>{{-occlusionDuration, 0_cs, first}, {0_cs, duration, second}};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns the result of `getShapeSets` when called with identical arguments
|
||||||
|
// except for a different phone.
|
||||||
|
const auto like = [duration, previousDuration](Phone referencePhone) {
|
||||||
|
return getShapeSets(referencePhone, duration, previousDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
static const ShapeSet any{A, B, C, D, E, F, G, H, X};
|
||||||
|
static const ShapeSet anyOpen{B, C, D, E, F, G, H};
|
||||||
|
|
||||||
|
// Note:
|
||||||
|
// The shapes {A, B, G, X} are very similar. You should avoid regular shape sets containing more
|
||||||
|
// than one of these shapes.
|
||||||
|
// Otherwise, the resulting shape may be more or less random and might not be a good fit.
|
||||||
|
// As an exception, a very flexible rule may contain *all* these shapes.
|
||||||
|
|
||||||
|
switch (phone) {
|
||||||
|
case Phone::AO: return single({E});
|
||||||
|
case Phone::AA: return single({D});
|
||||||
|
case Phone::IY: return single({B});
|
||||||
|
case Phone::UW: return single({F});
|
||||||
|
case Phone::EH: return single({C});
|
||||||
|
case Phone::IH: return single({B});
|
||||||
|
case Phone::UH: return single({F});
|
||||||
|
case Phone::AH: return duration < 20_cs ? single({C}) : single({D});
|
||||||
|
case Phone::Schwa: return single({B, C});
|
||||||
|
case Phone::AE: return single({C});
|
||||||
|
case Phone::EY: return diphthong({C}, {B});
|
||||||
|
case Phone::AY: return duration < 20_cs ? diphthong({C}, {B}) : diphthong({D}, {B});
|
||||||
|
case Phone::OW: return diphthong({E}, {F});
|
||||||
|
case Phone::AW: return duration < 30_cs ? diphthong({C}, {E}) : diphthong({D}, {E});
|
||||||
|
case Phone::OY: return diphthong({E}, {B});
|
||||||
|
case Phone::ER: return duration < 7_cs ? like(Phone::Schwa) : single({E});
|
||||||
|
|
||||||
|
case Phone::P:
|
||||||
|
case Phone::B: return plosive({A}, any);
|
||||||
|
case Phone::T:
|
||||||
|
case Phone::D: return plosive({B, F}, anyOpen);
|
||||||
|
case Phone::K:
|
||||||
|
case Phone::G: return plosive({B, C, E, F, H}, anyOpen);
|
||||||
|
case Phone::CH:
|
||||||
|
case Phone::JH: return single({B, F});
|
||||||
|
case Phone::F:
|
||||||
|
case Phone::V: return single({G});
|
||||||
|
case Phone::TH:
|
||||||
|
case Phone::DH:
|
||||||
|
case Phone::S:
|
||||||
|
case Phone::Z:
|
||||||
|
case Phone::SH:
|
||||||
|
case Phone::ZH: return single({B, F});
|
||||||
|
case Phone::HH: return single(any); // think "m-hm"
|
||||||
|
case Phone::M: return single({A});
|
||||||
|
case Phone::N: return single({B, C, F, H});
|
||||||
|
case Phone::NG: return single({B, C, E, F});
|
||||||
|
case Phone::L: return duration < 20_cs ? single({B, E, F, H}) : single({H});
|
||||||
|
case Phone::R: return single({B, E, F});
|
||||||
|
case Phone::Y: return single({B, C, F});
|
||||||
|
case Phone::W: return single({F});
|
||||||
|
|
||||||
|
case Phone::Breath:
|
||||||
|
case Phone::Cough:
|
||||||
|
case Phone::Smack: return single({C});
|
||||||
|
case Phone::Noise: return single({B});
|
||||||
|
|
||||||
|
default: throw std::invalid_argument("Unexpected phone.");
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <set>
|
#include <set>
|
||||||
#include "core/Shape.h"
|
|
||||||
#include "time/Timeline.h"
|
#include "core/phone.h"
|
||||||
#include "core/Phone.h"
|
#include "core/shape.h"
|
||||||
|
#include "time/timeline.h"
|
||||||
|
|
||||||
// Returns the basic shape (A-F) that most closely resembles the specified shape.
|
// Returns the basic shape (A-F) that most closely resembles the specified shape.
|
||||||
Shape getBasicShape(Shape shape);
|
Shape getBasicShape(Shape shape);
|
|
@ -1,166 +0,0 @@
|
||||||
#include "animationRules.h"
|
|
||||||
#include <boost/algorithm/clamp.hpp>
|
|
||||||
#include "shapeShorthands.h"
|
|
||||||
#include "tools/array.h"
|
|
||||||
#include "time/ContinuousTimeline.h"
|
|
||||||
|
|
||||||
using std::chrono::duration_cast;
|
|
||||||
using boost::algorithm::clamp;
|
|
||||||
using boost::optional;
|
|
||||||
using std::array;
|
|
||||||
using std::pair;
|
|
||||||
using std::map;
|
|
||||||
|
|
||||||
constexpr size_t shapeValueCount = static_cast<size_t>(Shape::EndSentinel);
|
|
||||||
|
|
||||||
Shape getBasicShape(Shape shape) {
|
|
||||||
static constexpr array<Shape, shapeValueCount> basicShapes =
|
|
||||||
make_array(A, B, C, D, E, F, A, C, A);
|
|
||||||
return basicShapes[static_cast<size_t>(shape)];
|
|
||||||
}
|
|
||||||
|
|
||||||
Shape relax(Shape shape) {
|
|
||||||
static constexpr array<Shape, shapeValueCount> relaxedShapes =
|
|
||||||
make_array(A, B, B, C, C, B, X, B, X);
|
|
||||||
return relaxedShapes[static_cast<size_t>(shape)];
|
|
||||||
}
|
|
||||||
|
|
||||||
Shape getClosestShape(Shape reference, ShapeSet shapes) {
|
|
||||||
if (shapes.empty()) {
|
|
||||||
throw std::invalid_argument("Cannot select from empty set of shapes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A matrix that for each shape contains all shapes in ascending order of effort required to
|
|
||||||
// move to them
|
|
||||||
constexpr static array<array<Shape, shapeValueCount>, shapeValueCount> effortMatrix = make_array(
|
|
||||||
/* A */ make_array(A, X, G, B, C, H, E, D, F),
|
|
||||||
/* B */ make_array(B, G, A, X, C, H, E, D, F),
|
|
||||||
/* C */ make_array(C, H, B, G, D, A, X, E, F),
|
|
||||||
/* D */ make_array(D, C, H, B, G, A, X, E, F),
|
|
||||||
/* E */ make_array(E, C, H, B, G, A, X, D, F),
|
|
||||||
/* F */ make_array(F, B, G, A, X, C, H, E, D),
|
|
||||||
/* G */ make_array(G, A, B, C, H, X, E, D, F),
|
|
||||||
/* H */ make_array(H, C, B, G, D, A, X, E, F), // Like C
|
|
||||||
/* X */ make_array(X, A, G, B, C, H, E, D, F) // Like A
|
|
||||||
);
|
|
||||||
|
|
||||||
auto& closestShapes = effortMatrix.at(static_cast<size_t>(reference));
|
|
||||||
for (Shape closestShape : closestShapes) {
|
|
||||||
if (shapes.find(closestShape) != shapes.end()) {
|
|
||||||
return closestShape;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw std::invalid_argument("Unable to find closest shape.");
|
|
||||||
}
|
|
||||||
|
|
||||||
optional<pair<Shape, TweenTiming>> getTween(Shape first, Shape second) {
|
|
||||||
// Note that most of the following rules work in one direction only.
|
|
||||||
// That's because in animation, the mouth should usually "pop" open without inbetweens,
|
|
||||||
// then close slowly.
|
|
||||||
static const map<pair<Shape, Shape>, pair<Shape, TweenTiming>> lookup {
|
|
||||||
{ { D, A }, { C, TweenTiming::Early } },
|
|
||||||
{ { D, B }, { C, TweenTiming::Centered } },
|
|
||||||
{ { D, G }, { C, TweenTiming::Early } },
|
|
||||||
{ { D, X }, { C, TweenTiming::Late } },
|
|
||||||
{ { C, F }, { E, TweenTiming::Centered } }, { { F, C }, { E, TweenTiming::Centered } },
|
|
||||||
{ { D, F }, { E, TweenTiming::Centered } },
|
|
||||||
{ { H, F }, { E, TweenTiming::Late } }, { { F, H }, { E, TweenTiming::Early } }
|
|
||||||
};
|
|
||||||
const auto it = lookup.find({ first, second });
|
|
||||||
return it != lookup.end() ? it->second : optional<pair<Shape, TweenTiming>>();
|
|
||||||
}
|
|
||||||
|
|
||||||
Timeline<ShapeSet> getShapeSets(Phone phone, centiseconds duration, centiseconds previousDuration) {
|
|
||||||
// Returns a timeline with a single shape set
|
|
||||||
const auto single = [duration](ShapeSet value) {
|
|
||||||
return Timeline<ShapeSet> { { 0_cs, duration, value } };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns a timeline with two shape sets, timed as a diphthong
|
|
||||||
const auto diphthong = [duration](ShapeSet first, ShapeSet second) {
|
|
||||||
const centiseconds firstDuration = duration_cast<centiseconds>(duration * 0.6);
|
|
||||||
return Timeline<ShapeSet> {
|
|
||||||
{ 0_cs, firstDuration, first },
|
|
||||||
{ firstDuration, duration, second }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns a timeline with two shape sets, timed as a plosive
|
|
||||||
const auto plosive = [duration, previousDuration](ShapeSet first, ShapeSet second) {
|
|
||||||
const centiseconds minOcclusionDuration = 4_cs;
|
|
||||||
const centiseconds maxOcclusionDuration = 12_cs;
|
|
||||||
const centiseconds occlusionDuration =
|
|
||||||
clamp(previousDuration / 2, minOcclusionDuration, maxOcclusionDuration);
|
|
||||||
return Timeline<ShapeSet> {
|
|
||||||
{ -occlusionDuration, 0_cs, first },
|
|
||||||
{ 0_cs, duration, second }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Returns the result of `getShapeSets` when called with identical arguments
|
|
||||||
// except for a different phone.
|
|
||||||
const auto like = [duration, previousDuration](Phone referencePhone) {
|
|
||||||
return getShapeSets(referencePhone, duration, previousDuration);
|
|
||||||
};
|
|
||||||
|
|
||||||
static const ShapeSet any { A, B, C, D, E, F, G, H, X };
|
|
||||||
static const ShapeSet anyOpen { B, C, D, E, F, G, H };
|
|
||||||
|
|
||||||
// Note:
|
|
||||||
// The shapes {A, B, G, X} are very similar. You should avoid regular shape sets containing more
|
|
||||||
// than one of these shapes.
|
|
||||||
// Otherwise, the resulting shape may be more or less random and might not be a good fit.
|
|
||||||
// As an exception, a very flexible rule may contain *all* these shapes.
|
|
||||||
|
|
||||||
switch (phone) {
|
|
||||||
case Phone::AO: return single({ E });
|
|
||||||
case Phone::AA: return single({ D });
|
|
||||||
case Phone::IY: return single({ B });
|
|
||||||
case Phone::UW: return single({ F });
|
|
||||||
case Phone::EH: return single({ C });
|
|
||||||
case Phone::IH: return single({ B });
|
|
||||||
case Phone::UH: return single({ F });
|
|
||||||
case Phone::AH: return duration < 20_cs ? single({ C }) : single({ D });
|
|
||||||
case Phone::Schwa: return single({ B, C });
|
|
||||||
case Phone::AE: return single({ C });
|
|
||||||
case Phone::EY: return diphthong({ C }, { B });
|
|
||||||
case Phone::AY: return duration < 20_cs ? diphthong({ C }, { B }) : diphthong({ D }, { B });
|
|
||||||
case Phone::OW: return diphthong({ E }, { F });
|
|
||||||
case Phone::AW: return duration < 30_cs ? diphthong({ C }, { E }) : diphthong({ D }, { E });
|
|
||||||
case Phone::OY: return diphthong({ E }, { B });
|
|
||||||
case Phone::ER: return duration < 7_cs ? like(Phone::Schwa) : single({ E });
|
|
||||||
|
|
||||||
case Phone::P:
|
|
||||||
case Phone::B: return plosive({ A }, any);
|
|
||||||
case Phone::T:
|
|
||||||
case Phone::D: return plosive({ B, F }, anyOpen);
|
|
||||||
case Phone::K:
|
|
||||||
case Phone::G: return plosive({ B, C, E, F, H }, anyOpen);
|
|
||||||
case Phone::CH:
|
|
||||||
case Phone::JH: return single({ B, F });
|
|
||||||
case Phone::F:
|
|
||||||
case Phone::V: return single({ G });
|
|
||||||
case Phone::TH:
|
|
||||||
case Phone::DH:
|
|
||||||
case Phone::S:
|
|
||||||
case Phone::Z:
|
|
||||||
case Phone::SH:
|
|
||||||
case Phone::ZH: return single({ B, F });
|
|
||||||
case Phone::HH: return single(any); // think "m-hm"
|
|
||||||
case Phone::M: return single({ A });
|
|
||||||
case Phone::N: return single({ B, C, F, H });
|
|
||||||
case Phone::NG: return single({ B, C, E, F });
|
|
||||||
case Phone::L: return duration < 20_cs ? single({ B, E, F, H }) : single({ H });
|
|
||||||
case Phone::R: return single({ B, E, F });
|
|
||||||
case Phone::Y: return single({ B, C, F });
|
|
||||||
case Phone::W: return single({ F });
|
|
||||||
|
|
||||||
case Phone::Breath:
|
|
||||||
case Phone::Cough:
|
|
||||||
case Phone::Smack: return single({ C });
|
|
||||||
case Phone::Noise: return single({ B });
|
|
||||||
|
|
||||||
default: throw std::invalid_argument("Unexpected phone.");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
#include "mouth-animation.h"
|
||||||
|
|
||||||
|
#include "pause-animation.h"
|
||||||
|
#include "rough-animation.h"
|
||||||
|
#include "shape-rule.h"
|
||||||
|
#include "static-segments.h"
|
||||||
|
#include "target-shape-set.h"
|
||||||
|
#include "time/timed-logging.h"
|
||||||
|
#include "timing-optimization.h"
|
||||||
|
#include "tweening.h"
|
||||||
|
|
||||||
|
JoiningContinuousTimeline<Shape> animate(
|
||||||
|
const BoundedTimeline<Phone>& phones, const ShapeSet& targetShapeSet
|
||||||
|
) {
|
||||||
|
// Create timeline of shape rules
|
||||||
|
ContinuousTimeline<ShapeRule> shapeRules = getShapeRules(phones);
|
||||||
|
|
||||||
|
// Modify shape rules to only contain allowed shapes -- plus X, which is needed for pauses and
|
||||||
|
// will be replaced later
|
||||||
|
ShapeSet targetShapeSetPlusX = targetShapeSet;
|
||||||
|
targetShapeSetPlusX.insert(Shape::X);
|
||||||
|
shapeRules = convertToTargetShapeSet(shapeRules, targetShapeSetPlusX);
|
||||||
|
|
||||||
|
// Animate in multiple steps
|
||||||
|
const auto performMainAnimationSteps = [&targetShapeSet](const auto& shapeRules) {
|
||||||
|
JoiningContinuousTimeline<Shape> animation = animateRough(shapeRules);
|
||||||
|
animation = optimizeTiming(animation);
|
||||||
|
animation = animatePauses(animation);
|
||||||
|
animation = insertTweens(animation);
|
||||||
|
animation = convertToTargetShapeSet(animation, targetShapeSet);
|
||||||
|
return animation;
|
||||||
|
};
|
||||||
|
const JoiningContinuousTimeline<Shape> result =
|
||||||
|
avoidStaticSegments(shapeRules, performMainAnimationSteps);
|
||||||
|
|
||||||
|
for (const auto& timedShape : result) {
|
||||||
|
logTimedEvent("shape", timedShape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/phone.h"
|
||||||
|
#include "core/shape.h"
|
||||||
|
#include "target-shape-set.h"
|
||||||
|
#include "time/continuous-timeline.h"
|
||||||
|
|
||||||
|
JoiningContinuousTimeline<Shape> animate(
|
||||||
|
const BoundedTimeline<Phone>& phones, const ShapeSet& targetShapeSet
|
||||||
|
);
|
|
@ -1,41 +0,0 @@
|
||||||
#include "mouthAnimation.h"
|
|
||||||
#include "time/timedLogging.h"
|
|
||||||
#include "ShapeRule.h"
|
|
||||||
#include "roughAnimation.h"
|
|
||||||
#include "pauseAnimation.h"
|
|
||||||
#include "tweening.h"
|
|
||||||
#include "timingOptimization.h"
|
|
||||||
#include "targetShapeSet.h"
|
|
||||||
#include "staticSegments.h"
|
|
||||||
|
|
||||||
JoiningContinuousTimeline<Shape> animate(
|
|
||||||
const BoundedTimeline<Phone>& phones,
|
|
||||||
const ShapeSet& targetShapeSet
|
|
||||||
) {
|
|
||||||
// Create timeline of shape rules
|
|
||||||
ContinuousTimeline<ShapeRule> shapeRules = getShapeRules(phones);
|
|
||||||
|
|
||||||
// Modify shape rules to only contain allowed shapes -- plus X, which is needed for pauses and
|
|
||||||
// will be replaced later
|
|
||||||
ShapeSet targetShapeSetPlusX = targetShapeSet;
|
|
||||||
targetShapeSetPlusX.insert(Shape::X);
|
|
||||||
shapeRules = convertToTargetShapeSet(shapeRules, targetShapeSetPlusX);
|
|
||||||
|
|
||||||
// Animate in multiple steps
|
|
||||||
const auto performMainAnimationSteps = [&targetShapeSet](const auto& shapeRules) {
|
|
||||||
JoiningContinuousTimeline<Shape> animation = animateRough(shapeRules);
|
|
||||||
animation = optimizeTiming(animation);
|
|
||||||
animation = animatePauses(animation);
|
|
||||||
animation = insertTweens(animation);
|
|
||||||
animation = convertToTargetShapeSet(animation, targetShapeSet);
|
|
||||||
return animation;
|
|
||||||
};
|
|
||||||
const JoiningContinuousTimeline<Shape> result =
|
|
||||||
avoidStaticSegments(shapeRules, performMainAnimationSteps);
|
|
||||||
|
|
||||||
for (const auto& timedShape : result) {
|
|
||||||
logTimedEvent("shape", timedShape);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "core/Phone.h"
|
|
||||||
#include "core/Shape.h"
|
|
||||||
#include "time/ContinuousTimeline.h"
|
|
||||||
#include "targetShapeSet.h"
|
|
||||||
|
|
||||||
JoiningContinuousTimeline<Shape> animate(
|
|
||||||
const BoundedTimeline<Phone>& phones,
|
|
||||||
const ShapeSet& targetShapeSet
|
|
||||||
);
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
#include "pause-animation.h"
|
||||||
|
|
||||||
|
#include "animation-rules.h"
|
||||||
|
|
||||||
|
Shape getPauseShape(Shape previous, Shape next, centiseconds duration) {
|
||||||
|
// For very short pauses: Just hold the previous shape
|
||||||
|
if (duration < 12_cs) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For short pauses: Relax the mouth
|
||||||
|
if (duration <= 35_cs) {
|
||||||
|
// It looks odd if the pause shape is identical to the next shape.
|
||||||
|
// Make sure we find a relaxed shape that's different from the next one.
|
||||||
|
for (Shape currentRelaxedShape = previous;;) {
|
||||||
|
const Shape nextRelaxedShape = relax(currentRelaxedShape);
|
||||||
|
if (nextRelaxedShape != next) {
|
||||||
|
return nextRelaxedShape;
|
||||||
|
}
|
||||||
|
if (nextRelaxedShape == currentRelaxedShape) {
|
||||||
|
// We're going in circles
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentRelaxedShape = nextRelaxedShape;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For longer pauses: Close the mouth
|
||||||
|
return Shape::X;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoiningContinuousTimeline<Shape> animatePauses(const JoiningContinuousTimeline<Shape>& animation) {
|
||||||
|
JoiningContinuousTimeline<Shape> result(animation);
|
||||||
|
|
||||||
|
for_each_adjacent(
|
||||||
|
animation.begin(),
|
||||||
|
animation.end(),
|
||||||
|
[&](const Timed<Shape>& previous, const Timed<Shape>& pause, const Timed<Shape>& next) {
|
||||||
|
if (pause.getValue() != Shape::X) return;
|
||||||
|
|
||||||
|
result.set(
|
||||||
|
pause.getTimeRange(),
|
||||||
|
getPauseShape(previous.getValue(), next.getValue(), pause.getDuration())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "core/Shape.h"
|
#include "core/shape.h"
|
||||||
#include "time/ContinuousTimeline.h"
|
#include "time/continuous-timeline.h"
|
||||||
|
|
||||||
// Takes an existing animation and modifies the pauses (X shapes) to look better.
|
// Takes an existing animation and modifies the pauses (X shapes) to look better.
|
||||||
JoiningContinuousTimeline<Shape> animatePauses(const JoiningContinuousTimeline<Shape>& animation);
|
JoiningContinuousTimeline<Shape> animatePauses(const JoiningContinuousTimeline<Shape>& animation);
|
|
@ -1,48 +0,0 @@
|
||||||
#include "pauseAnimation.h"
|
|
||||||
#include "animationRules.h"
|
|
||||||
|
|
||||||
Shape getPauseShape(Shape previous, Shape next, centiseconds duration) {
|
|
||||||
// For very short pauses: Just hold the previous shape
|
|
||||||
if (duration < 12_cs) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For short pauses: Relax the mouth
|
|
||||||
if (duration <= 35_cs) {
|
|
||||||
// It looks odd if the pause shape is identical to the next shape.
|
|
||||||
// Make sure we find a relaxed shape that's different from the next one.
|
|
||||||
for (Shape currentRelaxedShape = previous;;) {
|
|
||||||
const Shape nextRelaxedShape = relax(currentRelaxedShape);
|
|
||||||
if (nextRelaxedShape != next) {
|
|
||||||
return nextRelaxedShape;
|
|
||||||
}
|
|
||||||
if (nextRelaxedShape == currentRelaxedShape) {
|
|
||||||
// We're going in circles
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
currentRelaxedShape = nextRelaxedShape;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For longer pauses: Close the mouth
|
|
||||||
return Shape::X;
|
|
||||||
}
|
|
||||||
|
|
||||||
JoiningContinuousTimeline<Shape> animatePauses(const JoiningContinuousTimeline<Shape>& animation) {
|
|
||||||
JoiningContinuousTimeline<Shape> result(animation);
|
|
||||||
|
|
||||||
for_each_adjacent(
|
|
||||||
animation.begin(),
|
|
||||||
animation.end(),
|
|
||||||
[&](const Timed<Shape>& previous, const Timed<Shape>& pause, const Timed<Shape>& next) {
|
|
||||||
if (pause.getValue() != Shape::X) return;
|
|
||||||
|
|
||||||
result.set(
|
|
||||||
pause.getTimeRange(),
|
|
||||||
getPauseShape(previous.getValue(), next.getValue(), pause.getDuration())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
#include "rough-animation.h"
|
||||||
|
|
||||||
|
#include <boost/optional.hpp>
|
||||||
|
|
||||||
|
// Create timeline of shapes using a bidirectional algorithm.
|
||||||
|
// Here's a rough sketch:
|
||||||
|
//
|
||||||
|
// * Most consonants result in shape sets with multiple options; most vowels have only one shape
|
||||||
|
// option.
|
||||||
|
// * When speaking, we tend to slur mouth shapes into each other. So we animate from start to end,
|
||||||
|
// always choosing a shape from the current set that resembles the last shape and is somewhat
|
||||||
|
// relaxed.
|
||||||
|
// * When speaking, we anticipate vowels, trying to form their shape before the actual vowel.
|
||||||
|
// So whenever we come across a one-shape vowel, we backtrack a little, spreading that shape to
|
||||||
|
// the left.
|
||||||
|
JoiningContinuousTimeline<Shape> animateRough(const ContinuousTimeline<ShapeRule>& shapeRules) {
|
||||||
|
JoiningContinuousTimeline<Shape> animation(shapeRules.getRange(), Shape::X);
|
||||||
|
|
||||||
|
Shape referenceShape = Shape::X;
|
||||||
|
// Animate forwards
|
||||||
|
centiseconds lastAnticipatedShapeStart = -1_cs;
|
||||||
|
for (auto it = shapeRules.begin(); it != shapeRules.end(); ++it) {
|
||||||
|
const ShapeRule shapeRule = it->getValue();
|
||||||
|
const Shape shape = getClosestShape(referenceShape, shapeRule.shapeSet);
|
||||||
|
animation.set(it->getTimeRange(), shape);
|
||||||
|
const bool anticipateShape =
|
||||||
|
shapeRule.phone && isVowel(*shapeRule.phone) && shapeRule.shapeSet.size() == 1;
|
||||||
|
if (anticipateShape) {
|
||||||
|
// Animate backwards a little
|
||||||
|
const Shape anticipatedShape = shape;
|
||||||
|
const centiseconds anticipatedShapeStart = it->getStart();
|
||||||
|
referenceShape = anticipatedShape;
|
||||||
|
for (auto reverseIt = it; reverseIt != shapeRules.begin();) {
|
||||||
|
--reverseIt;
|
||||||
|
|
||||||
|
// Make sure we haven't animated too far back
|
||||||
|
centiseconds anticipatingShapeStart = reverseIt->getStart();
|
||||||
|
if (anticipatingShapeStart == lastAnticipatedShapeStart) break;
|
||||||
|
const centiseconds maxAnticipationDuration = 20_cs;
|
||||||
|
const centiseconds anticipationDuration =
|
||||||
|
anticipatedShapeStart - anticipatingShapeStart;
|
||||||
|
if (anticipationDuration > maxAnticipationDuration) break;
|
||||||
|
|
||||||
|
// Overwrite forward-animated shape with backwards-animated, anticipating shape
|
||||||
|
const Shape anticipatingShape =
|
||||||
|
getClosestShape(referenceShape, reverseIt->getValue().shapeSet);
|
||||||
|
animation.set(reverseIt->getTimeRange(), anticipatingShape);
|
||||||
|
|
||||||
|
// Make sure the new, backwards-animated shape still resembles the anticipated shape
|
||||||
|
if (getBasicShape(anticipatingShape) != getBasicShape(anticipatedShape)) break;
|
||||||
|
|
||||||
|
referenceShape = anticipatingShape;
|
||||||
|
}
|
||||||
|
lastAnticipatedShapeStart = anticipatedShapeStart;
|
||||||
|
}
|
||||||
|
referenceShape = anticipateShape ? shape : relax(shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return animation;
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "ShapeRule.h"
|
#include "shape-rule.h"
|
||||||
|
|
||||||
// Does a rough animation (no tweening, special pause animation, etc.) using a bidirectional
|
// Does a rough animation (no tweening, special pause animation, etc.) using a bidirectional
|
||||||
// algorithm.
|
// algorithm.
|
|
@ -1,60 +0,0 @@
|
||||||
#include "roughAnimation.h"
|
|
||||||
#include <boost/optional.hpp>
|
|
||||||
|
|
||||||
// Create timeline of shapes using a bidirectional algorithm.
|
|
||||||
// Here's a rough sketch:
|
|
||||||
//
|
|
||||||
// * Most consonants result in shape sets with multiple options; most vowels have only one shape
|
|
||||||
// option.
|
|
||||||
// * When speaking, we tend to slur mouth shapes into each other. So we animate from start to end,
|
|
||||||
// always choosing a shape from the current set that resembles the last shape and is somewhat
|
|
||||||
// relaxed.
|
|
||||||
// * When speaking, we anticipate vowels, trying to form their shape before the actual vowel.
|
|
||||||
// So whenever we come across a one-shape vowel, we backtrack a little, spreading that shape to
|
|
||||||
// the left.
|
|
||||||
JoiningContinuousTimeline<Shape> animateRough(const ContinuousTimeline<ShapeRule>& shapeRules) {
|
|
||||||
JoiningContinuousTimeline<Shape> animation(shapeRules.getRange(), Shape::X);
|
|
||||||
|
|
||||||
Shape referenceShape = Shape::X;
|
|
||||||
// Animate forwards
|
|
||||||
centiseconds lastAnticipatedShapeStart = -1_cs;
|
|
||||||
for (auto it = shapeRules.begin(); it != shapeRules.end(); ++it) {
|
|
||||||
const ShapeRule shapeRule = it->getValue();
|
|
||||||
const Shape shape = getClosestShape(referenceShape, shapeRule.shapeSet);
|
|
||||||
animation.set(it->getTimeRange(), shape);
|
|
||||||
const bool anticipateShape = shapeRule.phone
|
|
||||||
&& isVowel(*shapeRule.phone)
|
|
||||||
&& shapeRule.shapeSet.size() == 1;
|
|
||||||
if (anticipateShape) {
|
|
||||||
// Animate backwards a little
|
|
||||||
const Shape anticipatedShape = shape;
|
|
||||||
const centiseconds anticipatedShapeStart = it->getStart();
|
|
||||||
referenceShape = anticipatedShape;
|
|
||||||
for (auto reverseIt = it; reverseIt != shapeRules.begin();) {
|
|
||||||
--reverseIt;
|
|
||||||
|
|
||||||
// Make sure we haven't animated too far back
|
|
||||||
centiseconds anticipatingShapeStart = reverseIt->getStart();
|
|
||||||
if (anticipatingShapeStart == lastAnticipatedShapeStart) break;
|
|
||||||
const centiseconds maxAnticipationDuration = 20_cs;
|
|
||||||
const centiseconds anticipationDuration =
|
|
||||||
anticipatedShapeStart - anticipatingShapeStart;
|
|
||||||
if (anticipationDuration > maxAnticipationDuration) break;
|
|
||||||
|
|
||||||
// Overwrite forward-animated shape with backwards-animated, anticipating shape
|
|
||||||
const Shape anticipatingShape =
|
|
||||||
getClosestShape(referenceShape, reverseIt->getValue().shapeSet);
|
|
||||||
animation.set(reverseIt->getTimeRange(), anticipatingShape);
|
|
||||||
|
|
||||||
// Make sure the new, backwards-animated shape still resembles the anticipated shape
|
|
||||||
if (getBasicShape(anticipatingShape) != getBasicShape(anticipatedShape)) break;
|
|
||||||
|
|
||||||
referenceShape = anticipatingShape;
|
|
||||||
}
|
|
||||||
lastAnticipatedShapeStart = anticipatedShapeStart;
|
|
||||||
}
|
|
||||||
referenceShape = anticipateShape ? shape : relax(shape);
|
|
||||||
}
|
|
||||||
|
|
||||||
return animation;
|
|
||||||
}
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
#include "shape-rule.h"
|
||||||
|
|
||||||
|
#include <boost/range/adaptor/transformed.hpp>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "time/continuous-timeline.h"
|
||||||
|
|
||||||
|
using boost::optional;
|
||||||
|
using boost::adaptors::transformed;
|
||||||
|
|
||||||
|
template <typename T, bool AutoJoin>
|
||||||
|
ContinuousTimeline<optional<T>, AutoJoin> boundedTimelinetoContinuousOptional(
|
||||||
|
const BoundedTimeline<T, AutoJoin>& timeline
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
timeline.getRange(),
|
||||||
|
boost::none,
|
||||||
|
timeline | transformed([](const Timed<T>& timedValue) {
|
||||||
|
return Timed<optional<T>>(timedValue.getTimeRange(), timedValue.getValue());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ShapeRule::ShapeRule(ShapeSet shapeSet, optional<Phone> phone, TimeRange phoneTiming) :
|
||||||
|
shapeSet(std::move(shapeSet)),
|
||||||
|
phone(std::move(phone)),
|
||||||
|
phoneTiming(phoneTiming) {}
|
||||||
|
|
||||||
|
ShapeRule ShapeRule::getInvalid() {
|
||||||
|
return {{}, boost::none, {0_cs, 0_cs}};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShapeRule::operator==(const ShapeRule& rhs) const {
|
||||||
|
return shapeSet == rhs.shapeSet && phone == rhs.phone && phoneTiming == rhs.phoneTiming;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShapeRule::operator!=(const ShapeRule& rhs) const {
|
||||||
|
return !operator==(rhs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShapeRule::operator<(const ShapeRule& rhs) const {
|
||||||
|
return shapeSet < rhs.shapeSet || phone < rhs.phone
|
||||||
|
|| phoneTiming.getStart() < rhs.phoneTiming.getStart()
|
||||||
|
|| phoneTiming.getEnd() < rhs.phoneTiming.getEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
ContinuousTimeline<ShapeRule> getShapeRules(const BoundedTimeline<Phone>& phones) {
|
||||||
|
// Convert to continuous timeline so that silences aren't skipped when iterating
|
||||||
|
auto continuousPhones = boundedTimelinetoContinuousOptional(phones);
|
||||||
|
|
||||||
|
// Create timeline of shape rules
|
||||||
|
ContinuousTimeline<ShapeRule> shapeRules(
|
||||||
|
phones.getRange(), {{Shape::X}, boost::none, {0_cs, 0_cs}}
|
||||||
|
);
|
||||||
|
centiseconds previousDuration = 0_cs;
|
||||||
|
for (const auto& timedPhone : continuousPhones) {
|
||||||
|
optional<Phone> phone = timedPhone.getValue();
|
||||||
|
const centiseconds duration = timedPhone.getDuration();
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
// Animate one phone
|
||||||
|
Timeline<ShapeSet> phoneShapeSets = getShapeSets(*phone, duration, previousDuration);
|
||||||
|
|
||||||
|
// Result timing is relative to phone. Make absolute.
|
||||||
|
phoneShapeSets.shift(timedPhone.getStart());
|
||||||
|
|
||||||
|
// Copy to timeline.
|
||||||
|
// Later shape sets may overwrite earlier ones if overlapping.
|
||||||
|
for (const auto& timedShapeSet : phoneShapeSets) {
|
||||||
|
shapeRules.set(
|
||||||
|
timedShapeSet.getTimeRange(),
|
||||||
|
ShapeRule(timedShapeSet.getValue(), phone, timedPhone.getTimeRange())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDuration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shapeRules;
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "animation-rules.h"
|
||||||
|
#include "core/phone.h"
|
||||||
|
#include "time/bounded-timeline.h"
|
||||||
|
#include "time/continuous-timeline.h"
|
||||||
|
#include "time/time-range.h"
|
||||||
|
|
||||||
|
struct ShapeRule {
|
||||||
|
ShapeSet shapeSet;
|
||||||
|
boost::optional<Phone> phone;
|
||||||
|
TimeRange phoneTiming;
|
||||||
|
|
||||||
|
ShapeRule(ShapeSet shapeSet, boost::optional<Phone> phone, TimeRange phoneTiming);
|
||||||
|
|
||||||
|
static ShapeRule getInvalid();
|
||||||
|
|
||||||
|
bool operator==(const ShapeRule&) const;
|
||||||
|
bool operator!=(const ShapeRule&) const;
|
||||||
|
bool operator<(const ShapeRule&) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns shape rules for an entire timeline of phones.
|
||||||
|
ContinuousTimeline<ShapeRule> getShapeRules(const BoundedTimeline<Phone>& phones);
|
|
@ -1,6 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "core/Shape.h"
|
#include "core/shape.h"
|
||||||
|
|
||||||
constexpr Shape A = Shape::A;
|
constexpr Shape A = Shape::A;
|
||||||
constexpr Shape B = Shape::B;
|
constexpr Shape B = Shape::B;
|
|
@ -0,0 +1,239 @@
|
||||||
|
#include "static-segments.h"
|
||||||
|
|
||||||
|
#include <numeric>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "tools/next-combination.h"
|
||||||
|
|
||||||
|
using std::vector;
|
||||||
|
|
||||||
|
int getSyllableCount(const ContinuousTimeline<ShapeRule>& shapeRules, TimeRange timeRange) {
|
||||||
|
if (timeRange.empty()) return 0;
|
||||||
|
|
||||||
|
const auto begin = shapeRules.find(timeRange.getStart());
|
||||||
|
const auto end = std::next(shapeRules.find(timeRange.getEnd(), FindMode::SampleLeft));
|
||||||
|
|
||||||
|
// Treat every vowel as one syllable
|
||||||
|
int syllableCount = 0;
|
||||||
|
for (auto it = begin; it != end; ++it) {
|
||||||
|
const ShapeRule shapeRule = it->getValue();
|
||||||
|
|
||||||
|
// Disregard phones that are mostly outside the specified time range.
|
||||||
|
const centiseconds phoneMiddle = shapeRule.phoneTiming.getMiddle();
|
||||||
|
if (phoneMiddle < timeRange.getStart() || phoneMiddle >= timeRange.getEnd()) continue;
|
||||||
|
|
||||||
|
auto phone = shapeRule.phone;
|
||||||
|
if (phone && isVowel(*phone)) {
|
||||||
|
++syllableCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return syllableCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A static segment is a prolonged period during which the mouth shape doesn't change
|
||||||
|
vector<TimeRange> getStaticSegments(
|
||||||
|
const ContinuousTimeline<ShapeRule>& shapeRules,
|
||||||
|
const JoiningContinuousTimeline<Shape>& animation
|
||||||
|
) {
|
||||||
|
// A static segment must contain a certain number of syllables to look distractingly static
|
||||||
|
const int minSyllableCount = 3;
|
||||||
|
// It must also have a minimum duration. The same number of syllables in fast speech usually
|
||||||
|
// looks good.
|
||||||
|
const centiseconds minDuration = 75_cs;
|
||||||
|
|
||||||
|
vector<TimeRange> result;
|
||||||
|
for (const auto& timedShape : animation) {
|
||||||
|
const TimeRange timeRange = timedShape.getTimeRange();
|
||||||
|
const bool isStatic = timeRange.getDuration() >= minDuration
|
||||||
|
&& getSyllableCount(shapeRules, timeRange) >= minSyllableCount;
|
||||||
|
if (isStatic) {
|
||||||
|
result.push_back(timeRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicates whether this shape rule can potentially be replaced by a modified version that breaks
|
||||||
|
// up long static segments
|
||||||
|
bool canChange(const ShapeRule& rule) {
|
||||||
|
return rule.phone && isVowel(*rule.phone) && rule.shapeSet.size() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a new shape rule that is identical to the specified one, except that it leads to a
|
||||||
|
// slightly different visualization
|
||||||
|
ShapeRule getChangedShapeRule(const ShapeRule& rule) {
|
||||||
|
assert(canChange(rule));
|
||||||
|
|
||||||
|
ShapeRule result(rule);
|
||||||
|
// So far, I've only encountered B as a static shape.
|
||||||
|
// If there is ever a problem with another static shape, this function can easily be extended.
|
||||||
|
if (rule.shapeSet == ShapeSet{Shape::B}) {
|
||||||
|
result.shapeSet = {Shape::C};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains the start times of all rules to be changed
|
||||||
|
using RuleChanges = vector<centiseconds>;
|
||||||
|
|
||||||
|
// Replaces the indicated shape rules with slightly different ones, breaking up long static segments
|
||||||
|
ContinuousTimeline<ShapeRule> applyChanges(
|
||||||
|
const ContinuousTimeline<ShapeRule>& shapeRules, const RuleChanges& changes
|
||||||
|
) {
|
||||||
|
ContinuousTimeline<ShapeRule> result(shapeRules);
|
||||||
|
for (centiseconds changedRuleStart : changes) {
|
||||||
|
const Timed<ShapeRule> timedOriginalRule = *shapeRules.get(changedRuleStart);
|
||||||
|
const ShapeRule changedRule = getChangedShapeRule(timedOriginalRule.getValue());
|
||||||
|
result.set(timedOriginalRule.getTimeRange(), changedRule);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuleChangeScenario {
|
||||||
|
public:
|
||||||
|
RuleChangeScenario(
|
||||||
|
const ContinuousTimeline<ShapeRule>& originalRules,
|
||||||
|
const RuleChanges& changes,
|
||||||
|
const AnimationFunction& animate
|
||||||
|
) :
|
||||||
|
changedRules(applyChanges(originalRules, changes)),
|
||||||
|
animation(animate(changedRules)),
|
||||||
|
staticSegments(getStaticSegments(changedRules, animation)) {}
|
||||||
|
|
||||||
|
bool isBetterThan(const RuleChangeScenario& rhs) const {
|
||||||
|
// We want zero static segments
|
||||||
|
if (staticSegments.empty() && !rhs.staticSegments.empty()) return true;
|
||||||
|
|
||||||
|
// Short shapes are better than long ones. Minimize sum-of-squares.
|
||||||
|
if (getSumOfShapeDurationSquares() < rhs.getSumOfShapeDurationSquares()) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getStaticSegmentCount() const {
|
||||||
|
return static_cast<int>(staticSegments.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
ContinuousTimeline<ShapeRule> getChangedRules() const {
|
||||||
|
return changedRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
ContinuousTimeline<ShapeRule> changedRules;
|
||||||
|
JoiningContinuousTimeline<Shape> animation;
|
||||||
|
vector<TimeRange> staticSegments;
|
||||||
|
|
||||||
|
double getSumOfShapeDurationSquares() const {
|
||||||
|
return std::accumulate(
|
||||||
|
animation.begin(),
|
||||||
|
animation.end(),
|
||||||
|
0.0,
|
||||||
|
[](const double sum, const Timed<Shape>& timedShape) {
|
||||||
|
const double duration = std::chrono::duration_cast<std::chrono::duration<double>>(
|
||||||
|
timedShape.getDuration()
|
||||||
|
)
|
||||||
|
.count();
|
||||||
|
return sum + duration * duration;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RuleChanges getPossibleRuleChanges(const ContinuousTimeline<ShapeRule>& shapeRules) {
|
||||||
|
RuleChanges result;
|
||||||
|
for (auto it = shapeRules.begin(); it != shapeRules.end(); ++it) {
|
||||||
|
const ShapeRule rule = it->getValue();
|
||||||
|
if (canChange(rule)) {
|
||||||
|
result.push_back(it->getStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ContinuousTimeline<ShapeRule> fixStaticSegmentRules(
|
||||||
|
const ContinuousTimeline<ShapeRule>& shapeRules, const AnimationFunction& animate
|
||||||
|
) {
|
||||||
|
// The complexity of this function is exponential with the number of replacements.
|
||||||
|
// So let's cap that value.
|
||||||
|
const int maxReplacementCount = 3;
|
||||||
|
|
||||||
|
// All potential changes
|
||||||
|
const RuleChanges possibleRuleChanges = getPossibleRuleChanges(shapeRules);
|
||||||
|
|
||||||
|
// Find best solution. Start with a single replacement, then increase as necessary.
|
||||||
|
RuleChangeScenario bestScenario(shapeRules, {}, animate);
|
||||||
|
for (int replacementCount = 1; bestScenario.getStaticSegmentCount() > 0
|
||||||
|
&& replacementCount
|
||||||
|
<= std::min(static_cast<int>(possibleRuleChanges.size()), maxReplacementCount);
|
||||||
|
++replacementCount) {
|
||||||
|
// Only the first <replacementCount> elements of `currentRuleChanges` count
|
||||||
|
auto currentRuleChanges(possibleRuleChanges);
|
||||||
|
do {
|
||||||
|
RuleChangeScenario currentScenario(
|
||||||
|
shapeRules,
|
||||||
|
{currentRuleChanges.begin(), currentRuleChanges.begin() + replacementCount},
|
||||||
|
animate
|
||||||
|
);
|
||||||
|
if (currentScenario.isBetterThan(bestScenario)) {
|
||||||
|
bestScenario = currentScenario;
|
||||||
|
}
|
||||||
|
} while (next_combination(
|
||||||
|
currentRuleChanges.begin(),
|
||||||
|
currentRuleChanges.begin() + replacementCount,
|
||||||
|
currentRuleChanges.end()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestScenario.getChangedRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indicates whether the specified shape rule may result in different shapes depending on context
|
||||||
|
bool isFlexible(const ShapeRule& rule) {
|
||||||
|
return rule.shapeSet.size() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extends the specified time range until it starts and ends with a non-flexible shape rule, if
|
||||||
|
// possible
|
||||||
|
TimeRange extendToFixedRules(
|
||||||
|
const TimeRange& timeRange, const ContinuousTimeline<ShapeRule>& shapeRules
|
||||||
|
) {
|
||||||
|
auto first = shapeRules.find(timeRange.getStart());
|
||||||
|
while (first != shapeRules.begin() && isFlexible(first->getValue())) {
|
||||||
|
--first;
|
||||||
|
}
|
||||||
|
auto last = shapeRules.find(timeRange.getEnd(), FindMode::SampleLeft);
|
||||||
|
while (std::next(last) != shapeRules.end() && isFlexible(last->getValue())) {
|
||||||
|
++last;
|
||||||
|
}
|
||||||
|
return {first->getStart(), last->getEnd()};
|
||||||
|
}
|
||||||
|
|
||||||
|
JoiningContinuousTimeline<Shape> avoidStaticSegments(
|
||||||
|
const ContinuousTimeline<ShapeRule>& shapeRules, const AnimationFunction& animate
|
||||||
|
) {
|
||||||
|
const auto animation = animate(shapeRules);
|
||||||
|
const vector<TimeRange> staticSegments = getStaticSegments(shapeRules, animation);
|
||||||
|
if (staticSegments.empty()) {
|
||||||
|
return animation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify shape rules to eliminate static segments
|
||||||
|
ContinuousTimeline<ShapeRule> fixedShapeRules(shapeRules);
|
||||||
|
for (const TimeRange& staticSegment : staticSegments) {
|
||||||
|
// Extend time range to the left and right so we don't lose adjacent rules that might
|
||||||
|
// influence the animation
|
||||||
|
const TimeRange extendedStaticSegment = extendToFixedRules(staticSegment, shapeRules);
|
||||||
|
|
||||||
|
// Fix shape rules within the static segment
|
||||||
|
const auto fixedSegmentShapeRules = fixStaticSegmentRules(
|
||||||
|
{extendedStaticSegment, ShapeRule::getInvalid(), fixedShapeRules}, animate
|
||||||
|
);
|
||||||
|
for (const auto& timedShapeRule : fixedSegmentShapeRules) {
|
||||||
|
fixedShapeRules.set(timedShapeRule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return animate(fixedShapeRules);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include "core/shape.h"
|
||||||
|
#include "shape-rule.h"
|
||||||
|
#include "time/continuous-timeline.h"
|
||||||
|
|
||||||
|
using AnimationFunction =
|
||||||
|
std::function<JoiningContinuousTimeline<Shape>(const ContinuousTimeline<ShapeRule>&)>;
|
||||||
|
|
||||||
|
// Calls the specified animation function with the specified shape rules.
|
||||||
|
// If the resulting animation contains long static segments, the shape rules are tweaked and
|
||||||
|
// animated again.
|
||||||
|
// Static segments happen rather often.
|
||||||
|
// See
|
||||||
|
// http://animateducated.blogspot.de/2016/10/lip-sync-animation-2.html?showComment=1478861729702#c2940729096183546458.
|
||||||
|
JoiningContinuousTimeline<Shape> avoidStaticSegments(
|
||||||
|
const ContinuousTimeline<ShapeRule>& shapeRules, const AnimationFunction& animate
|
||||||
|
);
|
|
@ -1,239 +0,0 @@
|
||||||
#include "staticSegments.h"
|
|
||||||
#include <vector>
|
|
||||||
#include <numeric>
|
|
||||||
#include "tools/nextCombination.h"
|
|
||||||
|
|
||||||
using std::vector;
|
|
||||||
|
|
||||||
int getSyllableCount(const ContinuousTimeline<ShapeRule>& shapeRules, TimeRange timeRange) {
|
|
||||||
if (timeRange.empty()) return 0;
|
|
||||||
|
|
||||||
const auto begin = shapeRules.find(timeRange.getStart());
|
|
||||||
const auto end = std::next(shapeRules.find(timeRange.getEnd(), FindMode::SampleLeft));
|
|
||||||
|
|
||||||
// Treat every vowel as one syllable
|
|
||||||
int syllableCount = 0;
|
|
||||||
for (auto it = begin; it != end; ++it) {
|
|
||||||
const ShapeRule shapeRule = it->getValue();
|
|
||||||
|
|
||||||
// Disregard phones that are mostly outside the specified time range.
|
|
||||||
const centiseconds phoneMiddle = shapeRule.phoneTiming.getMiddle();
|
|
||||||
if (phoneMiddle < timeRange.getStart() || phoneMiddle >= timeRange.getEnd()) continue;
|
|
||||||
|
|
||||||
auto phone = shapeRule.phone;
|
|
||||||
if (phone && isVowel(*phone)) {
|
|
||||||
++syllableCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return syllableCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A static segment is a prolonged period during which the mouth shape doesn't change
|
|
||||||
vector<TimeRange> getStaticSegments(
|
|
||||||
const ContinuousTimeline<ShapeRule>& shapeRules,
|
|
||||||
const JoiningContinuousTimeline<Shape>& animation
|
|
||||||
) {
|
|
||||||
// A static segment must contain a certain number of syllables to look distractingly static
|
|
||||||
const int minSyllableCount = 3;
|
|
||||||
// It must also have a minimum duration. The same number of syllables in fast speech usually
|
|
||||||
// looks good.
|
|
||||||
const centiseconds minDuration = 75_cs;
|
|
||||||
|
|
||||||
vector<TimeRange> result;
|
|
||||||
for (const auto& timedShape : animation) {
|
|
||||||
const TimeRange timeRange = timedShape.getTimeRange();
|
|
||||||
const bool isStatic = timeRange.getDuration() >= minDuration
|
|
||||||
&& getSyllableCount(shapeRules, timeRange) >= minSyllableCount;
|
|
||||||
if (isStatic) {
|
|
||||||
result.push_back(timeRange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indicates whether this shape rule can potentially be replaced by a modified version that breaks
|
|
||||||
// up long static segments
|
|
||||||
bool canChange(const ShapeRule& rule) {
|
|
||||||
return rule.phone && isVowel(*rule.phone) && rule.shapeSet.size() == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a new shape rule that is identical to the specified one, except that it leads to a
|
|
||||||
// slightly different visualization
|
|
||||||
ShapeRule getChangedShapeRule(const ShapeRule& rule) {
|
|
||||||
assert(canChange(rule));
|
|
||||||
|
|
||||||
ShapeRule result(rule);
|
|
||||||
// So far, I've only encountered B as a static shape.
|
|
||||||
// If there is ever a problem with another static shape, this function can easily be extended.
|
|
||||||
if (rule.shapeSet == ShapeSet { Shape::B }) {
|
|
||||||
result.shapeSet = { Shape::C };
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contains the start times of all rules to be changed
|
|
||||||
using RuleChanges = vector<centiseconds>;
|
|
||||||
|
|
||||||
// Replaces the indicated shape rules with slightly different ones, breaking up long static segments
|
|
||||||
ContinuousTimeline<ShapeRule> applyChanges(
|
|
||||||
const ContinuousTimeline<ShapeRule>& shapeRules,
|
|
||||||
const RuleChanges& changes
|
|
||||||
) {
|
|
||||||
ContinuousTimeline<ShapeRule> result(shapeRules);
|
|
||||||
for (centiseconds changedRuleStart : changes) {
|
|
||||||
const Timed<ShapeRule> timedOriginalRule = *shapeRules.get(changedRuleStart);
|
|
||||||
const ShapeRule changedRule = getChangedShapeRule(timedOriginalRule.getValue());
|
|
||||||
result.set(timedOriginalRule.getTimeRange(), changedRule);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RuleChangeScenario {
|
|
||||||
public:
|
|
||||||
RuleChangeScenario(
|
|
||||||
const ContinuousTimeline<ShapeRule>& originalRules,
|
|
||||||
const RuleChanges& changes,
|
|
||||||
const AnimationFunction& animate
|
|
||||||
) :
|
|
||||||
changedRules(applyChanges(originalRules, changes)),
|
|
||||||
animation(animate(changedRules)),
|
|
||||||
staticSegments(getStaticSegments(changedRules, animation))
|
|
||||||
{}
|
|
||||||
|
|
||||||
bool isBetterThan(const RuleChangeScenario& rhs) const {
|
|
||||||
// We want zero static segments
|
|
||||||
if (staticSegments.empty() && !rhs.staticSegments.empty()) return true;
|
|
||||||
|
|
||||||
// Short shapes are better than long ones. Minimize sum-of-squares.
|
|
||||||
if (getSumOfShapeDurationSquares() < rhs.getSumOfShapeDurationSquares()) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int getStaticSegmentCount() const {
|
|
||||||
return static_cast<int>(staticSegments.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
ContinuousTimeline<ShapeRule> getChangedRules() const {
|
|
||||||
return changedRules;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
ContinuousTimeline<ShapeRule> changedRules;
|
|
||||||
JoiningContinuousTimeline<Shape> animation;
|
|
||||||
vector<TimeRange> staticSegments;
|
|
||||||
|
|
||||||
double getSumOfShapeDurationSquares() const {
|
|
||||||
return std::accumulate(
|
|
||||||
animation.begin(),
|
|
||||||
animation.end(),
|
|
||||||
0.0,
|
|
||||||
[](const double sum, const Timed<Shape>& timedShape) {
|
|
||||||
const double duration = std::chrono::duration_cast<std::chrono::duration<double>>(
|
|
||||||
timedShape.getDuration()
|
|
||||||
).count();
|
|
||||||
return sum + duration * duration;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
RuleChanges getPossibleRuleChanges(const ContinuousTimeline<ShapeRule>& shapeRules) {
|
|
||||||
RuleChanges result;
|
|
||||||
for (auto it = shapeRules.begin(); it != shapeRules.end(); ++it) {
|
|
||||||
const ShapeRule rule = it->getValue();
|
|
||||||
if (canChange(rule)) {
|
|
||||||
result.push_back(it->getStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
ContinuousTimeline<ShapeRule> fixStaticSegmentRules(
|
|
||||||
const ContinuousTimeline<ShapeRule>& shapeRules,
|
|
||||||
const AnimationFunction& animate
|
|
||||||
) {
|
|
||||||
// The complexity of this function is exponential with the number of replacements.
|
|
||||||
// So let's cap that value.
|
|
||||||
const int maxReplacementCount = 3;
|
|
||||||
|
|
||||||
// All potential changes
|
|
||||||
const RuleChanges possibleRuleChanges = getPossibleRuleChanges(shapeRules);
|
|
||||||
|
|
||||||
// Find best solution. Start with a single replacement, then increase as necessary.
|
|
||||||
RuleChangeScenario bestScenario(shapeRules, {}, animate);
|
|
||||||
for (
|
|
||||||
int replacementCount = 1;
|
|
||||||
bestScenario.getStaticSegmentCount() > 0 && replacementCount <= std::min(static_cast<int>(possibleRuleChanges.size()), maxReplacementCount);
|
|
||||||
++replacementCount
|
|
||||||
) {
|
|
||||||
// Only the first <replacementCount> elements of `currentRuleChanges` count
|
|
||||||
auto currentRuleChanges(possibleRuleChanges);
|
|
||||||
do {
|
|
||||||
RuleChangeScenario currentScenario(
|
|
||||||
shapeRules,
|
|
||||||
{ currentRuleChanges.begin(), currentRuleChanges.begin() + replacementCount },
|
|
||||||
animate
|
|
||||||
);
|
|
||||||
if (currentScenario.isBetterThan(bestScenario)) {
|
|
||||||
bestScenario = currentScenario;
|
|
||||||
}
|
|
||||||
} while (next_combination(currentRuleChanges.begin(), currentRuleChanges.begin() + replacementCount, currentRuleChanges.end()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestScenario.getChangedRules();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indicates whether the specified shape rule may result in different shapes depending on context
|
|
||||||
bool isFlexible(const ShapeRule& rule) {
|
|
||||||
return rule.shapeSet.size() > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extends the specified time range until it starts and ends with a non-flexible shape rule, if
|
|
||||||
// possible
|
|
||||||
TimeRange extendToFixedRules(
|
|
||||||
const TimeRange& timeRange,
|
|
||||||
const ContinuousTimeline<ShapeRule>& shapeRules
|
|
||||||
) {
|
|
||||||
auto first = shapeRules.find(timeRange.getStart());
|
|
||||||
while (first != shapeRules.begin() && isFlexible(first->getValue())) {
|
|
||||||
--first;
|
|
||||||
}
|
|
||||||
auto last = shapeRules.find(timeRange.getEnd(), FindMode::SampleLeft);
|
|
||||||
while (std::next(last) != shapeRules.end() && isFlexible(last->getValue())) {
|
|
||||||
++last;
|
|
||||||
}
|
|
||||||
return { first->getStart(), last->getEnd() };
|
|
||||||
}
|
|
||||||
|
|
||||||
JoiningContinuousTimeline<Shape> avoidStaticSegments(
|
|
||||||
const ContinuousTimeline<ShapeRule>& shapeRules,
|
|
||||||
const AnimationFunction& animate
|
|
||||||
) {
|
|
||||||
const auto animation = animate(shapeRules);
|
|
||||||
const vector<TimeRange> staticSegments = getStaticSegments(shapeRules, animation);
|
|
||||||
if (staticSegments.empty()) {
|
|
||||||
return animation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify shape rules to eliminate static segments
|
|
||||||
ContinuousTimeline<ShapeRule> fixedShapeRules(shapeRules);
|
|
||||||
for (const TimeRange& staticSegment : staticSegments) {
|
|
||||||
// Extend time range to the left and right so we don't lose adjacent rules that might
|
|
||||||
// influence the animation
|
|
||||||
const TimeRange extendedStaticSegment = extendToFixedRules(staticSegment, shapeRules);
|
|
||||||
|
|
||||||
// Fix shape rules within the static segment
|
|
||||||
const auto fixedSegmentShapeRules = fixStaticSegmentRules(
|
|
||||||
{ extendedStaticSegment, ShapeRule::getInvalid(), fixedShapeRules },
|
|
||||||
animate
|
|
||||||
);
|
|
||||||
for (const auto& timedShapeRule : fixedSegmentShapeRules) {
|
|
||||||
fixedShapeRules.set(timedShapeRule);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return animate(fixedShapeRules);
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "core/Shape.h"
|
|
||||||
#include "time/ContinuousTimeline.h"
|
|
||||||
#include "ShapeRule.h"
|
|
||||||
#include <functional>
|
|
||||||
|
|
||||||
using AnimationFunction = std::function<JoiningContinuousTimeline<Shape>(const ContinuousTimeline<ShapeRule>&)>;
|
|
||||||
|
|
||||||
// Calls the specified animation function with the specified shape rules.
|
|
||||||
// If the resulting animation contains long static segments, the shape rules are tweaked and
|
|
||||||
// animated again.
|
|
||||||
// Static segments happen rather often.
|
|
||||||
// See http://animateducated.blogspot.de/2016/10/lip-sync-animation-2.html?showComment=1478861729702#c2940729096183546458.
|
|
||||||
JoiningContinuousTimeline<Shape> avoidStaticSegments(
|
|
||||||
const ContinuousTimeline<ShapeRule>& shapeRules,
|
|
||||||
const AnimationFunction& animate
|
|
||||||
);
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
#include "target-shape-set.h"
|
||||||
|
|
||||||
|
Shape convertToTargetShapeSet(Shape shape, const ShapeSet& targetShapeSet) {
|
||||||
|
if (targetShapeSet.find(shape) != targetShapeSet.end()) {
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
const Shape basicShape = getBasicShape(shape);
|
||||||
|
if (targetShapeSet.find(basicShape) == targetShapeSet.end()) {
|
||||||
|
throw std::invalid_argument(
|
||||||
|
fmt::format("Target shape set must contain basic shape {}.", basicShape)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return basicShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShapeSet convertToTargetShapeSet(const ShapeSet& shapes, const ShapeSet& targetShapeSet) {
|
||||||
|
ShapeSet result;
|
||||||
|
for (Shape shape : shapes) {
|
||||||
|
result.insert(convertToTargetShapeSet(shape, targetShapeSet));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ContinuousTimeline<ShapeRule> convertToTargetShapeSet(
|
||||||
|
const ContinuousTimeline<ShapeRule>& shapeRules, const ShapeSet& targetShapeSet
|
||||||
|
) {
|
||||||
|
ContinuousTimeline<ShapeRule> result(shapeRules);
|
||||||
|
for (const auto& timedShapeRule : shapeRules) {
|
||||||
|
ShapeRule rule = timedShapeRule.getValue();
|
||||||
|
rule.shapeSet = convertToTargetShapeSet(rule.shapeSet, targetShapeSet);
|
||||||
|
result.set(timedShapeRule.getTimeRange(), rule);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoiningContinuousTimeline<Shape> convertToTargetShapeSet(
|
||||||
|
const JoiningContinuousTimeline<Shape>& animation, const ShapeSet& targetShapeSet
|
||||||
|
) {
|
||||||
|
JoiningContinuousTimeline<Shape> result(animation);
|
||||||
|
for (const auto& timedShape : animation) {
|
||||||
|
result.set(
|
||||||
|
timedShape.getTimeRange(),
|
||||||
|
convertToTargetShapeSet(timedShape.getValue(), targetShapeSet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|