#!/usr/bin/env python3 import hashlib import json import os import pathlib import shutil import sys import tempfile import bpy def state_report(scene): return { "current": int(scene.frame_current), "start": int(scene.frame_start), "end": int(scene.frame_end), } def write_report(fixture, output, state, *, evidence_status=None): report = { "schemaVersion": 1, "task": "M16-GAP-00177", "operation": "ANIM_END_FRAME_SET_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "frame": state, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "FRAME_END_SET", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string, } if evidence_status: report["evidenceStatus"] = evidence_status output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") def foreground_end_frame_set(fixture, output): state = {"started": False} def finish_failure(message): print(f"anim-end-frame-set-desktop-failed: {message}") bpy.ops.wm.quit_blender() return None def execute(): window = bpy.context.window if window is None: return 0.25 scene = bpy.context.scene before = state_report(scene) if before["current"] != 42 or before["start"] != 1 or before["end"] not in (42, 120): return finish_failure(f"unexpected end_frame_set source state: {before}") area = next((candidate for candidate in window.screen.areas if candidate.type == "TIMELINE"), None) if area is None: area = next((candidate for candidate in window.screen.areas if candidate.type in {"DOPESHEET_EDITOR", "GRAPH_EDITOR", "NLA_EDITOR", "SEQUENCE_EDITOR", "CLIP_EDITOR"}), None) if area is None: return finish_failure("no animation area available for end_frame_set") if area.type == "TIMELINE": area.type = "DOPESHEET_EDITOR" region = next(candidate for candidate in area.regions if candidate.type == "WINDOW") try: with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region): poll = bool(bpy.ops.anim.end_frame_set.poll()) result = bpy.ops.anim.end_frame_set() except Exception as error: return finish_failure(error) if not poll or result != {"FINISHED"}: return finish_failure(f"unexpected ANIM_OT_end_frame_set result: poll={poll} result={result}") after_operator = state_report(scene) if after_operator != {"current": 42, "start": 1, "end": 42}: return finish_failure(f"end_frame_set produced unexpected state: {after_operator}") descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-end-frame-set-reopen-", suffix=".blend") os.close(descriptor) temporary_path = pathlib.Path(temporary) try: bpy.ops.wm.save_as_mainfile(filepath=str(temporary_path), check_existing=False, compress=True) bpy.ops.wm.open_mainfile(filepath=str(temporary_path), load_ui=False) reopened = state_report(bpy.context.scene) if reopened != after_operator: return finish_failure("anim.end_frame_set save/reopen drift") shutil.copyfile(temporary_path, fixture) write_report(fixture, output, reopened) print("anim-end-frame-set-desktop-ok poll=true status=FINISHED mainMutation=frame_end_set saveReopen=exact") bpy.ops.wm.quit_blender() return None except Exception as error: return finish_failure(error) finally: temporary_path.unlink(missing_ok=True) def start(): if state["started"]: return 0.25 state["started"] = True bpy.app.timers.register(execute, first_interval=1.0) return None bpy.app.timers.register(start, first_interval=1.5) def main(): arguments = sys.argv[sys.argv.index("--") + 1 :] if len(arguments) != 2: raise SystemExit("usage: blender -b --python check-action-end-frame-set-desktop.py -- FIXTURE REPORT") fixture, output = (pathlib.Path(value).resolve() for value in arguments) output.unlink(missing_ok=True) bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False) if bpy.app.background: raise RuntimeError("end_frame_set requires a foreground animation area") foreground_end_frame_set(fixture, output) if __name__ == "__main__": try: main() except Exception as error: print(f"anim-end-frame-set-desktop-failed: {error}") raise SystemExit(1)