@nodetool-ai/blender-nodes 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/blender_ops/depth.py +133 -0
- package/blender_ops/errors.py +46 -0
- package/blender_ops/exr.py +205 -0
- package/blender_ops/framing.py +75 -0
- package/blender_ops/ops/__init__.py +24 -0
- package/blender_ops/ops/common.py +310 -0
- package/blender_ops/ops/export_model.py +95 -0
- package/blender_ops/ops/prepare_for_engine.py +352 -0
- package/blender_ops/ops/render_animation.py +382 -0
- package/blender_ops/ops/render_image.py +41 -0
- package/blender_ops/ops/render_passes.py +363 -0
- package/blender_ops/run_job.py +110 -0
- package/blender_ops/tests/test_camera_lens.py +107 -0
- package/blender_ops/tests/test_depth.py +158 -0
- package/blender_ops/tests/test_exr.py +218 -0
- package/blender_ops/tests/test_framing.py +50 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/bake.d.ts +107 -0
- package/dist/bake.d.ts.map +1 -0
- package/dist/bake.js +209 -0
- package/dist/bake.js.map +1 -0
- package/dist/blender-binary.d.ts +38 -0
- package/dist/blender-binary.d.ts.map +1 -0
- package/dist/blender-binary.js +169 -0
- package/dist/blender-binary.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/job.d.ts +163 -0
- package/dist/job.d.ts.map +1 -0
- package/dist/job.js +67 -0
- package/dist/job.js.map +1 -0
- package/dist/nodes/bake-timeline-clip.d.ts +48 -0
- package/dist/nodes/bake-timeline-clip.d.ts.map +1 -0
- package/dist/nodes/bake-timeline-clip.js +152 -0
- package/dist/nodes/bake-timeline-clip.js.map +1 -0
- package/dist/nodes/blender-error.d.ts +21 -0
- package/dist/nodes/blender-error.d.ts.map +1 -0
- package/dist/nodes/blender-error.js +38 -0
- package/dist/nodes/blender-error.js.map +1 -0
- package/dist/nodes/defaults.d.ts +18 -0
- package/dist/nodes/defaults.d.ts.map +1 -0
- package/dist/nodes/defaults.js +18 -0
- package/dist/nodes/defaults.js.map +1 -0
- package/dist/nodes/export-model.d.ts +49 -0
- package/dist/nodes/export-model.d.ts.map +1 -0
- package/dist/nodes/export-model.js +127 -0
- package/dist/nodes/export-model.js.map +1 -0
- package/dist/nodes/prepare-for-engine.d.ts +52 -0
- package/dist/nodes/prepare-for-engine.d.ts.map +1 -0
- package/dist/nodes/prepare-for-engine.js +127 -0
- package/dist/nodes/prepare-for-engine.js.map +1 -0
- package/dist/nodes/progress.d.ts +11 -0
- package/dist/nodes/progress.d.ts.map +1 -0
- package/dist/nodes/progress.js +21 -0
- package/dist/nodes/progress.js.map +1 -0
- package/dist/nodes/render-animation.d.ts +52 -0
- package/dist/nodes/render-animation.d.ts.map +1 -0
- package/dist/nodes/render-animation.js +136 -0
- package/dist/nodes/render-animation.js.map +1 -0
- package/dist/nodes/render-base.d.ts +35 -0
- package/dist/nodes/render-base.d.ts.map +1 -0
- package/dist/nodes/render-base.js +95 -0
- package/dist/nodes/render-base.js.map +1 -0
- package/dist/nodes/render-image.d.ts +41 -0
- package/dist/nodes/render-image.d.ts.map +1 -0
- package/dist/nodes/render-image.js +98 -0
- package/dist/nodes/render-image.js.map +1 -0
- package/dist/nodes/render-passes.d.ts +54 -0
- package/dist/nodes/render-passes.d.ts.map +1 -0
- package/dist/nodes/render-passes.js +167 -0
- package/dist/nodes/render-passes.js.map +1 -0
- package/dist/run-job.d.ts +34 -0
- package/dist/run-job.d.ts.map +1 -0
- package/dist/run-job.js +137 -0
- package/dist/run-job.js.map +1 -0
- package/dist/runner.d.ts +124 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +521 -0
- package/dist/runner.js.map +1 -0
- package/package.json +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @nodetool-ai/blender-nodes
|
|
2
|
+
|
|
3
|
+
Headless Blender nodes for NodeTool: render glTF scenes to images, image
|
|
4
|
+
sequences and render passes, and prepare meshes for a game engine.
|
|
5
|
+
|
|
6
|
+
The nodes shell out to a Blender binary, resolved by `blender-binary.ts` from
|
|
7
|
+
`BLENDER_PATH` or the platform's usual install locations. Without a
|
|
8
|
+
Blender install the nodes fail their preflight with the path they looked for —
|
|
9
|
+
they never silently produce an empty render.
|
|
10
|
+
|
|
11
|
+
## Nodes
|
|
12
|
+
|
|
13
|
+
| Node | Does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `RenderImage` | One frame of a glTF scene |
|
|
16
|
+
| `RenderAnimation` | A frame range, returned as a video |
|
|
17
|
+
| `RenderPasses` | Per-pass output (depth, normal, cryptomatte, …) |
|
|
18
|
+
| `PrepareForEngine` | Mesh cleanup, decimation and export for engine import |
|
|
19
|
+
| `ExportModel` | Convert a scene between the formats Blender writes |
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Install the package alongside the runtime and it registers with the node
|
|
24
|
+
registry like any other node package:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @nodetool-ai/blender-nodes
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Run a single node without a graph:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
nodetool node run nodetool.blender.RenderImage --props '{"scene": "..."}'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
See [AGENTS.md](../../AGENTS.md) for the repo-wide build and test commands.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Depth normalization for the `render_passes` op (D4).
|
|
2
|
+
|
|
3
|
+
Pure Python, no `bpy` import, so `tests/test_depth.py` runs under system
|
|
4
|
+
Python as well as Blender's own interpreter.
|
|
5
|
+
|
|
6
|
+
Contracts:
|
|
7
|
+
|
|
8
|
+
- `depth`: linear distance along the camera view axis, in scene units, from
|
|
9
|
+
the Z pass. `png16` (default) normalizes to `[0, 65535]` between
|
|
10
|
+
`depth_near` and `depth_far` — the min and max finite depth over the
|
|
11
|
+
foreground (masked) pixels — with background `65535`. `exr` keeps the raw
|
|
12
|
+
float with background `+inf`, rewritten from the `1e10` sentinel the
|
|
13
|
+
staged file carries.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import struct
|
|
17
|
+
import zlib
|
|
18
|
+
|
|
19
|
+
#: 16-bit value written for background (masked-out or non-finite) pixels.
|
|
20
|
+
BACKGROUND16 = 65535
|
|
21
|
+
|
|
22
|
+
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _isfinite(value):
|
|
26
|
+
return value != float("inf") and value != float("-inf") and value == value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def depth_range(depths, foreground):
|
|
30
|
+
"""Min/max finite depth over the foreground pixels.
|
|
31
|
+
|
|
32
|
+
`depths` is the Z channel in row-major order, `foreground` a parallel
|
|
33
|
+
mask (nonzero is foreground). Returns `(near, far)`. Raises
|
|
34
|
+
`ValueError` when no foreground pixel carries a finite depth.
|
|
35
|
+
"""
|
|
36
|
+
near = float("inf")
|
|
37
|
+
far = float("-inf")
|
|
38
|
+
for depth, mask in zip(depths, foreground):
|
|
39
|
+
if not mask:
|
|
40
|
+
continue
|
|
41
|
+
if not _isfinite(depth):
|
|
42
|
+
continue
|
|
43
|
+
if depth < near:
|
|
44
|
+
near = depth
|
|
45
|
+
if depth > far:
|
|
46
|
+
far = depth
|
|
47
|
+
if near == float("inf"):
|
|
48
|
+
raise ValueError("no foreground pixel carries a finite depth")
|
|
49
|
+
return near, far
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_to_u16(depths, foreground, near, far):
|
|
53
|
+
"""Map depths to `[0, 65535]`: foreground `near -> 0`, `far -> 65535`.
|
|
54
|
+
|
|
55
|
+
Background (unmasked) and non-finite pixels map to `65535`. A degenerate
|
|
56
|
+
range (`far <= near`, e.g. one fronto-parallel plane) maps finite
|
|
57
|
+
foreground to `0` instead of dividing by zero.
|
|
58
|
+
"""
|
|
59
|
+
span = far - near
|
|
60
|
+
values = []
|
|
61
|
+
for depth, mask in zip(depths, foreground):
|
|
62
|
+
if not mask or not _isfinite(depth):
|
|
63
|
+
values.append(BACKGROUND16)
|
|
64
|
+
elif span <= 0:
|
|
65
|
+
values.append(0)
|
|
66
|
+
else:
|
|
67
|
+
scaled = (depth - near) / span
|
|
68
|
+
if scaled < 0:
|
|
69
|
+
scaled = 0
|
|
70
|
+
elif scaled > 1:
|
|
71
|
+
scaled = 1
|
|
72
|
+
values.append(int(round(scaled * BACKGROUND16)))
|
|
73
|
+
return values
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _chunk(kind, payload):
|
|
77
|
+
return (
|
|
78
|
+
struct.pack(">I", len(payload))
|
|
79
|
+
+ kind
|
|
80
|
+
+ payload
|
|
81
|
+
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check_fills(values, width, height, channels, label):
|
|
86
|
+
if len(values) != width * height * channels:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"%s values %d do not fill %dx%dx%d"
|
|
89
|
+
% (label, len(values), width, height, channels)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_png(path, width, height, bit_depth, color_type, raw):
|
|
94
|
+
ihdr = struct.pack(">IIBBBBB", width, height, bit_depth, color_type, 0, 0, 0)
|
|
95
|
+
with open(path, "wb") as handle:
|
|
96
|
+
handle.write(_PNG_SIGNATURE)
|
|
97
|
+
handle.write(_chunk(b"IHDR", ihdr))
|
|
98
|
+
handle.write(_chunk(b"IDAT", zlib.compress(bytes(raw))))
|
|
99
|
+
handle.write(_chunk(b"IEND", b""))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_gray16_png(path, width, height, values):
|
|
103
|
+
"""Write 16-bit grayscale PNG: big-endian samples, filter 0 per row."""
|
|
104
|
+
_check_fills(values, width, height, 1, "depth")
|
|
105
|
+
raw = bytearray()
|
|
106
|
+
for row in range(height):
|
|
107
|
+
raw.append(0)
|
|
108
|
+
for col in range(width):
|
|
109
|
+
raw += struct.pack(">H", values[row * width + col])
|
|
110
|
+
_write_png(path, width, height, 16, 0, raw)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_gray8_png(path, width, height, values):
|
|
114
|
+
"""Write 8-bit grayscale PNG (the mask pass): filter 0 per row."""
|
|
115
|
+
_check_fills(values, width, height, 1, "mask")
|
|
116
|
+
raw = bytearray()
|
|
117
|
+
for row in range(height):
|
|
118
|
+
raw.append(0)
|
|
119
|
+
for col in range(width):
|
|
120
|
+
raw.append(values[row * width + col])
|
|
121
|
+
_write_png(path, width, height, 8, 0, raw)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def write_rgb8_png(path, width, height, values):
|
|
125
|
+
"""Write 8-bit RGB PNG (the normal pass): filter 0 per row."""
|
|
126
|
+
_check_fills(values, width, height, 3, "normal")
|
|
127
|
+
raw = bytearray()
|
|
128
|
+
for row in range(height):
|
|
129
|
+
raw.append(0)
|
|
130
|
+
for col in range(width):
|
|
131
|
+
base = (row * width + col) * 3
|
|
132
|
+
raw += bytes(values[base : base + 3])
|
|
133
|
+
_write_png(path, width, height, 8, 2, raw)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Error classes shared by `run_job.py` and the op modules.
|
|
2
|
+
|
|
3
|
+
An op failure carries its `result.json` error code on the class, so
|
|
4
|
+
`run_job.py` turns it into `ok: false` with the code from the class and the
|
|
5
|
+
message from the exception. Imported before `sys.excepthook` is installed:
|
|
6
|
+
this module imports nothing, so the import itself cannot fail.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BlenderOpError(Exception):
|
|
11
|
+
"""An op failure with a `result.json` error code on the class."""
|
|
12
|
+
|
|
13
|
+
code = "render_failed"
|
|
14
|
+
|
|
15
|
+
def __init__(self, message="", code=None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
if code is not None:
|
|
18
|
+
self.code = code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BadJob(BlenderOpError):
|
|
22
|
+
code = "bad_job"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ImportFailed(BlenderOpError):
|
|
26
|
+
code = "import_failed"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NoGeometry(BlenderOpError):
|
|
30
|
+
code = "no_geometry"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NoCamera(BlenderOpError):
|
|
34
|
+
code = "no_camera"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RenderFailed(BlenderOpError):
|
|
38
|
+
code = "render_failed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExportFailed(BlenderOpError):
|
|
42
|
+
code = "export_failed"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BakeFailed(BlenderOpError):
|
|
46
|
+
code = "bake_failed"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Minimal OpenEXR reader for the compositor's staged passes.
|
|
2
|
+
|
|
3
|
+
Blender's File Output node writes multilayer scanline EXR that this
|
|
4
|
+
Blender's own `bpy.data.images.load` surfaces as a 0x0 image, so the op
|
|
5
|
+
reads the bytes directly. Only the shape Blender writes under
|
|
6
|
+
`--factory-startup` is supported, and the reader refuses anything else
|
|
7
|
+
rather than guessing:
|
|
8
|
+
|
|
9
|
+
- single-part scanline image, increasing line order,
|
|
10
|
+
- `NO_COMPRESSION` (Blender's default for file outputs),
|
|
11
|
+
- `FLOAT` channels (depth and normal passes are float),
|
|
12
|
+
- one chunk per scanline holding every channel in file order.
|
|
13
|
+
|
|
14
|
+
Pure Python, no `bpy`: `tests/test_exr.py` (inside `test_depth.py`) covers
|
|
15
|
+
it with a synthetic file, and the T4 passes suite covers it against Blender.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import struct
|
|
19
|
+
|
|
20
|
+
_MAGIC = b"v/1\x01"
|
|
21
|
+
|
|
22
|
+
_NO_COMPRESSION = 0
|
|
23
|
+
|
|
24
|
+
_FLOAT = 2
|
|
25
|
+
_HALF = 1
|
|
26
|
+
|
|
27
|
+
_TYPE_SIZES = {_FLOAT: 4, _HALF: 2}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExrError(ValueError):
|
|
31
|
+
"""The file is not the scanline EXR shape this reader supports."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_cstring(blob, pos):
|
|
35
|
+
end = blob.index(b"\x00", pos)
|
|
36
|
+
return blob[pos:end].decode("utf-8"), end + 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_header(blob):
|
|
40
|
+
if blob[:4] != _MAGIC:
|
|
41
|
+
raise ExrError("not an EXR file")
|
|
42
|
+
(version,) = struct.unpack("<I", blob[4:8])
|
|
43
|
+
if version & 0x200:
|
|
44
|
+
raise ExrError("tiled EXR is not supported")
|
|
45
|
+
if version & 0x800:
|
|
46
|
+
raise ExrError("multipart EXR is not supported")
|
|
47
|
+
if version & 0x400:
|
|
48
|
+
raise ExrError("long-name EXR is not supported")
|
|
49
|
+
pos = 8
|
|
50
|
+
attrs = {}
|
|
51
|
+
while True:
|
|
52
|
+
name, pos = _read_cstring(blob, pos)
|
|
53
|
+
if name == "":
|
|
54
|
+
break
|
|
55
|
+
_type, pos = _read_cstring(blob, pos)
|
|
56
|
+
(size,) = struct.unpack("<i", blob[pos : pos + 4])
|
|
57
|
+
pos += 4
|
|
58
|
+
attrs[name] = blob[pos : pos + size]
|
|
59
|
+
pos += size
|
|
60
|
+
return attrs, pos
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_channels(raw):
|
|
64
|
+
channels = []
|
|
65
|
+
pos = 0
|
|
66
|
+
while pos < len(raw):
|
|
67
|
+
name, pos = _read_cstring(raw, pos)
|
|
68
|
+
if name == "":
|
|
69
|
+
break
|
|
70
|
+
pixel_type = struct.unpack("<i", raw[pos : pos + 4])[0]
|
|
71
|
+
x_sampling = struct.unpack("<i", raw[pos + 8 : pos + 12])[0]
|
|
72
|
+
y_sampling = struct.unpack("<i", raw[pos + 12 : pos + 16])[0]
|
|
73
|
+
pos += 16
|
|
74
|
+
channels.append((name, pixel_type, x_sampling, y_sampling))
|
|
75
|
+
return channels
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_exr_rgba(path):
|
|
79
|
+
"""Read R, G, B channels as float lists in row-major order.
|
|
80
|
+
|
|
81
|
+
Returns `(width, height, r, g, b)`. Channel lookup is by suffix: the
|
|
82
|
+
compositor writes `*.R`, `*.G`, `*.B` (multilayer prefixes the socket
|
|
83
|
+
layer, e.g. `Image.R`). Raises `ExrError` on any other shape.
|
|
84
|
+
"""
|
|
85
|
+
with open(path, "rb") as handle:
|
|
86
|
+
blob = handle.read()
|
|
87
|
+
attrs, pos = _parse_header(blob)
|
|
88
|
+
(compression,) = struct.unpack("B", attrs["compression"][:1])
|
|
89
|
+
if compression != _NO_COMPRESSION:
|
|
90
|
+
raise ExrError("compressed EXR (codec %d) is not supported" % (compression,))
|
|
91
|
+
(x0, y0, x1, y1) = struct.unpack("<iiii", attrs["dataWindow"])
|
|
92
|
+
width, height = x1 - x0 + 1, y1 - y0 + 1
|
|
93
|
+
channels = _parse_header_channels(attrs)
|
|
94
|
+
if any(s != (1, 1) for _, _, s in channels):
|
|
95
|
+
raise ExrError("subsampled EXR channels are not supported")
|
|
96
|
+
for _, pixel_type, _ in channels:
|
|
97
|
+
if pixel_type not in _TYPE_SIZES:
|
|
98
|
+
raise ExrError("non-float EXR channel is not supported")
|
|
99
|
+
|
|
100
|
+
offsets = struct.unpack("<%dq" % (height,), blob[pos : pos + 8 * height])
|
|
101
|
+
found = {}
|
|
102
|
+
for row, offset in enumerate(offsets):
|
|
103
|
+
(y,) = struct.unpack("<i", blob[offset : offset + 4])
|
|
104
|
+
if y != y0 + row:
|
|
105
|
+
raise ExrError("scanline %d out of order" % (row,))
|
|
106
|
+
(size,) = struct.unpack("<i", blob[offset + 4 : offset + 8])
|
|
107
|
+
cursor = offset + 8
|
|
108
|
+
for name, pixel_type, _ in channels:
|
|
109
|
+
count = width * _TYPE_SIZES[pixel_type]
|
|
110
|
+
chunk = blob[cursor : cursor + count]
|
|
111
|
+
if len(chunk) != count:
|
|
112
|
+
raise ExrError("scanline %d is truncated" % (row,))
|
|
113
|
+
cursor += count
|
|
114
|
+
values = _decode(chunk, pixel_type)
|
|
115
|
+
found.setdefault(name, []).extend(values)
|
|
116
|
+
if cursor != offset + 8 + size:
|
|
117
|
+
raise ExrError("scanline %d size mismatch" % (row,))
|
|
118
|
+
|
|
119
|
+
def channel(suffix):
|
|
120
|
+
for name, values in found.items():
|
|
121
|
+
if name == suffix or name.endswith("." + suffix):
|
|
122
|
+
return values
|
|
123
|
+
raise ExrError("EXR has no .%s channel" % (suffix,))
|
|
124
|
+
|
|
125
|
+
return width, height, channel("R"), channel("G"), channel("B")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_header_channels(attrs):
|
|
129
|
+
raw = attrs.get("channels")
|
|
130
|
+
if raw is None:
|
|
131
|
+
raise ExrError("EXR has no channel list")
|
|
132
|
+
channels = [
|
|
133
|
+
(name, pixel_type, (xs, ys))
|
|
134
|
+
for name, pixel_type, xs, ys in _parse_channels(raw)
|
|
135
|
+
]
|
|
136
|
+
if not channels:
|
|
137
|
+
raise ExrError("EXR has no channels")
|
|
138
|
+
return channels
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_color_channel(name):
|
|
142
|
+
return name in ("R", "G", "B") or name.endswith((".R", ".G", ".B"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rewrite_background_to_inf(path, foreground):
|
|
146
|
+
"""Set every non-foreground sample of a staged depth EXR to `+inf`.
|
|
147
|
+
|
|
148
|
+
The compositor stages the raw Z pass, whose off-geometry pixels carry
|
|
149
|
+
the no-hit sentinel (`1e10`, measured on Blender 5.2.1 on both EEVEE
|
|
150
|
+
and Cycles) instead of the `+inf` the `render_passes` contract
|
|
151
|
+
promises for `depth_format: "exr"`. `foreground` is the row-major
|
|
152
|
+
mask the op resolved from this same file (`True` is foreground):
|
|
153
|
+
every other sample becomes `+inf` in each R/G/B-suffixed float
|
|
154
|
+
channel, foreground samples are untouched. Refuses files outside
|
|
155
|
+
the shape `read_exr_rgba` supports.
|
|
156
|
+
"""
|
|
157
|
+
with open(path, "rb") as handle:
|
|
158
|
+
blob = bytearray(handle.read())
|
|
159
|
+
attrs, pos = _parse_header(blob)
|
|
160
|
+
(compression,) = struct.unpack("B", attrs["compression"][:1])
|
|
161
|
+
if compression != _NO_COMPRESSION:
|
|
162
|
+
raise ExrError("compressed EXR (codec %d) is not supported" % (compression,))
|
|
163
|
+
(x0, y0, x1, y1) = struct.unpack("<iiii", attrs["dataWindow"])
|
|
164
|
+
width, height = x1 - x0 + 1, y1 - y0 + 1
|
|
165
|
+
if len(foreground) != width * height:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"foreground mask %d does not fill %dx%d"
|
|
168
|
+
% (len(foreground), width, height)
|
|
169
|
+
)
|
|
170
|
+
channels = _parse_header_channels(attrs)
|
|
171
|
+
if any(s != (1, 1) for _, _, s in channels):
|
|
172
|
+
raise ExrError("subsampled EXR channels are not supported")
|
|
173
|
+
for _, pixel_type, _ in channels:
|
|
174
|
+
if pixel_type not in _TYPE_SIZES:
|
|
175
|
+
raise ExrError("non-float EXR channel is not supported")
|
|
176
|
+
|
|
177
|
+
offsets = struct.unpack("<%dq" % (height,), blob[pos : pos + 8 * height])
|
|
178
|
+
for row, offset in enumerate(offsets):
|
|
179
|
+
(y,) = struct.unpack("<i", blob[offset : offset + 4])
|
|
180
|
+
if y != y0 + row:
|
|
181
|
+
raise ExrError("scanline %d out of order" % (row,))
|
|
182
|
+
(size,) = struct.unpack("<i", blob[offset + 4 : offset + 8])
|
|
183
|
+
cursor = offset + 8
|
|
184
|
+
for name, pixel_type, _ in channels:
|
|
185
|
+
sample = _TYPE_SIZES[pixel_type]
|
|
186
|
+
if _is_color_channel(name):
|
|
187
|
+
if pixel_type == _FLOAT:
|
|
188
|
+
inf = struct.pack("<f", float("inf"))
|
|
189
|
+
else:
|
|
190
|
+
inf = struct.pack("<e", float("inf"))
|
|
191
|
+
for col in range(width):
|
|
192
|
+
if not foreground[row * width + col]:
|
|
193
|
+
start = cursor + col * sample
|
|
194
|
+
blob[start : start + sample] = inf
|
|
195
|
+
cursor += width * sample
|
|
196
|
+
if cursor != offset + 8 + size:
|
|
197
|
+
raise ExrError("scanline %d size mismatch" % (row,))
|
|
198
|
+
with open(path, "wb") as handle:
|
|
199
|
+
handle.write(bytes(blob))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _decode(chunk, pixel_type):
|
|
203
|
+
if pixel_type == _FLOAT:
|
|
204
|
+
return list(struct.unpack("<%df" % (len(chunk) // 4,), chunk))
|
|
205
|
+
return list(struct.unpack("<%de" % (len(chunk) // 2,), chunk))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Auto-framing math for the Blender orbit camera.
|
|
2
|
+
|
|
3
|
+
A line-for-line port of `computeFraming` and `orbitOffset` from
|
|
4
|
+
`packages/video-nodes/src/nodes/model3d/render3d-core.ts`: same math, same
|
|
5
|
+
names (snake_cased). The two sides are pinned to the same golden camera
|
|
6
|
+
position by `tests/framing.test.ts` (TypeScript) and
|
|
7
|
+
`blender_ops/tests/test_framing.py` (this interpreter).
|
|
8
|
+
|
|
9
|
+
The offsets are in the three.js convention (Y up). `ops/render_image.py`
|
|
10
|
+
rotates them rigidly into Blender space when placing the camera; the
|
|
11
|
+
functions here stay identical so the cross-language pin holds.
|
|
12
|
+
|
|
13
|
+
`apply_camera_lens` is the one function here that is not part of that port:
|
|
14
|
+
it writes the framing onto a camera datablock, and lives beside the math so
|
|
15
|
+
that what `fov` means (the vertical axis) is decided in one place. It touches
|
|
16
|
+
no bpy API, so `tests/test_camera_lens.py` exercises it without Blender.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_framing(radius, fov_deg, aspect, zoom):
|
|
23
|
+
"""Distance fitting a bounding sphere of `radius` into the frame.
|
|
24
|
+
|
|
25
|
+
`zoom` > 1 moves closer. Returns a dict with `distance`, `near`, `far`.
|
|
26
|
+
"""
|
|
27
|
+
safe_radius = max(radius, 1e-6)
|
|
28
|
+
v_fov = (max(fov_deg, 1) * math.pi) / 180
|
|
29
|
+
h_fov = 2 * math.atan(math.tan(v_fov / 2) * max(aspect, 1e-6))
|
|
30
|
+
fit_v = safe_radius / math.sin(v_fov / 2)
|
|
31
|
+
fit_h = safe_radius / math.sin(h_fov / 2)
|
|
32
|
+
distance = max(fit_v, fit_h) / max(zoom, 1e-3)
|
|
33
|
+
return {
|
|
34
|
+
"distance": distance,
|
|
35
|
+
"near": max(distance - safe_radius * 4, distance / 100, 1e-4),
|
|
36
|
+
"far": distance + safe_radius * 10,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def orbit_offset(azimuth_deg, elevation_deg, distance):
|
|
41
|
+
"""Spherical-to-cartesian offset for the orbit camera (Y up)."""
|
|
42
|
+
az = (azimuth_deg * math.pi) / 180
|
|
43
|
+
el = (min(max(elevation_deg, -89.9), 89.9) * math.pi) / 180
|
|
44
|
+
return {
|
|
45
|
+
"x": distance * math.cos(el) * math.sin(az),
|
|
46
|
+
"y": distance * math.sin(el),
|
|
47
|
+
"z": distance * math.cos(el) * math.cos(az),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def apply_camera_lens(data, fov_deg, framing):
|
|
52
|
+
"""Give a camera datablock the vertical fov `fov_deg` and `framing`'s clips.
|
|
53
|
+
|
|
54
|
+
`fov` means the *vertical* field of view on both sides of this stack:
|
|
55
|
+
`compute_framing` fits the bounding sphere against `v_fov`, and the
|
|
56
|
+
three.js preview sets `PerspectiveCamera.fov`, which is vertical too. So
|
|
57
|
+
the fit is `VERTICAL` and the angle is written through `angle_y`, which
|
|
58
|
+
always resolves against `sensor_height`.
|
|
59
|
+
|
|
60
|
+
Bare `angle` would not: it resolves against whichever axis `sensor_fit`
|
|
61
|
+
names, so writing it before the fit is set means the 36mm sensor *width*
|
|
62
|
+
and leaves a 35 degree request as a 23.7 degree camera — a 1.52x tighter
|
|
63
|
+
crop that no longer fits the sphere `compute_framing` measured. Two
|
|
64
|
+
callers wrote `angle` in opposite orders and rendered the same model at
|
|
65
|
+
two sizes; `angle_y` makes the order irrelevant.
|
|
66
|
+
|
|
67
|
+
Takes the datablock instead of importing bpy, so the rule is testable
|
|
68
|
+
outside Blender (`tests/test_camera_lens.py`).
|
|
69
|
+
"""
|
|
70
|
+
data.sensor_fit = "VERTICAL"
|
|
71
|
+
# The same clamp `compute_framing` applies, so the lens and the distance
|
|
72
|
+
# it computed always describe one camera.
|
|
73
|
+
data.angle_y = math.radians(max(fov_deg, 1))
|
|
74
|
+
data.clip_start = framing["near"]
|
|
75
|
+
data.clip_end = framing["far"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Blender op modules. Stage 3 adds `prepare_for_engine` and `export_model`.
|
|
2
|
+
|
|
3
|
+
Each op exposes `run(job, workdir) -> (produced, stats)` where `job` is the
|
|
4
|
+
parsed `job.json` dict, `workdir` the scratch directory holding the inputs,
|
|
5
|
+
`produced` the logical output names written, and `stats` the fields for
|
|
6
|
+
`result.json`. An op raises `BlenderOpError` (or a subclass) on failure;
|
|
7
|
+
`run_job.py` turns it into `ok: false` with the code from the class.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ops.export_model import run as export_model
|
|
11
|
+
from ops.prepare_for_engine import run as prepare_for_engine
|
|
12
|
+
from ops.render_animation import run as render_animation
|
|
13
|
+
from ops.render_image import run as render_image
|
|
14
|
+
from ops.render_passes import run as render_passes
|
|
15
|
+
|
|
16
|
+
DISPATCH = {
|
|
17
|
+
"render_image": render_image,
|
|
18
|
+
"render_passes": render_passes,
|
|
19
|
+
"render_animation": render_animation,
|
|
20
|
+
"prepare_for_engine": prepare_for_engine,
|
|
21
|
+
"export_model": export_model,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
__all__ = ["DISPATCH"]
|