Compare commits

..

11 Commits

Author SHA1 Message Date
Daniel Wolf f239a51fb0 fixup! Update readme file 2024-12-29 18:24:55 +01:00
Daniel Wolf 3283e66db4 Refactor CMakeLists.txt 2024-12-28 19:49:35 +01:00
Daniel Wolf 4a30e5a7d2 Publish documentation as HTML rather than AsciiDoc/Markdown 2024-12-28 19:49:35 +01:00
Daniel Wolf 3847b2fe59 Improve formatting of license file 2024-12-28 19:49:35 +01:00
Daniel Wolf 99315be8df Update readme file 2024-12-28 19:49:34 +01:00
Daniel Wolf 79c923b916 Use doit as leading build system rather than CMake 2024-12-28 19:37:09 +01:00
Daniel Wolf 260c22c7ed Upgrade GitHub actions CI script 2024-12-28 19:37:09 +01:00
Daniel Wolf 00e4996cd6 Name files in kebab case rather than camel case 2024-12-27 20:42:34 +01:00
Daniel Wolf 9d3782a08b Auto-format code files 2024-12-27 11:29:35 +01:00
Daniel Wolf b365c4c1d5 Indent code files with spaces rather than tabs 2024-12-17 11:22:49 +01:00
Daniel Wolf 71259421a9 Disable EOL conversion, using LF on all platforms 2024-12-17 11:21:33 +01:00
313 changed files with 12229 additions and 11798 deletions

26
.clang-format Normal file
View File

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

View File

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

4
.gersemirc Normal file
View File

@ -0,0 +1,4 @@
# Config file for gersemi, a CMake code formatter.
line_length: 100
warn_about_unknown_commands: false

View File

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

8
.gitignore vendored
View File

@ -1,3 +1,9 @@
.vs/ .vs/
build/ .vscode/
*.user *.user
artifacts/
build/
venv/
__pycache__
.doit.db.*

11
.prettierrc.yml Normal file
View File

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

7
.ruff.toml Normal file
View File

@ -0,0 +1,7 @@
# Config file for Ruff, a Python code formatter.
line-length = 100
[format]
quote-style = "single"
skip-magic-trailing-comma = true

View File

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

View File

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

4
app-info.toml Normal file
View File

@ -0,0 +1,4 @@
appName = "Rhubarb Lip Sync"
# Can be any valid SemVer version, including suffixes
appVersion = "1.13.0"

View File

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

279
dodo.py Normal file
View File

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

View File

@ -1,11 +0,0 @@
cmake_minimum_required(VERSION 3.2)
set(afterEffectsFiles
"Rhubarb Lip Sync.jsx"
"README.adoc"
)
install(
FILES ${afterEffectsFiles}
DESTINATION "extras/AdobeAfterEffects"
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. Youll 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. Youll find Rhubarb Lip Sync for Spine in the directory `extras/esoteric-software-spine`.
To create lip sync animation, youll need Spine 3.7 or better. To create lip sync animation, youll need Spine 3.7 or better.

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,80 @@
package com.rhubarb_lip_sync.rhubarb_for_spine
import javafx.beans.property.SimpleStringProperty
import javafx.beans.property.StringProperty
import javafx.beans.value.ObservableValue
import javafx.scene.Group
import javafx.scene.Node
import javafx.scene.Parent
import javafx.scene.control.Tooltip
import javafx.scene.paint.Color
import tornadofx.addChildIfPossible
import tornadofx.circle
import tornadofx.rectangle
import tornadofx.removeFromParent
fun renderErrorIndicator(): Node {
return Group().apply {
isManaged = false
circle {
radius = 7.0
fill = Color.ORANGERED
}
rectangle {
x = -1.0
y = -5.0
width = 2.0
height = 7.0
fill = Color.WHITE
}
rectangle {
x = -1.0
y = 3.0
width = 2.0
height = 2.0
fill = Color.WHITE
}
}
}
fun Parent.errorProperty() : StringProperty {
return properties.getOrPut("rhubarb.errorProperty", {
val errorIndicator: Node = renderErrorIndicator()
val tooltip = Tooltip()
val property = SimpleStringProperty()
fun updateTooltipVisibility() {
if (tooltip.text.isNotEmpty() && isFocused) {
val bounds = localToScreen(boundsInLocal)
tooltip.show(scene.window, bounds.minX + 5, bounds.maxY + 2)
} else {
tooltip.hide()
}
}
focusedProperty().addListener({
_: ObservableValue<out Boolean>, _: Boolean, _: Boolean ->
updateTooltipVisibility()
})
property.addListener({
_: ObservableValue<out String?>, _: String?, newValue: String? ->
if (newValue != null) {
this.addChildIfPossible(errorIndicator)
tooltip.text = newValue
Tooltip.install(this, tooltip)
updateTooltipVisibility()
} else {
errorIndicator.removeFromParent()
tooltip.text = ""
tooltip.hide()
Tooltip.uninstall(this, tooltip)
updateTooltipVisibility()
}
})
return@getOrPut property
}) as StringProperty
}

View File

@ -0,0 +1,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 386 B

View File

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

Before

Width:  |  Height:  |  Size: 942 B

After

Width:  |  Height:  |  Size: 942 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
#!/bin/sh
rm -rf build
mkdir build
cd build
cmake .. -G Xcode
cmake --build . --config Release --target package

View File

@ -1,5 +0,0 @@
rmdir /s /q build
mkdir build
cd build
cmake .. -G "Visual Studio 16 2019"
cmake --build . --config Release --target package

5
requirements.txt Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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.");
}
}

View File

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

View File

@ -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.");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More