2026-02-16

This commit is contained in:
2026-03-17 15:25:32 -06:00
parent d5dd373de0
commit 60100fbab2
560 changed files with 33397 additions and 20776 deletions
@@ -0,0 +1,278 @@
name: Full Build (Espeak + Blender Addon)
on:
workflow_dispatch:
jobs:
build-windows:
name: Build Espeak NG for Windows
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Clone Espeak NG repo
- name: Clone forked espeak NG
# Clone fork containing a fix to filter out mbrola voices on Windows
# Without it, phonemizer will try to use unexisting voices
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Clone pcaudiolib repo (required for Windows build)
- name: Clone pcaudiolib
run: git clone --depth 1 https://github.com/espeak-ng/pcaudiolib.git espeak-ng/src/pcaudiolib
- name: Modify config.h
working-directory: espeak-ng
shell: bash
run: |
CONFIG_FILE=src/windows/config.h
echo "Updating $CONFIG_FILE..."
# Add or update the definitions
sed -i "s/^#define USE_KLATT.*/#define USE_KLATT 0/" $CONFIG_FILE || echo "#define USE_KLATT 0" >> $CONFIG_FILE
sed -i "s/^#define USE_SPEECHPLAYER.*/#define USE_SPEECHPLAYER 0/" $CONFIG_FILE || echo "#define USE_SPEECHPLAYER 0" >> $CONFIG_FILE
sed -i "s/^#define USE_MBROLA.*/#define USE_MBROLA 0/" $CONFIG_FILE || echo "#define USE_MBROLA 0" >> $CONFIG_FILE
sed -i "s/^#define USE_SONIC.*/#define USE_SONIC 0/" $CONFIG_FILE || echo "#define USE_SONIC 0" >> $CONFIG_FILE
sed -i "s/^#define USE_ASYNC.*/#define USE_ASYNC 0/" $CONFIG_FILE || echo "#define USE_ASYNC 0" >> $CONFIG_FILE
# Build Espeak NG for Windows
- name: Build with MSBuild
working-directory: espeak-ng
shell: cmd
run: |
cd src/windows
"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe > msbuild_path.txt
set /p MSBUILD_PATH=<msbuild_path.txt
call "%MSBUILD_PATH%" espeak-ng.sln /p:Configuration=Release
# Copy .dll to temporary dist dir
- name: Copy Windows DLL + Data
run: |
mkdir dist
copy espeak-ng\src\windows\x64\Release\libespeak-ng.dll dist\
# Zip artifacts
- name: Zip Windows build
shell: pwsh
run: |
Compress-Archive -Path dist\* -DestinationPath espeak-ng-windows.zip
# Upload ZIP artifacts
- name: Upload Windows Build of Espeak NG
uses: actions/upload-artifact@v4
with:
name: espeak-ng-windows
path: espeak-ng-windows.zip
build-linux:
name: Build Espeak NG for linux
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Clone forked eSpeak NG
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Step 2: Install Build Dependencies
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y make autoconf automake libtool pkg-config
sudo apt-get install -y gcc g++
sudo apt-get install -y libpcaudio-dev
# Build & Copy lib and data folder to temporary dist dir
- name: Building
working-directory: espeak-ng
run: |
./autogen.sh
./configure --with-klatt=no --with-speechplayer=no --with-mbrola=no --with-sonic=no --with-async=no
make
mkdir -p ../dist
cp src/.libs/libespeak-ng.so* ../dist/
zip -r ../espeak-ng-data.zip espeak-ng-data
cd ../dist
zip -r ../espeak-ng-linux.zip .
# Upload ZIP artifacts
- name: Upload Linux lib
uses: actions/upload-artifact@v4
with:
name: espeak-ng-linux
path: espeak-ng-linux.zip
# Upload ZIP artifacts
- name: Upload espeak data folder
uses: actions/upload-artifact@v4
with:
name: espeak-ng-data
path: espeak-ng-data.zip
build-arm64:
name: Build Espeak NG for macOS (arm64)
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Clone forked eSpeak NG
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Step 2: Install Build Dependencies
- name: Install dependencies on macOS
run: |
brew update
brew install make gcc autoconf automake libtool pkg-config portaudio
- name: Building arm64
working-directory: espeak-ng
run: |
./autogen.sh
./configure CFLAGS="-arch arm64" LDFLAGS="-arch arm64" --with-klatt=no --with-speechplayer=no --with-mbrola=no --with-sonic=no --with-async=no
make
cp src/.libs/libespeak-ng.dylib ../libespeak-ng-arm64.dylib
- name: Upload arm64 lib + data
uses: actions/upload-artifact@v4
with:
name: espeak-ng-arm64
path: libespeak-ng-arm64.dylib
build-x86_64:
name: Build Espeak NG for macOS (x86_64)
runs-on: macos-13
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Clone forked eSpeak NG
run: git clone --depth 1 -b fix-windows-list-voices https://github.com/Charley3d/espeak-ng.git
# Step 2: Install Build Dependencies
- name: Install dependencies on macOS
run: |
brew update
brew install make gcc autoconf automake libtool pkg-config portaudio
- name: Building x86_64
working-directory: espeak-ng
run: |
./autogen.sh
./configure CFLAGS="-arch x86_64" LDFLAGS="-arch x86_64" --with-klatt=no --with-speechplayer=no --with-mbrola=no --with-sonic=no --with-async=no
make
cp src/.libs/libespeak-ng.dylib ../libespeak-ng-x86_64.dylib
- name: Upload x86_64 lib
uses: actions/upload-artifact@v4
with:
name: espeak-ng-x86_64
path: libespeak-ng-x86_64.dylib
merge:
name: Create Universal macOS Build
runs-on: macos-latest
needs: [build-arm64, build-x86_64]
steps:
- name: Download arm64 lib + data
uses: actions/download-artifact@v4
with:
name: espeak-ng-arm64
- name: Download x86_64 lib
uses: actions/download-artifact@v4
with:
name: espeak-ng-x86_64
- name: Merge with lipo and prepare bundle
run: |
mkdir -p dist
lipo -create -output dist/libespeak-ng.dylib \
libespeak-ng-arm64.dylib \
libespeak-ng-x86_64.dylib
file dist/libespeak-ng.dylib
cd dist
zip -r ../espeak-ng-darwin.zip .
- name: Upload Universal lib
uses: actions/upload-artifact@v4
with:
name: espeak-ng-darwin
path: espeak-ng-darwin.zip
collect-artifacts:
name: Make Blender Addon
runs-on: ubuntu-latest
needs: [ build-windows, merge, build-linux ]
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Blender via Snap
run: sudo snap install blender --classic
- name: Download eSpeak Data Folder
uses: actions/download-artifact@v4
with:
name: espeak-ng-data
path: temp/espeak-ng-data
# Download each platforms build artifact
- name: Download Windows build
uses: actions/download-artifact@v4
with:
name: espeak-ng-windows
path: temp/windows
- name: Download macOS build
uses: actions/download-artifact@v4
with:
name: espeak-ng-darwin
path: temp/macos
- name: Download Linux build
uses: actions/download-artifact@v4
with:
name: espeak-ng-linux
path: temp/linux
# Move them to Assets/{platform}
- name: Move artifacts to Assets
run: |
mkdir -p Assets/Archives/windows Assets/Archives/darwin Assets/Archives/linux
cp -r temp/windows/* Assets/Archives/windows/
cp -r temp/macos/* Assets/Archives/darwin/
cp -r temp/linux/* Assets/Archives/linux/
mkdir -p Assets/Archives/common && cp -r temp/espeak-ng-data/* Assets/Archives/common/
# Remove temp files and folders + unwanted files for release
- name: Clean up folder to zip Blender Addon
run: |
rm -rf temp || true
rm -rf dist || true
rm -f .gitignore || true
rm -f dev_tools.py || true
rm -rf .github || true
rm -rf .idea || true
- name: Build Extension with blender
run: blender --command extension build
- name: Get generated zip filename
id: get-zip
run: echo "ZIP_NAME=$(basename $(ls ./*.zip))" >> $GITHUB_OUTPUT
# Upload the full release of Blender Add-on
- name: Upload Blender Addon
uses: actions/upload-artifact@v4
with:
name: lipsync-addon
path: ${{ steps.get-zip.outputs.ZIP_NAME }}
@@ -0,0 +1,118 @@
name: Publish Release
on:
push:
branches: [ master ]
workflow_dispatch:
jobs:
build-extension:
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
pull-requests: write # to be able to comment on released pull requests
name: Blender Extension Build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Get next version from semantic-release
id: next_version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
OUTPUT=$(npx semantic-release --dry-run 2>&1 || true)
echo "$OUTPUT"
VERSION=$(echo "$OUTPUT" | grep -oP 'The next release version is \K[0-9]+\.[0-9]+\.[0-9]+' || true)
if [ -z "$VERSION" ]; then
echo "⚠️ No new version detected (probably no semantic commits)."
exit 0
fi
echo "✅ Found next version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set up Python
if: steps.next_version.outputs.version != ''
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install tomlkit
if: steps.next_version.outputs.version != ''
run: pip install tomlkit
- name: Update version in Blender Manifest
if: steps.next_version.outputs.version != ''
run: |
python scripts/update_version.py ${{ steps.next_version.outputs.version }}
- name: Install Blender via Snap
run: sudo snap install blender --classic
- name: Build Extension with blender
run: blender --command extension build --split-platforms
# Upload the full release of Blender Add-on
- name: Clean platform-specific archives
run: |
for zipfile in iocgpoly_lip_sync-*-*.zip; do
echo "Examining $zipfile"
unzip -l "$zipfile" | grep -i "archives"
# Extract platform from filename (between last - and _)
platform=$(echo $zipfile | sed 's/.*-\(.*\)_.*/\1/')
echo "Processing $zipfile (platform: $platform)"
# Create temp dir
temp_dir="${platform}_temp"
unzip -q "$zipfile" -d "$temp_dir"
# List the actual directory structure
echo "Directory structure in temp_dir:"
ls -la "$temp_dir/Assets/Archives/"
rm "$zipfile"
cd "$temp_dir"
# Remove irrelevant platform folders based on the platform
case $platform in
linux)
rm -rf Assets/Archives/darwin Assets/Archives/windows
;;
macos)
rm -rf Assets/Archives/linux Assets/Archives/windows
;;
windows)
rm -rf Assets/Archives/darwin Assets/Archives/linux
;;
esac
# List what remains
echo "Remaining directories:"
ls -la Assets/Archives/
# Rezip with same name
zip -r "../$zipfile" .
cd ..
rm -rf "$temp_dir"
done
- name: Semantic release
# if: github.event_name == 'push' && github.ref == 'refs/heads/master'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release --debug true
- name: Upload Blender Addon
if: ${{ github.event_name == 'workflow_dispatch' || steps.next_version.outputs.version == '' }}
uses: actions/upload-artifact@v4
with:
name: extension-builds
path: ./*.zip
@@ -0,0 +1,36 @@
name: Upload to Extensions Platform
on:
# release:
# types: [published]
workflow_dispatch:
jobs:
upload-to-extensions:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download Release Assets and Notes
run: |
mkdir assets
gh release download -p "*.zip" -D assets/
RELEASE_NOTES=$(gh release view --json body | jq -r .body)
echo "$RELEASE_NOTES" > release_notes.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload to Extensions Platform
env:
EXTENSION: iocgpoly_lip_sync
run: |
for zipfile in assets/*.zip; do
echo "Uploading $zipfile to Blender Extensions Platform"
curl -X POST https://extensions.blender.org/api/v1/extensions/${EXTENSION}/versions/upload/ \
-H "Authorization:bearer ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}" \
-F "version_file=@${zipfile}" \
-F "release_notes=<release_notes.txt"
done
@@ -0,0 +1,19 @@
{
"repositoryUrl": "git@github.com:Charley3d/lip-sync.git",
"debug": true,
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/github",
{
"assets": [
{ "path": "iocgpoly_lip_sync-*-linux_x64.zip", "label": "LipSync for Linux ${nextRelease.gitTag}" },
{ "path": "iocgpoly_lip_sync-*-macos_arm64.zip", "label": "LipSync for MacOS arm64 ${nextRelease.gitTag}" },
{ "path": "iocgpoly_lip_sync-*-macos_x64.zip", "label": "LipSync for MacOS x64 ${nextRelease.gitTag}" },
{ "path": "iocgpoly_lip_sync-*-windows_x64.zip", "label": "LipSync for Windows x64 ${nextRelease.gitTag}" }
]
}
]
]
}
@@ -177,14 +177,43 @@ class LIPSYNC2D_PoseAssetsAnimator:
:param interpolation: The interpolation type for the keyframes.
:type interpolation: Literal["LINEAR"]
"""
# Determine where to read the pose asset fcurves from. In newer Blender
# APIs pose assets may not expose `.fcurves` directly; the pose data is
# stored in the action's first layer/strip channelbag. Fall back to
# `pose_action.fcurves` when available.
pose_fcurves = None
# Try to get fcurves from the pose asset's strip channelbag first
try:
if hasattr(pose_action, "layers") and len(pose_action.layers) > 0:
pose_strip = pose_action.layers[0].strips[0]
# Use first slot if present
pose_slot = pose_action.slots[0] if getattr(pose_action, "slots", None) else None
if pose_strip is not None and pose_slot is not None:
pose_channelbag = pose_strip.channelbag(pose_slot)
pose_fcurves = getattr(pose_channelbag, "fcurves", None)
except Exception:
pose_fcurves = None
# Fallback to action.fcurves when present
if pose_fcurves is None:
pose_fcurves = getattr(pose_action, "fcurves", None)
if pose_fcurves is None:
# Nothing we can copy from
return
for fcurve in self.channelbag.fcurves:
pose_asset_fcurve = pose_action.fcurves.find(
pose_asset_fcurve = pose_fcurves.find(
fcurve.data_path, index=fcurve.array_index
)
if pose_asset_fcurve is None:
continue
# Since Action is from a Pose Asset, we can safely assume that first keyframe point holds the Pose
if len(pose_asset_fcurve.keyframe_points) == 0:
continue
fcurve_value = pose_asset_fcurve.keyframe_points[0].co.y
kframe = fcurve.keyframe_points.insert(
frame,
@@ -57,11 +57,29 @@ class LIPSYNC_SpriteSheetAnimator:
:type obj: BpyObject
:return: None
"""
action = obj.animation_data.action if obj.animation_data else None
if action:
for fcurve in action.fcurves:
if (action := obj.animation_data.action) is None:
return
# Retrieve the strip and channelbag correctly for Layered Actions
# Assuming single layer/strip structure as per setup()
if not action.layers or not action.layers[0].strips:
return
strip = cast(BpyActionKeyframeStrip, action.layers[0].strips[0])
# Ensure we get the correct slot for sprite sheet
slot = action.slots.get(f"OB{SLOT_SPRITE_SHEET_NAME}")
if not slot:
return
try:
channelbag = strip.channelbag(slot)
for fcurve in channelbag.fcurves:
if fcurve.data_path == "lipsync2d_props.lip_sync_2d_sprite_sheet_index":
fcurve.keyframe_points.clear()
except Exception:
# Fallback or silence if channelbag creation fails (though it should exist if we are clearing)
pass
def insert_keyframes(self, obj: BpyObject, props: BpyPropertyGroup, visemes_data: VisemeData,
word_timing: WordTiming,
@@ -203,12 +221,29 @@ class LIPSYNC_SpriteSheetAnimator:
:type obj: BpyObject
:return: None
"""
action = obj.animation_data.action if obj.animation_data else None
if (action := obj.animation_data.action) is None:
return
# We can try to use self.channelbag if it's already set up, but to be robust
# we re-fetch it similar to clear_previous_keyframes to ensure we have the right one.
if not action.layers or not action.layers[0].strips:
return
if action:
for fcurve in action.fcurves:
strip = cast(BpyActionKeyframeStrip, action.layers[0].strips[0])
# Ensure we get the correct slot for sprite sheet
slot = action.slots.get(f"OB{SLOT_SPRITE_SHEET_NAME}")
if not slot:
return
try:
channelbag = strip.channelbag(slot)
for fcurve in channelbag.fcurves:
for keyframe in fcurve.keyframe_points:
keyframe.interpolation = 'CONSTANT'
except Exception:
pass
def setup(self, obj: BpyObject):
self.setup_animation_properties(obj)
@@ -53,8 +53,9 @@ phoneme_to_viseme_arkit_v2 = {
"ŋ": "nn",
"ɲ": "nn",
"ɳ": "nn",
"l": "nn",
"ɫ": "nn",
# LL lateral group
"l": "LL",
"ɫ": "LL",
# aa open and low/mid front vowels
"a": "aa",
"aː": "aa",
@@ -119,7 +120,8 @@ def viseme_items_mpeg4_v2(self, context):
("kk", "kk", "K, G (velar stops)"),
("CH", "CH", "CH, J (affricates)"),
("SS", "SS", "S, Z, SH, ZH (narrow fricatives)"),
("nn", "nn", "N, NG, L (nasals and laterals)"),
("nn", "nn", "N, NG (nasals)"),
("LL", "LL", "L (lateral)"),
("RR", "RR", "R (r-like sounds)"),
("aa", "aa", "A, Æ (open/low vowels)"),
("E", "E", "E, Ø, Ə (mid front vowels)"),
@@ -141,6 +143,7 @@ def phonemes_to_default_sprite_index():
"CH": 10,
"SS": 2,
"nn": 10,
"LL": 10,
"RR": 3,
"aa": 11,
"E": 8,
@@ -62,7 +62,26 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
return {"CANCELLED"}
self.set_bake_range()
file_path = extract_audio()
props = context.active_object.lipsync2d_props # type: ignore
target_channel = props.lip_sync_2d_bake_channel
muted_strips = []
try:
if target_channel != "ALL":
target_ch_int = int(target_channel)
for strip in context.scene.sequence_editor.strips_all:
if strip.type == "SOUND" and not strip.mute:
if strip.channel != target_ch_int:
strip.mute = True
muted_strips.append(strip)
file_path = extract_audio()
finally:
# Restore mute state
for strip in muted_strips:
strip.mute = False
if not os.path.isfile(f"{file_path}"):
self.report(
@@ -90,15 +109,20 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
phonemes = LIPSYNC2D_DialogInspector.extract_phonemes(words, context)
auto_obj = self.get_animator(obj)
props = obj.lipsync2d_props # type: ignore
debug_entries = [] if props.lip_sync_2d_debug_output else None
auto_obj.setup(obj)
self.auto_insert_keyframes(
auto_obj, obj, recognized_words, dialog_inspector, total_words, phonemes
auto_obj, obj, recognized_words, dialog_inspector, total_words, phonemes, debug_entries
)
auto_obj.set_interpolation(obj)
auto_obj.cleanup(obj)
self.reset_bake_range()
if debug_entries is not None:
self.write_debug_output(debug_entries)
if bpy.context.view_layer:
bpy.context.view_layer.update()
@@ -116,6 +140,7 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
dialog_inspector: LIPSYNC2D_DialogInspector,
total_words,
phonemes,
debug_entries: list | None = None,
):
props = obj.lipsync2d_props # type: ignore
words = enumerate(recognized_words)
@@ -123,9 +148,19 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
for index, recognized_word in words:
is_last_word = index == total_words - 1
word_timing = dialog_inspector.get_word_timing(recognized_word)
current_phonemes = phonemes[index]
visemes_data = dialog_inspector.get_visemes(
phonemes[index], word_timing["duration"]
current_phonemes, word_timing["duration"]
)
if debug_entries is not None:
debug_entries.append({
"word": recognized_word["word"],
"phonemes": current_phonemes,
"visemes": visemes_data,
"start": word_timing["word_frame_start"],
})
next_word_timing = dialog_inspector.get_next_word_timing(
recognized_words, index
)
@@ -151,6 +186,54 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
index,
)
def write_debug_output(self, entries):
text_name = "LipSync Debug"
text = bpy.data.texts.get(text_name)
if text is None:
text = bpy.data.texts.new(text_name)
else:
text.clear()
# Header
output = [
f"{'Word':<15} {'Start':<10} {'Phonemes':<15} {'Viseme':<10} {'Frame':<10}",
"-" * 60
]
for entry in entries:
word = entry['word']
start_frame = entry['start']
phonemes = entry['phonemes'] # list of phonemes strings
phonemes_str = " ".join(phonemes)
viseme_data = entry['visemes']
visemes_list = viseme_data['visemes']
part_duration = viseme_data['visemes_parts']
# First line with word info
first_viseme = visemes_list[0] if visemes_list else ""
first_viseme_frame = f"{start_frame:.2f}"
# If no visemes, just print word info
if not visemes_list:
output.append(f"{word:<15} {start_frame:<10} {phonemes_str:<15}")
continue
# Print first viseme with word info
output.append(f"{word:<15} {start_frame:<10} {phonemes_str:<15} {visemes_list[0]:<10} {first_viseme_frame:<10}")
# Print remaining visemes
current_frame = start_frame
for i in range(1, len(visemes_list)):
current_frame += part_duration
viseme = visemes_list[i]
output.append(f"{'':<15} {'':<10} {'':<15} {viseme:<10} {current_frame:.2f}")
# Add a separator blank line or just spacing
# output.append("")
text.write("\n".join(output))
@staticmethod
def get_animator(obj: BpyObject) -> LIPSYNC2D_LipSyncAnimator:
props = obj.lipsync2d_props # type: ignore
@@ -95,6 +95,8 @@ class AnimatorPanelMixin:
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_use_clear_keyframes")
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_debug_output")
row = panel_body.row()
row.label(text="Range:")
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_use_bake_range")
@@ -102,3 +104,6 @@ class AnimatorPanelMixin:
row.prop(self.props, "lip_sync_2d_bake_start", text="Start")
row.prop(self.props, "lip_sync_2d_bake_end", text="End")
row.enabled = self.props.lip_sync_2d_use_bake_range # type: ignore
row = panel_body.row()
row.prop(self.props, "lip_sync_2d_bake_channel")
@@ -154,47 +154,93 @@ def get_lip_sync_type_items(self, context: BpyContext | None):
def poll_pose_assets(self, obj: bpy.types.ID):
return bool(obj.asset_data)
# Ensure it's an Action
if not isinstance(obj, bpy.types.Action):
return False
# Must have asset data (be marked as a pose asset)
if not obj.asset_data:
return False
# Allow local, linked, and library override actions
# This is necessary because linked library pose assets should still be usable
return True
def get_channel_items(self, context: BpyContext | None):
items = [("ALL", "All Channels", "Bake audio from all channels")]
if context is None or context.scene is None or context.scene.sequence_editor is None:
return intern_enum_items(items)
channels = set()
for strip in context.scene.sequence_editor.strips_all:
if strip.type == "SOUND" and not strip.mute:
channels.add(strip.channel)
sorted_channels = sorted(list(channels))
for channel in sorted_channels:
c_data = context.scene.sequence_editor.channels[channel]
channel_name = str(channel)+" - "+c_data.name
if c_data.mute:
channel_name += " (Muted)"
items.append((str(channel), channel_name, f"Bake audio from Channel {channel}"))
return intern_enum_items(items)
class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
lip_sync_2d_bake_channel: bpy.props.EnumProperty(
name="Channel",
description="Select specific channel to bake audio from",
items=get_channel_items
) # type: ignore
lip_sync_2d_initialized: bpy.props.BoolProperty(
name="Initilize Lip Sync",
description="Initilize Lip Sync on selection",
default=False,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet: bpy.props.PointerProperty(
name="Sprite Sheet",
description="The name of the addon to reload",
type=bpy.types.Image,
update=update_sprite_sheet,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_main_material: bpy.props.PointerProperty(
name="Main Material",
description="Material containing Sprite sheet",
type=bpy.types.Material,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_columns: bpy.props.IntProperty(
name="Columns", description="Total of columns in sprite sheet", default=1
name="Columns", description="Total of columns in sprite sheet", default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_rows: bpy.props.IntProperty(
name="Rows",
description="Total of rows in sprite sheet",
update=update_sprite_sheet_rows,
default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_sprite_scale: bpy.props.FloatProperty(
name="Sprite",
description="Adjust sprite scale so it fits in mouth area",
default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_main_scale: bpy.props.FloatProperty(
name="Lips", description="Adjust Lips scale", default=1
name="Lips", description="Adjust Lips scale", default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_index: bpy.props.IntProperty(
name="Sprite Index",
description="Sprite Index. Start at 0, from Bottom Left to Top Right",
default=1,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sprite_sheet_format: bpy.props.EnumProperty(
name="Sprite sheet format",
@@ -215,6 +261,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
],
update=update_sprite_sheet_format,
default=3,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_lips_type: bpy.props.EnumProperty(
@@ -223,6 +270,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
items=get_lip_sync_type_items,
update=update_sprite_sheet_format,
default=0,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_in_between_threshold: bpy.props.FloatProperty(
@@ -231,6 +279,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.0417,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sil_threshold: bpy.props.FloatProperty(
@@ -239,6 +288,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.22,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sps_in_between_threshold: bpy.props.FloatProperty(
@@ -247,6 +297,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.0417,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_sps_sil_threshold: bpy.props.FloatProperty(
@@ -255,6 +306,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.22,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_close_motion_duration: bpy.props.FloatProperty(
@@ -263,30 +315,41 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
default=0.2,
subtype="TIME",
unit="TIME_ABSOLUTE",
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_remove_animation_data: bpy.props.BoolProperty(
name="Remove Animation",
description="Also remove action, action slot and keyframes",
default=True,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_remove_cgp_node_group: bpy.props.BoolProperty(
name="Remove Nodes",
description="Also remove node groups from Object's Materials",
default=True,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_use_clear_keyframes: bpy.props.BoolProperty(
name="Clear Keyframes",
description="Clear Keyframes before Bake",
default=True,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_use_bake_range: bpy.props.BoolProperty(
name="Use Range",
description="Only bake between specified range",
default=False,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_debug_output: bpy.props.BoolProperty(
name="Debug Output",
description="Output phoneme and viseme data to a text block",
default=False,
) # type: ignore
lip_sync_2d_bake_start: bpy.props.IntProperty(
@@ -296,6 +359,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
min=0,
set=set_bake_start,
get=get_bake_start,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_bake_end: bpy.props.IntProperty(
@@ -305,6 +369,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
min=0,
set=set_bake_end,
get=get_bake_end,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_rig_type_basic: bpy.props.BoolProperty(
@@ -315,6 +380,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
),
default=True,
update=update_rig_type_basic,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_rig_type_advanced: bpy.props.BoolProperty(
@@ -325,6 +391,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
"Only use this if Basic Rig is not working."
),
update=update_rig_type_advanced,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
lip_sync_2d_prioritize_accuracy: bpy.props.BoolProperty(
@@ -335,6 +402,7 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
"from being skipped when they occur in rapid succession."
),
default=False,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
@classmethod
@@ -373,5 +441,6 @@ class LIPSYNC2D_PG_CustomProperties(bpy.types.PropertyGroup):
name=f"Viseme {name}",
description=desc,
poll=poll_pose_assets,
),
override={'LIBRARY_OVERRIDABLE'}
), # type: ignore
)
@@ -38,7 +38,10 @@ def register():
bpy.utils.register_class(LIPSYNC2D_PT_Edit)
bpy.utils.register_class(LIPSYNC2D_OT_RemoveAnimations)
bpy.utils.register_class(LIPSYNC2D_OT_refresh_pose_assets)
bpy.types.Object.lipsync2d_props = bpy.props.PointerProperty(type=LIPSYNC2D_PG_CustomProperties) # type: ignore
bpy.types.Object.lipsync2d_props = bpy.props.PointerProperty(
type=LIPSYNC2D_PG_CustomProperties,
override={'LIBRARY_OVERRIDABLE'}
) # type: ignore
def unregister():
@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "iocgpoly_lip_sync"
version = "2.3.2"
version = "1.0.6"
name = "Lip Sync"
tagline = "Automatic lip sync for your Blender models"
maintainer = "Charley 3D <charley@cgpoly.fr>"
@@ -30,11 +30,3 @@ paths_exclude_pattern = [
"/scripts/",
".releaserc"
]
# BEGIN GENERATED CONTENT.
# This must not be included in source manifests.
[build.generated]
platforms = ["windows-x64"]
wheels = ["./wheels/attrs-25.3.0-py3-none-any.whl", "./wheels/babel-2.17.0-py3-none-any.whl", "./wheels/cffi-1.17.1-cp311-cp311-win_amd64.whl", "./wheels/csvw-3.5.1-py2.py3-none-any.whl", "./wheels/dlinfo-2.0.0-py3-none-any.whl", "./wheels/isodate-0.7.2-py3-none-any.whl", "./wheels/joblib-1.4.2-py3-none-any.whl", "./wheels/jsonschema-4.23.0-py3-none-any.whl", "./wheels/jsonschema_specifications-2024.10.1-py3-none-any.whl", "./wheels/language_tags-1.2.0-py3-none-any.whl", "./wheels/phonemizer-3.3.0-py3-none-any.whl", "./wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", "./wheels/rdflib-7.1.4-py3-none-any.whl", "./wheels/referencing-0.36.2-py3-none-any.whl", "./wheels/regex-2024.11.6-cp311-cp311-win_amd64.whl", "./wheels/rfc3986-1.5.0-py2.py3-none-any.whl", "./wheels/rpds_py-0.24.0-cp311-cp311-win_amd64.whl", "./wheels/segments-2.3.0-py2.py3-none-any.whl", "./wheels/six-1.17.0-py2.py3-none-any.whl", "./wheels/tqdm-4.67.1-py3-none-any.whl", "./wheels/typing_extensions-4.13.2-py3-none-any.whl", "./wheels/uritemplate-4.1.1-py2.py3-none-any.whl", "./wheels/vosk-0.3.41-py3-none-win_amd64.whl"]
# END GENERATED CONTENT.
@@ -0,0 +1,169 @@
import os
import shutil
import zipfile
from collections import defaultdict
from glob import glob
import tomlkit
def update_wheels():
folder = "./wheels"
files = os.listdir(folder)
toml_path = "blender_manifest.toml"
wheel_paths = glob("./wheels/**/*.whl", recursive=True)
print(wheel_paths)
clean = [path.replace("\\", "/") for path in wheel_paths]
# Load the TOML file
with open(toml_path, "r", encoding="utf-8") as f:
toml_data = tomlkit.load(f)
# Update the "wheels" entry
toml_data["wheels"] = clean
# Write back to the TOML file, preserving formatting and comments
with open(toml_path, "w", encoding="utf-8") as f:
f.write(tomlkit.dumps(toml_data))
def build_addon():
current_dir = os.getcwd()
exclude_patterns = [
'.venv',
'.gitignore',
'.git',
'.idea',
'__pycache__',
'dist',
'dev_tools.py'
]
dist_dir = os.path.join(current_dir, "dist")
if not os.path.exists(dist_dir):
os.makedirs(dist_dir)
with open("blender_manifest.toml", "r") as f:
parsed = tomlkit.parse(f.read())
version = parsed["version"]
addon_id = parsed["id"]
with zipfile.ZipFile(f"{current_dir}/dist/{addon_id}_{version}.zip", "w") as zip_file:
for root, dirs, files in os.walk(f"{current_dir}"):
dirs[:] = [d for d in dirs if os.path.relpath(os.path.join(root,d), current_dir) not in exclude_patterns]
for file in files:
file_path = os.path.relpath(os.path.join(root, file), current_dir)
# Skip files based on exclude patterns
if any(file_path.startswith(pattern) or file_path in exclude_patterns for pattern in exclude_patterns):
continue
# Add the file to the zip archive
zip_file.write(os.path.join(root, file), file_path)
print(f"Build complete. Addon file saved to {dist_dir}")
def handle_duplicate_wheels(directory: str):
"""
Find and handle duplicate .whl files in the given directory and its subdirectories.
Keeps files in ./wheels/common and removes duplicates. If a file is not in ./wheels/common,
moves one to ./wheels/common and deletes the others.
:param directory: str: The path to the directory to search for .whl files.
:return: None
"""
# Path to the common directory
common_path = os.path.join(directory, "common")
# Create the common directory if it doesn't exist
os.makedirs(common_path, exist_ok=True)
# Dictionary to store .whl filenames and their paths
files_dict = defaultdict(list)
# Traverse the directory and its subdirectories
for root, _, files in os.walk(directory):
for file in files:
print(f"Processing file: {file}")
# Check if the file is a .whl file
if file.endswith(".whl"):
# Store the file and its full path in the dictionary
full_path = os.path.join(root, file)
files_dict[file].append(full_path)
# Handle duplicates (files with more than one associated path)
for file, paths in files_dict.items():
if len(paths) > 1: # Check if there are duplicates
print(f"\nDuplicate found for: {file}")
print("Locations:")
for path in paths:
print(f" - {path}")
# Check if the file already exists in the common directory
common_file_path = os.path.join(common_path, file)
if os.path.exists(common_file_path):
# Remove all duplicates, as the file already exists in common
print(f"File already in common directory: {common_file_path}")
for path in paths:
if path != common_file_path:
print(f"Removing duplicate: {path}")
os.remove(path)
else:
# Move one file to common and remove the others
print(f"Moving one copy to common directory: {common_file_path}")
shutil.move(paths[0], common_file_path) # Move the first file to common
for path in paths[1:]:
print(f"Removing duplicate: {path}")
os.remove(path)
else:
print(f"No duplicates for: {file}")
import hashlib
import os
def md5_for_folder(folder_path: str):
"""
Compute the MD5 hash of the contents of a folder including file names and contents.
:param folder_path: str: Path to the folder.
:return: str: The MD5 hash of the folder.
"""
md5_hash = hashlib.md5()
# Walk through the directory
for root, dirs, files in os.walk(folder_path):
# Sort directories and files to ensure consistent order (important for consistent hash values)
for names in sorted(dirs + files):
# Update hash with file/folder name
path = os.path.join(root, names)
md5_hash.update(names.encode('utf-8'))
# If it's a file, include its content in the hash
if os.path.isfile(path):
with open(path, 'rb') as f:
while chunk := f.read(8192): # Read file in chunks
md5_hash.update(chunk)
return md5_hash.hexdigest()
if __name__ == "__main__":
update_wheels()
# Example usage
# folder = "./Assets/Archives/darwin/espeak-ng-darwin/espeak-ng-data"
# print(f"MD5 Hash for the folder '{folder}': {md5_for_folder(folder)}")
# folder = "./Assets/Archives/linux/espeak-ng-data/lang"
# print(f"MD5 Hash for the folder '{folder}': {md5_for_folder(folder)}")
# folder = "./Assets/Archives/windows/espeak-ng-data/lang"
# print(f"MD5 Hash for the folder '{folder}': {md5_for_folder(folder)}")
# update_wheels()
@@ -0,0 +1,13 @@
import sys
from tomlkit import parse, dumps # type: ignore
version = sys.argv[1]
toml_path = "blender_manifest.toml"
with open(toml_path, "r", encoding="utf-8") as f:
doc = parse(f.read())
doc["version"] = version
with open(toml_path, "w", encoding="utf-8") as f:
f.write(dumps(doc))