update extensions; FMT, BATv2, lipsync

This commit is contained in:
Nathan
2026-07-17 15:02:13 -06:00
parent c51a8313b5
commit b3cac30802
64 changed files with 958 additions and 904 deletions
+2 -2
View File
@@ -10,6 +10,8 @@ D:\Work\9 iClone\Amazon\
D:\Amazon\00_external-files\
N:\1. CHARACTERS\remapping\
[Recent]
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Deliverable\
A:\1 Amazon_Active_Projects\260617_Dual_Induct\Blends\animations\
T:\Amazon\active\1 BlenderAssets\
F:\renders\
@@ -18,5 +20,3 @@ A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Deliverable\Stills\
A:\1 Amazon_Active_Projects\260706_Leader_Turbotote\Blends\stills\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Deliverable\Scan Aware\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\Scan Aware\
A:\1 Amazon_Active_Projects\1 BlenderAssets\
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\BD2\
+22 -22
View File
@@ -1,3 +1,25 @@
F:\jobs\Dual-induct_animation 2_vertical-tkev\Dual-induct_animation 2_vertical.flamenco.blend
F:\jobs\Dual-induct_animation 2_vertical-sq1g\Dual-induct_animation 2_vertical.flamenco.blend
F:\jobs\Dual-induct_animation 1_vertical-drth\Dual-induct_animation 1_vertical.flamenco.blend
F:\jobs\gembawalkEU_horizontal_animation_2A-2x4k\gembawalkEU_horizontal_animation_2A.blend
F:\jobs\gembawalkEU_horizontal_animation_3a2-gs4a\gembawalkEU_horizontal_animation_3a2.blend
F:\jobs\gembawalkEU_horizontal_animation_6C-5dvl\gembawalkEU_horizontal_animation_6C.blend
F:\jobs\gembawalkEU_horizontal_animation_6C-5o3r\gembawalkEU_horizontal_animation_6C.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2A.blend
F:\jobs\gembawalkEU_horizontal_animation_3a2-32d8\gembawalkEU_horizontal_animation_3a2.blend
F:\jobs\gembawalkEU_horizontal_animation_2A-76p5\gembawalkEU_horizontal_animation_2A.blend
F:\jobs\gembawalkEU_horizontal_animation_2A-p0p3\gembawalkEU_horizontal_animation_2A.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_3a2.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_3b.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_6B.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_3c.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_3a1.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_6C.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_6A.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2C.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2B.blend
A:\1 Amazon_Active_Projects\260716_Agile_Gemba_EU\Blends\animations\gembawalkEU_horizontal_animation_2A_ins2.blend
A:\1 Amazon_Active_Projects\260617_Dual_Induct\Blends\animations\Dual-induct_animation 1_horizontal.blend
C:\Users\Nathan\Downloads\test.blend
A:\1 Amazon_Active_Projects\260709_Optimal_ADTA_stow\Blends\animations\OptimalStow_animation 2.blend
@@ -16,7 +38,6 @@ A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\2026_new\iP&S_2026_4a-b.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A_ins3.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A_ins0.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Blends\animations\gembawalk_horizontal_animation_2A.blend
A:\1 Amazon_Active_Projects\260622_Stow_and_Go\Blends\animations\STAG_animation 3c.blend
A:\1 Amazon_Active_Projects\260622_Stow_and_Go\Assets\Blends\lipsync.blend
A:\1 Amazon_Active_Projects\260622_Stow_and_Go\Blends\animations\STAG_animation 3b.blend
@@ -177,24 +198,3 @@ C:\Users\Nathan\AppData\Local\Temp\2026-05-13_12-55_FlowCSA_360_1b_ADTA_A.blend
C:\Users\Nathan\AppData\Local\Temp\2026-05-13_13-03_FlowCSA_360_1b_ADTA_A.blend
C:\Users\Nathan\AppData\Local\Temp\2026-05-13_13-11_FlowCSA_360_1b_ADTA_A.blend
A:\1 Amazon_Active_Projects\260420_FLOW_2026\Blends\animations\FlowCSA_360_1bA.blend
C:\Users\Nathan\AppData\Local\Temp\2026-05-12_17-26_FlowCSA_360_1b_ADTA_B.blend
F:\jobs\FlowCSA_360_1b_ADTA_C-jh9z\_outside_project\A\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Bebe_v1.2.blend
F:\jobs\FlowCSA_360_1b_ADTA_C\_outside_project\A\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Bebe_v1.2.blend
A:\1 Amazon_Active_Projects\260420_FLOW_2026\Blends\animations\FlowCSA_360_1b_ADTA_C.blend
F:\jobs\FlowCSA_360_1b_ADTA_C-sgxq\_outside_project\A\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Bebe_v1.2.blend
F:\jobs\FlowCSA_360_1b_ADTA_C-n6rw\FlowCSA_360_1b_ADTA_C.blend
F:\jobs\FlowCSA_360_1b_ADTA_C-n6rw\_outside_project\A\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Bebe_v1.2.blend
C:\Users\Nathan\AppData\Local\Temp\bat-flow-not-relative-only\_outside_project\a\1 Amazon_Active_Projects\1 BlenderAssets\Amazon\Char\Cartoon3\Bebe_v1.2.blend
A:\1 Amazon_Active_Projects\260420_FLOW_2026\Blends\animations\FlowCSA_360_1bC.blend
A:\1 Amazon_Active_Projects\260512_Agile_Gemba\Assets\Blends\Marker.blend
C:\Users\Nathan\Downloads\SS_2026_animation_2b.blend
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M6_13.blend
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M6_12.blend
F:\jobs\Pak Buddy Animation_v2\Pak Buddy Animation_v2.flamenco.blend
C:\Users\Nathan\Downloads\House.blend
C:\Users\Nathan\Downloads\UP_M5_3.blend
C:\Users\Nathan\Downloads\UP_M2_animation_d.blend
G:\Amazon\2024\240402_Amazon_Coaching&Upstream\Upstream\blends\mod 2\animation 1\iUP_M2_6.blend
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M5_6.blend
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M2_5.blend
A:\1 Amazon_Active_Projects\260422_Upstream_edits_2026\Blends\stills\iUP_M5_13.blend
Binary file not shown.
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,278 +0,0 @@
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 }}
@@ -1,118 +0,0 @@
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
@@ -1,36 +0,0 @@
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
@@ -1,19 +0,0 @@
{
"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}" }
]
}
]
]
}
@@ -676,6 +676,9 @@ class LIPSYNC2D_PoseAssetsAnimator:
:return: The corrected end frame for the word.
:rtype: int
"""
if visemes_data["visemes_len"] <= 0:
return word_start_frame
return word_start_frame + round(
visemes_data["visemes_parts"] * (visemes_data["visemes_len"] - 1)
)
@@ -497,6 +497,9 @@ class LIPSYNC2D_ShapeKeysAnimator:
@staticmethod
def get_corrected_end_frame(word_start_frame, visemes_data: VisemeData) -> int:
if visemes_data["visemes_len"] <= 0:
return word_start_frame
return word_start_frame + round(
visemes_data["visemes_parts"] * (visemes_data["visemes_len"] - 1)
)
@@ -47,7 +47,17 @@ class LIPSYNC2D_DialogInspector:
visemes = [LIPSYNC2D_DialogInspector.ipaphoneme_to_viseme(p) for p in phoneme]
visemes_no_sil = [v for v in visemes if v != "sil"]
visemes_len = len(visemes_no_sil)
visemes_parts = duration / len(visemes_no_sil)
# Some phoneme strings collapse to silence-only markers after normalization.
# Keep those words skippable instead of crashing on division by zero.
if visemes_len == 0:
return {
"visemes": [],
"visemes_len": 0,
"visemes_parts": 0,
}
visemes_parts = duration / visemes_len
return {
"visemes": visemes_no_sil,
@@ -106,10 +106,25 @@ class LIPSYNC2D_VoskHelper():
raise Exception(f"Error while loading cached files index.{e}")
if langs_list:
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
# List should already be filtered. This is done as a safety measure.
all_langs = [(l["lang"], l["lang_text"]) for l in langs_list if
l["lang"] != "all" and l["obsolete"] == "false" and l['type'] == 'small' and l[
"lang"] not in LIPSYNC2D_VoskHelper.excluded_lang]
for l in langs_list:
if l["lang"] == "all" or l["obsolete"] != "false" or l["lang"] in LIPSYNC2D_VoskHelper.excluded_lang:
continue
name = l["name"]
lang_text = l["lang_text"]
size_text = l.get('size_text', 'Unknown')
label = f"{lang_text} ({size_text})"
# Check if installed
model_path = cache_path / name
if model_path.exists() and model_path.is_dir():
label = f"{label} - Installed"
all_langs.append((name, label))
all_langs.sort(key=lambda x: x[1])
all_langs = [('none', "-- None --", "No selection"), ] + all_langs
@@ -161,9 +176,9 @@ class LIPSYNC2D_VoskHelper():
raise Exception(f"Error while loading cached files index. {e}")
all_dir_names = {
lang["name"]: (lang["lang"], lang["lang_text"], lang["lang"])
lang["name"]: (lang["name"], f"{lang['lang_text']} ({lang.get('size_text', 'Unknown')})", lang["name"])
for lang in langs_list
if lang["type"] == "small" and lang["obsolete"] == "false"
if lang["obsolete"] == "false"
}
all_offline_langs = [all_dir_names[pathlib.Path(model_dir).name] for model_dir in ext_path.iterdir() if
@@ -192,7 +207,7 @@ class LIPSYNC2D_VoskHelper():
with open(LIPSYNC2D_VoskHelper.get_language_list_file(), "w", encoding="utf-8") as f:
full_list = list_request.json()
filter_list = [item for item in full_list if
item["type"] == "small" and item["obsolete"] == "false"]
item["obsolete"] == "false"]
json.dump(filter_list, f, ensure_ascii=False)
except Exception as e:
raise Exception(f"Error while creating cached file index: {e}")
@@ -217,7 +232,7 @@ class LIPSYNC2D_VoskHelper():
"""
Retrieve the list of available languages either online or offline.
This method determines available languages based on the applications online
This method determines available languages based on the application's online
connectivity. When online access is available, it returns the list of languages
available online; otherwise, it retrieves the list of cached languages. Each language entry
is represented by a tuple containing three string elements.
@@ -233,6 +248,32 @@ class LIPSYNC2D_VoskHelper():
available_langs = LIPSYNC2D_VoskHelper.get_available_langs_online() if bpy.app.online_access else LIPSYNC2D_VoskHelper.get_available_langs_offline()
return available_langs
@staticmethod
def get_model_url(model_name: str) -> str | None:
"""
Get the download URL for a specific model from the cached languages list.
:param model_name: The name of the model to get the URL for
:return: The download URL for the model, or None if not found
:rtype: str | None
"""
cached_langs_list_file = LIPSYNC2D_VoskHelper.get_language_list_file()
if not os.path.isfile(cached_langs_list_file):
return None
try:
with open(cached_langs_list_file, "r", encoding="utf-8") as f:
langs_list = json.load(f)
for lang in langs_list:
if lang.get("name") == model_name:
return lang.get("url")
except Exception:
return None
return None
@staticmethod
def install_model(addon_prefs, context) -> None:
"""
@@ -254,7 +295,13 @@ class LIPSYNC2D_VoskHelper():
if addon_prefs.current_lang == "none":
return
# Get the model URL from the cached list
model_url = LIPSYNC2D_VoskHelper.get_model_url(addon_prefs.current_lang)
if not model_url:
raise Exception(f"Could not find download URL for model: {addon_prefs.current_lang}")
addon_prefs.is_downloading = True
addon_prefs.download_progress = 0.0
# Prepare env to ensure process can access to all modules
env = os.environ.copy()
@@ -263,7 +310,10 @@ class LIPSYNC2D_VoskHelper():
# Get custom cache path to change vosk default one
vosk_cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
args = [addon_prefs.current_lang, vosk_cache_path]
# Create progress file path
progress_file = LIPSYNC2D_VoskHelper.get_extension_path("tmp") / "download_progress.json"
args = [model_url, addon_prefs.current_lang, str(vosk_cache_path), str(progress_file)]
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
@@ -279,30 +329,101 @@ class LIPSYNC2D_VoskHelper():
bpy.app.timers.register(LIPSYNC2D_VoskHelper.check_worker_finished)
return
@staticmethod
def cancel_download() -> None:
"""
Cancel the current download process.
Terminates the worker process, cleans up partial files, and resets the download state.
"""
if LIPSYNC2D_VoskHelper.worker_proc:
try:
LIPSYNC2D_VoskHelper.worker_proc.terminate()
LIPSYNC2D_VoskHelper.worker_proc.wait(timeout=2)
except Exception:
try:
LIPSYNC2D_VoskHelper.worker_proc.kill()
except Exception:
pass
LIPSYNC2D_VoskHelper.worker_proc = None
# Clean up the partial zip file
all_preferences = bpy.context.preferences
package_name = get_package_name()
if package_name and all_preferences and all_preferences.addons:
addon = all_preferences.addons.get(package_name)
if addon and addon.preferences:
current_lang = addon.preferences.current_lang
if current_lang and current_lang != "none":
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
zip_path = cache_path / f"{current_lang}.zip"
if zip_path.exists():
try:
os.remove(zip_path)
except Exception:
pass
LIPSYNC2D_VoskHelper.reset_download_state()
@staticmethod
def reset_download_state() -> None:
"""
Reset the download state to clear any stuck or orphaned download status.
This is useful when a download was interrupted or Blender was closed mid-download.
"""
all_preferences = bpy.context.preferences
package_name = get_package_name()
if package_name is None or all_preferences is None or all_preferences.addons is None:
return
addon = all_preferences.addons.get(package_name)
if addon is None or addon.preferences is None:
return
addon.preferences.is_downloading = False
addon.preferences.download_progress = 0.0
# Clean up progress file if it exists
progress_file = LIPSYNC2D_VoskHelper.get_extension_path("tmp") / "download_progress.json"
if progress_file.exists():
try:
os.remove(progress_file)
except Exception:
pass
@staticmethod
def check_worker_finished() -> float | None:
"""
Checks the state of the worker process for LIPSYNC2D_VoskHelper and updates
related addon preferences if necessary.
related addon preferences if necessary. Also reads progress updates from
the progress file and updates the UI.
This method inspects the current state of the `worker_proc` attribute
belonging to `LIPSYNC2D_VoskHelper`. If the process is still active,
it returns 1. If the process has completed execution, the method performs
additional cleanup tasks, such as resetting the `worker_proc` to None,
retrieving the associated addon preferences from Blender's context,
and updating the `is_downloading` preference flag for the identified addon
(if applicable).
it reads the progress file and updates the download_progress property.
If the process has completed execution, the method performs cleanup tasks,
such as resetting the `worker_proc` to None, retrieving the associated
addon preferences from Blender's context, and updating the `is_downloading`
preference flag for the identified addon (if applicable).
:returns: 1 if the worker process is still active; None otherwise.
:rtype: int or None
:returns: 0.5 if the worker process is still active (check every 0.5s); None otherwise.
:rtype: float or None
"""
if LIPSYNC2D_VoskHelper.worker_proc is None:
# Check if we have an orphaned download state (Blender was restarted mid-download)
all_preferences = bpy.context.preferences
package_name = get_package_name()
if package_name and all_preferences and all_preferences.addons:
addon = all_preferences.addons.get(package_name)
if addon and addon.preferences and addon.preferences.is_downloading:
# Reset orphaned download state
LIPSYNC2D_VoskHelper.reset_download_state()
return None
if LIPSYNC2D_VoskHelper.worker_proc.poll() is None:
return 1
else:
LIPSYNC2D_VoskHelper.worker_proc = None
all_preferences = bpy.context.preferences
package_name = get_package_name()
@@ -312,8 +433,32 @@ class LIPSYNC2D_VoskHelper():
addon = all_preferences.addons.get(package_name)
if addon is None or addon.preferences is None:
return
return None
addon.preferences["is_downloading"] = False
# Read progress from file
progress_file = LIPSYNC2D_VoskHelper.get_extension_path("tmp") / "download_progress.json"
if progress_file.exists():
try:
with open(progress_file, "r") as f:
progress_data = json.load(f)
addon.preferences.download_progress = progress_data.get("progress", 0.0)
except Exception:
pass # Silently ignore read errors
if LIPSYNC2D_VoskHelper.worker_proc.poll() is None:
# Process still running, check again in 0.5 seconds
return 0.5
else:
# Process finished
LIPSYNC2D_VoskHelper.worker_proc = None
addon.preferences.is_downloading = False
addon.preferences.download_progress = 0.0
# Clean up progress file
if progress_file.exists():
try:
os.remove(progress_file)
except Exception:
pass
return None
@@ -31,6 +31,9 @@ phoneme_to_viseme_arkit_v2 = {
"ʀ": "RR",
"ɹ": "RR",
"ɻ": "RR",
"ɚ": "RR",
"ɜ:": "RR",
"ɜ": "RR",
# DD default consonants
"t": "DD",
"d": "DD",
@@ -48,6 +51,7 @@ phoneme_to_viseme_arkit_v2 = {
# kk velar stops
"k": "kk",
"g": "kk",
"ɡ": "kk",
# nn nasal group + "L"
"n": "nn",
"ŋ": "nn",
@@ -59,6 +63,7 @@ phoneme_to_viseme_arkit_v2 = {
# aa open and low/mid front vowels
"a": "aa",
"aː": "aa",
"ɑː": "aa",
"ä": "aa",
"æ": "aa",
"ɐ": "aa",
@@ -67,6 +67,7 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
target_channel = props.lip_sync_2d_bake_channel
muted_strips = []
file_path: str | None = None
try:
if target_channel != "ALL":
target_ch_int = int(target_channel)
@@ -83,10 +84,10 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
for strip in muted_strips:
strip.mute = False
if not os.path.isfile(f"{file_path}"):
if not file_path or not os.path.isfile(file_path):
self.report(
type={"ERROR"},
message="Error while importing extracted audio WAV file from /tmp",
message="Failed to mix down audio to the extension tmp folder",
)
self.reset_bake_range()
return {"CANCELLED"}
@@ -249,7 +250,12 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
@LIPSYNC2D_VoskHelper.setextensionpath
def get_model(self, prefs):
model = Model(lang=prefs.current_lang)
# Get the full path to the model directory
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
model_path = cache_path / prefs.current_lang
# Vosk Model expects the full path when using custom cache directories
model = Model(model_path=str(model_path))
return model
def vosk_recognize_voice(self, file_path: str, model: Model):
@@ -308,14 +314,21 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
bpy.context.scene.frame_end = self.frame_end
def extract_audio():
def extract_audio() -> str | None:
"""Mix down scene audio to a mono 16 kHz WAV for Vosk.
Returns the absolute filepath on success, or None if mixdown failed.
"""
package_name = cast(str, get_package_name())
output_path = bpy.utils.extension_path_user(package_name, path="tmp", create=True)
filepath = os.path.join(output_path, "cgp_lipsync_extracted_audio.wav")
bpy.ops.sound.mixdown(
# relative_path defaults to True and can rewrite absolute paths relative to
# the .blend file, so the WAV never lands where we check for it.
result = bpy.ops.sound.mixdown(
filepath=filepath,
check_existing=False,
relative_path=False,
container="WAV",
codec="PCM",
format="S16",
@@ -323,4 +336,7 @@ def extract_audio():
channels="MONO", # Vosk prefers mono
)
if result != {"FINISHED"} or not os.path.isfile(filepath):
return None
return filepath
@@ -0,0 +1,189 @@
import json
import bpy
from bpy_extras.io_utils import ExportHelper, ImportHelper
from ..Core.phoneme_to_viseme import viseme_items_mpeg4_v2
class LIPSYNC2D_OT_ExportMappings(bpy.types.Operator, ExportHelper):
"""Export Viseme Mappings to JSON"""
bl_idname = "lipsync2d.export_mappings"
bl_label = "Export Mappings"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(
default="*.json",
options={'HIDDEN'},
maxlen=255,
) # type: ignore
def execute(self, context):
obj = context.active_object
if not obj or not hasattr(obj, "lipsync2d_props"):
self.report({'ERROR'}, "No valid object selected")
return {'CANCELLED'}
props = obj.lipsync2d_props
anim_type = props.lip_sync_2d_lips_type
data = {
"animation_type": anim_type,
"mappings": []
}
visemes = viseme_items_mpeg4_v2(None, None)
for v in visemes:
viseme_id = v[0] # "sil", "PP", etc.
mapping_value = None
if anim_type == "SPRITESHEET":
prop_name = f"lip_sync_2d_viseme_{viseme_id}"
mapping_value = getattr(props, prop_name)
elif anim_type == "SHAPEKEYS":
prop_name = f"lip_sync_2d_viseme_shape_keys_{viseme_id}"
mapping_value = getattr(props, prop_name)
elif anim_type == "POSEASSETS":
prop_name = f"lip_sync_2d_viseme_pose_{viseme_id}"
action = getattr(props, prop_name)
if action:
mapping_value = action.name
if mapping_value is not None:
data["mappings"].append({
"viseme": viseme_id,
"mapping": mapping_value
})
try:
with open(self.filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4)
self.report({'INFO'}, f"Mappings exported to {self.filepath}")
except Exception as e:
self.report({'ERROR'}, f"Failed to export mappings: {str(e)}")
return {'CANCELLED'}
return {'FINISHED'}
class LIPSYNC2D_OT_ImportMappings(bpy.types.Operator, ImportHelper):
"""Import Viseme Mappings from JSON"""
bl_idname = "lipsync2d.import_mappings"
bl_label = "Import Mappings"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(
default="*.json",
options={'HIDDEN'},
maxlen=255,
) # type: ignore
create_missing: bpy.props.BoolProperty(
name="Create Missing",
description="Create missing Shape Keys or Actions if they don't exist",
default=False,
) # type: ignore
def execute(self, context):
obj = context.active_object
if not obj or not hasattr(obj, "lipsync2d_props"):
self.report({'ERROR'}, "No valid object selected")
return {'CANCELLED'}
props = obj.lipsync2d_props
current_anim_type = props.lip_sync_2d_lips_type
try:
with open(self.filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
self.report({'ERROR'}, f"Failed to read file: {str(e)}")
return {'CANCELLED'}
file_anim_type = data.get("animation_type")
if file_anim_type != current_anim_type:
self.report({'ERROR'}, f"Animation type mismatch. File: {file_anim_type}, Object: {current_anim_type}")
return {'CANCELLED'}
mappings = data.get("mappings", [])
# Helper to check if property exists on the object (for version compatibility)
def prop_exists(prop_name):
return hasattr(props, prop_name)
loaded_count = 0
skipped_count = 0
created_count = 0
for item in mappings:
viseme_id = item.get("viseme")
mapping_value = item.get("mapping")
if not viseme_id:
continue
if current_anim_type == "SPRITESHEET":
prop_name = f"lip_sync_2d_viseme_{viseme_id}"
if prop_exists(prop_name):
setattr(props, prop_name, int(mapping_value))
loaded_count += 1
else:
self.report({'INFO'}, f"Skipped '{viseme_id}': Property '{prop_name}' not found.")
skipped_count += 1
elif current_anim_type == "SHAPEKEYS":
prop_name = f"lip_sync_2d_viseme_shape_keys_{viseme_id}"
if prop_exists(prop_name):
# Check if shape key exists on the mesh or if it is "NONE"
if mapping_value == "NONE":
setattr(props, prop_name, mapping_value)
loaded_count += 1
elif obj.data and obj.data.shape_keys and mapping_value in obj.data.shape_keys.key_blocks:
setattr(props, prop_name, mapping_value)
loaded_count += 1
elif self.create_missing:
# Ensure Basis key exists first
if not obj.data.shape_keys:
obj.shape_key_add(name="Basis")
obj.shape_key_add(name=mapping_value)
# We need to refresh the EnumProperty somehow or just set the string if it allows (usually it stores string index)
# For now, just setting the string property should work if the Enum updates dynamically or if it's a StringProperty storage.
# However, for EnumProperty backed by a function, setting the string *should* work if the item is now in the list.
# Since we just added it to key_blocks, it should be valid.
setattr(props, prop_name, mapping_value)
created_count += 1
loaded_count += 1
else:
self.report({'INFO'}, f"Skipped '{viseme_id}': Shape Key '{mapping_value}' not found on object.")
skipped_count += 1
else:
self.report({'INFO'}, f"Skipped '{viseme_id}': Property '{prop_name}' not found.")
skipped_count += 1
elif current_anim_type == "POSEASSETS":
prop_name = f"lip_sync_2d_viseme_pose_{viseme_id}"
if prop_exists(prop_name):
if mapping_value == "None" or not mapping_value:
setattr(props, prop_name, None)
loaded_count += 1
else:
# Check if Action exists in bpy.data.actions
action = bpy.data.actions.get(mapping_value)
if action:
setattr(props, prop_name, action)
loaded_count += 1
elif self.create_missing:
new_action = bpy.data.actions.new(name=mapping_value)
# Mark as asset so it appears in the filtered dropdowns
new_action.asset_mark()
setattr(props, prop_name, new_action)
created_count += 1
loaded_count += 1
else:
self.report({'INFO'}, f"Skipped '{viseme_id}': Action '{mapping_value}' not found in blend file.")
skipped_count += 1
else:
self.report({'INFO'}, f"Skipped '{viseme_id}': Property '{prop_name}' not found.")
skipped_count += 1
self.report({'INFO'}, f"Imported {loaded_count} mappings. Created {created_count} missing items. Skipped {skipped_count}.")
return {'FINISHED'}
@@ -0,0 +1,37 @@
from typing import Literal
import bpy
from bpy.types import Context
from ..Preferences.LIPSYNC2D_AP_Preferences import LIPSYNC2D_VoskHelper
from ..LIPSYNC2D_Utils import get_package_name
class LIPSYNC2D_OT_InstallModel(bpy.types.Operator):
bl_idname = "wm.lipsync_install_model"
bl_label="Download Model"
bl_description="Download and install the selected language model"
def execute(self, context: Context) -> set[Literal['RUNNING_MODAL', 'CANCELLED', 'FINISHED', 'PASS_THROUGH', 'INTERFACE']]:
prefs = context.preferences.addons[get_package_name()].preferences
try:
LIPSYNC2D_VoskHelper.install_model(prefs, context)
except Exception as e:
self.report({'ERROR'}, f"Unable to install model: {e}")
return {'CANCELLED'}
self.report({'INFO'}, "Model download started...")
return {'FINISHED'}
class LIPSYNC2D_OT_CancelDownload(bpy.types.Operator):
bl_idname = "wm.lipsync_cancel_download"
bl_label="Cancel Download"
bl_description="Cancel the current model download"
def execute(self, context: Context) -> set[Literal['RUNNING_MODAL', 'CANCELLED', 'FINISHED', 'PASS_THROUGH', 'INTERFACE']]:
try:
LIPSYNC2D_VoskHelper.cancel_download()
except Exception as e:
self.report({'ERROR'}, f"Unable to cancel download: {e}")
return {'CANCELLED'}
self.report({'INFO'}, "Download cancelled")
return {'FINISHED'}
@@ -4,6 +4,7 @@ import bpy
from ..LIPSYNC2D_Utils import get_package_name
from ..lipsync_types import BpyContext, BpyObject, BpyPropertyGroup, BpyUILayout
from ..Core.LIPSYNC2D_VoskHelper import LIPSYNC2D_VoskHelper
class AnimatorPanelMixin:
@@ -23,9 +24,13 @@ class AnimatorPanelMixin:
self.prefs = bpy.context.preferences.addons[self.package_name].preferences
self.current_lang = self.prefs.current_lang if self.prefs is not None else None # type: ignore
self.is_model_installed = (
True if self.current_lang not in ["", "none"] else False
)
self.is_model_installed = False
if self.current_lang and self.current_lang not in ["", "none"]:
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
model_path = cache_path / self.current_lang
if model_path.exists() and model_path.is_dir():
self.is_model_installed = True
def draw_animation_section(self, context: BpyContext, layout: BpyUILayout):
raise NotImplementedError()
@@ -72,11 +77,24 @@ class AnimatorPanelMixin:
def draw_baking_section(self, context: BpyContext, layout: BpyUILayout):
if not self.is_model_installed:
new_row = layout.row()
if self.current_lang and self.current_lang != "none":
new_row.label(
text="Model not installed. Click to download.",
icon="INFO",
)
new_row = layout.row()
new_row.operator("wm.lipsync_install_model", text="Download Model", icon="IMPORT")
else:
new_row.label(
text="Select a Language Model before Analyzing audio",
icon="WARNING_LARGE",
)
new_row = layout.row()
new_row.operator(
"wm.lipsync_download_list", text="Reload Models List", icon="FILE_REFRESH"
)
new_row.enabled = bpy.app.online_access
new_row = layout.row()
new_row.operator(
"sound.cgp_analyze_audio", text="Bake audio", icon="SCRIPTPLUGINS"
@@ -78,6 +78,10 @@ class LIPSYNC2D_PT_Panel(bpy.types.Panel):
row.label(text="Animation type")
row.prop(props, "lip_sync_2d_lips_type", text="")
row = layout.row(align=True)
row.operator("lipsync2d.export_mappings", text="Export Viseme Mappings", icon="EXPORT")
row.operator("lipsync2d.import_mappings", text="Import Viseme Mappings", icon="IMPORT")
if self.animator_panel is None:
return
@@ -4,6 +4,7 @@ import bpy
from ..LIPSYNC2D_Utils import get_package_name
from ..Preferences.LIPSYNC2D_AP_Preferences import LIPSYNC2D_AP_Preferences
from ..Core.LIPSYNC2D_VoskHelper import LIPSYNC2D_VoskHelper
class LIPSYNC2D_PT_Settings(bpy.types.Panel):
@@ -26,7 +27,16 @@ class LIPSYNC2D_PT_Settings(bpy.types.Panel):
def draw_espeak_model_settings(self, layout: bpy.types.UILayout, prefs: bpy.types.AddonPreferences):
LIPSYNC2D_AP_Preferences.draw_online_access_warning(layout)
row = layout.row()
row = layout.row()
row.label(text="Language Model")
row.prop(prefs, "current_lang", text="")
LIPSYNC2D_AP_Preferences.draw_model_state(row) #type: ignore
row.prop(prefs, "current_lang", text="")
if prefs.current_lang != "none":
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
model_path = cache_path / prefs.current_lang
if not model_path.exists() or not model_path.is_dir():
row.operator("wm.lipsync_install_model", text="", icon="IMPORT")
LIPSYNC2D_AP_Preferences.draw_model_state(row) #type: ignore
@@ -15,17 +15,36 @@ from ..LIPSYNC2D_Utils import get_package_name
class LIPSYNC2D_AP_Preferences(bpy.types.AddonPreferences):
bl_idname = get_package_name() # type: ignore
current_lang: bpy.props.EnumProperty(name="Lip Sync Lang", items=LIPSYNC2D_VoskHelper.get_available_languages, update=LIPSYNC2D_VoskHelper.install_model, default=0) # type: ignore
current_lang: bpy.props.EnumProperty(name="Lip Sync Lang", items=LIPSYNC2D_VoskHelper.get_available_languages, default=0) # type: ignore
is_downloading: bpy.props.BoolProperty(name="Download Status", default=False) # type: ignore
download_progress: bpy.props.FloatProperty(name="Download Progress", default=0.0, min=0.0, max=100.0) # type: ignore
def draw(self, context):
layout = self.layout
# Check for orphaned download state (e.g., after Blender restart)
if self.is_downloading and LIPSYNC2D_VoskHelper.worker_proc is None:
self.is_downloading = False
self.download_progress = 0.0
LIPSYNC2D_VoskHelper.reset_download_state()
LIPSYNC2D_AP_Preferences.draw_online_access_warning(layout)
row = layout.row(align=True)
row.label(text="Language Model")
row.prop(self, "current_lang", text="")
# Use sub-layout for property to control enabled state independently
sub = row.row(align=True)
sub.enabled = not self.is_downloading
sub.prop(self, "current_lang", text="")
current_lang = self.current_lang
if current_lang != "none":
# Check if model installed
prefs = context.preferences.addons[get_package_name()].preferences
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
model_path = cache_path / current_lang
if (not model_path.exists() or not model_path.is_dir()) and not self.is_downloading:
row.operator("wm.lipsync_install_model", text="", icon="IMPORT")
LIPSYNC2D_AP_Preferences.draw_model_state(row)
LIPSYNC2D_AP_Preferences.draw_fetch_list_ops(layout)
@@ -51,10 +70,14 @@ class LIPSYNC2D_AP_Preferences(bpy.types.AddonPreferences):
installed = " Installed"
row.enabled = True
elif model_status == "DOWNLOADING":
installed = " Downloading..."
row.enabled = False
# Get progress percentage
prefs = bpy.context.preferences.addons[get_package_name()].preferences # type: ignore
progress = getattr(prefs, "download_progress", 0.0)
installed = f" Downloading... {int(progress)}%"
row.label(text=installed)
row.enabled = True
row.operator("wm.lipsync_cancel_download", text="", icon="X")
else:
row.label(text=installed)
@staticmethod
@@ -1,5 +1,35 @@
# 🗣️ Blender Lip Sync Addon
## NON OFFICIAL FORK - USE AT YOUR OWN RISK
[Offical Addon Repository](https://github.com/Charley3d/lip-sync)
### NEW FEATURES
- Blender 5.0+ Compatibility
- Linked Library Armature Support
- No need to make linked library armatures local
- Select a single audio channel to analyze
- Additional Phoneme to Viseme Mapping
- L sounds get their own viseme
- Add ɡ to kk
- Add ɚ to RR
- Add ɜ: to R
- Add ɑː to aa
- Optional debugging
- text output to text editor
- Advanced model download
- Download any available language model
- Progress bar
- Cancel download
- Export/Import Mappings
- Export viseme mappings to JSON
- Import viseme mappings from JSON
- Create missing shape keys or actions if they don't exist
### README FROM OFFICAL REPO
Official Documentation: [https://docs.cgpoly.io](https://docs.cgpoly.io/lip-sync-documentation)
A **Blender addon** for automatic lip-syncing based on audio input.
@@ -1,30 +1,115 @@
import json
import pathlib
import sys
from vosk import MODEL_DIRS, Model
import zipfile
import os
import requests
from typing import Optional
def install_model(lang: str, vosk_cache: str) -> None:
def write_progress(progress_file: pathlib.Path, progress: float, status: str = "downloading", error: Optional[str] = None) -> None:
"""
Download and/or Installs the specified language model for speech recognition. This function ensures
the target directory for the model exists and initializes the model using the
provided language and cache directory.
Write progress information to a JSON file for Blender to read.
:param lang: The language code for the model to be installed.
:param vosk_cache: The directory path where the model should be cached.
:return: None
:param progress_file: Path to the progress JSON file
:param progress: Download progress percentage (0-100)
:param status: Status string ("downloading", "extracting", "complete", "error")
:param error: Error message if status is "error"
"""
MODEL_DIRS[3] = pathlib.Path(vosk_cache)
model_path = MODEL_DIRS[3]
if not model_path.exists():
model_path.mkdir(parents=True, exist_ok=True)
Model(lang=lang)
data = {
"progress": progress,
"status": status,
"error": error
}
try:
with open(progress_file, 'w') as f:
json.dump(data, f)
except Exception:
pass # Silently fail if we can't write progress
args = sys.argv[1:]
lang = args[0]
vosk_cache = args[1]
def download_and_extract_model(model_url: str, model_name: str, cache_dir: pathlib.Path, progress_file: pathlib.Path) -> None:
"""
Download a Vosk model from the given URL with progress tracking and extract it.
install_model(args[0], args[1])
:param model_url: URL to download the model ZIP file from
:param model_name: Name of the model (used for the extracted directory)
:param cache_dir: Directory to extract the model to
:param progress_file: Path to write progress updates to
"""
zip_path = cache_dir / f"{model_name}.zip"
try:
# Ensure cache directory exists
if not cache_dir.exists():
cache_dir.mkdir(parents=True, exist_ok=True)
# Download with progress tracking
write_progress(progress_file, 0, "downloading")
response = requests.get(model_url, stream=True, timeout=30)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
last_reported_progress = -1
with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
# Update progress every 1% or every 100KB
if total_size > 0:
current_progress = (downloaded_size / total_size) * 90 # Reserve 10% for extraction
if int(current_progress) > last_reported_progress:
write_progress(progress_file, current_progress, "downloading")
last_reported_progress = int(current_progress)
# Extract the ZIP file
write_progress(progress_file, 90, "extracting")
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(cache_dir)
# Clean up ZIP file
if zip_path.exists():
os.remove(zip_path)
# Mark as complete
write_progress(progress_file, 100, "complete")
except requests.exceptions.RequestException as e:
write_progress(progress_file, 0, "error", f"Download failed: {str(e)}")
# Clean up partial download
if zip_path.exists():
os.remove(zip_path)
raise
except zipfile.BadZipFile as e:
write_progress(progress_file, 0, "error", f"Invalid ZIP file: {str(e)}")
if zip_path.exists():
os.remove(zip_path)
raise
except Exception as e:
write_progress(progress_file, 0, "error", f"Installation failed: {str(e)}")
if zip_path.exists():
os.remove(zip_path)
raise
if __name__ == "__main__":
# Parse command line arguments
# Expected: model_url, model_name, cache_dir, progress_file
args = sys.argv[1:]
if len(args) < 4:
print("Usage: wrk_download_models.py <model_url> <model_name> <cache_dir> <progress_file>")
sys.exit(1)
model_url = args[0]
model_name = args[1]
cache_dir = pathlib.Path(args[2])
progress_file = pathlib.Path(args[3])
download_and_extract_model(model_url, model_name, cache_dir, progress_file)
@@ -12,6 +12,8 @@ from .Operators.LIPSYNC2D_OT_RemoveLipSync import LIPSYNC2D_OT_RemoveLipSync
from .Operators.LIPSYNC2D_OT_RemoveNodeGroups import LIPSYNC2D_OT_RemoveNodeGroups
from .Operators.LIPSYNC2D_OT_SetCustomProperties import LIPSYNC2D_OT_SetCustomProperties
from .Operators.LIPSYNC2D_OT_SetMouthArea import LIPSYNC2D_OT_SetMouthArea
from .Operators.LIPSYNC2D_OT_InstallModel import LIPSYNC2D_OT_InstallModel, LIPSYNC2D_OT_CancelDownload
from .Operators.LIPSYNC2D_OT_ExportImportMappings import LIPSYNC2D_OT_ExportMappings, LIPSYNC2D_OT_ImportMappings
from .Panels.LIPSYNC2D_PT_Panel import LIPSYNC2D_PT_Panel
from .Panels.LIPSYNC2D_PT_Settings import LIPSYNC2D_PT_Settings
from .Panels.LIPSYNC2D_PT_Edit import LIPSYNC2D_PT_Edit
@@ -31,10 +33,14 @@ def register():
bpy.utils.register_class(LIPSYNC2D_PT_Panel)
bpy.utils.register_class(LIPSYNC2D_OT_SetMouthArea)
bpy.utils.register_class(LIPSYNC2D_OT_SetCustomProperties)
bpy.utils.register_class(LIPSYNC2D_OT_InstallModel)
bpy.utils.register_class(LIPSYNC2D_OT_CancelDownload)
bpy.utils.register_class(LIPSYNC2D_OT_AnalyzeAudio)
bpy.utils.register_class(LIPSYNC2D_OT_DownloadModelsList)
bpy.utils.register_class(LIPSYNC2D_OT_RemoveLipSync)
bpy.utils.register_class(LIPSYNC2D_OT_RemoveNodeGroups)
bpy.utils.register_class(LIPSYNC2D_OT_ExportMappings)
bpy.utils.register_class(LIPSYNC2D_OT_ImportMappings)
bpy.utils.register_class(LIPSYNC2D_PT_Edit)
bpy.utils.register_class(LIPSYNC2D_OT_RemoveAnimations)
bpy.utils.register_class(LIPSYNC2D_OT_refresh_pose_assets)
@@ -49,12 +55,16 @@ def unregister():
bpy.utils.unregister_class(LIPSYNC2D_PT_Panel)
bpy.utils.unregister_class(LIPSYNC2D_OT_SetMouthArea)
bpy.utils.unregister_class(LIPSYNC2D_OT_SetCustomProperties)
bpy.utils.unregister_class(LIPSYNC2D_OT_InstallModel)
bpy.utils.unregister_class(LIPSYNC2D_OT_CancelDownload)
bpy.utils.unregister_class(LIPSYNC2D_OT_AnalyzeAudio)
bpy.utils.unregister_class(LIPSYNC2D_AP_Preferences)
bpy.utils.unregister_class(LIPSYNC2D_PT_Settings)
bpy.utils.unregister_class(LIPSYNC2D_OT_DownloadModelsList)
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveLipSync)
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveNodeGroups)
bpy.utils.unregister_class(LIPSYNC2D_OT_ExportMappings)
bpy.utils.unregister_class(LIPSYNC2D_OT_ImportMappings)
bpy.utils.unregister_class(LIPSYNC2D_PT_Edit)
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveAnimations)
bpy.utils.unregister_class(LIPSYNC2D_OT_refresh_pose_assets)
@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "iocgpoly_lip_sync"
version = "1.0.6"
version = "2.3.2"
name = "Lip Sync"
tagline = "Automatic lip sync for your Blender models"
maintainer = "Charley 3D <charley@cgpoly.fr>"
@@ -12,7 +12,52 @@ license = [
"SPDX:GPL-3.0-or-later",
]
platforms = ["windows-x64", "macos-x64", "macos-arm64", "linux-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-macosx_10_9_x86_64.whl", "./wheels/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", "./wheels/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", "./wheels/cffi-1.17.1-cp311-cp311-win_amd64.whl", "./wheels/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", "./wheels/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.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-macosx_10_9_universal2.whl", "./wheels/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.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-macosx_10_12_x86_64.whl", "./wheels/rpds_py-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", "./wheels/rpds_py-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.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-macosx_10_6_universal2.whl", "./wheels/vosk-0.3.41-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", "./wheels/vosk-0.3.41-py3-none-win_amd64.whl"]
wheels = [
"./wheels/attrs-25.3.0-py3-none-any.whl",
"./wheels/babel-2.17.0-py3-none-any.whl",
"./wheels/certifi-2026.2.25-py3-none-any.whl",
"./wheels/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl",
"./wheels/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl",
"./wheels/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/cffi-1.17.1-cp313-cp313-win_amd64.whl",
"./wheels/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl",
"./wheels/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl",
"./wheels/colorama-0.4.6-py2.py3-none-any.whl",
"./wheels/csvw-3.5.1-py2.py3-none-any.whl",
"./wheels/dlinfo-2.0.0-py3-none-any.whl",
"./wheels/idna-3.11-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/pycparser-3.0-py3-none-any.whl",
"./wheels/pyparsing-3.3.2-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-cp313-cp313-macosx_10_13_x86_64.whl",
"./wheels/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl",
"./wheels/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/regex-2024.11.6-cp313-cp313-win_amd64.whl",
"./wheels/requests-2.33.1-py3-none-any.whl",
"./wheels/rfc3986-1.5.0-py2.py3-none-any.whl",
"./wheels/rpds_py-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl",
"./wheels/rpds_py-0.24.0-cp313-cp313-macosx_11_0_arm64.whl",
"./wheels/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"./wheels/rpds_py-0.24.0-cp313-cp313-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/urllib3-2.6.3-py3-none-any.whl",
"./wheels/vosk-0.3.41-py3-none-macosx_10_6_universal2.whl",
"./wheels/vosk-0.3.41-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl",
"./wheels/vosk-0.3.41-py3-none-win_amd64.whl",
]
[permissions]
network = "Need to download Voices Models to enable lip sync"
files = "Import Models / Extract libs to disk"
@@ -28,5 +73,18 @@ paths_exclude_pattern = [
".github/",
".gitignore",
"/scripts/",
".releaserc"
".releaserc",
"/.specstory/",
"/.cursor/",
"/.vscode/",
".cursorindexingignore",
"github_commands.txt",
]
# 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/certifi-2026.2.25-py3-none-any.whl", "./wheels/cffi-1.17.1-cp313-cp313-win_amd64.whl", "./wheels/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", "./wheels/colorama-0.4.6-py2.py3-none-any.whl", "./wheels/csvw-3.5.1-py2.py3-none-any.whl", "./wheels/dlinfo-2.0.0-py3-none-any.whl", "./wheels/idna-3.11-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/pycparser-3.0-py3-none-any.whl", "./wheels/pyparsing-3.3.2-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-cp313-cp313-win_amd64.whl", "./wheels/requests-2.33.1-py3-none-any.whl", "./wheels/rfc3986-1.5.0-py2.py3-none-any.whl", "./wheels/rpds_py-0.24.0-cp313-cp313-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/urllib3-2.6.3-py3-none-any.whl", "./wheels/vosk-0.3.41-py3-none-win_amd64.whl"]
# END GENERATED CONTENT.
@@ -1,169 +0,0 @@
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()
@@ -1,13 +0,0 @@
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))
@@ -1,7 +1,7 @@
schema_version = "1.0.0"
id = "viewport_pie_menus"
name = "3D Viewport Pie Menus"
version = "1.7.4"
version = "1.7.6"
tagline = "Various pie menus to speed up your workflow"
maintainer = "Community"
type = "add-on"
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
#
# SPDX-License-Identifier: GPL-3.0-or-later
@@ -11,7 +11,7 @@ from typing import Any, Callable
import bpy
from bpy.app.translations import pgettext_n as n_
from bpy.app.translations import pgettext_rpt as rpt_
from bpy.types import KeyMap, KeyMapItem, UILayout
from bpy.types import Context, KeyConfig, KeyMap, KeyMapItem, Operator, Region, UILayout
# Preserve across Reload Scripts (module reload) when possible, but also keep
# names defined for static analyzers.
@@ -39,19 +39,17 @@ KEYMAP_UI_NAMES = {
'Object Non-modal': n_("Object Mode"),
}
KMI_DEFAULTS = {
prop.identifier: prop.default
for prop in KeyMapItem.bl_rna.properties
if hasattr(prop, 'default')
}
KMI_DEFAULTS = {prop.identifier: prop.default for prop in KeyMapItem.bl_rna.properties if hasattr(prop, 'default')}
def register_hotkey(
bl_idname,
bl_idname: str,
*,
op_kwargs=None,
hotkey_kwargs=None,
keymap_name='Window'
):
op_kwargs: dict | None = None,
hotkey_kwargs: dict | None = None,
keymap_name='Window',
):
"""Register an operator hotkey in the add-on keyconfig, avoiding duplicates on Reload Scripts."""
if op_kwargs is None:
op_kwargs = {}
if hotkey_kwargs is None:
@@ -95,15 +93,16 @@ def register_hotkey(
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
ADDON_KEYMAPS.append((addon_km, addon_kmi))
def draw_hotkey_list(
context,
layout,
context: Context,
layout: UILayout,
*,
compact=False,
compact: bool | None = False,
debug=False,
sort_mode='BY_KEYMAP',
ignore_missing=False,
button_draw_func: Callable = None,
button_draw_func: Callable | None = None,
):
"""Draw the list of hotkeys registered by this add-on.
Will find the corresponding User KeyMapItems, which are safe to modify.
@@ -147,22 +146,15 @@ def draw_hotkey_list(
if "Missing" in kmi_name and ignore_missing:
continue
if sort_mode == 'BY_OPERATOR' and i > 0:
prev_km, prev_kmi = user_kmis[i-1]
prev_km, prev_kmi = user_kmis[i - 1]
_, _, prev_kmi_name = get_kmi_ui_info(prev_km, prev_kmi)
if prev_kmi_name != kmi_name:
layout.separator()
draw_kmi(
user_km,
user_kmi,
layout,
compact=compact,
button_draw_func=button_draw_func,
debug=debug
)
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, KeyMapItem]]:
def get_user_kmis_of_addon(context: Context, *, do_update=True) -> list[tuple[KeyMap, KeyMapItem]]:
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
ret = []
@@ -178,7 +170,10 @@ def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, Key
return ret
def get_user_kmi_of_addon(context, addon_km, addon_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
def get_user_kmi_of_addon(
context: Context, addon_km: KeyMap, addon_kmi: KeyMapItem
) -> tuple[KeyMap | None, KeyMapItem | None]:
"""Return the matching User KeyMap and KeyMapItem for the given add-on KMI pair."""
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
if not user_km:
# This should never happen.
@@ -192,7 +187,8 @@ def get_user_kmi_of_addon(context, addon_km, addon_kmi) -> tuple[KeyMap | None,
return user_km, user_kmi
def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
def get_kmi_ui_info(km: KeyMap, kmi: KeyMapItem) -> tuple[str, str, str]:
"""Return (keymap icon, keymap display name, operator display name) for a KeyMapItem."""
km_name: str = km.name
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
@@ -217,6 +213,7 @@ def get_kmi_ui_info(km, kmi) -> tuple[str, str, str]:
return km_icon, km_name, kmi_name
def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_kwargs: dict) -> KeyMapItem | None:
"""Loop over KeyMapItems of the provided KeyMap, and return the first entry,
if any, which matches the passed key combo, operator, and operator properties.
@@ -267,12 +264,13 @@ def find_kmi_in_km_by_data(km: KeyMap, hotkey_kwargs: dict, op_idname: str, op_k
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
def draw_kmi(
km: KeyMap,
kmi: KeyMapItem,
layout: UILayout,
compact=False,
button_draw_func: Callable = None,
button_draw_func: Callable | None = None,
debug=False,
):
"""Draw a KeyMapItem in the provided UI.
@@ -314,7 +312,8 @@ def draw_kmi(
layout.template_keymap_item_properties(kmi)
def get_sidebar(context):
def get_sidebar(context: Context) -> Region | None:
"""Return the N-panel (sidebar) region of the 3D Viewport, or None if not in a 3D Viewport."""
if not context.area.type == 'VIEW_3D':
return None
for region in context.area.regions:
@@ -322,7 +321,10 @@ def get_sidebar(context):
return region
def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMap | None, KeyMapItem | None]:
def find_matching_km_and_kmi(
context: Context, target_kc: KeyConfig, src_km: KeyMap, src_kmi: KeyMapItem
) -> tuple[KeyMap | None, KeyMapItem | None]:
"""Find the matching KeyMap and KeyMapItem in target_kc corresponding to the given src_km/src_kmi."""
target_km = find_matching_keymap(context, target_kc, src_km)
if not target_km:
raise RuntimeError(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
@@ -346,7 +348,8 @@ def find_matching_km_and_kmi(context, target_kc, src_km, src_kmi) -> tuple[KeyMa
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
return None, None
def find_matching_keymap(context, target_kc, src_km):
def find_matching_keymap(context: Context, target_kc: KeyConfig, src_km: KeyMap) -> KeyMap | None:
"""Find the equivalent keymap in another keyconfig."""
kc_user = context.window_manager.keyconfigs.user
@@ -365,20 +368,20 @@ def find_matching_keymap(context, target_kc, src_km):
return km
class WINDOW_OT_restore_deleted_hotkeys(bpy.types.Operator):
class WINDOW_OT_restore_deleted_hotkeys(Operator):
bl_idname = "window.restore_deleted_hotkeys"
bl_description = "Restore any missing built-in or add-on hotkeys.\n(These should be disabled instead of being deleted.)\nThis operation cannot be undone!"
# Undo flag is omitted, because this operation cannot be un-done.
bl_options = {'REGISTER'}
bl_label = "Restore Deleted Hotkeys"
def execute(self, context):
def execute(self, context: Context):
num_restored = restore_deleted_keymap_items_global(context)
self.report({'INFO'}, rpt_("Restored {num_restored} deleted keymaps.").format(num_restored=num_restored))
return {'FINISHED'}
def restore_deleted_keymap_items_global(context) -> int:
def restore_deleted_keymap_items_global(context: Context) -> int:
"""Deleting built-in or add-on KeyMapItems should never be done by users, as there's no way to recover them.
Changing the operator name also shouldn't be done, since that makes it impossible to track modifications.
Blender shouldn't even allow either of these things. You can disable instead of delete, and you can disable and add new entry instead of modifying idname.
@@ -400,7 +403,8 @@ def restore_deleted_keymap_items_global(context) -> int:
return total_restored
def restore_deleted_keymap_items(context, user_km_name) -> int:
def restore_deleted_keymap_items(context: Context, user_km_name: str) -> int:
"""Restore deleted KeyMapItems in user_km_name, preserving any user modifications that were present."""
keyconfigs = context.window_manager.keyconfigs
user_kc = keyconfigs.user
default_kc = keyconfigs.default
@@ -441,9 +445,21 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
user_km.keymap_items.new_from_item(temp_def_kmi)
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
for key in ('active', 'alt', 'any', 'ctrl', 'hyper', 'key_modifier',
'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
_user_km, user_kmi = find_matching_km_and_kmi(context, user_kc, default_km, default_kmi)
for key in (
'active',
'alt',
'any',
'ctrl',
'hyper',
'key_modifier',
'map_type',
'oskey',
'shift',
'repeat',
'type',
'value',
):
setattr(user_kmi, key, getattr(temp_kmi, key))
if temp_kmi.properties:
for key in temp_kmi.properties.keys():
@@ -463,9 +479,11 @@ def restore_deleted_keymap_items(context, user_km_name) -> int:
return len(user_km.keymap_items) - num_kmis
def any_to_hash(*args) -> str:
def any_to_hash(*args: Any) -> str:
"""Hash whatever."""
def stable(obj: Any):
def stable(obj: Any) -> Any:
# Make hashing deterministic across runs and independent of dict insertion order.
# Keep it conservative to avoid surprises with Blender RNA objects.
if isinstance(obj, dict):
@@ -3,7 +3,7 @@ import os
from pathlib import Path
import bpy
from bpy.types import AddonPreferences, PropertyGroup
from bpy.types import AddonPreferences, PropertyGroup, bpy_prop_collection
from rna_prop_ui import IDPropertyGroup
from .. import __package__ as base_package
@@ -75,7 +75,7 @@ class PrefsFileSaveLoadMixin:
def to_dict(self):
return props_to_dict_recursive(self, skip=type(self).omit_from_disk)
def save_prefs_to_file(self, _context=None):
def save_prefs_to_file(self):
filepath = get_prefs_filepath()
os.makedirs(os.path.dirname(filepath), exist_ok=True)
@@ -133,11 +133,9 @@ def props_to_dict_recursive(propgroup: IDPropertyGroup, skip=[]) -> dict:
if key in skip or key in ["rna_type", "bl_idname"]:
continue
value = getattr(propgroup, key)
if isinstance(value, bpy.types.bpy_prop_collection):
if isinstance(value, bpy_prop_collection):
ret[key] = [props_to_dict_recursive(elem) for elem in value]
elif isinstance(value, IDPropertyGroup) or isinstance(
value, bpy.types.PropertyGroup
):
elif isinstance(value, IDPropertyGroup) or isinstance(value, PropertyGroup):
ret[key] = props_to_dict_recursive(value)
else:
if hasattr(propgroup.bl_rna.properties[key], "enum_items"):
@@ -1,8 +1,8 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
# SPDX-FileCopyrightText: 2025-2026 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any
from typing import Any, Iterator
import bpy
from bpy.types import Object, PropertyGroup, bpy_prop_collection
@@ -16,7 +16,7 @@ from rna_prop_ui import IDPropertyGroup
# Compatible with the API changes in 5.0, but also older versions.
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
def copy_all_runtime_properties(src_id: Any, tgt_id: Any, x_mirror=False):
"""Copy add-on and custom properties from source to target.
Both should be the same type.
Should support anything that supports custom properties or property registration.
@@ -25,25 +25,29 @@ def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
def copy_all_custom_properties(src_id, tgt_id, x_mirror=False):
def copy_all_custom_properties(src_id: Any, tgt_id: Any, x_mirror=False):
"""Copy all custom properties (UI-created or dict-syntax) from source to target."""
for prop_name in get_custom_prop_names(src_id):
copy_custom_property(src_id, tgt_id, prop_name=prop_name, x_mirror=x_mirror)
def get_all_runtime_prop_names(owner):
def get_all_runtime_prop_names(owner: Any) -> list[str]:
"""Return names of all add-on and custom properties on owner."""
custom_props = list(owner.keys())
addon_props = get_addon_prop_names(owner)
props = custom_props + addon_props
return props
def get_custom_prop_names(owner):
def get_custom_prop_names(owner: Any) -> Iterator[str]:
"""Yield names of all custom (non-add-on) properties on owner."""
for prop_name in get_all_runtime_prop_names(owner):
if is_custom_prop(owner, prop_name):
yield prop_name
def get_addon_prop_names(owner):
def get_addon_prop_names(owner: Any) -> list[str]:
"""Return names of all add-on registered or system properties on owner."""
if bpy.app.version >= (5, 0, 0):
sys_props = owner.bl_system_properties_get()
if sys_props is None:
@@ -51,21 +55,17 @@ def get_addon_prop_names(owner):
return []
return list(sys_props.keys())
else:
return [
prop_name for prop_name in owner.keys()
if is_addon_prop(owner, prop_name)
]
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
def rename_custom_prop(owner, from_name, to_name):
assert is_custom_prop(owner, from_name), (
f"Property {from_name} of {owner} is not a Custom Property."
)
def rename_custom_prop(owner: Any, from_name: str, to_name: str):
"""Rename a custom property in-place, preserving its value and UI metadata."""
assert is_custom_prop(owner, from_name), f"Property {from_name} of {owner} is not a Custom Property."
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
remove_property(owner, from_name)
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
def copy_runtime_property(src_id: Any, tgt_id: Any, prop_name: str, x_mirror=False):
"""Copy add-on properties or custom properties."""
if is_addon_prop(src_id, prop_name):
if is_registered_addon_prop(src_id, prop_name):
@@ -125,7 +125,8 @@ def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=F
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
def copy_coll_prop(src_cp: bpy_prop_collection, tgt_cp: bpy_prop_collection, x_mirror=False):
"""Replace the contents of tgt_cp with a copy of each entry from src_cp."""
tgt_cp.clear()
for src_pg in src_cp:
assert isinstance(src_pg, PropertyGroup)
@@ -133,7 +134,7 @@ def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
copy_property_group(src_pg, tgt_pg, x_mirror)
def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=False):
def copy_custom_property(src_owner: Any, tgt_owner: Any, prop_name: str, new_name="", x_mirror=False) -> Any:
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
if not new_name:
new_name = prop_name
@@ -157,7 +158,8 @@ def copy_custom_property(src_owner, tgt_owner, prop_name, new_name="", x_mirror=
return tgt_owner.id_properties_ui(new_name)
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> bool:
def copy_single_addon_prop(src: Any, tgt: Any, prop_name: str, x_mirror=False) -> bool:
"""Copy a single scalar add-on property, optionally X-mirroring the value."""
if src.is_property_readonly(prop_name):
# This "early" exit has to come after CollectionProperty & PropertyGroup
# checks, since they are technically read-only.
@@ -167,11 +169,15 @@ def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> bool:
if x_mirror:
value = x_mirror_value(value)
try:
setattr(tgt, prop_name, value)
except TypeError:
print(f"{__package__}: Copy Addon property failed: \n\tName:{prop_name}\n\tValue:{value}")
return True
def x_mirror_value(value):
def x_mirror_value(value: Any) -> Any:
"""Return the X-mirrored equivalent of value, flipping string names and Object references."""
if isinstance(value, str):
return flip_name(value)
elif isinstance(value, Object):
@@ -184,51 +190,49 @@ def get_opposite_obj(obj: Object) -> Object:
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
flipped_name = flip_name(obj.name)
lib = obj.library
return (
bpy.data.objects.get((lib, flipped_name))
if lib
else bpy.data.objects.get(flipped_name)
) or obj
return (bpy.data.objects.get((lib, flipped_name)) if lib else bpy.data.objects.get(flipped_name)) or obj
def is_addon_prop(owner, prop_name):
def is_addon_prop(owner: Any, prop_name: str) -> bool:
"""Return whether prop_name is an add-on registered or system property on owner."""
if bpy.app.version >= (5, 0, 0):
return prop_name in get_addon_prop_names(owner)
else:
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
# They just behave completely as custom properties.
return prop_name in owner and (
isinstance(owner[prop_name], IDPropertyGroup)
or isinstance(owner[prop_name], list)
isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list)
)
def is_registered_addon_prop(owner, prop_name):
def is_registered_addon_prop(owner: Any, prop_name: str) -> bool:
"""Return whether prop_name is an actively registered add-on property on owner."""
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
def is_custom_prop(owner, prop_name):
def is_custom_prop(owner: Any, prop_name: str) -> bool:
"""Return whether prop_name is a user-created custom property on owner."""
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
def remove_property(obj, prop_name):
if is_custom_prop(obj, prop_name):
del obj[prop_name]
elif is_registered_addon_prop(obj, prop_name):
obj.property_unset(prop_name)
elif is_addon_prop(obj, prop_name):
disabled_addon_props = obj.bl_system_properties_get()
def remove_property(owner: Any, prop_name: str):
"""Remove a property from owner, regardless of whether it is custom or an add-on property."""
if is_custom_prop(owner, prop_name):
del owner[prop_name]
elif is_registered_addon_prop(owner, prop_name):
owner.property_unset(prop_name)
elif is_addon_prop(owner, prop_name):
disabled_addon_props = owner.bl_system_properties_get()
del disabled_addon_props[prop_name]
else:
raise KeyError(f"{prop_name} not found in {obj.name}")
raise KeyError(f"{prop_name} not found in {owner.name}")
def get_property_defaults(bpy_type: type, exclude: list[str] = []) -> dict[str, Any]:
def get_default(prop):
def get_default(prop: Any) -> Any:
if not hasattr(prop, "default"):
return None
if hasattr(prop, "default"):
if hasattr(prop, "default_array"):
default_array = list(prop.default_array)
if default_array:
@@ -244,8 +248,6 @@ def get_property_defaults(bpy_type: type, exclude: list[str] = []) -> dict[str,
return prop.default
assert False, f"Couldn't find default for {prop}"
return {
prop.identifier: get_default(prop)
for prop in bpy_type.bl_rna.properties
@@ -5,13 +5,22 @@
from bpy.types import UILayout
def aligned_label(layout: UILayout, *, alert=False, alignment="LEFT", **kwargs):
def aligned_label(layout: UILayout, *, factor=0.4, alert=False, alignment="LEFT", **kwargs):
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
row = layout.split(factor=0.4)
text = kwargs.get("text")
if not text:
return
del kwargs['text']
lines = text.split("\n")
for line in lines:
row = layout.split(factor=factor)
row.separator()
row.alert = alert
row.alignment = alignment
row.label(**kwargs)
row.label(text=line, **kwargs)
if 'icon' in kwargs:
kwargs['icon'] = 'BLANK1'
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
@@ -2,8 +2,9 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import contextlib
import io
import json
import os
from typing import Any
import bpy
@@ -12,6 +13,20 @@ from bpy.types import Context, Event, Operator
from .bs_utils.hotkeys import register_hotkey
# Reports from operators called via bpy.ops(...) are printed to stdout and then
# discarded rather than being surfaced to the caller, so we can't otherwise know
# what the fallback operator wants to report (eg. WM_OT_save_mainfile's "Saved" message).
REPORT_TYPESTR_TO_ENUM = {
"Debug": 'DEBUG',
"Info": 'INFO',
"Operator": 'OPERATOR',
"Property": 'PROPERTY',
"Warning": 'WARNING',
"Error": 'ERROR',
"Invalid Input Error": 'ERROR_INVALID_INPUT',
"Invalid Context Error": 'ERROR_INVALID_CONTEXT',
"Out Of Memory Error": 'ERROR_OUT_OF_MEMORY',
}
class WM_OT_call_menu_pie_drag_only(Operator):
"""Summon a pie menu only on mouse drag, otherwise initiate an operator"""
@@ -64,15 +79,21 @@ class WM_OT_call_menu_pie_drag_only(Operator):
fallback_op_kwargs = json.loads(fallback_op_kwargs)
if op_cls.poll():
stdout = io.StringIO()
try:
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
# HACK: Fix save notification not appearing when tapping Ctrl+S.
filename = os.path.basename(bpy.data.filepath)
self.report({'INFO'}, f'Saved "{filename}"')
with contextlib.redirect_stdout(stdout):
op_cls('INVOKE_DEFAULT', True, **fallback_op_kwargs)
except TypeError:
# This can apparently happen sometimes, see issue #86.
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
return
# Relay whatever the fallback operator reported (eg. "Saved" on Ctrl+S),
# since it would otherwise only be printed to the console and lost.
for line in stdout.getvalue().splitlines():
typestr, _, message = line.partition(": ")
report_type = REPORT_TYPESTR_TO_ENUM.get(typestr, 'INFO')
self.report({report_type}, message)
def modal(self, context: Context, event: Event) -> set[str]:
if event.value == 'RELEASE':
@@ -1110,7 +1110,7 @@ registry = [
def register():
for keymap_name, pie_idname in (
('Object Mode', PIE_MT_copy_object.bl_idname),
('3D View', PIE_MT_copy_object.bl_idname),
('Pose', PIE_MT_copy_pose.bl_idname),
):
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
@@ -444,6 +444,8 @@ class OBJECT_OT_pie_delete(Operator):
bl_label = "Delete From File"
bl_options = {'REGISTER', 'UNDO'}
_users_per_obj: list[tuple[ID, list[ID]]] = []
@staticmethod
def _get_items_to_delete(context: Context) -> list[ID]:
if context.area and context.area.type == 'OUTLINER':
@@ -476,6 +478,9 @@ class OBJECT_OT_pie_delete(Operator):
return
layout.use_property_decorate = False
if not self._users_per_obj:
return
for obj, users in self._users_per_obj:
if len(self._users_per_obj) > 1 and users:
header, body = layout.panel(
@@ -534,7 +539,7 @@ class OUTLINER_OT_delete_override_library_hierarchy(Operator):
@classmethod
def description(cls, _context: Context, properties: OperatorProperties) -> str:
if properties.remove_from_file:
return cls.bl_description
return cls.__doc__
return "Unlink the library override hierarchy that the active datablock belongs to from the active Scene"
@classmethod
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@ class FMT_PT_Panel(Panel):
bl_context = "scene"
bl_category = "FMT"
bl_icon = "OUTLINER_OB_HAIR"
bl_icon = "OUTLINER_OB_CURVES"
def invoke(self, context, event):
bpy.context.scene.fmt_search_bars.add()