-
Notifications
You must be signed in to change notification settings - Fork 12
Add animation.cfg/NLA import-export (from PR #69), pep8 CI, and NLA test coverage #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 21 commits
c3de075
7d44a67
fdc2c30
43e1526
fd017f7
b487683
689ed66
646aaa6
c456d44
f0bba81
9801d0e
9e1b0e8
44a0763
e4923c6
b824736
2b5e132
e4fad28
d330fab
0f00cab
ba71449
6c412a4
d9793eb
a1e58bd
ab6234e
b32817a
75c2a2b
eb25989
b095e1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| --- | ||
| name: blender-run-script | ||
| description: Run an arbitrary one-off Python script headlessly inside a pinned Blender version via podman. Use for anything the fixed test suite (blender-tests) doesn't cover -- generating/regenerating a .blend fixture, inspecting .gla/.glm data that needs bpy/mathutils, or any other ad-hoc headless Blender task. | ||
| --- | ||
|
|
||
| # Run an arbitrary script in headless Blender | ||
|
|
||
| A generic counterpart to `.claude/skills/blender-tests`, which only runs the fixed | ||
| `tests/run_tests.py` suite. This one takes any script path instead, for the recurring need to run | ||
| one-off headless Blender work (e.g. generating a `.blend` test fixture) without having to craft a | ||
| fresh `podman run ... -v "$(pwd)":/repo:Z ...` invocation each time -- the `$(pwd)`-based mount is | ||
| what makes the ad-hoc version unsafe to whitelist as a fixed pattern. | ||
|
|
||
| ``` | ||
| .claude/skills/blender-run-script/run_blender_script.sh <version> <script-path-relative-to-repo> [-- <args> ...] | ||
| # e.g. | ||
| .claude/skills/blender-run-script/run_blender_script.sh 4.1 tests/tools/generate_simpleskel_nla_blend.py | ||
| ``` | ||
|
|
||
| Pulls `docker.io/blenderkit/headless-blender:blender-<version>-stable` if not already present, | ||
| mounts the repo read-write at `/repo` inside the container, and runs | ||
| `blender --background --python-exit-code 1 --python /repo/<script-path> -- <args>`. Doesn't rely on | ||
| the caller's `$(pwd)` or take a repo path -- it derives the repo root from its own on-disk | ||
| location, so call it directly from any cwd. | ||
|
|
||
| Pin the version deliberately: a `.blend` saved by a newer Blender can't be opened by an older one | ||
| (see the add-on's minimum supported version in `bl_info["blender"]`, `__init__.py`), so fixtures | ||
| meant to stay openable on the oldest supported version must be generated with that version, not | ||
| whatever's newest. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| #!/usr/bin/env bash | ||
| # Runs an arbitrary script headlessly in one version of Blender via podman. | ||
| # Usage: run_blender_script.sh <version> <script-path-relative-to-repo> [-- <args> ...] | ||
| # e.g. run_blender_script.sh 4.1 tests/tools/generate_simpleskel_nla_blend.py | ||
| # | ||
| # Repo root is derived from this script's own location, not the caller's cwd, so the | ||
| # invocation never needs a $(pwd)-style substitution at the call site -- that's what made | ||
| # the equivalent ad-hoc commands unsafe to whitelist as a fixed pattern. | ||
| set -euo pipefail | ||
|
|
||
| if [ "$#" -lt 2 ]; then | ||
| echo "Usage: $(basename "$0") <version> <script-path-relative-to-repo> [-- <args> ...]" >&2 | ||
| echo " e.g. $(basename "$0") 4.1 tests/tools/generate_simpleskel_nla_blend.py" >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| VERSION="$1" | ||
| SCRIPT_PATH="$2" | ||
| shift 2 | ||
|
|
||
| # an optional leading "--" separating our own args from the target script's is conventional | ||
| # but not required -- drop it if present so both forms work. | ||
| if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then | ||
| shift | ||
| fi | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" | ||
|
|
||
| podman run --rm \ | ||
| --entrypoint /home/headless/blender/blender \ | ||
| -v "$REPO_ROOT:/repo:Z" \ | ||
| "docker.io/blenderkit/headless-blender:blender-${VERSION}-stable" \ | ||
| --background --python-exit-code 1 --python "/repo/$SCRIPT_PATH" -- "$@" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| [pycodestyle] | ||
| # disable wrapping of long lines, our `# pyright: ignore` line comments can get long | ||
| ignore = E501 | ||
| # specifying `ignore` replaces pycodestyle's own default ignore list rather than extending it, so | ||
| # restate pycodestyle's defaults (E121,E123,E126,E226,E24,E704,W503,W504) here alongside E501 -- | ||
| # otherwise those checks turn on and flag pre-existing, stylistically-fine code. | ||
| # E501: our `# pyright: ignore` line comments can get long. | ||
| ignore = E121,E123,E126,E226,E24,E704,W503,W504,E501 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| # ##### BEGIN GPL LICENSE BLOCK ##### | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU General Public License | ||
| # as published by the Free Software Foundation; either version 2 | ||
| # of the License, or (at your option) any later version. | ||
| # | ||
| # This program is distributed in the hope that it will be useful, | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| # GNU General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU General Public License | ||
| # along with this program; if not, write to the Free Software Foundation, | ||
| # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| # ##### END GPL LICENSE BLOCK ##### | ||
|
|
||
| from .mod_reload import reload_modules | ||
| reload_modules(locals(), __package__, ["JAFilesystem"], [".casts", ".error_types"]) # nopep8 | ||
|
|
||
| import bpy | ||
| from . import JAFilesystem | ||
| from .error_types import ErrorMessage | ||
| from typing import List, Tuple | ||
|
|
||
|
|
||
| class AnimationSequence(): | ||
| def __init__(self): | ||
| self.name = "" | ||
| self.start_frame = -1 | ||
| self.num_frames = -1 | ||
| self.loop = False | ||
| self.fps = -1 | ||
|
|
||
| def __str__(self): | ||
| return "{name}\t\t{start}\t{frames}\t{loop}\t{fps}".format( | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. let's use an f-string |
||
| name=self.name, | ||
| start=self.start_frame, | ||
| frames=self.num_frames, | ||
| loop=0 if self.loop else -1, | ||
| fps=self.fps | ||
| ) | ||
|
|
||
| @classmethod | ||
| def from_cfg_line(cls, txt_line): | ||
| try: | ||
| # remove comments inline first, someone might have annotated these | ||
| line = txt_line.split("//")[0] | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this parser is over simplified, a mid-token // does not initiate a comment, and there are also multi-line comments. Tokens may also be quoted. Numbers can have trailing garbage. I'm not sure if an entry must even stay on the same line? Build a generator-based token parser and use that instead. Use https://github.com/mrwonko/ghoul2-browser-tools/blob/main/src/commonTokenizer.ts for reference, but note that we don't need to retain whitespace and comments here, we can just yield a string for each token. Unit-test the generic token parser using our fixture. You can also look at mrwonko/ghoul2-browser-tools#28 for animation.cfg parsing reference, but it has not been reviewed yet, so don't trust it completely. The canonical source is Do test-driven development: first add a unit test to persist the current parse result for our fixture, plus maybe an additional case for partial final line. Make sure it passes. Then adjust the parser in a separate commit and verify it does not regress. |
||
| name, sf, nf, l, fps = line.split() | ||
| new_frame = cls() | ||
| new_frame.name = name | ||
| new_frame.start_frame = int(sf) | ||
| new_frame.num_frames = int(nf) | ||
| new_frame.loop = int(l) != -1 | ||
| new_frame.fps = int(fps) | ||
| return new_frame | ||
| except Exception: | ||
| return None | ||
|
|
||
| @classmethod | ||
| def from_blender_markers(cls, marker1: bpy.types.TimelineMarker, marker2: bpy.types.TimelineMarker, fps: int, offset: int = 0): | ||
| new_frame = cls() | ||
| new_frame.name = marker1.name | ||
| new_frame.start_frame = int(marker1.frame + offset) | ||
| new_frame.num_frames = int(marker2.frame - marker1.frame) | ||
| new_frame.loop = False | ||
| new_frame.fps = int(fps) | ||
| return new_frame | ||
|
|
||
| @classmethod | ||
| def from_blender_strip(cls, nla_strip: bpy.types.NlaStrip, length_difference: int, fps: int, offset: int = 0): | ||
| assert nla_strip.action is not None | ||
| new_frame = cls() | ||
| new_frame.name = nla_strip.action.name | ||
| new_frame.start_frame = int(nla_strip.frame_start + offset) | ||
| new_frame.num_frames = int(nla_strip.frame_end - nla_strip.frame_start + length_difference) | ||
| new_frame.loop = bool(nla_strip.action.g2_sequence_prop.loop_frame) # pyright: ignore[reportAttributeAccessIssue] | ||
| new_frame.fps = int(nla_strip.action.g2_sequence_prop.fps) # pyright: ignore[reportAttributeAccessIssue] | ||
| return new_frame | ||
|
|
||
|
|
||
| class AnimationCFG(): | ||
|
|
||
| def __init__(self): | ||
| self.sequences: List[AnimationSequence] = [] | ||
|
|
||
| def __str__(self): | ||
| lines = [str(seq) for seq in self.sequences] | ||
| return "\n".join(lines) | ||
|
|
||
| def load_from_cfg(self, cfg_file_path: str) -> Tuple[bool, ErrorMessage]: | ||
| success, cfg_abs = JAFilesystem.FindFile(cfg_file_path + "/animation", "", ["cfg"]) | ||
| if not success: | ||
| print("Could not find file: ", cfg_abs, sep="") | ||
| return False, ErrorMessage("Could not find the animation.cfg next to the .gla file") | ||
|
|
||
| try: | ||
| file = open(cfg_abs, mode="r") | ||
| except IOError: | ||
| print("Could not open file: ", cfg_abs, sep="") | ||
| return False, ErrorMessage("Could not open skin!") | ||
| for line in file: | ||
| if line.startswith("//") or line.strip() == "": | ||
| continue | ||
| sequence = AnimationSequence().from_cfg_line(line) | ||
| if sequence: | ||
| self.sequences.append(sequence) | ||
| else: | ||
| print("Could not parse following line in animations.cfg", line) | ||
| self.sequences.sort(key=lambda sequence: sequence.start_frame) | ||
| return True, ErrorMessage("Nothing") | ||
|
|
||
| def from_blender_markers(self, scene: bpy.types.Scene, offset: int): | ||
| start_frame = scene.frame_start | ||
| offset -= start_frame | ||
| end_frame = scene.frame_end | ||
| base_fps = scene.render.fps | ||
|
|
||
| blender_markers = [ | ||
| marker for marker in scene.timeline_markers if ( | ||
| marker.frame >= start_frame and marker.frame <= end_frame + 1) | ||
| ] | ||
| blender_markers.sort(key=lambda marker: marker.frame) | ||
|
|
||
| if (len(blender_markers) == 0 or | ||
| (len(blender_markers) == 1 and blender_markers[0].frame == end_frame + 1)): | ||
| return False, ErrorMessage("No timeline markers found! Add Markers to label animations.") | ||
|
|
||
| if blender_markers[len(blender_markers) - 1].frame != end_frame + 1: | ||
| blender_markers.append( | ||
| scene.timeline_markers.new("LAST_EXPORT_FRAME", frame=end_frame + 1)) | ||
|
|
||
| for marker1, marker2 in zip(blender_markers[:-1], blender_markers[1:]): | ||
| self.sequences.append(AnimationSequence().from_blender_markers( | ||
| marker1, | ||
| marker2, | ||
| base_fps, | ||
| offset | ||
| )) | ||
|
|
||
| last_frame = scene.timeline_markers.get("LAST_EXPORT_FRAME") | ||
| if last_frame: | ||
| scene.timeline_markers.remove(last_frame) | ||
|
|
||
| return True, ErrorMessage("Nothing") | ||
|
|
||
| def from_blender_nla_tracks(self, scene: bpy.types.Scene, offset: int): | ||
| start_frame = scene.frame_start | ||
| offset -= start_frame | ||
| end_frame = scene.frame_end | ||
| base_fps = scene.render.fps | ||
|
|
||
| skeleton_object = bpy.data.objects.get("skeleton_root") | ||
| if skeleton_object is None: | ||
| return False, ErrorMessage("Could not find skeleton object: skeleton_root") | ||
| if skeleton_object.animation_data is None: | ||
| return False, ErrorMessage('Skeleton object (skeleton_root) does not have animation data') | ||
| if len(skeleton_object.animation_data.nla_tracks) == 0: | ||
| return False, ErrorMessage("Couldn't find NLA tracks for the Skeleton object: skeleton_root") | ||
|
|
||
| blender_strips: List[Tuple[bpy.types.NlaStrip, int]] = [] | ||
| for nla_track in [track for track in skeleton_object.animation_data.nla_tracks]: | ||
| # TODO test if this works when not using English localisation | ||
| length_difference = 0 if nla_track.name.startswith("Stills Layer") else 1 | ||
| for nla_strip in [strip for strip in nla_track.strips if strip.frame_start >= start_frame and strip.frame_start <= end_frame]: | ||
| blender_strips.append((nla_strip, length_difference)) | ||
| blender_strips.sort(key=lambda strip: strip[0].frame_start) | ||
|
|
||
| if len(blender_strips) == 0: | ||
| return False, ErrorMessage("No NLA strips found! Add animation strips to label animations.") | ||
|
|
||
| for strip, length_difference in blender_strips: | ||
| self.sequences.append(AnimationSequence().from_blender_strip( | ||
| strip, | ||
| length_difference, | ||
| base_fps, | ||
| offset | ||
| )) | ||
|
|
||
| return True, ErrorMessage("Nothing") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this also extends to issues