update extensions; FMT, BATv2, lipsync
This commit is contained in:
@@ -10,6 +10,8 @@ D:\Work\9 iClone\Amazon\
|
|||||||
D:\Amazon\00_external-files\
|
D:\Amazon\00_external-files\
|
||||||
N:\1. CHARACTERS\remapping\
|
N:\1. CHARACTERS\remapping\
|
||||||
[Recent]
|
[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\
|
A:\1 Amazon_Active_Projects\260617_Dual_Induct\Blends\animations\
|
||||||
T:\Amazon\active\1 BlenderAssets\
|
T:\Amazon\active\1 BlenderAssets\
|
||||||
F:\renders\
|
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\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\Deliverable\Scan Aware\
|
||||||
A:\1 Amazon_Active_Projects\260127_Pick-and-Stage_2026_edits\Blends\stills\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
@@ -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
|
A:\1 Amazon_Active_Projects\260617_Dual_Induct\Blends\animations\Dual-induct_animation 1_horizontal.blend
|
||||||
C:\Users\Nathan\Downloads\test.blend
|
C:\Users\Nathan\Downloads\test.blend
|
||||||
A:\1 Amazon_Active_Projects\260709_Optimal_ADTA_stow\Blends\animations\OptimalStow_animation 2.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\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_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_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\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\Assets\Blends\lipsync.blend
|
||||||
A:\1 Amazon_Active_Projects\260622_Stow_and_Go\Blends\animations\STAG_animation 3b.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-03_FlowCSA_360_1b_ADTA_A.blend
|
||||||
C:\Users\Nathan\AppData\Local\Temp\2026-05-13_13-11_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
|
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
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 platform’s 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 }}
|
|
||||||
|
|
||||||
-118
@@ -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
|
|
||||||
|
|
||||||
-36
@@ -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.
|
:return: The corrected end frame for the word.
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
|
if visemes_data["visemes_len"] <= 0:
|
||||||
|
return word_start_frame
|
||||||
|
|
||||||
return word_start_frame + round(
|
return word_start_frame + round(
|
||||||
visemes_data["visemes_parts"] * (visemes_data["visemes_len"] - 1)
|
visemes_data["visemes_parts"] * (visemes_data["visemes_len"] - 1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -497,6 +497,9 @@ class LIPSYNC2D_ShapeKeysAnimator:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_corrected_end_frame(word_start_frame, visemes_data: VisemeData) -> int:
|
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(
|
return word_start_frame + round(
|
||||||
visemes_data["visemes_parts"] * (visemes_data["visemes_len"] - 1)
|
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 = [LIPSYNC2D_DialogInspector.ipaphoneme_to_viseme(p) for p in phoneme]
|
||||||
visemes_no_sil = [v for v in visemes if v != "sil"]
|
visemes_no_sil = [v for v in visemes if v != "sil"]
|
||||||
visemes_len = len(visemes_no_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 {
|
return {
|
||||||
"visemes": visemes_no_sil,
|
"visemes": visemes_no_sil,
|
||||||
|
|||||||
@@ -106,10 +106,25 @@ class LIPSYNC2D_VoskHelper():
|
|||||||
raise Exception(f"Error while loading cached files index.{e}")
|
raise Exception(f"Error while loading cached files index.{e}")
|
||||||
|
|
||||||
if langs_list:
|
if langs_list:
|
||||||
|
cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
|
||||||
|
|
||||||
# List should already be filtered. This is done as a safety measure.
|
# 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
|
for l in langs_list:
|
||||||
l["lang"] != "all" and l["obsolete"] == "false" and l['type'] == 'small' and l[
|
if l["lang"] == "all" or l["obsolete"] != "false" or l["lang"] in LIPSYNC2D_VoskHelper.excluded_lang:
|
||||||
"lang"] not 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.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
all_langs = [('none', "-- None --", "No selection"), ] + all_langs
|
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}")
|
raise Exception(f"Error while loading cached files index. {e}")
|
||||||
|
|
||||||
all_dir_names = {
|
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
|
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
|
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:
|
with open(LIPSYNC2D_VoskHelper.get_language_list_file(), "w", encoding="utf-8") as f:
|
||||||
full_list = list_request.json()
|
full_list = list_request.json()
|
||||||
filter_list = [item for item in full_list if
|
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)
|
json.dump(filter_list, f, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error while creating cached file index: {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.
|
Retrieve the list of available languages either online or offline.
|
||||||
|
|
||||||
This method determines available languages based on the application’s online
|
This method determines available languages based on the application's online
|
||||||
connectivity. When online access is available, it returns the list of languages
|
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
|
available online; otherwise, it retrieves the list of cached languages. Each language entry
|
||||||
is represented by a tuple containing three string elements.
|
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()
|
available_langs = LIPSYNC2D_VoskHelper.get_available_langs_online() if bpy.app.online_access else LIPSYNC2D_VoskHelper.get_available_langs_offline()
|
||||||
return available_langs
|
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
|
@staticmethod
|
||||||
def install_model(addon_prefs, context) -> None:
|
def install_model(addon_prefs, context) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -254,7 +295,13 @@ class LIPSYNC2D_VoskHelper():
|
|||||||
if addon_prefs.current_lang == "none":
|
if addon_prefs.current_lang == "none":
|
||||||
return
|
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.is_downloading = True
|
||||||
|
addon_prefs.download_progress = 0.0
|
||||||
|
|
||||||
# Prepare env to ensure process can access to all modules
|
# Prepare env to ensure process can access to all modules
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
@@ -262,8 +309,11 @@ class LIPSYNC2D_VoskHelper():
|
|||||||
|
|
||||||
# Get custom cache path to change vosk default one
|
# Get custom cache path to change vosk default one
|
||||||
vosk_cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
|
vosk_cache_path = LIPSYNC2D_VoskHelper.get_extension_path("cache")
|
||||||
|
|
||||||
|
# Create progress file path
|
||||||
|
progress_file = LIPSYNC2D_VoskHelper.get_extension_path("tmp") / "download_progress.json"
|
||||||
|
|
||||||
args = [addon_prefs.current_lang, vosk_cache_path]
|
args = [model_url, addon_prefs.current_lang, str(vosk_cache_path), str(progress_file)]
|
||||||
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(current_dir)
|
project_root = os.path.dirname(current_dir)
|
||||||
@@ -279,41 +329,136 @@ class LIPSYNC2D_VoskHelper():
|
|||||||
bpy.app.timers.register(LIPSYNC2D_VoskHelper.check_worker_finished)
|
bpy.app.timers.register(LIPSYNC2D_VoskHelper.check_worker_finished)
|
||||||
return
|
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
|
@staticmethod
|
||||||
def check_worker_finished() -> float | None:
|
def check_worker_finished() -> float | None:
|
||||||
"""
|
"""
|
||||||
Checks the state of the worker process for LIPSYNC2D_VoskHelper and updates
|
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
|
This method inspects the current state of the `worker_proc` attribute
|
||||||
belonging to `LIPSYNC2D_VoskHelper`. If the process is still active,
|
belonging to `LIPSYNC2D_VoskHelper`. If the process is still active,
|
||||||
it returns 1. If the process has completed execution, the method performs
|
it reads the progress file and updates the download_progress property.
|
||||||
additional cleanup tasks, such as resetting the `worker_proc` to None,
|
If the process has completed execution, the method performs cleanup tasks,
|
||||||
retrieving the associated addon preferences from Blender's context,
|
such as resetting the `worker_proc` to None, retrieving the associated
|
||||||
and updating the `is_downloading` preference flag for the identified addon
|
addon preferences from Blender's context, and updating the `is_downloading`
|
||||||
(if applicable).
|
preference flag for the identified addon (if applicable).
|
||||||
|
|
||||||
:returns: 1 if the worker process is still active; None otherwise.
|
:returns: 0.5 if the worker process is still active (check every 0.5s); None otherwise.
|
||||||
:rtype: int or None
|
:rtype: float or None
|
||||||
"""
|
"""
|
||||||
if LIPSYNC2D_VoskHelper.worker_proc is None:
|
if LIPSYNC2D_VoskHelper.worker_proc is None:
|
||||||
return None
|
# Check if we have an orphaned download state (Blender was restarted mid-download)
|
||||||
|
|
||||||
if LIPSYNC2D_VoskHelper.worker_proc.poll() is None:
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
LIPSYNC2D_VoskHelper.worker_proc = None
|
|
||||||
all_preferences = bpy.context.preferences
|
all_preferences = bpy.context.preferences
|
||||||
package_name = get_package_name()
|
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 package_name is None or all_preferences is None or all_preferences.addons is None:
|
all_preferences = bpy.context.preferences
|
||||||
return None
|
package_name = get_package_name()
|
||||||
|
|
||||||
addon = all_preferences.addons.get(package_name)
|
if package_name is None or all_preferences is None or all_preferences.addons is None:
|
||||||
|
return None
|
||||||
|
|
||||||
if addon is None or addon.preferences is None:
|
addon = all_preferences.addons.get(package_name)
|
||||||
return
|
|
||||||
|
|
||||||
addon.preferences["is_downloading"] = False
|
if addon is None or addon.preferences is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 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
|
return None
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ phoneme_to_viseme_arkit_v2 = {
|
|||||||
"ʀ": "RR",
|
"ʀ": "RR",
|
||||||
"ɹ": "RR",
|
"ɹ": "RR",
|
||||||
"ɻ": "RR",
|
"ɻ": "RR",
|
||||||
|
"ɚ": "RR",
|
||||||
|
"ɜ:": "RR",
|
||||||
|
"ɜ": "RR",
|
||||||
# DD – default consonants
|
# DD – default consonants
|
||||||
"t": "DD",
|
"t": "DD",
|
||||||
"d": "DD",
|
"d": "DD",
|
||||||
@@ -48,6 +51,7 @@ phoneme_to_viseme_arkit_v2 = {
|
|||||||
# kk – velar stops
|
# kk – velar stops
|
||||||
"k": "kk",
|
"k": "kk",
|
||||||
"g": "kk",
|
"g": "kk",
|
||||||
|
"ɡ": "kk",
|
||||||
# nn – nasal group + "L"
|
# nn – nasal group + "L"
|
||||||
"n": "nn",
|
"n": "nn",
|
||||||
"ŋ": "nn",
|
"ŋ": "nn",
|
||||||
@@ -59,6 +63,7 @@ phoneme_to_viseme_arkit_v2 = {
|
|||||||
# aa – open and low/mid front vowels
|
# aa – open and low/mid front vowels
|
||||||
"a": "aa",
|
"a": "aa",
|
||||||
"aː": "aa",
|
"aː": "aa",
|
||||||
|
"ɑː": "aa",
|
||||||
"ä": "aa",
|
"ä": "aa",
|
||||||
"æ": "aa",
|
"æ": "aa",
|
||||||
"ɐ": "aa",
|
"ɐ": "aa",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
|
|||||||
target_channel = props.lip_sync_2d_bake_channel
|
target_channel = props.lip_sync_2d_bake_channel
|
||||||
|
|
||||||
muted_strips = []
|
muted_strips = []
|
||||||
|
file_path: str | None = None
|
||||||
try:
|
try:
|
||||||
if target_channel != "ALL":
|
if target_channel != "ALL":
|
||||||
target_ch_int = int(target_channel)
|
target_ch_int = int(target_channel)
|
||||||
@@ -77,16 +78,16 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
|
|||||||
muted_strips.append(strip)
|
muted_strips.append(strip)
|
||||||
|
|
||||||
file_path = extract_audio()
|
file_path = extract_audio()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Restore mute state
|
# Restore mute state
|
||||||
for strip in muted_strips:
|
for strip in muted_strips:
|
||||||
strip.mute = False
|
strip.mute = False
|
||||||
|
|
||||||
if not os.path.isfile(f"{file_path}"):
|
if not file_path or not os.path.isfile(file_path):
|
||||||
self.report(
|
self.report(
|
||||||
type={"ERROR"},
|
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()
|
self.reset_bake_range()
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
@@ -249,7 +250,12 @@ class LIPSYNC2D_OT_AnalyzeAudio(bpy.types.Operator):
|
|||||||
|
|
||||||
@LIPSYNC2D_VoskHelper.setextensionpath
|
@LIPSYNC2D_VoskHelper.setextensionpath
|
||||||
def get_model(self, prefs):
|
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
|
return model
|
||||||
|
|
||||||
def vosk_recognize_voice(self, file_path: str, model: 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
|
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())
|
package_name = cast(str, get_package_name())
|
||||||
output_path = bpy.utils.extension_path_user(package_name, path="tmp", create=True)
|
output_path = bpy.utils.extension_path_user(package_name, path="tmp", create=True)
|
||||||
filepath = os.path.join(output_path, "cgp_lipsync_extracted_audio.wav")
|
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,
|
filepath=filepath,
|
||||||
check_existing=False,
|
check_existing=False,
|
||||||
|
relative_path=False,
|
||||||
container="WAV",
|
container="WAV",
|
||||||
codec="PCM",
|
codec="PCM",
|
||||||
format="S16",
|
format="S16",
|
||||||
@@ -323,4 +336,7 @@ def extract_audio():
|
|||||||
channels="MONO", # Vosk prefers mono
|
channels="MONO", # Vosk prefers mono
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if result != {"FINISHED"} or not os.path.isfile(filepath):
|
||||||
|
return None
|
||||||
|
|
||||||
return filepath
|
return filepath
|
||||||
|
|||||||
+189
@@ -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 ..LIPSYNC2D_Utils import get_package_name
|
||||||
|
|
||||||
from ..lipsync_types import BpyContext, BpyObject, BpyPropertyGroup, BpyUILayout
|
from ..lipsync_types import BpyContext, BpyObject, BpyPropertyGroup, BpyUILayout
|
||||||
|
from ..Core.LIPSYNC2D_VoskHelper import LIPSYNC2D_VoskHelper
|
||||||
|
|
||||||
|
|
||||||
class AnimatorPanelMixin:
|
class AnimatorPanelMixin:
|
||||||
@@ -23,9 +24,13 @@ class AnimatorPanelMixin:
|
|||||||
self.prefs = bpy.context.preferences.addons[self.package_name].preferences
|
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.current_lang = self.prefs.current_lang if self.prefs is not None else None # type: ignore
|
||||||
self.is_model_installed = (
|
self.is_model_installed = False
|
||||||
True if self.current_lang not in ["", "none"] else 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):
|
def draw_animation_section(self, context: BpyContext, layout: BpyUILayout):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
@@ -72,11 +77,24 @@ class AnimatorPanelMixin:
|
|||||||
def draw_baking_section(self, context: BpyContext, layout: BpyUILayout):
|
def draw_baking_section(self, context: BpyContext, layout: BpyUILayout):
|
||||||
if not self.is_model_installed:
|
if not self.is_model_installed:
|
||||||
new_row = layout.row()
|
new_row = layout.row()
|
||||||
new_row.label(
|
if self.current_lang and self.current_lang != "none":
|
||||||
text="Select a Language Model before Analyzing audio",
|
new_row.label(
|
||||||
icon="WARNING_LARGE",
|
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 = layout.row()
|
||||||
new_row.operator(
|
new_row.operator(
|
||||||
"sound.cgp_analyze_audio", text="Bake audio", icon="SCRIPTPLUGINS"
|
"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.label(text="Animation type")
|
||||||
row.prop(props, "lip_sync_2d_lips_type", text="")
|
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:
|
if self.animator_panel is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bpy
|
|||||||
|
|
||||||
from ..LIPSYNC2D_Utils import get_package_name
|
from ..LIPSYNC2D_Utils import get_package_name
|
||||||
from ..Preferences.LIPSYNC2D_AP_Preferences import LIPSYNC2D_AP_Preferences
|
from ..Preferences.LIPSYNC2D_AP_Preferences import LIPSYNC2D_AP_Preferences
|
||||||
|
from ..Core.LIPSYNC2D_VoskHelper import LIPSYNC2D_VoskHelper
|
||||||
|
|
||||||
|
|
||||||
class LIPSYNC2D_PT_Settings(bpy.types.Panel):
|
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):
|
def draw_espeak_model_settings(self, layout: bpy.types.UILayout, prefs: bpy.types.AddonPreferences):
|
||||||
LIPSYNC2D_AP_Preferences.draw_online_access_warning(layout)
|
LIPSYNC2D_AP_Preferences.draw_online_access_warning(layout)
|
||||||
row = layout.row()
|
row = layout.row()
|
||||||
|
row = layout.row()
|
||||||
row.label(text="Language Model")
|
row.label(text="Language Model")
|
||||||
|
|
||||||
row.prop(prefs, "current_lang", text="")
|
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
|
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):
|
class LIPSYNC2D_AP_Preferences(bpy.types.AddonPreferences):
|
||||||
bl_idname = get_package_name() # type: ignore
|
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
|
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):
|
def draw(self, context):
|
||||||
layout = self.layout
|
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)
|
LIPSYNC2D_AP_Preferences.draw_online_access_warning(layout)
|
||||||
|
|
||||||
row = layout.row(align=True)
|
row = layout.row(align=True)
|
||||||
row.label(text="Language Model")
|
# Use sub-layout for property to control enabled state independently
|
||||||
row.prop(self, "current_lang", text="")
|
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_model_state(row)
|
||||||
LIPSYNC2D_AP_Preferences.draw_fetch_list_ops(layout)
|
LIPSYNC2D_AP_Preferences.draw_fetch_list_ops(layout)
|
||||||
@@ -51,11 +70,15 @@ class LIPSYNC2D_AP_Preferences(bpy.types.AddonPreferences):
|
|||||||
installed = " Installed"
|
installed = " Installed"
|
||||||
row.enabled = True
|
row.enabled = True
|
||||||
elif model_status == "DOWNLOADING":
|
elif model_status == "DOWNLOADING":
|
||||||
installed = " Downloading..."
|
# Get progress percentage
|
||||||
row.enabled = False
|
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.label(text=installed)
|
||||||
|
row.enabled = True
|
||||||
|
row.operator("wm.lipsync_cancel_download", text="", icon="X")
|
||||||
|
else:
|
||||||
|
row.label(text=installed)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@LIPSYNC2D_VoskHelper.setextensionpath
|
@LIPSYNC2D_VoskHelper.setextensionpath
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
# 🗣️ Blender Lip Sync Addon
|
# 🗣️ 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)
|
Official Documentation: [https://docs.cgpoly.io](https://docs.cgpoly.io/lip-sync-documentation)
|
||||||
|
|
||||||
A **Blender addon** for automatic lip-syncing based on audio input.
|
A **Blender addon** for automatic lip-syncing based on audio input.
|
||||||
|
|||||||
@@ -1,30 +1,115 @@
|
|||||||
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
import zipfile
|
||||||
from vosk import MODEL_DIRS, Model
|
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
|
Write progress information to a JSON file for Blender to read.
|
||||||
the target directory for the model exists and initializes the model using the
|
|
||||||
provided language and cache directory.
|
:param progress_file: Path to the progress JSON file
|
||||||
|
:param progress: Download progress percentage (0-100)
|
||||||
:param lang: The language code for the model to be installed.
|
:param status: Status string ("downloading", "extracting", "complete", "error")
|
||||||
:param vosk_cache: The directory path where the model should be cached.
|
:param error: Error message if status is "error"
|
||||||
:return: None
|
|
||||||
"""
|
"""
|
||||||
MODEL_DIRS[3] = pathlib.Path(vosk_cache)
|
data = {
|
||||||
model_path = MODEL_DIRS[3]
|
"progress": progress,
|
||||||
|
"status": status,
|
||||||
if not model_path.exists():
|
"error": error
|
||||||
model_path.mkdir(parents=True, exist_ok=True)
|
}
|
||||||
|
try:
|
||||||
Model(lang=lang)
|
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:]
|
def download_and_extract_model(model_url: str, model_name: str, cache_dir: pathlib.Path, progress_file: pathlib.Path) -> None:
|
||||||
lang = args[0]
|
"""
|
||||||
vosk_cache = args[1]
|
Download a Vosk model from the given URL with progress tracking and extract it.
|
||||||
|
|
||||||
|
: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
|
||||||
|
|
||||||
install_model(args[0], args[1])
|
|
||||||
|
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_RemoveNodeGroups import LIPSYNC2D_OT_RemoveNodeGroups
|
||||||
from .Operators.LIPSYNC2D_OT_SetCustomProperties import LIPSYNC2D_OT_SetCustomProperties
|
from .Operators.LIPSYNC2D_OT_SetCustomProperties import LIPSYNC2D_OT_SetCustomProperties
|
||||||
from .Operators.LIPSYNC2D_OT_SetMouthArea import LIPSYNC2D_OT_SetMouthArea
|
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_Panel import LIPSYNC2D_PT_Panel
|
||||||
from .Panels.LIPSYNC2D_PT_Settings import LIPSYNC2D_PT_Settings
|
from .Panels.LIPSYNC2D_PT_Settings import LIPSYNC2D_PT_Settings
|
||||||
from .Panels.LIPSYNC2D_PT_Edit import LIPSYNC2D_PT_Edit
|
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_PT_Panel)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_SetMouthArea)
|
bpy.utils.register_class(LIPSYNC2D_OT_SetMouthArea)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_SetCustomProperties)
|
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_AnalyzeAudio)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_DownloadModelsList)
|
bpy.utils.register_class(LIPSYNC2D_OT_DownloadModelsList)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_RemoveLipSync)
|
bpy.utils.register_class(LIPSYNC2D_OT_RemoveLipSync)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_RemoveNodeGroups)
|
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_PT_Edit)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_RemoveAnimations)
|
bpy.utils.register_class(LIPSYNC2D_OT_RemoveAnimations)
|
||||||
bpy.utils.register_class(LIPSYNC2D_OT_refresh_pose_assets)
|
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_PT_Panel)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_SetMouthArea)
|
bpy.utils.unregister_class(LIPSYNC2D_OT_SetMouthArea)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_SetCustomProperties)
|
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_OT_AnalyzeAudio)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_AP_Preferences)
|
bpy.utils.unregister_class(LIPSYNC2D_AP_Preferences)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_PT_Settings)
|
bpy.utils.unregister_class(LIPSYNC2D_PT_Settings)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_DownloadModelsList)
|
bpy.utils.unregister_class(LIPSYNC2D_OT_DownloadModelsList)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveLipSync)
|
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveLipSync)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveNodeGroups)
|
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_PT_Edit)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveAnimations)
|
bpy.utils.unregister_class(LIPSYNC2D_OT_RemoveAnimations)
|
||||||
bpy.utils.unregister_class(LIPSYNC2D_OT_refresh_pose_assets)
|
bpy.utils.unregister_class(LIPSYNC2D_OT_refresh_pose_assets)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
schema_version = "1.0.0"
|
schema_version = "1.0.0"
|
||||||
id = "iocgpoly_lip_sync"
|
id = "iocgpoly_lip_sync"
|
||||||
version = "1.0.6"
|
version = "2.3.2"
|
||||||
name = "Lip Sync"
|
name = "Lip Sync"
|
||||||
tagline = "Automatic lip sync for your Blender models"
|
tagline = "Automatic lip sync for your Blender models"
|
||||||
maintainer = "Charley 3D <charley@cgpoly.fr>"
|
maintainer = "Charley 3D <charley@cgpoly.fr>"
|
||||||
@@ -12,7 +12,52 @@ license = [
|
|||||||
"SPDX:GPL-3.0-or-later",
|
"SPDX:GPL-3.0-or-later",
|
||||||
]
|
]
|
||||||
platforms = ["windows-x64", "macos-x64", "macos-arm64", "linux-x64"]
|
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]
|
[permissions]
|
||||||
network = "Need to download Voices Models to enable lip sync"
|
network = "Need to download Voices Models to enable lip sync"
|
||||||
files = "Import Models / Extract libs to disk"
|
files = "Import Models / Extract libs to disk"
|
||||||
@@ -28,5 +73,18 @@ paths_exclude_pattern = [
|
|||||||
".github/",
|
".github/",
|
||||||
".gitignore",
|
".gitignore",
|
||||||
"/scripts/",
|
"/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))
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
schema_version = "1.0.0"
|
schema_version = "1.0.0"
|
||||||
id = "viewport_pie_menus"
|
id = "viewport_pie_menus"
|
||||||
name = "3D 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"
|
tagline = "Various pie menus to speed up your workflow"
|
||||||
maintainer = "Community"
|
maintainer = "Community"
|
||||||
type = "add-on"
|
type = "add-on"
|
||||||
@@ -20,4 +20,4 @@ copyright = [
|
|||||||
"2021 Yashar JafarKhanpour",
|
"2021 Yashar JafarKhanpour",
|
||||||
]
|
]
|
||||||
[permissions]
|
[permissions]
|
||||||
files = "To load brush icons from disk"
|
files = "To load brush icons from disk"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# SPDX-FileCopyrightText: 2016-2024 Blender Foundation
|
# SPDX-FileCopyrightText: 2016-2026 Blender Foundation
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ from typing import Any, Callable
|
|||||||
import bpy
|
import bpy
|
||||||
from bpy.app.translations import pgettext_n as n_
|
from bpy.app.translations import pgettext_n as n_
|
||||||
from bpy.app.translations import pgettext_rpt as rpt_
|
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
|
# Preserve across Reload Scripts (module reload) when possible, but also keep
|
||||||
# names defined for static analyzers.
|
# names defined for static analyzers.
|
||||||
@@ -39,19 +39,17 @@ KEYMAP_UI_NAMES = {
|
|||||||
'Object Non-modal': n_("Object Mode"),
|
'Object Non-modal': n_("Object Mode"),
|
||||||
}
|
}
|
||||||
|
|
||||||
KMI_DEFAULTS = {
|
KMI_DEFAULTS = {prop.identifier: prop.default for prop in KeyMapItem.bl_rna.properties if hasattr(prop, 'default')}
|
||||||
prop.identifier: prop.default
|
|
||||||
for prop in KeyMapItem.bl_rna.properties
|
|
||||||
if hasattr(prop, 'default')
|
|
||||||
}
|
|
||||||
|
|
||||||
def register_hotkey(
|
def register_hotkey(
|
||||||
bl_idname,
|
bl_idname: str,
|
||||||
*,
|
*,
|
||||||
op_kwargs=None,
|
op_kwargs: dict | None = None,
|
||||||
hotkey_kwargs=None,
|
hotkey_kwargs: dict | None = None,
|
||||||
keymap_name='Window'
|
keymap_name='Window',
|
||||||
):
|
):
|
||||||
|
"""Register an operator hotkey in the add-on keyconfig, avoiding duplicates on Reload Scripts."""
|
||||||
if op_kwargs is None:
|
if op_kwargs is None:
|
||||||
op_kwargs = {}
|
op_kwargs = {}
|
||||||
if hotkey_kwargs is None:
|
if hotkey_kwargs is None:
|
||||||
@@ -95,15 +93,16 @@ def register_hotkey(
|
|||||||
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
|
KMI_HASHES[kmi_hash] = (addon_km, addon_kmi)
|
||||||
ADDON_KEYMAPS.append((addon_km, addon_kmi))
|
ADDON_KEYMAPS.append((addon_km, addon_kmi))
|
||||||
|
|
||||||
|
|
||||||
def draw_hotkey_list(
|
def draw_hotkey_list(
|
||||||
context,
|
context: Context,
|
||||||
layout,
|
layout: UILayout,
|
||||||
*,
|
*,
|
||||||
compact=False,
|
compact: bool | None = False,
|
||||||
debug=False,
|
debug=False,
|
||||||
sort_mode='BY_KEYMAP',
|
sort_mode='BY_KEYMAP',
|
||||||
ignore_missing=False,
|
ignore_missing=False,
|
||||||
button_draw_func: Callable = None,
|
button_draw_func: Callable | None = None,
|
||||||
):
|
):
|
||||||
"""Draw the list of hotkeys registered by this add-on.
|
"""Draw the list of hotkeys registered by this add-on.
|
||||||
Will find the corresponding User KeyMapItems, which are safe to modify.
|
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:
|
if "Missing" in kmi_name and ignore_missing:
|
||||||
continue
|
continue
|
||||||
if sort_mode == 'BY_OPERATOR' and i > 0:
|
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)
|
_, _, prev_kmi_name = get_kmi_ui_info(prev_km, prev_kmi)
|
||||||
if prev_kmi_name != kmi_name:
|
if prev_kmi_name != kmi_name:
|
||||||
layout.separator()
|
layout.separator()
|
||||||
|
|
||||||
draw_kmi(
|
draw_kmi(user_km, user_kmi, layout, compact=compact, button_draw_func=button_draw_func, debug=debug)
|
||||||
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)."""
|
"""Return a list of (KeyMap, KeyMapItem) tuples of user-shortcuts (the ones that can be modified by user)."""
|
||||||
ret = []
|
ret = []
|
||||||
|
|
||||||
@@ -178,7 +170,10 @@ def get_user_kmis_of_addon(context, *, do_update=True) -> list[tuple[KeyMap, Key
|
|||||||
return ret
|
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)
|
user_km = context.window_manager.keyconfigs.user.keymaps.get(addon_km.name)
|
||||||
if not user_km:
|
if not user_km:
|
||||||
# This should never happen.
|
# 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
|
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_name: str = km.name
|
||||||
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
|
km_icon = KEYMAP_ICONS.get(km_name, 'BLANK1')
|
||||||
km_name = KEYMAP_UI_NAMES.get(km_name, km_name)
|
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
|
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:
|
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,
|
"""Loop over KeyMapItems of the provided KeyMap, and return the first entry,
|
||||||
if any, which matches the passed key combo, operator, and operator properties.
|
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)
|
return next((kmi for kmi in km.keymap_items if is_kmi_matching(kmi, hotkey_kwargs, op_idname, op_kwargs)), None)
|
||||||
|
|
||||||
|
|
||||||
def draw_kmi(
|
def draw_kmi(
|
||||||
km: KeyMap,
|
km: KeyMap,
|
||||||
kmi: KeyMapItem,
|
kmi: KeyMapItem,
|
||||||
layout: UILayout,
|
layout: UILayout,
|
||||||
compact=False,
|
compact=False,
|
||||||
button_draw_func: Callable = None,
|
button_draw_func: Callable | None = None,
|
||||||
debug=False,
|
debug=False,
|
||||||
):
|
):
|
||||||
"""Draw a KeyMapItem in the provided UI.
|
"""Draw a KeyMapItem in the provided UI.
|
||||||
@@ -314,7 +312,8 @@ def draw_kmi(
|
|||||||
layout.template_keymap_item_properties(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':
|
if not context.area.type == 'VIEW_3D':
|
||||||
return None
|
return None
|
||||||
for region in context.area.regions:
|
for region in context.area.regions:
|
||||||
@@ -322,7 +321,10 @@ def get_sidebar(context):
|
|||||||
return region
|
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)
|
target_km = find_matching_keymap(context, target_kc, src_km)
|
||||||
if not target_km:
|
if not target_km:
|
||||||
raise RuntimeError(f"Failed to find KeyMap '{src_km.name}' in KeyConfig '{target_kc.name}'")
|
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.
|
# We will return here eg. when looking for an add-on keymap in the default keyconfig.
|
||||||
return None, None
|
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."""
|
"""Find the equivalent keymap in another keyconfig."""
|
||||||
|
|
||||||
kc_user = context.window_manager.keyconfigs.user
|
kc_user = context.window_manager.keyconfigs.user
|
||||||
@@ -365,20 +368,20 @@ def find_matching_keymap(context, target_kc, src_km):
|
|||||||
return 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_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!"
|
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.
|
# Undo flag is omitted, because this operation cannot be un-done.
|
||||||
bl_options = {'REGISTER'}
|
bl_options = {'REGISTER'}
|
||||||
bl_label = "Restore Deleted Hotkeys"
|
bl_label = "Restore Deleted Hotkeys"
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context: Context):
|
||||||
num_restored = restore_deleted_keymap_items_global(context)
|
num_restored = restore_deleted_keymap_items_global(context)
|
||||||
self.report({'INFO'}, rpt_("Restored {num_restored} deleted keymaps.").format(num_restored=num_restored))
|
self.report({'INFO'}, rpt_("Restored {num_restored} deleted keymaps.").format(num_restored=num_restored))
|
||||||
return {'FINISHED'}
|
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.
|
"""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.
|
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.
|
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
|
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
|
keyconfigs = context.window_manager.keyconfigs
|
||||||
user_kc = keyconfigs.user
|
user_kc = keyconfigs.user
|
||||||
default_kc = keyconfigs.default
|
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)
|
user_km.keymap_items.new_from_item(temp_def_kmi)
|
||||||
|
|
||||||
for (default_km, default_kmi), (temp_km, temp_kmi) in kmis_user_modified:
|
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)
|
_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',
|
for key in (
|
||||||
'map_type', 'oskey', 'shift', 'repeat', 'type', 'value'):
|
'active',
|
||||||
|
'alt',
|
||||||
|
'any',
|
||||||
|
'ctrl',
|
||||||
|
'hyper',
|
||||||
|
'key_modifier',
|
||||||
|
'map_type',
|
||||||
|
'oskey',
|
||||||
|
'shift',
|
||||||
|
'repeat',
|
||||||
|
'type',
|
||||||
|
'value',
|
||||||
|
):
|
||||||
setattr(user_kmi, key, getattr(temp_kmi, key))
|
setattr(user_kmi, key, getattr(temp_kmi, key))
|
||||||
if temp_kmi.properties:
|
if temp_kmi.properties:
|
||||||
for key in temp_kmi.properties.keys():
|
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
|
return len(user_km.keymap_items) - num_kmis
|
||||||
|
|
||||||
def any_to_hash(*args) -> str:
|
|
||||||
|
def any_to_hash(*args: Any) -> str:
|
||||||
"""Hash whatever."""
|
"""Hash whatever."""
|
||||||
def stable(obj: Any):
|
|
||||||
|
def stable(obj: Any) -> Any:
|
||||||
# Make hashing deterministic across runs and independent of dict insertion order.
|
# Make hashing deterministic across runs and independent of dict insertion order.
|
||||||
# Keep it conservative to avoid surprises with Blender RNA objects.
|
# Keep it conservative to avoid surprises with Blender RNA objects.
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import AddonPreferences, PropertyGroup
|
from bpy.types import AddonPreferences, PropertyGroup, bpy_prop_collection
|
||||||
from rna_prop_ui import IDPropertyGroup
|
from rna_prop_ui import IDPropertyGroup
|
||||||
|
|
||||||
from .. import __package__ as base_package
|
from .. import __package__ as base_package
|
||||||
@@ -75,7 +75,7 @@ class PrefsFileSaveLoadMixin:
|
|||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return props_to_dict_recursive(self, skip=type(self).omit_from_disk)
|
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()
|
filepath = get_prefs_filepath()
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
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"]:
|
if key in skip or key in ["rna_type", "bl_idname"]:
|
||||||
continue
|
continue
|
||||||
value = getattr(propgroup, key)
|
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]
|
ret[key] = [props_to_dict_recursive(elem) for elem in value]
|
||||||
elif isinstance(value, IDPropertyGroup) or isinstance(
|
elif isinstance(value, IDPropertyGroup) or isinstance(value, PropertyGroup):
|
||||||
value, bpy.types.PropertyGroup
|
|
||||||
):
|
|
||||||
ret[key] = props_to_dict_recursive(value)
|
ret[key] = props_to_dict_recursive(value)
|
||||||
else:
|
else:
|
||||||
if hasattr(propgroup.bl_rna.properties[key], "enum_items"):
|
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
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Iterator
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Object, PropertyGroup, bpy_prop_collection
|
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.
|
# 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.
|
"""Copy add-on and custom properties from source to target.
|
||||||
Both should be the same type.
|
Both should be the same type.
|
||||||
Should support anything that supports custom properties or property registration.
|
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)
|
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):
|
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)
|
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())
|
custom_props = list(owner.keys())
|
||||||
addon_props = get_addon_prop_names(owner)
|
addon_props = get_addon_prop_names(owner)
|
||||||
props = custom_props + addon_props
|
props = custom_props + addon_props
|
||||||
return 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):
|
for prop_name in get_all_runtime_prop_names(owner):
|
||||||
if is_custom_prop(owner, prop_name):
|
if is_custom_prop(owner, prop_name):
|
||||||
yield 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):
|
if bpy.app.version >= (5, 0, 0):
|
||||||
sys_props = owner.bl_system_properties_get()
|
sys_props = owner.bl_system_properties_get()
|
||||||
if sys_props is None:
|
if sys_props is None:
|
||||||
@@ -51,21 +55,17 @@ def get_addon_prop_names(owner):
|
|||||||
return []
|
return []
|
||||||
return list(sys_props.keys())
|
return list(sys_props.keys())
|
||||||
else:
|
else:
|
||||||
return [
|
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
|
||||||
prop_name for prop_name in owner.keys()
|
|
||||||
if is_addon_prop(owner, prop_name)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def rename_custom_prop(owner, from_name, to_name):
|
def rename_custom_prop(owner: Any, from_name: str, to_name: str):
|
||||||
assert is_custom_prop(owner, from_name), (
|
"""Rename a custom property in-place, preserving its value and UI metadata."""
|
||||||
f"Property {from_name} of {owner} is not a Custom Property."
|
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)
|
copy_custom_property(owner, owner, from_name, new_name=to_name, x_mirror=False)
|
||||||
remove_property(owner, from_name)
|
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."""
|
"""Copy add-on properties or custom properties."""
|
||||||
if is_addon_prop(src_id, prop_name):
|
if is_addon_prop(src_id, prop_name):
|
||||||
if is_registered_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)
|
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()
|
tgt_cp.clear()
|
||||||
for src_pg in src_cp:
|
for src_pg in src_cp:
|
||||||
assert isinstance(src_pg, PropertyGroup)
|
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)
|
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)."""
|
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
|
||||||
if not new_name:
|
if not new_name:
|
||||||
new_name = prop_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)
|
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):
|
if src.is_property_readonly(prop_name):
|
||||||
# This "early" exit has to come after CollectionProperty & PropertyGroup
|
# This "early" exit has to come after CollectionProperty & PropertyGroup
|
||||||
# checks, since they are technically read-only.
|
# 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:
|
if x_mirror:
|
||||||
value = x_mirror_value(value)
|
value = x_mirror_value(value)
|
||||||
|
|
||||||
setattr(tgt, prop_name, 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
|
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):
|
if isinstance(value, str):
|
||||||
return flip_name(value)
|
return flip_name(value)
|
||||||
elif isinstance(value, Object):
|
elif isinstance(value, Object):
|
||||||
@@ -184,67 +190,63 @@ def get_opposite_obj(obj: Object) -> Object:
|
|||||||
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
|
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
|
||||||
flipped_name = flip_name(obj.name)
|
flipped_name = flip_name(obj.name)
|
||||||
lib = obj.library
|
lib = obj.library
|
||||||
return (
|
return (bpy.data.objects.get((lib, flipped_name)) if lib else bpy.data.objects.get(flipped_name)) or obj
|
||||||
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):
|
if bpy.app.version >= (5, 0, 0):
|
||||||
return prop_name in get_addon_prop_names(owner)
|
return prop_name in get_addon_prop_names(owner)
|
||||||
else:
|
else:
|
||||||
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
|
# 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.
|
# They just behave completely as custom properties.
|
||||||
return prop_name in owner and (
|
return prop_name in owner and (
|
||||||
isinstance(owner[prop_name], IDPropertyGroup)
|
isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list)
|
||||||
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
|
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)
|
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
|
||||||
|
|
||||||
|
|
||||||
def remove_property(obj, prop_name):
|
def remove_property(owner: Any, prop_name: str):
|
||||||
if is_custom_prop(obj, prop_name):
|
"""Remove a property from owner, regardless of whether it is custom or an add-on property."""
|
||||||
del obj[prop_name]
|
if is_custom_prop(owner, prop_name):
|
||||||
elif is_registered_addon_prop(obj, prop_name):
|
del owner[prop_name]
|
||||||
obj.property_unset(prop_name)
|
elif is_registered_addon_prop(owner, prop_name):
|
||||||
elif is_addon_prop(obj, prop_name):
|
owner.property_unset(prop_name)
|
||||||
disabled_addon_props = obj.bl_system_properties_get()
|
elif is_addon_prop(owner, prop_name):
|
||||||
|
disabled_addon_props = owner.bl_system_properties_get()
|
||||||
del disabled_addon_props[prop_name]
|
del disabled_addon_props[prop_name]
|
||||||
else:
|
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_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"):
|
if not hasattr(prop, "default"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if hasattr(prop, "default"):
|
if hasattr(prop, "default_array"):
|
||||||
if hasattr(prop, "default_array"):
|
default_array = list(prop.default_array)
|
||||||
default_array = list(prop.default_array)
|
if default_array:
|
||||||
if default_array:
|
if len(default_array) == 9:
|
||||||
if len(default_array) == 9:
|
return Matrix.Identity(3)
|
||||||
return Matrix.Identity(3)
|
elif len(default_array) == 16:
|
||||||
elif len(default_array) == 16:
|
return Matrix.Identity(4)
|
||||||
return Matrix.Identity(4)
|
elif len(default_array) == 3 and prop.type == 'FLOAT':
|
||||||
elif len(default_array) == 3 and prop.type == 'FLOAT':
|
default = default_array[0]
|
||||||
default = default_array[0]
|
return Vector((default, default, default))
|
||||||
return Vector((default, default, default))
|
else:
|
||||||
else:
|
return default_array
|
||||||
return default_array
|
|
||||||
|
|
||||||
return prop.default
|
return prop.default
|
||||||
|
|
||||||
assert False, f"Couldn't find default for {prop}"
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prop.identifier: get_default(prop)
|
prop.identifier: get_default(prop)
|
||||||
|
|||||||
@@ -5,13 +5,22 @@
|
|||||||
from bpy.types import UILayout
|
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%."""
|
"""Draw some text in the single-column-layout style, ie. offset by 60%."""
|
||||||
row = layout.split(factor=0.4)
|
|
||||||
row.separator()
|
text = kwargs.get("text")
|
||||||
row.alert = alert
|
if not text:
|
||||||
row.alignment = alignment
|
return
|
||||||
row.label(**kwargs)
|
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(text=line, **kwargs)
|
||||||
|
if 'icon' in kwargs:
|
||||||
|
kwargs['icon'] = 'BLANK1'
|
||||||
|
|
||||||
|
|
||||||
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
|
def label_split(layout: UILayout, *, alert=False, **kwargs) -> UILayout:
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
@@ -12,6 +13,20 @@ from bpy.types import Context, Event, Operator
|
|||||||
|
|
||||||
from .bs_utils.hotkeys import register_hotkey
|
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):
|
class WM_OT_call_menu_pie_drag_only(Operator):
|
||||||
"""Summon a pie menu only on mouse drag, otherwise initiate an 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)
|
fallback_op_kwargs = json.loads(fallback_op_kwargs)
|
||||||
|
|
||||||
if op_cls.poll():
|
if op_cls.poll():
|
||||||
|
stdout = io.StringIO()
|
||||||
try:
|
try:
|
||||||
result = op_cls('INVOKE_DEFAULT', **fallback_op_kwargs)
|
with contextlib.redirect_stdout(stdout):
|
||||||
if 'FINISHED' in result and self.fallback_operator == 'wm.save_mainfile':
|
op_cls('INVOKE_DEFAULT', True, **fallback_op_kwargs)
|
||||||
# HACK: Fix save notification not appearing when tapping Ctrl+S.
|
|
||||||
filename = os.path.basename(bpy.data.filepath)
|
|
||||||
self.report({'INFO'}, f'Saved "{filename}"')
|
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# This can apparently happen sometimes, see issue #86.
|
# This can apparently happen sometimes, see issue #86.
|
||||||
print(f"Pie Menu Fallback Operator failed: {self.fallback_operator}, {self.fallback_op_kwargs}")
|
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]:
|
def modal(self, context: Context, event: Event) -> set[str]:
|
||||||
if event.value == 'RELEASE':
|
if event.value == 'RELEASE':
|
||||||
|
|||||||
@@ -1110,7 +1110,7 @@ registry = [
|
|||||||
|
|
||||||
def register():
|
def register():
|
||||||
for keymap_name, pie_idname in (
|
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),
|
('Pose', PIE_MT_copy_pose.bl_idname),
|
||||||
):
|
):
|
||||||
WM_OT_call_menu_pie_drag_only.register_drag_hotkey(
|
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_label = "Delete From File"
|
||||||
bl_options = {'REGISTER', 'UNDO'}
|
bl_options = {'REGISTER', 'UNDO'}
|
||||||
|
|
||||||
|
_users_per_obj: list[tuple[ID, list[ID]]] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_items_to_delete(context: Context) -> list[ID]:
|
def _get_items_to_delete(context: Context) -> list[ID]:
|
||||||
if context.area and context.area.type == 'OUTLINER':
|
if context.area and context.area.type == 'OUTLINER':
|
||||||
@@ -476,6 +478,9 @@ class OBJECT_OT_pie_delete(Operator):
|
|||||||
return
|
return
|
||||||
layout.use_property_decorate = False
|
layout.use_property_decorate = False
|
||||||
|
|
||||||
|
if not self._users_per_obj:
|
||||||
|
return
|
||||||
|
|
||||||
for obj, users in self._users_per_obj:
|
for obj, users in self._users_per_obj:
|
||||||
if len(self._users_per_obj) > 1 and users:
|
if len(self._users_per_obj) > 1 and users:
|
||||||
header, body = layout.panel(
|
header, body = layout.panel(
|
||||||
@@ -534,7 +539,7 @@ class OUTLINER_OT_delete_override_library_hierarchy(Operator):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def description(cls, _context: Context, properties: OperatorProperties) -> str:
|
def description(cls, _context: Context, properties: OperatorProperties) -> str:
|
||||||
if properties.remove_from_file:
|
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"
|
return "Unlink the library override hierarchy that the active datablock belongs to from the active Scene"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ class FMT_PT_Panel(Panel):
|
|||||||
bl_context = "scene"
|
bl_context = "scene"
|
||||||
|
|
||||||
bl_category = "FMT"
|
bl_category = "FMT"
|
||||||
bl_icon = "OUTLINER_OB_HAIR"
|
bl_icon = "OUTLINER_OB_CURVES"
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
bpy.context.scene.fmt_search_bars.add()
|
bpy.context.scene.fmt_search_bars.add()
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user