├── rhubarb_lipsync ├── blender │ ├── __init__.py │ ├── aud_mock.py │ ├── icons_manager.py │ ├── mapping_uilist.py │ ├── cue_uilist.py │ ├── depsgraph_handler.py │ ├── strip_placement_preferences.py │ ├── capture_operators.py │ ├── ui_utils.py │ ├── auto_load.py │ ├── mapping_utils.py │ └── dropdown_helper.py ├── rhubarb │ ├── __init__.py │ ├── mouth_shape_info.py │ ├── cue_processor.py │ └── log_manager.py ├── resources │ ├── lisa-A.png │ ├── lisa-B.png │ ├── lisa-C.png │ ├── lisa-D.png │ ├── lisa-E.png │ ├── lisa-F.png │ ├── lisa-G.png │ ├── lisa-H.png │ ├── lisa-X.png │ ├── rhubarb.png │ ├── rhubarb32x32.png │ ├── rhubarb64x64.png │ └── placementSettings.png ├── blender_manifest.toml └── __init__.py ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── support.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ └── unit-tests.yml ├── doc ├── img │ ├── pad.png │ ├── wav.png │ ├── capture.gif │ ├── maping.gif │ ├── addonPath.png │ ├── correction.png │ ├── settings.png │ ├── checkVersion.png │ ├── demo0Thumb.jpeg │ ├── demo1Thumb.jpeg │ ├── demo2Thumb.jpeg │ ├── demo3Thumb.jpeg │ ├── demo4Thumb.jpeg │ ├── ActionFilters.png │ ├── BakeNLATracks.png │ ├── BakeToNLADialog.png │ ├── CueDurationHist.png │ ├── GithubActions.png │ ├── PluginInstall.png │ ├── rhubarbVersion.png │ ├── ActionFrameRange.png │ ├── NLATrackSelection.png │ ├── placementSettings.png │ ├── CueDurationPerType.png │ ├── CueTypesOccuranceEn.png │ ├── ExtensionInstallDisk.gif │ ├── checkPuginInstalled.png │ ├── release │ │ ├── NLATrackRef.gif │ │ ├── bakeValidations.png │ │ ├── in_out_blend_type.png │ │ ├── mapping_preview.png │ │ ├── nla_bake_dialog.png │ │ ├── shape_keys_baking.png │ │ ├── up_to_date_check.png │ │ ├── AudioStrip2WaySync.gif │ │ ├── mapping_panel_reorg.png │ │ └── capture_import_export.png │ └── checkPluginRegistered.png └── diagrams │ ├── capture.png │ ├── capture.svg.png │ ├── mapping.svg.png │ ├── thumbs.sh │ ├── template.iuml │ ├── capture.wsd │ └── mapping.wsd ├── tests ├── data │ ├── cs_female_o_a.ogg │ ├── en_male_electricity.ogg │ ├── en_male_watchingtv.ogg │ ├── threelittlekittens_01_rountreesmith.ogg │ ├── shorted-threelittlekittens_01_rountreesmith.ogg │ ├── en_male_electricity-expected.json │ └── threelittlekittens_01_rountreesmith.txt ├── readme.md ├── helper.py ├── test_ui_utils.py ├── test_capture.py ├── loopTest.sh ├── test_properties.py ├── test_mouth_shape_data.py ├── test_mapping_utils.py ├── sample_data.py ├── test_baking_preparation.py ├── test_baking_execution.py ├── run_within_blender.py ├── test_dropdown_selections.py ├── test_cue_frames.py ├── test_process_sound_file.py ├── test_ui_dropdown_changes.py └── test_NLA_dropdown.py ├── sphinx ├── index.rst ├── rinoh_template.rts ├── rinoh_style.rts └── conf.py ├── .gitignore ├── scripts ├── config.py ├── github_releases.py ├── adhoc-install.py ├── package.py └── sphinx_build.py ├── environment.yml ├── environment-github.yml ├── LICENSE ├── faq.md ├── pyproject.toml └── release_notes.md /rhubarb_lipsync/blender/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rhubarb_lipsync/rhubarb/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://blendermarket.com/products/rhubarb-lipsync-ng"] 2 | -------------------------------------------------------------------------------- /doc/img/pad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/pad.png -------------------------------------------------------------------------------- /doc/img/wav.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/wav.png -------------------------------------------------------------------------------- /doc/img/capture.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/capture.gif -------------------------------------------------------------------------------- /doc/img/maping.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/maping.gif -------------------------------------------------------------------------------- /doc/img/addonPath.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/addonPath.png -------------------------------------------------------------------------------- /doc/img/correction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/correction.png -------------------------------------------------------------------------------- /doc/img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/settings.png -------------------------------------------------------------------------------- /doc/diagrams/capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/diagrams/capture.png -------------------------------------------------------------------------------- /doc/img/checkVersion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/checkVersion.png -------------------------------------------------------------------------------- /doc/img/demo0Thumb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/demo0Thumb.jpeg -------------------------------------------------------------------------------- /doc/img/demo1Thumb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/demo1Thumb.jpeg -------------------------------------------------------------------------------- /doc/img/demo2Thumb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/demo2Thumb.jpeg -------------------------------------------------------------------------------- /doc/img/demo3Thumb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/demo3Thumb.jpeg -------------------------------------------------------------------------------- /doc/img/demo4Thumb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/demo4Thumb.jpeg -------------------------------------------------------------------------------- /doc/img/ActionFilters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/ActionFilters.png -------------------------------------------------------------------------------- /doc/img/BakeNLATracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/BakeNLATracks.png -------------------------------------------------------------------------------- /doc/img/BakeToNLADialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/BakeToNLADialog.png -------------------------------------------------------------------------------- /doc/img/CueDurationHist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/CueDurationHist.png -------------------------------------------------------------------------------- /doc/img/GithubActions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/GithubActions.png -------------------------------------------------------------------------------- /doc/img/PluginInstall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/PluginInstall.png -------------------------------------------------------------------------------- /doc/img/rhubarbVersion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/rhubarbVersion.png -------------------------------------------------------------------------------- /doc/diagrams/capture.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/diagrams/capture.svg.png -------------------------------------------------------------------------------- /doc/diagrams/mapping.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/diagrams/mapping.svg.png -------------------------------------------------------------------------------- /doc/img/ActionFrameRange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/ActionFrameRange.png -------------------------------------------------------------------------------- /doc/img/NLATrackSelection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/NLATrackSelection.png -------------------------------------------------------------------------------- /doc/img/placementSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/placementSettings.png -------------------------------------------------------------------------------- /tests/data/cs_female_o_a.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/tests/data/cs_female_o_a.ogg -------------------------------------------------------------------------------- /doc/img/CueDurationPerType.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/CueDurationPerType.png -------------------------------------------------------------------------------- /doc/img/CueTypesOccuranceEn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/CueTypesOccuranceEn.png -------------------------------------------------------------------------------- /doc/img/ExtensionInstallDisk.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/ExtensionInstallDisk.gif -------------------------------------------------------------------------------- /doc/img/checkPuginInstalled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/checkPuginInstalled.png -------------------------------------------------------------------------------- /doc/img/release/NLATrackRef.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/NLATrackRef.gif -------------------------------------------------------------------------------- /doc/img/checkPluginRegistered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/checkPluginRegistered.png -------------------------------------------------------------------------------- /tests/data/en_male_electricity.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/tests/data/en_male_electricity.ogg -------------------------------------------------------------------------------- /tests/data/en_male_watchingtv.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/tests/data/en_male_watchingtv.ogg -------------------------------------------------------------------------------- /doc/img/release/bakeValidations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/bakeValidations.png -------------------------------------------------------------------------------- /doc/img/release/in_out_blend_type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/in_out_blend_type.png -------------------------------------------------------------------------------- /doc/img/release/mapping_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/mapping_preview.png -------------------------------------------------------------------------------- /doc/img/release/nla_bake_dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/nla_bake_dialog.png -------------------------------------------------------------------------------- /doc/img/release/shape_keys_baking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/shape_keys_baking.png -------------------------------------------------------------------------------- /doc/img/release/up_to_date_check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/up_to_date_check.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-A.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-B.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-C.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-C.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-D.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-D.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-E.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-E.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-F.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-F.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-G.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-G.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-H.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-H.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/lisa-X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/lisa-X.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/rhubarb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/rhubarb.png -------------------------------------------------------------------------------- /doc/img/release/AudioStrip2WaySync.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/AudioStrip2WaySync.gif -------------------------------------------------------------------------------- /doc/img/release/mapping_panel_reorg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/mapping_panel_reorg.png -------------------------------------------------------------------------------- /doc/img/release/capture_import_export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/doc/img/release/capture_import_export.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/rhubarb32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/rhubarb32x32.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/rhubarb64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/rhubarb64x64.png -------------------------------------------------------------------------------- /rhubarb_lipsync/resources/placementSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/rhubarb_lipsync/resources/placementSettings.png -------------------------------------------------------------------------------- /tests/data/threelittlekittens_01_rountreesmith.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/tests/data/threelittlekittens_01_rountreesmith.ogg -------------------------------------------------------------------------------- /tests/data/shorted-threelittlekittens_01_rountreesmith.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/HEAD/tests/data/shorted-threelittlekittens_01_rountreesmith.ogg -------------------------------------------------------------------------------- /doc/diagrams/thumbs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | inkscape -z --export-background=white --export-background-opacity=0.5 -h 256 --export-type=png *.svg 4 | #inkscape -z --export-background=white --export-background-opacity=0.5 -w 1024 --export-type=png *.svg 5 | -------------------------------------------------------------------------------- /sphinx/index.rst: -------------------------------------------------------------------------------- 1 | Rhubarb Lip Sync NG documentation 2 | =============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | README.md 9 | troubleshooting.md 10 | faq.md 11 | release_notes.md 12 | 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support.yml: -------------------------------------------------------------------------------- 1 | name: 🗨️ Support, questions 2 | description: Use this form if you need help, have a question, or not sure whether something is a bug. 3 | body: 4 | - type: textarea 5 | attributes: 6 | label: Description 7 | validations: 8 | required: true 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | venv 4 | __pycache__ 5 | /release/ 6 | copyfiles.bat 7 | bin/ 8 | bin 9 | dist/ 10 | support/ 11 | download/ 12 | *.egg* 13 | settings.json 14 | .blenderFresh 15 | *junit*.xml 16 | dist 17 | .crossnote 18 | summary.py.txt 19 | sphinx/build/ 20 | sphinx/build 21 | -------------------------------------------------------------------------------- /sphinx/rinoh_template.rts: -------------------------------------------------------------------------------- 1 | [TEMPLATE_CONFIGURATION] 2 | template = book 3 | ;template = article 4 | 5 | 6 | ;https://www.mos6581.org/rinohtype/master/book.html 7 | parts = 8 | title 9 | front_matter 10 | contents 11 | ;back_matter 12 | stylesheet = rinoh_style.rts 13 | language = en 14 | -------------------------------------------------------------------------------- /sphinx/rinoh_style.rts: -------------------------------------------------------------------------------- 1 | [STYLESHEET] 2 | base=sphinx_base14 3 | 4 | [chapter] 5 | page_break=none 6 | 7 | ;[default:Paragraph] 8 | ;border=1mm,01A000 9 | 10 | [inline image] 11 | ;width=100% ;This sadly doesn't work when image is comming from markdown 12 | ;horizontal_align = center 13 | ;border=2mm,A10000 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🔆 Feature Request 2 | description: Use this form to suggest a new feature, improvement idea, or to report a missing feature. 3 | labels: ["enhancement"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Description 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /tests/readme.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | Some tests are more like integration tests where they use the blender-as-python module. Some of these test would make other tests to fail since blender-module has a global state. 3 | 4 | To run the tests active conde env. The `pytest-xdist` and `pytest-forked` plugins are needed. Then run: 5 | 6 | ```sh 7 | pytest -n auto --forked 8 | ``` -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | def has_blender_audacity_support() -> bool: 5 | """Official bpy from pip for some reason comes without `aud` module and blender fails to work with Sound objects""" 6 | try: 7 | import aud 8 | 9 | return getattr(aud, 'MOCK', False) 10 | except ImportError: 11 | return False # handle the 12 | 13 | 14 | def skip_no_aud(test_func): 15 | return unittest.skipIf(has_blender_audacity_support(), "No AUD support, has to skip")(test_func) 16 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | id = "rhubarb_lipsync" 3 | version = "1.6.1" 4 | name = "Rhubarb Lipsync NG" 5 | tagline = "Integrate Rhubarb Lipsync into Blender" 6 | maintainer = "Premysl Srubar" 7 | type = "add-on" 8 | 9 | website = "https://blendermarket.com/products/rhubarb-lipsync-ng" 10 | tags = ["Animation"] 11 | 12 | blender_version_min = "4.2.0" 13 | license = ["SPDX:MIT"] 14 | 15 | platforms = ["linux-x64"] 16 | files = "Import/export sound files and captures, execute rhubarb cli file" -------------------------------------------------------------------------------- /doc/diagrams/template.iuml: -------------------------------------------------------------------------------- 1 | @startuml 2 | skinparam dpi 240 3 | allowmixing 4 | hide empty members 5 | skinparam lineType polyline 6 | skinparam titleBorderThickness 2 7 | skinparam titleBorderRoundCorner 15 8 | 9 | skinparam class { 10 | BackgroundColor<> LightBlue 11 | BorderColor<> Blue 12 | } 13 | 14 | skinparam class { 15 | BackgroundColor<> LightGrey 16 | BorderColor<> Grey 17 | } 18 | skinparam stereotypeCBackgroundColor<> DimGray 19 | 20 | !unquoted function $q($tx) return '**""' + $tx + '""**' 21 | 22 | 23 | @enduml -------------------------------------------------------------------------------- /scripts/config.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import tomli 4 | 5 | # https://realpython.com/python-toml/ 6 | # Helper module to load custom config directly from the pyproject.toml file 7 | 8 | 9 | def load_project_cfg() -> dict: 10 | path = pathlib.Path(__file__).parent.parent / "pyproject.toml" 11 | with path.open(mode="rb") as fp: 12 | return tomli.load(fp) 13 | 14 | 15 | def dist_zip_name(platform: str, ver: str) -> str: 16 | return f"rhubarb_lipsync_ng-{platform}-{ver}" 17 | 18 | 19 | project_cfg = load_project_cfg() 20 | rhubarb_cfg = project_cfg["tool"]["rhubarb_lipsync"] 21 | -------------------------------------------------------------------------------- /tests/test_ui_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import sample_project 4 | from rhubarb_lipsync import IconsManager 5 | 6 | # def setUpModule(): 7 | # rhubarb_lipsync.register() # Simulate blender register call 8 | 9 | 10 | class IconsManagerTest(unittest.TestCase): 11 | def setUp(self) -> None: 12 | self.project = sample_project.SampleProject() 13 | 14 | @unittest.skip("Seems not working for bpy as module") 15 | def testGetIcon(self) -> None: 16 | assert IconsManager.logo_icon(), "Icon id is zero. Icons loading is probably broken." 17 | 18 | 19 | if __name__ == '__main__': 20 | unittest.main() 21 | -------------------------------------------------------------------------------- /tests/test_capture.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import bpy 4 | 5 | import rhubarb_lipsync.blender.ui_utils as ui_utils 6 | import sample_project 7 | from helper import skip_no_aud 8 | 9 | 10 | class CaptureTest(unittest.TestCase): 11 | def setUp(self) -> None: 12 | self.project = sample_project.SampleProject() 13 | 14 | def testGetVer(self) -> None: 15 | self.project.create_capture() 16 | assert self.project.cprops 17 | ui_utils.assert_op_ret(bpy.ops.rhubarb.get_executable_version()) 18 | 19 | @skip_no_aud 20 | def testCaputre(self) -> None: 21 | self.project.capture() 22 | print("done") 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: rhubarb 2 | channels: 3 | - defaults 4 | - kitsune.one # For blender as module 5 | - conda-forge 6 | dependencies: 7 | - python=3.10 8 | - pip 9 | # - pytest>=7.2.0 10 | - pytest-xdist # For test parallel execution 11 | - pytest-forked # For test isolation. bpy has global context 12 | - mypy 13 | - python-blender~=3.4.0 #Blender as module () 14 | - tomli 15 | - requests 16 | - debugpy 17 | - flask 18 | #- numpy 19 | - black 20 | - ruff 21 | - bs4 # Only for compa. tests 22 | - html5lib # Only for compa. tests 23 | - pip: 24 | - -e . 25 | #- bpy>=3.2 # Official blender-as-module, only since 3.4 (no Audaspace) 26 | #- fake-bpy-module-3.4 27 | #- fake-bpy-module-latest -------------------------------------------------------------------------------- /doc/diagrams/capture.wsd: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include template.iuml 3 | title Capture components 4 | 5 | ' component "Apache ActiveMQ Artemis" as jms { 6 | ' queue "logging.rawEvent" as rawEventQ 7 | ' } 8 | 9 | together { 10 | class Scene<> 11 | class Sound<> 12 | } 13 | 14 | Scene-down[hidden]-Sound 15 | 16 | class Cue { 17 | shape key : ⒶⒷⒸ ... 18 | start time 19 | end time 20 | } 21 | 22 | Scene -> "*" Capture : Captures in the scene 23 | Capture --> Sound 24 | Capture -> "*" Cue : Captured mouth cues 25 | 26 | note top of Capture 27 | Capture properties are bound to Blender $q(Scene). 28 | There can be multiple $q(Captures) in the $q(Scene). 29 | There is list of $q(Cues) captured from the provided $q(Sound). 30 | end note 31 | 32 | @enduml 33 | -------------------------------------------------------------------------------- /environment-github.yml: -------------------------------------------------------------------------------- 1 | # mamba create -n rhubarb-github -c conda-forge python~=3.10 pip~=23.0.0 2 | # conda activate rhubarb-github 3 | # mamba env update -f environment-github.yml 4 | name: rhubarb-github 5 | channels: 6 | - defaults 7 | #- kitsune.one # For blender as module 8 | - conda-forge 9 | dependencies: 10 | - python=3.10 11 | - pip 12 | - pytest-xdist # For test parallel execution 13 | - pytest-forked # For test isolation. bpy has global context 14 | #- python-blender~=3.4.0 #Blender as module () - has been removed/made private 15 | #3.2 3.4.0 3.6.0 16 | #- tomli 17 | - requests 18 | - pip: 19 | - -e . 20 | - audaspace 21 | - bpy>=3.2 # Official blender-as-module, only since 3.4 (no Audaspace) 22 | #- fake-bpy-module-3.4 23 | #- fake-bpy-module-latest -------------------------------------------------------------------------------- /tests/loopTest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Directory where the pytest output files will be stored 4 | output_dir="/tmp/pytest" 5 | 6 | # Create the output directory if it doesn't exist 7 | mkdir -p "$output_dir" 8 | 9 | # Initialize a variable to keep track of the timestamp 10 | ts="" 11 | 12 | # Loop until a unit test fails 13 | while true; do 14 | # Generate a new timestamp in the format hour:minute:second 15 | ts=$(date "+%H:%M:%S") 16 | 17 | # Run pytest and redirect output to a file with the timestamp 18 | pytest > "$output_dir/out-${ts}.txt" 2>&1 19 | 20 | # Check the exit status of pytest 21 | if [ $? -ne 0 ]; then 22 | echo "An unit test failed. Exiting the loop." 23 | break 24 | else 25 | echo "Unit tests passed. Waiting before the next iteration." 26 | sleep 1 27 | fi 28 | done 29 | -------------------------------------------------------------------------------- /tests/test_properties.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import sample_project 4 | from helper import skip_no_aud 5 | 6 | 7 | class PropertiesTest(unittest.TestCase): 8 | def setUp(self) -> None: 9 | self.project = sample_project.SampleProject() 10 | self.project.create_capture() 11 | assert self.project.cprops 12 | 13 | @skip_no_aud 14 | def testSoundFilePath(self) -> None: 15 | props = self.project.cprops 16 | self.project.set_capture_sound() 17 | 18 | self.assertEqual(props.sound_file_extension, 'ogg') 19 | self.assertEqual(props.sound_file_basename, 'en_male_electricity') 20 | self.assertIn('data', props.sound_file_folder) 21 | self.assertTrue(props.is_sound_format_supported()) 22 | 23 | newName = self.project.cprops.get_sound_name_with_new_extension("wav") 24 | self.assertEqual(newName, 'en_male_electricity.wav') 25 | 26 | 27 | if __name__ == '__main__': 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Premik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/data/en_male_electricity-expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "soundFile": "en_male_electricity.ogg", 4 | "duration": "1.06" 5 | }, 6 | "mouthCues": [ 7 | { 8 | "start": "0.00", 9 | "end": "0.10", 10 | "value": "X" 11 | }, 12 | { 13 | "start": "0.10", 14 | "end": "0.15", 15 | "value": "B" 16 | }, 17 | { 18 | "start": "0.15", 19 | "end": "0.19", 20 | "value": "H" 21 | }, 22 | { 23 | "start": "0.19", 24 | "end": "0.26", 25 | "value": "C" 26 | }, 27 | { 28 | "start": "0.26", 29 | "end": "0.54", 30 | "value": "B" 31 | }, 32 | { 33 | "start": "0.54", 34 | "end": "0.61", 35 | "value": "C" 36 | }, 37 | { 38 | "start": "0.61", 39 | "end": "0.96", 40 | "value": "B" 41 | }, 42 | { 43 | "start": "0.96", 44 | "end": "1.06", 45 | "value": "X" 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /doc/diagrams/mapping.wsd: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include template.iuml 3 | title Mapping components 4 | 5 | 6 | 7 | ' component "Apache ActiveMQ Artemis" as jms { 8 | ' queue "logging.rawEvent" as rawEventQ 9 | ' } 10 | 11 | together { 12 | class Action<> 13 | class Object<> 14 | class NLATrack<> 15 | } 16 | 17 | Action-down[hidden]-Object 18 | Object-down[hidden]-NLATrack 19 | 20 | class MapingItem { 21 | shape key : ⒶⒷⒸ ... 22 | } 23 | 24 | class StripPlacement { 25 | extrapolation type 26 | blending type 27 | offset start 28 | offset end 29 | } 30 | 31 | 32 | 33 | Object -> "0..1" Mapping : Object's Mapping properties 34 | Mapping -up-> "*" MapingItem : Cue (type) mapping 35 | Mapping -up-> StripPlacement 36 | Mapping -down> "1..2" NLATrackRef 37 | 38 | NLATrackRef -left-> NLATrack 39 | MapingItem -left-> "0..2 (ShapeKey)Action" Action 40 | 41 | note top of StripPlacement 42 | Describes initial properties and how to 43 | place the $q(NLAStrips) while baking. 44 | end note 45 | 46 | note top of MapingItem 47 | Links single $q(Cue) type to an $q(Action) or $q(ShapeKey Action) 48 | end note 49 | 50 | note left of Mapping 51 | Mapping of the $q(Capture) properties 52 | to $q(Object Actions). 53 | Bound to Blender $q(Object). 54 | end note 55 | 56 | note bottom of NLATrackRef 57 | One or two $q(NLATracks) to bake the mapped $q(Actions) to. 58 | end note 59 | 60 | 61 | @enduml 62 | -------------------------------------------------------------------------------- /tests/test_mouth_shape_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | 4 | import rhubarb_lipsync.rhubarb.rhubarb_command as rhubarb_command 5 | 6 | # import tests.sample_data 7 | from rhubarb_lipsync.rhubarb.mouth_cues import duration_scale, frame2time, time2frame_float 8 | 9 | 10 | def enableDebug() -> None: 11 | logging.basicConfig() 12 | rhubarb_command.log.setLevel(logging.DEBUG) 13 | 14 | 15 | class TimeFrameConversionTest(unittest.TestCase): 16 | def setUp(self) -> None: 17 | enableDebug() 18 | 19 | def testTime2Frame(self) -> None: 20 | self.assertAlmostEqual(time2frame_float(2, 60), 120) 21 | 22 | def testTime2FrameConsistency(self) -> None: 23 | self.assertAlmostEqual(time2frame_float(frame2time(10, 25, 2), 25, 2), 10) 24 | 25 | def testDurationScaleNoScaling(self) -> None: 26 | # Scaling disabled 27 | self.assertAlmostEqual(duration_scale(10, 20, 1, 1), 10) 28 | self.assertAlmostEqual(duration_scale(10, 0, 1, 1), 10) 29 | 30 | def testDurationScaleFullScaling(self) -> None: 31 | # Can scale fully 32 | self.assertAlmostEqual(duration_scale(10, 20, 0.1, 5), 20) 33 | self.assertAlmostEqual(duration_scale(10, 5, 0.1, 5), 5) 34 | 35 | def testDurationScaleClamped(self) -> None: 36 | # Clamped to -+50% 37 | self.assertAlmostEqual(duration_scale(10, 30, 2, 2), 20) 38 | self.assertAlmostEqual(duration_scale(10, 0, 0.5, 5), 5) 39 | -------------------------------------------------------------------------------- /faq.md: -------------------------------------------------------------------------------- 1 | 2 | # FAQ 3 | 4 | ## Where to get support, report a bug, suggest a change or a new feautre 5 | 6 | **Github** is the preferable place: 7 | - Search [Opened issues](https://github.com/Premik/blender_rhubarb_lipsync_ng/issues?q=is%3Aopen). Maybe somebody has already reported the same issue. 8 | - Also search already [Closed issues](https://github.com/Premik/blender_rhubarb_lipsync_ng/issues?q=is%3Aclosed). There might be similar issue with a solution from the past. 9 | - Follow the [Troubleshooting guide](https://github.com/Premik/blender_rhubarb_lipsync_ng/blob/master/troubleshooting.md). 10 | - If still no luck or you have some suggestion/question/feature request open a [New ticket](https://github.com/Premik/blender_rhubarb_lipsync_ng/issues/new/choose) 11 | 12 | You can also use the inbuilt product-support of the `blendermarket`. 13 | 14 | 15 | ## What is the difference between rhubarb-cli, rhubarb_lipsync, and rhubarb_lipsync_ng? 16 | 17 | The engine used to recognize the phonemes and convert them to the 9 mouth cue types is 18 | Daniel Wolf's [Rhubarb Lip Sync application](https://github.com/DanielSWolf/rhubarb-lip-sync). 19 | The add-on ships this CLI ( command line interface) application as part of the installation zip file in the bin folder. 20 | 21 | The **rhubarb lipsync NG** is a complete rework of the original [rhubarb lipsync addon](https://github.com/scaredyfish/blender-rhubarb-lipsync) based on the same principles and ideas. 22 | 23 | ## What does the NG stands for 24 | The `NG` in the name stands for `Next generation`. This is to distinguish this reworked addon from the original one. -------------------------------------------------------------------------------- /sphinx/conf.py: -------------------------------------------------------------------------------- 1 | 2 | # Configuration file for the Sphinx documentation builder. 3 | # 4 | # For the full list of built-in configuration values, see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Project information ----------------------------------------------------- 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 9 | 10 | 11 | project = 'Rhubarb Lip Sync NG' 12 | release = '1.6' 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions: list[str] = ['myst_parser'] 17 | source_suffix = { 18 | '.rst': 'restructuredtext', 19 | '.md': 'markdown', 20 | } 21 | root_doc = "index" 22 | templates_path = ['templates'] 23 | # include_patterns = ["md_temp/**"] 24 | rinoh_documents = [ 25 | dict( 26 | doc='index', 27 | target='Rhubarb Lip Sync NG', 28 | logo='doc/img/RLSP-banner.png', 29 | author="", 30 | subtitle=f"For v{release}", 31 | template="rinoh_template.rts" 32 | # template='/wrk/dev/rhubarb-lipsync/sphinx/rinoh_template.rts', # Article Book 33 | ) 34 | ] 35 | 36 | # -- Options for HTML output ------------------------------------------------- 37 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 38 | 39 | # pip install sphinx-book-theme 40 | html_theme = 'sphinx_book_theme' 41 | html_static_path = ['static'] 42 | 43 | # -- Options for rinoh output ------------------------------------------------- 44 | rinoh_stylesheets = ["rinoh_style.rts"] 45 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/aud_mock.py: -------------------------------------------------------------------------------- 1 | # aud module is not part of official Blender's as module `bpy` in pip. So this mocks it out 2 | 3 | MOCK = True 4 | 5 | 6 | class Sound: 7 | def __init__(self, *args) -> None: 8 | pass 9 | 10 | def write(self, *args) -> None: 11 | pass 12 | 13 | 14 | AP_LOCATION = None 15 | AP_ORIENTATION = None 16 | AP_PANNING = None 17 | AP_PITCH = None 18 | AP_VOLUME = None 19 | CHANNELS_INVALID = None 20 | CHANNELS_MONO = None 21 | CHANNELS_STEREO = None 22 | CHANNELS_STEREO_LFE = None 23 | CHANNELS_SURROUND4 = None 24 | CHANNELS_SURROUND5 = None 25 | CHANNELS_SURROUND51 = None 26 | CHANNELS_SURROUND61 = None 27 | CHANNELS_SURROUND71 = None 28 | CODEC_AAC = None 29 | CODEC_AC3 = None 30 | CODEC_FLAC = None 31 | CODEC_INVALID = None 32 | CODEC_MP2 = None 33 | CODEC_MP3 = None 34 | CODEC_OPUS = None 35 | CODEC_PCM = None 36 | CODEC_VORBIS = None 37 | CONTAINER_AC3 = None 38 | CONTAINER_FLAC = None 39 | CONTAINER_INVALID = None 40 | CONTAINER_MATROSKA = None 41 | CONTAINER_MP2 = None 42 | CONTAINER_MP3 = None 43 | CONTAINER_OGG = None 44 | CONTAINER_WAV = None 45 | DISTANCE_MODEL_EXPONENT = None 46 | DISTANCE_MODEL_EXPONENT_CLAMPED = None 47 | DISTANCE_MODEL_INVALID = None 48 | DISTANCE_MODEL_INVERSE = None 49 | DISTANCE_MODEL_INVERSE_CLAMPED = None 50 | DISTANCE_MODEL_LINEAR = None 51 | DISTANCE_MODEL_LINEAR_CLAMPED = None 52 | FORMAT_FLOAT32 = None 53 | FORMAT_FLOAT64 = None 54 | FORMAT_INVALID = None 55 | FORMAT_S16 = None 56 | FORMAT_S24 = None 57 | FORMAT_S32 = None 58 | FORMAT_U8 = None 59 | RATE_11025 = None 60 | RATE_16000 = None 61 | RATE_192000 = None 62 | RATE_22050 = None 63 | RATE_32000 = None 64 | RATE_44100 = None 65 | RATE_48000 = None 66 | RATE_8000 = None 67 | RATE_88200 = None 68 | RATE_96000 = None 69 | RATE_INVALID = None 70 | STATUS_INVALID = None 71 | STATUS_PAUSED = None 72 | STATUS_PLAYING = None 73 | STATUS_STOPPED = None 74 | -------------------------------------------------------------------------------- /scripts/github_releases.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import cached_property 3 | from pathlib import Path 4 | from typing import Iterator 5 | 6 | from github import Github 7 | from github.GitRelease import GitRelease 8 | from github.Repository import Repository 9 | 10 | 11 | @dataclass 12 | class GithubReleaseManager: 13 | repo_name: str = "Premik/blender_rhubarb_lipsync_ng" 14 | 15 | @cached_property 16 | def github(self) -> Github: 17 | token_path = Path.home() / "github-token.txt" 18 | token = token_path.read_text().strip() 19 | return Github(token) 20 | 21 | @cached_property 22 | def repo(self) -> Repository: 23 | return self.github.get_repo(self.repo_name) 24 | 25 | def list_releases(self) -> Iterator[GitRelease]: 26 | for release in self.repo.get_releases(): 27 | yield release 28 | 29 | def save_release_history(self, output_file: str) -> None: 30 | """Save release history to a markdown file.""" 31 | with open(output_file, 'w', encoding='utf-8') as f: 32 | f.write("# Release History\n\n") 33 | 34 | for release in self.list_releases(): 35 | title = release.title or release.tag_name 36 | f.write(f"## {title}\n\n") 37 | 38 | # Write date 39 | release_date = release.created_at.strftime("%Y-%m-%d") 40 | f.write(f"**Date:** {release_date}\n\n") 41 | 42 | if release.body: 43 | f.write(release.body) 44 | f.write("\n\n") 45 | 46 | # Add separator between releases 47 | f.write("---\n\n") 48 | 49 | 50 | def main() -> None: 51 | manager = GithubReleaseManager() 52 | output_file = "release_notes.md" 53 | manager.save_release_history(output_file) 54 | print(f"Release history saved to {output_file}") 55 | 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /tests/test_mapping_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import bpy 4 | 5 | import rhubarb_lipsync.blender.mapping_utils as mapping_utils 6 | import sample_project 7 | 8 | 9 | class BakingContextTest(unittest.TestCase): 10 | def setUp(self) -> None: 11 | self.project = sample_project.SampleProject() 12 | self.anrmal = self.project.action_single 13 | self.aasset = self.project.action_10 14 | self.ashpky = self.project.action_shapekey1 15 | self.ainvld = self.project.action_invalid 16 | 17 | def list_action(self, shapekeys: bool, valid: bool, assets: bool) -> list[bpy.types.Action]: 18 | self.project.mprops.only_shapekeys = shapekeys 19 | self.project.mprops.only_asset_actions = assets 20 | self.project.mprops.only_valid_actions = valid 21 | return list(mapping_utils.filtered_actions_for_current_object(bpy.context)) 22 | 23 | def testWithouShapeKeys(self) -> None: 24 | self.project.armature1 25 | actions = self.list_action(False, True, False) 26 | self.assertIn(self.anrmal, actions) 27 | self.assertIn(self.aasset, actions) 28 | self.assertNotIn(self.ashpky, actions) 29 | self.assertNotIn(self.ainvld, actions) 30 | 31 | def testValidShapeKeysOnly(self) -> None: 32 | self.project.sphere1 33 | actions = self.list_action(True, True, False) 34 | self.assertNotIn(self.anrmal, actions) 35 | self.assertNotIn(self.aasset, actions) 36 | self.assertIn(self.ashpky, actions) 37 | self.assertNotIn(self.ainvld, actions) 38 | 39 | def testValidOnly(self) -> None: 40 | self.project.armature1 41 | actions = self.list_action(False, True, False) 42 | self.assertIn(self.anrmal, actions) 43 | self.assertIn(self.aasset, actions) 44 | self.assertNotIn(self.ashpky, actions) # Shape key has invalid key on the sphere1 45 | self.assertNotIn(self.ainvld, actions) 46 | 47 | def testValidAssetsOnly(self) -> None: 48 | self.project.armature1 49 | actions = self.list_action(False, True, True) 50 | self.assertNotIn(self.anrmal, actions) 51 | self.assertIn(self.aasset, actions) 52 | self.assertNotIn(self.ashpky, actions) 53 | self.assertNotIn(self.ainvld, actions) 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🪲 Bug Report 2 | description: When something is not working as expected. 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 💡 Check the [Troubleshooting guide](../blob/master/troubleshooting.md). 9 | 10 | 💡 Start Blender in debug mode from the command line: `blender --debug`. This will make the addon log messages with maximal verbosity, possibly giving more clues about the issue. 11 | 12 | 💡 On Windows, go to the main menu: `Window/Toggle System Console` to open the console window. 13 | 14 | 💡 You can make the plugin write logs directly to a file: 15 | - Go to the main menu: `Edit/Preferences/Add-ons`. 16 | - Find and expand `Animation: Rhubarb Lipsync NG`. 17 | - Set `Log File` to file-path. For example, `C:\temp\rhubarb.log`. 18 | 19 | 20 | 21 | - type: checkboxes 22 | attributes: 23 | label: Confirmation 24 | description: Please confirm you have followed the [Troubleshooting guide](../blob/master/troubleshooting.md). 25 | options: 26 | - label: I have followed the troubleshooting guide. 27 | required: true 28 | 29 | 30 | - type: textarea 31 | attributes: 32 | label: Summary 33 | description: | 34 | Explain the problem briefly below. Where it makes sense, include: 35 | - The steps to reproduce the issue. 36 | - What is the current vs. expected outcome. 37 | - Any error message or screenshot. 38 | - If it used to work in the previous version of the addon (regression), provide the last working version. 39 | validations: 40 | required: true 41 | 42 | - type: input 43 | attributes: 44 | label: Rhubarb Lipsync NG addon version 45 | description: | 46 | Addon version. Check the `.zip` file name you used to install the addon or in Blender: `Edit/Preferences/Add-ons`. 47 | Find and expand `Animation: Rhubarb Lipsync NG`. 48 | placeholder: "1.2.1, 1.3.1, 1.4.0, 1.4.1" 49 | 50 | - type: input 51 | attributes: 52 | label: Blender Version 53 | placeholder: "3.3.20, 3.4, 3.5, 3.6, 4.0, 4.1, 4.2" 54 | 55 | - type: input 56 | attributes: 57 | label: Operating System 58 | placeholder: Windows, Linux, macOS 59 | 60 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: "Junit tests" 2 | 3 | on: [push] 4 | 5 | jobs: 6 | unit-tests: 7 | name: Unit tests (${{ matrix.python-version }}, ${{ matrix.os }}) 8 | runs-on: ${{ matrix.os }} 9 | # defaults: 10 | # run: 11 | # shell: bash -el {0} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: ["ubuntu-latest", "macos-latest", "windows-latest"] 16 | #os: ["ubuntu-latest"] 17 | # , "3.11.3" - Only bpy 4.0.0 in pypi 18 | python-version: ["3.11"] 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 1 23 | # - uses: awalsh128/cache-apt-pkgs-action@latest 24 | # with: 25 | # packages: libxxf86vm1 libxfixes3 libxi6 libxkbcommon0 libgl1-mesa-glx 26 | # #version: 1.0 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | cache: 'pip' 31 | 32 | # - name: Install System Dependencies (Ubuntu) 33 | # sudo apt-get install -y libxxf86vm1 libxfixes3 libxi6 libxkbcommon0 libgl1-mesa-glx 34 | # if: startsWith(matrix.os, 'ubuntu') 35 | # run: | 36 | # sudo apt-get update 37 | # sudo apt-get install -y libgl1-mesa-glx 38 | - name: Install dependencies 39 | run: | 40 | pip install pytest tomli 41 | pip install bpy 42 | pip install -e . 43 | 44 | - name: Cache Downloads 45 | id: cache-download 46 | uses: actions/cache@v4 47 | with: 48 | path: download 49 | key: download-${{hashFiles('pyproject.toml')}} 50 | 51 | - name: Download rhubard binaries 52 | run: | 53 | cd scripts 54 | python rhubarb_bin.py 55 | 56 | - name: Run pytest 57 | run: pytest --junitxml=pytest_junit_results.xml --tb=long 58 | 59 | - name: Test Report # https://github.com/dorny/test-reporter/issues/244 60 | uses: phoenix-actions/test-reporting@v8 61 | if: success() || failure() # run this step even if previous step failed 62 | with: 63 | name: Pytest results ${{ matrix.os }} 64 | path: pytest_junit_results.xml 65 | reporter: java-junit 66 | 67 | - name: Read output variables 68 | run: | 69 | echo "url is ${{ steps.test-report.outputs.runHtmlUrl }}" 70 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/icons_manager.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import bpy 4 | import bpy.types 5 | import bpy.utils 6 | 7 | from .ui_utils import resources_path 8 | 9 | 10 | class IconsManager: 11 | _previews: bpy.utils.previews.ImagePreviewCollection = None 12 | _loaded: set[str] = set() 13 | 14 | @staticmethod 15 | def unregister() -> None: 16 | if IconsManager._previews: 17 | IconsManager._previews.close() 18 | # bpy.utils.previews.remove(IconsManager._previews) 19 | IconsManager._previews = None 20 | IconsManager._loaded = set() 21 | 22 | @staticmethod 23 | def get_icon(key: str) -> int: 24 | if IconsManager._previews is None: 25 | IconsManager._previews = bpy.utils.previews.new() 26 | prew = IconsManager._previews 27 | if key not in IconsManager._loaded: 28 | IconsManager._loaded.add(key) 29 | fn = key 30 | if not pathlib.Path(key).suffix: 31 | fn = f"{key}.png" # .png default extension 32 | prew.load(key, str(resources_path() / fn), 'IMAGE') 33 | return prew[key].icon_id 34 | 35 | @staticmethod 36 | def get_image(image_name: str) -> tuple[bpy.types.Image, bpy.types.Texture]: 37 | """Loads an image into Blender's data blocks.""" 38 | if not image_name.endswith(".png"): 39 | image_name = f"{image_name}.png" 40 | image_path = resources_path() / image_name 41 | # if not image_path.exists(): 42 | # raise RuntimeError(f"Image not found: {image_path}") 43 | img = bpy.data.images.load(str(image_path), check_existing=True) 44 | # img.preview_ensure() 45 | # Create a new texture and assign the loaded image to it 46 | text_name = image_name 47 | if text_name not in bpy.data.textures.keys(): 48 | tex = bpy.data.textures.new(name=image_name, type='IMAGE') 49 | tex.extension = 'EXTEND' 50 | tex.image = img 51 | else: 52 | tex = bpy.data.textures[text_name] 53 | 54 | return img, tex 55 | 56 | @staticmethod 57 | def logo_icon() -> int: 58 | return IconsManager.get_icon('rhubarb64x64') 59 | # return IconsManager.get('1.dat') 60 | 61 | @staticmethod 62 | def placement_help_image() -> tuple[bpy.types.Image, bpy.types.Texture]: 63 | return IconsManager.get_image('placementSettings') 64 | # return IconsManager.get('1.dat') 65 | 66 | @staticmethod 67 | def cue_icon(key: str) -> int: 68 | return IconsManager.get_icon(f"lisa-{key}") 69 | -------------------------------------------------------------------------------- /scripts/adhoc-install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import shutil 4 | import ssl 5 | import tempfile 6 | import urllib.request 7 | 8 | import bpy 9 | 10 | version_number = "1.0.3" 11 | # version_number = "1.0.2" 12 | # version_number = "1.0.1" 13 | 14 | if platform.system() == 'Linux': 15 | platform_name = 'Linux' 16 | elif platform.system() == 'Darwin': # macOS is identified as 'Darwin' 17 | platform_name = 'macOS' 18 | elif platform.system() == 'Windows': 19 | platform_name = 'Windows' 20 | else: 21 | raise Exception("Unsupported operating system") 22 | zip_name = f"rhubarb_lipsync_ng-{platform_name}-{version_number}.zip" 23 | 24 | # GitHub release URL 25 | github_release_url = f"https://github.com/Premik/blender_rhubarb_lipsync_ng/releases/download/v{version_number}/{zip_name}" 26 | 27 | 28 | # Create a temporary directory 29 | temp_dir = os.path.join(tempfile.gettempdir(), 'blender_rhubarb_temp') 30 | os.makedirs(temp_dir, exist_ok=True) 31 | # Full path for the downloaded zip file 32 | addon_zip_path = os.path.join(temp_dir, zip_name) 33 | 34 | if not os.path.isfile(addon_zip_path): 35 | print(f"Downloading {github_release_url}") 36 | ssl_context = ssl._create_unverified_context() 37 | # urllib.request.urlretrieve(github_release_url, addon_zip_path) 38 | # urllib.request.urlretrieve(github_release_url, addon_zip_path) 39 | with urllib.request.urlopen(github_release_url, context=ssl_context) as response, open(addon_zip_path, 'wb') as out_file: 40 | shutil.copyfileobj(response, out_file) 41 | 42 | else: 43 | print(f"File already exists: {addon_zip_path}") 44 | 45 | print(f"Installing {addon_zip_path}") 46 | bpy.ops.preferences.addon_install(overwrite=True, filepath=addon_zip_path) 47 | 48 | print("Doing smoke tests.") 49 | print("\033[93m" + "-" * 50 + "\033[0m") 50 | bpy.ops.preferences.addon_enable(module='rhubarb_lipsync') 51 | print(bpy.ops.rhubarb.get_executable_version()) 52 | print(bpy.ops.rhubarb.create_capture_props()) 53 | # print(bpy.ops.rhubarb.process_sound_file()) # No sound file selected 54 | 55 | # Run: 56 | 57 | # wget --no-check-certificate https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/master/scripts/adhoc-install.py 58 | # or 59 | # curl --insecure https://raw.githubusercontent.com/Premik/blender_rhubarb_lipsync_ng/master/scripts/adhoc-install.py -o adhoc-install.py 60 | # blender --debug --background --python adhoc-install.py 61 | 62 | # MacOS 63 | # cd /Applications/Blender.app/Contents/MacOS 64 | # ./Blender --debug --background --python adhoc-install.py 65 | # XDG_CONFIG_HOME=/tmp/blenderTemp ./Blender --background --python adhoc-install.py 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "rhubarb_lipsync" 3 | version = "1.6.1" 4 | license = { file = "LISENSE" } 5 | 6 | 7 | [tool.rhubarb_lipsync.download] 8 | base_url = "https://github.com/DanielSWolf/rhubarb-lip-sync/releases/download" 9 | base_name = "Rhubarb-Lip-Sync" 10 | version="1.13.0" 11 | 12 | 13 | [[tool.rhubarb_lipsync.platforms]] 14 | name="Linux" 15 | system_names=["Linux"] # To map from platform.system() values 16 | blender_names=["linux-x64"] 17 | download_sha256="bd260905e88d0bdadbd4d7b452cae3b78a880fe27d10d51586772f84aac69f71" 18 | executable_name="rhubarb" 19 | 20 | 21 | [[tool.rhubarb_lipsync.platforms]] 22 | name="macOS" 23 | system_names=["Darwin"] #TODO Verify 24 | blender_names=["macos-x64", "macos-arm64"] 25 | download_sha256="2d25c0ad89c0435d864a0f9ddb9d44757def8bf354b86be28eb3b5e7e9d78f62" 26 | executable_name="rhubarb" 27 | 28 | 29 | [[tool.rhubarb_lipsync.platforms]] 30 | name="Windows" 31 | system_names=["Windows"] #TODO Verify 32 | blender_names=["windows-x64"] 33 | download_sha256="189ac55dae253dba3994d4075b8375b615f255759c105c7ed21bd88ad7728386" 34 | executable_name="rhubarb.exe" 35 | 36 | [tool.setuptools] 37 | packages = ["rhubarb_lipsync"] 38 | 39 | [tool.pytest.ini_options] 40 | # The tests can run on multiple threads but might not be safe/stable unless --forked is used too. This requires: pytest-xdist pytest-forked 41 | #addopts = ["--import-mode=importlib", "--forked", "--numprocesses=auto"] 42 | #addopts = ["--import-mode=importlib", "--numprocesses=auto"] 43 | # https://stackoverflow.com/questions/10253826/path-issue-with-pytest-importerror-no-module-named-yadayadayada/50610630#answer-50610630 44 | pythonpath = [".", "tests"] 45 | # https://docs.pytest.org/en/7.1.x/explanation/goodpractices.html 46 | 47 | # https://stackoverflow.com/questions/4673373/logging-within-pytest-tests 48 | log_cli = true 49 | log_cli_level = "DEBUG" 50 | #log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" 51 | #log_cli_date_format = "%Y-%m-%d %H:%M:%S" 52 | 53 | [tool.black] 54 | line-length = 160 55 | skip-string-normalization = true 56 | 57 | [tool.isort] 58 | profile = "black" 59 | src_paths = ["rhubarb_lipsync", "tests", "scripts"] 60 | line_length = 160 61 | 62 | [tool.ruff] 63 | line-length = 160 64 | ignore = ["E741", "E722", "E731"] 65 | 66 | [tool.mypy] 67 | # https://mypy.readthedocs.io/en/latest/config_file.html 68 | strict_optional = false 69 | disallow_untyped_calls = true 70 | warn_unused_configs = true 71 | # disallow_untyped_defs = true 72 | check_untyped_defs = true 73 | pretty = true 74 | 75 | 76 | [[tool.mypy.overrides]] 77 | module = ["bpy.*", "bgl", "blf", "aud", "addon_utils"] 78 | ignore_missing_imports = true 79 | 80 | # log.trace fix - below doesn't work. Seems it can't be set per imported module 81 | #[[tool.mypy.overrides]] 82 | #module = ["logging"] 83 | #disable_error_code = ["attr-defined"] 84 | 85 | -------------------------------------------------------------------------------- /tests/sample_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import cached_property 3 | from pathlib import Path 4 | from typing import cast 5 | 6 | from bpy.types import Context, Sound 7 | 8 | from rhubarb_lipsync.rhubarb.mouth_cues import MouthCue 9 | from rhubarb_lipsync.rhubarb.rhubarb_command import RhubarbParser 10 | 11 | try: 12 | from bpy.types import Strip # Since v4.4 13 | except ImportError: # Fall back to old API 14 | from bpy.types import SoundSequence as Strip 15 | 16 | sample_data_path = Path(__file__).parent / "data" 17 | 18 | 19 | class SampleData: 20 | """Test data. Links to a sound file and additional data""" 21 | 22 | def __init__(self, name: str, sample_data_path=sample_data_path) -> None: 23 | if not sample_data_path.exists(): 24 | raise FileNotFoundError(f"The path '{sample_data_path}' does not exist.") 25 | if not sample_data_path.is_dir(): 26 | raise NotADirectoryError(f"The path '{sample_data_path}' is not a directory.") 27 | self.name = name 28 | self.sample_data_path = sample_data_path 29 | 30 | @cached_property 31 | def snd_file_path(self) -> Path: 32 | return self.sample_data_path / f"{self.name}.ogg" 33 | 34 | @cached_property 35 | def expected_json_path(self) -> Path: 36 | return self.sample_data_path / f"{self.name}-expected.json" 37 | 38 | @cached_property 39 | def expected_json(self) -> str: 40 | with open(self.expected_json_path) as f: 41 | return f.read() 42 | 43 | @cached_property 44 | def expected_json_dict(self) -> dict: 45 | return json.loads(self.expected_json) 46 | 47 | @cached_property 48 | def expected_cues(self) -> list[MouthCue]: 49 | json_parsed = RhubarbParser.parse_lipsync_json(self.expected_json) 50 | return RhubarbParser.lipsync_json2MouthCues(json_parsed) 51 | 52 | def to_sound(self, ctx: Context) -> Sound: 53 | se = ctx.scene.sequence_editor 54 | sq = se.sequences.new_sound(self.name, str(self.snd_file_path), 1, 1) 55 | return cast(Strip, sq).sound 56 | 57 | @staticmethod 58 | def compare_cues(a_cues: list[MouthCue], b_cues: list[MouthCue]) -> str: 59 | if len(a_cues) != len(b_cues): 60 | return f"Lengths don't match \n{a_cues}\n{b_cues}" 61 | for i, (a, b) in enumerate(zip(a_cues, b_cues)): 62 | if a != b: 63 | return f"Cues at position {i} don't match:\n{a}\n{b}" 64 | return None 65 | 66 | def compare_cues_with_expected(self, b_cues: list[MouthCue]) -> str: 67 | return SampleData.compare_cues(self.expected_cues, b_cues) 68 | 69 | 70 | snd_cs_female_o_a = SampleData("cs_female_o_a") 71 | snd_en_male_watchingtv = SampleData("en_male_watchingtv") 72 | snd_en_male_electricity = SampleData("en_male_electricity") 73 | snd_en_femal_3kittens = SampleData("threelittlekittens_01_rountreesmith") 74 | -------------------------------------------------------------------------------- /tests/test_baking_preparation.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from dataclasses import dataclass 3 | 4 | import rhubarb_lipsync.blender.baking_utils as baking_utils 5 | import sample_project 6 | 7 | 8 | @dataclass 9 | class MockStrip: 10 | frame_start: float 11 | frame_end: float 12 | 13 | 14 | @dataclass 15 | class MockTrack: 16 | strips: list[MockStrip] 17 | 18 | 19 | class BakingUtilsTest(unittest.TestCase): 20 | s0 = MockStrip(1, 10) 21 | s1 = MockStrip(10, 20) 22 | s2 = MockStrip(30, 100) 23 | 24 | t1 = MockTrack([s0, s1, s2]) 25 | 26 | def testFindStrips(self) -> None: 27 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 0)[0], -1) 28 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 1.1)[0], 0) 29 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 1)[0], 0) 30 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 5)[0], 0) 31 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 19)[0], 1) 32 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 25)[0], -1) 33 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 20)[0], -1) 34 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 40)[0], 2) 35 | self.assertEqual(baking_utils.find_strip_at(BakingUtilsTest.t1, 200)[0], -1) 36 | 37 | 38 | class BakingContextTest(unittest.TestCase): 39 | def setUp(self) -> None: 40 | self.project = sample_project.SampleProject() 41 | self.project.capture_load_json() 42 | 43 | def basic_asserts(self) -> None: 44 | assert len(self.bc.objects) == 1, "No active object" 45 | assert len(self.bc.mouth_cue_items) > 1, "No cues in the capture" 46 | assert self.bc.total_frame_range == (1, 26) 47 | 48 | def testBasic1Action(self) -> None: 49 | self.bc = self.project.create_mapping_1action_on_armature() 50 | self.basic_asserts() 51 | 52 | def testBasicTwoActions(self) -> None: 53 | self.bc = self.project.create_mapping_2actions_on_armature() 54 | self.basic_asserts() 55 | 56 | def trackValidation(self) -> None: 57 | errs = self.bc.validate_track() 58 | assert len(errs) > 0, f"Expected validation error since a track is not selected {errs}" 59 | print(self.bc.track_pair) 60 | assert not self.bc.current_track 61 | self.project.add_track1() 62 | assert self.bc.current_object, "No object selected" 63 | assert self.bc.current_track, "No current track" 64 | errs = self.bc.validate_track() 65 | assert len(errs) == 0, errs[0] 66 | 67 | def testTrackValidation_1action(self) -> None: 68 | self.bc = self.project.create_mapping_1action_on_armature() 69 | self.trackValidation() 70 | 71 | def testTrackValidation_2actions(self) -> None: 72 | self.bc = self.project.create_mapping_2actions_on_armature() 73 | self.trackValidation() 74 | 75 | 76 | if __name__ == "__main__": 77 | unittest.main() 78 | -------------------------------------------------------------------------------- /tests/test_baking_execution.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import bpy 4 | 5 | import rhubarb_lipsync.blender.ui_utils as ui_utils 6 | import sample_project 7 | 8 | 9 | class BakingTest(unittest.TestCase): 10 | def setUp(self) -> None: 11 | self.project = sample_project.SampleProject() 12 | # Set the trim-long-cues to hight number to avoid trimming for test consistency (extra X cues) 13 | self.project.prefs.cue_list_prefs.highlight_long_cues = 1 14 | self.project.capture_load_json() 15 | 16 | def bake(self) -> None: 17 | for _ in self.bc.object_iter(): 18 | # errs = self.bc.validate_selection() 19 | errs = self.bc.validate_current_object() 20 | self.assertFalse(errs, errs) 21 | ui_utils.assert_op_ret(bpy.ops.rhubarb.bake_to_nla()) 22 | self.assertFalse(list(self.project.last_result.errors), list(self.project.last_result.items)) 23 | # Trimming warnings are ok 24 | w = self.project.last_result.warnings 25 | w = [w for w in w if "Had to trim" not in w.msg] 26 | self.assertFalse(w, list(self.project.last_result.items)) 27 | cues, strips = self.project.parse_last_bake_result_details() 28 | self.assertGreater(strips, 1, "No strips baked") 29 | self.assertEqual(strips % cues, 0, f"Number of strips ({strips}) created doesn't match the number of captured cues ({cues})") 30 | self.assertEqual( 31 | len(self.project.cue_items), cues, f"Number of baked cues ({cues}) doesn't match the number of cues in the capture ({self.project.cue_items})" 32 | ) 33 | for _ in self.bc.object_iter(): 34 | for t in (self.bc.track1, self.bc.track2): 35 | if t: 36 | self.assertGreater(len(t.strips), 1, f"Track {t} was empty after the bake") 37 | 38 | def bakeTwoTracks(self) -> None: 39 | for o in self.bc.object_iter(): 40 | self.project.make_object_active(o) 41 | self.project.add_track1() 42 | self.project.add_track2() 43 | self.assertTrue(self.bc.has_two_tracks) 44 | self.bake() 45 | 46 | def testBake2Tracks1ActionArmature(self) -> None: 47 | self.bc = self.project.create_mapping_1action_on_armature() 48 | self.bakeTwoTracks() 49 | 50 | def testBake1Track1ActionsArmature(self) -> None: 51 | self.bc = self.project.create_mapping_1action_on_armature() 52 | self.project.add_track2() 53 | self.bake() 54 | 55 | def testBake2Tracks2ActionsArmature(self) -> None: 56 | self.bc = self.project.create_mapping_2actions_on_armature() 57 | self.bakeTwoTracks() 58 | 59 | def testBake1Tracks2ActionsArmature(self) -> None: 60 | self.bc = self.project.create_mapping_2actions_on_armature() 61 | self.project.add_track1() 62 | self.assertFalse(self.bc.has_two_tracks) 63 | self.bake() 64 | 65 | def testBake2Tracks1ShapekeyAction(self) -> None: 66 | self.bc = self.project.create_mapping_1action_on_mesh() 67 | self.bakeTwoTracks() 68 | 69 | def testBakeTwoObjects(self) -> None: 70 | self.bc = self.project.create_mapping_two_objects() 71 | self.bakeTwoTracks() 72 | # self.project.save_blend_file("/tmp/work/1.blend") 73 | 74 | def testBakeActionSheet(self) -> None: 75 | self.bc = self.project.create_mapping_sheet() 76 | self.bakeTwoTracks() 77 | 78 | 79 | if __name__ == "__main__": 80 | unittest.main() 81 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/mapping_uilist.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from bpy.types import Context, UI_UL_list, UILayout, UIList 4 | 5 | from . import mapping_operators, mapping_utils 6 | from .mapping_properties import MappingItem, MappingProperties 7 | from .preferences import CueListPreferences, MappingPreferences, RhubarbAddonPreferences 8 | 9 | 10 | def draw_mapping_item(ctx: Context, layout: UILayout, mp: MappingProperties, itemIndex: int) -> None: 11 | prefs = RhubarbAddonPreferences.from_context(ctx) 12 | mlp: MappingPreferences = prefs.mapping_prefs 13 | clp: CueListPreferences = prefs.cue_list_prefs 14 | mi: MappingItem = mp.items[itemIndex] 15 | 16 | split = layout.split(factor=0.1) 17 | # row = split.row() 18 | col1 = split.column().row() 19 | col2 = split.column().row(align=True) 20 | 21 | # row.template_icon(icon_value=IconsManager.cue_icon(item.key), scale=5) 22 | # row = split.row() 23 | 24 | emboss = mlp.action_buttons_emboss 25 | if not prefs.use_extended_shapes and mi.cue_info.extended: 26 | col1.enabled = False # Indicate extended shape not in use 27 | if clp.as_circle: 28 | key_label = mi.cue_info.key_displ 29 | else: 30 | key_label = mi.key 31 | col1.operator(mapping_operators.ShowCueInfoHelp.bl_idname, emboss=emboss, text=key_label).key = mi.key 32 | if mi.custom_frame_ranage: 33 | desc = f"{mi.action_str} {mi.frame_range_str}" 34 | else: 35 | desc = f"{mi.action_str}" 36 | 37 | blid = mapping_operators.ListFilteredActions.bl_idname 38 | col2.operator(blid, text=desc, emboss=mlp.action_dropdown_emboss, icon="DOWNARROW_HLT").target_cue_index = itemIndex 39 | 40 | # col2.prop(item, "frame_start", text="") 41 | # col2.prop(item, "frame_count", text="") 42 | # col2.prop(item, "key", text="") 43 | 44 | if mapping_utils.is_mapping_item_active(ctx, mi, ctx.object): 45 | icon = "SNAP_FACE" 46 | else: 47 | icon = "PLAY" 48 | 49 | blid = mapping_operators.PreviewMappingAction.bl_idname 50 | col2.operator(blid, text="", emboss=emboss, icon=icon).target_cue_index = itemIndex 51 | 52 | if mi.custom_frame_ranage: 53 | icon = "CENTER_ONLY" 54 | else: 55 | icon = "ARROW_LEFTRIGHT" 56 | blid = mapping_operators.SetActionFrameRange.bl_idname 57 | col2.operator(blid, text="", emboss=emboss, icon=icon).target_cue_index = itemIndex 58 | 59 | blid = mapping_operators.ClearMappedActions.bl_idname 60 | row = col2.row() 61 | row.operator(blid, text="", emboss=emboss, icon="TRASH").target_cue_index = itemIndex 62 | if mi.action is None and not mi.custom_frame_ranage: 63 | row.enabled = False 64 | 65 | 66 | class MappingUIList(UIList): 67 | bl_idname = "RLPS_UL_mapping" 68 | 69 | def filter_items(self, context: Context, data: MappingProperties, propname: str) -> tuple[Any, list]: 70 | f = self.filter_name.upper() 71 | filtered = UI_UL_list.filter_items_by_name(f, self.bitflag_filter_item, data.items, "key", reverse=False) 72 | return filtered, [] 73 | 74 | def draw_item( 75 | self, 76 | context: Context, 77 | layout: UILayout, 78 | data: MappingProperties, 79 | item: MappingItem, 80 | icon: int, 81 | active_data: MappingProperties, 82 | active_property: str, 83 | index: int, 84 | flt_flag: int, 85 | ) -> None: 86 | RhubarbAddonPreferences.from_context(context) 87 | draw_mapping_item(context, layout, data, index) 88 | # draw_mapping_item_multiline(context, layout, data, index) 89 | -------------------------------------------------------------------------------- /rhubarb_lipsync/__init__.py: -------------------------------------------------------------------------------- 1 | print("RLSP: enter __init__") 2 | from typing import Optional 3 | 4 | import bpy 5 | from bpy.props import PointerProperty 6 | 7 | from .blender.auto_load import AutoLoader 8 | from .blender.capture_properties import CaptureListProperties 9 | from .blender.depsgraph_handler import DepsgraphHandler 10 | from .blender.icons_manager import IconsManager 11 | from .blender.mapping_properties import MappingProperties 12 | from .blender.preferences import RhubarbAddonPreferences 13 | from .rhubarb.log_manager import logManager 14 | 15 | bl_info = { 16 | 'name': 'Rhubarb Lipsync NG', 17 | 'author': 'Premysl Srubar. Inspired by the original version by Andrew Charlton. Includes Rhubarb Lip Sync by Daniel S. Wolf', 18 | 'version': (1, 6, 1), 19 | 'blender': (4, 0, 2), 20 | 'location': '3d View > Sidebar', 21 | 'description': 'Integrate Rhubarb Lipsync into Blender', 22 | 'wiki_url': 'https://github.com/Premik/blender_rhubarb_lipsync_ng', 23 | 'tracker_url': 'https://github.com/Premik/blender_rhubarb_lipsync_ng/issues', 24 | 'support': 'COMMUNITY', 25 | 'category': 'Animation', 26 | } 27 | 28 | autoloader: Optional[AutoLoader] 29 | 30 | 31 | def is_blender_in_debug() -> bool: 32 | """Whether Blender was started with --debug or --debug-python flags""" 33 | return bpy.app.debug or bpy.app.debug_python 34 | 35 | 36 | def init_loggers(prefs: Optional[RhubarbAddonPreferences]) -> None: 37 | global autoloader 38 | if is_blender_in_debug(): 39 | print("RLPS: enter init_loggers() ") 40 | logManager.init(autoloader.modules) 41 | 42 | if is_blender_in_debug(): # If Blender is in debug, force TRACE loglevel 43 | logManager.set_trace() 44 | print(f"RLPS Set TRACE level for {len(logManager.logs)} loggers") 45 | logManager.ensure_console_handler() 46 | else: 47 | if hasattr(prefs, 'log_level') and prefs.log_level != 0: # 0 default level 48 | logManager.set_level(prefs.log_level) 49 | 50 | 51 | # print(f"FILE: {__file__}") 52 | # if is_blender_in_debug(): 53 | 54 | 55 | def register() -> None: 56 | global autoloader 57 | 58 | if is_blender_in_debug(): 59 | print("RLPS: enter register() ") 60 | RhubarbAddonPreferences.bl_idname = __package__ 61 | autoloader = AutoLoader(root_init_file=__file__, root_package_name=__package__) 62 | try: 63 | autoloader.find_classes() 64 | autoloader.register() 65 | finally: 66 | autoloader.trace_print_str() 67 | 68 | bpy.types.Scene.rhubarb_lipsync_captures = PointerProperty(type=CaptureListProperties) 69 | bpy.types.Object.rhubarb_lipsync_mapping = PointerProperty( 70 | type=MappingProperties, 71 | options={'LIBRARY_EDITABLE'}, 72 | override={'LIBRARY_OVERRIDABLE'}, 73 | ) 74 | 75 | prefs = RhubarbAddonPreferences.from_context(bpy.context, False) 76 | init_loggers(prefs) 77 | if hasattr(prefs, 'capture_tab_name'): # Re-set the tab names in case they differ from defaults 78 | prefs.capture_tab_name_updated(bpy.context) 79 | prefs.map_tab_name_updated(bpy.context) 80 | DepsgraphHandler.register() 81 | if is_blender_in_debug(): 82 | print("RLPS: exit register() ") 83 | 84 | 85 | def unregister() -> None: 86 | global autoloader 87 | IconsManager.unregister() 88 | # if 'logManager' in globals(): 89 | # global logManager 90 | # del logManager 91 | autoloader.unregister() 92 | DepsgraphHandler.pending_count = 0 93 | DepsgraphHandler.unregister() 94 | logManager.remove_console_handler() 95 | # del log_manager.logManager 96 | del bpy.types.Scene.rhubarb_lipsync_captures 97 | del bpy.types.Object.rhubarb_lipsync_mapping 98 | 99 | 100 | print("RLSP: exit __init__") 101 | -------------------------------------------------------------------------------- /tests/run_within_blender.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import json 4 | import os 5 | import platform 6 | import sys 7 | import unittest 8 | from pathlib import Path 9 | from typing import Any 10 | 11 | import bpy 12 | 13 | print("-----------------------------------------------------") 14 | print("Python version:", sys.version) 15 | print("Python executable:", sys.executable) 16 | 17 | print("Python paths:") 18 | for p in sys.path: 19 | print(f" {p}") 20 | 21 | 22 | user_dirs = ['BLENDER_USER_RESOURCES', 'BLENDER_USER_CONFIG', 'BLENDER_USER_SCRIPTS', 'XDG_CONFIG_HOME'] 23 | print("USER DIR:") 24 | print('\n'.join(filter(None, [os.environ.get(var, '') for var in user_dirs]))) 25 | 26 | print("-----------------------------------------------------") 27 | import sample_data 28 | 29 | # sample_project.SampleProject.blender_as_module = False 30 | 31 | 32 | # def pytest_configure(config) -> None: 33 | # config.option.log_cli = True 34 | # config.option.log_cli_level = "DEBUG" 35 | 36 | 37 | addons_path: Path = sample_data.sample_data_path.parent 38 | 39 | # For some reason default value is: 40 | # blender/4.2/scripts/ rhubarb_lipsync/bin/rhubarb` 41 | # blender/4.2/scripts/addons/rhubarb_lipsync/bin/ 42 | # 43 | # print('!!!!!') 44 | # print(bpy.utils.user_resource('SCRIPTS', path="addons")) 45 | # prefs: Path = RhubarbAddonPreferences.from_context(bpy.context) 46 | # print(prefs.executable_path_string) 47 | # addons/rhubarb_lipsync/bin/ 48 | 49 | 50 | # Discover all test modules in the addons_path 51 | def discover_test_modules(path: Path): 52 | test_modules = [] 53 | for file in path.rglob('test_*.py'): 54 | # Convert file path to module name 55 | module_name = str(file.relative_to(path)).replace('/', '.').replace('\\', '.').replace('.py', '') 56 | test_modules.append(module_name) 57 | return test_modules 58 | 59 | 60 | test_modules = discover_test_modules(addons_path) 61 | 62 | # test_modules = [ 63 | # 'test_ui_utils', 64 | # 'test_baking_preparation', 65 | # 'test_process_sound_file', 66 | # ] 67 | print(f"Discovered test modules: {test_modules}") 68 | 69 | 70 | def load_tests_from_module(module_name) -> unittest.TestSuite: 71 | module = importlib.import_module(module_name) 72 | suite = unittest.TestSuite() 73 | for name, obj in inspect.getmembers(module): 74 | if inspect.isclass(obj) and issubclass(obj, unittest.TestCase): 75 | suite.addTests(unittest.TestLoader().loadTestsFromTestCase(obj)) 76 | return suite 77 | 78 | 79 | # Discover and load all tests from the specified modules 80 | all_tests = unittest.TestSuite() 81 | for module_name in test_modules: 82 | all_tests.addTests(load_tests_from_module(module_name)) 83 | 84 | print(f"Running tests {all_tests}") 85 | 86 | 87 | def create_test_report(result) -> dict[str, Any]: 88 | report = { 89 | "blender_version": bpy.app.version_string, 90 | "system": platform.system(), 91 | "total_tests": result.testsRun, 92 | "total_passed": result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped), 93 | "total_failed": len(result.failures), 94 | "total_errors": len(result.errors), 95 | "total_skipped": len(result.skipped), 96 | } 97 | return report 98 | 99 | 100 | runner = unittest.TextTestRunner(verbosity=2) 101 | result = runner.run(all_tests) 102 | print("Done") 103 | 104 | 105 | env_log_file_path = os.getenv('TEST_RESULTS_PATH') 106 | default_log_file_path = Path(addons_path / 'test_results.json') 107 | 108 | log_file_path = Path(env_log_file_path) if env_log_file_path else default_log_file_path 109 | 110 | with open(log_file_path, 'w') as log_file: 111 | # Create and write JSON report 112 | report = create_test_report(result) 113 | json.dump(report, log_file, indent=4) 114 | 115 | print(f"Test summary is saved to {log_file_path}") 116 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/cue_uilist.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from bpy.types import Context, UI_UL_list, UILayout, UIList 4 | 5 | from .. import IconsManager 6 | from .capture_properties import MouthCueList, MouthCueListItem 7 | from .misc_operators import PlayRange 8 | from .preferences import CueListPreferences, RhubarbAddonPreferences 9 | 10 | 11 | class MouthCueUIList(UIList): 12 | bl_idname = "RLPS_UL_cues" 13 | 14 | def cuelist_prefs(self, ctx: Context) -> CueListPreferences: 15 | prefs = RhubarbAddonPreferences.from_context(ctx) 16 | return prefs.cue_list_prefs 17 | 18 | def filter_items(self, context: Context, data: MouthCueList, propname: str) -> tuple[Any, list]: 19 | f = self.filter_name.upper() 20 | filtered = UI_UL_list.filter_items_by_name(f, self.bitflag_filter_item, data.items, "key", reverse=False) 21 | return filtered, [] 22 | 23 | def draw_item( 24 | self, 25 | context: Context, 26 | layout: UILayout, 27 | data: MouthCueList, 28 | item: MouthCueListItem, 29 | icon: int, 30 | active_data: MouthCueList, 31 | active_property: str, 32 | index: int, 33 | flt_flag: int, 34 | ) -> None: 35 | if self.layout_type in {'DEFAULT', 'COMPACT'}: 36 | self.draw_compact(layout, item, context) 37 | elif self.layout_type in {'GRID'}: 38 | self.draw_grid(layout, item, context) 39 | 40 | def draw_compact(self, layout: UILayout, item: MouthCueListItem, context: Context) -> None: 41 | clp = self.cuelist_prefs(context) 42 | # row = layout.row() 43 | # prefs = RhubarbAddonPreferences.from_context(context) 44 | if clp.show_col_icon: 45 | split = layout.split(factor=0.2) 46 | else: 47 | split = layout.split(factor=0.1) 48 | 49 | # row.scale_x = 1.15 50 | # row.scale_x = 0.95 51 | 52 | row = split.row() # Icon(0.1) and shape key (0.1) 53 | 54 | if clp.show_col_icon: 55 | row.label(icon_value=IconsManager.cue_icon(item.cue.key)) 56 | if clp.as_circle: 57 | row.label(text=item.cue.info.key_displ) 58 | else: 59 | row.label(text=item.key) 60 | 61 | row = split.row() # Times and operators (0.8) 62 | if clp.show_col_play: 63 | subs = row.split(factor=0.85) 64 | else: 65 | subs = row 66 | 67 | row = subs.row() # Times (0.85) 68 | # row.active = False 69 | # row.enabled = False 70 | # row. 71 | if item.cue.key == 'X': 72 | row.active = False 73 | else: 74 | long = clp.highlight_long_cues 75 | short = clp.highlight_short_cues 76 | if (long > 0 and item.cue.duration > long) or (short >= 0 and item.cue.duration <= short): 77 | row.alert = True # Too long/short cue is suspicious, unless it is silence 78 | 79 | cf = item.cue_frames(context) 80 | if clp.show_col_start_frame: 81 | row.label(text=f"{cf.start_frame_str}") 82 | if clp.show_col_start_time: 83 | row.label(text=f"{cf.start_time_str}s") 84 | 85 | if clp.show_col_len_frame: 86 | row.label(text=f"{cf.duration_frames_str}") 87 | if clp.show_col_len_time: 88 | row.label(text=f"{cf.duration_str}s") 89 | 90 | if clp.show_col_end_frame: 91 | row.label(text=f"{cf.end_frame_str}") 92 | if clp.show_col_end_time: 93 | row.label(text=f"{cf.end_time_str}s") 94 | 95 | if clp.show_col_play: 96 | row = subs.row() # Operator (0.15) 97 | op = row.operator(PlayRange.bl_idname, text="", icon="PLAY") 98 | 99 | op.start_frame = cf.start_frame_left 100 | op.play_frames = cf.duration_frames 101 | 102 | def draw_grid(self, layout: UILayout, item: MouthCueListItem, context: Context) -> None: 103 | layout.alignment = 'CENTER' 104 | 105 | # l.emboss = 'NONE' 106 | # layout.alignment = 'EXPAND' 107 | # l = layout.row() 108 | # l.scale_x = 1 109 | # l.alignment = 'CENTER' 110 | if self.cuelist_prefs(context).as_circle: 111 | layout.label(text=item.cue.info.key_displ) 112 | else: 113 | layout.label(text=item.key) 114 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/depsgraph_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | import bpy 5 | from bpy.types import Context, Depsgraph, Object, Scene 6 | 7 | from . import capture_properties, mapping_properties 8 | from .dropdown_helper import DropdownHelper 9 | from .mapping_properties import NlaTrackRef 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class DepsgraphHandler: 15 | """ 16 | Manages Blender's depsgraph_update_post application handler to trigger 17 | callbacks when specific objects (with mapping) or the scene updates. 18 | """ 19 | 20 | # Static counter to track pending updates 21 | pending_count = 0 22 | MAX_PENDING_UPDATES = 2 23 | 24 | @staticmethod 25 | def handle_track_change(obj: Object, track_field_index: int) -> bool: 26 | mp = mapping_properties.MappingProperties.from_object(obj) 27 | track: NlaTrackRef = mp.nla_track1 if track_field_index == 1 else mp.nla_track2 28 | if not track: 29 | return False 30 | change = track.dropdown_helper.detect_item_changes() 31 | status, idx = change 32 | if status == DropdownHelper.ChangeStatus.UNCHANGED: 33 | return False # Don't call the operator when not change was detected 34 | 35 | # Avoid stack-overflow 36 | pending = DepsgraphHandler.pending_count 37 | if pending >= DepsgraphHandler.MAX_PENDING_UPDATES: 38 | log.warning(f"NLA track_{track_field_index} ref update skipped for {obj.name}: too many pending updates ({pending})") 39 | return False 40 | 41 | try: 42 | DepsgraphHandler.pending_count += 1 43 | log.debug(f"Object: '{obj.name}' changes: {status.name}@{idx}, {track}_{track_field_index} Synchronization triggerd. ({pending})") 44 | # bpy.ops.rhubarb.sync_nla_track_refs(object_name=obj.name, change_status=status.name, new_index=idx, track_field_index=track_field_index) 45 | 46 | track.dropdown_helper.sync_from_items(change) 47 | finally: 48 | DepsgraphHandler.pending_count -= 1 49 | return True 50 | 51 | @staticmethod 52 | def object_with_mapping_updated(ctx: Context, obj: Object, mp: mapping_properties.MappingProperties) -> None: 53 | if not mp.has_NLA_track_selected: 54 | return 55 | changed = DepsgraphHandler.handle_track_change(obj, 1) 56 | changed = DepsgraphHandler.handle_track_change(obj, 2) or changed 57 | if not changed and log.isEnabledFor(logging.TRACE): # type: ignore 58 | log.trace(f"No changes detected for object: {obj.name}") 59 | 60 | @staticmethod 61 | def sync_capture(ctx: Context, cp: capture_properties.CaptureProperties) -> int: 62 | if not cp: 63 | return 0 64 | if cp.on_strip_update(ctx): 65 | return 1 66 | return 0 67 | 68 | @staticmethod 69 | def scene_updated(ctx: Context, scene: Scene) -> None: 70 | if log.isEnabledFor(logging.TRACE): # type: ignore 71 | log.trace("Scene updated") 72 | cprops = capture_properties.CaptureListProperties.from_context(ctx) 73 | # TODO Active strip selection change doesn't generate any events so the sync happens too late and is confusing 74 | # cprops.sync_selection_from_active_strip(ctx) 75 | updated_count = 0 76 | for cp in cprops.items: 77 | updated_count += DepsgraphHandler.sync_capture(ctx, cp) 78 | if updated_count > 0 and log.isEnabledFor(logging.DEBUG): 79 | log.debug(f"Synced {updated_count} Captures") 80 | 81 | @staticmethod 82 | def on_depsgraph_update_post(scene: Scene, depsgraph: Depsgraph) -> None: 83 | try: 84 | ctx: Context = bpy.context 85 | if not ctx: 86 | return 87 | 88 | for update in depsgraph.updates: 89 | if isinstance(update.id, Object): 90 | # Get the actual data object so any changes would persist. https://b3d.interplanety.org/en/objects-referring-in-a-depsgraph_update-handler-feature/ 91 | obj = bpy.data.objects[update.id.name] 92 | mp = mapping_properties.MappingProperties.from_object(obj) 93 | if not mp: # Object but with no mapping 94 | continue 95 | DepsgraphHandler.object_with_mapping_updated(ctx, obj, mp) 96 | continue 97 | if isinstance(update.id, Scene): 98 | DepsgraphHandler.scene_updated(ctx, update.id) 99 | except Exception as e: 100 | msg = f"Unexpected error occured in depsgraph update post handler: {e}" 101 | log.error(msg) 102 | log.debug(traceback.format_exc()) 103 | 104 | @staticmethod 105 | def register() -> None: 106 | if DepsgraphHandler.on_depsgraph_update_post not in bpy.app.handlers.depsgraph_update_post: 107 | bpy.app.handlers.depsgraph_update_post.append(DepsgraphHandler.on_depsgraph_update_post) 108 | 109 | @staticmethod 110 | def unregister() -> None: 111 | if DepsgraphHandler.on_depsgraph_update_post in bpy.app.handlers.depsgraph_update_post: 112 | bpy.app.handlers.depsgraph_update_post.remove(DepsgraphHandler.on_depsgraph_update_post) 113 | -------------------------------------------------------------------------------- /tests/data/threelittlekittens_01_rountreesmith.txt: -------------------------------------------------------------------------------- 1 | Yeah. Section. One of three little kittens who lost their mittens. This is a Librivox recording. All Librivox recordings are in the public domain. For more information or to volunteer, please visit librivox.org. Read by Rachel Tevis. Three little kittens who lost their mittens by Laura Roundtree Smith losing their mittens. Once upon a time, three little kittens went out to slide upon the ice. Old mother kit Kat called after them dot Tot Trot. You have forgotten your mittens. They came back. Pitter, patter, pitter patter, as fast as their furry little feet would carry them. Old mother kit Kat said, 03 little kittens come put on your mittens and she handed dot A pair of red mittens and tat, a pair of blue mittens and trot. A pair of brown mittens. And the three little kittens went merrily off to skate. I don't like to wear mittens. Said dot I don't like to either. Said, Tot trot said, oh meow. They squeeze my paws. Now, what do you suppose those naughty little kittens did? They took off their mittens and left them on the bank by the ice pond. They put on their cunning little skates and began to skate to and fro to and fro and the wind whistled and called, I may freeze your paws and toes. Nobody knows. Nobody knows. My long whiskers cried dot How cold it is? Skating on the ice by my long tail said, Tot how cold my paws are trot. Said we will go back at once to the bank and get our mittens. The three little kittens did not know that three little foxes had crept up on the bank. They did not know that the three little foxes said, see the nice little mittens left here by the kittens. They did not know that the three little foxes had put on their mittens and had run away waving their beautiful tails behind them. When the three little kittens got to the bank and saw that their mittens were gone. Dot And Tot cried together. We are sad little kittens. We have lost our new mittens. Brave little trot said we are smart little kittens. We'll go find our mittens then dot And Tot dried their eyes on their little weaved pocket handkerchiefs and said, you are so cheerful brother trot. We will follow where you lead. The three little kittens looked one hour and 36 minutes for their mittens, but they could not find them. And so they went, sadly Homeward old mother, Kit Kat stood in the doorway looking for them. She cried. Go back, go back. You naughty kittens. Go back and pick up your nice new mittens. The three little kittens hung their heads and said, we put them on the bank to dry. We hope to find them by and by you. Naughty kittens. Old kit Kat said she sent them without supper to bed. The three little kittens cried. Meow, meow, meow. Can't we have a dish of milk? Can't we have a chicken bone? Old mother kit Kat shook her head. So the three little kittens went pit, a pat pit, a pat upstairs to bed. They were so hungry. They kept talking about good things to eat and that made them want their supper. More and more fish and bones said dot Milk and cream said Tot meat and gravy said trot old mother kit Kat called Hush be still for over the hill. The sandman comes his bag to fill at this. The three little kittens pulled the covers high up over their heads for they did not want the sandman to tuck them in his bag. All this time, three little foxes wearing three pairs of mittens were dancing merrily in the moonlight. The sandman came up over the hill and peeped in at the window. He saw the beds where the three little kittens lay with the covers pulled up over their heads. He sang softly. Oh, ho little kittens. I saw your lost mittens. Where, where, where cried? The three little kittens in one breath, uncovering their heads. The sandman answered in a sing song lazy kind of way come jump in the sack. I have on my back. The three little kittens were thinking so hard about their mittens that they forgot to be afraid. And they went, whisk bound into the sandman's sack and rode merrily far away over hill and Dale away away away. Suddenly the sandman's sack broke and out fell. The three little kittens, the sandman did not notice what had happened and he ran on leaving the three little kittens behind him. They saw a little wee house in the woods and ran and knocked on the door. Old mother catastrophe. The oldest cat in the world stuck her head out of the window and called. Did you happen to have a mishap? You sadly disturbed me from my nap. The three little kittens answered old mother catastrophe. Kind and good. We are three little kittens lost in the wood. Old mother catastrophe. Always knew what to do. She opened her door and hugged and kissed the three little kittens and said to the delight of all. Now for the cookie jar, she put a cookie jar on the floor and the three little kittens put in their three little paws without saying, buy or leave or thank you. Or if you please, then what do you suppose happened? The cookie jar rose in the air and settled down out of reach on the highest shelf in the pantry. The three little kittens cried. Oh, and, ah, and what happened to the cookie jar. Old mother catastrophe said I do not really mean to tease but learn some little words like these. Thank you, say. And if you please, the three little kittens said, thank you. Thank you. Please give us a cookie. Please do at that. The cookie jar floated down again and they ate cookies to their heart's content. They told old mother catastrophe about their lost mittens, but she only shook her wise old head and said enough, said enough said night time is the time for bed? She tucked the three little kittens up in her own bed dot And tot and trot fell asleep wondering about their lost mittens. All this time, three little foxes wearing three pairs of mittens were dancing merrily in the moonlight end of section one. 2 | -------------------------------------------------------------------------------- /tests/test_dropdown_selections.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from rhubarb_lipsync.blender.dropdown_helper import DropdownHelper 4 | 5 | 6 | class MockDropdown: 7 | def __init__(self) -> None: 8 | self.index = -1 9 | self.name = "" 10 | self.last_length = 0 11 | 12 | def item2str(self, item) -> str: 13 | return str(item) 14 | 15 | 16 | class DrowpdownHelperSelectAnyTest(unittest.TestCase): 17 | def create_dropdown(self, items: list[str], handling_mode=DropdownHelper.NameNotFoundHandling.SELECT_ANY) -> DropdownHelper: 18 | return DropdownHelper(MockDropdown(), items, handling_mode) 19 | 20 | def testIndexEmpty(self) -> None: 21 | d = self.create_dropdown([]) 22 | d.ensure_index_bounds() 23 | self.assertEqual(len(d.names), 0) 24 | self.assertEqual(d.name, "") 25 | self.assertEqual(d.index, -1) 26 | 27 | def testAddingToEnd(self) -> None: 28 | lst: list[str] = [] 29 | d = self.create_dropdown(lst) 30 | lst.append("aa") 31 | d.ensure_index_bounds() 32 | self.assertEqual(d.index, 0, "Originally invalid index didn't change to the first item") 33 | lst.append("bb") 34 | d.ensure_index_bounds() 35 | self.assertEqual(d.index, 0) 36 | self.assertEqual(d.name, "aa") 37 | 38 | def testAddingToBegening(self) -> None: 39 | lst: list[str] = ["aa"] 40 | d = self.create_dropdown(lst) 41 | d.index = 0 42 | lst.insert(0, "zz") 43 | d.sync_from_items() 44 | self.assertEqual(d.index, 1) 45 | self.assertEqual(d.name, "aa") 46 | 47 | def testIndex2Name(self) -> None: 48 | lst: list[str] = ["0 aa", "1 bb", "2 cc"] 49 | d = self.create_dropdown(lst) 50 | d.ensure_index_bounds() 51 | self.assertEqual(d.index, 0) 52 | self.assertEqual(d.name, "0 aa") 53 | d.index = 10 54 | d.index2name() 55 | self.assertEqual(d.name, "2 cc") 56 | 57 | @unittest.skip("For SELECT_ANY, no unselecting possible currently") 58 | def testUnselecting(self) -> None: 59 | d = self.create_dropdown(["aa", "bb", "cc"]) 60 | d.index = -1 61 | d.index2name() 62 | self.assertEqual(d.name, "") 63 | self.assertEqual(d.index, -1) 64 | 65 | def testName2index(self) -> None: 66 | d = self.create_dropdown(["000 aa", "001 bb", "2 cc"]) 67 | d.index = 10 68 | d.name2index() 69 | self.assertEqual(d.index, 2) 70 | self.assertEqual(d.name, "2 cc") 71 | 72 | d.index = -1 73 | d.name = "001 foo" 74 | self.assertEqual(d.index, 1) 75 | d.name = "nope" 76 | self.assertEqual(d.index, 0) 77 | self.assertEqual(d.name, "000 aa") 78 | 79 | def testName2indexDeletion(self) -> None: 80 | lst: list[str] = ["000 aa"] 81 | d = self.create_dropdown(lst) 82 | lst.clear() 83 | d.name2index() 84 | self.assertEqual(d.name, "") 85 | self.assertEqual(d.index, -1) 86 | 87 | 88 | class DrowpdownHelperUnselectTest(unittest.TestCase): 89 | def create_dropdown(self, items: list[str]) -> DropdownHelper: 90 | return DropdownHelper(MockDropdown(), items, DropdownHelper.NameNotFoundHandling.UNSELECT) 91 | 92 | def testIndexEmpty(self) -> None: 93 | d = self.create_dropdown([]) 94 | d.ensure_index_bounds() 95 | self.assertEqual(d.name, "") 96 | self.assertEqual(d.index, -1) 97 | 98 | def testAdding(self) -> None: 99 | lst: list[str] = [] 100 | d = self.create_dropdown(lst) 101 | lst.append("aa") 102 | d.ensure_index_bounds() 103 | self.assertEqual(d.index, -1) 104 | self.assertEqual(d.name, "") 105 | d.index = 10 106 | d.ensure_index_bounds() 107 | self.assertEqual(d.index, -1) 108 | self.assertEqual(d.name, "") 109 | lst.append("bb") 110 | d.ensure_index_bounds() 111 | self.assertEqual(d.index, -1) 112 | self.assertEqual(d.name, "") 113 | 114 | # def testRename(self) -> None: 115 | # lst: list[str] = ["0 aa", "1 bb", "2 cc"] 116 | # d = self.create_dropdown(lst) 117 | # d.index = 1 118 | # d.ensure_index_bounds() 119 | # self.assertEqual(d.index_from_name, 1) 120 | # self.assertEqual(d.item_name_from_name, "bb") 121 | 122 | def testIndex2Name(self) -> None: 123 | d = self.create_dropdown(["0 aa", "bb", "cc"]) 124 | self.assertEqual(d.index, -1) 125 | self.assertEqual(d.name, "") 126 | d.index2name() 127 | self.assertEqual(d.name, "") 128 | self.assertEqual(d.index, -1) 129 | d.index = 0 130 | d.index2name() 131 | self.assertEqual(d.name, "0 aa") 132 | self.assertEqual(d.index, 0) 133 | d.index = 10 134 | d.index2name() 135 | self.assertEqual(d.name, "") 136 | self.assertEqual(d.index, -1) 137 | 138 | def testName2index(self) -> None: 139 | d = self.create_dropdown(["000 aa", "abc:bb", "2 cc"]) 140 | d.index = 10 141 | d.name2index() 142 | self.assertEqual(d.name, "") 143 | self.assertEqual(d.index, -1) 144 | d.name = "001 foo" 145 | d.name2index() 146 | self.assertEqual(d.name, "") 147 | self.assertEqual(d.index, -1) 148 | d.name = "2 cc" 149 | self.assertEqual(d.index, 2) 150 | self.assertEqual(d.name, "2 cc") 151 | 152 | def testName2indexDeletion(self) -> None: 153 | lst: list[str] = ["000 aa"] 154 | d = self.create_dropdown(lst) 155 | self.assertEqual(d.index, -1) 156 | d.index = 0 157 | self.assertEqual(d.index, 0) 158 | lst.clear() 159 | d.ensure_index_bounds() 160 | self.assertEqual(d.name, "") 161 | self.assertEqual(d.index, -1) 162 | 163 | 164 | if __name__ == '__main__': 165 | unittest.main() 166 | -------------------------------------------------------------------------------- /rhubarb_lipsync/rhubarb/mouth_shape_info.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from enum import Enum 3 | 4 | 5 | class MouthShapeInfo: 6 | """Description of a mouth shape. Metadata.""" 7 | 8 | def __init__(self, key: str, key_displ: str, short_dest: str = "", description: str = "", extended=False) -> None: 9 | self.key = key 10 | self.short_dest = short_dest 11 | self.description = textwrap.dedent(description) 12 | self.key_displ = key_displ 13 | self.extended = extended 14 | 15 | def __str__(self) -> str: 16 | return f"({self.key})-'{self.short_dest}'" 17 | 18 | def __repr__(self) -> str: 19 | return f"{self.key}" 20 | 21 | 22 | class MouthShapeInfos(Enum): 23 | """All possible mouth shapes. Based on the https://github.com/DanielSWolf/rhubarb-lip-sync#readme 24 | https://sunewatts.dk/lipsync/lipsync/article_02.php 25 | """ 26 | 27 | _all: list[MouthShapeInfo] 28 | 29 | A = MouthShapeInfo( 30 | 'A', 31 | 'Ⓐ', 32 | 'P B M sounds. Closed mouth.', 33 | '''\ 34 | Closed mouth for the “P”, “B”, and “M” sounds. 35 | This is almost identical to the Ⓧ shape, but there is ever-so-slight pressure between the lips.''', 36 | ) 37 | B = MouthShapeInfo( 38 | 'B', 39 | 'Ⓑ', 40 | 'K S T sounds. Slightly opened mouth.', 41 | '''\ 42 | Slightly open mouth with clenched teeth. 43 | This mouth shape is used for most consonants (“K”, “S”, “T”, etc.). 44 | It’s also used for some vowels such as the “EE” /i:/ sound in b[ee].''', 45 | ) 46 | C = MouthShapeInfo( 47 | 'C', 48 | 'Ⓒ', 49 | 'EH AE sounds. Opened mouth.', 50 | '''\ 51 | Open mouth. This mouth shape is used for front-open vowels like “EH” /ɛ/ as in m[e]n or r[e]d 52 | and “AE” /æ/ as in b[a]t, s[u]n or s[a]y. 53 | It’s also used for some consonants, depending on context. 54 | This shape is also used as an in-between when animating from Ⓐ or Ⓑ to Ⓓ. 55 | So make sure the animations ⒶⒸⒹ and ⒷⒸⒹ look smooth!''', 56 | ) 57 | D = MouthShapeInfo( 58 | 'D', 59 | 'Ⓓ', 60 | 'A sound. Wide opened mouth.', 61 | '''\ 62 | Wide open mouth. The widest of them all. This mouth shape is used vowels 63 | like “AA” /ɑ/ as in f[a]ther or h[i]de''', 64 | ) 65 | E = MouthShapeInfo( 66 | 'E', 67 | 'Ⓔ', 68 | 'AO ER sounds. Slightly rounded mouth.', 69 | '''\ 70 | Slightly rounded mouth. This mouth shape is used for vowels like “AO” /ɒ/ as in [o]ff, f[a]ll or fr[o]st 71 | and “ER” /ɝ/ as in h[er], b[ir]d or h[ur]t. 72 | This shape is also used as an in-between when animating from Ⓒ or Ⓓ to Ⓕ. 73 | Make sure the mouth isn’t wider open than for Ⓒ. 74 | Both ⒸⒺⒻ and ⒹⒺⒻ should result in smooth animation.''', 75 | ) 76 | F = MouthShapeInfo( 77 | 'F', 78 | 'Ⓕ', 79 | 'UW OW W sounds. Puckered lips.', 80 | '''\ 81 | Puckered lips. This mouth shape is used for “UW” as in y[ou] 82 | and “OW” as in sh[ow] /əʊ/, and “W” as in [w]ay.''', 83 | ) 84 | G = MouthShapeInfo( 85 | 'G', 86 | 'Ⓖ', 87 | 'F V sounds. Teeth touched lip.', 88 | '''\ 89 | Upper teeth touching the lower lip for “F” as in [f]or and “V” as in [v]ery. 90 | If your art style is detailed enough, it greatly improves the overall look of the animation.''', 91 | True, 92 | ) 93 | H = MouthShapeInfo( 94 | 'H', 95 | 'Ⓗ', 96 | 'L sounds. Tongue raised.', 97 | '''\ 98 | This shape is used for long “L” sounds, with the tongue raised behind the upper teeth. 99 | The mouth should be at least far open as in Ⓒ, but not quite as far as in Ⓓ. 100 | Depending on your art style and the angle of the head, the tongue may not be visible at all. 101 | In this case, there is no point in drawing this extra shape.''', 102 | True, 103 | ) 104 | X = MouthShapeInfo( 105 | 'X', 106 | 'Ⓧ', 107 | 'Idle.', 108 | '''\ 109 | Idle position. This mouth shape is used for pauses in speech. 110 | This should be the same mouth drawing you use when your character is walking around without talking. 111 | It is almost identical to Ⓐ, but with slightly less pressure between the lips: For Ⓧ, the lips should be closed but relaxed. 112 | Whether there should be any visible difference between the rest position Ⓧ and the closed 113 | talking mouth Ⓐ depends on your art style and personal taste.''', 114 | True, 115 | ) 116 | 117 | @staticmethod 118 | def all() -> list[MouthShapeInfo]: 119 | if not getattr(MouthShapeInfos, '_all', None): 120 | MouthShapeInfos._all = [m.value for m in MouthShapeInfos.__members__.values()] 121 | return MouthShapeInfos._all # type: ignore 122 | 123 | @staticmethod 124 | def extended() -> list[MouthShapeInfo]: 125 | return [mi for mi in MouthShapeInfos.all() if mi.extended] 126 | 127 | @staticmethod 128 | def extended_keys() -> list[str]: 129 | return [mi.key for mi in MouthShapeInfos.extended()] 130 | 131 | @staticmethod 132 | def is_key_extended(key: str) -> bool: 133 | return bool(key in MouthShapeInfos.extended_keys()) 134 | 135 | @staticmethod 136 | def key2index(key: str) -> int: 137 | i = ord(key) - ord('A') 138 | all = MouthShapeInfos.all() 139 | if i < 0 or i >= len(all): 140 | return len(all) - 1 # Return the last ('X') for unknown keys 141 | return i 142 | 143 | @staticmethod 144 | def index2Info(index: int) -> MouthShapeInfo: 145 | all = MouthShapeInfos.all() 146 | if index >= len(all) or index < 0: 147 | return all[-1] # Return 'X' for out-of-range indices 148 | return all[index] 149 | -------------------------------------------------------------------------------- /tests/test_cue_frames.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from pytest import approx 5 | 6 | import rhubarb_lipsync.rhubarb.rhubarb_command as rhubarb_command 7 | from rhubarb_lipsync.rhubarb.cue_processor import CueProcessor 8 | 9 | # import tests.sample_data 10 | from rhubarb_lipsync.rhubarb.mouth_cues import FrameConfig, MouthCue, MouthCueFrames, frame2time 11 | 12 | 13 | def enableDebug() -> None: 14 | logging.basicConfig() 15 | rhubarb_command.log.setLevel(logging.DEBUG) 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "fcfg", 20 | [ 21 | FrameConfig(60, 1, 0), 22 | FrameConfig(60, 1, 10), 23 | FrameConfig(2997, 100, -2), 24 | FrameConfig(5, 1, 0), 25 | ], 26 | ids=[ 27 | "60fps no offset", 28 | "60fps +10 frames shift", 29 | "29.97fps -2 frames offset", 30 | "5fps no offset", 31 | ], 32 | ) 33 | class TestCueFrames: 34 | def create_mcf_at(self, start_frame: float, duration_frames: float, fcfg: FrameConfig, key="A") -> MouthCueFrames: 35 | start = frame2time(start_frame, fcfg.fps, fcfg.fps_base) 36 | end = frame2time(start_frame + duration_frames, fcfg.fps, fcfg.fps_base) 37 | mc = MouthCue(key, start, end) 38 | return MouthCueFrames(mc, fcfg) 39 | 40 | # @pytest.fixture() 41 | # def fcfg(self) -> FrameConfig: 42 | # return FrameConfig(60, 1, 0) 43 | 44 | @pytest.fixture(autouse=True) 45 | def setup_debug(self) -> None: 46 | enableDebug() 47 | 48 | def test_frame_rounding_two_frames(self, fcfg: FrameConfig) -> None: 49 | # The cue starts slightly before frame 1 and ends little bit after frame 2 50 | # I.e. spawns slightly over 1 frame duration 51 | c = self.create_mcf_at(0.9, 1.2, fcfg) 52 | o = fcfg.offset 53 | 54 | assert c.start_frame == approx(1 + o) 55 | assert c.end_frame == approx(2 + o) 56 | assert c.start_frame_right == approx(1 + o) 57 | assert c.end_frame_right == approx(3 + o) 58 | assert c.end_frame_left == approx(2 + o) 59 | assert c.intersects_frame 60 | 61 | def test_frame_rounding_one_frame(self, fcfg: FrameConfig) -> None: 62 | # # The cue starts slightly before frame 1 and ends litte bit right after it 63 | c = self.create_mcf_at(0.9, 0.3, fcfg) 64 | o = fcfg.offset 65 | 66 | assert c.start_frame == approx(1 + o) 67 | assert c.end_frame == approx(1 + o) 68 | assert c.start_frame_right == approx(1 + o) 69 | assert c.end_frame_right == approx(2 + o) 70 | assert c.end_frame_left == approx(1 + o) 71 | assert c.intersects_frame 72 | 73 | def test_frame_rounding_no_frame(self, fcfg: FrameConfig) -> None: 74 | # The cue duration is shorter than a single frame duration 75 | # and starts in the middle of two frames so there is no intersection 76 | c = self.create_mcf_at(1.1, 0.5, fcfg) 77 | o = fcfg.offset 78 | 79 | assert c.start_frame == approx(1 + o) # Start is closer to 1 + o 80 | assert c.end_frame == approx(2 + o) # End time is 1.6 + o, closer to 2 + o 81 | assert c.start_frame_right == approx(2 + o) 82 | assert c.end_frame_right == approx(2 + o) 83 | assert c.end_frame_left == approx(1 + o) 84 | assert not c.intersects_frame 85 | 86 | def create_cue_processor(self, fcfg: FrameConfig, *frames: float) -> CueProcessor: 87 | mcfs = [] 88 | for i in range(len(frames) - 1): 89 | duration = frames[i + 1] - frames[i] 90 | mcfs.append(self.create_mcf_at(frames[i], duration, fcfg)) 91 | return CueProcessor(fcfg, mcfs) 92 | 93 | def test_cue_processor_trim_no_x(self, fcfg: FrameConfig) -> None: 94 | # Two cues, first got trimmed, second doesn't 95 | cp = self.create_cue_processor(fcfg, 2, 7, 8) 96 | cp.trim_tolerance = 0.001 97 | 98 | assert cp.cue_frames[0].duration_frames == approx(5) 99 | assert cp.cue_frames[1].duration_frames == approx(1) 100 | max_dur = frame2time(2, fcfg.fps, fcfg.fps_base) 101 | cp.trim_long_cues(max_dur, False) 102 | assert cp.cue_frames[0].cue.duration == approx(max_dur) 103 | assert cp.cue_frames[1].duration_frames == approx(1) 104 | 105 | def test_cue_processor_trim_extra_x(self, fcfg: FrameConfig) -> None: 106 | # Three cues, first got trimmed, second doesn't, third does 107 | cp = self.create_cue_processor(fcfg, 2, 7, 8, 18) 108 | cp.trim_tolerance = 0.01 109 | 110 | assert cp.cue_frames[0].duration_frames == approx(5) 111 | assert cp.cue_frames[1].duration_frames == approx(1) 112 | max_dur = frame2time(2, fcfg.fps, fcfg.fps_base) 113 | cp.trim_long_cues(max_dur, True) 114 | assert not cp.cue_frames[0].is_X 115 | assert cp.cue_frames[0].cue.duration == approx(max_dur) 116 | assert cp.cue_frames[1].is_X 117 | assert cp.cue_frames[0].duration_frames + cp.cue_frames[1].duration_frames == approx(5) 118 | 119 | assert cp.cue_frames[2].duration_frames == approx(1) 120 | 121 | assert not cp.cue_frames[3].is_X 122 | assert cp.cue_frames[3].cue.duration == approx(max_dur) 123 | assert cp.cue_frames[4].is_X 124 | 125 | def test_cue_processor_expand_short(self, fcfg: FrameConfig) -> None: 126 | # First cue is between frames 1 and 2, second crosses frame 2 127 | cp = self.create_cue_processor(fcfg, 1.1, 1.3, 2.3) 128 | o = fcfg.offset 129 | 130 | assert cp.cue_frames[0].duration_frames_float == approx(0.2) 131 | assert cp.cue_frames[1].duration_frames_float == approx(1) 132 | cp.ensure_frame_intersection() 133 | # First one should got expaned to left since 1.1 is closer to 1 than 1.3 is to 2 134 | assert cp.cue_frames[0].start_frame_float == approx(1 + o) 135 | assert cp.cue_frames[0].end_frame_float == approx(1.3 + o) 136 | assert cp.cue_frames[1].start_frame_float == approx(1.3 + o) 137 | assert cp.cue_frames[1].duration_frames_float == approx(1) 138 | assert cp.cue_frames[0].intersects_frame 139 | assert cp.cue_frames[1].intersects_frame 140 | -------------------------------------------------------------------------------- /rhubarb_lipsync/rhubarb/cue_processor.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Callable, Iterable, Optional 3 | 4 | from .mouth_cues import FrameConfig, MouthCueFrames, frame2time, log, time2frame_float 5 | 6 | 7 | @dataclass 8 | class CueProcessor: 9 | """Holds and processes the list of detected Mouth cues before they are baked.""" 10 | 11 | frame_cfg: FrameConfig 12 | cue_frames: list[MouthCueFrames] = field(repr=False) 13 | trim_tolerance: float = 0.05 14 | use_extended_shapes: bool = True 15 | 16 | # @docstring_from(frame2time) # type: ignore[misc] 17 | def frame2time(self, frame: float) -> float: 18 | return frame2time(frame - self.frame_cfg.offset, self.frame_cfg.fps, self.frame_cfg.fps_base) 19 | 20 | # @docstring_from(time2frame_float) # type: ignore[misc] 21 | def time2frame_float(self, t: float) -> float: 22 | return time2frame_float(t, self.frame_cfg.fps, self.frame_cfg.fps_base) + self.frame_cfg.offset 23 | 24 | def create_silence_cue(self, start: float, end: float) -> MouthCueFrames: 25 | if self.use_extended_shapes: 26 | return MouthCueFrames.create_X(self.frame_cfg, start, end) 27 | # When not using extended shapes, the A is used instead of X 28 | return MouthCueFrames.create_A(self.frame_cfg, start, end) 29 | 30 | def is_cue_silence(self, cf: MouthCueFrames) -> bool: 31 | if self.use_extended_shapes: 32 | return cf.is_X 33 | return cf.is_A 34 | 35 | @property 36 | def pre_start_cue(self) -> MouthCueFrames: 37 | s = 0.0 38 | if self.cue_frames: 39 | s = self.cue_frames[0].cue.start 40 | return self.create_silence_cue(s - 10, s) 41 | 42 | def __getitem__(self, index) -> MouthCueFrames: 43 | """Returns CueFrames at the given index while supporting out-of-range indices too 44 | by providing fake X cues after the range boundary""" 45 | if index < 0: 46 | return self.pre_start_cue 47 | if index >= len(self.cue_frames): 48 | return self.post_end_cue 49 | return self.cue_frames[index] 50 | 51 | @property 52 | def post_end_cue(self) -> MouthCueFrames: 53 | s = 0.0 54 | if self.cue_frames: 55 | s = self.cue_frames[-1].cue.end 56 | return self.create_silence_cue(s, s + 10) 57 | 58 | @property 59 | def the_last_cue(self) -> Optional[MouthCueFrames]: 60 | if not self.cue_frames: 61 | return None 62 | return self.cue_frames[-1] 63 | 64 | def find_cues_by_duration(self, min_dur=-1.0, max_dur=-1.0, tol_max=0.05, tol_min=0.001) -> Iterable[tuple[int, MouthCueFrames]]: 65 | """Finds cues with duration shorter than min_dur (-1 to ignore) or longer than max_dur (-1 to ignore). 66 | The X (silence) is ignored. Only cues which are significantly (driven by two tolerance params) longer/short are returned""" 67 | for i, cf in enumerate(list(self.cue_frames)): 68 | d = cf.cue.duration 69 | if self.is_cue_silence(cf): 70 | continue # Ignore X (silence) 71 | if max_dur > 0 and d <= max_dur + tol_max: 72 | continue 73 | if min_dur > 0 and d >= min_dur - tol_min: 74 | continue 75 | yield i, cf 76 | 77 | def trim_long_cues(self, max_dur: float, append_x: bool = True) -> int: 78 | modified = 0 79 | for i, cf in self.find_cues_by_duration(-1, max_dur, self.trim_tolerance): 80 | modified += 1 81 | new_end = cf.cue.start + max_dur 82 | if append_x: 83 | cf_silence = self.create_silence_cue(new_end, cf.cue.end) 84 | # Insert the new X after the current trimmed X, encountering previous insertions as they shift the indices 85 | self.cue_frames.insert(i + modified, cf_silence) 86 | cf.cue.end = new_end # Trim duration 87 | 88 | if modified > 0: 89 | log.info(f"Trimmed {modified} Cues as they were too long.") 90 | return modified 91 | 92 | def merge_double_x(self) -> int: 93 | modified = 0 94 | orig_list = list(self.cue_frames) 95 | for i, cf in enumerate(orig_list): 96 | if i <= 0: 97 | continue 98 | if not self.is_cue_silence(cf): 99 | continue 100 | prev_cue = orig_list[i - 1] 101 | if not self.is_cue_silence(prev_cue): 102 | continue 103 | prev_cue.cue.end = cf.cue.end # Prolong prev X end up to this X end 104 | removed = self.cue_frames.pop(i - modified) # Remove the current X 105 | assert removed == cf 106 | modified += 1 107 | prev_cue = cf 108 | 109 | if modified > 0: 110 | log.info(f"Removed {modified} X-Cues as they duplicate.") 111 | return modified 112 | 113 | def ensure_frame_intersection(self) -> int: 114 | """Finds extremely short cues where there is no intersection with a frame and move either start or end to the closest frame time""" 115 | modified = 0 116 | for cf in self.cue_frames: 117 | if cf.intersects_frame: 118 | continue 119 | # Cue is in the middle of two frames, find which end is closer to a frame 120 | d_start = cf.start_frame_float - cf.start_frame_left 121 | d_end = cf.end_frame_right - cf.end_frame_float 122 | assert d_start > 0 and d_end > 0 123 | if d_start < d_end: # Start is closer, expand the cue start to the left 124 | cf.cue.start = self.frame2time(cf.start_frame_left) 125 | else: # End is closer, expand the cue end to the right 126 | cf.cue.end = self.frame2time(cf.end_frame_right) 127 | modified += 1 128 | if modified > 0: 129 | log.info(f"Prolonged {modified} Cues as they were too short and would not have been visible.") 130 | return modified 131 | 132 | def optimize_cues(self, max_cue_duration=0.2) -> str: 133 | steps: list[tuple[Callable[[], int], str]] = [ 134 | (lambda: self.trim_long_cues(max_cue_duration), "ends trimmed"), 135 | (self.ensure_frame_intersection, "duration enlarged"), 136 | (self.merge_double_x, "double X removed"), 137 | ] 138 | report = "" 139 | for s in steps: 140 | count = s[0]() 141 | if count > 0: 142 | report += f" {s[1]}: {count}" 143 | return report 144 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/strip_placement_preferences.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import textwrap 3 | 4 | from bpy.props import BoolProperty, EnumProperty, FloatProperty 5 | from bpy.types import PropertyGroup 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class StripPlacementPreferences(PropertyGroup): 11 | """Defines how to fit an action strip to the track constrained by the cue start and cue length""" 12 | 13 | scale_min: FloatProperty( # type: ignore 14 | "Scale Min", 15 | description=textwrap.dedent( 16 | """\ 17 | Scale down minimum value. Reduces the clip playback seep up to this fraction when the action is too long. 18 | Has no effect when set to 1. Does not affect Actions with only a single keyframe (poses). 19 | """ 20 | ), 21 | min=0.01, 22 | soft_min=0.4, 23 | max=1, 24 | soft_max=1, 25 | default=0.8, 26 | options={'LIBRARY_EDITABLE'}, 27 | override={'LIBRARY_OVERRIDABLE'}, 28 | ) 29 | scale_max: FloatProperty( # type: ignore 30 | "Scale Max", 31 | description=textwrap.dedent( 32 | """\ 33 | Scale up maximum value. Increases the clip playback speed up to this fraction when the action is too short. 34 | Has no effect when set to 1. Does not affect Actions with only a single keyframe (poses). 35 | """ 36 | ), 37 | min=1, 38 | soft_min=1, 39 | max=10, 40 | soft_max=2, 41 | default=1.4, 42 | options={'LIBRARY_EDITABLE'}, 43 | override={'LIBRARY_OVERRIDABLE'}, 44 | ) 45 | 46 | strip_blend_type: EnumProperty( # type: ignore 47 | name="Strip Blend Type", 48 | description=textwrap.dedent( 49 | """\ 50 | Method used for combining the strip's result with accumulated result. 51 | Value used for the newly created strips""" 52 | ), 53 | items=[ 54 | ( 55 | "REPLACE", 56 | "Replace", 57 | textwrap.dedent( 58 | """\ 59 | 60 | The strip values replace the accumulated results by amount specified by influence""" 61 | ), 62 | ), 63 | ( 64 | "COMBINE", 65 | "Combine", 66 | textwrap.dedent( 67 | """\ 68 | 69 | The strip values are combined with accumulated results by appropriately using 70 | addition, multiplication, or quaternion math, based on channel type.""" 71 | ), 72 | ), 73 | ], 74 | default="REPLACE", 75 | options={'LIBRARY_EDITABLE'}, 76 | override={'LIBRARY_OVERRIDABLE'}, 77 | ) 78 | 79 | extrapolation: EnumProperty( # type: ignore 80 | name="Extrapolation", 81 | description=textwrap.dedent( 82 | """\ 83 | How to handle the gaps past the strip extents. 84 | Value used for the newly created strips""" 85 | ), 86 | items=[ 87 | ( 88 | "NOTHING", 89 | "Nothing", 90 | textwrap.dedent( 91 | """\ 92 | 93 | The strip has no influence past its extents.""" 94 | ), 95 | ), 96 | ( 97 | "HOLD", 98 | "Hold", 99 | textwrap.dedent( 100 | """\ 101 | 102 | Hold the first frame if no previous strips in track, and always hold last frame.""" 103 | ), 104 | ), 105 | ( 106 | "HOLD_FORWARD", 107 | "Hold Forward", 108 | textwrap.dedent( 109 | """\ 110 | 111 | Hold Forward -- Only hold last frame.""" 112 | ), 113 | ), 114 | ], 115 | default="NOTHING", 116 | options={'LIBRARY_EDITABLE'}, 117 | override={'LIBRARY_OVERRIDABLE'}, 118 | ) 119 | 120 | use_sync_length: BoolProperty( # type: ignore 121 | default=False, 122 | description='Update range of frames referenced from action after tweaking strip and its keyframes', 123 | name="Sync Length", 124 | options={'LIBRARY_EDITABLE'}, 125 | override={'LIBRARY_OVERRIDABLE'}, 126 | ) 127 | 128 | blend_inout_ratio: FloatProperty( # type: ignore 129 | "Blend In-Out Ratio", 130 | description=textwrap.dedent( 131 | """\ 132 | Ratio between blend-in and blend-out sections. 133 | 134 | For the default value of 0.5, it takes the same amount of time for a cue to fully appear as it does to disappear. 135 | Lower values mean the cue appears faster but takes longer to disappear. 136 | Higher values make the cue appears slower but disappears faster. 137 | """ 138 | ), 139 | min=0, 140 | max=1, 141 | soft_min=0.1, 142 | soft_max=0.9, 143 | default=0.5, 144 | options={'LIBRARY_EDITABLE'}, 145 | override={'LIBRARY_OVERRIDABLE'}, 146 | ) 147 | 148 | inout_blend_type: EnumProperty( # type: ignore 149 | name="In Out Blend Type", 150 | description=textwrap.dedent( 151 | """\ 152 | Method used for blend in/blend out strip options. I.e. how the strips influence changes over time. 153 | """ 154 | ), 155 | items=[ 156 | ( 157 | "NO_BLENDING", 158 | "No blending", 159 | textwrap.dedent( 160 | """\ 161 | 162 | Strip influence is always set to 1. Use this for 2D animations where blending is not desired""" 163 | ), 164 | ), 165 | ( 166 | "BY_RATIO", 167 | "By ratio", 168 | textwrap.dedent( 169 | """\ 170 | 171 | The Blend in/out values are calculated by the addon based on the provided Blend in/out ratio value. 172 | Only strips indicating silence (Ⓧ or Ⓐ) has Auto-blend enabled.""" 173 | ), 174 | ), 175 | ( 176 | "ALWAYS_AUTOBLEND", 177 | "Always auto-blend", 178 | textwrap.dedent( 179 | """\ 180 | 181 | Same as for the By-Ratio option, but the Blender's inbuilt autoblending is always enabled for all Strips. 182 | This should ease futher tweaking of the NLA strip ends.""" 183 | ), 184 | ), 185 | ], 186 | default="BY_RATIO", 187 | options={'LIBRARY_EDITABLE'}, 188 | override={'LIBRARY_OVERRIDABLE'}, 189 | ) 190 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/capture_operators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import bpy 4 | from bpy.props import StringProperty 5 | from bpy.types import Context 6 | 7 | from ..rhubarb.rhubarb_command import RhubarbParser 8 | from . import ui_utils 9 | from .capture_properties import CaptureListProperties, MouthCueList 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class CreateCaptureProps(bpy.types.Operator): 15 | """Create new CaptureProperties item and add it to the capture list in the current scene""" 16 | 17 | bl_idname = "rhubarb.create_capture_props" 18 | bl_label = "Create new capture" 19 | bl_options = {'UNDO', 'REGISTER'} 20 | 21 | def execute(self, context: Context) -> set[str]: 22 | rootProps = CaptureListProperties.from_context(context) 23 | assert rootProps, "Failed to got root properties from the scene. Registration error?" 24 | log.trace("Creating new capture properties") # type: ignore 25 | rootProps.items.add() 26 | rootProps.index = len(rootProps.items) - 1 # Select the newly created item 27 | rootProps.dropdown_helper(context).index2name() 28 | 29 | return {'FINISHED'} 30 | 31 | 32 | class DeleteCaptureProps(bpy.types.Operator): 33 | """Delete existing CaptureProperties item""" 34 | 35 | bl_idname = "rhubarb.delete_capture_props" 36 | bl_label = "Delete capture" 37 | bl_options = {'UNDO', 'REGISTER'} 38 | 39 | @classmethod 40 | def disabled_reason(cls, context: Context) -> str: 41 | rootProps = CaptureListProperties.from_context(context) 42 | if not rootProps.selected_item: 43 | return "No capture selected" 44 | # TODO Check capture is not runnig 45 | return "" 46 | 47 | @classmethod 48 | def poll(cls, context: Context) -> bool: 49 | return ui_utils.validation_poll(cls, context) 50 | 51 | def execute(self, context: Context) -> set[str]: 52 | rootProps = CaptureListProperties.from_context(context) 53 | rootProps.items.remove(rootProps.index) 54 | rootProps.index = rootProps.index - 1 55 | rootProps.dropdown_helper(context).index2name() 56 | return {'FINISHED'} 57 | 58 | 59 | class ClearCueList(bpy.types.Operator): 60 | """Remove all captured cues from the cue list""" 61 | 62 | bl_idname = "rhubarb.clear_cue_list" 63 | bl_label = "Clear the cue list" 64 | bl_options = {'UNDO'} 65 | 66 | @classmethod 67 | def disabled_reason(cls, context: Context) -> str: 68 | props = CaptureListProperties.capture_from_context(context) 69 | if not props: 70 | return "No capture selected" 71 | cl: MouthCueList = props.cue_list 72 | if len(cl.items) <= 0: 73 | return "Cue list is empty" 74 | return "" 75 | 76 | @classmethod 77 | def poll(cls, context: Context) -> bool: 78 | return ui_utils.validation_poll(cls, context) 79 | 80 | # def invoke(self, context: Context, event) -> set: 81 | # wm = context.window_manager 82 | # return wm.invoke_confirm(self, event) 83 | 84 | def execute(self, context: Context) -> set[str]: 85 | props = CaptureListProperties.capture_from_context(context) 86 | cl: MouthCueList = props.cue_list 87 | cl.items.clear() 88 | 89 | return {'FINISHED'} 90 | 91 | 92 | class ExportCueList2Json(bpy.types.Operator): 93 | """Export the current cue list of the selected capture to a json file following the rhubarb-cli format""" 94 | 95 | bl_idname = "rhubarb.export_cue_list2json" 96 | bl_label = "Export to JSON" 97 | 98 | filepath: StringProperty(subtype="FILE_PATH") # type: ignore 99 | filter_glob: StringProperty(default='*.json;', options={'HIDDEN'}) # type: ignore 100 | 101 | @classmethod 102 | def disabled_reason(cls, context: Context) -> str: 103 | props = CaptureListProperties.capture_from_context(context) 104 | if not props: 105 | return "No capture selected" 106 | return "" 107 | 108 | @classmethod 109 | def poll(cls, context: Context) -> bool: 110 | return ui_utils.validation_poll(cls, context) 111 | 112 | def invoke(self, context: Context, event) -> set: 113 | rootProps = CaptureListProperties.from_context(context) 114 | if not self.filepath: 115 | n = rootProps.name 116 | if not n: 117 | n = "capture" 118 | self.filepath = f"{n}.json" 119 | context.window_manager.fileselect_add(self) 120 | return {'RUNNING_MODAL'} 121 | 122 | def execute(self, context: Context) -> set[str]: 123 | cprops = CaptureListProperties.capture_from_context(context) 124 | cl: MouthCueList = cprops.cue_list 125 | cues = [c.cue for c in cl.items] 126 | json = RhubarbParser.unparse_mouth_cues(cues, f"{cprops.sound_file_basename}.{cprops.sound_file_extension}") 127 | log.debug(f"Saving {len(json)} char to {self.filepath} ") 128 | with open(self.filepath, 'w', encoding='utf-8') as file: 129 | file.write(json) 130 | self.report(type={"INFO"}, message=f"Exported {len(cues)} to {self.filepath}") 131 | # cl: MouthCueList = props.cue_list 132 | 133 | return {'FINISHED'} 134 | 135 | 136 | class ImportJsonCueList(bpy.types.Operator): 137 | """Import json file in the rhubarb-cli format""" 138 | 139 | bl_idname = "rhubarb.import_json_cue_list" 140 | bl_label = "Import from JSON" 141 | 142 | filepath: StringProperty(subtype="FILE_PATH") # type: ignore 143 | filter_glob: StringProperty(default='*.json;', options={'HIDDEN'}) # type: ignore 144 | 145 | @classmethod 146 | def disabled_reason(cls, context: Context) -> str: 147 | props = CaptureListProperties.capture_from_context(context) 148 | if not props: 149 | return "No capture selected" 150 | cl: MouthCueList = props.cue_list 151 | if len(cl.items) > 0: 152 | return "There are cues in the list. Clear the list first" 153 | return "" 154 | 155 | @classmethod 156 | def poll(cls, context: Context) -> bool: 157 | return ui_utils.validation_poll(cls, context) 158 | 159 | def invoke(self, context: Context, event) -> set: 160 | context.window_manager.fileselect_add(self) 161 | return {'RUNNING_MODAL'} 162 | 163 | def execute(self, context: Context) -> set[str]: 164 | if not (self.filepath): 165 | return {'CANCELLED'} 166 | 167 | with open(self.filepath, 'r', encoding='utf-8') as file: 168 | json = file.read() 169 | log.debug(f"Parsing {len(json)} char from {self.filepath} ") 170 | json_parsed = RhubarbParser.parse_lipsync_json(json) 171 | cues = RhubarbParser.lipsync_json2MouthCues(json_parsed) 172 | log.debug(f"Parsed {len(cues)} adding them to the uilist") 173 | cprops = CaptureListProperties.capture_from_context(context) 174 | cl: MouthCueList = cprops.cue_list 175 | cl.add_cues(cues) 176 | 177 | self.report(type={"INFO"}, message=f"Imported {len(cues)} from {self.filepath}") 178 | # cl: MouthCueList = props.cue_list 179 | 180 | return {'FINISHED'} 181 | -------------------------------------------------------------------------------- /scripts/package.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | import sys 4 | from functools import cached_property 5 | from pathlib import Path 6 | 7 | from config import dist_zip_name, project_cfg, rhubarb_cfg 8 | from rhubarb_bin import RhubarbBinary 9 | 10 | 11 | def clean_temp_files_at(start_path: Path) -> int: 12 | # https://stackoverflow.com/questions/28991015/python3-project-remove-pycache-folders-and-pyc-files 13 | deleted = 0 14 | for p in start_path.rglob('*.py[cod]'): 15 | p.unlink() 16 | deleted += 1 17 | for p in start_path.rglob('__pycache__'): 18 | shutil.rmtree(p, ignore_errors=True) 19 | deleted += 1 20 | for p in start_path.rglob('.mypy_cache'): 21 | shutil.rmtree(p, ignore_errors=True) 22 | deleted += 1 23 | for p in start_path.rglob('.ruff_cache'): 24 | shutil.rmtree(p, ignore_errors=True) 25 | deleted += 1 26 | 27 | return deleted 28 | 29 | 30 | class PackagePlugin: 31 | """Package (zip) project for the distribution""" 32 | 33 | # 'version': (4, 0, 0), 34 | bl_info_version_pattern = r'''['"]version["']\s*:\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)''' 35 | bl_info_version_rx = re.compile(bl_info_version_pattern) 36 | 37 | misc_ops_pattern = r'''return\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)''' 38 | misc_ops_version_rx = re.compile(misc_ops_pattern) 39 | 40 | readme_version_zip_pattern = r'-\d+\.\d+\.\d+\.zip' 41 | readme_version_zip_rx = re.compile(readme_version_zip_pattern) 42 | 43 | readme_version_release_url_pattern = r'/v\d+\.\d+\.\d+/' 44 | readme_version_release_url_rx = re.compile(readme_version_release_url_pattern) 45 | 46 | blender_manifest_pattern = r'^\s*version\s*=\s*["]\d+\.\d+\.\d+["]' 47 | blender_manifest_rx = re.compile(blender_manifest_pattern) 48 | 49 | sphinx_conf_pattern = r"^\s*release\s*=\s*[']\d+\.\d+[']" 50 | sphinx_conf_rx = re.compile(sphinx_conf_pattern) 51 | 52 | main_package_name = 'rhubarb_lipsync' 53 | 54 | def __init__(self, cfg: dict) -> None: 55 | assert cfg and cfg["project"] 56 | self.cfg = cfg 57 | 58 | @property 59 | def version_str(self) -> str: 60 | return self.cfg["project"]["version"] 61 | 62 | @cached_property 63 | def version_tuple(self) -> tuple[int, int, int]: 64 | t = tuple([int(p) for p in self.version_str.split(".")]) 65 | assert len(t) == 3, f"Unexpected version string. Expect 3 digits. Got '{self.version_str}'" 66 | return t # type: ignore 67 | 68 | @cached_property 69 | def project_dir(self) -> Path: 70 | return Path(__file__).parents[1] 71 | 72 | @cached_property 73 | def main__init_path(self) -> Path: 74 | return self.project_dir / PackagePlugin.main_package_name / "__init__.py" 75 | 76 | @cached_property 77 | def misc_ops_path(self) -> Path: 78 | return self.project_dir / PackagePlugin.main_package_name / "blender" / "misc_operators.py" 79 | 80 | @cached_property 81 | def readme_md_path(self) -> Path: 82 | return self.project_dir / "README.md" 83 | 84 | @cached_property 85 | def blender_manifest_path(self) -> Path: 86 | return self.project_dir / PackagePlugin.main_package_name / "blender_manifest.toml" 87 | 88 | @cached_property 89 | def sphinx_conf_path(self) -> Path: 90 | return self.project_dir / "sphinx" / "conf.py" 91 | 92 | @cached_property 93 | def dist_dir(self) -> Path: 94 | return self.project_dir / "dist" 95 | 96 | def update_version_in_file(self, rx: re.Pattern[str], p: Path, new_ver: str) -> None: 97 | assert p.exists(), f"The {p} doesn't exist" 98 | 99 | replacement_count = 0 100 | updated_lines = [] 101 | 102 | def replace_match(m) -> str: 103 | return new_ver 104 | 105 | with open(p, 'r', encoding='utf-8') as s: 106 | for line in s: 107 | new_line = rx.sub(replace_match, line) # Replaces all occurrences 108 | num_replacements = len(re.findall(rx, line)) # Count how many replacements were made 109 | replacement_count += num_replacements 110 | updated_lines.append(new_line) 111 | 112 | assert replacement_count > 0, f"Failed to update any lines in the {p}. Pattern\n{rx}" 113 | 114 | with open(p, 'w', encoding='utf-8') as s: 115 | s.writelines(updated_lines) 116 | 117 | print(f"Updated {replacement_count} version string(s) to '{new_ver}' in the {p}") 118 | 119 | def update_version_files(self) -> None: 120 | self.update_version_in_file(PackagePlugin.bl_info_version_rx, self.main__init_path, f"'version': {self.version_tuple}") 121 | self.update_version_in_file(PackagePlugin.misc_ops_version_rx, self.misc_ops_path, f'return {self.version_tuple}') 122 | self.update_version_in_file(PackagePlugin.readme_version_zip_rx, self.readme_md_path, f"-{self.version_str}.zip") 123 | self.update_version_in_file(PackagePlugin.readme_version_release_url_rx, self.readme_md_path, f"/v{self.version_str}/") 124 | self.update_version_in_file(PackagePlugin.blender_manifest_rx, self.blender_manifest_path, f'version = "{self.version_str}"') 125 | self.update_version_in_file(PackagePlugin.sphinx_conf_rx, self.sphinx_conf_path, f"release = '{self.version_tuple[0]}.{self.version_tuple[1]}'") 126 | 127 | def clean_temp_files(self) -> None: 128 | d = clean_temp_files_at(self.project_dir) 129 | print(f"Deleted {d} temp/cache files/dirs from the {self.project_dir}") 130 | 131 | def dist_zip_path(self, platform: str) -> Path: 132 | return self.dist_dir / dist_zip_name(platform, self.version_str) 133 | 134 | def github_release_url(self, platform: str) -> str: 135 | zip = self.dist_zip_path(platform).name 136 | # https://github.com/Premik/blender_rhubarb_lipsync_ng/releases/download/v1.5.0/rhubarb_lipsync_ng-Windows-1.5.0 137 | 138 | return f"https://github.com/Premik/blender_rhubarb_lipsync_ng/releases/download/v{self.version_str}/{zip}" 139 | 140 | def zip_dist(self, platform: str) -> None: 141 | """Creates the zip for distribution. Assumes the correct binaries are already deployed in the bin_dir subfolder""" 142 | zip = self.dist_zip_path(platform) 143 | # https://stackoverflow.com/questions/1855095/how-to-create-a-zip-archive-of-a-directory# 144 | print(f"Creating {zip}.") 145 | shutil.make_archive(str(zip), 'zip', self.project_dir, PackagePlugin.main_package_name) 146 | 147 | 148 | if __name__ == '__main__': 149 | platform_name = sys.argv[1] if len(sys.argv) > 1 else "" 150 | pp = PackagePlugin(project_cfg) 151 | pp.update_version_files() 152 | 153 | pp.clean_temp_files() 154 | current = RhubarbBinary.currently_deployed_platform(rhubarb_cfg) # Keep the current platform bin 155 | for b in RhubarbBinary.platforms_by_name(platform_name, rhubarb_cfg): 156 | if not b.is_deployed_to_bin(): 157 | b.deploy_to_bin() # Deploy the correct platform before zipping 158 | pp.zip_dist(b.platform_name) 159 | 160 | if current: # Recover the previously deployed platform, if any 161 | current.deploy_to_bin() 162 | -------------------------------------------------------------------------------- /release_notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## v1.6.0 4 | 5 | **Date:** 2025-06-12 6 | 7 | - There is now two-way **synchronization** between the `Sound Strip` in the `Sequencer` and the `Capture` object. The `Start Frame` updates as the `Strip` is being moved and vice versa. Can be enabled/disabled with the chain like button (enabled by default). 8 | ![AudioStripSync](doc/img/release/AudioStrip2WaySync.gif) 9 | 10 | 11 | 12 | - The `NLA Track` selection dropdowns are now more robust and persists most of the NLA track modifications like track reordering renaming or deletions. 13 | ![NLATrackRef](doc/img/release/NLATrackRef.gif) 14 | 15 | - Offline html/pdf documentation is included as a separate zip file. 16 | --- 17 | 18 | 19 | ## v1.5.4 20 | 21 | **Date:** 2025-03-25 22 | 23 | - Updated the plugin to work with Blender **v4.4** 24 | - The `Use extended shapes` checkbox is now visible in both the Capture as well as the Mapping panel. 25 | 26 | --- 27 | 28 | ## v1.5.3 29 | 30 | **Date:** 2025-03-09 31 | 32 | **Maintenance Release** 33 | 34 | - The `Remove previous strip` button has been moved to the error report section. An option to enable automatic removal has been added. 35 | - A new button has been added to manually or automatically unlink an Active Action (preview) before baking, addressing a common issue where the baked animation would not move. 36 | - Improved error handling for the `Object has no animation data` error on the preview buttons when using only Shape Key Actions. 37 | 38 | ![bakeValidations](doc/img/release/bakeValidations.png) 39 | 40 | 41 | --- 42 | 43 | ## v1.5.2 44 | 45 | **Date:** 2024-09-23 46 | 47 | Fixed a regression where macos-arm64 was missing from the supported architectures in the extension manifest, causing installation failures on arm platform. 48 | 49 | --- 50 | 51 | ## v1.5.1 52 | 53 | **Date:** 2024-09-20 54 | 55 | Fixed legacy installation method broken in 1.5.0. 56 | 57 | --- 58 | 59 | ## v1.5.0 60 | 61 | **Date:** 2024-09-17 62 | 63 | 64 | - Can now be installed as a Blender **Extension**, while legacy addon installation is still supported. 65 | - For **library overrides**, fixed the issue where Mapping items were lost on reload. The mapping [preinitialization-in-the-linked-library-workaround](https://github.com/Premik/blender_rhubarb_lipsync_ng/issues/7#issuecomment-1726421716) is no longer necessary. 66 | 67 | 68 | --- 69 | 70 | ## v1.4.0 71 | 72 | **Date:** 2024-06-18 73 | 74 | - Added `In Out Blend Type` to control Strip Blending, including new **No Blending** to better support 2D animations. 75 | 76 | ![image](doc/img/release/in_out_blend_type.png) 77 | 78 | - The basic/non-extended shapes scenario now works correctly. 79 | - Fixed the `'NoneType' object has no attribute 'fcurve'` error. 80 | - Fixed the `'NoneType' object has no attribute 'sequences_all'` error for some scenes. 81 | 82 | 83 | 84 | --- 85 | 86 | ## v1.3.1 87 | 88 | **Date:** 2024-05-27 89 | 90 | Bugfix release for missed import #15 91 | 92 | --- 93 | 94 | ## v1.3.0 95 | 96 | **Date:** 2024-05-25 97 | 98 | 99 | 100 | - **New simplified bake-to-NLA which should produce better results out-of-the-box**. 101 | - After the too-long cues are trimmed down the gap is filled with the `X` cues (silence) 102 | - The scene end frame is updated if the sound strip reaches after the last frame of the scene. 103 | - For troubleshoting: starting `blender --debug` will force trace level debugging of the plugin. Enabled console logging where missing before 104 | 105 | --- 106 | 107 | ## v1.2.1 108 | 109 | **Date:** 2024-04-11 110 | 111 | Bug fix release 112 | * Mapping preview button works correctly also with shape-key Actions 113 | * Baking multiple objects at once correctly alternate between the two track-pairs 114 | 115 | --- 116 | 117 | ## v1.2.0 118 | 119 | **Date:** 2024-03-30 120 | 121 | ### Capture 122 | - Import/Export captured Cues to json file (mainly used by unitests ) 123 | 124 | ![image](doc/img/release/capture_import_export.png) 125 | 126 | 127 | ### Mapping 128 | - Mapping preview button. Activates the mapped Action on the selected object(s) or all the objects which have mapping. 129 | - A custom frame-range on the mapped Action can be configured. So it is now possible to use ***pose-sheets*** and it is no longer necessary to slice individual poses out into individual Actions. Like for example the `overwrite_shape_action` from the `Faceit` plugin. This is also prerequisite for more automated Faceit integration (wip). 130 | 131 | ![image](doc/img/release/mapping_preview.png) 132 | 133 | 134 | ### NLA Bake dialog 135 | - Caputre selection can be now changed directly on the bake dialog. 136 | - Auto-trim too long Cues on the NLA baking. Enabled by default with some reasonable time. 137 | 138 | 139 | ![image](doc/img/release/nla_bake_dialog.png) 140 | 141 | 142 | ### Other 143 | - Several small UI changes and reorganization 144 | - Unittests on Github works for all three platforms 145 | 146 | 147 | --- 148 | 149 | ## v1.1.1 150 | 151 | **Date:** 2024-01-08 152 | 153 | Bug fix release: the Action dropdown was failing silently where there was any blank Action #8 154 | 155 | --- 156 | 157 | ## v1.1.0 158 | 159 | **Date:** 2024-01-02 160 | 161 | 162 | - The dialog file is automatically used if there is `.txt` file beside the `wav/ogg` file. See #7 163 | - **Up to date** check button in the preferences: 164 | ![image](doc/img/release/up_to_date_check.png) 165 | 166 | 167 | - Baking **Shape-keys** is now supported. 168 | - For Armature use normal Actions 169 | - For Mesh use Shape-key Actions 170 | ![image](doc/img/release/shape_keys_baking.png) 171 | 172 | - Mapping section of the panel has been reorganized. 173 | - Cue-type help button has been moved to the left (click on the A,B,C symbols) 174 | - Action dropdown is now filtered based on the Flags in the "toolbar". Shape/Normal Actions, Assets only, Only Valid/Matching Actions 175 | ![image](doc/img/release/mapping_panel_reorg.png) 176 | 177 | 178 | --- 179 | 180 | ## v1.0.3 181 | 182 | **Date:** 2023-10-09 183 | 184 | Improved logging (log to file) 185 | 186 | --- 187 | 188 | ## v1.0.2 189 | 190 | **Date:** 2023-10-02 191 | 192 | Capture doesn't work for longer sounds clips fix (#8) 193 | 194 | --- 195 | 196 | ## v1.0.1 197 | 198 | **Date:** 2023-09-19 199 | 200 | Library overrides support 201 | 202 | --- 203 | 204 | ## v1.0.0 205 | 206 | **Date:** 2023-09-13 207 | 208 | - Attempt to add library overrides support #7 209 | - Tab name `RLSP` in the 3d view sidebar can now be changed in the preferences 210 | 211 | --- 212 | 213 | ## v0.9.1 214 | 215 | **Date:** 2023-07-10 216 | 217 | Bugfix release, removed stalled imports 218 | 219 | --- 220 | 221 | ## v0.9.0 222 | 223 | **Date:** 2023-07-06 224 | 225 | - Project renamed to `rhubarb_lipsync_ng` as there was nothing left from the original code-base. The versioning was reset as well. 226 | - The Action baking somehow works now. But the Strip Placement needs a rework. Especially the strip-ends placing. Baking of the shape keys actions is not implemented yet. 227 | - Captures are now bound to `Scene` and only Mapping-settings are bound to individual `Object(s)` (typically armature). So one capture can be used for multiple objects and baked at once. 228 | - Setting the start frame works as expected. 229 | -------------------------------------------------------------------------------- /tests/test_process_sound_file.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | from functools import cached_property 4 | from pathlib import Path 5 | from time import sleep 6 | 7 | import rhubarb_lipsync.rhubarb.rhubarb_command as rhubarb_command 8 | 9 | # import tests.sample_data 10 | import sample_data 11 | from rhubarb_lipsync.rhubarb.rhubarb_command import RhubarbCommandAsyncJob, RhubarbCommandWrapper, RhubarbParser 12 | 13 | 14 | def enableDebug() -> None: 15 | logging.basicConfig() 16 | rhubarb_command.log.setLevel(logging.DEBUG) 17 | 18 | 19 | def wait_until_finished(r: RhubarbCommandWrapper) -> None: 20 | assert r.was_started 21 | for i in range(0, 1000): 22 | if r.has_finished: 23 | r.collect_output_sync(ignore_timeout_error=True) 24 | return 25 | sleep(0.1) 26 | r.lipsync_check_progress() 27 | 28 | # print(f"{p}%") 29 | # print(r.stderr) 30 | # print(r.stdout) 31 | assert False, "Seems the process in hanging up" 32 | 33 | 34 | def wait_until_finished_async(job: RhubarbCommandAsyncJob, only_loop_times=0) -> None: 35 | assert job.cmd.was_started 36 | loops = 0 37 | for i in range(0, 1000): 38 | if job.cmd.has_finished: 39 | assert loops > 2, "No progress updates was provided " 40 | return 41 | sleep(0.1) 42 | p = job.lipsync_check_progress_async() 43 | if p is not None: 44 | loops += 1 45 | # print(f"{p}%") 46 | if only_loop_times > 0 and loops > only_loop_times: 47 | return 48 | # print(r.stderr) 49 | # print(r.stdout) 50 | assert False, "Seems the process in hanging up" 51 | 52 | 53 | class RhubarbCommandWrapperTest(unittest.TestCase): 54 | def setUp(self) -> None: 55 | enableDebug() 56 | self.wrapper = RhubarbCommandWrapper(self.executable_path) 57 | 58 | @cached_property 59 | def project_dir(self) -> Path: 60 | return Path(__file__).parents[1] 61 | 62 | @cached_property 63 | def executable_path(self) -> Path: 64 | ret = self.project_dir / "rhubarb_lipsync" / "bin" / RhubarbCommandWrapper.executable_default_filename() 65 | if ret.exists(): 66 | return ret 67 | ret = self.project_dir / "addons" / "rhubarb_lipsync" / "bin" / RhubarbCommandWrapper.executable_default_filename() 68 | return ret 69 | 70 | @cached_property 71 | def data_short(self) -> sample_data.SampleData: 72 | return sample_data.snd_en_male_electricity 73 | 74 | @cached_property 75 | def data_long(self) -> sample_data.SampleData: 76 | return sample_data.snd_en_femal_3kittens 77 | 78 | def compare_cues_testdata(self, expected: sample_data.SampleData, wrapper: RhubarbCommandWrapper) -> None: 79 | res = expected.compare_cues_with_expected(wrapper.get_lipsync_output_cues()) 80 | self.assertIsNone(res, res) 81 | 82 | def compare_testdata_with_current(self, data: sample_data.SampleData) -> None: 83 | self.compare_cues_testdata(data, self.wrapper) 84 | 85 | def testVersion(self) -> None: 86 | self.assertEqual(self.wrapper.get_version(), "1.13.0") 87 | self.assertEqual(self.wrapper.get_version(), "1.13.0") 88 | 89 | def testLipsync_sync(self) -> None: 90 | self.wrapper.lipsync_start(str(self.data_short.snd_file_path)) 91 | wait_until_finished(self.wrapper) 92 | self.compare_testdata_with_current(self.data_short) 93 | 94 | def testLipsync_async(self) -> None: 95 | self.wrapper.lipsync_start(str(self.data_short.snd_file_path)) 96 | wait_until_finished_async(RhubarbCommandAsyncJob(self.wrapper)) 97 | assert not self.wrapper.was_started 98 | assert self.wrapper.has_finished 99 | self.compare_testdata_with_current(self.data_short) 100 | 101 | @unittest.skip("Takes very long. And the output differs a bit each run. Probably after multi-threadinig kicks in.") 102 | def testLipsync_async_long(self) -> None: 103 | self.wrapper.lipsync_start(str(self.data_long.snd_file_path)) 104 | wait_until_finished_async(RhubarbCommandAsyncJob(self.wrapper)) 105 | assert not self.wrapper.was_started 106 | assert self.wrapper.has_finished 107 | self.compare_testdata_with_current(self.data_long) 108 | 109 | def testLipsync_cancel(self) -> None: 110 | job = RhubarbCommandAsyncJob(self.wrapper) 111 | assert not self.wrapper.was_started 112 | assert not self.wrapper.has_finished 113 | self.wrapper.lipsync_start(str(self.data_long.snd_file_path)) 114 | assert self.wrapper.was_started 115 | assert not self.wrapper.has_finished 116 | wait_until_finished_async(job, 4) 117 | assert self.wrapper.was_started 118 | assert not self.wrapper.has_finished 119 | job.cancel() 120 | assert not self.wrapper.was_started 121 | assert not self.wrapper.has_finished 122 | 123 | assert not self.wrapper.stdout, f"No cues expected since the process was canceled. But got\n'{self.wrapper.stdout}' " 124 | 125 | # self.assertEqual(len(s.fullyMatchingParts()), 2) 126 | 127 | def testLipsync_cancel_restat(self) -> None: 128 | job = RhubarbCommandAsyncJob(self.wrapper) 129 | assert job.status == "Stopped" 130 | self.wrapper.lipsync_start(str(self.data_short.snd_file_path)) 131 | assert job.status == "Running" 132 | wait_until_finished_async(job, 4) 133 | assert job.status == "Running" 134 | job.cancel() 135 | assert job.status == "Stopped" 136 | # Start again resuing same cmd wrapper and job 137 | self.wrapper.lipsync_start(str(self.data_short.snd_file_path)) 138 | wait_until_finished(self.wrapper) 139 | assert job.status == "Done" 140 | self.compare_testdata_with_current(self.data_short) 141 | 142 | 143 | class RhubarbParserTest(unittest.TestCase): 144 | def setUp(self) -> None: 145 | enableDebug() 146 | 147 | def testVersion(self) -> None: 148 | self.assertFalse(RhubarbParser.parse_version_info("")) 149 | self.assertFalse(RhubarbParser.parse_version_info("invalid")) 150 | self.assertEqual(RhubarbParser.parse_version_info("\nRhubarb Lip Sync version 01.2.3 \n"), "01.2.3") 151 | 152 | def testStatusLine(self) -> None: 153 | failed = '''{ "type": "failure", "reason": "Error processing file Foo\\nBar\\n" }''' 154 | sts = RhubarbParser.parse_status_infos(failed) 155 | assert len(sts) == 1 156 | st = sts[0] 157 | assert st["type"] == "failure" 158 | 159 | def testParseUnparse(self) -> None: 160 | sample = sample_data.snd_en_male_electricity 161 | # Serialize the expected capture to json first 162 | json = RhubarbParser.unparse_mouth_cues(sample.expected_cues, sample.snd_file_path.name, "test") 163 | # Try to parse it back 164 | parsed_dict = RhubarbParser.parse_lipsync_json(json) 165 | parsed_cues = RhubarbParser.lipsync_json2MouthCues(parsed_dict) 166 | res = sample.compare_cues_with_expected(parsed_cues) 167 | self.assertIsNone(res, res) 168 | 169 | 170 | if __name__ == '__main__': 171 | # unittest.main(RhubarbParserTest()) 172 | # unittest.main(RhubarbCommandWrapperTest()) 173 | unittest.main() 174 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/ui_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | import traceback 4 | from typing import Any, Callable, Iterator, Type 5 | 6 | import bpy 7 | import bpy.utils.previews 8 | from bpy.types import Area, Context, Sound, UILayout, Window 9 | 10 | try: 11 | from bpy.types import Strip # Since v4.4 12 | except ImportError: # Fall back to old API 13 | from bpy.types import SoundSequence as Strip 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | def addon_path() -> pathlib.Path: 19 | return pathlib.Path(__file__).parent.parent 20 | 21 | 22 | def resources_path() -> pathlib.Path: 23 | return addon_path() / 'resources' 24 | 25 | 26 | def find_areas_by_type(context: Context, area_type: str) -> Iterator[tuple[Window, Area]]: 27 | assert context 28 | for window in context.window_manager.windows: 29 | for area in window.screen.areas: 30 | if area.type != area_type: 31 | continue 32 | yield (window, area) 33 | 34 | 35 | def get_sequencer_context(context: Context) -> dict: 36 | """Context needed for sequencer ops (visible sequencer is needed)""" 37 | areas = list(find_areas_by_type(context, 'SEQUENCE_EDITOR')) 38 | if not areas: 39 | return {} 40 | (window, area) = areas[0] 41 | return { 42 | "window": window, 43 | "screen": window.screen, 44 | "area": area, 45 | "scene": context.scene, 46 | } 47 | 48 | 49 | def assert_op_ret(ret: set[str]) -> None: 50 | assert 'FINISHED' in ret, f"Operation execution failed with {ret} code" 51 | 52 | 53 | def draw_expandable_header(props: Any, property_name: str, label: str, layout: UILayout, errors=False) -> bool: 54 | """Draws a checkbox which looks like collapsable sub-panel's header. 55 | Expanded/collapsed state is driven by the provided property. 56 | Returns the expanded status. Inspired by GameRigtTool plugin""" 57 | assert props and property_name, f"Blank '{property_name}' or '{props}'" 58 | row = layout.row(align=True) 59 | row.alignment = "LEFT" 60 | 61 | expanded = getattr(props, property_name) 62 | if expanded: 63 | # icon = 'TRIA_DOWN' 64 | icon = 'DISCLOSURE_TRI_DOWN' 65 | else: 66 | # icon = 'TRIA_RIGHT' 67 | icon = 'DISCLOSURE_TRI_RIGHT' 68 | if errors: 69 | row.alert = True 70 | icon = "ERROR" 71 | 72 | row.prop(props, property_name, text=label, emboss=False, icon=icon) 73 | 74 | return expanded 75 | 76 | 77 | def draw_prop_with_label(props: Any, property_name: str, label, layout: UILayout) -> None: 78 | # TODO This could probably be done better using columns layout 79 | col = layout.column() 80 | split = col.split(factor=0.229) 81 | split.alignment = 'LEFT' 82 | split.label(text=label) 83 | split.prop(props, property_name, text="") 84 | 85 | 86 | def draw_error(layout: bpy.types.UILayout, msg: str, wrap_bo=True) -> None: 87 | if wrap_bo: 88 | box = layout.box() 89 | else: 90 | box = layout 91 | box.alert = True 92 | lines = msg.splitlines() 93 | if not lines: 94 | lines = [""] 95 | if len(lines) == 1: # Single line 96 | box.label(text=msg, icon="ERROR") 97 | return 98 | # Multiline 99 | box.label(text="", icon="ERROR") 100 | for l in lines: 101 | box.label(text=l, icon="BLANK1") 102 | 103 | 104 | def to_relative_path(blender_path: str) -> str: 105 | if not blender_path: 106 | return "" 107 | try: # Can fail on windows 108 | return bpy.path.relpath(blender_path) 109 | except ValueError: 110 | return blender_path # Keep unchanged 111 | 112 | 113 | def to_abs_path(blender_path: str) -> str: 114 | if not blender_path: 115 | return "" 116 | return bpy.path.abspath(blender_path) 117 | 118 | 119 | def validation_poll(cls: Type, context: Context, disabled_reason: Callable[[Context], str] = None) -> bool: 120 | """Helper method to show a validation error of an operator to user in a popup.""" 121 | try: 122 | assert cls 123 | if not disabled_reason: # Locate the 'disabled_reason' as the validation fn if no one is provided 124 | assert hasattr(cls, 'disabled_reason'), f"No validation function provided and the {cls} has no 'disabled_reason' class method" 125 | disabled_reason = cls.disabled_reason 126 | ret = disabled_reason(context) 127 | if not ret: # No validation errors 128 | return True 129 | # Following is not a class method per doc. But seems to work like it 130 | cls.poll_message_set(ret) # type: ignore 131 | return False 132 | except Exception as e: 133 | msg = f"Unexpected error occured when validating operator: {e}" 134 | log.error(msg) 135 | log.debug(traceback.format_exc()) 136 | cls.poll_message_set(msg) # type: ignore 137 | return False 138 | 139 | 140 | def func_fqname(fn: Callable) -> str: 141 | """Fully qualified function name. Including module name""" 142 | return f"{fn.__module__}/{fn.__qualname__}" 143 | 144 | 145 | def remove_handler(handlers: list[Callable], fn: Callable) -> bool: 146 | """Remove function(s) from the handler list. Returns true if anything was removed""" 147 | fqfn = func_fqname(fn) 148 | remove = None 149 | try: 150 | remove = next((f for f in handlers if func_fqname(f) == fqfn)) 151 | handlers.remove(remove) 152 | remove_handler(handlers, fn) 153 | return True 154 | except StopIteration: 155 | return False 156 | 157 | 158 | def redraw_3dviews(ctx: Context) -> None: 159 | if ctx.area: 160 | ctx.area.tag_redraw() 161 | for area in ctx.screen and ctx.screen.areas or []: 162 | if area.type == 'VIEW_3D': 163 | area.tag_redraw() 164 | 165 | 166 | def set_panel_category(panel, category: str) -> None: 167 | """Change the bl_category of the Panel by re-registering the class. This would rename the tab the panel is shown in.""" 168 | try: 169 | if "bl_rna" in panel.__dict__: # Is the class registered? 170 | bpy.utils.unregister_class(panel) # Unregister first if so 171 | panel.bl_category = category 172 | # label_with_prefix:str=panel.bl_label.split(": ", 1) 173 | # panel.bl_label=f"{category}{}" 174 | bpy.utils.register_class(panel) 175 | except: 176 | print("Failed to change panel category") 177 | traceback.print_exc() 178 | 179 | 180 | def len_limited(iterator: Iterator, max_count=1000) -> int: 181 | """Count the number of items of an iterator but would break if the limit is reached.""" 182 | count = 0 183 | for _ in iterator: 184 | count += 1 185 | if count >= max_count: 186 | break 187 | return count 188 | 189 | 190 | def find_sound_strips_by_sound(context: Context, sound: Sound, limit=0) -> list[Strip]: 191 | '''Finds sound strips which are using the specified sound.''' 192 | ret: list[Strip] = [] 193 | if not sound or not context.scene.sequence_editor: 194 | return [] 195 | 196 | for i, sq in enumerate(context.scene.sequence_editor.sequences_all): 197 | if limit > 0 and i > limit: 198 | break # Limit reached, break the search (for performance reasons) 199 | if not hasattr(sq, "sound"): 200 | continue # Not a sound strip 201 | ssq: Strip = sq 202 | foundSnd = ssq.sound 203 | if foundSnd is None: 204 | continue # An empty strip 205 | if sound == foundSnd: 206 | ret.insert(0, ssq) # At the top, priority 207 | continue 208 | if bpy.path.abspath(sound.filepath) == bpy.path.abspath(foundSnd.filepath): 209 | ret.append(ssq) # Match by name, append to end 210 | return ret 211 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/auto_load.py: -------------------------------------------------------------------------------- 1 | ## https://devtalk.blender.org/t/batch-registering-multiple-classes-in-blender-2-8/3253/8 2 | 3 | import importlib 4 | import inspect 5 | import pkgutil 6 | from dataclasses import dataclass, field 7 | from pathlib import Path 8 | from types import ModuleType 9 | from typing import Any, Generator, Iterable, Iterator, Type, get_type_hints 10 | 11 | import bpy 12 | 13 | 14 | @dataclass 15 | class AutoLoader: 16 | root_init_file: str 17 | root_package_name: str 18 | modules: list[ModuleType] = field(default_factory=list) 19 | ordered_classes: list[Type] = field(default_factory=list) 20 | trace: list[str] = field(default_factory=list) 21 | 22 | def trace_pop(self) -> str: 23 | return self.trace.pop() 24 | 25 | def trace_push(self, v: str = "") -> None: 26 | self.trace.append(v) 27 | 28 | @property 29 | def trace_peek(self) -> str: 30 | return self.trace[-1] 31 | 32 | @trace_peek.setter 33 | def trace_peek(self, value: str) -> None: 34 | self.trace[-1] = value 35 | 36 | def trace_items(self, items: Iterable, frame_name: str) -> Generator: 37 | self.trace_push(frame_name) 38 | for item in items: 39 | self.trace_push(str(item)) # Push only the first item 40 | yield item 41 | self.trace_peek = str(item) # Modify the last pushed item to be `str(item)` 42 | self.trace_pop() # Pop the last item 43 | 44 | self.trace_pop() # Pop the frame name 45 | 46 | def trace_str(self) -> str: 47 | """Joins the trace list into a formatted string: frame:item/frame:item/...""" 48 | pairs = [f"{self.trace[i]}={self.trace[i + 1]}" for i in range(0, len(self.trace), 2)] 49 | return "/".join(pairs) 50 | 51 | def trace_print_str(self) -> str: 52 | if self.trace: 53 | print('-' * 80) 54 | print(f"- {self.trace_str()}") 55 | print('-' * 80) 56 | 57 | def find_classes(self) -> None: 58 | self.collect_all_submodules() 59 | self.toposort_classes() 60 | 61 | def register(self) -> None: 62 | for cls in self.trace_items(self.ordered_classes, "class"): 63 | bpy.utils.register_class(cls) 64 | 65 | for module in self.trace_items(self.modules, "module"): 66 | if module.__name__ == __name__: 67 | continue 68 | if hasattr(module, "register"): 69 | module.register() 70 | 71 | def unregister(self) -> None: 72 | for cls in self.trace_items(reversed(self.ordered_classes), "class"): 73 | bpy.utils.unregister_class(cls) 74 | 75 | for module in self.trace_items(self.modules, "module"): 76 | if module.__name__ == __name__: 77 | continue 78 | if hasattr(module, "unregister"): 79 | module.unregister() 80 | 81 | def collect_all_submodules(self) -> None: 82 | directory = Path(self.root_init_file).parent 83 | self.modules = list(self.iter_submodules(directory, self.root_package_name)) 84 | 85 | def iter_submodules(self, path: Path, package_name: str) -> Iterator[ModuleType]: 86 | for name in self.trace_items(sorted(self.iter_submodule_names(path)), f"package={package_name},module"): 87 | yield importlib.import_module("." + name, package_name) 88 | 89 | def iter_submodule_names(self, path: Path, root="") -> Iterator[str]: 90 | for _, module_name, is_package in self.trace_items(pkgutil.iter_modules([str(path)]), f"path:{path} module_details"): 91 | if is_package: 92 | sub_path = path / module_name 93 | sub_root = root + module_name + "." 94 | yield from self.iter_submodule_names(sub_path, sub_root) 95 | else: 96 | yield root + module_name 97 | 98 | def get_register_deps_dict(self) -> dict[Type, set[Type]]: 99 | my_classes = set(self.iter_my_classes()) 100 | deps_dict = {} 101 | for cls in self.trace_items(my_classes, "class"): 102 | deps_dict[cls] = set(self.iter_my_register_deps(cls, my_classes)) 103 | return deps_dict 104 | 105 | def iter_my_register_deps(self, cls: Type, my_classes: set[Type]) -> Generator[Type, None, None]: 106 | yield from self.iter_my_deps_from_annotations(cls, my_classes) 107 | my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")} 108 | yield from self.iter_my_deps_from_parent_id(cls, my_classes_by_idname) 109 | 110 | def iter_my_deps_from_annotations(self, cls: Type, my_classes: set[Type]) -> Generator[Type, None, None]: 111 | for value in self.trace_items(get_type_hints(cls, {}, {}).values(), f"class={cls} obj"): 112 | dependency = self.get_dependency_from_annotation(value) 113 | if dependency is not None: 114 | if dependency in my_classes: 115 | yield dependency 116 | 117 | def get_dependency_from_annotation(self, value: Any) -> Type: 118 | blender_version = bpy.app.version 119 | if blender_version and blender_version >= (2, 93): 120 | if isinstance(value, bpy.props._PropertyDeferred): 121 | return value.keywords.get("type") 122 | else: 123 | if isinstance(value, tuple) and len(value) == 2: 124 | if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): 125 | return value[1]["type"] 126 | return None 127 | 128 | def iter_my_deps_from_parent_id(self, cls: Type, my_classes_by_idname: dict[str, Type]) -> Generator[Type, None, None]: 129 | if bpy.types.Panel in cls.__bases__: 130 | parent_idname = getattr(cls, "bl_parent_id", None) 131 | if parent_idname is not None: 132 | parent_cls = my_classes_by_idname.get(parent_idname) 133 | if parent_cls is not None: 134 | yield parent_cls 135 | 136 | def iter_my_classes(self) -> Generator[Type, None, None]: 137 | base_types = self.get_register_base_types() 138 | for cls in self.trace_items(self.get_classes_in_modules(), "module"): 139 | if any(base in base_types for base in cls.__bases__): 140 | if not getattr(cls, "is_registered", False): 141 | yield cls 142 | 143 | def get_classes_in_modules(self) -> set[Type]: 144 | classes = set() 145 | for module in self.trace_items(self.modules, "module"): 146 | for cls in self.trace_items(self.iter_classes_in_module(module), "class"): 147 | classes.add(cls) 148 | return classes 149 | 150 | def iter_classes_in_module(self, module: ModuleType) -> Generator[Type, None, None]: 151 | for value in self.trace_items(module.__dict__.values(), "attr"): 152 | if inspect.isclass(value): 153 | yield value 154 | 155 | def get_register_base_types(self) -> set[Type]: 156 | return set( 157 | getattr(bpy.types, name) 158 | for name in [ 159 | "Panel", 160 | "Operator", 161 | "PropertyGroup", 162 | "AddonPreferences", 163 | "Header", 164 | "Menu", 165 | "Node", 166 | "NodeSocket", 167 | "NodeTree", 168 | "UIList", 169 | "RenderEngine", 170 | "Gizmo", 171 | "GizmoGroup", 172 | ] 173 | ) 174 | 175 | def toposort_classes(self) -> None: 176 | sorted_values = set() 177 | deps_dict = self.get_register_deps_dict() 178 | while len(deps_dict) > 0: 179 | unsorted = [] 180 | for value, deps in deps_dict.items(): 181 | if len(deps) == 0: 182 | self.ordered_classes.append(value) 183 | sorted_values.add(value) 184 | else: 185 | unsorted.append(value) 186 | deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted} 187 | return self.ordered_classes 188 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/mapping_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Iterator 3 | 4 | import bpy 5 | from bpy.types import Object 6 | 7 | from . import mapping_properties 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | def objects_with_mapping(objects: Iterator[Object]) -> Iterator[Object]: 13 | """Filter all objects which non-blank mapping properties""" 14 | 15 | for o in objects or []: 16 | mp = mapping_properties.MappingProperties.from_object(o) 17 | if mp and mp.has_any_mapping: 18 | yield o 19 | 20 | 21 | def is_fcurve_for_shapekey(fcurve: bpy.types.FCurve) -> bool: 22 | """Determine if an fcurve is for a shape-key action.""" 23 | return fcurve.data_path.startswith("key_blocks[") # There doesn't seems to be a better way that check the data path 24 | 25 | 26 | def is_action_shape_key_action(action: bpy.types.Action) -> bool: 27 | """Determine whether an action is a shape-key action or a regular one.""" 28 | if not action: 29 | return False 30 | if not action.fcurves: 31 | return False # This is not strictly correct, but seems there is not way to know if a blank action is a shape-key one 32 | # return any(is_fcurve_for_shapekey(fcurve) for fcurve in action.fcurves) 33 | return is_fcurve_for_shapekey(action.fcurves[0]) # Should be enought to check the first one only 34 | 35 | 36 | def does_object_support_shapekey_actions(o: bpy.types.Object) -> bool: 37 | """Whether it is currently possible to assign a shape-key action to the provided object. 38 | Object has to be a Mesh with some shape-keys already created""" 39 | if not o: 40 | return False 41 | if o.type != "MESH": 42 | return False 43 | return bool(o.data and o.data.shape_keys) 44 | 45 | 46 | def does_action_fit_object(o: bpy.types.Object, action: bpy.types.Action) -> bool: 47 | """Check if all action's F-Curves paths are valid for the provided object.""" 48 | if not action.fcurves: # Blank actions are considered invalid (#8) 49 | return False 50 | for fcurve in action.fcurves: 51 | try: 52 | if is_fcurve_for_shapekey(fcurve): 53 | if not does_object_support_shapekey_actions(o): 54 | return False # Shape-key action can't fit an object with no shape-key blocks (no-mesh) in the first place 55 | # fcurve Action paths are based on the shape_keys data block 56 | o.data.shape_keys.path_resolve(fcurve.data_path) 57 | else: # Normal action, fcurves are based on the Object 58 | o.path_resolve(fcurve.data_path) 59 | # Sucessfully accessed the property using the F-Curve's data path. 60 | # TODO Check the index too 61 | # if not hasattr(prop, fcurve.array_index): 62 | # return False 63 | except ValueError: 64 | # The data path does not exist on the object 65 | return False 66 | 67 | return True 68 | 69 | 70 | def filtered_actions(o: bpy.types.Object, mp: "mapping_properties.MappingProperties") -> Iterator[bpy.types.Action]: 71 | """Yields all Actions of the current Blender project while applying various filters when enabled in the provided mapping properties""" 72 | if not mp: 73 | return 74 | for action in bpy.data.actions: 75 | if not does_action_fit_object(o, action): # An invalid action 76 | if not mp.only_valid_actions: 77 | yield action # Show-invalid-actions take precedence 78 | continue 79 | # The Only-shape-key is a switch 80 | if mp.only_shapekeys != is_action_shape_key_action(action): 81 | continue 82 | if mp.only_asset_actions and not action.asset_data: 83 | continue 84 | yield action 85 | 86 | 87 | def filtered_actions_for_current_object(ctx: bpy.types.Context) -> Iterator[bpy.types.Action]: 88 | o: bpy.types.Object = ctx.object 89 | mprops = mapping_properties.MappingProperties.from_object(o) 90 | yield from filtered_actions(o, mprops) 91 | 92 | 93 | def is_mapping_item_active(ctx: bpy.types.Context, mi: 'mapping_properties.MappingItem', on_object: Object) -> bool: 94 | """Indicates whether the provided mapping item's Action is active on the provided Object.""" 95 | if (not mi) or (not mi.action) or (not object): 96 | return False 97 | active_object_action: Action 98 | if mi.maps_to_shapekey: # There is a shape-key Action on the mapping item 99 | shape_keys = on_object.data.shape_keys 100 | if (not shape_keys) or (not shape_keys.animation_data): 101 | return False # Shape-key Action can't be active on an object without any shape-keys 102 | active_object_action = shape_keys.animation_data.action 103 | else: # Target is a non-mesh object 104 | if not bool(on_object.animation_data): 105 | return False 106 | active_object_action = on_object.animation_data.action 107 | if not active_object_action: # No Action active 108 | return False 109 | if mi.action != active_object_action: 110 | return False # Object has an Active action but it is not the one mapped 111 | if not mi.custom_frame_ranage: 112 | return True # When not custom framerange and actions match, any timeline position is cosidered active 113 | f = ctx.scene.frame_current # Only active when the timeline position is within the frame sub-range 114 | return mi.frame_range[0] <= f < mi.frame_range[1] 115 | 116 | 117 | def activate_mapping_item(ctx: bpy.types.Context, mi: 'mapping_properties.MappingItem', on_object: Object) -> None: 118 | """Make sure the provided object has the provided Action active and current timeline is at the mapped frame-range""" 119 | if not mi or not mi.action: 120 | return 121 | f = ctx.scene.frame_current 122 | if not (mi.frame_range[0] <= f < mi.frame_range[1]): 123 | # Unless already within the Action's frame subrange set timeline to the begening of the frame (sub)range 124 | ctx.scene.frame_set(frame=int(mi.frame_range[0]), subframe=0) 125 | 126 | if mi.maps_to_shapekey: # There is a shape-key Action on the mapping item 127 | if not does_object_support_shapekey_actions(on_object): 128 | return # Object is not mesh or doesn't have any shape-keys 129 | shape_keys = on_object.data.shape_keys 130 | # Shapekeys action are nested onto the shape_keys animation data 131 | if not bool(shape_keys.animation_data): 132 | shape_keys.animation_data_create() 133 | shape_keys.animation_data.action = mi.action 134 | return 135 | # The Action of the mi is a normal Action 136 | if on_object.type == 'ARMATURE': # Ensure Armature is not in the rest pose 137 | on_object.data.pose_position = 'POSE' 138 | 139 | if not bool(on_object.animation_data): 140 | on_object.animation_data_create() # Ensure the object has animation data 141 | on_object.animation_data.action = mi.action 142 | # TODO - mute the RLPS (or any track?) which affects the object 143 | 144 | 145 | def deactivate_mapping_item(ctx: bpy.types.Context, on_object: Object) -> None: 146 | if getattr(ctx.screen, 'is_animation_playing', False): 147 | bpy.ops.screen.animation_cancel() 148 | if bool(on_object.animation_data): 149 | on_object.animation_data.action = None 150 | if does_object_support_shapekey_actions(on_object): 151 | shape_keys = on_object.data.shape_keys 152 | if shape_keys and bool(shape_keys.animation_data): 153 | shape_keys.animation_data.action = None 154 | 155 | 156 | def list_nla_tracks_of_object(o: bpy.types.Object) -> Iterator[bpy.types.NlaTrack]: 157 | if not o: 158 | return 159 | # For mesh provide shape-key tracks only. But only if the object has any shape-keys created 160 | if does_object_support_shapekey_actions(o): 161 | if not o.data or not o.data.shape_keys or not o.data.shape_keys.animation_data: 162 | return 163 | for t in o.data.shape_keys.animation_data.nla_tracks: 164 | yield t 165 | return 166 | 167 | if not o.animation_data or not o.animation_data.nla_tracks: 168 | return 169 | for t in o.animation_data.nla_tracks: 170 | yield t 171 | -------------------------------------------------------------------------------- /rhubarb_lipsync/rhubarb/log_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | import sys 4 | from functools import cached_property 5 | from types import ModuleType 6 | from typing import Optional 7 | 8 | 9 | # https://stackoverflow.com/questions/2183233/how-to-add-a-custom-loglevel-to-pythons-logging-facility/35804945#35804945 10 | def addLoggingLevel(levelName, levelNum, methodName=None) -> None: 11 | """ 12 | Comprehensively adds a new logging level to the `logging` module and the 13 | currently configured logging class. 14 | 15 | `levelName` becomes an attribute of the `logging` module with the value 16 | `levelNum`. `methodName` becomes a convenience method for both `logging` 17 | itself and the class returned by `logging.getLoggerClass()` (usually just 18 | `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is 19 | used. 20 | 21 | To avoid accidental clobberings of existing attributes, this method will 22 | raise an `AttributeError` if the level name is already an attribute of the 23 | `logging` module or if the method name is already present 24 | 25 | Example 26 | ------- 27 | >>> addLoggingLevel('TRACE', logging.DEBUG - 5) 28 | >>> logging.getLogger(__name__).setLevel("TRACE") 29 | >>> logging.getLogger(__name__).trace('that worked') 30 | >>> logging.trace('so did this') 31 | >>> logging.TRACE 32 | 5 33 | 34 | """ 35 | if not methodName: 36 | methodName = levelName.lower() 37 | 38 | if hasattr(logging, levelName): 39 | raise AttributeError('{} already defined in logging module'.format(levelName)) 40 | if hasattr(logging, methodName): 41 | raise AttributeError('{} already defined in logging module'.format(methodName)) 42 | if hasattr(logging.getLoggerClass(), methodName): 43 | raise AttributeError('{} already defined in logger class'.format(methodName)) 44 | 45 | # This method was inspired by the answers to Stack Overflow post 46 | # http://stackoverflow.com/q/2183233/2988730, especially 47 | # http://stackoverflow.com/a/13638084/2988730 48 | def logForLevel(self, message, *args, **kwargs) -> None: 49 | if self.isEnabledFor(levelNum): 50 | self._log(levelNum, message, args, **kwargs) 51 | 52 | def logToRoot(message, *args, **kwargs) -> None: 53 | logging.log(levelNum, message, *args, **kwargs) 54 | 55 | logging.addLevelName(levelNum, levelName) 56 | setattr(logging, levelName, levelNum) 57 | setattr(logging.getLoggerClass(), methodName, logForLevel) 58 | setattr(logging, methodName, logToRoot) 59 | 60 | 61 | class LogManager: 62 | """Manages loggers from the plugins modules. Allows to batch-get/set log levels""" 63 | 64 | TRACE_LEVEL = logging.DEBUG - 5 65 | 66 | def __init__(self) -> None: 67 | self.modules: list[ModuleType] = [] 68 | self.file_handler: Optional[logging.FileHandler] = None 69 | self.log_file_path: Optional[pathlib.Path] = None 70 | 71 | def init(self, modules: list[ModuleType]) -> None: 72 | self.modules = modules 73 | 74 | @cached_property 75 | def logs(self) -> list[logging.Logger]: 76 | return [m.log for m in self.modules if hasattr(m, 'log')] 77 | 78 | @property 79 | def current_level(self) -> int: 80 | if not self.logs: 81 | return logging.NOTSET 82 | return self.logs[0].level # The (random) first logger's level 83 | 84 | @property 85 | def current_level_name(self) -> str: 86 | return LogManager.level2name(self.current_level) 87 | 88 | @property 89 | def current_level_max(self) -> int: 90 | """The maximal (most verbose) logging level of the managed loggers curently set""" 91 | if not self.logs: 92 | return logging.NOTSET 93 | 94 | # Find the logger with the biggest level value 95 | max_level_logger = max(self.logs, key=lambda l: l.level) 96 | return max_level_logger.level 97 | 98 | @property 99 | def current_level_max_name(self) -> str: 100 | return LogManager.level2name(self.current_level_max) 101 | 102 | def set_level(self, level: int) -> None: 103 | for l in self.logs: 104 | try: 105 | l.setLevel(level) 106 | 107 | except Exception as e: 108 | print(f"Failed to set log level for '{l}': \n{e}") 109 | try: 110 | logManager.ensure_console_handler() 111 | except Exception as e: 112 | print(f"Failed enable console handlers: \n{e}") 113 | 114 | def set_debug(self) -> None: 115 | self.set_level(logging.DEBUG) 116 | 117 | def set_trace(self) -> None: 118 | self.set_level(LogManager.TRACE_LEVEL) 119 | 120 | def validate_log_file(self) -> str: 121 | p = self.log_file_path 122 | if not p: 123 | # return "Log file not specified" 124 | return "" 125 | try: 126 | p.parent.mkdir(parents=True, exist_ok=True) 127 | except Exception as e: 128 | msg = f"Failed to create parent folders {p.parents}." 129 | print(f"{msg}\n{e}") 130 | return msg 131 | if p.is_dir(): 132 | return f"The '{p}' is not a file" 133 | return "" 134 | 135 | def enable_log_file(self) -> None: 136 | err = self.validate_log_file() 137 | if err: 138 | raise RuntimeError(f"Invalid log file. {err}") 139 | fmt = logging.Formatter(logging.BASIC_FORMAT, None, '%') 140 | self.file_handler = logging.FileHandler(self.log_file_path, encoding="UTF-8") # type: ignore 141 | self.file_handler.formatter = fmt 142 | self.file_handler.setLevel(1) # All 143 | try: 144 | for l in self.logs: 145 | l.addHandler(self.file_handler) 146 | 147 | except Exception as e: 148 | msg = "Failed to add log file hander" 149 | self.file_handler = None 150 | print(f"{msg}\n{e}") 151 | raise 152 | print(f"Set {self.log_file_path} file handler on {len(self.logs)} loggers") 153 | 154 | def disable_log_file(self) -> None: 155 | if not self.file_handler: 156 | return 157 | try: 158 | for l in self.logs: 159 | l.removeHandler(self.file_handler) 160 | except Exception as e: 161 | print(f"Failed to remove log file handler\n{e}") 162 | finally: 163 | self.file_handler = None 164 | 165 | @property 166 | def log_file_status(self) -> str: 167 | if not self.log_file_path: 168 | return "DISABLED" 169 | errors = logManager.validate_log_file() 170 | if errors: 171 | return "ERROR" 172 | if self.file_handler is not None: 173 | return "ENABLED" 174 | return "FAILED" 175 | 176 | @cached_property 177 | def console_handler(self) -> logging.StreamHandler: 178 | console_formatter = logging.Formatter(logging.BASIC_FORMAT) 179 | console_handler = logging.StreamHandler(sys.stdout) 180 | console_handler.setFormatter(console_formatter) 181 | console_handler.setLevel(1) # All 182 | return console_handler 183 | 184 | def ensure_console_handler(self) -> None: 185 | added = 0 186 | for logger in self.logs: 187 | if not any(handler == self.console_handler for handler in logger.handlers): 188 | logger.addHandler(self.console_handler) 189 | added += 1 190 | print(f"Added console handler on {added} loggers.") 191 | 192 | def remove_console_handler(self) -> None: 193 | """Removes the console handler from all loggers.""" 194 | removed = 0 195 | for logger in self.logs: 196 | if any(handler == self.console_handler for handler in logger.handlers): 197 | logger.removeHandler(self.console_handler) 198 | removed += 1 199 | print(f"Removed console handler from {removed} loggers.") 200 | 201 | @staticmethod 202 | def level2name(level: int) -> str: 203 | return logging._levelToName.get(level, "?") 204 | 205 | @staticmethod 206 | def ensure_trace() -> None: 207 | if not hasattr(logging, 'TRACE'): 208 | addLoggingLevel('TRACE', LogManager.TRACE_LEVEL) 209 | 210 | 211 | logManager = LogManager() 212 | logManager.ensure_trace() 213 | -------------------------------------------------------------------------------- /tests/test_ui_dropdown_changes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from rhubarb_lipsync.blender.dropdown_helper import DropdownHelper 4 | from test_dropdown_selections import MockDropdown 5 | 6 | # def setUpModule(): 7 | # rhubarb_lipsync.register() # Simulate blender register call 8 | 9 | 10 | class DropdownHelperChangeDetectionTest(unittest.TestCase): 11 | def create_dropdown(self, items: list[str]) -> DropdownHelper: 12 | mock = MockDropdown() 13 | ret = DropdownHelper(mock, items, DropdownHelper.NameNotFoundHandling.UNSELECT) 14 | return ret 15 | 16 | def create_dropdown_and_select(self, items: list[str], index: int) -> DropdownHelper: 17 | ret = self.create_dropdown(items) 18 | ret.index = index 19 | return ret 20 | 21 | def test_detect_empty_list(self) -> None: 22 | d = self.create_dropdown([]) 23 | status, _ = d.detect_item_changes() 24 | self.assertEqual(status, DropdownHelper.ChangeStatus.UNCHANGED) 25 | 26 | def test_removed_last(self) -> None: 27 | lst: list[str] = ["0 aa"] 28 | d = self.create_dropdown_and_select(lst, 0) 29 | lst.clear() 30 | status, _ = d.detect_item_changes() 31 | self.assertEqual(status, DropdownHelper.ChangeStatus.REMOVED) 32 | 33 | def test_detect_unchanged(self) -> None: 34 | d = self.create_dropdown_and_select(["0 item1", "1 item2", "2 item3"], 1) 35 | status, _ = d.detect_item_changes() 36 | self.assertEqual(status, DropdownHelper.ChangeStatus.UNCHANGED) 37 | 38 | def test_detect_moved_right(self) -> None: 39 | d = self.create_dropdown_and_select(["0 item1", "1 item2", "2 item3"], 1) 40 | self.assertEqual(d.item_name_from_name, "item2") 41 | 42 | # Change list: insert new item before item2 43 | d.names = ["0 item1", "1 newitem", "2 item2", "3 item3"] 44 | 45 | status, new_index = d.detect_item_changes() 46 | self.assertEqual(status, DropdownHelper.ChangeStatus.MOVED_TO) 47 | self.assertEqual(new_index, 2) 48 | 49 | def test_detect_moved_left(self) -> None: 50 | d = self.create_dropdown_and_select(["0 item1", "1 newitem", "2 item2", "3 item3"], 2) 51 | self.assertEqual(d.item_name_from_name, "item2") 52 | 53 | d.names = ["0 item1", "1 item2", "2 item3"] 54 | 55 | status, new_index = d.detect_item_changes() 56 | self.assertEqual(status, DropdownHelper.ChangeStatus.MOVED_TO) 57 | self.assertEqual(new_index, 1) 58 | 59 | def test_detect_swap(self) -> None: 60 | orig_items = ["0 item1", "1 item2", "2 other"] 61 | new_items = ["0 item1", "1 other", "2 item2"] 62 | d = self.create_dropdown_and_select(orig_items, 0) 63 | d.names = new_items 64 | status, new_index = d.detect_item_changes() 65 | self.assertEqual(status, DropdownHelper.ChangeStatus.UNCHANGED) 66 | 67 | d = self.create_dropdown_and_select(orig_items, 1) 68 | d.names = new_items 69 | status, new_index = d.detect_item_changes() 70 | self.assertEqual(status, DropdownHelper.ChangeStatus.MOVED_TO) 71 | self.assertEqual(new_index, 2) 72 | 73 | d = self.create_dropdown_and_select(orig_items, 2) 74 | d.names = new_items 75 | status, new_index = d.detect_item_changes() 76 | self.assertEqual(status, DropdownHelper.ChangeStatus.MOVED_TO) 77 | self.assertEqual(new_index, 1) 78 | 79 | def test_detect_moved_distant(self) -> None: 80 | d = self.create_dropdown_and_select(["0 item1", "1 item2", "2 item3"], 1) 81 | 82 | # Change list drastically 83 | d.names = ["0 item1", "1 newitem", "2 newitem2", "3 newitem3", "4 item2", "5 item3"] 84 | 85 | # Detect changes 86 | status, new_index = d.detect_item_changes() 87 | self.assertEqual(status, DropdownHelper.ChangeStatus.MOVED_TO) 88 | self.assertEqual(new_index, 4) # item2 moved to index 4 89 | 90 | def test_detect_removed(self) -> None: 91 | d = self.create_dropdown_and_select(["0 item1", "1 item2", "2 item3"], 1) 92 | d.names = ["1 item1", "2 item3"] # Remove item2 from the list 93 | 94 | status, _ = d.detect_item_changes() 95 | self.assertEqual(status, DropdownHelper.ChangeStatus.REMOVED) 96 | 97 | def test_detect_renamed(self) -> None: 98 | mock = MockDropdown() 99 | 100 | d = DropdownHelper(mock, ["0 item1", "1 item2", "2 item3"], DropdownHelper.NameNotFoundHandling.UNSELECT) 101 | d.index = 1 102 | d.last_length = 3 103 | self.assertEqual(d.item_name_from_name, "item2") 104 | 105 | # Keep same length but rename item at index 1 106 | d.names = ["0 item1", "1 renamed_item", "2 item3"] 107 | status, new_index = d.detect_item_changes() 108 | self.assertEqual(status, DropdownHelper.ChangeStatus.RENAMED) 109 | self.assertEqual(new_index, 1) # Same index but renamed 110 | 111 | def test_detect_renamed_with_multiple_changes(self) -> None: 112 | mock = MockDropdown() 113 | 114 | d = DropdownHelper(mock, ["0 item1", "1 item2", "2 item3"], DropdownHelper.NameNotFoundHandling.UNSELECT) 115 | d.last_length = 3 116 | d.index = 1 117 | 118 | # Change other items but keep same length and rename item at index 1 119 | d.names = ["0 changed1", "1 renamed_item", "2 changed3"] 120 | 121 | # Detect changes 122 | status, new_index = d.detect_item_changes() 123 | self.assertEqual(status, DropdownHelper.ChangeStatus.RENAMED) 124 | self.assertEqual(new_index, 1) 125 | 126 | 127 | class DropdownHelperSyncTest(unittest.TestCase): 128 | def create_dropdown(self, items: list[str], handling_mode=DropdownHelper.NameNotFoundHandling.SELECT_ANY) -> DropdownHelper: 129 | mock = MockDropdown() 130 | ret = DropdownHelper(mock, items, handling_mode) 131 | return ret 132 | 133 | def test_sync_unchanged(self) -> None: 134 | """Test that sync does nothing when items are unchanged""" 135 | d = self.create_dropdown(["0 item1", "1 item2", "2 item3"]) 136 | d.index = 1 137 | original_index = d.index 138 | original_name = d.name 139 | 140 | d.sync_from_items() 141 | 142 | self.assertEqual(d.index, original_index) 143 | self.assertEqual(d.name, original_name) 144 | 145 | def test_sync_item_moved_down(self) -> None: 146 | """Test that sync updates index when item is moved down""" 147 | d = self.create_dropdown(["0 item1", "1 item2", "2 item3"]) 148 | d.index = 1 149 | 150 | # Change list: insert new item before item2 151 | d.names = ["0 item1", "1 newitem", "2 item2", "3 item3"] 152 | 153 | d.sync_from_items() 154 | 155 | self.assertEqual(d.index, 2) # Should find item2 at new position 156 | self.assertEqual(d.item_name_from_name, "item2") 157 | 158 | def test_sync_item_moved_up(self) -> None: 159 | """Test that sync updates index when item is moved to the up""" 160 | d = self.create_dropdown(["0 item1", "1 item2", "2 item3"]) 161 | d.index = 2 162 | 163 | # Change list: remove item2 164 | d.names = ["0 item1", "1 item3"] 165 | 166 | d.sync_from_items() 167 | 168 | self.assertEqual(d.index, 1) # Should find item3 at new position 169 | self.assertEqual(d.item_name_from_name, "item3") 170 | 171 | def test_sync_item_renamed(self) -> None: 172 | """Test that sync handles renamed items""" 173 | d = self.create_dropdown(["0 item1", "1 item2", "2 item3"]) 174 | d.index = 1 175 | d.last_length = 3 # Explicitly set last_length to enable rename detection 176 | 177 | # Change list: rename item2 to modified_item 178 | d.names = ["0 item1", "1 modified_item", "2 item3"] 179 | 180 | d.sync_from_items() 181 | 182 | # Index should stay the same, but name should be updated 183 | self.assertEqual(d.index, 1) 184 | self.assertEqual(d.item_name_from_name, "modified_item") 185 | 186 | def test_sync_item_removed_unselect(self) -> None: 187 | """Test that sync unselects when current item is removed with UNSELECT policy""" 188 | d = self.create_dropdown(["0 item1", "1 item2", "2 item3"], DropdownHelper.NameNotFoundHandling.UNSELECT) 189 | d.index = 1 190 | 191 | # Change list: remove item2 192 | d.names = ["0 item1", "1 item3"] 193 | 194 | d.sync_from_items() 195 | 196 | # Should unselect (index = -1) 197 | self.assertEqual(d.index, -1) 198 | 199 | def test_sync_all_items_removed_select_any(self) -> None: 200 | """Test that sync handles when all items are removed with SELECT_ANY policy""" 201 | d = self.create_dropdown(["0 item1", "1 item2"]) 202 | d.index = 0 203 | 204 | # Change list: remove all items 205 | d.names = [] 206 | 207 | d.sync_from_items() 208 | 209 | # Should unselect even with SELECT_ANY since there's nothing to select 210 | self.assertEqual(d.index, -1) 211 | 212 | def test_sync_new_items_added_when_unselected(self) -> None: 213 | """Test that sync maintains unselected state when new items are added""" 214 | d = self.create_dropdown([], DropdownHelper.NameNotFoundHandling.SELECT_ANY) 215 | d.index = -1 # Explicitly unselected 216 | 217 | # Add new items 218 | d.names = ["0 item1", "1 item2"] 219 | 220 | d.sync_from_items() 221 | 222 | # First item should get selected automatically 223 | self.assertEqual(d.index, 0) 224 | 225 | 226 | if __name__ == '__main__': 227 | unittest.main() 228 | -------------------------------------------------------------------------------- /scripts/sphinx_build.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | import sys 4 | from dataclasses import dataclass 5 | from functools import cached_property 6 | from pathlib import Path 7 | 8 | from PIL import Image 9 | 10 | from config import project_cfg 11 | from markdown_helper import MarkdownLineEditor 12 | from package import PackagePlugin 13 | 14 | 15 | @dataclass 16 | class SphinxBuilder: 17 | """Builds the Sphinx documentation.""" 18 | 19 | sanitize: bool = False 20 | 21 | @cached_property 22 | def project_dir(self) -> Path: 23 | return Path(__file__).parents[1] 24 | 25 | @cached_property 26 | def dist_dir(self) -> Path: 27 | return self.project_dir / "dist" 28 | 29 | @cached_property 30 | def sphinx_dir(self) -> Path: 31 | return self.project_dir / "sphinx" 32 | 33 | @cached_property 34 | def build_dir(self) -> Path: 35 | return self.sphinx_dir / "build" 36 | 37 | @cached_property 38 | def doc_root_dir(self) -> Path: 39 | return self.build_dir / "md_temp" 40 | 41 | @cached_property 42 | def media_dir(self) -> Path: 43 | return self.doc_root_dir / "doc" / "img" 44 | 45 | @cached_property 46 | def html_dir(self) -> Path: 47 | return self.build_dir / "html" 48 | 49 | @cached_property 50 | def pdf_out_dir(self) -> Path: 51 | return self.build_dir / "pdf" 52 | 53 | def copy_readme(self) -> None: 54 | md = MarkdownLineEditor(self.project_dir / "README.md", self.sanitize) 55 | if self.sanitize: 56 | md.replace_images_with_rst() 57 | md.delete_chapter("Rhubarb Lip.*Blender plugin", keep_heading=True) 58 | 59 | # md.delete_chapter("Video tutorials", keep_heading=False) 60 | # md.delete_chapter("Quick Intro", keep_heading=False) 61 | # md.delete_chapter("Combining Armature Actions with Shape Keys", keep_heading=False) 62 | 63 | md.delete_chapter("More details", keep_heading=False) 64 | md.delete_chapter("Contributions", keep_heading=False) 65 | md.delete_table("🪟 Windows") 66 | 67 | md.save_to(self.doc_root_dir / "README.md") 68 | 69 | def copy_media(self) -> None: 70 | src = self.project_dir / "doc" / "img" 71 | dest = self.media_dir 72 | if dest.exists(): 73 | print(f"Removing existing media directory: {dest}") 74 | shutil.rmtree(dest) 75 | print(f"Copying {src} to {dest}") 76 | shutil.copytree(src, dest) 77 | shutil.copy(self.project_dir / "support/blendermarket/assetsUp/RLSP-banner.png", dest) 78 | 79 | def copy_faq(self) -> None: 80 | src = self.project_dir / "faq.md" 81 | dest = self.doc_root_dir / "faq.md" 82 | print(f"Copying {src} to {dest}") 83 | shutil.copy(src, dest) 84 | 85 | def copy_troubleshooting(self) -> None: 86 | md = MarkdownLineEditor(self.project_dir / "troubleshooting.md", self.sanitize) 87 | if self.sanitize: 88 | md.replace_images_with_rst() 89 | md.delete_chapter("Additional detail", keep_heading=False) 90 | 91 | md.save_to(self.doc_root_dir / "troubleshooting.md") 92 | 93 | def copy_test(self) -> None: 94 | md = MarkdownLineEditor(self.project_dir / "test.md", self.sanitize) 95 | if self.sanitize: 96 | md.replace_images_with_rst() 97 | md.save_to(self.doc_root_dir / "test.md") 98 | 99 | def copy_release_notes(self) -> None: 100 | md = MarkdownLineEditor(self.project_dir / "release_notes.md", self.sanitize) 101 | if self.sanitize: 102 | md.replace_images_with_rst() 103 | md.save_to(self.doc_root_dir / "release_notes.md") 104 | 105 | def copy_sphinx_files(self) -> None: 106 | for f in self.sphinx_dir.glob("*"): 107 | if not f.is_file(): 108 | continue 109 | dest_file = self.doc_root_dir / f.name 110 | print(f"Copying {f} to {dest_file}") 111 | shutil.copy(f, dest_file) 112 | 113 | def copy_docs_to_root(self) -> None: 114 | self.doc_root_dir.mkdir(parents=True, exist_ok=True) 115 | # self.copy_test() 116 | self.copy_sphinx_files() 117 | for d in ["static", "templates"]: 118 | src = self.sphinx_dir / d 119 | dest = self.doc_root_dir / d 120 | if dest.exists(): 121 | shutil.rmtree(dest) 122 | if src.exists(): 123 | shutil.copytree(src, dest) 124 | self.copy_media() 125 | self.copy_readme() 126 | self.copy_faq() 127 | self.copy_troubleshooting() 128 | self.copy_release_notes() 129 | 130 | def clean_build(self) -> None: 131 | to_clean = [self.html_dir, self.pdf_out_dir, self.doc_root_dir] 132 | for path in to_clean: 133 | if path.exists(): 134 | print(f"Removing existing output directory: {path}") 135 | shutil.rmtree(path) 136 | self.build_dir.mkdir(parents=True, exist_ok=True) 137 | 138 | def sphinx_build(self, build_type: str, target_dir: Path) -> None: 139 | """Builds the Sphinx documentation.""" 140 | print(f"Building Sphinx documentation in {self.doc_root_dir}") 141 | process = None 142 | try: 143 | command = ["sphinx-build", "-b", build_type, str(self.doc_root_dir), str(target_dir)] 144 | print(f"Running command: {' '.join(command)}") 145 | process = subprocess.run( 146 | command, 147 | capture_output=True, 148 | text=True, 149 | check=True, 150 | ) 151 | print(f"Sphinx documentation built successfully in {target_dir}") 152 | except subprocess.CalledProcessError as e: 153 | print(f"Error building Sphinx documentation: {e}") 154 | print(e.stdout) 155 | if e.stderr: 156 | print("Sphinx Stderr:") 157 | print(e.stderr) 158 | sys.exit(1) 159 | except FileNotFoundError: 160 | print("Error: sphinx-build command not found. Make sure Sphinx is installed and in your PATH.") 161 | sys.exit(1) 162 | finally: 163 | if process: 164 | print(process.stdout) 165 | if process.stderr: 166 | print(process.stderr) 167 | 168 | def sphinx_build_html(self) -> None: 169 | self.sphinx_build("html", self.html_dir) 170 | 171 | def sphinx_build_pdf(self) -> None: 172 | self.sphinx_build("rinoh", self.pdf_out_dir) 173 | 174 | def unanime_gif(self, gif_path: Path) -> None: 175 | """Takes a path to an (animated) gif, extracts the middle frame, and saves it as a single-frame gif, 176 | overwriting the original file.""" 177 | with Image.open(gif_path) as im: 178 | if not getattr(im, "is_animated", False): 179 | # print(f"Image at {gif_path} is not an animated gif.") 180 | return 181 | middle_frame_index = im.n_frames // 2 182 | im.seek(middle_frame_index) 183 | print(f"Unanimating {gif_path}, saving frame {middle_frame_index+1}/{im.n_frames}") 184 | # A copy is needed, otherwise the save fails with "cannot write mode P as G" 185 | frame = im.copy() 186 | frame.save(gif_path) 187 | 188 | def unanime_gifs(self, folder: Path) -> None: 189 | for gif_path in folder.rglob("*.gif"): 190 | self.unanime_gif(gif_path) 191 | 192 | def resize_images(self, max_width: int) -> None: 193 | """Resizes all images in the media directory to a maximum width.""" 194 | for img_path in self.media_dir.rglob("*"): 195 | if not img_path.is_file(): 196 | continue 197 | if img_path.suffix.lower() not in [".png", ".jpg", ".jpeg", ".gif"]: 198 | continue 199 | with Image.open(img_path) as im: 200 | if im.width > max_width: 201 | print(f"Resizing {img_path} from {im.width} to {max_width}") 202 | im.thumbnail((max_width, im.height), Image.Resampling.LANCZOS) 203 | im.save(img_path) 204 | 205 | def build_html(self) -> None: 206 | self.sanitize = False 207 | self.copy_docs_to_root() 208 | self.sphinx_build_html() 209 | 210 | def build_pdf(self) -> None: 211 | self.sanitize = True 212 | self.copy_docs_to_root() 213 | self.unanime_gifs(self.media_dir) 214 | # self.resize_images(max_width=400) 215 | self.sphinx_build_pdf() 216 | 217 | def get_doc_version(self) -> str: 218 | pp = PackagePlugin(project_cfg) 219 | t = pp.version_tuple 220 | return f"{t[0]}.{t[1]}" 221 | 222 | def zip_docs(self) -> None: 223 | version = self.get_doc_version() 224 | zip_name = f"rhubarb-lipsync-docs-{version}" 225 | zip_path = self.dist_dir / zip_name 226 | print(f"Creating documentation package '{zip_path}.zip'") 227 | 228 | # Create a temporary directory to assemble the files to be zipped 229 | temp_dir = self.build_dir / "zip_temp" 230 | if temp_dir.exists(): 231 | shutil.rmtree(temp_dir) 232 | temp_dir.mkdir() 233 | 234 | # Copy HTML and PDF output to the temporary directory 235 | shutil.copytree(self.html_dir, temp_dir / "html") 236 | pdf_dest = temp_dir / "pdf" 237 | pdf_dest.mkdir() 238 | for f in self.pdf_out_dir.glob("*.pdf"): 239 | shutil.copy(f, pdf_dest) 240 | 241 | # Create the zip archive 242 | shutil.make_archive(str(zip_path), 'zip', root_dir=temp_dir) 243 | 244 | # Clean up the temporary directory 245 | shutil.rmtree(temp_dir) 246 | 247 | 248 | if __name__ == '__main__': 249 | builder = SphinxBuilder() 250 | builder.clean_build() 251 | builder.build_html() 252 | builder.build_pdf() 253 | builder.zip_docs() 254 | -------------------------------------------------------------------------------- /tests/test_NLA_dropdown.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | from dataclasses import dataclass 4 | 5 | import bpy 6 | 7 | import rhubarb_lipsync.blender.ui_utils as ui_utils 8 | import sample_project 9 | from rhubarb_lipsync.blender.mapping_properties import MappingProperties, NlaTrackRef 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class DeleteObjectNLATrack(bpy.types.Operator): 15 | bl_idname = "rhubarb.delete_object_nla_track" 16 | bl_label = "Delete a NLA track on an object. Used only by tests." 17 | bl_options = {'INTERNAL'} 18 | 19 | object_name: bpy.props.StringProperty() # type: ignore 20 | track_index: bpy.props.IntProperty(default=-1) # type: ignore 21 | 22 | def execute(self, context: bpy.types.Context) -> set[str]: 23 | obj = bpy.data.objects.get(self.object_name) 24 | if not obj: 25 | return {'CANCELLED'} 26 | if self.track_index < 0: 27 | return {'CANCELLED'} 28 | ad = obj.animation_data 29 | if not ad: 30 | return {'CANCELLED'} 31 | if self.track_index >= len(ad.nla_tracks): 32 | return {'CANCELLED'} 33 | ad.nla_tracks.remove(ad.nla_tracks[self.track_index]) 34 | log.debug(f"Removed NLATrack[{self.track_index}] on {self.object_name} ") 35 | 36 | # Update NlaTrackRef objects - needed to avoid unit-test side effect 37 | mprops = MappingProperties.from_object(obj) 38 | if mprops: 39 | for track_ref in [mprops.nla_track1, mprops.nla_track2]: 40 | if track_ref.index == self.track_index: 41 | track_ref.index = -1 42 | track_ref.name = "" 43 | elif track_ref.index > self.track_index: 44 | track_ref.index -= 1 45 | track_ref.dropdown_helper.detect_item_changes() 46 | 47 | return {'FINISHED'} 48 | 49 | 50 | class DummyOp(bpy.types.Operator): 51 | bl_idname = "rhubarb.dummy_op" 52 | bl_label = "Helper for unit tests" 53 | bl_options = {'INTERNAL'} 54 | 55 | def execute(self, context: bpy.types.Context) -> set[str]: 56 | log.warn("\n---------------------\nDummy op\n--------------------\n\n") 57 | return {'FINISHED'} 58 | 59 | 60 | @dataclass 61 | class NLATestHelper: 62 | project: sample_project.SampleProject 63 | 64 | def __post_init__(self) -> None: 65 | self.project.create_mapping_1action_on_armature() 66 | self.create_tracks() 67 | if "delete_object_nla_track" not in dir(bpy.ops.rhubarb): 68 | bpy.utils.register_class(DeleteObjectNLATrack) 69 | 70 | def create_track(self, name: str) -> None: 71 | o = bpy.context.object 72 | assert o, "No object active" 73 | ad = o.animation_data 74 | if not ad: 75 | o.animation_data_create() 76 | ad = o.animation_data 77 | tracks = ad.nla_tracks 78 | t = tracks.new() 79 | t.name = name 80 | 81 | def create_tracks(self) -> None: 82 | self.create_track("VeryFirst") 83 | self.project.add_track1() 84 | self.create_track("Middle") 85 | self.project.add_track2() 86 | self.create_track("End") 87 | ad = bpy.context.object.animation_data 88 | assert len(ad.nla_tracks) >= 5 89 | self.trigger_depsgraph() 90 | self.verify_rlps_tracks(1, 3) 91 | 92 | @property 93 | def track1(self) -> NlaTrackRef: 94 | return self.project.mprops.nla_track1 95 | 96 | @property 97 | def track2(self) -> NlaTrackRef: 98 | return self.project.mprops.nla_track2 99 | 100 | def verify_rlps_track(self, track_ref: NlaTrackRef, expected_index: int, msg: str = "") -> None: 101 | """Verify that the track reference points to a valid RLPS track with the expected index""" 102 | o: bpy.Object = track_ref.object 103 | tracks = [t.name for t in o.animation_data.nla_tracks] 104 | 105 | # Create a formatted track list with indices, highlighting the selected track 106 | formatted_tracks = [] 107 | for i, track in enumerate(tracks): 108 | if i == track_ref.index: 109 | formatted_tracks.append(f"[{i}:{track}]".ljust(20)) 110 | else: 111 | formatted_tracks.append(f"{i}:{track}".ljust(20)) 112 | track_list_str = "|".join(formatted_tracks) 113 | 114 | assert track_ref.selected_item is not None, f"{msg} Track reference should have a selected item.\n{track_list_str}" 115 | assert track_ref.index == expected_index, f"{msg} Track index should be {expected_index}, but is {track_ref.index}\n{track_list_str}" 116 | assert ( 117 | "RLPS Track" in track_ref.selected_item.name 118 | ), f"{msg} Track name should contain 'RLPS Track', but is '{track_ref.selected_item.name}'\{track_list_str}" 119 | 120 | def verify_rlps_tracks(self, expected_index1: int, expected_index2: int, msg: str = "") -> None: 121 | """Verify both track references point to valid RLPS tracks with the expected indices""" 122 | self.verify_rlps_track(self.track1, expected_index1, f"Track1: {msg}") 123 | self.verify_rlps_track(self.track2, expected_index2, f"Track2: {msg}") 124 | 125 | def trigger_depsgraph(self) -> None: 126 | if "dummy_op" not in dir(bpy.ops.rhubarb): 127 | bpy.utils.register_class(DummyOp) 128 | ui_utils.assert_op_ret(bpy.ops.rhubarb.dummy_op()) 129 | 130 | 131 | class NLADropdownBasicTest(unittest.TestCase): 132 | def setUp(self) -> None: 133 | self.helper = NLATestHelper(sample_project.SampleProject()) 134 | 135 | def testBasic1Action(self) -> None: 136 | assert self.helper.track1.index 137 | print(self.helper.track1) 138 | print(self.helper.track2) 139 | self.helper.verify_rlps_tracks(1, 3) # Initially track1 is at index 1, track2 is at index 3 140 | 141 | def testRenameTrack2(self) -> None: 142 | # Rename track2 but keep RLPS Track in the name 143 | ad = bpy.context.object.animation_data 144 | self.helper.trigger_depsgraph() 145 | ad.nla_tracks[3].name = "NEW_PREFIX RLPS Track 2" 146 | 147 | self.helper.verify_rlps_tracks(1, 3) 148 | 149 | def testDeleteLastTrack(self) -> None: 150 | ad = bpy.context.object.animation_data 151 | ui_utils.assert_op_ret(bpy.ops.rhubarb.delete_object_nla_track(object_name=bpy.context.object.name, track_index=4)) 152 | self.helper.verify_rlps_tracks(1, 3) # Should stay the same 153 | 154 | 155 | class NLADropdownComplex(unittest.TestCase): 156 | def setUp(self) -> None: 157 | self.helper = NLATestHelper(sample_project.SampleProject()) 158 | 159 | def testDeleteMiddleTrack(self) -> None: 160 | # ad = bpy.context.object.animation_data 161 | ui_utils.assert_op_ret(bpy.ops.rhubarb.delete_object_nla_track(object_name=bpy.context.object.name, track_index=2)) 162 | 163 | # Verify track references - track1 should still be at index 1, 164 | # but track2 should now be at index 2 since the track before it was removed 165 | self.helper.verify_rlps_tracks(1, 2, "After deleting middle track") 166 | 167 | def testDeleteFirstTrack(self) -> None: 168 | ad = bpy.context.object.animation_data 169 | ui_utils.assert_op_ret(bpy.ops.rhubarb.delete_object_nla_track(object_name=bpy.context.object.name, track_index=0)) 170 | # Verify track references - both should be shifted down by 1 171 | self.helper.verify_rlps_tracks(0, 2, "After deleting first track") 172 | 173 | @unittest.skip("When track is moved with an operation the change is somehow not (instantly?) propagate") 174 | def testMoveFirstTrackDown(self) -> None: 175 | # Move "VeryFirst" track down (swap with RLPS Track 1) 176 | ad = bpy.context.object.animation_data 177 | very_first_track = ad.nla_tracks[0] 178 | very_first_track.select = True 179 | ad.nla_tracks.active = very_first_track 180 | 181 | area = None 182 | for a in bpy.context.screen.areas: 183 | print(a.type) 184 | if a.type == 'OUTLINER': 185 | area = a 186 | area.type = 'NLA_EDITOR' 187 | region = None 188 | for r in area.regions: 189 | if r.type == 'WINDOW': 190 | region = r 191 | break 192 | break 193 | else: 194 | # If no dopesheet editor is open, we may need to create one or skip the test 195 | self.skipTest("No outliner area found to convert to NLA editor") 196 | 197 | if region is None: 198 | self.skipTest("Could not find WINDOW region in NLA editor") 199 | # Use the temp_override context manager 200 | with bpy.context.temp_override(area=area, region=region): 201 | bpy.ops.anim.channels_move(direction='DOWN') 202 | 203 | # After moving, the RLPS Track 1 should be at index 0 and VeryFirst at index 1 204 | self.helper.verify_rlps_tracks(0, 3, "After moving first track down") 205 | 206 | # Verify the track names at their new positions 207 | self.assertEqual(ad.nla_tracks[0].name, self.helper.track1.selected_item.name, "RLPS Track 1 should be at index 0") 208 | self.assertEqual(ad.nla_tracks[1].name, "VeryFirst", "VeryFirst track should be at index 1") 209 | 210 | def testDeleteRLPSTrack(self) -> None: 211 | # Delete one of the RLPS tracks (track1) 212 | rlps_track_index = self.helper.track1.index 213 | 214 | ui_utils.assert_op_ret(bpy.ops.rhubarb.delete_object_nla_track(object_name=bpy.context.object.name, track_index=rlps_track_index)) 215 | 216 | # Now track2 should still exist with adjusted index 217 | # track1 should be None or invalid 218 | o: bpy.Object = self.helper.track1.object 219 | tracks = [t.name for t in o.animation_data.nla_tracks] 220 | 221 | self.assertIsNone(self.helper.track1.selected_item, f"track1 should be None after deletion \n{tracks}") 222 | # track2 should be at index 2 (original 3 minus 1 for deleted track) 223 | self.helper.verify_rlps_track(self.helper.track2, 2, "After deleting RLPS track1") 224 | 225 | 226 | if __name__ == "__main__": 227 | unittest.main() 228 | -------------------------------------------------------------------------------- /rhubarb_lipsync/blender/dropdown_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from enum import Enum 4 | from typing import Optional, Sequence, Tuple 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | class DropdownHelper: 10 | """Helper for building dropdowns for non-ID items of an collection. Item is referenced 11 | by index and a (search) name. Index is further encoded as number prefix of the name separated by space. 12 | For example: `001 First item`. 13 | """ 14 | 15 | numbered_item_re = re.compile(r"^(?:(?P\d+)\s+)?(?P\S.*\S|\S)$") 16 | NameNotFoundHandling = Enum('NameNotFoundHandling', ['SELECT_ANY', 'UNSELECT']) 17 | ChangeStatus = Enum('ChangeStatus', ['UNCHANGED', 'MOVED_TO', 'REMOVED', 'RENAMED']) 18 | 19 | def __init__(self, dropdown, names: Sequence[str], nameNotFoundHandling=NameNotFoundHandling.SELECT_ANY) -> None: 20 | self.obj = dropdown 21 | self.names = names 22 | self.nameNotFoundHandling = nameNotFoundHandling 23 | # if nameNotFoundHandling == DropdownHelper.NameNotFoundHandling.UNSELECT: 24 | # self.index = -1 25 | # else 26 | # self.ensure_index_bounds() 27 | 28 | @property 29 | def index(self) -> int: 30 | """Currently selected index.""" 31 | return getattr(self.obj, 'index', -1) 32 | 33 | @index.setter 34 | def index(self, index: int) -> None: 35 | if self.index != index: 36 | setattr(self.obj, 'index', index) 37 | self.index2name() 38 | 39 | @property 40 | def name(self) -> str: 41 | """Currently selected full name (e.g., '001 Item Name').""" 42 | return getattr(self.obj, 'name', "") 43 | 44 | @name.setter 45 | def name(self, n: str) -> None: 46 | if self.name != n: 47 | setattr(self.obj, 'name', n) 48 | self.name2index() 49 | 50 | @property 51 | def last_length(self) -> int: 52 | """Last known length of the items collection.""" 53 | return getattr(self.obj, 'last_length', -1) 54 | 55 | @last_length.setter 56 | def last_length(self, length: int) -> None: 57 | if self.last_length_supported: 58 | setattr(self.obj, 'last_length', length) 59 | 60 | @property 61 | def last_length_supported(self) -> bool: 62 | """Check if the underlying object supports storing the last length.""" 63 | return hasattr(self.obj, 'last_length') 64 | 65 | @staticmethod 66 | def parse_name(numbered_item: str) -> tuple[int, str]: 67 | """Parses a numbered item and returns a tuple containing the index and item name part. 68 | For example '001 The item' => (1, ' The item') 69 | If the item doesn't match the pattern, returns (-1, "") 70 | """ 71 | if not numbered_item: 72 | return (-1, "") 73 | m = DropdownHelper.numbered_item_re.search(numbered_item) 74 | if m is None: 75 | return (-1, "") 76 | 77 | groups = m.groupdict() 78 | idx_str = groups.get("idx") # Use .get for safety 79 | item_name = groups.get("item_name", "") # Default to empty string 80 | 81 | if idx_str is None: 82 | idx = -1 83 | else: 84 | try: 85 | idx = int(idx_str) 86 | except ValueError: 87 | idx = -1 # Handle potential non-integer index string 88 | 89 | return (idx, item_name) 90 | 91 | @property 92 | def index_from_name(self) -> int: 93 | """Gets the index encoded in the item name prefix. Returns -1 if not found/invalid.""" 94 | return DropdownHelper.parse_name(self.name)[0] 95 | 96 | @property 97 | def item_name_from_name(self) -> str: 98 | """Gets the underlying item name (without the encoded index prefix).""" 99 | return DropdownHelper.parse_name(self.name)[1] 100 | 101 | def item_name_match(self, index: int, item_name_to_match: str) -> bool: 102 | """Check if the item at the given index in the current list has a matching item name part.""" 103 | if index == -1: # Unselected, empty name expected 104 | return bool(item_name_to_match == "") 105 | if index >= len(self.names): 106 | return False 107 | _, current_item_name = DropdownHelper.parse_name(self.names[index]) 108 | return current_item_name == item_name_to_match 109 | 110 | def detect_item_changes(self) -> Tuple[ChangeStatus, int]: 111 | """Detects changes in the items collection relative to the current selection (`self.index`, `self.name`). 112 | 113 | Returns: Tuple (ChangeStatus, New/Current index) 114 | """ 115 | 116 | current_length = len(self.names) 117 | previous_length = self.last_length 118 | if self.last_length_supported: 119 | self.last_length = current_length 120 | index = self.index 121 | item_name = self.item_name_from_name 122 | if current_length == 0: # No items 123 | if self.index < 0: # Nothing was selected, so no change 124 | return (DropdownHelper.ChangeStatus.UNCHANGED, self.index) 125 | else: # Something was selected so it means it was removed 126 | return (DropdownHelper.ChangeStatus.REMOVED, -1) 127 | if self.index < 0: # No selection 128 | if self.nameNotFoundHandling == DropdownHelper.NameNotFoundHandling.UNSELECT: 129 | return (DropdownHelper.ChangeStatus.UNCHANGED, self.index) # Simple keep no-selection 130 | # Select any strategy. There is at least one item, so try to select it by faking "move_to" 131 | return (DropdownHelper.ChangeStatus.MOVED_TO, self.index_within_bounds()) 132 | 133 | # Is item still at the same index with the same name? 134 | if self.item_name_match(index, item_name): 135 | return (DropdownHelper.ChangeStatus.UNCHANGED, index) 136 | 137 | # Try adjacent indices first (handle single insertion/deletion, optimization) 138 | if self.item_name_match(index + 1, item_name): 139 | return (DropdownHelper.ChangeStatus.MOVED_TO, index + 1) 140 | 141 | if self.item_name_match(index - 1, item_name): 142 | return (DropdownHelper.ChangeStatus.MOVED_TO, index - 1) 143 | 144 | # Scan all items for matching item name 145 | for i, name in enumerate(self.names): 146 | _, current_item_name = DropdownHelper.parse_name(name) 147 | if current_item_name == item_name: 148 | return (DropdownHelper.ChangeStatus.MOVED_TO, i) 149 | 150 | # No item with matching name not found. Could be REMOVED or RENAMED. Use last_length to deduce. 151 | if self.last_length_supported and previous_length == current_length and 0 <= index < current_length: 152 | # Length is the same, index is still valid, and name didn't match anywhere. Assume item at `index` was renamed in place. 153 | return (DropdownHelper.ChangeStatus.RENAMED, index) 154 | if self.nameNotFoundHandling == DropdownHelper.NameNotFoundHandling.SELECT_ANY: 155 | index = self.index_within_bounds() 156 | return (DropdownHelper.ChangeStatus.MOVED_TO, index) 157 | else: 158 | return (DropdownHelper.ChangeStatus.REMOVED, self.index_within_bounds(-1)) 159 | 160 | def sync_from_items(self, change: Optional[Tuple[ChangeStatus, int]] = None) -> None: 161 | """Sync index based on item name, trying to maintain position or adjust minimally. Used when items changes (add/delete..)""" 162 | if change: 163 | status, new_index = change 164 | else: # Only detect if change is not explicitly provided already 165 | status, new_index = self.detect_item_changes() 166 | 167 | log.trace(f"Dropdown change detected: {status}@{new_index} len={self.last_length} on {self.obj}") 168 | 169 | if status == DropdownHelper.ChangeStatus.UNCHANGED: 170 | return 171 | if status == DropdownHelper.ChangeStatus.MOVED_TO: 172 | self.index = new_index 173 | return 174 | if status == DropdownHelper.ChangeStatus.RENAMED: 175 | self.index2name() # Update the name with new one from the same current index 176 | return 177 | # Removed 178 | if self.nameNotFoundHandling == DropdownHelper.NameNotFoundHandling.SELECT_ANY: 179 | new_index = self.index_within_bounds(max(0, self.index - 1)) # Try to select a nearby item if available 180 | self.index = new_index 181 | else: # Unselect 182 | self.index = -1 183 | 184 | def index_within_bounds(self, index=None) -> int: 185 | """Returns index bounded to the names length without changing the `index` attr. 186 | Returns -1 when the list is empty""" 187 | l = len(self.names) 188 | if l == 0: 189 | return -1 # Empty list 190 | if index is None: 191 | index = self.index 192 | if index >= l: # After the last 193 | if self.nameNotFoundHandling == DropdownHelper.NameNotFoundHandling.SELECT_ANY: 194 | index = l - 1 # Select last 195 | else: 196 | index = -1 # Unselect 197 | 198 | if index < 0: # Befor the first (unselected) 199 | if self.nameNotFoundHandling == DropdownHelper.NameNotFoundHandling.SELECT_ANY: 200 | index = 0 # Select first 201 | else: 202 | index = -1 # Keep unselected, make sure not <-1 203 | return index 204 | 205 | def ensure_index_bounds(self) -> None: 206 | """Changes the `index` attr to be within the `items` bounds.""" 207 | new = self.index_within_bounds() 208 | if self.index != new: 209 | self.index = new 210 | self.index2name() 211 | if self.index < 0: 212 | if self.name: 213 | # index_from_name = DropdownHelper.index_from_name(self.name) 214 | self.name = "" 215 | return 216 | 217 | def name2index(self) -> None: 218 | """Changes the index property based on the name property. Takes index from the name prefix. 219 | This is used when a new search-name is selected from dropdown 220 | """ 221 | index = self.index_from_name 222 | index = self.index_within_bounds(index) 223 | self.index = index 224 | # self.index2name() # Sync name too 225 | 226 | def index2name(self) -> None: 227 | self.ensure_index_bounds() 228 | if self.index >= 0: 229 | self.name = self.names[self.index] 230 | else: 231 | self.name = "" 232 | 233 | def select_last(self) -> None: 234 | l = len(self.names) 235 | self.index = l - 1 236 | --------------------------------------------------------------------------------