Claude Skill

scenario-patina-retexture

Use when retexturing an existing 3D asset with PATINA PBR materials through the Scenario MCP server: a GLB or Blender scene (an image-to-3D result with flat colors, a prop, a diorama) whose surfaces need basecolor, normal, roughness, metalness, and height maps per material family

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_scenario-patina-retexture-dc592af.zip · 44 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-patina-retexture
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Scenario PATINA retexture

Overview

A mesh whose materials are flat colors or placeholders (an image-to-3D result, a whole diorama) becomes a PBR-shaded asset in three moves: group its materials into physical surface families, generate one seamless PATINA material per family on Scenario, and wire the five maps into Blender materials with vertices, faces, and transforms left unchanged. An optional film renders the original and the retextured scene along one camera path with the same lights, exposure, and cut, so the material response is the only thing that differs between the two panels. The film is rendered locally to present the 3D deliverable, never generated on Scenario, so the skill's output type stays 3D.

PATINA is Patina AI's material family on Scenario (tag sc:texture). This skill runs PATINA Material, the prompt-to-material member; the flat-texture members (Image to Maps, Material Extract) belong to scenario-textures. Repainting a whole mesh with a 3D-to-3D texturing model is the scenario-3d lane; this one keeps per-material control and editable Blender shaders. The agent's own steps stay on MCP; the bundled scripts (Blender 4.2 or newer, Python 3 with Pillow and numpy, ffmpeg) do the local repetition and never call Scenario. Connection and the core loop: the scenario skill. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

Quick reference

Step Tool Notes
Scope teams_list, projects_list The source asset and the generation destination may live in different projects: pass the right pair on every call
Fetch the mesh asset_get, then asset_download properties.materialCount, faceCount, hasUVs, dimensions; save with curl -fL
Inventory inventory.py in Blender Writes Before.blend and inventory.json: per-material object counts, bounds, base color
Find PATINA search with target="models", query="patina", public=true Pick PATINA Material (txt2img); model_schema_get before running
Price model_run with dry_run=true One 1024x1024 five-map set was 17 CU at authoring time; width, height, maps, upscaleFactor, and numOutputs move it
Generate model_run with wait=false, one per family Fixed seed per family; one jobs_wait over every job_id
Identify maps asset_get on each output metadata.type names the role; never rely on position
Download asset_download, then curl -fL textures/<family>/<role>.png
Apply apply_materials.py in Blender Manifest in materials-manifest.md; geometry hash asserted
Film film.py pilot, run, status Config in film-config.md; resumable

Material families

Read inventory.json, not the scene. Group materials by what the surface physically is (plaster, cedar, terracotta, glazed ceramic, brass, painted steel, glass, canvas, asphalt, foliage), reusing one family across color variants: the shader keeps each original color as a tint under the PATINA basecolor by default, so one plaster set serves cream, ochre, and gray walls. Split a shared color that sits on different surfaces (the same green on a canvas awning and a painted sign) with an object_overrides entry keyed by object-name prefix. A 41-material diorama needed 14 families; expect one job per family. Where the mesh already models bricks, roof tiles, or planks, prompt for the material of one element (fired clay, weathered cedar), never for the pattern: the geometry supplies the structure and a patterned map doubles it.

Generating one set per family

model_schema_get on the discovered id is the contract; the names below held at authoring time. prompt (required, 2048 characters) describes a flat, evenly lit, seamless physical surface with no objects or signage. maps is an array even for one entry (a bare string is ignored); keep the default of all five. width and height run 512 to 2048 in steps of 16, 1024 by default, and tileSize (128, about 1024 px) follows them: 256 for a 2048 set, or the repeat window no longer matches the texture. upscaleFactor 2 or 4 enlarges the predicted maps only, not the base texture. tilingMode restricts the repeat to one axis for planks or corrugation. numOutputs returns up to 4 variants of one prompt, each priced. enablePromptExpansion is on by default: turn it off when the prompt is deliberate. Set seed per family and keep it in the manifest so a family can be regenerated identically.

Keep dry_run and wait at the top level of model_run, beside model_id, team_id, and project_id; only model inputs belong under parameters. Price the exact payload with dry_run=true (a cost estimate, not parameter validation), confirm the budget (families times cost), then launch every family with wait=false and one jobs_wait over all the job ids (32 per call), re-called with pending_job_ids on timeout rather than answered with a second model_run. On a transport error, jobs_list before re-running: the job usually landed.

With the default five maps and numOutputs 1, a completed job carried six asset ids at authoring time: the base texture (metadata.type inference-txt2img-texture) and one asset per map, labeled texture-albedo, texture-normal, texture-smoothness, texture-metallic, and texture-height; numOutputs multiplies the set. Treat the metadata.type values read back with asset_get as the contract and map them to the five roles, never the order. The roughness request comes back labeled smoothness, and at authoring time the values were roughness (a brushed brass map read darker than a matte gray one), so the label is the trap, not the data: asset_display that map for a family whose finish you know before touching roughness_is_smoothness in the manifest, which inverts it when true. asset_download each map (PNG by default) into textures/<family>/<role>.png, and record prompt, seed, job id, asset ids, scope pair, and billed cuCost under the manifest's provenance.

Applying in Blender

blender --background --factory-startup --python scripts/inventory.py -- model.glb work/ imports the mesh (GLB, glTF, OBJ, or FBX) or opens a .blend, saves a packed work/Before.blend, and writes work/inventory.json. Write work/materials.json mapping every material name to a family and every family to its five maps (the manifest reference has every key and default), then run blender --background --factory-startup work/Before.blend --python scripts/apply_materials.py -- work/materials.json. It validates the manifest, projects a dedicated PatinaUV layer along each face's dominant axis at object scale so tile_span means meters per repeat (a mesh shared by linked duplicates is made single-user first), builds one Principled BSDF per original material and family, packs the images, and asserts the geometry hash is unchanged. The .blend is the deliverable: a glTF export flattens the calibrated shader graph (the exporter carries only direct texture links), so bake before exporting to an engine. Open the result or render one still before spending render time on a film.

Comparison film

python3 scripts/film.py config.json pilot renders both endpoints of every shot at half resolution for both passes and writes Pilot Contact.jpg with a rough time estimate. Fix framing and material defects from that sheet, then run renders at native resolution, resumes valid frames, saves both camera scenes as editable .blend files, and assembles and verifies the films; status prints progress. The run folder is fingerprinted from the inputs, the scripts, and the config, so a change to any of them starts a new folder. The entry points share common.py and scene.py, and film.py launches worker.py inside Blender; none of the three is run directly.

Design shots from inventory.json bounds, not from the example plan, whose coordinates fit one asset. The fast preset (EEVEE, 24 samples, half-resolution ray-traced reflections with denoising, 12 fps sources interpolated to 24 fps for slow moves) ran at about a second per 2368x1332 frame on a laptop, so the example plan's 792 source frames per pass take roughly a quarter hour each; refine from pilot_estimate.json, and keep source_fps 24 for fast motion. Verify with review/Sweep Contact.jpg, sampled at 2 fps across the whole master: one still per shot passes a defect that fades in mid-shot. A clean encoder log is not visual approval.

Worked example: retexture a street diorama and film the comparison

  1. Use the team/project pair already authorized by the user, or enumerate with teams_list and projects_list and ask them to choose. Unattended without an authorized pair, stop before scoped calls and report the missing scope. asset_get on the diorama (an image-to-3D result: 41 materials, no image textures, 78K faces), asset_download, then curl -fL -o work/street.glb "<url>".
  2. Inventory with inventory.py; group the 41 materials into families such as plaster, cedar, terracotta, ceramic, brass, steel, glass, canvas, asphalt, and leaf, with an override sending the awning objects to canvas.
  3. search for PATINA, then model_schema_get on the returned PATINA Material id; re-discover each session.
  4. model_run with top-level dry_run=true on the exact family payloads. At authoring time, 17 CU per family made 238 CU for fourteen; use the current estimates. Proceed within an already authorized budget or ask the user to approve the total. Unattended without enough authorized budget, return the estimates and stop before generation.
  5. Fourteen model_run calls with top-level wait=false, prompts such as "aged lime plaster wall, fine mineral grain, flat, evenly lit, seamless", seed fixed per family; one jobs_wait with all fourteen ids until every row is complete.
  6. asset_get on each output to read metadata.type; asset_display the smoothness-labeled plaster map to settle polarity; asset_download and curl -fL every map into textures/<family>/.
  7. Write materials.json (roughness ranges per family, roughness_is_smoothness as settled, provenance with job ids and cost) and run apply_materials.py; open PATINA.blend once.
  8. Copy example-config.json, replace the shots with ones framed from the inventory bounds, run film.py in pilot mode, inspect, then run; hand over the master, the sharing copy, both passes, and the two editable scenes, and name the interpolation used.
  9. Optional: follow the scenario skill's multipart upload flow: upload_asset with kind: "video", file_name, content_type: "video/mp4", and file_size in bytes (omit data), PUT every part to its returned URL, then upload_asset_complete with the returned upload_id and the same scope. Only after completion returns the video asset id, file it with the source and the maps in a collection (collection_create, then collection_add_assets: catalog tools, run through scenario_tool_execute_write with their arguments under parameters).

Common mistakes

  • Mapping outputs by position or by the source field alone: read metadata.type, and confirm the smoothness label's polarity before inverting.
  • Prompting for bricks, tiles, or planks on a mesh that already models them: the pattern doubles.
  • Asking a PATINA prompt for signage, objects, or lighting: it produces a material, not a scene.
  • Regenerating a family because jobs_wait timed out or model_run raised a transport error: re-call with pending_job_ids, or jobs_list first.
  • Running the full film before a pilot, or approving it from one still per shot instead of the 2 fps sweep sheet.
  • Editing the config or a script mid-run and expecting the render to resume: the fingerprint changed, so it starts fresh.
  • Hardcoding the PATINA id or the 17 CU figure: re-discover with search, re-price with dry_run.
