# procedural-blender-nodes

Adopt this skill: https://proskills.md/skills/development/gokayfem-video-to-blender-skills-procedural-blender-nodes

Page: https://proskills.md/skills/development/gokayfem-video-to-blender-skills-procedural-blender-nodes
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/procedural-blender-nodes/SKILL.md
size: 8792
sha: 6369dfe7628c595e1597a82add964b60ca1a8331
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/SKILL.md

```text
---
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.
```

## File: skills/procedural-blender-nodes/scripts/_check_in_blender.py
size: 3428
sha: 3011cd62f182d9a8674bd962ac552aeb521339e3
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/_check_in_blender.py

```text
"""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)
```

## File: skills/procedural-blender-nodes/scripts/compare.py
size: 21350
sha: 8c5bb8f198180a408a9a6f43707aa1dda8a71bdb
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/compare.py

```text
#!/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()
```

## File: skills/procedural-blender-nodes/scripts/graph_atlas.py
size: 1654
sha: a9272633989c6fba715533339de7e6156fd2c349
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/graph_atlas.py

```text
"""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")
```

## File: skills/procedural-blender-nodes/scripts/graph_doc.py
size: 4910
sha: 010b218a802fc08b7827ad1c6b5703381ea1a007
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/graph_doc.py

````text
"""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")
````

## File: skills/procedural-blender-nodes/scripts/graph_shots.py
size: 7905
sha: 1f6321ec2629acd0a664044f2581f1560e279b78
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/graph_shots.py

```text
"""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)
```

## File: skills/procedural-blender-nodes/scripts/nodes_tools.py
size: 7587
sha: 3bc78165b2ced8c53103021729096984f82485a0
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/nodes_tools.py

```text
#!/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()
```

## File: skills/procedural-blender-nodes/scripts/render.py
size: 3033
sha: 56350bc58d2dbc49ff103858a6692b74b6b39bd6
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/render.py

```text
"""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")
```

## File: skills/procedural-blender-nodes/scripts/smoke_graph.py
size: 1255
sha: 09bdf922ee3a48dad5804e2d77d36f810ff62d65
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/smoke_graph.py

```text
"""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"]])
```

## File: skills/procedural-blender-nodes/scripts/sweep.py
size: 2201
sha: 3f2846209e5a85724c2ee1e1d7b6a8f7c5cf410a
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/sweep.py

```text
"""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))
```

## File: skills/procedural-blender-nodes/scripts/try_group.py
size: 3693
sha: 11860a8e460c50ea4d64f39298f4297e76a52f12
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/try_group.py

```text
"""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()))
```

## File: skills/procedural-blender-nodes/scripts/ui_shot.py
size: 4524
sha: a8c8fa10a0960a7730117fd6fdebe78d2f9670c1
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/ui_shot.py

```text
"""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)
```

## File: skills/procedural-blender-nodes/scripts/validate.py
size: 5865
sha: 980f1d68707905c9ca6e140f80bd89ab48769d1b
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/validate.py

```text
"""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)
```

## File: skills/procedural-blender-nodes/scripts/video.py
size: 19284
sha: 8043abdeeec347e1af25e00ce739632976b8c407
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/scripts/video.py

```text
#!/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()
```

## File: skills/procedural-blender-nodes/references/animation-and-simulation.md
size: 11791
sha: 4480132fef9e6fc2bf8716cb4ea951e4f15bd534
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/animation-and-simulation.md

````text
# 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.
````

## File: skills/procedural-blender-nodes/references/blender-api-notes.md
size: 8996
sha: 696c545b96b2c1da9fd7650ddf35ef56052cb2f5
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/blender-api-notes.md

```text
# 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.
```

## File: skills/procedural-blender-nodes/references/controls-and-presets.md
size: 8650
sha: b63dddb3b1c889b5049603a847314b750f86c529
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/controls-and-presets.md

````text
# 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).
````

## File: skills/procedural-blender-nodes/references/geometry-nodes-patterns.md
size: 11405
sha: 3a8edc7060da44e49df046967c1534d6b7bf0267
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/geometry-nodes-patterns.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/geometry-nodes-patterns.md

## File: skills/procedural-blender-nodes/references/library-index.md
size: 12960
sha: 0b7c9352fb2bb37be646d76eeb3c0ce754b68fa7
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/library-index.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/library-index.md

## File: skills/procedural-blender-nodes/references/materials-and-look.md
size: 8569
sha: 26d729e7b38246b8e0c69ac4ae5458d2c1d0bea2
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/materials-and-look.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/materials-and-look.md

## File: skills/procedural-blender-nodes/references/nodekit-guide.md
size: 14589
sha: 66bc4da3a229553a83804a977f42a73db60783e2
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/nodekit-guide.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/nodekit-guide.md

## File: skills/procedural-blender-nodes/references/subject-routing.md
size: 11699
sha: 2eb2164a22fd8dafa374e96dd84c42cb23749637
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/subject-routing.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/subject-routing.md

## File: skills/procedural-blender-nodes/references/validation-and-review.md
size: 7980
sha: 408514809f02148cac4940b08483c2ded742c3f4
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/validation-and-review.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/references/validation-and-review.md

## File: skills/procedural-blender-nodes/agents/openai.yaml
size: 373
sha: 8195cbb66686224f03afad689a9f69bcc19fb3eb
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/agents/openai.yaml

```text
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."
```

## File: skills/procedural-blender-nodes/library/__init__.py
size: 741
sha: 9cd1b6aac26abc4388e5dade30b7b44992bcfcdb
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/__init__.py

```text
"""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
]
```

## File: skills/procedural-blender-nodes/library/curve.py
size: 27068
sha: e3b347e1a7f3a942a8122873c6056ab9b5cbfc55
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/curve.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/curve.py

## File: skills/procedural-blender-nodes/library/materials.py
size: 42527
sha: a10098c6aa580868cf307b46d15d6d75857e693e
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/materials.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/materials.py

## File: skills/procedural-blender-nodes/library/motion.py
size: 21300
sha: 82063d86a89e45571501e9fc641b4984e2d369ea
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/motion.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/motion.py

## File: skills/procedural-blender-nodes/library/post.py
size: 6216
sha: cbdea45f353453d34129c6a600c4527f2affc59a
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/post.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/post.py

## File: skills/procedural-blender-nodes/library/scatter.py
size: 23896
sha: 79dc3e4d690b071ba820bbd42e9ddb27dfba6578
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/scatter.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/scatter.py

## File: skills/procedural-blender-nodes/library/shapes.py
size: 39785
sha: 403d34bac4ce9216fe2059adbea9044689343cb1
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/shapes.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/library/shapes.py

## File: skills/procedural-blender-nodes/nodekit/__init__.py
size: 111
sha: eb29b81b23f087e4e81249e357ac9d4bd01b9e3e
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/__init__.py

```text
"""nodekit: build native Blender node graphs (Geometry, Shader, Compositor) from Python and read them back."""
```

## File: skills/procedural-blender-nodes/nodekit/graph.py
size: 16905
sha: 79f0bed096c0f80324f22735b289017faca3c226
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/graph.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/graph.py

## File: skills/procedural-blender-nodes/nodekit/inspect.py
size: 7700
sha: f4ee700c8cab98fdf382a67727feda9a4f3b9182
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/inspect.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/inspect.py

## File: skills/procedural-blender-nodes/nodekit/library.py
size: 2232
sha: 853583e0281cf7ad3e7da381b264aef448988705
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/library.py

```text
"""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()}
```

## File: skills/procedural-blender-nodes/nodekit/materials.py
size: 846
sha: 2416d6ee6d1e433037eed93b3aebafea5ece9e79
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/materials.py

```text
"""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 m
```

## File: skills/procedural-blender-nodes/nodekit/scene.py
size: 10133
sha: 4c8b170b48bea2fc7c544308078de0e78520b7dc
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/scene.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/nodekit/scene.py

## File: skills/procedural-blender-nodes/templates/curve_fence.py
size: 34589
sha: 4ec29e77e4773199221348975ec4e917930b73e6
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/curve_fence.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/curve_fence.py

## File: skills/procedural-blender-nodes/templates/growth_sim.py
size: 65051
sha: 090effa430f9d4a2a31606e30dceae01ee817628
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/growth_sim.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/growth_sim.py

## File: skills/procedural-blender-nodes/templates/parametric_house.py
size: 68065
sha: 5346de6d4b93ee3237b73ae4aa586192823f09ab
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/parametric_house.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/parametric_house.py

## File: skills/procedural-blender-nodes/templates/surface_scatter.py
size: 45474
sha: 00991d1423886b1359767e15b2fa74cd7200087b
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/surface_scatter.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/surface_scatter.py

## File: skills/procedural-blender-nodes/templates/wind_bunting.py
size: 30172
sha: c021433676c7599ca000863fcda2a3705624f592
upstream: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/wind_bunting.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/gokayfem/video-to-blender/blob/main/skills/procedural-blender-nodes/templates/wind_bunting.py

## File: README.md
upstream: https://github.com/gokayfem/video-to-blender/blob/main/README.md

````text
# 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.

<p align="center"><img src="gallery/storybook/render.jpg" width="100%" alt="Storybook Street: a frame rendered from the node graphs"></p>

<p align="center"><img src="gallery/storybook/blender.jpg" width="100%" alt="The same scene inside Blender: viewport, shader nodes, geometry nodes and the modifier controls"></p>

| The top-level graph: one modifier builds the whole street | One part opened: the houses, framed block by block |
|---|---|
| ![Storybook Street top-level graph](gallery/storybook/graph_Storybook_Street.jpg) | ![Houses graph](gallery/storybook/graph_SS_Facades.jpg) |

**Storybook Street** ([`prompts/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 check` builds and evaluates every group with zero warnings and
  zero lint. `validate.py` reopens 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`](prompts/reference-to-nodes-prompt.md)), not a requirement.

## Install

**Claude Code** (plugin marketplace):

```bash
claude plugin marketplace add gokayfem/video-to-blender
```

```bash
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](https://github.com/vercel-labs/skills)):

```bash
npx skills add gokayfem/video-to-blender
```

Or install by hand:

- **Codex:** copy `skills/procedural-blender-nodes` to `~/.agents/skills/`.
- **Cursor:** copy it to `~/.cursor/skills/`, plus the rule in `adapters/cursor/`.
- **Copilot:** `gh skill install gokayfem/video-to-blender procedural-blender-nodes`, plus `adapters/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/`](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:

```bash
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:

- `search` ranks library groups by name, summary, tags and uses.
- `try` builds one group on a test input, prints its node warnings and lint, and renders a still.
- `validate` reopens 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`](skills/procedural-blender-nodes/references/library-index.md).

## Storybook Street in detail, and the templates

The top of this page is [`prompts/storybook-street/`](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 |
|---|---|---|
| ![reference vs reconstruction](prompts/storybook-street/docs/media/reference_vs_reconstruction.jpg) | ![variations](prompts/storybook-street/docs/media/variations.jpg) | ![transfer](prompts/storybook-street/docs/media/transfer.jpg) |

Its [workflow](prompts/storybo
````
