⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
procedural-blender-nodes
>-
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
Video to Blender
Turn a reference video, image or idea into an editable procedural Blender node system. A skill for coding agents (Claude Code, Codex, Cursor, Copilot, Gemini) that builds native Geometry Nodes, Shader Nodes and compositor graphs from Python, with a tested node-group library and validation tools.
| The top-level graph: one modifier builds the whole street | One part opened: the houses, framed block by block |
|---|---|
![]() | ![]() |
Storybook Street (prompts/storybook-street/) rebuilds a 20 s AI-generated village
clip as one Geometry Nodes modifier on a path curve: the cobbled road, half-timbered houses, props, bunting moving
in the wind, and a walker with planted feet. A camera follows the walker on any path. Every visible part is a node
graph: geometry and motion in Geometry Nodes, materials in Shader Nodes, the sky in the world shader, haze and glow
in the compositor. Python only builds those graphs and sets up the input curve, sun, camera rig and render
settings. Draw a different curve and the same graph builds a different street.
The skill pairs a workflow with nodekit, a Python graph builder and inspector, and a tested node-group
library: 39 NK groups in 6 modules, each built, evaluated and numerically checked in Blender. Blender 5.2 or
later is required.
Why
Agents writing Blender node scripts work blind: they cannot see the graph or the render, Blender's Python API changed in 5.x, and a script that runs can still leave a broken or unreadable graph. Common results:
- modifier inputs set as ID properties, which raises in 5.2;
- a shared node group rebuilt in place, silently unlinking every user;
- sockets looked up by index, which breaks when a node's mode changes;
- a single 300-node group with no frames or descriptions;
- motion baked as keyframes where a function of time would do;
- a "procedural" asset that only works on the one input it was built for.
This skill closes those gaps:
- nodekit builds graphs from Python the way you would write them by hand. Sockets are found by name, panels and ranges are declared, blocks are framed with a sentence, and the layout is automatic. It reads the result back as JSON, lints it, and sets modifier inputs the 5.2 way.
- Tested building blocks you instance instead of re-deriving. Each is checked in Blender with numbers, for
example:
- the curve frame is orthonormal on hills;
- a bend preserves centreline length;
- parcel widths sum to the curve length;
- scatter counts follow density;
- a planted foot never slides;
- spring gain matches the analytic formula;
- premultiplied haze has no halos.
- A checker and a validator.
nodes_tools.py checkbuilds and evaluates every group with zero warnings and zero lint.validate.pyreopens a saved .blend and checks warnings, evaluation time and camera clearance. - A workflow with a definition of done: artist controls (a Primary panel of macros), presets, sweeps, reuse on other inputs, temporal checks for animation, and honest reporting.
- Reference-driven reconstruction as one optional prompt
(
prompts/reference-to-nodes-prompt.md), not a requirement.
Install
Claude Code (plugin marketplace):
claude plugin marketplace add gokayfem/video-to-blender
claude plugin install video-to-blender@video-to-blender
Or copy the skill: cp -R skills/procedural-blender-nodes ~/.claude/skills/.
Codex, Cursor, Gemini CLI, Copilot and other agents (via skills):
npx skills add gokayfem/video-to-blender
Or install by hand:
- Codex: copy
skills/procedural-blender-nodesto~/.agents/skills/. - Cursor: copy it to
~/.cursor/skills/, plus the rule inadapters/cursor/. - Copilot:
gh skill install gokayfem/video-to-blender procedural-blender-nodes, plusadapters/copilot/. - Gemini CLI:
gemini extensions install https://github.com/gokayfem/video-to-blender.
claude.ai: zip skills/procedural-blender-nodes and upload it under Settings > Capabilities > Skills.
ChatGPT, Gemini Gems and other chat tools: see adapters/.
You also need Blender 5.2 or later, as blender on PATH, $BLENDER, or the macOS app.
Use
Ask naturally, for example:
- "Make a Geometry Nodes fence that follows any curve, with controls for post spacing and lean."
- "Ivy that grows over whatever mesh I give it."
- "Recreate this clip as an editable Blender setup."
- "Port my 4.2 node script to Blender 5.2."
The agent routes the request, finds groups, writes a build script that saves a .blend, and checks it:
python3 scripts/nodes_tools.py search curve fence
python3 scripts/nodes_tools.py try -- --module my_effect --root . --png out/try.png
blender -b my_effect.blend --python scripts/validate.py
What the commands do:
searchranks library groups by name, summary, tags and uses.trybuilds one group on a test input, prints its node warnings and lint, and renders a still.validatereopens the saved file and checks warnings, counts, evaluation time and camera clearance.
What's in the library
| Module | Groups |
|---|---|
curve | NK Curve Frame (s, d, h to a level frame on any curve), NK Curve Bend (bend curve-space geometry onto a path), NK Curve Samples, NK Curve Parcels (random widths that sum exactly, with fan data for bends), NK Curve Ribbon, NK Catenary Point (sagging ropes and cables) |
shapes | NK Rounded Box, NK Chamfer Box / NK Chamfer Boxes (chamfer size independent of scale), NK Rod, NK Ring, NK Profile Revolve (pots, barrels, vases), NK Leaf, NK Blossom, NK Foliage Mound, NK Grass Tuft, NK Facade Frame (one placement frame per wall face), NK Store Local Coords (per-piece texture space) |
scatter | NK Scatter On Surface (density, Poisson distance, slope/attribute masks, alignment), NK Scatter Along Curve, NK Clumps |
motion | NK Damped Spring, NK Gait Step (integrated phase, planted feet), NK Wind Gust, NK Pendulum Sway, NK Time Remap (loop, ping-pong, ease), NK Flicker |
materials | shader groups: NK Attribute Tint, NK Stucco, NK Wood Grain, NK Stone, NK Felt, NK Terracotta, NK Stripes, NK Glow Glass, NK Ground, NK Root Tip Gradient, plus make_material() |
post | compositor groups: NK Haze Over Sky (mist haze on premultiplied geometry over a sky gradient), NK Soft Glow |
The full catalog with inputs and uses is
references/library-index.md.
Storybook Street in detail, and the templates
The top of this page is prompts/storybook-street/. It was measured against the
reference over time (colour, luma percentiles, image motion) and validated by reopening the file; its README
reports the results and the remaining differences. It has a Primary panel of eight macro controls, six presets,
and the same graph on an S-bend, a hill and a spiral town:
| Reference (top) vs reconstruction (bottom) | Variations of one graph | The same graph on other curves |
|---|---|---|
![]() | ![]() | ![]() |
Its [workflow](prompts/storybo
// HOW IT'S BUILT
// FILES
43 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/procedural-blender-nodes/SKILL.md8.6 KB6369dfe7628cupstream ↗
--- name: procedural-blender-nodes description: >- Builds, debugs, and art-directs procedural Blender node systems from Python: Geometry Nodes generators and modifiers, Shader Node materials, simulation and repeat zones, analytic animation, and compositor looks, as readable node groups with artist controls that work on new input geometry. Ships nodekit (a graph builder and inspector), a tested NK node-group library (curve placement, shapes, scatter, motion, materials, post), templates, and a checker. Use when the user wants a Geometry Nodes setup, a procedural asset or generator in Blender, a bpy script that creates node trees, node-based materials or effects, or wants such a script fixed, ported to Blender 5.x, organised, or explained; also for recreating a reference (image, clip or description) as an editable node system. Not for Unity/Unreal/Houdini graphs, three.js, or plain mesh modelling without nodes. license: MIT metadata: hermes: tags: - blender - geometry-nodes - shader-nodes - procedural - bpy category: development --- # Procedural Blender Nodes Treat a node system as an explanation of an effect: decide what it must produce and on what inputs, build it as named node groups an artist can read, expose the few controls that matter, and prove it by rendering it, changing those controls, and running it on other inputs. ## Workflow 1. **Target.** State the effect: what geometry, materials and motion; the input it runs on (a curve, a mesh, a point set, nothing); what an artist should be able to change; the shot or use that judges it. 2. **Route.** Pick the technique and a starting template from `references/subject-routing.md`: curve-space layout, surface scatter, instanced assemblies, mesh modelling, analytic animation, simulation zone, shader-only. 3. **Assemble from the library.** Find groups with `python3 scripts/nodes_tools.py search <words>` or `references/library-index.md`, and instance them (`Graph.group(builder(), ...)`). Do not rebuild what the library has; it is tested. 4. **Build through nodekit.** Write a Python build script: `nodekit.graph.Graph` creates each group (inputs with descriptions, ranges and panels; frames around each block), a top-level modifier group composes them, and the script creates the scene and saves the .blend. The .blend's node graph is the deliverable; the script makes it reproducible. See `references/nodekit-guide.md`. 5. **First result fast.** One part, its test input, one render: `python3 scripts/nodes_tools.py try -- --module <your.module> --root <dir> --png out/try.png`. Look at the image. Then grow part by part, judging the full assembly, not parts alone. 6. **Refine in order:** silhouette and proportions, layout and density, materials, light and look, motion. Calibrate against the reference (if any) with measurements over time or across views, not one frame. 7. **Controls.** A Primary panel with a handful of macro controls; detailed panels per part; presets as JSON. Sweep each primary control and look at the results (`scripts/sweep.py`). 8. **Validate.** `scripts/validate.py` reopens the saved file: zero node warnings, zero lint, evaluation time, camera clearance. Run the effect on other inputs. For animation, render a continuous sequence and check it for sliding, popping and flicker. Rebuild twice and diff the graph dumps. Run the scripts from this skill's directory. They need Python 3 and Blender 5.2+ (`$BLENDER`, `blender` on PATH, or the macOS app); `nodes_tools.py` starts Blender itself. ## Conventions - **Python builds native nodes.** Every tree is built with `Graph` (Geometry, Shader or Compositor). Sockets are found by name among enabled sockets (`"Value#1"` for duplicates); set node properties via `props=` before inputs, because properties change which sockets exist. - **Groups explain.** Name groups for what they make (`NK Curve Parcels`, `My Effect Roof`), describe every group and input, frame every block with a sentence (`"Parcels: split each side into houses"`). Split reusable parts into sub-groups. `Graph.finish()` lays out frames left to right and gives each frame its own Group Input. - **Time.** Animated groups take a `Time` input in seconds. Only the top-level modifier group reads Scene Time (with Time Scale and Time Offset controls), so parts stay testable and retimable. - **Space.** Model in the input's space (curve space: s along, d left, h up; or object space) and place through the library's frame groups, so the effect follows any curve or mesh. - **Variation into materials.** Store `nk_color` (FLOAT_COLOR) and `nk_rand` (FLOAT 0–1) per piece; materials read them with an Attribute node (type GEOMETRY) after realize. - **Shared groups build once** (`@nodegroup` or `nodekit.graph.once`). Rebuilding a group other groups use clears its interface and silently unlinks them. - **Analytic first.** Periodic or driven motion is a function of `Time`. Use a simulation zone only for state: accumulation, growth, collisions, anything whose next frame depends on the last. ## Hard rules (these break Blender 5.2 scripts) - Modifier inputs are not ID properties: use `nodekit.inspect.set_modifier_inputs(mod, {"Name": value})`, never `mod["Socket_2"]`. - Never rebuild a shared node group in place after other groups use it (see Conventions). - Mode switches on Transform, Resample and Set Curve Normal, and Remove Named Attribute's pattern mode, are input sockets, not node properties. Texture outputs are named "Factor". - Field-default sockets (Extrude Mesh `Offset`) ignore constants; link a node. - In background mode, drivers update only for the window's scene: set `bpy.context.window.scene` before `frame_set`, render with `bpy.ops.render.render(scene=...)`. - EEVEE world volumes swallow the sun: do distance haze in the compositor on premultiplied geometry. - Always run Blender as `blender -b --factory-startup --python script.py -- args`; pass negative CLI values as `--x=-1`. - Every input gets a description, every group a description; zero node warnings, zero `check_groups` lint. The full list with fixes is in `references/blender-api-notes.md`. ## Reference routing Read only what the task needs: | File | Read when | |---|---| | `references/subject-routing.md` | starting from a subject ("a fence along a path", "ivy over a statue"): technique, groups, template | | `references/library-index.md` | choosing groups: every NK group with inputs, summary and uses | | `references/nodekit-guide.md` | writing the build script: Graph API, zones, frames, layout, inspect, the scripts | | `references/geometry-nodes-patterns.md` | fields and domains, instancing vs realize, attributes into materials, booleans, budgets, per-frame cost | | `references/animation-and-simulation.md` | motion: analytic vs simulation zone, integrated phase, planted feet, springs, wind, loops, caches | | `references/controls-and-presets.md` | designing the modifier panel, macro controls, presets, sweeps | | `references/materials-and-look.md` | Shader Node materials, attribute variation, EEVEE, AgX, light, compositor haze and glow, calibration | | `references/validation-and-review.md` | before delivering: determinism, reopen-and-validate, reuse, temporal checks, honest reporting | | `references/blender-api-notes.md` | a Blender 5.x API error or surprise | | `../../prompts/reference-to-nodes-prompt.md` (repository) | recreating a reference video, image or description as a node system | Templates in `templates/` are complete, render-verified build scripts (gallery in the repository README); each builds a .blend whose top modifier group runs on its own test input and on others. ## Output expectations When building an effect: - Deliver the build script and the .blend it saves. The .blend holds native node groups, not baked geometry or image sequences (state any image texture you use and why). - The top-level modifier group has a Primary panel, then per-part panels; every group and input is described. - End with a short guide: the primary controls, what each part group does, how to run it on new input, and how it was validated (check/validate output, renders looked at, inputs tried). - Separate what is implemented from what is approximated or out of scope; do not call an analytic sway a cloth simulation, or a texture mask modelled geometry. When fixing or porting an existing node script: - Identify how it builds trees and where it breaks on Blender 5.2 (modifier inputs, socket names, modes as inputs). - Preserve the graph's interface (input names and order) unless asked; call out any change. - Fix correctness first, then readability (frames, descriptions, layout), then performance.skills/procedural-blender-nodes/scripts/_check_in_blender.py3.3 KB3011cd62f182upstream ↗
"""Run by `nodes_tools.py check` inside Blender: build every library group, lint it, and evaluate every Geometry Nodes group on standard test inputs (a curve for curve/'Curve' inputs, an icosphere for other geometry), with zero node warnings required. Exits 1 on any problem.""" import pathlib, sys, time SKILL = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(SKILL)) import bpy from nodekit import inspect from nodekit.graph import Graph from nodekit.library import REGISTRY, build_all problems = [] t0 = time.time() try: trees = build_all() except Exception as e: # a builder that raises is a hard failure import traceback traceback.print_exc() sys.exit(1) print(f"built {len(trees)} groups in {time.time() - t0:.2f}s") for name, tree in trees.items(): if tree.name != name: problems.append(f"{name}: builder returned a tree named {tree.name!r}") problems += inspect.check_groups("NK ") # evaluation on standard inputs cu = bpy.data.curves.new("NK Test Curve", "CURVE") cu.dimensions = "3D" sp = cu.splines.new("BEZIER") sp.bezier_points.add(3) for bp, p in zip(sp.bezier_points, [(0, 0, 0), (4, 6, 0), (-3, 12, 1), (0, 20, 1)]): bp.co = p bp.handle_left_type = bp.handle_right_type = "AUTO" curve_ob = bpy.data.objects.new("NK Test Curve", cu) bpy.context.scene.collection.objects.link(curve_ob) bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=3, radius=1.0) mesh_ob = bpy.context.object for name, tree in trees.items(): if tree.bl_idname != "GeometryNodeTree": continue outs = [it for it in tree.interface.items_tree if it.item_type == "SOCKET" and it.in_out == "OUTPUT"] if not any(it.socket_type == "NodeSocketGeometry" for it in outs): continue # field-only helper groups are checked by tests w = Graph(f"NKT {name}", description="check wrapper", modifier=True) ci = w.node("GeometryNodeObjectInfo", props={"transform_space": "RELATIVE"}, Object=curve_ob) mi = w.node("GeometryNodeObjectInfo", props={"transform_space": "RELATIVE"}, Object=mesh_ob) t = w.node("GeometryNodeInputSceneTime") gn = w.group(tree) for s in gn.b.inputs: if not s.enabled: continue if s.type == "GEOMETRY": w.connect(ci["Geometry"] if "curve" in s.name.lower() or "path" in s.name.lower() else mi["Geometry"], s) elif s.name == "Time": w.connect(t["Seconds"], s) j = w.node("GeometryNodeJoinGeometry") for s in gn.b.outputs: if s.enabled and s.type == "GEOMETRY": w.connect(s, j.i("Geometry")) w.output("Geometry", "GEOMETRY", j["Geometry"]) w.finish() me = bpy.data.meshes.new(f"NKT {name}") ob = bpy.data.objects.new(f"NKT {name}", me) bpy.context.scene.collection.objects.link(ob) mod = ob.modifiers.new("check", "NODES") mod.node_group = w.nt t1 = time.time() bpy.context.scene.frame_set(24) stats = inspect.evaluated_stats(ob) dt = (time.time() - t1) * 1000 warns = [f"{x.type}: {x.message}" for x in mod.node_warnings] print(f" {name:<32} {stats['verts']:>8} verts {dt:7.1f} ms {'; '.join(warns)}") for x in warns: problems.append(f"{name}: node warning on test inputs: {x}") for p in problems: print("ERROR", p) print(f"blender: {len(problems)} problem(s)") sys.exit(1 if problems else 0)skills/procedural-blender-nodes/scripts/compare.py21 KB8c5bb8f19818upstream ↗
#!/usr/bin/env python3 """Compare a render with the reference ACROSS TIME, per screen region (see docs/COMPARE.md). python3 tools/compare.py --ref reference/source/village_source.mp4 --render out/lead/seq \ --times 0:20:0.5 --out out/compare/run1 [--regions my_regions.json] [--render-offset 0] [--width 640] python3 tools/compare.py --dump-regions out/compare/regions.json # write the default regions to edit --render is a directory of f_####.png (frame N = time (N-1)/24 s; sparse is fine: missing times become gaps) or a video. For every sampled time t, frame i = round(t*fps) and its successor i+1 are read from both sources (scaled to the reference's analysis size), and colour, structure and motion metrics are computed per region. Outputs in --out: metrics.csv (long: time,region,metric,ref,render), metrics.json, summary.md, and plots colour.png, structure.png, motion.png, grid_<metric>.png (3x3 screen grid laid out spatially). Dependencies: ffmpeg and numpy (re-executes under Blender's bundled python if the system python has no numpy). """ import argparse import csv import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import video # noqa: E402 (also guarantees numpy) from video import Source, draw_text, fill_rect, parse_times, text_size, write_png # noqa: E402 import numpy as np # noqa: E402 # Fractional rects [x0, y0, x1, y1] in the 1280x704 reference frame (y down). "exclude" rects are cut out. # Tuned on reference/frames/t05.png: facades flank a narrow sky strip with bunting; walker centred at the bottom. DEFAULT_REGIONS = { "full": {"rect": [0.0, 0.0, 1.0, 1.0]}, "sky_strip": {"rect": [0.36, 0.0, 0.64, 0.30], "note": "narrow sky between the roofs (contains bunting)"}, "bunting": {"rect": [0.33, 0.0, 0.70, 0.24], "note": "flag strings across the street; flutter lives here"}, "left_facade": {"rect": [0.0, 0.05, 0.30, 0.62], "note": "near left wall, streams out to the left"}, "right_facade": {"rect": [0.70, 0.05, 1.0, 0.62], "note": "near right wall, streams out to the right"}, "road": {"rect": [0.0, 0.70, 1.0, 1.0], "exclude": [[0.37, 0.44, 0.63, 0.95]], "note": "cobbles, curbs and verges below the horizon, walker cut out"}, "walker": {"rect": [0.37, 0.44, 0.63, 0.95], "note": "hooded walker box"}, } GRID = ["g%d%d" % (r, c) for r in range(3) for c in range(3)] # g<row><col>, row 0 = top COLOUR = ["srgb_r", "srgb_g", "srgb_b", "ok_L", "ok_a", "ok_b", "chroma", "luma_p10", "luma_p50", "luma_p90"] STRUCT = ["edge", "edge_frac", "ssim"] MOTION = ["mad", "flow_x", "flow_y", "flow_conf", "flutter"] METRICS = COLOUR + STRUCT + MOTION PAIR_ONLY = {"ssim"} # one value per time (ref vs render), stored in the "render" column, ref column = 1 UNITS = {"srgb_r": "0-1", "srgb_g": "0-1", "srgb_b": "0-1", "ok_L": "0-1", "ok_a": "", "ok_b": "", "chroma": "", "luma_p10": "0-1", "luma_p50": "0-1", "luma_p90": "0-1", "edge": "grad", "edge_frac": "frac", "ssim": "", "mad": "luma/frame", "flow_x": "px/frame@1280", "flow_y": "px/frame@1280", "flow_conf": "peak", "flutter": "luma/frame"} # --------------------------------------------------------------------------------------------------------------- # image maths def srgb_to_linear(c): return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def oklab(rgb): lin = srgb_to_linear(rgb) m1 = np.array([[0.4122214708, 0.5363325363, 0.0514459929], [0.2119034982, 0.6806995451, 0.1073969566], [0.0883024619, 0.2817188376, 0.6299787005]]) m2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468], [1.9779984951, -2.4285922050, 0.4505937099], [0.0259040371, 0.7827717662, -0.8086757660]]) lms = np.cbrt(lin @ m1.T) return lms @ m2.T def luma(rgb): return rgb @ np.array([0.2126, 0.7152, 0.0722]) def sobel_mag(y): p = np.pad(y, 1, mode="edge") gx = (p[:-2, 2:] + 2 * p[1:-1, 2:] + p[2:, 2:] - p[:-2, :-2] - 2 * p[1:-1, :-2] - p[2:, :-2]) / 4 gy = (p[2:, :-2] + 2 * p[2:, 1:-1] + p[2:, 2:] - p[:-2, :-2] - 2 * p[:-2, 1:-1] - p[:-2, 2:]) / 4 return np.hypot(gx, gy) def down(img, f): h, w = img.shape[0] // f * f, img.shape[1] // f * f img = img[:h, :w] return img.reshape(h // f, f, w // f, f, *img.shape[2:]).mean(axis=(1, 3)) def box(x, r): """Mean filter of size (2r+1)^2 with edge padding, via cumulative sums.""" p = np.pad(x, r + 1, mode="edge") c = p.cumsum(0).cumsum(1) k = 2 * r + 1 s = c[k:, k:] - c[:-k, k:] - c[k:, :-k] + c[:-k, :-k] return s[:x.shape[0], :x.shape[1]] / (k * k) def ssim_map(a, b, r=3): c1, c2 = 0.01 ** 2, 0.03 ** 2 ma, mb = box(a, r), box(b, r) va = box(a * a, r) - ma * ma vb = box(b * b, r) - mb * mb cov = box(a * b, r) - ma * mb return ((2 * ma * mb + c1) * (2 * cov + c2)) / ((ma * ma + mb * mb + c1) * (va + vb + c2)) def phase_corr(a, b, weight): """Shift d (dx, dy) such that b(x) ~ a(x - d), with parabolic sub-pixel refinement, plus peak height.""" a = (a - (a * weight).sum() / max(weight.sum(), 1e-9)) * weight b = (b - (b * weight).sum() / max(weight.sum(), 1e-9)) * weight A, B = np.fft.fft2(a), np.fft.fft2(b) R = B * np.conj(A) R /= np.abs(R) + 1e-9 r = np.fft.ifft2(R).real py, px = np.unravel_index(np.argmax(r), r.shape) h, w = r.shape def sub(m, z, p): if abs(m - 2 * z + p) < 1e-12: return 0.0 return float(np.clip(0.5 * (m - p) / (m - 2 * z + p), -0.5, 0.5)) dx = px + sub(r[py, (px - 1) % w], r[py, px], r[py, (px + 1) % w]) dy = py + sub(r[(py - 1) % h, px], r[py, px], r[(py + 1) % h, px]) if dx > w / 2: dx -= w if dy > h / 2: dy -= h return dx, dy, float(r[py, px]) def fourier_shift(img, dx, dy): """img sampled at x + d (moves content by -d), circular.""" h, w = img.shape ky = np.fft.fftfreq(h)[:, None] kx = np.fft.fftfreq(w)[None, :] return np.fft.ifft2(np.fft.fft2(img) * np.exp(2j * np.pi * (kx * dx + ky * dy))).real def region_masks(regions, w, h): masks, boxes = {}, {} for name, spec in regions.items(): x0, y0, x1, y1 = spec["rect"] bx = (int(round(x0 * w)), int(round(y0 * h)), int(round(x1 * w)), int(round(y1 * h))) m = np.zeros((h, w), bool) m[bx[1]:bx[3], bx[0]:bx[2]] = True for e in spec.get("exclude", []): m[int(round(e[1] * h)):int(round(e[3] * h)), int(round(e[0] * w)):int(round(e[2] * w))] = False masks[name], boxes[name] = m, bx return masks, boxes # --------------------------------------------------------------------------------------------------------------- # per-frame metrics def frame_metrics(fa, fb, masks, boxes, w, fps): """Colour/structure from fa; motion from fa -> fb (fb may be None). Returns {region: {metric: value}}.""" rgb = fa.astype(np.float64) / 255 lab = oklab(rgb) y = luma(rgb) chroma = np.hypot(lab[..., 1], lab[..., 2]) grad = sobel_mag(y) yb = luma(fb.astype(np.float64) / 255) if fb is not None else None # flow at half the analysis resolution y2 = down(y, 2) yb2 = down(yb, 2) if yb is not None else None px_scale = 1280.0 / (w / 2) # half-res px -> 1280-wide px out = {} for name, m in masks.items(): v = {} sel = rgb[m] v["srgb_r"], v["srgb_g"], v["srgb_b"] = sel.mean(0) L = lab[m] v["ok_L"], v["ok_a"], v["ok_b"] = L.mean(0) v["chroma"] = chroma[m].mean() v["luma_p10"], v["luma_p50"], v["luma_p90"] = np.percentile(y[m], [10, 50, 90]) v["edge"] = grad[m].mean() v["edge_frac"] = (grad[m] > 0.08).mean() if yb is None: for k in MOTION: v[k] = np.nan else: v["mad"] = np.abs(yb - y)[m].mean() x0, y0, x1, y1 = (c // 2 for c in boxes[name]) a, b = y2[y0:y1, x0:x1], yb2[y0:y1, x0:x1] m2 = down(m.astype(float), 2)[y0:y1, x0:x1] win = np.outer(np.hanning(a.shape[0]), np.hanning(a.shape[1])) * m2 dx, dy, conf = phase_corr(a, b, win) v["flow_x"], v["flow_y"], v["flow_conf"] = dx * px_scale, dy * px_scale, conf comp = fourier_shift(b, dx, dy) mx, my = int(np.ceil(abs(dx))) + 2, int(np.ceil(abs(dy))) + 2 inner = m2[my:-my or None, mx:-mx or None] > 0.5 res = np.abs(comp - a)[my:-my or None, mx:-mx or None] v["flutter"] = res[inner].mean() if inner.any() else np.nan out[name] = v return out def ssim_by_region(fa, fb, masks): a = down(luma(fa.astype(np.float64) / 255), 4) b = down(luma(fb.astype(np.float64) / 255), 4) s = ssim_map(a, b) return {n: float(s[down(m.astype(float), 4)[:s.shape[0], :s.shape[1]] > 0.5].mean()) for n, m in masks.items()} # --------------------------------------------------------------------------------------------------------------- # plotting (numpy raster + bitmap font; no matplotlib) REF_C, REN_C, GRID_C, AX_C = (40, 80, 170), (230, 110, 20), (225, 225, 225), (90, 90, 90) def nice_ticks(lo, hi, n=4): span = hi - lo if span <= 0: return [lo] raw = span / n mag = 10 ** np.floor(np.log10(raw)) step = min((s * mag for s in (1, 2, 2.5, 5, 10) if s * mag >= raw), default=raw) t0 = np.ceil(lo / step - 1e-9) * step return [t0 + k * step for k in range(int((hi - t0) / step + 1e-9) + 1)] def fmt(v): if v == 0: return "0" a = abs(v) if a >= 100: return "%.0f" % v if a >= 10: return "%.1f" % v if a >= 1: return "%.2f" % v if a >= 0.01: return ("%.3f" % v).replace("0.", ".", 1) if a >= 0.001: return ("%.4f" % v).replace("0.", ".", 1) return "%.0e" % v def draw_line(img, x0, y0, x1, y1, color, th=2): n = int(max(abs(x1 - x0), abs(y1 - y0)) * 2) + 1 xs = np.linspace(x0, x1, n) ys = np.linspace(y0, y1, n) h, w = img.shape[:2] for ox in range(th): for oy in range(th): xi = np.clip(np.round(xs).astype(int) + ox - th // 2, 0, w - 1) yi = np.clip(np.round(ys).astype(int) + oy - th // 2, 0, h - 1) img[yi, xi] = color def draw_panel(img, x, y, w, h, title, times, series, ylabel=""): """series: list of (values, color, thickness). NaNs break the line.""" fill_rect(img, x, y, x + w, y + h, (255, 255, 255)) draw_text(img, x + 4, y + 4, title, (0, 0, 0), 2) if ylabel: tw, _ = text_size(ylabel, 1) draw_text(img, x + w - tw - 6, y + 8, ylabel, (110, 110, 110), 1) px0, py0, px1, py1 = x + 64, y + 26, x + w - 10, y + h - 24 allv = np.concatenate([np.asarray(v, float) for v, _, _ in series]) if series else np.array([np.nan]) allv = allv[np.isfinite(allv)] if allv.size == 0: draw_text(img, px0 + 10, (py0 + py1) // 2, "NO DATA", (150, 150, 150), 2) return lo, hi = float(allv.min()), float(allv.max()) if hi - lo < 1e-9 * max(1.0, abs(hi)) or hi - lo < 1e-12: lo, hi = lo - max(abs(lo) * 0.05, 1e-3), hi + max(abs(hi) * 0.05, 1e-3) pad = (hi - lo) * 0.08 lo, hi = lo - pad, hi + pad t0, t1 = times[0], times[-1] if times[-1] > times[0] else times[0] + 1 def X(t): return px0 + (t - t0) / (t1 - t0) * (px1 - px0) def Y(v): return py1 - (v - lo) / (hi - lo) * (py1 - py0) for tv in nice_ticks(lo, hi, 3): yy = int(round(Y(tv))) fill_rect(img, px0, yy, px1, yy + 1, GRID_C) s = fmt(tv) tw, _ = text_size(s, 2) draw_text(img, px0 - tw - 4, yy - 7, s, AX_C, 2) for tt in nice_ticks(t0, t1, 5): xx = int(round(X(tt))) fill_rect(img, xx, py0, xx + 1, py1, GRID_C) s = "%g" % round(tt, 2) tw, _ = text_size(s, 2) draw_text(img, xx - tw // 2, py1 + 6, s, AX_C, 2) if lo < 0 < hi: yy = int(round(Y(0))) fill_rect(img, px0, yy, px1, yy + 1, (170, 170, 170)) fill_rect(img, px0, py0, px0 + 1, py1 + 1, AX_C) fill_rect(img, px0, py1, px1, py1 + 1, AX_C) for vals, col, th in series: prev = None for t, v in zip(times, vals): if v is None or not np.isfinite(v): prev = None continue pt = (X(t), Y(v)) if prev: draw_line(img, prev[0], prev[1], pt[0], pt[1], col, th) else: fill_rect(img, pt[0] - 1, pt[1] - 1, pt[0] + 2, pt[1] + 2, col) prev = pt def figure(path, title, times, cells, ncols, pw=380, ph=190, ref_label="REFERENCE", ren_label="RENDER"): """cells: list of (panel title, [(values, color)], unit) or None, laid out row-major with ncols columns.""" nrows = (len(cells) + ncols - 1) // ncols head = 40 img = np.full((head + nrows * (ph + 6) + 6, ncols * (pw + 6) + 6, 3), 238, np.uint8) draw_text(img, 8, 10, title, (0, 0, 0), 2) x = 8 + text_size(title + " ", 2)[0] for lab, col in ((ref_label, REF_C), (ren_label, REN_C)): fill_rect(img, x, 14, x + 24, 18, col) draw_text(img, x + 30, 10, lab, (0, 0, 0), 2) x += 30 + text_size(lab + " ", 2)[0] for k, cell in enumerate(cells): if cell is None: continue r, c = divmod(k, ncols) t, series, unit = cell draw_panel(img, 6 + c * (pw + 6), head + r * (ph + 6), pw, ph, t, times, series, unit) write_png(path, img) # --------------------------------------------------------------------------------------------------------------- def run(args): regions = dict(DEFAULT_REGIONS) if args.regions: with open(args.regions) as f: regions = json.load(f) for r in range(3): for c in range(3): regions.setdefault("g%d%d" % (r, c), {"rect": [c / 3, r / 3, (c + 1) / 3, (r + 1) / 3]}) named = [n for n in regions if n not in GRID] ref, ren = Source(args.ref), Source(args.render, args.fps) times = parse_times(args.times) w = args.width h = int(round(w * ref.height / ref.width / 4)) * 4 w = w // 4 * 4 if abs(ren.width / ren.height - ref.width / ref.height) > 0.01: print("warning: aspect differs (ref %dx%d, render %dx%d); render is stretched to the ref aspect" % (ref.width, ref.height, ren.width, ren.height)) masks, boxes = region_masks(regions, w, h) def pairs(src, offset): res = {} for t in times: i = src.index(t + offset) if src.has(i): res[t] = (i, i + 1 if src.has(i + 1) else None) return res pr, pq = pairs(ref, 0.0), pairs(ren, args.render_offset) print("reading %d ref and %d render frames at %dx%d ..." % (2 * len(pr), 2 * len(pq), w, h)) fr = ref.read([i for p in pr.values() for i in p if i is not None], (w, h)) fq = ren.read([i for p in pq.values() for i in p if i is not None], (w, h)) data = {n: {m: {"ref": [], "render": []} for m in METRICS} for n in regions} for t in times: mr = mq = None if t in pr: i, j = pr[t] mr = frame_metrics(fr[i], fr.get(j), masks, boxes, w, ref.fps) if t in pq: i, j = pq[t] mq = frame_metrics(fq[i], fq.get(j), masks, boxes, w, ren.fps) ss = ssim_by_region(fr[pr[t][0]], fq[pq[t][0]], masks) if (mr and mq) else {} for n in regions: for m in METRICS: if m in PAIR_ONLY: data[n][m]["ref"].append(1.0 if ss else np.nan) data[n][m]["render"].append(ss.get(n, np.nan)) else: data[n][m]["ref"].append(float(mr[n][m]) if mr else np.nan) data[n][m]["render"].append(float(mq[n][m]) if mq else np.nan) os.makedirs(args.out, exist_ok=True) with open(os.path.join(args.out, "metrics.csv"), "w", newline="") as f: wr = csv.writer(f) wr.writerow(["time", "region", "metric", "ref", "render", "diff"]) for n in regions: for m in METRICS: for k, t in enumerate(times): a, b = data[n][m]["ref"][k], data[n][m]["render"][k] wr.writerow([t, n, m, "%.6g" % a, "%.6g" % b, "%.6g" % (b - a)]) def clean(x): return None if not np.isfinite(x) else round(float(x), 6) with open(os.path.join(args.out, "metrics.json"), "w") as f: json.dump({"ref": args.ref, "render": args.render, "render_offset": args.render_offset, "times": times, "analysis_size": [w, h], "regions": regions, "units": UNITS, "metrics": {n: {m: {s: [clean(v) for v in d[s]] for s in d} for m, d in md.items()} for n, md in data.items()}}, f, indent=1) # summary: median |render - ref| and median (render - ref) over time def stats(n, m): a = np.array(data[n][m]["ref"]) b = np.array(data[n][m]["render"]) ok = np.isfinite(a) & np.isfinite(b) if not ok.any(): return np.nan, np.nan, np.nan, np.nan d = b[ok] - a[ok] return np.median(np.abs(d)), np.median(d), np.median(a[ok]), np.median(b[ok]) def table(names, cell): lines = ["| metric | unit | " + " | ".join(names) + " |", "|---|---|" + "---|" * len(names)] for m in METRICS: row = [cell(*stats(n, m), m) for n in names] lines.append("| %s | %s | %s |" % (m, UNITS[m], " | ".join(row))) return "\n".join(lines) def mad_cell(mad, bias, a, b, m): if not np.isfinite(mad): return "-" if m in PAIR_ONLY: return "%s" % fmt(b) return "%s (%s%s)" % (fmt(mad), "+" if bias >= 0 else "", fmt(bias)) def val_cell(mad, bias, a, b, m): if not np.isfinite(mad): return "-" return fmt(b) if m in PAIR_ONLY else "%s / %s" % (fmt(a), fmt(b)) n_ok = sum(1 for k in range(len(times)) if np.isfinite(data["full"]["ok_L"]["ref"][k]) and np.isfinite(data["full"]["ok_L"]["render"][k])) md = ["# Compare: %s vs %s" % (args.ref, args.render), "", "Times `%s` (%d samples, %d with both frames), render offset %+g s, analysis %dx%d." % (args.times, len(times), n_ok, args.render_offset, w, h), "", "## Median |render - ref| over time (median signed bias in brackets; ssim: median value)", "", table(named, mad_cell), "", "### 3x3 grid (g<row><col>, row 0 = top)", "", table(GRID, mad_cell), "", "## Median values over time: ref / render", "", table(named, val_cell), ""] with open(os.path.join(args.out, "summary.md"), "w") as f: f.write("\n".join(md)) print("\n".join(md[:7])) # plots rl, ql = "REF", "RENDER " + ren.name + (" %+gS" % args.render_offset if args.render_offset else "") def cell(n, m): d = data[n][m] if m in PAIR_ONLY: return ("%s: %s" % (n, m), [(d["render"], REN_C, 2)], UNITS[m]) return ("%s: %s" % (n, m), [(d["ref"], REF_C, 4), (d["render"], REN_C, 2)], UNITS[m]) sets = {"colour": ["ok_L", "chroma", "ok_a", "ok_b", "luma_p10", "luma_p90"], "structure": ["edge", "edge_frac", "ssim"], "motion": ["flow_x", "flow_y", "mad", "flutter"]} for fname, ms in sets.items(): cells = [cell(n, m) for n in named for m in ms] figure(os.path.join(args.out, fname + ".png"), fname.upper() + " VS TIME (S)", times, cells, len(ms), ref_label=rl, ren_label=ql) for m in args.grid_metrics.split(","): cells = [cell(g, m) for g in GRID] figure(os.path.join(args.out, "grid_%s.png" % m), "%s ON THE 3X3 SCREEN GRID VS TIME (S)" % m.upper(), times, cells, 3, ref_label=rl, ren_label=ql) print("wrote", args.out, "(metrics.csv, metrics.json, summary.md, colour/structure/motion/grid_*.png)") def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--ref", default="reference/source/village_source.mp4") ap.add_argument("--render") ap.add_argument("--times", default="0:20:0.5", help="a:b:step (inclusive) or t1,t2,... in seconds") ap.add_argument("--out") ap.add_argument("--regions", help="JSON {name: {rect: [x0,y0,x1,y1], exclude: [[...]]}} (fractions, y down)") ap.add_argument("--render-offset", type=float, default=0.0, help="render time = ref time + offset (s)") ap.add_argument("--fps", type=float, default=24.0, help="fps of a PNG-sequence render") ap.add_argument("--width", type=int, default=640, help="analysis width (both sources are scaled to it)") ap.add_argument("--grid-metrics", default="ok_L,chroma,edge,flow_x,flow_y,mad") ap.add_argument("--dump-regions", metavar="PATH", help="write the default regions JSON and exit") args = ap.parse_args() if args.dump_regions: with open(args.dump_regions, "w") as f: json.dump(DEFAULT_REGIONS, f, indent=1) print("wrote", args.dump_regions) return if not (args.render and args.out): ap.error("--render and --out are required") run(args) if __name__ == "__main__": main()skills/procedural-blender-nodes/scripts/graph_atlas.py1.6 KBa9272633989cupstream ↗
"""Tile the numbered captures from `graph_shots.py --shots ALL` into one large poster: the whole scene's graphs. python3 scripts/graph_atlas.py shots/my_effect/ -o my_effect_graphs.jpg [--cols 6] [--tile 1280] Order is the capture order: the hero group and its sub-groups depth first, then materials, shader groups, world, compositor. Each tile keeps the editor header, which names the tree. Needs ffmpeg. """ import argparse, pathlib, subprocess, sys ap = argparse.ArgumentParser() ap.add_argument("shots") ap.add_argument("-o", "--out", required=True) ap.add_argument("--cols", type=int, default=6) ap.add_argument("--tile", type=int, default=1280, help="tile width in pixels") a = ap.parse_args() files = sorted(p for p in pathlib.Path(a.shots).glob("[0-9][0-9][0-9]_*.png")) if not files: sys.exit(f"no NNN_*.png captures in {a.shots}") n, cols = len(files), min(a.cols, len(files)) ins, chains = [], [] for i, f in enumerate(files): ins += ["-i", str(f)] chains.append(f"[{i}]scale={a.tile}:-2,setsar=1[t{i}]") layout = "|".join(f"{'+'.join(['w0'] * (i % cols)) or '0'}_{'+'.join(['h0'] * (i // cols)) or '0'}" for i in range(n)) graph = ";".join(chains) + ";" + "".join(f"[t{i}]" for i in range(n)) + \ f"xstack=inputs={n}:layout={layout}:fill=0x181818" if n == 1: graph = chains[0].replace("[t0]", "") cmd = ["ffmpeg", "-v", "error", "-y", *ins, "-filter_complex", graph, "-q:v", "3", a.out] subprocess.run(cmd, check=True) kinds = {} for f in files: k = f.stem.split("_")[1] kinds[k] = kinds.get(k, 0) + 1 print(f"wrote {a.out}: {n} graphs ({', '.join(f'{v} {k}' for k, v in kinds.items())}), {cols} columns")skills/procedural-blender-nodes/scripts/graph_doc.py4.8 KB010b218a802fupstream ↗
"""Generate docs/GRAPH.md and docs/CONTROLS.md from the graph dump written by street/build.py. python3 scripts/graph_doc.py my_effect.graph.json --top "My Effect" --out docs/ GRAPH.md: the group hierarchy (which group instances which), each group's description, frames (the readable "blocks" inside it), node counts and interface. CONTROLS.md: the Storybook Street modifier panel by panel, with defaults, ranges and descriptions. Both are regenerated, never edited by hand, so they cannot drift from the built graph. """ import collections, json, pathlib, sys import argparse ap = argparse.ArgumentParser() ap.add_argument("graph", help="graph dump written by nodekit.inspect.dump_groups") ap.add_argument("--top", required=True, help="name of the top-level (modifier) node group") ap.add_argument("--out", default="docs", help="directory for GRAPH.md and CONTROLS.md") a = ap.parse_args() OUTDIR = pathlib.Path(a.out) OUTDIR.mkdir(parents=True, exist_ok=True) groups = {g["name"]: g for g in json.loads(pathlib.Path(a.graph).read_text())} TOP = a.top def fmt(v): if isinstance(v, float): return f"{v:g}" if isinstance(v, list): if len(v) == 4 and all(isinstance(x, (int, float)) for x in v): r, g, b = (max(0, min(1, x)) for x in v[:3]) # linear -> sRGB hex for readability to = lambda c: round(255 * (12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055)) return f"`#{to(r):02X}{to(g):02X}{to(b):02X}`" return "(" + ", ".join(fmt(x) for x in v) + ")" return "" if v is None else str(v) def children(g): return [n["props"].get("node_tree") for n in g["nodes"] if n["props"].get("node_tree")] # ---- GRAPH.md --------------------------------------------------------------------------------------------- lines = ["# Graph", "", "Generated by `scripts/graph_doc.py` from the built file; do not edit by hand.", "", "## Hierarchy", "", "Each line is a node group; indented lines are the groups it instances " "(×n = instanced n times inside its parent).", "", "```text"] seen = set() def tree(name, depth=0, count=1): g = groups.get(name) if g is None: return n_nodes = len([n for n in g["nodes"] if n["type"] not in ("NodeGroupInput", "NodeGroupOutput")]) mult = f" ×{count}" if count > 1 else "" again = " (see above)" if name in seen else "" lines.append(f"{' ' * depth}{name}{mult} [{n_nodes} nodes, {len(g['frames'])} frames]{again}") if name in seen: return seen.add(name) for child, c in collections.Counter(children(g)).items(): tree(child, depth + 1, c) tree(TOP) lines += ["```", ""] total_nodes = sum(len(g["nodes"]) for g in groups.values() if g["type"] == "GeometryNodeTree") lines += [f"{len([g for g in groups.values() if g['type'] == 'GeometryNodeTree'])} Geometry Nodes groups " f"({total_nodes} nodes), {len([g for g in groups.values() if g['type'] == 'ShaderNodeTree'])} shader " "groups.", "", "## Groups", ""] order = [n for n in seen] + sorted(set(groups) - seen) for name in sorted(order, key=lambda n: (n != TOP, n)): g = groups[name] lines += [f"### {name}", "", g["description"] or "_(no description)_", ""] if g["frames"]: lines += ["Blocks: " + "; ".join(g["frames"]), ""] ins = [i for i in g["interface"] if i.get("io") == "INPUT"] outs = [i for i in g["interface"] if i.get("io") == "OUTPUT"] if name != TOP and ins: lines += ["Inputs: " + ", ".join(f"{i['name']}" for i in ins), ""] if outs: lines += ["Outputs: " + ", ".join(f"{i['name']}" for i in outs), ""] (OUTDIR / "GRAPH.md").write_text("\n".join(lines) + "\n") # ---- CONTROLS.md ------------------------------------------------------------------------------------------ top = groups[TOP] lines = ["# Controls", "", f"The `{TOP}` modifier panel, generated by `scripts/graph_doc.py` from the built " "file. Defaults are the reference look. Colours are shown as sRGB.", ""] panels = collections.OrderedDict() for it in top["interface"]: if it.get("io") != "INPUT" or it["type"] == "NodeSocketGeometry": continue panels.setdefault(it.get("panel") or "Main", []).append(it) for panel, items in panels.items(): lines += [f"## {panel}", "", "| Control | Default | Range | What it does |", "|---|---|---|---|"] for it in items: rng = "" if "min_value" in it and isinstance(it["min_value"], (int, float)) and abs(it["min_value"]) < 1e8: rng = f"{fmt(it['min_value'])} – {fmt(it['max_value'])}" lines.append(f"| {it['name']} | {fmt(it.get('default_value'))} | {rng} | {it['description']} |") lines.append("") (OUTDIR / "CONTROLS.md").write_text("\n".join(lines) + "\n") print(f"wrote {OUTDIR}/GRAPH.md and {OUTDIR}/CONTROLS.md:", len(panels), "panels,", len(seen), "groups in hierarchy")skills/procedural-blender-nodes/scripts/graph_shots.py7.7 KB1f6321ec2629upstream ↗
"""Screenshot node graphs of every kind in a real Blender UI (needs a display, not -b): blender my_effect.blend --python scripts/graph_shots.py -- --out shots/ \ --shots "TOP,MAT:*,WORLD,COMP,MODIFIER" [--prefix my_effect_] Shot specs (comma separated): TOP the Geometry Nodes group on the scene's hero object (the Nodes modifier with the most nodes) <group name> any node group by name: Geometry, Shader or Compositor group MAT:<name> a material's Shader Nodes; MAT:* = the material with the most nodes SGROUP:* the shader node group (e.g. an NK material group) with the most nodes, opened WORLD the scene's world shader COMP the scene's compositor graph MODIFIER the Properties editor on the hero object's modifier tab ALL every node tree the scene uses, in order: the hero group and its sub-groups depth first, then materials, shader groups, world, compositor; files are numbered NNN_<kind>_<name>.png for scripts/graph_atlas.py Each shot turns the largest area into the right editor, frames all nodes and saves <prefix><kind>.png (geometry, shader, world, compositor, modifier, or the slugged group name). Specs that do not exist in the file are skipped with a message. Quits when done. """ import pathlib, re, sys import bpy argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] opts = dict(zip(argv[::2], argv[1::2])) OUT = pathlib.Path(opts.get("--out", "out/graphs")).resolve() OUT.mkdir(parents=True, exist_ok=True) PREFIX = opts.get("--prefix", "") SPECS = [s.strip() for s in opts.get("--shots", opts.get("--groups", "TOP")).split(",") if s.strip()] QUEUE = [] def expand_all(): """Every tree the scene uses, hierarchy first: [(spec, stem)].""" _, ob, mod = hero() out, seen = [], set() def walk(ng): if ng is None or ng.name in seen: return seen.add(ng.name) out.append((ng.name, "geo_" + slug(ng.name))) for n in ng.nodes: if getattr(n, "node_tree", None) is not None and n.node_tree.bl_idname == "GeometryNodeTree": walk(n.node_tree) walk(mod.node_group if mod else None) for m in sorted((m for m in bpy.data.materials if m.node_tree and m.users), key=lambda m: m.name): out.append(("MAT:" + m.name, "mat_" + slug(m.name))) for g in sorted((g for g in bpy.data.node_groups if g.bl_idname == "ShaderNodeTree" and g.users), key=lambda g: g.name): out.append((g.name, "sgroup_" + slug(g.name))) out += [("WORLD", "world"), ("COMP", "compositor")] return [(spec, f"{i:03d}_{stem}") for i, (spec, stem) in enumerate(out)] STEMS = {} for s in SPECS: if s == "ALL": QUEUE.append("__ALL__") else: QUEUE.append(s) def slug(s): return re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_") def hero(): sc = bpy.context.window_manager.windows[0].scene cands = [(len(m.node_group.nodes), o, m) for o in sc.objects for m in o.modifiers if m.type == "NODES" and m.node_group] return max(cands, key=lambda c: c[0]) if cands else (0, None, None) def resolve(spec): """-> (ui_type, shader_type or None, node_tree or None, file stem) or None when absent.""" sc = bpy.context.window_manager.windows[0].scene if spec == "TOP": _, ob, mod = hero() return ("GeometryNodeTree", None, mod.node_group, "geometry") if mod else None if spec.startswith("MAT:"): name = spec[4:] mats = [m for m in bpy.data.materials if m.node_tree and m.users] mat = max(mats, key=lambda m: len(m.node_tree.nodes)) if name == "*" and mats else bpy.data.materials.get(name) return ("ShaderNodeTree", "OBJECT", mat.node_tree, "shader" if name == "*" else "shader_" + slug(name)) \ if mat and mat.node_tree else None if spec == "SGROUP:*": groups = [g for g in bpy.data.node_groups if g.bl_idname == "ShaderNodeTree" and g.users] g = max(groups, key=lambda g: len(g.nodes)) if groups else None return ("ShaderNodeTree", "OBJECT", g, "shader_group") if g else None if spec == "WORLD": return ("ShaderNodeTree", "WORLD", sc.world.node_tree, "world") if sc.world and sc.world.node_tree else None if spec == "COMP": ng = getattr(sc, "compositing_node_group", None) return ("CompositorNodeTree", None, ng, "compositor") if ng else None ng = bpy.data.node_groups.get(spec) if ng is None: return None ui = {"GeometryNodeTree": "GeometryNodeTree", "ShaderNodeTree": "ShaderNodeTree", "CompositorNodeTree": "CompositorNodeTree"}[ng.bl_idname] return (ui, "OBJECT" if ui == "ShaderNodeTree" else None, ng, slug(spec)) def big_area(): win = bpy.context.window_manager.windows[0] return win, max(win.screen.areas, key=lambda a: a.width * a.height) def shoot(): if not QUEUE: bpy.ops.wm.quit_blender() return None spec = QUEUE.pop(0) if spec == "__ALL__": items = expand_all() for sp, stem in reversed(items): QUEUE.insert(0, sp) STEMS.setdefault(sp, stem) print("ALL:", len(items), "trees") return 0.1 win, area = big_area() if spec == "MODIFIER": return shoot_modifier(win, area) target = resolve(spec) if target is None: print("SKIP", spec, "(not in this file)") return 0.2 ui, shader_type, tree, stem = target stem = STEMS.get(spec, stem) area.ui_type = ui def set_tree(): # the space only exists after the type switch redraws space = area.spaces.active if shader_type and hasattr(space, "shader_type"): space.shader_type = shader_type try: space.pin = True space.node_tree = tree except (AttributeError, TypeError, RuntimeError): pass # editor shows the scene's own tree (compositor) space.show_region_ui = False space.show_region_toolbar = False return None def frame_all(): region = next(r for r in area.regions if r.type == "WINDOW") with bpy.context.temp_override(window=win, area=area, region=region): bpy.ops.node.select_all(action="DESELECT") bpy.ops.node.view_all() return None def capture(): region = next(r for r in area.regions if r.type == "WINDOW") path = OUT / f"{PREFIX}{stem}.png" with bpy.context.temp_override(window=win, area=area, region=region): bpy.ops.screen.screenshot_area(filepath=str(path)) print("SHOT", spec, "->", path.name) return None bpy.app.timers.register(set_tree, first_interval=0.4) bpy.app.timers.register(frame_all, first_interval=1.0) bpy.app.timers.register(frame_all, first_interval=1.6) bpy.app.timers.register(capture, first_interval=2.4) return 3.2 def shoot_modifier(win, area): """The Properties editor on the hero object's modifier tab: the artist's control surface.""" _, ob, _ = hero() sc = win.scene for o in sc.objects: o.select_set(o == ob) bpy.context.view_layer.objects.active = ob area.ui_type = "PROPERTIES" def tab(): area.spaces.active.context = "MODIFIER" return None def capture(): region = next(r for r in area.regions if r.type == "WINDOW") with bpy.context.temp_override(window=win, area=area, region=region): bpy.ops.screen.screenshot_area(filepath=str(OUT / f"{PREFIX}modifier.png")) print("SHOT MODIFIER") return None bpy.app.timers.register(tab, first_interval=0.4) bpy.app.timers.register(capture, first_interval=1.6) return 2.4 bpy.app.timers.register(shoot, first_interval=2.0)skills/procedural-blender-nodes/scripts/nodes_tools.py7.4 KB3bc78165b2ceupstream ↗
#!/usr/bin/env python3 """procedural-blender-nodes command line. Plain Python 3; commands that need Blender start it themselves. python3 scripts/nodes_tools.py search curve fence rank library groups by name, summary, tags, uses python3 scripts/nodes_tools.py index [--check] regenerate references/library-index.md python3 scripts/nodes_tools.py check static rules, then build every group in Blender: lint, node warnings, evaluation on test inputs python3 scripts/nodes_tools.py test [-k name] numeric property tests (tests/ in the repository) python3 scripts/nodes_tools.py try -- <try_group args> build one group in a test scene (scripts/try_group.py) Blender is found as $BLENDER, else `blender` on PATH, else the macOS app bundle. """ import ast, os, pathlib, re, shutil, subprocess, sys SKILL = pathlib.Path(__file__).resolve().parents[1] LIB = SKILL / "library" REPO = SKILL.parents[1] def blender_exe(): for cand in (os.environ.get("BLENDER"), shutil.which("blender"), "/Applications/Blender.app/Contents/MacOS/Blender"): if cand and pathlib.Path(cand).exists(): return cand sys.exit("Blender not found: set $BLENDER or put `blender` on PATH (Blender 5.2+)") def run_blender(script, *args): cmd = [blender_exe(), "-b", "--factory-startup", "--python", str(script), "--", *args] return subprocess.run(cmd).returncode # ---- static reading of the library (no Blender) ----------------------------------------------------------- def module_order(): tree = ast.parse((LIB / "__init__.py").read_text()) for node in tree.body: if isinstance(node, ast.Assign) and node.targets[0].id == "MODULE_ORDER": return [e.value for e in node.value.elts] return [] def read_library(): """[{name, kind, tags, use, summary, module, builder, line, inputs}] from @nodegroup decorators.""" groups = [] for mod in module_order(): path = LIB / f"{mod}.py" if not path.exists(): continue tree = ast.parse(path.read_text()) for fn in [n for n in tree.body if isinstance(n, ast.FunctionDef)]: for dec in fn.decorator_list: if isinstance(dec, ast.Call) and getattr(dec.func, "id", "") == "nodegroup": meta = {"name": ast.literal_eval(dec.args[0]), "kind": "GEOMETRY", "tags": [], "use": "", "module": mod, "builder": fn.name, "line": fn.lineno} for kw in dec.keywords: meta[kw.arg] = ast.literal_eval(kw.value) doc = ast.get_docstring(fn) or "" meta["summary"] = doc.strip().splitlines()[0] if doc.strip() else "" meta["doc"] = doc meta["inputs"] = re.findall(r'\.input\(\s*"([^"]+)"', ast.get_source_segment(path.read_text(), fn)) groups.append(meta) return groups def cmd_search(words): words = [w.lower() for w in words] scored = [] for g in read_library(): hay = {"name": g["name"].lower(), "summary": g["summary"].lower(), "tags": " ".join(g["tags"]).lower(), "use": g["use"].lower()} score = sum(3 * (w in hay["name"]) + 2 * (w in hay["tags"]) + (w in hay["summary"]) + (w in hay["use"]) for w in words) if score: scored.append((score, g)) for score, g in sorted(scored, key=lambda x: -x[0])[:15]: print(f"{g['name']:<28} {g['kind']:<10} library/{g['module']}.py:{g['line']} {g['summary']}") print(f"{'':28} use: {g['use']}") if not scored: print("no match; try broader words, or read references/library-index.md") def render_index(): groups = read_library() out = ["# Library index", "", "Generated by `python3 scripts/nodes_tools.py index`; do not edit by hand. Build a group with its", "builder (`from library.<module> import <builder>; tree = <builder>()`) and instance it with", "`Graph.group(tree, ...)`.", ""] for mod in module_order(): gs = [g for g in groups if g["module"] == mod] if not gs: continue out += [f"## {mod}", "", "| Group | Kind | Builder | Inputs | Summary | Use for |", "|---|---|---|---|---|---|"] for g in gs: out.append(f"| `{g['name']}` | {g['kind'].lower()} | `{g['builder']}()` | " f"{', '.join(g['inputs'][:10])}{' …' if len(g['inputs']) > 10 else ''} | {g['summary']} | " f"{g['use']} |") out.append("") out.append(f"{len(groups)} groups in {len([m for m in module_order() if (LIB / f'{m}.py').exists()])} modules.") return "\n".join(out) + "\n" def cmd_index(check=False): path = SKILL / "references" / "library-index.md" text = render_index() if check: if not path.exists() or path.read_text() != text: sys.exit("references/library-index.md is stale: run `python3 scripts/nodes_tools.py index`") print("library-index.md is up to date") return path.write_text(text) print("wrote", path.relative_to(SKILL)) def static_check(): problems = [] order = module_order() names = {} for g in read_library(): where = f"library/{g['module']}.py:{g['line']}" if not g["name"].startswith("NK "): problems.append(f"{where}: group name {g['name']!r} must start with 'NK '") if g["name"] in names: problems.append(f"{where}: duplicate group name {g['name']!r} (also {names[g['name']]})") names[g["name"]] = where if not g["summary"]: problems.append(f"{where}: {g['builder']}() needs a one-line docstring summary") if not g["use"]: problems.append(f"{where}: {g['name']} needs use=... naming concrete subjects") for i, mod in enumerate(order): path = LIB / f"{mod}.py" if not path.exists(): continue for imp in re.findall(r"^\s*(?:from\s+library\.(\w+)|from\s+library\s+import\s+(\w+))", path.read_text(), re.M): dep = imp[0] or imp[1] if dep in order and order.index(dep) >= i: problems.append(f"library/{mod}.py imports library.{dep}, which is not earlier in MODULE_ORDER") return problems def cmd_check(): problems = static_check() for p in problems: print("ERROR", p) print(f"static: {len(problems)} problem(s)") rc = run_blender(SKILL / "scripts" / "_check_in_blender.py") sys.exit(1 if problems or rc else 0) def main(): if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): print(__doc__) return cmd, rest = sys.argv[1], sys.argv[2:] if cmd == "search": cmd_search(rest) elif cmd == "index": cmd_index("--check" in rest) elif cmd == "check": cmd_check() elif cmd == "test": runner = REPO / "tests" / "run_tests.py" if not runner.exists(): sys.exit("the property tests live in the repository (tests/), not in the installed skill: clone " "https://github.com/gokayfem/video-to-blender and run this from there") sys.exit(run_blender(runner, *rest)) elif cmd == "try": sys.exit(run_blender(SKILL / "scripts" / "try_group.py", *[r for r in rest if r != "--"])) else: sys.exit(f"unknown command {cmd!r}; see --help") if __name__ == "__main__": main()skills/procedural-blender-nodes/scripts/render.py3.0 KB56350bc58d2dupstream ↗
"""Render stills or sequences from the built .blend, optionally with control overrides (variations). blender -b my_effect.blend --python tools/render.py -- --scene Reference \ --frames 1,97,193 --width 640 --samples 8 --out out/lead/stills/f_ blender -b my_effect.blend --python tools/render.py -- --scene Reference \ --range 1:481:1 --width 1280 --out out/lead/seq/f_ # variations: --preset path/to/preset.json (or a name in ./presets/) and/or --set "Width=6" --set "Seed=7" # --report prints the evaluated stats and the camera position for each frame instead of rendering """ import argparse, json, pathlib, sys ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) import bpy from nodekit import inspect argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser() ap.add_argument("--scene", default="Reference") ap.add_argument("--frames", default="") ap.add_argument("--range", default="") ap.add_argument("--width", type=int, default=640) ap.add_argument("--samples", type=int, default=8) ap.add_argument("--out", default="out/lead/r_") ap.add_argument("--preset", default="") ap.add_argument("--set", action="append", default=[]) ap.add_argument("--report", action="store_true") ap.add_argument("--engine", default="") ap.add_argument("--camera", default="", help="camera object name (default: the scene camera)") a = ap.parse_args(argv) sc = bpy.data.scenes[a.scene] if bpy.context.window: bpy.context.window.scene = sc street = next(o for o in sc.objects if o.type == "MESH" and any(m.type == "NODES" for m in o.modifiers)) mod = next(m for m in street.modifiers if m.type == "NODES") values = {} if a.preset: pp = pathlib.Path(a.preset) if not pp.exists(): # a bare name looks in ./presets/<name>.json pp = pathlib.Path("presets") / f"{a.preset}.json" values.update(json.loads(pp.read_text())) for kv in a.set: k, v = kv.split("=", 1) values[k] = json.loads(v) if values: inspect.set_modifier_inputs(mod, values) if a.camera: sc.camera = bpy.data.objects[a.camera] r = sc.render r.resolution_x = a.width r.resolution_y = int(round(a.width * 704 / 1280 / 2)) * 2 r.resolution_percentage = 100 if a.engine: r.engine = a.engine if hasattr(sc, "eevee"): sc.eevee.taa_render_samples = a.samples if a.range: s, e, st = (int(x) for x in a.range.split(":")) frames = list(range(s, e + 1, st)) else: frames = [int(f) for f in a.frames.split(",") if f] or [sc.frame_current] out = pathlib.Path(a.out) out.parent.mkdir(parents=True, exist_ok=True) for f in frames: sc.frame_set(f) if a.report: cam = sc.camera print(f"FRAME {f} cam {tuple(round(x, 3) for x in cam.matrix_world.translation)} " f"stats {inspect.evaluated_stats(street, sc)}") continue r.filepath = str(out.resolve()) + f"{f:04d}" bpy.ops.render.render(write_still=True, scene=sc.name) print("RENDERED", r.filepath + ".png")skills/procedural-blender-nodes/scripts/smoke_graph.py1.2 KB09bdf922ee3aupstream ↗
"""Smoke test for v2n.graph: blender -b --factory-startup --python tools/smoke_graph.py""" import sys, pathlib sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) import bpy from nodekit.graph import Graph from nodekit import inspect g = Graph("T Grid", description="Test grid", modifier=True) g.input("Geometry", "GEOMETRY", desc="unused") w = g.input("Width", "FLOAT", 4.0, min=0.5, max=20, subtype="DISTANCE", panel="Street", desc="Grid width") with g.frame("Grid"): grid = g.node("GeometryNodeMeshGrid", **{"Size X": w, "Size Y": g.math("MULTIPLY", w, 2.0), "Vertices X": 8}) pos = g.node("GeometryNodeInputPosition") z = g.math("SINE", g.separate(pos)["X"]) sp = g.node("GeometryNodeSetPosition", Geometry=grid["Mesh"], Offset=g.combine(0, 0, z)) g.output("Geometry", "GEOMETRY", sp["Geometry"]) g.finish() bpy.ops.mesh.primitive_plane_add() o = bpy.context.object m = o.modifiers.new("T", "NODES") m.node_group = g.nt inspect.set_modifier_inputs(m, {"Width": 6.0}) print("STATS", inspect.evaluated_stats(o), inspect.get_modifier_inputs(m)) print("CHECK", inspect.check_groups("T ")) d = inspect.dump_groups("/dev/stdout" if False else "/tmp/v2n_smoke.json", "T ") print("NODES", [n["type"] for n in d[0]["nodes"]])skills/procedural-blender-nodes/scripts/sweep.py2.1 KB3f2846209e5aupstream ↗
"""Render a control sweep: one moment of the street while a control moves between values (the "edit the graph" part of the demo). blender -b my_effect.blend --python tools/sweep.py -- --control "Timber Amount" \ --values 0,1.7 --frames 48 --at 169 --out out/demo/sweep/timber_ [--scene Reference] [--width 960] Values are interpolated with smoothstep (integers round). Each rendered frame is a full re-evaluation of the node graph with that control value; nothing is baked. """ import argparse, pathlib, sys ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) import bpy from nodekit import inspect argv = sys.argv[sys.argv.index("--") + 1:] ap = argparse.ArgumentParser() ap.add_argument("--control", required=True) ap.add_argument("--values", required=True) ap.add_argument("--frames", type=int, default=48) ap.add_argument("--at", type=int, default=169) ap.add_argument("--scene", default="Reference") ap.add_argument("--camera", default="") ap.add_argument("--width", type=int, default=960) ap.add_argument("--samples", type=int, default=8) ap.add_argument("--out", required=True) a = ap.parse_args(argv) sc = bpy.data.scenes[a.scene] if bpy.context.window: bpy.context.window.scene = sc if a.camera: sc.camera = bpy.data.objects[a.camera] street = next(o for o in sc.objects if o.type == "MESH" and any(m.type == "NODES" for m in o.modifiers)) mod = next(m for m in street.modifiers if m.type == "NODES") v0, v1 = (float(x) for x in a.values.split(",")) is_int = isinstance(inspect.get_modifier_inputs(mod)[a.control], int) sc.render.resolution_x = a.width sc.render.resolution_y = int(round(a.width * 704 / 1280 / 2)) * 2 sc.eevee.taa_render_samples = a.samples sc.frame_set(a.at) out = pathlib.Path(a.out) out.parent.mkdir(parents=True, exist_ok=True) for i in range(a.frames): u = i / max(1, a.frames - 1) u = u * u * (3 - 2 * u) v = v0 + (v1 - v0) * u inspect.set_modifier_inputs(mod, {a.control: int(round(v)) if is_int else v}) sc.frame_set(a.at) sc.render.filepath = str(out.resolve()) + f"{i + 1:04d}" bpy.ops.render.render(write_still=True, scene=sc.name) print("SWEEP", i + 1, a.control, round(v, 3))skills/procedural-blender-nodes/scripts/try_group.py3.6 KB11860a8e460cupstream ↗
"""Build ONE street group in a fresh scene, wire it to a test path, report stats and lint, optionally render. blender -b --factory-startup --python scripts/try_group.py -- --module groups.my_part --root path/to/project \ --path reference --frame 1 --png out/try/part.png --cam 0,-6,1.6:0,8,1.0 --width 640 # --set "Street Width=5" --set "Seed=3" override group inputs by name # --save out/try/part.blend keep the test file to open in the UI The module must expose build() returning its top node group. The group's 'Curve' input gets the path curve (via Object Info), 'Time' gets scene time in seconds (frame / 24), and other inputs keep their defaults unless --set. Extra geometry inputs (e.g. 'Anchors') are left empty unless the module exposes test_inputs(graph) that wires them. """ import argparse, importlib, json, pathlib, sys ROOT = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) import bpy from nodekit.graph import Graph from nodekit import inspect from nodekit import scene argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser() ap.add_argument("--module", required=True) ap.add_argument("--root", default=".", help="directory the module is imported from (default: cwd)") ap.add_argument("--path", default="reference") ap.add_argument("--frame", type=int, default=1) ap.add_argument("--png") ap.add_argument("--save") ap.add_argument("--cam", default="0,-6,1.6:0,8,1.0", help="camera loc:target") ap.add_argument("--lens", type=float, default=24) ap.add_argument("--width", type=int, default=640) ap.add_argument("--samples", type=int, default=8) ap.add_argument("--set", action="append", default=[]) a = ap.parse_args(argv) sys.path.insert(0, str(pathlib.Path(a.root).resolve())) scene.reset() path = scene.path_curve(a.path) mod_py = importlib.import_module(a.module) top = mod_py.build() # wrapper: Object Info(path) -> group, Scene Time -> Time w = Graph("TEST Wrapper", description="test wrapper", modifier=True) info = w.node("GeometryNodeObjectInfo", props={"transform_space": "RELATIVE"}, Object=path) t = w.node("GeometryNodeInputSceneTime") gn = w.group(top) names = [s.name for s in gn.b.inputs if s.enabled] if "Curve" in names: w.connect(info["Geometry"], gn.i("Curve")) if "Time" in names: w.connect(t["Seconds"], gn.i("Time")) for kv in a.set: k, v = kv.split("=", 1) v = json.loads(v) w.connect(v, gn.i(k)) if hasattr(mod_py, "test_inputs"): mod_py.test_inputs(w, gn, info) outs = [s for s in gn.b.outputs if s.enabled and s.type == "GEOMETRY"] j = w.node("GeometryNodeJoinGeometry") for s in outs: w.connect(s, j.i("Geometry")) real = w.node("GeometryNodeRealizeInstances", Geometry=j["Geometry"]) w.output("Geometry", "GEOMETRY", real["Geometry"]) w.finish() ob, mod = scene.holder("Test Street", path, w.nt) bpy.context.scene.frame_set(a.frame) scene.light_world() loc, tgt = [tuple(float(x) for x in p.split(",")) for p in a.cam.split(":")] scene.camera(lens=a.lens, loc=loc, target=tgt) scene.render_setup(a.width, int(a.width * 704 / 1280) // 2 * 2, a.samples) print("STATS", json.dumps(inspect.evaluated_stats(ob))) print("WARNINGS", [(w_.type, w_.message) for w_ in mod.node_warnings]) print("LINT", json.dumps(inspect.check_groups(""))) if a.png: pathlib.Path(a.png).parent.mkdir(parents=True, exist_ok=True) bpy.context.scene.render.filepath = str(pathlib.Path(a.png).resolve()) bpy.ops.render.render(write_still=True) print("PNG", a.png) if a.save: pathlib.Path(a.save).parent.mkdir(parents=True, exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=str(pathlib.Path(a.save).resolve()))skills/procedural-blender-nodes/scripts/ui_shot.py4.4 KBa8c8fa10a096upstream ↗
"""Screenshot an effect inside Blender's own UI (needs a display, not -b): the Geometry Nodes workspace with the viewport looking through the scene camera, the effect's node graph, and its modifier panel. blender my_effect.blend -p 0 0 1920 1080 --python scripts/ui_shot.py -- --out shots/my_effect_ui.png \ [--scene NAME] [--frame 100] [--shading MATERIAL|RENDERED|SOLID] The hero object is the scene's object with a Nodes modifier whose group has the most nodes. Frames are stepped from the start (simulation zones need it). The left area shows the most complex material's Shader Nodes. For the node graph alone, use scripts/graph_shots.py. Quits when done. """ import pathlib, sys import bpy argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] opts = dict(zip(argv[::2], argv[1::2])) OUT = pathlib.Path(opts["--out"]).resolve() OUT.parent.mkdir(parents=True, exist_ok=True) SHADING = opts.get("--shading", "MATERIAL") state = {"step": 0} def hero(sc): cands = [(len(m.node_group.nodes), o) for o in sc.objects for m in o.modifiers if m.type == "NODES" and m.node_group] return max(cands, key=lambda c: c[0])[1] if cands else None def setup(): win = bpy.context.window_manager.windows[0] sc = bpy.data.scenes.get(opts.get("--scene", "")) or bpy.data.scenes[0] win.scene = sc ws = bpy.data.workspaces.get("Geometry Nodes") if ws: win.workspace = ws frame = int(opts.get("--frame", sc.frame_current)) for f in range(sc.frame_start, frame + 1): # step: simulation zones need every frame sc.frame_set(f) ob = hero(sc) for o in sc.objects: o.select_set(o == ob) bpy.context.view_layer.objects.active = ob return win, sc, ob def arrange(win, sc, ob): areas = sorted(win.screen.areas, key=lambda a: a.width * a.height, reverse=True) for area in win.screen.areas: if area.type == "SPREADSHEET" or area == state.get("left"): # the spreadsheet becomes a Shader Editor on the most complex material (type first, tree next pass) state["left"] = area mats = [m for m in bpy.data.materials if m.node_tree and m.users] if not mats: continue if area.ui_type != "ShaderNodeTree": area.ui_type = "ShaderNodeTree" continue sp = area.spaces.active sp.shader_type = "OBJECT" sp.pin = True sp.node_tree = max(mats, key=lambda m: len(m.node_tree.nodes)).node_tree sp.show_region_ui = False sp.show_region_toolbar = False with bpy.context.temp_override(window=win, area=area, region=next(r for r in area.regions if r.type == "WINDOW")): bpy.ops.node.view_all() continue if area.type == "PROPERTIES": area.spaces.active.context = "MODIFIER" if area.type == "VIEW_3D": sp = area.spaces.active sp.shading.type = SHADING sp.overlay.show_overlays = False sp.show_gizmo = False sp.region_3d.view_perspective = "CAMERA" with bpy.context.temp_override(window=win, area=area, region=next(r for r in area.regions if r.type == "WINDOW")): bpy.ops.view3d.view_center_camera() if area.type == "NODE_EDITOR" and area != state.get("left"): area.ui_type = "GeometryNodeTree" sp = area.spaces.active sp.show_region_ui = False sp.show_region_toolbar = False with bpy.context.temp_override(window=win, area=area, region=next(r for r in area.regions if r.type == "WINDOW")): bpy.ops.node.select_all(action="DESELECT") bpy.ops.node.view_all() return areas def tick(): s = state["step"] state["step"] += 1 if s == 0: state["ctx"] = setup() return 1.0 win, sc, ob = state["ctx"] if s in (1, 2, 3): arrange(win, sc, ob) # thrice: editors switch type, get a tree, then frame return 2.5 if s == 3 else 1.0 if s == 4: with bpy.context.temp_override(window=win): bpy.ops.screen.screenshot(filepath=str(OUT)) print("UI SHOT", OUT) bpy.ops.wm.quit_blender() return None bpy.app.timers.register(tick, first_interval=2.0)skills/procedural-blender-nodes/scripts/validate.py5.7 KB980f1d687079upstream ↗
"""Validate a saved .blend the way a user meets it: reopen, evaluate every scene, check the cameras, lint graphs. blender -b my_effect.blend --python scripts/validate.py -- [--step 24] [--out out/validate] [--hide "Show Walker"] [--min-clear 0.25] [--prefix "NK "] [--no-cameras] For every scene with a Geometry Nodes object: - node warnings on every Nodes modifier, evaluated vertex/face counts and evaluation time at the first frame; - every --step frames, each camera's clearance: distance to the nearest evaluated geometry (BVH built once at the first frame from all Nodes objects; animated parts can be switched off for it with --hide <bool input>), and whether the camera is inside a closed mesh (odd number of hits on an upward ray); - node-group lint (nodekit.inspect.check_groups) for groups whose names start with --prefix (default: all). Writes report.json, prints a summary, exits 1 on any failure. Clearance is checked on the curves and cameras in the file only; it is evidence, not a proof for every input. """ import argparse, json, pathlib, sys, time SKILL = pathlib.Path(__file__).resolve().parents[1] sys.path.insert(0, str(SKILL)) import bpy from mathutils import Vector from mathutils.bvhtree import BVHTree from nodekit import inspect argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser() ap.add_argument("--step", type=int, default=24) ap.add_argument("--out", default="out/validate") ap.add_argument("--hide", action="append", default=[], help="bool modifier input set False for the BVH") ap.add_argument("--min-clear", type=float, default=0.25) ap.add_argument("--prefix", default="") ap.add_argument("--no-cameras", action="store_true") a = ap.parse_args(argv) OUT = pathlib.Path(a.out) OUT.mkdir(parents=True, exist_ok=True) report, failures = {"file": bpy.data.filepath, "scenes": {}}, [] def inside(bvh, p): hits, origin = 0, p.copy() for _ in range(64): loc, nrm, idx, dist = bvh.ray_cast(origin, Vector((0, 0, 1))) if loc is None: break hits += 1 origin = loc + Vector((0, 0, 1e-4)) return hits % 2 == 1 def nodes_objects(sc): return [(o, m) for o in sc.objects for m in o.modifiers if m.type == "NODES" and m.node_group] for sc in bpy.data.scenes: objs = nodes_objects(sc) if not objs: continue if bpy.context.window: # drivers (camera rigs) only update in the window's scene bpy.context.window.scene = sc t0 = time.time() sc.frame_set(sc.frame_start) dg = sc.view_layers[0].depsgraph dg.update() rec = {"objects": {}, "eval_s": None} for o, m in objs: ev = o.evaluated_get(dg) me = ev.to_mesh() rec["objects"][o.name] = {"modifier": m.name, "verts": len(me.vertices), "faces": len(me.polygons), "warnings": [w.message for w in m.node_warnings]} ev.to_mesh_clear() if rec["objects"][o.name]["warnings"]: failures.append(f"{sc.name}/{o.name}: node warnings {rec['objects'][o.name]['warnings']}") rec["eval_s"] = round(time.time() - t0, 3) cams = [] if a.no_cameras else [o for o in sc.objects if o.type == "CAMERA"] if cams: hidden = [] for o, m in objs: ids = inspect.modifier_inputs(m) vals = {h: False for h in a.hide if h in ids} if vals: hidden.append((m, {h: inspect.get_modifier_inputs(m)[h] for h in vals})) inspect.set_modifier_inputs(m, vals) dg.update() verts, polys = [], [] for o, m in objs: ev = o.evaluated_get(dg) me = ev.to_mesh() mw = o.matrix_world base = len(verts) verts += [mw @ v.co for v in me.vertices] polys += [tuple(base + i for i in p.vertices) for p in me.polygons] ev.to_mesh_clear() for m, vals in hidden: inspect.set_modifier_inputs(m, vals) bvh = BVHTree.FromPolygons(verts, polys) if polys else None rec["cameras"] = {} for cam in cams: samples = [] for f in range(sc.frame_start, sc.frame_end + 1, a.step): sc.frame_set(f) p = cam.matrix_world.translation.copy() loc, nrm, idx, dist = bvh.find_nearest(p) if bvh else (None, None, None, None) samples.append({"frame": f, "clearance": None if dist is None else round(dist, 3), "inside": bool(bvh) and inside(bvh, p)}) clears = [s["clearance"] for s in samples if s["clearance"] is not None] c = {"min_clearance": min(clears) if clears else None, "inside_frames": [s["frame"] for s in samples if s["inside"]], "samples": samples} rec["cameras"][cam.name] = c if c["inside_frames"]: failures.append(f"{sc.name}/{cam.name}: inside geometry at frames {c['inside_frames']}") if c["min_clearance"] is not None and c["min_clearance"] < a.min_clear: failures.append(f"{sc.name}/{cam.name}: clearance {c['min_clearance']} < {a.min_clear}") report["scenes"][sc.name] = rec cam_txt = ", ".join(f"{k} min clear {v['min_clearance']}" for k, v in rec.get("cameras", {}).items()) print(f"SCENE {sc.name}: " + ", ".join(f"{k} {v['verts']} verts {len(v['warnings'])} warnings" for k, v in rec["objects"].items()) + f", eval {rec['eval_s']}s; {cam_txt}") lint = inspect.check_groups(a.prefix) report["lint"] = lint failures += [f"lint: {x}" for x in lint] report["failures"] = failures (OUT / "report.json").write_text(json.dumps(report, indent=1)) print("FAILURES", len(failures)) for x in failures: print(" ", x) sys.exit(1 if failures else 0)skills/procedural-blender-nodes/scripts/video.py19 KB8043abdeeec3upstream ↗
#!/usr/bin/env python3 """Video helpers for comparing the reference clip with our renders (see docs/COMPARE.md). Dependencies: ffmpeg/ffprobe on PATH and numpy. PIL and matplotlib are NOT needed: images are decoded through ffmpeg pipes, PNGs are written with zlib, and labels use a built-in 5x7 bitmap font (this ffmpeg has no drawtext). If the running python lacks numpy, the script re-executes itself with Blender's bundled python (or $V2N_PYTHON). Frame convention: a render sequence is a directory of f_####.png where frame N shows time (N-1)/fps. A sparse directory (only some N present) is fine for stills/metrics; videos need a dense run of frames. python3 tools/video.py extract --src reference/source/village_source.mp4 --times 0:20:1 --out out/compare/ref_1s python3 tools/video.py assemble --seq out/lead/seq --out out/lead/seq.mp4 python3 tools/video.py sbs --ref reference/source/village_source.mp4 --render out/lead/seq \ --out out/compare/lead_sbs.mp4 [--stack v] [--start 0 --end 20] [--scale 0.5] python3 tools/video.py sheet --ref reference/source/village_source.mp4 --render out/lead/seq \ --times 0:20:2.5 --out out/compare/lead_sheet.png """ import glob import json import os import re import struct import subprocess import sys import zlib def require_numpy(): """Import numpy, re-executing this script under a python that has it if needed.""" try: import numpy # noqa: F401 return except ImportError: pass if os.environ.get("_V2N_REEXEC"): sys.exit("numpy not available (tried %s)" % sys.executable) cands = [os.environ.get("V2N_PYTHON", "")] cands += sorted(glob.glob("/Applications/Blender.app/Contents/Resources/*/python/bin/python3.*[0-9]"), reverse=True) for c in cands: if c and os.path.isfile(c) and os.access(c, os.X_OK): os.environ["_V2N_REEXEC"] = "1" os.execv(c, [c, os.path.abspath(sys.argv[0])] + sys.argv[1:]) sys.exit("numpy not found; set V2N_PYTHON to a python3 with numpy (e.g. Blender's bundled python)") require_numpy() import numpy as np # noqa: E402 SEQ_RE = re.compile(r"f_(\d{4,})\.png$") # --------------------------------------------------------------------------------------------------------------- # sources def probe(path): out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets", "-show_entries", "stream=width,height,r_frame_rate,nb_read_packets", "-of", "json", path], capture_output=True, text=True, check=True).stdout s = json.loads(out)["streams"][0] num, den = s["r_frame_rate"].split("/") return int(s["width"]), int(s["height"]), float(num) / float(den), int(s.get("nb_read_packets", 0)) class Source: """An mp4 (or any ffmpeg-readable video) or a directory of f_####.png. Frame index i is 0-based (= N-1).""" def __init__(self, path, fps=24.0): self.path = path self.is_dir = os.path.isdir(path) if self.is_dir: self.files = {} for f in os.listdir(path): m = SEQ_RE.match(f) if m: self.files[int(m.group(1)) - 1] = os.path.join(path, f) if not self.files: raise SystemExit("no f_####.png frames in %s" % path) self.fps = fps self.width, self.height, _, _ = probe(self.files[min(self.files)]) self.n_frames = max(self.files) + 1 else: self.width, self.height, self.fps, self.n_frames = probe(path) self.name = os.path.basename(os.path.normpath(path)) def has(self, i): return (i in self.files) if self.is_dir else (0 <= i < self.n_frames) def index(self, t): return int(round(t * self.fps)) def read(self, indices, size=None): """Return {i: uint8 HxWx3} for the requested frame indices that exist, scaled to size=(w, h).""" want = sorted({i for i in indices if self.has(i)}) w, h = size or (self.width, self.height) vf = ["-vf", "scale=%d:%d:flags=area" % (w, h)] if (w, h) != (self.width, self.height) else [] nbytes = w * h * 3 out = {} if self.is_dir: for i in want: raw = subprocess.run(["ffmpeg", "-v", "error", "-i", self.files[i]] + vf + ["-f", "rawvideo", "-pix_fmt", "rgb24", "-"], capture_output=True, check=True).stdout out[i] = np.frombuffer(raw[:nbytes], np.uint8).reshape(h, w, 3) return out if not want: return out wanted = set(want) last = want[-1] p = subprocess.Popen(["ffmpeg", "-v", "error", "-i", self.path, "-fps_mode", "passthrough"] + vf + ["-frames:v", str(last + 1), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], stdout=subprocess.PIPE) i = 0 while i <= last: buf = p.stdout.read(nbytes) if len(buf) < nbytes: break if i in wanted: out[i] = np.frombuffer(buf, np.uint8).reshape(h, w, 3) i += 1 p.stdout.close() p.wait() return out def stream(self, start, end, fps, size): """Yield (t, frame or None) at fps from start to end (exclusive) for videos; dense sequences for dirs.""" w, h = size nbytes = w * h * 3 n = int(round((end - start) * fps)) if self.is_dir: first = int(round(start * self.fps)) cmd = ["ffmpeg", "-v", "error", "-framerate", str(self.fps), "-start_number", str(first + 1), "-i", os.path.join(self.path, "f_%04d.png"), "-vf", "fps=%g,scale=%d:%d:flags=area" % (fps, w, h), "-frames:v", str(n), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"] if not self.has(first): cmd = None else: cmd = ["ffmpeg", "-v", "error", "-ss", "%.4f" % start, "-i", self.path, "-vf", "fps=%g,scale=%d:%d:flags=area" % (fps, w, h), "-frames:v", str(n), "-f", "rawvideo", "-pix_fmt", "rgb24", "-"] p = subprocess.Popen(cmd, stdout=subprocess.PIPE) if cmd else None for k in range(n): buf = p.stdout.read(nbytes) if p else b"" frame = np.frombuffer(buf, np.uint8).reshape(h, w, 3) if len(buf) == nbytes else None yield start + k / fps, frame if p: p.stdout.close() p.wait() def parse_times(spec): """'a:b:step' (inclusive of b when it lands on the grid) or 'a,b,c'.""" if ":" in spec: a, b, s = (float(x) for x in spec.split(":")) n = int(np.floor((b - a) / s + 1e-6)) + 1 return [round(a + k * s, 6) for k in range(n)] return [float(x) for x in spec.split(",") if x.strip()] # --------------------------------------------------------------------------------------------------------------- # PNG writing and a tiny bitmap font def write_png(path, img): img = np.ascontiguousarray(img, dtype=np.uint8) h, w = img.shape[:2] raw = b"".join(b"\x00" + img[y].tobytes() for y in range(h)) def chunk(tag, data): return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff) d = os.path.dirname(path) if d: os.makedirs(d, exist_ok=True) with open(path, "wb") as f: f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 4)) + chunk(b"IEND", b"")) _GLYPHS = { "0": ".###. #...# #..## #.#.# ##..# #...# .###.", "1": "..#.. .##.. ..#.. ..#.. ..#.. ..#.. .###.", "2": ".###. #...# ....# ...#. ..#.. .#... #####", "3": "####. ....# ....# .###. ....# ....# ####.", "4": "...#. ..##. .#.#. #..#. ##### ...#. ...#.", "5": "##### #.... ####. ....# ....# #...# .###.", "6": "..##. .#... #.... ####. #...# #...# .###.", "7": "##### ....# ...#. ..#.. .#... .#... .#...", "8": ".###. #...# #...# .###. #...# #...# .###.", "9": ".###. #...# #...# .#### ....# ...#. .##..", "A": ".###. #...# #...# ##### #...# #...# #...#", "B": "####. #...# #...# ####. #...# #...# ####.", "C": ".###. #...# #.... #.... #.... #...# .###.", "D": "####. #...# #...# #...# #...# #...# ####.", "E": "##### #.... #.... ####. #.... #.... #####", "F": "##### #.... #.... ####. #.... #.... #....", "G": ".###. #...# #.... #.### #...# #...# .####", "H": "#...# #...# #...# ##### #...# #...# #...#", "I": ".###. ..#.. ..#.. ..#.. ..#.. ..#.. .###.", "J": "..### ...#. ...#. ...#. ...#. #..#. .##..", "K": "#...# #..#. #.#.. ##... #.#.. #..#. #...#", "L": "#.... #.... #.... #.... #.... #.... #####", "M": "#...# ##.## #.#.# #.#.# #...# #...# #...#", "N": "#...# #...# ##..# #.#.# #..## #...# #...#", "O": ".###. #...# #...# #...# #...# #...# .###.", "P": "####. #...# #...# ####. #.... #.... #....", "Q": ".###. #...# #...# #...# #.#.# #..#. .##.#", "R": "####. #...# #...# ####. #.#.. #..#. #...#", "S": ".#### #.... #.... .###. ....# ....# ####.", "T": "##### ..#.. ..#.. ..#.. ..#.. ..#.. ..#..", "U": "#...# #...# #...# #...# #...# #...# .###.", "V": "#...# #...# #...# #...# #...# .#.#. ..#..", "W": "#...# #...# #...# #.#.# #.#.# #.#.# .#.#.", "X": "#...# #...# .#.#. ..#.. .#.#. #...# #...#", "Y": "#...# #...# .#.#. ..#.. ..#.. ..#.. ..#..", "Z": "##### ....# ...#. ..#.. .#... #.... #####", " ": "..... ..... ..... ..... ..... ..... .....", ".": "..... ..... ..... ..... ..... .##.. .##..", ",": "..... ..... ..... ..... .##.. ..#.. .#...", ":": "..... .##.. .##.. ..... .##.. .##.. .....", "-": "..... ..... ..... ##### ..... ..... .....", "+": "..... ..#.. ..#.. ##### ..#.. ..#.. .....", "_": "..... ..... ..... ..... ..... ..... #####", "/": "....# ....# ...#. ..#.. .#... #.... #....", "(": "...#. ..#.. .#... .#... .#... ..#.. ...#.", ")": ".#... ..#.. ...#. ...#. ...#. ..#.. .#...", "%": "##..# ##..# ...#. ..#.. .#... #..## #..##", "=": "..... ..... ##### ..... ##### ..... .....", "|": "..#.. ..#.. ..#.. ..#.. ..#.. ..#.. ..#..", "<": "...#. ..#.. .#... #.... .#... ..#.. ...#.", ">": ".#... ..#.. ...#. ....# ...#. ..#.. .#...", "[": ".###. .#... .#... .#... .#... .#... .###.", "]": ".###. ...#. ...#. ...#. ...#. ...#. .###.", "#": ".#.#. .#.#. ##### .#.#. ##### .#.#. .#.#.", "'": "..#.. ..#.. .#... ..... ..... ..... .....", "*": "..... ..#.. #.#.# .###. #.#.# ..#.. .....", "@": ".###. #...# #.### #.#.# #.##. #.... .###.", "?": ".###. #...# ....# ...#. ..#.. ..... ..#..", "!": "..#.. ..#.. ..#.. ..#.. ..#.. ..... ..#..", } _GLYPH_BITS = {c: np.array([[ch == "#" for ch in row] for row in g.split()], bool) for c, g in _GLYPHS.items()} def text_size(text, scale=2): return len(text) * 6 * scale, 7 * scale def draw_text(img, x, y, text, color=(0, 0, 0), scale=2, bg=None, pad=None): """Draw upper-cased text with the 5x7 font; (x, y) is the top-left corner. Clipped to the image.""" h, w = img.shape[:2] if bg is not None: p = scale * 2 if pad is None else pad tw, th = text_size(text, scale) fill_rect(img, x - p, y - p, x + tw + p - scale, y + th + p, bg) for k, ch in enumerate(text.upper()): bits = _GLYPH_BITS.get(ch, _GLYPH_BITS["?"]) big = np.kron(bits, np.ones((scale, scale), bool)) x0, y0 = x + k * 6 * scale, y x1, y1 = x0 + big.shape[1], y0 + big.shape[0] cx0, cy0, cx1, cy1 = max(x0, 0), max(y0, 0), min(x1, w), min(y1, h) if cx0 >= cx1 or cy0 >= cy1: continue m = big[cy0 - y0:cy1 - y0, cx0 - x0:cx1 - x0] img[cy0:cy1, cx0:cx1][m] = color def fill_rect(img, x0, y0, x1, y1, color): h, w = img.shape[:2] x0, y0, x1, y1 = max(int(x0), 0), max(int(y0), 0), min(int(x1), w), min(int(y1), h) if x0 < x1 and y0 < y1: img[y0:y1, x0:x1] = color # --------------------------------------------------------------------------------------------------------------- # commands def extract(src, times, out_dir, width=None): s = Source(src) size = None if width: size = (width, int(round(width * s.height / s.width / 2)) * 2) idx = {t: s.index(t) for t in times} frames = s.read(idx.values(), size) os.makedirs(out_dir, exist_ok=True) for t, i in idx.items(): if i in frames: write_png(os.path.join(out_dir, "f_%04d.png" % (i + 1)), frames[i]) print("wrote %d frames to %s (f_N.png, N-1 = round(t*%g))" % (len(frames), out_dir, s.fps)) def assemble(seq_dir, out, fps=24.0, crf=18): s = Source(seq_dir, fps) first = min(s.files) subprocess.run(["ffmpeg", "-v", "error", "-y", "-framerate", str(fps), "-start_number", str(first + 1), "-i", os.path.join(seq_dir, "f_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", str(crf), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", out], check=True) print("wrote", out) def _pane_size(src_ref, scale): w = int(round(src_ref.width * scale / 2)) * 2 h = int(round(src_ref.height * scale / 2)) * 2 return w, h def side_by_side(ref, render, out, start=0.0, end=None, stack="h", scale=0.5, fps=24.0, labels=("REFERENCE", None), render_offset=0.0, crf=18): """Reference | render (or stacked), synchronized by time, with labels and a timecode drawn in.""" r, q = Source(ref), Source(render, fps) if end is None: end = min(r.n_frames / r.fps, q.n_frames / q.fps - render_offset) w, h = _pane_size(r, scale) lab_r = labels[0] lab_q = labels[1] or ("RENDER " + q.name) W, H = (2 * w, h) if stack == "h" else (w, 2 * h) ts = max(1, int(round(h / 176))) enc = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "%dx%d" % (W, H), "-r", str(fps), "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", str(crf), out], stdin=subprocess.PIPE) n = 0 for (t, fa), (_, fb) in zip(r.stream(start, end, fps, (w, h)), q.stream(start + render_offset, end + render_offset, fps, (w, h))): canvas = np.zeros((H, W, 3), np.uint8) ox, oy = (w, 0) if stack == "h" else (0, h) if fa is not None: canvas[:h, :w] = fa if fb is not None: canvas[oy:oy + h, ox:ox + w] = fb else: draw_text(canvas, ox + w // 2 - 60 * ts, oy + h // 2, "NO FRAME", (200, 60, 60), 2 * ts) draw_text(canvas, 6 * ts, 6 * ts, lab_r, (255, 255, 255), ts, bg=(0, 0, 0)) draw_text(canvas, ox + 6 * ts, oy + 6 * ts, lab_q, (255, 255, 255), ts, bg=(0, 0, 0)) tc = "T %6.3fS F %04d" % (t, int(round(t * fps)) + 1) tw, th = text_size(tc, ts) draw_text(canvas, W - tw - 6 * ts, H - th - 6 * ts, tc, (255, 255, 0), ts, bg=(0, 0, 0)) enc.stdin.write(canvas.tobytes()) n += 1 enc.stdin.close() enc.wait() print("wrote %s (%d frames, %.2f-%.2f s, %dx%d)" % (out, n, start, end, W, H)) def contact_sheet(ref, render, times, out, thumb_width=320, render_offset=0.0, label=None): """Reference row over render row at the given times (columns), with time labels.""" r = Source(ref) q = Source(render) if render else None th = int(round(thumb_width * r.height / r.width / 2)) * 2 size = (thumb_width, th) fr = r.read([r.index(t) for t in times], size) fq = q.read([q.index(t + render_offset) for t in times], size) if q else {} gap, top, left = 4, 22, 0 rows = 2 if q else 1 img = np.full((top + rows * (th + gap), len(times) * (thumb_width + gap) + gap, 3), 255, np.uint8) for k, t in enumerate(times): x = gap + k * (thumb_width + gap) draw_text(img, x, 4, "T=%.2fS" % t, (0, 0, 0), 2) a = fr.get(r.index(t)) if a is not None: img[top:top + th, x:x + thumb_width] = a if q: b = fq.get(q.index(t + render_offset)) y = top + th + gap if b is not None: img[y:y + th, x:x + thumb_width] = b else: fill_rect(img, x, y, x + thumb_width, y + th, (60, 60, 60)) draw_text(img, x + 8, y + th // 2, "NO FRAME", (255, 120, 120), 2) draw_text(img, gap + 4, top + 4, "REF", (255, 255, 255), 2, bg=(0, 0, 0)) if q: draw_text(img, gap + 4, top + th + gap + 4, label or q.name, (255, 255, 255), 2, bg=(0, 0, 0)) write_png(out, img) print("wrote", out) def main(): import argparse ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) a = sub.add_parser("extract", help="reference (or any video) frames at times -> f_####.png") a.add_argument("--src", required=True) a.add_argument("--times", required=True, help="a:b:step or t1,t2,...") a.add_argument("--out", required=True) a.add_argument("--width", type=int) a = sub.add_parser("assemble", help="PNG sequence -> H.264 mp4 (yuv420p, crf 18)") a.add_argument("--seq", required=True) a.add_argument("--out", required=True) a.add_argument("--fps", type=float, default=24.0) a.add_argument("--crf", type=int, default=18) a = sub.add_parser("sbs", help="labelled side-by-side or stacked comparison video") a.add_argument("--ref", required=True) a.add_argument("--render", required=True) a.add_argument("--out", required=True) a.add_argument("--start", type=float, default=0.0) a.add_argument("--end", type=float) a.add_argument("--stack", choices=["h", "v"], default="h") a.add_argument("--scale", type=float, default=0.5) a.add_argument("--label", help="render label (default: RENDER <dir name>)") a.add_argument("--render-offset", type=float, default=0.0, help="render time = ref time + offset (s)") a = sub.add_parser("sheet", help="contact sheet: ref row over render row") a.add_argument("--ref", required=True) a.add_argument("--render") a.add_argument("--times", default="0:20:2.5") a.add_argument("--out", required=True) a.add_argument("--width", type=int, default=320) a.add_argument("--label") a.add_argument("--render-offset", type=float, default=0.0) args = ap.parse_args() if args.cmd == "extract": extract(args.src, parse_times(args.times), args.out, args.width) elif args.cmd == "assemble": assemble(args.seq, args.out, args.fps, args.crf) elif args.cmd == "sbs": side_by_side(args.ref, args.render, args.out, args.start, args.end, args.stack, args.scale, labels=("REFERENCE", args.label), render_offset=args.render_offset) elif args.cmd == "sheet": contact_sheet(args.ref, args.render, parse_times(args.times), args.out, args.width, args.render_offset, args.label) if __name__ == "__main__": main()skills/procedural-blender-nodes/references/animation-and-simulation.md12 KB4480132fef9eupstream ↗
# Animation and Simulation Motion in a node system is either **analytic** (a closed-form function of a `Time` input, valid at any frame in any order) or **simulated** (a simulation zone that steps state forward frame by frame). Choose deliberately: it decides cost, scrubbing, baking and how the effect can be verified. ## Contents - [The decision rule](#the-decision-rule) - [Time plumbing](#time-plumbing) - [Analytic recipes](#analytic-recipes) - [Loops](#loops) - [Simulation zones in 5.x](#simulation-zones-in-5x) - [Simulation recipes](#simulation-recipes) - [Verifying motion numerically](#verifying-motion-numerically) ## The decision rule | The motion needs... | Use | Examples | |---|---|---| | **state**: the present depends on the history (where it went, what it hit) | simulation | growth that spreads, a trail, a rope that settles after a hit | | **interaction**: pieces react to each other or to geometry that moves | simulation | collisions, pushing, piling up, avoidance | | **accumulation**: something builds up and stays | simulation | snow settling, leaves landing and staying, footprints, wear | | periodic or quasi-periodic motion | analytic | walk cycles, swinging signs, wind sway, bobbing | | a function of a driven quantity (distance walked, a path, a keyed speed) | analytic | planted feet, wheels rolling, a camera on a path | | independent particles with lifetimes | analytic | falling leaves that vanish, sparks, rain, bubbles | | a damped response to known events | analytic | flaps lagging the body, a sign ringing after a gust | If both fit, choose analytic: it scrubs, renders any frame alone, needs no bake, and can be tested at any time. State the choice and the reason in the deliverable ("no simulation zone: the reference shows nothing that needs stored state" is a valid finding; the storybook street is fully analytic). ## Time plumbing - Only the top (modifier) group reads **Scene Time** (`GeometryNodeInputSceneTime`, `Seconds`). Every part takes a `Time` input (FLOAT, seconds). Parts are then testable at any time, retimable and reusable. - Top group: `time = Scene Seconds * Time Scale + Time Offset` (`Time Offset` subtype `TIME_ABSOLUTE`). - Per-piece time: `t_i = Time + offset_i` with `offset_i` hashed from the piece id, so pieces do not move in sync. - Anything outside Geometry Nodes that must agree (a camera rig on a path) reads the same modifier inputs through a driver (`build.py` in storybook-street): `offset = (Walk Start + Walk Speed * (frame/fps * Time Scale + Time Offset) + along) / curve_length`. Drivers in background mode update only for `bpy.context.window.scene`. ## Analytic recipes **Integrated phase.** Never write `phase = speed * t` when speed changes: changing speed then jumps the phase. Integrate distance, then derive phase from distance: ```text s(t) = s0 + ∫ v dt (piecewise: constant segments add v_k * Δt_k; a linear ramp from v0 to v1 over T adds v0 τ + (v1 − v0) τ² / (2T)) phase(t) = s(t) / stride (gait cycles; feet are locked to distance walked) ``` **Planted feet.** For each foot with phase offset `o` (0 left, 0.5 right): `u = phase − o`, cycle `k = floor(u)`, fraction `f = u − k`. During stance (`f < STANCE`) the foot sits at a fixed arc length `plant = (k + o + STANCE/2) * stride` (plus a toe offset), placed through the curve frame, so it is fixed in the world on any curve. During swing, travel from this plant to the next with smootherstep `e = w³(6w² − 15w + 10)` (zero velocity at both ends), lift `∝ w(1 − w)²`. See `SS Walker Step` in `prompts/storybook-street/street/groups/walker.py`. **Body motion** from the same phase: bob `−A cos(4π phase)` (twice per cycle), sway `cos(2π phase)` over the stance foot, arm swing opposite to the legs, twist with the arms. **Springs (secondary motion).** A damped spring with natural frequency `fn`, damping ratio `ζ`, driven by a sine at `fd`, has a closed-form steady state: ```text r = fd / fn gain = 1 / sqrt((1 − r²)² + (2ζr)²) lag = atan2(2ζr, 1 − r²) response = A * gain * cos(drive_phase − lag) ``` Flaps, scarves, antennae and hanging bags lag the body and overshoot near resonance. For a single event at time `t0` (a landing, a gust onset), add the impulse response `A e^(−ζω(t − t0)) sin(ω_d (t − t0))` for `t > t0`, `ω = 2π fn`, `ω_d = ω sqrt(1 − ζ²)`. **Wind.** A gust envelope: slow noise in time, shifted by position so gusts travel (`noise(t − lag − s / gust_speed)`), mapped to `max(1 + gust * n, 0)`. Each part responds with the lagged envelope (steady deflection) plus a ring at its own pendulum period scaled by the envelope. At small angles this linear response is accurate and needs no state. See `SS Bunting Wind` in storybook-street; the `motion` library module has reusable versions. **Pendulum / sign swing.** `angle = A (sin(2π t / T + φ_i) + 0.15 sin(4π t / T + 1.3))`, `T = 2π sqrt(L / g)`, `φ_i` hashed per piece. **Ballistic.** `z = z0 + v0 τ − g τ² / 2`; bounces lose height by restitution `e < 1` per contact; spin matches distance rolled (`angle = distance / radius`). **Independent particles with lifetimes** (falling leaves, sparks): `age = fract((t + φ_i) / life) * life`, position = spawn_i + v_i age + sway(age), fade or scale in and out near the ends of life so recycling never pops. **Wheels, tracks, conveyor textures**: angle or UV offset = distance / radius, from the same integrated `s(t)`. ## Loops A loop of length `L` seconds is seamless when every periodic term completes a whole number of cycles in `L`: - choose periods `L / n`; derive stride or speed from that instead of the reverse; - noise that must loop: sample 4D noise on a circle in two dimensions, `(cos 2πt/L, sin 2πt/L) * radius`, or use the W input of 4D noise with a periodic coordinate; - a translating camera or walker loops only if the world repeats: use a closed path, or a periodic layout whose period matches `speed * L`; - verify: frame 1 and frame `L*fps + 1` are identical (image difference 0, or attribute difference below 1e-5). ## Simulation zones in 5.x ```python zin, zout = g.zone("SIMULATION", [("VECTOR", "Velocity")]) g.connect(points, zin.i("Geometry")) # initial state, read on the start frame only g.connect(initial_velocity, zin.i("Velocity")) dt = zin["Delta Time"] # 0 on the start frame, which still runs one step ... # update fields with dt g.connect(new_geo, zout.i("Geometry")); g.connect(new_vel, zout.i("Velocity")) out = zout["Geometry"] ``` Behaviour, verified in Blender 5.2.2 (background, 24 fps): | Fact | Detail | |---|---| | Delta Time | seconds per frame, 1/fps (0.041667 at 24 fps). Use it for every rate so the result does not depend on fps | | First frame | the start frame already runs **one step with Delta Time 0**: the zone body is evaluated on the initial state and its output is shown. Anything scaled by Delta Time does not move, but per-step events do happen once (a step counter reads 1, a trail appends a point, `Random(id, step)` branches, Duplicate/Delete per step). Gate such events with `Delta Time > 0`, or connect `Delta Time < 1e-9` to `zout.i("Skip")` to show the untouched initial state on the start frame (test: `tests/test_motion.py::test_simulation_first_frame_runs_a_zero_dt_step`) | | Start frame | `scene.frame_start`, or `scene.use_custom_simulation_range` + `simulation_frame_start/end`. Before the start the initial state is shown | | Returning to the start frame | resets the simulation | | **Skipping frames** | jumping from frame 3 to 10 without a cache advanced the state by **one** step, not seven. Scripts must step every frame in order from the start (`for f in range(start, end+1): scene.frame_set(f)`) or bake first | | Cache | filled during playback (purple line in the timeline); edits upstream invalidate it | | Bake | `mod.bakes[i]` per zone; after `geometry_node_bake_single` every frame is exact in any order (verified) | | Skip | `zout.i("Skip")` true passes the state through unchanged (pause a simulation) | | Inputs | nodes linked into the zone input are evaluated once at the start; nodes inside are evaluated every step | Rules: - Render or hand off a simulation only baked. Say so in the deliverable, and say which controls need a re-bake. - Keep the state small: the attributes you need, not the whole visual geometry. Build the visual mesh after the zone from the state (instancing leaves on a trail after the zone, not inside it). - Stability: explicit integration of a spring needs `ω dt < ~0.5`. For stiff springs or constraints, substep with a repeat zone inside the simulation zone (`dt_sub = dt / n`). - Seed every random choice inside the zone with the step (Scene Frame or an accumulated step counter) plus the id, or every step repeats the same choice. - 5.2 adds an experimental physics system (Cloth and Hair Dynamics assets, an XPBD Solver node, colliders and effectors). Use it for cloth and hair that must collide; label it experimental; it is not wrapped by nodekit. ## Simulation recipes **Growth over a mesh** (ivy, moss, lichen). State: tip points with `direction`, `age`, `alive`; a trail point cloud. Each step: move tips by `speed * dt` along `direction` (blended toward the surface tangent and a noise field), snap to the surface with Sample Nearest Surface, append the old tip positions to the trail (Join), branch: duplicate tips where `Random Value(id, step) < rate * dt`, kill tips that age out or leave the surface. After the zone: Points to Curves or Curve Line segments for stems, leaves instanced on the trail by age. Template: `growth-sim`. **Spring settle** (a sign hit by a gust, a curtain). State: position, velocity per point. `v += (−k (x − rest) − c v + F) dt; x += v dt` (semi-implicit Euler), pinned points keep `x = rest`. **Accumulation** (snow, leaves that land). Analytic fall until contact (Raycast down from each flake); on contact, mark `landed` and store the position in the state; landed items stay. **Verlet rope.** State: positions and previous positions on a curve; each step `x' = 2x − x_prev + a dt²`, then a repeat zone of distance-constraint passes along the curve. ## Verifying motion numerically Judge motion as numbers and sequences, never one still. - **Sample attributes over time.** Store a probe attribute (`nk_probe` on the foot, root, a sign tip) and read it from the evaluated mesh at every frame: ```python vals = [] for f in range(1, 49): sc.frame_set(f) me = ob.evaluated_get(bpy.context.evaluated_depsgraph_get()).data vals.append([v.co.copy() for v, p in zip(me.vertices, me.attributes["nk_probe"].data) if p.value > 0.5]) ``` - **Planted feet**: during stance the foot's world position changes by less than 1e-4 units per frame. - **Continuity**: first differences (velocity) have no spikes; second differences (acceleration) spike only at physical events (contact, takeoff). Flag any jump above 3× the median. - **Phase continuity across speed changes**: phase is monotonic and its derivative equals speed / stride. - **Springs settle**: after a step input the response decays by `e^(−ζω t)`; check the overshoot and settle time. - **Bounded wind**: maximum angle over a long run stays within the stated amplitude. - **Loops**: frame 1 equals frame `L*fps + 1`. - **Simulation determinism**: bake twice, compare states at several frames (identical); step without a bake from the start frame and compare with the bake. - Put these checks in a property test (`tests/test_motion.py` pattern) or a project script, and report the numbers.skills/procedural-blender-nodes/references/blender-api-notes.md8.8 KB696c545b96b2upstream ↗
# Blender 5.2 API Notes Gotchas met while building node systems from Python in Blender 5.2 (5.2.2 LTS). Items marked **verified** were checked in 5.2.2 with `blender -b --factory-startup`; the others come from building the library and storybook-street. Check `bpy.app.version_string` first: these notes assume 5.2+. ## Running Blender - Always `blender -b --factory-startup --python <script> -- <args>`. Arguments after `--` are the script's; pass negative values as `--values=-1,3`, `--cam=-2,0,1:...` (argparse reads `-1` as a flag). - `nodekit.scene.reset()` (`read_factory_settings(use_empty=True)`) frees every data-block. Build node groups **after** it; a tree built before raises `ReferenceError: StructRNA of type GeometryNodeTree has been removed` (verified). The `once` cache detects freed trees and rebuilds. - Drivers update only for the window's scene in background mode: set `bpy.context.window.scene = sc` before `frame_set`, and render with `bpy.ops.render.render(scene=sc.name)`. - `mod.execution_time` reads 0 in background mode (verified); time `scene.frame_set()` yourself. - zsh: a loop variable named `path` overwrites `$PATH`. ## Modifiers and group interfaces - Modifier inputs are RNA properties, not ID properties (5.2 release notes; verified): use `mod.properties.inputs.<identifier>.value` and `.attribute_name`, or `nodekit.inspect.set_modifier_inputs(mod, {"Name": v})`. `mod["Socket_2"]` raises `TypeError: this type doesn't support IDProperties`. - `mod.node_warnings` lists evaluation warnings (`type`, `message`) after the object is evaluated at a frame. - Group-level modifier flag: `node_group.is_modifier = True`. - Empty objects can carry Geometry Nodes modifiers in 5.2 (verified); `nodekit.scene.holder` still uses an empty mesh object, which works in every version. - Interface sockets: `subtype` is validated per type; float subtypes are `NONE PIXEL PERCENTAGE FACTOR MASS ANGLE TIME TIME_ABSOLUTE DISTANCE WAVELENGTH COLOR_TEMPERATURE FREQUENCY` (verified). Other item properties: `hide_in_modifier`, `force_non_field`, `structure_type` (`AUTO DYNAMIC FIELD GRID LIST SINGLE`), `default_input` (`VALUE INDEX ID_OR_INDEX NORMAL POSITION INSTANCE_TRANSFORM HANDLE_LEFT HANDLE_RIGHT SCENE_FRAME UNIFORM_IMAGE_COORDINATES SELF_OBJECT`), `attribute_domain` (verified). Panels have `default_closed`. - A shared group rebuilt by a second module clears its interface and silently unlinks every earlier user; shared builders are build-once (`@nodegroup` does this; `nodekit.graph.once` for anything else). ## Nodes and sockets - Look sockets up by **name among enabled sockets** (`find_socket`). Identifiers are unstable: 5.2 changed the socket identifiers of Compare and Random Value (release notes), and zone items are `Item_0`, `Generation_0`, …. - `ShaderNodeMix` has several sockets named A/B (one pair per data type); `Graph.mix` and `find_socket` pick the enabled ones. Set `data_type` through `props` before linking. - Many former node properties are menu **input sockets** in 5.x: Transform's mode, Set Curve Normal's mode, Resample Curve's `Mode` (`"Count"`, `"Length"`), Remove Named Attribute's `Pattern Mode` (`"Wildcard"`), compositor Glare `Type`/`Quality`, Set Alpha `Type` (`"Replace Alpha"`), Alpha Over `Type` (verified for the compositor ones). Set them like values: `Mode="Length"`. - Texture outputs are displayed as `Factor` (identifier `Fac`) in 5.x, in both Geometry and Shader Nodes (verified for Noise and Wave); `find_socket` accepts either. Voronoi outputs `Distance`, `Color`, `Position`. - Sockets whose default is an implicit field (Extrude Mesh `Offset` = normal, Set Position `Position`) ignore constant defaults in some nodes; link a node (Combine XYZ, Value) instead of setting `default_value`. - Mesh Boolean solvers: `EXACT`, `FLOAT`, `MANIFOLD` (verified). Manifold does not reliably keep attributes; recover ids from positions after cutting (`geometry-nodes-patterns.md`). - Realize Instances inputs: `Geometry`, `Selection`, `Realize All`, `Depth` (verified). - Instance-domain attributes land on the **POINT** domain after Realize Instances (verified). - Remove Named Attribute on instances also strips nested instances' attributes; remove on points first. - Extrude Mesh on a bent grid shades with grid artefacts in EEVEE; use a flipped back sheet instead. ## Zones - Simulation zone: input node output `Delta Time` (1/fps; verified 0.041667 at 24 fps); output node input `Skip`; state in `state_items`. Repeat zone: `Iterations` input, `Iteration` output, `repeat_items`. For Each Element: input node `Geometry`, `Selection`, output `Index`; items in `main_items` (per element), `input_items`, `generation_items`; the generated geometry is the output's second `Geometry` (`"Geometry#1"`) (verified). - For Each over an empty selection gives an INFO warning; guard with a Switch on Domain Size > 0. - Simulation without a cache: **jumping frames advances one step** (frame 3 → 10 added one Delta Time, verified); returning to the start frame resets. Step every frame in order from the start, or bake. - Scene simulation range: `scene.use_custom_simulation_range`, `simulation_frame_start`, `simulation_frame_end`; before the start the initial state is shown (verified). - Bakes: `mod.bakes[i]` with `bake_id`, `bake_mode`, `bake_target` (`INHERIT` → `mod.bake_target`, default `PACKED`), `frame_start/end`, `use_custom_simulation_frame_range`, `directory`. Operators `object.geometry_node_bake_single` / `geometry_node_bake_delete_single` / `geometry_node_bake_pack_single` / `geometry_node_bake_unpack_single` take `session_uid`, `modifier_name`, `bake_id`; run them under `temp_override(object=ob, active_object=ob, selected_objects=[ob])` (verified). `object.simulation_nodes_cache_bake` bakes all simulations of the selection. ## Evaluation and dependencies - Any Scene Time node in a modifier's tree makes it re-evaluate on **every** frame change, even when its value is multiplied by 0 (verified: 0.13 s per frame vs 0 s for the same static graph). Only the top group should read it. - `obj.evaluated_get(dg).to_mesh()` (and `inspect.evaluated_stats`) sees only the mesh component: point clouds (Points node, Distribute Points output not converted) and unconverted curves are not counted (verified). - Read evaluated attributes with `ev.data.attributes[name].data[i].value` after `scene.frame_set(f)`. ## Materials and rendering - New materials and worlds already have node trees; `Material.use_nodes` is deprecated and scheduled for removal in 6.0 (verified: DeprecationWarning). `nodekit.materials.material()` still sets it; harmless for now. - A shader Attribute node returns 0 for a missing attribute: store terms where 0 means "no effect". Types: `GEOMETRY OBJECT INSTANCER VIEW_LAYER` (verified). - EEVEE's engine id is `BLENDER_EEVEE` (renamed from `BLENDER_EEVEE_NEXT` in 5.0). `CYCLES` is available with factory settings (verified). - View transforms include `AgX`, `Standard`, `Filmic`, `Khronos PBR Neutral`, `ACES 2.0`; AgX looks are prefixed: `"AgX - Base Contrast"`, `"AgX - Punchy"`, … (verified; `"Base Contrast"` raises). - Sky Texture types: `SINGLE_SCATTERING MULTIPLE_SCATTERING PREETHAM HOSEK_WILKIE` (verified). - World volumes swallow the sun in EEVEE; do distance haze in the compositor (mist pass, premultiplied). ## Compositor (5.x) - The compositor tree is a node group: `scene.compositing_node_group = tree` (`CompositorNodeTree`). There is no `CompositorNodeComposite` (verified); the Group Output's first Color input is the result. `Viewer` still exists. - Alpha Over inputs: `Background`, `Foreground`, `Factor`, `Type`, `Straight Alpha` (verified). - Image Coordinates (`Uniform`, `Normalized`, `Pixel`) needs an image input to know the size. - Mix also blends alpha; restore the render's alpha with Set Alpha (`Type` = `"Replace Alpha"`). - Render Layers outputs `Image`, `Alpha` plus enabled passes (`Mist` after `view_layer.use_pass_mist = True`). - `nodekit.graph.Graph.group` creates a `ShaderNodeGroup` for any non-geometry kind, so it cannot nest compositor groups. ## 5.2 features worth knowing From the 5.2 release notes (not all used by the library yet): Lists (Field to List, Get List Item, Filter/Sort List), Geometry Bundles (Set/Get Geometry Bundle), recursive Closures, Collection Children, Mesh Bevel node, Capture Attribute with selection, Rename Attribute / Get Attribute Names / Transfer Attributes, String fields, Scene Frame as a group-input default, Object inputs defaulting to the self object, and the experimental physics system (Cloth Dynamics, Hair Dynamics, XPBD Solver, colliders and effectors). Mark anything built on the physics system as experimental. ## Adding to this list When a Blender API fact surprises you, verify it with a minimal `blender -b --factory-startup --python-expr ...` probe, then add it here with "(verified)" and the version.skills/procedural-blender-nodes/references/controls-and-presets.md8.4 KBb63dddb3b1c8upstream ↗
# Controls and Presets The modifier panel is the product. An artist who never opens the node editor should be able to get every intended variation from it, and an artist who opens it should find each control wired to something visible. ## Contents - [What to expose](#what-to-expose) - [Declaring a good input](#declaring-a-good-input) - [Panels](#panels) - [The Primary panel: macro controls](#the-primary-panel-macro-controls) - [Forwarding part inputs to the top group](#forwarding-part-inputs-to-the-top-group) - [Naming and collisions](#naming-and-collisions) - [Structure vs parameters](#structure-vs-parameters) - [Presets as JSON](#presets-as-json) - [Proving each control matters: sweeps](#proving-each-control-matters-sweeps) ## What to expose Expose a control when an artist would plausibly want to change it **and** it changes the result visibly. Keep the rest as constants in the builder. | Expose | Keep internal | |---|---| | sizes and proportions (width, storeys, sag) | epsilon offsets, z-fighting nudges | | densities and chances (prop chance, flowers per m²) | derived values (spacing from count and length) | | style choices (roof style as a Menu, timber pattern) | node-count parameters with one correct value | | palette colours | normalisation constants | | motion amounts (wind, walk speed, swing) | integration constants, stance fraction (unless a gait control) | | seed, input object, time scale/offset, visibility toggles | anything that breaks the graph outside a narrow range | ## Declaring a good input ```python g.input("Sag", "FLOAT", 0.25, min=0.0, max=2.0, subtype="DISTANCE", panel="Strings", desc="How far each string hangs below the straight line between its anchors") ``` - **Description**: what it does in the image, in the artist's words, with the reference value if there is one ("1 = the reference's light breeze; 4–6 = a windy day"). Lint fails without one. - **Range**: `min`/`max` that keep the graph valid. A range the graph cannot survive is a bug: either clamp internally or narrow the range. Test both ends. - **Subtype**: `DISTANCE`, `ANGLE`, `FACTOR` (0..1 slider), `PERCENTAGE`, `TIME`, `TIME_ABSOLUTE`; vectors `XYZ`, `TRANSLATION`, `EULER`, `DIRECTION`. Units then show correctly. - **Type**: INT for counts and storeys, BOOL for toggles, MENU (fed into a Menu Switch) for named styles, COLOR for palette, OBJECT/COLLECTION for inputs, never a FLOAT that is rounded internally. - **Seed**: one `Seed` INT per top group; internal decisions use `Seed + k` with a distinct `k` per decision. ## Panels Group inputs into panels named for what an artist thinks about: `Street`, `Houses`, `Windows`, `Palette`, `Wind`, `Visibility`. Declaration order is display order. Collapse detail panels by default (`g._panels[name].default_closed = True`). ## The Primary panel: macro controls A large generator has hundreds of detailed controls (storybook street: about 180). Put a **Primary** panel first with 6–10 macro controls, each of which scales several detailed ones. The detailed controls stay available and are combined with the macro: ```python PRIMARY = [ # name, type, default, min, max, description, [(part, detailed input, op)] ("Prop Amount", "FLOAT", 1.0, 0.0, 2.0, "Scales every prop chance", [("Props", "Window Box Chance", "MULTIPLY"), ("Props", "Awning Chance", "MULTIPLY")]), ("Extra Storeys", "INT", 0, -2, 4, "Taller or lower houses", [("Houses", "Storeys Min", "ADD"), ("Houses", "Storeys Max", "ADD")]), ("Festivity", "FLOAT", 1.0, 0.2, 3.0, "More bunting strings", [("Bunting", "String Spacing", "DIVIDE")]), ] # when forwarding a detailed input that is a macro target: src = g.math(op, detailed_input_socket, macro_socket, label=f"{name} {op.lower()} {macro}") ``` - Neutral macro values (1 for MULTIPLY/DIVIDE, 0 for ADD) reproduce the defaults exactly, so the reference look is the macro defaults. - A macro must change the image visibly at both ends of its range (prove with a sweep). - Put the input object (`Path`), `Seed` and the one or two most important direct controls (`Walk Speed`) in Primary too. ## Forwarding part inputs to the top group Each part is a group usable alone; the top group instances every part and forwards its inputs to the modifier. Pattern (from `prompts/storybook-street/street/groups/assembly.py`): ```python for it in part_tree.interface.items_tree: if it.item_type != "SOCKET" or it.in_out != "INPUT": continue sock = next(s for s in node.b.inputs if s.identifier == it.identifier) if it.name == "Curve": g.connect(path_geometry, sock) # shared wiring elif it.name == "Time": g.connect(time, sock) elif it.name in shared: g.connect(shared[it.name], sock) # Seed, Street Width, ... else: # forward with the same spec panel = f"{label} > {it.parent.name}" if it.parent and it.parent.name else label src = g.input(name, stype, it.default_value, min=it.min_value, max=it.max_value, subtype=it.subtype, desc=it.description, panel=panel) g.connect(src, sock) ``` - Forwarded panels are `"<Part> > <Panel>"`, so the modifier reads like the part structure. - Shared inputs (one `Seed`, one `Street Width`) are declared once on the top group and wired to every part that has an input with that exact name. Agree the shared names before building parts. - Forward the description, range and subtype; drop subtype `NONE`. - Geometry inputs between parts (`Anchors`) are wired, not exposed. ## Naming and collisions - Names must be unique on one group interface. When two parts both have `Color`, forward as `"<Part> Color"`, or better, make the part names unambiguous at the source (`Awning Color`, `Shutter Color`): presets and sweeps address controls by name. - Names are API: renaming a control breaks presets, sweeps, drivers and docs. Rename deliberately and update them. - Groups: library `NK <Name>`, project groups with a project prefix (`SS <Name>`), sub-groups `<Prefix> <Name> <Part>`. Materials the same. ## Structure vs parameters | Kind | What | Where it changes | Reviewed as | |---|---|---|---| | **Structure** | which parts exist, how they connect, which inputs exist | Python builders only | code + regenerated `GRAPH.md` | | **Parameters** | default values, ranges, presets, light, camera | builder defaults, `presets/*.json`, `light.json`, build script | value diffs | Fitting to a reference (exposure, sun, densities, sizes) touches **parameters only**, so it can never break reuse on other inputs. If matching a reference seems to need a structural special case ("house 7 is taller"), it is a parameter distribution instead (a storeys range and a chance), or it is not reproduced. ## Presets as JSON A preset is a flat `{control name: value}` over the top group's inputs. Colours are linear RGBA lists. ```json { "Festivity": 2.4, "Wind": 4.0, "Flower Amount": 2.2, "Seed": 3 } ``` ```python inspect.set_modifier_inputs(mod, json.loads(pathlib.Path("presets/festival.json").read_text())) ``` - `{}` is the reference preset: the defaults are the reference. - Presets touch mostly Primary controls; a preset that needs 30 detailed values means a macro is missing. - Render presets with `scripts/render.py --preset <path or name> --set "Seed=7"`. Validate every preset (`validate.py` after applying, or a build per preset) for warnings and camera clearance. - Store presets next to the build script (`presets/`), one file each, numbered for the gallery order. ## Proving each control matters: sweeps A control that does nothing visible, or only breaks things, should not be exposed. ```bash blender -b my_effect.blend --python scripts/sweep.py -- --control "Timber Amount" \ --values 0,1.7 --frames 48 --at 169 --out out/sweep/timber_ ``` - Sweep every Primary control over its full range at a frame where its effect is visible; look at the first and last frames side by side, and play the sweep for discontinuities (sudden jumps mean a hard threshold that should be a smooth chance or a hashed per-piece threshold). - Smooth sweeps: per-piece decisions compare a hashed `nk_rand` against the control (`rand < chance`), so raising the chance adds pieces one at a time instead of reshuffling all of them. Keep layout seeds independent of chance controls, so changing a chance never moves unrelated pieces. - Spot-check detailed controls by group: each panel's controls at min and max on a small test render. - Record sweeps in the deliverable (a start/end contact sheet per control).skills/procedural-blender-nodes/references/geometry-nodes-patterns.md11 KB3a8edc7060daupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/references/library-index.md13 KB0b7c9352fb2bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/references/materials-and-look.md8.4 KB26d729e7b382upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/references/nodekit-guide.md14 KB66bc4da3a229upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/references/subject-routing.md11 KB2eb2164a22fdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/references/validation-and-review.md7.8 KB408514809f02upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/agents/openai.yaml373 B8195cbb66686upstream ↗
interface: display_name: "Procedural Blender Nodes" short_description: "Geometry/Shader/compositor node systems built from Python with a tested node-group library" brand_color: "#E87D0D" default_prompt: "Use $procedural-blender-nodes to build a procedural Geometry Nodes effect with artist controls, saved as a .blend by a reproducible build script and validated."skills/procedural-blender-nodes/library/__init__.py741 B9cd1b6aac26aupstream ↗
"""Reusable node-group builders. Import order is dependency order: a module may only build groups from modules listed before it (checked by `nodes_tools.py check`).""" MODULE_ORDER = [ "curve", # curve frames, bending flat layouts onto a path, samples and parcels along a path "shapes", # small parametric meshes: rounded/chamfered boxes, rods, rings, leaves, blossoms, foliage "scatter", # distributing instances on surfaces and along curves "motion", # analytic animation: springs, gait steps, wind gusts, pendulums, time remaps "materials", # shader node groups: attribute tints, stucco, wood, stone, felt, terracotta, stripes, glow "post", # compositor groups: haze over sky, glow ]skills/procedural-blender-nodes/library/curve.py26 KBe3b347e1a7f3upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/library/materials.py42 KBa10098c6aa58upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/library/motion.py21 KB82063d86a89eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/library/post.py6.1 KBcbdea45f3534upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/library/scatter.py23 KB79dc3e4d690bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/library/shapes.py39 KB403d34bac4ceupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/nodekit/__init__.py111 Beb29b81b23f0upstream ↗
"""nodekit: build native Blender node graphs (Geometry, Shader, Compositor) from Python and read them back."""skills/procedural-blender-nodes/nodekit/graph.py17 KB79f0bed096c0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/nodekit/inspect.py7.5 KBf4ee700c8cabupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/nodekit/library.py2.2 KB853583e0281cupstream ↗
"""The node-group library registry. Every reusable group in `library/` is a builder function decorated with `@nodegroup`. The decorator records its metadata (for `nodes_tools.py search` / `index`, which read it statically with `ast`, without Blender) and makes the builder build-once-per-session (rebuilding a group that other groups already instance would unlink them). from nodekit.graph import Graph from nodekit.library import nodegroup @nodegroup("NK Curve Frame", kind="GEOMETRY", tags=["curve", "placement"], use="streets, fences, rails, anything placed along a path") def curve_frame(): '''(s, d, h) along a curve to world position and orientation.''' g = Graph("NK Curve Frame", description=curve_frame.__doc__.strip().splitlines()[0]) ... return g.finish() Rules (checked by `nodes_tools.py check`): - the group name passed to the decorator equals the Graph name, and starts with "NK "; - the docstring's first line is a one-sentence summary; `use` names concrete subjects; - a builder may call builders only from modules listed before its own in library/__init__.py MODULE_ORDER. """ from nodekit.graph import once REGISTRY = {} # name -> metadata dict (filled when a library module is imported) def nodegroup(name, kind="GEOMETRY", tags=(), use=""): def deco(fn): built = once(fn) built.nodegroup = {"name": name, "kind": kind, "tags": list(tags), "use": use, "summary": (fn.__doc__ or "").strip().splitlines()[0] if fn.__doc__ else "", "module": fn.__module__, "builder": fn.__name__} REGISTRY[name] = dict(built.nodegroup, build=built) return built return deco def build_all(modules=None): """Import every library module (in MODULE_ORDER) and build every registered group. Returns {name: tree}.""" import importlib, importlib.util from library import MODULE_ORDER for m in modules or MODULE_ORDER: if importlib.util.find_spec(f"library.{m}") is not None: # modules not written yet are skipped importlib.import_module(f"library.{m}") return {name: meta["build"]() for name, meta in REGISTRY.items()}skills/procedural-blender-nodes/nodekit/materials.py846 B2416d6ee6d1eupstream ↗
"""Shared material helpers. Owner-specific materials live in street/materials/<owner>.py.""" import bpy def material(name): """Get-or-create a node material named ``name`` with an emptied tree (rebuilt on every build).""" m = bpy.data.materials.get(name) or bpy.data.materials.new(name) if m.node_tree is None: # Blender 5.x materials always use nodes; use_nodes is deprecated m.use_nodes = True m.node_tree.nodes.clear() return m def flat(name, rgb, roughness=0.8): m = material(name) nt = m.node_tree b = nt.nodes.new("ShaderNodeBsdfPrincipled") b.inputs["Base Color"].default_value = (*rgb, 1) b.inputs["Roughness"].default_value = roughness o = nt.nodes.new("ShaderNodeOutputMaterial") o.location = (300, 0) nt.links.new(b.outputs[0], o.inputs[0]) return mskills/procedural-blender-nodes/nodekit/scene.py9.9 KB4c8b170b48beupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/templates/curve_fence.py34 KB4ec29e77e477upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/templates/growth_sim.py64 KB090effa430f9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/templates/parametric_house.py66 KB5346de6d4b93upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/templates/surface_scatter.py44 KB00991d142388upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/procedural-blender-nodes/templates/wind_bunting.py29 KBc021433676c7upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/gokayfem-video-to-blender-skills-procedural-blender-nodes.md