Files (skills)
  • references
    • example-config.json 2.3 KB
      {
        "project_root": ".",
        "before": "work/Before.blend",
        "after": "work/PATINA.blend",
        "output": "video/automatic",
        "title": "STREET DIORAMA",
        "before_label": "Original materials",
        "after_label": "PATINA textures",
        "width": 2368,
        "height": 1332,
        "fps": 24,
        "source_fps": 12,
        "shot_seconds": 5.5,
        "transition": 0.5,
        "samples": 24,
        "workers": 1,
        "rig_scale": 1,
        "rig_origin": [0, 0, 0],
        "shots": [
          {
            "name": "Architecture",
            "start": [[0, 0.3, 4.2], [-0.55, -1, 0.58], 20.5],
            "end": [[0, 0.3, 4.2], [-0.38, -1, 0.54], 19.8]
          },
          {
            "name": "Glazed Ceramic",
            "start": [[2.7, 0.18, 7.25], [0.32, -1, 0.25], 3.8],
            "end": [[2.7, 0.18, 7.25], [-0.2, -1, 0.18], 3.35]
          },
          {
            "name": "Brass In Detail",
            "start": [[2.81, -0.16, 6.9], [-0.22, -1, 0.17], 1.85],
            "end": [[2.81, -0.16, 6.9], [0.22, -1, 0.1], 1.68]
          },
          {
            "name": "Terracotta Roof",
            "start": [[1.6, -0.56, 5.94], [0.24, -1, 1.0], 3.5],
            "end": [[1.82, -0.55, 5.97], [-0.12, -1, 0.85], 3.0]
          },
          {
            "name": "Cedar And Joinery",
            "start": [[-1.07, -1.5, 4.23], [-0.23, -1, 0.13], 2.7],
            "end": [[-0.97, -1.5, 4.27], [0.18, -1, 0.1], 2.45]
          },
          {
            "name": "Plaster And Aged Metal",
            "start": [[-1.08, -1.32, 6.2], [-0.2, -1, 0.18], 2.55],
            "end": [[-0.9, -1.32, 6.18], [0.15, -1, 0.14], 2.25]
          },
          {
            "name": "Woven Canvas",
            "start": [[-1.65, -1.64, 2.96], [-0.2, -1, 0.65], 1.65],
            "end": [[-1.4, -1.63, 2.96], [0.18, -1, 0.55], 1.45]
          },
          {
            "name": "Shopfront",
            "start": [[-0.52, -1.6, 2.16], [-0.32, -1, 0.22], 5.0],
            "end": [[-0.28, -1.6, 2.2], [0.2, -1, 0.2], 4.7]
          },
          {
            "name": "Paving And Street",
            "start": [[-0.9, -2.04, 1.22], [-0.38, -1, 1.45], 3.5],
            "end": [[-0.6, -2.04, 1.22], [0.2, -1, 1.3], 3.1]
          },
          {
            "name": "Enamel And Tram",
            "start": [[-3.35, 2.28, 2.02], [-0.4, -1, 0.43], 5.3],
            "end": [[-3.35, 2.28, 2.02], [-0.65, -1, 0.4], 5.1]
          },
          {
            "name": "Side Street Signage",
            "start": [[-2.8, 1.28, 4.62], [-1, -0.25, 0.2], 3.7],
            "end": [[-2.8, 1.28, 4.62], [-1, 0.15, 0.15], 3.3]
          },
          {
            "name": "The Finished Scene",
            "start": [[0, 0.3, 4.2], [0.64, -1, 0.56], 19.8],
            "end": [[0, 0.3, 4.2], [0.4, -1, 0.62], 21.0]
          }
        ]
      }
      
    • film-config.md 11.4 KB
      # Film configuration and commands
      
      `film.py` drives Blender and ffmpeg locally and makes no Scenario calls. Requirements: Blender 4.2 or newer with EEVEE ray tracing (written against 5.x), Python 3 with Pillow (10.1 or newer when no `.ttf` is configured and no system font is found) and numpy, ffmpeg and ffprobe with libx264.
      
      ```sh
      python3 scripts/film.py config.json pilot
      python3 scripts/film.py config.json run
      python3 scripts/film.py config.json status
      python3 scripts/film.py config.json assemble
      ```
      
      `--blender PATH` overrides the config `blender` key; that key, then the `BLENDER` environment variable, is used when set and may be an executable path or a command name looked up on `PATH`; a set value that names a missing file is an error rather than a fallback, and a directory (a macOS `Blender.app` bundle) is rejected with a message pointing at `Contents/MacOS/Blender`; when neither is set, `blender` on `PATH` and then the macOS application bundle are tried.
      
      ## Modes
      
      - `pilot` renders the first and last frame of every shot for both passes at half resolution, writes `Pilot Contact.jpg` (before above after, per shot), and a `pilot_estimate.json` whose time figure scales the pilot by the pixel ratio and includes process startup: a rough estimate, not a promise.
      - `run` renders every source frame at native resolution in bounded Blender batches, skipping frames that already exist and decode, saves `Original Camera Animation.blend` and `Patina Camera Animation.blend` (the editable scenes), then assembles and verifies.
      - `assemble` repeats the assembly and verification after an interrupted encode.
      - `status` writes and prints `status.json`: stage, frames rendered per pass, frames required, run folder.
      
      ## Configuration
      
      Copy [example-config.json](example-config.json) and replace everything that describes the asset. `project_root` is relative to the JSON file; `before`, `after`, `output`, `environment`, `font`, and `bold_font` are relative to `project_root` (absolute paths are kept as given). Both scenes must hold the same mesh geometry, transforms, and object names (only the materials differ): `pilot` and `run` each compare a geometry hash, a camera hash, and the render settings between the two passes, but only once both passes have rendered, so a mismatch costs a few endpoint frames in `pilot` and the whole native render in `run`.
      
      | Field                                  | Default                                                       | Effect                                                                                                                                                                                        |
      | -------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `before`, `after`                      | required                                                      | The two packed `.blend` scenes                                                                                                                                                                |
      | `shots`                                | required                                                      | Ordered list, see below                                                                                                                                                                       |
      | `title`, `before_label`, `after_label` | `PBR MATERIAL STUDY`, `Original materials`, `PATINA textures` | Header text, and the two labels drawn after fixed `BEFORE  /  ` and `AFTER  /  ` prefixes (do not repeat before/after in a label); the footer IDENTICAL GEOMETRY / CAMERA / LIGHTING is fixed |
      | `width`, `height`                      | `2368`, `1332`                                                | Native panel resolution; the stacked master adds padding and captions (2560x3200 at these)                                                                                                    |
      | `fps`, `source_fps`                    | `24` (fixed), `12`                                            | Delivery cadence, always the integer 24 (`24.0` or any other value fails validation), and rendered cadence, 12 or 24: 12 is interpolated to 24, 24 renders every frame                        |
      | `shot_seconds`, `transition`           | `5.5`, `0.5`                                                  | Raw shot length and crossfade overlap; the crossfade must last at least one source frame (`1/source_fps`, 0.0833 s at 12 fps) and less than `shot_seconds`; hard cuts (`0`) are rejected      |
      | `samples`                              | `24`                                                          | EEVEE render samples                                                                                                                                                                          |
      | `workers`                              | `1`                                                           | Simultaneous Blender processes, 1 or 2; two only with measured memory headroom                                                                                                                |
      | `rig_scale`, `rig_origin`              | `1`, `[0, 0, 0]`                                              | Scale and ground-level center of the light rig: the asset's height divided by about ten, and its footprint center                                                                             |
      | `environment`                          | Blender's bundled studio HDRI                                 | Path to a local HDRI for reflections; its content enters the run fingerprint and a missing file fails before Blender starts                                                                   |
      | `output`                               | `video/automatic`                                             | Parent of the fingerprinted run folders                                                                                                                                                       |
      | `font`, `bold_font`                    | first found system font                                       | Caption fonts for Pillow                                                                                                                                                                      |
      | `blender`, `ffmpeg`, `ffprobe`         | see above, `ffmpeg`, `ffprobe`                                | Executables; none of the three enters the run fingerprint                                                                                                                                     |
      
      Final duration is `shots x shot_seconds - (shots - 1) x transition`: twelve default shots make 60.5 seconds.
      
      ### Shots
      
      Each shot has `name`, `start`, and `end`; `name` labels the timeline marker and the contact sheets, and the master carries only `title`, `before_label`, and `after_label`. An endpoint is `[target_xyz, viewing_direction_xyz, visible_width]`: the point the 85 mm camera looks at, the direction from the target toward the camera (not normalized), and how wide the frame is at the target in scene units. Target and width ease smoothly between the endpoints; the viewing direction rotates at constant angular speed, and exactly opposite start and end directions arc overhead through world +Z, so a 180-degree turnaround is a legal shot. A narrow reflection strip light sweeps across the subject during every shot, blending from its wide to its close-up preset as the visible width moves from 7 down to 5 rig units, so roughness and normal response move on screen; a straight-down or straight-up view sweeps it around the rig's front side.
      
      Take targets from `inventory.json` bounds; check both endpoints on the pilot sheet for clipping and occlusion. The example plan opens wide, spends ten shots on one material each (glazed ceramic, brass, roof tiles, timber, plaster, canvas, a mixed shopfront, paving, painted metal, signage), and closes with a pullback. Its coordinates were framed for one public street diorama and fit nothing else.
      
      ## Fast preset and its cost
      
      The scripts set EEVEE with ray-traced reflections at half resolution, denoised, one shadow ray and six shadow steps, AgX view transform, no depth of field, a packed studio environment plus a warm grazing key, a cool rim, a soft fill, and the moving strip. The rig replaces the input scenes' own setup rather than adding to it: every light object is deleted, the world node tree is rebuilt around the packed environment, and a fresh camera named `Synchronized Comparison Camera` (85 mm on a 36 mm sensor, horizontal sensor fit, zero shift, no parent or constraints, depth of field off, clip 0.01 to at least 1000 units) becomes the scene camera while the source camera stays in the file unused; render border and crop, pixel aspect, stamp, motion blur, Freestyle, multiview, compositing, sequencer, simplify, and any color-management override are switched off, with display device sRGB, AgX with the Medium High Contrast look, exposure 0 and gamma 1. The `before` and `after` files are never written to; the rig lives only in the two saved `Camera Animation.blend` scenes. Rendering 12 fps sources for slow moves and interpolating to 24 fps halves render time at the price of motion detail; keep `source_fps` 24 for fast motion. Fewer samples leave more noise in close-ups, so read the sweep sheet before delivery.
      
      ## Run folder and resume
      
      The run folder name is a fingerprint of the two input files and the configured HDRI by content, the scripts, and the config (minus `workers`, executables, `output`, and `project_root`; file keys under `project_root` enter as relative paths and those outside it as resolved absolute paths, so moving or renaming a project keeps its run folder, while renaming an input file or editing the HDRI in place starts a new one). Resume works only while nothing in that set changes; a packed `.blend` is what makes texture edits part of the fingerprint. Each pass records a contract (`<mode>_contract.json`, with the render settings, the camera, and the Blender version); a resume into a folder whose contract differs lists every differing key with both values, and a Blender version difference alone does not block it. Partial or corrupt frames, clips, review stills, and contact sheets are moved to an `archive/` folder next to the replaced file, never overwritten in place; only `review/sweep/` is deleted and re-extracted when `Sweep Contact.jpg` is missing or any sweep frame fails to decode. Every `assemble` decodes every review still and sweep frame and verifies each joined pass right after encoding.
      
      Outputs in the run folder: `PATINA Comparison.mp4` (stacked master), `PATINA Comparison Share.mp4` (1440 wide), `Original Matched Camera.mp4` and `Patina Matched Camera.mp4` (the two landscape passes), the two editable scenes, `review/` (one mid-shot still per shot, `Sweep Contact.jpg` sampled at 2 fps across the master, `Final Contact.jpg`), `verification.json` (frame counts and durations from ffprobe for every movie, and the sweep path), and the frame and clip caches.
      
      Keep the movies, scenes, source assets, textures, manifests, and verification files. When asked to clean up, move `frames/`, `clips/`, `sequence/`, pilots, and old runs to an archive outside the delivery folder; archiving keeps files, it frees nothing, so say so.
      
    • materials-manifest.md 15.4 KB
      # Inventory and materials manifest
      
      Both files are read and written by the Blender scripts next to this reference. Paths inside `materials.json` are relative to that JSON file.
      
      ## inventory.json
      
      Written by `inventory.py` next to `Before.blend`. Read it instead of dumping the scene.
      
      | Key                        | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
      | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | `source`, `before`         | The file that was imported or opened, and the packed `Before.blend` written next to the inventory                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
      | `meshes`, `polygons`       | Mesh object count and total polygon count                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
      | `dimensions`               | World bounding box size in scene units, `[x, y, z]`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
      | `geometry_sha256`          | Hash of every vertex, polygon, and object transform; `apply_materials.py` asserts it is unchanged                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
      | `objects_without_material` | Object names with an empty slot; give them a material in Blender before applying, the script refuses them                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
      | `materials.<name>`         | `objects` (slot count), `examples` (up to 3 object names), `bounds.lo` and `bounds.hi` (world space, composed from object parenting so hidden or excluded meshes are placed correctly; bone or vertex parents, constraints, and drivers are not applied; shot targets in film-config.md start from these), `base_color` (RGBA, `null` when unknown) with `base_color_source` (`principled` for an unlinked Principled Base Color, `linked:<NODE_TYPE>` when another node drives the socket so the palette is unknown, `viewport` for an author-set viewport color, `library` for a linked material, or `none`), `has_image_textures` |
      | `linked_materials`         | `[{name, library}]`, library-linked materials the scripts do not inspect                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
      | `non_mesh_renderables`     | `[{name, type, materials}]`, objects that render but are not meshes (text, curves, metaballs, surfaces); `apply_materials.py` keeps their materials, so convert them to mesh before the inventory when they need PATINA                                                                                                                                                                                                                                                                                                                                                                                                              |
      | `packed`, `pack_error`     | Whether every external file was packed into `Before.blend`, and Blender's message when one was missing: that texture renders as a placeholder in the original pass and sits outside the run fingerprint                                                                                                                                                                                                                                                                                                                                                                                                                              |
      
      ## materials.json
      
      ```json
      {
        "output": "PATINA.blend",
        "provenance": {
          "model_id": "model_...",
          "team_id": "...",
          "project_id": "...",
          "jobs": { "plaster": { "job_id": "job_...", "seed": 3201, "cu": 17 } }
        },
        "materials": {
          "Wall_Cream": "plaster",
          "Wall_Ochre": "plaster",
          "Beam": "cedar",
          "Trim_Green": "steel"
        },
        "object_overrides": [{ "prefixes": ["Awning"], "family": "canvas" }],
        "families": {
          "plaster": {
            "maps": {
              "basecolor": "textures/plaster/basecolor.png",
              "normal": "textures/plaster/normal.png",
              "roughness": "textures/plaster/roughness.png",
              "metalness": "textures/plaster/metalness.png",
              "height": "textures/plaster/height.png"
            },
            "tile_span": 1.2,
            "normal_strength": 0.3,
            "bump_distance": 0.002,
            "roughness": [0.55, 0.95],
            "metalness": [0, 0],
            "roughness_is_smoothness": false,
            "basecolor_mode": "tint"
          }
        }
      }
      ```
      
      | Key                       | Default                | Effect                                                                                                                                                                                                                                                                                                                                                                        |
      | ------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `output`                  | required               | Output `.blend`; an existing file is refused, never overwritten                                                                                                                                                                                                                                                                                                               |
      | `provenance`              | none                   | A JSON object with free-form contents (a string or list fails validation); stored as the `patina_provenance` custom property (a JSON string) on every generated material, beside `patina_family` and `original_material`                                                                                                                                                      |
      | `materials`               | required               | Every material in the scene maps to a family, unless an override covers all of its objects                                                                                                                                                                                                                                                                                    |
      | `object_overrides`        | `[]`                   | Object-name prefixes that send every material slot on a matching object to the named family, over the material mapping; the first listed match wins when prefixes overlap. The way to split one shared color across two surfaces, so check each overridden object's other materials in `inventory.json` `examples`: a brass rod slot on an `Awning` object becomes canvas too |
      | `families.<f>.maps`       | required               | All five roles as paths under the manifest; each existing file is opened with Pillow when available and an unreadable one is refused; one file may serve several families but not both as a basecolor and as a data map (copy it), and one datablock is packed per file                                                                                                       |
      | `tile_span`               | required               | Meters per texture repeat on the `PatinaUV` layer: 0.3 for a hand-sized detail, 1 to 2 for walls and paving                                                                                                                                                                                                                                                                   |
      | `normal_strength`         | `0.25`                 | Normal map node strength                                                                                                                                                                                                                                                                                                                                                      |
      | `bump_distance`           | `0.002`                | Height feeds a Bump node at this distance; there is no displacement, so geometry never moves                                                                                                                                                                                                                                                                                  |
      | `roughness`, `metalness`  | `[0.3, 0.7]`, `[0, 0]` | The map's 0 to 1 range is remapped into these bounds; `[0.8, 1]` metalness for brass, `[0, 0]` for dielectrics, a narrow roughness range for a uniform finish                                                                                                                                                                                                                 |
      | `roughness_is_smoothness` | `false`                | Inverts the roughness map first; settle it by looking at a known family, see the skill body                                                                                                                                                                                                                                                                                   |
      | `basecolor_mode`          | `"tint"`               | `tint` multiplies the original palette color by the map's luminance variation, keeping the asset's colors; `replace` uses the PATINA basecolor as is                                                                                                                                                                                                                          |
      
      Family names `ceramic`, `enamel`, and `plastic` add a coat layer, `glass` adds coat plus a little transmission at IOR 1.46, and `leaf` adds subsurface. Other names get the plain PBR stack.
      
      Validation runs before the scene is touched and lists every problem at once: a manifest that is not a JSON object, an unmapped material, a non-string family name, an undefined family, a missing or unreadable map file, a family missing a role, a range outside 0 to 1, a negative or non-numeric `normal_strength` or `bump_distance`, an override without a non-empty list of non-empty string `prefixes` and a string `family`, and a file used both as a basecolor and as a data map. Both scripts exit 1 with a one-line `inventory failed:` or `apply_materials failed:` message after the traceback on any failure, with or without Blender's `--python-exit-code 1`.
      
      ## Checks before the film
      
      - Self-tile the basecolor of each family as a 2x2 sheet. A visible border means the generation carried tile edges: check that `tileSize` matched the resolution (128 for 1024, 256 for 2048), then regenerate that family with another seed, or crop the interior and re-tile locally before wiring it. The script does not detect borders.
      - Open the output `.blend` once. The planar projection suits architecture; a curved prop, a UV seam, or wood grain running the wrong way is fixed by keeping that object's authored UVs or rotating its projection in Blender.
      - The report sits next to the output with `.blend` replaced by `.materials.json` (`PATINA.blend` gives `PATINA.materials.json`; the script's printed result names it under `report`) and lists the geometry hash before and after, the material variants created, the packed image count (unique map files, so it can be below families times five), and `non_mesh_renderables`; the material count is original materials times families they map to, so 41 materials over 14 families produced 43 variants in one project.
      
  • scripts
    • apply_materials.py 22.1 KB
      """Wire PATINA PBR map sets into Blender materials without touching geometry.
      
          blender --background --factory-startup <Before.blend> \
              --python scripts/apply_materials.py -- <materials.json>
      
      Paths in the manifest are relative to the manifest file. Every material on a mesh object must
      be mapped to a family or covered by an object override; the manifest is validated in full
      before the scene is modified, and geometry is hashed before and after as proof that only
      materials changed. Other object types keep their materials and are listed in the result under
      non_mesh_renderables.
      """
      
      from __future__ import annotations
      
      import json
      import sys
      import traceback
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      
      from common import (  # noqa: E402
          dump,
          ensure_nodes,
          geometry_hash,
          is_number,
          script_args,
          world_matrix,
      )
      from inventory import non_mesh_renderables, principled_node  # noqa: E402
      
      ROLES = ("basecolor", "normal", "roughness", "metalness", "height")
      BASECOLOR_MODES = ("tint", "replace")
      UV_LAYER = "PatinaUV"
      PREFIX = "PATINA | "
      COAT_WEIGHT = {"ceramic": 0.22, "enamel": 0.12, "plastic": 0.12}
      LUMINANCE_WEIGHTS = (0.2126, 0.7152, 0.0722)
      FAMILY_DEFAULTS = {
          "normal_strength": 0.25,
          "bump_distance": 0.002,
          "roughness": [0.3, 0.7],
          "metalness": [0, 0],
          "roughness_is_smoothness": False,
          "basecolor_mode": "tint",
      }
      
      
      def load_manifest(path) -> tuple[dict, Path]:
          manifest_path = Path(path).resolve()
          manifest = json.loads(manifest_path.read_text())
          if not isinstance(manifest, dict):
              raise SystemExit(f"Manifest problems:\n  {manifest_path}: must be a JSON object")
          return manifest, manifest_path.parent
      
      
      def family_settings(family: dict) -> dict:
          settings = dict(FAMILY_DEFAULTS)
          settings.update(family)
          return settings
      
      
      def colorspace(role: str) -> str:
          return "sRGB" if role == "basecolor" else "Non-Color"
      
      
      def override_family(object_name: str, overrides: list) -> str | None:
          for override in overrides:
              if object_name.startswith(tuple(override.get("prefixes", []))):
                  return override.get("family")
          return None
      
      
      def usable_override(override) -> bool:
          # A bare string under startswith is a tuple of its characters, and an empty prefix
          # matches every object, so both would retexture the whole scene with one family.
          return (
              isinstance(override, dict)
              and isinstance(override.get("prefixes"), list)
              and bool(override["prefixes"])
              and all(isinstance(prefix, str) and prefix for prefix in override["prefixes"])
              and isinstance(override.get("family"), str)
              and bool(override["family"])
          )
      
      
      def is_unit_range(value) -> bool:
          return (
              isinstance(value, (list, tuple))
              and len(value) == 2
              and all(is_number(v) for v in value)
              and all(0 <= v <= 1 for v in value)
          )
      
      
      def unreadable_image(path: Path) -> str | None:
          """Why Pillow rejects the file, or None when it reads or when Pillow cannot judge the format."""
          try:
              from PIL import Image
          except ImportError:
              return None
          if path.suffix.lower() not in Image.registered_extensions():
              return None
          try:
              with Image.open(path) as image:
                  image.verify()
          except Exception as exc:
              return str(exc) or type(exc).__name__
          return None
      
      
      def validate_manifest(manifest: dict, root: Path, objects_by_material: dict) -> list[str]:
          """Every problem with the manifest against the open scene, so one run reports them all.
      
          objects_by_material maps each scene material name to the names of the objects using it.
          """
          problems = []
          if not isinstance(manifest.get("output"), str) or not manifest["output"]:
              problems.append("output: required .blend path is missing")
          materials = manifest.get("materials", {})
          families = manifest.get("families", {})
          overrides = manifest.get("object_overrides", [])
          if not isinstance(materials, dict):
              problems.append("materials: must map material names to family names")
              materials = {}
          if not isinstance(families, dict):
              problems.append("families: must map family names to their definitions")
              families = {}
          if not isinstance(overrides, list):
              problems.append("object_overrides: must be a list of {prefixes, family}")
              overrides = []
          for name, family in sorted(materials.items()):
              if not isinstance(family, str) or not family:
                  problems.append(f"materials[{name!r}]: must be a family name")
          for index, override in enumerate(overrides):
              if not usable_override(override):
                  problems.append(
                      f"object_overrides[{index}]: needs a prefixes list of non-empty strings"
                      " and a family"
                  )
          overrides = [o for o in overrides if usable_override(o)]
          for name, objects in sorted(objects_by_material.items()):
              if name in materials:
                  continue
              if objects and all(override_family(obj, overrides) for obj in objects):
                  continue
              problems.append(
                  f"material {name!r} is neither mapped in materials nor covered by an override"
              )
          referenced = {f for f in materials.values() if isinstance(f, str)}
          referenced |= {o["family"] for o in overrides}
          for family in sorted(f for f in referenced if f and f not in families):
              problems.append(f"family {family!r} is referenced but not defined")
          spaces_by_path = {}
          for name, family in sorted(families.items()):
              if not isinstance(family, dict):
                  problems.append(f"family {name!r}: must be an object")
                  continue
              span = family.get("tile_span")
              if not is_number(span) or span <= 0:
                  problems.append(f"family {name!r}: tile_span must be a positive number of scene units")
              maps = family.get("maps", {})
              if not isinstance(maps, dict):
                  problems.append(f"family {name!r}: maps must map roles to files")
                  maps = {}
              missing = [role for role in ROLES if role not in maps]
              if missing:
                  problems.append(f"family {name!r}: missing map roles {', '.join(missing)}")
              for role in ROLES:
                  if role not in maps:
                      continue
                  relative = maps[role]
                  if not isinstance(relative, str) or not relative:
                      problems.append(f"family {name!r}: {role} map must be a path under the manifest")
                      continue
                  path = root / relative
                  if not path.is_file():
                      problems.append(f"family {name!r}: {role} map {relative} not found under {root}")
                      continue
                  reason = unreadable_image(path)
                  if reason:
                      problems.append(
                          f"family {name!r}: {role} map {relative} is not a readable image ({reason})"
                      )
                  spaces_by_path.setdefault(path.resolve(), set()).add(colorspace(role))
              for key in ("roughness", "metalness"):
                  if key in family and not is_unit_range(family[key]):
                      problems.append(f"family {name!r}: {key} must be two numbers between 0 and 1")
              for key in ("normal_strength", "bump_distance"):
                  if key in family and (not is_number(family[key]) or family[key] < 0):
                      problems.append(f"family {name!r}: {key} must be a number of at least 0")
              if family.get("basecolor_mode", "tint") not in BASECOLOR_MODES:
                  modes = ", ".join(BASECOLOR_MODES)
                  problems.append(f"family {name!r}: basecolor_mode must be one of {modes}")
              if not isinstance(family.get("roughness_is_smoothness", False), bool):
                  problems.append(f"family {name!r}: roughness_is_smoothness must be true or false")
          # One file is one image datablock with one color space, however many roles point at it.
          for path, spaces in sorted(spaces_by_path.items()):
              if len(spaces) > 1:
                  problems.append(f"map {path} is used both as a basecolor and as a data map; copy it")
          if "provenance" in manifest and not isinstance(manifest["provenance"], dict):
              problems.append("provenance: must be an object")
          return problems
      
      
      def projected_uv(co, normal, scale) -> tuple[float, float]:
          """Project a vertex along the polygon's dominant axis, in object-scaled (meter) units.
      
          Scaling by the object scale first means a stretched facade keeps the same texel
          density as an unscaled one, and the sign flip keeps tangents consistent so normal
          maps read the same way on opposite faces.
          """
          axis = max(range(3), key=lambda i: abs(normal[i]))
          sign = 1 if normal[axis] >= 0 else -1
          x, y, z = (co[i] * scale[i] for i in range(3))
          if axis == 0:
              return (y * sign, z)
          if axis == 1:
              return (-x * sign, z)
          return (x * sign, y)
      
      
      def srgb_to_linear(values):
          import numpy as np
      
          values = np.asarray(values, dtype=np.float32)
          return np.where(values <= 0.04045, values / 12.92, ((values + 0.055) / 1.055) ** 2.4)
      
      
      def luminance_mean(linear) -> float:
          """Mean luminance of scene-linear RGB, floored so a tint never divides by zero."""
          import numpy as np
      
          linear = np.asarray(linear, dtype=np.float32).reshape((-1, 3))
          if linear.size == 0:
              raise ValueError("no pixels to average")
          return max(0.02, float((linear @ np.array(LUMINANCE_WEIGHTS, dtype=np.float32)).mean()))
      
      
      def linear_luminance_mean(rgb) -> float:
          """luminance_mean of sRGB-encoded pixels."""
          return luminance_mean(srgb_to_linear(rgb))
      
      
      def is_linear_buffer(image) -> bool:
          # Blender converts float buffers (16-bit PNG, EXR) to scene-linear on load and leaves byte
          # buffers in their stored color space. A 16-bit gray PNG reports depth 32, so is_float leads.
          return bool(image.is_float) or image.depth > 32
      
      
      def image_luminance_mean(image) -> float:
          import numpy as np
      
          # foreach_get fills the buffer directly; pixels[:] builds a Python float per channel.
          pixels = np.empty(len(image.pixels), dtype=np.float32)
          image.pixels.foreach_get(pixels)
          rgb = pixels.reshape((-1, 4))[:, :3]
          return luminance_mean(rgb) if is_linear_buffer(image) else linear_luminance_mean(rgb)
      
      
      def add_patina_uv(obj) -> None:
          mesh = obj.data
          layer = mesh.uv_layers.new(name=UV_LAYER)
          scale = tuple(world_matrix(obj).to_scale())
          for polygon in mesh.polygons:
              for loop_index in polygon.loop_indices:
                  co = mesh.vertices[mesh.loops[loop_index].vertex_index].co
                  layer.data[loop_index].uv = projected_uv(tuple(co), tuple(polygon.normal), scale)
          mesh.uv_layers.active = layer
          layer.active_render = True
      
      
      def original_color(material) -> tuple:
          """The color a tint multiplies: an unlinked Principled Base Color, else the viewport color."""
          node = principled_node(material)
          if node is not None and not node.inputs["Base Color"].is_linked:
              return tuple(node.inputs["Base Color"].default_value)
          return tuple(material.diffuse_color)
      
      
      class Applier:
          """Holds the image cache and built materials for one manifest."""
      
          def __init__(self, manifest: dict, root: Path):
              self.manifest = manifest
              self.root = root
              # Both caches key on the resolved file: a map shared by two families is one datablock,
              # packed once, and its mean is read once however many variants tint with it.
              self.images = {}
              self.luminance = {}
              self.made = {}
              self.report = []
      
          def map_path(self, family_name: str, role: str) -> Path:
              return (self.root / self.manifest["families"][family_name]["maps"][role]).resolve()
      
          def image(self, family_name: str, role: str):
              path = self.map_path(family_name, role)
              if path not in self.images:
                  import bpy
      
                  try:
                      image = bpy.data.images.load(str(path))
                  except RuntimeError as exc:
                      raise RuntimeError(f"family {family_name!r} {role} map: {exc}") from None
                  # Blender loads lazily and never raises on an unreadable file; a zero size is the tell.
                  if not all(image.size):
                      raise RuntimeError(f"family {family_name!r} {role} map {path}: unreadable image")
                  image.name = f"{PREFIX}{family_name} | {role}"
                  image.colorspace_settings.name = colorspace(role)
                  self.images[path] = image
              return self.images[path]
      
          def luminance_mean(self, family_name: str) -> float:
              path = self.map_path(family_name, "basecolor")
              if path not in self.luminance:
                  self.luminance[path] = image_luminance_mean(self.image(family_name, "basecolor"))
              return self.luminance[path]
      
          def variant_name(self, original_name: str, family_name: str) -> str:
              if self.manifest.get("materials", {}).get(original_name) == family_name:
                  return PREFIX + original_name
              return f"{PREFIX}{original_name} | {family_name}"
      
          def material(self, original, family_name: str):
              key = (original.name, family_name)
              if key not in self.made:
                  self.made[key] = self.build(original, family_name)
              return self.made[key]
      
          def build(self, original, family_name: str):
              import bpy
      
              family = family_settings(self.manifest["families"][family_name])
              color = original_color(original)
              material = bpy.data.materials.new(self.variant_name(original.name, family_name))
              material.diffuse_color = color
              material["patina_family"] = family_name
              material["original_material"] = original.name
              material["description"] = "PATINA PBR maps on a meter-scaled PatinaUV projection"
              if "provenance" in self.manifest:
                  material["patina_provenance"] = json.dumps(self.manifest["provenance"], sort_keys=True)
              tree = ensure_nodes(material)
              tree.nodes.clear()
              links = tree.links
      
              def node(kind, name, location):
                  created = tree.nodes.new(kind)
                  created.label = name
                  created.name = name
                  created.location = location
                  return created
      
              output = node("ShaderNodeOutputMaterial", "Material Output", (930, 140))
              bsdf = node("ShaderNodeBsdfPrincipled", "PATINA Surface", (620, 140))
              links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
              uv = node("ShaderNodeUVMap", "Dedicated PatinaUV", (-1200, 100))
              uv.uv_map = UV_LAYER
              tiling = node("ShaderNodeVectorMath", "Physical Tile Scale", (-1000, 100))
              tiling.operation = "SCALE"
              tiling.inputs[3].default_value = 1 / family["tile_span"]
              links.new(uv.outputs["UV"], tiling.inputs[0])
              textures = {}
              for role, y in zip(ROLES, (560, -430, 220, -100, -760)):
                  texture = node("ShaderNodeTexImage", f"PATINA {role}", (-750, y))
                  texture.image = self.image(family_name, role)
                  texture.extension = "REPEAT"
                  texture.interpolation = "Linear"
                  links.new(tiling.outputs["Vector"], texture.inputs["Vector"])
                  textures[role] = texture
              luminance = None
              if family["basecolor_mode"] == "replace":
                  links.new(textures["basecolor"].outputs["Color"], bsdf.inputs["Base Color"])
              else:
                  # Tint: PATINA luminance variation multiplied over the original palette color,
                  # normalized so the mean brightness of the map does not darken the palette.
                  luminance = self.luminance_mean(family_name)
                  gray = node("ShaderNodeRGBToBW", "Retain Surface Variation", (-490, 560))
                  links.new(textures["basecolor"].outputs["Color"], gray.inputs["Color"])
                  normalize = node("ShaderNodeMath", "Normalize PATINA Luminance", (-300, 560))
                  normalize.operation = "DIVIDE"
                  normalize.inputs[1].default_value = luminance
                  links.new(gray.outputs[0], normalize.inputs[0])
                  contrast = node("ShaderNodeMapRange", "Controlled Material Variation", (-100, 560))
                  contrast.inputs["From Min"].default_value = 0
                  contrast.inputs["From Max"].default_value = 2
                  contrast.inputs["To Min"].default_value = 0.35
                  contrast.inputs["To Max"].default_value = 1.65
                  contrast.clamp = True
                  links.new(normalize.outputs[0], contrast.inputs["Value"])
                  tint = node("ShaderNodeMixRGB", "Original Palette", (130, 560))
                  tint.blend_type = "MULTIPLY"
                  tint.inputs[0].default_value = 1
                  tint.inputs[1].default_value = color
                  links.new(contrast.outputs[0], tint.inputs[2])
                  links.new(tint.outputs[0], bsdf.inputs["Base Color"])
              for role, socket, y in (("roughness", "Roughness", 220), ("metalness", "Metallic", -50)):
                  remap = node("ShaderNodeMapRange", f"Calibrated {role}", (-150, y))
                  remap.inputs["To Min"].default_value = family[role][0]
                  remap.inputs["To Max"].default_value = family[role][1]
                  signal = textures[role].outputs["Color"]
                  if role == "roughness" and family["roughness_is_smoothness"]:
                      invert = node("ShaderNodeMath", "Convert Smoothness To Roughness", (-380, 180))
                      invert.operation = "SUBTRACT"
                      invert.inputs[0].default_value = 1
                      links.new(signal, invert.inputs[1])
                      signal = invert.outputs[0]
                  links.new(signal, remap.inputs["Value"])
                  links.new(remap.outputs[0], bsdf.inputs[socket])
              normal = node("ShaderNodeNormalMap", "PATINA Tangent Normal", (-280, -410))
              normal.uv_map = UV_LAYER
              normal.inputs["Strength"].default_value = family["normal_strength"]
              links.new(textures["normal"].outputs["Color"], normal.inputs["Color"])
              bump = node("ShaderNodeBump", "PATINA Height Microdetail", (80, -380))
              bump.inputs["Strength"].default_value = 0.25
              bump.inputs["Distance"].default_value = family["bump_distance"]
              links.new(textures["height"].outputs["Color"], bump.inputs["Height"])
              links.new(normal.outputs["Normal"], bump.inputs["Normal"])
              links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
              if family_name in COAT_WEIGHT:
                  bsdf.inputs["Coat Weight"].default_value = COAT_WEIGHT[family_name]
                  bsdf.inputs["Coat Roughness"].default_value = 0.16
              if family_name == "glass":
                  bsdf.inputs["IOR"].default_value = 1.46
                  bsdf.inputs["Coat Weight"].default_value = 0.30
                  bsdf.inputs["Transmission Weight"].default_value = 0.12
              if family_name == "leaf":
                  bsdf.inputs["Subsurface Weight"].default_value = 0.04
              self.report.append(
                  {
                      "material": material.name,
                      "original": original.name,
                      "family": family_name,
                      "basecolor_mode": family["basecolor_mode"],
                      "basecolor_mean_linear": luminance,
                      "tile_span": family["tile_span"],
                      "maps": len(ROLES),
                  }
              )
              return material
      
      
      def objects_by_material(objects) -> dict:
          grouped = {}
          for obj in objects:
              for slot in obj.material_slots:
                  if slot.material is not None:
                      grouped.setdefault(slot.material.name, []).append(obj.name)
          return grouped
      
      
      def apply(manifest: dict, root: Path) -> dict:
          import bpy
      
          scene = bpy.context.scene
          objects = [obj for obj in scene.objects if obj.type == "MESH"]
          problems = validate_manifest(manifest, root, objects_by_material(objects))
          for obj in objects:
              if not obj.material_slots or any(s.material is None for s in obj.material_slots):
                  problems.append(f"object {obj.name!r} has an empty material slot; assign one first")
          output = root / str(manifest.get("output", ""))
          if isinstance(manifest.get("output"), str) and manifest["output"] and output.exists():
              problems.append(f"output {output} exists; choose a new filename")
          if problems:
              raise SystemExit("Manifest problems:\n  " + "\n  ".join(problems))
          before_hash = geometry_hash(objects)
          overrides = manifest.get("object_overrides", [])
          applier = Applier(manifest, root)
          for obj in objects:
              # Each object gets its own mesh so the UV projection can follow its own scale.
              if obj.data.users > 1:
                  obj.data = obj.data.copy()
              add_patina_uv(obj)
              family = override_family(obj.name, overrides)
              for slot in obj.material_slots:
                  original = slot.material
                  mapped = family or manifest["materials"][original.name]
                  slot.material = applier.material(original, mapped)
          after_hash = geometry_hash(objects)
          if after_hash != before_hash:
              raise RuntimeError("Geometry changed while applying materials; output not saved")
          for image in applier.images.values():
              image.pack()
          untouched = non_mesh_renderables(bpy.context.view_layer.objects)
          output.parent.mkdir(parents=True, exist_ok=True)
          report = {
              "geometry_sha256_before": before_hash,
              "geometry_sha256_after": after_hash,
              "mesh_objects": len(objects),
              "materials": applier.report,
              "packed_images": len(applier.images),
              "non_mesh_renderables": untouched,
          }
          report_path = output.with_suffix(".materials.json")
          report_path.write_text(json.dumps(report, indent=2))
          bpy.ops.wm.save_as_mainfile(filepath=str(output))
          return {
              "output": str(output),
              "report": str(report_path),
              "meshes": len(objects),
              "material_variants": len(applier.made),
              "packed_images": len(applier.images),
              "non_mesh_renderables": untouched,
              "geometry_preserved": True,
          }
      
      
      def main(argv: list[str]) -> dict:
          if len(argv) != 1:
              raise SystemExit("usage: ... --python apply_materials.py -- <materials.json>")
          manifest, root = load_manifest(argv[0])
          result = apply(manifest, root)
          print(dump(result), flush=True)
          return result
      
      
      if __name__ == "__main__":
          try:
              main(script_args())
          except Exception as exc:
              traceback.print_exc()
              # Without --python-exit-code Blender exits 0 on an uncaught exception; SystemExit
              # sets the status either way.
              raise SystemExit(f"apply_materials failed: {exc}") from None
      
    • common.py 12.3 KB
      """Shared helpers for the PATINA retexture scripts: film config, fingerprints, hashes.
      
      Standard library only, so film.py can import it outside Blender and the Blender-side
      scripts can import it from Blender's bundled Python. Nothing here talks to Scenario.
      """
      
      from __future__ import annotations
      
      import hashlib
      import json
      import os
      import shutil
      import sys
      import warnings
      from pathlib import Path
      
      MACOS_BLENDER = "/Applications/Blender.app/Contents/MacOS/Blender"
      
      # Keys that never change what gets rendered, so they stay out of the run fingerprint.
      # project_root is the anchor the file paths are taken relative to, see run_directory.
      FINGERPRINT_EXCLUDED = ("workers", "blender", "ffmpeg", "ffprobe", "output", "project_root")
      
      # Config values that name files; read_config resolves them against project_root.
      FILE_KEYS = ("before", "after", "environment", "font", "bold_font")
      
      DEFAULTS = {
          "project_root": ".",
          "output": "video/automatic",
          "title": "PBR MATERIAL STUDY",
          "before_label": "Original materials",
          "after_label": "PATINA textures",
          "width": 2368,
          "height": 1332,
          "fps": 24,
          "source_fps": 12,
          "shot_seconds": 5.5,
          "transition": 0.5,
          "samples": 24,
          "workers": 1,
          "rig_scale": 1,
          "rig_origin": [0, 0, 0],
          "ffmpeg": "ffmpeg",
          "ffprobe": "ffprobe",
      }
      
      
      class ConfigError(ValueError):
          """The film config is unusable; the message lists every problem found."""
      
      
      def is_number(value) -> bool:
          return isinstance(value, (int, float)) and not isinstance(value, bool)
      
      
      def is_vector3(value) -> bool:
          if not isinstance(value, (list, tuple)) or len(value) != 3:
              return False
          return all(is_number(v) for v in value)
      
      
      def validate_config(config: dict) -> list[str]:
          """Return every problem with a config that already has its defaults applied."""
          problems = []
          for key in ("before", "after"):
              if not isinstance(config.get(key), str) or not config[key]:
                  problems.append(f"{key}: required path is missing")
          fps, source_fps = config["fps"], config["source_fps"]
          if (
              not isinstance(fps, int)
              or fps != 24
              or not isinstance(source_fps, int)
              or source_fps <= 0
              or fps % source_fps
          ):
              problems.append("fps/source_fps: use 12 or 24 source fps with 24 fps output")
          shot_seconds = config["shot_seconds"]
          if not is_number(shot_seconds) or shot_seconds <= 0:
              problems.append("shot_seconds: must be a positive number")
          elif isinstance(source_fps, int) and source_fps > 0:
              source_frames = shot_seconds * source_fps
              if int(source_frames) != source_frames:
                  problems.append("shot_seconds: must hold a whole number of source frames")
              transition = config["transition"]
              # xfade never runs a fade shorter than one frame: it drops the tail of the film instead.
              if is_number(transition) and not 1 / source_fps <= transition < shot_seconds:
                  problems.append(
                      "transition: hard cuts are not supported; the crossfade must last at least"
                      " one source frame (1/source_fps) and less than shot_seconds"
                  )
          if not is_number(config["transition"]):
              problems.append("transition: must be a number")
          if config["workers"] not in (1, 2):
              problems.append("workers: must be 1 or 2")
          for key in ("width", "height"):
              value = config[key]
              if not isinstance(value, int) or value <= 0 or value % 2:
                  problems.append(f"{key}: must be a positive even integer")
          if not isinstance(config["samples"], int) or config["samples"] <= 0:
              problems.append("samples: must be a positive integer")
          if not is_number(config["rig_scale"]) or config["rig_scale"] <= 0:
              problems.append("rig_scale: must be a positive number")
          if not is_vector3(config["rig_origin"]):
              problems.append("rig_origin: must be three numbers")
          shots = config.get("shots")
          if not isinstance(shots, list) or not shots:
              problems.append("shots: at least one shot is required")
              return problems
          for index, shot in enumerate(shots):
              label = f"shots[{index}]"
              if not isinstance(shot, dict) or not shot.get("name"):
                  problems.append(f"{label}: needs a name")
                  continue
              for key in ("start", "end"):
                  endpoint = shot.get(key)
                  if (
                      not isinstance(endpoint, (list, tuple))
                      or len(endpoint) != 3
                      or not is_vector3(endpoint[0])
                      or not is_vector3(endpoint[1])
                      or not any(endpoint[1])
                      or not is_number(endpoint[2])
                      or endpoint[2] <= 0
                  ):
                      problems.append(
                          f"{label}.{key}: expected [target_xyz, direction_xyz, visible_width > 0]"
                          " with a non-zero direction"
                      )
          return problems
      
      
      def read_config(path: str | os.PathLike | None = None) -> tuple[dict, Path]:
          """Load a film config, resolve its paths, apply defaults and validate it."""
          if path is None:
              path = os.environ.get("PATINA_CONFIG")
              if not path:
                  raise ConfigError("Pass the config path or set PATINA_CONFIG")
          config_path = Path(path).resolve()
          config = json.loads(config_path.read_text())
          for key, value in DEFAULTS.items():
              config.setdefault(key, json.loads(json.dumps(value)))
          root = Path(config["project_root"])
          if not root.is_absolute():
              root = (config_path.parent / root).resolve()
          config["project_root"] = str(root)
          for key in FILE_KEYS:
              if isinstance(config.get(key), str) and config[key]:
                  file = Path(config[key])
                  config[key] = str(file if file.is_absolute() else (root / file).resolve())
          problems = validate_config(config)
          if problems:
              raise ConfigError("Invalid film config:\n  " + "\n  ".join(problems))
          return config, config_path
      
      
      def shot_frames(config: dict) -> int:
          return round(config["shot_seconds"] * config["fps"])
      
      
      def expected(config: dict, kind: str) -> list[int]:
          """Frame numbers a pass must render: shot endpoints for a pilot, source cadence for a run."""
          count = shot_frames(config)
          shots = len(config["shots"])
          if kind == "pilot":
              return [frame for i in range(shots) for frame in (i * count + 1, (i + 1) * count)]
          return list(range(1, count * shots + 1, config["fps"] // config["source_fps"]))
      
      
      def file_hash(path: str | os.PathLike) -> str:
          digest = hashlib.sha256()
          with open(path, "rb") as handle:
              for block in iter(lambda: handle.read(1024 * 1024), b""):
                  digest.update(block)
          return digest.hexdigest()
      
      
      def portable_path(path: str | os.PathLike, root: str | os.PathLike) -> str:
          """POSIX form of `path`: relative to `root` when it lies under it, absolute otherwise.
      
          relpath spelled a system font as ../../usr/share/fonts/..., which changes with the
          project's depth and moved the run folder with it.
          """
          resolved = Path(path).resolve()
          root = Path(root).resolve()
          if resolved.is_relative_to(root):
              return resolved.relative_to(root).as_posix()
          return resolved.as_posix()
      
      
      def run_directory(config: dict, scripts_dir: str | os.PathLike | None = None) -> Path:
          """Run folder fingerprinted from the config, the input files and these scripts.
      
          Anything that changes a rendered pixel changes the folder, so a resume can only ever
          continue an identical production. File paths under project_root enter relative to it,
          so moving or renaming the project keeps its run folder; a path outside it (a system
          font) enters absolute. The files enter by content: the two .blend inputs (packed, or
          an external texture edit would not move the folder) and the HDRI when one is
          configured.
          """
          scripts = Path(scripts_dir) if scripts_dir else Path(__file__).parent
          root = config["project_root"]
          evidence = {k: v for k, v in config.items() if k not in FINGERPRINT_EXCLUDED}
          for key in FILE_KEYS:
              if evidence.get(key):
                  evidence[key] = portable_path(evidence[key], root)
          evidence["inputs"] = {key: file_hash(config[key]) for key in ("before", "after")}
          if config.get("environment"):
              evidence["inputs"]["environment"] = file_hash(config["environment"])
          evidence["scripts"] = {p.name: file_hash(p) for p in sorted(scripts.glob("*.py"))}
          digest = hashlib.sha256(json.dumps(evidence, sort_keys=True).encode()).hexdigest()[:16]
          return Path(root) / config["output"] / digest
      
      
      def resolve_blender(config: dict) -> str:
          """Blender executable: config or --blender, then $BLENDER, then PATH, then the macOS bundle.
      
          A configured value may also be a command name found on PATH, like ffmpeg and ffprobe.
          """
          for label, candidate in (
              ("--blender / config \"blender\"", config.get("blender")),
              ("BLENDER environment variable", os.environ.get("BLENDER")),
          ):
              if candidate:
                  # is_file first: shutil.which rejects an existing file without the executable bit.
                  if Path(candidate).is_file():
                      return str(candidate)
                  found = shutil.which(candidate)
                  if found:
                      return found
                  detail = (
                      "is a directory, not the executable"
                      " (a macOS bundle's is Blender.app/Contents/MacOS/Blender)"
                      if Path(candidate).is_dir()
                      else "does not exist"
                  )
                  raise FileNotFoundError(f"{label} points to {candidate}, which {detail}")
          found = shutil.which("blender")
          if found:
              return found
          if Path(MACOS_BLENDER).is_file():
              return MACOS_BLENDER
          raise FileNotFoundError(
              "No Blender executable found. Pass --blender PATH (or set \"blender\" in the config),"
              " set the BLENDER environment variable, put blender on PATH, or install the macOS"
              f" app bundle at {MACOS_BLENDER}"
          )
      
      
      def world_matrix(obj):
          """Object-to-world matrix composed up the parent chain, never read from matrix_world.
      
          matrix_world holds what the viewport depsgraph last evaluated: after open_mainfile an
          object hidden in the viewport or in an excluded collection reads identity, and
          view_layer.update() does not reach it. Object parenting only: bone and vertex parents,
          constraints and drivers are not composed. A root object's matrix_parent_inverse is
          ignored, as Blender ignores it.
          """
          if obj.parent is None:
              return obj.matrix_basis
          return world_matrix(obj.parent) @ obj.matrix_parent_inverse @ obj.matrix_basis
      
      
      def geometry_hash(objects) -> str:
          """SHA-256 over every mesh object's name, vertices, polygons and world matrix.
      
          Material changes leave it untouched; that is the whole point.
          """
          import numpy as np
      
          digest = hashlib.sha256()
          for obj in sorted(objects, key=lambda o: o.name):
              digest.update(obj.name.encode())
              for vertex in obj.data.vertices:
                  digest.update(np.array(vertex.co, dtype=np.float32).tobytes())
              for polygon in obj.data.polygons:
                  digest.update(np.array(polygon.vertices, dtype=np.int32).tobytes())
              digest.update(np.array(world_matrix(obj), dtype=np.float32).tobytes())
          return digest.hexdigest()
      
      
      def dump(data) -> str:
          """Compact JSON for stdout; the agent running the scripts reads it."""
          return json.dumps(data, separators=(",", ":"), sort_keys=True)
      
      
      def ensure_nodes(id_block):
          """Return the node tree of a material or world, creating it on Blender 4.x.
      
          Blender 5 materials and worlds are node trees from birth and deprecate use_nodes;
          4.x needs use_nodes on even when a tree already exists, or the tree is ignored.
          """
          with warnings.catch_warnings():
              warnings.simplefilter("ignore", DeprecationWarning)  # Blender 5 warns on every read
              enabled = getattr(id_block, "use_nodes", True)
          if getattr(id_block, "node_tree", None) is None or not enabled:
              try:
                  id_block.use_nodes = True
              except AttributeError:
                  # A read-only use_nodes flag can coexist with an existing node tree.
                  pass
          return id_block.node_tree
      
      
      def script_args() -> list[str]:
          """Arguments after the ``--`` separator Blender uses to hand argv to a script."""
          return sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
      
    • film.py 24.2 KB
      #!/usr/bin/env python3
      """Drive Blender and ffmpeg to produce a matched before/after comparison film.
      
          python3 scripts/film.py <config.json> pilot     # half-resolution shot endpoints, both passes
          python3 scripts/film.py <config.json> run       # full render, resume, assemble, verify
          python3 scripts/film.py <config.json> assemble  # redo the ffmpeg stage only
          python3 scripts/film.py <config.json> status    # progress as compact JSON
      
      Add --blender PATH when Blender is not on PATH or in $BLENDER. Needs Pillow, ffmpeg and
      ffprobe with libx264. No network calls; the Scenario side is the agent's job.
      """
      
      from __future__ import annotations
      
      import argparse
      import concurrent.futures
      import json
      import math
      import os
      import shutil
      import subprocess
      import sys
      import time
      from pathlib import Path
      
      from PIL import Image, ImageDraw, ImageFont
      
      sys.path.insert(0, str(Path(__file__).parent))
      
      from common import (  # noqa: E402
          dump,
          expected,
          read_config,
          resolve_blender,
          run_directory,
          shot_frames,
      )
      
      MODES = ("original", "patina")
      # One 5.5 second shot at 24 fps. EEVEE memory grows across frames in one process, so a
      # pass is rendered as bounded worker batches rather than one long-lived Blender.
      BATCH_FRAMES = 132
      SWEEP_FPS = 2
      SWEEP_COLUMNS = 8
      BACKGROUND = (14, 20, 26)
      FONTS = {
          "font": (
              "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
              "/System/Library/Fonts/Supplemental/Arial.ttf",
              "C:/Windows/Fonts/arial.ttf",
          ),
          "bold_font": (
              "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
              "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
              "C:/Windows/Fonts/arialbd.ttf",
          ),
      }
      
      
      def valid_png(path: Path) -> bool:
          try:
              with Image.open(path) as image:
                  image.verify()
              return True
          except (OSError, SyntaxError):
              return False
      
      
      def valid_still(path: Path) -> bool:
          # JPEG verify() is a no-op in Pillow; only a full load() sees a truncated file.
          try:
              with Image.open(path) as image:
                  image.load()
              return True
          except (OSError, SyntaxError):
              return False
      
      
      def archive(path: Path) -> None:
          """Move a stale or corrupt output aside instead of deleting it."""
          if path.exists():
              folder = path.parent / "archive"
              folder.mkdir(exist_ok=True)
              path.rename(folder / f"{path.stem}_{time.time_ns()}{path.suffix}")
      
      
      def command(args: list[str], log: Path) -> None:
          with open(log, "w") as handle:
              result = subprocess.run(args, stdout=handle, stderr=subprocess.STDOUT)
          if result.returncode:
              raise RuntimeError(f"{log}: " + log.read_text()[-1800:])
      
      
      def load_font(config: dict, size: int, bold: bool = False):
          key = "bold_font" if bold else "font"
          configured = config.get(key)
          if configured:
              if not Path(configured).is_file():
                  raise FileNotFoundError(f"{key} {configured} does not exist")
              return ImageFont.truetype(configured, size)
          for candidate in FONTS[key]:
              if Path(candidate).is_file():
                  return ImageFont.truetype(candidate, size)
          try:
              return ImageFont.load_default(size)
          except TypeError:  # Pillow before 10.1 has no sized built-in font
              raise FileNotFoundError(
                  f'No caption font found: set "{key}" in the film config to a .ttf file, or'
                  " upgrade Pillow to 10.1 or newer for the built-in fallback"
              ) from None
      
      
      def frame_path(run_dir: Path, kind: str, mode: str, frame: int) -> Path:
          return run_dir / ("pilot" if kind == "pilot" else "frames") / mode / f"{frame:04d}.png"
      
      
      def status(config: dict, run_dir: Path) -> dict:
          frames = expected(config, "run")
          counts = {
              mode: sum(frame_path(run_dir, "run", mode, f).exists() for f in frames) for mode in MODES
          }
          verification = run_dir / "verification.json"
          stage = "prepared"
          if verification.exists():
              outputs = json.loads(verification.read_text())["outputs"]
              if all(Path(o["path"]).is_file() for o in outputs):
                  stage = "encoded"
          if stage != "encoded" and any(counts.values()):
              stage = "rendering"
          report = {
              "stage": stage,
              "frames": counts,
              "required_per_pass": len(frames),
              "run": str(run_dir),
          }
          estimate = run_dir / "pilot_estimate.json"
          if estimate.exists():
              report["pilot"] = json.loads(estimate.read_text())
          (run_dir / "status.json").write_text(json.dumps(report, indent=2))
          return report
      
      
      def worker_command(
          blender: str, source: str, mode: str, job: Path, run_dir: Path, kind: str, config_path: Path
      ) -> list[str]:
          worker = Path(__file__).with_name("worker.py")
          return [
              blender,
              "--factory-startup",
              "--background",
              source,
              "--python-exit-code",
              "1",
              "--python",
              str(worker),
              "--",
              mode,
              str(job),
              str(run_dir),
              kind,
              str(config_path),
          ]
      
      
      def render_pass(config: dict, config_path: Path, run_dir: Path, kind: str, mode: str) -> None:
          blender = resolve_blender(config)
          env = dict(os.environ, PATINA_CONFIG=str(config_path))
          source = config["before" if mode == "original" else "after"]
          pending = []
          for frame in expected(config, kind):
              target = frame_path(run_dir, kind, mode, frame)
              if valid_png(target):
                  continue
              archive(target)
              pending.append(frame)
          for start in range(0, len(pending), BATCH_FRAMES):
              batch = pending[start : start + BATCH_FRAMES]
              job = run_dir / f"{kind}_{mode}_{start}.json"
              job.write_text(json.dumps(batch))
              log = run_dir / f"{kind}_{mode}_{start}.log"
              with log.open("w") as handle:
                  result = subprocess.run(
                      worker_command(blender, source, mode, job, run_dir, kind, config_path),
                      env=env,
                      stdout=handle,
                      stderr=subprocess.STDOUT,
                  )
              if result.returncode:
                  raise RuntimeError(f"{log}: " + log.read_text()[-1800:])
              missing = [f for f in batch if not valid_png(frame_path(run_dir, kind, mode, f))]
              if missing:
                  raise RuntimeError(f"{mode} {kind}: worker exited cleanly, frames missing: {missing}")
              print(dump(status(config, run_dir)), flush=True)
      
      
      def compare_contracts(run_dir: Path) -> None:
          contracts = [json.loads((run_dir / f"{mode}_contract.json").read_text()) for mode in MODES]
          for key in ("camera_sha256", "settings", "geometry_sha256"):
              if contracts[0][key] != contracts[1][key]:
                  raise RuntimeError(f"Before/after mismatch: {key}")
      
      
      def render(config: dict, config_path: Path, run_dir: Path, kind: str) -> None:
          started = time.monotonic()
          with concurrent.futures.ThreadPoolExecutor(max_workers=config["workers"]) as pool:
              list(pool.map(lambda mode: render_pass(config, config_path, run_dir, kind, mode), MODES))
          compare_contracts(run_dir)
          if kind == "pilot":
              elapsed = time.monotonic() - started
              per_frame = elapsed / max(1, len(expected(config, "pilot")))
              estimate = run_dir / "pilot_estimate.json"
              if not estimate.exists():
                  estimate.write_text(
                      json.dumps(
                          {
                              "pilot_elapsed_seconds": round(elapsed, 2),
                              "rough_render_estimate_seconds": round(
                                  per_frame * len(expected(config, "run")) * 4
                              ),
                              "note": "Half-resolution endpoint pilot scaled by 4 for pixels, includes"
                              " process startup; refine from actual batch throughput.",
                          },
                          indent=2,
                      )
                  )
              contact(config, run_dir, kind)
      
      
      def contact_path(run_dir: Path, kind: str) -> Path:
          if kind == "pilot":
              return run_dir / "Pilot Contact.jpg"
          return run_dir / "review" / "Final Contact.jpg"
      
      
      def contact(config: dict, run_dir: Path, kind: str) -> Path:
          """Pilot: both endpoints of every shot, before over after. Final: one still per shot."""
          output = contact_path(run_dir, kind)
          if output.exists():
              return output
          output.parent.mkdir(parents=True, exist_ok=True)
          font = load_font(config, 22)
          count = shot_frames(config)
          shots = config["shots"]
          page = Image.new("RGB", (1600, 640 * ((len(shots) + 3) // 4)), BACKGROUND)
          draw = ImageDraw.Draw(page)
          for index, shot in enumerate(shots):
              x, y = index % 4 * 400, index // 4 * 640
              title = f"{index + 1:02d} {shot['name']}"[:28]
              draw.text((x + 12, y + 10), title, font=font, fill="white")
              if kind != "pilot":
                  with Image.open(run_dir / "review" / f"{index + 1:02d}.jpg") as image:
                      image.thumbnail((380, 560))
                      page.paste(image, (x + 10, y + 45))
                  continue
              for mode, dy in zip(MODES, (45, 345)):
                  for j, frame in enumerate((index * count + 1, (index + 1) * count)):
                      with Image.open(frame_path(run_dir, "pilot", mode, frame)) as image:
                          image.thumbnail((380, 140))
                          page.paste(image, (x + 10, y + dy + j * 148))
          page.save(output, quality=94)
          return output
      
      
      def layout(width: int, height: int) -> dict:
          """Padded vertical stack: title header, before panel, gap with the after label, footer.
      
          Every margin is even (the header and side padding multiples of 8) so the stacked
          frame stays yuv420p-friendly at any panel size; 2368x1332 panels give 2560x3200.
          """
          pad = 8 * max(1, round(width * 0.04 / 8))
          header = 8 * max(1, round(width * 0.10 / 8))
          gap = 2 * max(1, round(width * 0.07 / 2))
          bottom = 2 * max(1, round(width * 0.055 / 2))
          return {
              "pad": pad,
              "header": header,
              "gap": gap,
              "bottom": bottom,
              "width": width + 2 * pad,
              "height": header + 2 * height + gap + bottom,
              "after_y": header + height + gap,
          }
      
      
      def draw_plate(config: dict, path: Path) -> Path:
          """Caption overlay with transparent holes where the two panels show through."""
          if path.exists():
              return path
          w, h = config["width"], config["height"]
          geometry = layout(w, h)
          pad, header, after_y = geometry["pad"], geometry["header"], geometry["after_y"]
          image = Image.new("RGBA", (geometry["width"], geometry["height"]), BACKGROUND + (255,))
          draw = ImageDraw.Draw(image)
          bold = load_font(config, max(12, round(w * 0.018)), bold=True)
          font = load_font(config, max(9, round(w * 0.012)))
          radius = max(4, round(w * 0.006))
          for y in (header, after_y):
              draw.rounded_rectangle((pad, y, pad + w - 1, y + h - 1), radius=radius, fill=(0, 0, 0, 0))
          draw.text((pad, round(header * 0.15)), config["title"], font=bold, fill="white")
          before = "BEFORE  /  " + config["before_label"]
          draw.text((pad, round(header * 0.69)), before, font=font, fill="white")
          draw.text(
              (pad, after_y - round(geometry["gap"] * 0.52)),
              "AFTER  /  " + config["after_label"],
              font=font,
              fill="#8ed9c4",
          )
          draw.text(
              (pad, geometry["height"] - round(geometry["bottom"] * 0.62)),
              "IDENTICAL GEOMETRY / CAMERA / LIGHTING",
              font=font,
              fill="#8b9ba4",
          )
          image.save(path)
          return path
      
      
      def ffmpeg(config: dict, args: list[str], run_dir: Path, label: str) -> None:
          command(
              [config["ffmpeg"], "-hide_banner", "-loglevel", "warning", "-n", *args],
              run_dir / f"encode_{label}.log",
          )
      
      
      def probe(config: dict, path: Path) -> dict:
          raw = subprocess.check_output(
              [
                  config["ffprobe"],
                  "-v",
                  "error",
                  "-count_frames",
                  "-show_streams",
                  "-show_format",
                  "-of",
                  "json",
                  str(path),
              ],
              stderr=subprocess.DEVNULL,
          )
          return json.loads(raw)
      
      
      def video_stream(info: dict) -> dict:
          return next(s for s in info["streams"] if s["codec_type"] == "video")
      
      
      def frame_count(config: dict, path: Path) -> int:
          return int(video_stream(probe(config, path))["nb_read_frames"])
      
      
      def keep_if_complete(config: dict, path: Path, frames: int) -> bool:
          """True when an existing encode already has the right frame count; archive it otherwise."""
          if not path.exists():
              return False
          try:
              if frame_count(config, path) == frames:
                  return True
          except (subprocess.CalledProcessError, StopIteration, KeyError, ValueError):
              pass
          archive(path)
          return False
      
      
      def final_duration(config: dict) -> float:
          shots = len(config["shots"])
          return shots * config["shot_seconds"] - (shots - 1) * config["transition"]
      
      
      def encode_clip(config: dict, run_dir: Path, mode: str, index: int) -> Path:
          fps, count = config["fps"], shot_frames(config)
          out = run_dir / "clips" / f"{mode}_{index:02d}.mp4"
          out.parent.mkdir(exist_ok=True)
          if keep_if_complete(config, out, count):
              return out
          stage = run_dir / "sequence" / mode / f"{index:02d}"
          stage.mkdir(parents=True, exist_ok=True)
          step = fps // config["source_fps"]
          for j, frame in enumerate(range(index * count + 1, (index + 1) * count + 1, step)):
              dest = stage / f"{j:04d}.png"
              if not dest.exists():
                  source = frame_path(run_dir, "run", mode, frame)
                  try:
                      os.link(source, dest)
                  except OSError:
                      shutil.copyfile(source, dest)
          filters = ""
          if config["source_fps"] != fps:
              # Clone the last frame briefly so the interpolator has a tail to blend into.
              filters = (
                  "tpad=stop_mode=clone:stop_duration=0.25,"
                  f"framerate=fps={fps}:interp_start=0:interp_end=255:scene=100,"
              )
          filters += "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"
          ffmpeg(
              config,
              [
                  "-framerate", str(config["source_fps"]),
                  "-start_number", "0",
                  "-i", str(stage / "%04d.png"),
                  "-frames:v", str(count),
                  "-vf", filters,
                  "-c:v", "libx264", "-preset", "fast", "-crf", "16", "-threads", "4",
                  "-movflags", "+faststart",
                  str(out),
              ],
              run_dir,
              f"{mode}_{index}",
          )
          return out
      
      
      def join_clips(config: dict, run_dir: Path, mode: str) -> Path:
          shots = len(config["shots"])
          fps = config["fps"]
          final_frames = round(final_duration(config) * fps)
          joined = run_dir / f"{mode.title()} Matched Camera.mp4"
          if keep_if_complete(config, joined, final_frames):
              return joined
          inputs, filters = [], []
          for index in range(shots):
              inputs += ["-i", str(run_dir / "clips" / f"{mode}_{index:02d}.mp4")]
              filters.append(f"[{index}:v]settb=AVTB,setpts=PTS-STARTPTS,format=yuv420p[v{index}]")
          last = "v0"
          for index in range(1, shots):
              offset = index * (config["shot_seconds"] - config["transition"])
              filters.append(
                  f"[{last}][v{index}]xfade=transition=fade:duration={config['transition']}"
                  f":offset={offset}[x{index}]"
              )
              last = f"x{index}"
          filters.append(f"[{last}]format=yuv420p[out]")
          ffmpeg(
              config,
              [
                  *inputs,
                  "-filter_complex_threads", "2",
                  "-filter_complex", ";".join(filters),
                  "-map", "[out]",
                  "-frames:v", str(final_frames),
                  "-r", str(fps),
                  "-c:v", "libx264", "-preset", "fast", "-crf", "16", "-threads", "6",
                  "-movflags", "+faststart",
                  str(joined),
              ],
              run_dir,
              f"{mode}_joined",
          )
          frames = frame_count(config, joined)
          if frames != final_frames:
              raise RuntimeError(f"{joined}: {frames} frames after the join, expected {final_frames}")
          return joined
      
      
      def stack(config: dict, run_dir: Path, joined: dict) -> Path:
          fps = config["fps"]
          final_frames = round(final_duration(config) * fps)
          geometry = layout(config["width"], config["height"])
          master = run_dir / "PATINA Comparison.mp4"
          if keep_if_complete(config, master, final_frames):
              return master
          plate = draw_plate(config, run_dir / "Layout.png")
          color = "0x{:02x}{:02x}{:02x}".format(*BACKGROUND)
          filters = (
              f"[0:v]pad={geometry['width']}:{geometry['height']}:{geometry['pad']}:{geometry['header']}"
              f":color={color}[a];[a][1:v]overlay={geometry['pad']}:{geometry['after_y']}:shortest=1[b];"
              "[b][2:v]overlay=0:0:shortest=1,format=yuv420p[out]"
          )
          ffmpeg(
              config,
              [
                  "-i", str(joined["original"]),
                  "-i", str(joined["patina"]),
                  "-loop", "1", "-framerate", str(fps), "-i", str(plate),
                  "-filter_complex_threads", "2",
                  "-filter_complex", filters,
                  "-map", "[out]",
                  "-frames:v", str(final_frames),
                  "-r", str(fps),
                  "-c:v", "libx264", "-preset", "fast", "-crf", "16", "-threads", "6",
                  "-movflags", "+faststart",
                  str(master),
              ],
              run_dir,
              "comparison",
          )
          return master
      
      
      def share_copy(config: dict, run_dir: Path, master: Path) -> Path:
          final_frames = round(final_duration(config) * config["fps"])
          share = run_dir / "PATINA Comparison Share.mp4"
          if keep_if_complete(config, share, final_frames):
              return share
          width = min(1440, layout(config["width"], config["height"])["width"])
          ffmpeg(
              config,
              [
                  "-i", str(master),
                  "-vf", f"scale={width}:-2:flags=lanczos",
                  "-c:v", "libx264", "-preset", "fast", "-crf", "18", "-threads", "4",
                  "-movflags", "+faststart",
                  str(share),
              ],
              run_dir,
              "share",
          )
          return share
      
      
      def review_stills(config: dict, run_dir: Path, master: Path) -> list[Path]:
          """One still from the middle of each shot."""
          review = run_dir / "review"
          review.mkdir(exist_ok=True)
          stills = []
          for index in range(len(config["shots"])):
              still = review / f"{index + 1:02d}.jpg"
              if not valid_still(still):
                  # ffmpeg -n writes nothing over an existing file, and a sheet built from the
                  # old still would be stale.
                  archive(still)
                  archive(contact_path(run_dir, "final"))
                  shot_seconds = config["shot_seconds"]
                  seek = index * (shot_seconds - config["transition"]) + shot_seconds / 2
                  ffmpeg(
                      config,
                      ["-ss", str(seek), "-i", str(master), "-frames:v", "1", "-q:v", "2", str(still)],
                      run_dir,
                      f"review_{index}",
                  )
              stills.append(still)
          return stills
      
      
      def tile(paths: list[Path], columns: int, labels: list[str], font, max_width: int = 320):
          """Grid of thumbnails with a caption strip above each one."""
          columns = max(1, min(columns, len(paths)))
          rows = math.ceil(len(paths) / columns)
          with Image.open(paths[0]) as first:
              scale = min(1.0, max_width / first.width)
              cell_w, cell_h = round(first.width * scale), round(first.height * scale)
          label_h = 24
          sheet = Image.new("RGB", (columns * cell_w, rows * (cell_h + label_h)), BACKGROUND)
          draw = ImageDraw.Draw(sheet)
          for index, (path, label) in enumerate(zip(paths, labels)):
              x = index % columns * cell_w
              y = index // columns * (cell_h + label_h)
              draw.text((x + 4, y + 4), label, font=font, fill="white")
              with Image.open(path) as image:
                  image.thumbnail((cell_w, cell_h))
                  sheet.paste(image, (x, y + label_h))
          return sheet
      
      
      def sweep_contact(config: dict, run_dir: Path, master: Path) -> tuple[Path, int]:
          """Sample the whole master at SWEEP_FPS: a defect that fades in mid-shot shows up here
          when the one-still-per-shot review misses it."""
          output = run_dir / "review" / "Sweep Contact.jpg"
          folder = run_dir / "review" / "sweep"
          frames = sorted(folder.glob("*.jpg")) if folder.is_dir() else []
          if not output.exists() or not frames or not all(valid_still(f) for f in frames):
              # ffmpeg writes the frames one file at a time, so a killed extraction leaves a valid
              # partial set: only the sheet proves the folder complete.
              shutil.rmtree(folder, ignore_errors=True)
              archive(output)
              folder.mkdir(parents=True)
              ffmpeg(
                  config,
                  ["-i", str(master), "-vf", f"fps={SWEEP_FPS}", "-q:v", "2", str(folder / "%04d.jpg")],
                  run_dir,
                  "sweep",
              )
              frames = sorted(folder.glob("*.jpg"))
              labels = [f"{index / SWEEP_FPS:.1f}s" for index in range(len(frames))]
              tile(frames, SWEEP_COLUMNS, labels, load_font(config, 14)).save(output, quality=90)
          return output, len(frames)
      
      
      def check_frames(config: dict, run_dir: Path) -> None:
          size = (config["width"], config["height"])
          for mode in MODES:
              for frame in expected(config, "run"):
                  path = frame_path(run_dir, "run", mode, frame)
                  if not valid_png(path):
                      raise RuntimeError(f"{path}: missing or corrupt source frame")
                  with Image.open(path) as image:
                      if image.size != size:
                          raise RuntimeError(f"{path}: size {image.size}, expected {size}")
      
      
      def assemble(config: dict, run_dir: Path) -> dict:
          fps = config["fps"]
          duration = final_duration(config)
          final_frames = round(duration * fps)
          check_frames(config, run_dir)
          joined = {}
          for mode in MODES:
              for index in range(len(config["shots"])):
                  encode_clip(config, run_dir, mode, index)
              joined[mode] = join_clips(config, run_dir, mode)
          master = stack(config, run_dir, joined)
          share = share_copy(config, run_dir, master)
          outputs = []
          for path in (master, share, joined["original"], joined["patina"]):
              info = probe(config, path)
              stream = video_stream(info)
              frames = int(stream["nb_read_frames"])
              if frames != final_frames:
                  raise RuntimeError(f"{path}: {frames} frames, expected {final_frames}")
              if abs(float(info["format"]["duration"]) - duration) >= 1 / fps:
                  actual = info["format"]["duration"]
                  raise RuntimeError(f"{path}: duration {actual}, expected {duration}")
              outputs.append(
                  {
                      "path": str(path),
                      "width": stream["width"],
                      "height": stream["height"],
                      "frames": frames,
                      "duration": float(info["format"]["duration"]),
                  }
              )
          stills = review_stills(config, run_dir, master)
          final_contact = contact(config, run_dir, "final")
          sweep, sweep_frames = sweep_contact(config, run_dir, master)
          verification = {
              "status": "technical_checks_passed_visual_review_required",
              "source_fps": config["source_fps"],
              "output_fps": fps,
              "duration_seconds": duration,
              "final_frames": final_frames,
              "camera_lighting_geometry_match": True,
              "outputs": outputs,
              "review_stills": [str(p) for p in stills],
              "final_contact": str(final_contact),
              "sweep_contact": str(sweep),
              "sweep_fps": SWEEP_FPS,
              "sweep_frames": sweep_frames,
          }
          (run_dir / "verification.json").write_text(json.dumps(verification, indent=2))
          return verification
      
      
      def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
          parser = argparse.ArgumentParser(
              description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
          )
          parser.add_argument("config", help="film config JSON")
          parser.add_argument("mode", choices=("pilot", "run", "assemble", "status"))
          parser.add_argument("--blender", help="Blender executable; overrides the config and $BLENDER")
          return parser.parse_args(argv)
      
      
      def main(argv: list[str] | None = None) -> dict:
          args = parse_args(argv)
          config, config_path = read_config(args.config)
          if args.blender:
              config["blender"] = args.blender
          run_dir = run_directory(config)
          run_dir.mkdir(parents=True, exist_ok=True)
          if args.mode in ("pilot", "run"):
              render(config, config_path, run_dir, args.mode)
          if args.mode in ("run", "assemble"):
              assemble(config, run_dir)
          result = status(config, run_dir)
          print(dump(result), flush=True)
          return result
      
      
      if __name__ == "__main__":
          main()
      
    • inventory.py 7.8 KB
      """Import a 3D asset into Blender, save a packed Before.blend and summarize its materials.
      
          blender --background --factory-startup --python scripts/inventory.py -- <source> <out_dir>
      
      <source> is a .glb, .gltf, .obj, .fbx or .blend file. The summary is what the agent
      groups into material families; it never dumps geometry.
      """
      
      from __future__ import annotations
      
      import json
      import sys
      import traceback
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      
      from common import dump, geometry_hash, script_args, world_matrix  # noqa: E402
      
      IMPORTERS = {
          ".glb": ("import_scene", "gltf"),
          ".gltf": ("import_scene", "gltf"),
          ".obj": ("wm", "obj_import"),
          ".fbx": ("import_scene", "fbx"),
      }
      DEFAULT_VIEWPORT_COLOR = [0.8, 0.8, 0.8, 1.0]
      # Object types Blender never renders; every other non-mesh type is geometry these scripts
      # leave alone.
      UNRENDERED_TYPES = {"ARMATURE", "CAMERA", "EMPTY", "LATTICE", "LIGHT", "LIGHT_PROBE", "SPEAKER"}
      
      
      def load_source(source: Path):
          suffix = source.suffix.lower()
          if suffix != ".blend" and suffix not in IMPORTERS:
              raise ValueError(f"{source.name}: expected .glb, .gltf, .obj, .fbx or .blend")
          import bpy
      
          if suffix == ".blend":
              bpy.ops.wm.open_mainfile(filepath=str(source))
              return
          bpy.ops.wm.read_factory_settings(use_empty=True)
          module, operator = IMPORTERS[suffix]
          getattr(getattr(bpy.ops, module), operator)(filepath=str(source))
      
      
      def principled_node(material):
          if material.node_tree is None:
              return None
          return next((n for n in material.node_tree.nodes if n.type == "BSDF_PRINCIPLED"), None)
      
      
      def material_base_color(material) -> tuple[list | None, str]:
          """The color that renders and where it was read, or None with the reason it is unknown.
      
          A Base Color socket driven by another node (vertex colors, a texture, a mix) renders that
          node, not the socket default, and the default viewport grey says nothing about the author's
          palette.
          """
          if material.library is not None or not getattr(material, "is_editable", True):
              return None, "library"
          node = principled_node(material)
          if node is not None:
              socket = node.inputs["Base Color"]
              if socket.is_linked:
                  return None, f"linked:{socket.links[0].from_node.type}"
              return [round(v, 6) for v in socket.default_value], "principled"
          color = [round(v, 6) for v in material.diffuse_color]
          if color != DEFAULT_VIEWPORT_COLOR:
              return color, "viewport"
          return None, "none"
      
      
      def has_image_textures(material) -> bool:
          if material.node_tree is None:
              return False
          return any(n.type == "TEX_IMAGE" and n.image is not None for n in material.node_tree.nodes)
      
      
      def non_mesh_renderables(objects) -> list[dict]:
          """Rendering non-mesh objects (text, curves, metaballs, surfaces) that keep their materials."""
          found = []
          for obj in objects:
              if obj.type == "MESH" or obj.type in UNRENDERED_TYPES or obj.hide_render:
                  continue
              materials = [s.material.name for s in obj.material_slots if s.material is not None]
              found.append({"name": obj.name, "type": obj.type, "materials": materials})
          return sorted(found, key=lambda entry: entry["name"])
      
      
      def extend_bounds(bounds: dict, corners) -> None:
          for axis in range(3):
              values = [corner[axis] for corner in corners]
              bounds["lo"][axis] = min(bounds["lo"][axis], min(values))
              bounds["hi"][axis] = max(bounds["hi"][axis], max(values))
      
      
      def new_bounds() -> dict:
          return {"lo": [float("inf")] * 3, "hi": [float("-inf")] * 3}
      
      
      def world_corners(obj) -> list[tuple]:
          matrix = world_matrix(obj)
          return [
              tuple(sum(matrix[i][j] * corner[j] for j in range(3)) + matrix[i][3] for i in range(3))
              for corner in obj.bound_box
          ]
      
      
      def summarize(objects, source: Path) -> dict:
          materials = {}
          linked = []
          scene_bounds = new_bounds()
          without_material = []
          for obj in objects:
              corners = world_corners(obj)
              extend_bounds(scene_bounds, corners)
              slots = [slot.material for slot in obj.material_slots]
              if not slots or any(material is None for material in slots):
                  without_material.append(obj.name)
              for material in slots:
                  if material is None:
                      continue
                  if material.name not in materials:
                      color, source_of_color = material_base_color(material)
                      materials[material.name] = {
                          "objects": 0,
                          "examples": [],
                          "bounds": new_bounds(),
                          "base_color": color,
                          "base_color_source": source_of_color,
                          "has_image_textures": has_image_textures(material),
                      }
                      if source_of_color == "library":
                          library = getattr(material.library, "filepath", None)
                          linked.append({"name": material.name, "library": library})
                  entry = materials[material.name]
                  entry["objects"] += 1
                  if len(entry["examples"]) < 3:
                      entry["examples"].append(obj.name)
                  extend_bounds(entry["bounds"], corners)
          dimensions = [0.0, 0.0, 0.0]
          if objects:
              dimensions = [hi - lo for lo, hi in zip(scene_bounds["lo"], scene_bounds["hi"])]
          return {
              "source": str(source),
              "meshes": len(objects),
              "polygons": sum(len(obj.data.polygons) for obj in objects),
              "dimensions": dimensions,
              "geometry_sha256": geometry_hash(objects),
              "objects_without_material": without_material,
              "materials": materials,
              "linked_materials": linked,
          }
      
      
      def pack_all() -> str | None:
          """Pack every external file into the .blend; the error text when Blender refused one.
      
          pack_all packs the files it finds and then raises about the ones it cannot, so a stale
          texture path (which the workflow is about to replace) must not cost the whole inventory.
          """
          import bpy
      
          try:
              bpy.ops.file.pack_all()
          except RuntimeError as exc:
              return "; ".join(dict.fromkeys(str(exc).strip().splitlines()))
          return None
      
      
      def main(argv: list[str]) -> dict:
          if len(argv) != 2:
              raise SystemExit("usage: ... --python inventory.py -- <source> <out_dir>")
          source = Path(argv[0]).resolve()
          out_dir = Path(argv[1]).resolve()
          if not source.is_file():
              raise FileNotFoundError(f"{source} does not exist")
          before = out_dir / "Before.blend"
          report_path = out_dir / "inventory.json"
          for path in (before, report_path):
              if path.exists():
                  raise FileExistsError(f"{path} exists; choose an empty output directory")
          import bpy
      
          load_source(source)
          # Evaluates the visible objects so bound_box follows their modifiers. It never reaches a
          # hidden or excluded one, whose matrix_world stays identity: the bounds and the hash
          # compose the matrix themselves (common.world_matrix).
          bpy.context.view_layer.update()
          objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
          report = summarize(objects, source)
          report["non_mesh_renderables"] = non_mesh_renderables(bpy.context.view_layer.objects)
          out_dir.mkdir(parents=True, exist_ok=True)
          error = pack_all()
          report["packed"] = error is None
          report["pack_error"] = error
          bpy.ops.wm.save_as_mainfile(filepath=str(before))
          report["before"] = str(before)
          report_path.write_text(json.dumps(report, indent=2))
          print(dump(report), flush=True)
          return report
      
      
      if __name__ == "__main__":
          try:
              main(script_args())
          except Exception as exc:
              traceback.print_exc()
              # Without --python-exit-code Blender exits 0 on an uncaught exception; SystemExit
              # sets the status either way.
              raise SystemExit(f"inventory failed: {exc}") from None
      
    • scene.py 20.4 KB
      """One camera and light rig, applied identically to the before and after scenes.
      
      setup() runs inside Blender. The pose math above it is plain tuples so it can be
      tested without bpy, and so the two passes cannot drift: every frame's camera and
      sweep light come from the same numbers.
      """
      
      from __future__ import annotations
      
      import hashlib
      import json
      import math
      from pathlib import Path
      
      from common import ensure_nodes, geometry_hash, shot_frames, world_matrix
      
      LENS = 85
      SENSOR_WIDTH = 36
      CLIP_START = 0.01
      # A fresh camera must never clip tighter than the default one it replaces.
      DEFAULT_CLIP_END = 1000.0
      ENVIRONMENT_STRENGTH = 0.45
      ENVIRONMENT_ROTATION_DEGREES = 35
      BACKDROP = (0.033, 0.048, 0.064, 1)
      CAMERA_NAME = "Synchronized Comparison Camera"
      STRIP_NAME = "Moving Reflection Strip"
      WORLD_UP = (0, 0, 1)
      # Blender 4.2 through 4.5 call the current EEVEE BLENDER_EEVEE_NEXT; 5.0 renamed it.
      ENGINE_CANDIDATES = ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT")
      # 4.2 and 5.0 both keep the "AgX - " prefix in the identifier (only the UI label drops
      # it); the bare name is a hedge for a later rename.
      LOOK_CANDIDATES = ("AgX - Medium High Contrast", "Medium High Contrast")
      
      # (name, location, power, size, color, target, shape, height) in rig units: the rig is
      # placed at rig_origin and scaled by rig_scale, power by rig_scale squared.
      FIXED_LIGHTS = (
          ("Raking Warm Key", (-6, -7, 12), 1250, 4.0, (1, 0.89, 0.75), (0, 0, 4.5), "DISK", None),
          ("Cool Rim", (6, 4, 11), 1800, 3.5, (0.78, 0.87, 1), (0, 0, 4.5), "DISK", None),
          ("Gentle Front Fill", (2, -9, 5), 180, 7, (0.86, 0.93, 1), (0, 0, 4.5), "DISK", None),
      )
      STRIP_LIGHT = (STRIP_NAME, (0, -4, 8), 100, 1, (1, 0.95, 0.86), (0, 0, 4), "RECTANGLE", 3)
      # The camera side of the rig: the front fill and the strip's rest position sit on -Y.
      RIG_FRONT = (0, -1, 0)
      # (radius, energy, size, size_y) in rig units for the wide and the close-up strip, blended
      # by smoothstep across visible widths of STRIP_BAND rig units so a push-in never pops.
      STRIP_WIDE = (11, 600, 2, 8)
      STRIP_CLOSE = (4.5, 260, 0.6, 2.8)
      STRIP_BAND = (5, 7)
      
      
      def smoothstep(t: float) -> float:
          return t * t * (3 - 2 * t)
      
      
      def ease_at(index: int, count: int) -> float:
          return smoothstep(index / (count - 1)) if count > 1 else 0.0
      
      
      def lerp(a, b, t: float) -> tuple:
          return tuple(x + (y - x) * t for x, y in zip(a, b))
      
      
      def mix(a: float, b: float, t: float) -> float:
          return a * (1 - t) + b * t
      
      
      def dot(a, b) -> float:
          return sum(x * y for x, y in zip(a, b))
      
      
      def normalize(vector) -> tuple:
          length = math.sqrt(dot(vector, vector))
          return tuple(x / length for x in vector)
      
      
      def perpendicular(vector, preferred=WORLD_UP) -> tuple:
          """Unit vector at right angles to `vector`, as close to `preferred` as possible."""
          for hint in (preferred, RIG_FRONT):
              rejected = tuple(h - v * dot(vector, hint) for v, h in zip(vector, hint))
              if math.sqrt(dot(rejected, rejected)) > 1e-6:
                  return normalize(rejected)
          raise ValueError(f"no perpendicular found for {vector}")
      
      
      def slerp(a, b, t: float) -> tuple:
          """Rotate the direction `a` toward `b` by the fraction `t` of the angle between them.
      
          Opposite directions share no unique plane, so the path then climbs through world up
          (through the rig front when the pair is vertical): a turnaround arcs overhead instead
          of collapsing to a zero vector at the midpoint.
          """
          a, b = normalize(a), normalize(b)
          cosine = max(-1.0, min(1.0, dot(a, b)))
          if cosine > 1 - 1e-9:
              return normalize(lerp(a, b, t))
          if cosine < -1 + 1e-9:
              axis = perpendicular(a)
              angle = math.pi * t
              return tuple(x * math.cos(angle) + y * math.sin(angle) for x, y in zip(a, axis))
          angle = math.acos(cosine)
          sine = math.sin(angle)
          return tuple(
              (x * math.sin((1 - t) * angle) + y * math.sin(t * angle)) / sine for x, y in zip(a, b)
          )
      
      
      def camera_distance(width: float, lens: float = LENS, sensor_width: float = SENSOR_WIDTH) -> float:
          """Distance at which a lens frames `width` scene units across the sensor."""
          return width * lens / sensor_width
      
      
      def camera_pose(start, end, ease: float, lens: float = LENS, sensor_width: float = SENSOR_WIDTH):
          """Interpolate two [target, direction, width] endpoints into a camera placement."""
          target = lerp(start[0], end[0], ease)
          direction = slerp(start[1], end[1], ease)
          width = mix(start[2], end[2], ease)
          distance = camera_distance(width, lens, sensor_width)
          location = tuple(t + d * distance for t, d in zip(target, direction))
          return {"location": location, "target": target, "direction": direction, "width": width}
      
      
      def far_clip(config: dict, corners=()) -> float:
          """Camera clip_end covering every mesh corner from every shot endpoint, with margin."""
          reach = 0.0
          for shot in config["shots"]:
              for endpoint in (shot["start"], shot["end"]):
                  location = camera_pose(endpoint, endpoint, 0)["location"]
                  reach = max(reach, camera_distance(endpoint[2]))
                  for corner in corners:
                      reach = max(reach, math.dist(location, corner))
          return max(DEFAULT_CLIP_END, 1.25 * reach)
      
      
      def rig_light(location, power, size, target, rig_origin, rig_scale, height=None) -> dict:
          """Place a rig-unit light in scene units."""
      
          def place(point):
              return tuple(o + v * rig_scale for o, v in zip(rig_origin, point))
      
          return {
              "location": place(location),
              "target": place(target),
              "power": power * rig_scale * rig_scale,
              "size": size * rig_scale,
              "size_y": height * rig_scale if height else None,
          }
      
      
      def strip_pose(
          target, direction, width: float, ease: float, rig_scale: float, fallback_front=RIG_FRONT
      ):
          """The reflection strip sweeps 90 degrees around the subject over each shot.
      
          The strip blends from the wide preset to the close-up one (tighter, dimmer, smaller,
          so the highlight still reads as a moving reflection rather than a flood) as the
          visible width crosses STRIP_BAND. A vertical viewing direction has no heading: the
          sweep then keeps `fallback_front`, the previous frame's heading or the rig front.
          """
          low, high = STRIP_BAND
          close = 1 - smoothstep(min(1.0, max(0.0, (width / rig_scale - low) / (high - low))))
          radius, energy, size, size_y = (mix(w, c, close) for w, c in zip(STRIP_WIDE, STRIP_CLOSE))
          radius *= rig_scale
          angle = math.radians(-65 + 90 * ease)
          if math.hypot(direction[0], direction[1]) > 1e-6:
              front = normalize((direction[0], direction[1], 0))
          else:
              front = tuple(fallback_front)
          right = (-front[1], front[0], 0)
          location = tuple(
              t + f * radius * math.cos(angle) + r * radius * math.sin(angle) + up
              for t, f, r, up in zip(target, front, right, (0, 0, radius * 0.48))
          )
          return {
              "location": location,
              "front": front,
              "energy": energy * rig_scale * rig_scale,
              "size": size * rig_scale,
              "size_y": size_y * rig_scale,
          }
      
      
      def assign_first(target, attribute: str, candidates, fallback: str | None = None) -> str:
          """Assign the first candidate the running Blender accepts and keeps; return it.
      
          Dynamic enums (render engines, looks) cannot be listed from Python: bl_rna enum_items
          holds only their static placeholder, so assignment is the probe. Blender raises
          TypeError for an identifier it lacks.
          """
          for value in candidates:
              try:
                  setattr(target, attribute, value)
              except (TypeError, ValueError):
                  continue
              if getattr(target, attribute) == value:
                  return value
          if fallback is None:
              raise ValueError(f"{attribute}: none of {candidates} is available in this Blender")
          setattr(target, attribute, fallback)
          return fallback
      
      
      def pick_engine(render) -> str:
          return assign_first(render, "engine", ENGINE_CANDIDATES)
      
      
      def pick_look(view) -> str:
          return assign_first(view, "look", LOOK_CANDIDATES, fallback="None")
      
      
      def find_environment(configured, roots) -> Path:
          """The configured HDRI, else Blender's bundled studio.exr, else any bundled world EXR."""
          if configured:
              path = Path(configured)
              if not path.is_file():
                  raise FileNotFoundError(f"environment {path} does not exist")
              return path
          found = []
          for root in roots:
              if root and Path(root).is_dir():
                  found.extend(sorted(Path(root).rglob("studiolights/world/*.exr")))
          if not found:
              raise FileNotFoundError(
                  "No studio environment found in Blender's resources; set \"environment\" in the"
                  " film config to a local HDRI file"
              )
          return next((p for p in found if p.name == "studio.exr"), found[0])
      
      
      def set_present(target, values: dict) -> dict:
          """Assign the attributes the running Blender exposes; return what was applied."""
          applied = {}
          for name, value in values.items():
              if hasattr(target, name):
                  setattr(target, name, value)
                  applied[name] = value
          return applied
      
      
      def track_quaternion(location, target):
          from mathutils import Vector
      
          return (Vector(target) - Vector(location)).to_track_quat("-Z", "Y")
      
      
      def add_area_light(scene, spec, rig_origin, rig_scale):
          import bpy
      
          name, location, power, size, color, target, shape, height = spec
          placed = rig_light(location, power, size, target, rig_origin, rig_scale, height)
          light = bpy.data.lights.new(name, "AREA")
          light.energy = placed["power"]
          light.shape = shape
          light.size = placed["size"]
          light.color = color
          if placed["size_y"]:
              light.size_y = placed["size_y"]
          obj = bpy.data.objects.new(name, light)
          scene.collection.objects.link(obj)
          obj.location = placed["location"]
          obj.rotation_euler = track_quaternion(placed["location"], placed["target"]).to_euler()
          return obj
      
      
      def build_world(scene, environment: Path) -> str:
          import bpy
      
          if scene.world is None:
              scene.world = bpy.data.worlds.new("Comparison World")
          tree = ensure_nodes(scene.world)
          nodes, links = tree.nodes, tree.links
          nodes.clear()
          env = nodes.new("ShaderNodeTexEnvironment")
          env.image = bpy.data.images.load(str(environment), check_existing=True)
          env.image.pack()
          coord = nodes.new("ShaderNodeTexCoord")
          mapping = nodes.new("ShaderNodeMapping")
          mapping.inputs["Rotation"].default_value[2] = math.radians(ENVIRONMENT_ROTATION_DEGREES)
          links.new(coord.outputs["Generated"], mapping.inputs["Vector"])
          links.new(mapping.outputs["Vector"], env.inputs["Vector"])
          lighting = nodes.new("ShaderNodeBackground")
          lighting.inputs["Strength"].default_value = ENVIRONMENT_STRENGTH
          links.new(env.outputs["Color"], lighting.inputs["Color"])
          # The HDRI lights the scene; the camera sees a flat dark backdrop instead.
          visible = nodes.new("ShaderNodeBackground")
          visible.inputs["Color"].default_value = BACKDROP
          visible.inputs["Strength"].default_value = 1
          path = nodes.new("ShaderNodeLightPath")
          mix_shader = nodes.new("ShaderNodeMixShader")
          out = nodes.new("ShaderNodeOutputWorld")
          links.new(path.outputs["Is Camera Ray"], mix_shader.inputs[0])
          links.new(lighting.outputs[0], mix_shader.inputs[1])
          links.new(visible.outputs[0], mix_shader.inputs[2])
          links.new(mix_shader.outputs[0], out.inputs["Surface"])
          return (
              f"{environment.name}, strength {ENVIRONMENT_STRENGTH},"
              f" rotation {ENVIRONMENT_ROTATION_DEGREES} degrees"
          )
      
      
      def configure_render(scene, config: dict, preview: bool) -> dict:
          render = scene.render
          pick_engine(render)
          # The loaded .blend keeps its own RenderSettings under --factory-startup. A render
          # border left on from a preview, a stamp, a pixel aspect or a colour-management
          # override would shape every frame of both passes alike, so the rig owns them.
          set_present(
              render,
              {
                  "use_border": False,
                  "use_crop_to_border": False,
                  "pixel_aspect_x": 1.0,
                  "pixel_aspect_y": 1.0,
                  "use_stamp": False,
                  "use_motion_blur": False,
                  "use_freestyle": False,
                  "use_multiview": False,
                  "use_compositing": False,
                  "use_sequencer": False,
                  "use_simplify": False,
              },
          )
          set_present(render.image_settings, {"color_management": "FOLLOW_SCENE"})
          set_present(scene.display_settings, {"display_device": "sRGB"})
          eevee = scene.eevee
          applied = set_present(
              eevee,
              {
                  "taa_render_samples": config["samples"],
                  "use_raytracing": True,
                  "use_fast_gi": False,
                  "shadow_ray_count": 1,
                  "shadow_step_count": 6,
              },
          )
          if hasattr(eevee, "ray_tracing_options"):
              # Half-resolution traced reflections with denoising: the fast preset the film uses.
              applied["ray_tracing_options"] = set_present(
                  eevee.ray_tracing_options,
                  {
                      "resolution_scale": "2",
                      "screen_trace_quality": 0.5,
                      "screen_trace_thickness": 0.1,
                      "use_denoise": True,
                      "denoise_spatial": True,
                      "denoise_temporal": True,
                      "denoise_bilateral": True,
                  },
              )
          render.resolution_x = config["width"]
          render.resolution_y = config["height"]
          render.resolution_percentage = 50 if preview else 100
          render.image_settings.file_format = "PNG"
          render.image_settings.color_mode = "RGB"
          render.image_settings.color_depth = "8"
          render.image_settings.compression = 10
          render.film_transparent = False
          render.fps = config["fps"]
          scene.frame_start = 1
          scene.frame_end = shot_frames(config) * len(config["shots"])
          view = scene.view_settings
          view.view_transform = "AgX"
          pick_look(view)  # after the transform, which resets the look
          view.exposure = 0.0
          view.gamma = 1
          set_present(view, {"use_curve_mapping": False, "use_white_balance": False})
          return applied
      
      
      def ensure_camera(scene, config: dict):
          import bpy
          from mathutils import Vector
      
          # Never reuse scene.camera: a source camera keeps its parent, constraints, shift and
          # sensor fit, which move the render away from the keyed pose while the contract (local
          # keyframes only) still matches between passes. The source camera stays in the file.
          stale = bpy.data.objects.get(CAMERA_NAME)
          if stale is not None:
              stale.name = f"{CAMERA_NAME} (previous)"
          camera = bpy.data.objects.new(CAMERA_NAME, bpy.data.cameras.new(CAMERA_NAME))
          scene.collection.objects.link(camera)
          scene.camera = camera
          corners = [
              tuple(world_matrix(obj) @ Vector(corner))
              for obj in scene.objects
              if obj.type == "MESH"
              for corner in obj.bound_box
          ]
          data = camera.data
          data.type = "PERSP"
          data.lens = LENS
          data.sensor_width = SENSOR_WIDTH
          # camera_distance assumes the sensor width spans the image width; AUTO fits the longer side.
          data.sensor_fit = "HORIZONTAL"
          data.shift_x = data.shift_y = 0
          data.dof.use_dof = False
          data.clip_start = CLIP_START
          data.clip_end = far_clip(config, corners)
          camera.rotation_mode = "QUATERNION"
          return camera
      
      
      def animate(scene, config: dict, camera, strip) -> list[dict]:
          count = shot_frames(config)
          rig_scale = config["rig_scale"]
          records = []
          for shot_index, shot in enumerate(config["shots"]):
              scene.timeline_markers.new(shot["name"], frame=shot_index * count + 1)
              front = RIG_FRONT
              for index in range(count):
                  frame = shot_index * count + index + 1
                  ease = ease_at(index, count)
                  pose = camera_pose(shot["start"], shot["end"], ease)
                  camera.location = pose["location"]
                  camera.rotation_quaternion = track_quaternion(pose["location"], pose["target"])
                  camera.keyframe_insert("location", frame=frame)
                  camera.keyframe_insert("rotation_quaternion", frame=frame)
                  sweep = strip_pose(
                      pose["target"], pose["direction"], pose["width"], ease, rig_scale, front
                  )
                  front = sweep["front"]
                  strip.location = sweep["location"]
                  strip.rotation_euler = track_quaternion(sweep["location"], pose["target"]).to_euler()
                  strip.data.energy = sweep["energy"]
                  strip.data.size = sweep["size"]
                  strip.data.size_y = sweep["size_y"]
                  strip.keyframe_insert("location", frame=frame)
                  strip.keyframe_insert("rotation_euler", frame=frame)
                  for prop in ("energy", "size", "size_y"):
                      strip.data.keyframe_insert(prop, frame=frame)
                  records.append(
                      {
                          "frame": frame,
                          "shot": shot["name"],
                          "location": list(camera.location),
                          "target": list(pose["target"]),
                          "lens": camera.data.lens,
                          "rotation": list(camera.rotation_quaternion),
                          "light_location": list(strip.location),
                          "light_rotation": list(strip.rotation_euler),
                          "light_power": strip.data.energy,
                          "light_width": strip.data.size,
                          "light_height": strip.data.size_y,
                      }
                  )
          return records
      
      
      def tidy_ui(scene):
          """Leave the saved scene looking through the camera in material preview."""
          import bpy
      
          scene.frame_set(1)
          for obj in scene.objects:
              try:
                  obj.select_set(False)
              except RuntimeError:
                  # Objects outside the active view layer cannot be deselected here.
                  pass
          bpy.context.view_layer.objects.active = None
          for screen in bpy.data.screens:
              for area in screen.areas:
                  if area.type == "VIEW_3D":
                      area.spaces.active.region_3d.view_perspective = "CAMERA"
                      area.spaces.active.shading.type = "MATERIAL"
      
      
      def setup(config: dict, mode: str, preview: bool = False):
          """Build the rig in the open scene. Returns (scene, per-frame records, contract)."""
          import bpy
      
          scene = bpy.context.scene
          applied = configure_render(scene, config, preview)
          environment = find_environment(
              config.get("environment"),
              [bpy.utils.resource_path(kind) for kind in ("LOCAL", "SYSTEM")],
          )
          environment_note = build_world(scene, environment)
          for obj in list(scene.objects):
              if obj.type == "LIGHT":
                  bpy.data.objects.remove(obj, do_unlink=True)
          rig_origin, rig_scale = config["rig_origin"], config["rig_scale"]
          for spec in FIXED_LIGHTS:
              add_area_light(scene, spec, rig_origin, rig_scale)
          strip = add_area_light(scene, STRIP_LIGHT, rig_origin, rig_scale)
          camera = ensure_camera(scene, config)
          records = animate(scene, config, camera, strip)
          tidy_ui(scene)
          scene["comparison_pass"] = mode
          scene["comparison_note"] = (
              "Exact shared camera and reflection-light samples. Geometry, illumination, exposure"
              " and render settings match. Source materials versus PATINA maps."
          )
          settings = {
              "engine": scene.render.engine,
              "eevee": applied,
              "resolution": [config["width"], config["height"]],
              "fps": config["fps"],
              "frames": scene.frame_end,
              "view_transform": scene.view_settings.view_transform,
              "look": scene.view_settings.look,
              "exposure": scene.view_settings.exposure,
              "environment": environment_note,
              "camera": {
                  "lens": camera.data.lens,
                  "sensor_width": camera.data.sensor_width,
                  "sensor_fit": camera.data.sensor_fit,
                  "shift": [camera.data.shift_x, camera.data.shift_y],
                  "clip": [camera.data.clip_start, camera.data.clip_end],
              },
              "lights": [
                  {
                      "name": obj.name,
                      "location": list(obj.location),
                      "rotation": list(obj.rotation_euler),
                      "power": obj.data.energy,
                      "color": list(obj.data.color),
                      "size": obj.data.size,
                  }
                  for obj in scene.objects
                  if obj.type == "LIGHT"
              ],
          }
          contract = {
              "pass_name": mode,
              "blender": bpy.app.version_string,
              "camera_sha256": hashlib.sha256(json.dumps(records, sort_keys=True).encode()).hexdigest(),
              "settings": settings,
              "shots": config["shots"],
              "geometry_sha256": geometry_hash([o for o in scene.objects if o.type == "MESH"]),
          }
          return scene, records, contract
      
    • worker.py 4.6 KB
      """Blender-side renderer for one pass of the comparison film. film.py launches it.
      
          blender --factory-startup --background <scene.blend> --python-exit-code 1 \
              --python scripts/worker.py -- <mode> <frames.json> <run_dir> <pilot|run> [config.json]
      
      <mode> is "original" or "patina". The config path falls back to $PATINA_CONFIG so a manual
      invocation still works. Frames already on disk are skipped, which is what makes a run
      resumable. Any failure exits 1 with a one-line message, with or without --python-exit-code.
      """
      
      from __future__ import annotations
      
      import json
      import os
      import sys
      import time
      import traceback
      from pathlib import Path
      
      sys.path.insert(0, str(Path(__file__).parent))
      
      from common import dump, read_config, script_args  # noqa: E402
      from scene import setup  # noqa: E402
      
      
      def contract_path(run_dir: Path, mode: str) -> Path:
          return run_dir / f"{mode}_contract.json"
      
      
      def native_scene_path(run_dir: Path, mode: str) -> Path:
          return run_dir / f"{mode.title()} Camera Animation.blend"
      
      
      def flatten(data, prefix: str = "") -> dict:
          """Dotted leaf paths, so a mismatch names settings.engine rather than settings."""
          if isinstance(data, dict):
              items = data.items()
          elif isinstance(data, list):
              items = enumerate(data)
          else:
              return {prefix.rstrip("."): data}
          out = {}
          for key, value in items:
              out.update(flatten(value, f"{prefix}{key}."))
          return out
      
      
      def record_contract(run_dir: Path, mode: str, contract: dict) -> None:
          """First worker writes the contract; every later one must reproduce it exactly.
      
          The Blender version is recorded and reported but not compared: a release that renders
          the rig identically may resume, and one that does not shows up in the other keys.
          """
          path = contract_path(run_dir, mode)
          if not path.exists():
              path.write_text(json.dumps(contract, indent=2))
              return
          stored = json.loads(path.read_text())
          before, now = flatten(stored), flatten(contract)
          differing = sorted(
              key
              for key in set(before) | set(now)
              if key != "blender" and before.get(key) != now.get(key)
          )
          if differing:
              detail = ", ".join(f"{k}: {before.get(k)!r} on disk, {now.get(k)!r} now" for k in differing)
              raise RuntimeError(
                  f"{path}: contract mismatch ({detail}); Blender {stored.get('blender')} on disk,"
                  f" {contract.get('blender')} now. The folder's frames were rendered under other"
                  " settings: another Blender release renames the engine and look, another scene"
                  " or rig changes the hashes and lights."
              )
      
      
      def render_frames(scene, frames: list[int], folder: Path, mode: str) -> int:
          import bpy
      
          folder.mkdir(parents=True, exist_ok=True)
          started = time.monotonic()
          rendered = 0
          for frame in frames:
              target = folder / f"{frame:04d}.png"
              if target.exists():
                  continue
              scene.frame_set(frame)
              scene.render.filepath = str(target)
              bpy.ops.render.render(write_still=True)
              rendered += 1
              elapsed = round(time.monotonic() - started, 2)
              print(dump({"pass": mode, "frame": frame, "elapsed": elapsed}), flush=True)
          return rendered
      
      
      def main(argv: list[str]) -> dict:
          if len(argv) not in (4, 5):
              raise SystemExit(
                  "usage: ... --python worker.py -- <mode> <frames.json> <run_dir> <pilot|run> [config]"
              )
          mode, frame_file, run_dir, kind = argv[:4]
          config_path = argv[4] if len(argv) == 5 else os.environ.get("PATINA_CONFIG")
          config, _ = read_config(config_path)
          run = Path(run_dir)
          run.mkdir(parents=True, exist_ok=True)
          scene, _, contract = setup(config, mode, preview=kind == "pilot")
          record_contract(run, mode, contract)
          native = native_scene_path(run, mode)
          if kind != "pilot" and not native.exists():
              import bpy
      
              scene.render.resolution_percentage = 100
              bpy.ops.wm.save_as_mainfile(filepath=str(native))
          frames = json.loads(Path(frame_file).read_text())
          folder = run / ("pilot" if kind == "pilot" else "frames") / mode
          rendered = render_frames(scene, frames, folder, mode)
          return {"pass": mode, "kind": kind, "requested": len(frames), "rendered": rendered}
      
      
      def run() -> None:
          """Blender only propagates an exception to its exit status under --python-exit-code."""
          try:
              print(dump(main(script_args())), flush=True)
          except Exception as error:
              traceback.print_exc()
              raise SystemExit(f"worker.py failed: {type(error).__name__}: {error}") from None
      
      
      if __name__ == "__main__":
          run()
      
  • SKILL.md 13.7 KB
    ---
    name: scenario-patina-retexture
    description: "Use when retexturing an existing 3D asset with PATINA PBR materials through the Scenario MCP server: a GLB or Blender scene (an image-to-3D result with flat colors, a prop, a diorama) whose surfaces need basecolor, normal, roughness, metalness, and height maps per material family with geometry untouched, or a matched before-and-after comparison film rendered in Blender with one camera path, lighting, and cut. Keywords: PATINA, PBR retexture, material families, Blender EEVEE, comparison video."
    license: MIT
    ---
    
    # Scenario PATINA retexture
    
    ## Overview
    
    A mesh whose materials are flat colors or placeholders (an image-to-3D result, a whole diorama) becomes a PBR-shaded asset in three moves: group its materials into physical surface families, generate one seamless PATINA material per family on Scenario, and wire the five maps into Blender materials with vertices, faces, and transforms left unchanged. An optional film renders the original and the retextured scene along one camera path with the same lights, exposure, and cut, so the material response is the only thing that differs between the two panels. The film is rendered locally to present the 3D deliverable, never generated on Scenario, so the skill's output type stays 3D.
    
    PATINA is Patina AI's material family on Scenario (tag `sc:texture`). This skill runs PATINA Material, the prompt-to-material member; the flat-texture members (Image to Maps, Material Extract) belong to `scenario-textures`. Repainting a whole mesh with a 3D-to-3D texturing model is the `scenario-3d` lane; this one keeps per-material control and editable Blender shaders. The agent's own steps stay on MCP; the bundled scripts (Blender 4.2 or newer, Python 3 with Pillow and numpy, ffmpeg) do the local repetition and never call Scenario. Connection and the core loop: the `scenario` skill. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    ## Quick reference
    
    | Step           | Tool                                                             | Notes                                                                                                                        |
    | -------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
    | Scope          | `teams_list`, `projects_list`                                    | The source asset and the generation destination may live in different projects: pass the right pair on every call            |
    | Fetch the mesh | `asset_get`, then `asset_download`                               | `properties.materialCount`, `faceCount`, `hasUVs`, `dimensions`; save with `curl -fL`                                        |
    | Inventory      | [inventory.py](scripts/inventory.py) in Blender                  | Writes `Before.blend` and `inventory.json`: per-material object counts, bounds, base color                                   |
    | Find PATINA    | `search` with `target="models"`, `query="patina"`, `public=true` | Pick PATINA Material (`txt2img`); `model_schema_get` before running                                                          |
    | Price          | `model_run` with `dry_run=true`                                  | One 1024x1024 five-map set was 17 CU at authoring time; `width`, `height`, `maps`, `upscaleFactor`, and `numOutputs` move it |
    | Generate       | `model_run` with `wait=false`, one per family                    | Fixed `seed` per family; one `jobs_wait` over every `job_id`                                                                 |
    | Identify maps  | `asset_get` on each output                                       | `metadata.type` names the role; never rely on position                                                                       |
    | Download       | `asset_download`, then `curl -fL`                                | `textures/<family>/<role>.png`                                                                                               |
    | Apply          | [apply_materials.py](scripts/apply_materials.py) in Blender      | Manifest in [materials-manifest.md](references/materials-manifest.md); geometry hash asserted                                |
    | Film           | [film.py](scripts/film.py) `pilot`, `run`, `status`              | Config in [film-config.md](references/film-config.md); resumable                                                             |
    
    ## Material families
    
    Read `inventory.json`, not the scene. Group materials by what the surface physically is (plaster, cedar, terracotta, glazed ceramic, brass, painted steel, glass, canvas, asphalt, foliage), reusing one family across color variants: the shader keeps each original color as a tint under the PATINA basecolor by default, so one plaster set serves cream, ochre, and gray walls. Split a shared color that sits on different surfaces (the same green on a canvas awning and a painted sign) with an `object_overrides` entry keyed by object-name prefix. A 41-material diorama needed 14 families; expect one job per family. Where the mesh already models bricks, roof tiles, or planks, prompt for the material of one element (fired clay, weathered cedar), never for the pattern: the geometry supplies the structure and a patterned map doubles it.
    
    ## Generating one set per family
    
    `model_schema_get` on the discovered id is the contract; the names below held at authoring time. `prompt` (required, 2048 characters) describes a flat, evenly lit, seamless physical surface with no objects or signage. `maps` is an array even for one entry (a bare string is ignored); keep the default of all five. `width` and `height` run 512 to 2048 in steps of 16, 1024 by default, and `tileSize` (128, about 1024 px) follows them: 256 for a 2048 set, or the repeat window no longer matches the texture. `upscaleFactor` 2 or 4 enlarges the predicted maps only, not the base texture. `tilingMode` restricts the repeat to one axis for planks or corrugation. `numOutputs` returns up to 4 variants of one prompt, each priced. `enablePromptExpansion` is on by default: turn it off when the prompt is deliberate. Set `seed` per family and keep it in the manifest so a family can be regenerated identically.
    
    Keep `dry_run` and `wait` at the top level of `model_run`, beside `model_id`, `team_id`, and `project_id`; only model inputs belong under `parameters`. Price the exact payload with `dry_run=true` (a cost estimate, not parameter validation), confirm the budget (families times cost), then launch every family with `wait=false` and one `jobs_wait` over all the job ids (32 per call), re-called with `pending_job_ids` on timeout rather than answered with a second `model_run`. On a transport error, `jobs_list` before re-running: the job usually landed.
    
    With the default five `maps` and `numOutputs` 1, a completed job carried six asset ids at authoring time: the base texture (`metadata.type` `inference-txt2img-texture`) and one asset per map, labeled `texture-albedo`, `texture-normal`, `texture-smoothness`, `texture-metallic`, and `texture-height`; `numOutputs` multiplies the set. Treat the `metadata.type` values read back with `asset_get` as the contract and map them to the five roles, never the order. The roughness request comes back labeled smoothness, and at authoring time the values were roughness (a brushed brass map read darker than a matte gray one), so the label is the trap, not the data: `asset_display` that map for a family whose finish you know before touching `roughness_is_smoothness` in the manifest, which inverts it when true. `asset_download` each map (PNG by default) into `textures/<family>/<role>.png`, and record prompt, seed, job id, asset ids, scope pair, and billed `cuCost` under the manifest's `provenance`.
    
    ## Applying in Blender
    
    `blender --background --factory-startup --python scripts/inventory.py -- model.glb work/` imports the mesh (GLB, glTF, OBJ, or FBX) or opens a `.blend`, saves a packed `work/Before.blend`, and writes `work/inventory.json`. Write `work/materials.json` mapping every material name to a family and every family to its five maps (the manifest reference has every key and default), then run `blender --background --factory-startup work/Before.blend --python scripts/apply_materials.py -- work/materials.json`. It validates the manifest, projects a dedicated `PatinaUV` layer along each face's dominant axis at object scale so `tile_span` means meters per repeat (a mesh shared by linked duplicates is made single-user first), builds one Principled BSDF per original material and family, packs the images, and asserts the geometry hash is unchanged. The `.blend` is the deliverable: a glTF export flattens the calibrated shader graph (the exporter carries only direct texture links), so bake before exporting to an engine. Open the result or render one still before spending render time on a film.
    
    ## Comparison film
    
    `python3 scripts/film.py config.json pilot` renders both endpoints of every shot at half resolution for both passes and writes `Pilot Contact.jpg` with a rough time estimate. Fix framing and material defects from that sheet, then `run` renders at native resolution, resumes valid frames, saves both camera scenes as editable `.blend` files, and assembles and verifies the films; `status` prints progress. The run folder is fingerprinted from the inputs, the scripts, and the config, so a change to any of them starts a new folder. The entry points share [common.py](scripts/common.py) and [scene.py](scripts/scene.py), and `film.py` launches [worker.py](scripts/worker.py) inside Blender; none of the three is run directly.
    
    Design shots from `inventory.json` bounds, not from the example plan, whose coordinates fit one asset. The fast preset (EEVEE, 24 samples, half-resolution ray-traced reflections with denoising, 12 fps sources interpolated to 24 fps for slow moves) ran at about a second per 2368x1332 frame on a laptop, so the example plan's 792 source frames per pass take roughly a quarter hour each; refine from `pilot_estimate.json`, and keep `source_fps` 24 for fast motion. Verify with `review/Sweep Contact.jpg`, sampled at 2 fps across the whole master: one still per shot passes a defect that fades in mid-shot. A clean encoder log is not visual approval.
    
    ## Worked example: retexture a street diorama and film the comparison
    
    1. Use the team/project pair already authorized by the user, or enumerate with `teams_list` and `projects_list` and ask them to choose. Unattended without an authorized pair, stop before scoped calls and report the missing scope. `asset_get` on the diorama (an image-to-3D result: 41 materials, no image textures, 78K faces), `asset_download`, then `curl -fL -o work/street.glb "<url>"`.
    2. Inventory with `inventory.py`; group the 41 materials into families such as `plaster`, `cedar`, `terracotta`, `ceramic`, `brass`, `steel`, `glass`, `canvas`, `asphalt`, and `leaf`, with an override sending the awning objects to `canvas`.
    3. `search` for PATINA, then `model_schema_get` on the returned PATINA Material id; re-discover each session.
    4. `model_run` with top-level `dry_run=true` on the exact family payloads. At authoring time, 17 CU per family made 238 CU for fourteen; use the current estimates. Proceed within an already authorized budget or ask the user to approve the total. Unattended without enough authorized budget, return the estimates and stop before generation.
    5. Fourteen `model_run` calls with top-level `wait=false`, prompts such as "aged lime plaster wall, fine mineral grain, flat, evenly lit, seamless", `seed` fixed per family; one `jobs_wait` with all fourteen ids until every row is complete.
    6. `asset_get` on each output to read `metadata.type`; `asset_display` the smoothness-labeled plaster map to settle polarity; `asset_download` and `curl -fL` every map into `textures/<family>/`.
    7. Write `materials.json` (roughness ranges per family, `roughness_is_smoothness` as settled, `provenance` with job ids and cost) and run `apply_materials.py`; open `PATINA.blend` once.
    8. Copy [example-config.json](references/example-config.json), replace the shots with ones framed from the inventory bounds, run `film.py` in `pilot` mode, inspect, then `run`; hand over the master, the sharing copy, both passes, and the two editable scenes, and name the interpolation used.
    9. Optional: follow the `scenario` skill's multipart upload flow: `upload_asset` with `kind: "video"`, `file_name`, `content_type: "video/mp4"`, and `file_size` in bytes (omit `data`), PUT every part to its returned URL, then `upload_asset_complete` with the returned `upload_id` and the same scope. Only after completion returns the video asset id, file it with the source and the maps in a collection (`collection_create`, then `collection_add_assets`: catalog tools, run through `scenario_tool_execute_write` with their arguments under `parameters`).
    
    ## Common mistakes
    
    - Mapping outputs by position or by the `source` field alone: read `metadata.type`, and confirm the smoothness label's polarity before inverting.
    - Prompting for bricks, tiles, or planks on a mesh that already models them: the pattern doubles.
    - Asking a PATINA prompt for signage, objects, or lighting: it produces a material, not a scene.
    - Regenerating a family because `jobs_wait` timed out or `model_run` raised a transport error: re-call with `pending_job_ids`, or `jobs_list` first.
    - Running the full film before a pilot, or approving it from one still per shot instead of the 2 fps sweep sheet.
    - Editing the config or a script mid-run and expecting the render to resume: the fingerprint changed, so it starts fresh.
    - Hardcoding the PATINA id or the 17 CU figure: re-discover with `search`, re-price with `dry_run`.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related