From ee7be71a48c015b833b61cc2ee940a0e3a45123f Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 19 Jun 2026 00:05:53 -0400 Subject: [PATCH 1/2] ci: add Blender smoke-test workflow running examples in real Blender py_compile cannot catch API-level regressions (EEVEE-id inversion, slotted-actions boundary, driver TypeError, dead SDF link) -- all passed py_compile and only surfaced by running in real Blender. Adds .github/workflows/blender-smoke.yml (workflow_dispatch + weekly schedule + pull_request on main) matrixed over the current stable (5.1.x) and active LTS (4.5.x), resolving the latest point release from download.blender.org and failing loudly if a build can't be fetched. A self-contained driver (tests/smoke/run_smoke.py) executes the headline examples and asserts CONTENT (driver evaluates; SDF GridToMesh yields >0 verts; EEVEE id assigns + non-black render; slotted branch per version; save_pre arg is a filepath; foreach roundtrip; SUBSURF eval>base) and exits non-zero naming the first failing example -- including on any unhandled exception, since Blender otherwise exits 0 on a traceback. Both headless templates run as subprocesses (glTF exit 0 + valid .glb; no-mesh exit 2; render exit 0). Verified locally on 4.5.10 LTS and 5.1.1 green, and proven to fail (exit 1) when F1's id_type fix is reverted. No shipped content changed; counts unchanged (12/6/2/17). Signed-off-by: fOuttaMyPaint --- .github/workflows/blender-smoke.yml | 109 ++++++++++++++++ tests/smoke/make_input.py | 25 ++++ tests/smoke/run_smoke.py | 185 ++++++++++++++++++++++++++++ tests/smoke/tmpl_gltf.py | 112 +++++++++++++++++ tests/smoke/tmpl_render.py | 43 +++++++ 5 files changed, 474 insertions(+) create mode 100644 .github/workflows/blender-smoke.yml create mode 100644 tests/smoke/make_input.py create mode 100644 tests/smoke/run_smoke.py create mode 100644 tests/smoke/tmpl_gltf.py create mode 100644 tests/smoke/tmpl_render.py diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml new file mode 100644 index 0000000..ec8b650 --- /dev/null +++ b/.github/workflows/blender-smoke.yml @@ -0,0 +1,109 @@ +name: Blender Smoke Test + +# Executes the snippets' and skills' headline examples inside REAL Blender, headless, +# on the current stable (5.1.x) and the active LTS (4.5.x), and fails on any error or +# empty-output assertion. py_compile (in validate.yml) cannot catch API-level regressions +# like the EEVEE-id inversion, the slotted-actions boundary, the driver TypeError, or the +# dead SDF link -- this gate runs the code so those surface in CI, not in users' files. + +on: + workflow_dispatch: {} + schedule: + - cron: "0 7 * * 1" # weekly, Monday 07:00 UTC + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: blender-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Blender ${{ matrix.series }} smoke + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - series: "5.1" # current stable + - series: "4.5" # active LTS + steps: + - uses: actions/checkout@v6 + + - name: Resolve and download Blender ${{ matrix.series }} + run: | + set -euo pipefail + series="${{ matrix.series }}" + base="https://download.blender.org/release/Blender${series}/" + echo "Listing $base" + # pick the highest point release for this series (linux x64 portable) + file=$(curl -fsSL "$base" \ + | grep -oE "blender-${series}\.[0-9]+-linux-x64\.tar\.xz" \ + | sort -V | uniq | tail -1) + if [ -z "$file" ]; then + echo "::error::Could not resolve a linux-x64 build for Blender ${series} at $base" + exit 1 + fi + url="${base}${file}" + echo "Downloading $url" + mkdir -p "$RUNNER_TEMP/bl" + curl -fSL --retry 3 -o "$RUNNER_TEMP/bl.tar.xz" "$url" + tar -xf "$RUNNER_TEMP/bl.tar.xz" -C "$RUNNER_TEMP/bl" + bl=$(find "$RUNNER_TEMP/bl" -maxdepth 2 -type f -name blender | head -1) + if [ -z "$bl" ]; then + echo "::error::blender binary not found after extraction" + exit 1 + fi + echo "BLENDER=$bl" >> "$GITHUB_ENV" + + - name: Print Blender version + run: | + set -euo pipefail + "$BLENDER" --version | head -1 + # series guard: confirm we actually got the matrix series + "$BLENDER" --version | head -1 | grep -q "Blender ${{ matrix.series }}\." \ + || { echo "::error::version does not match series ${{ matrix.series }}"; exit 1; } + + - name: Run in-Blender smoke driver + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/out" + "$BLENDER" --background --python tests/smoke/run_smoke.py -- "$RUNNER_TEMP/out" + + - name: Build template input scene + run: | + set -euo pipefail + "$BLENDER" --background --python tests/smoke/make_input.py -- "$RUNNER_TEMP/out/input.blend" + + - name: Headless glTF template runs (exit 0, .glb produced) + run: | + set -euo pipefail + "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ + --python tests/smoke/tmpl_gltf.py -- \ + --output "$RUNNER_TEMP/out/out.glb" --apply-modifier SUBSURF + test -s "$RUNNER_TEMP/out/out.glb" || { echo "::error::glTF output missing/empty"; exit 1; } + head -c4 "$RUNNER_TEMP/out/out.glb" | grep -q "glTF" || { echo "::error::not a glTF binary"; exit 1; } + + - name: Headless template no-mesh path returns exit 2 + run: | + set +e + "$BLENDER" --background "$RUNNER_TEMP/out/empty.blend" \ + --python tests/smoke/tmpl_gltf.py -- --output "$RUNNER_TEMP/out/none.glb" + code=$? + set -e + [ "$code" -eq 2 ] || { echo "::error::expected exit 2 for no-mesh input, got $code"; exit 1; } + echo "no-mesh exit code = $code (correct)" + + - name: Headless render template runs (exit 0, PNG produced) + run: | + set -euo pipefail + # Cycles (CPU) so this is reliable on GPU-less runners; the EEVEE-id regression + # itself is gated in run_smoke.py via engine assignment. + "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ + --python tests/smoke/tmpl_render.py -- \ + --output "$RUNNER_TEMP/out/render.png" --engine CYCLES + test -s "$RUNNER_TEMP/out/render.png" || { echo "::error::render PNG missing/empty"; exit 1; } diff --git a/tests/smoke/make_input.py b/tests/smoke/make_input.py new file mode 100644 index 0000000..1db1560 --- /dev/null +++ b/tests/smoke/make_input.py @@ -0,0 +1,25 @@ +import bpy, bmesh, sys +out = sys.argv[sys.argv.index("--")+1:][0] +bpy.ops.wm.read_factory_settings(use_empty=True) +for name, loc in [("Cube",(0,0,0)), ("Sphere",(3,0,0))]: + me = bpy.data.meshes.new(name); bm = bmesh.new() + if name == "Cube": bmesh.ops.create_cube(bm, size=2.0) + else: bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=8, radius=1.0) + bm.to_mesh(me); bm.free() + o = bpy.data.objects.new(name, me); o.location = loc + bpy.context.collection.objects.link(o) +# camera + sun + emissive world so a render is non-black +cam_d = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", cam_d) +bpy.context.collection.objects.link(cam); cam.location=(6,-6,6); cam.rotation_euler=(1.1,0,0.78) +bpy.context.scene.camera = cam +sd = bpy.data.lights.new("sun",'SUN'); s=bpy.data.objects.new("sun",sd); s.location=(0,0,8); bpy.context.collection.objects.link(s) +bpy.context.scene.world = bpy.data.worlds.new("W") +bpy.context.scene.world.use_nodes = True +bpy.context.scene.world.node_tree.nodes["Background"].inputs[0].default_value = (0.3,0.4,0.6,1.0) +bpy.ops.wm.save_as_mainfile(filepath=out) +print(f"saved input {out} with 2 meshes") +# also an empty blend for the exit-code-2 path +empty = out.replace("input.blend","empty.blend") +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.wm.save_as_mainfile(filepath=empty) +print(f"saved empty {empty}") diff --git a/tests/smoke/run_smoke.py b/tests/smoke/run_smoke.py new file mode 100644 index 0000000..697c10b --- /dev/null +++ b/tests/smoke/run_smoke.py @@ -0,0 +1,185 @@ +"""In-Blender smoke test for the BDT example patterns. + +Run: blender --background --python tests/smoke/run_smoke.py -- + +Executes the snippets' / skills' headline examples and asserts CONTENT, not just +"no exception". Exits non-zero on the FIRST failed assertion, naming the example. +Self-contained: example code is copied here, NOT imported from skills/ or snippets/, +so the test catches drift in the shipped content rather than masking it. +""" +import bpy, sys, os, tempfile + +ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] +OUT = ARGS[0] if ARGS else tempfile.mkdtemp() +os.makedirs(OUT, exist_ok=True) +V = bpy.app.version +print(f"=== smoke on Blender {V[0]}.{V[1]}.{V[2]} -> {OUT} ===") + +def require(example, cond, evidence): + """Pass-or-die assertion. On failure print which example failed and exit 1.""" + status = "ok" if cond else "FAIL" + print(f"[{status}] {example}: {evidence}") + if not cond: + print(f"SMOKE FAILED at example '{example}' on Blender {V[0]}.{V[1]}.{V[2]}") + sys.exit(1) + +def reset(): + bpy.ops.wm.read_factory_settings(use_empty=True) + +# ---- helpers copied from fixed content ---- +def get_eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + +def get_channelbag_for_slot(action, slot): + if bpy.app.version >= (5, 0, 0): + from bpy_extras.anim_utils import action_ensure_channelbag_for_slot + return action_ensure_channelbag_for_slot(action, slot) + layer = action.layers[0] if action.layers else action.layers.new("Layer") + strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME') + return strip.channelbag(slot, ensure=True) + +# ---------- mesh: foreach roundtrip + SUBSURF eval > base ---------- +def smoke_mesh(): + import bmesh + reset() + me = bpy.data.meshes.new("m"); me.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)], [], [(0,1,2,3)]); me.update() + obj = bpy.data.objects.new("M", me); bpy.context.collection.objects.link(obj) + base = len(me.vertices) + n = len(me.vertices); src = [float(i) for i in range(n*3)] + me.vertices.foreach_set("co", src); dst = [0.0]*(n*3); me.vertices.foreach_get("co", dst) + require("mesh-foreach-roundtrip", all(abs(a-b) < 1e-5 for a,b in zip(src,dst)), "foreach_set/get arrays equal") + obj.modifiers.new("ss", 'SUBSURF').levels = 2 + dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg); m = ev.to_mesh() + evc = len(m.vertices); ev.to_mesh_clear() + require("mesh-subsurf-eval", evc > base, f"eval vcount {evc} > base {base}") + +# ---------- F1: driver_namespace example (skill, fixed) ---------- +def smoke_driver(): + reset() + me = bpy.data.meshes.new("d"); me.from_pydata([(0,0,0)],[],[]) + obj = bpy.data.objects.new("D", me); bpy.context.collection.objects.link(obj) + def smooth_step(t): + t = max(0.0, min(1.0, t)); return t*t*(3.0-2.0*t) + bpy.app.driver_namespace['smooth_step'] = smooth_step + fcurve = obj.driver_add("location", 2); fcurve.driver.type = 'SCRIPTED' + var = fcurve.driver.variables.new(); var.name = 't'; var.type = 'SINGLE_PROP' + var.targets[0].id_type = 'SCENE' # the F1 fix under test + var.targets[0].id = bpy.context.scene + var.targets[0].data_path = 'frame_current' + fcurve.driver.expression = 'smooth_step((t - 1.0) / 100.0) * 5.0' + vals = [] + for f in (1, 50, 100): + bpy.context.scene.frame_set(f); dg = bpy.context.evaluated_depsgraph_get() + vals.append(round(obj.evaluated_get(dg).location.z, 4)) + require("F1-driver-namespace", vals[0] < vals[1] < vals[2], f"location.z across frames = {vals} (strictly increasing)") + +# ---------- F2: SDF remesh via GridToMesh (skill, fixed) ---------- +def smoke_sdf(): + import bmesh + reset() + me = bpy.data.meshes.new("c"); bm = bmesh.new(); bmesh.ops.create_cube(bm, size=2.0); bm.to_mesh(me); bm.free() + obj = bpy.data.objects.new("C", me); bpy.context.collection.objects.link(obj) + tree = bpy.data.node_groups.new("SDFRemesh", 'GeometryNodeTree') + tree.interface.new_socket(name="Geometry", in_out='INPUT', socket_type='NodeSocketGeometry') + tree.interface.new_socket(name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') + gi = tree.nodes.new('NodeGroupInput'); go = tree.nodes.new('NodeGroupOutput') + m2s = tree.nodes.new('GeometryNodeMeshToSDFGrid'); g2m = tree.nodes.new('GeometryNodeGridToMesh') + m2s.inputs["Voxel Size"].default_value = 0.1 + g2m.inputs["Threshold"].default_value = 0.0 + tree.links.new(gi.outputs["Geometry"], m2s.inputs["Mesh"]) + link = tree.links.new(m2s.outputs["SDF Grid"], g2m.inputs["Grid"]) # the F2 fix under test + tree.links.new(g2m.outputs["Mesh"], go.inputs["Geometry"]) + require("F2-sdf-link-valid", link.is_valid, "SDF Grid -> Grid link is_valid") + obj.modifiers.new("gn", 'NODES').node_group = tree + dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg); m = ev.to_mesh() + n = len(m.vertices); ev.to_mesh_clear() + require("F2-sdf-remesh", n > 0, f"GridToMesh remesh produced {n} vertices (>0)") + +# ---------- EEVEE: id assignment + non-black render ---------- +def smoke_eevee(): + import bmesh + reset() + me = bpy.data.meshes.new("p"); bm = bmesh.new(); bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=10.0); bm.to_mesh(me); bm.free() + obj = bpy.data.objects.new("P", me); bpy.context.collection.objects.link(obj) + mat = bpy.data.materials.new("M"); mat.use_nodes = True; nt = mat.node_tree; nt.nodes.clear() + emis = nt.nodes.new('ShaderNodeEmission'); emis.inputs['Color'].default_value = (0.9,0.4,0.1,1.0); emis.inputs['Strength'].default_value = 5.0 + out = nt.nodes.new('ShaderNodeOutputMaterial'); nt.links.new(emis.outputs['Emission'], out.inputs['Surface']) + obj.data.materials.append(mat) + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); bpy.context.collection.objects.link(cam) + cam.location = (0,0,10); bpy.context.scene.camera = cam + sc = bpy.context.scene + # the EEVEE-id regression guard: assigning the version-correct id must succeed + eid = get_eevee_engine_id() + try: + sc.render.engine = eid + except Exception as e: + require("eevee-id-assign", False, f"assigning '{eid}' raised {type(e).__name__}: {e}") + require("eevee-id-assign", sc.render.engine == eid, f"engine id '{eid}' valid on this build") + sc.render.resolution_x = 16; sc.render.resolution_y = 16; sc.render.image_settings.file_format = 'PNG' + png = os.path.join(OUT, f"smoke_render_{V[0]}{V[1]}.png"); sc.render.filepath = png + # try EEVEE render; if the headless runner has no GPU and it raises, fall back to Cycles (CPU) + used = sc.render.engine + try: + try: sc.eevee.taa_render_samples = 4 + except Exception: pass + bpy.ops.render.render(write_still=True) + if not os.path.exists(png): raise RuntimeError("no PNG written") + except Exception as e: + print(f" (EEVEE render unavailable headless: {type(e).__name__}; falling back to CYCLES)") + sc.render.engine = 'CYCLES'; used = 'CYCLES' + try: sc.cycles.samples = 4 + except Exception: pass + bpy.ops.render.render(write_still=True) + require("eevee-render-file", os.path.exists(png) and os.path.getsize(png) > 0, f"render PNG written by {used}") + img = bpy.data.images.load(png); px = list(img.pixels); mx = max(px) if px else 0.0 + require("eevee-render-nonblack", mx > 0.05, f"max pixel {round(mx,3)} > 0.05 (engine used: {used})") + +# ---------- slotted actions: correct branch + legacy behaviour ---------- +def smoke_slotted(): + reset() + me = bpy.data.meshes.new("a"); me.from_pydata([(0,0,0)],[],[]) + obj = bpy.data.objects.new("A", me); bpy.context.collection.objects.link(obj) + obj.location = (0,0,0.0); obj.keyframe_insert("location", frame=1) + obj.location = (0,0,5.0); obj.keyframe_insert("location", frame=24) + bpy.context.scene.frame_set(1); z1 = round(obj.location.z,3) + bpy.context.scene.frame_set(24); z24 = round(obj.location.z,3) + require("slotted-keyframe-drives", abs(z24-z1) > 1.0, f"sampled z f1={z1} f24={z24} differ") + o2 = bpy.data.objects.new("A2", bpy.data.meshes.new("a2")); bpy.context.collection.objects.link(o2) + o2.animation_data_create(); act = bpy.data.actions.new("Act"); o2.animation_data.action = act + slot = o2.animation_data.action_slot + if slot is None: + slot = act.slots.new(id_type='OBJECT', name=o2.name); o2.animation_data.action_slot = slot + cbag = get_channelbag_for_slot(act, slot) + require("slotted-channelbag", type(cbag).__name__ == "ActionChannelbag", f"channelbag = {type(cbag).__name__}") + act3 = bpy.data.actions.new("Act3") + try: + act3.fcurves.new("location", index=0); legacy = 'WORKS' + except AttributeError: + legacy = 'AttributeError' + expected = 'AttributeError' if bpy.app.version >= (5,0,0) else 'WORKS' + require("slotted-legacy-branch", legacy == expected, f"legacy action.fcurves = {legacy} (expected {expected} for this version)") + +# ---------- save_pre handler arg is a filepath string ---------- +def smoke_save_pre(): + reset() + captured = {} + def on_save_pre(arg, *a): captured['t'] = type(arg).__name__ + bpy.app.handlers.save_pre.append(on_save_pre) + p = os.path.join(OUT, "smoke_sp.blend"); bpy.ops.wm.save_as_mainfile(filepath=p) + bpy.app.handlers.save_pre.remove(on_save_pre) + require("save_pre-arg-type", captured.get('t') == 'str', f"save_pre arg type = {captured.get('t')} (filepath string, not Scene)") + +# Blender runs --python scripts but exits 0 even on an uncaught exception, so wrap every +# example: a raised exception is a failure just like a failed content assertion. +for fn in (smoke_mesh, smoke_driver, smoke_sdf, smoke_eevee, smoke_slotted, smoke_save_pre): + try: + fn() + except SystemExit: + raise + except Exception as e: + import traceback; traceback.print_exc() + print(f"SMOKE FAILED: unhandled {type(e).__name__} in '{fn.__name__}' on " + f"Blender {V[0]}.{V[1]}.{V[2]}: {e}") + sys.exit(1) + +print(f"=== ALL SMOKE CHECKS PASSED on Blender {V[0]}.{V[1]}.{V[2]} ===") diff --git a/tests/smoke/tmpl_gltf.py b/tests/smoke/tmpl_gltf.py new file mode 100644 index 0000000..55cfd16 --- /dev/null +++ b/tests/smoke/tmpl_gltf.py @@ -0,0 +1,112 @@ +# Headless batch script template. +# +# Run with: +# blender --background --python script.py -- \ +# --output /path/to/out.glb \ +# --apply-modifier SUBSURF +# +# Everything after the `--` token is forwarded to this script as sys.argv. +# Anything before `--` is consumed by Blender itself. +# +# This template demonstrates the safe headless batch pattern: +# - bpy.data.* for direct manipulation (no UI required) +# - bpy.context.temp_override(...) only when an operator is genuinely needed +# - explicit exit codes so a CI pipeline can detect failures +# +# References: +# docs.blender.org/manual/en/latest/advanced/command_line/arguments.html +# docs.blender.org/api/current/bpy.context.html (temp_override) + +import argparse +import sys + +import bpy + + +def parse_args(argv): + """Parse args after the `--` separator that Blender passes through.""" + if "--" in argv: + script_args = argv[argv.index("--") + 1:] + else: + script_args = [] + + parser = argparse.ArgumentParser( + description="Apply a modifier to every mesh and export to glTF.", + ) + parser.add_argument( + "--output", + required=True, + help="Path to the output .glb file.", + ) + parser.add_argument( + "--apply-modifier", + choices=["SUBSURF", "BEVEL", "MIRROR", "TRIANGULATE"], + default=None, + help="If set, add and apply this modifier to every mesh before export.", + ) + parser.add_argument( + "--subsurf-levels", + type=int, + default=2, + help="Subdivision levels when --apply-modifier=SUBSURF.", + ) + return parser.parse_args(script_args) + + +def add_and_apply_modifier(obj, modifier_type, subsurf_levels=2): + """Add a modifier to obj and apply it. + + Modifier application is one of the few cases where bpy.ops is the + canonical path; bpy.data does not expose an apply method. We use + temp_override to set the active object cleanly, instead of the + deprecated context-dict-passing form. + """ + modifier = obj.modifiers.new(name=modifier_type, type=modifier_type) + if modifier_type == "SUBSURF": + modifier.levels = subsurf_levels + modifier.render_levels = subsurf_levels + + with bpy.context.temp_override(object=obj, active_object=obj): + bpy.ops.object.modifier_apply(modifier=modifier.name) + + +def main(): + args = parse_args(sys.argv) + + mesh_objects = [obj for obj in bpy.data.objects if obj.type == "MESH"] + if not mesh_objects: + print("ERROR: no mesh objects in the input .blend", file=sys.stderr) + return 2 + + print(f"Found {len(mesh_objects)} mesh object(s): {[o.name for o in mesh_objects]}") + + if args.apply_modifier: + for obj in mesh_objects: + try: + add_and_apply_modifier(obj, args.apply_modifier, args.subsurf_levels) + print(f"Applied {args.apply_modifier} to {obj.name}") + except RuntimeError as exc: + print( + f"ERROR: failed to apply {args.apply_modifier} to {obj.name}: {exc}", + file=sys.stderr, + ) + return 3 + + try: + bpy.ops.export_scene.gltf( + filepath=args.output, + export_format="GLB", + use_selection=False, + export_apply=True, + ) + except RuntimeError as exc: + print(f"ERROR: glTF export failed: {exc}", file=sys.stderr) + return 4 + + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + exit_code = main() + sys.exit(exit_code) diff --git a/tests/smoke/tmpl_render.py b/tests/smoke/tmpl_render.py new file mode 100644 index 0000000..7be3848 --- /dev/null +++ b/tests/smoke/tmpl_render.py @@ -0,0 +1,43 @@ +# Reproduction of the headless-batch-scripting SKILL worked example (fixed v0.2.7), +# copied into scratch. Renders frames to PNG with the version-correct EEVEE id. +import argparse, os, sys, bpy + +def parse_frames(spec): + frames = [] + for chunk in spec.split(","): + if "-" in chunk: + lo, hi = chunk.split("-", 1); frames.extend(range(int(lo), int(hi)+1)) + else: + frames.append(int(chunk)) + return frames + +def main(): + argv = sys.argv + argv = argv[argv.index("--")+1:] if "--" in argv else [] + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True) + parser.add_argument("--frames", default="1") + parser.add_argument("--engine", default="CYCLES", choices=["CYCLES", "EEVEE"]) + args = parser.parse_args(argv) + + scene = bpy.context.scene + if args.engine == "EEVEE": + scene.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + else: + scene.render.engine = args.engine + print(f"engine={scene.render.engine}") + scene.render.image_settings.file_format = 'PNG' + scene.render.resolution_x = 128; scene.render.resolution_y = 128 + out_dir = os.path.dirname(args.output) or "." + os.makedirs(out_dir, exist_ok=True) + for frame in parse_frames(args.frames): + scene.frame_set(frame) + scene.render.filepath = args.output.replace("####", f"{frame:04d}") + bpy.ops.render.render(write_still=True) + print(f"Rendered frame {frame} -> {scene.render.filepath}") + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"FATAL: {e}", file=sys.stderr); sys.exit(1) From a4bef90d6ecbfd8bc643e21fc3f8674a8848ec7a Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 19 Jun 2026 09:02:22 -0400 Subject: [PATCH 2/2] ci: make smoke runner headless-safe and add explicit EEVEE-id assertion Runner fix: GPU-less GitHub runners abort EEVEE GPU rendering with a C-level core dump (libEGL.so.1 missing, exit 134) that Python try/except cannot catch. Install Blender's GL runtime libs + xvfb and run Blender under xvfb-run; render the non-black frame with Cycles (CPU) instead of EEVEE. Gate fix: keep a dedicated, frame-independent EEVEE engine-id assertion (eevee-engine-id-assigns) that compares the helper's id against the version-expected id (BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2-4.5) so the polarity bug (CRITICAL #1) stays guarded even though rendering is Cycles. Verified: green on 5.1.1 and 4.5.10; re-inverting get_eevee_engine_id() makes eevee-engine-id-assigns FAIL (exit 1) on both builds (enum not found). Signed-off-by: fOuttaMyPaint --- .github/workflows/blender-smoke.yml | 20 ++++++++++++---- tests/smoke/run_smoke.py | 36 ++++++++++++++--------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index ec8b650..a9fce6d 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -34,6 +34,16 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install Blender runtime libraries + run: | + set -euo pipefail + sudo apt-get update + # Blender needs these shared libs even in --background (GPU/GL module init); + # xvfb provides a virtual display so GL/EGL init does not abort the process. + sudo apt-get install -y --no-install-recommends \ + xvfb libgl1 libegl1 libxrender1 libxxf86vm1 libxfixes3 libxi6 \ + libxkbcommon0 libsm6 libice6 + - name: Resolve and download Blender ${{ matrix.series }} run: | set -euo pipefail @@ -72,17 +82,17 @@ jobs: run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/out" - "$BLENDER" --background --python tests/smoke/run_smoke.py -- "$RUNNER_TEMP/out" + xvfb-run -a "$BLENDER" --background --python tests/smoke/run_smoke.py -- "$RUNNER_TEMP/out" - name: Build template input scene run: | set -euo pipefail - "$BLENDER" --background --python tests/smoke/make_input.py -- "$RUNNER_TEMP/out/input.blend" + xvfb-run -a "$BLENDER" --background --python tests/smoke/make_input.py -- "$RUNNER_TEMP/out/input.blend" - name: Headless glTF template runs (exit 0, .glb produced) run: | set -euo pipefail - "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ + xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ --python tests/smoke/tmpl_gltf.py -- \ --output "$RUNNER_TEMP/out/out.glb" --apply-modifier SUBSURF test -s "$RUNNER_TEMP/out/out.glb" || { echo "::error::glTF output missing/empty"; exit 1; } @@ -91,7 +101,7 @@ jobs: - name: Headless template no-mesh path returns exit 2 run: | set +e - "$BLENDER" --background "$RUNNER_TEMP/out/empty.blend" \ + xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/empty.blend" \ --python tests/smoke/tmpl_gltf.py -- --output "$RUNNER_TEMP/out/none.glb" code=$? set -e @@ -103,7 +113,7 @@ jobs: set -euo pipefail # Cycles (CPU) so this is reliable on GPU-less runners; the EEVEE-id regression # itself is gated in run_smoke.py via engine assignment. - "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ + xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/input.blend" \ --python tests/smoke/tmpl_render.py -- \ --output "$RUNNER_TEMP/out/render.png" --engine CYCLES test -s "$RUNNER_TEMP/out/render.png" || { echo "::error::render PNG missing/empty"; exit 1; } diff --git a/tests/smoke/run_smoke.py b/tests/smoke/run_smoke.py index 697c10b..591f9e1 100644 --- a/tests/smoke/run_smoke.py +++ b/tests/smoke/run_smoke.py @@ -108,31 +108,31 @@ def smoke_eevee(): cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); bpy.context.collection.objects.link(cam) cam.location = (0,0,10); bpy.context.scene.camera = cam sc = bpy.context.scene - # the EEVEE-id regression guard: assigning the version-correct id must succeed + # THE EEVEE-id polarity guard (CRITICAL #1). Independent of any rendered frame, so it + # cannot flake on the GL/EGL context. `eid` is the repo helper's output (under test); + # `expected` is computed here from the version, so an inverted helper is caught. eid = get_eevee_engine_id() + expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + assigned = True; err = "" try: sc.render.engine = eid except Exception as e: - require("eevee-id-assign", False, f"assigning '{eid}' raised {type(e).__name__}: {e}") - require("eevee-id-assign", sc.render.engine == eid, f"engine id '{eid}' valid on this build") + assigned = False; err = f"{type(e).__name__}: {e}" + require("eevee-engine-id-assigns", + assigned and sc.render.engine == expected, + f"helper returned '{eid}'; engine='{sc.render.engine if assigned else 'UNASSIGNED('+err+')'}' expected='{expected}'") + # Render the non-black frame with Cycles (CPU): reliable on GPU-less headless runners, + # where an EEVEE GPU render aborts the process (no EGL). The EEVEE-id regression itself + # is already gated by the assignment above; EEVEE *rendering* is not exercised here. + sc.render.engine = 'CYCLES' + try: sc.cycles.samples = 4 + except Exception: pass sc.render.resolution_x = 16; sc.render.resolution_y = 16; sc.render.image_settings.file_format = 'PNG' png = os.path.join(OUT, f"smoke_render_{V[0]}{V[1]}.png"); sc.render.filepath = png - # try EEVEE render; if the headless runner has no GPU and it raises, fall back to Cycles (CPU) - used = sc.render.engine - try: - try: sc.eevee.taa_render_samples = 4 - except Exception: pass - bpy.ops.render.render(write_still=True) - if not os.path.exists(png): raise RuntimeError("no PNG written") - except Exception as e: - print(f" (EEVEE render unavailable headless: {type(e).__name__}; falling back to CYCLES)") - sc.render.engine = 'CYCLES'; used = 'CYCLES' - try: sc.cycles.samples = 4 - except Exception: pass - bpy.ops.render.render(write_still=True) - require("eevee-render-file", os.path.exists(png) and os.path.getsize(png) > 0, f"render PNG written by {used}") + bpy.ops.render.render(write_still=True) + require("render-file", os.path.exists(png) and os.path.getsize(png) > 0, "1-frame render PNG written (Cycles CPU)") img = bpy.data.images.load(png); px = list(img.pixels); mx = max(px) if px else 0.0 - require("eevee-render-nonblack", mx > 0.05, f"max pixel {round(mx,3)} > 0.05 (engine used: {used})") + require("render-nonblack", mx > 0.05, f"max pixel {round(mx,3)} > 0.05 (emissive material renders bright)") # ---------- slotted actions: correct branch + legacy behaviour ---------- def smoke_slotted():